Skip to content

Mixtape editor

Mixtape Manager

The mixtape_manager package defines the MixtapeManager class, This class acts as the core data access and management layer for mixtape-related features in the larger system, abstracting away file system operations and data consistency concerns.

It is responsible for managing the storage, retrieval, and organization of mixtape data and their associated cover images on disk. It provides a high-level interface for saving, deleting, listing, and loading mixtape metadata, handling both JSON data and image files. The class ensures that mixtape data is consistently stored, cover images are processed from base64, and metadata is maintained for easy retrieval and display.

🏛️ Architecture Overview

classDiagram
    class MixtapeManager {
        -Path path_mixtapes
        -Path path_cover
        -MusicCollection collection
        -Logger _logger
        +MixtapeManager(path_mixtapes, collection, logger=None)
        +save(mixtape_data) str
        +update(slug, updated_data) str
        +get(slug) dict|None
        +list_all() list[dict]
        +delete(slug) None
        -_sanitize_title(title) str
        -_generate_unique_slug(base_slug, current_slug=None) str
        -_save_with_slug(mixtape_data, title, slug) str
        -_process_cover(cover_data, slug) str|None
        -_cover_resize(image, new_width=1200) Image
        -_find_by_client_id(client_id) dict|None
        -_verify_against_collection(data) (dict, bool|None)
        -_verify_mixtape_metadata(data) dict
        -_normalize_timestamps(data) dict
        -_convert_old_mixtape(data) dict
    }
Hold "Alt" / "Option" to enable pan & zoom
  • Storage rootpath_mixtapes (provided by the caller).
  • Cover sub‑directorypath_cover = path_mixtapes / "covers" (created automatically).
  • DependencyPillow (PIL.Image) is required for cover‑image resizing.

📂 Filesystem Layout & JSON Schema

/path/to/mixtapes/
├─ my‑awesome‑mix.json           Mixtape metadata (UTF‑8 JSON)
├─ another‑mix.json
└─ covers/
   ├─ my‑awesome‑mix.jpg        JPEG cover (max width 1200px, quality100)
   └─ another‑mix.jpg

Minimal JSON structure (all fields are optional unless noted)

Field Type Description
title str Human-readable title (used for slug generation).
client_id str Optional identifier that ties a mixtape to a specific client/device.
created_at ISO-8601 str Set on first save; auto-filled if missing.
updated_at ISO-8601 str Refreshed on every save or update.
cover str Relative path to the JPEG cover (covers/<slug>.jpg) or a data-URI (data:image/...).
liner_notes str Free-form notes; defaults to "".
tracks list[dict] Each entry must contain at least path (relative to the music root). Other keys (artist, album, track, duration, filename, cover) are filled/validated on read.
slug str (added on output) Filesystem-safe identifier derived from title, added by the manager on output.

⚙️ Behavior Details

Slug generation & uniqueness

  • _sanitize_title removes any character that isn’t alphanumeric, hyphen, underscore, or space, then strips surrounding whitespace.
  • _generate_unique_slug checks path_mixtapes/*.json. If a conflict exists it appends -1, -2, … until a free filename is found.
  • When updating an existing mixtape you may pass current_slug so the method can reuse the same filename.

Client‑ID reuse on save

  • If mixtape_data contains a client_id, save() first calls _find_by_client_id.
  • The first mixtape that matches that client_id is updated in‑place (the same slug is kept).
  • If no match is found a brand‑new mixtape is created.

Timestamp handling & legacy migration

  • On first creation both created_at and updated_at are set to datetime.now().isoformat().
  • On every update only updated_at is refreshed.
  • _normalize_timestamps migrates a historic saved_at field to updated_at and guarantees that created_at and updated_at are present (fills missing values with None or the current time).

Cover image handling

Helper What it does Important details
_process_cover(cover_data: str, slug: str) → str \| None Decodes a data:image/...;base64,… string, resizes the image, saves it as a JPEG under covers/<slug>.jpg, and returns the relative path (covers/<slug>.jpg). Returns None on any error; the calling code keeps the original cover value.
_cover_resize(image: Image, new_width: int = 1200) → Image Resizes the Pillow Image to a maximum width of new_width while preserving aspect ratio. Uses Image.LANCZOS for high-quality down-sampling.
_save_with_slug Handles cover persistence during save operations. If cover starts with data:image, delegates to _process_cover. If it is a plain string (already a relative path), it is stored unchanged. Errors during cover processing are logged but do not abort the mixtape save.
  • The JPEG is saved with quality = 100 (lossless as far as JPEG permits).
  • The filename on disk is exactly covers/<slug>.jpg – the method builds the relative path string for the JSON field.

Verification against the music collection

  • get(slug) loads the JSON, then calls _verify_against_collection.
  • For each track it fetches the canonical record from the injected MusicCollection (self.collection.get_track(path=Path(track* ["path"]))).
  • Missing or stale fields (artist, album, track, filename, duration, cover) are refreshed.
  • If the collection cannot be accessed (e.g., DB corruption) the method falls back to the raw JSON and logs a warning.

Legacy field conversion

  • Older mixtapes stored a track title under the key title. _convert_old_mixtape renames that key to track so the rest of the code can rely on a uniform schema.

Error handling & robustness

  • Corrupted JSON files – both list_all and _find_by_client_id catch json.JSONDecodeError / OSError, log a warning, and simply skip the offending file.
  • Cover‑image failures_process_cover catches any exception, logs an error, and returns None. The mixtape is still saved; the cover field will be omitted or retain its previous value.
  • Missing slug on updateupdate raises FileNotFoundError if the target JSON does not exist, making the failure explicit to the caller.

🔁 Sequence Diagram

sequenceDiagram
    participant Client
    participant MixtapeManager
    participant Disk
    participant MusicCollection

    Client->>MixtapeManager: save(mixtape_data)
    MixtapeManager->>Disk: Write JSON file (<slug>.json)
    alt cover is a data‑uri
        MixtapeManager->>Disk: Decode + resize + JPEG → covers/<slug>.jpg
    end
    MixtapeManager-->>Client: slug

    Client->>MixtapeManager: get(slug)
    MixtapeManager->>Disk: Read JSON file
    MixtapeManager->>MusicCollection: get_track for each track
    MusicCollection-->>MixtapeManager: track metadata
    MixtapeManager-->>Client: enriched mixtape dict

    Client->>MixtapeManager: list_all()
    MixtapeManager->>Disk: glob *.json, read each
    MixtapeManager-->>Client: sorted list of mixtapes

    Client->>MixtapeManager: delete(slug)
    MixtapeManager->>Disk: Delete JSON + optional cover
    MixtapeManager-->>Client: (none)
Hold "Alt" / "Option" to enable pan & zoom

🔌 API

MixtapeManager(path_mixtapes, collection, logger=None)

Manages mixtape files and their associated cover images.

Provides functionality to create, update, delete, list, and retrieve mixtapes stored on disk.

Initializes the MixtapeManager with paths for mixtapes and covers.

Sets up the directory structure for storing mixtape JSON files and cover images.

Parameters:

Name Type Description Default
path_mixtapes Path

Path to the directory where mixtapes are stored.

required
collection MusicCollection

Access to the collection's metadata

required
logger Logger

Optional logger instance for logging actions.

None

Methods:

Name Description
save

Creates a new mixtape or updates an existing one based on client identity.

update

Updates an existing mixtape's data while preserving key metadata.

delete

Deletes a mixtape and its associated cover image by slug.

list_all

Lists all mixtapes stored on disk.

get

Retrieves a single mixtape by its slug if it exists and is valid.

Source code in src/mixtape_manager/mixtape_manager.py
def __init__(
    self,
    path_mixtapes: Path,
    collection: MusicCollection,
    logger: Logger | None = None,
) -> None:
    """Initializes the MixtapeManager with paths for mixtapes and covers.

    Sets up the directory structure for storing mixtape JSON files and cover images.

    Args:
        path_mixtapes (Path): Path to the directory where mixtapes are stored.
        collection (MusicCollection): Access to the collection's metadata
        logger (Logger): Optional logger instance for logging actions.
    """
    self._logger: Logger = logger or NullLogger()
    self.path_mixtapes: Path = path_mixtapes
    self.path_cover: Path = path_mixtapes / "covers"
    self.path_mixtapes.mkdir(exist_ok=True)
    self.path_cover.mkdir(exist_ok=True)
    self.collection = collection

save(mixtape_data)

Creates a new mixtape or updates an existing one based on client identity.

Reuses an existing mixtape when a matching client_id is found, otherwise generates a fresh mixtape entry.

Parameters:

Name Type Description Default
mixtape_data dict

Dictionary containing mixtape information, including optional client_id, title, and tracks.

required

Returns:

Name Type Description
str str

The slug of the created or updated mixtape.

Source code in src/mixtape_manager/mixtape_manager.py
def save(self, mixtape_data: dict) -> str:
    """Creates a new mixtape or updates an existing one based on client identity.

    Reuses an existing mixtape when a matching client_id is found, otherwise generates a fresh mixtape entry.

    Args:
        mixtape_data (dict): Dictionary containing mixtape information, including optional client_id, title, and tracks.

    Returns:
        str: The slug of the created or updated mixtape.
    """
    client_id = mixtape_data.get("client_id")
    now = datetime.now().isoformat()

    if existing := self._find_by_client_id(client_id):
        slug = existing["slug"]
        self._logger.info(
            f"Found existing mixtape for client_id {client_id}, updating slug {slug}"
        )
        return self.update(slug, mixtape_data)

    # New creation
    mixtape_data["schema_version"] = self.CURRENT_SCHEMA_VERSION
    title = mixtape_data.get("title", "Untitled Mixtape")
    base_slug = self._sanitize_title(title)
    slug = self._generate_unique_slug(base_slug)

    # Preserve the client_id in the saved data
    if client_id:
        mixtape_data["client_id"] = client_id

    # Set both timestamps on first save
    mixtape_data["created_at"] = now
    mixtape_data["updated_at"] = now

    # Ensure gift flow fields have defaults if not provided
    mixtape_data = self._ensure_gift_flow_fields(mixtape_data)

    return self._save_with_slug(mixtape_data=mixtape_data, title=title, slug=slug)

update(slug, updated_data)

Updates an existing mixtape's data while preserving key metadata.

Loads the stored mixtape, applies changes only to allowed fields, maintains backward compatibility, and refreshes the update timestamp before saving.

Parameters:

Name Type Description Default
slug str

The slug of the mixtape to update.

required
updated_data dict

A dictionary of fields to update on the mixtape.

required

Returns:

Name Type Description
str str

The slug of the updated mixtape.

Raises:

Type Description
FileNotFoundError

If a mixtape with the given slug does not exist.

Source code in src/mixtape_manager/mixtape_manager.py
def update(self, slug: str, updated_data: dict) -> str:
    """Updates an existing mixtape's data while preserving key metadata.

    Loads the stored mixtape, applies changes only to allowed fields, maintains backward compatibility, and
    refreshes the update timestamp before saving.

    Args:
        slug (str): The slug of the mixtape to update.
        updated_data (dict): A dictionary of fields to update on the mixtape.

    Returns:
        str: The slug of the updated mixtape.

    Raises:
        FileNotFoundError: If a mixtape with the given slug does not exist.
    """
    existing_data = self._load_existing_mixtape(slug)
    updated_data = self._preserve_client_id(existing_data, updated_data)
    existing_data = self._apply_allowed_updates(existing_data, updated_data)
    existing_data = self._ensure_required_fields(existing_data)
    existing_data["updated_at"] = datetime.now().isoformat()

    return self._save_with_slug(
        mixtape_data=existing_data, title=existing_data["title"], slug=slug
    )

delete(slug)

Deletes a mixtape and its associated cover image by slug.

Removes the mixtape JSON file and cover image from disk if they exist.

Parameters:

Name Type Description Default
slug str

The slug of the mixtape to delete.

required
Source code in src/mixtape_manager/mixtape_manager.py
def delete(self, slug: str) -> None:
    """
    Deletes a mixtape and its associated cover image by slug.

    Removes the mixtape JSON file and cover image from disk if they exist.

    Args:
        slug (str): The slug of the mixtape to delete.
    """
    json_path = self.path_mixtapes / f"{slug}.json"
    json_path.unlink(missing_ok=True)

    cover_path = self.path_cover / f"{slug}.jpg"
    cover_path.unlink(missing_ok=True)

list_all()

Lists all mixtapes stored on disk.

Returns a list of dictionaries containing mixtape data, sorted by update or save time.

Returns:

Type Description
list[dict]

list[dict]: List of all mixtape data dictionaries.

Source code in src/mixtape_manager/mixtape_manager.py
def list_all(self) -> list[dict]:
    """
    Lists all mixtapes stored on disk.

    Returns a list of dictionaries containing mixtape data, sorted by update or save time.

    Returns:
        list[dict]: List of all mixtape data dictionaries.
    """
    mixtapes = []
    for file in self.path_mixtapes.glob("*.json"):
        try:
            with open(file, "r", encoding="utf-8") as f:
                data = json.load(f)
        except (json.JSONDecodeError, OSError) as e:
            self._logger.warning(f"Skipping corrupted mixtape file {file}: {e}")
            continue

        slug = file.stem
        data["slug"] = slug
        if "liner_notes" not in data:
            data["liner_notes"] = ""
        if "client_id" not in data:
            data["client_id"] = None

        # Normalize timestamps (will also handle legacy saved_at)
        data = self._normalize_timestamps(data)

        mixtapes.append(data)

    # Sort by most recently updated first
    mixtapes.sort(
        key=lambda x: x.get("updated_at") or x.get("created_at") or "", reverse=True
    )
    return mixtapes

get(slug)

Retrieves a single mixtape by its slug if it exists and is valid.

Loads the mixtape from disk, validates its tracks against the collection, and normalizes optional fields.

Parameters:

Name Type Description Default
slug str

The slug identifier of the mixtape to retrieve.

required

Returns:

Type Description
dict | None

dict | None: The validated and normalized mixtape data, or None if the mixtape is missing or invalid.

Source code in src/mixtape_manager/mixtape_manager.py
def get(self, slug: str) -> dict | None:
    """Retrieves a single mixtape by its slug if it exists and is valid.

    Loads the mixtape from disk, validates its tracks against the collection, and normalizes optional fields.

    Args:
        slug (str): The slug identifier of the mixtape to retrieve.

    Returns:
        dict | None: The validated and normalized mixtape data, or None if the mixtape is missing or invalid.
    """
    path = self.path_mixtapes / f"{slug}.json"
    if not path.exists():
        return None

    try:
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
    except (json.JSONDecodeError, OSError) as e:
        self._logger.error(f"Failed to read mixtape {slug}: {e}")
        return None

    try:
        data_verified, has_changed = self._verify_against_collection(data=data)
        if data_verified:
            data_verified = self._verify_mixtape_metadata(data=data_verified)
            data_verified["slug"] = slug
            return data_verified
        else:
            return None
    except Exception as e:
        # Database unavailable (corruption, indexing, etc.)
        # Just use the mixtape data as-is from the JSON file
        self._logger.warning(
            f"Could not verify mixtape {slug} against collection: {e}. "
            f"Using cached data from JSON."
        )

    # Always normalize metadata
    data = self._verify_mixtape_metadata(data=data)
    data["slug"] = slug
    return data