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