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