cinnamon-cli 0.2.33__tar.gz → 0.2.37__tar.gz
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_cli-0.2.33/cinnamon_cli.egg-info → cinnamon_cli-0.2.37}/PKG-INFO +5 -2
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/README.md +4 -1
- cinnamon_cli-0.2.37/cinnamon/__init__.py +1 -0
- cinnamon_cli-0.2.37/cinnamon/anilist.py +92 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/cli.py +78 -175
- cinnamon_cli-0.2.37/cinnamon/embedapi.py +198 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/player.py +25 -6
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/scrapers/__init__.py +0 -1
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/scrapers/anime.py +14 -1
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/scrapers/base.py +1 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/scrapers/vidsrc.py +1 -1
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/scrapers/webstream.py +97 -54
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37/cinnamon_cli.egg-info}/PKG-INFO +5 -2
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon_cli.egg-info/SOURCES.txt +1 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/pyproject.toml +1 -1
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/tests/test_ui_smoke.py +1 -1
- cinnamon_cli-0.2.33/cinnamon/__init__.py +0 -1
- cinnamon_cli-0.2.33/cinnamon/anilist.py +0 -33
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/LICENSE +0 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/config.py +0 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/downloads.py +0 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/errors.py +0 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/history.py +0 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/scrapers/torrentio.py +0 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/tmdb.py +0 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon/tui.py +0 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon_cli.egg-info/dependency_links.txt +0 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon_cli.egg-info/entry_points.txt +0 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon_cli.egg-info/requires.txt +0 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/cinnamon_cli.egg-info/top_level.txt +0 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/setup.cfg +0 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/tests/test_directstream.py +0 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/tests/test_directstream2.py +0 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/tests/test_directstream3.py +0 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/tests/test_directstream4.py +0 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/tests/test_directstream5.py +0 -0
- {cinnamon_cli-0.2.33 → cinnamon_cli-0.2.37}/tests/test_directstream6.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cinnamon-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.37
|
|
4
4
|
Summary: CLI to search TV shows, movies & anime via TMDB/AniList, scrape m3u8 links, and play in VLC/mpv
|
|
5
5
|
Author: pizza-droid
|
|
6
6
|
License: CC BY-NC 4.0
|
|
@@ -47,7 +47,7 @@ pip install cinnamon-cli
|
|
|
47
47
|
cinnamon setup
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
> The setup wizard asks for a TMDB API key ([Create a free account here](https://www.themoviedb.org/signup)), the api key is used for show and movie information.
|
|
50
|
+
> The setup wizard asks for a TMDB API key ([Create a free account here](https://www.themoviedb.org/signup)), the api key is used for show and movie information. If you skip it, `search` & `watch` fall back to an **experimental** 2embed metadata proxy (no key needed) — it works but is less reliable.
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
|
|
@@ -149,6 +149,7 @@ cinnamon search "Breaking Bad" -d -e 1-5
|
|
|
149
149
|
| `webstream` | TV shows & movies from vixsrc.to and vidlink.pro (HLS) |
|
|
150
150
|
| `anime` | Anime from allanime.day via mp4upload |
|
|
151
151
|
|
|
152
|
+
|
|
152
153
|
**Optional (install with `cinnamon install <name>`):**
|
|
153
154
|
|
|
154
155
|
| Name | Needs |
|
|
@@ -177,6 +178,8 @@ cinnamon config set-api-key YOUR_KEY
|
|
|
177
178
|
|
|
178
179
|
## Notes
|
|
179
180
|
|
|
181
|
+
**Experimental 2embed metadata proxy:** When no TMDB API key is configured, `search` and `watch` fall back to `api.2embed.cc` (no key needed). This works for most popular shows/movies but may be slower, less reliable, or return incomplete results. We reccomend getting a TMDB key for a more reliable experience
|
|
182
|
+
|
|
180
183
|
This project is mostly vibe coded and our lazy ass didnt even write more than 300 lines.
|
|
181
184
|
feel free to give us feedback so we can improve this project and make it as good as possible.
|
|
182
185
|
|
|
@@ -18,7 +18,7 @@ pip install cinnamon-cli
|
|
|
18
18
|
cinnamon setup
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
> The setup wizard asks for a TMDB API key ([Create a free account here](https://www.themoviedb.org/signup)), the api key is used for show and movie information.
|
|
21
|
+
> The setup wizard asks for a TMDB API key ([Create a free account here](https://www.themoviedb.org/signup)), the api key is used for show and movie information. If you skip it, `search` & `watch` fall back to an **experimental** 2embed metadata proxy (no key needed) — it works but is less reliable.
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
|
|
@@ -120,6 +120,7 @@ cinnamon search "Breaking Bad" -d -e 1-5
|
|
|
120
120
|
| `webstream` | TV shows & movies from vixsrc.to and vidlink.pro (HLS) |
|
|
121
121
|
| `anime` | Anime from allanime.day via mp4upload |
|
|
122
122
|
|
|
123
|
+
|
|
123
124
|
**Optional (install with `cinnamon install <name>`):**
|
|
124
125
|
|
|
125
126
|
| Name | Needs |
|
|
@@ -148,6 +149,8 @@ cinnamon config set-api-key YOUR_KEY
|
|
|
148
149
|
|
|
149
150
|
## Notes
|
|
150
151
|
|
|
152
|
+
**Experimental 2embed metadata proxy:** When no TMDB API key is configured, `search` and `watch` fall back to `api.2embed.cc` (no key needed). This works for most popular shows/movies but may be slower, less reliable, or return incomplete results. We reccomend getting a TMDB key for a more reliable experience
|
|
153
|
+
|
|
151
154
|
This project is mostly vibe coded and our lazy ass didnt even write more than 300 lines.
|
|
152
155
|
feel free to give us feedback so we can improve this project and make it as good as possible.
|
|
153
156
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.37"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
from .errors import CinnamonError
|
|
4
|
+
|
|
5
|
+
API = "https://graphql.anilist.co"
|
|
6
|
+
UA = "cinnamon/0.1.0"
|
|
7
|
+
|
|
8
|
+
_SEARCH_QUERY = """
|
|
9
|
+
query ($search: String, $page: Int) {
|
|
10
|
+
Page(page: $page, perPage: 20) {
|
|
11
|
+
media(search: $search, type: ANIME) {
|
|
12
|
+
id
|
|
13
|
+
idMal
|
|
14
|
+
title { romaji english native }
|
|
15
|
+
episodes
|
|
16
|
+
status
|
|
17
|
+
format
|
|
18
|
+
startDate { year }
|
|
19
|
+
genres
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_RELATION_QUERY = """
|
|
27
|
+
query ($search: String) {
|
|
28
|
+
Page(page: 1, perPage: 5) {
|
|
29
|
+
media(search: $search, type: ANIME) {
|
|
30
|
+
id
|
|
31
|
+
title { romaji english }
|
|
32
|
+
relations {
|
|
33
|
+
edges {
|
|
34
|
+
relationType
|
|
35
|
+
node {
|
|
36
|
+
id
|
|
37
|
+
title { romaji english }
|
|
38
|
+
episodes
|
|
39
|
+
status
|
|
40
|
+
format
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def search_anime(query):
|
|
51
|
+
try:
|
|
52
|
+
resp = requests.post(
|
|
53
|
+
API,
|
|
54
|
+
json={"query": _SEARCH_QUERY, "variables": {"search": query, "page": 1}},
|
|
55
|
+
timeout=15,
|
|
56
|
+
headers={"User-Agent": UA, "Content-Type": "application/json", "Accept": "application/json"},
|
|
57
|
+
)
|
|
58
|
+
resp.raise_for_status()
|
|
59
|
+
data = resp.json()
|
|
60
|
+
return data.get("data", {}).get("Page", {}).get("media", [])
|
|
61
|
+
except requests.ConnectionError:
|
|
62
|
+
raise CinnamonError("Could not reach AniList. Check your internet connection.")
|
|
63
|
+
except requests.Timeout:
|
|
64
|
+
raise CinnamonError("AniList request timed out.")
|
|
65
|
+
except requests.HTTPError as e:
|
|
66
|
+
raise CinnamonError(f"AniList returned an error (HTTP {e.response.status_code}).")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def find_sequel(anime_name):
|
|
70
|
+
"""Search AniList for an anime and return its first SEQUEL relation, or None."""
|
|
71
|
+
try:
|
|
72
|
+
resp = requests.post(
|
|
73
|
+
API,
|
|
74
|
+
json={"query": _RELATION_QUERY, "variables": {"search": anime_name}},
|
|
75
|
+
timeout=15,
|
|
76
|
+
headers={"User-Agent": UA, "Content-Type": "application/json", "Accept": "application/json"},
|
|
77
|
+
)
|
|
78
|
+
resp.raise_for_status()
|
|
79
|
+
data = resp.json()
|
|
80
|
+
media_list = data.get("data", {}).get("Page", {}).get("media", [])
|
|
81
|
+
for m in media_list:
|
|
82
|
+
edges = m.get("relations", {}).get("edges", [])
|
|
83
|
+
for e in edges:
|
|
84
|
+
if e.get("relationType") == "SEQUEL":
|
|
85
|
+
node = e.get("node", {})
|
|
86
|
+
title = node.get("title", {})
|
|
87
|
+
name = title.get("romaji") or title.get("english") or ""
|
|
88
|
+
if name:
|
|
89
|
+
return {"id": node["id"], "name": name}
|
|
90
|
+
return None
|
|
91
|
+
except (requests.RequestException, CinnamonError):
|
|
92
|
+
return None
|
|
@@ -27,6 +27,7 @@ from .config import (
|
|
|
27
27
|
from .errors import (
|
|
28
28
|
CinnamonError,
|
|
29
29
|
MissingAPIKey,
|
|
30
|
+
PlayerError,
|
|
30
31
|
PlayerLaunchError,
|
|
31
32
|
PlayerNotFoundError,
|
|
32
33
|
ScraperError,
|
|
@@ -38,6 +39,7 @@ from .errors import (
|
|
|
38
39
|
)
|
|
39
40
|
from .player import download_video, play, ytdlp_install_hint
|
|
40
41
|
from .scrapers import get_scraper, list_scrapers
|
|
42
|
+
from .embedapi import EmbedClient
|
|
41
43
|
from .tmdb import TMDBClient
|
|
42
44
|
|
|
43
45
|
console = Console()
|
|
@@ -160,141 +162,6 @@ def _prompt(message, default=None, password=False):
|
|
|
160
162
|
return default
|
|
161
163
|
|
|
162
164
|
|
|
163
|
-
def _pick_episode_keys(items, label_fn, message):
|
|
164
|
-
"""Keyboard-driven episode picker.
|
|
165
|
-
|
|
166
|
-
Press a number once to *select* (highlight) that episode; press the same
|
|
167
|
-
number again to *confirm* and play it. Arrow keys / j / k move the
|
|
168
|
-
highlight, Enter confirms the highlighted episode, and q / esc abort.
|
|
169
|
-
|
|
170
|
-
Multi-digit entry works too: typing "1" then "2" highlights episode 12,
|
|
171
|
-
and repeating the final digit (or pressing Enter) confirms it.
|
|
172
|
-
"""
|
|
173
|
-
if not items:
|
|
174
|
-
return None
|
|
175
|
-
n = len(items)
|
|
176
|
-
|
|
177
|
-
try:
|
|
178
|
-
from prompt_toolkit import Application
|
|
179
|
-
from prompt_toolkit.layout import Layout, Window
|
|
180
|
-
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
181
|
-
from prompt_toolkit.key_binding import KeyBindings
|
|
182
|
-
except Exception:
|
|
183
|
-
return None
|
|
184
|
-
|
|
185
|
-
t = get_theme()
|
|
186
|
-
accent = _q_hex(t.get("accent", "orange1"))
|
|
187
|
-
dim = _q_hex(t.get("dim", "grey50"))
|
|
188
|
-
|
|
189
|
-
class _State:
|
|
190
|
-
sel = 0
|
|
191
|
-
buffer = ""
|
|
192
|
-
last_key = None
|
|
193
|
-
last_press = 0.0
|
|
194
|
-
result = None
|
|
195
|
-
abort = False
|
|
196
|
-
|
|
197
|
-
state = _State()
|
|
198
|
-
app_ref = [None]
|
|
199
|
-
|
|
200
|
-
def _render():
|
|
201
|
-
lines = [(f"bold fg:{accent}", f"{message}\n")]
|
|
202
|
-
lines.append((f"fg:{dim}", " press a number to select · press it again to play · ↑↓/jk move · enter play · q quit\n\n"))
|
|
203
|
-
start = max(0, min(state.sel - 7, n - 15))
|
|
204
|
-
end = min(n, start + 15)
|
|
205
|
-
for i in range(start, end):
|
|
206
|
-
ep = items[i]
|
|
207
|
-
label = label_fn(ep)
|
|
208
|
-
num = f"{i + 1:>3}"
|
|
209
|
-
if i == state.sel:
|
|
210
|
-
if state.buffer:
|
|
211
|
-
lines.append((f"bold fg:{accent}", f" {_POINTER} {num} {label}\n"))
|
|
212
|
-
else:
|
|
213
|
-
lines.append((f"bold fg:{accent}", f" {_POINTER} {num} {label}\n"))
|
|
214
|
-
else:
|
|
215
|
-
lines.append((f"fg:{dim}", f" {num} {label}\n"))
|
|
216
|
-
return lines
|
|
217
|
-
|
|
218
|
-
control = FormattedTextControl(_render)
|
|
219
|
-
|
|
220
|
-
def _confirm():
|
|
221
|
-
state.result = items[state.sel]
|
|
222
|
-
if app_ref[0] is not None:
|
|
223
|
-
app_ref[0].exit()
|
|
224
|
-
|
|
225
|
-
def _abort():
|
|
226
|
-
state.abort = True
|
|
227
|
-
if app_ref[0] is not None:
|
|
228
|
-
app_ref[0].exit()
|
|
229
|
-
|
|
230
|
-
def _press_digit(d):
|
|
231
|
-
now = _time.time()
|
|
232
|
-
if state.last_key is None or (now - state.last_press) > 1.2:
|
|
233
|
-
state.buffer = str(d)
|
|
234
|
-
else:
|
|
235
|
-
state.buffer += str(d)
|
|
236
|
-
state.last_press = now
|
|
237
|
-
last_key = state.last_key
|
|
238
|
-
state.last_key = d
|
|
239
|
-
try:
|
|
240
|
-
idx = int(state.buffer) - 1
|
|
241
|
-
except ValueError:
|
|
242
|
-
idx = -1
|
|
243
|
-
if 0 <= idx < n:
|
|
244
|
-
state.sel = idx
|
|
245
|
-
# Pressing the same digit twice in a row confirms the highlight.
|
|
246
|
-
if d == last_key:
|
|
247
|
-
_confirm()
|
|
248
|
-
return
|
|
249
|
-
if app_ref[0] is not None:
|
|
250
|
-
app_ref[0].invalidate()
|
|
251
|
-
|
|
252
|
-
kb = KeyBindings()
|
|
253
|
-
|
|
254
|
-
for d in range(10):
|
|
255
|
-
kb.add(str(d))(lambda event, d=d: _press_digit(d))
|
|
256
|
-
|
|
257
|
-
@kb.add("up")
|
|
258
|
-
@kb.add("k")
|
|
259
|
-
def _up(event):
|
|
260
|
-
state.buffer = ""
|
|
261
|
-
state.last_key = None
|
|
262
|
-
state.sel = (state.sel - 1) % n
|
|
263
|
-
event.app.invalidate()
|
|
264
|
-
|
|
265
|
-
@kb.add("down")
|
|
266
|
-
@kb.add("j")
|
|
267
|
-
def _down(event):
|
|
268
|
-
state.buffer = ""
|
|
269
|
-
state.last_key = None
|
|
270
|
-
state.sel = (state.sel + 1) % n
|
|
271
|
-
event.app.invalidate()
|
|
272
|
-
|
|
273
|
-
@kb.add("enter")
|
|
274
|
-
def _enter(event):
|
|
275
|
-
_confirm()
|
|
276
|
-
|
|
277
|
-
@kb.add("q")
|
|
278
|
-
@kb.add("escape")
|
|
279
|
-
def _quit(event):
|
|
280
|
-
_abort()
|
|
281
|
-
|
|
282
|
-
app = Application(
|
|
283
|
-
layout=Layout(Window(control, always_scroll=True)),
|
|
284
|
-
key_bindings=kb,
|
|
285
|
-
mouse_support=False,
|
|
286
|
-
full_screen=False,
|
|
287
|
-
)
|
|
288
|
-
app_ref[0] = app
|
|
289
|
-
try:
|
|
290
|
-
app.run()
|
|
291
|
-
except Exception:
|
|
292
|
-
return None
|
|
293
|
-
if state.abort:
|
|
294
|
-
return None
|
|
295
|
-
return state.result
|
|
296
|
-
|
|
297
|
-
|
|
298
165
|
_UPDATE_CHECK_CACHE = 86400 # 24 hours
|
|
299
166
|
|
|
300
167
|
|
|
@@ -380,14 +247,10 @@ def _get_tmdb():
|
|
|
380
247
|
try:
|
|
381
248
|
return TMDBClient()
|
|
382
249
|
except MissingAPIKey:
|
|
383
|
-
|
|
384
|
-
"TMDB API key not configured.",
|
|
385
|
-
'Run "cinnamon setup" for a step-by-step guide to get a free key.',
|
|
386
|
-
)
|
|
387
|
-
raise SystemExit(1)
|
|
250
|
+
return EmbedClient()
|
|
388
251
|
|
|
389
252
|
|
|
390
|
-
def
|
|
253
|
+
def _handle_errors(fn):
|
|
391
254
|
@wraps(fn)
|
|
392
255
|
def wrapper(*a, **kw):
|
|
393
256
|
try:
|
|
@@ -402,8 +265,12 @@ def _handle_tmdb_error(fn):
|
|
|
402
265
|
_print_error(str(e))
|
|
403
266
|
except TMDBError as e:
|
|
404
267
|
_print_error(str(e))
|
|
268
|
+
except (ScraperError, PlayerError) as e:
|
|
269
|
+
_print_error(str(e))
|
|
405
270
|
except MissingAPIKey:
|
|
406
271
|
_get_tmdb()
|
|
272
|
+
except Exception as e:
|
|
273
|
+
_print_error("Something went wrong.", str(e)[:500])
|
|
407
274
|
|
|
408
275
|
return wrapper
|
|
409
276
|
|
|
@@ -587,17 +454,19 @@ def _play_with_menu(show, season_num, ep_start, ep_end, ep_name, scraper, player
|
|
|
587
454
|
theme = get_theme()
|
|
588
455
|
console.print()
|
|
589
456
|
|
|
457
|
+
choices = [
|
|
458
|
+
questionary.Choice(title="Next episode", value="next"),
|
|
459
|
+
questionary.Choice(title="Previous episode", value="prev"),
|
|
460
|
+
questionary.Choice(title="Replay", value="replay"),
|
|
461
|
+
]
|
|
462
|
+
if scraper == "anime":
|
|
463
|
+
choices.append(questionary.Choice(title="Next season \u2192", value="next_season"))
|
|
464
|
+
choices += [
|
|
465
|
+
questionary.Choice(title="Change quality", value="quality"),
|
|
466
|
+
questionary.Choice(title="Quit", value="quit"),
|
|
467
|
+
]
|
|
590
468
|
try:
|
|
591
|
-
choice = _select(
|
|
592
|
-
"Options",
|
|
593
|
-
choices=[
|
|
594
|
-
questionary.Choice(title="Next episode", value="next"),
|
|
595
|
-
questionary.Choice(title="Previous episode", value="prev"),
|
|
596
|
-
questionary.Choice(title="Replay", value="replay"),
|
|
597
|
-
questionary.Choice(title="Change quality", value="quality"),
|
|
598
|
-
questionary.Choice(title="Quit", value="quit"),
|
|
599
|
-
],
|
|
600
|
-
)
|
|
469
|
+
choice = _select("Options", choices=choices)
|
|
601
470
|
except Exception:
|
|
602
471
|
return
|
|
603
472
|
|
|
@@ -611,6 +480,29 @@ def _play_with_menu(show, season_num, ep_start, ep_end, ep_name, scraper, player
|
|
|
611
480
|
ep_name = f"S{season_num:02d}E{ep_num:02d}"
|
|
612
481
|
elif choice == "replay":
|
|
613
482
|
pass
|
|
483
|
+
elif choice == "next_season":
|
|
484
|
+
try:
|
|
485
|
+
from .anilist import find_sequel as _find_sequel
|
|
486
|
+
sequel = _find_sequel(show.get("name", ""))
|
|
487
|
+
if not sequel:
|
|
488
|
+
_print_error("No next season found on AniList.")
|
|
489
|
+
continue
|
|
490
|
+
from .scrapers.anime import _find_show as _aa_find, _allanime_episodes as _aa_eps
|
|
491
|
+
import requests as _req
|
|
492
|
+
_as = _req.Session()
|
|
493
|
+
_as.headers.update({"User-Agent": "Mozilla/5.0"})
|
|
494
|
+
_aid = _aa_find(_as, sequel["name"])
|
|
495
|
+
if not _aid:
|
|
496
|
+
_print_error(f"No match for \"{sequel['name']}\" on allanime.")
|
|
497
|
+
continue
|
|
498
|
+
_ed = _aa_eps(_as, _aid)
|
|
499
|
+
if not _ed:
|
|
500
|
+
_print_error("No episode data from allanime.")
|
|
501
|
+
continue
|
|
502
|
+
_run_anime_flow(sequel["name"], _ed, player=player, quality=quality, info_only=info_only, download=download)
|
|
503
|
+
return
|
|
504
|
+
except Exception as _e:
|
|
505
|
+
_print_error("Failed to load next season.", str(_e))
|
|
614
506
|
elif choice == "quality":
|
|
615
507
|
try:
|
|
616
508
|
quality = _select(
|
|
@@ -740,7 +632,7 @@ def _resolve_and_play(show, season_num, ep_num, ep_name, scraper, player, qualit
|
|
|
740
632
|
return None # magnets: can't wait for process
|
|
741
633
|
else:
|
|
742
634
|
console.print(f" [{theme['info']}]Opening in {player_choice.upper()}...[/{theme['info']}]")
|
|
743
|
-
return play(result.m3u8_url, title=result.title, player=player_choice, season=season_num, episode=ep_num, referer=result.referer)
|
|
635
|
+
return play(result.m3u8_url, title=result.title, player=player_choice, season=season_num, episode=ep_num, referer=result.referer, subtitle_url=result.subtitle_url)
|
|
744
636
|
except PlayerNotFoundError as e:
|
|
745
637
|
_print_error(str(e))
|
|
746
638
|
return None
|
|
@@ -853,7 +745,8 @@ def cli(ctx, setup):
|
|
|
853
745
|
border_style="bright_yellow",
|
|
854
746
|
))
|
|
855
747
|
console.print()
|
|
856
|
-
|
|
748
|
+
console.print(" [dim]Search & watch will use an experimental proxy (no key needed).[/dim]")
|
|
749
|
+
if Confirm.ask("Add a TMDB API key for better results?", default=False):
|
|
857
750
|
ctx.invoke(setup_cmd)
|
|
858
751
|
return
|
|
859
752
|
theme = get_theme()
|
|
@@ -899,7 +792,12 @@ def _setup_wizard():
|
|
|
899
792
|
else:
|
|
900
793
|
api_key = _setup_api_key()
|
|
901
794
|
else:
|
|
902
|
-
|
|
795
|
+
console.print(" No TMDB API key found.")
|
|
796
|
+
console.print(" [dim]Without it, search & watch use an experimental 2embed proxy (less reliable).[/dim]")
|
|
797
|
+
if Confirm.ask(" Add a TMDB API key now?", default=True):
|
|
798
|
+
api_key = _setup_api_key()
|
|
799
|
+
else:
|
|
800
|
+
api_key = None
|
|
903
801
|
|
|
904
802
|
if api_key and api_key != existing_key:
|
|
905
803
|
cfg["tmdb_api_key"] = api_key
|
|
@@ -978,7 +876,7 @@ def _setup_api_key():
|
|
|
978
876
|
webbrowser.open("https://www.themoviedb.org/signup")
|
|
979
877
|
|
|
980
878
|
console.print()
|
|
981
|
-
api_key = Prompt.ask("[bold]Paste your TMDB API Key[/bold]")
|
|
879
|
+
api_key = Prompt.ask("[bold]Paste your TMDB API Key[/bold] [dim](leave empty to skip)[/dim]")
|
|
982
880
|
if not api_key.strip():
|
|
983
881
|
return None
|
|
984
882
|
|
|
@@ -1015,7 +913,7 @@ def _theme_preview(name):
|
|
|
1015
913
|
@click.option("-q", "--quality", help="Video quality: 480p, 720p, 1080p, best, worst")
|
|
1016
914
|
@click.option("-d", "--download", is_flag=True, help="Download instead of streaming")
|
|
1017
915
|
@click.option("--info-only", is_flag=True, help="Show the m3u8 URL without playing")
|
|
1018
|
-
@
|
|
916
|
+
@_handle_errors
|
|
1019
917
|
def search(query, media_type, season, ep_str, scraper, player, quality, download, info_only):
|
|
1020
918
|
"""Search for a show or movie, pick one, and watch it."""
|
|
1021
919
|
_check_for_updates()
|
|
@@ -1100,7 +998,7 @@ def search(query, media_type, season, ep_str, scraper, player, quality, download
|
|
|
1100
998
|
@click.option("-d", "--download", is_flag=True, help="Download instead of streaming")
|
|
1101
999
|
@click.option("--info-only", is_flag=True, help="Show the m3u8 URL without playing")
|
|
1102
1000
|
@click.option("-q", "--quality", help="Video quality: 480p, 720p, 1080p, best, worst")
|
|
1103
|
-
@
|
|
1001
|
+
@_handle_errors
|
|
1104
1002
|
def watch(query, tmdb_id, media_type, season, ep_str, scraper, player, download, info_only, quality):
|
|
1105
1003
|
"""Browse episodes interactively and play one."""
|
|
1106
1004
|
_check_for_updates()
|
|
@@ -1118,7 +1016,7 @@ def watch(query, tmdb_id, media_type, season, ep_str, scraper, player, download,
|
|
|
1118
1016
|
if season is not None and ep_start is not None:
|
|
1119
1017
|
_play_with_menu(show, season, ep_start, ep_end, f"E{ep_start}", scraper, player, quality, info_only, download)
|
|
1120
1018
|
else:
|
|
1121
|
-
_interactive_episode_picker(
|
|
1019
|
+
_interactive_episode_picker(_get_tmdb(), show, scraper, player, quality, info_only, ep_start=ep_start, ep_end=ep_end)
|
|
1122
1020
|
return
|
|
1123
1021
|
|
|
1124
1022
|
tmdb = _get_tmdb()
|
|
@@ -1288,15 +1186,15 @@ def _interactive_episode_picker(tmdb, show, scraper, player, quality, info_only,
|
|
|
1288
1186
|
return
|
|
1289
1187
|
|
|
1290
1188
|
try:
|
|
1291
|
-
|
|
1189
|
+
ep_choices = []
|
|
1190
|
+
for ep in episodes:
|
|
1292
1191
|
ep_num = ep.get("episode_number", "?")
|
|
1293
1192
|
ep_title = ep.get("name", f"Episode {ep_num}")
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
ep_chosen =
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
f"Season {season_num} — select an episode",
|
|
1193
|
+
label = f"E{ep_num:02d} {ep_title}" if isinstance(ep_num, int) else f"{ep_num} {ep_title}"
|
|
1194
|
+
ep_choices.append(questionary.Choice(title=label, value=ep))
|
|
1195
|
+
ep_chosen = _select(
|
|
1196
|
+
f"Season {season_num} - Select an episode:",
|
|
1197
|
+
choices=ep_choices,
|
|
1300
1198
|
)
|
|
1301
1199
|
if not ep_chosen:
|
|
1302
1200
|
return
|
|
@@ -1320,6 +1218,7 @@ def _interactive_episode_picker(tmdb, show, scraper, player, quality, info_only,
|
|
|
1320
1218
|
@click.argument("url")
|
|
1321
1219
|
@click.option("--player", help="Player: vlc, mpv, or auto")
|
|
1322
1220
|
@click.option("--title", default="", help="Media title")
|
|
1221
|
+
@_handle_errors
|
|
1323
1222
|
def play_url(url, player, title):
|
|
1324
1223
|
"""Play a direct m3u8 URL in VLC or mpv."""
|
|
1325
1224
|
config = load_config()
|
|
@@ -1340,7 +1239,7 @@ def play_url(url, player, title):
|
|
|
1340
1239
|
|
|
1341
1240
|
|
|
1342
1241
|
@cli.command()
|
|
1343
|
-
@
|
|
1242
|
+
@_handle_errors
|
|
1344
1243
|
def resume():
|
|
1345
1244
|
"""List interrupted downloads and resume one."""
|
|
1346
1245
|
from .downloads import get, list_all, update as _track_update
|
|
@@ -1609,6 +1508,7 @@ def config_scraper_show(name):
|
|
|
1609
1508
|
|
|
1610
1509
|
|
|
1611
1510
|
@cli.command()
|
|
1511
|
+
@_handle_errors
|
|
1612
1512
|
def scrapers():
|
|
1613
1513
|
"""List available scrapers."""
|
|
1614
1514
|
all_scrapers = list_scrapers()
|
|
@@ -1642,6 +1542,7 @@ def scrapers():
|
|
|
1642
1542
|
|
|
1643
1543
|
@cli.command()
|
|
1644
1544
|
@click.argument("name")
|
|
1545
|
+
@_handle_errors
|
|
1645
1546
|
def install(name):
|
|
1646
1547
|
"""Install an optional scraper (vidsrc, torrentio)."""
|
|
1647
1548
|
from .scrapers import _OPTIONAL_SCRAPERS, install_optional
|
|
@@ -1668,6 +1569,7 @@ def install(name):
|
|
|
1668
1569
|
@click.option("--info-only", is_flag=True, help="Show the stream URL without playing")
|
|
1669
1570
|
@click.option("--sub", "prefer_sub", is_flag=True, help="Prefer subtitled audio")
|
|
1670
1571
|
@click.option("--dub", "prefer_dub", is_flag=True, help="Prefer dubbed audio")
|
|
1572
|
+
@_handle_errors
|
|
1671
1573
|
def anime(query, season, ep_str, download, player, quality, info_only, prefer_sub, prefer_dub):
|
|
1672
1574
|
"""Search anime via AniList (no API key needed) and stream from allanime."""
|
|
1673
1575
|
_check_for_updates()
|
|
@@ -1683,7 +1585,7 @@ def anime(query, season, ep_str, download, player, quality, info_only, prefer_su
|
|
|
1683
1585
|
return
|
|
1684
1586
|
|
|
1685
1587
|
def _title(m):
|
|
1686
|
-
return m.get("title", {}).get("
|
|
1588
|
+
return m.get("title", {}).get("english") or m.get("title", {}).get("romaji") or m.get("title", {}).get("native", "?")
|
|
1687
1589
|
def _year(m):
|
|
1688
1590
|
sd = m.get("startDate") or {}
|
|
1689
1591
|
return str(sd.get("year", "")) if sd.get("year") else ""
|
|
@@ -1785,7 +1687,11 @@ def _run_anime_flow(show_name, episodes_detail, season=None, ep_str=None, player
|
|
|
1785
1687
|
_print_error(f"No episodes for season {season}.")
|
|
1786
1688
|
return
|
|
1787
1689
|
|
|
1788
|
-
tt_keys =
|
|
1690
|
+
tt_keys = [k for k in season_data if season_data[k]]
|
|
1691
|
+
if not tt_keys:
|
|
1692
|
+
_print_error(f"No episodes available for season {season}.")
|
|
1693
|
+
return
|
|
1694
|
+
|
|
1789
1695
|
if translation and translation in tt_keys:
|
|
1790
1696
|
tt = translation
|
|
1791
1697
|
elif "sub" in tt_keys and len(tt_keys) > 1:
|
|
@@ -1815,13 +1721,9 @@ def _run_anime_flow(show_name, episodes_detail, season=None, ep_str=None, player
|
|
|
1815
1721
|
ep_end = max_ep
|
|
1816
1722
|
else:
|
|
1817
1723
|
try:
|
|
1818
|
-
|
|
1819
|
-
ep_chosen =
|
|
1820
|
-
|
|
1821
|
-
lambda e: f"Episode {e}",
|
|
1822
|
-
"Select an episode",
|
|
1823
|
-
)
|
|
1824
|
-
if ep_chosen is None:
|
|
1724
|
+
ep_choices = [questionary.Choice(title=f"Episode {e}", value=e) for e in sorted(episodes)]
|
|
1725
|
+
ep_chosen = _select("Select an episode:", choices=ep_choices)
|
|
1726
|
+
if not ep_chosen:
|
|
1825
1727
|
return
|
|
1826
1728
|
ep_start = int(ep_chosen)
|
|
1827
1729
|
except Exception:
|
|
@@ -1904,6 +1806,7 @@ def update():
|
|
|
1904
1806
|
@cli.command()
|
|
1905
1807
|
@click.argument("query", nargs=-1, required=False)
|
|
1906
1808
|
@click.option("--clear", is_flag=True, help="Clear all watch history")
|
|
1809
|
+
@_handle_errors
|
|
1907
1810
|
def history(query, clear):
|
|
1908
1811
|
"""Show watch history and resume from last episode."""
|
|
1909
1812
|
from .history import clear_history, get_history, list_history
|