This repository has no description
0

Configure Feed

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

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