//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 }