Files
minstrel/internal/importer/place.go
T
bvandeusenandClaude Opus 5.5 80ecc2f7e7
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 5m1s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
fix: imported FLAC, Ogg and Opus files keep their track and disc numbers (#6171)
The importer wrote track=N/M for every format, which ffmpeg stores in a
Vorbis comment as TRACKNUMBER=3/20; dhowden/tag reads a Vorbis TRACKNUMBER
with a bare Atoi, so it came back 0. All 245 tracks imported since
2026-10-10 (Humanz, Plastic Beach, the Gorillaz super deluxe and twelve
more albums) have no track or disc number, and their album pages have no
order.

- The importer writes a Vorbis track and disc number alone, with
  TRACKTOTAL and DISCTOTAL, as Picard does. ID3 and MP4 keep N/M.
- ReadTrackAndDisc takes the number before a slash when the Vorbis reader
  returns 0, for the scanner and for the import's own check of a download.
- tagReadVersion 6 re-reads the files already held (tag reads only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:49:44 -04:00

211 lines
6.9 KiB
Go

package importer
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// ErrOccupied: the destination already has a file. Nothing is overwritten.
var ErrOccupied = errors.New("importer: destination already holds a file")
// tagTimeout bounds one ffmpeg run. A remux copies the audio untouched, so a
// whole album track takes well under a second on local disk; two minutes is
// for a slow network mount, not for ffmpeg.
const tagTimeout = 2 * time.Minute
// Tags are the names written into an imported file. Empty values are left
// out; the file's own tags stay for everything not named here.
type Tags struct {
Title, Artist, Album, AlbumArtist string
Year int
Disc, Discs, Track, Tracks int
// MusicBrainz ids, written the way Picard writes them.
ReleaseMBID, GroupMBID, RecordingMBID, ArtistMBID string
}
// args turns the tags into ffmpeg -metadata pairs for a file of this
// extension. Vorbis comments (FLAC, Ogg, Opus) take Picard's key names;
// ID3 takes Picard's TXXX descriptions, which ffmpeg writes for any key it
// has no frame for. MP4's freeform atoms ffmpeg cannot write, so an M4A
// gets the plain names only.
//
// A Vorbis track or disc number is the number alone, its count in
// TRACKTOTAL and DISCTOTAL, as Picard writes them: dhowden/tag reads
// TRACKNUMBER "3/20" as 0, and every album imported before #6171 lost its
// order that way. ID3 and MP4 take "3/20", which their readers parse.
func (t Tags) args(ext string) []string {
vorbis := ext == ".flac" || ext == ".ogg" || ext == ".opus"
track, disc := ofN(t.Track, t.Tracks), ofN(t.Disc, t.Discs)
if vorbis {
track, disc = ofN(t.Track, 0), ofN(t.Disc, 0)
}
kv := [][2]string{
{"title", t.Title}, {"artist", t.Artist}, {"album", t.Album}, {"album_artist", t.AlbumArtist},
{"track", track}, {"disc", disc},
}
if t.Year > 0 {
kv = append(kv, [2]string{"date", strconv.Itoa(t.Year)})
}
switch ext {
case ".flac", ".ogg", ".opus":
if t.Track > 0 && t.Tracks > 0 {
kv = append(kv, [2]string{"TRACKTOTAL", strconv.Itoa(t.Tracks)})
}
if t.Disc > 0 && t.Discs > 0 {
kv = append(kv, [2]string{"DISCTOTAL", strconv.Itoa(t.Discs)})
}
kv = append(kv,
[2]string{"MUSICBRAINZ_ALBUMID", t.ReleaseMBID},
[2]string{"MUSICBRAINZ_RELEASEGROUPID", t.GroupMBID},
[2]string{"MUSICBRAINZ_TRACKID", t.RecordingMBID},
[2]string{"MUSICBRAINZ_ARTISTID", t.ArtistMBID},
[2]string{"MUSICBRAINZ_ALBUMARTISTID", t.ArtistMBID})
case ".mp3":
kv = append(kv,
[2]string{"MusicBrainz Album Id", t.ReleaseMBID},
[2]string{"MusicBrainz Release Group Id", t.GroupMBID},
[2]string{"MusicBrainz Track Id", t.RecordingMBID},
[2]string{"MusicBrainz Artist Id", t.ArtistMBID},
[2]string{"MusicBrainz Album Artist Id", t.ArtistMBID})
}
var out []string
for _, p := range kv {
if p[1] != "" {
out = append(out, "-metadata", p[0]+"="+p[1])
}
}
return out
}
func ofN(n, of int) string {
switch {
case n <= 0:
return ""
case of > 0:
return fmt.Sprintf("%d/%d", n, of)
}
return strconv.Itoa(n)
}
// taggable: the formats ffmpeg can rewrite tags in by remuxing.
var taggable = map[string]bool{".flac": true, ".ogg": true, ".opus": true, ".mp3": true, ".m4a": true}
// Place copies src to dst with tags written, leaving src as it was (the
// download keeps seeding). The copy is written to a dot-named temp file
// beside dst, which the scanner skips, and renamed into place, so the
// library never sees half a file. Tags are written by an ffmpeg remux that
// copies the audio untouched; when ffmpeg cannot (it is missing, or the
// container will not take the file's streams) the file is copied as it is
// and tagged reports false. An existing dst is never overwritten.
func Place(ctx context.Context, src, dst string, t Tags) (tagged bool, err error) {
return placeFile(ctx, src, dst, t, false)
}
// Overwrite is Place onto a file an upgrade replaces (#5524): the new copy
// takes the old one's path in one rename, so the library never sees the
// path empty, and the track row at that path, history and all, carries on
// with the new file.
func Overwrite(ctx context.Context, src, dst string, t Tags) (tagged bool, err error) {
return placeFile(ctx, src, dst, t, true)
}
func placeFile(ctx context.Context, src, dst string, t Tags, overwrite bool) (tagged bool, err error) {
if _, err := os.Lstat(dst); err == nil && !overwrite {
return false, fmt.Errorf("%w: %s", ErrOccupied, dst)
}
dir := filepath.Dir(dst)
if err := os.MkdirAll(dir, 0o755); err != nil {
return false, fmt.Errorf("make %s: %w", dir, err)
}
ext := strings.ToLower(filepath.Ext(dst))
tmp := filepath.Join(dir, ".minstrel-import-"+randomHex()+ext)
defer func() { _ = os.Remove(tmp) }() // gone already once renamed
if taggable[ext] {
tagged = remux(ctx, src, tmp, t.args(ext)) == nil
}
if !tagged {
if err := copyFile(src, tmp); err != nil {
return false, err
}
}
if err := os.Chmod(tmp, 0o644); err != nil {
return false, fmt.Errorf("chmod %s: %w", tmp, err)
}
if _, err := os.Lstat(dst); err == nil && !overwrite {
return false, fmt.Errorf("%w: %s", ErrOccupied, dst)
}
if err := os.Rename(tmp, dst); err != nil {
return false, fmt.Errorf("move into place %s: %w", dst, err)
}
return tagged, nil
}
// remux writes src to dst through ffmpeg with every stream copied as it is
// (the cover art too) and the tags set. Its output is checked against the
// source's size: a remux shifts container bytes, never the audio, so a file
// much smaller than its source lost something on the way.
func remux(ctx context.Context, src, dst string, meta []string) error {
ctx, cancel := context.WithTimeout(ctx, tagTimeout)
defer cancel()
args := append([]string{"-nostdin", "-v", "error", "-y", "-i", src,
"-map", "0", "-c", "copy", "-map_metadata", "0"}, meta...)
args = append(args, dst)
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
if out, err := cmd.CombinedOutput(); err != nil {
_ = os.Remove(dst)
return fmt.Errorf("ffmpeg: %w: %s", err, strings.TrimSpace(string(out)))
}
si, err := os.Stat(src)
if err != nil {
return err
}
di, err := os.Stat(dst)
if err != nil {
return err
}
if di.Size() < si.Size()/2 {
_ = os.Remove(dst)
return fmt.Errorf("ffmpeg wrote %d bytes from %d", di.Size(), si.Size())
}
return nil
}
// copyFile copies src to dst byte for byte and syncs it.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer func() { _ = in.Close() }()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return fmt.Errorf("copy %s: %w", src, err)
}
if err := out.Sync(); err != nil {
_ = out.Close()
return err
}
return out.Close()
}
func randomHex() string {
b := make([]byte, 6)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}