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