diff --git a/requirements.txt b/requirements.txt index 1441308..ef37382 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ requests>=2.28.2 tqdm>=4.65.0 musicbrainzngs>=0.7.1 PyQt6>=6.5.0 +numpy>=1.24.0 diff --git a/src/artwork/__init__.py b/src/artwork/__init__.py new file mode 100644 index 0000000..24ed794 --- /dev/null +++ b/src/artwork/__init__.py @@ -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", +] diff --git a/src/artwork/cache.py b/src/artwork/cache.py new file mode 100644 index 0000000..806bb68 --- /dev/null +++ b/src/artwork/cache.py @@ -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() diff --git a/src/artwork/chunks.py b/src/artwork/chunks.py new file mode 100644 index 0000000..2ea83b3 --- /dev/null +++ b/src/artwork/chunks.py @@ -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("= 48: + header[32:48] = reference_mhfd[32:48] + struct.pack_into("= 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(" 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(" bytes: + header = bytearray(MHLA_HEADER_SIZE) + header[0:4] = b"mhla" + struct.pack_into(" 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(" bytes: + header = bytearray(MHIF_HEADER_SIZE) + header[0:4] = b"mhif" + struct.pack_into(" 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(" bytes: + total_len = MHOD_HEADER_SIZE + len(mhni_data) + header = bytearray(MHOD_HEADER_SIZE) + header[0:4] = b"mhod" + struct.pack_into(" 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(" 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(" 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(" 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(" 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(" 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(" 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(" 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(" 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(" 0: + formats[fmt_id] = ExistingFormatRef( + path=ipath, + ithmb_offset=ithmb_offset, + size=img_size, + width=max(1, struct.unpack_from(" 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(" 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(" 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(" 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(" 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" + 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" + 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 diff --git a/src/artwork/extractor.py b/src/artwork/extractor.py new file mode 100644 index 0000000..53233ba --- /dev/null +++ b/src/artwork/extractor.py @@ -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 diff --git a/src/artwork/presets.py b/src/artwork/presets.py new file mode 100644 index 0000000..0effde3 --- /dev/null +++ b/src/artwork/presets.py @@ -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) +""" diff --git a/src/artwork/types.py b/src/artwork/types.py new file mode 100644 index 0000000..ca5c960 --- /dev/null +++ b/src/artwork/types.py @@ -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) diff --git a/src/artwork/writer.py b/src/artwork/writer.py new file mode 100644 index 0000000..9526f6e --- /dev/null +++ b/src/artwork/writer.py @@ -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 + } diff --git a/src/audio_converter.py b/src/audio_converter.py index bea3ef2..2e373ee 100644 --- a/src/audio_converter.py +++ b/src/audio_converter.py @@ -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, diff --git a/src/ipod_device.py b/src/ipod_device.py index 8cbaef9..83d174f 100644 --- a/src/ipod_device.py +++ b/src/ipod_device.py @@ -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() diff --git a/src/ipod_nano7_db.py b/src/ipod_nano7_db.py index 2be437a..a4edca6 100644 --- a/src/ipod_nano7_db.py +++ b/src/ipod_nano7_db.py @@ -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) diff --git a/src/main.py b/src/main.py index b77883f..019e057 100644 --- a/src/main.py +++ b/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.") diff --git a/src/metadata_handler.py b/src/metadata_handler.py index 5303ca3..9671bec 100644 --- a/src/metadata_handler.py +++ b/src/metadata_handler.py @@ -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() @@ -347,7 +350,12 @@ class MetadataHandler: fallback = self._parse_filename_trackinfo(base_name) 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: diff --git a/src/youtube_downloader.py b/src/youtube_downloader.py index 0b063f3..4c16ba8 100644 --- a/src/youtube_downloader.py +++ b/src/youtube_downloader.py @@ -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: