This reverts commit 4bae674.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
180 lines
5.1 KiB
Go
180 lines
5.1 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/cdnutter/playlink/internal/engine"
|
|
"github.com/cdnutter/playlink/internal/probe"
|
|
"github.com/cdnutter/playlink/internal/store"
|
|
)
|
|
|
|
var kindLabels = map[string]string{
|
|
store.KindPath: "same path",
|
|
store.KindPathCase: "path differs in case",
|
|
store.KindExtension: "other format",
|
|
store.KindFuzzy: "similar title",
|
|
store.KindElsewhere: "same top folder, other subfolder",
|
|
store.KindApprox: "approximate match",
|
|
store.KindNone: "no match",
|
|
}
|
|
|
|
var exportHeader = []string{
|
|
"status", "match_type", "ignored", "recommendation",
|
|
"slave_path", "master_path", "link_target", "reason",
|
|
"slave_size_bytes", "master_size_bytes",
|
|
"slave_codec", "slave_lossless", "slave_bitrate_kbps", "slave_sample_rate_hz", "slave_bit_depth", "slave_duration_s",
|
|
"master_codec", "master_lossless", "master_bitrate_kbps", "master_sample_rate_hz", "master_bit_depth", "master_duration_s",
|
|
"better_copy", "duration_difference_s", "notes", "error",
|
|
"slave_modified", "master_modified", "checked_at",
|
|
}
|
|
|
|
// csvSafe neutralises cells a spreadsheet would evaluate as a formula (a file
|
|
// named "=HYPERLINK(...).mp3" must stay text).
|
|
func csvSafe(s string) string {
|
|
if s != "" && strings.ContainsRune("=+-@\t\r", rune(s[0])) {
|
|
return "'" + s
|
|
}
|
|
return s
|
|
}
|
|
|
|
func recommendation(m store.Match, v probe.Verdict) string {
|
|
switch {
|
|
case m.Ignored:
|
|
return "ignored"
|
|
case m.Status == store.StatusLinked:
|
|
return "done"
|
|
case m.Status == store.StatusIdentical:
|
|
return "link (identical)"
|
|
case m.Status == store.StatusDifferent || m.Status == store.StatusSimilar:
|
|
switch v.Better {
|
|
case "master":
|
|
return "upgrade to master copy"
|
|
case "slave":
|
|
return "review: master is lower quality"
|
|
}
|
|
return "review"
|
|
case m.Status == store.StatusError:
|
|
return "fix error"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func probeCols(raw json.RawMessage) []string {
|
|
if len(raw) == 0 {
|
|
return make([]string, 6)
|
|
}
|
|
var p probe.Info
|
|
if json.Unmarshal(raw, &p) != nil || p.Error != "" {
|
|
return make([]string, 6)
|
|
}
|
|
num := func(n int) string {
|
|
if n == 0 {
|
|
return ""
|
|
}
|
|
return strconv.Itoa(n)
|
|
}
|
|
bitrate := ""
|
|
if p.Bitrate > 0 {
|
|
bitrate = strconv.FormatInt((p.Bitrate+500)/1000, 10)
|
|
}
|
|
dur := ""
|
|
if p.Duration > 0 {
|
|
dur = strconv.FormatFloat(p.Duration, 'f', 1, 64)
|
|
}
|
|
return []string{p.Codec, strconv.FormatBool(p.Lossless), bitrate, num(p.SampleRate), num(p.BitDepth), dur}
|
|
}
|
|
|
|
func nsTime(ns int64) string {
|
|
if ns == 0 {
|
|
return ""
|
|
}
|
|
return time.Unix(0, ns).UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
var unsafeName = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
|
|
|
|
// handleExport streams the pair's scan results as CSV. It honours the same
|
|
// status and search filters as the Review list.
|
|
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
p, err := s.st.Pair(r.Context(), id)
|
|
if err != nil {
|
|
opErr(w, err)
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
f := store.MatchFilter{Status: q.Get("status"), Query: q.Get("q")}
|
|
|
|
scope := f.Status
|
|
if scope == "" {
|
|
scope = "all"
|
|
}
|
|
slug := strings.Trim(unsafeName.ReplaceAllString(p.Name, "-"), "-")
|
|
if slug == "" {
|
|
slug = fmt.Sprintf("pair%d", p.ID)
|
|
}
|
|
name := fmt.Sprintf("playlink-%s-%s-%s.csv", slug, scope, time.Now().Format("20060102-1504"))
|
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, name, url.PathEscape(name)))
|
|
|
|
// A UTF-8 byte order mark makes Excel read non-ASCII names correctly.
|
|
_, _ = w.Write([]byte{0xEF, 0xBB, 0xBF})
|
|
cw := csv.NewWriter(w)
|
|
_ = cw.Write(exportHeader)
|
|
rows := 0
|
|
err = s.st.ExportMatches(r.Context(), id, f, func(m store.Match) error {
|
|
var v probe.Verdict
|
|
_ = json.Unmarshal(m.Verdict, &v)
|
|
target := ""
|
|
if m.RelMaster != "" && m.Status != store.StatusLinked {
|
|
target = engine.LinkTarget(m.RelSlave, m.RelMaster, false)
|
|
}
|
|
size := func(n int64) string {
|
|
if n == 0 {
|
|
return ""
|
|
}
|
|
return strconv.FormatInt(n, 10)
|
|
}
|
|
delta := ""
|
|
if v.DurationDelta > 0 {
|
|
delta = strconv.FormatFloat(v.DurationDelta, 'f', 1, 64)
|
|
}
|
|
rec := []string{
|
|
m.Status, kindLabels[m.Kind], strconv.FormatBool(m.Ignored), recommendation(m, v),
|
|
csvSafe(m.RelSlave), csvSafe(m.RelMaster), csvSafe(target), csvSafe(m.Reason),
|
|
size(m.SlaveSize), size(m.MasterSize),
|
|
}
|
|
rec = append(rec, probeCols(m.SlaveProbe)...)
|
|
rec = append(rec, probeCols(m.MasterProbe)...)
|
|
better := v.Better
|
|
if better == "unknown" {
|
|
better = ""
|
|
}
|
|
rec = append(rec, better, delta, csvSafe(strings.Join(v.Notes, "; ")), csvSafe(m.Note),
|
|
nsTime(m.SlaveMtime), nsTime(m.MasterMtime), time.Unix(m.UpdatedAt, 0).UTC().Format(time.RFC3339))
|
|
rows++
|
|
if err := cw.Write(rec); err != nil {
|
|
return err
|
|
}
|
|
if rows%500 == 0 {
|
|
cw.Flush()
|
|
}
|
|
return cw.Error()
|
|
})
|
|
cw.Flush()
|
|
if err != nil {
|
|
// Headers are already sent; the truncated file is the only signal left.
|
|
s.log.Warn("csv export interrupted", "pair", id, "rows", rows, "err", err)
|
|
return
|
|
}
|
|
s.log.Info("csv export", "pair", p.Name, "filter", scope, "rows", rows)
|
|
}
|