Skip to main content

Event system

Everything significant runs asynchronously through RabbitMQ. A trigger (API call, scheduler, scan) calls MessageSender.send(), which routes the event to its queue; a Handle<T> implementation in disk/, worker/, search/ or transcoder/ consumes it, does its work, and may send further events. The full event overview shows how the main triggers fan out through the handlers.

The Handle<T> contract

Handle<T extends MessageData> (core module) is the central interface. handles() returns the EventType the handler owns; the default listener() throws an IllegalArgumentException for any message whose eventType field does not match before dispatching to handle() — so after the retries such a message ends up on the dead-letter queue. Handlers are plain Spring beans with a @RabbitListener on their queue.

No Hibernate session on listener threads. RabbitMQ listener threads have no open session, so lazy association navigation throws LazyInitializationException. Handlers must load what they need with explicit repository queries (fetch joins or dedicated finder methods), never by walking entity graphs.

Two enums, do not confuse them

  • EventType (database/.../enums/EventType.java) is the logical message type — the source of truth for what kinds of events exist (33 values). Handle.handles() returns one.
  • MessageQueue (core/.../MessageQueue.java) holds the queue base names. MessageSender maps an event to its queue.

Queue names follow app.ister.server.<Event>[.<scope>], where the scope is a directory name, node name, or absent for global queues. The event part is PascalCase — the actual queues are named app.ister.server.MediaFileFound, app.ister.server.TranscodeRequested.<dirName> and so on (see MessageQueue.java) — worth knowing when grepping the RabbitMQ management UI. Scoping is what routes work to the node that owns the files: each node declares and listens only on the queues for its own directories.

Retries and dead-lettering

Failed listeners retry with exponential backoff (spring.rabbitmq.listener.simple.retry.* in core.properties: 3 attempts, 2s initial interval, multiplier 2). After the final failure a RepublishMessageRecoverer moves the message to the app.ister.server.dead-letter queue with the exception preserved in the message headers (RabbitReliabilityConfig). Recent failures also feed the RecentFailuresBuffer for the status subscriptions (chapter 5). The Helm chart's e2e fails on any dead-lettered event, which is why every external call must sit behind a configurable base URL.

Queue scoping

ScopeEvents
Node .{nodeName}PERSON_FOUND, ALBUM_FOUND — the node-scoped sends (MessageSender) that reach the disk handlers on the node holding the files (artist/album .nfo and folder-artwork re-parse); the maintenance flows dispatch them via worker/.../FoundEventDispatcher. The same events also have global sends for the worker's enrichment handlers (see below).
Directory .{dirName}NEW_DIRECTORIES_SCAN_REQUEST, FILE_SCAN_REQUESTED, MEDIA_FILE_FOUND, AUDIO_FILE_FOUND, EPUB_FILE_FOUND, COMIC_FILE_FOUND, SUBTITLE_FILE_FOUND, IMAGE_FOUND, NFO_FILE_FOUND, UPDATE_IMAGES_REQUESTED, ANALYZE_DATA (disk), DETECT_SEGMENTS, SUBTITLE_EXTRACT_REQUESTED, PRE_TRANSCODE_RECENTLY_WATCHED, TRANSCODE_REQUESTED, TRANSCODE_PASS_REQUESTED
GlobalSHOW_FOUND, EPISODE_FOUND, MOVIE_FOUND, PERSON_FOUND (worker), ALBUM_FOUND (worker), TRACK_FOUND (no consumer), BOOK_FOUND, COMIC_SERIES_FOUND, CHAPTER_FOUND (no consumer), PODCAST_FOUND (no consumer), PODCAST_EPISODE_FOUND (no consumer), PODCAST_REFRESH_REQUESTED, CONTINUE_WATCHING_REBUILD_REQUESTED, ANALYZE_DATA (worker), METADATA_BACKFILL_REQUESTED, SEARCH_INDEX_REQUESTED, SEARCH_REINDEX_REQUESTED
Cache directory .{nodeName}-cache-directoryPODCAST_EPISODE_DOWNLOAD_REQUESTED exists only with this suffix (the download lands on that node's disk). Beyond that, nearly every directory-scoped queue also gets a cache-directory variant: DiskQueueNamingConfig adds one for each of its queues (FILE_SCAN_REQUESTED, MEDIA_FILE_FOUND, AUDIO_FILE_FOUND, IMAGE_FOUND, SUBTITLE_FILE_FOUND, NFO_FILE_FOUND, EPUB_FILE_FOUND, COMIC_FILE_FOUND, UPDATE_IMAGES_REQUESTED, ANALYZE_DATA, DETECT_SEGMENTS, SUBTITLE_EXTRACT_REQUESTED, PRE_TRANSCODE_RECENTLY_WATCHED, …), and TranscoderQueueNamingConfig does the same for the transcode queues — downloaded podcast episodes live in the cache directory and must flow through the same pipelines.

PRE_TRANSCODE_RECENTLY_WATCHED is suffixed with the directory name: PreTranscodeScheduler (worker) sends one event per configured directory (WorkerDiskConfig, reading app.ister.disk.directories), and the disk module listens on the matching queues (DiskQueueNamingConfig.getPreTranscodeRecentlyWatchedQueues).

Helper-capable families. All directory-scoped names come from one core component, DirectoryQueueNames: queues(base) is the node's own directories plus its cache directory (owner-only events, they need the file on local disk), and queues(base, HelperJob) is the same set minus the own directories when the owner lists the job in app.ister.helper.offload-jobs, plus every app.ister.helper.disks[n] entry configured for that job. Three families go through the second form — TRANSCODE (TRANSCODE_REQUESTED, TRANSCODE_PASS_REQUESTED), DETECT_SEGMENTS and SUBTITLES (SUBTITLE_EXTRACT_REQUESTED) — so a helper node consumes the owner's queues as a competing consumer and reads the source through MediaFileInputResolver (local path, or the owner's tokenized /mediaFile/{id}/download, which serves byte ranges). The cache-directory queue is always consumed by its owner, offloaded or not. The deprecated app.ister.transcoder.disks is mapped onto helper disks with the TRANSCODE job.

Handler reference

HandlerModuleReceivesSends
HandleNewDirectoriesScanRequesteddiskNEW_DIRECTORIES_SCAN_REQUESTFILE_SCAN_REQUESTED
FileScanRequestedHandlediskFILE_SCAN_REQUESTEDMEDIA_FILE_FOUND / AUDIO_FILE_FOUND / EPUB_FILE_FOUND / COMIC_FILE_FOUND / IMAGE_FOUND / NFO_FILE_FOUND / SUBTITLE_FILE_FOUND
HandleMediaFileFounddiskMEDIA_FILE_FOUNDIMAGE_FOUND, DETECT_SEGMENTS (season-scoped, after commit), SUBTITLE_EXTRACT_REQUESTED (one per embedded subtitle stream, after commit)
HandleDetectSegmentsdiskDETECT_SEGMENTSDETECT_SEGMENTS (intro/outro detection per season, processed in chunks — the handler re-queues itself for the next chunk and once more as a final sweep, because an episode analyzed while the season was locked had its own event dropped; helper-capable)
HandleSubtitleExtractRequesteddiskSUBTITLE_EXTRACT_REQUESTED— (extracts/OCRs one subtitle stream to an SRT in the owner's cache directory, uploading it when run on a helper; helper-capable)
HandleAudioFileFounddiskAUDIO_FILE_FOUNDIMAGE_FOUND (track- or chapter-bound, by library type)
HandleEpubFileFounddiskEPUB_FILE_FOUNDIMAGE_FOUND
HandleComicFileFounddiskCOMIC_FILE_FOUNDIMAGE_FOUND (extracted cover)
HandleSubtitleFileFounddiskSUBTITLE_FILE_FOUND
HandleImageFounddiskIMAGE_FOUND
HandleNfoFileFounddiskNFO_FILE_FOUND
HandleUpdateImagesRequesteddiskUPDATE_IMAGES_REQUESTEDUPDATE_IMAGES_REQUESTED (next chunk)
HandleAnalyzeDataDiskdiskANALYZE_DATAMEDIA_FILE_FOUND / AUDIO_FILE_FOUND / NFO_FILE_FOUND / SUBTITLE_FILE_FOUND
HandlePreTranscodeRecentlyWatcheddiskPRE_TRANSCODE_RECENTLY_WATCHEDTRANSCODE_REQUESTED, MEDIA_FILE_FOUND (for files without analyzed streams)
HandlePersonFounddiskPERSON_FOUND (node-scoped queue)NFO_FILE_FOUND
HandleAlbumFounddiskALBUM_FOUND (node-scoped queue)NFO_FILE_FOUND, FILE_SCAN_REQUESTED (re-ingest of local album artwork such as cover.jpg)
HandlePodcastEpisodeDownloadRequesteddiskPODCAST_EPISODE_DOWNLOAD_REQUESTEDAUDIO_FILE_FOUND (on the cache-dir queue → ffprobe + HLS pre-generation)
MetadataBackfillHandleworkerMETADATA_BACKFILL_REQUESTEDSHOW_FOUND, EPISODE_FOUND, MOVIE_FOUND, PERSON_FOUND, ALBUM_FOUND, AUDIO_FILE_FOUND, BOOK_FOUND, COMIC_SERIES_FOUND, EPUB_FILE_FOUND, COMIC_FILE_FOUND, NFO_FILE_FOUND
AnalyzeDataHandleworkerANALYZE_DATAcascade per entity type
HandleShowFoundworkerSHOW_FOUNDIMAGE_FOUND (+ cast credits written directly to the database)
HandleEpisodeFoundworkerEPISODE_FOUNDIMAGE_FOUND (+ cast/guest-star credits directly to the database)
MovieFoundHandleworkerMOVIE_FOUNDIMAGE_FOUND (+ cast credits directly to the database)
HandlePersonFoundworkerPERSON_FOUND (global queue)
HandleAlbumFoundworkerALBUM_FOUND (global queue)IMAGE_FOUND
HandleBookFoundworkerBOOK_FOUNDIMAGE_FOUND (Open Library cover, only when none exists yet)
HandleComicSeriesFoundworkerCOMIC_SERIES_FOUNDIMAGE_FOUND (Wikipedia thumbnail, only when no local artwork)
HandlePodcastRefreshRequestedworkerPODCAST_REFRESH_REQUESTEDIMAGE_FOUND (feed cover), PODCAST_EPISODE_FOUND, PODCAST_EPISODE_DOWNLOAD_REQUESTED (newest N)
HandleContinueWatchingRebuildRequestedworkerCONTINUE_WATCHING_REBUILD_REQUESTED
HandleTranscodeRequestedtranscoderTRANSCODE_REQUESTEDTRANSCODE_PASS_REQUESTED
HandleTranscodePassRequestedtranscoderTRANSCODE_PASS_REQUESTED
HandleSearchIndexRequestedsearchSEARCH_INDEX_REQUESTED— (upsert/delete in Typesense)
HandleSearchReindexRequestedsearchSEARCH_REINDEX_REQUESTED— (full rebuild + alias swap)

Publish after commit. Handlers that delete or write rows and then emit an event pointing at those rows must publish via AfterCommitPublisher.publishAfterCommit (core; ServerEventService uses it for every create*FoundEvent): the consumer often runs within milliseconds and would otherwise read pre-commit state — e.g. an album-found consumer seeing image rows that the analysis transaction is about to delete, and skipping the cover refetch. The wrap deliberately lives at the call sites, not inside MessageSender: several handlers register their own after-commit callbacks, and a synchronization registered from within an afterCommit callback is never invoked, which would silently drop messages.

SEARCH_INDEX_REQUESTED is emitted from many places: ServerEventService.createXFoundEvent (on creation), MetadataSave (TMDB), the MusicBrainz and NFO handlers, audio-tag saves (including action=DELETE on track dedup), and metadata deletes — see chapter 6.