This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

2configs: archiver-bot with IPFS/Jellyfin deep links, ipfs.lassul.us proxy

+184 -10
+18
2configs/services/archiver-bot.nix
··· 5 5 ... 6 6 }: 7 7 { 8 + clan.core.vars.generators.archiver-jellyfin = { 9 + prompts.jellyfin-api-key = { 10 + description = "Jellyfin API key - create one in Jellyfin Dashboard -> API Keys"; 11 + type = "hidden"; 12 + persist = true; 13 + }; 14 + }; 15 + 8 16 clan.core.vars.generators.archiver-matrix = { 9 17 prompts.matrix-access-token = { 10 18 description = "Matrix access token for @archiver:lassul.us - register the user on synapse, then get a token via: curl -XPOST 'https://matrix.lassul.us/_matrix/client/r0/login' -d '{\"type\":\"m.login.password\",\"user\":\"archiver\",\"password\":\"...\"}' -H 'Content-Type: application/json'"; 11 19 type = "hidden"; 12 20 persist = true; 13 21 }; 22 + 14 23 }; 15 24 16 25 systemd.services.archiver-bot = { ··· 22 31 ]; 23 32 wants = [ "network-online.target" ]; 24 33 34 + path = [ pkgs.kubo ]; 25 35 environment = { 26 36 MATRIX_HOMESERVER = "https://matrix.lassul.us"; 27 37 MATRIX_USER_ID = "@archiver:lassul.us"; 28 38 MATRIX_DEVICE_ID = "ARCHIVER"; 29 39 JELLYSEERR_URL = "http://localhost:5055"; 30 40 FLAX_URL = "https://flax.lassul.us"; 41 + FLIX_URL = "https://flix.lassul.us"; 42 + IPFS_GATEWAY_URL = "https://ipfs.lassul.us"; 43 + JELLYFIN_URL = "http://localhost:8096"; 44 + 31 45 MEDIA_PATH_PREFIX = "/var/download/"; 32 46 WEBHOOK_PORT = "8099"; 33 47 STORE_PATH = "/var/lib/archiver-bot"; ··· 38 52 export JELLYSEERR_API_KEY="$(${pkgs.jq}/bin/jq -r '.main.apiKey' "$CREDENTIALS_DIRECTORY/jellyseerr-settings")" 39 53 export RADARR_API_KEY="$(${pkgs.libxml2}/bin/xmllint --xpath 'string(//ApiKey)' "$CREDENTIALS_DIRECTORY/radarr-config")" 40 54 export SONARR_API_KEY="$(${pkgs.libxml2}/bin/xmllint --xpath 'string(//ApiKey)' "$CREDENTIALS_DIRECTORY/sonarr-config")" 55 + export JELLYFIN_API_KEY="$(cat "$CREDENTIALS_DIRECTORY/jellyfin-api-key")" 41 56 exec ${self.packages.${pkgs.system}.archiver-bot}/bin/archiver-bot 42 57 ''; 43 58 ··· 53 68 "jellyseerr-settings:/var/lib/private/jellyseerr/config/settings.json" 54 69 "radarr-config:/var/lib/radarr/.config/Radarr/config.xml" 55 70 "sonarr-config:/var/lib/sonarr/.config/NzbDrone/config.xml" 71 + "jellyfin-api-key:${ 72 + config.clan.core.vars.generators.archiver-jellyfin.files."jellyfin-api-key".path 73 + }" 56 74 ]; 57 75 }; 58 76 };
+8
2configs/services/flix/proxy.nix
··· 1 1 { pkgs, ... }: 2 2 { 3 + services.nginx.virtualHosts."ipfs.lassul.us" = { 4 + forceSSL = true; 5 + enableACME = true; 6 + locations."/" = { 7 + proxyPass = "http://localhost:8089"; 8 + recommendedProxySettings = true; 9 + }; 10 + }; 3 11 services.nginx.virtualHosts."flix.lassul.us" = { 4 12 forceSSL = true; 5 13 enableACME = true;
+158 -10
5pkgs/archiver-bot/archiver_bot/__init__.py
··· 34 34 STORE_PATH = os.environ.get("STORE_PATH", "/var/lib/archiver-bot") 35 35 WEBHOOK_PORT = int(os.environ.get("WEBHOOK_PORT", "8099")) 36 36 FLAX_URL = os.environ.get("FLAX_URL", "https://flax.lassul.us") 37 + FLIX_URL = os.environ.get("FLIX_URL", "https://flix.lassul.us") 38 + JELLYFIN_URL = os.environ.get("JELLYFIN_URL", "http://localhost:8096") 39 + JELLYFIN_API_KEY = os.environ.get("JELLYFIN_API_KEY", "") 40 + IPFS_GATEWAY_URL = os.environ.get("IPFS_GATEWAY_URL", "https://ipfs.lassul.us") 41 + IPFS_BIN = os.environ.get("IPFS_BIN", "ipfs") 37 42 MEDIA_PATH_PREFIX = os.environ.get("MEDIA_PATH_PREFIX", "/var/download/") 38 43 39 44 # Persistent tracking of requested media -> rooms ··· 80 85 if relative.startswith(MEDIA_PATH_PREFIX): 81 86 relative = relative[len(MEDIA_PATH_PREFIX):] 82 87 return f"{FLAX_URL}/{quote(relative)}" 88 + 89 + 90 + async def compute_cid(path: str) -> str | None: 91 + """Compute the IPFS CID for a file using ipfs add --only-hash.""" 92 + try: 93 + ipfs_path = "/tmp/ipfs-hash-repo" 94 + if not os.path.exists(os.path.join(ipfs_path, "config")): 95 + init_proc = await asyncio.create_subprocess_exec( 96 + IPFS_BIN, "init", 97 + stdout=asyncio.subprocess.PIPE, 98 + stderr=asyncio.subprocess.PIPE, 99 + env={**os.environ, "IPFS_PATH": ipfs_path}, 100 + ) 101 + await init_proc.communicate() 102 + env = {**os.environ, "IPFS_PATH": ipfs_path} 103 + proc = await asyncio.create_subprocess_exec( 104 + IPFS_BIN, "add", "--only-hash", "--quieter", path, 105 + stdout=asyncio.subprocess.PIPE, 106 + stderr=asyncio.subprocess.PIPE, 107 + env=env, 108 + ) 109 + try: 110 + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30) 111 + except asyncio.TimeoutError: 112 + proc.kill() 113 + await proc.communicate() 114 + log.warning(f"ipfs add --only-hash timed out for {path}") 115 + return None 116 + if proc.returncode == 0: 117 + cid = stdout.decode().strip() 118 + if cid: 119 + return cid 120 + else: 121 + log.warning(f"ipfs add --only-hash failed for {path}: {stderr.decode().strip()}") 122 + except Exception as e: 123 + log.warning(f"Failed to compute CID for {path}: {e}") 124 + return None 125 + 126 + 127 + async def trigger_jellyfin_scan(session: aiohttp.ClientSession): 128 + """Trigger a Jellyfin library scan and Jellyseerr sync.""" 129 + try: 130 + async with session.post( 131 + f"{JELLYFIN_URL}/Library/Refresh", 132 + headers={"X-Emby-Token": JELLYFIN_API_KEY}, 133 + ) as resp: 134 + log.info(f"Triggered Jellyfin library scan: {resp.status}") 135 + # also trigger Jellyseerr sync so it picks up the new jellyfinMediaId 136 + await jellyseerr_request(session, "/settings/jellyfin/sync", method="POST") 137 + log.info("Triggered Jellyseerr sync") 138 + except Exception as e: 139 + log.warning(f"Failed to trigger library scan: {e}") 140 + 141 + 142 + async def lookup_jellyfin_id(session: aiohttp.ClientSession, media_type: str, tmdb_id: int) -> str | None: 143 + """Look up Jellyfin item ID via Jellyseerr's mediaInfo or Jellyfin API.""" 144 + # Try Jellyseerr first — it tracks the Jellyfin media ID 145 + # Try Jellyseerr first 146 + try: 147 + endpoint = f"/movie/{tmdb_id}" if media_type == "movie" else f"/tv/{tmdb_id}" 148 + details = await jellyseerr_request(session, endpoint) 149 + media_info = details.get("mediaInfo") 150 + if media_info: 151 + jf_id = media_info.get("jellyfinMediaId") 152 + if jf_id: 153 + log.info(f"Jellyfin ID from Jellyseerr for {media_type}:{tmdb_id} = {jf_id}") 154 + return jf_id 155 + except Exception as e: 156 + log.warning(f"Jellyseerr Jellyfin ID lookup failed for {media_type}:{tmdb_id}: {e}") 157 + 158 + # Fallback: query Jellyfin directly, searching by provider ID 159 + if JELLYFIN_API_KEY: 160 + try: 161 + item_type = "Movie" if media_type == "movie" else "Series" 162 + async with session.get( 163 + f"{JELLYFIN_URL}/Items", 164 + params={ 165 + "IncludeItemTypes": item_type, 166 + "Recursive": "true", 167 + "Fields": "ProviderIds", 168 + }, 169 + headers={"X-Emby-Token": JELLYFIN_API_KEY}, 170 + ) as resp: 171 + data = await resp.json() 172 + for item in data.get("Items", []): 173 + provider_ids = item.get("ProviderIds", {}) 174 + if str(provider_ids.get("Tmdb")) == str(tmdb_id): 175 + jf_id = item.get("Id") 176 + log.info(f"Jellyfin ID from Jellyfin API for {media_type}:{tmdb_id} = {jf_id}") 177 + return jf_id 178 + except Exception as e: 179 + log.warning(f"Jellyfin API lookup failed for {media_type}:{tmdb_id}: {e}") 180 + 181 + log.info(f"No Jellyfin ID found for {media_type}:{tmdb_id}") 182 + return None 183 + 184 + 185 + async def make_media_links(path: str, is_dir: bool = False, jellyfin_id: str | None = None) -> tuple[str, str]: 186 + """Generate plain and HTML text with all available links for a media path.""" 187 + from urllib.parse import quote 188 + 189 + lines_plain = [] 190 + lines_html = [] 191 + 192 + # Flax link (direct file download) 193 + flax = make_flax_link(path) 194 + lines_plain.append(f"📁 Download: {flax}") 195 + lines_html.append(f'📁 Download: <a href="{flax}">{flax}</a>') 196 + 197 + # Jellyfin link (streaming) 198 + if jellyfin_id: 199 + flix_link = f"{FLIX_URL}/web/#/details?id={jellyfin_id}" 200 + else: 201 + flix_link = FLIX_URL 202 + lines_plain.append(f"🎬 Stream: {flix_link}") 203 + lines_html.append(f'🎬 Stream: <a href="{flix_link}">{flix_link}</a>') 204 + 205 + # IPFS link (compute CID for the file) 206 + if not is_dir: 207 + cid = await compute_cid(path) 208 + if cid: 209 + ipfs_link = f"{IPFS_GATEWAY_URL}/ipfs/{cid}" 210 + lines_plain.append(f"🌐 IPFS: {ipfs_link}") 211 + lines_html.append(f'🌐 IPFS: <a href="{ipfs_link}">{ipfs_link}</a>') 212 + lines_plain.append(f" ipfs get {cid}") 213 + lines_html.append(f" <code>ipfs get {cid}</code>") 214 + 215 + return ("\n".join(lines_plain), "<br>".join(lines_html)) 83 216 84 217 85 218 async def jellyseerr_request(session: aiohttp.ClientSession, path: str, **kwargs) -> dict: ··· 189 322 if status == 5: 190 323 path = await lookup_media_path(session, media_type, media_id) 191 324 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>') 325 + is_dir = media_type == "tv" 326 + jf_id = await lookup_jellyfin_id(session, media_type, media_id) 327 + links_plain, links_html = await make_media_links(path, is_dir=is_dir, jellyfin_id=jf_id) 328 + return (f"✅ {display} is already available!\n{links_plain}", f"✅ <b>{display}</b> is already available!<br>{links_html}") 194 329 return (f"✅ {display} is already available!", f"✅ <b>{display}</b> is already available!") 195 330 elif status in (2, 3, 4): 196 331 track_request(media_type, media_id, room_id) ··· 203 338 # Check if Radarr/Sonarr already has the file before requesting 204 339 existing_path = await lookup_media_path(session, media_type, media_id) 205 340 if existing_path: 206 - link = make_flax_link(existing_path) 341 + is_dir = media_type == "tv" 342 + jf_id = await lookup_jellyfin_id(session, media_type, media_id) 343 + links_plain, links_html = await make_media_links(existing_path, is_dir=is_dir, jellyfin_id=jf_id) 207 344 # Still send the request to Jellyseerr so it's tracked there too 208 345 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>') 346 + return (f"✅ {display} is already available!\n{links_plain}", f"✅ <b>{display}</b> is already available!<br>{links_html}") 210 347 211 348 result = await jellyseerr_request(session, "/request", method="POST", json=payload) 212 349 ··· 329 466 rooms = pop_tracked("movie", tmdb_id) 330 467 if rooms: 331 468 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>' 469 + http = request.app["http_session"] 470 + # trigger library scan so Jellyfin picks up the new file 471 + await trigger_jellyfin_scan(http) 472 + await asyncio.sleep(10) 473 + jf_id = await lookup_jellyfin_id(http, "movie", tmdb_id) 474 + links_plain, links_html = await make_media_links(file_path, jellyfin_id=jf_id) 475 + plain = f"✅ {display} has been downloaded and is now available!\n{links_plain}" 476 + html = f"✅ <b>{display}</b> has been downloaded and is now available!<br>{links_html}" 335 477 for room_id in rooms: 336 478 try: 337 479 await send_notice(client, room_id, plain, html) ··· 367 509 rooms = pop_tracked("tv", tmdb_id) 368 510 if rooms: 369 511 client = request.app["matrix_client"] 512 + http = request.app["http_session"] 370 513 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>' 514 + # trigger library scan so Jellyfin picks up the new file 515 + await trigger_jellyfin_scan(http) 516 + await asyncio.sleep(10) 517 + jf_id = await lookup_jellyfin_id(http, "tv", tmdb_id) 518 + links_plain, links_html = await make_media_links(folder_path, is_dir=True, jellyfin_id=jf_id) 519 + plain = f"✅ {display}{ep_info} has been downloaded and is now available!\n{links_plain}" 520 + html = f"✅ <b>{display}</b>{ep_info} has been downloaded and is now available!<br>{links_html}" 374 521 for room_id in rooms: 375 522 try: 376 523 await send_notice(client, room_id, plain, html) ··· 413 560 # Start webhook server for Radarr/Sonarr notifications 414 561 app = web.Application() 415 562 app["matrix_client"] = client 563 + app["http_session"] = http 416 564 app.router.add_post("/webhook/radarr", handle_radarr_webhook) 417 565 app.router.add_post("/webhook/sonarr", handle_sonarr_webhook) 418 566 runner = web.AppRunner(app)