riff-player 2.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.
riff/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ Riff - Terminal Media Player
3
+ Play music and video from your terminal. Local or internet.
4
+ """
5
+
6
+ __version__ = "2.0.0"
7
+ __author__ = "Sapnoneel Barik"
8
+ __license__ = "MIT"
riff/cache.py ADDED
@@ -0,0 +1,236 @@
1
+ """
2
+ riff/cache.py
3
+ Offline cache - downloads media as compressed .opus files.
4
+ Enforces user-defined size limit with LRU eviction.
5
+ Index stored at ~/.riff/cache_index.json
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import time
13
+
14
+ from riff.config import (
15
+ get_cache_dir,
16
+ get_cache_limit_mb,
17
+ get_cache_mode,
18
+ CACHE_INDEX,
19
+ )
20
+ from riff.utils import find_ytdlp, find_mpv
21
+
22
+ #--- Index helpers -----------------------------------------------------------------------------------------
23
+ def _load_index() -> dict:
24
+ if os.path.exists(CACHE_INDEX):
25
+ try:
26
+ with open(CACHE_INDEX, "r", encoding="utf-8") as f:
27
+ return json.load(f)
28
+ except Exception:
29
+ pass
30
+ return {}
31
+
32
+ def _save_index(index: dict) -> None:
33
+ with open(CACHE_INDEX, "w", encoding="utf-8") as f:
34
+ json.dump(index, f, indent=2)
35
+
36
+ def _safe_key(text: str) -> str:
37
+ """Convert title/URL to a safe filename key."""
38
+ import re
39
+ return re.sub(r'[^\w\-]', '_', text)[:80]
40
+
41
+ #--- Core API -----------------------------------------------------------------------------------------------------
42
+ def save_offline(query: str, title: str | None = None) -> str | None:
43
+ """
44
+ Downloads a track/URL as .opus and store in cache.
45
+ Returns the local file path on success, None on failure.
46
+ """
47
+ ytdlp = find_ytdlp()
48
+ if not ytdlp:
49
+ print("✕ yt-dlp not found.")
50
+ return None
51
+
52
+ cache_dir = get_cache_dir()
53
+ os.makedirs(cache_dir, exist_ok=True)
54
+
55
+ key = _safe_key(title or query)
56
+ outpath = os.path.join(cache_dir, f"{key}.opus")
57
+
58
+ #Already cached?
59
+ if os.path.exists(outpath):
60
+ print(f" ✓ Already cached: {os.path.basename(outpath)}")
61
+ _touch(key)
62
+ return outpath
63
+
64
+ #Enforce size limit before downloading
65
+ _evict_if_needed()
66
+
67
+ print(f" ↓ Downloading offline copy: {title or query}")
68
+
69
+ import subprocess
70
+ source = query if query.startswith(("http://", "https://")) else f"ytsearch1:{query}"
71
+
72
+ result = subprocess.run(
73
+ [
74
+ ytdlp,
75
+ "--format", "bestaudio/best",
76
+ "--extract-audio",
77
+ "--audio-format", "opus",
78
+ "--audio-quality", "128K",
79
+ "--output", outpath.replace(".opus", ".%(ext)s"),
80
+ "--no-playlist",
81
+ "--quiet",
82
+ "--no-warnings",
83
+ source,
84
+ ],
85
+ capture_output=True,
86
+ )
87
+
88
+ #yt-dlp may produce .opus or .webm; find what it made
89
+ actual = _find_output(cache_dir, key)
90
+ if actual and os.path.exists(actual):
91
+ index = _load_index()
92
+ index[key] = {
93
+ "path": actual,
94
+ "title": title or query,
95
+ "query": query,
96
+ "cached_at": time.time(),
97
+ "last_played": time.time(),
98
+ "play_count": 0,
99
+ "size_mb": round(os.path.getsize(actual) / (1024 * 1024), 2),
100
+ }
101
+ _save_index(index)
102
+ print(f" ✓ Saved: {os.path.basename(actual)}")
103
+ return actual
104
+
105
+ print(f" ✕ Failed to cache: {title or query}")
106
+ return None
107
+
108
+ def get_cached(query: str) -> str | None:
109
+ """
110
+ Check if a query/title is already in the cache.
111
+ Returns the file path if found, else None.
112
+ """
113
+ key = _safe_key(query)
114
+ index = _load_index()
115
+ if key in index:
116
+ path = index[key]["path"]
117
+ if os.path.exists(path):
118
+ _touch(key)
119
+ return path
120
+ else:
121
+ #File missing - remove from index
122
+ del index[key]
123
+ _save_index(index)
124
+ return None
125
+
126
+ def play_cached(path: str) -> int:
127
+ """Play a cached file via mpv."""
128
+ from riff.player import play
129
+ return play(path)
130
+
131
+ def list_cache() -> list[dict]:
132
+ """Return all cachedd tracks sorted by last played (most recent first)."""
133
+ index = _load_index()
134
+ tracks = []
135
+ for key, meta in index.items():
136
+ if os.path.exists(meta.get("path", "")):
137
+ tracks.append(meta)
138
+ return sorted(tracks, key=lambda x: x.get("last_played", 0), reverse=True)
139
+
140
+ def clear_cache(key: str | None = None) -> None:
141
+ """Clear one track (by title) or the entire cache."""
142
+ index = _load_index()
143
+
144
+ if key is None:
145
+ #Clear all
146
+ for meta in index.values():
147
+ _remove_file(meta.get("path"))
148
+ _save_index({})
149
+ print(" ✓ Cache cleared.")
150
+ return
151
+
152
+ safe = _safe_key(key)
153
+ if safe in index:
154
+ _remove_file(index[safe].get("path"))
155
+ del index[safe]
156
+ _save_index(index)
157
+ print(f" ✓ Removed: {key}")
158
+ else:
159
+ print(f" ✕ Not found in cache: {key}")
160
+
161
+ def cache_size_mb() -> float:
162
+ """Return total cache size in MB."""
163
+ index = _load_index()
164
+ total = 0.0
165
+ for meta in index.values():
166
+ p = meta.get("path", "")
167
+ if os.path.exists(p):
168
+ total += os.path.getsize(p)
169
+ return round(total / (1024 * 1024), 2)
170
+
171
+ #--- Smart cache prompt --------------------------------------------------------------------------------------------
172
+ def maybe_cache(query: str, title: str | None = None) -> None:
173
+ """
174
+ Called after a stream.
175
+ Depending on cache-mode, either silently caches, asks the user, or does nothing.
176
+ """
177
+ mode = get_cache_mode()
178
+
179
+ if mode == "always":
180
+ save_offline(query, title)
181
+
182
+ elif mode == "ask":
183
+ try:
184
+ ans = input("\n 💾 Save this track for offline listening? [y/N]: ").strip().lower()
185
+ if ans in ("y", "yes"):
186
+ save_offline(query, title)
187
+ except (KeyboardInterrupt, EOFError):
188
+ pass
189
+
190
+ elif mode == "smart":
191
+ #Cache if this query has been played 3+ times
192
+ key = _safe_key(title or query)
193
+ index = _load_index()
194
+ if key in index:
195
+ count = index[key].get("play_count", 0)
196
+ if count >=2:
197
+ print(f"\n 📥 Smart cache: saving frequently played track…")
198
+ save_offline(query, title)
199
+
200
+ #mode == "manual" → do nothing
201
+
202
+ #--- Internal helpers ---------------------------------------------------------------------------------------------------------
203
+ def _touch(key: str) -> None:
204
+ """Update last_played and increment play_count."""
205
+ index = _load_index()
206
+ if key in index:
207
+ index[key]["last_played"] = time.time()
208
+ index[key]["play_count"] = index[key].get("play_count", 0) + 1
209
+ _save_index(index)
210
+
211
+ def _evict_if_needed() -> None:
212
+ """Remove least-recently-played tracks until under the size limit."""
213
+ limit = get_cache_limit_mb()
214
+ index = _load_index()
215
+
216
+ while cache_size_mb() >= limit and index:
217
+ #LRU: remove the track with the oldest last_played
218
+ lru_key = min(index, key=lambda k: index[k].get("last_played", 0))
219
+ print(f" ♻️ Cache full - evicting: {index[lru_key]['title']}")
220
+ _remove_file(index[lru_key].get("path"))
221
+ del index[lru_key]
222
+ _save_index(index)
223
+
224
+ def _find_output(cache_dir: str, key: str) -> str | None:
225
+ for ext in (".opus", ".webm", ".m4a", ".ogg"):
226
+ p = os.path.join(cache_dir, f"{key}{ext}")
227
+ if os.path.exists(p):
228
+ return p
229
+ return None
230
+
231
+ def _remove_file(path: str | None) -> None:
232
+ if path and os.path.exists(path):
233
+ try:
234
+ os.remove(path)
235
+ except OSError:
236
+ pass
riff/cli.py ADDED
@@ -0,0 +1,454 @@
1
+ """
2
+ riff/cli.py
3
+ Command-line interface for Riff v2.0.0
4
+
5
+ Usage:
6
+ python riff.py play <file>
7
+ python riff.py search <query>
8
+ python riff.py stream <query or URL>
9
+ python riff.py video <query or URL>
10
+ python riff.py save <query or URL> --save offline
11
+ python riff.py browse
12
+ python riff.py tui
13
+
14
+ python riff.py playlist save <name>
15
+ python riff.py playlist load <name>
16
+ python riff.py playlist list
17
+ python riff.py playlist fetch <platform> [name]
18
+ python riff.py playlist delete <name>
19
+
20
+ python riff.py account connect <platform>
21
+ python riff.py account sync [platform|all]
22
+ python riff.py account list
23
+ python riff.py account autosync <platform> <on|off>
24
+
25
+ python riff.py cache list
26
+ python riff.py cache clear [name]
27
+
28
+ python riff.py config show
29
+ python riff.py config set <key.path> <value>
30
+
31
+ python riff.py info
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import os
37
+ import sys
38
+ import argparse
39
+
40
+ from riff import __version__
41
+
42
+
43
+ # --- Banner ------------------------------------------------------------------------------------------------------
44
+ BANNER = r"""
45
+ ▮▮▮▮▮▮◱ ▮▮◱ ▮▮▮▮▮▮▮▮◱ ▮▮▮▮▮▮▮▮◱
46
+ ▮▮◲⊟⊟▮▮◱ ▮▮◫ ▮▮▮◲⊟⊟⊟⊟◰ ▮▮▮◲⊟⊟⊟⊟◰
47
+ ▮▮▮▮▮▮◲◰ ▮▮◫ ▮▮▮▮▮▮◱ ▮▮▮▮▮▮◱
48
+ ▮▮◲⊟⊟▮▮◱ ▮▮◫ ▮▮▮◲⊟⊟◰ ▮▮▮◲⊟⊟◰
49
+ ▮▮◫ ▮▮◫ ▮▮◫ ▮▮▮◫ ▮▮▮◫
50
+ ◳◰ ◳◰ ◳◰ ◳⊟◰ ◳⊟◰ Terminal Media Player v{version}
51
+ """.format(version=__version__)
52
+
53
+
54
+ def main():
55
+ #--- Startup sync (background) -----------------------------------------------------------------------------------
56
+ from riff.sync import startup_sync
57
+ startup_sync()
58
+
59
+ parser = argparse.ArgumentParser(
60
+ prog="riff",
61
+ description="Riff - Terminal Media Player",
62
+ formatter_class=argparse.RawDescriptionHelpFormatter,
63
+ epilog="""
64
+ Platforms: spotify | ytmusic | soundcloud
65
+ Cache modes: ask | always | manual | smart
66
+
67
+ Examples:
68
+ python riff.py stream "lofi hip hop"
69
+ python riff.py save "https://youtu.be/..."
70
+ python riff.py playlist fetch spotify "My Mix"
71
+ python riff.py account connect spotify
72
+ python riff.py account sync all
73
+ python riff.py account autosync ytmusic on
74
+ python riff.py cache list
75
+ python riff.py config set cache.mode always
76
+ python riff.py config set cache.size_limit_mb 2048
77
+ python riff.py play "C:/Music/song.mp3"
78
+ python riff.py search "bohemian rphapsody"
79
+ python riff.py browse
80
+ python riff.py info
81
+ """,
82
+ )
83
+
84
+ parser.add_argument("--version", action="version", version=f"Riff {__version__}")
85
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
86
+
87
+ #play
88
+ sp = sub.add_parser("play", help="Play a local file")
89
+ sp.add_argument("file")
90
+ sp.add_argument("--video", action="store_true")
91
+ sp.add_argument("--audio", action="store_true")
92
+
93
+ #search
94
+ sp = sub.add_parser("search", help="Search local storage and play")
95
+ sp.add_argument("query", nargs="?", default="")
96
+ sp.add_argument("--audio", action="store_true")
97
+ sp.add_argument("--video", action="store_true")
98
+ sp.add_argument("--queue", action="store_true")
99
+ sp.add_argument("--shuffle", action="store_true")
100
+
101
+ #stream
102
+ sp = sub.add_parser("stream", help="Stream audio from the internet")
103
+ sp.add_argument("query")
104
+ sp.add_argument("--save",action="store_true", help="Save offline after streaming")
105
+
106
+ #video
107
+ sp = sub.add_parser("video", help="Stream video from the internet")
108
+ sp.add_argument("query")
109
+
110
+ #save
111
+ sub.add_parser("save", help="Save a track offline").add_argument("query")
112
+
113
+ #browse
114
+ sub.add_parser("browse", help="Interactively browse your media library")
115
+
116
+ #tui
117
+ sub.add_parser("tui", help="Launch the full TUI interface")
118
+
119
+ #playlist
120
+ sp = sub.add_parser("playlist", help="Manage playlists")
121
+ sp.add_argument("action", choices=["save", "load", "list", "fetch", "delete"])
122
+ sp.add_argument("args", nargs="*")
123
+
124
+ #account
125
+ sp = sub.add_parser("account", help="Manage music platform accounts")
126
+ sp.add_argument("action", choices=["connect", "sync", "list", "autosync"])
127
+ sp.add_argument("args", nargs="*")
128
+
129
+ #cache
130
+ sp = sub.add_parser("cache", help="Manage offline cache")
131
+ sp.add_argument("action", choices=["list", "clear"])
132
+ sp.add_argument("args", nargs="*")
133
+
134
+ #config
135
+ sp = sub.add_parser("config", help="View or update Riff settings")
136
+ sp.add_argument("action", choices=["show", "set"])
137
+ sp.add_argument("args", nargs="*")
138
+
139
+ #info
140
+ sub.add_parser("info", help="Show system and dependency info")
141
+
142
+ #--- Parse ---------------------------------------------------------------------------------------
143
+ args = parser.parse_args()
144
+
145
+ if args.command is None:
146
+ print(BANNER)
147
+ parser.print_help()
148
+ return
149
+
150
+ #--- Dispatch -------------------------------------------------------------------------------------
151
+ dispatch = {
152
+ "play": _cmd_play,
153
+ "search": _cmd_search,
154
+ "stream": _cmd_stream,
155
+ "video": _cmd_stream,
156
+ "save": _cmd_save,
157
+ "browse": _cmd_browse,
158
+ "tui": _cmd_tui,
159
+ "playlist": _cmd_playlist,
160
+ "account": _cmd_account,
161
+ "cache": _cmd_cache,
162
+ "config": _cmd_config,
163
+ "info": _cmd_info,
164
+ }
165
+ dispatch[args.command](args)
166
+
167
+ #--- Command Handlers -------------------------------------------------------------------------
168
+
169
+ def _cmd_play(args):
170
+ from riff.player import play
171
+ video = True if args.video else (False if args.audio else None)
172
+ play(args.file, video=video)
173
+
174
+
175
+ def _cmd_search(args):
176
+ from riff.search import find_local_media, fzf_pick
177
+ from riff.player import play, play_queue
178
+
179
+ mode = "audio" if args.audio else ("video" if args.video else "all")
180
+ query = args.query
181
+
182
+ print(f"\n🔍 Searching local library{' for: ' + query if query else ''}…")
183
+ results = find_local_media(query=query, mode=mode)
184
+
185
+ if not results:
186
+ print(" No files found.")
187
+ return
188
+
189
+ print(f" Found {len(results)} file(s).\n")
190
+
191
+ if args.queue:
192
+ play_queue(results, shuffle=args.shuffle)
193
+ return
194
+
195
+ chosen = fzf_pick(results)
196
+ if chosen:
197
+ play(chosen)
198
+
199
+
200
+ def _cmd_stream(args):
201
+ from riff.stream import stream
202
+ from riff.cache import maybe_cache
203
+
204
+ video = (args.command == "video")
205
+ print(f"\n🌐 Streaming {'video' if video else 'audio'}: {args.query}\n")
206
+ stream(args.query, video=video)
207
+
208
+ #Post-stream cache prompt (audio only)
209
+ if not video:
210
+ save_flag = getattr(args, "save", False)
211
+ if save_flag:
212
+ from riff.cache import save_offline
213
+ save_offline(args.query)
214
+ else:
215
+ maybe_cache(args.query)
216
+
217
+
218
+ def _cmd_save(args):
219
+ from riff.cache import save_offline
220
+ print(f"\n💾 Saving offline: {args.query}\n")
221
+ save_offline(args.query)
222
+
223
+
224
+ def _cmd_browse(args=None):
225
+ from riff.search import find_local_media, fzf_pick
226
+ from riff.player import play
227
+
228
+ print("\n📂 Browsing media library…\n")
229
+ results = find_local_media(query="", mode="all", limit=500)
230
+ if not results:
231
+ print(" No media files found.")
232
+ return
233
+ print(f" {len(results)} file(s). Arrow keys to browse, Enter to play.\n")
234
+ chosen = fzf_pick(results)
235
+ if chosen:
236
+ play(chosen)
237
+
238
+
239
+ def _cmd_tui(args=None):
240
+ try:
241
+ from riff.tui import RiffApp
242
+ RiffApp().run()
243
+ except ImportError as e:
244
+ print(f"✕ TUI requires textual: pip install textual\n ({e})")
245
+
246
+
247
+ def _cmd_playlist(args):
248
+ from riff import playlist as pl
249
+
250
+ action = args.action
251
+ pargs = args.args
252
+
253
+ if action == "list":
254
+ names = pl.list_all()
255
+ if not names:
256
+ print("\n No saved playlists.")
257
+ else:
258
+ print(f"\n 🗒️ Saved playlists ({len(names)}):")
259
+ for n in names:
260
+ print(f" • {n}")
261
+
262
+ elif action == "save":
263
+ if not pargs:
264
+ print(" Usage: riff playlist save <name>")
265
+ return
266
+ name = pargs[0]
267
+ #Save current search results or prompt for files
268
+ print(f"\n Enter file paths to add (one per line, blank to finish):")
269
+ paths = []
270
+ while True:
271
+ try:
272
+ line = input(" > ").strip()
273
+ if not line:
274
+ break
275
+ if os.path.exists(line):
276
+ paths.append(line)
277
+ else:
278
+ print(f" ✕ Not found: {line}")
279
+ except (KeyboardInterrupt, EOFError):
280
+ break
281
+ if paths:
282
+ pl.save(name, paths)
283
+
284
+ elif action == "load":
285
+ if not pargs:
286
+ print(" Usage: riff playlist load <name>")
287
+ return
288
+ from riff.player import play_queue
289
+ paths = pl.load(pargs[0])
290
+ if paths:
291
+ play_queue(paths)
292
+
293
+ elif action == "fetch":
294
+ if not pargs:
295
+ print(" Usage: riff playlist fetch <platform> [playlist-name]")
296
+ print(" Platforms: spotify | ytmusic | soundcloud")
297
+ return
298
+ platform = pargs[0]
299
+ name = pargs[1]
300
+ pl.save_from_platform(platform, name)
301
+
302
+ elif action == "delete":
303
+ if not pargs:
304
+ print(" Usage: riff playlist delete <name>")
305
+ return
306
+ if pl.delete(pargs[0]):
307
+ print(f" ✓ Deleted playlist: {pargs[0]}")
308
+ else:
309
+ print(f" ✕ Not found: {pargs[0]}")
310
+
311
+
312
+ def _cmd_account(args):
313
+ from riff.sync import sync_account, set_autosync
314
+ from riff.config import get_account, RIFF_DIR
315
+
316
+ action = args.action
317
+ pargs = args.args
318
+
319
+ if action == "list":
320
+ print("\n 🔗 Connected Accounts:")
321
+ print(" --------------------------------------------------------------------------------")
322
+ for platform in ("spotify", "ytmusic", "soundcloud"):
323
+ acc = get_account(platform)
324
+ enabled = acc.get("enabled", False)
325
+ autosync = acc.get("auto_sync", False)
326
+ status = "✓ connected" if enabled else "✕ not connected"
327
+ sync_str = " [auto-sync ON]" if autosync else " [auto-sync OFF]"
328
+ print(f" {platform:<12} {status}{sync_str if enabled else ''}")
329
+ print()
330
+
331
+ elif action == "connect":
332
+ if not pargs:
333
+ print(" Usage: riff account connect <platform>")
334
+ print(" Platforms: spotify | ytmusic | soundcloud")
335
+ return
336
+ platform = pargs[0].lower()
337
+ if platform == "spotify":
338
+ from riff.spotify import connect
339
+ elif platform == "ytmusic":
340
+ from riff.ytmusic import connect
341
+ elif platform == "soundcloud":
342
+ from riff.soundcloud import connect
343
+ else:
344
+ print(f" Unknown platform: {platform}")
345
+ return
346
+ connect()
347
+
348
+ elif action == "sync":
349
+ platform = pargs[0].lower() if pargs else "all"
350
+ offline = "--offline" in pargs
351
+ print(f"\n 🔁 Syncing: {platform}")
352
+ sync_account(platform, offline=offline)
353
+
354
+ elif action == "autosync":
355
+ if len(pargs) < 2:
356
+ print(" Usage: riff account autosync <platform> <on|off>")
357
+ return
358
+ platform = pargs[0].lower()
359
+ enabled = pargs[1].lower() in ("on", "true", "yes", "1")
360
+ set_autosync(platform, enabled)
361
+
362
+
363
+ def _cmd_cache(args):
364
+ from riff.cache import list_cache, clear_cache, cache_size_mb
365
+
366
+ action = args.action
367
+ pargs = args.args
368
+
369
+ if action == "list":
370
+ tracks = list_cache()
371
+ total = cache_size_mb()
372
+ if not tracks:
373
+ print("\n Cache is empty.")
374
+ return
375
+ print(f"\n 💾 Cached tracks ({len(tracks)}) - {total:.1f} MB used:\n")
376
+ for t in tracks:
377
+ import datetime
378
+ played = datetime.datetime.fromtimestamp(
379
+ t.get("last_played", 0)
380
+ ).strftime("%b %d")
381
+ print(f" [{t.get('size_mb', 0):.1f}MB] {t['title']:<50} last: {played}")
382
+ print()
383
+
384
+ elif action == "clear":
385
+ name = pargs[0] if pargs else None
386
+ if name is None:
387
+ confirm = input(" Clear entire cache? [y/N]: ").strip().lower()
388
+ if confirm in ("y", "yes"):
389
+ clear_cache()
390
+ else:
391
+ clear_cache(name)
392
+
393
+
394
+ def _cmd_config(args):
395
+ from riff.config import load, set_value
396
+
397
+ action = args.action
398
+ pargs = args.args
399
+
400
+ if action == "show":
401
+ import json
402
+ cfg = load()
403
+ print("\n ⚙️ Riff Configuration:\n")
404
+ print(json.dumps(cfg, indent=4))
405
+ print()
406
+
407
+ elif action == "set":
408
+ if len(pargs) < 2:
409
+ print(" Usage: riff config set <key.path> <value>")
410
+ print(" Examples:")
411
+ print(" riff config set cache.mode always")
412
+ print(" riff config cache.size_limit_mb 2048")
413
+ print(" riff config accounts.spotify.auto_sync true")
414
+ return
415
+ key, value = pargs[0], pargs[1]
416
+ if set_value(key, value):
417
+ print(f" ✓ Set {key} = {value}")
418
+ else:
419
+ print(f" ✕ Unknown config key: {key}")
420
+
421
+
422
+ def _cmd_info(args=None):
423
+ from riff.utils import find_mpv, find_fzf, find_ytdlp, get_media_roots, is_online
424
+ from riff.config import load, get_cache_dir
425
+ from riff.cache import cache_size_mb
426
+
427
+ print(BANNER)
428
+ print(f" Version : Riff {__version__}")
429
+ print(f" Python : {sys.version.split()[0]}")
430
+ print(f" Platform : {sys.platform}")
431
+ print()
432
+ print(" Dependencies:")
433
+ print(f" mpv : {'✓ ' + find_mpv() if find_mpv() else '✕ not found (winget install shinchiro.mpv)'}")
434
+ print(f" yt-dlp : {'✓ ' + find_ytdlp() if find_ytdlp() else '✕ not found (pip install yt-dlp)'}")
435
+ print(f" fzf : {'✓ ' + find_fzf() if find_fzf() else '✕ not found - optional (winget install fzf)'}")
436
+ print()
437
+ print(" Accounts:")
438
+ for platform in ("spotify", "ytmusic", "soundcloud"):
439
+ from riff.config import get_account
440
+ acc = get_account(platform)
441
+ status = "✓ connected" if acc.get("enabled") else "✕ not connected"
442
+ print(f" {platform:<12}: {status}")
443
+ print()
444
+ print(" Cache:")
445
+ print(f" Directory: {get_cache_dir()}")
446
+ print(f" Used : {cache_size_mb():.1f} MB / {load()['cache']['size_limit_mb']} MB")
447
+ print(f" Mode : {load()['cache']['mode']}")
448
+ print()
449
+ print(" Media roots:")
450
+ for root in get_media_roots():
451
+ print(f" {root}")
452
+ print()
453
+ print(f" Internet : {'✓ online' if is_online() else '✕ offline'}")
454
+ print()