v0.11.1: a web mix stops shipping an .m3u

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBSM2bFC6UToiEg8BE4dqj
This commit is contained in:
2026-08-22 15:22:01 -04:00
co-authored by Claude Opus 5
parent 800e8ffb5d
commit a61cbaa369
5 changed files with 37 additions and 18 deletions
+4 -2
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.11.0"
__version__ = "0.11.1"
+15 -10
View File
@@ -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")
+11
View File
@@ -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`
+6 -5
View File
@@ -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",