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:
@@ -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,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.11.0"
|
||||
__version__ = "0.11.1"
|
||||
|
||||
+15
-10
@@ -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")
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user