Release v1.46.0
Some checks are pending
Test and SonarQube / quality (push) Waiting to run

This commit is contained in:
mARTin-B78 2026-09-22 21:16:42 +02:00
parent 0e75b24a7a
commit bc3f82d975
7 changed files with 213 additions and 18 deletions

View File

@ -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<page> - 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 ## [1.45.0] — 2026-09-22
### Added ### 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.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.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 [1.43.1]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.43.0...v1.43.1

View File

@ -1 +1 @@
1.45.0 1.46.0

View File

@ -1703,14 +1703,40 @@ async def magazine_generate_article_audio(doc_id: str, article_id: str, request:
return {"job_id": job_id} 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") @router.get("/api/magazine/{doc_id}/articles/{article_id}/audio")
async def magazine_article_audio_file(doc_id: str, article_id: str): 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) article_id = _article_id(article_id)
path = d / "audio" / f"{article_id}.mp3" path = d / "audio" / f"{article_id}.mp3"
if not path.is_file(): if not path.is_file():
raise HTTPException(404, "No audio generated yet") 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: 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): async def magazine_audio_zip(doc_id: str):
d, meta = _require_magazine(doc_id) d, meta = _require_magazine(doc_id)
audio_dir = d / "audio" audio_dir = d / "audio"
files = sorted(audio_dir.glob("*.mp3")) if audio_dir.is_dir() else [] if not audio_dir.is_dir():
if not files: 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") raise HTTPException(404, "No audio generated yet")
buf = io.BytesIO() buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf: with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
for f in files: for i, a in enumerate(ordered, start=1):
zf.write(f, arcname=f.name) 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) buf.seek(0)
safe_title = re.sub(r"[^\w \-]+", "", meta.get("title") or "magazine").strip() or "magazine" safe_title = re.sub(r"[^\w \-]+", "", meta.get("title") or "magazine").strip() or "magazine"
return Response(content=buf.getvalue(), media_type="application/zip", return Response(content=buf.getvalue(), media_type="application/zip",

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.45.0"> <meta name="app-version" content="1.46.0">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,12 +27,12 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.45.0"> <link rel="stylesheet" href="/static/style.css?v=1.46.0">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.45.0"> <link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.46.0">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.45.0"> <link rel="stylesheet" href="/static/css/background-jobs.css?v=1.46.0">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.45.0"> <link rel="stylesheet" href="/static/css/voice-design.css?v=1.46.0">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.45.0"> <link rel="stylesheet" href="/static/css/rehearser.css?v=1.46.0">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.45.0"> <link rel="stylesheet" href="/static/css/audiobook.css?v=1.46.0">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -403,7 +403,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.45.0"></script> <script src="/static/loader.js?v=1.46.0"></script>
</body> </body>
</html> </html>

View File

@ -25,6 +25,7 @@ const state = {
contentsView: 'list', // list | grid — how the article list itself is shown contentsView: 'list', // list | grid — how the article list itself is shown
contentsSelectMode: false, // batch-select articles for bulk approve/reject contentsSelectMode: false, // batch-select articles for bulk approve/reject
contentsSelected: new Set(), contentsSelected: new Set(),
playlistCurrent: null, // article id of the MP3 playlist's current track
pane: 'reflow', // reflow | pdf pane: 'reflow', // reflow | pdf
playing: false, playing: false,
cancel: false, cancel: false,
@ -848,6 +849,7 @@ async function magGenerateAllAudio() {
if (status) status.textContent = ''; if (status) status.textContent = '';
await magRefreshArticles(); await magRefreshArticles();
renderContents(); renderContents();
if (!$('mag-playlist')?.hidden) renderPlaylistTracks();
} }
function magUpdateAudioZipLink() { function magUpdateAudioZipLink() {
@ -2963,14 +2965,101 @@ function playBlob(blob, sentence) {
// ── Play all approved articles in sequence ────────────────────────────── // ── Play all approved articles in sequence ──────────────────────────────
async function magPlayAll() { async function magPlayAll() {
const ids = state.articles.filter((a) => a.status === 'approved').map((a) => a.id); const approved = state.articles.filter((a) => a.status === 'approved');
if (!ids.length) return say('Approve at least one article first', 'err'); 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.queue = ids;
state.queueIndex = 0; state.queueIndex = 0;
await magRead(ids[0], { fromStart: true }); await magRead(ids[0], { fromStart: true });
magPlay(); 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 <audio> element at the
// already-rendered, already-tagged files from the MP3 export feature, one
// after another. No synthesis wait, no karaoke word-timing to estimate --
// exactly what "driving mode" wants, when the audio already exists.
function _magPlaylistOrder() {
return state.articles
.filter((a) => a.has_audio)
.slice()
.sort((a, b) => (a.pages?.[0] ?? 0) - (b.pages?.[0] ?? 0));
}
function magTogglePlaylist(forceOpen) {
const panel = $('mag-playlist');
const btn = $('mag-playlist-toggle');
if (!panel) return;
const open = forceOpen === true ? true : forceOpen === false ? false : panel.hidden;
panel.hidden = !open;
if (btn) btn.setAttribute('aria-pressed', String(open));
if (open) renderPlaylistTracks();
else { const audio = $('mag-playlist-audio'); if (audio) audio.pause(); }
}
function renderPlaylistTracks() {
const list = $('mag-playlist-tracks');
if (!list) return;
const order = _magPlaylistOrder();
if (!order.length) {
list.innerHTML = '<li class="muted">No generated MP3s yet — use "Generate all audio" above.</li>';
return;
}
list.innerHTML = order.map((a, i) => {
const active = a.id === state.playlistCurrent;
return '<li class="mag-playlist-track' + (active ? ' is-playing' : '') + '" ' +
'onclick="magPlaylistPlay(\'' + a.id + '\')">' +
'<span class="mag-playlist-track-num">' + (active ? '<span class="mdi mdi-volume-high" aria-hidden="true"></span>' : i + 1) + '</span>' +
'<span class="mag-playlist-track-main">' +
'<span class="mag-playlist-track-title">' + esc(a.title || 'Untitled') + '</span>' +
(a.author ? '<span class="mag-playlist-track-sub muted">by ' + esc(a.author) + '</span>' : '') +
'</span>' +
'<span class="muted">' + fmtPages(a.pages) + ' · ' + fmtDuration(a.estimated_seconds) + '</span>' +
'</li>';
}).join('');
}
function magPlaylistPlay(articleId) {
if (!articleId) return;
const a = state.articles.find((x) => x.id === articleId);
if (!a) return;
state.playlistCurrent = articleId;
const audio = $('mag-playlist-audio');
if (audio) {
audio.src = '/api/magazine/' + state.doc.id + '/articles/' + articleId + '/audio';
audio.play().catch(() => {});
}
const titleEl = $('mag-playlist-title');
const subEl = $('mag-playlist-sub');
if (titleEl) titleEl.textContent = a.title || 'Untitled';
if (subEl) subEl.textContent = (a.author ? 'by ' + a.author + ' · ' : '') + fmtPages(a.pages);
const cover = $('mag-playlist-cover');
if (cover) {
const firstPage = (a.pages || [])[0];
if (firstPage) { cover.src = '/api/magazine/' + state.doc.id + '/pages/' + firstPage; cover.hidden = false; }
else cover.hidden = true;
}
renderPlaylistTracks();
}
function _magPlaylistNext() {
const order = _magPlaylistOrder();
const i = order.findIndex((a) => a.id === state.playlistCurrent);
if (i >= 0 && i < order.length - 1) magPlaylistPlay(order[i + 1].id);
}
function magStopQueue() { function magStopQueue() {
state.queue = []; state.queue = [];
state.queueIndex = -1; state.queueIndex = -1;
@ -3231,6 +3320,11 @@ function syncDrivingPlayButton() {
async function magOnShow() { async function magOnShow() {
setupMagDropzone(); setupMagDropzone();
setupMagIngestPreviewResize(); setupMagIngestPreviewResize();
const playlistAudio = $('mag-playlist-audio');
if (playlistAudio && !playlistAudio.dataset.wired) {
playlistAudio.dataset.wired = '1';
playlistAudio.addEventListener('ended', _magPlaylistNext);
}
if ($('mag-library-list')) { if ($('mag-library-list')) {
magLoadLibrary(); magLoadLibrary();
magLoadVoices(); magLoadVoices();
@ -3240,6 +3334,11 @@ async function magOnShow() {
async function magInit() { async function magInit() {
setupMagDropzone(); setupMagDropzone();
setupMagIngestPreviewResize(); setupMagIngestPreviewResize();
const playlistAudio = $('mag-playlist-audio');
if (playlistAudio && !playlistAudio.dataset.wired) {
playlistAudio.dataset.wired = '1';
playlistAudio.addEventListener('ended', _magPlaylistNext);
}
try { try {
const savedView = localStorage.getItem(MAG_CONTENTS_VIEW_KEY); const savedView = localStorage.getItem(MAG_CONTENTS_VIEW_KEY);
if (savedView === 'grid' || savedView === 'list') state.contentsView = savedView; if (savedView === 'grid' || savedView === 'list') state.contentsView = savedView;
@ -3276,6 +3375,8 @@ root.magToggleContentsView = magToggleContentsView;
root.magContentsToggleSelect = magContentsToggleSelect; root.magContentsToggleSelect = magContentsToggleSelect;
root.magContentsCommitTitle = magContentsCommitTitle; root.magContentsCommitTitle = magContentsCommitTitle;
root.magGenerateAllAudio = magGenerateAllAudio; root.magGenerateAllAudio = magGenerateAllAudio;
root.magTogglePlaylist = magTogglePlaylist;
root.magPlaylistPlay = magPlaylistPlay;
root.magContentsToggleArticle = magContentsToggleArticle; root.magContentsToggleArticle = magContentsToggleArticle;
root.magContentsSelectAllVisible = magContentsSelectAllVisible; root.magContentsSelectAllVisible = magContentsSelectAllVisible;
root.magContentsClearSelection = magContentsClearSelection; root.magContentsClearSelection = magContentsClearSelection;

View File

@ -64,6 +64,10 @@
<button type="button" class="btn-sm" id="mag-contents-select-toggle" aria-pressed="false" onclick="magContentsToggleSelect()" title="Select several articles to approve or reject at once"> <button type="button" class="btn-sm" id="mag-contents-select-toggle" aria-pressed="false" onclick="magContentsToggleSelect()" title="Select several articles to approve or reject at once">
<span class="mdi mdi-checkbox-multiple-marked-outline"></span> Select <span class="mdi mdi-checkbox-multiple-marked-outline"></span> Select
</button> </button>
<button type="button" class="btn-sm" id="mag-playlist-toggle" aria-pressed="false" onclick="magTogglePlaylist()"
title="Show every article with a generated MP3, in reading order, with a player">
<span class="mdi mdi-playlist-music-outline"></span> Playlist
</button>
<button type="button" class="btn-sm primary" onclick="magPlayAll()"> <button type="button" class="btn-sm primary" onclick="magPlayAll()">
<span class="mdi mdi-playlist-play"></span> Play all articles <span class="mdi mdi-playlist-play"></span> Play all articles
</button> </button>
@ -88,6 +92,20 @@
</a> </a>
<span id="mag-audio-export-status" class="muted" role="status" aria-live="polite"></span> <span id="mag-audio-export-status" class="muted" role="status" aria-live="polite"></span>
</div> </div>
<div id="mag-playlist" class="mag-playlist" hidden>
<div class="mag-playlist-player">
<img id="mag-playlist-cover" class="mag-playlist-cover" alt="" hidden>
<div class="mag-playlist-nowplaying">
<strong id="mag-playlist-title">—</strong>
<span id="mag-playlist-sub" class="muted"></span>
<audio id="mag-playlist-audio" controls preload="none" style="width:100%"></audio>
</div>
<button type="button" class="btn-sm" onclick="magTogglePlaylist()" title="Close playlist" aria-label="Close playlist">
<span class="mdi mdi-close"></span>
</button>
</div>
<ol id="mag-playlist-tracks" class="mag-playlist-tracks"></ol>
</div>
<div id="mag-ingest-status" class="mag-status" hidden></div> <div id="mag-ingest-status" class="mag-status" hidden></div>
<!-- Decorative "watch it work" visualisation -- the essential <!-- Decorative "watch it work" visualisation -- the essential
progress information (percentage, page count, done/failed) is progress information (percentage, page count, done/failed) is

View File

@ -4934,6 +4934,33 @@ input[list="lang-codes"] {
padding: 10px 14px; margin-bottom: 12px; border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; margin-bottom: 12px; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--panel); } background: var(--panel); }
.mag-audio-export label { color: var(--subtext); font-size: 13px; } .mag-audio-export label { color: var(--subtext); font-size: 13px; }
/* MP3 playlist -- a separate, self-contained player from the live reader:
just an <audio> element pointed at already-rendered files, one track
after another. Sticky player bar + scrollable track list underneath. */
.mag-playlist { border: 1px solid var(--border); border-radius: var(--radius);
background: var(--panel); margin-bottom: 12px; overflow: hidden; }
.mag-playlist-player { display: flex; align-items: center; gap: 12px; padding: 10px 14px;
background: var(--surface); border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 2; }
.mag-playlist-cover { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
.mag-playlist-nowplaying { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.mag-playlist-nowplaying strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mag-playlist-tracks { list-style: none; margin: 0; padding: 4px; max-height: 420px; overflow-y: auto; }
.mag-playlist-track { display: flex; align-items: center; gap: 10px; padding: 8px 10px;
border-radius: 6px; cursor: pointer; }
.mag-playlist-track:hover { background: var(--surface); }
.mag-playlist-track.is-playing { background: var(--surface); border-left: 3px solid var(--accent); }
.mag-playlist-track-num { width: 22px; text-align: center; color: var(--subtext); flex-shrink: 0; }
.mag-playlist-track-num .mdi { color: var(--accent); }
.mag-playlist-track-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.mag-playlist-track-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mag-playlist-track-sub { font-size: 12px; }
@media (max-width: 640px) {
.mag-playlist-player { flex-wrap: wrap; }
.mag-playlist-nowplaying { order: 3; flex-basis: 100%; }
.mag-playlist-track .muted:last-child { display: none; }
}
.mag-audio-export select { padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; .mag-audio-export select { padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px;
background: var(--surface); color: var(--text); } background: var(--surface); color: var(--text); }