mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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>
1902 lines
88 KiB
Python
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'
|