import asyncio
import json
import logging
import os
import re
import sys
from pathlib import Path
from urllib.parse import quote
import aiohttp
from aiohttp import web
from nio import (
AsyncClient,
AsyncClientConfig,
InviteMemberEvent,
LoginResponse,
MatrixRoom,
MegolmEvent,
RoomMessageText,
)
from nio.api import Api
from nio.responses import JoinError, JoinResponse
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("archiver-bot")
IMDB_RE = re.compile(r"https?://(?:www\.)?imdb\.com/(?:[a-z]{2}/)?title/(tt\d+)")
TMDB_MOVIE_RE = re.compile(r"https?://(?:www\.)?themoviedb\.org/movie/(\d+)")
TMDB_TV_RE = re.compile(r"https?://(?:www\.)?themoviedb\.org/tv/(\d+)")
JELLYSEERR_URL = os.environ.get("JELLYSEERR_URL", "http://localhost:5055")
JELLYSEERR_API_KEY = os.environ["JELLYSEERR_API_KEY"]
RADARR_API_KEY = os.environ.get("RADARR_API_KEY", "")
SONARR_API_KEY = os.environ.get("SONARR_API_KEY", "")
MATRIX_HOMESERVER = os.environ["MATRIX_HOMESERVER"]
MATRIX_ACCESS_TOKEN = os.environ["MATRIX_ACCESS_TOKEN"]
MATRIX_USER_ID = os.environ.get("MATRIX_USER_ID", "@archiver:lassul.us")
MATRIX_DEVICE_ID = os.environ.get("MATRIX_DEVICE_ID", "ARCHIVER")
STORE_PATH = os.environ.get("STORE_PATH", "/var/lib/archiver-bot")
WEBHOOK_PORT = int(os.environ.get("WEBHOOK_PORT", "8099"))
FLAX_URL = os.environ.get("FLAX_URL", "https://flax.lassul.us")
FLIX_URL = os.environ.get("FLIX_URL", "https://flix.lassul.us")
JELLYFIN_URL = os.environ.get("JELLYFIN_URL", "http://localhost:8096")
JELLYFIN_API_KEY = os.environ.get("JELLYFIN_API_KEY", "")
IPFS_GATEWAY_URL = os.environ.get("IPFS_GATEWAY_URL", "https://ipfs.lassul.us")
MEDIA_PATH_PREFIX = os.environ.get("MEDIA_PATH_PREFIX", "/var/download/")
IPFS_BIN = os.environ.get("IPFS_BIN", "ipfs")
# Persistent tracking of requested media -> rooms
TRACKING_FILE = Path(STORE_PATH) / "tracked_requests.json"
def load_tracked() -> dict:
"""Load tracked requests. Schema: {key: [{"room": id, "event": id|None}, ...]}.
Legacy entries (bare room_id strings) are normalized to event=None."""
try:
raw = json.loads(TRACKING_FILE.read_text())
except Exception:
return {}
out = {}
for key, entries in raw.items():
normalized = []
for e in entries:
if isinstance(e, str):
normalized.append({"room": e, "event": None})
elif isinstance(e, dict) and e.get("room"):
normalized.append({"room": e["room"], "event": e.get("event")})
out[key] = normalized
return out
def save_tracked(tracked: dict):
"""Save tracked requests to disk."""
TRACKING_FILE.write_text(json.dumps(tracked))
def track_request(media_type: str, tmdb_id: int, room_id: str, event_id: str | None = None):
"""Track that a room requested this media; event_id is the originating
message so download notifications can thread back into that conversation."""
tracked = load_tracked()
key = f"{media_type}:{tmdb_id}"
entries = tracked.get(key, [])
for entry in entries:
if entry["room"] == room_id:
if event_id is not None:
entry["event"] = event_id
break
else:
entries.append({"room": room_id, "event": event_id})
tracked[key] = entries
save_tracked(tracked)
def pop_tracked(media_type: str, tmdb_id: int) -> list[dict]:
"""Get and remove tracked entries for this media."""
tracked = load_tracked()
key = f"{media_type}:{tmdb_id}"
entries = tracked.pop(key, [])
save_tracked(tracked)
return entries
def make_flax_link(path: str) -> str:
"""Turn a local path like /var/download/movies/Title (Year)/file.mp4 into a flax.lassul.us URL."""
from urllib.parse import quote
relative = path
if relative.startswith(MEDIA_PATH_PREFIX):
relative = relative[len(MEDIA_PATH_PREFIX):]
return f"{FLAX_URL}/{quote(relative)}"
def find_main_video(path: str) -> str | None:
"""Find the main video file in a path. If path is a file, return it. If directory, find largest video."""
if os.path.isfile(path):
return path
if os.path.isdir(path):
video_exts = {".mkv", ".mp4", ".avi", ".m4v", ".ts", ".wmv"}
best = None
best_size = 0
for root, _dirs, files in os.walk(path):
for f in files:
ext = os.path.splitext(f)[1].lower()
if ext in video_exts:
full = os.path.join(root, f)
size = os.path.getsize(full)
if size > best_size:
best = full
best_size = size
return best
return None
async def compute_cid(path: str) -> str | None:
"""Compute the IPFS CID for a file using ipfs add --only-hash --raw-leaves."""
video = find_main_video(path)
if not video:
log.warning(f"No video file found for {path}")
return None
import tempfile
import shutil
ipfs_path = tempfile.mkdtemp(prefix="ipfs-hash-")
try:
env = {**os.environ, "IPFS_PATH": ipfs_path}
init_proc = await asyncio.create_subprocess_exec(
IPFS_BIN, "init",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
await init_proc.communicate()
proc = await asyncio.create_subprocess_exec(
IPFS_BIN, "add", "--only-hash", "--raw-leaves", "--quieter", video,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
except asyncio.TimeoutError:
proc.kill()
await proc.communicate()
log.warning(f"ipfs add --only-hash timed out for {video}")
return None
if proc.returncode == 0:
cid = stdout.decode().strip()
if cid:
return cid
else:
log.warning(f"ipfs add --only-hash failed for {video}: {stderr.decode().strip()}")
except Exception as e:
log.warning(f"Failed to compute CID for {video}: {e}")
finally:
shutil.rmtree(ipfs_path, ignore_errors=True)
return None
async def trigger_jellyfin_scan(session: aiohttp.ClientSession):
"""Trigger a Jellyfin library scan and Jellyseerr sync."""
try:
async with session.post(
f"{JELLYFIN_URL}/Library/Refresh",
headers={"X-Emby-Token": JELLYFIN_API_KEY},
) as resp:
log.info(f"Triggered Jellyfin library scan: {resp.status}")
# also trigger Jellyseerr sync so it picks up the new jellyfinMediaId
await jellyseerr_request(session, "/settings/jellyfin/sync", method="POST")
log.info("Triggered Jellyseerr sync")
except Exception as e:
log.warning(f"Failed to trigger library scan: {e}")
async def lookup_jellyfin_id(session: aiohttp.ClientSession, media_type: str, tmdb_id: int) -> str | None:
"""Look up Jellyfin item ID via Jellyseerr's mediaInfo or Jellyfin API."""
# Try Jellyseerr first — it tracks the Jellyfin media ID
# Try Jellyseerr first
try:
endpoint = f"/movie/{tmdb_id}" if media_type == "movie" else f"/tv/{tmdb_id}"
details = await jellyseerr_request(session, endpoint)
media_info = details.get("mediaInfo")
if media_info:
jf_id = media_info.get("jellyfinMediaId")
if jf_id:
log.info(f"Jellyfin ID from Jellyseerr for {media_type}:{tmdb_id} = {jf_id}")
return jf_id
except Exception as e:
log.warning(f"Jellyseerr Jellyfin ID lookup failed for {media_type}:{tmdb_id}: {e}")
# Fallback: query Jellyfin directly, searching by provider ID
if JELLYFIN_API_KEY:
try:
item_type = "Movie" if media_type == "movie" else "Series"
async with session.get(
f"{JELLYFIN_URL}/Items",
params={
"IncludeItemTypes": item_type,
"Recursive": "true",
"Fields": "ProviderIds",
},
headers={"X-Emby-Token": JELLYFIN_API_KEY},
) as resp:
data = await resp.json()
for item in data.get("Items", []):
provider_ids = item.get("ProviderIds", {})
if str(provider_ids.get("Tmdb")) == str(tmdb_id):
jf_id = item.get("Id")
log.info(f"Jellyfin ID from Jellyfin API for {media_type}:{tmdb_id} = {jf_id}")
return jf_id
except Exception as e:
log.warning(f"Jellyfin API lookup failed for {media_type}:{tmdb_id}: {e}")
log.info(f"No Jellyfin ID found for {media_type}:{tmdb_id}")
return None
async def make_media_links(path: str, is_dir: bool = False, jellyfin_id: str | None = None) -> tuple[str, str]:
"""Generate plain and HTML text with all available links for a media path."""
from urllib.parse import quote
lines_plain = []
lines_html = []
# Flax link (direct file download)
flax = make_flax_link(path)
lines_plain.append(f"📁 Download: {flax}")
lines_html.append(f'📁 Download: {flax}')
# Jellyfin link (streaming)
if jellyfin_id:
flix_link = f"{FLIX_URL}/web/#/details?id={jellyfin_id}"
else:
flix_link = FLIX_URL
lines_plain.append(f"🎬 Stream: {flix_link}")
lines_html.append(f'🎬 Stream: {flix_link}')
# IPFS link (compute CID for the file, retry up to 3 times)
cid = None
for attempt in range(3):
cid = await compute_cid(path)
if cid:
break
log.warning(f"IPFS hash attempt {attempt + 1}/3 failed for {path}")
await asyncio.sleep(2)
if cid:
ipfs_link = f"{IPFS_GATEWAY_URL}/ipfs/{cid}"
lines_plain.append(f"🌐 IPFS: {ipfs_link}")
lines_html.append(f'🌐 IPFS: {ipfs_link}')
lines_plain.append(f" ipfs get {cid}")
lines_html.append(f" ipfs get {cid}")
else:
log.error(f"IPFS hash failed after 3 attempts for {path}, not posting message")
return None
return ("\n".join(lines_plain), "
".join(lines_html))
async def jellyseerr_request(session: aiohttp.ClientSession, path: str, **kwargs) -> dict:
"""Make an authenticated request to Jellyseerr."""
headers = {
"X-Api-Key": JELLYSEERR_API_KEY,
"Content-Type": "application/json",
"Accept": "application/json",
}
method = kwargs.pop("method", "GET")
url = f"{JELLYSEERR_URL}/api/v1{path}"
async with session.request(method, url, headers=headers, **kwargs) as resp:
return await resp.json()
async def find_by_imdb(session: aiohttp.ClientSession, imdb_id: str) -> dict | None:
"""Look up a movie or TV show by IMDb ID via Radarr/Sonarr, returning Jellyseerr-compatible data."""
# Try Radarr (movies) first
try:
async with session.get(
f"http://localhost:7878/api/v3/movie/lookup?term=imdb:{imdb_id}",
headers={"X-Api-Key": RADARR_API_KEY},
) as resp:
results = await resp.json()
if results and len(results) > 0:
tmdb_id = results[0].get("tmdbId")
title = results[0].get("title", "Unknown")
year = str(results[0].get("year", ""))
if tmdb_id:
log.info(f"Radarr resolved {imdb_id} -> TMDB {tmdb_id} ({title})")
return {
"id": tmdb_id,
"mediaType": "movie",
"title": title,
"releaseDate": f"{year}-01-01" if year else "",
}
except Exception as e:
log.warning(f"Radarr lookup failed: {e}")
# Try Sonarr (TV shows)
try:
async with session.get(
f"http://localhost:8989/api/v3/series/lookup?term=imdb:{imdb_id}",
headers={"X-Api-Key": SONARR_API_KEY},
) as resp:
results = await resp.json()
if results and len(results) > 0:
tmdb_id = results[0].get("tmdbId", 0)
title = results[0].get("title", "Unknown")
year = str(results[0].get("year", ""))
if tmdb_id:
log.info(f"Sonarr resolved {imdb_id} -> TMDB {tmdb_id} ({title})")
return {
"id": tmdb_id,
"mediaType": "tv",
"name": title,
"firstAirDate": f"{year}-01-01" if year else "",
}
except Exception as e:
log.warning(f"Sonarr lookup failed: {e}")
return None
async def lookup_media_path(session: aiohttp.ClientSession, media_type: str, tmdb_id: int) -> str | None:
"""Look up the path for a movie file or show folder from Radarr/Sonarr by TMDB ID."""
try:
if media_type == "movie":
async with session.get(
f"http://localhost:7878/api/v3/movie?tmdbId={tmdb_id}",
headers={"X-Api-Key": RADARR_API_KEY},
) as resp:
results = await resp.json()
if results and len(results) > 0:
movie = results[0]
if not movie.get("hasFile"):
return None
movie_file = movie.get("movieFile", {})
if movie_file and movie_file.get("path"):
return movie_file["path"]
return None
else:
async with session.get(
f"http://localhost:8989/api/v3/series?tvdbId={tmdb_id}",
headers={"X-Api-Key": SONARR_API_KEY},
) as resp:
results = await resp.json()
if results and len(results) > 0:
return results[0].get("path")
except Exception as e:
log.warning(f"Media path lookup failed for {media_type}:{tmdb_id}: {e}")
return None
async def request_media(session: aiohttp.ClientSession, media: dict, room_id: str, event_id: str | None = None) -> tuple[str, str]:
"""Request a movie or TV show via Jellyseerr. Returns (plain, html) status message."""
media_type = media["mediaType"]
media_id = media["id"]
title = media.get("title") or media.get("name") or "Unknown"
year = (media.get("releaseDate") or media.get("firstAirDate") or "")[:4]
display = f"{title} ({year})" if year else title
# Check if already available or requested
existing = media.get("mediaInfo")
if existing:
status = existing.get("status")
# status 5 = available, 4 = partially available, 3 = processing, 2 = pending
if status == 5:
path = await lookup_media_path(session, media_type, media_id)
if path:
is_dir = media_type == "tv"
jf_id = await lookup_jellyfin_id(session, media_type, media_id)
links = await make_media_links(path, is_dir=is_dir, jellyfin_id=jf_id)
if not links:
log.error(f"IPFS hash failed for {display}, not posting"); return None
links_plain, links_html = links
return (f"✅ {display} is already available!\n{links_plain}", f"✅ {display} is already available!
{links_html}")
return (f"✅ {display} is already available!", f"✅ {display} is already available!")
elif status in (2, 3, 4):
track_request(media_type, media_id, room_id, event_id)
return (f"⏳ {display} has already been requested and is being processed.", f"⏳ {display} has already been requested and is being processed.")
payload = {"mediaType": media_type, "mediaId": media_id}
if media_type == "tv":
payload["seasons"] = "all"
# Check if Radarr/Sonarr already has the file before requesting
existing_path = await lookup_media_path(session, media_type, media_id)
if existing_path:
is_dir = media_type == "tv"
jf_id = await lookup_jellyfin_id(session, media_type, media_id)
links = await make_media_links(existing_path, is_dir=is_dir, jellyfin_id=jf_id)
# Still send the request to Jellyseerr so it's tracked there too
await jellyseerr_request(session, "/request", method="POST", json=payload)
if not links:
log.error(f"IPFS hash failed for {display}, not posting"); return None
links_plain, links_html = links
return (f"✅ {display} is already available!\n{links_plain}", f"✅ {display} is already available!
{links_html}")
result = await jellyseerr_request(session, "/request", method="POST", json=payload)
if "id" in result:
track_request(media_type, media_id, room_id, event_id)
return (f"🎬 Successfully requested {display}! I'll notify you when it's downloaded.", f"🎬 Successfully requested {display}! I'll notify you when it's downloaded.")
else:
error = result.get("message", json.dumps(result))
return (f"❌ Failed to request {display}: {error}", f"❌ Failed to request {display}: {error}")
async def trust_room_devices(client: AsyncClient, room_id: str):
"""Trust all devices of all members in a room."""
if not client.olm:
return
room = client.rooms.get(room_id)
if not room:
return
for user_id in room.users:
for device in client.device_store.active_user_devices(user_id):
if not client.olm.is_device_verified(device):
client.verify_device(device)
log.info(f"Verified device {device.device_id} of {user_id}")
async def send_notice(client: AsyncClient, room_id: str, plain: str, html: str, thread_root: str | None = None):
"""Send a notice message to a room. If thread_root is given, the message
becomes a reply within that thread (with the in_reply_to fallback so
non-threaded clients still render a quote)."""
await trust_room_devices(client, room_id)
content = {
"msgtype": "m.notice",
"body": plain,
"format": "org.matrix.custom.html",
"formatted_body": html,
}
if thread_root:
content["m.relates_to"] = {
"rel_type": "m.thread",
"event_id": thread_root,
"is_falling_back": True,
"m.in_reply_to": {"event_id": thread_root},
}
await client.room_send(room_id, "m.room.message", content)
def thread_root_for(event) -> str:
"""If event is already part of a thread, return that thread's root;
otherwise return event's own id (start a new thread on it)."""
rel = (getattr(event, "source", None) or {}).get("content", {}).get("m.relates_to") or {}
if rel.get("rel_type") == "m.thread" and rel.get("event_id"):
return rel["event_id"]
return event.event_id
async def on_message(client: AsyncClient, room: MatrixRoom, event: RoomMessageText, http: aiohttp.ClientSession):
"""Handle messages in rooms, looking for IMDb/TMDB links."""
# Ignore our own messages
if event.sender == client.user_id:
return
body = event.body
log.info(f"Message from {event.sender} in {room.display_name}: {body[:100]}")
thread_root = thread_root_for(event)
# Help command
if body.strip().lower() in ("help", "!help"):
plain = (
"Archiver Bot\n\n"
"Send me an IMDb or TMDB link and I'll request the movie/show on Jellyseerr.\n"
"I'll notify you when the download is complete.\n\n"
"Examples:\n"
" https://www.imdb.com/title/tt0133093/\n"
" https://www.themoviedb.org/movie/603\n"
" https://www.themoviedb.org/tv/1399"
)
html = (
"Archiver Bot
"
"Send me an IMDb or TMDB link and I'll request the movie/show on Jellyseerr.
"
"I'll notify you when the download is complete.
"
"Examples:
"
"https://www.imdb.com/title/tt0133093/
"
"https://www.themoviedb.org/movie/603
"
"https://www.themoviedb.org/tv/1399"
)
await send_notice(client, room.room_id, plain, html, thread_root=thread_root)
return
# Collect all media references from the message
results = []
for imdb_id in set(IMDB_RE.findall(body)):
log.info(f"Found IMDb link {imdb_id} in {room.display_name} from {event.sender}")
media = await find_by_imdb(http, imdb_id)
if media is None:
results.append((f"❓ Could not find anything for {imdb_id}.", f"❓ Could not find anything for {imdb_id}."))
else:
results.append(await request_media(http, media, room.room_id, thread_root))
for tmdb_id in set(TMDB_MOVIE_RE.findall(body)):
log.info(f"Found TMDB movie link {tmdb_id} in {room.display_name} from {event.sender}")
media = {"id": int(tmdb_id), "mediaType": "movie"}
try:
details = await jellyseerr_request(http, f"/movie/{tmdb_id}")
media["title"] = details.get("title", "Unknown")
media["releaseDate"] = details.get("releaseDate", "")
media["mediaInfo"] = details.get("mediaInfo")
except Exception as e:
log.warning(f"Failed to fetch TMDB movie {tmdb_id}: {e}")
results.append(await request_media(http, media, room.room_id, thread_root))
for tmdb_id in set(TMDB_TV_RE.findall(body)):
log.info(f"Found TMDB TV link {tmdb_id} in {room.display_name} from {event.sender}")
media = {"id": int(tmdb_id), "mediaType": "tv"}
try:
details = await jellyseerr_request(http, f"/tv/{tmdb_id}")
media["name"] = details.get("name", "Unknown")
media["firstAirDate"] = details.get("firstAirDate", "")
media["mediaInfo"] = details.get("mediaInfo")
except Exception as e:
log.warning(f"Failed to fetch TMDB TV {tmdb_id}: {e}")
results.append(await request_media(http, media, room.room_id, thread_root))
for result in results:
if result is not None:
plain, html = result
await send_notice(client, room.room_id, plain, html, thread_root=thread_root)
async def on_megolm(client: AsyncClient, room: MatrixRoom, event: MegolmEvent):
"""Handle messages we couldn't decrypt."""
log.warning(
f"Failed to decrypt message from {event.sender} in {room.display_name} "
f"(session {event.session_id})"
)
# Request the keys we're missing
if client.olm:
try:
resp = await client.request_room_key(event)
log.info(f"Requested room key for session {event.session_id}: {resp}")
except Exception as e:
log.warning(f"Failed to request room key: {e}")
await send_notice(
client,
room.room_id,
"🔒 I couldn't decrypt your message. Please try again or re-invite me to this room.",
"🔒 I couldn't decrypt your message. Please try again or re-invite me to this room.",
thread_root=event.event_id,
)
def _server_name(matrix_id: str | None) -> str | None:
if matrix_id and ":" in matrix_id:
return matrix_id.split(":", 1)[1]
return None
async def try_join(client: AsyncClient, room_id: str, via: str | None = None, attempts: int = 6) -> bool:
# client.join doesn't expose server_name; pass it through to help our
# homeserver resolve the room over federation. Retry with backoff because
# invites delivered via sync can briefly race against the server's own
# room-state view (M_FORBIDDEN even though the invite just arrived).
method, path = Api.join(client.access_token, room_id)
if via:
path += "&server_name=" + quote(via)
for attempt in range(1, attempts + 1):
result = await client._send(JoinResponse, method, path)
if not isinstance(result, JoinError):
log.info(f"Joined {room_id} on attempt {attempt}")
return True
log.warning(f"Join attempt {attempt}/{attempts} for {room_id} failed: {result}")
if attempt < attempts:
await asyncio.sleep(2 ** (attempt - 1))
log.error(f"Gave up joining {room_id} after {attempts} attempts")
return False
async def fetch_pending_invites(client: AsyncClient, http: aiohttp.ClientSession) -> list[str]:
# nio's incremental sync uses the persisted next_batch and synapse will
# not re-deliver invites that were already seen in a prior session, so
# client.invited_rooms misses anything the bot saw and failed to join
# before a restart. Ask the server explicitly with a fresh /sync.
url = f"{client.homeserver}/_matrix/client/v3/sync?timeout=0"
headers = {"Authorization": f"Bearer {client.access_token}"}
try:
async with http.get(url, headers=headers) as resp:
data = await resp.json()
return list(data.get("rooms", {}).get("invite", {}).keys())
except Exception as e:
log.warning(f"Failed to fetch pending invites: {e}")
return []
async def on_invite(client: AsyncClient, room: MatrixRoom, event: InviteMemberEvent):
"""Auto-join rooms when invited."""
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}")
if event.state_key != client.user_id:
return
log.info(f"Joining {room.room_id} (invited by {event.sender})...")
await try_join(client, room.room_id, via=_server_name(event.sender))
async def handle_radarr_webhook(request):
"""Handle Radarr webhook for download completion."""
try:
data = await request.json()
event_type = data.get("eventType", "")
log.info(f"Radarr webhook: {event_type}")
if event_type in ("Download", "MovieFileDelete"):
movie = data.get("movie", {})
tmdb_id = movie.get("tmdbId")
title = movie.get("title", "Unknown")
year = movie.get("year", "")
display = f"{title} ({year})" if year else title
# Prefer the actual movie file path, fall back to folder
movie_file = data.get("movieFile", {})
file_path = movie_file.get("path") or movie.get("folderPath", "")
if event_type == "Download" and tmdb_id:
entries = pop_tracked("movie", tmdb_id)
if entries:
client = request.app["matrix_client"]
http = request.app["http_session"]
# trigger library scan so Jellyfin picks up the new file
await trigger_jellyfin_scan(http)
await asyncio.sleep(10)
jf_id = await lookup_jellyfin_id(http, "movie", tmdb_id)
links = await make_media_links(file_path, jellyfin_id=jf_id)
if not links:
log.error(f"IPFS hash failed for {display}, not notifying")
return web.Response(text="ok")
links_plain, links_html = links
plain = f"✅ {display} has been downloaded and is now available!\n{links_plain}"
html = f"✅ {display} has been downloaded and is now available!
{links_html}"
for entry in entries:
room_id = entry["room"]
try:
await send_notice(client, room_id, plain, html, thread_root=entry.get("event"))
except Exception as e:
log.warning(f"Failed to notify room {room_id}: {e}")
except Exception as e:
log.exception(f"Error handling Radarr webhook: {e}")
return web.Response(text="ok")
async def handle_sonarr_webhook(request):
"""Handle Sonarr webhook for download completion."""
try:
data = await request.json()
event_type = data.get("eventType", "")
log.info(f"Sonarr webhook: {event_type}")
if event_type in ("Download", "EpisodeFileDelete"):
series = data.get("series", {})
tmdb_id = series.get("tmdbId")
title = series.get("title", "Unknown")
year = series.get("year", "")
display = f"{title} ({year})" if year else title
episodes = data.get("episodes", [])
ep_info = ""
if episodes:
ep_nums = [f"S{ep.get('seasonNumber', 0):02d}E{ep.get('episodeNumber', 0):02d}" for ep in episodes]
ep_info = f" ({', '.join(ep_nums)})"
if event_type == "Download" and tmdb_id:
entries = pop_tracked("tv", tmdb_id)
if entries:
client = request.app["matrix_client"]
http = request.app["http_session"]
folder_path = series.get("path", "")
# trigger library scan so Jellyfin picks up the new file
await trigger_jellyfin_scan(http)
await asyncio.sleep(10)
jf_id = await lookup_jellyfin_id(http, "tv", tmdb_id)
links = await make_media_links(folder_path, is_dir=True, jellyfin_id=jf_id)
if not links:
log.error(f"IPFS hash failed for {display}, not notifying")
return web.Response(text="ok")
links_plain, links_html = links
plain = f"✅ {display}{ep_info} has been downloaded and is now available!\n{links_plain}"
html = f"✅ {display}{ep_info} has been downloaded and is now available!
{links_html}"
for entry in entries:
room_id = entry["room"]
try:
await send_notice(client, room_id, plain, html, thread_root=entry.get("event"))
except Exception as e:
log.warning(f"Failed to notify room {room_id}: {e}")
except Exception as e:
log.exception(f"Error handling Sonarr webhook: {e}")
return web.Response(text="ok")
async def run():
"""Main bot loop."""
client_config = AsyncClientConfig(
store_sync_tokens=True,
encryption_enabled=True,
)
client = AsyncClient(
MATRIX_HOMESERVER,
MATRIX_USER_ID,
device_id=MATRIX_DEVICE_ID,
store_path=STORE_PATH,
config=client_config,
)
client.access_token = MATRIX_ACCESS_TOKEN
client.user_id = MATRIX_USER_ID
client.load_store()
log.info(f"Crypto store loaded, olm={client.olm is not None}")
http = aiohttp.ClientSession()
# Do an initial sync BEFORE registering callbacks to skip old messages
log.info("Performing initial sync...")
resp = await client.sync(timeout=10000, full_state=True)
if hasattr(resp, "next_batch"):
log.info(f"Initial sync complete, next_batch: {resp.next_batch}")
else:
log.error(f"Initial sync failed: {resp}")
await http.close()
await client.close()
sys.exit(1)
# Join any rooms we have a pending invite for (server-side truth; see
# fetch_pending_invites for why we can't rely on client.invited_rooms).
pending = await fetch_pending_invites(client, http)
for room_id in pending:
log.info(f"Joining pending invite for {room_id}...")
await try_join(client, room_id, via=_server_name(room_id))
# E2EE setup
if client.olm:
try:
log.info("Uploading device keys...")
key_upload = await client.keys_upload()
log.info(f"Key upload result: {key_upload}")
except Exception as e:
log.info(f"Key upload skipped: {e}")
# Trust all known device keys so we can decrypt messages
for user_id in client.device_store.users:
for device in client.device_store.active_user_devices(user_id):
if not client.olm.is_device_verified(device):
client.verify_device(device)
log.info(f"Verified device {device.device_id} of {user_id}")
else:
log.warning("Olm not available after sync — E2EE will not work")
# Register event callbacks only after initial sync
client.add_event_callback(lambda room, event: on_message(client, room, event, http), RoomMessageText)
client.add_event_callback(lambda room, event: on_invite(client, room, event), InviteMemberEvent)
client.add_event_callback(lambda room, event: on_megolm(client, room, event), MegolmEvent)
# Start webhook server for Radarr/Sonarr notifications
app = web.Application()
app["matrix_client"] = client
app["http_session"] = http
app.router.add_post("/webhook/radarr", handle_radarr_webhook)
app.router.add_post("/webhook/sonarr", handle_sonarr_webhook)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", WEBHOOK_PORT)
await site.start()
log.info(f"Webhook server listening on 127.0.0.1:{WEBHOOK_PORT}")
log.info(f"Bot {MATRIX_USER_ID} is running!")
try:
await client.sync_forever(timeout=30000, full_state=True)
except Exception:
log.exception("sync_forever crashed")
raise
finally:
await runner.cleanup()
await http.close()
await client.close()
def main():
asyncio.run(run())