Skip to content

Commit fd10020

Browse files
committedMay 20, 2015
YouTube: Support V3 API via user-supplied key (bug 1999)
This adds auto-discovery of the channel ID and new-style feed for old-style (username-based) feed URLs when the V3 API key is available, and also adds an extra menu item for migrating subscriptions.
1 parent 6714384 commit fd10020

File tree

6 files changed

+135
-5
lines changed

6 files changed

+135
-5
lines changed
 

‎share/gpodder/ui/gtk/gpodder.ui

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,13 @@
233233
</object>
234234
<accelerator key="S" modifiers="GDK_CONTROL_MASK"/>
235235
</child>
236+
<child>
237+
<object class="GtkAction" id="item_update_youtube_subscriptions">
238+
<property name="name">item_update_youtube_subscriptions</property>
239+
<property name="label" translatable="yes">Update YouTube subscriptions</property>
240+
<signal handler="on_update_youtube_subscriptions_activate" name="activate"/>
241+
</object>
242+
</child>
236243
<child>
237244
<object class="GtkAction" id="menuView">
238245
<property name="name">menuView</property>
@@ -367,6 +374,7 @@
367374
</menu>
368375
<menu action="menuExtras">
369376
<menuitem action="item_sync"/>
377+
<menuitem action="item_update_youtube_subscriptions"/>
370378
</menu>
371379
<menu action="menuView">
372380
<menuitem action="itemShowToolbar"/>

‎share/gpodder/ui/gtk/gpodderpreferences.ui

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
<object class="GtkTable" id="table_players">
5353
<property name="column_spacing">6</property>
5454
<property name="n_columns">3</property>
55-
<property name="n_rows">4</property>
55+
<property name="n_rows">5</property>
5656
<property name="row_spacing">6</property>
5757
<property name="visible">True</property>
5858
<child>
@@ -162,15 +162,62 @@
162162
</packing>
163163
</child>
164164
<child>
165-
<object class="GtkLabel" id="label_preferred_vimeo_format">
165+
<object class="GtkLabel" id="label_youtube_api_key">
166166
<property name="visible">True</property>
167167
<property name="can_focus">False</property>
168168
<property name="xalign">0</property>
169-
<property name="label" translatable="yes">Preferred Vimeo format:</property>
169+
<property name="label" translatable="yes">YouTube API key (v3):</property>
170+
</object>
171+
<packing>
172+
<property name="left_attach">0</property>
173+
<property name="right_attach">1</property>
174+
<property name="top_attach">3</property>
175+
<property name="bottom_attach">4</property>
176+
<property name="x_options">fill</property>
177+
</packing>
178+
</child>
179+
<child>
180+
<object class="GtkEntry" id="entry_youtube_api_key">
181+
<property name="visible">True</property>
182+
<signal handler="on_youtube_api_key_changed" name="changed"/>
183+
</object>
184+
<packing>
185+
<property name="left_attach">1</property>
186+
<property name="right_attach">2</property>
187+
<property name="top_attach">3</property>
188+
<property name="bottom_attach">4</property>
189+
<property name="x_options">fill</property>
190+
</packing>
191+
</child>
192+
<child>
193+
<object class="GtkButton" id="button_youtube_api_key">
194+
<property name="visible">True</property>
195+
<signal name="clicked" handler="on_button_youtube_api_key_clicked"/>
196+
<child>
197+
<object class="GtkImage" id="image_youtube_api_key">
198+
<property name="stock">gtk-jump-to</property>
199+
<property name="visible">True</property>
200+
</object>
201+
</child>
170202
</object>
171203
<packing>
172204
<property name="top_attach">3</property>
173205
<property name="bottom_attach">4</property>
206+
<property name="left_attach">2</property>
207+
<property name="right_attach">3</property>
208+
<property name="x_options">fill</property>
209+
</packing>
210+
</child>
211+
<child>
212+
<object class="GtkLabel" id="label_preferred_vimeo_format">
213+
<property name="visible">True</property>
214+
<property name="can_focus">False</property>
215+
<property name="xalign">0</property>
216+
<property name="label" translatable="yes">Preferred Vimeo format:</property>
217+
</object>
218+
<packing>
219+
<property name="top_attach">4</property>
220+
<property name="bottom_attach">5</property>
174221
<property name="x_options">fill</property>
175222
</packing>
176223
</child>
@@ -183,8 +230,8 @@
183230
<packing>
184231
<property name="left_attach">1</property>
185232
<property name="right_attach">2</property>
186-
<property name="top_attach">3</property>
187-
<property name="bottom_attach">4</property>
233+
<property name="top_attach">4</property>
234+
<property name="bottom_attach">5</property>
188235
</packing>
189236
</child>
190237
</object>

‎src/gpodder/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@
191191
'youtube': {
192192
'preferred_fmt_id': 18, # default fmt_id (see fallbacks in youtube.py)
193193
'preferred_fmt_ids': [], # for advanced uses (custom fallback sequence)
194+
'api_key_v3': '', # API key, register for one at https://developers.google.com/youtube/v3/
194195
},
195196

196197
'vimeo': {

‎src/gpodder/gtkui/desktop/preferences.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@ def new(self):
314314
self.entry_username.set_text(self._config.mygpo.username)
315315
self.entry_password.set_text(self._config.mygpo.password)
316316
self.entry_caption.set_text(self._config.mygpo.device.caption)
317+
self.entry_youtube_api_key.set_text(self._config.youtube.api_key_v3)
317318

318319
# Disable mygpo sync while the dialog is open
319320
self._config.mygpo.enabled = False
@@ -596,6 +597,12 @@ def on_enabled_toggled(self, widget):
596597
# Only update indirectly (see on_dialog_destroy)
597598
self._enable_mygpo = widget.get_active()
598599

600+
def on_youtube_api_key_changed(self, widget):
601+
self._config.youtube.api_key_v3 = widget.get_text()
602+
603+
def on_button_youtube_api_key_clicked(self, widget):
604+
util.open_website('https://developers.google.com/youtube/v3/')
605+
599606
def on_username_changed(self, widget):
600607
self._config.mygpo.username = widget.get_text()
601608

‎src/gpodder/gtkui/main.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2177,6 +2177,17 @@ def add_podcast_list(self, podcasts, auth_tokens=None):
21772177
queued, failed, existing, worked, authreq = [], [], [], [], []
21782178
for input_title, input_url in podcasts:
21792179
url = util.normalize_feed_url(input_url)
2180+
2181+
# Check if it's a YouTube feed, and if we have an API key, auto-resolve the channel
2182+
if url is not None and self.config.youtube.api_key_v3:
2183+
xurl, xuser = youtube.for_each_feed_pattern(lambda url, channel: (url, channel), url, (None, None))
2184+
if xurl is not None and xuser is not None:
2185+
logger.info('Getting channels for YouTube user %s (%s)', xuser, xurl)
2186+
new_urls = youtube.get_channels_for_user(xuser, self.config.youtube.api_key_v3)
2187+
logger.debug('YouTube channels retrieved: %r', new_urls)
2188+
if len(new_urls) == 1:
2189+
url = new_urls[0]
2190+
21802191
if url is None:
21812192
# Fail this one because the URL is not valid
21822193
failed.append(input_url)
@@ -3484,6 +3495,52 @@ def on_sync_to_device_activate(self, widget, episodes=None, force_played=True):
34843495

34853496
self.sync_ui.on_synchronize_episodes(self.channels, episodes, force_played)
34863497

3498+
def on_update_youtube_subscriptions_activate(self, widget):
3499+
if not self.config.youtube.api_key_v3:
3500+
if self.show_confirmation('\n'.join((_('Please register a YouTube API key and set it in the preferences.'),
3501+
_('Would you like to set up an API key now?'))), _('API key required')):
3502+
self.on_itemPreferences_activate(self, widget)
3503+
return
3504+
3505+
failed_urls = []
3506+
migrated_users = []
3507+
for podcast in self.channels:
3508+
url, user = youtube.for_each_feed_pattern(lambda url, channel: (url, channel), podcast.url, (None, None))
3509+
if url is not None and user is not None:
3510+
try:
3511+
logger.info('Getting channels for YouTube user %s (%s)', user, url)
3512+
new_urls = youtube.get_channels_for_user(user, self.config.youtube.api_key_v3)
3513+
logger.debug('YouTube channels retrieved: %r', new_urls)
3514+
3515+
if len(new_urls) != 1:
3516+
failed_urls.append(url, _('No unique URL found'))
3517+
continue
3518+
3519+
new_url = new_urls[0]
3520+
if new_url in set(x.url for x in self.model.get_podcasts()):
3521+
failed_urls.append((url, _('Already subscribed')))
3522+
continue
3523+
3524+
logger.info('New feed location: %s => %s', url, new_url)
3525+
podcast.url = new_url
3526+
podcast.save()
3527+
migrated_users.append(user)
3528+
except Exception as e:
3529+
logger.error('Exception happened while updating download list.', exc_info=True)
3530+
self.show_message(_('Make sure the API key is correct. Error: %(message)s') % {'message': str(e)},
3531+
_('Error getting YouTube channels'), important=True)
3532+
3533+
if migrated_users:
3534+
self.show_message('\n'.join(migrated_users), _('Successfully migrated subscriptions'))
3535+
elif not failed_urls:
3536+
self.show_message(_('Subscriptions are up to date'))
3537+
3538+
if failed_urls:
3539+
self.show_message('\n'.join([_('These URLs failed:'), ''] + ['{}: {}'.format(url, message)
3540+
for url, message in failed_urls]),
3541+
_('Could not migrate some subscriptions'), important=True)
3542+
3543+
34873544
def main(options=None):
34883545
gobject.threads_init()
34893546
gobject.set_application_name('gPodder')

‎src/gpodder/youtube.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@
7878
]
7979
formats_dict = dict(formats)
8080

81+
V3_API_ENDPOINT = 'https://www.googleapis.com/youtube/v3'
82+
CHANNEL_VIDEOS_XML = 'https://www.youtube.com/feeds/videos.xml'
83+
84+
8185
class YouTubeError(Exception): pass
8286

8387

@@ -188,6 +192,7 @@ def for_each_feed_pattern(func, url, fallback_result):
188192
'http[s]?://(?:[a-z]+\.)?youtube\.com/channel/([_a-z0-9]+)',
189193
'http[s]?://(?:[a-z]+\.)?youtube\.com/rss/user/([a-z0-9]+)/videos\.rss',
190194
'http[s]?://gdata.youtube.com/feeds/users/([^/]+)/uploads',
195+
'http[s]?://(?:[a-z]+\.)?youtube\.com/feeds/videos.xml?channel_id=([a-z0-9]+)',
191196
]
192197

193198
for pattern in CHANNEL_MATCH_PATTERNS:
@@ -219,3 +224,8 @@ def return_user_cover(url, channel):
219224
return None
220225

221226
return for_each_feed_pattern(return_user_cover, url, None)
227+
228+
def get_channels_for_user(username, api_key_v3):
229+
stream = util.urlopen('{}/channels?forUsername={}&part=id&key={}'.format(V3_API_ENDPOINT, username, api_key_v3))
230+
data = json.load(stream)
231+
return ['{}?channel_id={}'.format(CHANNEL_VIDEOS_XML, item['id']) for item in data['items']]

0 commit comments

Comments
 (0)