How files become rows: the scan and its plan, track identity, tags, albums, watched folders, network shares, the Folders tree, removal, covers, search and playlists.
The library is an index of the disk, not the data: a scan compares the watched folders (library.roots) with the tracks table and reconciles them. The decision is a pure function, rymembra_domain::scan_plan::plan_scan; rymembra_app::library::Scan::run_su executes it.
The plan
plan_scan(ScanInput { roots, found, known }, rules) returns a ScanPlan: to_insert, to_update, to_remove (Disappeared or DuplicateRow), skipped (InTrash, NonAudio, TooSmall, Duplicate), and the counts unchanged / untouched. Rules:
paths are compared through path_key (unified slashes, trailing separators trimmed, case-folded on Windows); is_under matches on a segment boundary, so C:\Musica is not inside C:\Music;
a file is skipped if it sits in a .trash folder, has an extension outside the 12 supported (mp3, flac, m4a, aac, ogg, oga, wav, aiff, aif, opus, wma, ape) or is smaller than MIN_TRACK_BYTES = 32 KiB — checked in that order; a track of a CD image (below) counts as audio;
unchanged means same mtime (truncated ms) and same size and same spelling under the root — the size catches taggers that preserve the mtime, the spelling catches case-only renames;
a known row is removed only if it lies under a root this pass actually walked; rows elsewhere are untouched;
duplicate rows with the same path_key keep the lowest id (the one history and playlists point to).
Moves keep their history
match_moved_tracks pairs rows about to disappear with newly found files by content_key; a matched row is updated in place (new path), keeping its id and therefore plays, rating and playlist slots. Only keys whose second segment is non-empty count as identities. Matching happens batch by batch against the remaining disappearances, so each file is read only once, and never across the line between a whole file and a track of a CD image: a disc that becomes its tracks is not a move.
CD images and cue sheets
A .cue that describes one audio file (rymembra_domain::cue::leggi_foglio: one FILE, at least two TRACK, increasing INDEX 01; UTF-8, UTF-16 or windows-1252) turns that file — the image — into its tracks. trova_immagine looks for the image by the name in FILE, then by that name with another supported extension (a rip saved as .wav and compressed later), then by the sheet's own name; never «the only large file». espandi_immagini replaces the image among the found files with one virtual entry per track, path …\Album.cue|NN (| cannot appear in a Windows file name), size = the image's, mtime = the later of sheet and image — exactly what the row stores, so an unchanged image is never re-read. Rows of an image carry immagine, inizio_cd and fine_cd (CD frames, 1/75 s); the image is opened once per sheet and yields every track. Parsed sheets are cached in fogli_cue; a sheet that cannot be read uses the cache, or makes its folder an unverified branch.
Phases and the lock
#
Phase
Library lock
1
Walk the roots (walk_a_rate, four threads): files and a heartbeat per folder arrive as a stream, each root probed first (SONDA_RADICE = 8 s)
no
1b
Read the .cue sheets that are new or changed, expand the images
one short hold for the cache
2
Read known rows, run plan_scan, drop files already refused and unchanged
one hold
3
Remove duplicate rows, set disappearances aside
one hold
4
Read tags and covers of files to insert/update, up to four at a time
no
5
Write each batch of LOTTO = 500 files in one Immediate transaction
one hold per batch
6
Remaining removals
one hold
7
Rebuild aggregates for touched albums/artists only
one hold
The core receives a Deposito (one connection loan per phase) instead of a Connection; between loans the window, the player and the exit path can use the database. Because rows may change between loans, removals match on id and path, and only one scan may run at a time (Stato::turno_di_scansione; a second gets library.scanBusy).
Deadlines and network roots
Walking allows SILENZIO_CAMMINATA = 15 s of silence between two deliveries — a file or a folder heartbeat — (not a cap on the whole walk); reading one file is capped at LETTURA_BRANO = 25 s on a reusable worker thread, with up to four reads in flight whose results are handled in order. A root reads one file at a time until a read succeeds, and again after a timeout or a network error, so a failing share is not hit four times harder. A fs.networkUnavailable triggers a root probe: an unreachable root goes to radici_saltate and none of its rows is judged. Three timeouts in a row on the same root (SCADENZE_DI_FILA) abandon it even if the probe answers. Unreadable sub-folders go to rami_persi, and rows beneath them are counted in non_verificati instead of being removed.
The mass-removal guard
If a pass would remove more than SOGLIA_RIMOZIONI = 50 rows and more than one fifth of the library (FRAZIONE_SOSPETTA = 5), nothing is removed. A manual scan (prudente = false) returns the held rows in rimozioni_in_attesa; the window shows a summary per root (ConfermaRimozioni.tsx) and rimozioni_conferma(gettone) applies exactly those rows without walking again. Automatic scans only report the count.
Cancel and progress
annulla_scansione sets an AtomicBool next to the library mutex. During the walk the plan is discarded (nothing written); during reading the scan stops at the end of the current file, closing the batch with what it has. scansione:avanzamento reports a phase — cerca (walking: folders and files seen), legge (done, total of files to read) or attende (a manual scan waiting for an automatic one, without a time limit; Cancel ends the wait); scansione:finita is sent when the scan's guard drops. scansione_avanzamento lets a reloaded window adopt a running scan. Every scan writes one [scansione] line to the log with its origin, counts and the time of each phase (ScanReport::tempi).
Files the reader refuses
read_tags sets the type from the extension before guessing from the content, retries with the extension's type when the guess fails, and as a last resort keeps the sound properties plus a hand-read ID3v1 tag — a single malformed ID3v2 frame makes lofty refuse the whole file. What is still refused for a deterministic reason (metadata.tagReadFailed, playback.formatUnsupported: WMA, Monkey's Audio before 3.99) is remembered in file_illeggibili by path, size, mtime and VERSIONE_LETTORE, and is not reopened until one of them changes. Network errors, timeouts and reader crashes are never remembered.
Re-read all tags
rileggi_tutti_i_tag sets every row's date_modified to the sentinel -1, which no file has, empties file_illeggibili and fogli_cue, and then the ordinary scan re-reads everything. The same sentinel is used for rows whose content_key is still NULL after migration 019, and for a track of an image whose enrichment is forgotten: the scan rebuilds it from the sheet.
First run
primo::esamina counts music files (up to TETTO = 2,000) in the OS-declared music folders so the first-run screen can propose them already ticked.
A track has four names, each answering a different question.
Name
Computed from
Used for
tracks.id
SQLite rowid
Local foreign keys: history, playlists, queue
path
The file system
Opening the file; compared through path_key
TrackKey
Effective artist, title, album (after fallbacks and your corrections)
Identity across devices: sync documents, backups, profiles, lyrics, scrobbles
ContentKey
Raw tags as read, before fallbacks; never touched by corrections
Recognising a moved or returning file
Normalization
normalize_key: unify curly quotes, primes and dashes, … → ., then fold_text (NFD, strip U+0300–U+036F only, lowercase), strip ASCII punctuation, collapse whitespace. "(Don't Fear) The Reaper" becomes dont fear the reaper. The folding rules are transcribed literally from the previous JavaScript implementation and checked against golden vectors (tests/golden/text-keys.json): folding one character differently would make every track look missing on another device.
TrackKey = artist|title|album, each segment normalized. Duration is deliberately excluded: the same recording from two sources differs by seconds. upgrade_legacy_track_key converts four-segment keys from the first sync format.
ContentKey = the same three segments when artist and title are non-empty after normalization; otherwise duration_ms|file_size, so renaming an untagged file does not change its identity. The two shapes cannot collide (three segments versus two). They are distinct Rust types precisely so that one can never be sent where the other is expected.
PlaylistKey is the normalized playlist name: two playlists with the same normalized name are the same playlist (library.playlistExists), and renaming creates a new identity.
Sort names
sort_name drops one leading article from a closed list — the, a, an, il, lo, la, i, gli, le, l', les, los, las, der, die, das — so «The Cure» sorts under C. «El» is excluded on purpose (El-P, El Guincho).
metadata::read_tags uses lofty (one library for all formats) with read_properties(true), so VBR durations are computed rather than estimated from the header. It reads the primary tag (falling back to the first: an MP3 with both ID3v2 and ID3v1 must not get 30-character titles), NFC-normalizes and trims every string, and extracts: title, artist, album, album artist, year (recording date, falling back to Year), track and disc numbers, genre, comment, unsynced lyrics, BPM, initial key, ReplayGain track/album gain, MusicBrainz recording / release-group / release ids, duration, sample rate, channels, codec and the embedded cover.
Bitrate: audio_bitrate is trusted only from BITRATE_MINIMO_KBPS = 32 up; below it falls back to overall_bitrate (file size over duration). MP4 files often declare 0–15 kbps in esds, and the file-format line would otherwise say «4 kbps». Migration 027 forced a re-read of rows stored with a bitrate under 32.
Fallbacks
read_track turns tags into a TrackRow: a missing title becomes the file stem, a missing artist «Artista sconosciuto», a missing album «Album sconosciuto» (rymembra_domain::album::UNKNOWN_*). The ContentKey is computed before these fallbacks, the TrackKey after. A cover that cannot be stored does not fail the track: it lands in ScanReport::cover_failures.
Text repair and hints (not wired into the scan)
rymembra_domain::codifica repairs mojibake (Björk → Björk) behind a structural gate and a margin over the original; leggi_byte decodes raw bytes trying windows-1252/1251, Shift_JIS and GBK — used today for .lrc sidecars. indizi extracts hints from Artist/Year - Album/NN - Title paths and ricostruzione combines tags, repairs and hints into per-field provenance. The review list in incerti reads meta_salute, but read_track does not call this reconstruction, so the scan never fills `meta_salute` and the list is empty until that side is connected; corrections made by hand work regardless.
Album identity is not the text (album, album_artist) — that splits one release into several cards at every inconsistency. album_group_key(album, path) = normalized title with edition suffixes stripped + the normalized containing folder, the way Jellyfin, Plex and Navidrome treat local libraries.
strip_edition_suffix repeatedly removes trailing (…)/[…] groups that contain one of 21 edition keywords (deluxe, remaster, remastered, bonus, expanded, anniversary, edition, version, reissue, mono, stereo, explicit, …), matched as whole words;
album_folder collapses a disc sub-folder (CD1, Disc 2, Disco 1) into its parent, so multi-disc sets stay one album.
Merging by identifiers
build_album_groups runs a union-find over base keys: groups sharing a MusicBrainz release group, a MusicBrainz release or a Spotify album id are merged. Identifiers merge, never split, so partial tagging is harmless. The canonical key is mbrg:<id>, mbr:<id> or sp:<id> when an identifier is shared, otherwise the smallest base key; remap rewrites tracks.album_key accordingly.
Per album: title and artist are the most frequent value (ties: shortest, then alphabetical; the artist is the effective artist album_artist or artist, which drops guest credits); genre is the dominant one; year the maximum; the cover is chosen by source (tag > provider/Spotify > Cover Art Archive), then by how many tracks use it, then pixel area.
Incremental rebuild
albums is fully derived; artists rows are upserted and pruned, never truncated, so biography and image survive. Writers record Toccati — album keys and effective artists before and after each change — and ricostruisci_aggregati rebuilds only the closure of those keys (including groups that share an identifier). It falls back to a full rebuild above TETTO_PER_CHIAVI = 2,000 keys, when libreria_stato.aggregati differs from VERSIONE_AGGREGATI = 1, or on request. Migration 030 added a path COLLATE NOCASE index and an expression index on the effective artist for these queries.
sorveglia.rs keeps the library current without pressing «Scan». The mode is library.watch: spenta (off), locali (local disks only) or tutte (default since 3.0.2: also network shares).
Mode \ where the root is
Local
Network
Unknown (not answering)
spenta
off
off
off
locali
OS notifications
off
retry every 60 s
tutte
OS notifications
OS notifications + realignment
OS notifications + realignment
Notifications: one notify watcher (ReadDirectoryChangesW) per root, on SMB shares too — a share sends notifications for changes made through SMB. Events are coalesced by Accorpatore: scan when they have been quiet for ACCORPAMENTO = 2 s, and no later than ATTESA_MASSIMA = 60 s after the first. A new folder is scanned by itself; any other change scans the folder that contains it (cartelle_da_riguardare, reduced to the outermost with piu_esterne) — copying an album scans that album. A plain «modified» on a folder is skipped: its children report their own change.
Realignment (tutte, network roots): a prudent scan of the whole root, which walks the folders and reads only the files that changed. The Pianificatore runs it at startup (after risveglia), after the computer wakes from sleep (measured on the wall clock across the thread's idle wait only), when a root that stopped answering answers again, and every RIALLINEAMENTO = 30 min, re-arming the watcher first — notify drops a watcher silently on a network error, so a deaf watcher lasts at most one period. A root whose watcher cannot be armed gets the realignment alone, every SONDAGGIO = 5 min. Local roots are realigned too, with their watcher re-armed first, after sleep and every RIALLINEAMENTO_LOCALE = 1 h: notify 8.2 on Windows never reports an error or a «rescan», so a local watcher that overflowed would otherwise stay deaf until a restart.
These scans are prudent: nobody watches them, so the mass-removal guard only reports, a folder subtree is the whole scope of the pass, a root that does not answer judges nothing, and a watched root (Scan::radici_della_libreria, never a sub-folder of one) that answers with no audio while the library knows at least 50 of its tracks counts as not answering — it is reported in radici_vuote and left to its normal rhythm. Folders that could not be scanned — the turn busy, the root silent, the scan failed — go back in the queue, each with its own deadline: seconds for a busy turn, minutes for a silent root, never a tight loop. Unreadable sub-folders (rami_persi) are retried after RAMO_PERSO = 60 s, doubling, RAMO_PERSO_TENTATIVI = 5 times. Every CONTROLLO = 60 s the root list and mode are re-read, so a profile import or a sync that changes the roots is picked up. Results reach the window as cartelle:cambiate, and each scan writes a [scansione] line to the log with its phases.
At startup rymembra-risveglio touches mapped and UNC roots once (30 s deadline), because Windows reconnects mapped drives lazily and an untouched Z: answers ERROR_NOT_CONNECTED for a while.
On SMB a failing call does not fail: it waits up to ~40 s. Every disk operation that may touch a share therefore has a deadline, and every «is it there?» has three answers.
Deadlines without killing threads
rymembra_app::scadenza runs blocking work on a thread and stops waiting after a deadline; the thread is abandoned, not killed, so the work must be Send + 'static. con_scadenza = one job, one thread; Operaio = one reusable thread for many jobs (scan reads), replaced when one hangs; a_rate = a producer streaming results with a silence limit (the walk).
Local or network
files::percorso_di_rete recognises a path by its shape (UNC). A mapped letter lies about its shape, so volumi asks Windows: std::fs::canonicalize of the containing folder returns \\?\UNC\server\share\… for a mapped drive. Calls run with SCADENZA_RISOLUZIONE = 5 s, up to PARALLELISMO = 8 at a time. Collocazione is Locale, DiRete or Ignota; deletion treats Ignota as network (no Recycle Bin promise). Esistenza is Presente, Assente or Ignota.
Failures that are the network
errors::rete::e_di_rete maps raw Windows errors (64 ERROR_NETNAME_DELETED, 2250 ERROR_NOT_CONNECTED, 1201, 53, 121, …) to fs.networkUnavailable, which is retryable. A track that cannot be opened because the share is down stops playback with «Retry» instead of skipping — see Playing music. Opening a track that may be on the network uses APERTURA_FLUSSO = 15 s instead of 5 s.
Same place, two spellings
Z:\ and \\nas\musica can be the same folder. When a root is added, radici::ordina_cartelle detects that it is a twin of an existing root and proposes a relocation (ConfermaTrasloco.tsx → cartelle_trasloca) that rewrites tracks.path instead of adding a second root, which would re-import every track with an empty history.
The data folder on a share
If the data folder itself is on a share, SQLite's WAL cannot work and the journal falls back to TRUNCATE — see Data. The database quick check is skipped in that mode.
Folders shows the library as a tree in the foobar2000 style. It is built from tracks.path, never from the disk: expanding a node on a dead share would otherwise cost 40 s, and the only folders worth showing are those with indexed tracks.
A LIKE 'C:\Musica%' cannot use the BINARY index on path (case-insensitive LIKE), and GLOB would need the exact bytes of the prefix, which vary in case and slashes. So the tree is an in-memory trie built with one read of tracks (35 ms measured on 18,534 tracks), keyed by path_key. Above SOGLIA_IDENTIFICATIVI = 200,000 rows nodes keep counts only; the filter returns at most LIMITE_FILTRO = 2,000 matches; reads go in chunks of 500. Siblings sort in natural order (CD 2 before CD 10).
The desktop keeps the trie in IndiceCartelle on a secondary read-only connection, built lazily on first use and dropped after VITA = 5 min without questions (thread cartelle-sfratto, checking every 30 s). Writers call cartelle::cambiate, which invalidates it and emits cartelle:cambiate. Commands: cartelle_figlie, cartelle_brani, cartelle_file, cartelle_filtra, cartella_mostra, and cartelle_radici_vive — the only one that touches the disk, probing which roots answer.
In the window, albero.ts flattens the open nodes into rows (at most MASSIMO_APERTI = 200 remembered open nodes, persisted in ui.folders.open); Cartelle.tsx renders a windowed role="tree" with a track pane, multi-selection and drag to the queue.
Two commands for two questions. brani_togli removes rows only — the file stays, and if it is still under a watched root the next scan brings it back (the result says how many will return). brani_elimina sends files to the Recycle Bin (trash crate) and removes the rows of the files that actually got there.
On a network location there is no Recycle Bin, and Windows' IFileOperation with «undo» silently deletes for good while reporting success. So brani_su_rete first classifies each file with volumi; the confirmation dialog says which files will be deleted permanently, and only with that confirmation are they removed with remove_file. Without it the core refuses with fs.noRecycleBin before touching anything. Order of operations: clear the tracks from the queue and stop the engine (an open file cannot be trashed on Windows), trash, then write the database.
Statistics survive for 90 days
A row deleted by the user or by a scan fires two BEFORE DELETE triggers keyed by content_key: correzioni_restano_orfane moves your corrections into correzioni_orfane, and statistiche_restano_orfane moves play count, rating, heart, play history (ascolti_orfani) and playlist slots (playlist_orfane) into brani_orfani. When a file with the same content_key is inserted again, correzioni_tornano and statistiche_tornano put everything back. Entries older than 90 days are purged inside the triggers. Catalog rows (path IS NULL) are excluded.
CoverStore is content-addressed: the name is blake3 of the original image bytes, so a cover shared by twelve tracks is stored once, retagging does not orphan it, and a rescan of an already-stored cover costs one stat. Originals are not kept (embedded PNGs are 500–600 KB each); the store writes re-encoded JPEGs under copertine/<hh>/<hash>:
Variant
File
Max side
JPEG quality
Made
full
<hash>.jpg
MAX_LATO = 640 px
85
at store time
medium
<hash>.m.jpg
MAX_LATO_MEDIA = 320 px
85
lazily, on first request
thumbnail
<hash>.t.jpg
MAX_LATO_MINIATURA = 160 px
78
at store time
Decoding is bounded against decompression bombs: LATO_MASSIMO_DECODIFICA = 8,192 px per side and MEMORIA_MASSIMA_DECODIFICA = 192 MiB, checked from the header before allocating. CoverSource (tag, provider, spotify, caa, manual) is stored in cover_art.source and drives replacement decisions.
Clean-up is reference counting: riferimenti discovers from the schema every column that names a cover, dimentica_orfane removes unreferenced rows, and the maintenance thread deletes the files — see Maintenance.
Serving
The window never receives image bytes over IPC. The custom scheme rymembra-cover (seen by WebView2 as http://rymembra-cover.localhost/<hash>[.m|.t]) is served asynchronously by copertine::servi; it accepts only a hash — anything with a separator is refused — so it cannot read arbitrary files. copertine.ts builds srcset with the three sizes and lets the browser choose.
Dominant hue
tinta::dominante builds a 36-bin hue histogram (10° per bin), excluding near-black (< 0.15), near-white (> 0.95) and greys (saturation < 0.20), weights by saturation (capped at 0.85), requires a 5 % share and returns the circular mean of the winning bin — or None for black-and-white covers. It feeds the cover-driven accent (Accent).
Search uses the FTS5 table tracks_fts (title, artist, album, album artist, genre; tokenizer unicode61 remove_diacritics 2), kept current by triggers. fts_query turns each typed word into a quoted prefix term — "bjor"* — so FTS operators (AND, NEAR, -, parentheses) in names like «AC/DC» are literal, and results appear while typing. Results are ordered by bm25. search is paged (offset, limit) and search_count returns the total, which the sparse list in the window needs before fetching rows (Lists).
FTS5 availability is probed at open; without it search is unavailable and Settings says so.
A playlist's identity is its PlaylistKey (unique), names are at most NOME_MASSIMO = 200 characters. Members are playlist_tracks(playlist_id, track_id, position) with primary key (playlist_id, position), so removing or reordering rewrites positions; adding appends. Deleting writes a playlist tombstone for sync; the desktop keeps deleted playlists in memory for DURATA_ANNULLO = 60 s (at most 8) so playlist_ripristina can undo.
Smart playlists
A smart playlist is its query: rules are stored as JSON in playlists.rules and never materialized, so «played in the last 30 days» is right after midnight without anyone writing. The vocabulary is closed enums in rymembra_domain::regole:
Part
Values
Campo (field)
title, artist, album, genre, year, rating, liked, play count, added, duration, last played
Operatore
contains, not contains, equals, differs, starts with, ends with, greater, less, in the last N days, not in the last N days, empty, not empty
Combinazione
all / any
Ordinamento
shelf order, most played, least played, recent, random
smart.rs compiles them to SQL with one hard rule: no value ever reaches the SQL text — column names and comparison shapes come from match arms on the enums, and every user value is bound as ?. Text comparisons use the custom function rymembra_piega (deterministic, applies fold_text), registered lazily so cached statements are not invalidated. Results are capped at TETTO = 5,000; counts are cached per libreria_stato.generazione (up to 256 entries). Manual edits to a smart playlist are refused (library.playlistIsSmart).
The rule editor (Regole.tsx) previews the count live through playlist_regole_prova (first 20 tracks).