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