123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426 |
- //go:build id3v2
- // Read icedax .INF files for each track and change the ID3 tags accordingly.
- //
- // Licensed under a MIT license (https://gwyneth-llewelyn.mit-license.org/)
- package main
- import (
- // "bytes"
- "bufio"
- "fmt"
- // "io"
- "log"
- "os"
- "os/exec"
- "path/filepath"
- "strconv"
- "strings"
- "time"
- "github.com/bogem/id3v2/v2"
- "github.com/karrick/godirwalk"
- "github.com/karrick/golf" // flag replacement library
- )
- /*
- #.INF example file
- #created by icedax 1.1.11 07/01/23 10:07:54
- #
- CDINDEX_DISCID= '8_8DwRM2rLmumKtqXp59zmg4jkk-'
- CDDB_DISCID= 0x450fae05
- MCN=
- ISRC=
- #
- Albumperformer= 'CD005-Berliner Philharmoniker, Herbert von Karajan'
- Performer= 'CD005-Berliner Philharmoniker, Herbert von Karajan'
- Albumtitle= 'Beethoven: Symphony No.9'
- Tracktitle= 'Symphony No.9 in d op.125 - Presto - Allegro assai -'
- Tracknumber= 5
- Trackstart= 220380
- # track length in sectors (1/75 seconds each), rest samples
- Tracklength= 80702, 0
- Pre-emphasis= no
- Channels= 2
- Copy_permitted= once (copyright protected)
- Endianess= little
- # index list
- Index= 0
- Index0= -1
- // Tags on audio.cddb
- "DISCID": "",
- "DTITLE": "TALB", // may have multiple lines!!
- "DYEAR": "TDRC",
- "DGENRE": "TCON",
- // on each .inf file (one per track)
- "CDINDEX_DISCID": "",
- "CDDB_DISCID": "",
- "MCN": "",
- "ISRC": "TSRC",
- "Albumperformer": "TPE2",
- "Performer": "TPE3",
- "Albumtitle": "TALB",
- "Tracktitle": "TIT2",
- "Tracknumber": "TRCK",
- "Trackstart": "", // probably sectors
- "Tracklength": "TLEN", // sectors [1 sector = 1/75 sec], rest
- "Pre-emphasis": "", // yes/no
- "Channels": "", // 1: mono; 2: stereo
- "Copy_permitted": "", // once (copyright protected)
- "Endianess": "", // little/big
- "Index": "", // 0
- "Index0": "", // -1 */
- // instantiate a few global variables, to avoid memory leaks.
- var (
- dirPath string // path where the files are
- extension string // file extension (including dot); almost always ".inf"
- recursive bool // if set, walks through directories recursively
- noConvert bool // if set, will NOT launch ffmpeg
- dryRun bool // if set, doesn't save any tags (and enter debug mode)
- justRead bool // if set, just read tags from a single file (for debugging purposes)
- help bool // if set, show usage
- ffmpegPath string // path to ffmpeg executable
- genre string // album genre; must be provided via command line, since .inf doesn't have it.
- picture string // path to the album image, to set the .m4a to it
- pic id3v2.PictureFrame // an encoded picture for inserting into a .m4a as album cover.
- )
- func main() {
- // read flags, if any
- golf.StringVarP(&dirPath, 'd', "dir", ".", "directory to convert from")
- golf.BoolVarP(&recursive, 'r', "recursive", false, "traverse directory structure recursively")
- golf.StringVarP(&extension, 'e', "extension", ".inf", "icedax track info file extension")
- golf.StringVarP(&ffmpegPath, 'm', "ffmpeg", "/usr/local/bin/ffmpeg", "path to ffmpeg executable")
- golf.BoolVarP(&noConvert, 'n', "no-conversion", false, "If true, does not convert from .wav to .m4a with ffmpeg")
- golf.StringVarP(&genre, 'g', "genre", "", "override album genre (sometimes called 'content type')")
- golf.StringVarP(&picture, 'p', "picture", "", "path to album picture")
- golf.BoolVarP(&dryRun, 'x', "dry-run", false, "if true, does not save any tags to file (and enters debug mode)")
- golf.BoolVarP(&justRead, 'j', "just-read", false, "just read a .m4a file for debugging purposes")
- golf.BoolVarP(&help, 'h', "help", false, "show command usage")
- golf.Parse()
- if help {
- golf.Usage()
- os.Exit(0)
- }
- if justRead {
- os.Exit(justReadM4A(golf.Args()[0]))
- }
- // fmt.Printf("[DEBUG] Skip conversion mode set to %v; Dry run set to %v; Recursive set to %v\n", noConvert, dryRun, recursive)
- if !noConvert {
- // check of we can run ffmpeg
- path, err := exec.LookPath(ffmpegPath) // also search under the $PATH shell
- if err != nil {
- log.Panicf("couldn't find executable `ffmpeg` at %q, error was: %s", ffmpegPath, err)
- }
- fmt.Printf("`ffmpeg` is available at %s\n", path)
- }
- fmt.Printf("Now entering directory %q (recursive: %t)...\n\n", dirPath, recursive)
- // prepare image, if we got one:
- if picture != "" {
- artwork, err := os.ReadFile(picture)
- if err != nil {
- // abort if we cannot read the file, the user
- log.Fatalf("Error while reading artwork file %q: %s\nTry a different picture or check that the path is correct.\n", picture, err)
- }
- // see id3v2 documentation
- pic = id3v2.PictureFrame{
- Encoding: id3v2.EncodingUTF8,
- MimeType: "image/jpeg",
- PictureType: id3v2.PTFrontCover,
- Description: "Front cover",
- Picture: artwork,
- }
- }
- // tags we managed to extract successfully, either via audio.cddb or the .INF files.
- tags := make(map[string]string)
- // Before transversing this directory, we will attempt to read the audio.cddb file.
- // If we cannot find it, we'll have to assume sort-of-reasonable defaults...
- audioCddb, ferr := os.ReadFile(filepath.Join(dirPath, "audio.cddb"))
- if ferr != nil {
- fmt.Println("[WARN] audio.cddb file not found; proceeding with defaults. Error was: ", ferr)
- } else {
- var line, key, value string // one input line
- sc := bufio.NewScanner(strings.NewReader(string(audioCddb)))
- for sc.Scan() {
- line = sc.Text()
- // Drop comments
- if strings.HasPrefix(line, "#") {
- continue // skip comments
- }
- // key = value (see also below)
- // Value can be an empty string, of course!
- keyValue := strings.Split(line, "=")
- // missing "="?
- if len(keyValue) < 2 {
- fmt.Println("malformed line...")
- continue
- }
- key = strings.ToUpper(strings.Trim(keyValue[0], " \t\"'"))
- value = strings.Trim(keyValue[1], " \t\"'")
- // extract the values for each key, and set it on tags
- switch key {
- case "DISCID":
- tags["DISCID"] = value
- case "DTITLE":
- tags["DTITLE"] += value // note: multiple lines are possible!
- case "DYEAR":
- tags["DYEAR"] = value
- case "DGENRE":
- tags["DGENRE"] = value
- } // end switch key
- } // end for sc.Scan()
- } // end read file audio.cbbd
- // similar to executor code: transverse all files, check if we have .inf
- // then parse it, and eventually convert the files, too.
- // Note: this is done serially for now, but it *should+ become goroutines! (gwyneth 20230709)
- err := godirwalk.Walk(dirPath,
- &godirwalk.Options{
- Callback: func(osPathname string, de *godirwalk.Dirent) error {
- // skip directories/symlinks to directories (if not in recursive mode)
- isDir, dirErr := de.IsDirOrSymlinkToDir();
- if isDir && !recursive {
- if dirErr == nil {
- // return godirwalk.SkipThis
- return nil
- }
- fmt.Printf("error when trying to access directory/symlink %q: %s",
- osPathname, dirErr)
- return nil
- }
- // only read files ending in .inf
- // note: I was using HasSuffix here, but apparently filepath.Ext() is way faster! (gwyneth 20230706)
- if filepath.Ext(osPathname) != extension {
- return nil
- }
- // open INF file for reading, and scan it line by line into
- // an array of strings
- rawInfFile, err := os.Open(osPathname)
- if err != nil {
- log.Fatal(err)
- }
- fileScanner := bufio.NewScanner(rawInfFile)
- fileScanner.Split(bufio.ScanLines)
- var (
- fileLines []string // array of strings
- infFileLength int // length of array
- )
- for fileScanner.Scan() {
- fileLines = append(fileLines, fileScanner.Text())
- infFileLength++
- }
- rawInfFile.Close()
- var key, value string
- // now go through the array
- for i := 0; i < infFileLength; i++ {
- if strings.HasPrefix(fileLines[i], "#") {
- continue // skip comments
- }
- // .INF files have each line with key=value, with lots of space in-between (or not!)
- // Value often has quotes around but we'll ignore it for now (gwyneth 20230706)
- keyValue := strings.Split(fileLines[i], "=")
- // missing "="?
- if len(keyValue) < 2 {
- fmt.Printf("malformed line %d\n", i)
- continue
- }
- key = strings.Trim(keyValue[0], " \t\"'")
- value = strings.Trim(keyValue[1], " \t\"'")
- tags[key] = value
- } // end for i loop
- fmt.Printf("[DEBUG] Matched tags: %v\n", tags)
- fullFileNameBase := osPathname[:len(osPathname) - len(filepath.Ext(osPathname))]
- fullDir := filepath.Dir(osPathname)
- fileNameM4A := fullFileNameBase + ".m4a"
- var track int // track number, which will be used elsewhere
- // Deal with conversion
- if !noConvert {
- // icedax generates .wav and .inf with the same basename:
- fileNameWAV := fullFileNameBase + ".wav"
- // Do we know how the track is called? (from .inf file)
- // This should almost always be the case, unless the original CD
- // was completely borked and icedax couldn't find the track names at all.
- if tags["Tracktitle"] != "" && tags["Tracknumber"] != "" {
- track, err = strconv.Atoi(tags["Tracknumber"])
- if err != nil {
- fmt.Printf("invalid track number: %q, conversion error: %s", track, err)
- }
- fileNameM4A = filepath.Join(fullDir, fmt.Sprintf("%02d %s.m4a", track, tags["Tracktitle"]))
- }
- cmd := exec.Command(ffmpegPath, "-i", fileNameWAV, fileNameM4A)
- stdoutStderr, err := cmd.CombinedOutput()
- if err != nil {
- fmt.Printf("error while running %q: %q \n", ffmpegPath, err)
- return nil
- }
- fmt.Printf("✅ %s\n", stdoutStderr)
- }
- // open .m4a file
- m4aFile, err := id3v2.Open(fileNameM4A, id3v2.Options{Parse: true})
- if err != nil {
- if !dryRun {
- fmt.Println("[ERROR] ", err) // err includes filename
- } else {
- fmt.Println("[WARN] Couldn't open .m4a file (not generated?); cannot proceed with tag processing.")
- }
- return nil
- }
- defer m4aFile.Close()
- // See what ffmpeg already wrote
- fmt.Printf("[INFO] %d tag(s) found in existing .m4a file, taking %d bytes.\n",
- m4aFile.Count(), m4aFile.Size())
- m4aFile.SetDefaultEncoding(id3v2.EncodingUTF8)
- m4aFile.SetArtist(tags["Performer"]) // TPE3
- m4aFile.SetTitle(tags["Tracktitle"]) // TIT2
- // are we overriding the genre from the command line?
- if genre == "" {
- // no? then use whatever was on audio.cddb; could also be empty, of course.
- genre = tags["DGENRE"]
- }
- m4aFile.SetGenre(genre)
- m4aFile.SetVersion(4) // ID3v2 version; v4 is the latest
- m4aFile.SetYear(tags["DYEAR"])
- // Set comment frame.
- comment := id3v2.CommentFrame{
- Encoding: id3v2.EncodingUTF8,
- Language: "eng",
- Description: "Additional song IDs",
- Text: fmt.Sprintf("DISCID=%q\tCDINDEX_DISCID=%q\tCDDB_DISCID=%q\tMCN=%q",
- tags["DISCID"],
- tags["CDINDEX_DISCID"],
- tags["CDDB_DISCID"],
- tags["MCN"],
- ),
- }
- m4aFile.AddCommentFrame(comment)
- // if we have a picture, read it and attach it to .m4a file
- if picture != "" {
- m4aFile.AddAttachedPicture(pic)
- }
- // other tags from .INF file
- switch true {
- case tags["Albumtitle"] != "":
- m4aFile.AddTextFrame(m4aFile.CommonID("Album/Movie/Show title"),
- id3v2.EncodingUTF8, tags["Albumtitle"])
- case tags["Albumperformer"] != "":
- m4aFile.AddTextFrame(m4aFile.CommonID("Band/Orchestra/Accompaniment"),
- id3v2.EncodingUTF8, tags["Albumperformer"])
- case tags["ISRC"] != "":
- m4aFile.AddTextFrame(m4aFile.CommonID("ISRC"), id3v2.EncodingUTF8, tags["ISRC"])
- // other tags we have from the .inf file
- case tags["Tracknumber"] != "":
- m4aFile.AddTextFrame(m4aFile.CommonID("Track number/Position in set"),
- id3v2.EncodingUTF8, tags["Tracknumber"])
- case tags["Tracklength"] != "":
- var tlen, ignore int // Track length, in sectors
- _, err := fmt.Sscanf(tags["Tracklength"], "%d, %d", tlen, ignore)
- if err != nil {
- fmt.Printf("[WARN] Track length not found! Error was: %s\n", err)
- } else {
- m4aFile.AddTextFrame(m4aFile.CommonID("Length"),
- id3v2.EncodingUTF8,
- strconv.FormatFloat(float64(tlen) / 75, 'f', 2, 64))
- }
- }
- m4aFile.AddTextFrame(m4aFile.CommonID("Software/Hardware and settings used for encoding"),
- id3v2.EncodingUTF8,
- "ffmpeg version 6.0")
- // basically an experiment. TOFN is the original file name, in case someone borks the
- // filename and needs to get it back somehow
- m4aFile.AddTextFrame("TOFN", id3v2.EncodingUTF8,
- fmt.Sprintf("%02d %s.m4a", track, tags["Tracktitle"]))
- // capture a timestamp to embed as the date the tags were generated
- m4aFile.AddTextFrame(m4aFile.CommonID("Tagging time"),
- id3v2.EncodingUTF8,
- time.Now().UTC().Format(time.RFC3339))
- fmt.Printf("[INFO] After reading .inf file, we now have %d tag(s), taking %d bytes.\n",
- m4aFile.Count(), m4aFile.Size())
- fmt.Printf("[DEBUG] All the frames added by inf2ffmpeg: %#v\n",
- m4aFile.AllFrames())
- // Write everything to file, unless --dry-run is set
- if !dryRun {
- m4err := m4aFile.Save()
- if m4err != nil {
- log.Printf("[ERROR] Couldn't save tags to %q: %s\n", fileNameM4A, m4err)
- } else {
- fmt.Println("✅ All tags successfully saved to ", fileNameM4A)
- }
- }
- return nil
- }, // ends Callback
- Unsorted: false, // (optional) set true for faster yet non-deterministic enumeration (see godoc)
- }) // end options for dirwalk
- if err != nil {
- log.Printf("sorry, got error %s\n", err)
- }
- }
- // justReadM4A is a debugging tool to read a .m4a and show what tags it contains.
- func justReadM4A(fileNameM4A string) int {
- m4aFile, err := id3v2.Open(fileNameM4A, id3v2.Options{Parse: true})
- if err != nil {
- log.Println(err)
- os.Exit(66) // proper exit status code for file not found
- }
- defer m4aFile.Close()
- log.Printf("[INFO] %d tag(s) found in %q, taking %d bytes.\n",
- m4aFile.Count(), fileNameM4A, m4aFile.Size())
- fmt.Println("Title:", m4aFile.Title())
- fmt.Println("Artist:", m4aFile.Artist())
- fmt.Println("Year:", m4aFile.Year())
- fmt.Println("Genre:", m4aFile.Genre())
- fmt.Println("ID3 version:", m4aFile.Version())
- fmt.Println("Other frames found:")
- allTags := m4aFile.AllFrames()
- for i, aFrame := range allTags {
- fmt.Printf("%s: %#v\nTags for this frame:\n", i, aFrame)
- for j, aTag := range aFrame {
- fmt.Printf("\t%s: %02d - %v\n", i, j, aTag.UniqueIdentifier())
- }
- }
- log.Printf("[INFO] Finished parsing %q.\n",
- fileNameM4A)
- return 0 // 1 is the status error for 'generic' failure
- }
|