On this page
Developer guide

Library

The Library context manages media libraries -- collections of media files organized by type (music, movies, video). It handles library creation, file scanning for new or changed media, and membership tracking (which users belong to which libraries). When a scan completes, it dispatches domain events that the Metadata context picks up for enrichment.

Domain Models

Aggregate Roots

Model Key Properties Purpose
Library name, type, path, slug, owner Represents a media library tied to a filesystem path

Value Objects

Model Purpose
LibraryPath Filesystem path with validation (must exist, must be readable)
LibrarySlug URL-safe identifier derived from the library name
LibraryType Enum: Music, Movie, Video

Commands

Command Handler Purpose
CreateLibraryCommand CreateLibraryHandler Creates a new library with a name, type, and filesystem path. Validates the path and generates a slug.
ScanLibraryCommand ScanLibraryHandler Walks the library's filesystem path to discover new, changed, or removed media files, and publishes FilesDiscovered for each directory with new or changed files. Dispatches LibraryScanCompleted when finished. A cancelled job stops before its next directory and marks the scan failed.

Ports

Port Purpose Implemented By
LibraryPortInterface Library CRUD operations (create, read, update, delete, list) Doctrine repository
DirectoryScannerPortInterface Walks a filesystem directory tree and returns discovered media files DirectoryScanner
CoverArtExtractorPortInterface Extracts embedded cover art from media files (e.g., ID3 tags in MP3s) CoverArtExtractor
LibraryMembershipQueryPort Queries which users belong to which libraries Doctrine repository
LibraryContentStatsInterface Per-library song, album, artist, genre, size and duration totals Catalog CatalogStatsQuery (published as the Library Content Stats Contract)
LibraryProvisioningInterface Finds or creates a local movie library, scans it synchronously and returns FilesDiscovered messages for worker-free developer ingest LibraryProvisioner (published as the Library Provisioning Contract; used by Catalog's app:e2e:ingest-video)
LibraryMediaFilesInterface Checks and deletes the audio files of catalog entries being deleted, confined to their library's root LibraryMediaFiles (published with its LibraryMediaFile* result types as the Library Media Files Contract; used by Catalog's album and song deletes and their previews)

Media File Deletion

LibraryMediaFilesInterface deletes the audio files of catalog entries that are being deleted, confined to their library's root. It is the single guard for that deletion, so the API and the console apply the same checks. A path is the absolute path the scanner stored. A caller uses the port in five steps, and releases the claim in a finally block:

  1. claim(), before anything else, claims the library for the delete with a claim of the kind delete, the same lease a scan claims, so no scan indexes the files while the delete runs. It throws LibraryBusyException (409, reason library_busy, with the holder's kind) while a scan or another delete holds a live claim. The claim leaves the library's scan status and last scan time alone; a lapsed scan claim it takes over belonged to a scan that died, which it marks failed. Before it claims, it waits for the import writes in flight on the library to commit (see below), so a caller that reads its songs after claim() reads every song the import wrote.
  2. prepareDeletion(), before the transaction, checks every path under the delete's own claim and refuses the whole request before anything changes: LibraryRootUnavailableException (409) when the library root is not an existing directory (unmounted storage would otherwise read as every file missing, and dropping their index rows would let the next scan import them again), LibraryMediaFileOutsideRootException (422) when a path, or the file a symlink at it points to, lies outside the library root, LibraryMediaFilesAllMissingException (409, reason all_files_missing) when the request has paths and every one reads as missing, as when the storage under part of the library is not mounted (a request without paths, such as an album without songs, is not refused for this), and LibraryMediaDirectoryNotWritableException (409) when the server cannot write a directory that holds one of the files. A delete preview calls inspect() instead, which runs the same checks, reports whether a scan or another delete holds the library, and changes nothing.
  3. deleteIndexRows(), inside the caller's transaction, deletes the library_file_index rows of the paths through LibraryFileIndexRepositoryInterface::removeByPaths(), up to 1,000 paths per statement. It throws a LogicException when no transaction is active, so the index rows go only in the transaction that deletes the catalog rows.
  4. deleteFiles(), after the commit, renews the claim's lease (once per minute at most), checks each file again and unlinks it. A symlink is removed as a link and its target is kept. A file that now resolves outside the root, or cannot be unlinked, is reported as left in LibraryMediaFileDeletionResult. When the renewal finds the claim taken over, which happens only after the delete went past its lease, the files not yet unlinked are left with claim_lost.
  5. release() ends the claim, also when a step before failed. A failure to end the claim is retried once, then logged instead of thrown, so it never hides the delete's own outcome; the claim then lapses with its lease. The console delete commands turn the first SIGINT or SIGTERM of a delete into an exception inside the running delete, so this step runs for them too. Later signals of the same delete are ignored, so they cannot cut the release short, and the retry ends a claim whose first release attempt the interruption cut short. When a catch inside the delete swallows the interruption, the delete finishes and the command prints its result with a warning naming the signal.

With its index row gone, a file the deletion leaves on disk reads as new to the next incremental scan and is imported again.

MediaFileGuard checks each file again immediately before unlinking it. PHP has no unlinkat(), so a parent directory replaced with a symlink between that check and the unlink is not caught. This is an accepted limit, documented for operators on the delete command pages; only a user who can write the library's directories can cause it.

Catalog's FilesDiscoveredHandler also uses the port when it imports a directory:

  • isHeldByDelete() is true while a delete claim holds the library. Before it starts, the handler then puts the message back on the async transport with a 30-second delay and imports nothing, so it cannot import a file the delete is about to unlink (a scan only drops index rows, so such a song would stay in the catalog). The claim lapses with its lease if the delete dies, so the wait ends.
  • markMissingUnavailable() names paths with no file now and marks their retained library_file_index rows unavailable. The handler skips these paths before resolving the album or probing a video, so a directory whose files are all gone creates no album or movie. A later scan restores availability and queues unchanged returning files again.
  • isHeldByDeleteForImport() guards each write. The handler reads the metadata of up to 50 files, then writes their songs, artist links and genres in one transaction (TransactionPortInterface) that first calls this method. It takes pg_advisory_xact_lock_shared on a key derived from the library ID and then reads the claim; claimDelete() takes pg_advisory_xact_lock on the same key in the transaction of its claim statement. A delete claim therefore waits for the writes in flight to commit, and every later write sees the claim. A write that sees it writes nothing; the handler puts the files whose songs it has not committed back on the queue with the same delay, and the songs already committed stay. The key is hashtextextended(<library ID>, 20261010120000); the seed keeps it apart from the application's other advisory locks, and a hash collision only makes two libraries wait for each other. The read of the claim is a statement of its own, so under PostgreSQL's default READ COMMITTED isolation its snapshot is taken after the lock is granted. A scan claim takes no import lock.

findAvailableHashesByPaths() reads the current available observations for the requested library and paths. Ingestion filters queued files against these hashes before checking presence or reading metadata. Missing, unavailable and mismatched observations are skipped. lockAvailableHashesByPathsForImport() repeats the read with FOR SHARE inside an active transaction, after the shared import/delete guard. Album and movie parent writes, song and video writes, and source rename reconciliation use this boundary. Metadata reads and FFprobe run before the locks. If an album's metadata source becomes superseded during preparation, the handler reselects from the remaining current candidates; each failed selection removes at least one candidate. Current files in a mixed message still import.

The index locks use bytewise path order across bounded SQL statements. Scanners sort a copy for their database upserts while preserving result and event order. Bulk unavailable/removal operations lock existing target rows in the same order and mutate only those locked paths, so a path inserted after the lock query is not changed by that older operation. Import locks hold the matched observations stable through commit or rollback. Hash equality permits a latest observation that returns to an earlier hash; this is a committed-index check, not a live filesystem snapshot.

Each path gets a LibraryMediaFileVerdict: deletable, missing (nothing at the path, or a symlink to nothing; only the index row goes), outside_root, or directory_not_writable. LibraryRepositoryInterface::liveClaimKind() answers which kind of claim, if any, holds the library without having lapsed, by the database clock (clock_timestamp()); liveClaimKindForImport() answers the same after taking the import lock, and throws a LogicException outside a transaction, where the lock would end with its own statement.

Domain Events

Event When Emitted Consumers
LibraryScanCompleted After a library scan finishes discovering files Notification (BackgroundJobs category)

API Endpoints

All endpoints are prefixed with /api and served by LibraryController.

Method Path Purpose
GET /api/libraries List libraries the authenticated user has access to
POST /api/libraries Create a new library (administrators only)
POST /api/libraries/validate-path Check that a server path exists and is readable (administrators only)
GET /api/libraries/{id} Get a single library by ID
PATCH /api/libraries/{id} Update library metadata (name, path)
DELETE /api/libraries/{id} Delete a library and its membership records

Infrastructure

Component Purpose
DirectoryScanner Recursively walks a filesystem path, filtering for media file extensions. Returns a collection of MediaFile objects.
MediaFile Value object representing a discovered file (path, size, MIME type, modification time).
LibraryMediaFiles Implements LibraryMediaFilesInterface: loads the library, takes, renews and releases the delete claim through the repository, and delegates the file system checks to MediaFileGuard.
MediaFileGuard The file system rules of media file deletion: resolves symlinks in parent directories, checks that the entry and a symlink's target lie under the root's real path, and checks again immediately before each unlink.
PathSecurityService Validates filesystem paths to prevent directory traversal attacks. Ensures resolved paths stay within allowed boundaries.
LibraryEntity Doctrine ORM entity for the libraries table.
UserLibraryEntity Doctrine ORM entity for the user_libraries junction table (many-to-many relationship between users and libraries).

Cross-Context Dependencies

Direction Context Relationship
Depends on Shared Uses Uuid and PublicId for entity identification
Depends on Filesystem Uses MimeDetectorPortInterface during scans and FileWatcher to detect filesystem changes
Depended on by Catalog Album and song deletes and their previews use LibraryMediaFilesInterface; FilesDiscoveredHandler uses it to wait for a delete and skip missing files