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.
- station_player-0.1.0/.gitignore +58 -0
- station_player-0.1.0/LICENSE +21 -0
- station_player-0.1.0/PKG-INFO +145 -0
- station_player-0.1.0/README.md +112 -0
- station_player-0.1.0/pyproject.toml +33 -0
- station_player-0.1.0/station_player/__init__.py +9 -0
- station_player-0.1.0/station_player/__main__.py +102 -0
- station_player-0.1.0/station_player/config.py +124 -0
- station_player-0.1.0/station_player/library.py +373 -0
- station_player-0.1.0/station_player/server.py +449 -0
- station_player-0.1.0/station_player/static/app.js +696 -0
- station_player-0.1.0/station_player/static/index.html +63 -0
- station_player-0.1.0/station_player/static/style.css +178 -0
- station_player-0.1.0/tests/conftest.py +37 -0
- station_player-0.1.0/tests/test_cli.py +44 -0
- station_player-0.1.0/tests/test_library.py +221 -0
- station_player-0.1.0/tests/test_server.py +429 -0
|
@@ -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)
|