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
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>
110 lines
3.1 KiB
Go
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
|
|
}
|