Skip to content

Reading

Searching the music collection

The music collection supports a flexible, tag-based search language that can return artists, albums, and tracks in a single request. Search results are grouped, scored, highlighted, and designed for lazy navigation via follow-up queries.

Searching is implemented in two layers:

  • Core search engineMusicCollection.search_grouped(...) (in reader.py)
  • UI-facing APIMusicCollectionUI.search_highlighting(...) (in ui.py)

Most applications should call the UI-facing method.


🚀 Entry points

results = mc_ui.search_highlighting(query, limit=30)

Returns a single list of result objects, ready for rendering in a UI. Each result object has a type field (artist, album, or track) and includes highlighted text and navigation metadata.

Core grouped search (lower-level)

grouped, terms = mc.search_grouped(query, limit=20)

Returns:

  1. A dictionary with three lists: artists, albums, tracks
  2. The parsed search terms, grouped by category

This method is primarily used internally by the UI layer.


🔎 Query language

The query parser recognizes tagged terms and general free-text terms.

Supported tags

Tag Example Meaning
artist: artist:Prince Restrict results to a specific artist
album: album:"Purple Rain" Restrict results to a specific album
track: / song: track:"When Doves Cry" Restrict results to track titles

Free-text terms

Any term not prefixed by a tag is treated as free-text and matched against:

  • artist
  • album
  • track title

Example:

love

Quoting and escaping

  • Single or double quotes allow multi-word values
artist:"The Beatles"
album:'Purple Rain'
  • Backslashes can escape special characters inside quoted values

⚙️ Parsed term structure

The query is normalized into a dictionary:

{
    "artist":  [...],
    "album":   [...],
    "track":   [...],
    "general": [...]
}

This structure is returned alongside the search results and reused for:

  • scoring
  • highlighting
  • UI explanation ("why did this match?")

⚡ Search execution model

Pass-one candidate collection

The search engine performs a first pass to collect candidates:

  • Artists are scored by how well they match artist terms
  • Albums are scored by album name and artist context
  • Tracks are scored by title and tag matches

The engine may reuse candidates from the previous search session if the new query is a refinement (e.g. clicking an artist).

Scoring (simplified)

Matches are weighted using:

  • Exact matches
  • Prefix matches
  • Substring matches
  • Tag bonuses (explicit artist:, album:, track:)

This produces ranked candidate sets for artists, albums, and tracks.

Performance optimization

To minimize database overhead, the search engine batches related queries:

  • Album counts for all matched artists are fetched in a single query
  • Track counts for all matched albums are fetched in a single query
  • Compilation status for all matched albums is checked in a single query

This reduces the number of database round trips from O(n+m) to O(1), where n is the number of artists and m is the number of albums.


🏘️ Result grouping and hierarchy

After scoring, results are assembled into a hierarchical structure:

  • Artists
  • Albums
  • Tracks

The engine decides which groups to include based on the query:

Query type Included sections
Free-text only Artists, albums, and tracks
artist: present Artists + related albums/tracks
album: present Albums + tracks
track: present Tracks only

🎯 UI result model

The UI layer converts grouped results into a single flat list of result objects via the function search_highlighting.

Each object has a type field and a shape appropriate for rendering.

Artist results

{
  "type": "artist",
  "artist": "<mark>Prince</mark>",
  "raw_artist": "Prince",
  "reasons": [
    { "type": "album", "text": "3 album(s)" },
    { "type": "track", "text": "12 nummer(s)" }
  ],
  "load_on_demand": true,
  "clickable": true,
  "click_query": "artist:'Prince'"
}

Characteristics:

  • Summary only (no albums or tracks included)
  • Always lazy-loaded
  • Clicking triggers a new search using click_query

Album results

{
  "type": "album",
  "artist": "Prince",
  "album": "<mark>Purple Rain</mark>",
  "is_compilation": false,
  "cover": "covers/prince_purplerain.jpg",
  "reasons": [
    { "type": "track", "text": "5 nummer(s)" }
  ],
  "load_on_demand": true,
  "clickable": true,
  "click_query": "release_dir:'/Prince/Purple Rain'"
}

Characteristics:

  • Summary only
  • Tracks are loaded on demand
  • Albums with tracks by more than three artists are shown as "Various Artists"
  • Includes cover field with relative URL to cached cover image

Track results

{
  "type": "track",
  "artist": "Prince",
  "album": "Purple Rain",
  "track": "<mark>When Doves Cry</mark>",
  "duration": "5:54",
  "path": "Prince/Purple Rain/01 - When Doves Cry.flac",
  "cover": "covers/prince_purplerain.jpg",
  "artist_click_query": "artist:'Prince'",
  "album_click_query": "album:'Purple Rain'"
}

Characteristics:

  • Fully populated (no lazy loading)
  • Includes navigation queries for artist and album
  • Includes cover field with relative URL to cached cover image

🖼️ Cover art management

The music collection provides automatic cover art extraction, caching, and serving with support for size-optimized variants for responsive clients like Android Auto.

Basic cover retrieval

# Get cover URL for a release directory
cover_url = mc.get_cover("Artist/Album")
# Returns: "covers/artist_album.jpg" or "covers/_fallback.jpg"

Behavior:

  • Searches for common cover image files (cover.jpg, folder.jpg, etc.)
  • Extracts embedded artwork from audio files if no standalone image found
  • Optimizes images to max 800×800px, 85% quality, ≤500KB
  • Caches extracted covers in DATA_ROOT/cache/covers/
  • Returns fallback image if no cover found

Size-optimized cover variants

For bandwidth-conscious applications (mobile, Android Auto), request specific sizes:

# Get multiple size variants
cover_sizes = mc.get_cover_sizes("Artist/Album")
# Returns:
# {
#   "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"
# }

Behavior:

  • Generates size variants on-demand (lazy generation)
  • Caches variants permanently for future requests
  • Falls back to main cover if variant generation fails
  • Returns fallback URLs for all sizes if no cover found

Standard sizes:

Size Use case Typical file size
96×96 Thumbnails, lists 5-8 KB
128×128 Small tiles 8-12 KB
192×192 Medium tiles 15-20 KB
256×256 Android Auto (optimal) 30-50 KB
384×384 High-DPI displays 60-90 KB
512×512 Full-screen player 100-150 KB

Flask API endpoints

Two routes are available for serving cover images:

Direct file serving (existing):

GET /covers/<filename>

Serves cached cover files directly. Used by existing UI code.

Size-parameterized API (new):

GET /api/covers/<release_dir>?size=256x256

Serves size-specific cover variants. Generates on-demand if needed.

Example usage:

# Android Auto - request optimal size
GET /api/covers/Artist%2FAlbum?size=256x256

# Without size parameter - returns main cover
GET /api/covers/Artist%2FAlbum

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

Cover extraction details

The extraction process follows this priority:

  1. Common image files in release directory:
  2. cover.jpg, folder.jpg, album.jpg, front.jpg
  3. cover.png, folder.png

  4. Embedded artwork from audio files:

  5. Extracted from first audio file with embedded art
  6. Supports all formats handled by TinyTag

  7. Optimization:

  8. Converts all images to RGB JPEG
  9. Resizes to max 800×800px (maintains aspect ratio)
  10. Compresses to 85% quality initially
  11. Reduces quality iteratively if file >500KB
  12. Handles transparency by compositing on white background

  13. Caching:

  14. Sanitizes release directory to safe filename slug
  15. Stores in DATA_ROOT/cache/covers/{slug}.jpg
  16. Size variants stored as {slug}_{size}x{size}.jpg

Performance characteristics

Storage impact:

  • Main cover: 300-500 KB per album
  • All 6 size variants: 50-150 KB total per album
  • Lazy generation: variants only created when requested

Bandwidth savings:

Client Original (800px) Optimized Savings
Android Auto 300-500 KB 30-50 KB (256×256) ~90%
Mobile web 300-500 KB 15-25 KB (128×128) ~95%
List thumbnails 300-500 KB 5-8 KB (96×96) ~98%

Generation performance:

  • First request with variants: +100-200ms (one-time cost)
  • Subsequent requests: 0ms (served from cache)
  • Main cover extraction: typically <100ms

💤 Lazy loading

Artist and album results include a click_query field.

When the user clicks such a result:

  1. The UI issues a new search
  2. Using the stored click_query
  3. Which returns a more specific result set

This keeps the API stateless and avoids nested payloads.


✨ Highlighting

All matched terms are automatically highlighted:

  • Implemented in _highlight_text(...)
  • Case-insensitive
  • Wrapped in <mark>...</mark>

Highlighting applies to:

  • Artist names
  • Album titles
  • Track titles

This behavior is UI-specific and not part of the core search engine.


💡 Match explanations

Each result may include a reasons list explaining why it matched:

  • Matching artist name
  • Number of matching albums
  • Number of matching tracks

These are intended for UI hints, badges, or tooltips.


👀 Real‑time monitoring

MusicCollection.start_monitoring() creates a watchdog.observers.Observer that uses the EnhancedWatcher class (defined in src/musiclib/_watcher.py). The enhanced watcher adds two important behaviours that differ from a naïve FileSystemEventHandler:

Feature What it does Why it matters
Debounce delay (DEBOUNCE_DELAY = 2.0 s) After the last change to a given file, the watcher waits 2 seconds before queuing an INDEX_FILE or DELETE_FILE event. Prevents a burst of rapid edits (e.g., a tag‑editing batch) from generating many separate index operations, which could corrupt the DB.
Coalescing Multiple created/modified events for the same path are merged into a single INDEX_FILE event; a later deleted event overrides any pending modified events. Guarantees that the final state of the file is what gets indexed.
Graceful shutdown (shutdown() method) Cancels all pending timers and flushes any remaining events to the write queue before the observer is stopped. Ensures no file‑system changes are lost when the application exits.

The rest of the monitoring flow (observer start/stop, queue → writer thread) remains exactly as described in the original diagram.


📄 Summary

In short, searching works as follows:

  1. Parse the query into tagged and free-text terms
  2. Collect and score artist, album, and track candidates
  3. Build hierarchical grouped results
  4. Flatten results into UI-friendly objects
  5. Highlight matches and attach navigation queries
  6. Support lazy exploration through follow-up searches

Cover art management works as follows:

  1. Extract covers from release directories or embedded artwork
  2. Optimize and cache at 800×800px
  3. Generate size variants on-demand for bandwidth efficiency
  4. Serve via direct file URLs or size-parameterized API
  5. Fall back gracefully when covers unavailable

This design allows the UI to deliver a fast, expressive, and navigable search experience without embedding deep hierarchies in a single response, while efficiently serving cover art to clients with varying bandwidth and display requirements.

🔌 API

Only the following methods are considered stable public APIs: MusicCollection.search_grouped, MusicCollectionUI.search_highlighting, MusicCollection.rebuild, MusicCollection.resync, MusicCollection.close, MusicCollection.get_collection_stats, MusicCollection.get_cover, MusicCollection.get_cover_sizes.

MusicCollection(music_root, db_path, logger=None)

Manages a music collection database and provides search and detail retrieval functionality. Handles lazy loading, background indexing, and query parsing for artists, albums, and tracks.

Initializes a MusicCollection backed by a SQLite database and music root directory. Sets up logging, collection extraction, and schedules any required initial indexing or resync operations.

Parameters:

Name Type Description Default
music_root Path | str

The root directory containing the music files to be indexed.

required
db_path Path | str

The path to the SQLite database file used to store collection metadata.

required
logger Logger

Optional logger instance for recording informational and error messages.

None

Methods:

Name Description
rebuild

Triggers a full rebuild of the music collection database.

resync

Performs a resync of the music collection database.

close

Stops monitoring and closes resources associated with the music collection.

count

Returns the total number of tracks in the music collection database.

search_grouped

Searches the music collection and returns grouped results by artists, albums, and tracks.

get_artist_details

Retrieves detailed information about an artist, including their albums and tracks.

get_album_details

Retrieves detailed information about an album given its release directory.

get_track

Retrieves metadata for a single track identified by its path.

get_cover

Retrieves or generates the cover image path for a given album release directory.

get_cover_sizes

Returns URLs for multiple size variants of a cover image.

get_collection_stats

Returns high-level statistics about the music collection.

Source code in src/musiclib/reader.py
def __init__(
    self, music_root: Path | str, db_path: Path | str, logger: Logger = None
) -> None:
    """Initializes a MusicCollection backed by a SQLite database and music root directory.
    Sets up logging, collection extraction, and schedules any required initial indexing or resync operations.

    Args:
        music_root: The root directory containing the music files to be indexed.
        db_path: The path to the SQLite database file used to store collection metadata.
        logger: Optional logger instance for recording informational and error messages.
    """
    self.music_root = Path(music_root).resolve()
    self.db_path = Path(db_path)
    self._logger = logger or NullLogger()
    self._extractor = CollectionExtractor(self.music_root, self.db_path, logger=logger)

    track_count = self.count()
    self._startup_mode = "rebuild" if track_count == 0 else "resync"
    self._logger.info(
        "No tracks in DB — scheduling initial rebuild"
        if track_count == 0
        else "Start resync of DB"
    )

    self._background_task_running = False
    self._extractor.start_monitoring()
    self._start_background_startup_job()
    if track_count == 0:
        self._extractor.wait_for_indexing_start()

    self._last_search_session: SearchSession | None = None

    self.covers_dir = self.db_path.parent / "cache" / "covers"
    self.covers_dir.mkdir(parents=True, exist_ok=True)
    self._setup_fallback_cover()

rebuild()

Triggers a full rebuild of the music collection database. Rebuilds the database from scratch using the current music files.

Returns:

Type Description
None

None

Source code in src/musiclib/reader.py
def rebuild(self) -> None:
    """Triggers a full rebuild of the music collection database.
    Rebuilds the database from scratch using the current music files.

    Returns:
        None
    """
    self._extractor.rebuild()

resync()

Performs a resync of the music collection database. Updates the database to reflect changes in the music files without a full rebuild.

Returns:

Type Description
None

None

Source code in src/musiclib/reader.py
def resync(self) -> None:
    """Performs a resync of the music collection database.
    Updates the database to reflect changes in the music files without a full rebuild.

    Returns:
        None
    """
    self._extractor.resync()

close()

Stops monitoring and closes resources associated with the music collection. Cleans up background tasks and releases any held resources.

Returns:

Type Description
None

None

Source code in src/musiclib/reader.py
def close(self) -> None:
    """Stops monitoring and closes resources associated with the music collection.
    Cleans up background tasks and releases any held resources.

    Returns:
        None
    """
    self._extractor.stop()

count()

Returns the total number of tracks in the music collection database. Executes a query to count all tracks currently indexed.

Returns:

Name Type Description
int int

The number of tracks in the database.

Source code in src/musiclib/reader.py
def count(self) -> int:
    """Returns the total number of tracks in the music collection database.
    Executes a query to count all tracks currently indexed.

    Returns:
        int: The number of tracks in the database.
    """
    with self._get_conn() as conn:
        return conn.execute("SELECT COUNT(*) FROM tracks").fetchone()[0]

search_grouped(query, limit=30)

Searches the music collection and returns grouped results by artists, albums, and tracks. Also returns the parsed terms for highlighting.

Parameters:

Name Type Description Default
query str

The search query string.

required
limit int

Maximum number of results to return per group.

30

Returns:

Type Description
tuple[dict, dict]

tuple[dict, dict]: A tuple of (grouped results, parsed terms).

Source code in src/musiclib/reader.py
def search_grouped(self, query: str, limit: int = 30) -> tuple[dict, dict]:
    """Searches the music collection and returns grouped results by artists, albums, and tracks.
    Also returns the parsed terms for highlighting.

    Args:
        query: The search query string.
        limit: Maximum number of results to return per group.

    Returns:
        tuple[dict, dict]: A tuple of (grouped results, parsed terms).
    """
    if not query.strip():
        return {"artists": [], "albums": [], "tracks": []}, {}

    terms = self._parse_query(query)
    has_artist = bool(terms["artist"])
    has_album = bool(terms["album"])
    is_free_text = not has_artist and not has_album and not terms["track"]

    reuse_session = self._last_search_session
    can_reuse = self._can_reuse_session(reuse_session, query, terms)

    if can_reuse:
        artist_candidates, album_candidates, track_candidates = (
            self._collect_pass_two_candidates(
                reuse_session=reuse_session,
                terms=terms,
                has_artist=has_artist,
                has_album=has_album,
                is_free_text=is_free_text,
            )
        )
    else:
        artist_candidates, album_candidates, track_candidates = (
            self._collect_pass_one_candidates(
                terms=terms,
                limit=limit,
                has_artist=has_artist,
                has_album=has_album,
                is_free_text=is_free_text,
            )
        )

    grouped = self._build_hierarchical_results(
        artist_candidates=artist_candidates,
        album_candidates=album_candidates,
        track_candidates=track_candidates,
        limit=limit,
    )

    self._last_search_session = SearchSession(
        query=query,
        terms=terms,
        artist_candidates=artist_candidates,
        album_candidates=album_candidates,
        track_candidates=track_candidates,
        timestamp=time(),
    )

    return grouped, terms

get_artist_details(artist)

Retrieves detailed information about an artist, including their albums and tracks. Returns a dictionary with the artist name and a list of albums containing track details.

Parameters:

Name Type Description Default
artist str

The name of the artist to retrieve details for.

required

Returns:

Name Type Description
dict dict

Dictionary containing the artist name and a list of albums with track information.

Source code in src/musiclib/reader.py
def get_artist_details(self, artist: str) -> dict:
    """Retrieves detailed information about an artist, including their albums and tracks.
    Returns a dictionary with the artist name and a list of albums containing track details.

    Args:
        artist: The name of the artist to retrieve details for.

    Returns:
        dict: Dictionary containing the artist name and a list of albums with track information.
    """
    with self._get_conn() as conn:
        cur = conn.execute(
            """
            SELECT album, title, path, filename, duration, disc_number, track_number
            FROM tracks
            WHERE artist = ?
            ORDER BY album COLLATE NOCASE, disc_number, track_number, title COLLATE NOCASE
            """,
            (artist,),
        )
        releases_map = defaultdict(list)
        for row in cur:
            release_dir = self._get_release_dir(row["path"])
            releases_map[release_dir].append(
                self._build_track_dict(row, artist=artist, album=row["album"])
            )

        albums = []
        for release_dir, tracks in sorted(
            releases_map.items(),
            key=lambda x: x[1][0].get("album", "") if x[1] else "",
        ):
            album_name = (
                tracks[0].get("album", "Unknown Album")
                if tracks
                else "Unknown Album"
            )
            album_cover = self.get_cover(release_dir)

            # Check if this is a compilation album
            is_compilation = self._is_compilation_album(release_dir)

            albums.append(
                {
                    "album": album_name,
                    "cover": album_cover,
                    "tracks": tracks,
                    "release_dir": release_dir,
                    "is_compilation": is_compilation,
                }
            )

        return {"artist": artist, "albums": albums}

get_album_details(release_dir)

Retrieves detailed information about an album given its release directory. Returns a dictionary with album details, including artist, tracks, compilation status, and release directory.

Parameters:

Name Type Description Default
release_dir str

The release directory relative to the music root.

required

Returns:

Name Type Description
dict dict

Dictionary containing album details, track list, and compilation status.

Source code in src/musiclib/reader.py
def get_album_details(self, release_dir: str) -> dict:
    """Retrieves detailed information about an album given its release directory.
    Returns a dictionary with album details, including artist, tracks, compilation status, and release directory.

    Args:
        release_dir: The release directory relative to the music root.

    Returns:
        dict: Dictionary containing album details, track list, and compilation status.
    """
    # Construct the expected directory pattern with trailing slash
    expected_dir = release_dir if release_dir.endswith("/") else f"{release_dir}/"

    with self._get_conn() as conn:
        cur = conn.execute(
            f"""
            SELECT artist, title, path, filename, duration, album
            FROM tracks
            WHERE {self._sql_release_dir_expr()} = ?
            ORDER BY disc_number, track_number, title COLLATE NOCASE
            """,
            (expected_dir,),
        )
        rows = cur.fetchall()

        if not rows:
            return {
                "artist": "",
                "album": "",
                "tracks": [],
                "is_compilation": False,
                "release_dir": release_dir,
            }

        # Album name from first row
        album_name = rows[0]["album"] or "Unknown Album"

        # Build track list
        track_list = [self._build_track_dict(row) for row in rows]

        # Detect compilation
        artists = {t["artist"] for t in track_list if t["artist"]}
        is_compilation = len(artists) > 3
        display_artist = (
            "Various Artists" if is_compilation else next(iter(artists))
        )

        return {
            "artist": display_artist,
            "album": album_name,
            "tracks": track_list,
            "is_compilation": is_compilation,
            "release_dir": release_dir,
        }

get_track(path)

Retrieves metadata for a single track identified by its path.

Queries the music library database for the track and returns a normalized metadata dictionary if found.

Parameters:

Name Type Description Default
path Path

The full or relative path of the track to look up.

required

Returns:

Type Description
dict | None

dict | None: A dictionary of track metadata if the track exists, otherwise None.

Source code in src/musiclib/reader.py
def get_track(self, path: Path) -> dict | None:
    """Retrieves metadata for a single track identified by its path.

    Queries the music library database for the track and returns a normalized metadata dictionary if found.

    Args:
        path: The full or relative path of the track to look up.

    Returns:
        dict | None: A dictionary of track metadata if the track exists, otherwise None.
    """
    with self._get_conn() as conn:
        cur = conn.execute(
            """
            SELECT path, filename, artist, album, title, albumartist, track_number, disc_number, duration, mtime
            FROM tracks
            WHERE path = ?
            """,
            (str(path),),
        )
        if rows := cur.fetchall():
            track = rows[0]
            # Get the release directory to fetch the cover
            release_dir = self._get_release_dir(track["path"])

            return {
                "path": Path(path),
                "filename": track["filename"],
                "artist": track["artist"],
                "album": track["album"],
                "track": track["title"],
                "albumartist": track["albumartist"],
                "track_number": track["track_number"],
                "disc_number": track["disc_number"],
                "duration": self._format_duration(track["duration"]),
                "time_added": track["mtime"],
                "cover": self.get_cover(release_dir),
            }
        else:
            return None

get_cover(release_dir)

Retrieves or generates the cover image path for a given album release directory. Returns a relative path to a cached or newly extracted cover image if available, or a fallback image path if no cover is found.

Parameters:

Name Type Description Default
release_dir str

The release directory identifier used to locate or derive the cover image.

required

Returns:

Type Description
str | None

str | None: Relative path to the cover image within the covers directory, or fallback image path if not found.

Source code in src/musiclib/reader.py
def get_cover(self, release_dir: str) -> str | None:
    """Retrieves or generates the cover image path for a given album release directory.
    Returns a relative path to a cached or newly extracted cover image if available,
    or a fallback image path if no cover is found.

    Args:
        release_dir: The release directory identifier used to locate or derive the cover image.

    Returns:
        str | None: Relative path to the cover image within the covers directory, or fallback image path if not found.
    """
    if not release_dir:
        return "covers/_fallback.jpg"

    # Sanitize to a safe filename slug
    slug = self._sanitize_release_dir(release_dir)
    cover_path = self.covers_dir / f"{slug}.jpg"

    if cover_path.exists():
        return f"covers/{slug}.jpg"

    # Extract on demand
    if self._extract_cover(release_dir, cover_path):
        return f"covers/{slug}.jpg"

    return "covers/_fallback.jpg"

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

get_collection_stats()

Returns high-level statistics about the music collection.

This method queries the tracks table to compute aggregate counts of distinct artists, distinct artist/album combinations, total tracks, and the timestamp of the most recently added track.

Returns:

Name Type Description
dict dict

A dictionary containing qty_tracks, qty_artists,

dict

qty_albums, and last_added (or None when no tracks exist).

Source code in src/musiclib/reader.py
def get_collection_stats(self) -> dict:
    """Returns high-level statistics about the music collection.

    This method queries the tracks table to compute aggregate counts of
    distinct artists, distinct artist/album combinations, total tracks, and
    the timestamp of the most recently added track.

    Returns:
        dict: A dictionary containing ``qty_tracks``, ``qty_artists``,
        ``qty_albums``, and ``last_added`` (or None when no tracks exist).
    """
    with self._get_conn() as conn:
        cur = conn.execute(
            """
            SELECT COUNT(DISTINCT artist) as qty_artists,
                COUNT(DISTINCT artist || album) as qty_albums,
                COUNT(*) AS qty_tracks,
                SUM(duration) AS total_duration,
                MAX(mtime) AS time_lastadded
            FROM tracks
            """
        )
        if rows := cur.fetchall():
            return {
                    "num_tracks": rows[0]["qty_tracks"],
                    "num_artists": rows[0]["qty_artists"],
                    "num_albums": rows[0]["qty_albums"],
                    "total_duration": rows[0]["total_duration"],
                    "last_added": rows[0]["time_lastadded"],
                }
        else:
            return {
                "num_tracks": 0,
                "num_artists": 0,
                "num_albums": 0,
                "total_duration": 0,
                "last_added": None,
            }

MusicCollectionUI(music_root, db_path, logger=None)

Bases: MusicCollection

Extends MusicCollection to provide UI-specific search and highlighting features. Adds methods for formatting, escaping, and highlighting search results for user interfaces.

Initializes the MusicCollectionUI with the given music root, database path, and optional logger. Sets up the UI-specific extension of the music collection functionality.

Parameters:

Name Type Description Default
music_root Path

Path to the root directory containing music files.

required
db_path Path

Path to the SQLite database file.

required
logger Logger

Optional logger instance.

None

Returns:

Type Description
None

None

Methods:

Name Description
search_highlighting

Performs a search and returns results with highlighted matching terms for UI display.

Source code in src/musiclib/ui.py
def __init__(self, music_root: Path, db_path: Path, logger: Logger = None) -> None:
    """Initializes the MusicCollectionUI with the given music root, database path, and optional logger.
    Sets up the UI-specific extension of the music collection functionality.

    Args:
        music_root: Path to the root directory containing music files.
        db_path: Path to the SQLite database file.
        logger: Optional logger instance.

    Returns:
        None
    """
    super().__init__(music_root, db_path, logger)

search_highlighting(query, limit=30)

Performs a search and returns results with highlighted matching terms for UI display. Groups and highlights artists, albums, and tracks based on the search query and terms.

Parameters:

Name Type Description Default
query str

The search query string.

required
limit int

Maximum number of results to return.

30

Returns:

Type Description
list[dict]

list[dict]: List of dictionaries containing highlighted search results for artists, albums, and tracks.

Source code in src/musiclib/ui.py
def search_highlighting(self, query: str, limit: int = 30) -> list[dict]:
    """Performs a search and returns results with highlighted matching terms for UI display.
    Groups and highlights artists, albums, and tracks based on the search query and terms.

    Args:
        query: The search query string.
        limit: Maximum number of results to return.

    Returns:
        list[dict]: List of dictionaries containing highlighted search results for artists, albums, and tracks.
    """
    if not query.strip():
        return []

    grouped, terms = self.search_grouped(query, limit=limit)

    # Combine all search terms for highlighting
    all_terms = (
        terms.get("artist", [])
        + terms.get("album", [])
        + terms.get("track", [])
        + terms.get("general", [])
    )
    query_lower = query.lower()

    # Apply strict filtering for tagged searches
    filter_artists, filter_albums = self._should_filter_strict(terms)
    if filter_artists:
        grouped["artists"] = []
    if filter_albums:
        grouped["albums"] = []

    results = []

    # Process artists
    for artist in grouped["artists"]:
        if artist_result := self._process_artist_result(
            artist, terms, all_terms, query_lower
        ):
            results.append(artist_result)

    # Process albums
    for album in grouped["albums"]:
        if album_result := self._process_album_result(
            album, terms, all_terms, query_lower
        ):
            results.append(album_result)

    # Process tracks
    for track in grouped["tracks"]:
        track_result = self._process_track_result(track, all_terms)
        results.append(track_result)

    return results