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.
- station_player/__init__.py +9 -0
- station_player/__main__.py +102 -0
- station_player/config.py +124 -0
- station_player/library.py +373 -0
- station_player/server.py +449 -0
- station_player/static/app.js +696 -0
- station_player/static/index.html +63 -0
- station_player/static/style.css +178 -0
- station_player-0.1.0.dist-info/METADATA +145 -0
- station_player-0.1.0.dist-info/RECORD +13 -0
- station_player-0.1.0.dist-info/WHEEL +4 -0
- station_player-0.1.0.dist-info/entry_points.txt +2 -0
- station_player-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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())
|
station_player/config.py
ADDED
|
@@ -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
|