Find people who are watching your plex content from multiple devices / locations simultaniously.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Find simultanious devices through Tautulli

Logan Saso c43477c1

+674
+2
.gitignore
··· 1 + .envrc 2 + .direnv/
+61
flake.lock
··· 1 + { 2 + "nodes": { 3 + "flake-utils": { 4 + "inputs": { 5 + "systems": "systems" 6 + }, 7 + "locked": { 8 + "lastModified": 1731533236, 9 + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 + "owner": "numtide", 11 + "repo": "flake-utils", 12 + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 + "type": "github" 14 + }, 15 + "original": { 16 + "owner": "numtide", 17 + "repo": "flake-utils", 18 + "type": "github" 19 + } 20 + }, 21 + "nixpkgs": { 22 + "locked": { 23 + "lastModified": 1774701658, 24 + "narHash": "sha256-CIS/4AMUSwUyC8X5g+5JsMRvIUL3YUfewe8K4VrbsSQ=", 25 + "owner": "nixos", 26 + "repo": "nixpkgs", 27 + "rev": "b63fe7f000adcfa269967eeff72c64cafecbbebe", 28 + "type": "github" 29 + }, 30 + "original": { 31 + "owner": "nixos", 32 + "ref": "nixpkgs-unstable", 33 + "repo": "nixpkgs", 34 + "type": "github" 35 + } 36 + }, 37 + "root": { 38 + "inputs": { 39 + "flake-utils": "flake-utils", 40 + "nixpkgs": "nixpkgs" 41 + } 42 + }, 43 + "systems": { 44 + "locked": { 45 + "lastModified": 1681028828, 46 + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 + "owner": "nix-systems", 48 + "repo": "default", 49 + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 + "type": "github" 51 + }, 52 + "original": { 53 + "owner": "nix-systems", 54 + "repo": "default", 55 + "type": "github" 56 + } 57 + } 58 + }, 59 + "root": "root", 60 + "version": 7 61 + }
+19
flake.nix
··· 1 + { 2 + inputs = { 3 + nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; 4 + flake-utils.url = "github:numtide/flake-utils"; 5 + }; 6 + 7 + outputs = { nixpkgs, flake-utils, ... }: 8 + flake-utils.lib.eachDefaultSystem (system: 9 + let 10 + pkgs = import nixpkgs { inherit system; }; 11 + python = pkgs.python3.withPackages (ps: with ps; [ 12 + requests 13 + ]); 14 + in { 15 + devShells.default = pkgs.mkShell { 16 + buildInputs = [ python ]; 17 + }; 18 + }); 19 + }
+592
simul_finder.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Plex account sharing detector using Tautulli API. 4 + 5 + Finds users streaming from multiple devices/locations, with per-device 6 + usage breakdowns and "teleportation" detection (impossible travel between 7 + geographically distant sessions). 8 + """ 9 + 10 + import argparse 11 + import math 12 + import os 13 + import sys 14 + from datetime import datetime, timedelta 15 + 16 + import requests 17 + 18 + # --------------------------------------------------------------------------- 19 + # Tautulli API helpers 20 + # --------------------------------------------------------------------------- 21 + 22 + def tautulli_api(base_url: str, api_key: str, cmd: str, **params) -> dict: 23 + params = {k: v for k, v in params.items() if v is not None} 24 + resp = requests.get( 25 + f"{base_url}/api/v2", 26 + params={"apikey": api_key, "cmd": cmd, **params}, 27 + timeout=30, 28 + ) 29 + resp.raise_for_status() 30 + data = resp.json() 31 + if data.get("response", {}).get("result") != "success": 32 + msg = data.get("response", {}).get("message", "unknown error") 33 + raise RuntimeError(f"Tautulli API error ({cmd}): {msg}") 34 + return data["response"]["data"] 35 + 36 + 37 + def get_users(base_url: str, api_key: str) -> list[dict]: 38 + data = tautulli_api(base_url, api_key, "get_users_table", length=500) 39 + return data.get("data", []) 40 + 41 + 42 + def get_history(base_url: str, api_key: str, user_id: int, after: str, length: int = 1000) -> list[dict]: 43 + data = tautulli_api( 44 + base_url, api_key, "get_history", 45 + user_id=user_id, after=after, length=length, 46 + ) 47 + return data.get("data", []) 48 + 49 + 50 + def get_geoip(base_url: str, api_key: str, ip: str) -> dict: 51 + return tautulli_api(base_url, api_key, "get_geoip_lookup", ip_address=ip) 52 + 53 + 54 + # --------------------------------------------------------------------------- 55 + # Geo / teleportation helpers 56 + # --------------------------------------------------------------------------- 57 + 58 + def is_private_ip(ip: str) -> bool: 59 + return ip.startswith(("10.", "192.168.", "172.16.", "127.", "0.")) 60 + 61 + 62 + def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: 63 + """Great-circle distance between two points in km.""" 64 + R = 6371.0 65 + dlat = math.radians(lat2 - lat1) 66 + dlon = math.radians(lon2 - lon1) 67 + a = (math.sin(dlat / 2) ** 2 68 + + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) 69 + * math.sin(dlon / 2) ** 2) 70 + return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) 71 + 72 + 73 + # ~900 km/h is the fastest commercial flight 74 + MAX_TRAVEL_SPEED_KMH = 900 75 + 76 + 77 + def find_teleportations( 78 + history: list[dict], 79 + ip_geo_cache: dict[str, dict], 80 + ) -> list[dict]: 81 + """Find session transitions where the user moved faster than physically possible.""" 82 + # Build a timeline: (timestamp, ip, machine_id, title) sorted by time 83 + events = [] 84 + for rec in history: 85 + ip = rec.get("ip_address", "") 86 + if not ip or is_private_ip(ip): 87 + continue 88 + geo = ip_geo_cache.get(ip) 89 + if not geo or not geo.get("latitude") or not geo.get("longitude"): 90 + continue 91 + started = rec.get("started") 92 + stopped = rec.get("stopped") 93 + if started: 94 + events.append({ 95 + "time": started, 96 + "ip": ip, 97 + "lat": float(geo["latitude"]), 98 + "lon": float(geo["longitude"]), 99 + "location": geo.get("city", "?"), 100 + "region": geo.get("region", ""), 101 + "machine_id": rec.get("machine_id", "?"), 102 + "platform": rec.get("platform", "?"), 103 + "title": rec.get("full_title") or rec.get("title", "?"), 104 + "event": "start", 105 + }) 106 + if stopped: 107 + events.append({ 108 + "time": stopped, 109 + "ip": ip, 110 + "lat": float(geo["latitude"]), 111 + "lon": float(geo["longitude"]), 112 + "location": geo.get("city", "?"), 113 + "region": geo.get("region", ""), 114 + "machine_id": rec.get("machine_id", "?"), 115 + "platform": rec.get("platform", "?"), 116 + "title": rec.get("full_title") or rec.get("title", "?"), 117 + "event": "stop", 118 + }) 119 + 120 + events.sort(key=lambda e: e["time"]) 121 + 122 + teleportations = [] 123 + for i in range(len(events) - 1): 124 + a, b = events[i], events[i + 1] 125 + if a["ip"] == b["ip"]: 126 + continue 127 + dist_km = haversine_km(a["lat"], a["lon"], b["lat"], b["lon"]) 128 + if dist_km < 50: # same metro area, ignore 129 + continue 130 + time_diff_h = (b["time"] - a["time"]) / 3600 131 + if time_diff_h <= 0: 132 + time_diff_h = 0.001 # concurrent 133 + speed_kmh = dist_km / time_diff_h 134 + if speed_kmh > MAX_TRAVEL_SPEED_KMH: 135 + teleportations.append({ 136 + "from": a, 137 + "to": b, 138 + "dist_km": dist_km, 139 + "time_diff_h": time_diff_h, 140 + "speed_kmh": speed_kmh, 141 + }) 142 + 143 + return teleportations 144 + 145 + 146 + # --------------------------------------------------------------------------- 147 + # Analysis 148 + # --------------------------------------------------------------------------- 149 + 150 + def same_network(ip_a: str, ip_b: str) -> bool: 151 + """True if both IPs are on the same network (both private = same household).""" 152 + if is_private_ip(ip_a) and is_private_ip(ip_b): 153 + return True 154 + return ip_a == ip_b 155 + 156 + 157 + def effective_end(session: dict) -> int: 158 + """Use started + play_duration as the end time, since 'stopped' can be hours 159 + after playback ended (e.g. fell asleep, left app open).""" 160 + started = session["started"] 161 + duration = session.get("play_duration") or session.get("duration") or 0 162 + if duration > 0: 163 + return started + duration 164 + return session.get("stopped") or started 165 + 166 + 167 + def find_concurrent_sessions(history: list[dict]) -> list[tuple[dict, dict]]: 168 + """Find pairs of history records that overlap in time from different devices and networks.""" 169 + overlaps = [] 170 + for i, a in enumerate(history): 171 + if not a.get("started"): 172 + continue 173 + a_end = effective_end(a) 174 + for b in history[i + 1:]: 175 + if not b.get("started"): 176 + continue 177 + b_end = effective_end(b) 178 + if a.get("machine_id") == b.get("machine_id"): 179 + continue 180 + if same_network(a.get("ip_address", ""), b.get("ip_address", "")): 181 + continue 182 + if a["started"] < b_end and b["started"] < a_end: 183 + overlaps.append((a, b)) 184 + return overlaps 185 + 186 + 187 + def resolve_ips(base_url: str, api_key: str, ips: set[str]) -> dict[str, dict]: 188 + """Geo-resolve a set of IPs, returning {ip: geo_dict}.""" 189 + cache: dict[str, dict] = {} 190 + for ip in ips: 191 + if is_private_ip(ip): 192 + cache[ip] = {"city": "LAN", "region": "", "country": ""} 193 + continue 194 + try: 195 + cache[ip] = get_geoip(base_url, api_key, ip) 196 + except Exception: 197 + cache[ip] = {} 198 + return cache 199 + 200 + 201 + def fmt_location(geo: dict) -> str: 202 + if not geo: 203 + return "Unknown" 204 + parts = [geo.get("city"), geo.get("region"), geo.get("country")] 205 + return ", ".join(p for p in parts if p) or "Unknown" 206 + 207 + 208 + def analyze_user(base_url: str, api_key: str, user: dict, after: str, geo: bool) -> dict | None: 209 + uid = user["user_id"] 210 + name = user.get("friendly_name") or user.get("username") or str(uid) 211 + 212 + history = get_history(base_url, api_key, uid, after) 213 + if not history: 214 + return None 215 + 216 + # --- Device breakdown from history --- 217 + devices: dict[str, dict] = {} 218 + all_ips: set[str] = set() 219 + for rec in history: 220 + mid = rec.get("machine_id", "unknown") 221 + if mid not in devices: 222 + devices[mid] = { 223 + "machine_id": mid, 224 + "platform": rec.get("platform", "?"), 225 + "player": rec.get("player", "?"), 226 + "ips": set(), 227 + "plays": 0, 228 + "duration_sec": 0, 229 + } 230 + devices[mid]["plays"] += 1 231 + devices[mid]["duration_sec"] += rec.get("play_duration") or rec.get("duration") or 0 232 + ip = rec.get("ip_address") 233 + if ip: 234 + devices[mid]["ips"].add(ip) 235 + all_ips.add(ip) 236 + 237 + if len(devices) < 2: 238 + return None 239 + 240 + # --- Concurrent sessions --- 241 + overlaps = find_concurrent_sessions(history) 242 + overlap_pairs: set[tuple[str, str]] = set() 243 + for a, b in overlaps: 244 + pair = tuple(sorted([a.get("machine_id", "?"), b.get("machine_id", "?")])) 245 + overlap_pairs.add(pair) 246 + 247 + # --- Geo + teleportation (when --geo) --- 248 + ip_geo_cache: dict[str, dict] = {} 249 + ip_locations: dict[str, str] = {} 250 + teleportations: list[dict] = [] 251 + if geo: 252 + ip_geo_cache = resolve_ips(base_url, api_key, all_ips) 253 + ip_locations = {ip: fmt_location(g) for ip, g in ip_geo_cache.items()} 254 + teleportations = find_teleportations(history, ip_geo_cache) 255 + 256 + # --- Scoring --- 257 + n_devices = len(devices) 258 + n_overlaps = len(overlaps) 259 + n_overlap_pairs = len(overlap_pairs) 260 + heavy_devices = sum(1 for d in devices.values() if d["plays"] > 5) 261 + 262 + score = ( 263 + (n_devices - 1) * 10 264 + + n_overlap_pairs * 25 265 + + min(n_overlaps, 50) * 2 266 + + max(0, heavy_devices - 1) * 15 267 + + len(teleportations) * 30 # teleportation is a strong signal 268 + ) 269 + 270 + return { 271 + "user_id": uid, 272 + "name": name, 273 + "history": history, 274 + "devices": devices, 275 + "n_devices": n_devices, 276 + "heavy_devices": heavy_devices, 277 + "total_plays": sum(d["plays"] for d in devices.values()), 278 + "total_duration_sec": sum(d["duration_sec"] for d in devices.values()), 279 + "n_overlaps": n_overlaps, 280 + "n_overlap_pairs": n_overlap_pairs, 281 + "teleportations": teleportations, 282 + "score": score, 283 + "ip_locations": ip_locations, 284 + } 285 + 286 + 287 + # --------------------------------------------------------------------------- 288 + # Output 289 + # --------------------------------------------------------------------------- 290 + 291 + def fmt_duration(seconds: int) -> str: 292 + h, m = divmod(seconds // 60, 60) 293 + if h > 0: 294 + return f"{h}h {m}m" 295 + return f"{m}m" 296 + 297 + 298 + def fmt_timestamp(ts: int | float) -> str: 299 + return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") 300 + 301 + 302 + def filter_flagged(results: list[dict], min_score: int, concurrent_only: bool) -> list[dict]: 303 + flagged = [r for r in results if r["score"] >= min_score] 304 + if concurrent_only: 305 + flagged = [r for r in flagged if r["n_overlaps"] > 0] 306 + flagged.sort(key=lambda r: r["score"], reverse=True) 307 + return flagged 308 + 309 + 310 + def print_device_report(flagged: list[dict]): 311 + """Device-centric view: one row per device with aggregated stats.""" 312 + print(f"\n{'='*78}") 313 + print(f" DEVICE BREAKDOWN — {len(flagged)} user(s) flagged") 314 + print(f"{'='*78}\n") 315 + 316 + for r in flagged: 317 + print(f" {r['name']} (user_id: {r['user_id']})") 318 + print(f" Score: {r['score']} | Devices: {r['n_devices']} | " 319 + f"Heavy devices: {r['heavy_devices']} | " 320 + f"Concurrent sessions: {r['n_overlaps']} | " 321 + f"Concurrent device pairs: {r['n_overlap_pairs']}") 322 + print(f" Total plays: {r['total_plays']} | " 323 + f"Total watch time: {fmt_duration(r['total_duration_sec'])}") 324 + print() 325 + 326 + sorted_devices = sorted(r["devices"].values(), key=lambda d: d["duration_sec"], reverse=True) 327 + print(f" {'Platform':<16} {'Player':<24} {'Plays':>6} {'Watch Time':>11} IPs") 328 + print(f" {'-'*16} {'-'*24} {'-'*6} {'-'*11} {'-'*30}") 329 + for d in sorted_devices: 330 + ips_display = [] 331 + for ip in sorted(d["ips"]): 332 + loc = r["ip_locations"].get(ip) 333 + if loc: 334 + ips_display.append(f"{ip} ({loc})") 335 + else: 336 + ips_display.append(ip) 337 + ip_str = ", ".join(ips_display) if ips_display else "—" 338 + print(f" {d['platform']:<16} {d['player']:<24} {d['plays']:>6} " 339 + f"{fmt_duration(d['duration_sec']):>11} {ip_str}") 340 + 341 + if r["teleportations"]: 342 + print(f"\n TELEPORTATION DETECTED ({len(r['teleportations'])} event(s)):") 343 + for t in r["teleportations"][:10]: 344 + f_ = t["from"] 345 + to = t["to"] 346 + print(f" {fmt_timestamp(f_['time'])} {f_['location']}, {f_['region']}" 347 + f" --> {fmt_timestamp(to['time'])} {to['location']}, {to['region']}") 348 + print(f" {t['dist_km']:.0f} km in {t['time_diff_h']:.1f}h" 349 + f" ({t['speed_kmh']:.0f} km/h — max plausible: {MAX_TRAVEL_SPEED_KMH} km/h)") 350 + print(f" Devices: {f_['platform']} -> {to['platform']}") 351 + 352 + print(f"\n{'-'*78}\n") 353 + 354 + 355 + def print_timeline_report(flagged: list[dict]): 356 + """Chronological view: sessions listed by date with device/IP info and concurrency markers.""" 357 + print(f"\n{'='*78}") 358 + print(f" SESSION TIMELINE — {len(flagged)} user(s) flagged") 359 + print(f"{'='*78}\n") 360 + 361 + for r in flagged: 362 + history = r.get("history", []) 363 + if not history: 364 + continue 365 + 366 + print(f" {r['name']} (user_id: {r['user_id']}) — score: {r['score']}") 367 + print() 368 + 369 + # Sort sessions chronologically 370 + sessions = sorted( 371 + [s for s in history if s.get("started")], 372 + key=lambda s: s["started"], 373 + ) 374 + 375 + # Pre-compute which sessions are concurrent with another from a different device + network 376 + concurrent_sessions: set[int] = set() 377 + for i, a in enumerate(sessions): 378 + a_end = effective_end(a) 379 + for j, b in enumerate(sessions[i + 1:], start=i + 1): 380 + b_end = effective_end(b) 381 + if a.get("machine_id") == b.get("machine_id"): 382 + continue 383 + if same_network(a.get("ip_address", ""), b.get("ip_address", "")): 384 + continue 385 + if a["started"] < b_end and b["started"] < a_end: 386 + concurrent_sessions.add(i) 387 + concurrent_sessions.add(j) 388 + 389 + if not concurrent_sessions: 390 + print(f" (no concurrent sessions)") 391 + print(f"\n{'-'*78}\n") 392 + continue 393 + 394 + current_date = None 395 + for idx, s in enumerate(sessions): 396 + if idx not in concurrent_sessions: 397 + continue 398 + 399 + ts = datetime.fromtimestamp(s["started"]) 400 + date_str = ts.strftime("%Y-%m-%d") 401 + time_str = ts.strftime("%H:%M") 402 + dur = s.get("play_duration") or s.get("duration") or 0 403 + end_ts = effective_end(s) 404 + end_str = datetime.fromtimestamp(end_ts).strftime("%H:%M") 405 + 406 + if date_str != current_date: 407 + current_date = date_str 408 + print(f" {date_str}") 409 + 410 + ip = s.get("ip_address", "?") 411 + loc = r["ip_locations"].get(ip) 412 + ip_display = f"{ip} ({loc})" if loc else ip 413 + platform = s.get("platform", "?") 414 + player = s.get("player", "?") 415 + title = s.get("full_title") or s.get("title") or "?" 416 + if len(title) > 40: 417 + title = title[:37] + "..." 418 + 419 + print(f" {time_str}-{end_str} {fmt_duration(dur):>7}" 420 + f" {platform:<14} {player:<20} {ip_display}") 421 + print(f" {title}") 422 + 423 + print(f"\n{'-'*78}\n") 424 + 425 + 426 + def print_ip_report(user_ip_stats: list[dict], ip_locations: dict[str, str]): 427 + """Per-user breakdown of usage by IP, sorted by watch time.""" 428 + print(f"\n{'='*78}") 429 + print(f" IP USAGE BY USER — {len(user_ip_stats)} user(s)") 430 + print(f"{'='*78}\n") 431 + 432 + for u in user_ip_stats: 433 + print(f" {u['name']} (user_id: {u['user_id']})") 434 + total_dur = sum(ip["duration_sec"] for ip in u["ips"].values()) 435 + print(f" Total: {u['total_plays']} plays, {fmt_duration(total_dur)} watch time, " 436 + f"{len(u['ips'])} unique IP(s)") 437 + print() 438 + 439 + sorted_ips = sorted(u["ips"].values(), key=lambda x: x["duration_sec"], reverse=True) 440 + print(f" {'IP':<22} {'Plays':>6} {'Watch Time':>11} {'Pct':>5} Devices") 441 + print(f" {'-'*22} {'-'*6} {'-'*11} {'-'*5} {'-'*30}") 442 + for ip_info in sorted_ips: 443 + ip = ip_info["ip"] 444 + loc = ip_locations.get(ip) 445 + ip_display = f"{ip} ({loc})" if loc else ip 446 + pct = (ip_info["duration_sec"] / total_dur * 100) if total_dur else 0 447 + devices = ", ".join(sorted(ip_info["platforms"])) 448 + print(f" {ip_display:<22} {ip_info['plays']:>6} " 449 + f"{fmt_duration(ip_info['duration_sec']):>11} {pct:>4.0f}% {devices}") 450 + 451 + print(f"\n{'-'*78}\n") 452 + 453 + 454 + def print_report(results: list[dict], min_score: int, concurrent_only: bool, timeline: bool): 455 + flagged = filter_flagged(results, min_score, concurrent_only) 456 + 457 + if not flagged: 458 + print("No users flagged for potential account sharing.") 459 + return 460 + 461 + print_device_report(flagged) 462 + if timeline: 463 + print_timeline_report(flagged) 464 + 465 + 466 + # --------------------------------------------------------------------------- 467 + # Main 468 + # --------------------------------------------------------------------------- 469 + 470 + def build_base_url(raw: str) -> str: 471 + """Accept a hostname, host:port, or full URL and return a base URL.""" 472 + if raw.startswith(("http://", "https://")): 473 + return raw.rstrip("/") 474 + return f"http://{raw.rstrip('/')}" 475 + 476 + 477 + def main(): 478 + parser = argparse.ArgumentParser( 479 + description="Detect Plex account sharing via Tautulli", 480 + ) 481 + parser.add_argument("--host", 482 + default=os.environ.get("TAUTULLI_HOST") or os.environ.get("TAUTULLI_URL"), 483 + help="Tautulli host or URL (or set TAUTULLI_HOST env var)") 484 + parser.add_argument("--api-key", 485 + default=os.environ.get("TAUTULLI_API_KEY"), 486 + help="Tautulli API key (or set TAUTULLI_API_KEY env var)") 487 + parser.add_argument("--days", type=int, default=30, 488 + help="Look back N days (default: 30)") 489 + parser.add_argument("--min-score", type=int, default=20, 490 + help="Minimum suspicion score to flag (default: 20)") 491 + parser.add_argument("--geo", action="store_true", 492 + help="Enable IP geolocation + teleportation detection (more API calls)") 493 + parser.add_argument("--concurrent-only", action="store_true", 494 + help="Only show users with concurrent sessions from different devices") 495 + parser.add_argument("--timeline", action="store_true", 496 + help="Show a chronological session timeline in addition to the device breakdown") 497 + parser.add_argument("--top-ips", action="store_true", 498 + help="Show per-user IP usage breakdown ranked by watch time") 499 + parser.add_argument("--user", type=str, default=None, 500 + help="Analyze a single user by friendly name") 501 + args = parser.parse_args() 502 + 503 + if not args.host or not args.api_key: 504 + print("Error: provide --host and --api-key (or set TAUTULLI_HOST / TAUTULLI_API_KEY).", 505 + file=sys.stderr) 506 + sys.exit(1) 507 + 508 + base_url = build_base_url(args.host) 509 + after = (datetime.now() - timedelta(days=args.days)).strftime("%Y-%m-%d") 510 + 511 + print(f"Fetching users from Tautulli ({base_url})...") 512 + users = get_users(base_url, args.api_key) 513 + if args.user: 514 + users = [u for u in users if (u.get("friendly_name") or "").lower() == args.user.lower() 515 + or (u.get("username") or "").lower() == args.user.lower()] 516 + if not users: 517 + print(f"User '{args.user}' not found.", file=sys.stderr) 518 + sys.exit(1) 519 + 520 + print(f"Analyzing {len(users)} user(s) over the last {args.days} days...") 521 + if args.geo: 522 + print(" (geo lookups enabled — teleportation detection active)") 523 + print() 524 + 525 + if args.top_ips: 526 + all_ips_seen: set[str] = set() 527 + user_ip_stats = [] 528 + for i, user in enumerate(users): 529 + uid = user["user_id"] 530 + name = user.get("friendly_name") or user.get("username") or "?" 531 + print(f" [{i+1}/{len(users)}] {name}...", end="", flush=True) 532 + try: 533 + history = get_history(base_url, args.api_key, uid, after) 534 + except Exception as e: 535 + print(f" error: {e}") 536 + continue 537 + if not history: 538 + print(" no history") 539 + continue 540 + 541 + ips: dict[str, dict] = {} 542 + for rec in history: 543 + ip = rec.get("ip_address", "") 544 + if not ip: 545 + continue 546 + if ip not in ips: 547 + ips[ip] = {"ip": ip, "plays": 0, "duration_sec": 0, "platforms": set()} 548 + ips[ip]["plays"] += 1 549 + ips[ip]["duration_sec"] += rec.get("play_duration") or rec.get("duration") or 0 550 + platform = rec.get("platform", "?") 551 + ips[ip]["platforms"].add(platform) 552 + all_ips_seen.add(ip) 553 + 554 + total_plays = sum(d["plays"] for d in ips.values()) 555 + user_ip_stats.append({"user_id": uid, "name": name, "ips": ips, "total_plays": total_plays}) 556 + print(f" {total_plays} plays, {len(ips)} IP(s)") 557 + 558 + ip_locations: dict[str, str] = {} 559 + if args.geo: 560 + print(f"\n Resolving {len(all_ips_seen)} IP(s)...") 561 + geo_cache = resolve_ips(base_url, args.api_key, all_ips_seen) 562 + ip_locations = {ip: fmt_location(g) for ip, g in geo_cache.items()} 563 + 564 + # Sort users by total watch time descending 565 + user_ip_stats.sort( 566 + key=lambda u: sum(ip["duration_sec"] for ip in u["ips"].values()), reverse=True 567 + ) 568 + print_ip_report(user_ip_stats, ip_locations) 569 + return 570 + 571 + results = [] 572 + for i, user in enumerate(users): 573 + name = user.get("friendly_name") or user.get("username") or "?" 574 + print(f" [{i+1}/{len(users)}] {name}...", end="", flush=True) 575 + try: 576 + result = analyze_user(base_url, args.api_key, user, after, args.geo) 577 + if result: 578 + results.append(result) 579 + extra = "" 580 + if result["teleportations"]: 581 + extra = f", {len(result['teleportations'])} teleportation(s)!" 582 + print(f" {result['n_devices']} devices, score {result['score']}{extra}") 583 + else: 584 + print(" skip (0-1 devices)") 585 + except Exception as e: 586 + print(f" error: {e}") 587 + 588 + print_report(results, args.min_score, args.concurrent_only, args.timeline) 589 + 590 + 591 + if __name__ == "__main__": 592 + main()