Transcoding
Streaming is HLS, geproduceerd door FFmpeg (via Jaffree) in de transcoder-module. HlsService +
HlsTranscodeService coördineren het bouwen van playlists en de FFmpeg-processen. Zie het
transcode-flow-diagram; drie triggers voeden dezelfde queue:
interactieve playback, de periodieke pre-transcode-taak en de playqueue-prefetch.
Playlists vooraf, segmenten lazy
Een cache-miss op GET .../master.m3u8 stuurt TRANSCODE_REQUESTED; HandleTranscodeRequested →
generateAllPlaylists schrijft de master- en per-stream-.m3u8-bestanden naar
tmpDir/{mediaFileId}/, terwijl de HTTP-thread pollt tot ze bestaan. Segmenten worden pas
geproduceerd als erom gevraagd wordt: het eerste .ts-verzoek voor een kwaliteitsniveau stuurt
TRANSCODE_PASS_REQUESTED, en segmentverzoeken pollen de cache-directory tot de pass dat segment
geschreven (en gesloten) heeft.
Eén continue pass per kwaliteit
Elk kwaliteitsniveau is één continue FFmpeg-pass over het hele bestand, geen proces per segment,
met -f segment -segment_times zodat de encoder de PTS nooit reset — dát voorkomt A/V-drift. De
keerzijde: passes encoderen sequentieel vanaf t=0, dus een sprong vooruit wacht tot de encoder het
gevraagde segment heeft ingehaald.
Dat geldt ook voor de copy-kwaliteiten (direct spelen): video wordt stream-gekopieerd en geknipt
op hetzelfde raster dat de playlists adverteren (zie de volgende paragraaf voor waar dat raster
ophoudt), en copy-audio laat elke MPEG-TS-native
codec (AAC, MP3, AC-3, E-AC-3, DTS) ongemoeid en valt alleen terug op AAC voor codecs die MPEG-TS
niet kan dragen. Copy-audio werd voorheen geadverteerd als één segment dat het hele bestand
besloeg en on-demand werd gegenereerd; bij lange bestanden blokkeerde dat het eerste verzoek
minutenlang en verhongerde de audiostream van de client terwijl videosegmenten vooruit renden.
Videopasses zetten bovendien omit_video_pes_length=0: zonder expliciete PES-lengtes is het
laatste PES-pakket van elk segment onbegrensd, en een client die segmenten achter elkaar leest
markeert dat op elke grens als corrupt — een decodeerhapering om de paar seconden.
Het grid stopt waar de stream stopt
Het knipraster komt uit de video-keyframes, maar elke stream eindigt ergens anders. De segment-muxer knipt op het eerste keyframe op of na elk gevraagd tijdstip, dus een knip waar niets meer te knippen valt levert geen bestand op — en een playlist die het tóch adverteerde beloofde iets waarop geen enkel verzoek ooit antwoord kon krijgen. Dat gebeurt in twee vormen: audio eindigt vaak eerder dan de container (heel gewoon in mkv), en de laatste knip van een videokopie kan precies op het allerlaatste keyframe landen, waar het van tijdstempelafronding en de kniptolerantie afhangt of hij nog splitst.
Zo'n grens wegtrimmen kost niets. Het raster voedt de FFmpeg-pass én de playlist, dus het segment raakt niet verweesd — het vorige loopt in plaats daarvan door tot het einde van de stream, en een pakkettelling over een getrimd raster laat elk pakket van de bron nog aanwezig zien.
SegmentGrid wordt daarom per stream en per rol gebouwd: HlsTranscodeService.gridFor meet waar
die stream eindigt — de videopakketscan rapporteert zowel het laatste keyframe (voor een kopie) als
het einde van het laatste pakket (voor een re-encode), en audio krijgt één extra ffprobe die via
-read_intervals alleen de laatste 30 seconden leest. Grenzen die minder dan een kwart seconde
overlaten vervallen, en de laatste #EXTINF volgt het gemeten einde in plaats van de containerduur.
Een probe die niets kan meten valt terug op de containerduur en knipt niets weg, zodat een mislukte
probe een playlist nooit kan inkorten. Remote invoer wordt op deze manier nooit geprobed: die wordt
gelezen via /mediaFile/{id}/download, dat geen byte-ranges serveert, dus een zoekende probe zou
ontaarden in het hele bestand over het netwerk streamen.
De playlist en de pass gaan allebei via gridFor met dezelfde argumenten, zodat wat geadverteerd
wordt en wat geproduceerd wordt niet uit elkaar kunnen lopen. Als vangnet voor wat de meting niet
kan bereiken worden de segmenten die een afgeronde pass schreef geteld vóórdat de done-marker wordt
geschreven, en wordt de playlist daarop teruggeknipt; dat repareert meteen cachemappen van vóór deze
wijziging. Een segment dat een afgeronde pass nooit schreef antwoordt 404, geen 503 — het komt niet
meer, en "probeer opnieuw" liet clients urenlang opnieuw proberen.
Het zichtbare gevolg: bij zo'n bestand kan de playlist een fractie van een seconde, en bij audio een seconde of zo, eerder eindigen dan de containerduur zegt. Er gaat geen beeld of geluid verloren — het laatste segment draagt het — alleen de geadverteerde duur is nu de eerlijke.
Concurrency
transcodeExecutor is een vaste pool ter grootte van
app.ister.transcoder.hls.max-concurrent-passes (default 4), extra begrensd door de
concurrentFileSlots-semafoor (max-concurrent-files, default 2). Een pass houdt een thread vast
voor de volledige duur van het bestand. Pre-transcoding concurreert om dezelfde pool, dus het is
makkelijk om interactieve playback uit te hongeren — vandaar dat achtergrondwerk gethrottled en
preëmptabel is (zie hieronder).
Pre-transcoding en achtergrondprioriteit
PRE_TRANSCODE_RECENTLY_WATCHED (per disk, elke 15 minuten) leest de continue-watching-entries
(hoofdstuk 5) — precies de items die gebruikers hierna gaan
spelen, plus de episode dáárna, zodat autoplay nooit stilvalt — en stuurt TRANSCODE_REQUESTED met
preTranscode=true. Bestanden zonder geanalyseerde streams gaan eerst terug door
MEDIA_FILE_FOUND.
Pre-transcode-passes worden versmald door PassFilter, op basis van de instellingen van de
gebruikers die het bestand binnentrokken: alleen audiostreams in een voorkeurstaal
(user_settings.preferred_audio_languages, met fallback op app.ister.languages) en alleen
videovarianten tot max_video_height. De 64k-audiobitrate wordt nooit geproduceerd —
HlsPlaylistBuilder vouwt die groep samen met 192k, dus geen enkele master-playlist verwijst
ernaar. Interactieve playback gebruikt PassFilter.none(): die moet elke track kunnen serveren
waar een speler om vraagt, en start passes toch al lazy. Een afgeronde achtergrondpass trekt de
volgende wachtende pass van hetzelfde bestand binnen; anders zou een wegens budget gedropte pass
moeten wachten op de volgende pre-transcode-cyclus.
Achtergrondpasses (background=true) draaien alleen op restcapaciteit (max-background-files,
max-background-passes) en worden gepreëmpt — FFmpeg gestopt, het event vervalt; de
scheduler/prefetch stuurt later opnieuw — zodra interactieve playback een slot of thread nodig
heeft. Achtergrond-FFmpeg draait daarnaast met OS-niceness (background-nice, default 10, 0 = uit)
via een bij startup gegenereerd wrapper-script, met terugval op normale prioriteit als nice
ontbreekt. Een succesvolle pass schrijft een done_<segmentPrefix>-marker; alleen die marker (niet
de enkele aanwezigheid van segmenten) laat een latere pre-transcode de pass overslaan.
Crop-detectie en transcoderen
De bestandsanalyse detecteert ingebakken zwarte balken en slaat de crop-rechthoek op op de
video-MediaFileStreamEntity (hoofdstuk 2). De transcoder past
die bewust niet toe: geen enkele FFmpeg-pass voegt een crop-filter toe, en streams worden
getranscodeerd mét de balken. Croppen is het werk van de client — de player leest de rechthoek
uit de GraphQL-cropvelden, schaalt hem naar de gedecodeerde afmetingen van wat hij afspeelt
(direct play of elke HLS-variant, die verkleind kan zijn) en past hem toe als mpv video-crop.
Die verdeling vermijdt drie serverside problemen tegelijk: de COPY-variant (-c:v copy) kan
nooit gefilterd worden, dus een serverside crop zou ABR-wissels tussen COPY en een
getranscodeerde variant in kadrering laten verspringen; een serverside crop zonder bijpassend
client-contract zou dubbel worden toegepast; en de geadverteerde RESOLUTION-waarden in de
masterplaylist zouden per variant herrekend moeten worden. De bekende beperking van het
client-side ontwerp is de webplayer, die geen mpv heeft en de balken toont — de enige plek waar
een serverside crop ooit iets zou toevoegen.
Retentie
Twee losse sweeps schonen de transcode-cache op, gestuurd door verschillende properties:
HlsTranscodeService.cleanupOldFilesdraait elke 15 minuten. Die verwijdert een cache-dir pas als elk bestand erinapp.ister.transcoder.hls.cache-retention-hours(default 2) onaangeraakt is én dekeep_until-deadline van de dir (de hoogste ooit ontvangenkeepUntilEpochMillis) verstreken is. De playqueue-prefetch stuurt nu +app.ister.server.prefetch.keep-hours(default 24 uur); de periodieke pre-transcode stuurt +30 min en ververst dat elke 15 minuten zolang de entry in aanmerking komt. Eén uitzondering: een dir met uitsluitend.m3u8-playlists blijft permanent staan — dat zijn de scan-time-playlists voor muziek, een paar KB die de eerste afspeelactie instant maken. De uitzondering is bewust zo smal: een dir met geëxtraheerde ondertitels of andere restanten maar zonder.tsveroudert alsnog.TmpTranscodeCleanupSchedulerdraait dagelijks (cronapp.ister.server.cache-cleanup.cron) en pakt wat de eerste sweep niet kan zien: wezen — dirs waarvan het mediabestand niet meer in de database bestaat — worden onvoorwaardelijk verwijderd, en stilliggende dirs zonder actieve FFmpeg-pass naapp.ister.server.cache-cleanup.min-age(default 24 uur). Deze sweep negeertkeep_untilen gehoorzaamt de gedeelde vlagapp.ister.server.cache-cleanup.dry-run(defaulttrue— hij logt alleen totdat die omgezet wordt).
Multi-node
Transcode-queues zijn directory-gescoped (TranscoderQueueNamingConfig boven op
DirectoryQueueNames, hoofdstuk 1), dus een transcode draait op de node die
het bronbestand bezit — of op een helper-node die de directory onder app.ister.helper.disks met de
TRANSCODE-job opsomt en dezelfde queues meeleest. Als de node die transcodeert niet de node is die
het afspelen bedient, uploadt een watcher-thread elk stabiel segment naar de eigenaar via POST /transcode/upload/{id}/{fileName} (FileController). Is de bron zelf remote, dan voert
MediaFileInputResolver FFmpeg de getokeniseerde /mediaFile/{id}/download-URL van de eigenaar in
plaats van een lokaal pad; dat endpoint serveert byte-ranges, zodat FFmpeg's seeks en de
stream-eind-probes van de transcoder goedkoop blijven. Ook het pad van een
EXTERNAL_SUBTITLE-stream is eigenaar-lokaal (cache-directory of een sidecar-.srt), dus een
remote transcoder haalt hem eenmalig op via /mediaFileStream/{id}/download naar de
transcode-cachemap van het bestand.