cinnamon-cli 0.2.37__tar.gz → 0.2.38__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.37 → cinnamon_cli-0.2.38}/PKG-INFO +10 -4
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/README.md +6 -2
- cinnamon_cli-0.2.38/cinnamon/__init__.py +1 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/cli.py +311 -73
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/config.py +1 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/history.py +22 -9
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/player.py +209 -9
- cinnamon_cli-0.2.38/cinnamon/scrapers/_vidlink_crypto.py +163 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/scrapers/anime.py +247 -10
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/scrapers/webstream.py +38 -18
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon_cli.egg-info/PKG-INFO +10 -4
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon_cli.egg-info/SOURCES.txt +1 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon_cli.egg-info/requires.txt +3 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/pyproject.toml +5 -2
- cinnamon_cli-0.2.37/cinnamon/__init__.py +0 -1
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/LICENSE +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/anilist.py +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/downloads.py +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/embedapi.py +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/errors.py +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/scrapers/__init__.py +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/scrapers/base.py +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/scrapers/torrentio.py +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/scrapers/vidsrc.py +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/tmdb.py +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/tui.py +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon_cli.egg-info/dependency_links.txt +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon_cli.egg-info/entry_points.txt +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon_cli.egg-info/top_level.txt +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/setup.cfg +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/tests/test_directstream.py +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/tests/test_directstream2.py +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/tests/test_directstream3.py +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/tests/test_directstream4.py +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/tests/test_directstream5.py +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/tests/test_directstream6.py +0 -0
- {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/tests/test_ui_smoke.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cinnamon-cli
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.2.38
|
|
4
|
+
Summary: Watch Movies, TV shows and anime from your terminal.
|
|
5
5
|
Author: pizza-droid
|
|
6
6
|
License: CC BY-NC 4.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/pizza-droid/cinnamon
|
|
@@ -23,6 +23,8 @@ Requires-Dist: questionary>=2
|
|
|
23
23
|
Requires-Dist: pycryptodome>=3
|
|
24
24
|
Provides-Extra: scrapers
|
|
25
25
|
Requires-Dist: playwright>=1.60; extra == "scrapers"
|
|
26
|
+
Provides-Extra: vidlink
|
|
27
|
+
Requires-Dist: pynacl>=1.5; extra == "vidlink"
|
|
26
28
|
Provides-Extra: download
|
|
27
29
|
Requires-Dist: yt-dlp>=2024.1.0; extra == "download"
|
|
28
30
|
Dynamic: license-file
|
|
@@ -117,8 +119,12 @@ cinnamon search "Breaking Bad" -d -e 1-5
|
|
|
117
119
|
| `cinnamon watch --id 123 -s 2 -e 5` | Go straight to S2E5 without menus |
|
|
118
120
|
| `cinnamon play-url <url>` | Play any m3u8/mp4 link |
|
|
119
121
|
| `cinnamon resume` | Continue an interrupted download |
|
|
120
|
-
| `cinnamon history` | Show watch history
|
|
121
|
-
| `cinnamon history
|
|
122
|
+
| `cinnamon history` | Show watch history |
|
|
123
|
+
| `cinnamon history <query>` | Show episodes played for a show and continue from next |
|
|
124
|
+
| `cinnamon history clear` | Clear all watch history |
|
|
125
|
+
| `cinnamon history toggle [on|off]` | Enable or disable history tracking |
|
|
126
|
+
| `cinnamon config history show` | Show whether history tracking is enabled |
|
|
127
|
+
| `cinnamon config history toggle [on|off]` | Enable or disable via config |
|
|
122
128
|
| `cinnamon scrapers` | See available streaming sources |
|
|
123
129
|
| `cinnamon install <name>` | Add an optional scraper (vidsrc, torrentio) |
|
|
124
130
|
| `cinnamon update` | Check for and install the latest version |
|
|
@@ -88,8 +88,12 @@ cinnamon search "Breaking Bad" -d -e 1-5
|
|
|
88
88
|
| `cinnamon watch --id 123 -s 2 -e 5` | Go straight to S2E5 without menus |
|
|
89
89
|
| `cinnamon play-url <url>` | Play any m3u8/mp4 link |
|
|
90
90
|
| `cinnamon resume` | Continue an interrupted download |
|
|
91
|
-
| `cinnamon history` | Show watch history
|
|
92
|
-
| `cinnamon history
|
|
91
|
+
| `cinnamon history` | Show watch history |
|
|
92
|
+
| `cinnamon history <query>` | Show episodes played for a show and continue from next |
|
|
93
|
+
| `cinnamon history clear` | Clear all watch history |
|
|
94
|
+
| `cinnamon history toggle [on|off]` | Enable or disable history tracking |
|
|
95
|
+
| `cinnamon config history show` | Show whether history tracking is enabled |
|
|
96
|
+
| `cinnamon config history toggle [on|off]` | Enable or disable via config |
|
|
93
97
|
| `cinnamon scrapers` | See available streaming sources |
|
|
94
98
|
| `cinnamon install <name>` | Add an optional scraper (vidsrc, torrentio) |
|
|
95
99
|
| `cinnamon update` | Check for and install the latest version |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.38"
|
|
@@ -746,9 +746,7 @@ def cli(ctx, setup):
|
|
|
746
746
|
))
|
|
747
747
|
console.print()
|
|
748
748
|
console.print(" [dim]Search & watch will use an experimental proxy (no key needed).[/dim]")
|
|
749
|
-
|
|
750
|
-
ctx.invoke(setup_cmd)
|
|
751
|
-
return
|
|
749
|
+
console.print(" [dim]Optional: set a TMDB key later with [cyan]cinnamon config set-api-key <key>[/cyan].[/dim]")
|
|
752
750
|
theme = get_theme()
|
|
753
751
|
console.clear()
|
|
754
752
|
console.print(Panel.fit(
|
|
@@ -1008,11 +1006,21 @@ def watch(query, tmdb_id, media_type, season, ep_str, scraper, player, download,
|
|
|
1008
1006
|
# Explicit id given: skip TMDB entirely (no API key needed) and let the
|
|
1009
1007
|
# scraper resolve the stream straight from the id.
|
|
1010
1008
|
if tmdb_id and media_type == "movie":
|
|
1011
|
-
|
|
1009
|
+
try:
|
|
1010
|
+
details = _get_tmdb().get_movie_details(tmdb_id)
|
|
1011
|
+
title = details.get("title", f"Movie {tmdb_id}")
|
|
1012
|
+
except Exception:
|
|
1013
|
+
title = f"Movie {tmdb_id}"
|
|
1014
|
+
show = {"title": title, "id": tmdb_id}
|
|
1012
1015
|
_play_movie(show, scraper, player, quality, info_only, download)
|
|
1013
1016
|
return
|
|
1014
1017
|
if tmdb_id and media_type == "tv":
|
|
1015
|
-
|
|
1018
|
+
try:
|
|
1019
|
+
details = _get_tmdb().get_tv_details(tmdb_id)
|
|
1020
|
+
name = details.get("name", f"Show {tmdb_id}")
|
|
1021
|
+
except Exception:
|
|
1022
|
+
name = f"Show {tmdb_id}"
|
|
1023
|
+
show = {"name": name, "id": tmdb_id}
|
|
1016
1024
|
if season is not None and ep_start is not None:
|
|
1017
1025
|
_play_with_menu(show, season, ep_start, ep_end, f"E{ep_start}", scraper, player, quality, info_only, download)
|
|
1018
1026
|
else:
|
|
@@ -1158,27 +1166,27 @@ def _interactive_episode_picker(tmdb, show, scraper, player, quality, info_only,
|
|
|
1158
1166
|
|
|
1159
1167
|
if ep_start is None:
|
|
1160
1168
|
from .history import get_history as _get_history
|
|
1161
|
-
|
|
1162
|
-
if
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1169
|
+
entries = _get_history(show_name)
|
|
1170
|
+
if entries:
|
|
1171
|
+
last = entries[0]
|
|
1172
|
+
if last.get("season") == season_num and last.get("episode"):
|
|
1173
|
+
resume_ep = last["episode"] + 1
|
|
1174
|
+
if not any(
|
|
1175
|
+
isinstance(ep.get("episode_number"), int) and ep["episode_number"] == resume_ep
|
|
1176
|
+
for ep in episodes
|
|
1177
|
+
):
|
|
1178
|
+
resume_ep = last["episode"]
|
|
1179
|
+
try:
|
|
1180
|
+
answer = Prompt.ask(
|
|
1181
|
+
f" Resume from [bold]E{resume_ep:02d}[/bold]? [dim](y/n)[/dim]",
|
|
1182
|
+
default="y",
|
|
1183
|
+
show_default=False,
|
|
1184
|
+
).strip().lower()
|
|
1185
|
+
resume = answer in ("", "y", "yes")
|
|
1186
|
+
except (EOFError, KeyboardInterrupt):
|
|
1187
|
+
resume = True
|
|
1188
|
+
if resume:
|
|
1189
|
+
ep_start = resume_ep
|
|
1182
1190
|
|
|
1183
1191
|
if ep_start is not None:
|
|
1184
1192
|
ep_name = f"S{season_num:02d}E{ep_start:02d}"
|
|
@@ -1467,6 +1475,34 @@ def config_default_player(player):
|
|
|
1467
1475
|
_print_success(f"Default player set to [bold]{player}[/bold].")
|
|
1468
1476
|
|
|
1469
1477
|
|
|
1478
|
+
@config.group("history")
|
|
1479
|
+
def config_history():
|
|
1480
|
+
"""View or change watch history tracking."""
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
@config_history.command("show")
|
|
1484
|
+
def config_history_show():
|
|
1485
|
+
"""Show whether watch history tracking is enabled."""
|
|
1486
|
+
cfg = load_config()
|
|
1487
|
+
enabled = cfg.get("history_enabled", True)
|
|
1488
|
+
status = "enabled" if enabled else "disabled"
|
|
1489
|
+
console.print(f" Watch history tracking: [bold]{status}[/bold]")
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
@config_history.command("toggle")
|
|
1493
|
+
@click.argument("state", type=click.Choice(["on", "off"]), required=False)
|
|
1494
|
+
def config_history_toggle(state):
|
|
1495
|
+
"""Enable or disable watch history tracking."""
|
|
1496
|
+
cfg = load_config()
|
|
1497
|
+
if state:
|
|
1498
|
+
cfg["history_enabled"] = (state == "on")
|
|
1499
|
+
else:
|
|
1500
|
+
cfg["history_enabled"] = not cfg.get("history_enabled", True)
|
|
1501
|
+
save_config(cfg)
|
|
1502
|
+
status = "enabled" if cfg["history_enabled"] else "disabled"
|
|
1503
|
+
_print_success(f"Watch history tracking {status}.")
|
|
1504
|
+
|
|
1505
|
+
|
|
1470
1506
|
@config.group("scraper")
|
|
1471
1507
|
def config_scraper():
|
|
1472
1508
|
"""Configure a specific scraper."""
|
|
@@ -1610,26 +1646,51 @@ def anime(query, season, ep_str, download, player, quality, info_only, prefer_su
|
|
|
1610
1646
|
|
|
1611
1647
|
show_name = _title(show)
|
|
1612
1648
|
theme = get_theme()
|
|
1649
|
+
|
|
1650
|
+
if show.get("format") == "MOVIE":
|
|
1651
|
+
console.print(f" [{theme['info']}]Anime movie — playing directly[/{theme['info']}]")
|
|
1652
|
+
show_dict = {"name": show_name, "id": 0}
|
|
1653
|
+
proc = _resolve_and_play(show_dict, None, 1, show_name, "anime", player, quality, info_only, download)
|
|
1654
|
+
if proc is None and not info_only and not download:
|
|
1655
|
+
# allanime has no playable source for this movie (e.g. its obfuscated
|
|
1656
|
+
# streamer link can't be decoded, or it only offers iframe embeds with
|
|
1657
|
+
# no direct mp4). Fall back to the webstream scraper via the movie's
|
|
1658
|
+
# TMDB id so the film still plays.
|
|
1659
|
+
tmdb_id = _anime_movie_tmdb_id(show_name, year=(show.get("startDate") or {}).get("year"))
|
|
1660
|
+
if tmdb_id:
|
|
1661
|
+
_print_info(f"allanime has no direct stream — using [bold]webstream[/bold] for the movie.")
|
|
1662
|
+
proc = _resolve_and_play(
|
|
1663
|
+
{"name": show_name, "id": tmdb_id}, None, None, show_name,
|
|
1664
|
+
"webstream", player, quality, info_only, download,
|
|
1665
|
+
)
|
|
1666
|
+
if proc is not None and not info_only and not download:
|
|
1667
|
+
try:
|
|
1668
|
+
proc.wait()
|
|
1669
|
+
except AttributeError:
|
|
1670
|
+
pass
|
|
1671
|
+
return
|
|
1672
|
+
|
|
1613
1673
|
console.clear()
|
|
1614
1674
|
console.print(Panel(f"[bold {theme['accent']}]{show_name}[/bold {theme['accent']}]", border_style=theme["border"]))
|
|
1615
1675
|
|
|
1616
1676
|
from .history import get_history as _get_history
|
|
1617
1677
|
if not ep_str:
|
|
1618
|
-
|
|
1619
|
-
if
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1678
|
+
entries = _get_history(show_name)
|
|
1679
|
+
if entries:
|
|
1680
|
+
last = entries[0]
|
|
1681
|
+
if last.get("episode"):
|
|
1682
|
+
resume_ep = last["episode"] + 1
|
|
1683
|
+
try:
|
|
1684
|
+
answer = Prompt.ask(
|
|
1685
|
+
f" Resume from [bold]S{last.get('season', 1)}E{resume_ep}[/bold]? [dim](y/n)[/dim]",
|
|
1686
|
+
default="y",
|
|
1687
|
+
show_default=False,
|
|
1688
|
+
).strip().lower()
|
|
1689
|
+
resume = answer in ("", "y", "yes")
|
|
1690
|
+
except (EOFError, KeyboardInterrupt):
|
|
1691
|
+
resume = True
|
|
1692
|
+
if resume:
|
|
1693
|
+
ep_str = str(resume_ep)
|
|
1633
1694
|
|
|
1634
1695
|
from .scrapers.anime import _find_show, _allanime_episodes
|
|
1635
1696
|
import requests as _req
|
|
@@ -1654,6 +1715,85 @@ def anime(query, season, ep_str, download, player, quality, info_only, prefer_su
|
|
|
1654
1715
|
_run_anime_flow(show_name, episodes_detail, season, ep_str, player, quality, info_only, download, translation=translation)
|
|
1655
1716
|
|
|
1656
1717
|
|
|
1718
|
+
def _anime_movie_tmdb_id(show_name, year=None):
|
|
1719
|
+
"""Best-effort lookup of a movie's TMDB id by title, for webstream fallback.
|
|
1720
|
+
|
|
1721
|
+
allanime only carries the anime's name, not its TMDB id, so we search TMDB
|
|
1722
|
+
for the closest movie match and return its id. When the AniList year is
|
|
1723
|
+
known it is used as a tie-breaker.
|
|
1724
|
+
|
|
1725
|
+
The keyless 2embed proxy (EmbedClient) returns the same real TMDB ids that
|
|
1726
|
+
vixsrc/vidlink use, so it is safe to fall back to it when no TMDB API key
|
|
1727
|
+
is configured — the returned id is genuine, not a guessed/renamed one. The
|
|
1728
|
+
final safety net is webstream itself: it verifies the id against vixsrc's
|
|
1729
|
+
playlist and falls back to vidlink, raising ScraperNoStreamError if neither
|
|
1730
|
+
serves the film. We additionally keep the vixsrc API check below as a
|
|
1731
|
+
pre-filter so we don't hand the player a dead id."""
|
|
1732
|
+
try:
|
|
1733
|
+
tmdb = _get_tmdb()
|
|
1734
|
+
results = tmdb.search_movie(show_name).get("results", [])
|
|
1735
|
+
except Exception:
|
|
1736
|
+
results = []
|
|
1737
|
+
if not results:
|
|
1738
|
+
# Try a normalized title (drop collection suffixes) — vixsrc indexes the
|
|
1739
|
+
# canonical TMDB entry, not the "...: The Movie" / " Movie" rename.
|
|
1740
|
+
norm = _normalize_movie_title(show_name)
|
|
1741
|
+
if norm and norm.lower() != show_name.lower():
|
|
1742
|
+
try:
|
|
1743
|
+
results = tmdb.search_movie(norm).get("results", [])
|
|
1744
|
+
except Exception:
|
|
1745
|
+
results = []
|
|
1746
|
+
if not results:
|
|
1747
|
+
return None
|
|
1748
|
+
q = show_name.lower().strip()
|
|
1749
|
+
nq = _normalize_movie_title(show_name).lower()
|
|
1750
|
+
scored = []
|
|
1751
|
+
for r in results:
|
|
1752
|
+
title = (r.get("title") or r.get("name") or "").lower().strip()
|
|
1753
|
+
if title != q and title != nq and q not in title and nq not in title and title not in q:
|
|
1754
|
+
continue
|
|
1755
|
+
score = 0
|
|
1756
|
+
if title in (q, nq):
|
|
1757
|
+
score = 3
|
|
1758
|
+
elif title.startswith(q) or q.startswith(title):
|
|
1759
|
+
score = 2
|
|
1760
|
+
elif q in title or nq in title or title in q:
|
|
1761
|
+
score = 1
|
|
1762
|
+
ryear = (r.get("release_date") or "")[:4]
|
|
1763
|
+
if year and ryear and str(year) == str(ryear):
|
|
1764
|
+
score += 2
|
|
1765
|
+
scored.append((score, r.get("popularity") or 0, r))
|
|
1766
|
+
if not scored:
|
|
1767
|
+
return None
|
|
1768
|
+
scored.sort(key=lambda x: (x[0], x[1]), reverse=True)
|
|
1769
|
+
# Return the best-scored TMDB id. Do NOT pre-filter against vixsrc's API:
|
|
1770
|
+
# vixsrc's movie id space diverges from TMDB for many anime films (the top
|
|
1771
|
+
# pick may 404 on vixsrc yet play fine via vidlink), and webstream already
|
|
1772
|
+
# verifies the id end-to-end (vixsrc playlist + vidlink fallback) and raises
|
|
1773
|
+
# ScraperNoStreamError if neither source serves it. Letting webstream do the
|
|
1774
|
+
# verification keeps the correct film instead of falling back to a wrong one
|
|
1775
|
+
# that merely happens to be indexed by vixsrc.
|
|
1776
|
+
for _, _, r in scored:
|
|
1777
|
+
mid = r.get("id")
|
|
1778
|
+
if mid:
|
|
1779
|
+
return mid
|
|
1780
|
+
return None
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
def _normalize_movie_title(name):
|
|
1784
|
+
"""Strip collection/rename suffixes so the canonical TMDB id is found.
|
|
1785
|
+
|
|
1786
|
+
e.g. "A Silent Voice: The Movie" -> "A Silent Voice". vixsrc indexes the
|
|
1787
|
+
base title, not the renamed TMDB entry."""
|
|
1788
|
+
import re
|
|
1789
|
+
n = (name or "").strip()
|
|
1790
|
+
n = re.sub(r"\s*:\s*the\s+movie$", "", n, flags=re.I)
|
|
1791
|
+
n = re.sub(r"\s*:\s*the\s+motion\s+picture$", "", n, flags=re.I)
|
|
1792
|
+
n = re.sub(r"\s*:\s*movie$", "", n, flags=re.I)
|
|
1793
|
+
n = re.sub(r"\s+movie$", "", n, flags=re.I)
|
|
1794
|
+
return n
|
|
1795
|
+
|
|
1796
|
+
|
|
1657
1797
|
def _run_anime_flow(show_name, episodes_detail, season=None, ep_str=None, player=None, quality=None, info_only=False, download=False, translation=None):
|
|
1658
1798
|
"""Drive the episode picker from allanime's *available* episodes (not TMDB's
|
|
1659
1799
|
full list), so we never offer an episode the anime source doesn't have."""
|
|
@@ -1803,39 +1943,111 @@ def update():
|
|
|
1803
1943
|
# ---------------------------------------------------------------------------
|
|
1804
1944
|
|
|
1805
1945
|
|
|
1806
|
-
|
|
1946
|
+
class HistoryGroup(click.Group):
|
|
1947
|
+
def resolve_command(self, ctx, args):
|
|
1948
|
+
if not args:
|
|
1949
|
+
return super().resolve_command(ctx, args)
|
|
1950
|
+
parent = super()
|
|
1951
|
+
cmd = parent.get_command(ctx, args[0])
|
|
1952
|
+
if cmd is not None:
|
|
1953
|
+
return super().resolve_command(ctx, args)
|
|
1954
|
+
cmd = parent.get_command(ctx, "list")
|
|
1955
|
+
return "list", cmd, args
|
|
1956
|
+
|
|
1957
|
+
|
|
1958
|
+
@cli.group(cls=HistoryGroup, invoke_without_command=True)
|
|
1959
|
+
@click.pass_context
|
|
1960
|
+
def history(ctx):
|
|
1961
|
+
"""View or manage watch history."""
|
|
1962
|
+
if ctx.invoked_subcommand is None:
|
|
1963
|
+
ctx.invoke(history_list, query=None)
|
|
1964
|
+
|
|
1965
|
+
|
|
1966
|
+
@history.command("list")
|
|
1807
1967
|
@click.argument("query", nargs=-1, required=False)
|
|
1808
|
-
@click.option("--clear", is_flag=True, help="Clear all watch history")
|
|
1809
1968
|
@_handle_errors
|
|
1810
|
-
def
|
|
1969
|
+
def history_list(query):
|
|
1811
1970
|
"""Show watch history and resume from last episode."""
|
|
1812
|
-
from .history import
|
|
1813
|
-
|
|
1814
|
-
if clear:
|
|
1815
|
-
clear_history()
|
|
1816
|
-
_print_success("Watch history cleared.")
|
|
1817
|
-
return
|
|
1971
|
+
from .history import get_history, list_history
|
|
1818
1972
|
|
|
1819
1973
|
q = " ".join(query) if query else None
|
|
1820
1974
|
if q:
|
|
1821
|
-
|
|
1822
|
-
if not
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1975
|
+
entries = get_history(q)
|
|
1976
|
+
if not entries:
|
|
1977
|
+
tmdb = _get_tmdb()
|
|
1978
|
+
results = tmdb.search_tv(q).get("results", [])
|
|
1979
|
+
if not results:
|
|
1980
|
+
results = tmdb.search_movie(q).get("results", [])
|
|
1981
|
+
if results:
|
|
1982
|
+
title_key = "name" if results[0].get("name") else "title"
|
|
1983
|
+
matched = results[0].get(title_key, "")
|
|
1984
|
+
if matched:
|
|
1985
|
+
entries = get_history(matched)
|
|
1986
|
+
if not entries:
|
|
1987
|
+
from .anilist import search_anime
|
|
1988
|
+
anilist_results = search_anime(q)
|
|
1989
|
+
if anilist_results:
|
|
1990
|
+
def _title(m):
|
|
1991
|
+
return m.get("title", {}).get("english") or m.get("title", {}).get("romaji") or m.get("title", {}).get("native", "?")
|
|
1992
|
+
matched = _title(anilist_results[0])
|
|
1993
|
+
if matched:
|
|
1994
|
+
entries = get_history(matched)
|
|
1995
|
+
if not entries:
|
|
1996
|
+
_print_info(f"No history found for \"{q}\".")
|
|
1997
|
+
return
|
|
1998
|
+
|
|
1999
|
+
table = Table(border_style="dim")
|
|
2000
|
+
table.add_column("#", style="cyan")
|
|
2001
|
+
table.add_column("Episode", style="green")
|
|
2002
|
+
table.add_column("Date", style="yellow")
|
|
2003
|
+
import datetime
|
|
2004
|
+
for i, e in enumerate(entries, 1):
|
|
2005
|
+
ts = e.get("timestamp", 0)
|
|
2006
|
+
date_label = datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else ""
|
|
2007
|
+
season = e.get("season")
|
|
2008
|
+
episode = e.get("episode")
|
|
2009
|
+
if season is not None and episode is not None:
|
|
2010
|
+
ep_label = f"S{season}E{episode}"
|
|
2011
|
+
else:
|
|
2012
|
+
ep_label = "[dim]—[/dim]"
|
|
2013
|
+
table.add_row(str(i), ep_label, date_label)
|
|
2014
|
+
|
|
2015
|
+
console.print(table)
|
|
2016
|
+
|
|
2017
|
+
last = entries[0]
|
|
2018
|
+
resume_ep = (last.get("episode") or 0) + 1
|
|
2019
|
+
try:
|
|
2020
|
+
answer = Prompt.ask(
|
|
2021
|
+
f" Continue from [bold]S{last.get('season', 1)}E{resume_ep}[/bold]? [dim](y/n)[/dim]",
|
|
2022
|
+
default="y",
|
|
2023
|
+
show_default=False,
|
|
2024
|
+
).strip().lower()
|
|
2025
|
+
if answer in ("", "y", "yes"):
|
|
2026
|
+
show_name = q
|
|
2027
|
+
show_id = 0
|
|
2028
|
+
tmdb = _get_tmdb()
|
|
2029
|
+
try:
|
|
2030
|
+
results = tmdb.search_tv(q).get("results", [])
|
|
2031
|
+
if results:
|
|
2032
|
+
show_name = results[0].get("name", q)
|
|
2033
|
+
show_id = results[0]["id"]
|
|
2034
|
+
except Exception:
|
|
2035
|
+
pass
|
|
2036
|
+
_play_with_menu(
|
|
2037
|
+
{"name": show_name, "id": show_id},
|
|
2038
|
+
last.get("season", 1),
|
|
2039
|
+
resume_ep,
|
|
2040
|
+
None,
|
|
2041
|
+
"",
|
|
2042
|
+
last.get("scraper") or load_config().get("default_scraper", "webstream"),
|
|
2043
|
+
None,
|
|
2044
|
+
last.get("quality"),
|
|
2045
|
+
False,
|
|
2046
|
+
False,
|
|
2047
|
+
translation=last.get("translation"),
|
|
2048
|
+
)
|
|
2049
|
+
except (EOFError, KeyboardInterrupt):
|
|
2050
|
+
pass
|
|
1839
2051
|
return
|
|
1840
2052
|
|
|
1841
2053
|
entries = list_history()
|
|
@@ -1849,16 +2061,42 @@ def history(query, clear):
|
|
|
1849
2061
|
table.add_column("Last watched", style="yellow")
|
|
1850
2062
|
|
|
1851
2063
|
import datetime
|
|
1852
|
-
for
|
|
2064
|
+
for e in entries:
|
|
1853
2065
|
ts = e.get("timestamp", 0)
|
|
1854
2066
|
date_label = datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else ""
|
|
1855
|
-
|
|
2067
|
+
season = e.get("season")
|
|
2068
|
+
episode = e.get("episode")
|
|
2069
|
+
if season is not None and episode is not None:
|
|
2070
|
+
ep_label = f"S{season}E{episode}"
|
|
2071
|
+
else:
|
|
2072
|
+
ep_label = "[dim]—[/dim]"
|
|
2073
|
+
table.add_row(e.get("title", "?"), ep_label, date_label)
|
|
1856
2074
|
|
|
1857
2075
|
console.print(table)
|
|
1858
2076
|
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
2077
|
+
|
|
2078
|
+
@history.command("clear")
|
|
2079
|
+
@_handle_errors
|
|
2080
|
+
def history_clear():
|
|
2081
|
+
"""Clear all watch history."""
|
|
2082
|
+
from .history import clear_history
|
|
2083
|
+
clear_history()
|
|
2084
|
+
_print_success("Watch history cleared.")
|
|
2085
|
+
|
|
2086
|
+
|
|
2087
|
+
@history.command("toggle")
|
|
2088
|
+
@click.argument("state", type=click.Choice(["on", "off"]), required=False)
|
|
2089
|
+
@_handle_errors
|
|
2090
|
+
def history_toggle(state):
|
|
2091
|
+
"""Enable or disable watch history tracking."""
|
|
2092
|
+
cfg = load_config()
|
|
2093
|
+
if state:
|
|
2094
|
+
cfg["history_enabled"] = (state == "on")
|
|
2095
|
+
else:
|
|
2096
|
+
cfg["history_enabled"] = not cfg.get("history_enabled", True)
|
|
2097
|
+
save_config(cfg)
|
|
2098
|
+
status = "enabled" if cfg["history_enabled"] else "disabled"
|
|
2099
|
+
_print_success(f"Watch history tracking {status}.")
|
|
1862
2100
|
|
|
1863
2101
|
|
|
1864
2102
|
# ---------------------------------------------------------------------------
|
|
@@ -25,9 +25,17 @@ def _db():
|
|
|
25
25
|
def _load():
|
|
26
26
|
try:
|
|
27
27
|
with open(_db(), encoding="utf-8") as f:
|
|
28
|
-
|
|
28
|
+
data = json.load(f)
|
|
29
29
|
except (FileNotFoundError, json.JSONDecodeError):
|
|
30
|
-
return
|
|
30
|
+
return []
|
|
31
|
+
if isinstance(data, dict):
|
|
32
|
+
entries = []
|
|
33
|
+
for title, entry in data.items():
|
|
34
|
+
entry["title"] = title
|
|
35
|
+
entries.append(entry)
|
|
36
|
+
_save(entries)
|
|
37
|
+
return entries
|
|
38
|
+
return data
|
|
31
39
|
|
|
32
40
|
|
|
33
41
|
def _save(h):
|
|
@@ -36,31 +44,36 @@ def _save(h):
|
|
|
36
44
|
|
|
37
45
|
|
|
38
46
|
def set_history(title, season, episode, scraper=None, translation=None, quality=None):
|
|
47
|
+
from .config import load_config
|
|
48
|
+
if not load_config().get("history_enabled", True):
|
|
49
|
+
return
|
|
39
50
|
h = _load()
|
|
40
|
-
h
|
|
51
|
+
h.append({
|
|
52
|
+
"title": title,
|
|
41
53
|
"season": season,
|
|
42
54
|
"episode": episode,
|
|
43
55
|
"timestamp": time.time(),
|
|
44
56
|
"scraper": scraper,
|
|
45
57
|
"translation": translation,
|
|
46
58
|
"quality": quality,
|
|
47
|
-
}
|
|
59
|
+
})
|
|
48
60
|
_save(h)
|
|
49
61
|
|
|
50
62
|
|
|
51
63
|
def get_history(title):
|
|
52
|
-
|
|
64
|
+
h = _load()
|
|
65
|
+
return [e for e in h if e.get("title") == title]
|
|
53
66
|
|
|
54
67
|
|
|
55
68
|
def list_history():
|
|
56
69
|
h = _load()
|
|
57
|
-
return sorted(h
|
|
70
|
+
return sorted(h, key=lambda e: e.get("timestamp", 0), reverse=True)
|
|
58
71
|
|
|
59
72
|
|
|
60
73
|
def clear_history(title=None):
|
|
61
74
|
h = _load()
|
|
62
75
|
if title:
|
|
63
|
-
h.
|
|
76
|
+
h = [e for e in h if e.get("title") != title]
|
|
64
77
|
else:
|
|
65
|
-
h
|
|
66
|
-
_save(h)
|
|
78
|
+
h = []
|
|
79
|
+
_save(h)
|