v0.10.0: a first launch that welcomes you
Three things that only ever hurt new users.
The startup font modal is gone. _ensure_now_playing_font ran before
MainWindow existed, so on a machine without Century Gothic the very first
thing LinTunes did was open a parentless dialog demanding a font decision.
theme.NOW_PLAYING_FALLBACKS now picks the closest geometric sans installed
(URW Gothic, the Avant Garde clone CG derives from, leads the chain), and
the choice moved to Preferences ▸ Now-playing font. An uninstalled saved
family falls back to automatic instead of the app default.
A non-iTunes user can finally set their music folder. library.music_folder
was written in exactly one place — the iTunes importer — and with it unset
_music_import_dir fell back to a *relative* Path("Music"), resolved against
a working directory GNOME's dash does not set predictably (see
packaging/install-desktop.sh). Music scattered somewhere unfindable. Now
the first launch asks one plain-language question, Preferences can change
it later, and an import with no folder set refuses rather than guessing.
Picking ~/Music files into ~/Music, not ~/Music/Music.
music_folder is portable at last. It was the only path in the library
stored raw absolute in the *synced* metadata, so machine 2 inherited
machine 1's paths. It is now also stored relative to the data dir, the
same trick Track.location has used all along. The absolute key stays
forever as the shared floor between versions: old code reads it and
behaves exactly as before, and old code that writes the file just drops
the new keys, so no version combination hard-fails.
Two traps worth naming. set_music_folder must call
mark_library_settings_dirty() or reload_from_disk reverts the change on
the next sync tick. And the dirty flag only guards until flush, so a
music_folder_set_at stamp decides adoption semantically — _merge_metadata
picks the whole file by mtime, which moves when someone resizes a column
(the Round 39 lesson, applied to metadata).
Also: correct CLAUDE.md's claim that the importer skips smart playlists —
it imports them; system playlists are what's skipped. README gains a
"never used a terminal?" on-ramp and loses the instruction to hand-write
library_metadata.json before first launch.
Verified against the live 21,490-track library: still resolves (via the
legacy key), metadata untouched, 646 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4Z46BMQYS57bcbxWbSS3C
This commit is contained in:
@@ -113,13 +113,36 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
|
||||
- **`lintunes/importers/itunes_importer.py`** — parses the iTunes XML plist.
|
||||
Remaps Mac `file:///Volumes/...` paths to the local `--music-root` with
|
||||
case/Unicode-normalization fuzzy matching (macOS is case-insensitive + NFD vs
|
||||
ext4). Imports user playlists/folders only; smart and system playlists are
|
||||
currently skipped. Album art is read live from embedded ID3 tags
|
||||
ext4). Imports user playlists, folders **and smart playlists** (criteria
|
||||
parsed by `smart.parse_itunes_smart`; criteria it can't model are kept as a
|
||||
static snapshot — `report.smart_unsupported`). System playlists are skipped
|
||||
(`report.skipped_system`). Album art is read live from embedded ID3 tags
|
||||
(`tagging.py`), never stored in the library JSON.
|
||||
|
||||
- **`lintunes/preferences.py`** — app settings in `<data_dir>/preferences.json`
|
||||
(rides the same Syncthing share). `Preferences.set(key, value)` saves and emits
|
||||
`changed`; `MainWindow._on_prefs_changed` re-applies theme/metrics live.
|
||||
Because it is synced, **anything machine-specific belongs in `config.py`'s
|
||||
`config.json` instead** (see `music_folder.py`). `now_playing_font` has three
|
||||
states — `None` automatic (best available from `theme.NOW_PLAYING_FALLBACKS`),
|
||||
`""` the app font, or an explicit family. Startup never prompts for it; the
|
||||
row in Preferences is the only place it's chosen.
|
||||
|
||||
- **`lintunes/music_folder.py`** — where the organized tree lives *on this
|
||||
machine*. The library stores it three ways (`models/library.py`):
|
||||
`music_folder` absolute (what pre-0.10 code reads — kept forever as the shared
|
||||
floor between versions), `music_folder_rel` relative to the data dir (the
|
||||
portable one, same trick as `Track.location`), and `music_folder_set_at`, a
|
||||
stamp so a merge prefers the newest *setting* rather than the newest *file*.
|
||||
`resolve()` tries rel → legacy → the per-machine override in `config.json`,
|
||||
and every candidate must **exist** — the override is consulted last so a
|
||||
machine that once needed one isn't pinned to it forever. Two rules that bite:
|
||||
`LibraryManager.set_music_folder` must call `mark_library_settings_dirty()`
|
||||
(without it `reload_from_disk` reverts the change on the next sync tick), and
|
||||
`organize_root()` can return `None`, which every caller must handle rather
|
||||
than inventing a relative path — `MainWindow._music_import_dir` used to fall
|
||||
back to `Path("Music")`, which resolved against a working directory GNOME's
|
||||
dash doesn't set predictably.
|
||||
|
||||
- **`lintunes/mpris.py`** — registers `org.mpris.MediaPlayer2.lintunes` over D-Bus
|
||||
so the desktop's media keys / now-playing popup control playback. Spacebar and
|
||||
|
||||
@@ -2,49 +2,110 @@
|
||||
|
||||

|
||||
|
||||
an mp3 library manager and player for linux. Absolutely no guarantees, if it wrecks your itunes library or wipes your
|
||||
harddrive that's on you (maybe just have your LLM of choice review the
|
||||
software for bugs and vulns?).
|
||||
an mp3 library manager and player for linux. Absolutely no guarantees, if it wrecks your itunes library or wipes your harddrive that's on you (maybe just have your LLM of choice review the software for bugs and vulns?).
|
||||
|
||||
## Features
|
||||
|
||||
- can import an iTunes 12 (untested on other versions) library, including
|
||||
smart playlists (auto-updating, editable, marked with a ❧)
|
||||
- runs on several machines off one Syncthing-synced library (see below)
|
||||
|
||||
|
||||
**Library**
|
||||
- Sortable track list with 24 optional columns (plays, skips, rating, BPM,
|
||||
bit rate, date added…) — column choice, sort and widths are remembered
|
||||
*per playlist*.
|
||||
- **Column browser** (Ctrl+B) — filter by genre / artist / album, iTunes-style.
|
||||
- Instant **search** over name, artist, album, composer and more.
|
||||
- **Get Info** (Ctrl+I) edits tags on one track or a whole selection at once,
|
||||
writing them back to the file. Artwork can be pasted, dragged in, or fetched
|
||||
automatically (**Download Album Art…**, via the iTunes Search API).
|
||||
- Rename an artist or album and the files **move themselves** into a tidy
|
||||
`Artist/Album/` tree.
|
||||
- **Undo/redo everything** (Ctrl+Z / Ctrl+Shift+Z) — edits, deletes, reorders.
|
||||
- Deleting a track sends the file to your desktop **Trash**, never `rm`.
|
||||
|
||||
**Playlists**
|
||||
- Regular playlists, **folders**, and **smart playlists** (auto-updating, with a
|
||||
full rule editor — nested groups, limits; marked with a ❧).
|
||||
- Drag and drop to reorder or insert at a spot; duplicate-aware adds;
|
||||
copy/cut/paste tracks between playlists.
|
||||
|
||||
**Playing**
|
||||
- mp3, m4a, flac, aac, wav, aiff via Qt Multimedia/FFmpeg.
|
||||
- Shuffle, per-track **start/stop times**, play counts, skip counts and ratings.
|
||||
- **Media keys** work (MPRIS), plus Space and ←/→ anywhere in the app.
|
||||
- Live **spectrum visualizer** — click it to cycle bright / dim / off.
|
||||
- Optional **last.fm scrobbling** (Edit ▸ Preferences).
|
||||
- Theming: highlight color, UI scale, and custom colors for most of the chrome.
|
||||
|
||||
**Sending music elsewhere**
|
||||
- **Chromecast** (Connections menu) — streams the original file bit-for-bit,
|
||||
no transcode, with album art on the TV.
|
||||
- **Export a playlist** (File ▸ Export Playlist…) — either a folder of
|
||||
`Artist - Title.mp3` files plus an `.m3u`, or a self-contained **web mix**
|
||||
(`index.html` you can upload anywhere).
|
||||
- **Sync a playlist to a Rabbit R1** over MTP, `.m3u` included.
|
||||
|
||||
**Living on more than one machine**
|
||||
- The whole library is **plain JSON**, designed to be synced with
|
||||
[Syncthing](https://syncthing.net). Sync conflicts merge themselves on
|
||||
startup, and a change synced in from elsewhere shows up **while the app is
|
||||
running**.
|
||||
- Play counts can't conflict: every machine keeps its own tally and they're
|
||||
summed.
|
||||
- The version number in the status bar checks for updates and, on a click,
|
||||
pulls and restarts.
|
||||
|
||||
## How it works
|
||||
|
||||
- Your music files are never moved or rewritten (except when you edit tags).
|
||||
- Your music files are never moved or rewritten (except when you edit tags,
|
||||
rename an artist/album, or delete a track on purpose).
|
||||
- The library lives as **plain JSON files** (`library.json` + one file per
|
||||
playlist) in a directory you choose — designed to be synced with
|
||||
[Syncthing](https://syncthing.net); sync conflicts are merged automatically
|
||||
on startup (play counts take the max, edits take the newest, playlists
|
||||
take the union).
|
||||
- Playback via Qt Multimedia/FFmpeg (mp3, m4a, flac). Media keys work
|
||||
through MPRIS. Scrobbling to last.fm is optional (Edit → Preferences).
|
||||
|
||||
playlist) in a directory you choose. Track paths are stored *relative to that
|
||||
directory*, so the library works no matter where each machine mounts it.
|
||||
- Playback via Qt Multimedia/FFmpeg. Media keys through MPRIS. Scrobbling to
|
||||
last.fm is optional (Edit → Preferences).
|
||||
|
||||
## Why
|
||||
|
||||
I have been a mac user for ~34 years. I gave up daily driving mac os in 2020. I figured Apple
|
||||
I have been a mac user for ~34 years. I gave up daily driving mac os in 2021. I got tired of my music being solely on my desktop for 5 years. So I vibe-coded this replacement of all the features I wanted from iTunes.
|
||||
|
||||
but my music library
|
||||
even some tracks I got from napster all the way back in 2000 (I have since
|
||||
paid for!!)
|
||||
## Never used a terminal? Start here
|
||||
|
||||
If the commands below look intimidating, they're friendlier than they look.
|
||||
|
||||
The **terminal** is a window where you type commands instead of clicking. On
|
||||
most Linux desktops you open it by pressing the Super key (the one with the
|
||||
Windows/Command logo) and typing "terminal". Everything in a grey box below is
|
||||
meant to be **copied and pasted** into that window — paste it, press Enter,
|
||||
done. You don't need to understand a command to run it.
|
||||
|
||||
Two bits of shorthand that show up everywhere:
|
||||
|
||||
- **`~`** means your home folder. `~/Music` is just the Music folder inside your
|
||||
home folder — the same one your file manager shows you.
|
||||
- **Your "distro"** is which flavour of Linux you're running (Ubuntu, Fedora,
|
||||
Debian, Mint…). If you don't know, it's almost certainly Ubuntu or Mint, and
|
||||
you want the **Debian/Ubuntu** instructions below.
|
||||
|
||||
If a command fails, nothing is broken — copy the error into a search engine, or
|
||||
ask whoever pointed you here. You cannot damage your music by running these.
|
||||
|
||||
## What you need
|
||||
|
||||
Linux, Python, and a handful of free add-ons. **Nothing to compile, no account
|
||||
to make, no database to set up, nothing that phones home.** LinTunes keeps your
|
||||
library as ordinary files on your own computer.
|
||||
|
||||
In detail, for anyone who wants it:
|
||||
|
||||
- **Linux** with Python **3.10 or newer** (already installed on essentially
|
||||
every Linux machine — check with `python3 --version`)
|
||||
- **PyQt6** (including its QtMultimedia part), **mutagen**, **numpy**, **requests**
|
||||
- The **FFmpeg codecs for Qt Multimedia**. Without these the window opens fine
|
||||
but nothing plays — so if you get a silent LinTunes, this is the thing that's
|
||||
missing. They come from `qt6-qtmultimedia` built with ffmpeg (Fedora: enable
|
||||
RPM Fusion; Debian/Ubuntu: it comes along with `python3-pyqt6.qtmultimedia`).
|
||||
|
||||
## Installing
|
||||
|
||||
LinTunes needs four Python packages — **PyQt6** (with its QtMultimedia module),
|
||||
**mutagen**, **numpy**, **requests** — plus the **FFmpeg codecs** for Qt
|
||||
Multimedia so audio actually plays. Nothing compiles; get the deps whichever way
|
||||
fits your distro:
|
||||
Pick whichever fits your distro:
|
||||
|
||||
- **pip** — simplest where your distro allows it; also puts a `lintunes` command
|
||||
in `~/.local/bin`:
|
||||
@@ -71,28 +132,85 @@ fits your distro:
|
||||
|
||||
**You never strictly need pip or the `lintunes` command.** As long as those four
|
||||
deps import, `python3 -m lintunes.main` from the checkout runs the app — which is
|
||||
exactly what the desktop launcher does. The FFmpeg codecs come from
|
||||
`qt6-qtmultimedia` built with ffmpeg (Fedora: RPM Fusion; Debian/Ubuntu: pulled in
|
||||
by `python3-pyqt6.qtmultimedia`). Without them the UI runs but tracks won't play.
|
||||
exactly what the desktop launcher does.
|
||||
|
||||
Below, `lintunes` and `python3 -m lintunes.main` are interchangeable — use
|
||||
whichever your install gave you.
|
||||
|
||||
## Starting from scratch (no iTunes needed)
|
||||
|
||||
LinTunes doesn't care where your music came from — a shelf of ripped CDs, a
|
||||
Bandcamp folder, twenty years of downloads. You do **not** need iTunes, and you
|
||||
don't need to have ever owned a Mac.
|
||||
|
||||
Tell it where to keep the library, once:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/Music/lintunes # the library lives here
|
||||
lintunes --data-dir ~/Music/lintunes --save-config # remembers it from now on
|
||||
```
|
||||
|
||||
Then just run `lintunes`. The first time it opens, it asks one question — where
|
||||
to keep your music — and suggests `~/Music`. Pick a folder, or click **Not
|
||||
Now** and set it later in **Edit ▸ Preferences**.
|
||||
|
||||
Now add music: **File ▸ Add Files to Library** (Ctrl+O), or drag files and
|
||||
folders straight onto the window. Whole folders are fine — LinTunes looks
|
||||
inside them, reads each song's tags, and skips anything already in your library.
|
||||
|
||||
Your songs are **copied** into the folder you chose and filed by artist and
|
||||
album, iTunes-style:
|
||||
|
||||
```
|
||||
~/Music/Neko Case/Fox Confessor Brings the Flood/Star Witness.mp3
|
||||
```
|
||||
|
||||
The originals are left exactly where they were, so nothing is lost if you
|
||||
change your mind. From there, make playlists and go.
|
||||
|
||||
## Importing an existing iTunes library
|
||||
|
||||
In iTunes 12.x: **File ▸ Library ▸ Export Library** to get the XML, then:
|
||||
|
||||
```sh
|
||||
lintunes --import-xml "iTunes Library.xml" \
|
||||
--music-root "/path/to/iTunes Media" \
|
||||
--data-dir /path/to/library-data --save-config
|
||||
lintunes # from now on, just run it
|
||||
```
|
||||
|
||||
This brings across your tracks, playlists, playlist folders and smart playlists
|
||||
(auto-updating and still editable), along with play counts, ratings and dates.
|
||||
Mac paths are remapped to `--music-root` automatically, including
|
||||
case/accent differences. Anything it couldn't find is written to
|
||||
`import_missing_files.txt` in your data dir. Untested on iTunes versions other
|
||||
than 12.
|
||||
|
||||
## Running it
|
||||
|
||||
```sh
|
||||
# one-time import from iTunes (XML: File ▸ Library ▸ Export Library in iTunes 12.x)
|
||||
python3 -m lintunes.main --import-xml "iTunes Library.xml" \
|
||||
--music-root "/path/to/iTunes Media" \
|
||||
--data-dir /path/to/library-data --save-config
|
||||
python3 -m lintunes.main # run the app (uses the saved config)
|
||||
lintunes # uses the saved config
|
||||
```
|
||||
|
||||
(Installed with pip? Type `lintunes` instead of `python3 -m lintunes.main`.)
|
||||
LinTunes looks best with **Century Gothic** installed
|
||||
(drop the font in `~/.local/share/fonts/`); if it's missing, the first launch
|
||||
asks you to pick a substitute or use the default.
|
||||
|
||||
LinTunes looks best with **Century Gothic** installed (`~/.local/share/fonts/`);
|
||||
if it's missing, you'll be asked to pick a font on first run.
|
||||
The desktop launcher runs LinTunes with no arguments, so it always uses whatever
|
||||
is saved in `~/.config/lintunes/config.json` — point that config wherever you
|
||||
want and the dock icon follows.
|
||||
|
||||
The desktop launcher runs LinTunes with no `--data-dir`, so it always uses
|
||||
whatever is saved in `~/.config/lintunes/config.json` — point that config wherever
|
||||
you want and the dock icon follows.
|
||||
The now-playing panel uses **Century Gothic** if you have it and quietly picks
|
||||
the closest match installed if you don't — it never interrupts you to ask. To
|
||||
choose a different one, see **Edit ▸ Preferences ▸ Now-playing font**.
|
||||
|
||||
## Keys
|
||||
|
||||
Space play/pause · ←/→ previous/next · Ctrl+B column browser ·
|
||||
Ctrl+I get info · Ctrl+L go to current song · Ctrl+O add files ·
|
||||
Ctrl+N new playlist (Ctrl+Alt+N smart, Ctrl+Shift+N folder) ·
|
||||
Ctrl+Z undo · Ctrl+, preferences · Ctrl+C/Ctrl+X/Ctrl+V copy/cut/paste tracks ·
|
||||
double-click sidebar art for a big art window
|
||||
|
||||
## Running on a second machine (Syncthing)
|
||||
|
||||
@@ -112,23 +230,15 @@ cd lintunes
|
||||
|
||||
# 2. let Syncthing finish replicating the music folder, then point the config at
|
||||
# THIS machine's paths and launch — no re-import, the data is already synced:
|
||||
python3 -m lintunes.main --data-dir "/path/to/synced/music/lintunes" \
|
||||
--music-root "/path/to/synced/music/iTunes Media" \
|
||||
--save-config
|
||||
python3 -m lintunes.main --data-dir "/path/to/synced/music/lintunes" --save-config
|
||||
|
||||
# 3. install the launcher + icon, then pin it (see "App icon" below)
|
||||
bash packaging/install-desktop.sh
|
||||
```
|
||||
|
||||
(Step 2 just writes `data_dir` + `music_root` into `~/.config/lintunes/config.json`
|
||||
— you can also create that file by hand. After it's saved, every launch, including
|
||||
the dock icon, uses the synced library automatically.)
|
||||
|
||||
## Keys
|
||||
|
||||
Space play/pause · ←/→ previous/next · Ctrl+B column browser ·
|
||||
Ctrl+I get info · Ctrl+, preferences · Ctrl+C/Ctrl+X/Ctrl+V copy/cut/paste tracks ·
|
||||
double-click sidebar art for a big art window
|
||||
(Step 2 just writes `data_dir` into `~/.config/lintunes/config.json` — you can
|
||||
also create that file by hand. After it's saved, every launch, including the
|
||||
dock icon, uses the synced library automatically.)
|
||||
|
||||
## App icon
|
||||
|
||||
|
||||
@@ -9,6 +9,27 @@ When a round closes, move its finished items to `tasks-done.md`.
|
||||
|
||||
- [ ] archive the done tasks in here to another file, this is crufty....
|
||||
|
||||
## Round 40 (2026-08-22) — welcoming the newcomers: done, see tasks-done.md
|
||||
|
||||
No startup font modal, a first-run music-folder question, and a portable
|
||||
`music_folder`. Left on the table:
|
||||
|
||||
- [ ] **Offer to move the files when the music folder changes.** Today changing
|
||||
it warns that existing songs stay put and only new additions go to the new
|
||||
folder — honest, but someone genuinely relocating a library has to move it
|
||||
by hand. Constraints already worked out, so this needn't be re-derived:
|
||||
`_move_file` uses `Path.rename`, which is **same-filesystem only**, so a
|
||||
move to another drive needs a chunked copy + unlink (never `shutil.move`
|
||||
in one shot — no progress, no cancel point); every moved track's
|
||||
`location` must be repointed through `LibraryManager`, and its
|
||||
`date_modified` bumped or `_merge_track_fields` won't carry the new path
|
||||
across a sync; files **outside** the organize root must not be touched;
|
||||
and it wants the `plan_export()`-then-worker split from
|
||||
`export/exporter.py` so it can show progress and be cancelled. Not
|
||||
undoable — an `UndoStack` command is a synchronous closure, and a 21k-file
|
||||
move can't run on the GUI thread. Tabled 2026-08-22: trav has no plans to
|
||||
move his own library, so this was not worth the round.
|
||||
|
||||
## Round 37 (2026-08-19) — Export Playlist: done, see tasks-done.md
|
||||
|
||||
Folder + web-mix export, audio.js/jQuery dropped for a dependency-free
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.9.1"
|
||||
__version__ = "0.10.0"
|
||||
|
||||
+111
-9
@@ -9,7 +9,7 @@ from PyQt6.QtWidgets import (
|
||||
from PyQt6.QtCore import Qt, QEvent, QTimer
|
||||
from PyQt6.QtGui import QAction, QKeySequence
|
||||
|
||||
from lintunes import device_sync, mpris, tagging, theme
|
||||
from lintunes import device_sync, mpris, music_folder, tagging, theme
|
||||
from lintunes.art_search import AlbumArtFetcher
|
||||
from lintunes.cast.controller import CastController
|
||||
from lintunes.eventlog import log_control
|
||||
@@ -25,6 +25,7 @@ from lintunes.gui.library_view import LibraryView
|
||||
from lintunes.gui.playlist_view import PlaylistView
|
||||
from lintunes.gui.transport import TransportBar
|
||||
from lintunes.gui.info_dialog import InfoDialog
|
||||
from lintunes.gui.music_folder_dialog import MusicFolderDialog
|
||||
from lintunes.gui.preferences_dialog import PreferencesDialog
|
||||
from lintunes.gui.track_table import format_total_time
|
||||
from lintunes.gui.version_button import VersionButton
|
||||
@@ -126,6 +127,7 @@ class MainWindow(QMainWindow):
|
||||
# stretch would squeeze the message area to zero width and swallow
|
||||
# every message.
|
||||
self.restart_requested = False # run_gui re-execs when True on quit
|
||||
self._missing_folder_prompted = False
|
||||
self._updater = Updater(self)
|
||||
self._version_button = VersionButton(self._updater)
|
||||
self.statusBar().addWidget(self._version_button)
|
||||
@@ -261,7 +263,12 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def _show_preferences(self):
|
||||
if self._prefs_dialog is None:
|
||||
self._prefs_dialog = PreferencesDialog(self._prefs, self._lastfm, self)
|
||||
self._prefs_dialog = PreferencesDialog(
|
||||
self._prefs, self._lastfm, self,
|
||||
music_folder=self._music_folder_summary,
|
||||
on_music_folder_change=self._change_music_folder)
|
||||
else:
|
||||
self._prefs_dialog.refresh_music_folder()
|
||||
self._prefs_dialog.show()
|
||||
self._prefs_dialog.raise_()
|
||||
self._prefs_dialog.activateWindow()
|
||||
@@ -554,8 +561,10 @@ class MainWindow(QMainWindow):
|
||||
return
|
||||
details = web_dialog.values()
|
||||
|
||||
start_dir = self._music_import_dir()
|
||||
parent_dir = QFileDialog.getExistingDirectory(
|
||||
self, "Export To", str(self._music_import_dir().parent))
|
||||
self, "Export To",
|
||||
str(start_dir.parent) if start_dir else str(Path.home()))
|
||||
if not parent_dir:
|
||||
return
|
||||
|
||||
@@ -777,12 +786,94 @@ class MainWindow(QMainWindow):
|
||||
ids = view.table.selected_track_ids()
|
||||
self._show_info(ids)
|
||||
|
||||
# ---- music folder ----
|
||||
|
||||
def check_music_folder(self):
|
||||
"""Ask where the music goes, if we don't already know.
|
||||
|
||||
Called once shortly after the window appears, and again lazily if an
|
||||
import happens while no folder is set. Never blocks startup: by the
|
||||
time this runs the main window is already up, so the dialog is a normal
|
||||
child dialog rather than a popup in front of an empty screen.
|
||||
"""
|
||||
state = self._manager.music_folder_state()
|
||||
if state.path is not None:
|
||||
return
|
||||
if state.source == "unset":
|
||||
if music_folder.prompt_seen():
|
||||
return
|
||||
elif self._missing_folder_prompted:
|
||||
return # asked once this session; don't nag on every import
|
||||
|
||||
dialog = MusicFolderDialog(
|
||||
self, missing_path=state.stored if state.source == "missing" else "")
|
||||
dialog.exec()
|
||||
chosen = dialog.chosen_folder()
|
||||
|
||||
# Remember we asked, whatever the answer — including "Not Now".
|
||||
music_folder.mark_prompt_seen()
|
||||
self._missing_folder_prompted = True
|
||||
|
||||
if chosen is not None:
|
||||
# A folder that exists elsewhere but not here is this machine's own
|
||||
# problem to solve; don't overwrite what the other machine set.
|
||||
self._manager.set_music_folder(
|
||||
chosen, machine_only=(state.source == "missing"))
|
||||
self.statusBar().showMessage(
|
||||
f"Music folder set to {self._manager.organize_root()}", 6000)
|
||||
|
||||
def _change_music_folder(self):
|
||||
"""Preferences ▸ Music folder ▸ Change… — pick a new one."""
|
||||
state = self._manager.music_folder_state()
|
||||
start = str(state.path) if state.path else str(Path.home())
|
||||
chosen = QFileDialog.getExistingDirectory(
|
||||
self, "Choose Music Folder", start)
|
||||
if not chosen:
|
||||
return
|
||||
|
||||
media = music_folder.normalize_media_folder(chosen)
|
||||
old_root = self._manager.organize_root()
|
||||
if old_root is not None and old_root.exists() and \
|
||||
media != Path(state.path or ""):
|
||||
# Say the quiet part out loud: nothing moves, and from here on only
|
||||
# new music goes to the new place. Otherwise the change looks total
|
||||
# but silently applies to future additions only.
|
||||
answer = QMessageBox.question(
|
||||
self, "Change music folder?",
|
||||
f"Your existing songs will stay where they are:\n{old_root}\n\n"
|
||||
f"Only music you add from now on will go to:\n"
|
||||
f"{music_folder.organize_root_for(media)}\n\n"
|
||||
"Change the folder?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No)
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
self._manager.set_music_folder(
|
||||
media, machine_only=state.is_machine_local)
|
||||
self.statusBar().showMessage(
|
||||
f"Music folder set to {self._manager.organize_root()}", 6000)
|
||||
|
||||
def _music_folder_summary(self) -> tuple[str, str]:
|
||||
"""(folder, note) for the Preferences row."""
|
||||
state = self._manager.music_folder_state()
|
||||
if state.path is None:
|
||||
return ("", "Not set yet — pick a folder to start adding music.")
|
||||
if state.is_machine_local:
|
||||
return (str(state.path), "Used on this computer only.")
|
||||
return (str(state.path), "Shared with your other computers.")
|
||||
|
||||
# ---- file import ----
|
||||
|
||||
def _music_import_dir(self) -> Path:
|
||||
# Fall back to ./Music when no music folder is set (fresh library),
|
||||
# matching the pre-organize_root behavior.
|
||||
return self._manager.organize_root() or Path("Music")
|
||||
def _music_import_dir(self) -> Path | None:
|
||||
"""Where imports are filed, or None when no music folder is usable.
|
||||
|
||||
Deliberately no fallback. The old `or Path("Music")` was *relative*, so
|
||||
it resolved against the process working directory — which GNOME's dash
|
||||
does not set predictably (see packaging/install-desktop.sh), scattering
|
||||
a new user's music somewhere they'd never find it.
|
||||
"""
|
||||
return self._manager.organize_root()
|
||||
|
||||
def _open_files_dialog(self):
|
||||
paths, _filter = QFileDialog.getOpenFileNames(
|
||||
@@ -793,9 +884,20 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def import_files(self, paths, playlist_pid: str = "",
|
||||
position: int | None = None):
|
||||
music_dir = self._music_import_dir()
|
||||
if music_dir is None:
|
||||
# Ask rather than guess — better one question than music copied
|
||||
# somewhere unfindable.
|
||||
self.check_music_folder()
|
||||
music_dir = self._music_import_dir()
|
||||
if music_dir is None:
|
||||
self.statusBar().showMessage(
|
||||
"Choose where to keep your music first — Edit ▸ Preferences.",
|
||||
8000)
|
||||
return []
|
||||
self.statusBar().showMessage(f"Importing {len(paths)} item(s)…")
|
||||
imported = file_importer.import_paths(
|
||||
paths, self._music_import_dir(), self._manager, playlist_pid,
|
||||
paths, music_dir, self._manager, playlist_pid,
|
||||
position)
|
||||
self.statusBar().showMessage(f"Imported {len(imported)} track(s)", 5000)
|
||||
if imported:
|
||||
@@ -981,7 +1083,7 @@ class MainWindow(QMainWindow):
|
||||
if p.exists():
|
||||
return str(p)
|
||||
music = self._music_import_dir()
|
||||
return str(music) if music.exists() else str(Path.home())
|
||||
return str(music) if music and music.exists() else str(Path.home())
|
||||
|
||||
def _on_track_missing(self, track):
|
||||
"""A track's file is gone (moved/renamed outside lintunes). Offer to
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Ask, in plain language, where the music should live.
|
||||
|
||||
Two variants of the same dialog:
|
||||
|
||||
* **unset** — a brand-new library. Shown once per machine, right after the main
|
||||
window appears, and answering is entirely optional.
|
||||
* **missing** — the folder on file isn't on this computer (an unplugged drive,
|
||||
or a Syncthing share laid out differently here). Whatever the user picks is
|
||||
recorded for *this* machine only, so the other one is left alone.
|
||||
|
||||
Wording is deliberately jargon-free: no "path", no "directory", no "root".
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
|
||||
QFileDialog, QDialogButtonBox)
|
||||
|
||||
from lintunes import music_folder
|
||||
|
||||
|
||||
class MusicFolderDialog(QDialog):
|
||||
"""Returns the chosen folder via ``chosen_folder()``, or None if skipped."""
|
||||
|
||||
def __init__(self, parent=None, *, missing_path: str = ""):
|
||||
super().__init__(parent)
|
||||
self._missing = bool(missing_path)
|
||||
self.setWindowTitle("Your music folder" if self._missing
|
||||
else "Welcome to LinTunes")
|
||||
self.setModal(True)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(10)
|
||||
|
||||
if self._missing:
|
||||
headline = "LinTunes can't find your music folder on this computer."
|
||||
body = (f"It's set to:\n{missing_path}\n\n"
|
||||
"That folder isn't here right now — the drive may be "
|
||||
"unplugged, or this computer may keep its music somewhere "
|
||||
"else. Pick where the music lives on this computer.\n\n"
|
||||
"Your other computer keeps its own setting, so this only "
|
||||
"changes things here.")
|
||||
else:
|
||||
headline = "Where should LinTunes keep your music?"
|
||||
body = ("When you add songs, LinTunes copies them into one folder "
|
||||
"and files them away by artist and album — so your music "
|
||||
"stays tidy no matter where it came from.\n\n"
|
||||
"You can change this later in Preferences.")
|
||||
|
||||
title = QLabel(headline)
|
||||
title.setStyleSheet("font-weight: bold;")
|
||||
title.setWordWrap(True)
|
||||
layout.addWidget(title)
|
||||
|
||||
explain = QLabel(body)
|
||||
explain.setWordWrap(True)
|
||||
layout.addWidget(explain)
|
||||
|
||||
row = QHBoxLayout()
|
||||
self._folder = QLineEdit(str(Path.home() / "Music"))
|
||||
self._folder.setReadOnly(True)
|
||||
row.addWidget(self._folder, stretch=1)
|
||||
browse = QPushButton("Browse…")
|
||||
browse.clicked.connect(self._choose)
|
||||
row.addWidget(browse)
|
||||
layout.addLayout(row)
|
||||
|
||||
# Spell out the actual result, so nobody has to guess what the
|
||||
# <folder>/Music layout does to the folder they picked.
|
||||
self._preview = QLabel()
|
||||
self._preview.setWordWrap(True)
|
||||
self._preview.setTextFormat(Qt.TextFormat.PlainText)
|
||||
layout.addWidget(self._preview)
|
||||
|
||||
buttons = QDialogButtonBox()
|
||||
self._use = buttons.addButton("Use This Folder",
|
||||
QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
buttons.addButton("Not Now", QDialogButtonBox.ButtonRole.RejectRole)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
self._refresh_preview()
|
||||
|
||||
# ---- internals ----
|
||||
|
||||
def _choose(self):
|
||||
start = self._folder.text() or str(Path.home())
|
||||
chosen = QFileDialog.getExistingDirectory(
|
||||
self, "Choose Music Folder", start)
|
||||
if chosen:
|
||||
self._folder.setText(chosen)
|
||||
self._refresh_preview()
|
||||
|
||||
def _refresh_preview(self):
|
||||
text = self._folder.text()
|
||||
if not text:
|
||||
self._preview.setText("")
|
||||
return
|
||||
root = music_folder.organize_root_for(
|
||||
music_folder.normalize_media_folder(text))
|
||||
self._preview.setText(f"Songs will be filed in {root}/Artist/Album")
|
||||
|
||||
# ---- result ----
|
||||
|
||||
def chosen_folder(self) -> Path | None:
|
||||
"""The folder the user accepted, or None if they skipped."""
|
||||
if self.result() != QDialog.DialogCode.Accepted:
|
||||
return None
|
||||
text = self._folder.text()
|
||||
return Path(text) if text else None
|
||||
@@ -1,10 +1,10 @@
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, QSlider,
|
||||
QRadioButton, QButtonGroup, QLineEdit, QPushButton, QCheckBox,
|
||||
QFormLayout,
|
||||
QFormLayout, QFontComboBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtGui import QPixmap, QColor, QIcon
|
||||
from PyQt6.QtGui import QPixmap, QColor, QIcon, QFont
|
||||
|
||||
from lintunes import theme
|
||||
from lintunes.theme import HIGHLIGHT_COLORS
|
||||
@@ -30,10 +30,21 @@ def _swatch(color_hex: str) -> QIcon:
|
||||
|
||||
|
||||
class PreferencesDialog(QDialog):
|
||||
def __init__(self, prefs, lastfm, parent=None):
|
||||
"""App preferences. Applies immediately — there is no OK/Cancel.
|
||||
|
||||
``music_folder``/``on_music_folder_change`` are optional callbacks rather
|
||||
than a LibraryManager reference, keeping this dialog manager-free (the same
|
||||
injection style as track_table's ``playlists_for_track``). When they are
|
||||
omitted the music-folder row is simply left out.
|
||||
"""
|
||||
|
||||
def __init__(self, prefs, lastfm, parent=None, *,
|
||||
music_folder=None, on_music_folder_change=None):
|
||||
super().__init__(parent)
|
||||
self._prefs = prefs
|
||||
self._lastfm = lastfm
|
||||
self._music_folder = music_folder
|
||||
self._on_music_folder_change = on_music_folder_change
|
||||
self.setWindowTitle("Preferences")
|
||||
self.setMinimumWidth(420)
|
||||
|
||||
@@ -46,6 +57,8 @@ class PreferencesDialog(QDialog):
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self._build_general_group())
|
||||
if self._music_folder is not None:
|
||||
layout.addWidget(self._build_music_group())
|
||||
layout.addWidget(self._build_colors_group())
|
||||
layout.addWidget(self._build_lastfm_group())
|
||||
layout.addStretch()
|
||||
@@ -93,8 +106,92 @@ class PreferencesDialog(QDialog):
|
||||
color_row.addWidget(radio)
|
||||
color_row.addStretch()
|
||||
form.addRow("Highlight:", color_row)
|
||||
|
||||
# Now-playing font. Three states, so three radios: automatic (None),
|
||||
# the app's own font (""), or an explicit family.
|
||||
font_row = QHBoxLayout()
|
||||
self._font_mode = QButtonGroup(self)
|
||||
self._font_auto = QRadioButton("Automatic")
|
||||
self._font_auto.setToolTip(
|
||||
"Century Gothic if you have it, otherwise the closest match installed")
|
||||
self._font_default = QRadioButton("Same as the app")
|
||||
self._font_pick = QRadioButton("Choose:")
|
||||
for button in (self._font_auto, self._font_default, self._font_pick):
|
||||
self._font_mode.addButton(button)
|
||||
font_row.addWidget(button)
|
||||
self._font_combo = QFontComboBox()
|
||||
font_row.addWidget(self._font_combo, stretch=1)
|
||||
|
||||
# State before signals, or building the dialog would write a pref.
|
||||
saved = self._prefs.get("now_playing_font")
|
||||
resolved = theme.now_playing_font_family(self._prefs) or self.font().family()
|
||||
self._font_combo.setCurrentFont(QFont(resolved))
|
||||
self._font_combo.setEnabled(bool(saved))
|
||||
(self._font_default if saved == ""
|
||||
else self._font_pick if saved
|
||||
else self._font_auto).setChecked(True)
|
||||
|
||||
self._font_auto.toggled.connect(
|
||||
lambda on: on and self._set_font_mode(None))
|
||||
self._font_default.toggled.connect(
|
||||
lambda on: on and self._set_font_mode(""))
|
||||
self._font_pick.toggled.connect(
|
||||
lambda on: on and self._set_font_mode(self._font_combo.currentFont().family()))
|
||||
self._font_combo.currentFontChanged.connect(self._on_font_chosen)
|
||||
form.addRow("Now-playing font:", font_row)
|
||||
return group
|
||||
|
||||
def _set_font_mode(self, value):
|
||||
self._font_combo.setEnabled(value not in (None, ""))
|
||||
self._prefs.set("now_playing_font", value)
|
||||
if value is None:
|
||||
# Show what "automatic" actually landed on.
|
||||
resolved = theme.now_playing_font_family(self._prefs)
|
||||
if resolved:
|
||||
self._font_combo.blockSignals(True)
|
||||
self._font_combo.setCurrentFont(QFont(resolved))
|
||||
self._font_combo.blockSignals(False)
|
||||
|
||||
def _on_font_chosen(self, font):
|
||||
if self._font_pick.isChecked():
|
||||
self._prefs.set("now_playing_font", font.family())
|
||||
|
||||
# ---- music folder ----
|
||||
|
||||
def _build_music_group(self) -> QGroupBox:
|
||||
group = QGroupBox("Music folder")
|
||||
form = QFormLayout(group)
|
||||
|
||||
row = QHBoxLayout()
|
||||
self._music_edit = QLineEdit()
|
||||
self._music_edit.setReadOnly(True)
|
||||
row.addWidget(self._music_edit, stretch=1)
|
||||
change = QPushButton("Change…")
|
||||
change.clicked.connect(self._change_music_folder)
|
||||
row.addWidget(change)
|
||||
form.addRow("Keep music in:", row)
|
||||
|
||||
self._music_note = QLabel()
|
||||
self._music_note.setWordWrap(True)
|
||||
form.addRow("", self._music_note)
|
||||
|
||||
self.refresh_music_folder()
|
||||
return group
|
||||
|
||||
def refresh_music_folder(self):
|
||||
"""Repopulate the row from the injected getter."""
|
||||
if self._music_folder is None or not hasattr(self, "_music_edit"):
|
||||
return
|
||||
folder, note = self._music_folder()
|
||||
self._music_edit.setText(folder)
|
||||
self._music_edit.setPlaceholderText("Not set")
|
||||
self._music_note.setText(note)
|
||||
|
||||
def _change_music_folder(self):
|
||||
if self._on_music_folder_change is not None:
|
||||
self._on_music_folder_change()
|
||||
self.refresh_music_folder()
|
||||
|
||||
def _on_size_changed(self, value: int):
|
||||
scale = _SCALES[value]
|
||||
self._size_label.setText(scale.capitalize())
|
||||
|
||||
@@ -7,9 +7,11 @@ from PyQt6.QtCore import QObject, QTimer, pyqtSignal
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from lintunes import music_folder as music_folder_mod
|
||||
from lintunes import smart, tagging, trash
|
||||
from lintunes.importers.file_importer import organized_destination, unique_path
|
||||
from lintunes.models import Library, Playlist, PlaylistType
|
||||
from lintunes.paths import to_relative
|
||||
from lintunes.perf import timed
|
||||
from lintunes.storage import json_storage
|
||||
from lintunes.storage.play_journal import PlayJournal
|
||||
@@ -42,6 +44,7 @@ class LibraryManager(QObject):
|
||||
file_move_failed = pyqtSignal(str, str) # track name, error text
|
||||
tracks_removed = pyqtSignal(list) # track ids gone from the library
|
||||
tracks_restored = pyqtSignal(list) # track ids back after an undo
|
||||
music_folder_changed = pyqtSignal(str) # new media folder ('' if none)
|
||||
|
||||
def __init__(self, library: Library, data_dir: Path, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -49,6 +52,12 @@ class LibraryManager(QObject):
|
||||
self.data_dir = Path(data_dir)
|
||||
self.undo_stack = UndoStack(parent=self)
|
||||
|
||||
# Resolved once and cached: organize_root() is consulted on every tag
|
||||
# edit, and resolution stats the filesystem.
|
||||
self._music_folder: Path | None = None
|
||||
self._music_folder_source = "unset"
|
||||
self.refresh_music_folder()
|
||||
|
||||
self._dirty_tracks = False
|
||||
self._dirty_metadata = False
|
||||
self._dirty_playlists: set[str] = set()
|
||||
@@ -626,12 +635,76 @@ class LibraryManager(QObject):
|
||||
|
||||
_ORGANIZE_FIELDS = frozenset({"artist", "album_artist", "album"})
|
||||
|
||||
# ---- music folder ----
|
||||
|
||||
def refresh_music_folder(self):
|
||||
"""Re-resolve which media folder applies on this machine."""
|
||||
state = music_folder_mod.resolve(self.library, self.data_dir)
|
||||
self._music_folder = state.path
|
||||
self._music_folder_source = state.source
|
||||
|
||||
def music_folder_state(self):
|
||||
"""Full resolution (path + where it came from), for the GUI."""
|
||||
return music_folder_mod.resolve(self.library, self.data_dir)
|
||||
|
||||
def _should_adopt_music_folder(self, disk) -> bool:
|
||||
"""Whether a synced-in library_metadata.json may replace our setting.
|
||||
|
||||
The dirty flag only protects us until the next flush; after that a
|
||||
*stale* metadata file arriving from the other machine would silently
|
||||
revert a deliberate change. So compare the semantic stamp instead of
|
||||
the file's mtime, which moves for cosmetic reasons (the Round 39
|
||||
lesson, applied to metadata).
|
||||
"""
|
||||
ours = self.library.music_folder_set_at
|
||||
if not ours:
|
||||
# Legacy library, or never set here — keep the old behavior of
|
||||
# trusting disk, so nothing regresses for existing libraries.
|
||||
return True
|
||||
theirs = disk.music_folder_set_at
|
||||
return bool(theirs) and theirs >= ours
|
||||
|
||||
def organize_root(self) -> Path | None:
|
||||
"""Root of the organized music tree (<music_folder>/Music) — the only
|
||||
place imports copy into and rename-moves manage."""
|
||||
if not self.library.music_folder:
|
||||
place imports copy into and rename-moves manage.
|
||||
|
||||
None when no music folder is set *or* the one on file isn't present on
|
||||
this machine (an unmounted drive). Callers must handle None rather than
|
||||
inventing a path: a relative fallback would resolve against the process
|
||||
working directory, which GNOME's dash does not set predictably.
|
||||
"""
|
||||
if self._music_folder is None:
|
||||
return None
|
||||
return Path(self.library.music_folder) / "Music"
|
||||
return self._music_folder / "Music"
|
||||
|
||||
def set_music_folder(self, chosen, *, machine_only: bool = False) -> Path:
|
||||
"""Point the library at a media folder. Returns the stored folder.
|
||||
|
||||
``machine_only`` writes just this machine's override in config.json and
|
||||
leaves the synced library metadata untouched — used when the shared
|
||||
value is real but simply isn't reachable here, so one machine can never
|
||||
clobber another's setting.
|
||||
"""
|
||||
media = music_folder_mod.normalize_media_folder(chosen)
|
||||
if machine_only:
|
||||
music_folder_mod.set_machine_override(str(media))
|
||||
else:
|
||||
# A stale override would otherwise keep shadowing the new shared
|
||||
# value on this machine.
|
||||
music_folder_mod.set_machine_override(None)
|
||||
self.library.music_folder = str(media)
|
||||
self.library.music_folder_rel = to_relative(str(media), self.data_dir)
|
||||
self.library.music_folder_set_at = _utc_now_iso()
|
||||
# Mandatory: without the dirty mark, reload_from_disk's
|
||||
# `if not self._dirty_metadata` branch silently reverts this the
|
||||
# next time the sync watcher fires. Flushing immediately shrinks
|
||||
# that window to nothing — dirty means protected, flushed means
|
||||
# disk already agrees with us.
|
||||
self.mark_library_settings_dirty()
|
||||
self.flush()
|
||||
self.refresh_music_folder()
|
||||
self.music_folder_changed.emit(str(self._music_folder or ""))
|
||||
return media
|
||||
|
||||
def _maybe_move_file(self, track, changed: dict) -> str | None:
|
||||
"""If an artist/album_artist/album edit changes where the file belongs
|
||||
@@ -935,9 +1008,13 @@ class LibraryManager(QObject):
|
||||
self.library.playlists.pop(pid, None)
|
||||
|
||||
if not self._dirty_metadata:
|
||||
self.library.music_folder = disk.music_folder
|
||||
if self._should_adopt_music_folder(disk):
|
||||
self.library.music_folder = disk.music_folder
|
||||
self.library.music_folder_rel = disk.music_folder_rel
|
||||
self.library.music_folder_set_at = disk.music_folder_set_at
|
||||
self.library.import_date = disk.import_date
|
||||
self.library.library_settings = disk.library_settings
|
||||
self.refresh_music_folder()
|
||||
|
||||
self._max_track_id = max(self.library.tracks.keys(), default=0)
|
||||
|
||||
|
||||
+12
-43
@@ -86,6 +86,14 @@ def run_import(xml_path: Path, music_root: str | None, data_dir: Path):
|
||||
library, report = import_itunes_xml(xml_path, Path(music_root))
|
||||
print(report.summary())
|
||||
|
||||
# The importer has no data dir, so the portable spelling of the music
|
||||
# folder is filled in here, where we do.
|
||||
from datetime import datetime, timezone
|
||||
from lintunes.paths import to_relative
|
||||
library.music_folder_rel = to_relative(library.music_folder, data_dir)
|
||||
library.music_folder_set_at = (
|
||||
datetime.now(timezone.utc).replace(tzinfo=None).isoformat())
|
||||
|
||||
if report.missing_files:
|
||||
missing_log = data_dir / "import_missing_files.txt"
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -112,7 +120,7 @@ def run_gui(data_dir: Path, files: list[Path],
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
from PyQt6.QtCore import QTimer
|
||||
from PyQt6.QtWidgets import QApplication, QMessageBox, QFontDialog
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
from lintunes import theme
|
||||
from lintunes.storage.json_storage import load_library
|
||||
@@ -138,7 +146,6 @@ def run_gui(data_dir: Path, files: list[Path],
|
||||
|
||||
prefs = Preferences(data_dir)
|
||||
theme.apply_theme(app, prefs)
|
||||
_ensure_now_playing_font(prefs)
|
||||
manager = LibraryManager(library, data_dir)
|
||||
lastfm = LastFm(prefs, data_dir)
|
||||
window = MainWindow(manager, prefs, lastfm)
|
||||
@@ -155,6 +162,9 @@ def run_gui(data_dir: Path, files: list[Path],
|
||||
watcher.changed.connect(manager.check_for_external_changes)
|
||||
|
||||
window.show()
|
||||
# Ask where the music goes only once the window is up, so a new user is
|
||||
# never met by a dialog floating in front of nothing.
|
||||
QTimer.singleShot(0, window.check_music_folder)
|
||||
# Rebuild smart-playlist membership from criteria now that the full library
|
||||
# (and any merged sync conflicts) is loaded. Deferred until after the first
|
||||
# paint: it's a full pass over every track per smart playlist, and running
|
||||
@@ -170,46 +180,5 @@ def run_gui(data_dir: Path, files: list[Path],
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
def _ensure_now_playing_font(prefs):
|
||||
"""Prompt for a now-playing font only when needed, then persist the choice.
|
||||
|
||||
Stays silent when Century Gothic is installed (and the user hasn't picked a
|
||||
substitute), or when the user has already chosen "use default". Prompts
|
||||
when the desired family is missing, offering "Pick a font…" (QFontDialog)
|
||||
or "Use default". Canceling the picker leaves prefs untouched so it
|
||||
re-prompts next launch (lets the user install Century Gothic and retry).
|
||||
"""
|
||||
from PyQt6.QtWidgets import QMessageBox, QFontDialog
|
||||
|
||||
from lintunes import theme
|
||||
desired = theme.now_playing_font_family(prefs)
|
||||
if desired is None and prefs.get("now_playing_font") == "":
|
||||
return # user already chose the app default
|
||||
if desired is not None:
|
||||
return # resolved to an installed family (CG or a saved substitute)
|
||||
|
||||
saved = prefs.get("now_playing_font")
|
||||
if saved is None:
|
||||
msg = ("LinTunes looks best with Century Gothic, which isn't installed. "
|
||||
"Install it (~/.local/share/fonts/) and restart, or pick a font "
|
||||
"to use for the now-playing panel now.")
|
||||
else:
|
||||
msg = (f"The font saved for the now-playing panel ({saved}) is no "
|
||||
"longer installed. Pick a different font, or use the app default.")
|
||||
box = QMessageBox(QMessageBox.Icon.Warning, "Now-playing font", msg)
|
||||
pick = box.addButton("Pick a font…", QMessageBox.ButtonRole.AcceptRole)
|
||||
default = box.addButton("Use default", QMessageBox.ButtonRole.RejectRole)
|
||||
box.exec()
|
||||
if box.clickedButton() is default:
|
||||
prefs.set("now_playing_font", "")
|
||||
return
|
||||
if box.clickedButton() is not pick:
|
||||
return # closed via X / escape → leave untouched, re-prompt next run
|
||||
font, ok = QFontDialog.getFont()
|
||||
if not ok or font.family() in ("", None):
|
||||
return
|
||||
prefs.set("now_playing_font", font.family())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -9,7 +9,20 @@ from .playlist import Playlist, PlaylistSettings
|
||||
class Library:
|
||||
tracks: dict[int, Track] = field(default_factory=dict) # track_id -> Track
|
||||
playlists: dict[str, Playlist] = field(default_factory=dict) # persistent_id -> Playlist
|
||||
# Where the organized music tree lives. Three fields, because the value has
|
||||
# to survive both an older LinTunes reading this library and a Syncthing
|
||||
# merge that picks files by mtime:
|
||||
# music_folder absolute — what pre-0.10 code reads; kept forever as
|
||||
# the shared floor between versions
|
||||
# music_folder_rel relative to the data dir — the portable one, so a
|
||||
# synced library resolves wherever each machine mounts
|
||||
# it (same trick as Track.location, see paths.py)
|
||||
# music_folder_set_at ISO UTC stamp of the last deliberate change, so a
|
||||
# merge can prefer the newest *setting* rather than
|
||||
# the newest *file* (mtime moves for cosmetic reasons)
|
||||
music_folder: str = ""
|
||||
music_folder_rel: str = ""
|
||||
music_folder_set_at: Optional[str] = None
|
||||
import_date: Optional[str] = None
|
||||
# Column/sort settings for the all-tracks Library view
|
||||
library_settings: PlaylistSettings = field(
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Where the organized music tree lives, on *this* machine.
|
||||
|
||||
The library stores the music folder three ways (see `models/library.py`), and
|
||||
which one applies depends on the machine you are sitting at. `resolve()` is the
|
||||
single place that decides, in this order:
|
||||
|
||||
1. ``music_folder_rel`` resolved against the data dir — the portable value.
|
||||
Both the music and the data dir ride one Syncthing share, so the *shape*
|
||||
("the media folder is one level up from my data dir") is identical on every
|
||||
machine even when the absolute paths are not.
|
||||
2. ``music_folder`` — the legacy absolute value. Libraries written before 0.10,
|
||||
and libraries last written by an older LinTunes, only have this one.
|
||||
3. The machine-local override in ``~/.config/lintunes/config.json``.
|
||||
|
||||
The override is consulted **last, not first**, on purpose. If it won outright, a
|
||||
machine that once needed an override would be pinned to it forever and would
|
||||
never pick up a later shared change. Consulting it only after the shared value
|
||||
fails to resolve gives an escape hatch *and* self-healing: plug the drive back
|
||||
in and the shared setting takes over again on its own.
|
||||
|
||||
Every candidate must be an existing directory. A path that isn't there is not a
|
||||
music folder — better to say "I can't find it" than to silently rebuild a
|
||||
phantom tree next to wherever the app happened to be launched from.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from lintunes.config import load_config, save_config
|
||||
from lintunes.paths import to_absolute
|
||||
|
||||
# Keys in the per-machine config.json (never synced).
|
||||
OVERRIDE_KEY = "music_folder_override"
|
||||
PROMPT_SEEN_KEY = "music_folder_prompted"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Resolution:
|
||||
"""What the music folder resolved to here, and where the answer came from."""
|
||||
|
||||
path: Path | None # an existing media folder, or None
|
||||
source: str # library | legacy | machine | missing | unset
|
||||
stored: str = "" # what we tried, for the "can't find it" copy
|
||||
|
||||
@property
|
||||
def is_machine_local(self) -> bool:
|
||||
return self.source == "machine"
|
||||
|
||||
|
||||
def _existing_dir(candidate: str) -> Path | None:
|
||||
if not candidate:
|
||||
return None
|
||||
path = Path(candidate).expanduser()
|
||||
return path if path.is_dir() else None
|
||||
|
||||
|
||||
def resolve(library, data_dir, config=None) -> Resolution:
|
||||
"""Pick the media folder for this machine. See the module docstring."""
|
||||
config = load_config() if config is None else config
|
||||
stored = ""
|
||||
|
||||
rel = getattr(library, "music_folder_rel", "")
|
||||
if rel:
|
||||
stored = to_absolute(rel, str(data_dir))
|
||||
found = _existing_dir(stored)
|
||||
if found is not None:
|
||||
return Resolution(found, "library", stored)
|
||||
|
||||
legacy = getattr(library, "music_folder", "")
|
||||
if legacy:
|
||||
stored = stored or legacy
|
||||
found = _existing_dir(legacy)
|
||||
if found is not None:
|
||||
return Resolution(found, "legacy", legacy)
|
||||
|
||||
override = config.get(OVERRIDE_KEY, "")
|
||||
if override:
|
||||
found = _existing_dir(override)
|
||||
if found is not None:
|
||||
return Resolution(found, "machine", override)
|
||||
stored = stored or override
|
||||
|
||||
# Something is on file but not on this machine, versus never set at all —
|
||||
# two different conversations to have with the user.
|
||||
return Resolution(None, "missing" if stored else "unset", stored)
|
||||
|
||||
|
||||
def normalize_media_folder(chosen) -> Path:
|
||||
"""Turn the folder a user picked into the value we store.
|
||||
|
||||
``organize_root()`` is ``<media folder>/Music`` — the iTunes layout, where
|
||||
an "iTunes Media" folder contains "Music". Taken literally, a newcomer who
|
||||
picks ``~/Music`` would get ``~/Music/Music/Artist/Album``, which just looks
|
||||
broken. So a folder that is *itself* called Music and doesn't already
|
||||
contain one is treated as the tree, not its parent.
|
||||
|
||||
Applied once, at set time, so the answer is persisted and can never flip
|
||||
later because a subfolder appeared. An "iTunes Media" folder (or any folder
|
||||
that already has a Music child) is stored unchanged, which leaves existing
|
||||
libraries exactly where they are.
|
||||
"""
|
||||
chosen = Path(chosen).expanduser()
|
||||
if chosen.name.lower() == "music" and not (chosen / "Music").is_dir():
|
||||
return chosen.parent
|
||||
return chosen
|
||||
|
||||
|
||||
def organize_root_for(media_folder) -> Path:
|
||||
"""The tree files are actually filed into, for preview text."""
|
||||
return Path(media_folder) / "Music"
|
||||
|
||||
|
||||
def machine_override(config=None) -> str:
|
||||
config = load_config() if config is None else config
|
||||
return config.get(OVERRIDE_KEY, "")
|
||||
|
||||
|
||||
def set_machine_override(path: str | None):
|
||||
"""Record (or clear) this machine's own music folder. Never synced."""
|
||||
config = load_config()
|
||||
if path:
|
||||
config[OVERRIDE_KEY] = str(path)
|
||||
else:
|
||||
config.pop(OVERRIDE_KEY, None)
|
||||
save_config(config)
|
||||
|
||||
|
||||
def prompt_seen(config=None) -> bool:
|
||||
config = load_config() if config is None else config
|
||||
return bool(config.get(PROMPT_SEEN_KEY))
|
||||
|
||||
|
||||
def mark_prompt_seen():
|
||||
"""Remember that this machine has been asked once.
|
||||
|
||||
Deliberately in config.json rather than preferences.json: preferences ride
|
||||
the synced data dir, so a "Not Now" on one machine would silently suppress
|
||||
the question on the other — but each machine may genuinely need its own
|
||||
answer.
|
||||
"""
|
||||
config = load_config()
|
||||
config[PROMPT_SEEN_KEY] = True
|
||||
save_config(config)
|
||||
@@ -356,13 +356,46 @@ def _merge_play_journal(original_path: Path, conflict_path: Path) -> ConflictSum
|
||||
if changed else ["No differences needed reconciling."])
|
||||
|
||||
|
||||
_MUSIC_KEYS = ("music_folder", "music_folder_rel", "music_folder_set_at")
|
||||
|
||||
|
||||
def _merge_metadata(original_path: Path, conflict_path: Path) -> ConflictSummary:
|
||||
# Read both before _keep_newer overwrites one of them.
|
||||
try:
|
||||
before = read_json(original_path)
|
||||
other = read_json(conflict_path)
|
||||
except (ValueError, OSError):
|
||||
before = other = None
|
||||
|
||||
summary = _keep_newer(original_path, conflict_path)
|
||||
summary.file, summary.kind = "library_metadata.json", "metadata"
|
||||
summary.lines = ["Library columns/settings taken from the most recently edited copy."]
|
||||
|
||||
# The whole-file pick above goes by mtime, which moves for cosmetic reasons
|
||||
# (a column resize rewrites this file). The music folder is a deliberate
|
||||
# setting, so decide it on its own stamp instead — otherwise a resize on one
|
||||
# machine can silently revert a folder change made on the other.
|
||||
if before is not None and other is not None:
|
||||
kept = read_json(original_path)
|
||||
loser = other if kept.get("music_folder_set_at") == before.get(
|
||||
"music_folder_set_at") else before
|
||||
if _newer_stamp(loser.get("music_folder_set_at"),
|
||||
kept.get("music_folder_set_at")):
|
||||
for key in _MUSIC_KEYS:
|
||||
if key in loser:
|
||||
kept[key] = loser[key]
|
||||
write_json(original_path, kept)
|
||||
summary.lines.append(
|
||||
"Music folder taken from the copy that set it most recently.")
|
||||
return summary
|
||||
|
||||
|
||||
def _newer_stamp(candidate, current) -> bool:
|
||||
if not candidate:
|
||||
return False
|
||||
return not current or candidate > current
|
||||
|
||||
|
||||
def _keep_newer(original_path: Path, conflict_path: Path) -> ConflictSummary:
|
||||
took = "this machine"
|
||||
if conflict_path.stat().st_mtime > original_path.stat().st_mtime:
|
||||
|
||||
@@ -54,7 +54,14 @@ def _write_base_play_fields(data: dict, base: dict):
|
||||
def save_metadata(library: Library, data_dir: Path):
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
metadata = {
|
||||
# Both spellings, always. Old code reads the absolute key and behaves
|
||||
# exactly as it did; new code prefers the relative one. Old code that
|
||||
# *writes* this file simply drops the new keys, and the reader below
|
||||
# falls back to the absolute one — so no version combination hard-fails.
|
||||
"music_folder": library.music_folder,
|
||||
"music_folder_rel": to_relative(library.music_folder, data_dir)
|
||||
if library.music_folder else "",
|
||||
"music_folder_set_at": library.music_folder_set_at,
|
||||
"import_date": library.import_date,
|
||||
"track_count": len(library.tracks),
|
||||
"playlist_count": len(library.playlists),
|
||||
@@ -90,14 +97,21 @@ def load_library(data_dir: Path) -> Library:
|
||||
track.location = to_absolute(track.location, data_dir)
|
||||
tracks[track.track_id] = track
|
||||
|
||||
# Load metadata
|
||||
# Load metadata. The three music_folder fields are read raw and left
|
||||
# unresolved on purpose — deciding *which* one applies on this machine
|
||||
# needs the config file too, so it lives in lintunes.music_folder and this
|
||||
# module stays a dumb (de)serializer.
|
||||
music_folder = ""
|
||||
music_folder_rel = ""
|
||||
music_folder_set_at = None
|
||||
import_date = None
|
||||
library_settings = PlaylistSettings()
|
||||
metadata_path = data_dir / "library_metadata.json"
|
||||
if metadata_path.exists():
|
||||
metadata = read_json(metadata_path)
|
||||
music_folder = metadata.get("music_folder", "")
|
||||
music_folder_rel = metadata.get("music_folder_rel", "")
|
||||
music_folder_set_at = metadata.get("music_folder_set_at")
|
||||
import_date = metadata.get("import_date")
|
||||
if "library_settings" in metadata:
|
||||
library_settings = PlaylistSettings.from_dict(metadata["library_settings"])
|
||||
@@ -115,6 +129,8 @@ def load_library(data_dir: Path) -> Library:
|
||||
tracks=tracks,
|
||||
playlists=playlists,
|
||||
music_folder=music_folder,
|
||||
music_folder_rel=music_folder_rel,
|
||||
music_folder_set_at=music_folder_set_at,
|
||||
import_date=import_date,
|
||||
library_settings=library_settings,
|
||||
)
|
||||
|
||||
+39
-11
@@ -9,10 +9,25 @@ from PyQt6.QtGui import QPalette, QColor, QFontDatabase
|
||||
|
||||
|
||||
# The font the now-playing panel wants by default. Not bundled (it's a
|
||||
# proprietary Monotype face); the user is asked to install it and, if missing,
|
||||
# to pick a substitute on first run (see main.ensure_now_playing_font).
|
||||
# proprietary Monotype face), so it's simply the head of the fallback chain
|
||||
# below: whatever geometric sans this machine actually has wins, silently.
|
||||
# Startup never asks — the user can override in Edit ▸ Preferences.
|
||||
CENTURY_GOTHIC = "Century Gothic"
|
||||
|
||||
# Closest-first substitutes for Century Gothic. URW Gothic is the ITC Avant
|
||||
# Garde clone CG derives from and ships with ghostscript, so it's present on
|
||||
# most Linux boxes; TeX Gyre Adventor is its TeX-world descendant. Names must
|
||||
# be spelled exactly as the family is registered — QFontDatabase does no fuzzy
|
||||
# matching, which is why "Jost*" appears with its asterisk.
|
||||
NOW_PLAYING_FALLBACKS = (
|
||||
CENTURY_GOTHIC,
|
||||
"URW Gothic", "URW Gothic L", "URW Gothic Book",
|
||||
"TeX Gyre Adventor",
|
||||
"Questrial", "Didact Gothic", "Jost*", "Jost", "Josefin Sans",
|
||||
"Poppins", "Futura", "Avant Garde", "ITC Avant Garde Gothic",
|
||||
"Montserrat", "Nunito Sans", "Nunito",
|
||||
)
|
||||
|
||||
|
||||
HIGHLIGHT_COLORS = {
|
||||
"blue": "#3584E4",
|
||||
@@ -141,23 +156,36 @@ def apply_theme(app, prefs):
|
||||
""")
|
||||
|
||||
|
||||
def century_gothic_available() -> bool:
|
||||
"""True if the Century Gothic family is installed and resolvable by Qt."""
|
||||
return CENTURY_GOTHIC in QFontDatabase.families()
|
||||
def installed_families() -> set[str]:
|
||||
"""Families Qt can actually resolve on this machine."""
|
||||
return set(QFontDatabase.families())
|
||||
|
||||
|
||||
def auto_now_playing_family() -> str | None:
|
||||
"""Best available face from NOW_PLAYING_FALLBACKS, or None for the app font.
|
||||
|
||||
Deliberately uncached: a cache would go stale for tests that stub
|
||||
QFontDatabase, and for a font installed while the app is running.
|
||||
"""
|
||||
families = installed_families()
|
||||
return next((f for f in NOW_PLAYING_FALLBACKS if f in families), None)
|
||||
|
||||
|
||||
def now_playing_font_family(prefs) -> str | None:
|
||||
"""Family the now-playing panel should use, or None for the app default.
|
||||
|
||||
prefs["now_playing_font"] semantics:
|
||||
- missing/None : never set → prefer Century Gothic, fall back to default
|
||||
- "" : user chose "use default" → always the app default
|
||||
- "Family" : user picked a family → use it (if still installed)
|
||||
- missing/None : automatic → best available face from the fallback chain
|
||||
- "" : user chose "same as the app" → always the app default
|
||||
- "Family" : user picked a family → use it, or fall back to automatic
|
||||
if it has since been uninstalled
|
||||
|
||||
Never prompts and never blocks; an unavailable wish quietly becomes the
|
||||
nearest thing installed.
|
||||
"""
|
||||
saved = prefs.get("now_playing_font")
|
||||
if saved == "":
|
||||
return None
|
||||
if saved: # a previously chosen family
|
||||
return saved if saved in QFontDatabase.families() else None
|
||||
# Never set: prefer Century Gothic when present, else default.
|
||||
return CENTURY_GOTHIC if century_gothic_available() else None
|
||||
return saved if saved in installed_families() else auto_now_playing_family()
|
||||
return auto_now_playing_family()
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
"""Round 40 — welcoming the newcomers.
|
||||
|
||||
Three defects, all of which hit a *new* user hardest:
|
||||
|
||||
* Startup could open a blocking font dialog before the main window existed, so
|
||||
the very first thing LinTunes did on a fresh machine was demand a decision
|
||||
about Century Gothic. Now the best available geometric sans is chosen
|
||||
silently and the font lives in Preferences.
|
||||
* A non-iTunes user had no supported way to set their music folder at all, and
|
||||
with it unset imports fell back to a *relative* ``Path("Music")`` — resolved
|
||||
against the working directory, which GNOME's dash does not set predictably.
|
||||
Music scattered somewhere unfindable.
|
||||
* ``music_folder`` was the only path in the library stored raw absolute in the
|
||||
*synced* metadata, so a second machine inherited the first machine's paths.
|
||||
It is now stored relative as well (both spellings, so an older LinTunes on
|
||||
the other machine keeps working), with a semantic stamp so a merge picks the
|
||||
newest *setting* rather than the newest *file*.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
from lintunes import music_folder, theme
|
||||
from lintunes.library_manager import LibraryManager
|
||||
from lintunes.models import Library, Track
|
||||
from lintunes.preferences import Preferences
|
||||
from lintunes.storage import json_storage
|
||||
from lintunes.storage.conflict_resolver import resolve_conflicts
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# helpers
|
||||
|
||||
|
||||
class _FakeLastFm(QObject):
|
||||
login_finished = pyqtSignal(bool, str)
|
||||
status_message = pyqtSignal(str)
|
||||
|
||||
def is_logged_in(self):
|
||||
return False
|
||||
|
||||
|
||||
class _FakePrefs:
|
||||
"""Just enough of Preferences for the pure font resolution."""
|
||||
|
||||
def __init__(self, value=None):
|
||||
self._value = value
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._value if key == "now_playing_font" else default
|
||||
|
||||
|
||||
def _families(monkeypatch, *names):
|
||||
"""Pretend this machine has exactly these font families."""
|
||||
class _DB:
|
||||
@staticmethod
|
||||
def families():
|
||||
return list(names)
|
||||
monkeypatch.setattr(theme, "QFontDatabase", _DB)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_config(tmp_path, monkeypatch):
|
||||
"""Point config.json at a temp dir so tests never touch the real one."""
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
||||
return tmp_path / "config"
|
||||
|
||||
|
||||
def _library(tmp_path, count=2, music=None):
|
||||
music = music or (tmp_path / "media")
|
||||
library = Library(music_folder=str(music))
|
||||
for tid in range(1, count + 1):
|
||||
path = music / "Music" / f"Artist{tid}" / "Album" / f"T{tid}.mp3"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"audio")
|
||||
library.tracks[tid] = Track(track_id=tid, name=f"T{tid}",
|
||||
artist=f"Artist{tid}", location=str(path))
|
||||
return library
|
||||
|
||||
|
||||
def _manager(tmp_path, library):
|
||||
return LibraryManager(library, tmp_path / "data")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# A. the font no longer stops the app
|
||||
|
||||
|
||||
class TestNowPlayingFont:
|
||||
def test_prefers_century_gothic_when_installed(self, monkeypatch):
|
||||
_families(monkeypatch, "DejaVu Sans", "Century Gothic", "URW Gothic")
|
||||
assert theme.now_playing_font_family(_FakePrefs(None)) == "Century Gothic"
|
||||
|
||||
def test_falls_back_to_the_closest_geometric_sans(self, monkeypatch):
|
||||
_families(monkeypatch, "DejaVu Sans", "URW Gothic", "Montserrat")
|
||||
# URW Gothic is the Avant Garde clone Century Gothic derives from, and
|
||||
# sits ahead of Montserrat in the chain.
|
||||
assert theme.now_playing_font_family(_FakePrefs(None)) == "URW Gothic"
|
||||
|
||||
def test_returns_none_when_nothing_comparable_exists(self, monkeypatch):
|
||||
_families(monkeypatch, "DejaVu Sans", "Liberation Serif")
|
||||
assert theme.now_playing_font_family(_FakePrefs(None)) is None
|
||||
|
||||
def test_empty_string_always_means_the_app_font(self, monkeypatch):
|
||||
_families(monkeypatch, "Century Gothic")
|
||||
assert theme.now_playing_font_family(_FakePrefs("")) is None
|
||||
|
||||
def test_a_chosen_family_wins_while_it_is_installed(self, monkeypatch):
|
||||
_families(monkeypatch, "Century Gothic", "Comic Neue")
|
||||
assert theme.now_playing_font_family(_FakePrefs("Comic Neue")) == "Comic Neue"
|
||||
|
||||
def test_uninstalled_choice_falls_back_to_auto_not_the_app_font(self, monkeypatch):
|
||||
"""The behavior change: an uninstalled saved family used to drop to the
|
||||
app default (and then nag on next launch). Now it quietly becomes the
|
||||
nearest thing installed."""
|
||||
_families(monkeypatch, "URW Gothic", "DejaVu Sans")
|
||||
assert theme.now_playing_font_family(_FakePrefs("Gone Sans")) == "URW Gothic"
|
||||
|
||||
def test_startup_never_opens_a_font_dialog(self):
|
||||
import inspect
|
||||
from lintunes import main as main_module
|
||||
source = inspect.getsource(main_module)
|
||||
assert "QFontDialog" not in source
|
||||
assert not hasattr(main_module, "_ensure_now_playing_font")
|
||||
|
||||
|
||||
class TestFontPreferenceRow:
|
||||
def _dialog(self, tmp_path, qapp):
|
||||
from lintunes.gui.preferences_dialog import PreferencesDialog
|
||||
return PreferencesDialog(Preferences(tmp_path), _FakeLastFm())
|
||||
|
||||
def test_building_the_dialog_writes_no_preference(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
from lintunes.gui.preferences_dialog import PreferencesDialog
|
||||
PreferencesDialog(prefs, _FakeLastFm())
|
||||
assert not (tmp_path / "preferences.json").exists()
|
||||
|
||||
def test_the_three_modes_write_the_three_states(self, qapp, tmp_path):
|
||||
from lintunes.gui.preferences_dialog import PreferencesDialog
|
||||
prefs = Preferences(tmp_path)
|
||||
dialog = PreferencesDialog(prefs, _FakeLastFm())
|
||||
|
||||
dialog._font_default.setChecked(True)
|
||||
assert prefs.get("now_playing_font") == ""
|
||||
assert not dialog._font_combo.isEnabled()
|
||||
|
||||
dialog._font_pick.setChecked(True)
|
||||
assert prefs.get("now_playing_font") # an explicit family
|
||||
assert dialog._font_combo.isEnabled()
|
||||
|
||||
dialog._font_auto.setChecked(True)
|
||||
assert prefs.get("now_playing_font") is None # automatic
|
||||
|
||||
def test_dialog_still_builds_without_the_music_callbacks(self, qapp, tmp_path):
|
||||
"""Back-compat: the older two-arg construction must keep working."""
|
||||
dialog = self._dialog(tmp_path, qapp)
|
||||
assert not hasattr(dialog, "_music_edit")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# B. the music folder: portable, settable, and never guessed
|
||||
|
||||
|
||||
class TestMusicFolderStorage:
|
||||
def test_metadata_carries_both_spellings_and_a_stamp(self, tmp_path):
|
||||
data_dir = tmp_path / "data"
|
||||
library = _library(tmp_path)
|
||||
library.music_folder_set_at = "2026-08-22T00:00:00"
|
||||
json_storage.save_library(library, data_dir)
|
||||
|
||||
written = json.loads((data_dir / "library_metadata.json").read_text())
|
||||
assert written["music_folder"] == str(tmp_path / "media") # legacy
|
||||
assert written["music_folder_rel"] == "../media" # portable
|
||||
assert written["music_folder_set_at"] == "2026-08-22T00:00:00"
|
||||
|
||||
def test_a_library_without_the_new_keys_still_loads(self, tmp_path, isolated_config):
|
||||
"""Protects the live library: pre-0.10 metadata has only the absolute key."""
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
(data_dir / "library_metadata.json").write_text(
|
||||
json.dumps({"music_folder": str(media)}))
|
||||
|
||||
loaded = json_storage.load_library(data_dir)
|
||||
state = music_folder.resolve(loaded, data_dir)
|
||||
assert state.path == media
|
||||
assert state.source == "legacy"
|
||||
|
||||
def test_the_relative_key_survives_the_whole_tree_moving(self, tmp_path,
|
||||
isolated_config):
|
||||
"""The cross-machine case — the entire point of the change."""
|
||||
import shutil
|
||||
first = tmp_path / "machine1"
|
||||
(first / "media").mkdir(parents=True)
|
||||
library = Library(music_folder=str(first / "media"))
|
||||
json_storage.save_library(library, first / "data")
|
||||
|
||||
second = tmp_path / "machine2"
|
||||
shutil.move(str(first), str(second))
|
||||
|
||||
loaded = json_storage.load_library(second / "data")
|
||||
state = music_folder.resolve(loaded, second / "data")
|
||||
assert state.path == second / "media"
|
||||
assert state.source == "library"
|
||||
# ...even though the absolute key still names the old machine's path.
|
||||
assert loaded.music_folder == str(first / "media")
|
||||
|
||||
def test_old_code_can_still_read_an_absolute_path(self, tmp_path, isolated_config):
|
||||
"""Forward-compat pin: the legacy key stays usable for the other
|
||||
machine while it is still on older code."""
|
||||
manager = _manager(tmp_path, _library(tmp_path))
|
||||
media = tmp_path / "elsewhere"
|
||||
media.mkdir()
|
||||
manager.set_music_folder(media)
|
||||
|
||||
written = json.loads(
|
||||
(tmp_path / "data" / "library_metadata.json").read_text())
|
||||
assert written["music_folder"] == str(media)
|
||||
assert written["music_folder"].startswith("/")
|
||||
|
||||
|
||||
class TestMusicFolderResolution:
|
||||
def test_the_override_is_only_used_when_the_stored_folder_is_gone(
|
||||
self, tmp_path, isolated_config):
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
library = Library(music_folder=str(media))
|
||||
music_folder.set_machine_override(str(elsewhere))
|
||||
|
||||
# Stored folder exists → it wins, override ignored.
|
||||
assert music_folder.resolve(library, tmp_path).path == media
|
||||
|
||||
# Stored folder gone → the override is the escape hatch.
|
||||
gone = Library(music_folder=str(tmp_path / "unplugged"))
|
||||
state = music_folder.resolve(gone, tmp_path)
|
||||
assert state.path == elsewhere
|
||||
assert state.source == "machine"
|
||||
|
||||
def test_unset_and_missing_are_different_conversations(self, tmp_path,
|
||||
isolated_config):
|
||||
assert music_folder.resolve(Library(), tmp_path).source == "unset"
|
||||
state = music_folder.resolve(
|
||||
Library(music_folder=str(tmp_path / "nope")), tmp_path)
|
||||
assert state.source == "missing"
|
||||
assert state.stored == str(tmp_path / "nope")
|
||||
|
||||
def test_a_folder_called_music_is_the_tree_not_its_parent(self, tmp_path):
|
||||
"""Otherwise picking ~/Music files everything into ~/Music/Music."""
|
||||
home_music = tmp_path / "Music"
|
||||
home_music.mkdir()
|
||||
assert music_folder.normalize_media_folder(home_music) == tmp_path
|
||||
assert music_folder.organize_root_for(
|
||||
music_folder.normalize_media_folder(home_music)) == home_music
|
||||
|
||||
def test_an_itunes_media_folder_is_stored_unchanged(self, tmp_path):
|
||||
media = tmp_path / "iTunes Media"
|
||||
(media / "Music").mkdir(parents=True)
|
||||
assert music_folder.normalize_media_folder(media) == media
|
||||
|
||||
def test_a_music_folder_that_already_has_a_music_child_is_left_alone(self, tmp_path):
|
||||
nested = tmp_path / "Music"
|
||||
(nested / "Music").mkdir(parents=True)
|
||||
assert music_folder.normalize_media_folder(nested) == nested
|
||||
|
||||
|
||||
class TestSettingTheMusicFolder:
|
||||
def test_setting_it_persists_immediately(self, qapp, tmp_path, isolated_config):
|
||||
manager = _manager(tmp_path, _library(tmp_path))
|
||||
media = tmp_path / "new-media"
|
||||
media.mkdir()
|
||||
manager.set_music_folder(media)
|
||||
|
||||
assert manager._dirty_metadata is False # flushed, not left pending
|
||||
on_disk = json.loads(
|
||||
(tmp_path / "data" / "library_metadata.json").read_text())
|
||||
assert on_disk["music_folder"] == str(media)
|
||||
assert manager.organize_root() == media / "Music"
|
||||
|
||||
def test_a_machine_local_choice_never_touches_the_shared_library(
|
||||
self, qapp, tmp_path, isolated_config):
|
||||
"""The 'never clobber the other machine' pin.
|
||||
|
||||
This is the real scenario: the shared folder is a path that exists on
|
||||
the *other* machine but not here, so this machine records its own
|
||||
answer and leaves the synced metadata completely alone.
|
||||
"""
|
||||
library = Library(music_folder=str(tmp_path / "other-machines-drive"))
|
||||
manager = _manager(tmp_path, library)
|
||||
manager.mark_library_settings_dirty()
|
||||
manager.flush()
|
||||
metadata = tmp_path / "data" / "library_metadata.json"
|
||||
before = metadata.read_text()
|
||||
assert manager.organize_root() is None # not reachable here
|
||||
|
||||
here = tmp_path / "local-media"
|
||||
here.mkdir()
|
||||
manager.set_music_folder(here, machine_only=True)
|
||||
|
||||
assert metadata.read_text() == before # byte-identical
|
||||
assert manager.organize_root() == here / "Music"
|
||||
assert music_folder.machine_override() == str(here)
|
||||
|
||||
def test_organize_root_is_none_when_the_folder_is_not_here(
|
||||
self, qapp, tmp_path, isolated_config):
|
||||
library = Library(music_folder=str(tmp_path / "unplugged-drive"))
|
||||
manager = _manager(tmp_path, library)
|
||||
assert manager.organize_root() is None
|
||||
|
||||
|
||||
class TestSyncDoesNotRevertTheSetting:
|
||||
def _write_metadata(self, data_dir, folder, stamp):
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
(data_dir / "library_metadata.json").write_text(json.dumps({
|
||||
"music_folder": str(folder),
|
||||
"music_folder_rel": "",
|
||||
"music_folder_set_at": stamp,
|
||||
"library_settings": {},
|
||||
}))
|
||||
|
||||
def test_a_stale_synced_file_does_not_revert_a_newer_local_change(
|
||||
self, qapp, tmp_path, isolated_config):
|
||||
"""The trap: the dirty flag only guards until flush. After that an
|
||||
older library_metadata.json arriving from the other machine would
|
||||
silently undo a deliberate change."""
|
||||
manager = _manager(tmp_path, _library(tmp_path))
|
||||
mine = tmp_path / "mine"
|
||||
mine.mkdir()
|
||||
manager.set_music_folder(mine) # flushes; now clean
|
||||
|
||||
theirs = tmp_path / "theirs"
|
||||
theirs.mkdir()
|
||||
self._write_metadata(tmp_path / "data", theirs, "2000-01-01T00:00:00")
|
||||
manager.reload_from_disk()
|
||||
|
||||
assert manager.organize_root() == mine / "Music"
|
||||
|
||||
def test_a_newer_remote_change_is_still_adopted(self, qapp, tmp_path,
|
||||
isolated_config):
|
||||
"""The inverse — the guard must not lock the setting forever."""
|
||||
manager = _manager(tmp_path, _library(tmp_path))
|
||||
mine = tmp_path / "mine"
|
||||
mine.mkdir()
|
||||
manager.set_music_folder(mine)
|
||||
|
||||
theirs = tmp_path / "theirs"
|
||||
theirs.mkdir()
|
||||
self._write_metadata(tmp_path / "data", theirs, "2099-01-01T00:00:00")
|
||||
manager.reload_from_disk()
|
||||
|
||||
assert manager.organize_root() == theirs / "Music"
|
||||
|
||||
def test_a_legacy_library_still_trusts_disk(self, qapp, tmp_path,
|
||||
isolated_config):
|
||||
"""No stamps on either side → the pre-0.10 behavior, unchanged."""
|
||||
manager = _manager(tmp_path, _library(tmp_path))
|
||||
theirs = tmp_path / "theirs"
|
||||
theirs.mkdir()
|
||||
self._write_metadata(tmp_path / "data", theirs, None)
|
||||
manager.reload_from_disk()
|
||||
assert manager.organize_root() == theirs / "Music"
|
||||
|
||||
def test_a_conflict_keeps_the_most_recently_set_folder(self, tmp_path):
|
||||
"""_merge_metadata picks the whole file by mtime, which moves for
|
||||
cosmetic reasons; the folder must be decided on its own stamp."""
|
||||
import os
|
||||
import time
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
original = data_dir / "library_metadata.json"
|
||||
conflict = data_dir / "library_metadata.sync-conflict-20260822-120000-ABCDEFG.json"
|
||||
|
||||
# The conflict copy set the folder later, but the local file was
|
||||
# touched more recently (someone resized a column).
|
||||
conflict.write_text(json.dumps({
|
||||
"music_folder": "/other/media",
|
||||
"music_folder_rel": "../other",
|
||||
"music_folder_set_at": "2099-01-01T00:00:00",
|
||||
}))
|
||||
original.write_text(json.dumps({
|
||||
"music_folder": "/old/media",
|
||||
"music_folder_rel": "../old",
|
||||
"music_folder_set_at": "2000-01-01T00:00:00",
|
||||
}))
|
||||
now = time.time()
|
||||
os.utime(conflict, (now - 500, now - 500))
|
||||
os.utime(original, (now, now))
|
||||
|
||||
resolve_conflicts(data_dir)
|
||||
|
||||
merged = json.loads(original.read_text())
|
||||
assert merged["music_folder"] == "/other/media"
|
||||
assert merged["music_folder_rel"] == "../other"
|
||||
|
||||
|
||||
class TestImportsNeverScatter:
|
||||
def test_no_relative_music_fallback_remains(self):
|
||||
"""The scatter bug: `or Path("Music")` resolved against the working
|
||||
directory, which GNOME's dash does not set predictably."""
|
||||
import inspect
|
||||
from lintunes.gui.main_window import MainWindow
|
||||
source = inspect.getsource(MainWindow._music_import_dir)
|
||||
code = [line for line in source.splitlines()
|
||||
if line.strip().startswith("return")]
|
||||
assert code == [" return self._manager.organize_root()"]
|
||||
|
||||
def test_import_refuses_rather_than_guessing(self, qapp, tmp_path,
|
||||
isolated_config, monkeypatch):
|
||||
from lintunes.gui.main_window import MainWindow
|
||||
library = Library() # no music folder at all
|
||||
manager = _manager(tmp_path, library)
|
||||
window = MainWindow(manager, Preferences(tmp_path / "data"))
|
||||
try:
|
||||
# Pretend the user declines the prompt.
|
||||
monkeypatch.setattr(window, "check_music_folder", lambda: None)
|
||||
source = tmp_path / "song.mp3"
|
||||
source.write_bytes(b"audio")
|
||||
|
||||
cwd_before = set(p.name for p in tmp_path.iterdir())
|
||||
assert window.import_files([source]) == []
|
||||
# Nothing created, nothing copied anywhere.
|
||||
assert set(p.name for p in tmp_path.iterdir()) == cwd_before
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
def test_files_land_under_the_music_folder_when_set(self, qapp, tmp_path,
|
||||
isolated_config,
|
||||
mp3_file):
|
||||
from lintunes.gui.main_window import MainWindow
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
manager = _manager(tmp_path, Library(music_folder=str(media)))
|
||||
window = MainWindow(manager, Preferences(tmp_path / "data"))
|
||||
try:
|
||||
window.import_files([mp3_file])
|
||||
copied = list((media / "Music").rglob("*.mp3"))
|
||||
assert len(copied) == 1
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
class TestFirstRunPrompt:
|
||||
def test_the_machine_is_asked_only_once(self, tmp_path, isolated_config):
|
||||
assert music_folder.prompt_seen() is False
|
||||
music_folder.mark_prompt_seen()
|
||||
assert music_folder.prompt_seen() is True
|
||||
|
||||
def test_the_flag_is_per_machine_not_synced(self, tmp_path, isolated_config):
|
||||
"""It lives in config.json, not the synced preferences.json — each
|
||||
machine may genuinely need its own answer."""
|
||||
music_folder.mark_prompt_seen()
|
||||
config = json.loads(
|
||||
(isolated_config / "lintunes" / "config.json").read_text())
|
||||
assert config[music_folder.PROMPT_SEEN_KEY] is True
|
||||
|
||||
def test_the_dialog_shows_where_songs_will_actually_go(self, qapp, tmp_path):
|
||||
from lintunes.gui.music_folder_dialog import MusicFolderDialog
|
||||
dialog = MusicFolderDialog()
|
||||
try:
|
||||
target = tmp_path / "Tunes"
|
||||
target.mkdir()
|
||||
dialog._folder.setText(str(target))
|
||||
dialog._refresh_preview()
|
||||
assert str(target / "Music" / "Artist" / "Album") in \
|
||||
dialog._preview.text()
|
||||
finally:
|
||||
dialog.close()
|
||||
Reference in New Issue
Block a user