File: id3pic.py
   1 #!/usr/bin/python3
   2 
   3 # The MIT License (MIT)
   4 #
   5 # Copyright © 2020-2025 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          show this help message
  39     -help       show this help message
  40 '''
  41 
  42 # handle standard help cmd-line options, quitting right away in that case
  43 if len(argv) == 2 and argv[1] in ('-h', '--h', '-help', '--help'):
  44     print(info.strip())
  45     exit(0)
  46 
  47 
  48 def seems_url(s: str) -> bool:
  49     protocols = ('https://', 'http://', 'file://', 'ftp://', 'data:')
  50     return any(s.startswith(p) for p in protocols)
  51 
  52 
  53 def read_byte(src) -> int:
  54     b = src.read(1)
  55     return b[0] if b else -1
  56 
  57 
  58 def skip_zstring(src) -> int:
  59     return skip_until(src, 0)
  60 
  61 
  62 def skip_thumbnail_type_apic(src) -> int:
  63     n = 0
  64 
  65     if not src.read(2):
  66         raise Exception('failed to sync to thumbnail flags')
  67     n += 2
  68 
  69     if read_byte(src) < 0:
  70         raise Exception('failed to sync to thumbnail text-encoding')
  71     n += 1
  72 
  73     m = skip_zstring(src)
  74     if m < 0:
  75         raise Exception('failed to sync to thumbnail MIME-type')
  76     n += m
  77 
  78     if read_byte(src) < 0:
  79         raise Exception('failed to sync to thumbnail picture type')
  80     n += 1
  81 
  82     m = skip_zstring(src)
  83     if m < 0:
  84         raise Exception('failed to sync to thumbnail comment')
  85     n += m
  86 
  87     return n
  88 
  89 
  90 def skip_until(src, what: int) -> int:
  91     n = 0
  92 
  93     while True:
  94         b = src.read(1)
  95         if not b:
  96             return -1
  97 
  98         n += 1
  99 
 100         if b[0] == what:
 101             return n
 102 
 103 
 104 def handle_apic(w, src) -> bool:
 105     # section-size seems stored as 4 big-endian bytes
 106     chunk = src.read(4)
 107     if not chunk:
 108         raise Exception('failed to read thumbnail-payload size')
 109     size = unpack('>I', chunk)[0]
 110 
 111     n = skip_thumbnail_type_apic(src)
 112     if n < 0:
 113         raise Exception('failed to sync to start of thumbnail data')
 114     size -= n
 115 
 116     # copy all thumbnail bytes
 117     w.write(src.read(size))
 118     return True
 119 
 120 
 121 def handle_pic(w, src) -> bool:
 122     # thumbnail-payload-size seems stored as 3 big-endian bytes
 123     a = src.read(1)
 124     b = src.read(1)
 125     c = src.read(1)
 126     if not (a and b and c):
 127         raise Exception('failed to read thumbnail-payload size')
 128     size = (256 * 256) * a[0] + 256 * b[0] + c[0]
 129 
 130     # skip the text encoding
 131     src.read(5)
 132 
 133     # skip a null-delimited string
 134     while True:
 135         b = src.read(1)
 136         if not b:
 137             raise Exception('failed to read thumbnail-payload description')
 138 
 139         if b[0] == 0:
 140             break
 141 
 142     # copy all thumbnail bytes
 143     w.write(src.read(size))
 144     return True
 145 
 146 
 147 def handle_id3_picture(w, src) -> bool:
 148     a = ord('A')
 149     c = ord('C')
 150     d = ord('D')
 151     i = ord('I')
 152     p = ord('P')
 153     three = ord('3')
 154 
 155     # match the ID3 mark
 156     while True:
 157         chunk = src.read(1)
 158         if not chunk:
 159             return False
 160         v = chunk[0]
 161 
 162         if v == i:
 163             if src.read(1)[0] != d:
 164                 continue
 165             if src.read(1)[0] != three:
 166                 continue
 167             break
 168 
 169     while True:
 170         chunk = src.read(1)
 171         if not chunk:
 172             break
 173         v = chunk[0]
 174 
 175         if v == a:
 176             if src.read(1)[0] != p:
 177                 continue
 178             if src.read(1)[0] != i:
 179                 continue
 180             if src.read(1)[0] != c:
 181                 continue
 182             return handle_apic(w, src)
 183 
 184         if v == p:
 185             if src.read(1)[0] != i:
 186                 continue
 187             if src.read(1)[0] != c:
 188                 continue
 189             return handle_pic(w, src)
 190 
 191     return False
 192 
 193 
 194 def handle_bytes(w, src) -> None:
 195     if not handle_id3_picture(w, src):
 196         raise Exception('no thumbnail found')
 197 
 198 
 199 name = '-' if len(argv) == 1 else argv[1]
 200 if len(argv) > 2:
 201     print('\x1b[31mmultiple inputs not allowed\x1b[0m', file=stderr)
 202     exit(1)
 203 
 204 try:
 205     if seems_url(name):
 206         from urllib.request import urlopen
 207 
 208     # handle all named inputs given
 209     if name == '-':
 210         handle_bytes(stdout.buffer, stdin.buffer)
 211     elif seems_url(name):
 212         with urlopen(name) as inp:
 213             handle_bytes(stdout.buffer, inp)
 214     else:
 215         with open(name, mode='rb') as inp:
 216             handle_bytes(stdout.buffer, inp)
 217 except BrokenPipeError:
 218     # quit quietly, instead of showing a confusing error message
 219     stderr.close()
 220 except KeyboardInterrupt:
 221     exit(2)
 222 except Exception as e:
 223     print(f'\x1b[31m{e}\x1b[0m', file=stderr)
 224     exit(1)