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.
Files changed (37) hide show
  1. {cinnamon_cli-0.2.34/cinnamon_cli.egg-info → cinnamon_cli-0.2.37}/PKG-INFO +5 -2
  2. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/README.md +4 -1
  3. cinnamon_cli-0.2.37/cinnamon/__init__.py +1 -0
  4. cinnamon_cli-0.2.37/cinnamon/anilist.py +92 -0
  5. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/cli.py +68 -26
  6. cinnamon_cli-0.2.37/cinnamon/embedapi.py +198 -0
  7. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/player.py +25 -6
  8. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/scrapers/__init__.py +0 -1
  9. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/scrapers/anime.py +14 -1
  10. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/scrapers/base.py +1 -0
  11. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/scrapers/vidsrc.py +1 -1
  12. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/scrapers/webstream.py +97 -54
  13. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37/cinnamon_cli.egg-info}/PKG-INFO +5 -2
  14. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon_cli.egg-info/SOURCES.txt +1 -0
  15. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/pyproject.toml +1 -1
  16. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/tests/test_ui_smoke.py +1 -1
  17. cinnamon_cli-0.2.34/cinnamon/__init__.py +0 -1
  18. cinnamon_cli-0.2.34/cinnamon/anilist.py +0 -33
  19. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/LICENSE +0 -0
  20. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/config.py +0 -0
  21. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/downloads.py +0 -0
  22. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/errors.py +0 -0
  23. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/history.py +0 -0
  24. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/scrapers/torrentio.py +0 -0
  25. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/tmdb.py +0 -0
  26. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon/tui.py +0 -0
  27. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon_cli.egg-info/dependency_links.txt +0 -0
  28. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon_cli.egg-info/entry_points.txt +0 -0
  29. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon_cli.egg-info/requires.txt +0 -0
  30. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/cinnamon_cli.egg-info/top_level.txt +0 -0
  31. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/setup.cfg +0 -0
  32. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/tests/test_directstream.py +0 -0
  33. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/tests/test_directstream2.py +0 -0
  34. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/tests/test_directstream3.py +0 -0
  35. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/tests/test_directstream4.py +0 -0
  36. {cinnamon_cli-0.2.34 → cinnamon_cli-0.2.37}/tests/test_directstream5.py +0 -0
  37. {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.34
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
- _print_error(
249
- "TMDB API key not configured.",
250
- 'Run "cinnamon setup" for a step-by-step guide to get a free key.',
251
- )
252
- raise SystemExit(1)
250
+ return EmbedClient()
253
251
 
254
252
 
255
- def _handle_tmdb_error(fn):
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
- if Confirm.ask("No API key found. Run setup?", default=True):
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
- api_key = _setup_api_key()
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
- @_handle_tmdb_error
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
- @_handle_tmdb_error
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(None, show, scraper, player, quality, info_only, ep_start=ep_start, ep_end=ep_end)
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
- @_handle_tmdb_error
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("romaji") or m.get("title", {}).get("english") or m.get("title", {}).get("native", "?")
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 = list(season_data.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", "--slang=eng", "--subs-with-matching-audio=yes",
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.")
@@ -8,7 +8,6 @@ from pathlib import Path
8
8
  from .base import BaseScraper
9
9
  from .webstream import WebStreamScraper
10
10
  from .anime import AnimeScraper
11
-
12
11
  _BUILTIN_SCRAPERS = [WebStreamScraper, AnimeScraper]
13
12
 
14
13
  _OPTIONAL_SCRAPERS = {
@@ -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
- return results[0]["_id"]
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)
@@ -10,6 +10,7 @@ class ScraperResult:
10
10
  referer: Optional[str] = None
11
11
  user_agent: Optional[str] = None
12
12
  headers: dict = field(default_factory=dict)
13
+ subtitle_url: Optional[str] = None
13
14
 
14
15
  def __post_init__(self):
15
16
  if not self.title:
@@ -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
- result = _try_vixsrc_movie(tmdb_id, quality)
59
- if result:
60
- label = quality.upper() if quality else "Auto"
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
- label = quality.upper() if quality else "Auto"
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
- result = _try_vixsrc(tmdb_id, season, episode, quality)
91
- if result:
92
- label = quality.upper() if quality else "Auto"
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
- label = quality.upper() if quality else "Auto"
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
- raise ScraperNoStreamError(self.name,
117
- f"No HTTP stream found for {show} S{season:02d}E{episode:02d}")
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
- _verify_playlist(session, master_url, "vixsrc")
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
- return _vidlink_quality_url(data, quality)
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
- return _vidlink_quality_url(data, quality)
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.34
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
 
@@ -6,6 +6,7 @@ cinnamon/anilist.py
6
6
  cinnamon/cli.py
7
7
  cinnamon/config.py
8
8
  cinnamon/downloads.py
9
+ cinnamon/embedapi.py
9
10
  cinnamon/errors.py
10
11
  cinnamon/history.py
11
12
  cinnamon/player.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cinnamon-cli"
7
- version = "0.2.34"
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