Skip to main content

Scanning and analysis

Two distinct flows populate the database: scanning registers what is on disk (GraphQL mutation scanLibraries(libraryId?), ScannerController), metadata refresh enriches it with metadata from external providers (refreshMetadata(mode, libraryId?) and the per-item refresh* mutations, MetadataRefreshController).

Startup bootstrap

disk/.../StartupTasks handles Spring's ContextRefreshedEvent — no RabbitMQ events are sent at startup. It creates or updates NodeEntity, LibraryEntity and DirectoryEntity rows from the configuration properties (disk.properties / env vars), creates the cache directories on disk, and validates the multi-node configuration. See the startup diagram.

Library scan

See the scan-flow diagram. scanLibraries() sends NEW_DIRECTORIES_SCAN_REQUEST per directory; the disk handler walks the filesystem and emits one FILE_SCAN_REQUESTED per file. FileScanRequestedHandle routes on extension (and library type). The extension lists are exact and short (PathObject): images are jpg/png, video is mkv/mp4, subtitles are srt — a .jpeg or .avi is simply not picked up. Which scanners run at all depends on the library type: a COMIC library uses only ComicScanner + ImageScanner; MUSIC uses audio/image/nfo, BOOK adds EpubScanner; only movie/show libraries run the SubtitleScanner and MediaFileScanner.

FileEventHandler work
VideoMEDIA_FILE_FOUNDffprobe streams + duration, extract embedded subs to SRT, screenshot as backdrop
AudioAUDIO_FILE_FOUNDffprobe, ID3 tags (title/track no, track credits from the artist tag — primary artist plus feat. guests — with the path artist as fallback), embedded cover, clear the HLS cache
.epub (BOOK library)EPUB_FILE_FOUNDOPF title/language/description, media overlays from content, cover from the zip
.cbz/.pdf/.epub (COMIC library)COMIC_FILE_FOUND (epubs reuse EPUB_FILE_FOUND)page count, ComicInfo.xml, cover extraction
.srtSUBTITLE_FILE_FOUNDlink SRT to episode as an EXTERNAL_SUBTITLE stream
ImageIMAGE_FOUNDsave ImageEntity, link to show/movie/episode/etc.
.nfoNFO_FILE_FOUNDparse XML: title, description, release date, biography/review

Entity creation goes through ScannerHelperService.getOrCreate*, which also fires the *_FOUND enrichment events and the search-index creation events.

getOrCreatePerson looks a person up on the normalized name (PersonNames.normalize: lower-case, collapsed whitespace — mirrored by the generated person_entity.name_normalized column), so "ABBA" on one album and "Abba" on the next are one artist. The lookup is scoped per library, with a fallback to a library-less person (a TMDB actor, say) that is then attached to the library — not a single global lookup. The stored name keeps the spelling seen first, as the display value. ArtistTagParser splits a feat./ft./featuring tag into the primary artist and its guests; an ampersand is never split, because "Simon & Garfunkel" and "Mumford & Sons" are single acts.

Multi-episode files

A filename may carry an episode ranges04e06-e07.mkv, s04e06-08.mkv, s04e06e07.mkv — for a file holding up to three consecutive episodes. PathObject parses the range (an implausible range, backwards or longer than three, falls back to the first episode) and MediaFileScanner creates one EpisodeEntity per episode, so each gets its own TMDB metadata and watch status. The file's episode_entity_id FK always points at the first episode; every contained episode (including the first) additionally gets a media_file_episode_entity link row carrying its start/duration slice within the file. No link rows means "normal single-episode file" — all existing FK-based queries stay correct, and playback paths resolve files via MediaFileEpisodeService.filesForEpisode.

The slice boundaries are computed in HandleMediaFileFound (MediaFileFoundEpisodeBoundaries) once ffprobe knows the file duration: one MKV chapter per episode is used directly; with more chapters (scene markers) the chapter nearest to each equal-split point wins, unless that would make an episode implausibly short; otherwise the duration is split equally. Each episode also gets its own backdrop still, taken at the midpoint of its slice. Files scanned before multi-episode support are backfilled by a normal library rescan: the scanner notices an existing file whose path parses as a range but has no link rows, creates the missing episodes and links, and re-sends MEDIA_FILE_FOUND so the boundaries and stills get computed.

Sidecar files (NFO, local images, external subtitles) attach to the first episode of the range, as before.

Crop detection

Some rips carry baked-in black bars. HandleMediaFileFound runs a crop-detection step (MediaFileFoundDetectCrop): ffmpeg's cropdetect filter samples a handful of moments in the file, and the converged crop rectangle is stored on the video's MediaFileStreamEntity (crop_* columns, V37). This decodes a few dozen frames per sample, so it is not free — it runs as part of the file analysis, not on every scan. Files analyzed before the feature existed are caught by a scanner-side backfill: app.ister.server.crop-detect-backfill (default true) re-sends MEDIA_FILE_FOUND on a rescan for video files whose streams have no crop values yet. The consumer of the rectangle is the player, via the GraphQL crop fields — the transcoder deliberately leaves the bars in place (chapter 4).

Subtitle extraction

Embedded subtitle streams become SRT files in the owner's cache directory, one SUBTITLE_EXTRACT_REQUESTED event per stream fired after the file's analysis commits (HandleSubtitleExtractRequestedSubtitleExtractionProcessorSubtitleExtractor). Text codecs are a plain ffmpeg remux; bitmap codecs (DVD/PGS) go through ffmpeg → mkvextract → subtile-ocr (tesseract), which can take minutes per stream — hence a separate, non-transactional event rather than a step inside MEDIA_FILE_FOUND, and one message per stream so each stays well under RabbitMQ's consumer timeout. The result is an EXTERNAL_SUBTITLE row whose path is the owner-local SRT; a stream whose tools fail is flagged extractionFailed so the scanner backfill (subtitleStreamsToReextract) stops re-firing it. The family is helper-capable: a helper node reads the source through the owner's download URL, extracts into its own tmp dir, uploads the SRT with POST /cache/upload/{fileName} and records the owner's path, so the row is indistinguishable from a local extraction. Because extraction now finishes after the analysis, a master playlist generated in between lists the SRT rendition only once that file's playlist cache is regenerated.

Intro/outro detection

Recurring intros and closing credits are found by comparing audio across a season: the disk module (on the owner, or on a helper node listing the directory for DETECT_SEGMENTS — the reader takes a local path or the owner's ranged download URL alike) decodes short windows (first 10 / last 4 minutes of each episode's slice) to mono PCM, fingerprints them (ChromaFingerprinter, a chromaprint-style 32-bit gradient hash per 128 ms — loudness-invariant, no external library), and SegmentMatcher finds the longest shared run between an episode and up to four season neighbours. Because a lag between two episodes that falls between hash frames shears every frame pair apart (the shared run then fragments below the minimum length), the comparison side is fingerprinted at four quarter-hop phase shifts (0/32/64/96 ms) and the phase with the longest run wins. The median bounds over agreeing pairs (at least two, one for two-episode seasons) become media_file_segment_entity rows (INTRO/OUTRO) in absolute file time; in a multi-episode file each slice gets its own rows, disambiguated by episode_entity_id. The player reads them as MediaFile.segments for its skip-intro / next-episode buttons. An intro must be 10–150 s long and start within the first 8 minutes — the bounds are deliberately loose because many intros carry per-episode voice-over over the same music, leaving only part of the audio identical, and long cold opens push the intro well past the five-minute mark.

Intro matching runs in two stages. Pending episodes are ordered from the season's ends inward (first, last, second, second-to-last, …), pairwise-matched as above. Once three episodes of the season carry a confirmed intro, the remaining episodes switch to template matching: their window is matched against up to three confirmed intros (first, middle, last of the season, so a mid-season variant switch still contributes both variants). A template is a known-good intro, so a single ≥ 8 s match suffices where the pairwise stage demands two agreeing neighbours — that rescues episodes whose intro variant none of their four neighbours share — and sliding a ~20 s template over a window is far cheaper than aligning two whole windows against each other. When the season's last chunk finishes, episodes that failed the pairwise stage get one template retry. Outros always use the pairwise stage (their 4-minute window keeps that cheap).

Because detection is cross-episode it cannot run inside the per-file MEDIA_FILE_FOUND handler: that handler instead fires a season-scoped DETECT_SEGMENTS event after its transaction commits, on the same directory-scoped queue family, so detection runs on the node owning the files. HandleDetectSegments is idempotent — files whose media_file_entity.segment_detector_version already equals the current detector version are only used as comparison material — so one event per analyzed episode is fine: the last episode's event does the real work. The version column is also the "ran but found nothing" sentinel; null means detection never ran, and the scanner's backfill (app.ister.server.segment-detect-backfill, default on, once per season per run) sends DETECT_SEGMENTS for such files on a rescan. Per-item reanalysis wipes the segment rows and resets the version, and bumping SegmentDetectionChunkProcessor.DETECTOR_VERSION re-runs detection everywhere via the same backfill. Known limitation: a season spread over multiple nodes only pairs the episodes local to each node — a node holding a single stray episode detects nothing for it.

That idempotence is serial, not concurrent. Recomputing a file is a delete-then-insert, and two transactions doing it for the same season neither see nor cancel each other's rows, so a re-analysis sweep — one message per file, all for the same few seasons — stored every segment once per consumer. A chunk therefore claims its season with a non-blocking advisory lock (pg_try_advisory_xact_lock, namespace 1, key hashtext(seasonId)); a message that does not get the claim is dropped rather than retried, because the holder's chunk chain covers the whole season anyway. Blocking instead would idle a listener thread for the minutes a season takes and walk into the same consumer_timeout. A unique index on (file, type, episode) with NULLS NOT DISTINCT (V39, which also de-duplicated the existing rows) is the backstop.

One message detects at most app.ister.server.segment-detect.chunk-size episodes (default 4): fingerprinting a whole season in one go can outlast RabbitMQ's consumer_timeout (default 30 minutes), which closes the channel and requeues the message forever. Like the blur-hash sweep, SegmentDetectionChunkProcessor runs one chunk in its own transaction (stretched so slices of one multi-episode file never straddle a chunk boundary), and HandleDetectSegments publishes a successor message for the same season only after that commit. The version column is the cursor, and it is stamped even when decoding fails, so the chain always terminates.

Metadata backfill

See the refresh-flow diagram. refreshMetadata(MISSING) sends one global METADATA_BACKFILL_REQUESTED event (consumed by exactly one worker, so the backfill runs once cluster-wide; it used to run per node, which duplicated all globally-queried book/comic/ music/person work on multi-node installs). MetadataBackfillHandle finds everything missing metadata, artwork or TMDB enrichment (movies/shows also match when their tmdbId was never filled — the V45 backfill marker) and fans out: SHOW_FOUND / EPISODE_FOUND / MOVIE_FOUND (TMDB), PERSON_FOUND / ALBUM_FOUND (MusicBrainz + NFO lookup on the disk side), AUDIO_FILE_FOUND for tracks, BOOK_FOUND/EPUB_FILE_FOUND for books, COMIC_SERIES_FOUND/ COMIC_FILE_FOUND for comics, and an optional libraryId scopes everything to one library. The controller sends UPDATE_IMAGES_REQUESTED per directory for the BlurHash sweep itself (those queues are directory-scoped, so the work lands on the owning node). The steps run in separate transactions in MetadataBackfillService; the book-series heuristic runs once cluster-wide in its own write transaction. The per-type pipelines are covered in chapter 3.

Force refresh (per item or per library)

refreshMetadata(FORCE, libraryId) and the per-item mutations (refreshShow(id), refreshMovie(id), …) send ANALYZE_DATA, consumed by two handlers: AnalyzeDataHandle (worker) wipes the item's metadata/images/streams and cascades — a library fans out per type (shows, movies, artists, book authors, comic series), a show to its episodes, a person to their albums and books, an album to its tracks — re-firing the *_FOUND events (persons get both the global send for the external enrichment and the node-scoped send for the artist.nfo re-parse); HandleAnalyzeDataDisk (disk) clears the HLS cache and re-emits the file-level events (MEDIA_FILE_FOUND/AUDIO_FILE_FOUND, NFO_FILE_FOUND, SUBTITLE_FILE_FOUND). Deleted image rows leave their cache files behind (unlinking cross-node is unsafe); the daily cache cleanup reclaims them.

The *_FOUND events are published after the wipe has committed (AfterCommitPublisher.publishAfterCommit): their consumers check for existing metadata/image rows and would otherwise still see the doomed rows and skip the refetch, leaving the item permanently without covers. For albums, disk-side HandleAlbumFound additionally re-emits FILE_SCAN_REQUESTED for local artwork (cover.jpg and friends) in the album directory — album analysis wipes those image rows too, and unlike movies/episodes no directory rescan follows, so the files are re-ingested explicitly (deduped by ImageScanner on the existing (directory, path) row).

The BlurHash sweep

HandleImageFound deliberately saves images without a BlurHash: encoding one is CPU-expensive and made that handler the bottleneck of large scans. The hashes are filled afterwards by the UPDATE_IMAGES_REQUESTED sweep, per directory — including the cache directory, which holds the downloaded artwork and therefore the vast majority of images.

Each message processes at most app.ister.server.blur-hash.chunk-size images, then publishes a successor message carrying a keyset cursor (afterId). One sweep over a whole library in a single message used to exceed RabbitMQ's consumer_timeout (30 minutes), so the message was requeued and the sweep restarted endlessly without ever committing.

Two subtleties:

  • The cursor is a keyset on id — not an offset and not "next row without a hash". An image that can never be hashed (a corrupt file) keeps blur_hash NULL; a naive LIMIT query would re-select such rows every round and never terminate. PostgreSQL orders uuid unsigned while java.util.UUID.compareTo compares signed, so both the ORDER BY and the id > comparison must run in the database, never in Java.
  • The successor message is published only after the chunk's transaction commits (BlurHashChunkProcessor). The other order would let a failed commit leave a cursor pointing past work that was never saved.

Images are decoded through RasterImageDecoder, not ImageIO.read directly. A steady trickle of JPEGs — TMDB serves plenty — carries an ICC profile whose component count disagrees with the actual number of raster bands, typically a Photoshop export tagged CMYK over three-band data. ImageIO builds its colour model from that profile and refuses the file outright, so those images used to keep a null hash for good. The decoder falls back to reading the raw raster, which bypasses colour management, and converts by hand: readRaster performs no conversion at all, so a plain JFIF JPEG arrives as YCbCr rather than RGB, and the Adobe APP14 marker decides whether four bands are CMYK or YCCK.

CacheCleanupScheduler (disk) and TmpTranscodeCleanupScheduler (transcoder) run a daily zombie sweep of the image cache and transcode tmp dirs, deleting files no database row references, and expire old podcast downloads. app.ister.server.cache-cleanup.dry-run defaults to true — the cleanup only logs until that flag is switched off. A third, much more frequent sweep exists next to these two: HlsTranscodeService.cleanupOldFiles runs every 15 minutes over the HLS cache dirs and honors each dir's keep_until deadline — see chapter 4 for how the two HLS sweeps divide the work.