File: id3pic.go
   1 /*
   2 The MIT License (MIT)
   3 
   4 Copyright © 2020-2025 pacman64
   5 
   6 Permission is hereby granted, free of charge, to any person obtaining a copy of
   7 this software and associated documentation files (the “Software”), to deal
   8 in the Software without restriction, including without limitation the rights to
   9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
  10 of the Software, and to permit persons to whom the Software is furnished to do
  11 so, subject to the following conditions:
  12 
  13 The above copyright notice and this permission notice shall be included in all
  14 copies or substantial portions of the Software.
  15 
  16 THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  22 SOFTWARE.
  23 */
  24 
  25 /*
  26 Single-file source-code for id3pic.
  27 
  28 To compile a smaller-sized command-line app, you can use the `go` command as
  29 follows:
  30 
  31 go build -ldflags "-s -w" -trimpath id3pic.go
  32 */
  33 
  34 package main
  35 
  36 import (
  37     "bufio"
  38     "encoding/binary"
  39     "errors"
  40     "io"
  41     "os"
  42 )
  43 
  44 const info = `
  45 id3pic [options...] [file...]
  46 
  47 Extract picture/thumbnail bytes from ID3/MP3 metadata, if available.
  48 
  49 All (optional) leading options start with either single or double-dash:
  50 
  51     -h          show this help message
  52     -help       show this help message
  53 `
  54 
  55 // errNoMoreOutput is a dummy error, whose message is ignored, and which
  56 // causes the app to quit immediately and successfully
  57 var errNoMoreOutput = errors.New(`no more output`)
  58 
  59 // errNoThumb is a generic error to handle lack of thumbnails, in case no
  60 // picture-metadata-starters are found at all
  61 var errNoThumb = errors.New(`no thumbnail data found`)
  62 
  63 const errorStyle = "\x1b[31m"
  64 
  65 func main() {
  66     if len(os.Args) > 1 {
  67         switch os.Args[1] {
  68         case `-h`, `--h`, `-help`, `--help`:
  69             os.Stderr.WriteString(info[1:])
  70             return
  71         }
  72     }
  73 
  74     if len(os.Args) > 2 {
  75         os.Stderr.WriteString(errorStyle)
  76         os.Stderr.WriteString(`can only handle 1 file`)
  77         os.Stderr.WriteString("\x1b[0m\n")
  78         os.Exit(1)
  79     }
  80 
  81     name := `-`
  82     if len(os.Args) > 1 {
  83         name = os.Args[1]
  84     }
  85 
  86     if err := run(os.Stdout, name); isActualError(err) {
  87         os.Stderr.WriteString(errorStyle)
  88         os.Stderr.WriteString(err.Error())
  89         os.Stderr.WriteString("\x1b[0m\n")
  90         os.Exit(1)
  91     }
  92 }
  93 
  94 func run(w io.Writer, name string) error {
  95     if name == `-` {
  96         return id3pic(w, bufio.NewReader(os.Stdin))
  97     }
  98 
  99     f, err := os.Open(name)
 100     if err != nil {
 101         return errors.New(`can't read from file named "` + name + `"`)
 102     }
 103     defer f.Close()
 104 
 105     return id3pic(w, bufio.NewReader(f))
 106 }
 107 
 108 // isActualError is to figure out whether not to ignore an error, and thus
 109 // show it as an error message
 110 func isActualError(err error) bool {
 111     return err != nil && err != io.EOF && err != errNoMoreOutput
 112 }
 113 
 114 func match(r *bufio.Reader, what []byte) bool {
 115     for _, v := range what {
 116         b, err := r.ReadByte()
 117         if b != v || err != nil {
 118             return false
 119         }
 120     }
 121     return true
 122 }
 123 
 124 func id3pic(w io.Writer, r *bufio.Reader) error {
 125     for {
 126         b, err := r.ReadByte()
 127         if err == io.EOF {
 128             return errNoThumb
 129         }
 130         if err != nil {
 131             return err
 132         }
 133 
 134         // handle APIC-type chunks
 135         if b == 'A' {
 136             if match(r, []byte{'P', 'I', 'C'}) {
 137                 // section-size seems stored as 4 big-endian bytes
 138                 var size uint32
 139                 err := binary.Read(r, binary.BigEndian, &size)
 140                 if err != nil {
 141                     return err
 142                 }
 143 
 144                 // a, err1 := r.ReadByte()
 145                 // b, err2 := r.ReadByte()
 146                 // c, err3 := r.ReadByte()
 147                 // d, err4 := r.ReadByte()
 148                 // if err1 != nil || err2 != nil || err3 != nil || err4 != nil {
 149                 //  continue
 150                 // }
 151 
 152                 // var size int64 = int(a)<<24 + int(b)<<16 + int(c)<<8 + int(d)
 153 
 154                 n, err := skipThumbnailTypeAPIC(r)
 155                 if err != nil {
 156                     continue
 157                 }
 158 
 159                 _, err = io.Copy(w, io.LimitReader(r, int64(int(size)-n)))
 160                 return err
 161             }
 162         }
 163 
 164         // handle PIC-type chunks
 165         if b == 'P' {
 166             if match(r, []byte{'I', 'C'}) {
 167                 // http://www.unixgods.org/Ruby/ID3/docs/id3v2-00.html#PIC
 168 
 169                 // thumbnail-payload-size seems stored as 3 big-endian bytes
 170                 a, err1 := r.ReadByte()
 171                 b, err2 := r.ReadByte()
 172                 c, err3 := r.ReadByte()
 173                 if err1 != nil || err2 != nil || err3 != nil {
 174                     continue
 175                 }
 176 
 177                 // skip the text encoding
 178                 n, err := r.Discard(5)
 179                 if n != 5 || err != nil {
 180                     continue
 181                 }
 182 
 183                 // skip a null-delimited string
 184                 _, err = r.ReadSlice(0)
 185                 if err != nil {
 186                     continue
 187                 }
 188 
 189                 size := int(a)<<16 + int(b)<<8 + int(c)
 190                 _, err = io.Copy(w, io.LimitReader(r, int64(size)))
 191                 return err
 192             }
 193         }
 194     }
 195 }
 196 
 197 func skipThumbnailTypeAPIC(r *bufio.Reader) (int, error) {
 198     n := 0
 199     _, err := r.Discard(1)
 200     if err != nil {
 201         return -1, errors.New(`failed to sync APIC thumbnail text-encoding`)
 202     }
 203     n++
 204 
 205     junk, err := r.ReadSlice('/')
 206     if err != nil {
 207         return -1, errors.New(`failed to sync to APIC thumbnail MIME-type`)
 208     }
 209     n += len(junk)
 210 
 211     // skip a null-delimited string
 212     junk, err = r.ReadSlice(0)
 213     if err != nil {
 214         return -1, errors.New(`failed to sync to APIC thumbnail MIME-type`)
 215     }
 216     n += len(junk)
 217 
 218     _, err = r.Discard(1)
 219     if err != nil {
 220         return -1, errors.New(`failed to sync APIC thumbnail picture type`)
 221     }
 222     n++
 223 
 224     // skip a null-delimited string
 225     junk, err = r.ReadSlice(0)
 226     if err != nil {
 227         return -1, errors.New(`failed to sync to thumbnail comment`)
 228     }
 229     n += len(junk)
 230 
 231     return n, nil
 232 }