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/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)