// Package sheet renders and parses the one document a person edits to describe // an album before it is filed. // // It replaces the narrower track-name editor that came first. That one could // change the two folder names and the text after the track number, and nothing // else: the album title, the album artist, the year, the disc and track numbers // and the artist on a single track were all decided from the tags and could only // be fixed by retagging the files. Everything intake is about to write is // editable here instead, in one file, at any point before the album is filed. // // The format is deliberately dull: 'field: value' lines, whole-line '#' comments, // and '## n' to begin a track. Nothing is positional, so a block can be moved or // deleted without the rest shifting underneath it; a value may contain '#' and // ':' freely because a comment is only ever a whole line. package sheet import ( "errors" "fmt" "strconv" "strings" ) // Track is one file in the album. ID is the identity: it is the position of the // track in the plan as it was when the sheet was written, and it is what ties an // edited block back to the audio it describes. It is deliberately not the file // name, which the user may change, nor the track number, which they may also // change, nor the row order, which they may sort. type Track struct { ID int Title string Artist string Disc int Number int File string // the exact name to write; empty means derive it from the title Source string // shown as a comment; not editable Derived string // the name intake would use with an empty File; a comment } type Sheet struct { Album string AlbumArtist string ArtistDir string AlbumDir string Year string // Cover is a path to an image, the word "placeholder", or "none". Only the // caller knows what to do with it. Cover string Tracks []Track } // Changes says which fields differ from the sheet the user started with, so a // caller can tell "left alone" from "deliberately set to this value". type Changes struct { Album bool AlbumArtist bool ArtistDir bool AlbumDir bool Year bool Cover bool } func (s Sheet) ChangedFrom(before Sheet) Changes { return Changes{ Album: s.Album != before.Album, AlbumArtist: s.AlbumArtist != before.AlbumArtist, ArtistDir: s.ArtistDir != before.ArtistDir, AlbumDir: s.AlbumDir != before.AlbumDir, Year: s.Year != before.Year, Cover: s.Cover != before.Cover, } } // Any reports whether anything at all was changed. func (c Changes) Any() bool { return c.Album || c.AlbumArtist || c.ArtistDir || c.AlbumDir || c.Year || c.Cover } const header = `# intake album sheet # # Edit anything here and save. intake throws away everything it read from the # tags and uses exactly what this file says, so a wrong album artist or a # missing year is fixed here rather than by retagging the files. # # Whole lines beginning with '#' are comments. A '#' or a ':' inside a value is # fine. Save an empty file to leave the album alone. # # album ALBUM tag, and the folder name unless albumdir says otherwise # albumartist ALBUMARTIST tag, and the artist folder unless artistdir says # otherwise # artistdir the folder under archive/ and library/ # albumdir the folder inside artistdir # year DATE tag, four digits, or empty # cover a path to an image, or 'placeholder', or 'none' # # Each '## n' block is one file. 'n' says which file, and must not change. # Delete a whole block to leave that file out of the album. # # title TITLE tag; the file name is made from it # artist ARTIST tag; empty means the album artist # disc disc number # track track number, which is the number at the front of the file name # file the exact name to write; empty means intake names it from the # title. Uncomment the commented line to set it by hand. ` // Render writes the sheet a person edits. func Render(s Sheet) string { var b strings.Builder b.WriteString(header) b.WriteString("\n") fmt.Fprintf(&b, "album: %s\n", s.Album) fmt.Fprintf(&b, "albumartist: %s\n", s.AlbumArtist) fmt.Fprintf(&b, "artistdir: %s\n", s.ArtistDir) fmt.Fprintf(&b, "albumdir: %s\n", s.AlbumDir) fmt.Fprintf(&b, "year: %s\n", s.Year) fmt.Fprintf(&b, "cover: %s\n", s.Cover) for _, t := range s.Tracks { fmt.Fprintf(&b, "\n## %d\n", t.ID) if t.Source != "" { fmt.Fprintf(&b, "# from: %s\n", t.Source) } fmt.Fprintf(&b, "title: %s\n", t.Title) fmt.Fprintf(&b, "artist: %s\n", t.Artist) fmt.Fprintf(&b, "disc: %d\n", t.Disc) fmt.Fprintf(&b, "track: %d\n", t.Number) if t.File != "" { fmt.Fprintf(&b, "file: %s\n", t.File) continue } // Commented so that the common case stays empty and the line is there to // uncomment when a file name really should differ from the title. name := t.Derived if name == "" { name = "(from the title)" } fmt.Fprintf(&b, "# file: %s\n", name) } return b.String() } var albumFields = []string{"album", "albumartist", "artistdir", "albumdir", "year", "cover"} var trackFields = []string{"title", "artist", "disc", "track", "file"} // Parse reads an edited sheet back. initial supplies the track identities and // every value that was not touched. Only whole lines are comments, so a title // containing '#' survives. func Parse(text string, initial Sheet) (Sheet, error) { out := initial out.Tracks = make([]Track, len(initial.Tracks)) copy(out.Tracks, initial.Tracks) present := make([]bool, len(out.Tracks)) byID := make(map[int]int, len(out.Tracks)) for i, t := range out.Tracks { byID[t.ID] = i } current := -1 for n, raw := range strings.Split(text, "\n") { line := n + 1 trimmed := strings.TrimSpace(strings.TrimRight(raw, "\r")) if trimmed == "" { continue } // '##' before '#' or a track heading looks like a comment. if !strings.HasPrefix(trimmed, "##") && strings.HasPrefix(trimmed, "#") { continue } if strings.HasPrefix(trimmed, "##") { id, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(trimmed, "##"))) if err != nil { return Sheet{}, fmt.Errorf("line %d: %q is not a track number", line, trimmed) } i, ok := byID[id] if !ok { return Sheet{}, fmt.Errorf("line %d: there is no track %d; the number after '##' says which file the block is about and must not change", line, id) } if present[i] { return Sheet{}, fmt.Errorf("line %d: track %d appears twice", line, id) } present[i] = true current = i continue } key, value, ok := strings.Cut(trimmed, ":") if !ok { return Sheet{}, fmt.Errorf("line %d: %q is not a 'field: value' line and does not begin a track", line, trimmed) } key = strings.ToLower(strings.TrimSpace(key)) value = strings.TrimSpace(value) if current < 0 { switch key { case "album": out.Album = value case "albumartist": out.AlbumArtist = value case "artistdir": out.ArtistDir = value case "albumdir": out.AlbumDir = value case "year": out.Year = value case "cover": out.Cover = value default: if contains(trackFields, key) { return Sheet{}, fmt.Errorf("line %d: %q belongs inside a track block, after a '## n' line", line, key) } return Sheet{}, fmt.Errorf("line %d: unknown field %q; the album fields are %s", line, key, strings.Join(albumFields, ", ")) } continue } switch key { case "title": out.Tracks[current].Title = value case "artist": out.Tracks[current].Artist = value case "file": out.Tracks[current].File = value case "disc": v, err := strconv.Atoi(value) if err != nil || v < 1 { return Sheet{}, fmt.Errorf("line %d: disc %q is not a number of 1 or more", line, value) } out.Tracks[current].Disc = v case "track": v, err := strconv.Atoi(value) if err != nil || v < 1 { return Sheet{}, fmt.Errorf("line %d: track %q is not a number of 1 or more", line, value) } out.Tracks[current].Number = v default: if contains(albumFields, key) { return Sheet{}, fmt.Errorf("line %d: %q is an album field and belongs at the top of the file, before the first '## n' line", line, key) } return Sheet{}, fmt.Errorf("line %d: unknown track field %q; the track fields are %s", line, key, strings.Join(trackFields, ", ")) } } kept := make([]Track, 0, len(out.Tracks)) for i, t := range out.Tracks { if !present[i] { continue } t.Title = strings.TrimSpace(t.Title) t.Artist = strings.TrimSpace(t.Artist) t.File = strings.TrimSpace(t.File) kept = append(kept, t) } if len(kept) == 0 { return Sheet{}, errors.New("no tracks are left in the sheet; an empty sheet leaves the album alone") } out.Tracks = kept return out, nil } func contains(list []string, s string) bool { for _, v := range list { if v == s { return true } } return false }