station-player 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ """Station Player — a local music + radio player for station-studio output.
2
+
3
+ Plays the iTunes XML playlists station-studio generates (and any iTunes /
4
+ Music library export), plain folders of audio files, and internet radio
5
+ streams. Runs as a small local web server bound to 127.0.0.1 with a
6
+ browser UI; standard library only, no third-party dependencies.
7
+ """
8
+
9
+ __version__ = "0.1.0"
@@ -0,0 +1,102 @@
1
+ """`station-player` command: start the local server and open the UI."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import logging
6
+ import sys
7
+ import webbrowser
8
+ from pathlib import Path
9
+
10
+ from station_player import __version__
11
+ from station_player.config import ConfigStore
12
+ from station_player.library import AUDIO_EXTENSIONS
13
+ from station_player.server import PlayerApp, make_server
14
+
15
+
16
+ def _folder_contents(folder: Path) -> tuple[bool, bool]:
17
+ """(has .xml, has audio) anywhere below folder; stops once both seen."""
18
+ has_xml = has_audio = False
19
+ for p in folder.rglob("*"):
20
+ suffix = p.suffix.lower()
21
+ if suffix == ".xml":
22
+ has_xml = True
23
+ elif suffix in AUDIO_EXTENSIONS:
24
+ has_audio = True
25
+ if has_xml and has_audio:
26
+ break
27
+ return has_xml, has_audio
28
+
29
+
30
+ def main(argv: list[str] | None = None) -> int:
31
+ parser = argparse.ArgumentParser(
32
+ prog="station-player",
33
+ description="Play station-studio playlists, local music folders and radio streams.",
34
+ )
35
+ parser.add_argument("sources", nargs="*", metavar="PATH",
36
+ help="iTunes XML files, folders of XMLs, or folders of audio to add")
37
+ parser.add_argument("--port", type=int, default=8765, help="port to listen on (default 8765)")
38
+ parser.add_argument("--no-browser", action="store_true", help="don't open a browser window")
39
+ parser.add_argument("--version", action="version", version=f"station-player {__version__}")
40
+ args = parser.parse_args(argv)
41
+
42
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
43
+ store = ConfigStore()
44
+ added = False
45
+ for raw in args.sources:
46
+ p = Path(raw).expanduser().resolve()
47
+ if not p.exists():
48
+ print(f"Skipped {raw}: not found", file=sys.stderr)
49
+ continue
50
+ if p.is_file():
51
+ if p.suffix.lower() != ".xml":
52
+ print(f"Skipped {raw}: pass an iTunes .xml file or a folder", file=sys.stderr)
53
+ continue
54
+ targets = [store.config.playlist_sources]
55
+ else:
56
+ # Folders holding XML at any depth (station-studio writes
57
+ # exports/<station>/<week>.xml) are playlist sources; folders
58
+ # holding audio are music folders. An iTunes folder is both.
59
+ try:
60
+ has_xml, has_audio = _folder_contents(p)
61
+ except OSError as exc:
62
+ print(f"Skipped {raw}: {exc}", file=sys.stderr)
63
+ continue
64
+ targets = []
65
+ if has_xml:
66
+ targets.append(store.config.playlist_sources)
67
+ if has_audio or not has_xml:
68
+ targets.append(store.config.music_folders)
69
+ for target in targets:
70
+ if str(p) not in target:
71
+ target.append(str(p))
72
+ added = True
73
+ if added:
74
+ try:
75
+ store.save()
76
+ except OSError as exc:
77
+ print(f"Could not save settings to {store.path}: {exc}", file=sys.stderr)
78
+ app = PlayerApp(store) # scans once, with every source in place
79
+
80
+ try:
81
+ server = make_server(app, port=args.port)
82
+ except OSError as exc:
83
+ print(f"Could not listen on port {args.port}: {exc}", file=sys.stderr)
84
+ return 1
85
+ url = f"http://127.0.0.1:{args.port}/"
86
+ print(f"Station Player {__version__} on {url} (Ctrl+C to quit)")
87
+ print(f"Settings: {store.path}")
88
+ for err in app.library.errors:
89
+ print(f" ! {err}")
90
+ if not args.no_browser:
91
+ webbrowser.open(url)
92
+ try:
93
+ server.serve_forever()
94
+ except KeyboardInterrupt:
95
+ pass
96
+ finally:
97
+ server.server_close()
98
+ return 0
99
+
100
+
101
+ if __name__ == "__main__":
102
+ sys.exit(main())
@@ -0,0 +1,124 @@
1
+ """Player settings, stored as JSON under the player's home folder.
2
+
3
+ Resolution: `$STATION_PLAYER_HOME`, else `~/.station-player/`. The file
4
+ is small and human-editable; the UI writes it through the API.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import os
11
+ import threading
12
+ import time
13
+ from dataclasses import asdict, dataclass, field
14
+ from pathlib import Path
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ ENV_VAR = "STATION_PLAYER_HOME"
19
+
20
+ DEFAULT_STATIONS = [
21
+ {
22
+ "name": "Rebirth Radio",
23
+ "url": "https://sh.uk.audio-stream.com/proxy/adgerdes?mp=/stream",
24
+ },
25
+ ]
26
+
27
+
28
+ def get_home() -> Path:
29
+ override = os.environ.get(ENV_VAR, "").strip()
30
+ if override:
31
+ return Path(override).expanduser()
32
+ return Path.home() / ".station-player"
33
+
34
+
35
+ def station_studio_exports() -> Path:
36
+ """Where station-studio writes exported playlists (its app-home)."""
37
+ override = os.environ.get("STATION_STUDIO_HOME", "").strip()
38
+ base = Path(override).expanduser() if override else Path.home() / ".station-studio"
39
+ return base / "exports"
40
+
41
+
42
+ @dataclass
43
+ class Config:
44
+ # iTunes XML files, or folders that are scanned for *.xml
45
+ playlist_sources: list[str] = field(default_factory=list)
46
+ # folders of audio files; each becomes a playlist, and their files
47
+ # are used to find tracks whose iTunes Location has moved
48
+ music_folders: list[str] = field(default_factory=list)
49
+ # [{"from": "/Users/rob/Music", "to": "D:/Music"}]
50
+ path_remaps: list[dict] = field(default_factory=list)
51
+ # [{"name": ..., "url": ...}]
52
+ stations: list[dict] = field(default_factory=lambda: [dict(s) for s in DEFAULT_STATIONS])
53
+
54
+ def to_json(self) -> dict:
55
+ return asdict(self)
56
+
57
+
58
+ def _str_list(value) -> list[str]:
59
+ if not isinstance(value, list):
60
+ return []
61
+ return [v for v in value if isinstance(v, str) and v.strip()]
62
+
63
+
64
+ def _pairs(value, keys: tuple[str, str]) -> list[dict]:
65
+ if not isinstance(value, list):
66
+ return []
67
+ return [
68
+ {k: v[k] for k in keys} for v in value
69
+ if isinstance(v, dict) and all(isinstance(v.get(k), str) and v[k].strip() for k in keys)
70
+ ]
71
+
72
+
73
+ def config_from_json(data: dict) -> Config:
74
+ """Build a Config from parsed JSON, dropping anything malformed."""
75
+ cfg = Config(
76
+ playlist_sources=_str_list(data.get("playlist_sources")),
77
+ music_folders=_str_list(data.get("music_folders")),
78
+ path_remaps=_pairs(data.get("path_remaps"), ("from", "to")),
79
+ )
80
+ if "stations" in data:
81
+ cfg.stations = _pairs(data.get("stations"), ("name", "url"))
82
+ return cfg
83
+
84
+
85
+ class ConfigStore:
86
+ def __init__(self, home: Path | None = None) -> None:
87
+ self.home = home or get_home()
88
+ self.path = self.home / "config.json"
89
+ self._lock = threading.Lock()
90
+ self.warnings: list[str] = []
91
+ self.config = self._read()
92
+
93
+ def _read(self) -> Config:
94
+ if not self.path.is_file():
95
+ cfg = Config()
96
+ exports = station_studio_exports()
97
+ if exports.is_dir():
98
+ cfg.playlist_sources.append(str(exports.resolve()))
99
+ return cfg
100
+ try:
101
+ data = json.loads(self.path.read_text(encoding="utf-8"))
102
+ if not isinstance(data, dict):
103
+ raise ValueError("expected a JSON object")
104
+ except (OSError, ValueError) as exc:
105
+ # Keep the broken file for the user to fix rather than
106
+ # overwriting it with defaults on the next save.
107
+ backup = self.path.with_name(f"config.broken-{int(time.time())}.json")
108
+ try:
109
+ self.path.replace(backup)
110
+ where = f"moved to {backup}"
111
+ except OSError:
112
+ where = f"left at {self.path}"
113
+ message = f"Settings file could not be read ({exc}); started with defaults, old file {where}"
114
+ logger.warning(message)
115
+ self.warnings.append(message)
116
+ return Config()
117
+ return config_from_json(data)
118
+
119
+ def save(self) -> None:
120
+ with self._lock:
121
+ self.home.mkdir(parents=True, exist_ok=True)
122
+ tmp = self.path.with_suffix(".json.tmp")
123
+ tmp.write_text(json.dumps(self.config.to_json(), indent=2), encoding="utf-8")
124
+ tmp.replace(self.path)
@@ -0,0 +1,373 @@
1
+ """Build a playable library from iTunes XML files, audio folders and config.
2
+
3
+ Everything the server can play goes through `Library.tracks`: a dict of
4
+ opaque track id -> Track. The audio endpoint only serves paths found
5
+ there, so the player never becomes a general file server.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import logging
11
+ import plistlib
12
+ import re
13
+ import unicodedata
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from urllib.parse import unquote, urlparse
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ AUDIO_EXTENSIONS = frozenset({
21
+ ".mp3", ".m4a", ".aac", ".mp4", ".flac", ".wav", ".ogg", ".oga",
22
+ ".opus", ".webm", ".aif", ".aiff",
23
+ })
24
+
25
+ # Leading track numbers in filenames: "01 ", "01 - ", "1. ", "A2 - ".
26
+ # A bare number followed only by a space is stripped just when it has a
27
+ # leading zero, so "99 Luftballons" and "7 Seconds" keep their titles.
28
+ _TRACK_NO_RE = re.compile(r"^\s*(?:[A-Da-d]?\d{1,3}\s*[-.)_]\s*|0\d{1,2}\s+)")
29
+
30
+
31
+ def name_key(name: str) -> str:
32
+ """Compare file and folder names the way people expect: ignoring case
33
+ (Windows and macOS do) and Unicode composition (macOS writes "é" as
34
+ two code points, most other systems as one)."""
35
+ return unicodedata.normalize("NFC", name).casefold()
36
+
37
+
38
+ @dataclass
39
+ class Track:
40
+ id: str
41
+ name: str
42
+ artist: str | None = None
43
+ album: str | None = None
44
+ genre: str | None = None
45
+ duration_ms: int | None = None
46
+ path: Path | None = None # resolved local file, None when missing
47
+
48
+ @property
49
+ def available(self) -> bool:
50
+ return self.path is not None
51
+
52
+ def to_json(self) -> dict:
53
+ return {
54
+ "id": self.id,
55
+ "name": self.name,
56
+ "artist": self.artist,
57
+ "album": self.album,
58
+ "genre": self.genre,
59
+ "duration_ms": self.duration_ms,
60
+ "available": self.available,
61
+ }
62
+
63
+
64
+ @dataclass
65
+ class Playlist:
66
+ id: str
67
+ name: str
68
+ kind: str # "itunes" | "folder"
69
+ source: str # the file or folder it came from
70
+ track_ids: list[str] = field(default_factory=list)
71
+
72
+ def summary(self, tracks: dict[str, Track]) -> dict:
73
+ missing = sum(1 for t in self.track_ids if not tracks[t].available)
74
+ return {
75
+ "id": self.id,
76
+ "name": self.name,
77
+ "kind": self.kind,
78
+ "source": self.source,
79
+ "count": len(self.track_ids),
80
+ "missing": missing,
81
+ }
82
+
83
+
84
+ def _short_hash(*parts: str) -> str:
85
+ h = hashlib.sha1("\0".join(parts).encode("utf-8", "surrogateescape"))
86
+ return h.hexdigest()[:16]
87
+
88
+
89
+ def location_to_path(location: str) -> Path | None:
90
+ """Turn an iTunes `Location` (a file:// URL) into a local Path.
91
+
92
+ Handles the macOS form (file:///Users/...), the Windows form
93
+ (file://localhost/C:/...) and percent-encoding. Non-file URLs
94
+ return None.
95
+ """
96
+ if not location:
97
+ return None
98
+ parsed = urlparse(location)
99
+ if parsed.scheme and parsed.scheme != "file":
100
+ return None
101
+ raw = unquote(parsed.path if parsed.scheme else location)
102
+ # Windows drive letter: "/C:/Music/x.mp3" -> "C:/Music/x.mp3"
103
+ if re.match(r"^/[A-Za-z]:[/\\]", raw):
104
+ raw = raw[1:]
105
+ elif parsed.netloc and parsed.netloc not in ("", "localhost"):
106
+ raw = f"//{parsed.netloc}{raw}" # UNC share
107
+ return Path(raw)
108
+
109
+
110
+ def apply_remaps(path: Path, remaps: list[dict]) -> Path:
111
+ """Rewrite a path prefix, e.g. a Mac library path onto a PC drive.
112
+
113
+ Matches whole folder names, ignoring case and Unicode composition, so
114
+ "c:/users/rob/music" rewrites "C:/Users/Rob/Music/..." too.
115
+ """
116
+ parts = path.as_posix().split("/")
117
+ for remap in remaps:
118
+ src = str(remap.get("from", "")).replace("\\", "/").rstrip("/")
119
+ dst = str(remap.get("to", "")).replace("\\", "/").rstrip("/")
120
+ if not src:
121
+ continue
122
+ src_parts = src.split("/")
123
+ head = parts[:len(src_parts)]
124
+ if len(head) == len(src_parts) and all(
125
+ name_key(a) == name_key(b) for a, b in zip(head, src_parts)
126
+ ):
127
+ rest = parts[len(src_parts):]
128
+ return Path("/".join([dst, *rest]) if rest else dst)
129
+ return path
130
+
131
+
132
+ def title_from_filename(path: Path) -> tuple[str, str | None]:
133
+ """Best-effort (title, artist) from "01 - Artist - Title.mp3"."""
134
+ stem = _TRACK_NO_RE.sub("", path.stem, count=1) or path.stem
135
+ if " - " in stem:
136
+ artist, title = stem.split(" - ", 1)
137
+ return title.strip(), artist.strip() or None
138
+ return stem.strip(), None
139
+
140
+
141
+ def scan_audio_files(folder: Path) -> list[Path]:
142
+ files = [
143
+ p for p in folder.rglob("*")
144
+ if p.suffix.lower() in AUDIO_EXTENSIONS and p.is_file()
145
+ and not p.name.startswith(".")
146
+ ]
147
+ return sorted(files, key=lambda p: p.as_posix().lower())
148
+
149
+
150
+ class _NotItunes(Exception):
151
+ pass
152
+
153
+
154
+ def _text(value) -> str | None:
155
+ """iTunes text fields, tolerating numbers (e.g. an album called 1999)."""
156
+ if isinstance(value, str):
157
+ return value
158
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
159
+ return str(value)
160
+ return None
161
+
162
+
163
+ class Library:
164
+ """In-memory index of everything playable. Rebuilt by `load()`."""
165
+
166
+ def __init__(self) -> None:
167
+ self.tracks: dict[str, Track] = {}
168
+ self.playlists: dict[str, Playlist] = {}
169
+ self.errors: list[str] = []
170
+ # filename (lowercased) -> paths, from scanned music folders; used
171
+ # to find tracks whose iTunes Location points somewhere else.
172
+ self._by_filename: dict[str, list[Path]] = {}
173
+
174
+ # ---- loading --------------------------------------------------------
175
+
176
+ def load(
177
+ self,
178
+ playlist_sources: list[str],
179
+ music_folders: list[str],
180
+ remaps: list[dict] | None = None,
181
+ ) -> None:
182
+ self.tracks.clear()
183
+ self.playlists.clear()
184
+ self.errors.clear()
185
+ self._by_filename.clear()
186
+ remaps = remaps or []
187
+
188
+ folder_files: list[tuple[Path, list[Path]]] = []
189
+ indexed: set[Path] = set() # overlapping folders must not double-count
190
+ for raw in music_folders:
191
+ folder = Path(raw).expanduser()
192
+ if not folder.is_dir():
193
+ self.errors.append(f"Music folder not found: {folder}")
194
+ continue
195
+ try:
196
+ files = scan_audio_files(folder)
197
+ except OSError as exc:
198
+ self.errors.append(f"Could not read music folder {folder}: {exc}")
199
+ continue
200
+ folder_files.append((folder, files))
201
+ for f in files:
202
+ key = f.resolve()
203
+ if key in indexed:
204
+ continue
205
+ indexed.add(key)
206
+ self._by_filename.setdefault(name_key(f.name), []).append(f)
207
+
208
+ for raw in playlist_sources:
209
+ src = Path(raw).expanduser()
210
+ if src.is_dir():
211
+ try:
212
+ xmls = sorted(src.rglob("*.xml"), key=lambda p: p.as_posix().lower())
213
+ except OSError as exc:
214
+ self.errors.append(f"Could not read playlist folder {src}: {exc}")
215
+ continue
216
+ for xml in xmls:
217
+ self._load_itunes_xml(xml, remaps, quiet=True)
218
+ elif src.is_file():
219
+ self._load_itunes_xml(src, remaps)
220
+ else:
221
+ self.errors.append(f"Playlist source not found: {src}")
222
+
223
+ for folder, files in folder_files:
224
+ self._add_folder_playlist(folder, files)
225
+
226
+ def _resolve(self, raw: dict, remaps: list[dict]) -> Path | None:
227
+ loc = raw.get("Location")
228
+ candidate = location_to_path(loc) if isinstance(loc, str) else None
229
+ # Only audio is ever playable: a crafted XML pointing at, say,
230
+ # ~/.ssh/id_rsa or an .html file must not make it servable.
231
+ if candidate is not None and candidate.suffix.lower() in AUDIO_EXTENSIONS:
232
+ candidate = apply_remaps(candidate, remaps)
233
+ if candidate.is_file():
234
+ return candidate
235
+ return self._find_moved(candidate)
236
+ return None
237
+
238
+ def _find_moved(self, original: Path) -> Path | None:
239
+ """Find a moved file by name, refusing to guess between namesakes.
240
+
241
+ "01 Intro.mp3" exists on many albums, so a bare name match is only
242
+ trusted when it is unique; otherwise the parent folders (album,
243
+ then artist) must match too.
244
+ """
245
+ matches = self._by_filename.get(name_key(original.name), [])
246
+ for depth in range(0, 3):
247
+ if depth:
248
+ want = [name_key(p.name) for p in original.parents[:depth]]
249
+ matches = [
250
+ m for m in matches
251
+ if [name_key(p.name) for p in m.parents[:depth]] == want
252
+ ]
253
+ if len(matches) == 1:
254
+ return matches[0]
255
+ if not matches:
256
+ return None
257
+ return None
258
+
259
+ def _load_itunes_xml(self, xml: Path, remaps: list[dict], quiet: bool = False) -> None:
260
+ """Load one XML. Any file that isn't a usable iTunes library is
261
+ reported (or skipped quietly when found by a folder scan), never
262
+ allowed to take the whole library down with it."""
263
+ try:
264
+ with xml.open("rb") as fh:
265
+ data = plistlib.load(fh)
266
+ tracks, playlists = self._parse_itunes(xml, data, remaps)
267
+ except _NotItunes:
268
+ if not quiet:
269
+ self.errors.append(f"{xml.name} is not an iTunes library XML")
270
+ return
271
+ except Exception as exc: # malformed plist, unreadable file
272
+ if not quiet:
273
+ self.errors.append(f"Could not read {xml.name}: {exc}")
274
+ return
275
+ for t in tracks:
276
+ self.tracks.setdefault(t.id, t)
277
+ for pl in playlists:
278
+ self.playlists.setdefault(pl.id, pl) # same playlist loaded twice
279
+
280
+ def _parse_itunes(self, xml: Path, data, remaps: list[dict]) -> tuple[list[Track], list[Playlist]]:
281
+ if not isinstance(data, dict) or not isinstance(data.get("Tracks"), dict):
282
+ raise _NotItunes()
283
+
284
+ tracks: list[Track] = []
285
+ # iTunes Track ID (scoped to this file) -> our track id
286
+ local_ids: dict[int, str] = {}
287
+ for key, raw in data["Tracks"].items():
288
+ if not isinstance(raw, dict):
289
+ continue
290
+ path = self._resolve(raw, remaps)
291
+ pid = _text(raw.get("Persistent ID")) or f"{xml}:{key}"
292
+ tid = _short_hash("itunes", pid, str(path or ""))
293
+ duration = raw.get("Total Time")
294
+ tracks.append(Track(
295
+ id=tid,
296
+ name=_text(raw.get("Name")) or (path.stem if path else "Unknown"),
297
+ artist=_text(raw.get("Artist")),
298
+ album=_text(raw.get("Album")),
299
+ genre=_text(raw.get("Genre")),
300
+ duration_ms=duration if isinstance(duration, int) and not isinstance(duration, bool) else None,
301
+ path=path,
302
+ ))
303
+ track_id = raw.get("Track ID")
304
+ if isinstance(track_id, int):
305
+ local_ids[track_id] = tid
306
+ elif str(key).isdigit():
307
+ local_ids[int(key)] = tid
308
+
309
+ raw_playlists = data.get("Playlists")
310
+ candidates = [
311
+ p for p in (raw_playlists if isinstance(raw_playlists, list) else [])
312
+ if isinstance(p, dict)
313
+ and not p.get("Master")
314
+ and not p.get("Folder")
315
+ and "Distinguished Kind" not in p
316
+ and isinstance(p.get("Playlist Items"), list)
317
+ and p["Playlist Items"]
318
+ ]
319
+ if not candidates:
320
+ # A library with no usable playlists: offer every track.
321
+ candidates = [{
322
+ "Name": xml.stem,
323
+ "Playlist Items": [{"Track ID": k} for k in local_ids],
324
+ }]
325
+
326
+ playlists: list[Playlist] = []
327
+ for p in candidates:
328
+ name = _text(p.get("Name")) or xml.stem
329
+ pl = Playlist(
330
+ id=_short_hash("pl", str(xml), _text(p.get("Playlist Persistent ID")) or name),
331
+ name=name,
332
+ kind="itunes",
333
+ source=str(xml),
334
+ )
335
+ for item in p["Playlist Items"]:
336
+ if not isinstance(item, dict):
337
+ continue
338
+ ref = item.get("Track ID")
339
+ tid = local_ids.get(ref) if isinstance(ref, int) else None
340
+ if tid:
341
+ pl.track_ids.append(tid)
342
+ playlists.append(pl)
343
+ return tracks, playlists
344
+
345
+ def _add_folder_playlist(self, folder: Path, files: list[Path]) -> None:
346
+ pl = Playlist(
347
+ id=_short_hash("folder", str(folder)),
348
+ name=folder.name or str(folder),
349
+ kind="folder",
350
+ source=str(folder),
351
+ )
352
+ for f in files:
353
+ tid = _short_hash("file", str(f))
354
+ if tid not in self.tracks:
355
+ title, artist = title_from_filename(f)
356
+ album = f.parent.name if f.parent != folder else None
357
+ self.tracks[tid] = Track(id=tid, name=title, artist=artist, album=album, path=f)
358
+ pl.track_ids.append(tid)
359
+ self.playlists[pl.id] = pl
360
+
361
+ # ---- queries --------------------------------------------------------
362
+
363
+ def playlist_summaries(self) -> list[dict]:
364
+ items = [p.summary(self.tracks) for p in self.playlists.values()]
365
+ return sorted(items, key=lambda s: (s["kind"] != "itunes", s["name"].lower()))
366
+
367
+ def playlist_detail(self, playlist_id: str) -> dict | None:
368
+ pl = self.playlists.get(playlist_id)
369
+ if pl is None:
370
+ return None
371
+ out = pl.summary(self.tracks)
372
+ out["tracks"] = [self.tracks[t].to_json() for t in pl.track_ids]
373
+ return out