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 InviteMemberEvent, 14 LoginResponse, 15 MatrixRoom, 16 RoomMessageText, 17) 18 19logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") 20log = logging.getLogger("archiver-bot") 21 22IMDB_RE = re.compile(r"https?://(?:www\.)?imdb\.com/title/(tt\d+)") 23TMDB_MOVIE_RE = re.compile(r"https?://(?:www\.)?themoviedb\.org/movie/(\d+)") 24TMDB_TV_RE = re.compile(r"https?://(?:www\.)?themoviedb\.org/tv/(\d+)") 25 26JELLYSEERR_URL = os.environ.get("JELLYSEERR_URL", "http://localhost:5055") 27JELLYSEERR_API_KEY = os.environ["JELLYSEERR_API_KEY"] 28RADARR_API_KEY = os.environ.get("RADARR_API_KEY", "") 29SONARR_API_KEY = os.environ.get("SONARR_API_KEY", "") 30MATRIX_HOMESERVER = os.environ["MATRIX_HOMESERVER"] 31MATRIX_ACCESS_TOKEN = os.environ["MATRIX_ACCESS_TOKEN"] 32MATRIX_USER_ID = os.environ.get("MATRIX_USER_ID", "@archiver:lassul.us") 33MATRIX_DEVICE_ID = os.environ.get("MATRIX_DEVICE_ID", "ARCHIVER") 34STORE_PATH = os.environ.get("STORE_PATH", "/var/lib/archiver-bot") 35WEBHOOK_PORT = int(os.environ.get("WEBHOOK_PORT", "8099")) 36FLAX_URL = os.environ.get("FLAX_URL", "https://flax.lassul.us") 37MEDIA_PATH_PREFIX = os.environ.get("MEDIA_PATH_PREFIX", "/var/download/") 38 39# Persistent tracking of requested media -> rooms 40TRACKING_FILE = Path(STORE_PATH) / "tracked_requests.json" 41 42 43def load_tracked() -> dict: 44 """Load tracked requests from disk. Keys are 'movie:TMDBID' or 'tv:TMDBID', values are lists of room_ids.""" 45 try: 46 return json.loads(TRACKING_FILE.read_text()) 47 except Exception: 48 return {} 49 50 51def save_tracked(tracked: dict): 52 """Save tracked requests to disk.""" 53 TRACKING_FILE.write_text(json.dumps(tracked)) 54 55 56def track_request(media_type: str, tmdb_id: int, room_id: str): 57 """Track that a room requested this media.""" 58 tracked = load_tracked() 59 key = f"{media_type}:{tmdb_id}" 60 rooms = tracked.get(key, []) 61 if room_id not in rooms: 62 rooms.append(room_id) 63 tracked[key] = rooms 64 save_tracked(tracked) 65 66 67def pop_tracked(media_type: str, tmdb_id: int) -> list[str]: 68 """Get and remove tracked rooms for this media.""" 69 tracked = load_tracked() 70 key = f"{media_type}:{tmdb_id}" 71 rooms = tracked.pop(key, []) 72 save_tracked(tracked) 73 return rooms 74 75 76def make_flax_link(path: str) -> str: 77 """Turn a local path like /var/download/movies/Title (Year)/file.mp4 into a flax.lassul.us URL.""" 78 from urllib.parse import quote 79 relative = path 80 if relative.startswith(MEDIA_PATH_PREFIX): 81 relative = relative[len(MEDIA_PATH_PREFIX):] 82 return f"{FLAX_URL}/{quote(relative)}" 83 84 85async def jellyseerr_request(session: aiohttp.ClientSession, path: str, **kwargs) -> dict: 86 """Make an authenticated request to Jellyseerr.""" 87 headers = { 88 "X-Api-Key": JELLYSEERR_API_KEY, 89 "Content-Type": "application/json", 90 "Accept": "application/json", 91 } 92 method = kwargs.pop("method", "GET") 93 url = f"{JELLYSEERR_URL}/api/v1{path}" 94 async with session.request(method, url, headers=headers, **kwargs) as resp: 95 return await resp.json() 96 97 98async def find_by_imdb(session: aiohttp.ClientSession, imdb_id: str) -> dict | None: 99 """Look up a movie or TV show by IMDb ID via Radarr/Sonarr, returning Jellyseerr-compatible data.""" 100 101 # Try Radarr (movies) first 102 try: 103 async with session.get( 104 f"http://localhost:7878/api/v3/movie/lookup?term=imdb:{imdb_id}", 105 headers={"X-Api-Key": RADARR_API_KEY}, 106 ) as resp: 107 results = await resp.json() 108 if results and len(results) > 0: 109 tmdb_id = results[0].get("tmdbId") 110 title = results[0].get("title", "Unknown") 111 year = str(results[0].get("year", "")) 112 if tmdb_id: 113 log.info(f"Radarr resolved {imdb_id} -> TMDB {tmdb_id} ({title})") 114 return { 115 "id": tmdb_id, 116 "mediaType": "movie", 117 "title": title, 118 "releaseDate": f"{year}-01-01" if year else "", 119 } 120 except Exception as e: 121 log.warning(f"Radarr lookup failed: {e}") 122 123 # Try Sonarr (TV shows) 124 try: 125 async with session.get( 126 f"http://localhost:8989/api/v3/series/lookup?term=imdb:{imdb_id}", 127 headers={"X-Api-Key": SONARR_API_KEY}, 128 ) as resp: 129 results = await resp.json() 130 if results and len(results) > 0: 131 tmdb_id = results[0].get("tmdbId", 0) 132 title = results[0].get("title", "Unknown") 133 year = str(results[0].get("year", "")) 134 if tmdb_id: 135 log.info(f"Sonarr resolved {imdb_id} -> TMDB {tmdb_id} ({title})") 136 return { 137 "id": tmdb_id, 138 "mediaType": "tv", 139 "name": title, 140 "firstAirDate": f"{year}-01-01" if year else "", 141 } 142 except Exception as e: 143 log.warning(f"Sonarr lookup failed: {e}") 144 145 return None 146 147 148async def lookup_media_path(session: aiohttp.ClientSession, media_type: str, tmdb_id: int) -> str | None: 149 """Look up the path for a movie file or show folder from Radarr/Sonarr by TMDB ID.""" 150 try: 151 if media_type == "movie": 152 async with session.get( 153 f"http://localhost:7878/api/v3/movie?tmdbId={tmdb_id}", 154 headers={"X-Api-Key": RADARR_API_KEY}, 155 ) as resp: 156 results = await resp.json() 157 if results and len(results) > 0: 158 # Return direct file path if available, otherwise folder 159 movie_file = results[0].get("movieFile", {}) 160 if movie_file and movie_file.get("path"): 161 return movie_file["path"] 162 return results[0].get("path") or results[0].get("folderName") 163 else: 164 async with session.get( 165 f"http://localhost:8989/api/v3/series?tvdbId={tmdb_id}", 166 headers={"X-Api-Key": SONARR_API_KEY}, 167 ) as resp: 168 results = await resp.json() 169 if results and len(results) > 0: 170 return results[0].get("path") 171 except Exception as e: 172 log.warning(f"Media path lookup failed for {media_type}:{tmdb_id}: {e}") 173 return None 174 175 176async def request_media(session: aiohttp.ClientSession, media: dict, room_id: str) -> tuple[str, str]: 177 """Request a movie or TV show via Jellyseerr. Returns (plain, html) status message.""" 178 media_type = media["mediaType"] 179 media_id = media["id"] 180 title = media.get("title") or media.get("name") or "Unknown" 181 year = (media.get("releaseDate") or media.get("firstAirDate") or "")[:4] 182 display = f"{title} ({year})" if year else title 183 184 # Check if already available or requested 185 existing = media.get("mediaInfo") 186 if existing: 187 status = existing.get("status") 188 # status 5 = available, 4 = partially available, 3 = processing, 2 = pending 189 if status == 5: 190 path = await lookup_media_path(session, media_type, media_id) 191 if path: 192 link = make_flax_link(path) 193 return (f"✅ {display} is already available!\n{link}", f'✅ <b>{display}</b> is already available!<br><a href="{link}">{link}</a>') 194 return (f"✅ {display} is already available!", f"✅ <b>{display}</b> is already available!") 195 elif status in (2, 3, 4): 196 track_request(media_type, media_id, room_id) 197 return (f"⏳ {display} has already been requested and is being processed.", f"⏳ <b>{display}</b> has already been requested and is being processed.") 198 199 payload = {"mediaType": media_type, "mediaId": media_id} 200 if media_type == "tv": 201 payload["seasons"] = "all" 202 203 # Check if Radarr/Sonarr already has the file before requesting 204 existing_path = await lookup_media_path(session, media_type, media_id) 205 if existing_path: 206 link = make_flax_link(existing_path) 207 # Still send the request to Jellyseerr so it's tracked there too 208 await jellyseerr_request(session, "/request", method="POST", json=payload) 209 return (f"✅ {display} is already available!\n{link}", f'✅ <b>{display}</b> is already available!<br><a href="{link}">{link}</a>') 210 211 result = await jellyseerr_request(session, "/request", method="POST", json=payload) 212 213 if "id" in result: 214 track_request(media_type, media_id, room_id) 215 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.") 216 else: 217 error = result.get("message", json.dumps(result)) 218 return (f"❌ Failed to request {display}: {error}", f"❌ Failed to request <b>{display}</b>: {error}") 219 220 221async def send_notice(client: AsyncClient, room_id: str, plain: str, html: str): 222 """Send a notice message to a room.""" 223 await client.room_send( 224 room_id, 225 "m.room.message", 226 {"msgtype": "m.notice", "body": plain, "format": "org.matrix.custom.html", "formatted_body": html}, 227 ) 228 229 230async def on_message(client: AsyncClient, room: MatrixRoom, event: RoomMessageText, http: aiohttp.ClientSession): 231 """Handle messages in rooms, looking for IMDb/TMDB links.""" 232 # Ignore our own messages 233 if event.sender == client.user_id: 234 return 235 236 body = event.body 237 log.info(f"Message from {event.sender} in {room.display_name}: {body[:100]}") 238 239 # Help command 240 if body.strip().lower() in ("help", "!help"): 241 plain = ( 242 "Archiver Bot\n\n" 243 "Send me an IMDb or TMDB link and I'll request the movie/show on Jellyseerr.\n" 244 "I'll notify you when the download is complete.\n\n" 245 "Examples:\n" 246 " https://www.imdb.com/title/tt0133093/\n" 247 " https://www.themoviedb.org/movie/603\n" 248 " https://www.themoviedb.org/tv/1399" 249 ) 250 html = ( 251 "<b>Archiver Bot</b><br><br>" 252 "Send me an IMDb or TMDB link and I'll request the movie/show on Jellyseerr.<br>" 253 "I'll notify you when the download is complete.<br><br>" 254 "Examples:<br>" 255 "<code>https://www.imdb.com/title/tt0133093/</code><br>" 256 "<code>https://www.themoviedb.org/movie/603</code><br>" 257 "<code>https://www.themoviedb.org/tv/1399</code>" 258 ) 259 await send_notice(client, room.room_id, plain, html) 260 return 261 262 # Collect all media references from the message 263 results = [] 264 265 for imdb_id in set(IMDB_RE.findall(body)): 266 log.info(f"Found IMDb link {imdb_id} in {room.display_name} from {event.sender}") 267 media = await find_by_imdb(http, imdb_id) 268 if media is None: 269 results.append((f"❓ Could not find anything for {imdb_id}.", f"❓ Could not find anything for <code>{imdb_id}</code>.")) 270 else: 271 results.append(await request_media(http, media, room.room_id)) 272 273 for tmdb_id in set(TMDB_MOVIE_RE.findall(body)): 274 log.info(f"Found TMDB movie link {tmdb_id} in {room.display_name} from {event.sender}") 275 media = {"id": int(tmdb_id), "mediaType": "movie"} 276 try: 277 details = await jellyseerr_request(http, f"/movie/{tmdb_id}") 278 media["title"] = details.get("title", "Unknown") 279 media["releaseDate"] = details.get("releaseDate", "") 280 media["mediaInfo"] = details.get("mediaInfo") 281 except Exception as e: 282 log.warning(f"Failed to fetch TMDB movie {tmdb_id}: {e}") 283 results.append(await request_media(http, media, room.room_id)) 284 285 for tmdb_id in set(TMDB_TV_RE.findall(body)): 286 log.info(f"Found TMDB TV link {tmdb_id} in {room.display_name} from {event.sender}") 287 media = {"id": int(tmdb_id), "mediaType": "tv"} 288 try: 289 details = await jellyseerr_request(http, f"/tv/{tmdb_id}") 290 media["name"] = details.get("name", "Unknown") 291 media["firstAirDate"] = details.get("firstAirDate", "") 292 media["mediaInfo"] = details.get("mediaInfo") 293 except Exception as e: 294 log.warning(f"Failed to fetch TMDB TV {tmdb_id}: {e}") 295 results.append(await request_media(http, media, room.room_id)) 296 297 for plain, html in results: 298 await send_notice(client, room.room_id, plain, html) 299 300 301async def on_invite(client: AsyncClient, room: MatrixRoom, event: InviteMemberEvent): 302 """Auto-join rooms when invited.""" 303 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}") 304 if event.state_key != client.user_id: 305 return 306 log.info(f"Joining {room.room_id} (invited by {event.sender})...") 307 result = await client.join(room.room_id) 308 log.info(f"Join result for {room.room_id}: {result}") 309 310 311async def handle_radarr_webhook(request): 312 """Handle Radarr webhook for download completion.""" 313 try: 314 data = await request.json() 315 event_type = data.get("eventType", "") 316 log.info(f"Radarr webhook: {event_type}") 317 318 if event_type in ("Download", "MovieFileDelete"): 319 movie = data.get("movie", {}) 320 tmdb_id = movie.get("tmdbId") 321 title = movie.get("title", "Unknown") 322 year = movie.get("year", "") 323 display = f"{title} ({year})" if year else title 324 # Prefer the actual movie file path, fall back to folder 325 movie_file = data.get("movieFile", {}) 326 file_path = movie_file.get("path") or movie.get("folderPath", "") 327 328 if event_type == "Download" and tmdb_id: 329 rooms = pop_tracked("movie", tmdb_id) 330 if rooms: 331 client = request.app["matrix_client"] 332 link = make_flax_link(file_path) 333 plain = f"✅ {display} has been downloaded and is now available!\n{link}" 334 html = f'✅ <b>{display}</b> has been downloaded and is now available!<br><a href="{link}">{link}</a>' 335 for room_id in rooms: 336 try: 337 await send_notice(client, room_id, plain, html) 338 except Exception as e: 339 log.warning(f"Failed to notify room {room_id}: {e}") 340 except Exception as e: 341 log.exception(f"Error handling Radarr webhook: {e}") 342 343 return web.Response(text="ok") 344 345 346async def handle_sonarr_webhook(request): 347 """Handle Sonarr webhook for download completion.""" 348 try: 349 data = await request.json() 350 event_type = data.get("eventType", "") 351 log.info(f"Sonarr webhook: {event_type}") 352 353 if event_type in ("Download", "EpisodeFileDelete"): 354 series = data.get("series", {}) 355 tmdb_id = series.get("tmdbId") 356 title = series.get("title", "Unknown") 357 year = series.get("year", "") 358 display = f"{title} ({year})" if year else title 359 360 episodes = data.get("episodes", []) 361 ep_info = "" 362 if episodes: 363 ep_nums = [f"S{ep.get('seasonNumber', 0):02d}E{ep.get('episodeNumber', 0):02d}" for ep in episodes] 364 ep_info = f" ({', '.join(ep_nums)})" 365 366 if event_type == "Download" and tmdb_id: 367 rooms = pop_tracked("tv", tmdb_id) 368 if rooms: 369 client = request.app["matrix_client"] 370 folder_path = series.get("path", "") 371 link = make_flax_link(folder_path) 372 plain = f"✅ {display}{ep_info} has been downloaded and is now available!\n{link}" 373 html = f'✅ <b>{display}</b>{ep_info} has been downloaded and is now available!<br><a href="{link}">{link}</a>' 374 for room_id in rooms: 375 try: 376 await send_notice(client, room_id, plain, html) 377 except Exception as e: 378 log.warning(f"Failed to notify room {room_id}: {e}") 379 except Exception as e: 380 log.exception(f"Error handling Sonarr webhook: {e}") 381 382 return web.Response(text="ok") 383 384 385async def run(): 386 """Main bot loop.""" 387 client = AsyncClient( 388 MATRIX_HOMESERVER, 389 MATRIX_USER_ID, 390 device_id=MATRIX_DEVICE_ID, 391 store_path=STORE_PATH, 392 ) 393 client.access_token = MATRIX_ACCESS_TOKEN 394 client.user_id = MATRIX_USER_ID 395 396 http = aiohttp.ClientSession() 397 398 # Do an initial sync BEFORE registering callbacks to skip old messages 399 log.info("Performing initial sync...") 400 resp = await client.sync(timeout=10000, full_state=True) 401 if hasattr(resp, "next_batch"): 402 log.info(f"Initial sync complete, next_batch: {resp.next_batch}") 403 else: 404 log.error(f"Initial sync failed: {resp}") 405 await http.close() 406 await client.close() 407 sys.exit(1) 408 409 # Register event callbacks only after initial sync 410 client.add_event_callback(lambda room, event: on_message(client, room, event, http), RoomMessageText) 411 client.add_event_callback(lambda room, event: on_invite(client, room, event), InviteMemberEvent) 412 413 # Start webhook server for Radarr/Sonarr notifications 414 app = web.Application() 415 app["matrix_client"] = client 416 app.router.add_post("/webhook/radarr", handle_radarr_webhook) 417 app.router.add_post("/webhook/sonarr", handle_sonarr_webhook) 418 runner = web.AppRunner(app) 419 await runner.setup() 420 site = web.TCPSite(runner, "127.0.0.1", WEBHOOK_PORT) 421 await site.start() 422 log.info(f"Webhook server listening on 127.0.0.1:{WEBHOOK_PORT}") 423 424 log.info(f"Bot {MATRIX_USER_ID} is running!") 425 426 try: 427 await client.sync_forever(timeout=30000, full_state=True) 428 except Exception: 429 log.exception("sync_forever crashed") 430 finally: 431 await runner.cleanup() 432 await http.close() 433 await client.close() 434 435 436def main(): 437 asyncio.run(run())