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) }