feat(lib): add editing functionality, add logging, add categories class

This commit is contained in:
h
2025-11-07 16:00:41 +01:00
parent 9e2544e506
commit ba3005d9b4
10 changed files with 692 additions and 32 deletions

View File

@@ -1,11 +1,16 @@
import logging
from .components import AudioComponent, AudioUnitType, Component from .components import AudioComponent, AudioUnitType, Component
from .exceptions import MusicAppsLoadError, PluginLoadError, TagsetLoadError from .exceptions import MusicAppsLoadError, PluginLoadError, TagsetLoadError
from .logic import Logic, Plugins, SearchResult from .logic import Logic, Plugins, SearchResult
from .tags import MusicApps, Properties, Tagpool, Tagset from .tags import Category, MusicApps, Properties, Tagpool, Tagset
logging.getLogger(__name__).addHandler(logging.NullHandler())
__all__ = [ __all__ = [
"AudioComponent", "AudioComponent",
"AudioUnitType", "AudioUnitType",
"Category",
"Component", "Component",
"Logic", "Logic",
"MusicApps", "MusicApps",

View File

@@ -1,10 +1,13 @@
from dataclasses import dataclass import logging
from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from .. import defaults from .. import defaults
from ..exceptions import CannotParseComponentError from ..exceptions import CannotParseComponentError
from ..tags import Tagset from ..tags import Category, MusicApps, Tagset
logger = logging.getLogger(__name__)
class AudioUnitType(Enum): class AudioUnitType(Enum):
@@ -62,12 +65,19 @@ class AudioComponent:
version: int version: int
tags_id: str tags_id: str
tagset: Tagset tagset: Tagset
categories: list[Category] = field(default_factory=list)
def __init__( def __init__(
self, data: dict, *, lazy: bool = False, tags_path: Path = defaults.tags_path self,
data: dict,
*,
lazy: bool = False,
tags_path: Path = defaults.tags_path,
musicapps: MusicApps = None,
): ):
self.tags_path = tags_path self.tags_path = tags_path
self.lazy = lazy self.lazy = lazy
self.musicapps = musicapps or MusicApps(tags_path=self.tags_path, lazy=lazy)
try: try:
self.full_name = data.get("name") self.full_name = data.get("name")
@@ -85,17 +95,34 @@ class AudioComponent:
f"{self.subtype_code.encode('ascii').hex()}-" f"{self.subtype_code.encode('ascii').hex()}-"
f"{self.manufacturer_code.encode('ascii').hex()}" f"{self.manufacturer_code.encode('ascii').hex()}"
) )
logger.debug(f"Created AudioComponent {self.full_name} from data")
except Exception as e: except Exception as e:
raise CannotParseComponentError(f"An error occurred while parsing: {e}") raise CannotParseComponentError(
f"An error occurred while parsing: {e}"
) from e
if not lazy: if not lazy:
self.load() self.load()
def load(self) -> "AudioComponent": def load(self) -> "AudioComponent":
logger.debug(f"Loading AudioComponent {self.full_name}")
self.tagset = Tagset(self.tags_path / self.tags_id, lazy=self.lazy) self.tagset = Tagset(self.tags_path / self.tags_id, lazy=self.lazy)
logger.debug(f"Loaded Tagset for {self.full_name}")
self.categories = []
for name in self.tagset.tags.keys():
try:
logger.debug(f"Loading category {name} for {self.full_name}")
self.categories.append(
Category(name, musicapps=self.musicapps, lazy=self.lazy)
)
except Exception as e:
logger.warning(
f"Failed to load category {name} for {self.full_name}: {e}"
)
logger.debug(f"Loaded {len(self.categories)} categories for {self.full_name}")
return self return self
def __eq__(self, other): def __eq__(self, other) -> bool:
if not isinstance(other, AudioComponent): if not isinstance(other, AudioComponent):
return NotImplemented return NotImplemented
return self.tags_id == other.tags_id return self.tags_id == other.tags_id
@@ -103,5 +130,42 @@ class AudioComponent:
def __hash__(self): def __hash__(self):
return hash(self.tags_id) return hash(self.tags_id)
def set_nickname(self, nickname: str) -> "AudioComponent":
self.tagset.set_nickname(nickname)
self.load()
return self
def set_shortname(self, shortname: str) -> "AudioComponent":
self.tagset.set_shortname(shortname)
self.load()
return self
def set_categories(self, categories: list[Category]) -> "AudioComponent":
self.tagset.set_tags({category.name: "user" for category in categories})
self.load()
return self
def add_to_category(self, category: Category) -> "AudioComponent":
self.tagset.add_tag(category.name, "user")
self.load()
return self
def remove_from_category(self, category: Category) -> "AudioComponent":
self.tagset.remove_tag(category.name)
self.load()
return self
def move_to_category(self, category: Category) -> "AudioComponent":
self.tagset.move_to_tag(category.name, "user")
self.load()
return self
def move_to_parents(self) -> "AudioComponent":
for category in self.categories:
self.tagset.add_tag(category.parent.name, "user")
self.tagset.remove_tag(category.name)
self.load()
return self
__all__ = ["AudioComponent", "AudioUnitType"] __all__ = ["AudioComponent", "AudioUnitType"]

View File

@@ -1,3 +1,4 @@
import logging
import plistlib import plistlib
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -9,8 +10,11 @@ from ..exceptions import (
NonexistentPlistError, NonexistentPlistError,
OldComponentFormatError, OldComponentFormatError,
) )
from ..tags import MusicApps
from .audiocomponent import AudioComponent from .audiocomponent import AudioComponent
logger = logging.getLogger(__name__)
@dataclass @dataclass
class Component: class Component:
@@ -21,16 +25,25 @@ class Component:
audio_components: list[AudioComponent] audio_components: list[AudioComponent]
def __init__( def __init__(
self, path: Path, *, lazy: bool = False, tags_path: Path = defaults.tags_path self,
path: Path,
*,
lazy: bool = False,
tags_path: Path = defaults.tags_path,
musicapps: MusicApps = None,
): ):
self.path = path if path.suffix == ".component" else Path(f"{path}.component") self.path = path if path.suffix == ".component" else Path(f"{path}.component")
self.lazy = lazy self.lazy = lazy
self.tags_path = tags_path self.tags_path = tags_path
self.musicapps = musicapps or MusicApps(tags_path=self.tags_path, lazy=lazy)
logger.debug(f"Created Component from {self.path}")
if not lazy: if not lazy:
self.load() self.load()
def _parse_plist(self): def _parse_plist(self):
info_plist_path = self.path / "Contents" / "Info.plist" info_plist_path = self.path / "Contents" / "Info.plist"
logger.debug(f"Parsing Info.plist at {info_plist_path}")
if not info_plist_path.exists(): if not info_plist_path.exists():
raise NonexistentPlistError(f"Info.plist not found at {info_plist_path}") raise NonexistentPlistError(f"Info.plist not found at {info_plist_path}")
@@ -39,25 +52,36 @@ class Component:
plist_data = plistlib.load(fp) plist_data = plistlib.load(fp)
return plist_data return plist_data
except Exception as e: except Exception as e:
raise CannotParsePlistError(f"An error occurred: {e}") raise CannotParsePlistError(f"An error occurred: {e}") from e
def load(self) -> "Component": def load(self) -> "Component":
plist_data = self._parse_plist() plist_data = self._parse_plist()
logger.debug(f"Loaded Info.plist for {self.path}")
try: try:
self.name = self.path.name.removesuffix(".component") self.name = self.path.name.removesuffix(".component")
self.bundle_id = plist_data["CFBundleIdentifier"] self.bundle_id = plist_data.get("CFBundleIdentifier")
self.version = plist_data["CFBundleVersion"] self.version = plist_data.get("CFBundleVersion")
self.short_version = plist_data["CFBundleShortVersionString"] self.short_version = plist_data.get("CFBundleShortVersionString")
logger.debug(f"Loaded component info for {self.bundle_id}")
except Exception as e: except Exception as e:
raise CannotParsePlistError( raise CannotParsePlistError(
f"An error occurred while extracting: {e}" f"An error occurred while extracting: {e}"
) from e ) from e
try: try:
logger.debug(f"Loading components for {self.bundle_id}")
self.audio_components = [ self.audio_components = [
AudioComponent(name, lazy=self.lazy, tags_path=self.tags_path) AudioComponent(
name,
lazy=self.lazy,
tags_path=self.tags_path,
musicapps=self.musicapps,
)
for name in plist_data["AudioComponents"] for name in plist_data["AudioComponents"]
] ]
logger.debug(
f"Loaded {len(self.audio_components)} components for {self.bundle_id}"
)
except KeyError as e: except KeyError as e:
raise OldComponentFormatError( raise OldComponentFormatError(
"This component is in an old format and cannot be loaded" "This component is in an old format and cannot be loaded"
@@ -67,6 +91,7 @@ class Component:
"An error occurred while loading components" "An error occurred while loading components"
) from e ) from e
logger.debug(f"Loaded {self.name} from {self.path}")
return self return self
def __hash__(self): def __hash__(self):

View File

@@ -30,5 +30,21 @@ class CannotParseTagsetError(TagsetLoadError):
pass pass
class TagsetWriteError(TagsetLoadError):
pass
class MusicAppsLoadError(Exception): class MusicAppsLoadError(Exception):
pass pass
class MusicAppsWriteError(Exception):
pass
class CategoryValidationError(Exception):
pass
class CategoryExistsError(Exception):
pass

View File

@@ -1,17 +1,21 @@
import logging
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from .. import defaults from .. import defaults
from ..components import Component from ..components import AudioComponent, Component
from ..tags import MusicApps from ..tags import Category, MusicApps
from .plugins import Plugins from .plugins import Plugins
logger = logging.getLogger(__name__)
@dataclass @dataclass
class Logic: class Logic:
musicapps: MusicApps musicapps: MusicApps
plugins: Plugins plugins: Plugins
components: set[Component] components: set[Component]
categories: dict[str, Category]
components_path: Path = defaults.components_path components_path: Path = defaults.components_path
tags_path: Path = defaults.tags_path tags_path: Path = defaults.tags_path
@@ -33,22 +37,92 @@ class Logic:
self.musicapps = MusicApps(tags_path=self.tags_path, lazy=lazy) self.musicapps = MusicApps(tags_path=self.tags_path, lazy=lazy)
self.plugins = Plugins() self.plugins = Plugins()
self.components = set() self.components = set()
self.categories = {}
self.lazy = lazy self.lazy = lazy
logger.debug("Created Logic instance")
if not lazy: if not lazy:
self.discover_plugins() self.discover_plugins()
self.discover_categories()
def discover_plugins(self): def discover_plugins(self) -> "Logic":
for component_path in self.components_path.glob("*.component"): for component_path in self.components_path.glob("*.component"):
try: try:
component = Component(component_path, lazy=self.lazy) logger.debug(f"Loading component {component_path}")
component = Component(
component_path, lazy=self.lazy, musicapps=self.musicapps
)
self.components.add(component) self.components.add(component)
logger.debug(f"Loading plugins for {component.name}")
for plugin in component.audio_components: for plugin in component.audio_components:
self.plugins.add(plugin, lazy=self.lazy) self.plugins.add(plugin, lazy=self.lazy)
except Exception as e: except Exception as e:
assert e logger.warning(f"Failed to load component {component_path}: {e}")
continue
return self
def discover_categories(self) -> "Logic":
for category in self.musicapps.tagpool.categories.keys():
logger.debug(f"Loading category {category}")
self.categories[category] = Category(
category, musicapps=self.musicapps, lazy=self.lazy
)
return self
def sync_category_plugin_amount(self, category: Category | str) -> "Logic":
if isinstance(category, str):
category = self.categories[category]
logger.debug(f"Syncing plugin amount for {category.name}")
category.update_plugin_amount(
len(
self.plugins.get_by_category(
category.name if isinstance(category, Category) else category
)
)
)
return self
def sync_all_categories_plugin_amount(self) -> "Logic":
for category in self.categories.values():
self.sync_category_plugin_amount(category)
return self
def search_categories(self, query: str) -> set[Category]:
return {
category
for category in self.categories.values()
if query in category.name.lower()
}
def introduce_category(self, name: str) -> Category:
return Category.introduce(name, musicapps=self.musicapps, lazy=self.lazy)
def add_plugins_to_category(
self, category: Category, plugins: set[AudioComponent]
) -> "Logic":
for plugin in plugins:
plugin.add_to_category(category)
self.sync_category_plugin_amount(category)
return self
def move_plugins_to_category(
self, category: Category, plugins: set[AudioComponent]
) -> "Logic":
for plugin in plugins:
plugin.move_to_category(category)
self.sync_category_plugin_amount(category)
return self
def remove_plugins_from_category(
self, category: Category, plugins: set[AudioComponent]
) -> "Logic":
for plugin in plugins:
plugin.remove_from_category(category)
self.sync_category_plugin_amount(category)
return self
__all__ = ["Logic"] __all__ = ["Logic"]

View File

@@ -1,8 +1,11 @@
import logging
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
from ..components import AudioComponent, AudioUnitType from ..components import AudioComponent, AudioUnitType
logger = logging.getLogger(__name__)
@dataclass @dataclass
class SearchResult: class SearchResult:
@@ -26,13 +29,16 @@ class Plugins:
self._by_category: dict[str | None, set[AudioComponent]] = defaultdict(set) self._by_category: dict[str | None, set[AudioComponent]] = defaultdict(set)
def add(self, plugin: AudioComponent, *, lazy: bool = False) -> "Plugins": def add(self, plugin: AudioComponent, *, lazy: bool = False) -> "Plugins":
logger.debug(f"Adding plugin {plugin.full_name}")
self._plugins.add(plugin) self._plugins.add(plugin)
if not lazy: if not lazy:
self._index_plugin(plugin) self._index_plugin(plugin)
return self return self
def _index_plugin(self, plugin: AudioComponent): def _index_plugin(self, plugin: AudioComponent):
logger.debug(f"Indexing plugin {plugin.full_name}")
if plugin.lazy: if plugin.lazy:
logger.debug(f"{plugin.full_name} is lazy, loading first")
plugin.load() plugin.load()
plugin.tagset.load() plugin.tagset.load()
@@ -48,8 +54,10 @@ class Plugins:
self._by_category[tag.lower()].add(plugin) self._by_category[tag.lower()].add(plugin)
if not plugin.tagset.tags.keys(): if not plugin.tagset.tags.keys():
self._by_category[None].add(plugin) self._by_category[None].add(plugin)
logger.debug(f"Indexed plugin {plugin.full_name}")
def reindex_all(self): def reindex_all(self):
logger.debug("Reindexing all plugins")
self._by_full_name.clear() self._by_full_name.clear()
self._by_manufacturer.clear() self._by_manufacturer.clear()
self._by_name.clear() self._by_name.clear()
@@ -59,9 +67,11 @@ class Plugins:
self._by_subtype_code.clear() self._by_subtype_code.clear()
self._by_tags_id.clear() self._by_tags_id.clear()
self._by_category.clear() self._by_category.clear()
logger.debug("Cleared all indexes")
for plugin in self._plugins: for plugin in self._plugins:
self._index_plugin(plugin) self._index_plugin(plugin)
logger.debug("Reindexed all plugins")
def all(self): def all(self):
return self._plugins.copy() return self._plugins.copy()

View File

@@ -1,4 +1,5 @@
from .category import Category
from .musicapps import MusicApps, Properties, Tagpool from .musicapps import MusicApps, Properties, Tagpool
from .tagset import Tagset from .tagset import Tagset
__all__ = ["MusicApps", "Properties", "Tagpool", "Tagset"] __all__ = ["Category", "MusicApps", "Properties", "Tagpool", "Tagset"]

View File

@@ -0,0 +1,159 @@
import logging
from dataclasses import dataclass, field
from ..exceptions import CategoryExistsError, CategoryValidationError
from .musicapps import MusicApps
logger = logging.getLogger(__name__)
@dataclass
class Category:
name: str
musicapps: MusicApps = field(repr=False)
is_root: bool
plugin_amount: int
lazy: bool
def __init__(self, name: str, *, musicapps: MusicApps = None, lazy: bool = False):
self.name = name
self.musicapps = musicapps or MusicApps(lazy=lazy)
self.is_root = False
self.plugin_amount = 0
self.lazy = lazy
if not lazy:
self.load()
def load(self):
logger.debug(f"Validating category {self.name}")
if self.name not in self.musicapps.tagpool.categories.keys():
raise CategoryValidationError(f"Category {self.name} not found in tagpool")
self.plugin_amount = self.musicapps.tagpool.categories[self.name]
logger.debug(f"Loaded plugin amount for {self.name} - {self.plugin_amount}")
if self.name == "":
self.is_root = True
logger.debug("This is the root category")
return
if self.name not in self.musicapps.properties.sorting:
raise CategoryValidationError(f"Category {self.name} not found in sorting")
logger.debug(f"Valid category {self.name}")
@classmethod
def introduce(cls, name: str, *, musicapps: MusicApps = None, lazy: bool = False):
logger.debug(f"Introducing category {name}")
if musicapps is None:
musicapps = MusicApps()
try:
cls(name, musicapps=musicapps, lazy=lazy)
raise CategoryExistsError(f"Category {name} already exists")
except CategoryValidationError:
logger.debug(f"Category {name} doesn't exist, proceeding")
pass
musicapps.introduce_category(name)
logger.debug(f"Introduced category {name}")
return cls(name, musicapps=musicapps)
@property
def parent(self) -> "Category":
if self.is_root:
return self
return self.__class__(
":".join(self.name.split(":")[:-1]),
musicapps=self.musicapps,
lazy=self.lazy,
)
def child(self, name: str) -> "Category":
return self.__class__(
f"{self.name}:{name}", musicapps=self.musicapps, lazy=self.lazy
)
def delete(self):
if self.is_root:
return
self.musicapps.tagpool.remove_category(self.name)
self.musicapps.properties.remove_category(self.name)
def update_plugin_amount(self, amount: int):
if self.is_root:
return
self.musicapps.tagpool.write_category(self.name, amount)
self.load()
def move_up(self, steps: int = 1):
if self.is_root:
return
self.musicapps.properties.move_up(self.name, steps)
self.load()
def move_down(self, steps: int = 1):
if self.is_root:
return
self.musicapps.properties.move_down(self.name, steps)
self.load()
def move_to_top(self):
if self.is_root:
return
self.musicapps.properties.move_to_top(self.name)
self.load()
def move_to_bottom(self):
if self.is_root:
return
self.musicapps.properties.move_to_bottom(self.name)
self.load()
def move_before(self, other: "Category"):
if self.is_root:
return
self.musicapps.properties.move_before(self.name, other.name)
self.load()
def move_after(self, other: "Category"):
if self.is_root:
return
self.musicapps.properties.move_after(self.name, other.name)
self.load()
def move_to(self, index: int):
if self.is_root:
return
self.musicapps.properties.move_to_index(self.name, index)
self.load()
def swap(self, other: "Category"):
if self.is_root:
return
self.musicapps.properties.swap(self.name, other.name)
self.load()
@property
def index(self):
return self.musicapps.properties.get_index(self.name)
@property
def neighbors(self):
if self.is_root:
return None, None
neighbors = self.musicapps.properties.get_neighbors(self.name)
if neighbors is None or len(neighbors) != 2:
return None, None
return (
self.__class__(name=neighbors[0], musicapps=self.musicapps),
self.__class__(name=neighbors[1], musicapps=self.musicapps),
)
@property
def is_first(self):
return self.musicapps.properties.is_first(self.name)
@property
def is_last(self):
return self.musicapps.properties.is_last(self.name)
__all__ = ["Category"]

View File

@@ -1,20 +1,35 @@
import logging
import plistlib import plistlib
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from .. import defaults from .. import defaults
from ..exceptions import MusicAppsLoadError from ..exceptions import MusicAppsLoadError, MusicAppsWriteError
logger = logging.getLogger(__name__)
def _parse_plist(path: Path): def _parse_plist(path: Path):
logger.debug(f"Parsing plist at {path}")
if not path.exists(): if not path.exists():
raise MusicAppsLoadError(f"File not found at {path}") raise MusicAppsLoadError(f"File not found at {path}")
try: try:
with open(path, "rb") as fp: with open(path, "rb") as fp:
plist_data = plistlib.load(fp) plist_data = plistlib.load(fp)
logger.debug(f"Parsed plist for {path}")
return plist_data return plist_data
except Exception as e: except Exception as e:
raise MusicAppsLoadError(f"An error occurred: {e}") raise MusicAppsLoadError(f"An error occurred: {e}") from e
def _save_plist(path: Path, data: dict):
logger.debug(f"Saving plist to {path}")
try:
with open(path, "wb") as fp:
plistlib.dump(data, fp)
logger.debug(f"Saved plist to {path}")
except Exception as e:
raise MusicAppsWriteError(f"An error occurred: {e}") from e
@dataclass @dataclass
@@ -25,32 +40,263 @@ class Tagpool:
self.path = tags_path / "MusicApps.tagpool" self.path = tags_path / "MusicApps.tagpool"
self.lazy = lazy self.lazy = lazy
logger.debug(f"Created Tagpool from {self.path}")
if not lazy: if not lazy:
self.load() self.load()
def load(self) -> "Tagpool": def load(self) -> "Tagpool":
logger.debug(f"Loading Tagpool data from {self.path}")
self.categories = _parse_plist(self.path) self.categories = _parse_plist(self.path)
logger.debug(f"Loaded Tagpool data from {self.path}")
return self return self
def write_category(self, name: str, plugin_count: int = 0):
self.load()
self.categories[name] = plugin_count
_save_plist(self.path, self.categories)
self.load()
def introduce_category(self, name: str):
self.load()
if name in self.categories:
return
self.write_category(name)
def remove_category(self, name: str):
self.load()
self.categories.pop(name, None)
_save_plist(self.path, self.categories)
self.load()
@dataclass @dataclass
class Properties: class Properties:
sorting: list[str] sorting: list[str]
user_sorted: bool user_sorted: bool
__raw_data: dict[str, str | list[str] | bool] = field(repr=False)
def __init__(self, tags_path: Path, *, lazy: bool = False): def __init__(self, tags_path: Path, *, lazy: bool = False):
self.path = tags_path / "MusicApps.properties" self.path = tags_path / "MusicApps.properties"
self.lazy = lazy self.lazy = lazy
logger.debug(f"Created Properties from {self.path}")
if not lazy: if not lazy:
self.load() self.load()
def load(self) -> "Properties": def load(self) -> "Properties":
properties_data = _parse_plist(self.path) logger.debug(f"Loading Properties data from {self.path}")
self.sorting = properties_data.get("sorting", []) self.__raw_data = _parse_plist(self.path)
self.user_sorted = bool(properties_data.get("user_sorted", False)) logger.debug(f"Loaded Properties data from {self.path}")
self.sorting = self.__raw_data.get("sorting", [])
self.user_sorted = bool(self.__raw_data.get("user_sorted", False))
logger.debug(f"Parsed Properties data from {self.path}")
return self return self
def introduce_category(self, name: str):
self.load()
if name in self.sorting:
return
self.__raw_data["sorting"].append(name)
_save_plist(self.path, self.__raw_data)
self.load()
def enable_user_sorting(self):
self.load()
self.__raw_data["user_sorted"] = "property"
_save_plist(self.path, self.__raw_data)
self.load()
def enable_alphabetical_sorting(self):
self.load()
del self.__raw_data["user_sorted"]
_save_plist(self.path, self.__raw_data)
self.load()
def remove_category(self, name: str):
self.load()
self.__raw_data["sorting"].remove(name)
_save_plist(self.path, self.__raw_data)
self.load()
def move_up(self, category: str, steps: int = 1):
self.load()
sorting = self.sorting.copy()
if category not in sorting:
raise ValueError(f"Category '{category}' not found in sorting")
current_idx = sorting.index(category)
new_idx = max(0, current_idx - steps)
sorting.pop(current_idx)
sorting.insert(new_idx, category)
self.__raw_data["sorting"] = sorting
_save_plist(self.path, self.__raw_data)
self.load()
def move_down(self, category: str, steps: int = 1):
self.load()
sorting = self.sorting.copy()
if category not in sorting:
raise ValueError(f"Category '{category}' not found in sorting")
current_idx = sorting.index(category)
new_idx = min(len(sorting) - 1, current_idx + steps)
sorting.pop(current_idx)
sorting.insert(new_idx, category)
self.__raw_data["sorting"] = sorting
_save_plist(self.path, self.__raw_data)
self.load()
def move_to_top(self, category: str):
self.load()
sorting = self.sorting.copy()
if category not in sorting:
raise ValueError(f"Category '{category}' not found in sorting")
sorting.remove(category)
sorting.insert(0, category)
self.__raw_data["sorting"] = sorting
_save_plist(self.path, self.__raw_data)
self.load()
def move_to_bottom(self, category: str):
self.load()
sorting = self.sorting.copy()
if category not in sorting:
raise ValueError(f"Category '{category}' not found in sorting")
sorting.remove(category)
sorting.append(category)
self.__raw_data["sorting"] = sorting
_save_plist(self.path, self.__raw_data)
self.load()
def move_before(self, category: str, target: str):
self.load()
sorting = self.sorting.copy()
if category not in sorting:
raise ValueError(f"Category '{category}' not found")
if target not in sorting:
raise ValueError(f"Target category '{target}' not found")
sorting.remove(category)
target_idx = sorting.index(target)
sorting.insert(target_idx, category)
self.__raw_data["sorting"] = sorting
_save_plist(self.path, self.__raw_data)
self.load()
def move_after(self, category: str, target: str):
self.load()
sorting = self.sorting.copy()
if category not in sorting:
raise ValueError(f"Category '{category}' not found")
if target not in sorting:
raise ValueError(f"Target category '{target}' not found")
sorting.remove(category)
target_idx = sorting.index(target)
sorting.insert(target_idx + 1, category)
self.__raw_data["sorting"] = sorting
_save_plist(self.path, self.__raw_data)
self.load()
def move_to_index(self, category: str, index: int):
self.load()
sorting = self.sorting.copy()
if category not in sorting:
raise ValueError(f"Category '{category}' not found")
if index < 0:
index = len(sorting) + index
index = max(0, min(len(sorting) - 1, index))
sorting.remove(category)
sorting.insert(index, category)
self.__raw_data["sorting"] = sorting
_save_plist(self.path, self.__raw_data)
self.load()
def swap(self, category1: str, category2: str):
self.load()
sorting = self.sorting.copy()
if category1 not in sorting or category2 not in sorting:
raise ValueError("Both categories must exist")
idx1 = sorting.index(category1)
idx2 = sorting.index(category2)
sorting[idx1], sorting[idx2] = sorting[idx2], sorting[idx1]
self.__raw_data["sorting"] = sorting
_save_plist(self.path, self.__raw_data)
self.load()
def set_order(self, categories: list[str]):
self.load()
current = set(self.sorting)
new = set(categories)
if new != current:
missing = current - new
extra = new - current
raise ValueError(f"Category mismatch. Missing: {missing}, Extra: {extra}")
self.__raw_data["sorting"] = categories
_save_plist(self.path, self.__raw_data)
self.load()
def reorder(self, key_func=None, reverse: bool = False):
self.load()
sorting = self.sorting.copy()
if key_func is None:
sorting.sort(reverse=reverse)
else:
sorting.sort(key=key_func, reverse=reverse)
self.__raw_data["sorting"] = sorting
_save_plist(self.path, self.__raw_data)
self.load()
def get_index(self, category: str) -> int:
return self.sorting.index(category)
def get_at_index(self, index: int) -> str:
return self.sorting[index]
def get_neighbors(self, category: str) -> tuple[str | None, str | None]:
idx = self.get_index(category)
prev_cat = self.sorting[idx - 1] if idx > 0 else None
next_cat = self.sorting[idx + 1] if idx < len(self.sorting) - 1 else None
return prev_cat, next_cat
def is_first(self, category: str) -> bool:
return self.get_index(category) == 0
def is_last(self, category: str) -> bool:
return self.get_index(category) == len(self.sorting) - 1
@dataclass @dataclass
class MusicApps: class MusicApps:
@@ -61,13 +307,24 @@ class MusicApps:
self.path = tags_path self.path = tags_path
self.lazy = lazy self.lazy = lazy
logger.debug(f"Created MusicApps from {self.path}")
if not lazy: if not lazy:
self.load() self.load()
def load(self) -> "MusicApps": def load(self) -> "MusicApps":
self.tagpool = Tagpool(self.path, lazy=self.lazy) self.tagpool = Tagpool(self.path, lazy=self.lazy)
self.properties = Properties(self.path, lazy=self.lazy) self.properties = Properties(self.path, lazy=self.lazy)
logger.debug(f"Loaded MusicApps from {self.path}")
return self return self
def introduce_category(self, name: str):
self.tagpool.introduce_category(name)
self.properties.introduce_category(name)
def remove_category(self, name: str):
self.tagpool.remove_category(name)
self.properties.remove_category(name)
__all__ = ["MusicApps", "Properties", "Tagpool"] __all__ = ["MusicApps", "Properties", "Tagpool"]

View File

@@ -1,8 +1,12 @@
import plistlib import plistlib
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from ..exceptions import CannotParseTagsetError, NonexistentTagsetError from ..exceptions import (
CannotParseTagsetError,
NonexistentTagsetError,
TagsetWriteError,
)
@dataclass @dataclass
@@ -11,6 +15,7 @@ class Tagset:
nickname: str nickname: str
shortname: str shortname: str
tags: dict[str, str] tags: dict[str, str]
__raw_data: dict[str, str | dict[str, str]] = field(repr=False)
def __init__(self, path: Path, *, lazy: bool = False): def __init__(self, path: Path, *, lazy: bool = False):
self.path = path.with_suffix(".tagset") self.path = path.with_suffix(".tagset")
@@ -27,17 +32,61 @@ class Tagset:
plist_data = plistlib.load(fp) plist_data = plistlib.load(fp)
return plist_data return plist_data
except Exception as e: except Exception as e:
raise CannotParseTagsetError(f"An error occurred: {e}") raise CannotParseTagsetError(f"An error occurred: {e}") from e
def _write_plist(self):
try:
with open(self.path, "wb") as fp:
plistlib.dump(self.__raw_data, fp)
except Exception as e:
raise TagsetWriteError(f"An error occurred: {e}") from e
def load(self) -> "Tagset": def load(self) -> "Tagset":
tagset_data = self._parse_plist() self.__raw_data = self._parse_plist()
self.tags_id = self.path.name.removesuffix(".tagset") self.tags_id = self.path.name.removesuffix(".tagset")
self.nickname = tagset_data.get("nickname") self.nickname = self.__raw_data.get("nickname")
self.shortname = tagset_data.get("shortname") self.shortname = self.__raw_data.get("shortname")
self.tags = tagset_data.get("tags") or {} self.tags = self.__raw_data.get("tags") or {}
return self return self
def set_nickname(self, nickname: str):
self.load()
self.__raw_data["nickname"] = nickname
self._write_plist()
self.load()
def set_shortname(self, shortname: str):
self.load()
self.__raw_data["shortname"] = shortname
self._write_plist()
self.load()
def set_tags(self, tags: dict[str, str]):
self.load()
self.__raw_data["tags"] = tags
self._write_plist()
self.load()
def add_tag(self, tag: str, value: str):
self.load()
self.tags[tag] = value
self._write_plist()
self.load()
def remove_tag(self, tag: str):
self.load()
del self.tags[tag]
self._write_plist()
self.load()
def move_to_tag(self, tag: str, value: str):
self.load()
self.tags.clear()
self.tags[tag] = value
self._write_plist()
self.load()
__all__ = ["Tagset"] __all__ = ["Tagset"]