inf2ffmpeg-deprecated.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. //go:build id3v2
  2. // Read icedax .INF files for each track and change the ID3 tags accordingly.
  3. //
  4. // Licensed under a MIT license (https://gwyneth-llewelyn.mit-license.org/)
  5. package main
  6. import (
  7. // "bytes"
  8. "bufio"
  9. "fmt"
  10. // "io"
  11. "log"
  12. "os"
  13. "os/exec"
  14. "path/filepath"
  15. "strconv"
  16. "strings"
  17. "time"
  18. "github.com/bogem/id3v2/v2"
  19. "github.com/karrick/godirwalk"
  20. "github.com/karrick/golf" // flag replacement library
  21. )
  22. /*
  23. #.INF example file
  24. #created by icedax 1.1.11 07/01/23 10:07:54
  25. #
  26. CDINDEX_DISCID= '8_8DwRM2rLmumKtqXp59zmg4jkk-'
  27. CDDB_DISCID= 0x450fae05
  28. MCN=
  29. ISRC=
  30. #
  31. Albumperformer= 'CD005-Berliner Philharmoniker, Herbert von Karajan'
  32. Performer= 'CD005-Berliner Philharmoniker, Herbert von Karajan'
  33. Albumtitle= 'Beethoven: Symphony No.9'
  34. Tracktitle= 'Symphony No.9 in d op.125 - Presto - Allegro assai -'
  35. Tracknumber= 5
  36. Trackstart= 220380
  37. # track length in sectors (1/75 seconds each), rest samples
  38. Tracklength= 80702, 0
  39. Pre-emphasis= no
  40. Channels= 2
  41. Copy_permitted= once (copyright protected)
  42. Endianess= little
  43. # index list
  44. Index= 0
  45. Index0= -1
  46. // Tags on audio.cddb
  47. "DISCID": "",
  48. "DTITLE": "TALB", // may have multiple lines!!
  49. "DYEAR": "TDRC",
  50. "DGENRE": "TCON",
  51. // on each .inf file (one per track)
  52. "CDINDEX_DISCID": "",
  53. "CDDB_DISCID": "",
  54. "MCN": "",
  55. "ISRC": "TSRC",
  56. "Albumperformer": "TPE2",
  57. "Performer": "TPE3",
  58. "Albumtitle": "TALB",
  59. "Tracktitle": "TIT2",
  60. "Tracknumber": "TRCK",
  61. "Trackstart": "", // probably sectors
  62. "Tracklength": "TLEN", // sectors [1 sector = 1/75 sec], rest
  63. "Pre-emphasis": "", // yes/no
  64. "Channels": "", // 1: mono; 2: stereo
  65. "Copy_permitted": "", // once (copyright protected)
  66. "Endianess": "", // little/big
  67. "Index": "", // 0
  68. "Index0": "", // -1 */
  69. // instantiate a few global variables, to avoid memory leaks.
  70. var (
  71. dirPath string // path where the files are
  72. extension string // file extension (including dot); almost always ".inf"
  73. recursive bool // if set, walks through directories recursively
  74. noConvert bool // if set, will NOT launch ffmpeg
  75. dryRun bool // if set, doesn't save any tags (and enter debug mode)
  76. justRead bool // if set, just read tags from a single file (for debugging purposes)
  77. help bool // if set, show usage
  78. ffmpegPath string // path to ffmpeg executable
  79. genre string // album genre; must be provided via command line, since .inf doesn't have it.
  80. picture string // path to the album image, to set the .m4a to it
  81. pic id3v2.PictureFrame // an encoded picture for inserting into a .m4a as album cover.
  82. )
  83. func main() {
  84. // read flags, if any
  85. golf.StringVarP(&dirPath, 'd', "dir", ".", "directory to convert from")
  86. golf.BoolVarP(&recursive, 'r', "recursive", false, "traverse directory structure recursively")
  87. golf.StringVarP(&extension, 'e', "extension", ".inf", "icedax track info file extension")
  88. golf.StringVarP(&ffmpegPath, 'm', "ffmpeg", "/usr/local/bin/ffmpeg", "path to ffmpeg executable")
  89. golf.BoolVarP(&noConvert, 'n', "no-conversion", false, "If true, does not convert from .wav to .m4a with ffmpeg")
  90. golf.StringVarP(&genre, 'g', "genre", "", "override album genre (sometimes called 'content type')")
  91. golf.StringVarP(&picture, 'p', "picture", "", "path to album picture")
  92. golf.BoolVarP(&dryRun, 'x', "dry-run", false, "if true, does not save any tags to file (and enters debug mode)")
  93. golf.BoolVarP(&justRead, 'j', "just-read", false, "just read a .m4a file for debugging purposes")
  94. golf.BoolVarP(&help, 'h', "help", false, "show command usage")
  95. golf.Parse()
  96. if help {
  97. golf.Usage()
  98. os.Exit(0)
  99. }
  100. if justRead {
  101. os.Exit(justReadM4A(golf.Args()[0]))
  102. }
  103. // fmt.Printf("[DEBUG] Skip conversion mode set to %v; Dry run set to %v; Recursive set to %v\n", noConvert, dryRun, recursive)
  104. if !noConvert {
  105. // check of we can run ffmpeg
  106. path, err := exec.LookPath(ffmpegPath) // also search under the $PATH shell
  107. if err != nil {
  108. log.Panicf("couldn't find executable `ffmpeg` at %q, error was: %s", ffmpegPath, err)
  109. }
  110. fmt.Printf("`ffmpeg` is available at %s\n", path)
  111. }
  112. fmt.Printf("Now entering directory %q (recursive: %t)...\n\n", dirPath, recursive)
  113. // prepare image, if we got one:
  114. if picture != "" {
  115. artwork, err := os.ReadFile(picture)
  116. if err != nil {
  117. // abort if we cannot read the file, the user
  118. log.Fatalf("Error while reading artwork file %q: %s\nTry a different picture or check that the path is correct.\n", picture, err)
  119. }
  120. // see id3v2 documentation
  121. pic = id3v2.PictureFrame{
  122. Encoding: id3v2.EncodingUTF8,
  123. MimeType: "image/jpeg",
  124. PictureType: id3v2.PTFrontCover,
  125. Description: "Front cover",
  126. Picture: artwork,
  127. }
  128. }
  129. // tags we managed to extract successfully, either via audio.cddb or the .INF files.
  130. tags := make(map[string]string)
  131. // Before transversing this directory, we will attempt to read the audio.cddb file.
  132. // If we cannot find it, we'll have to assume sort-of-reasonable defaults...
  133. audioCddb, ferr := os.ReadFile(filepath.Join(dirPath, "audio.cddb"))
  134. if ferr != nil {
  135. fmt.Println("[WARN] audio.cddb file not found; proceeding with defaults. Error was: ", ferr)
  136. } else {
  137. var line, key, value string // one input line
  138. sc := bufio.NewScanner(strings.NewReader(string(audioCddb)))
  139. for sc.Scan() {
  140. line = sc.Text()
  141. // Drop comments
  142. if strings.HasPrefix(line, "#") {
  143. continue // skip comments
  144. }
  145. // key = value (see also below)
  146. // Value can be an empty string, of course!
  147. keyValue := strings.Split(line, "=")
  148. // missing "="?
  149. if len(keyValue) < 2 {
  150. fmt.Println("malformed line...")
  151. continue
  152. }
  153. key = strings.ToUpper(strings.Trim(keyValue[0], " \t\"'"))
  154. value = strings.Trim(keyValue[1], " \t\"'")
  155. // extract the values for each key, and set it on tags
  156. switch key {
  157. case "DISCID":
  158. tags["DISCID"] = value
  159. case "DTITLE":
  160. tags["DTITLE"] += value // note: multiple lines are possible!
  161. case "DYEAR":
  162. tags["DYEAR"] = value
  163. case "DGENRE":
  164. tags["DGENRE"] = value
  165. } // end switch key
  166. } // end for sc.Scan()
  167. } // end read file audio.cbbd
  168. // similar to executor code: transverse all files, check if we have .inf
  169. // then parse it, and eventually convert the files, too.
  170. // Note: this is done serially for now, but it *should+ become goroutines! (gwyneth 20230709)
  171. err := godirwalk.Walk(dirPath,
  172. &godirwalk.Options{
  173. Callback: func(osPathname string, de *godirwalk.Dirent) error {
  174. // skip directories/symlinks to directories (if not in recursive mode)
  175. isDir, dirErr := de.IsDirOrSymlinkToDir();
  176. if isDir && !recursive {
  177. if dirErr == nil {
  178. // return godirwalk.SkipThis
  179. return nil
  180. }
  181. fmt.Printf("error when trying to access directory/symlink %q: %s",
  182. osPathname, dirErr)
  183. return nil
  184. }
  185. // only read files ending in .inf
  186. // note: I was using HasSuffix here, but apparently filepath.Ext() is way faster! (gwyneth 20230706)
  187. if filepath.Ext(osPathname) != extension {
  188. return nil
  189. }
  190. // open INF file for reading, and scan it line by line into
  191. // an array of strings
  192. rawInfFile, err := os.Open(osPathname)
  193. if err != nil {
  194. log.Fatal(err)
  195. }
  196. fileScanner := bufio.NewScanner(rawInfFile)
  197. fileScanner.Split(bufio.ScanLines)
  198. var (
  199. fileLines []string // array of strings
  200. infFileLength int // length of array
  201. )
  202. for fileScanner.Scan() {
  203. fileLines = append(fileLines, fileScanner.Text())
  204. infFileLength++
  205. }
  206. rawInfFile.Close()
  207. var key, value string
  208. // now go through the array
  209. for i := 0; i < infFileLength; i++ {
  210. if strings.HasPrefix(fileLines[i], "#") {
  211. continue // skip comments
  212. }
  213. // .INF files have each line with key=value, with lots of space in-between (or not!)
  214. // Value often has quotes around but we'll ignore it for now (gwyneth 20230706)
  215. keyValue := strings.Split(fileLines[i], "=")
  216. // missing "="?
  217. if len(keyValue) < 2 {
  218. fmt.Printf("malformed line %d\n", i)
  219. continue
  220. }
  221. key = strings.Trim(keyValue[0], " \t\"'")
  222. value = strings.Trim(keyValue[1], " \t\"'")
  223. tags[key] = value
  224. } // end for i loop
  225. fmt.Printf("[DEBUG] Matched tags: %v\n", tags)
  226. fullFileNameBase := osPathname[:len(osPathname) - len(filepath.Ext(osPathname))]
  227. fullDir := filepath.Dir(osPathname)
  228. fileNameM4A := fullFileNameBase + ".m4a"
  229. var track int // track number, which will be used elsewhere
  230. // Deal with conversion
  231. if !noConvert {
  232. // icedax generates .wav and .inf with the same basename:
  233. fileNameWAV := fullFileNameBase + ".wav"
  234. // Do we know how the track is called? (from .inf file)
  235. // This should almost always be the case, unless the original CD
  236. // was completely borked and icedax couldn't find the track names at all.
  237. if tags["Tracktitle"] != "" && tags["Tracknumber"] != "" {
  238. track, err = strconv.Atoi(tags["Tracknumber"])
  239. if err != nil {
  240. fmt.Printf("invalid track number: %q, conversion error: %s", track, err)
  241. }
  242. fileNameM4A = filepath.Join(fullDir, fmt.Sprintf("%02d %s.m4a", track, tags["Tracktitle"]))
  243. }
  244. cmd := exec.Command(ffmpegPath, "-i", fileNameWAV, fileNameM4A)
  245. stdoutStderr, err := cmd.CombinedOutput()
  246. if err != nil {
  247. fmt.Printf("error while running %q: %q \n", ffmpegPath, err)
  248. return nil
  249. }
  250. fmt.Printf("✅ %s\n", stdoutStderr)
  251. }
  252. // open .m4a file
  253. m4aFile, err := id3v2.Open(fileNameM4A, id3v2.Options{Parse: true})
  254. if err != nil {
  255. if !dryRun {
  256. fmt.Println("[ERROR] ", err) // err includes filename
  257. } else {
  258. fmt.Println("[WARN] Couldn't open .m4a file (not generated?); cannot proceed with tag processing.")
  259. }
  260. return nil
  261. }
  262. defer m4aFile.Close()
  263. // See what ffmpeg already wrote
  264. fmt.Printf("[INFO] %d tag(s) found in existing .m4a file, taking %d bytes.\n",
  265. m4aFile.Count(), m4aFile.Size())
  266. m4aFile.SetDefaultEncoding(id3v2.EncodingUTF8)
  267. m4aFile.SetArtist(tags["Performer"]) // TPE3
  268. m4aFile.SetTitle(tags["Tracktitle"]) // TIT2
  269. // are we overriding the genre from the command line?
  270. if genre == "" {
  271. // no? then use whatever was on audio.cddb; could also be empty, of course.
  272. genre = tags["DGENRE"]
  273. }
  274. m4aFile.SetGenre(genre)
  275. m4aFile.SetVersion(4) // ID3v2 version; v4 is the latest
  276. m4aFile.SetYear(tags["DYEAR"])
  277. // Set comment frame.
  278. comment := id3v2.CommentFrame{
  279. Encoding: id3v2.EncodingUTF8,
  280. Language: "eng",
  281. Description: "Additional song IDs",
  282. Text: fmt.Sprintf("DISCID=%q\tCDINDEX_DISCID=%q\tCDDB_DISCID=%q\tMCN=%q",
  283. tags["DISCID"],
  284. tags["CDINDEX_DISCID"],
  285. tags["CDDB_DISCID"],
  286. tags["MCN"],
  287. ),
  288. }
  289. m4aFile.AddCommentFrame(comment)
  290. // if we have a picture, read it and attach it to .m4a file
  291. if picture != "" {
  292. m4aFile.AddAttachedPicture(pic)
  293. }
  294. // other tags from .INF file
  295. switch true {
  296. case tags["Albumtitle"] != "":
  297. m4aFile.AddTextFrame(m4aFile.CommonID("Album/Movie/Show title"),
  298. id3v2.EncodingUTF8, tags["Albumtitle"])
  299. case tags["Albumperformer"] != "":
  300. m4aFile.AddTextFrame(m4aFile.CommonID("Band/Orchestra/Accompaniment"),
  301. id3v2.EncodingUTF8, tags["Albumperformer"])
  302. case tags["ISRC"] != "":
  303. m4aFile.AddTextFrame(m4aFile.CommonID("ISRC"), id3v2.EncodingUTF8, tags["ISRC"])
  304. // other tags we have from the .inf file
  305. case tags["Tracknumber"] != "":
  306. m4aFile.AddTextFrame(m4aFile.CommonID("Track number/Position in set"),
  307. id3v2.EncodingUTF8, tags["Tracknumber"])
  308. case tags["Tracklength"] != "":
  309. var tlen, ignore int // Track length, in sectors
  310. _, err := fmt.Sscanf(tags["Tracklength"], "%d, %d", tlen, ignore)
  311. if err != nil {
  312. fmt.Printf("[WARN] Track length not found! Error was: %s\n", err)
  313. } else {
  314. m4aFile.AddTextFrame(m4aFile.CommonID("Length"),
  315. id3v2.EncodingUTF8,
  316. strconv.FormatFloat(float64(tlen) / 75, 'f', 2, 64))
  317. }
  318. }
  319. m4aFile.AddTextFrame(m4aFile.CommonID("Software/Hardware and settings used for encoding"),
  320. id3v2.EncodingUTF8,
  321. "ffmpeg version 6.0")
  322. // basically an experiment. TOFN is the original file name, in case someone borks the
  323. // filename and needs to get it back somehow
  324. m4aFile.AddTextFrame("TOFN", id3v2.EncodingUTF8,
  325. fmt.Sprintf("%02d %s.m4a", track, tags["Tracktitle"]))
  326. // capture a timestamp to embed as the date the tags were generated
  327. m4aFile.AddTextFrame(m4aFile.CommonID("Tagging time"),
  328. id3v2.EncodingUTF8,
  329. time.Now().UTC().Format(time.RFC3339))
  330. fmt.Printf("[INFO] After reading .inf file, we now have %d tag(s), taking %d bytes.\n",
  331. m4aFile.Count(), m4aFile.Size())
  332. fmt.Printf("[DEBUG] All the frames added by inf2ffmpeg: %#v\n",
  333. m4aFile.AllFrames())
  334. // Write everything to file, unless --dry-run is set
  335. if !dryRun {
  336. m4err := m4aFile.Save()
  337. if m4err != nil {
  338. log.Printf("[ERROR] Couldn't save tags to %q: %s\n", fileNameM4A, m4err)
  339. } else {
  340. fmt.Println("✅ All tags successfully saved to ", fileNameM4A)
  341. }
  342. }
  343. return nil
  344. }, // ends Callback
  345. Unsorted: false, // (optional) set true for faster yet non-deterministic enumeration (see godoc)
  346. }) // end options for dirwalk
  347. if err != nil {
  348. log.Printf("sorry, got error %s\n", err)
  349. }
  350. }
  351. // justReadM4A is a debugging tool to read a .m4a and show what tags it contains.
  352. func justReadM4A(fileNameM4A string) int {
  353. m4aFile, err := id3v2.Open(fileNameM4A, id3v2.Options{Parse: true})
  354. if err != nil {
  355. log.Println(err)
  356. os.Exit(66) // proper exit status code for file not found
  357. }
  358. defer m4aFile.Close()
  359. log.Printf("[INFO] %d tag(s) found in %q, taking %d bytes.\n",
  360. m4aFile.Count(), fileNameM4A, m4aFile.Size())
  361. fmt.Println("Title:", m4aFile.Title())
  362. fmt.Println("Artist:", m4aFile.Artist())
  363. fmt.Println("Year:", m4aFile.Year())
  364. fmt.Println("Genre:", m4aFile.Genre())
  365. fmt.Println("ID3 version:", m4aFile.Version())
  366. fmt.Println("Other frames found:")
  367. allTags := m4aFile.AllFrames()
  368. for i, aFrame := range allTags {
  369. fmt.Printf("%s: %#v\nTags for this frame:\n", i, aFrame)
  370. for j, aTag := range aFrame {
  371. fmt.Printf("\t%s: %02d - %v\n", i, j, aTag.UniqueIdentifier())
  372. }
  373. }
  374. log.Printf("[INFO] Finished parsing %q.\n",
  375. fileNameM4A)
  376. return 0 // 1 is the status error for 'generic' failure
  377. }