cloudspin 0.1.1__tar.gz → 0.2.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.
Files changed (48) hide show
  1. {cloudspin-0.1.1 → cloudspin-0.2.0}/.github/workflows/ci.yml +15 -0
  2. {cloudspin-0.1.1 → cloudspin-0.2.0}/PKG-INFO +44 -20
  3. {cloudspin-0.1.1 → cloudspin-0.2.0}/README.md +43 -19
  4. {cloudspin-0.1.1 → cloudspin-0.2.0}/packaging/aur/PKGBUILD +1 -1
  5. {cloudspin-0.1.1 → cloudspin-0.2.0}/packaging/npm/package.json +1 -1
  6. {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/__init__.py +1 -1
  7. {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/app.py +89 -15
  8. {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/cli.py +28 -3
  9. {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/config.py +4 -0
  10. cloudspin-0.2.0/src/cloudspin/links.py +166 -0
  11. {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/mood.py +91 -34
  12. {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/player.py +3 -0
  13. {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/screens.py +2 -2
  14. cloudspin-0.2.0/tests/conftest.py +13 -0
  15. cloudspin-0.2.0/tests/test_links.py +75 -0
  16. cloudspin-0.2.0/tests/test_network.py +40 -0
  17. {cloudspin-0.1.1 → cloudspin-0.2.0}/tests/test_real_mpv.py +33 -0
  18. cloudspin-0.1.1/tests/conftest.py +0 -6
  19. {cloudspin-0.1.1 → cloudspin-0.2.0}/.github/workflows/release.yml +0 -0
  20. {cloudspin-0.1.1 → cloudspin-0.2.0}/.gitignore +0 -0
  21. {cloudspin-0.1.1 → cloudspin-0.2.0}/LICENSE +0 -0
  22. {cloudspin-0.1.1 → cloudspin-0.2.0}/docs/evening.png +0 -0
  23. {cloudspin-0.1.1 → cloudspin-0.2.0}/docs/night.png +0 -0
  24. {cloudspin-0.1.1 → cloudspin-0.2.0}/docs/storm.png +0 -0
  25. {cloudspin-0.1.1 → cloudspin-0.2.0}/packaging/npm/README.md +0 -0
  26. {cloudspin-0.1.1 → cloudspin-0.2.0}/packaging/npm/bin/cloudspin.js +0 -0
  27. {cloudspin-0.1.1 → cloudspin-0.2.0}/pyproject.toml +0 -0
  28. {cloudspin-0.1.1 → cloudspin-0.2.0}/scripts/screenshot.py +0 -0
  29. {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/__main__.py +0 -0
  30. {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/canvas.py +0 -0
  31. {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/color.py +0 -0
  32. {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/library.py +0 -0
  33. {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/mpvget.py +0 -0
  34. {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/sky.py +0 -0
  35. {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/songlook.py +0 -0
  36. {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/spectrum.py +0 -0
  37. {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/themes.py +0 -0
  38. {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/vinyl.py +0 -0
  39. {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/weather.py +0 -0
  40. {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/widgets.py +0 -0
  41. {cloudspin-0.1.1 → cloudspin-0.2.0}/tests/fake_mpv.py +0 -0
  42. {cloudspin-0.1.1 → cloudspin-0.2.0}/tests/fixtures/forecast.json +0 -0
  43. {cloudspin-0.1.1 → cloudspin-0.2.0}/tests/synth.py +0 -0
  44. {cloudspin-0.1.1 → cloudspin-0.2.0}/tests/test_app.py +0 -0
  45. {cloudspin-0.1.1 → cloudspin-0.2.0}/tests/test_mood.py +0 -0
  46. {cloudspin-0.1.1 → cloudspin-0.2.0}/tests/test_player.py +0 -0
  47. {cloudspin-0.1.1 → cloudspin-0.2.0}/tests/test_sky.py +0 -0
  48. {cloudspin-0.1.1 → cloudspin-0.2.0}/tests/test_weather.py +0 -0
@@ -35,3 +35,18 @@ jobs:
35
35
  run: brew install mpv ffmpeg
36
36
  - run: pip install -e ".[dev]"
37
37
  - run: pytest -q -rs
38
+
39
+ network:
40
+ # Real Spotify and YouTube. Informational: their servers can refuse CI machines.
41
+ runs-on: ubuntu-latest
42
+ continue-on-error: true
43
+ steps:
44
+ - uses: actions/checkout@v4
45
+ - uses: actions/setup-python@v5
46
+ with:
47
+ python-version: "3.12"
48
+ - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends mpv
49
+ - run: pip install -e ".[dev]"
50
+ - run: pytest -q -rs -s tests/test_network.py
51
+ env:
52
+ CLOUDSPIN_TEST_NETWORK: "1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cloudspin
3
- Version: 0.1.1
3
+ Version: 0.2.0
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
@@ -45,7 +45,8 @@ synthwave turns the stars on.
45
45
  ## What it does
46
46
 
47
47
  - Plays local files, folders, radio streams and links through `mpv`.
48
- - Paste a link while it's running (press `o`) and it starts playing.
48
+ - Opens YouTube, YouTube Music and Spotify playlists as a queue.
49
+ - Paste a link or a playlist while it's running (press `o`) and it starts playing.
49
50
  - Shows live weather and a six-day forecast from [Open-Meteo](https://open-meteo.com) (free, no API key).
50
51
  - Draws an animated sky: sun, moon with the real phase, stars, drifting clouds, rain, snow, fog and lightning.
51
52
  - Picks the sky to suit the song, or follows the real weather, or stays on a scene you pin.
@@ -75,7 +76,6 @@ and most others).
75
76
  | --- | --- | --- |
76
77
  | `yt-dlp` | YouTube, SoundCloud and similar links | Installed with Cloudspin |
77
78
  | `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 |
79
79
 
80
80
  Without mpv Cloudspin still runs, silently, and tells you the command to install it.
81
81
 
@@ -86,6 +86,8 @@ cloudspin # plays ~/Music
86
86
  cloudspin ~/Music/phonk # a folder
87
87
  cloudspin song.flac other.mp3 # files
88
88
  cloudspin https://example.com/stream.mp3 # a link
89
+ cloudspin "https://music.youtube.com/playlist?list=..." # a YouTube Music playlist
90
+ cloudspin "https://open.spotify.com/playlist/..." # a Spotify playlist
89
91
  cloudspin --city "New Delhi" # set the city once; it is remembered
90
92
  cloudspin --demo # look around without any music or sound
91
93
  ```
@@ -110,24 +112,43 @@ No music folder? Start `cloudspin`, press `o` and paste a link.
110
112
  | `?` | Show the keys |
111
113
  | `q` | Quit |
112
114
 
115
+ ## Playlists
116
+
117
+ Paste a playlist link with `o`, pass it on the command line, or set it as
118
+ `music` in the settings so Cloudspin opens with it every time.
119
+
120
+ - **YouTube, YouTube Music, SoundCloud** and other sites yt-dlp knows play
121
+ straight from the site.
122
+ - **Spotify** works differently. Spotify's audio only plays inside its own
123
+ apps, so Cloudspin reads the list of songs from the public playlist page and
124
+ plays each one from the closest match on YouTube. Usually that is the same
125
+ recording; now and then it is a live or alternate version. The playlist has
126
+ to be public, and long ones are read up to the first 50 to 100 songs.
127
+
113
128
  ## How the sky picks a scene
114
129
 
115
- With the sky set to follow the song (the default), Cloudspin looks for a
116
- scene in this order and uses the first one it finds:
117
-
118
- 1. **Your rules.** Words you map to scenes in the config file.
119
- 2. **The tags.** The genre tag, then sky words in the title or album.
120
- `phonk`, `metal` and `dubstep` storm; `lofi`, `jazz` and `acoustic` get the
121
- evening; `synthwave` and `r&b` get the night; `blues` rains; `dream pop`
122
- fogs over; `christmas` snows; `pop` and `dance` get a sunny day. A song
123
- called "Midnight Rain" needs no tag at all.
124
- 3. **A short listen.** If `ffmpeg` is installed, 30 seconds of the song are
125
- measured for loudness, tempo, bass and brightness. Loud, fast and
126
- bass-heavy becomes a storm; quiet and slow becomes night.
130
+ With the sky set to follow the song (the default), Cloudspin decides like this:
131
+
132
+ 1. **Your rules.** Words you map to scenes in the config file always win.
133
+ 2. **How the song sounds.** Thirty seconds are decoded in the background and
134
+ measured for rhythm (attacks per second, tempo), brightness, bass weight
135
+ and loudness. Busy, bright and bass-heavy becomes a storm; slow and soft
136
+ becomes evening or night. This works for links as well as files, and each
137
+ song is only measured once.
138
+ 3. **The words**, as a first guess while the listening happens and as the
139
+ answer when listening isn't possible: the genre tag, then sky words in the
140
+ title or album. `phonk`, `hoodtrap` and `metal` storm; `lofi` and `jazz`
141
+ get the evening; `synthwave` gets the night; "Midnight Rain" needs no tag.
127
142
  4. **The real weather**, if nothing above says anything.
128
143
 
129
144
  The caption in the top right corner of the sky always says which scene is
130
- showing and why, for example `Thunderstorm, tagged Drift phonk`.
145
+ showing and why, for example `Thunderstorm, hard-hitting and heavy on bass`.
146
+
147
+ To see what Cloudspin hears in a song, and report one it gets wrong:
148
+
149
+ ```sh
150
+ cloudspin --listen "song.mp3"
151
+ ```
131
152
 
132
153
  The scenes are `storm`, `rain`, `evening`, `night`, `day`, `dawn`, `snow`,
133
154
  `fog` and `overcast`.
@@ -141,9 +162,10 @@ city = "New Delhi"
141
162
  units = "metric" # or "imperial"
142
163
  sky = "song" # "song", "weather", or a scene name to pin
143
164
  colors = "sky" # "sky", "cover", a built-in theme, or one of yours
144
- music = "~/Music"
165
+ music = "~/Music" # a folder, or a playlist link
145
166
  volume = 70
146
167
  fps = 24 # lower this on a slow terminal or over SSH
168
+ listen = true # false: choose the sky from tags and titles only
147
169
 
148
170
  # Send particular artists, albums or words to a scene.
149
171
  [moods]
@@ -165,9 +187,10 @@ Built-in themes: `sky`, `cover`, `amber`, `orchid`, `lagoon`, `moss`, `rose`, `m
165
187
 
166
188
  - **The bars are generative.** The spectrum moves with the play state, the
167
189
  volume and a tempo chosen per song. It is not a live analysis of the audio.
168
- - **The listen is a guess.** Step 3 above is a heuristic that has only been
169
- checked against synthetic test audio. Tags and your own rules are exact;
170
- use `[moods]` when it gets a song wrong.
190
+ - **The listening is a rule of thumb.** It has been checked against synthetic
191
+ test audio, not a real music library, so expect some wrong skies. Use
192
+ `[moods]` to pin an artist or word to a scene, and `cloudspin --listen` to
193
+ see the numbers behind a choice.
171
194
  - **Links** play through mpv, with the bundled yt-dlp handling sites like
172
195
  YouTube. Sites change often; if a link stops working, update with
173
196
  `pipx upgrade cloudspin` (or `uv tool upgrade cloudspin`) to get a newer yt-dlp.
@@ -208,6 +231,7 @@ The code is small and split by job:
208
231
  | `mood.py` | Choosing a scene for a song |
209
232
  | `weather.py` | Open-Meteo and sun and moon maths |
210
233
  | `player.py` | mpv control and the queue |
234
+ | `links.py` | Reading YouTube and Spotify playlists |
211
235
  | `mpvget.py` | Finding mpv and yt-dlp; the Windows mpv download |
212
236
  | `widgets.py`, `app.py` | The interface |
213
237
 
@@ -14,7 +14,8 @@ synthwave turns the stars on.
14
14
  ## What it does
15
15
 
16
16
  - Plays local files, folders, radio streams and links through `mpv`.
17
- - Paste a link while it's running (press `o`) and it starts playing.
17
+ - Opens YouTube, YouTube Music and Spotify playlists as a queue.
18
+ - Paste a link or a playlist while it's running (press `o`) and it starts playing.
18
19
  - Shows live weather and a six-day forecast from [Open-Meteo](https://open-meteo.com) (free, no API key).
19
20
  - Draws an animated sky: sun, moon with the real phase, stars, drifting clouds, rain, snow, fog and lightning.
20
21
  - Picks the sky to suit the song, or follows the real weather, or stays on a scene you pin.
@@ -44,7 +45,6 @@ and most others).
44
45
  | --- | --- | --- |
45
46
  | `yt-dlp` | YouTube, SoundCloud and similar links | Installed with Cloudspin |
46
47
  | `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 |
48
48
 
49
49
  Without mpv Cloudspin still runs, silently, and tells you the command to install it.
50
50
 
@@ -55,6 +55,8 @@ cloudspin # plays ~/Music
55
55
  cloudspin ~/Music/phonk # a folder
56
56
  cloudspin song.flac other.mp3 # files
57
57
  cloudspin https://example.com/stream.mp3 # a link
58
+ cloudspin "https://music.youtube.com/playlist?list=..." # a YouTube Music playlist
59
+ cloudspin "https://open.spotify.com/playlist/..." # a Spotify playlist
58
60
  cloudspin --city "New Delhi" # set the city once; it is remembered
59
61
  cloudspin --demo # look around without any music or sound
60
62
  ```
@@ -79,24 +81,43 @@ No music folder? Start `cloudspin`, press `o` and paste a link.
79
81
  | `?` | Show the keys |
80
82
  | `q` | Quit |
81
83
 
84
+ ## Playlists
85
+
86
+ Paste a playlist link with `o`, pass it on the command line, or set it as
87
+ `music` in the settings so Cloudspin opens with it every time.
88
+
89
+ - **YouTube, YouTube Music, SoundCloud** and other sites yt-dlp knows play
90
+ straight from the site.
91
+ - **Spotify** works differently. Spotify's audio only plays inside its own
92
+ apps, so Cloudspin reads the list of songs from the public playlist page and
93
+ plays each one from the closest match on YouTube. Usually that is the same
94
+ recording; now and then it is a live or alternate version. The playlist has
95
+ to be public, and long ones are read up to the first 50 to 100 songs.
96
+
82
97
  ## How the sky picks a scene
83
98
 
84
- With the sky set to follow the song (the default), Cloudspin looks for a
85
- scene in this order and uses the first one it finds:
86
-
87
- 1. **Your rules.** Words you map to scenes in the config file.
88
- 2. **The tags.** The genre tag, then sky words in the title or album.
89
- `phonk`, `metal` and `dubstep` storm; `lofi`, `jazz` and `acoustic` get the
90
- evening; `synthwave` and `r&b` get the night; `blues` rains; `dream pop`
91
- fogs over; `christmas` snows; `pop` and `dance` get a sunny day. A song
92
- called "Midnight Rain" needs no tag at all.
93
- 3. **A short listen.** If `ffmpeg` is installed, 30 seconds of the song are
94
- measured for loudness, tempo, bass and brightness. Loud, fast and
95
- bass-heavy becomes a storm; quiet and slow becomes night.
99
+ With the sky set to follow the song (the default), Cloudspin decides like this:
100
+
101
+ 1. **Your rules.** Words you map to scenes in the config file always win.
102
+ 2. **How the song sounds.** Thirty seconds are decoded in the background and
103
+ measured for rhythm (attacks per second, tempo), brightness, bass weight
104
+ and loudness. Busy, bright and bass-heavy becomes a storm; slow and soft
105
+ becomes evening or night. This works for links as well as files, and each
106
+ song is only measured once.
107
+ 3. **The words**, as a first guess while the listening happens and as the
108
+ answer when listening isn't possible: the genre tag, then sky words in the
109
+ title or album. `phonk`, `hoodtrap` and `metal` storm; `lofi` and `jazz`
110
+ get the evening; `synthwave` gets the night; "Midnight Rain" needs no tag.
96
111
  4. **The real weather**, if nothing above says anything.
97
112
 
98
113
  The caption in the top right corner of the sky always says which scene is
99
- showing and why, for example `Thunderstorm, tagged Drift phonk`.
114
+ showing and why, for example `Thunderstorm, hard-hitting and heavy on bass`.
115
+
116
+ To see what Cloudspin hears in a song, and report one it gets wrong:
117
+
118
+ ```sh
119
+ cloudspin --listen "song.mp3"
120
+ ```
100
121
 
101
122
  The scenes are `storm`, `rain`, `evening`, `night`, `day`, `dawn`, `snow`,
102
123
  `fog` and `overcast`.
@@ -110,9 +131,10 @@ city = "New Delhi"
110
131
  units = "metric" # or "imperial"
111
132
  sky = "song" # "song", "weather", or a scene name to pin
112
133
  colors = "sky" # "sky", "cover", a built-in theme, or one of yours
113
- music = "~/Music"
134
+ music = "~/Music" # a folder, or a playlist link
114
135
  volume = 70
115
136
  fps = 24 # lower this on a slow terminal or over SSH
137
+ listen = true # false: choose the sky from tags and titles only
116
138
 
117
139
  # Send particular artists, albums or words to a scene.
118
140
  [moods]
@@ -134,9 +156,10 @@ Built-in themes: `sky`, `cover`, `amber`, `orchid`, `lagoon`, `moss`, `rose`, `m
134
156
 
135
157
  - **The bars are generative.** The spectrum moves with the play state, the
136
158
  volume and a tempo chosen per song. It is not a live analysis of the audio.
137
- - **The listen is a guess.** Step 3 above is a heuristic that has only been
138
- checked against synthetic test audio. Tags and your own rules are exact;
139
- use `[moods]` when it gets a song wrong.
159
+ - **The listening is a rule of thumb.** It has been checked against synthetic
160
+ test audio, not a real music library, so expect some wrong skies. Use
161
+ `[moods]` to pin an artist or word to a scene, and `cloudspin --listen` to
162
+ see the numbers behind a choice.
140
163
  - **Links** play through mpv, with the bundled yt-dlp handling sites like
141
164
  YouTube. Sites change often; if a link stops working, update with
142
165
  `pipx upgrade cloudspin` (or `uv tool upgrade cloudspin`) to get a newer yt-dlp.
@@ -177,6 +200,7 @@ The code is small and split by job:
177
200
  | `mood.py` | Choosing a scene for a song |
178
201
  | `weather.py` | Open-Meteo and sun and moon maths |
179
202
  | `player.py` | mpv control and the queue |
203
+ | `links.py` | Reading YouTube and Spotify playlists |
180
204
  | `mpvget.py` | Finding mpv and yt-dlp; the Windows mpv download |
181
205
  | `widgets.py`, `app.py` | The interface |
182
206
 
@@ -3,7 +3,7 @@
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.1
6
+ pkgver=0.2.0
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')
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudspin",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
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"
@@ -1,3 +1,3 @@
1
1
  """Cloudspin: a terminal music player that lives under a living sky."""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.2.0"
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import math
6
7
  import queue
7
8
  import threading
@@ -12,7 +13,7 @@ from textual.binding import Binding
12
13
  from textual.color import Color
13
14
  from textual.containers import Horizontal, Vertical
14
15
 
15
- from . import library, mood, weather
16
+ from . import library, links, mood, weather
16
17
  from .config import Config, cache_dir
17
18
  from pathlib import Path
18
19
 
@@ -108,6 +109,14 @@ class CloudspinApp(App[None]):
108
109
  self._weather_due = 0.0
109
110
  self._weather_busy = False
110
111
  self._closing = False
112
+ self._heard: set[str] = set() # songs whose sky came from listening, not from words
113
+ self._features: dict[str, list[float]] = {}
114
+ self._features_lock = threading.Lock()
115
+ try:
116
+ saved = json.loads((cache_dir() / "listened.json").read_text(encoding="utf-8"))
117
+ self._features = {str(k): [float(x) for x in v] for k, v in saved.items() if isinstance(v, list) and len(v) == 5}
118
+ except (OSError, ValueError, TypeError):
119
+ pass
111
120
  self._is_up = False
112
121
 
113
122
  # -- layout --------------------------------------------------------------
@@ -171,6 +180,8 @@ class CloudspinApp(App[None]):
171
180
  # -- the clock -----------------------------------------------------------
172
181
 
173
182
  def _tick(self) -> None:
183
+ if self._closing or not self.screen_stack:
184
+ return # a last timer tick can land while the app is shutting down
174
185
  now = time.monotonic()
175
186
  dt = min(0.25, now - self._last)
176
187
  self._last = now
@@ -267,7 +278,7 @@ class CloudspinApp(App[None]):
267
278
  def _follow_stream(self) -> None:
268
279
  """Links only learn their real name once they start playing."""
269
280
  track = self.deck.current
270
- if track is None or not track.is_stream:
281
+ if track is None or not track.is_stream or track.pinned:
271
282
  return
272
283
  backend = self.deck.backend
273
284
  title = getattr(backend, "media_title", "")
@@ -281,7 +292,8 @@ class CloudspinApp(App[None]):
281
292
  look = look_for(track.title, track.artist, track.album, None)
282
293
  guess = mood.from_words(track.title, track.artist, track.album, track.genre, self.config.moods)
283
294
  self._looks[track.path] = self.look = look
284
- self._moods[track.path] = self.mood = guess
295
+ if track.path not in self._heard:
296
+ self._moods[track.path] = self.mood = guess
285
297
 
286
298
  def _open(self, target: str | None) -> None:
287
299
  if not target:
@@ -289,7 +301,18 @@ class CloudspinApp(App[None]):
289
301
  target = target.strip()
290
302
  if "://" in target:
291
303
  if self.deck.backend.silent:
292
- self.flash("Links need mpv for sound. Install mpv, plus yt-dlp for YouTube links.", 7.0)
304
+ self.flash("Links need mpv for sound, and mpv isn't installed.", 7.0)
305
+ return
306
+ if links.is_collection(target):
307
+ self.flash("Reading the playlist…", 30.0)
308
+
309
+ def work() -> None:
310
+ try:
311
+ self._inbox.put(("playlist", links.expand(target), ""))
312
+ except links.LinkError as error:
313
+ self._inbox.put(("playlist", [], str(error)))
314
+
315
+ threading.Thread(target=work, name="cloudspin-playlist", daemon=True).start()
293
316
  return
294
317
  tracks = [Track(path=target, title=target.split("://", 1)[1].rstrip("/") or target)]
295
318
  else:
@@ -312,15 +335,52 @@ class CloudspinApp(App[None]):
312
335
  library.read_tags(track)
313
336
  art = library.cover_art(path)
314
337
  self._inbox.put(("look", path, look_for(track.title, track.artist, track.album, art)))
315
- guess = mood.from_words(track.title, track.artist, track.album, track.genre, self.config.moods)
316
- if guess is None and not track.is_stream:
317
- features = mood.listen(path)
318
- if features is not None:
319
- self._inbox.put(("tempo", path, features.tempo))
320
- guess = mood.from_features(features)
321
- self._inbox.put(("mood", path, guess))
338
+ ruled = mood.from_rules(track.title, track.artist, track.album, track.genre, self.config.moods)
339
+ if ruled is not None:
340
+ self._inbox.put(("mood", path, ruled, "sound")) # your rules outrank everything
341
+ return
342
+ # Words give an instant first guess; the sound of the song has the final say.
343
+ guess = mood.from_words(track.title, track.artist, track.album, track.genre)
344
+ self._inbox.put(("mood", path, guess, "words"))
345
+ features = self._listen(track) if self.config.listen else None
346
+ if features is not None:
347
+ self._inbox.put(("tempo", path, features.tempo))
348
+ self._inbox.put(("mood", path, mood.from_features(features), "sound"))
322
349
  except Exception: # a broken file must never take the app down
323
- self._inbox.put(("mood", path, None))
350
+ self._inbox.put(("mood", path, None, "words"))
351
+ finally:
352
+ self._inbox.put(("studied", path))
353
+
354
+ def _listen(self, track) -> mood.Features | None:
355
+ """Measure a song, remembering the answer so it is only ever done once."""
356
+ path = track.path
357
+ key = path
358
+ if not track.is_stream:
359
+ try:
360
+ stat = Path(path).stat()
361
+ key = f"{path}|{stat.st_size}|{int(stat.st_mtime)}"
362
+ except OSError:
363
+ return None
364
+ known = self._features.get(key)
365
+ if known is not None:
366
+ return mood.Features(*known)
367
+ backend = self.deck.backend
368
+ if track.is_stream and backend.silent:
369
+ return None
370
+ features = mood.listen(path, getattr(backend, "binary", None), getattr(backend, "ytdlp", None))
371
+ if features is None:
372
+ return None
373
+ with self._features_lock:
374
+ self._features[key] = [features.loudness, features.tempo, features.onsets, features.bass, features.brightness]
375
+ while len(self._features) > 4000:
376
+ self._features.pop(next(iter(self._features)))
377
+ try:
378
+ target = cache_dir() / "listened.json"
379
+ target.parent.mkdir(parents=True, exist_ok=True)
380
+ target.write_text(json.dumps(self._features), encoding="utf-8")
381
+ except OSError:
382
+ pass
383
+ return features
324
384
 
325
385
  def _read_all_tags(self) -> None:
326
386
  for track in list(self.deck.tracks):
@@ -342,10 +402,24 @@ class CloudspinApp(App[None]):
342
402
  if item[1] == current:
343
403
  self.look = item[2]
344
404
  elif kind == "mood":
345
- self._moods[item[1]] = item[2]
405
+ path, guess, source = item[1], item[2], item[3]
406
+ if source == "sound":
407
+ self._heard.add(path)
408
+ elif path in self._heard:
409
+ continue # a guess from words never replaces what was heard
410
+ self._moods[path] = guess
411
+ if path == current:
412
+ self.mood = guess
413
+ elif kind == "studied":
346
414
  self._studying.discard(item[1])
347
- if item[1] == current:
348
- self.mood = item[2]
415
+ elif kind == "playlist":
416
+ tracks, problem = item[1], item[2]
417
+ if problem:
418
+ self.flash(problem, 8.0)
419
+ else:
420
+ first = self.deck.add(tracks)
421
+ self.deck.play(first)
422
+ self.flash(f"Added {len(tracks)} songs from the playlist", 5.0)
349
423
  elif kind == "tempo":
350
424
  self._tempos[item[1]] = item[2]
351
425
  if item[1] == current:
@@ -15,12 +15,13 @@ def build_parser() -> argparse.ArgumentParser:
15
15
  description="A terminal music player under a living sky.",
16
16
  epilog="Settings live in ~/.config/cloudspin/config.toml. Press ? inside the app for the keys.",
17
17
  )
18
- parser.add_argument("music", nargs="*", help="files, folders or stream URLs to play (default: your music folder)")
18
+ parser.add_argument("music", nargs="*", help="files, folders, links or playlist links to play (default: your music folder)")
19
19
  parser.add_argument("--city", help="city for the weather, for example --city \"New Delhi\"")
20
20
  parser.add_argument("--sky", metavar="MODE", help="song, weather, or a scene: storm, rain, evening, night, day, dawn, snow, fog, overcast")
21
21
  parser.add_argument("--colors", metavar="THEME", help="sky, cover, amber, orchid, lagoon, moss, rose, mono, or one of your own")
22
22
  parser.add_argument("--units", choices=("metric", "imperial"), help="°C and km/h, or °F and mph")
23
23
  parser.add_argument("--fps", type=int, metavar="N", help="animation frames per second (4-60, default 24)")
24
+ parser.add_argument("--listen", metavar="SONG", help="measure one file or link, print what was heard and which sky it gets, then exit")
24
25
  parser.add_argument("--demo", action="store_true", help="run a silent demo with made-up songs")
25
26
  parser.add_argument("--version", action="version", version=f"cloudspin {__version__}")
26
27
  return parser
@@ -47,13 +48,28 @@ def main(argv: list[str] | None = None) -> int:
47
48
  args = build_parser().parse_args(argv)
48
49
 
49
50
  # Imported late so --help and --version stay instant.
50
- from . import library, mood, mpvget, weather
51
+ from . import library, links, mood, mpvget, weather
51
52
  from .app import CloudspinApp
52
53
  from .config import Config
53
54
  from .player import DEMO_TRACKS, BackendError, Deck, DemoBackend, MpvBackend
54
55
  from .themes import load_themes
55
56
 
56
57
  config = Config.load()
58
+ if args.listen:
59
+ binary = mpvget.find_mpv()
60
+ features = mood.listen(args.listen, binary, mpvget.find_ytdlp())
61
+ if features is None:
62
+ print("Couldn't listen to that. It needs mpv (or ffmpeg for files) and at least 8 seconds of sound.")
63
+ return 1
64
+ chosen = mood.from_features(features)
65
+ print(f"loudness {features.loudness:6.1f} dBFS")
66
+ print(f"tempo {features.tempo:6.0f} bpm (rough)")
67
+ print(f"attacks {features.onsets:6.2f} per second")
68
+ print(f"bass {features.bass:6.2f} share of energy")
69
+ print(f"brightness {features.brightness:6.3f}")
70
+ print(f"intensity {features.intensity:6.2f}")
71
+ print(f"sky {mood.SCENES[chosen.scene].label} ({chosen.why})")
72
+ return 0
57
73
  if args.units:
58
74
  config.units = args.units
59
75
  if args.fps:
@@ -87,7 +103,16 @@ def main(argv: list[str] | None = None) -> int:
87
103
  silent_reason = "Silent demo"
88
104
  else:
89
105
  sources = args.music or [config.music]
90
- tracks = library.scan(sources)
106
+ tracks = []
107
+ for source in sources:
108
+ if links.is_collection(source):
109
+ print("Reading the playlist...")
110
+ try:
111
+ tracks += links.expand(source)
112
+ except links.LinkError as error:
113
+ print(f" {error}")
114
+ else:
115
+ tracks += library.scan([source])
91
116
  try:
92
117
  binary = mpvget.find_mpv()
93
118
  if binary is None and mpvget.WINDOWS:
@@ -35,6 +35,7 @@ class Config:
35
35
  music: str = "~/Music"
36
36
  volume: int = 70
37
37
  fps: int = 24
38
+ listen: bool = True # decode a little of each song to choose its sky
38
39
  themes: dict[str, dict[str, str]] = field(default_factory=dict)
39
40
  moods: dict[str, str] = field(default_factory=dict)
40
41
  path: Path | None = None
@@ -56,6 +57,8 @@ class Config:
56
57
  for key, lo, hi in (("volume", 0, 100), ("fps", 4, 60)):
57
58
  if isinstance(data.get(key), int) and not isinstance(data.get(key), bool):
58
59
  setattr(config, key, max(lo, min(hi, data[key])))
60
+ if isinstance(data.get("listen"), bool):
61
+ config.listen = data["listen"]
59
62
  if config.units not in ("metric", "imperial"):
60
63
  config.units = "metric"
61
64
  themes = data.get("themes")
@@ -91,6 +94,7 @@ class Config:
91
94
  f"music = {quote(self.music)}",
92
95
  f"volume = {self.volume}",
93
96
  f"fps = {self.fps}",
97
+ f"listen = {str(self.listen).lower()}",
94
98
  ]
95
99
  if self.moods:
96
100
  lines += ["", "[moods]"]
@@ -0,0 +1,166 @@
1
+ """Turning a playlist link into songs.
2
+
3
+ YouTube, YouTube Music, SoundCloud and the many other sites yt-dlp knows are
4
+ read with yt-dlp, and their songs play straight from the site.
5
+
6
+ Spotify is different: its audio is locked to its own apps, so nothing outside
7
+ them can play it. Cloudspin reads the *list* of songs from the public playlist
8
+ page and plays each one from the closest match on YouTube.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import html
14
+ import json
15
+ import re
16
+ import urllib.error
17
+ import urllib.request
18
+
19
+ from .player import Track
20
+
21
+ LIMIT = 500
22
+ _SPOTIFY = re.compile(r"open\.spotify\.com/(?:intl-[a-z-]+/)?(?:embed/)?(playlist|album|track)/([A-Za-z0-9]+)")
23
+ _NEXT_DATA = re.compile(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', re.S)
24
+ _BROWSER = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"
25
+
26
+
27
+ class LinkError(Exception):
28
+ """A playlist could not be read; the message is written for the user."""
29
+
30
+
31
+ def is_collection(link: str) -> bool:
32
+ """Does this link point at many songs rather than one?"""
33
+ if "://" not in link:
34
+ return False
35
+ lowered = link.lower()
36
+ if "open.spotify.com/" in lowered:
37
+ return True
38
+ return any(mark in lowered for mark in ("list=", "/playlist", "/album/", "/sets/", "/browse/"))
39
+
40
+
41
+ def expand(link: str) -> list[Track]:
42
+ """Every song behind a playlist link. Raises LinkError with a readable reason."""
43
+ if _SPOTIFY.search(link):
44
+ tracks = _spotify(link)
45
+ else:
46
+ tracks = _with_ytdlp(link)
47
+ if not tracks:
48
+ raise LinkError("That playlist is empty, private, or couldn't be read.")
49
+ return tracks[:LIMIT]
50
+
51
+
52
+ # -- yt-dlp ------------------------------------------------------------------
53
+
54
+
55
+ def _with_ytdlp(link: str) -> list[Track]:
56
+ try:
57
+ import yt_dlp
58
+ except ImportError as error:
59
+ raise LinkError("Reading playlists needs yt-dlp, which should have been installed with Cloudspin.") from error
60
+ options = {"extract_flat": "in_playlist", "quiet": True, "no_warnings": True, "skip_download": True, "playlistend": LIMIT}
61
+ try:
62
+ with yt_dlp.YoutubeDL(options) as reader:
63
+ info = reader.extract_info(link, download=False)
64
+ except Exception as error: # yt-dlp raises many kinds; all mean "couldn't read it"
65
+ reason = str(error).replace("ERROR: ", "").strip().splitlines()[0][:140] if str(error).strip() else "unknown error"
66
+ raise LinkError(f"That playlist couldn't be read ({reason}).") from error
67
+ return tracks_from_ytdlp(info or {}, link)
68
+
69
+
70
+ def tracks_from_ytdlp(info: dict, link: str) -> list[Track]:
71
+ entries = info.get("entries")
72
+ if entries is None:
73
+ entries = [info]
74
+ tracks = []
75
+ for entry in entries:
76
+ if not isinstance(entry, dict):
77
+ continue
78
+ url = entry.get("url") or entry.get("webpage_url") or ""
79
+ if "://" not in url:
80
+ if entry.get("id") and "youtu" in f"{entry.get('ie_key', '')}{link}".lower():
81
+ url = f"https://www.youtube.com/watch?v={entry['id']}"
82
+ else:
83
+ continue
84
+ title = str(entry.get("title") or "").strip()
85
+ if not title or title in ("[Deleted video]", "[Private video]"):
86
+ continue
87
+ artist = str(entry.get("artist") or entry.get("channel") or entry.get("uploader") or "")
88
+ artist = artist.removesuffix(" - Topic").strip()
89
+ tracks.append(Track(path=url, title=title, artist=artist, duration=float(entry.get("duration") or 0.0)))
90
+ return tracks
91
+
92
+
93
+ # -- Spotify -----------------------------------------------------------------
94
+
95
+
96
+ def _find_track_list(node) -> list | None:
97
+ if isinstance(node, dict):
98
+ found = node.get("trackList")
99
+ if isinstance(found, list) and found:
100
+ return found
101
+ if node.get("type") == "track" and node.get("title") and "artists" in node:
102
+ return [node]
103
+ for value in node.values():
104
+ deeper = _find_track_list(value)
105
+ if deeper:
106
+ return deeper
107
+ elif isinstance(node, list):
108
+ for value in node:
109
+ deeper = _find_track_list(value)
110
+ if deeper:
111
+ return deeper
112
+ return None
113
+
114
+
115
+ def tracks_from_spotify_page(page: str) -> list[Track]:
116
+ """Pull the song list out of a Spotify embed page."""
117
+ match = _NEXT_DATA.search(page)
118
+ if not match:
119
+ return []
120
+ try:
121
+ data = json.loads(html.unescape(match.group(1)) if "&quot;" in match.group(1) else match.group(1))
122
+ except ValueError:
123
+ return []
124
+ tracks = []
125
+ for item in _find_track_list(data) or []:
126
+ if not isinstance(item, dict):
127
+ continue
128
+ title = str(item.get("title") or item.get("name") or "").strip()
129
+ artist = item.get("subtitle")
130
+ if not artist and isinstance(item.get("artists"), list):
131
+ artist = ", ".join(str(a.get("name", "")) for a in item["artists"] if isinstance(a, dict))
132
+ artist = str(artist or "").replace(" ", " ").strip()
133
+ if not title:
134
+ continue
135
+ # Spotify itself can't be played from here, so each song is found on YouTube.
136
+ search = f"{artist} - {title}" if artist else title
137
+ tracks.append(
138
+ Track(
139
+ path=f"ytdl://ytsearch1:{search}",
140
+ title=title,
141
+ artist=artist,
142
+ duration=float(item.get("duration") or 0) / 1000.0,
143
+ pinned=True,
144
+ )
145
+ )
146
+ return tracks
147
+
148
+
149
+ def _spotify(link: str) -> list[Track]:
150
+ kind, ident = _SPOTIFY.search(link).groups() # type: ignore[union-attr]
151
+ request = urllib.request.Request(
152
+ f"https://open.spotify.com/embed/{kind}/{ident}", headers={"User-Agent": _BROWSER, "Accept-Language": "en"}
153
+ )
154
+ try:
155
+ with urllib.request.urlopen(request, timeout=20) as response:
156
+ page = response.read().decode("utf-8", "replace")
157
+ except urllib.error.HTTPError as error:
158
+ if error.code == 404:
159
+ raise LinkError("Spotify says that playlist doesn't exist or is private.") from error
160
+ raise LinkError(f"Spotify answered with an error ({error.code}).") from error
161
+ except (urllib.error.URLError, TimeoutError, OSError) as error:
162
+ raise LinkError("Can't reach Spotify. Check your connection.") from error
163
+ tracks = tracks_from_spotify_page(page)
164
+ if not tracks:
165
+ raise LinkError("Spotify's page didn't list any songs. The playlist may be private.")
166
+ return tracks
@@ -1,11 +1,12 @@
1
1
  """Which sky suits a song.
2
2
 
3
- Three sources, in order of trust:
3
+ In order of trust:
4
4
 
5
5
  1. your own ``[moods]`` rules from the config file,
6
- 2. words in the genre tag, title and album ("phonk", "lofi", "rain", ...),
7
- 3. a short listen: 30 seconds decoded with ffmpeg and measured for loudness,
8
- tempo, bass weight and brightness. This last one is a rough guess.
6
+ 2. how the song sounds: 30 seconds are decoded (by mpv, or ffmpeg for files)
7
+ and measured for rhythm, brightness, bass weight and loudness,
8
+ 3. words in the genre tag, title and album ("phonk", "lofi", "rain", ...),
9
+ which also give an instant first guess while the listening happens.
9
10
 
10
11
  If none of them says anything, the sky goes back to showing the real weather.
11
12
  """
@@ -17,8 +18,10 @@ import re
17
18
  import shutil
18
19
  import subprocess
19
20
  import sys
21
+ import tempfile
20
22
  from array import array
21
23
  from dataclasses import dataclass
24
+ from pathlib import Path
22
25
 
23
26
  from .sky import Daylight, Weather
24
27
 
@@ -55,7 +58,7 @@ class Mood:
55
58
 
56
59
  # A trailing * matches any ending ("melanchol*" matches "melancholy").
57
60
  _GENRES: list[tuple[str, tuple[str, ...]]] = [
58
- ("storm", ("phonk", "metal", "metalcore", "deathcore", "hardstyle", "hardcore", "drill", "dubstep",
61
+ ("storm", ("phonk", "hoodtrap", "hood trap", "trap metal", "rage", "bass boosted", "metal", "metalcore", "deathcore", "hardstyle", "hardcore", "drill", "dubstep",
59
62
  "drum and bass", "drum & bass", "dnb", "industrial", "rage", "grindcore", "punk",
60
63
  "breakcore", "gabber", "riddim", "hard rock", "thrash", "screamo")),
61
64
  ("rain", ("blues", "emo", "sad", "melanchol*", "grunge", "slowcore", "doom")),
@@ -75,7 +78,7 @@ _GENRES: list[tuple[str, tuple[str, ...]]] = [
75
78
 
76
79
  # Only words that plainly name a sky are trusted in titles and album names.
77
80
  _TITLES: list[tuple[str, tuple[str, ...]]] = [
78
- ("storm", ("phonk", "thunder*", "storm*", "lightning", "hurricane", "tempest")),
81
+ ("storm", ("phonk", "hoodtrap", "hood trap", "bass boosted", "thunder*", "storm*", "lightning", "hurricane", "tempest")),
79
82
  ("rain", ("rain*", "monsoon", "petrichor", "drizzle", "downpour", "tears", "umbrella")),
80
83
  ("snow", ("snow*", "winter", "christmas", "xmas", "frost*", "blizzard", "icicle*")),
81
84
  ("fog", ("fog*", "mist*", "haze", "hazy", "smoke")),
@@ -96,16 +99,26 @@ _GENRE_RULES = [(scene, word, _pattern(word)) for scene, words in _GENRES for wo
96
99
  _TITLE_RULES = [(scene, word, _pattern(word)) for scene, words in _TITLES for word in words]
97
100
 
98
101
 
99
- def from_words(
102
+ def from_rules(
100
103
  title: str, artist: str = "", album: str = "", genre: str = "", rules: dict[str, str] | None = None
101
104
  ) -> Mood | None:
102
- """Guess a sky from what the song is called and how it is tagged."""
103
- genre_text = genre.lower()
104
- name_text = f"{title} {album}".lower()
105
+ """Your own ``[moods]`` rules. These always win, even over what the song sounds like."""
105
106
  everything = f"{genre} {title} {artist} {album}".lower()
106
107
  for word, scene in (rules or {}).items():
107
108
  if scene in SCENES and word.strip() and _pattern(word.strip().lower()).search(everything):
108
109
  return Mood(scene, f"your rule for “{word.strip()}”")
110
+ return None
111
+
112
+
113
+ def from_words(
114
+ title: str, artist: str = "", album: str = "", genre: str = "", rules: dict[str, str] | None = None
115
+ ) -> Mood | None:
116
+ """Guess a sky from what the song is called and how it is tagged."""
117
+ genre_text = genre.lower()
118
+ name_text = f"{title} {album}".lower()
119
+ ruled = from_rules(title, artist, album, genre, rules)
120
+ if ruled is not None:
121
+ return ruled
109
122
  if genre_text.strip():
110
123
  # Rules run in order, so "dream pop" is caught as fog before "pop" can claim it.
111
124
  for scene, word, pattern in _GENRE_RULES:
@@ -131,10 +144,13 @@ class Features:
131
144
 
132
145
  @property
133
146
  def intensity(self) -> float:
147
+ """How hard the song hits, 0..1. Rhythm counts for more than volume,
148
+ because nearly everything is mastered loud."""
134
149
  loud = min(1.0, max(0.0, (self.loudness + 28.0) / 18.0))
135
- busy = min(1.0, self.onsets / 5.0)
150
+ busy = min(1.0, self.onsets / 6.0)
151
+ bright = min(1.0, self.brightness / 0.10)
136
152
  fast = min(1.0, max(0.0, (self.tempo - 70.0) / 90.0))
137
- return 0.5 * loud + 0.3 * busy + 0.2 * fast
153
+ return 0.25 * loud + 0.35 * busy + 0.2 * bright + 0.2 * fast
138
154
 
139
155
 
140
156
  def measure(samples, rate: int = 8000) -> Features | None:
@@ -202,41 +218,82 @@ def measure(samples, rate: int = 8000) -> Features | None:
202
218
  )
203
219
 
204
220
 
205
- def listen(path: str, ffmpeg: str | None = None) -> Features | None:
206
- """Decode half a minute from the middle of a file and measure it."""
207
- if "://" in path:
208
- return None
209
- ffmpeg = ffmpeg or shutil.which("ffmpeg")
210
- if not ffmpeg:
211
- return None
212
- raw = b""
213
- for start in (40, 0):
221
+ SAMPLE_RATE = 8000
222
+ _ENOUGH = SAMPLE_RATE * 2 * 8 # eight seconds is the least worth measuring
223
+
224
+
225
+ def _decode_with_mpv(mpv: str, path: str, ytdlp: str | None, start: int, timeout: float) -> bytes:
226
+ """Have mpv write 30 seconds of the song as raw samples. Works for links too."""
227
+ with tempfile.TemporaryDirectory(prefix="cloudspin-listen-") as folder:
228
+ clip = Path(folder) / "clip.raw"
229
+ command = [
230
+ mpv, "--no-config", "--no-terminal", "--no-video", "--ytdl-format=bestaudio/best",
231
+ f"--start={start}", "--length=30", "--audio-channels=mono", f"--audio-samplerate={SAMPLE_RATE}",
232
+ "--oac=pcm_s16le", "--of=s16le", f"--o={clip}",
233
+ ] # fmt: skip
234
+ if ytdlp:
235
+ command.append(f"--script-opts=ytdl_hook-ytdl_path={ytdlp}")
214
236
  try:
215
- done = subprocess.run(
216
- [ffmpeg, "-v", "error", "-nostdin", "-ss", str(start), "-t", "30", "-i", path,
217
- "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"],
218
- stdin=subprocess.DEVNULL, capture_output=True, timeout=25,
237
+ subprocess.run(
238
+ [*command, "--", path],
239
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout,
219
240
  ) # fmt: skip
241
+ return clip.read_bytes()
220
242
  except (OSError, subprocess.SubprocessError):
221
- return None
222
- raw = done.stdout
223
- if len(raw) >= 8000 * 2 * 8:
243
+ return b""
244
+
245
+
246
+ def _decode_with_ffmpeg(ffmpeg: str, path: str, start: int) -> bytes:
247
+ try:
248
+ done = subprocess.run(
249
+ [ffmpeg, "-v", "error", "-nostdin", "-ss", str(start), "-t", "30", "-i", path,
250
+ "-vn", "-ac", "1", "-ar", str(SAMPLE_RATE), "-f", "s16le", "-"],
251
+ stdin=subprocess.DEVNULL, capture_output=True, timeout=25,
252
+ ) # fmt: skip
253
+ except (OSError, subprocess.SubprocessError):
254
+ return b""
255
+ return done.stdout
256
+
257
+
258
+ def listen(
259
+ path: str,
260
+ mpv: str | None = None,
261
+ ytdlp: str | None = None,
262
+ ffmpeg: str | None | bool = None,
263
+ ) -> Features | None:
264
+ """Decode half a minute from inside a song and measure it.
265
+
266
+ mpv does the decoding when it is given, which covers links as well as
267
+ files. ffmpeg is the fallback for files; pass ``ffmpeg=False`` to rule it out.
268
+ """
269
+ stream = "://" in path
270
+ if ffmpeg is None:
271
+ ffmpeg = shutil.which("ffmpeg")
272
+ raw = b""
273
+ for start in (40, 0):
274
+ if mpv:
275
+ raw = _decode_with_mpv(mpv, path, ytdlp, start, 75 if stream else 30)
276
+ if len(raw) < _ENOUGH and ffmpeg and not stream:
277
+ raw = _decode_with_ffmpeg(str(ffmpeg), path, start)
278
+ if len(raw) >= _ENOUGH:
224
279
  break
280
+ if len(raw) < _ENOUGH:
281
+ return None
225
282
  samples = array("h")
226
283
  samples.frombytes(raw[: len(raw) // 2 * 2])
227
284
  if sys.byteorder == "big":
228
285
  samples.byteswap()
229
- return measure(samples, 8000)
286
+ return measure(samples, SAMPLE_RATE)
230
287
 
231
288
 
232
289
  def from_features(features: Features) -> Mood:
233
- """Turn measurements into a sky. Heavy and loud storms; quiet and slow settles."""
290
+ """Turn measurements into a sky. Busy, bright and bass-heavy storms; slow and soft settles."""
234
291
  intensity = features.intensity
235
- if intensity >= 0.68:
236
- if features.bass >= 0.42:
237
- return Mood("storm", "loud, fast and heavy on bass")
292
+ if intensity >= 0.62:
293
+ if features.bass >= 0.40:
294
+ return Mood("storm", "hard-hitting and heavy on bass")
238
295
  return Mood("day", "bright and energetic")
239
- if intensity >= 0.48:
296
+ if intensity >= 0.45:
240
297
  if features.brightness < 0.06 and features.bass >= 0.35:
241
298
  return Mood("rain", "dark and steady")
242
299
  return Mood("day", "upbeat")
@@ -26,6 +26,7 @@ class Track:
26
26
  album: str = ""
27
27
  duration: float = 0.0
28
28
  genre: str = ""
29
+ pinned: bool = False # the title and artist are already right; don't take them from the stream
29
30
 
30
31
  @property
31
32
  def is_stream(self) -> bool:
@@ -182,6 +183,8 @@ class MpvBackend:
182
183
  path = rf"\\.\pipe\cloudspin-{os.getpid()}-{Path(self._dir).name}"
183
184
  else:
184
185
  path = str(Path(self._dir) / "mpv.sock")
186
+ self.binary = binary
187
+ self.ytdlp = ytdlp
185
188
  options = list(extra_args)
186
189
  if ytdlp:
187
190
  # Point mpv at the yt-dlp that was installed with Cloudspin.
@@ -78,7 +78,7 @@ def city_prompt(current: str = "") -> Prompt:
78
78
  def link_prompt() -> Prompt:
79
79
  return Prompt(
80
80
  "Play from a link",
81
- "Paste a link to a song, a radio stream or a video. A file or folder path works too.",
81
+ "Paste a song, a radio stream, or a YouTube, YouTube Music or Spotify playlist. A file or folder path works too.",
82
82
  placeholder="https://",
83
83
  confirm="play",
84
84
  )
@@ -93,7 +93,7 @@ _KEYS = (
93
93
  ("r", "Repeat: off, all, one"),
94
94
  ("w", "Sky: follow the song, follow the weather, or pin a scene"),
95
95
  ("t", "Colours: follow the sky, follow the cover art, or pick a theme"),
96
- ("o", "Open a link, file or folder and play it"),
96
+ ("o", "Open a link, a playlist, a file or a folder and play it"),
97
97
  ("c", "Choose the city for the weather"),
98
98
  ("u", "Switch between °C and °F"),
99
99
  ("q", "Quit"),
@@ -0,0 +1,13 @@
1
+ import pytest
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ ROOT = Path(__file__).resolve().parent.parent
6
+ sys.path.insert(0, str(ROOT / "src"))
7
+ sys.path.insert(0, str(ROOT / "tests"))
8
+
9
+
10
+ @pytest.fixture(autouse=True)
11
+ def _private_cache(tmp_path, monkeypatch):
12
+ """Keep tests from reading or writing the real cache folder."""
13
+ monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache"))
@@ -0,0 +1,75 @@
1
+ """Playlist links: which links count as playlists, and turning what yt-dlp and
2
+ Spotify's page say into songs."""
3
+
4
+ import json
5
+
6
+ from cloudspin import links
7
+ from cloudspin.links import is_collection, tracks_from_spotify_page, tracks_from_ytdlp
8
+
9
+
10
+ def test_which_links_are_playlists():
11
+ assert is_collection("https://www.youtube.com/playlist?list=PL123")
12
+ assert is_collection("https://music.youtube.com/playlist?list=OLAK5uy_abc")
13
+ assert is_collection("https://www.youtube.com/watch?v=abc&list=PL123")
14
+ assert is_collection("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M?si=x")
15
+ assert is_collection("https://open.spotify.com/intl-de/album/4aawyAB9vmqN3uQ7FjRGTy")
16
+ assert is_collection("https://soundcloud.com/someone/sets/a-set")
17
+ assert not is_collection("https://www.youtube.com/watch?v=abc")
18
+ assert not is_collection("https://example.com/stream.mp3")
19
+ assert not is_collection("/home/me/Music/playlist")
20
+
21
+
22
+ def test_youtube_playlists_become_songs():
23
+ info = {
24
+ "_type": "playlist",
25
+ "entries": [
26
+ {"id": "aaa", "url": "https://www.youtube.com/watch?v=aaa", "title": "First", "channel": "Band - Topic", "duration": 201},
27
+ {"id": "bbb", "ie_key": "Youtube", "title": "Second", "uploader": "Someone", "duration": None},
28
+ {"id": "ccc", "url": "https://www.youtube.com/watch?v=ccc", "title": "[Deleted video]"},
29
+ None,
30
+ ],
31
+ }
32
+ tracks = tracks_from_ytdlp(info, "https://www.youtube.com/playlist?list=PL1")
33
+ assert [(t.path, t.title, t.artist, t.duration) for t in tracks] == [
34
+ ("https://www.youtube.com/watch?v=aaa", "First", "Band", 201.0),
35
+ ("https://www.youtube.com/watch?v=bbb", "Second", "Someone", 0.0),
36
+ ]
37
+ assert not tracks[0].pinned # the site's own title may still improve on this one
38
+
39
+
40
+ def test_a_single_video_is_one_song():
41
+ info = {"id": "x", "webpage_url": "https://www.youtube.com/watch?v=x", "title": "Solo", "uploader": "Me", "duration": 61}
42
+ assert [t.title for t in tracks_from_ytdlp(info, "https://www.youtube.com/watch?v=x")] == ["Solo"]
43
+
44
+
45
+ def spotify_page(track_list):
46
+ data = {"props": {"pageProps": {"state": {"data": {"entity": {"type": "playlist", "trackList": track_list}}}}}}
47
+ return f'<html><script id="__NEXT_DATA__" type="application/json">{json.dumps(data)}</script></html>'
48
+
49
+
50
+ def test_spotify_songs_are_matched_on_youtube():
51
+ page = spotify_page(
52
+ [
53
+ {"uri": "spotify:track:1", "title": "Blue Hour", "subtitle": "Nine Volt Garden, Kestrel", "duration": 229000},
54
+ {"uri": "spotify:track:2", "title": "", "subtitle": "Nobody"},
55
+ ]
56
+ )
57
+ tracks = tracks_from_spotify_page(page)
58
+ assert len(tracks) == 1
59
+ song = tracks[0]
60
+ assert song.path == "ytdl://ytsearch1:Nine Volt Garden, Kestrel - Blue Hour"
61
+ assert (song.title, song.artist, song.duration) == ("Blue Hour", "Nine Volt Garden, Kestrel", 229.0)
62
+ assert song.pinned and song.is_stream # keep Spotify's name, not the video's
63
+
64
+
65
+ def test_a_spotify_page_without_songs_gives_nothing():
66
+ assert tracks_from_spotify_page("<html>nothing here</html>") == []
67
+ assert tracks_from_spotify_page('<script id="__NEXT_DATA__">{broken</script>') == []
68
+
69
+
70
+ def test_expand_explains_failures(monkeypatch):
71
+ import pytest
72
+
73
+ monkeypatch.setattr(links, "_with_ytdlp", lambda link: [])
74
+ with pytest.raises(links.LinkError, match="empty, private"):
75
+ links.expand("https://www.youtube.com/playlist?list=PLnothing")
@@ -0,0 +1,40 @@
1
+ """Checks against the real internet. Only run when CLOUDSPIN_TEST_NETWORK=1,
2
+ and allowed to fail in CI, because other people's servers decide the outcome."""
3
+
4
+ import os
5
+
6
+ import pytest
7
+
8
+ from cloudspin import links, mood, mpvget
9
+
10
+ pytestmark = pytest.mark.skipif(not os.environ.get("CLOUDSPIN_TEST_NETWORK"), reason="network tests are opt-in")
11
+
12
+
13
+ def test_spotify_playlist_lists_its_songs():
14
+ tracks = links.expand("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M")
15
+ print("spotify:", len(tracks), [(t.artist, t.title, t.duration) for t in tracks[:3]])
16
+ assert len(tracks) >= 10 and all(t.title and t.pinned for t in tracks)
17
+ assert sum(1 for t in tracks if t.artist) >= len(tracks) // 2
18
+
19
+
20
+ def test_ytdlp_reads_a_list_of_results():
21
+ tracks = links._with_ytdlp("ytsearch3:creative commons lofi music")
22
+ print("youtube:", [(t.path, t.title, t.artist, t.duration) for t in tracks])
23
+ assert len(tracks) == 3 and all("://" in t.path for t in tracks)
24
+
25
+
26
+ def test_listening_to_a_link():
27
+ binary = mpvget.find_mpv()
28
+ if binary is None:
29
+ pytest.skip("mpv is not installed")
30
+ import subprocess
31
+
32
+ probe = subprocess.run(
33
+ [binary, "--no-config", "--no-video", "--ao=null", "--length=2", "--msg-level=all=warn,ytdl_hook=debug",
34
+ f"--script-opts=ytdl_hook-ytdl_path={mpvget.find_ytdlp()}", "ytdl://ytsearch1:creative commons lofi music"],
35
+ capture_output=True, text=True, timeout=120,
36
+ ) # fmt: skip
37
+ print("mpv says:", probe.returncode, (probe.stdout + probe.stderr)[-1500:])
38
+ features = mood.listen("ytdl://ytsearch1:creative commons lofi music", binary, mpvget.find_ytdlp())
39
+ print("heard:", features, features and mood.from_features(features))
40
+ assert features is not None
@@ -72,6 +72,39 @@ def test_real_mpv_reports_a_file_it_cannot_play(mpv_binary, tmp_path):
72
72
  deck.close()
73
73
 
74
74
 
75
+ def test_mpv_can_do_the_listening_without_ffmpeg(mpv_binary, tmp_path):
76
+ from cloudspin import mood
77
+
78
+ heavy = synth.write_wav(tmp_path / "heavy.wav", synth.heavy(14))
79
+ soft = synth.write_wav(tmp_path / "soft.wav", synth.gentle(14))
80
+ heard = mood.listen(str(heavy), mpv_binary, ffmpeg=False)
81
+ assert heard is not None and heard.bass > 0.6
82
+ assert mood.from_features(heard).scene == "storm"
83
+ quiet = mood.listen(str(soft), mpv_binary, ffmpeg=False)
84
+ assert quiet is not None and mood.from_features(quiet).scene in ("night", "evening")
85
+ assert mood.listen(str(tmp_path / "missing.wav"), mpv_binary, ffmpeg=False) is None
86
+
87
+
88
+ def test_mpv_listens_to_a_link(mpv_binary, tmp_path):
89
+ """A song served over http, the way a link is, with no outside server involved."""
90
+ import functools
91
+ import http.server
92
+ import threading
93
+
94
+ from cloudspin import mood
95
+
96
+ synth.write_wav(tmp_path / "heavy.wav", synth.heavy(14))
97
+ handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(tmp_path))
98
+ handler.log_message = lambda *args: None
99
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
100
+ threading.Thread(target=server.serve_forever, daemon=True).start()
101
+ try:
102
+ heard = mood.listen(f"http://127.0.0.1:{server.server_address[1]}/heavy.wav", mpv_binary, mpvget.find_ytdlp())
103
+ finally:
104
+ server.shutdown()
105
+ assert heard is not None and mood.from_features(heard).scene == "storm"
106
+
107
+
75
108
  def test_ytdlp_ships_with_cloudspin():
76
109
  assert mpvget.find_ytdlp() is not None
77
110
 
@@ -1,6 +0,0 @@
1
- import sys
2
- from pathlib import Path
3
-
4
- ROOT = Path(__file__).resolve().parent.parent
5
- sys.path.insert(0, str(ROOT / "src"))
6
- sys.path.insert(0, str(ROOT / "tests"))
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