release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m20s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 27s
release / Verify release artifacts (tag releases only) (push) Skipped
- acquisition_settings.upgrade_until (default High quality lossy, Lidarr's cutoff, note #5527; "" is off), an "Upgrade until" row under Searching. - The wanted worker judges held albums hourly: tier per format from size per second (the scanner records no bitrate), the album's worst. Below the cutoff it is wanted for quality; a video rip among its tracks always. - The schedule searches upgrades weekly, after open wants; a release no better than the copy held is refused ("not_better"). - The import matches each file to the track it would replace (recording, then disc/track) and replaces it when it is a rip or the file is a better tier: over the same path in one rename, or filed beside it and the old track folded in (library.ReplaceTrack: file first, then foldTrackInto). Nothing better: the release is set aside. - Collection › Upgrades (Lidarr's Cutoff Unmet); Suspect sources says when a clean copy was looked for instead of Lidarr's search. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
6.2 KiB
Go
195 lines
6.2 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.
|
|
func (t Tags) args(ext string) []string {
|
|
kv := [][2]string{
|
|
{"title", t.Title}, {"artist", t.Artist}, {"album", t.Album}, {"album_artist", t.AlbumArtist},
|
|
{"track", ofN(t.Track, t.Tracks)}, {"disc", ofN(t.Disc, t.Discs)},
|
|
}
|
|
if t.Year > 0 {
|
|
kv = append(kv, [2]string{"date", strconv.Itoa(t.Year)})
|
|
}
|
|
switch ext {
|
|
case ".flac", ".ogg", ".opus":
|
|
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)
|
|
}
|