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:
1 parent
42161e4287
commit
1da0132d5e
7 files changed
+238
-2
No files matched your search
@@ -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_FFPROBE` | `ffprobe` | Path to ffprobe. Without it, quality comparison is disabled but everything else works. |
|
||||||
| `PLAYLINK_LOG_LEVEL` | `info` | `debug` for verbose logs. |
|
| `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,
|
Per-pair settings (in the UI) are file extensions, auto-link, approximate matching and its threshold, scan interval, quarantine folder,
|
||||||
retention and settle time.
|
retention and settle time.
|
||||||
|
|
||||||
|
|||||||
+18
-2
@@ -193,16 +193,19 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
"allowed_roots": s.eng.AllowedRoots(),
|
"allowed_roots": s.eng.AllowedRoots(),
|
||||||
"default_extensions": engine.DefaultExtensions,
|
"default_extensions": engine.DefaultExtensions,
|
||||||
"dry_run": s.st.DryRun(r.Context()),
|
"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) {
|
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) {
|
func (s *Server) handlePutSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
var body struct {
|
var body struct {
|
||||||
DryRun *bool `json:"dry_run"`
|
DryRun *bool `json:"dry_run"`
|
||||||
|
PlexIgnore *bool `json:"plex_ignore"`
|
||||||
}
|
}
|
||||||
if !readJSON(w, r, &body) {
|
if !readJSON(w, r, &body) {
|
||||||
return
|
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)
|
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)
|
s.handleGetSettings(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -229,6 +229,13 @@ func (e *Engine) open(p store.Pair) (*libs, error) {
|
|||||||
if err := os.MkdirAll(q, 0o775); err != nil {
|
if err := os.MkdirAll(q, 0o775); err != nil {
|
||||||
return nil, fmt.Errorf("quarantine directory: %w", err)
|
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)) {
|
if rq, err := filepath.EvalSymlinks(q); err != nil || (rq != q && !e.withinAllowed(rq)) {
|
||||||
return nil, errors.New("quarantine directory resolves outside the allowed roots")
|
return nil, errors.New("quarantine directory resolves outside the allowed roots")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export type Info = {
|
|||||||
allowed_roots: string[];
|
allowed_roots: string[];
|
||||||
default_extensions: string[];
|
default_extensions: string[];
|
||||||
dry_run: boolean;
|
dry_run: boolean;
|
||||||
|
plex_ignore: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Job = {
|
export type Job = {
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ export function SettingsPage() {
|
|||||||
mutationFn: (v: boolean) => api.put("/settings", { dry_run: v }),
|
mutationFn: (v: boolean) => api.put("/settings", { dry_run: v }),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["info"] }),
|
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({
|
const pw = useMutation({
|
||||||
mutationFn: () => api.post("/auth/password", { current: cur, new: next }),
|
mutationFn: () => api.post("/auth/password", { current: cur, new: next }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -52,6 +63,21 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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">
|
<Card className="space-y-2 p-5">
|
||||||
<h2 className="font-semibold">Environment</h2>
|
<h2 className="font-semibold">Environment</h2>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
|
|||||||
Reference in new issue
Block a user