Four SQLite databases:

  • catalog.sqlite — read-only, bundled with the binary; built from upstream DATs/XMLs at compile time (No-Intro, MAME, FBNeo, Flycast, Wikidata, etc.).
  • library.db — rebuildable cache at /var/lib/replay-control/storages/<storage-id>/library.db on the host SD. Centralised + keyed by a stable per-storage id so it stays on ext4/WAL and survives storage swaps. See Design Decision #15.
  • external_metadata.db — host-global at /var/lib/replay-control/external_metadata.db. Holds source-derived data that’s identical across storages (provider metadata/resources, libretro thumbnail manifests, source-version stamps). Read only by enrichment — never at request time.
  • user_data.db — persistent user customizations at <storage>/.replay-control/user_data.db. Stays on the ROM storage so it travels with the ROMs; never auto-deleted.

Schema defined in tools/build-catalog/src/main.rs (catalog), replay-control-core-server/src/library/db/mod.rs (library), replay-control-core-server/src/external_metadata.rs (external metadata), and replay-control-core-server/src/user_data/db.rs (user data).

catalog.sqlite

Read-only, mounted via the catalog pool (replay-control-core-server/src/catalog_pool.rs). Lives next to the binary on disk; auto-update swaps it atomically alongside the binary on each release.

arcade_game

One row per (rom_name, source). Each upstream curates ROMs in its own style — for example, MAME current ships display_name="Galaga88" for rom_name="galaga88" while FBNeo ships "Galaga '88". Storing one row per source preserves each upstream’s data; the runtime merges fields by per-system priority so each system shows its own upstream’s curated values, with field-level fallback to other sources for fields the primary doesn’t fill.

ColumnTypePurpose
rom_nameTEXTMAME-style ROM short name (PK part 1, e.g. "sf2", "galaga88")
sourceTEXTUpstream tag: "fbneo", "mame", "mame_2k3p", "naomi" (PK part 2)
display_nameTEXTHuman-readable name as the source wrote it
yearTEXTRelease year (4-digit string or empty)
manufacturerTEXTHardware manufacturer / publisher
playersINTEGERMax player count (0 = unset)
rotationTEXT"horizontal", "vertical", or "unknown"
statusTEXTDriver status: "working", "imperfect", "preliminary", "unknown"
is_cloneINTEGERWhether this ROM is a clone of another
is_biosINTEGERWhether this is a BIOS entry (filtered from playable lists)
parentTEXTParent ROM short name if this is a clone
categoryTEXTDetail genre, e.g. "Shooter / Gallery"
normalized_genreTEXTCanonicalized genre group, e.g. "Shooter"

PRIMARY KEY: (rom_name, source) — covers WHERE rom_name = ? lookups via leading-prefix scan, so no separate index is needed.

Per-system arcade merge

replay-control-core-server/src/game/arcade_db.rs exposes lookup_arcade_game(system, rom_name) and lookup_arcade_games_batch(system, &[rom_names]). Both return a single merged ArcadeGameInfo per ROM, built from the up-to-four source rows for that ROM.

The priority order per system lives in replay_control_core::systems::arcade_source_priority:

SystemPriority order
arcade_fbneoFBNeo → MAME → MAME 2003+
arcade_mameMAME → MAME 2003+ → FBNeo
arcade_mame_2k3pMAME 2003+ → MAME → FBNeo
arcade_dcNaomi → MAME → MAME 2003+ → FBNeo
arcade_stvMAME → MAME 2003+ → FBNeo
(any other)empty — uses deterministic fallback

After the priority list is exhausted, the merge walks any remaining sources (ArcadeSource::ALL) so that a ROM present only in (e.g.) the Naomi catalog still resolves on arcade_mame.

For each field the first source with a non-default value wins. Booleans (is_clone, is_bios) take the value from the first source that has the row at all, since false is a valid value rather than “missing”.

canonical_game

One row per canonical console game identity. ROM filename variants in rom_entry point at this table.

ColumnTypePurpose
idINTEGERSurrogate primary key
systemTEXTRePlay system folder, e.g. "nintendo_snes"
display_nameTEXTHuman-readable game name
yearINTEGERRelease year when known, 0 when unset
genreTEXTSource genre text
developerTEXTDeveloper name
publisherTEXTPublisher name
playersINTEGERMax player count, 0 when unset
coopINTEGERCo-op support flag, nullable when unknown
ratingTEXTSource rating text
normalized_genreTEXTCanonical genre group
descriptionTEXTCatalog-backed long-form description, currently from community metadata
sourceTEXTSource tag for the canonical row, e.g. "no-intro" or "community"

PRIMARY KEY: id

Index: idx_cg_system ON canonical_game(system) — supports system-scoped catalog scans and stats.

rom_entry

One row per known No-Intro/libretro ROM filename stem. Maps concrete filenames and CRC32 values to a canonical game.

ColumnTypePurpose
idINTEGERSurrogate primary key
systemTEXTRePlay system folder
filename_stemTEXTROM filename without extension
regionTEXTParsed region tag
crc32INTEGERNo-Intro CRC32, 0 when unavailable
canonical_game_idINTEGERFK to canonical_game.id
normalized_titleTEXTNormalized title for fuzzy lookup

PRIMARY KEY: id

Indexes:

IndexColumnsCovers
idx_re_stem(system, filename_stem)Exact filename-stem lookup
idx_re_crc(system, crc32)Hash-based ROM identification
idx_re_norm(system, normalized_title)Normalized-title fallback lookup
idx_re_cgid(canonical_game_id)Joins from canonical games to their ROM entries

rom_alternate

Alternate names for canonical console games. Used to seed library.db.game_alias during enrichment.

ColumnTypePurpose
canonical_game_idINTEGERFK to canonical_game.id
systemTEXTRePlay system folder
alternate_nameTEXTAlternate title/name

PRIMARY KEY: none. Rows are source-derived aliases; duplicates are tolerated by downstream INSERT OR IGNORE / de-duplication paths.

Index: idx_ra_game ON rom_alternate(canonical_game_id, system) — covers alternate lookup for matched canonical games.

Sample usage: catalog matching loads aliases for a matched canonical_game_id and inserts them into library.db.game_alias for search/detail enrichment.

series_entry

Wikidata-derived series/franchise relationships.

ColumnTypePurpose
idINTEGERSurrogate primary key
game_titleTEXTSource game title
series_nameTEXTSeries/franchise name
systemTEXTRePlay system folder
series_orderINTEGERPosition in series, nullable
followsTEXTPrevious game title, if known
followed_byTEXTNext game title, if known
normalized_titleTEXTNormalized title for matching

PRIMARY KEY: id

Index: idx_se_system ON series_entry(system, normalized_title) — supports per-system series lookup for a matched game.

arcade_release_date

Per-source arcade release year attribution. Seeded into library.db.game_release_date for arcade systems.

ColumnTypePurpose
rom_nameTEXTArcade ROM short name
yearTEXTRelease year
sourceTEXTSource tag, default "mame"

PRIMARY KEY: none.

Sample usage: arcade_db::arcade_release_dates() reads all rows ordered by rom_name; the resolver merges them with matched arcade ROMs during scan/enrichment.

console_release_date

Per-region console release dates, sourced from TGDB during catalog build.

ColumnTypePurpose
systemTEXTRePlay system folder (PK part 1)
base_titleTEXTCanonical base title (PK part 2)
regionTEXTRegion key, e.g. "usa", "japan", "europe" (PK part 3)
release_dateTEXTISO 8601 partial/full date
precisionTEXT"day", "month", or "year"
sourceTEXTSource tag, default "tgdb"

PRIMARY KEY: (system, base_title, region)

Sample usage: game_db::console_release_dates() streams rows into library.db.game_release_date; the per-storage resolver mirrors the preferred row into game_library.release_date.

catalog_game_resource

Catalog-bundled resources linked to normalized game titles. Currently used for manual URL indexes from MiSTer Manual Downloader and the Retrokit manuals Archive.org collection, plus strategy guide and video-index links from Shmups Wiki. Only URLs are bundled in catalog.sqlite; PDFs are downloaded later only when a user saves a manual. These rows are copied into library.db.library_game_resource during scan/enrichment so game-detail reads stay on the library DB.

ColumnTypePurpose
systemTEXTRePlay system folder, or "*" for a global title resource that applies to every system (PK part 1)
normalized_titleTEXTNormalized game title (PK part 2)
resource_typeTEXTResource kind, e.g. "manual", "strategy_guide", or "video_index" (PK part 3)
sourceTEXTCatalog source, e.g. "mister_manuals", "retrokit", or "shmups_wiki" (PK part 4)
resource_idTEXTSource-stable resource identifier or URL hash (PK part 5)
urlTEXTDownload URL
titleTEXTHuman-readable resource title
languagesTEXTComma-separated BCP-47-ish language tags, e.g. "en" or "en,es,it"
mime_typeTEXTExpected content type such as "application/pdf"

PRIMARY KEY: (system, normalized_title, resource_type, source, resource_id)

Index: catalog_game_resource_idx_lookup ON catalog_game_resource(system, normalized_title, resource_type) — supports enrichment lookups for all known titles in one system. Global ("*") resources are intentionally small and may be loaded as a whole for the requested resource types.

db_meta

Build metadata for the bundled catalog.

ColumnTypePurpose
keyTEXTMetadata key (PK)
valueTEXTMetadata value

PRIMARY KEY: key

Known keys include mame_version, generated_at, is_stub, and catalog_enrichment_inputs_version (a content hash over bundled resource rows and catalog-backed detail metadata; startup compares it with library_meta.enrichment_inputs_version to decide whether existing libraries need re-enrichment).

library.db

Per-storage rebuildable cache. Lives at /var/lib/replay-control/storages/<id>/library.db on the host SD.

Schema is built by init_tables() (creates the current v6 shape on a fresh DB) and patched by run_migrations() (drops v1 tables on existing DBs from older binaries and applies additive migrations).

Write-isolation rule

Writes to library.db are restricted to scan / rebuild / enrichment / watcher / explicit-user-action paths — never request-time SSR or HTTP read handlers. The system_summaries derived view and LibraryService::load_roms_from_db reader entry point intentionally do not fall through to a filesystem scan; population is the job of BackgroundManager::populate_all_systems, which iterates visible_systems() and calls scan_and_cache_system (strict reconcile) per system. system_summaries is not a cache: static system fields come from the core SYSTEMS catalog, while counts come from game_library_meta.

Rationale: an earlier read-time L3 fallback wrote the result of a silent walker straight back to game_library_meta. On a partially-mounted NFS the walk returned 41 zero-rom rows that no recovery path could undo (mtime was stamped, rom_count > 0 guard skipped). Removing the read-time write closes the vector at its source.

The strict reconcile rule (scan_and_cache_system) closes the matching writer-side vector: a successful filesystem read replaces L2 for that system, but a failed read returns Err and preserves cached state. Rebuild and watcher paths additionally do not pre-clear L2 — strict reconcile is only safe when there are cached rows to fall back on. The SQL-level zero-overwrite guard in save_system_meta is belt-and-suspenders.

The type-level split between LibraryReadPool and LibraryWritePool makes the rule a compile-time invariant for SSR/HTTP handlers (they only see the read pool). The regression suite at replay-control-app/tests/cold_nfs_tests.rs plus the per-system reconcile tests in replay-control-app/src/api/library/mod.rs lock in the runtime invariants.

game_library

Primary game catalog. One row per ROM file. Populated by the scan pipeline, enriched by the enrichment pipeline.

ColumnTypePurpose
systemTEXTSystem folder name (PK part 1)
rom_filenameTEXTROM filename (PK part 2)
rom_pathTEXTFull path to ROM file
display_nameTEXTHuman-readable name (nullable)
base_titleTEXTTags stripped, used for grouping variants
series_keyTEXTAlgorithmic franchise key (base_title minus trailing numbers/roman numerals)
regionTEXTDetected region string
developerTEXTFrom arcade_db at scan time or LaunchBox via enrichment
search_textTEXTPre-computed search string
genreTEXTDetail genre (e.g., “Maze / Shooter”)
genre_groupTEXTNormalized genre for filtering (e.g., “Shooter”)
ratingREALLaunchBox community rating
rating_countINTEGERNumber of ratings
playersINTEGERMax player count
is_cloneINTEGERWhether this is a variant of another ROM
is_m3uINTEGERWhether this is an M3U playlist entry
is_translationINTEGERTranslation patch detected
is_hackINTEGERROM hack detected
is_specialINTEGERExcluded from recommendations (FastROM, 60Hz, unlicensed, etc.)
box_art_urlTEXTResolved box art URL (e.g., /media/snes/boxart/Name.png)
driver_statusTEXTArcade driver status (good/imperfect/preliminary)
size_bytesINTEGERROM file size
crc32INTEGERCRC32 hash (NULL for CD/computer/arcade)
hash_mtimeINTEGERFile mtime when CRC32 was computed (cache key)
hash_size_bytesINTEGERROM file size when CRC32 was computed (cache key)
hash_matched_nameTEXTNo-Intro canonical name if CRC32 matched
scan_tokenINTEGERDiscovery reconcile token for the latest successful per-system scan attempt
identity_stateINTEGERRow-level hash identity state (pending, running, matched/unmatched complete, failed, or not applicable)
release_dateTEXTISO 8601 partial/full date, mirror from game_release_date resolver
release_precisionTEXT"day" / "month" / "year"
release_region_usedTEXTRegion the resolver picked for this row
cooperativeINTEGERCo-op support flag
normalized_titleTEXTScan-time normalized title for enrichment matching
normalized_title_altTEXTAlternate normalized title for enrichment matching

PRIMARY KEY: (system, rom_filename)

Indexes:

IndexColumnsCovers
idx_game_library_genre(system, genre) WHERE genre IS NOT NULL AND genre != ''similar_by_genre, system_genre_groups
idx_game_library_genre_group(system, genre_group) WHERE genre_group != ''Genre group filtering
idx_game_library_series_key(series_key) WHERE series_key != ''series_siblings
idx_game_library_developer_title(developer, base_title) WHERE developer != ''find_developer_matches, games_by_developer, top_developers
idx_game_library_base_title(system, base_title) WHERE base_title != ''regional_variants, translations, hacks, specials, find_best_rom
idx_game_library_identity_pending(system, identity_state) WHERE identity_state IN (2, 3, 6)pending/running/failed identity recovery
idx_game_library_cooperative(system, cooperative) WHERE cooperative = 1coop_only filter, random_coop_games

game_library_meta

Per-system scan metadata. Used by system_summaries to derive UI counts and by aggregate info/coverage endpoints that only need per-system counts. Startup does not use dir_mtime_secs as its correctness boundary; it performs a full visible-system reconciliation so nested-folder changes made while the device was off are not missed.

ColumnTypePurpose
systemTEXTSystem folder name (PK)
dir_mtime_secsINTEGERDirectory mtime at last scan
scanned_atINTEGERUnix timestamp of last scan
rom_countINTEGERNumber of ROMs found
total_size_bytesINTEGERTotal size of all ROMs
discovery_stateINTEGERPer-system discovery state (pending, running, complete, failed)
enrichment_stateINTEGERPer-system enrichment state (pending, running, complete, failed)
thumbnail_stateINTEGERPer-system thumbnail planning/download state

PRIMARY KEY: system

game_library_system_stats

Rebuildable per-system romset facts for metadata/coverage pages. Most columns are a materialized view over game_library, game_detail_metadata, and library_game_resource; thumbnail media columns are refreshed from the downloaded media folders after scan/rebuild/startup verification and thumbnail update maintenance. Missing rows are backfilled when library.db opens so upgraded installs do not need a manual rescan before coverage appears. Request-time metadata pages read library summary, matched artwork coverage, downloaded artwork totals, and per-system coverage from this table and do not keep an additional app-local snapshot cache.

ColumnTypePurpose
systemTEXTSystem folder name (PK)
rom_countINTEGERTotal discovered ROM rows
total_size_bytesINTEGERTotal discovered ROM bytes
clone_countINTEGERROMs classified as clones
hack_countINTEGERROMs classified as hacks
translation_countINTEGERROMs classified as translations
homebrew_countINTEGERReserved for homebrew/aftermarket classification when stored separately
unlicensed_countINTEGERReserved for unlicensed classification when stored separately
special_countINTEGERROMs excluded from recommendation-style surfaces
region_counts_jsonTEXTDisplay-only region distribution
release_year_minINTEGEREarliest known release year
release_year_maxINTEGERLatest known release year
release_date_known_countINTEGERROMs with known release date/year
genre_counts_jsonTEXTDisplay-only genre distribution
genre_group_counts_jsonTEXTDisplay-only normalized genre distribution
developer_known_countINTEGERROMs with developer data
publisher_known_countINTEGERROMs with publisher data from detail metadata
player_count_distribution_jsonTEXTDisplay-only player-count distribution
rating_known_countINTEGERROMs with rating data
description_countINTEGERROMs with long-form description data
boxart_countINTEGERROMs with box art URL coverage
snap_countINTEGERReserved for screenshot coverage
title_screen_countINTEGERReserved for title-screen coverage
thumbnail_total_size_bytesINTEGERTotal size of valid downloaded thumbnail media for this system
thumbnail_file_countINTEGERTotal valid downloaded thumbnail files for this system
thumbnail_boxart_file_countINTEGERValid downloaded box art files for this system
thumbnail_snap_file_countINTEGERValid downloaded screenshot files for this system
thumbnail_title_file_countINTEGERValid downloaded title-screen files for this system
manual_countINTEGERROMs with manual resource suggestions
video_countINTEGERROMs with video resource suggestions
resource_countINTEGERTotal rebuildable resource rows
coop_countINTEGERROMs marked as cooperative
verified_countINTEGERROMs matched by CRC identity
driver_status_jsonTEXTArcade driver-status distribution
refresh_stateINTEGERStats state (unknown, fresh, stale, refreshing, failed)
updated_atINTEGERUnix timestamp of last refresh

PRIMARY KEY: system

game_detail_metadata

Long-form description + publisher per ROM, denormalized so the game-detail server fn stays on the library pool (no cross-pool acquire to external_metadata.db). One row per matched ROM; rebuilt at every enrichment pass.

ColumnTypePurpose
systemTEXTPK part 1
rom_filenameTEXTPK part 2
descriptionTEXTLong-form description (nullable)
publisherTEXTPublisher name (nullable)

PRIMARY KEY: (system, rom_filename)

Foreign key: (system, rom_filename) REFERENCES game_library(system, rom_filename) ON DELETE CASCADE

library_game_resource

Per-ROM resource suggestions copied from provider and catalog sources during enrichment. Game-detail request paths read this table only; they do not query external_metadata.db or catalog.sqlite.

ColumnTypePurpose
systemTEXTSystem folder name (PK part 1, FK to game_library)
rom_filenameTEXTROM filename (PK part 2, FK to game_library)
sourceTEXTSource tag, e.g. "launchbox", "mister_manuals", "retrokit" (PK part 3)
resource_typeTEXTResource kind, e.g. "manual" or "video" (PK part 4)
resource_idTEXTSource-stable resource ID (PK part 5)
urlTEXTExternal URL
titleTEXTDisplay title
languagesTEXTComma-separated language tags
platformTEXTPlatform hint for video resources, e.g. "youtube"
mime_typeTEXTExpected content type for downloadable resources

PRIMARY KEY: (system, rom_filename, source, resource_type, resource_id)

Foreign key: (system, rom_filename) REFERENCES game_library(system, rom_filename) ON DELETE CASCADE

Index: library_game_resource_idx_rom_type ON library_game_resource(system, rom_filename, resource_type) — supports game-detail manual/video suggestions.

game_detail_metadata_stage

Temporary staging table for chunked enrichment. Detail rows are written here before the live game_detail_metadata rows are replaced, so cancelled or failed enrichment keeps the previous live detail data.

ColumnTypePurpose
systemTEXTSystem folder name (PK part 1)
stage_tokenINTEGERPer-run staging token (PK part 2)
rom_filenameTEXTROM filename (PK part 3)
descriptionTEXTLong-form description (nullable)
publisherTEXTPublisher name (nullable)

PRIMARY KEY: (system, stage_token, rom_filename)

library_game_resource_stage

Temporary staging table for chunked enrichment. Resource rows are written here before the live library_game_resource rows are replaced, matching the detail staging lifecycle.

ColumnTypePurpose
systemTEXTSystem folder name (PK part 1)
stage_tokenINTEGERPer-run staging token (PK part 2)
rom_filenameTEXTROM filename (PK part 3)
sourceTEXTSource tag, e.g. "launchbox", "mister_manuals", "retrokit" (PK part 4)
resource_typeTEXTResource kind, e.g. "manual" or "video" (PK part 5)
resource_idTEXTSource-stable resource ID (PK part 6)
urlTEXTExternal URL
titleTEXTDisplay title
languagesTEXTComma-separated language tags
platformTEXTPlatform hint for video resources, e.g. "youtube"
mime_typeTEXTExpected content type for downloadable resources

PRIMARY KEY: (system, stage_token, rom_filename, source, resource_type, resource_id)

library_thumbnail_job

Durable per-storage artwork download queue. Rebuild/rescan queues missing artwork here; downloads run after the library is usable.

ColumnTypePurpose
systemTEXTSystem folder name (PK part 1, FK to game_library)
rom_filenameTEXTROM filename (PK part 2, FK to game_library)
kindTEXTLibretro image kind (Named_Boxarts, Named_Titles, Named_Snaps)
filenameTEXTManifest filename stem
repo_url_nameTEXTURL-safe libretro-thumbnails repository name
branchTEXTRepository branch
is_symlinkINTEGERWhether the manifest entry is a symlink
stateINTEGERQueue state (queued, running, failed)
attemptsINTEGERNumber of failed runtime attempts recorded for the job
priorityINTEGERDownload priority: box art first, then titles, then screenshots
updated_atINTEGERUnix timestamp of last queue update

PRIMARY KEY: (system, rom_filename, kind, filename)

Foreign key: (system, rom_filename) REFERENCES game_library(system, rom_filename) ON DELETE CASCADE

Index: idx_library_thumbnail_job_system_priority_status ON library_thumbnail_job(system, priority, state) — supports priority queue fetches.

library_build_sequence

Small rebuildable counter table used by library build phases.

ColumnTypePurpose
nameTEXTCounter name; currently scan_token
next_valueINTEGERNext monotonic value to allocate

PRIMARY KEY: name

game_release_date

Multi-region, full-precision release dates. Seeded from the bundled catalog (console_release_date / arcade_release_date) at scan time; resolver picks the user’s preferred region and mirrors into game_library.release_date / release_precision / release_region_used.

ColumnTypePurpose
systemTEXTPK part 1
base_titleTEXTPK part 2
regionTEXTPK part 3 ("usa", "japan", "europe", "world", …)
release_dateTEXTISO 8601 partial/full
precisionTEXT"day" / "month" / "year"
sourceTEXTData origin tag

PRIMARY KEY: (system, base_title, region)

Indexes:

IndexColumnsCovers
idx_release_date_lookup(system, base_title)Resolver lookups
idx_release_date_chrono(release_date)Chronological scans

game_alias

Alternative names for games. Populated by enrichment from external_metadata.db.provider_alternate UNION the catalog rom_alternate.

ColumnTypePurpose
systemTEXTPK part 1
base_titleTEXTPK part 2
alias_nameTEXTAlternative name (PK part 3)
alias_regionTEXTRegion for this alias
sourceTEXTData source tag

PRIMARY KEY: (system, base_title, alias_name)

Indexes:

IndexColumnsCovers
idx_game_alias_name(alias_name COLLATE NOCASE)search_aliases (LIKE queries)
idx_game_alias_system_alias(system, alias_name)alias_variants, alias_base_titles

game_series

Franchise/series relationships (from Wikidata). Links games that belong to the same series.

ColumnTypePurpose
systemTEXTPK part 1
base_titleTEXTPK part 2
series_nameTEXTSeries identifier (PK part 3)
series_orderINTEGERPosition in series (nullable)
sourceTEXTData source
follows_base_titleTEXTPrevious game in chain
followed_by_base_titleTEXTNext game in chain

PRIMARY KEY: (system, base_title, series_name)

Indexes:

IndexColumnsCovers
idx_game_series_name(series_name COLLATE NOCASE)Series name lookups
idx_game_series_system(system, series_name)System-scoped series queries
idx_game_series_order(series_name, series_order) WHERE series_order IS NOT NULLNeighbor lookups, max order queries

library_meta

Per-storage key/value metadata that does not deserve a dedicated table.

ColumnTypePurpose
keyTEXTMetadata key (PK)
valueTEXTMetadata value

PRIMARY KEY: key

Known keys include:

KeyPurpose
title_norm_versionreplay_control_core::title_utils::TITLE_NORM_VERSION used when game_library.normalized_title / normalized_title_alt were last reconciled
enrichment_inputs_versionBundled catalog enrichment-input hash that was last flushed into library_game_resource and game_detail_metadata
discovery_fingerprint:<system>Last clean recursive startup-scan fingerprint for one system; startup uses it to skip unchanged systems after a full file walk

schema_version

Records the applied schema version. Used by the downgrade guard: LibraryDb::open refuses to open a DB whose stamped version is greater than the binary’s SCHEMA_VERSION, since silently treating new-shape rows as old-shape would corrupt them on subsequent writes.

ColumnTypePurpose
versionINTEGERApplied version (PK)
applied_atINTEGERUnix timestamp

PRIMARY KEY: version

external_metadata.db

Host-global. Lives at /var/lib/replay-control/external_metadata.db. Holds source-derived metadata that doesn’t depend on which storage is mounted (provider metadata/resources + libretro thumbnail manifests + source-version stamps). Schema in replay-control-core-server/src/external_metadata.rs.

Read mostly by enrichment, metadata maintenance, thumbnail planning, and box-art variant lookups. Normal game-detail/list request paths read library.db. Exposed on AppState::external_metadata_pool with a 2-reader / 1-writer pool.

provider_game

Per-system provider entries, keyed by normalized title and provider (not ROM filename — the same DB serves every storage, so the row exists once regardless of how many storages have a matching ROM). LaunchBox currently writes provider "launchbox".

ColumnTypePurpose
providerTEXTProvider tag, currently "launchbox" (PK part 3)
systemTEXTRePlay system folder (PK part 1)
normalized_titleTEXTNormalized title (PK part 2)
descriptionTEXTLong-form description
genreTEXTProvider genre
developerTEXTProvider developer
publisherTEXTProvider publisher
release_dateTEXTISO 8601 partial/full
release_precisionTEXT"day" / "month" / "year"
ratingREALCommunity rating
rating_countINTEGERNumber of ratings
cooperativeINTEGERCo-op flag
playersINTEGERMax players

PRIMARY KEY: (system, normalized_title, provider)

provider_alternate

Per-system alternate names from provider data. LaunchBox populates this from <GameAlternateName> entries.

ColumnTypePurpose
providerTEXTProvider tag (PK part 4)
systemTEXTRePlay system folder (PK part 1)
normalized_titleTEXTNormalized primary title (PK part 2, matches provider_game.normalized_title)
alternate_nameTEXTPK part 3
normalized_alternateTEXTNormalized alternate title used by enrichment matching

PRIMARY KEY: (system, normalized_title, alternate_name, provider)

provider_resource

Provider-supplied links attached to a normalized game title. LaunchBox currently writes video URLs as "video" resources.

ColumnTypePurpose
providerTEXTProvider tag (PK part 4)
systemTEXTRePlay system folder (PK part 1)
normalized_titleTEXTNormalized game title (PK part 2)
resource_typeTEXTResource kind, e.g. "video" (PK part 3)
resource_idTEXTSource-stable ID or URL hash (PK part 5)
urlTEXTExternal URL
titleTEXTDisplay title
languagesTEXTComma-separated language tags
platformTEXTPlatform hint such as "youtube"
mime_typeTEXTExpected content type for downloadable resources

PRIMARY KEY: (system, normalized_title, resource_type, provider, resource_id)

thumbnail_manifest

Index of available thumbnails from libretro-thumbnails repos. Populated by the thumbnail update pipeline (GitHub API) or rebuilt from disk by phase_auto_rebuild_thumbnail_index.

ColumnTypePurpose
repo_nameTEXTSource repo identifier (PK part 1)
kindTEXTImage kind: "Named_Boxarts", "Named_Snaps", etc. (PK part 2)
filenameTEXTImage filename stem (PK part 3)
symlink_targetTEXTSymlink target if the repo entry was a symlink

PRIMARY KEY: (repo_name, kind, filename)

data_source

Tracks imported data sources and their versions (libretro repo commit shas, future external sources). One row per source.

ColumnTypePurpose
source_nameTEXTUnique identifier (PK)
source_typeTEXTCategory (e.g., "libretro-thumbnails")
version_hashTEXTContent hash for change detection (e.g. git commit sha)
imported_atINTEGERUnix timestamp
entry_countINTEGERNumber of entries imported
branchTEXTGit branch name (libretro repos use master or main)

PRIMARY KEY: source_name

Index: idx_data_source_type ON data_source(source_type) — supports source-family stats and cleanup.

external_meta

Key-value blob for DB-level metadata.

ColumnTypePurpose
keyTEXTMetadata key (PK)
valueTEXTMetadata value

PRIMARY KEY: key

Known keys:

KeyPurpose
launchbox_xml_crc32CRC32 of the last-parsed LaunchBox XML — content-derived freshness check at boot
launchbox_upstream_etagETag from the last successful upstream LaunchBox Metadata.zip download
thumbnail_manifest_fetched_atUnix timestamp for the last successful libretro manifest fetch; short TTL for repeated update clicks
title_norm_versionTitle normalizer version used for provider_alternate.normalized_alternate

user_data.db

Defined in replay-control-core-server/src/user_data/db.rs. Separate from library.db so user choices survive metadata clears and rebuilds.

box_art_overrides

User-selected box art for specific ROMs.

ColumnTypePurpose
systemTEXTPK part 1
rom_filenameTEXTPK part 2
override_pathTEXTPath to selected image
set_atINTEGERUnix timestamp

PRIMARY KEY: (system, rom_filename)

game_videos

User-saved video links for games.

ColumnTypePurpose
systemTEXTPK part 1
base_titleTEXTFor cross-ROM video sharing
rom_filenameTEXTPK part 2
video_idTEXTUnique video identifier (PK part 3)
urlTEXTCanonical video URL
platformTEXTe.g., "youtube"
platform_video_idTEXTPlatform-specific ID
titleTEXTHuman-readable title
added_atINTEGERUnix timestamp
from_recommendationINTEGERWhether pinned from search
tagTEXTCategory: "trailer", "gameplay", "1cc", or NULL

PRIMARY KEY: (system, rom_filename, video_id)

Index: idx_game_videos_base_title ON (system, base_title) — enables sharing videos across ROMs of the same game.

game_manual_resource

User-saved manual resources for a game. Downloaded manuals are stored under <storage>/.replay-control/manuals/ and tracked here; uploaded manuals use the same table. Legacy manuals found in <storage>/manuals remain read-only and are not inserted.

ColumnTypePurpose
systemTEXTSystem folder name (PK part 1)
base_titleTEXTBase title for cross-ROM manual sharing
rom_filenameTEXTROM filename (PK part 2)
manual_idTEXTStable saved-manual ID (PK part 3)
resource_keyTEXTOriginal source/resource key for duplicate suppression
titleTEXTDisplay title
originTEXT"downloaded" or "upload"
providerTEXTOptional source/provider attribution for downloaded manuals, e.g. "retrokit" or "mister_manuals"
urlTEXTSource URL for downloaded manuals
storage_pathTEXTRelative path under .replay-control/manuals
original_filenameTEXTOriginal upload/download filename when known
languagesTEXTComma-separated language tags
mime_typeTEXTValidated content type (application/pdf or text/plain)
size_bytesINTEGERSaved file size
added_atINTEGERUnix timestamp

PRIMARY KEY: (system, rom_filename, manual_id)

Indexes:

IndexColumnsCovers
game_manual_resource_idx_base_title(system, base_title)Sharing saved manuals across ROM variants of the same game
game_manual_resource_idx_resource_key(system, rom_filename, resource_key)Suppressing duplicate suggestions after a manual is saved

Schema Migrations

library.db has a versioned migration handler in LibraryDb::run_migrations. The current SCHEMA_VERSION is 9. Recent library-build pipeline shape changes also use open-time schema validation: if rebuildable library.db tables are missing required columns or indexes, the table is recreated and startup discovery repopulates it.

History:

  • v1: original shape (game_library, game_metadata, thumbnail_index, data_sources, game_release_date, game_alias, game_series).
  • v2: external_metadata.db redesign — drops game_metadata, thumbnail_index, and data_sources. Their content moves to external_metadata.db (LaunchBox text + libretro manifests + source version stamps).
  • v3: adds game_description (description + publisher denormalized from external metadata so the game-detail page stays on the library pool).
  • v4: adds game_library.normalized_title and normalized_title_alt, populated at scan time for faster enrichment matching and reconciled via library_meta.title_norm_version.
  • v5: adds game_library.hash_size_bytes, allowing CRC32 cache validation by mtime + size without a full post-upgrade rehash storm.
  • v6: renames game_description to game_detail_metadata and adds library_game_resource for manual/video suggestions copied from provider/catalog sources during enrichment.
  • v7: adds game_library.identity_state for resumable hash matching.
  • v8: adds per-system discovery, enrichment, and thumbnail state to game_library_meta.
  • v9: adds durable thumbnail download jobs. Newer rebuildable columns/tables such as scan_token, thumbnail priority, detail/resource staging, and game_library_system_stats, plus rebuildable library_meta stamps such as per-system discovery fingerprints, are validated or regenerated by the library pipeline instead of a formal migration.

run_migrations reads the stored version, applies each if current < N step in order, then stamps SCHEMA_VERSION. Each step’s destructive SQL (DROP TABLE) is logged at info above the SQL. For rebuildable library-cache tables, column drift is also treated as cache drift: the app recreates the table rather than carrying long migration code for data that can be rebuilt from ROM storage and metadata sources.

A downgrade guard at the top of run_migrations refuses to open a DB stamped with a version newer than the binary — silently treating new-shape rows as old-shape would corrupt them on subsequent writes.

external_metadata.db uses a different pattern: column-set drift triggers drop-and-recreate via crate::sqlite::table_columns_diverge. Its content is reproducible (LaunchBox XML, libretro repos, on-disk image scan) so a destructive rebuild costs only the next refresh cycle. user_data.db is treated as user-owned data and is not auto-dropped for schema drift.

Corruption Handling

Two layers of detection run at open time:

  1. Magic-header pre-flightsqlite::has_invalid_sqlite_header reads the first 16 bytes of the file. If they’re non-empty but don’t match the SQLite magic string, the file has been clobbered by a torn write or partial overwrite. SQLite itself would refuse to open the file with a generic SQLITE_NOTADB, which previously crash-looped the systemd service before any recovery code could run. The pre-flight short-circuits to recovery instead.
  2. Table probeprobe_tables() issues a row-scan against every known table. Catches logical/index corruption that the file-level magic check can’t see.

Both layers feed the same recovery model. For library.db and external_metadata.db, corruption triggers automatic delete-and-recreate (both are rebuildable). For user_data.db, corruption is flagged via DbPool::new_corrupt but the DB is not destroyed — the user gets a banner with a one-click Reset action (user data is irreplaceable, so the choice belongs to the user). The banner is delivered via the /sse/config push channel, so it appears immediately on every connected tab without polling.

Both SQLITE_CORRUPT (11) and SQLITE_NOTADB (26) route through the same check_for_corruption path, so runtime queries that fail either way trigger the same recovery flow.