File: id3pic.py
   1 #!/usr/bin/python
   2 
   3 # The MIT License (MIT)
   4 #
   5 # Copyright (c) 2026 pacman64
   6 #
   7 # Permission is hereby granted, free of charge, to any person obtaining a copy
   8 # of this software and associated documentation files (the "Software"), to deal
   9 # in the Software without restriction, including without limitation the rights
  10 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  11 # copies of the Software, and to permit persons to whom the Software is
  12 # furnished to do so, subject to the following conditions:
  13 #
  14 # The above copyright notice and this permission notice shall be included in
  15 # all copies or substantial portions of the Software.
  16 #
  17 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  18 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  19 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  20 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  21 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  22 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  23 # SOFTWARE.
  24 
  25 
  26 from struct import unpack
  27 from sys import argv, exit, stderr, stdin, stdout
  28 
  29 
  30 info = '''
  31 id3pic [options...] [filepath/URI...]
  32 
  33 
  34 Extract picture/thumbnail bytes from ID3/MP3 metadata, when available.
  35 
  36 Any leading options can start with either single or double-dash:
  37 
  38     -h, -help    show this help message
  39 '''
  40 
  41 # handle standard help cmd-line options, quitting right away in that case
  42 if len(argv) > 1 and argv[1] in ('-h', '--h', '-help', '--help'):
  43     print(info.strip())
  44     exit(0)
  45 
  46 
  47 def seems_url(s: str) -> bool:
  48     protocols = ('https://', 'http://', 'file://', 'ftp://', 'data:')
  49     return any(s.startswith(p) for p in protocols)
  50 
  51 
  52 def read_byte(src) -> int:
  53     b = src.read(1)
  54     return b[0] if b else -1
  55 
  56 
  57 def skip_zstring(src) -> int:
  58     return skip_until(src, 0)
  59 
  60 
  61 def skip_thumbnail_type_apic(src) -> int:
  62     n = 0
  63 
  64     if not src.read(2):
  65         raise Exception('failed to sync to thumbnail flags')
  66     n += 2
  67 
  68     if read_byte(src) < 0:
  69         raise Exception('failed to sync to thumbnail text-encoding')
  70     n += 1
  71 
  72     m = skip_zstring(src)
  73     if m < 0:
  74         raise Exception('failed to sync to thumbnail MIME-type')
  75     n += m
  76 
  77     if read_byte(src) < 0:
  78         raise Exception('failed to sync to thumbnail picture type')
  79     n += 1
  80 
  81     m = skip_zstring(src)
  82     if m < 0:
  83         raise Exception('failed to sync to thumbnail comment')
  84     n += m
  85 
  86     return n
  87 
  88 
  89 def skip_until(src, what: int) -> int:
  90     n = 0
  91 
  92     while True:
  93         b = src.read(1)
  94         if not b:
  95             return -1
  96 
  97         n += 1
  98 
  99         if b[0] == what:
 100             return n
 101 
 102 
 103 def handle_apic(w, src) -> bool:
 104     # section-size seems stored as 4 big-endian bytes
 105     chunk = src.read(4)
 106     if not chunk:
 107         raise Exception('failed to read thumbnail-payload size')
 108     size = unpack('>I', chunk)[0]
 109 
 110     n = skip_thumbnail_type_apic(src)
 111     if n < 0:
 112         raise Exception('failed to sync to start of thumbnail data')
 113     size -= n
 114 
 115     # copy all thumbnail bytes
 116     w.write(src.read(size))
 117     return True
 118 
 119 
 120 def handle_pic(w, src) -> bool:
 121     # thumbnail-payload-size seems stored as 3 big-endian bytes
 122     a = src.read(1)
 123     b = src.read(1)
 124     c = src.read(1)
 125     if not (a and b and c):
 126         raise Exception('failed to read thumbnail-payload size')
 127     size = (256 * 256) * a[0] + 256 * b[0] + c[0]
 128 
 129     # skip the text encoding
 130     src.read(5)
 131 
 132     # skip a null-delimited string
 133     while True:
 134         b = src.read(1)
 135         if not b:
 136             raise Exception('failed to read thumbnail-payload description')
 137 
 138         if b[0] == 0:
 139             break
 140 
 141     # copy all thumbnail bytes
 142     w.write(src.read(size))
 143     return True
 144 
 145 
 146 def handle_id3_picture(w, src) -> bool:
 147     a = ord('A')
 148     c = ord('C')
 149     d = ord('D')
 150     i = ord('I')
 151     p = ord('P')
 152     three = ord('3')
 153 
 154     # match the ID3 mark
 155     while True:
 156         chunk = src.read(1)
 157         if not chunk:
 158             return False
 159         v = chunk[0]
 160 
 161         if v == i:
 162             if src.read(1)[0] != d:
 163                 continue
 164             if src.read(1)[0] != three:
 165                 continue
 166             break
 167 
 168     while True:
 169         chunk = src.read(1)
 170         if not chunk:
 171             break
 172         v = chunk[0]
 173 
 174         if v == a:
 175             if src.read(1)[0] != p:
 176                 continue
 177             if src.read(1)[0] != i:
 178                 continue
 179             if src.read(1)[0] != c:
 180                 continue
 181             return handle_apic(w, src)
 182 
 183         if v == p:
 184             if src.read(1)[0] != i:
 185                 continue
 186             if src.read(1)[0] != c:
 187                 continue
 188             return handle_pic(w, src)
 189 
 190     return False
 191 
 192 
 193 def handle_bytes(w, src) -> None:
 194     if not handle_id3_picture(w, src):
 195         raise Exception('no thumbnail found')
 196 
 197 
 198 name = '-' if len(argv) == 1 else argv[1]
 199 if len(argv) > 2:
 200     print('\x1b[31mmultiple inputs not allowed\x1b[0m', file=stderr)
 201     exit(1)
 202 
 203 try:
 204     if seems_url(name):
 205         from urllib.request import urlopen
 206 
 207     # handle all named inputs given
 208     if name == '-':
 209         handle_bytes(stdout.buffer, stdin.buffer)
 210     elif seems_url(name):
 211         with urlopen(name) as inp:
 212             handle_bytes(stdout.buffer, inp)
 213     else:
 214         with open(name, mode='rb') as inp:
 215             handle_bytes(stdout.buffer, inp)
 216 except BrokenPipeError:
 217     # quit quietly, instead of showing a confusing error message
 218     stderr.close()
 219 except KeyboardInterrupt:
 220     exit(2)
 221 except Exception as e:
 222     print(f'\x1b[31m{e}\x1b[0m', file=stderr)
 223     exit(1)