Files
bvandeusenandClaude Opus 5.5 3a16732883
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 17s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m9s
release / Build signed APK (releases and dev) (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
feat: Minstrel no longer talks to Lidarr: its connection, Delete via Lidarr and the unmonitor options are gone, and the track reports are plain quarantine (M502 #5525)
Migration 0091 drops lidarr_config and renames the quarantine tables, types,
indexes and the history column off the Lidarr name. The Integrations Lidarr
card, the Reports "Delete via Lidarr" action, the merge and remove-track
unmonitor options, the Lidarr audit actions and error codes go, on the
server, web and Android. The README describes acquisition as it is now.

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

110 lines
3.1 KiB
Go

package importer
import (
"fmt"
"path/filepath"
"strings"
"unicode/utf8"
)
// Placement is where one file goes and the names written into its tags.
type Placement struct {
File int // index into the files
Dest string // absolute
Title string
Disc int
Discs int
Track int
Tracks int // on its disc
// Recording is the MusicBrainz recording the file was matched to.
Recording string
}
// Plan names every file's destination under root the way the library is
// already named (note #5527), so new arrivals sit beside what is there:
//
// {Artist}/{Album} ({Year})/{Artist} - {Album} - {track:00} - {Title}.ext
//
// with a "{Medium Format} {medium:00}" folder between when the edition has
// more than one medium. A file the check paired to a track takes the track's
// name and place; one it did not (an "import anyway" with more files than
// tracks) keeps its own tags.
func Plan(root string, w Want, e *Edition, files []File, pairs []Pair) []Placement {
album := w.Title
if w.Year > 0 {
album = fmt.Sprintf("%s (%d)", w.Title, w.Year)
}
dir := filepath.Join(root, Segment(w.Artist), Segment(album))
media := 1
if e != nil {
media = max(e.Media(), 1)
}
perDisc := map[int]int{}
if e != nil {
for _, t := range e.Tracks {
perDisc[t.Medium]++
}
}
paired := map[int]Pair{}
for _, p := range pairs {
paired[p.File] = p
}
out := make([]Placement, 0, len(files))
for i, f := range files {
pl := Placement{File: i, Title: f.Title, Disc: max(f.Disc, 1), Track: f.Track, Discs: media}
format := ""
if p, ok := paired[i]; ok && e != nil {
t := e.Tracks[p.Track]
pl.Title, pl.Disc, pl.Track, pl.Recording, format = t.Title, max(t.Medium, 1), t.Position, t.RecordingMBID, t.Format
pl.Tracks = perDisc[t.Medium]
}
if pl.Title == "" {
pl.Title = strings.TrimSuffix(filepath.Base(f.Path), filepath.Ext(f.Path))
}
d := dir
if media > 1 {
if format == "" {
format = "Disc"
}
d = filepath.Join(dir, Segment(fmt.Sprintf("%s %02d", format, pl.Disc)))
}
name := fmt.Sprintf("%s - %s - %02d - %s", w.Artist, w.Title, pl.Track, pl.Title)
pl.Dest = filepath.Join(d, Segment(name)+strings.ToLower(filepath.Ext(f.Path)))
out = append(out, pl)
}
return out
}
// segmentMax keeps a folder or file name inside the 255 bytes most
// filesystems allow, with room for an extension and a temp prefix.
const segmentMax = 200
var illegal = strings.NewReplacer(
`\`, "+", "/", "+", "<", "", ">", "", "?", "!", "*", "-", "|", "", `"`, "",
// The "smart" colon: "Title: Subtitle" reads "Title - Subtitle".
": ", " - ", ":", "-",
)
// Segment makes a name safe as one path segment, the way the *arr programs
// clean file names: their illegal-character mapping, no leading or trailing
// spaces or dots, at most segmentMax bytes, never "" or "..".
func Segment(s string) string {
s = illegal.Replace(s)
s = strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, s)
s = strings.Trim(s, " .")
for len(s) > segmentMax {
_, size := utf8.DecodeLastRuneInString(s)
s = strings.TrimRight(s[:len(s)-size], " .")
}
if s == "" {
return "_"
}
return s
}