This repository has no description
18 kB
437 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 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())