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.
Files changed (53) hide show
  1. {tidal_cli-1.0.2 → tidal_cli-1.0.3}/PKG-INFO +27 -12
  2. {tidal_cli-1.0.2 → tidal_cli-1.0.3}/README.md +26 -11
  3. {tidal_cli-1.0.2 → tidal_cli-1.0.3}/pyproject.toml +1 -1
  4. {tidal_cli-1.0.2 → tidal_cli-1.0.3}/setup.py +1 -1
  5. tidal_cli-1.0.3/ticli/agent.py +422 -0
  6. tidal_cli-1.0.3/ticli/agent_docs.py +177 -0
  7. tidal_cli-1.0.3/ticli/cli.py +161 -0
  8. tidal_cli-1.0.3/ticli/player.py +9011 -0
  9. tidal_cli-1.0.3/ticli/tests/conftest.py +41 -0
  10. tidal_cli-1.0.3/ticli/tests/fakes.py +46 -0
  11. tidal_cli-1.0.3/ticli/tests/test_add_to_playlist.py +462 -0
  12. tidal_cli-1.0.3/ticli/tests/test_agent_surface.py +465 -0
  13. tidal_cli-1.0.3/ticli/tests/test_artist.py +501 -0
  14. tidal_cli-1.0.3/ticli/tests/test_artwork.py +652 -0
  15. tidal_cli-1.0.3/ticli/tests/test_buffering.py +433 -0
  16. tidal_cli-1.0.3/ticli/tests/test_bulk_downloads.py +878 -0
  17. tidal_cli-1.0.3/ticli/tests/test_cache.py +2399 -0
  18. tidal_cli-1.0.3/ticli/tests/test_config.py +1054 -0
  19. tidal_cli-1.0.3/ticli/tests/test_display.py +1649 -0
  20. tidal_cli-1.0.3/ticli/tests/test_download_queue.py +717 -0
  21. tidal_cli-1.0.3/ticli/tests/test_downloads.py +2573 -0
  22. {tidal_cli-1.0.2 → tidal_cli-1.0.3}/ticli/tests/test_full_e2e.py +2 -2
  23. tidal_cli-1.0.3/ticli/tests/test_input_latency.py +295 -0
  24. tidal_cli-1.0.3/ticli/tests/test_login.py +830 -0
  25. tidal_cli-1.0.3/ticli/tests/test_media_keys.py +247 -0
  26. tidal_cli-1.0.3/ticli/tests/test_playback_exclusion.py +460 -0
  27. tidal_cli-1.0.3/ticli/tests/test_player_controls.py +764 -0
  28. tidal_cli-1.0.3/ticli/tests/test_radio.py +412 -0
  29. tidal_cli-1.0.3/ticli/tests/test_resume.py +1051 -0
  30. tidal_cli-1.0.3/ticli/tests/test_search.py +1172 -0
  31. tidal_cli-1.0.3/ticli/tests/test_seek.py +505 -0
  32. tidal_cli-1.0.3/ticli/tests/test_single_instance.py +266 -0
  33. tidal_cli-1.0.3/ticli/tests/vt.py +148 -0
  34. tidal_cli-1.0.3/ticli/utils/artwork.py +790 -0
  35. tidal_cli-1.0.3/ticli/utils/cache.py +1014 -0
  36. tidal_cli-1.0.3/ticli/utils/config.py +321 -0
  37. {tidal_cli-1.0.2 → tidal_cli-1.0.3}/ticli/utils/credential_store.py +43 -4
  38. tidal_cli-1.0.3/ticli/utils/downloads.py +557 -0
  39. tidal_cli-1.0.3/ticli/utils/tags.py +413 -0
  40. tidal_cli-1.0.3/ticli/utils/throttle.py +159 -0
  41. {tidal_cli-1.0.2 → tidal_cli-1.0.3}/tidal_cli.egg-info/PKG-INFO +27 -12
  42. tidal_cli-1.0.3/tidal_cli.egg-info/SOURCES.txt +48 -0
  43. tidal_cli-1.0.2/ticli/cli.py +0 -19
  44. tidal_cli-1.0.2/ticli/player.py +0 -1452
  45. tidal_cli-1.0.2/tidal_cli.egg-info/SOURCES.txt +0 -16
  46. {tidal_cli-1.0.2 → tidal_cli-1.0.3}/setup.cfg +0 -0
  47. {tidal_cli-1.0.2 → tidal_cli-1.0.3}/ticli/__init__.py +0 -0
  48. {tidal_cli-1.0.2 → tidal_cli-1.0.3}/ticli/tests/__init__.py +0 -0
  49. {tidal_cli-1.0.2 → tidal_cli-1.0.3}/ticli/utils/__init__.py +0 -0
  50. {tidal_cli-1.0.2 → tidal_cli-1.0.3}/tidal_cli.egg-info/dependency_links.txt +0 -0
  51. {tidal_cli-1.0.2 → tidal_cli-1.0.3}/tidal_cli.egg-info/entry_points.txt +0 -0
  52. {tidal_cli-1.0.2 → tidal_cli-1.0.3}/tidal_cli.egg-info/requires.txt +0 -0
  53. {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.2
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 LOSSLESS │
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 [ffmpeg](https://ffmpeg.org).
73
+ Requires Python 3.10+ and [mpv](https://mpv.io).
73
74
 
74
75
  ```bash
75
76
  # macOS
76
- brew install ffmpeg python3
77
+ brew install mpv
77
78
  pip install tidal-cli
78
79
 
79
80
  # Ubuntu / Debian
80
- sudo apt install ffmpeg python3-pip
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 HIRES # 24-bit hi-res FLAC
102
- ticli --quality LOSSLESS # 16-bit FLAC
103
- ticli --quality HIGH # lossless FLAC (default)
104
- ticli --quality LOW # 320kbps
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 [ffplay](https://ffmpeg.org/ffplay.html). The TUI is built with [Rich](https://github.com/Textualize/rich).
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 │ ──────────────► │ ffplay │
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
- - ffmpeg
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 LOSSLESS │
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 [ffmpeg](https://ffmpeg.org).
41
+ Requires Python 3.10+ and [mpv](https://mpv.io).
41
42
 
42
43
  ```bash
43
44
  # macOS
44
- brew install ffmpeg python3
45
+ brew install mpv
45
46
  pip install tidal-cli
46
47
 
47
48
  # Ubuntu / Debian
48
- sudo apt install ffmpeg python3-pip
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 HIRES # 24-bit hi-res FLAC
70
- ticli --quality LOSSLESS # 16-bit FLAC
71
- ticli --quality HIGH # lossless FLAC (default)
72
- ticli --quality LOW # 320kbps
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 [ffplay](https://ffmpeg.org/ffplay.html). The TUI is built with [Rich](https://github.com/Textualize/rich).
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 │ ──────────────► │ ffplay │
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
- - ffmpeg
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.2"
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"
@@ -4,7 +4,7 @@ from setuptools import find_packages, setup
4
4
 
5
5
  setup(
6
6
  name="tidal-cli",
7
- version="1.0.2",
7
+ version="1.0.3",
8
8
  description="Ticli - Terminal music player for TIDAL",
9
9
  author="Ticli",
10
10
  license="MIT",
@@ -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)