From a61cbaa369773029e1b21fa6038dd8ff4270133e Mon Sep 17 00:00:00 2001 From: trav Date: Sat, 22 Aug 2026 15:22:01 -0400 Subject: [PATCH] v0.11.1: a web mix stops shipping an .m3u MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was there on the theory that it cost nothing and let the folder double as a plain music folder. But a web mix is a folder you upload, and a stray playlist file next to index.html is one more thing to explain to whoever receives it. plan.m3u_name is now "" for WEB, so the plan doesn't name a file it won't write, and index.html is simply the last thing written — which is what the manifest-last invariant always meant for that branch. Folder exports keep their m3u unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JBSM2bFC6UToiEg8BE4dqj --- CLAUDE.md | 6 ++++-- lintunes/__init__.py | 2 +- lintunes/export/exporter.py | 25 +++++++++++++++---------- tasks-done.md | 11 +++++++++++ tests/test_round37.py | 11 ++++++----- 5 files changed, 37 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 60edd10..b00fe40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -171,8 +171,10 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal and `build_m3u`: pure `plan_export()` first, then `ExportWorker` on a daemon thread. Two destinations — a **folder** (files as `Artist - Title.ext` plus an `.m3u`) or a **web mix** (`index.html` + `audios/` + hero image, from - `templates/`). **The manifest is written last**, so an interrupted export - never leaves a page or m3u naming files that aren't there. `web_support.py` + `templates/`; **no m3u** — it's a folder you upload, not one you open in a + player, so `plan.m3u_name` is `""` there). **The manifest is written last**, + so an interrupted export never leaves a page or m3u naming files that aren't + there. `web_support.py` is the format gate, shaped like `cast/support.py`: deny-by-default on the suffix with the iTunes `kind` breaking the `.m4a` tie. **Bitrate never triggers a conversion** — only unplayability does — and every conversion diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 219abf3..a7e0a18 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.11.0" +__version__ = "0.11.1" diff --git a/lintunes/export/exporter.py b/lintunes/export/exporter.py index dbff756..0b975d2 100644 --- a/lintunes/export/exporter.py +++ b/lintunes/export/exporter.py @@ -11,8 +11,9 @@ Two invariants worth keeping: * **Local music files are only ever read.** Like sync, this never moves, rewrites or deletes anything in the library. * **The manifest is written last** — ``index.html`` for a web mix, the - ``.m3u`` for a folder. An interrupted export therefore never leaves behind - a playlist promising files that aren't there. + ``.m3u`` for a folder (a web mix gets no m3u: it is a page to upload, not a + music folder to open in a player). An interrupted export therefore never + leaves behind a page or playlist promising files that aren't there. """ import html @@ -103,7 +104,7 @@ class ExportPlan: playlist_name: str dest_dir: Path # the folder that gets created audio_dir: Path # dest_dir, or dest_dir/"audios" for a web mix - m3u_name: str + m3u_name: str # "" for a web mix, which writes no m3u title: str = "" description: str = "" image: Path | None = None # None = ship the gray placeholder @@ -135,7 +136,7 @@ def plan_export(playlist_name: str, tracks: list, dest_parent: Path, playlist_name=playlist_name, dest_dir=dest_dir, audio_dir=dest_dir / "audios" if kind == WEB else dest_dir, - m3u_name=folder + ".m3u", + m3u_name="" if kind == WEB else folder + ".m3u", title=playlist_name, description=DEFAULT_DESCRIPTION, ) @@ -369,14 +370,18 @@ class ExportWorker(QObject): self.failed.emit(f"Export failed: {e}") def _write_manifest(self, completed: list): - """The .m3u, plus the page and its assets for a web mix. Written last.""" + """The page for a web mix, the .m3u for a folder. Written last. + + A web mix gets no m3u. It used to ship one on the theory that it cost + nothing and let the folder double as a plain music folder — but the + folder is a page you upload, and a stray playlist file next to + index.html is one more thing to explain to whoever receives it. + """ plan = self._plan - entries = [(item.dest_name, item.secs, item.display) for item in completed] if plan.kind == WEB: for name in _WEB_ASSETS: shutil.copyfile(templates() / name, plan.dest_dir / name) - image_name = None if plan.image is not None and Path(plan.image).is_file(): image_name = sanitize_name(Path(plan.image).stem) \ + Path(plan.image).suffix.lower() @@ -385,11 +390,11 @@ class ExportWorker(QObject): image_name = "placeholder.png" shutil.copyfile(templates() / "placeholder.png", plan.dest_dir / image_name) - # An m3u alongside the page costs nothing and lets the folder - # double as a plain music folder. - entries = [("audios/" + n, s, d) for n, s, d in entries] (plan.dest_dir / "index.html").write_text( build_index_html(plan, image_name), encoding="utf-8") + return + entries = [(item.dest_name, item.secs, item.display) + for item in completed] (plan.dest_dir / plan.m3u_name).write_text( build_m3u(entries), encoding="utf-8") diff --git a/tasks-done.md b/tasks-done.md index ed0e4d2..40ea202 100644 --- a/tasks-done.md +++ b/tasks-done.md @@ -1,5 +1,16 @@ ## Done +### Round 42 (2026-08-22) — A web mix stops shipping an .m3u (v0.11.1) + +- [x] **No `.m3u` beside `index.html`.** It shipped on the theory that it cost + nothing and let the folder double as a plain music folder, but a web mix + is a folder you upload — a stray playlist file next to the page is one + more thing to explain to whoever receives it. `plan.m3u_name` is now `""` + for `WEB` so the plan says so rather than naming a file it won't write, + and `index.html` is simply the last thing written, which is what the + manifest-last invariant always meant for that branch. Folder exports are + untouched. + ### Round 41 (2026-08-22) — Web mixes pick their own color (v0.11.0) Every exported mix came out in the same 2010 gold-and-brown skin: a `#c7b563` diff --git a/tests/test_round37.py b/tests/test_round37.py index 3b21a81..45e9610 100644 --- a/tests/test_round37.py +++ b/tests/test_round37.py @@ -12,7 +12,8 @@ The load-bearing claims under test: * a conversion is always to FLAC, so it can never lose a bit; * DRM'd tracks are reported, never silently dropped and never attempted; * the manifest is written last, so a cancelled export leaves no page or m3u - promising files that aren't there. + promising files that aren't there. Since Round 42 the web variant's only + manifest is the page — a folder you upload has no use for a playlist file. """ import hashlib @@ -236,7 +237,7 @@ class TestWebExport: dest, _ = self._export( tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))]) for name in ("index.html", "player.js", "player.css", - "player-graphics.gif", "placeholder.png", "My Mix.m3u"): + "player-graphics.gif", "placeholder.png"): assert (dest / name).is_file(), f"missing {name}" assert (dest / "audios" / "A - S.mp3").is_file() @@ -314,10 +315,11 @@ class TestWebExport: assert (dest / "friend.png").read_bytes() == image.read_bytes() assert 'src="friend.png"' in (dest / "index.html").read_text() - def test_m3u_paths_point_into_audios(self, tmp_path): + def test_no_m3u_beside_the_page(self, tmp_path): + """A web mix is a folder you upload, not one you open in a player.""" dest, _ = self._export( tmp_path, [_track(1, "S", "A", _audio(tmp_path, "a.mp3"))]) - assert "audios/A - S.mp3" in (dest / "My Mix.m3u").read_text() + assert not list(dest.glob("*.m3u")) def test_drm_track_is_reported_not_dropped_silently(self, tmp_path): tracks = [ @@ -411,7 +413,6 @@ class TestCancel: _run(plan, cancel_on_emit=1) dest = tmp_path / "out" / "Mix" assert not (dest / "index.html").exists() - assert not (dest / "Mix.m3u").exists() def test_cancel_removes_the_partial_file(self, tmp_path): plan = plan_export("Mix", self._many(tmp_path), tmp_path / "out",