ShowShark Profiles: From Devices to People
Here is a thing that should not be complicated: two people share an Apple TV. One of them watches crime dramas. The other watches cartoons with the kids. They would like their watch history and recommendations to reflect their own preferences, not a statistical average of the entire household.
Every major streaming service figured this out years ago. Netflix has profiles. Disney+ has profiles. Plex has profiles. And for the first three years of its life, ShowShark did not, because ShowShark was built for a household of one. My household. I live alone with my cat, and my cat does not have strong opinions about media.
Then my friend installed ShowShark on his family's Apple TV, and the first bug report was: "My daughter's Bluey episodes are showing up in my continue watching."
Fair enough.
The Accidental Profile System
ShowShark already had something that looked, if you squinted, like a profile system. It just did not know it.
Every time a device connected to the server, it sent a deviceIdentifier, a stable UUID that persisted across app launches. The server used this identifier to scope all stateful operations: play history, playlists, watch stats, resume positions. If you watched half of a movie on your iPhone, your iPhone's deviceIdentifier told the server to save the resume point. When you reopened the app, the server looked up your progress by that same identifier.
The Old World: One Device = One Identity
┌───────────┐ ┌───────────────────┐
│ iPhone │────────▶│ play_history │
│ (id: A) │ │ WHERE client_id │
└───────────┘ │ = 'A' │
└───────────────────┘
┌───────────┐ ┌───────────────────┐
│ iPad │────────▶│ play_history │
│ (id: B) │ │ WHERE client_id │
└───────────┘ │ = 'B' │
└───────────────────┘
┌───────────┐ ┌───────────────────┐
│ Apple TV │────────▶│ play_history │
│ (id: C) │ │ WHERE client_id │
└───────────┘ │ = 'C' │
└───────────────────┘
This worked perfectly for a single user with multiple devices. It did not work at all for multiple users sharing a single device. And it had a subtler problem too: your watch history was fragmented across devices. Start a movie on your phone during a flight, come home and switch to the TV, and the TV had no idea you were 45 minutes in. Two devices, two identities, two completely separate views of your media life.
The device was never really the right thing to track. The person was.
What a Profile Actually Is
A ShowShark profile is a small, named entity that lives on the server. It has an ID (a UUID), a name ("Robert" or "Guest" or "The Kids"), and an avatar. That is it. The profile itself carries no data. What it carries is a key: every row in play_history, playlists, and watch_stats is tagged with a profile_id instead of a client_id. The profile is the bucket that all your media state flows into, and it travels with you regardless of which device you are holding.
The New World: People, Not Devices
┌───────────┐
│ iPhone │──┐
│ (id: A) │ │
└───────────┘ │ ┌──────────────┐ ┌───────────────────┐
├────▶│ Profile: │────▶│ play_history │
┌───────────┐ │ │ "Robert" │ │ WHERE profile_id │
│ Apple TV │──┘ │ (id: P1) │ │ = 'P1' │
│ (id: C) │ └──────────────┘ └───────────────────┘
└───────────┘
┌───────────┐ ┌──────────────┐ ┌───────────────────┐
│ Apple TV │────────▶│ Profile: │────▶│ play_history │
│ (id: C) │ │ "Roommate" │ │ WHERE profile_id │
└───────────┘ │ (id: P2) │ │ = 'P2' │
└──────────────┘ └───────────────────┘
Notice that the Apple TV appears twice. It can be bound to either profile. The iPhone and Apple TV can share the same profile. One device, many profiles. Many devices, one profile. The device is just a viewport now.
The Thin Client Philosophy
One of the earliest design decisions was that the client should not store profile selection persistently. There is no UserDefaults key on the client that remembers "this device is using the Kids profile." Instead, the server remembers it.
When a device connects, the server looks up that device's defaultProfileId in its known-devices table and binds the session to that profile automatically. The client never had to ask. It just starts sending requests, and the server routes them to the right profile. If the user switches profiles mid-session, the server updates both the session binding and the persisted default so the next connection picks up where they left off.
This fits a philosophy that runs through all of ShowShark's architecture: the server is the source of truth, and the client is a window into it. The client does not store your library, does not store your watch history, does not store your playlists. It asks the server for everything. Profiles are no different. Your profile lives on the server, your state lives on the server, and the client is a thin rendering layer that happens to have a really nice remote control.
The practical benefit is that it just works. Factory-reset your Apple TV, reinstall the app, connect to your server, and all your data is there. No export, no import, no sync. The server knew who you were before you asked.
Silent Migration: Nobody Noticed
The hardest part of adding profiles was not building the profile system. It was making every users' existing data appear inside profiles they never created, on a server version to which they had not yet upgraded, without them noticing anything changed.
The migration runs exactly once, when the server database upgrades from schema version 24 to version 25. It works like this:
- Walk every row in
play_history,playlists, andwatch_stats. Collect the distinctclient_idvalues. These are the device identifiers that had accumulated state over the life of the server. - For each
client_id, look up the corresponding known device. Use the device's display name as the profile name. So if you named your Apple TV "Living Room," you get a profile called "Living Room." - Create the profile, then rewrite every row that referenced that
client_idto reference the newprofile_idinstead. - Orphaned data (rows from devices the server no longer recognizes) gets dropped. No sense creating a ghost profile for a phone you sold two years ago.
The entire operation runs inside a single SQLite transaction. If anything fails, the database rolls back to version 24 and tries again next launch. The user never sees a migration screen, never gets a "set up your profile" wizard, never knows anything happened. They just update the server, open the app, and their watch history is right where they left it, now owned by a profile that shares their device's name.
Migration: Rewriting History (Safely)
Before (v24): After (v25):
play_history play_history
┌────────────┬──────────┐ ┌─────────────┬──────────┐
│ client_id │ file │ │ profile_id │ file │
├────────────┼──────────┤ ├─────────────┼──────────┤
│ device-A │ movie.mk │ ──▶ │ prof-P1 │ movie.mk │
│ device-A │ show.mkv │ ──▶ │ prof-P1 │ show.mkv │
│ device-C │ ep01.mkv │ ──▶ │ prof-P2 │ ep01.mkv │
│ device-X │ old.avi │ ──▶ │ (dropped) │ │
└────────────┴──────────┘ └─────────────┴──────────┘
New table:
profiles
┌─────────┬──────────────┬────────────┐
│ id │ name │ icon_name │
├─────────┼──────────────┼────────────┤
│ prof-P1 │ "iPhone" │ dragon.png │
│ prof-P2 │ "Apple TV" │ fox.png │
└─────────┴──────────────┴────────────┘
Backward Compatibility: Old Clients, New Server
Not everyone updates their apps on the same day. A household might have the latest ShowShark on the Apple TV but an older version on someone's phone. The older version knows nothing about profiles. It does not send a ChooseProfileRequest. It does not display a profile picker. It just connects and starts playing.
The server handles this gracefully. When any client connects and authenticates, the server runs a profile resolution step:
- Does this device already have a
defaultProfileId? If so, bind the session to that profile. - If not, does an existing profile with a matching name exist that is not bound to another device? If so, claim it.
- If not, create a new profile automatically. Name it after the device. Assign an avatar deterministically. Bind it.
The old client never knows any of this happened. From its perspective, it sent the same login message it always sent, got the same login response, and started streaming. Behind the scenes, every play-history update, every playlist change, every resume-position save is being routed through the profile system. The old client is using profiles without knowing profiles exist.
This meant we could ship the server update first and the client update later, in any order, to any subset of devices, with zero coordination required. In fact, that's what we do since we can ship new server builds daily, while the app store reviewers frown at that kind of behavior.
Avatars: A Small Detail That Matters More Than You Think
Early in development, profiles used SF Symbols for their icons. A person outline, a star, a gamepad. They were fine. They were also completely forgettable. When you glanced at the profile picker, every option looked the same: a small monochrome shape on a circle.
We replaced them with a catalog of bundled PNG avatars. A dragon, a fox, an octopus, an owl, a robot, a penguin. Custom art with color and personality. Each avatar is owned by the server and served to clients as raw image bytes over the existing protobuf channel.
When a new profile is created, the server assigns an avatar deterministically using FNV-1a hashing on the profile's UUID. The same UUID always produces the same avatar. This is not about randomness or surprise. It is about testability and consistency. In tests, you can assert that profile X got avatar Y. In production, a profile's avatar does not change between server restarts. If the user does not like their assigned avatar, the user can change the avatar in the profile editor.
The client caches avatar images in a two-tier system: memory first, then disk (Caches/ShowShark/ProfileAvatars/). A profile picker with five profiles does not make five network requests. It makes zero after the first load.
What Happens When You Switch Profiles
On the surface, switching profiles looks instant. Tap a different avatar, and the UI updates. Under the hood, it is a carefully sequenced operation:
- The client sends a
ChooseProfileRequestwith the target profile ID. - The server updates the session binding AND persists the new default for the device. Both happen in the same handler. This means the next time this device connects, it will automatically start with the chosen profile.
- The client receives the response and immediately clears every profile-scoped cache: play history, watch stats, playlists, favorites.
- The client then immediately reloads all of those caches from the server for the new profile.
The "clear then reload" ordering matters. The client's tab bar visibility is derived from cached data. If the Playlists tab shows up when the playlist cache is non-empty, and you switch to a profile that has no playlists, you need the cache to be empty before the tab bar re-evaluates. Clearing first, then reloading, ensures the UI always reflects the current profile's actual state, even if the reload takes a moment.
Profile Merging: Cleaning Up the Inevitable Mess
The migration creates one profile per device. If you have three Apple devices, you get three profiles. That is technically correct but practically annoying. You do not want three copies of yourself. You want one profile that contains the union of all your data.
Profile merging is a server-side-only operation (there is no client UI for it; it lives in the server's admin interface). The merge takes a source profile and a destination profile and combines them:
- Play history conflicts (same file in both profiles): keep the row with the more recent timestamp.
- Playlists: move all source playlists to the destination. For the special Favorites playlist, take the union of both, with the destination winning on conflicts.
- Watch stats: sum the numeric counters (movies watched, episodes watched, listen time).
- Device bindings: any device that pointed at the source profile now points at the destination.
The entire merge runs in a single SQLite transaction. If any step fails, the whole thing rolls back and both profiles remain untouched. After a successful merge, the source profile is deleted. The destination profile now contains everything from both.
The Default Profile: Zero-Friction by Design
The most important profile interaction is the one that does not happen. Most users, most of the time, should never have to think about profiles. They open the app and they are themselves.
Every device has a defaultProfileId stored on the server. When the device connects, the server binds the session to that profile without asking. The client does not present a "who's watching?" screen on launch (though it can, if the user wants to switch). The profile picker is available in settings, not in the startup flow.
For a single-user household, profiles are invisible. The migration created one profile from the device's existing data, bound it as the default, and from that point forward, the system behaves exactly as it did before profiles existed. The user's experience is unchanged. They just gained the ability to invite a friend over, create a second profile, and keep their watch histories separate. Or not. It is entirely optional.
This, ultimately, is what makes me happiest about the design. Profiles are a feature that adds power for the people who need it and adds nothing, not even a setup screen, for the people who do not. The server does all the work. The client stays thin. And my friend's daughter's Bluey episodes stay where they belong.