cloudspin 0.1.0__tar.gz → 0.1.1__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.
Files changed (45) hide show
  1. cloudspin-0.1.1/.github/workflows/ci.yml +37 -0
  2. {cloudspin-0.1.0 → cloudspin-0.1.1}/PKG-INFO +34 -24
  3. {cloudspin-0.1.0 → cloudspin-0.1.1}/README.md +31 -23
  4. {cloudspin-0.1.0 → cloudspin-0.1.1}/packaging/aur/PKGBUILD +3 -4
  5. {cloudspin-0.1.0 → cloudspin-0.1.1}/packaging/npm/package.json +1 -1
  6. {cloudspin-0.1.0 → cloudspin-0.1.1}/pyproject.toml +2 -0
  7. {cloudspin-0.1.0 → cloudspin-0.1.1}/src/cloudspin/__init__.py +1 -1
  8. {cloudspin-0.1.0 → cloudspin-0.1.1}/src/cloudspin/cli.py +35 -12
  9. cloudspin-0.1.1/src/cloudspin/mpvget.py +119 -0
  10. {cloudspin-0.1.0 → cloudspin-0.1.1}/src/cloudspin/player.py +84 -14
  11. {cloudspin-0.1.0 → cloudspin-0.1.1}/src/cloudspin/widgets.py +3 -2
  12. {cloudspin-0.1.0 → cloudspin-0.1.1}/tests/test_player.py +2 -2
  13. cloudspin-0.1.1/tests/test_real_mpv.py +80 -0
  14. cloudspin-0.1.0/.github/workflows/ci.yml +0 -23
  15. {cloudspin-0.1.0 → cloudspin-0.1.1}/.github/workflows/release.yml +0 -0
  16. {cloudspin-0.1.0 → cloudspin-0.1.1}/.gitignore +0 -0
  17. {cloudspin-0.1.0 → cloudspin-0.1.1}/LICENSE +0 -0
  18. {cloudspin-0.1.0 → cloudspin-0.1.1}/docs/evening.png +0 -0
  19. {cloudspin-0.1.0 → cloudspin-0.1.1}/docs/night.png +0 -0
  20. {cloudspin-0.1.0 → cloudspin-0.1.1}/docs/storm.png +0 -0
  21. {cloudspin-0.1.0 → cloudspin-0.1.1}/packaging/npm/README.md +0 -0
  22. {cloudspin-0.1.0 → cloudspin-0.1.1}/packaging/npm/bin/cloudspin.js +0 -0
  23. {cloudspin-0.1.0 → cloudspin-0.1.1}/scripts/screenshot.py +0 -0
  24. {cloudspin-0.1.0 → cloudspin-0.1.1}/src/cloudspin/__main__.py +0 -0
  25. {cloudspin-0.1.0 → cloudspin-0.1.1}/src/cloudspin/app.py +0 -0
  26. {cloudspin-0.1.0 → cloudspin-0.1.1}/src/cloudspin/canvas.py +0 -0
  27. {cloudspin-0.1.0 → cloudspin-0.1.1}/src/cloudspin/color.py +0 -0
  28. {cloudspin-0.1.0 → cloudspin-0.1.1}/src/cloudspin/config.py +0 -0
  29. {cloudspin-0.1.0 → cloudspin-0.1.1}/src/cloudspin/library.py +0 -0
  30. {cloudspin-0.1.0 → cloudspin-0.1.1}/src/cloudspin/mood.py +0 -0
  31. {cloudspin-0.1.0 → cloudspin-0.1.1}/src/cloudspin/screens.py +0 -0
  32. {cloudspin-0.1.0 → cloudspin-0.1.1}/src/cloudspin/sky.py +0 -0
  33. {cloudspin-0.1.0 → cloudspin-0.1.1}/src/cloudspin/songlook.py +0 -0
  34. {cloudspin-0.1.0 → cloudspin-0.1.1}/src/cloudspin/spectrum.py +0 -0
  35. {cloudspin-0.1.0 → cloudspin-0.1.1}/src/cloudspin/themes.py +0 -0
  36. {cloudspin-0.1.0 → cloudspin-0.1.1}/src/cloudspin/vinyl.py +0 -0
  37. {cloudspin-0.1.0 → cloudspin-0.1.1}/src/cloudspin/weather.py +0 -0
  38. {cloudspin-0.1.0 → cloudspin-0.1.1}/tests/conftest.py +0 -0
  39. {cloudspin-0.1.0 → cloudspin-0.1.1}/tests/fake_mpv.py +0 -0
  40. {cloudspin-0.1.0 → cloudspin-0.1.1}/tests/fixtures/forecast.json +0 -0
  41. {cloudspin-0.1.0 → cloudspin-0.1.1}/tests/synth.py +0 -0
  42. {cloudspin-0.1.0 → cloudspin-0.1.1}/tests/test_app.py +0 -0
  43. {cloudspin-0.1.0 → cloudspin-0.1.1}/tests/test_mood.py +0 -0
  44. {cloudspin-0.1.0 → cloudspin-0.1.1}/tests/test_sky.py +0 -0
  45. {cloudspin-0.1.0 → cloudspin-0.1.1}/tests/test_weather.py +0 -0
@@ -0,0 +1,37 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ${{ matrix.os }}
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ os: [ubuntu-latest]
15
+ python: ["3.11", "3.12", "3.13"]
16
+ include:
17
+ - os: windows-latest
18
+ python: "3.12"
19
+ - os: macos-latest
20
+ python: "3.12"
21
+ env:
22
+ # On Windows the tests download mpv the same way a first run does.
23
+ CLOUDSPIN_TEST_DOWNLOAD: "1"
24
+ PYTHONUTF8: "1"
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ - uses: actions/setup-python@v5
28
+ with:
29
+ python-version: ${{ matrix.python }}
30
+ - name: Install mpv and ffmpeg (Linux)
31
+ if: runner.os == 'Linux'
32
+ run: sudo apt-get update && sudo apt-get install -y --no-install-recommends mpv ffmpeg
33
+ - name: Install mpv and ffmpeg (macOS)
34
+ if: runner.os == 'macOS'
35
+ run: brew install mpv ffmpeg
36
+ - run: pip install -e ".[dev]"
37
+ - run: pytest -q -rs
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cloudspin
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: A terminal music player that lives under a living sky: spinning vinyl, real weather, animated scenes.
5
5
  Project-URL: Homepage, https://github.com/itsHarshDabas/Cloudspin
6
6
  Project-URL: Issues, https://github.com/itsHarshDabas/Cloudspin/issues
@@ -12,6 +12,7 @@ Classifier: Development Status :: 3 - Alpha
12
12
  Classifier: Environment :: Console
13
13
  Classifier: Intended Audience :: End Users/Desktop
14
14
  Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: Microsoft :: Windows
15
16
  Classifier: Operating System :: POSIX :: Linux
16
17
  Classifier: Programming Language :: Python :: 3
17
18
  Classifier: Programming Language :: Python :: 3.11
@@ -22,6 +23,7 @@ Requires-Python: >=3.11
22
23
  Requires-Dist: mutagen>=1.47
23
24
  Requires-Dist: pillow>=10.0
24
25
  Requires-Dist: textual>=8.0
26
+ Requires-Dist: yt-dlp
25
27
  Provides-Extra: dev
26
28
  Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
27
29
  Requires-Dist: pytest>=8; extra == 'dev'
@@ -53,26 +55,29 @@ synthwave turns the stars on.
53
55
 
54
56
  ## Install
55
57
 
56
- Cloudspin is not on PyPI, the AUR or npm yet. Until it is, install it from this repository:
58
+ Pick whichever you already have:
57
59
 
58
60
  ```sh
59
- pipx install git+https://github.com/itsHarshDabas/Cloudspin
60
- # or
61
- uv tool install git+https://github.com/itsHarshDabas/Cloudspin
61
+ pipx install cloudspin # Python
62
+ uv tool install cloudspin # uv
63
+ npm install -g cloudspin # npm (a launcher; it runs the Python app through uv or pipx)
62
64
  ```
63
65
 
64
- You also need `mpv` for sound. Two more tools are optional:
66
+ Or try it without installing: `uvx cloudspin --demo` or `npx cloudspin --demo`.
65
67
 
66
- | Tool | Why |
67
- | --- | --- |
68
- | `mpv` | Plays the music. Without it Cloudspin runs silently. |
69
- | `ffmpeg` | Lets Cloudspin listen to songs that have no genre tag, to guess a sky. |
70
- | `yt-dlp` | Lets mpv play YouTube, SoundCloud and similar links. |
68
+ Works on Linux, macOS and Windows, with Python 3.11 or newer and a truecolor
69
+ terminal (Windows Terminal, kitty, foot, Alacritty, WezTerm, Ghostty, iTerm2
70
+ and most others).
71
+
72
+ ### What comes with it
71
73
 
72
- On Arch: `sudo pacman -S mpv ffmpeg yt-dlp`.
74
+ | Tool | What it's for | How you get it |
75
+ | --- | --- | --- |
76
+ | `yt-dlp` | YouTube, SoundCloud and similar links | Installed with Cloudspin |
77
+ | `mpv` | Plays the sound | **Windows:** downloaded automatically the first time you start Cloudspin (about 30 MB, kept in its cache folder). **Linux and macOS:** from your package manager, e.g. `sudo pacman -S mpv`, `sudo apt install mpv`, `brew install mpv` |
78
+ | `ffmpeg` | Optional. Lets Cloudspin listen to songs with no genre tag to guess a sky | Your package manager |
73
79
 
74
- Needs Python 3.11 or newer, Linux or macOS, and a terminal with truecolor
75
- (kitty, foot, Alacritty, WezTerm, Ghostty, iTerm2 and most others).
80
+ Without mpv Cloudspin still runs, silently, and tells you the command to install it.
76
81
 
77
82
  ## Use
78
83
 
@@ -85,6 +90,8 @@ cloudspin --city "New Delhi" # set the city once; it is remembered
85
90
  cloudspin --demo # look around without any music or sound
86
91
  ```
87
92
 
93
+ No music folder? Start `cloudspin`, press `o` and paste a link.
94
+
88
95
  ### Keys
89
96
 
90
97
  | Key | Does |
@@ -161,23 +168,25 @@ Built-in themes: `sky`, `cover`, `amber`, `orchid`, `lagoon`, `moss`, `rose`, `m
161
168
  - **The listen is a guess.** Step 3 above is a heuristic that has only been
162
169
  checked against synthetic test audio. Tags and your own rules are exact;
163
170
  use `[moods]` when it gets a song wrong.
164
- - **Links need mpv.** Plain audio URLs and radio streams work with mpv alone.
165
- Sites like YouTube also need `yt-dlp`.
166
- - **Windows is not supported** except through WSL: playback control uses a
167
- Unix socket.
168
- - **MPRIS** (media keys, `playerctl`, Waybar) works if you have the
171
+ - **Links** play through mpv, with the bundled yt-dlp handling sites like
172
+ YouTube. Sites change often; if a link stops working, update with
173
+ `pipx upgrade cloudspin` (or `uv tool upgrade cloudspin`) to get a newer yt-dlp.
174
+ - **Windows** uses the mpv that Cloudspin downloads for itself unless `mpv` is
175
+ already on your PATH.
176
+ - **MPRIS** (media keys, `playerctl`, Waybar) works on Linux if you have the
169
177
  `mpv-mpris` plugin installed for mpv; Cloudspin loads your normal mpv config.
170
178
 
171
- ## Packaging status
179
+ ## Releases
172
180
 
173
181
  | Channel | Status |
174
182
  | --- | --- |
175
- | Git (`pipx`, `uv`) | Works today |
176
- | PyPI and npm | Published by `.github/workflows/release.yml` when a `v*` tag is pushed. Setup steps are at the top of that file. |
177
- | AUR | `packaging/aur/PKGBUILD` is written; it builds from the PyPI release |
183
+ | [PyPI](https://pypi.org/project/cloudspin/) | Published |
184
+ | [npm](https://www.npmjs.com/package/cloudspin) | Published (launcher for the Python app) |
185
+ | AUR | `packaging/aur/PKGBUILD` is written, not submitted yet |
178
186
  | Homebrew | Not started |
179
187
 
180
- The npm package is a small launcher that runs the Python app through `uv` or `pipx`.
188
+ Pushing a `v*` tag runs `.github/workflows/release.yml`, which tests, then
189
+ publishes to PyPI and npm.
181
190
 
182
191
  ## Develop
183
192
 
@@ -199,6 +208,7 @@ The code is small and split by job:
199
208
  | `mood.py` | Choosing a scene for a song |
200
209
  | `weather.py` | Open-Meteo and sun and moon maths |
201
210
  | `player.py` | mpv control and the queue |
211
+ | `mpvget.py` | Finding mpv and yt-dlp; the Windows mpv download |
202
212
  | `widgets.py`, `app.py` | The interface |
203
213
 
204
214
  ## Licence
@@ -24,26 +24,29 @@ synthwave turns the stars on.
24
24
 
25
25
  ## Install
26
26
 
27
- Cloudspin is not on PyPI, the AUR or npm yet. Until it is, install it from this repository:
27
+ Pick whichever you already have:
28
28
 
29
29
  ```sh
30
- pipx install git+https://github.com/itsHarshDabas/Cloudspin
31
- # or
32
- uv tool install git+https://github.com/itsHarshDabas/Cloudspin
30
+ pipx install cloudspin # Python
31
+ uv tool install cloudspin # uv
32
+ npm install -g cloudspin # npm (a launcher; it runs the Python app through uv or pipx)
33
33
  ```
34
34
 
35
- You also need `mpv` for sound. Two more tools are optional:
35
+ Or try it without installing: `uvx cloudspin --demo` or `npx cloudspin --demo`.
36
36
 
37
- | Tool | Why |
38
- | --- | --- |
39
- | `mpv` | Plays the music. Without it Cloudspin runs silently. |
40
- | `ffmpeg` | Lets Cloudspin listen to songs that have no genre tag, to guess a sky. |
41
- | `yt-dlp` | Lets mpv play YouTube, SoundCloud and similar links. |
37
+ Works on Linux, macOS and Windows, with Python 3.11 or newer and a truecolor
38
+ terminal (Windows Terminal, kitty, foot, Alacritty, WezTerm, Ghostty, iTerm2
39
+ and most others).
40
+
41
+ ### What comes with it
42
42
 
43
- On Arch: `sudo pacman -S mpv ffmpeg yt-dlp`.
43
+ | Tool | What it's for | How you get it |
44
+ | --- | --- | --- |
45
+ | `yt-dlp` | YouTube, SoundCloud and similar links | Installed with Cloudspin |
46
+ | `mpv` | Plays the sound | **Windows:** downloaded automatically the first time you start Cloudspin (about 30 MB, kept in its cache folder). **Linux and macOS:** from your package manager, e.g. `sudo pacman -S mpv`, `sudo apt install mpv`, `brew install mpv` |
47
+ | `ffmpeg` | Optional. Lets Cloudspin listen to songs with no genre tag to guess a sky | Your package manager |
44
48
 
45
- Needs Python 3.11 or newer, Linux or macOS, and a terminal with truecolor
46
- (kitty, foot, Alacritty, WezTerm, Ghostty, iTerm2 and most others).
49
+ Without mpv Cloudspin still runs, silently, and tells you the command to install it.
47
50
 
48
51
  ## Use
49
52
 
@@ -56,6 +59,8 @@ cloudspin --city "New Delhi" # set the city once; it is remembered
56
59
  cloudspin --demo # look around without any music or sound
57
60
  ```
58
61
 
62
+ No music folder? Start `cloudspin`, press `o` and paste a link.
63
+
59
64
  ### Keys
60
65
 
61
66
  | Key | Does |
@@ -132,23 +137,25 @@ Built-in themes: `sky`, `cover`, `amber`, `orchid`, `lagoon`, `moss`, `rose`, `m
132
137
  - **The listen is a guess.** Step 3 above is a heuristic that has only been
133
138
  checked against synthetic test audio. Tags and your own rules are exact;
134
139
  use `[moods]` when it gets a song wrong.
135
- - **Links need mpv.** Plain audio URLs and radio streams work with mpv alone.
136
- Sites like YouTube also need `yt-dlp`.
137
- - **Windows is not supported** except through WSL: playback control uses a
138
- Unix socket.
139
- - **MPRIS** (media keys, `playerctl`, Waybar) works if you have the
140
+ - **Links** play through mpv, with the bundled yt-dlp handling sites like
141
+ YouTube. Sites change often; if a link stops working, update with
142
+ `pipx upgrade cloudspin` (or `uv tool upgrade cloudspin`) to get a newer yt-dlp.
143
+ - **Windows** uses the mpv that Cloudspin downloads for itself unless `mpv` is
144
+ already on your PATH.
145
+ - **MPRIS** (media keys, `playerctl`, Waybar) works on Linux if you have the
140
146
  `mpv-mpris` plugin installed for mpv; Cloudspin loads your normal mpv config.
141
147
 
142
- ## Packaging status
148
+ ## Releases
143
149
 
144
150
  | Channel | Status |
145
151
  | --- | --- |
146
- | Git (`pipx`, `uv`) | Works today |
147
- | PyPI and npm | Published by `.github/workflows/release.yml` when a `v*` tag is pushed. Setup steps are at the top of that file. |
148
- | AUR | `packaging/aur/PKGBUILD` is written; it builds from the PyPI release |
152
+ | [PyPI](https://pypi.org/project/cloudspin/) | Published |
153
+ | [npm](https://www.npmjs.com/package/cloudspin) | Published (launcher for the Python app) |
154
+ | AUR | `packaging/aur/PKGBUILD` is written, not submitted yet |
149
155
  | Homebrew | Not started |
150
156
 
151
- The npm package is a small launcher that runs the Python app through `uv` or `pipx`.
157
+ Pushing a `v*` tag runs `.github/workflows/release.yml`, which tests, then
158
+ publishes to PyPI and npm.
152
159
 
153
160
  ## Develop
154
161
 
@@ -170,6 +177,7 @@ The code is small and split by job:
170
177
  | `mood.py` | Choosing a scene for a song |
171
178
  | `weather.py` | Open-Meteo and sun and moon maths |
172
179
  | `player.py` | mpv control and the queue |
180
+ | `mpvget.py` | Finding mpv and yt-dlp; the Windows mpv download |
173
181
  | `widgets.py`, `app.py` | The interface |
174
182
 
175
183
  ## Licence
@@ -3,15 +3,14 @@
3
3
  # Not on the AUR yet. This builds from the PyPI release, so publish to PyPI
4
4
  # first, then run `updpkgsums` here to fill in the checksum.
5
5
  pkgname=cloudspin
6
- pkgver=0.1.0
6
+ pkgver=0.1.1
7
7
  pkgrel=1
8
8
  pkgdesc="A terminal music player under a living sky: spinning vinyl, real weather, scenes that follow the song"
9
9
  arch=('any')
10
10
  url="https://github.com/itsHarshDabas/Cloudspin"
11
11
  license=('MIT')
12
- depends=('python' 'python-textual' 'python-mutagen' 'python-pillow' 'mpv')
13
- optdepends=('ffmpeg: guess a sky for songs with no genre tag'
14
- 'yt-dlp: play YouTube and similar links')
12
+ depends=('python' 'python-textual' 'python-mutagen' 'python-pillow' 'yt-dlp' 'mpv')
13
+ optdepends=('ffmpeg: guess a sky for songs with no genre tag')
15
14
  makedepends=('python-build' 'python-installer' 'python-hatchling')
16
15
  source=("https://files.pythonhosted.org/packages/source/c/cloudspin/cloudspin-$pkgver.tar.gz")
17
16
  sha256sums=('SKIP')
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudspin",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Launcher for Cloudspin, a terminal music player under a living sky. Runs the Python app through uv or pipx.",
5
5
  "bin": {
6
6
  "cloudspin": "bin/cloudspin.js"
@@ -18,6 +18,7 @@ classifiers = [
18
18
  "Intended Audience :: End Users/Desktop",
19
19
  "Operating System :: POSIX :: Linux",
20
20
  "Operating System :: MacOS",
21
+ "Operating System :: Microsoft :: Windows",
21
22
  "Programming Language :: Python :: 3",
22
23
  "Programming Language :: Python :: 3.11",
23
24
  "Programming Language :: Python :: 3.12",
@@ -28,6 +29,7 @@ dependencies = [
28
29
  "textual>=8.0",
29
30
  "mutagen>=1.47",
30
31
  "pillow>=10.0",
32
+ "yt-dlp",
31
33
  ]
32
34
 
33
35
  [project.optional-dependencies]
@@ -1,3 +1,3 @@
1
1
  """Cloudspin: a terminal music player that lives under a living sky."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.1"
@@ -26,11 +26,28 @@ def build_parser() -> argparse.ArgumentParser:
26
26
  return parser
27
27
 
28
28
 
29
+ def _fetch_mpv(mpvget) -> str | None:
30
+ """First run on Windows: download mpv, showing progress on the plain terminal."""
31
+ print("Cloudspin plays sound through mpv. Downloading it once (about 30 MB)...")
32
+
33
+ def progress(done: int, total: int) -> None:
34
+ if total:
35
+ print(f"\r {done * 100 // total:3d}%", end="", flush=True)
36
+
37
+ try:
38
+ binary = mpvget.download_mpv(progress)
39
+ except mpvget.MpvInstallError as error:
40
+ print(f"\n{error}\nStarting without sound. You can install mpv yourself from https://mpv.io")
41
+ return None
42
+ print("\r done.")
43
+ return binary
44
+
45
+
29
46
  def main(argv: list[str] | None = None) -> int:
30
47
  args = build_parser().parse_args(argv)
31
48
 
32
49
  # Imported late so --help and --version stay instant.
33
- from . import library, mood, weather
50
+ from . import library, mood, mpvget, weather
34
51
  from .app import CloudspinApp
35
52
  from .config import Config
36
53
  from .player import DEMO_TRACKS, BackendError, Deck, DemoBackend, MpvBackend
@@ -71,19 +88,25 @@ def main(argv: list[str] | None = None) -> int:
71
88
  else:
72
89
  sources = args.music or [config.music]
73
90
  tracks = library.scan(sources)
91
+ try:
92
+ binary = mpvget.find_mpv()
93
+ if binary is None and mpvget.WINDOWS:
94
+ binary = _fetch_mpv(mpvget)
95
+ if binary is None:
96
+ raise BackendError(f"mpv isn't installed, so there's no sound. Get it with: {mpvget.install_hint()}")
97
+ backend = MpvBackend(volume=config.volume, binary=binary, ytdlp=mpvget.find_ytdlp())
98
+ except BackendError as error:
99
+ backend = DemoBackend()
100
+ silent_reason = "No sound"
101
+ notice = str(error)
74
102
  if not tracks:
75
103
  where = ", ".join(str(Path(s).expanduser()) if "://" not in s else s for s in sources)
76
- tracks = [type(t)(**vars(t)) for t in DEMO_TRACKS]
77
- backend = DemoBackend()
78
- silent_reason = "Silent demo"
79
- notice = f"No music found in {where}. Showing a silent demo; start with: cloudspin <folder>"
80
- else:
81
- try:
82
- backend = MpvBackend(volume=config.volume)
83
- except BackendError as error:
84
- backend = DemoBackend()
85
- silent_reason = "No sound"
86
- notice = str(error)
104
+ if backend.silent:
105
+ tracks = [type(t)(**vars(t)) for t in DEMO_TRACKS]
106
+ silent_reason = "Silent demo"
107
+ notice = notice or f"No music found in {where}. Showing a silent demo; start with: cloudspin <folder>"
108
+ else:
109
+ notice = f"No music found in {where}. Press o to play a link, a file or a folder"
87
110
 
88
111
  deck = Deck(backend, tracks, volume=config.volume)
89
112
  app = CloudspinApp(config, deck, silent_reason=silent_reason, notice=notice)
@@ -0,0 +1,119 @@
1
+ """Finding mpv and yt-dlp, and fetching mpv on Windows where there is no
2
+ package manager to lean on.
3
+
4
+ yt-dlp comes with Cloudspin as a Python dependency. mpv is a separate program:
5
+ on Linux and macOS it comes from the system's package manager; on Windows
6
+ Cloudspin downloads a build once into its cache folder.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import platform
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import urllib.error
18
+ import urllib.request
19
+ from collections.abc import Callable
20
+ from pathlib import Path
21
+
22
+ from . import __version__
23
+ from .config import cache_dir
24
+
25
+ WINDOWS = sys.platform == "win32"
26
+ RELEASES = "https://api.github.com/repos/shinchiro/mpv-winbuild-cmake/releases/latest"
27
+
28
+
29
+ class MpvInstallError(Exception):
30
+ """mpv could not be fetched; the message is written for the user."""
31
+
32
+
33
+ def _bundled_mpv() -> Path:
34
+ return cache_dir() / "mpv" / "mpv.exe"
35
+
36
+
37
+ def find_mpv() -> str | None:
38
+ """mpv from the PATH, or the copy Cloudspin downloaded earlier."""
39
+ found = shutil.which("mpv")
40
+ if found:
41
+ return found
42
+ if WINDOWS and _bundled_mpv().exists():
43
+ return str(_bundled_mpv())
44
+ return None
45
+
46
+
47
+ def find_ytdlp() -> str | None:
48
+ """yt-dlp from the same Python environment as Cloudspin, or from the PATH."""
49
+ name = "yt-dlp.exe" if WINDOWS else "yt-dlp"
50
+ beside = Path(sys.executable).parent / name
51
+ if beside.exists():
52
+ return str(beside)
53
+ return shutil.which("yt-dlp")
54
+
55
+
56
+ def install_hint() -> str:
57
+ """The command that installs mpv on this machine, as far as can be told."""
58
+ if sys.platform == "darwin":
59
+ return "brew install mpv"
60
+ for tool, command in (
61
+ ("pacman", "sudo pacman -S mpv"),
62
+ ("apt", "sudo apt install mpv"),
63
+ ("dnf", "sudo dnf install mpv"),
64
+ ("zypper", "sudo zypper install mpv"),
65
+ ):
66
+ if shutil.which(tool):
67
+ return command
68
+ return "install mpv with your package manager"
69
+
70
+
71
+ def _asset_pattern() -> re.Pattern[str]:
72
+ arch = "aarch64" if platform.machine().lower() in ("arm64", "aarch64") else "x86_64"
73
+ return re.compile(rf"^mpv-{arch}-\d{{8}}-git-[0-9a-f]+\.7z$")
74
+
75
+
76
+ def _open(url: str):
77
+ request = urllib.request.Request(url, headers={"User-Agent": f"cloudspin/{__version__}"})
78
+ return urllib.request.urlopen(request, timeout=30)
79
+
80
+
81
+ def download_mpv(progress: Callable[[int, int], None] | None = None) -> str:
82
+ """Windows only: download mpv into the cache folder and return its path."""
83
+ target = _bundled_mpv()
84
+ folder = target.parent
85
+ try:
86
+ with _open(RELEASES) as response:
87
+ release = json.loads(response.read().decode("utf-8"))
88
+ pattern = _asset_pattern()
89
+ asset = next((a for a in release.get("assets", []) if pattern.match(a.get("name", ""))), None)
90
+ if asset is None:
91
+ raise MpvInstallError("No mpv build for this computer was found to download.")
92
+ folder.mkdir(parents=True, exist_ok=True)
93
+ archive = folder / "mpv.7z"
94
+ total = int(asset.get("size") or 0)
95
+ done = 0
96
+ with _open(asset["browser_download_url"]) as response, open(archive, "wb") as out:
97
+ while True:
98
+ chunk = response.read(256 * 1024)
99
+ if not chunk:
100
+ break
101
+ out.write(chunk)
102
+ done += len(chunk)
103
+ if progress is not None:
104
+ progress(done, total)
105
+ except (urllib.error.URLError, TimeoutError, OSError, ValueError, KeyError) as error:
106
+ raise MpvInstallError(f"mpv couldn't be downloaded ({error}).") from error
107
+ # Windows 10 and later ship a tar that reads 7z archives.
108
+ try:
109
+ subprocess.run(
110
+ ["tar", "-xf", str(archive), "-C", str(folder)],
111
+ check=True, stdin=subprocess.DEVNULL, capture_output=True, timeout=300,
112
+ ) # fmt: skip
113
+ except (OSError, subprocess.SubprocessError) as error:
114
+ raise MpvInstallError("mpv was downloaded but couldn't be unpacked.") from error
115
+ finally:
116
+ archive.unlink(missing_ok=True)
117
+ if not target.exists():
118
+ raise MpvInstallError("mpv was downloaded but mpv.exe wasn't inside.")
119
+ return str(target)
@@ -5,10 +5,12 @@ backend, and the ``Deck`` that turns either into a queue with shuffle and repeat
5
5
  from __future__ import annotations
6
6
 
7
7
  import json
8
+ import os
8
9
  import random
9
10
  import shutil
10
11
  import socket
11
12
  import subprocess
13
+ import sys
12
14
  import tempfile
13
15
  import threading
14
16
  import time
@@ -87,13 +89,79 @@ class DemoBackend:
87
89
  self.alive = False
88
90
 
89
91
 
92
+ class _SocketLink:
93
+ """mpv's control channel on Linux and macOS: a unix socket."""
94
+
95
+ def __init__(self, path: str) -> None:
96
+ self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
97
+ try:
98
+ self._sock.connect(path)
99
+ except OSError:
100
+ self._sock.close()
101
+ raise
102
+
103
+ def send(self, data: bytes) -> None:
104
+ self._sock.sendall(data)
105
+
106
+ def receive(self) -> bytes:
107
+ """Block until data arrives; empty bytes means mpv hung up."""
108
+ return self._sock.recv(65536)
109
+
110
+ def close(self) -> None:
111
+ try:
112
+ self._sock.close()
113
+ except OSError:
114
+ pass
115
+
116
+
117
+ class _PipeLink:
118
+ """mpv's control channel on Windows: a named pipe.
119
+
120
+ A blocking read on a pipe handle would hold up writes from other threads,
121
+ so this only reads what PeekNamedPipe says is already waiting.
122
+ """
123
+
124
+ def __init__(self, path: str) -> None:
125
+ import msvcrt
126
+
127
+ self._file = open(path, "r+b", buffering=0)
128
+ self._handle = msvcrt.get_osfhandle(self._file.fileno())
129
+ self._closed = False
130
+
131
+ def send(self, data: bytes) -> None:
132
+ self._file.write(data)
133
+
134
+ def receive(self) -> bytes:
135
+ import _winapi
136
+
137
+ while not self._closed:
138
+ waiting = _winapi.PeekNamedPipe(self._handle, 0)[0]
139
+ if waiting:
140
+ return self._file.read(waiting) or b""
141
+ time.sleep(0.02)
142
+ return b""
143
+
144
+ def close(self) -> None:
145
+ self._closed = True
146
+ try:
147
+ self._file.close()
148
+ except OSError:
149
+ pass
150
+
151
+
90
152
  class MpvBackend:
91
153
  """Plays audio by running mpv in the background and talking to its IPC socket."""
92
154
 
93
155
  name = "mpv"
94
156
  silent = False
95
157
 
96
- def __init__(self, volume: int = 70, binary: str | None = None) -> None:
158
+ def __init__(
159
+ self,
160
+ volume: int = 70,
161
+ binary: str | None = None,
162
+ ytdlp: str | None = None,
163
+ extra_args: tuple[str, ...] = (),
164
+ ) -> None:
97
165
  self.position = 0.0
98
166
  self.duration = 0.0
99
167
  self.media_title = ""
@@ -102,7 +170,7 @@ class MpvBackend:
102
170
  self._ended = False
103
171
  self._error = ""
104
172
  self._lock = threading.Lock()
105
- self._sock: socket.socket | None = None
173
+ self._sock: _SocketLink | _PipeLink | None = None
106
174
  self._dir = ""
107
175
  self._process: subprocess.Popen | None = None
108
176
 
@@ -110,7 +178,14 @@ class MpvBackend:
110
178
  if not binary:
111
179
  raise BackendError("mpv isn't installed, so there's no sound yet. Install mpv and start Cloudspin again.")
112
180
  self._dir = tempfile.mkdtemp(prefix="cloudspin-")
113
- path = str(Path(self._dir) / "mpv.sock")
181
+ if sys.platform == "win32":
182
+ path = rf"\\.\pipe\cloudspin-{os.getpid()}-{Path(self._dir).name}"
183
+ else:
184
+ path = str(Path(self._dir) / "mpv.sock")
185
+ options = list(extra_args)
186
+ if ytdlp:
187
+ # Point mpv at the yt-dlp that was installed with Cloudspin.
188
+ options.append(f"--script-opts=ytdl_hook-ytdl_path={ytdlp}")
114
189
  try:
115
190
  self._process = subprocess.Popen(
116
191
  [
@@ -122,6 +197,7 @@ class MpvBackend:
122
197
  "--keep-open=no",
123
198
  f"--volume={int(volume)}",
124
199
  f"--input-ipc-server={path}",
200
+ *options,
125
201
  ],
126
202
  stdin=subprocess.DEVNULL,
127
203
  stdout=subprocess.DEVNULL,
@@ -143,13 +219,10 @@ class MpvBackend:
143
219
  if self._process is not None and self._process.poll() is not None:
144
220
  break
145
221
  try:
146
- sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
147
- sock.connect(path)
148
- self._sock = sock
222
+ self._sock = _PipeLink(path) if sys.platform == "win32" else _SocketLink(path)
149
223
  return
150
224
  except OSError as error:
151
225
  last = error
152
- sock.close()
153
226
  time.sleep(0.05)
154
227
  self.close()
155
228
  raise BackendError("mpv started but didn't open its control socket.") from last
@@ -161,7 +234,7 @@ class MpvBackend:
161
234
  data = (json.dumps({"command": command}) + "\n").encode("utf-8")
162
235
  try:
163
236
  with self._lock:
164
- sock.sendall(data)
237
+ sock.send(data)
165
238
  except OSError:
166
239
  self.alive = False
167
240
 
@@ -170,7 +243,7 @@ class MpvBackend:
170
243
  buffer = b""
171
244
  while sock is not None:
172
245
  try:
173
- chunk = sock.recv(65536)
246
+ chunk = sock.receive()
174
247
  except OSError:
175
248
  break
176
249
  if not chunk:
@@ -250,13 +323,10 @@ class MpvBackend:
250
323
  self.alive = False
251
324
  if self._sock is not None:
252
325
  try:
253
- self._sock.sendall(b'{"command": ["quit"]}\n')
254
- except OSError:
255
- pass
256
- try:
257
- self._sock.close()
326
+ self._sock.send(b'{"command": ["quit"]}\n')
258
327
  except OSError:
259
328
  pass
329
+ self._sock.close()
260
330
  self._sock = None
261
331
  if self._process is not None:
262
332
  try:
@@ -274,7 +274,8 @@ class SkyView(Pixels):
274
274
  def _write_big(self, texts: dict[int, RowText], report: Report, x0: int, room: int) -> int:
275
275
  name = fit(report.place.label, min(room, 30))
276
276
  used = lay_text(texts, x0, 1, name, INK, True)
277
- when = f"{report.local_now():%a %-d %b %H:%M}"
277
+ now = report.local_now()
278
+ when = f"{now:%a} {now.day} {now:%b} {now:%H:%M}" # %-d is not portable to Windows
278
279
  if used + 3 + len(when) <= min(room, 52):
279
280
  used += 3 + lay_text(texts, x0 + used + 3, 1, when, INK_SOFT)
280
281
  number = str(round(report.temperature))
@@ -387,7 +388,7 @@ class NowPlaying(Painted):
387
388
  lines: list[list[Segment]] = []
388
389
  if track is None:
389
390
  title = "Nothing on the platter"
390
- second = "Press space to start playing" if deck.tracks else "Start Cloudspin with a music folder to fill the queue"
391
+ second = "Press space to start playing" if deck.tracks else "Press o to play a link, a file or a folder"
391
392
  third = ""
392
393
  else:
393
394
  title = track.title
@@ -131,7 +131,7 @@ def mpv():
131
131
  backend.close()
132
132
 
133
133
 
134
- @pytest.mark.skipif(sys.platform == "win32", reason="mpv is driven over a unix socket")
134
+ @pytest.mark.skipif(sys.platform == "win32", reason="the stand-in mpv speaks over a unix socket")
135
135
  def test_mpv_backend_speaks_the_protocol(mpv):
136
136
  backend, commands = mpv
137
137
  deck = Deck(backend, [Track("/music/one.flac", "one"), Track("/music/two.flac", "two")], volume=55)
@@ -151,7 +151,7 @@ def test_mpv_backend_speaks_the_protocol(mpv):
151
151
  assert wait_for(lambda: backend.position == 31.25)
152
152
 
153
153
 
154
- @pytest.mark.skipif(sys.platform == "win32", reason="mpv is driven over a unix socket")
154
+ @pytest.mark.skipif(sys.platform == "win32", reason="the stand-in mpv speaks over a unix socket")
155
155
  def test_mpv_backend_moves_on_at_the_end_and_stops_on_errors(mpv):
156
156
  backend, _ = mpv
157
157
  deck = Deck(backend, [Track("/music/short.mp3", "short"), Track("https://example.com/missing", "link"), Track("/c.mp3", "c")])
@@ -0,0 +1,80 @@
1
+ """Playback against a real mpv, with the sound thrown away.
2
+
3
+ Skipped when mpv isn't installed. On Windows CI, CLOUDSPIN_TEST_DOWNLOAD=1 makes
4
+ this download mpv first, which also checks the downloader end to end.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+ sys.path.insert(0, str(Path(__file__).parent))
15
+ import synth # noqa: E402
16
+
17
+ from cloudspin import mpvget # noqa: E402
18
+ from cloudspin.player import Deck, MpvBackend, Track # noqa: E402
19
+
20
+
21
+ @pytest.fixture(scope="module")
22
+ def mpv_binary():
23
+ binary = mpvget.find_mpv()
24
+ if binary is None and mpvget.WINDOWS and os.environ.get("CLOUDSPIN_TEST_DOWNLOAD"):
25
+ binary = mpvget.download_mpv()
26
+ assert Path(binary).exists() and mpvget.find_mpv() == binary
27
+ if binary is None:
28
+ pytest.skip("mpv is not installed")
29
+ return binary
30
+
31
+
32
+ def wait_for(check, seconds=15.0):
33
+ end = time.monotonic() + seconds
34
+ while time.monotonic() < end:
35
+ if check():
36
+ return True
37
+ time.sleep(0.05)
38
+ return False
39
+
40
+
41
+ def test_real_mpv_plays_seeks_pauses_and_moves_on(mpv_binary, tmp_path):
42
+ first, second = tmp_path / "first song.wav", tmp_path / "second.wav"
43
+ synth.write_wav(first, synth.gentle()[: 8000 * 4])
44
+ synth.write_wav(second, synth.gentle()[: 8000 * 4])
45
+ backend = MpvBackend(volume=40, binary=mpv_binary, extra_args=("--ao=null", "--no-config"))
46
+ deck = Deck(backend, [Track(str(first), "first"), Track(str(second), "second")])
47
+ try:
48
+ deck.play(0)
49
+ assert wait_for(lambda: 3.5 < backend.duration < 4.5), "mpv should report the length"
50
+ assert wait_for(lambda: backend.position > 0.3), "time should move while playing"
51
+ deck.toggle()
52
+ time.sleep(0.4)
53
+ held = backend.position
54
+ time.sleep(0.5)
55
+ assert abs(backend.position - held) < 0.15, "time should stop while paused"
56
+ deck.toggle()
57
+ deck.seek_to(0.8)
58
+ assert wait_for(lambda: backend.position > 2.5), "seeking should jump ahead"
59
+ assert wait_for(lambda: (deck.tick(), deck.index == 1)[1]), "the next song should start at the end"
60
+ assert backend.alive
61
+ finally:
62
+ deck.close()
63
+
64
+
65
+ def test_real_mpv_reports_a_file_it_cannot_play(mpv_binary, tmp_path):
66
+ backend = MpvBackend(binary=mpv_binary, extra_args=("--ao=null", "--no-config"))
67
+ deck = Deck(backend, [Track(str(tmp_path / "missing.mp3"), "missing")])
68
+ try:
69
+ deck.play(0)
70
+ assert wait_for(lambda: (deck.tick(), deck.failure != "")[1])
71
+ finally:
72
+ deck.close()
73
+
74
+
75
+ def test_ytdlp_ships_with_cloudspin():
76
+ assert mpvget.find_ytdlp() is not None
77
+
78
+
79
+ def test_install_hint_names_a_command():
80
+ assert "mpv" in mpvget.install_hint()
@@ -1,23 +0,0 @@
1
- name: Tests
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
-
8
- jobs:
9
- test:
10
- runs-on: ubuntu-latest
11
- strategy:
12
- fail-fast: false
13
- matrix:
14
- python: ["3.11", "3.12", "3.13"]
15
- steps:
16
- - uses: actions/checkout@v4
17
- - uses: actions/setup-python@v5
18
- with:
19
- python-version: ${{ matrix.python }}
20
- - name: Install ffmpeg (used to listen to songs)
21
- run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
22
- - run: pip install -e ".[dev]"
23
- - run: pytest -q
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes