Playlists can be tagged on creation. Playlist destination is no longer empty by default.

This commit is contained in:
Roland 2026-06-10 15:56:20 -06:00
parent 141a8b016e
commit bfbfa0710b
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
7 changed files with 122 additions and 10 deletions

View file

@ -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'];
}

View file

@ -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() {
<Input autoFocus placeholder='Playlist name...' value={name}
onChange={(e, {value}) => setName(value)}/>
</Form.Field>
<Form.Field>
<label>
Tag
<InfoPopup content='Optional. A tagged playlist lives under its tag in the
Playlists Directory.'/>
</label>
<TagsSelector
limit={1}
selectedTagNames={tagName ? [tagName] : []}
onAdd={setTagName}
onRemove={() => setTagName(null)}
/>
</Form.Field>
</Form>
</Modal.Content>
<Modal.Actions>
@ -504,8 +520,9 @@ export function PlaylistEditPage() {
<Form.Field>
<label>
Directory
<InfoPopup content='Optional custom directory. When empty, the playlist
lives in the Playlists Directory (under its tag, if tagged).'/>
<InfoPopup content='Where the playlist lives on disk. By default it is
managed automatically in the Playlists Directory (under its tag, if
tagged); choose a different directory to manage it manually.'/>
</label>
<DirectorySearch
value={directory}

View file

@ -68,7 +68,8 @@ async def create_collection_endpoint(request: Request, body: schema.CollectionCr
session = request.ctx.session
try:
collection = lib.create_collection(
session, name=body.name, description=body.description, kind=body.kind or 'playlist')
session, name=body.name, description=body.description, kind=body.kind or 'playlist',
tag_name=body.tag_name)
except ValidationError as e:
return json_response({'error': str(e)}, status=HTTPStatus.BAD_REQUEST)

View file

@ -276,6 +276,14 @@ def update_collection(
"""
collection = Collection.find_by_id(session, collection_id)
# The managed location before any rename/retag. The API reports this location for a playlist
# whose stored directory is None, so the UI round-trips it back; recognize it below and keep
# storing None (auto-managed) instead of freezing the playlist at the pre-rename path.
old_default = None
if collection.kind == 'playlist':
from .sync import get_playlists_directory, _default_playlist_subdir
old_default = _default_playlist_subdir(get_playlists_directory(), collection)
# Rename if provided.
if name is not None:
name = name.strip()
@ -333,8 +341,10 @@ def update_collection(
_default_playlist_subdir, sync_playlists_directory)
# A directory equal to the managed tag location is not "custom" -- store None so the sync
# keeps auto-managing it (future tag changes keep moving the playlist automatically).
if collection.directory and \
pathlib.Path(collection.directory) == _default_playlist_subdir(get_playlists_directory(), collection):
# The location is compared both before (`old_default`) and after this update, so a managed
# playlist stays managed when the UI round-trips the displayed directory with a rename.
new_default = _default_playlist_subdir(get_playlists_directory(), collection)
if collection.directory and pathlib.Path(collection.directory) in (new_default, old_default):
collection.directory = None
session.flush()
# Commit before the cleanup and switch activations so the background sync reads the NEW
@ -354,8 +364,8 @@ def update_collection(
def create_collection(session: Session, name: str, description: Optional[str] = None,
kind: str = 'playlist') -> 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])

View file

@ -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
# (<playlists>/<tag>/<name>). 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,

View file

@ -13,6 +13,7 @@ class CollectionCreateRequest:
name: str
description: Optional[str] = None
kind: str = 'playlist'
tag_name: Optional[str] = None
@dataclass

View file

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