diff --git a/CHANGELOG.md b/CHANGELOG.md index dc50f31..a1b93c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - When syncing playlists to traktor, we now insert missing tracks into the library collection. This avoids those track to disappear from the playlist when launching Traktor after the sync. (#54) +### Changes + +- Introduced a OfflineTrack class mirroring the idea behind the OfflinePlaylist. This allows us to preserve track information even after deletion from the remote service, and to keep the Playlist class hierarchy cleanly separated between in-memory and service-synced implementations. + ## [0.6.0] - 2026-04-18 ### Breaking Changes diff --git a/plistsync/core/playlist.py b/plistsync/core/playlist.py index 72e43fb..a6d005a 100644 --- a/plistsync/core/playlist.py +++ b/plistsync/core/playlist.py @@ -30,7 +30,7 @@ class MyPlaylistCollection(PlaylistCollection): from .collection import Collection, Library, TrackStream from .diff import DeleteOp, InsertOp, MoveOp, batch_consecutive, list_diff -from .track import Track +from .track import OfflineTrack, Track class PlaylistIDs(TypedDict, total=False): @@ -190,7 +190,7 @@ def __len__(self) -> int: return len(self.tracks) -class OfflinePlaylist(Playlist[Track]): +class OfflinePlaylist(Playlist[OfflineTrack]): """A offline (in memory) playlist with no service synchronization. This class provides a concrete implementation of `Playlist` for @@ -199,7 +199,7 @@ class OfflinePlaylist(Playlist[Track]): representation during playlist conversions. """ - _tracks: list[Track] + _tracks: list[OfflineTrack] _info: PlaylistInfo _ids: PlaylistIDs @@ -207,7 +207,7 @@ def __init__( self, name: str, description: str | None = None, - tracks: Sequence[Track] | None = None, + tracks: Sequence[OfflineTrack] | None = None, ) -> None: self._info = PlaylistInfo( name=name, @@ -229,11 +229,11 @@ def info(self, value: PlaylistInfo) -> None: self._info = value @property - def tracks(self) -> list[Track]: + def tracks(self) -> list[OfflineTrack]: return self._tracks @tracks.setter - def tracks(self, value: list[Track]) -> None: + def tracks(self, value: list[OfflineTrack]) -> None: self._tracks = value @@ -276,7 +276,11 @@ def delete(self) -> OfflinePlaylist: as they existed before deletion. This allows the data to be preserved or migrated elsewhere even after the remote resource is gone. """ - offline = OfflinePlaylist(self.name, self.description, self.tracks) + offline = OfflinePlaylist( + self.name, + self.description, + [OfflineTrack.from_track(t) for t in self.tracks], + ) self._remote_delete() return offline diff --git a/plistsync/core/track.py b/plistsync/core/track.py index c8ac399..93afff0 100644 --- a/plistsync/core/track.py +++ b/plistsync/core/track.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod +from copy import copy from pathlib import PurePath from typing import TypedDict @@ -95,12 +96,27 @@ class Track(ABC): is needed. """ - # ----------------------------- Info Getters ----------------------------- # + # --------------------------- Required (protocol) ---------------------------- # + + @property + @abstractmethod + def info(self) -> TrackInfo: + """Get this tracks information.""" + ... - # Lets not overdo it, in principle we could expose all underlying data, but - # this bloats a lot. - # Convention: The convenience getters give value or None, - # or lists that can be empty but have the same length as the input + @property + @abstractmethod + def global_ids(self) -> GlobalTrackIDs: + """The globally unique identifiers of this track.""" + ... + + @property + @abstractmethod + def local_ids(self) -> LocalTrackIDs: + """The locally unique identifiers of this track.""" + ... + + # ----------------------------- Info Getters ----------------------------- # @property def title(self) -> str | None: @@ -144,26 +160,6 @@ def primary_artist(self) -> str | None: # --------------------------------- Contracts -------------------------------- # - # Every track has to implement all three contracts. - - @property - @abstractmethod - def info(self) -> TrackInfo: - """Get this tracks information.""" - ... - - @property - @abstractmethod - def global_ids(self) -> GlobalTrackIDs: - """The globally unique identifiers of this track.""" - ... - - @property - @abstractmethod - def local_ids(self) -> LocalTrackIDs: - """The locally unique identifiers of this track.""" - ... - # ----------------------------------- Other ---------------------------------- # def diff(self, track2: Track) -> dict: @@ -224,3 +220,60 @@ def __repr__(self) -> str: artist = self.primary_artist or "?" title = self.title or "?" return f"{cls}(artist={artist!r}, title={title!r})" + + +class OfflineTrack(Track): + """A offline (in memory) track with attached service. + + This class provides a concrete implementation of `Track` for + managing tracks in memory without any connection to online music services. + It is useful as an intermediate representation during matching or syncing. + """ + + _info: TrackInfo + _local_ids: LocalTrackIDs + _global_ids: GlobalTrackIDs + + def __init__( + self, + info: TrackInfo, + local_ids: LocalTrackIDs | None = None, + global_ids: GlobalTrackIDs | None = None, + ): + self._info = info + self._local_ids = local_ids or {} + self._global_ids = global_ids or {} + + @property + def info(self) -> TrackInfo: + return self._info + + @property + def global_ids(self) -> GlobalTrackIDs: + return self._global_ids + + @property + def local_ids(self) -> LocalTrackIDs: + return self._local_ids + + def merge(self, track: Track) -> OfflineTrack: + """Merge another track into this one. + + This operation returns a new offline track + with the merged data. + """ + info = copy(self.info) + info.update(track.info) + + local_ids = copy(self.local_ids) + local_ids.update(track.local_ids) + + global_ids = copy(self.global_ids) + global_ids.update(track.global_ids) + + return OfflineTrack(info, local_ids, global_ids) + + @classmethod + def from_track(cls, track: Track) -> OfflineTrack: + """Create a offline track from arbitrary other track.""" + return cls(track.info, track.local_ids, track.global_ids) diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index b04334e..a86e852 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -6,6 +6,7 @@ OfflinePlaylist, Snapshot, ) +from plistsync.core.track import OfflineTrack from ..core.mock_playlist import MockMultiRequestServicePlaylist, MockServicePlaylist from ..core.mock_track import MockTrack @@ -21,7 +22,10 @@ def create_playlist(self, name="Name", n_tracks=0): return OfflinePlaylist( name, "description", - [MockTrack(global_ids={"isrc": str(i)}) for i in range(n_tracks)], + [ + OfflineTrack(info={"title": f"Track {i}"}, global_ids={"isrc": str(i)}) + for i in range(n_tracks) + ], ) @pytest.mark.parametrize( diff --git a/tests/core/test_track.py b/tests/core/test_track.py index 75042de..7871375 100644 --- a/tests/core/test_track.py +++ b/tests/core/test_track.py @@ -1,7 +1,7 @@ import pytest from pathlib import PurePath -from plistsync.core.track import Track, GlobalTrackIDs, LocalTrackIDs +from plistsync.core.track import OfflineTrack, Track, GlobalTrackIDs, LocalTrackIDs from tests.abc.tracks import TestTrack from .mock_track import MockTrack @@ -237,3 +237,20 @@ def test_track_diff_partial_data(self): assert diffs["local_ids.file_path"] == (PurePath("/path.mp3"), None) assert "local_ids.beets_id" in diffs assert diffs["local_ids.beets_id"] == (None, 42) + + +class TestOfflineTrack: + def test_merge(self): + """Test that the OfflinePlaylist merge works as expected""" + track1 = OfflineTrack(info={"title": "Title"}, global_ids={"isrc": "ISRC123"}) + track2 = OfflineTrack( + info={"artists": ["Artist"], "title": "Title2"}, + local_ids={"file_path": PurePath("/path.mp3")}, + ) + + merged = track1.merge(track2) + + assert merged.artists == ["Artist"] + assert merged.path == PurePath("/path.mp3") + assert merged.isrc == "ISRC123" + assert merged.title == "Title2" # Precedence of merged track's info