wrolpi/modules/videos/test/test_downloader.py
lrnselfreliance fc7db34864
Video download deletes yt-dlp format leftovers of any extension (#816)
A merge that never finished leaves `<stem>.f313.webm` (video-only) next to
where the merged file belongs.  A later download of the same video globs
every `<stem>.*` 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 `<stem>.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 <noreply@anthropic.com>
2026-10-11 16:20:25 -06:00

1902 lines
88 KiB
Python

import asyncio
import json
import shutil
from copy import copy
from http import HTTPStatus
from unittest import mock
import pytest
from modules.videos.downloader import VideoDownloader, \
get_or_create_channel, channel_downloader, video_downloader, preview_filename, \
prepare_filename, convert_wrolpi_filename_format, _bot_blocked, _skip_download, \
parse_ytdlp_progress, is_youtube_rss_feed_url, is_youtube_url, format_selector, VIDEO_RESOLUTION_MAP
from modules.videos.lib import get_videos_downloader_config
from modules.videos.models import Channel, Video
from wrolpi.conftest import test_directory, await_switches
from wrolpi.downloader import Download, DownloadResult, get_download_manager_config
from wrolpi.errors import InvalidDownload, UnrecoverableDownloadError
from wrolpi.test.common import skip_ci
from wrolpi.vars import PROJECT_DIR
example_video_json = {
'age_limit': 0,
'average_rating': 4.6923075,
'categories': ['Howto & Style'],
'channel': 'channel name',
'channel_id': 'channel id',
'channel_url': 'https://example.com/example_video_json_channel',
'duration': 635,
'extractor': 'youtube',
'extractor_key': 'Youtube',
'id': 'some long id',
'is_live': None,
'like_count': 24,
'subtitles': {},
'title': "The video's title",
'upload_date': '20190707',
'uploader': 'uploader name',
'uploader_id': 'uploader id',
'uploader_url': 'https://example.com/example_video_json_uploader',
'view_count': 406,
'webpage_url': 'webpage url',
'webpage_url_basename': 'watch',
}
example_channel_json = {
'_type': 'playlist',
'channel_id': 'some id',
'entries': [
{'_type': 'url',
'description': None,
'duration': None,
'id': 'video 1 id',
'ie_key': 'Youtube',
'title': 'video 1 title',
'uploader': None,
'url': 'video_1_url',
'view_count': 58504,
'upload_date': '20190707',
'webpage_url': 'https://youtube.com/watch?v=video_1_url'},
{'_type': 'url',
'description': None,
'duration': None,
'id': 'video 2 id',
'ie_key': 'Youtube',
'title': 'video 2 title',
'uploader': None,
'url': 'video_2_url',
'view_count': 1413,
'upload_date': '20190707',
'webpage_url': 'https://youtube.com/watch?v=video_2_url'},
],
'extractor': 'youtube:tab',
'extractor_key': 'YoutubeTab',
'id': 'some id',
'title': 'channel title',
'uploader': 'the uploader',
'webpage_url': 'channel url',
'webpage_url_basename': 'videos',
}
@pytest.mark.asyncio
async def test_download_no_channel(test_session, video_download_manager, test_directory, await_switches,
mock_video_extract_info, mock_video_process_runner):
"""A video can be downloaded even if it does not have a Channel."""
channel_dir = test_directory / 'NO CHANNEL'
channel_dir.mkdir(parents=True)
video_path = channel_dir / 'video.mp4'
shutil.copy(PROJECT_DIR / 'test/big_buck_bunny_720p_1mb.mp4', video_path)
channel_downloader, video_downloader = video_download_manager.instances
# Video has no channel
info_json = copy(example_video_json)
del info_json['channel']
del info_json['channel_id']
del info_json['channel_url']
url = 'https://www.youtube.com/watch?v=31jPEBiAC3c'
mock_video_extract_info.return_value = info_json
with mock.patch('modules.videos.downloader.prepare_video_filename') as mock_prepare_filename:
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()
mock_video_process_runner.assert_called_once()
video: Video = test_session.query(Video).one()
assert str(video.video_path) == f'{channel_dir}/video.mp4'
@pytest.mark.asyncio
async def test_download_video_tags(test_session, video_download_manager, video_file_factory, test_directory,
mock_video_extract_info, mock_video_process_runner, tag_factory, await_switches):
"""A Video is tagged when Download record requires it."""
video_path = video_file_factory()
tag1, tag2 = await tag_factory(), await tag_factory()
url = 'https://www.youtube.com/watch?v=31jPEBiAC3c'
mock_video_extract_info.return_value = copy(example_video_json)
with mock.patch('modules.videos.downloader.prepare_video_filename') as mock_prepare_filename:
mock_prepare_filename.return_value = (video_path, {'id': 'foo'})
settings = dict(tag_names=[tag1.name, tag2.name])
video_download_manager.create_download(test_session, url, video_downloader.name, settings=settings)
await video_download_manager.wait_for_all_downloads()
mock_video_process_runner.assert_called_once()
video: Video = test_session.query(Video).one()
assert [i.tag.name for i in video.file_group.tag_files] == [tag1.name, tag2.name]
@skip_ci
@pytest.mark.asyncio
async def test_download_channel(test_session, test_directory, simple_channel, video_download_manager, video_file,
mock_video_extract_info, mock_video_prepare_filename, await_switches,
mock_video_process_runner):
"""Downloading (updating the catalog of) a Channel updates its info_json.
If a Channel has `match_regex` only those videos with matching titles will be downloaded."""
url = 'https://www.youtube.com/c/LearningSelfReliance/videos'
mock_video_prepare_filename.return_value = video_file
mock_video_extract_info.return_value = example_channel_json
with mock.patch('modules.videos.downloader.get_channel') as mock_get_channel:
mock_get_channel.return_value = simple_channel
simple_channel.get_or_create_download(test_session, url, 60)
# Download channel.
await video_download_manager.wait_for_all_downloads()
# Download videos.
await video_download_manager.wait_for_all_downloads()
def reset_downloads():
[i.delete(add_to_skip_list=False) for i in test_session.query(Download)]
# Let background tasks run.
await asyncio.sleep(0)
# Two videos are in the example channel.
downloads = video_download_manager.get_once_downloads(test_session)
downloads = filter(lambda i: 'watch' in i.url, downloads)
assert {i.url for i in downloads} == \
{'https://youtube.com/watch?v=video_2_url', 'https://youtube.com/watch?v=video_1_url'}
assert all(i.status_code == 'complete' for i in downloads)
reset_downloads()
# A channel with `title_include` only returns matching video URLs.
test_session.commit()
with mock.patch('modules.videos.downloader.get_channel') as mock_get_channel:
mock_get_channel.return_value = simple_channel
download = simple_channel.get_or_create_download(test_session, url, 60)
download.frequency = 100
download.settings = {'title_include': '2'}
await video_download_manager.wait_for_all_downloads()
downloads = video_download_manager.get_once_downloads(test_session)
assert {i.url for i in downloads} == {'https://youtube.com/watch?v=video_2_url'}
assert downloads[0].settings == {
'collection_id': simple_channel.collection_id,
'channel_url': 'https://www.youtube.com/c/LearningSelfReliance/videos',
'destination': str(test_directory),
'parent_download_url': url,
}
reset_downloads()
# A channel with `title_exclude` only returns non-matching video URLs.
test_session.commit()
with mock.patch('modules.videos.downloader.get_channel') as mock_get_channel:
mock_get_channel.return_value = simple_channel
download = simple_channel.get_or_create_download(test_session, url, 60)
download.frequency = 100
download.settings = {'title_exclude': '2'}
await video_download_manager.wait_for_all_downloads()
downloads = video_download_manager.get_once_downloads(test_session)
assert {i.url for i in downloads} == {'https://youtube.com/watch?v=video_1_url'}
assert downloads[0].settings == {
'collection_id': simple_channel.collection_id,
'channel_url': 'https://www.youtube.com/c/LearningSelfReliance/videos',
'destination': str(test_directory),
'parent_download_url': url,
}
@pytest.mark.asyncio
async def test_get_or_create_channel(async_client, test_session, test_directory, tag_factory, await_switches):
"""A Channel may need to be created for an arbitrary download.
Attempt to use an existing Channel if we can match it.
"""
from wrolpi.collections import Collection
one, two = await tag_factory(), await tag_factory()
# Create Collections for each Channel
def create_channel_with_collection(name, source_id=None, url=None):
collection = Collection(name=name, kind='channel')
test_session.add(collection)
test_session.flush([collection])
channel = Channel(collection_id=collection.id, source_id=source_id, url=url)
test_session.add(channel)
test_session.flush([channel])
return channel
c1 = create_channel_with_collection('foo', source_id='foo', url='https://example.com')
c2 = create_channel_with_collection('bar', source_id='bar')
c3 = create_channel_with_collection('baz', source_id='baz', url='https://example.net')
c4 = create_channel_with_collection('qux')
test_session.commit()
# All existing channels should be used.
tests = [
(dict(source_id='foo'), c1),
(dict(url='https://example.com'), c1),
(dict(url='https://example.com', source_id='bar'), c2), # source_id has priority.
(dict(name='foo', source_id='bar'), c2),
(dict(source_id='bar'), c2),
(dict(source_id='baz'), c3),
(dict(name='qux'), c4),
]
for kwargs, expected in tests:
channel = get_or_create_channel(test_session, **kwargs)
assert expected.id == channel.id, f'Expected {expected} for {kwargs} but got {channel}'
# A new channel is created. It will not be automatically downloaded.
channel = get_or_create_channel(test_session, source_id='quux', name='quux', url='https://example.org')
assert channel.id == 5
assert channel.source_id == 'quux'
assert channel.name == 'quux'
assert channel.url == 'https://example.org'
# New channel can be retrieved.
assert get_or_create_channel(test_session, source_id='quux') == channel
# A Channel can be created with a tag name.
channel = get_or_create_channel(test_session, name='One Channel', tag_name=one.name)
await await_switches()
assert channel.directory == test_directory / f'videos/one/One Channel'
assert channel.tag_name == one.name and channel.tag == one
# Getting the same Channel will not change the tag.
channel = get_or_create_channel(test_session, name='One Channel', tag_name=two.name)
assert channel.directory == test_directory / f'videos/one/One Channel'
assert channel.tag_name == one.name and channel.tag == one
channel = get_or_create_channel(test_session, name='One Channel')
assert channel.directory == test_directory / f'videos/one/One Channel'
assert channel.directory.is_dir()
assert channel.tag_name == one.name and channel.tag == one
@pytest.mark.asyncio
async def test_get_or_create_channel_conflict_recovery(async_client, test_session, test_directory):
"""get_or_create_channel should find an existing channel even when source_id and url don't match,
rather than raising ChannelNameConflict."""
from wrolpi.collections import Collection
# Create a channel with a specific source_id and url.
collection = Collection(name='My Channel', kind='channel')
test_session.add(collection)
test_session.flush([collection])
channel = Channel(collection_id=collection.id, source_id='old_source_id',
url='https://www.youtube.com/channel/UCold')
test_session.add(channel)
test_session.commit()
# Calling with different source_id and url but same name should return the existing channel.
result = get_or_create_channel(test_session, source_id='new_source_id',
url='https://www.youtube.com/@mychannel', name='My Channel')
assert result.id == channel.id
@pytest.mark.asyncio
async def test_get_or_create_channel_youtube_channel_url(async_client, test_session, test_directory):
"""get_or_create_channel should find an existing channel when the URL contains a YouTube /channel/ ID
that matches the channel's source_id."""
from wrolpi.collections import Collection
collection = Collection(name='WROLPi', kind='channel')
test_session.add(collection)
test_session.flush([collection])
channel = Channel(collection_id=collection.id, source_id='UC4t8bw1besFTyjW7ZBCOIrw',
url='https://www.youtube.com/@wrolpi')
test_session.add(channel)
test_session.commit()
# A /channel/UCXXX URL should match via source_id extraction.
result = get_or_create_channel(test_session,
url='https://www.youtube.com/channel/UC4t8bw1besFTyjW7ZBCOIrw',
name='WROLPi Channel')
assert result.id == channel.id
@pytest.mark.asyncio
async def test_get_or_create_channel_with_destination(async_client, test_session, test_directory):
"""A new Channel should be created at the destination directory when provided."""
destination = test_directory / 'custom/my_channel'
channel = get_or_create_channel(test_session, source_id='abc', name='My Channel',
url='https://example.com', destination=destination)
assert channel.name == 'My Channel'
assert channel.directory == destination
assert destination.is_dir()
@pytest.mark.asyncio
async def test_get_or_create_channel_destination_conflict(async_client, test_session, test_directory):
"""When destination matches an existing channel's directory, that channel should be returned
instead of trying to create a new one (which would raise ChannelDirectoryConflict)."""
from wrolpi.collections import Collection
# Create an existing channel at the destination directory.
existing_dir = test_directory / 'videos/existing'
existing_dir.mkdir(parents=True)
collection = Collection(name='Existing Channel', kind='channel', directory=existing_dir)
test_session.add(collection)
test_session.flush([collection])
channel = Channel(collection_id=collection.id, source_id='existing')
test_session.add(channel)
test_session.commit()
# Destination matches an existing channel, so it should be returned even though source_id/name/url don't match.
result = get_or_create_channel(test_session, source_id='new_id', name='New Channel',
url='https://example.com', destination=existing_dir)
assert result.id == channel.id
@pytest.mark.asyncio
async def test_get_or_create_channel_destination_nested_conflict(async_client, test_session, test_directory):
"""When destination is nested inside an existing channel's directory, the parent channel should be returned."""
from wrolpi.collections import Collection
parent_dir = test_directory / 'videos/parent_channel'
parent_dir.mkdir(parents=True)
collection = Collection(name='Parent Channel', kind='channel', directory=parent_dir)
test_session.add(collection)
test_session.flush([collection])
channel = Channel(collection_id=collection.id, source_id='parent')
test_session.add(channel)
test_session.commit()
# Nested directory should find the parent channel.
nested_dir = parent_dir / 'nested'
result = get_or_create_channel(test_session, source_id='nested_id', name='Nested Channel',
url='https://example.net', destination=nested_dir)
assert result.id == channel.id
@pytest.mark.asyncio
async def test_get_or_create_channel_computed_directory_conflict(async_client, test_session, test_directory):
"""When no existing channel matches by source_id/url/name, but the directory that would be created
already belongs to a channel, that channel should be returned instead of raising.
YouTube collaboration videos report the primary uploader's channel, which may not match any local
channel by name (e.g. the local channel was renamed) even though its directory is already taken."""
from wrolpi.collections import Collection
existing_dir = test_directory / 'videos/Uploader'
existing_dir.mkdir(parents=True)
collection = Collection(name='Uploader (old name)', kind='channel', directory=existing_dir)
test_session.add(collection)
test_session.flush([collection])
channel = Channel(collection_id=collection.id, source_id='UCold', url='https://www.youtube.com/@olduploader')
test_session.add(channel)
test_session.commit()
result = get_or_create_channel(test_session, source_id='UCnew',
url='https://www.youtube.com/channel/UCnew', name='Uploader')
assert result.id == channel.id
@pytest.mark.asyncio
async def test_get_or_create_channel_existing_ignores_destination(async_client, test_session, test_directory):
"""When a channel already exists (matched by source_id), the destination parameter should be ignored."""
from wrolpi.collections import Collection
existing_dir = test_directory / 'videos/existing'
existing_dir.mkdir(parents=True)
collection = Collection(name='Existing', kind='channel', directory=existing_dir)
test_session.add(collection)
test_session.flush([collection])
channel = Channel(collection_id=collection.id, source_id='match_me', url='https://example.com')
test_session.add(channel)
test_session.commit()
# Even with a different destination, the existing channel is returned.
other_dir = test_directory / 'videos/other'
result = get_or_create_channel(test_session, source_id='match_me', name='Existing',
destination=other_dir)
assert result.id == channel.id
assert result.directory == existing_dir
@pytest.mark.asyncio
async def test_get_channel_prefers_destination_over_ytdlp(async_client, test_session, test_directory, channel_factory):
"""When yt-dlp metadata identifies ChannelA but the download destination is ChannelB's directory,
_get_channel should return ChannelB (the destination's channel)."""
channel_a = channel_factory(source_id='channel_a_id', name='ChannelA',
url='https://example.com/channel_a')
channel_b = channel_factory(source_id='channel_b_id', name='ChannelB',
url='https://example.com/channel_b')
# Download has yt-dlp metadata matching ChannelA, but destination is ChannelB's directory.
download = Download(
url='https://example.com/video1',
destination=channel_b.directory,
info_json={'channel': 'ChannelA', 'channel_id': 'channel_a_id',
'channel_url': 'https://example.com/channel_a'},
)
channel = await VideoDownloader._get_channel(test_session, download)
assert channel.id == channel_b.id, 'Should prefer the destination channel over yt-dlp metadata'
@pytest.mark.asyncio
async def test_get_channel_uses_ytdlp_when_destination_matches(async_client, test_session, test_directory,
channel_factory):
"""When yt-dlp metadata identifies ChannelA and the download destination is also ChannelA's directory,
_get_channel should return ChannelA (no conflict)."""
channel_a = channel_factory(source_id='channel_a_id', name='ChannelA',
url='https://example.com/channel_a')
download = Download(
url='https://example.com/video1',
destination=channel_a.directory,
info_json={'channel': 'ChannelA', 'channel_id': 'channel_a_id',
'channel_url': 'https://example.com/channel_a'},
)
channel = await VideoDownloader._get_channel(test_session, download)
assert channel.id == channel_a.id
def test_channel_downloader_hidden(video_download_manager):
"""
ChannelDownloader should not be presented to the User.
"""
downloaders = video_download_manager.list_downloaders()
assert [i.__json__() for i in downloaders] == [(dict(name='video', pretty_name='Videos')), ]
def test_bad_downloader(test_session, video_download_manager):
"""
Attempting to use an unknown downloader should raise an error.
"""
with pytest.raises(InvalidDownload):
video_download_manager.create_download(test_session, 'https://example.com', downloader_name='bad downloader')
@pytest.mark.parametrize('url,expected', [
('https://www.youtube.com/feeds/videos.xml?channel_id=UC2JGH5ZBtWQJuTYCyfI3M3Q', True),
('http://youtube.com/feeds/videos.xml?channel_id=UC123', True),
('https://www.youtube.com/feeds/videos.xml', True),
('https://www.youtube.com/@SomeChannel/videos', False),
('https://www.youtube.com/watch?v=abc123', False),
('https://example.com/feeds/videos.xml', False),
('', False),
(None, False),
])
def test_is_youtube_rss_feed_url(url, expected):
"""Detect YouTube RSS feed URLs so ChannelDownloader can refuse to process them."""
assert is_youtube_rss_feed_url(url) is expected
@pytest.mark.asyncio
async def test_channel_downloader_rejects_rss_feed_url(test_session, mock_video_extract_info):
"""ChannelDownloader must refuse YouTube RSS feed URLs.
Previously, yt-dlp would return no `uploader`/`channel_id` for an RSS feed, so
`name = info.get('uploader') or info.get('webpage_url_basename')` fell back to
'videos.xml' and `source_id` fell back to 'videos', creating a junk channel named
'videos.xml' in directory 'videos/videos.xml' that never received any videos.
"""
download = Download(
url='https://www.youtube.com/feeds/videos.xml?channel_id=UC2JGH5ZBtWQJuTYCyfI3M3Q',
downloader=channel_downloader.name,
)
test_session.add(download)
test_session.commit()
# If the rejection was missing, ChannelDownloader would call extract_info and create a channel.
mock_video_extract_info.return_value = {
'id': 'videos',
'webpage_url_basename': 'videos.xml',
'entries': [],
}
with pytest.raises(UnrecoverableDownloadError):
# RSS-feed rejection happens in the prepare phase (sync), so we don't even
# need to drive execute_download here.
channel_downloader.prepare_download(test_session, download)
# No Channel should have been created from the RSS URL.
assert test_session.query(Channel).count() == 0
mock_video_extract_info.assert_not_called()
@pytest.mark.asyncio
async def test_video_download(test_session, test_directory, mock_video_extract_info, simple_channel, await_switches,
video_download_manager, mock_video_process_runner, image_file):
"""A video download is performed, files are grouped."""
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)
# Create a poster file which was downloaded.
poster_path = video_path.with_suffix('.png')
image_file.rename(poster_path)
# `formats` should be cleaned from info json.
info_json_path = video_path.with_suffix('.info.json')
info_json_path.write_text(json.dumps({'duration': 5, 'formats': [1, 2, 3]}))
# .part file should be deleted
part_path = video_path.with_suffix('.mp4.part')
part_path.touch()
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()
mock_video_process_runner.assert_called_once()
download, cmd, out_dir = mock_video_process_runner.call_args[0]
download: Download = test_session.query(Download).one()
assert download.url in cmd
assert test_session.query(Channel).one()
video: Video = test_session.query(Video).one()
assert video.video_path.is_absolute() and video.video_path == video_path, 'Video path is not absolute'
assert video.video_path.is_absolute() and video.poster_path == poster_path, 'Video poster was not discovered'
assert not any([i for i in video.file_group.my_paths() if i.name.endswith('.part')]), \
'Part file should not be in group'
assert not part_path.exists(), 'Part file should be deleted.'
# Some info json data is deleted during download.
assert video.get_info_json(), 'Download should keep info json file'
assert 'duration' in video.get_info_json(), 'Duration should stay in info json'
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 (`<stem>.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.
Regression: `Video.get_by_url` was called with its arguments swapped — `(url, session)` instead of
`(session, url)` — which raised `AttributeError: 'str' object has no attribute 'query'`."""
source_id = 'video-source-id'
url = 'https://example.com/the-video'
video = video_factory(with_info_json={'id': source_id}, source_id=source_id)
video.file_group.url = url
test_session.commit()
download = Download(url=url, info_json={'id': source_id}, settings={})
# The info_json download itself fails here, but the buggy existing-video lookup runs first.
with mock.patch('modules.videos.downloader.download_video_info_json') as mock_download:
mock_download.side_effect = Exception('no network')
result = await VideoDownloader.download_info_json(download, video.video_path)
assert result.success is False, 'Download should fail gracefully once the info json download fails'
mock_download.assert_called_once_with(url)
@pytest.mark.asyncio
async def test_download_result(test_session, test_directory, video_download_manager, mock_video_process_runner,
mock_video_extract_info, image_file, await_switches):
"""VideoDownloader returns a DownloadResult when complete."""
channel_directory = test_directory / 'videos/channel name'
channel_directory.mkdir(parents=True)
video_file = channel_directory / 'the video.mp4'
shutil.copy(PROJECT_DIR / 'test/big_buck_bunny_720p_1mb.mp4', video_file)
image_file.rename(video_file.with_suffix('.jpg'))
mock_video_extract_info.return_value = example_video_json
with mock.patch('modules.videos.downloader.prepare_video_filename') as mock_prepare_filename:
mock_prepare_filename.return_value = [video_file, {'id': 'foo'}]
video_download_manager.create_download(test_session, 'https://example.com', video_downloader.name)
await video_download_manager.wait_for_all_downloads()
# Sleep to allow background tasks to finish.
await asyncio.sleep(1)
download: Download = test_session.query(Download).one()
assert download.url == 'https://example.com'
video = test_session.query(Video).one()
assert download.location == f'/videos/{video.file_group_id}'
# Video has its files.
video = test_session.query(Video).one()
assert video.video_path.is_file(), 'Video file was not found.'
assert video.poster_path.is_file(), 'Poster file was not found.'
@pytest.mark.asyncio
async def test_download_destination(test_session, test_directory, video_download_manager, await_switches,
mock_video_process_runner, mock_video_extract_info, mock_video_prepare_filename):
"""A Video can be downloaded to a directory other than it's Channel's directory."""
# yt-dlp would return the video json.
mock_video_extract_info.return_value = example_video_json
# This result will be ignored during the test, we just need some value so the download does not fail.
mock_video_prepare_filename.return_value = str(test_directory / 'test video.mp4')
video_download_manager.create_download(test_session, 'https://example.com/1', downloader_name=VideoDownloader.name)
await video_download_manager.wait_for_all_downloads()
# Output directory matches the channel directory.
assert mock_video_prepare_filename.call_args_list[0].kwargs['ydl'].params['outtmpl']['default'] \
.startswith(f'{test_directory}/videos/channel name/%(uploader)s')
mock_video_prepare_filename.reset_mock()
video_download_manager.create_download(test_session, 'https://example.com/2', downloader_name=VideoDownloader.name,
destination=f'{test_directory}/custom')
await video_download_manager.wait_for_all_downloads()
# Output directory matches the custom directory specified.
assert mock_video_prepare_filename.call_args_list[0].kwargs['ydl'].params['outtmpl']['default'] \
.startswith(f'{test_directory}/custom/%(uploader)s')
example_playlist_json = {
'_type': 'playlist',
'availability': None,
'channel': 'the channel name',
'channel_follower_count': None,
'channel_id': 'the channel id',
'channel_url': 'channel url',
'description': '',
'entries': [
{'_type': 'url',
'description': None,
'duration': None,
'id': 'video 2 id',
'ie_key': 'Youtube',
'title': 'video 2 title',
'uploader': None,
'upload_date': '20241108',
'url': 'https://www.youtube.com/shorts/video_2_url',
'view_count': 1413,
'webpage_url': 'https://www.youtube.com/shorts/video_2_url'},
{'_type': 'url',
'description': None,
'duration': None,
'id': 'video 1 id',
'ie_key': 'Youtube',
'title': 'video 1 title',
'uploader': None,
'upload_date': '20241108',
'url': 'https://www.youtube.com/watch?v=video_1_url',
'view_count': 58504,
'webpage_url': 'https://www.youtube.com/watch?v=video_1_url'},
{'_type': 'url',
'description': None,
'duration': None,
'id': 'video 3 id',
'ie_key': 'Youtube',
'title': 'video 3 title',
'uploader': None,
'upload_date': '20241108',
'url': 'https://youtube.com/watch?v=video_3_url',
'view_count': 58504,
'webpage_url': 'https://youtube.com/watch?v=video_3_url'},
],
'extractor': 'youtube:tab',
'extractor_key': 'YoutubeTab',
'id': 'the playlist id',
'modified_date': '20220426',
'original_url': 'original url',
'playlist_count': 10,
'requested_entries': None,
'tags': [],
'title': 'some title',
'uploader': 'Playlist Uploader',
'uploader_id': 'uploader id',
'uploader_url': 'uploader url',
'view_count': 22298,
'webpage_url': 'webpage url',
'webpage_url_basename': 'playlist',
'webpage_url_domain': 'youtube.com',
}
@pytest.mark.asyncio
async def test_download_playlist(test_session, test_directory, mock_video_extract_info, video_download_manager,
video_file):
"""All videos in a playlist can be downloaded for it's Channel."""
download = Download(url='https://example.com/playlist-url')
test_session.add(download)
channel = get_or_create_channel(test_session, source_id=example_playlist_json['channel_id'], url=download.url,
name=example_channel_json['uploader'])
video = Video.from_paths(test_session, video_file)
video.channel_id = channel.id
video.source_id = 'video 1 id'
video.file_group.url = 'https://www.youtube.com/watch?v=video_1_url'
test_session.commit()
mock_video_extract_info.return_value = example_playlist_json # Playlist info is fetched first.
# Drive the phase-split API directly: prepare → execute → finalize. No dispatch
# loop, so child video downloads are never spawned here — no need to stub them.
from modules.archive.conftest import make_test_ctx
prepared = channel_downloader.prepare_download(test_session, download)
executed = await channel_downloader.execute_download(prepared, make_test_ctx(), download=download)
result = channel_downloader.finalize_download(test_session, download, executed)
assert result.success is True, 'Download was not successful'
assert set(result.downloads) == {
'https://www.youtube.com/watch?v=video_2_url', # Shorts is converted to regular URL.
'https://youtube.com/watch?v=video_3_url',
}
def test_get_missing_videos_views_order(test_session):
"""The 'views' download order sorts videos most-viewed-first. Entries missing a `view_count`
(some listings omit it) sort last rather than raising a KeyError."""
from modules.videos.downloader import channel_downloader
download = Download(
url='https://www.youtube.com/@example/videos',
settings={'download_order': 'views'},
info_json={'entries': [
{'_type': 'url', 'id': 'a', 'title': 'a', 'url': 'https://youtube.com/watch?v=a', 'view_count': 918},
{'_type': 'url', 'id': 'b', 'title': 'b', 'url': 'https://youtube.com/watch?v=b'}, # No view_count.
{'_type': 'url', 'id': 'c', 'title': 'c', 'url': 'https://youtube.com/watch?v=c', 'view_count': 3000},
]},
)
test_session.add(download)
test_session.commit()
missing_videos = channel_downloader.get_missing_videos(test_session, download)
assert missing_videos == [
'https://youtube.com/watch?v=c',
'https://youtube.com/watch?v=a',
'https://youtube.com/watch?v=b',
]
def test_get_missing_videos_views_order_no_view_counts(test_session):
"""When no entry has a `view_count` (old yt-dlp does not extract it from YouTube's lockupViewModel
listings), the 'views' download order fails cleanly rather than downloading in arbitrary order."""
from modules.videos.downloader import channel_downloader
download = Download(
url='https://www.youtube.com/@example/videos',
settings={'download_order': 'views'},
info_json={'entries': [
{'_type': 'url', 'id': 'a', 'title': 'a', 'url': 'https://youtube.com/watch?v=a'},
{'_type': 'url', 'id': 'b', 'title': 'b', 'url': 'https://youtube.com/watch?v=b'},
]},
)
test_session.add(download)
test_session.commit()
with pytest.raises(UnrecoverableDownloadError):
channel_downloader.get_missing_videos(test_session, download)
@pytest.mark.asyncio
async def test_channel_download_crud(test_session, async_client, assert_downloads, tag_factory):
"""Test creating more complex Channel Download."""
one, two = await tag_factory(), await tag_factory()
download = {
'urls': ['https://www.youtube.com/@wrolpi/videos'],
'destination': '',
'downloader': 'video_channel',
'sub_downloader': 'video',
'frequency': 2592000,
'tag_names': [one.name, two.name],
'settings': {
'channel_tag_name': [one.name, ],
'download_order': 'newest',
'maximum_duration': 600,
'minimum_duration': 60,
'title_exclude': 'foo,bar',
'title_include': 'baz,qux',
'video_count_limit': 100,
'video_resolutions': ['1080p', '720p', '480p', '360p', 'maximum'],
'video_format': 'mp4',
}
}
request, response = await async_client.post('/api/download', json=download)
assert response.status == HTTPStatus.CREATED, request.body
download = test_session.query(Download).one()
assert download.url == 'https://www.youtube.com/@wrolpi/videos'
assert download.destination is None
assert download.tag_names == ['one', 'two']
assert download.downloader == 'video_channel'
assert download.sub_downloader == 'video'
assert download.frequency == 2592000
assert download.settings
assert download.settings['channel_tag_name'] == ['one']
assert download.settings['download_order'] == 'newest'
assert download.settings['maximum_duration'] == 600
assert download.settings['minimum_duration'] == 60
assert download.settings['title_exclude'] == 'foo,bar'
assert download.settings['title_include'] == 'baz,qux'
assert download.settings['video_count_limit'] == 100
assert download.settings['video_resolutions'] == ['1080p', '720p', '480p', '360p', 'maximum']
assert download.settings['video_format'] == 'mp4'
def test_preview_filename(test_directory, fake_now):
assert preview_filename('%(uploader)s_%(upload_date)s_%(id)s_%(title)s.%(ext)s') \
== 'WROLPi_20000101_Qz-FuenRylQ_The title of the video.mp4'
assert preview_filename('%(upload_date)s_%(id)s_%(title)s.%(ext)s') \
== '20000101_Qz-FuenRylQ_The title of the video.mp4'
assert preview_filename('%(upload_date)s_%(title)s.%(ext)s') \
== '20000101_The title of the video.mp4'
assert preview_filename('%(upload_date)s_%(description)s.%(ext)s') \
== '20000101_A description of the video.mp4'
assert preview_filename('%(timestamp)s_%(title)s.%(ext)s') \
== '946684800_The title of the video.mp4'
with pytest.raises(RuntimeError):
preview_filename('%(upload_date)s_%(title)s.asdf')
def test_convert_wrolpi_filename_format():
"""WROLPi filename variables are converted to yt-dlp syntax."""
from modules.videos.downloader import convert_wrolpi_filename_format
# Test upload_year conversion
assert convert_wrolpi_filename_format('%(upload_year)s/%(title)s.%(ext)s') \
== '%(upload_date>%Y)s/%(title)s.%(ext)s'
# Test upload_month conversion
assert convert_wrolpi_filename_format('%(upload_month)s/%(title)s.%(ext)s') \
== '%(upload_date>%m)s/%(title)s.%(ext)s'
# Test both together
assert convert_wrolpi_filename_format('%(upload_year)s/%(upload_month)s/%(title)s.%(ext)s') \
== '%(upload_date>%Y)s/%(upload_date>%m)s/%(title)s.%(ext)s'
# Test with other variables unchanged
format_str = '%(upload_year)s/%(uploader)s_%(upload_date)s_%(id)s_%(title)s.%(ext)s'
expected = '%(upload_date>%Y)s/%(uploader)s_%(upload_date)s_%(id)s_%(title)s.%(ext)s'
assert convert_wrolpi_filename_format(format_str) == expected
# Test format without WROLPi variables unchanged
original = '%(uploader)s_%(upload_date)s_%(id)s_%(title)s.%(ext)s'
assert convert_wrolpi_filename_format(original) == original
def test_preview_filename_with_upload_year(test_directory, fake_now):
"""preview_filename works with upload_year and upload_month variables."""
# Year only
assert preview_filename('%(upload_year)s/%(title)s.%(ext)s') \
== '2000/The title of the video.mp4'
# Month only
assert preview_filename('%(upload_month)s/%(title)s.%(ext)s') \
== '01/The title of the video.mp4'
# Year and month together
assert preview_filename('%(upload_year)s/%(upload_month)s/%(title)s.%(ext)s') \
== '2000/01/The title of the video.mp4'
# Combined with traditional format
assert preview_filename('%(upload_year)s/%(uploader)s_%(upload_date)s_%(title)s.%(ext)s') \
== '2000/WROLPi_20000101_The title of the video.mp4'
def test_preview_filename_matches_youtubedl(test_directory, fake_now):
"""Ensure our lightweight preview_filename() matches YoutubeDL's actual output.
This test validates that our simple string formatting produces identical
results to YoutubeDL's prepare_filename(). If yt-dlp changes behavior,
this test will catch it.
"""
from copy import deepcopy
from yt_dlp import YoutubeDL
from wrolpi.dates import now
test_formats = [
'%(uploader)s_%(upload_date)s_%(id)s_%(title)s.%(ext)s',
'%(upload_date)s_%(id)s_%(title)s.%(ext)s',
'%(upload_year)s/%(upload_month)s/%(title)s.%(ext)s',
'%(upload_year)s/%(uploader)s_%(upload_date)s_%(title)s.%(ext)s',
'%(channel)s/%(id)s.%(ext)s',
]
# Sample entry used by both methods - must match preview_filename's variables
sample_date = now()
entry = dict(
uploader='WROLPi',
channel='WROLPi',
timestamp=int(sample_date.timestamp()),
upload_date=sample_date.strftime('%Y%m%d'),
id='Qz-FuenRylQ',
title='The title of the video',
ext='mp4',
description='A description of the video',
)
for fmt in test_formats:
# Get result from our lightweight implementation
lightweight_result = preview_filename(fmt)
# Get result from actual YoutubeDL
converted_format = convert_wrolpi_filename_format(fmt)
options = get_videos_downloader_config().yt_dlp_options
options = deepcopy(options)
options['outtmpl'] = converted_format
ydl = YoutubeDL(options)
ytdlp_result = prepare_filename(entry, ydl=ydl).lstrip('/')
assert lightweight_result == ytdlp_result, \
f"Mismatch for format '{fmt}':\n lightweight: {lightweight_result}\n ytdlp: {ytdlp_result}"
def test_normalize_video_file_names(test_directory, video_download_manager):
"""VideoDownloader renames caption files that do not share the video file's stem."""
downloader = VideoDownloader()
mp4 = test_directory / 'video.mp4'
info_json = test_directory / 'video.info.json'
# Minimal video files.
vtt = test_directory / 'video.vtt'
assert set(downloader.normalize_video_file_names(mp4, [mp4, vtt, info_json])) == {mp4, vtt, info_json}
# Normal video files.
vtt = test_directory / 'video.en.vtt'
assert set(downloader.normalize_video_file_names(mp4, [mp4, vtt, info_json])) == {mp4, vtt, info_json}
# Rare -auto file.
vtt = test_directory / 'video.en-auto.vtt'
assert set(downloader.normalize_video_file_names(mp4, [mp4, vtt, info_json])) == {mp4, vtt, info_json}
# Extra characters get removed from the vtt file.
vtt = test_directory / 'video.en-uYU-mmqFLq8.vtt'
vtt.touch() # must exist so it can be renamed.
assert set(downloader.normalize_video_file_names(mp4, [mp4, vtt, info_json])) == {
mp4,
(test_directory / 'video.en.vtt'),
info_json,
}
assert not vtt.exists()
# VTT suffix that does not match expected pattern is left unchanged.
vtt = test_directory / 'video.unknown_format.vtt'
vtt.touch()
assert set(downloader.normalize_video_file_names(mp4, [mp4, vtt, info_json])) == {mp4, vtt, info_json}
assert vtt.exists()
@pytest.mark.asyncio
async def test_channel_downloader_sets_collection_id(test_session, channel_factory, mock_video_extract_info,
video_download_manager, await_switches):
"""Test that ChannelDownloader.do_download sets download.collection_id to the channel's collection.
When a channel download runs, it should link the download to the channel's collection immediately.
"""
channel = channel_factory(url='https://example.com/channel', source_id='UCtest123')
download = Download(
url='https://example.com/channel',
downloader=channel_downloader.name,
frequency=99,
)
test_session.add(download)
test_session.commit()
assert not download.collection_id, 'Download should not be linked yet'
# Mock extract_info to return channel info
mock_info = {
'id': 'UCtest123',
'channel_id': 'UCtest123',
'uploader': channel.name,
'entries': [],
'webpage_url_basename': 'videos',
}
mock_video_extract_info.return_value = mock_info
# Drive the phase-split API directly — no dispatch loop fires, so child video
# downloads are never spawned and don't need stubbing. refresh_files and
# file_worker.wait_for_job are still patched because ChannelDownloader's
# prepare_channel_for_downloads calls them inline.
with mock.patch.object(Channel, 'refresh_files', return_value='test-job-id'), \
mock.patch('modules.videos.downloader.file_worker.wait_for_job', new_callable=mock.AsyncMock):
from modules.archive.conftest import make_test_ctx
prepared = channel_downloader.prepare_download(test_session, download)
executed = await channel_downloader.execute_download(prepared, make_test_ctx(), download=download)
channel_downloader.finalize_download(test_session, download, executed)
await await_switches()
test_session.refresh(download)
assert download.collection_id == channel.collection_id, \
'ChannelDownloader should link download to channel collection'
@pytest.mark.asyncio
async def test_channel_passes_all_inheritable_settings_to_video(test_session, channel_factory,
mock_video_extract_info,
video_download_manager, await_switches):
"""ChannelDownloader passes all CHANNEL_INHERITABLE_SETTINGS to child video downloads."""
from modules.videos.lib import CHANNEL_INHERITABLE_SETTINGS
channel = channel_factory(url='https://example.com/channel', source_id='UCtest456')
channel_settings = {
'video_resolutions': ['720p'],
'video_format': 'mkv',
'writesubtitles': False,
'sleep_requests': 3.0,
'user_agent': 'TestBot/1.0',
'video_codecs': ['h264'],
'audio_codecs': ['aac'],
'transcode': True,
'strict_codecs': True,
}
download = Download(
url='https://example.com/channel',
downloader=channel_downloader.name,
frequency=99,
settings=channel_settings,
)
test_session.add(download)
test_session.commit()
mock_info = {
'id': 'UCtest456',
'channel_id': 'UCtest456',
'uploader': channel.name,
'entries': [{'id': 'vid1', 'url': 'https://youtube.com/watch?v=vid1', 'title': 'Video 1'}],
'webpage_url_basename': 'videos',
}
mock_video_extract_info.return_value = mock_info
# Direct phase-split call — child video downloads are never dispatched here.
with mock.patch.object(Channel, 'refresh_files', return_value='test-job-id'), \
mock.patch('modules.videos.downloader.file_worker.wait_for_job', new_callable=mock.AsyncMock):
from modules.archive.conftest import make_test_ctx
prepared = channel_downloader.prepare_download(test_session, download)
executed = await channel_downloader.execute_download(prepared, make_test_ctx(), download=download)
result = channel_downloader.finalize_download(test_session, download, executed)
# Verify all channel-level inheritable settings are passed through
for key in ('video_resolutions', 'video_format', 'writesubtitles', 'sleep_requests', 'user_agent',
'video_codecs', 'audio_codecs', 'transcode', 'strict_codecs'):
assert key in result.settings, f'{key} missing from DownloadResult.settings'
assert result.settings[key] == channel_settings[key], \
f'{key}: expected {channel_settings[key]}, got {result.settings[key]}'
@pytest.mark.asyncio
async def test_channel_does_not_pass_continue_dl_nooverwrites(test_session, channel_factory,
mock_video_extract_info,
video_download_manager, await_switches):
"""continue_dl and nooverwrites should NOT be passed from channel to video."""
channel = channel_factory(url='https://example.com/channel', source_id='UCtest789')
channel_settings = {
'continue_dl': False,
'nooverwrites': False,
'video_format': 'mkv',
}
download = Download(
url='https://example.com/channel',
downloader=channel_downloader.name,
frequency=99,
settings=channel_settings,
)
test_session.add(download)
test_session.commit()
mock_info = {
'id': 'UCtest789',
'channel_id': 'UCtest789',
'uploader': channel.name,
'entries': [{'id': 'vid2', 'url': 'https://youtube.com/watch?v=vid2', 'title': 'Video 2'}],
'webpage_url_basename': 'videos',
}
mock_video_extract_info.return_value = mock_info
# Direct phase-split call — child video downloads are never dispatched here.
with mock.patch.object(Channel, 'refresh_files', return_value='test-job-id'), \
mock.patch('modules.videos.downloader.file_worker.wait_for_job', new_callable=mock.AsyncMock):
from modules.archive.conftest import make_test_ctx
prepared = channel_downloader.prepare_download(test_session, download)
executed = await channel_downloader.execute_download(prepared, make_test_ctx(), download=download)
result = channel_downloader.finalize_download(test_session, download, executed)
assert 'continue_dl' not in result.settings, 'continue_dl should not be passed to child video downloads'
assert 'nooverwrites' not in result.settings, 'nooverwrites should not be passed to child video downloads'
assert result.settings['video_format'] == 'mkv', 'video_format should be passed through'
@pytest.mark.asyncio
async def test_video_download_uses_effective_settings(test_session, test_directory, mock_video_extract_info,
video_download_manager, mock_video_process_runner,
image_file, simple_channel, test_videos_downloader_config):
"""VideoDownloader uses merged effective settings from global + download overrides."""
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)
image_file.rename(video_path.with_suffix('.jpg'))
url = 'https://www.youtube.com/watch?v=31jPEBiAC3c'
# Download with writesubtitles=False override (global default is True)
settings = {'writesubtitles': False, 'sleep_requests': 5.0}
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, settings=settings)
await video_download_manager.wait_for_all_downloads()
mock_video_process_runner.assert_called_once()
download, cmd, out_dir = mock_video_process_runner.call_args[0]
# writesubtitles=False means no --write-subs in command
assert '--write-subs' not in cmd, 'writesubtitles=False should prevent --write-subs'
# sleep_requests=5.0 should appear
assert '--sleep-requests' in cmd, 'sleep_requests override should appear in command'
sleep_idx = cmd.index('--sleep-requests')
assert cmd[sleep_idx + 1] == '5.0', f'Expected sleep_requests=5.0, got {cmd[sleep_idx + 1]}'
@pytest.mark.parametrize('video_resolutions', [
['1080p', '720p', '480p', 'maximum'], # The default.
['480p'],
['2160p', 'maximum'],
])
def test_format_selector_contains_no_comma(video_resolutions):
"""`format_selector` joins selectors with "/" so yt-dlp downloads only the first match.
A "," would tell yt-dlp to download *every* matching selector, which downloads and
post-processes the same video once per match.
"""
selector = format_selector(video_resolutions)
assert ',' not in selector, f'A comma makes yt-dlp download every matching format: {selector}'
# Every selector of every requested resolution is present, in order, as a "/" fallback.
expected = [j for i in video_resolutions for j in VIDEO_RESOLUTION_MAP[i]]
assert selector == '/'.join(expected)
@pytest.mark.asyncio
async def test_video_download_requests_one_format(test_session, test_directory, mock_video_extract_info,
video_download_manager, mock_video_process_runner,
image_file, simple_channel, test_videos_downloader_config):
"""The `-f` passed to yt-dlp is a fallback chain, so only one format is downloaded."""
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)
image_file.rename(video_path.with_suffix('.jpg'))
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()
mock_video_process_runner.assert_called_once()
download, cmd, out_dir = mock_video_process_runner.call_args[0]
assert '-f' in cmd, 'A video download must request formats'
format_arg = cmd[cmd.index('-f') + 1]
assert ',' not in format_arg, \
f'A comma makes yt-dlp download the video once per matching format: {format_arg}'
# The multiple resolutions the config requests are all still offered, as fallbacks.
assert format_arg == format_selector(get_videos_downloader_config().video_resolutions)
@pytest.mark.asyncio
async def test_live_video_retry_seconds(test_session, test_directory, mock_video_extract_info,
video_download_manager, mock_video_process_runner,
simple_channel):
"""A live video download should return retry_seconds=3600 so it retries in 1 hour."""
from wrolpi.cmd import CommandResult
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 output when a video is live.
mock_video_process_runner.return_value = CommandResult(
return_code=0, cancelled=False,
stdout=b'does not pass filter (!is_live), skipping ..', stderr=b'', elapsed=0,
)
url = 'https://www.youtube.com/watch?v=live_video'
mock_video_extract_info.return_value = example_video_json
with mock.patch('modules.videos.downloader.prepare_video_filename') as mock_prepare_filename:
# Return a path that won't exist (video wasn't downloaded because it's live).
mock_prepare_filename.return_value = (simple_channel.directory / 'live video.mp4', {'id': 'foo'})
download = Download(url=url, downloader='video', attempts=0)
# Drive the phase-split API directly: when execute_download detects a live video
# it short-circuits with a DownloadResult (the dispatch surfaces it verbatim and
# skips finalize_download).
from modules.archive.conftest import make_test_ctx
prepared = video_downloader.prepare_download(test_session, download)
result = await video_downloader.execute_download(prepared, make_test_ctx(), download=download)
assert result.success is False
assert result.retry_seconds == 3600
assert 'live' in result.error.lower()
def test_bot_blocked_detection():
"""Bot detection errors should be identified by _bot_blocked."""
assert _bot_blocked("Sign in to confirm you're not a bot") is True
assert _bot_blocked("Sign in to confirm you\u2019re not a bot") is True
assert _bot_blocked("cookies are no longer valid") is True
assert _bot_blocked("ERROR: Sign in to confirm you're not a bot. Use --cookies") is True
# These should NOT be detected as bot-blocked.
assert _bot_blocked("404: Not Found") is False
assert _bot_blocked("requires payment") is False
assert _bot_blocked("some random error") is False
def test_skip_download_excludes_bot_blocked():
"""Bot-blocked errors should NOT cause a video to be skipped (added to skip list)."""
# Bot-blocked errors should not be skipped (they should stop all downloads instead).
assert _skip_download("Sign in to confirm you're not a bot") is False
assert _skip_download("cookies are no longer valid") is False
# Regular "Sign in" errors that are NOT bot-related should still be skipped.
assert _skip_download("Sign in to view this video") is True
# 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.prepare_video_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
def test_parse_ytdlp_progress():
"""yt-dlp progress lines are parsed correctly."""
line = '[download] 50.0% of ~ 45.67MiB at 2.34MiB/s ETA 00:19'
result = parse_ytdlp_progress(line)
assert result is not None
total = int(45.67 * 1024 ** 2)
assert result['total_bytes'] == total
assert result['percent'] == 50
assert result['bytes_downloaded'] == int(0.5 * total)
assert result['speed'] == int(2.34 * 1024 ** 2)
assert result['eta'] == '00:19'
def test_parse_ytdlp_progress_no_estimated():
"""yt-dlp progress lines without ~ estimated marker are parsed."""
line = '[download] 100% of 45.67MiB at 3.45MiB/s ETA 00:00'
result = parse_ytdlp_progress(line)
assert result is not None
assert result['percent'] == 100
assert result['total_bytes'] == int(45.67 * 1024 ** 2)
assert result['speed'] == int(3.45 * 1024 ** 2)
assert result['eta'] == '00:00'
def test_parse_ytdlp_progress_small_percent():
"""yt-dlp progress lines with small percentages are parsed."""
line = '[download] 0.1% of ~ 45.67MiB at 1.23MiB/s ETA 00:37'
result = parse_ytdlp_progress(line)
assert result is not None
assert result['percent'] == 0
assert result['eta'] == '00:37'
def test_parse_ytdlp_progress_completion_line():
"""The final yt-dlp line with 'in' instead of 'at/ETA' returns None (no speed/ETA)."""
line = '[download] 100% of 45.67MiB in 00:13'
result = parse_ytdlp_progress(line)
assert result is None
def test_parse_ytdlp_progress_not_progress_line():
"""Non-progress lines return None."""
assert parse_ytdlp_progress('[info] Downloading video 1 of 1') is None
assert parse_ytdlp_progress('') is None
assert parse_ytdlp_progress('[download] Destination: video.mp4') is None
@pytest.mark.asyncio
async def test_fetch_video_duration_persistent_cache(test_download_cache_config):
"""Video durations are persisted to the download cache config and reused on subsequent calls."""
from modules.videos.downloader import _cache_video_duration
from wrolpi.downloader import get_download_cache_config
url = 'https://www.youtube.com/watch?v=test123'
# Verify cache is initially empty.
assert get_download_cache_config().video_durations == []
# Persist a duration directly.
_cache_video_duration(url, 120)
assert get_download_cache_config().video_durations == [[url, 120]]
# Re-caching the same URL updates the entry (no duplicate).
_cache_video_duration(url, 200)
assert get_download_cache_config().video_durations == [[url, 200]]
@pytest.mark.asyncio
async def test_fetch_video_duration_cache_eviction(test_download_cache_config):
"""Video duration cache evicts oldest entries when over the max size."""
from modules.videos.downloader import _cache_video_duration, VIDEO_DURATION_CACHE_MAX_SIZE
from wrolpi.downloader import get_download_cache_config
# Fill to max size.
for i in range(VIDEO_DURATION_CACHE_MAX_SIZE):
_cache_video_duration(f'https://example.com/video{i}', i)
assert len(get_download_cache_config().video_durations) == VIDEO_DURATION_CACHE_MAX_SIZE
# Adding one more should evict the oldest (video0), keeping it at max size.
_cache_video_duration('https://example.com/new_video', 999)
durations = get_download_cache_config().video_durations
assert len(durations) == VIDEO_DURATION_CACHE_MAX_SIZE
# Newest entry is at the end.
assert durations[-1] == ['https://example.com/new_video', 999]
# Oldest entry (video0) was evicted.
durations_dict = dict(durations)
assert 'https://example.com/video0' not in durations_dict
# Second oldest (video1) is still present.
assert durations_dict['https://example.com/video1'] == 1
@pytest.mark.asyncio
async def test_channel_download_preserves_skip_list(test_session, test_directory, simple_channel,
video_download_manager, video_file,
mock_video_extract_info, mock_video_prepare_filename,
await_switches, mock_video_process_runner,
test_download_manager_config):
"""A skipped video URL should remain in the skip list when its channel downloads again.
Regression: child video downloads created by ChannelDownloader used reset_attempts=True which
caused create_downloads to remove the URL from the skip list."""
skipped_url = 'https://youtube.com/watch?v=video_1_url'
url = 'https://www.youtube.com/c/ExampleChannel/videos'
# Skip video_1_url — simulating user skipping a video.
get_download_manager_config().skip_urls = [skipped_url]
assert get_download_manager_config().skip_urls == [skipped_url]
mock_video_prepare_filename.return_value = video_file
mock_video_extract_info.return_value = example_channel_json
with mock.patch('modules.videos.downloader.get_channel') as mock_get_channel:
mock_get_channel.return_value = simple_channel
simple_channel.get_or_create_download(test_session, url, 60)
# Download channel catalog.
await video_download_manager.wait_for_all_downloads()
# Download the child videos.
await video_download_manager.wait_for_all_downloads()
# The skipped URL must still be in the skip list.
assert skipped_url in get_download_manager_config().skip_urls, \
'Channel download removed a URL from the skip list!'
# Only video_2 should have been downloaded, not the skipped video_1.
once_downloads = video_download_manager.get_once_downloads(test_session)
video_downloads = [i for i in once_downloads if 'watch' in i.url]
assert {i.url for i in video_downloads} == {'https://youtube.com/watch?v=video_2_url'}
# ---------------------------------------------------------------------------
# Phase-split unit tests for VideoDownloader.prepare_download.
#
# These exercise the sync prep invariants in isolation — no async_client, no
# extract_info, no subprocess. Heavier execute/finalize behavior is covered by
# the integration tests above (which drive through video_download_manager).
# ---------------------------------------------------------------------------
from modules.videos.downloader import PreparedVideo # noqa: E402
def test_prepare_max_attempts_raises(test_session):
"""prepare_download enforces the 10-attempt cap (videos retry more than other
downloaders because YouTube transients are common)."""
download = Download(url='https://youtube.com/watch?v=foo', downloader='video', attempts=10)
with pytest.raises(UnrecoverableDownloadError, match='Max download attempts'):
video_downloader.prepare_download(test_session, download)
def test_prepare_normalizes_url(test_session):
"""Shorts URLs become watch URLs in the PreparedVideo (matches DB-stored format)."""
download = Download(
url='https://www.youtube.com/shorts/ABC123',
downloader='video',
settings={},
)
prepared = video_downloader.prepare_download(test_session, download)
assert isinstance(prepared, PreparedVideo)
assert prepared.url == 'https://www.youtube.com/watch?v=ABC123'
def test_prepare_copies_upstream_settings_onto_download(test_session):
"""ChannelDownloader passes destination + tag_names via settings; prep copies them
onto the Download row so downstream code sees them as columns."""
download = Download(
url='https://youtube.com/watch?v=foo',
downloader='video',
destination=None,
tag_names=None,
settings={'destination': '/media/wrolpi/videos/foo', 'tag_names': ['news', 'tech']},
)
prepared = video_downloader.prepare_download(test_session, download)
assert download.destination == '/media/wrolpi/videos/foo'
assert download.tag_names == ['news', 'tech']
assert prepared.tag_names == ['news', 'tech']
def test_prepare_uses_existing_video_location(test_session, video_factory):
"""If a Video already exists at the URL, prep captures its location for error
attribution (so a failed re-download still tells the user where the previous file is)."""
url = 'https://youtube.com/watch?v=existing'
existing = video_factory(with_video_file=True)
existing.file_group.url = url
test_session.commit()
download = Download(url=url, downloader='video', settings={})
prepared = video_downloader.prepare_download(test_session, download)
assert prepared.location == existing.location
assert prepared.location is not None
@pytest.mark.parametrize('url,expected', [
('https://www.youtube.com/watch?v=abc123', True),
('https://youtube.com/watch?v=abc123', True),
('https://m.youtube.com/watch?v=abc123', True),
('https://music.youtube.com/watch?v=abc123', True),
('https://youtu.be/abc123', True),
# Lookalike / malicious hosts must be rejected.
('https://youtube.com.example.org/watch?v=abc123', False),
('https://notyoutube.com/watch?v=abc123', False),
('https://vimeo.com/12345', False),
('https://example.com/youtube.com', False),
('', False),
(None, False),
])
def test_is_youtube_url(url, expected):
"""is_youtube_url matches YouTube hosts (and subdomains) but rejects lookalike hosts."""
assert is_youtube_url(url) is expected
def test_format_selector_with_codecs():
"""Codec preferences generate codec-major selectors: a preferred codec at a lower resolution
beats a non-preferred codec at a higher resolution. The legacy resolution selectors are
appended as a fallback unless strict."""
from modules.videos.downloader import VIDEO_CODEC_YT_DLP_PATTERNS, AUDIO_CODEC_YT_DLP_PATTERNS
h264 = VIDEO_CODEC_YT_DLP_PATTERNS['h264']
aac = AUDIO_CODEC_YT_DLP_PATTERNS['aac']
legacy = format_selector(['1080p', '720p'])
selector = format_selector(['1080p', '720p'], video_codecs=['h264'], audio_codecs=['aac'])
expected = '/'.join([
f"bestvideo[height=1080][vcodec~='{h264}']+bestaudio[acodec~='{aac}']",
f"bestvideo[height=1080][vcodec~='{h264}']+bestaudio",
f"bestvideo[width=1080][vcodec~='{h264}']+bestaudio[acodec~='{aac}']",
f"bestvideo[width=1080][vcodec~='{h264}']+bestaudio",
f"bestvideo[height=720][vcodec~='{h264}']+bestaudio[acodec~='{aac}']",
f"bestvideo[height=720][vcodec~='{h264}']+bestaudio",
f"bestvideo[width=720][vcodec~='{h264}']+bestaudio[acodec~='{aac}']",
f"bestvideo[width=720][vcodec~='{h264}']+bestaudio",
legacy,
])
assert selector == expected
assert ',' not in selector
# Strict drops the any-codec audio fallbacks and the legacy tail.
strict = format_selector(['1080p', '720p'], video_codecs=['h264'], audio_codecs=['aac'],
strict_codecs=True)
expected_strict = '/'.join([
f"bestvideo[height=1080][vcodec~='{h264}']+bestaudio[acodec~='{aac}']",
f"bestvideo[width=1080][vcodec~='{h264}']+bestaudio[acodec~='{aac}']",
f"bestvideo[height=720][vcodec~='{h264}']+bestaudio[acodec~='{aac}']",
f"bestvideo[width=720][vcodec~='{h264}']+bestaudio[acodec~='{aac}']",
])
assert strict == expected_strict
assert ',' not in strict
# Codec-major: every h264 selector comes before the first vp9 selector.
vp9 = VIDEO_CODEC_YT_DLP_PATTERNS['vp9']
two = format_selector(['1080p'], video_codecs=['h264', 'vp9'], strict_codecs=True)
assert two.rindex(h264) < two.index(vp9)
assert two.count('bestvideo') == 4 # (height + width) x 2 codecs, one audio selector each.
# No codecs at all: byte-identical to the legacy selector.
assert format_selector(['1080p', '720p']) == legacy
# Only audio codecs: video is unfiltered but audio is preferred.
audio_only_pref = format_selector(['720p'], audio_codecs=['opus'])
assert "bestvideo[height=720]+bestaudio[acodec~='^opus']" in audio_only_pref
assert audio_only_pref.endswith(format_selector(['720p']))
# 'maximum' has no dimension filter.
maximum = format_selector(['maximum'], video_codecs=['h264'], strict_codecs=True)
assert maximum == f"bestvideo[vcodec~='{h264}']+bestaudio"
def test_format_selector_codec_patterns_are_safe():
"""Codec regex patterns are embedded in `-f` bracket filters; a comma would make yt-dlp
download every matching format, and a slash would break the fallback chain."""
from modules.videos.downloader import VIDEO_CODEC_YT_DLP_PATTERNS, AUDIO_CODEC_YT_DLP_PATTERNS
for pattern in (*VIDEO_CODEC_YT_DLP_PATTERNS.values(), *AUDIO_CODEC_YT_DLP_PATTERNS.values()):
assert ',' not in pattern
assert '/' not in pattern
@pytest.mark.asyncio
async def test_video_download_requests_codec_format(test_session, test_directory, mock_video_extract_info,
video_download_manager, mock_video_process_runner,
image_file, simple_channel, test_videos_downloader_config):
"""Codec settings change the `-f` selector; the legacy selectors remain as fallbacks."""
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)
image_file.rename(video_path.with_suffix('.jpg'))
url = 'https://www.youtube.com/watch?v=31jPEBiAC3c'
settings = {'video_codecs': ['h264'], 'audio_codecs': ['aac']}
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, settings=settings)
await video_download_manager.wait_for_all_downloads()
mock_video_process_runner.assert_called_once()
download, cmd, out_dir = mock_video_process_runner.call_args[0]
format_arg = cmd[cmd.index('-f') + 1]
resolutions = get_videos_downloader_config().video_resolutions
assert format_arg == format_selector(resolutions, video_codecs=['h264'], audio_codecs=['aac'])
assert "vcodec~=" in format_arg
# The legacy selectors are still offered as a fallback (strict is off).
assert format_arg.endswith(format_selector(resolutions))
@pytest.mark.asyncio
async def test_strict_selector_when_transcode_cannot_satisfy(test_session, test_directory,
mock_video_extract_info,
video_download_manager,
mock_video_process_runner, image_file,
simple_channel,
test_videos_downloader_config):
"""Transcode suppresses strict only when it can produce a preferred codec; av1 has no
transcode target, so strict still removes the fallback selectors."""
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)
image_file.rename(video_path.with_suffix('.jpg'))
url = 'https://www.youtube.com/watch?v=31jPEBiAC3c'
settings = {'video_codecs': ['av1'], 'strict_codecs': True, 'transcode': True}
with mock.patch('modules.videos.downloader.prepare_video_filename') as mock_prepare_filename, \
mock.patch('modules.videos.downloader.enforce_codecs', new_callable=mock.AsyncMock) as mock_enforce:
mock_video_extract_info.return_value = example_video_json
mock_prepare_filename.return_value = (video_path, {'id': 'foo'})
mock_enforce.side_effect = lambda path, paths, effective: (path, paths)
video_download_manager.create_download(test_session, url, video_downloader.name, settings=settings)
await video_download_manager.wait_for_all_downloads()
mock_video_process_runner.assert_called_once()
download, cmd, out_dir = mock_video_process_runner.call_args[0]
format_arg = cmd[cmd.index('-f') + 1]
resolutions = get_videos_downloader_config().video_resolutions
assert format_arg == format_selector(resolutions, video_codecs=['av1'], strict_codecs=True)
# No fallback to the legacy selectors.
assert not format_arg.endswith(format_selector(resolutions))
@pytest.mark.asyncio
async def test_strict_codecs_unavailable_fails_download(test_session, test_directory, mock_video_extract_info,
video_download_manager, mock_video_process_runner,
image_file, simple_channel,
test_videos_downloader_config):
"""With strict_codecs, yt-dlp's "Requested format is not available" fails the download
permanently (the source will never grow new formats)."""
from wrolpi.cmd import CommandResult
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'
url = 'https://www.youtube.com/watch?v=31jPEBiAC3c'
settings = {'video_codecs': ['h264'], 'strict_codecs': True}
mock_video_process_runner.return_value = CommandResult(
return_code=1, cancelled=False, stdout=b'',
stderr=b'ERROR: [youtube] 31jPEBiAC3c: Requested format is not available.', elapsed=0)
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, settings=settings)
await video_download_manager.wait_for_all_downloads()
download = test_session.query(Download).one()
assert download.is_failed, f'Download should have failed permanently, got {download.status}'
assert 'transcoding cannot produce them' in download.error
@pytest.mark.asyncio
async def test_codecs_unavailable_defers_without_strict(test_session, test_directory, mock_video_extract_info,
video_download_manager, mock_video_process_runner,
image_file, simple_channel,
test_videos_downloader_config):
"""The same yt-dlp failure without strict_codecs is deferred (retried), as before."""
from wrolpi.cmd import CommandResult
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'
url = 'https://www.youtube.com/watch?v=31jPEBiAC3c'
mock_video_process_runner.return_value = CommandResult(
return_code=1, cancelled=False, stdout=b'',
stderr=b'ERROR: [youtube] 31jPEBiAC3c: Requested format is not available.', elapsed=0)
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,
settings={'video_codecs': ['h264']})
await video_download_manager.wait_for_all_downloads()
download = test_session.query(Download).one()
assert download.is_deferred, f'Download should defer without strict_codecs, got {download.status}'
@pytest.mark.asyncio
async def test_enforce_codecs():
"""enforce_codecs transcodes, fails, or keeps the file depending on the effective settings."""
import pathlib
from modules.videos.downloader import enforce_codecs
video_path = pathlib.Path('/tmp/video.webm')
poster_path = pathlib.Path('/tmp/video.jpg')
video_paths = [video_path, poster_path]
vp9_ffprobe = {'streams': [
{'codec_type': 'video', 'codec_name': 'vp9'},
{'codec_type': 'audio', 'codec_name': 'opus'},
]}
with mock.patch('modules.videos.downloader.get_or_create_ffprobe_json',
new_callable=mock.AsyncMock) as mock_ffprobe, \
mock.patch('modules.videos.downloader.transcode_video_file',
new_callable=mock.AsyncMock) as mock_transcode:
mock_ffprobe.return_value = (vp9_ffprobe, None)
# Codecs match: nothing happens.
effective = {'video_codecs': ['vp9'], 'audio_codecs': ['opus']}
result = await enforce_codecs(video_path, video_paths, effective)
assert result == (video_path, video_paths)
mock_transcode.assert_not_called()
# Mismatch with neither transcode nor strict: file is kept.
effective = {'video_codecs': ['h264']}
result = await enforce_codecs(video_path, video_paths, effective)
assert result == (video_path, video_paths)
mock_transcode.assert_not_called()
# Mismatch with strict: permanent failure.
effective = {'video_codecs': ['h264'], 'strict_codecs': True}
with pytest.raises(UnrecoverableDownloadError):
await enforce_codecs(video_path, video_paths, effective)
mock_transcode.assert_not_called()
# Mismatch with transcode: the file is transcoded and the paths updated.
new_path = video_path.with_suffix('.mp4')
mock_transcode.return_value = new_path
effective = {'video_codecs': ['h264'], 'audio_codecs': ['aac'], 'transcode': True,
'video_format': 'mp4'}
result = await enforce_codecs(video_path, video_paths, effective)
assert result == (new_path, [new_path, poster_path])
mock_transcode.assert_called_once_with(video_path, 'h264', 'aac', container='mp4')
# Transcode wins over strict when it can produce the preferred codec.
mock_transcode.reset_mock()
mock_transcode.return_value = new_path
effective = {'video_codecs': ['h264'], 'transcode': True, 'strict_codecs': True,
'video_format': 'mp4'}
result = await enforce_codecs(video_path, video_paths, effective)
assert result[0] == new_path
mock_transcode.assert_called_once()
# No supported transcode target: file is kept, no error.
mock_transcode.reset_mock()
effective = {'video_codecs': ['av1'], 'transcode': True}
result = await enforce_codecs(video_path, video_paths, effective)
assert result == (video_path, video_paths)
mock_transcode.assert_not_called()
# Regression: strict must hold when transcode cannot produce the preferred codec.
effective = {'video_codecs': ['av1'], 'transcode': True, 'strict_codecs': True}
with pytest.raises(UnrecoverableDownloadError):
await enforce_codecs(video_path, video_paths, effective)
mock_transcode.assert_not_called()
# No partial transcode: an unfixable video mismatch (av1) blocks the audio-only
# conversion; the file is kept as-is (or fails when strict).
effective = {'video_codecs': ['av1'], 'audio_codecs': ['aac'], 'transcode': True}
result = await enforce_codecs(video_path, video_paths, effective)
assert result == (video_path, video_paths)
mock_transcode.assert_not_called()
effective = {'video_codecs': ['av1'], 'audio_codecs': ['aac'], 'transcode': True,
'strict_codecs': True}
with pytest.raises(UnrecoverableDownloadError):
await enforce_codecs(video_path, video_paths, effective)
mock_transcode.assert_not_called()
@pytest.mark.asyncio
async def test_metadata_refresh_keeps_wrolpi_section(test_session, test_directory, video_factory):
"""Refreshing a Video's metadata replaces yt-dlp's info json; the wrolpi section (the user's
custom title and description) is carried over and still wins."""
source_id = 'video-source-id'
url = 'https://example.com/the-video'
video = video_factory(with_info_json={'id': source_id, 'title': 'Orig',
'wrolpi': {'custom_title': 'Mine', 'custom_description': 'my words'}},
source_id=source_id)
video.file_group.url = url
test_session.commit()
assert video.file_group.title == 'Mine'
download = Download(url=url, info_json={'id': source_id}, settings={})
with mock.patch('modules.videos.downloader.download_video_info_json') as mock_download:
mock_download.return_value = {'id': source_id, 'title': 'Orig v2', 'description': 'theirs', 'duration': 9}
result = await VideoDownloader.download_info_json(download, video.video_path)
assert result.success is True, result
test_session.expire_all()
video = Video.find_by_file_group_id(test_session, video.file_group_id)
info_json = video.get_info_json()
assert info_json['title'] == 'Orig v2', 'yt-dlp data was refreshed'
assert info_json['wrolpi'] == {'custom_title': 'Mine', 'custom_description': 'my words'}
assert video.file_group.title == 'Mine'
assert video.get_description() == 'my words'
@pytest.mark.asyncio
async def test_redownload_keeps_wrolpi_section(test_session, test_directory, mock_video_extract_info, simple_channel,
await_switches, video_download_manager, mock_video_process_runner,
image_file):
"""A full re-download has yt-dlp overwrite the info json; the wrolpi section is carried over."""
simple_channel.source_id = example_video_json['channel_id']
simple_channel.directory = test_directory / 'videos/channel name'
simple_channel.directory.mkdir(parents=True)
url = 'https://www.youtube.com/watch?v=31jPEBiAC3c'
video_path = simple_channel.directory / 'a video.mp4'
shutil.copy(PROJECT_DIR / 'test/big_buck_bunny_720p_1mb.mp4', video_path)
info_json_path = video_path.with_suffix('.info.json')
info_json_path.write_text(json.dumps({'title': 'Orig', 'webpage_url': url, 'wrolpi': {'custom_title': 'Mine'}}))
from modules.videos.normalize_video_url import normalize_video_url
existing = Video.from_paths(test_session, video_path, info_json_path)
existing.validate(test_session)
test_session.commit()
assert existing.file_group.title == 'Mine'
assert Video.get_by_url(test_session, normalize_video_url(url)) is not None, 'lookup by normalized url'
assert existing.file_group.get_wrolpi_json() == {'custom_title': 'Mine'}
from wrolpi.cmd import CommandResult
async def yt_dlp_overwrites_info_json(*args, **kwargs):
# yt-dlp rewrites the whole info json; the wrolpi section is gone from disk.
info_json_path.write_text(json.dumps({'title': 'Orig v2', 'webpage_url': url, 'duration': 5}))
return CommandResult(return_code=0, cancelled=False, stdout=b'', stderr=b'', elapsed=0)
mock_video_process_runner.side_effect = yt_dlp_overwrites_info_json
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()
mock_video_process_runner.assert_called_once()
test_session.expire_all()
video: Video = test_session.query(Video).one()
info_json = video.get_info_json()
assert info_json['title'] == 'Orig v2'
assert info_json['wrolpi'] == {'custom_title': 'Mine'}
assert video.file_group.title == 'Mine'