feat: write playlist/channel metadata files where their items go (#660)

yt-dlp emits the feed-level .info.json, description and thumbnail from
__process_playlist_result without consulting `download`, so they fell out of
MeTube's classification pass with nothing steering them: they landed in
DOWNLOAD_DIR under yt-dlp's own pl_* names, ignoring OUTPUT_TEMPLATE, the
download's folder and AUDIO_DOWNLOAD_DIR. #1040 reported the names; #660 asked
for control over them. Same accident from both sides.

The classification pass now writes nothing at all, and the files are produced
once the feed has been accepted and its type is known, reusing the template its
items use — OUTPUT_TEMPLATE_CHANNEL for a channel, OUTPUT_TEMPLATE_PLAYLIST for
a playlist, falling back to OUTPUT_TEMPLATE — evaluated against the feed dict.
With the defaults that puts them in the same folder as the videos, named after
the feed, which is the Jellyfin layout #660 asked for. No new environment
variable: knowing the feed type is what makes reusing the item template
possible, and doing this after extraction is what makes the type known.

The write re-runs yt-dlp over a copy of the feed with its entries removed,
which reaches the playlist-file writing without re-extracting anything and
without touching yt-dlp's private write helpers. It runs in an executor and
never fails the add.

Nothing new appears for anyone who hasn't enabled writeinfojson /
writethumbnail, an explicit allow_playlist_files=false still turns it off, and
a failed or cancelled add no longer leaves metadata behind.

Subscription scans keep allow_playlist_files=False: a scan is a timer-driven
poll, and items it finds are queued through the download queue, which does the
writing. Previously every check interval rewrote these files.

Test doubles for __extract_info now take *args/**kwargs.
This commit is contained in:
Alex Shnitman
2026-07-27 22:32:10 +03:00
parent d66b04ccf5
commit 06c63ec6e5
5 changed files with 327 additions and 13 deletions
+87
View File
@@ -1314,6 +1314,14 @@ class DownloadQueue:
'ignore_no_formats_error': True,
'noplaylist': True,
'paths': {"home": self.config.DOWNLOAD_DIR, "temp": self.config.TEMP_DIR},
# This is a classification pass, not a download. yt-dlp emits the
# feed-level infojson/description/thumbnail from
# __process_playlist_result without consulting `download`, so
# without this a writeinfojson user gets stray files here — in
# DOWNLOAD_DIR, under yt-dlp's pl_* names, even for an add that goes
# on to fail. __write_feed_metadata writes them properly once the
# feed is accepted. See issues #1040 and #660.
'allow_playlist_files': False,
}
imp = user_opts.get('impersonate')
if imp is not None:
@@ -1377,6 +1385,81 @@ class DownloadQueue:
self.pending.put(download)
await self.notifier.added(dl)
def __write_feed_metadata_sync(self, entry, etype, download_type, folder,
ytdl_options_presets, ytdl_options_overrides):
"""Write the feed-level .info.json/description/thumbnail for a playlist
or channel add, using the same output template its items will use.
yt-dlp produces these from __process_playlist_result, which ignores
``download`` — so they used to fall out of the classification pass with
yt-dlp's own pl_* names, in DOWNLOAD_DIR, ignoring the download's folder
(issue #1040) and with no way to steer them (issue #660). Doing it here
instead means the feed type is already known, so the file lands beside
the items rather than in a differently-named sibling directory.
Re-runs yt-dlp on a copy of the feed with no entries: that reaches the
playlist-file writing without re-extracting anything or touching
yt-dlp's private write helpers.
"""
user_opts = self._build_ytdl_options(ytdl_options_presets, ytdl_options_overrides)
wants = ('writeinfojson', 'writedescription', 'writethumbnail', 'write_all_thumbnails')
if not any(user_opts.get(key) for key in wants):
return
# An explicit allow_playlist_files=false is the user asking for exactly
# this to not happen.
if user_opts.get('allow_playlist_files') is False:
return
dldirectory, error_message = self.__calc_download_path(download_type, folder)
if error_message is not None:
return
template = (
self.config.OUTPUT_TEMPLATE_CHANNEL if etype == 'channel'
else self.config.OUTPUT_TEMPLATE_PLAYLIST
) or self.config.OUTPUT_TEMPLATE
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
params = {
**user_opts,
'quiet': not debug_logging,
'verbose': debug_logging,
'no_color': True,
'skip_download': True,
'extract_flat': True,
'allow_playlist_files': True,
'paths': {"home": dldirectory, "temp": self.config.TEMP_DIR},
# Feed-level keys only; per-item names are resolved by __add_download.
'outtmpl': {
'pl_infojson': template,
'pl_thumbnail': template,
'pl_description': template,
},
}
imp = user_opts.get('impersonate')
if imp is not None:
params['impersonate'] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(imp)
# A copy: process_ie_result mutates entries/requested_entries, and the
# caller still needs the real feed dict to queue the items.
feed = {k: v for k, v in entry.items() if k != 'entries'}
feed['entries'] = []
yt_dlp.YoutubeDL(params=params).process_ie_result(feed, download=False)
async def __write_feed_metadata(self, entry, etype, download_type, folder,
ytdl_options_presets, ytdl_options_overrides):
try:
await asyncio.get_running_loop().run_in_executor(
None,
partial(
self.__write_feed_metadata_sync, entry, etype, download_type, folder,
ytdl_options_presets, ytdl_options_overrides,
),
)
except Exception as exc:
# Supplemental output must never fail the add.
log.warning(f'Could not write {etype} metadata files: {exc}')
async def __add_entry(
self,
entry,
@@ -1453,6 +1536,10 @@ class DownloadQueue:
entries = list(entries)
total_entries = len(entries)
log.info(f'{etype} detected with {total_entries} entries')
await self.__write_feed_metadata(
entry, etype, download_type, folder,
ytdl_options_presets, ytdl_options_overrides,
)
index_digits = len(str(total_entries))
results = []
if playlist_item_limit > 0: