diff --git a/CLAUDE.md b/CLAUDE.md index e1c4de3f..6c8c000a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -307,7 +307,7 @@ four first-party visualizers really are byte-identical in six files (one md5 eac and the fifteen Apple tests that read Swift as text do so through two helpers, `AppSource.swift` and `EngineSource.swift`, so they are found by searching for those rather than for the file paths. -**`ADR-0131`–`ADR-0138` make the server its own app** (`0131`–`0136` and `0138` accepted and built — `0134` in #345 and familiar-apple #198, `0136`/`0138` in #346–#350, `0135`'s notarized `.dmg` on 2026-09-30 — `0137` `proposed`). `0131` frames it: +**`ADR-0131`–`ADR-0138` make the server its own app** (`0131`–`0136` and `0138` accepted and built — `0134` in #345 and familiar-apple #198, `0136`/`0138` in #346–#350, `0135`'s notarized `.dmg` on 2026-09-30 — `0137` accepted 2026-10-04, being built in `familiar-apple`). `0131` frames it: Familiar is a server and the clients that play from it, installed and paired separately. The server comes as **Familiar Server** (a Developer ID menu-bar Mac app, source at `desktop/macos/` in this repo) or as Docker on Linux/NAS, and both are first-class. The Mac player stays in the App Store and carries diff --git a/docs/ADR-INDEX.md b/docs/ADR-INDEX.md index aed0b990..a54c6cb3 100644 --- a/docs/ADR-INDEX.md +++ b/docs/ADR-INDEX.md @@ -3,8 +3,8 @@ Generated by `scripts/adr_index.py` from the records in `docs/decisions/` — do not edit; run `make adr-index` after adding or changing one, and CI fails on a stale copy (ADR-0127 point 6). -139 current records (138 accepted, -1 proposed), 10 retired. A record whose status carries a note has had a point superseded by a later one; the note says which. +140 current records (140 accepted, +0 proposed), 10 retired. A record whose status carries a note has had a point superseded by a later one; the note says which. ## Apple clients @@ -39,7 +39,7 @@ Generated by `scripts/adr_index.py` from the records in `docs/decisions/` — do | [0122](decisions/ADR-0122-the-apple-client-has-explicit-module-boundaries.md) | The Apple Client Has Explicit Module Boundaries | accepted | | [0123](decisions/ADR-0123-the-apple-client-has-one-process-lifetime-composition-root.md) | The Apple Client Has One Process-Lifetime Composition Root | accepted | | [0124](decisions/ADR-0124-generated-api-types-stop-at-the-apple-clients-repositories.md) | Generated API Types Stop at the Apple Client's Repositories | accepted | -| [0137](decisions/ADR-0137-the-phone-keeps-a-copy-of-its-library.md) | The Phone Keeps a Copy of Its Library | proposed | +| [0137](decisions/ADR-0137-the-phone-keeps-a-copy-of-its-library.md) | The Phone Keeps a Copy of Its Library | accepted | | [0139](decisions/ADR-0139-the-apple-clients-record-where-a-queue-was-started-from.md) | The Apple Clients Record Where a Queue Was Started From | accepted | | [0146](decisions/ADR-0146-the-site-leads-with-the-mac.md) | The Site Leads with the Mac | accepted | | [0148](decisions/ADR-0148-familiar-server-has-a-stable-download-link.md) | Familiar Server Has a Stable Download Link | accepted | @@ -180,6 +180,7 @@ Generated by `scripts/adr_index.py` from the records in `docs/decisions/` — do | [0138](decisions/ADR-0138-a-desktop-server-yields-to-its-owner.md) | A Desktop Server Yields to Its Owner | accepted | | [0142](decisions/ADR-0142-the-server-watches-its-library.md) | The Server Watches Its Library | accepted | | [0147](decisions/ADR-0147-windows-and-android-are-planned.md) | Windows and Android Are Planned | accepted | +| [0150](decisions/ADR-0150-each-server-and-listener-gets-8-gb-on-a-device.md) | Each Server and Listener Gets 8 GB on a Device | accepted | ## Security diff --git a/docs/decisions/ADR-0137-the-phone-keeps-a-copy-of-its-library.md b/docs/decisions/ADR-0137-the-phone-keeps-a-copy-of-its-library.md index 9e30fcc9..e3bf92de 100644 --- a/docs/decisions/ADR-0137-the-phone-keeps-a-copy-of-its-library.md +++ b/docs/decisions/ADR-0137-the-phone-keeps-a-copy-of-its-library.md @@ -1,9 +1,42 @@ # ADR-0137: The Phone Keeps a Copy of Its Library -Status: proposed +Status: accepted; point 5 superseded by [ADR-0150](ADR-0150-each-server-and-listener-gets-8-gb-on-a-device.md) Date: 2026-09-29 +Implementation: +- Accepted 2026-10-04. Client-only: every point is built in `familiar-apple`, and the server is + untouched. Planned as five slices, in this order. The order differs from the numbering, and the + budget deliberately lands before the default. + 1. **The away state (point 4)** and the reachability it depends on: "can this phone see its + server", which is not `Connectivity.isOnline`. Point 3's triggers and point 4's dimming both + need it. Offline favourite toggles join the event queue here. + 2. **The storage budget (point 5, now ADR-0150).** It comes before point 1 so that turning the + default on can never fill a phone without a bound. ADR-0150 replaced "a share of free space" + with 8 GB per server and profile, which needs storage split per pair first. + 3. **The pairing offer (point 1).** + 4. **Kept playlists and the sync that follows them (points 2 and 3).** + 5. **The Mac against a server on the same machine (point 6).** It depends on nothing above, so + it can move earlier. +- **Slice 1 built 2026-10-04** in `familiar-apple`, not yet run on a device: + - #210: reachability from the contract check, and the away state in the player and rows. + - #211: "Needs " wherever the server is needed. + - #212: favourites kept on disk, with changes made away queued as absolute adds and removes. + **There had been no favourites cache at all**, so a phone launched away from its server + showed no favourites. Point 4 assumed browsing worked from cache everywhere. +- **Slice 2 built** as ADR-0150 (#213–#216). +- **Slices 3–5 built 2026-10-04**, not yet run on a device: + - #217: point 1. The toggle is above the profile list, because choosing a profile saves at once. + - #218: point 6. Same machine is decided by address, loopback or one of this Mac's own, which + covers both ways a same-Mac server is paired without browsing. The play cache is off too. + - #219: points 2 and 3. `KeptSet` and one `syncKeptTracks()` replace the favourites-only + auto-download. **A sync that cannot read every rule releases nothing**, so a network blip is + never why music leaves the phone. Un-favouriting a kept favourite now releases it, which it + did not before. +- **Follow-up:** point 3's opportunistic background refresh is not built. It needs a + `BGTaskScheduler` identifier and background modes in `familiar-apple`'s Info.plist. Foreground + and the server's return cover the cases the point names. + Extends [ADR-0131](ADR-0131-the-server-is-its-own-app.md), [ADR-0009](ADR-0009-offline-downloads-are-background-transfers.md), [ADR-0011](ADR-0011-the-library-is-cached-whole-and-refreshed-by-delta.md), @@ -28,8 +61,8 @@ Most of the pieces already exist, built one at a time for the NAS: need the server. - **Listening events queued offline**, each carrying its own timestamp (`FamiliarKit/ListeningEventQueue.swift`). -- **Favourites auto-download**, following the profile's server-side `favorites_auto_download`, - which defaults to off (`backend/app/api/routes/favorites.py:157`, +- **Favourites auto-download**, a device-local setting per profile that defaults to off + (`familiar-apple`'s `ServerConfiguration.favoritesAutoDownloadEnabled`, `FamiliarKit/FavoritesAutoDownload.swift`). - **A one-time "Download" for an album or playlist** (`App/Shared/BrowseViews.swift`), which fetches what is there now and does not follow later additions. @@ -37,10 +70,24 @@ Most of the pieces already exist, built one at a time for the NAS: What is missing is not a mechanism but a default: nothing treats the phone's copy as the normal state of affairs. +**The first draft had the auto-download setting wrong.** It said the setting followed the profile's +server-side `favorites_auto_download`, and its third alternative rejected making it device-local. +That move had already happened on 2026-08-05: ADR-0029 point 4, `familiar` #99 and `familiar-apple` +#71. The server value survives only as a seed. `ServerConfiguration.seedFavoritesAutoDownload` copies +it across once per profile, and the endpoints at `backend/app/api/routes/favorites.py:141` and `:162` +are deprecated. Point 1 below is written against the device-local setting, and the rejected +alternative has been replaced with a real one. Checked against both repositories on 2026-10-04, before +acceptance. The rest of the list above held. Three things this ADR treats as new were confirmed +missing. `Connectivity` reports only whether the device has a network, never whether the server +answers. No favourite toggle is queued offline. And nothing on the Mac detects a server on the same +machine. + ## Decision 1. **Pairing a phone to any server offers "Keep my favourites on this phone", on by - default.** Accepting it sets the profile's `favorites_auto_download`. The existing + default.** Accepting it turns on this device's auto-download for the paired profile + (ADR-0029 point 4) and marks that profile's seed as done. Otherwise the first launch would copy + the server's deprecated value, off by default, over the answer just given. The existing `FavoritesAutoDownload` then fetches newest first, as AAC per ADR-0118. Nothing new is invented for the default case. @@ -93,10 +140,12 @@ state of affairs. - **Mirror the whole library onto the phone.** Nothing is ever missing. Rejected: a 26k-track library is hundreds of GB even as AAC, well beyond any phone. The kept sets (point 2) with a budget (point 5) are the same idea at a size a phone can hold. -- **Make auto-download a device-local preference instead of the profile's setting.** It would fix - the odd case where the web app, the Mac and the phone all act on one flag. Rejected here as a - separate problem: `FavoritesAutoDownload.swift` records why it follows the profile deliberately, - and point 6 removes the case (the local Mac) where following it did harm. +- **Leave auto-download off by default and promote it in Settings instead.** Nothing would fill a + phone without being asked, and the setting already exists (Settings → Downloads). Rejected: an + off-by-default setting is found by people who already know they need it, and they find out when + they are already away. Away from the server, the phone would show a catalogue of dimmed rows, which + is point 4's honest state with nothing in it. Offering the choice at pairing, with the size spelled + out, asks the question while the server is in reach to answer it. ## Consequences diff --git a/docs/decisions/ADR-0150-each-server-and-listener-gets-8-gb-on-a-device.md b/docs/decisions/ADR-0150-each-server-and-listener-gets-8-gb-on-a-device.md new file mode 100644 index 00000000..b9f75de3 --- /dev/null +++ b/docs/decisions/ADR-0150-each-server-and-listener-gets-8-gb-on-a-device.md @@ -0,0 +1,128 @@ +# ADR-0150: Each Server and Listener Gets 8 GB on a Device + +Status: accepted + +Date: 2026-10-04 + +Implementation: +- Accepted 2026-10-04. Built in `familiar-apple` in three parts, in this order: the per-pair + storage partition (points 1, 3 and 6), the budget (point 2), then the warning and the storage + screen (points 4 and 5). +- **Built 2026-10-04, all six points, as a stack in `familiar-apple`.** None of it had been run on + a device when this was written. + - #213: points 1 and 6. `StorageSpaces` hands out one `DownloadStore` per pair. A download + request is stamped with its pair, which travels in the task description, so a transfer that + finishes after a switch of server still lands with whoever asked. The shared folders are + renamed into the pair in use, and never merged into a pair folder that already exists. + - #214: point 3. The `Kept` directory and `DownloadRequest.residency`. Downloading a kept track + moves it into `Downloads`. + - #215: point 2. `StorageBudget`. The play cache's bound is 8 GB on the Mac too. **Auto-download + queues only what the room left by kept tracks can hold**, which this ADR did not spell out: + without it, the budget releases a favourite and the next launch fetches it straight back. + - #216: points 4 and 5. `ServerConfiguration.storageNotice`, and "Storage on this device" in + Settings → Downloads. Offering to remove files at the moment a server is forgotten is not + built: forgotten pairs stay listed there instead. + +Supersedes point 5 of [ADR-0137](ADR-0137-the-phone-keeps-a-copy-of-its-library.md). +Extends [ADR-0010](ADR-0010-played-bytes-are-cached-downloads-are-pinned.md). + +## Context + +ADR-0137 point 5 gave kept tracks a budget "defaulting to a share of free space the owner can +change". The owner has since set the rule directly: **8 GB per server and profile, and say so when +a device starts holding more than one.** + +Checked against `familiar-apple` on 2026-10-04, these facts shape the change: + +- **Storage is global today.** `DownloadStore` keeps one directory, `Application + Support/Familiar/Downloads` (`FamiliarKit/DownloadStore.swift`). `PlayCacheStore` keeps another, + `Application Support/Familiar/Cache` (`FamiliarKit/PlayCacheStore.swift`). Every server and profile + this device has used shares both. Track ids are per server, so a second server's files sit + in the same folders as the first's, and nothing can free one server's share without the other's. +- **The play cache already has a budget**, from ADR-0010's measurement: 16 GB on the Mac and 8 GB on + the phone (`PlayCacheStore.defaultBudgetBytes`). That budget is per device, not per server. +- **A device holds up to five server/profile pairs.** `RecentServers.limit` is 5, and + each entry is one server with one profile. Five pairs at 8 GB each is 40 GB. +- **A download does not record why it was made.** `DownloadedTrack` carries an id, a file name, + a size, tags and a completion time. So the favourites already fetched by auto-download + (`FavoritesAutoDownload`, ADR-0029 point 4) cannot be told apart from tracks the listener + downloaded by hand. ADR-0137 point 5 distinguishes the two ("auto-kept" against "downloaded + explicitly"), and nothing on disk does. + +## Decision + +1. **Each server-and-profile pair on a device has its own storage.** Downloads, kept tracks and the + play cache live under one folder per pair, keyed by the server's `server_id` (ADR-0134 point 4) + or, for a server reached by typed address, its host and port, and then by profile id. Switching + pair switches folder. A track downloaded for one listener is not visible to another. + +2. **The budget is 8 GB per pair, on the phone and the Mac alike.** It covers two classes of file: + - **kept** tracks, fetched because a rule said so: favourites auto-download, and kept + playlists (ADR-0137 point 2); + - **cached** tracks, kept because they were played (ADR-0010). + + When the two together would pass 8 GB, cached tracks are released first, least recently played + first, then kept tracks in the same order. **Downloaded** tracks, the ones the listener asked + for, are outside the budget and are never released automatically. That is ADR-0009 point 10's + promise, unchanged. + +3. **Kept is a third class with its own directory**, beside ADR-0010's pinned `Downloads` and cached + `Cache`. ADR-0010 point 2 made a file's class its directory so reconciliation can repair a + mislabel, and a rule-fetched file is a different class from a chosen one. Downloading a kept + track explicitly promotes it into `Downloads` with a rename, as ADR-0010 point 6 does for a + cached one. + +4. **The device says so when it starts holding a second pair.** Connecting this device to a pair + it has not held before, by pairing, by setup or from the recent list, shows the cost before + anything is saved: "Each server and listener on this iPhone keeps up to 8 GB. With 3, Familiar + may use up to 24 GB." The count is the pairs on the recent list plus the new one. It is shown + once per new pair, never when switching between pairs the device already holds, and never for + the first one. + +5. **Storage on the device is shown per pair**, in Settings → Downloads: what each pair holds, + split into downloaded, kept and cached, with a way to remove one pair's files. Forgetting a + pair on the recent list offers to remove its files too, since nothing else can reach them. + +6. **What is on disk at upgrade belongs to the pair in use then.** The global `Downloads` and + `Cache` move into that pair's folder. Files from other servers cannot be attributed, because a + track id says nothing about its server, and reconciliation against that pair's catalogue later + releases the cached ones. Existing downloads all stay **downloaded**, auto-downloaded favourites + included, because nothing records which they were. A listener who wants them counted against + the budget can remove and re-keep them. + +## Alternatives Considered + +- **A share of free space, as ADR-0137 point 5 first said.** It adapts to the device without a + number anyone has to pick. Rejected by the owner: the share moves as the phone fills with other + things, so nobody can predict what Familiar will take, and it hides the cost of a second server + inside a number that was already changing. +- **One budget for the device, shared by every pair.** No partitioning, and the total can never + pass 8 GB. Rejected: the pair in use would evict the others' files every time it played, so + switching back to a server would find its favourites gone. It also leaves the global folders, + where removing one server's files is impossible. +- **Separate budgets for kept and cached tracks**, the play cache keeping ADR-0010's figure. Each + class would get its own measured size. Rejected by the owner in favour of one number per pair a + listener can hold in their head. That would mean up to 16 GB per pair, twice what anyone was + told. +- **Keep the Mac at 16 GB**, ADR-0010's measured figure. It holds a third of plays against roughly + a quarter at 8 GB. Rejected for one rule on every device. ADR-0137 point 6 already removes the + Mac's largest case, a server on the same machine, where the Mac downloads nothing. +- **Count explicit downloads against the budget too.** One number would bound everything. Rejected: + a full budget would then have to refuse a download or release one the listener chose. Both break + ADR-0009 point 10, which promises that nothing asked for is removed. + +## Consequences + +- **Positive:** what Familiar can take is a number a listener can work out before agreeing to it, + and the cost of every further server or listener is stated at the moment it is incurred. +- **Positive:** a server's files can be removed without touching another's, and forgetting a + server can free its space. +- **Tradeoff:** on the Mac, the play cache shrinks from 16 GB to at most 8 GB, shared with kept + tracks. ADR-0010's table puts 8 GB of cache alone at a 23.6% hit rate against 33.3% at 16 GB. + Shared with kept tracks it will be lower. The cost falls on a Mac playing from a NAS. +- **Tradeoff:** two listeners on one device who both download a track hold it twice. +- **Tradeoff:** favourites auto-downloaded before this ships stay outside the budget (point 6) + until removed and re-kept. +- **Follow-up:** the storage partition (points 1, 3 and 6) is the prerequisite for everything else + here and for ADR-0137's slices 3 and 4. It lands first, then the budget, then the alert and the + storage screen.