import express from "express"; import mongoose from "mongoose"; import bcrypt from "bcrypt"; import QRCode from "qrcode"; import User from "../models/User"; import BlockedIP from "../models/blockedIP"; import LoginLog from "../models/LoginLog"; import Settings from "../models/Settings"; import Collection from "../models/Collection"; import { requireAuth, requireAdmin, requireCollectionRole } from "../middleware/authMiddleware"; import { generateUniqueSlug, generateShareToken } from "../utils/collectionHelpers"; import { BASE_URL, SUPPORTED_LANGUAGES } from "../config/constants"; import { getInstanceSettings, saveInstanceSettings, InstanceSettingsData } from "../utils/instanceSettings"; import PRESETS from "../config/themes"; import Item from "../models/Item"; import PriceHistory from "../models/PriceHistory"; import Furniture from "../models/Furniture"; import List from "../models/List"; import { registry } from "../core/registry.js"; import { canRefresh, refreshPatchFor, gatherImages, pluginSources } from "../core/sources.js"; import { CARD_ASPECT_RATIOS } from "../core/customPlugin"; import { PermanentRefreshError, syncStamp, getPublicProtocol } from "../core/helpers"; import { deleteItemsAndContents } from "../utils/itemHelpers"; import { deleteUnusedManagedItemImages, managedItemImagesForQuery } from "../core/itemImageStorage"; import { collectionInfoFromForm, collectionInfoOf, MAX_COLLECTION_INFO_BODY, MAX_COLLECTION_INFO_IMAGES, MAX_COLLECTION_INFO_TITLE, } from "../core/collectionInfo"; import { renderMarkdown } from "../core/markdown"; import { nativeLookalikesByPlugin } from "../core/pluginExtraFields"; import { alignImagesAfterRefresh } from "../core/itemImages"; import { AI_PROVIDERS } from "../core/ai/providers"; import { getAiConfig, getStoredAiSettings, saveAiSettings } from "../core/ai/instance"; import { isAiConfigured, resolveTestConfig } from "../core/ai/config"; import { keyHint } from "../core/ai/secret"; import { aiChat, AiError } from "../core/ai/client"; const router = express.Router(); const createPassword = (length = 12): string => { const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+"; let password = ""; for (let i = 0; i < length; i++) { password += chars.charAt(Math.floor(Math.random() * chars.length)); } return password; }; const escapeRegExp = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); interface CollectionAdminData { allGenres: Record; visibilitySettings: any; collectionDoc: any; members: any[]; } interface InstanceAdminData { users: any[]; blockedIps: any[]; logs: any[]; collections: any[]; itemsTotal: number; instanceSettings: InstanceSettingsData; } /** * Data for the per-collection admin page (/admin): everything scoped to the * active collection - genres for the visibility panel, visibility settings, * the collection document itself and its members (user info populated). */ async function loadCollectionAdminData(activeCollectionId: any): Promise { // Get distinct genres grouped by kind (scoped to the active collection) const pipeline = [ // Contained items carry their holder's genres, so counting them here would just // repeat what the holder already contributed. { $match: { collection: activeCollectionId, parent: { $exists: false } } }, { $project: { kind: 1, allGenres: { $concatArrays: [ { $cond: [{ $in: ["$genre", ["", null]] }, [], ["$genre"]] }, { $ifNull: ["$genres", []] }, { $ifNull: ["$styles", []] }, ], }, }, }, { $unwind: "$allGenres" }, { $group: { _id: "$kind", genres: { $addToSet: "$allGenres" }, }, }, ]; const genreGroupsRaw = await Item.aggregate(pipeline); const allGenres: Record = {}; genreGroupsRaw.forEach((group: any) => { if (group._id && group.genres && group.genres.length > 0) { allGenres[group._id] = group.genres.filter(Boolean).sort(); } }); const visibilitySettings = (await Settings.findOne({ collection: activeCollectionId }).populate("visibility.hiddenItems").lean()) || {}; const collectionDoc = await Collection.findById(activeCollectionId) .populate("members.user", "username email img isAdmin lastChange") .lean(); const members = (collectionDoc?.members || []).filter((m: any) => m.user); return { allGenres, visibilitySettings, collectionDoc, members }; } /** * Data for the instance admin page (/admin/instance): global users, IPs, * login logs and the full collections list (members populated + item counts, * to drive the per-collection management modal). */ async function loadInstanceAdminData(): Promise { const users = await User.find().sort({ lastChange: -1 }); const blockedIps = await BlockedIP.find().sort({ createdAt: -1 }); const logs = await LoginLog.find().sort({ timestamp: -1 }).limit(20); const collections: any[] = await Collection.find() .sort({ created_at: -1 }) .populate("members.user", "username email img isAdmin") .lean(); // Counted like the grid shows: a show with five seasons is one line and counts as one, // so the number here always matches what a member can count on screen. const counts = await Item.aggregate([ { $match: { parent: { $exists: false } } }, { $group: { _id: "$collection", n: { $sum: 1 } } }, ]); const countByCollection: Record = {}; let itemsTotal = 0; for (const c of counts) { countByCollection[String(c._id)] = c.n; itemsTotal += c.n; } for (const c of collections) { c.itemCount = countByCollection[String(c._id)] || 0; // Drop membership rows whose user was deleted outside the app c.members = (c.members || []).filter((m: any) => m.user); } return { users, blockedIps, logs, collections, itemsTotal, instanceSettings: await getInstanceSettings(), }; } // The message keys a redirect reports a failure with, so the banner is drawn as // an error instead of a success. function isErrorMessageKey(key: string | undefined): boolean { return !!key && (key.startsWith("error_") || key.endsWith("_error")); } // COLLECTION ADMIN PAGE (GET /admin) - gated on the active collection's admin role router.get("/", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { const data = await loadCollectionAdminData(res.locals.activeCollectionId); // Read optional message key from query and translate in the view. const msgKey = req.query.msg as string | undefined; // User-defined fields that now look like a plugin's own field, typically one the // plugin gained in an update. Surfaced here since nothing else would say so until // someone opened that plugin's customization. const lookalikes = nativeLookalikesByPlugin( res.locals.settings, registry.getEnabled(res.locals.settings), (key) => SUPPORTED_LANGUAGES.map((lng) => req.t(key, { lng })), ); const fieldLookalikes = Object.entries(lookalikes).map(([pluginId, fields]) => ({ pluginLabel: req.t(registry.get(pluginId)!.label), fields: fields.map((f) => ({ name: f.name, label: f.label, nativeLabel: req.t(f.nativeLabel) })), })); res.render("admin", { ...data, fieldLookalikes, user: res.locals.user, successMessage: msgKey ? req.t(`messages.${msgKey}`) : null, messageIsError: isErrorMessageKey(msgKey), newPassword: null, // Share links must show a full, absolute URL (scheme + host) - a bare // baseUrl-relative path is not something you can scan/paste elsewhere. siteOrigin: `${getPublicProtocol(req)}://${req.get("host")}`, // Known once, server-side, from the config this page already has every reason to // read - no reason to make the browser ask again just to decide whether to show the card. aiConfigured: isAiConfigured(await getAiConfig()), }); } catch (err) { console.error(err); res.status(500).send(req.t("errors.generic_server_error")); } }); // INSTANCE ADMIN PAGE (GET /admin/instance): global users, all collections, IPs, // login logs, whole-instance backup. Distinct from GET / above, which manages // only the active collection and is reachable by a collection's own admins. router.get("/instance", requireAuth, requireAdmin, async (req: any, res: any) => { try { const data = await loadInstanceAdminData(); const msgKey = req.query.msg as string | undefined; res.render("admin-instance", { ...data, user: res.locals.user, successMessage: msgKey ? req.t(`messages.${msgKey}`) : null, messageIsError: isErrorMessageKey(msgKey), newPassword: null, apiKeyStatus: registry.getApiKeyStatus(), }); } catch (err) { console.error(err); res.status(500).send(req.t("errors.generic_server_error")); } }); // Names why an account could not be created: a duplicate key names the field // already in use, anything else falls back to a generic failure. function userCreationErrorKey(err: any): string { if (err?.code === 11000) { return err.keyPattern?.username ? "error_username_taken" : "error_email_taken"; } if (err?.name === "ValidationError") return "error_user_invalid"; return "error_user_create"; } // Add user at the INSTANCE level (POST) - creates a global account with no // collection membership; collection admins attach members from their own page. router.post("/add-user", requireAuth, requireAdmin, async (req: any, res: any) => { try { const { username, email } = req.body; const password = createPassword(); const hashedPassword = await bcrypt.hash(password, 10); // Create user then force-update the stored password hash. const newUser = await User.create({ username, email, password: password, lastChange: new Date(), }); await User.updateOne( { _id: newUser._id }, { $set: { password: hashedPassword } }, ); console.log(`[ADMIN] User created: ${username} <${email}> by ${res.locals.user?.email}`); const data = await loadInstanceAdminData(); res.render("admin-instance", { ...data, user: res.locals.user, successMessage: req.t("messages.user_created_success", { name: username }), newPassword: password, apiKeyStatus: registry.getApiKeyStatus(), }); } catch (err) { console.error("[ADMIN] User creation error:", err); res.redirect(`/admin/instance?msg=${userCreationErrorKey(err)}`); } }); // Assign a member's home collection: the one they land in when they open the app // (see utils/homePage.ts). The user can change it themselves from their settings; // an admin sets it here so a new account starts somewhere sensible. Empty clears it, // which puts the user back on whatever they last browsed. router.post("/users/home-collection", requireAuth, requireAdmin, async (req: any, res: any) => { try { const { userId, collectionId } = req.body; if (!mongoose.Types.ObjectId.isValid(userId)) { return res.redirect("/admin/instance"); } if (!collectionId) { await User.updateOne({ _id: userId }, { $set: { homeCollectionId: null } }); return res.redirect("/admin/instance?msg=home_collection_updated"); } // Only a collection the user belongs to: anything else would send them, every // launch, somewhere the collection middleware has to heal them away from. const member = await Collection.findOne({ _id: mongoose.Types.ObjectId.isValid(collectionId) ? collectionId : null, "members.user": userId, }).select("_id"); if (!member) { return res.redirect("/admin/instance?msg=error_member_not_found"); } await User.updateOne({ _id: userId }, { $set: { homeCollectionId: member._id } }); res.redirect("/admin/instance?msg=home_collection_updated"); } catch (err) { console.error("[ADMIN] Home collection error:", err); res.redirect("/admin/instance?msg=generic_error"); } }); // Instance-wide settings (POST) - currently the member self-service collection toggle // and its per-user quota. Stored in the InstanceSettings singleton, not in the // per-collection Settings document. router.post("/instance/settings", requireAuth, requireAdmin, async (req: any, res: any) => { try { // Unchecked checkboxes are simply absent from the body const allow = req.body.allowMemberCollectionCreation === "on" || req.body.allowMemberCollectionCreation === "true"; // Clamp to the schema's bounds: the number input is client-side only, a crafted // POST could otherwise store 0 (nobody can create) or a negative quota. const parsed = parseInt(req.body.maxCollectionsPerUser, 10); const max = Math.min(100, Math.max(1, isNaN(parsed) ? 1 : parsed)); await saveInstanceSettings({ allowMemberCollectionCreation: allow, maxCollectionsPerUser: max, }); console.log(`[ADMIN] Instance settings updated by ${res.locals.user?.email}: member collection creation ${allow ? "enabled" : "disabled"} (max ${max}/user)`); res.redirect("/admin/instance?msg=saved"); } catch (err) { console.error("[ADMIN] Instance settings error:", err); res.redirect("/admin/instance?msg=generic_error"); } }); // GET /admin/instance/ai -> current AI settings, never the key itself router.get("/instance/ai", requireAuth, requireAdmin, async (_req: any, res: any) => { const config = await getAiConfig(); res.json({ enabled: config.enabled, provider: config.provider, baseUrl: config.baseUrl, model: config.model, visionModel: config.visionModel, hasKey: Boolean(config.apiKey), keyHint: keyHint(config.apiKey), // The panel greys its inputs out when the environment is in charge, so nobody edits // a value that an AI_* variable is going to override on the next read. fromEnv: config.fromEnv, configured: isAiConfigured(config), providers: AI_PROVIDERS.map(p => ({ id: p.id, label: p.label, baseUrl: p.baseUrl, defaultModel: p.defaultModel, defaultVisionModel: p.defaultVisionModel, docsUrl: p.docsUrl })) }); }); // POST /admin/instance/ai -> save the AI settings router.post("/instance/ai", requireAuth, requireAdmin, async (req: any, res: any) => { try { await saveAiSettings({ enabled: req.body?.enabled === true || req.body?.enabled === 'true', provider: String(req.body?.provider || 'openrouter'), baseUrl: String(req.body?.baseUrl || ''), model: String(req.body?.model || ''), visionModel: String(req.body?.visionModel || ''), apiKey: typeof req.body?.apiKey === 'string' ? req.body.apiKey : '' }); res.json({ success: true }); } catch (err: any) { console.error('[ERR] AI settings save:', err.message); res.status(500).json({ success: false, error: req.t('ai.err_save') }); } }); // POST /admin/instance/ai/test -> one real round-trip, so a bad key is found here. // Tests the panel's current fields, not the saved config, so a key can be tried before // committing to Save; any field left blank falls back to what's actually stored. router.post("/instance/ai/test", requireAuth, requireAdmin, async (req: any, res: any) => { const stored = await getStoredAiSettings(); const config = resolveTestConfig({ provider: String(req.body?.provider || ''), baseUrl: String(req.body?.baseUrl || ''), model: String(req.body?.model || ''), visionModel: String(req.body?.visionModel || ''), apiKey: typeof req.body?.apiKey === 'string' ? req.body.apiKey.trim() : '' }, stored); if (!isAiConfigured(config)) { // A hosted provider other than the saved one is tested with a key typed for it: the // held key never leaves for another endpoint (see resolveTestConfig). const error = !config.apiKey && config.provider !== 'custom' ? 'ai.err_test_key_required' : 'ai.err_not_configured'; return res.status(400).json({ success: false, error: req.t(error) }); } try { const result = await aiChat( config, [{ role: 'user', content: 'Reply with the single word: ready' }], { maxTokens: 16, timeoutMs: 15000 } ); res.json({ success: true, model: result.model, reply: result.text.trim().slice(0, 100) }); } catch (err: any) { const status = err instanceof AiError && err.status ? err.status : 502; res.status(status).json({ success: false, error: err.message }); } }); // Create collection (POST) - instance level: only the instance admin creates collections. router.post("/collections/create", requireAuth, requireAdmin, async (req: any, res: any) => { try { const name = (req.body.name || "").trim(); if (!name) { return res.redirect("/admin/instance?msg=error_collection_name"); } const slug = await generateUniqueSlug(name); await Collection.create({ name, slug, createdBy: req.user._id, isDefault: false, shelvesSeeded: true, members: [{ user: req.user._id, role: "admin" }], }); res.redirect("/admin/instance?msg=collection_created"); } catch (err) { console.error("Collection creation error:", err); res.redirect("/admin/instance?msg=error_collection_name"); } }); // Rename collection (POST) - a collection admin renames HIS OWN (active) collection; // the instance admin can rename any. Slug stays stable (not used for routing). router.post("/collections/:id/rename", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { const name = (req.body.name || "").trim(); if (!name) { return res.redirect("/admin?msg=error_collection_name"); } if (!mongoose.Types.ObjectId.isValid(req.params.id)) { return res.redirect("/admin"); } const isOwnActive = String(req.params.id) === String(res.locals.activeCollectionId); if (!isOwnActive && !res.locals.user.isAdmin) { return res.redirect("/admin"); } await Collection.updateOne({ _id: req.params.id }, { $set: { name } }); res.redirect(isOwnActive ? "/admin?msg=collection_renamed" : "/admin/instance?msg=collection_renamed"); } catch (err) { console.error("Collection rename error:", err); res.redirect("/admin?msg=error_collection_name"); } }); // Delete collection (POST) - instance level, destructive cascade: removes the // collection's items and settings. The default collection cannot be deleted. router.post("/collections/:id/delete", requireAuth, requireAdmin, async (req: any, res: any) => { try { if (!mongoose.Types.ObjectId.isValid(req.params.id)) { return res.redirect("/admin/instance"); } const target = await Collection.findById(req.params.id); if (!target) { return res.redirect("/admin/instance"); } if (target.isDefault) { return res.redirect("/admin/instance?msg=error_delete_default_collection"); } const itemImages = await managedItemImagesForQuery({ collection: target._id }); await Item.deleteMany({ collection: target._id }); await Settings.deleteMany({ collection: target._id }); // The value snapshots describe a collection that is about to stop existing, and // nothing else points at them: left behind they would only be unreachable rows. await PriceHistory.deleteMany({ collection: target._id }); // Shelves and lists only mean anything next to the items they arrange. await Furniture.deleteMany({ collection: target._id }); await List.deleteMany({ collection: target._id }); // Users pointing at this collection self-heal to another membership on next request await User.updateMany( { lastActiveCollectionId: target._id }, { $set: { lastActiveCollectionId: null } }, ); await User.updateMany( { homeCollectionId: target._id }, { $set: { homeCollectionId: null } }, ); await Collection.deleteOne({ _id: target._id }); try { await deleteUnusedManagedItemImages(itemImages); } catch (cleanupError) { console.warn('[ITEM IMAGE] Collection cleanup failed:', cleanupError); } res.redirect("/admin/instance?msg=collection_deleted"); } catch (err) { console.error("Collection delete error:", err); res.redirect("/admin/instance"); } }); // ============ INSTANCE-LEVEL MEMBER MANAGEMENT (any collection by :id) ============ // The instance admin manages any collection's members straight from /admin/instance. router.post("/instance/collections/:id/members/add", requireAuth, requireAdmin, async (req: any, res: any) => { try { const { userId } = req.body; const role = ["admin", "editor", "viewer"].includes(req.body.role) ? req.body.role : "viewer"; if (!mongoose.Types.ObjectId.isValid(req.params.id) || !mongoose.Types.ObjectId.isValid(userId)) { return res.redirect("/admin/instance"); } const target = await User.findById(userId).select("_id lastActiveCollectionId"); if (!target) return res.redirect("/admin/instance?msg=error_member_not_found"); const already = await Collection.findOne({ _id: req.params.id, "members.user": userId }); if (already) return res.redirect("/admin/instance?msg=error_member_exists"); await Collection.updateOne( { _id: req.params.id }, { $addToSet: { members: { user: userId, role } } }, ); // Give homeless users a landing collection right away if (!target.lastActiveCollectionId) { await User.updateOne({ _id: userId }, { $set: { lastActiveCollectionId: req.params.id } }); } res.redirect("/admin/instance?msg=member_added"); } catch (err) { console.error("Instance member add error:", err); res.redirect("/admin/instance?msg=error_member"); } }); router.post("/instance/collections/:id/members/role", requireAuth, requireAdmin, async (req: any, res: any) => { try { const { userId } = req.body; const role = ["admin", "editor", "viewer"].includes(req.body.role) ? req.body.role : null; if (!role || !mongoose.Types.ObjectId.isValid(req.params.id) || !mongoose.Types.ObjectId.isValid(userId)) { return res.redirect("/admin/instance"); } await Collection.updateOne( { _id: req.params.id, "members.user": userId }, { $set: { "members.$.role": role } }, ); res.redirect("/admin/instance?msg=member_role_updated"); } catch (err) { console.error("Instance member role error:", err); res.redirect("/admin/instance?msg=error_member"); } }); router.post("/instance/collections/:id/members/remove", requireAuth, requireAdmin, async (req: any, res: any) => { try { const { userId } = req.body; if (!mongoose.Types.ObjectId.isValid(req.params.id) || !mongoose.Types.ObjectId.isValid(userId)) { return res.redirect("/admin/instance"); } await Collection.updateOne( { _id: req.params.id }, { $pull: { members: { user: userId } } }, ); await User.updateOne( { _id: userId, lastActiveCollectionId: req.params.id }, { $set: { lastActiveCollectionId: null } }, ); await User.updateOne( { _id: userId, homeCollectionId: req.params.id }, { $set: { homeCollectionId: null } }, ); res.redirect("/admin/instance?msg=member_removed"); } catch (err) { console.error("Instance member remove error:", err); res.redirect("/admin/instance?msg=error_member"); } }); // ============ COLLECTION MEMBERS (collection-admin scope) ============ const MEMBER_ROLES = ["admin", "editor", "viewer"]; /** True if the collection keeps at least one 'admin' member besides `excludedUserId`. */ function hasAnotherCollectionAdmin(collectionDoc: any, excludedUserId: any): boolean { return (collectionDoc?.members || []).some( (m: any) => m.role === "admin" && String(m.user) !== String(excludedUserId), ); } // Create a NEW instance user directly as a member of the active collection. router.post("/members/create", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { const { username, email } = req.body; const role = MEMBER_ROLES.includes(req.body.role) ? req.body.role : "viewer"; const password = createPassword(); const hashedPassword = await bcrypt.hash(password, 10); const newUser = await User.create({ username, email, password: password, lastChange: new Date(), }); await User.updateOne({ _id: newUser._id }, { $set: { password: hashedPassword } }); await Collection.updateOne( { _id: res.locals.activeCollectionId }, { $addToSet: { members: { user: newUser._id, role } } }, ); await User.updateOne( { _id: newUser._id }, { $set: { lastActiveCollectionId: res.locals.activeCollectionId } }, ); const data = await loadCollectionAdminData(res.locals.activeCollectionId); res.render("admin", { ...data, user: res.locals.user, successMessage: req.t("messages.user_created_success", { name: username }), newPassword: password, }); } catch (err) { console.error("Member creation error:", err); res.redirect(`/admin?msg=${userCreationErrorKey(err)}`); } }); // Invite an EXISTING instance user into the active collection (by email or username). router.post("/members/invite", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { const identifier = (req.body.identifier || "").trim(); const role = MEMBER_ROLES.includes(req.body.role) ? req.body.role : "viewer"; if (!identifier) return res.redirect("/admin?msg=error_member_not_found"); const target: any = await User.findOne({ $or: [{ email: identifier.toLowerCase() }, { username: identifier }], }); if (!target) return res.redirect("/admin?msg=error_member_not_found"); const already = await Collection.findOne({ _id: res.locals.activeCollectionId, "members.user": target._id, }); if (already) return res.redirect("/admin?msg=error_member_exists"); await Collection.updateOne( { _id: res.locals.activeCollectionId }, { $addToSet: { members: { user: target._id, role } } }, ); res.redirect("/admin?msg=member_added"); } catch (err) { console.error("Member invite error:", err); res.redirect("/admin?msg=error_member"); } }); // Change a member's role. One cannot change one's OWN role (another admin must), // and the last collection admin cannot be demoted. router.post("/members/role", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { const { userId } = req.body; const role = MEMBER_ROLES.includes(req.body.role) ? req.body.role : null; if (!role || !mongoose.Types.ObjectId.isValid(userId)) return res.redirect("/admin"); if (String(userId) === String(req.user._id)) { return res.redirect("/admin?msg=error_self_role"); } const coll = await Collection.findById(res.locals.activeCollectionId); const member = (coll?.members || []).find((m: any) => String(m.user) === String(userId)); if (!member) return res.redirect("/admin?msg=error_member_not_found"); if (member.role === "admin" && role !== "admin" && !hasAnotherCollectionAdmin(coll, userId)) { return res.redirect("/admin?msg=error_last_admin"); } await Collection.updateOne( { _id: res.locals.activeCollectionId, "members.user": userId }, { $set: { "members.$.role": role } }, ); res.redirect("/admin?msg=member_role_updated"); } catch (err) { console.error("Member role error:", err); res.redirect("/admin?msg=error_member"); } }); // Remove a member from the active collection. The last collection admin cannot leave. router.post("/members/remove", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { const { userId } = req.body; if (!mongoose.Types.ObjectId.isValid(userId)) return res.redirect("/admin"); const coll = await Collection.findById(res.locals.activeCollectionId); const member = (coll?.members || []).find((m: any) => String(m.user) === String(userId)); if (!member) return res.redirect("/admin?msg=error_member_not_found"); if (member.role === "admin" && !hasAnotherCollectionAdmin(coll, userId)) { return res.redirect("/admin?msg=error_last_admin"); } await Collection.updateOne( { _id: res.locals.activeCollectionId }, { $pull: { members: { user: userId } } }, ); // If they were browsing this collection, let the middleware pick another one await User.updateOne( { _id: userId, lastActiveCollectionId: res.locals.activeCollectionId }, { $set: { lastActiveCollectionId: null } }, ); await User.updateOne( { _id: userId, homeCollectionId: res.locals.activeCollectionId }, { $set: { homeCollectionId: null } }, ); res.redirect("/admin?msg=member_removed"); } catch (err) { console.error("Member remove error:", err); res.redirect("/admin?msg=error_member"); } }); // Reset a member's password. Refused when the target is the instance admin OR // belongs to any other collection: resetting a shared user's password would let // this collection's admin impersonate them and take over their roles elsewhere. router.post("/members/reset-password", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { const { userId } = req.body; if (!mongoose.Types.ObjectId.isValid(userId)) return res.redirect("/admin"); const isMember = await Collection.findOne({ _id: res.locals.activeCollectionId, "members.user": userId, }); const target: any = await User.findById(userId); if (!isMember || !target || target.isAdmin) { return res.redirect("/admin?msg=error_member_not_found"); } const otherMembership = await Collection.findOne({ _id: { $ne: res.locals.activeCollectionId }, "members.user": userId, }); if (otherMembership && !res.locals.user.isAdmin) { return res.redirect("/admin?msg=error_shared_member_reset"); } const password = createPassword(); const hashedPassword = await bcrypt.hash(password, 10); await User.updateOne( { _id: userId }, { $set: { password: hashedPassword, lastChange: new Date() } }, ); const data = await loadCollectionAdminData(res.locals.activeCollectionId); res.render("admin", { ...data, user: res.locals.user, // A name is a value, not markup: the view escapes it on the way out, and letting // i18next escape it first would print the entities instead of the apostrophe. successMessage: req.t("messages.password_reset_success", { name: target.username, interpolation: { escapeValue: false }, }), newPassword: password, }); } catch (err) { console.error("Member reset error:", err); res.redirect("/admin?msg=error_member"); } }); // ============ COLLECTION SHARING (collection-admin scope) ============ // Public, account-free read-only browsing via routes/shareRoutes.ts. A collection can // have several independent links at once (e.g. one scoped to Vinyls, one to CDs) - // each with its own token, so disabling/regenerating/deleting one never touches // the others. /** * Builds a shareLinks[].scope array from a create/edit submission. Checking no type * boxes at all means "whole collection" - there is no separate on/off toggle for * scope itself. A format checked without its type box (e.g. JS failed to auto-check * it - see the checkbox script in views/admin.ejs) still counts: the type is implied * by having any format selected under it, so a submission is never silently dropped. */ function parseShareScope(body: any): { pluginId: string; formats: string[] }[] { const rawTypes = body.scopeTypes; const checkedTypes: string[] = Array.isArray(rawTypes) ? rawTypes : rawTypes ? [rawTypes] : []; const impliedTypes = Object.keys(body) .filter((k) => k.startsWith("scopeFormats_")) .filter((k) => (Array.isArray(body[k]) ? body[k].length > 0 : !!body[k])) .map((k) => k.slice("scopeFormats_".length)); const selectedTypes = [...new Set([...checkedTypes, ...impliedTypes])]; return selectedTypes .map((id: string) => { const plugin = registry.get(id); if (!plugin) return null; const rawFormats = body[`scopeFormats_${id}`]; const submitted: string[] = Array.isArray(rawFormats) ? rawFormats : rawFormats ? [rawFormats] : []; const validFormats = new Set((plugin.formats || []).map((f: any) => f.value)); const formats = submitted.filter((f: string) => validFormats.has(f)); return { pluginId: id, formats }; }) .filter((entry): entry is { pluginId: string; formats: string[] } => !!entry); } // The share links page: every link of the collection with what it opens to, and the // sheets that create and edit them. The actions below all come back here. router.get("/sharing", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { const collectionDoc = await Collection.findById(res.locals.activeCollectionId).lean(); res.render("sharing", { collectionDoc, // Absolute, as on the admin page: a baseUrl-relative path cannot be pasted elsewhere. siteOrigin: `${getPublicProtocol(req)}://${req.get("host")}`, user: res.locals.user, }); } catch (err) { console.error("Sharing page error:", err); res.redirect("/admin?msg=error_share"); } }); // Create a new share link with the submitted scope (and optional label). router.post("/share/create", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { const label = String(req.body.label || "").trim().slice(0, 60); const scope = parseShareScope(req.body); await Collection.updateOne( { _id: res.locals.activeCollectionId }, { $push: { shareLinks: { token: generateShareToken(), label, enabled: true, scope } } }, ); res.redirect("/admin/sharing?msg=share_link_created"); } catch (err) { console.error("Share create error:", err); res.redirect("/admin/sharing?msg=error_share"); } }); // Flip one link's enabled state (disabling keeps its token - re-enabling reuses the // same link/QR code instead of forcing a reprint). router.post("/share/:token/toggle", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { const coll: any = await Collection.findOne( { _id: res.locals.activeCollectionId, "shareLinks.token": req.params.token }, { "shareLinks.$": 1 }, ); const link = coll?.shareLinks?.[0]; if (!link) return res.redirect("/admin/sharing?msg=error_share"); await Collection.updateOne( { _id: res.locals.activeCollectionId, "shareLinks.token": req.params.token }, { $set: { "shareLinks.$.enabled": !link.enabled } }, ); res.redirect(`/admin/sharing?msg=${link.enabled ? "share_disabled" : "share_enabled"}`); } catch (err) { console.error("Share toggle error:", err); res.redirect("/admin/sharing?msg=error_share"); } }); // Update an existing link's scope and/or label in place. router.post("/share/:token/scope", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { const label = String(req.body.label || "").trim().slice(0, 60); const scope = parseShareScope(req.body); const result = await Collection.updateOne( { _id: res.locals.activeCollectionId, "shareLinks.token": req.params.token }, { $set: { "shareLinks.$.scope": scope, "shareLinks.$.label": label } }, ); if (result.matchedCount === 0) return res.redirect("/admin/sharing?msg=error_share"); res.redirect("/admin/sharing?msg=share_scope_updated"); } catch (err) { console.error("Share scope error:", err); res.redirect("/admin/sharing?msg=error_share"); } }); // Rotate a link's token - instantly invalidates whatever URL/QR code is already out // there, without touching this collection's other links. router.post("/share/:token/regenerate", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { const result = await Collection.updateOne( { _id: res.locals.activeCollectionId, "shareLinks.token": req.params.token }, { $set: { "shareLinks.$.token": generateShareToken(), "shareLinks.$.enabled": true } }, ); if (result.matchedCount === 0) return res.redirect("/admin/sharing?msg=error_share"); res.redirect("/admin/sharing?msg=share_regenerated"); } catch (err) { console.error("Share regenerate error:", err); res.redirect("/admin/sharing?msg=error_share"); } }); // Remove a link entirely. router.post("/share/:token/delete", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { // The token is named in the filter as well as in the $pull, the way the routes above // do it: a token belonging to another collection would otherwise pull nothing while // still reporting a success, since the schema's timestamps count every update as a // modification whether or not anything came out of the array. const result = await Collection.updateOne( { _id: res.locals.activeCollectionId, "shareLinks.token": req.params.token }, { $pull: { shareLinks: { token: req.params.token } } }, ); if (result.matchedCount === 0) return res.redirect("/admin/sharing?msg=error_share"); res.redirect("/admin/sharing?msg=share_link_deleted"); } catch (err) { console.error("Share delete error:", err); res.redirect("/admin/sharing?msg=error_share"); } }); // Server-rendered QR PNG so a link's token never has to be handed to a // third-party QR-image API - it stays entirely within this instance. router.get("/share/:token/qr.png", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { // $elemMatch, because a collection holds several links: named separately, the two // conditions can be met by two different ones, and the QR of a link someone just // disabled would keep being served as long as any other link is still on. const coll = await Collection.findOne( { _id: res.locals.activeCollectionId, shareLinks: { $elemMatch: { token: req.params.token, enabled: true } }, }, { _id: 1 }, ); if (!coll) return res.status(404).send(req.t("errors.not_found")); const url = `${getPublicProtocol(req)}://${req.get("host")}${BASE_URL}/share/${req.params.token}`; const png = await QRCode.toBuffer(url, { type: "png", width: 320, margin: 1 }); res.set("Content-Type", "image/png"); res.send(png); } catch (err) { console.error("Share QR error:", err); res.status(500).send(req.t("errors.generic_server_error")); } }); // ============ INSTANCE USERS ============ // Reset password (POST) router.post("/reset-password", requireAuth, requireAdmin, async (req: any, res: any) => { try { const { userId } = req.body; const userToUpdate = await User.findById(userId); // Instance admins are peers: none may reset another instance admin's // password (that would let them hijack the account). Resetting your own is // still allowed. if ( userToUpdate && userToUpdate.isAdmin && userToUpdate._id.toString() !== res.locals.user._id.toString() ) { return res.redirect("/admin/instance?msg=reset_admin_error"); } if (userToUpdate) { const password = createPassword(); const hashedPassword = await bcrypt.hash(password, 10); await User.updateOne( { _id: userId }, { $set: { password: hashedPassword, lastChange: new Date() } }, ); // Reload data for the view after change. const data = await loadInstanceAdminData(); res.render("admin-instance", { ...data, user: res.locals.user, successMessage: req.t("messages.password_reset_success", { name: userToUpdate.username, interpolation: { escapeValue: false }, }), newPassword: password, apiKeyStatus: registry.getApiKeyStatus(), }); } else { res.redirect("/admin/instance"); } } catch (err) { console.error(err); res.redirect("/admin"); } }); // Delete user (POST) - instance level; also drops every collection membership. router.post("/delete-user", requireAuth, requireAdmin, async (req: any, res: any) => { try { if (req.body.userId === res.locals.user._id.toString()) return res.redirect("/admin/instance?msg=delete_self_error"); // Instance admins are peers: none may delete another instance admin. const target = await User.findById(req.body.userId); if (target && target.isAdmin) return res.redirect("/admin/instance?msg=delete_admin_error"); await User.findByIdAndDelete(req.body.userId); await Collection.updateMany({}, { $pull: { members: { user: req.body.userId } } }); console.log(`[ADMIN] User deleted: ${req.body.userId} by ${res.locals.user?.email}`); res.redirect("/admin/instance?msg=user_deleted"); } catch (err) { console.error("[ADMIN] User deletion error:", err); res.redirect("/admin/instance"); } }); router.post("/block-ip", requireAuth, requireAdmin, async (req: any, res: any) => { try { const { ipAddress } = req.body; const exists = await BlockedIP.findOne({ ip: ipAddress }); if (!exists) await BlockedIP.create({ ip: ipAddress }); console.log(`[ADMIN] IP blocked: ${ipAddress} by ${res.locals.user?.email}${exists ? ' (already blocked)' : ''}`); res.redirect("/admin/instance?msg=ip_blocked"); } catch (err) { console.error("[ADMIN] block-ip error:", err); res.redirect("/admin/instance"); } }); router.post("/unblock-ip", requireAuth, requireAdmin, async (req: any, res: any) => { try { await BlockedIP.findByIdAndDelete(req.body.ipId); console.log(`[ADMIN] IP unblocked: ${req.body.ipId} by ${res.locals.user?.email}`); res.redirect("/admin/instance?msg=ip_unblocked"); } catch (err) { console.error("[ADMIN] unblock-ip error:", err); res.redirect("/admin/instance"); } }); // Delete the N most recent login logs (instance level, JSON API like delete-last-items). router.post("/delete-last-logs", requireAuth, requireAdmin, async (req: any, res: any) => { const n = parseInt(req.body.count); if (!n || n < 1) return res.status(400).json({ error: "Invalid count" }); try { const logs = await LoginLog.find().sort({ timestamp: -1 }).limit(n).select("_id"); const ids = logs.map((l) => l._id); const result = await LoginLog.deleteMany({ _id: { $in: ids } }); res.json({ deleted: result.deletedCount }); } catch (err: any) { console.error("[ERR] delete-last-logs:", err.message); res.status(500).json({ error: err.message }); } }); // COLLECTION INFO PAGE EDITOR (see core/collectionInfo.ts). The page it writes is // served by core/routes/collectionInfoRoute.ts and is readable by share visitors, so // only a collection admin gets to write it. router.get("/collection-info", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { const collection = await Collection.findById(res.locals.activeCollectionId).lean(); if (!collection) return res.redirect("/admin"); const info = collectionInfoOf(collection); res.render("admin-collection-info", { user: res.locals.user, collectionName: (collection as any).name, info, infoHtml: renderMarkdown(info.body), maxTitle: MAX_COLLECTION_INFO_TITLE, maxBody: MAX_COLLECTION_INFO_BODY, maxImages: MAX_COLLECTION_INFO_IMAGES, successMessage: req.query.msg ? req.t(`messages.${req.query.msg}`) : null, }); } catch (err) { console.error("[ERR] Collection info editor:", err); res.status(500).send(req.t("errors.generic_server_error")); } }); router.post("/collection-info", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { const collectionId = res.locals.activeCollectionId; const previous = collectionInfoOf(await Collection.findById(collectionId).lean()); const info = collectionInfoFromForm(req.body); await Collection.updateOne( { _id: collectionId }, { $set: { info: { ...info, updated_at: new Date() } } }, ); // An upload dropped from the page is nothing's image any more. The release is // guarded against every other reference (items, other collections), so a picture // used twice stays where it is. const removed = previous.images.filter(image => !info.images.includes(image)); if (removed.length > 0) { try { await deleteUnusedManagedItemImages(removed); } catch (cleanupError) { console.warn("[COLLECTION INFO] Image cleanup failed:", cleanupError); } } res.redirect("/admin/collection-info?msg=saved"); } catch (err) { console.error("[ERR] Collection info save:", err); res.status(500).send(req.t("errors.generic_server_error")); } }); // Live preview of the editor's text. Rendered here rather than in the browser so the // preview and the page itself can never drift apart: both read core/markdown.ts. router.post("/collection-info/preview", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { const body = typeof req.body?.body === "string" ? req.body.body : ""; res.json({ html: renderMarkdown(body.slice(0, MAX_COLLECTION_INFO_BODY)) }); }); router.get("/personnalisation", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { const settings = res.locals.settings; res.render("personnalisation", { presets: PRESETS, // Scan mode is only worth offering when an active module has a source that can // read a query as one exact item (see ExternalSource.exactQuery). scanModeAvailable: registry.getAll().some(p => settings?.modules?.[p.collectionType] && pluginSources(p).some(s => typeof s.exactQuery === "function") ), }); } catch (err) { console.error(err); res.status(500).send("ERR"); } }); router.post( "/personnalisation/save", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { const { homePreset, navbarShortcuts, statsWidgets, aspectRatioClass, } = req.body; const shortcuts = Array.isArray(navbarShortcuts) ? navbarShortcuts : navbarShortcuts ? [navbarShortcuts] : []; const stats = Array.isArray(statsWidgets) ? statsWidgets : statsWidgets ? [statsWidgets] : []; const validFastAdd = [""].concat( registry.getAll().flatMap(p => (p.fastAddOptions || []).map(o => o.value)) ); const fastAdd = validFastAdd.includes(req.body.fastAdd) ? req.body.fastAdd : ""; // Ends up in a class attribute on every card, so only the offered frames pass. const cardAspect = (CARD_ASPECT_RATIOS as readonly string[]).includes(aspectRatioClass) ? aspectRatioClass : "aspect-square"; const update: Record = { "theme.home.preset": homePreset, aspectRatioClass: cardAspect, navbarShortcuts: shortcuts, statsWidgets: stats, fastAdd: fastAdd, instantAdd: req.body.instantAdd === "on", }; for (const p of registry.getAll()) { const preset = req.body[`${p.collectionType}Preset`]; if (preset) update[`theme.${p.collectionType}.preset`] = preset; } await Settings.findOneAndUpdate( { collection: res.locals.activeCollectionId }, { $set: update }, { upsert: true }, ); res.redirect("/admin/personnalisation?msg=saved"); } catch (err) { console.error("[ERR] perso save", err); res.status(500).send("[ERR] perso save failed."); } }, ); router.post("/modules/save", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { const moduleKeys = registry.getAll().map(p => p.collectionType); if (!moduleKeys.some(key => req.body[`${key}Active`] === "on")) { return res.redirect("/admin?msg=error_no_module"); } const update: Record = {}; for (const key of moduleKeys) { update[`modules.${key}`] = req.body[`${key}Active`] === "on"; } update.mergeDuplicates = req.body.mergeDuplicates === "on"; // Plugin-scoped settings (⚙ per module): pluginSetting__ for (const p of registry.getAll()) { for (const opt of p.settings || []) { update[`pluginSettings.${p.id}.${opt.key}`] = req.body[`pluginSetting_${p.id}_${opt.key}`] === "on"; } } // Order the collection prefers its sources in: sourceOrder_, a comma-separated // list of ids. Filtered against what the plugin declares, so a stale form or a hand-made // request cannot store an id that means nothing, and deduplicated, since a repeated id // would silently drop whichever source it displaced. for (const p of registry.getAll()) { const raw = req.body[`sourceOrder_${p.id}`]; if (typeof raw !== "string") continue; const declared = new Set(pluginSources(p).map(s => s.id)); const ids = [...new Set(raw.split(",").map((id: string) => id.trim()).filter((id: string) => declared.has(id)))]; if (ids.length > 0) update[`sourceOrder.${p.id}`] = ids; } await Settings.findOneAndUpdate( { collection: res.locals.activeCollectionId }, { $set: update }, { upsert: true }, ); res.redirect("/admin?msg=saved"); } catch (err) { console.error("[ERR] modules save", err); res.status(500).send("[ERR] modules save failed."); } }); router.post("/visibility/save", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { const { applyToAdmin, hiddenItems, hiddenGenres, hiddenTypes } = req.body || {}; let parsedItems = []; if (hiddenItems) { try { parsedItems = JSON.parse(hiddenItems); } catch (e) { parsedItems = []; } } const applyToAdminVal = applyToAdmin === "on" || applyToAdmin === "true" || applyToAdmin === true; const update = { "visibility.applyToAdmin": applyToAdminVal, "visibility.hiddenItems": parsedItems, "visibility.hiddenGenres": Array.isArray(hiddenGenres) ? hiddenGenres : hiddenGenres ? [hiddenGenres] : [], "visibility.hiddenTypes": Array.isArray(hiddenTypes) ? hiddenTypes : hiddenTypes ? [hiddenTypes] : [], }; await Settings.findOneAndUpdate( { collection: res.locals.activeCollectionId }, { $set: update }, { upsert: true }, ); res.redirect("/admin?msg=saved"); } catch (err) { console.error("[ERR] visibility save", err); res.status(500).send("[ERR] visibility save failed."); } }); router.get( "/api/search-collection", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { try { const { q } = req.query; const trimmedQ = typeof q === 'string' ? q.trim() : ''; if (!trimmedQ) return res.json([]); const regex = new RegExp(escapeRegExp(trimmedQ), 'i'); const searchOr: any[] = [ { title: regex }, { artist: regex }, { author: regex }, { director: regex }, { barcode: regex }, { 'tracklist.title': regex } ]; if (mongoose.Types.ObjectId.isValid(trimmedQ)) { searchOr.push({ _id: trimmedQ }); } const items = await Item.find({ collection: res.locals.activeCollectionId, $or: searchOr }).limit(10).select('_id title artist author director kind cover_image format format_type platform media_type').lean(); res.json(items); } catch (err) { console.error("[ERR] search collection", err); res.status(500).json({ error: "Search failed" }); } }, ); router.get( "/api/search-image-universal", requireAuth, requireCollectionRole("editor"), async (req: any, res: any) => { let { q, type } = req.query; q = typeof q === 'string' ? q.trim() : ''; const pluginId = typeof req.query.plugin === 'string' ? req.query.plugin : ''; try { // The picker names its plugin outright. `type` is what it used to send and is still // accepted: the route is a public endpoint of the instance, and an old page left // open in a tab must not start coming back empty. // // Nothing falls back to the legacy plugin any more. It used to, which is how a // custom plugin asking for its own images was handed music's album covers: a // plugin with no image source of its own now gets none rather than someone else's. const plugin = (pluginId ? registry.get(pluginId) : undefined) || registry.getAll().find(p => p.imageSearchType === type); if (!plugin) { return res.json([]); } // Every image source of the plugin at once, merged and deduplicated. const urls = await gatherImages(plugin, q, { language: req.language }, res.locals.settings); console.log(`[SEARCH] ${plugin.id} found: ${urls.length} images`); res.json(urls); } catch (err: any) { console.error("[ERR] search image universal:", err.message); res.status(500).json({ error: "[ERR] connexion error" }); } }, ); router.post( "/delete-last-items", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { const { count, kind } = req.body; const n = parseInt(count); if (!n || n < 1) return res.status(400).json({ error: "Invalid count" }); const allowedKinds = registry.getAll().map(p => p.kind); if (!allowedKinds.includes(kind)) return res.status(400).json({ error: "Invalid kind" }); try { // Counted the way the grid counts: "the last 3 items" means the last 3 lines someone // can see, and a show leaves with its seasons rather than counting as several. const items = await Item.find({ collection: res.locals.activeCollectionId, kind, parent: { $exists: false } }) .sort({ added_at: -1, _id: -1 }) .limit(n) .select("_id"); const deleted = await deleteItemsAndContents(items.map((i) => i._id)); res.json({ deleted }); } catch (err: any) { console.error("[ERR] delete-last-items:", err.message); res.status(500).json({ error: err.message }); } }, ); router.post( "/refresh-all/:pluginId", requireAuth, requireCollectionRole("admin"), async (req: any, res: any) => { const { pluginId } = req.params; const { mode = "all" } = req.body; const plugin = registry.get(pluginId); if (!plugin) return res.status(404).json({ error: "Plugin not found" }); if (!canRefresh(plugin)) return res.status(400).json({ error: "Plugin does not support refresh" }); try { const idField = plugin.externalIdField || '_id'; // An item qualifies on either reference: the plugin's own id field, which is what // everything added before sources existed carries, or the stored source pair, which // is the only thing an item filled in from another database has. Selecting on the id // field alone is what would leave those out of every bulk run, silently. let query: any = { collection: res.locals.activeCollectionId, $or: [ { [idField]: { $exists: true, $ne: null } }, { source_id: { $exists: true, $nin: [null, ''] } } ] }; if (plugin.matchesLegacyItems) { query.$and = [{ $or: [{ kind: plugin.kind }, { kind: { $exists: false } }] }]; } else { query.kind = plugin.kind; } if (mode === "missing") { query.$and = query.$and || []; query.$and.push({ $or: [ { genre: { $exists: false } }, { genre: "" }, { genre: null }, { genres: { $exists: false } }, { genres: { $size: 0 } }, { styles: { $exists: false } }, { styles: { $size: 0 } } ] }); } const items = await Item.find(query).lean(); if (items.length === 0) return res.json({ success: true, count: 0 }); res.status(202).json({ success: true, total: items.length }); (async () => { const io = req.app.get("io"); let current = 0; for (const item of items) { current++; let success = false; let retries = 0; while (!success && retries < 3) { try { if (io && retries === 0) { io.emit("refresh_all_progress", { current, total: items.length, title: `${item[plugin.creatorField]} - ${item.title}`, }); } const refreshedData = await refreshPatchFor(plugin, item, req); // "missing" mode only backfills genre metadata, never clobber cover/description/ // publisher/etc that the user may have edited by hand. let dataToApply = refreshedData; if (mode === "missing") { dataToApply = {}; for (const k of ["genre", "genres", "styles"]) { if (refreshedData[k] !== undefined) dataToApply[k] = refreshedData[k]; } } // Same realignment as the single-item refresh: a new cover replaces the old // one inside the gallery instead of pushing it down into it. Copied rather // than mutated, since in the full mode this is the plugin's own return value. const update = { ...dataToApply }; const replacedCover = alignImagesAfterRefresh(item, update); // Written even when the provider changed nothing, so the date says when the // item was last checked rather than when it last happened to differ. // // Through the discriminator model rather than the base one: the plugin's own // paths (developer, publisher, tracklist...) do not exist on the base schema, // and strict mode drops them without a word. A bulk run reported every item // as refreshed while writing back only the fields the base schema shares. // The single-item route sidesteps this by casting through `kind` in its filter. const RefreshModel = mongoose.model(plugin.kind); await RefreshModel.updateOne({ _id: item._id }, { $set: { ...update, ...syncStamp() } }); if (replacedCover) { try { await deleteUnusedManagedItemImages([replacedCover]); } catch (cleanupError) { console.warn('[ITEM IMAGE] Post-refresh cleanup failed:', cleanupError); } } success = true; await new Promise((r) => setTimeout(r, plugin.bulkRefreshDelayMs ?? 500)); } catch (err: any) { retries++; console.error( `[ERR] Refresh bulk ID for ${plugin.id} (Attempt ${retries}):`, err.message, ); // Nothing about this item can change between attempts: retrying only // stretches the run by 6 seconds per item for the same failure. if (err instanceof PermanentRefreshError) break; await new Promise((r) => setTimeout(r, 2000)); } } } if (io) io.emit("refresh_all_finished", { count: current }); })(); } catch (err: any) { console.error("[ERR] Bulk refresh route:", err.message); if (!res.headersSent) res.status(500).json({ error: err.message }); } } ); export = router;