Go back to the hardlink deduplicator (1da0132), keeping backups
Restores the code of 1da0132 ("Add a setting to hide the quarantine from
Plex") and removes everything added after it: the tag editor, album art,
organizing/naming, the step-by-step tune-up and library types.
Kept:
- Database backups (Settings -> Backups): scheduled, manual, download,
restore, and the optional backup before every action.
- The .env-driven docker-compose.yml for Arcane.
Databases that already ran the later migrations keep working: migrations
6-10 stay in the list (they only add columns) so numbering never goes
backwards, and Undo refuses History entries of removed features instead
of treating them as links.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7b180e26db
commit
e0617047f7
44 files changed
+103
-10456
No files matched your search
@@ -1,154 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/cdnutter/playlink/internal/art"
|
||||
"github.com/cdnutter/playlink/internal/engine"
|
||||
)
|
||||
|
||||
func (s *Server) handleArtSettings(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := s.eng.ArtSettings(r.Context())
|
||||
writeJSON(w, 200, map[string]any{"settings": cfg, "all_sources": art.AllSources})
|
||||
}
|
||||
|
||||
func (s *Server) handlePutArtSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var cfg engine.ArtSettings
|
||||
if !readJSON(w, r, &cfg) {
|
||||
return
|
||||
}
|
||||
if err := s.eng.SetArtSettings(r.Context(), cfg); err != nil {
|
||||
writeErr(w, 400, err)
|
||||
return
|
||||
}
|
||||
s.handleArtSettings(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleArtSearch(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
query := art.Query{Artist: q.Get("artist"), Album: q.Get("album"), Text: q.Get("q")}
|
||||
if strings.TrimSpace(query.Artist+query.Album+query.Text) == "" {
|
||||
writeErr(w, 400, errors.New("enter an artist, album or search text"))
|
||||
return
|
||||
}
|
||||
var sources []string
|
||||
if src := q.Get("sources"); src != "" {
|
||||
sources = strings.Split(src, ",")
|
||||
}
|
||||
res, errs, err := s.eng.SearchArt(r.Context(), query, sources)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"results": res, "errors": errs})
|
||||
}
|
||||
|
||||
func (s *Server) handleArtPrepare(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
p, err := s.eng.PrepareArtURL(r.Context(), body.URL)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, p)
|
||||
}
|
||||
|
||||
func (s *Server) handleArtUpload(w http.ResponseWriter, r *http.Request) {
|
||||
f, _, err := r.FormFile("image")
|
||||
if err != nil {
|
||||
writeErr(w, 400, errors.New("choose an image file"))
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
p, err := s.eng.PrepareArtUpload(r.Context(), f)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, p)
|
||||
}
|
||||
|
||||
func (s *Server) handleArtPrepared(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.eng.PreparedArtPath(chi.URLParam(r, "token"))
|
||||
if err != nil {
|
||||
writeErr(w, 404, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
http.ServeFile(w, r, p)
|
||||
}
|
||||
|
||||
func (s *Server) handleCover(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
q := r.URL.Query()
|
||||
b, err := s.eng.Cover(r.Context(), id, q.Get("side"), q.Get("path"))
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
}
|
||||
if len(b) == 0 {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", http.DetectContentType(b))
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeContent(w, r, "cover", time.Time{}, strings.NewReader(string(b)))
|
||||
}
|
||||
|
||||
func (s *Server) handleAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
q := r.URL.Query()
|
||||
a, err := s.eng.AlbumSongs(r.Context(), id, q.Get("side"), q.Get("path"))
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, a)
|
||||
}
|
||||
|
||||
func (s *Server) handleArtAuto(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
var m engine.ArtMatch
|
||||
if !readJSON(w, r, &m) {
|
||||
return
|
||||
}
|
||||
if !s.preAction(w, r, id, "auto-match album art") {
|
||||
return
|
||||
}
|
||||
j, err := s.eng.StartArtAutoMatch(id, m)
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 202, j)
|
||||
}
|
||||
|
||||
// handleArtThumb shows a search result's thumbnail through the server, so
|
||||
// the page keeps its strict content policy and the browser never contacts
|
||||
// the image hosts directly. Fetching uses the local-network-safe client.
|
||||
func (s *Server) handleArtThumb(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := s.eng.FetchArtThumb(r.Context(), r.URL.Query().Get("u"))
|
||||
if err != nil {
|
||||
writeErr(w, 502, err)
|
||||
return
|
||||
}
|
||||
ct := http.DetectContentType(b)
|
||||
if !strings.HasPrefix(ct, "image/") {
|
||||
writeErr(w, 502, errors.New("not an image"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", ct)
|
||||
w.Header().Set("Cache-Control", "private, max-age=86400")
|
||||
w.Write(b)
|
||||
}
|
||||
@@ -23,7 +23,6 @@ var kindLabels = map[string]string{
|
||||
store.KindFuzzy: "similar title",
|
||||
store.KindElsewhere: "same top folder, other subfolder",
|
||||
store.KindApprox: "approximate match",
|
||||
store.KindInode: "same file, other path",
|
||||
store.KindNone: "no match",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/cdnutter/playlink/internal/engine"
|
||||
)
|
||||
|
||||
// ---- tune-up (step-by-step library fixing) ----
|
||||
|
||||
func (s *Server) handleFixer(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
p, err := s.st.Pair(r.Context(), id)
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
}
|
||||
out := map[string]any{"state": engine.FixerStateOf(p), "steps": engine.FixerSteps, "plan": nil,
|
||||
"items": []engine.FixItem{}, "albums": []*engine.AlbumFix{}}
|
||||
if plan, items, albums := s.eng.FixerPlan(id); plan != nil {
|
||||
out["plan"], out["items"], out["albums"] = plan, items, albums
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleFixCheck(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
var body engine.FixCheck
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
j, err := s.eng.StartFixCheck(id, body)
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 202, j)
|
||||
}
|
||||
|
||||
func (s *Server) handleFixApply(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
var body engine.FixApply
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if !s.preAction(w, r, id, "tune-up") {
|
||||
return
|
||||
}
|
||||
j, err := s.eng.StartFixApply(id, body)
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
}
|
||||
s.log.Info("tune-up apply started", "pair", id, "selected", len(body.IDs))
|
||||
writeJSON(w, 202, j)
|
||||
}
|
||||
|
||||
func (s *Server) handleFixStep(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
var body struct {
|
||||
Side string `json:"side"` // master, slave, or "" for the link step
|
||||
Step string `json:"step"`
|
||||
State string `json:"state"` // done, skipped or "" (to do)
|
||||
}
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if err := s.eng.SetStep(r.Context(), id, body.Side, body.Step, body.State, ""); err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
}
|
||||
s.handleFixer(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleFixRelease(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
var body struct {
|
||||
Album int `json:"album"`
|
||||
Release string `json:"release"` // a MusicBrainz release id, or "search"
|
||||
}
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if j := s.eng.Job(id); j != nil && j.State == "running" {
|
||||
writeErr(w, 409, engine.ErrBusy)
|
||||
return
|
||||
}
|
||||
a, err := s.eng.ChooseRelease(r.Context(), id, body.Album, body.Release)
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, a)
|
||||
}
|
||||
+4
-163
@@ -23,7 +23,6 @@ import (
|
||||
"github.com/cdnutter/playlink/internal/engine"
|
||||
"github.com/cdnutter/playlink/internal/fsops"
|
||||
"github.com/cdnutter/playlink/internal/store"
|
||||
"github.com/cdnutter/playlink/internal/tags"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -58,9 +57,6 @@ func (s *Server) Handler() http.Handler {
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
limit := int64(1 << 20)
|
||||
if strings.HasSuffix(r.URL.Path, "/art/upload") {
|
||||
limit = 21 << 20
|
||||
}
|
||||
if strings.HasSuffix(r.URL.Path, "/backups/restore-upload") {
|
||||
limit = 20 << 30 // backup zips can be large
|
||||
}
|
||||
@@ -82,15 +78,6 @@ func (s *Server) Handler() http.Handler {
|
||||
r.Get("/settings", s.handleGetSettings)
|
||||
r.Put("/settings", s.handlePutSettings)
|
||||
r.Get("/browse", s.handleBrowse)
|
||||
r.Get("/naming", s.handleNamingInfo)
|
||||
r.Get("/art/settings", s.handleArtSettings)
|
||||
r.Put("/art/settings", s.handlePutArtSettings)
|
||||
r.Get("/art/search", s.handleArtSearch)
|
||||
r.Post("/art/prepare", s.handleArtPrepare)
|
||||
r.Post("/art/upload", s.handleArtUpload)
|
||||
r.Get("/art/prepared/{token}", s.handleArtPrepared)
|
||||
r.Get("/art/thumb", s.handleArtThumb)
|
||||
r.Post("/naming/examples", s.handleNamingExamples)
|
||||
if s.cfg.Backups != nil {
|
||||
s.backupRoutes(r)
|
||||
}
|
||||
@@ -110,23 +97,9 @@ func (s *Server) Handler() http.Handler {
|
||||
r.Get("/groups", s.handleGroups)
|
||||
r.Post("/bulk/preview", s.handleBulkPreview)
|
||||
r.Post("/bulk", s.handleBulk)
|
||||
r.Get("/files", s.handleFiles)
|
||||
r.Get("/cover", s.handleCover)
|
||||
r.Get("/album", s.handleAlbum)
|
||||
r.Post("/art/auto", s.handleArtAuto)
|
||||
r.Post("/tags/read", s.handleReadTags)
|
||||
r.Post("/tags", s.handleEditTags)
|
||||
r.Post("/organize/preview", s.handleOrganizePreview)
|
||||
r.Get("/organize/plan", s.handleOrganizePlan)
|
||||
r.Post("/organize/apply", s.handleOrganizeApply)
|
||||
r.Get("/renames", s.handleRenamePreview)
|
||||
r.Post("/renames", s.handleRenames)
|
||||
r.Post("/link-identical", s.handleLinkIdentical)
|
||||
r.Get("/fixer", s.handleFixer)
|
||||
r.Post("/fixer/check", s.handleFixCheck)
|
||||
r.Post("/fixer/apply", s.handleFixApply)
|
||||
r.Put("/fixer/step", s.handleFixStep)
|
||||
r.Post("/fixer/release", s.handleFixRelease)
|
||||
r.Get("/tree", s.handleTree)
|
||||
r.Post("/build", s.handleBuild)
|
||||
r.Post("/remove", s.handleRemove)
|
||||
@@ -215,7 +188,7 @@ func opErr(w http.ResponseWriter, err error) {
|
||||
errors.Is(err, fsops.ErrChanged), errors.Is(err, fsops.ErrExists):
|
||||
writeErr(w, 409, err)
|
||||
case errors.Is(err, fsops.ErrOutsideRoot), errors.Is(err, fsops.ErrNotRegular), errors.Is(err, fsops.ErrCrossDevice),
|
||||
errors.Is(err, engine.ErrInvalidBulk), errors.Is(err, engine.ErrNoTagTools):
|
||||
errors.Is(err, engine.ErrInvalidBulk):
|
||||
writeErr(w, 400, err)
|
||||
default:
|
||||
writeErr(w, 500, err)
|
||||
@@ -231,8 +204,6 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
||||
"default_extensions": engine.DefaultExtensions,
|
||||
"dry_run": s.st.DryRun(r.Context()),
|
||||
"plex_ignore": s.st.Setting(r.Context(), "plex_ignore", "0") == "1",
|
||||
"tag_editing": s.eng.TagEditing(),
|
||||
"tag_fields": tags.FieldIDs,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -326,15 +297,13 @@ func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
type pairView struct {
|
||||
store.Pair
|
||||
Naming engine.NamingConfig `json:"naming"`
|
||||
Fixer engine.FixerState `json:"fixer"`
|
||||
Summary store.Summary `json:"summary"`
|
||||
Job *engine.Job `json:"job"`
|
||||
Summary store.Summary `json:"summary"`
|
||||
Job *engine.Job `json:"job"`
|
||||
}
|
||||
|
||||
func (s *Server) view(r *http.Request, p store.Pair) pairView {
|
||||
sum, _ := s.st.Summary(r.Context(), p.ID)
|
||||
return pairView{Pair: p, Naming: engine.NamingOf(p), Fixer: engine.FixerStateOf(p), Summary: sum, Job: s.eng.Job(p.ID)}
|
||||
return pairView{Pair: p, Summary: sum, Job: s.eng.Job(p.ID)}
|
||||
}
|
||||
|
||||
func (s *Server) handleListPairs(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -372,10 +341,6 @@ type pairInput struct {
|
||||
SettleMinutes int `json:"settle_minutes"`
|
||||
ApproxMatching bool `json:"approx_matching"`
|
||||
ApproxThreshold int `json:"approx_threshold"`
|
||||
AutoOrganize string `json:"auto_organize"`
|
||||
LibraryType string `json:"library_type"`
|
||||
// Naming is optional: when absent, the pair keeps its current naming.
|
||||
Naming *engine.NamingConfig `json:"naming,omitempty"`
|
||||
}
|
||||
|
||||
func (in pairInput) apply(p *store.Pair) {
|
||||
@@ -383,13 +348,6 @@ func (in pairInput) apply(p *store.Pair) {
|
||||
p.Extensions, p.AutoLinkIdentical = in.Extensions, in.AutoLinkIdentical
|
||||
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes = in.ScanIntervalMinutes, in.RetentionDays, in.SettleMinutes
|
||||
p.ApproxMatching, p.ApproxThreshold = in.ApproxMatching, in.ApproxThreshold
|
||||
p.AutoOrganize = in.AutoOrganize
|
||||
if in.LibraryType != "" {
|
||||
p.LibraryType = in.LibraryType
|
||||
}
|
||||
if in.Naming != nil {
|
||||
p.OrganizeConfig, _ = json.Marshal(in.Naming)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleValidatePair(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -430,8 +388,6 @@ func (s *Server) handleSavePair(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
in.apply(&p)
|
||||
engine.ChangeLibraryType(old, &p)
|
||||
engine.ChangeLibraryType(old, &p)
|
||||
warns, err := s.eng.NormalizePair(&p)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err)
|
||||
@@ -767,54 +723,6 @@ func (s *Server) handleRenames(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 202, j)
|
||||
}
|
||||
|
||||
// ---- tag editor ----
|
||||
|
||||
func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
q := r.URL.Query()
|
||||
ents, err := s.eng.ListFiles(r.Context(), id, q.Get("side"), q.Get("path"))
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, ents)
|
||||
}
|
||||
|
||||
func (s *Server) handleReadTags(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
var body struct {
|
||||
Side string `json:"side"`
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
files, err := s.eng.ReadTags(r.Context(), id, body.Side, body.Paths)
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"files": files})
|
||||
}
|
||||
|
||||
func (s *Server) handleEditTags(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
var body engine.TagEdit
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if !s.preAction(w, r, id, "edit tags") {
|
||||
return
|
||||
}
|
||||
j, err := s.eng.StartTagEdit(id, body)
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
}
|
||||
s.log.Info("tag edit started", "pair", id, "side", body.Side, "files", len(body.Paths), "fields", len(body.Set), "separate", body.Separate)
|
||||
writeJSON(w, 202, j)
|
||||
}
|
||||
|
||||
// preAction runs the optional database backup before an action that changes
|
||||
// files (live mode only). It refuses up front when the pair is busy, so no
|
||||
// backup is made for an action that could not start.
|
||||
@@ -832,70 +740,3 @@ func (s *Server) preAction(w http.ResponseWriter, r *http.Request, pairID int64,
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- organizer ----
|
||||
|
||||
func (s *Server) handleNamingInfo(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"defaults": engine.DefaultNaming(), "presets": engine.NamingPresets, "tokens": engine.TokenHelp,
|
||||
"folder_default": engine.SoundtrackFolder})
|
||||
}
|
||||
|
||||
// handleNamingExamples renders sample songs with unsaved settings, using the
|
||||
// same code as real renames.
|
||||
func (s *Server) handleNamingExamples(w http.ResponseWriter, r *http.Request) {
|
||||
var c engine.NamingConfig
|
||||
if !readJSON(w, r, &c) {
|
||||
return
|
||||
}
|
||||
if err := engine.ValidateNaming(c); err != nil {
|
||||
writeJSON(w, 200, map[string]any{"error": err.Error(), "examples": []engine.NamingExample{}})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"examples": engine.NamingExamples(c)})
|
||||
}
|
||||
|
||||
func (s *Server) handleOrganizePreview(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
var body struct {
|
||||
Side string `json:"side"` // slave, master or both
|
||||
}
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
j, err := s.eng.StartOrganizePreview(id, body.Side)
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 202, j)
|
||||
}
|
||||
|
||||
func (s *Server) handleOrganizePlan(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
plan, items := s.eng.OrganizePreview(id)
|
||||
if plan == nil {
|
||||
writeJSON(w, 200, map[string]any{"plan": nil, "items": []any{}})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"plan": plan, "items": items})
|
||||
}
|
||||
|
||||
func (s *Server) handleOrganizeApply(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
var body struct {
|
||||
IDs []int `json:"ids"` // empty: every rename in the preview
|
||||
}
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if !s.preAction(w, r, id, "rename files") {
|
||||
return
|
||||
}
|
||||
j, err := s.eng.StartOrganizeApply(id, body.IDs)
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
}
|
||||
s.log.Info("rename now started", "pair", id, "selected", len(body.IDs))
|
||||
writeJSON(w, 202, j)
|
||||
}
|
||||
Reference in new issue
Block a user