Restore package-based link collector without blocking DLC drops

Reintroduce expandable collector packages, background metadata enrichment, filters, selection, and controlled queue transfer. Keep DLC files dropped outside the collector on the direct addContainers path without settings persistence or metadata waits. Protect collector state with stable URL identities, non-degrading metadata merges, and per-URL generations so stale enrichment responses cannot overwrite newer data or resurrect removed links.
This commit is contained in:
Sucukdeluxe
2026-08-26 13:52:49 +02:00
parent 8ef60eda87
commit 14525fa4c3
22 changed files with 2534 additions and 1210 deletions
+156
View File
@@ -0,0 +1,156 @@
export type CollectorAvailability = "online" | "offline" | "unknown";
export type CollectorLinkStatus = "ready" | "offline" | "unknown";
export type CollectorPackageNameSource = "explicit" | "inferred";
export interface CollectorLink {
id: string;
url: string;
fileName: string;
fileSizeBytes: number | null;
hoster: string;
availability: CollectorAvailability;
status: CollectorLinkStatus;
addedAt: number;
}
export interface CollectorPackage {
id: string;
name: string;
nameSource: CollectorPackageNameSource;
links: CollectorLink[];
addedAt: number;
}
export interface CollectorTextPreparationRequest {
rawText: string;
addedAt: number;
}
export interface CollectorContainerPreparationRequest {
filePaths: string[];
addedAt: number;
}
export interface CollectorEnrichmentRequest {
packages: CollectorPackage[];
}
export interface CollectorInspectionResult {
packages: CollectorPackage[];
invalidCount: number;
duplicateCount: number;
}
function validAddedAt(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
function isAbsoluteDlcPath(value: unknown): value is string {
return typeof value === "string"
&& value.length > 0
&& value.length <= 32767
&& /^(?:[a-z]:[\\/]|\\\\|\/)/i.test(value)
&& value.toLowerCase().endsWith(".dlc");
}
export function validateCollectorTextPreparationRequest(value: unknown): CollectorTextPreparationRequest {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Linksammler-Payload ist ungültig");
}
const raw = value as Record<string, unknown>;
if (Object.keys(raw).some((key) => key !== "rawText" && key !== "addedAt")
|| typeof raw.rawText !== "string"
|| new TextEncoder().encode(raw.rawText).byteLength > 2_000_000
|| !validAddedAt(raw.addedAt)) {
throw new Error("Linksammler-Payload ist ungültig");
}
return { rawText: raw.rawText, addedAt: raw.addedAt };
}
export function validateCollectorContainerPreparationRequest(value: unknown): CollectorContainerPreparationRequest {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Container-Payload ist ungültig");
}
const raw = value as Record<string, unknown>;
if (Object.keys(raw).some((key) => key !== "filePaths" && key !== "addedAt")
|| !Array.isArray(raw.filePaths)
|| raw.filePaths.length === 0
|| raw.filePaths.length > 100
|| raw.filePaths.some((entry) => !isAbsoluteDlcPath(entry))
|| !validAddedAt(raw.addedAt)) {
throw new Error("Container-Payload ist ungültig");
}
return { filePaths: [...raw.filePaths] as string[], addedAt: raw.addedAt };
}
function validCollectorLink(value: unknown): value is CollectorLink {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const raw = value as Record<string, unknown>;
return Object.keys(raw).every((key) => [
"id", "url", "fileName", "fileSizeBytes", "hoster", "availability", "status", "addedAt"
].includes(key))
&& typeof raw.id === "string"
&& raw.id.length > 0
&& raw.id.length <= 160
&& typeof raw.url === "string"
&& raw.url.length <= 32767
&& /^https?:\/\/[^\s]+$/i.test(raw.url)
&& typeof raw.fileName === "string"
&& raw.fileName.length <= 1024
&& (raw.fileSizeBytes === null || (typeof raw.fileSizeBytes === "number" && Number.isSafeInteger(raw.fileSizeBytes) && raw.fileSizeBytes >= 0))
&& typeof raw.hoster === "string"
&& raw.hoster.length <= 255
&& (raw.availability === "online" || raw.availability === "offline" || raw.availability === "unknown")
&& (raw.status === "ready" || raw.status === "offline" || raw.status === "unknown")
&& validAddedAt(raw.addedAt);
}
function validCollectorPackage(value: unknown): value is CollectorPackage {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const raw = value as Record<string, unknown>;
return Object.keys(raw).every((key) => ["id", "name", "nameSource", "links", "addedAt"].includes(key))
&& typeof raw.id === "string"
&& raw.id.length > 0
&& raw.id.length <= 160
&& typeof raw.name === "string"
&& raw.name.length > 0
&& raw.name.length <= 1024
&& (raw.nameSource === "explicit" || raw.nameSource === "inferred")
&& Array.isArray(raw.links)
&& raw.links.length > 0
&& raw.links.every(validCollectorLink)
&& validAddedAt(raw.addedAt);
}
export function validateCollectorEnrichmentRequest(value: unknown): CollectorEnrichmentRequest {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Linksammler-Anreicherung ist ungültig");
}
const raw = value as Record<string, unknown>;
if (Object.keys(raw).some((key) => key !== "packages")
|| !Array.isArray(raw.packages)
|| raw.packages.length === 0
|| raw.packages.length > 2_000
|| raw.packages.some((entry) => !validCollectorPackage(entry))
|| raw.packages.reduce((sum, entry) => sum + (entry as CollectorPackage).links.length, 0) > 20_000) {
throw new Error("Linksammler-Anreicherung ist ungültig");
}
return { packages: structuredClone(raw.packages) as CollectorPackage[] };
}
function collectorMarkerValue(value: string): string {
return String(value || "").replace(/[\r\n]+/g, " ").trim();
}
export function serializeCollectorPackages(packages: CollectorPackage[]): string {
const lines: string[] = [];
for (const pkg of packages) {
if (pkg.links.length === 0) continue;
lines.push(`# Package: ${collectorMarkerValue(pkg.name)}`);
for (const link of pkg.links) {
if (link.fileName) lines.push(`# File: ${collectorMarkerValue(link.fileName)}`);
lines.push(link.url);
}
}
return lines.join("\n");
}
+6 -3
View File
@@ -12,9 +12,12 @@ export const IPC_CHANNELS = {
DELETE_ACCOUNT: "app:delete-account",
RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage",
RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage",
ADD_LINKS: "queue:add-links",
ADD_CONTAINERS: "queue:add-containers",
GET_START_CONFLICTS: "queue:get-start-conflicts",
ADD_LINKS: "queue:add-links",
ADD_CONTAINERS: "queue:add-containers",
PREPARE_COLLECTOR_TEXT: "collector:prepare-text",
PREPARE_COLLECTOR_CONTAINERS: "collector:prepare-containers",
ENRICH_COLLECTOR_PACKAGES: "collector:enrich-packages",
GET_START_CONFLICTS: "queue:get-start-conflicts",
RESOLVE_START_CONFLICT: "queue:resolve-start-conflict",
CLEAR_ALL: "queue:clear-all",
START: "queue:start",
+12 -3
View File
@@ -34,6 +34,11 @@ import type {
UpdateInstallResult
} from "./types";
import { isRealDebridWebAccountId } from "./real-debrid-accounts";
import type {
CollectorEnrichmentRequest,
CollectorInspectionResult,
CollectorTextPreparationRequest
} from "./collector";
export interface RealDebridLoginRequest {
accountId: string;
@@ -77,9 +82,13 @@ export interface ElectronApi {
replaceAccount: (command: AccountReplaceCommand) => Promise<AccountCommandResult>;
updateAccountSecret: (command: AccountUpdateSecretCommand) => Promise<AccountCommandResult>;
deleteAccount: (command: AccountDeleteCommand) => Promise<AccountCommandResult>;
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
getStartConflicts: () => Promise<StartConflictEntry[]>;
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
prepareCollectorText: (request: CollectorTextPreparationRequest) => Promise<CollectorInspectionResult>;
prepareCollectorContainers: (filePaths: string[], addedAt: number) => Promise<CollectorInspectionResult>;
enrichCollectorPackages: (request: CollectorEnrichmentRequest) => Promise<CollectorInspectionResult>;
getPathForDroppedFile: (file: File) => string;
getStartConflicts: () => Promise<StartConflictEntry[]>;
resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>;
clearAll: () => Promise<void>;
start: () => Promise<void>;