v0.25.0: New Tracks, a soft emboss, date added pinned
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>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user