Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions docs/ADR-INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down
65 changes: 57 additions & 8 deletions docs/decisions/ADR-0137-the-phone-keeps-a-copy-of-its-library.md
Original file line number Diff line number Diff line change
@@ -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 <server>" 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),
Expand All @@ -28,19 +61,33 @@ 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.

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.

Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Loading