From fc7db34864f29c9355d2f611f0ee79593f3c1306 Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:20:25 -0600 Subject: [PATCH] Video download deletes yt-dlp format leftovers of any extension (#816) A merge that never finished leaves `.f313.webm` (video-only) next to where the merged file belongs. A later download of the same video globs every `.*` file into its FileGroup, but only `.part` files and format files with the *same* extension as the merged video were deleted. A `.webm` leftover beside a new `.mp4` survived, joined the new FileGroup, and could become the Video's file. Delete every `.fNNN.*` file in the group, whatever its extension, which also covers WROLPi's poster/ffprobe sidecars of an indexed leftover and yt-dlp's `.ytdl` state files. Co-authored-by: Claude Opus 5.5 --- modules/videos/downloader.py | 20 ++++++-------- modules/videos/test/test_downloader.py | 38 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/modules/videos/downloader.py b/modules/videos/downloader.py index d5996d45..421ab11c 100755 --- a/modules/videos/downloader.py +++ b/modules/videos/downloader.py @@ -1406,23 +1406,20 @@ class VideoDownloader(Downloader, ABC): """Delete temporary "part" files that are used by yt-dlp when downloading video (and related) files.""" files = list(files) - video_suffix = video_file.suffix.lstrip('.') - # Delete any files in this group that end with .part. for part_file in [i for i in files if i.name.endswith('.part')]: logger.warning(f'Deleting leftover .part file: {part_file}') part_file.unlink() files.remove(part_file) - # Delete any video files that are a temporary file that contain the "format" number. - # Example: The Video Title.f616.mp4 - video_files = [i for i in files if i != video_file and i.name.endswith(video_suffix)] - for part_video in video_files: - if VIDEO_FORMAT_PARSER.match(part_video.stem): - # Extra yt-dlp format video file that is not the video file, delete it. - logger.warning(f'Deleting leftover incomplete video file: {part_video}') - part_video.unlink() - files.remove(part_video) + # Delete files of an unmerged yt-dlp "format" download, whatever their extension, and anything made from them + # (an earlier refresh may have indexed one and made a poster). Example: The Video Title.f313.webm + video_stem, _ = split_path_stem_and_suffix(video_file) + format_file = re.compile(rf'^{re.escape(video_stem)}\.f\d{{2,4}}\.') + for part_file in [i for i in files if i != video_file and format_file.match(i.name)]: + logger.warning(f'Deleting leftover yt-dlp format file: {part_file}') + part_file.unlink() + files.remove(part_file) return files @@ -1451,7 +1448,6 @@ class VideoDownloader(Downloader, ABC): VTT_SUFFIX_PARSER = re.compile(r'^\.([a-z]{2,3})-(.+?)\.(vtt|srt)$', re.IGNORECASE) -VIDEO_FORMAT_PARSER = re.compile(r'^.+?\.f\d{2,4}') channel_downloader = ChannelDownloader() # Videos may match the ChannelDownloader, give it a higher priority. diff --git a/modules/videos/test/test_downloader.py b/modules/videos/test/test_downloader.py index 0674a1fe..640a2598 100644 --- a/modules/videos/test/test_downloader.py +++ b/modules/videos/test/test_downloader.py @@ -563,6 +563,44 @@ async def test_video_download(test_session, test_directory, mock_video_extract_i assert 'formats' not in video.get_info_json(), 'formats should be deleted from info json' +@pytest.mark.asyncio +async def test_video_download_deletes_format_leftovers(test_session, test_directory, mock_video_extract_info, + simple_channel, await_switches, video_download_manager, + mock_video_process_runner, image_file): + """yt-dlp's per-format files (`.f313.webm`) left by an earlier, unmerged download are deleted, whatever + their extension, so they do not join the new Video's FileGroup.""" + simple_channel.source_id = example_video_json['channel_id'] + simple_channel.directory = test_directory / 'videos/channel name' + simple_channel.directory.mkdir(parents=True) + + video_path = simple_channel.directory / 'a video.mp4' + shutil.copy(PROJECT_DIR / 'test/big_buck_bunny_720p_1mb.mp4', video_path) + # An earlier download's video-only format file was never merged; WROLPi indexed it and made a poster for it. + leftover_video = simple_channel.directory / 'a video.f313.webm' + shutil.copy(PROJECT_DIR / 'test/big_buck_bunny_720p_1mb.mp4', leftover_video) + leftover_poster = simple_channel.directory / 'a video.f313.jpg' + image_file.rename(leftover_poster) + leftover_ytdl = simple_channel.directory / 'a video.f251.webm.ytdl' + leftover_ytdl.write_text('{}') + # Not a format file of this video. + other = simple_channel.directory / 'a video.f313 notes.txt' + other.write_text('keep me') + + url = 'https://www.youtube.com/watch?v=31jPEBiAC3c' + with mock.patch('modules.videos.downloader.prepare_video_filename') as mock_prepare_filename: + mock_video_extract_info.return_value = example_video_json + mock_prepare_filename.return_value = (video_path, {'id': 'foo'}) + + video_download_manager.create_download(test_session, url, video_downloader.name) + await video_download_manager.wait_for_all_downloads() + + video: Video = test_session.query(Video).one() + assert video.video_path == video_path + assert not leftover_video.exists() and not leftover_poster.exists() and not leftover_ytdl.exists() + assert not any('.f313.' in i.name or '.f251.' in i.name for i in video.file_group.my_paths()) + assert other.exists() + + @pytest.mark.asyncio async def test_download_info_json_finds_existing_video(test_session, test_directory, video_factory): """`download_info_json` looks up the existing Video by its URL.