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/CHANGELOG.md b/CHANGELOG.md index 1f682a2..4ce0699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [1.5.0] - 2026-04-10 + +### ✨ 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 +- **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 }` + +### 🗑️ 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..9cc6fae 100644 --- a/bot/botUtils.js +++ b/bot/botUtils.js @@ -26,7 +26,7 @@ export function getOptionStringRobust( return null; } -export function parseQualityAndServerOptions(options, mediaType) { +export function parseQualityAndServerOptions(options, mediaType, isAnime = false) { let profileId = null; let serverId = null; @@ -78,10 +78,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 +98,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 +109,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..0da0bae 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") { @@ -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) { @@ -861,9 +874,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) { 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/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..d0091b7 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.
+
diff --git a/web/script.js b/web/script.js index 634c8b0..76dea7d 100644 --- a/web/script.js +++ b/web/script.js @@ -1080,13 +1080,21 @@ 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 = ''; @@ -1110,6 +1118,26 @@ 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 = ''; const radarrServers = serversResult.servers.filter(s => s.type === "radarr"); @@ -1132,6 +1160,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`;