1 # -*- coding: utf-8 -*-
2 # Manage YouTube subscriptions using youtube-dl (https://github.com/ytdl-org/youtube-dl)
3 # Requirements: youtube-dl module (pip install youtube_dl)
4 # (c) 2019-08-17 Eric Le Lay <elelay.fr:contact>
5 # Released under the same license terms as gPodder itself.
12 from collections
.abc
import Iterable
15 import yt_dlp
as youtube_dl
16 program_name
= 'yt-dlp'
17 want_ytdl_version
= '2023.06.22'
20 program_name
= 'youtube-dl'
21 want_ytdl_version
= '2023.02.17' # youtube-dl has been patched, but not yet released
24 from gpodder
import download
, feedcore
, model
, registry
, util
, youtube
26 import gi
# isort:skip
27 gi
.require_version('Gtk', '3.0') # isort:skip
28 from gi
.repository
import Gtk
# isort:skip
33 logger
= logging
.getLogger(__name__
)
36 __title__
= 'youtube-dl'
37 __description__
= _('Manage YouTube subscriptions using youtube-dl (pip install youtube_dl) or yt-dlp (pip install yt-dlp)')
38 __only_for__
= 'gtk, cli'
39 __authors__
= 'Eric Le Lay <elelay.fr:contact>'
40 __doc__
= 'https://gpodder.github.io/docs/extensions/youtubedl.html'
42 want_ytdl_version_msg
= _('Your version of youtube-dl/yt-dlp %(have_version)s has known issues, please upgrade to %(want_version)s or newer.')
45 # youtube-dl downloads and parses each video page to get information about it, which is very slow.
46 # Set to False to fall back to the fast but limited (only 15 episodes) gpodder code
47 'manage_channel': True,
48 # If for some reason youtube-dl download doesn't work for you, you can fallback to gpodder code.
49 # Set to False to fall back to default gpodder code (less available formats).
50 'manage_downloads': True,
51 # Embed all available subtitles to downloaded videos. Needs ffmpeg.
52 'embed_subtitles': False,
56 # youtube feed still preprocessed by youtube.py (compat)
57 CHANNEL_RE
= re
.compile(r
'''https://www.youtube.com/feeds/videos.xml\?channel_id=(.+)''')
58 USER_RE
= re
.compile(r
'''https://www.youtube.com/feeds/videos.xml\?user=(.+)''')
59 PLAYLIST_RE
= re
.compile(r
'''https://www.youtube.com/feeds/videos.xml\?playlist_id=(.+)''')
62 def youtube_parsedate(s
):
63 """Parse a string into a unix timestamp
65 Only strings provided by youtube-dl API are
66 parsed with this function (20170920).
69 return time
.mktime(time
.strptime(s
, "%Y%m%d"))
73 def video_guid(video_id
):
75 generate same guid as youtube
77 return 'yt:video:{}'.format(video_id
)
80 class YoutubeCustomDownload(download
.CustomDownload
):
82 Represents the download of a single episode using youtube-dl.
84 Actual youtube-dl interaction via gPodderYoutubeDL.
86 def __init__(self
, ytdl
, url
, episode
):
89 self
._reporthook
= None
90 self
._prev
_dl
_bytes
= 0
91 self
._episode
= episode
92 self
._partial
_filename
= None
95 def partial_filename(self
):
96 return self
._partial
_filename
98 @partial_filename.setter
99 def partial_filename(self
, val
):
100 self
._partial
_filename
= val
102 def retrieve_resume(self
, tempname
, reporthook
=None):
104 called by download.DownloadTask to perform the download.
106 self
._reporthook
= reporthook
107 # outtmpl: use given tempname by DownloadTask
108 # (escape % because outtmpl used as a string template by youtube-dl)
109 outtmpl
= tempname
.replace('%', '%%')
110 info
, opts
= self
._ytdl
.fetch_info(self
._url
, outtmpl
, self
._my
_hook
)
111 if program_name
== 'yt-dlp':
112 default
= opts
['outtmpl']['default'] if type(opts
['outtmpl']) == dict else opts
['outtmpl']
113 self
.partial_filename
= os
.path
.join(opts
['paths']['home'], default
) % info
114 elif program_name
== 'youtube-dl':
115 self
.partial_filename
= opts
['outtmpl'] % info
117 res
= self
._ytdl
.fetch_video(info
, opts
)
118 if program_name
== 'yt-dlp':
119 # yt-dlp downloads to whatever file name it wants, so rename
120 filepath
= res
.get('requested_downloads', [{}])[0].get('filepath')
122 raise Exception("Could not determine youtube-dl output file")
123 if filepath
!= tempname
:
124 logger
.debug('yt-dlp downloaded to "%s" instead of "%s", moving',
125 os
.path
.basename(filepath
),
126 os
.path
.basename(tempname
))
128 os
.rename(filepath
, tempname
)
130 if 'duration' in res
and res
['duration']:
131 self
._episode
.total_time
= res
['duration']
133 # youtube-dl doesn't return a content-type but an extension
135 dot_ext
= '.{}'.format(res
['ext'])
136 if program_name
== 'youtube-dl':
137 # See #673 when merging multiple formats, the extension is appended to the tempname
138 # by youtube-dl resulting in empty .partial file + .partial.mp4 exists
139 # and #796 .mkv is chosen by ytdl sometimes
140 for try_ext
in (dot_ext
, ".mp4", ".m4a", ".webm", ".mkv"):
141 tempname_with_ext
= tempname
+ try_ext
142 if os
.path
.isfile(tempname_with_ext
):
143 logger
.debug('youtube-dl downloaded to "%s" instead of "%s", moving',
144 os
.path
.basename(tempname_with_ext
),
145 os
.path
.basename(tempname
))
147 os
.rename(tempname_with_ext
, tempname
)
151 ext_filetype
= util
.mimetype_from_extension(dot_ext
)
153 # YouTube weba formats have a webm extension and get a video/webm mime-type
154 # but audio content has no width or height, so change it to audio/webm for correct icon and player
155 if ext_filetype
.startswith('video/') and ('height' not in res
or res
['height'] is None):
156 ext_filetype
= ext_filetype
.replace('video/', 'audio/')
157 headers
['content-type'] = ext_filetype
158 return headers
, res
.get('url', self
._url
)
160 def _my_hook(self
, d
):
161 if d
['status'] == 'downloading':
163 dl_bytes
= d
['downloaded_bytes']
164 total_bytes
= d
.get('total_bytes') or d
.get('total_bytes_estimate') or 0
165 self
._reporthook
(self
._prev
_dl
_bytes
+ dl_bytes
,
167 self
._prev
_dl
_bytes
+ total_bytes
)
168 elif d
['status'] == 'finished':
169 dl_bytes
= d
['downloaded_bytes']
170 self
._prev
_dl
_bytes
+= dl_bytes
172 self
._reporthook
(self
._prev
_dl
_bytes
, 1, self
._prev
_dl
_bytes
)
173 elif d
['status'] == 'error':
174 logger
.error('download hook error: %r', d
)
176 logger
.debug('unknown download hook status: %r', d
)
179 class YoutubeFeed(model
.Feed
):
181 Represents the youtube feed for model.PodcastChannel
183 def __init__(self
, url
, cover_url
, description
, max_episodes
, ie_result
, downloader
):
185 self
._cover
_url
= cover_url
186 self
._description
= description
187 self
._max
_episodes
= max_episodes
188 ie_result
['entries'] = self
._process
_entries
(ie_result
.get('entries', []))
189 self
._ie
_result
= ie_result
190 self
._downloader
= downloader
192 def _process_entries(self
, entries
):
193 filtered_entries
= []
195 for i
, e
in enumerate(entries
): # consumes the generator!
196 if e
.get('_type', 'video') in ('url', 'url_transparent') and e
.get('ie_key') == 'Youtube':
197 guid
= video_guid(e
['id'])
199 if guid
in seen_guids
:
200 logger
.debug('dropping already seen entry %s title="%s"', guid
, e
.get('title'))
202 filtered_entries
.append(e
)
205 logger
.debug('dropping entry not youtube video %r', e
)
206 if len(filtered_entries
) == self
._max
_episodes
:
207 # entries is a generator: stopping now prevents it to download more pages
208 logger
.debug('stopping entry enumeration')
210 return filtered_entries
213 return '{} (YouTube)'.format(self
._ie
_result
.get('title') or self
._ie
_result
.get('id') or self
._url
)
216 return self
._ie
_result
.get('webpage_url')
218 def get_description(self
):
219 return self
._description
221 def get_cover_url(self
):
222 return self
._cover
_url
224 def get_http_etag(self
):
225 """ :return str: optional -- last HTTP etag header, for conditional request next time """
226 # youtube-dl doesn't provide it!
229 def get_http_last_modified(self
):
230 """ :return str: optional -- last HTTP Last-Modified header, for conditional request next time """
231 # youtube-dl doesn't provide it!
234 def get_new_episodes(self
, channel
, existing_guids
):
235 # entries are already sorted by decreasing date
236 # trim guids to max episodes
237 entries
= [e
for i
, e
in enumerate(self
._ie
_result
['entries'])
238 if not self
._max
_episodes
or i
< self
._max
_episodes
]
239 all_seen_guids
= set(e
['guid'] for e
in entries
)
240 # only fetch new ones from youtube since they are so slow to get
241 new_entries
= [e
for e
in entries
if e
['guid'] not in existing_guids
]
242 logger
.debug('%i/%i new entries', len(new_entries
), len(all_seen_guids
))
243 self
._ie
_result
['entries'] = new_entries
244 self
._downloader
.refresh_entries(self
._ie
_result
)
245 # episodes from entries
247 for en
in self
._ie
_result
['entries']:
248 guid
= video_guid(en
['id'])
250 mime_type
= util
.mimetype_from_extension('.{}'.format(en
['ext']))
252 mime_type
= 'application/octet-stream'
253 if en
.get('filesize'):
254 filesize
= int(en
['filesize'] or 0)
256 filesize
= sum(int(f
.get('filesize') or 0)
257 for f
in en
.get('requested_formats', []))
259 'title': en
.get('title', guid
),
260 'link': en
.get('webpage_url'),
261 'episode_art_url': en
.get('thumbnail'),
262 'description': util
.remove_html_tags(en
.get('description') or ''),
263 'description_html': '',
264 'url': en
.get('webpage_url'),
265 'file_size': filesize
,
266 'mime_type': mime_type
,
268 'published': youtube_parsedate(en
.get('upload_date', None)),
269 'total_time': int(en
.get('duration') or 0),
271 episode
= channel
.episode_factory(ep
)
273 episodes
.append(episode
)
274 return episodes
, all_seen_guids
276 def get_next_page(self
, channel
, max_episodes
):
278 Paginated feed support (RFC 5005).
279 If the feed is paged, return the next feed page.
280 Returned page will in turn be asked for the next page, until None is returned.
281 :return feedcore.Result: the next feed's page,
282 as a fully parsed Feed or None
287 class gPodderYoutubeDL(download
.CustomDownloader
):
288 def __init__(self
, gpodder_config
, my_config
, force
=False):
290 :param force: force using this downloader even if config says don't manage downloads
292 self
.gpodder_config
= gpodder_config
293 self
.my_config
= my_config
295 # cachedir is not much used in youtube-dl, but set it anyway
296 cachedir
= os
.path
.join(gpodder
.home
, 'youtube-dl')
297 os
.makedirs(cachedir
, exist_ok
=True)
299 'cachedir': cachedir
,
300 'noprogress': True, # prevent progress bar from appearing in console
302 # prevent escape codes in desktop notifications on errors
303 if program_name
== 'yt-dlp':
304 self
._ydl
_opts
['color'] = 'no_color'
306 self
._ydl
_opts
['no_color'] = True
309 self
._ydl
_opts
['verbose'] = True
311 self
._ydl
_opts
['quiet'] = True
312 # Don't create downloaders for URLs supported by these youtube-dl extractors
313 self
.ie_blacklist
= ["Generic"]
314 # Cache URL regexes from youtube-dl matches here, seed with youtube regex
315 self
.regex_cache
= [(re
.compile(r
'https://www.youtube.com/watch\?v=.+'),)]
316 # #686 on windows without a console, sys.stdout is None, causing exceptions
317 # when adding podcasts.
318 # See https://docs.python.org/3/library/sys.html#sys.__stderr__ Note
320 logger
.debug('no stdout, setting youtube-dl logger')
321 self
._ydl
_opts
['logger'] = logger
323 def add_format(self
, gpodder_config
, opts
, fallback
=None):
324 """ construct youtube-dl -f argument from configured format. """
325 # You can set a custom format or custom formats by editing the config for key
326 # `youtube.preferred_fmt_ids`
328 # It takes a list of format strings separated by comma: bestaudio, 18
329 # they are translated to youtube dl format bestaudio/18, meaning preferably
330 # the best audio quality (audio-only) and MP4 360p if it's not available.
332 # See https://github.com/ytdl-org/youtube-dl#format-selection for details
333 # about youtube-dl format specification.
334 fmt_ids
= youtube
.get_fmt_ids(gpodder_config
.youtube
, False)
335 opts
['format'] = '/'.join(str(fmt
) for fmt
in fmt_ids
)
337 opts
['format'] += '/' + fallback
338 logger
.debug('format=%s', opts
['format'])
340 def fetch_info(self
, url
, tempname
, reporthook
):
341 subs
= self
.my_config
.embed_subtitles
343 'paths': {'home': os
.path
.dirname(tempname
)},
344 # Postprocessing in yt-dlp breaks without ext
345 'outtmpl': (os
.path
.basename(tempname
) if program_name
== 'yt-dlp'
346 else tempname
) + '.%(ext)s',
347 'nopart': True, # don't append .part (already .partial)
348 'retries': 3, # retry a few times
349 'progress_hooks': [reporthook
], # to notify UI
350 'writesubtitles': subs
,
351 'subtitleslangs': ['all'] if subs
else [],
352 'postprocessors': [{'key': 'FFmpegEmbedSubtitle'}] if subs
else [],
354 opts
.update(self
._ydl
_opts
)
355 self
.add_format(self
.gpodder_config
, opts
)
356 with youtube_dl
.YoutubeDL(opts
) as ydl
:
357 info
= ydl
.extract_info(url
, download
=False)
360 def fetch_video(self
, info
, opts
):
361 with youtube_dl
.YoutubeDL(opts
) as ydl
:
362 return ydl
.process_video_result(info
, download
=True)
364 def refresh_entries(self
, ie_result
):
365 # only interested in video metadata
367 'skip_download': True, # don't download the video
368 'youtube_include_dash_manifest': False, # don't download the DASH manifest
370 self
.add_format(self
.gpodder_config
, opts
, fallback
='18')
371 opts
.update(self
._ydl
_opts
)
373 # refresh videos one by one to catch single videos blocked by youtube
374 for e
in ie_result
.get('entries', []):
375 tmp
= {k
: v
for k
, v
in ie_result
.items() if k
!= 'entries'}
378 with youtube_dl
.YoutubeDL(opts
) as ydl
:
379 ydl
.process_ie_result(tmp
, download
=False)
380 new_entries
.extend(tmp
.get('entries'))
381 except youtube_dl
.utils
.DownloadError
as ex
:
382 if ex
.exc_info
[0] == youtube_dl
.utils
.ExtractorError
:
383 # for instance "This video contains content from xyz, who has blocked it on copyright grounds"
384 logger
.warning('Skipping %s: %s', e
.get('title', ''), ex
.exc_info
[1])
386 logger
.exception('Skipping %r: %s', tmp
, ex
.exc_info
)
387 ie_result
['entries'] = new_entries
389 def refresh(self
, url
, channel_url
, max_episodes
):
391 Fetch a channel or playlist contents.
393 Doesn't yet fetch video entry information, so we only get the video id and title.
395 # Duplicate a bit of the YoutubeDL machinery here because we only
396 # want to parse the channel/playlist first, not to fetch video entries.
397 # We call YoutubeDL.extract_info(process=False), so we
398 # have to call extract_info again ourselves when we get a result of type 'url'.
399 def extract_type(ie_result
):
400 result_type
= ie_result
.get('_type', 'video')
401 if result_type
not in ('url', 'playlist', 'multi_video'):
402 raise Exception('Unsuported result_type: {}'.format(result_type
))
403 has_playlist
= result_type
in ('playlist', 'multi_video')
404 return result_type
, has_playlist
407 'youtube_include_dash_manifest': False, # only interested in video title and id
409 opts
.update(self
._ydl
_opts
)
410 with youtube_dl
.YoutubeDL(opts
) as ydl
:
411 ie_result
= ydl
.extract_info(url
, download
=False, process
=False)
412 result_type
, has_playlist
= extract_type(ie_result
)
413 while not has_playlist
:
414 if result_type
in ('url', 'url_transparent'):
415 ie_result
['url'] = youtube_dl
.utils
.sanitize_url(ie_result
['url'])
416 if result_type
== 'url':
417 logger
.debug("extract_info(%s) to get the video list", ie_result
['url'])
418 # We have to add extra_info to the results because it may be
419 # contained in a playlist
420 ie_result
= ydl
.extract_info(ie_result
['url'],
423 ie_key
=ie_result
.get('ie_key'))
424 result_type
, has_playlist
= extract_type(ie_result
)
425 cover_url
= youtube
.get_cover(channel_url
) # youtube-dl doesn't provide the cover url!
426 description
= youtube
.get_channel_desc(channel_url
) # youtube-dl doesn't provide the description!
427 return feedcore
.Result(feedcore
.UPDATED_FEED
,
428 YoutubeFeed(url
, cover_url
, description
, max_episodes
, ie_result
, self
))
430 def fetch_channel(self
, channel
, max_episodes
=0):
432 called by model.gPodderFetcher to get a custom feed.
433 :returns feedcore.Result: a YoutubeFeed or None if channel is not a youtube channel or playlist
435 if not self
.my_config
.manage_channel
:
438 m
= CHANNEL_RE
.match(channel
.url
)
440 url
= 'https://www.youtube.com/channel/{}/videos'.format(m
.group(1))
442 m
= USER_RE
.match(channel
.url
)
444 url
= 'https://www.youtube.com/user/{}/videos'.format(m
.group(1))
446 m
= PLAYLIST_RE
.match(channel
.url
)
448 url
= 'https://www.youtube.com/playlist?list={}'.format(m
.group(1))
450 logger
.info('youtube-dl handling %s => %s', channel
.url
, url
)
451 return self
.refresh(url
, channel
.url
, max_episodes
)
454 def is_supported_url(self
, url
):
457 for i
, res
in enumerate(self
.regex_cache
):
458 if next(filter(None, (r
.match(url
) for r
in res
)), None) is not None:
460 self
.regex_cache
.remove(res
)
461 self
.regex_cache
.insert(0, res
)
463 with youtube_dl
.YoutubeDL(self
._ydl
_opts
) as ydl
:
464 # youtube-dl returns a list, yt-dlp returns a dict
466 if type(ydl
._ies
) == dict:
467 ies
= ydl
._ies
.values()
469 if ie
.suitable(url
) and ie
.ie_key() not in self
.ie_blacklist
:
470 self
.regex_cache
.insert(
471 0, (ie
._VALID
_URL
_RE
if isinstance(ie
._VALID
_URL
_RE
, Iterable
)
472 else (ie
._VALID
_URL
_RE
,)))
476 def custom_downloader(self
, unused_config
, episode
):
478 called from registry.custom_downloader.resolve
480 if not self
.force
and not self
.my_config
.manage_downloads
:
483 try: # Reject URLs linking to known media files
484 (_
, ext
) = util
.filename_from_url(episode
.url
)
485 if util
.file_type_by_extension(ext
) is not None:
490 if self
.is_supported_url(episode
.url
):
491 return YoutubeCustomDownload(self
, episode
.url
, episode
)
496 class gPodderExtension
:
497 def __init__(self
, container
):
498 self
.container
= container
503 self
.ytdl
= gPodderYoutubeDL(self
.container
.manager
.core
.config
, self
.container
.config
)
504 logger
.info('Registering youtube-dl. (using %s %s)' % (program_name
, youtube_dl
.version
.__version
__))
505 registry
.feed_handler
.register(self
.ytdl
.fetch_channel
)
506 registry
.custom_downloader
.register(self
.ytdl
.custom_downloader
)
508 if youtube_dl
.utils
.version_tuple(youtube_dl
.version
.__version
__) < youtube_dl
.utils
.version_tuple(want_ytdl_version
):
509 logger
.error(want_ytdl_version_msg
510 % {'have_version': youtube_dl
.version
.__version
__, 'want_version': want_ytdl_version
})
513 logger
.info('Unregistering youtube-dl.')
515 registry
.feed_handler
.unregister(self
.ytdl
.fetch_channel
)
519 registry
.custom_downloader
.unregister(self
.ytdl
.custom_downloader
)
524 def on_ui_object_available(self
, name
, ui_object
):
525 if name
== 'gpodder-gtk':
526 self
.gpodder
= ui_object
528 if youtube_dl
.utils
.version_tuple(youtube_dl
.version
.__version
__) < youtube_dl
.utils
.version_tuple(want_ytdl_version
):
529 ui_object
.notification(want_ytdl_version_msg
%
530 {'have_version': youtube_dl
.version
.__version
__, 'want_version': want_ytdl_version
},
531 _('Old youtube-dl'), important
=True, widget
=ui_object
.main_window
)
533 def on_episodes_context_menu(self
, episodes
):
534 if not self
.container
.config
.manage_downloads
and any(e
.can_download() for e
in episodes
):
535 return [(_("Download with youtube-dl"), self
.download_episodes
)]
537 def download_episodes(self
, episodes
):
538 episodes
= [e
for e
in episodes
if e
.can_download()]
540 # create a new gPodderYoutubeDL to force using it even if manage_downloads is False
541 downloader
= gPodderYoutubeDL(self
.container
.manager
.core
.config
, self
.container
.config
, force
=True)
542 self
.gpodder
.download_episode_list(episodes
, downloader
=downloader
)
544 def toggle_manage_channel(self
, widget
):
545 self
.container
.config
.manage_channel
= widget
.get_active()
547 def toggle_manage_downloads(self
, widget
):
548 self
.container
.config
.manage_downloads
= widget
.get_active()
550 def toggle_embed_subtitles(self
, widget
):
551 if widget
.get_active():
552 if not util
.find_command('ffmpeg'):
554 widget
.set_active(False)
555 self
.container
.config
.embed_subtitles
= False
557 self
.container
.config
.embed_subtitles
= True
559 self
.container
.config
.embed_subtitles
= False
561 def show_preferences(self
):
562 box
= Gtk
.Box(orientation
=Gtk
.Orientation
.VERTICAL
, spacing
=10)
563 box
.set_border_width(10)
565 label
= Gtk
.Label('%s %s' % (program_name
, youtube_dl
.version
.__version
__))
566 box
.pack_start(label
, False, False, 0)
568 box
.pack_start(Gtk
.HSeparator(), False, False, 0)
570 checkbox
= Gtk
.CheckButton(_('Parse YouTube channel feeds with youtube-dl to access more than 15 episodes'))
571 checkbox
.set_active(self
.container
.config
.manage_channel
)
572 checkbox
.connect('toggled', self
.toggle_manage_channel
)
573 box
.pack_start(checkbox
, False, False, 0)
575 box
.pack_start(Gtk
.HSeparator(), False, False, 0)
577 checkbox
= Gtk
.CheckButton(_('Download all supported episodes with youtube-dl'))
578 checkbox
.set_active(self
.container
.config
.manage_downloads
)
579 checkbox
.connect('toggled', self
.toggle_manage_downloads
)
580 box
.pack_start(checkbox
, False, False, 0)
581 note
= Gtk
.Label(use_markup
=True, wrap
=True, label
=_(
582 'youtube-dl provides access to additional YouTube formats and DRM content.'
583 ' Episodes from non-YouTube channels, that have youtube-dl support, will <b>fail</b> to download unless you manually'
584 ' <a href="https://gpodder.github.io/docs/youtube.html#formats">add custom formats</a> for each site.'
585 ' <b>Download with youtube-dl</b> appears in the episode menu when this option is disabled,'
586 ' and can be used to manually download from supported sites.'))
587 note
.connect('activate-link', lambda label
, url
: util
.open_website(url
))
588 note
.set_property('xalign', 0.0)
591 box
.pack_start(Gtk
.HSeparator(), False, False, 0)
593 checkbox
= Gtk
.CheckButton(_('Embed all available subtitles in downloaded video'))
594 checkbox
.set_active(self
.container
.config
.embed_subtitles
)
595 checkbox
.connect('toggled', self
.toggle_embed_subtitles
)
596 box
.pack_start(checkbox
, False, False, 0)
598 infobar
= Gtk
.InfoBar()
599 infobar
.get_content_area().add(Gtk
.Label(wrap
=True, label
=_(
600 'The "ffmpeg" command was not found. FFmpeg is required for embedding subtitles.')))
601 self
.infobar
= infobar
602 box
.pack_end(infobar
, False, False, 0)
608 def on_preferences(self
):
609 return [(_('youtube-dl'), self
.show_preferences
)]