tidal-cli 1.0.0__py3-none-any.whl
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.
- ticli/__init__.py +3 -0
- ticli/cli.py +19 -0
- ticli/player.py +1452 -0
- ticli/tests/__init__.py +0 -0
- ticli/tests/test_full_e2e.py +70 -0
- ticli/utils/__init__.py +1 -0
- ticli/utils/credential_store.py +94 -0
- tidal_cli-1.0.0.dist-info/METADATA +171 -0
- tidal_cli-1.0.0.dist-info/RECORD +12 -0
- tidal_cli-1.0.0.dist-info/WHEEL +5 -0
- tidal_cli-1.0.0.dist-info/entry_points.txt +2 -0
- tidal_cli-1.0.0.dist-info/top_level.txt +1 -0
ticli/tests/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""End-to-end tests for Ticli CLI.
|
|
2
|
+
|
|
3
|
+
Tests the CLI entry point and installed command via subprocess.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from click.testing import CliRunner
|
|
13
|
+
|
|
14
|
+
from ticli.cli import cli
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestCLIHelp:
|
|
18
|
+
"""Test CLI help and basic invocation."""
|
|
19
|
+
|
|
20
|
+
def setup_method(self):
|
|
21
|
+
self.runner = CliRunner()
|
|
22
|
+
|
|
23
|
+
def test_main_help(self):
|
|
24
|
+
result = self.runner.invoke(cli, ["--help"])
|
|
25
|
+
assert result.exit_code == 0
|
|
26
|
+
assert "Ticli" in result.output
|
|
27
|
+
|
|
28
|
+
def test_quality_flag(self):
|
|
29
|
+
result = self.runner.invoke(cli, ["--help"])
|
|
30
|
+
assert result.exit_code == 0
|
|
31
|
+
assert "quality" in result.output.lower()
|
|
32
|
+
|
|
33
|
+
def test_quality_choices(self):
|
|
34
|
+
result = self.runner.invoke(cli, ["--help"])
|
|
35
|
+
assert result.exit_code == 0
|
|
36
|
+
assert "low" in result.output.lower()
|
|
37
|
+
assert "high" in result.output.lower()
|
|
38
|
+
assert "lossless" in result.output.lower()
|
|
39
|
+
assert "hires" in result.output.lower()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestCLISubprocess:
|
|
43
|
+
"""Test the installed CLI command via subprocess."""
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def _resolve_cli(name: str) -> str:
|
|
47
|
+
if os.environ.get("CLI_ANYTHING_FORCE_INSTALLED") == "1":
|
|
48
|
+
path = shutil.which(name)
|
|
49
|
+
if path:
|
|
50
|
+
return path
|
|
51
|
+
pytest.skip(f"{name} not found in PATH")
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def _run(self, args: list[str], **kwargs) -> subprocess.CompletedProcess:
|
|
55
|
+
exe = self._resolve_cli("ticli")
|
|
56
|
+
if exe:
|
|
57
|
+
cmd = [exe] + args
|
|
58
|
+
else:
|
|
59
|
+
cmd = [sys.executable, "-m", "ticli.cli"] + args
|
|
60
|
+
return subprocess.run(cmd, capture_output=True, text=True, timeout=10, **kwargs)
|
|
61
|
+
|
|
62
|
+
def test_help_exit_code(self):
|
|
63
|
+
result = self._run(["--help"])
|
|
64
|
+
assert result.returncode == 0
|
|
65
|
+
assert "Ticli" in result.stdout
|
|
66
|
+
|
|
67
|
+
def test_quality_in_help(self):
|
|
68
|
+
result = self._run(["--help"])
|
|
69
|
+
assert result.returncode == 0
|
|
70
|
+
assert "quality" in result.stdout.lower()
|
ticli/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utility modules for TIDAL CLI harness."""
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Secure credential storage for Ticli.
|
|
2
|
+
|
|
3
|
+
Prefers the OS keychain (macOS Keychain, GNOME Keyring, Windows Credential Manager)
|
|
4
|
+
via the `keyring` library. Falls back to a chmod-600 JSON file if keyring is
|
|
5
|
+
unavailable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
SERVICE_NAME = "ticli"
|
|
17
|
+
FALLBACK_DIR = Path.home() / ".config" / SERVICE_NAME
|
|
18
|
+
FALLBACK_FILE = FALLBACK_DIR / "session.json"
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import keyring
|
|
22
|
+
# Verify the backend isn't the fail-open "null" backend
|
|
23
|
+
_backend = keyring.get_keyring()
|
|
24
|
+
_backend_name = type(_backend).__name__
|
|
25
|
+
if "fail" in _backend_name.lower() or "null" in _backend_name.lower():
|
|
26
|
+
keyring = None
|
|
27
|
+
logger.debug("keyring backend is %s — falling back to file", _backend_name)
|
|
28
|
+
except Exception:
|
|
29
|
+
keyring = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _ensure_fallback_dir() -> None:
|
|
33
|
+
FALLBACK_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def save_tokens(data: dict) -> None:
|
|
37
|
+
"""Persist OAuth tokens securely."""
|
|
38
|
+
payload = json.dumps(data)
|
|
39
|
+
|
|
40
|
+
if keyring is not None:
|
|
41
|
+
try:
|
|
42
|
+
keyring.set_password(SERVICE_NAME, "oauth_session", payload)
|
|
43
|
+
# Remove any leftover plaintext file from previous runs
|
|
44
|
+
_delete_fallback_file()
|
|
45
|
+
return
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.warning("keyring.set_password failed, falling back to file: %s", e)
|
|
48
|
+
|
|
49
|
+
# Fallback: write to file with restrictive permissions
|
|
50
|
+
_ensure_fallback_dir()
|
|
51
|
+
FALLBACK_FILE.write_text(payload)
|
|
52
|
+
os.chmod(FALLBACK_FILE, 0o600)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_tokens() -> Optional[dict]:
|
|
56
|
+
"""Load stored OAuth tokens. Returns None if nothing is stored."""
|
|
57
|
+
# Try keychain first
|
|
58
|
+
if keyring is not None:
|
|
59
|
+
try:
|
|
60
|
+
raw = keyring.get_password(SERVICE_NAME, "oauth_session")
|
|
61
|
+
if raw:
|
|
62
|
+
return json.loads(raw)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.debug("keyring.get_password failed: %s", e)
|
|
65
|
+
|
|
66
|
+
# Fallback: read from file
|
|
67
|
+
if FALLBACK_FILE.exists():
|
|
68
|
+
try:
|
|
69
|
+
return json.loads(FALLBACK_FILE.read_text())
|
|
70
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
71
|
+
logger.debug("Failed to read fallback token file: %s", e)
|
|
72
|
+
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def delete_tokens() -> None:
|
|
77
|
+
"""Remove stored OAuth tokens from all backends."""
|
|
78
|
+
if keyring is not None:
|
|
79
|
+
try:
|
|
80
|
+
keyring.delete_password(SERVICE_NAME, "oauth_session")
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
_delete_fallback_file()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _delete_fallback_file() -> None:
|
|
87
|
+
"""Remove the plaintext fallback file if it exists."""
|
|
88
|
+
try:
|
|
89
|
+
if FALLBACK_FILE.exists():
|
|
90
|
+
# Overwrite before unlinking for slightly better security
|
|
91
|
+
FALLBACK_FILE.write_bytes(b"\x00" * len(FALLBACK_FILE.read_bytes()))
|
|
92
|
+
FALLBACK_FILE.unlink()
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tidal-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Terminal music player for TIDAL — search, browse, queue, and stream lossless audio from your terminal
|
|
5
|
+
Author: odonald
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/odonald/ticli
|
|
8
|
+
Project-URL: Repository, https://github.com/odonald/ticli
|
|
9
|
+
Project-URL: Issues, https://github.com/odonald/ticli/issues
|
|
10
|
+
Project-URL: Funding, https://buymeacoffee.com/odonald
|
|
11
|
+
Keywords: tidal,music,terminal,cli,player,tui,streaming,lossless,hifi
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Players
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
Requires-Dist: click>=8.0
|
|
27
|
+
Requires-Dist: rich>=13.0
|
|
28
|
+
Requires-Dist: tidalapi>=0.8.0
|
|
29
|
+
Provides-Extra: keyring
|
|
30
|
+
Requires-Dist: keyring>=24.0; extra == "keyring"
|
|
31
|
+
Dynamic: requires-python
|
|
32
|
+
|
|
33
|
+
# Ticli
|
|
34
|
+
|
|
35
|
+
A terminal music player for TIDAL. Search, browse, queue, and play music — all from your terminal.
|
|
36
|
+
|
|
37
|
+
Ticli connects directly to TIDAL's API using your premium account. No desktop app needed. Just authenticate, search, and play.
|
|
38
|
+
|
|
39
|
+
Works on **macOS** and **Linux**.
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
╭──────────────────────── Ticli ────────────────────────╮
|
|
43
|
+
│ │
|
|
44
|
+
│ ▶ ♥ Arlo Parks - Sophie │
|
|
45
|
+
│ Super Sad Generation │
|
|
46
|
+
│ 1:47 ━━━━━━━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:28 │
|
|
47
|
+
│ Queue: 3/12 LOSSLESS │
|
|
48
|
+
│ Next: Cola • Arlo Parks │
|
|
49
|
+
│ │
|
|
50
|
+
│ [space] play/pause [n/→] next [←] prev │
|
|
51
|
+
│ [s] search [q] queue [p] playlists │
|
|
52
|
+
│ [l] like [r] radio [t] mini [m] more │
|
|
53
|
+
│ │
|
|
54
|
+
╰────────────────────────────────────────────────────────╯
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Features
|
|
58
|
+
|
|
59
|
+
- **Search** — Find tracks, albums, artists, and playlists
|
|
60
|
+
- **Browse** — Navigate album and playlist tracklists
|
|
61
|
+
- **Queue** — Manage your playback queue, reorder, remove tracks
|
|
62
|
+
- **Playlists** — Browse and play your saved playlists
|
|
63
|
+
- **Likes** — Toggle favorites on any track
|
|
64
|
+
- **Radio** — Generate a station from any track
|
|
65
|
+
- **Mini mode** — Condensed single-line display
|
|
66
|
+
- **Session restore** — Picks up where you left off
|
|
67
|
+
- **Lossless & Hi-Res** — Stream up to 24-bit/192kHz FLAC
|
|
68
|
+
- **Secure auth** — OAuth tokens stored in your OS keychain
|
|
69
|
+
|
|
70
|
+
## Install
|
|
71
|
+
|
|
72
|
+
Requires Python 3.10+ and [ffmpeg](https://ffmpeg.org).
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# macOS
|
|
76
|
+
brew install ffmpeg python3
|
|
77
|
+
pip install tidal-cli
|
|
78
|
+
|
|
79
|
+
# Ubuntu / Debian
|
|
80
|
+
sudo apt install ffmpeg python3-pip
|
|
81
|
+
pip install tidal-cli
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
For secure token storage in your OS keychain (recommended):
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pip install "tidal-cli[keyring]"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Usage
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
ticli
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
On first run you'll get a URL to authorize with your TIDAL account. After that, your session is cached and you go straight to the player.
|
|
97
|
+
|
|
98
|
+
### Quality
|
|
99
|
+
|
|
100
|
+
```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
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Keybindings
|
|
108
|
+
|
|
109
|
+
#### Player
|
|
110
|
+
|
|
111
|
+
| Key | Action |
|
|
112
|
+
|-----|--------|
|
|
113
|
+
| `space` | Play / pause |
|
|
114
|
+
| `n` `→` | Next track |
|
|
115
|
+
| `←` | Previous track |
|
|
116
|
+
| `s` | Search |
|
|
117
|
+
| `q` | Queue |
|
|
118
|
+
| `p` | Playlists |
|
|
119
|
+
| `l` | Like / unlike track |
|
|
120
|
+
| `r` | Start radio from track |
|
|
121
|
+
| `t` | Toggle mini player |
|
|
122
|
+
| `m` | Show more controls |
|
|
123
|
+
| `esc` | Quit |
|
|
124
|
+
|
|
125
|
+
#### Search
|
|
126
|
+
|
|
127
|
+
| Key | Action |
|
|
128
|
+
|-----|--------|
|
|
129
|
+
| `↑` `↓` | Navigate results |
|
|
130
|
+
| `enter` `→` | Play track / open album or artist |
|
|
131
|
+
| `backspace` | Delete character |
|
|
132
|
+
| `esc` `←` | Back |
|
|
133
|
+
|
|
134
|
+
#### Queue
|
|
135
|
+
|
|
136
|
+
| Key | Action |
|
|
137
|
+
|-----|--------|
|
|
138
|
+
| `↑` `↓` | Navigate |
|
|
139
|
+
| `enter` | Jump to track |
|
|
140
|
+
| `x` | Remove track |
|
|
141
|
+
| `esc` `←` | Back |
|
|
142
|
+
|
|
143
|
+
## How it works
|
|
144
|
+
|
|
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).
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
┌─────────┐ OAuth ┌───────────┐ stream URL ┌───────────┐
|
|
149
|
+
│ Ticli │ ──────────────► │ TIDAL │ ──────────────► │ ffplay │
|
|
150
|
+
│ (TUI) │ ◄────────────── │ API │ │ │
|
|
151
|
+
└─────────┘ metadata └───────────┘ └───────────┘
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Auth & credentials
|
|
155
|
+
|
|
156
|
+
OAuth tokens are stored in your OS keychain (macOS Keychain or GNOME Keyring). Falls back to `~/.config/ticli/session.json` with `0600` permissions if keyring is unavailable.
|
|
157
|
+
|
|
158
|
+
## Requirements
|
|
159
|
+
|
|
160
|
+
- macOS or Linux
|
|
161
|
+
- Python 3.10+
|
|
162
|
+
- TIDAL Premium subscription
|
|
163
|
+
- ffmpeg
|
|
164
|
+
|
|
165
|
+
## Support
|
|
166
|
+
|
|
167
|
+
If you enjoy Ticli, consider [buying me a coffee](https://buymeacoffee.com/odonald).
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
ticli/__init__.py,sha256=vIKekzWvFIuYxaaWDvCh5ZiUXmmfzS-kMzEgAqwl9jE,70
|
|
2
|
+
ticli/cli.py,sha256=NMXEc7P7Q7ktGuOqxpngp5UpEgASOXtL0RmwnET0CrQ,453
|
|
3
|
+
ticli/player.py,sha256=2mIXOoX82_u6io2ntOo5Iaq2uKWIKRtvPhMhrpFAC8E,57067
|
|
4
|
+
ticli/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
ticli/tests/test_full_e2e.py,sha256=gP_XC_3znSf7jmkEE_hGVhAIWI7ttMfGAfp-zmLsOYE,2037
|
|
6
|
+
ticli/utils/__init__.py,sha256=DrvkIMwhPUNqvoHinCi1AsUGUlSlkqhy6WynhPG3oDQ,45
|
|
7
|
+
ticli/utils/credential_store.py,sha256=cZc83IgXthCPUmmvbVzoxdkaZsJEj6ZVPwwan-xaJtU,2887
|
|
8
|
+
tidal_cli-1.0.0.dist-info/METADATA,sha256=2sm0Xf9SSgtF61zWPOwLUzaC7TnI_28cRq7oxGOJJvQ,5892
|
|
9
|
+
tidal_cli-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
tidal_cli-1.0.0.dist-info/entry_points.txt,sha256=ocr52-A7b6MV_1bQptxVNk1mFPkOHWJLOOWS-c1ZPas,41
|
|
11
|
+
tidal_cli-1.0.0.dist-info/top_level.txt,sha256=3JHg4ScCRtEOG1Go5pzJhkYJWpjLS-CKgdnwwsMUH5k,6
|
|
12
|
+
tidal_cli-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ticli
|