mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
* channels.yaml imports by diffing channels.yaml declares two sections: Channels keyed by directory (comparing name, URL, Tag, file format, and the source id and flags, which keep the database's value when missing), and the Downloads listed under each Channel, keyed by (directory, URL); an existing Download keeps its own frequency. An unchanged import writes nothing (it ran about a thousand queries and committed several times). Removed Channels are deleted with their Collection (and Downloads); a Download removed from a Channel is deleted. A channel Collection without a Channel is deleted unless listed (then it gets its Channel). A Channel whose directory changed, with the same name, is moved. An invalid entry is skipped and counted, and keeps its Channel (by name when its directory is invalid) and that Channel's Downloads; so does an entry without a list of Downloads. Duplicate directories still refuse the import. Unknown Tags are dropped once tags.yaml is imported. New Downloads are created in the import's one write (commit=False on Channel.get_or_create_download, Download.delete and delete_with_videos). After the import, Videos are claimed and source ids fetched only for new and changed Channels; Downloads are linked without a query per Download, and not while entries are skipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * channels.yaml: moves, invalid Downloads, and saves after the write - A moved Channel's Download diff is retargeted to its new directory: one its new entry lists is the same Download, one it does not is deleted in the same import (the old directory was treated as kept). - An unreadable Download is counted as skipped, and its Channel keeps its Downloads (it was dropped, and the Channel's Download deleted). - A new Download without a frequency is dropped before the write and counted (it re-opened a write on every import). - No source id is fetched while entries are skipped (its save would be refused, leaving the database newer than the file); a fetched one is saved with dump_config, which records the database's version. - Download.delete(commit=False) neither changes the skip list nor starts config saves, which would read the database before the import commits (and write a removed Download back into channels.yaml). The import saves download_manager.yaml after its write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
286 lines
12 KiB
Python
286 lines
12 KiB
Python
"""channels.yaml imports by diffing its Channels (and their Downloads) against the database, and writing only the
|
|
differences (see wrolpi.config_import). A Channel is keyed by its directory."""
|
|
import pytest
|
|
import yaml
|
|
|
|
from modules.videos import lib
|
|
from modules.videos.lib import get_channels_config, import_channels_config
|
|
from modules.videos.models import Channel
|
|
from wrolpi.collections import Collection
|
|
from wrolpi.common import get_relative_to_media_directory
|
|
from wrolpi.config_version import get_config_version
|
|
from wrolpi.downloader import Download, DownloadFrequency
|
|
from wrolpi.test.test_config_import import edit_config, statements, writes
|
|
|
|
|
|
@pytest.fixture
|
|
async def saved_channels(test_session, test_directory, test_channels_config, test_download_manager_config,
|
|
channel_factory, tag_factory, await_switches):
|
|
"""Channel `one` (tagged, with a weekly Download) and `two`, saved to the config (which records its
|
|
version)."""
|
|
await tag_factory('news')
|
|
channel_factory(name='one', url='https://example.com/one', tag_name='news',
|
|
download_frequency=DownloadFrequency.weekly)
|
|
channel_factory(name='two', url='https://example.com/two')
|
|
get_channels_config().dump_config()
|
|
await await_switches()
|
|
assert get_config_version(test_session, 'channels.yaml') is not None
|
|
return test_channels_config
|
|
|
|
|
|
def channels(test_session) -> dict:
|
|
test_session.expire_all()
|
|
return {i.name: i for i in test_session.query(Channel)}
|
|
|
|
|
|
def edit_channel(path, name, **values):
|
|
def change(data):
|
|
next(i for i in data['channels'] if i['name'] == name).update(values)
|
|
|
|
edit_config(path, change)
|
|
|
|
|
|
def directory_of(test_session, name) -> str:
|
|
return str(get_relative_to_media_directory(channels(test_session)[name].directory))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unchanged_import_writes_nothing(test_session, saved_channels):
|
|
"""Importing a config the database already matches opens no write transaction, nor does linking Downloads when
|
|
there are none to link."""
|
|
config = get_channels_config()
|
|
with statements() as collected:
|
|
import_channels_config()
|
|
|
|
assert config.successful_import is True
|
|
assert writes(collected) == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_writes_only_the_differences(test_session, saved_channels):
|
|
"""A changed Channel is updated, a removed one is deleted (with its Collection), an added one is created; the
|
|
next import writes nothing."""
|
|
def change(data):
|
|
data['channels'] = [i for i in data['channels'] if i['name'] != 'two']
|
|
one = data['channels'][0]
|
|
one.update(name='One', url='https://example.com/new-one', generate_posters=False)
|
|
data['channels'].append(dict(name='three', directory='videos/three', url='https://example.com/three'))
|
|
|
|
edit_config(saved_channels, change)
|
|
config = get_channels_config()
|
|
config.import_config()
|
|
|
|
assert config.successful_import is True
|
|
found = channels(test_session)
|
|
assert set(found) == {'One', 'three'}
|
|
assert (found['One'].url, found['One'].generate_posters, found['One'].tag_name) == \
|
|
('https://example.com/new-one', False, 'news')
|
|
assert {i.name for i in test_session.query(Collection).filter_by(kind='channel')} == {'One', 'three'}
|
|
with statements() as collected:
|
|
config.import_config()
|
|
assert writes(collected) == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_downloads_follow_the_config(test_session, saved_channels):
|
|
"""A Download removed from a Channel is deleted; an added one is created (with the file's frequency) in the
|
|
import's one write. An existing Download keeps its own frequency."""
|
|
edit_channel(saved_channels, 'one', downloads=[])
|
|
edit_channel(saved_channels, 'two', downloads=[dict(url='https://example.com/two/feed', frequency=86400)])
|
|
|
|
config = get_channels_config()
|
|
config.import_config()
|
|
|
|
test_session.expire_all()
|
|
assert {(i.url, i.frequency, i.collection_id) for i in test_session.query(Download)} == {
|
|
('https://example.com/two/feed', 86400, channels(test_session)['two'].collection_id)}
|
|
with statements() as collected:
|
|
config.import_config()
|
|
assert writes(collected) == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failed_import_creates_nothing(test_session, saved_channels, monkeypatch):
|
|
"""A new Download is created in the import's one write: a failure later in the write leaves nothing (it was
|
|
committed on its own)."""
|
|
edit_channel(saved_channels, 'two', downloads=[dict(url='https://example.com/two/feed', frequency=86400)])
|
|
edit_config(saved_channels, lambda data: data.update(channels=[i for i in data['channels'] if i['name'] != 'one']))
|
|
|
|
def fail(*args, **kwargs):
|
|
raise RuntimeError('Write failed')
|
|
|
|
monkeypatch.setattr(lib.ChannelsSection, 'delete', fail)
|
|
with pytest.raises(RuntimeError):
|
|
get_channels_config().import_config()
|
|
|
|
test_session.expire_all()
|
|
assert {i.url for i in test_session.query(Download)} == {'https://example.com/one'}
|
|
assert set(channels(test_session)) == {'one', 'two'}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_moved_channel_is_the_same_channel(test_session, saved_channels):
|
|
"""A Channel whose directory changed in the config (its name did not) is moved, not deleted and re-created."""
|
|
before = channels(test_session)['two'].id
|
|
edit_channel(saved_channels, 'two', directory='videos/moved/two')
|
|
|
|
get_channels_config().import_config()
|
|
|
|
two = channels(test_session)['two']
|
|
assert (two.id, directory_of(test_session, 'two')) == (before, 'videos/moved/two')
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_entries_are_kept(test_session, saved_channels):
|
|
"""An entry without a name, or with an invalid directory, is skipped (counted), and its Channel is kept."""
|
|
one_directory = directory_of(test_session, 'one')
|
|
|
|
def change(data):
|
|
next(i for i in data['channels'] if i['name'] == 'one').pop('name')
|
|
next(i for i in data['channels'] if i.get('name') == 'two')['directory'] = '../outside'
|
|
|
|
edit_config(saved_channels, change)
|
|
config = get_channels_config()
|
|
config.import_config()
|
|
|
|
assert config.import_skipped == 2
|
|
assert set(channels(test_session)) == {'one', 'two'}
|
|
assert directory_of(test_session, 'one') == one_directory
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_orphan_collections(test_session, test_directory, saved_channels):
|
|
"""A channel Collection without a Channel is deleted when the config does not list it; one the config lists
|
|
gets its Channel."""
|
|
test_session.add_all([
|
|
Collection(name='orphan', kind='channel', directory=test_directory / 'videos/orphan'),
|
|
Collection(name='listed', kind='channel', directory=test_directory / 'videos/listed'),
|
|
])
|
|
test_session.commit()
|
|
# Saved: the config lists only Channels.
|
|
get_channels_config().dump_config()
|
|
edit_config(saved_channels, lambda data: data['channels'].append(
|
|
dict(name='listed', directory='videos/listed', url='https://example.com/listed')))
|
|
|
|
get_channels_config().import_config()
|
|
|
|
test_session.expire_all()
|
|
assert {i.name for i in test_session.query(Collection).filter_by(kind='channel')} == {'one', 'two', 'listed'}
|
|
assert channels(test_session)['listed'].url == 'https://example.com/listed'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_tag_with_a_skipped_entry_converges(test_session, saved_channels, test_tags_config,
|
|
await_switches):
|
|
"""Once tags.yaml is imported, a Channel's Tag that no longer exists is dropped; with a skipped entry (the config
|
|
cannot be saved) the next import writes nothing."""
|
|
from wrolpi.tags import get_tags_config
|
|
get_tags_config().dump_config()
|
|
get_tags_config().import_config()
|
|
edit_channel(saved_channels, 'two', tag_name='gone')
|
|
edit_config(saved_channels, lambda data: data['channels'].append(dict(directory='videos/nameless')))
|
|
|
|
config = get_channels_config()
|
|
config.import_config()
|
|
await await_switches()
|
|
assert channels(test_session)['two'].tag_name is None
|
|
|
|
with statements() as collected:
|
|
config.import_config()
|
|
assert writes(collected) == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_links_while_entries_are_skipped(test_session, saved_channels, await_switches):
|
|
"""Linking changes channels.yaml; while an entry is skipped the config cannot be saved, so Downloads are not
|
|
linked (the next startup would save over the skipped entry)."""
|
|
test_session.add(Download(url='https://example.com/two', downloader='video_channel', frequency=86400))
|
|
test_session.commit()
|
|
edit_config(saved_channels, lambda data: data['channels'].append(dict(directory='videos/nameless')))
|
|
|
|
import_channels_config()
|
|
await await_switches()
|
|
|
|
test_session.expire_all()
|
|
assert Download.get_by_url(test_session, 'https://example.com/two').collection_id is None
|
|
assert get_config_version(test_session, 'channels.yaml') == yaml.safe_load(saved_channels.read_text())['version']
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_moved_channel_downloads(test_session, saved_channels):
|
|
"""A moved Channel keeps the Downloads its new entry lists, and loses the ones it does not, in the same import;
|
|
the next import writes nothing."""
|
|
edit_channel(saved_channels, 'one', directory='videos/moved/one', downloads=[])
|
|
edit_channel(saved_channels, 'two', directory='videos/moved/two',
|
|
downloads=[dict(url='https://example.com/two/feed', frequency=86400)])
|
|
config = get_channels_config()
|
|
config.import_config()
|
|
|
|
test_session.expire_all()
|
|
assert {i.url for i in test_session.query(Download)} == {'https://example.com/two/feed'}
|
|
assert directory_of(test_session, 'one') == 'videos/moved/one'
|
|
with statements() as collected:
|
|
config.import_config()
|
|
assert writes(collected) == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_download_keeps_the_channel_downloads(test_session, saved_channels):
|
|
"""An unreadable Download of a Channel is counted as skipped, and the Channel's Downloads are kept."""
|
|
edit_channel(saved_channels, 'one', downloads=[dict(uri='https://example.com/one')])
|
|
|
|
config = get_channels_config()
|
|
config.import_config()
|
|
|
|
assert config.import_skipped == 1
|
|
test_session.expire_all()
|
|
assert {i.url for i in test_session.query(Download)} == {'https://example.com/one'}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_new_download_without_frequency_is_skipped(test_session, saved_channels):
|
|
"""A new Download without a frequency cannot be created: it is skipped (counted), and the next import writes
|
|
nothing."""
|
|
edit_channel(saved_channels, 'two', downloads=[dict(url='https://example.com/two/feed')])
|
|
|
|
config = get_channels_config()
|
|
with statements() as collected:
|
|
config.import_config()
|
|
|
|
assert config.import_skipped == 1
|
|
assert writes(collected) == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_source_id_fetch_while_entries_are_skipped(test_session, saved_channels, monkeypatch):
|
|
"""Fetching a Channel's source id changes channels.yaml; while an entry is skipped it is not fetched."""
|
|
from wrolpi import flags
|
|
scheduled = []
|
|
monkeypatch.setattr(lib, 'background_task', lambda coro: scheduled.append(coro.close()))
|
|
monkeypatch.setattr(flags.have_internet, 'is_set', lambda: True)
|
|
monkeypatch.setattr(type(lib.download_manager), 'can_download', property(lambda self: True))
|
|
edit_config(saved_channels, lambda data: data['channels'].extend([
|
|
dict(name='three', directory='videos/three', url='https://example.com/three', download_missing_data=True),
|
|
dict(directory='videos/nameless'),
|
|
]))
|
|
|
|
get_channels_config().import_config()
|
|
|
|
assert set(channels(test_session)) == {'one', 'two', 'three'}
|
|
assert scheduled == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_does_not_save_during_its_write(test_session, saved_channels, test_download_manager_config,
|
|
monkeypatch):
|
|
"""Deleting a Download inside the import's write neither skips its URL nor starts a config save (which would read
|
|
the database before the import commits)."""
|
|
from wrolpi.downloader import get_download_manager_config
|
|
saves = []
|
|
monkeypatch.setattr(lib.save_channels_config, 'activate_switch', lambda *a, **kw: saves.append('channels'))
|
|
edit_channel(saved_channels, 'one', downloads=[])
|
|
|
|
get_channels_config().import_config()
|
|
|
|
assert saves == []
|
|
assert 'https://example.com/one' not in (get_download_manager_config().skip_urls or [])
|