diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 0b1cc24..1c50e26 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -10,8 +10,26 @@ on: workflow_dispatch: jobs: + audit: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: npm ci + + - name: Run npm audit + run: npm audit --audit-level=high + build: runs-on: ubuntu-latest + needs: audit permissions: contents: read diff --git a/.gitignore b/.gitignore index b5bff79..282d3c4 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,5 @@ coverage/ # Local notes TODO.md -SRP_ANALYSIS.md \ No newline at end of file +SRP_ANALYSIS.md +ToDo_v2.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f682a2..62afa4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [1.5.0] - 2026-04-14 + +### ✨ Added + +- **Anime quality profiles & server selection**: Separate default quality profiles and Radarr/Sonarr servers can now be configured for anime content (both TV series and movies). Anime is detected automatically via TMDB metadata (Animation genre + Japanese origin). If no anime-specific config is set, the standard movie/TV defaults are used — existing setups are unaffected +- **Jellyseerr `isAnime` flag**: When anime content is detected, the `isAnime: true` flag is included in the Jellyseerr request payload, allowing Jellyseerr to route to its anime-configured instance +- **Anime library toggle**: Each library row in the Jellyfin notification mapping UI now has a compact checkbox to manually mark it as an anime library. This is explicit and works regardless of library naming. When flagged, the `isAnime` flag is passed through to all notification paths (webhook, poller, WebSocket) — independent of TMDB metadata detection +- **CI: npm audit gate**: A new `audit` job runs `npm audit --audit-level=high` before the Docker build — vulnerable dependencies now block the pipeline + +### 🐛 Fixed + +- **Jellyfin library matching**: Libraries with `null` or `mixed` CollectionType no longer get incorrectly skipped during notification path matching +- **Docker config volume permissions**: Added entrypoint script to fix ownership on first run, preventing write failures for the non-root container user +- **Seerr error messages**: Request failures now surface specific messages (auth errors, server errors, connection refused) instead of generic "An error occurred" +- **Seerr `checkMediaStatus` error handling**: Network and 5xx errors are now propagated instead of silently returning `{ exists: false }` +- **Silent failures in dashboard**: Previously empty or unchecked `catch` blocks in the web dashboard now log errors at the appropriate level (`console.error`/`console.warn`/`console.debug`). Affected paths include bot status polling, logout, guild/channel loading, role loading, connection status checks, webhook secret loading, and localStorage cache access +- **Legacy library config migration**: The `JELLYFIN_NOTIFICATION_LIBRARIES` config is now parsed consistently across all notification paths. Legacy array format (`["libId1", "libId2"]`) and legacy string-value format (`{ libId: "channelId" }`) are both migrated transparently to the new object format (`{ libId: { channel, isAnime } }`) at read time + +### 🏗️ Code Quality + +- **Shared library resolver**: All three notification sources (webhook, poller, WebSocket) now go through shared `getLibraryChannels()`, `resolveTargetChannel()`, and `getLibraryAnimeFlag()` functions in `jellyfin/libraryResolver.js`, eliminating duplicated inline logic + +### 🗑️ Removed + +- **Legacy `.env` migration**: The automatic `.env` → `config.json` migration has been removed. If a `.env` file is detected, a warning is logged pointing to the web dashboard + +--- + ## [1.4.9] - 2026-04-03 ### 🔒 Security diff --git a/api/seerr.js b/api/seerr.js index 5ee4747..6990218 100644 --- a/api/seerr.js +++ b/api/seerr.js @@ -130,7 +130,8 @@ async function fetchFromServers(seerrUrl, apiKey, fetchDetails, extractData) { * @param {Array} requestedSeasons - Season numbers or ['all'] * @param {string} seerrUrl - Seerr API URL * @param {string} apiKey - Seerr API key - * @returns {Promise} Status object + * @returns {Promise} Status object { exists, available, status?, data? } + * @throws {Error} For network errors, authentication failures, or 5xx responses */ export async function checkMediaStatus( tmdbId, @@ -215,12 +216,13 @@ export async function checkMediaStatus( data: response.data, }; } catch (err) { - // If 404, media doesn't exist in Seerr + // If 404, media doesn't exist in Seerr — this is an expected state, not an error if (err.response && err.response.status === 404) { return { exists: false, available: false }; } - logger.warn("Error checking media status:", err?.message || err); - return { exists: false, available: false }; + // For all other errors (network, auth, 5xx) propagate so callers can surface them + logger.error("Error checking media status:", err?.message || err); + throw err; } } @@ -360,6 +362,7 @@ export async function sendRequest({ serverId = null, profileId = null, tags = null, + isAnime = false, isAutoApproved = null, seerrUrl, apiKey, @@ -383,6 +386,7 @@ export async function sendRequest({ const payload = { mediaType, mediaId: parseInt(tmdbId, 10), + ...(isAnime && { isAnime: true }), }; // Always include seasons field for TV shows (empty array = all seasons) @@ -396,10 +400,12 @@ export async function sendRequest({ logger.debug(`[SEERR] Using tags: ${payload.tags.join(", ")}`); } - // CRITICAL: Logic to handle auto-approval vs pending status - // Seerr will auto-approve requests if serverId/profileId are provided, - // regardless of the isAutoApproved flag. Therefore, we MUST NOT send these - // fields unless we explicitly want auto-approval. + // Auto-approval is controlled by the isAutoApproved flag AND the x-api-user header + // (set below). When isAutoApproved is false, we explicitly set payload.isAutoApproved = false + // and send x-api-user so the request runs under the mapped user's permissions. + // serverId/profileId are included in both branches because Seerr requires them + // for TV show requests to work correctly — the pending/approved outcome is determined + // by the combination of isAutoApproved and x-api-user, not by omitting server fields. if (isAutoApproved === true) { // User wants auto-approval - send all details diff --git a/app.js b/app.js index bfc7f68..af182ca 100644 --- a/app.js +++ b/app.js @@ -38,69 +38,6 @@ import { SENSITIVE_FIELDS, isMaskedValue } from "./utils/configSanitize.js"; // --- Helper Functions --- // --- CONFIGURATION --- -const ENV_PATH = path.join(process.cwd(), ".env"); - -function parseEnvFile(filePath) { - if (!fs.existsSync(filePath)) { - return {}; - } - - try { - const content = fs.readFileSync(filePath, "utf-8"); - const envVars = {}; - - content.split("\n").forEach((line) => { - line = line.trim(); - // Skip empty lines and comments - if (!line || line.startsWith("#")) return; - - const [key, ...valueParts] = line.split("="); - const trimmedKey = key.trim(); - const trimmedValue = valueParts.join("=").trim(); - - // Remove quotes if present - const cleanValue = trimmedValue.replace(/^["']|["']$/g, ""); - - if (trimmedKey && cleanValue) { - envVars[trimmedKey] = cleanValue; - } - }); - - return envVars; - } catch (error) { - logger.error("Error reading or parsing .env file:", error); - return {}; - } -} - -function migrateEnvToConfig() { - // Check if .env exists and config.json doesn't - if (fs.existsSync(ENV_PATH) && !fs.existsSync(CONFIG_PATH)) { - logger.info( - "🔄 Detected .env file. Migrating environment variables to config.json..." - ); - - const envVars = parseEnvFile(ENV_PATH); - const migratedConfig = { ...configTemplate }; - - // Map .env variables to config - for (const [key, value] of Object.entries(envVars)) { - if (key in migratedConfig) { - migratedConfig[key] = value; - } - } - - // Save migrated config using centralized writeConfig - if (writeConfig(migratedConfig)) { - logger.info("✅ Migration successful! config.json created from .env"); - logger.info( - "📝 You can now delete the .env file as it's no longer needed." - ); - } else { - logger.error("❌ Error saving migrated config - check permissions"); - } - } -} function loadConfig() { logger.debug("[LOADCONFIG] Checking CONFIG_PATH:", CONFIG_PATH); @@ -124,7 +61,10 @@ function loadConfig() { process.env.DISCORD_TOKEN ? "SET" : "UNDEFINED" ); } else { - logger.debug("[LOADCONFIG] Config file does not exist or failed to load"); + logger.warn("[LOADCONFIG] No config.json found — the bot cannot start correctly."); + if (fs.existsSync(path.join(__dirname, ".env"))) { + logger.warn("[LOADCONFIG] A .env file was detected. Anchorr no longer reads .env — configure the bot via the web dashboard at http://localhost:8282"); + } } return success; @@ -1081,9 +1021,6 @@ function configureWebServer() { } // --- INITIALIZE AND START SERVER --- -// First, check for .env migration before anything else -migrateEnvToConfig(); - logger.info("Initializing web server..."); configureWebServer(); logger.info("Web server configured successfully"); diff --git a/bot/botUtils.js b/bot/botUtils.js index 84c3bb2..763c38b 100644 --- a/bot/botUtils.js +++ b/bot/botUtils.js @@ -12,7 +12,9 @@ export function getOptionStringRobust( try { const v = interaction.options.getString(n); if (typeof v === "string" && v.length > 0) return v; - } catch (e) { } + } catch (e) { + logger.debug(`getOptionStringRobust: getString("${n}") threw: ${e?.message}`); + } } try { const data = (interaction.options && interaction.options.data) || []; @@ -22,11 +24,13 @@ export function getOptionStringRobust( return String(opt.value); } } - } catch (e) { } + } catch (e) { + logger.warn(`getOptionStringRobust: options.data fallback threw: ${e?.message}`); + } return null; } -export function parseQualityAndServerOptions(options, mediaType) { +export function parseQualityAndServerOptions(options, mediaType, isAnime = false) { let profileId = null; let serverId = null; @@ -78,10 +82,16 @@ export function parseQualityAndServerOptions(options, mediaType) { // Apply defaults from config if not specified if (profileId === null && serverId === null) { - const defaultQualityConfig = - mediaType === "movie" + let defaultQualityConfig; + if (isAnime && mediaType === "movie") { + defaultQualityConfig = process.env.DEFAULT_QUALITY_PROFILE_ANIME_MOVIE || process.env.DEFAULT_QUALITY_PROFILE_MOVIE; + } else if (isAnime) { + defaultQualityConfig = process.env.DEFAULT_QUALITY_PROFILE_ANIME || process.env.DEFAULT_QUALITY_PROFILE_TV; + } else { + defaultQualityConfig = mediaType === "movie" ? process.env.DEFAULT_QUALITY_PROFILE_MOVIE : process.env.DEFAULT_QUALITY_PROFILE_TV; + } if (defaultQualityConfig) { const [dProfileId, dServerId] = defaultQualityConfig.split("|"); @@ -92,7 +102,7 @@ export function parseQualityAndServerOptions(options, mediaType) { if (!isNaN(parsedProfileId) && !isNaN(parsedServerId)) { profileId = parsedProfileId; serverId = parsedServerId; - logger.debug(`Using default quality profile ID: ${profileId} from config`); + logger.debug(`Using default quality profile ID: ${profileId} from config${isAnime ? " (anime)" : ""}`); } else { logger.warn( `Invalid default quality config format - non-numeric values: profileId=${dProfileId}, serverId=${dServerId}` @@ -103,10 +113,16 @@ export function parseQualityAndServerOptions(options, mediaType) { } if (serverId === null) { - const defaultServerConfig = - mediaType === "movie" + let defaultServerConfig; + if (isAnime && mediaType === "movie") { + defaultServerConfig = process.env.DEFAULT_SERVER_ANIME_MOVIE || process.env.DEFAULT_SERVER_MOVIE; + } else if (isAnime) { + defaultServerConfig = process.env.DEFAULT_SERVER_ANIME || process.env.DEFAULT_SERVER_TV; + } else { + defaultServerConfig = mediaType === "movie" ? process.env.DEFAULT_SERVER_MOVIE : process.env.DEFAULT_SERVER_TV; + } if (defaultServerConfig) { const [dServerId] = defaultServerConfig.split("|"); diff --git a/bot/dailyPick.js b/bot/dailyPick.js index 68d3e31..ba32774 100644 --- a/bot/dailyPick.js +++ b/bot/dailyPick.js @@ -59,7 +59,10 @@ export async function sendDailyRandomPick(client) { return; } - const channel = await client.channels.fetch(channelId).catch(() => null); + const channel = await client.channels.fetch(channelId).catch((err) => { + logger.error(`[DAILY PICK] Failed to fetch channel ${channelId}:`, err); + return null; + }); if (!channel) { logger.warn(`Daily Random Pick channel not found: ${channelId}`); return; diff --git a/bot/interactions.js b/bot/interactions.js index 05ce85f..dcda61c 100644 --- a/bot/interactions.js +++ b/bot/interactions.js @@ -132,9 +132,17 @@ async function handleSearchOrRequest( } } + // Detect anime: Animation genre (id 16) + Japanese origin + const isAnime = + Array.isArray(details.genres) && + details.genres.some((g) => g.id === 16) && + details.original_language === "ja"; + if (isAnime) logger.info(`[REQUEST] Detected anime content: ${tmdbId}`); + const { profileId, serverId } = parseQualityAndServerOptions( options, - mediaType + mediaType, + isAnime ); let seasonsToRequest = ["all"]; @@ -158,6 +166,7 @@ async function handleSearchOrRequest( tags: tagIds, profileId, serverId, + isAnime, seerrUrl: getSeerrUrl(), apiKey: getSeerrApiKey(), discordUserId: interaction.user.id, @@ -165,7 +174,7 @@ async function handleSearchOrRequest( isAutoApproved: getSeerrAutoApprove(), }); logger.info( - `[REQUEST] Discord User ${interaction.user.id} requested ${mediaType} ${tmdbId}. Auto-Approve: ${getSeerrAutoApprove()}` + `[REQUEST] Discord User ${interaction.user.id} requested ${mediaType} ${tmdbId}. Auto-Approve: ${getSeerrAutoApprove()}${isAnime ? " [anime]" : ""}` ); if (process.env.NOTIFY_ON_AVAILABLE === "true") { @@ -241,7 +250,7 @@ async function handleSearchOrRequest( components.push(tagRow); } } catch (err) { - logger.debug( + logger.warn( "Failed to fetch tags for movie tag selector:", err?.message ); @@ -253,15 +262,19 @@ async function handleSearchOrRequest( logger.error("Error in handleSearchOrRequest:", err); let errorMessage = "⚠️ An error occurred."; - if (err.response && err.response.data && err.response.data.message) { - errorMessage = `⚠️ Seerr error: ${err.response.data.message}`; - } else if (err.message) { - if (err.message.includes("403")) { - errorMessage = - "⚠️ Request failed: You might have exceeded your quota or don't have permission."; - } else { - errorMessage = `⚠️ Error: ${err.message}`; + if (err.response) { + const status = err.response.status; + if (status === 401 || status === 403) { + errorMessage = "⚠️ Request failed: You might have exceeded your quota or don't have permission."; + } else if (status >= 500) { + errorMessage = "⚠️ Seerr returned a server error. Try again later."; + } else if (err.response.data?.message) { + errorMessage = `⚠️ Seerr error: ${err.response.data.message}`; } + } else if (err.code === "ECONNREFUSED" || err.code === "ETIMEDOUT" || err.code === "ENOTFOUND") { + errorMessage = "⚠️ Could not reach Seerr. Check that your Seerr URL is correct and reachable."; + } else if (err.message) { + errorMessage = `⚠️ Error: ${err.message}`; } if (isPrivateMode) { @@ -745,7 +758,7 @@ export function registerInteractions(client) { }) .filter((id) => id !== null); } catch (err) { - logger.debug( + logger.warn( "Failed to fetch tags for API call:", err?.message ); @@ -801,9 +814,17 @@ export function registerInteractions(client) { } } + // Detect anime: Animation genre (id 16) + Japanese origin + const isAnime = + Array.isArray(details.genres) && + details.genres.some((g) => g.id === 16) && + details.original_language === "ja"; + if (isAnime) logger.info(`[REQUEST BTN] Detected anime content: ${tmdbId}`); + const { profileId, serverId } = parseQualityAndServerOptions( {}, - mediaType + mediaType, + isAnime ); await seerrApi.sendRequest({ @@ -813,6 +834,7 @@ export function registerInteractions(client) { tags: selectedTagIds.length > 0 ? selectedTagIds : undefined, profileId, serverId, + isAnime, seerrUrl: getSeerrUrl(), apiKey: getSeerrApiKey(), discordUserId: interaction.user.id, @@ -820,7 +842,7 @@ export function registerInteractions(client) { isAutoApproved: getSeerrAutoApprove(), }); logger.info( - `[REQUEST] Discord User ${interaction.user.id} requested ${mediaType} ${tmdbId}. Auto-Approve: ${getSeerrAutoApprove()}` + `[REQUEST] Discord User ${interaction.user.id} requested ${mediaType} ${tmdbId}. Auto-Approve: ${getSeerrAutoApprove()}${isAnime ? " [anime]" : ""}` ); if (process.env.NOTIFY_ON_AVAILABLE === "true") { @@ -861,9 +883,22 @@ export function registerInteractions(client) { await interaction.editReply({ embeds: [embed], components }); } catch (err) { logger.error("Button request error:", err); + let userMessage = "⚠️ I could not send the request."; + if (err.response) { + const status = err.response.status; + if (status === 401 || status === 403) { + userMessage = "⚠️ Seerr authentication failed. Check your API key in the bot configuration."; + } else if (status >= 500) { + userMessage = "⚠️ Seerr returned a server error. Try again later."; + } else if (err.response.data?.message) { + userMessage = `⚠️ Seerr error: ${err.response.data.message}`; + } + } else if (err.code === "ECONNREFUSED" || err.code === "ETIMEDOUT" || err.code === "ENOTFOUND") { + userMessage = "⚠️ Could not reach Seerr. Check that your Seerr URL is correct and reachable."; + } try { await interaction.followUp({ - content: "⚠️ I could not send the request.", + content: userMessage, flags: 64, }); } catch (followUpErr) { @@ -1093,7 +1128,7 @@ export function registerInteractions(client) { components.push(tagRow); } } catch (err) { - logger.debug( + logger.warn( "Failed to fetch tags for season selector:", err?.message ); @@ -1290,6 +1325,11 @@ export function registerInteractions(client) { } } catch (outerErr) { logger.error("Interaction handler error:", outerErr); + try { + if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) { + await interaction.reply({ content: "⚠️ An unexpected error occurred. Please try again.", flags: 64 }); + } + } catch (_) { /* interaction may already be acknowledged */ } } }); } diff --git a/jellyfin/libraryResolver.js b/jellyfin/libraryResolver.js index 148eac1..e507021 100644 --- a/jellyfin/libraryResolver.js +++ b/jellyfin/libraryResolver.js @@ -52,8 +52,13 @@ export function getLibraryChannels() { try { const raw = process.env.JELLYFIN_NOTIFICATION_LIBRARIES; if (!raw) return {}; - if (typeof raw === "object") return raw; - return JSON.parse(raw); + const parsed = typeof raw === "object" ? raw : JSON.parse(raw); + // Legacy: array of library IDs — convert to object mapped to default channel + if (Array.isArray(parsed)) { + const defaultCh = process.env.JELLYFIN_CHANNEL_ID || ""; + return Object.fromEntries(parsed.map((id) => [id, defaultCh])); + } + return parsed; } catch (e) { logger.warn("Failed to parse JELLYFIN_NOTIFICATION_LIBRARIES:", e); return {}; @@ -62,16 +67,41 @@ export function getLibraryChannels() { /** * Resolves the target Discord channel for a given configLibraryId. + * Supports both legacy string format ({ libraryId: channelId }) and + * new object format ({ libraryId: { channel, isAnime } }). * Returns null if the library is not in the notification list. */ export function resolveTargetChannel(configLibraryId, libraryChannels) { const defaultChannelId = process.env.JELLYFIN_CHANNEL_ID; - if (Object.keys(libraryChannels).length > 0 && !libraryChannels[configLibraryId]) { + const libConfig = libraryChannels[configLibraryId]; + if (Object.keys(libraryChannels).length > 0 && libConfig === undefined) { logger.info(`❌ Skipping item from library ${configLibraryId} (not in notification list)`); logger.info(` Available libraries: ${Object.keys(libraryChannels).join(", ")}`); return null; } - return libraryChannels[configLibraryId] || defaultChannelId || null; + const channelId = + typeof libConfig === "object" && libConfig !== null + ? libConfig.channel + : libConfig; + const resolved = channelId || defaultChannelId || null; + if (resolved === null) { + logger.warn( + `⚠️ Library ${configLibraryId} is in the notification list but no channel is configured and no default channel is set — notification will be skipped.` + ); + } + return resolved; +} + +/** + * Returns whether the given library is marked as an anime library. + * Only meaningful with the new object format; returns false for legacy configs. + */ +export function getLibraryAnimeFlag(configLibraryId, libraryChannels) { + const libConfig = libraryChannels[configLibraryId]; + if (typeof libConfig === "object" && libConfig !== null) { + return !!libConfig.isAnime; + } + return false; } /** diff --git a/jellyfinPoller.js b/jellyfinPoller.js index 1eeec95..1c2085a 100644 --- a/jellyfinPoller.js +++ b/jellyfinPoller.js @@ -6,6 +6,7 @@ import { resolveConfigLibraryId, getLibraryChannels, resolveTargetChannel, + getLibraryAnimeFlag, deduplicator, } from "./jellyfin/libraryResolver.js"; @@ -186,9 +187,13 @@ class JellyfinPoller { const configLibraryId = resolveConfigLibraryId(libraryId, libraryIdMap); const targetChannelId = resolveTargetChannel(configLibraryId, libraryChannels); - if (!targetChannelId) continue; + if (!targetChannelId) { + logger.error(`No channel resolved for "${item.Name}" (libraryId: ${configLibraryId}) — set JELLYFIN_CHANNEL_ID or configure library channels`); + continue; + } - logger.info(`✅ Will send to channel: ${targetChannelId}`); + const isAnimeLibrary = getLibraryAnimeFlag(configLibraryId, libraryChannels); + logger.info(`✅ Will send to channel: ${targetChannelId}${isAnimeLibrary ? " [anime]" : ""}`); const webhookData = jellyfinApi.transformToWebhookFormat(item, baseUrl, serverId); @@ -197,7 +202,14 @@ class JellyfinPoller { webhookData, this.client, this.pendingRequests, - targetChannelId + targetChannelId, + 0, + null, + 0, + null, + false, + null, + isAnimeLibrary ); logger.info(`✅ Sent notification for ${itemType}: ${item.Name}`); } catch (err) { diff --git a/jellyfinWebSocket.js b/jellyfinWebSocket.js index aadca9b..86e57f3 100644 --- a/jellyfinWebSocket.js +++ b/jellyfinWebSocket.js @@ -7,6 +7,7 @@ import { resolveConfigLibraryId, getLibraryChannels, resolveTargetChannel, + getLibraryAnimeFlag, deduplicator, } from "./jellyfin/libraryResolver.js"; @@ -175,6 +176,7 @@ export class JellyfinWebSocketClient { logger.info(`✅ Cached ${libraries.length} library mappings`); } catch (err) { logger.warn("Failed to refresh library mappings:", err?.message || err); + logger.warn(`Library ID map is stale (${this.libraryIdMap.size} entries). Channel routing may be incorrect until the next successful refresh.`); } } @@ -320,9 +322,13 @@ export class JellyfinWebSocketClient { const configLibraryId = resolveConfigLibraryId(libraryId, this.libraryIdMap); const targetChannelId = resolveTargetChannel(configLibraryId, libraryChannels); - if (!targetChannelId) return; + if (!targetChannelId) { + logger.error(`No channel resolved for "${item.Name}" (libraryId: ${configLibraryId}) — set JELLYFIN_CHANNEL_ID or configure library channels`); + return; + } - logger.info(`✅ Will send to channel: ${targetChannelId}`); + const isAnimeLibrary = getLibraryAnimeFlag(configLibraryId, libraryChannels); + logger.info(`✅ Will send to channel: ${targetChannelId}${isAnimeLibrary ? " [anime]" : ""}`); try { const webhookData = jellyfinApi.transformToWebhookFormat(item, baseUrl, serverId); @@ -331,7 +337,14 @@ export class JellyfinWebSocketClient { webhookData, this.client, this.pendingRequests, - targetChannelId + targetChannelId, + 0, + null, + 0, + null, + false, + null, + isAnimeLibrary ); logger.info(`📤 Notification sent for "${item.Name}"`); diff --git a/jellyfinWebhook.js b/jellyfinWebhook.js index a5c46b0..4becfb9 100644 --- a/jellyfinWebhook.js +++ b/jellyfinWebhook.js @@ -11,6 +11,11 @@ import logger from "./utils/logger.js"; import { fetchOMDbData } from "./api/omdb.js"; import { findBestBackdrop } from "./api/tmdb.js"; import { isValidUrl } from "./utils/url.js"; +import { + getLibraryChannels, + resolveTargetChannel, + getLibraryAnimeFlag, +} from "./jellyfin/libraryResolver.js"; const debouncedSenders = new Map(); const sentNotifications = new Map(); @@ -111,6 +116,7 @@ function buildJellyfinUrl(_baseUrl, appendPath, hash) { } return u.toString(); } catch (_e) { + logger.warn(`buildJellyfinUrl: Invalid JELLYFIN_BASE_URL "${effectiveBaseUrl}": ${_e?.message}. Falling back to string concatenation.`); const baseNoSlash = String(effectiveBaseUrl || "").replace(/\/+$/, ""); const pathNoLead = String(appendPath || "").replace(/^\/+/, ""); const h = hash @@ -148,7 +154,8 @@ async function processAndSendNotification( seasonCount = 0, seasonDetails = null, isTestNotif = false, - onPendingRequestsChanged = null + onPendingRequestsChanged = null, + isAnimeLibrary = false ) { const { ItemType, @@ -181,7 +188,7 @@ async function processAndSendNotification( const testPrefix = isTestNotif ? "[TEST NOTIFICATION] " : ""; logger.info( - `${testPrefix}Webhook received: ItemType=${ItemType}, Name=${Name}, tmdbId=${tmdbId}, Provider_imdb=${data.Provider_imdb}` + `${testPrefix}Webhook received: ItemType=${ItemType}, Name=${Name}, tmdbId=${tmdbId}, Provider_imdb=${data.Provider_imdb}${isAnimeLibrary ? " [anime library]" : ""}` ); // Get embed customization settings from environment @@ -263,7 +270,7 @@ async function processAndSendNotification( apiCache.set(cacheKey, { data: details, timestamp: now }); logger.debug(`Cached TMDB data for ${tmdbId}`); } catch (e) { - logger.warn(`Could not fetch TMDB details for ${tmdbId}`); + logger.warn(`Could not fetch TMDB details for ${tmdbId}: ${e?.message || e}`); } } } @@ -877,78 +884,42 @@ export async function handleJellyfinWebhook(req, res, client, pendingRequests, o } } - // Parse notification libraries from config (supports both array and object format) - let notificationLibraries = {}; let libraryChannelId = null; + let isAnimeLibrary = false; // Check if this is a test notification - if so, use default channel if (isTestNotification) { libraryChannelId = process.env.JELLYFIN_CHANNEL_ID; logger.info(`🧪 Test notification detected. Using default channel: ${libraryChannelId}`); } else { - // Normal library checking logic - try { - const parsedLibraries = JSON.parse( - process.env.JELLYFIN_NOTIFICATION_LIBRARIES || "{}" - ); - - // Handle both array (legacy) and object format - if (Array.isArray(parsedLibraries)) { - // Convert array to object with default channel - parsedLibraries.forEach((libId) => { - notificationLibraries[libId] = process.env.JELLYFIN_CHANNEL_ID || ""; - }); - } else { - notificationLibraries = parsedLibraries; - } - } catch (e) { - logger.error("Error parsing JELLYFIN_NOTIFICATION_LIBRARIES:", e); - notificationLibraries = {}; - } + const notificationLibraries = getLibraryChannels(); + const libraryKeys = Object.keys(notificationLibraries); - logger.debug( - `Configured libraries: ${JSON.stringify(notificationLibraries)}` - ); + logger.debug(`Configured libraries: ${JSON.stringify(notificationLibraries)}`); - // Check if library is enabled and get specific channel - const libraryKeys = Object.keys(notificationLibraries); - if ( - libraryKeys.length > 0 && - libraryId && - libraryId in notificationLibraries - ) { - // Library found in configuration - use its specific channel or default if empty - libraryChannelId = - notificationLibraries[libraryId] || process.env.JELLYFIN_CHANNEL_ID; + if (libraryKeys.length > 0 && libraryId) { + libraryChannelId = resolveTargetChannel(libraryId, notificationLibraries); + if (libraryChannelId === null) { + // resolveTargetChannel already logged the skip + if (res) { + return res.status(200).send("OK: Notification skipped for disabled library."); + } + return; + } + isAnimeLibrary = getLibraryAnimeFlag(libraryId, notificationLibraries); logger.info( - `✅ Using channel: ${libraryChannelId} for configured library: ${libraryId}` - ); - } else if (libraryKeys.length > 0 && libraryId) { - // Library detected but not in configuration - disable notifications - logger.info( - `🚫 Library ${libraryId} not enabled in JELLYFIN_NOTIFICATION_LIBRARIES. Skipping notification.` - ); - if (res) { - return res - .status(200) - .send("OK: Notification skipped for disabled library."); - } - return; - } else if (libraryKeys.length > 0 && !libraryId) { - // Libraries are configured but we couldn't detect which library this item belongs to - // Use default channel instead of skipping to ensure notification is sent - libraryChannelId = process.env.JELLYFIN_CHANNEL_ID; - if (!isTestNotification) { + `✅ Using channel: ${libraryChannelId} for configured library: ${libraryId}${isAnimeLibrary ? " [anime]" : ""}` + ); + } else if (libraryKeys.length > 0 && !libraryId) { + // Libraries are configured but we couldn't detect which library this item belongs to + libraryChannelId = process.env.JELLYFIN_CHANNEL_ID; logger.warn( `⚠️ Could not detect library for item "${data.Name}". Using default channel: ${libraryChannelId}` ); - } - } else { - // No library filtering configured - use default channel - libraryChannelId = process.env.JELLYFIN_CHANNEL_ID; - logger.debug( - `No library filtering configured. Using default channel: ${libraryChannelId}` - ); + } else { + // No library filtering configured - use default channel + libraryChannelId = process.env.JELLYFIN_CHANNEL_ID; + logger.debug(`No library filtering configured. Using default channel: ${libraryChannelId}`); } } @@ -979,7 +950,8 @@ export async function handleJellyfinWebhook(req, res, client, pendingRequests, o 0, null, isTestMovie, - onPendingRequestsChanged + onPendingRequestsChanged, + isAnimeLibrary ); // Mark this movie as notified @@ -1103,7 +1075,8 @@ export async function handleJellyfinWebhook(req, res, client, pendingRequests, o 0, null, isTestNotification, - onPendingRequestsChanged + onPendingRequestsChanged, + isAnimeLibrary ); if (res) return res @@ -1125,7 +1098,8 @@ export async function handleJellyfinWebhook(req, res, client, pendingRequests, o 0, null, isTestNotification, - onPendingRequestsChanged + onPendingRequestsChanged, + isAnimeLibrary ); if (res) return res @@ -1200,7 +1174,8 @@ export async function handleJellyfinWebhook(req, res, client, pendingRequests, o seasonCount, seasonDetails, isBatchTestNotif, - onPendingRequestsChanged + onPendingRequestsChanged, + isAnimeLibrary ); const levelSent = getItemLevel(latestData.ItemType); @@ -1487,7 +1462,8 @@ export async function handleJellyfinWebhook(req, res, client, pendingRequests, o 0, null, isUnknownTest, - onPendingRequestsChanged + onPendingRequestsChanged, + isAnimeLibrary ); if (res) return res.status(200).send("OK: Notification sent."); } catch (err) { diff --git a/lib/config.js b/lib/config.js index 44a2f0f..3a39557 100644 --- a/lib/config.js +++ b/lib/config.js @@ -31,8 +31,12 @@ export const configTemplate = { ROLE_BLOCKLIST: [], DEFAULT_QUALITY_PROFILE_MOVIE: "", DEFAULT_QUALITY_PROFILE_TV: "", + DEFAULT_QUALITY_PROFILE_ANIME: "", + DEFAULT_QUALITY_PROFILE_ANIME_MOVIE: "", DEFAULT_SERVER_MOVIE: "", DEFAULT_SERVER_TV: "", + DEFAULT_SERVER_ANIME: "", + DEFAULT_SERVER_ANIME_MOVIE: "", EMBED_SHOW_BACKDROP: "true", EMBED_SHOW_OVERVIEW: "true", EMBED_SHOW_GENRE: "true", diff --git a/locales/de.json b/locales/de.json index 15f5b62..d712579 100644 --- a/locales/de.json +++ b/locales/de.json @@ -18,7 +18,8 @@ "error": "Fehler", "success": "Erfolgreich", "username": "Benutzername", - "password": "Passwort" + "password": "Passwort", + "processing": "Wird verarbeitet..." }, "auth": { "login": "Anmelden", @@ -106,6 +107,14 @@ "default_tv_server": "Standard-TV-Server", "default_tv_server_help": "Sonarr-Server für TV-Serien-Anfragen", "use_seerr_default": "Seerr-Standard verwenden", + "default_anime_quality": "Standard-Anime-Qualität", + "default_anime_quality_help": "Qualitätsprofil für Anime-TV-Serien (Sonarr). Fällt auf TV-Standard zurück, wenn nicht gesetzt.", + "default_anime_movie_quality": "Standard-Anime-Film-Qualität", + "default_anime_movie_quality_help": "Qualitätsprofil für Anime-Filme (Radarr). Fällt auf Film-Standard zurück, wenn nicht gesetzt.", + "default_anime_server": "Standard-Anime-Server", + "default_anime_server_help": "Sonarr-Server für Anime-TV-Serien. Fällt auf TV-Server zurück, wenn nicht gesetzt.", + "default_anime_movie_server": "Standard-Anime-Film-Server", + "default_anime_movie_server_help": "Radarr-Server für Anime-Filme. Fällt auf Film-Server zurück, wenn nicht gesetzt.", "tmdb_api_key": "TMDB API-Schlüssel", "tmdb_api_key_help": "Ein TMDB API-Schlüssel ist erforderlich für die Suche und das Abrufen von Film-/Serien-Details. Du findest ihn auf der API-Einstellungsseite deines TMDB-Kontos nach der Anmeldung.", "omdb_api_key": "OMDb API-Schlüssel (Optional)", @@ -192,6 +201,83 @@ "test_episodes_notification": "Episoden testen", "test_batch_episodes_notification": "Episoden (Batch) testen", "save_button": "Einstellungen speichern", + "debounce_title": "Episoden-Benachrichtigungs-Verzögerung", + "debounce_unit": "Sekunden", + "debounce_range": "(Bereich: 1 Sekunde – 10 Minuten)", + "debounce_help": "Bestimmt, wie lange auf weitere Episoden/Staffeln gewartet wird, bevor eine gebündelte Benachrichtigung gesendet wird.", + "library_channel_mapping_title": "Bibliothek-Kanal-Zuordnung", + "library_channel_mapping_help": "Weise bestimmten Jellyfin-Bibliotheken verschiedene Discord-Kanäle zu. Klicke auf Bibliotheken laden, um deine Bibliotheken zu laden, und wähle dann einen Kanal für jede aus. Nicht angehakte Bibliotheken senden keine Benachrichtigungen.", + "fetch_libraries": "Bibliotheken laden", + "embed_customization_title": "Embed-Anpassung", + "embed_customization_help": "Passe an, welche Elemente in deinen Discord-Benachrichtigungs-Embeds erscheinen.", + "embed_backdrop": "Hintergrundbild", + "embed_backdrop_help": "Großes Hintergrundbild", + "embed_overview": "Übersicht/Beschreibung", + "embed_overview_help": "Handlungszusammenfassung", + "embed_genre": "Genre", + "embed_genre_help": "Genre-Feld", + "embed_runtime": "Laufzeit", + "embed_runtime_help": "Dauer-Feld", + "embed_rating": "Bewertung", + "embed_rating_help": "IMDB/TMDB-Bewertung", + "embed_letterboxd_btn": "Letterboxd-Button", + "embed_letterboxd_btn_help": "Link zu Letterboxd", + "embed_imdb_btn": "IMDb-Button", + "embed_imdb_btn_help": "Link zu IMDb", + "embed_watch_btn": "Jetzt-ansehen-Button", + "embed_watch_btn_help": "Link zu Jellyfin", + "mappings_legend": "Benutzer-Zuordnung", + "mappings_pane_description": "Weise Discord-Benutzer ihren Seerr-Konten zu. Wenn ein Benutzer eine Anfrage von Discord stellt, wird sie mit seinem Seerr-Konto verknüpft statt mit dem API-Schlüssel-Besitzer.", + "add_new_mapping": "Neue Zuordnung hinzufügen", + "auto_map_seerr": "Auto-Zuordnung von Seerr", + "sync_seerr": "Mit Seerr synchronisieren", + "refresh_users": "Benutzer aktualisieren", + "discord_user_label": "Discord-Benutzer", + "discord_user_placeholder": "Discord-Benutzer suchen...", + "discord_user_help": "Wähle ein Discord-Server-Mitglied, oder gib die Benutzer-ID manuell unten ein, falls die Person nicht in der Liste erscheint.", + "discord_user_id_placeholder": "Oder Discord-Benutzer-ID manuell eingeben...", + "seerr_user_label": "Seerr-Benutzer", + "seerr_user_placeholder": "Seerr-Benutzer suchen...", + "seerr_user_help": "Das entsprechende Seerr-Konto auswählen", + "add_mapping_btn": "Zuordnung hinzufügen", + "roles_legend": "Rollen-Berechtigungen", + "roles_pane_description": "Kontrolliere, wer Bot-Befehle verwenden kann, indem du rollenbasierte Berechtigungen konfigurierst. Wenn keine Rollen in der Erlaubnisliste ausgewählt sind, kann jeder Befehle verwenden. Wenn eine Rolle zur Sperrliste hinzugefügt wird, werden Mitglieder mit dieser Rolle unabhängig von der Erlaubnisliste blockiert.", + "allowlist_title": "Erlaubnisliste (Nur diese Rollen erlauben)", + "allowlist_help": "Wenn leer, können alle Mitglieder Befehle verwenden. Wenn Rollen ausgewählt sind, können nur Mitglieder mit diesen Rollen Befehle verwenden.", + "blocklist_title": "Sperrliste (Diese Rollen blockieren)", + "blocklist_help": "Mitglieder mit diesen Rollen können niemals Bot-Befehle verwenden, auch wenn sie eine erlaubte Rolle haben.", + "misc_legend": "Verschiedenes", + "misc_pane_description": "Optionale Einstellungen zur Verbesserung deiner Bot-Erfahrung. Diese Funktionen können aktiviert werden, nachdem dein Bot vollständig konfiguriert ist und läuft.", + "auto_start_label": "Bot beim Server-Start automatisch starten", + "auto_start_help": "Wenn aktiviert, startet der Bot automatisch, wenn der Container/Server neu startet und gültige Discord-Zugangsdaten vorhanden sind.", + "notify_on_available_label": "PM senden, wenn Anfrage verfügbar ist", + "notify_on_available_help": "Wenn aktiviert, erhalten Benutzer eine private Nachricht, wenn ihr angeforderter Inhalt auf Jellyfin verfügbar wird.", + "ephemeral_mode_label": "Ephemerer Nachrichten-Modus", + "ephemeral_mode_help": "Wenn aktiviert, werden alle Bot-Antworten als ephemere Nachrichten gesendet (nur für den Benutzer sichtbar, der den Befehl verwendet hat). Wenn deaktiviert, sind erfolgreiche Ergebnisse für alle im Kanal sichtbar.", + "auto_approve_label": "Seerr Anfragen automatisch genehmigen", + "auto_approve_help": "Wenn aktiviert, werden Anfragen über den Bot in Seerr automatisch genehmigt. Wenn deaktiviert, bleiben Anfragen ausstehend für manuelle Genehmigung.", + "daily_pick_title": "Tägliche Zufallsauswahl aktivieren", + "daily_pick_help": "Sende täglich eine Empfehlung von zufälligen Medien an einen Discord-Kanal. Großartig zum Entdecken versteckter Schätze!", + "daily_pick_channel_label": "Kanal", + "daily_pick_channel_placeholder": "Kanal auswählen...", + "daily_pick_channel_help": "Discord-Kanal, in den tägliche Auswahlen gesendet werden", + "daily_pick_interval_label": "Intervall (Minuten)", + "daily_pick_interval_help": "Wie oft eine Zufallsauswahl in Minuten gesendet wird (Standard: 1440 = 24 Stunden)", + "test_random_pick": "Zufallsauswahl testen", + "embed_colors_title": "Discord Embed-Farben", + "embed_colors_help": "Passe die Farben der Discord-Benachrichtigungs-Embeds für verschiedene Inhaltstypen an.", + "embed_color_movie_label": "🎬 Filme", + "embed_color_series_label": "📺 TV-Serien", + "embed_color_season_label": "📺 Staffeln", + "embed_color_episode_single_label": "📺 Einzelne Episode", + "embed_color_episode_few_label": "📺 Wenige Episoden (2-5)", + "embed_color_episode_many_label": "📺 Viele Episoden (6+)", + "embed_color_search_label": "🔍 Suchergebnisse", + "embed_color_success_label": "✅ Anfrage erfolgreich", + "app_settings_title": "App-Einstellungen", + "app_settings_help": "Allgemeine Anwendungseinstellungen für Anchorr.", + "library_anime_label": "Anime", + "library_anime_title": "Als Anime-Bibliothek markieren", "bot_token": "Bot Token", "server_id": "Discord Server ID", "api_key": "API Schlüssel", @@ -279,7 +365,8 @@ "loading_members_bot_running": "Fehler beim Laden der Mitglieder. Läuft der Bot?", "loading_logs": "Fehler beim Laden der Logs", "bot_must_be_running": "Bot muss laufen, um Rollen zu laden", - "no_roles_available": "Keine Rollen verfügbar" + "no_roles_available": "Keine Rollen verfügbar", + "logout_failed": "Abmeldung fehlgeschlagen. Bitte erneut versuchen." }, "modals": { "start_bot_yes": "Ja, Bot starten", diff --git a/locales/en.json b/locales/en.json index 8819510..492b8cd 100644 --- a/locales/en.json +++ b/locales/en.json @@ -18,7 +18,8 @@ "error": "Error", "success": "Success", "username": "Username", - "password": "Password" + "password": "Password", + "processing": "Processing..." }, "auth": { "login": "Login", @@ -106,6 +107,14 @@ "default_tv_server": "Default TV Server", "default_tv_server_help": "Sonarr server for TV show requests", "use_seerr_default": "Use Seerr default", + "default_anime_quality": "Default Anime Quality", + "default_anime_quality_help": "Quality profile for anime TV series (Sonarr). Falls back to TV default if unset.", + "default_anime_movie_quality": "Default Anime Movie Quality", + "default_anime_movie_quality_help": "Quality profile for anime movies (Radarr). Falls back to movie default if unset.", + "default_anime_server": "Default Anime Server", + "default_anime_server_help": "Sonarr server for anime TV series. Falls back to TV server if unset.", + "default_anime_movie_server": "Default Anime Movie Server", + "default_anime_movie_server_help": "Radarr server for anime movies. Falls back to movie server if unset.", "tmdb_api_key": "TMDB API Key", "tmdb_api_key_help": "A TMDB API Key is required for searching and fetching movie/show details. You can find it in the API settings page of your TMDB account after you log in.", "omdb_api_key": "OMDb API Key (Optional)", @@ -126,7 +135,84 @@ "test_batch_seasons_notification": "Test Seasons Batch", "test_episodes_notification": "Test Episodes", "test_batch_episodes_notification": "Test Episodes Batch", - "save_button": "Save Settings" + "save_button": "Save Settings", + "debounce_title": "Episode Notification Debounce", + "debounce_unit": "seconds", + "debounce_range": "(range: 1 second - 10 minutes)", + "debounce_help": "Determines how long to wait before sending notifications for multiple episodes/seasons, bundling them into a single notification message.", + "library_channel_mapping_title": "Library Channel Mapping", + "library_channel_mapping_help": "Map specific Jellyfin libraries to different Discord channels. Click \"Fetch Libraries\" to load your libraries, then select a channel for each one. Unchecked libraries will not send any notifications.", + "fetch_libraries": "Fetch Libraries", + "embed_customization_title": "Embed Customization", + "embed_customization_help": "Customize which elements appear in your Discord notification embeds.", + "embed_backdrop": "Backdrop Image", + "embed_backdrop_help": "Large background image", + "embed_overview": "Overview/Description", + "embed_overview_help": "Plot summary text", + "embed_genre": "Genre", + "embed_genre_help": "Genre field", + "embed_runtime": "Runtime", + "embed_runtime_help": "Duration field", + "embed_rating": "Rating", + "embed_rating_help": "IMDB/TMDB rating", + "embed_letterboxd_btn": "Letterboxd Button", + "embed_letterboxd_btn_help": "Link to Letterboxd", + "embed_imdb_btn": "IMDb Button", + "embed_imdb_btn_help": "Link to IMDb", + "embed_watch_btn": "Watch Now Button", + "embed_watch_btn_help": "Link to Jellyfin", + "mappings_legend": "User Mapping", + "mappings_pane_description": "Map Discord users to their Seerr accounts. When a user makes a request from Discord, it will be associated with their Seerr account instead of the API key owner.", + "add_new_mapping": "Add New Mapping", + "auto_map_seerr": "Auto-Map from Seerr", + "sync_seerr": "Sync with Seerr", + "refresh_users": "Refresh Users", + "discord_user_label": "Discord User", + "discord_user_placeholder": "Search Discord users...", + "discord_user_help": "Select a Discord server member, or enter their User ID manually below if they don't appear in the list.", + "discord_user_id_placeholder": "Or enter Discord User ID manually...", + "seerr_user_label": "Seerr User", + "seerr_user_placeholder": "Search Seerr users...", + "seerr_user_help": "Select the corresponding Seerr account", + "add_mapping_btn": "Add Mapping", + "roles_legend": "Role Permissions", + "roles_pane_description": "Control who can use bot commands by configuring role-based permissions. If no roles are selected in the allowlist, everyone can use commands. If any role is added to the blocklist, members with that role will be blocked regardless of allowlist settings.", + "allowlist_title": "Allowlist (Allow Only These Roles)", + "allowlist_help": "If empty, all members can use commands. If roles are selected, only members with these roles can use commands.", + "blocklist_title": "Blocklist (Block These Roles)", + "blocklist_help": "Members with these roles will never be able to use bot commands, even if they have an allowed role.", + "misc_legend": "Miscellaneous Settings", + "misc_pane_description": "Optional settings to enhance your bot experience. These are extra features that can be enabled after your bot is fully configured and running.", + "auto_start_label": "Auto-start bot on server boot", + "auto_start_help": "If enabled, the bot starts automatically when the container/server restarts and valid Discord credentials are present.", + "notify_on_available_label": "Send PM when request is available", + "notify_on_available_help": "When enabled, users will receive a private message when their requested content becomes available on Jellyfin.", + "ephemeral_mode_label": "Ephemeral Message Mode", + "ephemeral_mode_help": "When enabled, all bot responses are sent as ephemeral messages (only visible to the user who used the command). When disabled, successful results are visible to everyone in the channel.", + "auto_approve_label": "Seerr Auto-Approve Requests", + "auto_approve_help": "When enabled, requests made through the bot will be automatically approved in Seerr. If disabled, requests will remain \"Pending\" for manual approval.", + "daily_pick_title": "Enable Daily Random Pick", + "daily_pick_help": "Send a daily recommendation of random media to a Discord channel. Great for discovering hidden gems!", + "daily_pick_channel_label": "Channel", + "daily_pick_channel_placeholder": "Select a channel...", + "daily_pick_channel_help": "Discord channel where daily picks will be sent", + "daily_pick_interval_label": "Interval (minutes)", + "daily_pick_interval_help": "How often to send a random pick in minutes (default: 1440 = 24 hours)", + "test_random_pick": "Test Random Pick", + "embed_colors_title": "Discord Embed Colors", + "embed_colors_help": "Customize the colors of Discord notification embeds for different content types.", + "embed_color_movie_label": "🎬 Movies", + "embed_color_series_label": "📺 TV Series", + "embed_color_season_label": "📺 Seasons", + "embed_color_episode_single_label": "📺 Single Episode", + "embed_color_episode_few_label": "📺 Few Episodes (2-5)", + "embed_color_episode_many_label": "📺 Many Episodes (6+)", + "embed_color_search_label": "🔍 Search Results", + "embed_color_success_label": "✅ Request Success", + "app_settings_title": "App Settings", + "app_settings_help": "General application settings for Anchorr.", + "library_anime_label": "Anime", + "library_anime_title": "Mark as anime library" }, "ui": { "hide_header": "Hide header", @@ -207,7 +293,8 @@ "loading_members_bot_running": "Error loading members. Is bot running?", "loading_logs": "Error loading logs", "bot_must_be_running": "Bot must be running to load roles", - "no_roles_available": "No roles available" + "no_roles_available": "No roles available", + "logout_failed": "Logout failed. Please try again." }, "modals": { "start_bot_yes": "Yes, Start Bot", diff --git a/locales/sv.json b/locales/sv.json index 86c5213..24eee9c 100644 --- a/locales/sv.json +++ b/locales/sv.json @@ -18,7 +18,8 @@ "error": "Fel", "success": "succé", "username": "Användarnamn", - "password": "Lösenord" + "password": "Lösenord", + "processing": "Bearbetar..." }, "auth": { "login": "Logga in", @@ -73,6 +74,10 @@ "discord_token_help": "Klistra in din Discord-token här", "bot_id": "Bot-ID", "bot_id_help": "Unikt ID för din bot", + "next_steps_title": "Nästa steg", + "next_step_1": "Klicka på knappen Spara inställningar längst ned i sidofältet", + "next_step_2": "Klicka på knappen Starta bot högst upp på sidan", + "next_step_3": "När boten körs fylls listrutorna nedan automatiskt med dina servrar och kanaler", "auto_start_info": "Starta automatiskt vid uppstart", "discord_server": "Discord-server", "loading_servers": "Laddar servrar...", @@ -90,6 +95,26 @@ "seerr_api_key": "Seerr API-nyckel", "seerr_api_key_help": "Ange Seerr API-nyckel", "test_connection": "Testa anslutning", + "load_profiles_servers": "Ladda profiler och servrar", + "default_quality_profiles": "Standardkvalitetsprofiler (valfritt)", + "default_quality_profiles_help": "Ange standardkvalitetsprofiler och servrar för mediebegäran. Dessa används när användare inte anger en profil via kommandot /request.", + "default_movie_quality": "Standardfilmkvalitet", + "default_movie_quality_help": "Kvalitetsprofil för filmbegäran (Radarr)", + "default_tv_quality": "Standard-TV-kvalitet", + "default_tv_quality_help": "Kvalitetsprofil för TV-seriebegäran (Sonarr)", + "default_movie_server": "Standardfilmserver", + "default_movie_server_help": "Radarr-server för filmbegäran", + "default_tv_server": "Standard-TV-server", + "default_tv_server_help": "Sonarr-server för TV-seriebegäran", + "use_seerr_default": "Använd Seerr-standard", + "default_anime_quality": "Standardkvalitet för anime", + "default_anime_quality_help": "Kvalitetsprofil för anime-TV-serier (Sonarr). Faller tillbaka på TV-standard om ej inställt.", + "default_anime_movie_quality": "Standardkvalitet för animefilmer", + "default_anime_movie_quality_help": "Kvalitetsprofil för animefilmer (Radarr). Faller tillbaka på filmstandard om ej inställt.", + "default_anime_server": "Standardserver för anime", + "default_anime_server_help": "Sonarr-server för anime-TV-serier. Faller tillbaka på TV-server om ej inställt.", + "default_anime_movie_server": "Standardserver för animefilmer", + "default_anime_movie_server_help": "Radarr-server för animefilmer. Faller tillbaka på filmserver om ej inställt.", "tmdb_api_key": "TMDB API-nyckel", "tmdb_api_key_help": "Ange TMDB API-nyckel", "omdb_api_key": "OMDB API-nyckel", @@ -110,7 +135,84 @@ "test_batch_seasons_notification": "Testa säsonger (batch)", "test_episodes_notification": "Testa episoder", "test_batch_episodes_notification": "Testa episoder (batch)", - "save_button": "Spara inställningar" + "save_button": "Spara inställningar", + "debounce_title": "Debounce för avsnittnotifieringar", + "debounce_unit": "sekunder", + "debounce_range": "(intervall: 1 sekund – 10 minuter)", + "debounce_help": "Bestämmer hur länge som väntas på fler avsnitt/säsonger innan en samlad notifiering skickas.", + "library_channel_mapping_title": "Bibliotek-kanalmappning", + "library_channel_mapping_help": "Mappa specifika Jellyfin-bibliotek till olika Discord-kanaler. Klicka på \"Hämta bibliotek\" för att ladda dina bibliotek och välj sedan en kanal för varje. Obockade bibliotek skickar inga notifieringar.", + "fetch_libraries": "Hämta bibliotek", + "embed_customization_title": "Anpassa embeds", + "embed_customization_help": "Anpassa vilka element som visas i dina Discord-notifieringsembeds.", + "embed_backdrop": "Bakgrundsbild", + "embed_backdrop_help": "Stor bakgrundsbild", + "embed_overview": "Översikt/Beskrivning", + "embed_overview_help": "Handlingssammanfattning", + "embed_genre": "Genre", + "embed_genre_help": "Genrefält", + "embed_runtime": "Speltid", + "embed_runtime_help": "Längdfält", + "embed_rating": "Betyg", + "embed_rating_help": "IMDB/TMDB-betyg", + "embed_letterboxd_btn": "Letterboxd-knapp", + "embed_letterboxd_btn_help": "Länk till Letterboxd", + "embed_imdb_btn": "IMDb-knapp", + "embed_imdb_btn_help": "Länk till IMDb", + "embed_watch_btn": "Titta nu-knapp", + "embed_watch_btn_help": "Länk till Jellyfin", + "mappings_legend": "Användarmappning", + "mappings_pane_description": "Mappa Discord-användare till deras Seerr-konton. När en användare gör en begäran från Discord kopplas den till deras Seerr-konto istället för API-nyckelägaren.", + "add_new_mapping": "Lägg till ny mappning", + "auto_map_seerr": "Auto-mappa från Seerr", + "sync_seerr": "Synkronisera med Seerr", + "refresh_users": "Uppdatera användare", + "discord_user_label": "Discord-användare", + "discord_user_placeholder": "Sök Discord-användare...", + "discord_user_help": "Välj en Discord-servermedlem, eller ange deras användar-ID manuellt nedan om de inte visas i listan.", + "discord_user_id_placeholder": "Eller ange Discord-användar-ID manuellt...", + "seerr_user_label": "Seerr-användare", + "seerr_user_placeholder": "Sök Seerr-användare...", + "seerr_user_help": "Välj motsvarande Seerr-konto", + "add_mapping_btn": "Lägg till mappning", + "roles_legend": "Rollbehörigheter", + "roles_pane_description": "Kontrollera vem som kan använda bot-kommandon genom att konfigurera rollbaserade behörigheter. Om inga roller väljs i tillåtlistan kan alla använda kommandon. Om en roll läggs till i blockeringslistan blockeras medlemmar med den rollen oavsett tillåtlisteinställningar.", + "allowlist_title": "Tillåtlista (Tillåt endast dessa roller)", + "allowlist_help": "Om tom kan alla medlemmar använda kommandon. Om roller väljs kan bara medlemmar med dessa roller använda kommandon.", + "blocklist_title": "Blockeringslista (Blockera dessa roller)", + "blocklist_help": "Medlemmar med dessa roller kan aldrig använda bot-kommandon, även om de har en tillåten roll.", + "misc_legend": "Diverse", + "misc_pane_description": "Valfria inställningar för att förbättra din botupplevelse. Dessa är extra funktioner som kan aktiveras efter att din bot är fullt konfigurerad och igång.", + "auto_start_label": "Starta bot automatiskt vid serverstart", + "auto_start_help": "Om aktiverat startar boten automatiskt när containern/servern startar om och giltiga Discord-uppgifter finns.", + "notify_on_available_label": "Skicka PM när begäran är tillgänglig", + "notify_on_available_help": "När aktiverat får användare ett privat meddelande när deras begärda innehåll blir tillgängligt på Jellyfin.", + "ephemeral_mode_label": "Efemeriskt meddelandeläge", + "ephemeral_mode_help": "När aktiverat skickas alla botsvar som efemeriska meddelanden (endast synliga för användaren som använde kommandot). När inaktiverat är framgångsrika resultat synliga för alla i kanalen.", + "auto_approve_label": "Seerr auto-godkänn begäranden", + "auto_approve_help": "När aktiverat godkänns begäranden via boten automatiskt i Seerr. Om inaktiverat förblir begäranden \"Väntande\" för manuellt godkännande.", + "daily_pick_title": "Aktivera dagligt slumpmässigt val", + "daily_pick_help": "Skicka en daglig rekommendation av slumpmässiga medier till en Discord-kanal. Utmärkt för att hitta dolda pärlor!", + "daily_pick_channel_label": "Kanal", + "daily_pick_channel_placeholder": "Välj en kanal...", + "daily_pick_channel_help": "Discord-kanal dit dagliga val skickas", + "daily_pick_interval_label": "Intervall (minuter)", + "daily_pick_interval_help": "Hur ofta ett slumpmässigt val skickas i minuter (standard: 1440 = 24 timmar)", + "test_random_pick": "Testa slumpmässigt val", + "embed_colors_title": "Discord embed-färger", + "embed_colors_help": "Anpassa färgerna på Discord-notifieringsembeds för olika innehållstyper.", + "embed_color_movie_label": "🎬 Filmer", + "embed_color_series_label": "📺 TV-serier", + "embed_color_season_label": "📺 Säsonger", + "embed_color_episode_single_label": "📺 Enskilt avsnitt", + "embed_color_episode_few_label": "📺 Få avsnitt (2-5)", + "embed_color_episode_many_label": "📺 Många avsnitt (6+)", + "embed_color_search_label": "🔍 Sökresultat", + "embed_color_success_label": "✅ Begäran lyckades", + "app_settings_title": "App-inställningar", + "app_settings_help": "Allmänna programinställningar för Anchorr.", + "library_anime_label": "Anime", + "library_anime_title": "Markera som anime-bibliotek" }, "ui": { "hide_header": "Dölj rubrik", @@ -191,7 +293,8 @@ "loading_members_bot_running": "Fel vid laddning av medlemmar. Körs boten?", "loading_logs": "Fel vid laddning av loggar", "bot_must_be_running": "Boten måste köras för att ladda roller", - "no_roles_available": "Inga roller tillgängliga" + "no_roles_available": "Inga roller tillgängliga", + "logout_failed": "Utloggning misslyckades. Försök igen." }, "modals": { "start_bot_yes": "Ja, starta bot", diff --git a/locales/template.json b/locales/template.json index fd0a98a..8bac387 100644 --- a/locales/template.json +++ b/locales/template.json @@ -18,7 +18,8 @@ "error": "", "success": "", "username": "", - "password": "" + "password": "", + "processing": "" }, "auth": { "login": "", @@ -73,11 +74,20 @@ "discord_token_help": "", "bot_id": "", "bot_id_help": "", + "next_steps_title": "", + "next_step_1": "", + "next_step_2": "", + "next_step_3": "", "auto_start_info": "", "discord_server": "", "loading_servers": "", "discord_server_help": "", "default_channel": "", + "episode_channel": "", + "episode_channel_help": "", + "season_channel": "", + "season_channel_help": "", + "use_default_channel": "", "select_server_first": "", "default_channel_help": "", "seerr_url": "", @@ -85,6 +95,26 @@ "seerr_api_key": "", "seerr_api_key_help": "", "test_connection": "", + "load_profiles_servers": "", + "default_quality_profiles": "", + "default_quality_profiles_help": "", + "default_movie_quality": "", + "default_movie_quality_help": "", + "default_tv_quality": "", + "default_tv_quality_help": "", + "default_movie_server": "", + "default_movie_server_help": "", + "default_tv_server": "", + "default_tv_server_help": "", + "use_seerr_default": "", + "default_anime_quality": "", + "default_anime_quality_help": "", + "default_anime_movie_quality": "", + "default_anime_movie_quality_help": "", + "default_anime_server": "", + "default_anime_server_help": "", + "default_anime_movie_server": "", + "default_anime_movie_server_help": "", "tmdb_api_key": "", "tmdb_api_key_help": "", "omdb_api_key": "", @@ -105,7 +135,84 @@ "test_batch_seasons_notification": "", "test_episodes_notification": "", "test_batch_episodes_notification": "", - "save_button": "" + "save_button": "", + "debounce_title": "", + "debounce_unit": "", + "debounce_range": "", + "debounce_help": "", + "library_channel_mapping_title": "", + "library_channel_mapping_help": "", + "fetch_libraries": "", + "embed_customization_title": "", + "embed_customization_help": "", + "embed_backdrop": "", + "embed_backdrop_help": "", + "embed_overview": "", + "embed_overview_help": "", + "embed_genre": "", + "embed_genre_help": "", + "embed_runtime": "", + "embed_runtime_help": "", + "embed_rating": "", + "embed_rating_help": "", + "embed_letterboxd_btn": "", + "embed_letterboxd_btn_help": "", + "embed_imdb_btn": "", + "embed_imdb_btn_help": "", + "embed_watch_btn": "", + "embed_watch_btn_help": "", + "mappings_legend": "", + "mappings_pane_description": "", + "add_new_mapping": "", + "auto_map_seerr": "", + "sync_seerr": "", + "refresh_users": "", + "discord_user_label": "", + "discord_user_placeholder": "", + "discord_user_help": "", + "discord_user_id_placeholder": "", + "seerr_user_label": "", + "seerr_user_placeholder": "", + "seerr_user_help": "", + "add_mapping_btn": "", + "roles_legend": "", + "roles_pane_description": "", + "allowlist_title": "", + "allowlist_help": "", + "blocklist_title": "", + "blocklist_help": "", + "misc_legend": "", + "misc_pane_description": "", + "auto_start_label": "", + "auto_start_help": "", + "notify_on_available_label": "", + "notify_on_available_help": "", + "ephemeral_mode_label": "", + "ephemeral_mode_help": "", + "auto_approve_label": "", + "auto_approve_help": "", + "daily_pick_title": "", + "daily_pick_help": "", + "daily_pick_channel_label": "", + "daily_pick_channel_placeholder": "", + "daily_pick_channel_help": "", + "daily_pick_interval_label": "", + "daily_pick_interval_help": "", + "test_random_pick": "", + "embed_colors_title": "", + "embed_colors_help": "", + "embed_color_movie_label": "", + "embed_color_series_label": "", + "embed_color_season_label": "", + "embed_color_episode_single_label": "", + "embed_color_episode_few_label": "", + "embed_color_episode_many_label": "", + "embed_color_search_label": "", + "embed_color_success_label": "", + "app_settings_title": "", + "app_settings_help": "", + "library_anime_label": "", + "library_anime_title": "" }, "ui": { "hide_header": "", @@ -186,7 +293,8 @@ "loading_members_bot_running": "", "loading_logs": "", "bot_must_be_running": "", - "no_roles_available": "" + "no_roles_available": "", + "logout_failed": "" }, "modals": { "start_bot_yes": "", diff --git a/package-lock.json b/package-lock.json index 5932fb0..2bd886e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "anchorr", - "version": "1.4.9", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "anchorr", - "version": "1.4.9", + "version": "1.5.0", "license": "ISC", "dependencies": { "axios": "^1.12.2", @@ -347,9 +347,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", @@ -635,12 +635,12 @@ ] }, "node_modules/discord.js": { - "version": "14.26.0", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.0.tgz", - "integrity": "sha512-I+5dmdg7WnjIOEXspX6mJ4jLgblhZV6QpQcC3U2JjrFWnHCKDpoLkhV46SBP8k9QePs3YWJOvndVutUARRO0AQ==", + "version": "14.26.2", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.2.tgz", + "integrity": "sha512-feShi+gULJ6R2MAA4/KkCFnkJcuVrROJrKk4czplzq8gE1oqhqgOy9K0Scu44B8oGeWKe04egquzf+ia6VtXAw==", "license": "Apache-2.0", "dependencies": { - "@discordjs/builders": "^1.14.0", + "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.1", diff --git a/package.json b/package.json index b879614..0231149 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "anchorr", - "version": "1.4.9", + "version": "1.5.0", "main": "app.js", "scripts": { "start": "node app.js", diff --git a/utils/validation.js b/utils/validation.js index 2de10e6..2f57b66 100644 --- a/utils/validation.js +++ b/utils/validation.js @@ -103,10 +103,6 @@ export function validateBody(schema) { message: detail.message, })); - // Log validation errors for debugging - // console.error("Validation failed:", JSON.stringify(errors, null, 2)); - // console.error("Received body:", JSON.stringify(req.body, null, 2)); - return res.status(400).json({ success: false, message: "Validation failed", diff --git a/web/index.html b/web/index.html index 0f8b0c5..3f96827 100644 --- a/web/index.html +++ b/web/index.html @@ -388,6 +388,38 @@

Anchorr Configuration

Sonarr server for TV show requests + +
+ + +
Quality profile for anime TV series (Sonarr). Falls back to TV default if unset.
+
+ +
+ + +
Quality profile for anime movies (Radarr). Falls back to movie default if unset.
+
+ +
+ + +
Sonarr server for anime TV series. Falls back to TV server if unset.
+
+ +
+ + +
Radarr server for anime movies. Falls back to movie server if unset.
+
@@ -512,7 +544,7 @@

Anchorr Configuration

-
- seconds (range: 1 second - 10 minutes) + seconds (range: 1 second - 10 minutes) -
- Debounce function to determine how long to wait before sending notifications for multiple episodes/seasons, bundling them into a single notification message. +
+ Determines how long to wait before sending notifications for multiple episodes/seasons, bundling them into a single notification message.
-
- Map specific Jellyfin libraries to different Discord channels. -
- - Click "Fetch Libraries" to load your libraries, then select a channel for each one. -
Note: Unchecked libraries will not send any notifications. -
+
+ Map specific Jellyfin libraries to different Discord channels. Click "Fetch Libraries" to load your libraries, then select a channel for each one. Unchecked libraries will not send any notifications.
@@ -632,9 +659,9 @@

Your Jellyfin Webhook URL

-
+
Customize which elements appear in your Discord notification embeds.
@@ -642,64 +669,64 @@

Your Jellyfin Webhook URL

@@ -748,37 +775,37 @@

Your Jellyfin Webhook URL

- User Mapping -

+ User Mapping +

Map Discord users to their Seerr accounts. When a user makes a request from Discord, it will be associated with their Seerr account instead of the API key owner.

- +
- +
-

Add New Mapping

+

Add New Mapping

- +
- +
@@ -786,19 +813,19 @@

-
+
Select a Discord server member, or enter their User ID manually below if they don't appear in the list.
- +
- +
- +
@@ -806,11 +833,11 @@

-
+
Select the corresponding Seerr account
- +
@@ -819,27 +846,27 @@

- Role Permissions -

+ Role Permissions +

Control who can use bot commands by configuring role-based permissions. If no roles are selected in the allowlist, everyone can use commands. If any role is added to the blocklist, members with that role will be blocked regardless of allowlist settings.

- +
- +

Loading roles...

-
+
If empty, all members can use commands. If roles are selected, only members with these roles can use commands.
- +
- +

Loading roles...

-
+
Members with these roles will never be able to use bot commands, even if they have an allowed role.
@@ -849,17 +876,17 @@

- Miscellaneous Settings -

+ Miscellaneous Settings +

Optional settings to enhance your bot experience. These are extra features that can be enabled after your bot is fully configured and running.

- +
-
+
If enabled, the bot starts automatically when the container/server restarts and valid Discord credentials are present.
@@ -867,9 +894,9 @@

-
+
When enabled, users will receive a private message when their requested content becomes available on Jellyfin.
@@ -877,9 +904,9 @@

-
+
When enabled, all bot responses (search and request results) are sent as ephemeral messages (only visible to the user who used the command). When disabled, successful results are visible to everyone in the channel, but warning messages (e.g., "already on Jellyfin") are always ephemeral.
@@ -887,9 +914,9 @@

-
+
When enabled, requests made through the bot will be automatically approved in Seerr. If disabled, requests will remain "Pending" for manual approval.
@@ -898,36 +925,36 @@

-
+
Send a daily recommendation of random media to a Discord channel. Great for discovering hidden gems!
- + -
+
Discord channel where daily picks will be sent
- + -
+
How often to send a random pick in minutes (default: 1440 = 24 hours)
@@ -935,58 +962,58 @@

-
+
Customize the colors of Discord notification embeds for different content types.
- +
- +
- +
- +
- +
- +
- +
- +
@@ -995,18 +1022,18 @@

-
+
General application settings for Anchorr.
- + -
+
Select the language for the Anchorr dashboard interface.
diff --git a/web/script.js b/web/script.js index 634c8b0..42f0a16 100644 --- a/web/script.js +++ b/web/script.js @@ -16,14 +16,19 @@ async function loadTranslations(language) { try { const response = await fetch(`/locales/${language}.json`); if (!response.ok) { - console.warn(`Failed to load ${language} translations, falling back to English`); + console.warn(`Failed to load ${language} translations (HTTP ${response.status}), falling back to English`); const fallbackResponse = await fetch('/locales/en.json'); + if (!fallbackResponse.ok) { + throw new Error(`English fallback also failed: HTTP ${fallbackResponse.status}`); + } return await fallbackResponse.json(); } return await response.json(); } catch (error) { console.error('Error loading translations:', error); - // Return minimal fallback + if (typeof showToast === 'function') { + showToast('UI language could not be loaded. Some labels may be missing.'); + } return { common: { loading: 'Loading...' }, auth: { login: 'Login' }, @@ -214,7 +219,7 @@ document.addEventListener("DOMContentLoaded", async () => { if (aboutEl) aboutEl.textContent = v; } }) - .catch(() => {}); + .catch((e) => console.debug("Could not fetch version:", e)); const form = document.getElementById("config-form"); const botControlBtn = document.getElementById("bot-control-btn"); const botControlText = document.getElementById("bot-control-text"); @@ -352,6 +357,7 @@ document.addEventListener("DOMContentLoaded", async () => { const status = await response.json(); updateStatusIndicator(status.isBotRunning, status.botUsername); } catch (error) { + console.warn("Failed to fetch bot status:", error); updateStatusIndicator(false); } } @@ -362,13 +368,13 @@ document.addEventListener("DOMContentLoaded", async () => { botControlBtn.classList.remove("btn-success"); botControlBtn.classList.add("btn-danger"); botControlIcon.className = "bi bi-pause-fill"; - botControlText.textContent = "Stop Bot"; + botControlText.textContent = t("bot.actions.stop"); botControlBtn.dataset.action = "stop"; } else { botControlBtn.classList.remove("btn-danger"); botControlBtn.classList.add("btn-success"); botControlIcon.className = "bi bi-play-fill"; - botControlText.textContent = "Start Bot"; + botControlText.textContent = t("bot.actions.start"); botControlBtn.dataset.action = "start"; } } @@ -415,7 +421,8 @@ document.addEventListener("DOMContentLoaded", async () => { showAuth(data.hasUsers); } } catch (error) { - showAuth(true); // Default to showing login if check fails + console.warn("Session check failed, showing auth:", error); + showAuth(true); } } @@ -622,7 +629,8 @@ document.addEventListener("DOMContentLoaded", async () => { await fetch("/api/auth/logout", { method: "POST" }); location.reload(); } catch (error) { - // Logout error handling + console.error("Logout request failed:", error); + showToast(t('errors.logout_failed')); } }); } @@ -678,6 +686,7 @@ document.addEventListener("DOMContentLoaded", async () => { ? JSON.parse(libConfigString) : {}; } catch (e) { + console.error("Failed to parse JELLYFIN_NOTIFICATION_LIBRARIES before save:", e); config.JELLYFIN_NOTIFICATION_LIBRARIES = {}; } @@ -698,7 +707,8 @@ document.addEventListener("DOMContentLoaded", async () => { await saveConfig(config); } } catch (error) { - // If check fails, save normally + console.error("Autostart check failed, proceeding with save:", error); + showToast("Warning: Could not check bot autostart state. Saving anyway."); await saveConfig(config); } }); @@ -789,7 +799,8 @@ document.addEventListener("DOMContentLoaded", async () => { showToast(result.message); } } catch (error) { - showToast("Error saving configuration."); + console.error("saveConfig failed:", error); + showToast(`Error saving configuration: ${error.message || "Unknown error"}`); } } @@ -799,7 +810,7 @@ document.addEventListener("DOMContentLoaded", async () => { botControlBtn.disabled = true; const originalText = botControlText.textContent; - botControlText.textContent = "Processing..."; + botControlText.textContent = t("common.processing"); try { const response = await fetch(`/api/${action}-bot`, { method: "POST" }); @@ -918,7 +929,9 @@ document.addEventListener("DOMContentLoaded", async () => { const input = document.getElementById("WEBHOOK_SECRET"); if (input && data.secret) input.value = data.secret; } - } catch (_) {} + } catch (e) { + console.warn("Could not load webhook secret:", e); + } })(); // Copy webhook secret (reads from the already-populated input field) @@ -1080,16 +1093,24 @@ document.addEventListener("DOMContentLoaded", async () => { // Get current saved values const movieQualitySelect = document.getElementById("DEFAULT_QUALITY_PROFILE_MOVIE"); const tvQualitySelect = document.getElementById("DEFAULT_QUALITY_PROFILE_TV"); + const animeQualitySelect = document.getElementById("DEFAULT_QUALITY_PROFILE_ANIME"); + const animeMovieQualitySelect = document.getElementById("DEFAULT_QUALITY_PROFILE_ANIME_MOVIE"); const movieServerSelect = document.getElementById("DEFAULT_SERVER_MOVIE"); const tvServerSelect = document.getElementById("DEFAULT_SERVER_TV"); + const animeServerSelect = document.getElementById("DEFAULT_SERVER_ANIME"); + const animeMovieServerSelect = document.getElementById("DEFAULT_SERVER_ANIME_MOVIE"); const savedMovieQuality = movieQualitySelect.dataset.savedValue || movieQualitySelect.value; const savedTvQuality = tvQualitySelect.dataset.savedValue || tvQualitySelect.value; + const savedAnimeQuality = animeQualitySelect.dataset.savedValue || animeQualitySelect.value; + const savedAnimeMovieQuality = animeMovieQualitySelect.dataset.savedValue || animeMovieQualitySelect.value; const savedMovieServer = movieServerSelect.dataset.savedValue || movieServerSelect.value; const savedTvServer = tvServerSelect.dataset.savedValue || tvServerSelect.value; + const savedAnimeServer = animeServerSelect.dataset.savedValue || animeServerSelect.value; + const savedAnimeMovieServer = animeMovieServerSelect.dataset.savedValue || animeMovieServerSelect.value; // Movie quality profiles (Radarr) - movieQualitySelect.innerHTML = ''; + movieQualitySelect.innerHTML = ``; const radarrProfiles = profilesResult.profiles.filter(p => p.type === "radarr"); radarrProfiles.forEach(profile => { const option = document.createElement("option"); @@ -1100,7 +1121,7 @@ document.addEventListener("DOMContentLoaded", async () => { if (savedMovieQuality) movieQualitySelect.value = savedMovieQuality; // TV quality profiles (Sonarr) - tvQualitySelect.innerHTML = ''; + tvQualitySelect.innerHTML = ``; const sonarrProfiles = profilesResult.profiles.filter(p => p.type === "sonarr"); sonarrProfiles.forEach(profile => { const option = document.createElement("option"); @@ -1110,8 +1131,28 @@ document.addEventListener("DOMContentLoaded", async () => { }); if (savedTvQuality) tvQualitySelect.value = savedTvQuality; + // Anime TV quality profiles (Sonarr) + animeQualitySelect.innerHTML = ``; + sonarrProfiles.forEach(profile => { + const option = document.createElement("option"); + option.value = `${profile.id}|${profile.serverId}`; + option.textContent = `${profile.name} (${profile.serverName})`; + animeQualitySelect.appendChild(option); + }); + if (savedAnimeQuality) animeQualitySelect.value = savedAnimeQuality; + + // Anime movie quality profiles (Radarr) + animeMovieQualitySelect.innerHTML = ``; + radarrProfiles.forEach(profile => { + const option = document.createElement("option"); + option.value = `${profile.id}|${profile.serverId}`; + option.textContent = `${profile.name} (${profile.serverName})`; + animeMovieQualitySelect.appendChild(option); + }); + if (savedAnimeMovieQuality) animeMovieQualitySelect.value = savedAnimeMovieQuality; + // Movie servers (Radarr) - movieServerSelect.innerHTML = ''; + movieServerSelect.innerHTML = ``; const radarrServers = serversResult.servers.filter(s => s.type === "radarr"); radarrServers.forEach(server => { const option = document.createElement("option"); @@ -1122,7 +1163,7 @@ document.addEventListener("DOMContentLoaded", async () => { if (savedMovieServer) movieServerSelect.value = savedMovieServer; // TV servers (Sonarr) - tvServerSelect.innerHTML = ''; + tvServerSelect.innerHTML = ``; const sonarrServers = serversResult.servers.filter(s => s.type === "sonarr"); sonarrServers.forEach(server => { const option = document.createElement("option"); @@ -1132,6 +1173,26 @@ document.addEventListener("DOMContentLoaded", async () => { }); if (savedTvServer) tvServerSelect.value = savedTvServer; + // Anime TV servers (Sonarr) + animeServerSelect.innerHTML = ``; + sonarrServers.forEach(server => { + const option = document.createElement("option"); + option.value = `${server.id}|${server.type}`; + option.textContent = `${server.name}${server.isDefault ? " (default)" : ""}`; + animeServerSelect.appendChild(option); + }); + if (savedAnimeServer) animeServerSelect.value = savedAnimeServer; + + // Anime movie servers (Radarr) + animeMovieServerSelect.innerHTML = ``; + radarrServers.forEach(server => { + const option = document.createElement("option"); + option.value = `${server.id}|${server.type}`; + option.textContent = `${server.name}${server.isDefault ? " (default)" : ""}`; + animeMovieServerSelect.appendChild(option); + }); + if (savedAnimeMovieServer) animeMovieServerSelect.value = savedAnimeMovieServer; + const totalProfiles = radarrProfiles.length + sonarrProfiles.length; const totalServers = radarrServers.length + sonarrServers.length; loadSeerrOptionsStatus.textContent = `Loaded ${totalProfiles} profiles, ${totalServers} servers`; @@ -1340,7 +1401,7 @@ document.addEventListener("DOMContentLoaded", async () => { librariesList.innerHTML = '
No libraries found.
'; } else { - // Get currently enabled libraries (object format: { libraryId: channelId }) + // Get currently enabled libraries (object format: { libraryId: { channel, isAnime } }) let libraryChannels = {}; try { const currentValue = notificationLibrariesInput.value; @@ -1353,13 +1414,14 @@ document.addEventListener("DOMContentLoaded", async () => { const defaultChannel = document.getElementById("JELLYFIN_CHANNEL_ID").value || ""; parsed.forEach((libId) => { - libraryChannels[libId] = defaultChannel; + libraryChannels[libId] = { channel: defaultChannel, isAnime: false }; }); } else if (typeof parsed === "object") { libraryChannels = parsed; } } } catch (e) { + console.error("Failed to parse saved library channel config:", e); libraryChannels = {}; } @@ -1375,9 +1437,16 @@ document.addEventListener("DOMContentLoaded", async () => { // 2. This library ID exists as a key in libraryChannels object const isChecked = allEnabled || libraryChannels.hasOwnProperty(lib.id); + const libConfig = libraryChannels[lib.id]; const selectedChannel = isChecked - ? libraryChannels[lib.id] || defaultChannel + ? (typeof libConfig === "object" && libConfig !== null + ? libConfig.channel + : libConfig) || defaultChannel : ""; + const isAnime = + typeof libConfig === "object" && libConfig !== null + ? !!libConfig.isAnime + : false; return `
@@ -1388,17 +1457,27 @@ document.addEventListener("DOMContentLoaded", async () => { class="library-checkbox" ${isChecked ? "checked" : ""} /> -
- ${escapeHtml(lib.name)} -
+ ${escapeHtml(lib.name)} - +
+ + +
`; }) @@ -1433,7 +1512,7 @@ document.addEventListener("DOMContentLoaded", async () => { class="library-channel-select" ${!episodesEnabled ? "disabled" : ""} > - +
@@ -1455,7 +1534,7 @@ document.addEventListener("DOMContentLoaded", async () => { class="library-channel-select" ${!seasonsEnabled ? "disabled" : ""} > - +
`; @@ -1472,9 +1551,11 @@ document.addEventListener("DOMContentLoaded", async () => { const select = librariesList.querySelector( `select[data-library-id="${libraryId}"]` ); - if (select) { - select.disabled = !e.target.checked; - } + const animeToggle = librariesList.querySelector( + `.library-anime-toggle[data-library-id="${libraryId}"]` + ); + if (select) select.disabled = !e.target.checked; + if (animeToggle) animeToggle.disabled = !e.target.checked; updateNotificationLibraries(); }); }); @@ -1486,6 +1567,13 @@ document.addEventListener("DOMContentLoaded", async () => { select.addEventListener("change", updateNotificationLibraries); }); + // Add change listeners to all anime toggles + librariesList + .querySelectorAll(".library-anime-toggle") + .forEach((toggle) => { + toggle.addEventListener("change", updateNotificationLibraries); + }); + // Add event listeners for Episodes and Seasons checkboxes const episodesCheckbox = document.getElementById("episodes-notify-checkbox"); const seasonsCheckbox = document.getElementById("seasons-notify-checkbox"); @@ -1550,11 +1638,15 @@ document.addEventListener("DOMContentLoaded", async () => { selects.forEach((select) => { const libraryId = select.dataset.libraryId; - const currentChannel = libraryChannels[libraryId] || ""; + const libConfig = libraryChannels[libraryId]; + const currentChannel = + typeof libConfig === "object" && libConfig !== null + ? libConfig.channel || "" + : libConfig || ""; // Clear and populate options select.innerHTML = - '' + + `` + channels .map((ch) => { let icon = ""; @@ -1580,7 +1672,7 @@ document.addEventListener("DOMContentLoaded", async () => { if (episodesSelect) { episodesSelect.innerHTML = - '' + + `` + channels .map((ch) => { let icon = ""; @@ -1598,7 +1690,7 @@ document.addEventListener("DOMContentLoaded", async () => { if (seasonsSelect) { seasonsSelect.innerHTML = - '' + + `` + channels .map((ch) => { let icon = ""; @@ -1613,7 +1705,9 @@ document.addEventListener("DOMContentLoaded", async () => { seasonsSelect.value = seasonChannel; } } - } catch (error) {} + } catch (error) { + console.error("Failed to populate library channel dropdowns:", error); + } } // Update the hidden input with selected notification libraries (object format) @@ -1631,12 +1725,15 @@ document.addEventListener("DOMContentLoaded", async () => { const select = librariesList.querySelector( `select[data-library-id="${libraryId}"]` ); + const animeToggle = librariesList.querySelector( + `.library-anime-toggle[data-library-id="${libraryId}"]` + ); const channelId = select ? select.value : ""; - libraryChannels[libraryId] = channelId; // Empty string means "use default" + const isAnime = animeToggle ? animeToggle.checked : false; + libraryChannels[libraryId] = { channel: channelId, isAnime }; }); - const jsonValue = JSON.stringify(libraryChannels); - notificationLibrariesInput.value = jsonValue; + notificationLibrariesInput.value = JSON.stringify(libraryChannels); } // --- Initial Load --- @@ -1694,6 +1791,7 @@ document.addEventListener("DOMContentLoaded", async () => { ``; } } catch (error) { + console.error("[Discord] Failed to load guilds:", error); guildSelect.innerHTML = ``; } } @@ -1845,6 +1943,7 @@ document.addEventListener("DOMContentLoaded", async () => { } } } catch (error) { + console.error("[Discord] Failed to load channels for guild:", guildId, error); if (channelSelect) { channelSelect.innerHTML = ``; @@ -1966,6 +2065,7 @@ document.addEventListener("DOMContentLoaded", async () => { return data.value; } catch (error) { + console.debug("Cache read error:", error); return null; } } @@ -1979,7 +2079,7 @@ document.addEventListener("DOMContentLoaded", async () => { }; localStorage.setItem(key, JSON.stringify(data)); } catch (error) { - // Cache save error + console.debug("Cache write error:", error); } } @@ -2170,7 +2270,9 @@ document.addEventListener("DOMContentLoaded", async () => { saveToCache(SEERR_USERS_CACHE_KEY, data.users); populateSeerrUserSelect(); } - } catch (error) {} + } catch (error) { + console.error("Failed to load Seerr users:", error); + } } function populateSeerrUserSelect() { @@ -2376,7 +2478,9 @@ document.addEventListener("DOMContentLoaded", async () => { // Reload mappings after update const response = await fetch("/api/user-mappings"); currentMappings = await response.json(); - } catch (error) {} + } catch (error) { + console.error("[MAPPINGS] Failed to reload after update:", error); + } } function displayMappings() { @@ -2576,7 +2680,8 @@ document.addEventListener("DOMContentLoaded", async () => { showToast(`Error: ${result.message}`); } } catch (error) { - showToast("Failed to add mapping."); + console.error("Failed to add mapping:", error); + showToast(`Failed to add mapping: ${error.message || "Unknown error"}`); } }); } @@ -2921,6 +3026,9 @@ document.addEventListener("DOMContentLoaded", async () => { fetch("/api/seerr-users"), ]); + if (!discordResponse.ok) throw new Error(`Discord members: HTTP ${discordResponse.status}`); + if (!seerrResponse.ok) throw new Error(`Seerr users: HTTP ${seerrResponse.status}`); + const discordData = await discordResponse.json(); const seerrData = await seerrResponse.json(); @@ -3245,7 +3353,13 @@ document.addEventListener("DOMContentLoaded", async () => { document.getElementById("blocklist-roles").innerHTML = `

${t('errors.bot_must_be_running')}

`; } - } catch (error) {} + } catch (error) { + console.error("Failed to load roles:", error); + const allowlistEl = document.getElementById("allowlist-roles"); + const blocklistEl = document.getElementById("blocklist-roles"); + if (allowlistEl) allowlistEl.innerHTML = `

${t('errors.bot_must_be_running')}

`; + if (blocklistEl) blocklistEl.innerHTML = `

${t('errors.bot_must_be_running')}

`; + } } function populateRoleList(containerId, selectedRoles) { @@ -3459,6 +3573,7 @@ document.addEventListener("DOMContentLoaded", async () => { } } } catch (error) { + console.warn("[Status] Seerr connection check failed:", error); seerrIndicator.className = "status-dot status-disconnected"; } @@ -3486,6 +3601,7 @@ document.addEventListener("DOMContentLoaded", async () => { } } } catch (error) { + console.warn("[Status] Jellyfin connection check failed:", error); jellyfinIndicator.className = "status-dot status-disconnected"; } } @@ -3504,14 +3620,16 @@ document.addEventListener("DOMContentLoaded", async () => { botControlBtnLogs.classList.remove("btn-success"); botControlBtnLogs.classList.add("btn-danger"); botControlBtnLogs.querySelector("i").className = "bi bi-pause-fill"; - botControlTextLogs.textContent = "Stop Bot"; + botControlTextLogs.textContent = t("bot.actions.stop"); } else { botControlBtnLogs.classList.remove("btn-danger"); botControlBtnLogs.classList.add("btn-success"); botControlBtnLogs.querySelector("i").className = "bi bi-play-fill"; - botControlTextLogs.textContent = "Start Bot"; + botControlTextLogs.textContent = t("bot.actions.start"); } - } catch (error) {} + } catch (error) { + console.error("Failed to fetch bot status for logs page:", error); + } } // Bot control button for logs page @@ -3526,7 +3644,7 @@ document.addEventListener("DOMContentLoaded", async () => { botControlBtnLogs.disabled = true; const originalText = botControlTextLogs.textContent; - botControlTextLogs.textContent = "Processing..."; + botControlTextLogs.textContent = t("common.processing"); const response = await fetch(endpoint, { method: "POST" }); diff --git a/web/style.css b/web/style.css index 095ccd0..4964c7b 100644 --- a/web/style.css +++ b/web/style.css @@ -2624,92 +2624,120 @@ body.auth-mode .footer { .library-item { display: flex; align-items: center; - gap: 1.5rem; - padding: 0.75rem 1.25rem; - background-color: var(--surface0); - border: 1px solid var(--surface1); - border-radius: 12px; - transition: border-color 0.2s ease, transform 0.2s ease; -} - -.library-item:hover { - border-color: var(--mauve); - transform: translateY(-2px); + gap: 0.75rem; } .library-label { display: flex; align-items: center; - flex: 1; + gap: 0.6rem; cursor: pointer; + flex: 1; min-width: 0; - gap: 1rem; + background-color: var(--surface0); + border: 1px solid var(--surface1); + border-radius: 10px; + padding: 0.6rem 0.9rem; + transition: border-color 0.2s ease; +} + +.library-label:hover { + border-color: var(--mauve); } .library-checkbox { - width: 22px; - height: 22px; + width: 16px; + height: 16px; flex-shrink: 0; accent-color: var(--mauve); } -.library-info { - display: flex; - flex-direction: column; - min-width: 0; -} - .library-name { - font-weight: 700; + font-weight: 600; color: var(--text); - font-size: 1.05rem; - margin-bottom: 0; + font-size: 0.95rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.library-channel-select { - flex: 1; - max-width: 300px; /* Limit width for better look */ - background-color: var(--background); +.library-controls { + display: flex; + align-items: center; + gap: 0.5rem; + background-color: var(--surface0); border: 1px solid var(--surface1); + border-radius: 10px; + padding: 0.6rem 0.9rem; + flex-shrink: 0; +} + +.library-channel-select { + background-color: transparent; + border: none; color: var(--text); - padding: 0.85rem 1rem; - border-radius: 8px; - font-size: 0.95rem; + padding: 0 1.4rem 0 0; + font-size: 0.875rem; cursor: pointer; - transition: all 0.2s ease; - appearance: none; /* Remove default arrow */ + appearance: none; background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23cba6f7%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E"); background-repeat: no-repeat; - background-position: right 1rem center; - background-size: 0.75rem auto; - padding-right: 2.5rem; /* Space for arrow */ + background-position: right 0 center; + background-size: 0.6rem auto; + min-width: 140px; + max-width: 240px; + outline: none; } -.library-channel-select:hover { - border-color: var(--mauve); - background-color: var(--surface1); +.library-channel-select:disabled { + opacity: 0.5; + cursor: not-allowed; } -.library-channel-select:focus { - outline: none; - border-color: var(--mauve); - box-shadow: 0 0 0 3px rgba(203, 166, 247, 0.2); +.library-controls:has(.library-channel-select:disabled) { + opacity: 0.5; } -.library-channel-select:disabled { - opacity: 0.5; +.library-anime-label { + display: flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + font-size: 0.75rem; + color: var(--subtext0); + user-select: none; + padding-left: 0.5rem; + border-left: 1px solid var(--surface1); +} + +.library-anime-toggle { + width: 13px; + height: 13px; + flex-shrink: 0; + accent-color: var(--mauve); + cursor: pointer; +} + +.library-anime-toggle:disabled { cursor: not-allowed; - background-color: var(--surface0); +} + +.library-anime-label:has(.library-anime-toggle:checked) { + color: var(--mauve); } @media (max-width: 768px) { .library-item { flex-direction: column; align-items: stretch; - gap: 1rem; } - .library-channel-select { - max-width: none; + .library-label { + flex: unset; + } + .library-controls { + flex-wrap: wrap; } }