diff --git a/lib/metadata_db.py b/lib/metadata_db.py index ccdbbb9..3aec7e3 100644 --- a/lib/metadata_db.py +++ b/lib/metadata_db.py @@ -667,6 +667,16 @@ class MetadataDB: self.conn.execute(_ddl) except sqlite3.OperationalError: pass + # Manual playlist ordering (tester ask): `position` orders the + # PLAYLISTS themselves (playlist_songs.position orders songs within + # one). NULL = unpositioned — those sort alphabetically AFTER the + # manually positioned ones, and system playlists stay pinned first + # regardless (see list_playlists). Additive, idempotent — same + # pattern as `rules`/`kind` above. + try: + self.conn.execute("ALTER TABLE playlists ADD COLUMN position INTEGER") + except sqlite3.OperationalError: + pass # Wishlist / "wanted" (feedBack#636 item 4): a persisted, actionable # list of songs the user does NOT own yet — the *arr "Wanted/Monitored" # analogue. Unlike playlists (which reference owned local songs by @@ -2405,10 +2415,14 @@ class MetadataDB: def list_playlists(self) -> list[dict]: from urllib.parse import quote + # Order: system playlists pinned first, then manually positioned user + # playlists (position = drag order), then unpositioned ones + # alphabetically — so a manual order wins and a playlist created after + # a reorder still lands somewhere predictable (see reorder_playlists). rows = self.conn.execute( "SELECT id, name, system_key, created_at, updated_at, kind FROM playlists " "WHERE rules IS NULL " # smart collections live in the source picker, not here - "ORDER BY (system_key IS NULL), name COLLATE NOCASE" + "ORDER BY (system_key IS NULL), (position IS NULL), position, name COLLATE NOCASE" ).fetchall() out = [] for r in rows: @@ -2710,6 +2724,30 @@ class MetadataDB: self.conn.commit() return True + def reorder_playlists(self, ordered_ids: list[int]) -> bool: + """Persist a manual ordering of the playlists THEMSELVES: position = + index in `ordered_ids` (the songs-within sibling is reorder_playlist). + Caller (the route) validates the list is an exact permutation of the + current non-system playlist ids.""" + with self._lock: + for pos, pid in enumerate(ordered_ids): + self.conn.execute( + "UPDATE playlists SET position = ?, updated_at = datetime('now') WHERE id = ?", + (pos, pid), + ) + self.conn.commit() + return True + + def clear_playlist_positions(self) -> bool: + """Drop every manual playlist position → back to alphabetical + (the "Sort A–Z" affordance).""" + with self._lock: + self.conn.execute( + "UPDATE playlists SET position = NULL, updated_at = datetime('now') " + "WHERE position IS NOT NULL") + self.conn.commit() + return True + def toggle_saved(self, filename: str) -> bool: """Add/remove a song on the Saved-for-Later playlist. Returns new state. The presence check and the add/remove run under one lock so two diff --git a/lib/routers/playlists.py b/lib/routers/playlists.py index f517fc2..c8198d5 100644 --- a/lib/routers/playlists.py +++ b/lib/routers/playlists.py @@ -70,6 +70,37 @@ def api_create_playlist(data: dict): return appstate.meta_db.create_playlist(name, kind=kind) +@router.post("/api/playlists/reorder") +def api_reorder_playlists(data: dict): + """Manual ordering of the playlists themselves (position = index in + `order`); the songs-within sibling is /api/playlists/{pid}/reorder. + System playlists stay pinned first and are not part of the order.""" + order = data.get("order") + if not isinstance(order, list) or not all( + isinstance(i, int) and not isinstance(i, bool) for i in order): + return JSONResponse({"error": "order must be a list of playlist ids"}, status_code=400) + # Require an exact permutation of the current non-system playlist ids: a + # list with duplicates, omissions, extras, unknown ids, or a system id + # would otherwise produce duplicate positions / a partial reorder while + # still returning 200 (mirrors the songs-within validation). + current = [p["id"] for p in appstate.meta_db.list_playlists() if not p["system_key"]] + if len(order) != len(current) or sorted(order) != sorted(current): + return JSONResponse( + {"error": "order must be a permutation of your playlists' ids"}, + status_code=400, + ) + appstate.meta_db.reorder_playlists(order) + return api_list_playlists() + + +@router.post("/api/playlists/sort-alpha") +def api_sort_playlists_alpha(): + """Clear every manual playlist position → back to the alphabetical + default (system playlists were pinned first either way).""" + appstate.meta_db.clear_playlist_positions() + return api_list_playlists() + + @router.get("/api/playlists/{pid}") def api_get_playlist(pid: int): pl = appstate.meta_db.get_playlist(pid) diff --git a/static/v3/playlists.js b/static/v3/playlists.js index 7684344..950345f 100644 --- a/static/v3/playlists.js +++ b/static/v3/playlists.js @@ -144,19 +144,29 @@ const root = document.getElementById('v3-playlists'); if (!root) return; const lists = (await jget('/api/playlists')) || []; + // Drag-to-reorder is for user playlists only — system ones (Saved for + // Later) stay pinned first by the server ordering. + const userCount = lists.filter((p) => !p.system_key).length; root.innerHTML = '
' + '
' + + // Sort A–Z: clears the manual (drag) order server-side. Only worth + // showing once there are two user playlists to order. + (userCount > 1 + ? '' : '') + // Curated album (P6): a hand-picked ORDERED set with a chosen chart // per track — same machinery as a playlist, kind='album'. '' + '' + '
' + (lists.length - ? '
' + lists.map((p) => - '').join('') + '
' : '

No playlists yet. Create one to group songs.

') + @@ -173,8 +183,44 @@ await jsend('POST', '/api/playlists', { name, kind: 'album' }); renderPlaylists(); }); + root.querySelector('#v3-pl-sort-az')?.addEventListener('click', async () => { + await jsend('POST', '/api/playlists/sort-alpha'); + renderPlaylists(); + }); root.querySelectorAll('[data-pl]').forEach((b) => b.addEventListener('click', () => renderPlaylistDetail(parseInt(b.getAttribute('data-pl'), 10)))); + // Drag-reorder of the playlist cards themselves (mirrors wireSongRows). + // Only user playlists carry draggable="true"; system cards are neither + // drag sources nor drop targets, so nothing can be inserted ahead of + // them (and the server pins them first regardless). + const grid = root.querySelector('#v3-pl-grid'); + if (grid) { + let dragEl = null; + grid.querySelectorAll('button[data-pl][draggable="true"]').forEach((card) => { + card.addEventListener('dragstart', () => { dragEl = card; card.classList.add('opacity-50'); }); + card.addEventListener('dragend', () => { card.classList.remove('opacity-50'); }); + card.addEventListener('dragover', (e) => { + e.preventDefault(); + if (!dragEl || dragEl === card) return; + // Grid tiles flow left→right then wrap, so the insert side + // is horizontal (the song rows' vertical-midpoint idiom, + // rotated); moving to another row targets that row's cards. + const rect = card.getBoundingClientRect(); + const after = (e.clientX - rect.left) > rect.width / 2; + card.parentNode.insertBefore(dragEl, after ? card.nextSibling : card); + }); + card.addEventListener('drop', async (e) => { + e.preventDefault(); + const order = Array.from(grid.querySelectorAll('button[data-pl][draggable="true"]')) + .map((x) => parseInt(x.getAttribute('data-pl'), 10)); + await jsend('POST', '/api/playlists/reorder', { order }); + // Re-sync from the server: if /reorder was rejected + // (concurrent change) or the request failed, the optimistic + // DOM order would otherwise diverge from what persisted. + renderPlaylists(); + }); + }); + } } async function renderPlaylistDetail(pid) { diff --git a/tests/test_playlists_api.py b/tests/test_playlists_api.py index 8b9b308..940aaba 100644 --- a/tests/test_playlists_api.py +++ b/tests/test_playlists_api.py @@ -175,3 +175,94 @@ def test_deleting_playlist_removes_custom_cover(client, server): assert _playlist_cover_path(pid).exists() client.delete(f"/api/playlists/{pid}") assert not _playlist_cover_path(pid).exists() + + +# ── Reordering the playlists THEMSELVES (not songs-within) ─────────────────── + +def _mk(client, name): + return client.post("/api/playlists", json={"name": name}).json()["id"] + + +def _ids(client): + return [p["id"] for p in client.get("/api/playlists").json()] + + +def test_playlists_default_order_is_alphabetical(client): + b = _mk(client, "Bravo") + a = _mk(client, "alpha") # NOCASE: lowercase still sorts by letter + z = _mk(client, "Zulu") + assert _ids(client) == [a, b, z] + + +def test_playlist_manual_reorder_persists(client): + a = _mk(client, "Alpha") + b = _mk(client, "Bravo") + c = _mk(client, "Charlie") + r = client.post("/api/playlists/reorder", json={"order": [c, a, b]}) + assert r.status_code == 200 + assert [p["id"] for p in r.json()] == [c, a, b] + # persists across independent list calls + assert _ids(client) == [c, a, b] + assert _ids(client) == [c, a, b] + + +def test_playlist_reorder_excludes_system_and_keeps_it_pinned(client): + # First toggle creates the "Saved for Later" system playlist. + client.post("/api/saved/toggle", json={"filename": "x.archive"}) + a = _mk(client, "Alpha") + b = _mk(client, "Bravo") + saved = next(p["id"] for p in client.get("/api/playlists").json() if p["system_key"]) + # A system id in the order is rejected — it isn't reorderable. + assert client.post("/api/playlists/reorder", json={"order": [saved, b, a]}).status_code == 400 + # User playlists reorder; the system playlist stays pinned first. + assert client.post("/api/playlists/reorder", json={"order": [b, a]}).status_code == 200 + listing = client.get("/api/playlists").json() + assert listing[0]["system_key"] == "saved_for_later" + assert [p["id"] for p in listing[1:]] == [b, a] + + +def test_playlist_reorder_rejects_bad_orders(client): + a = _mk(client, "Alpha") + b = _mk(client, "Bravo") + for bad in ( + [a], # missing an id (partial order) + [a, b, 999999], # extra unknown id + [a, a], # duplicate (drops b) + [a, 999999], # unknown id in place of b + "nope", # not a list + [a, str(b)], # non-int entry + [True, False], # bools are ints to Python — must still be rejected + None, # {"order": null} + ): + assert client.post("/api/playlists/reorder", json={"order": bad}).status_code == 400, bad + assert client.post("/api/playlists/reorder", json={}).status_code == 400 + # Nothing was persisted by any rejected request. + assert _ids(client) == [a, b] + + +def test_sort_alpha_clears_manual_order(client): + a = _mk(client, "Alpha") + b = _mk(client, "Bravo") + z = _mk(client, "Zulu") + client.post("/api/playlists/reorder", json={"order": [z, b, a]}) + assert _ids(client) == [z, b, a] + r = client.post("/api/playlists/sort-alpha") + assert r.status_code == 200 + assert [p["id"] for p in r.json()] == [a, b, z] + assert _ids(client) == [a, b, z] + + +def test_new_playlist_after_manual_reorder_sorts_alphabetically_after_positioned(client): + a = _mk(client, "Alpha") + b = _mk(client, "Bravo") + client.post("/api/playlists/reorder", json={"order": [b, a]}) + # New playlists are unpositioned → they follow the manually positioned + # ones, alphabetically among themselves, and never disturb the manual + # order ("Aardvark" would be first alphabetically). + z = _mk(client, "Zebra") + aa = _mk(client, "Aardvark") + assert _ids(client) == [b, a, aa, z] + # A subsequent full reorder must include the newcomers (exact permutation). + assert client.post("/api/playlists/reorder", json={"order": [b, a]}).status_code == 400 + assert client.post("/api/playlists/reorder", json={"order": [z, aa, b, a]}).status_code == 200 + assert _ids(client) == [z, aa, b, a]