Add a setting to hide the quarantine from Plex

Settings -> Media servers -> "Hide quarantine from Plex" writes a
.plexignore into every pair's quarantine folder (.playlink-trash by
default), with patterns for every folder depth quarantined files live
at. Quarantine folders opened later get the file automatically.

The file carries a marker line: turning the option off removes only
playlink's own file, and an existing .plexignore written by someone else
is never overwritten or deleted (reported instead). Writes go through a
temporary file and a rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-28 21:57:56 -07:00
1 parent 42161e4287
commit 1da0132d5e
7 files changed
+238 -2

No files matched your search

+4
View File
@@ -152,6 +152,10 @@ Run a scan, check the Review tab, and turn off dry run once the planned actions
| `PLAYLINK_FFPROBE` | `ffprobe` | Path to ffprobe. Without it, quality comparison is disabled but everything else works. |
| `PLAYLINK_LOG_LEVEL` | `info` | `debug` for verbose logs. |
**Hide quarantine from Plex** (Settings → Media servers) writes a `.plexignore` into each pair's quarantine folder so Plex does not
pick up quarantined songs; new quarantine folders get it automatically. Turning it off removes the file again, but only if playlink
created it.
Per-pair settings (in the UI) are file extensions, auto-link, approximate matching and its threshold, scan interval, quarantine folder,
retention and settle time.
+18 -2
View File
@@ -193,16 +193,19 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
"allowed_roots": s.eng.AllowedRoots(),
"default_extensions": engine.DefaultExtensions,
"dry_run": s.st.DryRun(r.Context()),
"plex_ignore": s.st.Setting(r.Context(), "plex_ignore", "0") == "1",
})
}
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"dry_run": s.st.DryRun(r.Context())})
writeJSON(w, 200, map[string]any{"dry_run": s.st.DryRun(r.Context()),
"plex_ignore": s.st.Setting(r.Context(), "plex_ignore", "0") == "1"})
}
func (s *Server) handlePutSettings(w http.ResponseWriter, r *http.Request) {
var body struct {
DryRun *bool `json:"dry_run"`
DryRun *bool `json:"dry_run"`
PlexIgnore *bool `json:"plex_ignore"`
}
if !readJSON(w, r, &body) {
return
@@ -218,6 +221,19 @@ func (s *Server) handlePutSettings(w http.ResponseWriter, r *http.Request) {
}
s.log.Info("dry run changed", "dry_run", *body.DryRun)
}
if body.PlexIgnore != nil {
v := "0"
if *body.PlexIgnore {
v = "1"
}
if err := s.st.SetSetting(r.Context(), "plex_ignore", v); err != nil {
writeErr(w, 500, err)
return
}
results := s.eng.ApplyPlexIgnore(r.Context(), *body.PlexIgnore)
writeJSON(w, 200, map[string]any{"dry_run": s.st.DryRun(r.Context()), "plex_ignore": *body.PlexIgnore, "results": results})
return
}
s.handleGetSettings(w, r)
}
+7
View File
@@ -229,6 +229,13 @@ func (e *Engine) open(p store.Pair) (*libs, error) {
if err := os.MkdirAll(q, 0o775); err != nil {
return nil, fmt.Errorf("quarantine directory: %w", err)
}
// Keep the quarantine hidden from Plex when enabled, including quarantine
// folders created after the setting was turned on. Never fatal.
if e.plexIgnoreEnabled() {
if err := syncPlexIgnore(q, true); err != nil && !errors.Is(err, ErrForeignIgnore) {
e.log.Warn(".plexignore not written", "err", plexIgnoreError(q, err))
}
}
if rq, err := filepath.EvalSymlinks(q); err != nil || (rq != q && !e.withinAllowed(rq)) {
return nil, errors.New("quarantine directory resolves outside the allowed roots")
}
+108
View File
@@ -0,0 +1,108 @@
package engine
import (
"bytes"
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/cdnutter/playlink/internal/fsops"
)
// PlexIgnoreName is the file Plex reads to skip content in a folder.
const PlexIgnoreName = ".plexignore"
// plexIgnoreMarker identifies a .plexignore written by playlink, so only
// that file is ever replaced or removed; a user's own file is left alone.
const plexIgnoreMarker = "# Created by playlink"
// plexIgnoreDepth covers quarantine paths like <op>/<side>/Artist/Album/Disc/Song.
const plexIgnoreDepth = 12
func plexIgnoreContent() []byte {
var b strings.Builder
b.WriteString(plexIgnoreMarker + ": keeps Plex from scanning files in playlink's quarantine.\n")
b.WriteString("# Turn off \"Hide quarantine from Plex\" in playlink's settings to remove it.\n")
for i := 1; i <= plexIgnoreDepth; i++ {
b.WriteString(strings.TrimSuffix(strings.Repeat("*/", i), "/") + "\n")
}
return []byte(b.String())
}
// ErrForeignIgnore means a .plexignore exists that playlink did not write.
var ErrForeignIgnore = errors.New("a .plexignore not created by playlink already exists; it was left unchanged")
// syncPlexIgnore makes the quarantine folder's .plexignore match the setting.
func syncPlexIgnore(quarantine string, enabled bool) error {
p := filepath.Join(quarantine, PlexIgnoreName)
cur, err := os.ReadFile(p)
exists := err == nil
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
ours := exists && bytes.HasPrefix(cur, []byte(plexIgnoreMarker))
want := plexIgnoreContent()
switch {
case enabled && exists && !ours:
return ErrForeignIgnore
case enabled && ours && bytes.Equal(cur, want):
return nil
case enabled:
// Write via a temporary file so Plex never sees a half-written file.
tmp := filepath.Join(quarantine, fsops.TmpPrefix+"plexignore")
if err := os.WriteFile(tmp, want, 0o644); err != nil {
return err
}
if err := os.Rename(tmp, p); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
case ours:
return os.Remove(p)
}
return nil
}
// IgnoreResult reports what happened to one pair's quarantine folder.
type IgnoreResult struct {
Pair string `json:"pair"`
Path string `json:"path"`
Error string `json:"error,omitempty"`
}
// ApplyPlexIgnore adds or removes the .plexignore in every pair's quarantine.
func (e *Engine) ApplyPlexIgnore(ctx context.Context, enabled bool) []IgnoreResult {
pairs, err := e.st.Pairs(ctx)
if err != nil {
return []IgnoreResult{{Error: err.Error()}}
}
out := []IgnoreResult{}
for _, p := range pairs {
r := IgnoreResult{Pair: p.Name, Path: filepath.Join(p.QuarantineDir, PlexIgnoreName)}
// open re-validates the pair and syncs the file as a side effect.
l, err := e.open(p)
if err == nil {
err = syncPlexIgnore(l.quarantine, enabled)
}
if err != nil {
r.Error = err.Error()
e.log.Warn(".plexignore not updated", "pair", p.Name, "err", err)
}
out = append(out, r)
}
e.log.Info(".plexignore setting applied", "enabled", enabled, "pairs", len(pairs))
return out
}
func (e *Engine) plexIgnoreEnabled() bool {
return e.st.Setting(context.Background(), "plex_ignore", "0") == "1"
}
func plexIgnoreError(quarantine string, err error) error {
return fmt.Errorf("%s: %w", filepath.Join(quarantine, PlexIgnoreName), err)
}
+74
View File
@@ -0,0 +1,74 @@
package engine
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/cdnutter/playlink/internal/store"
)
func TestPlexIgnoreToggle(t *testing.T) {
ev := setup(t, false)
q := ev.pair.QuarantineDir
ignore := filepath.Join(q, PlexIgnoreName)
if err := ev.st.SetSetting(ctx, "plex_ignore", "1"); err != nil {
t.Fatal(err)
}
res := ev.e.ApplyPlexIgnore(ctx, true)
if len(res) != 1 || res[0].Error != "" {
t.Fatalf("apply: %+v", res)
}
b, err := os.ReadFile(ignore)
if err != nil || !strings.HasPrefix(string(b), plexIgnoreMarker) || !strings.Contains(string(b), "\n*\n*/*\n") {
t.Fatalf("expected playlink's .plexignore, got %q (%v)", b, err)
}
// A quarantine created later (new pair) gets the file automatically.
other := store.Pair{Name: "other", MasterRoot: ev.master, SlaveRoot: ev.slave, QuarantineDir: filepath.Join(ev.root, "q2")}
if _, err := ev.e.NormalizePair(&other); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(ev.root, "q2", PlexIgnoreName)); err != nil {
t.Fatalf("new quarantine folder should get a .plexignore: %v", err)
}
// Turning it off removes playlink's file.
if err := ev.st.SetSetting(ctx, "plex_ignore", "0"); err != nil {
t.Fatal(err)
}
ev.e.ApplyPlexIgnore(ctx, false)
if _, err := os.Stat(ignore); !errors.Is(err, os.ErrNotExist) {
t.Fatal(".plexignore should be removed when the option is turned off")
}
}
func TestPlexIgnoreLeavesUserFileAlone(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, PlexIgnoreName)
os.WriteFile(p, []byte("my own rules\n"), 0o644)
if err := syncPlexIgnore(dir, true); !errors.Is(err, ErrForeignIgnore) {
t.Fatalf("want ErrForeignIgnore, got %v", err)
}
if err := syncPlexIgnore(dir, false); err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile(p); string(b) != "my own rules\n" {
t.Fatal("a user's .plexignore must never be changed or removed")
}
}
func TestPlexIgnoreCoversDeepQuarantinePaths(t *testing.T) {
lines := strings.Split(strings.TrimSpace(string(plexIgnoreContent())), "\n")
deepest := lines[len(lines)-1]
// e.g. 12/slave/Artist/Album/Disc 1/01 Song.flac is 6 levels deep.
if strings.Count(deepest, "*") < 6 {
t.Fatalf("patterns do not reach deep enough: %q", deepest)
}
if err := syncPlexIgnore(t.TempDir(), true); err != nil {
t.Fatal(err)
}
}
+1
View File
@@ -33,6 +33,7 @@ export type Info = {
allowed_roots: string[];
default_extensions: string[];
dry_run: boolean;
plex_ignore: boolean;
};
export type Job = {
+26
View File
@@ -15,6 +15,17 @@ export function SettingsPage() {
mutationFn: (v: boolean) => api.put("/settings", { dry_run: v }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["info"] }),
});
const plex = useMutation({
mutationFn: (v: boolean) =>
api.put<{ plex_ignore: boolean; results?: { pair: string; path: string; error?: string }[] }>("/settings", { plex_ignore: v }),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ["info"] });
const failed = (res.results ?? []).filter((r) => r.error);
if (failed.length > 0) failed.forEach((r) => toast("err", `${r.pair}: ${r.error}`));
else toast("ok", res.plex_ignore ? "Added .plexignore to every quarantine folder." : "Removed playlink's .plexignore files.");
},
onError: (e) => toast("err", e.message),
});
const pw = useMutation({
mutationFn: () => api.post("/auth/password", { current: cur, new: next }),
onSuccess: () => {
@@ -52,6 +63,21 @@ export function SettingsPage() {
</div>
</Card>
<Card className="space-y-3 p-5">
<h2 className="font-semibold">Media servers</h2>
<Toggle
checked={info.data?.plex_ignore ?? false}
onChange={(v) => plex.mutate(v)}
label={<span className="font-medium">Hide quarantine from Plex</span>}
/>
<p className="text-sm text-muted">
Adds a <code className="mono">.plexignore</code> file to each library pair's quarantine folder (by default{" "}
<code className="mono">.playlink-trash</code> in the slave library), so Plex doesn't pick up quarantined songs as duplicates. New
quarantine folders get it automatically. Turning this off removes the file again, but only if playlink created it: a{" "}
<code className="mono">.plexignore</code> of your own is never changed.
</p>
</Card>
<Card className="space-y-2 p-5">
<h2 className="font-semibold">Environment</h2>
<div className="text-sm">