ani-cli-ar 1.8.4__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.
- ani_cli_ar-1.8.4.dist-info/METADATA +348 -0
- ani_cli_ar-1.8.4.dist-info/RECORD +27 -0
- ani_cli_ar-1.8.4.dist-info/WHEEL +5 -0
- ani_cli_ar-1.8.4.dist-info/entry_points.txt +3 -0
- ani_cli_ar-1.8.4.dist-info/licenses/LICENSE +674 -0
- ani_cli_ar-1.8.4.dist-info/top_level.txt +1 -0
- ani_cli_arabic/__init__.py +3 -0
- ani_cli_arabic/__main__.py +8 -0
- ani_cli_arabic/api.py +309 -0
- ani_cli_arabic/app.py +1110 -0
- ani_cli_arabic/cli.py +598 -0
- ani_cli_arabic/config.py +88 -0
- ani_cli_arabic/deps.py +453 -0
- ani_cli_arabic/discord_rpc.py +361 -0
- ani_cli_arabic/favorites.py +72 -0
- ani_cli_arabic/history.py +72 -0
- ani_cli_arabic/models.py +36 -0
- ani_cli_arabic/monitoring.py +92 -0
- ani_cli_arabic/player.py +281 -0
- ani_cli_arabic/scrapers/__init__.py +0 -0
- ani_cli_arabic/scrapers/english.py +316 -0
- ani_cli_arabic/settings.py +54 -0
- ani_cli_arabic/storage.py +28 -0
- ani_cli_arabic/ui.py +1421 -0
- ani_cli_arabic/updater.py +280 -0
- ani_cli_arabic/utils.py +619 -0
- ani_cli_arabic/version.py +10 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import threading
|
|
3
|
+
try:
|
|
4
|
+
from pypresence import Presence, PipeClosed
|
|
5
|
+
try:
|
|
6
|
+
from pypresence import ActivityType
|
|
7
|
+
except ImportError:
|
|
8
|
+
class ActivityType:
|
|
9
|
+
PLAYING = 0
|
|
10
|
+
STREAMING = 1
|
|
11
|
+
LISTENING = 2
|
|
12
|
+
WATCHING = 3
|
|
13
|
+
CUSTOM = 4
|
|
14
|
+
COMPETING = 5
|
|
15
|
+
DISCORD_RPC_AVAILABLE = True
|
|
16
|
+
except ImportError:
|
|
17
|
+
DISCORD_RPC_AVAILABLE = False
|
|
18
|
+
|
|
19
|
+
from .config import DISCORD_CLIENT_ID, DISCORD_LOGO_URL, DISCORD_LOGO_TEXT
|
|
20
|
+
|
|
21
|
+
class DiscordRPCManager:
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self.rpc = None
|
|
24
|
+
self.connected = False
|
|
25
|
+
self.current_state = "Browsing"
|
|
26
|
+
self.current_anime = None
|
|
27
|
+
self.current_episode = None
|
|
28
|
+
self.start_time = int(time.time())
|
|
29
|
+
self.update_thread = None
|
|
30
|
+
self.running = False
|
|
31
|
+
self._anime_poster_url = None
|
|
32
|
+
|
|
33
|
+
def connect(self):
|
|
34
|
+
if not DISCORD_RPC_AVAILABLE:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
self.rpc = Presence(DISCORD_CLIENT_ID)
|
|
39
|
+
self.rpc.connect()
|
|
40
|
+
self.connected = True
|
|
41
|
+
self.running = True
|
|
42
|
+
|
|
43
|
+
self.update_browsing()
|
|
44
|
+
|
|
45
|
+
self.update_thread = threading.Thread(target=self._auto_update, daemon=True)
|
|
46
|
+
self.update_thread.start()
|
|
47
|
+
|
|
48
|
+
return True
|
|
49
|
+
except Exception:
|
|
50
|
+
self.connected = False
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
def _auto_update(self):
|
|
54
|
+
while self.running:
|
|
55
|
+
time.sleep(15)
|
|
56
|
+
if self.connected:
|
|
57
|
+
try:
|
|
58
|
+
self._update_presence()
|
|
59
|
+
except PipeClosed:
|
|
60
|
+
try:
|
|
61
|
+
self.rpc.connect()
|
|
62
|
+
self._update_presence()
|
|
63
|
+
except Exception:
|
|
64
|
+
self.connected = False
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
def _update_presence(self):
|
|
69
|
+
if not self.connected or not self.rpc:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
buttons = [{"label": "View on GitHub", "url": "https://np4abdou1.github.io/ani-cli-arabic"}]
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
if self.current_state == "Watching":
|
|
76
|
+
self.rpc.update(
|
|
77
|
+
activity_type=ActivityType.WATCHING,
|
|
78
|
+
details=f"Episode {self.current_episode}",
|
|
79
|
+
state=self.current_anime,
|
|
80
|
+
start=self.start_time,
|
|
81
|
+
large_image=self._get_anime_poster(),
|
|
82
|
+
large_text=self.current_anime,
|
|
83
|
+
small_image=DISCORD_LOGO_URL,
|
|
84
|
+
small_text=DISCORD_LOGO_TEXT,
|
|
85
|
+
buttons=buttons
|
|
86
|
+
)
|
|
87
|
+
elif self.current_state == "Choosing Quality":
|
|
88
|
+
self.rpc.update(
|
|
89
|
+
details=self.current_anime,
|
|
90
|
+
state=f"⚙️ Choosing quality for Episode {self.current_episode}",
|
|
91
|
+
start=self.start_time,
|
|
92
|
+
large_image=self._get_anime_poster(),
|
|
93
|
+
large_text=self.current_anime,
|
|
94
|
+
small_image=DISCORD_LOGO_URL,
|
|
95
|
+
small_text=DISCORD_LOGO_TEXT,
|
|
96
|
+
buttons=buttons
|
|
97
|
+
)
|
|
98
|
+
elif self.current_state == "Loading":
|
|
99
|
+
self.rpc.update(
|
|
100
|
+
details=self.current_anime,
|
|
101
|
+
state=f"⏳ Loading Episode {self.current_episode}",
|
|
102
|
+
start=self.start_time,
|
|
103
|
+
large_image=self._get_anime_poster(),
|
|
104
|
+
large_text=self.current_anime,
|
|
105
|
+
small_image=DISCORD_LOGO_URL,
|
|
106
|
+
small_text=DISCORD_LOGO_TEXT,
|
|
107
|
+
buttons=buttons
|
|
108
|
+
)
|
|
109
|
+
elif self.current_state == "Viewing":
|
|
110
|
+
self.rpc.update(
|
|
111
|
+
details=self.current_anime,
|
|
112
|
+
state="📖 Viewing details",
|
|
113
|
+
start=self.start_time,
|
|
114
|
+
large_image=self._get_anime_poster(),
|
|
115
|
+
large_text=self.current_anime,
|
|
116
|
+
small_image=DISCORD_LOGO_URL,
|
|
117
|
+
small_text=DISCORD_LOGO_TEXT,
|
|
118
|
+
buttons=buttons
|
|
119
|
+
)
|
|
120
|
+
elif self.current_state == "Selecting Episode":
|
|
121
|
+
self.rpc.update(
|
|
122
|
+
details=self.current_anime,
|
|
123
|
+
state="📺 Browsing episodes",
|
|
124
|
+
start=self.start_time,
|
|
125
|
+
large_image=self._get_anime_poster(),
|
|
126
|
+
large_text=self.current_anime,
|
|
127
|
+
small_image=DISCORD_LOGO_URL,
|
|
128
|
+
small_text=DISCORD_LOGO_TEXT,
|
|
129
|
+
buttons=buttons
|
|
130
|
+
)
|
|
131
|
+
elif self.current_state == "Searching":
|
|
132
|
+
self.rpc.update(
|
|
133
|
+
details="ani-cli-ar",
|
|
134
|
+
state="🔍 Searching for anime",
|
|
135
|
+
start=self.start_time,
|
|
136
|
+
large_image=DISCORD_LOGO_URL,
|
|
137
|
+
large_text=DISCORD_LOGO_TEXT,
|
|
138
|
+
buttons=buttons
|
|
139
|
+
)
|
|
140
|
+
elif self.current_state == "Trending":
|
|
141
|
+
self.rpc.update(
|
|
142
|
+
details="ani-cli-ar",
|
|
143
|
+
state="🔥 Browsing trending anime",
|
|
144
|
+
start=self.start_time,
|
|
145
|
+
large_image=DISCORD_LOGO_URL,
|
|
146
|
+
large_text=DISCORD_LOGO_TEXT,
|
|
147
|
+
buttons=buttons
|
|
148
|
+
)
|
|
149
|
+
elif self.current_state == "Popular":
|
|
150
|
+
self.rpc.update(
|
|
151
|
+
details="ani-cli-ar",
|
|
152
|
+
state="🏆 Browsing popular anime",
|
|
153
|
+
start=self.start_time,
|
|
154
|
+
large_image=DISCORD_LOGO_URL,
|
|
155
|
+
large_text=DISCORD_LOGO_TEXT,
|
|
156
|
+
buttons=buttons
|
|
157
|
+
)
|
|
158
|
+
elif self.current_state == "Genres":
|
|
159
|
+
self.rpc.update(
|
|
160
|
+
details="ani-cli-ar",
|
|
161
|
+
state="🎭 Browsing genres",
|
|
162
|
+
start=self.start_time,
|
|
163
|
+
large_image=DISCORD_LOGO_URL,
|
|
164
|
+
large_text=DISCORD_LOGO_TEXT,
|
|
165
|
+
buttons=buttons
|
|
166
|
+
)
|
|
167
|
+
elif self.current_state == "Studios":
|
|
168
|
+
self.rpc.update(
|
|
169
|
+
details="ani-cli-ar",
|
|
170
|
+
state="🏢 Browsing studios",
|
|
171
|
+
start=self.start_time,
|
|
172
|
+
large_image=DISCORD_LOGO_URL,
|
|
173
|
+
large_text=DISCORD_LOGO_TEXT,
|
|
174
|
+
buttons=buttons
|
|
175
|
+
)
|
|
176
|
+
elif self.current_state == "Featured":
|
|
177
|
+
self.rpc.update(
|
|
178
|
+
details="ani-cli-ar",
|
|
179
|
+
state="⭐ Browsing featured anime",
|
|
180
|
+
start=self.start_time,
|
|
181
|
+
large_image=DISCORD_LOGO_URL,
|
|
182
|
+
large_text=DISCORD_LOGO_TEXT,
|
|
183
|
+
buttons=buttons
|
|
184
|
+
)
|
|
185
|
+
elif self.current_state == "History":
|
|
186
|
+
self.rpc.update(
|
|
187
|
+
details="ani-cli-ar",
|
|
188
|
+
state="📜 Viewing watch history",
|
|
189
|
+
start=self.start_time,
|
|
190
|
+
large_image=DISCORD_LOGO_URL,
|
|
191
|
+
large_text=DISCORD_LOGO_TEXT,
|
|
192
|
+
buttons=buttons
|
|
193
|
+
)
|
|
194
|
+
elif self.current_state == "Favorites":
|
|
195
|
+
self.rpc.update(
|
|
196
|
+
details="ani-cli-ar",
|
|
197
|
+
state="❤️ Browsing favorites",
|
|
198
|
+
start=self.start_time,
|
|
199
|
+
large_image=DISCORD_LOGO_URL,
|
|
200
|
+
large_text=DISCORD_LOGO_TEXT,
|
|
201
|
+
buttons=buttons
|
|
202
|
+
)
|
|
203
|
+
elif self.current_state == "Settings":
|
|
204
|
+
self.rpc.update(
|
|
205
|
+
details="ani-cli-ar",
|
|
206
|
+
state="⚙️ Configuring settings",
|
|
207
|
+
start=self.start_time,
|
|
208
|
+
large_image=DISCORD_LOGO_URL,
|
|
209
|
+
large_text=DISCORD_LOGO_TEXT,
|
|
210
|
+
buttons=buttons
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
self.rpc.update(
|
|
214
|
+
details="ani-cli-ar",
|
|
215
|
+
state="🏠 Browsing",
|
|
216
|
+
start=self.start_time,
|
|
217
|
+
large_image=DISCORD_LOGO_URL,
|
|
218
|
+
large_text=DISCORD_LOGO_TEXT,
|
|
219
|
+
buttons=buttons
|
|
220
|
+
)
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
def _get_anime_poster(self) -> str:
|
|
225
|
+
if hasattr(self, '_anime_poster_url') and self._anime_poster_url:
|
|
226
|
+
return self._anime_poster_url
|
|
227
|
+
return DISCORD_LOGO_URL
|
|
228
|
+
|
|
229
|
+
def update_browsing(self):
|
|
230
|
+
self.current_state = "Browsing"
|
|
231
|
+
self.current_anime = None
|
|
232
|
+
self.current_episode = None
|
|
233
|
+
self.start_time = int(time.time())
|
|
234
|
+
self._anime_poster_url = None
|
|
235
|
+
self._update_presence()
|
|
236
|
+
|
|
237
|
+
def update_searching(self):
|
|
238
|
+
self.current_state = "Searching"
|
|
239
|
+
self.current_anime = None
|
|
240
|
+
self.current_episode = None
|
|
241
|
+
self.start_time = int(time.time())
|
|
242
|
+
self._anime_poster_url = None
|
|
243
|
+
self._update_presence()
|
|
244
|
+
|
|
245
|
+
def update_viewing_anime(self, anime_title: str, poster_url: str = None):
|
|
246
|
+
self.current_state = "Viewing"
|
|
247
|
+
self.current_anime = anime_title
|
|
248
|
+
self.current_episode = None
|
|
249
|
+
self.start_time = int(time.time())
|
|
250
|
+
self._anime_poster_url = poster_url
|
|
251
|
+
self._update_presence()
|
|
252
|
+
|
|
253
|
+
def update_selecting_episode(self, anime_title: str, poster_url: str = None):
|
|
254
|
+
self.current_state = "Selecting Episode"
|
|
255
|
+
self.current_anime = anime_title
|
|
256
|
+
self.current_episode = None
|
|
257
|
+
self.start_time = int(time.time())
|
|
258
|
+
self._anime_poster_url = poster_url
|
|
259
|
+
self._update_presence()
|
|
260
|
+
|
|
261
|
+
def update_watching(self, anime_title: str, episode_num: str, poster_url: str = None):
|
|
262
|
+
self.current_state = "Watching"
|
|
263
|
+
self.current_anime = anime_title
|
|
264
|
+
self.current_episode = episode_num
|
|
265
|
+
self.start_time = int(time.time())
|
|
266
|
+
self._anime_poster_url = poster_url
|
|
267
|
+
self._update_presence()
|
|
268
|
+
|
|
269
|
+
def update_choosing_quality(self, anime_title: str, episode_num: str, poster_url: str = None):
|
|
270
|
+
self.current_state = "Choosing Quality"
|
|
271
|
+
self.current_anime = anime_title
|
|
272
|
+
self.current_episode = episode_num
|
|
273
|
+
self.start_time = int(time.time())
|
|
274
|
+
self._anime_poster_url = poster_url
|
|
275
|
+
self._update_presence()
|
|
276
|
+
|
|
277
|
+
def update_loading(self, anime_title: str, episode_num: str, poster_url: str = None):
|
|
278
|
+
self.current_state = "Loading"
|
|
279
|
+
self.current_anime = anime_title
|
|
280
|
+
self.current_episode = episode_num
|
|
281
|
+
self.start_time = int(time.time())
|
|
282
|
+
self._anime_poster_url = poster_url
|
|
283
|
+
self._update_presence()
|
|
284
|
+
|
|
285
|
+
def update_trending(self):
|
|
286
|
+
self.current_state = "Trending"
|
|
287
|
+
self.current_anime = None
|
|
288
|
+
self.current_episode = None
|
|
289
|
+
self.start_time = int(time.time())
|
|
290
|
+
self._anime_poster_url = None
|
|
291
|
+
self._update_presence()
|
|
292
|
+
|
|
293
|
+
def update_popular(self):
|
|
294
|
+
self.current_state = "Popular"
|
|
295
|
+
self.current_anime = None
|
|
296
|
+
self.current_episode = None
|
|
297
|
+
self.start_time = int(time.time())
|
|
298
|
+
self._anime_poster_url = None
|
|
299
|
+
self._update_presence()
|
|
300
|
+
|
|
301
|
+
def update_genres(self):
|
|
302
|
+
self.current_state = "Genres"
|
|
303
|
+
self.current_anime = None
|
|
304
|
+
self.current_episode = None
|
|
305
|
+
self.start_time = int(time.time())
|
|
306
|
+
self._anime_poster_url = None
|
|
307
|
+
self._update_presence()
|
|
308
|
+
|
|
309
|
+
def update_studios(self):
|
|
310
|
+
self.current_state = "Studios"
|
|
311
|
+
self.current_anime = None
|
|
312
|
+
self.current_episode = None
|
|
313
|
+
self.start_time = int(time.time())
|
|
314
|
+
self._anime_poster_url = None
|
|
315
|
+
self._update_presence()
|
|
316
|
+
|
|
317
|
+
def update_featured(self):
|
|
318
|
+
self.current_state = "Featured"
|
|
319
|
+
self.current_anime = None
|
|
320
|
+
self.current_episode = None
|
|
321
|
+
self.start_time = int(time.time())
|
|
322
|
+
self._anime_poster_url = None
|
|
323
|
+
self._update_presence()
|
|
324
|
+
|
|
325
|
+
def update_history(self):
|
|
326
|
+
self.current_state = "History"
|
|
327
|
+
self.current_anime = None
|
|
328
|
+
self.current_episode = None
|
|
329
|
+
self.start_time = int(time.time())
|
|
330
|
+
self._anime_poster_url = None
|
|
331
|
+
self._update_presence()
|
|
332
|
+
|
|
333
|
+
def update_favorites(self):
|
|
334
|
+
self.current_state = "Favorites"
|
|
335
|
+
self.current_anime = None
|
|
336
|
+
self.current_episode = None
|
|
337
|
+
self.start_time = int(time.time())
|
|
338
|
+
self._anime_poster_url = None
|
|
339
|
+
self._update_presence()
|
|
340
|
+
|
|
341
|
+
def update_settings(self):
|
|
342
|
+
self.current_state = "Settings"
|
|
343
|
+
self.current_anime = None
|
|
344
|
+
self.current_episode = None
|
|
345
|
+
self.start_time = int(time.time())
|
|
346
|
+
self._anime_poster_url = None
|
|
347
|
+
self._update_presence()
|
|
348
|
+
|
|
349
|
+
def disconnect(self):
|
|
350
|
+
self.running = False
|
|
351
|
+
if self.connected and self.rpc:
|
|
352
|
+
try:
|
|
353
|
+
self.rpc.clear()
|
|
354
|
+
except Exception:
|
|
355
|
+
pass
|
|
356
|
+
try:
|
|
357
|
+
self.rpc.close()
|
|
358
|
+
except Exception:
|
|
359
|
+
pass
|
|
360
|
+
finally:
|
|
361
|
+
self.connected = False
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from .storage import atomic_write_json
|
|
5
|
+
|
|
6
|
+
class FavoritesManager:
|
|
7
|
+
# Maximum favorites to prevent database bloat and ensure performance
|
|
8
|
+
MAX_FAVORITES = 100
|
|
9
|
+
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.file_path = self._get_path()
|
|
12
|
+
self.favorites = self._load()
|
|
13
|
+
|
|
14
|
+
def _get_path(self) -> Path:
|
|
15
|
+
home_dir = Path.home()
|
|
16
|
+
db_dir = home_dir / ".ani-cli-arabic" / "database"
|
|
17
|
+
db_dir.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
return db_dir / "favorites.json"
|
|
19
|
+
|
|
20
|
+
def _load(self) -> dict:
|
|
21
|
+
if not self.file_path.exists():
|
|
22
|
+
return {}
|
|
23
|
+
try:
|
|
24
|
+
with open(self.file_path, 'r', encoding='utf-8') as f:
|
|
25
|
+
data = json.load(f)
|
|
26
|
+
if not isinstance(data, dict):
|
|
27
|
+
return {}
|
|
28
|
+
return data
|
|
29
|
+
except (json.JSONDecodeError, IOError, OSError):
|
|
30
|
+
return {}
|
|
31
|
+
|
|
32
|
+
def save(self):
|
|
33
|
+
try:
|
|
34
|
+
atomic_write_json(self.file_path, self.favorites, indent=4, ensure_ascii=False)
|
|
35
|
+
except (IOError, OSError, ValueError, TypeError) as e:
|
|
36
|
+
import sys
|
|
37
|
+
print(f"Warning: Failed to save favorites: {e}", file=sys.stderr)
|
|
38
|
+
|
|
39
|
+
def add(self, anime_id, title, thumbnail):
|
|
40
|
+
anime_id_str = str(anime_id)
|
|
41
|
+
if anime_id_str in self.favorites:
|
|
42
|
+
# Update existing favorite
|
|
43
|
+
self.favorites[anime_id_str]['added_at'] = datetime.now().isoformat()
|
|
44
|
+
self.save()
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
if len(self.favorites) >= self.MAX_FAVORITES:
|
|
48
|
+
oldest = min(self.favorites.items(), key=lambda x: x[1]['added_at'])
|
|
49
|
+
del self.favorites[oldest[0]]
|
|
50
|
+
|
|
51
|
+
self.favorites[str(anime_id)] = {
|
|
52
|
+
'title': title,
|
|
53
|
+
'thumbnail': thumbnail,
|
|
54
|
+
'added_at': datetime.now().isoformat()
|
|
55
|
+
}
|
|
56
|
+
self.save()
|
|
57
|
+
|
|
58
|
+
def remove(self, anime_id):
|
|
59
|
+
if str(anime_id) in self.favorites:
|
|
60
|
+
del self.favorites[str(anime_id)]
|
|
61
|
+
self.save()
|
|
62
|
+
|
|
63
|
+
def is_favorite(self, anime_id):
|
|
64
|
+
return str(anime_id) in self.favorites
|
|
65
|
+
|
|
66
|
+
def get_all(self):
|
|
67
|
+
# Return list sorted by added date (newest first)
|
|
68
|
+
return sorted(
|
|
69
|
+
[{'anime_id': k, 'id': k, **v} for k, v in self.favorites.items()],
|
|
70
|
+
key=lambda x: x['added_at'],
|
|
71
|
+
reverse=True
|
|
72
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from .storage import atomic_write_json
|
|
5
|
+
|
|
6
|
+
class HistoryManager:
|
|
7
|
+
# Maximum history entries to maintain reasonable file size and load times
|
|
8
|
+
MAX_HISTORY_SIZE = 100
|
|
9
|
+
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.history_file = self._get_history_path()
|
|
12
|
+
self.history = self._load_history()
|
|
13
|
+
|
|
14
|
+
def _get_history_path(self) -> Path:
|
|
15
|
+
home_dir = Path.home()
|
|
16
|
+
db_dir = home_dir / ".ani-cli-arabic" / "database"
|
|
17
|
+
db_dir.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
return db_dir / "history.json"
|
|
19
|
+
|
|
20
|
+
def _load_history(self) -> dict:
|
|
21
|
+
if not self.history_file.exists():
|
|
22
|
+
return {}
|
|
23
|
+
try:
|
|
24
|
+
with open(self.history_file, 'r', encoding='utf-8') as f:
|
|
25
|
+
data = json.load(f)
|
|
26
|
+
if not isinstance(data, dict):
|
|
27
|
+
return {}
|
|
28
|
+
return data
|
|
29
|
+
except (json.JSONDecodeError, IOError, OSError):
|
|
30
|
+
return {}
|
|
31
|
+
|
|
32
|
+
def save_history(self):
|
|
33
|
+
try:
|
|
34
|
+
if len(self.history) > self.MAX_HISTORY_SIZE:
|
|
35
|
+
sorted_items = sorted(
|
|
36
|
+
self.history.items(),
|
|
37
|
+
key=lambda x: x[1].get('last_updated', ''),
|
|
38
|
+
reverse=True
|
|
39
|
+
)
|
|
40
|
+
self.history = dict(sorted_items[:self.MAX_HISTORY_SIZE])
|
|
41
|
+
|
|
42
|
+
atomic_write_json(self.history_file, self.history, indent=4, ensure_ascii=False)
|
|
43
|
+
except (IOError, OSError, ValueError, TypeError) as e:
|
|
44
|
+
import sys
|
|
45
|
+
print(f"Warning: Failed to save history: {e}", file=sys.stderr)
|
|
46
|
+
|
|
47
|
+
def mark_watched(self, anime_id, episode_num, anime_title):
|
|
48
|
+
self.history[str(anime_id)] = {
|
|
49
|
+
'episode': str(episode_num),
|
|
50
|
+
'title': anime_title,
|
|
51
|
+
'last_updated': datetime.now().isoformat()
|
|
52
|
+
}
|
|
53
|
+
self.save_history()
|
|
54
|
+
|
|
55
|
+
def get_last_watched(self, anime_id):
|
|
56
|
+
data = self.history.get(str(anime_id))
|
|
57
|
+
if data:
|
|
58
|
+
return data.get('episode')
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def get_history(self):
|
|
62
|
+
items = []
|
|
63
|
+
for anime_id, data in self.history.items():
|
|
64
|
+
items.append({
|
|
65
|
+
'anime_id': anime_id,
|
|
66
|
+
'title': data.get('title', 'Unknown'),
|
|
67
|
+
'episode': data.get('episode', '?'),
|
|
68
|
+
'last_updated': data.get('last_updated', '')
|
|
69
|
+
})
|
|
70
|
+
# Sort by last_updated, most recent first
|
|
71
|
+
items.sort(key=lambda x: x['last_updated'], reverse=True)
|
|
72
|
+
return items
|
ani_cli_arabic/models.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
@dataclass
|
|
4
|
+
class AnimeResult:
|
|
5
|
+
id: str
|
|
6
|
+
title_en: str
|
|
7
|
+
title_jp: str
|
|
8
|
+
type: str
|
|
9
|
+
episodes: str
|
|
10
|
+
status: str
|
|
11
|
+
genres: str
|
|
12
|
+
mal_id: str
|
|
13
|
+
relation_id: str
|
|
14
|
+
score: str
|
|
15
|
+
rank: str
|
|
16
|
+
popularity: str
|
|
17
|
+
rating: str
|
|
18
|
+
premiered: str
|
|
19
|
+
creators: str
|
|
20
|
+
duration: str
|
|
21
|
+
thumbnail: str
|
|
22
|
+
title_romaji: str = ""
|
|
23
|
+
trailer: str = ""
|
|
24
|
+
yt_trailer: str = ""
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Episode:
|
|
28
|
+
number: str
|
|
29
|
+
type: str
|
|
30
|
+
display_num: int
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class QualityOption:
|
|
34
|
+
name: str
|
|
35
|
+
server_key: str
|
|
36
|
+
style: str
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import platform
|
|
2
|
+
import hashlib
|
|
3
|
+
import threading
|
|
4
|
+
import requests
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from .api import _get_endpoint_config
|
|
7
|
+
from .config import CURRENT_VERSION
|
|
8
|
+
|
|
9
|
+
class MonitoringSystem:
|
|
10
|
+
_instance = None
|
|
11
|
+
|
|
12
|
+
def __new__(cls):
|
|
13
|
+
if cls._instance is None:
|
|
14
|
+
cls._instance = super(MonitoringSystem, cls).__new__(cls)
|
|
15
|
+
cls._instance._initialized = False
|
|
16
|
+
return cls._instance
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
if hasattr(self, '_initialized') and self._initialized:
|
|
20
|
+
return
|
|
21
|
+
self._initialized = True
|
|
22
|
+
self.user_fingerprint = self._generate_fingerprint()
|
|
23
|
+
|
|
24
|
+
def _generate_fingerprint(self) -> str:
|
|
25
|
+
try:
|
|
26
|
+
components = [
|
|
27
|
+
platform.node(),
|
|
28
|
+
platform.machine(),
|
|
29
|
+
platform.system(),
|
|
30
|
+
platform.release(),
|
|
31
|
+
platform.processor()
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
raw_str = "|".join(str(c) for c in components)
|
|
35
|
+
return hashlib.sha256(raw_str.encode()).hexdigest()[:16]
|
|
36
|
+
except Exception:
|
|
37
|
+
return "unknown_user"
|
|
38
|
+
|
|
39
|
+
def _send_data(self, action: str, details: dict):
|
|
40
|
+
"""Send analytics data only if user has opted in."""
|
|
41
|
+
try:
|
|
42
|
+
from .settings import SettingsManager
|
|
43
|
+
settings = SettingsManager()
|
|
44
|
+
if not settings.get('analytics'):
|
|
45
|
+
return
|
|
46
|
+
except Exception:
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
def worker():
|
|
50
|
+
try:
|
|
51
|
+
endpoint_url, auth_secret = _get_endpoint_config()
|
|
52
|
+
|
|
53
|
+
payload = {
|
|
54
|
+
"fingerprint": self.user_fingerprint,
|
|
55
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
56
|
+
"action": action,
|
|
57
|
+
"details": details
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
headers = {
|
|
61
|
+
'Content-Type': 'application/json',
|
|
62
|
+
'X-Auth-Key': auth_secret,
|
|
63
|
+
'User-Agent': 'AniCliAr-Monitor/1.0'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
requests.post(
|
|
67
|
+
f"{endpoint_url}/monitor",
|
|
68
|
+
json=payload,
|
|
69
|
+
headers=headers,
|
|
70
|
+
timeout=3
|
|
71
|
+
)
|
|
72
|
+
except (requests.RequestException, ValueError, KeyError):
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
thread = threading.Thread(target=worker, daemon=True)
|
|
76
|
+
thread.start()
|
|
77
|
+
|
|
78
|
+
def track_app_start(self):
|
|
79
|
+
self._send_data("app_start", {
|
|
80
|
+
"version": CURRENT_VERSION,
|
|
81
|
+
"os": platform.system()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
def track_video_play(self, anime_title: str, episode: str, mode: str = "stream"):
|
|
85
|
+
self._send_data("video_play", {
|
|
86
|
+
"anime": anime_title,
|
|
87
|
+
"episode": episode,
|
|
88
|
+
"mode": mode
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
# Global instance
|
|
92
|
+
monitor = MonitoringSystem()
|