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:
72
tests/conftest.py
Normal file
72
tests/conftest.py
Normal file
@ -0,0 +1,72 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
# Let widget-level tests run headless; harmless for the non-GUI tests.
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def qapp():
|
||||
"""A Qt application so QObject/QTimer and real widgets work in tests.
|
||||
|
||||
Prefers a GUI QApplication (offscreen) so widget tests are possible;
|
||||
QApplication is a QCoreApplication, so existing non-GUI tests are unaffected.
|
||||
Falls back to QCoreApplication if no GUI platform is available.
|
||||
"""
|
||||
from PyQt6.QtCore import QCoreApplication
|
||||
inst = QCoreApplication.instance()
|
||||
if inst is not None:
|
||||
return inst
|
||||
try:
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
return QApplication([])
|
||||
except Exception:
|
||||
return QCoreApplication([])
|
||||
|
||||
|
||||
def _generate_audio(path, codec_args):
|
||||
"""Generate a 1-second sine-wave audio file with ffmpeg."""
|
||||
if shutil.which("ffmpeg") is None:
|
||||
pytest.skip("ffmpeg not available to generate audio fixtures")
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=1",
|
||||
*codec_args, str(path)],
|
||||
capture_output=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.skip(f"ffmpeg could not generate {path.suffix} fixture")
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mp3_file(tmp_path):
|
||||
return _generate_audio(tmp_path / "test.mp3", ["-codec:a", "libmp3lame", "-b:a", "64k"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def m4a_file(tmp_path):
|
||||
return _generate_audio(tmp_path / "test.m4a", ["-codec:a", "aac", "-b:a", "64k"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flac_file(tmp_path):
|
||||
return _generate_audio(tmp_path / "test.flac", ["-codec:a", "flac"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jpeg_bytes(tmp_path):
|
||||
"""A tiny valid JPEG for artwork tests."""
|
||||
if shutil.which("ffmpeg") is None:
|
||||
pytest.skip("ffmpeg not available to generate image fixture")
|
||||
path = tmp_path / "art.jpg"
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-y", "-f", "lavfi", "-i", "color=red:size=8x8",
|
||||
"-frames:v", "1", str(path)],
|
||||
capture_output=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.skip("ffmpeg could not generate jpeg fixture")
|
||||
return path.read_bytes()
|
||||
149
tests/test_conflict_resolver.py
Normal file
149
tests/test_conflict_resolver.py
Normal file
@ -0,0 +1,149 @@
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lintunes.storage.conflict_resolver import resolve_conflicts
|
||||
|
||||
|
||||
def _write_json(path: Path, data: dict):
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
class TestConflictResolver:
|
||||
def test_no_conflicts(self, tmp_path):
|
||||
data_dir = tmp_path / ".lintunes"
|
||||
data_dir.mkdir()
|
||||
_write_json(data_dir / "library.json", {"1": {"track_id": 1, "name": "Song"}})
|
||||
resolve_conflicts(data_dir)
|
||||
# Should not crash, data should be unchanged
|
||||
result = _read_json(data_dir / "library.json")
|
||||
assert result["1"]["name"] == "Song"
|
||||
|
||||
def test_nonexistent_dir(self, tmp_path):
|
||||
# Should not crash
|
||||
resolve_conflicts(tmp_path / "nonexistent")
|
||||
|
||||
def test_merge_play_counts(self, tmp_path):
|
||||
data_dir = tmp_path / ".lintunes"
|
||||
data_dir.mkdir()
|
||||
|
||||
original = {
|
||||
"1": {"track_id": 1, "name": "Song", "play_count": 5, "skip_count": 2}
|
||||
}
|
||||
conflict = {
|
||||
"1": {"track_id": 1, "name": "Song", "play_count": 8, "skip_count": 1}
|
||||
}
|
||||
|
||||
_write_json(data_dir / "library.json", original)
|
||||
_write_json(
|
||||
data_dir / "library.sync-conflict-20240101-120000-ABCDEFG.json",
|
||||
conflict,
|
||||
)
|
||||
|
||||
resolve_conflicts(data_dir)
|
||||
|
||||
result = _read_json(data_dir / "library.json")
|
||||
assert result["1"]["play_count"] == 8 # max(5, 8)
|
||||
assert result["1"]["skip_count"] == 2 # max(2, 1)
|
||||
|
||||
def test_merge_loved_or(self, tmp_path):
|
||||
data_dir = tmp_path / ".lintunes"
|
||||
data_dir.mkdir()
|
||||
|
||||
original = {"1": {"track_id": 1, "loved": False}}
|
||||
conflict = {"1": {"track_id": 1, "loved": True}}
|
||||
|
||||
_write_json(data_dir / "library.json", original)
|
||||
_write_json(
|
||||
data_dir / "library.sync-conflict-20240101-120000-ABCDEFG.json",
|
||||
conflict,
|
||||
)
|
||||
|
||||
resolve_conflicts(data_dir)
|
||||
|
||||
result = _read_json(data_dir / "library.json")
|
||||
assert result["1"]["loved"] is True
|
||||
|
||||
def test_merge_date_added_earliest(self, tmp_path):
|
||||
data_dir = tmp_path / ".lintunes"
|
||||
data_dir.mkdir()
|
||||
|
||||
original = {"1": {"track_id": 1, "date_added": "2022-06-01T00:00:00"}}
|
||||
conflict = {"1": {"track_id": 1, "date_added": "2020-01-15T00:00:00"}}
|
||||
|
||||
_write_json(data_dir / "library.json", original)
|
||||
_write_json(
|
||||
data_dir / "library.sync-conflict-20240101-120000-ABCDEFG.json",
|
||||
conflict,
|
||||
)
|
||||
|
||||
resolve_conflicts(data_dir)
|
||||
|
||||
result = _read_json(data_dir / "library.json")
|
||||
assert result["1"]["date_added"] == "2020-01-15T00:00:00"
|
||||
|
||||
def test_merge_new_track_from_conflict(self, tmp_path):
|
||||
data_dir = tmp_path / ".lintunes"
|
||||
data_dir.mkdir()
|
||||
|
||||
original = {"1": {"track_id": 1, "name": "Song A"}}
|
||||
conflict = {
|
||||
"1": {"track_id": 1, "name": "Song A"},
|
||||
"2": {"track_id": 2, "name": "Song B"},
|
||||
}
|
||||
|
||||
_write_json(data_dir / "library.json", original)
|
||||
_write_json(
|
||||
data_dir / "library.sync-conflict-20240101-120000-ABCDEFG.json",
|
||||
conflict,
|
||||
)
|
||||
|
||||
resolve_conflicts(data_dir)
|
||||
|
||||
result = _read_json(data_dir / "library.json")
|
||||
assert "2" in result
|
||||
assert result["2"]["name"] == "Song B"
|
||||
|
||||
def test_playlist_merge_union(self, tmp_path):
|
||||
data_dir = tmp_path / ".lintunes"
|
||||
playlists_dir = data_dir / "playlists"
|
||||
playlists_dir.mkdir(parents=True)
|
||||
|
||||
original = {"name": "My Playlist", "track_ids": [1, 2, 3], "settings": {}}
|
||||
conflict = {"name": "My Playlist", "track_ids": [2, 3, 4, 5], "settings": {}}
|
||||
|
||||
orig_path = playlists_dir / "PL1.json"
|
||||
conf_path = playlists_dir / "PL1.sync-conflict-20240101-120000-ABCDEFG.json"
|
||||
|
||||
_write_json(orig_path, original)
|
||||
_write_json(conf_path, conflict)
|
||||
|
||||
resolve_conflicts(data_dir)
|
||||
|
||||
result = _read_json(orig_path)
|
||||
# All track IDs should be present (union)
|
||||
assert set(result["track_ids"]) == {1, 2, 3, 4, 5}
|
||||
|
||||
def test_conflicts_archived(self, tmp_path):
|
||||
data_dir = tmp_path / ".lintunes"
|
||||
data_dir.mkdir()
|
||||
|
||||
_write_json(data_dir / "library.json", {"1": {"track_id": 1}})
|
||||
conf_path = data_dir / "library.sync-conflict-20240101-120000-ABCDEFG.json"
|
||||
_write_json(conf_path, {"1": {"track_id": 1}})
|
||||
|
||||
resolve_conflicts(data_dir)
|
||||
|
||||
assert not conf_path.exists()
|
||||
resolved_dir = data_dir / ".resolved"
|
||||
assert resolved_dir.exists()
|
||||
archived = list(resolved_dir.iterdir())
|
||||
assert len(archived) == 1
|
||||
118
tests/test_file_importer.py
Normal file
118
tests/test_file_importer.py
Normal file
@ -0,0 +1,118 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lintunes import tagging
|
||||
from lintunes.models import Library
|
||||
from lintunes.library_manager import LibraryManager
|
||||
from lintunes.importers import file_importer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(qapp, tmp_path):
|
||||
return LibraryManager(Library(), tmp_path / "data")
|
||||
|
||||
|
||||
class TestOrganizedDestination:
|
||||
def test_artist_album_layout(self):
|
||||
dest = file_importer.organized_destination(
|
||||
Path("/music"), {"artist": "The Band", "album": "Hits"}, "song.mp3")
|
||||
assert dest == Path("/music/The Band/Hits/song.mp3")
|
||||
|
||||
def test_album_artist_preferred(self):
|
||||
dest = file_importer.organized_destination(
|
||||
Path("/music"),
|
||||
{"artist": "Feat Guy", "album_artist": "Main Act", "album": "LP"},
|
||||
"song.mp3")
|
||||
assert dest == Path("/music/Main Act/LP/song.mp3")
|
||||
|
||||
def test_missing_tags(self):
|
||||
dest = file_importer.organized_destination(Path("/music"), {}, "song.mp3")
|
||||
assert dest == Path("/music/Unknown Artist/Unknown Album/song.mp3")
|
||||
|
||||
def test_sanitizes_separators(self):
|
||||
dest = file_importer.organized_destination(
|
||||
Path("/music"), {"artist": "AC/DC", "album": "Back: In Black?"},
|
||||
"song.mp3")
|
||||
assert dest == Path("/music/AC_DC/Back_ In Black_/song.mp3")
|
||||
|
||||
|
||||
class TestImportFile:
|
||||
def test_copies_and_adds_track(self, manager, mp3_file, tmp_path):
|
||||
tagging.write_tags(mp3_file, {"name": "My Song", "artist": "Artist X",
|
||||
"album": "Album Y"})
|
||||
music_dir = tmp_path / "music"
|
||||
track = file_importer.import_file(mp3_file, music_dir, manager)
|
||||
|
||||
assert track is not None
|
||||
expected = music_dir / "Artist X" / "Album Y" / "test.mp3"
|
||||
assert Path(track.location) == expected
|
||||
assert expected.exists()
|
||||
assert track.name == "My Song"
|
||||
assert track.date_added
|
||||
assert track.track_id in manager.library.tracks
|
||||
|
||||
def test_reimport_same_file_is_deduped(self, manager, mp3_file, tmp_path):
|
||||
music_dir = tmp_path / "music"
|
||||
track1 = file_importer.import_file(mp3_file, music_dir, manager)
|
||||
track2 = file_importer.import_file(mp3_file, music_dir, manager)
|
||||
assert track1.track_id == track2.track_id
|
||||
assert len(manager.library.tracks) == 1
|
||||
|
||||
def test_name_collision_gets_suffix(self, manager, mp3_file, tmp_path):
|
||||
music_dir = tmp_path / "music"
|
||||
dest = music_dir / "Unknown Artist" / "Unknown Album" / "test.mp3"
|
||||
dest.parent.mkdir(parents=True)
|
||||
dest.write_bytes(b"placeholder different file")
|
||||
track = file_importer.import_file(mp3_file, music_dir, manager)
|
||||
assert Path(track.location) == dest.with_stem("test 1")
|
||||
|
||||
def test_rejects_non_audio(self, manager, tmp_path):
|
||||
text = tmp_path / "notes.txt"
|
||||
text.write_text("hello")
|
||||
assert file_importer.import_file(text, tmp_path / "music", manager) is None
|
||||
|
||||
def test_import_paths_adds_to_playlist(self, manager, mp3_file, tmp_path):
|
||||
playlist = manager.create_playlist("Drops")
|
||||
imported = file_importer.import_paths(
|
||||
[mp3_file], tmp_path / "music", manager, playlist.persistent_id)
|
||||
assert len(imported) == 1
|
||||
assert playlist.track_ids == [imported[0].track_id]
|
||||
|
||||
|
||||
class TestLibraryManager:
|
||||
def test_playlist_crud_and_persistence(self, manager):
|
||||
folder = manager.create_folder("F")
|
||||
playlist = manager.create_playlist("P", folder.persistent_id)
|
||||
manager.rename_playlist(playlist.persistent_id, "P2")
|
||||
assert manager.library.playlists[playlist.persistent_id].name == "P2"
|
||||
manager.flush()
|
||||
path = manager.data_dir / "playlists" / f"{playlist.persistent_id}.json"
|
||||
assert path.exists()
|
||||
manager.delete_playlist(folder.persistent_id) # recursive
|
||||
assert manager.library.playlists == {}
|
||||
manager.flush()
|
||||
assert not path.exists()
|
||||
|
||||
def test_move_tracks_in_playlist(self, manager):
|
||||
from lintunes.models import Track
|
||||
for i in range(1, 6):
|
||||
manager.add_track(Track(track_id=i, name=f"t{i}"))
|
||||
playlist = manager.create_playlist("P")
|
||||
manager.add_tracks_to_playlist(playlist.persistent_id, [1, 2, 3, 4, 5])
|
||||
|
||||
# Move rows 0,1 (tracks 1,2) so the block starts at manual row 4
|
||||
manager.move_tracks_in_playlist(playlist.persistent_id, [0, 1], 4)
|
||||
assert playlist.track_ids == [3, 4, 1, 2, 5]
|
||||
|
||||
# Move last row (track 5) to the top
|
||||
manager.move_tracks_in_playlist(playlist.persistent_id, [4], 0)
|
||||
assert playlist.track_ids == [5, 3, 4, 1, 2]
|
||||
|
||||
def test_record_play(self, manager):
|
||||
from lintunes.models import Track
|
||||
manager.add_track(Track(track_id=7, name="x", play_count=3))
|
||||
manager.record_play(7)
|
||||
track = manager.library.tracks[7]
|
||||
assert track.play_count == 4
|
||||
assert track.play_date_utc is not None
|
||||
184
tests/test_itunes_importer.py
Normal file
184
tests/test_itunes_importer.py
Normal file
@ -0,0 +1,184 @@
|
||||
import plistlib
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lintunes.importers.itunes_importer import (
|
||||
import_itunes_xml, make_location_remapper,
|
||||
)
|
||||
|
||||
|
||||
MUSIC_FOLDER = "file:///Volumes/Lucia/iTunes/iTunes%20Media/"
|
||||
MUSIC_ROOT = Path("/run/media/trav/tummult/music/iTunes Media")
|
||||
|
||||
|
||||
def _make_itunes_xml(tracks: dict, playlists: list) -> Path:
|
||||
plist_data = {
|
||||
"Major Version": 1,
|
||||
"Minor Version": 1,
|
||||
"Application Version": "12.8.3.1",
|
||||
"Music Folder": MUSIC_FOLDER,
|
||||
"Tracks": tracks,
|
||||
"Playlists": playlists,
|
||||
}
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".xml", delete=False)
|
||||
plistlib.dump(plist_data, tmp, fmt=plistlib.FMT_XML)
|
||||
tmp.close()
|
||||
return Path(tmp.name)
|
||||
|
||||
|
||||
def _track(track_id, name, location_suffix, **extra):
|
||||
return {
|
||||
"Track ID": track_id,
|
||||
"Name": name,
|
||||
"Persistent ID": f"PID{track_id}",
|
||||
"Track Type": "File",
|
||||
"Location": MUSIC_FOLDER + location_suffix,
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
class TestLocationRemapper:
|
||||
def test_remaps_music_folder_prefix(self):
|
||||
remap = make_location_remapper(MUSIC_FOLDER, MUSIC_ROOT)
|
||||
assert remap("/Volumes/Lucia/iTunes/iTunes Media/Music/A/B/C.m4a") == \
|
||||
"/run/media/trav/tummult/music/iTunes Media/Music/A/B/C.m4a"
|
||||
|
||||
def test_leaves_outside_paths_alone(self):
|
||||
remap = make_location_remapper(MUSIC_FOLDER, MUSIC_ROOT)
|
||||
assert remap("/Volumes/Other/song.mp3") == "/Volumes/Other/song.mp3"
|
||||
|
||||
def test_localhost_form(self):
|
||||
remap = make_location_remapper(
|
||||
"file://localhost/Users/test/Music/iTunes/iTunes%20Media/",
|
||||
Path("/home/user/Music"))
|
||||
assert remap("/Users/test/Music/iTunes/iTunes Media/Music/X/Y/Z.mp3") == \
|
||||
"/home/user/Music/Music/X/Y/Z.mp3"
|
||||
|
||||
|
||||
class TestItunesImporter:
|
||||
def test_import_basic_track(self):
|
||||
tracks = {
|
||||
"100": _track(
|
||||
100, "Test Song", "Music/Test%20Artist/Test%20Album/Test%20Song.mp3",
|
||||
Artist="Test Artist", Album="Test Album", Genre="Rock",
|
||||
**{"Total Time": 200000,
|
||||
"Date Added": datetime(2020, 1, 1, 0, 0, 0)},
|
||||
)
|
||||
}
|
||||
playlists = [
|
||||
{
|
||||
"Name": "Library",
|
||||
"Master": True,
|
||||
"Playlist Persistent ID": "MASTER",
|
||||
"Playlist Items": [{"Track ID": 100}],
|
||||
}
|
||||
]
|
||||
xml_path = _make_itunes_xml(tracks, playlists)
|
||||
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
|
||||
|
||||
assert len(library.tracks) == 1
|
||||
track = library.tracks[100]
|
||||
assert track.name == "Test Song"
|
||||
assert track.artist == "Test Artist"
|
||||
assert track.location == str(MUSIC_ROOT / "Music/Test Artist/Test Album/Test Song.mp3")
|
||||
# Master playlist is a system playlist — not imported
|
||||
assert len(library.playlists) == 0
|
||||
assert report.skipped_system == 1
|
||||
assert report.track_count == 1
|
||||
|
||||
xml_path.unlink()
|
||||
|
||||
def test_import_playlists_skips_smart_and_system(self):
|
||||
tracks = {
|
||||
"1": _track(1, "Song A", "Music/A/A/A.mp3"),
|
||||
"2": _track(2, "Song B", "Music/B/B/B.mp3"),
|
||||
}
|
||||
playlists = [
|
||||
{"Name": "Library", "Master": True,
|
||||
"Playlist Persistent ID": "MASTER",
|
||||
"Playlist Items": [{"Track ID": 1}, {"Track ID": 2}]},
|
||||
{"Name": "Movies", "Distinguished Kind": 2,
|
||||
"Playlist Persistent ID": "MOVIES"},
|
||||
{"Name": "Smarty", "Smart Info": b"x", "Smart Criteria": b"y",
|
||||
"Playlist Persistent ID": "SMART1",
|
||||
"Playlist Items": [{"Track ID": 1}]},
|
||||
{"Name": "My Folder", "Folder": True,
|
||||
"Playlist Persistent ID": "FOLDER1"},
|
||||
{"Name": "My Playlist", "Playlist Persistent ID": "PL1",
|
||||
"Parent Persistent ID": "FOLDER1",
|
||||
"Playlist Items": [{"Track ID": 2}, {"Track ID": 999}]},
|
||||
]
|
||||
xml_path = _make_itunes_xml(tracks, playlists)
|
||||
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
|
||||
|
||||
assert set(library.playlists.keys()) == {"FOLDER1", "PL1"}
|
||||
# Track 999 doesn't exist — dropped from the playlist
|
||||
assert library.playlists["PL1"].track_ids == [2]
|
||||
assert library.playlists["PL1"].parent_persistent_id == "FOLDER1"
|
||||
assert report.skipped_smart == 1
|
||||
assert report.skipped_system == 2
|
||||
assert report.playlist_count == 1
|
||||
assert report.folder_count == 1
|
||||
|
||||
xml_path.unlink()
|
||||
|
||||
def test_orphaned_child_detached_when_parent_skipped(self):
|
||||
playlists = [
|
||||
{"Name": "Inside Smart Folder", "Playlist Persistent ID": "PL1",
|
||||
"Parent Persistent ID": "GONE"},
|
||||
]
|
||||
xml_path = _make_itunes_xml({}, playlists)
|
||||
library, _report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
|
||||
assert library.playlists["PL1"].parent_persistent_id == ""
|
||||
xml_path.unlink()
|
||||
|
||||
def test_skips_stream_tracks(self):
|
||||
tracks = {
|
||||
"1": {"Track ID": 1, "Name": "Radio", "Track Type": "URL",
|
||||
"Persistent ID": "R1"},
|
||||
"2": _track(2, "Real Song", "Music/A/A/A.mp3"),
|
||||
}
|
||||
xml_path = _make_itunes_xml(tracks, [])
|
||||
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
|
||||
assert set(library.tracks.keys()) == {2}
|
||||
assert report.skipped_tracks == 1
|
||||
xml_path.unlink()
|
||||
|
||||
def test_unmapped_location_reported(self):
|
||||
tracks = {
|
||||
"1": {"Track ID": 1, "Name": "Elsewhere", "Persistent ID": "E1",
|
||||
"Track Type": "File",
|
||||
"Location": "file:///Volumes/Other/song.mp3"},
|
||||
}
|
||||
xml_path = _make_itunes_xml(tracks, [])
|
||||
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
|
||||
assert report.unmapped_locations == ["/Volumes/Other/song.mp3"]
|
||||
assert 1 in library.tracks # still imported, just flagged
|
||||
xml_path.unlink()
|
||||
|
||||
def test_computed_ratings_dropped(self):
|
||||
tracks = {
|
||||
"1": _track(1, "Guessed", "Music/A/A/A.mp3",
|
||||
**{"Rating": 100, "Rating Computed": True,
|
||||
"Album Rating": 80, "Album Rating Computed": True}),
|
||||
"2": _track(2, "Explicit", "Music/B/B/B.mp3",
|
||||
**{"Rating": 80, "Album Rating": 60}),
|
||||
}
|
||||
xml_path = _make_itunes_xml(tracks, [])
|
||||
library, _report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
|
||||
assert library.tracks[1].rating == 0
|
||||
assert library.tracks[1].album_rating == 0
|
||||
assert library.tracks[2].rating == 80
|
||||
assert library.tracks[2].album_rating == 60
|
||||
xml_path.unlink()
|
||||
|
||||
def test_import_empty_library(self):
|
||||
xml_path = _make_itunes_xml({}, [])
|
||||
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
|
||||
assert len(library.tracks) == 0
|
||||
assert len(library.playlists) == 0
|
||||
assert report.track_count == 0
|
||||
xml_path.unlink()
|
||||
175
tests/test_models.py
Normal file
175
tests/test_models.py
Normal file
@ -0,0 +1,175 @@
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
|
||||
from lintunes.models import Track, Playlist, PlaylistSettings, PlaylistType, Library
|
||||
|
||||
|
||||
class TestTrack:
|
||||
def test_to_dict_omits_defaults(self):
|
||||
track = Track(track_id=1, name="Test Song")
|
||||
d = track.to_dict()
|
||||
assert d == {"track_id": 1, "name": "Test Song"}
|
||||
assert "genre" not in d
|
||||
assert "play_count" not in d
|
||||
|
||||
def test_from_dict_round_trip(self):
|
||||
track = Track(
|
||||
track_id=42,
|
||||
name="My Song",
|
||||
artist="The Artist",
|
||||
album="The Album",
|
||||
genre="Rock",
|
||||
total_time=180000,
|
||||
play_count=5,
|
||||
rating=80,
|
||||
loved=True,
|
||||
date_added="2020-01-15T10:30:00",
|
||||
)
|
||||
d = track.to_dict()
|
||||
restored = Track.from_dict(d)
|
||||
assert restored.track_id == 42
|
||||
assert restored.name == "My Song"
|
||||
assert restored.artist == "The Artist"
|
||||
assert restored.play_count == 5
|
||||
assert restored.rating == 80
|
||||
assert restored.loved is True
|
||||
|
||||
def test_from_dict_ignores_unknown_fields(self):
|
||||
d = {"track_id": 1, "name": "Test", "unknown_field": "ignored"}
|
||||
track = Track.from_dict(d)
|
||||
assert track.track_id == 1
|
||||
assert track.name == "Test"
|
||||
|
||||
def test_from_itunes_dict(self):
|
||||
itunes_data = {
|
||||
"Track ID": 100,
|
||||
"Name": "iTunes Song",
|
||||
"Artist": "iTunes Artist",
|
||||
"Album": "iTunes Album",
|
||||
"Genre": "Pop",
|
||||
"Total Time": 240000,
|
||||
"Play Count": 10,
|
||||
"Play Date UTC": datetime(2023, 6, 15, 12, 0, 0),
|
||||
"Date Added": datetime(2020, 3, 1, 8, 0, 0),
|
||||
"Rating": 100,
|
||||
"Loved": True,
|
||||
"Persistent ID": "ABC123",
|
||||
"Location": "file://localhost/Users/me/Music/Artist/Album/Song.mp3",
|
||||
}
|
||||
track = Track.from_itunes_dict(itunes_data)
|
||||
assert track.track_id == 100
|
||||
assert track.name == "iTunes Song"
|
||||
assert track.total_time == 240000
|
||||
assert track.play_count == 10
|
||||
assert track.rating == 100
|
||||
assert track.loved is True
|
||||
assert track.play_date_utc == "2023-06-15T12:00:00"
|
||||
assert track.location == "/Users/me/Music/Artist/Album/Song.mp3"
|
||||
|
||||
def test_location_decoding(self):
|
||||
itunes_data = {
|
||||
"Track ID": 1,
|
||||
"Location": "file://localhost/Users/me/Music/The%20Artist/My%20Album/01%20Song.mp3",
|
||||
}
|
||||
track = Track.from_itunes_dict(itunes_data)
|
||||
assert track.location == "/Users/me/Music/The Artist/My Album/01 Song.mp3"
|
||||
|
||||
def test_location_decoding_volumes_form(self):
|
||||
# Modern iTunes XML uses file:///Volumes/... URLs
|
||||
itunes_data = {
|
||||
"Track ID": 1,
|
||||
"Location": "file:///Volumes/Lucia/iTunes/iTunes%20Media/Music/A/B/C.m4a",
|
||||
}
|
||||
track = Track.from_itunes_dict(itunes_data)
|
||||
assert track.location == "/Volumes/Lucia/iTunes/iTunes Media/Music/A/B/C.m4a"
|
||||
|
||||
|
||||
class TestPlaylist:
|
||||
def test_to_dict_from_dict_round_trip(self):
|
||||
playlist = Playlist(
|
||||
name="My Favorites",
|
||||
persistent_id="ABCD1234",
|
||||
playlist_type=PlaylistType.REGULAR,
|
||||
track_ids=[1, 2, 3, 4, 5],
|
||||
)
|
||||
d = playlist.to_dict()
|
||||
restored = Playlist.from_dict(d)
|
||||
assert restored.name == "My Favorites"
|
||||
assert restored.persistent_id == "ABCD1234"
|
||||
assert restored.playlist_type == PlaylistType.REGULAR
|
||||
assert restored.track_ids == [1, 2, 3, 4, 5]
|
||||
|
||||
def test_from_itunes_dict_regular(self):
|
||||
itunes_data = {
|
||||
"Name": "Party Mix",
|
||||
"Playlist Persistent ID": "XYZ789",
|
||||
"Playlist Items": [
|
||||
{"Track ID": 10},
|
||||
{"Track ID": 20},
|
||||
{"Track ID": 30},
|
||||
],
|
||||
}
|
||||
playlist = Playlist.from_itunes_dict(itunes_data)
|
||||
assert playlist.name == "Party Mix"
|
||||
assert playlist.persistent_id == "XYZ789"
|
||||
assert playlist.playlist_type == PlaylistType.REGULAR
|
||||
assert playlist.track_ids == [10, 20, 30]
|
||||
assert playlist.is_system is False
|
||||
|
||||
def test_from_itunes_dict_system(self):
|
||||
itunes_data = {
|
||||
"Name": "Library",
|
||||
"Master": True,
|
||||
"Playlist Persistent ID": "MASTER1",
|
||||
"Playlist Items": [{"Track ID": 1}],
|
||||
}
|
||||
playlist = Playlist.from_itunes_dict(itunes_data)
|
||||
assert playlist.is_system is True
|
||||
assert playlist.playlist_type == PlaylistType.SYSTEM
|
||||
|
||||
def test_from_itunes_dict_folder(self):
|
||||
itunes_data = {
|
||||
"Name": "My Folder",
|
||||
"Folder": True,
|
||||
"Playlist Persistent ID": "FOLDER1",
|
||||
}
|
||||
playlist = Playlist.from_itunes_dict(itunes_data)
|
||||
assert playlist.playlist_type == PlaylistType.FOLDER
|
||||
|
||||
def test_from_itunes_dict_smart(self):
|
||||
itunes_data = {
|
||||
"Name": "Recently Added",
|
||||
"Smart Info": b"...",
|
||||
"Smart Criteria": b"...",
|
||||
"Playlist Persistent ID": "SMART1",
|
||||
}
|
||||
playlist = Playlist.from_itunes_dict(itunes_data)
|
||||
assert playlist.playlist_type == PlaylistType.SMART
|
||||
|
||||
|
||||
class TestPlaylistSettings:
|
||||
def test_default_settings(self):
|
||||
settings = PlaylistSettings()
|
||||
assert "name" in settings.visible_columns
|
||||
assert settings.sort_ascending is True
|
||||
|
||||
def test_round_trip(self):
|
||||
settings = PlaylistSettings(
|
||||
visible_columns=["name", "artist"],
|
||||
sort_column="artist",
|
||||
sort_ascending=False,
|
||||
column_widths={"name": 300},
|
||||
)
|
||||
d = settings.to_dict()
|
||||
restored = PlaylistSettings.from_dict(d)
|
||||
assert restored.visible_columns == ["name", "artist"]
|
||||
assert restored.sort_column == "artist"
|
||||
assert restored.sort_ascending is False
|
||||
assert restored.column_widths == {"name": 300}
|
||||
|
||||
|
||||
class TestLibrary:
|
||||
def test_empty_library(self):
|
||||
lib = Library()
|
||||
assert len(lib.tracks) == 0
|
||||
assert len(lib.playlists) == 0
|
||||
107
tests/test_player.py
Normal file
107
tests/test_player.py
Normal file
@ -0,0 +1,107 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from lintunes.models import Track
|
||||
from lintunes.models.library import Library
|
||||
from lintunes import player as player_module
|
||||
from lintunes.player import Player
|
||||
|
||||
|
||||
class FakeManager:
|
||||
"""Minimal stand-in for LibraryManager: the Player reads `library.tracks`,
|
||||
calls `record_play` on natural end-of-media, and `set_track_location` when
|
||||
the user relocates a moved/renamed file."""
|
||||
|
||||
def __init__(self, library):
|
||||
self.library = library
|
||||
self.played = []
|
||||
|
||||
def record_play(self, track_id):
|
||||
self.played.append(track_id)
|
||||
|
||||
def set_track_location(self, track_id, location):
|
||||
self.library.tracks[track_id].location = location
|
||||
|
||||
|
||||
def _make_player(qapp, tracks):
|
||||
library = Library()
|
||||
for track in tracks:
|
||||
library.tracks[track.track_id] = track
|
||||
# The Qt multimedia backend (QMediaPlayer/QAudioOutput/QMediaDevices)
|
||||
# blocks on init in a headless environment, so stub it out — the
|
||||
# missing-file path under test never reaches real playback anyway.
|
||||
with patch.multiple(
|
||||
player_module,
|
||||
QMediaPlayer=MagicMock(),
|
||||
QAudioOutput=MagicMock(),
|
||||
QAudioBufferOutput=MagicMock(),
|
||||
QMediaDevices=MagicMock(),
|
||||
):
|
||||
player = Player(FakeManager(library))
|
||||
errors, missing = [], []
|
||||
player.error_occurred.connect(errors.append)
|
||||
player.track_missing.connect(missing.append)
|
||||
return player, errors, missing
|
||||
|
||||
|
||||
def test_missing_file_emits_track_missing_and_stops(qapp, tmp_path):
|
||||
location = str(tmp_path / "not-mounted" / "song.mp3")
|
||||
track = Track(track_id=1, name="Ghost Song", location=location)
|
||||
player, errors, missing = _make_player(qapp, [track])
|
||||
|
||||
player.play_queue([1], 0)
|
||||
|
||||
# Routed to track_missing (so the UI can offer "Locate File…"), not the
|
||||
# generic error dialog.
|
||||
assert errors == []
|
||||
assert missing == [track]
|
||||
# Stopped, not advanced into a track; setSource was never called.
|
||||
assert player.current_track is None
|
||||
player._media.setSource.assert_not_called()
|
||||
|
||||
|
||||
def test_all_missing_queue_does_not_recurse(qapp, tmp_path):
|
||||
# A whole-library queue with every file gone (drive unmounted) must not
|
||||
# cascade through the queue and blow the recursion limit.
|
||||
tracks = [
|
||||
Track(track_id=i, name=f"T{i}", location=str(tmp_path / f"{i}.mp3"))
|
||||
for i in range(5000)
|
||||
]
|
||||
player, errors, missing = _make_player(qapp, tracks)
|
||||
|
||||
player.play_queue([t.track_id for t in tracks], 0)
|
||||
|
||||
# One alert for the track we tried to play, then a clean stop.
|
||||
assert len(missing) == 1
|
||||
assert player.current_track is None
|
||||
|
||||
|
||||
def test_track_not_in_library_emits_error(qapp):
|
||||
# Queue references an id that isn't in the library: nothing to locate, so
|
||||
# this stays a plain error (not track_missing).
|
||||
player, errors, missing = _make_player(qapp, [])
|
||||
|
||||
player.play_queue([99], 0)
|
||||
|
||||
assert missing == []
|
||||
assert len(errors) == 1
|
||||
assert player.current_track is None
|
||||
|
||||
|
||||
def test_retry_current_plays_after_relocate(qapp, tmp_path):
|
||||
real = tmp_path / "real.mp3"
|
||||
real.write_bytes(b"audio")
|
||||
track = Track(track_id=1, name="Moved Song",
|
||||
location=str(tmp_path / "gone.mp3"))
|
||||
player, errors, missing = _make_player(qapp, [track])
|
||||
|
||||
player.play_queue([1], 0)
|
||||
assert missing == [track] and player.current_track is None
|
||||
|
||||
# User relocates the file, then the UI retries the same queue position.
|
||||
player._manager.set_track_location(1, str(real))
|
||||
missing.clear()
|
||||
player.retry_current()
|
||||
|
||||
assert errors == [] and missing == []
|
||||
assert player.current_track is track
|
||||
player._media.setSource.assert_called_once()
|
||||
47
tests/test_relocate.py
Normal file
47
tests/test_relocate.py
Normal file
@ -0,0 +1,47 @@
|
||||
"""LibraryManager.set_track_location: repoint a track at a moved/renamed file.
|
||||
|
||||
It's a path repair, not a content edit — so it persists the new location and
|
||||
notifies the UI, but must not touch date_modified or write tags.
|
||||
"""
|
||||
|
||||
from lintunes.models import Library, Track
|
||||
from lintunes.library_manager import LibraryManager
|
||||
|
||||
|
||||
def _manager(tmp_path):
|
||||
track = Track(track_id=1, name="Song",
|
||||
location="/old/path/song.mp3",
|
||||
date_modified="2020-01-01T00:00:00")
|
||||
return LibraryManager(Library(tracks={1: track}), tmp_path), track
|
||||
|
||||
|
||||
def test_set_track_location_repoints_and_persists(qapp, tmp_path):
|
||||
manager, track = _manager(tmp_path)
|
||||
updates, edits = [], []
|
||||
manager.track_updated.connect(updates.append)
|
||||
manager.track_fields_edited.connect(lambda tid, fields: edits.append(tid))
|
||||
|
||||
manager.set_track_location(1, "/new/path/from there to here.mp3")
|
||||
|
||||
assert track.location == "/new/path/from there to here.mp3"
|
||||
assert manager._dirty_tracks # will be saved
|
||||
assert updates == [1] # UI refreshes the row
|
||||
assert edits == [] # not a metadata edit
|
||||
assert track.date_modified == "2020-01-01T00:00:00" # untouched
|
||||
|
||||
|
||||
def test_set_track_location_noop_when_unchanged(qapp, tmp_path):
|
||||
manager, track = _manager(tmp_path)
|
||||
updates = []
|
||||
manager.track_updated.connect(updates.append)
|
||||
|
||||
manager.set_track_location(1, track.location)
|
||||
|
||||
assert updates == []
|
||||
assert not manager._dirty_tracks
|
||||
|
||||
|
||||
def test_set_track_location_ignores_unknown_track(qapp, tmp_path):
|
||||
manager, _track = _manager(tmp_path)
|
||||
manager.set_track_location(999, "/whatever.mp3") # no such track
|
||||
assert not manager._dirty_tracks
|
||||
105
tests/test_round3.py
Normal file
105
tests/test_round3.py
Normal file
@ -0,0 +1,105 @@
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from lintunes.lastfm import sign, LastFm
|
||||
from lintunes.player import make_shuffle_order
|
||||
from lintunes.preferences import Preferences
|
||||
|
||||
|
||||
class TestLastFmSignature:
|
||||
def test_signature_matches_hand_computed(self):
|
||||
params = {
|
||||
"method": "auth.getMobileSession",
|
||||
"api_key": "KEY",
|
||||
"username": "trav",
|
||||
"password": "hunter2",
|
||||
"format": "json", # must be excluded from the signature
|
||||
}
|
||||
expected = hashlib.md5(
|
||||
("api_keyKEY"
|
||||
"methodauth.getMobileSession"
|
||||
"passwordhunter2"
|
||||
"usernametrav"
|
||||
"SECRET").encode()).hexdigest()
|
||||
assert sign(params, "SECRET") == expected
|
||||
|
||||
def test_format_and_callback_excluded(self):
|
||||
base = {"method": "x", "api_key": "k"}
|
||||
with_extras = dict(base, format="json", callback="cb")
|
||||
assert sign(base, "s") == sign(with_extras, "s")
|
||||
|
||||
|
||||
class TestScrobbleQueue:
|
||||
def test_queue_round_trip(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
lastfm = LastFm(prefs, tmp_path)
|
||||
entry = {"artist": "A", "track": "T", "album": "L",
|
||||
"timestamp": 123, "duration": 180}
|
||||
lastfm._enqueue(entry)
|
||||
assert lastfm._load_queue() == [entry]
|
||||
lastfm._save_queue([])
|
||||
assert lastfm._load_queue() == []
|
||||
assert not (tmp_path / "scrobble_queue.json").exists()
|
||||
|
||||
def test_queue_capped(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
lastfm = LastFm(prefs, tmp_path)
|
||||
for i in range(600):
|
||||
lastfm._enqueue({"artist": "A", "track": str(i), "timestamp": i})
|
||||
queue = lastfm._load_queue()
|
||||
assert len(queue) == 500
|
||||
assert queue[-1]["track"] == "599"
|
||||
|
||||
|
||||
class TestShuffleOrder:
|
||||
def test_permutation_starts_at_current(self):
|
||||
order = make_shuffle_order(10, 4)
|
||||
assert order[0] == 4
|
||||
assert sorted(order) == list(range(10))
|
||||
|
||||
def test_single_track(self):
|
||||
assert make_shuffle_order(1, 0) == [0]
|
||||
|
||||
def test_empty(self):
|
||||
assert make_shuffle_order(0, 0) == []
|
||||
|
||||
def test_is_randomized(self):
|
||||
# With 20 tracks, two consecutive shuffles colliding entirely is
|
||||
# vanishingly unlikely (1/19!)
|
||||
orders = {tuple(make_shuffle_order(20, 0)) for _ in range(5)}
|
||||
assert len(orders) > 1
|
||||
|
||||
|
||||
class TestPreferences:
|
||||
def test_defaults(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
assert prefs.get("ui_scale") == "medium"
|
||||
assert prefs.get("highlight") == "blue"
|
||||
assert prefs.lastfm["scrobble_enabled"] is False
|
||||
|
||||
def test_round_trip(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
prefs.set("ui_scale", "large")
|
||||
prefs.update_lastfm(username="trav", session_key="abc")
|
||||
|
||||
reloaded = Preferences(tmp_path)
|
||||
assert reloaded.get("ui_scale") == "large"
|
||||
assert reloaded.lastfm["username"] == "trav"
|
||||
assert reloaded.lastfm["session_key"] == "abc"
|
||||
# Unknown/missing keys fall back to defaults
|
||||
assert reloaded.get("highlight") == "blue"
|
||||
|
||||
def test_changed_signal(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
fired = []
|
||||
prefs.changed.connect(lambda: fired.append(1))
|
||||
prefs.set("highlight", "seafoam")
|
||||
prefs.set("highlight", "seafoam") # no-op, shouldn't fire again
|
||||
assert len(fired) == 1
|
||||
|
||||
def test_corrupt_file_falls_back(self, qapp, tmp_path):
|
||||
(tmp_path / "preferences.json").write_text("{not json")
|
||||
prefs = Preferences(tmp_path)
|
||||
assert prefs.get("ui_scale") == "medium"
|
||||
46
tests/test_round5.py
Normal file
46
tests/test_round5.py
Normal file
@ -0,0 +1,46 @@
|
||||
from lintunes.gui.track_table import format_total_time
|
||||
from lintunes.gui.library_view import _canonical_values, _artist_sort_key
|
||||
|
||||
|
||||
class TestFormatTotalTime:
|
||||
def test_minutes_seconds(self):
|
||||
assert format_total_time((14 * 60 + 21) * 1000) == "14:21"
|
||||
|
||||
def test_hours(self):
|
||||
ms = (9 * 3600 + 47 * 60 + 33) * 1000
|
||||
assert format_total_time(ms) == "9:47:33"
|
||||
|
||||
def test_days_zero_pads_lower_units(self):
|
||||
ms = (24 * 86400 + 18 * 3600 + 42 * 60 + 7) * 1000
|
||||
assert format_total_time(ms) == "24:18:42:07"
|
||||
|
||||
def test_zero_keeps_min_sec(self):
|
||||
assert format_total_time(0) == "0:00"
|
||||
|
||||
|
||||
class TestCanonicalValues:
|
||||
def test_most_tracks_spelling_wins(self):
|
||||
values = ["The Beatles", "The Beatles", "the beatles"]
|
||||
assert _canonical_values(values) == ["The Beatles"]
|
||||
|
||||
def test_tie_breaks_on_more_capitals(self):
|
||||
# equal counts -> the spelling with more uppercase letters wins
|
||||
assert _canonical_values(["abc", "ABC"]) == ["ABC"]
|
||||
|
||||
def test_distinct_names_preserved(self):
|
||||
result = sorted(_canonical_values(["Beat Happening", "The Beatles"]))
|
||||
assert result == ["Beat Happening", "The Beatles"]
|
||||
|
||||
|
||||
class TestArtistSortKey:
|
||||
def test_strips_leading_the(self):
|
||||
assert _artist_sort_key("The Beatles") == "beatles"
|
||||
|
||||
def test_files_next_to_neighbor(self):
|
||||
# "The Beatles" should sort adjacent to "Beat Happening"
|
||||
names = ["The Beatles", "Beat Happening", "Beck"]
|
||||
assert sorted(names, key=_artist_sort_key) == [
|
||||
"Beat Happening", "The Beatles", "Beck"]
|
||||
|
||||
def test_no_article_unchanged(self):
|
||||
assert _artist_sort_key("Beck") == "beck"
|
||||
59
tests/test_round6.py
Normal file
59
tests/test_round6.py
Normal file
@ -0,0 +1,59 @@
|
||||
from lintunes.gui.playlist_ops import (
|
||||
has_duplicates, filter_for_add, add_tracks_with_dup_check)
|
||||
from lintunes.library_manager import LibraryManager
|
||||
from lintunes.models import Library, Track, Playlist
|
||||
|
||||
|
||||
class TestHasDuplicates:
|
||||
def test_none_present(self):
|
||||
assert has_duplicates([3, 4], [1, 2]) is False
|
||||
|
||||
def test_some_present(self):
|
||||
assert has_duplicates([2, 3], [1, 2]) is True
|
||||
|
||||
def test_empty_incoming(self):
|
||||
assert has_duplicates([], [1, 2]) is False
|
||||
|
||||
|
||||
class TestFilterForAdd:
|
||||
def test_add_duplicates_keeps_all_in_order(self):
|
||||
assert filter_for_add([2, 3, 1], [1, 2], add_duplicates=True) == [2, 3, 1]
|
||||
|
||||
def test_skip_drops_existing_preserves_order(self):
|
||||
assert filter_for_add([2, 3, 1], [1, 2], add_duplicates=False) == [3]
|
||||
|
||||
def test_skip_can_drop_everything(self):
|
||||
assert filter_for_add([1, 2], [1, 2], add_duplicates=False) == []
|
||||
|
||||
|
||||
def _manager_with(qapp, tmp_path, track_ids, playlist_track_ids=()):
|
||||
library = Library()
|
||||
for tid in track_ids:
|
||||
library.tracks[tid] = Track(track_id=tid, name=f"t{tid}")
|
||||
playlist = Playlist(name="PL", persistent_id="PID",
|
||||
track_ids=list(playlist_track_ids))
|
||||
library.playlists["PID"] = playlist
|
||||
manager = LibraryManager(library, tmp_path)
|
||||
return manager, playlist
|
||||
|
||||
|
||||
class TestAddTracksWithDupCheck:
|
||||
def test_no_duplicates_adds_all_without_prompt(self, qapp, tmp_path):
|
||||
manager, playlist = _manager_with(qapp, tmp_path, [1, 2, 3], [1])
|
||||
added = add_tracks_with_dup_check(manager, None, "PID", [2, 3])
|
||||
assert added == [2, 3]
|
||||
assert playlist.track_ids == [1, 2, 3]
|
||||
|
||||
def test_position_inserts_in_place(self, qapp, tmp_path):
|
||||
manager, playlist = _manager_with(qapp, tmp_path, [1, 2, 3], [1, 3])
|
||||
add_tracks_with_dup_check(manager, None, "PID", [2], position=1)
|
||||
assert playlist.track_ids == [1, 2, 3]
|
||||
|
||||
def test_empty_input_is_noop(self, qapp, tmp_path):
|
||||
manager, playlist = _manager_with(qapp, tmp_path, [1], [1])
|
||||
assert add_tracks_with_dup_check(manager, None, "PID", []) == []
|
||||
assert playlist.track_ids == [1]
|
||||
|
||||
def test_unknown_playlist_is_noop(self, qapp, tmp_path):
|
||||
manager, _ = _manager_with(qapp, tmp_path, [1], [])
|
||||
assert add_tracks_with_dup_check(manager, None, "NOPE", [1]) == []
|
||||
14
tests/test_round7.py
Normal file
14
tests/test_round7.py
Normal file
@ -0,0 +1,14 @@
|
||||
from lintunes.gui import drag_ghost
|
||||
|
||||
|
||||
class TestDragIcon:
|
||||
"""The cursor-following drag indicator is loaded from a customizable PNG
|
||||
that ships next to the application icon (packaging/lintunes.png)."""
|
||||
|
||||
def test_default_png_ships_next_to_app_icon(self):
|
||||
assert drag_ghost.ICON_PATH.name == "drag-icon.png"
|
||||
assert drag_ghost.ICON_PATH.exists()
|
||||
assert (drag_ghost.ICON_PATH.parent / "lintunes.png").exists()
|
||||
|
||||
def test_default_file_is_a_png(self):
|
||||
assert drag_ghost.ICON_PATH.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
254
tests/test_round8.py
Normal file
254
tests/test_round8.py
Normal 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)
|
||||
164
tests/test_round9.py
Normal file
164
tests/test_round9.py
Normal file
@ -0,0 +1,164 @@
|
||||
"""Round 9: three 'Open / future' items.
|
||||
|
||||
1. New playlist is selected and drops straight into inline rename
|
||||
(PlaylistTree.create_playlist_interactive).
|
||||
2. EQ visualizer cycles on / dim / off on click (off goes blank).
|
||||
3. Album-art recovery plumbing: the .itc cache decoder (lintunes.itc).
|
||||
"""
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
from PyQt6.QtWidgets import QAbstractItemView
|
||||
|
||||
from lintunes.models import Library, Track
|
||||
from lintunes.library_manager import LibraryManager
|
||||
from lintunes.gui.sidebar import PlaylistTree
|
||||
from lintunes.gui.visualizer import VisualizerWidget, BANDS
|
||||
from lintunes.itc import decode_itc
|
||||
|
||||
|
||||
def _manager(tmp_path):
|
||||
library = Library(tracks={1: Track(track_id=1, name="One")})
|
||||
return LibraryManager(library, tmp_path)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. New playlist: instant select + live inline rename
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TestCreatePlaylistInteractive:
|
||||
def test_new_playlist_is_selected_and_editing(self, qapp, tmp_path):
|
||||
manager = _manager(tmp_path)
|
||||
tree = PlaylistTree(manager)
|
||||
|
||||
tree.create_playlist_interactive()
|
||||
|
||||
playlists = list(manager.library.playlists.values())
|
||||
assert len(playlists) == 1
|
||||
new = playlists[0]
|
||||
assert new.name == "untitled playlist"
|
||||
# Selected...
|
||||
assert tree.current_playlist_id() == new.persistent_id
|
||||
# ...and the inline editor is open so typing renames it immediately.
|
||||
assert tree.state() == QAbstractItemView.State.EditingState
|
||||
|
||||
def test_committing_the_edit_renames_via_manager(self, qapp, tmp_path):
|
||||
manager = _manager(tmp_path)
|
||||
tree = PlaylistTree(manager)
|
||||
tree.create_playlist_interactive()
|
||||
pid = tree.current_playlist_id()
|
||||
|
||||
# Simulate the user typing a name and the editor committing: the
|
||||
# itemChanged -> _on_item_renamed -> rename_playlist wiring fires.
|
||||
item = tree._find_item(pid)
|
||||
item.setText(0, "Road Trip")
|
||||
|
||||
assert manager.library.playlists[pid].name == "Road Trip"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. EQ visualizer blank-when-off
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class _FakePlayer(QObject):
|
||||
audio_buffer = pyqtSignal(object)
|
||||
playing_changed = pyqtSignal(bool)
|
||||
track_changed = pyqtSignal(object)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._playing = False
|
||||
|
||||
def is_playing(self):
|
||||
return self._playing
|
||||
|
||||
|
||||
class TestVisualizerToggle:
|
||||
def test_click_cycles_on_dim_off(self, qapp):
|
||||
from lintunes.gui.visualizer import MODE_ON, MODE_DIM, MODE_OFF
|
||||
player = _FakePlayer()
|
||||
player._playing = True
|
||||
vis = VisualizerWidget(player)
|
||||
|
||||
assert vis._mode == MODE_ON
|
||||
vis.mousePressEvent(None) # on -> dim
|
||||
assert vis._mode == MODE_DIM
|
||||
vis.mousePressEvent(None) # dim -> off
|
||||
assert vis._mode == MODE_OFF
|
||||
vis.mousePressEvent(None) # off -> on (wraps)
|
||||
assert vis._mode == MODE_ON
|
||||
|
||||
def test_off_goes_blank_and_stops(self, qapp):
|
||||
from lintunes.gui.visualizer import MODE_OFF
|
||||
player = _FakePlayer()
|
||||
player._playing = True
|
||||
vis = VisualizerWidget(player)
|
||||
vis._bars = [7] * BANDS # pretend mid-animation
|
||||
|
||||
vis.mousePressEvent(None) # on -> dim
|
||||
vis.mousePressEvent(None) # dim -> off
|
||||
|
||||
assert vis._mode == MODE_OFF
|
||||
assert vis._bars == [0] * BANDS # blank, not the held frame
|
||||
assert not vis._levels.any()
|
||||
assert not vis._timer.isActive() # stopped
|
||||
|
||||
def test_dim_keeps_animating_when_playing(self, qapp):
|
||||
from lintunes.gui.visualizer import MODE_DIM
|
||||
player = _FakePlayer()
|
||||
player._playing = True
|
||||
vis = VisualizerWidget(player)
|
||||
|
||||
vis.mousePressEvent(None) # on -> dim
|
||||
|
||||
assert vis._mode == MODE_DIM
|
||||
assert vis._timer.isActive() # dim animates while playing
|
||||
|
||||
def test_cycling_back_on_resumes_when_playing(self, qapp):
|
||||
from lintunes.gui.visualizer import MODE_ON
|
||||
player = _FakePlayer()
|
||||
player._playing = True
|
||||
vis = VisualizerWidget(player)
|
||||
|
||||
vis.mousePressEvent(None) # on -> dim
|
||||
vis.mousePressEvent(None) # dim -> off
|
||||
vis.mousePressEvent(None) # off -> on again
|
||||
|
||||
assert vis._mode == MODE_ON
|
||||
assert vis._timer.isActive()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. .itc artwork-cache decoder
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _itc_header():
|
||||
# A minimal iTunes .itc-style header preamble; decode_itc only cares that
|
||||
# the real image magic appears after it.
|
||||
return bytes([0, 0, 1, 0x1c]) + b"itch" + b"\x00" * 8 + b"artw" + \
|
||||
b"\x00" * 8 + b"item" + b"\x00" * 8
|
||||
|
||||
|
||||
class TestItcDecoder:
|
||||
def test_extracts_jpeg(self):
|
||||
image = b"\xff\xd8\xff\xe0" + b"JFIFpayload" + b"\xff\xd9"
|
||||
blob = _itc_header() + image + b"\x00\x00trailing"
|
||||
out = decode_itc(blob)
|
||||
assert out is not None
|
||||
data, mime = out
|
||||
assert mime == "image/jpeg"
|
||||
assert data == image # trimmed to EOI, trailing dropped
|
||||
|
||||
def test_extracts_png(self):
|
||||
sig = b"\x89PNG\r\n\x1a\n"
|
||||
image = sig + b"IHDRfakechunks" + b"IEND\xae\x42\x60\x82"
|
||||
blob = _itc_header() + image + b"junk"
|
||||
out = decode_itc(blob)
|
||||
assert out is not None
|
||||
data, mime = out
|
||||
assert mime == "image/png"
|
||||
assert data == image
|
||||
|
||||
def test_raw_bitmap_is_unsupported(self):
|
||||
# ARGb/PNGf raw payloads have no JPEG/PNG magic -> can't recover.
|
||||
blob = _itc_header() + b"ARGb" + bytes(range(64))
|
||||
assert decode_itc(blob) is None
|
||||
60
tests/test_tagging.py
Normal file
60
tests/test_tagging.py
Normal file
@ -0,0 +1,60 @@
|
||||
import pytest
|
||||
|
||||
from lintunes import tagging
|
||||
|
||||
|
||||
FIELDS = {
|
||||
"name": "Test Title",
|
||||
"artist": "Test Artist",
|
||||
"album_artist": "Test Album Artist",
|
||||
"album": "Test Album",
|
||||
"genre": "Electronic",
|
||||
"composer": "Test Composer",
|
||||
"comments": "A comment",
|
||||
"grouping": "Test Group",
|
||||
"year": 2021,
|
||||
"track_number": 3,
|
||||
"track_count": 12,
|
||||
"disc_number": 1,
|
||||
"disc_count": 2,
|
||||
"compilation": True,
|
||||
"bpm": 120,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", ["mp3_file", "m4a_file", "flac_file"])
|
||||
def test_write_read_round_trip(fixture, request):
|
||||
path = request.getfixturevalue(fixture)
|
||||
tagging.write_tags(path, FIELDS)
|
||||
read = tagging.read_tags(path)
|
||||
|
||||
for key in ("name", "artist", "album_artist", "album", "genre",
|
||||
"composer", "comments"):
|
||||
assert read.get(key) == FIELDS[key], key
|
||||
assert read.get("year") == 2021
|
||||
assert read.get("track_number") == 3
|
||||
assert read.get("track_count") == 12
|
||||
assert read.get("disc_number") == 1
|
||||
assert read.get("total_time", 0) > 500 # ~1s of audio
|
||||
assert read.get("size", 0) > 0
|
||||
|
||||
|
||||
def test_read_tags_reports_audio_properties(mp3_file):
|
||||
read = tagging.read_tags(mp3_file)
|
||||
assert read.get("kind") == "MPEG audio file"
|
||||
assert read.get("sample_rate", 0) > 0
|
||||
|
||||
|
||||
def test_no_artwork_in_generated_file(mp3_file):
|
||||
assert tagging.read_embedded_artwork(mp3_file) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", ["mp3_file", "m4a_file", "flac_file"])
|
||||
def test_artwork_write_read_round_trip(fixture, request, jpeg_bytes):
|
||||
path = request.getfixturevalue(fixture)
|
||||
assert tagging.read_embedded_artwork(path) is None
|
||||
tagging.write_artwork(path, jpeg_bytes, "image/jpeg")
|
||||
assert tagging.read_embedded_artwork(path) == jpeg_bytes
|
||||
# Replacing art works too (no duplicate covers accumulate)
|
||||
tagging.write_artwork(path, jpeg_bytes + b"\x00", "image/jpeg")
|
||||
assert tagging.read_embedded_artwork(path) == jpeg_bytes + b"\x00"
|
||||
49
tests/test_tap_tempo.py
Normal file
49
tests/test_tap_tempo.py
Normal file
@ -0,0 +1,49 @@
|
||||
from lintunes.tap_tempo import TapTempo
|
||||
|
||||
|
||||
def test_steady_taps_120_bpm():
|
||||
tempo = TapTempo()
|
||||
bpm = None
|
||||
for i in range(5):
|
||||
bpm = tempo.tap(10.0 + i * 0.5) # 0.5s intervals = 120 bpm
|
||||
assert bpm == 120
|
||||
|
||||
|
||||
def test_first_tap_gives_no_bpm():
|
||||
tempo = TapTempo()
|
||||
assert tempo.tap(1.0) is None
|
||||
assert tempo.tap_count == 1
|
||||
|
||||
|
||||
def test_long_gap_resets_series():
|
||||
tempo = TapTempo()
|
||||
tempo.tap(0.0)
|
||||
tempo.tap(0.5)
|
||||
assert tempo.bpm() == 120
|
||||
# 5 seconds later: new series, old taps discarded
|
||||
assert tempo.tap(5.5) is None
|
||||
assert tempo.tap_count == 1
|
||||
assert tempo.tap(6.1) == 100 # 0.6s interval
|
||||
|
||||
|
||||
def test_window_uses_recent_taps_only():
|
||||
tempo = TapTempo()
|
||||
# 4 slow taps (1s = 60bpm) then 8 fast taps (0.25s = 240bpm);
|
||||
# only the last 8 taps count, so the slow ones age out entirely
|
||||
t = 0.0
|
||||
for _ in range(4):
|
||||
tempo.tap(t)
|
||||
t += 1.0
|
||||
for _ in range(8):
|
||||
tempo.tap(t)
|
||||
t += 0.25
|
||||
assert tempo.bpm() == 240
|
||||
|
||||
|
||||
def test_reset():
|
||||
tempo = TapTempo()
|
||||
tempo.tap(0.0)
|
||||
tempo.tap(0.5)
|
||||
tempo.reset()
|
||||
assert tempo.bpm() is None
|
||||
assert tempo.tap_count == 0
|
||||
56
tests/test_track_edit.py
Normal file
56
tests/test_track_edit.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""Regression tests for live browser refresh after a Get-Info edit.
|
||||
|
||||
The bug: renaming a track's artist left the column browser (and its filter)
|
||||
pointing at the old name, so the renamed artist didn't re-sort and clicking it
|
||||
returned no tracks. The fix routes a new `track_fields_edited(track_id, fields)`
|
||||
signal to the library view, which rebuilds the browser only when a field it
|
||||
groups by changed — and leaves play-count/skip bumps alone.
|
||||
"""
|
||||
|
||||
from lintunes.models import Library, Track
|
||||
from lintunes.library_manager import LibraryManager
|
||||
from lintunes.gui.library_view import _BROWSER_FIELDS
|
||||
|
||||
|
||||
def _manager(tmp_path):
|
||||
track = Track(track_id=1, name="Song", artist="Aardvark",
|
||||
album="Debut", genre="Rock")
|
||||
library = Library(tracks={1: track})
|
||||
return LibraryManager(library, tmp_path), track
|
||||
|
||||
|
||||
def test_edit_emits_changed_fields(qapp, tmp_path):
|
||||
manager, _track = _manager(tmp_path)
|
||||
edits, updates = [], []
|
||||
manager.track_fields_edited.connect(lambda tid, fields: edits.append((tid, fields)))
|
||||
manager.track_updated.connect(updates.append)
|
||||
|
||||
manager.update_track_fields(1, {"artist": "Zz Top"})
|
||||
|
||||
assert edits == [(1, ["artist"])]
|
||||
assert updates == [1]
|
||||
assert _BROWSER_FIELDS.intersection(edits[0][1]) # would refresh the browser
|
||||
|
||||
|
||||
def test_no_op_edit_is_silent(qapp, tmp_path):
|
||||
manager, _track = _manager(tmp_path)
|
||||
edits = []
|
||||
manager.track_fields_edited.connect(lambda tid, fields: edits.append((tid, fields)))
|
||||
|
||||
manager.update_track_fields(1, {"artist": "Aardvark"}) # unchanged value
|
||||
|
||||
assert edits == []
|
||||
|
||||
|
||||
def test_play_count_does_not_reorganize(qapp, tmp_path):
|
||||
"""record_play bumps play_count via track_updated but must NOT emit
|
||||
track_fields_edited, so a finished play never rebuilds the browser."""
|
||||
manager, _track = _manager(tmp_path)
|
||||
edits, updates = [], []
|
||||
manager.track_fields_edited.connect(lambda tid, fields: edits.append((tid, fields)))
|
||||
manager.track_updated.connect(updates.append)
|
||||
|
||||
manager.record_play(1)
|
||||
|
||||
assert edits == []
|
||||
assert updates == [1]
|
||||
178
tests/test_undo.py
Normal file
178
tests/test_undo.py
Normal file
@ -0,0 +1,178 @@
|
||||
"""Undo/redo round-trips for LibraryManager operations (no GUI)."""
|
||||
from lintunes.library_manager import LibraryManager
|
||||
from lintunes.models import Library, Track, Playlist, PlaylistType
|
||||
from lintunes.undo import UndoStack, Command
|
||||
|
||||
|
||||
def _manager(tmp_path, track_ids=(), playlists=None):
|
||||
library = Library()
|
||||
for tid in track_ids:
|
||||
library.tracks[tid] = Track(track_id=tid, name=f"t{tid}")
|
||||
for pl in (playlists or []):
|
||||
library.playlists[pl.persistent_id] = pl
|
||||
return LibraryManager(library, tmp_path)
|
||||
|
||||
|
||||
def _playlist(pid="PID", track_ids=(), parent="", folder=False):
|
||||
return Playlist(
|
||||
name=pid, persistent_id=pid, parent_persistent_id=parent,
|
||||
playlist_type=PlaylistType.FOLDER if folder else PlaylistType.REGULAR,
|
||||
track_ids=list(track_ids))
|
||||
|
||||
|
||||
# ---- the stack itself ----
|
||||
|
||||
class TestUndoStack:
|
||||
def test_caps_at_maxlen(self):
|
||||
stack = UndoStack(maxlen=3)
|
||||
state = []
|
||||
for i in range(5):
|
||||
stack.push(Command(str(i), undo=lambda i=i: state.append(-i),
|
||||
redo=lambda: None))
|
||||
# Only the last 3 survive; undo them all and the 4th from last is gone.
|
||||
for _ in range(5):
|
||||
stack.undo()
|
||||
assert state == [-4, -3, -2] # commands 0,1 were dropped
|
||||
|
||||
def test_redo_branch_cleared_on_new_push(self):
|
||||
stack = UndoStack()
|
||||
stack.push(Command("a", undo=lambda: None, redo=lambda: None))
|
||||
stack.undo()
|
||||
assert stack.can_redo()
|
||||
stack.push(Command("b", undo=lambda: None, redo=lambda: None))
|
||||
assert not stack.can_redo()
|
||||
|
||||
def test_labels(self):
|
||||
stack = UndoStack()
|
||||
stack.push(Command("Add to Playlist", undo=lambda: None, redo=lambda: None))
|
||||
assert stack.undo_label() == "Add to Playlist"
|
||||
stack.undo()
|
||||
assert stack.redo_label() == "Add to Playlist"
|
||||
assert stack.undo_label() == ""
|
||||
|
||||
|
||||
# ---- playlist contents ----
|
||||
|
||||
class TestPlaylistContentUndo:
|
||||
def test_add_tracks(self, qapp, tmp_path):
|
||||
pl = _playlist(track_ids=[1])
|
||||
m = _manager(tmp_path, [1, 2, 3], [pl])
|
||||
m.add_tracks_to_playlist("PID", [2, 3])
|
||||
assert pl.track_ids == [1, 2, 3]
|
||||
m.undo_stack.undo()
|
||||
assert pl.track_ids == [1]
|
||||
m.undo_stack.redo()
|
||||
assert pl.track_ids == [1, 2, 3]
|
||||
|
||||
def test_remove_tracks(self, qapp, tmp_path):
|
||||
pl = _playlist(track_ids=[1, 2, 3])
|
||||
m = _manager(tmp_path, [1, 2, 3], [pl])
|
||||
m.remove_tracks_from_playlist("PID", [0, 2])
|
||||
assert pl.track_ids == [2]
|
||||
m.undo_stack.undo()
|
||||
assert pl.track_ids == [1, 2, 3]
|
||||
m.undo_stack.redo()
|
||||
assert pl.track_ids == [2]
|
||||
|
||||
def test_move_tracks(self, qapp, tmp_path):
|
||||
pl = _playlist(track_ids=[1, 2, 3, 4])
|
||||
m = _manager(tmp_path, [1, 2, 3, 4], [pl])
|
||||
m.move_tracks_in_playlist("PID", [0], 3)
|
||||
moved = list(pl.track_ids)
|
||||
assert moved != [1, 2, 3, 4]
|
||||
m.undo_stack.undo()
|
||||
assert pl.track_ids == [1, 2, 3, 4]
|
||||
m.undo_stack.redo()
|
||||
assert pl.track_ids == moved
|
||||
|
||||
def test_noop_add_does_not_push(self, qapp, tmp_path):
|
||||
pl = _playlist(track_ids=[1])
|
||||
m = _manager(tmp_path, [1], [pl])
|
||||
m.add_tracks_to_playlist("PID", []) # nothing valid
|
||||
assert not m.undo_stack.can_undo()
|
||||
|
||||
|
||||
# ---- playlist structure ----
|
||||
|
||||
class TestPlaylistStructureUndo:
|
||||
def test_create_playlist(self, qapp, tmp_path):
|
||||
m = _manager(tmp_path)
|
||||
pl = m.create_playlist("New")
|
||||
assert pl.persistent_id in m.library.playlists
|
||||
m.undo_stack.undo()
|
||||
assert pl.persistent_id not in m.library.playlists
|
||||
m.undo_stack.redo()
|
||||
assert pl.persistent_id in m.library.playlists
|
||||
|
||||
def test_rename_playlist(self, qapp, tmp_path):
|
||||
pl = _playlist()
|
||||
m = _manager(tmp_path, playlists=[pl])
|
||||
m.rename_playlist("PID", "Renamed")
|
||||
assert pl.name == "Renamed"
|
||||
m.undo_stack.undo()
|
||||
assert pl.name == "PID"
|
||||
m.undo_stack.redo()
|
||||
assert pl.name == "Renamed"
|
||||
|
||||
def test_move_playlist(self, qapp, tmp_path):
|
||||
folder = _playlist("FID", folder=True)
|
||||
pl = _playlist("PID")
|
||||
m = _manager(tmp_path, playlists=[folder, pl])
|
||||
m.move_playlist("PID", "FID")
|
||||
assert pl.parent_persistent_id == "FID"
|
||||
m.undo_stack.undo()
|
||||
assert pl.parent_persistent_id == ""
|
||||
m.undo_stack.redo()
|
||||
assert pl.parent_persistent_id == "FID"
|
||||
|
||||
def test_delete_playlist_restores_contents(self, qapp, tmp_path):
|
||||
pl = _playlist(track_ids=[1, 2])
|
||||
m = _manager(tmp_path, [1, 2], [pl])
|
||||
m.delete_playlist("PID")
|
||||
assert "PID" not in m.library.playlists
|
||||
m.undo_stack.undo()
|
||||
assert "PID" in m.library.playlists
|
||||
assert m.library.playlists["PID"].track_ids == [1, 2]
|
||||
|
||||
def test_delete_folder_restores_descendants(self, qapp, tmp_path):
|
||||
folder = _playlist("FID", folder=True)
|
||||
child = _playlist("CID", track_ids=[1], parent="FID")
|
||||
m = _manager(tmp_path, [1], [folder, child])
|
||||
m.delete_playlist("FID")
|
||||
assert "FID" not in m.library.playlists
|
||||
assert "CID" not in m.library.playlists
|
||||
m.undo_stack.undo()
|
||||
assert "FID" in m.library.playlists
|
||||
assert "CID" in m.library.playlists
|
||||
assert m.library.playlists["CID"].track_ids == [1]
|
||||
|
||||
|
||||
# ---- metadata ----
|
||||
|
||||
class TestMetadataUndo:
|
||||
def test_edit_fields_no_location(self, qapp, tmp_path):
|
||||
# No file location -> tag write is skipped but library/undo still work.
|
||||
m = _manager(tmp_path, [1])
|
||||
m.edit_track_fields(1, {"name": "New Name", "artist": "New Artist"})
|
||||
track = m.library.tracks[1]
|
||||
assert track.name == "New Name" and track.artist == "New Artist"
|
||||
m.undo_stack.undo()
|
||||
assert track.name == "t1" and (track.artist or "") == ""
|
||||
m.undo_stack.redo()
|
||||
assert track.name == "New Name"
|
||||
|
||||
def test_edit_unchanged_does_not_push(self, qapp, tmp_path):
|
||||
m = _manager(tmp_path, [1])
|
||||
m.edit_track_fields(1, {"name": "t1"}) # same value
|
||||
assert not m.undo_stack.can_undo()
|
||||
|
||||
def test_edit_writes_file_tags_and_reverts(self, qapp, tmp_path, mp3_file):
|
||||
from lintunes import tagging
|
||||
track = Track(track_id=1, name="orig", location=str(mp3_file))
|
||||
library = Library()
|
||||
library.tracks[1] = track
|
||||
m = LibraryManager(library, tmp_path)
|
||||
m.edit_track_fields(1, {"name": "edited", "artist": "Somebody"})
|
||||
assert tagging.read_tags(str(mp3_file))["name"] == "edited"
|
||||
m.undo_stack.undo()
|
||||
assert tagging.read_tags(str(mp3_file))["name"] == "orig"
|
||||
Reference in New Issue
Block a user