From 8c294dad3942dff26b08052ae850c6f13b4ad9bc Mon Sep 17 00:00:00 2001 From: Roland Date: Sun, 8 Mar 2026 11:55:59 -0600 Subject: [PATCH] Downloading only the first video when the VideoDownloader gets a playlist by accident. --- modules/videos/downloader.py | 13 ++++++++++++ modules/videos/test/test_downloader.py | 29 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/modules/videos/downloader.py b/modules/videos/downloader.py index bcc647bd..6204a1e9 100755 --- a/modules/videos/downloader.py +++ b/modules/videos/downloader.py @@ -639,6 +639,13 @@ class VideoDownloader(Downloader, ABC): video_path = preferred_path logger.info(f'Using preferred video file which exists: {preferred_path}') + if not video_path.is_file(): + # yt-dlp may have appended the real extension to a bogus one (e.g. .NA.mp4 from a playlist entry). + appended_path = video_path.parent / f'{video_path.name}.{video_format}' + if appended_path.is_file(): + video_path = appended_path + logger.info(f'Using video file with appended extension: {appended_path}') + if video_path.suffix == '.part': return DownloadResult( success=False, @@ -864,6 +871,12 @@ class VideoDownloader(Downloader, ABC): # Get the path where the video will be saved. try: entry = extract_info(url, ydl=ydl, process=True) + # If the entry is a playlist (e.g. Twitter/X post with multiple videos), + # use the first video entry for filename preparation. + if entry.get('entries'): + entries = list(entry['entries']) if not isinstance(entry['entries'], list) else entry['entries'] + if entries: + entry = entries[0] final_filename = pathlib.Path(prepare_filename(entry, ydl=ydl)).absolute() except DownloadError as e: if ' Cannot write ' in str(e): diff --git a/modules/videos/test/test_downloader.py b/modules/videos/test/test_downloader.py index 450c4c7e..44314245 100644 --- a/modules/videos/test/test_downloader.py +++ b/modules/videos/test/test_downloader.py @@ -880,3 +880,32 @@ def test_skip_download_excludes_bot_blocked(): # Other unrecoverable errors should still be skipped. assert _skip_download("404: Not Found") is True assert _skip_download("requires payment") is True + + +@pytest.mark.asyncio +async def test_video_download_playlist_appended_extension(test_session, test_directory, mock_video_extract_info, + video_download_manager, mock_video_process_runner, + simple_channel, image_file): + """A video download from a playlist (e.g. Twitter/X) where yt-dlp appends the real extension to a bogus .NA + extension should still find the file.""" + simple_channel.source_id = example_video_json['channel_id'] + simple_channel.directory = test_directory / 'videos/channel name' + simple_channel.directory.mkdir(parents=True) + + # Simulate yt-dlp saving a file with .NA.mp4 (appended extension from playlist entry with ext=NA). + video_path_na = simple_channel.directory / 'uploader name_20190707_some long id_The video title.NA.mp4' + shutil.copy(PROJECT_DIR / 'test/big_buck_bunny_720p_1mb.mp4', video_path_na) + + url = 'https://x.com/example/status/123' + mock_video_extract_info.return_value = example_video_json + + with mock.patch('modules.videos.downloader.VideoDownloader.prepare_filename') as mock_prepare_filename: + # prepare_filename returns the path without the real extension (ending in .NA). + bogus_path = video_path_na.parent / video_path_na.stem # Removes .mp4, leaving .NA as "extension" + mock_prepare_filename.return_value = (bogus_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_na