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:
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.24.1"
|
||||
__version__ = "0.25.0"
|
||||
|
||||
@@ -286,6 +286,7 @@ class MainWindow(QMainWindow):
|
||||
self.apply_ui_metrics()
|
||||
self._transport.refresh_theme()
|
||||
self._version_button.refresh_theme()
|
||||
self._sidebar.refresh_style()
|
||||
|
||||
def apply_ui_metrics(self):
|
||||
metrics = theme.scale_metrics(self._prefs)
|
||||
|
||||
+19
-9
@@ -1,12 +1,12 @@
|
||||
from PyQt6.QtWidgets import (
|
||||
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.QtGui import QBrush, QPainter, QPixmap
|
||||
from PyQt6.QtGui import QBrush, QPainter, QPalette, QPixmap
|
||||
|
||||
from lintunes.models import PlaylistType
|
||||
from lintunes import tagging
|
||||
from lintunes import tagging, theme
|
||||
from lintunes.gui.track_table import (
|
||||
parse_tracks_mime, TRACKS_MIME, autoscroll_direction,
|
||||
)
|
||||
@@ -39,12 +39,7 @@ class SidebarPanel(QWidget):
|
||||
self.library_button = QPushButton("Library")
|
||||
self.library_button.setCheckable(True)
|
||||
self.library_button.setChecked(True)
|
||||
self.library_button.setStyleSheet(
|
||||
"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); }")
|
||||
self.refresh_style()
|
||||
layout.addWidget(self.library_button)
|
||||
|
||||
self.tree = PlaylistTree(manager)
|
||||
@@ -85,6 +80,21 @@ class SidebarPanel(QWidget):
|
||||
def apply_metrics(self, header_strip: int):
|
||||
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):
|
||||
"""Square album art pinned to the sidebar bottom; gray square when the
|
||||
|
||||
@@ -36,7 +36,7 @@ def _box_style(bg: QColor) -> str:
|
||||
QFrame#transportBox {{
|
||||
border: 1px solid palette(mid);
|
||||
border-radius: 8px;
|
||||
background: {bg.name()};
|
||||
background: {theme.emboss_gradient(bg)};
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
@@ -145,6 +145,16 @@ class LibraryManager(QObject):
|
||||
redo=lambda: self._insert_playlists([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:
|
||||
playlist = Playlist(
|
||||
name=name,
|
||||
|
||||
@@ -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_set_at = (
|
||||
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:
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
theme.apply_theme(app, prefs)
|
||||
manager = LibraryManager(library, data_dir)
|
||||
if new_library:
|
||||
manager.seed_new_library()
|
||||
lastfm = LastFm(prefs, data_dir)
|
||||
window = MainWindow(manager, prefs, lastfm)
|
||||
app.installEventFilter(window)
|
||||
|
||||
@@ -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
|
||||
@@ -156,13 +156,56 @@ def apply_theme(app, prefs):
|
||||
app.setFont(font)
|
||||
|
||||
bar = metrics["scrollbar"]
|
||||
button = palette.color(QPalette.ColorRole.Button)
|
||||
app.setStyleSheet(f"""
|
||||
QScrollBar:vertical {{ width: {bar}px; }}
|
||||
QScrollBar:horizontal {{ height: {bar}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]:
|
||||
"""Families Qt can actually resolve on this machine."""
|
||||
return set(QFontDatabase.families())
|
||||
|
||||
@@ -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