Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 17 additions & 143 deletions bot/interactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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([]);
Expand Down
109 changes: 65 additions & 44 deletions bot/roundupFirstSeen.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand All @@ -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") {
Expand Down
9 changes: 7 additions & 2 deletions jellyfin/libraryResolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {};
}
}
Expand Down Expand Up @@ -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;
}
Expand Down
39 changes: 38 additions & 1 deletion utils/persistentMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)`
Expand Down Expand Up @@ -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;
}
Expand Down
Loading