Fix various typos
[gpodder.git] / share / gpodder / extensions / youtube-dl.py
blob058e4ce434f3f489a03c2d78634b19a6359b52c6
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.
7 import logging
8 import os
9 import re
10 import sys
11 import time
12 from collections.abc import Iterable
14 try:
15 import yt_dlp as youtube_dl
16 program_name = 'yt-dlp'
17 want_ytdl_version = '2023.06.22'
18 except:
19 import youtube_dl
20 program_name = 'youtube-dl'
21 want_ytdl_version = '2023.02.17' # youtube-dl has been patched, but not yet released
23 import gpodder
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
30 _ = gpodder.gettext
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.')
44 DefaultConfig = {
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).
67 """
68 if s:
69 return time.mktime(time.strptime(s, "%Y%m%d"))
70 return 0
73 def video_guid(video_id):
74 """
75 generate same guid as youtube
76 """
77 return 'yt:video:{}'.format(video_id)
80 class YoutubeCustomDownload(download.CustomDownload):
81 """
82 Represents the download of a single episode using youtube-dl.
84 Actual youtube-dl interaction via gPodderYoutubeDL.
85 """
86 def __init__(self, ytdl, url, episode):
87 self._ytdl = ytdl
88 self._url = url
89 self._reporthook = None
90 self._prev_dl_bytes = 0
91 self._episode = episode
92 self._partial_filename = None
94 @property
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')
121 if filepath is None:
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))
127 os.remove(tempname)
128 os.rename(filepath, tempname)
130 if 'duration' in res and res['duration']:
131 self._episode.total_time = res['duration']
132 headers = {}
133 # youtube-dl doesn't return a content-type but an extension
134 if 'ext' in res:
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))
146 os.remove(tempname)
147 os.rename(tempname_with_ext, tempname)
148 dot_ext = try_ext
149 break
151 ext_filetype = util.mimetype_from_extension(dot_ext)
152 if ext_filetype:
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':
162 if self._reporthook:
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
171 if self._reporthook:
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)
175 else:
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):
184 self._url = url
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 = []
194 seen_guids = set()
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'])
198 e['guid'] = guid
199 if guid in seen_guids:
200 logger.debug('dropping already seen entry %s title="%s"', guid, e.get('title'))
201 else:
202 filtered_entries.append(e)
203 seen_guids.add(guid)
204 else:
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')
209 break
210 return filtered_entries
212 def get_title(self):
213 return '{} (YouTube)'.format(self._ie_result.get('title') or self._ie_result.get('id') or self._url)
215 def get_link(self):
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!
227 return None
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!
232 return None
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
246 episodes = []
247 for en in self._ie_result['entries']:
248 guid = video_guid(en['id'])
249 if en.get('ext'):
250 mime_type = util.mimetype_from_extension('.{}'.format(en['ext']))
251 else:
252 mime_type = 'application/octet-stream'
253 if en.get('filesize'):
254 filesize = int(en['filesize'] or 0)
255 else:
256 filesize = sum(int(f.get('filesize') or 0)
257 for f in en.get('requested_formats', []))
258 ep = {
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,
267 'guid': guid,
268 'published': youtube_parsedate(en.get('upload_date', None)),
269 'total_time': int(en.get('duration') or 0),
271 episode = channel.episode_factory(ep)
272 episode.save()
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
284 return 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
294 self.force = force
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)
298 self._ydl_opts = {
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'
305 else:
306 self._ydl_opts['no_color'] = True
308 if gpodder.verbose:
309 self._ydl_opts['verbose'] = True
310 else:
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
319 if not sys.stdout:
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)
336 if fallback:
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
342 opts = {
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)
358 return info, opts
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
366 opts = {
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)
372 new_entries = []
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'}
376 tmp['entries'] = [e]
377 try:
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])
385 continue
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
406 opts = {
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'],
421 download=False,
422 process=False,
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:
436 return None
437 url = None
438 m = CHANNEL_RE.match(channel.url)
439 if m:
440 url = 'https://www.youtube.com/channel/{}/videos'.format(m.group(1))
441 else:
442 m = USER_RE.match(channel.url)
443 if m:
444 url = 'https://www.youtube.com/user/{}/videos'.format(m.group(1))
445 else:
446 m = PLAYLIST_RE.match(channel.url)
447 if m:
448 url = 'https://www.youtube.com/playlist?list={}'.format(m.group(1))
449 if url:
450 logger.info('youtube-dl handling %s => %s', channel.url, url)
451 return self.refresh(url, channel.url, max_episodes)
452 return None
454 def is_supported_url(self, url):
455 if url is None:
456 return False
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:
459 if i > 0:
460 self.regex_cache.remove(res)
461 self.regex_cache.insert(0, res)
462 return True
463 with youtube_dl.YoutubeDL(self._ydl_opts) as ydl:
464 # youtube-dl returns a list, yt-dlp returns a dict
465 ies = ydl._ies
466 if type(ydl._ies) == dict:
467 ies = ydl._ies.values()
468 for ie in ies:
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,)))
473 return True
474 return False
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:
481 return None
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:
486 return None
487 except Exception:
488 pass
490 if self.is_supported_url(episode.url):
491 return YoutubeCustomDownload(self, episode.url, episode)
493 return None
496 class gPodderExtension:
497 def __init__(self, container):
498 self.container = container
499 self.ytdl = None
500 self.infobar = None
502 def on_load(self):
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})
512 def on_unload(self):
513 logger.info('Unregistering youtube-dl.')
514 try:
515 registry.feed_handler.unregister(self.ytdl.fetch_channel)
516 except ValueError:
517 pass
518 try:
519 registry.custom_downloader.unregister(self.ytdl.custom_downloader)
520 except ValueError:
521 pass
522 self.ytdl = None
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'):
553 self.infobar.show()
554 widget.set_active(False)
555 self.container.config.embed_subtitles = False
556 else:
557 self.container.config.embed_subtitles = True
558 else:
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)
589 box.add(note)
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)
604 box.show_all()
605 infobar.hide()
606 return box
608 def on_preferences(self):
609 return [(_('youtube-dl'), self.show_preferences)]