weeb-cli 1.0.0__py3-none-any.whl → 2.0.0__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.
- weeb_cli/__init__.py +1 -1
- weeb_cli/commands/downloads.py +339 -8
- weeb_cli/commands/settings.py +127 -1
- weeb_cli/config.py +17 -27
- weeb_cli/locales/en.json +31 -3
- weeb_cli/locales/tr.json +31 -3
- weeb_cli/providers/__init__.py +2 -0
- weeb_cli/providers/allanime.py +252 -0
- weeb_cli/providers/extractors/__init__.py +0 -0
- weeb_cli/providers/extractors/megacloud.py +238 -0
- weeb_cli/providers/hianime.py +294 -0
- weeb_cli/services/database.py +313 -0
- weeb_cli/services/downloader.py +60 -49
- weeb_cli/services/local_library.py +263 -0
- weeb_cli/services/logger.py +55 -0
- weeb_cli/services/notifier.py +41 -0
- weeb_cli/services/progress.py +27 -77
- weeb_cli/ui/header.py +1 -1
- weeb_cli/ui/menu.py +2 -2
- weeb_cli-2.0.0.dist-info/METADATA +199 -0
- weeb_cli-2.0.0.dist-info/RECORD +46 -0
- weeb_cli-1.0.0.dist-info/METADATA +0 -148
- weeb_cli-1.0.0.dist-info/RECORD +0 -38
- {weeb_cli-1.0.0.dist-info → weeb_cli-2.0.0.dist-info}/WHEEL +0 -0
- {weeb_cli-1.0.0.dist-info → weeb_cli-2.0.0.dist-info}/entry_points.txt +0 -0
- {weeb_cli-1.0.0.dist-info → weeb_cli-2.0.0.dist-info}/licenses/LICENSE +0 -0
- {weeb_cli-1.0.0.dist-info → weeb_cli-2.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import urllib.request
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
from bs4 import BeautifulSoup
|
|
7
|
+
|
|
8
|
+
from weeb_cli.providers.base import (
|
|
9
|
+
BaseProvider,
|
|
10
|
+
AnimeResult,
|
|
11
|
+
AnimeDetails,
|
|
12
|
+
Episode,
|
|
13
|
+
StreamLink
|
|
14
|
+
)
|
|
15
|
+
from weeb_cli.providers.registry import register_provider
|
|
16
|
+
from weeb_cli.providers.extractors.megacloud import extract_stream
|
|
17
|
+
|
|
18
|
+
BASE_URL = "https://hianime.to"
|
|
19
|
+
AJAX_URL = f"{BASE_URL}/ajax/v2"
|
|
20
|
+
|
|
21
|
+
HEADERS = {
|
|
22
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
23
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0",
|
|
24
|
+
"Referer": BASE_URL
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _http_get(url: str, headers: dict = None, timeout: int = 15) -> bytes:
|
|
29
|
+
req = urllib.request.Request(url, headers=headers or HEADERS)
|
|
30
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
31
|
+
return resp.read()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_json(url: str, headers: dict = None) -> Optional[dict]:
|
|
35
|
+
try:
|
|
36
|
+
data = _http_get(url, headers)
|
|
37
|
+
return json.loads(data)
|
|
38
|
+
except:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _get_html(url: str, headers: dict = None) -> str:
|
|
43
|
+
try:
|
|
44
|
+
data = _http_get(url, headers)
|
|
45
|
+
return data.decode('utf-8')
|
|
46
|
+
except:
|
|
47
|
+
return ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@register_provider("hianime", lang="en", region="US")
|
|
51
|
+
class HiAnimeProvider(BaseProvider):
|
|
52
|
+
|
|
53
|
+
def __init__(self):
|
|
54
|
+
super().__init__()
|
|
55
|
+
self.headers = HEADERS.copy()
|
|
56
|
+
|
|
57
|
+
def search(self, query: str) -> List[AnimeResult]:
|
|
58
|
+
q = (query or "").strip()
|
|
59
|
+
if not q:
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
url = f"{BASE_URL}/search?keyword={quote(q)}"
|
|
63
|
+
html = _get_html(url, self.headers)
|
|
64
|
+
|
|
65
|
+
if not html:
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
soup = BeautifulSoup(html, 'html.parser')
|
|
69
|
+
results = []
|
|
70
|
+
|
|
71
|
+
for item in soup.select('.flw-item'):
|
|
72
|
+
try:
|
|
73
|
+
title_el = item.select_one('.film-name .dynamic-name')
|
|
74
|
+
if not title_el:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
title = title_el.get_text(strip=True)
|
|
78
|
+
alt_title = title_el.get('data-jname', '')
|
|
79
|
+
href = title_el.get('href', '')
|
|
80
|
+
anime_id = href.split('/')[-1].split('?')[0] if href else None
|
|
81
|
+
|
|
82
|
+
if not anime_id or not title:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
poster = item.select_one('.film-poster-img')
|
|
86
|
+
cover = poster.get('data-src') if poster else None
|
|
87
|
+
|
|
88
|
+
sub_el = item.select_one('.tick-sub')
|
|
89
|
+
dub_el = item.select_one('.tick-dub')
|
|
90
|
+
eps_el = item.select_one('.tick-eps')
|
|
91
|
+
|
|
92
|
+
sub_count = sub_el.get_text(strip=True) if sub_el else "0"
|
|
93
|
+
dub_count = dub_el.get_text(strip=True) if dub_el else "0"
|
|
94
|
+
|
|
95
|
+
type_el = item.select_one('.fdi-item')
|
|
96
|
+
anime_type = type_el.get_text(strip=True).lower() if type_el else "tv"
|
|
97
|
+
|
|
98
|
+
duration_el = item.select_one('.fdi-duration')
|
|
99
|
+
duration = duration_el.get_text(strip=True) if duration_el else ""
|
|
100
|
+
|
|
101
|
+
results.append(AnimeResult(
|
|
102
|
+
id=anime_id,
|
|
103
|
+
title=title,
|
|
104
|
+
type=self._parse_type(anime_type),
|
|
105
|
+
cover=cover
|
|
106
|
+
))
|
|
107
|
+
except:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
return results
|
|
111
|
+
|
|
112
|
+
def get_details(self, anime_id: str) -> Optional[AnimeDetails]:
|
|
113
|
+
url = f"{BASE_URL}/{anime_id}"
|
|
114
|
+
html = _get_html(url, self.headers)
|
|
115
|
+
|
|
116
|
+
if not html:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
soup = BeautifulSoup(html, 'html.parser')
|
|
120
|
+
|
|
121
|
+
title_el = soup.select_one('.anisc-detail .film-name')
|
|
122
|
+
title = title_el.get_text(strip=True) if title_el else anime_id
|
|
123
|
+
|
|
124
|
+
alt_title_el = soup.select_one('.film-name[data-jname]')
|
|
125
|
+
alt_title = alt_title_el.get('data-jname') if alt_title_el else None
|
|
126
|
+
|
|
127
|
+
desc_el = soup.select_one('.film-description .text')
|
|
128
|
+
description = desc_el.get_text(strip=True) if desc_el else None
|
|
129
|
+
|
|
130
|
+
poster_el = soup.select_one('.film-poster-img')
|
|
131
|
+
cover = poster_el.get('src') if poster_el else None
|
|
132
|
+
|
|
133
|
+
genres = []
|
|
134
|
+
for genre_el in soup.select('.item-list a[href*="/genre/"]'):
|
|
135
|
+
genres.append(genre_el.get_text(strip=True))
|
|
136
|
+
|
|
137
|
+
episodes = self.get_episodes(anime_id)
|
|
138
|
+
|
|
139
|
+
return AnimeDetails(
|
|
140
|
+
id=anime_id,
|
|
141
|
+
title=title,
|
|
142
|
+
description=description,
|
|
143
|
+
cover=cover,
|
|
144
|
+
genres=genres,
|
|
145
|
+
episodes=episodes,
|
|
146
|
+
total_episodes=len(episodes)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def get_episodes(self, anime_id: str) -> List[Episode]:
|
|
150
|
+
match = re.search(r'-(\d+)$', anime_id)
|
|
151
|
+
if not match:
|
|
152
|
+
return []
|
|
153
|
+
|
|
154
|
+
show_id = match.group(1)
|
|
155
|
+
url = f"{AJAX_URL}/episode/list/{show_id}"
|
|
156
|
+
|
|
157
|
+
data = _get_json(url, self.headers)
|
|
158
|
+
if not data or 'html' not in data:
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
soup = BeautifulSoup(data['html'], 'html.parser')
|
|
162
|
+
episodes = []
|
|
163
|
+
|
|
164
|
+
for i, item in enumerate(soup.select('.ssl-item.ep-item')):
|
|
165
|
+
try:
|
|
166
|
+
href = item.get('href', '')
|
|
167
|
+
ep_id = href.replace('/watch/', '').replace('?', '::') if href else None
|
|
168
|
+
|
|
169
|
+
if not ep_id:
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
title = item.get('title', '')
|
|
173
|
+
alt_title = item.select_one('.ep-name.e-dynamic-name')
|
|
174
|
+
alt_title = alt_title.get('data-jname') if alt_title else None
|
|
175
|
+
|
|
176
|
+
is_filler = 'ssl-item-filler' in item.get('class', [])
|
|
177
|
+
ep_num = i + 1
|
|
178
|
+
|
|
179
|
+
episodes.append(Episode(
|
|
180
|
+
id=ep_id,
|
|
181
|
+
number=ep_num,
|
|
182
|
+
title=title
|
|
183
|
+
))
|
|
184
|
+
except:
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
return episodes
|
|
188
|
+
|
|
189
|
+
def get_streams(self, anime_id: str, episode_id: str) -> List[StreamLink]:
|
|
190
|
+
ep_num = episode_id.split('ep=')[-1] if 'ep=' in episode_id else episode_id.split('::')[-1]
|
|
191
|
+
|
|
192
|
+
servers_url = f"{AJAX_URL}/episode/servers?episodeId={ep_num}"
|
|
193
|
+
servers_data = _get_json(servers_url, self.headers)
|
|
194
|
+
|
|
195
|
+
if not servers_data or 'html' not in servers_data:
|
|
196
|
+
return []
|
|
197
|
+
|
|
198
|
+
soup = BeautifulSoup(servers_data['html'], 'html.parser')
|
|
199
|
+
|
|
200
|
+
servers = {"sub": [], "dub": []}
|
|
201
|
+
|
|
202
|
+
for server_item in soup.select('.servers-sub .server-item'):
|
|
203
|
+
try:
|
|
204
|
+
server_id = server_item.get('data-id')
|
|
205
|
+
server_index = server_item.get('data-server-id')
|
|
206
|
+
server_name = server_item.select_one('a')
|
|
207
|
+
server_name = server_name.get_text(strip=True).lower() if server_name else 'unknown'
|
|
208
|
+
|
|
209
|
+
if server_id:
|
|
210
|
+
servers["sub"].append({
|
|
211
|
+
"id": int(server_id),
|
|
212
|
+
"index": int(server_index) if server_index else None,
|
|
213
|
+
"name": server_name,
|
|
214
|
+
"type": "sub"
|
|
215
|
+
})
|
|
216
|
+
except:
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
for server_item in soup.select('.servers-dub .server-item'):
|
|
220
|
+
try:
|
|
221
|
+
server_id = server_item.get('data-id')
|
|
222
|
+
server_index = server_item.get('data-server-id')
|
|
223
|
+
server_name = server_item.select_one('a')
|
|
224
|
+
server_name = server_name.get_text(strip=True).lower() if server_name else 'unknown'
|
|
225
|
+
|
|
226
|
+
if server_id:
|
|
227
|
+
servers["dub"].append({
|
|
228
|
+
"id": int(server_id),
|
|
229
|
+
"index": int(server_index) if server_index else None,
|
|
230
|
+
"name": server_name,
|
|
231
|
+
"type": "dub"
|
|
232
|
+
})
|
|
233
|
+
except:
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
streams = []
|
|
237
|
+
|
|
238
|
+
for server_type in ["sub", "dub"]:
|
|
239
|
+
for server in servers[server_type]:
|
|
240
|
+
try:
|
|
241
|
+
stream_data = extract_stream(
|
|
242
|
+
server_id=server["id"],
|
|
243
|
+
episode_id=episode_id,
|
|
244
|
+
server_type=server_type,
|
|
245
|
+
server_name=server["name"]
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
if stream_data and stream_data.get("file"):
|
|
249
|
+
streams.append(StreamLink(
|
|
250
|
+
url=stream_data["file"],
|
|
251
|
+
quality="auto",
|
|
252
|
+
server=f"{server['name']}-{server_type}",
|
|
253
|
+
headers={"Referer": "https://megacloud.tv"},
|
|
254
|
+
subtitles=self._get_subtitle_url(stream_data.get("tracks", []))
|
|
255
|
+
))
|
|
256
|
+
except:
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
if not streams:
|
|
260
|
+
for server_type in ["sub", "dub"]:
|
|
261
|
+
for server in servers[server_type]:
|
|
262
|
+
streams.append(StreamLink(
|
|
263
|
+
url=f"embedded:{server['id']}:{episode_id}:{server_type}:{server['name']}",
|
|
264
|
+
quality="auto",
|
|
265
|
+
server=f"{server['name']}-{server_type} (embedded)",
|
|
266
|
+
headers={"Referer": BASE_URL}
|
|
267
|
+
))
|
|
268
|
+
|
|
269
|
+
return streams
|
|
270
|
+
|
|
271
|
+
def _get_subtitle_url(self, tracks: List[dict]) -> Optional[str]:
|
|
272
|
+
for track in tracks:
|
|
273
|
+
if track.get('kind') in ['captions', 'subtitles']:
|
|
274
|
+
label = track.get('label', '').lower()
|
|
275
|
+
if 'english' in label:
|
|
276
|
+
return track.get('file')
|
|
277
|
+
|
|
278
|
+
for track in tracks:
|
|
279
|
+
if track.get('kind') in ['captions', 'subtitles']:
|
|
280
|
+
return track.get('file')
|
|
281
|
+
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
def _parse_type(self, type_str: str) -> str:
|
|
285
|
+
type_str = (type_str or "").lower()
|
|
286
|
+
if "movie" in type_str:
|
|
287
|
+
return "movie"
|
|
288
|
+
if "ova" in type_str:
|
|
289
|
+
return "ova"
|
|
290
|
+
if "ona" in type_str:
|
|
291
|
+
return "ona"
|
|
292
|
+
if "special" in type_str:
|
|
293
|
+
return "special"
|
|
294
|
+
return "series"
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
|
|
8
|
+
DB_PATH = Path.home() / ".weeb-cli" / "weeb.db"
|
|
9
|
+
|
|
10
|
+
class Database:
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self.db_path = DB_PATH
|
|
13
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
self._init_db()
|
|
15
|
+
self._migrate_from_json()
|
|
16
|
+
|
|
17
|
+
@contextmanager
|
|
18
|
+
def _conn(self):
|
|
19
|
+
conn = sqlite3.connect(self.db_path, timeout=10)
|
|
20
|
+
conn.row_factory = sqlite3.Row
|
|
21
|
+
try:
|
|
22
|
+
yield conn
|
|
23
|
+
conn.commit()
|
|
24
|
+
finally:
|
|
25
|
+
conn.close()
|
|
26
|
+
|
|
27
|
+
def _init_db(self):
|
|
28
|
+
with self._conn() as conn:
|
|
29
|
+
conn.executescript('''
|
|
30
|
+
CREATE TABLE IF NOT EXISTS config (
|
|
31
|
+
key TEXT PRIMARY KEY,
|
|
32
|
+
value TEXT
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS progress (
|
|
36
|
+
slug TEXT PRIMARY KEY,
|
|
37
|
+
title TEXT,
|
|
38
|
+
last_watched INTEGER DEFAULT 0,
|
|
39
|
+
total_episodes INTEGER DEFAULT 0,
|
|
40
|
+
completed TEXT DEFAULT '[]',
|
|
41
|
+
last_watched_at TEXT
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS search_history (
|
|
45
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
46
|
+
query TEXT UNIQUE,
|
|
47
|
+
searched_at TEXT
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS download_queue (
|
|
51
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
52
|
+
anime_title TEXT,
|
|
53
|
+
episode_number INTEGER,
|
|
54
|
+
episode_id TEXT,
|
|
55
|
+
slug TEXT,
|
|
56
|
+
status TEXT DEFAULT 'pending',
|
|
57
|
+
progress INTEGER DEFAULT 0,
|
|
58
|
+
eta TEXT DEFAULT '?',
|
|
59
|
+
error TEXT,
|
|
60
|
+
added_at REAL
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE TABLE IF NOT EXISTS external_drives (
|
|
64
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
65
|
+
path TEXT UNIQUE,
|
|
66
|
+
name TEXT,
|
|
67
|
+
added_at TEXT
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
CREATE TABLE IF NOT EXISTS anime_index (
|
|
71
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
72
|
+
title TEXT,
|
|
73
|
+
source_path TEXT,
|
|
74
|
+
source_name TEXT,
|
|
75
|
+
folder_path TEXT,
|
|
76
|
+
episode_count INTEGER DEFAULT 0,
|
|
77
|
+
indexed_at TEXT
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
CREATE INDEX IF NOT EXISTS idx_queue_status ON download_queue(status);
|
|
81
|
+
CREATE INDEX IF NOT EXISTS idx_progress_slug ON progress(slug);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_anime_title ON anime_index(title);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_anime_source ON anime_index(source_path);
|
|
84
|
+
''')
|
|
85
|
+
|
|
86
|
+
def _migrate_from_json(self):
|
|
87
|
+
config_dir = Path.home() / ".weeb-cli"
|
|
88
|
+
|
|
89
|
+
config_file = config_dir / "config.json"
|
|
90
|
+
if config_file.exists():
|
|
91
|
+
try:
|
|
92
|
+
with open(config_file, 'r', encoding='utf-8') as f:
|
|
93
|
+
data = json.load(f)
|
|
94
|
+
for key, value in data.items():
|
|
95
|
+
self.set_config(key, value)
|
|
96
|
+
config_file.rename(config_file.with_suffix('.json.bak'))
|
|
97
|
+
except:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
progress_file = config_dir / "progress.json"
|
|
101
|
+
if progress_file.exists():
|
|
102
|
+
try:
|
|
103
|
+
with open(progress_file, 'r', encoding='utf-8') as f:
|
|
104
|
+
data = json.load(f)
|
|
105
|
+
for slug, info in data.items():
|
|
106
|
+
self.save_progress(
|
|
107
|
+
slug,
|
|
108
|
+
info.get("title", slug),
|
|
109
|
+
info.get("last_watched", 0),
|
|
110
|
+
info.get("total_episodes", 0),
|
|
111
|
+
info.get("completed", []),
|
|
112
|
+
info.get("last_watched_at")
|
|
113
|
+
)
|
|
114
|
+
progress_file.rename(progress_file.with_suffix('.json.bak'))
|
|
115
|
+
except:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
history_file = config_dir / "search_history.json"
|
|
119
|
+
if history_file.exists():
|
|
120
|
+
try:
|
|
121
|
+
with open(history_file, 'r', encoding='utf-8') as f:
|
|
122
|
+
data = json.load(f)
|
|
123
|
+
for query in reversed(data):
|
|
124
|
+
self.add_search_history(query)
|
|
125
|
+
history_file.rename(history_file.with_suffix('.json.bak'))
|
|
126
|
+
except:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
queue_file = config_dir / "download_queue.json"
|
|
130
|
+
if queue_file.exists():
|
|
131
|
+
try:
|
|
132
|
+
with open(queue_file, 'r', encoding='utf-8') as f:
|
|
133
|
+
data = json.load(f)
|
|
134
|
+
for item in data:
|
|
135
|
+
self.add_to_queue(item)
|
|
136
|
+
queue_file.rename(queue_file.with_suffix('.json.bak'))
|
|
137
|
+
except:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
def get_config(self, key, default=None):
|
|
141
|
+
with self._conn() as conn:
|
|
142
|
+
row = conn.execute('SELECT value FROM config WHERE key = ?', (key,)).fetchone()
|
|
143
|
+
if row:
|
|
144
|
+
try:
|
|
145
|
+
return json.loads(row['value'])
|
|
146
|
+
except:
|
|
147
|
+
return row['value']
|
|
148
|
+
return default
|
|
149
|
+
|
|
150
|
+
def set_config(self, key, value):
|
|
151
|
+
with self._conn() as conn:
|
|
152
|
+
conn.execute(
|
|
153
|
+
'INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)',
|
|
154
|
+
(key, json.dumps(value))
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def get_all_config(self):
|
|
158
|
+
with self._conn() as conn:
|
|
159
|
+
rows = conn.execute('SELECT key, value FROM config').fetchall()
|
|
160
|
+
result = {}
|
|
161
|
+
for row in rows:
|
|
162
|
+
try:
|
|
163
|
+
result[row['key']] = json.loads(row['value'])
|
|
164
|
+
except:
|
|
165
|
+
result[row['key']] = row['value']
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
def save_progress(self, slug, title, last_watched, total_episodes, completed, last_watched_at=None):
|
|
169
|
+
with self._conn() as conn:
|
|
170
|
+
conn.execute('''
|
|
171
|
+
INSERT OR REPLACE INTO progress
|
|
172
|
+
(slug, title, last_watched, total_episodes, completed, last_watched_at)
|
|
173
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
174
|
+
''', (slug, title, last_watched, total_episodes, json.dumps(completed), last_watched_at))
|
|
175
|
+
|
|
176
|
+
def get_progress(self, slug):
|
|
177
|
+
with self._conn() as conn:
|
|
178
|
+
row = conn.execute('SELECT * FROM progress WHERE slug = ?', (slug,)).fetchone()
|
|
179
|
+
if row:
|
|
180
|
+
return {
|
|
181
|
+
"slug": row['slug'],
|
|
182
|
+
"title": row['title'],
|
|
183
|
+
"last_watched": row['last_watched'],
|
|
184
|
+
"total_episodes": row['total_episodes'],
|
|
185
|
+
"completed": json.loads(row['completed'] or '[]'),
|
|
186
|
+
"last_watched_at": row['last_watched_at']
|
|
187
|
+
}
|
|
188
|
+
return {"last_watched": 0, "completed": [], "title": "", "total_episodes": 0, "last_watched_at": None}
|
|
189
|
+
|
|
190
|
+
def get_all_progress(self):
|
|
191
|
+
with self._conn() as conn:
|
|
192
|
+
rows = conn.execute('SELECT * FROM progress').fetchall()
|
|
193
|
+
result = {}
|
|
194
|
+
for row in rows:
|
|
195
|
+
result[row['slug']] = {
|
|
196
|
+
"title": row['title'],
|
|
197
|
+
"last_watched": row['last_watched'],
|
|
198
|
+
"total_episodes": row['total_episodes'],
|
|
199
|
+
"completed": json.loads(row['completed'] or '[]'),
|
|
200
|
+
"last_watched_at": row['last_watched_at']
|
|
201
|
+
}
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
def add_search_history(self, query):
|
|
205
|
+
with self._conn() as conn:
|
|
206
|
+
conn.execute('DELETE FROM search_history WHERE query = ?', (query,))
|
|
207
|
+
conn.execute(
|
|
208
|
+
'INSERT INTO search_history (query, searched_at) VALUES (?, ?)',
|
|
209
|
+
(query, datetime.now().isoformat())
|
|
210
|
+
)
|
|
211
|
+
conn.execute('''
|
|
212
|
+
DELETE FROM search_history WHERE id NOT IN (
|
|
213
|
+
SELECT id FROM search_history ORDER BY searched_at DESC LIMIT 10
|
|
214
|
+
)
|
|
215
|
+
''')
|
|
216
|
+
|
|
217
|
+
def get_search_history(self):
|
|
218
|
+
with self._conn() as conn:
|
|
219
|
+
rows = conn.execute(
|
|
220
|
+
'SELECT query FROM search_history ORDER BY searched_at DESC LIMIT 10'
|
|
221
|
+
).fetchall()
|
|
222
|
+
return [row['query'] for row in rows]
|
|
223
|
+
|
|
224
|
+
def add_to_queue(self, item):
|
|
225
|
+
with self._conn() as conn:
|
|
226
|
+
existing = conn.execute(
|
|
227
|
+
'SELECT id FROM download_queue WHERE episode_id = ? AND status IN (?, ?)',
|
|
228
|
+
(item.get('episode_id'), 'pending', 'processing')
|
|
229
|
+
).fetchone()
|
|
230
|
+
if existing:
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
conn.execute('''
|
|
234
|
+
INSERT INTO download_queue
|
|
235
|
+
(anime_title, episode_number, episode_id, slug, status, progress, eta, added_at)
|
|
236
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
237
|
+
''', (
|
|
238
|
+
item.get('anime_title'),
|
|
239
|
+
item.get('episode_number'),
|
|
240
|
+
item.get('episode_id'),
|
|
241
|
+
item.get('slug'),
|
|
242
|
+
item.get('status', 'pending'),
|
|
243
|
+
item.get('progress', 0),
|
|
244
|
+
item.get('eta', '?'),
|
|
245
|
+
item.get('added_at', datetime.now().timestamp())
|
|
246
|
+
))
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
def get_queue(self):
|
|
250
|
+
with self._conn() as conn:
|
|
251
|
+
rows = conn.execute('SELECT * FROM download_queue ORDER BY added_at').fetchall()
|
|
252
|
+
return [dict(row) for row in rows]
|
|
253
|
+
|
|
254
|
+
def update_queue_item(self, episode_id, **kwargs):
|
|
255
|
+
with self._conn() as conn:
|
|
256
|
+
sets = ', '.join(f'{k} = ?' for k in kwargs.keys())
|
|
257
|
+
values = list(kwargs.values()) + [episode_id]
|
|
258
|
+
conn.execute(f'UPDATE download_queue SET {sets} WHERE episode_id = ?', values)
|
|
259
|
+
|
|
260
|
+
def clear_completed_queue(self):
|
|
261
|
+
with self._conn() as conn:
|
|
262
|
+
conn.execute('DELETE FROM download_queue WHERE status NOT IN (?, ?)', ('pending', 'processing'))
|
|
263
|
+
|
|
264
|
+
def get_external_drives(self):
|
|
265
|
+
with self._conn() as conn:
|
|
266
|
+
rows = conn.execute('SELECT * FROM external_drives ORDER BY name').fetchall()
|
|
267
|
+
return [dict(row) for row in rows]
|
|
268
|
+
|
|
269
|
+
def add_external_drive(self, path, name=None):
|
|
270
|
+
with self._conn() as conn:
|
|
271
|
+
conn.execute(
|
|
272
|
+
'INSERT OR REPLACE INTO external_drives (path, name, added_at) VALUES (?, ?, ?)',
|
|
273
|
+
(path, name or os.path.basename(path), datetime.now().isoformat())
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def remove_external_drive(self, path):
|
|
277
|
+
with self._conn() as conn:
|
|
278
|
+
conn.execute('DELETE FROM external_drives WHERE path = ?', (path,))
|
|
279
|
+
|
|
280
|
+
def update_drive_name(self, path, name):
|
|
281
|
+
with self._conn() as conn:
|
|
282
|
+
conn.execute('UPDATE external_drives SET name = ? WHERE path = ?', (name, path))
|
|
283
|
+
|
|
284
|
+
def index_anime(self, title, source_path, source_name, folder_path, episode_count):
|
|
285
|
+
with self._conn() as conn:
|
|
286
|
+
conn.execute('DELETE FROM anime_index WHERE folder_path = ?', (folder_path,))
|
|
287
|
+
conn.execute('''
|
|
288
|
+
INSERT INTO anime_index (title, source_path, source_name, folder_path, episode_count, indexed_at)
|
|
289
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
290
|
+
''', (title, source_path, source_name, folder_path, episode_count, datetime.now().isoformat()))
|
|
291
|
+
|
|
292
|
+
def clear_source_index(self, source_path):
|
|
293
|
+
with self._conn() as conn:
|
|
294
|
+
conn.execute('DELETE FROM anime_index WHERE source_path = ?', (source_path,))
|
|
295
|
+
|
|
296
|
+
def get_all_indexed_anime(self):
|
|
297
|
+
with self._conn() as conn:
|
|
298
|
+
rows = conn.execute('SELECT * FROM anime_index ORDER BY title').fetchall()
|
|
299
|
+
return [dict(row) for row in rows]
|
|
300
|
+
|
|
301
|
+
def search_indexed_anime(self, query):
|
|
302
|
+
with self._conn() as conn:
|
|
303
|
+
rows = conn.execute(
|
|
304
|
+
'SELECT * FROM anime_index WHERE title LIKE ? ORDER BY title',
|
|
305
|
+
(f'%{query}%',)
|
|
306
|
+
).fetchall()
|
|
307
|
+
return [dict(row) for row in rows]
|
|
308
|
+
|
|
309
|
+
def remove_indexed_anime(self, folder_path):
|
|
310
|
+
with self._conn() as conn:
|
|
311
|
+
conn.execute('DELETE FROM anime_index WHERE folder_path = ?', (folder_path,))
|
|
312
|
+
|
|
313
|
+
db = Database()
|