From 79c62a9d76723b8a108850b3fd49c46d8b8db20b Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Fri, 1 May 2026 17:15:47 +0200 Subject: [PATCH 1/2] Introduce a OfflineTrack that similar to a OfflinePlaylist represents a in memory Track representation. --- CHANGELOG.md | 4 ++ plistsync/core/playlist.py | 14 ++--- plistsync/core/track.py | 103 ++++++++++++++++++++++++++++--------- 3 files changed, 89 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc50f317..a1b93c51 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 72e43fb8..517534a6 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,7 @@ 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 c8ac399d..93afff04 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) From 014e3359f8abaf1594c9a2f0dc6afded5e820f27 Mon Sep 17 00:00:00 2001 From: Sebastian Mohr Date: Fri, 1 May 2026 17:28:10 +0200 Subject: [PATCH 2/2] Added test and formatting. --- plistsync/core/playlist.py | 6 +++++- tests/core/test_playlists.py | 6 +++++- tests/core/test_track.py | 19 ++++++++++++++++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/plistsync/core/playlist.py b/plistsync/core/playlist.py index 517534a6..a6d005ae 100644 --- a/plistsync/core/playlist.py +++ b/plistsync/core/playlist.py @@ -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, [OfflineTrack.from_track(t) for t in 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/tests/core/test_playlists.py b/tests/core/test_playlists.py index b04334ea..a86e8520 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 75042de0..78713755 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