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.
@@ -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()