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