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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,26 @@
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:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
runs-on: ubuntu-latest
needs: audit

permissions:
contents: read
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 14 additions & 8 deletions api/seerr.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object>} Status object
* @returns {Promise<Object>} Status object { exists, available, status?, data? }
* @throws {Error} For network errors, authentication failures, or 5xx responses
*/
export async function checkMediaStatus(
tmdbId,
Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -360,6 +362,7 @@ export async function sendRequest({
serverId = null,
profileId = null,
tags = null,
isAnime = false,
isAutoApproved = null,
seerrUrl,
apiKey,
Expand All @@ -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)
Expand All @@ -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
Expand Down
71 changes: 4 additions & 67 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
24 changes: 18 additions & 6 deletions bot/botUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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("|");
Expand All @@ -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}`
Expand All @@ -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("|");
Expand Down
5 changes: 4 additions & 1 deletion bot/dailyPick.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
48 changes: 37 additions & 11 deletions bot/interactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand All @@ -158,14 +166,15 @@ async function handleSearchOrRequest(
tags: tagIds,
profileId,
serverId,
isAnime,
seerrUrl: getSeerrUrl(),
apiKey: getSeerrApiKey(),
discordUserId: interaction.user.id,
userMappings: getUserMappings(),
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") {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading