diff --git a/bot/interactions.js b/bot/interactions.js index 87f8c75..e194322 100644 --- a/bot/interactions.js +++ b/bot/interactions.js @@ -511,75 +511,14 @@ export function registerInteractions(client) { }) .slice(0, 10); - const trendingChoices = await Promise.all( - filtered.map(async (item) => { - try { - const details = await tmdbApi.tmdbGetDetails( - item.id, - item.media_type, - getTmdbApiKey() - ); - - const emoji = item.media_type === "movie" ? "🎬" : "📺"; - const date = - item.release_date || item.first_air_date || ""; - const year = date ? ` (${date.slice(0, 4)})` : ""; - - let extraInfo = ""; - if (item.media_type === "movie") { - const director = details.credits?.crew?.find( - (c) => c.job === "Director" - ); - const directorName = director ? director.name : null; - const runtime = details.runtime; - const hours = runtime ? Math.floor(runtime / 60) : 0; - const minutes = runtime ? runtime % 60 : 0; - const runtimeStr = runtime - ? `${hours}h ${minutes}m` - : null; - - if (directorName && runtimeStr) { - extraInfo = ` — directed by ${directorName} — runtime: ${runtimeStr}`; - } else if (directorName) { - extraInfo = ` — directed by ${directorName}`; - } else if (runtimeStr) { - extraInfo = ` — runtime: ${runtimeStr}`; - } - } else { - const creator = details.created_by?.[0]?.name; - const seasonCount = details.number_of_seasons; - const seasonStr = seasonCount - ? `${seasonCount} season${seasonCount > 1 ? "s" : ""}` - : null; - - if (creator && seasonStr) { - extraInfo = ` — created by ${creator} — ${seasonStr}`; - } else if (creator) { - extraInfo = ` — created by ${creator}`; - } else if (seasonStr) { - extraInfo = ` — ${seasonStr}`; - } - } - - let fullName = `${emoji} ${item.title || item.name}${year}${extraInfo}`; - if (fullName.length > 98) { - fullName = fullName.substring(0, 95) + "..."; - } - - return { name: fullName, value: `${item.id}|${item.media_type}` }; - } catch (err) { - const emoji = item.media_type === "movie" ? "🎬" : "📺"; - const date = - item.release_date || item.first_air_date || ""; - const year = date ? ` (${date.slice(0, 4)})` : ""; - let basicName = `${emoji} ${item.title || item.name}${year}`; - if (basicName.length > 98) { - basicName = basicName.substring(0, 95) + "..."; - } - return { name: basicName, value: `${item.id}|${item.media_type}` }; - } - }) - ); + const trendingChoices = filtered.map((item) => { + const emoji = item.media_type === "movie" ? "🎬" : "📺"; + const date = item.release_date || item.first_air_date || ""; + const year = date ? ` (${date.slice(0, 4)})` : ""; + let label = `${emoji} ${item.title || item.name}${year}`; + if (label.length > 98) label = label.substring(0, 95) + "..."; + return { name: label, value: `${item.id}|${item.media_type}` }; + }); await interaction.respond(trendingChoices); return; @@ -603,81 +542,16 @@ export function registerInteractions(client) { ) .slice(0, 10); - const detailedChoices = await Promise.all( - filtered.map(async (item) => { - try { - const details = await tmdbApi.tmdbGetDetails( - item.id, - item.media_type, - getTmdbApiKey() - ); - - const emoji = item.media_type === "movie" ? "🎬" : "📺"; - const date = - item.release_date || item.first_air_date || ""; - const year = date ? ` (${date.slice(0, 4)})` : ""; - - let extraInfo = ""; - if (item.media_type === "movie") { - const director = details.credits?.crew?.find( - (c) => c.job === "Director" - ); - const directorName = director ? director.name : null; - const runtime = details.runtime; - const hours = runtime ? Math.floor(runtime / 60) : 0; - const minutes = runtime ? runtime % 60 : 0; - const runtimeStr = runtime - ? `${hours}h ${minutes}m` - : null; - - if (directorName && runtimeStr) { - extraInfo = ` — directed by ${directorName} — runtime: ${runtimeStr}`; - } else if (directorName) { - extraInfo = ` — directed by ${directorName}`; - } else if (runtimeStr) { - extraInfo = ` — runtime: ${runtimeStr}`; - } - } else { - const creator = details.created_by?.[0]?.name; - const seasonCount = details.number_of_seasons; - const seasonStr = seasonCount - ? `${seasonCount} season${seasonCount > 1 ? "s" : ""}` - : null; - - if (creator && seasonStr) { - extraInfo = ` — created by ${creator} — ${seasonStr}`; - } else if (creator) { - extraInfo = ` — created by ${creator}`; - } else if (seasonStr) { - extraInfo = ` — ${seasonStr}`; - } - } - - let fullName = `${emoji} ${item.title || item.name}${year}${extraInfo}`; - if (fullName.length > 98) { - fullName = fullName.substring(0, 95) + "..."; - } - - return { name: fullName, value: `${item.id}|${item.media_type}` }; - } catch (err) { - logger.debug( - `Failed to fetch details for ${item.id}:`, - err?.message - ); - const emoji = item.media_type === "movie" ? "🎬" : "📺"; - const date = - item.release_date || item.first_air_date || ""; - const year = date ? ` (${date.slice(0, 4)})` : ""; - let basicName = `${emoji} ${item.title || item.name}${year}`; - if (basicName.length > 98) { - basicName = basicName.substring(0, 95) + "..."; - } - return { name: basicName, value: `${item.id}|${item.media_type}` }; - } - }) - ); + const choices = filtered.map((item) => { + const emoji = item.media_type === "movie" ? "🎬" : "📺"; + const date = item.release_date || item.first_air_date || ""; + const year = date ? ` (${date.slice(0, 4)})` : ""; + let label = `${emoji} ${item.title || item.name}${year}`; + if (label.length > 98) label = label.substring(0, 95) + "..."; + return { name: label, value: `${item.id}|${item.media_type}` }; + }); - await interaction.respond(detailedChoices); + await interaction.respond(choices); } catch (e) { logger.error("Autocomplete error:", e); return await interaction.respond([]); diff --git a/bot/roundupFirstSeen.js b/bot/roundupFirstSeen.js index 9f49382..91d4ee5 100644 --- a/bot/roundupFirstSeen.js +++ b/bot/roundupFirstSeen.js @@ -1,4 +1,6 @@ import { PersistentMap } from "../utils/persistentMap.js"; +import { buildIdentityKey } from "../jellyfin/libraryResolver.js"; +import logger from "../utils/logger.js"; // Items already seen should never re-appear as "new" regardless of how many // Sonarr/Radarr quality upgrades happen. ~5 years is effectively permanent. @@ -8,52 +10,71 @@ const map = new PersistentMap("roundup-first-seen", TTL_MS, { validateValue: (v) => v && typeof v.firstSeenAt === "number", }); -/** - * Build a stable per-item identity key that survives Sonarr/Radarr quality - * upgrades (which change the Jellyfin ItemId). - * - * - Movies prefer TMDB; fall back to ItemId (re-imports without TMDB will - * slip through — known limitation, very rare in practice). - * - Episodes/Seasons use SeriesId, which is the Jellyfin series *container* - * ItemId. The container persists across episode-file re-imports, so it's - * stable for our purposes. - */ -export function stableKeyFor(item) { - if (!item || !item.Type) return null; - const tmdb = item.ProviderIds?.Tmdb; - switch (item.Type) { - case "Movie": - if (tmdb) return `m:tmdb:${tmdb}`; - return item.Id ? `m:jf:${item.Id}` : null; - case "Series": - if (tmdb) return `S:tmdb:${tmdb}`; - return item.Id ? `S:jf:${item.Id}` : null; - case "Season": { - const sid = item.SeriesId; - const n = item.IndexNumber; - if (sid && n != null) return `s:${sid}-S${n}`; - return item.Id ? `s:jf:${item.Id}` : null; - } - case "Episode": { - const sid = item.SeriesId; - const season = item.ParentIndexNumber; - const ep = item.IndexNumber; - const epEnd = item.IndexNumberEnd; - if (sid && season != null && ep != null) { - return epEnd != null && epEnd !== ep - ? `e:${sid}-S${season}E${ep}-${epEnd}` - : `e:${sid}-S${season}E${ep}`; - } - // No usable index — best we can do is series + episode title. - if (sid && item.Name) { - return `e:${sid}-n:${item.Name.toLowerCase().trim()}`; +// Declared before the migration IIFE that calls it (avoids a latent boot-crash +// if this function is ever converted to a const arrow, which is not hoisted). +function migrateKey(oldKey) { + // m:tmdb:X → movie:tmdb:X + if (oldKey.startsWith("m:tmdb:")) return "movie:tmdb:" + oldKey.slice(7); + // m:jf:X → id:X + if (oldKey.startsWith("m:jf:")) return "id:" + oldKey.slice(5); + // S:tmdb:X → series:tmdb:X + if (oldKey.startsWith("S:tmdb:")) return "series:tmdb:" + oldKey.slice(7); + // S:jf:X → series:id:X + if (oldKey.startsWith("S:jf:")) return "series:id:" + oldKey.slice(5); + // s:SeriesId-SN → series:id:SeriesId:sN + // Use .+? so dashed GUIDs in SeriesId are captured correctly. + const seasonMatch = oldKey.match(/^s:(.+?)-S(\d+)$/); + if (seasonMatch) return `series:id:${seasonMatch[1]}:s${seasonMatch[2]}`; + // s:jf:X → id:X + if (oldKey.startsWith("s:jf:")) return "id:" + oldKey.slice(5); + // e:SeriesId-SNEm(-eEnd)? → series:id:SeriesId:sNem(-eEnd)? + const epMatch = oldKey.match(/^e:(.+?)-S(\d+)E(\d+)(?:-(\d+))?$/); + if (epMatch) { + const [, sid, s, e, eEnd] = epMatch; + const suffix = eEnd != null ? `e${e}-${eEnd}` : `e${e}`; + return `series:id:${sid}:s${s}${suffix}`; + } + // e:SeriesId-n:name → no stable equivalent, drop + // e:jf:X → id:X + if (oldKey.startsWith("e:jf:")) return "id:" + oldKey.slice(5); + return null; +} + +// One-time migration from the v1 key format (used by stableKeyFor) to the v2 +// format (used by buildIdentityKey). Runs once on startup; a no-op after that +// since v2 keys don't start with the old single-letter prefixes. +(function migrateV1Keys() { + try { + const oldPrefixes = ["m:", "S:", "s:", "e:"]; + const toMigrate = map.keys().filter((k) => oldPrefixes.some((p) => k.startsWith(p))); + if (toMigrate.length === 0) return; + + let migrated = 0; + let dropped = 0; + for (const oldKey of toMigrate) { + const newKey = migrateKey(oldKey); + if (newKey !== null) { + map.rekey(oldKey, newKey); + migrated++; + } else { + map.delete(oldKey); + dropped++; } - return item.Id ? `e:jf:${item.Id}` : null; } - default: - return null; + if (dropped > 0) { + logger.warn( + `roundup-first-seen: dropped ${dropped} v1 key(s) with no v2 equivalent — those items may re-appear once in the next Weekly Roundup` + ); + } + if (migrated > 0) { + logger.info(`roundup-first-seen: migrated ${migrated} key(s) from v1 to v2 format`); + } + } catch (err) { + logger.warn( + `roundup-first-seen: v1→v2 key migration failed and was skipped (${err?.message || err}). Dedup state from before this update was not carried over — items may re-appear once in the next Weekly Roundup.` + ); } -} +})(); /** * Returns the recorded `firstSeenAt` timestamp for this item's stable @@ -63,7 +84,7 @@ export function stableKeyFor(item) { * rather over-include than drop a genuinely new item. */ export function recordOrGet(item, now = Date.now()) { - const key = stableKeyFor(item); + const key = buildIdentityKey(item); if (!key) return now; const existing = map.get(key); if (existing && typeof existing.firstSeenAt === "number") { diff --git a/jellyfin/libraryResolver.js b/jellyfin/libraryResolver.js index 3d342f2..7f051ae 100644 --- a/jellyfin/libraryResolver.js +++ b/jellyfin/libraryResolver.js @@ -60,7 +60,7 @@ export function getLibraryChannels() { } return parsed; } catch (e) { - logger.warn("Failed to parse JELLYFIN_NOTIFICATION_LIBRARIES:", e); + logger.warn(`Failed to parse JELLYFIN_NOTIFICATION_LIBRARIES: ${e?.message || e}`); return {}; } } @@ -166,7 +166,12 @@ export function buildIdentityKey(item) { ? `name:${item.SeriesName}` : null; if (seriesKey && seasonNum != null && episodeNum != null) { - return `series:${seriesKey}:s${seasonNum}e${episodeNum}`; + const epEnd = item.IndexNumberEnd ?? item.EpisodeNumberEnd; + const epSuffix = + epEnd != null && epEnd !== episodeNum + ? `e${episodeNum}-${epEnd}` + : `e${episodeNum}`; + return `series:${seriesKey}:s${seasonNum}${epSuffix}`; } return itemId ? `id:${itemId}` : null; } diff --git a/utils/persistentMap.js b/utils/persistentMap.js index 7200746..a0333af 100644 --- a/utils/persistentMap.js +++ b/utils/persistentMap.js @@ -137,7 +137,6 @@ export class PersistentMap { this.flushTimer = null; } if (!this.dirty) return; - this.dirty = false; const now = Date.now(); const data = []; for (const [key, entry] of this.entries) { @@ -151,6 +150,7 @@ export class PersistentMap { const tmpPath = `${this.filePath}.tmp`; fs.writeFileSync(tmpPath, JSON.stringify(data), { mode: 0o600 }); fs.renameSync(tmpPath, this.filePath); + this.dirty = false; if (this.consecutiveFlushFailures > 0) { logger.info( `PersistentMap[${this.name}]: flush recovered after ${this.consecutiveFlushFailures} failure(s)` @@ -226,6 +226,43 @@ export class PersistentMap { return removed; } + /** + * Rename a key without changing its value or expiresAt. If newKey already + * exists the old entry is discarded (existing entry wins) and the old key is + * removed. Returns true if oldKey existed. + */ + rekey(oldKey, newKey) { + const entry = this.entries.get(oldKey); + if (!entry) return false; + this.entries.delete(oldKey); + if (!this.entries.has(newKey)) { + this.entries.set(newKey, entry); + } else { + logger.debug(`PersistentMap[${this.name}]: rekey collision — ${oldKey} → ${newKey} already exists, old entry discarded`); + } + this._scheduleFlush(); + return true; + } + + /** Return all non-expired keys, evicting expired entries as a side effect (consistent with get/has). */ + keys() { + const now = Date.now(); + const result = []; + const expired = []; + for (const [key, entry] of this.entries) { + if (entry.expiresAt > now) { + result.push(key); + } else { + expired.push(key); + } + } + if (expired.length > 0) { + for (const key of expired) this.entries.delete(key); + this._scheduleFlush(); + } + return result; + } + size() { return this.entries.size; }