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.
| File | Event | Handler work |
|---|---|---|
| Video | MEDIA_FILE_FOUND | ffprobe streams + duration, extract embedded subs to SRT, screenshot as backdrop |
| Audio | AUDIO_FILE_FOUND | ffprobe, 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_FOUND | OPF 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 |
.srt | SUBTITLE_FILE_FOUND | link SRT to episode as an EXTERNAL_SUBTITLE stream |
| Image | IMAGE_FOUND | save ImageEntity, link to show/movie/episode/etc. |
.nfo | NFO_FILE_FOUND | parse 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 range — s04e06-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
(HandleSubtitleExtractRequested → SubtitleExtractionProcessor → SubtitleExtractor). 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) keepsblur_hash NULL; a naiveLIMITquery would re-select such rows every round and never terminate. PostgreSQL ordersuuidunsigned whilejava.util.UUID.compareTocompares signed, so both theORDER BYand theid >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.
Related scheduled jobs
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.