# Library storage decisions This document records the **reasoning** behind how the library is handled when its folder is missing, wrong or disappears, and the **limits** that remain. It also records the work done and then removed on network storage (NFS) and the criteria for resuming it. The operational commands are in [installation.md](installation.md); the rules the code must follow are in [conventions.md](conventions.md); the overall picture is in §15 of [architecture.md](architecture.md). ## 1. MVP scope The MVP supports **only local storage**, always in a regular folder: - a **host folder** on the server's disk, which must be specified with `VHS_HOST_LIBRARY` and is mounted in the containers as `/srv/video-library` (`VHS_MEDIA_ROOT`, fixed in the containers); - this applies to installations (for example `/srv/vhs/library`) and to development (`./video-library`), which also runs in containers. **Why a folder and not a Docker volume.** Videos are user data: in a folder chosen by whoever installs VHS they stay visible and browsable, they can live on a dedicated disk, they are backed up by copying them, and they do not disappear with a `docker compose down -v`. A volume would hide them in Docker's internal area, on the system disk. The volume remains only for PostgreSQL, whose files are not touched by hand. Until this decision the volume was the default for the library; it was removed so that there is only one way to configure it. **Network shares (NFS, SMB, NAS) and separately mounted disks are not supported in the MVP.** A dedicated mode was implemented, tested and then removed to keep complexity down (§5); the criteria for resuming it are in §6. ## 2. The failures we want to avoid **Writing in the wrong place.** If the library is not where VHS expects it (folder renamed or moved, wrong `VHS_HOST_LIBRARY` after an update, folder replaced), an empty folder in its place must not become a new library: - a download would create `.incomplete/`, sidecars and the **video file** in a folder that nobody considers the library; - the database would point to paths that do not exist in the real library, or vice versa; - a disk's space would fill up without anyone noticing. Before this work VHS fell into this trap: `available_space()` created the root, `work_dir()` created `.incomplete/` with all its parent directories, and `finalize()` created the destination folders. **Losing track of the main file.** A new download of an already registered video, with a changed title, could compute a new path and leave the old file on disk with no reference to it (§39, Phase 3). **Confusing "library gone" with "new library".** An empty folder can be the start of a library or a library that no longer exists. Treating the second as the first means starting to write in the wrong place again. ## 3. MVP protections **The marker** (`.vhs-library`, in the library root) says "this is the library". It lives **on the library itself**: if the folder is replaced or moved, the marker is not there in its place. - VHS writes only where it finds the marker. The check is **in the storage** (`FilesystemStorage._require_available()` before `work_dir`, `finalize`, `delete`), not left to each service remembering it: even new code cannot write to an unavailable library. - The storage **never creates the root**, with a single exception: the new library, created explicitly by `prepare_for_write()`. - The marker is created only explicitly (`init_library`) or for the new library; never by migrate, startup, reconciliations or health. **New library and missing library.** - A **truly new** library (no archived video in the database, folder absent or empty) is initialized **automatically** on the first download: no configuration for whoever installs VHS. - An **already populated** library that disappears stays **blocked** (`missing`): the database says files existed, so an empty folder is not a new library. - A non-empty folder without a marker (for example an installation predating this work) is `not_initialized` and is initialized once with `init_library`, which checks that the registered files are actually there. **With the library unavailable:** downloads and preparations fail with `error_code=storage` before touching the storage; streams and thumbnails respond 503; `/health` responds `degraded`; reconciliations are skipped; the `vhs.W002` system check and a banner in the UI show the reason and the command. `manage.py storage_status` shows the folder, the marker and the status. **Host folder with Compose.** `docker-compose.yml` mounts `VHS_HOST_LIBRARY` with a long-syntax bind mount and `create_host_path: false`, read-write in `migrate`, `backend` and `worker`, read-only in `nginx`. Without the variable, Compose stops with an error; if the folder does not exist on the host, Docker does not create the containers and does not create the folder (a short-syntax bind would create it empty, and VHS would treat it as a new library). An empty folder becomes a new library on the first download; a folder with files in it requires `init_library`. ## 4. MVP limits - **Between the check and the write.** `finalize` runs the check again before promoting a file; this reduces the risk of writing in the wrong place but does not eliminate it: the folder can disappear between the check and the write. - **A transfer already in progress can leave temporary files where the library used to be.** yt-dlp creates output directories on its own: if the library disappears midway (folder moved), it can recreate `.incomplete/` and keep writing there. The check before promotion prevents those files from entering the library or the database (download `failed/storage`), but the temporary files remain and must be removed by hand. This behavior is covered by a test that simulates the directory being recreated. With local storage the case is rare; with a network share that disappears it is the typical case, and it is one of the reasons NFS is outside the MVP (§6). - **Library on an unsupported mount.** Anyone who puts the library on a share or on a separately mounted disk anyway is protected only by the marker, which lives on the mount: with videos already archived, a missing mount blocks VHS (`missing`). Still uncovered are the **first installation** with the mount absent (the empty local folder looks like a new library) and the mount disappearing during a download (previous point). - **Reconciliation.** With the library unavailable, reconciliations do not mark `interrupted`, do not register promoted files and do not clean up work dirs: they act only when the library becomes available again. ## 4b. Backup: consistency and deferred choices - **Who writes to the library:** only the worker's tasks (downloads, preparations, periodic reconciliation) and the `migrate` service at startup, besides commands run by hand (`init_library`, which creates the marker). The backup therefore stops only the worker, after checking that no jobs are running, and checks again after the stop: a job started in the meantime would have been interrupted, and the backup stops. The worker is restarted as a container, not with `docker compose start`, which would rerun `migrate` and its reconciliation. - **Order:** first the database dump, then the file copy. A file that appeared after the dump can only end up without a reference, never missing. - **Restore outcome:** the `verify_library --checksums` reports taken at backup time and after the restore are compared. A problem is identified by type, path and observed value (size or checksum found): a file that was already different at backup time and is different in another way after the restore counts as new. The comparison lives only in the restore procedure; the exit codes of `verify_library` do not change. - **Deferred:** `rsync --link-dest`. Hard links would save space, but they tie the files of later backups to those of earlier ones and add cases to handle (attributes that must match for the linking to happen). In the MVP every backup is a full copy. ## 5. Network storage: the work done and removed The mounted mode was implemented in commit `207fc9a` and removed in the following simplification commit, `a9dbf01`. Both belong to the development history before the first public release, kept in a private archive of the maintainer: the code can be recovered from there (`library_service.py`, `docker-compose.mounted.yml`, tests in `test_library_availability.py`). **How it worked.** - It was enabled with `VHS_STORAGE_MODE=mounted` and by declaring the **expected mount**: filesystem type (`VHS_MOUNT_FSTYPE`, for example `nfs4`) and source (`VHS_MOUNT_SOURCE`, for example `nas.lan:/volume1/vhs`). - Before every access, VHS read the kernel's mount table (`/proc/self/mountinfo`), found the most specific mount containing `VHS_MEDIA_ROOT` and compared its type and source with the declared ones; if they differed, the status was `mount_mismatch` and nothing was written, even with the marker present. - No automatic initialization: only `init_library`, and only after the mount verification (no option bypassed it). - The library lived in a **subfolder** of the share, and `docker-compose.mounted.yml` mounted it with `create_host_path: false`. **Reasoning that still holds.** - **The marker alone is not enough with a mount.** A local directory under a mount point can contain a marker: created by hand, copied, left behind by a mistake. Proof is needed that the library really is on the expected mount. - **Why not `os.path.ismount`:** inside Docker every bind mount is a mount point, so `ismount` is always true and says nothing about whether NFS is present. - **Why not comparison with the parent directory:** inferring the mount from device differences is indirect and fragile; declaring the expected mount makes it possible to verify its **source and type**. - In a container, `mountinfo` reports the filesystem behind the bind: with NFS mounted on the host, `nfs4` appears with its source; with a local directory, the local filesystem appears (for example `ext4`, or `virtiofs` on Docker Desktop). - **The container's view can remain different from the host's.** The bind mount is taken when the container is created: if the NAS is unmounted or remounted on the host, the container may keep seeing the initial mount or a different view. After an unmount or a remount you must **recreate the containers** (`up -d --force-recreate`, or `down` and `up`, never with `-v`); `restart` is not enough. `bind.propagation: rslave` was an alternative to evaluate. - **NAS mounted but unresponsive.** With a "hard" mount even a `stat` can block indefinitely. A check in a thread with a timeout was discarded: it frees the request, not the thread blocked in the kernel. **Tests carried out.** - Pytest tests of the logic, with realistic `mountinfo` lines: different type or source rejected, most specific mount wins, path escaping, system without `/proc` unsupported. - **Decisive test** in an isolated Compose project, with its own database: NFS absent, `mnt/library` present **on the local disk** with a hand-made `.vhs-library`, mounted mode with `nfs4` and an expected source. The stack started; `storage_status` in the container showed `virtiofs` and `mount_mismatch`; `/health` responded `degraded`; `init_library --allow-empty` was rejected; a real download failed with `error_code=storage`; on the host, `mnt/library` contained **only the pre-existing marker**. With the bind source absent, the containers were not created. **What was never tested.** That the mode worked with a real NFS: a local folder with a marker only demonstrates the rejection. The positive path was covered only by the parser tests. **Why it was removed.** In the MVP it added variables, a mode and procedures to understand, and it left open work items that do not exist with local storage: temporary files written to the local disk when the mount disappears, the test on real NFS, the unresponsive NAS. The protections that are also useful for local storage (§3) were kept. ## 6. Criteria for resuming NFS after the MVP To be resumed when a library on a NAS is actually needed, in a dedicated phase (for example together with the Jellyfin integration, which shares the same library). Before declaring it supported: 1. **Restore the mount verification** from commit `207fc9a` (private archive, §5), reassessing whether an explicit mode is still needed or whether the expected-mount variables are enough. 2. **Temporary files during a transfer:** a download in progress must not be able to write to the local disk when the share disappears, or else those files must be detected and reported. This is the first work item, before any real test. 3. **Test on real NFS**, with an **independent test NFS mount point** (a dedicated export, or the same export mounted a second time on another point, for example `/mnt/vhs-test`) and a separate Compose project with its own database. A subfolder of the library in use cannot be unmounted on its own, and renaming a folder is not equivalent to a mount that disappears. Steps: `storage_status` shows the type and source of the test mount; `init_library`; a real download completes; unmount **only the test mount** and recreate the containers (VHS rejects the library and writes nothing under the mount point); remount and the library becomes available again. The real mount is never touched. 4. **Mount view in the containers:** verify on real NFS the recreation procedure and, possibly, `bind.propagation: rslave`. 5. **Unresponsive NAS:** choose between a check in a separate process that can be terminated, health delegated to an external process, and mount options (`soft`, `timeo`, `retrans`) with their trade-offs on data integrity. 6. Document the configuration and procedure in `installation.md` only after points 2 and 3. ## 7. Main file and new downloads (related decisions) - An `available` video is never downloaded again: the guard is the file's status, not the existence of a completed attempt (409 `already_archived`). - A new download of an already registered video reuses the basename of the registered path: a changed title or channel does not move the file. Promotion without overwriting accepts an identical file and rejects a different one. - If the extension changes, the old path is **always** recorded in `Video.superseded_files`, whether or not the file exists: a file that is absent today can reappear when the folder comes back. `tracked_paths()` includes these paths, so `verify_library` does not report them as unreferenced files. - If the registered file reappears before the download (same checksum), the video becomes available again without a transfer. - A video may be marked `missing` only while the library is available (`can_mark_missing()`). For now `verify_library` only reports; when it starts updating statuses it must follow this rule.