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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 11 additions & 7 deletions plistsync/core/playlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -199,15 +199,15 @@ class OfflinePlaylist(Playlist[Track]):
representation during playlist conversions.
"""

_tracks: list[Track]
_tracks: list[OfflineTrack]
_info: PlaylistInfo
_ids: PlaylistIDs

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,
Expand All @@ -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


Expand Down Expand Up @@ -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

Expand Down
103 changes: 78 additions & 25 deletions plistsync/core/track.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
6 changes: 5 additions & 1 deletion tests/core/test_playlists.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
19 changes: 18 additions & 1 deletion tests/core/test_track.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Loading