+
+ 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': ' '})