diff --git a/CHANGELOG.md b/CHANGELOG.md index bbe8f65..301e7d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.46.0] — 2026-09-22 + +### Added +- **Magazine: readable filenames for exported MP3s.** A downloaded or zipped article MP3 is now named `Magazine Title - p - Article Title - Author.mp3` instead of its internal id; the on-disk file itself is unchanged (still the stable id), only the name offered on download/inside the zip. The zip's entries are also numbered by reading order, so extracting and sorting by filename plays back in the magazine's own order. +- **Magazine: an MP3 playlist.** A new Playlist panel lists every article with a generated MP3, in reading order, with cover art, title/author and a player that auto-advances to the next track. "Play all articles" now uses this directly (no synthesis wait) whenever every approved article already has a generated MP3; a queue with even one ungenerated article keeps the original live sentence-by-sentence reader, so a listener never hits a silent gap mid-drive. + +--- + ## [1.45.0] — 2026-09-22 ### Added @@ -4335,7 +4343,8 @@ Initial feature-complete release. --- -[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.45.0...HEAD +[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.46.0...HEAD +[1.46.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.45.0...v1.46.0 [1.45.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.44.0...v1.45.0 [1.44.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.43.1...v1.44.0 [1.43.1]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.43.0...v1.43.1 diff --git a/VERSION b/VERSION index 50aceaa..0a3db35 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.45.0 +1.46.0 diff --git a/routes/magazine.py b/routes/magazine.py index c2efacf..7d3961e 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -1703,14 +1703,40 @@ async def magazine_generate_article_audio(doc_id: str, article_id: str, request: return {"job_id": job_id} +_FILENAME_UNSAFE_RE = re.compile(r'[\\/:*?"<>|]+') + + +def _sanitize_filename_part(text: str) -> str: + text = _FILENAME_UNSAFE_RE.sub("-", text or "").strip() + return re.sub(r"\s+", " ", text) + + +def _article_download_filename(meta: dict, article: dict) -> str: + """A human-readable name for an exported MP3 -- the on-disk file stays + named by article id (stable, filesystem-safe, never collides); this is + only what a download or a zip entry is called, since that's what a + person actually sees in their music/podcast player or file browser.""" + parts = [meta.get("title") or "Magazine"] + page = (article.get("pages") or [None])[0] + if page is not None: + parts.append(f"p{page}") + parts.append(article.get("title") or "Untitled") + if article.get("author"): + parts.append(article["author"]) + name = " - ".join(_sanitize_filename_part(p) for p in parts if p) + return (name[:180] or article.get("id") or "article") + ".mp3" + + @router.get("/api/magazine/{doc_id}/articles/{article_id}/audio") async def magazine_article_audio_file(doc_id: str, article_id: str): - d, _ = _require_magazine(doc_id) + d, meta = _require_magazine(doc_id) article_id = _article_id(article_id) path = d / "audio" / f"{article_id}.mp3" if not path.is_file(): raise HTTPException(404, "No audio generated yet") - return FileResponse(path, media_type="audio/mpeg", filename=f"{article_id}.mp3") + articles = mag.load_articles(d) + article = next((a for a in articles if a.get("id") == article_id), {"id": article_id}) + return FileResponse(path, media_type="audio/mpeg", filename=_article_download_filename(meta, article)) def _audio_generate_batch_sync(doc_id: str, job_id: str, article_ids: list[str], voice: str) -> None: @@ -1759,13 +1785,27 @@ async def magazine_generate_batch_audio(doc_id: str, request: Request): async def magazine_audio_zip(doc_id: str): d, meta = _require_magazine(doc_id) audio_dir = d / "audio" - files = sorted(audio_dir.glob("*.mp3")) if audio_dir.is_dir() else [] - if not files: + if not audio_dir.is_dir(): + raise HTTPException(404, "No audio generated yet") + articles = mag.load_articles(d) + # Reading order, not filesystem/alpha order, so extracting the zip and + # sorting by filename plays back in the magazine's own order. + ordered = sorted( + (a for a in articles if (audio_dir / f"{a['id']}.mp3").is_file()), + key=lambda a: (a.get("pages") or [0])[0], + ) + if not ordered: raise HTTPException(404, "No audio generated yet") buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf: - for f in files: - zf.write(f, arcname=f.name) + for i, a in enumerate(ordered, start=1): + page = (a.get("pages") or [0])[0] + name = _article_download_filename(meta, a) + # Zero-padded sequence prefix so extracting the zip and sorting + # by filename gives the reading order even where two articles + # start on the same page. + arcname = f"{i:03d} - " + name + zf.write(audio_dir / f"{a['id']}.mp3", arcname=arcname) buf.seek(0) safe_title = re.sub(r"[^\w \-]+", "", meta.get("title") or "magazine").strip() or "magazine" return Response(content=buf.getvalue(), media_type="application/zip", diff --git a/static/index.html b/static/index.html index 78aaf1c..1bd0771 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,12 +27,12 @@ - - - - - - + + + + + + @@ -403,7 +403,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/magazine.js b/static/js/magazine.js index d35385a..2dd8850 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -25,6 +25,7 @@ const state = { contentsView: 'list', // list | grid — how the article list itself is shown contentsSelectMode: false, // batch-select articles for bulk approve/reject contentsSelected: new Set(), + playlistCurrent: null, // article id of the MP3 playlist's current track pane: 'reflow', // reflow | pdf playing: false, cancel: false, @@ -848,6 +849,7 @@ async function magGenerateAllAudio() { if (status) status.textContent = ''; await magRefreshArticles(); renderContents(); + if (!$('mag-playlist')?.hidden) renderPlaylistTracks(); } function magUpdateAudioZipLink() { @@ -2963,14 +2965,101 @@ function playBlob(blob, sentence) { // ── Play all approved articles in sequence ────────────────────────────── async function magPlayAll() { - const ids = state.articles.filter((a) => a.status === 'approved').map((a) => a.id); - if (!ids.length) return say('Approve at least one article first', 'err'); + const approved = state.articles.filter((a) => a.status === 'approved'); + if (!approved.length) return say('Approve at least one article first', 'err'); + // Every approved article already has a generated MP3: use those directly + // (ready instantly, tagged, works exactly like the standalone export) + // instead of synthesising sentence by sentence live. A queue with even + // one ungenerated article keeps the original live-reading behaviour, + // since a listener partway through a magazine mid-drive should not hit a + // gap where one piece silently has no audio. + if (approved.every((a) => a.has_audio)) { + magTogglePlaylist(true); + magPlaylistPlay(_magPlaylistOrder()[0]?.id); + return; + } + const ids = approved.map((a) => a.id); state.queue = ids; state.queueIndex = 0; await magRead(ids[0], { fromStart: true }); magPlay(); } +// ── MP3 Playlist (generated audio, reading order) ─────────────────────────── +// A separate, self-contained playback path from the live sentence-by- +// sentence reader above: this just points a plain