station-player 0.1.0__tar.gz

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,58 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .pytest_cache/
5
+ .venv/
6
+ venv/
7
+ *.db
8
+ *.db-journal
9
+ *.db-wal
10
+ *.db-shm
11
+ .DS_Store
12
+ node_modules/
13
+ .svelte-kit/
14
+ web/build/
15
+ dist/
16
+ build/
17
+ *.egg-info/
18
+ .env
19
+ .env.local
20
+
21
+ # User-dropped iTunes XML exports (large, not repo content)
22
+ from_user/*
23
+ !from_user/README.md
24
+
25
+ # Generated iTunes XML exports (default disk-export destination)
26
+ out/
27
+ output/
28
+
29
+ # Playwright
30
+ web/test-results/
31
+ web/playwright-report/
32
+ test-results/
33
+ playwright-report/
34
+ playwright-test.*.db
35
+ playwright-test.*.db-wal
36
+ playwright-test.*.db-shm
37
+ playwright-stations.*/
38
+
39
+ # Coverage output
40
+ .coverage
41
+ web/coverage/
42
+
43
+ # Database backups (DESIGN.md §14.18). User-data; never repo content.
44
+ backups/
45
+ .claude/
46
+
47
+ # §16.15 c1 — MkDocs build output. Source lives in docs/; the
48
+ # rendered HTML/CSS/JS lands in site/ and is published to GH Pages
49
+ # via the docs workflow, never committed to main.
50
+ site/
51
+ # §16.15 c4 — OpenAPI dump consumed by mkdocs-render-swagger-plugin.
52
+ # Regenerated by `make docs-openapi` (chained from docs-build +
53
+ # docs-serve); committing would risk drift between the JSON and
54
+ # the FastAPI app's actual schema.
55
+ docs/reference/openapi.json
56
+ # §16.15 c5 — CLI --help dump consumed by the docs build.
57
+ # Regenerated by `make docs-cli`; same drift logic as openapi.json.
58
+ docs/reference/cli.md
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Raby Whyte
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.5
2
+ Name: station-player
3
+ Version: 0.1.0
4
+ Summary: Local music and radio player for station-studio playlists
5
+ Author-email: Raby Whyte <rabywhyte@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2025-2026 Raby Whyte
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ License-File: LICENSE
28
+ Keywords: itunes,music,player,playlist,radio
29
+ Requires-Python: >=3.11
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=8.0; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # Station Player
35
+
36
+ A music and radio player for the playlists Station Studio makes, built to
37
+ replace iTunes / Music as the thing that actually plays them.
38
+
39
+ It plays three kinds of thing:
40
+
41
+ - **Station Studio playlists**: the iTunes XML files Station Studio
42
+ exports, plus any library you export from Music / iTunes
43
+ (File › Library › Export Library). Tracks play in the order the station
44
+ generated them.
45
+ - **Music folders**: any folder of audio files (mp3, m4a/aac, flac, wav,
46
+ ogg/opus, aiff). Each folder shows up as its own playlist.
47
+ - **Internet radio**: any stream URL, including `.pls` and `.m3u` links.
48
+ Rebirth Radio is set up out of the box.
49
+
50
+ It runs as a small local server on your computer with a browser window as
51
+ the UI. It needs nothing beyond Python 3.11+ and listens only on
52
+ `127.0.0.1`, so nothing is reachable from your network.
53
+
54
+ ## Install and run
55
+
56
+ You need Python 3.11 or newer and [pipx](https://pipx.pypa.io)
57
+ (macOS: `brew install pipx`; Windows: `py -m pip install --user pipx`,
58
+ then `py -m pipx ensurepath`). No checkout of this repo is needed.
59
+
60
+ Install the latest release (the exact command is on each
61
+ [Station Player release](https://github.com/raby/station-studio/releases)):
62
+
63
+ ```bash
64
+ pipx install https://github.com/raby/station-studio/releases/download/player-v0.1.0/station_player-0.1.0-py3-none-any.whl
65
+ station-player
66
+ ```
67
+
68
+ Once it's on PyPI, this shorter form works too: `pipx install station-player`.
69
+ Upgrade later with `pipx upgrade station-player` (PyPI) or by running
70
+ `pipx install --force` with a newer release's link.
71
+
72
+ From a checkout, for development:
73
+
74
+ ```bash
75
+ cd player
76
+ python -m station_player
77
+ ```
78
+
79
+ A browser tab opens at <http://127.0.0.1:8765/>. Useful flags:
80
+
81
+ ```bash
82
+ station-player ~/Music/Albums ~/exports/week-012.xml # add sources on launch
83
+ station-player --port 9000 --no-browser
84
+ ```
85
+
86
+ ## Setting it up
87
+
88
+ Open **Sources & settings** in the sidebar.
89
+
90
+ 1. **Playlists (iTunes XML).** Add an exported `.xml` file, or a folder of
91
+ them. On first run Station Player adds Station Studio's exports folder
92
+ (`~/.station-studio/exports`) automatically when it exists, so every
93
+ week you export appears without doing anything.
94
+ 2. **Music folders.** Add the folder your music lives in. Besides
95
+ appearing under *Folders*, these files are used to find playlist
96
+ tracks whose saved location no longer exists (matched by file name).
97
+ 3. **Path rewrites.** If the playlists were made on another computer, say
98
+ a Mac library played back on a PC, rewrite the start of the path:
99
+ from `/Users/rob/Music/iTunes/iTunes Media` to `D:/Music`.
100
+
101
+ Tracks whose audio file can't be found are greyed out and skipped.
102
+ Only audio files (mp3, m4a/aac, flac, wav, ogg/opus, aiff, webm) are
103
+ ever played or served, whatever a playlist file points at.
104
+
105
+ Settings live in `~/.station-player/config.json` (override the folder
106
+ with `$STATION_PLAYER_HOME`).
107
+
108
+ ## Playing
109
+
110
+ - Double-click a track to play from there, or **▶ Play** for the whole
111
+ playlist. Shuffle and repeat (all / one) sit next to the transport.
112
+ - Keyboard: Tab into the track list, move with ↑ ↓ (Home / End /
113
+ Page Up / Page Down), Enter plays. Space plays and pauses; ← and →
114
+ skip 10 seconds. Keyboard media keys
115
+ and the OS now-playing panel work through the browser's Media Session
116
+ support.
117
+ - Pausing a radio station and pressing play again reconnects to the live
118
+ broadcast instead of resuming from where it stopped.
119
+
120
+ ## A note on Station Studio's example stations
121
+
122
+ The example pools bundled with Station Studio are synthetic: their tracks
123
+ have no audio files behind them, so they load but show as missing. Point
124
+ Station Studio at an export of your own library and the playlists it
125
+ generates carry each track's file location through to this player.
126
+
127
+ ## Development
128
+
129
+ ```bash
130
+ cd player
131
+ python -m pytest
132
+ ```
133
+
134
+ ### Releasing
135
+
136
+ Bump `version` in `player/pyproject.toml` and merge to main, then run
137
+ **Actions → Publish Station Player → Run workflow**. It tests, builds,
138
+ smoke-installs the wheel and creates a `player-v<version>` GitHub
139
+ release with the files attached. Tick *Also publish to PyPI* to upload
140
+ the same files to pypi.org (one-time setup is described at the top of
141
+ `.github/workflows/publish-player.yml`).
142
+
143
+ The server is `station_player/server.py` (standard library
144
+ `http.server`), the library reader is `station_player/library.py`, and
145
+ the UI is plain HTML / CSS / JS in `station_player/static/`.
@@ -0,0 +1,112 @@
1
+ # Station Player
2
+
3
+ A music and radio player for the playlists Station Studio makes, built to
4
+ replace iTunes / Music as the thing that actually plays them.
5
+
6
+ It plays three kinds of thing:
7
+
8
+ - **Station Studio playlists**: the iTunes XML files Station Studio
9
+ exports, plus any library you export from Music / iTunes
10
+ (File › Library › Export Library). Tracks play in the order the station
11
+ generated them.
12
+ - **Music folders**: any folder of audio files (mp3, m4a/aac, flac, wav,
13
+ ogg/opus, aiff). Each folder shows up as its own playlist.
14
+ - **Internet radio**: any stream URL, including `.pls` and `.m3u` links.
15
+ Rebirth Radio is set up out of the box.
16
+
17
+ It runs as a small local server on your computer with a browser window as
18
+ the UI. It needs nothing beyond Python 3.11+ and listens only on
19
+ `127.0.0.1`, so nothing is reachable from your network.
20
+
21
+ ## Install and run
22
+
23
+ You need Python 3.11 or newer and [pipx](https://pipx.pypa.io)
24
+ (macOS: `brew install pipx`; Windows: `py -m pip install --user pipx`,
25
+ then `py -m pipx ensurepath`). No checkout of this repo is needed.
26
+
27
+ Install the latest release (the exact command is on each
28
+ [Station Player release](https://github.com/raby/station-studio/releases)):
29
+
30
+ ```bash
31
+ pipx install https://github.com/raby/station-studio/releases/download/player-v0.1.0/station_player-0.1.0-py3-none-any.whl
32
+ station-player
33
+ ```
34
+
35
+ Once it's on PyPI, this shorter form works too: `pipx install station-player`.
36
+ Upgrade later with `pipx upgrade station-player` (PyPI) or by running
37
+ `pipx install --force` with a newer release's link.
38
+
39
+ From a checkout, for development:
40
+
41
+ ```bash
42
+ cd player
43
+ python -m station_player
44
+ ```
45
+
46
+ A browser tab opens at <http://127.0.0.1:8765/>. Useful flags:
47
+
48
+ ```bash
49
+ station-player ~/Music/Albums ~/exports/week-012.xml # add sources on launch
50
+ station-player --port 9000 --no-browser
51
+ ```
52
+
53
+ ## Setting it up
54
+
55
+ Open **Sources & settings** in the sidebar.
56
+
57
+ 1. **Playlists (iTunes XML).** Add an exported `.xml` file, or a folder of
58
+ them. On first run Station Player adds Station Studio's exports folder
59
+ (`~/.station-studio/exports`) automatically when it exists, so every
60
+ week you export appears without doing anything.
61
+ 2. **Music folders.** Add the folder your music lives in. Besides
62
+ appearing under *Folders*, these files are used to find playlist
63
+ tracks whose saved location no longer exists (matched by file name).
64
+ 3. **Path rewrites.** If the playlists were made on another computer, say
65
+ a Mac library played back on a PC, rewrite the start of the path:
66
+ from `/Users/rob/Music/iTunes/iTunes Media` to `D:/Music`.
67
+
68
+ Tracks whose audio file can't be found are greyed out and skipped.
69
+ Only audio files (mp3, m4a/aac, flac, wav, ogg/opus, aiff, webm) are
70
+ ever played or served, whatever a playlist file points at.
71
+
72
+ Settings live in `~/.station-player/config.json` (override the folder
73
+ with `$STATION_PLAYER_HOME`).
74
+
75
+ ## Playing
76
+
77
+ - Double-click a track to play from there, or **▶ Play** for the whole
78
+ playlist. Shuffle and repeat (all / one) sit next to the transport.
79
+ - Keyboard: Tab into the track list, move with ↑ ↓ (Home / End /
80
+ Page Up / Page Down), Enter plays. Space plays and pauses; ← and →
81
+ skip 10 seconds. Keyboard media keys
82
+ and the OS now-playing panel work through the browser's Media Session
83
+ support.
84
+ - Pausing a radio station and pressing play again reconnects to the live
85
+ broadcast instead of resuming from where it stopped.
86
+
87
+ ## A note on Station Studio's example stations
88
+
89
+ The example pools bundled with Station Studio are synthetic: their tracks
90
+ have no audio files behind them, so they load but show as missing. Point
91
+ Station Studio at an export of your own library and the playlists it
92
+ generates carry each track's file location through to this player.
93
+
94
+ ## Development
95
+
96
+ ```bash
97
+ cd player
98
+ python -m pytest
99
+ ```
100
+
101
+ ### Releasing
102
+
103
+ Bump `version` in `player/pyproject.toml` and merge to main, then run
104
+ **Actions → Publish Station Player → Run workflow**. It tests, builds,
105
+ smoke-installs the wheel and creates a `player-v<version>` GitHub
106
+ release with the files attached. Tick *Also publish to PyPI* to upload
107
+ the same files to pypi.org (one-time setup is described at the top of
108
+ `.github/workflows/publish-player.yml`).
109
+
110
+ The server is `station_player/server.py` (standard library
111
+ `http.server`), the library reader is `station_player/library.py`, and
112
+ the UI is plain HTML / CSS / JS in `station_player/static/`.
@@ -0,0 +1,33 @@
1
+ # Station Player — a separate app from station-studio that plays its
2
+ # exported iTunes XML playlists, local music folders and radio streams.
3
+ # Standard library only at runtime, so `pipx install ./player` is the
4
+ # whole install.
5
+
6
+ [build-system]
7
+ requires = ["hatchling"]
8
+ build-backend = "hatchling.build"
9
+
10
+ [project]
11
+ name = "station-player"
12
+ version = "0.1.0"
13
+ description = "Local music and radio player for station-studio playlists"
14
+ readme = "README.md"
15
+ license = { file = "LICENSE" }
16
+ requires-python = ">=3.11"
17
+ authors = [
18
+ { name = "Raby Whyte", email = "rabywhyte@gmail.com" },
19
+ ]
20
+ keywords = ["music", "player", "radio", "itunes", "playlist"]
21
+ dependencies = []
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["pytest>=8.0"]
25
+
26
+ [project.scripts]
27
+ station-player = "station_player.__main__:main"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["station_player"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
@@ -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)