Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a405c5ed35 | ||
|
|
4a33898a6c | ||
|
|
21c98bd485 | ||
|
|
a651c81dfb | ||
|
|
0951d909c9 | ||
|
|
69effb542d | ||
|
|
e9314ea1ee | ||
|
|
8889a4ceff | ||
|
|
c21c48e61e |
No files matched your search
@@ -735,7 +735,12 @@ linked runbooks.
|
|||||||
**Ops**
|
**Ops**
|
||||||
|
|
||||||
- Never `pkill -f` a pattern that appears in your own command line — it kills your ssh session.
|
- Never `pkill -f` a pattern that appears in your own command line — it kills your ssh session.
|
||||||
Use `pkill -x <exact-name>`.
|
Use `pkill -x <exact-name>`, or the bracket trick — `pkill -f "serve[-]script.sh"` —
|
||||||
|
when the pattern has to be named: the bracket makes the pattern stop matching the
|
||||||
|
command line that carries it. Quote it: a zsh remote glob-expands an unquoted `[s]`
|
||||||
|
and the kill silently never runs. Re-hit 2026-11-02 —
|
||||||
|
`ssh host 'pkill -f jfshowswatch.py; …'` killed the remote shell mid-command, and
|
||||||
|
ssh exits **255 with zero output**, which reads exactly like a network fault.
|
||||||
- Both hosts allow passwordless sudo for the deploy user. Use plain `sudo` in committed scripts
|
- Both hosts allow passwordless sudo for the deploy user. Use plain `sudo` in committed scripts
|
||||||
(no `echo pw | sudo -S`); `FRANK_SUDO_PW` covers hosts without it.
|
(no `echo pw | sudo -S`); `FRANK_SUDO_PW` covers hosts without it.
|
||||||
- **ComfyUI on Dankclaw runs with GPU access** (`/dev/kfd` + `/dev/dri` via `AddDevice`,
|
- **ComfyUI on Dankclaw runs with GPU access** (`/dev/kfd` + `/dev/dri` via `AddDevice`,
|
||||||
|
|||||||
@@ -135,7 +135,18 @@ text = escape(val)
|
|||||||
if pat.search(src):
|
if pat.search(src):
|
||||||
out = pat.sub(lambda m: f"{m.group(1)}<{elem}>{text}</{elem}>", src, count=1)
|
out = pat.sub(lambda m: f"{m.group(1)}<{elem}>{text}</{elem}>", src, count=1)
|
||||||
else:
|
else:
|
||||||
out = src.replace("</Configuration>", f" <{elem}>{text}</{elem}>\n</Configuration>", 1)
|
# The plugin owns this file and writes it under ITS OWN root element
|
||||||
|
# (TheTVDB's is <PluginConfiguration>), and its serializer omits a
|
||||||
|
# null-valued setting entirely — so the element being filled can be
|
||||||
|
# absent outright, and a hardcoded </Configuration> anchor then matches
|
||||||
|
# nothing. Anchor on the document's real root instead.
|
||||||
|
out = src
|
||||||
|
root = re.search(r"<([A-Za-z_][\w.:-]*)[^>]*>", src)
|
||||||
|
if root and ("</%s>" % root.group(1)) in src:
|
||||||
|
name = root.group(1)
|
||||||
|
out = src.replace(
|
||||||
|
"</%s>" % name, f" <{elem}>{text}</{elem}>\n</{name}>", 1
|
||||||
|
)
|
||||||
if out == src:
|
if out == src:
|
||||||
raise SystemExit(f"{xml_path}: no place to write <{elem}>")
|
raise SystemExit(f"{xml_path}: no place to write <{elem}>")
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -121,14 +121,14 @@ class Journal:
|
|||||||
|
|
||||||
def read_plan(path, root, force):
|
def read_plan(path, root, force):
|
||||||
"""Plan rows: (mkdirs, keeps, moves), header dropped, all paths under root."""
|
"""Plan rows: (mkdirs, keeps, moves), header dropped, all paths under root."""
|
||||||
mkdirs, keeps, moves = [], [], []
|
mkdirs, keeps, moves, bad = [], [], [], []
|
||||||
with open(path) as fh:
|
with open(path) as fh:
|
||||||
header = fh.readline()
|
header = fh.readline()
|
||||||
if not header.startswith("action\t"):
|
if not header.startswith("action\t"):
|
||||||
raise Refuse(f"{path} is not a plan (first column is "
|
raise Refuse(f"{path} is not a plan (first column is "
|
||||||
f"{header.split(chr(9))[0]!r}, expected 'action') —"
|
f"{header.split(chr(9))[0]!r}, expected 'action') —"
|
||||||
" regenerate it with scripts/media-plan.py")
|
" regenerate it with scripts/media-plan.py")
|
||||||
for line in fh:
|
for n, line in enumerate(fh, 2):
|
||||||
f = line.rstrip("\n").split("\t")
|
f = line.rstrip("\n").split("\t")
|
||||||
if f[0] == "mkdir":
|
if f[0] == "mkdir":
|
||||||
mkdirs.append(f[2])
|
mkdirs.append(f[2])
|
||||||
@@ -136,6 +136,14 @@ def read_plan(path, root, force):
|
|||||||
keeps.append(f[1])
|
keeps.append(f[1])
|
||||||
elif f[0] == "move":
|
elif f[0] == "move":
|
||||||
moves.append((f[1], f[2], f[3] if len(f) > 3 else ""))
|
moves.append((f[1], f[2], f[3] if len(f) > 3 else ""))
|
||||||
|
else:
|
||||||
|
bad.append(f"line {n}: unknown action {f[0]!r} — this tool"
|
||||||
|
" knows mkdir, keep and move (a rename is a move)")
|
||||||
|
if bad:
|
||||||
|
# A row this tool could not read is a plan bug, and a plan bug refuses:
|
||||||
|
# silently dropping it turns a typo into a no-op run that reports success.
|
||||||
|
head = "; ".join(bad[:3])
|
||||||
|
raise Refuse(head + (f" (+{len(bad) - 3} more)" if len(bad) > 3 else ""))
|
||||||
if not force:
|
if not force:
|
||||||
for p in mkdirs + keeps + [p for m in moves for p in m[:2]]:
|
for p in mkdirs + keeps + [p for m in moves for p in m[:2]]:
|
||||||
if p and not (p == root or p.startswith(root + os.sep)):
|
if p and not (p == root or p.startswith(root + os.sep)):
|
||||||
|
|||||||
@@ -89,13 +89,16 @@ looks exactly like an unconfigured library.
|
|||||||
| Library | `CollectionType` | `EnableInternetProviders` | `EnableRealtimeMonitor` |
|
| Library | `CollectionType` | `EnableInternetProviders` | `EnableRealtimeMonitor` |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `Movies` | movies | true (fixed 2026-11-02) | **false** |
|
| `Movies` | movies | true (fixed 2026-11-02) | **false** |
|
||||||
| `Shows` | tvshows | **false** | true |
|
| `Shows` | tvshows | true (fixed 2026-11-02) | true |
|
||||||
| `Music` | music | **false** | true |
|
| `Music` | music | **false** | true |
|
||||||
| `Collections` | boxsets | **false** | false |
|
| `Collections` | boxsets | **false** | false |
|
||||||
|
|
||||||
`Shows` and `Music` are silent file indexes in the sense above: a refresh answers
|
`Music` and `Collections` are silent file indexes in the sense above: a refresh answers
|
||||||
`204`, stores nothing, logs nothing. Any metadata or artwork work on either one
|
`204`, stores nothing, logs nothing. Any metadata or artwork work on either one
|
||||||
starts by flipping the switch, never by firing a scan.
|
starts by flipping the switch, never by firing a scan. `Shows` sat in that set
|
||||||
|
until 2026-11-02; the door is `POST /Library/VirtualFolders/LibraryOptions` with
|
||||||
|
the library's own `ItemId` (below), and `scripts/jellyfin-providers.sh verify` is
|
||||||
|
the one command that says which libraries are still in it.
|
||||||
|
|
||||||
**The per-library option door on 12.x is `POST /Library/VirtualFolders/LibraryOptions`**
|
**The per-library option door on 12.x is `POST /Library/VirtualFolders/LibraryOptions`**
|
||||||
— `GET` there answers `405`, which is how the route announces itself. The 10.x doors
|
— `GET` there answers `405`, which is how the route announces itself. The 10.x doors
|
||||||
@@ -119,6 +122,33 @@ store on a build where the automatic image pass will not run at all — it is ho
|
|||||||
the Movies library got its art: 2,515 of 2,564 films carry a primary now, and the
|
the Movies library got its art: 2,515 of 2,564 films carry a primary now, and the
|
||||||
rest have none in TMDb (shorts like `Astartes`, files whose names match nothing).
|
rest have none in TMDb (shorts like `Astartes`, files whose names match nothing).
|
||||||
|
|
||||||
|
**The Download door's exact wire shape:** the list response carries the address in
|
||||||
|
`Url` — there is no `ImageUrl` field — and the POST takes camelCase query params,
|
||||||
|
`?type=Primary&imageUrl=<Url>`, answering `204`; the stored tag then appears in
|
||||||
|
`ImageTags`. PascalCase params, or an `imageUrl` left empty, answer `500`, which
|
||||||
|
reads as a server fault and is a field-name fault.
|
||||||
|
|
||||||
|
**`RemoteImages` answers empty for an item with no `ProviderIds`** — no provider id,
|
||||||
|
nothing to look up. Art coverage is capped by identification, not by the image door:
|
||||||
|
the items a campaign returns nothing for are the unidentified ones, and no image
|
||||||
|
retry will change that.
|
||||||
|
|
||||||
|
**A `Download` campaign over a library that is already dressed does nothing in
|
||||||
|
seconds, and that is the measurement proving identification is the only lever
|
||||||
|
left.** Run over all 753 series and 2,805 seasons of `Shows` on 2026-11-02 it
|
||||||
|
classified 3,558 items in 20 s: 2,908 already carried a primary, 624 have no
|
||||||
|
provider id at all, 26 have an id and no poster in TMDb — zero downloads. The
|
||||||
|
artless remainder is not an image-door problem: it is the unnamed long tail (clip
|
||||||
|
collections, `(Warhammer TV)`-shaped directories), and the lever is folder names
|
||||||
|
or an identify pass, never another retry loop.
|
||||||
|
|
||||||
|
**Series and season art is fetched; episode stills are not, deliberately.** Every
|
||||||
|
episode query is a fresh provider round-trip — nothing caches per episode — measured
|
||||||
|
at ~11 s per item beside ~2 s per series on the same host, with one hit in five in a
|
||||||
|
sample. Across 42k episodes that is days of provider traffic to dress a fifth of them,
|
||||||
|
and clients display the series and season art anyway. If episode stills are ever
|
||||||
|
ordered, the loop is the same door and its log makes the pass resumable.
|
||||||
|
|
||||||
**API shape on 12.x:** `/Items` and `/Items/{id}` need `UserId` when
|
**API shape on 12.x:** `/Items` and `/Items/{id}` need `UserId` when
|
||||||
authenticated by API key — without it the answer is `400 Error processing
|
authenticated by API key — without it the answer is `400 Error processing
|
||||||
request`, which reads exactly like an authentication failure. `MetadataRefreshMode`
|
request`, which reads exactly like an authentication failure. `MetadataRefreshMode`
|
||||||
@@ -232,9 +262,17 @@ scripts/jellyfin-providers.sh verify # plugin + key LENGTHS, never key bytes
|
|||||||
- **The homelab TVDB key is a licensed key** (its JWT claims say
|
- **The homelab TVDB key is a licensed key** (its JWT claims say
|
||||||
`community_supported: false`), so it needs **no PIN** — the optional
|
`community_supported: false`), so it needs **no PIN** — the optional
|
||||||
`TVDB_PIN=` line exists only for a subscriber-shaped key.
|
`TVDB_PIN=` line exists only for a subscriber-shaped key.
|
||||||
- **TVDB is installed and keyed but is not a metadata agent**: TMDb still
|
- **TVDB is installed but is not a metadata agent**: TMDb still identifies
|
||||||
identifies everything. Enabling TVDB as a provider would re-fetch show
|
everything. Enabling TVDB as a provider would re-fetch show metadata across
|
||||||
metadata across the library, so it is a deliberate, separate change.
|
the library, so it is a deliberate, separate change.
|
||||||
|
- **A plugin owns its own config file, under its own root, and omits null
|
||||||
|
settings** — so the element an installer wants to fill may simply not exist,
|
||||||
|
and its root is `<PluginConfiguration>`, not `<Configuration>`. TVDB's file had
|
||||||
|
no `<ApiKey>` element at all, which is why `install` refused with
|
||||||
|
`no place to write <ApiKey>` from October until the anchor fix: the secret sat
|
||||||
|
valid in `provider-keys` the whole time, and `verify`'s `MISSING key` was this
|
||||||
|
fault, not a missing key. The writer now anchors on the file's own root and
|
||||||
|
still refuses by name when a file has none.
|
||||||
- **The deployer places the keys before it restarts jellyfin**
|
- **The deployer places the keys before it restarts jellyfin**
|
||||||
(`ensure_provider_keys` in `scripts/deploy-remote.sh`) and warns instead of
|
(`ensure_provider_keys` in `scripts/deploy-remote.sh`) and warns instead of
|
||||||
failing when `provider-keys` is absent, so a host without it still deploys.
|
failing when `provider-keys` is absent, so a host without it still deploys.
|
||||||
@@ -284,12 +322,34 @@ anything. The fix is `systemctl --user restart jellyfin.service`, one POST after
|
|||||||
health answers, and then believe `ScheduledTasks` State at 130 % CPU, not the
|
health answers, and then believe `ScheduledTasks` State at 130 % CPU, not the
|
||||||
response code.
|
response code.
|
||||||
|
|
||||||
**A scan that must see a new folder has to be recursive.** `POST /Library/Refresh`
|
**The door that scans is `POST /Library/Refresh` (204), and `ScheduledTasks` is
|
||||||
is the incremental pass, and inotify does not cross a rootless podman bind, so
|
the progress read.** A task that has never completed answers
|
||||||
folders created on the host stay invisible to it. The move is
|
`LastTaskResult=None, EndTimeUtc=None`, and that pair is what separates "never
|
||||||
`POST /Items/{id}/Refresh?Recursive=true` (204) per library, the id from
|
ran" from "finished a moment ago" — the difference between a door that is dead
|
||||||
`GET /Library/VirtualFolders` — its field is `ItemId`, not `Id` — and that same
|
and one that merely answered `204`.
|
||||||
record's `RefreshStatus` is the progress read.
|
|
||||||
|
**The per-library `POST /Items/{id}/Refresh` queues nothing, and the provider
|
||||||
|
switch is not the reason.** Measured twice on 2026-11-02 against `Shows`: before
|
||||||
|
the flip and again after it, the call answered `204`, the journal wrote zero lines
|
||||||
|
over the following ten minutes, and `Scan Media Library` kept its never-run
|
||||||
|
signature; a service restart changes nothing. `POST /Library/Refresh` does run —
|
||||||
|
`204`, then `Running` inside 15 s at 33 % CPU — but it walks every library, so on
|
||||||
|
this build a library-scoped metadata pass has no working door and the global one
|
||||||
|
is the only lever.
|
||||||
|
|
||||||
|
**That endpoint takes its parameters from one source.** The documented query form
|
||||||
|
(empty body) and the JSON body form (no query) each parse; supplying both at once
|
||||||
|
answers `400` with an empty body whatever the values, and `ValidationFailure` is
|
||||||
|
rejected in the body while the query form accepts it.
|
||||||
|
|
||||||
|
**`RefreshStatus` on the `GET /Library/VirtualFolders` record is not a progress
|
||||||
|
read.** It stayed `Idle` through a scan that was verifiably running at 75 %. The
|
||||||
|
record's useful field is `ItemId`, and the live library set is still the entries
|
||||||
|
whose `Locations` are non-empty.
|
||||||
|
|
||||||
|
`inotify` does not cross a rootless podman bind, so folders created on the host
|
||||||
|
stay invisible until a pass like this runs; every pass that writes into a library
|
||||||
|
ends by firing one.
|
||||||
|
|
||||||
**`Movies` has no watcher at all.** `EnableRealtimeMonitor` is per library (table in
|
**`Movies` has no watcher at all.** `EnableRealtimeMonitor` is per library (table in
|
||||||
*Data layout*) and it is false for `Movies` and `Collections`. Nothing notices a
|
*Data layout*) and it is false for `Movies` and `Collections`. Nothing notices a
|
||||||
|
|||||||
@@ -403,9 +403,12 @@ executor, the one-journal-per-arm law, the sweep and the rollback.
|
|||||||
- **The journal lives BESIDE the library root, never inside it** (default:
|
- **The journal lives BESIDE the library root, never inside it** (default:
|
||||||
`media-relayout.jsonl` next to the root): a log inside the library is a file
|
`media-relayout.jsonl` next to the root): a log inside the library is a file
|
||||||
Jellyfin indexes.
|
Jellyfin indexes.
|
||||||
- **Refusals are all-or-nothing.** An occupied target, a vanished source or a
|
- **Refusals are all-or-nothing.** An occupied target, a vanished source, a
|
||||||
duplicate destination stops the run *before the first rename*, so the operator
|
duplicate destination, or a plan row whose verb is not `mkdir`, `keep` or
|
||||||
fixes one thing and re-runs instead of untangling a half-applied plan.
|
`move`, stops the run *before the first rename*, so the operator fixes one
|
||||||
|
thing and re-runs instead of untangling a half-applied plan. An unread verb is
|
||||||
|
refused by line number: silently dropping one turned a pass meant to rename
|
||||||
|
two series folders into `applied: 0 moved` with exit 0 (2026-11-02).
|
||||||
`--force` relaxes only the opinion guards; it never permits overwriting.
|
`--force` relaxes only the opinion guards; it never permits overwriting.
|
||||||
- **A sweep deletes only what the journal proves the run emptied.** A folder is
|
- **A sweep deletes only what the journal proves the run emptied.** A folder is
|
||||||
eligible when a journalled file moved out of it or out of something under it,
|
eligible when a journalled file moved out of it or out of something under it,
|
||||||
|
|||||||
@@ -13,9 +13,12 @@
|
|||||||
# 3. a downloaded plugin artifact is accepted only on its pinned sha256, its
|
# 3. a downloaded plugin artifact is accepted only on its pinned sha256, its
|
||||||
# official guid and its pinned version (a swapped or downgraded artifact
|
# official guid and its pinned version (a swapped or downgraded artifact
|
||||||
# is refused before anything lands in plugins/);
|
# is refused before anything lands in plugins/);
|
||||||
# 4. the TMDb key is written into the EXISTING config with every other
|
# 4. a key is written into the EXISTING config with every other setting
|
||||||
# setting preserved — Jellyfin owns that file, and clobbering it would
|
# preserved, under whatever root that file's owner chose — Jellyfin's core
|
||||||
# silently reset country, language and adult settings;
|
# configs say `<Configuration>`, TheTVDB writes `<PluginConfiguration>` and
|
||||||
|
# omits a null setting outright, so the element to fill can be absent and
|
||||||
|
# the anchor has to come from the file; clobbering the file would silently
|
||||||
|
# reset country, language and adult settings;
|
||||||
# 5. a TVDB config the operator already saved through the UI is kept;
|
# 5. a TVDB config the operator already saved through the UI is kept;
|
||||||
# 6. install is idempotent: a second run changes no byte;
|
# 6. install is idempotent: a second run changes no byte;
|
||||||
# 7. whatever is replaced is copied to the trash first (fleet law: never
|
# 7. whatever is replaced is copied to the trash first (fleet law: never
|
||||||
@@ -254,6 +257,38 @@ XML
|
|||||||
grep -qF '<EnableSeasonPathMatching>true</EnableSeasonPathMatching>' "$cfg"
|
grep -qF '<EnableSeasonPathMatching>true</EnableSeasonPathMatching>' "$cfg"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "the key lands in a plugin-written config with no ApiKey element" {
|
||||||
|
# What TheTVDB actually writes on a fresh install: its own root element and
|
||||||
|
# no <ApiKey /> at all, because its serializer drops null settings. The
|
||||||
|
# installer used to anchor on </Configuration> and refuse this file.
|
||||||
|
cat >"$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml" <<XML
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<PluginConfiguration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
||||||
|
<SubscriberPIN />
|
||||||
|
<CacheDurationInHours>1</CacheDurationInHours>
|
||||||
|
<OriginalTitle>true</OriginalTitle>
|
||||||
|
</PluginConfiguration>
|
||||||
|
XML
|
||||||
|
run prov install
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
printf '%s\n' "${lines[@]}" | grep -qiF 'filled the ApiKey'
|
||||||
|
local cfg="$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml"
|
||||||
|
grep -qF "<ApiKey>$TVDB_KEY</ApiKey>" "$cfg"
|
||||||
|
grep -qF '<CacheDurationInHours>1</CacheDurationInHours>' "$cfg"
|
||||||
|
grep -qF '<OriginalTitle>true</OriginalTitle>' "$cfg"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a config file with no root element is refused by name" {
|
||||||
|
# The guard still has to fire when there is genuinely nowhere to write —
|
||||||
|
# a refusal naming the file beats inventing a document shape.
|
||||||
|
printf '<?xml version="1.0" encoding="utf-8"?>\n' \
|
||||||
|
>"$JF/plugins/configurations/Jellyfin.Plugin.Tvdb.xml"
|
||||||
|
run prov install
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
printf '%s\n' "${lines[@]}" | grep -qF 'Jellyfin.Plugin.Tvdb.xml'
|
||||||
|
printf '%s\n' "${lines[@]}" | grep -qiF 'no place to write'
|
||||||
|
}
|
||||||
|
|
||||||
@test "a pin in the TVDB secret lands in the plugin config" {
|
@test "a pin in the TVDB secret lands in the plugin config" {
|
||||||
# A subscriber-shaped key needs the account's PIN beside it; a licensed key
|
# A subscriber-shaped key needs the account's PIN beside it; a licensed key
|
||||||
# does not, so the element appears only when the secret carries one.
|
# does not, so the element appears only when the secret carries one.
|
||||||
|
|||||||
@@ -460,6 +460,39 @@ print(sum(1 for l in open(sys.argv[1]) if json.loads(l).get("op") == "move"))' "
|
|||||||
[ -e "$LIB/Alien (1979)/Alien.1979.1080p.BluRay.x265.10bit.mkv" ]
|
[ -e "$LIB/Alien (1979)/Alien.1979.1080p.BluRay.x265.10bit.mkv" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# A hand-written plan row whose verb is not in the executor's vocabulary used
|
||||||
|
# to be dropped in silence: read_plan's if/elif chain had no else, so a plan of
|
||||||
|
# nothing but typos applied as a successful no-op (hit for real on 2026-11-02,
|
||||||
|
# when a pass meant to rename two series folders reported "applied: 0 moved"
|
||||||
|
# and exit 0). An unread row is a plan bug, and a plan bug refuses.
|
||||||
|
@test "a plan row with a verb this tool does not know stops the whole run" {
|
||||||
|
plan
|
||||||
|
JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl"
|
||||||
|
at "$LIB/Stray/Stray.S01E01.mkv" 4000
|
||||||
|
printf 'rename\t%s\t%s\tfile\n' \
|
||||||
|
"$LIB/Stray/Stray.S01E01.mkv" "$LIB/Stray/Stray - S01E01.mkv" >> "$OUT"
|
||||||
|
run python3 "$APPLY" apply --plan "$OUT" --root "$LIB" --journal "$JOURNAL" --apply
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"rename"* ]]
|
||||||
|
# all-or-nothing: the rows this tool DOES understand did not run either
|
||||||
|
[ -e "$LIB/Stray/Stray.S01E01.mkv" ]
|
||||||
|
[ ! -e "$JOURNAL" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a plan whose every row is an unknown verb refuses instead of reporting success" {
|
||||||
|
JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl"
|
||||||
|
at "$LIB/Stray/Stray.S01E01.mkv" 4000
|
||||||
|
{ printf 'action\tsrc\tdst\tkind\n'
|
||||||
|
printf 'rename\t%s\t%s\tfile\n' \
|
||||||
|
"$LIB/Stray/Stray.S01E01.mkv" "$LIB/Stray/Stray - S01E01.mkv"
|
||||||
|
} > "$BATS_TEST_TMPDIR/typo.tsv"
|
||||||
|
run python3 "$APPLY" apply --plan "$BATS_TEST_TMPDIR/typo.tsv" --root "$LIB" --journal "$JOURNAL" --apply
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"unknown action"* ]]
|
||||||
|
[[ "$output" != *"applied"* ]]
|
||||||
|
[ -e "$LIB/Stray/Stray.S01E01.mkv" ]
|
||||||
|
}
|
||||||
|
|
||||||
@test "a run interrupted between the journal write and the move finishes the job" {
|
@test "a run interrupted between the journal write and the move finishes the job" {
|
||||||
plan
|
plan
|
||||||
JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl"
|
JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl"
|
||||||
|
|||||||
Reference in new issue
Block a user