fix(now-playing): episode as title, podcast as artist on both paths

The card put the podcast name in the title slot ('Podcast — Episode'
prefix truncating on long names) with an empty artist slot for local
playback — the downloaded files carry no artist tag. Now:
- mediaTitle is the episode title only (UI + CLI); streams keep their
  artist from stream tags, matching Apple Podcasts' title/artist layout.
- downloads are tagged at completion (ffmpeg -c copy, atomic rename):
  title=episode, artist=podcast — verified on a real 72MB file in 1.5s.
This commit is contained in:
2026-08-12 11:10:07 -04:00
parent b5432e3e5f
commit e7ed89056e
3 changed files with 37 additions and 8 deletions

View File

@@ -348,7 +348,7 @@ async function play(episode: Episode): Promise<void> {
volume: vol, volume: vol,
speed: spd, speed: spd,
startPosition: startPos > 0 ? startPos : undefined, startPosition: startPos > 0 ? startPos : undefined,
mediaTitle: podcastTitle ? `${podcastTitle}${episode.title}` : episode.title, mediaTitle: episode.title,
coverArtPath: coverArtPath ?? undefined, coverArtPath: coverArtPath ?? undefined,
}); });
@@ -444,9 +444,7 @@ async function load(episode: Episode): Promise<void> {
volume: volume(), volume: volume(),
speed: storeSpeed || speed(), speed: storeSpeed || speed(),
startPosition: pos > 0 ? pos : undefined, startPosition: pos > 0 ? pos : undefined,
mediaTitle: podcastTitle mediaTitle: episode.title,
? `${podcastTitle}${episode.title}`
: episode.title,
coverArtPath: coverArtPath ?? undefined, coverArtPath: coverArtPath ?? undefined,
}) })
.catch(() => {}); .catch(() => {});
@@ -620,9 +618,7 @@ async function switchBackend(name: BackendName): Promise<void> {
startPosition: pos, startPosition: pos,
volume: vol, volume: vol,
speed: spd, speed: spd,
mediaTitle: podcastTitle mediaTitle: ep.title,
? `${podcastTitle}${ep.title}`
: ep.title,
coverArtPath: coverArtPath ?? undefined, coverArtPath: coverArtPath ?? undefined,
}); });
setIsPlaying(true); setIsPlaying(true);

View File

@@ -195,7 +195,7 @@ async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
? await fetchCoverArt(coverUrl) ? await fetchCoverArt(coverUrl)
: null : null
await backend.play(episodeResult.audioUrl, { await backend.play(episodeResult.audioUrl, {
mediaTitle: `${feedResult.podcast.title}${episodeResult.title}`, mediaTitle: episodeResult.title,
coverArtPath: coverArtPath ?? undefined, coverArtPath: coverArtPath ?? undefined,
}) })
console.log("Playback started (use the UI to control)") console.log("Playback started (use the UI to control)")

View File

@@ -269,6 +269,39 @@ function createDownloadStore() {
.exited.catch(() => {}); .exited.catch(() => {});
} }
} }
// Tag the local file (codec-copy, no re-encode) so mpv's Now
// Playing metadata for local playback is title=episode,
// artist=podcast — the source streams carry no usable tags and
// macOS composes "title - artist" from exactly these fields.
// Atomic: ffmpeg writes a temp file, then renames into place.
if (result.filePath && episode) {
const podcastTitle =
feedStore.feeds().find((f) => f.id === item.feedId)?.podcast.title ??
downloads().get(item.episodeId)?.podcastTitle;
if (podcastTitle) {
const tmp = `${result.filePath}.tag`;
Bun.spawn([
"ffmpeg",
"-y",
"-i",
result.filePath,
"-c",
"copy",
"-metadata",
`title=${episode.title}`,
"-metadata",
`artist=${podcastTitle}`,
tmp,
])
.exited.then(async (code) => {
if (code !== 0) return;
const { renameSync } = await import("node:fs");
renameSync(tmp, result.filePath);
})
.catch(() => {});
}
}
} else { } else {
updateDownload(item.episodeId, { updateDownload(item.episodeId, {
status: DownloadStatus.FAILED, status: DownloadStatus.FAILED,