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.
- {cloudspin-0.1.1 → cloudspin-0.2.0}/.github/workflows/ci.yml +15 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/PKG-INFO +44 -20
- {cloudspin-0.1.1 → cloudspin-0.2.0}/README.md +43 -19
- {cloudspin-0.1.1 → cloudspin-0.2.0}/packaging/aur/PKGBUILD +1 -1
- {cloudspin-0.1.1 → cloudspin-0.2.0}/packaging/npm/package.json +1 -1
- {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/__init__.py +1 -1
- {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/app.py +89 -15
- {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/cli.py +28 -3
- {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/config.py +4 -0
- cloudspin-0.2.0/src/cloudspin/links.py +166 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/mood.py +91 -34
- {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/player.py +3 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/screens.py +2 -2
- cloudspin-0.2.0/tests/conftest.py +13 -0
- cloudspin-0.2.0/tests/test_links.py +75 -0
- cloudspin-0.2.0/tests/test_network.py +40 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/tests/test_real_mpv.py +33 -0
- cloudspin-0.1.1/tests/conftest.py +0 -6
- {cloudspin-0.1.1 → cloudspin-0.2.0}/.github/workflows/release.yml +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/.gitignore +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/LICENSE +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/docs/evening.png +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/docs/night.png +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/docs/storm.png +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/packaging/npm/README.md +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/packaging/npm/bin/cloudspin.js +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/pyproject.toml +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/scripts/screenshot.py +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/__main__.py +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/canvas.py +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/color.py +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/library.py +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/mpvget.py +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/sky.py +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/songlook.py +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/spectrum.py +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/themes.py +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/vinyl.py +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/weather.py +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/src/cloudspin/widgets.py +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/tests/fake_mpv.py +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/tests/fixtures/forecast.json +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/tests/synth.py +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/tests/test_app.py +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/tests/test_mood.py +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/tests/test_player.py +0 -0
- {cloudspin-0.1.1 → cloudspin-0.2.0}/tests/test_sky.py +0 -0
- {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.
|
|
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
|
-
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
evening
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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,
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
-
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
evening
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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,
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
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.
|
|
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')
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
316
|
-
if
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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
|
|
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 =
|
|
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 """ 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
|
-
|
|
3
|
+
In order of trust:
|
|
4
4
|
|
|
5
5
|
1. your own ``[moods]`` rules from the config file,
|
|
6
|
-
2.
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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
|
-
"""
|
|
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 /
|
|
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.
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
[
|
|
217
|
-
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
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,
|
|
286
|
+
return measure(samples, SAMPLE_RATE)
|
|
230
287
|
|
|
231
288
|
|
|
232
289
|
def from_features(features: Features) -> Mood:
|
|
233
|
-
"""Turn measurements into a sky.
|
|
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.
|
|
236
|
-
if features.bass >= 0.
|
|
237
|
-
return Mood("storm", "
|
|
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.
|
|
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
|
|
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
|
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|