Groundwork for Cassette (friend library sharing) that stands on its own: - A brand-new library (fresh data dir, or a fresh iTunes import) starts with a "New Tracks" smart playlist: added in the last 3 months, newest first. Fixed persistent id, so two machines seeding one unsynced share merge into a single playlist. - Every non-flat button, the sidebar Library button and the transport boxes get a very slight top-lit gradient computed from the live palette, with the chosen color kept exactly at its midpoint. - Tests pin that individual imports stamp date added = now even for an old file, and that the iTunes migration keeps the XML's dates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
148 lines
5.8 KiB
Python
148 lines
5.8 KiB
Python
"""Round 63: groundwork for Cassette that isn't Cassette.
|
|
|
|
* Every individual import stamps *date added = now*, even for a file that is
|
|
years old. The iTunes XML migration is the exception: it recreates a library
|
|
rather than adding to one, so it keeps the dates the XML carries.
|
|
* A brand-new library (a fresh data dir, or a fresh iTunes import) starts with
|
|
a "New Tracks" smart playlist: added in the last 3 months, newest first. Its
|
|
persistent id is fixed, so two machines seeding the same unsynced share merge
|
|
into one playlist rather than two.
|
|
* Buttons get a very slight emboss, computed from the live palette.
|
|
"""
|
|
import os
|
|
import plistlib
|
|
import shutil
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from PyQt6.QtGui import QColor
|
|
|
|
from lintunes import theme
|
|
from lintunes.importers.file_importer import import_paths
|
|
from lintunes.library_manager import LibraryManager
|
|
from lintunes.models import PlaylistType, Track
|
|
from lintunes.models.library import Library
|
|
from lintunes.seed_playlists import (
|
|
NEW_TRACKS_NAME, NEW_TRACKS_PID, new_tracks_playlist, seed_new_library,
|
|
)
|
|
from lintunes.smart import evaluate
|
|
from lintunes.storage.json_storage import load_library
|
|
|
|
|
|
def _utc_now():
|
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
|
|
|
|
|
# ---- date added ----
|
|
|
|
class TestDateAdded:
|
|
def test_import_stamps_now_even_for_an_old_file(self, qapp, tmp_path, mp3_file):
|
|
old = tmp_path / "old.mp3"
|
|
shutil.copyfile(mp3_file, old)
|
|
ancient = datetime(2004, 5, 1).timestamp()
|
|
os.utime(old, (ancient, ancient))
|
|
manager = LibraryManager(Library(), tmp_path / "data")
|
|
|
|
before = _utc_now() - timedelta(seconds=1)
|
|
[track] = import_paths([old], tmp_path / "Music", manager)
|
|
|
|
added = datetime.fromisoformat(track.date_added)
|
|
assert before <= added <= _utc_now() + timedelta(seconds=1)
|
|
|
|
def test_itunes_migration_keeps_its_dates(self, tmp_path):
|
|
from lintunes.main import run_import
|
|
xml = tmp_path / "Library.xml"
|
|
with open(xml, "wb") as fh:
|
|
plistlib.dump({
|
|
"Music Folder": "file:///Volumes/M/iTunes%20Media/",
|
|
"Tracks": {"7": {
|
|
"Track ID": 7, "Name": "Old Song", "Persistent ID": "P7",
|
|
"Track Type": "File",
|
|
"Date Added": datetime(2009, 3, 4, 5, 6, 7),
|
|
"Location": "file:///Volumes/M/iTunes%20Media/Music/a.mp3",
|
|
}},
|
|
"Playlists": [],
|
|
}, fh)
|
|
data = tmp_path / "data"
|
|
run_import(xml, str(tmp_path / "media"), data)
|
|
|
|
library = load_library(data)
|
|
assert library.tracks[7].date_added.startswith("2009-03-04")
|
|
|
|
|
|
# ---- New Tracks ----
|
|
|
|
class TestNewTracks:
|
|
def test_shape(self):
|
|
playlist = new_tracks_playlist()
|
|
assert playlist.name == NEW_TRACKS_NAME
|
|
assert playlist.persistent_id == NEW_TRACKS_PID
|
|
assert playlist.playlist_type == PlaylistType.SMART
|
|
[rule] = playlist.smart_criteria.root.children
|
|
assert (rule.field, rule.operator, rule.value, rule.unit) == \
|
|
("date_added", "in_last", 3, "months")
|
|
assert playlist.settings.sort_column == "date_added"
|
|
assert playlist.settings.sort_ascending is False
|
|
|
|
def test_matches_only_recent_tracks(self):
|
|
now = _utc_now()
|
|
recent = Track(track_id=1, date_added=(now - timedelta(days=20)).isoformat())
|
|
stale = Track(track_id=2, date_added=(now - timedelta(days=200)).isoformat())
|
|
criteria = new_tracks_playlist().smart_criteria
|
|
assert evaluate(criteria, [recent, stale]) == [1]
|
|
|
|
def test_seed_is_idempotent(self):
|
|
playlists = {}
|
|
assert len(seed_new_library(playlists)) == 1
|
|
assert seed_new_library(playlists) == []
|
|
assert list(playlists) == [NEW_TRACKS_PID]
|
|
|
|
def test_manager_seed_persists(self, qapp, tmp_path):
|
|
manager = LibraryManager(Library(), tmp_path)
|
|
manager.seed_new_library()
|
|
manager.flush()
|
|
reloaded = load_library(tmp_path)
|
|
assert reloaded.playlists[NEW_TRACKS_PID].name == NEW_TRACKS_NAME
|
|
assert reloaded.playlists[NEW_TRACKS_PID].is_smart
|
|
|
|
def test_itunes_import_is_seeded(self, tmp_path):
|
|
from lintunes.main import run_import
|
|
xml = tmp_path / "Library.xml"
|
|
with open(xml, "wb") as fh:
|
|
plistlib.dump({"Music Folder": "file:///M/", "Tracks": {},
|
|
"Playlists": []}, fh)
|
|
run_import(xml, str(tmp_path), tmp_path / "data")
|
|
assert NEW_TRACKS_PID in load_library(tmp_path / "data").playlists
|
|
|
|
def test_existing_library_is_not_seeded(self):
|
|
"""run_gui seeds only when library.json is absent; pin the predicate
|
|
by source rather than launching a GUI."""
|
|
import inspect
|
|
from lintunes import main
|
|
source = inspect.getsource(main.run_gui)
|
|
assert 'not (data_dir / "library.json").exists()' in source
|
|
assert "manager.seed_new_library()" in source
|
|
|
|
|
|
# ---- emboss ----
|
|
|
|
class TestEmboss:
|
|
def test_gradient_keeps_the_color_at_its_middle(self):
|
|
color = QColor("#808080")
|
|
gradient = theme.emboss_gradient(color)
|
|
assert "stop:0.5 #808080" in gradient
|
|
assert color.lighter(theme.EMBOSS_LIGHTER).name() in gradient
|
|
assert color.darker(theme.EMBOSS_DARKER).name() in gradient
|
|
|
|
def test_pressed_flips(self):
|
|
color = QColor("#808080")
|
|
up = theme.emboss_gradient(color)
|
|
down = theme.emboss_gradient(color, pressed=True)
|
|
light = color.lighter(theme.EMBOSS_LIGHTER).name()
|
|
assert up.index(light) < up.index("stop:1")
|
|
assert down.index(light) > down.index("stop:1")
|
|
|
|
def test_flat_buttons_opt_out(self):
|
|
qss = theme.button_emboss_qss(QColor("#808080"))
|
|
assert "QPushButton:!flat" in qss
|
|
assert "QPushButton {" not in qss
|