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'.
'' +
'' +
'