Source code for rekordbox_set_list_manager.models.project
"""Project domain model — top-level container for all set-manager data."""
from __future__ import annotations
import datetime
from typing import cast
from uuid import UUID, uuid4
from pydantic import BaseModel, Field, model_validator
from rekordbox_set_list_manager.models.enums import RekordboxColor, SectionType
from rekordbox_set_list_manager.models.section import Section
from rekordbox_set_list_manager.models.section_name_theme import SectionNameTheme
from rekordbox_set_list_manager.models.track import Track
from rekordbox_set_list_manager.utils.constants import DEFAULT_SECTION_COLORS
def _default_section_color_map() -> dict[SectionType, RekordboxColor]:
return dict(DEFAULT_SECTION_COLORS)
[docs]
class Project(BaseModel):
"""Top-level container: one project per save file."""
[docs]
id: UUID = Field(default_factory=uuid4)
[docs]
created_at: datetime.datetime = Field(
default_factory=lambda: datetime.datetime.now(datetime.UTC)
)
[docs]
updated_at: datetime.datetime = Field(
default_factory=lambda: datetime.datetime.now(datetime.UTC)
)
# The single set — sections stored directly on the project.
[docs]
sections: list[Section] = Field(default_factory=list)
# Streaming playlist IDs from which this set was imported.
[docs]
spotify_playlist_id: str | None = None
[docs]
tidal_playlist_id: str | None = None
# Master track store: track_id → Track. Sections reference tracks by ID.
[docs]
tracks: dict[UUID, Track] = Field(default_factory=dict)
# Override the default color per section type; used when creating new sections.
[docs]
section_color_map: dict[SectionType, RekordboxColor] = Field(
default_factory=_default_section_color_map
)
# User-defined section name themes, created and edited via the GUI.
[docs]
themes: list[SectionNameTheme] = Field(default_factory=list)
# ---------------------------------------------------------------- migration
@model_validator(mode="before")
@classmethod
def _migrate_set_lists(cls, data: object) -> object:
"""Migrate old format: project.set_lists[0] → project.sections."""
if isinstance(data, dict) and "set_lists" in data:
d = cast("dict[str, object]", data)
set_lists = d.pop("set_lists", [])
if set_lists and isinstance(set_lists, list):
first = set_lists[0]
if isinstance(first, dict):
fd = cast("dict[str, object]", first)
if "sections" not in d:
d["sections"] = fd.get("sections", [])
if not d.get("spotify_playlist_id"):
d["spotify_playlist_id"] = fd.get("spotify_playlist_id")
if not d.get("tidal_playlist_id"):
d["tidal_playlist_id"] = fd.get("tidal_playlist_id")
return data
# ------------------------------------------------------------------ tracks
[docs]
def add_track(self, track: Track) -> None:
"""Add *track* to the project track dictionary.
Parameters
----------
track : Track
The track to register; stored under ``track.id``.
"""
self.tracks[track.id] = track
[docs]
def remove_track(self, track_id: UUID) -> None:
"""Remove the track and all its section references from the project.
Parameters
----------
track_id : UUID
ID of the track to delete from the track store and all sections.
"""
self.tracks.pop(track_id, None)
for section in self.sections:
section.remove_track(track_id)
[docs]
def get_track(self, track_id: UUID) -> Track | None:
"""Return the track with *track_id*, or None if not found.
Parameters
----------
track_id : UUID
The ID of the track to look up.
Returns
-------
Track | None
The matching track, or ``None`` if no track has that ID.
"""
return self.tracks.get(track_id)
# --------------------------------------------------------------- sections
[docs]
def add_section(self, section: Section) -> None:
"""Append *section* to the project's section list.
Parameters
----------
section : Section
The section to append.
"""
self.sections.append(section)
[docs]
def remove_section(self, section_id: UUID) -> None:
"""Remove the section with *section_id* from the project.
Parameters
----------
section_id : UUID
ID of the section to remove.
"""
self.sections = [s for s in self.sections if s.id != section_id]
[docs]
def get_section(self, section_id: UUID) -> Section | None:
"""Return the section with *section_id*, or None if not found.
Parameters
----------
section_id : UUID
The ID of the section to look up.
Returns
-------
Section | None
The matching section, or ``None`` if not found.
"""
return next((s for s in self.sections if s.id == section_id), None)
[docs]
def move_section(self, section_id: UUID, new_index: int) -> None:
"""Move the section with *section_id* to *new_index* in the list.
Parameters
----------
section_id : UUID
ID of the section to reposition.
new_index : int
Target zero-based position in the sections list.
"""
section = self.get_section(section_id)
if section is None:
raise ValueError(f"Section {section_id} not found in project '{self.name}'")
self.sections.remove(section)
self.sections.insert(new_index, section)
@property
[docs]
def all_track_ids(self) -> list[UUID]:
"""All track IDs across all sections, in order."""
return [tid for section in self.sections for tid in section.track_ids]
@property
[docs]
def total_track_count(self) -> int:
"""Return the total number of tracks across all sections."""
return sum(s.track_count for s in self.sections)
# ------------------------------------------------------------------ themes
[docs]
def add_theme(self, theme: SectionNameTheme) -> None:
"""Append *theme* to the project's theme list.
Parameters
----------
theme : SectionNameTheme
The theme preset to add.
"""
self.themes.append(theme)
[docs]
def remove_theme(self, theme_name: str) -> None:
"""Remove the theme named *theme_name* from the project.
Parameters
----------
theme_name : str
Exact name of the theme to remove.
"""
self.themes = [t for t in self.themes if t.name != theme_name]
[docs]
def get_theme(self, theme_name: str) -> SectionNameTheme | None:
"""Return the theme named *theme_name*, or None if not found.
Parameters
----------
theme_name : str
Exact name of the theme to look up.
Returns
-------
SectionNameTheme | None
The matching theme, or ``None`` if not found.
"""
return next((t for t in self.themes if t.name == theme_name), None)
[docs]
def apply_theme(self, theme_name: str) -> None:
"""Rename sections using the named theme.
Only sections whose SectionType appears in the theme's ``names`` mapping
are renamed; all others are left unchanged.
Parameters
----------
theme_name : str
Name of the theme to apply.
Raises
------
ValueError
If no theme with *theme_name* exists in the project.
"""
theme = self.get_theme(theme_name)
if theme is None:
raise ValueError(f"Theme '{theme_name}' not found")
for section in self.sections:
themed_name = theme.display_name_for(section.section_type)
if themed_name is not None:
section.name = themed_name
# -------------------------------------------------------------------- misc
[docs]
def touch(self) -> None:
"""Update the `updated_at` timestamp to now."""
self.updated_at = datetime.datetime.now(datetime.UTC)
[docs]
def default_color_for(self, section_type: SectionType) -> RekordboxColor:
"""Return the configured default color for *section_type*.
Parameters
----------
section_type : SectionType
The section type whose default color to retrieve.
Returns
-------
RekordboxColor
The mapped default color, or ``RekordboxColor.NONE`` if not set.
"""
return self.section_color_map.get(section_type, RekordboxColor.NONE)