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  }