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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- Unified `__repr__` format to ClassName(key=value)
- For all services, the `get_playlist` method is now more consistent and always returns None if no playlist is found, independent of the used identifier. Added `get_playlist_or_raise` to get predictable behaviour and return Type.

## [0.4.0] - 2026-03-07

Expand Down
9 changes: 8 additions & 1 deletion plistsync/core/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,9 +401,16 @@ def get_playlist(self, *args, **kwargs) -> C | None:
"""Get a playlist by identifier.

Implement with kwargs like ``name=``, ``id=``, ``url=``, or ``uri=``.
Return ``None`` for name searches that fail.
Return ``None`` for searches that fail.
"""
...

def get_playlist_or_raise(self, *args, **kwargs) -> C:
"""Like get_playlist() but raises if no result is found."""
playlist = self.get_playlist(*args, **kwargs)
if playlist is None:
raise ValueError(f"Could not find playlist for {kwargs}")
return playlist

def __repr__(self) -> str:
return f"{type(self).__name__}()"
5 changes: 3 additions & 2 deletions plistsync/services/plex/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -652,7 +652,7 @@ def section_name_to_id(self, section_name_or_id: str | int) -> int:
return section_id
raise ValueError(f"Library '{section_name_or_id}' not found.")

def playlist_name_to_id(self, playlist_name_or_id: str | int) -> int:
def playlist_name_to_id(self, playlist_name_or_id: str | int) -> int | None:
"""Resolve a playlist ID from a name or return the ID if already numeric."""
try:
playlist_id = int(playlist_name_or_id)
Expand All @@ -672,5 +672,6 @@ def playlist_name_to_id(self, playlist_name_or_id: str | int) -> int:
)
return playlist_id

raise ValueError(f"Playlist '{playlist_name_or_id}' not found.")
log.debug(f"Playlist '{playlist_name_or_id}' not found.")
return None
return playlist_id
31 changes: 14 additions & 17 deletions plistsync/services/plex/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from pathlib import Path
from typing import Any, overload

from typing_extensions import override
from requests import HTTPError

from plistsync.core import GlobalTrackIDs, LibraryCollection, PathRewrite
from plistsync.core.collection import GlobalLookup, LocalLookup, TrackStream
Expand Down Expand Up @@ -84,23 +84,20 @@ def playlists(self) -> Iterable[PlexPlaylistCollection]:

@overload
def get_playlist(self, *, name: str) -> PlexPlaylistCollection | None: ...

@overload
def get_playlist(self, *, id: int) -> PlexPlaylistCollection: ...
def get_playlist(self, *, id: int) -> PlexPlaylistCollection | None: ...

@override
def get_playlist(
self,
name: str | None = None,
id: int | None = None,
) -> PlexPlaylistCollection | None:
"""Get a specific playlist.

One of the kwargs must be given. Either search
by name or get by id (rating_key).
Exactly one of the kwargs must be given. Either search
by name or by id (rating_key).

Will raise on id not found but return None if
search by name not found.
Will return None if not found.

Tracks are fetched eagerly.
"""
Expand All @@ -111,17 +108,17 @@ def get_playlist(
id = self.api.converts.playlist_name_to_id(name)

if id is None:
# For searches we want to return None if not found
log.debug(f"Could not find playlist with name '{name}'")
return None

plist = PlexPlaylistCollection.from_response_data(
library=self,
playlist_data=self.api.playlist.get(id),
tracks_data=self.api.playlist.get_items(id),
)

return plist
try:
return PlexPlaylistCollection.from_response_data(
library=self,
playlist_data=self.api.playlist.get(id),
tracks_data=self.api.playlist.get_items(id),
)
except HTTPError as e:
log.debug(f"Failed to get playlist for {id=}, likely invalid id: {e}")
return None

@cached_property
def locations(self) -> list[Path]:
Expand Down
5 changes: 3 additions & 2 deletions plistsync/services/spotify/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,7 @@ def _get_playlists_full(self) -> list[SpotifyApiPlaylistResponseFull]:
return playlists_details


def extract_spotify_playlist_id(url_or_uri: str) -> str:
def extract_spotify_playlist_id(url_or_uri: str) -> str | None:
"""Extract the Spotify ID from a playlist URL or URI."""
# Pattern matches:
# spotify:playlist:<id>
Expand All @@ -690,4 +690,5 @@ def extract_spotify_playlist_id(url_or_uri: str) -> str:
if match:
return match.group(1)
else:
raise ValueError(f"Invalid Spotify playlist URL or URI: {url_or_uri}")
log.debug(f"Invalid Spotify playlist URL or URI: {url_or_uri}")
return None
39 changes: 16 additions & 23 deletions plistsync/services/spotify/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
from typing import overload

from requests import HTTPError
from typing_extensions import override

from plistsync.core import GlobalTrackIDs
from plistsync.core.collection import (
Expand Down Expand Up @@ -46,17 +45,13 @@ def playlists(self) -> Iterable[SpotifyPlaylistCollection]:

@overload
def get_playlist(self, *, name: str) -> SpotifyPlaylistCollection | None: ...

@overload
def get_playlist(self, *, id: str) -> SpotifyPlaylistCollection: ...

def get_playlist(self, *, id: str) -> SpotifyPlaylistCollection | None: ...
@overload
def get_playlist(self, *, url: str) -> SpotifyPlaylistCollection: ...

def get_playlist(self, *, url: str) -> SpotifyPlaylistCollection | None: ...
@overload
def get_playlist(self, *, uri: str) -> SpotifyPlaylistCollection: ...
def get_playlist(self, *, uri: str) -> SpotifyPlaylistCollection | None: ...

@override
def get_playlist(
self,
name: str | None = None,
Expand All @@ -66,11 +61,9 @@ def get_playlist(
) -> SpotifyPlaylistCollection | None:
"""Get a specific playlist.

One of the kwargs must be given. Either search
by name or get by id/url/uri.
Exactly one of the kwargs must be given: name/id/url/uri.

Will raise on id/url/uri not found but return None if
search by name not found.
Returns None if not found.
"""

if sum(arg is not None for arg in [name, id, url, uri]) != 1:
Expand All @@ -89,19 +82,19 @@ def get_playlist(
id = plist["id"]
break

if id is None:
# For searches we want to return None if not found
log.debug(f"Could not find playlist with name '{name}'")
return None

# This should never realistically happen -> assert instead of error
assert id is not None, "ID must be set after resolving name/url/uri"
if id is None:
log.debug(f"Could not find playlist for {name=} {uri=} {url=}")
return None

# Direct ID lookup (fastest path)
return SpotifyPlaylistCollection.from_response_data(
self,
self.api.playlist.get(id),
)
try:
return SpotifyPlaylistCollection.from_response_data(
self,
self.api.playlist.get(id),
)
except HTTPError as e:
log.debug(f"Failed to get playlist for {id=}, likely invalid id: {e}")
return None

# --------------------------- GlobalLookup protocol -------------------------- #

Expand Down
5 changes: 3 additions & 2 deletions plistsync/services/tidal/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -802,7 +802,7 @@ def include_to_lookup(included: list[T_Included]) -> LookupDict[T_Included]:
return {(item["type"], item["id"]): item for item in included}


def extract_tidal_playlist_id(url: str) -> str:
def extract_tidal_playlist_id(url: str) -> str | None:
"""Extract the Tidal playlist ID from a URL."""
# Example URL formats:
# https://tidal.com/browse/playlist/{playlist_id}
Expand All @@ -815,4 +815,5 @@ def extract_tidal_playlist_id(url: str) -> str:
if match:
return match.group(1)
else:
raise ValueError(f"Invalid Tidal playlist URL: {url}")
log.debug(f"Invalid Tidal playlist URL: {url}")
return None
36 changes: 20 additions & 16 deletions plistsync/services/tidal/library.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from collections.abc import Iterable
from typing import overload

from requests import HTTPError

from plistsync.core import GlobalTrackIDs
from plistsync.core.collection import (
GlobalLookup,
Expand Down Expand Up @@ -34,12 +36,10 @@ def playlists(self) -> Iterable[TidalPlaylistCollection]:

@overload
def get_playlist(self, *, name: str) -> TidalPlaylistCollection | None: ...

@overload
def get_playlist(self, *, id: str) -> TidalPlaylistCollection: ...

def get_playlist(self, *, id: str) -> TidalPlaylistCollection | None: ...
@overload
def get_playlist(self, *, url: str) -> TidalPlaylistCollection: ...
def get_playlist(self, *, url: str) -> TidalPlaylistCollection | None: ...

def get_playlist(
self,
Expand All @@ -49,11 +49,9 @@ def get_playlist(
) -> TidalPlaylistCollection | None:
"""Get a specific playlist.

One of the kwargs must be given. Either search
by name or get by id/url.
Exactly one of the kwargs must be given: name/id/url.

Will raise on id/url not found but return None if
search by name not found.
Returns None if not found.
"""
if sum(arg is not None for arg in [name, id, url]) != 1:
raise ValueError("Exactly one of name, id, or url must be provided")
Expand All @@ -69,18 +67,24 @@ def get_playlist(
)
found = [p for p in playlists if p["attributes"]["name"] == name]
if len(found) == 0:
log.info(f"Could not find playlist with name {name}")
return None
id = None
else:
id = found[0]["id"]

id = found[0]["id"]
if len(found) > 1:
log.info(f"Found more than one playlist with name {name}, using {id}")

# This should never realistically happen -> assert instead of error
assert id is not None, "ID must be set after resolving name/url"
return TidalPlaylistCollection.from_response_data(
self, *self.api.playlist.get(id)
)
if id is None:
log.debug(f"Could not find playlist for {name=} {url=}")
return None

try:
return TidalPlaylistCollection.from_response_data(
self, *self.api.playlist.get(id)
)
except HTTPError as e:
log.debug(f"Failed to get playlist for {id=}, likely invalid id: {e}")
return None

def has_playlist(self, name: str) -> bool:
"""Check if a playlist with the given name exists in the user's library."""
Expand Down
41 changes: 25 additions & 16 deletions plistsync/services/traktor/library.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from collections.abc import Iterable
from datetime import datetime
from pathlib import Path
Expand All @@ -14,7 +16,7 @@
from .path import NMLPath
from .playlist import NMLPlaylistCollection
from .track import NMLTrack
from .utility import xpath_string_escape
from .utility import sanitize_plist_name, xpath_string_escape

if TYPE_CHECKING:
from lxml.etree import _Element, _ElementTree
Expand All @@ -31,7 +33,7 @@ class NMLLibraryCollection(LibraryCollection, TrackStream, LocalLookup):
"""

path: Path
tree: "_ElementTree"
tree: _ElementTree

def __init__(self, path: Path | str):
if isinstance(path, str):
Expand Down Expand Up @@ -81,23 +83,21 @@ def playlists(self) -> Iterable[NMLPlaylistCollection]:
yield pl

@overload
def get_playlist(self, *, name: str) -> NMLPlaylistCollection: ...
def get_playlist(self, *, name: str) -> NMLPlaylistCollection | None: ...

@overload
def get_playlist(self, *, uuid: str) -> NMLPlaylistCollection: ...
def get_playlist(self, *, uuid: str) -> NMLPlaylistCollection | None: ...

def get_playlist(
self,
name: str | None = None,
uuid: str | None = None,
# path: str | Path | None = None,
) -> NMLPlaylistCollection:
) -> NMLPlaylistCollection | None:
"""Get a specific playlist.

One of the kwargs must be given. Either search
by name or get by uuid.
Exactly one of the kwargs must be given. Either search by name or by uuid.

Will raise if not found!
If Ids are not found this raises, but if names are not found it retuns None.
"""
if sum(arg is not None for arg in [name, uuid]) != 1:
raise ValueError("Exactly one of name or uuid must be provided")
Expand All @@ -106,17 +106,26 @@ def get_playlist(

if uuid is not None:
root_node = self._get_playlist_root_node_by_uuid(uuid)
else:
root_node = self._get_playlist_root_node_by_name(name) # type: ignore[arg-type]
elif name is not None:
s_name = sanitize_plist_name(name)
if s_name != name:
log.warning(
f"Playlist name changed from `{name}` to `{s_name}`"
" to avoid issues with Traktor.",
)
root_node = self._get_playlist_root_node_by_name(s_name)

if root_node is None:
return None

return NMLPlaylistCollection(self, root_node)

def _playlist_nodes(self) -> "Iterable[_Element]":
def _playlist_nodes(self) -> Iterable[_Element]:
"""Get all playlists in the NML file."""
nodes = self.tree.xpath(".//NODE[@TYPE='PLAYLIST']")
return nodes

def _get_playlist_root_node_by_uuid(self, uuid: str) -> "_Element":
def _get_playlist_root_node_by_uuid(self, uuid: str) -> _Element | None:
"""Get a playlist by uuid."""

node = self.tree.xpath(
Expand All @@ -125,16 +134,16 @@ def _get_playlist_root_node_by_uuid(self, uuid: str) -> "_Element":
if len(node) > 0:
return node[0]
else:
raise ValueError(f"Playlist '{uuid}' not found!")
return None

def _get_playlist_root_node_by_name(self, name: str) -> "_Element":
def _get_playlist_root_node_by_name(self, name: str) -> _Element | None:
node = self.tree.xpath(
f".//NODE[@TYPE='PLAYLIST'][@NAME={xpath_string_escape(name)}]"
)
if len(node) > 0:
return node[0]
else:
raise ValueError(f"Playlist '{name}' not found!")
return None

# --------------------------- LocalLookup protocol --------------------------- #

Expand Down
Loading
Loading