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)