Import existing LinTunes project

Snapshot of the existing codebase before working through the TASKS.md
backlog. Real library data (data/) and the iTunes import fixture
(itunes-test-library/) are gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 21:12:01 -04:00
commit f6d35fa594
70 changed files with 8992 additions and 0 deletions

254
tests/test_round8.py Normal file
View File

@ -0,0 +1,254 @@
"""Round 8: the four 'Open / future' features.
1. "Show in Playlist…" — LibraryManager.playlists_containing.
2. Multi-track Get Info — LibraryManager.edit_tracks_fields (one composite undo,
only genuinely-changed fields per track).
3. Library search — the _matches_search predicate behind the filter box.
4. Custom start/stop times — Get Info time parsing/formatting, library-only
persistence, and the player honoring them during playback.
"""
from unittest.mock import MagicMock, patch
from lintunes.models import Library, Track, Playlist, PlaylistType
from lintunes.library_manager import LibraryManager
from lintunes.gui.library_view import _matches_search
from lintunes.gui.info_dialog import _format_ms, _parse_time_to_ms
from lintunes import player as player_module
from lintunes.player import Player
def _track(tid, **kw):
return Track(track_id=tid, name=kw.pop("name", f"Track {tid}"), **kw)
# --------------------------------------------------------------------------
# 1. Show in Playlist
# --------------------------------------------------------------------------
class TestPlaylistsContaining:
def _manager(self, tmp_path):
tracks = {i: _track(i) for i in (1, 2, 3)}
library = Library(tracks=tracks)
# Two regular playlists, a folder, and a smart playlist.
library.playlists = {
"P1": Playlist(name="Beta", persistent_id="P1",
playlist_type=PlaylistType.REGULAR,
track_ids=[1, 2]),
"P2": Playlist(name="alpha", persistent_id="P2",
playlist_type=PlaylistType.REGULAR,
track_ids=[2]),
"F1": Playlist(name="Folder", persistent_id="F1",
playlist_type=PlaylistType.FOLDER,
track_ids=[1]),
"S1": Playlist(name="Smart", persistent_id="S1",
playlist_type=PlaylistType.SMART,
track_ids=[1]),
}
return LibraryManager(library, tmp_path)
def test_lists_only_regular_playlists_with_the_track(self, qapp, tmp_path):
manager = self._manager(tmp_path)
# Track 1 is in P1 (regular), F1 (folder), S1 (smart): only P1 qualifies.
assert manager.playlists_containing(1) == [("P1", "Beta")]
def test_sorted_by_name_casefolded(self, qapp, tmp_path):
manager = self._manager(tmp_path)
# Track 2 is in both regular playlists; "alpha" sorts before "Beta".
assert manager.playlists_containing(2) == [("P2", "alpha"), ("P1", "Beta")]
def test_track_in_no_playlist_is_empty(self, qapp, tmp_path):
manager = self._manager(tmp_path)
assert manager.playlists_containing(3) == []
# --------------------------------------------------------------------------
# 2. Multi-track Get Info
# --------------------------------------------------------------------------
class TestEditTracksFields:
def _manager(self, tmp_path):
tracks = {
1: _track(1, artist="A", album="Old", genre="Rock"),
2: _track(2, artist="B", album="New", genre="Rock"),
3: _track(3, artist="C", album="Old", genre="Jazz"),
}
# No file locations, so _write_track_tags is a no-op success.
return LibraryManager(Library(tracks=tracks), tmp_path), tracks
def test_applies_to_all_selected(self, qapp, tmp_path):
manager, tracks = self._manager(tmp_path)
manager.edit_tracks_fields([1, 2, 3], {"genre": "Pop"})
assert all(t.genre == "Pop" for t in tracks.values())
def test_one_composite_undo_reverts_everything(self, qapp, tmp_path):
manager, tracks = self._manager(tmp_path)
manager.edit_tracks_fields([1, 2, 3], {"genre": "Pop"})
# A single undo entry, not one per track.
assert manager.undo_stack.can_undo()
manager.undo_stack.undo()
assert [t.genre for t in (tracks[1], tracks[2], tracks[3])] == \
["Rock", "Rock", "Jazz"]
assert not manager.undo_stack.can_undo()
def test_only_changed_fields_per_track(self, qapp, tmp_path):
manager, tracks = self._manager(tmp_path)
edited = []
manager.track_fields_edited.connect(lambda tid, f: edited.append(tid))
# album="Old" already matches tracks 1 and 3, so only track 2 changes.
manager.edit_tracks_fields([1, 2, 3], {"album": "Old"})
assert edited == [2]
assert tracks[2].album == "Old"
def test_no_change_pushes_no_undo(self, qapp, tmp_path):
manager, tracks = self._manager(tmp_path)
manager.edit_tracks_fields([1, 2, 3], {"genre": "Rock", "album": "Old"})
# Tracks 1 & 3 already match on both touched fields; track 2 changes
# genre? no — genre Rock matches track 2 too. album New != Old -> change.
# So track 2 changes album only; an undo IS pushed.
manager.undo_stack.undo()
assert tracks[2].album == "New"
def test_truly_noop_pushes_nothing(self, qapp, tmp_path):
manager, tracks = self._manager(tmp_path)
manager.edit_tracks_fields([1], {"genre": "Rock"}) # already Rock
assert not manager.undo_stack.can_undo()
# --------------------------------------------------------------------------
# 3. Library search predicate
# --------------------------------------------------------------------------
class TestSearchMatch:
def test_substring_case_insensitive_across_fields(self):
track = _track(1, name="Yesterday", artist="The Beatles",
album="Help!", year=1965)
assert _matches_search(track, ["beat"]) # artist substring
assert _matches_search(track, ["YESTER"]) # name, case-folded
assert _matches_search(track, ["1965"]) # numeric field stringified
def test_all_tokens_must_match_and(self):
track = _track(1, name="Yesterday", artist="The Beatles", year=1965)
assert _matches_search(track, ["beatles", "1965"])
assert not _matches_search(track, ["beatles", "1999"])
def test_empty_tokens_match_everything(self):
assert _matches_search(_track(1), [])
def test_no_match(self):
track = _track(1, name="Yesterday", artist="The Beatles")
assert not _matches_search(track, ["zzdoesnotexist"])
# --------------------------------------------------------------------------
# 4. Custom start/stop times
# --------------------------------------------------------------------------
class TestTimeParsing:
def test_format_round_seconds(self):
assert _format_ms(30_000) == "0:30"
assert _format_ms(90_000) == "1:30"
assert _format_ms(0) == ""
def test_format_with_millis(self):
assert _format_ms(30_500) == "0:30.500"
def test_parse_variants(self):
assert _parse_time_to_ms("0:30") == 30_000
assert _parse_time_to_ms("1:30") == 90_000
assert _parse_time_to_ms("0:30.5") == 30_500
assert _parse_time_to_ms("") == 0
def test_parse_invalid_is_none(self):
assert _parse_time_to_ms("abc") is None
def test_round_trip(self):
for ms in (15_000, 62_250, 125_000):
assert _parse_time_to_ms(_format_ms(ms)) == ms
class TestStartStopPersistence:
def test_times_persist_to_library_json(self, qapp, tmp_path):
track = _track(1, total_time=200_000) # no location -> no file write
manager = LibraryManager(Library(tracks={1: track}), tmp_path)
manager.edit_track_fields(1, {"start_time": 30_000, "stop_time": 60_000})
assert track.start_time == 30_000 and track.stop_time == 60_000
manager.flush()
# Reload from disk and confirm they survived.
from lintunes.storage import json_storage
reloaded = json_storage.load_library(tmp_path)
assert reloaded.tracks[1].start_time == 30_000
assert reloaded.tracks[1].stop_time == 60_000
class TestPlayerStartStop:
def _player(self, qapp, tracks):
library = Library()
for t in tracks:
library.tracks[t.track_id] = t
manager = MagicMock()
manager.library = library
with patch.multiple(
player_module,
QMediaPlayer=MagicMock(),
QAudioOutput=MagicMock(),
QAudioBufferOutput=MagicMock(),
QMediaDevices=MagicMock(),
):
player = Player(manager)
return player, manager
def test_stop_time_arms_only_when_before_end(self, qapp, tmp_path):
loc = str(tmp_path / "a.mp3")
(tmp_path / "a.mp3").write_bytes(b"x")
track = _track(1, location=loc, total_time=200_000, stop_time=60_000)
player, _ = self._player(qapp, [track])
player.play_queue([1], 0)
assert player._stop_at_ms == 60_000
def test_stop_time_at_or_past_end_is_disabled(self, qapp, tmp_path):
loc = str(tmp_path / "a.mp3")
(tmp_path / "a.mp3").write_bytes(b"x")
track = _track(1, location=loc, total_time=60_000, stop_time=60_000)
player, _ = self._player(qapp, [track])
player.play_queue([1], 0)
assert player._stop_at_ms == 0
def test_position_past_stop_records_play_and_advances(self, qapp, tmp_path):
for name in ("a.mp3", "b.mp3"):
(tmp_path / name).write_bytes(b"x")
t1 = _track(1, location=str(tmp_path / "a.mp3"),
total_time=200_000, stop_time=60_000)
t2 = _track(2, location=str(tmp_path / "b.mp3"), total_time=200_000)
player, manager = self._player(qapp, [t1, t2])
finished = []
player.track_finished.connect(lambda t: finished.append(t.track_id))
player.play_queue([1, 2], 0)
player._on_position(59_999) # before stop: nothing
assert manager.record_play.call_count == 0
player._on_position(60_001) # crosses the stop time
manager.record_play.assert_called_once_with(1)
assert finished == [1]
assert player.current_track.track_id == 2 # advanced
def test_note_finished_guards_double_count(self, qapp, tmp_path):
(tmp_path / "a.mp3").write_bytes(b"x")
track = _track(1, location=str(tmp_path / "a.mp3"), total_time=200_000)
player, manager = self._player(qapp, [track])
player._current_track = track
player._counted_finish_id = None
player._note_finished(track)
player._note_finished(track) # same track again
manager.record_play.assert_called_once_with(1)
def test_loaded_media_seeks_to_pending_start(self, qapp, tmp_path):
(tmp_path / "a.mp3").write_bytes(b"x")
track = _track(1, location=str(tmp_path / "a.mp3"),
total_time=200_000, start_time=30_000)
player, _ = self._player(qapp, [track])
player.play_queue([1], 0)
assert player._pending_start_ms == 30_000
player._on_media_status(player_module.QMediaPlayer.MediaStatus.LoadedMedia)
player._media.setPosition.assert_called_with(30_000)
assert player._pending_start_ms == 0 # consumed (one-shot)