cinebox 0.1.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.
- cinebox/__init__.py +808 -0
- cinebox/__main__.py +4 -0
- cinebox/config.py +192 -0
- cinebox/discord_rpc.py +67 -0
- cinebox/downloader.py +117 -0
- cinebox/favorites.py +72 -0
- cinebox/history.py +67 -0
- cinebox/metadata.py +122 -0
- cinebox/models.py +129 -0
- cinebox/player.py +96 -0
- cinebox/providers/__init__.py +27 -0
- cinebox/providers/base.py +126 -0
- cinebox/providers/discovery.py +76 -0
- cinebox/providers/imdb_su.py +183 -0
- cinebox/providers/keyword.py +67 -0
- cinebox/providers/multiembed.py +44 -0
- cinebox/providers/vidsrc.py +126 -0
- cinebox/search.py +92 -0
- cinebox/settings.py +160 -0
- cinebox/subtitles.py +698 -0
- cinebox/ui/__init__.py +8 -0
- cinebox/ui/art.py +60 -0
- cinebox/ui/components.py +718 -0
- cinebox/ui/poster.py +110 -0
- cinebox/ui/terminal.py +153 -0
- cinebox/ui/themes.py +73 -0
- cinebox/utils.py +131 -0
- cinebox-0.1.0.dist-info/METADATA +335 -0
- cinebox-0.1.0.dist-info/RECORD +31 -0
- cinebox-0.1.0.dist-info/WHEEL +4 -0
- cinebox-0.1.0.dist-info/entry_points.txt +3 -0
cinebox/__init__.py
ADDED
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
"""cinebox: TUI for searching, streaming, and downloading movies and TV shows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.align import Align
|
|
9
|
+
|
|
10
|
+
from .config import ensure_config, get
|
|
11
|
+
from .discord_rpc import DiscordRPCManager
|
|
12
|
+
from .downloader import (
|
|
13
|
+
download_stream,
|
|
14
|
+
episode_filename,
|
|
15
|
+
format_selector_for_height,
|
|
16
|
+
resolution_height,
|
|
17
|
+
)
|
|
18
|
+
from .favorites import FavoritesManager
|
|
19
|
+
from .history import HistoryManager
|
|
20
|
+
from .models import MediaType, SearchResult, StreamInfo
|
|
21
|
+
from .player import play, player_exists
|
|
22
|
+
from .providers import ResolutionError, build_registry
|
|
23
|
+
from .providers.imdb_su import ImdbSuProvider, _discover_player_host
|
|
24
|
+
from .search import search_all
|
|
25
|
+
from .subtitles import download_subtitle, fetch_subsource, fetch_yify, pick_track
|
|
26
|
+
from .ui import UIManager
|
|
27
|
+
from .ui.art import get_goodbye_renderable
|
|
28
|
+
from .utils import Log
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_media_type(raw: str) -> MediaType | None:
|
|
32
|
+
if raw == "movie":
|
|
33
|
+
return MediaType.MOVIE
|
|
34
|
+
if raw == "tv":
|
|
35
|
+
return MediaType.TV
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _prompt_player() -> str:
|
|
40
|
+
p = get("player", "default", "mpv")
|
|
41
|
+
if not player_exists(p):
|
|
42
|
+
for fallback in ("mpv", "vlc"):
|
|
43
|
+
if player_exists(fallback):
|
|
44
|
+
Log.warn(f"Configured player '{p}' not found, falling back to '{fallback}'.")
|
|
45
|
+
return fallback
|
|
46
|
+
return p
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CineApp:
|
|
50
|
+
"""Drives the TUI through search → select → play/download flows."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, verbose: bool = False) -> None:
|
|
53
|
+
Log.verbose = verbose
|
|
54
|
+
self.ui = UIManager()
|
|
55
|
+
self.history = HistoryManager()
|
|
56
|
+
self.favorites = FavoritesManager()
|
|
57
|
+
self.rpc = DiscordRPCManager()
|
|
58
|
+
self.registry = build_registry()
|
|
59
|
+
self._interactive = self.ui.comfortable_tui() and sys.stdin.isatty() and sys.stdout.isatty()
|
|
60
|
+
|
|
61
|
+
# -- entry points ------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def run_menu(self) -> None:
|
|
64
|
+
"""Bare `cinebox` opens the home screen menu."""
|
|
65
|
+
if not self._interactive:
|
|
66
|
+
click.echo(
|
|
67
|
+
"No interactive terminal detected. Use 'cinebox search QUERY',\n"
|
|
68
|
+
"'cinebox play <imdb_id>', or 'cinebox download QUERY' instead."
|
|
69
|
+
)
|
|
70
|
+
return
|
|
71
|
+
while True:
|
|
72
|
+
self.ui.show_banner(
|
|
73
|
+
"H: History | F: Favorites | S: Settings | Q: Quit | Type to search"
|
|
74
|
+
)
|
|
75
|
+
try:
|
|
76
|
+
query = self.ui.render_main_menu("", "")
|
|
77
|
+
except (KeyboardInterrupt, EOFError):
|
|
78
|
+
self._goodbye()
|
|
79
|
+
return
|
|
80
|
+
action = self._menu_command(query)
|
|
81
|
+
if action == "quit":
|
|
82
|
+
self._goodbye()
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
def _menu_command(self, query: str) -> str | None:
|
|
86
|
+
q = query.strip().lower()
|
|
87
|
+
if q in ("q", "quit", "exit"):
|
|
88
|
+
return "quit"
|
|
89
|
+
if q in ("h", "history"):
|
|
90
|
+
self.history_menu()
|
|
91
|
+
elif q in ("f", "favorites"):
|
|
92
|
+
self.favorites_menu()
|
|
93
|
+
elif q in ("s", "settings"):
|
|
94
|
+
self.settings_menu()
|
|
95
|
+
elif not q:
|
|
96
|
+
return None
|
|
97
|
+
else:
|
|
98
|
+
self.run_search(query)
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
def run_search(self, query: str, media_type: MediaType | None = None) -> None:
|
|
102
|
+
"""Search, browse results, then route into the play/download flow."""
|
|
103
|
+
try:
|
|
104
|
+
results = self._load(
|
|
105
|
+
f"Searching '{query}'…", search_all, query, media_type, self.registry
|
|
106
|
+
)
|
|
107
|
+
except (KeyboardInterrupt, EOFError):
|
|
108
|
+
return
|
|
109
|
+
if not results:
|
|
110
|
+
if self._interactive:
|
|
111
|
+
self._message("No results", f"No matches for '{query}'.", "error")
|
|
112
|
+
else:
|
|
113
|
+
click.echo(f"No results for '{query}'.")
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
def toggle_favorite(result: SearchResult) -> None:
|
|
117
|
+
if self.favorites.has(result.imdb_id):
|
|
118
|
+
self.favorites.remove(result.imdb_id)
|
|
119
|
+
self._message("Favorites", "Removed from favorites.", "info")
|
|
120
|
+
else:
|
|
121
|
+
self.favorites.add(result)
|
|
122
|
+
self._message("Favorites", "Added to favorites.", "success")
|
|
123
|
+
|
|
124
|
+
if not self._interactive:
|
|
125
|
+
pick = self._fallback_numbered(
|
|
126
|
+
[r.label for r in results], f"Pick a match for '{query}'"
|
|
127
|
+
)
|
|
128
|
+
if pick is None:
|
|
129
|
+
return
|
|
130
|
+
chosen = next((r for r in results if r.label == pick), None)
|
|
131
|
+
if chosen is not None:
|
|
132
|
+
self._handle_result(chosen, intent="play")
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
while True:
|
|
136
|
+
try:
|
|
137
|
+
sel = self.ui.results_menu(results, on_favorite=toggle_favorite)
|
|
138
|
+
except (KeyboardInterrupt, EOFError):
|
|
139
|
+
return
|
|
140
|
+
if sel is None:
|
|
141
|
+
return
|
|
142
|
+
if sel == -1:
|
|
143
|
+
self._goodbye()
|
|
144
|
+
raise SystemExit(0)
|
|
145
|
+
keep = self._handle_result(results[sel], intent="play")
|
|
146
|
+
if not keep:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
# -- TV / movie flows --------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def _handle_result(self, chosen: SearchResult, intent: str) -> bool:
|
|
152
|
+
"""Route to TV or movie flow. Returns True to keep browsing."""
|
|
153
|
+
self.rpc.update_status(chosen.title, chosen.media_type.value)
|
|
154
|
+
if chosen.media_type == MediaType.TV:
|
|
155
|
+
return self._tv_flow(chosen)
|
|
156
|
+
return self._movie_flow(chosen, intent)
|
|
157
|
+
|
|
158
|
+
def _movie_flow(self, chosen: SearchResult, intent: str) -> bool:
|
|
159
|
+
"""Resolve, quality-pick, and play a movie.
|
|
160
|
+
|
|
161
|
+
Returns True when the user wants to keep browsing (Back to List /
|
|
162
|
+
Replay exhausted) and False when quitting.
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
stream = self._load(
|
|
166
|
+
f"Resolving '{chosen.title}'…",
|
|
167
|
+
self.registry.resolve,
|
|
168
|
+
chosen.imdb_id,
|
|
169
|
+
MediaType.MOVIE,
|
|
170
|
+
None,
|
|
171
|
+
None,
|
|
172
|
+
)
|
|
173
|
+
except Exception as e: # noqa: BLE001 - user-facing
|
|
174
|
+
self._resolution_failure(e, "No stream")
|
|
175
|
+
return True
|
|
176
|
+
action = self._quality_and_act(stream, chosen, None, None, intent)
|
|
177
|
+
if not self._interactive or action != "watch":
|
|
178
|
+
return True
|
|
179
|
+
option = self.ui.post_watch_menu(["Replay", "Back to List"])
|
|
180
|
+
if option == "Replay":
|
|
181
|
+
return self._movie_flow(chosen, intent)
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
def _tv_flow(self, chosen: SearchResult) -> bool:
|
|
185
|
+
"""Show the episode list and handle play/download for a TV show.
|
|
186
|
+
|
|
187
|
+
Returns True when the user wants to keep browsing results (Back),
|
|
188
|
+
False when quitting to the home menu (q / Ctrl-C).
|
|
189
|
+
"""
|
|
190
|
+
try:
|
|
191
|
+
eps_map = self._load(
|
|
192
|
+
f"Loading episodes for '{chosen.title}'…",
|
|
193
|
+
self._episode_map,
|
|
194
|
+
chosen.imdb_id,
|
|
195
|
+
)
|
|
196
|
+
except Exception as e: # noqa: BLE001 - user-facing
|
|
197
|
+
self._message("No episodes", f"Could not fetch episodes: {e}", "error")
|
|
198
|
+
return True
|
|
199
|
+
if not eps_map:
|
|
200
|
+
self._message("No episodes", "No episode data returned.", "error")
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
seasons = sorted(
|
|
204
|
+
eps_map.keys(), key=lambda s: int(s) if str(s).isdigit() else 0
|
|
205
|
+
)
|
|
206
|
+
season = int(seasons[0]) if seasons else 1
|
|
207
|
+
if len(seasons) > 1:
|
|
208
|
+
pick = self._pick(
|
|
209
|
+
[f"Season {s}" for s in seasons], f"{chosen.title} — Season"
|
|
210
|
+
)
|
|
211
|
+
if pick is None:
|
|
212
|
+
return True
|
|
213
|
+
season = int(pick.split()[-1])
|
|
214
|
+
|
|
215
|
+
episodes = sorted({int(e) for e in eps_map.get(str(season), [])})
|
|
216
|
+
if not episodes:
|
|
217
|
+
self._message(
|
|
218
|
+
"No episodes", f"No episodes for season {season}.", "error"
|
|
219
|
+
)
|
|
220
|
+
return True
|
|
221
|
+
|
|
222
|
+
while True:
|
|
223
|
+
try:
|
|
224
|
+
choice = self.ui.episode_menu(
|
|
225
|
+
episodes,
|
|
226
|
+
f"{chosen.title} — Season {season}",
|
|
227
|
+
subtitle="↑↓ Navigate | ENTER Play | SPACE Mark | D Download | b Back",
|
|
228
|
+
)
|
|
229
|
+
except (KeyboardInterrupt, EOFError):
|
|
230
|
+
return True
|
|
231
|
+
if choice is None:
|
|
232
|
+
return False
|
|
233
|
+
if choice.action == "back":
|
|
234
|
+
return True
|
|
235
|
+
if choice.action == "download":
|
|
236
|
+
if choice.marked:
|
|
237
|
+
targets = list(choice.marked)
|
|
238
|
+
else:
|
|
239
|
+
targets = [episodes[choice.index]]
|
|
240
|
+
self._download_episodes(chosen, season, targets)
|
|
241
|
+
continue
|
|
242
|
+
self._play_watched_episode(
|
|
243
|
+
chosen, season, episodes[choice.index], episodes
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def _play_watched_episode(
|
|
247
|
+
self,
|
|
248
|
+
chosen: SearchResult,
|
|
249
|
+
season: int,
|
|
250
|
+
episode: int,
|
|
251
|
+
episodes: list[int] | None = None,
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Watch episode-by-episode with Next/Previous/Replay/Back navigation.
|
|
254
|
+
|
|
255
|
+
Mirrors ani-cli-arabic's ``handle_episode_selection`` inner loop:
|
|
256
|
+
navigation is index-based over the episode list; Next at the last
|
|
257
|
+
episode and Previous at the first show an info message and return.
|
|
258
|
+
"""
|
|
259
|
+
if episodes is None:
|
|
260
|
+
try:
|
|
261
|
+
eps_map = self._episode_map(chosen.imdb_id)
|
|
262
|
+
except Exception: # noqa: BLE001
|
|
263
|
+
eps_map = {}
|
|
264
|
+
episodes = sorted({int(e) for e in eps_map.get(str(season), [])})
|
|
265
|
+
try:
|
|
266
|
+
idx = episodes.index(episode)
|
|
267
|
+
except ValueError:
|
|
268
|
+
idx = 0
|
|
269
|
+
while True:
|
|
270
|
+
try:
|
|
271
|
+
stream = self._load(
|
|
272
|
+
f"Resolving S{season:02d}E{episodes[idx]:02d}…",
|
|
273
|
+
self.registry.resolve,
|
|
274
|
+
chosen.imdb_id,
|
|
275
|
+
MediaType.TV,
|
|
276
|
+
season,
|
|
277
|
+
episodes[idx],
|
|
278
|
+
)
|
|
279
|
+
except Exception as e: # noqa: BLE001 - user-facing
|
|
280
|
+
self._resolution_failure(e, "No stream")
|
|
281
|
+
return
|
|
282
|
+
action = self._quality_and_act(stream, chosen, season, episodes[idx], "play")
|
|
283
|
+
if not self._interactive or action != "watch":
|
|
284
|
+
return
|
|
285
|
+
option = self.ui.post_watch_menu()
|
|
286
|
+
if option == "Next Episode":
|
|
287
|
+
if idx + 1 < len(episodes):
|
|
288
|
+
idx += 1
|
|
289
|
+
continue
|
|
290
|
+
self._message("Info", "No more episodes!", "info")
|
|
291
|
+
return
|
|
292
|
+
if option == "Previous Episode":
|
|
293
|
+
if idx > 0:
|
|
294
|
+
idx -= 1
|
|
295
|
+
continue
|
|
296
|
+
self._message("Info", "This is the first episode.", "info")
|
|
297
|
+
return
|
|
298
|
+
if option == "Replay":
|
|
299
|
+
continue
|
|
300
|
+
return # Back to List
|
|
301
|
+
|
|
302
|
+
# -- quality / act -----------------------------------------------------
|
|
303
|
+
|
|
304
|
+
def _quality_and_act(
|
|
305
|
+
self,
|
|
306
|
+
stream: StreamInfo,
|
|
307
|
+
chosen: SearchResult,
|
|
308
|
+
season: int | None,
|
|
309
|
+
episode: int | None,
|
|
310
|
+
intent: str,
|
|
311
|
+
) -> str | None:
|
|
312
|
+
"""Let the user pick a quality and act (watch/download).
|
|
313
|
+
|
|
314
|
+
Returns the chosen action ("watch" or "download"), or None when the
|
|
315
|
+
user backed out.
|
|
316
|
+
"""
|
|
317
|
+
qualities = stream.quality_choices or ["1080p (4.5 Mbps)"]
|
|
318
|
+
label = chosen.title
|
|
319
|
+
if season is not None:
|
|
320
|
+
label += f" — S{season:02d}E{episode:02d}"
|
|
321
|
+
action: str
|
|
322
|
+
if not self._interactive:
|
|
323
|
+
picked_idx = -1
|
|
324
|
+
picked = None
|
|
325
|
+
action = "watch" if intent == "play" else "download"
|
|
326
|
+
else:
|
|
327
|
+
try:
|
|
328
|
+
picked = self.ui.quality_menu(qualities, f"{label} — Quality")
|
|
329
|
+
except (KeyboardInterrupt, EOFError):
|
|
330
|
+
return
|
|
331
|
+
if picked is None:
|
|
332
|
+
return
|
|
333
|
+
if picked[1] == "quit":
|
|
334
|
+
self._goodbye()
|
|
335
|
+
raise SystemExit(0)
|
|
336
|
+
picked_idx, action = picked
|
|
337
|
+
if picked_idx is not None and picked:
|
|
338
|
+
quality_label = qualities[picked_idx]
|
|
339
|
+
else:
|
|
340
|
+
quality_label = qualities[-1]
|
|
341
|
+
self._act(stream, chosen, quality_label, season, episode, action)
|
|
342
|
+
return action
|
|
343
|
+
|
|
344
|
+
def _act(
|
|
345
|
+
self,
|
|
346
|
+
stream: StreamInfo,
|
|
347
|
+
chosen: SearchResult,
|
|
348
|
+
quality_label: str,
|
|
349
|
+
season: int | None,
|
|
350
|
+
episode: int | None,
|
|
351
|
+
action: str,
|
|
352
|
+
) -> None:
|
|
353
|
+
if action == "download":
|
|
354
|
+
out = get("download", "tv_dir" if season is not None else "movies_dir", "~/Movies")
|
|
355
|
+
if season is not None:
|
|
356
|
+
filename = episode_filename(chosen.title, season, episode)
|
|
357
|
+
else:
|
|
358
|
+
filename = None
|
|
359
|
+
height = resolution_height(quality_label)
|
|
360
|
+
try:
|
|
361
|
+
path = self._load(
|
|
362
|
+
f"Downloading {quality_label}…",
|
|
363
|
+
download_stream, stream, out, quality_label, filename,
|
|
364
|
+
format_selector_for_height(height),
|
|
365
|
+
)
|
|
366
|
+
self._message("Done", f"Saved to {path}", "success")
|
|
367
|
+
except Exception as e: # noqa: BLE001 - user-facing
|
|
368
|
+
self._message("Download failed", str(e), "error")
|
|
369
|
+
return
|
|
370
|
+
self._record_history(chosen, season, episode, stream)
|
|
371
|
+
subtitle_file = self._fetch_subtitle(stream, chosen, season, episode)
|
|
372
|
+
player = _prompt_player()
|
|
373
|
+
title = chosen.title
|
|
374
|
+
detail = f"• S{season:02d}E{episode:02d} •" if season is not None else ""
|
|
375
|
+
try:
|
|
376
|
+
self.ui.now_playing(title, detail, quality_label)
|
|
377
|
+
play(
|
|
378
|
+
stream,
|
|
379
|
+
player=player,
|
|
380
|
+
subtitle_file=subtitle_file,
|
|
381
|
+
fullscreen=bool(get("player", "fullscreen", True)),
|
|
382
|
+
extra_args=list(get("player", "extra_args", []) or []),
|
|
383
|
+
)
|
|
384
|
+
except Exception as e: # noqa: BLE001 - user-facing
|
|
385
|
+
self._message("Playback error", str(e), "error")
|
|
386
|
+
|
|
387
|
+
def _download_one(
|
|
388
|
+
self, chosen: SearchResult, season: int | None, episode: int | None, quality: str
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Resolve and download a single title/episode at the given quality."""
|
|
391
|
+
mtype = MediaType.TV if season is not None else MediaType.MOVIE
|
|
392
|
+
try:
|
|
393
|
+
stream = self._load(
|
|
394
|
+
f"Resolving {mtype.value}…",
|
|
395
|
+
self.registry.resolve, chosen.imdb_id, mtype, season, episode,
|
|
396
|
+
)
|
|
397
|
+
except Exception as e: # noqa: BLE001 - user-facing
|
|
398
|
+
self._resolution_failure(e, "No stream")
|
|
399
|
+
return
|
|
400
|
+
out = get("download", "tv_dir" if season is not None else "movies_dir", "~/Movies")
|
|
401
|
+
filename = episode_filename(chosen.title, season, episode) if season is not None else None
|
|
402
|
+
try:
|
|
403
|
+
path = self._load(
|
|
404
|
+
f"Downloading ({quality})…",
|
|
405
|
+
download_stream, stream, out, quality, filename,
|
|
406
|
+
)
|
|
407
|
+
self._record_history(chosen, season, episode, stream)
|
|
408
|
+
self._message("Done", f"Saved to {path}", "success")
|
|
409
|
+
except Exception as e: # noqa: BLE001 - user-facing
|
|
410
|
+
self._message("Download failed", str(e), "error")
|
|
411
|
+
|
|
412
|
+
def _download_episodes(
|
|
413
|
+
self, chosen: SearchResult, season: int, episodes: list[int]
|
|
414
|
+
) -> None:
|
|
415
|
+
out = get("download", "tv_dir", "~/Movies/TV")
|
|
416
|
+
for ep in episodes:
|
|
417
|
+
try:
|
|
418
|
+
stream = self._load(
|
|
419
|
+
f"Resolving S{season:02d}E{ep:02d}…",
|
|
420
|
+
self.registry.resolve, chosen.imdb_id, MediaType.TV, season, ep,
|
|
421
|
+
)
|
|
422
|
+
except Exception as e: # noqa: BLE001 - user-facing
|
|
423
|
+
self._resolution_failure(
|
|
424
|
+
e, f"No stream S{season:02d}E{ep:02d}"
|
|
425
|
+
)
|
|
426
|
+
continue
|
|
427
|
+
if stream.quality_choices:
|
|
428
|
+
height = resolution_height(stream.quality_choices[-1])
|
|
429
|
+
else:
|
|
430
|
+
height = 1080
|
|
431
|
+
filename = episode_filename(chosen.title, season, ep)
|
|
432
|
+
try:
|
|
433
|
+
path = self._load(
|
|
434
|
+
f"Downloading S{season:02d}E{ep:02d}…",
|
|
435
|
+
download_stream, stream, out, None, filename,
|
|
436
|
+
format_selector_for_height(height),
|
|
437
|
+
)
|
|
438
|
+
self._record_history(chosen, season, ep, stream)
|
|
439
|
+
Log.info(f"Saved: {path}")
|
|
440
|
+
except Exception as e: # noqa: BLE001 - user-facing
|
|
441
|
+
self._message("Download failed", f"S{season:02d}E{ep:02d}: {e}", "error")
|
|
442
|
+
|
|
443
|
+
# -- sub-menus ---------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
def history_menu(self) -> None:
|
|
446
|
+
entries = self.history.list()
|
|
447
|
+
if not entries:
|
|
448
|
+
self._message("History", "Nothing watched yet.", "info")
|
|
449
|
+
return
|
|
450
|
+
labels = [self._history_label(e) for e in entries]
|
|
451
|
+
pick = self._pick(labels, "History")
|
|
452
|
+
if pick is None:
|
|
453
|
+
return
|
|
454
|
+
i = next((n for n, lbl in enumerate(labels) if lbl == pick), 0)
|
|
455
|
+
result = self._entry_to_result(entries[i])
|
|
456
|
+
if result is not None:
|
|
457
|
+
self._handle_result(result, intent="play")
|
|
458
|
+
|
|
459
|
+
def favorites_menu(self) -> None:
|
|
460
|
+
entries = self.favorites.list()
|
|
461
|
+
if not entries:
|
|
462
|
+
self._message(
|
|
463
|
+
"Favorites", "No favorites yet (press F while browsing).", "info"
|
|
464
|
+
)
|
|
465
|
+
return
|
|
466
|
+
labels = [
|
|
467
|
+
f"{e.get('title', e.get('imdb_id'))} ({e.get('year')})" for e in entries
|
|
468
|
+
]
|
|
469
|
+
pick = self._pick(labels, "Favorites")
|
|
470
|
+
if pick is None:
|
|
471
|
+
return
|
|
472
|
+
i = next((n for n, lbl in enumerate(labels) if lbl == pick), 0)
|
|
473
|
+
result = self.favorites.to_search_result(entries[i])
|
|
474
|
+
if result is not None:
|
|
475
|
+
self._handle_result(result, intent="play")
|
|
476
|
+
|
|
477
|
+
def settings_menu(self) -> None:
|
|
478
|
+
from .settings import SettingsEditor, all_settings # noqa: PLC0415
|
|
479
|
+
|
|
480
|
+
editor = SettingsEditor(self.ui)
|
|
481
|
+
while True:
|
|
482
|
+
pick = self.ui.settings_menu(all_settings())
|
|
483
|
+
if pick is None:
|
|
484
|
+
return
|
|
485
|
+
editor.edit(pick)
|
|
486
|
+
|
|
487
|
+
def _hard_download(self, chosen: SearchResult, quality: str) -> None:
|
|
488
|
+
"""Direct download path used by the `download` command.
|
|
489
|
+
|
|
490
|
+
Movies resolve once and download; TV shows offer the episode
|
|
491
|
+
picker and download the chosen (or marked) episodes.
|
|
492
|
+
"""
|
|
493
|
+
if chosen.media_type == MediaType.TV:
|
|
494
|
+
try:
|
|
495
|
+
eps_map = self._load(
|
|
496
|
+
f"Loading episodes for '{chosen.title}'…", self._episode_map, chosen.imdb_id
|
|
497
|
+
)
|
|
498
|
+
except Exception as e: # noqa: BLE001 - user-facing
|
|
499
|
+
self._message("No episodes", f"Could not fetch episodes: {e}", "error")
|
|
500
|
+
return
|
|
501
|
+
if not eps_map:
|
|
502
|
+
self._message("No episodes", "No episode data returned.", "error")
|
|
503
|
+
return
|
|
504
|
+
seasons = sorted(eps_map.keys(), key=lambda s: int(s) if str(s).isdigit() else 0)
|
|
505
|
+
season = int(seasons[0]) if seasons else 1
|
|
506
|
+
if len(seasons) > 1 and self._interactive:
|
|
507
|
+
pick = self._pick([f"Season {s}" for s in seasons], f"{chosen.title} — Season")
|
|
508
|
+
if pick is None:
|
|
509
|
+
return
|
|
510
|
+
season = int(pick.split()[-1])
|
|
511
|
+
episodes = sorted({int(e) for e in eps_map.get(str(season), [])})
|
|
512
|
+
if not episodes:
|
|
513
|
+
self._message("No episodes", f"No episodes for season {season}.", "error")
|
|
514
|
+
return
|
|
515
|
+
if self._interactive:
|
|
516
|
+
choice = self.ui.episode_menu(
|
|
517
|
+
episodes,
|
|
518
|
+
f"{chosen.title} — Season {season}",
|
|
519
|
+
subtitle="↑↓ Navigate | SPACE Mark | D Download | b Back",
|
|
520
|
+
)
|
|
521
|
+
if choice is None or choice.action == "back":
|
|
522
|
+
return
|
|
523
|
+
if choice.marked:
|
|
524
|
+
targets = list(choice.marked)
|
|
525
|
+
else:
|
|
526
|
+
targets = [episodes[choice.index]]
|
|
527
|
+
else:
|
|
528
|
+
nums = [f"E{e}" for e in episodes]
|
|
529
|
+
pick = self._fallback_numbered(nums, f"{chosen.title} S{season}")
|
|
530
|
+
if pick is None:
|
|
531
|
+
return
|
|
532
|
+
targets = [int(pick[1:])]
|
|
533
|
+
for ep in targets:
|
|
534
|
+
self._download_one(chosen, season, ep, quality)
|
|
535
|
+
return
|
|
536
|
+
self._download_one(chosen, None, None, quality)
|
|
537
|
+
|
|
538
|
+
# -- helpers -----------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
def _history_label(self, entry: dict) -> str:
|
|
541
|
+
base = entry.get("title", entry.get("imdb_id", "?"))
|
|
542
|
+
if entry.get("year"):
|
|
543
|
+
base += f" ({entry['year']})"
|
|
544
|
+
if entry.get("media_type") == "tv" and entry.get("season") and entry.get("episode"):
|
|
545
|
+
base += f" — S{entry['season']:02d}E{entry['episode']:02d}"
|
|
546
|
+
return base
|
|
547
|
+
|
|
548
|
+
def _episode_map(self, imdb_id: str) -> dict[str, list[str]]:
|
|
549
|
+
host = _discover_player_host()
|
|
550
|
+
return ImdbSuProvider.episode_map(imdb_id, host)
|
|
551
|
+
|
|
552
|
+
def _load(self, message: str, func, *args):
|
|
553
|
+
"""Run with a progress spinner when interactive, else inline."""
|
|
554
|
+
if self._interactive:
|
|
555
|
+
return self.ui.run_with_loading(message, func, *args)
|
|
556
|
+
Log.info(message)
|
|
557
|
+
return func(*args)
|
|
558
|
+
|
|
559
|
+
def _message(self, title: str, body: str, kind: str = "info") -> None:
|
|
560
|
+
"""Show a message panel (TUI) or a plain line (headless)."""
|
|
561
|
+
if self._interactive:
|
|
562
|
+
self.ui.render_message(title, body, kind)
|
|
563
|
+
else:
|
|
564
|
+
icon = {"error": "[ERROR]", "success": "[OK] ", "info": "[INFO]"}.get(kind, "[INFO]")
|
|
565
|
+
click.echo(f"{icon} {title}: {body}")
|
|
566
|
+
|
|
567
|
+
def _resolution_failure(self, e: Exception, fallback: str) -> None:
|
|
568
|
+
"""Report a failed provider chain: per-provider panel or plain line."""
|
|
569
|
+
if not self._interactive:
|
|
570
|
+
click.echo(f"[ERROR] {fallback}: {e}")
|
|
571
|
+
elif isinstance(e, ResolutionError):
|
|
572
|
+
self.ui.render_error_panel(fallback, e.errors)
|
|
573
|
+
else:
|
|
574
|
+
self._message(fallback, str(e), "error")
|
|
575
|
+
|
|
576
|
+
def _pick(self, items: list[str], title: str) -> str | None:
|
|
577
|
+
if self._interactive:
|
|
578
|
+
idx = self.ui.generic_menu(items, title)
|
|
579
|
+
if idx is None or idx == -1:
|
|
580
|
+
return None
|
|
581
|
+
return items[idx]
|
|
582
|
+
return self._fallback_numbered(items, title)
|
|
583
|
+
|
|
584
|
+
def _fallback_numbered(self, items: list[str], title: str) -> str | None:
|
|
585
|
+
click.echo(title)
|
|
586
|
+
for i, item in enumerate(items, start=1):
|
|
587
|
+
click.echo(f" {i:>2}. {item}")
|
|
588
|
+
while True:
|
|
589
|
+
try:
|
|
590
|
+
raw = input("Number: ").strip()
|
|
591
|
+
except (EOFError, KeyboardInterrupt):
|
|
592
|
+
return None
|
|
593
|
+
if not raw.isdigit():
|
|
594
|
+
continue
|
|
595
|
+
idx = int(raw) - 1
|
|
596
|
+
if 0 <= idx < len(items):
|
|
597
|
+
return items[idx]
|
|
598
|
+
|
|
599
|
+
def _record_history(self, chosen: SearchResult, season, episode, stream: StreamInfo) -> None:
|
|
600
|
+
is_tv = chosen.media_type is MediaType.TV
|
|
601
|
+
self.history.add({
|
|
602
|
+
"imdb_id": chosen.imdb_id,
|
|
603
|
+
"title": stream.title or chosen.title,
|
|
604
|
+
"year": chosen.year,
|
|
605
|
+
"media_type": chosen.media_type.value,
|
|
606
|
+
"image_url": chosen.image_url,
|
|
607
|
+
"cast": chosen.cast,
|
|
608
|
+
"season": season if is_tv else None,
|
|
609
|
+
"episode": episode if is_tv else None,
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
def _entry_to_result(self, entry: dict) -> SearchResult | None:
|
|
613
|
+
try:
|
|
614
|
+
return SearchResult(
|
|
615
|
+
imdb_id=entry.get("imdb_id", ""),
|
|
616
|
+
title=entry.get("title", ""),
|
|
617
|
+
year=entry.get("year"),
|
|
618
|
+
media_type=MediaType(entry.get("media_type", "movie")),
|
|
619
|
+
cast=entry.get("cast", ""),
|
|
620
|
+
image_url=entry.get("image_url", ""),
|
|
621
|
+
)
|
|
622
|
+
except (KeyError, ValueError):
|
|
623
|
+
return None
|
|
624
|
+
|
|
625
|
+
def _fetch_subtitle(
|
|
626
|
+
self,
|
|
627
|
+
stream: StreamInfo,
|
|
628
|
+
chosen: SearchResult,
|
|
629
|
+
season: int | None = None,
|
|
630
|
+
episode: int | None = None,
|
|
631
|
+
) -> str | None:
|
|
632
|
+
"""Resolve a subtitle file for the stream from the configured source.
|
|
633
|
+
|
|
634
|
+
Source is selected by ``[subtitle] source``:
|
|
635
|
+
- ``provider``: only the provider's own tracks (never a network hit
|
|
636
|
+
for titles without them);
|
|
637
|
+
- ``yifysubtitles``: always query YIFYS by IMDb ID (movies only);
|
|
638
|
+
- ``subsource``: always query SubSource by title (+ season/episode
|
|
639
|
+
for episodes);
|
|
640
|
+
- ``auto`` (default): provider tracks when available, otherwise
|
|
641
|
+
YIFYS for movies and SubSource for episodes, falling back to the
|
|
642
|
+
other when the primary has nothing.
|
|
643
|
+
|
|
644
|
+
Every language in ``[subtitle] languages`` (defaults: English +
|
|
645
|
+
Arabic) is searched, with ``default_lang`` taking priority for the
|
|
646
|
+
automatic pick. When several tracks are found, a picker menu is shown
|
|
647
|
+
(mirrors the player's subtitle chooser).
|
|
648
|
+
"""
|
|
649
|
+
if not get("subtitle", "enabled", True):
|
|
650
|
+
return None
|
|
651
|
+
source = get("subtitle", "source", "auto")
|
|
652
|
+
lang = get("subtitle", "default_lang", "en")
|
|
653
|
+
langs = list(get("subtitle", "languages", ["en", "ar"]) or [lang])
|
|
654
|
+
if lang and lang in langs:
|
|
655
|
+
langs.remove(lang)
|
|
656
|
+
langs.insert(0, lang)
|
|
657
|
+
tracks = list(stream.subtitles or [])
|
|
658
|
+
use_yify = source in ("auto", "yifysubtitles")
|
|
659
|
+
use_subsource = source in ("auto", "subsource")
|
|
660
|
+
if self._interactive:
|
|
661
|
+
if season is None:
|
|
662
|
+
if use_yify and not tracks:
|
|
663
|
+
tracks += fetch_yify(chosen.imdb_id, langs)
|
|
664
|
+
if use_subsource and not tracks:
|
|
665
|
+
tracks += fetch_subsource(
|
|
666
|
+
chosen.title, langs, year=chosen.year
|
|
667
|
+
)
|
|
668
|
+
elif use_subsource and not tracks:
|
|
669
|
+
# YIFYS does not index TV series; SubSource is per-episode.
|
|
670
|
+
tracks += fetch_subsource(
|
|
671
|
+
chosen.title, langs, season=season, episode=episode
|
|
672
|
+
)
|
|
673
|
+
if not tracks:
|
|
674
|
+
return None
|
|
675
|
+
if not self._interactive or get("subtitle", "auto_download", False) or len(tracks) == 1:
|
|
676
|
+
track = pick_track(tracks, lang)
|
|
677
|
+
else:
|
|
678
|
+
try:
|
|
679
|
+
track = self.ui.subtitle_menu(tracks, f"Subtitles for '{chosen.title}'")
|
|
680
|
+
except (KeyboardInterrupt, EOFError):
|
|
681
|
+
return None
|
|
682
|
+
if track is None:
|
|
683
|
+
return None
|
|
684
|
+
dest = f"/tmp/{chosen.imdb_id}"
|
|
685
|
+
if season is not None:
|
|
686
|
+
dest += f"/s{season:02d}e{episode:02d}"
|
|
687
|
+
try:
|
|
688
|
+
# Some entries can be broken/empty; retry with the next best
|
|
689
|
+
# candidates until a real subtitle file lands.
|
|
690
|
+
candidates = [
|
|
691
|
+
candidate
|
|
692
|
+
for candidate in tracks
|
|
693
|
+
if candidate is not track
|
|
694
|
+
]
|
|
695
|
+
candidates.insert(0, track)
|
|
696
|
+
for candidate in candidates[:5]:
|
|
697
|
+
path = download_subtitle(candidate, dest)
|
|
698
|
+
if not path:
|
|
699
|
+
continue
|
|
700
|
+
return str(path)
|
|
701
|
+
return None
|
|
702
|
+
except Exception as e: # noqa: BLE001 - subtitles are optional
|
|
703
|
+
Log.debug(f"Subtitle fetch skipped: {e}")
|
|
704
|
+
return None
|
|
705
|
+
|
|
706
|
+
def _goodbye(self) -> None:
|
|
707
|
+
self.ui.clear()
|
|
708
|
+
self.ui.print(Align.center(get_goodbye_renderable()))
|
|
709
|
+
self.ui.print()
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
# -- click CLI ---------------------------------------------------------------
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
@click.group(invoke_without_command=True)
|
|
716
|
+
@click.option("--verbose", "-v", is_flag=True, help="Enable debug logging.")
|
|
717
|
+
@click.pass_context
|
|
718
|
+
def main(ctx: click.Context, verbose: bool) -> None:
|
|
719
|
+
"""cinebox: Search, stream, download movies and TV shows."""
|
|
720
|
+
Log.verbose = verbose
|
|
721
|
+
ensure_config()
|
|
722
|
+
if ctx.invoked_subcommand is None:
|
|
723
|
+
CineApp(verbose=verbose).run_menu()
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
@main.command()
|
|
727
|
+
@click.argument("query")
|
|
728
|
+
@click.option(
|
|
729
|
+
"--type", "-t", "media_type",
|
|
730
|
+
type=click.Choice(["movie", "tv", "both"]),
|
|
731
|
+
default="both",
|
|
732
|
+
)
|
|
733
|
+
def search(query: str, media_type: str) -> None:
|
|
734
|
+
"""Search IMDb and browse results in the TUI."""
|
|
735
|
+
CineApp().run_search(query, _parse_media_type(media_type))
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
@main.command("play")
|
|
739
|
+
@click.argument("imdb_id")
|
|
740
|
+
@click.option("--type", "-t", "media_type", type=click.Choice(["movie", "tv"]), default="movie")
|
|
741
|
+
@click.option("--season", "-s", type=int, default=None)
|
|
742
|
+
@click.option("--episode", "-e", type=int, default=None)
|
|
743
|
+
def play_cmd(imdb_id: str, media_type: str, season: int | None, episode: int | None) -> None:
|
|
744
|
+
"""Stream a title directly by IMDb ID."""
|
|
745
|
+
app = CineApp()
|
|
746
|
+
mtype = MediaType.MOVIE if media_type == "movie" else MediaType.TV
|
|
747
|
+
result = app._entry_to_result(
|
|
748
|
+
{"imdb_id": imdb_id, "title": imdb_id, "media_type": mtype.value}
|
|
749
|
+
)
|
|
750
|
+
if result is None:
|
|
751
|
+
click.echo("Invalid IMDb ID.", err=True)
|
|
752
|
+
return
|
|
753
|
+
if mtype == MediaType.TV and season is None and episode is None:
|
|
754
|
+
app._tv_flow(result)
|
|
755
|
+
return
|
|
756
|
+
if mtype == MediaType.TV and season is not None and episode is not None:
|
|
757
|
+
app._play_watched_episode(result, season, episode)
|
|
758
|
+
return
|
|
759
|
+
app._movie_flow(result, "play")
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
@main.command("download")
|
|
763
|
+
@click.argument("query")
|
|
764
|
+
@click.option(
|
|
765
|
+
"--quality", "-q",
|
|
766
|
+
type=click.Choice(["480p", "720p", "1080p", "best"]),
|
|
767
|
+
default=None,
|
|
768
|
+
)
|
|
769
|
+
@click.option(
|
|
770
|
+
"--type", "-t", "media_type",
|
|
771
|
+
type=click.Choice(["movie", "tv", "both"]),
|
|
772
|
+
default="both",
|
|
773
|
+
)
|
|
774
|
+
def download_cmd(query: str, quality: str | None, media_type: str) -> None:
|
|
775
|
+
"""Search and download a title locally."""
|
|
776
|
+
app = CineApp()
|
|
777
|
+
quality = quality or get("download", "quality", "1080p")
|
|
778
|
+
try:
|
|
779
|
+
results = app._load(
|
|
780
|
+
f"Searching '{query}'…", search_all, query, _parse_media_type(media_type), app.registry
|
|
781
|
+
)
|
|
782
|
+
except Exception as e: # noqa: BLE001 - user-facing
|
|
783
|
+
click.echo(f"Error: {e}", err=True)
|
|
784
|
+
return
|
|
785
|
+
if not results:
|
|
786
|
+
click.echo(f"No results for '{query}'.")
|
|
787
|
+
return
|
|
788
|
+
|
|
789
|
+
if app._interactive:
|
|
790
|
+
try:
|
|
791
|
+
sel = app.ui.results_menu(results)
|
|
792
|
+
except (KeyboardInterrupt, EOFError):
|
|
793
|
+
return
|
|
794
|
+
if sel is None:
|
|
795
|
+
return
|
|
796
|
+
chosen = results[sel]
|
|
797
|
+
else:
|
|
798
|
+
pick = app._fallback_numbered([r.label for r in results], f"Pick a match for '{query}'")
|
|
799
|
+
if pick is None:
|
|
800
|
+
return
|
|
801
|
+
chosen = next((r for r in results if r.label == pick), None)
|
|
802
|
+
if chosen is None:
|
|
803
|
+
return
|
|
804
|
+
app._hard_download(chosen, quality)
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
if __name__ == "__main__":
|
|
808
|
+
main()
|