From bfbfa0710bd4979367d80d692651d5de81c7c8f1 Mon Sep 17 00:00:00 2001 From: Roland Date: Wed, 10 Jun 2026 15:56:20 -0600 Subject: [PATCH] Playlists can be tagged on creation. Playlist destination is no longer empty by default. --- app/src/api.js | 6 +- app/src/components/Playlists.js | 23 ++++++- wrolpi/collections/api.py | 3 +- wrolpi/collections/lib.py | 27 ++++++-- wrolpi/collections/models.py | 7 ++ wrolpi/collections/schema.py | 1 + .../collections/test/test_playlist_items.py | 65 +++++++++++++++++++ 7 files changed, 122 insertions(+), 10 deletions(-) diff --git a/app/src/api.js b/app/src/api.js index 5665836f..261e7d59 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -331,6 +331,7 @@ export async function getSettings() { map_destination: content.map_destination, nav_color: content.nav_color, media_directory: content.media_directory, + playlists_destination: content.playlists_destination, require_cookies_unlocked: content.require_cookies_unlocked, require_media_mounted: content.require_media_mounted, save_ffprobe_json: content.save_ffprobe_json, @@ -2286,9 +2287,10 @@ export async function getPlaylist(playlistId) { throw Error(message); } -export async function createPlaylist(name, description) { +export async function createPlaylist(name, description, tagName) { // Trailing slash avoids Sanic's 302 redirect on the root POST route (which would drop the body). - const response = await apiPost(`${COLLECTIONS_API}/`, {name, description, kind: 'playlist'}); + const response = await apiPost(`${COLLECTIONS_API}/`, + {name, description, tag_name: tagName || null, kind: 'playlist'}); if (response.ok) { return (await response.json())['collection']; } diff --git a/app/src/components/Playlists.js b/app/src/components/Playlists.js index dbb98022..096ff180 100644 --- a/app/src/components/Playlists.js +++ b/app/src/components/Playlists.js @@ -36,6 +36,7 @@ import { updatePlaylist, } from "../api"; import {ThemeContext} from "../contexts/contexts"; +import {TagsSelector} from "../Tags"; const PLAYLIST_COLUMNS = [ @@ -95,6 +96,7 @@ export function PlaylistsPage() { const [searchStr, setSearchStr] = useOneQuery('name'); const [modalOpen, setModalOpen] = useState(false); const [name, setName] = useState(''); + const [tagName, setTagName] = useState(null); const searchInputRef = React.useRef(); const navigate = useNavigate(); @@ -102,9 +104,10 @@ export function PlaylistsPage() { const trimmed = name.trim(); if (!trimmed) return; try { - const playlist = await createPlaylist(trimmed); + const playlist = await createPlaylist(trimmed, undefined, tagName); toast({type: 'success', title: 'Playlist created', description: trimmed, time: 3000}); setName(''); + setTagName(null); setModalOpen(false); if (playlist && playlist.id) { navigate(`/playlists/${playlist.id}`); @@ -147,6 +150,19 @@ export function PlaylistsPage() { setName(value)}/> + + + setTagName(null)} + /> + @@ -504,8 +520,9 @@ export function PlaylistEditPage() { Collection: - """Create a new Collection (defaults to a 'playlist'). + kind: str = 'playlist', tag_name: Optional[str] = None) -> Collection: + """Create a new Collection (defaults to a 'playlist'), optionally with an initial tag. Raises: ValidationError: if the name is empty/invalid for the kind, or a duplicate exists. @@ -368,12 +378,21 @@ def create_collection(session: Session, name: str, description: Optional[str] = if not collection_type_registry.validate(kind, name): description_msg = collection_type_registry.get_description(kind) or 'Invalid name format' raise ValidationError(f'Invalid {kind} name {name!r}. {description_msg}') + # Same rule as update_collection: only playlists can be tagged without a directory, and a new + # collection never has one yet. + if tag_name and kind != 'playlist': + raise ValidationError(f'A new {kind} cannot be created with a tag. Set a directory first.') existing = session.query(Collection).filter_by(name=name, kind=kind).first() if existing: raise ValidationError(f'A {kind} named {name!r} already exists') collection = Collection(name=name, description=description, kind=kind) + if tag_name: + tag = Tag.get_or_create_tag(session, name=tag_name) + # Assign the relationship too so `collection.tag_name` is fresh for serialization. + collection.tag = tag + collection.tag_id = tag.id session.add(collection) session.flush([collection]) diff --git a/wrolpi/collections/models.py b/wrolpi/collections/models.py index 848fbf74..cca3d7e8 100644 --- a/wrolpi/collections/models.py +++ b/wrolpi/collections/models.py @@ -956,6 +956,13 @@ class Collection(ModelHelper, Base): except ValueError: # Directory is not relative to media directory, return as-is directory_str = str(self.directory) + elif self.kind == 'playlist': + # A playlist without an explicit directory lives at the managed location + # (//). Report that location so the directory is known even + # before the sync creates it; the DB keeps None so the sync auto-manages it. + from wrolpi.collections.sync import get_playlists_directory, _default_playlist_subdir + directory_str = str(get_relative_to_media_directory( + _default_playlist_subdir(get_playlists_directory(), self))) return { 'id': self.id, diff --git a/wrolpi/collections/schema.py b/wrolpi/collections/schema.py index 8e28c650..55fa35d3 100644 --- a/wrolpi/collections/schema.py +++ b/wrolpi/collections/schema.py @@ -13,6 +13,7 @@ class CollectionCreateRequest: name: str description: Optional[str] = None kind: str = 'playlist' + tag_name: Optional[str] = None @dataclass diff --git a/wrolpi/collections/test/test_playlist_items.py b/wrolpi/collections/test/test_playlist_items.py index b1344283..8a1bf0b7 100644 --- a/wrolpi/collections/test/test_playlist_items.py +++ b/wrolpi/collections/test/test_playlist_items.py @@ -20,6 +20,71 @@ async def test_create_playlist(async_client, test_session): assert test_session.query(Collection).filter_by(name='Fire Making', kind='playlist').one() +@pytest.mark.asyncio +async def test_new_playlist_reports_managed_directory(async_client, test_session): + """A new playlist's directory is reported immediately (the managed location), even though the + sync has not yet created it on disk and the DB stores None (auto-managed).""" + _, response = await async_client.post('/api/collections', json={'name': 'Fire Making'}) + assert response.status_code == HTTPStatus.CREATED, response.json + assert response.json['collection']['directory'] == 'playlists/Fire Making' + + # The DB keeps None so the sync auto-manages the location. + collection = test_session.query(Collection).filter_by(name='Fire Making').one() + assert collection.directory is None + + # Tagging moves the reported managed location under the tag. + cid = response.json['collection']['id'] + _, response = await async_client.put(f'/api/collections/{cid}', json={'tag_name': 'Survival'}) + assert response.status_code == HTTPStatus.OK, response.json + assert response.json['collection']['directory'] == 'playlists/Survival/Fire Making' + + # The playlist list reports the managed location too. + _, response = await async_client.get('/api/collections?kind=playlist') + assert response.json['collections'][0]['directory'] == 'playlists/Survival/Fire Making' + + +@pytest.mark.asyncio +async def test_rename_playlist_round_trips_managed_directory(async_client, test_session): + """Renaming while the UI round-trips the displayed (pre-rename) managed directory keeps the + playlist auto-managed at its new location, instead of pinning it to the old path.""" + _, response = await async_client.post('/api/collections', json={'name': 'Old Name'}) + cid = response.json['collection']['id'] + + # The edit form submits the directory it displayed (the old managed location) with the rename. + _, response = await async_client.put(f'/api/collections/{cid}', json={ + 'name': 'New Name', 'directory': 'playlists/Old Name'}) + assert response.status_code == HTTPStatus.OK, response.json + assert response.json['collection']['name'] == 'New Name' + assert response.json['collection']['directory'] == 'playlists/New Name' + + collection = test_session.query(Collection).filter_by(id=cid).one() + test_session.refresh(collection) + assert collection.directory is None, 'playlist must stay auto-managed' + + +@pytest.mark.asyncio +async def test_create_playlist_with_initial_tag(async_client, test_session): + """A playlist can be created with an initial tag; its managed directory is tag-namespaced.""" + from wrolpi.tags import Tag + + _, response = await async_client.post('/api/collections', + json={'name': 'Fire Making', 'tag_name': 'Survival'}) + assert response.status_code == HTTPStatus.CREATED, response.json + assert response.json['collection']['tag_name'] == 'Survival' + assert response.json['collection']['directory'] == 'playlists/Survival/Fire Making' + + # The tag was created, and the playlist remains auto-managed (no stored directory). + assert test_session.query(Tag).filter_by(name='Survival').one() + collection = test_session.query(Collection).filter_by(name='Fire Making').one() + assert collection.tag_name == 'Survival' + assert collection.directory is None + + # Non-playlist kinds cannot be created with a tag (they need a directory first). + _, response = await async_client.post('/api/collections', json={ + 'name': 'example.com', 'kind': 'domain', 'tag_name': 'Survival'}) + assert response.status_code == HTTPStatus.BAD_REQUEST + + @pytest.mark.asyncio async def test_create_playlist_rejects_blank_and_duplicate(async_client, test_session): _, response = await async_client.post('/api/collections', json={'name': ' '})