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.
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()
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ticli = ticli.cli:main
@@ -0,0 +1 @@
1
+ ticli