diff --git a/README.md b/README.md index 1d52107..adf8c43 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/internal/api/server.go b/internal/api/server.go index a323370..b3a6100 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) } diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 3df399e..1a130cd 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -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") } diff --git a/internal/engine/ignore.go b/internal/engine/ignore.go new file mode 100644 index 0000000..697bbf6 --- /dev/null +++ b/internal/engine/ignore.go @@ -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 //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) +} diff --git a/internal/engine/ignore_test.go b/internal/engine/ignore_test.go new file mode 100644 index 0000000..6c3c24e --- /dev/null +++ b/internal/engine/ignore_test.go @@ -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) + } +} diff --git a/web/src/api.ts b/web/src/api.ts index daf5c1b..abbd93b 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -33,6 +33,7 @@ export type Info = { allowed_roots: string[]; default_extensions: string[]; dry_run: boolean; + plex_ignore: boolean; }; export type Job = { diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 64c83f4..2c79c91 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -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() { + +

Media servers

+ plex.mutate(v)} + label={Hide quarantine from Plex} + /> +

+ Adds a .plexignore file to each library pair's quarantine folder (by default{" "} + .playlink-trash 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{" "} + .plexignore of your own is never changed. +

+
+

Environment