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 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()