This repository has no description
0

Configure Feed

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

1import asyncio 2import json 3import logging 4import os 5import re 6import sys 7from pathlib import Path 8 9import aiohttp 10from aiohttp import web 11from nio import ( 12 AsyncClient, 13 AsyncClientConfig, 14 InviteMemberEvent, 15 LoginResponse, 16 MatrixRoom, 17 RoomMessageText, 18) 19 20logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") 21log = logging.getLogger("archiver-bot") 22 23IMDB_RE = re.compile(r"https?://(?:www\.)?imdb\.com/(?:[a-z]{2}/)?title/(tt\d+)") 24TMDB_MOVIE_RE = re.compile(r"https?://(?:www\.)?themoviedb\.org/movie/(\d+)") 25TMDB_TV_RE = re.compile(r"https?://(?:www\.)?themoviedb\.org/tv/(\d+)") 26 27JELLYSEERR_URL = os.environ.get("JELLYSEERR_URL", "http://localhost:5055") 28JELLYSEERR_API_KEY = os.environ["JELLYSEERR_API_KEY"] 29RADARR_API_KEY = os.environ.get("RADARR_API_KEY", "") 30SONARR_API_KEY = os.environ.get("SONARR_API_KEY", "") 31MATRIX_HOMESERVER = os.environ["MATRIX_HOMESERVER"] 32MATRIX_ACCESS_TOKEN = os.environ["MATRIX_ACCESS_TOKEN"] 33MATRIX_USER_ID = os.environ.get("MATRIX_USER_ID", "@archiver:lassul.us") 34MATRIX_DEVICE_ID = os.environ.get("MATRIX_DEVICE_ID", "ARCHIVER") 35STORE_PATH = os.environ.get("STORE_PATH", "/var/lib/archiver-bot") 36WEBHOOK_PORT = int(os.environ.get("WEBHOOK_PORT", "8099")) 37FLAX_URL = os.environ.get("FLAX_URL", "https://flax.lassul.us") 38FLIX_URL = os.environ.get("FLIX_URL", "https://flix.lassul.us") 39JELLYFIN_URL = os.environ.get("JELLYFIN_URL", "http://localhost:8096") 40JELLYFIN_API_KEY = os.environ.get("JELLYFIN_API_KEY", "") 41IPFS_GATEWAY_URL = os.environ.get("IPFS_GATEWAY_URL", "https://ipfs.lassul.us") 42MEDIA_PATH_PREFIX = os.environ.get("MEDIA_PATH_PREFIX", "/var/download/") 43IPFS_BIN = os.environ.get("IPFS_BIN", "ipfs") 44 45# Persistent tracking of requested media -> rooms 46TRACKING_FILE = Path(STORE_PATH) / "tracked_requests.json" 47 48 49def load_tracked() -> dict: 50 """Load tracked requests from disk. Keys are 'movie:TMDBID' or 'tv:TMDBID', values are lists of room_ids.""" 51 try: 52 return json.loads(TRACKING_FILE.read_text()) 53 except Exception: 54 return {} 55 56 57def save_tracked(tracked: dict): 58 """Save tracked requests to disk.""" 59 TRACKING_FILE.write_text(json.dumps(tracked)) 60 61 62def track_request(media_type: str, tmdb_id: int, room_id: str): 63 """Track that a room requested this media.""" 64 tracked = load_tracked() 65 key = f"{media_type}:{tmdb_id}" 66 rooms = tracked.get(key, []) 67 if room_id not in rooms: 68 rooms.append(room_id) 69 tracked[key] = rooms 70 save_tracked(tracked) 71 72 73def pop_tracked(media_type: str, tmdb_id: int) -> list[str]: 74 """Get and remove tracked rooms for this media.""" 75 tracked = load_tracked() 76 key = f"{media_type}:{tmdb_id}" 77 rooms = tracked.pop(key, []) 78 save_tracked(tracked) 79 return rooms 80 81 82def make_flax_link(path: str) -> str: 83 """Turn a local path like /var/download/movies/Title (Year)/file.mp4 into a flax.lassul.us URL.""" 84 from urllib.parse import quote 85 relative = path 86 if relative.startswith(MEDIA_PATH_PREFIX): 87 relative = relative[len(MEDIA_PATH_PREFIX):] 88 return f"{FLAX_URL}/{quote(relative)}" 89 90 91def find_main_video(path: str) -> str | None: 92 """Find the main video file in a path. If path is a file, return it. If directory, find largest video.""" 93 if os.path.isfile(path): 94 return path 95 if os.path.isdir(path): 96 video_exts = {".mkv", ".mp4", ".avi", ".m4v", ".ts", ".wmv"} 97 best = None 98 best_size = 0 99 for root, _dirs, files in os.walk(path): 100 for f in files: 101 ext = os.path.splitext(f)[1].lower() 102 if ext in video_exts: 103 full = os.path.join(root, f) 104 size = os.path.getsize(full) 105 if size > best_size: 106 best = full 107 best_size = size 108 return best 109 return None 110 111 112async def compute_cid(path: str) -> str | None: 113 """Compute the IPFS CID for a file using ipfs add --only-hash --raw-leaves.""" 114 video = find_main_video(path) 115 if not video: 116 log.warning(f"No video file found for {path}") 117 return None 118 import tempfile 119 import shutil 120 ipfs_path = tempfile.mkdtemp(prefix="ipfs-hash-") 121 try: 122 env = {**os.environ, "IPFS_PATH": ipfs_path} 123 init_proc = await asyncio.create_subprocess_exec( 124 IPFS_BIN, "init", 125 stdout=asyncio.subprocess.PIPE, 126 stderr=asyncio.subprocess.PIPE, 127 env=env, 128 ) 129 await init_proc.communicate() 130 proc = await asyncio.create_subprocess_exec( 131 IPFS_BIN, "add", "--only-hash", "--raw-leaves", "--quieter", video, 132 stdout=asyncio.subprocess.PIPE, 133 stderr=asyncio.subprocess.PIPE, 134 env=env, 135 ) 136 try: 137 stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300) 138 except asyncio.TimeoutError: 139 proc.kill() 140 await proc.communicate() 141 log.warning(f"ipfs add --only-hash timed out for {video}") 142 return None 143 if proc.returncode == 0: 144 cid = stdout.decode().strip() 145 if cid: 146 return cid 147 else: 148 log.warning(f"ipfs add --only-hash failed for {video}: {stderr.decode().strip()}") 149 except Exception as e: 150 log.warning(f"Failed to compute CID for {video}: {e}") 151 finally: 152 shutil.rmtree(ipfs_path, ignore_errors=True) 153 return None 154 155 156async def trigger_jellyfin_scan(session: aiohttp.ClientSession): 157 """Trigger a Jellyfin library scan and Jellyseerr sync.""" 158 try: 159 async with session.post( 160 f"{JELLYFIN_URL}/Library/Refresh", 161 headers={"X-Emby-Token": JELLYFIN_API_KEY}, 162 ) as resp: 163 log.info(f"Triggered Jellyfin library scan: {resp.status}") 164 # also trigger Jellyseerr sync so it picks up the new jellyfinMediaId 165 await jellyseerr_request(session, "/settings/jellyfin/sync", method="POST") 166 log.info("Triggered Jellyseerr sync") 167 except Exception as e: 168 log.warning(f"Failed to trigger library scan: {e}") 169 170 171async def lookup_jellyfin_id(session: aiohttp.ClientSession, media_type: str, tmdb_id: int) -> str | None: 172 """Look up Jellyfin item ID via Jellyseerr's mediaInfo or Jellyfin API.""" 173 # Try Jellyseerr first — it tracks the Jellyfin media ID 174 # Try Jellyseerr first 175 try: 176 endpoint = f"/movie/{tmdb_id}" if media_type == "movie" else f"/tv/{tmdb_id}" 177 details = await jellyseerr_request(session, endpoint) 178 media_info = details.get("mediaInfo") 179 if media_info: 180 jf_id = media_info.get("jellyfinMediaId") 181 if jf_id: 182 log.info(f"Jellyfin ID from Jellyseerr for {media_type}:{tmdb_id} = {jf_id}") 183 return jf_id 184 except Exception as e: 185 log.warning(f"Jellyseerr Jellyfin ID lookup failed for {media_type}:{tmdb_id}: {e}") 186 187 # Fallback: query Jellyfin directly, searching by provider ID 188 if JELLYFIN_API_KEY: 189 try: 190 item_type = "Movie" if media_type == "movie" else "Series" 191 async with session.get( 192 f"{JELLYFIN_URL}/Items", 193 params={ 194 "IncludeItemTypes": item_type, 195 "Recursive": "true", 196 "Fields": "ProviderIds", 197 }, 198 headers={"X-Emby-Token": JELLYFIN_API_KEY}, 199 ) as resp: 200 data = await resp.json() 201 for item in data.get("Items", []): 202 provider_ids = item.get("ProviderIds", {}) 203 if str(provider_ids.get("Tmdb")) == str(tmdb_id): 204 jf_id = item.get("Id") 205 log.info(f"Jellyfin ID from Jellyfin API for {media_type}:{tmdb_id} = {jf_id}") 206 return jf_id 207 except Exception as e: 208 log.warning(f"Jellyfin API lookup failed for {media_type}:{tmdb_id}: {e}") 209 210 log.info(f"No Jellyfin ID found for {media_type}:{tmdb_id}") 211 return None 212 213 214async def make_media_links(path: str, is_dir: bool = False, jellyfin_id: str | None = None) -> tuple[str, str]: 215 """Generate plain and HTML text with all available links for a media path.""" 216 from urllib.parse import quote 217 218 lines_plain = [] 219 lines_html = [] 220 221 # Flax link (direct file download) 222 flax = make_flax_link(path) 223 lines_plain.append(f"📁 Download: {flax}") 224 lines_html.append(f'📁 Download: <a href="{flax}">{flax}</a>') 225 226 # Jellyfin link (streaming) 227 if jellyfin_id: 228 flix_link = f"{FLIX_URL}/web/#/details?id={jellyfin_id}" 229 else: 230 flix_link = FLIX_URL 231 lines_plain.append(f"🎬 Stream: {flix_link}") 232 lines_html.append(f'🎬 Stream: <a href="{flix_link}">{flix_link}</a>') 233 234 # IPFS link (compute CID for the file, retry up to 3 times) 235 cid = None 236 for attempt in range(3): 237 cid = await compute_cid(path) 238 if cid: 239 break 240 log.warning(f"IPFS hash attempt {attempt + 1}/3 failed for {path}") 241 await asyncio.sleep(2) 242 243 if cid: 244 ipfs_link = f"{IPFS_GATEWAY_URL}/ipfs/{cid}" 245 lines_plain.append(f"🌐 IPFS: {ipfs_link}") 246 lines_html.append(f'🌐 IPFS: <a href="{ipfs_link}">{ipfs_link}</a>') 247 lines_plain.append(f" ipfs get {cid}") 248 lines_html.append(f" <code>ipfs get {cid}</code>") 249 else: 250 log.error(f"IPFS hash failed after 3 attempts for {path}, not posting message") 251 return None 252 253 return ("\n".join(lines_plain), "<br>".join(lines_html)) 254 255 256async def jellyseerr_request(session: aiohttp.ClientSession, path: str, **kwargs) -> dict: 257 """Make an authenticated request to Jellyseerr.""" 258 headers = { 259 "X-Api-Key": JELLYSEERR_API_KEY, 260 "Content-Type": "application/json", 261 "Accept": "application/json", 262 } 263 method = kwargs.pop("method", "GET") 264 url = f"{JELLYSEERR_URL}/api/v1{path}" 265 async with session.request(method, url, headers=headers, **kwargs) as resp: 266 return await resp.json() 267 268 269async def find_by_imdb(session: aiohttp.ClientSession, imdb_id: str) -> dict | None: 270 """Look up a movie or TV show by IMDb ID via Radarr/Sonarr, returning Jellyseerr-compatible data.""" 271 272 # Try Radarr (movies) first 273 try: 274 async with session.get( 275 f"http://localhost:7878/api/v3/movie/lookup?term=imdb:{imdb_id}", 276 headers={"X-Api-Key": RADARR_API_KEY}, 277 ) as resp: 278 results = await resp.json() 279 if results and len(results) > 0: 280 tmdb_id = results[0].get("tmdbId") 281 title = results[0].get("title", "Unknown") 282 year = str(results[0].get("year", "")) 283 if tmdb_id: 284 log.info(f"Radarr resolved {imdb_id} -> TMDB {tmdb_id} ({title})") 285 return { 286 "id": tmdb_id, 287 "mediaType": "movie", 288 "title": title, 289 "releaseDate": f"{year}-01-01" if year else "", 290 } 291 except Exception as e: 292 log.warning(f"Radarr lookup failed: {e}") 293 294 # Try Sonarr (TV shows) 295 try: 296 async with session.get( 297 f"http://localhost:8989/api/v3/series/lookup?term=imdb:{imdb_id}", 298 headers={"X-Api-Key": SONARR_API_KEY}, 299 ) as resp: 300 results = await resp.json() 301 if results and len(results) > 0: 302 tmdb_id = results[0].get("tmdbId", 0) 303 title = results[0].get("title", "Unknown") 304 year = str(results[0].get("year", "")) 305 if tmdb_id: 306 log.info(f"Sonarr resolved {imdb_id} -> TMDB {tmdb_id} ({title})") 307 return { 308 "id": tmdb_id, 309 "mediaType": "tv", 310 "name": title, 311 "firstAirDate": f"{year}-01-01" if year else "", 312 } 313 except Exception as e: 314 log.warning(f"Sonarr lookup failed: {e}") 315 316 return None 317 318 319async def lookup_media_path(session: aiohttp.ClientSession, media_type: str, tmdb_id: int) -> str | None: 320 """Look up the path for a movie file or show folder from Radarr/Sonarr by TMDB ID.""" 321 try: 322 if media_type == "movie": 323 async with session.get( 324 f"http://localhost:7878/api/v3/movie?tmdbId={tmdb_id}", 325 headers={"X-Api-Key": RADARR_API_KEY}, 326 ) as resp: 327 results = await resp.json() 328 if results and len(results) > 0: 329 movie = results[0] 330 if not movie.get("hasFile"): 331 return None 332 movie_file = movie.get("movieFile", {}) 333 if movie_file and movie_file.get("path"): 334 return movie_file["path"] 335 return None 336 else: 337 async with session.get( 338 f"http://localhost:8989/api/v3/series?tvdbId={tmdb_id}", 339 headers={"X-Api-Key": SONARR_API_KEY}, 340 ) as resp: 341 results = await resp.json() 342 if results and len(results) > 0: 343 return results[0].get("path") 344 except Exception as e: 345 log.warning(f"Media path lookup failed for {media_type}:{tmdb_id}: {e}") 346 return None 347 348 349async def request_media(session: aiohttp.ClientSession, media: dict, room_id: str) -> tuple[str, str]: 350 """Request a movie or TV show via Jellyseerr. Returns (plain, html) status message.""" 351 media_type = media["mediaType"] 352 media_id = media["id"] 353 title = media.get("title") or media.get("name") or "Unknown" 354 year = (media.get("releaseDate") or media.get("firstAirDate") or "")[:4] 355 display = f"{title} ({year})" if year else title 356 357 # Check if already available or requested 358 existing = media.get("mediaInfo") 359 if existing: 360 status = existing.get("status") 361 # status 5 = available, 4 = partially available, 3 = processing, 2 = pending 362 if status == 5: 363 path = await lookup_media_path(session, media_type, media_id) 364 if path: 365 is_dir = media_type == "tv" 366 jf_id = await lookup_jellyfin_id(session, media_type, media_id) 367 links = await make_media_links(path, is_dir=is_dir, jellyfin_id=jf_id) 368 if not links: 369 log.error(f"IPFS hash failed for {display}, not posting"); return None 370 links_plain, links_html = links 371 return (f"✅ {display} is already available!\n{links_plain}", f"✅ <b>{display}</b> is already available!<br>{links_html}") 372 return (f"✅ {display} is already available!", f"✅ <b>{display}</b> is already available!") 373 elif status in (2, 3, 4): 374 track_request(media_type, media_id, room_id) 375 return (f"⏳ {display} has already been requested and is being processed.", f"⏳ <b>{display}</b> has already been requested and is being processed.") 376 377 payload = {"mediaType": media_type, "mediaId": media_id} 378 if media_type == "tv": 379 payload["seasons"] = "all" 380 381 # Check if Radarr/Sonarr already has the file before requesting 382 existing_path = await lookup_media_path(session, media_type, media_id) 383 if existing_path: 384 is_dir = media_type == "tv" 385 jf_id = await lookup_jellyfin_id(session, media_type, media_id) 386 links = await make_media_links(existing_path, is_dir=is_dir, jellyfin_id=jf_id) 387 # Still send the request to Jellyseerr so it's tracked there too 388 await jellyseerr_request(session, "/request", method="POST", json=payload) 389 if not links: 390 log.error(f"IPFS hash failed for {display}, not posting"); return None 391 links_plain, links_html = links 392 return (f"✅ {display} is already available!\n{links_plain}", f"✅ <b>{display}</b> is already available!<br>{links_html}") 393 394 result = await jellyseerr_request(session, "/request", method="POST", json=payload) 395 396 if "id" in result: 397 track_request(media_type, media_id, room_id) 398 return (f"🎬 Successfully requested {display}! I'll notify you when it's downloaded.", f"🎬 Successfully requested <b>{display}</b>! I'll notify you when it's downloaded.") 399 else: 400 error = result.get("message", json.dumps(result)) 401 return (f"❌ Failed to request {display}: {error}", f"❌ Failed to request <b>{display}</b>: {error}") 402 403 404async def trust_room_devices(client: AsyncClient, room_id: str): 405 """Trust all devices of all members in a room.""" 406 if not client.olm: 407 return 408 room = client.rooms.get(room_id) 409 if not room: 410 return 411 for user_id in room.users: 412 for device in client.device_store.active_user_devices(user_id): 413 if not client.olm.is_device_verified(device): 414 client.verify_device(device) 415 log.info(f"Verified device {device.device_id} of {user_id}") 416 417 418async def send_notice(client: AsyncClient, room_id: str, plain: str, html: str): 419 """Send a notice message to a room.""" 420 await trust_room_devices(client, room_id) 421 await client.room_send( 422 room_id, 423 "m.room.message", 424 {"msgtype": "m.notice", "body": plain, "format": "org.matrix.custom.html", "formatted_body": html}, 425 ) 426 427 428async def on_message(client: AsyncClient, room: MatrixRoom, event: RoomMessageText, http: aiohttp.ClientSession): 429 """Handle messages in rooms, looking for IMDb/TMDB links.""" 430 # Ignore our own messages 431 if event.sender == client.user_id: 432 return 433 434 body = event.body 435 log.info(f"Message from {event.sender} in {room.display_name}: {body[:100]}") 436 437 # Help command 438 if body.strip().lower() in ("help", "!help"): 439 plain = ( 440 "Archiver Bot\n\n" 441 "Send me an IMDb or TMDB link and I'll request the movie/show on Jellyseerr.\n" 442 "I'll notify you when the download is complete.\n\n" 443 "Examples:\n" 444 " https://www.imdb.com/title/tt0133093/\n" 445 " https://www.themoviedb.org/movie/603\n" 446 " https://www.themoviedb.org/tv/1399" 447 ) 448 html = ( 449 "<b>Archiver Bot</b><br><br>" 450 "Send me an IMDb or TMDB link and I'll request the movie/show on Jellyseerr.<br>" 451 "I'll notify you when the download is complete.<br><br>" 452 "Examples:<br>" 453 "<code>https://www.imdb.com/title/tt0133093/</code><br>" 454 "<code>https://www.themoviedb.org/movie/603</code><br>" 455 "<code>https://www.themoviedb.org/tv/1399</code>" 456 ) 457 await send_notice(client, room.room_id, plain, html) 458 return 459 460 # Collect all media references from the message 461 results = [] 462 463 for imdb_id in set(IMDB_RE.findall(body)): 464 log.info(f"Found IMDb link {imdb_id} in {room.display_name} from {event.sender}") 465 media = await find_by_imdb(http, imdb_id) 466 if media is None: 467 results.append((f"❓ Could not find anything for {imdb_id}.", f"❓ Could not find anything for <code>{imdb_id}</code>.")) 468 else: 469 results.append(await request_media(http, media, room.room_id)) 470 471 for tmdb_id in set(TMDB_MOVIE_RE.findall(body)): 472 log.info(f"Found TMDB movie link {tmdb_id} in {room.display_name} from {event.sender}") 473 media = {"id": int(tmdb_id), "mediaType": "movie"} 474 try: 475 details = await jellyseerr_request(http, f"/movie/{tmdb_id}") 476 media["title"] = details.get("title", "Unknown") 477 media["releaseDate"] = details.get("releaseDate", "") 478 media["mediaInfo"] = details.get("mediaInfo") 479 except Exception as e: 480 log.warning(f"Failed to fetch TMDB movie {tmdb_id}: {e}") 481 results.append(await request_media(http, media, room.room_id)) 482 483 for tmdb_id in set(TMDB_TV_RE.findall(body)): 484 log.info(f"Found TMDB TV link {tmdb_id} in {room.display_name} from {event.sender}") 485 media = {"id": int(tmdb_id), "mediaType": "tv"} 486 try: 487 details = await jellyseerr_request(http, f"/tv/{tmdb_id}") 488 media["name"] = details.get("name", "Unknown") 489 media["firstAirDate"] = details.get("firstAirDate", "") 490 media["mediaInfo"] = details.get("mediaInfo") 491 except Exception as e: 492 log.warning(f"Failed to fetch TMDB TV {tmdb_id}: {e}") 493 results.append(await request_media(http, media, room.room_id)) 494 495 for result in results: 496 if result is not None: 497 plain, html = result 498 await send_notice(client, room.room_id, plain, html) 499 500 501async def on_invite(client: AsyncClient, room: MatrixRoom, event: InviteMemberEvent): 502 """Auto-join rooms when invited.""" 503 log.info(f"Invite event in {room.room_id}: state_key={event.state_key}, sender={event.sender}, membership={event.membership}, our_id={client.user_id}") 504 if event.state_key != client.user_id: 505 return 506 log.info(f"Joining {room.room_id} (invited by {event.sender})...") 507 result = await client.join(room.room_id) 508 log.info(f"Join result for {room.room_id}: {result}") 509 510 511async def handle_radarr_webhook(request): 512 """Handle Radarr webhook for download completion.""" 513 try: 514 data = await request.json() 515 event_type = data.get("eventType", "") 516 log.info(f"Radarr webhook: {event_type}") 517 518 if event_type in ("Download", "MovieFileDelete"): 519 movie = data.get("movie", {}) 520 tmdb_id = movie.get("tmdbId") 521 title = movie.get("title", "Unknown") 522 year = movie.get("year", "") 523 display = f"{title} ({year})" if year else title 524 # Prefer the actual movie file path, fall back to folder 525 movie_file = data.get("movieFile", {}) 526 file_path = movie_file.get("path") or movie.get("folderPath", "") 527 528 if event_type == "Download" and tmdb_id: 529 rooms = pop_tracked("movie", tmdb_id) 530 if rooms: 531 client = request.app["matrix_client"] 532 http = request.app["http_session"] 533 # trigger library scan so Jellyfin picks up the new file 534 await trigger_jellyfin_scan(http) 535 await asyncio.sleep(10) 536 jf_id = await lookup_jellyfin_id(http, "movie", tmdb_id) 537 links = await make_media_links(file_path, jellyfin_id=jf_id) 538 if not links: 539 log.error(f"IPFS hash failed for {display}, not notifying") 540 return web.Response(text="ok") 541 links_plain, links_html = links 542 plain = f"✅ {display} has been downloaded and is now available!\n{links_plain}" 543 html = f"✅ <b>{display}</b> has been downloaded and is now available!<br>{links_html}" 544 for room_id in rooms: 545 try: 546 await send_notice(client, room_id, plain, html) 547 except Exception as e: 548 log.warning(f"Failed to notify room {room_id}: {e}") 549 except Exception as e: 550 log.exception(f"Error handling Radarr webhook: {e}") 551 552 return web.Response(text="ok") 553 554 555async def handle_sonarr_webhook(request): 556 """Handle Sonarr webhook for download completion.""" 557 try: 558 data = await request.json() 559 event_type = data.get("eventType", "") 560 log.info(f"Sonarr webhook: {event_type}") 561 562 if event_type in ("Download", "EpisodeFileDelete"): 563 series = data.get("series", {}) 564 tmdb_id = series.get("tmdbId") 565 title = series.get("title", "Unknown") 566 year = series.get("year", "") 567 display = f"{title} ({year})" if year else title 568 569 episodes = data.get("episodes", []) 570 ep_info = "" 571 if episodes: 572 ep_nums = [f"S{ep.get('seasonNumber', 0):02d}E{ep.get('episodeNumber', 0):02d}" for ep in episodes] 573 ep_info = f" ({', '.join(ep_nums)})" 574 575 if event_type == "Download" and tmdb_id: 576 rooms = pop_tracked("tv", tmdb_id) 577 if rooms: 578 client = request.app["matrix_client"] 579 http = request.app["http_session"] 580 folder_path = series.get("path", "") 581 # trigger library scan so Jellyfin picks up the new file 582 await trigger_jellyfin_scan(http) 583 await asyncio.sleep(10) 584 jf_id = await lookup_jellyfin_id(http, "tv", tmdb_id) 585 links = await make_media_links(folder_path, is_dir=True, jellyfin_id=jf_id) 586 if not links: 587 log.error(f"IPFS hash failed for {display}, not notifying") 588 return web.Response(text="ok") 589 links_plain, links_html = links 590 plain = f"✅ {display}{ep_info} has been downloaded and is now available!\n{links_plain}" 591 html = f"✅ <b>{display}</b>{ep_info} has been downloaded and is now available!<br>{links_html}" 592 for room_id in rooms: 593 try: 594 await send_notice(client, room_id, plain, html) 595 except Exception as e: 596 log.warning(f"Failed to notify room {room_id}: {e}") 597 except Exception as e: 598 log.exception(f"Error handling Sonarr webhook: {e}") 599 600 return web.Response(text="ok") 601 602 603async def run(): 604 """Main bot loop.""" 605 client_config = AsyncClientConfig( 606 store_sync_tokens=True, 607 encryption_enabled=True, 608 ) 609 client = AsyncClient( 610 MATRIX_HOMESERVER, 611 MATRIX_USER_ID, 612 device_id=MATRIX_DEVICE_ID, 613 store_path=STORE_PATH, 614 config=client_config, 615 ) 616 client.access_token = MATRIX_ACCESS_TOKEN 617 client.user_id = MATRIX_USER_ID 618 client.load_store() 619 log.info(f"Crypto store loaded, olm={client.olm is not None}") 620 621 http = aiohttp.ClientSession() 622 623 # Do an initial sync BEFORE registering callbacks to skip old messages 624 log.info("Performing initial sync...") 625 resp = await client.sync(timeout=10000, full_state=True) 626 if hasattr(resp, "next_batch"): 627 log.info(f"Initial sync complete, next_batch: {resp.next_batch}") 628 else: 629 log.error(f"Initial sync failed: {resp}") 630 await http.close() 631 await client.close() 632 sys.exit(1) 633 634 # E2EE setup 635 if client.olm: 636 try: 637 log.info("Uploading device keys...") 638 key_upload = await client.keys_upload() 639 log.info(f"Key upload result: {key_upload}") 640 except Exception as e: 641 log.info(f"Key upload skipped: {e}") 642 643 # Trust all known device keys so we can decrypt messages 644 for user_id in client.device_store.users: 645 for device in client.device_store.active_user_devices(user_id): 646 if not client.olm.is_device_verified(device): 647 client.verify_device(device) 648 log.info(f"Verified device {device.device_id} of {user_id}") 649 else: 650 log.warning("Olm not available after sync — E2EE will not work") 651 652 # Register event callbacks only after initial sync 653 client.add_event_callback(lambda room, event: on_message(client, room, event, http), RoomMessageText) 654 client.add_event_callback(lambda room, event: on_invite(client, room, event), InviteMemberEvent) 655 656 # Start webhook server for Radarr/Sonarr notifications 657 app = web.Application() 658 app["matrix_client"] = client 659 app["http_session"] = http 660 app.router.add_post("/webhook/radarr", handle_radarr_webhook) 661 app.router.add_post("/webhook/sonarr", handle_sonarr_webhook) 662 runner = web.AppRunner(app) 663 await runner.setup() 664 site = web.TCPSite(runner, "127.0.0.1", WEBHOOK_PORT) 665 await site.start() 666 log.info(f"Webhook server listening on 127.0.0.1:{WEBHOOK_PORT}") 667 668 log.info(f"Bot {MATRIX_USER_ID} is running!") 669 670 try: 671 await client.sync_forever(timeout=30000, full_state=True) 672 except Exception: 673 log.exception("sync_forever crashed") 674 finally: 675 await runner.cleanup() 676 await http.close() 677 await client.close() 678 679 680def main(): 681 asyncio.run(run())