ani-cli-ar 1.9.2__tar.gz → 1.9.3__tar.gz

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.
Files changed (42) hide show
  1. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/PKG-INFO +1 -1
  2. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_ar.egg-info/PKG-INFO +1 -1
  3. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_ar.egg-info/SOURCES.txt +1 -0
  4. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/api.py +29 -4
  5. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/app.py +94 -17
  6. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/cli.py +5 -0
  7. ani_cli_ar-1.9.3/ani_cli_arabic/monitoring.py +355 -0
  8. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/player.py +2 -0
  9. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/scrapers/provider_manager.py +46 -0
  10. ani_cli_ar-1.9.3/ani_cli_arabic/stats.py +114 -0
  11. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/version.py +1 -1
  12. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/watch_together.py +70 -2
  13. ani_cli_ar-1.9.2/ani_cli_arabic/monitoring.py +0 -92
  14. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/LICENSE +0 -0
  15. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/README.md +0 -0
  16. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_ar.egg-info/dependency_links.txt +0 -0
  17. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_ar.egg-info/entry_points.txt +0 -0
  18. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_ar.egg-info/requires.txt +0 -0
  19. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_ar.egg-info/top_level.txt +0 -0
  20. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/__init__.py +0 -0
  21. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/__main__.py +0 -0
  22. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/config.py +0 -0
  23. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/deps.py +0 -0
  24. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/discord_rpc.py +0 -0
  25. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/favorites.py +0 -0
  26. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/history.py +0 -0
  27. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/models.py +0 -0
  28. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/scrapers/__init__.py +0 -0
  29. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/scrapers/anikoto.py +0 -0
  30. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/scrapers/animepahe.py +0 -0
  31. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/scrapers/api_provider.py +0 -0
  32. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/scrapers/base.py +0 -0
  33. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/scrapers/gogoanime.py +0 -0
  34. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/scrapers/miruro.py +0 -0
  35. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/scrapers/mkissa.py +0 -0
  36. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/settings.py +0 -0
  37. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/storage.py +0 -0
  38. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/ui.py +0 -0
  39. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/updater.py +0 -0
  40. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/utils.py +0 -0
  41. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/pyproject.toml +0 -0
  42. {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ani-cli-ar
3
- Version: 1.9.2
3
+ Version: 1.9.3
4
4
  Summary: Terminal-based anime streaming with Arabic subtitles
5
5
  Author: NoBy
6
6
  Maintainer: NoBy
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ani-cli-ar
3
- Version: 1.9.2
3
+ Version: 1.9.3
4
4
  Summary: Terminal-based anime streaming with Arabic subtitles
5
5
  Author: NoBy
6
6
  Maintainer: NoBy
@@ -21,6 +21,7 @@ ani_cli_arabic/models.py
21
21
  ani_cli_arabic/monitoring.py
22
22
  ani_cli_arabic/player.py
23
23
  ani_cli_arabic/settings.py
24
+ ani_cli_arabic/stats.py
24
25
  ani_cli_arabic/storage.py
25
26
  ani_cli_arabic/ui.py
26
27
  ani_cli_arabic/updater.py
@@ -284,7 +284,25 @@ class AnimeAPI:
284
284
  except (requests.RequestException, ValueError, TypeError):
285
285
  return []
286
286
 
287
- def get_streaming_servers(self, anime_id: str, episode_num: str, anime_type: str = 'SERIES') -> Optional[Dict]:
287
+ def _report_api_error(self, error_msg: str, context: dict = None,
288
+ exception: BaseException = None, url: str = ""):
289
+ """Report an Arabic-API failure to telemetry without blocking the caller."""
290
+ try:
291
+ import sys as _sys
292
+ from .monitoring import monitor
293
+ details = dict(context or {})
294
+ details.setdefault("translation_mode", "arabic_sub")
295
+ if url:
296
+ details["server_url"] = url
297
+ if exception is not None:
298
+ monitor.track_error(error_msg, details, exception=exception)
299
+ else:
300
+ monitor.track_error(error_msg, details, exc_info=_sys.exc_info())
301
+ except Exception:
302
+ pass
303
+
304
+ def get_streaming_servers(self, anime_id: str, episode_num: str, anime_type: str = 'SERIES',
305
+ context: dict = None) -> Optional[Dict]:
288
306
  endpoint = get_api_base() + "anime/load_servers.php"
289
307
  payload = {
290
308
  'UserId': '0',
@@ -298,10 +316,12 @@ class AnimeAPI:
298
316
  response = requests.post(endpoint, data=payload, timeout=10)
299
317
  response.raise_for_status()
300
318
  return response.json()
301
- except (requests.RequestException, ValueError, TypeError):
319
+ except (requests.RequestException, ValueError, TypeError) as e:
320
+ self._report_api_error("Arabic server list request failed", context,
321
+ exception=e, url=endpoint)
302
322
  return None
303
323
 
304
- def extract_mediafire_direct(self, mf_url: str) -> Optional[str]:
324
+ def extract_mediafire_direct(self, mf_url: str, context: dict = None) -> Optional[str]:
305
325
  try:
306
326
  headers = {
307
327
  'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
@@ -309,8 +329,13 @@ class AnimeAPI:
309
329
  response = requests.get(mf_url, headers=headers, timeout=10)
310
330
  response.raise_for_status()
311
331
  match = re.search(r'(https://download[^"]+)', response.text)
332
+ if not match:
333
+ self._report_api_error("MediaFire page did not contain a download link",
334
+ context, url=mf_url)
312
335
  return match.group(1) if match else None
313
- except (requests.RequestException, AttributeError):
336
+ except (requests.RequestException, AttributeError) as e:
337
+ self._report_api_error("MediaFire direct link extraction failed", context,
338
+ exception=e, url=mf_url)
314
339
  return None
315
340
 
316
341
  def build_mediafire_url(self, server_id: str) -> str:
@@ -1,6 +1,7 @@
1
1
  import sys
2
2
  import atexit
3
3
  import re
4
+ import time
4
5
  from pathlib import Path
5
6
  from rich.align import Align
6
7
  from rich.panel import Panel
@@ -55,10 +56,17 @@ class AniCliArApp:
55
56
  parser.add_argument('--dub', action='store_true', help="Override: use English Dubbed streams")
56
57
  parser.add_argument('--provider', type=str, default=None,
57
58
  help="Preferred English stream provider (overrides auto-fallback)")
59
+ parser.add_argument('--stats', action='store_true',
60
+ help="Show streaming history summary from telemetry/local history and exit")
58
61
  parser.add_argument('query', nargs='*', help="Anime name to search for")
59
62
 
60
63
  args = parser.parse_args()
61
-
64
+
65
+ if args.stats:
66
+ from .stats import render_stats
67
+ render_stats()
68
+ sys.exit(0)
69
+
62
70
  if args.version:
63
71
  from .version import __version__
64
72
  print(f"ani-cli-arabic v{__version__}")
@@ -130,6 +138,7 @@ class AniCliArApp:
130
138
  threading.Thread(target=connect_rpc, daemon=True).start()
131
139
 
132
140
  threading.Thread(target=lambda: monitor.track_app_start(), daemon=True).start()
141
+ monitor.track_app_session()
133
142
 
134
143
  def check_updates_bg():
135
144
  try:
@@ -737,7 +746,9 @@ class AniCliArApp:
737
746
 
738
747
  self.ui.console.print(Align.center(panel, vertical="middle", height=self.ui.console.height))
739
748
 
749
+ monitor.set_activity("watching", anime.title_en, None)
740
750
  self.player.play(trailer_url, f"Trailer - {anime.title_en}")
751
+ monitor.set_activity("idle")
741
752
 
742
753
  def handle_episode_selection(self, selected_anime, episodes, initial_idx=0):
743
754
  current_idx = max(0, min(int(initial_idx or 0), len(episodes) - 1))
@@ -812,7 +823,10 @@ class AniCliArApp:
812
823
  self.api.get_streaming_servers,
813
824
  selected_anime.id,
814
825
  selected_ep.number,
815
- selected_anime.type
826
+ selected_anime.type,
827
+ {"anime": selected_anime.title_en,
828
+ "episode": str(selected_ep.display_num),
829
+ "provider": "arabic_api"}
816
830
  )
817
831
 
818
832
  if not server_data:
@@ -887,10 +901,20 @@ class AniCliArApp:
887
901
  pm = ProviderManager(preferred_provider=preferred if preferred else None)
888
902
 
889
903
  mode = "dub" if dub else "sub"
890
- url, headers, _ = asyncio.run(
891
- pm.resolve_stream(anime_title, episode_num, mode=mode, language="english", provider=provider)
892
- )
893
- return url, headers
904
+ try:
905
+ url, headers, used_provider = asyncio.run(
906
+ pm.resolve_stream(anime_title, episode_num, mode=mode, language="english", provider=provider)
907
+ )
908
+ except Exception:
909
+ exc_type, exc_val, exc_tb = sys.exc_info()
910
+ monitor.track_error(
911
+ "English stream resolution failed",
912
+ {"anime": anime_title, "episode": str(episode_num), "provider": provider or "auto",
913
+ "mode": mode, "translation_mode": mode},
914
+ exc_info=(exc_type, exc_val, exc_tb),
915
+ )
916
+ return "", {}, ""
917
+ return url, headers, used_provider or ""
894
918
 
895
919
  def _pick_default_download_quality_option(self, current_ep_data):
896
920
  qualities = [
@@ -912,16 +936,20 @@ class AniCliArApp:
912
936
  return None
913
937
 
914
938
  def resolve_default_download_target(self, selected_anime, selected_ep, show_loading=False):
939
+ _ctx = {"anime": selected_anime.title_en,
940
+ "episode": str(selected_ep.display_num),
941
+ "provider": "arabic_api"}
915
942
  if show_loading:
916
943
  server_data = self.ui.run_with_loading(
917
944
  "Loading servers...",
918
945
  self.api.get_streaming_servers,
919
946
  selected_anime.id,
920
947
  selected_ep.number,
921
- selected_anime.type
948
+ selected_anime.type,
949
+ _ctx
922
950
  )
923
951
  else:
924
- server_data = self.api.get_streaming_servers(selected_anime.id, selected_ep.number, selected_anime.type)
952
+ server_data = self.api.get_streaming_servers(selected_anime.id, selected_ep.number, selected_anime.type, _ctx)
925
953
 
926
954
  if not server_data:
927
955
  return None, None, "No servers found for this episode."
@@ -940,10 +968,11 @@ class AniCliArApp:
940
968
  direct_url = self.ui.run_with_loading(
941
969
  "Extracting direct link...",
942
970
  self.api.extract_mediafire_direct,
943
- mediafire_url
971
+ mediafire_url,
972
+ _ctx
944
973
  )
945
974
  else:
946
- direct_url = self.api.extract_mediafire_direct(mediafire_url)
975
+ direct_url = self.api.extract_mediafire_direct(mediafire_url, _ctx)
947
976
 
948
977
  if not direct_url:
949
978
  return None, None, "Failed to extract direct link from MediaFire."
@@ -1059,7 +1088,10 @@ class AniCliArApp:
1059
1088
  direct_url = self.ui.run_with_loading(
1060
1089
  "Extracting direct link...",
1061
1090
  self.api.extract_mediafire_direct,
1062
- self.api.build_mediafire_url(server_id)
1091
+ self.api.build_mediafire_url(server_id),
1092
+ {"anime": selected_anime.title_en,
1093
+ "episode": str(selected_ep.display_num),
1094
+ "provider": "arabic_api"}
1063
1095
  )
1064
1096
 
1065
1097
  if direct_url:
@@ -1110,8 +1142,6 @@ class AniCliArApp:
1110
1142
 
1111
1143
  self.rpc.update_watching(selected_anime.title_en, str(selected_ep.display_num), selected_anime.thumbnail)
1112
1144
 
1113
- monitor.track_video_play(selected_anime.title_en, str(selected_ep.display_num))
1114
-
1115
1145
  ipc_socket = None
1116
1146
  rc_port = None
1117
1147
  if self.watch_host is not None:
@@ -1121,7 +1151,20 @@ class AniCliArApp:
1121
1151
  ipc_socket = self.watch_host.socket_path
1122
1152
  self.watch_host.notify_load(selected_anime.title_en, selected_ep.display_num, "Arabic Sub")
1123
1153
 
1124
- self.player.play(direct_url, f"{selected_anime.title_en} - Ep {selected_ep.display_num} ({quality.name})", player_type=player_type, ipc_socket=ipc_socket, rc_port=rc_port)
1154
+ watch_start = time.time()
1155
+ monitor.set_activity("watching", selected_anime.title_en, str(selected_ep.display_num))
1156
+ selected_player = self.player.play(direct_url, f"{selected_anime.title_en} - Ep {selected_ep.display_num} ({quality.name})", player_type=player_type, ipc_socket=ipc_socket, rc_port=rc_port)
1157
+ watch_end = time.time()
1158
+ monitor.set_activity("idle")
1159
+ monitor.track_video_play(
1160
+ selected_anime.title_en,
1161
+ str(selected_ep.display_num),
1162
+ player=selected_player or player_type or "",
1163
+ provider="arabic_api",
1164
+ quality=quality_tag or "",
1165
+ watch_start=watch_start,
1166
+ watch_end=watch_end,
1167
+ )
1125
1168
  if self.watch_host is not None:
1126
1169
  self.watch_host.notify_stop()
1127
1170
  self.ui.clear()
@@ -1129,6 +1172,17 @@ class AniCliArApp:
1129
1172
  self.rpc.update_selecting_episode(selected_anime.title_en, selected_anime.thumbnail)
1130
1173
  return "watch"
1131
1174
  else:
1175
+ monitor.track_error(
1176
+ "Failed to extract direct link from MediaFire",
1177
+ {
1178
+ "anime": selected_anime.title_en,
1179
+ "episode": str(selected_ep.display_num),
1180
+ "language": "Arabic",
1181
+ "provider": "arabic_api",
1182
+ "translation_mode": "arabic_sub",
1183
+ "server_url": "www.mediafire.com",
1184
+ },
1185
+ )
1132
1186
  self.ui.render_message(
1133
1187
  "✗ Error",
1134
1188
  "Failed to extract direct link from MediaFire.",
@@ -1185,6 +1239,17 @@ class AniCliArApp:
1185
1239
  )
1186
1240
 
1187
1241
  if not (url_and_headers and url_and_headers[0]):
1242
+ monitor.track_error(
1243
+ "No working English stream found",
1244
+ {
1245
+ "anime": selected_anime.title_en,
1246
+ "episode": str(selected_ep.display_num),
1247
+ "provider": provider_choice or "auto",
1248
+ "quality": quality_name,
1249
+ "dub": dub,
1250
+ "translation_mode": "dub" if dub else "sub",
1251
+ },
1252
+ )
1188
1253
  self.ui.render_message(
1189
1254
  "Error",
1190
1255
  f"No working English stream found via {label}. Please try another provider.",
@@ -1192,7 +1257,7 @@ class AniCliArApp:
1192
1257
  )
1193
1258
  return None
1194
1259
 
1195
- direct_url, stream_headers = url_and_headers
1260
+ direct_url, stream_headers, provider_name = url_and_headers
1196
1261
  player_type = self.watch_host.player_kind if self.watch_host else self.settings.get('player')
1197
1262
  suffix = " [English Dub]" if dub else " [English Sub]"
1198
1263
 
@@ -1218,7 +1283,6 @@ class AniCliArApp:
1218
1283
  self.ui.console.print(Align.center(watching_panel, vertical="middle", height=self.ui.console.height))
1219
1284
 
1220
1285
  self.rpc.update_watching(selected_anime.title_en, str(selected_ep.display_num), selected_anime.thumbnail)
1221
- monitor.track_video_play(selected_anime.title_en, str(selected_ep.display_num))
1222
1286
 
1223
1287
  ipc_socket = None
1224
1288
  rc_port = None
@@ -1233,7 +1297,20 @@ class AniCliArApp:
1233
1297
  "English Dub" if dub else "English Sub",
1234
1298
  )
1235
1299
 
1236
- self.player.play(direct_url, f"{selected_anime.title_en} - Ep {selected_ep.display_num}", player_type=player_type, headers=stream_headers, ipc_socket=ipc_socket, rc_port=rc_port)
1300
+ watch_start = time.time()
1301
+ monitor.set_activity("watching", selected_anime.title_en, str(selected_ep.display_num))
1302
+ selected_player = self.player.play(direct_url, f"{selected_anime.title_en} - Ep {selected_ep.display_num}", player_type=player_type, headers=stream_headers, ipc_socket=ipc_socket, rc_port=rc_port)
1303
+ watch_end = time.time()
1304
+ monitor.set_activity("idle")
1305
+ monitor.track_video_play(
1306
+ selected_anime.title_en,
1307
+ str(selected_ep.display_num),
1308
+ player=selected_player or player_type or "",
1309
+ provider=provider_name or "",
1310
+ quality=quality_name or "",
1311
+ watch_start=watch_start,
1312
+ watch_end=watch_end,
1313
+ )
1237
1314
  if self.watch_host is not None:
1238
1315
  self.watch_host.notify_stop()
1239
1316
  self.ui.clear()
@@ -581,6 +581,11 @@ def run_simple_cli(query=None, deps=None):
581
581
 
582
582
  def main():
583
583
  """Entry point for running the CLI directly: python -m ani_cli_arabic.cli"""
584
+ if "--stats" in sys.argv:
585
+ from ani_cli_arabic.stats import render_stats
586
+ render_stats()
587
+ return
588
+
584
589
  from ani_cli_arabic.api import AnimeAPI
585
590
  from ani_cli_arabic.player import PlayerManager
586
591
  from ani_cli_arabic.history import HistoryManager
@@ -0,0 +1,355 @@
1
+ import platform
2
+ import getpass
3
+ import hashlib
4
+ import sys
5
+ import threading
6
+ import traceback as _traceback
7
+ from typing import Optional
8
+ from urllib.parse import urlsplit
9
+ import requests
10
+ import time
11
+ import atexit
12
+ from datetime import datetime, timezone
13
+ from .api import _get_analytics_endpoint_config
14
+ from .config import CURRENT_VERSION
15
+ from .version import __version__
16
+
17
+ _MAX_TRACEBACK_LINES = 6
18
+ _HEARTBEAT_INTERVAL = 30
19
+
20
+ class MonitoringSystem:
21
+ _instance = None
22
+
23
+ def __new__(cls):
24
+ if cls._instance is None:
25
+ cls._instance = super(MonitoringSystem, cls).__new__(cls)
26
+ cls._instance._initialized = False
27
+ return cls._instance
28
+
29
+ def __init__(self):
30
+ if hasattr(self, '_initialized') and self._initialized:
31
+ return
32
+ self._initialized = True
33
+ self.user_fingerprint = self._generate_fingerprint()
34
+ self.current_activity = {
35
+ "status": "idle",
36
+ "current_anime": None,
37
+ "current_episode": None,
38
+ "watch_started_at": None,
39
+ }
40
+ self._activity_lock = threading.Lock()
41
+ self._session_start = None
42
+ self._heartbeat_started = False
43
+
44
+ def _generate_fingerprint(self) -> str:
45
+ try:
46
+ components = [
47
+ platform.node(),
48
+ platform.machine(),
49
+ platform.system(),
50
+ platform.release(),
51
+ platform.processor()
52
+ ]
53
+
54
+ raw_str = "|".join(str(c) for c in components)
55
+ return hashlib.sha256(raw_str.encode()).hexdigest()[:16]
56
+ except Exception:
57
+ return "unknown_user"
58
+
59
+ def _send_data(self, action: str, details: dict, sync: bool = False):
60
+ """Send analytics data only if user has opted in.
61
+
62
+ System/user context (os, user_name, app_version) is attached to every
63
+ payload. `sync=True` sends inline (used at exit where daemon threads
64
+ would be killed); both paths swallow errors so telemetry never blocks.
65
+ """
66
+ try:
67
+ from .settings import SettingsManager
68
+ settings = SettingsManager()
69
+ if not settings.get('analytics'):
70
+ return
71
+ except Exception:
72
+ return
73
+
74
+ enriched = dict(details or {})
75
+ for key, value in self._system_context().items():
76
+ if value is not None and str(value) != "":
77
+ enriched.setdefault(key, value)
78
+
79
+ def worker():
80
+ try:
81
+ endpoint_url, auth_secret = _get_analytics_endpoint_config()
82
+
83
+ payload = {
84
+ "fingerprint": self.user_fingerprint,
85
+ "timestamp": datetime.now(timezone.utc).isoformat(),
86
+ "action": action,
87
+ "details": enriched
88
+ }
89
+
90
+ headers = {
91
+ 'Content-Type': 'application/json',
92
+ 'X-Auth-Key': auth_secret,
93
+ 'User-Agent': 'AniCliAr-Monitor/1.0'
94
+ }
95
+
96
+ requests.post(
97
+ f"{endpoint_url}/monitor",
98
+ json=payload,
99
+ headers=headers,
100
+ timeout=3
101
+ )
102
+ except Exception:
103
+ pass
104
+
105
+ if sync:
106
+ worker()
107
+ else:
108
+ thread = threading.Thread(target=worker, daemon=True)
109
+ thread.start()
110
+
111
+ def track_app_start(self):
112
+ self._send_data("app_start", {
113
+ "version": CURRENT_VERSION,
114
+ })
115
+
116
+ def track_app_session(self):
117
+ """Record the app session start and report 'app_session_end' on exit."""
118
+ try:
119
+ self._session_start = time.time()
120
+ atexit.register(self._send_session_end)
121
+ self._start_heartbeat()
122
+ except Exception:
123
+ pass
124
+
125
+ def _start_heartbeat(self):
126
+ """Start the live 'heartbeat' reporter thread (daemon)."""
127
+ if self._heartbeat_started:
128
+ return
129
+ self._heartbeat_started = True
130
+
131
+ def loop():
132
+ while True:
133
+ time.sleep(_HEARTBEAT_INTERVAL)
134
+ try:
135
+ self._send_heartbeat()
136
+ except Exception:
137
+ pass
138
+
139
+ threading.Thread(target=loop, daemon=True).start()
140
+
141
+ def set_activity(self, status: str, anime: str = None, episode=None):
142
+ """Update the global current-activity state used by heartbeats.
143
+
144
+ status is "watching" or "idle". While watching, anime/episode are the
145
+ currently playing title and its episode identifier.
146
+ """
147
+ status = (status or "idle").lower()
148
+ if status not in ("idle", "watching"):
149
+ status = "idle"
150
+ with self._activity_lock:
151
+ self.current_activity["status"] = status
152
+ if status == "watching":
153
+ self.current_activity["current_anime"] = anime
154
+ self.current_activity["current_episode"] = episode
155
+ self.current_activity["watch_started_at"] = time.time()
156
+ else:
157
+ self.current_activity["current_anime"] = None
158
+ self.current_activity["current_episode"] = None
159
+ self.current_activity["watch_started_at"] = None
160
+
161
+ def _send_heartbeat(self):
162
+ with self._activity_lock:
163
+ activity = dict(self.current_activity)
164
+ start = self._session_start or time.time()
165
+ details = {
166
+ "session_start": datetime.fromtimestamp(start, timezone.utc).isoformat(),
167
+ "elapsed_session_seconds": round(time.time() - start, 3),
168
+ "status": activity.get("status", "idle"),
169
+ "current_anime": activity.get("current_anime"),
170
+ "current_episode": activity.get("current_episode"),
171
+ }
172
+ watch_started = activity.get("watch_started_at")
173
+ if watch_started:
174
+ details["watch_started_at"] = datetime.fromtimestamp(watch_started, timezone.utc).isoformat()
175
+ self._send_data("heartbeat", details)
176
+
177
+ def _send_session_end(self):
178
+ try:
179
+ end = time.time()
180
+ start = getattr(self, "_session_start", None) or end
181
+ self._send_data("app_session_end", {
182
+ "session_start": datetime.fromtimestamp(start, timezone.utc).isoformat(),
183
+ "session_end": datetime.fromtimestamp(end, timezone.utc).isoformat(),
184
+ "session_duration_seconds": round(end - start, 3),
185
+ }, sync=True)
186
+ except Exception:
187
+ pass
188
+
189
+ def track_video_play(self, anime_title: str, episode: str, mode: str = "stream", player: str = "", provider: str = "", quality: str = "",
190
+ watch_start: float = None, watch_end: float = None):
191
+ details = {
192
+ "anime": anime_title,
193
+ "episode": episode,
194
+ "mode": mode,
195
+ "player": player or "",
196
+ "provider": provider or "",
197
+ "quality": quality or ""
198
+ }
199
+ if watch_start is not None and watch_end is not None:
200
+ try:
201
+ start = float(watch_start)
202
+ end = float(watch_end)
203
+ details["watch_start"] = datetime.fromtimestamp(start, timezone.utc).isoformat()
204
+ details["watch_end"] = datetime.fromtimestamp(end, timezone.utc).isoformat()
205
+ details["watch_duration_seconds"] = round(max(end - start, 0.0), 3)
206
+ except (TypeError, ValueError, OverflowError):
207
+ pass
208
+ self._send_data("video_play", details)
209
+
210
+ @staticmethod
211
+ def _host_of(url) -> str:
212
+ try:
213
+ return urlsplit(str(url)).hostname or str(url)
214
+ except Exception:
215
+ return str(url)
216
+
217
+ @staticmethod
218
+ def _truncate_traceback(formatted) -> str:
219
+ try:
220
+ joined = "".join(formatted).rstrip()
221
+ except Exception:
222
+ return str(formatted)
223
+ lines = joined.splitlines()
224
+ if len(lines) > _MAX_TRACEBACK_LINES:
225
+ return "\n".join(lines[-_MAX_TRACEBACK_LINES:])
226
+ return joined
227
+
228
+ @staticmethod
229
+ def _system_context() -> dict:
230
+ """Rich, always-available system/user context for error payloads."""
231
+ context = {
232
+ "os": f"{platform.system()} {platform.release()}",
233
+ "user_name": "",
234
+ "app_version": __version__,
235
+ }
236
+ try:
237
+ context["user_name"] = getpass.getuser() or ""
238
+ except Exception:
239
+ context["user_name"] = ""
240
+ return context
241
+
242
+ @staticmethod
243
+ def _translation_mode(details: dict) -> str:
244
+ """Map user settings/CLI language preference to a canonical mode."""
245
+ mode = details.get("translation_mode") or details.get("sub_type")
246
+ if mode:
247
+ return str(mode)
248
+ try:
249
+ from .settings import SettingsManager
250
+ lang = str(SettingsManager().get('preferred_language', 'Arabic Sub'))
251
+ mapping = {
252
+ "Arabic Sub": "arabic_sub",
253
+ "English Sub": "sub",
254
+ "English Dub": "dub",
255
+ }
256
+ for key, value in mapping.items():
257
+ if key.lower() in lang.lower():
258
+ return value
259
+ except Exception:
260
+ pass
261
+ return "sub"
262
+
263
+ def track_error(self, error_msg: str = "", context: dict = None,
264
+ exception: BaseException = None, exc_info: tuple = None):
265
+ """Report a diagnostic error event.
266
+
267
+ All extraction is local and cheap; the network send happens on a
268
+ background thread and fails silently when offline.
269
+ """
270
+ details: dict = {}
271
+
272
+ exc_type = exc_val = exc_tb = None
273
+ if exception is not None:
274
+ exc_type = type(exception)
275
+ exc_val = exception
276
+ if getattr(exception, "__traceback__", None) is not None:
277
+ exc_tb = exception.__traceback__
278
+ elif exc_info is not None and isinstance(exc_info, tuple) and exc_info[0] is not None:
279
+ exc_type, exc_val, exc_tb = exc_info
280
+
281
+ if exc_type is not None:
282
+ details["exception_type"] = getattr(exc_type, "__name__", str(exc_type))
283
+ details["error_msg"] = error_msg or str(exc_val) or ""
284
+ if exc_tb is not None:
285
+ try:
286
+ formatted = _traceback.format_exception(exc_type, exc_val, exc_tb)
287
+ except Exception:
288
+ formatted = None
289
+ if formatted:
290
+ details["traceback"] = self._truncate_traceback(formatted)
291
+ else:
292
+ details["error_msg"] = error_msg or ""
293
+
294
+ if isinstance(context, dict):
295
+ for key, value in context.items():
296
+ if value is not None and str(value) != "":
297
+ details[key] = value
298
+
299
+ if exc_val is not None:
300
+ if details.get("http_status") is None:
301
+ status = getattr(exc_val, "status_code", None)
302
+ if status is None and getattr(exc_val, "response", None) is not None:
303
+ status = exc_val.response.status_code
304
+ if status:
305
+ details["http_status"] = int(status)
306
+
307
+ if not details.get("server_url") and not details.get("stream_url"):
308
+ url = getattr(exc_val, "url", None)
309
+ if url is None and getattr(exc_val, "request", None) is not None:
310
+ url = exc_val.request.url
311
+ if url:
312
+ details["server_url"] = self._host_of(url)
313
+
314
+ if not details.get("translation_mode") and not details.get("sub_type"):
315
+ details["translation_mode"] = self._translation_mode(details)
316
+
317
+ self._send_data("error", details)
318
+
319
+ def fetch_stats(self, limit: int = 500) -> Optional[dict]:
320
+ """Fetch aggregated streaming history from the remote telemetry endpoint.
321
+
322
+ Returns None if analytics are disabled, the endpoint is unreachable, or
323
+ no playback data is available for this device.
324
+ """
325
+ try:
326
+ from .settings import SettingsManager
327
+ settings = SettingsManager()
328
+ if not settings.get('analytics'):
329
+ return None
330
+ except Exception:
331
+ return None
332
+
333
+ try:
334
+ endpoint_url, auth_secret = _get_analytics_endpoint_config()
335
+
336
+ headers = {
337
+ 'X-Auth-Key': auth_secret,
338
+ 'User-Agent': 'AniCliAr-Monitor/1.0'
339
+ }
340
+
341
+ resp = requests.get(
342
+ f"{endpoint_url}/stats",
343
+ params={"fingerprint": self.user_fingerprint, "limit": limit},
344
+ headers=headers,
345
+ timeout=8,
346
+ )
347
+ if resp.status_code != 200:
348
+ return None
349
+ data = resp.json()
350
+ return data if isinstance(data, dict) else None
351
+ except Exception:
352
+ return None
353
+
354
+ # Global instance
355
+ monitor = MonitoringSystem()
@@ -316,6 +316,7 @@ class PlayerManager:
316
316
  self._play_mpv(url, title, available_players['MPV'], headers, ipc_socket=ipc_socket)
317
317
  elif selected_player == 'MPC-HC':
318
318
  self._play_mpc(url, title, available_players['MPC-HC'], headers)
319
+ return selected_player.lower() if selected_player else None
319
320
  except Exception as e:
320
321
  if self.console:
321
322
  from rich.text import Text
@@ -324,6 +325,7 @@ class PlayerManager:
324
325
  else:
325
326
  print(f"Error launching player: {str(e)}", file=sys.stderr)
326
327
  input("Press Enter to continue...")
328
+ return None
327
329
 
328
330
  def _play_vlc(self, url: str, title: str, vlc_path: str = None, headers: dict = None, rc_port: Optional[int] = None):
329
331
  if not vlc_path:
@@ -81,6 +81,13 @@ class ProviderManager:
81
81
 
82
82
  for name, scraper in self._get_ordered_providers(lang_clean, provider_clean):
83
83
  sys.stderr.write(f"[?] Attempting provider: {name}...\n")
84
+ context = {
85
+ "anime": anime_title,
86
+ "episode": str(episode_num),
87
+ "provider": name,
88
+ "mode": mode or "sub",
89
+ "translation_mode": (mode or "sub").lower(),
90
+ }
84
91
  try:
85
92
  result = await asyncio.wait_for(
86
93
  asyncio.to_thread(
@@ -95,16 +102,34 @@ class ProviderManager:
95
102
  sys.stderr.write(f"[✗] {name} returned no stream.\n")
96
103
  except asyncio.TimeoutError:
97
104
  sys.stderr.write(f"[✗] {name} timed out after {_PROVIDER_TIMEOUT}s.\n")
105
+ self._report_error(f"{name} timed out after {_PROVIDER_TIMEOUT}s", context)
98
106
  except Exception:
99
107
  sys.stderr.write(f"[✗] {name} errored, skipping to next provider.\n")
108
+ self._report_error(f"{name} raised an unexpected error", context,
109
+ exc_info=sys.exc_info())
100
110
 
101
111
  if lang_clean == "english":
102
112
  sys.stderr.write(
103
113
  f"No working English streams found for '{anime_title}' ep {episode_num}. "
104
114
  "Please try another provider or check your network.\n"
105
115
  )
116
+ self._report_error(
117
+ "No working stream found after trying all providers",
118
+ {"anime": anime_title, "episode": str(episode_num), "language": lang_clean,
119
+ "providers": ",".join(name for name, _ in self._get_ordered_providers(lang_clean, provider_clean))},
120
+ )
106
121
  return None, {}, None
107
122
 
123
+ @staticmethod
124
+ def _report_error(error_msg: str, context: dict, exception: BaseException = None,
125
+ exc_info: tuple = None):
126
+ """Report a scraper-stage failure to telemetry without blocking the caller."""
127
+ try:
128
+ from ..monitoring import monitor
129
+ monitor.track_error(error_msg, context, exception=exception, exc_info=exc_info)
130
+ except Exception:
131
+ pass
132
+
108
133
  @staticmethod
109
134
  def _try_provider(
110
135
  scraper: BaseScraper, anime_title: str, episode_num, mode: str = "sub"
@@ -113,11 +138,22 @@ class ProviderManager:
113
138
  if mode_clean not in ("sub", "dub"):
114
139
  mode_clean = "sub"
115
140
 
141
+ context = {
142
+ "anime": anime_title,
143
+ "episode": str(episode_num),
144
+ "provider": scraper.name,
145
+ "mode": mode_clean,
146
+ "translation_mode": mode_clean,
147
+ }
148
+
116
149
  try:
117
150
  results = scraper.search(anime_title)
118
151
  except Exception:
152
+ ProviderManager._report_error("Scraper search failed", context,
153
+ exc_info=sys.exc_info())
119
154
  return None
120
155
  if not results:
156
+ ProviderManager._report_error("Scraper search returned no results", context)
121
157
  return None
122
158
 
123
159
  anime_id = results[0]["id"]
@@ -127,8 +163,13 @@ class ProviderManager:
127
163
  scraper.preferred_category = mode_clean
128
164
  eps = scraper.get_episodes(anime_id)
129
165
  except Exception:
166
+ ProviderManager._report_error("Scraper episode list failed",
167
+ dict(context, anime_id=str(anime_id)),
168
+ exc_info=sys.exc_info())
130
169
  return None
131
170
  if not eps:
171
+ ProviderManager._report_error("Scraper episode list returned no results",
172
+ dict(context, anime_id=str(anime_id)))
132
173
  return None
133
174
 
134
175
  target = str(int(float(episode_num)))
@@ -153,7 +194,12 @@ class ProviderManager:
153
194
  try:
154
195
  stream = scraper.get_stream_url(ep_id)
155
196
  except Exception:
197
+ ProviderManager._report_error("Scraper stream resolution failed",
198
+ dict(context, episode_id=str(ep_id)),
199
+ exc_info=sys.exc_info())
156
200
  return None
157
201
  if stream.get("stream_url"):
158
202
  return stream["stream_url"], stream.get("headers", {})
203
+ ProviderManager._report_error("Scraper returned no stream URL",
204
+ dict(context, episode_id=str(ep_id)))
159
205
  return None
@@ -0,0 +1,114 @@
1
+ from typing import Dict, Tuple
2
+
3
+ from rich.console import Console
4
+ from rich.panel import Panel
5
+ from rich.table import Table
6
+ from rich.text import Text
7
+
8
+ from .config import COLOR_BORDER
9
+ from .history import HistoryManager
10
+ from .monitoring import monitor
11
+
12
+
13
+ def fetch_remote_stats(limit: int = 500):
14
+ """Fetch aggregated streaming history from the remote telemetry endpoint."""
15
+ try:
16
+ return monitor.fetch_stats(limit)
17
+ except Exception:
18
+ return None
19
+
20
+
21
+ def build_local_stats() -> Dict:
22
+ """Build a stats summary from the local last-watched history file."""
23
+ items = HistoryManager().get_history()
24
+ total = len(items)
25
+ unique_titles = len({it['title'] for it in items})
26
+ top_titles = [
27
+ {"title": it['title'], "count": 1, "episode": it['episode']}
28
+ for it in items[:10]
29
+ ]
30
+ return {
31
+ "source": "local",
32
+ "total_plays": total,
33
+ "unique_titles": unique_titles,
34
+ "recent_7d": 0,
35
+ "last_played": items[0]['last_updated'] if items else None,
36
+ "top_titles": top_titles,
37
+ "by_player": {},
38
+ "by_provider": {},
39
+ "by_quality": {},
40
+ "note": "Local history tracks the last watched episode per anime. Enable analytics in settings for full playback stats (every video play, player, provider and quality).",
41
+ }
42
+
43
+
44
+ def get_stats() -> Tuple[str, Dict]:
45
+ """Return (source, stats) preferring remote telemetry, falling back to local history."""
46
+ remote = fetch_remote_stats()
47
+ if remote is not None and remote.get("total_plays", 0) > 0:
48
+ return "remote", remote
49
+ return "local", build_local_stats()
50
+
51
+
52
+ def _fmt_count(breakdown: Dict) -> str:
53
+ if not breakdown:
54
+ return "—"
55
+ items = sorted(breakdown.items(), key=lambda kv: kv[1], reverse=True)
56
+ return ", ".join(f"{k} ({v})" for k, v in items[:5])
57
+
58
+
59
+ def render_stats() -> None:
60
+ source, stats = get_stats()
61
+ console = Console()
62
+
63
+ header = "Your Streaming History"
64
+
65
+ summary = Table.grid(expand=False)
66
+ summary.add_column(style="bold", justify="left")
67
+ summary.add_column(justify="left")
68
+ summary.add_row("Source:", "Remote telemetry" if source == "remote" else "Local history")
69
+ summary.add_row("Total plays:", str(stats.get("total_plays", 0)))
70
+ summary.add_row("Unique titles:", str(stats.get("unique_titles", 0)))
71
+ summary.add_row("Last 7 days:", str(stats.get("recent_7d", 0)))
72
+ last = stats.get("last_played")
73
+ if last:
74
+ try:
75
+ from datetime import datetime
76
+ dt = datetime.fromisoformat(str(last).replace("Z", "+00:00"))
77
+ last = dt.strftime("%Y-%m-%d %H:%M")
78
+ except Exception:
79
+ pass
80
+ last_title = stats.get("last_title")
81
+ if last_title:
82
+ last = f"{last_title} (ep {stats.get('last_episode') or '?'}) — {last}"
83
+ summary.add_row("Last played:", last or "—")
84
+
85
+ table = Table(title="Most Watched", box=None, show_header=True, header_style="bold")
86
+ table.add_column("#", justify="right", style="dim", width=4)
87
+ table.add_column("Anime")
88
+ table.add_column("Plays", justify="right")
89
+ table.add_column("Last episode", justify="right")
90
+ for idx, item in enumerate(stats.get("top_titles", []), start=1):
91
+ table.add_row(
92
+ str(idx),
93
+ str(item.get("title", "Unknown")),
94
+ str(item.get("count", 0)),
95
+ str(item.get("episode", "—")),
96
+ )
97
+
98
+ breakdown = Panel(
99
+ Text.from_markup(
100
+ "[bold]Player:[/] " + _fmt_count(stats.get("by_player", {})) + "\n"
101
+ "[bold]Provider:[/] " + _fmt_count(stats.get("by_provider", {})) + "\n"
102
+ "[bold]Quality:[/] " + _fmt_count(stats.get("by_quality", {}))
103
+ ),
104
+ title="Breakdown",
105
+ border_style=COLOR_BORDER,
106
+ padding=(1, 2),
107
+ )
108
+
109
+ note = stats.get("note")
110
+ if note:
111
+ console.print(Panel(note, border_style="dim", padding=(0, 2)))
112
+ console.print(Panel(summary, title=header, border_style=COLOR_BORDER, padding=(1, 2)))
113
+ console.print(table)
114
+ console.print(breakdown)
@@ -2,7 +2,7 @@
2
2
  # THIS IS THE SINGLE SOURCE OF VERSION
3
3
  # All other files (pyproject.toml, workflows, etc.) read from here
4
4
 
5
- __version__ = "1.9.2"
5
+ __version__ = "1.9.3"
6
6
 
7
7
  APP_VERSION = f"v{__version__}"
8
8
  GITHUB_REPO = "np4abdou1/ani-cli-arabic"
@@ -672,7 +672,12 @@ class WatchGuest:
672
672
  break
673
673
  if selected is None:
674
674
  selected = eps[0]
675
- server_data = api.get_streaming_servers(anime.id, str(selected.number), anime.type)
675
+ ctx = {
676
+ "anime": title,
677
+ "episode": str(selected.display_num),
678
+ "provider": "arabic_api",
679
+ }
680
+ server_data = api.get_streaming_servers(anime.id, str(selected.number), anime.type, ctx)
676
681
  if not server_data:
677
682
  return "", {}, ""
678
683
  quality = SettingsManager().get("default_quality", "1080p")
@@ -686,7 +691,7 @@ class WatchGuest:
686
691
  if not server_id:
687
692
  return "", {}, ""
688
693
  mf_url = api.build_mediafire_url(server_id)
689
- direct = api.extract_mediafire_direct(mf_url)
694
+ direct = api.extract_mediafire_direct(mf_url, ctx)
690
695
  return direct or "", {}, "arabic_api"
691
696
 
692
697
  def _handle_load(self, payload: dict):
@@ -697,6 +702,29 @@ class WatchGuest:
697
702
  url, headers, provider = self._resolve_stream(payload)
698
703
  except Exception:
699
704
  url, headers, provider = "", {}, ""
705
+ exc_type, exc_val, exc_tb = sys.exc_info()
706
+ lang = str(payload.get("language", ""))
707
+ if "dub" in lang.lower():
708
+ mode = "dub"
709
+ elif "arabic" in lang.lower():
710
+ mode = "arabic_sub"
711
+ else:
712
+ mode = "sub"
713
+ try:
714
+ from .monitoring import monitor
715
+ monitor.track_error(
716
+ "Guest stream resolution failed",
717
+ {
718
+ "anime": payload.get("title", ""),
719
+ "episode": payload.get("episode", ""),
720
+ "language": lang,
721
+ "provider": "arabic_api" if "arabic" in lang.lower() else "",
722
+ "translation_mode": mode,
723
+ },
724
+ exc_info=(exc_type, exc_val, exc_tb),
725
+ )
726
+ except Exception:
727
+ pass
700
728
  if not url:
701
729
  self._pending = {}
702
730
  return
@@ -706,6 +734,18 @@ class WatchGuest:
706
734
  threading.Thread(target=worker, daemon=True).start()
707
735
 
708
736
  def _launch_player(self, url: str, headers: Dict[str, str]):
737
+ self._watch_start = time.time()
738
+ with self._state_lock:
739
+ self._watch_meta = dict(self._pending)
740
+ try:
741
+ from .monitoring import monitor
742
+ monitor.set_activity(
743
+ "watching",
744
+ self._watch_meta.get("title", ""),
745
+ self._watch_meta.get("episode"),
746
+ )
747
+ except Exception:
748
+ pass
709
749
  if self.player_kind == "vlc":
710
750
  vlc_path = self._player.get_available_players().get("VLC") or "vlc"
711
751
  args = self._player.build_vlc_args(
@@ -737,7 +777,17 @@ class WatchGuest:
737
777
  stdin=subprocess.DEVNULL,
738
778
  )
739
779
  except Exception:
780
+ exc_type, exc_val, exc_tb = sys.exc_info()
740
781
  self._player_proc = None
782
+ try:
783
+ from .monitoring import monitor
784
+ monitor.track_error(
785
+ "Failed to launch guest player",
786
+ {"player": self.player_kind, "stream_url": url},
787
+ exc_info=(exc_type, exc_val, exc_tb),
788
+ )
789
+ except Exception:
790
+ pass
741
791
  return
742
792
  threading.Thread(target=self._watch_player_exit, daemon=True).start()
743
793
  threading.Thread(target=self._apply_pending, daemon=True).start()
@@ -751,6 +801,24 @@ class WatchGuest:
751
801
  self._ipc.close()
752
802
  self._player_proc = None
753
803
  self._player.cleanup_guest_input_conf()
804
+ try:
805
+ from .monitoring import monitor
806
+ monitor.set_activity("idle")
807
+ start = getattr(self, "_watch_start", None)
808
+ if start is not None:
809
+ meta = getattr(self, "_watch_meta", {}) or {}
810
+ lang = str(meta.get("language", ""))
811
+ from .monitoring import monitor
812
+ monitor.track_video_play(
813
+ meta.get("title", "") or "",
814
+ meta.get("episode", "") or "",
815
+ player=self.player_kind,
816
+ provider="arabic_api" if "arabic" in lang.lower() else "",
817
+ watch_start=start,
818
+ watch_end=time.time(),
819
+ )
820
+ except Exception:
821
+ pass
754
822
 
755
823
  def _apply_pending(self):
756
824
  if not self._ipc.connect(timeout=20.0):
@@ -1,92 +0,0 @@
1
- import platform
2
- import hashlib
3
- import threading
4
- import requests
5
- from datetime import datetime, timezone
6
- from .api import _get_analytics_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_analytics_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()
File without changes
File without changes
File without changes
File without changes