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:
2026-09-28 20:56:28 -07:00
co-authored by Claude Opus 5.5
parent da9186420b
commit 6d65ca4050
9 changed files with 275 additions and 11 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux.""" """LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.24.1" __version__ = "0.25.0"
+1
View File
@@ -286,6 +286,7 @@ class MainWindow(QMainWindow):
self.apply_ui_metrics() self.apply_ui_metrics()
self._transport.refresh_theme() self._transport.refresh_theme()
self._version_button.refresh_theme() self._version_button.refresh_theme()
self._sidebar.refresh_style()
def apply_ui_metrics(self): def apply_ui_metrics(self):
metrics = theme.scale_metrics(self._prefs) metrics = theme.scale_metrics(self._prefs)
+19 -9
View File
@@ -1,12 +1,12 @@
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu, QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu,
QInputDialog, QMessageBox, QAbstractItemView, QLineEdit, QInputDialog, QMessageBox, QAbstractItemView, QLineEdit, QApplication,
) )
from PyQt6.QtCore import Qt, QTimer, QPoint, pyqtSignal from PyQt6.QtCore import Qt, QTimer, QPoint, pyqtSignal
from PyQt6.QtGui import QBrush, QPainter, QPixmap from PyQt6.QtGui import QBrush, QPainter, QPalette, QPixmap
from lintunes.models import PlaylistType from lintunes.models import PlaylistType
from lintunes import tagging from lintunes import tagging, theme
from lintunes.gui.track_table import ( from lintunes.gui.track_table import (
parse_tracks_mime, TRACKS_MIME, autoscroll_direction, parse_tracks_mime, TRACKS_MIME, autoscroll_direction,
) )
@@ -39,12 +39,7 @@ class SidebarPanel(QWidget):
self.library_button = QPushButton("Library") self.library_button = QPushButton("Library")
self.library_button.setCheckable(True) self.library_button.setCheckable(True)
self.library_button.setChecked(True) self.library_button.setChecked(True)
self.library_button.setStyleSheet( self.refresh_style()
"QPushButton { text-align: left; padding-left: 10px;"
" border: 1px solid palette(mid); border-radius: 4px;"
" margin: 2px 4px 2px 8px; background: palette(alternate-base); }"
"QPushButton:checked { background: palette(highlight);"
" color: palette(highlighted-text); }")
layout.addWidget(self.library_button) layout.addWidget(self.library_button)
self.tree = PlaylistTree(manager) self.tree = PlaylistTree(manager)
@@ -85,6 +80,21 @@ class SidebarPanel(QWidget):
def apply_metrics(self, header_strip: int): def apply_metrics(self, header_strip: int):
self.library_button.setFixedHeight(header_strip) self.library_button.setFixedHeight(header_strip)
def refresh_style(self):
"""(Re)build the Library button's look from the live palette, so its
emboss follows theme changes like every other button's."""
palette = QApplication.palette()
base = palette.color(QPalette.ColorRole.AlternateBase)
lit = palette.color(QPalette.ColorRole.Highlight)
self.library_button.setStyleSheet(
"QPushButton { text-align: left; padding-left: 10px;"
" border: 1px solid palette(mid); border-radius: 4px;"
" margin: 2px 4px 2px 8px;"
f" background: {theme.emboss_gradient(base)}; }}"
"QPushButton:checked {"
f" background: {theme.emboss_gradient(lit, pressed=True)};"
" color: palette(highlighted-text); }")
class SidebarArt(QWidget): class SidebarArt(QWidget):
"""Square album art pinned to the sidebar bottom; gray square when the """Square album art pinned to the sidebar bottom; gray square when the
+1 -1
View File
@@ -36,7 +36,7 @@ def _box_style(bg: QColor) -> str:
QFrame#transportBox {{ QFrame#transportBox {{
border: 1px solid palette(mid); border: 1px solid palette(mid);
border-radius: 8px; border-radius: 8px;
background: {bg.name()}; background: {theme.emboss_gradient(bg)};
}} }}
""" """
+10
View File
@@ -145,6 +145,16 @@ class LibraryManager(QObject):
redo=lambda: self._insert_playlists([folder]))) redo=lambda: self._insert_playlists([folder])))
return folder return folder
def seed_new_library(self):
"""Add the playlists a brand-new library starts with (not undoable —
there is nothing before them to go back to)."""
from lintunes.seed_playlists import seed_new_library
added = seed_new_library(dict(self.library.playlists))
if added:
self._insert_playlists(added)
for playlist in added:
self.recompute_smart_playlist(playlist.persistent_id, force=True)
def create_smart_playlist(self, name: str, criteria, parent_pid: str = "") -> Playlist: def create_smart_playlist(self, name: str, criteria, parent_pid: str = "") -> Playlist:
playlist = Playlist( playlist = Playlist(
name=name, name=name,
+8
View File
@@ -93,6 +93,9 @@ def run_import(xml_path: Path, music_root: str | None, data_dir: Path):
library.music_folder_rel = to_relative(library.music_folder, data_dir) library.music_folder_rel = to_relative(library.music_folder, data_dir)
library.music_folder_set_at = ( library.music_folder_set_at = (
datetime.now(timezone.utc).replace(tzinfo=None).isoformat()) datetime.now(timezone.utc).replace(tzinfo=None).isoformat())
# A fresh import is a new library, so it starts with the seed playlists.
from lintunes.seed_playlists import seed_new_library
seed_new_library(library.playlists)
if report.missing_files: if report.missing_files:
missing_log = data_dir / "import_missing_files.txt" missing_log = data_dir / "import_missing_files.txt"
@@ -134,6 +137,9 @@ def run_gui(data_dir: Path, files: list[Path],
from lintunes.sync_watcher import LibrarySyncWatcher from lintunes.sync_watcher import LibrarySyncWatcher
startup_conflicts = resolve_conflicts(data_dir) startup_conflicts = resolve_conflicts(data_dir)
# No library.json means a library is being born here (not merely an empty
# one), which is the one moment the seed playlists are added.
new_library = not (data_dir / "library.json").exists()
library = load_library(data_dir) library = load_library(data_dir)
app = QApplication([sys.argv[0]] + (qt_args or [])) app = QApplication([sys.argv[0]] + (qt_args or []))
@@ -147,6 +153,8 @@ def run_gui(data_dir: Path, files: list[Path],
prefs = Preferences(data_dir) prefs = Preferences(data_dir)
theme.apply_theme(app, prefs) theme.apply_theme(app, prefs)
manager = LibraryManager(library, data_dir) manager = LibraryManager(library, data_dir)
if new_library:
manager.seed_new_library()
lastfm = LastFm(prefs, data_dir) lastfm = LastFm(prefs, data_dir)
window = MainWindow(manager, prefs, lastfm) window = MainWindow(manager, prefs, lastfm)
app.installEventFilter(window) app.installEventFilter(window)
+45
View File
@@ -0,0 +1,45 @@
"""Playlists a brand-new library starts with.
Seeded once, when a library is born: a fresh empty data dir (``run_gui``) or a
fresh iTunes import. An existing library never gains one, and a seeded playlist
is an ordinary smart playlist afterwards — rename it, edit it, delete it.
The persistent id is **fixed**, not random: two machines that each start a new
library in the same (not yet synced) share both seed it, and the conflict merge
then sees one playlist with two copies instead of two playlists both called
"New Tracks".
"""
from lintunes.models import Playlist, PlaylistType
from lintunes.models.playlist import PlaylistSettings
from lintunes.smart import SmartCriteria, SmartGroup, SmartRule
NEW_TRACKS_NAME = "New Tracks"
NEW_TRACKS_PID = "4E4557545241434B" # "NEWTRACK" in hex
def new_tracks_playlist() -> Playlist:
"""Smart playlist of everything added in the last three months, newest
first."""
criteria = SmartCriteria(root=SmartGroup(conjunction="all", children=[
SmartRule(field="date_added", operator="in_last", value=3,
unit="months"),
]))
return Playlist(
name=NEW_TRACKS_NAME,
persistent_id=NEW_TRACKS_PID,
playlist_type=PlaylistType.SMART,
smart_criteria=criteria,
settings=PlaylistSettings(sort_column="date_added",
sort_ascending=False),
)
def seed_new_library(playlists: dict) -> list[Playlist]:
"""Add the seed playlists to ``playlists`` (persistent id → Playlist)
unless a playlist with that id is already there. Returns what was added."""
added = []
for playlist in (new_tracks_playlist(),):
if playlist.persistent_id not in playlists:
playlists[playlist.persistent_id] = playlist
added.append(playlist)
return added
+43
View File
@@ -156,13 +156,56 @@ def apply_theme(app, prefs):
app.setFont(font) app.setFont(font)
bar = metrics["scrollbar"] bar = metrics["scrollbar"]
button = palette.color(QPalette.ColorRole.Button)
app.setStyleSheet(f""" app.setStyleSheet(f"""
QScrollBar:vertical {{ width: {bar}px; }} QScrollBar:vertical {{ width: {bar}px; }}
QScrollBar:horizontal {{ height: {bar}px; }} QScrollBar:horizontal {{ height: {bar}px; }}
QScrollBar::handle {{ min-height: {bar * 2}px; min-width: {bar * 2}px; }} QScrollBar::handle {{ min-height: {bar * 2}px; min-width: {bar * 2}px; }}
{button_emboss_qss(button)}
""") """)
def emboss_gradient(color: QColor, pressed: bool = False) -> str:
"""A barely-there top-lit vertical gradient around ``color``.
Computed from the live color rather than hard-coded, so it follows the
palette (light or dark) and the user's grayscale overrides. ``pressed``
flips it, which reads as the button sinking in."""
top, bottom = color.lighter(EMBOSS_LIGHTER), color.darker(EMBOSS_DARKER)
if pressed:
top, bottom = bottom, top
# The color itself sits at the midpoint, so what the user picked is still
# exactly what the middle of the button shows.
return (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, "
f"stop:0 {top.name()}, stop:0.5 {color.name()}, "
f"stop:1 {bottom.name()})")
# How far the emboss strays from the base color (Qt's lighter/darker factor,
# 100 = unchanged). Kept small on purpose: "very slight, soft".
EMBOSS_LIGHTER = 106
EMBOSS_DARKER = 104
def button_emboss_qss(button: QColor) -> str:
"""Global rule for every non-flat QPushButton. Flat buttons (the version
button, the cancel-transfer ✕, the bpm label) opt out by being flat."""
hover = button.lighter(103)
return f"""
QPushButton:!flat {{
border: 1px solid palette(mid); border-radius: 4px;
padding: 3px 12px; min-height: 1.3em;
background: {emboss_gradient(button)};
}}
QPushButton:!flat:hover {{ background: {emboss_gradient(hover)}; }}
QPushButton:!flat:pressed, QPushButton:!flat:checked {{
background: {emboss_gradient(button, pressed=True)};
}}
QPushButton:!flat:default {{ border-color: palette(highlight); }}
QPushButton:!flat:disabled {{ color: palette(mid); }}
"""
def installed_families() -> set[str]: def installed_families() -> set[str]:
"""Families Qt can actually resolve on this machine.""" """Families Qt can actually resolve on this machine."""
return set(QFontDatabase.families()) return set(QFontDatabase.families())
+147
View File
@@ -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