github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/cmd/nup/client/files/song.go (about) 1 // Copyright 2022 Daniel Erat. 2 // All rights reserved. 3 4 // Package files contains client code for reading song files. 5 package files 6 7 import ( 8 "errors" 9 "fmt" 10 "os" 11 "path/filepath" 12 "regexp" 13 "strconv" 14 "strings" 15 "time" 16 17 "github.com/derat/mpeg" 18 "github.com/derat/nup/cmd/nup/client" 19 "github.com/derat/nup/server/db" 20 "github.com/derat/taglib-go/taglib" 21 ) 22 23 const ( 24 // NonAlbumTracksValue is used as the album name for standalone recordings by Picard. 25 NonAlbumTracksValue = "[non-album tracks]" 26 27 albumIDTag = "MusicBrainz Album Id" // usually used as cover ID 28 coverIDTag = "nup Cover Id" // can be set for non-MusicBrainz tracks 29 recordingIDOwner = "http://musicbrainz.org" // UFID for Song.RecordingID 30 ) 31 32 // ReadSongFlag values can be masked together to configure ReadSong's behavior. 33 type ReadSongFlag uint32 34 35 const ( 36 // SkipAudioData indicates that audio data (used to compute the song's SHA1, 37 // duration, and gain adjustments) will not be read. 38 SkipAudioData ReadSongFlag = 1 << iota 39 // OnlyFileMetadata indicates that the returned db.Song object should only include 40 // metadata from the file's ID3 tag. cfg.ArtistRewrites and cfg.AlbumIDRewrites will 41 // not be used and metadata override files will not be read. 42 OnlyFileMetadata 43 ) 44 45 // ReadSong reads the song file at p and creates a Song object. 46 // If fi is non-nil, it will be used; otherwise the file will be stat-ed by this function. 47 // gc is only used if cfg.ComputeGains is true and flags does not contain SkipAudioData. 48 func ReadSong(cfg *client.Config, p string, fi os.FileInfo, flags ReadSongFlag, gc *GainsCache) (*db.Song, error) { 49 var relPath string 50 var err error 51 if cfg.MusicDir == "" { 52 return nil, errors.New("musicDir not set in config") 53 } else if abs, err := filepath.Abs(p); err != nil { 54 return nil, err 55 } else if !strings.HasPrefix(abs, cfg.MusicDir+"/") { 56 return nil, fmt.Errorf("%q isn't under %q", p, cfg.MusicDir) 57 } else if relPath, err = filepath.Rel(cfg.MusicDir, abs); err != nil { 58 return nil, err 59 } 60 61 f, err := os.Open(p) 62 if err != nil { 63 return nil, err 64 } 65 defer f.Close() 66 67 if fi == nil { 68 if fi, err = f.Stat(); err != nil { 69 return nil, err 70 } 71 } 72 73 s := db.Song{Filename: relPath} 74 75 var headerLen, footerLen int64 76 if tag, err := mpeg.ReadID3v1Footer(f, fi); err != nil { 77 return nil, err 78 } else if tag != nil { 79 footerLen = mpeg.ID3v1Length 80 s.Artist = tag.Artist 81 s.Title = tag.Title 82 s.Album = tag.Album 83 if year, err := strconv.Atoi(tag.Year); err == nil { 84 s.Date = time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC) 85 } 86 } 87 88 if tag, err := taglib.Decode(f, fi.Size()); err != nil { 89 // Tolerate missing ID3v2 tags if we got an artist and title from ID3v1. 90 if len(s.Artist) == 0 && len(s.Title) == 0 { 91 return nil, err 92 } 93 } else { 94 s.Artist = tag.Artist() 95 s.Title = tag.Title() 96 s.Album = tag.Album() 97 s.AlbumID = tag.CustomFrames()[albumIDTag] 98 s.CoverID = tag.CustomFrames()[coverIDTag] 99 s.RecordingID = tag.UniqueFileIdentifiers()[recordingIDOwner] 100 s.Track = int(tag.Track()) 101 s.Disc = int(tag.Disc()) 102 headerLen = int64(tag.TagSize()) 103 104 if date, err := getSongDate(tag); err != nil { 105 return nil, err 106 } else if !date.IsZero() { 107 s.Date = date 108 } 109 110 // ID3 v2.4 defines TPE2 (Band/orchestra/accompaniment) as 111 // "additional information about the performers in the recording". 112 // Only save the album artist if it's different from the track artist. 113 if aa, err := mpeg.GetID3v2TextFrame(tag, "TPE2"); err != nil { 114 return nil, err 115 } else if aa != s.Artist { 116 s.AlbumArtist = aa 117 } 118 119 // TSST (Set subtitle) contains the disc's subtitle. 120 // Most multi-disc albums don't have subtitles. 121 if s.DiscSubtitle, err = mpeg.GetID3v2TextFrame(tag, "TSST"); err != nil { 122 return nil, err 123 } 124 125 // Some old files might be missing the TPOS "part of set" frame. 126 // Assume that they're from a single-disc album in that case: 127 // https://github.com/derat/nup/issues/37 128 if s.Disc == 0 && s.Track > 0 && s.Album != NonAlbumTracksValue { 129 s.Disc = 1 130 } 131 } 132 133 if flags&OnlyFileMetadata == 0 { 134 if repl, ok := cfg.ArtistRewrites[s.Artist]; ok { 135 s.Artist = repl 136 } 137 if repl, ok := cfg.AlbumIDRewrites[s.AlbumID]; ok { 138 // Look for a cover image corresponding to the original ID as well. 139 // Don't bother setting this if the rewrite didn't actually change anything 140 // (i.e. it was just defined to set the disc number). 141 if s.CoverID == "" && s.AlbumID != repl { 142 s.CoverID = s.AlbumID 143 } 144 s.AlbumID = repl 145 146 // Extract the disc number and subtitle from the album name. 147 if album, disc, subtitle := extractAlbumDisc(s.Album); disc != 0 { 148 s.Album = album 149 s.Disc = disc 150 if s.DiscSubtitle == "" { 151 s.DiscSubtitle = subtitle 152 } 153 } 154 } 155 if err := applyMetadataOverride(cfg, &s); err != nil { 156 return nil, err 157 } 158 } 159 160 if flags&SkipAudioData != 0 { 161 return &s, nil 162 } 163 164 s.SHA1, err = mpeg.ComputeAudioSHA1(f, fi, headerLen, footerLen) 165 if err != nil { 166 return nil, err 167 } 168 dur, _, err := mpeg.ComputeAudioDuration(f, fi, headerLen, footerLen) 169 if err != nil { 170 return nil, err 171 } 172 s.Length = dur.Seconds() 173 174 if cfg.ComputeGain { 175 gain, err := gc.get(p, s.Album, s.AlbumID) 176 if err != nil { 177 return nil, err 178 } 179 s.TrackGain = gain.TrackGain 180 s.AlbumGain = gain.AlbumGain 181 s.PeakAmp = gain.PeakAmp 182 } 183 184 return &s, nil 185 } 186 187 // extractAlbumDisc attempts to extract a disc number and optional title from an album name. 188 // "Some Album (disc 2: The Second Disc)" is split into "Some Album", 2, and "The Second Disc". 189 // If disc information cannot be extracted, the original album name and 0 are returned. 190 func extractAlbumDisc(orig string) (album string, discNum int, discTitle string) { 191 ms := albumDiscRegexp.FindStringSubmatch(orig) 192 if ms == nil { 193 return orig, 0, "" 194 } 195 var err error 196 if discNum, err = strconv.Atoi(ms[1]); err != nil { 197 discNum = 0 198 } 199 return orig[:len(orig)-len(ms[0])], discNum, ms[2] 200 } 201 202 // albumDiscRegexp matches pre-NGS MusicBrainz album names used for multi-disc releases. 203 // The first subgroup contains the disc number, while the second subgroup contains 204 // the disc/medium title (if any). 205 var albumDiscRegexp = regexp.MustCompile(`\s+\(disc (\d+)(?::\s+([^)]+))?\)$`) 206 207 // getSongDate tries to extract a song's release or recording date. 208 func getSongDate(tag taglib.GenericTag) (time.Time, error) { 209 for _, tt := range []mpeg.TimeType{ 210 mpeg.OriginalReleaseTime, 211 mpeg.RecordingTime, 212 mpeg.ReleaseTime, 213 } { 214 if tm, err := mpeg.GetID3v2Time(tag, tt); err != nil { 215 return time.Time{}, err 216 } else if !tm.Empty() { 217 return tm.Time(), nil 218 } 219 } 220 return time.Time{}, nil 221 } 222 223 // IsMusicPath returns true if path p has an extension suggesting that it's a music file. 224 func IsMusicPath(p string) bool { 225 // TODO: Add support for other file types someday, maybe. 226 // At the very least, ReadSong would need to be updated to understand non-MPEG files. 227 return strings.ToLower(filepath.Ext(p)) == ".mp3" 228 }