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 }