Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.6.1] - Upcoming
## [0.7.0] - Upcoming

### Added

- Added typed `Service` marker class with `track_cls`, `library_cls`, `playlist_cls` attributes and module-inferred `name` property, enabling single-handle access to all a service's core types. (#95)
- Added a migration example to fully copy a playlist from an arbitrary service to another. (#60)

### Fixed
Expand Down
45 changes: 27 additions & 18 deletions examples/community/migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@

from plistsync.core import Library, Matches, ServicePlaylist, Track
from plistsync.logger import log
from plistsync.services.spotify import SpotifyLibrary
from plistsync.services.tidal import TidalLibrary
from plistsync.services import ServiceRegistry


class MigrationContext(NamedTuple):
Expand Down Expand Up @@ -113,12 +112,6 @@ def migrate_library(
)


service_mapping: dict[str, type[SpotifyLibrary] | type[TidalLibrary]] = {
"spotify": SpotifyLibrary,
"tidal": TidalLibrary,
}


def main(
from_service: Annotated[
str,
Expand All @@ -145,25 +138,41 @@ def main(
),
] = True,
):
if not (from_library := service_mapping.get(from_service.lower())):
if not (_from_service := ServiceRegistry.get(from_service.lower())):
log.error(
f"Invalid from_service {from_service!r}.\n"
f"Pick one of {list(ServiceRegistry.dict().keys())}"
)
sys.exit(1)

if _from_service.library_cls is None:
log.error(
f"Invalid from_service {from_service!r}."
f"Pick one of {service_mapping.keys()}"
f"Invalid from_service {_from_service}.\n"
"Does not support library based operations."
)
sys.exit(1)
if not (to_library := service_mapping.get(to_service.lower())):

if not (_to_service := ServiceRegistry.get(to_service.lower())):
log.error(
f"Invalid to_service {to_service!r}.\n"
f"Pick one of {list(ServiceRegistry.dict().keys())}."
)
sys.exit(1)

if _to_service.library_cls is None:
log.error(
f"Invalid to_service {to_service!r}. "
f"Pick one of {list(service_mapping.keys())}."
f"Invalid to_service {_to_service}.\n"
"Does not support library based operations."
)
sys.exit(1)

if from_library == to_library:
raise ValueError("from_service and to_service must be different!")
if _from_service == _to_service:
log.error("from_service and to_service must be different!")
sys.exit(1)

migrate_library(
from_library(),
to_library(),
_from_service.library_cls(),
_to_service.library_cls(),
MigrationContext(overwrite=overwrite, skip_empty=skip_empty),
)

Expand Down
6 changes: 3 additions & 3 deletions plistsync/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from eyconf.cli import create_config_cli
from rich.logging import RichHandler

from plistsync.services import ServiceRegistry

from .config import Config
from .errors import DependencyError
from .logger import log, set_log_level
Expand Down Expand Up @@ -107,10 +109,8 @@ def version_callback(value: bool) -> None:

from importlib.metadata import version

from .services import available_services

ver = version("plistsync")
services = [service.name.split(".")[-1] for service in available_services()]
services = [service for service in ServiceRegistry.dict().keys()]

svc_str = ", ".join(services) if services else "none"
typer.echo(f"plistsync: {ver} ({svc_str})")
Expand Down
81 changes: 67 additions & 14 deletions plistsync/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,79 @@
# might raise with check_dependencies.

import importlib
import inspect
import pkgutil
from abc import ABC
from functools import cache
from typing import ClassVar

from plistsync.core import Library, Playlist, Track
from plistsync.errors import DependencyError


@cache
def available_services() -> list[pkgutil.ModuleInfo]:
"""Get the available services.
class Service(ABC):
"""Abstraction for discoverable service plugins.

Skips modules where dependencies are missing.
Subclasses in `services/<name>/` modules are automatically
discovered by the ServiceRegistry.

Each concrete Service exposes the key types that belong to its
service: the library/collection class and the track class.
Optionally a playlist class for playlist-capable services.
"""
valid_services = []
for module_info in pkgutil.iter_modules(__path__, __name__ + "."):

track_cls: ClassVar[type[Track]]
library_cls: ClassVar[type[Library] | None] = None
playlist_cls: ClassVar[type[Playlist] | None] = None

@property
def name(self) -> str:
"""Service name, inferred from the module (e.g. 'spotify', 'plex')."""
return self.__module__.split(".")[-1]


class ServiceRegistry:
"""Central registry for dynamic service discovery and instantiation.

Automatically discovers Service subclasses in services/* modules
using pkgutil + importlib. Lazy-loaded with @cache for performance.
Handles missing dependencies gracefully.

Usage:
>>> service = ServiceRegistry.get_service('spotify')
>>> services = ServiceRegistry.list() # {'spotify': SpotifyService(), ...}
"""

@classmethod
@cache
def get(cls, name: str) -> Service | None:
"""Dynamically import services.NAME module, find Service ABC, instantiate."""
try:
# Test import - catches missing deps (beets, plex, etc.)
importlib.import_module(module_info.name)
valid_services.append(module_info)
except DependencyError:
# Skip services with missing dependencies
pass

return valid_services
module = importlib.import_module(f"{__name__}.{name}")
except (DependencyError, ModuleNotFoundError):
return None

# Find first Service subclass (assumes 1/module)
service_cls: None | type[Service] = None
for _, obj in inspect.getmembers(module, inspect.isclass):
if issubclass(obj, Service) and obj != Service:
service_cls = obj
break

return service_cls() if service_cls else None

@classmethod
@cache
def dict(cls) -> dict[str, Service]:
"""All importable services {name: instance}.

Scans services/* modules via pkgutil, filters valid ones.
"""
services: dict[str, Service] = {}
# REQUIRES: ServiceRegistry in package with __path__ (services/__init__.py)
for module_info in pkgutil.iter_modules(__path__, __name__ + "."):
short_name = module_info.name.rsplit(".", 1)[-1]
service = cls.get(short_name)
if service is not None:
services[short_name] = service
return services
13 changes: 12 additions & 1 deletion plistsync/services/beets/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from plistsync.errors import check_imports
from plistsync.services import Service

check_imports(
service="beets",
Expand All @@ -9,4 +10,14 @@
from .database import BeetsDatabase
from .track import BeetsTrack

__all__ = ["BeetsCollection", "BeetsDatabase", "BeetsTrack"]

class BeetsService(Service):
track_cls = BeetsTrack


__all__ = [
"BeetsCollection",
"BeetsDatabase",
"BeetsService",
"BeetsTrack",
]
12 changes: 11 additions & 1 deletion plistsync/services/local/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from plistsync.errors import check_imports
from plistsync.services import Service

check_imports(
service="local",
Expand All @@ -8,4 +9,13 @@
from .collection import LocalCollection
from .track import LocalTrack

__all__ = ["LocalCollection", "LocalTrack"]

class LocalService(Service):
track_cls = LocalTrack


__all__ = [
"LocalCollection",
"LocalService",
"LocalTrack",
]
11 changes: 10 additions & 1 deletion plistsync/services/plex/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from plistsync.errors import check_imports
from plistsync.services import Service

check_imports(
service="plex",
Expand All @@ -10,9 +11,17 @@
from .playlist import PlexPlaylist
from .track import PlexTrack


class PlexService(Service):
library_cls = PlexLibrary
track_cls = PlexTrack
playlist_cls = PlexPlaylist


__all__ = [
"api",
"PlexLibrary",
"PlexPlaylist",
"PlexService",
"PlexTrack",
"api",
]
11 changes: 10 additions & 1 deletion plistsync/services/spotify/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from plistsync.errors import check_imports
from plistsync.services import Service

check_imports(
service="spotify",
Expand All @@ -10,10 +11,18 @@
from .playlist import SpotifyPlaylist
from .track import SpotifyPlaylistTrack, SpotifyTrack


class SpotifyService(Service):
library_cls = SpotifyLibrary
track_cls = SpotifyTrack
playlist_cls = SpotifyPlaylist


__all__ = [
"api",
"SpotifyLibrary",
"SpotifyPlaylist",
"SpotifyPlaylistTrack",
"SpotifyService",
"SpotifyTrack",
"api",
]
15 changes: 12 additions & 3 deletions plistsync/services/tidal/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from plistsync.errors import check_imports
from plistsync.services import Service

check_imports(
service="tidal",
Expand All @@ -10,10 +11,18 @@
from .playlist import TidalPlaylist
from .track import TidalPlaylistTrack, TidalTrack


class TidalService(Service):
library_cls = TidalLibrary
track_cls = TidalTrack
playlist_cls = TidalPlaylist


__all__ = [
"api",
"TidalTrack",
"TidalPlaylistTrack",
"TidalLibrary",
"TidalPlaylist",
"TidalPlaylistTrack",
"TidalService",
"TidalTrack",
"api",
]
11 changes: 10 additions & 1 deletion plistsync/services/traktor/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from plistsync.errors import check_imports
from plistsync.services import Service

check_imports(
service="traktor",
Expand All @@ -10,10 +11,18 @@
from .playlist import NMLPlaylist
from .track import NMLPlaylistTrack, NMLTrack


class TraktorService(Service):
library_cls = NMLLibrary
playlist_cls = NMLPlaylist
track_cls = NMLTrack


__all__ = [
"NMLPath",
"NMLLibrary",
"NMLPath",
"NMLPlaylist",
"NMLPlaylistTrack",
"NMLTrack",
"TraktorService",
]
Loading
Loading