1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2018 The gPodder Team
6 # gPodder is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
11 # gPodder is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 # Soundcloud.com API client module for gPodder
21 # Thomas Perl <thp@gpodder.org>; 2009-11-03
33 from gpodder
import feedcore
, model
, registry
, util
38 # gPodder's consumer key for the Soundcloud API
39 CONSUMER_KEY
= 'zrweghtEtnZLpXf3mlm8mQ'
42 logger
= logging
.getLogger(__name__
)
45 def soundcloud_parsedate(s
):
46 """Parse a string into a unix timestamp
48 Only strings provided by Soundcloud's API are
49 parsed with this function (2009/11/03 13:37:00).
51 m
= re
.match(r
'(\d{4})/(\d{2})/(\d{2}) (\d{2}):(\d{2}):(\d{2})', s
)
52 return time
.mktime(tuple([int(x
) for x
in m
.groups()] + [0, 0, -1]))
55 def get_metadata(url
):
56 """Get file download metadata
58 Returns a (size, type, name) from the given download
59 URL. Will use the network connection to determine the
60 metadata via the HTTP header fields.
62 track_response
= util
.urlopen(url
)
63 filesize
= track_response
.headers
['content-length'] or '0'
64 filetype
= track_response
.headers
['content-type'] or 'application/octet-stream'
65 filename
= util
.get_header_param(track_response
.headers
, 'filename', 'content-disposition') \
66 or os
.path
.basename(os
.path
.dirname(url
))
67 track_response
.close()
68 return filesize
, filetype
, filename
71 class SoundcloudUser(object):
72 def __init__(self
, username
):
73 self
.username
= username
74 self
.cache_file
= os
.path
.join(gpodder
.home
, 'Soundcloud')
75 if os
.path
.exists(self
.cache_file
):
77 self
.cache
= json
.load(open(self
.cache_file
, 'r'))
83 def commit_cache(self
):
84 json
.dump(self
.cache
, open(self
.cache_file
, 'w'))
86 def get_user_info(self
):
88 key
= ':'.join((self
.username
, 'user_info'))
90 if self
.cache
[key
].get('code', 200) == 200:
91 return self
.cache
[key
]
94 # find user ID in soundcloud page
95 url
= 'https://soundcloud.com/' + self
.username
98 raise Exception('Soundcloud "%s": %d %s' % (url
, r
.status_code
, r
.reason
))
99 uid
= re
.search(r
'"https://api.soundcloud.com/users/([0-9]+)"', r
.text
)
101 raise Exception('Soundcloud user ID not found for "%s"' % url
)
102 uid
= int(uid
.group(1))
105 json_url
= 'https://api.soundcloud.com/users/%d.json?consumer_key=%s' % (uid
, CONSUMER_KEY
)
106 r
= util
.urlopen(json_url
)
108 raise Exception('Soundcloud "%s": %d %s' % (json_url
, r
.status_code
, r
.reason
))
109 user_info
= json
.loads(r
.text
)
110 if user_info
.get('code', 200) != 200:
111 raise Exception('Soundcloud "%s": %s' % (json_url
, user_info
.get('message', '')))
113 self
.cache
[key
] = user_info
119 def get_coverart(self
):
120 user_info
= self
.get_user_info()
121 return user_info
.get('avatar_url', None)
123 def get_user_id(self
):
124 user_info
= self
.get_user_info()
125 return user_info
.get('id', None)
127 def get_tracks(self
, feed
):
128 """Get a generator of tracks from a SC user
130 The generator will give you a dictionary for every
131 track it can find for its user."""
134 json_url
= ('https://api.soundcloud.com/users/%(user)s/%(feed)s.'
135 'json?consumer_key=%'
136 '(consumer_key)s&limit=200'
137 % {"user": self
.get_user_id(),
139 "consumer_key": CONSUMER_KEY
})
140 logger
.debug("loading %s", json_url
)
142 json_tracks
= util
.urlopen(json_url
).json()
143 tracks
= [track
for track
in json_tracks
if track
['streamable'] or track
['downloadable']]
144 total_count
= len(json_tracks
)
146 if len(tracks
) == 0 and total_count
> 0:
147 logger
.warning("Download of all %i %s of user %s is disabled" %
148 (total_count
, feed
, self
.username
))
150 logger
.info("%i/%i downloadable tracks for user %s %s feed" %
151 (len(tracks
), total_count
, self
.username
, feed
))
154 # Prefer stream URL (MP3), fallback to download URL
155 base_url
= track
.get('stream_url') if track
['streamable'] else track
['download_url']
156 url
= base_url
+ '?consumer_key=' + CONSUMER_KEY
157 if url
not in self
.cache
:
159 self
.cache
[url
] = get_metadata(url
)
162 filesize
, filetype
, filename
= self
.cache
[url
]
165 'title': track
.get('title', track
.get('permalink')) or _('Unknown track'),
166 'link': track
.get('permalink_url') or 'https://soundcloud.com/' + self
.username
,
167 'description': util
.remove_html_tags(track
.get('description') or ''),
168 'description_html': '',
170 'file_size': int(filesize
),
171 'mime_type': filetype
,
172 'guid': str(track
.get('permalink', track
.get('id'))),
173 'published': soundcloud_parsedate(track
.get('created_at', None)),
179 class SoundcloudFeed(model
.Feed
):
180 URL_REGEX
= re
.compile(r
'https?://([a-z]+\.)?soundcloud\.com/([^/]+)$', re
.I
)
183 def fetch_channel(cls
, channel
, max_episodes
=0):
184 url
= channel
.authenticate_url(channel
.url
)
185 return cls
.handle_url(url
, max_episodes
)
188 def handle_url(cls
, url
, max_episodes
):
189 m
= cls
.URL_REGEX
.match(url
)
191 subdomain
, username
= m
.groups()
192 return feedcore
.Result(feedcore
.UPDATED_FEED
, cls(username
, max_episodes
))
194 def __init__(self
, username
, max_episodes
):
195 self
.username
= username
196 self
.sc_user
= SoundcloudUser(username
)
197 self
.max_episodes
= max_episodes
200 return _('%s on Soundcloud') % self
.username
202 def get_cover_url(self
):
203 return self
.sc_user
.get_coverart()
206 return 'https://soundcloud.com/%s' % self
.username
208 def get_description(self
):
209 return _('Tracks published by %s on Soundcloud.') % self
.username
211 def get_new_episodes(self
, channel
, existing_guids
):
212 return self
._get
_new
_episodes
(channel
, existing_guids
, 'tracks')
214 def get_next_page(self
, channel
, max_episodes
=0):
215 # one could return more, but it would consume too many api calls
219 def _get_new_episodes(self
, channel
, existing_guids
, track_type
):
220 tracks
= list(self
.sc_user
.get_tracks(track_type
))
221 if self
.max_episodes
> 0:
222 tracks
= tracks
[:self
.max_episodes
]
224 seen_guids
= {track
['guid'] for track
in tracks
}
228 if track
['guid'] not in existing_guids
:
229 episode
= channel
.episode_factory(track
)
231 episodes
.append(episode
)
233 return episodes
, seen_guids
236 class SoundcloudFavFeed(SoundcloudFeed
):
237 URL_REGEX
= re
.compile(r
'https?://([a-z]+\.)?soundcloud\.com/([^/]+)/favorites', re
.I
)
239 def __init__(self
, username
):
240 super(SoundcloudFavFeed
, self
).__init
__(username
)
243 return _("%s's favorites on Soundcloud") % self
.username
246 return 'https://soundcloud.com/%s/favorites' % self
.username
248 def get_description(self
):
249 return _('Tracks favorited by %s on Soundcloud.') % self
.username
251 def get_new_episodes(self
, channel
, existing_guids
):
252 return self
._get
_new
_episodes
(channel
, existing_guids
, 'favorites')
255 # Register our URL handlers
256 registry
.feed_handler
.register(SoundcloudFeed
.fetch_channel
)
257 registry
.feed_handler
.register(SoundcloudFavFeed
.fetch_channel
)
260 def search_for_user(query
):
261 json_url
= 'https://api.soundcloud.com/users.json?q=%s&consumer_key=%s' % (urllib
.parse
.quote(query
), CONSUMER_KEY
)
262 return util
.urlopen(json_url
).json()