Ga naar hoofdinhoud

Scannen en analyseren

Twee losse flows vullen de database: scannen registreert wat er op disk staat (GraphQL-mutation scanLibraries(libraryId?), ScannerController), metadata verversen verrijkt dat met metadata van externe providers (refreshMetadata(mode, libraryId?) en de per-item refresh*-mutations, MetadataRefreshController).

Startup-bootstrap​

disk/.../StartupTasks luistert op Spring's ContextRefreshedEvent — bij startup worden geen RabbitMQ-events verstuurd. Het maakt of updatet NodeEntity-, LibraryEntity- en DirectoryEntity-rijen op basis van de configuratieproperties (disk.yaml / env vars), maakt de cache-directories op disk aan en valideert de multi-node-configuratie. Zie het startup-diagram.

Library scannen​

Zie het scan-flow-diagram. scanLibraries() stuurt per directory een NEW_DIRECTORIES_SCAN_REQUEST; de disk-handler loopt door het filesystem en stuurt per bestand één FILE_SCAN_REQUESTED. FileScanRequestedHandle routeert op extensie (en library-type). Na de wandeling worden de rijen van bestanden die niet meer gezien zijn verwijderd (ScannedCache), en voor een MUSIC-library ruimt OrphanTrackCleanupService daarna de tracks zonder mediabestand en de albums zonder tracks op. Een verplaatst of hernoemd bestand komt terug als een nieuwe track, dus voordat een wees verdwijnt gaan zijn kijkstatus, wachtrij- en playlist-items en rating over naar de track van dezelfde artiest met dezelfde titel die nog wel een bestand heeft. Artiesten waar niets meer naar verwijst (geen album, track, track-credit, filmcredit, boek, hoofdstuk of serie) gaan ook weg — meestal een gesplitste samenwerking zoals "A & B". De credits worden herschreven door de analyse die op de scan volgt, dus zo'n artiest verdwijnt bij de volgende scan. Elke verwijderde track, elk album en elke artiest krijgt een delete in de zoekindex.

HandleMediaFileFound doet zijn ffprobe-/ffmpeg-werk (streams, duur, cropdetectie, afleveringsgrenzen, de achtergrondstill) buiten elke transactie en slaat het resultaat op in één kort TransactionTemplate-blok. Op een trage schijf duurt één bestand minuten; een verbinding zo lang "idle in transaction" houden liet de pool leeglopen voor alle andere handlers. De events die de nieuwe rijen nodig hebben (ondertitelextractie, introdetectie) gaan na die commit de deur uit. De extensielijsten zijn exact en kort (PathObject): afbeeldingen zijn jpg/png, video is mkv/mp4/webm/m4v/flv/avi, ondertitels zijn srt — een .jpeg of .wmv wordt simpelweg niet opgepakt. Welke scanners überhaupt draaien hangt af van het library-type: een COMIC-library gebruikt alleen ComicScanner + ImageScanner; MUSIC gebruikt audio/image/nfo, BOOK voegt de EpubScanner toe; alleen film-/show-libraries draaien de SubtitleScanner en MediaFileScanner:

BestandEventWat de handler doet
VideoMEDIA_FILE_FOUNDffprobe: streams + duur, embedded subs naar SRT extraheren, screenshot als achtergrond
AudioAUDIO_FILE_FOUNDffprobe, ID3-tags (titel/tracknr, track-credits uit de artist-tag — primaire artiest plus feat.-gasten — met de pad-artiest als fallback), embedded cover, HLS-cache leegmaken
.epub (BOOK-library)EPUB_FILE_FOUNDOPF: titel/taal/beschrijving, media overlays uit de inhoud, cover uit de zip
.cbz/.pdf/.epub (COMIC-library)COMIC_FILE_FOUND (epubs hergebruiken EPUB_FILE_FOUND)paginatelling, ComicInfo.xml, cover extraheren
.srtSUBTITLE_FILE_FOUNDSRT als EXTERNAL_SUBTITLE-stream aan de episode koppelen
AfbeeldingIMAGE_FOUNDImageEntity opslaan, koppelen aan show/movie/episode/etc.
.nfoNFO_FILE_FOUNDXML parsen: titel, beschrijving, releasedatum, biografie/review

Entity-creatie loopt via ScannerHelperService.getOrCreate*, dat ook de *_FOUND-verrijkingsevents en de creatie-events voor de zoekindex afvuurt.

getOrCreatePerson zoekt een persoon op de genormaliseerde naam (PersonNames.normalize: kleine letters, samengetrokken spaties — gespiegeld door de gegenereerde kolom person_entity.name_normalized), zodat "ABBA" op het ene album en "Abba" op het volgende één artiest zijn. De lookup is per library gescoped, met een fallback naar een library-loze persoon (bijvoorbeeld een TMDB-acteur) die dan aan de library gehangen wordt — geen enkele globale lookup. De opgeslagen name houdt de eerst geziene schrijfwijze als weergavenaam. ArtistTagParser splitst een feat./ft./featuring-tag in de primaire artiest en haar gasten. Een samenwerking — "A & B", "A, B", "A / B", "A + B" of de "A;B" die ffprobe van een tag met meerdere waarden maakt — wordt alleen gesplitst als elk deel een persoon is die de server al kent (in de library, of library-loos zoals een TMDB-acteur) en de hele naam zelf geen albumartiest is: "Victoria Justice & Elizabeth Gillies" wordt twee credits, terwijl "Mumford & Sons" ("Sons" is niemand) en "Selena Gomez & The Scene" (heeft albums) één act blijven. Het eerste deel wordt de trackartiest, de rest krijgt een featured-credit.

Opslagsoorten en de S3-scan​

DirectoryEntity.storageKind is LOCAL (een absoluut pad op precies één eigenaar-node) of S3 (s3://bucket/prefix, geen eigenaar, N gekoppelde nodes in directory_node). Rijen van een S3-directory bewaren s3://bucket/key in dezelfde path-kolom, zodat de padparsers — pure string-parsers die de root strippen en op / splitsen — en de uniciteit op (directory, path) ongewijzigd werken. De twee walkers delen één beslissing per bestand: AnalyzerSimpleFileVisitor (een java.nio-adapter) en S3LibraryScanner (een recursieve ListObjectsV2 met delimiter) snoeien allebei met DirectoryPruner en geven ScanEntrys aan ScanEntryDispatcher, die de scanners bevraagt en FILE_SCAN_REQUESTED publiceert (zijn path is een string, met een tolerante setter voor de file:-URI die oudere producenten stuurden). HandleNewDirectoriesScanRequested neemt per directory een transactie-gescopete advisory lock, omdat de scan van een S3-directory door elke gekoppelde node opgepakt kan worden.

Handlers raken java.nio nooit rechtstreeks aan op het pad van een rij. core/.../storage levert de naden: ObjectStore (één per app.ister.s3.connections[n], beheerd door ObjectStoreRegistry), FileAccess (stat/open/delete op directory + pad), LocalCopy (een LRU-gecachte download voor zip-/PDF-tools), CacheStore via CacheDirectoryResolver (elke schrijver van afgeleide bestanden) en TmpStoreProvider (de gedeelde transcode-opslag, hoofdstuk 4). MediaFileInputResolver bepaalt wat ffmpeg leest: een lokaal pad, de getokeniseerde download-URL van een andere node, of — voor een S3-bestand op een gekoppelde node — het eigen /mediaFile/{id}/download van de node over loopback (FileController proxiet het object met Range), dan wel een presigned URL als app.ister.s3.ffmpeg-direct aanstaat.

Multi-episode-bestanden​

Een bestandsnaam mag een aflevering-range dragen — s04e06-e07.mkv, s04e06-08.mkv, s04e06e07.mkv — voor een bestand met maximaal drie opeenvolgende afleveringen. PathObject parst de range (een onwaarschijnlijke range, achterstevoren of langer dan drie, valt terug op de eerste aflevering) en MediaFileScanner maakt per aflevering een eigen EpisodeEntity, zodat elke z'n eigen TMDB-metadata en watch status krijgt. De FK episode_entity_id van het bestand wijst altijd naar de eerste aflevering; elke bevatte aflevering (ook de eerste) krijgt daarnaast een media_file_episode_entity-linkrij met z'n start/duration-slice binnen het bestand. Geen linkrijen betekent "gewoon single-episode-bestand" — alle bestaande FK-queries blijven correct, en afspeelpaden zoeken bestanden op via MediaFileEpisodeService.filesForEpisode.

De slicegrenzen worden berekend in HandleMediaFileFound (MediaFileFoundEpisodeBoundaries) zodra ffprobe de bestandsduur kent: één MKV-chapter per aflevering wordt direct gebruikt; bij meer chapters (scene-markers) wint de chapter het dichtst bij elk gelijk-verdeel-punt, tenzij dat een aflevering onwaarschijnlijk kort zou maken; anders wordt de duur gelijk verdeeld. Elke aflevering krijgt ook een eigen backdrop-still, genomen op het midden van z'n eigen slice. Bestanden die vóór multi-episode-ondersteuning zijn gescand worden gebackfilld door een gewone library-rescan: de scanner ziet een bestaand bestand waarvan het pad als range parst maar zonder linkrijen, maakt de ontbrekende afleveringen en links aan en stuurt MEDIA_FILE_FOUND opnieuw, zodat de grenzen en stills worden berekend.

Sidecar-bestanden (NFO, lokale afbeeldingen, externe ondertitels) hangen zoals voorheen aan de eerste aflevering van de range.

Crop-detectie​

Sommige rips hebben ingebakken zwarte balken. HandleMediaFileFound draait een crop-detectiestap (MediaFileFoundDetectCrop): ffmpeg's cropdetect-filter bemonstert een handvol momenten in het bestand, en de geconvergeerde crop-rechthoek wordt opgeslagen op de video-MediaFileStreamEntity (crop_*-kolommen, V37). Dit decodeert enkele tientallen frames per sample en is dus niet gratis — het draait als onderdeel van de bestandsanalyse, niet bij elke scan. Bestanden die vóór deze feature zijn geanalyseerd worden opgevangen door een backfill aan de scannerkant: app.ister.server.crop-detect-backfill (standaard true) stuurt bij een rescan MEDIA_FILE_FOUND opnieuw voor videobestanden waarvan de streams nog geen crop-waarden hebben. De afnemer van de rechthoek is de player, via de GraphQL-cropvelden — de transcoder laat de balken bewust staan (hoofdstuk 4).

Ondertitel-extractie​

Ingebedde tekst-ondertitelstreams (subrip, ass/ssa, mov_text, webvtt) worden SRT-bestanden in de cache-directory van de eigenaar, één SUBTITLE_EXTRACT_REQUESTED-event per stream, verstuurd nadat de analyse van het bestand gecommit is (HandleSubtitleExtractRequested → SubtitleExtractionProcessor → SubtitleExtractor). Het is een simpele ffmpeg-remux, maar wel een die de hele container leest — vandaar een apart, niet-transactioneel event in plaats van een stap binnen MEDIA_FILE_FOUND, en één bericht per stream zodat elk ruim onder de consumer-timeout van RabbitMQ blijft. Het resultaat is een EXTERNAL_SUBTITLE-rij waarvan path de eigenaar-lokale SRT is; een stream waarvan de extractie mislukt krijgt de vlag extractionFailed, zodat een opnieuw afgeleverd event hem niet herhaalt (een heranalyse herschrijft de streamrijen en doet dat wel). Bitmap-ondertitels (blu-ray-PGS, dvd-VobSub) krijgen geen event en geen rij: ze worden nooit tekst — de transcoder serveert hun plaatjes als sprite-sheets zodra het afspelen wordt opgezet, zie hoofdstuk 4. De familie is helper-geschikt: een helper-node leest de bron via de download-URL van de eigenaar, extraheert in zijn eigen tmp-map, uploadt de SRT met POST /cache/upload/{fileName} en registreert het pad van de eigenaar, zodat de rij niet te onderscheiden is van een lokale extractie. Omdat de extractie nu ná de analyse klaar is, toont een tussentijds gegenereerde master-playlist de SRT-rendition pas zodra de playlist-cache van dat bestand opnieuw wordt opgebouwd.

Intro/outro-detectie​

Terugkerende intro's en aftitelingen worden gevonden door audio over een seizoen te vergelijken: de disk-module (op de eigenaar, of op een helper-node die de directory voor DETECT_SEGMENTS opsomt — de reader neemt een lokaal pad of de ranged download-URL van de eigenaar even goed) decodeert korte vensters (eerste 10 / laatste 4 minuten van de slice van elke aflevering) naar mono-PCM, fingerprint ze (ChromaFingerprinter, een chromaprint-achtige 32-bits gradiënthash per 128 ms — ongevoelig voor volumeverschillen, zonder externe library), en SegmentMatcher zoekt de langste gedeelde run tussen een aflevering en maximaal vier seizoensburen. Omdat een lag tussen twee afleveringen die tussen twee hashframes valt elk framepaar uit elkaar trekt (de gedeelde run versplintert dan tot onder het minimum), wordt de vergelijkingskant op vier kwart-hop-faseverschuivingen (0/32/64/96 ms) gefingerprint en wint de fase met de langste run. De mediaan van de grenzen over instemmende paren (minimaal twee, één bij seizoenen van twee afleveringen) wordt opgeslagen als media_file_segment_entity-rijen (INTRO/OUTRO) in absolute bestandstijd; in een multi-episode-bestand krijgt elke slice eigen rijen, gedisambigueerd via episode_entity_id. De player leest ze als MediaFile.segments voor zijn intro-overslaan- en volgende-aflevering-knoppen. Een intro moet 10–150 s lang zijn en binnen de eerste 8 minuten beginnen — bewust ruime grenzen, want veel intro's dragen per aflevering een andere voice-over over dezelfde muziek (waardoor maar een deel van de audio identiek is) en lange cold opens duwen de intro ruim voorbij de vijf minuten.

Intro-matching verloopt in twee fasen. Nog te detecteren afleveringen worden geordend van de seizoensuiteinden naar binnen (eerste, laatste, tweede, voorlaatste, …) en paarsgewijs gematcht zoals hierboven. Zodra drie afleveringen van het seizoen een bevestigde intro dragen, schakelen de resterende afleveringen over op template-matching: hun venster wordt gematcht tegen maximaal drie bevestigde intro's (eerste, middelste, laatste van het seizoen, zodat een intro-wissel halverwege beide varianten aanlevert). Een template is een bewezen intro, dus één match van ≥ 8 s volstaat waar de paarsgewijze fase twee instemmende buren eist — dat redt afleveringen met een intro-variant die geen van hun vier buren deelt — en een template van ~20 s door een venster schuiven is veel goedkoper dan twee volledige vensters tegen elkaar uitlijnen. Wanneer de laatste chunk van het seizoen klaar is, krijgen afleveringen die in de paarsgewijze fase faalden nog één template-poging. Outro's gebruiken altijd de paarsgewijze fase (hun venster van 4 minuten houdt dat goedkoop).

Omdat de detectie afleveringen onderling vergelijkt kan ze niet in de per-bestand MEDIA_FILE_FOUND-handler draaien: die vuurt in plaats daarvan een seizoens-gescopeerd DETECT_SEGMENTS-event nadat zijn transactie is gecommit, op dezelfde directory-gescopeerde queue-familie, zodat de detectie draait op de node die de bestanden bezit. HandleDetectSegments is idempotent — bestanden waarvan media_file_entity.segment_detector_version al gelijk is aan de huidige detectorversie dienen alleen nog als vergelijkingsmateriaal — dus één event per geanalyseerde aflevering is prima: het event van de laatste aflevering doet het echte werk. De versiekolom is tegelijk de sentinel voor "gedraaid maar niets gevonden"; null betekent dat detectie nooit liep, en de backfill van de scanner (app.ister.server.segment-detect-backfill, standaard aan, één keer per seizoen per run) stuurt bij een rescan DETECT_SEGMENTS voor zulke bestanden. Heranalyse per item wist de segmentrijen en reset de versie, en het ophogen van SegmentDetectionChunkProcessor.DETECTOR_VERSION laat de detectie overal opnieuw draaien via dezelfde backfill. Bekende beperking: een seizoen verspreid over meerdere nodes paart alleen de afleveringen die lokaal op elke node staan — een node met één losse aflevering detecteert er niets voor.

Zolang een admin-upload naar de directory nog loopt (upload_session_entity ACTIVE) stuurt HandleMediaFileFound het event niet: de detectie zou met de overdracht om CPU concurreren en, erger, de eerste gelande afleveringen vergelijken met één of twee siblings, niets vinden en ze toch als klaar stempelen. UploadSessionService publiceert bij het einde van de sessie één DETECT_SEGMENTS per seizoen (UploadedFilePublisher.publishSegmentDetection); een aflevering waarvan de analyse daarna pas klaar is stuurt zoals altijd haar eigen event.

Al het ffmpeg-/ffprobe-werk in dit hoofdstuk loopt via BackgroundFfmpeg (core): wrapperscripts die de binaries onder nice -n 10 starten (BACKGROUND_NICE), en HandleDetectSegments heeft een eigen listener-concurrency (SEGMENT_DETECT_CONCURRENCY, standaard 1) in plaats van de gedeelde helperwaarde, zodat detectie ongeveer één core neemt en die afstaat aan afspelen en uploads.

Die idempotentie geldt serieel, niet gelijktijdig. Het herberekenen van een bestand is een delete-gevolgd-door-insert, en twee transacties die dat voor hetzelfde seizoen doen zien noch annuleren elkaars rijen, dus een heranalyse-sweep — één bericht per bestand, allemaal voor dezelfde paar seizoenen — sloeg elk segment één keer per consumer op. Een chunk claimt zijn seizoen daarom met een niet-blokkerende advisory lock (pg_try_advisory_xact_lock, namespace 1, sleutel hashtext(seasonId)); een bericht dat de claim niet krijgt wordt weggegooid in plaats van opnieuw geprobeerd, want de keten van de houder dekt het hele seizoen toch al. Blokkeren zou een listener-thread minutenlang stilzetten en opnieuw tegen dezelfde consumer_timeout aanlopen. Een unieke index op (bestand, type, aflevering) met NULLS NOT DISTINCT (V39, die ook de bestaande dubbelen opruimt) is het vangnet.

Eén bericht detecteert maximaal app.ister.server.segment-detect.chunk-size afleveringen (standaard 4): een heel seizoen in één keer fingerprinten kan langer duren dan RabbitMQ's consumer_timeout (standaard 30 minuten), waarna het kanaal wordt gesloten en het bericht eindeloos wordt gerequeued. Net als bij de blurhash-sweep draait SegmentDetectionChunkProcessor één chunk in een eigen transactie (opgerekt zodat slices van één multi-episode-bestand nooit over een chunkgrens vallen), en publiceert HandleDetectSegments pas ná die commit een opvolgerbericht voor hetzelfde seizoen. De versiekolom is de cursor en wordt ook bij een mislukte decode gestempeld, dus de keten termineert altijd.

Games​

Een GAME-library is platform-eerst (<dir>/<platform>/<Titel (Jaar)>/…, geparsed door GamePathObject; de platformtabel — mapaliassen, startbare extensies, bijbehorende extensies, standaard-launcher — is de enum GamePlatform). GameScanner reageert op precies twee soorten bestand: het gamebestand van een één-bestands-vorm (een ROM met zijn .bin-companions, een pc-zip, een Linux-installer of -tar — elk wordt een MediaFileEntity met gameEntity, zoals stripvolumes) en de marker van een mapvorm (PS3_GAME/PARAM.SFO, appmanifest_<appid>.acf). Al het andere in een PS3- of Steam-boom is bewust niet scanbaar: zo'n game bestaat uit 5k–100k bestanden, wat de mediabestandtabel en de scancache zou overspoelen, dus het is één game_entity-rij met rootPath, opgemeten door de boom te doorlopen (GameTreeLister, LOCAL en S3) en opnieuw gemeten bij elke herscan van de marker. DirectoryPruner daalt alleen voor die twee platformen af in de submappen van een game. Een Steam-marker wordt direct geparsed (VdfParser: app-id, naam, installdir, build-id) en de game telt alleen mee als de installdir-map naast de .acf bestaat. Aanmaken publiceert GAME_FOUND (globale queue); de handler staat in hoofdstuk 3.

Admin-upload​

Zie het upload-flow-diagram. LibraryUploadController (disk-module, /library-upload/**, alleen admin, alleen bearer-JWT) laat de player bestanden ÍN een library-directory schrijven. Hij staat in disk omdat de pad-parsers daar staan, en het is REST omdat geen enkele module buiten api GraphQL host.

  • Eén beslissing, drie aanroepers. Scanners.forLibrary / analyzableBy is de enige plek die weet welke scanners naar een librarytype kijken en hoe elke scanner bevraagd wordt. De scan-walk (ScanEntryDispatcher), de FILE_SCAN_REQUESTED-handler en UploadPreviewService gebruiken hem alle drie, en de preview past daarnaast DirectoryPruner.shouldDescend toe op elke bovenliggende map. Een bestand dat de preview herkend noemt, is dus een bestand dat de scan oppikt. De geparste details die de admin ziet komen uit de expliciete drie-argument-constructors van de pad-objecten (de directory-vlag): de punt-heuristiek van de twee-argument-vorm leest R.E.M. en J.K. Rowling verkeerd.
  • Paden. LibraryPathValidator vervangt hier SafeFilename (echte medianamen bevatten spaties, haakjes en unicode): hij weigert op regels — traversal, scheidingstekens, control-tekens, segmenten die met een punt beginnen (de scan slaat ze over, en het houdt .ister-upload buiten bereik), gereserveerde namen — normaliseert naar NFC en controleert insluiting met PathStrings.isUnder. LocalLibraryWriteStore resolvet daarnaast het echte pad van de dichtstbijzijnde bestaande bovenliggende map vóórdat er iets wordt aangemaakt, zodat een gesymlinkte map geen schrijfactie de library uit kan dragen.
  • Opslag-seam. LibraryWriteStore (core/.../storage/) is de schrijf-tegenhanger van FileAccess. LOCAL voegt chunks toe aan <root>/.ister-upload/<sessie>/<bestand>.part en rondt af met een atomic move op hetzelfde bestandssysteem. S3 maakt van een bestand één multipart-upload en van een chunk één part; de upload-id en de part-ETags staan in upload_file / upload_part, omdat niet elke S3-server openstaande multipart-uploads op prefix kan opsommen (MinIO antwoordt alleen voor een exacte key). complete is veilig te herhalen.
  • Transacties. Een chunk is "korte transactie, streamen, korte transactie" (UploadSessionService, expliciete TransactionTemplate): niets houdt een databasetransactie open terwijl er bytes over een trage uplink binnenkomen. Een semafoor begrenst de chunk-requests per node (elk ervan houdt via open-in-view nog steeds een connectie vast), en een lock per bestand houdt twee schrijvers van één part-bestand af.
  • De pipeline in. UploadedFilePublisher publiceert na de commit. Een nieuw bestand is één FILE_SCAN_REQUESTED op <base>.<directoryName> — wat de scan-walk ook gestuurd zou hebben. Een vervangen bestand kan die weg niet nemen (de scanners zien een rij en stoppen), dus het analyse-event wordt rechtstreeks gepubliceerd, nadat is weggegooid wat van de oude bytes was afgeleid (de HLS-tmp-map, de entry in de gedeelde tmp-opslag, de LocalCopy van een S3-object).
  • Multi-node. Er wordt niets doorgestuurd: de client stuurt chunks naar Directory.servingNode (LibraryWriteStoreResolver.servingNode, waar het GraphQL-veld naar delegeert), en een request dat een node bereikt die de directory niet kan schrijven krijgt 421.
  • Opruimen. UploadCleanupScheduler (elk uur, elke node) laat idle sessies verlopen waarvan hij de opslag kan bereiken en verwijdert staging-mappen zonder actieve sessie.

Game-bomen in de upload​

Omdat alleen de marker van een PS3-/Steam-game scanbaar is, zou de preview elk ander bestand van de boom niet ondersteund noemen. UploadPreviewService groepeert de entries daarom per gamemap: zit de marker in de groep, dan is elk bestand onder die map RECOGNISED (GAME_TREE_FILE); zonder marker zijn ze IGNORED met GAME_INCOMPLETE en een detail dat de ontbrekende marker noemt. Bij voltooiing publiceert UploadedFilePublisher voor boombestanden helemaal geen event en stelt het FILE_SCAN_REQUESTED van de marker uit tot het einde van de sessie (publishGameMarkers), zodat de scanner een complete boom opmeet. max-files-per-session staat daarom standaard op 100 000.

Metadata-backfill​

Zie het refresh-flow-diagram. refreshMetadata(MISSING) stuurt één globaal METADATA_BACKFILL_REQUESTED-event (precies één worker consumeert het, dus de backfill draait één keer cluster-breed; vroeger liep hij per node, wat op multi-node al het globaal gequery'de boek-/comic-/muziek-/persoonswerk dupliceerde). MetadataBackfillHandle zoekt alles op waar metadata, afbeeldingen of TMDB-verrijking ontbreken (films/shows matchen ook wanneer hun tmdbId nooit gevuld is — de V45-backfill-marker) en waaiert uit: SHOW_FOUND / EPISODE_FOUND / MOVIE_FOUND (TMDB), PERSON_FOUND / ALBUM_FOUND (MusicBrainz + NFO-lookup aan de disk-kant), AUDIO_FILE_FOUND voor tracks, BOOK_FOUND/EPUB_FILE_FOUND voor boeken, COMIC_SERIES_FOUND/ COMIC_FILE_FOUND voor comics; een optionele libraryId beperkt alles tot één bibliotheek. De controller stuurt zelf UPDATE_IMAGES_REQUESTED per directory voor de BlurHash-sweep (die queues zijn al directory-scoped, dus het werk landt op de eigenaarsnode). De stappen draaien in aparte transacties in MetadataBackfillService; de boekserie-heuristiek draait één keer cluster-breed in een eigen schrijftransactie. De pijplijnen per type staan in hoofdstuk 3.

Force refresh (per item of per bibliotheek)​

refreshMetadata(FORCE, libraryId) en de per-item-mutations (refreshShow(id), refreshMovie(id), …) sturen ANALYZE_DATA, dat door twee handlers geconsumeerd wordt: AnalyzeDataHandle (worker) wist de metadata/afbeeldingen/streams van het item en cascadeert — een library waaiert uit per type (shows, films, artiesten, boekauteurs, comicseries), een show naar zijn afleveringen, een persoon naar zijn albums én boeken, een album naar zijn tracks — en vuurt de *_FOUND-events opnieuw af (personen krijgen zowel de globale send voor de externe verrijking als de node-scoped send voor de artist.nfo-herparse); HandleAnalyzeDataDisk (disk) wist de HLS-cache en stuurt de bestandsniveau-events opnieuw (MEDIA_FILE_FOUND/AUDIO_FILE_FOUND, NFO_FILE_FOUND, SUBTITLE_FILE_FOUND). Verwijderde image-rijen laten hun cachebestanden achter (cross-node unlinken is onveilig); de dagelijkse cache-opschoning ruimt ze op.

De *_FOUND-events worden pas gepubliceerd nadat de wipe gecommit is (AfterCommitPublisher.publishAfterCommit): hun consumers controleren op bestaande metadata- en image-rijen en zouden anders de ten dode opgeschreven rijen nog zien staan en de refetch overslaan, waardoor het item blijvend zonder covers achterblijft. Voor albums stuurt disk-HandleAlbumFound daarnaast FILE_SCAN_REQUESTED voor lokale artwork (cover.jpg en verwanten) in de albummap — de album-analyse wist ook die image-rijen, en anders dan bij films/afleveringen volgt er geen directory-rescan, dus worden de bestanden expliciet opnieuw ingelezen (door ImageScanner gededupt op de bestaande (directory, path)-rij).

De BlurHash-sweep​

HandleImageFound slaat afbeeldingen bewust zonder BlurHash op: die coderen is CPU-duur en maakte die handler de bottleneck bij grote scans. De hashes worden achteraf gevuld door de UPDATE_IMAGES_REQUESTED-sweep, per directory — de cache-directory inbegrepen, want daar staat de gedownloade artwork en dus de overgrote meerderheid van de afbeeldingen.

Elk bericht verwerkt hoogstens app.ister.server.blur-hash.chunk-size afbeeldingen en publiceert daarna een opvolgerbericht met een keyset-cursor (afterId). Eén sweep over een hele library in één bericht duurde vroeger langer dan RabbitMQ's consumer_timeout (30 minuten), waarna het bericht teruggezet werd en de sweep eindeloos opnieuw begon zonder ooit te committen. De chunkgrootte alleen sluit dat op een drukke node niet uit, dus een chunk is ook in tijd begrensd (app.ister.server.blur-hash.chunk-seconds, standaard 120): is het budget op, dan worden de tot dan toe gehashte afbeeldingen gecommit en gaat de opvolger verder na de laatste daarvan; alleen een chunk die eindigt met minder afbeeldingen dan de chunkgrootte beëindigt de sweep (Chunk.exhausted). Afbeeldingen worden vóór het coderen verkleind tot hoogstens 96 pixels aan de lange zijde: een BlurHash vangt een paar gradiënten, dus een poster op volle grootte hashen is verspilde CPU.

Twee subtiliteiten:

  • De cursor is een keyset op id — geen offset en geen "eerstvolgende rij zonder hash". Een afbeelding die nooit te hashen is (een corrupt bestand) houdt blur_hash NULL; een naïeve LIMIT-query zou zulke rijen elke ronde opnieuw selecteren en nooit eindigen. PostgreSQL sorteert uuid unsigned terwijl java.util.UUID.compareTo signed vergelijkt, dus zowel de ORDER BY als de id >-vergelijking moeten in de database draaien, nooit in Java.
  • Het opvolgerbericht wordt pas gepubliceerd nadat de transactie van de chunk gecommit is (BlurHashChunkProcessor). Andersom zou een mislukte commit een cursor achterlaten die voorbij nooit-opgeslagen werk wijst.

Afbeeldingen worden gedecodeerd via RasterImageDecoder, niet rechtstreeks met ImageIO.read. Een gestage stroom JPEG's — TMDB levert er genoeg — draagt een ICC-profiel waarvan het aantal componenten niet klopt met het werkelijke aantal rasterbanden, meestal een Photoshop-export met een CMYK-profiel over drie-bands data. ImageIO bouwt zijn kleurmodel uit dat profiel en weigert het bestand vervolgens helemaal, waardoor die afbeeldingen voorgoed zonder hash bleven. De decoder valt terug op het ruwe raster, wat het kleurbeheer overslaat, en converteert met de hand: readRaster converteert namelijk niets, dus een gewone JFIF-JPEG komt binnen als YCbCr in plaats van RGB, en de Adobe APP14-marker bepaalt of vier banden CMYK of YCCK zijn.

Verwante scheduled jobs​

CacheCleanupScheduler (disk) en TmpTranscodeCleanupScheduler (transcoder) draaien dagelijks een zombie-sweep over de image-cache en de transcode-tmp-dirs: bestanden waar geen enkele database-rij meer naar verwijst worden verwijderd, en oude podcastdownloads verlopen. app.ister.server.cache-cleanup.dry-run staat standaard op true — de cleanup logt alleen totdat die vlag omgezet wordt. Naast deze twee bestaat een derde, veel frequentere sweep: HlsTranscodeService.cleanupOldFiles loopt elke 15 minuten over de HLS-cache-dirs en respecteert per dir de keep_until-deadline — zie hoofdstuk 4 voor hoe de twee HLS-sweeps het werk verdelen.