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 +8 -0
- riff/cache.py +236 -0
- riff/cli.py +454 -0
- riff/config.py +158 -0
- riff/meta.py +52 -0
- riff/player.py +117 -0
- riff/playlist.py +106 -0
- riff/search.py +115 -0
- riff/soundcloud.py +129 -0
- riff/spotify.py +175 -0
- riff/stream.py +87 -0
- riff/sync.py +146 -0
- riff/tui.py +229 -0
- riff/utils.py +128 -0
- riff/ytmusic.py +148 -0
- riff_player-2.0.0.dist-info/METADATA +244 -0
- riff_player-2.0.0.dist-info/RECORD +22 -0
- riff_player-2.0.0.dist-info/WHEEL +5 -0
- riff_player-2.0.0.dist-info/entry_points.txt +2 -0
- riff_player-2.0.0.dist-info/licenses/LICENSE +21 -0
- riff_player-2.0.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
riff/__init__.py
ADDED
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()
|