Files
playlink/internal/api/export.go
T
2026-10-01 12:36:22 -07:00

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