Recently, we overhauled Journiv's media stack. We moved away from custom authentication headers toward a signed URL architecture, unified our internal and external media models, and optimized for the unique constraints of the self-hosted environment. Here's a deep dive into the "why" and "how" of these changes.
The Problem: Authentication Headers vs. Native Streaming
In our earlier versions, Journiv used standard Authorization: Bearer headers to fetch media. While this worked for small images, it hit a wall with video and high-resolution assets, especially on the web.
The Web Limitation: Browser elements like <video> and <img> cannot easily send custom HTTP headers for their source requests.
The Memory Trap: To work around this, our frontend had to "manually" download the file as bytes, store it in RAM, and then convert it to a local blob URL. For a 500MB journal video, this was a recipe for browser crashes and "Out of Memory" errors.
No Seeking: Because we weren't using native streaming, users couldn't scrub through a video. You had to download the whole thing before you could watch the end.
The Solution: HMAC-Signed URLs
We transitioned to Signed URLs. Instead of a static path, the backend now generates a temporary "ticket" appended to the URL as a cryptographic signature.
Signature = HMAC-SHA256(SecretKey, "path:uid:expires_at")By moving the authentication into query parameters (e.g., ?sig=abc&exp=123), we unlocked native streaming. The browser can now use HTTP Range Requests to fetch only the bits of the video it needs for the current playback position. Crucially, we implemented a "Stable Cache Key" system that strips these signatures during caching, ensuring that a signature rotation doesn't force a multi-megabyte re-download of an image already on your device.
Dual-TTL Strategy
Security in a journal is non-negotiable, but so is performance. We implemented a split-expiry system:
Original Media (15m TTL): High-resolution originals and videos are strictly gated. The signature expires quickly, ensuring that even if a link is leaked, it's useless within minutes.
Thumbnails (24h TTL): To keep the UI snappy, thumbnails have a longer life. This allows the Flutter client to leverage local caching (CachedNetworkImage), so your media grid loads instantly across app sessions.
Unifying the World: The Source-Agnostic Media Model
A major milestone was our Immich integration. We didn't want Journiv to be a silo; we wanted it to work seamlessly with your existing self-hosted photo library.
Initially, the frontend had separate code paths for "Internal" and "External" (Immich) media. This created massive technical debt. We solved this by creating a Unified Media Model in the backend.
The frontend now receives a single, consistent JSON object regardless of where the file lives:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "image",
"thumbnail_url": "/api/v1/media/thumbnail?sig=...",
"original_url": "/api/v1/media/original?sig=...",
"origin": {
"source": "immich",
"asset_id": "abc-123"
}
}The Proxy Layer: The Journiv backend acts as a lightweight proxy for Immich. When you request an Immich thumbnail, Journiv signs a proxy URL, fetches the asset from your Immich instance, and streams it to your device.
The Origin Metadata: To keep the UI "dumb" but powerful, we moved integration-specific data (like Immich Asset IDs) into a small origin object. This allows us to show a "View in Immich" deep-link button in the full-screen viewer without cluttering the rest of the app with integration logic.
Portability: Why We Switched to Root-Relative URLs
In a Dockerized, self-hosted world, absolute URLs (e.g., http://192.168.1.50/media/1) are a trap. If you access your NAS via a local IP at home but via a public domain or VPN at a coffee shop, those hardcoded links break.
We refactored the entire system to use root-relative URLs (/api/v1/media/...):
- The backend only ever returns the path and the signature
- The Flutter app prepends its current
BaseURL(which it already knows)
This makes Journiv incredibly portable. You can change your domain, move your NAS, or switch from HTTP to HTTPS, and your media links will never break.
The Challenge: The "Proxy" Bottleneck
When streaming video through Immich integration, we hit two major walls:
State Synchronization: Navigating between editing an entry and viewing it often resulted in stale data errors in Flutter.
Resource Exhaustion: Streaming long high-definition video through a database-backed API was holding database connections hostage, leading to backend timeouts.
The Frontend Solution: Self-Healing Notifiers
We moved away from manual cache invalidation toward a Self-Healing Notifier pattern using Riverpod.
Smart Refresh Logic: Instead of forcing a "hard reload" every time you open an entry, our new provider manages its own lifecycle. It detects if media is in a PROCESSING state and automatically polls the backend every 2 seconds until completion.
Lifecycle-Aware Caching: Mobile apps move between foreground and background constantly. We implemented an AppLifecycleListener that triggers a silent "signature check" whenever you resume the app. If your Immich or NAS signed URLs are near expiration, Journiv refreshes them invisibly in the background so when you use them next they are fresh and ready to use giving a seamless experience.
Viewport Optimization: For users with thousands of entries, loading every media provider at once is a memory disaster. We refactored our MediaGrid into a lazy-loading architecture. Using Flutter's viewport logic, Journiv now only initializes the "heavy" media signing logic for entries currently visible on your screen.
The Backend Solution: Decoupling Data from Streams
On the backend, our FastAPI service faced a "thundering herd" of range requests. Modern video players don't download one file; they request multiple "chunks" (byte ranges) simultaneously to ensure instant seeking.
The Session-Lock Fix: Previously, our streaming logic was wrapped inside a database session block. This meant a single 4K video stream could keep a database connection busy for minutes. We refactored our endpoints to Fetch then Stream:
- Fetch: Open a tiny, microsecond-long DB session to verify ownership and get metadata
- Release: Immediately return the connection to the pool
- Stream: Conduct the heavy proxy operation to Immich or local storage entirely outside the database context
This simple change increased our concurrent streaming capacity by over 10x on standard home-server hardware.
The Hybrid "Self-Healing" Player
Finally, we addressed the UX issue of expired tokens. If you pause a video and come back 20 minutes later, your 15-minute signature has expired.
We built a Hybrid Video Player that wraps the standard Flutter's chewie video player. It includes an "Auto-Refresh" listener: if the native player hits a 403 Forbidden error, the widget silently calls the backend for a fresh signature and resumes playback. To the user, it just works.
Results: Performance by the Numbers
Zero-Flicker Navigation: By using smart cache invalidation, the UI is snappier and more responsive.
HMAC Efficiency: Caching signed URLs on the frontend has reduced redundant backend HMAC calculations by 60% for active users.
NAS Friendly: Improved byte-range handling ensures that your self-hosted hardware only works as hard as it needs to.
What's Next?
These architectural changes have laid the groundwork for even more advanced features, and we're just getting started. We're also transitioning our logging and security middlewares to Pure ASGI, which will allow Journiv to handle client-side cancellations (like when you close a video mid-stream) even more gracefully.
Journiv is built by the community, for the community. Private, performant, and deeply integrated into your digital life. If you're interested in the code behind these changes, head over to our GitHub and check out the latest release.