Skip to content

Backend Implementation

This page documents the server-side changes required for Android Auto integration, focusing on size-optimized cover art generation and serving.


🎯 Goals

  1. Generate multiple size variants of cover artwork
  2. Serve size-specific covers via query parameters
  3. Maintain backward compatibility with existing code
  4. Optimize bandwidth for mobile/in-car use

📁 Modified Files

src/musiclib/reader.py

Methods to MusicCollection class:

  • _generate_cover_variants() - Generates 6 size variants
  • get_cover_sizes() - Returns URLs for all size variants

src/app.py

API endpoint:

  • /api/covers/<release_dir_encoded>?size=WxH - Serves size-specific covers

🔧 Implementation Details

1. Cover Variant Generation

_extract_cover(release_dir, target_path)

Attempts to locate or extract a cover image for a given release directory. Searches for existing image files or embedded artwork, resizes them to a reasonable size, and writes an optimized copy to the target path.

Parameters:

Name Type Description Default
release_dir str

The release directory relative to the music root where audio and image files are stored.

required
target_path Path

The filesystem path where the discovered or extracted cover image should be written.

required

Returns:

Name Type Description
bool bool

True if a cover image was successfully found and written, otherwise False.

Source code in src/musiclib/reader.py
def _extract_cover(self, release_dir: str, target_path: Path) -> bool:
    """Attempts to locate or extract a cover image for a given release directory.
    Searches for existing image files or embedded artwork, resizes them to a reasonable size,
    and writes an optimized copy to the target path.

    Args:
        release_dir: The release directory relative to the music root where audio and image files are stored.
        target_path: The filesystem path where the discovered or extracted cover image should be written.

    Returns:
        bool: True if a cover image was successfully found and written, otherwise False.
    """
    abs_dir = self.music_root / release_dir.rstrip("/")
    if not abs_dir.is_dir():
        self._logger.warning(f"Release directory not found: {abs_dir}")
        return False

    # Configuration for cover optimization
    MAX_SIZE = 800  # Maximum width/height in pixels
    JPEG_QUALITY = 85  # JPEG quality (1-100)
    MAX_FILE_SIZE = 500 * 1024  # 500KB max file size

    # Step 1: Check for common image files in the folder
    common_cover_names = [
        "cover.jpg",
        "folder.jpg",
        "album.jpg",
        "front.jpg",
        "cover.png",
        "folder.png",
    ]

    for name in common_cover_names:
        img_path = abs_dir / name
        if img_path.exists():
            try:
                if self._resize_and_save_cover(img_path, target_path, MAX_SIZE, JPEG_QUALITY, MAX_FILE_SIZE):
                    self._logger.debug(f"Processed cover from {img_path} for {release_dir}")
                    return True
            except Exception as e:
                self._logger.error(f"Failed to process cover {img_path}: {e}")
                continue

    # Step 2: Extract embedded art from audio files
    for file in abs_dir.iterdir():
        if file.suffix.lower() in CollectionExtractor.SUPPORTED_EXTS:
            try:
                tag = TinyTag.get(str(file), image=True)
                if img_data := tag.get_image():
                    if self._resize_and_save_cover_from_bytes(img_data, target_path, MAX_SIZE, JPEG_QUALITY, MAX_FILE_SIZE):
                        self._logger.debug(
                            f"Extracted and processed embedded cover from {file} for {release_dir}"
                        )
                        return True
            except Exception as e:
                self._logger.warning(f"Failed to extract from {file}: {e}")
                continue

    self._logger.info(f"No cover found for {release_dir}")
    return False

File naming convention:

  • Main cover: {slug}.jpg (e.g., artist_album.jpg)
  • Variants: {slug}_{size}x{size}.jpg (e.g., artist_album_256x256.jpg)

Standard sizes generated:

  • 96×96 px - Thumbnails (5-8 KB)
  • 128×128 px - Small tiles (8-12 KB)
  • 192×192 px - Medium tiles (15-20 KB)
  • 256×256 px - Android Auto optimal (30-50 KB)
  • 384×384 px - High-DPI displays (60-90 KB)
  • 512×512 px - Full-screen player (100-150 KB)

2. Size URL Retrieval

get_cover_sizes(release_dir)

Returns URLs for multiple size variants of a cover image. Generates size variants on-demand if not already cached.

Parameters:

Name Type Description Default
release_dir str

The release directory identifier for which to retrieve cover variants.

required

Returns:

Type Description
dict[str, str]

dict[str, str]: Dictionary mapping size strings (e.g., "96x96") to relative URL paths.

Source code in src/musiclib/reader.py
def get_cover_sizes(self, release_dir: str) -> dict[str, str]:
    """Returns URLs for multiple size variants of a cover image.
    Generates size variants on-demand if not already cached.

    Args:
        release_dir: The release directory identifier for which to retrieve cover variants.

    Returns:
        dict[str, str]: Dictionary mapping size strings (e.g., "96x96") to relative URL paths.
    """
    if not release_dir:
        sizes = [96, 128, 192, 256, 384, 512]
        return {f"{s}x{s}": "covers/_fallback.jpg" for s in sizes}

    slug = self._sanitize_release_dir(release_dir)
    sizes = [96, 128, 192, 256, 384, 512]

    # Ensure main cover exists first
    main_path = self.covers_dir / f"{slug}.jpg"
    if not main_path.exists() and not self._extract_cover(release_dir, main_path):
        return {f"{s}x{s}": "covers/_fallback.jpg" for s in sizes}

    # Check if variants exist, generate if needed
    variants_exist = all(
        (self.covers_dir / f"{slug}_{s}x{s}.jpg").exists() for s in sizes
    )

    if not variants_exist:
        self._generate_cover_variants(release_dir, slug)

    # Build URL dictionary
    result = {}
    for size in sizes:
        variant_path = self.covers_dir / f"{slug}_{size}x{size}.jpg"
        if variant_path.exists():
            result[f"{size}x{size}"] = f"covers/{slug}_{size}x{size}.jpg"
        else:
            # Fallback to main cover if variant doesn't exist
            result[f"{size}x{size}"] = f"covers/{slug}.jpg"

    return result

Return value example:

{
    "96x96": "covers/artist_album_96x96.jpg",
    "128x128": "covers/artist_album_128x128.jpg",
    "192x192": "covers/artist_album_192x192.jpg",
    "256x256": "covers/artist_album_256x256.jpg",
    "384x384": "covers/artist_album_384x384.jpg",
    "512x512": "covers/artist_album_512x512.jpg"
}

3. API Endpoint

Route: /api/covers/<release_dir_encoded>

Location: src/app.py (added after existing /covers/<filename> route)

@app.route("/api/covers/<path:release_dir_encoded>")
def serve_cover_by_size(release_dir_encoded):
    """Serves cover art with optional size parameter.

    Supports ?size=WxH query parameter (e.g., ?size=256x256).
    Generates size variants on-demand and caches them.

    Args:
        release_dir_encoded: URL-encoded release directory path

    Query Parameters:
        size: Optional size in format WxH (e.g., 256x256)
              Valid: 96x96, 128x128, 192x192, 256x256, 384x384, 512x512

    Returns:
        Image file from cache directory
    """
    from urllib.parse import unquote

    release_dir = unquote(release_dir_encoded)
    requested_size = request.args.get('size', '').lower()

    valid_sizes = ['96x96', '128x128', '192x192', '256x256', '384x384', '512x512']
    covers_dir = app.config["DATA_ROOT"] / "cache" / "covers"

    if not requested_size:
        # No size specified, return main cover
        cover_url = collection.get_cover(release_dir)
        if cover_url:
            filename = cover_url.split('/')[-1]
            return send_from_directory(covers_dir, filename)
        abort(404)

    # Validate size parameter
    if requested_size not in valid_sizes:
        return jsonify({
            "error": "Invalid size parameter",
            "valid_sizes": valid_sizes
        }), 400

    # Get or generate size-specific cover
    slug = collection._sanitize_release_dir(release_dir)
    size_filename = f"{slug}_{requested_size}.jpg"
    size_path = covers_dir / size_filename

    # Generate if doesn't exist
    if not size_path.exists():
        main_path = covers_dir / f"{slug}.jpg"
        if not main_path.exists():
            collection._extract_cover(release_dir, main_path)

        if main_path.exists():
            collection._generate_cover_variants(release_dir, slug)

    # Serve size-specific cover
    if size_path.exists():
        return send_from_directory(covers_dir, size_filename)

    # Fallback to main cover
    main_filename = f"{slug}.jpg"
    main_path = covers_dir / main_filename
    if main_path.exists():
        return send_from_directory(covers_dir, main_filename)

    # Final fallback
    return send_from_directory(covers_dir, "_fallback.jpg")

Usage examples:

# Get 256×256 variant
GET /api/covers/Artist%2FAlbum?size=256x256

# Get main cover (no size)
GET /api/covers/Artist%2FAlbum

# Invalid size returns error
GET /api/covers/Artist%2FAlbum?size=999x999
# → {"error": "Invalid size parameter", "valid_sizes": [...]}

🔄 Request Flow

sequenceDiagram
    participant Client
    participant Flask
    participant API Endpoint
    participant Collection
    participant Filesystem

    Client->>Flask: GET /api/covers/Artist%2FAlbum?size=256x256
    Flask->>API Endpoint: serve_cover_by_size()
    API Endpoint->>API Endpoint: Decode release_dir
    API Endpoint->>API Endpoint: Validate size parameter

    alt Size variant exists
        API Endpoint->>Filesystem: Check cache
        Filesystem-->>API Endpoint: File found
        API Endpoint-->>Client: 200 + JPEG (40KB)
    else Size variant missing
        API Endpoint->>Collection: _sanitize_release_dir()
        Collection-->>API Endpoint: slug
        API Endpoint->>Collection: _extract_cover() if needed
        Collection->>Filesystem: Extract main cover
        API Endpoint->>Collection: _generate_cover_variants()
        Collection->>Filesystem: Generate 6 variants
        Filesystem-->>Collection: Success
        Collection-->>API Endpoint: True
        API Endpoint->>Filesystem: Read variant
        Filesystem-->>API Endpoint: File bytes
        API Endpoint-->>Client: 200 + JPEG (40KB)
    end
Hold "Alt" / "Option" to enable pan & zoom

📊 Performance Characteristics

Storage Impact

  • Main cover: 300-500 KB per album
  • All 6 variants: 50-150 KB total per album
  • Overhead: ~15-25% of main cover size

Generation Performance

  • First request: +100-200ms (one-time)
  • Subsequent requests: 0ms additional (served from cache)
  • Batch generation: Can pre-generate for popular albums

Caching Strategy

  • Lazy generation: Only creates variants when requested
  • Permanent cache: Variants never expire
  • No cleanup: Variants remain until manual cleanup
  • Cache location: DATA_ROOT/cache/covers/

🔙 Backward Compatibility

Existing Routes Unchanged

/covers/<filename> - Still works exactly as before ✅ get_cover(release_dir) - Returns single URL as always ✅ All existing templates - No changes required

New Features Are Additive

  • New API endpoint doesn't affect existing code
  • get_cover_sizes() is additional, not replacement
  • Variants generated only when needed
  • Main cover extraction logic unchanged

Migration Path

  1. Deploy backend changes
  2. No immediate action required
  3. Update frontend when ready
  4. Variants generate automatically

🧪 Testing

Unit Tests

def test_generate_cover_variants():
    """Test variant generation creates all sizes."""
    collection = MusicCollection(music_root, db_path)
    slug = "test_album"

    result = collection._generate_cover_variants("Artist/Album", slug)

    assert result == True
    assert (covers_dir / f"{slug}_96x96.jpg").exists()
    assert (covers_dir / f"{slug}_256x256.jpg").exists()
    assert (covers_dir / f"{slug}_512x512.jpg").exists()

def test_get_cover_sizes():
    """Test URL dictionary returns all sizes."""
    collection = MusicCollection(music_root, db_path)

    sizes = collection.get_cover_sizes("Artist/Album")

    assert "96x96" in sizes
    assert "256x256" in sizes
    assert sizes["256x256"].endswith("_256x256.jpg")

Integration Tests

# Test API endpoint
curl "http://localhost:5000/api/covers/Artist%2FAlbum?size=256x256" -o test.jpg

# Verify file size
ls -lh test.jpg  # Should be ~40KB

# Test invalid size
curl "http://localhost:5000/api/covers/Artist%2FAlbum?size=999x999"
# Should return JSON error

# Test fallback
curl "http://localhost:5000/api/covers/NonExistent%2FAlbum?size=256x256"
# Should return fallback image

🐛 Troubleshooting

Issue: Variants not generating

Symptoms: 404 on size-specific requests Check: Main cover exists? PIL/Pillow installed? Solution: Ensure _extract_cover() succeeds first

Issue: Large file sizes

Symptoms: Variants larger than expected Adjust: Quality setting in _generate_cover_variants():

quality = 90 if size >= 256 else 85  # Increase quality

Issue: Slow first request

Expected: 100-200ms delay on first size request Solution: Pre-generate for popular albums with background job