Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### ✨ Added

- **Separate overview toggle for episodes**: The embed overview setting is now split into two independent options -- one for movies and series, one for episodes. Episode summaries can be disabled independently to avoid spoilers for shows you haven't caught up on. Both options are on by default. Configurable via the dashboard under "Embed Options". The previous `EMBED_SHOW_OVERVIEW` setting has been replaced; users who had it disabled will need to re-configure the new options.
- **Separate overview toggle for episodes**: The embed overview setting is now split into two independent options -- one for movies and series, one for episodes. Episode summaries can be disabled independently to avoid spoilers for shows you haven't caught up on. Both options are on by default. Configurable via the dashboard under "Embed Options". The previous `EMBED_SHOW_OVERVIEW` setting has been replaced and is migrated automatically to both new options on first start after upgrading; no action needed.

- **Weekly Roundup**: Optional scheduled Discord post that summarizes new Jellyfin content from the last 7 days. Disabled by default. Configurable via the dashboard (channel, weekday, hour, embed color). The roundup groups items by library and collapses episodes of the same series into one line (e.g. _"My Show — Seasons 1 & 2 (12 episodes)"_). Item titles link directly to Jellyfin. A hourly scheduler tick with a persisted `WEEKLY_ROUNDUP_LAST_POSTED_AT` timestamp makes the post idempotent across Docker restarts. Sonarr/Radarr quality upgrades are filtered out via a stable-identity first-seen map (`config/dedup-roundup-first-seen.json`) so a re-imported file does not show up as "new".

Expand Down
13 changes: 13 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { handleJellyfinWebhook } from "./jellyfinWebhook.js";
import { configTemplate } from "./lib/config.js";
import { sendDailyRandomPick } from "./bot/dailyPick.js";
import { sendWeeklyRoundupTest } from "./bot/weeklyRoundup.js";
import { stop as stopRoundupScheduler } from "./bot/roundupScheduler.js";
import { seedLibrary } from "./jellyfin/librarySeeder.js";
import { pruneLibrary } from "./jellyfin/libraryPruner.js";

Expand Down Expand Up @@ -674,6 +675,7 @@ function configureWebServer() {
"Critical Discord settings changed. Restarting bot logic..."
);

stopRoundupScheduler();
await botState.discordClient.destroy();
botState.isBotRunning = false;
botState.discordClient = null;
Expand Down Expand Up @@ -1160,6 +1162,17 @@ function startServer() {
});

const LIBRARY_PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
const LIBRARY_PRUNE_INITIAL_DELAY_MS = 5 * 60 * 1000;
// setInterval alone only fires after a full 24h of uptime — a container
// that restarts more often than that (e.g. on every config save) would
// otherwise never run a prune cycle at all. Run one shortly after boot too
// so seeded/pruned keys get re-asserted regardless of restart frequency.
setTimeout(() => {
pruneLibrary().catch((err) =>
logger.error(`libraryPruner: unexpected rejection in initial prune (${err?.message || err})`)
);
}, LIBRARY_PRUNE_INITIAL_DELAY_MS);

libraryPruneTimer = setInterval(() => {
pruneLibrary().catch((err) =>
logger.error(`libraryPruner: unexpected rejection in prune cycle (${err?.message || err})`)
Expand Down
13 changes: 7 additions & 6 deletions bot/interactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "./botState.js";
import { getUserMappings } from "../utils/configFile.js";
import { getSeerrApiUrl } from "../utils/seerrUrl.js";
import { t } from "../utils/i18n.js";
import logger from "../utils/logger.js";

// Convenience accessors — read process.env at call time so config reloads are respected
Expand All @@ -29,22 +30,22 @@ function getSeerrErrorMessage(err) {
const status = err.response.status;
const msg = err.response.data?.message || "";
if (/quota/i.test(msg)) {
return `⚠️ ${msg} You've reached your request limit — contact an admin if you think this is a mistake.`;
return t("seerr_request_errors.quota", { msg });
}
if (status === 401 || status === 403) {
return "⚠️ Request failed: authentication error. Check the bot's API key configuration.";
return t("seerr_request_errors.auth");
}
if (status >= 500) {
return "⚠️ Seerr returned a server error. Try again later.";
return t("seerr_request_errors.server_error");
}
if (msg) {
return `⚠️ Seerr error: ${msg}`;
return t("seerr_request_errors.generic", { msg });
}
}
if (err.code === "ECONNREFUSED" || err.code === "ETIMEDOUT" || err.code === "ENOTFOUND") {
return "⚠️ Could not reach Seerr. Check that your Seerr URL is correct and reachable.";
return t("seerr_request_errors.unreachable");
}
return `⚠️ An error occurred: ${err.message || "unknown error"}`;
return t("seerr_request_errors.unknown", { msg: err.message || "unknown error" });
}

// ----------------- COMMON SEARCH LOGIC -----------------
Expand Down
38 changes: 32 additions & 6 deletions bot/roundupScheduler.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ const TICK_OFFSET_SECONDS = 5;

const WEEKDAY_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];

let started = false;
let pendingTimer = null;
let generation = 0;

function parseIntInRange(raw, fallback, min, max) {
const n = parseInt(raw, 10);
Expand Down Expand Up @@ -96,11 +97,22 @@ async function runTick(client, now = new Date()) {
}

export function start(client) {
if (started) {
logger.warn("Roundup scheduler start() called twice; ignoring second call");
return;
// Restarting the bot from the dashboard destroys the old Discord client
// and constructs a new one (see bot/botManager.js), so this must rebind
// to the fresh client rather than ignore the second call — otherwise the
// scheduler keeps ticking against a destroyed client until every tick
// fails and the failure circuit permanently opens for the week.
if (pendingTimer) {
clearTimeout(pendingTimer);
pendingTimer = null;
}
started = true;
// clearTimeout only helps if the previous tick hasn't fired yet. If a
// restart lands while an old tick is mid-flight (sendWeeklyRoundup does
// Jellyfin + Discord I/O and can take a while), the old chain's finally()
// would otherwise re-arm itself with the stale client after we've already
// set up the new one. The generation token makes any such stale chain a
// no-op instead of silently ticking against a destroyed client.
const myGeneration = ++generation;

const installedAt = getInstalledAt();
const now = new Date();
Expand Down Expand Up @@ -141,7 +153,9 @@ export function start(client) {
// the next hour. setInterval(HOUR_MS) drifts off the boundary across DST
// transitions and could push a post a full hour late.
const scheduleNext = () => {
setTimeout(() => {
if (myGeneration !== generation) return; // superseded by a newer start()
pendingTimer = setTimeout(() => {
if (myGeneration !== generation) return;
runTick(client)
.catch((err) =>
logger.error(`Weekly Roundup tick crash: ${err?.message || err}`)
Expand All @@ -151,3 +165,15 @@ export function start(client) {
};
scheduleNext();
}

// Called when the bot is stopped (not restarted) so the scheduler doesn't
// keep ticking against a destroyed Discord client — that would fail every
// tick, burn the failure counter, and open the circuit for the rest of the
// week even though nothing about the roundup content actually failed.
export function stop() {
generation++;
if (pendingTimer) {
clearTimeout(pendingTimer);
pendingTimer = null;
}
}
4 changes: 2 additions & 2 deletions bot/weeklyRoundup.js
Original file line number Diff line number Diff line change
Expand Up @@ -367,8 +367,8 @@ export async function sendWeeklyRoundup(client, channelId, now, options = {}) {
// schemes are allowed, never file:/gopher:/etc.
baseUrlOk = parsed.protocol === "http:" || parsed.protocol === "https:";
}
} catch {
/* fall through */
} catch (err) {
logger.debug(`${logPrefix}: JELLYFIN_BASE_URL failed to parse as URL: ${err?.message}`);
}
if (!baseUrlOk) {
const msg = `JELLYFIN_BASE_URL is missing or not a valid http(s) URL ("${baseUrl ?? ""}")`;
Expand Down
7 changes: 6 additions & 1 deletion jellyfin/libraryResolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import * as jellyfinApi from "../api/jellyfin.js";
import logger from "../utils/logger.js";
import { PersistentMap } from "../utils/persistentMap.js";

const SEEN_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; // 7 days — survive Sonarr/Radarr upgrade cycles
// Also backs the library seed's permanent "already existed" markers (see
// jellyfin/librarySeeder.js), which must outlive Sonarr/Radarr upgrade
// cycles indefinitely, not just 7 days. Real removal for deleted items
// happens via the daily libraryPruner.js scan, not TTL expiry — this is a
// backstop TTL, kept in step with roundup-first-seen's.
const SEEN_THRESHOLD_MS = 5 * 365 * 24 * 60 * 60 * 1000;

/**
* Fetches all libraries from Jellyfin and returns the library array,
Expand Down
8 changes: 8 additions & 0 deletions locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -473,5 +473,13 @@
"section_series": "Serien",
"field_continued": "(Forts.)",
"unknown_title": "Unbekannt"
},
"seerr_request_errors": {
"quota": "⚠️ {msg} Du hast dein Anfragelimit erreicht — kontaktiere einen Admin, falls das ein Fehler ist.",
"auth": "⚠️ Anfrage fehlgeschlagen: Authentifizierungsfehler. Prüfe die API-Key-Konfiguration des Bots.",
"server_error": "⚠️ Seerr hat einen Serverfehler zurückgegeben. Versuche es später erneut.",
"generic": "⚠️ Seerr-Fehler: {msg}",
"unreachable": "⚠️ Seerr konnte nicht erreicht werden. Prüfe, ob die Seerr-URL korrekt und erreichbar ist.",
"unknown": "⚠️ Ein Fehler ist aufgetreten: {msg}"
}
}
8 changes: 8 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -404,5 +404,13 @@
"section_series": "Series",
"field_continued": "(cont.)",
"unknown_title": "Unknown"
},
"seerr_request_errors": {
"quota": "⚠️ {msg} You've reached your request limit — contact an admin if you think this is a mistake.",
"auth": "⚠️ Request failed: authentication error. Check the bot's API key configuration.",
"server_error": "⚠️ Seerr returned a server error. Try again later.",
"generic": "⚠️ Seerr error: {msg}",
"unreachable": "⚠️ Could not reach Seerr. Check that your Seerr URL is correct and reachable.",
"unknown": "⚠️ An error occurred: {msg}"
}
}
9 changes: 6 additions & 3 deletions locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
"_meta": {
"language_name": "Français",
"language_code": "fr",
"contributors": ["purplelines", "GitHub Copilot"],
"contributors": [
"purplelines",
"GitHub Copilot"
],
"completion": "100%",
"last_updated": "2026-06-08",
"notes": "Traduction complète et révisée par purplelines avec l'aide de GitHub Copilot. Chaque traduction a été soigneusement vérifiée."
Expand Down Expand Up @@ -253,7 +256,7 @@
},
"actions": {
"start": "Démarrer le bot",
"stop": "Arrêter le bot",
"stop": "Arrêter le bot",
"restart": "Redémarrer le bot"
},
"messages": {
Expand Down Expand Up @@ -361,4 +364,4 @@
"webhook_secret_desc": "Votre secret de webhook est utilisé pour sécuriser les requêtes entrantes. Copiez-le ci-dessous — vous devrez l'ajouter en tant qu'en-tête HTTP personnalisé dans le plugin webhook Jellyfin:",
"notification_testing_info": "Ces tests contournent le point de terminaison webhook et s'exécutent en interne — ils fonctionnent sans l'en-tête X-Webhook-Secret. Assurez-vous d'avoir encore copié le secret et de l'avoir ajouté à votre plugin webhook Jellyfin afin que les notifications réelles de Jellyfin soient acceptées."
}
}
}
8 changes: 8 additions & 0 deletions locales/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -403,5 +403,13 @@
"section_series": "Serier",
"field_continued": "(forts.)",
"unknown_title": "Okänd"
},
"seerr_request_errors": {
"quota": "⚠️ {msg} Du har nått din förfrågningsgräns — kontakta en admin om detta verkar fel.",
"auth": "⚠️ Förfrågan misslyckades: autentiseringsfel. Kontrollera botens API-nyckelkonfiguration.",
"server_error": "⚠️ Seerr returnerade ett serverfel. Försök igen senare.",
"generic": "⚠️ Seerr-fel: {msg}",
"unreachable": "⚠️ Kunde inte nå Seerr. Kontrollera att Seerr-URL:en är korrekt och nåbar.",
"unknown": "⚠️ Ett fel uppstod: {msg}"
}
}
8 changes: 8 additions & 0 deletions locales/template.json
Original file line number Diff line number Diff line change
Expand Up @@ -404,5 +404,13 @@
"section_series": "Series",
"field_continued": "(cont.)",
"unknown_title": "Unknown"
},
"seerr_request_errors": {
"quota": "⚠️ {msg} You've reached your request limit — contact an admin if you think this is a mistake.",
"auth": "⚠️ Request failed: authentication error. Check the bot's API key configuration.",
"server_error": "⚠️ Seerr returned a server error. Try again later.",
"generic": "⚠️ Seerr error: {msg}",
"unreachable": "⚠️ Could not reach Seerr. Check that your Seerr URL is correct and reachable.",
"unknown": "⚠️ An error occurred: {msg}"
}
}
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
},
"overrides": {
"follow-redirects": "^1.16.0",
"undici": "^6.26.1"
"undici": "^6.26.1",
"body-parser": "^1.20.6"
}
}
8 changes: 8 additions & 0 deletions routes/botRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import rateLimit from "express-rate-limit";
import { createRequire } from "module";
import { authenticateToken } from "../utils/auth.js";
import { botState } from "../bot/botState.js";
import { stop as stopRoundupScheduler } from "../bot/roundupScheduler.js";
import cache from "../utils/cache.js";
import logger from "../utils/logger.js";

Expand Down Expand Up @@ -106,6 +107,13 @@ export function createBotRoutes({ startBot, jellyfinPoller }) {
logger.error("Error stopping Jellyfin poller:", error);
}

try {
stopRoundupScheduler();
logger.info("Weekly Roundup scheduler stopped");
} catch (error) {
logger.error("Error stopping Weekly Roundup scheduler:", error);
}

await botState.discordClient.destroy();
botState.isBotRunning = false;
botState.discordClient = null;
Expand Down
21 changes: 21 additions & 0 deletions utils/configFile.js
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,27 @@ export function loadConfigToEnv() {
}
}

// 5. Migrate old single EMBED_SHOW_OVERVIEW flag to the new split
// movies/episodes settings, so users who had it disabled (often for
// episode spoilers) don't silently get overviews turned back on.
if (
config.EMBED_SHOW_OVERVIEW !== undefined &&
config.EMBED_SHOW_OVERVIEW_MOVIES === undefined &&
config.EMBED_SHOW_OVERVIEW_EPISODES === undefined
) {
config.EMBED_SHOW_OVERVIEW_MOVIES = config.EMBED_SHOW_OVERVIEW;
config.EMBED_SHOW_OVERVIEW_EPISODES = config.EMBED_SHOW_OVERVIEW;
delete config.EMBED_SHOW_OVERVIEW;
logger.info(
"🔄 Migrated EMBED_SHOW_OVERVIEW → EMBED_SHOW_OVERVIEW_MOVIES/EMBED_SHOW_OVERVIEW_EPISODES"
);
if (writeConfig(config)) {
logger.info("✅ Embed overview migration saved to config.json");
} else {
logger.error("❌ Failed to save embed overview migration");
}
}

// --- LOAD INTO PROCESS.ENV ---
for (const [key, value] of Object.entries(config)) {
// Convert objects/arrays to JSON strings to avoid "[object Object]"
Expand Down
24 changes: 22 additions & 2 deletions utils/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const FALLBACK_LANG = "en";
const LANG_CODE_RE = /^[a-zA-Z]{2,3}(?:[_-][a-zA-Z0-9]{2,8})?$/;

let translations = null;
let englishFallback = null;
let loadedLang = null;

function safeLang(raw) {
Expand Down Expand Up @@ -54,6 +55,20 @@ function ensureLoaded() {
logger.warn(`[i18n] Locale '${lang}' not found, using '${FALLBACK_LANG}'.`);
}
}
// Per-key fallback: a locale file that exists but is missing a specific
// key (e.g. added after that translation was last updated) should still
// resolve to English instead of leaking the raw key into user-facing text.
// Reuse `fallback` above instead of re-reading en.json a second time.
englishFallback = lang === FALLBACK_LANG ? translations : fallback;
if (lang !== FALLBACK_LANG && !englishFallback) {
// Unlike the "primary locale missing" case above (expected for
// less-maintained translations), en.json missing entirely means
// per-key fallback has nothing to fall back to — every miss in the
// active locale will now silently return the raw key.
logger.error(
`[i18n] English fallback locale ('${FALLBACK_LANG}.json') failed to load; missing keys in '${lang}' will render as raw keys.`
);
}
loadedLang = lang;
}

Expand All @@ -74,12 +89,17 @@ function interpolate(str, vars) {
export function t(key, vars) {
ensureLoaded();
if (!key || typeof key !== "string") return String(key ?? "");
const value = lookup(translations, key);
if (typeof value !== "string") return key;
let value = lookup(translations, key);
if (typeof value !== "string") {
value = lookup(englishFallback, key);
if (typeof value !== "string") return key;
logger.warn(`[i18n] Key '${key}' missing in '${loadedLang}', falling back to '${FALLBACK_LANG}'.`);
}
return interpolate(value, vars);
}

export function resetI18nCache() {
translations = null;
englishFallback = null;
loadedLang = null;
}
Loading