Skip to content
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,28 @@ All configuration is done via environment variables. Below is the complete refer
| `ARR_BRIDGE_ENABLED` | `false` | Enable the fake qBittorrent API server |
| `ARR_BRIDGE_PORT` | `8282` | Port for the *arr bridge (add as qBittorrent in Radarr/Sonarr) |

### 🔄 Provider Reconciliation (opt-in)

After the initial historical library import performed in Radarr/Sonarr,
provider reconciliation detects newly completed files added directly to a
configured debrid provider. It compares snapshots in SQLite, creates
mount-backed symlinks in the existing Arr library, and asks Radarr/Sonarr to
rescan the affected movie or series. It does not copy media locally and does
not delete or repair provider content.

| Variable | Default | Description |
| --- | --- | --- |
| `PROVIDER_RECONCILIATION_ENABLED` | `false` | Enable reconciliation |
| `PROVIDER_RECONCILIATION_RECENT_INTERVAL_MS` | `900000` | Recent snapshot interval |
| `PROVIDER_RECONCILIATION_FULL_INTERVAL_MS` | `21600000` | Full snapshot interval |
| `PROVIDER_RECONCILIATION_RADARR_URL` | — | Radarr API endpoint |
| `PROVIDER_RECONCILIATION_SONARR_URL` | — | Sonarr API endpoint |
| `PROVIDER_RECONCILIATION_MOUNT_BASE` | `/mnt/schrodrive` | Shared mount base |

The provider adapter uses the common `DebridProvider` contract. See
[`docs/provider-reconciliation.md`](docs/provider-reconciliation.md) for
provider capability and live-validation status.

### 📁 Organiser

| Variable | Default | Description |
Expand Down
50 changes: 50 additions & 0 deletions docs/provider-reconciliation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Provider reconciliation

SchröDrive's provider reconciliation layer is provider-agnostic. It consumes
the existing `DebridProvider` contract and never calls provider delete, repair,
or dead-scanner operations.

The historical library import is a one-time operation performed through the
Radarr/Sonarr library import flow. This worker handles the ongoing case where
a new completed file is added directly to a provider after that library is
already configured.

```text
provider listTorrents/fetchDirectories
-> normalized snapshot and local SQLite diff
-> mount-backed symlink in the Arr library
-> RescanMovie / RescanSeries
-> Radarr, Sonarr, Plex and Jellyfin see the same provider-backed file
```

The worker is disabled by default with
`PROVIDER_RECONCILIATION_ENABLED=false`.

## Contract coverage

Every provider registered by SchröDrive implements the common provider
contract used by the worker: stable torrent IDs, normalized torrent files, a
completed virtual directory tree, and a provider mount path. Recent polling is
an optimization; full polling plus the local snapshot diff is the source of
truth.

| Provider | Contract adapter | Live reconciliation validation |
| --- | --- | --- |
| TorBox | common adapter | pending provider-backed test |
| Real-Debrid | common adapter | pending provider-backed test |
| AllDebrid | common adapter | validated with provider-backed Sonarr E2E |
| Premiumize | common adapter | pending provider-backed test |
| Debrid-Link | common adapter | pending provider-backed test |
| Deepbrid | common adapter | pending provider-backed test |
| Offcloud | common adapter | pending provider-backed test |
| Put.io | common adapter | pending provider-backed test |
| MegaDebrid | common adapter | pending provider-backed test |
| Seedr | common adapter | pending provider-backed test |
| PikPak | common adapter | pending provider-backed test |

“Common adapter” means the provider satisfies SchröDrive's existing interface;
it does not claim that a live account has been tested. A provider is skipped
when it is not configured or cannot expose a complete read-only snapshot.

The Arr library contains symlinks only. Provider content remains on the mount;
the reconciliation layer does not download or copy media to local storage.
14 changes: 14 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ export const config = {
alldebridApiKey: process.env.ALLDEBRID_API_KEY || "",
alldebridApiBase: process.env.ALLDEBRID_API_BASE || "https://api.alldebrid.com/v4",
alldebridAgent: process.env.ALLDEBRID_AGENT || "schrodrive",
// Provider reconciliation is opt-in and disabled by default.
providerReconciliationEnabled: String(process.env.PROVIDER_RECONCILIATION_ENABLED ?? "false").toLowerCase() === "true",
providerReconciliationRecentIntervalMs: Number(process.env.PROVIDER_RECONCILIATION_RECENT_INTERVAL_MS || 900000),
providerReconciliationFullIntervalMs: Number(process.env.PROVIDER_RECONCILIATION_FULL_INTERVAL_MS || 21600000),
providerReconciliationRecentLimit: Number(process.env.PROVIDER_RECONCILIATION_RECENT_LIMIT || 30),
providerReconciliationRunFullOnStart: String(process.env.PROVIDER_RECONCILIATION_RUN_FULL_ON_START ?? "true").toLowerCase() !== "false",
providerReconciliationDryRun: String(process.env.PROVIDER_RECONCILIATION_DRY_RUN ?? "false").toLowerCase() === "true",
providerReconciliationRadarrUrl: process.env.PROVIDER_RECONCILIATION_RADARR_URL || "",
providerReconciliationRadarrApiKey: process.env.PROVIDER_RECONCILIATION_RADARR_API_KEY || "",
providerReconciliationSonarrUrl: process.env.PROVIDER_RECONCILIATION_SONARR_URL || "",
providerReconciliationSonarrApiKey: process.env.PROVIDER_RECONCILIATION_SONARR_API_KEY || "",
providerReconciliationMountBase: process.env.PROVIDER_RECONCILIATION_MOUNT_BASE || "/mnt/schrodrive",
providerReconciliationMoviesLibraryPath: process.env.PROVIDER_RECONCILIATION_MOVIES_LIBRARY_PATH || "",
providerReconciliationShowsLibraryPath: process.env.PROVIDER_RECONCILIATION_SHOWS_LIBRARY_PATH || "",
// AllDebrid WebDAV (if supported)
alldebridWebdavUrl: process.env.ALLDEBRID_WEBDAV_URL || "",
alldebridWebdavUsername: process.env.ALLDEBRID_WEBDAV_USERNAME || "",
Expand Down
14 changes: 13 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { getDb, closeDb, pruneOldEntries, pruneExpiredStrmCodes } from "./core/d
import { startStrmServer, stopStrmServer } from "./services/strmService";
import { startCloudLinksBridge, stopCloudLinksBridge } from "./services/cloudLinks/bridge";
import { startArrBridge, stopArrBridge } from "./services/arrBridge";
import { startProviderReconciliation } from "./services/providerReconciliationRuntime";
import type { ProviderReconciliationWorker } from "./services/providerReconciliation";

const program = new Command();
program
Expand All @@ -31,6 +33,7 @@ program
}

// Register graceful shutdown handlers
let providerReconciliationWorker: ProviderReconciliationWorker | undefined;
const shutdown = () => {
console.log(`[${new Date().toISOString()}][serve] Shutting down — unmounting FUSE drives...`);
try {
Expand All @@ -43,6 +46,7 @@ program
stopStrmServer().catch(() => {});
stopCloudLinksBridge().catch(() => {});
stopArrBridge().catch(() => {});
providerReconciliationWorker?.stop();
setTimeout(() => {
console.log(`[${new Date().toISOString()}][serve] Closing database and exiting...`);
closeDb();
Expand Down Expand Up @@ -77,7 +81,13 @@ program
});
}

promises.push(mountVirtualDrive());
// The provider reconciliation worker submits Arr rescans against this
// mount. Wait until mountVirtualDrive has established the visible paths
// before starting the worker below; otherwise Arr can reject the first
// scan as a missing file during FUSE startup.
await mountVirtualDrive().catch((err: any) => {
console.error(`[${new Date().toISOString()}][serve] Virtual drive mount failed (non-fatal): ${err?.message}`);
});
}

if (config.runDeadScannerWatch) {
Expand All @@ -97,6 +107,8 @@ program
console.log("[serve] Starting media server watchlist poller (RUN_WATCHLIST_POLLER=true)");
startWatchlistPoller();
}

providerReconciliationWorker = startProviderReconciliation();

// Start the main server
startServer();
Expand Down
24 changes: 24 additions & 0 deletions src/providers/alldebrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,30 @@ export class AllDebridProvider implements DebridProvider {
return result;
}

/**
* Returns completed virtual directories for an explicit magnet subset.
* This is read-only and lets provider reconciliation implement
* a bounded recent scan without downloading or mutating provider state.
*/
async fetchDirectoriesForIds(magnets: Array<Pick<TorrentInfo, 'id' | 'name' | 'filename'>>): Promise<VirtualDirectory[]> {
const ids = magnets.map((magnet) => String(magnet.id)).filter(Boolean);
const fileTrees = await this.fetchFileTrees(ids);
return magnets.map((magnet) => {
const id = String(magnet.id);
const files: VirtualFile[] = (fileTrees.get(id) || []).map((file) => ({
id: file.path,
name: file.path,
size: file.size,
}));
return {
id,
name: sanitiseName(magnet.filename || magnet.name || id),
originalName: magnet.filename || magnet.name || id,
files,
};
});
}

/**
* Fetches the complete magnet list from AllDebrid and converts it into
* virtual directories. Only includes fully downloaded magnets
Expand Down
Loading
Loading