playlists.yaml imports by diffing

playlists.yaml declares two sections: playlists, keyed by name, and
their items, keyed by what each points to (a file's or Zim's
media-relative path and entry, or a URL) with position and title
compared.  A playlist has at most one item per reference, so a moved
item changes only its position and keeps its row; an unchanged import
writes nothing (every import deleted and re-created every item).

prepare skips items whose file or Zim is not indexed before the write
(their place is kept), drops a Tag that no longer exists once tags.yaml
is imported (the config is then saved), and, without deletes, keeps the
database's order and adds the file's other items after the item that
precedes them.

Moving a FileGroup in a playlist counts as a playlists.yaml change (ORM,
the bulk move, and a refresh that changes the primary file).

media_relative moves to wrolpi.config_import for both configs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-10-24 20:26:06 -06:00
parent b67d51b69a
commit 50048ed0d3
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
7 changed files with 517 additions and 144 deletions

View file

@ -1,12 +1,15 @@
import pathlib
import shutil
from dataclasses import dataclass, field
from typing import List
from typing import Dict, List, Optional, Set, Tuple
from sqlalchemy import insert
from sqlalchemy.orm import Session
from wrolpi.common import ConfigFile, get_media_directory, get_relative_to_media_directory, logger
from wrolpi.config_version import IMPORT, SAVE, count_config_change
from wrolpi.config_import import ConfigSection, SectionDiff, chunks, media_relative
from wrolpi.config_version import count_config_change
from wrolpi.db import get_db_session
from wrolpi.events import Events
from wrolpi.switches import ActivateSwitchMethod, register_switch_handler
from wrolpi.tags import TagFile, TagsConfig, save_tags_config
@ -15,9 +18,188 @@ logger = logger.getChild(__name__)
__all__ = ['PlaylistsConfig', 'playlists_config', 'save_playlists_config', 'get_playlists_config']
def _item_key(item) -> tuple:
"""What a playlist item points to; a playlist has at most one item per key (see CollectionItem's constraints)."""
return item.item_kind, item.file_group_id, item.zim_id, item.zim_entry, item.url
def _playlist_tag_names(rows) -> Set[str]:
return {i['tag_name'] for i in rows if i['tag_name']}
class PlaylistsSection(ConfigSection):
"""playlists.yaml's playlists (Collections with kind 'playlist'), keyed by name."""
name = 'playlists'
def file_rows(self, config: dict) -> Tuple[Dict[str, dict], Set[str]]:
rows, invalid = dict(), set()
for data in config.get('playlists') or []:
name = data.get('name')
if not name:
logger.error(f'Playlist config entry has no name, skipping: {data}')
continue
try:
rows[name] = dict(
description=data.get('description') or None,
# Optional custom directory (media-relative); None when not customized.
directory=media_relative(data['directory']) if data.get('directory') else None,
tag_name=data.get('tag_name') or None,
)
except Exception as e:
logger.error(f'Skipping invalid playlist in config: {name}', exc_info=e)
invalid.add(name)
return rows, invalid
def db_rows(self, session: Session) -> Dict[str, dict]:
from wrolpi.tags import Tag
from .models import Collection
query = session.query(Collection.name, Collection.description, Collection.directory, Tag.name) \
.outerjoin(Tag, Tag.id == Collection.tag_id) \
.filter(Collection.kind == 'playlist')
return {name: dict(description=description or None,
directory=media_relative(directory) if directory else None,
tag_name=tag_name)
for name, description, directory, tag_name in query}
def upsert(self, session: Session, rows: Dict[str, dict]):
from wrolpi.tags import Tag
from .models import Collection
existing = {i.name: i for i in
session.query(Collection).filter(Collection.kind == 'playlist', Collection.name.in_(list(rows)))}
tags = {i.name: i for i in session.query(Tag).filter(Tag.name.in_(list(_playlist_tag_names(rows.values()))))}
for name, row in rows.items():
if not (collection := existing.get(name)):
collection = Collection(name=name, kind='playlist')
session.add(collection)
collection.description = row['description']
collection.directory = (get_media_directory() / row['directory']) if row['directory'] else None
collection.tag = tags.get(row['tag_name'])
session.flush()
def delete(self, session: Session, keys: Set[str]):
from .models import Collection
for collection in session.query(Collection).filter(Collection.kind == 'playlist',
Collection.name.in_(list(keys))):
logger.info(f'Deleting playlist {collection.name!r} (no longer in config)')
session.delete(collection)
class PlaylistItemsSection(ConfigSection):
"""playlists.yaml's playlist items, keyed by (playlist name, kind, reference, Zim entry): a file's or Zim's
media-relative path, or a URL. A playlist has at most one item per reference (CollectionItem's constraints), so
moving an item changes only its position, and it keeps its row."""
name = 'items'
@staticmethod
def item_key(name: str, data: dict) -> Optional[tuple]:
if 'file' in data:
return name, 'file', media_relative(data['file']), None
elif 'zim' in data:
if not data.get('entry'):
raise ValueError('A zim item requires an entry')
return name, 'zim', media_relative(data['zim']), data['entry']
elif 'url' in data:
return name, 'url', data['url'], None
raise ValueError('An item requires a file, zim, or url')
def file_rows(self, config: dict) -> Tuple[Dict[tuple, dict], Set[tuple]]:
rows, invalid = dict(), set()
for data in config.get('playlists') or []:
if not (name := data.get('name')):
continue
# Positions follow the file's order; an item skipped now keeps its place for a later import.
for position, item in enumerate(data.get('items') or [], start=1):
try:
key = self.item_key(name, item)
except Exception as e:
logger.error(f'Skipping invalid item of playlist {name!r}: {item}', exc_info=e)
continue
if key in rows:
logger.warning(f'Skipping duplicate item of playlist {name!r}: {item}')
continue
rows[key] = dict(position=position, title=item.get('title'))
return rows, invalid
@staticmethod
def _query(session: Session, *columns):
from modules.zim.models import Zim
from wrolpi.files.models import FileGroup
from .models import Collection, CollectionItem
return session.query(*columns, Collection.name, CollectionItem.item_kind, FileGroup.primary_path, Zim.path,
CollectionItem.zim_entry, CollectionItem.url) \
.join(Collection, Collection.id == CollectionItem.collection_id) \
.outerjoin(FileGroup, FileGroup.id == CollectionItem.file_group_id) \
.outerjoin(Zim, Zim.id == CollectionItem.zim_id) \
.filter(Collection.kind == 'playlist')
@staticmethod
def _db_key(name, item_kind, primary_path, zim_path, zim_entry, url) -> Optional[tuple]:
if item_kind == 'file' and primary_path:
return name, 'file', media_relative(primary_path), None
elif item_kind == 'zim' and zim_path:
return name, 'zim', media_relative(zim_path), zim_entry
elif item_kind == 'url':
return name, 'url', url, None
# Not saved (see `CollectionItem.to_config`), so never "removed".
return None
def db_rows(self, session: Session) -> Dict[tuple, dict]:
from .models import CollectionItem
rows = dict()
for position, title, *values in self._query(session, CollectionItem.position, CollectionItem.title):
if key := self._db_key(*values):
rows[key] = dict(position=position, title=title)
return rows
def upsert(self, session: Session, rows: Dict[tuple, dict]):
from modules.zim.models import Zim
from wrolpi.files.models import FileGroup
from .models import Collection, CollectionItem
media_directory = get_media_directory()
collection_ids = dict(session.query(Collection.name, Collection.id).filter(
Collection.kind == 'playlist', Collection.name.in_(list({i[0] for i in rows}))))
file_group_ids = dict()
for paths in chunks({str(media_directory / i[2]) for i in rows if i[1] == 'file'}):
file_group_ids.update((media_relative(path), id_) for path, id_ in
session.query(FileGroup.primary_path, FileGroup.id)
.filter(FileGroup.primary_path.in_(paths)))
zim_ids = {media_relative(path): id_ for id_, path in session.query(Zim.id, Zim.path)} \
if any(i[1] == 'zim' for i in rows) else dict()
existing = {(i.collection_id, i.item_kind, i.file_group_id, i.zim_id, i.zim_entry, i.url): i for i in
session.query(CollectionItem).filter(CollectionItem.collection_id.in_(collection_ids.values()))}
new = list()
for key, row in rows.items():
name, kind, reference, zim_entry = key
values = dict(collection_id=collection_ids.get(name), item_kind=kind, file_group_id=None, zim_id=None,
zim_entry=None, url=None)
if kind == 'file':
values['file_group_id'] = file_group_ids.get(reference)
elif kind == 'zim':
values.update(zim_id=zim_ids.get(reference), zim_entry=zim_entry)
else:
values['url'] = reference
if not values['collection_id'] or (kind == 'file' and not values['file_group_id']) \
or (kind == 'zim' and not values['zim_id']):
logger.warning(f'Playlist {name!r}: cannot find {reference!r}, skipping')
continue
if item := existing.get(tuple(values.values())):
item.position, item.title = row['position'], row['title']
else:
new.append(dict(values, position=row['position'], title=row['title']))
if new:
# One statement for all new items (a rebuilt database has thousands). Not flushed: count it.
session.execute(insert(CollectionItem), new)
count_config_change(session, PlaylistsConfig.file_name)
session.flush()
def delete(self, session: Session, keys: Set[tuple]):
from .models import CollectionItem
for item, *values in self._query(session, CollectionItem) \
.filter(CollectionItem.collection_id.in_(self._collection_ids(session, keys))):
if self._db_key(*values) in keys:
session.delete(item)
@staticmethod
def _collection_ids(session: Session, keys) -> List[int]:
from .models import Collection
return [i for i, in session.query(Collection.id).filter(
Collection.kind == 'playlist', Collection.name.in_(list({i[0] for i in keys})))]
@dataclass
@ -48,6 +230,7 @@ class PlaylistsConfig(ConfigFile):
title: "The spot"
"""
file_name = 'playlists.yaml'
sections = (PlaylistsSection(), PlaylistItemsSection())
validator = PlaylistsConfigValidator
default_config = dict(version=0, playlists=[])
width = 120
@ -56,140 +239,80 @@ class PlaylistsConfig(ConfigFile):
def playlists(self) -> List[dict]:
return self._config.get('playlists', [])
def import_config(self, file: pathlib.Path = None, send_events=False):
"""Import playlists (and their ordered items) from the config into the database."""
def prepare(self, diffs: dict):
self._drop_unknown_tags(diffs['playlists'])
self._drop_missing_items(diffs['items'])
if not self.import_deletes:
self._merge_items(diffs['items'])
def _drop_unknown_tags(self, diff: SectionDiff):
"""A Tag that no longer exists (it was removed) is dropped, and the config is saved without it. Only once
tags.yaml was imported; until then its Tags may be missing."""
from wrolpi.tags import Tag, get_tags_config
rows = [*diff.added.values(), *diff.changed.values()]
if not (names := _playlist_tag_names(rows)) or not get_tags_config().successful_import:
return
with get_db_session() as session:
unknown = names - {i for i, in session.query(Tag.name).filter(Tag.name.in_(list(names)))}
for row in rows:
if row['tag_name'] in unknown:
logger.warning(f'Removing unknown Tag {row["tag_name"]!r} from a playlist')
row['tag_name'] = None
self._config_outdated = True
def _drop_missing_items(self, diff: SectionDiff):
"""An added item whose file or Zim is not in the database (not indexed yet, after a rebuild) is skipped
before the write; a later import (after the refresh) adds it."""
from modules.zim.models import Zim
from wrolpi.files.models import FileGroup
from wrolpi.tags import Tag
media_directory = get_media_directory()
files = {i[2] for i in diff.added if i[1] == 'file'}
zims = {i[2] for i in diff.added if i[1] == 'zim'}
known = set()
with get_db_session() as session:
for paths in chunks({str(media_directory / i) for i in files}):
known.update(('file', media_relative(i)) for i, in
session.query(FileGroup.primary_path).filter(FileGroup.primary_path.in_(paths)))
if zims:
known.update(('zim', media_relative(i)) for i, in session.query(Zim.path))
for key in list(diff.added):
if key[1] in ('file', 'zim') and (key[1], key[2]) not in known:
logger.warning(f'Playlist {key[0]!r}: {key[1]} not indexed, skipping {key[2]!r}')
del diff.added[key]
self.import_skipped += 1
from .models import Collection, CollectionItem
file = file or self.get_file()
file_str = str(self.get_relative_file())
# A missing config is NOT an empty config (mirrors ChannelsConfig). Deleting here would
# cascade: the sync prunes the on-disk playlist dirs and the next dump writes an empty
# config -- a single lost file would permanently destroy every playlist.
if not file.is_file():
logger.info(f'No playlists config file, skipping import: {file_str}')
self.successful_import = True
def _merge_items(self, diff: SectionDiff):
"""Without deletes, a playlist's items in the database keep their order, and the file's other items are
added after the item that precedes them in the file."""
section = PlaylistItemsSection()
if not diff.added:
return
with get_db_session() as session:
db_rows = section.db_rows(session)
file_rows, _ = section.file_rows(self._config)
for name in {i[0] for i in diff.added}:
merged = sorted((i for i in db_rows if i[0] == name), key=lambda i: db_rows[i]['position'])
previous = None
for key in sorted((i for i in file_rows if i[0] == name), key=lambda i: file_rows[i]['position']):
if key in db_rows:
previous = key
elif key in diff.added:
merged.insert(merged.index(previous) + 1 if previous else 0, key)
previous = key
for position, key in enumerate(merged, start=1):
if key in diff.added:
diff.added[key]['position'] = position
elif db_rows[key]['position'] != position or key in diff.changed:
title = (diff.changed.get(key) or db_rows[key])['title']
diff.changed[key] = dict(position=position, title=title)
def import_config(self, file: pathlib.Path = None, send_events=False):
self._config_outdated = False
super().import_config(file, send_events)
decision = self.import_decision()
if decision == SAVE:
self.save_database_instead()
return
playlists_data = self._config.get('playlists', [])
# An empty playlists list never deletes DB records (mirrors ChannelsConfig). Deleting all
# playlists is done through the API, which dumps the resulting (empty) config itself.
if not playlists_data:
logger.info(f'No playlists in config, preserving existing DB playlists: {file_str}')
self.successful_import = True
return
try:
with get_db_session(commit=True) as session:
imported_names = set()
for data in playlists_data:
name = data.get('name')
if not name:
logger.error(f'Playlist config entry has no name, skipping: {data}')
self.import_skipped += 1
continue
collection = session.query(Collection).filter_by(
name=name, kind='playlist').one_or_none()
new = not collection
if new:
collection = Collection(name=name, kind='playlist')
session.add(collection)
collection.description = data.get('description')
# Optional custom directory (media-relative); None when not customized.
directory = data.get('directory')
collection.directory = (get_media_directory() / directory) if directory else None
tag_name = data.get('tag_name')
collection.tag = Tag.get_by_name(session, tag_name) if tag_name else None
session.flush([collection])
file_items = []
for item_data in (data.get('items') or []):
item = None
if 'file' in item_data:
path = get_media_directory() / item_data['file']
fg = session.query(FileGroup).filter_by(primary_path=str(path)).one_or_none()
if fg:
item = CollectionItem(collection_id=collection.id, item_kind='file',
file_group_id=fg.id)
else:
logger.warning(f'Playlist {name!r}: file not indexed, skipping '
f'{item_data["file"]!r}')
self.import_skipped += 1
elif 'zim' in item_data:
zpath = get_media_directory() / item_data['zim']
zim = session.query(Zim).filter_by(path=str(zpath)).one_or_none()
if zim:
item = CollectionItem(collection_id=collection.id, item_kind='zim',
zim_id=zim.id, zim_entry=item_data.get('entry'))
else:
logger.warning(f'Playlist {name!r}: zim not found, skipping '
f'{item_data["zim"]!r}')
self.import_skipped += 1
elif 'url' in item_data:
item = CollectionItem(collection_id=collection.id, item_kind='url',
url=item_data['url'])
if item is not None:
item.title = item_data.get('title')
file_items.append(item)
if decision == IMPORT or new:
# Rebuild items to match the config's order exactly.
for item in list(collection.items):
session.delete(item)
session.flush()
items = file_items
else:
# Without deletes, keep the database's items in their order; add the file's other items
# after the item that precedes them in the file.
items = sorted(collection.items, key=lambda i: i.position)
existing = {_item_key(i): i for i in items}
previous = None
for item in file_items:
if found := existing.get(_item_key(item)):
previous = found
continue
items.insert(items.index(previous) + 1 if previous else 0, item)
previous = item
for position, item in enumerate(items, start=1):
item.position = position
session.add(item)
session.flush()
imported_names.add(name)
# Delete playlists that are no longer present in the config.
for collection in session.query(Collection).filter_by(kind='playlist').all() \
if decision == IMPORT else []:
if collection.name not in imported_names:
logger.info(f'Deleting playlist {collection.name!r} (no longer in config)')
session.delete(collection)
logger.info(f'Imported {len(imported_names)} playlists from {file_str}')
self.record_import_version()
self.successful_import = True
except Exception as e:
self.successful_import = False
message = f'Failed to import {file_str} config!'
logger.error(message, exc_info=e)
if send_events:
Events.send_config_import_failed(message)
raise
def after_import(self, diffs: dict):
if self._config_outdated:
self.background_dump.activate_switch()
def preview_backup_import(self, backup_date: str, mode: str) -> dict:
"""Preview which playlists a backup import would add/remove (keyed by playlist name)."""
@ -302,7 +425,7 @@ def file_groups_moved(session, file_group_ids):
playlists.yaml references file items by path, so it must be re-saved (and the playlist
directories re-synced) or the next import will not find the file and will drop the item.
tags.yaml saves tagged files by path too; the move (often raw SQL) is counted as its change.
tags.yaml saves tagged files by path too. The move (often raw SQL) is counted as each config's change.
`file_group_ids` may be an iterable of ids or a select of ids."""
from .models import Collection, CollectionItem
@ -316,6 +439,7 @@ def file_groups_moved(session, file_group_ids):
.filter(Collection.kind == 'playlist', CollectionItem.file_group_id.in_(file_group_ids)) \
.first()
if in_playlist:
count_config_change(session, PlaylistsConfig.file_name)
save_playlists_config.activate_switch()
from .sync import sync_playlists_directory
sync_playlists_directory.activate_switch()

View file

@ -0,0 +1,217 @@
"""playlists.yaml imports by diffing its playlists and their items against the database, and writing only the
differences (see wrolpi.config_import). An item is keyed by what it points to, so moving it changes only its
position."""
import pytest
import yaml
from wrolpi.collections.config import get_playlists_config
from wrolpi.collections.models import Collection, CollectionItem
from wrolpi.config_version import get_config_version
from wrolpi.conftest import config_was_imported
from wrolpi.files.models import FileGroup
from wrolpi.test.test_config_import import edit_config, statements, writes
ONE = ['url:/one', 'file:a.txt', 'zim:home']
@pytest.fixture
async def saved_playlists(test_session, test_directory, async_client, make_files_structure, test_zim, tag_factory,
await_switches):
"""Playlist `One` (a URL, a file, and a Zim entry, tagged `one`) and `Two` (a URL), saved to the config (which
records its version)."""
from copy import deepcopy
config = get_playlists_config()
config._config = deepcopy(config.default_config)
tag = await tag_factory('one')
a, = make_files_structure({'a.txt': 'a'})
a = FileGroup.from_paths(test_session, a)
one = Collection(name='One', kind='playlist', description='The first', tag_id=tag.id)
two = Collection(name='Two', kind='playlist')
test_session.add_all([a, one, two])
test_session.flush([a, one, two])
one.add_url(test_session, '/one', title='One URL')
one.add_file_group(a, session=test_session)
one.add_zim_entry(test_session, test_zim.id, 'home')
two.add_url(test_session, '/two')
test_session.commit()
config.dump_config()
await await_switches()
assert get_config_version(test_session, 'playlists.yaml') is not None
return config.get_file()
def items(test_session, name='One') -> list:
"""`kind:reference` of each item of the playlist, in order."""
test_session.expire_all()
collection = test_session.query(Collection).filter_by(name=name, kind='playlist').one()
return [f'{i.item_kind}:{i.url or i.zim_entry or i.file_group.primary_path.name}' for i in collection.items]
def item_ids(test_session, name='One') -> dict:
test_session.expire_all()
collection = test_session.query(Collection).filter_by(name=name, kind='playlist').one()
return {i.id for i in collection.items}
def edit_playlist(path, name, change):
def change_data(data):
change(next(i for i in data['playlists'] if i['name'] == name))
edit_config(path, change_data)
@pytest.mark.asyncio
async def test_unchanged_import_writes_nothing(test_session, saved_playlists):
"""Importing a config the database already matches opens no write transaction."""
config = get_playlists_config()
with statements() as collected:
config.import_config()
assert config.successful_import is True
assert writes(collected) == []
# The version, then one snapshot per section.
assert len(collected) <= 4, collected
assert items(test_session) == ONE
@pytest.mark.asyncio
async def test_reordered_items_keep_their_rows(test_session, saved_playlists):
"""Moving and inserting items changes only positions: every item keeps its row (and ID)."""
before = item_ids(test_session)
def change(playlist):
playlist['items'].reverse()
playlist['items'].insert(0, dict(url='/new', title='New'))
edit_playlist(saved_playlists, 'One', change)
config = get_playlists_config()
with statements() as collected:
config.import_config()
assert config.successful_import is True
assert items(test_session) == ['url:/new', 'zim:home', 'file:a.txt', 'url:/one']
assert before < item_ids(test_session)
assert not [i for i in writes(collected) if i.upper().startswith('DELETE FROM COLLECTION_ITEM')]
@pytest.mark.asyncio
async def test_removed_rows_are_deleted(test_session, saved_playlists):
"""A removed item and a removed playlist are deleted; a changed description and title are saved."""
def change(data):
data['playlists'] = [i for i in data['playlists'] if i['name'] != 'Two']
one = data['playlists'][0]
one['description'] = 'Changed'
one['items'] = [i for i in one['items'] if 'zim' not in i]
one['items'][0]['title'] = 'Renamed'
edit_config(saved_playlists, change)
config = get_playlists_config()
config.import_config()
assert config.successful_import is True
assert {i.name for i in test_session.query(Collection).filter_by(kind='playlist')} == {'One'}
assert items(test_session) == ['url:/one', 'file:a.txt']
one = test_session.query(Collection).filter_by(name='One', kind='playlist').one()
assert (one.description, one.items[0].title) == ('Changed', 'Renamed')
@pytest.mark.asyncio
async def test_unindexed_file_is_skipped_without_a_write(test_session, saved_playlists):
"""An item whose file is not indexed (yet) is skipped before the write, and keeps its place: the items after it
keep their file positions. The next import, with nothing else to do, writes nothing."""
edit_playlist(saved_playlists, 'Two', lambda playlist: playlist['items'].insert(0, dict(file='later.txt')))
config = get_playlists_config()
config.import_config()
test_session.expire_all()
assert [i.position for i in test_session.query(Collection).filter_by(name='Two').one().items] == [2]
with statements() as collected:
config.import_config()
assert config.successful_import is True
assert config.import_skipped == 1
assert writes(collected) == []
assert items(test_session, 'Two') == ['url:/two']
@pytest.mark.asyncio
async def test_new_playlist_is_created_with_its_items(test_session, saved_playlists):
edit_config(saved_playlists, lambda data: data['playlists'].append(dict(
name='Three', description='New', tag_name='one', items=[dict(url='/three'), dict(file='a.txt')])))
get_playlists_config().import_config()
three = test_session.query(Collection).filter_by(name='Three', kind='playlist').one()
assert (three.description, three.tag_name) == ('New', 'one')
assert items(test_session, 'Three') == ['url:/three', 'file:a.txt']
@pytest.mark.asyncio
async def test_unknown_tag_is_dropped(test_session, saved_playlists, test_tags_config, await_switches):
"""Once tags.yaml is imported, a playlist's Tag that no longer exists is dropped and the config is saved without
it: the next import writes nothing."""
from wrolpi.tags import get_tags_config
get_tags_config().dump_config()
get_tags_config().import_config()
edit_playlist(saved_playlists, 'One', lambda playlist: playlist.update(tag_name='gone'))
config = get_playlists_config()
config.import_config()
await await_switches()
assert test_session.query(Collection).filter_by(name='One', kind='playlist').one().tag_id is None
assert 'gone' not in saved_playlists.read_text()
with statements() as collected:
config.import_config()
assert writes(collected) == []
@pytest.mark.asyncio
async def test_moved_playlist_file_counts(test_session, test_directory, saved_playlists, make_files_structure):
"""playlists.yaml saves file items by path: moving a FileGroup in a playlist makes the database newer than the
file, so a restart before the save does not drop the item. Moving another FileGroup does not."""
before = get_config_version(test_session, 'playlists.yaml')
a = test_session.query(FileGroup).filter_by(primary_path=test_directory / 'a.txt').one()
a.move(test_directory / 'moved.txt')
test_session.commit()
assert get_config_version(test_session, 'playlists.yaml') == before + 1
other = FileGroup.from_paths(test_session, *make_files_structure({'other.txt': 'other'}))
test_session.commit()
other.move(test_directory / 'other-moved.txt')
test_session.commit()
assert get_config_version(test_session, 'playlists.yaml') == before + 1
@pytest.mark.asyncio
async def test_bulk_moved_playlist_file_counts(test_session, test_directory, saved_playlists):
"""A move written with raw SQL counts too."""
from wrolpi.files.lib import _bulk_update_file_groups_db
before = get_config_version(test_session, 'playlists.yaml')
_bulk_update_file_groups_db(test_session, {test_directory / 'a.txt': test_directory / 'moved/a.txt'})
test_session.commit()
assert get_config_version(test_session, 'playlists.yaml') > before
@pytest.mark.asyncio
async def test_refresh_changing_primary_file_counts(test_session, test_directory, make_files_structure, video_bytes,
refresh_files):
"""A refresh that changes a playlist FileGroup's primary file counts as a change of the item's path."""
make_files_structure({'video.mp4': video_bytes, 'video.jpg': 'poster'})
await refresh_files()
playlist = Collection(name='Videos', kind='playlist')
test_session.add(playlist)
test_session.flush([playlist])
playlist.add_file_group(test_session.query(FileGroup).one(), session=test_session)
test_session.commit()
config_was_imported(test_session, 'playlists.yaml')
test_session.commit()
(test_directory / 'video.mp4').unlink()
await refresh_files()
test_session.expire_all()
assert test_session.query(FileGroup).one().primary_path != test_directory / 'video.mp4'
assert get_config_version(test_session, 'playlists.yaml') > 0

View file

@ -848,6 +848,7 @@ class ConfigFile:
for i in self.sections}
# `prepare` may skip rows it cannot apply.
self.import_skipped = sum(len(invalid) for _, invalid in file_rows.values())
self.import_deletes = decision == IMPORT
if any(diffs.values()):
self.prepare(diffs)
@ -889,7 +890,8 @@ class ConfigFile:
def prepare(self, diffs: dict):
"""Slow work the write needs (files, network), run with no transaction open, and only when there are
differences. `diffs` maps each section's name to its `SectionDiff`; it may drop rows it cannot apply
(count them in `import_skipped`), or add what it found to a row for `upsert`.
(count them in `import_skipped`), or add what it found to a row for `upsert`. `import_deletes`: whether
this import deletes what the file no longer lists.
It must be safe to repeat and to abandon: when the database changes before the write, the import decides
again (up to three passes), and the diffs it was given may never be written (the write fails, or the next

View file

@ -6,11 +6,14 @@ Rows are compared as plain data keyed the way the file names them (a URL, a name
ORM objects and the read can finish before any slow preparation starts.
"""
import operator
import pathlib
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Hashable, Iterable, List, Set, Tuple
from sqlalchemy.orm import Session
from wrolpi.common import get_media_directory
@dataclass
class SectionDiff:
@ -71,6 +74,12 @@ def diff_section(file_rows: Dict[Hashable, dict], invalid_keys: Set[Hashable], d
return diff
def media_relative(path) -> str:
"""A path as a config names it: relative to the media directory."""
path = pathlib.Path(path)
return str(path.relative_to(get_media_directory()) if path.is_absolute() else path)
def chunks(items: Iterable[Any], size: int = 500) -> Iterable[List[Any]]:
"""Split `items` for `IN (...)` queries (SQLite limits the parameters of one statement)."""
items = list(items)

View file

@ -905,6 +905,20 @@ def _tagged_file_group_moved(file_group: FileGroup) -> bool:
register_ownership('tags.yaml', FileGroup, columns={'primary_path'}, row=_tagged_file_group_moved)
def _playlist_file_group_moved(file_group: FileGroup) -> bool:
"""An existing FileGroup in a playlist changed path (playlists.yaml saves file items by path)."""
if not inspect(file_group).attrs.primary_path.history.deleted:
return False
session = Session.object_session(file_group)
return session.connection().execute(
text('SELECT 1 FROM collection_item JOIN collection ON collection.id = collection_item.collection_id'
" WHERE collection_item.file_group_id = :id AND collection.kind = 'playlist' LIMIT 1"),
{'id': file_group.id}).first() is not None
register_ownership('playlists.yaml', FileGroup, columns={'primary_path'}, row=_playlist_file_group_moved)
class Directory(ModelHelper, Base):
"""A representation of a file directory in the media directory."""
__tablename__ = 'directory'

View file

@ -759,6 +759,19 @@ class FileWorker(FileMoveMixin, FileReorganizeMixin):
{'updates': updates})
if curs.fetchone():
count_config_change_curs(curs, 'tags.yaml')
# playlists.yaml saves file items by path too.
curs.execute(
'''SELECT 1
FROM collection_item
JOIN collection ON collection.id = collection_item.collection_id
JOIN file_group ON file_group.id = collection_item.file_group_id
JOIN json_each(:updates) AS v ON file_group.id = json_extract(v.value, '$[0]')
WHERE collection.kind = 'playlist'
AND file_group.primary_path != json_extract(v.value, '$[1]')
LIMIT 1''',
{'updates': updates})
if curs.fetchone():
count_config_change_curs(curs, 'playlists.yaml')
curs.execute(
'''UPDATE file_group
SET primary_path = v.new_path

View file

@ -14,7 +14,7 @@ from sqlalchemy.orm import Mapped, relationship, Session, load_only
from wrolpi import dates, flags
from wrolpi.common import loaded, ModelHelper, Base, logger, ConfigFile, get_media_directory, background_task, \
get_relative_to_media_directory, is_valid_hex_color, walk, INVALID_FILE_CHARS, get_wrolpi_config
from wrolpi.config_import import ConfigSection, SectionDiff, chunks
from wrolpi.config_import import ConfigSection, SectionDiff, chunks, media_relative
from wrolpi.config_version import count_config_change, register_ownership
from wrolpi.dates import TZDateTime
from wrolpi.db import get_db_session, named_placeholders, session_curs, serializer
@ -307,12 +307,6 @@ def _utc_or_none(value) -> Optional[datetime]:
return value.astimezone(pytz.UTC) if value.tzinfo else value.replace(tzinfo=pytz.UTC)
def _relative_path(path) -> str:
"""A path as the config names it: relative to the media directory."""
path = pathlib.Path(path)
return str(path.relative_to(get_media_directory()) if path.is_absolute() else path)
def without_tag_names(download_tag_names: Optional[List[str]], settings: Optional[dict], names: Set[str]) \
-> Tuple[Optional[List[str]], Optional[dict]]:
"""A Download's tag_names and settings without these Tag names, in the settings' tag_names and channel_tag_name
@ -398,7 +392,7 @@ class TagFilesSection(ConfigSection):
for item in config.get('tag_files') or []:
try:
tag_name, path, *rest = item
key = (tag_name, _relative_path(path))
key = (tag_name, media_relative(path))
except Exception as e:
logger.error(f'Skipping invalid tagged file in config: {item}', exc_info=e)
continue
@ -497,7 +491,7 @@ class TagZimsSection(ConfigSection):
for item in config.get('tag_zims') or []:
try:
tag_name, zim_path, zim_entry, *rest = item
key = (tag_name, _relative_path(zim_path), zim_entry)
key = (tag_name, media_relative(zim_path), zim_entry)
except Exception as e:
logger.error(f'Skipping invalid tagged Zim entry in config: {item}', exc_info=e)
continue