#+PROPERTY: COOKIE_DATA recursive
#+STARTUP: overview
most of this is old, I need to rework it
* design
** frontend (packages/app)
- http://localhost:7891
- proxies ~/api~ and ~/sync~ to the backend in development
- uses Dexie for local storage with sync plugin
- custom sync replication implementation using PeerJS through the signalling server
** backend (packages/server)
- http://localhost:7890
- serves ~/dist~ if the directory is present (see ~dist~ script)
- serves ~/api~ for RSS caching proxy
- file-based routing under the api directory
- serves ~/sync~ which is a ~peerjs~ signalling server
** sync
- each client keeps the full data set
- dexie sync and observable let us stream change sets
- we can publish the "latest" to all peers
- on first pull, if not the first client, we can request a dump out of band
*** rss feed data
- do we want to backup feed data?
- conceptually, this should be refetchable
- but feeds go away, and some will only show recent stories
- so yes, we'll need this
- but server side, we can dedupe
- content-addressed server-side cache?
- server side does RSS pulling
- can feeds be marked private, such that they won't be pulled through the proxy?
- but then we require everything to be fetchable via cors
- client configured proxy settings?
*** peer connection
- on startup, check for current realm-id and key pair
- if not present, ask to login or start new
- if login, run through the [[* pairing]] process
- if start new, run through the [[* registration]] process
- use keypair to authenticate to server
- response includes list of active peers to connect
- clients negotiate sync from there
- an identity is a keypair and a realm
- realm is uuid
- realm on the server is the socket connection for peer discovery
- keeps a list of verified public keys
- and manages the /current/ ~public-key->peer ids~ mapping
- realm on the client side is first piece of info required for sync
- when connecting to the signalling server, you present a realm, and a signed public key
- server accepts/rejects based on signature and current verified keys
- a new keypair can create a realm
- a new keypair can double sign an invitation
- invite = ~{ realm:, nonce:, not_before:, not_after:, authorizer: }~, signed with verified key
- exchanging an invite = ~{ invite: }~, signed with my key
- on startup
- start stand-alone (no syncing required, usually the case on first-run)
- generate a keypair
- want server backup?
- sign a "setup" message with new keypair and send to the server
- server responds with a new realm, that this keypair is already verified for
- move along
- exchange invite to sync to other devices
- generate a keypair
- sign the exchange message with the invite and send to the server
- server verifies the invite
- adds the new public key to the peer list and publishes downstream
- move along
***** standalone
in this mode, there is no syncing. this is the most likely first-time run option.
- generate a keypair on startup, so we have a stable fingerprint in the future
- done
***** pairing
in this mode, there is syncing to a named realm, but not necessarily server resources consumed
we don't need an email, since the server is just doing signalling and peer management
- generate an invite from an existing verified peer
- ~{ realm:, not_before:, not_after:, inviter: peer.public_key }~
- sign that invitation from the existing verified peer
- standalone -> paired
- get the invitation somehow (QR code?)
- sign an invite exchange with the standalone's public key
- send to server
- server verifies the invite
- adds the new public key to the peer list and publishes downstream
***** server backup
in this mode, there is syncing to a named realm by email.
goal of server backup mode is that we can go from email->fully working client with latest data without having to have any clients left around that could participate in the sync.
- generate a keypair on startup
- sign a registration message sent to the server
- send a verification email
- if email/realm already exists, this is authorization
- if not, it's email validation
- server starts a realm and associates the public key
- server acts as a peer for the realm, and stores private data
- since dexie is publishing change sets, we should be able to just store deltas
- but we'll need to store _all_ deltas, unless we're materializing on the server side too
- should we use an indexdb shim so we can import/export from the server for clean start?
- how much materialization does the server need?
* ai instructions
- when writing to the devlog, add tags to your entries specifying ~:ai:~ and what tool did it.
- false starts and prototypes are in ~./devlog/~
* notes and decision record [1/11]
** architecture design (may 28-29) :ai:claude:
details notes are in [[./devlog/may-29.org]]
key decisions and system design:
*** sync model
- device-specific records for playback state/queues to avoid conflicts
- content-addressed server cache with deduplication
- dual-JWT invitation flow for secure realm joining
*** data structures
- tag-based filtering system instead of rigid hierarchies
- regex patterns for episode title parsing and organization
- service worker caching with background download support
*** core schemas
**** client (dexie)
- Channel/ChannelEntry for RSS feeds and episodes
- PlayRecord/QueueItem scoped by deviceId
- FilterView for virtual feed organization
**** server (drizzle)
- ContentStore for deduplicated content by hash
- Realm/PeerConnection for sync authorization
- HttpCache with health tracking and TTL
*** push sync strategy
- revision-based sync (just send revision ranges in push notifications)
- background fetch API for large downloads where supported
- graceful degradation to reactive caching
*** research todos :ai:claude:
**** sync and data management
***** DONE identity and signature management
***** TODO dexie sync capabilities vs rxdb for multi-device sync implementation
***** TODO webrtc p2p sync implementation patterns and reliability
***** TODO conflict resolution strategies for device-specific data in distributed sync
***** TODO content-addressed deduplication algorithms for rss/podcast content
**** client-side storage and caching
***** TODO opfs storage limits and cleanup strategies for client-side caching
***** TODO practical background fetch api limits and edge cases for podcast downloads
**** automation and intelligence
***** TODO llm-based regex generation for episode title parsing automation
***** TODO push notification subscription management and realm authentication
**** platform and browser capabilities
***** TODO browser audio api capabilities for podcast-specific features (speed, silence skip)
***** TODO progressive web app installation and platform-specific behaviors
# Local Variables:
# org-hierarchical-todo-statistics: nil
# org-checkbox-hierarchical-statistics: nil
# End:
** <2025-05-28 Wed>
getting everything setup
the biggest open question I have is what sort of privacy/encryption guarantee I need. I want the server to be able to do things like cache and store feed data long-term.
Is "if you want full privacy, self-host" valid?
*** possibilities
- fully PWA
- CON: cors, which would require a proxy anyway
- CON: audio analysis, llm based stuff for categorization, etc. won't work
- PRO: private as all get out
- can still do WebRTC p2p sync for resiliancy
- can still do server backups, if sync stream is encrypted, but no compaction would be available
- could do _explicit_ server backups as dump files
- self hostable
- PRO: can do bunches of private stuff on the server, because if you don't want me to see it, do it elsewhere
- CON: hard for folk to use
*** brainstorm :ai:claude:
**** sync conflict resolution design discussion :ai:claude:
discussed the sync architecture and dexie conflict handling:
*dexie syncable limitations*:
- logical clocks handle causally-related changes well
- basic timestamp-based conflict resolution for concurrent updates
- last-writer-wins for same field conflicts
- no sophisticated CRDT or vector clock support
*solutions for podcast-specific conflicts*:
- play records: device-specific approach
- store separate ~play_records~ per ~device_id~
- each record: ~{ episode_id, device_id, position, completed, timestamp }~
- UI handles conflict resolution with "continue from X device?" prompts
- avoids arbitrary timestamp wins, gives users control
- subscription trees
- store ~parent_path~ as single string field ("/Tech/Programming")
- simpler than managing folder membership tables
- conflicts still possible but contained to single field
- could store move operations as events for richer resolution
*other sync considerations*:
- settings/preferences: distinguish device-local vs global
- bulk operations: "mark all played" can create duplicate operations
- metadata updates: server RSS updates vs local renames
- temporal ordering: recently played lists, queue reordering
- storage limits: cleanup operations conflicting across devices
- feed state: refresh timestamps, error states
*approach*: prefer "events not state" pattern and device-specific records where semantic conflicts are likely
**** data model brainstorm :ai:claude:
core entities designed with sync in mind:
***** ~Feed~ :: RSS/podcast subscription
- ~parent_path~ field for folder structure (eg. ~/Tech/Programming~)
- ~is_private~ flag to skip server proxy
- ~refresh_interval~ for custom update frequencies
***** ~Episode~ :: individual podcast episodes
- standard RSS metadata (guid, title, description, media url)
- duration and file info for playback
***** ~PlayRecord~ :: device-specific playback state
- separate record per ~device_id~ to avoid timestamp conflicts
- position, completed status, playback speed
- UI can prompt "continue from X device?" for resolution
***** ~QueueItem~ :: device-specific episode queue
- ordered list with position field
- ~device_id~ scoped to avoid queue conflicts
***** ~Subscription~ :: feed membership settings
- can be global or device-specific
- auto-download preferences per device
***** ~Settings~ :: split global vs device-local
- theme, default speed = global
- download path, audio device = device-local
***** Event tables for complex operations:
- ~FeedMoveEvent~ for folder reorganization
- ~BulkMarkPlayedEvent~ for "mark all read" operations
- better conflict resolution than direct state updates
***** sync considerations
- device identity established on first run
- dexie syncable handles basic timestamp conflicts
- prefer device-scoped records for semantic conflicts
- event-driven pattern for bulk operations
**** schema evolution from previous iteration :ai:claude:
reviewed existing schema from tmp/feed.ts - well designed foundation:
***** keep from original
- Channel/ChannelEntry naming and structure
- ~refreshHP~ adaptive refresh system (much better than simple intervals)
- rich podcast metadata (people, tags, enclosure, podcast object)
- HTTP caching with etag/status tracking
- epoch millisecond timestamps
- ~hashId()~ approach for entry IDs
***** add for multi-device sync
- ~PlayState~ table (device-scoped position/completion)
- Subscription table (with ~parentPath~ for folders, device-scoped settings)
- ~QueueItem~ table (device-scoped episode queues)
- Device table (identity management)
***** migration considerations
- existing Channel/ChannelEntry can be preserved
- new tables are additive
- ~fetchAndUpsert~ method works well with server proxy architecture
- dexie sync vs rxdb - need to evaluate change tracking capabilities
**** content-addressed caching for offline resilience :ai:claude:
designed caching system for when upstream feeds fail/disappear, building on existing cache-schema.ts:
***** server-side schema evolution (drizzle sqlite):
- keep existing ~httpCacheTable~ design (health tracking, http headers, ttl)
- add ~contentHash~ field pointing to deduplicated content
- new ~contentStoreTable~: deduplicated blobs by sha256 hash
- new ~contentHistoryTable~: url -> contentHash timeline with isLatest flag
- reference counting for garbage collection
***** client-side OPFS storage
- ~/cache/content/{contentHash}.xml~ for raw feeds
- ~/cache/media/{contentHash}.mp3~ for podcast episodes
- ~LocalCacheEntry~ metadata tracks expiration and offline-only flags
- maintains last N versions per feed for historical access
***** fetch strategy & fallback
1. check local OPFS cache first (fastest)
2. try server proxy ~/api/feed?url={feedUrl}~ (deduplicated)
3. server checks ~contentHistory~, serves latest or fetches upstream
4. server returns ~{contentHash, content, cached: boolean}~
5. client stores with content hash as filename
6. emergency mode: serve stale content when upstream fails
- preserves existing health tracking and HTTP caching logic
- popular feeds cached once on server, many clients benefit
- bandwidth savings via content hash comparison
- historical feed state preservation (feeds disappear!)
- true offline operation after initial sync
** <2025-05-29 Thu> :ai:claude:
e2e encryption and invitation flow design
worked through the crypto and invitation architecture. key decisions:
*** keypair strategy
- use jwk format for interoperability (server stores public keys)
- ed25519 for signing, separate x25519 for encryption if needed
- zustand lazy initialization pattern: ~ensureKeypair()~ on first use
- store private jwk in persisted zustand state
*** invitation flow: dual-jwt approach
solved the chicken-and-egg problem of sharing encryption keys securely.
**** qr code contains two signed jwts:
1. invitation token: ~{iss: inviter_fingerprint, sub: invitation_id, purpose: "realm_invite"}~
2. encryption key token: ~{iss: inviter_fingerprint, ephemeral_private: base64_key, purpose: "ephemeral_key"}~
**** exchange process:
1. invitee posts jwt1 + their public keys to ~/invitations~
2. server verifies jwt1 signature against realm members
3. if valid: adds invitee to realm, returns ~{realm_id, realm_members, encrypted_realm_key}~
4. invitee verifies jwt2 signature against returned realm members
5. invitee extracts ephemeral private key, decrypts realm encryption key
**** security properties:
- server never has decryption capability (missing ephemeral private key)
- both jwts must be signed by verified realm member
- if first exchange fails, second jwt is cryptographically worthless
- atomic operation: identity added only if invitation valid
- built-in expiration and tamper detection via jwt standard
**** considered alternatives:
- raw ephemeral keys in qr: simpler but no authenticity
- ecdh key agreement: chicken-and-egg problem with public key exchange
- server escrow: good but missing authentication layer
- password-based: requires secure out-of-band sharing
the dual-jwt approach provides proper authenticated invitations while maintaining e2e encryption properties.
**** refined dual-jwt with ephemeral signing
simplified the approach by using ephemeral key for second jwt signature:
**setup**:
1. inviter generates ephemeral keypair
2. encrypts realm key with ephemeral private key
3. posts to server: ~{invitation_id, realm_id, ephemeral_public, encrypted_realm_key}~
**qr code contains**:
#+BEGIN_SRC json
// JWT 1: signed with inviter's realm signing key
{
"realm_id": "uuid",
"invitation_id": "uuid",
"iss": "inviter_fingerprint"
}
// JWT 2: signed with ephemeral private key
{
"ephemeral_private": "base64_key",
"invitation_id": "uuid"
}
#+END_SRC
**exchange flow**:
1. submit jwt1 → server verifies against realm members → returns ~{invitation_id, realm_id, ephemeral_public, encrypted_realm_key}~
2. verify jwt2 signature using ~ephemeral_public~ from server response
3. extract ~ephemeral_private~ from jwt2, decrypt realm key
**benefits over previous version**:
- no premature key disclosure (invitee keys shared via normal webrtc peering)
- self-contained verification (ephemeral public key verifies jwt2)
- cleaner separation of realm auth vs encryption key distribution
- simpler flow (no need to return realm member list)
**crypto verification principle**: digital signatures work as sign-with-private/verify-with-public, while encryption works as encrypt-with-public/decrypt-with-private. jwt2 verification uses signature verification, not decryption.
**invitation flow diagram**:
#+BEGIN_SRC mermaid
sequenceDiagram
participant I as Inviter
participant S as Server
participant E as Invitee
Note over I: Generate ephemeral keypair
I->>I: ephemeral_private, ephemeral_public
Note over I: Encrypt realm key
I->>I: encrypted_realm_key = encrypt(realm_key, ephemeral_private)
I->>S: POST /invitations
{invitation_id, realm_id, ephemeral_public, encrypted_realm_key}
S-->>I: OK
Note over I: Create JWTs for QR code
I->>I: jwt1 = sign({realm_id, invitation_id}, inviter_private)
I->>I: jwt2 = sign({ephemeral_private, invitation_id}, ephemeral_private)
Note over I,E: QR code contains [jwt1, jwt2]
E->>S: POST /invitations/exchange
{jwt1}
Note over S: Verify jwt1 signature
against realm members
S-->>E: {invitation_id, realm_id, ephemeral_public, encrypted_realm_key}
Note over E: Verify jwt2 signature
using ephemeral_public
E->>E: verify_signature(jwt2, ephemeral_public)
Note over E: Extract key and decrypt
E->>E: ephemeral_private = decode(jwt2)
E->>E: realm_key = decrypt(encrypted_realm_key, ephemeral_private)
Note over E: Now member of realm!
#+END_SRC
**** jwk keypair generation and validation :ai:claude:
discussed jwk vs raw crypto.subtle for keypair storage. since public keys need server storage for realm authorization, jwk is better for interoperability.
**keypair generation**:
#+BEGIN_SRC typescript
const keypair = await crypto.subtle.generateKey(
{ name: "Ed25519" },
true,
["sign", "verify"]
);
const publicJWK = await crypto.subtle.exportKey("jwk", keypair.publicKey);
const privateJWK = await crypto.subtle.exportKey("jwk", keypair.privateKey);
// JWK format:
{
"kty": "OKP",
"crv": "Ed25519",
"x": "base64url-encoded-public-key",
"d": "base64url-encoded-private-key" // only in private JWK
}
#+END_SRC
**client validation**:
#+BEGIN_SRC typescript
function isValidEd25519PublicJWK(jwk: any): boolean {
return (
typeof jwk === 'object' &&
jwk.kty === 'OKP' &&
jwk.crv === 'Ed25519' &&
typeof jwk.x === 'string' &&
jwk.x.length === 43 && // base64url Ed25519 public key length
!jwk.d && // public key shouldn't have private component
!jwk.use || jwk.use === 'sig'
);
}
async function validatePublicKey(publicJWK: JsonWebKey): Promise {
try {
if (!isValidEd25519PublicJWK(publicJWK)) return null;
const key = await crypto.subtle.importKey(
'jwk',
publicJWK,
{ name: 'Ed25519' },
false,
['verify']
);
return key;
} catch {
return null;
}
}
#+END_SRC
**server validation (node.js)**:
#+BEGIN_SRC typescript
import { webcrypto } from 'node:crypto';
async function validateClientPublicKey(publicJWK: JsonWebKey): Promise {
try {
if (!isValidEd25519PublicJWK(publicJWK)) return false;
await webcrypto.subtle.importKey(
'jwk',
publicJWK,
{ name: 'Ed25519' },
false,
['verify']
);
return true;
} catch {
return false;
}
}
#+END_SRC
**authentication flow**:
#+BEGIN_SRC typescript
// client signs message
const authMessage = {
realm: 'uuid-here',
timestamp: Date.now(),
action: 'join'
};
const signature = await crypto.subtle.sign(
'Ed25519',
privateKey,
new TextEncoder().encode(JSON.stringify(authMessage))
);
// server verifies
async function verifyAuth(req: AuthRequest): Promise {
const publicKey = await webcrypto.subtle.importKey(
'jwk',
req.publicKey,
{ name: 'Ed25519' },
false,
['verify']
);
const messageBytes = new TextEncoder().encode(JSON.stringify(req.message));
const signatureBytes = new Uint8Array(req.signature);
return await webcrypto.subtle.verify(
'Ed25519',
publicKey,
signatureBytes,
messageBytes
);
}
#+END_SRC
**** proposed schemas :ai:claude:
***** client-side schema (dexie)
#+BEGIN_SRC typescript
// Core RSS/Podcast data (from your existing design)
interface Channel {
id: string;
feedUrl: string;
htmlUrl?: string;
imageUrl?: string;
title?: string;
description?: string;
language?: string;
people?: Record;
tags?: string[];
// Refresh management
refreshHP: number;
nextRefreshAt?: number;
lastRefreshAt?: number;
lastRefreshStatus?: string;
lastRefreshHttpStatus?: number;
lastRefreshHttpEtag?: string;
// Cache info
contentHash?: string;
lastFetchedAt?: number;
}
interface ChannelEntry {
id: string;
channelId: string;
guid: string;
title: string;
linkUrl?: string;
imageUrl?: string;
snippet?: string;
content?: string;
enclosure?: {
url: string;
type?: string;
length?: number;
};
podcast?: {
explicit?: boolean;
duration?: string;
seasonNum?: number;
episodeNum?: number;
transcriptUrl?: string;
};
publishedAt?: number;
fetchedAt?: number;
}
// Device-specific sync tables
interface PlayRecord {
id: string;
entryId: string;
deviceId: string;
position: number;
duration?: number;
completed: boolean;
speed: number;
updatedAt: number;
}
interface Subscription {
id: string;
channelId: string;
deviceId?: string;
parentPath: string; // "/Tech/Programming"
autoDownload: boolean;
downloadLimit?: number;
isActive: boolean;
createdAt: number;
updatedAt: number;
}
interface QueueItem {
id: string;
entryId: string;
deviceId: string;
position: number;
addedAt: number;
}
interface Device {
id: string;
name: string;
platform: string;
lastSeen: number;
}
// Local cache metadata
interface LocalCache {
id: string;
url: string;
contentHash: string;
filePath: string; // OPFS path
cachedAt: number;
expiresAt?: number;
size: number;
isOfflineOnly: boolean;
}
// Dexie schema
const db = new Dexie('SkypodDB');
db.version(1).stores({
channels: '&id, feedUrl, contentHash',
channelEntries: '&id, channelId, publishedAt',
playRecords: '&id, [entryId+deviceId], deviceId, updatedAt',
subscriptions: '&id, channelId, deviceId, parentPath',
queueItems: '&id, entryId, deviceId, position',
devices: '&id, lastSeen',
localCache: '&id, url, contentHash, expiresAt'
});
#+END_SRC
***** server-side schema
#+BEGIN_SRC typescript
// Content-addressed cache
interface ContentStore {
contentHash: string; // Primary key
content: Buffer; // Raw feed content
contentType: string;
contentLength: number;
firstSeenAt: number;
referenceCount: number;
}
interface ContentHistory {
id: string;
url: string;
contentHash: string;
fetchedAt: number;
isLatest: boolean;
}
// HTTP cache with health tracking (from your existing design)
interface HttpCache {
key: string; // URL hash, primary key
url: string;
status: 'alive' | 'dead';
lastFetchedAt: number;
lastFetchError?: string;
lastFetchErrorStreak: number;
lastHttpStatus: number;
lastHttpEtag?: string;
lastHttpHeaders: Record;
expiresAt: number;
expirationTtl: number;
contentHash: string; // Points to ContentStore
}
// Sync/auth tables
interface Realm {
id: string; // UUID
createdAt: number;
verifiedKeys: string[]; // Public key list
}
interface PeerConnection {
id: string;
realmId: string;
publicKey: string;
lastSeen: number;
isOnline: boolean;
}
// Media cache for podcast episodes
interface MediaCache {
contentHash: string; // Primary key
originalUrl: string;
mimeType: string;
fileSize: number;
content: Buffer;
cachedAt: number;
accessCount: number;
}
#+END_SRC
**** episode title parsing for sub-feed groupings :ai:claude:
*problem*: some podcast feeds contain multiple shows, need hierarchical organization within a feed
*example*: "Apocalypse Players" podcast
- episode title: "A Term of Art 6 - Winston's Hollow"
- desired grouping: "Apocalypse Players > A Term of Art > 6 - Winston's Hollow"
- UI shows sub-shows within the main feed
***** approaches considered
1. *manual regex patterns* (short-term solution)
- user provides regex with capture groups = tags
- reliable, immediate, user-controlled
- requires manual setup per feed
2. *LLM-generated regex* (automation goal)
- analyze last 100 episode titles
- generate regex pattern automatically
- good balance of automation + reliability
3. *NER model training* (experimental)
- train spacy model for episode title parsing
- current prototype: 150 labelled examples, limited success
- needs more training data to be viable
***** data model implications
- add regex pattern field to Channel/Feed
- store extracted groupings as hierarchical tags on ~ChannelEntry~
- maybe add grouping/series field to episodes
***** plan
*preference*: start with manual regex, evolve toward LLM automation
*implementation design*:
- if no title pattern: episodes are direct children of the feed
- title pattern = regex with named capture groups + path template
*example configuration*:
- regex: ~^(?[^0-9]+)\s*(?\d+)\s*-\s*(?.+)$~
- path template: ~{series} > Episode {episode} - {title}~
- result: "A Term of Art 6 - Winston's Hollow" → "A Term of Art > Episode 6 - Winston's Hollow"
*schema additions*:
#+BEGIN_SRC typescript
interface Channel {
// ... existing fields
titlePatterns?: Array<{
name: string; // "Main Episodes", "Bonus Content", etc.
regex: string; // named capture groups
pathTemplate: string; // interpolation template
priority: number; // order to try patterns (lower = first)
isActive: boolean; // can disable without deleting
}>;
fallbackPath?: string; // template for unmatched episodes
}
interface ChannelEntry {
// ... existing fields
parsedPath?: string; // computed from titlePattern
parsedGroups?: Record; // captured groups
matchedPatternName?: string; // which pattern was used
}
#+END_SRC
*pattern matching logic*:
1. try patterns in priority order (lower number = higher priority)
2. first matching pattern wins
3. if no patterns match, use fallbackPath template (e.g., "Misc > {title}")
4. if no fallbackPath, episode stays direct child of feed
*example multi-pattern setup*:
- Pattern 1: "Main Episodes" - ~^(?[^0-9]+)\s*(?\d+)~ → ~{series} > Episode {episode}~
- Pattern 2: "Bonus Content" - ~^Bonus:\s*(?.+)~ → ~Bonus > {title}~
- Fallback: ~Misc > {title}~
**** scoped tags and filter-based UI evolution :ai:claude:
*generalization*: move from rigid hierarchies to tag-based filtering system
*tag scoping*:
- feed-level tags: "Tech", "Gaming", "D&D"
- episode-level tags: from regex captures like "series:CriticalRole", "campaign:2", "type:main"
- user tags: manual additions like "favorites", "todo"
*UI as tag filtering*:
- default view: all episodes grouped by feed
- filter by ~series:CriticalRole~ → shows only CR episodes across all feeds
- filter by ~type:bonus~ → shows bonus content from all podcasts
- combine filters: ~series:CriticalRole AND type:main~ → main CR episodes only
*benefits*:
- no rigid hierarchy - users create their own views
- regex patterns become automated episode taggers
- same filtering system works for search, organization, queues
- tags are syncable metadata, views are client-side
*schema evolution*:
#+BEGIN_SRC typescript
interface Tag {
scope: 'feed' | 'episode' | 'user';
key: string; // "series", "type", "campaign"
value: string; // "CriticalRole", "bonus", "2"
}
interface ChannelEntry {
// ... existing
tags: Tag[]; // includes regex-generated + manual
}
interface FilterView {
id: string;
name: string;
folderPath: string; // "/Channels/Critical Role"
filters: Array<{
key: string;
value: string;
operator: 'equals' | 'contains' | 'not';
}>;
isDefault: boolean;
createdAt: number;
}
#+END_SRC
**** default UI construction and feed merging :ai:claude:
*auto-generated views on subscribe*:
- subscribe to "Critical Role" → creates ~/Channels/Critical Role~ folder
- default filter view: ~feed:CriticalRole~ (shows all episodes from that feed)
- user can customize, split into sub-views, or delete
*smart view suggestions*:
- after regex patterns generate tags, suggest splitting views
- "I noticed episodes with ~series:Campaign2~ and ~series:Campaign3~ - create separate views?"
- "Create view for ~type:bonus~ episodes?"
*view management UX*:
- right-click feed → "Split by series", "Split by type"
- drag episodes between views to create manual filters
- views can be nested: ~/Channels/Critical Role/Campaign 2/Main Episodes~
*feed merging for multi-source shows*:
problem: patreon feed + main show feed for same podcast
#+BEGIN_EXAMPLE
/Channels/
Critical Role/
All Episodes # merged view: feed:CriticalRole OR feed:CriticalRolePatreon
Main Feed # filter: feed:CriticalRole
Patreon Feed # filter: feed:CriticalRolePatreon
#+END_EXAMPLE
*deduplication strategy*:
- episodes matched by ~guid~ or similar content hash
- duplicate episodes get ~source:main,patreon~ tags
- UI shows single episode with source indicators
- user can choose preferred source for playback
- play state syncs across all sources of same episode
*feed relationship schema*:
#+BEGIN_SRC typescript
interface FeedGroup {
id: string;
name: string; // "Critical Role"
feedIds: string[]; // [mainFeedId, patreonFeedId]
mergeStrategy: 'guid' | 'title' | 'contentHash';
defaultView: FilterView;
}
interface ChannelEntry {
// ... existing
duplicateOf?: string; // points to canonical episode ID
sources: string[]; // feed IDs where this episode appears
}
#+END_SRC
**per-view settings and state**:
each filter view acts like a virtual feed with its own:
- unread counts (episodes matching filter that haven't been played)
- notification settings (notify for new episodes in this view)
- muted state (hide notifications, mark as read automatically)
- auto-download preferences (download episodes that match this filter)
- play queue integration (add new episodes to queue)
**use cases**:
- mute "Bonus Content" view but keep notifications for main episodes
- auto-download only "Campaign 2" episodes, skip everything else
- separate unread counts: "5 unread in Main Episodes, 2 in Bonus"
- queue only certain series automatically
**schema additions**:
#+BEGIN_SRC typescript
interface FilterView {
// ... existing fields
settings: {
notificationsEnabled: boolean;
isMuted: boolean;
autoDownload: boolean;
autoQueue: boolean;
downloadLimit?: number; // max episodes to keep
};
state: {
unreadCount: number;
lastViewedAt?: number;
isCollapsed: boolean; // in sidebar
};
}
#+END_SRC
*inheritance behavior*:
- new filter views inherit settings from parent feed/group
- user can override per-view
- "mute all Critical Role" vs "mute only bonus episodes"
**** client-side episode caching strategy :ai:claude:
*architecture*: service worker-based transparent caching
*flow*:
1. audio player requests ~/audio?url={episodeUrl}~
2. service worker intercepts request
3. if present in cache (with Range header support):
- serve from cache
4. else:
- let request continue to server (immediate playback)
- simultaneously start background fetch of full audio file
- when complete, broadcast "episode-cached" event
- audio player catches event and restarts feed → now uses cached version
**benefits**:
- no playback interruption (streaming starts immediately)
- seamless transition to cached version
- Range header support for seeking/scrubbing
- transparent to audio player implementation
*implementation considerations*:
- cache storage limits and cleanup policies
- partial download resumption if interrupted
- cache invalidation when episode URLs change
- offline playback support
- progress tracking for background downloads
**schema additions**:
#+BEGIN_SRC typescript
interface CachedEpisode {
episodeId: string;
originalUrl: string;
cacheKey: string; // for cache API
fileSize: number;
cachedAt: number;
lastAccessedAt: number;
downloadProgress?: number; // 0-100 for in-progress downloads
}
#+END_SRC
**service worker events**:
- ~episode-cache-started~ - background download began
- ~episode-cache-progress~ - download progress update
- ~episode-cache-complete~ - ready to switch to cached version
- ~episode-cache-error~ - download failed, stay with streaming
**background sync for proactive downloads**:
**browser support reality**:
- Background Sync API: good support (Chrome/Edge, limited Safari)
- Periodic Background Sync: very limited (Chrome only, requires PWA install)
- Push notifications: good support, but requires user permission
**hybrid approach**:
1. **foreground sync** (reliable): when app is open, check for new episodes
2. **background sync** (opportunistic): register sync event when app closes
3. **push notifications** (fallback): server pushes "new episodes available"
4. **manual sync** (always works): pull-to-refresh, settings toggle
**implementation strategy**:
#+BEGIN_SRC typescript
// Register background sync when app becomes hidden
document.addEventListener('visibilitychange', () => {
if (document.hidden && 'serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
return registration.sync.register('download-episodes');
});
}
});
// Service worker handles sync event
self.addEventListener('sync', event => {
if (event.tag === 'download-episodes') {
event.waitUntil(syncEpisodes());
}
});
#+END_SRC
**realistic expectations**:
- iOS Safari: very limited background processing
- Android Chrome: decent background sync support
- Desktop: mostly works
- battery/data saver modes: disabled by OS
**fallback strategy**: rely primarily on foreground sync + push notifications, treat background sync as nice-to-have enhancement
**push notification sync workflow**:
**server-side trigger**:
1. server detects new episodes during RSS refresh
2. check which users are subscribed to that feed
3. send push notification with episode metadata payload
4. notification wakes up service worker on client
**service worker notification handler**:
#+BEGIN_SRC typescript
self.addEventListener('push', event => {
const data = event.data?.json();
if (data.type === 'new-episodes') {
event.waitUntil(
// Start background download of new episodes
downloadNewEpisodes(data.episodes)
.then(() => {
// Show notification to user
return self.registration.showNotification('New episodes available', {
body: ~${data.episodes.length} new episodes downloaded~,
icon: '/icon-192.png',
badge: '/badge-72.png',
tag: 'new-episodes',
data: { episodeIds: data.episodes.map(e => e.id) }
});
})
);
}
});
// Handle notification click
self.addEventListener('notificationclick', event => {
event.notification.close();
// Open app to specific episode or feed
event.waitUntil(
clients.openWindow(~/episodes/${event.notification.data.episodeIds[0]}~)
);
});
#+END_SRC
**server push logic**:
- batch notifications (don't spam for every episode)
- respect user notification preferences from FilterView settings
- include episode metadata in payload to avoid round-trip
- throttle notifications (max 1 per feed per hour?)
**user flow**:
1. new episode published → server pushes notification
2. service worker downloads episode in background
3. user sees "New episodes downloaded" notification
4. tap notification → opens app to new episode, ready to play offline
*benefits*:
- true background downloading without user interaction
- works even when app is closed
- respects per-feed notification settings
**push payload size constraints**:
- **limit**: ~4KB (4,096 bytes) across most services
- **practical limit**: ~3KB to account for service overhead
- **implications for episode metadata**:
#+BEGIN_SRC json
{
"type": "new-episodes",
"episodes": [
{
"id": "ep123",
"channelId": "ch456",
"title": "Episode Title",
"url": "https://...",
"duration": 3600,
"size": 89432112
}
]
}
#+END_SRC
**payload optimization strategies**:
- minimal episode metadata in push (id, url, basic info)
- batch multiple episodes in single notification
- full episode details fetched after service worker wakes up
- URL shortening for long episode URLs
- compress JSON payload if needed
**alternative for large payloads**:
- push notification contains only "new episodes available" signal
- service worker makes API call to get full episode list
- trade-off: requires network round-trip but unlimited data
**logical clock sync optimization**:
much simpler approach using sync revisions:
#+BEGIN_SRC json
{
"type": "sync-available",
"fromRevision": 12345,
"toRevision": 12389,
"changeCount": 8
}
#+END_SRC
**service worker sync flow**:
1. push notification wakes service worker with revision range
2. service worker fetches ~/sync?from=12345&to=12389~
3. server returns only changes in that range (episodes, feed updates, etc)
4. service worker applies changes to local dexie store
5. service worker queues background downloads for new episodes
6. updates local revision to 12389
**benefits of revision-based approach**:
- tiny push payload (just revision numbers)
- server can efficiently return only changes in range
- automatic deduplication (revision already applied = skip)
- works for any sync data (episodes, feed metadata, user settings)
- handles offline gaps gracefully (fetch missing revision ranges)
**sync API response**:
#+BEGIN_SRC typescript
interface SyncResponse {
fromRevision: number;
toRevision: number;
changes: Array<{
type: 'episode' | 'channel' | 'subscription';
operation: 'create' | 'update' | 'delete';
data: any;
revision: number;
}>;
}
#+END_SRC
**integration with episode downloads**:
- service worker processes sync changes
- identifies new episodes that match user's auto-download filters
- queues those for background cache fetching
- much more efficient than sending episode metadata in push payload
**service worker processing time constraints**:
**hard limits**:
- **30 seconds idle timeout**: service worker terminates after 30s of inactivity
- **5 minutes event processing**: single event/request must complete within 5 minutes
- **30 seconds fetch timeout**: individual network requests timeout after 30s
- **notification requirement**: push events MUST display notification before promise settles
**practical implications**:
- sync API call (~/sync?from=X&to=Y~) must complete within 30s
- large episode downloads must be queued, not started immediately in push handler
- use ~event.waitUntil()~ to keep service worker alive during processing
- break large operations into smaller chunks
**recommended push event flow**:
#+BEGIN_SRC typescript
self.addEventListener('push', event => {
const data = event.data?.json();
event.waitUntil(
// Must complete within 5 minutes total
handlePushSync(data)
.then(() => {
// Required: show notification before promise settles
return self.registration.showNotification('Episodes synced');
})
);
});
async function handlePushSync(data) {
// 1. Quick sync API call (< 30s)
const changes = await fetch(~/sync?from=${data.fromRevision}&to=${data.toRevision}~);
// 2. Apply changes to dexie store (fast, local)
await applyChangesToStore(changes);
// 3. Queue episode downloads for later (don't start here)
await queueEpisodeDownloads(changes.newEpisodes);
// Total time: < 5 minutes, preferably < 30s
}
#+END_SRC
*download strategy*: use push event for sync + queuing, separate background tasks for actual downloads
*background fetch API for large downloads*:
*progressive enhancement approach*:
#+BEGIN_SRC typescript
async function queueEpisodeDownloads(episodes) {
for (const episode of episodes) {
if ('serviceWorker' in navigator && 'BackgroundFetch' in window) {
// Chrome/Edge: use Background Fetch API for true background downloading
await navigator.serviceWorker.ready.then(registration => {
return registration.backgroundFetch.fetch(
~episode-${episode.id}~,
episode.url,
{
icons: [{ src: '/icon-256.png', sizes: '256x256', type: 'image/png' }],
title: ~Downloading: ${episode.title}~,
downloadTotal: episode.fileSize
}
);
});
} else {
// Fallback: queue for reactive download (download while streaming)
await queueReactiveDownload(episode);
}
}
}
// Handle background fetch completion
self.addEventListener('backgroundfetch', event => {
if (event.tag.startsWith('episode-')) {
event.waitUntil(handleEpisodeDownloadComplete(event));
}
});
#+END_SRC
*browser support reality*:
- *Chrome/Edge*: Background Fetch API supported
- *Firefox/Safari*: not supported, fallback to reactive caching
- *mobile*: varies by platform and browser
*benefits when available*:
- true background downloading (survives app close, browser close)
- built-in download progress UI
- automatic retry on network failure
- no service worker time limits during download
*graceful degradation*:
- detect support, use when available
- fallback to reactive caching (download while streaming)
- user gets best experience possible on their platform
*** research todos :ai:claude:
high-level unanswered questions from architecture brainstorming:
**** sync and data management
***** TODO dexie sync capabilities vs rxdb for multi-device sync implementation
***** TODO webrtc p2p sync implementation patterns and reliability
***** TODO conflict resolution strategies for device-specific data in distributed sync
***** TODO content-addressed deduplication algorithms for rss/podcast content
**** client-side storage and caching
***** TODO opfs storage limits and cleanup strategies for client-side caching
***** TODO practical background fetch api limits and edge cases for podcast downloads
**** automation and intelligence
***** TODO llm-based regex generation for episode title parsing automation
***** TODO push notification subscription management and realm authentication
**** platform and browser capabilities
***** TODO browser audio api capabilities for podcast-specific features (speed, silence skip)
***** TODO progressive web app installation and platform-specific behaviors
# Local Variables:
# org-hierarchical-todo-statistics: nil
# org-checkbox-hierarchical-statistics: nil
# End: