diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 23f3978..8ec16ed 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.24.1" +__version__ = "0.25.0" diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index 29ace94..913f595 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -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) diff --git a/lintunes/gui/sidebar.py b/lintunes/gui/sidebar.py index 44fd241..b6a3a51 100644 --- a/lintunes/gui/sidebar.py +++ b/lintunes/gui/sidebar.py @@ -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 diff --git a/lintunes/gui/transport.py b/lintunes/gui/transport.py index d01579f..9624af3 100644 --- a/lintunes/gui/transport.py +++ b/lintunes/gui/transport.py @@ -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)}; }} """ diff --git a/lintunes/library_manager.py b/lintunes/library_manager.py index 16cdc2f..d0f59c8 100644 --- a/lintunes/library_manager.py +++ b/lintunes/library_manager.py @@ -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, diff --git a/lintunes/main.py b/lintunes/main.py index 6971bb8..e04966a 100644 --- a/lintunes/main.py +++ b/lintunes/main.py @@ -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) diff --git a/lintunes/seed_playlists.py b/lintunes/seed_playlists.py new file mode 100644 index 0000000..9ee9dc0 --- /dev/null +++ b/lintunes/seed_playlists.py @@ -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 diff --git a/lintunes/theme.py b/lintunes/theme.py index 10379de..f9cc200 100644 --- a/lintunes/theme.py +++ b/lintunes/theme.py @@ -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()) diff --git a/tests/test_round63.py b/tests/test_round63.py new file mode 100644 index 0000000..cc3e350 --- /dev/null +++ b/tests/test_round63.py @@ -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