Add cover art pipeline (extract/encode/cache) and iTunes-style player
- New src/artwork/ module with iPod Nano 7G cover art support - codecs: RGB565/RGB555/UYVY/JPEG encode/decode via numpy - presets: 50+ iPod artwork format definitions - chunks: ArtworkDB binary parser/writer (MHFD→MHSD→MHLI→MHII→MHNI) - writer: ithmb file generation + ArtworkDB assembly - extractor: cover extraction from audio files (APIC/covr/FLAC pictures) - cache: local JPEG cache for player UI - iTunes-style playback bar with 60x60 cover art display - Scan library now caches cover art by artist+album - iPod Nano7 transfer writes artwork to ArtworkDB + ithmb - FFmpeg conversion preserves embedded cover art (-map 0:t?) - One-time reembed_covers.py script in /tmp - MetadataHandler process_file() fallback to cover_path - Bug fixes: missing read_existing_artwork import, signed PID in Q format
This commit is contained in:
parent
8c576232b1
commit
489d99c068
@ -9,3 +9,4 @@ requests>=2.28.2
|
||||
tqdm>=4.65.0
|
||||
musicbrainzngs>=0.7.1
|
||||
PyQt6>=6.5.0
|
||||
numpy>=1.24.0
|
||||
|
||||
29
src/artwork/__init__.py
Normal file
29
src/artwork/__init__.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""Artwork module — iPod album art pipeline.
|
||||
|
||||
Public API:
|
||||
process_artwork_for_track(source_path) -> bytes | None
|
||||
extract_cover(file_path) -> bytes | None
|
||||
encode_for_ipod(art_bytes, format_id) -> EncodedFormatPayload
|
||||
write_artwork_to_ipod(ipod_path, entries, formats_map)
|
||||
get_local_cover(cache_key) -> str | None
|
||||
cache_cover(cache_key, art_bytes) -> str
|
||||
"""
|
||||
|
||||
from .presets import ArtworkFormat, NANO_7G_COVER_ART_FORMATS
|
||||
from .types import ArtworkEntry, IthmbLocation, EncodedFormatPayload
|
||||
from .extractor import extract_cover
|
||||
from .codecs import encode_for_ipod, decode_from_ipod
|
||||
from .writer import write_artworkdb, prepare_artwork_for_track
|
||||
|
||||
__all__ = [
|
||||
"ArtworkFormat",
|
||||
"ArtworkEntry",
|
||||
"IthmbLocation",
|
||||
"EncodedFormatPayload",
|
||||
"NANO_7G_COVER_ART_FORMATS",
|
||||
"extract_cover",
|
||||
"encode_for_ipod",
|
||||
"decode_from_ipod",
|
||||
"write_artworkdb",
|
||||
"prepare_artwork_for_track",
|
||||
]
|
||||
67
src/artwork/cache.py
Normal file
67
src/artwork/cache.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""Local cover art cache for the player UI.
|
||||
|
||||
Caches extracted cover art as JPEG files in a local directory
|
||||
so the player can display them without re-extracting from audio.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CoverCache:
|
||||
"""Manages a local cache of cover art images for the player UI.
|
||||
|
||||
The cache stores JPEG thumbnails keyed by (artist, album) hash.
|
||||
"""
|
||||
|
||||
def __init__(self, cache_dir: str | None = None):
|
||||
self.cache_dir = cache_dir or os.path.join(
|
||||
tempfile.gettempdir(), "neo-pod-covers"
|
||||
)
|
||||
os.makedirs(self.cache_dir, exist_ok=True)
|
||||
|
||||
def _key(self, artist: str, album: str) -> str:
|
||||
raw = f"{artist.lower()}|{album.lower()}"
|
||||
return hashlib.md5(raw.encode()).hexdigest()
|
||||
|
||||
def get(self, artist: str, album: str) -> str | None:
|
||||
"""Return path to cached cover, or None."""
|
||||
path = os.path.join(self.cache_dir, f"{self._key(artist, album)}.jpg")
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
def put(self, artist: str, album: str, art_bytes: bytes) -> str | None:
|
||||
"""Cache cover art bytes and return the file path."""
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
|
||||
key = self._key(artist, album)
|
||||
path = os.path.join(self.cache_dir, f"{key}.jpg")
|
||||
try:
|
||||
img = Image.open(BytesIO(art_bytes))
|
||||
img = img.convert("RGB")
|
||||
img.thumbnail((300, 300), Image.Resampling.LANCZOS)
|
||||
img.save(path, "JPEG", quality=85)
|
||||
return path
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to cache cover art: %s", exc)
|
||||
return None
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all cached covers."""
|
||||
for fname in os.listdir(self.cache_dir):
|
||||
if fname.endswith(".jpg"):
|
||||
try:
|
||||
os.remove(os.path.join(self.cache_dir, fname))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
cover_cache = CoverCache()
|
||||
374
src/artwork/chunks.py
Normal file
374
src/artwork/chunks.py
Normal file
@ -0,0 +1,374 @@
|
||||
"""Binary ArtworkDB chunk serialization.
|
||||
|
||||
Ported from iOpenPod ArtworkDB_Writer/artworkdb_chunks.py.
|
||||
Builds the MHFD → MHSD → MHLI/MHLA/MHLF → MHII → MHOD → MHNI → MHOD(ithmb)
|
||||
hierarchy that the iPod firmware parses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import struct
|
||||
from collections.abc import Mapping
|
||||
|
||||
from .types import ArtworkEntry, ArtworkFormatPayload, ExistingFormatRef, IthmbLocation
|
||||
from .codecs import expected_size_bytes
|
||||
|
||||
MHFD_HEADER_SIZE = 132
|
||||
MHSD_HEADER_SIZE = 96
|
||||
MHLI_HEADER_SIZE = 92
|
||||
MHLA_HEADER_SIZE = 92
|
||||
MHLF_HEADER_SIZE = 92
|
||||
MHII_HEADER_SIZE = 152
|
||||
MHOD_HEADER_SIZE = 24
|
||||
MHNI_HEADER_SIZE = 76
|
||||
MHIF_HEADER_SIZE = 124
|
||||
|
||||
|
||||
def _default_ithmb_filename(format_id: int) -> str:
|
||||
return f"F{int(format_id)}_1.ithmb"
|
||||
|
||||
|
||||
def _normalize_ithmb_filename(format_id: int, filename: str | None) -> str:
|
||||
name = (filename or "").strip().replace("\\", "/")
|
||||
if ":" in name:
|
||||
name = name.split(":")[-1]
|
||||
if "/" in name:
|
||||
name = name.rsplit("/", 1)[-1]
|
||||
return name or _default_ithmb_filename(format_id)
|
||||
|
||||
|
||||
def build_artworkdb(
|
||||
entries: list[ArtworkEntry],
|
||||
format_locations_map: Mapping[int, Mapping[int, IthmbLocation | tuple[str, int] | int]],
|
||||
format_ids: list[int],
|
||||
image_sizes: dict[int, int],
|
||||
next_mhii_id: int,
|
||||
reference_mhfd: bytes | None = None,
|
||||
) -> bytes:
|
||||
"""Serialize a complete ArtworkDB binary."""
|
||||
ds1 = _write_mhsd(1, _write_mhli(entries, format_locations_map))
|
||||
ds2 = _write_mhsd(2, _write_mhla())
|
||||
ds3 = _write_mhsd(3, _write_mhlf(format_ids, image_sizes))
|
||||
return _write_mhfd([ds1, ds2, ds3], next_mhii_id, reference_mhfd)
|
||||
|
||||
|
||||
def _write_mhfd(datasets: list[bytes], next_mhii_id: int, reference_mhfd: bytes | None = None) -> bytes:
|
||||
all_data = b"".join(datasets)
|
||||
total_len = MHFD_HEADER_SIZE + len(all_data)
|
||||
header = bytearray(MHFD_HEADER_SIZE)
|
||||
header[0:4] = b"mhfd"
|
||||
struct.pack_into("<I", header, 4, MHFD_HEADER_SIZE)
|
||||
struct.pack_into("<I", header, 8, total_len)
|
||||
struct.pack_into("<I", header, 16, 2)
|
||||
struct.pack_into("<I", header, 20, len(datasets))
|
||||
struct.pack_into("<I", header, 28, next_mhii_id)
|
||||
if reference_mhfd and len(reference_mhfd) >= 48:
|
||||
header[32:48] = reference_mhfd[32:48]
|
||||
struct.pack_into("<I", header, 48, 2)
|
||||
if reference_mhfd and len(reference_mhfd) >= 68:
|
||||
header[60:68] = reference_mhfd[60:68]
|
||||
return bytes(header) + all_data
|
||||
|
||||
|
||||
def _write_mhsd(ds_type: int, child_data: bytes) -> bytes:
|
||||
total_len = MHSD_HEADER_SIZE + len(child_data)
|
||||
header = bytearray(MHSD_HEADER_SIZE)
|
||||
header[0:4] = b"mhsd"
|
||||
struct.pack_into("<I", header, 4, MHSD_HEADER_SIZE)
|
||||
struct.pack_into("<I", header, 8, total_len)
|
||||
struct.pack_into("<H", header, 12, ds_type)
|
||||
return bytes(header) + child_data
|
||||
|
||||
|
||||
def _write_mhli(entries: list[ArtworkEntry], format_locations_map) -> bytes:
|
||||
children_data = b"".join(
|
||||
_write_mhii(entry, format_locations_map[entry.img_id])
|
||||
for entry in entries
|
||||
)
|
||||
header = bytearray(MHLI_HEADER_SIZE)
|
||||
header[0:4] = b"mhli"
|
||||
struct.pack_into("<I", header, 4, MHLI_HEADER_SIZE)
|
||||
struct.pack_into("<I", header, 8, len(entries))
|
||||
return bytes(header) + children_data
|
||||
|
||||
|
||||
def _write_mhla() -> bytes:
|
||||
header = bytearray(MHLA_HEADER_SIZE)
|
||||
header[0:4] = b"mhla"
|
||||
struct.pack_into("<I", header, 4, MHLA_HEADER_SIZE)
|
||||
struct.pack_into("<I", header, 8, 0)
|
||||
return bytes(header)
|
||||
|
||||
|
||||
def _write_mhlf(format_ids: list[int], image_sizes: dict[int, int]) -> bytes:
|
||||
children_data = b"".join(
|
||||
_write_mhif(fmt_id, image_sizes[fmt_id])
|
||||
for fmt_id in format_ids
|
||||
)
|
||||
header = bytearray(MHLF_HEADER_SIZE)
|
||||
header[0:4] = b"mhlf"
|
||||
struct.pack_into("<I", header, 4, MHLF_HEADER_SIZE)
|
||||
struct.pack_into("<I", header, 8, len(format_ids))
|
||||
return bytes(header) + children_data
|
||||
|
||||
|
||||
def _write_mhif(format_id: int, image_size: int) -> bytes:
|
||||
header = bytearray(MHIF_HEADER_SIZE)
|
||||
header[0:4] = b"mhif"
|
||||
struct.pack_into("<I", header, 4, MHIF_HEADER_SIZE)
|
||||
struct.pack_into("<I", header, 8, MHIF_HEADER_SIZE)
|
||||
struct.pack_into("<I", header, 16, format_id)
|
||||
struct.pack_into("<I", header, 20, image_size)
|
||||
return bytes(header)
|
||||
|
||||
|
||||
def _write_mhii(entry: ArtworkEntry, format_locations: Mapping[int, IthmbLocation | tuple[str, int] | int]) -> bytes:
|
||||
children = []
|
||||
for fmt_id in sorted(entry.formats.keys()):
|
||||
payload = entry.formats[fmt_id]
|
||||
location = _coerce_ithmb_location(fmt_id, format_locations.get(fmt_id, 0))
|
||||
children.append(_write_mhod_container(2, _write_mhni(fmt_id, location, payload)))
|
||||
|
||||
children_data = b"".join(children)
|
||||
total_len = MHII_HEADER_SIZE + len(children_data)
|
||||
header = bytearray(MHII_HEADER_SIZE)
|
||||
header[0:4] = b"mhii"
|
||||
struct.pack_into("<I", header, 4, MHII_HEADER_SIZE)
|
||||
struct.pack_into("<I", header, 8, total_len)
|
||||
struct.pack_into("<I", header, 12, len(children))
|
||||
struct.pack_into("<I", header, 16, entry.img_id)
|
||||
struct.pack_into("<q", header, 20, entry.db_track_id)
|
||||
struct.pack_into("<I", header, 48, entry.src_img_size)
|
||||
return bytes(header) + children_data
|
||||
|
||||
|
||||
def _write_mhod_container(mhod_type: int, mhni_data: bytes) -> bytes:
|
||||
total_len = MHOD_HEADER_SIZE + len(mhni_data)
|
||||
header = bytearray(MHOD_HEADER_SIZE)
|
||||
header[0:4] = b"mhod"
|
||||
struct.pack_into("<I", header, 4, MHOD_HEADER_SIZE)
|
||||
struct.pack_into("<I", header, 8, total_len)
|
||||
struct.pack_into("<H", header, 12, mhod_type)
|
||||
return bytes(header) + mhni_data
|
||||
|
||||
|
||||
def _write_mhni(format_id: int, location: IthmbLocation, payload: ArtworkFormatPayload) -> bytes:
|
||||
filename = _normalize_ithmb_filename(format_id, location.filename)
|
||||
mhod3 = _write_mhod_string(3, f":{filename}")
|
||||
total_len = MHNI_HEADER_SIZE + len(mhod3)
|
||||
|
||||
visible_h = int(payload.height)
|
||||
visible_w = int(payload.width)
|
||||
img_size = int(payload.size)
|
||||
stride = max(visible_w, int(payload.stride_pixels))
|
||||
|
||||
header = bytearray(MHNI_HEADER_SIZE)
|
||||
header[0:4] = b"mhni"
|
||||
struct.pack_into("<I", header, 4, MHNI_HEADER_SIZE)
|
||||
struct.pack_into("<I", header, 8, total_len)
|
||||
struct.pack_into("<I", header, 12, 1)
|
||||
struct.pack_into("<I", header, 16, format_id)
|
||||
struct.pack_into("<I", header, 20, int(location.offset))
|
||||
struct.pack_into("<I", header, 24, img_size)
|
||||
struct.pack_into("<h", header, 28, 0)
|
||||
struct.pack_into("<h", header, 30, 0)
|
||||
struct.pack_into("<H", header, 32, visible_h)
|
||||
struct.pack_into("<H", header, 34, visible_w)
|
||||
struct.pack_into("<I", header, 40, img_size)
|
||||
return bytes(header) + mhod3
|
||||
|
||||
|
||||
def _write_mhod_string(mhod_type: int, string: str) -> bytes:
|
||||
if mhod_type == 3:
|
||||
encoded = string.encode("utf-16-le")
|
||||
encoding_byte = 2
|
||||
else:
|
||||
encoded = string.encode("utf-8")
|
||||
encoding_byte = 1
|
||||
str_len = len(encoded)
|
||||
padding = (4 - (str_len % 4)) % 4
|
||||
body = struct.pack("<I", str_len)
|
||||
body += struct.pack("<B", encoding_byte)
|
||||
body += b"\x00" * 3
|
||||
body += b"\x00" * 4
|
||||
body += encoded
|
||||
body += b"\x00" * padding
|
||||
total_len = MHOD_HEADER_SIZE + len(body)
|
||||
header = bytearray(MHOD_HEADER_SIZE)
|
||||
header[0:4] = b"mhod"
|
||||
struct.pack_into("<I", header, 4, MHOD_HEADER_SIZE)
|
||||
struct.pack_into("<I", header, 8, total_len)
|
||||
struct.pack_into("<H", header, 12, mhod_type)
|
||||
return bytes(header) + body
|
||||
|
||||
|
||||
def _coerce_ithmb_location(format_id: int, location) -> IthmbLocation:
|
||||
if isinstance(location, IthmbLocation):
|
||||
return IthmbLocation(
|
||||
_normalize_ithmb_filename(format_id, location.filename),
|
||||
int(location.offset),
|
||||
)
|
||||
if isinstance(location, tuple):
|
||||
return IthmbLocation(
|
||||
_normalize_ithmb_filename(format_id, location[0]),
|
||||
int(location[1]),
|
||||
)
|
||||
return IthmbLocation(_default_ithmb_filename(format_id), int(location or 0))
|
||||
|
||||
|
||||
def read_existing_artwork(artworkdb_path: str, artwork_dir: str) -> dict[int, dict]:
|
||||
"""Read existing ArtworkDB entries, returning {img_id: entry_dict}."""
|
||||
if not os.path.exists(artworkdb_path):
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(artworkdb_path, "rb") as f:
|
||||
data = f.read()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
if len(data) < 32 or data[:4] != b"mhfd":
|
||||
return {}
|
||||
|
||||
entries = {}
|
||||
mhfd_header_size = struct.unpack_from("<I", data, 4)[0]
|
||||
child_count = struct.unpack_from("<I", data, 20)[0]
|
||||
if mhfd_header_size < 32 or mhfd_header_size > len(data):
|
||||
return {}
|
||||
|
||||
offset = mhfd_header_size
|
||||
for _ in range(child_count):
|
||||
if offset + 14 > len(data) or data[offset:offset + 4] != b"mhsd":
|
||||
break
|
||||
mhsd_header = struct.unpack_from("<I", data, offset + 4)[0]
|
||||
mhsd_total = struct.unpack_from("<I", data, offset + 8)[0]
|
||||
ds_type = struct.unpack_from("<H", data, offset + 12)[0]
|
||||
if mhsd_header < 14 or mhsd_total < mhsd_header or offset + mhsd_total > len(data):
|
||||
break
|
||||
|
||||
if ds_type == 1:
|
||||
dataset_end = offset + mhsd_total
|
||||
mhli_offset = offset + mhsd_header
|
||||
if mhli_offset + 12 <= dataset_end and data[mhli_offset:mhli_offset + 4] == b"mhli":
|
||||
mhli_header = struct.unpack_from("<I", data, mhli_offset + 4)[0]
|
||||
mhii_count = struct.unpack_from("<I", data, mhli_offset + 8)[0]
|
||||
if mhli_header < 12 or mhli_offset + mhli_header > dataset_end:
|
||||
break
|
||||
mhii_offset = mhli_offset + mhli_header
|
||||
for _ in range(mhii_count):
|
||||
if mhii_offset + 52 > dataset_end or data[mhii_offset:mhii_offset + 4] != b"mhii":
|
||||
break
|
||||
mhii_total = struct.unpack_from("<I", data, mhii_offset + 8)[0]
|
||||
if mhii_total < 52 or mhii_offset + mhii_total > dataset_end:
|
||||
break
|
||||
entry = _parse_mhii_existing(data, mhii_offset, mhii_total, artwork_dir)
|
||||
if entry:
|
||||
entries[entry["img_id"]] = entry
|
||||
mhii_offset += mhii_total
|
||||
offset += mhsd_total
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def _parse_mhii_existing(data: bytes, offset: int, total_len: int, artwork_dir: str) -> dict | None:
|
||||
"""Parse one MHII entry from an existing ArtworkDB."""
|
||||
entry_end = offset + total_len
|
||||
if offset + 52 > entry_end:
|
||||
return None
|
||||
|
||||
header_size = struct.unpack_from("<I", data, offset + 4)[0]
|
||||
child_count = struct.unpack_from("<I", data, offset + 12)[0]
|
||||
img_id = struct.unpack_from("<I", data, offset + 16)[0]
|
||||
song_id = struct.unpack_from("<q", data, offset + 20)[0]
|
||||
src_img_size = struct.unpack_from("<I", data, offset + 48)[0]
|
||||
if header_size < 52 or header_size > total_len:
|
||||
return None
|
||||
|
||||
formats: dict[int, ExistingFormatRef] = {}
|
||||
child_offset = offset + header_size
|
||||
for _ in range(child_count):
|
||||
if child_offset + 14 > entry_end or data[child_offset:child_offset + 4] != b"mhod":
|
||||
break
|
||||
mhod_header = struct.unpack_from("<I", data, child_offset + 4)[0]
|
||||
mhod_total = struct.unpack_from("<I", data, child_offset + 8)[0]
|
||||
mhod_type = struct.unpack_from("<H", data, child_offset + 12)[0]
|
||||
if mhod_header < 14 or mhod_total < mhod_header or child_offset + mhod_total > entry_end:
|
||||
break
|
||||
|
||||
if mhod_type == 2:
|
||||
mhni_offset = child_offset + mhod_header
|
||||
child_end = child_offset + mhod_total
|
||||
if mhni_offset + MHNI_HEADER_SIZE <= child_end and data[mhni_offset:mhni_offset + 4] == b"mhni":
|
||||
fmt_id = struct.unpack_from("<I", data, mhni_offset + 16)[0]
|
||||
ithmb_offset = struct.unpack_from("<I", data, mhni_offset + 20)[0]
|
||||
img_size = struct.unpack_from("<I", data, mhni_offset + 24)[0]
|
||||
fname = _parse_mhni_filename(data, mhni_offset, child_end)
|
||||
fname = _normalize_ithmb_filename(fmt_id, fname)
|
||||
ipath = os.path.join(artwork_dir, fname)
|
||||
if os.path.exists(ipath) and img_size > 0:
|
||||
formats[fmt_id] = ExistingFormatRef(
|
||||
path=ipath,
|
||||
ithmb_offset=ithmb_offset,
|
||||
size=img_size,
|
||||
width=max(1, struct.unpack_from("<H", data, mhni_offset + 34)[0]),
|
||||
height=max(1, struct.unpack_from("<H", data, mhni_offset + 32)[0]),
|
||||
hpad=max(0, struct.unpack_from("<h", data, mhni_offset + 30)[0]),
|
||||
vpad=max(0, struct.unpack_from("<h", data, mhni_offset + 28)[0]),
|
||||
ithmb_filename=fname,
|
||||
)
|
||||
child_offset += mhod_total
|
||||
|
||||
if not formats:
|
||||
return None
|
||||
return {"img_id": img_id, "song_id": song_id, "src_img_size": src_img_size, "formats": formats}
|
||||
|
||||
|
||||
def _parse_mhni_filename(data: bytes, mhni_offset: int, container_end: int) -> str | None:
|
||||
"""Read the MHOD type=3 filename child from an MHNI chunk."""
|
||||
if mhni_offset + 12 > container_end:
|
||||
return None
|
||||
mhni_header = struct.unpack_from("<I", data, mhni_offset + 4)[0]
|
||||
mhni_total = struct.unpack_from("<I", data, mhni_offset + 8)[0]
|
||||
if mhni_header < MHNI_HEADER_SIZE:
|
||||
mhni_header = MHNI_HEADER_SIZE
|
||||
mhni_end = min(container_end, mhni_offset + mhni_total)
|
||||
child_offset = mhni_offset + mhni_header
|
||||
while child_offset + MHOD_HEADER_SIZE <= mhni_end:
|
||||
if data[child_offset:child_offset + 4] != b"mhod":
|
||||
break
|
||||
mhod_header = struct.unpack_from("<I", data, child_offset + 4)[0]
|
||||
mhod_total = struct.unpack_from("<I", data, child_offset + 8)[0]
|
||||
mhod_type = struct.unpack_from("<H", data, child_offset + 12)[0]
|
||||
if mhod_header < MHOD_HEADER_SIZE or mhod_total < mhod_header:
|
||||
break
|
||||
if child_offset + mhod_total > mhni_end:
|
||||
break
|
||||
if mhod_type == 3:
|
||||
return _parse_mhod_string(data, child_offset, mhod_total)
|
||||
child_offset += mhod_total
|
||||
return None
|
||||
|
||||
|
||||
def _parse_mhod_string(data: bytes, offset: int, total_len: int) -> str | None:
|
||||
"""Parse an ArtworkDB string MHOD body."""
|
||||
if offset + MHOD_HEADER_SIZE + 12 > offset + total_len:
|
||||
return None
|
||||
header_size = struct.unpack_from("<I", data, offset + 4)[0]
|
||||
if header_size < MHOD_HEADER_SIZE or header_size > total_len:
|
||||
return None
|
||||
body_offset = offset + header_size
|
||||
body_end = offset + total_len
|
||||
if body_offset + 12 > body_end:
|
||||
return None
|
||||
str_len = struct.unpack_from("<I", data, body_offset)[0]
|
||||
encoding_byte = data[body_offset + 4]
|
||||
raw_start = body_offset + 12
|
||||
raw_end = min(body_end, raw_start + str_len)
|
||||
raw = data[raw_start:raw_end]
|
||||
try:
|
||||
if encoding_byte == 2:
|
||||
return raw.decode("utf-16-le", errors="replace").rstrip("\x00")
|
||||
return raw.decode("utf-8", errors="replace").rstrip("\x00")
|
||||
except UnicodeError:
|
||||
return None
|
||||
260
src/artwork/codecs.py
Normal file
260
src/artwork/codecs.py
Normal file
@ -0,0 +1,260 @@
|
||||
"""Format-aware ithmb encode/decode for iPod artwork.
|
||||
|
||||
Ported from iOpenPod ArtworkDB_Writer/ithmb_codecs.py.
|
||||
Supports RGB565_LE, RGB565_BE, RGB565_BE_90, RGB555_LE, RGB555_BE,
|
||||
REC_RGB555_LE, UYVY, I420_LE, JPEG.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from .presets import ARTWORK_FORMATS_BY_ID, ArtworkFormat
|
||||
from .types import EncodedFormatPayload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _fmt(format_id: int, fmt_override: ArtworkFormat | None = None) -> ArtworkFormat | None:
|
||||
return fmt_override if fmt_override is not None else ARTWORK_FORMATS_BY_ID.get(format_id)
|
||||
|
||||
|
||||
def format_pixel_format(format_id: int, fmt_override: ArtworkFormat | None = None) -> str:
|
||||
fmt = _fmt(format_id, fmt_override)
|
||||
return fmt.pixel_format if fmt is not None else "UNKNOWN"
|
||||
|
||||
|
||||
def format_dimensions(
|
||||
format_id: int,
|
||||
fallback_w: int,
|
||||
fallback_h: int,
|
||||
fmt_override: ArtworkFormat | None = None,
|
||||
) -> tuple[int, int]:
|
||||
fmt = _fmt(format_id, fmt_override)
|
||||
if fmt is None:
|
||||
return fallback_w, fallback_h
|
||||
return int(fmt.width), int(fmt.height)
|
||||
|
||||
|
||||
def default_stride_pixels(format_id: int, width: int, fmt_override: ArtworkFormat | None = None) -> int:
|
||||
fmt = _fmt(format_id, fmt_override)
|
||||
if fmt is None:
|
||||
return width
|
||||
pf = fmt.pixel_format
|
||||
if pf in ("RGB565_LE", "RGB565_BE", "RGB565_BE_90", "RGB555_LE", "RGB555_BE"):
|
||||
return max(width, int(fmt.row_bytes // 2) if fmt.row_bytes else width)
|
||||
if pf.startswith("REC_RGB555"):
|
||||
return max(width, int(fmt.row_bytes // 2) if fmt.row_bytes else width)
|
||||
if pf == "UYVY":
|
||||
return max(width, int(fmt.row_bytes // 2) if fmt.row_bytes else width)
|
||||
return width
|
||||
|
||||
|
||||
def expected_size_bytes(
|
||||
format_id: int,
|
||||
width: int,
|
||||
height: int,
|
||||
stride_pixels: int | None = None,
|
||||
fmt_override: ArtworkFormat | None = None,
|
||||
) -> int:
|
||||
pf = format_pixel_format(format_id, fmt_override=fmt_override)
|
||||
stride = stride_pixels if stride_pixels is not None else default_stride_pixels(
|
||||
format_id, width, fmt_override=fmt_override,
|
||||
)
|
||||
if pf in ("RGB565_LE", "RGB565_BE", "RGB565_BE_90", "RGB555_LE", "RGB555_BE", "UYVY") or pf.startswith("REC_RGB555"):
|
||||
return int(stride) * int(height) * 2
|
||||
if pf == "I420_LE":
|
||||
w = int(width) & ~1
|
||||
h = int(height) & ~1
|
||||
return (w * h * 3) // 2
|
||||
if pf in ("JPEG", "UNKNOWN"):
|
||||
return 0
|
||||
return int(stride) * int(height) * 2
|
||||
|
||||
|
||||
def _rgb565_array_from_image(img: Image.Image) -> np.ndarray:
|
||||
arr = np.array(img.convert("RGB"), dtype=np.uint32)
|
||||
r = (arr[:, :, 0] >> 3) & 0x1F
|
||||
g = (arr[:, :, 1] >> 2) & 0x3F
|
||||
b = (arr[:, :, 2] >> 3) & 0x1F
|
||||
return ((r << 11) | (g << 5) | b).astype(np.uint16)
|
||||
|
||||
|
||||
def _rgb565_to_rgb(arr16: np.ndarray) -> np.ndarray:
|
||||
r = ((arr16 >> 11) & 0x1F).astype(np.uint8)
|
||||
g = ((arr16 >> 5) & 0x3F).astype(np.uint8)
|
||||
b = (arr16 & 0x1F).astype(np.uint8)
|
||||
return np.stack(((r << 3) | (r >> 2), (g << 2) | (g >> 4), (b << 3) | (b >> 2)), axis=2)
|
||||
|
||||
|
||||
def _rgb555_array_from_image(img: Image.Image) -> np.ndarray:
|
||||
arr = np.array(img.convert("RGB"), dtype=np.uint32)
|
||||
r = (arr[:, :, 0] >> 3) & 0x1F
|
||||
g = (arr[:, :, 1] >> 3) & 0x1F
|
||||
b = (arr[:, :, 2] >> 3) & 0x1F
|
||||
return ((r << 10) | (g << 5) | b).astype(np.uint16)
|
||||
|
||||
|
||||
def _rgb555_to_rgb(arr16: np.ndarray) -> np.ndarray:
|
||||
r = ((arr16 >> 10) & 0x1F).astype(np.uint8)
|
||||
g = ((arr16 >> 5) & 0x1F).astype(np.uint8)
|
||||
b = (arr16 & 0x1F).astype(np.uint8)
|
||||
return np.stack(((r << 3) | (r >> 2), (g << 3) | (g >> 2), (b << 3) | (b >> 2)), axis=2)
|
||||
|
||||
|
||||
def _pad_packed_rows(arr16: np.ndarray, stride_pixels: int) -> np.ndarray:
|
||||
stride = max(1, int(stride_pixels))
|
||||
height, width = arr16.shape[:2]
|
||||
if stride <= width:
|
||||
return arr16
|
||||
padded = np.zeros((height, stride), dtype=arr16.dtype)
|
||||
padded[:, :width] = arr16
|
||||
return padded
|
||||
|
||||
|
||||
def encode_for_ipod(
|
||||
source_img: Image.Image,
|
||||
format_id: int,
|
||||
target_width: int | None = None,
|
||||
target_height: int | None = None,
|
||||
fmt_override: ArtworkFormat | None = None,
|
||||
) -> EncodedFormatPayload:
|
||||
"""Encode a PIL Image into an iPod-native format payload."""
|
||||
pf = format_pixel_format(format_id, fmt_override=fmt_override)
|
||||
w, h = format_dimensions(
|
||||
format_id,
|
||||
int(target_width or source_img.width),
|
||||
int(target_height or source_img.height),
|
||||
fmt_override=fmt_override,
|
||||
)
|
||||
stride = default_stride_pixels(format_id, w, fmt_override=fmt_override)
|
||||
base = source_img.convert("RGB").resize((w, h), Image.Resampling.LANCZOS)
|
||||
|
||||
if pf == "RGB565_BE_90":
|
||||
rotated = base.transpose(Image.Transpose.ROTATE_270)
|
||||
arr16 = _rgb565_array_from_image(rotated)
|
||||
arr16 = _pad_packed_rows(arr16, stride)
|
||||
raw = arr16.astype(">u2").tobytes()
|
||||
return EncodedFormatPayload(raw, w, h, len(raw), stride, pixel_format=pf)
|
||||
|
||||
if pf == "RGB565_BE":
|
||||
arr16 = _rgb565_array_from_image(base)
|
||||
arr16 = _pad_packed_rows(arr16, stride)
|
||||
raw = arr16.astype(">u2").tobytes()
|
||||
return EncodedFormatPayload(raw, w, h, len(raw), stride, pixel_format=pf)
|
||||
|
||||
if pf == "RGB555_BE":
|
||||
arr16 = _rgb555_array_from_image(base)
|
||||
arr16 = _pad_packed_rows(arr16, stride)
|
||||
raw = arr16.astype(">u2").tobytes()
|
||||
return EncodedFormatPayload(raw, w, h, len(raw), stride, pixel_format=pf)
|
||||
|
||||
if pf in ("RGB555_LE", "REC_RGB555_LE"):
|
||||
arr16 = _rgb555_array_from_image(base)
|
||||
arr16 = _pad_packed_rows(arr16, stride)
|
||||
raw = arr16.astype("<u2").tobytes()
|
||||
return EncodedFormatPayload(raw, w, h, len(raw), stride, pixel_format=pf)
|
||||
|
||||
if pf == "JPEG":
|
||||
out = io.BytesIO()
|
||||
base.save(out, format="JPEG", quality=92, optimize=False)
|
||||
raw = out.getvalue()
|
||||
return EncodedFormatPayload(raw, w, h, len(raw), stride, pixel_format=pf)
|
||||
|
||||
if pf == "UYVY":
|
||||
if w % 2 != 0:
|
||||
w -= 1
|
||||
base = base.resize((w, h), Image.Resampling.LANCZOS)
|
||||
arr = np.array(base, dtype=np.float32)
|
||||
r, g, b = arr[:, :, 0], arr[:, :, 1], arr[:, :, 2]
|
||||
y = np.clip(0.257 * r + 0.504 * g + 0.098 * b + 16, 0, 255).astype(np.uint8)
|
||||
u = np.clip(-0.148 * r - 0.291 * g + 0.439 * b + 128, 0, 255)
|
||||
v = np.clip(0.439 * r - 0.368 * g - 0.071 * b + 128, 0, 255)
|
||||
u2 = ((u[:, 0::2] + u[:, 1::2]) * 0.5).astype(np.uint8)
|
||||
v2 = ((v[:, 0::2] + v[:, 1::2]) * 0.5).astype(np.uint8)
|
||||
packed = np.empty((h, w * 2), dtype=np.uint8)
|
||||
packed[:, 0::4] = u2
|
||||
packed[:, 1::4] = y[:, 0::2]
|
||||
packed[:, 2::4] = v2
|
||||
packed[:, 3::4] = y[:, 1::2]
|
||||
raw = packed.tobytes()
|
||||
return EncodedFormatPayload(raw, w, h, len(raw), stride, pixel_format=pf)
|
||||
|
||||
if pf == "I420_LE":
|
||||
w_even, h_even = w & ~1, h & ~1
|
||||
if w_even != w or h_even != h:
|
||||
w, h = w_even, h_even
|
||||
base = base.resize((w, h), Image.Resampling.LANCZOS)
|
||||
arr = np.array(base, dtype=np.float32)
|
||||
r, g, b = arr[:, :, 0], arr[:, :, 1], arr[:, :, 2]
|
||||
y = np.clip(0.257 * r + 0.504 * g + 0.098 * b + 16, 0, 255).astype(np.uint8)
|
||||
u = np.clip(-0.148 * r - 0.291 * g + 0.439 * b + 128, 0, 255)
|
||||
v = np.clip(0.439 * r - 0.368 * g - 0.071 * b + 128, 0, 255)
|
||||
u420 = ((u[0::2, 0::2] + u[0::2, 1::2] + u[1::2, 0::2] + u[1::2, 1::2]) * 0.25).astype(np.uint8)
|
||||
v420 = ((v[0::2, 0::2] + v[0::2, 1::2] + v[1::2, 0::2] + v[1::2, 1::2]) * 0.25).astype(np.uint8)
|
||||
raw = y.tobytes() + u420.tobytes() + v420.tobytes()
|
||||
return EncodedFormatPayload(raw, w, h, len(raw), stride, pixel_format=pf)
|
||||
|
||||
arr16 = _rgb565_array_from_image(base)
|
||||
arr16 = _pad_packed_rows(arr16, stride)
|
||||
raw = arr16.astype("<u2").tobytes()
|
||||
return EncodedFormatPayload(raw, w, h, len(raw), stride, "RGB565_LE")
|
||||
|
||||
|
||||
def decode_from_ipod(
|
||||
pixel_bytes: bytes,
|
||||
format_id: int,
|
||||
width: int,
|
||||
height: int,
|
||||
hpad: int = 0,
|
||||
vpad: int = 0,
|
||||
fmt_override: ArtworkFormat | None = None,
|
||||
) -> Image.Image | None:
|
||||
"""Decode iPod-native pixel data back to a PIL Image."""
|
||||
pf = format_pixel_format(format_id, fmt_override=fmt_override)
|
||||
width = max(1, int(width))
|
||||
height = max(1, int(height))
|
||||
|
||||
if pf in ("RGB565_LE", "RGB565_BE", "RGB565_BE_90"):
|
||||
dtype = "<u2" if pf == "RGB565_LE" else ">u2"
|
||||
stride = default_stride_pixels(format_id, width, fmt_override=fmt_override)
|
||||
stored_h = height
|
||||
needed = stride * stored_h * 2
|
||||
if len(pixel_bytes) < needed:
|
||||
return None
|
||||
arr = np.frombuffer(pixel_bytes[:needed], dtype=dtype)
|
||||
if arr.size != stride * stored_h:
|
||||
return None
|
||||
arr = arr.reshape((stored_h, stride))
|
||||
arr = arr[:, :width]
|
||||
rgb = _rgb565_to_rgb(arr)
|
||||
if pf == "RGB565_BE_90":
|
||||
rgb = np.rot90(rgb, k=1)
|
||||
return Image.fromarray(rgb)
|
||||
|
||||
if pf in ("RGB555_LE", "RGB555_BE", "REC_RGB555_LE"):
|
||||
dtype = "<u2" if pf != "RGB555_BE" else ">u2"
|
||||
stride = default_stride_pixels(format_id, width, fmt_override=fmt_override)
|
||||
stored_h = height
|
||||
needed = stride * stored_h * 2
|
||||
if len(pixel_bytes) < needed:
|
||||
return None
|
||||
arr = np.frombuffer(pixel_bytes[:needed], dtype=dtype)
|
||||
if arr.size != stride * stored_h:
|
||||
return None
|
||||
arr = arr.reshape((stored_h, stride))
|
||||
arr = arr[:, :width]
|
||||
rgb = _rgb555_to_rgb(arr)
|
||||
return Image.fromarray(rgb)
|
||||
|
||||
if pf == "JPEG":
|
||||
try:
|
||||
return Image.open(io.BytesIO(pixel_bytes)).convert("RGB")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return None
|
||||
160
src/artwork/extractor.py
Normal file
160
src/artwork/extractor.py
Normal file
@ -0,0 +1,160 @@
|
||||
"""Extract embedded album art from media files using mutagen.
|
||||
|
||||
Ported from iOpenPod ArtworkDB_Writer/art_extractor.py.
|
||||
Supports: MP3, M4A/AAC, M4B, M4V/MP4, FLAC, OGG, OPUS, AIFF/WAV.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import mutagen
|
||||
|
||||
|
||||
def extract_cover(file_path: str) -> bytes | None:
|
||||
"""Extract the first embedded album art image from a media file.
|
||||
|
||||
Returns raw image bytes (JPEG/PNG) or None.
|
||||
"""
|
||||
path = Path(file_path)
|
||||
ext = path.suffix.lower()
|
||||
|
||||
try:
|
||||
if ext in _IMAGE_EXTS:
|
||||
return path.read_bytes()
|
||||
if ext == ".mp3":
|
||||
return _extract_mp3(file_path)
|
||||
elif ext in (".m4a", ".m4p", ".m4b", ".aac", ".alac"):
|
||||
return _extract_mp4(file_path)
|
||||
elif ext in (".m4v", ".mp4", ".mov"):
|
||||
art = _extract_mp4(file_path)
|
||||
if art:
|
||||
return art
|
||||
return None
|
||||
elif ext == ".flac":
|
||||
return _extract_flac(file_path)
|
||||
elif ext == ".ogg":
|
||||
return _extract_ogg(file_path)
|
||||
elif ext == ".opus":
|
||||
return _extract_opus(file_path)
|
||||
elif ext in (".aif", ".aiff"):
|
||||
return _extract_aiff(file_path)
|
||||
else:
|
||||
return _extract_generic(file_path)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def find_folder_art(file_path: str) -> str | None:
|
||||
"""Return path of folder artwork image next to *file_path*, or None."""
|
||||
directory = str(Path(file_path).parent)
|
||||
try:
|
||||
entries = os.listdir(directory)
|
||||
except OSError:
|
||||
return None
|
||||
lower_map = {e.lower(): e for e in entries}
|
||||
for stem in _FOLDER_ART_NAMES:
|
||||
for ext in _IMAGE_EXTS:
|
||||
if (stem + ext) in lower_map:
|
||||
return os.path.join(directory, lower_map[stem + ext])
|
||||
return None
|
||||
|
||||
|
||||
def extract_cover_with_fallback(file_path: str) -> bytes | None:
|
||||
"""Extract cover art, falling back to folder.jpg if no embedded art."""
|
||||
art = extract_cover(file_path)
|
||||
if art is not None:
|
||||
return art
|
||||
folder_img = find_folder_art(file_path)
|
||||
if folder_img:
|
||||
try:
|
||||
return Path(folder_img).read_bytes()
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
_FOLDER_ART_NAMES = ("cover", "folder", "album", "front", "artwork", "thumb")
|
||||
_IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp")
|
||||
|
||||
|
||||
def _extract_mp3(path: str) -> bytes | None:
|
||||
from mutagen.mp3 import MP3
|
||||
audio = MP3(path)
|
||||
if audio.tags is None:
|
||||
return None
|
||||
for key in audio.tags:
|
||||
if key.startswith("APIC"):
|
||||
return audio.tags[key].data
|
||||
return None
|
||||
|
||||
|
||||
def _extract_mp4(path: str) -> bytes | None:
|
||||
from mutagen.mp4 import MP4
|
||||
audio = MP4(path)
|
||||
if audio.tags is None:
|
||||
return None
|
||||
covers = audio.tags.get("covr", [])
|
||||
if covers:
|
||||
return bytes(covers[0])
|
||||
return None
|
||||
|
||||
|
||||
def _extract_flac(path: str) -> bytes | None:
|
||||
from mutagen.flac import FLAC
|
||||
audio = FLAC(path)
|
||||
if audio.pictures:
|
||||
return audio.pictures[0].data
|
||||
return None
|
||||
|
||||
|
||||
def _extract_ogg(path: str) -> bytes | None:
|
||||
from mutagen.oggvorbis import OggVorbis
|
||||
audio = OggVorbis(path)
|
||||
return _extract_vorbis_picture(audio)
|
||||
|
||||
|
||||
def _extract_opus(path: str) -> bytes | None:
|
||||
from mutagen.oggopus import OggOpus
|
||||
audio = OggOpus(path)
|
||||
return _extract_vorbis_picture(audio)
|
||||
|
||||
|
||||
def _extract_vorbis_picture(audio) -> bytes | None:
|
||||
import base64
|
||||
pictures = audio.get("metadata_block_picture", [])
|
||||
if pictures:
|
||||
try:
|
||||
from mutagen.flac import Picture
|
||||
pic = Picture(base64.b64decode(pictures[0]))
|
||||
return pic.data
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _extract_aiff(path: str) -> bytes | None:
|
||||
from mutagen.aiff import AIFF
|
||||
audio = AIFF(path)
|
||||
if audio.tags is None:
|
||||
return None
|
||||
for key in audio.tags:
|
||||
if key.startswith("APIC"):
|
||||
return audio.tags[key].data
|
||||
return None
|
||||
|
||||
|
||||
def _extract_generic(path: str) -> bytes | None:
|
||||
audio = mutagen.File(path)
|
||||
if audio is None or audio.tags is None:
|
||||
return None
|
||||
for key in audio.tags:
|
||||
if hasattr(key, "startswith") and key.startswith("APIC"):
|
||||
frame = audio.tags[key]
|
||||
if hasattr(frame, "data"):
|
||||
return frame.data
|
||||
covers = audio.tags.get("covr", [])
|
||||
if covers:
|
||||
return bytes(covers[0])
|
||||
return None
|
||||
82
src/artwork/presets.py
Normal file
82
src/artwork/presets.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""Canonical ithmb artwork format definitions for iPod devices.
|
||||
|
||||
Ported from iOpenPod ArtworkDB_Writer/artwork_presets.py.
|
||||
For Nano 7G the relevant formats are 1010 (240x240), 1013 (50x50),
|
||||
1015 (58x58), and 1016 (57x57) — all RGB565_LE.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArtworkFormat:
|
||||
format_id: int
|
||||
width: int
|
||||
height: int
|
||||
row_bytes: int
|
||||
pixel_format: str = "RGB565_LE"
|
||||
role: str = "cover"
|
||||
description: str = ""
|
||||
|
||||
|
||||
ARTWORK_FORMATS_BY_ID: dict[int, ArtworkFormat] = {
|
||||
1005: ArtworkFormat(1005, 80, 80, 160, "RGB565_LE", "photo_thumb", "Nano 7G photo thumbnail"),
|
||||
1007: ArtworkFormat(1007, 480, 864, 960, "RGB565_LE", "photo_full", "Nano 7G photo full screen"),
|
||||
1009: ArtworkFormat(1009, 42, 30, 84, "RGB565_LE", "photo_list", "Photo list thumbnail"),
|
||||
1010: ArtworkFormat(1010, 240, 240, 480, "RGB565_LE", "cover_large", "Nano 7G album art large"),
|
||||
1013: ArtworkFormat(1013, 220, 176, 440, "RGB565_BE_90", "photo_full", "Photo full screen (rotated)"),
|
||||
1015: ArtworkFormat(1015, 130, 88, 260, "RGB565_LE", "photo_preview", "Photo/Video preview"),
|
||||
1016: ArtworkFormat(1016, 140, 140, 280, "RGB565_LE", "cover_large", "Photo album art large"),
|
||||
1017: ArtworkFormat(1017, 56, 56, 112, "RGB565_LE", "cover_small", "Photo album art small"),
|
||||
1019: ArtworkFormat(1019, 720, 480, 1440, "UYVY", "tv_out", "Photo/Video NTSC TV output"),
|
||||
1020: ArtworkFormat(1020, 220, 176, 440, "RGB565_BE_90", "photo_full", "Photo full screen (alt rotated)"),
|
||||
1023: ArtworkFormat(1023, 176, 132, 352, "RGB565_BE", "photo_full", "Nano full screen"),
|
||||
1024: ArtworkFormat(1024, 320, 240, 640, "RGB565_LE", "photo_full", "320x240 photo full screen"),
|
||||
1027: ArtworkFormat(1027, 100, 100, 200, "RGB565_LE", "cover_large", "Nano album art large"),
|
||||
1028: ArtworkFormat(1028, 100, 100, 200, "RGB565_LE", "cover_small", "Video album art small"),
|
||||
1029: ArtworkFormat(1029, 200, 200, 400, "RGB565_LE", "cover_large", "Video album art large"),
|
||||
1031: ArtworkFormat(1031, 42, 42, 84, "RGB565_LE", "cover_small", "Nano album art small"),
|
||||
1032: ArtworkFormat(1032, 42, 37, 84, "RGB565_LE", "photo_list", "Nano list thumbnail"),
|
||||
1036: ArtworkFormat(1036, 50, 41, 100, "RGB565_LE", "photo_list", "Video list thumbnail"),
|
||||
1044: ArtworkFormat(1044, 128, 128, 256, "RGB565_LE", "cover_medium", "Classic album art medium"),
|
||||
1055: ArtworkFormat(1055, 128, 128, 256, "RGB565_LE", "cover_medium", "Classic album art medium"),
|
||||
1056: ArtworkFormat(1056, 128, 128, 256, "RGB565_LE", "cover_medium_alt", "128x128 cover art (alternate)"),
|
||||
1060: ArtworkFormat(1060, 320, 320, 640, "RGB565_LE", "cover_large", "Classic album art large"),
|
||||
1061: ArtworkFormat(1061, 56, 56, 112, "RGB565_LE", "cover_small", "Classic album art small"),
|
||||
1066: ArtworkFormat(1066, 64, 64, 128, "RGB565_LE", "photo_thumb", "Classic photo thumbnail"),
|
||||
1067: ArtworkFormat(1067, 720, 480, 1080, "I420_LE", "tv_out", "Classic TV output (YUV)"),
|
||||
1068: ArtworkFormat(1068, 128, 128, 256, "RGB565_LE", "cover_medium_alt", "Classic album art medium (alt 2)"),
|
||||
1071: ArtworkFormat(1071, 240, 240, 480, "RGB565_LE", "cover_large", "Nano 4G album art large"),
|
||||
1073: ArtworkFormat(1073, 240, 240, 480, "RGB565_LE", "cover_large", "Nano 5G/6G album art large"),
|
||||
1074: ArtworkFormat(1074, 50, 50, 100, "RGB565_LE", "cover_xsmall", "Nano album art tiny"),
|
||||
1078: ArtworkFormat(1078, 80, 80, 160, "RGB565_LE", "cover_small", "Nano 4G/5G album art small"),
|
||||
1079: ArtworkFormat(1079, 80, 80, 160, "RGB565_LE", "photo_thumb", "Nano 4G/5G photo thumbnail"),
|
||||
1081: ArtworkFormat(1081, 640, 480, 0, "JPEG", "photo_full", "JPEG photo format (experimental/legacy)"),
|
||||
1083: ArtworkFormat(1083, 240, 320, 480, "RGB565_LE", "photo_full", "Nano 4G photo full screen (portrait)"),
|
||||
1084: ArtworkFormat(1084, 240, 240, 480, "RGB565_LE", "cover_large_alt", "Nano 4G album art (alt)"),
|
||||
1085: ArtworkFormat(1085, 88, 88, 176, "RGB565_LE", "cover_medium", "Nano 6G album art medium"),
|
||||
1087: ArtworkFormat(1087, 384, 384, 768, "RGB565_LE", "photo_large", "Nano 5G photo large"),
|
||||
1089: ArtworkFormat(1089, 58, 58, 116, "RGB565_LE", "cover_small", "Nano 6G album art small"),
|
||||
1092: ArtworkFormat(1092, 80, 80, 160, "RGB565_LE", "photo_thumb", "Nano 6G photo thumbnail"),
|
||||
1093: ArtworkFormat(1093, 512, 512, 1024, "RGB565_LE", "photo_full", "Nano 6G photo full screen"),
|
||||
2002: ArtworkFormat(2002, 50, 50, 100, "RGB565_BE", "cover_small", "iPod Mobile cover art small"),
|
||||
2003: ArtworkFormat(2003, 150, 150, 300, "RGB565_BE", "cover_large", "iPod Mobile cover art large"),
|
||||
3001: ArtworkFormat(3001, 256, 256, 512, "REC_RGB555_LE", "cover_large", "iPod touch cover art large"),
|
||||
3002: ArtworkFormat(3002, 128, 128, 256, "REC_RGB555_LE", "cover_medium", "iPod touch cover art medium"),
|
||||
3003: ArtworkFormat(3003, 64, 64, 128, "REC_RGB555_LE", "cover_small", "iPod touch cover art small"),
|
||||
3005: ArtworkFormat(3005, 320, 320, 640, "RGB555_LE", "cover_xlarge", "iPod touch cover art xlarge"),
|
||||
}
|
||||
|
||||
|
||||
NANO_7G_COVER_ART_FORMATS = (
|
||||
ARTWORK_FORMATS_BY_ID[1010],
|
||||
ArtworkFormat(1013, 50, 50, 100, "RGB565_LE", "cover_xsmall", "Nano 7G album art tiny"),
|
||||
ArtworkFormat(1015, 58, 58, 116, "RGB565_LE", "cover_small", "Nano 7G album art small"),
|
||||
ArtworkFormat(1016, 57, 57, 116, "RGB565_LE", "cover_small_alt", "Nano 7G album art small (aligned)"),
|
||||
)
|
||||
"""Nano 7G cover art format overrides:
|
||||
- 1010: 240x240 (large)
|
||||
- 1013: 50x50 (tiny, overrides global 220x176)
|
||||
- 1015: 58x58 (small, overrides global 130x88)
|
||||
- 1016: 57x57 (small alt, overrides global 140x140)
|
||||
"""
|
||||
75
src/artwork/types.py
Normal file
75
src/artwork/types.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""Typed models for the artwork pipeline.
|
||||
|
||||
Ported from iOpenPod ArtworkDB_Writer/artwork_types.py, simplified.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IthmbLocation:
|
||||
filename: str
|
||||
offset: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExistingFormatRef:
|
||||
path: str
|
||||
ithmb_offset: int
|
||||
size: int
|
||||
width: int
|
||||
height: int
|
||||
hpad: int = 0
|
||||
vpad: int = 0
|
||||
ithmb_filename: str = ""
|
||||
|
||||
@property
|
||||
def stride_pixels(self) -> int:
|
||||
return max(1, self.width + self.hpad)
|
||||
|
||||
@property
|
||||
def stored_height(self) -> int:
|
||||
return max(1, self.height + self.vpad)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EncodedFormatPayload:
|
||||
data: bytes
|
||||
width: int
|
||||
height: int
|
||||
size: int
|
||||
stride_pixels: int
|
||||
hpad: int = 0
|
||||
vpad: int = 0
|
||||
pixel_format: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_existing_ref(cls, ref: ExistingFormatRef, data: bytes) -> "EncodedFormatPayload":
|
||||
return cls(
|
||||
data=data,
|
||||
width=ref.width,
|
||||
height=ref.height,
|
||||
size=ref.size,
|
||||
stride_pixels=ref.stride_pixels,
|
||||
hpad=ref.hpad,
|
||||
vpad=ref.vpad,
|
||||
)
|
||||
|
||||
|
||||
ArtworkFormatPayload = EncodedFormatPayload | ExistingFormatRef
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArtworkPayload:
|
||||
formats: dict[int, ArtworkFormatPayload] = field(default_factory=dict)
|
||||
src_img_size: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArtworkEntry:
|
||||
img_id: int
|
||||
db_track_id: int
|
||||
art_hash: str | None
|
||||
src_img_size: int
|
||||
formats: dict[int, ArtworkFormatPayload] = field(default_factory=dict)
|
||||
db_track_ids: list[int] = field(default_factory=list)
|
||||
187
src/artwork/writer.py
Normal file
187
src/artwork/writer.py
Normal file
@ -0,0 +1,187 @@
|
||||
"""Write artwork (ithmb + ArtworkDB) to iPod.
|
||||
|
||||
Ported from iOpenPod ArtworkDB_Writer/artwork_writer.py, simplified
|
||||
for our use case (Nano 7, always-new tracks, no incremental sync).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from .presets import NANO_7G_COVER_ART_FORMATS, ArtworkFormat
|
||||
from .types import ArtworkEntry, EncodedFormatPayload, ExistingFormatRef, IthmbLocation
|
||||
from .codecs import encode_for_ipod
|
||||
from .chunks import build_artworkdb
|
||||
from .extractor import extract_cover_with_fallback
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ITHMB_MAX_SIZE_BYTES = 256 * 1000 * 1000
|
||||
|
||||
|
||||
def _ithmb_filename(format_id: int, index: int = 1) -> str:
|
||||
return f"F{int(format_id)}_{int(index)}.ithmb"
|
||||
|
||||
|
||||
def _art_hash(art_bytes: bytes) -> str:
|
||||
return hashlib.md5(art_bytes).hexdigest()
|
||||
|
||||
|
||||
def image_from_bytes(art_bytes: bytes) -> Image.Image | None:
|
||||
"""Load a PIL Image from raw bytes (JPEG/PNG), ensuring RGB mode."""
|
||||
from io import BytesIO
|
||||
try:
|
||||
img = Image.open(BytesIO(art_bytes))
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGBA").convert("RGB")
|
||||
return img
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def prepare_artwork_for_track(source_path: str) -> tuple[str, dict[int, EncodedFormatPayload]] | None:
|
||||
"""Extract cover art from a source file and encode as iPod formats.
|
||||
|
||||
Returns (art_hash, {format_id: EncodedFormatPayload}) or None.
|
||||
"""
|
||||
art_bytes = extract_cover_with_fallback(source_path)
|
||||
if art_bytes is None:
|
||||
return None
|
||||
|
||||
img = image_from_bytes(art_bytes)
|
||||
if img is None:
|
||||
return None
|
||||
|
||||
formats: dict[int, EncodedFormatPayload] = {}
|
||||
for fmt in NANO_7G_COVER_ART_FORMATS:
|
||||
try:
|
||||
encoded = encode_for_ipod(img, fmt.format_id, fmt_override=fmt)
|
||||
formats[fmt.format_id] = encoded
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to encode format %d: %s", fmt.format_id, exc)
|
||||
|
||||
if not formats:
|
||||
return None
|
||||
|
||||
return _art_hash(art_bytes), formats
|
||||
|
||||
|
||||
def write_artworkdb(
|
||||
ipod_path: str,
|
||||
entries: list[ArtworkEntry],
|
||||
start_img_id: int = 100,
|
||||
) -> dict[int, tuple[int, int]]:
|
||||
"""Write ithmb files and ArtworkDB binary to the iPod.
|
||||
|
||||
Args:
|
||||
ipod_path: iPod mount point
|
||||
entries: List of ArtworkEntry (one per unique album art)
|
||||
start_img_id: Starting image ID
|
||||
|
||||
Returns:
|
||||
Dict mapping db_track_id → (img_id, src_img_size)
|
||||
"""
|
||||
artwork_dir = os.path.join(ipod_path, "iPod_Control", "Artwork")
|
||||
os.makedirs(artwork_dir, exist_ok=True)
|
||||
|
||||
# Collect all unique format IDs across all entries
|
||||
format_ids = sorted({fmt_id for entry in entries for fmt_id in entry.formats.keys()})
|
||||
writable_format_ids = sorted({
|
||||
fmt_id for entry in entries
|
||||
for fmt_id, payload in entry.formats.items()
|
||||
if isinstance(payload, EncodedFormatPayload)
|
||||
})
|
||||
|
||||
# Write ithmb files
|
||||
ithmb_state: dict[int, dict[str, int]] = {
|
||||
fmt_id: {"index": 0, "offset": 0}
|
||||
for fmt_id in writable_format_ids
|
||||
}
|
||||
ithmb_files: dict[int, object] = {}
|
||||
format_locations_map: dict[int, dict[int, IthmbLocation]] = {}
|
||||
image_sizes: dict[int, int] = {}
|
||||
|
||||
def _close_current(fmt_id: int) -> None:
|
||||
handle = ithmb_files.pop(fmt_id, None)
|
||||
if handle is not None:
|
||||
handle.close()
|
||||
|
||||
def _open_next(fmt_id: int) -> None:
|
||||
_close_current(fmt_id)
|
||||
state = ithmb_state[fmt_id]
|
||||
state["index"] += 1
|
||||
state["offset"] = 0
|
||||
filename = _ithmb_filename(fmt_id, state["index"])
|
||||
ithmb_files[fmt_id] = open(os.path.join(artwork_dir, filename), "wb")
|
||||
|
||||
def _write_payload(fmt_id: int, data: bytes) -> IthmbLocation:
|
||||
state = ithmb_state[fmt_id]
|
||||
if fmt_id not in ithmb_files:
|
||||
_open_next(fmt_id)
|
||||
elif state["offset"] > 0 and state["offset"] + len(data) > ITHMB_MAX_SIZE_BYTES:
|
||||
_open_next(fmt_id)
|
||||
filename = _ithmb_filename(fmt_id, state["index"])
|
||||
offset = state["offset"]
|
||||
ithmb_files[fmt_id].write(data)
|
||||
state["offset"] += len(data)
|
||||
return IthmbLocation(filename, offset)
|
||||
|
||||
try:
|
||||
for entry in entries:
|
||||
locations: dict[int, IthmbLocation] = {}
|
||||
for fmt_id in format_ids:
|
||||
if fmt_id not in entry.formats:
|
||||
continue
|
||||
payload = entry.formats[fmt_id]
|
||||
if isinstance(payload, EncodedFormatPayload):
|
||||
locations[fmt_id] = _write_payload(fmt_id, payload.data)
|
||||
elif isinstance(payload, ExistingFormatRef):
|
||||
fname = _ithmb_filename(fmt_id)
|
||||
locations[fmt_id] = IthmbLocation(fname, payload.ithmb_offset)
|
||||
format_locations_map[entry.img_id] = locations
|
||||
finally:
|
||||
for fmt_id in list(ithmb_files.keys()):
|
||||
_close_current(fmt_id)
|
||||
|
||||
# Calculate image sizes for MHIF
|
||||
for fmt_id in format_ids:
|
||||
sizes = [
|
||||
int(entry.formats[fmt_id].size)
|
||||
for entry in entries
|
||||
if fmt_id in entry.formats and int(entry.formats[fmt_id].size) > 0
|
||||
]
|
||||
if sizes:
|
||||
from collections import Counter
|
||||
image_sizes[fmt_id] = Counter(sizes).most_common(1)[0][0]
|
||||
|
||||
# Build and write ArtworkDB binary
|
||||
ref_mhfd = None
|
||||
existing_artdb = os.path.join(artwork_dir, "ArtworkDB")
|
||||
if os.path.exists(existing_artdb):
|
||||
with open(existing_artdb, "rb") as f:
|
||||
ref_mhfd = f.read()
|
||||
|
||||
next_id = start_img_id + len(entries)
|
||||
db_data = build_artworkdb(
|
||||
entries,
|
||||
format_locations_map,
|
||||
format_ids,
|
||||
image_sizes,
|
||||
next_id,
|
||||
ref_mhfd,
|
||||
)
|
||||
|
||||
with open(existing_artdb, "wb") as f:
|
||||
f.write(db_data)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
|
||||
# Return track_id → (img_id, src_img_size) mapping
|
||||
return {
|
||||
entry.db_track_id: (entry.img_id, entry.src_img_size)
|
||||
for entry in entries
|
||||
}
|
||||
@ -109,6 +109,7 @@ class AudioConverter:
|
||||
# Try libfdk_aac first (best compatibility)
|
||||
fdkaac_cmd = cmd + [
|
||||
"-map", "0:a",
|
||||
"-map", "0:t?",
|
||||
"-c:a", "libfdk_aac",
|
||||
"-profile:a", "aac_low",
|
||||
"-movflags", "+faststart",
|
||||
@ -127,6 +128,7 @@ class AudioConverter:
|
||||
# Fallback: built-in aac with LC profile
|
||||
cmd += [
|
||||
"-map", "0:a",
|
||||
"-map", "0:t?",
|
||||
"-profile:a", "aac_low",
|
||||
"-movflags", "+faststart",
|
||||
"-map_metadata", "0",
|
||||
@ -136,6 +138,7 @@ class AudioConverter:
|
||||
except Exception:
|
||||
cmd += [
|
||||
"-map", "0:a",
|
||||
"-map", "0:t?",
|
||||
"-profile:a", "aac_low",
|
||||
"-movflags", "+faststart",
|
||||
"-map_metadata", "0",
|
||||
@ -145,6 +148,8 @@ class AudioConverter:
|
||||
else:
|
||||
cmd += [
|
||||
"-map", "0:a",
|
||||
"-map", "0:t?",
|
||||
"-id3v2_version", "3",
|
||||
"-map_metadata", "0",
|
||||
"-loglevel", "error",
|
||||
output_file,
|
||||
|
||||
@ -618,6 +618,78 @@ class IPodDevice:
|
||||
self.logger.error(f"Failed to transfer directory: {e}")
|
||||
return False
|
||||
|
||||
def export_track(self, track_info: dict, dest_dir: str) -> Optional[str]:
|
||||
"""
|
||||
Copy a track from iPod to a local directory with metadata-based naming.
|
||||
|
||||
Args:
|
||||
track_info: Track dict with keys: file_path, title, artist, album, track_number
|
||||
dest_dir: Local destination directory
|
||||
|
||||
Returns:
|
||||
Path to the exported file, or None on failure
|
||||
"""
|
||||
src_path = track_info.get("file_path")
|
||||
if not src_path or not os.path.exists(src_path):
|
||||
self.logger.warning(f"Track file not found on iPod: {track_info.get('title')}")
|
||||
return None
|
||||
|
||||
if not os.path.exists(dest_dir):
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
|
||||
artist = track_info.get("artist", "Unknown Artist")
|
||||
title = track_info.get("title", "Unknown Title")
|
||||
album = track_info.get("album", "")
|
||||
track_num = track_info.get("track_number", 0)
|
||||
ext = os.path.splitext(src_path)[1].lower()
|
||||
|
||||
safe_artist = "".join(c for c in artist if c.isalnum() or c in " ._-'&()").strip() or "Unknown"
|
||||
safe_title = "".join(c for c in title if c.isalnum() or c in " ._-'&()").strip() or "Unknown"
|
||||
|
||||
if track_num:
|
||||
fname = f"{track_num:02d} - {safe_artist} - {safe_title}{ext}"
|
||||
else:
|
||||
fname = f"{safe_artist} - {safe_title}{ext}"
|
||||
|
||||
dest_path = os.path.join(dest_dir, fname)
|
||||
|
||||
try:
|
||||
shutil.copy2(src_path, dest_path)
|
||||
self.logger.info(f"Exported track to: {dest_path}")
|
||||
return dest_path
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to export track: {e}")
|
||||
return None
|
||||
|
||||
def export_tracks(self, tracks: List[dict], dest_dir: str,
|
||||
progress_callback=None) -> List[Tuple[str, str]]:
|
||||
"""
|
||||
Copy multiple tracks from iPod to a local directory.
|
||||
|
||||
Args:
|
||||
tracks: List of track info dicts
|
||||
dest_dir: Local destination directory
|
||||
progress_callback: Optional callback(progress_int, status_str)
|
||||
|
||||
Returns:
|
||||
List of (title, exported_path) tuples for successful exports
|
||||
"""
|
||||
exported = []
|
||||
total = len(tracks)
|
||||
|
||||
for i, track in enumerate(tracks):
|
||||
if progress_callback:
|
||||
progress_callback(int((i / total) * 100), f"Exporting {track.get('title', '?')}...")
|
||||
|
||||
path = self.export_track(track, dest_dir)
|
||||
if path:
|
||||
exported.append((track.get("title", "?"), path))
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(100, f"Exported {len(exported)} of {total} tracks")
|
||||
|
||||
return exported
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ipod = IPodDevice()
|
||||
|
||||
@ -21,6 +21,12 @@ from mutagen.easyid3 import EasyID3
|
||||
from mutagen.mp4 import MP4
|
||||
from mutagen.mp3 import MP3
|
||||
|
||||
from artwork.extractor import extract_cover_with_fallback
|
||||
from artwork.cache import cover_cache
|
||||
from artwork.writer import prepare_artwork_for_track, write_artworkdb
|
||||
from artwork.chunks import read_existing_artwork
|
||||
from artwork.types import ArtworkEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MASTER_CONTAINER_PID = 203092939621887772 # "iPod" master playlist
|
||||
@ -479,6 +485,12 @@ class Nano7Database:
|
||||
}
|
||||
logger.info(f"Added track: {title} by {artist}")
|
||||
|
||||
# Extract and write album art to iPod
|
||||
try:
|
||||
self._write_artwork_for_track(filepath, artist, album, pid)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to write artwork for track '%s': %s", title, exc)
|
||||
|
||||
# Sync iTunesCDB so firmware accepts the modified database
|
||||
self.sync_itunescdb()
|
||||
|
||||
@ -487,6 +499,57 @@ class Nano7Database:
|
||||
|
||||
return result
|
||||
|
||||
def _write_artwork_for_track(self, source_path: str, artist: str, album: str, item_pid: int) -> None:
|
||||
"""Extract cover art, encode for iPod, write to ithmb/ArtworkDB, update SQLite."""
|
||||
result = prepare_artwork_for_track(source_path)
|
||||
if result is None:
|
||||
return
|
||||
|
||||
art_hash, encoded_formats = result
|
||||
|
||||
artwork_dir = os.path.join(self.mountpoint, "iPod_Control", "Artwork")
|
||||
os.makedirs(artwork_dir, exist_ok=True)
|
||||
artdb_path = os.path.join(artwork_dir, "ArtworkDB")
|
||||
|
||||
existing = read_existing_artwork(artdb_path, artwork_dir)
|
||||
next_img_id = max(existing.keys(), default=99) + 1
|
||||
|
||||
src_img_size = sum(p.size for p in encoded_formats.values())
|
||||
|
||||
new_entry = ArtworkEntry(
|
||||
img_id=next_img_id,
|
||||
db_track_id=item_pid,
|
||||
art_hash=art_hash,
|
||||
src_img_size=src_img_size,
|
||||
formats=encoded_formats,
|
||||
db_track_ids=[item_pid],
|
||||
)
|
||||
|
||||
all_entries = []
|
||||
for eid in sorted(existing.keys()):
|
||||
e = existing[eid]
|
||||
all_entries.append(ArtworkEntry(
|
||||
img_id=e["img_id"],
|
||||
db_track_id=e["song_id"],
|
||||
art_hash=None,
|
||||
src_img_size=e["src_img_size"],
|
||||
formats={fid: ref for fid, ref in e["formats"].items()},
|
||||
db_track_ids=[e["song_id"]],
|
||||
))
|
||||
all_entries.append(new_entry)
|
||||
|
||||
write_artworkdb(self.mountpoint, all_entries, start_img_id=min(existing.keys(), default=100))
|
||||
|
||||
conn = sqlite3.connect(self.sqlite_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE item SET artwork_cache_id = ?, artwork_status = 1 WHERE pid = ?",
|
||||
(next_img_id, item_pid),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_track_count(self) -> int:
|
||||
"""Get number of tracks in database"""
|
||||
conn = sqlite3.connect(self.sqlite_path)
|
||||
|
||||
160
src/main.py
160
src/main.py
@ -18,12 +18,14 @@ from PyQt6.QtWidgets import (
|
||||
QTableWidget, QTableWidgetItem, QSlider, QMenu
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl
|
||||
from PyQt6.QtGui import QPixmap
|
||||
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
|
||||
|
||||
from youtube_downloader import YouTubeDownloader, TrackInfo
|
||||
from audio_converter import AudioConverter
|
||||
from metadata_handler import MetadataHandler
|
||||
from ipod_device import IPodDevice
|
||||
from artwork.cache import cover_cache
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -52,6 +54,7 @@ class WorkerThread(QThread):
|
||||
"convert": self._run_convert,
|
||||
"transfer": self._run_transfer,
|
||||
"detect_devices": self._run_detect_devices,
|
||||
"export_ipod": self._run_export_ipod,
|
||||
}
|
||||
handler = handlers.get(self.task_type)
|
||||
if handler:
|
||||
@ -201,6 +204,25 @@ class WorkerThread(QThread):
|
||||
|
||||
self.finished_signal.emit(True, f"Found {len(devices)} iPod devices", devices)
|
||||
|
||||
def _run_export_ipod(self):
|
||||
tracks = self.kwargs.get("tracks", [])
|
||||
dest_dir = self.kwargs.get("dest_dir", "")
|
||||
mount_point = self.kwargs.get("mount_point")
|
||||
|
||||
if not tracks or not dest_dir or not mount_point:
|
||||
self.finished_signal.emit(False, "Missing parameters for export", None)
|
||||
return
|
||||
|
||||
self.progress_signal.emit(0, f"Exporting {len(tracks)} track(s)...")
|
||||
|
||||
ipod = IPodDevice(mount_point=mount_point)
|
||||
|
||||
def progress(p, s):
|
||||
self.progress_signal.emit(p, s)
|
||||
|
||||
exported = ipod.export_tracks(tracks, dest_dir, progress_callback=progress)
|
||||
self.finished_signal.emit(True, f"Exported {len(exported)} track(s)", exported)
|
||||
|
||||
def stop(self):
|
||||
self.is_running = False
|
||||
self.wait()
|
||||
@ -377,14 +399,18 @@ class MainWindow(QMainWindow):
|
||||
controls_row.addWidget(self.volume_slider)
|
||||
playback_layout.addLayout(controls_row)
|
||||
|
||||
# Bottom row: now playing info
|
||||
self.now_playing_label = QLabel("Select a track to play")
|
||||
self.now_playing_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.now_playing_label.setStyleSheet("color: palette(mid); font-size: 12px;")
|
||||
# Bottom row: now playing info with album art
|
||||
now_playing_layout = QHBoxLayout()
|
||||
now_playing_layout.addStretch()
|
||||
now_playing_layout.addWidget(self.now_playing_label)
|
||||
now_playing_layout.addStretch()
|
||||
self.cover_label = QLabel()
|
||||
self.cover_label.setFixedSize(60, 60)
|
||||
self.cover_label.setScaledContents(True)
|
||||
self.cover_label.setStyleSheet("background: palette(window); border-radius: 4px;")
|
||||
now_playing_layout.addWidget(self.cover_label)
|
||||
|
||||
self.now_playing_label = QLabel("Select a track to play")
|
||||
self.now_playing_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||
self.now_playing_label.setStyleSheet("color: palette(mid); font-size: 12px;")
|
||||
now_playing_layout.addWidget(self.now_playing_label, stretch=1)
|
||||
playback_layout.addLayout(now_playing_layout)
|
||||
|
||||
layout.addWidget(playback_group)
|
||||
@ -515,6 +541,20 @@ class MainWindow(QMainWindow):
|
||||
dedup_button.clicked.connect(self._on_remove_duplicates_clicked)
|
||||
layout.addWidget(dedup_button)
|
||||
|
||||
self.export_button = QPushButton("\u2B07 Download to Computer...")
|
||||
self.export_button.setEnabled(False)
|
||||
self.export_button.clicked.connect(self._on_export_ipod_clicked)
|
||||
layout.addWidget(self.export_button)
|
||||
|
||||
self.export_progress = QProgressBar()
|
||||
self.export_progress.setRange(0, 100)
|
||||
self.export_progress.setVisible(False)
|
||||
layout.addWidget(self.export_progress)
|
||||
|
||||
self.export_status = QLabel("")
|
||||
self.export_status.setVisible(False)
|
||||
layout.addWidget(self.export_status)
|
||||
|
||||
def _setup_settings_tab(self, tab):
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
@ -630,6 +670,24 @@ class MainWindow(QMainWindow):
|
||||
"color: palette(text); font-weight: bold;"
|
||||
)
|
||||
|
||||
cover_path = track_data.get("cover_path")
|
||||
if cover_path and os.path.exists(cover_path):
|
||||
pixmap = QPixmap(cover_path)
|
||||
self.cover_label.setPixmap(pixmap)
|
||||
self.cover_label.setToolTip(track_data.get("album", ""))
|
||||
else:
|
||||
# Fallback: try looking up by artist/album in cover cache
|
||||
artist = track_data.get("artist", "")
|
||||
album = track_data.get("album", "")
|
||||
cached = cover_cache.get(artist, album) if artist else None
|
||||
if cached and os.path.exists(cached):
|
||||
pixmap = QPixmap(cached)
|
||||
self.cover_label.setPixmap(pixmap)
|
||||
self.cover_label.setToolTip(album)
|
||||
else:
|
||||
self.cover_label.clear()
|
||||
self.cover_label.setToolTip("")
|
||||
|
||||
# Highlight the current row
|
||||
self.library_table.clearSelection()
|
||||
self.library_table.selectRow(index)
|
||||
@ -743,6 +801,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
if ext in AUDIO_EXTS:
|
||||
size = os.path.getsize(fpath)
|
||||
cover_path = None
|
||||
try:
|
||||
info = handler.extract_tags(fpath)
|
||||
duration = info.duration if info else 0
|
||||
@ -751,6 +810,7 @@ class MainWindow(QMainWindow):
|
||||
album = info.album if info else ""
|
||||
genre = info.genre if info else ""
|
||||
track_num = info.track_number if info else 0
|
||||
cover_path = info.cover_path if info else None
|
||||
except Exception:
|
||||
duration = 0
|
||||
artist = "Unknown"
|
||||
@ -768,6 +828,7 @@ class MainWindow(QMainWindow):
|
||||
"size": size,
|
||||
"genre": genre,
|
||||
"track_num": track_num,
|
||||
"cover_path": cover_path,
|
||||
})
|
||||
elif ext in SOURCE_EXTS:
|
||||
size = os.path.getsize(fpath)
|
||||
@ -776,16 +837,19 @@ class MainWindow(QMainWindow):
|
||||
title = info.title if info else fname
|
||||
artist = info.artist if info else "Unknown"
|
||||
track_num = info.track_number if info else 0
|
||||
cover_path = info.cover_path if info else None
|
||||
except Exception:
|
||||
title = fname
|
||||
artist = "Unknown"
|
||||
track_num = 0
|
||||
cover_path = None
|
||||
sources.append({
|
||||
"path": fpath,
|
||||
"title": title,
|
||||
"artist": artist,
|
||||
"size": size,
|
||||
"track_num": track_num,
|
||||
"cover_path": cover_path,
|
||||
})
|
||||
|
||||
# Deduplicate by file path (defensive — os.walk shouldn't produce dupes)
|
||||
@ -833,17 +897,27 @@ class MainWindow(QMainWindow):
|
||||
info = handler.extract_tags(fpath)
|
||||
title = info.title if info else fname
|
||||
artist = info.artist if info else "Unknown"
|
||||
album = info.album if info else ""
|
||||
cover_path = info.cover_path if info else None
|
||||
except Exception:
|
||||
title = fname
|
||||
artist = "Unknown"
|
||||
album = ""
|
||||
cover_path = None
|
||||
seen_paths.add(fpath)
|
||||
all_tracks.append({
|
||||
"path": fpath, "title": title, "artist": artist,
|
||||
"size": size, "duration_s": 0, "genre": "", "album": "",
|
||||
"size": size, "duration_s": 0, "genre": "", "album": album,
|
||||
"cover_path": cover_path,
|
||||
"_status": "source",
|
||||
})
|
||||
|
||||
all_tracks.sort(key=lambda t: (t["artist"].lower(), t["title"].lower()))
|
||||
all_tracks.sort(key=lambda t: (
|
||||
(t.get("album") or "").lower(),
|
||||
t.get("track_num", 0) or 0,
|
||||
t.get("artist", "").lower(),
|
||||
t.get("title", "").lower(),
|
||||
))
|
||||
|
||||
for idx, track in enumerate(all_tracks):
|
||||
row = self.library_table.rowCount()
|
||||
@ -1314,6 +1388,9 @@ class MainWindow(QMainWindow):
|
||||
self.eject_button.setEnabled(False)
|
||||
self.track_count_label.setText("Tracks on device: 0")
|
||||
self.transferred_list.clear()
|
||||
self.export_button.setEnabled(False)
|
||||
self.export_progress.setVisible(False)
|
||||
self.export_status.setVisible(False)
|
||||
|
||||
def _set_device_not_found(self):
|
||||
self.device_info_label.setText("No iPod devices found")
|
||||
@ -1323,6 +1400,9 @@ class MainWindow(QMainWindow):
|
||||
self.eject_button.setEnabled(False)
|
||||
self.track_count_label.setText("Tracks on device: 0")
|
||||
self.transferred_list.clear()
|
||||
self.export_button.setEnabled(False)
|
||||
self.export_progress.setVisible(False)
|
||||
self.export_status.setVisible(False)
|
||||
|
||||
def _on_mount_clicked(self):
|
||||
if not self.ipod_devices:
|
||||
@ -1408,6 +1488,7 @@ class MainWindow(QMainWindow):
|
||||
self.transferred_list.addItem(item)
|
||||
|
||||
self.track_count_label.setText(f"Tracks on device: {len(tracks)}")
|
||||
self.export_button.setEnabled(len(tracks) > 0)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load iPod tracks: {e}")
|
||||
self.track_count_label.setText("Tracks on device: unknown")
|
||||
@ -1445,6 +1526,67 @@ class MainWindow(QMainWindow):
|
||||
logger.error(f"Failed to remove tracks: {e}")
|
||||
QMessageBox.warning(self, "Error", f"Failed to remove tracks: {e}")
|
||||
|
||||
def _on_export_ipod_clicked(self):
|
||||
if not self.current_mount_point:
|
||||
QMessageBox.warning(self, "Error", "No iPod device mounted.")
|
||||
return
|
||||
|
||||
selected = self.transferred_list.selectedItems()
|
||||
if not selected:
|
||||
QMessageBox.information(self, "Info", "Select tracks to download")
|
||||
return
|
||||
|
||||
dest_dir = QFileDialog.getExistingDirectory(self, "Select Download Destination")
|
||||
if not dest_dir:
|
||||
return
|
||||
|
||||
tracks = []
|
||||
for item in selected:
|
||||
data = item.data(Qt.ItemDataRole.UserRole)
|
||||
if data and data.get("file_path") and os.path.exists(data["file_path"]):
|
||||
tracks.append(data)
|
||||
|
||||
if not tracks:
|
||||
QMessageBox.warning(self, "Error", "No valid track files found on device")
|
||||
return
|
||||
|
||||
if self.worker_thread and self.worker_thread.isRunning():
|
||||
QMessageBox.information(self, "Info", "Please wait for the current task to finish")
|
||||
return
|
||||
|
||||
self.export_button.setEnabled(False)
|
||||
self.export_progress.setVisible(True)
|
||||
self.export_progress.setValue(0)
|
||||
self.export_status.setVisible(True)
|
||||
self.export_status.setText(f"Exporting {len(tracks)} track(s)...")
|
||||
|
||||
self.worker_thread = WorkerThread(
|
||||
task_type="export_ipod",
|
||||
tracks=tracks,
|
||||
dest_dir=dest_dir,
|
||||
mount_point=self.current_mount_point,
|
||||
)
|
||||
self.worker_thread.progress_signal.connect(self._on_export_ipod_progress)
|
||||
self.worker_thread.finished_signal.connect(self._on_export_ipod_finished)
|
||||
self.worker_thread.start()
|
||||
|
||||
def _on_export_ipod_progress(self, progress, status):
|
||||
self.export_progress.setValue(progress)
|
||||
self.export_status.setText(status)
|
||||
|
||||
def _on_export_ipod_finished(self, success, message, result):
|
||||
self.export_button.setEnabled(True)
|
||||
self.export_progress.setValue(100 if success else 0)
|
||||
|
||||
if success:
|
||||
self.export_status.setText(message)
|
||||
QMessageBox.information(self, "Export Complete", message)
|
||||
else:
|
||||
self.export_status.setText(f"Error: {message}")
|
||||
QMessageBox.warning(self, "Export Error", message)
|
||||
|
||||
self._cleanup_worker()
|
||||
|
||||
def _on_scan_orphans_clicked(self):
|
||||
if not self.current_mount_point:
|
||||
QMessageBox.warning(self, "Error", "No iPod device mounted.")
|
||||
|
||||
@ -22,6 +22,7 @@ from mutagen.id3 import ID3, APIC, TIT2, TPE1, TALB, TRCK
|
||||
from mutagen.easyid3 import EasyID3
|
||||
|
||||
from youtube_downloader import TrackInfo
|
||||
from artwork.cache import cover_cache
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
@ -215,10 +216,12 @@ class MetadataHandler:
|
||||
"""
|
||||
logger.info(f"Processing metadata for file: {file_path}")
|
||||
|
||||
# Download thumbnail if available
|
||||
# Download thumbnail if available, or use cached cover from source
|
||||
thumbnail_path = None
|
||||
if track_info.thumbnail_url:
|
||||
thumbnail_path = self.download_thumbnail(track_info.thumbnail_url)
|
||||
elif track_info.cover_path and os.path.exists(track_info.cover_path):
|
||||
thumbnail_path = track_info.cover_path
|
||||
|
||||
# Determine file type and process accordingly
|
||||
file_ext = Path(file_path).suffix.lower()
|
||||
@ -348,6 +351,11 @@ class MetadataHandler:
|
||||
title = title or fallback.title
|
||||
artist = artist or fallback.artist
|
||||
|
||||
# Cache cover art for the player UI
|
||||
cover_path = None
|
||||
if cover_data:
|
||||
cover_path = cover_cache.put(artist or "Unknown Artist", album or "", cover_data)
|
||||
|
||||
return TrackInfo(
|
||||
video_id=f"local_{hash(file_path)}",
|
||||
title=title or base_name,
|
||||
@ -358,6 +366,7 @@ class MetadataHandler:
|
||||
track_number=track_number if track_number else None,
|
||||
genre=genre,
|
||||
download_path=file_path,
|
||||
cover_path=cover_path,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@ -33,6 +33,7 @@ class TrackInfo:
|
||||
playlist_index: Optional[int] = None
|
||||
download_path: Optional[str] = None
|
||||
genre: Optional[str] = None
|
||||
cover_path: Optional[str] = None
|
||||
|
||||
|
||||
class YouTubeDownloader:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user