Media types and metadata
Every media type follows the same pattern: the scanner registers entities from the path, *_FOUND
events trigger enrichment from an external provider, images flow through IMAGE_FOUND. Metadata
providers live in worker/.../events/; their HTTP clients are centralized in
worker/.../http/MetadataRestClients, and every external base URL is a property with the real
service as default — the chart's e2e serves them all from one WireMock pod and fails on
dead-lettered events, so a hardcoded URL breaks CI.
Languages
app.ister.languages / ISTER_LANGUAGES (ISO-639-1 tags, default en,nl, exposed as
LanguageProperties) is the single app-wide language list. Movie/show/episode handlers fetch TMDB
details once per configured tag, producing one MetadataEntity row per language per item; the
language is stored as ISO-639-3 even though the fetch uses ISO-639-1. The first tag is the
primary/fallback language. The same list drives the search schema (chapter 6).
Adding a language requires a re-scan plus a reindex.
Movies, shows, episodes (TMDB)
MOVIE_FOUND / SHOW_FOUND / EPISODE_FOUND handlers fetch TMDB details per language, save
MetadataEntity rows, and download posters/backdrops (emitted as IMAGE_FOUND on the cache
directory).
The details response is mined for more than title/overview. Per language the metadata row also
carries the localized genre list (comma-separated names — this is what lights up the GENRE filter
and the genre_<tag> search fields for video) and the tagline. Language-independent facts are
columns on the entity itself, applied once on the first successful language: tmdbId, imdbId,
voteAverage/voteCount (null while there are zero votes), runtime (movies/episodes, minutes),
status, homepage, originCountry, studios, collection id/name (movies, from
belongs_to_collection) and networks (shows). After the language loop, TmdbExtrasService makes
a handful of extra endpoint calls per item — release dates/content ratings (certification for
app.ister.worker.tmdb.certification-country, default US, falling back to US and then any),
videos (one YouTube trailer key, preferring official trailers), keywords, and TV external ids
(imdb). Each extras call is individually fault-tolerant: a failure logs and leaves the field null
instead of dead-lettering the event (the chart e2e stubs TMDB with WireMock).
Credits come along in the same pass: movie credits, show aggregate credits, and episode credits
(cast + guest stars) become PersonEntity + CreditEntity rows, written directly to the database.
A CreditEntity links a person to exactly one of movie/show/episode. A PersonEntity is shared
between actors and music artists; TMDB cast members are deduplicated against existing persons on
exact name + birth year. The GraphQL Credit type exposes the back-references
(movie/show/episode, batch-resolved in CreditController), so a filmography is queryable via
personById { credits { movie/show/episode } }.
Related shows
Show.related(limit) (ShowController, ShowRepository.findRelatedShowIds) turns that same
enrichment into a "more like this" list, without any extra provider call: it scores the other shows
of the same library on shared TMDB keywords (3.0 each, capped at five so one long keyword list
cannot dominate), shared genres (1.5 each) and shared cast members (1.0 each, capped at three). An
overlapping network, origin country or a release year within five years adds half a point each, but
only ranks shows that already share content — a common broadcast year is not a relation by itself.
Shows that share nothing are left out, so a show without TMDB enrichment yields an empty list
instead of arbitrary neighbours, and the list gets better with every refreshMetadata.
Keywords, networks and origin countries are the comma-space joined strings on show_entity;
genres live per language on the metadata rows, so the query pairs the two shows' metadata rows on
language (the genre names themselves are translated) and takes the best-matching language. The
candidates are by definition in the library the caller was already granted access to through
showById, so the field needs no access check of its own. It is deliberately not a @BatchMapping:
only the show detail page asks for it, one show at a time.
Music (MusicBrainz)
Artist directories become PersonEntity rows (PERSON_FOUND), albums AlbumEntity
(ALBUM_FOUND), tracks via AUDIO_FILE_FOUND (ffprobe + ID3 tags + embedded cover). Album identity
comes from the path, never from tags. The track's artist, however, comes from the artist
tag (falling back to the path artist): on compilation albums the per-track performer only exists
there, while the album keeps the path-derived album artist. The worker's HandleAlbumFound queries MusicBrainz and
downloads the release-group cover; the disk-side HandlePersonFound/HandleAlbumFound look for
artist.nfo/album.nfo. Artists get a birthYear (MusicBrainz life-span, or the folder name)
precisely so the TMDB actor dedup above can match them.
Every track also carries credits (TrackCreditEntity): the primary artist — the same person as
TrackEntity.personEntity — plus any featured guests, one row per person. That join table is what
makes an artist's compilation and guest appearances reachable from their page
(chapter 7); the album keeps a single album artist.
Person bios and portraits (Wikipedia/Wikidata)
WikipediaService (worker) enriches persons with multilingual biographies and portraits: Wikidata
resolves the entity and its image/sitelinks, the Wikipedia summary endpoint (a URL template
property) supplies per-language extracts. The same service backs comic-series descriptions.
Books (LibraryType.BOOK)
Directory grammar is author-first: Author/Book.epub and Author/Book/NNN_Chapter.mp3. All formats
of one (normalized) book name converge on a single BookEntity (author = PersonEntity); formats
are attachments — epubs link via MediaFileEntity.bookEntity, audiobook mp3s via ChapterEntity
(streamed over the same audio-only HLS path as tracks). HandleAudioFileFound branches on library
type to create chapters instead of tracks.
- Media overlays (EPUB 3 read-aloud) are flagged on
MediaFileEntity.mediaOverlays, detected exclusively from the epub contents (SMIL entries in the OPF manifest, parsed bydisk/.../epub/EpubParser) — never from the filename. BOOK_FOUNDtriggers Open Library enrichment (description, and a cover only when none exists yet); Wikidata adds series membership (series name + position); NFO data is deduplicated against provider data so re-scans do not double descriptions.- Series (
BookSeriesService, core) come from three sources with fixed precedence: epub series metadata (calibre / EPUB 3 belongs-to-collection) is authoritative and rewrites the link on every scan; a path-prefix heuristic fills series-less books when ≥2 books of the author share the prefix before a-/:separator; and Wikidata series discovery (WikidataBookSeriesService.discoverSeries, run fromBOOK_FOUND) links a series-less book into one of the author's existing series via its P179 (part of series) statement — it never creates a series, and requires a P50 (author) label match so the same-titled film or game can never link. The position/original-year enrichment for books that already have a series (findBookInSeries) applies the same P50 check: the series label alone cannot reject the film, because a film series may carry exactly the same label as the book series ("Harry Potter"). Label matching accepts Wikidata'smul(default-for-all-languages) label next to the configured languages: bots consolidate labels that are identical across languages into it, deleting the per-language ones. Discovery covers what the other two can't see: titles without a separator ("Harry Potter en de steen der wijzen") and audiobook-only books without epub metadata. When epub metadata creates a series,BOOK_FOUNDre-fires once for the author's series-less books, so discovery converges within one scan regardless of scan order. The analyze backfill also re-dispatchesBOOK_FOUNDfor every series-less book whose author has a series — a rescan skips already-known files, so without this a book whose discovery ran too early (or failed against Wikidata at the time) would stay out of its series forever. - Epubs are read lazily by the client through
GET /epub/{mediaFileId}/resource/{entry}(chapter 7); reading position is aWatchStatusEntitywithreadingLocation(epubcfi) +readingProgress.
Comics (LibraryType.COMIC, migration V23)
Comics are series-first, the opposite of the book grammar: {root}/{Series Name (start year)}/Volume 27.cbz (also Vol 3 - Subtitle.pdf, Issue 8.epub, and tolerated wild patterns;
cover.jpg in the series directory is series artwork). The (YYYY) suffix is the series start
year, not an author year — comics have no author in the path; parsing lives in ComicPathObject +
ComicFileNameParser, anything nested deeper than the series directory is ignored.
ComicScanner creates the SeriesEntity and the volume (a BookEntity without author) from the
path and attaches the file as a MediaFileEntity; all formats of one volume (same basename)
converge on one volume row. Content reading is asynchronous: HandleComicFileFound (disk) reads cbz
via CbzParser and PDF via PDFBox (PdfParser) — page count onto the MediaFileEntity,
embedded ComicInfo.xml (cbz) becomes volume metadata and can refine the filename-derived series
position and title, and the cover (first cbz page, or PDF page 1 rendered) is extracted to the
cache. Epub volumes reuse the full EPUB_FILE_FOUND pipeline. COMIC_SERIES_FOUND (worker,
HandleComicSeriesFound) adds per-language series descriptions and a thumbnail from
Wikipedia/Wikidata — local artwork always wins and is never overwritten. Pages are served to the
reader by ComicResourceController (/comic/{mediaFileId}/manifest, /page/{index}, /file).
Podcasts (LibraryType.PODCAST)
The first feed-based library type: there is no library directory. subscribePodcast(feedUrl) or
the hourly PodcastRefreshScheduler (guarded by lastRefreshedAt so multiple nodes do not sweep
twice) sends PODCAST_REFRESH_REQUESTED (global queue). The worker's RssFeedParser fetches the
feed with a conditional GET (ETag/Last-Modified), caps at 500 items, syncs channel metadata +
cover, and creates PodcastEpisodeEntity rows deduplicated on guid.
The newest N episodes (app.ister.worker.podcast.auto-download-count, default 3) get
PODCAST_EPISODE_DOWNLOAD_REQUESTED on the cache-directory queue of the refreshing node; the
disk handler downloads the enclosure (following redirects) to {cache}/podcasts/ and emits
AUDIO_FILE_FOUND, after which playback is identical to tracks. Older episodes download on demand
via the downloadPodcastEpisode mutation. Retention: the daily cache cleanup deletes downloads
older than podcast-retention-days (default 30) unless someone is mid-episode — the episode row
survives and can re-download. Directory search uses the free iTunes Search API
(ItunesSearchService, api module).
NFO files
HandleNfoFileFound (disk) parses XML NFO files into metadata: title, description, release date for
film/TV, biography for artists, review for albums, book metadata for books.
PERSON_FOUND/ALBUM_FOUND on the disk side proactively look for artist.nfo/album.nfo next to
the media. NFO-sourced and provider-sourced metadata are deduplicated so neither overwrites richer
data from the other.