cinnamon-cli 0.2.19__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cinnamon/__init__.py +1 -0
- cinnamon/anilist.py +33 -0
- cinnamon/cli.py +1822 -0
- cinnamon/config.py +121 -0
- cinnamon/downloads.py +83 -0
- cinnamon/errors.py +145 -0
- cinnamon/history.py +66 -0
- cinnamon/player.py +550 -0
- cinnamon/scrapers/__init__.py +97 -0
- cinnamon/scrapers/anime.py +196 -0
- cinnamon/scrapers/base.py +34 -0
- cinnamon/scrapers/torrentio.py +123 -0
- cinnamon/scrapers/vidsrc.py +131 -0
- cinnamon/scrapers/webstream.py +282 -0
- cinnamon/tmdb.py +83 -0
- cinnamon/tui.py +543 -0
- cinnamon_cli-0.2.19.dist-info/METADATA +166 -0
- cinnamon_cli-0.2.19.dist-info/RECORD +22 -0
- cinnamon_cli-0.2.19.dist-info/WHEEL +5 -0
- cinnamon_cli-0.2.19.dist-info/entry_points.txt +2 -0
- cinnamon_cli-0.2.19.dist-info/licenses/LICENSE +38 -0
- cinnamon_cli-0.2.19.dist-info/top_level.txt +1 -0
cinnamon/config.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .errors import InvalidConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _config_dir():
|
|
10
|
+
if sys.platform == "win32":
|
|
11
|
+
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
12
|
+
elif os.environ.get("TERMUX_VERSION"):
|
|
13
|
+
base = Path.home() / ".config"
|
|
14
|
+
elif xdg := os.environ.get("XDG_CONFIG_HOME"):
|
|
15
|
+
base = Path(xdg)
|
|
16
|
+
else:
|
|
17
|
+
base = Path.home() / ".config"
|
|
18
|
+
return base / "cinnamon"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
CONFIG_DIR = _config_dir()
|
|
22
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
23
|
+
SCRAPERS_DIR = CONFIG_DIR / "scrapers"
|
|
24
|
+
|
|
25
|
+
DEFAULTS = {
|
|
26
|
+
"tmdb_api_key": "",
|
|
27
|
+
"default_scraper": "webstream",
|
|
28
|
+
"default_player": "auto",
|
|
29
|
+
"theme": "cinnamon",
|
|
30
|
+
"scrapers": {},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
THEMES = {
|
|
35
|
+
"cinnamon": {
|
|
36
|
+
"accent": "orange1",
|
|
37
|
+
"accent2": "gold1",
|
|
38
|
+
"success": "green",
|
|
39
|
+
"error": "red",
|
|
40
|
+
"info": "cyan",
|
|
41
|
+
"dim": "grey50",
|
|
42
|
+
"panel": "bright_yellow",
|
|
43
|
+
"border": "orange1",
|
|
44
|
+
},
|
|
45
|
+
"ocean": {
|
|
46
|
+
"accent": "deep_sky_blue1",
|
|
47
|
+
"accent2": "cyan",
|
|
48
|
+
"success": "spring_green2",
|
|
49
|
+
"error": "red",
|
|
50
|
+
"info": "light_cyan1",
|
|
51
|
+
"dim": "grey50",
|
|
52
|
+
"panel": "deep_sky_blue1",
|
|
53
|
+
"border": "blue",
|
|
54
|
+
},
|
|
55
|
+
"mono": {
|
|
56
|
+
"accent": "white",
|
|
57
|
+
"accent2": "bright_white",
|
|
58
|
+
"success": "green",
|
|
59
|
+
"error": "red",
|
|
60
|
+
"info": "bright_white",
|
|
61
|
+
"dim": "grey35",
|
|
62
|
+
"panel": "bright_white",
|
|
63
|
+
"border": "grey50",
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_theme():
|
|
69
|
+
cfg = load_config()
|
|
70
|
+
name = cfg.get("theme", "cinnamon")
|
|
71
|
+
return THEMES.get(name, THEMES["cinnamon"])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_config():
|
|
75
|
+
if CONFIG_FILE.exists():
|
|
76
|
+
try:
|
|
77
|
+
data = json.loads(CONFIG_FILE.read_text())
|
|
78
|
+
return {**DEFAULTS, **data}
|
|
79
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
80
|
+
raise InvalidConfig(str(e))
|
|
81
|
+
return dict(DEFAULTS)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def save_config(config):
|
|
85
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
merged = {**DEFAULTS, **config}
|
|
87
|
+
CONFIG_FILE.write_text(json.dumps(merged, indent=2))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_tmdb_api_key():
|
|
91
|
+
api_key = os.getenv("TMDB_API_KEY")
|
|
92
|
+
if api_key:
|
|
93
|
+
return api_key
|
|
94
|
+
return load_config().get("tmdb_api_key", "")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def set_tmdb_api_key(api_key):
|
|
98
|
+
config = load_config()
|
|
99
|
+
config["tmdb_api_key"] = api_key
|
|
100
|
+
save_config(config)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_scraper_config(scraper_name):
|
|
104
|
+
config = load_config()
|
|
105
|
+
return config.get("scrapers", {}).get(scraper_name, {})
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def set_scraper_config(scraper_name, key, value):
|
|
109
|
+
config = load_config()
|
|
110
|
+
if "scrapers" not in config:
|
|
111
|
+
config["scrapers"] = {}
|
|
112
|
+
if scraper_name not in config["scrapers"]:
|
|
113
|
+
config["scrapers"][scraper_name] = {}
|
|
114
|
+
config["scrapers"][scraper_name][key] = value
|
|
115
|
+
save_config(config)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_ensured_dirs():
|
|
119
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
SCRAPERS_DIR.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
return CONFIG_DIR, SCRAPERS_DIR
|
cinnamon/downloads.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
_DATA_DIR = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _data_dir():
|
|
11
|
+
global _DATA_DIR
|
|
12
|
+
if _DATA_DIR is None:
|
|
13
|
+
if sys.platform == "win32":
|
|
14
|
+
base = os.environ.get("APPDATA", os.path.expanduser("~"))
|
|
15
|
+
else:
|
|
16
|
+
base = os.path.join(os.path.expanduser("~"), ".local", "share")
|
|
17
|
+
_DATA_DIR = os.path.join(base, "cinnamon")
|
|
18
|
+
os.makedirs(_DATA_DIR, exist_ok=True)
|
|
19
|
+
return _DATA_DIR
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _db():
|
|
23
|
+
return os.path.join(_data_dir(), "downloads.json")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load():
|
|
27
|
+
try:
|
|
28
|
+
with open(_db(), encoding="utf-8") as f:
|
|
29
|
+
return json.load(f)
|
|
30
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _save(dls):
|
|
35
|
+
with open(_db(), "w", encoding="utf-8") as f:
|
|
36
|
+
json.dump(dls, f, indent=2, ensure_ascii=False)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def create(info):
|
|
40
|
+
dls = _load()
|
|
41
|
+
entry = {
|
|
42
|
+
"id": str(uuid.uuid4())[:8],
|
|
43
|
+
"title": info.get("title", "Untitled"),
|
|
44
|
+
"url": info.get("url"),
|
|
45
|
+
"tv_id": info.get("tv_id"),
|
|
46
|
+
"season": info.get("season"),
|
|
47
|
+
"episode": info.get("episode"),
|
|
48
|
+
"quality": info.get("quality"),
|
|
49
|
+
"referer": info.get("referer"),
|
|
50
|
+
"timestamp": time.time(),
|
|
51
|
+
"status": "queued",
|
|
52
|
+
}
|
|
53
|
+
dls.append(entry)
|
|
54
|
+
_save(dls)
|
|
55
|
+
return entry["id"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def update(download_id, **kwargs):
|
|
59
|
+
dls = _load()
|
|
60
|
+
for d in dls:
|
|
61
|
+
if d["id"] == download_id:
|
|
62
|
+
d.update(kwargs)
|
|
63
|
+
break
|
|
64
|
+
_save(dls)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def list_all(status=None):
|
|
68
|
+
dls = _load()
|
|
69
|
+
if status:
|
|
70
|
+
return [d for d in dls if d.get("status") == status]
|
|
71
|
+
return dls
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get(download_id):
|
|
75
|
+
for d in _load():
|
|
76
|
+
if d["id"] == download_id:
|
|
77
|
+
return d
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def remove(download_id):
|
|
82
|
+
dls = _load()
|
|
83
|
+
_save([d for d in dls if d["id"] != download_id])
|
cinnamon/errors.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CinnamonError(Exception):
|
|
5
|
+
"""Base for all cinnamon errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigError(CinnamonError):
|
|
9
|
+
"""Configuration issues."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MissingAPIKey(ConfigError):
|
|
13
|
+
def __init__(self):
|
|
14
|
+
super().__init__("TMDB API key not configured.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InvalidConfig(ConfigError):
|
|
18
|
+
def __init__(self, msg=""):
|
|
19
|
+
super().__init__(msg or "Configuration file is invalid.")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TMDBError(CinnamonError):
|
|
23
|
+
"""Base for TMDB API errors."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message, status_code=None, details=None):
|
|
26
|
+
self.status_code = status_code
|
|
27
|
+
self.details = details
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TMDBConnectionError(TMDBError):
|
|
32
|
+
def __init__(self, cause=None):
|
|
33
|
+
msg = "Could not connect to TMDB. Check your internet connection."
|
|
34
|
+
if cause:
|
|
35
|
+
msg = f"{msg}\n Details: {cause}"
|
|
36
|
+
super().__init__(msg)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TMDBAuthError(TMDBError):
|
|
40
|
+
def __init__(self):
|
|
41
|
+
super().__init__(
|
|
42
|
+
"Your TMDB API key is invalid or has been revoked.\n"
|
|
43
|
+
" Run [bold]cinnamon setup[/bold] to update it.",
|
|
44
|
+
status_code=401,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TMDBRateLimitError(TMDBError):
|
|
49
|
+
def __init__(self, retry_after=2):
|
|
50
|
+
self.retry_after = retry_after
|
|
51
|
+
super().__init__(
|
|
52
|
+
f"TMDB rate limit hit. Auto-retrying in {retry_after}s...",
|
|
53
|
+
status_code=429,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TMDBNotFoundError(TMDBError):
|
|
58
|
+
def __init__(self, resource):
|
|
59
|
+
super().__init__(
|
|
60
|
+
f"{resource} not found on TMDB. The ID may be wrong or removed.",
|
|
61
|
+
status_code=404,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TMDBAPIError(TMDBError):
|
|
66
|
+
def __init__(self, status_code, message=""):
|
|
67
|
+
super().__init__(
|
|
68
|
+
message or f"TMDB API returned error {status_code}.",
|
|
69
|
+
status_code=status_code,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ScraperError(CinnamonError):
|
|
74
|
+
"""Base for scraper errors."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ScraperNotFoundError(ScraperError):
|
|
78
|
+
def __init__(self, name, available=None):
|
|
79
|
+
msg = f"Scraper [bold]{name}[/bold] not found."
|
|
80
|
+
if available:
|
|
81
|
+
msg += f"\n Available: {', '.join(available)}"
|
|
82
|
+
super().__init__(msg)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ScraperNetworkError(ScraperError):
|
|
86
|
+
def __init__(self, scraper, cause=None):
|
|
87
|
+
msg = f"Network error in scraper [bold]{scraper}[/bold]."
|
|
88
|
+
if cause:
|
|
89
|
+
msg += f"\n Details: {cause}"
|
|
90
|
+
super().__init__(msg)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ScraperAuthError(ScraperError):
|
|
94
|
+
def __init__(self, scraper):
|
|
95
|
+
super().__init__(
|
|
96
|
+
f"Scraper [bold]{scraper}[/bold] requires authentication.\n"
|
|
97
|
+
f" Configure it via [bold]cinnamon config scraper {scraper} --key ...[/bold]"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ScraperRateLimitError(ScraperError):
|
|
102
|
+
def __init__(self, scraper, retry_after=None):
|
|
103
|
+
msg = f"Scraper [bold]{scraper}[/bold] is rate-limited."
|
|
104
|
+
if retry_after:
|
|
105
|
+
msg += f" Retry in {retry_after}s."
|
|
106
|
+
super().__init__(msg)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ScraperParseError(ScraperError):
|
|
110
|
+
def __init__(self, scraper, cause=None):
|
|
111
|
+
msg = f"Failed to parse response from scraper [bold]{scraper}[/bold]."
|
|
112
|
+
if cause:
|
|
113
|
+
msg += f"\n Details: {cause}"
|
|
114
|
+
super().__init__(msg)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ScraperNoStreamError(ScraperError):
|
|
118
|
+
def __init__(self, scraper, message=None):
|
|
119
|
+
super().__init__(
|
|
120
|
+
message or f"Scraper [bold]{scraper}[/bold] found no streams for this episode."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class PlayerError(CinnamonError):
|
|
125
|
+
"""Base for player errors."""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class PlayerNotFoundError(PlayerError):
|
|
129
|
+
def __init__(self, player="auto"):
|
|
130
|
+
msg = (
|
|
131
|
+
f"No media player found."
|
|
132
|
+
)
|
|
133
|
+
if player in ("mpv", "auto"):
|
|
134
|
+
msg += "\n Install mpv: https://mpv.io"
|
|
135
|
+
if player in ("vlc", "auto"):
|
|
136
|
+
msg += "\n Install VLC: https://videolan.org"
|
|
137
|
+
super().__init__(msg)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class PlayerLaunchError(PlayerError):
|
|
141
|
+
def __init__(self, player, cause=None):
|
|
142
|
+
msg = f"Failed to launch [bold]{player}[/bold]."
|
|
143
|
+
if cause:
|
|
144
|
+
msg += f"\n Details: {cause}"
|
|
145
|
+
super().__init__(msg)
|
cinnamon/history.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
_DATA_DIR = None
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _data_dir():
|
|
10
|
+
global _DATA_DIR
|
|
11
|
+
if _DATA_DIR is None:
|
|
12
|
+
if sys.platform == "win32":
|
|
13
|
+
base = os.environ.get("APPDATA", os.path.expanduser("~"))
|
|
14
|
+
else:
|
|
15
|
+
base = os.path.join(os.path.expanduser("~"), ".local", "share")
|
|
16
|
+
_DATA_DIR = os.path.join(base, "cinnamon")
|
|
17
|
+
os.makedirs(_DATA_DIR, exist_ok=True)
|
|
18
|
+
return _DATA_DIR
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _db():
|
|
22
|
+
return os.path.join(_data_dir(), "history.json")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load():
|
|
26
|
+
try:
|
|
27
|
+
with open(_db(), encoding="utf-8") as f:
|
|
28
|
+
return json.load(f)
|
|
29
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
30
|
+
return {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _save(h):
|
|
34
|
+
with open(_db(), "w", encoding="utf-8") as f:
|
|
35
|
+
json.dump(h, f, indent=2, ensure_ascii=False)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def set_history(title, season, episode, scraper=None, translation=None, quality=None):
|
|
39
|
+
h = _load()
|
|
40
|
+
h[title] = {
|
|
41
|
+
"season": season,
|
|
42
|
+
"episode": episode,
|
|
43
|
+
"timestamp": time.time(),
|
|
44
|
+
"scraper": scraper,
|
|
45
|
+
"translation": translation,
|
|
46
|
+
"quality": quality,
|
|
47
|
+
}
|
|
48
|
+
_save(h)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_history(title):
|
|
52
|
+
return _load().get(title)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def list_history():
|
|
56
|
+
h = _load()
|
|
57
|
+
return sorted(h.items(), key=lambda kv: kv[1].get("timestamp", 0), reverse=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def clear_history(title=None):
|
|
61
|
+
h = _load()
|
|
62
|
+
if title:
|
|
63
|
+
h.pop(title, None)
|
|
64
|
+
else:
|
|
65
|
+
h.clear()
|
|
66
|
+
_save(h)
|