mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 02:48:38 -04:00
Playlists can be tagged on creation. Playlist destination is no longer empty by default.
This commit is contained in:
parent
141a8b016e
commit
bfbfa0710b
7 changed files with 122 additions and 10 deletions
|
|
@ -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'];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class CollectionCreateRequest:
|
|||
name: str
|
||||
description: Optional[str] = None
|
||||
kind: str = 'playlist'
|
||||
tag_name: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
|
|
@ -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': ' '})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue