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:
claim(), before anything else, claims the library for the delete with a claim of the kinddelete, the same lease a scan claims, so no scan indexes the files while the delete runs. It throwsLibraryBusyException(409, reasonlibrary_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 marksfailed. 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 afterclaim()reads every song the import wrote.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, reasonall_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), andLibraryMediaDirectoryNotWritableException(409) when the server cannot write a directory that holds one of the files. A delete preview callsinspect()instead, which runs the same checks, reports whether a scan or another delete holds the library, and changes nothing.deleteIndexRows(), inside the caller's transaction, deletes thelibrary_file_indexrows of the paths throughLibraryFileIndexRepositoryInterface::removeByPaths(), up to 1,000 paths per statement. It throws aLogicExceptionwhen no transaction is active, so the index rows go only in the transaction that deletes the catalog rows.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 inLibraryMediaFileDeletionResult. 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 withclaim_lost.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 theasynctransport 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 retainedlibrary_file_indexrows 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 takespg_advisory_xact_lock_sharedon a key derived from the library ID and then reads the claim;claimDelete()takespg_advisory_xact_lockon 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 ishashtextextended(<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 |