tidal-cli 1.0.2__tar.gz → 1.0.3__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.
- {tidal_cli-1.0.2 → tidal_cli-1.0.3}/PKG-INFO +27 -12
- {tidal_cli-1.0.2 → tidal_cli-1.0.3}/README.md +26 -11
- {tidal_cli-1.0.2 → tidal_cli-1.0.3}/pyproject.toml +1 -1
- {tidal_cli-1.0.2 → tidal_cli-1.0.3}/setup.py +1 -1
- tidal_cli-1.0.3/ticli/agent.py +422 -0
- tidal_cli-1.0.3/ticli/agent_docs.py +177 -0
- tidal_cli-1.0.3/ticli/cli.py +161 -0
- tidal_cli-1.0.3/ticli/player.py +9011 -0
- tidal_cli-1.0.3/ticli/tests/conftest.py +41 -0
- tidal_cli-1.0.3/ticli/tests/fakes.py +46 -0
- tidal_cli-1.0.3/ticli/tests/test_add_to_playlist.py +462 -0
- tidal_cli-1.0.3/ticli/tests/test_agent_surface.py +465 -0
- tidal_cli-1.0.3/ticli/tests/test_artist.py +501 -0
- tidal_cli-1.0.3/ticli/tests/test_artwork.py +652 -0
- tidal_cli-1.0.3/ticli/tests/test_buffering.py +433 -0
- tidal_cli-1.0.3/ticli/tests/test_bulk_downloads.py +878 -0
- tidal_cli-1.0.3/ticli/tests/test_cache.py +2399 -0
- tidal_cli-1.0.3/ticli/tests/test_config.py +1054 -0
- tidal_cli-1.0.3/ticli/tests/test_display.py +1649 -0
- tidal_cli-1.0.3/ticli/tests/test_download_queue.py +717 -0
- tidal_cli-1.0.3/ticli/tests/test_downloads.py +2573 -0
- {tidal_cli-1.0.2 → tidal_cli-1.0.3}/ticli/tests/test_full_e2e.py +2 -2
- tidal_cli-1.0.3/ticli/tests/test_input_latency.py +295 -0
- tidal_cli-1.0.3/ticli/tests/test_login.py +830 -0
- tidal_cli-1.0.3/ticli/tests/test_media_keys.py +247 -0
- tidal_cli-1.0.3/ticli/tests/test_playback_exclusion.py +460 -0
- tidal_cli-1.0.3/ticli/tests/test_player_controls.py +764 -0
- tidal_cli-1.0.3/ticli/tests/test_radio.py +412 -0
- tidal_cli-1.0.3/ticli/tests/test_resume.py +1051 -0
- tidal_cli-1.0.3/ticli/tests/test_search.py +1172 -0
- tidal_cli-1.0.3/ticli/tests/test_seek.py +505 -0
- tidal_cli-1.0.3/ticli/tests/test_single_instance.py +266 -0
- tidal_cli-1.0.3/ticli/tests/vt.py +148 -0
- tidal_cli-1.0.3/ticli/utils/artwork.py +790 -0
- tidal_cli-1.0.3/ticli/utils/cache.py +1014 -0
- tidal_cli-1.0.3/ticli/utils/config.py +321 -0
- {tidal_cli-1.0.2 → tidal_cli-1.0.3}/ticli/utils/credential_store.py +43 -4
- tidal_cli-1.0.3/ticli/utils/downloads.py +557 -0
- tidal_cli-1.0.3/ticli/utils/tags.py +413 -0
- tidal_cli-1.0.3/ticli/utils/throttle.py +159 -0
- {tidal_cli-1.0.2 → tidal_cli-1.0.3}/tidal_cli.egg-info/PKG-INFO +27 -12
- tidal_cli-1.0.3/tidal_cli.egg-info/SOURCES.txt +48 -0
- tidal_cli-1.0.2/ticli/cli.py +0 -19
- tidal_cli-1.0.2/ticli/player.py +0 -1452
- tidal_cli-1.0.2/tidal_cli.egg-info/SOURCES.txt +0 -16
- {tidal_cli-1.0.2 → tidal_cli-1.0.3}/setup.cfg +0 -0
- {tidal_cli-1.0.2 → tidal_cli-1.0.3}/ticli/__init__.py +0 -0
- {tidal_cli-1.0.2 → tidal_cli-1.0.3}/ticli/tests/__init__.py +0 -0
- {tidal_cli-1.0.2 → tidal_cli-1.0.3}/ticli/utils/__init__.py +0 -0
- {tidal_cli-1.0.2 → tidal_cli-1.0.3}/tidal_cli.egg-info/dependency_links.txt +0 -0
- {tidal_cli-1.0.2 → tidal_cli-1.0.3}/tidal_cli.egg-info/entry_points.txt +0 -0
- {tidal_cli-1.0.2 → tidal_cli-1.0.3}/tidal_cli.egg-info/requires.txt +0 -0
- {tidal_cli-1.0.2 → tidal_cli-1.0.3}/tidal_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tidal-cli
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.3
|
|
4
4
|
Summary: Unofficial terminal music player for TIDAL — search, browse, queue, and stream lossless audio from your terminal. Not affiliated with TIDAL.
|
|
5
5
|
Author: odonald
|
|
6
6
|
License-Expression: MIT
|
|
@@ -44,7 +44,7 @@ Works on **macOS** and **Linux**.
|
|
|
44
44
|
│ ▶ ♥ Arlo Parks - Sophie │
|
|
45
45
|
│ Super Sad Generation │
|
|
46
46
|
│ 1:47 ━━━━━━━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:28 │
|
|
47
|
-
│ Queue: 3/12
|
|
47
|
+
│ Queue: 3/12 16/44.1 FLAC │
|
|
48
48
|
│ Next: Cola • Arlo Parks │
|
|
49
49
|
│ │
|
|
50
50
|
│ [space] play/pause [n/→] next [←] prev │
|
|
@@ -65,19 +65,20 @@ Works on **macOS** and **Linux**.
|
|
|
65
65
|
- **Mini mode** — Condensed single-line display
|
|
66
66
|
- **Session restore** — Picks up where you left off
|
|
67
67
|
- **Lossless & Hi-Res** — Stream up to 24-bit/192kHz FLAC
|
|
68
|
+
- **Downloads** — Keep tracks in your own music folder, tagged
|
|
68
69
|
- **Secure auth** — OAuth tokens stored in your OS keychain
|
|
69
70
|
|
|
70
71
|
## Install
|
|
71
72
|
|
|
72
|
-
Requires Python 3.10+ and [
|
|
73
|
+
Requires Python 3.10+ and [mpv](https://mpv.io).
|
|
73
74
|
|
|
74
75
|
```bash
|
|
75
76
|
# macOS
|
|
76
|
-
brew install
|
|
77
|
+
brew install mpv
|
|
77
78
|
pip install tidal-cli
|
|
78
79
|
|
|
79
80
|
# Ubuntu / Debian
|
|
80
|
-
sudo apt install
|
|
81
|
+
sudo apt install mpv python3-pip
|
|
81
82
|
pip install tidal-cli
|
|
82
83
|
```
|
|
83
84
|
|
|
@@ -98,12 +99,16 @@ On first run you'll get a URL to authorize with your TIDAL account. After that,
|
|
|
98
99
|
### Quality
|
|
99
100
|
|
|
100
101
|
```bash
|
|
101
|
-
ticli --quality
|
|
102
|
-
ticli --quality
|
|
103
|
-
ticli --quality
|
|
104
|
-
ticli --quality LOW
|
|
102
|
+
ticli --quality MAX # FLAC, up to 24-bit/192 kHz
|
|
103
|
+
ticli --quality HIGH # FLAC, 16-bit/44.1 kHz — the default
|
|
104
|
+
ticli --quality MEDIUM # AAC 320 kbps
|
|
105
|
+
ticli --quality LOW # AAC 96 kbps
|
|
105
106
|
```
|
|
106
107
|
|
|
108
|
+
The names follow TIDAL's own app. FLAC (`HIGH` and `MAX`) requires the PKCE
|
|
109
|
+
sign-in: `ticli --login-flow pkce`, or press `u` on the settings page later.
|
|
110
|
+
The old spellings `LOSSLESS` and `HIRES` still work as aliases.
|
|
111
|
+
|
|
107
112
|
### Keybindings
|
|
108
113
|
|
|
109
114
|
#### Player
|
|
@@ -142,11 +147,11 @@ ticli --quality LOW # 320kbps
|
|
|
142
147
|
|
|
143
148
|
## How it works
|
|
144
149
|
|
|
145
|
-
Ticli uses [tidalapi](https://github.com/tamland/python-tidal) to authenticate and fetch audio stream URLs. Audio is played through [
|
|
150
|
+
Ticli uses [tidalapi](https://github.com/tamland/python-tidal) to authenticate and fetch audio stream URLs. Audio is played through [mpv](https://mpv.io). The TUI is built with [Rich](https://github.com/Textualize/rich).
|
|
146
151
|
|
|
147
152
|
```
|
|
148
153
|
┌─────────┐ OAuth ┌───────────┐ stream URL ┌───────────┐
|
|
149
|
-
│ Ticli │ ──────────────► │ TIDAL │ ──────────────► │
|
|
154
|
+
│ Ticli │ ──────────────► │ TIDAL │ ──────────────► │ mpv │
|
|
150
155
|
│ (TUI) │ ◄────────────── │ API │ │ │
|
|
151
156
|
└─────────┘ metadata └───────────┘ └───────────┘
|
|
152
157
|
```
|
|
@@ -156,7 +161,17 @@ Ticli uses [tidalapi](https://github.com/tamland/python-tidal) to authenticate a
|
|
|
156
161
|
- macOS or Linux
|
|
157
162
|
- Python 3.10+
|
|
158
163
|
- TIDAL Premium subscription
|
|
159
|
-
-
|
|
164
|
+
- mpv
|
|
165
|
+
|
|
166
|
+
## Credits
|
|
167
|
+
|
|
168
|
+
Created and maintained by [odonald](https://github.com/odonald).
|
|
169
|
+
|
|
170
|
+
Contributors:
|
|
171
|
+
|
|
172
|
+
- [Garrett Simko](https://github.com/Starwaves1) — lossless/hi-res playback (PKCE login, segmented
|
|
173
|
+
DASH streams), album artwork, metadata and audio caching, scrubbing, scoped search, the artist
|
|
174
|
+
page, and macOS media keys.
|
|
160
175
|
|
|
161
176
|
## Support
|
|
162
177
|
|
|
@@ -12,7 +12,7 @@ Works on **macOS** and **Linux**.
|
|
|
12
12
|
│ ▶ ♥ Arlo Parks - Sophie │
|
|
13
13
|
│ Super Sad Generation │
|
|
14
14
|
│ 1:47 ━━━━━━━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:28 │
|
|
15
|
-
│ Queue: 3/12
|
|
15
|
+
│ Queue: 3/12 16/44.1 FLAC │
|
|
16
16
|
│ Next: Cola • Arlo Parks │
|
|
17
17
|
│ │
|
|
18
18
|
│ [space] play/pause [n/→] next [←] prev │
|
|
@@ -33,19 +33,20 @@ Works on **macOS** and **Linux**.
|
|
|
33
33
|
- **Mini mode** — Condensed single-line display
|
|
34
34
|
- **Session restore** — Picks up where you left off
|
|
35
35
|
- **Lossless & Hi-Res** — Stream up to 24-bit/192kHz FLAC
|
|
36
|
+
- **Downloads** — Keep tracks in your own music folder, tagged
|
|
36
37
|
- **Secure auth** — OAuth tokens stored in your OS keychain
|
|
37
38
|
|
|
38
39
|
## Install
|
|
39
40
|
|
|
40
|
-
Requires Python 3.10+ and [
|
|
41
|
+
Requires Python 3.10+ and [mpv](https://mpv.io).
|
|
41
42
|
|
|
42
43
|
```bash
|
|
43
44
|
# macOS
|
|
44
|
-
brew install
|
|
45
|
+
brew install mpv
|
|
45
46
|
pip install tidal-cli
|
|
46
47
|
|
|
47
48
|
# Ubuntu / Debian
|
|
48
|
-
sudo apt install
|
|
49
|
+
sudo apt install mpv python3-pip
|
|
49
50
|
pip install tidal-cli
|
|
50
51
|
```
|
|
51
52
|
|
|
@@ -66,12 +67,16 @@ On first run you'll get a URL to authorize with your TIDAL account. After that,
|
|
|
66
67
|
### Quality
|
|
67
68
|
|
|
68
69
|
```bash
|
|
69
|
-
ticli --quality
|
|
70
|
-
ticli --quality
|
|
71
|
-
ticli --quality
|
|
72
|
-
ticli --quality LOW
|
|
70
|
+
ticli --quality MAX # FLAC, up to 24-bit/192 kHz
|
|
71
|
+
ticli --quality HIGH # FLAC, 16-bit/44.1 kHz — the default
|
|
72
|
+
ticli --quality MEDIUM # AAC 320 kbps
|
|
73
|
+
ticli --quality LOW # AAC 96 kbps
|
|
73
74
|
```
|
|
74
75
|
|
|
76
|
+
The names follow TIDAL's own app. FLAC (`HIGH` and `MAX`) requires the PKCE
|
|
77
|
+
sign-in: `ticli --login-flow pkce`, or press `u` on the settings page later.
|
|
78
|
+
The old spellings `LOSSLESS` and `HIRES` still work as aliases.
|
|
79
|
+
|
|
75
80
|
### Keybindings
|
|
76
81
|
|
|
77
82
|
#### Player
|
|
@@ -110,11 +115,11 @@ ticli --quality LOW # 320kbps
|
|
|
110
115
|
|
|
111
116
|
## How it works
|
|
112
117
|
|
|
113
|
-
Ticli uses [tidalapi](https://github.com/tamland/python-tidal) to authenticate and fetch audio stream URLs. Audio is played through [
|
|
118
|
+
Ticli uses [tidalapi](https://github.com/tamland/python-tidal) to authenticate and fetch audio stream URLs. Audio is played through [mpv](https://mpv.io). The TUI is built with [Rich](https://github.com/Textualize/rich).
|
|
114
119
|
|
|
115
120
|
```
|
|
116
121
|
┌─────────┐ OAuth ┌───────────┐ stream URL ┌───────────┐
|
|
117
|
-
│ Ticli │ ──────────────► │ TIDAL │ ──────────────► │
|
|
122
|
+
│ Ticli │ ──────────────► │ TIDAL │ ──────────────► │ mpv │
|
|
118
123
|
│ (TUI) │ ◄────────────── │ API │ │ │
|
|
119
124
|
└─────────┘ metadata └───────────┘ └───────────┘
|
|
120
125
|
```
|
|
@@ -124,7 +129,17 @@ Ticli uses [tidalapi](https://github.com/tamland/python-tidal) to authenticate a
|
|
|
124
129
|
- macOS or Linux
|
|
125
130
|
- Python 3.10+
|
|
126
131
|
- TIDAL Premium subscription
|
|
127
|
-
-
|
|
132
|
+
- mpv
|
|
133
|
+
|
|
134
|
+
## Credits
|
|
135
|
+
|
|
136
|
+
Created and maintained by [odonald](https://github.com/odonald).
|
|
137
|
+
|
|
138
|
+
Contributors:
|
|
139
|
+
|
|
140
|
+
- [Garrett Simko](https://github.com/Starwaves1) — lossless/hi-res playback (PKCE login, segmented
|
|
141
|
+
DASH streams), album artwork, metadata and audio caching, scrubbing, scoped search, the artist
|
|
142
|
+
page, and macOS media keys.
|
|
128
143
|
|
|
129
144
|
## Support
|
|
130
145
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tidal-cli"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.3"
|
|
8
8
|
description = "Unofficial terminal music player for TIDAL — search, browse, queue, and stream lossless audio from your terminal. Not affiliated with TIDAL."
|
|
9
9
|
license = "MIT"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""The agent surface: ticli for callers that are programs.
|
|
2
|
+
|
|
3
|
+
`ticli agent <verb>` is a headless, JSON-speaking sibling of the TUI, built
|
|
4
|
+
after two sessions proved agents will otherwise interact with ticli the worst
|
|
5
|
+
way — importing internals, writing throwaway scripts, and firing requests at
|
|
6
|
+
a rate that has already gotten the owner's IP blocked by TIDAL once. Every
|
|
7
|
+
verb here goes through `utils.throttle`, so the working rules' rate limits
|
|
8
|
+
are enforced by code rather than by an agent having read them.
|
|
9
|
+
|
|
10
|
+
Contract, held everywhere:
|
|
11
|
+
|
|
12
|
+
- **stdout is JSON, always exactly one object.** Success is `{"ok": true,
|
|
13
|
+
...}`; failure is `{"ok": false, "error": <code>, "message": ...,
|
|
14
|
+
"hint": ...}` and a nonzero exit. Anything meant for a human goes to
|
|
15
|
+
stderr. An agent must never have to parse prose.
|
|
16
|
+
- **Errors are structured and honest.** `not_logged_in`, `rate_limited`
|
|
17
|
+
(the trip — includes what tripped it and that a *human* clears it),
|
|
18
|
+
`auth_failed`, `api_error`, `not_found`. Never a stack trace on stdout,
|
|
19
|
+
never a silent empty result for what was actually a failure.
|
|
20
|
+
- **Requests are counted and spaced.** Each verb documents its request cost
|
|
21
|
+
in `--help`; each network call takes one `throttle.acquire()` first. A 429
|
|
22
|
+
or a 401/subStatus-4006 trips the stop for every future agent call until
|
|
23
|
+
`ticli agent unblock`.
|
|
24
|
+
|
|
25
|
+
This module must not import `ticli.player` — that is the whole TUI's import
|
|
26
|
+
chain, and both `ticli --help` and `ticli agent --help` stay instant by
|
|
27
|
+
keeping tidalapi and player imports inside the functions that need them.
|
|
28
|
+
The playlist mutations here deliberately do not touch the running player's
|
|
29
|
+
queue or state files; a live TUI notices new playlists the way it notices
|
|
30
|
+
them changing on the server, by fetching.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import re
|
|
35
|
+
import sys
|
|
36
|
+
|
|
37
|
+
from ticli.utils import throttle
|
|
38
|
+
from ticli.utils.credential_store import load_tokens, save_tokens
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def emit(payload: dict) -> None:
|
|
42
|
+
json.dump(payload, sys.stdout, indent=2)
|
|
43
|
+
sys.stdout.write("\n")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def fail(error: str, message: str, hint: str = "", **extra) -> "SystemExit":
|
|
47
|
+
payload = {"ok": False, "error": error, "message": message}
|
|
48
|
+
if hint:
|
|
49
|
+
payload["hint"] = hint
|
|
50
|
+
payload.update(extra)
|
|
51
|
+
emit(payload)
|
|
52
|
+
return SystemExit(1)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Session
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _tripped_exit(record: dict) -> SystemExit:
|
|
60
|
+
return fail(
|
|
61
|
+
"rate_limited",
|
|
62
|
+
"TIDAL rate-limited this machine; all agent requests are stopped.",
|
|
63
|
+
hint=(
|
|
64
|
+
"Do not retry — retries extend the block. Report this to the "
|
|
65
|
+
"user; a human runs `ticli agent unblock` once it is safe."
|
|
66
|
+
),
|
|
67
|
+
tripped=record,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _acquire() -> None:
|
|
72
|
+
"""One request's worth of permission, or a structured refusal."""
|
|
73
|
+
try:
|
|
74
|
+
throttle.acquire()
|
|
75
|
+
except throttle.Tripped as t:
|
|
76
|
+
raise _tripped_exit(t.record)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _trip_from(exc) -> None:
|
|
80
|
+
"""Inspect a failed request; trip the stop if it is the kind that blocks.
|
|
81
|
+
|
|
82
|
+
A 429 always trips. A 401 trips only on TIDAL's subStatus 4006 — the
|
|
83
|
+
bot-detection escalation — because an ordinary 401 is a dead token, which
|
|
84
|
+
is an auth problem, not a ban in progress.
|
|
85
|
+
"""
|
|
86
|
+
response = getattr(exc, "response", None)
|
|
87
|
+
status = getattr(response, "status_code", None)
|
|
88
|
+
if status == 429:
|
|
89
|
+
record = throttle.trip("http_429", detail=str(exc))
|
|
90
|
+
raise _tripped_exit(record)
|
|
91
|
+
if status == 401:
|
|
92
|
+
sub = None
|
|
93
|
+
try:
|
|
94
|
+
sub = response.json().get("subStatus")
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
if sub == 4006:
|
|
98
|
+
record = throttle.trip("substatus_4006", detail=str(exc))
|
|
99
|
+
raise _tripped_exit(record)
|
|
100
|
+
raise fail(
|
|
101
|
+
"auth_failed",
|
|
102
|
+
"TIDAL rejected the stored session.",
|
|
103
|
+
hint="Run `ticli` interactively to log in again.",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _session():
|
|
108
|
+
"""The blessed bootstrap: stored tokens to a working tidalapi session.
|
|
109
|
+
|
|
110
|
+
The `is_pkce` flag must survive into `load_oauth_session` — it selects
|
|
111
|
+
which TIDAL client refreshes the token, and getting it wrong kills the
|
|
112
|
+
session hours later (see credential_store's module docstring). If loading
|
|
113
|
+
refreshed the token on the way in, the refreshed copy is saved back so
|
|
114
|
+
the next invocation doesn't repeat the round trip.
|
|
115
|
+
"""
|
|
116
|
+
import tidalapi # deferred: keep `ticli agent --help` instant
|
|
117
|
+
|
|
118
|
+
data = load_tokens()
|
|
119
|
+
if not data:
|
|
120
|
+
raise fail(
|
|
121
|
+
"not_logged_in",
|
|
122
|
+
"No stored TIDAL session.",
|
|
123
|
+
hint="Run `ticli` interactively to log in (PKCE for FLAC).",
|
|
124
|
+
)
|
|
125
|
+
session = tidalapi.Session()
|
|
126
|
+
try:
|
|
127
|
+
session.load_oauth_session(
|
|
128
|
+
data["token_type"],
|
|
129
|
+
data["access_token"],
|
|
130
|
+
data.get("refresh_token"),
|
|
131
|
+
data.get("expiry_time"),
|
|
132
|
+
is_pkce=data.get("is_pkce", False),
|
|
133
|
+
)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
raise fail(
|
|
136
|
+
"auth_failed",
|
|
137
|
+
f"Could not restore the stored session: {type(e).__name__}",
|
|
138
|
+
hint="Run `ticli` interactively to log in again.",
|
|
139
|
+
)
|
|
140
|
+
if session.access_token != data.get("access_token"):
|
|
141
|
+
_persist(session)
|
|
142
|
+
return session
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _persist(session) -> None:
|
|
146
|
+
expiry = session.expiry_time
|
|
147
|
+
try:
|
|
148
|
+
save_tokens({
|
|
149
|
+
"token_type": session.token_type,
|
|
150
|
+
"access_token": session.access_token,
|
|
151
|
+
"refresh_token": session.refresh_token,
|
|
152
|
+
"expiry_time": expiry.isoformat() if hasattr(expiry, "isoformat") else expiry,
|
|
153
|
+
"is_pkce": bool(session.is_pkce),
|
|
154
|
+
})
|
|
155
|
+
except Exception:
|
|
156
|
+
pass # a failed save costs one refresh next run, not correctness
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _api_call(fn, *args, **kwargs):
|
|
160
|
+
"""One throttled request: acquire a slot, run it, classify the failure.
|
|
161
|
+
|
|
162
|
+
Every failure class carries a hint — the docs promise "each carries a
|
|
163
|
+
hint saying what to do", and an audit caught api_error breaking that
|
|
164
|
+
promise. A 404 is its own code: "no such id" and "the API broke" send
|
|
165
|
+
an agent down different paths, and folding them together made the
|
|
166
|
+
common mistake (a stale or mistyped id) look like an outage.
|
|
167
|
+
"""
|
|
168
|
+
_acquire()
|
|
169
|
+
try:
|
|
170
|
+
return fn(*args, **kwargs)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
_trip_from(e)
|
|
173
|
+
status = getattr(getattr(e, "response", None), "status_code", None)
|
|
174
|
+
if status == 404:
|
|
175
|
+
raise fail(
|
|
176
|
+
"not_found", f"{type(e).__name__}: {e}",
|
|
177
|
+
hint=("No such id. Playlist ids come from `playlist list` or "
|
|
178
|
+
"`playlist create`; track ids from `resolve` or `search`."),
|
|
179
|
+
)
|
|
180
|
+
raise fail(
|
|
181
|
+
"api_error", f"{type(e).__name__}: {e}",
|
|
182
|
+
hint=("Not a rate limit and not auth — an unclassified API "
|
|
183
|
+
"failure. Report it to the human if it persists."),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# Serialization — plain dicts an agent can rely on, never tidalapi objects
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _track_json(t) -> dict:
|
|
192
|
+
album = getattr(t, "album", None)
|
|
193
|
+
return {
|
|
194
|
+
"id": t.id,
|
|
195
|
+
"title": t.name,
|
|
196
|
+
"artists": [a.name for a in (t.artists or [])],
|
|
197
|
+
"album": getattr(album, "name", None),
|
|
198
|
+
"duration_seconds": t.duration,
|
|
199
|
+
"explicit": bool(getattr(t, "explicit", False)),
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _album_json(a) -> dict:
|
|
204
|
+
return {
|
|
205
|
+
"id": a.id,
|
|
206
|
+
"title": a.name,
|
|
207
|
+
"artists": [ar.name for ar in (getattr(a, "artists", None) or [])],
|
|
208
|
+
"num_tracks": getattr(a, "num_tracks", None),
|
|
209
|
+
"year": getattr(a, "year", None),
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _artist_json(a) -> dict:
|
|
214
|
+
return {"id": a.id, "name": a.name}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _playlist_json(p) -> dict:
|
|
218
|
+
return {
|
|
219
|
+
"id": str(p.id),
|
|
220
|
+
"name": p.name,
|
|
221
|
+
"num_tracks": getattr(p, "num_tracks", None),
|
|
222
|
+
"description": getattr(p, "description", "") or "",
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# Verbs
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def status(verify: bool) -> None:
|
|
231
|
+
"""Zero requests by default: report what is knowable without the network.
|
|
232
|
+
`--verify` spends exactly one on `check_login`."""
|
|
233
|
+
data = load_tokens()
|
|
234
|
+
payload = {
|
|
235
|
+
"ok": True,
|
|
236
|
+
"session_stored": bool(data),
|
|
237
|
+
"flow": ("pkce" if data.get("is_pkce") else "device") if data else None,
|
|
238
|
+
# PKCE is the only flow TIDAL streams FLAC to; device gets AAC.
|
|
239
|
+
"flac_capable": bool(data and data.get("is_pkce")),
|
|
240
|
+
"player_running": _player_running(),
|
|
241
|
+
"throttle": {
|
|
242
|
+
"min_interval_seconds": throttle.MIN_INTERVAL_SECONDS,
|
|
243
|
+
"tripped": throttle.tripped(),
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
if verify:
|
|
247
|
+
if not data:
|
|
248
|
+
raise fail("not_logged_in", "No stored TIDAL session.",
|
|
249
|
+
hint="Run `ticli` interactively to log in.")
|
|
250
|
+
session = _session()
|
|
251
|
+
payload["verified"] = bool(_api_call(session.check_login))
|
|
252
|
+
if payload["verified"]:
|
|
253
|
+
_persist(session)
|
|
254
|
+
emit(payload)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _player_running() -> bool:
|
|
258
|
+
"""Whether a ticli TUI holds the instance lock right now. Best-effort —
|
|
259
|
+
probing takes the flock for a moment, so a *starting* TUI could race it,
|
|
260
|
+
and a filesystem that can't lock reads as not-running. Informational only."""
|
|
261
|
+
import fcntl
|
|
262
|
+
import os
|
|
263
|
+
|
|
264
|
+
lock_path = throttle.STATE_DIR / "instance.lock" # player._instance_lock_path
|
|
265
|
+
if not lock_path.exists():
|
|
266
|
+
return False
|
|
267
|
+
try:
|
|
268
|
+
fd = os.open(lock_path, os.O_RDWR)
|
|
269
|
+
except OSError:
|
|
270
|
+
return False
|
|
271
|
+
try:
|
|
272
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
273
|
+
except BlockingIOError:
|
|
274
|
+
return True
|
|
275
|
+
except OSError:
|
|
276
|
+
return False
|
|
277
|
+
finally:
|
|
278
|
+
os.close(fd)
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
_SEARCH_TYPES = {"track": "tracks", "album": "albums",
|
|
283
|
+
"artist": "artists", "playlist": "playlists"}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def search(query: str, types: tuple, limit: int) -> None:
|
|
287
|
+
"""One request regardless of how many types are asked for — limit is
|
|
288
|
+
per-type server-side, the same property the TUI's reservoir leans on."""
|
|
289
|
+
import tidalapi
|
|
290
|
+
|
|
291
|
+
wanted = list(types) or ["track"]
|
|
292
|
+
models = {"track": tidalapi.Track, "album": tidalapi.Album,
|
|
293
|
+
"artist": tidalapi.Artist, "playlist": tidalapi.Playlist}
|
|
294
|
+
session = _session()
|
|
295
|
+
results = _api_call(
|
|
296
|
+
session.search, query,
|
|
297
|
+
models=[models[t] for t in wanted], limit=limit,
|
|
298
|
+
)
|
|
299
|
+
payload = {"ok": True, "query": query}
|
|
300
|
+
render = {"track": _track_json, "album": _album_json,
|
|
301
|
+
"artist": _artist_json, "playlist": _playlist_json}
|
|
302
|
+
for t in wanted:
|
|
303
|
+
payload[_SEARCH_TYPES[t]] = [render[t](x) for x in (results.get(_SEARCH_TYPES[t]) or [])]
|
|
304
|
+
emit(payload)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# Version qualifiers that make a track a different listen from the plain
|
|
308
|
+
# title: a caller asking for "The Journey" plain does not mean the Alex
|
|
309
|
+
# Martyn Remix. "feat. …" is deliberately NOT here — a featured guest is the
|
|
310
|
+
# same recording, and treating it as a qualifier is what buried the real
|
|
311
|
+
# Folamour original in this feature's motivating incident.
|
|
312
|
+
_QUALIFIER = re.compile(
|
|
313
|
+
r"\b(remix|edit|rework|bootleg|dub|instrumental|acoustic|acapella|"
|
|
314
|
+
r"live|demo|radio|extended|vip|version|mix)\b", re.I)
|
|
315
|
+
_FEAT = re.compile(r"\s*[(\[]\s*(?:feat|ft|featuring|with)\.?\s[^)\]]*[)\]]", re.I)
|
|
316
|
+
_NOISE = re.compile(r"[^a-z0-9]+")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _normalize(title: str) -> str:
|
|
320
|
+
"""Case/punctuation-blind comparison form, with featured-artist credits
|
|
321
|
+
stripped — 'The Journey (feat. Zeke Manyika)' answers to 'The Journey'."""
|
|
322
|
+
return _NOISE.sub(" ", _FEAT.sub("", title or "").lower()).strip()
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def resolve(artist: str, title: str, limit: int) -> None:
|
|
326
|
+
"""One search request, then local ranking with the failure modes this
|
|
327
|
+
surface exists to prevent encoded as rules:
|
|
328
|
+
|
|
329
|
+
- **Artist is a gate, not a score.** A candidate whose artists don't
|
|
330
|
+
include the requested one can appear in `candidates` but can never be
|
|
331
|
+
`best` while any artist-matched candidate exists, and never `confident`.
|
|
332
|
+
(The motivating incident: a scorer whose remix penalty could outweigh
|
|
333
|
+
its artist bonus picked "The Journey" by H.E.R. over Folamour's.)
|
|
334
|
+
- **Unrequested qualifiers demote within the gate.** A remix outranks
|
|
335
|
+
nothing plain, but it still resolves when it is all there is — reported
|
|
336
|
+
as such, so the caller can decide instead of being silently served one.
|
|
337
|
+
- **`confident` is strict**: artist-matched, normalized-title equal, no
|
|
338
|
+
unrequested qualifier. Anything less is the caller's judgement call,
|
|
339
|
+
and the ranked list is there for it to make one.
|
|
340
|
+
"""
|
|
341
|
+
import tidalapi
|
|
342
|
+
|
|
343
|
+
session = _session()
|
|
344
|
+
results = _api_call(
|
|
345
|
+
session.search, f"{artist} {title}",
|
|
346
|
+
models=[tidalapi.Track], limit=limit,
|
|
347
|
+
)
|
|
348
|
+
want_artist = _normalize(artist)
|
|
349
|
+
want_title = _normalize(title)
|
|
350
|
+
asked_qualified = bool(_QUALIFIER.search(title or ""))
|
|
351
|
+
|
|
352
|
+
candidates = []
|
|
353
|
+
for t in results.get("tracks") or []:
|
|
354
|
+
names = " ".join(a.name for a in (t.artists or []))
|
|
355
|
+
artist_match = want_artist in _normalize(names)
|
|
356
|
+
got_title = _normalize(t.name)
|
|
357
|
+
title_exact = got_title == want_title
|
|
358
|
+
qualifier = (not asked_qualified) and bool(_QUALIFIER.search(t.name or ""))
|
|
359
|
+
# Rank *within* the artist gate; the gate itself is the sort's first key.
|
|
360
|
+
score = (2 if title_exact else (1 if want_title in got_title else 0)) - (1 if qualifier else 0)
|
|
361
|
+
candidates.append({
|
|
362
|
+
**_track_json(t),
|
|
363
|
+
"artist_match": artist_match,
|
|
364
|
+
"title_exact": title_exact,
|
|
365
|
+
"unrequested_qualifier": qualifier,
|
|
366
|
+
"score": score,
|
|
367
|
+
})
|
|
368
|
+
candidates.sort(key=lambda c: (c["artist_match"], c["score"]), reverse=True)
|
|
369
|
+
|
|
370
|
+
best = candidates[0] if candidates else None
|
|
371
|
+
confident = bool(
|
|
372
|
+
best and best["artist_match"] and best["title_exact"]
|
|
373
|
+
and not best["unrequested_qualifier"]
|
|
374
|
+
)
|
|
375
|
+
emit({
|
|
376
|
+
"ok": True,
|
|
377
|
+
"artist": artist,
|
|
378
|
+
"title": title,
|
|
379
|
+
"confident": confident,
|
|
380
|
+
"best": best,
|
|
381
|
+
"candidates": candidates,
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def playlist_list() -> None:
|
|
386
|
+
session = _session()
|
|
387
|
+
playlists = _api_call(session.user.playlists)
|
|
388
|
+
emit({"ok": True, "playlists": [_playlist_json(p) for p in playlists]})
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def playlist_show(playlist_id: str) -> None:
|
|
392
|
+
session = _session()
|
|
393
|
+
pl = _api_call(session.playlist, playlist_id)
|
|
394
|
+
tracks = _api_call(pl.tracks)
|
|
395
|
+
payload = _playlist_json(pl)
|
|
396
|
+
emit({"ok": True, "playlist": payload,
|
|
397
|
+
"tracks": [_track_json(t) for t in tracks]})
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def playlist_create(name: str, description: str) -> None:
|
|
401
|
+
session = _session()
|
|
402
|
+
pl = _api_call(session.user.create_playlist, name, description or "")
|
|
403
|
+
emit({"ok": True, "playlist": _playlist_json(pl)})
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def playlist_add(playlist_id: str, track_ids: tuple) -> None:
|
|
407
|
+
"""Two requests (fetch the playlist, add the tracks). The server skips
|
|
408
|
+
duplicates; `added` reports what it actually took."""
|
|
409
|
+
session = _session()
|
|
410
|
+
pl = _api_call(session.playlist, playlist_id)
|
|
411
|
+
added = _api_call(pl.add, [str(t) for t in track_ids])
|
|
412
|
+
emit({"ok": True, "playlist_id": str(playlist_id),
|
|
413
|
+
"requested": len(track_ids),
|
|
414
|
+
"added": len(added) if added is not None else 0})
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def unblock() -> None:
|
|
418
|
+
"""The human's lever, not the agent's: clear a tripped stop."""
|
|
419
|
+
was = throttle.unblock()
|
|
420
|
+
emit({"ok": True, "was_tripped": was})
|
|
421
|
+
if not was:
|
|
422
|
+
print("note: no stop was in force", file=sys.stderr)
|