Skip to content

Commit 0942a3a

Browse files
Merge pull request #556 from michaelmoskie/issue/477-client-playlists
Add playlist editing endpoints for connected clients
2 parents d553f5b + ad471e0 commit 0942a3a

20 files changed

Lines changed: 1854 additions & 62 deletions

‎docs/api-reference.md‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3540,6 +3540,56 @@ The playlists [`GET /playlists`](#get-playlists) lists, with track ids in playli
35403540

35413541
Keep the other people's playlists apart from the caller's own (a "Shared with me" and a "Public playlists" section, each entry labelled with its `owner`).
35423542

3543+
### Mobile playlist writes
3544+
3545+
`GET /api/server/info` advertises `capabilities.playlist_editing: true` when these routes are available. These routes use stable mobile **track IDs**, and write only **manual** (Library-created) playlists. An inaccessible playlist or track returns `404`; imported playlists and callers without the required write permission return `403`; malformed bodies or fields return `400`; a stale reorder returns `409` without changing the playlist. The library sync index must be ready; otherwise a write returns `503` (`Starting up`). There is no whole-playlist delete route in this mobile API.
3546+
3547+
Create and rename require the playlist owner or an admin. Add, remove, and reorder require the owner, an admin, or an editor of a shared playlist. Successful changes notify connected WebSocket clients with `playlists_changed` according to playlist visibility.
3548+
3549+
All successful writes return the canonical shape `{ "name", "manual", "count", "track_ids" }`, where `track_ids` is in playlist order and includes every repeated occurrence. Rename also returns `previous`; track edits may also return `added`, `removed`, and `already`.
3550+
3551+
#### `POST /api/v1/playlists`
3552+
3553+
Create an empty manual playlist.
3554+
3555+
**Body:** `{ "name": "Late night" }`
3556+
3557+
**Response:** `{ "name": "Late night", "manual": true, "count": 0, "track_ids": [] }`
3558+
3559+
#### `POST /api/v1/playlists/rename`
3560+
3561+
Rename a manual playlist.
3562+
3563+
**Body:** `{ "name": "Late night", "new_name": "Night drive" }`
3564+
3565+
**Response:** `{ "name": "Night drive", "previous": "Late night", "manual": true, "count": 0, "track_ids": [] }`
3566+
3567+
#### `POST /api/v1/playlists/tracks`
3568+
3569+
Add or remove tracks from a manual playlist. The body accepts `name` and any combination of these optional fields:
3570+
3571+
```json
3572+
{
3573+
"name": "Late night",
3574+
"add": ["track-id"],
3575+
"remove": ["track-id"],
3576+
"remove_entries": [{ "track_id": "track-id", "occurrence": 0 }],
3577+
"allow_duplicates": false
3578+
}
3579+
```
3580+
3581+
`add` and `remove` are arrays of track IDs. `remove` removes every copy of each ID; `remove_entries` removes only the specified zero-based occurrence of an ID in playlist order. A missing occurrence removes nothing. By default, adding an ID already in the playlist skips it and lists it in `already`; set `allow_duplicates` to `true` to add another copy. The response may include `added` and `removed` counts and `already` track IDs, alongside the canonical result shape.
3582+
3583+
The combined number of IDs in `add`, `remove`, and `remove_entries` may not exceed 10,000. The reorder endpoint also accepts at most 10,000 IDs. Larger requests return `400` before any IDs are resolved or the playlist is changed. Unknown and caller-hidden IDs both return `404`.
3584+
3585+
#### `PUT /api/v1/playlists/order`
3586+
3587+
Replace the order of a manual playlist's tracks. `track_ids` must describe the caller-visible playlist contents, including each repeated occurrence.
3588+
3589+
**Body:** `{ "name": "Late night", "track_ids": ["track-id", "track-id-2"] }`
3590+
3591+
If the playlist changed since the client loaded it, the request returns `409`; reload and retry with the current IDs. Success returns the canonical result shape.
3592+
35433593
---
35443594

35453595
### `GET /api/v1/usernames`

‎docs/mobile-client-contract.md‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Build the address from the resolved host + `port`. Discovery is a convenience: a
3838
"require_sign_in": true,
3939
"capabilities": {
4040
"transcoding": { "available": true, "formats": ["aac", "mp3", "opus"], "bitrates": [96, 128, 160, 192, 256, 320] },
41-
"signed_urls": true, "pairing": true, "password_sign_in": true, "podcasts": true, "discover": true, "lyrics": true, "library_sync": true
41+
"signed_urls": true, "pairing": true, "password_sign_in": true, "podcasts": true, "discover": true, "lyrics": true, "library_sync": true, "playlist_editing": true
4242
}
4343
}
4444
```
@@ -152,9 +152,23 @@ Other reads, all by track id:
152152
| `POST /api/v1/playlists/seen` | Body `{ "name" }` — clear the `new` marker of a playlist shared with the user |
153153
| `GET /api/v1/playlists/hidden?name=` | `{ "name", "songs": [{ "key", "label", "track_id", "hidden_at" }] }` — the songs the user hid from an imported playlist of theirs (`track_id` is `""` once the file is gone) |
154154
| `POST /api/v1/playlists/hidden` | Body `{ "name", "hide": [track_ids], "show": [keys] }` — hide tracks from an imported playlist of the user's (they stay out when its watch or link brings them back; the tracks stay in the library), or show hidden ones again. Answers `{ "name", "hidden", "keys", "shown", "restored", "count", "track_ids" }`; send `keys` as `show` to undo. Offer it on `mine` playlists that aren't `manual` or `liked` (`409` otherwise). See [Hidden playlist songs](features/hidden-playlist-songs.md) |
155+
| `POST /api/v1/playlists` | Body `{ "name" }` — create an empty manual playlist |
156+
| `POST /api/v1/playlists/rename` | Body `{ "name", "new_name" }` — rename a manual playlist |
157+
| `POST /api/v1/playlists/tracks` | Body `{ "name", "add"?: [track_id], "remove"?: [track_id], "remove_entries"?: [{ "track_id", "occurrence" }], "allow_duplicates"?: bool }` — edit a manual playlist by track ID; at most 10,000 total references across all three arrays (`400` above the limit) |
158+
| `PUT /api/v1/playlists/order` | Body `{ "name", "track_ids": [...] }` — reorder a manual playlist, including repeated occurrences; at most 10,000 IDs (`400` above the limit) |
155159
| `GET /api/v1/likes` | `{ "track_ids": [...] }`, newest first |
156160
| `PUT /api/v1/likes` | Body `{ "track_id", "liked" }` — idempotent |
157161

162+
### Editing playlists
163+
164+
The `capabilities.playlist_editing` flag in `/api/server/info` is `true` when the server supports these writes. Use the stable track IDs from library sync, never file paths. All four routes operate on manual playlists; this API has no whole-playlist delete route. Create and rename require the owner or an admin. Adding, removing, or reordering requires the owner, an admin, or an editor of a shared playlist.
165+
166+
Successful writes return `{ "name", "manual", "count", "track_ids" }`, with `track_ids` in playlist order and repeated once per occurrence. Rename also returns `previous`; track edits may return `added`, `removed`, and `already`.
167+
168+
For `POST /api/v1/playlists/tracks`, `add` appends IDs; existing IDs are skipped and listed in `already` unless `allow_duplicates` is `true`. `remove` removes every copy of each ID. `remove_entries` removes only a specified zero-based `occurrence` of that ID in playlist order; a nonexistent occurrence removes nothing. For `PUT /api/v1/playlists/order`, send the current caller-visible IDs in the desired order, retaining repeated IDs. A stale list returns `409`; reload the playlist and retry.
169+
170+
Inaccessible playlists or tracks return `404`. Imported playlists and callers without the required write permission return `403`. Invalid bodies or fields return `400`; if the library sync index is not ready, the write returns `503`. Successful playlist changes send `playlists_changed` to connected WebSocket clients according to playlist visibility.
171+
158172
## 5. Stream and download
159173

160174
```http
@@ -200,7 +214,7 @@ Messages the app can rely on:
200214
| Message | When |
201215
|---------|------|
202216
| `{ "type": "library_changed" }` | Tracks were added, removed or moved (debounced ~2 s) — sync |
203-
| `{ "type": "playlists_changed" }` | A playlist was shared, unshared, followed, copied, detached, renamed or deleted, or songs were hidden from it or shown again — refetch `GET /api/v1/playlists` |
217+
| `{ "type": "playlists_changed" }` | A playlist was shared, unshared, followed, copied, detached, renamed or deleted, its tracks changed, or songs were hidden from it or shown again — refetch `GET /api/v1/playlists`; playlist write notifications follow playlist visibility |
204218
| `{ "type": "likes", "count": 3 }` | Likes changed somewhere — refetch `GET /api/v1/likes` |
205219
| `{ "type": "queue_reload" }` | Many downloads were queued at once — refetch `GET /api/queue` |
206220
| `{ "song": {…}, "progress": 42.5, "status": "downloading"\|"done"\|"error", "message", "filename", "provider" }` | A server download's progress (no `type`) — one’s own jobs only (every job on an admin’s device) |

‎docs/zh-CN/mobile-client-contract.md‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ icon: lucide/file-code
3838
"require_sign_in": true,
3939
"capabilities": {
4040
"transcoding": { "available": true, "formats": ["aac", "mp3", "opus"], "bitrates": [96, 128, 160, 192, 256, 320] },
41-
"signed_urls": true, "pairing": true, "password_sign_in": true, "podcasts": true, "discover": true, "lyrics": true, "library_sync": true
41+
"signed_urls": true, "pairing": true, "password_sign_in": true, "podcasts": true, "discover": true, "lyrics": true, "library_sync": true, "playlist_editing": true
4242
}
4343
}
4444
```
@@ -152,9 +152,23 @@ GET /api/v1/library?since=<cursor>
152152
| `POST /api/v1/playlists/seen` | 请求体 `{ "name" }`;清除共享给用户的播放列表上的 `new` 标记。 |
153153
| `GET /api/v1/playlists/hidden?name=` | `{ "name", "songs": [{ "key", "label", "track_id", "hidden_at" }] }`,即用户从自己导入的播放列表中隐藏的歌曲(文件消失后 `track_id` 为空字符串)。 |
154154
| `POST /api/v1/playlists/hidden` | 请求体 `{ "name", "hide": [track_ids], "show": [keys] }`;从用户导入的播放列表中隐藏曲目(监控或重新导入链接时仍保持隐藏,但曲目仍在媒体库中),或重新显示隐藏曲目。返回 `{ "name", "hidden", "keys", "shown", "restored", "count", "track_ids" }`;将 `keys` 作为 `show` 发送即可撤销隐藏。应在 `mine` 为真的、且 `manual` 和 `liked` 均为假的播放列表中提供此功能(否则返回 `409`)。参见[隐藏播放列表歌曲](features/hidden-playlist-songs.md)。 |
155+
| `POST /api/v1/playlists` | 请求体 `{ "name" }`;创建空的自建播放列表。 |
156+
| `POST /api/v1/playlists/rename` | 请求体 `{ "name", "new_name" }`;重命名自建播放列表。 |
157+
| `POST /api/v1/playlists/tracks` | 请求体 `{ "name", "add"?: [track_id], "remove"?: [track_id], "remove_entries"?: [{ "track_id", "occurrence" }], "allow_duplicates"?: bool }`;按曲目 ID 编辑自建播放列表;三个数组合计最多 10,000 项(超出时返回 `400`)。 |
158+
| `PUT /api/v1/playlists/order` | 请求体 `{ "name", "track_ids": [...] }`;调整自建播放列表的曲目顺序,包括重复项;最多 10,000 个 ID(超出时返回 `400`)。 |
155159
| `GET /api/v1/likes` | `{ "track_ids": [...] }`,按最新时间在前排序。 |
156160
| `PUT /api/v1/likes` | 请求体 `{ "track_id", "liked" }`;操作具有幂等性。 |
157161

162+
### 编辑播放列表
163+
164+
`/api/server/info` 中的 `capabilities.playlist_editing` 为 `true` 时,服务器支持这些写入操作。使用媒体库同步返回的稳定曲目 ID,不要使用文件路径。这四个接口仅适用于自建播放列表,不能删除整个播放列表。所有者或管理员可以重命名;所有者、管理员或共享列表的编辑者可以添加、移除和排序曲目。
165+
166+
成功的写入返回 `{ "name", "manual", "count", "track_ids" }`,`track_ids` 按播放列表顺序排列,重复项各占一项。重命名还返回 `previous`;曲目编辑可能返回 `added`、`removed` 和 `already`。
167+
168+
在 `POST /api/v1/playlists/tracks` 中,`add` 将曲目追加到末尾;已有曲目会被跳过并列入 `already`,除非 `allow_duplicates` 为 `true`。`remove` 移除指定曲目的所有副本;`remove_entries` 仅移除从零开始计数的指定 `occurrence`,不存在的副本不会被移除。调用 `PUT /api/v1/playlists/order` 时,按目标顺序发送当前可见的所有曲目 ID,包括重复项。列表已过期时返回 `409`;重新加载后再试。
169+
170+
无法访问的播放列表或曲目返回 `404`;导入的播放列表或没有写入权限的调用者返回 `403`;请求体或字段无效返回 `400`;媒体库同步索引尚未就绪返回 `503`。成功修改播放列表后,服务器根据可见性向 WebSocket 客户端发送 `playlists_changed`。
171+
158172
## 5. 串流和下载
159173

160174
```http

‎downtify/api.py‎

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1857,24 +1857,6 @@ def require_playlist_visible(
18571857
return entry
18581858

18591859

1860-
def claim_manual_playlist(name: str, request: Optional[Request]) -> None:
1861-
"""Record a Library-created playlist's owner (best effort)."""
1862-
1863-
user_id = request_user_id(request)
1864-
if not user_id or state.manual_playlist_owners is None:
1865-
return
1866-
try:
1867-
state.manual_playlist_owners.set_owner(name, user_id)
1868-
if state.playlist_shares is not None:
1869-
# A new playlist never inherits what an older one by that name
1870-
# was shared with.
1871-
state.playlist_shares.forget(name)
1872-
except Exception:
1873-
logger.opt(exception=True).debug(
1874-
'Could not record owner for playlist {!r}', name
1875-
)
1876-
1877-
18781860
def request_ip(request: Request) -> str:
18791861
return client_ip(request.scope, _TRUSTED_PROXIES)
18801862

@@ -6995,12 +6977,18 @@ async def create_library_playlist_endpoint(request: Request) -> dict[str, Any]:
69956977
name = str(payload.get('name') or '').strip()
69966978
ctx = library_context()
69976979
try:
6998-
result = await asyncio.to_thread(create_manual_playlist, ctx, name)
6980+
result = await asyncio.to_thread(
6981+
create_manual_playlist,
6982+
ctx,
6983+
name,
6984+
state.manual_playlist_owners,
6985+
request_user_id(request),
6986+
state.playlist_shares,
6987+
)
69996988
except ManualPlaylistError as exc:
70006989
raise HTTPException(
70016990
status_code=exc.status_code, detail=str(exc)
70026991
) from exc
7003-
claim_manual_playlist(str(result.get('name') or name), request)
70046992
invalidate_library_paths_cache()
70056993
return result
70066994

@@ -7606,12 +7594,19 @@ async def copy_library_playlist_endpoint(request: Request) -> dict[str, Any]:
76067594
new_name = f'{base} {number}'
76077595
number += 1
76087596
try:
7609-
result = await asyncio.to_thread(copy_playlist, ctx, files, new_name)
7597+
result = await asyncio.to_thread(
7598+
copy_playlist,
7599+
ctx,
7600+
files,
7601+
new_name,
7602+
state.manual_playlist_owners,
7603+
request_user_id(request),
7604+
state.playlist_shares,
7605+
)
76107606
except ManualPlaylistError as exc:
76117607
raise HTTPException(
76127608
status_code=exc.status_code, detail=str(exc)
76137609
) from exc
7614-
claim_manual_playlist(str(result.get('name') or new_name), request)
76157610
invalidate_library_paths_cache()
76167611
await announce_playlists_changed()
76177612
return result

‎downtify/auth_routes.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ def get_server_info() -> dict[str, Any]:
161161
transcoding=transcoder.capability() if transcoder else None,
162162
)
163163
result['library_only'] = api.state.settings.get('library_only') is True
164+
result['capabilities']['playlist_editing'] = True
164165
return result
165166

166167

‎downtify/external_playlists.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,14 @@ def import_snapshot(
206206
while name.casefold() in taken:
207207
name = f'{base} ({suffix})'
208208
suffix += 1
209-
result = copy_playlist(ctx, files, name)
210-
owners.set_owner(result['name'], user_id)
211-
PlaylistShares(Path(self.path)).forget(result['name'])
209+
result = copy_playlist(
210+
ctx,
211+
files,
212+
name,
213+
owners,
214+
user_id,
215+
PlaylistShares(Path(self.path)),
216+
)
212217
with connect_sqlite(self.path) as conn:
213218
conn.execute(
214219
'INSERT INTO external_playlist_imports VALUES (?, ?, ?, ?) '

‎downtify/library_sync.py‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import hashlib
3434
import json
3535
import secrets
36+
import sqlite3
3637
import threading
3738
from collections.abc import Collection, Iterable
3839
from datetime import datetime, timedelta, timezone
@@ -548,6 +549,77 @@ def row_for(self, track_id: str) -> Optional[dict[str, Any]]:
548549
return None
549550
return {'id': row['track_id'], **json.loads(row['row_json'])}
550551

552+
def rows_for(self, track_ids: Iterable[str]) -> dict[str, dict[str, Any]]:
553+
"""Resolve many live ids in one database connection.
554+
555+
Includes the compatibility aliases handled by :meth:`row_for`.
556+
Missing and deleted ids are omitted from the returned mapping.
557+
"""
558+
559+
wanted = list(dict.fromkeys(track_ids))
560+
found: dict[str, dict[str, Any]] = {}
561+
with self._connect() as conn:
562+
for start in range(0, len(wanted), 400):
563+
chunk = wanted[start : start + 400]
564+
marks = ','.join('?' * len(chunk))
565+
replacements: dict[str, dict[str, Any]] = {}
566+
try:
567+
for row in conn.execute(
568+
f"""SELECT a.track_id, r.new_metadata
569+
FROM library_audio_sync_aliases a
570+
JOIN library_audio_replacements r
571+
ON r.old_file = a.old_file
572+
WHERE a.track_id IN ({marks})
573+
AND r.state IN ('linked','retained','removed','complete')""",
574+
chunk,
575+
).fetchall():
576+
replacements[row['track_id']] = json.loads(
577+
row['new_metadata']
578+
)
579+
except sqlite3.OperationalError as exc:
580+
# These tables are optional on installs predating audio
581+
# replacement support. Match replacement_for_id's behavior.
582+
if 'no such table' not in str(exc).lower():
583+
raise
584+
585+
# row_for gives compatibility aliases precedence over a
586+
# same-named live id, so resolve those first.
587+
replacement_paths = list({
588+
row['file'] for row in replacements.values()
589+
})
590+
current_rows = {}
591+
if replacement_paths:
592+
marks = ','.join('?' * len(replacement_paths))
593+
current_rows = {
594+
row['path']: json.loads(row['row_json'])
595+
for row in conn.execute(
596+
f'SELECT path, row_json FROM sync_tracks '
597+
f'WHERE deleted_at IS NULL AND path IN ({marks})',
598+
replacement_paths,
599+
).fetchall()
600+
}
601+
for track_id, replacement in replacements.items():
602+
current = current_rows.get(replacement['file'])
603+
found[track_id] = (
604+
{'id': track_id, **current}
605+
if current is not None
606+
else {'id': track_id, **public_row(replacement)}
607+
)
608+
remaining = [key for key in chunk if key not in replacements]
609+
if not remaining:
610+
continue
611+
marks = ','.join('?' * len(remaining))
612+
for row in conn.execute(
613+
f'SELECT track_id, row_json FROM sync_tracks '
614+
f'WHERE deleted_at IS NULL AND track_id IN ({marks})',
615+
remaining,
616+
).fetchall():
617+
found[row['track_id']] = {
618+
'id': row['track_id'],
619+
**json.loads(row['row_json']),
620+
}
621+
return found
622+
551623
def ids_for_paths(self, paths: Iterable[str]) -> dict[str, str]:
552624
"""``{path: track_id}`` for the paths that are known."""
553625

0 commit comments

Comments
 (0)