rework scan page, add ingest-source browsing, bump version to 2026.04.15.8
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
This commit is contained in:
@@ -23,6 +23,78 @@ export function parseScanLimit(raw: unknown): { ok: true; value: number | null }
|
||||
return { ok: true, value: n };
|
||||
}
|
||||
|
||||
type ScanStatusFilter = "all" | "pending" | "scanned" | "error";
|
||||
type ScanTypeFilter = "all" | "movie" | "episode";
|
||||
type ScanSourceFilter = "all" | "scan" | "webhook";
|
||||
|
||||
export interface ScanItemsQuery {
|
||||
offset: number;
|
||||
limit: number;
|
||||
search: string;
|
||||
status: ScanStatusFilter;
|
||||
type: ScanTypeFilter;
|
||||
source: ScanSourceFilter;
|
||||
}
|
||||
|
||||
function parsePositiveInt(raw: unknown, fallback: number): number {
|
||||
const n = typeof raw === "number" ? raw : Number(raw);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
if (!Number.isInteger(n)) return fallback;
|
||||
return n;
|
||||
}
|
||||
|
||||
function clamp(n: number, min: number, max: number): number {
|
||||
if (n < min) return min;
|
||||
if (n > max) return max;
|
||||
return n;
|
||||
}
|
||||
|
||||
function parseOneOf<T extends readonly string[]>(raw: unknown, allowed: T, fallback: T[number]): T[number] {
|
||||
if (typeof raw !== "string") return fallback;
|
||||
const lowered = raw.toLowerCase();
|
||||
return (allowed as readonly string[]).includes(lowered) ? (lowered as T[number]) : fallback;
|
||||
}
|
||||
|
||||
export function parseScanItemsQuery(raw: Record<string, unknown>): ScanItemsQuery {
|
||||
const limit = clamp(parsePositiveInt(raw.limit, 50), 1, 200);
|
||||
const offset = Math.max(0, parsePositiveInt(raw.offset, 0));
|
||||
const search = typeof raw.q === "string" ? raw.q.trim() : "";
|
||||
return {
|
||||
offset,
|
||||
limit,
|
||||
search,
|
||||
status: parseOneOf(raw.status, ["all", "pending", "scanned", "error"] as const, "all"),
|
||||
type: parseOneOf(raw.type, ["all", "movie", "episode"] as const, "all"),
|
||||
source: parseOneOf(raw.source, ["all", "scan", "webhook"] as const, "all"),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildScanItemsWhere(query: ScanItemsQuery): { sql: string; args: unknown[] } {
|
||||
const clauses: string[] = [];
|
||||
const args: unknown[] = [];
|
||||
if (query.status !== "all") {
|
||||
clauses.push("scan_status = ?");
|
||||
args.push(query.status);
|
||||
}
|
||||
if (query.type !== "all") {
|
||||
clauses.push("lower(type) = ?");
|
||||
args.push(query.type);
|
||||
}
|
||||
if (query.source !== "all") {
|
||||
clauses.push("ingest_source = ?");
|
||||
args.push(query.source);
|
||||
}
|
||||
if (query.search.length > 0) {
|
||||
clauses.push("(lower(name) LIKE ? OR lower(file_path) LIKE ?)");
|
||||
const needle = `%${query.search.toLowerCase()}%`;
|
||||
args.push(needle, needle);
|
||||
}
|
||||
return {
|
||||
sql: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "",
|
||||
args,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
let scanAbort: AbortController | null = null;
|
||||
@@ -60,12 +132,65 @@ app.get("/", (c) => {
|
||||
const errors = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'error'").get() as { n: number })
|
||||
.n;
|
||||
const recentItems = db
|
||||
.prepare("SELECT name, type, scan_status, file_path FROM media_items ORDER BY last_scanned_at DESC LIMIT 50")
|
||||
.all() as { name: string; type: string; scan_status: string; file_path: string }[];
|
||||
.prepare(
|
||||
"SELECT name, type, scan_status, file_path, last_scanned_at, ingest_source FROM media_items ORDER BY COALESCE(last_scanned_at, created_at) DESC, id DESC LIMIT 5",
|
||||
)
|
||||
.all() as {
|
||||
name: string;
|
||||
type: string;
|
||||
scan_status: string;
|
||||
file_path: string;
|
||||
last_scanned_at: string | null;
|
||||
ingest_source: string | null;
|
||||
}[];
|
||||
|
||||
return c.json({ running, progress: { scanned, total, errors }, recentItems, scanLimit: currentScanLimit() });
|
||||
});
|
||||
|
||||
app.get("/items", (c) => {
|
||||
const db = getDb();
|
||||
const query = parseScanItemsQuery({
|
||||
offset: c.req.query("offset"),
|
||||
limit: c.req.query("limit"),
|
||||
q: c.req.query("q"),
|
||||
status: c.req.query("status"),
|
||||
type: c.req.query("type"),
|
||||
source: c.req.query("source"),
|
||||
});
|
||||
const where = buildScanItemsWhere(query);
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, jellyfin_id, name, type, series_name, season_number, episode_number,
|
||||
scan_status, original_language, orig_lang_source, container, file_size, file_path,
|
||||
last_scanned_at, ingest_source
|
||||
FROM media_items
|
||||
${where.sql}
|
||||
ORDER BY COALESCE(last_scanned_at, created_at) DESC, id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`,
|
||||
)
|
||||
.all(...where.args, query.limit, query.offset) as Array<{
|
||||
id: number;
|
||||
jellyfin_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
series_name: string | null;
|
||||
season_number: number | null;
|
||||
episode_number: number | null;
|
||||
scan_status: string;
|
||||
original_language: string | null;
|
||||
orig_lang_source: string | null;
|
||||
container: string | null;
|
||||
file_size: number | null;
|
||||
file_path: string;
|
||||
last_scanned_at: string | null;
|
||||
ingest_source: string | null;
|
||||
}>;
|
||||
const total = (db.prepare(`SELECT COUNT(*) as n FROM media_items ${where.sql}`).get(...where.args) as { n: number }).n;
|
||||
return c.json({ rows, total, hasMore: query.offset + rows.length < total, query });
|
||||
});
|
||||
|
||||
// ─── Start ────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post("/start", async (c) => {
|
||||
|
||||
Reference in New Issue
Block a user