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