diff --git a/FLOW_GUIDE.md b/FLOW_GUIDE.md new file mode 100644 index 0000000..52fa4df --- /dev/null +++ b/FLOW_GUIDE.md @@ -0,0 +1,355 @@ +# HLS-Cache — Flow Guide + +A technical reference for understanding how requests move through the library, +how the cache is managed, and which design patterns are applied at each layer. + +--- + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [Request Flow](#request-flow) + - [Cache Hit Path](#cache-hit-path) + - [Cache Miss Path (Segment)](#cache-miss-path-segment) + - [Cache Miss Path (Manifest)](#cache-miss-path-manifest) +3. [Eviction Flow](#eviction-flow) +4. [Design Patterns](#design-patterns) +5. [API Reference](#api-reference) +6. [Debug Logging](#debug-logging) + +--- + +## Architecture Overview + +``` +JavaScript / React Native + │ + │ startServer() / convertUrl() / stopServer() / getCacheStats() ... + ▼ + HybridHlsCache.swift ← Nitro HybridObject — bridges JS ↔ Swift + │ + ▼ + VideoProxyServer.swift ← NWListener on localhost:9000 + │ one handler per TCP connection + ▼ + ClientConnectionHandler.swift ← parses raw HTTP, extracts target URL + Range + │ + ▼ + DataSource.swift ← Cache-Aside router (cache hit → disk, miss → network) + │ │ + ▼ ▼ +VideoCacheStorage.swift NetworkDownloader.swift + (SHA256 file keys) (URLSession + semaphore backpressure) +``` + +**Platform split:** + +| Platform | Proxy | Cache | +|----------|-------|-------| +| iOS | Full TCP proxy in Swift | SHA256-keyed files in `Caches/ExpoVideoCache/` | +| Android | No-op | ExoPlayer/Media3 handles its own cache internally | + +--- + +## Request Flow + +### How a URL reaches the proxy + +``` +1. App calls convertUrl("https://cdn.example.com/video.m3u8") +2. Returns "http://127.0.0.1:9000/proxy?url=https%3A%2F%2Fcdn..." +3. AVPlayer requests the rewritten URL +4. VideoProxyServer accepts the TCP connection +5. ClientConnectionHandler parses the HTTP request +6. DataSource decides: cache hit or miss +``` + +--- + +### Cache Hit Path + +``` +AVPlayer + │ GET /proxy?url= Range: bytes=0-65535 + ▼ +ClientConnectionHandler + │ parseRequest() → extracts URL + byte-range + ▼ +DataSource.start() + │ storage.exists(for: storageKey) → true ✓ + │ + │ [HLSCache] HIT seg001.ts + ▼ +DataSource.serveFileFromDisk() (runs on diskQueue) + │ FileHandle(forReadingFrom: cachedFilePath) + │ reads 64 KB chunks in a loop + ▼ +ClientConnectionHandler.didReceiveHeaders() → sends HTTP/1.1 200 OK (or 206) +ClientConnectionHandler.didReceiveData() → streams chunks to AVPlayer +ClientConnectionHandler.didComplete() → closes connection cleanly +``` + +**Key detail — Range responses:** When a segment was originally fetched with a +`Range` header the cached file contains *exactly* those bytes. On a hit, the +proxy returns `206 Partial Content` with a matching `Content-Range` header so +AVPlayer treats it identically to an online range response. + +--- + +### Cache Miss Path (Segment) + +``` +AVPlayer + │ GET /proxy?url= + ▼ +DataSource.start() + │ storage.exists(for: storageKey) → false ✗ + │ + │ [HLSCache] MISS seg001.ts + ▼ +DataSource.startStreamDownload() + │ + ▼ +NetworkDownloader.download(url:range:delegate:) + │ + │ Priority check: + │ .m3u8 / init.mp4 / small Range → fast lane (bypass semaphore) + │ everything else → slow lane (DispatchSemaphore, max 32) + │ + ▼ +URLSessionDataTask resumes + │ + ├── didReceiveResponse → storage.initializeStreamFile() (creates empty file) + │ ClientConnectionHandler forwards headers to AVPlayer + │ + ├── didReceiveData (×N) → write chunk to FileHandle (disk, async) + │ ClientConnectionHandler forwards chunk to AVPlayer + │ + └── didComplete + ├── error? → delete partial file [HLSCache] FAIL seg001.ts: … + └── ok → close FileHandle [HLSCache] SAVED seg001.ts (342 KB) + ClientConnectionHandler closes TCP connection +``` + +**Concurrent writes are safe:** each `DataSource` owns its own `FileHandle` +behind a `dataLock`. Two simultaneous requests for the same segment each write +to the same path (SHA256-keyed), but `initializeStreamFile` deletes the +previous file first — the second writer wins, which is fine since both copies +are identical. + +--- + +### Cache Miss Path (Manifest) + +Manifests (`.m3u8`) are handled differently because they must be **rewritten** +before being forwarded to AVPlayer. + +``` +AVPlayer + │ GET /proxy?url= + ▼ +DataSource.start() + │ isManifest = true + │ storage.exists(for: storageKey) → false + │ + │ [HLSCache] MISS playlist.m3u8 + ▼ +DataSource.downloadManifest() + │ URLSession.shared.dataTask (bypasses the semaphore queue — priority fast lane) + │ storage.save(data:for:) ← saved to disk as-is (raw m3u8 bytes) + │ + │ [HLSCache] MANIFEST fetched playlist.m3u8 (4 KB) + ▼ +DataSource.rewriteManifest(_:originalUrl:) + │ + │ For each line in the manifest: + │ • relative URL → resolve against originalUrl → absolute URL + │ • absolute URL → percent-encode → wrap as proxy URL + │ http://127.0.0.1:9000/proxy?url= + │ + │ headOnlyCache mode: + │ first N segments → proxied (will be cached) + │ remaining segments → direct (bypasses proxy/cache) + │ + │ URI="…" attributes in tags (e.g. EXT-X-KEY, EXT-X-MAP) → also rewritten + │ + │ [HLSCache] MANIFEST rewritten playlist.m3u8: 6 segments (limit: none) + ▼ +ClientConnectionHandler + │ sends rewritten manifest with accurate Content-Length + ▼ +AVPlayer receives manifest with localhost segment URLs + → subsequent segment requests loop back through the proxy +``` + +**On subsequent requests for the same manifest:** `storage.exists()` returns +`true` so the raw bytes are read from disk and rewritten again — manifests are +never served stale from cache without rewriting, because relative paths must +always be resolved against the current proxy port. + +--- + +## Eviction Flow + +`prune()` runs once, 5 seconds after `startServer()`, on a background queue. +It applies three passes in order: + +``` +prune() + │ + ├── Pass 1 — TTL (skipped if cacheTTLDays == 0) + │ for each file: + │ if (now - modificationDate) > maxAgeSeconds → delete + │ [HLSCache] Prune: deleted N files (X MB freed), reason: TTL + │ + ├── Pass 2 — Disk space guard + │ freeDiskSpace = volumeAvailableCapacityForImportantUsage + │ if freeDiskSpace < 500 MB: + │ [HLSCache] Disk guard: 312 MB free — evicting oldest files + │ delete oldest survivors until freed >= (500 MB - freeDisk) + │ [HLSCache] Prune: deleted N files (X MB freed), reason: disk low + │ + └── Pass 3 — LRU size limit + if totalCacheSize >= maxCacheSize: + delete oldest survivors until totalCacheSize < maxCacheSize + [HLSCache] Prune: deleted N files (X MB freed), reason: size limit +``` + +All three passes share the same oldest-first sorted file list. Failures are +silently ignored — cache maintenance never blocks playback. + +--- + +## Design Patterns + +| Pattern | Implementation | Purpose | +|---------|---------------|---------| +| **Proxy** | `VideoProxyServer` + `ClientConnectionHandler` | Intercept AVPlayer HTTP requests transparently | +| **Cache-Aside (Lazy Loading)** | `DataSource.start()` | Check cache first; fetch from network only on miss | +| **Singleton** | `CacheLogger.shared`, `NetworkDownloader.shared`, JS `_instance` | One shared instance per concern per app lifecycle | +| **Delegate Chain** | `ProxyConnectionDelegate` → `DataSourceDelegate` → `NetworkDownloaderDelegate` | Decouple layers; all delegates are `weak` to prevent retain cycles | +| **Strategy** | `prune()` — TTL / disk guard / LRU passes | Each eviction rule is an independent, swappable strategy | +| **Template Method** | `DataSource.start()` skeleton | Algorithm shape fixed; steps vary by file type (manifest vs segment) | +| **Bulkhead** | `DispatchSemaphore(value: 32)` in `NetworkDownloader` | Isolate heavy downloads; prevent socket exhaustion; manifest fast-lane | +| **Decorator** | `convertUrl()` | Wrap a CDN URL with proxy routing without changing the caller's interface | + +--- + +## API Reference + +### `startServer(port?, maxCacheSize?, headOnlyCache?, cacheTTLDays?, debugLogging?)` + +Starts the local TCP proxy. iOS only — no-op on Android. + +| Parameter | Type | Default | Notes | +|-----------|------|---------|-------| +| `port` | `number` | `9000` | TCP port for the local listener | +| `maxCacheSize` | `number` | `1_073_741_824` | Max disk bytes (1 GB). Pass 0 to disable size limit | +| `headOnlyCache` | `boolean` | `false` | Cache only first 3 segments per stream | +| `cacheTTLDays` | `number` | `2` | Files older than this are deleted on start. Pass 0 to disable TTL | +| `debugLogging` | `boolean` | `false` | Enable `[HLSCache]` stdout logs. **Compiled out in release builds** | + +Throws `409` if a server is already running on a different port. +Throws `500` if the port cannot be bound. + +--- + +### `stopServer()` + +Stops the server and terminates all active connections. Idempotent. + +--- + +### `isServerRunning()` + +Synchronous boolean — `true` if the server is listening. Always `false` on Android. + +--- + +### `convertUrl(url, isCacheable?)` + +Rewrites a remote HLS URL to route through the proxy. **Synchronous.** + +``` +"https://cdn.example.com/video.m3u8" + → "http://127.0.0.1:9000/proxy?url=https%3A%2F%2Fcdn..." +``` + +Returns the original URL unchanged when: +- `isCacheable` is `false` +- The server is not running +- The URL cannot be percent-encoded + +--- + +### `clearCache()` + +Deletes the entire `Caches/ExpoVideoCache/` directory and recreates it empty. + +--- + +### `invalidateUrl(url)` + +Removes the cached file for a single remote URL. Use this to force a fresh +download for one stream without wiping everything. + +The `url` must be the **original remote URL**, not the proxy-rewritten URL. + +--- + +### `getCacheStats()` + +Returns a `CacheStats` snapshot: + +```typescript +{ + fileCount: number; // files currently on disk + totalSizeBytes: number; // combined size of all cached files + freeDiskSpaceBytes: number; // available disk space (system estimate) +} +``` + +Example usage: + +```typescript +const stats = await getCacheStats(); +console.log(`Cache: ${stats.fileCount} files, ${(stats.totalSizeBytes / 1e6).toFixed(1)} MB used`); +``` + +--- + +## Debug Logging + +Enable with `debugLogging: true` in `startServer()`. + +**Release builds:** the `#if DEBUG` guard in `CacheLogger.swift` compiles out +every log statement entirely. The flag is ignored with zero runtime cost. + +**Debug builds:** logs are written to stdout with the `[HLSCache]` prefix, +readable in Xcode console or via `xcrun simctl` log stream. + +### Log reference + +| Prefix | Meaning | +|--------|---------| +| `HIT ` | Served from disk cache | +| `MISS ` | Not in cache — fetching from network | +| `SAVED (N KB)` | Segment written to disk after download | +| `FAIL : ` | Network error — partial file deleted | +| `MANIFEST fetched (N KB)` | Raw manifest downloaded and saved | +| `MANIFEST rewritten : N segments (limit: X)` | Manifest URLs rewritten for proxy routing | +| `Prune: deleted N files (X MB freed), reason: TTL` | TTL pass eviction | +| `Disk guard: N MB free — evicting oldest files` | Disk space pass triggered | +| `Prune: deleted N files (X MB freed), reason: disk low` | Disk space pass eviction | +| `Prune: deleted N files (X MB freed), reason: size limit` | LRU size-limit pass eviction | +| `New connection (active: N)` | TCP connection accepted | +| `Server started on port N (TTL: Xd)` | Server listener bound | +| `Server stopped (port N)` | Server shut down | +| `getCacheStats(): N files, X MB used, Y MB free` | Stats query | +| `invalidateUrl(): ` | Single URL cache invalidated | +| `clearCache(): all files removed` | Full cache wipe | + +--- + +> **Note:** After any change to `src/HlsCache.nitro.ts`, run `yarn nitrogen` +> to regenerate the Nitro bridge boilerplate (C++ / Kotlin / Swift specs). diff --git a/android/src/main/java/com/margelo/nitro/hlscache/HlsCache.kt b/android/src/main/java/com/margelo/nitro/hlscache/HlsCache.kt index b897e84..77dd451 100644 --- a/android/src/main/java/com/margelo/nitro/hlscache/HlsCache.kt +++ b/android/src/main/java/com/margelo/nitro/hlscache/HlsCache.kt @@ -10,10 +10,17 @@ class HlsCache : HybridHlsCacheSpec() { // Android uses ExoPlayer/Media3 native caching — no proxy needed. - override fun startServer(port: Double?, maxCacheSize: Double?, headOnlyCache: Boolean?): Promise { + override fun startServer(port: Double?, maxCacheSize: Double?, headOnlyCache: Boolean?, cacheTTLDays: Double?, debugLogging: Boolean?): Promise { return Promise.resolved(Unit) } + override fun stopServer(): Promise { + return Promise.resolved(Unit) + } + + override val isRunning: Boolean + get() = false + override fun convertUrl(url: String, isCacheable: Boolean?): String { return url } @@ -21,4 +28,12 @@ class HlsCache : HybridHlsCacheSpec() { override fun clearCache(): Promise { return Promise.resolved(Unit) } + + override fun invalidateUrl(url: String): Promise { + return Promise.resolved(Unit) + } + + override fun getCacheStats(): Promise { + return Promise.resolved(CacheStats(fileCount = 0.0, totalSizeBytes = 0.0, freeDiskSpaceBytes = 0.0)) + } } diff --git a/ios/CacheLogger.swift b/ios/CacheLogger.swift new file mode 100644 index 0000000..c811479 --- /dev/null +++ b/ios/CacheLogger.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Centralised debug logger for HLS-Cache. +/// +/// Logging is gated by two independent conditions that must **both** be true: +/// +/// 1. **DEBUG build** — the `#if DEBUG` guard compiles every log statement out +/// of release binaries entirely. No strings leak into App Store builds and +/// there is zero runtime overhead. +/// +/// 2. **JS opt-in** — the host app must pass `debugLogging: true` to +/// `startServer()`. Defaults to `false` so existing integrations are silent +/// until explicitly enabled. +internal final class CacheLogger { + + // MARK: - Shared instance + + static let shared = CacheLogger() + private init() {} + + // MARK: - State + + private var enabled: Bool = false + + // MARK: - Configuration + + /// Called once from `startServer()`. + /// + /// In release builds this is a no-op — the compiler strips the body. + func configure(enabled: Bool) { + #if DEBUG + self.enabled = enabled + #endif + } + + // MARK: - Logging + + /// Emits a tagged log line to stdout. + /// + /// Compiled out **entirely** in release builds — no overhead, no binary bloat. + func log(_ message: String) { + #if DEBUG + guard enabled else { return } + print("[HLSCache] \(message)") + #endif + } +} diff --git a/ios/DataSource.swift b/ios/DataSource.swift index cc0ad93..c4a8288 100644 --- a/ios/DataSource.swift +++ b/ios/DataSource.swift @@ -93,6 +93,7 @@ internal final class DataSource: NetworkDownloaderDelegate { /// Checks the disk cache first; if the data is missing, initiates a network download. func start() { if storage.exists(for: storageKey) { + CacheLogger.shared.log("HIT \(url.lastPathComponent)") if isManifest { serveManifestFromCache() } else { @@ -100,7 +101,9 @@ internal final class DataSource: NetworkDownloaderDelegate { } return } - + + CacheLogger.shared.log("MISS \(url.lastPathComponent)") + if isManifest { downloadManifest() } else { @@ -219,14 +222,19 @@ internal final class DataSource: NetworkDownloaderDelegate { func didComplete(task: NetworkTask, error: Error?) { closeFileHandle() - - if error != nil { + + if let error = error { if storage.exists(for: storageKey) { storage.delete(for: storageKey) } + CacheLogger.shared.log("FAIL \(url.lastPathComponent): \(error.localizedDescription)") + delegate?.didComplete(error: error) + } else { + let filePath = storage.getFilePath(for: storageKey).path + let fileSize = (try? FileManager.default.attributesOfItem(atPath: filePath)[.size] as? UInt64) ?? 0 + CacheLogger.shared.log("SAVED \(url.lastPathComponent) (\(fileSize / 1024) KB)") + delegate?.didComplete(error: nil) } - - delegate?.didComplete(error: error) } // MARK: - Manifest Handling @@ -247,6 +255,7 @@ internal final class DataSource: NetworkDownloaderDelegate { return } self.storage.save(data: data, for: self.storageKey) + CacheLogger.shared.log("MANIFEST fetched \(self.url.lastPathComponent) (\(data.count / 1024) KB)") self.sendRewrittenManifest(content) } task.resume() @@ -311,7 +320,11 @@ internal final class DataSource: NetworkDownloaderDelegate { segmentCount += 1 } } - return rewritten.joined(separator: "\n") + let rewrittenContent = rewritten.joined(separator: "\n") + CacheLogger.shared.log( + "MANIFEST rewritten \(originalUrl.lastPathComponent): \(segmentCount) segments (limit: \(segmentLimit == 0 ? "none" : "\(segmentLimit)"))" + ) + return rewrittenContent } private func rewriteLineToProxy(line: String, originalUrl: URL) -> String { diff --git a/ios/HlsCache.swift b/ios/HlsCache.swift index aad17e7..7e5db54 100644 --- a/ios/HlsCache.swift +++ b/ios/HlsCache.swift @@ -13,7 +13,7 @@ public class HybridHlsCache: HybridHlsCacheSpec { private var proxyServer: VideoProxyServer? private var activePort: Int = 9000 - // MARK: - HybridHlsCacheSpec + // MARK: - Lifecycle /// Starts the local TCP proxy server. /// @@ -21,9 +21,14 @@ public class HybridHlsCache: HybridHlsCacheSpec { /// - port: Local port to bind. Defaults to 9000. /// - maxCacheSize: Max disk cache size in bytes. Defaults to 1 GB. /// - headOnlyCache: If true, only caches the first ~3 segments per video. - public func startServer(port: Double?, maxCacheSize: Double?, headOnlyCache: Bool?) throws -> Promise { + /// - cacheTTLDays: Segments older than this many days are evicted on start. Defaults to 2. + /// - debugLogging: Emit `[HLSCache]` log lines to stdout. Ignored in release builds. + public func startServer(port: Double?, maxCacheSize: Double?, headOnlyCache: Bool?, cacheTTLDays: Double?, debugLogging: Bool?) throws -> Promise { let cacheLimit = maxCacheSize.map { Int($0) } ?? 1_073_741_824 let targetPort = port.map { Int($0) } ?? 9000 + let ttlDays = cacheTTLDays ?? 2.0 + + CacheLogger.shared.configure(enabled: debugLogging ?? false) return Promise.async { self.stateLock.lock() @@ -43,7 +48,8 @@ public class HybridHlsCache: HybridHlsCacheSpec { let newServer = VideoProxyServer( port: targetPort, maxCacheSize: cacheLimit, - headOnlyCache: headOnlyCache ?? false + headOnlyCache: headOnlyCache ?? false, + cacheTTLDays: ttlDays ) do { @@ -62,6 +68,32 @@ public class HybridHlsCache: HybridHlsCacheSpec { } } + /// Stops the proxy server and terminates all active connections. + /// + /// Safe to call when the server is already stopped — it is a no-op in that case. + public func stopServer() throws -> Promise { + return Promise.async { + self.stateLock.lock() + let server = self.proxyServer + self.stateLock.unlock() + + server?.stop() + + CacheLogger.shared.log("stopServer() called from JS") + } + } + + /// Whether the proxy server is currently accepting connections. + /// + /// Thread-safe. Always `false` before `startServer()` is called. + public var isRunning: Bool { + stateLock.lock() + defer { stateLock.unlock() } + return proxyServer?.isRunning ?? false + } + + // MARK: - URL helpers + /// Synchronously rewrites a remote URL to route through the local proxy. /// /// Returns the original URL unchanged if: @@ -92,6 +124,8 @@ public class HybridHlsCache: HybridHlsCacheSpec { return "http://127.0.0.1:\(port)/proxy?url=\(encoded)" } + // MARK: - Cache management + /// Purges all cached video files from disk. public func clearCache() throws -> Promise { return Promise.async { @@ -104,6 +138,48 @@ public class HybridHlsCache: HybridHlsCacheSpec { } else { VideoCacheStorage(maxCacheSize: 0).clearAll() } + + CacheLogger.shared.log("clearCache(): all files removed") + } + } + + /// Removes the cached file for a specific remote URL. + /// + /// Use this to force a fresh network fetch for a single stream without + /// wiping the entire cache. The `url` parameter must be the **original** + /// remote URL, not the rewritten proxy URL. + public func invalidateUrl(url: String) throws -> Promise { + return Promise.async { + self.stateLock.lock() + let server = self.proxyServer + self.stateLock.unlock() + + let storage = server?.storage ?? VideoCacheStorage(maxCacheSize: 0) + storage.delete(for: url) + + CacheLogger.shared.log("invalidateUrl(): \(URL(string: url)?.lastPathComponent ?? url)") + } + } + + /// Returns a lightweight snapshot of the current disk cache state. + public func getCacheStats() throws -> Promise { + return Promise.async { + self.stateLock.lock() + let server = self.proxyServer + self.stateLock.unlock() + + let storage = server?.storage ?? VideoCacheStorage(maxCacheSize: 0) + let (fileCount, totalSizeBytes, freeDiskSpaceBytes) = storage.getStats() + + CacheLogger.shared.log( + "getCacheStats(): \(fileCount) files, \(totalSizeBytes / 1_048_576) MB used, \(freeDiskSpaceBytes / 1_048_576) MB free" + ) + + return CacheStats( + fileCount: Double(fileCount), + totalSizeBytes: Double(totalSizeBytes), + freeDiskSpaceBytes: Double(freeDiskSpaceBytes) + ) } } } diff --git a/ios/VideoCacheStorage.swift b/ios/VideoCacheStorage.swift index 59a567b..fe709a5 100644 --- a/ios/VideoCacheStorage.swift +++ b/ios/VideoCacheStorage.swift @@ -16,36 +16,51 @@ import CryptoKit internal final class VideoCacheStorage { // MARK: - Properties - + /// The file manager instance used for all filesystem operations. private let fileManager = FileManager.default - + /// The maximum allowed size of the cache, in bytes. /// - /// When the total size of cached files exceeds this limit, the cache + /// When the total size of cached files exceeds this limit the cache /// is pruned using an LRU (Least Recently Used) strategy. private let maxCacheSize: Int - + + /// Maximum age of a cached file in seconds. + /// + /// Files older than this threshold are removed during `prune()`. + /// A value of `0` disables TTL eviction. + private let maxAgeSeconds: TimeInterval + + /// Free-disk-space threshold in bytes. + /// + /// When the device reports less free space than this value, `prune()` will + /// evict the oldest cached files until the threshold is satisfied. + private let diskSpaceThreshold: Int = 500 * 1_048_576 // 500 MB + /// The root directory where all cached video files are stored. /// /// This directory is created inside the system Caches directory and /// is guaranteed to exist after initialization. private let cacheDirectory: URL - + // MARK: - Initialization - + /// Initializes a new video cache storage manager. /// - /// During initialization, the cache directory is created if it does not + /// During initialization the cache directory is created if it does not /// already exist. /// - /// - Parameter maxCacheSize: The maximum allowed size of the cache in bytes. - init(maxCacheSize: Int) { + /// - Parameters: + /// - maxCacheSize: Maximum allowed size of the cache in bytes. + /// - cacheTTLDays: Files older than this many days are evicted during `prune()`. Pass `0` to disable. Defaults to `2`. + init(maxCacheSize: Int, cacheTTLDays: Double = 2.0) { self.maxCacheSize = maxCacheSize - + self.maxAgeSeconds = cacheTTLDays > 0 ? cacheTTLDays * 86_400 : 0 + let paths = fileManager.urls(for: .cachesDirectory, in: .userDomainMask) self.cacheDirectory = paths[0].appendingPathComponent("ExpoVideoCache") - + if !fileManager.fileExists(atPath: cacheDirectory.path) { try? fileManager.createDirectory( at: cacheDirectory, @@ -183,53 +198,161 @@ internal final class VideoCacheStorage { } // MARK: - Maintenance - - /// Prunes the cache to enforce the configured size limit. + + /// Prunes the cache using a three-pass eviction strategy. /// - /// This method removes the least recently modified files first until - /// the total cache size is within the allowed limit. + /// **Pass 1 — TTL:** deletes files older than `maxAgeSeconds` (skipped when `cacheTTLDays == 0`). + /// **Pass 2 — Disk guard:** if free disk space is below 500 MB, evicts oldest files until the + /// threshold is satisfied. + /// **Pass 3 — Size limit:** LRU eviction until total cache size is within `maxCacheSize`. /// - /// All failures are silently ignored to ensure that cache maintenance - /// never interferes with normal application execution. + /// All failures are silently ignored so that cache maintenance never interrupts playback. func prune() { - let keys: [URLResourceKey] = [ - .fileSizeKey, - .contentModificationDateKey - ] - + let keys: [URLResourceKey] = [.fileSizeKey, .contentModificationDateKey] + let now = Date() + do { let fileUrls = try fileManager.contentsOfDirectory( at: cacheDirectory, includingPropertiesForKeys: keys, options: [] ) - + var totalSize = 0 var files: [(url: URL, size: Int, date: Date)] = [] - + for url in fileUrls { - let values = try url.resourceValues(forKeys: Set(keys)) - if let size = values.fileSize, - let date = values.contentModificationDate { - totalSize += size - files.append((url, size, date)) - } + guard let values = try? url.resourceValues(forKeys: Set(keys)), + let size = values.fileSize, + let date = values.contentModificationDate else { continue } + totalSize += size + files.append((url, size, date)) } - - guard totalSize >= maxCacheSize else { return } - + + // Sort oldest-first — shared ordering for all three passes. files.sort { $0.date < $1.date } - + + // --- Pass 1: TTL --- + if maxAgeSeconds > 0 { + var ttlCount = 0 + var ttlBytes = 0 + var survivors: [(url: URL, size: Int, date: Date)] = [] + + for file in files { + if now.timeIntervalSince(file.date) > maxAgeSeconds { + try? fileManager.removeItem(at: file.url) + totalSize -= file.size + ttlCount += 1 + ttlBytes += file.size + } else { + survivors.append(file) + } + } + + files = survivors + + if ttlCount > 0 { + CacheLogger.shared.log( + "Prune: deleted \(ttlCount) files (\(ttlBytes / 1_048_576) MB freed), reason: TTL" + ) + } + } + + // --- Pass 2: Disk space guard (500 MB threshold) --- + let freeDisk = freeDiskSpace() + if freeDisk < diskSpaceThreshold { + var diskCount = 0 + var diskBytes = 0 + var survivors: [(url: URL, size: Int, date: Date)] = [] + var freed = 0 + + CacheLogger.shared.log( + "Disk guard: \(freeDisk / 1_048_576) MB free — evicting oldest files" + ) + + for file in files { + if freeDisk + freed < diskSpaceThreshold { + try? fileManager.removeItem(at: file.url) + totalSize -= file.size + freed += file.size + diskCount += 1 + diskBytes += file.size + } else { + survivors.append(file) + } + } + + files = survivors + + if diskCount > 0 { + CacheLogger.shared.log( + "Prune: deleted \(diskCount) files (\(diskBytes / 1_048_576) MB freed), reason: disk low" + ) + } + } + + // --- Pass 3: maxCacheSize LRU --- + guard maxCacheSize > 0, totalSize >= maxCacheSize else { return } + + var lruCount = 0 + var lruBytes = 0 + for file in files { try? fileManager.removeItem(at: file.url) totalSize -= file.size - if totalSize < maxCacheSize { - break - } + lruCount += 1 + lruBytes += file.size + if totalSize < maxCacheSize { break } + } + + if lruCount > 0 { + CacheLogger.shared.log( + "Prune: deleted \(lruCount) files (\(lruBytes / 1_048_576) MB freed), reason: size limit" + ) } - + } catch { // Intentionally ignored } } + + // MARK: - Stats + + /// Returns a lightweight snapshot of the cache directory state. + /// + /// - Returns: A tuple with the file count, total cached bytes, and estimated free disk space. + /// Returns `(0, 0, Int.max)` if the directory cannot be read. + func getStats() -> (fileCount: Int, totalSizeBytes: Int, freeDiskSpaceBytes: Int) { + let keys: [URLResourceKey] = [.fileSizeKey] + + guard let urls = try? fileManager.contentsOfDirectory( + at: cacheDirectory, + includingPropertiesForKeys: keys, + options: [] + ) else { + return (0, 0, freeDiskSpace()) + } + + var totalSize = 0 + for url in urls { + if let values = try? url.resourceValues(forKeys: Set(keys)), + let size = values.fileSize { + totalSize += size + } + } + + return (urls.count, totalSize, freeDiskSpace()) + } + + // MARK: - Helpers + + /// Returns the estimated available disk space in bytes, or `Int.max` if the query fails. + private func freeDiskSpace() -> Int { + guard let values = try? URL(fileURLWithPath: NSHomeDirectory()) + .resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]), + let capacity = values.volumeAvailableCapacityForImportantUsage else { + return Int.max + } + return Int(capacity) + } } \ No newline at end of file diff --git a/ios/VideoProxyServer.swift b/ios/VideoProxyServer.swift index 58f4515..e66259b 100644 --- a/ios/VideoProxyServer.swift +++ b/ios/VideoProxyServer.swift @@ -25,7 +25,7 @@ internal final class VideoProxyServer: ProxyConnectionDelegate { private var listener: NWListener? /// Disk-backed storage used for caching video data. - private let storage: VideoCacheStorage + internal let storage: VideoCacheStorage /// The local port on which the server listens for incoming connections. internal let port: Int @@ -44,6 +44,9 @@ internal final class VideoProxyServer: ProxyConnectionDelegate { // Internal constant: How many segments to cache when headOnlyCache is true. private let HEAD_SEGMENT_LIMIT = 3 + + /// TTL in days forwarded to VideoCacheStorage. + private let cacheTTLDays: Double /// Indicates whether the server is currently running. /// @@ -60,10 +63,12 @@ internal final class VideoProxyServer: ProxyConnectionDelegate { /// - port: The local TCP port to bind the listener to. /// - maxCacheSize: The maximum allowed size of the disk cache, in bytes. /// - headOnlyCache: If true, only the first few segments of each video are cached. - init(port: Int, maxCacheSize: Int, headOnlyCache: Bool = false) { + /// - cacheTTLDays: Files older than this many days are evicted on start. Defaults to 2. + init(port: Int, maxCacheSize: Int, headOnlyCache: Bool = false, cacheTTLDays: Double = 2.0) { self.port = port - self.storage = VideoCacheStorage(maxCacheSize: maxCacheSize) + self.storage = VideoCacheStorage(maxCacheSize: maxCacheSize, cacheTTLDays: cacheTTLDays) self.headOnlyCache = headOnlyCache + self.cacheTTLDays = cacheTTLDays } /// Starts the TCP server and begins accepting incoming connections. @@ -109,7 +114,9 @@ internal final class VideoProxyServer: ProxyConnectionDelegate { listener.start(queue: .global(qos: .userInitiated)) self.listener = listener self._isRunning = true - + + CacheLogger.shared.log("Server started on port \(port) (TTL: \(cacheTTLDays)d)") + DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 5.0) { [weak self] in self?.storage.prune() } @@ -129,12 +136,13 @@ internal final class VideoProxyServer: ProxyConnectionDelegate { _isRunning = false listener?.cancel() listener = nil - + let handlersToStop = activeHandlers.values activeHandlers.removeAll() - + serverLock.unlock() - + + CacheLogger.shared.log("Server stopped (port \(port))") handlersToStop.forEach { $0.stop() } } @@ -163,6 +171,10 @@ internal final class VideoProxyServer: ProxyConnectionDelegate { serverLock.unlock() if shouldStart { + serverLock.lock() + let count = activeHandlers.count + serverLock.unlock() + CacheLogger.shared.log("New connection (active: \(count))") handler.start() } else { connection.cancel() diff --git a/src/HlsCache.nitro.ts b/src/HlsCache.nitro.ts index f9ffd5f..3fb8fcb 100644 --- a/src/HlsCache.nitro.ts +++ b/src/HlsCache.nitro.ts @@ -1,12 +1,36 @@ import type { HybridObject } from 'react-native-nitro-modules'; +/** + * Snapshot of the current disk cache state. + * Returned by `getCacheStats()`. + */ +export interface CacheStats { + /** Number of files currently on disk. */ + fileCount: number; + /** Total size of all cached files, in bytes. */ + totalSizeBytes: number; + /** Estimated free disk space available to the app, in bytes. */ + freeDiskSpaceBytes: number; +} + export interface HlsCache extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> { + // ── Lifecycle ──────────────────────────────────────────────────────────── startServer( port?: number, maxCacheSize?: number, - headOnlyCache?: boolean + headOnlyCache?: boolean, + cacheTTLDays?: number, + debugLogging?: boolean ): Promise; + stopServer(): Promise; + readonly isRunning: boolean; + + // ── URL helpers ─────────────────────────────────────────────────────────── convertUrl(url: string, isCacheable?: boolean): string; + + // ── Cache management ────────────────────────────────────────────────────── clearCache(): Promise; + invalidateUrl(url: string): Promise; + getCacheStats(): Promise; } diff --git a/src/index.tsx b/src/index.tsx index fcff659..e921af7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,8 @@ import { NitroModules } from 'react-native-nitro-modules'; import { Platform } from 'react-native'; -import type { HlsCache } from './HlsCache.nitro'; +import type { HlsCache, CacheStats } from './HlsCache.nitro'; + +export type { CacheStats }; let _instance: HlsCache | null = null; @@ -11,27 +13,53 @@ function getInstance(): HlsCache { return _instance; } +// ── Lifecycle ────────────────────────────────────────────────────────────────── + /** * Starts the local HLS proxy server. * - * @param port - Local port to bind. Defaults to 9000. - * @param maxCacheSize - Max disk cache size in bytes. Defaults to 1 GB. - * @param headOnlyCache - If true, only caches the first ~3 segments per video (optimised for vertical feeds). + * @param port - Local port to bind. Defaults to 9000. + * @param maxCacheSize - Max disk cache size in bytes. Defaults to 1 GB. + * @param headOnlyCache - If true, only caches the first ~3 segments per video. + * @param cacheTTLDays - Files older than this many days are evicted on start. Defaults to 2. + * @param debugLogging - Emit `[HLSCache]` log lines to stdout. Ignored in release builds. */ export function startServer( port?: number, maxCacheSize?: number, - headOnlyCache?: boolean + headOnlyCache?: boolean, + cacheTTLDays?: number, + debugLogging?: boolean ): Promise { if (Platform.OS !== 'ios') return Promise.resolve(); - return getInstance().startServer(port, maxCacheSize, headOnlyCache); + return getInstance().startServer(port, maxCacheSize, headOnlyCache, cacheTTLDays, debugLogging); +} + +/** + * Stops the local proxy server and terminates all active connections. + * Safe to call even if the server is not running. + */ +export function stopServer(): Promise { + if (Platform.OS !== 'ios') return Promise.resolve(); + return getInstance().stopServer(); +} + +/** + * Returns `true` if the proxy server is currently listening for connections. + * Always `false` on Android/Web. + */ +export function isServerRunning(): boolean { + if (Platform.OS !== 'ios') return false; + return getInstance().isRunning; } +// ── URL helpers ──────────────────────────────────────────────────────────────── + /** * Rewrites a remote URL to route through the local proxy (iOS only). * Returns the original URL unchanged on Android/Web or when the server is not running. * - * @param url - Remote HLS URL (e.g. `https://cdn.example.com/video.m3u8`). + * @param url - Remote HLS URL (e.g. `https://cdn.example.com/video.m3u8`). * @param isCacheable - Set to `false` to bypass the proxy. Defaults to `true`. */ export function convertUrl(url: string, isCacheable?: boolean): string { @@ -39,6 +67,8 @@ export function convertUrl(url: string, isCacheable?: boolean): string { return getInstance().convertUrl(url, isCacheable); } +// ── Cache management ─────────────────────────────────────────────────────────── + /** * Purges all cached video files from disk. */ @@ -46,3 +76,24 @@ export function clearCache(): Promise { if (Platform.OS !== 'ios') return Promise.resolve(); return getInstance().clearCache(); } + +/** + * Removes the cached file(s) for a specific remote URL. + * Use this to force a fresh download for a single stream without clearing everything. + * + * @param url - The original remote HLS URL (not the proxy URL). + */ +export function invalidateUrl(url: string): Promise { + if (Platform.OS !== 'ios') return Promise.resolve(); + return getInstance().invalidateUrl(url); +} + +/** + * Returns a snapshot of the current disk cache state: + * file count, total size in bytes, and free disk space in bytes. + */ +export function getCacheStats(): Promise { + if (Platform.OS !== 'ios') + return Promise.resolve({ fileCount: 0, totalSizeBytes: 0, freeDiskSpaceBytes: 0 }); + return getInstance().getCacheStats(); +}