Add CSV export of scan results
The Review tab gets an Export CSV menu: "This view" exports the current status filter and search, "Entire scan" exports every scanned slave file. Each row carries status, match type, a recommendation, slave/master paths, the link target, the match reason, sizes, codec details for both sides, the quality verdict, notes, errors and timestamps. Rows are streamed in pages of 1000 so a large export never holds the database connection a running scan needs. The file starts with a UTF-8 BOM for Excel, and cells a spreadsheet would evaluate as formulas are prefixed with an apostrophe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5f895cab6a
commit
e8c534a3ae
6 files changed
+435
-6
No files matched your search
@@ -0,0 +1,179 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user