cinnamon-cli 0.2.34__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.34/cinnamon_cli.egg-info → cinnamon_cli-0.2.37}/PKG-INFO +5 -2
- {cinnamon_cli-0.2.34 → 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.34 → cinnamon_cli-0.2.37}/cinnamon/cli.py +68 -26
- cinnamon_cli-0.2.37/cinnamon/embedapi.py +198 -0
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/player.py +25 -6
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/scrapers/__init__.py +0 -1
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/scrapers/anime.py +14 -1
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/scrapers/base.py +1 -0
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/scrapers/vidsrc.py +1 -1
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/scrapers/webstream.py +97 -54
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37/cinnamon_cli.egg-info}/PKG-INFO +5 -2
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon_cli.egg-info/SOURCES.txt +1 -0
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/pyproject.toml +1 -1
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/tests/test_ui_smoke.py +1 -1
- cinnamon_cli-0.2.34/cinnamon/__init__.py +0 -1
- cinnamon_cli-0.2.34/cinnamon/anilist.py +0 -33
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/LICENSE +0 -0
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/config.py +0 -0
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/downloads.py +0 -0
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/errors.py +0 -0
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/history.py +0 -0
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/scrapers/torrentio.py +0 -0
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/tmdb.py +0 -0
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/tui.py +0 -0
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon_cli.egg-info/dependency_links.txt +0 -0
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon_cli.egg-info/entry_points.txt +0 -0
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon_cli.egg-info/requires.txt +0 -0
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon_cli.egg-info/top_level.txt +0 -0
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/setup.cfg +0 -0
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/tests/test_directstream.py +0 -0
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/tests/test_directstream2.py +0 -0
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/tests/test_directstream3.py +0 -0
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/tests/test_directstream4.py +0 -0
- {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/tests/test_directstream5.py +0 -0
- {cinnamon_cli-0.2.34 → 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()
|
|
@@ -245,14 +247,10 @@ def _get_tmdb():
|
|
|
245
247
|
try:
|
|
246
248
|
return TMDBClient()
|
|
247
249
|
except MissingAPIKey:
|
|
248
|
-
|
|
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)
|
|
250
|
+
return EmbedClient()
|
|
253
251
|
|
|
254
252
|
|
|
255
|
-
def
|
|
253
|
+
def _handle_errors(fn):
|
|
256
254
|
@wraps(fn)
|
|
257
255
|
def wrapper(*a, **kw):
|
|
258
256
|
try:
|
|
@@ -267,8 +265,12 @@ def _handle_tmdb_error(fn):
|
|
|
267
265
|
_print_error(str(e))
|
|
268
266
|
except TMDBError as e:
|
|
269
267
|
_print_error(str(e))
|
|
268
|
+
except (ScraperError, PlayerError) as e:
|
|
269
|
+
_print_error(str(e))
|
|
270
270
|
except MissingAPIKey:
|
|
271
271
|
_get_tmdb()
|
|
272
|
+
except Exception as e:
|
|
273
|
+
_print_error("Something went wrong.", str(e)[:500])
|
|
272
274
|
|
|
273
275
|
return wrapper
|
|
274
276
|
|
|
@@ -452,17 +454,19 @@ def _play_with_menu(show, season_num, ep_start, ep_end, ep_name, scraper, player
|
|
|
452
454
|
theme = get_theme()
|
|
453
455
|
console.print()
|
|
454
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
|
+
]
|
|
455
468
|
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
|
-
)
|
|
469
|
+
choice = _select("Options", choices=choices)
|
|
466
470
|
except Exception:
|
|
467
471
|
return
|
|
468
472
|
|
|
@@ -476,6 +480,29 @@ def _play_with_menu(show, season_num, ep_start, ep_end, ep_name, scraper, player
|
|
|
476
480
|
ep_name = f"S{season_num:02d}E{ep_num:02d}"
|
|
477
481
|
elif choice == "replay":
|
|
478
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))
|
|
479
506
|
elif choice == "quality":
|
|
480
507
|
try:
|
|
481
508
|
quality = _select(
|
|
@@ -605,7 +632,7 @@ def _resolve_and_play(show, season_num, ep_num, ep_name, scraper, player, qualit
|
|
|
605
632
|
return None # magnets: can't wait for process
|
|
606
633
|
else:
|
|
607
634
|
console.print(f" [{theme['info']}]Opening in {player_choice.upper()}...[/{theme['info']}]")
|
|
608
|
-
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)
|
|
609
636
|
except PlayerNotFoundError as e:
|
|
610
637
|
_print_error(str(e))
|
|
611
638
|
return None
|
|
@@ -718,7 +745,8 @@ def cli(ctx, setup):
|
|
|
718
745
|
border_style="bright_yellow",
|
|
719
746
|
))
|
|
720
747
|
console.print()
|
|
721
|
-
|
|
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):
|
|
722
750
|
ctx.invoke(setup_cmd)
|
|
723
751
|
return
|
|
724
752
|
theme = get_theme()
|
|
@@ -764,7 +792,12 @@ def _setup_wizard():
|
|
|
764
792
|
else:
|
|
765
793
|
api_key = _setup_api_key()
|
|
766
794
|
else:
|
|
767
|
-
|
|
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
|
|
768
801
|
|
|
769
802
|
if api_key and api_key != existing_key:
|
|
770
803
|
cfg["tmdb_api_key"] = api_key
|
|
@@ -843,7 +876,7 @@ def _setup_api_key():
|
|
|
843
876
|
webbrowser.open("https://www.themoviedb.org/signup")
|
|
844
877
|
|
|
845
878
|
console.print()
|
|
846
|
-
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]")
|
|
847
880
|
if not api_key.strip():
|
|
848
881
|
return None
|
|
849
882
|
|
|
@@ -880,7 +913,7 @@ def _theme_preview(name):
|
|
|
880
913
|
@click.option("-q", "--quality", help="Video quality: 480p, 720p, 1080p, best, worst")
|
|
881
914
|
@click.option("-d", "--download", is_flag=True, help="Download instead of streaming")
|
|
882
915
|
@click.option("--info-only", is_flag=True, help="Show the m3u8 URL without playing")
|
|
883
|
-
@
|
|
916
|
+
@_handle_errors
|
|
884
917
|
def search(query, media_type, season, ep_str, scraper, player, quality, download, info_only):
|
|
885
918
|
"""Search for a show or movie, pick one, and watch it."""
|
|
886
919
|
_check_for_updates()
|
|
@@ -965,7 +998,7 @@ def search(query, media_type, season, ep_str, scraper, player, quality, download
|
|
|
965
998
|
@click.option("-d", "--download", is_flag=True, help="Download instead of streaming")
|
|
966
999
|
@click.option("--info-only", is_flag=True, help="Show the m3u8 URL without playing")
|
|
967
1000
|
@click.option("-q", "--quality", help="Video quality: 480p, 720p, 1080p, best, worst")
|
|
968
|
-
@
|
|
1001
|
+
@_handle_errors
|
|
969
1002
|
def watch(query, tmdb_id, media_type, season, ep_str, scraper, player, download, info_only, quality):
|
|
970
1003
|
"""Browse episodes interactively and play one."""
|
|
971
1004
|
_check_for_updates()
|
|
@@ -983,7 +1016,7 @@ def watch(query, tmdb_id, media_type, season, ep_str, scraper, player, download,
|
|
|
983
1016
|
if season is not None and ep_start is not None:
|
|
984
1017
|
_play_with_menu(show, season, ep_start, ep_end, f"E{ep_start}", scraper, player, quality, info_only, download)
|
|
985
1018
|
else:
|
|
986
|
-
_interactive_episode_picker(
|
|
1019
|
+
_interactive_episode_picker(_get_tmdb(), show, scraper, player, quality, info_only, ep_start=ep_start, ep_end=ep_end)
|
|
987
1020
|
return
|
|
988
1021
|
|
|
989
1022
|
tmdb = _get_tmdb()
|
|
@@ -1185,6 +1218,7 @@ def _interactive_episode_picker(tmdb, show, scraper, player, quality, info_only,
|
|
|
1185
1218
|
@click.argument("url")
|
|
1186
1219
|
@click.option("--player", help="Player: vlc, mpv, or auto")
|
|
1187
1220
|
@click.option("--title", default="", help="Media title")
|
|
1221
|
+
@_handle_errors
|
|
1188
1222
|
def play_url(url, player, title):
|
|
1189
1223
|
"""Play a direct m3u8 URL in VLC or mpv."""
|
|
1190
1224
|
config = load_config()
|
|
@@ -1205,7 +1239,7 @@ def play_url(url, player, title):
|
|
|
1205
1239
|
|
|
1206
1240
|
|
|
1207
1241
|
@cli.command()
|
|
1208
|
-
@
|
|
1242
|
+
@_handle_errors
|
|
1209
1243
|
def resume():
|
|
1210
1244
|
"""List interrupted downloads and resume one."""
|
|
1211
1245
|
from .downloads import get, list_all, update as _track_update
|
|
@@ -1474,6 +1508,7 @@ def config_scraper_show(name):
|
|
|
1474
1508
|
|
|
1475
1509
|
|
|
1476
1510
|
@cli.command()
|
|
1511
|
+
@_handle_errors
|
|
1477
1512
|
def scrapers():
|
|
1478
1513
|
"""List available scrapers."""
|
|
1479
1514
|
all_scrapers = list_scrapers()
|
|
@@ -1507,6 +1542,7 @@ def scrapers():
|
|
|
1507
1542
|
|
|
1508
1543
|
@cli.command()
|
|
1509
1544
|
@click.argument("name")
|
|
1545
|
+
@_handle_errors
|
|
1510
1546
|
def install(name):
|
|
1511
1547
|
"""Install an optional scraper (vidsrc, torrentio)."""
|
|
1512
1548
|
from .scrapers import _OPTIONAL_SCRAPERS, install_optional
|
|
@@ -1533,6 +1569,7 @@ def install(name):
|
|
|
1533
1569
|
@click.option("--info-only", is_flag=True, help="Show the stream URL without playing")
|
|
1534
1570
|
@click.option("--sub", "prefer_sub", is_flag=True, help="Prefer subtitled audio")
|
|
1535
1571
|
@click.option("--dub", "prefer_dub", is_flag=True, help="Prefer dubbed audio")
|
|
1572
|
+
@_handle_errors
|
|
1536
1573
|
def anime(query, season, ep_str, download, player, quality, info_only, prefer_sub, prefer_dub):
|
|
1537
1574
|
"""Search anime via AniList (no API key needed) and stream from allanime."""
|
|
1538
1575
|
_check_for_updates()
|
|
@@ -1548,7 +1585,7 @@ def anime(query, season, ep_str, download, player, quality, info_only, prefer_su
|
|
|
1548
1585
|
return
|
|
1549
1586
|
|
|
1550
1587
|
def _title(m):
|
|
1551
|
-
return m.get("title", {}).get("
|
|
1588
|
+
return m.get("title", {}).get("english") or m.get("title", {}).get("romaji") or m.get("title", {}).get("native", "?")
|
|
1552
1589
|
def _year(m):
|
|
1553
1590
|
sd = m.get("startDate") or {}
|
|
1554
1591
|
return str(sd.get("year", "")) if sd.get("year") else ""
|
|
@@ -1650,7 +1687,11 @@ def _run_anime_flow(show_name, episodes_detail, season=None, ep_str=None, player
|
|
|
1650
1687
|
_print_error(f"No episodes for season {season}.")
|
|
1651
1688
|
return
|
|
1652
1689
|
|
|
1653
|
-
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
|
+
|
|
1654
1695
|
if translation and translation in tt_keys:
|
|
1655
1696
|
tt = translation
|
|
1656
1697
|
elif "sub" in tt_keys and len(tt_keys) > 1:
|
|
@@ -1765,6 +1806,7 @@ def update():
|
|
|
1765
1806
|
@cli.command()
|
|
1766
1807
|
@click.argument("query", nargs=-1, required=False)
|
|
1767
1808
|
@click.option("--clear", is_flag=True, help="Clear all watch history")
|
|
1809
|
+
@_handle_errors
|
|
1768
1810
|
def history(query, clear):
|
|
1769
1811
|
"""Show watch history and resume from last episode."""
|
|
1770
1812
|
from .history import clear_history, get_history, list_history
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from .errors import (
|
|
6
|
+
TMDBConnectionError,
|
|
7
|
+
TMDBError,
|
|
8
|
+
TMDBNotFoundError,
|
|
9
|
+
TMDBAPIError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
BASE_URL = "https://api.2embed.cc"
|
|
13
|
+
MAX_RETRIES = 3
|
|
14
|
+
RETRY_DELAY = 2
|
|
15
|
+
|
|
16
|
+
_COUNTRY_FULL_TO_CODE = {
|
|
17
|
+
"Japan": "JP",
|
|
18
|
+
"United States of America": "US",
|
|
19
|
+
"United States": "US",
|
|
20
|
+
"South Korea": "KR",
|
|
21
|
+
"China": "CN",
|
|
22
|
+
"United Kingdom": "GB",
|
|
23
|
+
"France": "FR",
|
|
24
|
+
"Germany": "DE",
|
|
25
|
+
"Italy": "IT",
|
|
26
|
+
"Spain": "ES",
|
|
27
|
+
"Canada": "CA",
|
|
28
|
+
"Australia": "AU",
|
|
29
|
+
"India": "IN",
|
|
30
|
+
"Brazil": "BR",
|
|
31
|
+
"Mexico": "MX",
|
|
32
|
+
"Russia": "RU",
|
|
33
|
+
"Taiwan": "TW",
|
|
34
|
+
"Hong Kong": "HK",
|
|
35
|
+
"Thailand": "TH",
|
|
36
|
+
"Philippines": "PH",
|
|
37
|
+
"Indonesia": "ID",
|
|
38
|
+
"Malaysia": "MY",
|
|
39
|
+
"Vietnam": "VN",
|
|
40
|
+
"Sweden": "SE",
|
|
41
|
+
"Norway": "NO",
|
|
42
|
+
"Denmark": "DK",
|
|
43
|
+
"Finland": "FI",
|
|
44
|
+
"Netherlands": "NL",
|
|
45
|
+
"Belgium": "BE",
|
|
46
|
+
"Switzerland": "CH",
|
|
47
|
+
"Austria": "AT",
|
|
48
|
+
"Poland": "PL",
|
|
49
|
+
"Turkey": "TR",
|
|
50
|
+
"Argentina": "AR",
|
|
51
|
+
"Chile": "CL",
|
|
52
|
+
"Colombia": "CO",
|
|
53
|
+
"New Zealand": "NZ",
|
|
54
|
+
"Ireland": "IE",
|
|
55
|
+
"Portugal": "PT",
|
|
56
|
+
"Czech Republic": "CZ",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_LANG_FULL_TO_CODE = {
|
|
60
|
+
"Japanese": "ja",
|
|
61
|
+
"English": "en",
|
|
62
|
+
"Korean": "ko",
|
|
63
|
+
"Chinese": "zh",
|
|
64
|
+
"French": "fr",
|
|
65
|
+
"German": "de",
|
|
66
|
+
"Italian": "it",
|
|
67
|
+
"Spanish": "es",
|
|
68
|
+
"Portuguese": "pt",
|
|
69
|
+
"Russian": "ru",
|
|
70
|
+
"Arabic": "ar",
|
|
71
|
+
"Hindi": "hi",
|
|
72
|
+
"Bengali": "bn",
|
|
73
|
+
"Turkish": "tr",
|
|
74
|
+
"Dutch": "nl",
|
|
75
|
+
"Polish": "pl",
|
|
76
|
+
"Swedish": "sv",
|
|
77
|
+
"Danish": "da",
|
|
78
|
+
"Finnish": "fi",
|
|
79
|
+
"Norwegian": "no",
|
|
80
|
+
"Czech": "cs",
|
|
81
|
+
"Thai": "th",
|
|
82
|
+
"Vietnamese": "vi",
|
|
83
|
+
"Indonesian": "id",
|
|
84
|
+
"Malay": "ms",
|
|
85
|
+
"Tagalog": "tl",
|
|
86
|
+
"Romanian": "ro",
|
|
87
|
+
"Hungarian": "hu",
|
|
88
|
+
"Greek": "el",
|
|
89
|
+
"Hebrew": "he",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _normalize_country(val):
|
|
94
|
+
if isinstance(val, list):
|
|
95
|
+
return [_COUNTRY_FULL_TO_CODE.get(c, c) for c in val]
|
|
96
|
+
if isinstance(val, str):
|
|
97
|
+
return _COUNTRY_FULL_TO_CODE.get(val, val)
|
|
98
|
+
return val
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _normalize_language(val):
|
|
102
|
+
return _LANG_FULL_TO_CODE.get(val, val)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _normalize_search_result(item, media_type="tv"):
|
|
106
|
+
item["_media_type"] = media_type
|
|
107
|
+
if media_type == "tv":
|
|
108
|
+
item["id"] = item.get("tmdb_id")
|
|
109
|
+
item["_media_type"] = "tv"
|
|
110
|
+
else:
|
|
111
|
+
item["id"] = item.get("tmdb_id")
|
|
112
|
+
item["_media_type"] = "movie"
|
|
113
|
+
if "origin_country" in item:
|
|
114
|
+
item["origin_country"] = _normalize_country(item["origin_country"])
|
|
115
|
+
if "original_language" in item:
|
|
116
|
+
item["original_language"] = _normalize_language(item["original_language"])
|
|
117
|
+
return item
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class EmbedClient:
|
|
121
|
+
def __init__(self, api_key=None):
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
def _request(self, path, params=None):
|
|
125
|
+
params = params or {}
|
|
126
|
+
url = f"{BASE_URL}{path}"
|
|
127
|
+
|
|
128
|
+
for attempt in range(MAX_RETRIES):
|
|
129
|
+
try:
|
|
130
|
+
resp = requests.get(url, params=params, timeout=15)
|
|
131
|
+
|
|
132
|
+
if resp.status_code == 429:
|
|
133
|
+
retry_after = int(resp.headers.get("Retry-After", RETRY_DELAY))
|
|
134
|
+
if attempt < MAX_RETRIES - 1:
|
|
135
|
+
time.sleep(retry_after)
|
|
136
|
+
continue
|
|
137
|
+
raise TMDBError(f"2embed rate limit hit. Retry in {retry_after}s.", status_code=429)
|
|
138
|
+
|
|
139
|
+
if resp.status_code == 404:
|
|
140
|
+
hint = path.strip("/").replace("/", " > ").title()
|
|
141
|
+
raise TMDBNotFoundError(hint)
|
|
142
|
+
|
|
143
|
+
if resp.status_code == 400:
|
|
144
|
+
body = resp.text[:200]
|
|
145
|
+
raise TMDBNotFoundError(f"2embed: {body}")
|
|
146
|
+
|
|
147
|
+
resp.raise_for_status()
|
|
148
|
+
return resp.json()
|
|
149
|
+
|
|
150
|
+
except (TMDBError, TMDBNotFoundError):
|
|
151
|
+
raise
|
|
152
|
+
except requests.ConnectionError as e:
|
|
153
|
+
if attempt == MAX_RETRIES - 1:
|
|
154
|
+
raise TMDBConnectionError(str(e))
|
|
155
|
+
time.sleep(RETRY_DELAY)
|
|
156
|
+
except requests.Timeout as e:
|
|
157
|
+
if attempt == MAX_RETRIES - 1:
|
|
158
|
+
raise TMDBConnectionError(f"Request timed out: {e}")
|
|
159
|
+
time.sleep(RETRY_DELAY)
|
|
160
|
+
except requests.HTTPError as e:
|
|
161
|
+
raise TMDBAPIError(resp.status_code, str(e))
|
|
162
|
+
|
|
163
|
+
def search_tv(self, query):
|
|
164
|
+
data = self._request("/searchtv", {"q": query, "page": 1})
|
|
165
|
+
results = [_normalize_search_result(r, "tv") for r in data.get("results", [])]
|
|
166
|
+
return {"results": results}
|
|
167
|
+
|
|
168
|
+
def search_movie(self, query):
|
|
169
|
+
data = self._request("/search", {"q": query, "page": 1})
|
|
170
|
+
results = [_normalize_search_result(r, "movie") for r in data.get("results", [])]
|
|
171
|
+
return {"results": results}
|
|
172
|
+
|
|
173
|
+
def get_tv_details(self, tv_id):
|
|
174
|
+
data = self._request("/tv", {"tmdb_id": tv_id})
|
|
175
|
+
if "origin_country" in data:
|
|
176
|
+
data["origin_country"] = _normalize_country(data["origin_country"])
|
|
177
|
+
if "original_language" in data:
|
|
178
|
+
data["original_language"] = _normalize_language(data["original_language"])
|
|
179
|
+
seasons = data.get("seasons", [])
|
|
180
|
+
real_seasons = [s for s in seasons if s.get("season_number", 0) > 0]
|
|
181
|
+
data["number_of_seasons"] = len(real_seasons)
|
|
182
|
+
return data
|
|
183
|
+
|
|
184
|
+
def get_season_details(self, tv_id, season_number):
|
|
185
|
+
return self._request("/season", {"tmdb_id": tv_id, "season": season_number})
|
|
186
|
+
|
|
187
|
+
def get_episode_details(self, tv_id, season_number, episode_number):
|
|
188
|
+
season_data = self.get_season_details(tv_id, season_number)
|
|
189
|
+
for ep in season_data.get("episodes", []):
|
|
190
|
+
if ep.get("episode_number") == episode_number:
|
|
191
|
+
return ep
|
|
192
|
+
raise TMDBNotFoundError(f"Episode S{season_number}E{episode_number}")
|
|
193
|
+
|
|
194
|
+
def get_movie_details(self, movie_id):
|
|
195
|
+
data = self._request("/movie", {"tmdb_id": movie_id})
|
|
196
|
+
if "original_language" in data:
|
|
197
|
+
data["original_language"] = _normalize_language(data["original_language"])
|
|
198
|
+
return data
|
|
@@ -3,6 +3,7 @@ import os
|
|
|
3
3
|
import shutil
|
|
4
4
|
import subprocess
|
|
5
5
|
import sys
|
|
6
|
+
import tempfile
|
|
6
7
|
import time
|
|
7
8
|
|
|
8
9
|
from .errors import PlayerNotFoundError, PlayerLaunchError
|
|
@@ -142,7 +143,7 @@ def ytdlp_install_hint():
|
|
|
142
143
|
return "brew install yt-dlp"
|
|
143
144
|
if _platform_os() == "termux":
|
|
144
145
|
return "pkg install yt-dlp"
|
|
145
|
-
return "pip install yt-dlp"
|
|
146
|
+
return "python -m pip install yt-dlp"
|
|
146
147
|
|
|
147
148
|
|
|
148
149
|
def _termux_ensure_mpv_conf():
|
|
@@ -318,21 +319,39 @@ def play_vlc(url, title="", referer=None):
|
|
|
318
319
|
return _launch("VLC", cmd)
|
|
319
320
|
|
|
320
321
|
|
|
321
|
-
def play_mpv(url, title="", referer=None):
|
|
322
|
+
def play_mpv(url, title="", referer=None, subtitle_url=None):
|
|
322
323
|
exe = _mpv_path()
|
|
323
324
|
if not exe:
|
|
324
325
|
raise PlayerNotFoundError("mpv")
|
|
325
326
|
if _in_termux():
|
|
326
327
|
return _termux_open(url, "mpv", referer=referer, user_agent=DEFAULT_UA)
|
|
327
|
-
cmd = [exe, f"--title={title}", "--alang=eng",
|
|
328
|
+
cmd = [exe, f"--title={title}", "--alang=eng",
|
|
328
329
|
"--cache=yes", "--cache-secs=300", "--ytdl=no",
|
|
329
330
|
"--network-timeout=20", "--keep-open=no"]
|
|
330
331
|
if referer:
|
|
331
332
|
cmd += ["--http-header-fields=Referer: " + referer]
|
|
333
|
+
if subtitle_url:
|
|
334
|
+
sub_path = _download_subtitle(subtitle_url)
|
|
335
|
+
cmd += [f"--sub-file={sub_path}"]
|
|
336
|
+
else:
|
|
337
|
+
cmd += ["--slang=eng", "--subs-with-matching-audio=yes"]
|
|
332
338
|
cmd.append(url)
|
|
333
339
|
return _launch("mpv", cmd)
|
|
334
340
|
|
|
335
341
|
|
|
342
|
+
def _download_subtitle(url):
|
|
343
|
+
import requests
|
|
344
|
+
fd, path = tempfile.mkstemp(suffix=".srt", prefix="cinnamon_sub_")
|
|
345
|
+
r = requests.get(url, timeout=15, headers={"User-Agent": DEFAULT_UA})
|
|
346
|
+
r.raise_for_status()
|
|
347
|
+
content = r.text
|
|
348
|
+
if content.startswith("\ufeff"):
|
|
349
|
+
content = content[1:]
|
|
350
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
351
|
+
f.write(content)
|
|
352
|
+
return path
|
|
353
|
+
|
|
354
|
+
|
|
336
355
|
def _streamer_path():
|
|
337
356
|
return os.path.join(os.path.dirname(__file__), "webtorrent_stream.mjs")
|
|
338
357
|
|
|
@@ -545,7 +564,7 @@ def download_video(url, title="", referer=None, output_dir=".", track_id=None):
|
|
|
545
564
|
raise
|
|
546
565
|
|
|
547
566
|
|
|
548
|
-
def play(url, title="", player="auto", season=None, episode=None, referer=None):
|
|
567
|
+
def play(url, title="", player="auto", season=None, episode=None, referer=None, subtitle_url=None):
|
|
549
568
|
if url.startswith("magnet:"):
|
|
550
569
|
return _play_magnet(url, title, player, season, episode)
|
|
551
570
|
if player == "auto":
|
|
@@ -553,7 +572,7 @@ def play(url, title="", player="auto", season=None, episode=None, referer=None):
|
|
|
553
572
|
vlc = _vlc_path()
|
|
554
573
|
if mpv:
|
|
555
574
|
try:
|
|
556
|
-
return play_mpv(url, title, referer)
|
|
575
|
+
return play_mpv(url, title, referer, subtitle_url)
|
|
557
576
|
except PlayerLaunchError:
|
|
558
577
|
if not vlc:
|
|
559
578
|
raise
|
|
@@ -563,5 +582,5 @@ def play(url, title="", player="auto", season=None, episode=None, referer=None):
|
|
|
563
582
|
if player == "vlc":
|
|
564
583
|
return play_vlc(url, title, referer)
|
|
565
584
|
if player == "mpv":
|
|
566
|
-
return play_mpv(url, title, referer)
|
|
585
|
+
return play_mpv(url, title, referer, subtitle_url)
|
|
567
586
|
raise ValueError(f"Unknown player: {player}. Use vlc, mpv, or auto.")
|
|
@@ -299,7 +299,20 @@ def _find_show(session, name):
|
|
|
299
299
|
rn = r.get("name", "").lower().strip()
|
|
300
300
|
if q in rn or rn in q:
|
|
301
301
|
return r["_id"]
|
|
302
|
-
|
|
302
|
+
|
|
303
|
+
q_words = frozenset(w for w in re.split(r"[^\w]+", q) if len(w) > 2)
|
|
304
|
+
scored = []
|
|
305
|
+
for r in results:
|
|
306
|
+
rn = r.get("name", "").lower().strip()
|
|
307
|
+
r_words = set(re.split(r"[^\w]+", rn))
|
|
308
|
+
overlap = len(q_words & r_words)
|
|
309
|
+
all_match = q_words <= r_words
|
|
310
|
+
has_suffix = bool(re.search(r"\b(?:part|season|special|cour|ova|movie|film)\s*\d*\b", rn))
|
|
311
|
+
extra_words = len(r_words - q_words)
|
|
312
|
+
name_len = len(rn)
|
|
313
|
+
scored.append((overlap, all_match, 0 if has_suffix else 1, extra_words, name_len, r["_id"]))
|
|
314
|
+
scored.sort(key=lambda x: (-x[0], -x[1], -x[2], x[3], x[4]))
|
|
315
|
+
return scored[0][5]
|
|
303
316
|
|
|
304
317
|
def _extract_mp4upload(session, embed_url):
|
|
305
318
|
resp = session.get(embed_url, timeout=TIMEOUT)
|
|
@@ -84,7 +84,7 @@ async def _async_resolve(embed_url: str, timeout: int = 30) -> Optional[str]:
|
|
|
84
84
|
except ImportError:
|
|
85
85
|
raise ScraperNetworkError(
|
|
86
86
|
"vidsrc",
|
|
87
|
-
"Playwright is required for the vidsrc scraper. Install it with: pip install playwright && python -m playwright install chromium"
|
|
87
|
+
"Playwright is required for the vidsrc scraper. Install it with: python -m pip install playwright && python -m playwright install chromium"
|
|
88
88
|
)
|
|
89
89
|
|
|
90
90
|
found_urls = []
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import re
|
|
2
2
|
import time as _time
|
|
3
|
-
from typing import Optional
|
|
3
|
+
from typing import Optional, Tuple
|
|
4
4
|
|
|
5
5
|
import requests
|
|
6
6
|
|
|
@@ -28,6 +28,9 @@ def _verify_playlist(session, master_url, source):
|
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
31
34
|
class WebStreamScraper(BaseScraper):
|
|
32
35
|
name = "webstream"
|
|
33
36
|
description = "HTTP streams from vixsrc.to and vidlink.pro (no browser needed)"
|
|
@@ -53,17 +56,15 @@ class WebStreamScraper(BaseScraper):
|
|
|
53
56
|
|
|
54
57
|
if media_type == "movie":
|
|
55
58
|
vixsrc_ref = f"https://vixsrc.to/embed/movie/{tmdb_id}"
|
|
59
|
+
vixsrc_url = None
|
|
60
|
+
vidlink_url = None
|
|
61
|
+
sub_url = None
|
|
62
|
+
|
|
56
63
|
if _time.time() < deadline:
|
|
57
64
|
try:
|
|
58
|
-
|
|
59
|
-
if
|
|
60
|
-
|
|
61
|
-
return ScraperResult(
|
|
62
|
-
title=f"{show} ({label})",
|
|
63
|
-
m3u8_url=result,
|
|
64
|
-
referer=vixsrc_ref,
|
|
65
|
-
user_agent=UA,
|
|
66
|
-
)
|
|
65
|
+
res = _try_vixsrc_movie(tmdb_id, quality)
|
|
66
|
+
if res:
|
|
67
|
+
vixsrc_url, _ = res
|
|
67
68
|
except ScraperNetworkError:
|
|
68
69
|
pass
|
|
69
70
|
|
|
@@ -71,55 +72,79 @@ class WebStreamScraper(BaseScraper):
|
|
|
71
72
|
try:
|
|
72
73
|
result = _try_vidlink_movie(tmdb_id, quality)
|
|
73
74
|
if result:
|
|
74
|
-
|
|
75
|
-
return ScraperResult(
|
|
76
|
-
title=f"{show} ({label})",
|
|
77
|
-
m3u8_url=result,
|
|
78
|
-
referer="https://vidlink.pro/",
|
|
79
|
-
user_agent=UA,
|
|
80
|
-
)
|
|
75
|
+
vidlink_url, sub_url = result
|
|
81
76
|
except ScraperNetworkError:
|
|
82
77
|
pass
|
|
83
78
|
|
|
79
|
+
if vixsrc_url:
|
|
80
|
+
label = quality.upper() if quality else "Auto"
|
|
81
|
+
return ScraperResult(
|
|
82
|
+
title=f"{show} ({label})",
|
|
83
|
+
m3u8_url=vixsrc_url,
|
|
84
|
+
referer=vixsrc_ref,
|
|
85
|
+
user_agent=UA,
|
|
86
|
+
subtitle_url=sub_url,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if vidlink_url:
|
|
90
|
+
label = quality.upper() if quality else "Auto"
|
|
91
|
+
return ScraperResult(
|
|
92
|
+
title=f"{show} ({label})",
|
|
93
|
+
m3u8_url=vidlink_url,
|
|
94
|
+
referer="https://vidlink.pro/",
|
|
95
|
+
user_agent=UA,
|
|
96
|
+
subtitle_url=sub_url,
|
|
97
|
+
)
|
|
98
|
+
|
|
84
99
|
raise ScraperNoStreamError(self.name, f"No HTTP stream found for {show}")
|
|
85
100
|
|
|
86
101
|
# TV
|
|
87
102
|
vixsrc_ref = f"https://vixsrc.to/embed/tv/{tmdb_id}/{season}/{episode}"
|
|
103
|
+
vixsrc_url = None
|
|
104
|
+
vixsrc_has_subs = False
|
|
105
|
+
vidlink_url = None
|
|
106
|
+
sub_url = None
|
|
107
|
+
|
|
88
108
|
if _time.time() < deadline:
|
|
89
109
|
try:
|
|
90
|
-
|
|
91
|
-
if
|
|
92
|
-
|
|
93
|
-
return ScraperResult(
|
|
94
|
-
title=f"{show} S{season:02d}E{episode:02d} ({label})",
|
|
95
|
-
m3u8_url=result,
|
|
96
|
-
referer=vixsrc_ref,
|
|
97
|
-
user_agent=UA,
|
|
98
|
-
)
|
|
110
|
+
res = _try_vixsrc(tmdb_id, season, episode, quality)
|
|
111
|
+
if res:
|
|
112
|
+
vixsrc_url, vixsrc_has_subs = res
|
|
99
113
|
except ScraperNetworkError:
|
|
100
114
|
pass
|
|
101
115
|
|
|
102
|
-
if _time.time() < deadline:
|
|
116
|
+
if not vixsrc_has_subs and _time.time() < deadline:
|
|
103
117
|
try:
|
|
104
118
|
result = _try_vidlink(tmdb_id, season, episode, quality)
|
|
105
119
|
if result:
|
|
106
|
-
|
|
107
|
-
return ScraperResult(
|
|
108
|
-
title=f"{show} S{season:02d}E{episode:02d} ({label})",
|
|
109
|
-
m3u8_url=result,
|
|
110
|
-
referer="https://vidlink.pro/",
|
|
111
|
-
user_agent=UA,
|
|
112
|
-
)
|
|
120
|
+
vidlink_url, sub_url = result
|
|
113
121
|
except ScraperNetworkError:
|
|
114
122
|
pass
|
|
115
123
|
|
|
116
|
-
|
|
117
|
-
|
|
124
|
+
if vixsrc_url:
|
|
125
|
+
label = quality.upper() if quality else "Auto"
|
|
126
|
+
return ScraperResult(
|
|
127
|
+
title=f"{show} S{season:02d}E{episode:02d} ({label})",
|
|
128
|
+
m3u8_url=vixsrc_url,
|
|
129
|
+
referer=vixsrc_ref,
|
|
130
|
+
user_agent=UA,
|
|
131
|
+
subtitle_url=sub_url,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if vidlink_url:
|
|
135
|
+
label = quality.upper() if quality else "Auto"
|
|
136
|
+
return ScraperResult(
|
|
137
|
+
title=f"{show} S{season:02d}E{episode:02d} ({label})",
|
|
138
|
+
m3u8_url=vidlink_url,
|
|
139
|
+
referer="https://vidlink.pro/",
|
|
140
|
+
user_agent=UA,
|
|
141
|
+
subtitle_url=sub_url,
|
|
142
|
+
)
|
|
118
143
|
|
|
119
144
|
|
|
120
145
|
_QUALITY_RENDITIONS = {"480p": "480p", "720p": "720p", "1080p": "1080p", "best": "", "worst": "worst"}
|
|
121
146
|
|
|
122
|
-
def _try_vixsrc(tmdb_id, season, episode, quality="") -> Optional[str]:
|
|
147
|
+
def _try_vixsrc(tmdb_id, season, episode, quality="") -> Optional[Tuple[str, bool]]:
|
|
123
148
|
session = requests.Session()
|
|
124
149
|
session.headers.update({"User-Agent": UA})
|
|
125
150
|
|
|
@@ -156,19 +181,19 @@ def _try_vixsrc(tmdb_id, season, episode, quality="") -> Optional[str]:
|
|
|
156
181
|
sep = "&" if "?" in playlist.group(1) else "?"
|
|
157
182
|
master_url = f'{playlist.group(1)}{sep}token={token.group(1)}&expires={expires.group(1)}&h=1&lang=en'
|
|
158
183
|
|
|
184
|
+
master_resp = _verify_playlist(session, master_url, "vixsrc")
|
|
185
|
+
has_subs = "#EXT-X-MEDIA:TYPE=SUBTITLES" in master_resp.text
|
|
186
|
+
|
|
159
187
|
if not quality:
|
|
160
|
-
|
|
161
|
-
return master_url
|
|
188
|
+
return (master_url, has_subs)
|
|
162
189
|
|
|
163
190
|
# Parse master playlist and filter by quality
|
|
164
|
-
master_resp = _verify_playlist(session, master_url, "vixsrc")
|
|
165
|
-
|
|
166
191
|
variants = re.findall(
|
|
167
192
|
r'#EXT-X-STREAM-INF:.*?RESOLUTION=\d+x(\d+).*?\n(https?://\S+)',
|
|
168
193
|
master_resp.text,
|
|
169
194
|
)
|
|
170
195
|
if not variants:
|
|
171
|
-
return master_url
|
|
196
|
+
return (master_url, has_subs)
|
|
172
197
|
|
|
173
198
|
target = quality.replace("p", "")
|
|
174
199
|
if quality == "best":
|
|
@@ -179,7 +204,19 @@ def _try_vixsrc(tmdb_id, season, episode, quality="") -> Optional[str]:
|
|
|
179
204
|
matches = [v for v in variants if v[0] == target]
|
|
180
205
|
match = matches[0] if matches else max(variants, key=lambda v: int(v[0]))
|
|
181
206
|
|
|
182
|
-
return match[1]
|
|
207
|
+
return (match[1], has_subs)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _vidlink_subtitle_url(data):
|
|
211
|
+
captions = data.get("stream", {}).get("captions", [])
|
|
212
|
+
eng = None
|
|
213
|
+
for cap in captions:
|
|
214
|
+
lang = (cap.get("language", "") or cap.get("lang", "")).strip().lower()
|
|
215
|
+
if lang in ("eng", "en", "english"):
|
|
216
|
+
return cap.get("url")
|
|
217
|
+
if not eng and lang:
|
|
218
|
+
eng = cap
|
|
219
|
+
return eng["url"] if eng else (captions[0]["url"] if captions else None)
|
|
183
220
|
|
|
184
221
|
|
|
185
222
|
def _vidlink_quality_url(data, quality=""):
|
|
@@ -198,7 +235,7 @@ def _vidlink_quality_url(data, quality=""):
|
|
|
198
235
|
return qualities[res]["url"]
|
|
199
236
|
|
|
200
237
|
|
|
201
|
-
def _try_vidlink(tmdb_id, season, episode, quality="") -> Optional[str]:
|
|
238
|
+
def _try_vidlink(tmdb_id, season, episode, quality="") -> Optional[tuple[str, Optional[str]]]:
|
|
202
239
|
session = requests.Session()
|
|
203
240
|
session.headers.update({"User-Agent": UA})
|
|
204
241
|
|
|
@@ -226,10 +263,13 @@ def _try_vidlink(tmdb_id, season, episode, quality="") -> Optional[str]:
|
|
|
226
263
|
data = stream_resp.json()
|
|
227
264
|
except ValueError:
|
|
228
265
|
raise ScraperNetworkError("vidlink", "Non-JSON response from stream API")
|
|
229
|
-
|
|
266
|
+
|
|
267
|
+
url = _vidlink_quality_url(data, quality)
|
|
268
|
+
sub_url = _vidlink_subtitle_url(data)
|
|
269
|
+
return (url, sub_url)
|
|
230
270
|
|
|
231
271
|
|
|
232
|
-
def _try_vixsrc_movie(tmdb_id, quality="") -> Optional[str]:
|
|
272
|
+
def _try_vixsrc_movie(tmdb_id, quality="") -> Optional[Tuple[str, bool]]:
|
|
233
273
|
session = requests.Session()
|
|
234
274
|
session.headers.update({"User-Agent": UA})
|
|
235
275
|
|
|
@@ -266,18 +306,18 @@ def _try_vixsrc_movie(tmdb_id, quality="") -> Optional[str]:
|
|
|
266
306
|
sep = "&" if "?" in playlist.group(1) else "?"
|
|
267
307
|
master_url = f'{playlist.group(1)}{sep}token={token.group(1)}&expires={expires.group(1)}&h=1&lang=en'
|
|
268
308
|
|
|
269
|
-
if not quality:
|
|
270
|
-
_verify_playlist(session, master_url, "vixsrc")
|
|
271
|
-
return master_url
|
|
272
|
-
|
|
273
309
|
master_resp = _verify_playlist(session, master_url, "vixsrc")
|
|
310
|
+
has_subs = "#EXT-X-MEDIA:TYPE=SUBTITLES" in master_resp.text
|
|
311
|
+
|
|
312
|
+
if not quality:
|
|
313
|
+
return (master_url, has_subs)
|
|
274
314
|
|
|
275
315
|
variants = re.findall(
|
|
276
316
|
r'#EXT-X-STREAM-INF:.*?RESOLUTION=\d+x(\d+).*?\n(https?://\S+)',
|
|
277
317
|
master_resp.text,
|
|
278
318
|
)
|
|
279
319
|
if not variants:
|
|
280
|
-
return master_url
|
|
320
|
+
return (master_url, has_subs)
|
|
281
321
|
|
|
282
322
|
target = quality.replace("p", "")
|
|
283
323
|
if quality == "best":
|
|
@@ -288,10 +328,10 @@ def _try_vixsrc_movie(tmdb_id, quality="") -> Optional[str]:
|
|
|
288
328
|
matches = [v for v in variants if v[0] == target]
|
|
289
329
|
match = matches[0] if matches else max(variants, key=lambda v: int(v[0]))
|
|
290
330
|
|
|
291
|
-
return match[1]
|
|
331
|
+
return (match[1], has_subs)
|
|
292
332
|
|
|
293
333
|
|
|
294
|
-
def _try_vidlink_movie(tmdb_id, quality="") -> Optional[str]:
|
|
334
|
+
def _try_vidlink_movie(tmdb_id, quality="") -> Optional[Tuple[str, Optional[str]]]:
|
|
295
335
|
session = requests.Session()
|
|
296
336
|
session.headers.update({"User-Agent": UA})
|
|
297
337
|
|
|
@@ -319,4 +359,7 @@ def _try_vidlink_movie(tmdb_id, quality="") -> Optional[str]:
|
|
|
319
359
|
data = stream_resp.json()
|
|
320
360
|
except ValueError:
|
|
321
361
|
raise ScraperNetworkError("vidlink", "Non-JSON response from stream API")
|
|
322
|
-
|
|
362
|
+
|
|
363
|
+
url = _vidlink_quality_url(data, quality)
|
|
364
|
+
sub_url = _vidlink_subtitle_url(data)
|
|
365
|
+
return (url, sub_url)
|
|
@@ -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
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cinnamon-cli"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.37"
|
|
8
8
|
description = "CLI to search TV shows, movies & anime via TMDB/AniList, scrape m3u8 links, and play in VLC/mpv"
|
|
9
9
|
license = { text = "CC BY-NC 4.0" }
|
|
10
10
|
readme = "README.md"
|
|
@@ -73,7 +73,7 @@ def _install_fakes():
|
|
|
73
73
|
c.list_scrapers = lambda: [{"name": "webstream", "description": "fake", "builtin": True}]
|
|
74
74
|
c.play = lambda *a, **k: _Proc()
|
|
75
75
|
c.download_video = lambda *a, **k: None
|
|
76
|
-
c.ytdlp_install_hint = lambda: "pip install yt-dlp"
|
|
76
|
+
c.ytdlp_install_hint = lambda: "python -m pip install yt-dlp"
|
|
77
77
|
|
|
78
78
|
|
|
79
79
|
class _Proc:
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.34"
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import requests
|
|
2
|
-
|
|
3
|
-
API = "https://graphql.anilist.co"
|
|
4
|
-
UA = "cinnamon/0.1.0"
|
|
5
|
-
|
|
6
|
-
_SEARCH_QUERY = """
|
|
7
|
-
query ($search: String, $page: Int) {
|
|
8
|
-
Page(page: $page, perPage: 20) {
|
|
9
|
-
media(search: $search, type: ANIME) {
|
|
10
|
-
id
|
|
11
|
-
idMal
|
|
12
|
-
title { romaji english native }
|
|
13
|
-
episodes
|
|
14
|
-
status
|
|
15
|
-
format
|
|
16
|
-
startDate { year }
|
|
17
|
-
genres
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def search_anime(query):
|
|
25
|
-
resp = requests.post(
|
|
26
|
-
API,
|
|
27
|
-
json={"query": _SEARCH_QUERY, "variables": {"search": query, "page": 1}},
|
|
28
|
-
timeout=15,
|
|
29
|
-
headers={"User-Agent": UA, "Content-Type": "application/json", "Accept": "application/json"},
|
|
30
|
-
)
|
|
31
|
-
resp.raise_for_status()
|
|
32
|
-
data = resp.json()
|
|
33
|
-
return data.get("data", {}).get("Page", {}).get("media", [])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|