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