This commit is contained in:
parent
0e75b24a7a
commit
bc3f82d975
11
CHANGELOG.md
11
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<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
|
||||
|
||||
### 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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<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="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||
@ -27,12 +27,12 @@
|
||||
|
||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||
<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/css/resource-diagnostics.css?v=1.45.0">
|
||||
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.45.0">
|
||||
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.45.0">
|
||||
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.45.0">
|
||||
<link rel="stylesheet" href="/static/css/audiobook.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.46.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.46.0">
|
||||
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.46.0">
|
||||
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.46.0">
|
||||
|
||||
|
||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||
@ -403,7 +403,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
||||
</script>
|
||||
|
||||
<!-- 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>
|
||||
</html>
|
||||
|
||||
@ -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 <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() {
|
||||
state.queue = [];
|
||||
state.queueIndex = -1;
|
||||
@ -3231,6 +3320,11 @@ function syncDrivingPlayButton() {
|
||||
async function magOnShow() {
|
||||
setupMagDropzone();
|
||||
setupMagIngestPreviewResize();
|
||||
const playlistAudio = $('mag-playlist-audio');
|
||||
if (playlistAudio && !playlistAudio.dataset.wired) {
|
||||
playlistAudio.dataset.wired = '1';
|
||||
playlistAudio.addEventListener('ended', _magPlaylistNext);
|
||||
}
|
||||
if ($('mag-library-list')) {
|
||||
magLoadLibrary();
|
||||
magLoadVoices();
|
||||
@ -3240,6 +3334,11 @@ async function magOnShow() {
|
||||
async function magInit() {
|
||||
setupMagDropzone();
|
||||
setupMagIngestPreviewResize();
|
||||
const playlistAudio = $('mag-playlist-audio');
|
||||
if (playlistAudio && !playlistAudio.dataset.wired) {
|
||||
playlistAudio.dataset.wired = '1';
|
||||
playlistAudio.addEventListener('ended', _magPlaylistNext);
|
||||
}
|
||||
try {
|
||||
const savedView = localStorage.getItem(MAG_CONTENTS_VIEW_KEY);
|
||||
if (savedView === 'grid' || savedView === 'list') state.contentsView = savedView;
|
||||
@ -3276,6 +3375,8 @@ root.magToggleContentsView = magToggleContentsView;
|
||||
root.magContentsToggleSelect = magContentsToggleSelect;
|
||||
root.magContentsCommitTitle = magContentsCommitTitle;
|
||||
root.magGenerateAllAudio = magGenerateAllAudio;
|
||||
root.magTogglePlaylist = magTogglePlaylist;
|
||||
root.magPlaylistPlay = magPlaylistPlay;
|
||||
root.magContentsToggleArticle = magContentsToggleArticle;
|
||||
root.magContentsSelectAllVisible = magContentsSelectAllVisible;
|
||||
root.magContentsClearSelection = magContentsClearSelection;
|
||||
|
||||
@ -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">
|
||||
<span class="mdi mdi-checkbox-multiple-marked-outline"></span> Select
|
||||
</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()">
|
||||
<span class="mdi mdi-playlist-play"></span> Play all articles
|
||||
</button>
|
||||
@ -88,6 +92,20 @@
|
||||
</a>
|
||||
<span id="mag-audio-export-status" class="muted" role="status" aria-live="polite"></span>
|
||||
</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>
|
||||
<!-- Decorative "watch it work" visualisation -- the essential
|
||||
progress information (percentage, page count, done/failed) is
|
||||
|
||||
@ -4934,6 +4934,33 @@ input[list="lang-codes"] {
|
||||
padding: 10px 14px; margin-bottom: 12px; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--panel); }
|
||||
.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;
|
||||
background: var(--surface); color: var(--text); }
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user