Media pipeline
Everything that turns a (library_id, rel_path) into bytes a player can render
lives in two places: internal/media (pure logic, no HTTP routing) and the two
media handlers in internal/api/handlers_library.go (handleStream,
handleCover). This page follows a request end to end. It explains the design
decisions that are easy to break by accident. Most of them exist because of one
strict client, iOS AVPlayer.
For the invariants behind this page ("path is the identity", "stream the file, not the book"), see Architecture invariants. For the request and response shapes, see the API reference.
The endpoints
| Route | Handler | Purpose |
|---|---|---|
GET /api/v1/libraries/{id}/stream?path= | handleStream | Serve one audio file with Range streaming. ?download=1 forces a download and ?transcode=1&t= transcodes. An MP3-in-Matroska .mka is served as its cached remux to MP3 unless ?original=1. ?rendition=watch serves the file's cached watch rendition |
POST/GET /api/v1/libraries/{id}/renditions?path= | handlePrepareRenditions / handleRenditionStatus | Queue (POST) or report (GET) the watch renditions of every audio file of a book |
GET /api/v1/libraries/{id}/cover?path= | handleCover | Serve a book's cover: the custom cover, else a sidecar image, else embedded art. With ?size=96|160|320|640, a JPEG thumbnail of it (handleCoverThumbnail). Size 96 is accepted from 2.5.1 |
POST /api/v1/admin/covers | handleAdminCovers | Admin only: JPEG thumbnails of many books' covers as data: URLs, for the admin console |
GET /api/v1/libraries/{id}/chapters?path= | handleChapters | The normalized playable-units envelope {chapters, files, duration, …}. Warms the book's remuxes |
GET /api/v1/libraries/{id}/item?path= | handleItem | Book detail. Carries direct_playable and warms the book's remuxes |
There is no separate download endpoint. A download is ?download=1 on
/stream. It makes media.ServeFile add a Content-Disposition: attachment
header with the quoted base filename, so browsers save the file instead of
playing it. A remuxed .mka downloads as <stem>.mp3, since the bytes are MP3.
On every media request, two things happen before any bytes are read:
- Authorization.
authorizedPathresolves{id}and?path=and checks the caller's share scope (Scope.Allows). Thecoverandstreamroutes are wired throughrequireBookMediaAuth. LikerequireMediaAuthon/meta/cover, it also accepts the session token as a?token=query parameter, because browser<img>and<audio>elements cannot set anAuthorizationheader. On these two routes it also accepts a cast token. Every other route rejects query tokens, with two exceptions:- the Upcoming cover (
/me/upcoming/cover), which also usesrequireMediaAuth; - the calendar feed (
releases.ics), which takes afeedtoken only (see Auth & security).
- the Upcoming cover (
- Path safety.
library.SafeJoinjoins the relative path to the library root and rejects any traversal outside the root.
Direct streaming: media.ServeFile
ServeFile opens the file and hands it to Go's http.ServeContent, which
provides HTTP Range support with no custom code. Seeking and scrubbing in
browsers, native players' byte-range fetches and resumable downloads all rely
on it. ServeFile sets Accept-Ranges: bytes explicitly.
The one thing ServeContent gets wrong for audiobooks is the Content-Type,
so ServeFile sets it explicitly first.
Why Content-Type is byte-sniffed (the iOS -12847 story)
Go's mime table and http.DetectContentType do not recognize .m4b or .aax,
so ServeContent would fall back to application/octet-stream. The server
also sends X-Content-Type-Options: nosniff globally (see the secureHeaders
middleware), which forbids the client from second-guessing that type. Strict
players, iOS AVPlayer in particular, then refuse the stream outright
(MediaToolbox error -12847). The fix has two layers:
-
sniffAudioTypereads the first 16 bytes of the actual file, then rewinds, so the type is correct even for mislabeled files:Leading bytes Result Notes fLaCaudio/flacOggSaudio/oggOgg container (Vorbis/Opus) RIFF+WAVEat offset 8audio/wavID3audio/mpegID3-tagged MP3 0xFFwithb[1] & 0xF6 == 0xF0audio/aacRaw ADTS AAC (e.g. per-chapter .aacfiles). Checked before the MP3 case because the ADTS sync word also matches the MPEG mask0xFFwithb[1] & 0xE0 == 0xE0audio/mpegBare MPEG audio frame sync (untagged MP3) ftypat offset 4audio/mp4ISO base media: .m4a/.m4b/.mp4, AAC or ALAC inside1A 45 DF A3audio/x-matroskaEBML header: Matroska audio ( .mka), MP3 or another codec inside. Only the stored bytes are served with this type. While ffmpeg is configured, an MP3-in-Matroska file goes out as its remux,audio/mpeg -
audioContentTypefalls back to the extension when the sniff comes up empty:Extension Type .m4b,.m4a,.aax,.mp4audio/mp4.mp3audio/mpeg.aacaudio/aac.flacaudio/flac.ogg,.ogaaudio/ogg.opusaudio/opus.wav,.waveaudio/wav.mkaaudio/x-matroskaFor any other extension,
ServeContentdecides.
If you add a new audio format, extend both sniffAudioType and
audioContentType, and the recognized-extension set below. A format that
streams with the wrong Content-Type looks fine in Chrome and silently fails
on iOS.
Recognized audio: metadata.AudioExtensions
The scanner and the /fs browse view treat a file as an audiobook when its
extension is in metadata.AudioExtensions (checked via metadata.IsAudio):
.m4b .m4a .mp4 .mp3 .flac .ogg .opus .mka
.mp4 is included because audiobooks are sometimes delivered as AAC-in-MP4.
media serves it as audio/mp4.
.mka is Matroska audio, often MP3 with its chapters, as Audiobookshelf reads
it. The scanner indexes it and reads its chapters with ffprobe. No browser and
no iOS player opens the container, so how it plays depends on what is inside and
on ffmpeg:
- MP3 inside, ffmpeg configured. The stream URL serves a lossless copy of
the audio as plain MP3
(Remuxing Matroska). Every client
streams it, old native builds included, and
/itemand/chapterssaydirect_playable: true. - Another codec inside (AAC, Opus, FLAC), or no ffmpeg. The server
serves the stored bytes as
audio/x-matroska, and the book is not direct-playable. The web player streams it through?transcode=1, which needs ffmpeg. The iOS app can't play it. Android's Media3 opens Matroska itself, so the Android app plays it where the device decodes the codec inside.
An .mka beside a file of the same name in another audio format is left out
of the book (folderAudio). Like an .aax beside its .m4b, it is that file's
source or copy, and reading both would give the book its chapters twice. The
/fs listing applies the same rule (see
the filesystem view).
Audible's DRM formats (.aax, .aaxc) are deliberately excluded. The
server can never stream them, because they need per-account decryption. Indexing
an .aax that sits next to its converted .m4b would also lump both into one
book, with duplicated chapters and an unplayable track.
audioContentType knows a slightly broader set (for example .aax and .wav),
so the server sends a correct type even for files the scanner would not index.
Covers: handleCover
Cover resolution has three tiers. An admin's custom cover wins, then a sidecar image found by the scanner, and embedded art is the fallback. For all three, the handler first authorizes the path against the caller's share scope.
- Custom (
catalog.Cover, thebook_coverstable). An image uploaded throughPUT /admin/libraries/{id}/coverand stored in the database, never in the library folder.- It is served only while a book is indexed at the path (
CoverInfoandCoverjoinbooks), so a pruned book's cover row stays dormant until the book returns. - The handler tries the requested path first. When that path is a part
inside a folder book, it tries the book's own path next, after
bookForPathresolves it. - The cover is served with its stored MIME type through
http.ServeContent, withCache-Control: private, no-cacheand anETag(coverETag:"cover-<base-36 Unix nanoseconds of updated_at>"). An admin can replace it at any moment, so clients revalidate rather than cache it for a day. - A matching
If-None-Matchis answered304from the cover's row alone (catalog.CoverInfo), without reading the image blob. - The validator is deliberately an ETag and not
Last-Modified. With a date validator, a client revalidating after the custom cover was deleted could get a304from the older sidecar file and keep showing the removed cover.
- It is served only while a book is indexed at the path (
- Sidecar (
books.cover_path, set at scan time byfindCoverininternal/library/scanner.go). A conventionally named file (cover.jpg,cover.jpeg,cover.png,folder.jpg,folder.png) in the book's own folder for a folder book, or next to the file for a loose single-file book.- Inside a folder book a stray image is almost certainly the cover, so any
image (
.jpg .jpeg .png .webp .gif) is accepted as a fallback. The scanner prefers a filename containing "cover", else the first image alphabetically. - Multi-CD subfolders (
CD1,Disc 2, …) look one level up for the parent book's art. - A sidecar cover is served through
ServeFile, so it gets Range and correct headers, withCache-Control: private, max-age=86400. The header is set only when the file is there, so a 404 is never cached. Without an explicit lifetime, a browser keeps a sidecar image fresh by heuristic (a tenth of the file's age), and a custom cover uploaded later could go unseen for weeks.
- Inside a folder book a stray image is almost certainly the cover, so any
image (
- Embedded (
media.EmbeddedCover). The handler reads the book's primary audio file withdhowden/tagand returns its embedded picture. The primary file is the firstfilesentry for a folder book, and the file itself otherwise.- The
Content-Typecomes from the picture's bytes (coverMIME,http.DetectContentTypeagainst an allow-list: JPEG, PNG, GIF, WebP, BMP), never from the MIME type the tag declares. Data that sniffs as anything else is treated as no art. The response comes from this origin, so trusting the tag would let a crafted audio file declaretext/htmland have the server serve a page. - It is served with
Cache-Control: private, max-age=86400.
- The
When no tier has a cover, the answer is 404 {"error":"no cover"}.
Thumbnails for the admin console: POST /admin/covers
The admin console can't show covers as plain <img src> URLs, for two reasons.
Its session is a full-privilege admin token that must never ride in a URL, and
its CSP allows images only from 'self' and data:. So the console asks for
covers in batches through
POST /api/v1/admin/covers
(handleAdminCovers, internal/api/handlers_covers.go). One request takes up
to 60 books, and the answer gives each a small JPEG thumbnail as a data: URL.
A grid of hundreds of covers is then a handful of requests and about 20 KB a
cover, instead of full-size art.
- Resolving the art.
coverArtresolves the art in the same order ashandleCover: the custom cover by the requested path, then by the book the path resolves to, then the sidecar throughSafeJoin, then embedded art. It never indexes on demand. - Scaling.
media.Thumbnailscales to fit within 96, 160, 320 or 640 pixels (thumbSizes) and encodes a JPEG at quality 80. 96 is a table row's cover. It never scales up. - Small JPEGs. A JPEG that is already that small is re-encoded at quality
80, and
Thumbnailreturns the smaller of that and the original bytes. Acover.jpgis often saved near quality 95, about twice the bytes for a 180 px tile. One saved at a lower quality comes out larger from the re-encode, so the original is kept. - Returned as is.
Thumbnailreturns the source unchanged in these cases:- it is CMYK, or carries an EXIF orientation other than 1 or an ICC colour
profile (
ownRendering). Go's encoder writes neither, so a re-encode would show it turned or in other colours. - it doesn't decode fully;
- its segments can't be read.
- it is CMYK, or carries an EXIF orientation other than 1 or an ICC colour
profile (
- Size limit.
Thumbnailreads the image header first and refuses anything overMaxThumbnailSourcePixels(40 megapixels) before decoding, so a small file that claims huge dimensions can't exhaust memory. - Cache.
media.ThumbCacheis a byte-bounded LRU (48 MiB). Its key is the library, the path, the size and the art's version (a custom cover'supdated_at, or a file's size and modification time), so a changed cover misses the cache. It holds the raw JPEG, and the batch base64-encodes it into itsdata:URL. A read failure (an unreachable mount) is not cached. An undecodable image is cached as no art. - Concurrency. Two semaphores bound the work across all requests, the batch
and the player's
?size=alike.coverReads(8) bounds the art being read or waiting to be decoded, so a slow mount never has a grid's 60 reads in flight.thumbSem(4) bounds the decodes themselves. - Version. Each thumbnail carries a
version:catalog.CoverVersionof the colour record'sVersion, else of itsArt, else of the source'sArt. A first thumbnail moves the row's version throughRecordCoverColors.POST /admin/coversexplains why a client files a thumbnail under this version rather than the row's. The console's side is its thumbnail cache.
Thumbnails for the player: ?size=
The player asks for the same thumbnails one cover at a time, as an image URL:
GET /libraries/{id}/cover?path=&size=160|320|640 (handleCoverThumbnail,
capability cover_sizes). The player never sends 96, because an older server
refuses it. The route shares everything with the batch: coverArt,
coverThumbnail, the cache and both semaphores.
A path with no book indexed at it (a part, a disc folder, a book the scan hasn't
reached) is resolved like /item, through bookForPath. Unlike the batch,
bookForPath may index on demand. Any size outside thumbSizes, including an
empty one, is a 400.
- ETag. The
ETagis"thumb-<size>-<rev>-<hash of the art's own version>"(thumbETag). It is matched with the sameIf-None-Matchlist matcher as the server's other validators. rev.revismedia.ThumbnailRev. Change it wheneverThumbnailwould return other bytes for the same source. Every validator then changes, and clients fetch each cover once more rather than keep other bytes or resume a range of them.304and the colour. The handler answers a match with304before reading any art only once the book's colour is recorded for this art (src.Colored, with its identity equal to the art's version). Until then it makes the thumbnail or takes it from the cache, records the colour, and still sends the304. Clients that only revalidate therefore don't leave a book without its colour.Cache-Control.private, no-cachefor a custom cover andprivate, max-age=86400for sidecar or embedded art, mirroring the full-art tiers above. Both headers are set only once there is art to answer with, so a404is never cached.- Errors. A read failure (an unreachable mount) is a
500 could not load cover, and is not cached. Art that can't be decoded, or is over 40 megapixels, is a404 no cover. The full-art URL may still serve it, and clients fall back to it. - Body. The body is served through
http.ServeContentasimage/jpeg.
Cover identity, colours and version
Two derived columns on books (migration 0023) carry what the player's Book
JSON says about the cover:
cover_art, the cover's art identity. It is never sent itself.- From index data alone it is
cplus the custom cover'supdated_at, elsefplus the book's mtime, size and sidecar path (catalog.coverArtSQL, also the migration's backfill). Every writer of those inputs recomputes it (refreshCoverArt):UpsertBook,SetCover,DeleteCover, and a move or a disc join that carries a custom cover. - The wire's
cover_versionis its hash (catalog.CoverVersion), present on every indexed book. The hash is the first 10 hex characters of its SHA-256, so the wire carries no stamp, size or path. - The scan records file art by the art file's own version
(
catalog.FileArtVersion, the scan'sBook.ArtVersion): the sidecar or the first audio file, with its kind, size and mtime. This is the same name the cover handler gives its thumbnail. A re-index of unchanged art therefore keeps its version and colour, and art replaced in place gets a new version at the next scan. A scan that finds the audio unchanged still records a replaced sidecar (SetArtVersions). - The index form above is left only for a row with no file version. The value changes, not its shape.
- From index data alone it is
cover_color, the palette read from a thumbnail, stored asversion bgorversion bg accent on_accent. It is tagged with thecover_versionit was read for, and decoded onto the book only while that tag is current. New art needs nothing cleared, because the old colour stops counting.
The palette (media.CoverPalette, internal/media/covercolor.go) is read from
the thumbnail JPEG when a book lacks a colour for the art:
- It samples at most 64 x 64 pixels, with transparent areas counted as white.
- It quantizes each sample to 16 levels per channel, and takes the most common
bucket's average as
bg. accentis the most common vibrant bucket: HSL saturation at least 0.3, lightness 0.2-0.8, and at least 1/200 of the samples. Its lightness is nudged until it reaches a WCAG contrast of 4.5:1 againstbg.on_accentis white or black, whichever contrasts more.
A near-greyscale cover, or one whose vibrant colour cannot be made to read
against bg, has bg only.
catalog.RecordCoverColors stores the colour together with the art's own
version as the new cover_art. For file art that is the sidecar's or audio
file's size and modification time. A custom cover's stamp is unchanged. The
write is a compare-and-set on the identity the source was read under, so a slow
thumbnail of old art never overwrites what a cover upload just set.
The identity therefore moves once, at a book's first thumbnail of file art. From then on it follows a sidecar overwritten in place (which leaves the index as it was), and it equals the thumbnail ETag's hash. A re-index of a changed book recomputes the identity from index data, and the next thumbnail moves it again.
The write is detached from the request and bounded to 250 ms
(coverColorWriteTimeout). A busy writer costs only the colour, until the next
thumbnail.
The background colour pass
If colours were read only from the thumbnails people ask for, a library nobody
has browsed would have none. So internal/covercolors reads them in the
background.
When it runs. Its Runner waits until the start, or a burst of book
changes, has been quiet for 30 s (at most 10 minutes), then runs a pass. A book
change is anything Catalog.OnBookChange reports: an index write, an edit, or
a custom cover set or removed. The runner also runs a pass every hour, which
retries art that failed to read. A scan that indexes books changes one every
moment, so the pass waits for the scan to finish rather than compete with it
for the disk. A rescan that finds nothing new changes nothing, so it doesn't
hold the pass back.
What a pass reads. A pass walks catalog.CoverColorsDue in pages of 100,
in the index's own order. It lists the books that may have art (a custom cover,
or has_cover set or not yet checked by a scan) and hold no colour for their
current art, each with its CoverSource. The pass reads one book at a time, the
way a thumbnail would (api.colorCover). It uses coverArt and the same
coverReads and thumbSem bounds as the cover endpoints, so it never holds
more than one of their slots.
- A thumbnail of the art already in the cache is used as it is. Otherwise the pass makes a 160 px thumbnail and does not cache it, so a pass over a library never pushes out the thumbnails people are looking at.
- A book with no art, or none that decodes, is recorded as having no colour
(
cover_colorholds only the version). No pass reads it again until its art changes, across restarts too. A thumbnail that does decode still records its colour over that, since the pass's read may have failed in passing. - A book whose art files aren't there is
covercolors.ErrArtMissing. This covers an unmounted share, whose books the scan keeps indexed, and a book not pruned yet. Nothing is recorded, so the next pass reads it again once the share is back, and it doesn't count as a failure.
Writing and stopping. Each page's colours are recorded in one
RecordCoverColors transaction (bounded to 30 s), compare-and-set like any
other. Ten books in a row whose art fails to read (an unreadable mount) end the
pass until the next hour. A scan doesn't re-index unchanged books, so after its
first full pass the runner only reads books whose art is new.
DirectPlayable: when a client should transcode
The scanner records each book's audio codec in books.codec: ffprobe's
codec_name verbatim, or the
in-process probe's identical answer.
media.DirectPlayable(format, codec) reports whether mainstream browsers can
play the stored file natively.
A container that no browser plays (containerFormats: mka, by the file
extension in books.format) is never direct-playable, whatever the codec
inside. For a folder book every file's container counts, not only the first
file's. Two places apply this rule:
Book.PlaysInBrowsers, part of what/itemand/chapterssay (below);directPlayableExpr, built fromDirectPlayableSQL, for the admin console, which stays as stored.
Otherwise DirectPlayable checks the codec against this allow-list
(browserCodecs):
aac mp3 mp2 flac opus vorbis pcm_s16le
These are codec names, not containers. AAC-in-MP4 probes as aac (not
mp4a), and WAV as pcm_s16le. An empty codec (ffprobe unavailable, or the
book not yet probed) is treated as playable, and the client streams it
directly. There is no automatic retry through the transcoder on a playback
failure.
The flag is surfaced as direct_playable on the item and chapters
responses, so a web client knows up front that, for example, an AC-3 or ALAC
book needs the transcoder. What those two responses say is "plays in browsers
as the server serves it" (API.directPlayable). That is
Book.PlaysInBrowsers, or Book.PlaysRemuxed while the remux is on
(API.remuxOn).
PlaysRemuxed holds when the book's codec is mp3 and every file is either in
a browser container or an .mka holding MP3. Each .mka part is judged by its
own codec (Book.FileRemuxable). A part indexed before per-file codecs were
recorded is judged by the book's codec. An .mka book with AAC inside stays
false.
Web transcode negotiation describes how the web player acts on the flag. The native apps always stream directly.
Transcoding: media.Transcode
GET /libraries/{id}/stream?path=…&transcode=1[&t=<seconds>] pipes the file
through ffmpeg to MP3. The handler returns 503 when no ffmpeg is configured,
either because of --ffmpeg "" or because the binary was never found. The
transcode capability flag in GET /server reflects this (see
Configuration). The exact invocation:
ffmpeg -nostdin -loglevel error [-ss <t>] -i <abs path> \
-vn -c:a libmp3lame -b:a 128k -f mp3 pipe:1
A podcast episode is transcoded from the descriptor the server already
verified (media.TranscodeOpened), never by reopening its path. The server
rewinds the descriptor and hands it to ffmpeg as its stdin. When that ffmpeg has
the fd: protocol (ffmpeg 6.1 and later), ffmpeg reads it seekably through
fd::
ffmpeg -nostdin -loglevel error -protocol_whitelist fd [-ss <t>] -i fd: \
-vn -c:a libmp3lame -b:a 128k -f mp3 pipe:1
With fd:, -ss is a fast input seek, and a non-faststart m4a (its moov
index after the audio) demuxes.
inputFor asks each ffmpeg binary once per process whether its Input protocols
list fd (-hide_banner -protocols, 5 s at most, cached per path). A probe that
couldn't run is asked again on the next request. An older ffmpeg gets the
previous non-seekable form, -protocol_whitelist pipe -i pipe:0 -ss <t>, an
output seek that decodes up to t. Either way, the descriptor is the only input
ffmpeg may open. transcodeArgs builds both shapes and is unit-tested.
Design points worth knowing before touching it:
-ssis input-side (before-i) for a path and forfd:. ffmpeg seeks near the start position before decoding, which is fast even deep into a long m4b.-vndrops any embedded cover-art or video stream, so the output is pure audio.- The response is
Content-Type: audio/mpeg,Accept-Ranges: none,Cache-Control: no-store, and has noContent-Length, because the output is produced on the fly. AHEADgets these headers without an ffmpeg run. - Why it isn't byte-seekable. The total encoded size is unknown while
encoding, and a byte offset into the MP3 output has no computable mapping
back to a source timestamp. So the handler refuses Range requests, and a
client seeks by re-requesting with a new
t=value. This is exactly the?transcode=1&t=contract the frontend's stream URLs implement. - The handler creates the ffmpeg process with
exec.CommandContext(r.Context(), …), so a client disconnect (pause, seek, navigate away) kills it. A broken pipe is logged at debug level. Only a genuine ffmpeg failure (a non-nil exit while the request context is still live) logs a warning, with ffmpeg's stderr attached.
Direct serving with Range stays the default. Transcoding is strictly the
fallback, for codecs DirectPlayable rejects and for an MP3-in-Matroska file
whose remux isn't available right now (below).
Remuxing Matroska: media.NewRemuxer
The remux and the watch rendition
are two caches of the same type, media.FileCache (filecache.go). Each holds
copies that ffmpeg makes of library files, in the data directory. A cacheSpec
describes each cache: the directory, extension, ffmpeg arguments, size cap, how
many runs go at once, the timeout, the ETag, and an optional check of the
finished copy. Both caches share the LRU, single-flight, .part-and-rename,
disk guard and refusal memory described below. The methods are:
Openwaits for a copy.Statusreports where a copy stands.Queuestarts a copy without waiting. It does this only for a cache with a queue limit, which is the watch rendition's. The remux'sQueuestarts nothing and leaves the filemissing.WarmAllwarms a book's copies.
NewRemuxer (remux.go) is the remux's spec.
An .mka holding MP3 is served at the same stream URL as a lossless copy of
its audio in a plain MP3 file. Every client then plays it with Range seeks:
browsers, iOS AVPlayer (which types by Content-Type when streaming) and
shipped native builds that only ever ask for the plain URL. No capability flag
announces it, and no client branches on it. A shipped iOS build saves its
download under .mka, which AVFoundation won't open offline. The updated app
renames it (see
Offline).
When. The remux is on (API.remuxOn) while ffmpeg is configured and the
cache is open, which needs <data> set. A cache that can't be created logs a
warning, and the server serves Matroska as stored. A podcast library's stream
never remuxes, because servePodcastStream answers it first. Otherwise
handleStream remuxes a path ending in .mka (any case), with or without
download=1, except in two cases:
transcode=1gets the live transcode, as before;original=1gets the stored bytes, for API tools and backups.
--ffmpeg "" restores the 2.4.x behaviour exactly.
The ffmpeg run. The server opens the source in Go after SafeJoin, as
ServeFile does, and hands it over as stdin (fd: or pipe:0, as inputFor
decides above):
ffmpeg -nostdin -loglevel error -xerror -protocol_whitelist fd -f matroska -i fd: \
-map 0:a:0 -c:a copy -map_chapters 0 -fflags +bitexact -id3v2_version 3 \
-f mp3 -y file:<cache>/<key>.<rand>.part
- The forced demuxer and the descriptor-only whitelist mean crafted bytes can't make ffmpeg open other files.
-c:a copyis a stream copy, not an encode: lossless and I/O-bound.- The chapters become ID3
CHAPframes, and ffmpeg writes a Xing/Info header, so a player gets a real duration. +bitexactmakes the output deterministic, so a resumed download'sIf-Rangestill matches after the copy was evicted and made again.-xerrorfails the run on a read error rather than leaving a short copy.
Is it MP3? The server decides this by trying. ffmpeg's mp3 muxer refuses
anything but exactly one MP3 audio stream, which also covers folder parts whose
codec the index doesn't hold. That refusal is ErrRefused. The server answers
it with the stored bytes and remembers it in memory per cache key for an hour
(cacheRefusalTTL, up to 1,024 keys). The memory expires because what looks
like the file's fault may be the environment's, such as an ffmpeg missing an
encoder. A changed file is a new key, and the server tries it again at once.
These are not refusals:
- an exit after ffmpeg wrote output (killed partway, a full disk, a read error);
- an exit whose log names an I/O error before any output (
readFailed, for example a flaky share). The next request tries again. - the server shutting down mid-run.
A run that its own timeout killed is a refusal, in either cache
(timedOut), so a copy that can't finish in time isn't started from zero on
every request.
The cache. The cache lives in <data>/cache/remux/ (0700, made again if it
went). It holds one file per source, named by the first 32 hex digits of a
SHA-256 of the absolute path, size and mtime. A changed source is therefore a
new key, and its old copy ages out.
- A remux writes
<key>.<rand>.part, syncs it and renames it into place. It never renames it under the key when the source changed while it was read. The server sweeps.partleftovers at start. - The cache is an LRU by file mtime. A hit bumps the mtime at most once every 10 minutes, so a player's run of Range requests counts as one use.
- After each new remux, and at start, the server removes the oldest copies while the total is over 8 GiB. It keeps the new copy even when that copy alone is bigger.
- A remux is skipped (
ErrUnavailable) when the disk would be left with under 1 GiB free after it and the other runs still writing (internal/diskspace). The server logs one warning per low-disk spell. Both caches live under<data>/cache, so they share one reservation (FileCache.ShareDiskGuard), and a remux and a rendition encode running together count each other.
Concurrency. One remux runs per key at a time. This is single-flight: every
request for the key waits on the same run. At most 2 remuxes run at once across
the server, a limit separate from the 4 transcode slots. The run belongs to the
server's base context, never to a request, with a 30-minute timeout from when it
gets a slot. A waiter that leaves (a seek, an AVPlayer probe) therefore
doesn't stop it. At shutdown the launcher waits for the runs of both caches
(API.WaitMediaCaches), which kills their ffmpeg and removes their .part
files.
A cold request. serveRemuxed waits at most remuxColdWait, 2 s, for
the copy. That is under the player's 3 s stall timer and Android's 8 s read
timeout. After that, the request gets the
live transcode instead when any of these holds:
- the remux is still running;
- the disk guard skipped it;
- ffmpeg failed some other way.
The transcode is needed because direct_playable told the web player not to
ask for one. With download=1 the file is still named <stem>.mp3. The remux
goes on, and the next request gets the copy. The transcode answer starts from
the beginning (the request carries no t), is not byte-seekable, and is a lossy
128 kb/s encode. A seek made in that window gets those bytes, not the copy's.
Two kinds of request wait longer instead, up to remuxDownloadWait, 45 s.
That is under the 60 s an idle response gets from Android's downloader and from
a reverse proxy's default read timeout.
- A
download=1request, whose saved copy would otherwise be the lossy transcode. - A stream that finds every transcode slot busy (
takeTranscodeSlotreturns none), which would otherwise be a503.
Past that wait, they get the transcode after all. While the server is stopping,
the answer is 503.
Warming. /item and /chapters start the remuxes of the book's
MP3-in-Matroska files in the background (API.warmRemux calls
FileCache.WarmAll), so the first stream is usually a hit. The answer never
waits for them.
- Warms run one file and one book at a time across the server, so they hold at most one of the 2 slots.
- A queued book is warmed once; at most 16 books are queued, and more are dropped.
- Cached copies and known refusals are skipped. A warm doesn't mark a copy used, since a book looked at is not one played.
- A podcast library is never warmed.
The response. media.ServeOpenedAs serves the copy with
Content-Type: audio/mpeg, Accept-Ranges: bytes and Range, named
<stem>.mp3 (the download=1 filename). Its validators are its own: a strong
ETag: "remux-<key>" and the copy's mtime as Last-Modified, never the
source's. A 2.4.x client may have kept an If-Range or If-Modified-Since
from the stored Matroska. That request gets the whole MP3, never a 304 or MP3
bytes spliced onto Matroska. A remuxed stream is not a "transcoded" listening
session.
What stays as stored. These describe the stored files, so an MP3-in-Matroska book still reads as needing transcoding there:
- the admin console's Playback column and its
direct_playablefilter; - the book page's playback line and Files card;
- the Health
transcodeissue.
Watch renditions: media.NewRenditionCache
Capability renditions. A watch downloads a book to play it away from the
phone, and the library's files are often too big for it. A 20-hour book as
128 kb/s stereo MP3 is over a gigabyte. So the server makes a small copy of each
audio file once, a rendition, and serves it like any file, with Range. The
library stays read-only, because the copies live in the data directory.
The encode (renditionArgs, media/rendition.go):
ffmpeg -nostdin -loglevel error -protocol_whitelist fd -i fd: \
-map 0:a:0 -vn -sn -dn -map_metadata -1 -map_chapters -1 \
-c:a aac -b:a 48k -ac 1 -ar 44100 -fflags +bitexact -flags:a +bitexact \
-movflags +faststart -f mp4 -y file:<cache>/<key>.<rand>.part
- AAC-LC, mono, 48 kb/s, 44.1 kHz, MP4 (
.m4a), about 430 MB for 20 hours. It uses ffmpeg's ownaacencoder. HE-AAC would be smaller, but needs the non-freelibfdk_aac. - The same duration as the source (within 0.1 s on MP3 and M4B), so whole-book positions, chapters and the file places of listening sync hold on the copy unchanged. The copy has no cover, tags or chapters, because the watch reads those from the API.
+faststartputs the index first, so a player can start before the end arrives.+bitexactmakes the same ffmpeg write the same bytes every run.- The only input is the descriptor (
fd:, orpipe:0for an older ffmpeg, as for the transcode). The demuxer is ffmpeg's guess, since the library holds every audio format. - Unlike the remux, the encode has no
-xerror, because it would fail the whole encode on one damaged MP3 frame that playback would skip. A read error partway still exits 0, so the server checks the finished copy instead (renditionCheck). A copy fails the check when it has no audio track, or when it is more than 1 s shorter than the source's exact duration (the in-process probe,metadata.ExactDuration). The length check is skipped when the source's length is only an estimate, such as a VBR MP3 with no Xing header. A copy that fails is not cached, and the server remembers it as a refusal for an hour.
The cache. <data>/cache/renditions/watch/<key>.m4a, a FileCache like the
remux's (above) with its own bounds:
| Watch renditions | Remux (for comparison) | |
|---|---|---|
| Key | SHA-256 of the absolute path, size, mtime and watch-v1 | the absolute path, size and mtime |
| Size cap (LRU by mtime) | 10 GiB | 8 GiB |
| At once | 1 encode (an encoder pins a core) | 2 stream copies |
| Timeout from its slot | 2 hours | 30 minutes |
| Waiting or running | at most 256 files, server-wide | warms: 16 books queued |
| Disk guard | 1 GiB left free, one reservation shared with the remux | the same |
The encode version (renditionVersion) is part of the key and the ETag, so a
change to the encode is a new key, and old copies age out of the LRU. A source
that changes while it is read is never cached under the old key.
A refusal is remembered for an hour, or until the file changes. A refusal is ffmpeg's exit before writing anything, a trackless or short copy, or an encode that its 2-hour timeout killed. A failed run's log carries the first and last 2 KiB of ffmpeg's output.
Without ffmpeg (--ffmpeg "") or without a data directory, the cache is off:
- the
renditionsflag isfalse; - the top-level
renditionslist is[]; - a
watchrendition request outside a podcast library answers404 rendition_unavailable. The kind and podcast checks come first, so those still answer400 bad_rendition.
Serving (serveRendition, api/handlers_renditions.go). handleStream
checks rendition= straight after the scope check, before the podcast and
remux branches. It checks, in this order:
- the kind:
400 bad_renditionfor anything butwatch, and for a podcast library; - the flag:
404 rendition_unavailable; - recognised audio and a regular file:
404.
Then FileCache.Open waits up to renditionWait for the copy. That is 45 s,
the same as the remux's download wait. The outcomes are:
- Ready.
serveCopyanswers throughmedia.ServeOpenedAs, with Range andIf-Range. The response hasContent-Type: audio/mp4, a strongETag: "rw1-<key>-<size>", and the copy's own mtime asLast-Modified.download=1addsContent-Disposition: attachmentnamed<stem>.m4a. The size in the ETag tells two encodes apart, should a re-encode after an eviction ever differ, so a resumed download never splices bytes of two encodes. - Still encoding.
503 rendition_pendingwithRetry-After: 15. The encode goes on under the server's base context, never the request's. The server never falls back to a live transcode here, because its bytes aren't rangeable, so a watch couldn't resume it. - Refused.
422 rendition_failed(remembered, as above).Retry-Aftergives the seconds the remembered refusal has left (FileCache.RefusedFor, 1 to 3600). - Failed otherwise.
503 rendition_errorwithRetry-After: 60. This covers the disk guard, a run killed partway by a shutdown, a source that changed, and a full 256-file queue.
Preparing a book. POST /libraries/{id}/renditions?path=<book> with
{"kind":"watch"} resolves the book as /item does, so a part path names its
book. It then calls FileCache.Queue for each of the book's audio files
(Book.AudioFiles, in play order). A single-file book is its own file. GET
with ?kind=watch calls Status only. Both answer
{kind, files: [{path, state, bytes?, retry_after_seconds?}]}, with state
ready, pending, failed or missing. retry_after_seconds appears only on
a failed file whose refusal is remembered (see
POST /libraries/{id}/renditions
in the reference). Both need a header credential (requireAuth, no ?token=).
A rendition request is not a listen: it opens no listening session and never marks one as transcoded.
The chapters envelope: {chapters, files, duration}
GET /libraries/{id}/chapters?path= is what makes a single chaptered m4b and
a folder of fifty mp3 parts render identically in a player. The response:
{
"library_id": 1,
"path": "Brandon Sanderson/Mistborn/01 - The Final Empire",
"duration": 88453.2,
"is_folder": true,
"codec": "mp3",
"direct_playable": true,
"files": [
{ "rel_path": ".../Part 01.mp3", "seq": 0, "duration": 3520.1, "format": "mp3", "size": 84480000 },
{ "rel_path": ".../Part 02.mp3", "seq": 1, "duration": 3498.7, "format": "mp3", "size": 83968000 }
],
"chapters": [
{ "index": 0, "title": "Chapter 1", "file_index": 0,
"file_path": ".../Part 01.mp3", "start": 0, "end": 3520.1, "book_offset": 0 },
{ "index": 1, "title": "Chapter 2", "file_index": 1,
"file_path": ".../Part 02.mp3", "start": 0, "end": 3498.7, "book_offset": 3520.1 }
]
}
Each chapter (metadata.Chapter) is one playable unit, normalized across
book shapes:
| Field | Meaning |
|---|---|
file_path | Library-relative path of the audio file to stream. This is what goes into /stream?path=. Never a folder or book path |
file_index | 0-based ordinal of the containing file (matches BookFile.seq; 0 for single-file books). Used for ordering only |
start / end | Offsets in seconds within that file: seek to start after loading file_path |
book_offset | The chapter's start on the whole-book timeline (the sum of earlier files' durations), so progress is one continuous position |
Two relationships must hold:
- A chapter's
book_offsetequals the sum of the durations of all files beforefile_index, plus its ownstart. - The last chapter's
book_offset + (end − start)reachesduration.
For folder books the scanner probes each part. A part with its own embedded chapters (a chaptered m4b inside a book folder) is expanded into those chapters. Otherwise the whole part becomes one chapter (see Scanner).
The files array is the track list a client actually queues. The rule is
stream the file, not the book: a folder path handed to /stream cannot
work, and in the frontend this once surfaced as MediaToolbox -12864.
Frontend playback covers how the player maps
between (trackIndex, position) and the whole-book position from this envelope.