cinnamon-cli 0.2.19__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.
- cinnamon/__init__.py +1 -0
- cinnamon/anilist.py +33 -0
- cinnamon/cli.py +1822 -0
- cinnamon/config.py +121 -0
- cinnamon/downloads.py +83 -0
- cinnamon/errors.py +145 -0
- cinnamon/history.py +66 -0
- cinnamon/player.py +550 -0
- cinnamon/scrapers/__init__.py +97 -0
- cinnamon/scrapers/anime.py +196 -0
- cinnamon/scrapers/base.py +34 -0
- cinnamon/scrapers/torrentio.py +123 -0
- cinnamon/scrapers/vidsrc.py +131 -0
- cinnamon/scrapers/webstream.py +282 -0
- cinnamon/tmdb.py +83 -0
- cinnamon/tui.py +543 -0
- cinnamon_cli-0.2.19.dist-info/METADATA +166 -0
- cinnamon_cli-0.2.19.dist-info/RECORD +22 -0
- cinnamon_cli-0.2.19.dist-info/WHEEL +5 -0
- cinnamon_cli-0.2.19.dist-info/entry_points.txt +2 -0
- cinnamon_cli-0.2.19.dist-info/licenses/LICENSE +38 -0
- cinnamon_cli-0.2.19.dist-info/top_level.txt +1 -0
cinnamon/cli.py
ADDED
|
@@ -0,0 +1,1822 @@
|
|
|
1
|
+
import concurrent.futures
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
import webbrowser
|
|
6
|
+
from functools import wraps
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import questionary
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.prompt import Confirm, Prompt
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .config import (
|
|
18
|
+
get_ensured_dirs,
|
|
19
|
+
get_scraper_config,
|
|
20
|
+
get_theme,
|
|
21
|
+
get_tmdb_api_key,
|
|
22
|
+
load_config,
|
|
23
|
+
save_config,
|
|
24
|
+
set_scraper_config,
|
|
25
|
+
THEMES,
|
|
26
|
+
)
|
|
27
|
+
from .errors import (
|
|
28
|
+
CinnamonError,
|
|
29
|
+
MissingAPIKey,
|
|
30
|
+
PlayerLaunchError,
|
|
31
|
+
PlayerNotFoundError,
|
|
32
|
+
ScraperError,
|
|
33
|
+
TMDBAuthError,
|
|
34
|
+
TMDBConnectionError,
|
|
35
|
+
TMDBError,
|
|
36
|
+
TMDBNotFoundError,
|
|
37
|
+
TMDBRateLimitError,
|
|
38
|
+
)
|
|
39
|
+
from .player import download_video, play, ytdlp_install_hint
|
|
40
|
+
from .scrapers import get_scraper, list_scrapers
|
|
41
|
+
from .tmdb import TMDBClient
|
|
42
|
+
|
|
43
|
+
console = Console()
|
|
44
|
+
|
|
45
|
+
RESOLVE_TIMEOUT = 90
|
|
46
|
+
|
|
47
|
+
_ANIME_GENRE_ID = 16
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_anime(show):
|
|
51
|
+
origin = show.get("origin_country") or []
|
|
52
|
+
if "JP" not in origin:
|
|
53
|
+
return False
|
|
54
|
+
if show.get("original_language") == "ja":
|
|
55
|
+
return True
|
|
56
|
+
ids = show.get("genre_ids") or [g["id"] for g in show.get("genres", []) if "id" in g]
|
|
57
|
+
if _ANIME_GENRE_ID in ids:
|
|
58
|
+
return True
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# helpers
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _print_error(title, detail=None):
|
|
68
|
+
t = get_theme()
|
|
69
|
+
if detail:
|
|
70
|
+
console.print(Panel(f"[{t['error']}]{title}[/{t['error']}]\n[{t['dim']}]{detail}[/{t['dim']}]", border_style=t["error"]))
|
|
71
|
+
else:
|
|
72
|
+
console.print(Panel(f"[{t['error']}]{title}[/{t['error']}]", border_style=t["error"]))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _print_success(title, detail=None):
|
|
76
|
+
t = get_theme()
|
|
77
|
+
if detail:
|
|
78
|
+
console.print(Panel(f"[{t['success']}]{title}[/{t['success']}]\n[{t['dim']}]{detail}[/{t['dim']}]", border_style=t["success"]))
|
|
79
|
+
else:
|
|
80
|
+
console.print(Panel(f"[{t['success']}]{title}[/{t['success']}]", border_style=t["success"]))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _print_info(title, detail=None):
|
|
84
|
+
t = get_theme()
|
|
85
|
+
if detail:
|
|
86
|
+
console.print(Panel(f"[{t['info']}]{title}[/{t['info']}]\n[{t['dim']}]{detail}[/{t['dim']}]", border_style=t["info"]))
|
|
87
|
+
else:
|
|
88
|
+
console.print(Panel(f"[{t['info']}]{title}[/{t['info']}]", border_style=t["info"]))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# interactive selection UI
|
|
93
|
+
#
|
|
94
|
+
# One focus point: the highlighted row (class:highlighted) is bright, every
|
|
95
|
+
# other row (class:text) is dimmed grey, and the question line (class:question)
|
|
96
|
+
# is the anchor. All pickers route through _select so the look is consistent.
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
_POINTER = "❯"
|
|
100
|
+
|
|
101
|
+
# prompt_toolkit needs hex colors, not rich color names. Map the theme accents
|
|
102
|
+
# to hex so the picker's focus color matches the active theme.
|
|
103
|
+
_Q_HEX = {
|
|
104
|
+
"orange1": "#ffaf00",
|
|
105
|
+
"deep_sky_blue1": "#00afff",
|
|
106
|
+
"blue": "#0000ff",
|
|
107
|
+
"white": "#ffffff",
|
|
108
|
+
"bright_white": "#ffffff",
|
|
109
|
+
"grey50": "#7f7f7f",
|
|
110
|
+
"grey35": "#595959",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _q_hex(name):
|
|
115
|
+
return _Q_HEX.get(name, "#ffaf00")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _q_style():
|
|
119
|
+
"""questionary Style that makes the focused choice the clear focus point."""
|
|
120
|
+
t = get_theme()
|
|
121
|
+
accent = _q_hex(t.get("accent", "orange1"))
|
|
122
|
+
dim = _q_hex(t.get("dim", "grey50"))
|
|
123
|
+
return questionary.Style(
|
|
124
|
+
[
|
|
125
|
+
("qmark", f"fg:{accent}"),
|
|
126
|
+
("question", f"bold fg:{accent}"),
|
|
127
|
+
("pointer", f"bold fg:{accent}"),
|
|
128
|
+
("highlighted", f"bold fg:{accent}"),
|
|
129
|
+
("text", f"fg:{dim}"),
|
|
130
|
+
("separator", f"fg:{dim}"),
|
|
131
|
+
("instruction", f"fg:{dim}"),
|
|
132
|
+
("answer", f"bold fg:{accent}"),
|
|
133
|
+
]
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _select(message, choices, default=None, **kwargs):
|
|
138
|
+
"""questionary.select with cinnamon's focus style applied."""
|
|
139
|
+
kwargs.setdefault("pointer", _POINTER)
|
|
140
|
+
try:
|
|
141
|
+
return questionary.select(
|
|
142
|
+
message, choices=choices, default=default, style=_q_style(), **kwargs
|
|
143
|
+
).unsafe_ask()
|
|
144
|
+
except Exception:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _prompt(message, default=None, password=False):
|
|
149
|
+
"""rich Prompt.ask that degrades gracefully when stdin is closed (EOF) or
|
|
150
|
+
not a TTY — returns the default instead of aborting the whole command.
|
|
151
|
+
|
|
152
|
+
This is what keeps the CLI from crashing under automation, Termux, or a
|
|
153
|
+
closed pipe (where Prompt.ask raises EOFError and click turns it into a
|
|
154
|
+
hard SystemExit)."""
|
|
155
|
+
try:
|
|
156
|
+
return Prompt.ask(message, default=default, password=password)
|
|
157
|
+
except (EOFError, KeyboardInterrupt):
|
|
158
|
+
return default
|
|
159
|
+
except Exception:
|
|
160
|
+
return default
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
_UPDATE_CHECK_CACHE = 86400 # 24 hours
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
_UPDATE_REPO = "pizza-droid/cinnamon"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
_UPDATE_CHECK_FALLBACK_DAYS = 7
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _latest_version():
|
|
173
|
+
import requests
|
|
174
|
+
|
|
175
|
+
# Primary: GitHub Tags API (includes lightweight tags, not just releases).
|
|
176
|
+
try:
|
|
177
|
+
resp = requests.get(
|
|
178
|
+
f"https://api.github.com/repos/{_UPDATE_REPO}/tags",
|
|
179
|
+
timeout=8,
|
|
180
|
+
headers={"Accept": "application/vnd.github+json"},
|
|
181
|
+
)
|
|
182
|
+
if resp.status_code == 200:
|
|
183
|
+
tags = resp.json()
|
|
184
|
+
if tags:
|
|
185
|
+
tag = tags[0].get("name", "")
|
|
186
|
+
if tag:
|
|
187
|
+
return tag[1:] if tag.startswith("v") else tag
|
|
188
|
+
except Exception:
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
# Fallback: parse version from pyproject.toml on the default branch.
|
|
192
|
+
try:
|
|
193
|
+
resp = requests.get(
|
|
194
|
+
f"https://raw.githubusercontent.com/{_UPDATE_REPO}/master/pyproject.toml",
|
|
195
|
+
timeout=8,
|
|
196
|
+
)
|
|
197
|
+
if resp.status_code == 200:
|
|
198
|
+
for line in resp.text.splitlines():
|
|
199
|
+
line = line.strip()
|
|
200
|
+
if line.startswith("version = "):
|
|
201
|
+
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
|
202
|
+
except Exception:
|
|
203
|
+
pass
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _check_for_updates():
|
|
208
|
+
try:
|
|
209
|
+
cfg = load_config()
|
|
210
|
+
last_check = cfg.get("_update_check", 0)
|
|
211
|
+
if time.time() - last_check < _UPDATE_CHECK_CACHE:
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
latest = _latest_version()
|
|
215
|
+
if not latest:
|
|
216
|
+
# Network failed — don't cache, retry next run.
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
current = __version__
|
|
220
|
+
try:
|
|
221
|
+
cur = tuple(map(int, current.split(".")))
|
|
222
|
+
lat = tuple(map(int, latest.split(".")))
|
|
223
|
+
except ValueError:
|
|
224
|
+
cfg["_update_check"] = time.time()
|
|
225
|
+
save_config(cfg)
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
if lat <= cur:
|
|
229
|
+
# Up to date: record check so we don't nag for a while.
|
|
230
|
+
cfg["_update_check"] = time.time()
|
|
231
|
+
save_config(cfg)
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
# Update available: remind every run (don't cache the timestamp).
|
|
235
|
+
t = get_theme()
|
|
236
|
+
console.print(
|
|
237
|
+
f" [{t['info']}]Update available:[/] {current} → [bold]{latest}[/] "
|
|
238
|
+
f"[dim](run[/dim] [cyan]cinnamon update[/cyan][dim])[/dim]"
|
|
239
|
+
)
|
|
240
|
+
except Exception:
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _get_tmdb():
|
|
245
|
+
try:
|
|
246
|
+
return TMDBClient()
|
|
247
|
+
except MissingAPIKey:
|
|
248
|
+
_print_error(
|
|
249
|
+
"TMDB API key not configured.",
|
|
250
|
+
'Run "cinnamon setup" for a step-by-step guide to get a free key.',
|
|
251
|
+
)
|
|
252
|
+
raise SystemExit(1)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _handle_tmdb_error(fn):
|
|
256
|
+
@wraps(fn)
|
|
257
|
+
def wrapper(*a, **kw):
|
|
258
|
+
try:
|
|
259
|
+
return fn(*a, **kw)
|
|
260
|
+
except TMDBAuthError as e:
|
|
261
|
+
_print_error(str(e))
|
|
262
|
+
except TMDBRateLimitError:
|
|
263
|
+
_print_error("TMDB rate limit reached. Wait a moment and try again.")
|
|
264
|
+
except TMDBConnectionError as e:
|
|
265
|
+
_print_error(str(e))
|
|
266
|
+
except TMDBNotFoundError as e:
|
|
267
|
+
_print_error(str(e))
|
|
268
|
+
except TMDBError as e:
|
|
269
|
+
_print_error(str(e))
|
|
270
|
+
except MissingAPIKey:
|
|
271
|
+
_get_tmdb()
|
|
272
|
+
|
|
273
|
+
return wrapper
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _pick_combined(items, message):
|
|
277
|
+
"""Picker for mixed TV/movie results; tags each entry with its type."""
|
|
278
|
+
if not items:
|
|
279
|
+
return None
|
|
280
|
+
try:
|
|
281
|
+
choices = []
|
|
282
|
+
for item in items:
|
|
283
|
+
mtype = item.get("_media_type", "tv")
|
|
284
|
+
title = item.get("title") or item.get("name") or "?"
|
|
285
|
+
date = item.get("release_date") or item.get("first_air_date") or ""
|
|
286
|
+
year = (date or "")[:4]
|
|
287
|
+
tag = "Movie" if mtype == "movie" else "TV"
|
|
288
|
+
label = f"{title} ({year}) [{tag}]"
|
|
289
|
+
choices.append(questionary.Choice(title=label, value=item))
|
|
290
|
+
return _select(message, choices)
|
|
291
|
+
except Exception:
|
|
292
|
+
# Fallback to a numbered table
|
|
293
|
+
table = Table(border_style="dim")
|
|
294
|
+
table.add_column("#", style="cyan")
|
|
295
|
+
table.add_column("Title", style="white")
|
|
296
|
+
table.add_column("Year", style="green")
|
|
297
|
+
table.add_column("Type", style="yellow")
|
|
298
|
+
for i, item in enumerate(items, 1):
|
|
299
|
+
mtype = item.get("_media_type", "tv")
|
|
300
|
+
title = item.get("title") or item.get("name") or "?"
|
|
301
|
+
date = item.get("release_date") or item.get("first_air_date") or ""
|
|
302
|
+
table.add_row(str(i), title, (date or "")[:4], "Movie" if mtype == "movie" else "TV")
|
|
303
|
+
console.print(table)
|
|
304
|
+
attempts = 0
|
|
305
|
+
while True:
|
|
306
|
+
try:
|
|
307
|
+
choice = int(_prompt(f"{message} (enter number)", default="1"))
|
|
308
|
+
if 1 <= choice <= len(items):
|
|
309
|
+
return items[choice - 1]
|
|
310
|
+
console.print(f"[red]Pick a number between 1 and {len(items)}.[/red]")
|
|
311
|
+
except ValueError:
|
|
312
|
+
console.print("[red]Invalid input. Enter a number.[/red]")
|
|
313
|
+
attempts += 1
|
|
314
|
+
if attempts >= 10:
|
|
315
|
+
console.print("[red]Too many invalid attempts.[/red]")
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _pick_with_arrows(items, title_key, subtitle_key, message):
|
|
320
|
+
if not items:
|
|
321
|
+
return None
|
|
322
|
+
try:
|
|
323
|
+
choices = []
|
|
324
|
+
for item in items:
|
|
325
|
+
if callable(title_key):
|
|
326
|
+
title = str(title_key(item) or "?")
|
|
327
|
+
else:
|
|
328
|
+
title = str(item.get(title_key, "?") or "?")
|
|
329
|
+
subtitle = ""
|
|
330
|
+
if subtitle_key:
|
|
331
|
+
if callable(subtitle_key):
|
|
332
|
+
subtitle = str(subtitle_key(item) or "")
|
|
333
|
+
else:
|
|
334
|
+
subtitle = str(item.get(subtitle_key, "") or "")
|
|
335
|
+
label = title if not subtitle else f"{title} ({subtitle})"
|
|
336
|
+
choices.append(questionary.Choice(title=label, value=item))
|
|
337
|
+
return _select(message, choices)
|
|
338
|
+
except Exception:
|
|
339
|
+
return _pick_numbered(items, title_key, subtitle_key, message)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _pick_numbered(items, title_key, subtitle_key, message):
|
|
343
|
+
if not items:
|
|
344
|
+
return None
|
|
345
|
+
table = Table(border_style="dim")
|
|
346
|
+
table.add_column("#", style="cyan", no_wrap=True)
|
|
347
|
+
table.add_column("Title", style="white")
|
|
348
|
+
if subtitle_key:
|
|
349
|
+
table.add_column(subtitle_key.capitalize() if isinstance(subtitle_key, str) else "", style="green")
|
|
350
|
+
|
|
351
|
+
for i, item in enumerate(items, 1):
|
|
352
|
+
if callable(title_key):
|
|
353
|
+
title = str(title_key(item) or "?")
|
|
354
|
+
else:
|
|
355
|
+
title = str(item.get(title_key, "?") or "?")
|
|
356
|
+
row = [str(i), title]
|
|
357
|
+
if subtitle_key:
|
|
358
|
+
if callable(subtitle_key):
|
|
359
|
+
row.append(str(subtitle_key(item) or ""))
|
|
360
|
+
else:
|
|
361
|
+
row.append(str(item.get(subtitle_key, "") or ""))
|
|
362
|
+
table.add_row(*row)
|
|
363
|
+
|
|
364
|
+
console.print(table)
|
|
365
|
+
attempts = 0
|
|
366
|
+
while True:
|
|
367
|
+
try:
|
|
368
|
+
choice = int(_prompt(f"{message} (enter number)", default="1"))
|
|
369
|
+
if 1 <= choice <= len(items):
|
|
370
|
+
return items[choice - 1]
|
|
371
|
+
console.print(f"[red]Pick a number between 1 and {len(items)}.[/red]")
|
|
372
|
+
except ValueError:
|
|
373
|
+
console.print("[red]Invalid input. Enter a number.[/red]")
|
|
374
|
+
attempts += 1
|
|
375
|
+
if attempts >= 10:
|
|
376
|
+
console.print("[red]Too many invalid attempts.[/red]")
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _parse_episode(ep_str):
|
|
381
|
+
"""Parse episode string. Returns (start, end) or (None, None)."""
|
|
382
|
+
if not ep_str:
|
|
383
|
+
return None, None
|
|
384
|
+
parts = str(ep_str).split("-", 1)
|
|
385
|
+
try:
|
|
386
|
+
start = int(parts[0])
|
|
387
|
+
except ValueError:
|
|
388
|
+
return None, None
|
|
389
|
+
if len(parts) == 2:
|
|
390
|
+
try:
|
|
391
|
+
end = int(parts[1])
|
|
392
|
+
except ValueError:
|
|
393
|
+
return None, None
|
|
394
|
+
if end < start:
|
|
395
|
+
return None, None
|
|
396
|
+
return start, end
|
|
397
|
+
return start, None
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _play_with_menu(show, season_num, ep_start, ep_end, ep_name, scraper, player, quality, info_only, download=False, translation=None):
|
|
401
|
+
"""Play episode(s), then show interactive menu until user quits."""
|
|
402
|
+
|
|
403
|
+
from .history import set_history as _set_history
|
|
404
|
+
|
|
405
|
+
def _save_history(ep):
|
|
406
|
+
_set_history(show.get("name", "?"), season_num, ep, scraper=scraper, translation=translation, quality=quality)
|
|
407
|
+
|
|
408
|
+
if ep_end is not None and ep_end > ep_start:
|
|
409
|
+
if not download:
|
|
410
|
+
_print_error("Episode ranges are only supported with --download.")
|
|
411
|
+
return
|
|
412
|
+
from .downloads import create as _track_create
|
|
413
|
+
show_name = show.get("name", "?")
|
|
414
|
+
range_track_id = _track_create({
|
|
415
|
+
"title": show_name,
|
|
416
|
+
"url": "",
|
|
417
|
+
"tv_id": show.get("id", 0),
|
|
418
|
+
"season": season_num,
|
|
419
|
+
"episode": f"{ep_start}-{ep_end}",
|
|
420
|
+
"quality": quality or "",
|
|
421
|
+
"referer": "",
|
|
422
|
+
"scraper": scraper,
|
|
423
|
+
"translation": translation or "",
|
|
424
|
+
})
|
|
425
|
+
from .downloads import update as _track_update
|
|
426
|
+
try:
|
|
427
|
+
for ep_num in range(ep_start, ep_end + 1):
|
|
428
|
+
ep_name = f"S{season_num:02d}E{ep_num:02d}"
|
|
429
|
+
_resolve_and_play(show, season_num, ep_num, ep_name, scraper, player, quality, info_only, download, translation=translation, range_track_id=range_track_id)
|
|
430
|
+
_save_history(ep_num)
|
|
431
|
+
console.print()
|
|
432
|
+
except KeyboardInterrupt:
|
|
433
|
+
_track_update(range_track_id, status="interrupted")
|
|
434
|
+
raise
|
|
435
|
+
_track_update(range_track_id, status="completed")
|
|
436
|
+
return
|
|
437
|
+
|
|
438
|
+
ep_num = ep_start
|
|
439
|
+
while True:
|
|
440
|
+
proc = _resolve_and_play(show, season_num, ep_num, ep_name, scraper, player, quality, info_only, download, translation=translation)
|
|
441
|
+
|
|
442
|
+
if proc is None or info_only or download:
|
|
443
|
+
return
|
|
444
|
+
|
|
445
|
+
try:
|
|
446
|
+
proc.wait()
|
|
447
|
+
except AttributeError:
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
_save_history(ep_num)
|
|
451
|
+
|
|
452
|
+
theme = get_theme()
|
|
453
|
+
console.print()
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
choice = _select(
|
|
457
|
+
"Options",
|
|
458
|
+
choices=[
|
|
459
|
+
questionary.Choice(title="Next episode", value="next"),
|
|
460
|
+
questionary.Choice(title="Previous episode", value="prev"),
|
|
461
|
+
questionary.Choice(title="Replay", value="replay"),
|
|
462
|
+
questionary.Choice(title="Change quality", value="quality"),
|
|
463
|
+
questionary.Choice(title="Quit", value="quit"),
|
|
464
|
+
],
|
|
465
|
+
)
|
|
466
|
+
except Exception:
|
|
467
|
+
return
|
|
468
|
+
|
|
469
|
+
if not choice or choice == "quit":
|
|
470
|
+
return
|
|
471
|
+
elif choice == "next":
|
|
472
|
+
ep_num += 1
|
|
473
|
+
ep_name = f"S{season_num:02d}E{ep_num:02d}"
|
|
474
|
+
elif choice == "prev":
|
|
475
|
+
ep_num = max(1, ep_num - 1)
|
|
476
|
+
ep_name = f"S{season_num:02d}E{ep_num:02d}"
|
|
477
|
+
elif choice == "replay":
|
|
478
|
+
pass
|
|
479
|
+
elif choice == "quality":
|
|
480
|
+
try:
|
|
481
|
+
quality = _select(
|
|
482
|
+
"Quality",
|
|
483
|
+
choices=["480p", "720p", "1080p", "best", "worst"],
|
|
484
|
+
default=quality or "best",
|
|
485
|
+
)
|
|
486
|
+
except Exception:
|
|
487
|
+
quality = Prompt.ask("Quality (480p, 720p, 1080p, best, worst)", default=quality or "best")
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _resolve_and_play(show, season_num, ep_num, ep_name, scraper, player, quality, info_only, download=False, translation=None, range_track_id=None):
|
|
491
|
+
show_name = show.get("name", "?")
|
|
492
|
+
show_id = show["id"]
|
|
493
|
+
|
|
494
|
+
config = load_config()
|
|
495
|
+
scraper_name = scraper or config.get("default_scraper", "example")
|
|
496
|
+
scraper_instance = get_scraper(scraper_name)
|
|
497
|
+
if not scraper_instance:
|
|
498
|
+
available = [s["name"] for s in list_scrapers()]
|
|
499
|
+
_print_error(
|
|
500
|
+
f"Unknown scraper: [bold]{scraper_name}[/bold]",
|
|
501
|
+
f"Available: {', '.join(available)}",
|
|
502
|
+
)
|
|
503
|
+
return None
|
|
504
|
+
|
|
505
|
+
# Fallback chain: try the chosen scraper, then other builtin scrapers.
|
|
506
|
+
# Only auto-fallback when no scraper was explicitly requested.
|
|
507
|
+
builtins = [s["name"] for s in list_scrapers() if not s.get("optional")]
|
|
508
|
+
if scraper:
|
|
509
|
+
fallback_names = [scraper_name]
|
|
510
|
+
else:
|
|
511
|
+
fallback_names = [scraper_name] + [n for n in builtins if n != scraper_name]
|
|
512
|
+
|
|
513
|
+
console.print()
|
|
514
|
+
|
|
515
|
+
last_error = None
|
|
516
|
+
for attempt_name in fallback_names:
|
|
517
|
+
attempt = get_scraper(attempt_name)
|
|
518
|
+
if not attempt:
|
|
519
|
+
continue
|
|
520
|
+
try:
|
|
521
|
+
with console.status(f"Resolving via [bold]{attempt_name}[/bold]... (timeout {RESOLVE_TIMEOUT}s)", spinner="dots"):
|
|
522
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
523
|
+
info = {"show": show_name, "tv_id": show_id, "season": season_num, "episode": ep_num}
|
|
524
|
+
if quality:
|
|
525
|
+
info["quality"] = quality
|
|
526
|
+
if translation:
|
|
527
|
+
info["translation"] = translation
|
|
528
|
+
future = pool.submit(attempt.resolve, info)
|
|
529
|
+
result = future.result(timeout=RESOLVE_TIMEOUT)
|
|
530
|
+
if result:
|
|
531
|
+
scraper_name = attempt_name
|
|
532
|
+
break
|
|
533
|
+
last_error = f"Scraper [bold]{attempt_name}[/bold] returned nothing."
|
|
534
|
+
except concurrent.futures.TimeoutError:
|
|
535
|
+
last_error = f"Scraper [bold]{attempt_name}[/bold] timed out after {RESOLVE_TIMEOUT}s."
|
|
536
|
+
except ScraperError as e:
|
|
537
|
+
last_error = str(e)
|
|
538
|
+
continue
|
|
539
|
+
else:
|
|
540
|
+
if last_error:
|
|
541
|
+
_print_error(last_error)
|
|
542
|
+
else:
|
|
543
|
+
_print_error(f"No streaming source found for {show_name} {ep_name}.")
|
|
544
|
+
return None
|
|
545
|
+
|
|
546
|
+
if not result:
|
|
547
|
+
_print_error(f"Scraper [bold]{scraper_name}[/bold] returned nothing.")
|
|
548
|
+
return None
|
|
549
|
+
|
|
550
|
+
theme = get_theme()
|
|
551
|
+
console.clear()
|
|
552
|
+
console.print(Panel(
|
|
553
|
+
f"[{theme['success']}]Stream ready![/{theme['success']}] [bold]{result.title}[/bold]",
|
|
554
|
+
border_style=theme["success"],
|
|
555
|
+
))
|
|
556
|
+
|
|
557
|
+
if info_only:
|
|
558
|
+
console.print(f" [{theme['dim']}]URL:[/{theme['dim']}] {result.m3u8_url}")
|
|
559
|
+
return None
|
|
560
|
+
|
|
561
|
+
if download:
|
|
562
|
+
if result.m3u8_url.startswith("magnet:"):
|
|
563
|
+
_print_error("Download not supported for magnet links.")
|
|
564
|
+
return None
|
|
565
|
+
if range_track_id:
|
|
566
|
+
track_id = range_track_id
|
|
567
|
+
else:
|
|
568
|
+
from .downloads import create as _track_create
|
|
569
|
+
track_id = _track_create({
|
|
570
|
+
"title": result.title,
|
|
571
|
+
"url": result.m3u8_url,
|
|
572
|
+
"tv_id": show_id,
|
|
573
|
+
"season": season_num,
|
|
574
|
+
"episode": ep_num,
|
|
575
|
+
"quality": quality,
|
|
576
|
+
"referer": result.referer,
|
|
577
|
+
})
|
|
578
|
+
try:
|
|
579
|
+
download_video(result.m3u8_url, title=result.title, referer=result.referer, track_id=track_id)
|
|
580
|
+
except PlayerNotFoundError:
|
|
581
|
+
_print_error(f"yt-dlp not found. Install it with: {ytdlp_install_hint()}")
|
|
582
|
+
from .downloads import remove as _track_remove
|
|
583
|
+
_track_remove(track_id)
|
|
584
|
+
except PlayerLaunchError as e:
|
|
585
|
+
_print_error(str(e))
|
|
586
|
+
from .downloads import update as _track_update
|
|
587
|
+
_track_update(track_id, status="error")
|
|
588
|
+
except KeyboardInterrupt:
|
|
589
|
+
raise
|
|
590
|
+
return None
|
|
591
|
+
|
|
592
|
+
player_choice = player or config.get("default_player", "auto")
|
|
593
|
+
try:
|
|
594
|
+
if result.m3u8_url.startswith("magnet:"):
|
|
595
|
+
console.print(f" [{theme['info']}]Connecting to torrent swarm...[/{theme['info']}]")
|
|
596
|
+
play(result.m3u8_url, title=result.title, player=player_choice, season=season_num, episode=ep_num, referer=result.referer)
|
|
597
|
+
return None # magnets: can't wait for process
|
|
598
|
+
else:
|
|
599
|
+
console.print(f" [{theme['info']}]Opening in {player_choice.upper()}...[/{theme['info']}]")
|
|
600
|
+
return play(result.m3u8_url, title=result.title, player=player_choice, season=season_num, episode=ep_num, referer=result.referer)
|
|
601
|
+
except PlayerNotFoundError as e:
|
|
602
|
+
_print_error(str(e))
|
|
603
|
+
return None
|
|
604
|
+
except Exception as e:
|
|
605
|
+
_print_error("Failed to launch player.", str(e))
|
|
606
|
+
return None
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _play_movie(show, scraper, player, quality, info_only, download=False):
|
|
610
|
+
"""Resolve and play a movie (single stream, no season/episode)."""
|
|
611
|
+
from .history import set_history as _set_history
|
|
612
|
+
|
|
613
|
+
show_name = show.get("title", "?")
|
|
614
|
+
show_id = show["id"]
|
|
615
|
+
|
|
616
|
+
config = load_config()
|
|
617
|
+
scraper_name = scraper or config.get("default_scraper", "webstream")
|
|
618
|
+
scraper_instance = get_scraper(scraper_name)
|
|
619
|
+
if not scraper_instance:
|
|
620
|
+
available = [s["name"] for s in list_scrapers()]
|
|
621
|
+
_print_error(
|
|
622
|
+
f"Unknown scraper: [bold]{scraper_name}[/bold]",
|
|
623
|
+
f"Available: {', '.join(available)}",
|
|
624
|
+
)
|
|
625
|
+
return
|
|
626
|
+
|
|
627
|
+
console.print()
|
|
628
|
+
try:
|
|
629
|
+
with console.status(f"Resolving via [bold]{scraper_name}[/bold]... (timeout {RESOLVE_TIMEOUT}s)", spinner="dots"):
|
|
630
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
631
|
+
info = {"show": show_name, "movie_id": show_id, "media_type": "movie"}
|
|
632
|
+
if quality:
|
|
633
|
+
info["quality"] = quality
|
|
634
|
+
future = pool.submit(scraper_instance.resolve, info)
|
|
635
|
+
result = future.result(timeout=RESOLVE_TIMEOUT)
|
|
636
|
+
except concurrent.futures.TimeoutError:
|
|
637
|
+
_print_error(f"Scraper [bold]{scraper_name}[/bold] timed out after {RESOLVE_TIMEOUT}s.")
|
|
638
|
+
return
|
|
639
|
+
except ScraperError as e:
|
|
640
|
+
_print_error(str(e))
|
|
641
|
+
return
|
|
642
|
+
|
|
643
|
+
if not result:
|
|
644
|
+
_print_error(f"Scraper [bold]{scraper_name}[/bold] returned nothing.")
|
|
645
|
+
return
|
|
646
|
+
|
|
647
|
+
theme = get_theme()
|
|
648
|
+
console.clear()
|
|
649
|
+
console.print(Panel(
|
|
650
|
+
f"[{theme['success']}]Stream ready![/{theme['success']}] [bold]{result.title}[/bold]",
|
|
651
|
+
border_style=theme["success"],
|
|
652
|
+
))
|
|
653
|
+
|
|
654
|
+
_set_history(show_name, None, None, scraper=scraper_name, quality=quality)
|
|
655
|
+
|
|
656
|
+
if info_only:
|
|
657
|
+
console.print(f" [{theme['dim']}]URL:[/{theme['dim']}] {result.m3u8_url}")
|
|
658
|
+
return
|
|
659
|
+
|
|
660
|
+
if download:
|
|
661
|
+
if result.m3u8_url.startswith("magnet:"):
|
|
662
|
+
_print_error("Download not supported for torrent/magnet links.")
|
|
663
|
+
return
|
|
664
|
+
from .downloads import create as _track_create
|
|
665
|
+
from .downloads import remove as _track_remove
|
|
666
|
+
from .downloads import update as _track_update
|
|
667
|
+
track_id = _track_create({
|
|
668
|
+
"title": result.title,
|
|
669
|
+
"url": result.m3u8_url,
|
|
670
|
+
"tv_id": show_id,
|
|
671
|
+
"season": None,
|
|
672
|
+
"episode": None,
|
|
673
|
+
"quality": quality or "",
|
|
674
|
+
"referer": result.referer,
|
|
675
|
+
"scraper": scraper_name,
|
|
676
|
+
})
|
|
677
|
+
try:
|
|
678
|
+
download_video(result.m3u8_url, title=result.title, referer=result.referer, track_id=track_id)
|
|
679
|
+
except PlayerNotFoundError:
|
|
680
|
+
_print_error(f"yt-dlp not found. Install it with: {ytdlp_install_hint()}")
|
|
681
|
+
_track_remove(track_id)
|
|
682
|
+
except PlayerLaunchError as e:
|
|
683
|
+
_print_error(str(e))
|
|
684
|
+
_track_update(track_id, status="error")
|
|
685
|
+
except KeyboardInterrupt:
|
|
686
|
+
raise
|
|
687
|
+
return
|
|
688
|
+
|
|
689
|
+
player_choice = player or config.get("default_player", "auto")
|
|
690
|
+
try:
|
|
691
|
+
console.print(f" [{theme['info']}]Opening in {player_choice.upper()}...[/{theme['info']}]")
|
|
692
|
+
play(result.m3u8_url, title=result.title, player=player_choice, referer=result.referer)
|
|
693
|
+
except PlayerNotFoundError as e:
|
|
694
|
+
_print_error(str(e))
|
|
695
|
+
except Exception as e:
|
|
696
|
+
_print_error("Failed to launch player.", str(e))
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
# ---------------------------------------------------------------------------
|
|
700
|
+
# custom group (allows `cinnamon <query>` as shortcut for search)
|
|
701
|
+
# ---------------------------------------------------------------------------
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
class CinnamonGroup(click.Group):
|
|
705
|
+
def resolve_command(self, ctx, args):
|
|
706
|
+
if not args:
|
|
707
|
+
return super().resolve_command(ctx, args)
|
|
708
|
+
parent = super()
|
|
709
|
+
cmd = parent.get_command(ctx, args[0])
|
|
710
|
+
if cmd is not None:
|
|
711
|
+
return super().resolve_command(ctx, args)
|
|
712
|
+
cmd = parent.get_command(ctx, "search")
|
|
713
|
+
return "search", cmd, args
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
# ---------------------------------------------------------------------------
|
|
717
|
+
# CLI
|
|
718
|
+
# ---------------------------------------------------------------------------
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
@click.group(cls=CinnamonGroup, invoke_without_command=True)
|
|
722
|
+
@click.option("--setup", is_flag=True, help="Run the setup wizard")
|
|
723
|
+
@click.version_option(__version__, "--version", "-V")
|
|
724
|
+
@click.pass_context
|
|
725
|
+
def cli(ctx, setup):
|
|
726
|
+
"""Cinnamon - Stream TV shows, movies & anime from the command line."""
|
|
727
|
+
_check_for_updates()
|
|
728
|
+
if setup:
|
|
729
|
+
ctx.invoke(setup_cmd)
|
|
730
|
+
return
|
|
731
|
+
if ctx.invoked_subcommand is None:
|
|
732
|
+
if not get_tmdb_api_key():
|
|
733
|
+
console.clear()
|
|
734
|
+
console.print(Panel.fit(
|
|
735
|
+
"[bold orange1]Welcome to Cinnamon![/bold orange1]",
|
|
736
|
+
border_style="bright_yellow",
|
|
737
|
+
))
|
|
738
|
+
console.print()
|
|
739
|
+
if Confirm.ask("No API key found. Run setup?", default=True):
|
|
740
|
+
ctx.invoke(setup_cmd)
|
|
741
|
+
return
|
|
742
|
+
theme = get_theme()
|
|
743
|
+
console.clear()
|
|
744
|
+
console.print(Panel.fit(
|
|
745
|
+
f"[bold {theme['accent']}]Cinna[/bold {theme['accent']}][bold]mon[/bold] [dim]v{__version__}[/dim]",
|
|
746
|
+
border_style=theme["panel"],
|
|
747
|
+
))
|
|
748
|
+
console.print()
|
|
749
|
+
query = _prompt(f"[bold]Search for a[/bold] [{theme['info']}]show or movie[/{theme['info']}]")
|
|
750
|
+
if query and query.strip():
|
|
751
|
+
ctx.invoke(search, query=(query.strip(),))
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
# ---------------------------------------------------------------------------
|
|
755
|
+
# setup
|
|
756
|
+
# ---------------------------------------------------------------------------
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
@cli.command("setup")
|
|
760
|
+
def setup_cmd():
|
|
761
|
+
"""Run the setup wizard."""
|
|
762
|
+
_setup_wizard()
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _setup_wizard():
|
|
766
|
+
cfg = load_config()
|
|
767
|
+
theme = get_theme()
|
|
768
|
+
|
|
769
|
+
console.clear()
|
|
770
|
+
console.print(Panel.fit(
|
|
771
|
+
"[bold]⚙ Cinnamon Setup[/bold]",
|
|
772
|
+
border_style=theme["panel"],
|
|
773
|
+
))
|
|
774
|
+
console.print()
|
|
775
|
+
|
|
776
|
+
# --- TMDB API Key ---
|
|
777
|
+
existing_key = get_tmdb_api_key()
|
|
778
|
+
if existing_key:
|
|
779
|
+
console.print(f" [{theme['success']}]✓[/] TMDB API key found")
|
|
780
|
+
if not Confirm.ask(" Replace it?", default=False):
|
|
781
|
+
api_key = existing_key
|
|
782
|
+
else:
|
|
783
|
+
api_key = _setup_api_key()
|
|
784
|
+
else:
|
|
785
|
+
api_key = _setup_api_key()
|
|
786
|
+
|
|
787
|
+
if api_key and api_key != existing_key:
|
|
788
|
+
cfg["tmdb_api_key"] = api_key
|
|
789
|
+
save_config(cfg)
|
|
790
|
+
|
|
791
|
+
# --- Default player ---
|
|
792
|
+
console.print()
|
|
793
|
+
try:
|
|
794
|
+
player_choice = _select(
|
|
795
|
+
"Default player:",
|
|
796
|
+
choices=[
|
|
797
|
+
questionary.Choice(title="auto [dim]– detect mpv then VLC[/dim]", value="auto"),
|
|
798
|
+
questionary.Choice(title="vlc", value="vlc"),
|
|
799
|
+
questionary.Choice(title="mpv", value="mpv"),
|
|
800
|
+
],
|
|
801
|
+
default=cfg.get("default_player", "auto"),
|
|
802
|
+
)
|
|
803
|
+
if player_choice:
|
|
804
|
+
cfg["default_player"] = player_choice
|
|
805
|
+
except Exception:
|
|
806
|
+
pass
|
|
807
|
+
|
|
808
|
+
# --- Theme ---
|
|
809
|
+
console.print()
|
|
810
|
+
try:
|
|
811
|
+
theme_names = list(THEMES.keys())
|
|
812
|
+
theme_choice = _select(
|
|
813
|
+
"Color theme:",
|
|
814
|
+
choices=[
|
|
815
|
+
questionary.Choice(
|
|
816
|
+
title=_theme_preview(name),
|
|
817
|
+
value=name,
|
|
818
|
+
) for name in theme_names
|
|
819
|
+
],
|
|
820
|
+
default=cfg.get("theme", "cinnamon"),
|
|
821
|
+
)
|
|
822
|
+
if theme_choice:
|
|
823
|
+
cfg["theme"] = theme_choice
|
|
824
|
+
except Exception:
|
|
825
|
+
pass
|
|
826
|
+
|
|
827
|
+
save_config(cfg)
|
|
828
|
+
theme = get_theme()
|
|
829
|
+
|
|
830
|
+
console.clear()
|
|
831
|
+
console.print(Panel.fit(
|
|
832
|
+
f"[{theme['success']}]✓ Setup complete![/]",
|
|
833
|
+
border_style=theme["panel"],
|
|
834
|
+
))
|
|
835
|
+
console.print(f" [{theme['info']}]cinnamon[/] – quick search")
|
|
836
|
+
console.print(f" [{theme['info']}]cinnamon <show>[/] – search & play")
|
|
837
|
+
console.print(f" [{theme['info']}]cinnamon watch[/] – browse episodes")
|
|
838
|
+
console.print()
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _setup_api_key():
|
|
842
|
+
console.print()
|
|
843
|
+
steps = [
|
|
844
|
+
("1", "Sign up", "https://www.themoviedb.org/signup"),
|
|
845
|
+
("2", "Verify your email"),
|
|
846
|
+
("3", "Visit", "https://www.themoviedb.org/settings/api"),
|
|
847
|
+
("4", 'Click "Create" under "Request an API Key"'),
|
|
848
|
+
("5", 'Choose "Developer"'),
|
|
849
|
+
("6", 'Fill in the form (any app name - e.g. "cinnamon")'),
|
|
850
|
+
("7", 'Copy the "API Key (v3 auth)" value'),
|
|
851
|
+
]
|
|
852
|
+
|
|
853
|
+
for num, action, *rest in steps:
|
|
854
|
+
url = rest[0] if rest else None
|
|
855
|
+
if url:
|
|
856
|
+
console.print(f" [cyan]{num}.[/cyan] {action} [underline]{url}[/underline]")
|
|
857
|
+
else:
|
|
858
|
+
console.print(f" [cyan]{num}.[/cyan] {action}")
|
|
859
|
+
|
|
860
|
+
if Confirm.ask("\nOpen step 1 in your browser?", default=True):
|
|
861
|
+
webbrowser.open("https://www.themoviedb.org/signup")
|
|
862
|
+
|
|
863
|
+
console.print()
|
|
864
|
+
api_key = Prompt.ask("[bold]Paste your TMDB API Key[/bold]")
|
|
865
|
+
if not api_key.strip():
|
|
866
|
+
return None
|
|
867
|
+
|
|
868
|
+
from .config import set_tmdb_api_key
|
|
869
|
+
|
|
870
|
+
set_tmdb_api_key(api_key.strip())
|
|
871
|
+
try:
|
|
872
|
+
TMDBClient().search_tv("test")
|
|
873
|
+
console.print(f" [green]✓ API key verified![/green]")
|
|
874
|
+
return api_key.strip()
|
|
875
|
+
except CinnamonError:
|
|
876
|
+
console.print(f" [red]✗ Verification failed.[/red] Check key at https://www.themoviedb.org/settings/api")
|
|
877
|
+
return api_key.strip()
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _theme_preview(name):
|
|
881
|
+
descs = {"cinnamon": "warm orange & gold", "ocean": "cool blue & teal", "mono": "minimal white"}
|
|
882
|
+
desc = descs.get(name, "")
|
|
883
|
+
return f"{name} — {desc}"
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
# ---------------------------------------------------------------------------
|
|
887
|
+
# search (also invoked via `cinnamon <query>` fallback)
|
|
888
|
+
# ---------------------------------------------------------------------------
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
@cli.command()
|
|
892
|
+
@click.argument("query", nargs=-1, required=False)
|
|
893
|
+
@click.option("-t", "--type", "media_type", type=click.Choice(["tv", "movie"]), default=None, help="tv or movie (defaults to both)")
|
|
894
|
+
@click.option("-s", "--season", type=int, help="Season number")
|
|
895
|
+
@click.option("-e", "--episode", "ep_str", help="Episode number or range (e.g. 1 or 1-10)")
|
|
896
|
+
@click.option("--scraper", help="Scraper name")
|
|
897
|
+
@click.option("--player", help="Player: vlc, mpv, or auto")
|
|
898
|
+
@click.option("-q", "--quality", help="Video quality: 480p, 720p, 1080p, best, worst")
|
|
899
|
+
@click.option("-d", "--download", is_flag=True, help="Download instead of streaming")
|
|
900
|
+
@click.option("--info-only", is_flag=True, help="Show the m3u8 URL without playing")
|
|
901
|
+
@_handle_tmdb_error
|
|
902
|
+
def search(query, media_type, season, ep_str, scraper, player, quality, download, info_only):
|
|
903
|
+
"""Search for a show or movie, pick one, and watch it."""
|
|
904
|
+
_check_for_updates()
|
|
905
|
+
tmdb = _get_tmdb()
|
|
906
|
+
|
|
907
|
+
ep_start, ep_end = _parse_episode(ep_str) if ep_str else (None, None)
|
|
908
|
+
|
|
909
|
+
query_str = " ".join(query) if query else None
|
|
910
|
+
if not query_str:
|
|
911
|
+
console.clear()
|
|
912
|
+
query_str = Prompt.ask("[bold]Search for a[/bold] [cyan]show or movie[/cyan]")
|
|
913
|
+
|
|
914
|
+
if media_type == "tv":
|
|
915
|
+
combined = [dict(r, _media_type="tv") for r in tmdb.search_tv(query_str).get("results", [])]
|
|
916
|
+
elif media_type == "movie":
|
|
917
|
+
combined = [dict(r, _media_type="movie") for r in tmdb.search_movie(query_str).get("results", [])]
|
|
918
|
+
else:
|
|
919
|
+
tv = [dict(r, _media_type="tv") for r in tmdb.search_tv(query_str).get("results", [])]
|
|
920
|
+
movie = [dict(r, _media_type="movie") for r in tmdb.search_movie(query_str).get("results", [])]
|
|
921
|
+
combined = tv + movie
|
|
922
|
+
|
|
923
|
+
if not combined:
|
|
924
|
+
_print_info(f"No results found for \"{query_str}\".")
|
|
925
|
+
return
|
|
926
|
+
|
|
927
|
+
show = _pick_combined(combined, "Select a title:")
|
|
928
|
+
|
|
929
|
+
if not show:
|
|
930
|
+
return
|
|
931
|
+
|
|
932
|
+
mtype = show.get("_media_type", "tv")
|
|
933
|
+
title_key = "name" if mtype == "tv" else "title"
|
|
934
|
+
date_key = "first_air_date" if mtype == "tv" else "release_date"
|
|
935
|
+
show_name = show.get(title_key, "?")
|
|
936
|
+
show_id = show["id"]
|
|
937
|
+
|
|
938
|
+
if scraper is None and mtype == "tv" and _is_anime(show):
|
|
939
|
+
# Route anime through allanime's available-episode list so we never
|
|
940
|
+
# offer an episode the source doesn't have (same as `cinnamon anime`).
|
|
941
|
+
from .scrapers.anime import _find_show, _allanime_episodes
|
|
942
|
+
import requests as _req
|
|
943
|
+
_as = _req.Session()
|
|
944
|
+
_as.headers.update({"User-Agent": "Mozilla/5.0"})
|
|
945
|
+
_aid = _find_show(_as, show_name)
|
|
946
|
+
if _aid:
|
|
947
|
+
_ed = _allanime_episodes(_as, _aid)
|
|
948
|
+
if _ed:
|
|
949
|
+
_run_anime_flow(show_name, _ed, season, ep_str, player, quality, info_only, download)
|
|
950
|
+
return
|
|
951
|
+
# Fall through to generic TV flow if allanime lookup fails.
|
|
952
|
+
scraper = "anime"
|
|
953
|
+
_print_info(f"Detected anime — using [bold]anime[/bold] scraper")
|
|
954
|
+
|
|
955
|
+
if mtype == "movie":
|
|
956
|
+
_play_movie(show, scraper, player, quality, info_only, download)
|
|
957
|
+
return
|
|
958
|
+
|
|
959
|
+
if season is not None and ep_start is not None:
|
|
960
|
+
_play_with_menu(show, season, ep_start, ep_end, f"S{season:02d}E{ep_start:02d}", scraper, player, quality, info_only, download)
|
|
961
|
+
return
|
|
962
|
+
|
|
963
|
+
theme = get_theme()
|
|
964
|
+
console.clear()
|
|
965
|
+
console.print(Panel(f"[bold {theme['accent']}]{show_name}[/bold {theme['accent']}]", border_style=theme["border"]))
|
|
966
|
+
|
|
967
|
+
_interactive_episode_picker(tmdb, show, scraper, player, quality, info_only, download, ep_start, ep_end)
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
# ---------------------------------------------------------------------------
|
|
971
|
+
# watch
|
|
972
|
+
# ---------------------------------------------------------------------------
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
@cli.command()
|
|
976
|
+
@click.option("--query", help="Search query (omit for interactive)")
|
|
977
|
+
@click.option("--id", "tmdb_id", type=int, help="TMDB show/movie ID")
|
|
978
|
+
@click.option("-t", "--type", "media_type", type=click.Choice(["tv", "movie"]), default=None, help="tv or movie (defaults to both)")
|
|
979
|
+
@click.option("-s", "--season", type=int, help="Season number")
|
|
980
|
+
@click.option("-e", "--episode", "ep_str", help="Episode number or range (e.g. 1 or 1-10)")
|
|
981
|
+
@click.option("--scraper", help="Scraper name")
|
|
982
|
+
@click.option("--player", help="Player: vlc, mpv, or auto")
|
|
983
|
+
@click.option("-d", "--download", is_flag=True, help="Download instead of streaming")
|
|
984
|
+
@click.option("--info-only", is_flag=True, help="Show the m3u8 URL without playing")
|
|
985
|
+
@click.option("-q", "--quality", help="Video quality: 480p, 720p, 1080p, best, worst")
|
|
986
|
+
@_handle_tmdb_error
|
|
987
|
+
def watch(query, tmdb_id, media_type, season, ep_str, scraper, player, download, info_only, quality):
|
|
988
|
+
"""Browse episodes interactively and play one."""
|
|
989
|
+
_check_for_updates()
|
|
990
|
+
tmdb = _get_tmdb()
|
|
991
|
+
|
|
992
|
+
ep_start, ep_end = _parse_episode(ep_str) if ep_str else (None, None)
|
|
993
|
+
|
|
994
|
+
# Explicit movie request.
|
|
995
|
+
if media_type == "movie":
|
|
996
|
+
if not tmdb_id and query:
|
|
997
|
+
results = tmdb.search_movie(query).get("results", [])
|
|
998
|
+
show = _pick_with_arrows(results, "title", "release_date", "Select a movie:")
|
|
999
|
+
if not show:
|
|
1000
|
+
return
|
|
1001
|
+
tmdb_id = show["id"]
|
|
1002
|
+
elif tmdb_id:
|
|
1003
|
+
show = tmdb.get_movie_details(tmdb_id)
|
|
1004
|
+
else:
|
|
1005
|
+
query = _prompt("Search for a movie")
|
|
1006
|
+
results = tmdb.search_movie(query).get("results", [])
|
|
1007
|
+
show = _pick_with_arrows(results, "title", "release_date", "Select a movie:")
|
|
1008
|
+
if not show:
|
|
1009
|
+
return
|
|
1010
|
+
_play_movie(show, scraper, player, quality, info_only, download)
|
|
1011
|
+
return
|
|
1012
|
+
|
|
1013
|
+
# Explicit TV request (or a TMDB id with no type hint).
|
|
1014
|
+
if media_type == "tv" or (tmdb_id and media_type is None):
|
|
1015
|
+
if tmdb_id:
|
|
1016
|
+
show = tmdb.get_tv_details(tmdb_id)
|
|
1017
|
+
elif query:
|
|
1018
|
+
results = tmdb.search_tv(query).get("results", [])
|
|
1019
|
+
show = _pick_with_arrows(results, "name", "first_air_date", "Select a show:")
|
|
1020
|
+
if not show:
|
|
1021
|
+
return
|
|
1022
|
+
else:
|
|
1023
|
+
query = _prompt("Search for a show")
|
|
1024
|
+
results = tmdb.search_tv(query).get("results", [])
|
|
1025
|
+
show = _pick_with_arrows(results, "name", "first_air_date", "Select a show:")
|
|
1026
|
+
if not show:
|
|
1027
|
+
return
|
|
1028
|
+
if scraper is None and _is_anime(show):
|
|
1029
|
+
scraper = "anime"
|
|
1030
|
+
_print_info(f"Detected anime — using [bold]anime[/bold] scraper")
|
|
1031
|
+
if season is not None and ep_start is not None:
|
|
1032
|
+
_play_with_menu(show, season, ep_start, ep_end, f"E{ep_start}", scraper, player, quality, info_only, download)
|
|
1033
|
+
return
|
|
1034
|
+
_interactive_episode_picker(tmdb, show, scraper, player, quality, info_only, ep_start=ep_start, ep_end=ep_end)
|
|
1035
|
+
return
|
|
1036
|
+
|
|
1037
|
+
# No type given: search both TV and movies, then dispatch by what is picked.
|
|
1038
|
+
if tmdb_id:
|
|
1039
|
+
# Ambiguous id without a type — try TV first, fall back to movie.
|
|
1040
|
+
try:
|
|
1041
|
+
show = tmdb.get_tv_details(tmdb_id)
|
|
1042
|
+
mtype = "tv"
|
|
1043
|
+
except TMDBNotFoundError:
|
|
1044
|
+
show = tmdb.get_movie_details(tmdb_id)
|
|
1045
|
+
mtype = "movie"
|
|
1046
|
+
elif query:
|
|
1047
|
+
tv = [dict(r, _media_type="tv") for r in tmdb.search_tv(query).get("results", [])]
|
|
1048
|
+
movie = [dict(r, _media_type="movie") for r in tmdb.search_movie(query).get("results", [])]
|
|
1049
|
+
combined = tv + movie
|
|
1050
|
+
if not combined:
|
|
1051
|
+
_print_info(f"No results found for \"{query}\".")
|
|
1052
|
+
return
|
|
1053
|
+
show = _pick_combined(combined, "Select a title:")
|
|
1054
|
+
if not show:
|
|
1055
|
+
return
|
|
1056
|
+
mtype = show.get("_media_type", "tv")
|
|
1057
|
+
else:
|
|
1058
|
+
query = _prompt("[bold]Search for a[/bold] [cyan]show or movie[/cyan]")
|
|
1059
|
+
if not query or not query.strip():
|
|
1060
|
+
return
|
|
1061
|
+
query = query.strip()
|
|
1062
|
+
tv = [dict(r, _media_type="tv") for r in tmdb.search_tv(query).get("results", [])]
|
|
1063
|
+
movie = [dict(r, _media_type="movie") for r in tmdb.search_movie(query).get("results", [])]
|
|
1064
|
+
combined = tv + movie
|
|
1065
|
+
if not combined:
|
|
1066
|
+
_print_info(f"No results found for \"{query}\".")
|
|
1067
|
+
return
|
|
1068
|
+
show = _pick_combined(combined, "Select a title:")
|
|
1069
|
+
if not show:
|
|
1070
|
+
return
|
|
1071
|
+
mtype = show.get("_media_type", "tv")
|
|
1072
|
+
|
|
1073
|
+
if mtype == "movie":
|
|
1074
|
+
_play_movie(show, scraper, player, quality, info_only, download)
|
|
1075
|
+
return
|
|
1076
|
+
|
|
1077
|
+
if scraper is None and _is_anime(show):
|
|
1078
|
+
scraper = "anime"
|
|
1079
|
+
_print_info(f"Detected anime — using [bold]anime[/bold] scraper")
|
|
1080
|
+
if season is not None and ep_start is not None:
|
|
1081
|
+
_play_with_menu(show, season, ep_start, ep_end, f"E{ep_start}", scraper, player, quality, info_only, download)
|
|
1082
|
+
return
|
|
1083
|
+
_interactive_episode_picker(tmdb, show, scraper, player, quality, info_only, ep_start=ep_start, ep_end=ep_end)
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
# ---------------------------------------------------------------------------
|
|
1087
|
+
# shared: interactive episode picker + resolve + play
|
|
1088
|
+
# ---------------------------------------------------------------------------
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _interactive_episode_picker(tmdb, show, scraper, player, quality, info_only, download=False, ep_start=None, ep_end=None):
|
|
1092
|
+
show_name = show.get("name", "?")
|
|
1093
|
+
show_id = show["id"]
|
|
1094
|
+
details = tmdb.get_tv_details(show_id)
|
|
1095
|
+
total = details.get("number_of_seasons", 0)
|
|
1096
|
+
if total == 0:
|
|
1097
|
+
_print_error(f"No season data for {show_name}.")
|
|
1098
|
+
return
|
|
1099
|
+
|
|
1100
|
+
if total == 1:
|
|
1101
|
+
season_num = 1
|
|
1102
|
+
console.print(f" [dim]Only one season — using Season 1[/dim]")
|
|
1103
|
+
else:
|
|
1104
|
+
try:
|
|
1105
|
+
season_choices = [questionary.Choice(title=f"Season {i}", value=i) for i in range(1, total + 1)]
|
|
1106
|
+
season_chosen = _select("Select a season:", choices=season_choices)
|
|
1107
|
+
if not season_chosen:
|
|
1108
|
+
return
|
|
1109
|
+
season_num = season_chosen
|
|
1110
|
+
except Exception:
|
|
1111
|
+
season_num = Prompt.ask("Season", default="1")
|
|
1112
|
+
try:
|
|
1113
|
+
season_num = int(season_num)
|
|
1114
|
+
except ValueError:
|
|
1115
|
+
_print_error("Invalid season number.")
|
|
1116
|
+
return
|
|
1117
|
+
|
|
1118
|
+
try:
|
|
1119
|
+
season_data = tmdb.get_season_details(show_id, season_num)
|
|
1120
|
+
except TMDBNotFoundError:
|
|
1121
|
+
_print_error(f"Season {season_num} not found for {show_name}.")
|
|
1122
|
+
return
|
|
1123
|
+
|
|
1124
|
+
episodes = season_data.get("episodes", [])
|
|
1125
|
+
if not episodes:
|
|
1126
|
+
_print_error(f"No episodes found for S{season_num}.")
|
|
1127
|
+
return
|
|
1128
|
+
|
|
1129
|
+
if ep_start is None:
|
|
1130
|
+
from .history import get_history as _get_history
|
|
1131
|
+
last = _get_history(show_name)
|
|
1132
|
+
if last and last.get("season") == season_num and last.get("episode"):
|
|
1133
|
+
# Suggest the next unwatched episode (last watched + 1) when it
|
|
1134
|
+
# exists in this season; otherwise fall back to the last watched.
|
|
1135
|
+
resume_ep = last["episode"] + 1
|
|
1136
|
+
if not any(
|
|
1137
|
+
isinstance(ep.get("episode_number"), int) and ep["episode_number"] == resume_ep
|
|
1138
|
+
for ep in episodes
|
|
1139
|
+
):
|
|
1140
|
+
resume_ep = last["episode"]
|
|
1141
|
+
try:
|
|
1142
|
+
answer = Prompt.ask(
|
|
1143
|
+
f" Resume from [bold]E{resume_ep:02d}[/bold]? [dim](y/n)[/dim]",
|
|
1144
|
+
default="y",
|
|
1145
|
+
show_default=False,
|
|
1146
|
+
).strip().lower()
|
|
1147
|
+
resume = answer in ("", "y", "yes")
|
|
1148
|
+
except (EOFError, KeyboardInterrupt):
|
|
1149
|
+
resume = True
|
|
1150
|
+
if resume:
|
|
1151
|
+
ep_start = resume_ep
|
|
1152
|
+
|
|
1153
|
+
if ep_start is not None:
|
|
1154
|
+
ep_name = f"S{season_num:02d}E{ep_start:02d}"
|
|
1155
|
+
_play_with_menu(show, season_num, ep_start, ep_end, ep_name, scraper, player, quality, info_only, download)
|
|
1156
|
+
return
|
|
1157
|
+
|
|
1158
|
+
try:
|
|
1159
|
+
ep_choices = []
|
|
1160
|
+
for ep in episodes:
|
|
1161
|
+
ep_num = ep.get("episode_number", "?")
|
|
1162
|
+
ep_title = ep.get("name", f"Episode {ep_num}")
|
|
1163
|
+
label = f"E{ep_num:02d} {ep_title}" if isinstance(ep_num, int) else f"{ep_num} {ep_title}"
|
|
1164
|
+
ep_choices.append(questionary.Choice(title=label, value=ep))
|
|
1165
|
+
ep_chosen = _select(
|
|
1166
|
+
f"Season {season_num} - Select an episode:",
|
|
1167
|
+
choices=ep_choices,
|
|
1168
|
+
)
|
|
1169
|
+
if not ep_chosen:
|
|
1170
|
+
return
|
|
1171
|
+
except Exception:
|
|
1172
|
+
ep_chosen = _pick_numbered(episodes, "name", "air_date", "Select an episode")
|
|
1173
|
+
if not ep_chosen:
|
|
1174
|
+
return
|
|
1175
|
+
|
|
1176
|
+
ep_num = ep_chosen["episode_number"]
|
|
1177
|
+
ep_name = ep_chosen.get("name", f"E{ep_num}")
|
|
1178
|
+
|
|
1179
|
+
_play_with_menu(show, season_num, ep_num, None, ep_name, scraper, player, quality, info_only, download)
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
# ---------------------------------------------------------------------------
|
|
1183
|
+
# play-url
|
|
1184
|
+
# ---------------------------------------------------------------------------
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
@cli.command()
|
|
1188
|
+
@click.argument("url")
|
|
1189
|
+
@click.option("--player", help="Player: vlc, mpv, or auto")
|
|
1190
|
+
@click.option("--title", default="", help="Media title")
|
|
1191
|
+
def play_url(url, player, title):
|
|
1192
|
+
"""Play a direct m3u8 URL in VLC or mpv."""
|
|
1193
|
+
config = load_config()
|
|
1194
|
+
player_choice = player or config.get("default_player", "auto")
|
|
1195
|
+
|
|
1196
|
+
try:
|
|
1197
|
+
play(url, title=title, player=player_choice)
|
|
1198
|
+
_print_success(f"Launched in {player_choice.upper()}.")
|
|
1199
|
+
except PlayerNotFoundError as e:
|
|
1200
|
+
_print_error(str(e))
|
|
1201
|
+
except ValueError as e:
|
|
1202
|
+
_print_error(str(e))
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
# ---------------------------------------------------------------------------
|
|
1206
|
+
# resume
|
|
1207
|
+
# ---------------------------------------------------------------------------
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
@cli.command()
|
|
1211
|
+
@_handle_tmdb_error
|
|
1212
|
+
def resume():
|
|
1213
|
+
"""List interrupted downloads and resume one."""
|
|
1214
|
+
from .downloads import get, list_all, update as _track_update
|
|
1215
|
+
|
|
1216
|
+
dls = list_all(status="interrupted")
|
|
1217
|
+
if not dls:
|
|
1218
|
+
_print_info("No interrupted downloads found.")
|
|
1219
|
+
return
|
|
1220
|
+
|
|
1221
|
+
console.print()
|
|
1222
|
+
table = Table(border_style="dim")
|
|
1223
|
+
table.add_column("#", style="cyan")
|
|
1224
|
+
table.add_column("Title")
|
|
1225
|
+
table.add_column("Episodes")
|
|
1226
|
+
table.add_column("Quality", style="yellow")
|
|
1227
|
+
table.add_column("Progress", style="green")
|
|
1228
|
+
|
|
1229
|
+
resume_indices = []
|
|
1230
|
+
for i, d in enumerate(dls, 1):
|
|
1231
|
+
ep = d.get("episode", "")
|
|
1232
|
+
season = d.get("season")
|
|
1233
|
+
ep_label = f"S{season}E{ep}" if season and ep else str(ep)
|
|
1234
|
+
if d.get("url") or (not d.get("url") and "-" in str(ep)):
|
|
1235
|
+
resume_indices.append(i)
|
|
1236
|
+
else:
|
|
1237
|
+
ep_label += " [dim](can't resume)[/dim]"
|
|
1238
|
+
table.add_row(str(i), d.get("title", "?"), ep_label, d.get("quality", "-"), "[yellow]interrupted[/yellow]")
|
|
1239
|
+
|
|
1240
|
+
console.print(table)
|
|
1241
|
+
|
|
1242
|
+
if not resume_indices:
|
|
1243
|
+
console.print(" [dim]No resumable entries found.[/dim]")
|
|
1244
|
+
if _prompt(" Remove these entries?", default="y").strip().lower() in ("y", "yes"):
|
|
1245
|
+
for d in dls:
|
|
1246
|
+
_track_remove(d["id"])
|
|
1247
|
+
_print_success("Cleaned up.")
|
|
1248
|
+
return
|
|
1249
|
+
|
|
1250
|
+
choice = _prompt("Resume which download?", default=str(resume_indices[0]))
|
|
1251
|
+
try:
|
|
1252
|
+
d = dls[int(choice) - 1]
|
|
1253
|
+
except (ValueError, IndexError):
|
|
1254
|
+
return
|
|
1255
|
+
|
|
1256
|
+
track_id = d["id"]
|
|
1257
|
+
url = d.get("url")
|
|
1258
|
+
title = d.get("title", "?")
|
|
1259
|
+
season = d.get("season")
|
|
1260
|
+
ep_raw = d.get("episode", "")
|
|
1261
|
+
|
|
1262
|
+
if not url and "-" in str(ep_raw):
|
|
1263
|
+
ep_parts = str(ep_raw).split("-", 1)
|
|
1264
|
+
try:
|
|
1265
|
+
ep_start, ep_end = int(ep_parts[0]), int(ep_parts[1])
|
|
1266
|
+
except ValueError:
|
|
1267
|
+
_print_error("Invalid episode range in download entry.")
|
|
1268
|
+
return
|
|
1269
|
+
show_dict = {"name": title, "id": d.get("tv_id", 0)}
|
|
1270
|
+
scraper = d.get("scraper", "anime")
|
|
1271
|
+
quality = d.get("quality")
|
|
1272
|
+
translation = d.get("translation") or None
|
|
1273
|
+
_track_update(track_id, status="queued")
|
|
1274
|
+
_play_with_menu(show_dict, season or 1, ep_start, ep_end, "", scraper, None, quality, False, True, translation=translation)
|
|
1275
|
+
return
|
|
1276
|
+
|
|
1277
|
+
if not url:
|
|
1278
|
+
_print_error("Cannot resume this download entry (no URL).")
|
|
1279
|
+
return
|
|
1280
|
+
referer = d.get("referer")
|
|
1281
|
+
|
|
1282
|
+
_track_update(track_id, status="queued")
|
|
1283
|
+
|
|
1284
|
+
try:
|
|
1285
|
+
download_video(url, title=title, referer=referer, track_id=track_id)
|
|
1286
|
+
except PlayerNotFoundError:
|
|
1287
|
+
_print_error(f"yt-dlp not found. Install it with: {ytdlp_install_hint()}")
|
|
1288
|
+
_track_update(track_id, status="interrupted")
|
|
1289
|
+
except PlayerLaunchError as e:
|
|
1290
|
+
_print_error(str(e))
|
|
1291
|
+
_track_update(track_id, status="error")
|
|
1292
|
+
except KeyboardInterrupt:
|
|
1293
|
+
pass
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
# ---------------------------------------------------------------------------
|
|
1297
|
+
# download
|
|
1298
|
+
# ---------------------------------------------------------------------------
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
@cli.group()
|
|
1302
|
+
def download():
|
|
1303
|
+
"""Manage downloads."""
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
@download.command("list")
|
|
1307
|
+
def download_list():
|
|
1308
|
+
"""Show all tracked downloads."""
|
|
1309
|
+
from .downloads import list_all, remove as _track_remove
|
|
1310
|
+
|
|
1311
|
+
all_dls = list_all()
|
|
1312
|
+
if not all_dls:
|
|
1313
|
+
_print_info("No downloads tracked.")
|
|
1314
|
+
return
|
|
1315
|
+
|
|
1316
|
+
table = Table(border_style="dim")
|
|
1317
|
+
table.add_column("ID", style="cyan")
|
|
1318
|
+
table.add_column("Title")
|
|
1319
|
+
table.add_column("Episodes")
|
|
1320
|
+
table.add_column("Quality", style="yellow")
|
|
1321
|
+
table.add_column("Status")
|
|
1322
|
+
table.add_column("Date")
|
|
1323
|
+
|
|
1324
|
+
for d in all_dls:
|
|
1325
|
+
ep = d.get("episode", "")
|
|
1326
|
+
season = d.get("season")
|
|
1327
|
+
ep_label = f"S{season}E{ep}" if season and ep else str(ep)
|
|
1328
|
+
ts = d.get("timestamp", 0)
|
|
1329
|
+
import datetime
|
|
1330
|
+
date_label = datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else ""
|
|
1331
|
+
table.add_row(
|
|
1332
|
+
d.get("id", "?"),
|
|
1333
|
+
d.get("title", "?"),
|
|
1334
|
+
ep_label,
|
|
1335
|
+
d.get("quality", "-"),
|
|
1336
|
+
d.get("status", "?"),
|
|
1337
|
+
date_label,
|
|
1338
|
+
)
|
|
1339
|
+
|
|
1340
|
+
console.print(table)
|
|
1341
|
+
|
|
1342
|
+
if _prompt("Clear completed/interrupted?", default="n").strip().lower() in ("y", "yes"):
|
|
1343
|
+
for d in all_dls:
|
|
1344
|
+
if d["status"] in ("completed", "error"):
|
|
1345
|
+
_track_remove(d["id"])
|
|
1346
|
+
_print_success("Cleaned up.")
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
# ---------------------------------------------------------------------------
|
|
1350
|
+
# config
|
|
1351
|
+
# ---------------------------------------------------------------------------
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
@cli.group()
|
|
1355
|
+
def config():
|
|
1356
|
+
"""View or change settings."""
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
@config.command("show")
|
|
1360
|
+
def config_show():
|
|
1361
|
+
"""Show current configuration."""
|
|
1362
|
+
cfg = load_config()
|
|
1363
|
+
masked = cfg.get("tmdb_api_key", "")
|
|
1364
|
+
if masked:
|
|
1365
|
+
if len(masked) > 10:
|
|
1366
|
+
masked = masked[:6] + "*" * (len(masked) - 6)
|
|
1367
|
+
else:
|
|
1368
|
+
masked = masked[:3] + "***"
|
|
1369
|
+
|
|
1370
|
+
table = Table(border_style="dim")
|
|
1371
|
+
table.add_column("Setting", style="cyan")
|
|
1372
|
+
table.add_column("Value", style="white")
|
|
1373
|
+
|
|
1374
|
+
table.add_row("TMDB API Key", masked or "[dim]not set[/dim]")
|
|
1375
|
+
table.add_row("Default scraper", cfg.get("default_scraper", "example"))
|
|
1376
|
+
table.add_row("Default player", cfg.get("default_player", "auto"))
|
|
1377
|
+
table.add_row("Theme", cfg.get("theme", "cinnamon"))
|
|
1378
|
+
|
|
1379
|
+
scrapers_cfg = cfg.get("scrapers", {})
|
|
1380
|
+
for name, sc in scrapers_cfg.items():
|
|
1381
|
+
for k, v in sc.items():
|
|
1382
|
+
table.add_row(f"scraper.{name}.{k}", str(v))
|
|
1383
|
+
|
|
1384
|
+
console.print(table)
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
@config.command("set-api-key")
|
|
1388
|
+
@click.argument("api_key")
|
|
1389
|
+
def config_set_api_key(api_key):
|
|
1390
|
+
"""Set your TMDB API key."""
|
|
1391
|
+
from .config import set_tmdb_api_key
|
|
1392
|
+
|
|
1393
|
+
set_tmdb_api_key(api_key)
|
|
1394
|
+
_print_success("TMDB API key saved.")
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
@config.command("show-api-key")
|
|
1398
|
+
def config_show_api_key():
|
|
1399
|
+
"""Show the active TMDB API key (env var or saved config) and its source."""
|
|
1400
|
+
import os
|
|
1401
|
+
env_key = os.getenv("TMDB_API_KEY")
|
|
1402
|
+
if env_key:
|
|
1403
|
+
console.print(f" Source: [cyan]TMDB_API_KEY[/cyan] env var")
|
|
1404
|
+
console.print(f" Key: [bold]{env_key}[/bold]")
|
|
1405
|
+
return
|
|
1406
|
+
from .config import get_tmdb_api_key
|
|
1407
|
+
key = get_tmdb_api_key()
|
|
1408
|
+
if key:
|
|
1409
|
+
console.print(" Source: [yellow]config.json[/yellow]")
|
|
1410
|
+
console.print(f" Key: [bold]{key}[/bold]")
|
|
1411
|
+
else:
|
|
1412
|
+
_print_info("No TMDB API key set (env var or config).")
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
@config.command("default-scraper")
|
|
1416
|
+
@click.argument("name")
|
|
1417
|
+
def config_default_scraper(name):
|
|
1418
|
+
"""Set the default scraper."""
|
|
1419
|
+
available = [s["name"] for s in list_scrapers()]
|
|
1420
|
+
if name not in available:
|
|
1421
|
+
_print_error(f"Unknown scraper: {name}", f"Available: {', '.join(available)}")
|
|
1422
|
+
return
|
|
1423
|
+
cfg = load_config()
|
|
1424
|
+
cfg["default_scraper"] = name
|
|
1425
|
+
save_config(cfg)
|
|
1426
|
+
_print_success(f"Default scraper set to [bold]{name}[/bold].")
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
@config.command("default-player")
|
|
1430
|
+
@click.argument("player", type=click.Choice(["auto", "vlc", "mpv"]))
|
|
1431
|
+
def config_default_player(player):
|
|
1432
|
+
"""Set the default player (auto, vlc, or mpv)."""
|
|
1433
|
+
cfg = load_config()
|
|
1434
|
+
cfg["default_player"] = player
|
|
1435
|
+
save_config(cfg)
|
|
1436
|
+
_print_success(f"Default player set to [bold]{player}[/bold].")
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
@config.group("scraper")
|
|
1440
|
+
def config_scraper():
|
|
1441
|
+
"""Configure a specific scraper."""
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
@config_scraper.command("set")
|
|
1445
|
+
@click.argument("name")
|
|
1446
|
+
@click.argument("key")
|
|
1447
|
+
@click.argument("value")
|
|
1448
|
+
def config_scraper_set(name, key, value):
|
|
1449
|
+
"""Set a config option for a scraper."""
|
|
1450
|
+
available = [s["name"] for s in list_scrapers()]
|
|
1451
|
+
if name not in available:
|
|
1452
|
+
_print_error(f"Unknown scraper: {name}", f"Available: {', '.join(available)}")
|
|
1453
|
+
return
|
|
1454
|
+
set_scraper_config(name, key, value)
|
|
1455
|
+
_print_success(f"Scraper [bold]{name}[/bold] {key} = {value}")
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
@config_scraper.command("show")
|
|
1459
|
+
@click.argument("name")
|
|
1460
|
+
def config_scraper_show(name):
|
|
1461
|
+
"""Show config for a specific scraper."""
|
|
1462
|
+
sc_cfg = get_scraper_config(name)
|
|
1463
|
+
if not sc_cfg:
|
|
1464
|
+
_print_info(f"No config set for scraper [bold]{name}[/bold].")
|
|
1465
|
+
return
|
|
1466
|
+
table = Table(border_style="dim")
|
|
1467
|
+
table.add_column("Key", style="cyan")
|
|
1468
|
+
table.add_column("Value", style="white")
|
|
1469
|
+
for k, v in sc_cfg.items():
|
|
1470
|
+
table.add_row(k, str(v))
|
|
1471
|
+
console.print(table)
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
# ---------------------------------------------------------------------------
|
|
1475
|
+
# scrapers
|
|
1476
|
+
# ---------------------------------------------------------------------------
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
@cli.command()
|
|
1480
|
+
def scrapers():
|
|
1481
|
+
"""List available scrapers."""
|
|
1482
|
+
all_scrapers = list_scrapers()
|
|
1483
|
+
if not all_scrapers:
|
|
1484
|
+
_print_info("No scrapers found.")
|
|
1485
|
+
return
|
|
1486
|
+
|
|
1487
|
+
table = Table(border_style="dim")
|
|
1488
|
+
table.add_column("Name", style="cyan")
|
|
1489
|
+
table.add_column("Description")
|
|
1490
|
+
table.add_column("Source", style="yellow")
|
|
1491
|
+
|
|
1492
|
+
for s in all_scrapers:
|
|
1493
|
+
source = "built-in" if s.get("builtin") else "user"
|
|
1494
|
+
table.add_row(s["name"], s["description"], source)
|
|
1495
|
+
|
|
1496
|
+
console.print(table)
|
|
1497
|
+
|
|
1498
|
+
from .scrapers import _OPTIONAL_SCRAPERS
|
|
1499
|
+
if _OPTIONAL_SCRAPERS:
|
|
1500
|
+
console.print()
|
|
1501
|
+
console.print("[dim]Optional (install via[/dim] [cyan]cinnamon install <name>[/cyan][dim]):[/dim]")
|
|
1502
|
+
for name, info in _OPTIONAL_SCRAPERS.items():
|
|
1503
|
+
console.print(f" [cyan]{name}[/cyan] {info['description']} [dim](needs: {info['deps']})[/dim]")
|
|
1504
|
+
console.print(
|
|
1505
|
+
"\n[dim]Tip: Drop a .py scraper in[/dim]"
|
|
1506
|
+
f" [cyan]{get_ensured_dirs()[1]}[/cyan]"
|
|
1507
|
+
"\n[dim]or set[/dim] [cyan]CINNAMON_SCRAPERS_PATH[/cyan] [dim]env var.[/dim]"
|
|
1508
|
+
)
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
@cli.command()
|
|
1512
|
+
@click.argument("name")
|
|
1513
|
+
def install(name):
|
|
1514
|
+
"""Install an optional scraper (vidsrc, torrentio)."""
|
|
1515
|
+
from .scrapers import _OPTIONAL_SCRAPERS, install_optional
|
|
1516
|
+
|
|
1517
|
+
if name not in _OPTIONAL_SCRAPERS:
|
|
1518
|
+
available = ", ".join(_OPTIONAL_SCRAPERS)
|
|
1519
|
+
_print_error(f"Unknown optional scraper: {name}", f"Available: {available}")
|
|
1520
|
+
return
|
|
1521
|
+
|
|
1522
|
+
try:
|
|
1523
|
+
dst = install_optional(name)
|
|
1524
|
+
_print_success(f"Installed [bold]{name}[/bold] scraper", f"Saved to {dst}")
|
|
1525
|
+
except Exception as e:
|
|
1526
|
+
_print_error(f"Failed to install {name}", str(e))
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
@cli.command()
|
|
1530
|
+
@click.argument("query", nargs=-1, required=False)
|
|
1531
|
+
@click.option("-s", "--season", type=int, help="Season number")
|
|
1532
|
+
@click.option("-e", "--episode", "ep_str", help="Episode number or range (e.g. 1 or 1-10)")
|
|
1533
|
+
@click.option("-d", "--download", is_flag=True, help="Download instead of streaming")
|
|
1534
|
+
@click.option("--player", help="Player: vlc, mpv, or auto")
|
|
1535
|
+
@click.option("-q", "--quality", help="Video quality: 480p, 720p, 1080p, best, worst")
|
|
1536
|
+
@click.option("--info-only", is_flag=True, help="Show the stream URL without playing")
|
|
1537
|
+
def anime(query, season, ep_str, download, player, quality, info_only):
|
|
1538
|
+
"""Search anime via AniList (no API key needed) and stream from allanime."""
|
|
1539
|
+
_check_for_updates()
|
|
1540
|
+
|
|
1541
|
+
query_str = " ".join(query) if query else None
|
|
1542
|
+
if not query_str:
|
|
1543
|
+
query_str = Prompt.ask("[bold]Search for an[/bold] [magenta]anime[/magenta]")
|
|
1544
|
+
from .anilist import search_anime
|
|
1545
|
+
|
|
1546
|
+
results = search_anime(query_str)
|
|
1547
|
+
if not results:
|
|
1548
|
+
_print_info(f"No anime found for \"{query_str}\".")
|
|
1549
|
+
return
|
|
1550
|
+
|
|
1551
|
+
def _title(m):
|
|
1552
|
+
return m.get("title", {}).get("romaji") or m.get("title", {}).get("english") or m.get("title", {}).get("native", "?")
|
|
1553
|
+
def _year(m):
|
|
1554
|
+
sd = m.get("startDate") or {}
|
|
1555
|
+
return str(sd.get("year", "")) if sd.get("year") else ""
|
|
1556
|
+
|
|
1557
|
+
ql = query_str.lower().strip()
|
|
1558
|
+
def _relevance(m):
|
|
1559
|
+
t = _title(m).lower()
|
|
1560
|
+
if t == ql:
|
|
1561
|
+
return 0
|
|
1562
|
+
if t.startswith(ql):
|
|
1563
|
+
return 1
|
|
1564
|
+
if ql in t:
|
|
1565
|
+
return 2
|
|
1566
|
+
return 3
|
|
1567
|
+
|
|
1568
|
+
results.sort(key=lambda m: (_relevance(m), _title(m).lower()))
|
|
1569
|
+
|
|
1570
|
+
show = _pick_with_arrows(results, _title, _year, "Select an anime:")
|
|
1571
|
+
|
|
1572
|
+
if not show:
|
|
1573
|
+
return
|
|
1574
|
+
|
|
1575
|
+
show_name = _title(show)
|
|
1576
|
+
theme = get_theme()
|
|
1577
|
+
console.clear()
|
|
1578
|
+
console.print(Panel(f"[bold {theme['accent']}]{show_name}[/bold {theme['accent']}]", border_style=theme["border"]))
|
|
1579
|
+
|
|
1580
|
+
from .history import get_history as _get_history
|
|
1581
|
+
if not ep_str:
|
|
1582
|
+
last = _get_history(show_name)
|
|
1583
|
+
if last and last.get("episode"):
|
|
1584
|
+
# Suggest the next unwatched episode (last watched + 1).
|
|
1585
|
+
resume_ep = last["episode"] + 1
|
|
1586
|
+
try:
|
|
1587
|
+
answer = Prompt.ask(
|
|
1588
|
+
f" Resume from [bold]S{last.get('season', 1)}E{resume_ep}[/bold]? [dim](y/n)[/dim]",
|
|
1589
|
+
default="y",
|
|
1590
|
+
show_default=False,
|
|
1591
|
+
).strip().lower()
|
|
1592
|
+
resume = answer in ("", "y", "yes")
|
|
1593
|
+
except (EOFError, KeyboardInterrupt):
|
|
1594
|
+
resume = True
|
|
1595
|
+
if resume:
|
|
1596
|
+
ep_str = str(resume_ep)
|
|
1597
|
+
|
|
1598
|
+
from .scrapers.anime import _find_show, _allanime_episodes
|
|
1599
|
+
import requests as _req
|
|
1600
|
+
|
|
1601
|
+
session = _req.Session()
|
|
1602
|
+
session.headers.update({"User-Agent": "Mozilla/5.0"})
|
|
1603
|
+
|
|
1604
|
+
allanime_id = _find_show(session, show_name)
|
|
1605
|
+
if not allanime_id:
|
|
1606
|
+
_print_error(f"No match for \"{show_name}\" on allanime.")
|
|
1607
|
+
return
|
|
1608
|
+
|
|
1609
|
+
episodes_detail = _allanime_episodes(session, allanime_id)
|
|
1610
|
+
if not episodes_detail:
|
|
1611
|
+
_print_error("No episode data from allanime.")
|
|
1612
|
+
return
|
|
1613
|
+
|
|
1614
|
+
_run_anime_flow(show_name, episodes_detail, season, ep_str, player, quality, info_only, download)
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
def _run_anime_flow(show_name, episodes_detail, season=None, ep_str=None, player=None, quality=None, info_only=False, download=False):
|
|
1618
|
+
"""Drive the episode picker from allanime's *available* episodes (not TMDB's
|
|
1619
|
+
full list), so we never offer an episode the anime source doesn't have."""
|
|
1620
|
+
parsed = {}
|
|
1621
|
+
for key, eps in episodes_detail.items():
|
|
1622
|
+
if "|" in key:
|
|
1623
|
+
parts = key.split("|", 1)
|
|
1624
|
+
s = int(parts[0]) if parts[0].isdigit() else 1
|
|
1625
|
+
tt = parts[1] if len(parts) > 1 else "sub"
|
|
1626
|
+
else:
|
|
1627
|
+
s = 1
|
|
1628
|
+
tt = key
|
|
1629
|
+
parsed.setdefault(s, {})[tt] = eps
|
|
1630
|
+
|
|
1631
|
+
if season is None:
|
|
1632
|
+
season_keys = sorted(parsed.keys())
|
|
1633
|
+
if len(season_keys) > 1:
|
|
1634
|
+
try:
|
|
1635
|
+
season = _select("Select a season:", choices=[
|
|
1636
|
+
questionary.Choice(title=f"Season {s}", value=s) for s in season_keys
|
|
1637
|
+
])
|
|
1638
|
+
except Exception:
|
|
1639
|
+
season = season_keys[0]
|
|
1640
|
+
else:
|
|
1641
|
+
season = season_keys[0]
|
|
1642
|
+
if season != 1:
|
|
1643
|
+
console.print(f" [dim]Using Season {season}[/dim]")
|
|
1644
|
+
|
|
1645
|
+
season_data = parsed.get(season)
|
|
1646
|
+
if not season_data:
|
|
1647
|
+
_print_error(f"No episodes for season {season}.")
|
|
1648
|
+
return
|
|
1649
|
+
|
|
1650
|
+
tt_keys = list(season_data.keys())
|
|
1651
|
+
if "sub" in tt_keys and len(tt_keys) > 1:
|
|
1652
|
+
try:
|
|
1653
|
+
tt = _select("Translation:", choices=[
|
|
1654
|
+
questionary.Choice(title=k, value=k) for k in tt_keys
|
|
1655
|
+
], default="sub")
|
|
1656
|
+
except Exception:
|
|
1657
|
+
tt = "sub"
|
|
1658
|
+
else:
|
|
1659
|
+
tt = tt_keys[0]
|
|
1660
|
+
if tt != "sub":
|
|
1661
|
+
console.print(f" [dim]Using {tt}[/dim]")
|
|
1662
|
+
|
|
1663
|
+
episodes = [int(e) for e in season_data[tt]]
|
|
1664
|
+
max_ep = max(episodes)
|
|
1665
|
+
ep_start, ep_end = _parse_episode(ep_str) if ep_str else (None, None)
|
|
1666
|
+
|
|
1667
|
+
if ep_start is not None:
|
|
1668
|
+
if ep_start not in episodes:
|
|
1669
|
+
# The suggested next episode may not exist yet (e.g. unaired) —
|
|
1670
|
+
# fall back to the last available episode instead of hard-erroring.
|
|
1671
|
+
fallback = max_ep
|
|
1672
|
+
_print_info(f"Episode {ep_start} not available yet — using E{fallback}.")
|
|
1673
|
+
ep_start = fallback
|
|
1674
|
+
if ep_end is not None and ep_end > max_ep:
|
|
1675
|
+
ep_end = max_ep
|
|
1676
|
+
else:
|
|
1677
|
+
try:
|
|
1678
|
+
ep_choices = [questionary.Choice(title=f"Episode {e}", value=e) for e in sorted(episodes)]
|
|
1679
|
+
ep_chosen = _select("Select an episode:", choices=ep_choices)
|
|
1680
|
+
if not ep_chosen:
|
|
1681
|
+
return
|
|
1682
|
+
ep_start = int(ep_chosen)
|
|
1683
|
+
except Exception:
|
|
1684
|
+
ep_start = Prompt.ask("Episode", default="1")
|
|
1685
|
+
try:
|
|
1686
|
+
ep_start = int(ep_start)
|
|
1687
|
+
except ValueError:
|
|
1688
|
+
_print_error("Invalid episode number.")
|
|
1689
|
+
return
|
|
1690
|
+
|
|
1691
|
+
scraper = "anime"
|
|
1692
|
+
|
|
1693
|
+
show_dict = {"name": show_name, "id": 0}
|
|
1694
|
+
ep_name = f"S{season:02d}E{ep_start:02d}"
|
|
1695
|
+
_play_with_menu(show_dict, season, ep_start, ep_end, ep_name, scraper, player, quality, info_only, download, translation=tt)
|
|
1696
|
+
|
|
1697
|
+
|
|
1698
|
+
@cli.command()
|
|
1699
|
+
def update():
|
|
1700
|
+
"""Check for and install the latest version of cinnamon from GitHub."""
|
|
1701
|
+
theme = get_theme()
|
|
1702
|
+
|
|
1703
|
+
latest = _latest_version()
|
|
1704
|
+
if not latest:
|
|
1705
|
+
_print_error("Could not determine latest version from GitHub.", "Make sure you have internet access.")
|
|
1706
|
+
return
|
|
1707
|
+
|
|
1708
|
+
current = __version__
|
|
1709
|
+
cur = tuple(map(int, current.split(".")))
|
|
1710
|
+
lat = tuple(map(int, latest.split(".")))
|
|
1711
|
+
|
|
1712
|
+
if lat <= cur:
|
|
1713
|
+
_print_success(f"Already up to date ({current}).")
|
|
1714
|
+
return
|
|
1715
|
+
|
|
1716
|
+
console.print(f" [{theme['info']}]Updating:[/] {current} → [bold]{latest}[/bold]")
|
|
1717
|
+
console.print()
|
|
1718
|
+
|
|
1719
|
+
url = f"https://github.com/{_UPDATE_REPO}/archive/refs/tags/v{latest}.tar.gz"
|
|
1720
|
+
try:
|
|
1721
|
+
proc = subprocess.Popen(
|
|
1722
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", url],
|
|
1723
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
1724
|
+
text=True, bufsize=1,
|
|
1725
|
+
)
|
|
1726
|
+
for line in proc.stdout:
|
|
1727
|
+
console.print(f" {line.rstrip()}")
|
|
1728
|
+
proc.wait()
|
|
1729
|
+
|
|
1730
|
+
if proc.returncode == 0:
|
|
1731
|
+
cfg = load_config()
|
|
1732
|
+
cfg["_update_check"] = 0
|
|
1733
|
+
save_config(cfg)
|
|
1734
|
+
try:
|
|
1735
|
+
import importlib
|
|
1736
|
+
import cinnamon as _pkg
|
|
1737
|
+
importlib.reload(_pkg)
|
|
1738
|
+
new_version = _pkg.__version__
|
|
1739
|
+
except Exception:
|
|
1740
|
+
new_version = None
|
|
1741
|
+
if new_version and new_version != current:
|
|
1742
|
+
_print_success(f"Updated to {new_version}!")
|
|
1743
|
+
else:
|
|
1744
|
+
_print_error(
|
|
1745
|
+
f"pip reported success but version is still {current}.",
|
|
1746
|
+
"You may be installing into a different Python environment. Try: "
|
|
1747
|
+
f"python -m pip install --upgrade {url}",
|
|
1748
|
+
)
|
|
1749
|
+
else:
|
|
1750
|
+
_print_error(f"Update failed (exit code {proc.returncode}).")
|
|
1751
|
+
except Exception as e:
|
|
1752
|
+
_print_error("Update failed.", str(e))
|
|
1753
|
+
|
|
1754
|
+
|
|
1755
|
+
# ---------------------------------------------------------------------------
|
|
1756
|
+
# history
|
|
1757
|
+
# ---------------------------------------------------------------------------
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
@cli.command()
|
|
1761
|
+
@click.argument("query", nargs=-1, required=False)
|
|
1762
|
+
@click.option("--clear", is_flag=True, help="Clear all watch history")
|
|
1763
|
+
def history(query, clear):
|
|
1764
|
+
"""Show watch history and resume from last episode."""
|
|
1765
|
+
from .history import clear_history, get_history, list_history
|
|
1766
|
+
|
|
1767
|
+
if clear:
|
|
1768
|
+
clear_history()
|
|
1769
|
+
_print_success("Watch history cleared.")
|
|
1770
|
+
return
|
|
1771
|
+
|
|
1772
|
+
q = " ".join(query) if query else None
|
|
1773
|
+
if q:
|
|
1774
|
+
entry = get_history(q)
|
|
1775
|
+
if not entry:
|
|
1776
|
+
_print_info(f"No history found for \"{q}\".")
|
|
1777
|
+
return
|
|
1778
|
+
_print_info(f"Last watched [bold]{q}[/bold]: S{entry.get('season', 1)}E{entry.get('episode')}")
|
|
1779
|
+
_play_with_menu(
|
|
1780
|
+
{"name": q, "id": 0},
|
|
1781
|
+
entry.get("season", 1),
|
|
1782
|
+
entry.get("episode"),
|
|
1783
|
+
None,
|
|
1784
|
+
"",
|
|
1785
|
+
entry.get("scraper") or "anime",
|
|
1786
|
+
None,
|
|
1787
|
+
entry.get("quality"),
|
|
1788
|
+
False,
|
|
1789
|
+
False,
|
|
1790
|
+
translation=entry.get("translation"),
|
|
1791
|
+
)
|
|
1792
|
+
return
|
|
1793
|
+
|
|
1794
|
+
entries = list_history()
|
|
1795
|
+
if not entries:
|
|
1796
|
+
_print_info("No watch history yet.")
|
|
1797
|
+
return
|
|
1798
|
+
|
|
1799
|
+
table = Table(border_style="dim")
|
|
1800
|
+
table.add_column("Show", style="cyan")
|
|
1801
|
+
table.add_column("Episode", style="green")
|
|
1802
|
+
table.add_column("Last watched", style="yellow")
|
|
1803
|
+
|
|
1804
|
+
import datetime
|
|
1805
|
+
for name, e in entries:
|
|
1806
|
+
ts = e.get("timestamp", 0)
|
|
1807
|
+
date_label = datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else ""
|
|
1808
|
+
table.add_row(name, f"S{e.get('season', 1)}E{e.get('episode')}", date_label)
|
|
1809
|
+
|
|
1810
|
+
console.print(table)
|
|
1811
|
+
|
|
1812
|
+
if _prompt(" Clear all history?", default="n").strip().lower() in ("y", "yes"):
|
|
1813
|
+
clear_history()
|
|
1814
|
+
_print_success("Watch history cleared.")
|
|
1815
|
+
|
|
1816
|
+
|
|
1817
|
+
# ---------------------------------------------------------------------------
|
|
1818
|
+
# entry point
|
|
1819
|
+
# ---------------------------------------------------------------------------
|
|
1820
|
+
|
|
1821
|
+
if __name__ == "__main__":
|
|
1822
|
+
cli()
|