Compare commits
No commits in common. "main" and "fix/radarr-exact-file-import" have entirely different histories.
main
...
fix/radarr
11 changed files with 114 additions and 1193 deletions
|
|
@ -1,6 +1,6 @@
|
|||
services:
|
||||
fileflows:
|
||||
image: revenz/fileflows:26.09
|
||||
image: revenz/fileflows:26.06
|
||||
container_name: fileflows
|
||||
restart: always
|
||||
ports:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"DateCreated": "2026-05-13T06:59:27Z",
|
||||
"DateModified": "2026-07-15T18:19:38Z",
|
||||
"Enabled": true,
|
||||
"Revision": 15,
|
||||
"Revision": 14,
|
||||
"Type": 0,
|
||||
"ReadOnly": false,
|
||||
"Default": false,
|
||||
|
|
@ -310,7 +310,7 @@
|
|||
"ErrorConnection": {
|
||||
"Input": 1,
|
||||
"Output": -1,
|
||||
"InputNode": "6bf4e65f-5376-4b52-a754-bc0972620be4"
|
||||
"InputNode": "64a5eb5c-13d3-47d7-b67a-a013fad58145"
|
||||
},
|
||||
"Type": 9,
|
||||
"Model": {
|
||||
|
|
@ -343,7 +343,7 @@
|
|||
"ErrorConnection": {
|
||||
"Input": 1,
|
||||
"Output": -1,
|
||||
"InputNode": "6bf4e65f-5376-4b52-a754-bc0972620be4"
|
||||
"InputNode": "64a5eb5c-13d3-47d7-b67a-a013fad58145"
|
||||
},
|
||||
"Type": 9,
|
||||
"Model": {
|
||||
|
|
@ -352,7 +352,7 @@
|
|||
"ImportPath": "/tdarr/converted/video/",
|
||||
"UseUnmappedPath": false,
|
||||
"MoveMode": true,
|
||||
"TimeOut": 900,
|
||||
"TimeOut": 300,
|
||||
"Color": ""
|
||||
}
|
||||
},
|
||||
|
|
@ -377,7 +377,7 @@
|
|||
"ErrorConnection": {
|
||||
"Input": 1,
|
||||
"Output": -1,
|
||||
"InputNode": "6bf4e65f-5376-4b52-a754-bc0972620be4"
|
||||
"InputNode": "64a5eb5c-13d3-47d7-b67a-a013fad58145"
|
||||
},
|
||||
"Type": 9,
|
||||
"Model": {
|
||||
|
|
@ -386,7 +386,7 @@
|
|||
"ImportPath": "/tdarr/converted/serien4k",
|
||||
"UseUnmappedPath": false,
|
||||
"MoveMode": true,
|
||||
"TimeOut": 900,
|
||||
"TimeOut": 300,
|
||||
"Color": ""
|
||||
}
|
||||
},
|
||||
|
|
@ -411,7 +411,7 @@
|
|||
"ErrorConnection": {
|
||||
"Input": 1,
|
||||
"Output": -1,
|
||||
"InputNode": "6bf4e65f-5376-4b52-a754-bc0972620be4"
|
||||
"InputNode": "64a5eb5c-13d3-47d7-b67a-a013fad58145"
|
||||
},
|
||||
"Type": 9,
|
||||
"Model": {
|
||||
|
|
@ -420,7 +420,7 @@
|
|||
"ImportPath": "/tdarr/converted/serien",
|
||||
"UseUnmappedPath": false,
|
||||
"MoveMode": true,
|
||||
"TimeOut": 900,
|
||||
"TimeOut": 300,
|
||||
"Color": ""
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,87 +0,0 @@
|
|||
# FileFlows ApiKey Guard
|
||||
|
||||
## Problem
|
||||
|
||||
Die FileFlows-API liefert `ApiKey`-Felder in Flow-Parts beim `GET` grundsätzlich
|
||||
als Literal `[REDACTED]` aus. Wird ein Flow danach gespeichert — aus der Web-UI
|
||||
oder über einen `GET → PUT`-Roundtrip (Scripts, Automationen) — landet dieser
|
||||
Platzhalter in der Datenbank.
|
||||
|
||||
Folge: die Nodes `Sonarr - Trigger Manual Import` und
|
||||
`Radarr - Trigger Manual Import` authentisieren sich mit `[REDACTED]`, die Arr
|
||||
antwortet **401**, der Node bricht **still** ab.
|
||||
|
||||
Fehlerbild in der Kette:
|
||||
|
||||
```
|
||||
SABnzbd (movetdarr.sh) → FileFlows → Arr Manual Import
|
||||
↑ 401, still
|
||||
```
|
||||
|
||||
- n8n Media-Handoff bleibt auf `processing` stehen
|
||||
- `movetdarr.sh` pollt den Handoff-Lease weiter → SAB-Job hängt in der Queue
|
||||
bis zum 24-h-Timeout
|
||||
- Symptom beim Nutzer: "hängt seit Stunden in der Queue"
|
||||
|
||||
**Ein einziger UI-Save zerschießt alle vier Keys gleichzeitig**, weil der GET
|
||||
alle vier als `[REDACTED]` ausliefert (verifiziert per Negativtest: 1 Key
|
||||
sabotiert → 4 Keys in der DB defekt).
|
||||
|
||||
## Lösung
|
||||
|
||||
`ff-apikey-guard.sh` prüft alle Flows in der FileFlows-SQLite auf ApiKeys mit
|
||||
Länge != 32 und schreibt die echten Keys via Butler-Proxy
|
||||
(`PUT /fileflows/api/flow`) zurück.
|
||||
|
||||
Keys kommen aus einer lokalen Map, nie aus Git.
|
||||
|
||||
## Deployment (tdarr, 10.2.1.104)
|
||||
|
||||
```bash
|
||||
sudo install -m 750 fileflows/guard/ff-apikey-guard.sh \
|
||||
/app-config/ff-guard/ff-apikey-guard.sh
|
||||
|
||||
# Butler-Token
|
||||
printf '%s' '<butler-token>' | sudo tee /app-config/ff-guard/butler.token
|
||||
sudo chmod 600 /app-config/ff-guard/butler.token
|
||||
|
||||
# Key-Map: "<port> <apikey>" pro Zeile, Keys aus den Arr-config.xml auf 10.2.1.100
|
||||
# 8989 sonarrUHD / 8990 sonarrFHD / 7878 radarrUHD / 7879 radarrFHD
|
||||
sudo chmod 600 /app-config/ff-guard/arr-keys.map
|
||||
```
|
||||
|
||||
Läuft als systemd-Timer alle 15 Minuten (`cron` ist auf tdarr inaktiv):
|
||||
|
||||
```
|
||||
/etc/systemd/system/ff-apikey-guard.service Type=oneshot
|
||||
/etc/systemd/system/ff-apikey-guard.timer OnUnitActiveSec=15min
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now ff-apikey-guard.timer
|
||||
```
|
||||
|
||||
## Betrieb
|
||||
|
||||
```bash
|
||||
# Log
|
||||
sudo tail /app-config/ff-guard/guard.log
|
||||
|
||||
# Manuell prüfen/reparieren
|
||||
sudo systemctl start ff-apikey-guard.service
|
||||
|
||||
# Timer-Status
|
||||
systemctl list-timers ff-apikey-guard.timer
|
||||
```
|
||||
|
||||
Logzeilen (Datum TT.MM.JJJJ):
|
||||
|
||||
- `OK: alle Flow-ApiKeys 32 Zeichen` — nichts zu tun
|
||||
- `WARNUNG: defekte ApiKeys in Flow <uid> — repariere`
|
||||
- `Flow <uid> repariert: N Key(s) zurueckgeschrieben (HTTP 200)`
|
||||
|
||||
## Nach einem UI-Save immer prüfen
|
||||
|
||||
Wer einen Flow in der FileFlows-UI speichert, sollte danach den Guard anstoßen
|
||||
oder maximal 15 Minuten auf den Timer warten. Vorher schlagen alle
|
||||
Arr-Importe still fehl.
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
#!/bin/bash
|
||||
# ff-apikey-guard.sh — repariert kaputte Arr-ApiKeys in FileFlows-Flows
|
||||
#
|
||||
# URSACHE: Die FileFlows-API liefert ApiKey-Felder im GET als "[REDACTED]".
|
||||
# Wird ein Flow aus der Web-UI (oder per GET->PUT-Roundtrip) gespeichert,
|
||||
# landet dieser Platzhalter in der DB. Die "Trigger Manual Import"-Nodes
|
||||
# schlagen dann still fehl (Arr antwortet 401), der n8n-Handoff bleibt auf
|
||||
# "processing" und der SAB-Job haengt bis zum 24h-Timeout in der Queue.
|
||||
#
|
||||
# Dieses Skript erkennt ApiKeys != 32 Zeichen und schreibt die echten Keys
|
||||
# aus /app-config/ff-guard/arr-keys.map zurueck (Format: "<port> <key>").
|
||||
set -u
|
||||
|
||||
BUTLER="${BUTLER:-http://10.5.85.2:8888}"
|
||||
GUARD_DIR="${GUARD_DIR:-/app-config/ff-guard}"
|
||||
TOKEN_FILE="$GUARD_DIR/butler.token"
|
||||
KEYMAP="$GUARD_DIR/arr-keys.map"
|
||||
LOG="$GUARD_DIR/guard.log"
|
||||
DB="${DB:-/app-config/fileflows-data/Data/FileFlows.sqlite}"
|
||||
|
||||
mkdir -p "$GUARD_DIR"
|
||||
log() { echo "$(date '+%d.%m.%Y %H:%M:%S'): $1" >> "$LOG"; }
|
||||
|
||||
for f in "$TOKEN_FILE" "$KEYMAP"; do
|
||||
[ -r "$f" ] || { log "FEHLER: Datei fehlt oder nicht lesbar: $f"; exit 1; }
|
||||
done
|
||||
[ -r "$DB" ] || { log "FEHLER: DB nicht lesbar: $DB"; exit 1; }
|
||||
|
||||
TOKEN="$(tr -d '\n\r' < "$TOKEN_FILE")"
|
||||
|
||||
# Flows mit defekten Keys ermitteln
|
||||
FLOWUIDS="$(python3 - "$DB" <<'PY'
|
||||
import sqlite3, json, sys
|
||||
con = sqlite3.connect("file:%s?mode=ro" % sys.argv[1], uri=True)
|
||||
for uid, name, typ, d in con.execute(
|
||||
"select Uid,Name,Type,Data from DbObject where Type like '%Flow%'"):
|
||||
try:
|
||||
o = json.loads(d)
|
||||
except Exception:
|
||||
continue
|
||||
for p in o.get("Parts", []):
|
||||
k = (p.get("Model") or {}).get("ApiKey")
|
||||
if k is not None and len(str(k)) != 32:
|
||||
print(uid)
|
||||
break
|
||||
PY
|
||||
)"
|
||||
|
||||
if [ -z "$FLOWUIDS" ]; then
|
||||
log "OK: alle Flow-ApiKeys 32 Zeichen"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RC=0
|
||||
for uid in $FLOWUIDS; do
|
||||
log "WARNUNG: defekte ApiKeys in Flow $uid — repariere"
|
||||
OUT="$(mktemp)"; PATCHED="$(mktemp)"
|
||||
|
||||
if ! curl -sf -m 60 -H "Authorization: Bearer $TOKEN" \
|
||||
"$BUTLER/fileflows/api/flow/$uid" -o "$OUT"; then
|
||||
log "FEHLER: GET flow $uid fehlgeschlagen"
|
||||
rm -f "$OUT" "$PATCHED"; RC=1; continue
|
||||
fi
|
||||
|
||||
CHANGED="$(python3 - "$OUT" "$KEYMAP" "$PATCHED" <<'PY'
|
||||
import json, sys
|
||||
flow = json.load(open(sys.argv[1]))
|
||||
keys = {}
|
||||
for line in open(sys.argv[2]):
|
||||
parts = line.split()
|
||||
if len(parts) == 2 and len(parts[1]) == 32:
|
||||
keys[parts[0]] = parts[1]
|
||||
n = 0
|
||||
for p in flow.get("Parts", []):
|
||||
mod = p.get("Model") or {}
|
||||
if "ApiKey" not in mod:
|
||||
continue
|
||||
url = str(mod.get("URL") or mod.get("ApiUrl") or "")
|
||||
port = url.rstrip("/").rsplit(":", 1)[-1]
|
||||
if port in keys and str(mod["ApiKey"]) != keys[port]:
|
||||
mod["ApiKey"] = keys[port]
|
||||
n += 1
|
||||
json.dump(flow, open(sys.argv[3], "w"))
|
||||
print(n)
|
||||
PY
|
||||
)"
|
||||
|
||||
if [ "${CHANGED:-0}" = "0" ]; then
|
||||
log "WARNUNG: Flow $uid — kein Key konnte gemappt werden (Ports pruefen)"
|
||||
rm -f "$OUT" "$PATCHED"; RC=1; continue
|
||||
fi
|
||||
|
||||
HTTP="$(curl -s -m 60 -o /dev/null -w '%{http_code}' -X PUT \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
"$BUTLER/fileflows/api/flow" --data @"$PATCHED")"
|
||||
|
||||
if [ "$HTTP" = "200" ]; then
|
||||
log "Flow $uid repariert: $CHANGED Key(s) zurueckgeschrieben (HTTP 200)"
|
||||
else
|
||||
log "FEHLER: PUT flow $uid -> HTTP $HTTP"
|
||||
RC=1
|
||||
fi
|
||||
rm -f "$OUT" "$PATCHED"
|
||||
done
|
||||
exit $RC
|
||||
|
|
@ -4,10 +4,8 @@
|
|||
* behandelt Untertitel (Text-Subs als .srt-Sidecar, Bild-Subs Disposition neutralisieren).
|
||||
* SICHERHEITS-FIX 17.07.2026: atomare Sidecar-Erzeugung, 0-Byte-Pruefung,
|
||||
* nur erfolgreich extrahierte Textspuren entfernen, Remux-Verifikation.
|
||||
* CLEANUP-FIX 16.09.2026: leeres Release-Verzeichnis unter SourceRoot
|
||||
* entfernen (nur wenn keine Mediendatei mehr drin liegt).
|
||||
* @author Sascha + Trulla
|
||||
* @revision 3
|
||||
* @revision 2
|
||||
* @output OK
|
||||
* @output Fehler
|
||||
*/
|
||||
|
|
@ -60,7 +58,6 @@ function Script(SourceRoot, DestRoot)
|
|||
if (origPath !== destAbs) {
|
||||
Flow.Execute({ command: '/bin/rm', argumentList: ['-f', origPath] });
|
||||
Logger.ILog('Deleted original: ' + origPath);
|
||||
cleanupSourceDir(origPath);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
|
|
@ -90,52 +87,6 @@ function Script(SourceRoot, DestRoot)
|
|||
return 1;
|
||||
|
||||
// -- Helfer --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Loescht das Release-Verzeichnis des Originals, wenn darin keine
|
||||
* Mediendatei mehr liegt. Ohne das bleiben nach jedem Import leere
|
||||
* Ordner mit .nfo/.sfv-Resten unter /tdarr/complete/ liegen, die den
|
||||
* Handoff-Diagnostics-Endpoint als "target_directories" verschmutzen
|
||||
* und faelschlich wie haengende Jobs aussehen.
|
||||
* Sicherheit: nur unter SourceRoot, nur wenn keine Mediendatei mehr da.
|
||||
*/
|
||||
function cleanupSourceDir(originalFile) {
|
||||
var dir = originalFile.substring(0, originalFile.lastIndexOf('/'));
|
||||
|
||||
if (!dir || dir.indexOf(SourceRoot) !== 0 || dir === SourceRoot ||
|
||||
dir === SourceRoot.replace(/\/$/, '')) {
|
||||
Logger.ILog('Cleanup: "' + dir + '" nicht unter ' + SourceRoot + ' - uebersprungen');
|
||||
return;
|
||||
}
|
||||
|
||||
var mediaExts = ['mkv', 'mp4', 'avi', 'ts', 'm2ts', 'mov', 'wmv', 'mpg', 'mpeg', 'm4v', 'iso'];
|
||||
var findArgs = [dir, '-type', 'f', '('];
|
||||
mediaExts.forEach(function (ext, i) {
|
||||
if (i > 0) findArgs.push('-o');
|
||||
findArgs.push('-iname', '*.' + ext);
|
||||
});
|
||||
findArgs.push(')');
|
||||
|
||||
var found = Flow.Execute({ command: '/usr/bin/find', argumentList: findArgs });
|
||||
if (!found || found.exitCode !== 0) {
|
||||
Logger.WLog('Cleanup: find fehlgeschlagen - Verzeichnis bleibt: ' + dir);
|
||||
return;
|
||||
}
|
||||
|
||||
var remaining = ('' + (found.standardOutput || '')).trim();
|
||||
if (remaining.length > 0) {
|
||||
Logger.ILog('Cleanup: Mediendateien noch vorhanden - Verzeichnis bleibt: ' + dir);
|
||||
return;
|
||||
}
|
||||
|
||||
var del = Flow.Execute({ command: '/bin/rm', argumentList: ['-rf', dir] });
|
||||
if (del && del.exitCode === 0) {
|
||||
Logger.ILog('Cleanup: Release-Verzeichnis entfernt: ' + dir);
|
||||
} else {
|
||||
Logger.WLog('Cleanup: rm fehlgeschlagen (exit=' + (del ? del.exitCode : '?') + '): ' + dir);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubtitles(file) {
|
||||
var ffprobe = Flow.GetToolPath('ffprobe') || Variables['ffprobe'] || 'ffprobe';
|
||||
var ffmpeg = Flow.GetToolPath('ffmpeg') || Variables['ffmpeg'] || 'ffmpeg';
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* @name Radarr - Trigger Manual Import
|
||||
* @description Safely trigger Radarr ManualImport for exactly one processed file.
|
||||
* @author FileFlows + Sascha
|
||||
* @revision 11
|
||||
* @revision 10
|
||||
* @output Import erfolgreich
|
||||
* @output Fehler
|
||||
* @param {string} URL Radarr root URL and port
|
||||
|
|
@ -48,21 +48,12 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) {
|
|||
const radarr = new Radarr(URL, ApiKey);
|
||||
|
||||
/*── movieId detection ──────────────────────────────────*/
|
||||
const filePattern = Variables.file.Orig.FileNameNoExtension;
|
||||
const folderPattern = System.IO.Path.GetFileName(System.IO.Path.GetDirectoryName(exactImportPath));
|
||||
const searchPatterns = [folderPattern, filePattern]
|
||||
.filter(Boolean)
|
||||
.filter((value, index, values) => values.indexOf(value) === index);
|
||||
|
||||
let movieId = Variables['Radarr.movieId'] ?? null;
|
||||
for (const pattern of searchPatterns) {
|
||||
if (!movieId) {
|
||||
movieId = findMovieIdFromQueue(pattern, radarr)
|
||||
?? findMovieIdFromGrabHistory(pattern, radarr);
|
||||
}
|
||||
if (movieId) break;
|
||||
}
|
||||
if (!movieId) movieId = parseMovie(searchPatterns[0] || '', radarr);
|
||||
const searchPattern = Variables.file.Orig.FileNameNoExtension;
|
||||
let movieId = Variables['Radarr.movieId']
|
||||
?? findMovieIdFromQueue(searchPattern, radarr)
|
||||
?? findMovieIdFromGrabHistory(searchPattern, radarr)
|
||||
?? parseMovie(searchPattern, radarr)
|
||||
?? null;
|
||||
|
||||
Logger.ILog(`Radarr URL: ${URL}`);
|
||||
Logger.ILog(`Triggering Path: ${exactImportPath}`);
|
||||
|
|
@ -70,12 +61,11 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) {
|
|||
Logger.ILog(movieId ? `movieId: ${movieId} → ManualImport` : 'No unambiguous movieId → fail closed');
|
||||
|
||||
if (!movieId) {
|
||||
Logger.ELog(`Unable to identify '${searchPatterns.join("' or '")}' uniquely; file remains for manual recovery.`);
|
||||
Logger.ELog(`Unable to identify '${searchPattern}' uniquely; file remains for manual recovery.`);
|
||||
return -1;
|
||||
}
|
||||
|
||||
const releaseMetadata = findGrabMetadata(searchPatterns, radarr, movieId);
|
||||
return manualImportWorkflow(radarr, exactImportPath, importMode, movieId, releaseMetadata, TimeOut);
|
||||
return manualImportWorkflow(radarr, exactImportPath, importMode, movieId, TimeOut);
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +80,7 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) {
|
|||
* @param {number} timeout - Timeout in milliseconds
|
||||
* @returns {number} 1 on success, −1 on failure
|
||||
*/
|
||||
function manualImportWorkflow(radarr, importPath, mode, movieId, releaseMetadata, timeout) {
|
||||
function manualImportWorkflow(radarr, importPath, mode, movieId, timeout) {
|
||||
const candidates = getManualImportCandidates(radarr, importPath);
|
||||
if (!candidates.length) {
|
||||
Logger.WLog('No candidates returned by fetching ManualImport');
|
||||
|
|
@ -108,7 +98,7 @@ function manualImportWorkflow(radarr, importPath, mode, movieId, releaseMetadata
|
|||
return -1;
|
||||
}
|
||||
|
||||
const fileObj = buildManualImportFile(cand, movieId, importPath, releaseMetadata);
|
||||
const fileObj = buildManualImportFile(cand, movieId, importPath);
|
||||
const cmdBody = { name: 'ManualImport', files: [fileObj], importMode: mode };
|
||||
const cmdId = sendManualImportCommand(radarr, cmdBody);
|
||||
if (cmdId === null) {
|
||||
|
|
@ -143,49 +133,21 @@ function getManualImportCandidates(radarr, importPath) {
|
|||
* @param {number} movieId – Radarr movieId to which the file is linked
|
||||
* @returns {object} File descriptor for ManualImport
|
||||
*/
|
||||
function buildManualImportFile(src, movieId, importPath, releaseMetadata) {
|
||||
function buildManualImportFile(src, movieId, importPath) {
|
||||
const path = src.path || importPath
|
||||
const fallbackFolder = System.IO.Path.GetFileName(System.IO.Path.GetDirectoryName(path)) ?? Variables.folder.Name;
|
||||
const metadata = releaseMetadata || {};
|
||||
const metadataData = metadata.data || {};
|
||||
|
||||
return {
|
||||
path: path,
|
||||
folderName: src.folderName || src.name || fallbackFolder,
|
||||
movieId: movieId,
|
||||
releaseGroup: metadataData.releaseGroup || metadata.releaseGroup || src.releaseGroup || 'Radarr',
|
||||
quality: metadata.quality || src.quality,
|
||||
languages: metadata.languages || src.languages || [ { id:0, name:'Unknown' } ],
|
||||
indexerFlags: Number(metadataData.indexerFlags ?? metadata.indexerFlags ?? src.indexerFlags ?? 0)
|
||||
releaseGroup: src.releaseGroup || 'Radarr',
|
||||
quality: src.quality,
|
||||
languages: src.languages || [ { id:0, name:'Unknown' } ],
|
||||
indexerFlags: src.indexerFlags ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
function findGrabMetadata(searchPatterns, radarr, movieId) {
|
||||
try {
|
||||
const query = buildQueryParams({
|
||||
page: 1, pageSize: 250, eventType: 1,
|
||||
sortKey: 'date', sortDirection: 'descending'
|
||||
});
|
||||
const response = radarr.fetchJson('history', query) || {};
|
||||
const records = Array.isArray(response) ? response : (response.records || []);
|
||||
const wanted = searchPatterns.map(normalizeReleaseTitle);
|
||||
const matching = records.filter(item =>
|
||||
item?.movieId === movieId
|
||||
&& wanted.includes(normalizeReleaseTitle(item.sourceTitle || ''))
|
||||
);
|
||||
if (!matching.length) return null;
|
||||
const movieIds = matching
|
||||
.map(item => item.movieId)
|
||||
.filter((id, index, values) => values.indexOf(id) === index);
|
||||
if (movieIds.length !== 1) return null;
|
||||
Logger.ILog(`Using metadata from exact Radarr grab history match (${matching[0].sourceTitle}).`);
|
||||
return matching[0];
|
||||
} catch (e) {
|
||||
Logger.WLog(`Unable to recover Radarr grab metadata: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Send the **ManualImport** command to Radarr and return command-id
|
||||
* @param {Radarr} radarr – Radarr API instance
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* @name Sonarr - Trigger Manual Import
|
||||
* @description Safely trigger Sonarr ManualImport for exactly one processed episode file.
|
||||
* @description Trigger Sonarr ManualImport or downloadedEpisodesScan with skip-guard.
|
||||
* @author FileFlows + Sascha
|
||||
* @revision 13
|
||||
* @revision 8
|
||||
* @output Import erfolgreich
|
||||
* @output Fehler
|
||||
* @param {string} URL Sonarr root URL and port
|
||||
|
|
@ -18,77 +18,61 @@ import { Sonarr } from 'Shared/Sonarr';
|
|||
function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) {
|
||||
URL = (URL || Variables['Sonarr.Url'] || Variables['Sonarr.URI']).replace(/\/+$/g, '');
|
||||
ApiKey = ApiKey || Variables['Sonarr.ApiKey'];
|
||||
const currentFile = Variables['file.FullName'] || Variables.file?.FullName || '';
|
||||
ImportPath = ImportPath || currentFile;
|
||||
ImportPath = ImportPath || Variables.file.FullName;
|
||||
TimeOut = TimeOut ? Math.min(TimeOut, 3600) * 1000 : 60000; // ms
|
||||
ImportPath = UseUnmappedPath ? Flow.UnMapPath(ImportPath) : ImportPath;
|
||||
const exactImportPath = UseUnmappedPath ? Flow.UnMapPath(currentFile) : currentFile;
|
||||
const importMode = MoveMode ? 'move' : 'copy';
|
||||
|
||||
if (!exactImportPath) {
|
||||
Logger.ELog('No exact current file path available; refusing broad Sonarr scan.');
|
||||
return -1;
|
||||
}
|
||||
|
||||
// ── SKIP-GUARD: Nur API-Call machen, wenn der aktuelle ImportPath
|
||||
// mit dem konfigurierten Pfad als vollständiges Pfadsegment
|
||||
// übereinstimmt. Ein nacktes Prefix würde z. B. /serien4k
|
||||
// fälschlich dem FHD-Root /serien zuordnen.
|
||||
const normalizedCurrentPath = normalizeImportPath(exactImportPath);
|
||||
const normalizedScopeRoot = normalizeImportPath(ImportPath);
|
||||
const isInScope = normalizedCurrentPath === normalizedScopeRoot
|
||||
|| normalizedCurrentPath.startsWith(normalizedScopeRoot + '/');
|
||||
if (!isInScope) {
|
||||
// mit dem konfigurierten Pfad uebereinstimmt. Sonst sofort OK.
|
||||
// Das verhindert 3 unnoetige API-Calls pro Datei (4 Arr-Instanzen).
|
||||
if (ImportPath && ImportPath.indexOf(URL) === -1) {
|
||||
var currentFile = Variables['file.FullName'] || '';
|
||||
if (currentFile && ImportPath && currentFile.indexOf(ImportPath) === -1) {
|
||||
Logger.ILog(`Skip: current file '${currentFile}' not under ImportPath '${ImportPath}' - not responsible`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const sonarr = new Sonarr(URL, ApiKey);
|
||||
|
||||
/*── seriesId / episodeId detection ──────────────────────────────────*/
|
||||
const filePattern = Variables.file.Orig.FileNameNoExtension;
|
||||
const folderPattern = System.IO.Path.GetFileName(System.IO.Path.GetDirectoryName(exactImportPath));
|
||||
const searchPatterns = [folderPattern, filePattern]
|
||||
.filter(Boolean)
|
||||
.filter((value, index, values) => values.indexOf(value) === index);
|
||||
const searchPattern = Variables.file.Orig.FileNameNoExtension;
|
||||
let seriesId = Variables['Sonarr.seriesId'] ?? Variables.TVShowInfo?.id ?? null;
|
||||
let episodeIds = Variables['Sonarr.episodeIds'] ?? Variables.TVShowInfo?.EpisodesInfo ?? null;
|
||||
|
||||
if (!(seriesId && episodeIds)) {
|
||||
let trackedEpisode = null;
|
||||
for (const pattern of searchPatterns) {
|
||||
trackedEpisode = findEpisodeFromQueue(pattern, sonarr, filePattern)
|
||||
?? findEpisodeFromGrabHistory(pattern, sonarr, filePattern);
|
||||
if (trackedEpisode) break;
|
||||
}
|
||||
if (!trackedEpisode) {
|
||||
// Ein Staffelpaket-Ordner ist absichtlich mehrdeutig. Zuerst
|
||||
// den exakten Episoden-Dateinamen parsen, danach erst den
|
||||
// Ordnernamen als letzten Fallback versuchen.
|
||||
const parsePatterns = [filePattern, folderPattern]
|
||||
.filter(Boolean)
|
||||
.filter((value, index, values) => values.indexOf(value) === index);
|
||||
for (const pattern of parsePatterns) {
|
||||
trackedEpisode = parseSeries(pattern, sonarr);
|
||||
if (trackedEpisode?.id && trackedEpisode?.episodeIds?.length) break;
|
||||
}
|
||||
}
|
||||
seriesId = trackedEpisode?.id ?? null;
|
||||
episodeIds = trackedEpisode?.episodeIds ?? null;
|
||||
}
|
||||
|
||||
Logger.ILog(`Sonarr URL: ${URL}`);
|
||||
Logger.ILog(`Triggering Path: ${exactImportPath}`);
|
||||
Logger.ILog(`Triggering Path: ${ImportPath}`);
|
||||
Logger.ILog(`Import Mode: ${importMode}`);
|
||||
|
||||
if (seriesId && episodeIds) {
|
||||
const releaseMetadata = findGrabMetadata(searchPatterns, sonarr, seriesId, episodeIds);
|
||||
Logger.ILog(`seriesId=${seriesId}, episodeIds=[${episodeIds.join(', ')}] → ManualImport`);
|
||||
return manualImportWorkflow(sonarr, exactImportPath, importMode, seriesId, episodeIds, releaseMetadata, TimeOut);
|
||||
} else {
|
||||
Logger.ILog('No seriesId/episodeIds, Trying parsing from File/Folder name');
|
||||
const parsedSeries = parseSeries(searchPattern, sonarr)
|
||||
seriesId = parsedSeries?.id ?? null
|
||||
episodeIds = parsedSeries?.episodeIds ?? null
|
||||
Logger.ILog(
|
||||
seriesId && episodeIds
|
||||
? `seriesId=${seriesId}, episodeIds=[${episodeIds.join(', ')}] → ManualImport`
|
||||
: 'No seriesId/episodeIds → downloadedEpisodesScan'
|
||||
);
|
||||
}
|
||||
|
||||
Logger.ELog(`Unable to identify '${searchPatterns.join("' or '")}' uniquely; file remains for manual recovery.`);
|
||||
return -1;
|
||||
|
||||
/*─ Execute first workflow, then fail-over if needed ─*/
|
||||
let result;
|
||||
if (seriesId && episodeIds) {
|
||||
result = manualImportWorkflow(sonarr, ImportPath, importMode, seriesId, episodeIds, TimeOut);
|
||||
if (result !== 1) {
|
||||
Logger.WLog('ManualImport failed, falling back to downloadedEpisodesScan.');
|
||||
result = scanWorkflow(sonarr, ImportPath, importMode, TimeOut);
|
||||
}
|
||||
} else {
|
||||
result = scanWorkflow(sonarr, ImportPath, importMode, TimeOut);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/*───────────────────────────── ManualImport ─────────────────────────────*/
|
||||
|
|
@ -103,25 +87,21 @@ function Script(URL, ApiKey, ImportPath, UseUnmappedPath, MoveMode, TimeOut) {
|
|||
* @param {number} timeout - Timeout in milliseconds
|
||||
* @returns {number} 1 on success, −1 on failure
|
||||
*/
|
||||
function manualImportWorkflow(sonarr, path, mode, seriesId, episodeIds, releaseMetadata, timeout) {
|
||||
function manualImportWorkflow(sonarr, path, mode, seriesId, episodeIds, timeout) {
|
||||
const candidates = getManualImportCandidates(sonarr, path);
|
||||
if (!candidates.length) {
|
||||
Logger.WLog('No candidates returned by fetching ManualImport');
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* accept only the candidate for the exact processed file */
|
||||
const exactPath = normalizeImportPath(path);
|
||||
const matchingCandidates = candidates.filter(c =>
|
||||
normalizeImportPath(c.path || '') === exactPath
|
||||
);
|
||||
const cand = matchingCandidates.find(c => c.quality && c.quality.quality);
|
||||
/* choose the first candidate that has quality info */
|
||||
const cand = candidates.find(c => c.quality && c.quality.quality);
|
||||
if (!cand) {
|
||||
Logger.WLog(`No ManualImport candidate matched exact path '${path}'.`);
|
||||
Logger.WLog('No ManualImport candidate contained quality information.');
|
||||
return -1;
|
||||
}
|
||||
|
||||
const fileObj = buildManualImportFile(cand, seriesId, episodeIds, path, releaseMetadata);
|
||||
const fileObj = buildManualImportFile(cand, seriesId, episodeIds, path);
|
||||
const cmdBody = { name: 'ManualImport', files: [fileObj], importMode: mode };
|
||||
const cmdId = sendManualImportCommand(sonarr, cmdBody);
|
||||
if (cmdId === null) {
|
||||
|
|
@ -160,52 +140,23 @@ function getManualImportCandidates(sonarr, importPath) {
|
|||
* @param {number} episodeIds – Sonarr episodeIds to which the file is linked
|
||||
* @returns {object} File descriptor for ManualImport
|
||||
*/
|
||||
function buildManualImportFile(src, seriesId, episodeIds, importPath, releaseMetadata) {
|
||||
function buildManualImportFile(src, seriesId, episodeIds, importPath) {
|
||||
const path = src.path || importPath;
|
||||
const fallbackFolder = System.IO.Path.GetFileName(System.IO.Path.GetDirectoryName(path)) ?? Variables.folder.Name;
|
||||
const metadata = releaseMetadata || {};
|
||||
const metadataData = metadata.data || {};
|
||||
|
||||
return {
|
||||
path: path,
|
||||
folderName: src.folderName || src.name || fallbackFolder,
|
||||
seriesId: seriesId,
|
||||
episodeIds: episodeIds,
|
||||
releaseGroup: metadataData.releaseGroup || metadata.releaseGroup || src.releaseGroup || 'Sonarr',
|
||||
quality: metadata.quality || src.quality,
|
||||
languages: metadata.languages || src.languages || [ { id:0, name:'Unknown' } ],
|
||||
indexerFlags: Number(metadataData.indexerFlags ?? metadata.indexerFlags ?? src.indexerFlags ?? 0),
|
||||
releaseType: metadataData.releaseType || metadata.releaseType || src.releaseType || 'unknown'
|
||||
releaseGroup: src.releaseGroup || 'Sonarr',
|
||||
quality: src.quality,
|
||||
languages: src.languages || [ { id:0, name:'Unknown' } ],
|
||||
indexerFlags: src.indexerFlags ?? 0,
|
||||
releaseType: src.releaseType ?? 'unknown'
|
||||
};
|
||||
}
|
||||
|
||||
function findGrabMetadata(searchPatterns, sonarr, seriesId, episodeIds) {
|
||||
try {
|
||||
const query = buildQueryParams({
|
||||
page: 1, pageSize: 250, eventType: 1,
|
||||
sortKey: 'date', sortDirection: 'descending'
|
||||
});
|
||||
const response = sonarr.fetchJson('history', query) || {};
|
||||
const records = Array.isArray(response) ? response : (response.records || []);
|
||||
const wanted = searchPatterns.map(normalizeReleaseTitle);
|
||||
const matching = records.filter(item =>
|
||||
item?.seriesId === seriesId
|
||||
&& episodeIds.includes(item?.episodeId)
|
||||
&& wanted.includes(normalizeReleaseTitle(item.sourceTitle || ''))
|
||||
);
|
||||
if (!matching.length) return null;
|
||||
const targetKeys = matching
|
||||
.map(item => `${item.seriesId}:${item.episodeId}`)
|
||||
.filter((key, index, values) => values.indexOf(key) === index);
|
||||
if (targetKeys.some(key => !episodeIds.includes(Number(key.split(':')[1])))) return null;
|
||||
Logger.ILog(`Using metadata from exact Sonarr grab history match (${matching[0].sourceTitle}).`);
|
||||
return matching[0];
|
||||
} catch (e) {
|
||||
Logger.WLog(`Unable to recover Sonarr grab metadata: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Send the **ManualImport** command to Sonarr and return command-id
|
||||
* @param {Sonarr} sonarr – Sonarr API instance
|
||||
|
|
@ -224,130 +175,47 @@ function sendManualImportCommand(sonarr, cmdBody) {
|
|||
}
|
||||
}
|
||||
|
||||
/*──────────────────────── downloadedEpisodesScan branch ─────────────────────*/
|
||||
|
||||
/**
|
||||
* @description Perform the downloadedEpisodesScan flow (when seriesId is absent).
|
||||
* @param {Sonarr} sonarr – Sonarr API instance
|
||||
* @param {string} importPath – Folder/File path for downloadedEpisodesScan
|
||||
* @param {string} mode – 'move' or 'copy'
|
||||
* @param {number} timeout – Timeout in milliseconds
|
||||
* @returns {number} 1 on success, −1 on failure
|
||||
*/
|
||||
function scanWorkflow(sonarr, importPath, mode, timeout) {
|
||||
const cmdId = sendDownloadedEpisodesScan(sonarr, importPath, mode);
|
||||
if (cmdId === null) {
|
||||
Logger.WLog('Faild sending downloadedEpisodesScan command');
|
||||
return -1;
|
||||
}
|
||||
|
||||
return waitForCommand(sonarr, cmdId, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Send the **downloadedEpisodesScan** command.
|
||||
* @param {Sonarr} sonarr - Sonarr API instance
|
||||
* @param {string} importPath - Folder/File path for the scan
|
||||
* @param {string} mode - 'move' or 'copy'
|
||||
* @returns {number|null} command id or null
|
||||
*/
|
||||
function sendDownloadedEpisodesScan(sonarr, importPath, mode) {
|
||||
try {
|
||||
const resp = sonarr.sendCommand('downloadedEpisodesScan', { path: importPath, importMode: mode });
|
||||
const cmdId = resp?.id;
|
||||
Logger.ILog(cmdId ? `downloadedEpisodesScan queued (cmdId=${cmdId}).` : 'downloadedEpisodesScan failed.');
|
||||
return cmdId ?? null;
|
||||
} catch (e) {
|
||||
Logger.ELog(`Send command downloadedEpisodesScan failed: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/*────────────────────────────── helpers ──────────────────────────────────*/
|
||||
|
||||
function findEpisodeFromQueue(searchPattern, sonarr, episodePattern) {
|
||||
if (!searchPattern) return null;
|
||||
try {
|
||||
const query = buildQueryParams({ page: 1, pageSize: 200, includeUnknownSeriesItems: true });
|
||||
const response = sonarr.fetchJson('queue', query) || {};
|
||||
const records = Array.isArray(response) ? response : (response.records || []);
|
||||
const wanted = normalizeReleaseTitle(searchPattern);
|
||||
const matches = records.filter(item =>
|
||||
item?.seriesId && item?.episodeId && normalizeReleaseTitle(item.title || '') === wanted
|
||||
);
|
||||
if (matches.length === 1) {
|
||||
Logger.ILog(`Matched Sonarr queue seriesId ${matches[0].seriesId}, episodeId ${matches[0].episodeId}.`);
|
||||
return { id: matches[0].seriesId, episodeIds: [matches[0].episodeId] };
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
const resolved = resolvePackEpisode(matches, episodePattern, sonarr);
|
||||
if (resolved) return resolved;
|
||||
Logger.WLog(`Ambiguous Sonarr queue match (${matches.length} records).`);
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
Logger.ELog(`Error fetching Sonarr queue: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findEpisodeFromGrabHistory(searchPattern, sonarr, episodePattern) {
|
||||
if (!searchPattern) return null;
|
||||
try {
|
||||
const query = buildQueryParams({
|
||||
page: 1, pageSize: 250, eventType: 1,
|
||||
sortKey: 'date', sortDirection: 'descending'
|
||||
});
|
||||
const response = sonarr.fetchJson('history', query) || {};
|
||||
const records = Array.isArray(response) ? response : (response.records || []);
|
||||
const wanted = normalizeReleaseTitle(searchPattern);
|
||||
const matches = records.filter(item =>
|
||||
item?.seriesId && item?.episodeId && normalizeReleaseTitle(item.sourceTitle || '') === wanted
|
||||
);
|
||||
const keys = matches
|
||||
.map(item => `${item.seriesId}:${item.episodeId}`)
|
||||
.filter((key, index, values) => values.indexOf(key) === index);
|
||||
if (keys.length === 1) {
|
||||
const [seriesId, episodeId] = keys[0].split(':').map(Number);
|
||||
Logger.ILog(`Matched Sonarr grab history seriesId ${seriesId}, episodeId ${episodeId}.`);
|
||||
return { id: seriesId, episodeIds: [episodeId] };
|
||||
}
|
||||
if (keys.length > 1) {
|
||||
const resolved = resolvePackEpisode(matches, episodePattern, sonarr);
|
||||
if (resolved) return resolved;
|
||||
Logger.WLog(`Ambiguous Sonarr grab history match (${keys.length} episodes).`);
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
Logger.ELog(`Error fetching Sonarr history: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePackEpisode(matches, episodePattern, sonarr) {
|
||||
const coordinates = extractEpisodeCoordinates(episodePattern);
|
||||
if (!coordinates) return null;
|
||||
|
||||
const seriesIds = matches
|
||||
.map(item => Number(item?.seriesId))
|
||||
.filter(Boolean)
|
||||
.filter((value, index, values) => values.indexOf(value) === index);
|
||||
if (seriesIds.length !== 1) {
|
||||
Logger.WLog('Tracked season-pack rows point to multiple series; refusing fallback parsing.');
|
||||
return { conflict: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const seriesId = seriesIds[0];
|
||||
const trackedSeries = sonarr.fetchJson(`series/${seriesId}`) || {};
|
||||
const cleanTitle = normalizeReleaseTitle(trackedSeries.cleanTitle || trackedSeries.title || '');
|
||||
const normalizedPattern = normalizeReleaseTitle(episodePattern);
|
||||
if (!cleanTitle || !normalizedPattern.startsWith(cleanTitle)) {
|
||||
Logger.WLog(
|
||||
`Tracked release title '${episodePattern}' does not match Sonarr series `
|
||||
+ `'${trackedSeries.title || seriesId}'; refusing fallback parsing.`
|
||||
);
|
||||
return { conflict: true };
|
||||
}
|
||||
|
||||
const query = buildQueryParams({ seriesId });
|
||||
const episodes = sonarr.fetchJson('episode', query) || [];
|
||||
const candidates = episodes.filter(item =>
|
||||
Number(item?.seasonNumber) === coordinates.seasonNumber
|
||||
&& Number(item?.episodeNumber) === coordinates.episodeNumber
|
||||
);
|
||||
if (candidates.length !== 1) return null;
|
||||
|
||||
const episodeId = Number(candidates[0]?.id);
|
||||
const trackedIds = matches.map(item => Number(item?.episodeId)).filter(Boolean);
|
||||
if (!episodeId || !trackedIds.includes(episodeId)) return null;
|
||||
|
||||
Logger.ILog(
|
||||
`Resolved season-pack match via exact filename S${coordinates.seasonNumber}`
|
||||
+ `E${coordinates.episodeNumber}: seriesId ${seriesId}, episodeId ${episodeId}.`
|
||||
);
|
||||
return { id: seriesId, episodeIds: [episodeId] };
|
||||
} catch (e) {
|
||||
Logger.WLog(`Unable to resolve season-pack episode: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractEpisodeCoordinates(value) {
|
||||
const match = String(value || '').match(/(?:^|[^a-z0-9])s(\d{1,2})e(\d{1,3})(?:[^a-z0-9]|$)/i);
|
||||
if (!match) return null;
|
||||
return { seasonNumber: Number(match[1]), episodeNumber: Number(match[2]) };
|
||||
}
|
||||
|
||||
function normalizeReleaseTitle(value) {
|
||||
return (value || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||
}
|
||||
|
||||
function normalizeImportPath(value) {
|
||||
return (value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Poll Sonarr until the command id completes or timeout is reached.
|
||||
* @param {Sonarr} sonarr - Sonarr API instance
|
||||
|
|
|
|||
|
|
@ -1,166 +0,0 @@
|
|||
"""Tests fuer cleanupSourceDir in 05_Finalize_Move.js.
|
||||
|
||||
Hintergrund (16.09.2026): Das Script loeschte nur die Original-Videodatei, nicht
|
||||
das Release-Verzeichnis. Dadurch blieben unter /tdarr/complete/ leere Ordner mit
|
||||
.nfo-Resten liegen. Die tauchten im Butler-Endpoint
|
||||
GET /media/handoff/diagnostics als "target_directories" auf und sahen wie
|
||||
haengende Jobs aus, obwohl der Import laengst durch war.
|
||||
|
||||
Diese Tests fahren das echte Script in einem Node-VM-Sandkasten und pruefen,
|
||||
dass rm -rf NUR bei leeren Release-Verzeichnissen unter SourceRoot laeuft.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "scripts" / "05_Finalize_Move.js"
|
||||
|
||||
NODE_HARNESS = r"""
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const scenario = JSON.parse(process.argv[2]);
|
||||
let source = fs.readFileSync(process.argv[3], 'utf8')
|
||||
.replace(/^\/\*\*[\s\S]*?\*\/\s*/, '');
|
||||
source += '\n;globalThis.__exports = { Script };';
|
||||
|
||||
const executed = [];
|
||||
|
||||
function fakeExecute(spec) {
|
||||
const cmd = spec.command;
|
||||
const args = spec.argumentList || [];
|
||||
executed.push({command: cmd, args: args});
|
||||
|
||||
if (cmd === '/usr/bin/find') {
|
||||
const dir = args[0];
|
||||
const hits = (scenario.mediaFiles || []).filter(f => f.startsWith(dir));
|
||||
return {exitCode: scenario.findExitCode === undefined ? 0 : scenario.findExitCode,
|
||||
standardOutput: hits.join('\n')};
|
||||
}
|
||||
if (cmd === '/usr/bin/test') {
|
||||
return {exitCode: 1}; // Ziel existiert nicht
|
||||
}
|
||||
if (cmd === '/bin/mv') {
|
||||
return {exitCode: scenario.mvExitCode === undefined ? 0 : scenario.mvExitCode};
|
||||
}
|
||||
if (cmd === '/bin/rm') {
|
||||
return {exitCode: scenario.rmExitCode === undefined ? 0 : scenario.rmExitCode};
|
||||
}
|
||||
if (cmd === '/bin/mkdir') {
|
||||
return {exitCode: 0};
|
||||
}
|
||||
if (String(cmd).indexOf('ffprobe') !== -1) {
|
||||
// keine Subtitle-Streams -> handleSubtitles steigt sofort aus
|
||||
return {exitCode: 0, standardOutput: JSON.stringify({streams: []})};
|
||||
}
|
||||
return {exitCode: 0, standardOutput: ''};
|
||||
}
|
||||
|
||||
const context = {
|
||||
Variables: {
|
||||
'file.Orig.FullName': scenario.origPath,
|
||||
'file.FullName': scenario.workingFile
|
||||
},
|
||||
Flow: {
|
||||
Execute: fakeExecute,
|
||||
SetWorkingFile() {},
|
||||
GetToolPath: name => '/usr/bin/' + name,
|
||||
TempPath: '/temp',
|
||||
NewGuid: () => 'guid-0000'
|
||||
},
|
||||
Logger: {ILog(){}, WLog(){}, ELog(){}, DLog(){}},
|
||||
JSON: JSON,
|
||||
console
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(source, context);
|
||||
|
||||
const rc = context.__exports.Script(
|
||||
scenario.sourceRoot || '/tdarr/complete/',
|
||||
scenario.destRoot || '/tdarr/converted/'
|
||||
);
|
||||
console.log(JSON.stringify({rc: rc, executed: executed}));
|
||||
"""
|
||||
|
||||
|
||||
def run_script(scenario):
|
||||
harness = Path("/tmp/ff_finalize_harness.js")
|
||||
harness.write_text(NODE_HARNESS)
|
||||
proc = subprocess.run(
|
||||
["node", str(harness), json.dumps(scenario), str(SCRIPT)],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise AssertionError(f"harness failed: {proc.stderr[-2000:]}")
|
||||
return json.loads(proc.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
def rm_rf_targets(result):
|
||||
return [e["args"][1] for e in result["executed"]
|
||||
if e["command"] == "/bin/rm" and e["args"][:1] == ["-rf"]]
|
||||
|
||||
|
||||
class CleanupSourceDirTests(unittest.TestCase):
|
||||
|
||||
BASE = {
|
||||
"origPath": "/tdarr/complete/serien4k/Slow.Horses.S06E01-W4K/slow.horses.s06e01-w4k.mkv",
|
||||
"workingFile": "/temp/Runner-1/slow.horses.s06e01-w4k.mkv",
|
||||
}
|
||||
|
||||
def test_leeres_release_verzeichnis_wird_entfernt(self):
|
||||
"""Nur .nfo uebrig -> Verzeichnis muss weg."""
|
||||
result = run_script({**self.BASE, "mediaFiles": []})
|
||||
self.assertEqual(result["rc"], 1)
|
||||
self.assertEqual(
|
||||
rm_rf_targets(result),
|
||||
["/tdarr/complete/serien4k/Slow.Horses.S06E01-W4K"],
|
||||
)
|
||||
|
||||
def test_verzeichnis_mit_restlicher_mediendatei_bleibt(self):
|
||||
"""Season-Pack: zweite Episode noch da -> nicht loeschen."""
|
||||
result = run_script({
|
||||
**self.BASE,
|
||||
"mediaFiles": [
|
||||
"/tdarr/complete/serien4k/Slow.Horses.S06E01-W4K/slow.horses.s06e02-w4k.mkv"
|
||||
],
|
||||
})
|
||||
self.assertEqual(result["rc"], 1)
|
||||
self.assertEqual(rm_rf_targets(result), [])
|
||||
|
||||
def test_find_fehler_laesst_verzeichnis_stehen(self):
|
||||
"""find schlaegt fehl -> fail safe, nichts loeschen."""
|
||||
result = run_script({**self.BASE, "mediaFiles": [], "findExitCode": 1})
|
||||
self.assertEqual(result["rc"], 1)
|
||||
self.assertEqual(rm_rf_targets(result), [])
|
||||
|
||||
def test_sourceroot_selbst_wird_nie_geloescht(self):
|
||||
"""Datei direkt in SourceRoot -> rm -rf /tdarr/complete darf NICHT passieren."""
|
||||
result = run_script({
|
||||
"origPath": "/tdarr/complete/lose.datei.mkv",
|
||||
"workingFile": "/temp/Runner-1/lose.datei.mkv",
|
||||
"mediaFiles": [],
|
||||
})
|
||||
self.assertEqual(rm_rf_targets(result), [])
|
||||
|
||||
def test_pfad_ausserhalb_sourceroot_wird_abgelehnt(self):
|
||||
"""Original nicht unter SourceRoot -> Script bricht mit 2 ab, kein rm -rf."""
|
||||
result = run_script({
|
||||
"origPath": "/mnt/anderswo/film/film.mkv",
|
||||
"workingFile": "/temp/Runner-1/film.mkv",
|
||||
"mediaFiles": [],
|
||||
})
|
||||
self.assertEqual(result["rc"], 2)
|
||||
self.assertEqual(rm_rf_targets(result), [])
|
||||
|
||||
def test_originaldatei_wird_weiterhin_geloescht(self):
|
||||
"""Regression: rm -f auf das Original bleibt erhalten."""
|
||||
result = run_script({**self.BASE, "mediaFiles": []})
|
||||
rm_f = [e["args"][1] for e in result["executed"]
|
||||
if e["command"] == "/bin/rm" and e["args"][:1] == ["-f"]]
|
||||
self.assertIn(self.BASE["origPath"], rm_f)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FLOW = Path(__file__).parents[1] / "flow" / "flow_auto_movie_pipeline.json"
|
||||
|
||||
|
||||
class FlowImportFailureRoutingTests(unittest.TestCase):
|
||||
def test_arr_import_failures_route_to_fail_flow(self):
|
||||
flow = json.loads(FLOW.read_text())
|
||||
fail_node = next(
|
||||
part
|
||||
for part in flow["Parts"]
|
||||
if part.get("FlowElementUid") == "FileFlows.BasicNodes.Functions.FailFlow"
|
||||
)
|
||||
import_nodes = [
|
||||
part
|
||||
for part in flow["Parts"]
|
||||
if part.get("Name")
|
||||
in {"Sonarr - Trigger Manual Import", "Radarr - Trigger Manual Import"}
|
||||
]
|
||||
|
||||
self.assertEqual(len(import_nodes), 4)
|
||||
for node in import_nodes:
|
||||
with self.subTest(node=node["Uid"]):
|
||||
self.assertEqual(node["ErrorConnection"]["InputNode"], fail_node["Uid"])
|
||||
|
||||
def test_fhd_radarr_import_timeout_handles_large_nas_copy(self):
|
||||
flow = json.loads(FLOW.read_text())
|
||||
fhd_radarr = next(
|
||||
part
|
||||
for part in flow["Parts"]
|
||||
if part.get("Name") == "Radarr - Trigger Manual Import"
|
||||
and part.get("Model", {}).get("ImportPath") == "/tdarr/converted/video/"
|
||||
)
|
||||
self.assertGreaterEqual(fhd_radarr["Model"]["TimeOut"], 900)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -193,76 +193,6 @@ class RadarrImportSafetyTests(unittest.TestCase):
|
|||
)
|
||||
self.assertIn("eventType=1", history_fetch["query"])
|
||||
|
||||
def test_grab_metadata_overrides_unknown_manualimport_metadata(self):
|
||||
release = "American.Sweatshop.2025.German.DTSHD.DL.1080p.BluRay.AVC.Remux-MAMA"
|
||||
exact = f"/tdarr/converted/video/{release}/{release}.mkv"
|
||||
grabbed_quality = {"quality": {"id": 30, "name": "Remux-1080p"}}
|
||||
grabbed_languages = [
|
||||
{"id": 1, "name": "English"},
|
||||
{"id": 4, "name": "German"},
|
||||
]
|
||||
result = run_script(
|
||||
{
|
||||
"releaseTitle": release,
|
||||
"exactPath": exact,
|
||||
"scopeRoot": "/tdarr/converted/video/",
|
||||
"queueResponse": {"records": [{"title": release, "movieId": 6425}]},
|
||||
"historyResponse": {
|
||||
"records": [
|
||||
{
|
||||
"sourceTitle": release,
|
||||
"movieId": 6425,
|
||||
"eventType": "grabbed",
|
||||
"quality": grabbed_quality,
|
||||
"languages": grabbed_languages,
|
||||
"data": {"releaseGroup": "MAMA", "indexerFlags": "4"},
|
||||
}
|
||||
]
|
||||
},
|
||||
"candidates": [
|
||||
{
|
||||
"path": exact,
|
||||
"releaseGroup": "Radarr",
|
||||
"quality": {"quality": {"id": 0, "name": "Unknown"}},
|
||||
"languages": [{"id": 0, "name": "Unknown"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result["result"], 1)
|
||||
command = next(call for call in result["calls"] if call["type"] == "command")
|
||||
imported = command["body"]["files"][0]
|
||||
self.assertEqual(imported["quality"], grabbed_quality)
|
||||
self.assertEqual(imported["languages"], grabbed_languages)
|
||||
self.assertEqual(imported["releaseGroup"], "MAMA")
|
||||
self.assertEqual(imported["indexerFlags"], 4)
|
||||
|
||||
def test_release_folder_matches_when_movie_filename_is_shortened(self):
|
||||
release = "Example.Movie.2026.German.DL.1080p.WEB.H264-GROUP"
|
||||
exact = f"/tdarr/converted/video/{release}/movie-final.mkv"
|
||||
result = run_script(
|
||||
{
|
||||
"releaseTitle": "movie-final",
|
||||
"exactPath": exact,
|
||||
"scopeRoot": "/tdarr/converted/video/",
|
||||
"queueResponse": {"records": [{"title": release, "movieId": 7331}]},
|
||||
"historyResponse": {"records": []},
|
||||
"parseResponse": {},
|
||||
"candidates": [
|
||||
{
|
||||
"path": exact,
|
||||
"quality": {"quality": {"id": 3, "name": "WEBDL-1080p"}},
|
||||
"languages": [{"id": 4, "name": "German"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result["result"], 1)
|
||||
command = next(call for call in result["calls"] if call["type"] == "command")
|
||||
self.assertEqual(command["body"]["files"][0]["movieId"], 7331)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -1,391 +0,0 @@
|
|||
import json
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "scripts" / "Sonarr_-_Trigger_Manual_Import.js"
|
||||
|
||||
NODE_HARNESS = r"""
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const path = require('path').posix;
|
||||
const scenario = JSON.parse(process.argv[1]);
|
||||
let source = fs.readFileSync(process.argv[2], 'utf8')
|
||||
.replace(/^\/\*\*[\s\S]*?\*\/\s*/, '')
|
||||
.replace(/^import .*$/m, '');
|
||||
source += '\n;globalThis.__exports = { Script };';
|
||||
const calls = [];
|
||||
class FakeSonarr {
|
||||
constructor(url, apiKey) { this.url = url; this.apiKey = apiKey; }
|
||||
fetchJson(endpoint, query) {
|
||||
calls.push({type: 'fetch', endpoint, query});
|
||||
if (endpoint === 'queue') return scenario.queueResponse || {records: []};
|
||||
if (endpoint === 'history') return scenario.historyResponse || {records: []};
|
||||
if (endpoint.startsWith('series/')) return scenario.seriesResponse || {};
|
||||
if (endpoint === 'episode') return scenario.episodeResponse || [];
|
||||
if (endpoint === 'parse') return scenario.parseResponse || {};
|
||||
if (endpoint === 'manualimport') return scenario.candidates || [];
|
||||
return null;
|
||||
}
|
||||
sendCommand(name, body) {
|
||||
calls.push({type: 'command', name, body});
|
||||
return {id: 42};
|
||||
}
|
||||
waitForCompletion(id, timeout) {
|
||||
calls.push({type: 'wait', id, timeout});
|
||||
return scenario.waitSuccess !== false;
|
||||
}
|
||||
}
|
||||
const exactPath = scenario.exactPath;
|
||||
const context = {
|
||||
Sonarr: FakeSonarr,
|
||||
Variables: {
|
||||
'file.FullName': exactPath,
|
||||
file: {
|
||||
FullName: exactPath,
|
||||
Orig: {FileNameNoExtension: scenario.releaseTitle}
|
||||
},
|
||||
folder: {Name: path.basename(path.dirname(exactPath))}
|
||||
},
|
||||
Flow: {UnMapPath: value => value},
|
||||
Logger: {ILog(){}, WLog(){}, ELog(){}, DLog(){}},
|
||||
System: {IO: {Path: {GetFileName: path.basename, GetDirectoryName: path.dirname}}},
|
||||
encodeURIComponent,
|
||||
console
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(source, context);
|
||||
const result = context.__exports.Script(
|
||||
'http://sonarr-fhd:8990',
|
||||
'test-key',
|
||||
scenario.scopeRoot,
|
||||
false,
|
||||
true,
|
||||
300
|
||||
);
|
||||
process.stdout.write(JSON.stringify({result, calls}));
|
||||
"""
|
||||
|
||||
|
||||
def run_script(scenario):
|
||||
completed = subprocess.run(
|
||||
["node", "-e", NODE_HARNESS, json.dumps(scenario), str(SCRIPT)],
|
||||
check=True,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
class SonarrImportSafetyTests(unittest.TestCase):
|
||||
def test_pack_with_ambiguous_queue_resolves_exact_episode_from_filename(self):
|
||||
pack = "Lucky.S01.Complete.2026.GERMAN.aac.720p.WEB.h265-FritzBox"
|
||||
episode = "Lucky.S01E05.German.Title.2026.GERMAN.aac.720p.WEB.h265-FritzBox"
|
||||
exact = f"/tdarr/converted/serien/{pack}/{episode}.mp4"
|
||||
queue_records = [
|
||||
{"title": pack, "seriesId": 794, "episodeId": 64000 + number}
|
||||
for number in range(1, 8)
|
||||
]
|
||||
history_records = [
|
||||
{
|
||||
"sourceTitle": pack,
|
||||
"seriesId": 794,
|
||||
"episodeId": 64000 + number,
|
||||
"eventType": "grabbed",
|
||||
"quality": {"quality": {"id": 6, "name": "WEBDL-720p"}},
|
||||
"languages": [{"id": 4, "name": "German"}],
|
||||
"data": {"releaseGroup": "FritzBox", "releaseType": "SeasonPack"},
|
||||
}
|
||||
for number in range(1, 8)
|
||||
]
|
||||
result = run_script(
|
||||
{
|
||||
"releaseTitle": episode,
|
||||
"exactPath": exact,
|
||||
"scopeRoot": "/tdarr/converted/serien",
|
||||
"queueResponse": {"records": queue_records},
|
||||
"historyResponse": {"records": history_records},
|
||||
"seriesResponse": {"id": 794, "title": "Lucky", "cleanTitle": "lucky"},
|
||||
"episodeResponse": [
|
||||
{"id": 64000 + number, "seasonNumber": 1, "episodeNumber": number}
|
||||
for number in range(1, 8)
|
||||
],
|
||||
"parseResponse": {},
|
||||
"candidates": [
|
||||
{
|
||||
"path": exact,
|
||||
"quality": {"quality": {"id": 0, "name": "Unknown"}},
|
||||
"languages": [{"id": 0, "name": "Unknown"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result["result"], 1)
|
||||
command = next(call for call in result["calls"] if call["type"] == "command")
|
||||
imported = command["body"]["files"][0]
|
||||
self.assertEqual(imported["seriesId"], 794)
|
||||
self.assertEqual(imported["episodeIds"], [64005])
|
||||
self.assertEqual(imported["releaseGroup"], "FritzBox")
|
||||
|
||||
def test_pack_resolution_fails_closed_when_tracked_series_title_differs(self):
|
||||
pack = "Lucky.S01.Complete.2026.GERMAN.aac.720p.WEB.h265-FritzBox"
|
||||
episode = "Lucky.S01E05.Are.We.Bad.People.2026.GERMAN.aac.720p.WEB.h265-FritzBox"
|
||||
exact = f"/tdarr/converted/serien/{pack}/{episode}.mp4"
|
||||
result = run_script(
|
||||
{
|
||||
"releaseTitle": episode,
|
||||
"exactPath": exact,
|
||||
"scopeRoot": "/tdarr/converted/serien",
|
||||
"queueResponse": {
|
||||
"records": [
|
||||
{"title": pack, "seriesId": 794, "episodeId": 64000 + number}
|
||||
for number in range(1, 8)
|
||||
]
|
||||
},
|
||||
"historyResponse": {"records": []},
|
||||
"seriesResponse": {"id": 794, "title": "Dutton Ranch", "cleanTitle": "duttonranch"},
|
||||
"episodeResponse": [
|
||||
{"id": 64005, "seasonNumber": 1, "episodeNumber": 5}
|
||||
],
|
||||
"parseResponse": {
|
||||
"series": {"id": 821, "title": "Lucky"},
|
||||
"episodes": [{"id": 61648}],
|
||||
},
|
||||
"candidates": [{"path": exact, "quality": {"quality": {"id": 6}}}],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result["result"], -1)
|
||||
self.assertFalse(any(call["type"] == "command" for call in result["calls"]))
|
||||
self.assertFalse(any(call.get("endpoint") == "parse" for call in result["calls"]))
|
||||
|
||||
def test_pack_resolution_fails_closed_when_series_ids_differ(self):
|
||||
pack = "Lucky.S01.Complete.2026.GERMAN.aac.720p.WEB.h265-FritzBox"
|
||||
episode = "Lucky.S01E05.German.Title.2026.GERMAN.aac.720p.WEB.h265-FritzBox"
|
||||
exact = f"/tdarr/converted/serien/{pack}/{episode}.mp4"
|
||||
result = run_script(
|
||||
{
|
||||
"releaseTitle": episode,
|
||||
"exactPath": exact,
|
||||
"scopeRoot": "/tdarr/converted/serien",
|
||||
"queueResponse": {
|
||||
"records": [
|
||||
{"title": pack, "seriesId": 794, "episodeId": 64005},
|
||||
{"title": pack, "seriesId": 999, "episodeId": 99005},
|
||||
]
|
||||
},
|
||||
"historyResponse": {"records": []},
|
||||
"episodeResponse": [
|
||||
{"id": 64005, "seasonNumber": 1, "episodeNumber": 5}
|
||||
],
|
||||
"parseResponse": {},
|
||||
"candidates": [],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result["result"], -1)
|
||||
self.assertFalse(any(call["type"] == "command" for call in result["calls"]))
|
||||
|
||||
def test_fhd_scope_does_not_match_uhd_sibling_prefix(self):
|
||||
exact = (
|
||||
"/tdarr/converted/serien4k/Marshals.2026.S01.GERMAN.DUBBED/"
|
||||
"Marshals.2026.S01E05.GERMAN.DUBBED.mkv"
|
||||
)
|
||||
result = run_script(
|
||||
{
|
||||
"releaseTitle": "Marshals.2026.S01E05.GERMAN.DUBBED",
|
||||
"exactPath": exact,
|
||||
"scopeRoot": "/tdarr/converted/serien",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result["result"], 1)
|
||||
self.assertEqual(result["calls"], [])
|
||||
|
||||
def test_season_pack_history_uses_exact_episode_filename_for_parse(self):
|
||||
pack = "Marshals.2026.S01.GERMAN.DUBBED.DL.2160p.WEB.H265-TSCC"
|
||||
episode = "Marshals.2026.S01E05.GERMAN.DUBBED.DL.2160p.WEB.H265-TSCC"
|
||||
exact = f"/tdarr/converted/serien4k/{pack}/{episode}.mkv"
|
||||
queue_records = [
|
||||
{"title": pack, "seriesId": 600, "episodeId": 37500 + number}
|
||||
for number in range(1, 14)
|
||||
]
|
||||
history_records = [
|
||||
{
|
||||
"sourceTitle": pack,
|
||||
"seriesId": 600,
|
||||
"episodeId": 37500 + number,
|
||||
"eventType": "grabbed",
|
||||
}
|
||||
for number in range(1, 14)
|
||||
]
|
||||
result = run_script(
|
||||
{
|
||||
"releaseTitle": episode,
|
||||
"exactPath": exact,
|
||||
"scopeRoot": "/tdarr/converted/serien4k",
|
||||
"queueResponse": {"records": queue_records},
|
||||
"historyResponse": {"records": history_records},
|
||||
"seriesResponse": {"id": 600, "title": "Marshals", "cleanTitle": "marshals"},
|
||||
"parseResponse": {
|
||||
"series": {"id": 600, "title": "Marshals"},
|
||||
"episodes": [{"id": 37505}],
|
||||
},
|
||||
"candidates": [
|
||||
{
|
||||
"path": exact,
|
||||
"quality": {"quality": {"id": 19, "name": "WEBDL-2160p"}},
|
||||
"languages": [{"id": 4, "name": "German"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result["result"], 1)
|
||||
command = next(call for call in result["calls"] if call["type"] == "command")
|
||||
self.assertEqual(command["body"]["files"][0]["episodeIds"], [37505])
|
||||
parse_call = next(
|
||||
call for call in result["calls"]
|
||||
if call["type"] == "fetch" and call["endpoint"] == "parse"
|
||||
)
|
||||
self.assertIn("s01e05", parse_call["query"].lower())
|
||||
|
||||
def test_percent_title_matches_queue_and_imports_exact_episode_file(self):
|
||||
source_title = (
|
||||
"100%.Wolf.S01E01.Die.Aufnahmeprufung.German.1080p."
|
||||
"RTLP.WEB-DL.AAC2.0.H.264-GLOTZE"
|
||||
)
|
||||
queue_title = source_title.replace("100%.", "100.")
|
||||
exact = f"/tdarr/converted/serien/{queue_title}/{source_title}.mkv"
|
||||
result = run_script(
|
||||
{
|
||||
"releaseTitle": source_title,
|
||||
"exactPath": exact,
|
||||
"scopeRoot": "/tdarr/converted/serien",
|
||||
"queueResponse": {
|
||||
"records": [
|
||||
{"title": queue_title, "seriesId": 586, "episodeId": 42301}
|
||||
]
|
||||
},
|
||||
"parseResponse": {},
|
||||
"candidates": [
|
||||
{
|
||||
"path": exact,
|
||||
"releaseGroup": "GLOTZE",
|
||||
"quality": {"quality": {"id": 3, "name": "WEBDL-1080p"}},
|
||||
"languages": [{"id": 4, "name": "German"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result["result"], 1)
|
||||
commands = [call for call in result["calls"] if call["type"] == "command"]
|
||||
self.assertEqual(len(commands), 1)
|
||||
self.assertEqual(commands[0]["name"], "ManualImport")
|
||||
imported = commands[0]["body"]["files"][0]
|
||||
self.assertEqual(imported["path"], exact)
|
||||
self.assertEqual(imported["seriesId"], 586)
|
||||
self.assertEqual(imported["episodeIds"], [42301])
|
||||
self.assertNotIn(
|
||||
"downloadedEpisodesScan",
|
||||
[call.get("name") for call in commands],
|
||||
)
|
||||
|
||||
def test_release_folder_matches_when_episode_filename_is_scene_shortened(self):
|
||||
release = (
|
||||
"Angie.Tribeca.S01E06.Frettchen.Royale.GERMAN.DL."
|
||||
"1080p.HDTV.x264-SYNCTV"
|
||||
)
|
||||
exact = f"/tdarr/converted/serien/{release}/synctv-angie.s01e06.1080p.mkv"
|
||||
result = run_script(
|
||||
{
|
||||
"releaseTitle": "synctv-angie.s01e06.1080p",
|
||||
"exactPath": exact,
|
||||
"scopeRoot": "/tdarr/converted/serien",
|
||||
"queueResponse": {
|
||||
"records": [
|
||||
{"title": release, "seriesId": 853, "episodeId": 63336}
|
||||
]
|
||||
},
|
||||
"historyResponse": {"records": []},
|
||||
"parseResponse": {},
|
||||
"candidates": [
|
||||
{
|
||||
"path": exact,
|
||||
"quality": {"quality": {"id": 4, "name": "HDTV-1080p"}},
|
||||
"languages": [{"id": 4, "name": "German"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result["result"], 1)
|
||||
command = next(call for call in result["calls"] if call["type"] == "command")
|
||||
imported = command["body"]["files"][0]
|
||||
self.assertEqual(imported["seriesId"], 853)
|
||||
self.assertEqual(imported["episodeIds"], [63336])
|
||||
|
||||
def test_grab_metadata_overrides_unknown_manualimport_metadata(self):
|
||||
release = (
|
||||
"The.Night.Agent.2025.S02E04.Desperate.Measures.2160p.NF."
|
||||
"WEB-DL.MULTi.DDP5.1.Atmos.H.265-FUZEER"
|
||||
)
|
||||
exact = f"/tdarr/converted/serien4k/{release}/{release}.mkv"
|
||||
grabbed_quality = {"quality": {"id": 18, "name": "WEBDL-2160p"}}
|
||||
grabbed_languages = [
|
||||
{"id": 1, "name": "English"},
|
||||
{"id": 4, "name": "German"},
|
||||
]
|
||||
result = run_script(
|
||||
{
|
||||
"releaseTitle": release,
|
||||
"exactPath": exact,
|
||||
"scopeRoot": "/tdarr/converted/serien4k",
|
||||
"queueResponse": {
|
||||
"records": [
|
||||
{"title": release, "seriesId": 602, "episodeId": 37528}
|
||||
]
|
||||
},
|
||||
"historyResponse": {
|
||||
"records": [
|
||||
{
|
||||
"sourceTitle": release,
|
||||
"seriesId": 602,
|
||||
"episodeId": 37528,
|
||||
"eventType": "grabbed",
|
||||
"quality": grabbed_quality,
|
||||
"languages": grabbed_languages,
|
||||
"data": {
|
||||
"releaseGroup": "FUZEER",
|
||||
"indexerFlags": "8",
|
||||
"releaseType": "SingleEpisode",
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
"candidates": [
|
||||
{
|
||||
"path": exact,
|
||||
"releaseGroup": "Sonarr",
|
||||
"quality": {"quality": {"id": 0, "name": "Unknown"}},
|
||||
"languages": [{"id": 0, "name": "Unknown"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result["result"], 1)
|
||||
command = next(call for call in result["calls"] if call["type"] == "command")
|
||||
imported = command["body"]["files"][0]
|
||||
self.assertEqual(imported["quality"], grabbed_quality)
|
||||
self.assertEqual(imported["languages"], grabbed_languages)
|
||||
self.assertEqual(imported["releaseGroup"], "FUZEER")
|
||||
self.assertEqual(imported["indexerFlags"], 8)
|
||||
self.assertEqual(imported["releaseType"], "SingleEpisode")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue