Add iOS app with CarPlay and Siri
SwiftUI port of the Android app under ios/, with CarPlay browse and Now Playing, Siri play-media intents, and a build-ipa.sh script that makes an unsigned IPA for sideloading. README gets an iOS section and the vibecoded disclaimer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
45bc0eefe0
commit
fdf9e970b2
45 files changed
+9822
No files matched your search
@@ -0,0 +1,572 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct CacheMeta: Codable {
|
||||
var length: Int64 = -1
|
||||
var bits = ""
|
||||
var pinned = false
|
||||
var access: Int64 = 0
|
||||
}
|
||||
|
||||
struct MediaError: LocalizedError {
|
||||
let message: String
|
||||
init(_ m: String) { message = m }
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
/// A growable bit set, persisted as base64 of its little-endian bytes.
|
||||
struct Bits {
|
||||
private(set) var words: [UInt64] = []
|
||||
private(set) var count = 0
|
||||
|
||||
init() {}
|
||||
init(base64: String) {
|
||||
guard let d = Data(base64Encoded: base64) else { return }
|
||||
words = [UInt64](repeating: 0, count: (d.count + 7) / 8)
|
||||
for (i, byte) in d.enumerated() { words[i / 8] |= UInt64(byte) << UInt64((i % 8) * 8) }
|
||||
count = words.reduce(0) { $0 + $1.nonzeroBitCount }
|
||||
}
|
||||
|
||||
subscript(i: Int) -> Bool {
|
||||
let w = i >> 6
|
||||
return w < words.count && words[w] & (1 << UInt64(i & 63)) != 0
|
||||
}
|
||||
|
||||
mutating func set(_ i: Int) {
|
||||
let w = i >> 6
|
||||
if w >= words.count { words += [UInt64](repeating: 0, count: w - words.count + 1) }
|
||||
let m: UInt64 = 1 << UInt64(i & 63)
|
||||
if words[w] & m == 0 { words[w] |= m; count += 1 }
|
||||
}
|
||||
|
||||
mutating func clear(_ i: Int) {
|
||||
let w = i >> 6
|
||||
guard w < words.count else { return }
|
||||
let m: UInt64 = 1 << UInt64(i & 63)
|
||||
if words[w] & m != 0 { words[w] &= ~m; count -= 1 }
|
||||
}
|
||||
|
||||
/// Clears every bit at or after [from].
|
||||
mutating func truncate(_ from: Int) {
|
||||
var i = from
|
||||
while i < words.count * 64 { clear(i); i += 1 }
|
||||
}
|
||||
|
||||
func nextClear(_ from: Int) -> Int {
|
||||
var i = max(0, from)
|
||||
while self[i] { i += 1 }
|
||||
return i
|
||||
}
|
||||
|
||||
var base64: String {
|
||||
var bytes = [UInt8]()
|
||||
bytes.reserveCapacity(words.count * 8)
|
||||
for w in words { for b in 0..<8 { bytes.append(UInt8((w >> UInt64(b * 8)) & 0xFF)) } }
|
||||
while bytes.last == 0 { bytes.removeLast() }
|
||||
return Data(bytes).base64EncodedString()
|
||||
}
|
||||
}
|
||||
|
||||
/// Disk cache for stems and original tracks. Each resource is a sparse file filled in
|
||||
/// BLOCK-sized pieces with HTTP range requests: reads block only on the bytes they need,
|
||||
/// a background prefetcher fills the rest in order, and seeks (FLAC without a seek table
|
||||
/// bisects the file) fetch out of order. Pinned resources are downloads and never evicted.
|
||||
@Observable
|
||||
final class MediaCache: @unchecked Sendable {
|
||||
static let block = 64 * 1024
|
||||
static let onDemandRun = 8
|
||||
static let prefetchRun = 32
|
||||
|
||||
struct Usage: Equatable { var cachedBytes: Int64; var pinnedBytes: Int64 }
|
||||
private final class CatalogEntry { var bytes: Int64; var access: Int64; var pinned: Bool
|
||||
init(_ b: Int64, _ a: Int64, _ p: Bool) { bytes = b; access = a; pinned = p } }
|
||||
|
||||
@ObservationIgnored private let dir: URL
|
||||
@ObservationIgnored let session: URLSession
|
||||
@ObservationIgnored private let limitBytes: () -> Int64
|
||||
@ObservationIgnored private let lock = NSRecursiveLock()
|
||||
@ObservationIgnored private var open: [String: CachedResource] = [:]
|
||||
@ObservationIgnored private var refs: [String: Int] = [:]
|
||||
@ObservationIgnored private var catalog: [String: CatalogEntry] = [:]
|
||||
@ObservationIgnored private let prefetchQueue: OperationQueue = {
|
||||
let q = OperationQueue()
|
||||
q.maxConcurrentOperationCount = 6
|
||||
q.qualityOfService = .utility
|
||||
return q
|
||||
}()
|
||||
@ObservationIgnored private let trimQueue = DispatchQueue(label: "media-trim", qos: .utility)
|
||||
@ObservationIgnored private var trimQueued = false
|
||||
|
||||
private(set) var usage = Usage(cachedBytes: 0, pinnedBytes: 0)
|
||||
|
||||
init(dir: URL, session: URLSession, limitBytes: @escaping () -> Int64) {
|
||||
self.dir = dir
|
||||
self.session = session
|
||||
self.limitBytes = limitBytes
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
trimQueue.async { [self] in
|
||||
let files = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? []
|
||||
for name in files where name.hasSuffix(".meta") {
|
||||
guard let m = readMeta(dir.appendingPathComponent(name)) else { continue }
|
||||
let id = String(name.dropLast(5))
|
||||
lock.lock()
|
||||
if catalog[id] == nil {
|
||||
catalog[id] = CatalogEntry(Int64(Bits(base64: m.bits).count) * Int64(Self.block), m.access, m.pinned)
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
publishUsage()
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens (or reuses) a resource; pair every call with [release].
|
||||
func acquire(_ key: String, url: @escaping () -> URL?) -> CachedResource {
|
||||
let id = Self.idFor(key)
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
refs[id, default: 0] += 1
|
||||
if let r = open[id] { return r }
|
||||
let r = CachedResource(id: id, dataFile: dir.appendingPathComponent("\(id).data"),
|
||||
metaFile: dir.appendingPathComponent("\(id).meta"), url: url, cache: self)
|
||||
open[id] = r
|
||||
return r
|
||||
}
|
||||
|
||||
func release(_ res: CachedResource) {
|
||||
lock.lock()
|
||||
let n = (refs[res.id] ?? 1) - 1
|
||||
if n > 0 {
|
||||
refs[res.id] = n
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
refs[res.id] = nil
|
||||
open[res.id] = nil
|
||||
lock.unlock()
|
||||
res.close()
|
||||
scheduleTrim()
|
||||
}
|
||||
|
||||
func isComplete(_ key: String) -> Bool {
|
||||
let id = Self.idFor(key)
|
||||
lock.lock()
|
||||
if let r = open[id] { lock.unlock(); return r.isComplete }
|
||||
lock.unlock()
|
||||
guard let m = readMeta(dir.appendingPathComponent("\(id).meta")) else { return false }
|
||||
return m.length >= 0 && Bits(base64: m.bits).count >= Self.blocksFor(m.length)
|
||||
&& FileManager.default.fileExists(atPath: dir.appendingPathComponent("\(id).data").path)
|
||||
}
|
||||
|
||||
func isPinned(_ key: String) -> Bool {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return catalog[Self.idFor(key)]?.pinned == true
|
||||
}
|
||||
|
||||
/// Unpins a download so normal eviction can reclaim it.
|
||||
func unpin(_ key: String) {
|
||||
let id = Self.idFor(key)
|
||||
lock.lock()
|
||||
let r = open[id]
|
||||
lock.unlock()
|
||||
if let r { r.pinned = false; return }
|
||||
let f = dir.appendingPathComponent("\(id).meta")
|
||||
guard var m = readMeta(f) else { return }
|
||||
m.pinned = false
|
||||
writeMeta(f, m)
|
||||
lock.lock()
|
||||
catalog[id]?.pinned = false
|
||||
lock.unlock()
|
||||
publishUsage()
|
||||
scheduleTrim()
|
||||
}
|
||||
|
||||
/// Deletes every resource that isn't pinned or in use.
|
||||
func clearUnpinned() {
|
||||
trimQueue.async { self.evict(keepBytes: 0) }
|
||||
}
|
||||
|
||||
func submitPrefetch(_ task: @escaping () -> Void) { prefetchQueue.addOperation(task) }
|
||||
|
||||
func update(_ id: String, bytes: Int64, access: Int64, pinned: Bool) {
|
||||
lock.lock()
|
||||
let e = catalog[id] ?? CatalogEntry(0, 0, false)
|
||||
catalog[id] = e
|
||||
e.bytes = bytes; e.access = access; e.pinned = pinned
|
||||
lock.unlock()
|
||||
publishUsage()
|
||||
}
|
||||
|
||||
private func publishUsage() {
|
||||
lock.lock()
|
||||
var all: Int64 = 0
|
||||
var pinned: Int64 = 0
|
||||
for e in catalog.values {
|
||||
all += e.bytes
|
||||
if e.pinned { pinned += e.bytes }
|
||||
}
|
||||
lock.unlock()
|
||||
let u = Usage(cachedBytes: all, pinnedBytes: pinned)
|
||||
DispatchQueue.main.async { if self.usage != u { self.usage = u } }
|
||||
}
|
||||
|
||||
func scheduleTrim() {
|
||||
lock.lock()
|
||||
if trimQueued { lock.unlock(); return }
|
||||
trimQueued = true
|
||||
lock.unlock()
|
||||
trimQueue.async { [self] in
|
||||
lock.lock(); trimQueued = false; lock.unlock()
|
||||
evict(keepBytes: limitBytes())
|
||||
}
|
||||
}
|
||||
|
||||
private func evict(keepBytes: Int64) {
|
||||
lock.lock()
|
||||
var total = catalog.values.reduce(Int64(0)) { $0 + $1.bytes }
|
||||
var victims: [String] = []
|
||||
for (id, e) in catalog.sorted(by: { $0.value.access < $1.value.access }) {
|
||||
if total <= keepBytes { break }
|
||||
if e.pinned || open[id] != nil { continue }
|
||||
victims.append(id)
|
||||
total -= e.bytes
|
||||
}
|
||||
victims.forEach { catalog[$0] = nil }
|
||||
lock.unlock()
|
||||
let fm = FileManager.default
|
||||
for id in victims {
|
||||
try? fm.removeItem(at: dir.appendingPathComponent("\(id).data"))
|
||||
try? fm.removeItem(at: dir.appendingPathComponent("\(id).meta"))
|
||||
}
|
||||
if !victims.isEmpty { publishUsage() }
|
||||
}
|
||||
|
||||
func readMeta(_ f: URL) -> CacheMeta? {
|
||||
(try? Data(contentsOf: f)).flatMap { try? JSONDecoder().decode(CacheMeta.self, from: $0) }
|
||||
}
|
||||
|
||||
func writeMeta(_ f: URL, _ m: CacheMeta) {
|
||||
if let d = try? JSONEncoder().encode(m) { try? d.write(to: f, options: .atomic) }
|
||||
}
|
||||
|
||||
static func idFor(_ key: String) -> String {
|
||||
Insecure.SHA1.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
static func blocksFor(_ length: Int64) -> Int { Int((length + Int64(block) - 1) / Int64(block)) }
|
||||
}
|
||||
|
||||
/// One cached remote file. Thread-safe; readers block until their bytes have arrived.
|
||||
final class CachedResource: @unchecked Sendable {
|
||||
let id: String
|
||||
private let dataFile: URL
|
||||
private let metaFile: URL
|
||||
private let url: () -> URL?
|
||||
private unowned let cache: MediaCache
|
||||
private let cond = NSCondition()
|
||||
private var bits: Bits
|
||||
private var claimed = Bits()
|
||||
private var handle: FileHandle?
|
||||
private let ioLock = NSLock()
|
||||
private var failure: Error?
|
||||
private var unsaved = 0
|
||||
private var prefetching = false
|
||||
private var lastRead = 0
|
||||
private var closed = false
|
||||
private(set) var length: Int64 = -1
|
||||
private var pinnedFlag = false
|
||||
private static let block = MediaCache.block
|
||||
|
||||
var pinned: Bool {
|
||||
get { cond.lock(); defer { cond.unlock() }; return pinnedFlag }
|
||||
set { cond.lock(); pinnedFlag = newValue; cond.unlock(); saveMeta() }
|
||||
}
|
||||
|
||||
init(id: String, dataFile: URL, metaFile: URL, url: @escaping () -> URL?, cache: MediaCache) {
|
||||
self.id = id
|
||||
self.dataFile = dataFile
|
||||
self.metaFile = metaFile
|
||||
self.url = url
|
||||
self.cache = cache
|
||||
if let m = cache.readMeta(metaFile), FileManager.default.fileExists(atPath: dataFile.path) {
|
||||
length = m.length
|
||||
bits = Bits(base64: m.bits)
|
||||
pinnedFlag = m.pinned
|
||||
} else {
|
||||
bits = Bits()
|
||||
try? FileManager.default.removeItem(at: dataFile)
|
||||
}
|
||||
}
|
||||
|
||||
/// Caller holds cond.
|
||||
private var blockCount: Int { length < 0 ? Int.max : MediaCache.blocksFor(length) }
|
||||
|
||||
var isComplete: Bool {
|
||||
cond.lock(); defer { cond.unlock() }
|
||||
return length >= 0 && bits.count >= MediaCache.blocksFor(length)
|
||||
}
|
||||
|
||||
var cachedBytes: Int64 {
|
||||
cond.lock(); defer { cond.unlock() }
|
||||
return min(Int64(bits.count) * Int64(Self.block), length < 0 ? Int64.max : length)
|
||||
}
|
||||
|
||||
private func file() throws -> FileHandle {
|
||||
ioLock.lock(); defer { ioLock.unlock() }
|
||||
if closed { throw MediaError("closed") }
|
||||
if let h = handle { return h }
|
||||
if !FileManager.default.fileExists(atPath: dataFile.path) {
|
||||
FileManager.default.createFile(atPath: dataFile.path, contents: nil)
|
||||
}
|
||||
let h = try FileHandle(forUpdating: dataFile)
|
||||
handle = h
|
||||
return h
|
||||
}
|
||||
|
||||
private func pread(_ at: Int64, _ buf: UnsafeMutableRawPointer, _ n: Int) throws -> Int {
|
||||
let fd = try file().fileDescriptor
|
||||
let r = Darwin.pread(fd, buf, n, off_t(at))
|
||||
if r < 0 { throw MediaError("read failed") }
|
||||
return r
|
||||
}
|
||||
|
||||
private func pwrite(_ at: Int64, _ buf: UnsafeRawPointer, _ n: Int) throws {
|
||||
let fd = try file().fileDescriptor
|
||||
var done = 0
|
||||
while done < n {
|
||||
let w = Darwin.pwrite(fd, buf + done, n - done, off_t(at) + off_t(done))
|
||||
if w <= 0 { throw MediaError("write failed") }
|
||||
done += w
|
||||
}
|
||||
}
|
||||
|
||||
/// Total size, fetching the first run if it isn't known yet.
|
||||
func size() throws -> Int64 {
|
||||
cond.lock()
|
||||
let known = length >= 0
|
||||
cond.unlock()
|
||||
if !known { try awaitBlock(0) }
|
||||
return length
|
||||
}
|
||||
|
||||
/// Reads up to [size] bytes at [pos] into [buf]; -1 at end of file. Blocks on the network if needed.
|
||||
func readAt(_ pos: Int64, _ buf: UnsafeMutableRawPointer, _ size: Int) throws -> Int {
|
||||
let total = try self.size()
|
||||
if pos >= total { return -1 }
|
||||
let n = Int(min(Int64(size), total - pos))
|
||||
if n <= 0 { return 0 }
|
||||
let first = Int(pos / Int64(Self.block))
|
||||
let last = Int((pos + Int64(n) - 1) / Int64(Self.block))
|
||||
for b in first...last { try awaitBlock(b) }
|
||||
cond.lock(); lastRead = last; cond.unlock()
|
||||
var got = 0
|
||||
while got < n {
|
||||
let r = try pread(pos + Int64(got), buf + got, n - got)
|
||||
if r <= 0 { break }
|
||||
got += r
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func readAt(_ pos: Int64, _ buf: inout [UInt8], _ off: Int, _ size: Int) throws -> Int {
|
||||
try buf.withUnsafeMutableBytes { try readAt(pos, $0.baseAddress! + off, size) }
|
||||
}
|
||||
|
||||
/// Starts filling the rest of the file in the background, from wherever reading is.
|
||||
func prefetch() {
|
||||
cond.lock()
|
||||
if prefetching || closed { cond.unlock(); return }
|
||||
prefetching = true
|
||||
cond.unlock()
|
||||
cache.submitPrefetch { [self] in
|
||||
// A reader that needs these bytes will retry and surface the error.
|
||||
try? fillAll(fromBlock: { self.lastRead }, cancelled: { self.closed })
|
||||
cond.lock(); prefetching = false; cond.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads every missing block on the calling thread.
|
||||
func downloadAll(progress: (Int64, Int64) -> Void, cancelled: () -> Bool) throws {
|
||||
try fillAll(fromBlock: { 0 }, cancelled: cancelled, progress: progress)
|
||||
}
|
||||
|
||||
private func fillAll(fromBlock: () -> Int, cancelled: () -> Bool, progress: (Int64, Int64) -> Void = { _, _ in }) throws {
|
||||
_ = try size()
|
||||
while !cancelled() {
|
||||
var run: ClosedRange<Int>?
|
||||
var waitFor = -1
|
||||
cond.lock()
|
||||
let count = blockCount
|
||||
if bits.count >= count { cond.unlock(); return }
|
||||
let start = min(max(fromBlock(), 0), count - 1)
|
||||
var b = bits.nextClear(start)
|
||||
if b >= count { b = bits.nextClear(0) }
|
||||
if b >= count { cond.unlock(); return }
|
||||
if claimed[b] {
|
||||
// Someone else is fetching here; look for a free block after it.
|
||||
var c = b
|
||||
while c < count && (bits[c] || claimed[c]) { c += 1 }
|
||||
if c >= count { waitFor = b } else { run = claimRun(c, MediaCache.prefetchRun) }
|
||||
} else {
|
||||
run = claimRun(b, MediaCache.prefetchRun)
|
||||
}
|
||||
cond.unlock()
|
||||
if let run { try fetch(run) }
|
||||
if waitFor >= 0 { try awaitBlock(waitFor) }
|
||||
progress(cachedBytes, length)
|
||||
}
|
||||
}
|
||||
|
||||
private func awaitBlock(_ b: Int) throws {
|
||||
var waited: TimeInterval = 0
|
||||
while true {
|
||||
var run: ClosedRange<Int>?
|
||||
cond.lock()
|
||||
if closed { cond.unlock(); throw MediaError("closed") }
|
||||
if length >= 0 && b >= blockCount { cond.unlock(); return }
|
||||
if bits[b] { cond.unlock(); return }
|
||||
if !claimed[b] {
|
||||
run = claimRun(b, MediaCache.onDemandRun)
|
||||
} else {
|
||||
let before = Date()
|
||||
_ = cond.wait(until: Date().addingTimeInterval(1))
|
||||
waited += Date().timeIntervalSince(before)
|
||||
if waited > 60 {
|
||||
let f = failure
|
||||
cond.unlock()
|
||||
throw f ?? MediaError("timed out waiting for data")
|
||||
}
|
||||
}
|
||||
cond.unlock()
|
||||
if let run { try fetch(run) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Claims up to [max] consecutive missing, unclaimed blocks from [b]. Caller holds the lock.
|
||||
private func claimRun(_ b: Int, _ max: Int) -> ClosedRange<Int> {
|
||||
var end = b
|
||||
let count = blockCount
|
||||
while end + 1 < count && end + 1 - b < max && !bits[end + 1] && !claimed[end + 1] { end += 1 }
|
||||
for i in b...end { claimed.set(i) }
|
||||
return b...end
|
||||
}
|
||||
|
||||
/// Synchronous range GET; the body of one run is at most a couple of MB.
|
||||
private func get(_ req: URLRequest) throws -> (Data, HTTPURLResponse) {
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
var out: (Data?, URLResponse?, Error?)
|
||||
let task = cache.session.dataTask(with: req) { d, r, e in out = (d, r, e); sem.signal() }
|
||||
task.resume()
|
||||
sem.wait()
|
||||
if let e = out.2 { throw e }
|
||||
guard let resp = out.1 as? HTTPURLResponse else { throw MediaError("no response") }
|
||||
return (out.0 ?? Data(), resp)
|
||||
}
|
||||
|
||||
private func fetch(_ run: ClosedRange<Int>) throws {
|
||||
var block = run.lowerBound
|
||||
let B = Int64(Self.block)
|
||||
defer {
|
||||
cond.lock()
|
||||
// Release whatever this run didn't deliver, including blocks past EOF.
|
||||
if block <= run.upperBound { for b in block...run.upperBound { claimed.clear(b) } }
|
||||
cond.broadcast()
|
||||
cond.unlock()
|
||||
}
|
||||
do {
|
||||
guard let u = url() else { throw MediaError("no URL") }
|
||||
let start = Int64(run.lowerBound) * B
|
||||
cond.lock()
|
||||
let len = length
|
||||
cond.unlock()
|
||||
let endExclusive = len >= 0 ? min(Int64(run.upperBound + 1) * B, len) : Int64(run.upperBound + 1) * B
|
||||
var req = URLRequest(url: u)
|
||||
req.setValue("bytes=\(start)-\(endExclusive - 1)", forHTTPHeaderField: "Range")
|
||||
let (body, resp) = try get(req)
|
||||
var skip: Int64 = 0
|
||||
var lastBlock = run.upperBound
|
||||
switch resp.statusCode {
|
||||
case 206:
|
||||
if let cr = resp.value(forHTTPHeaderField: "Content-Range"),
|
||||
let slash = cr.lastIndex(of: "/"), let total = Int64(cr[cr.index(after: slash)...]) {
|
||||
learnLength(total)
|
||||
}
|
||||
case 200:
|
||||
// No range support: the body is the whole file, so keep all of it.
|
||||
learnLength(Int64(body.count))
|
||||
skip = 0
|
||||
block = 0
|
||||
lastBlock = MediaCache.blocksFor(Int64(body.count)) - 1
|
||||
default:
|
||||
throw MediaError("HTTP \(resp.statusCode) fetching media")
|
||||
}
|
||||
cond.lock()
|
||||
let length = self.length
|
||||
let count = blockCount
|
||||
cond.unlock()
|
||||
if length < 0 { throw MediaError("server sent no length") }
|
||||
let bodyOffset = resp.statusCode == 200 ? Int64(0) : start
|
||||
lastBlock = min(lastBlock, count - 1)
|
||||
try body.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in
|
||||
while block <= lastBlock {
|
||||
let blockStart = Int64(block) * B
|
||||
let want = Int(min(blockStart + B, length) - blockStart)
|
||||
let from = Int(blockStart - bodyOffset + skip)
|
||||
if from < 0 || from + want > raw.count { throw MediaError("connection ended early") }
|
||||
cond.lock()
|
||||
let have = bits[block]
|
||||
cond.unlock()
|
||||
if !have { try pwrite(blockStart, raw.baseAddress! + from, want) }
|
||||
cond.lock()
|
||||
bits.set(block)
|
||||
claimed.clear(block)
|
||||
failure = nil
|
||||
cond.broadcast()
|
||||
unsaved += 1
|
||||
let save = unsaved >= 32
|
||||
cond.unlock()
|
||||
block += 1
|
||||
if save || (block > lastBlock && isComplete) { saveMeta() }
|
||||
}
|
||||
}
|
||||
if resp.statusCode == 200 { block = run.upperBound + 1 }
|
||||
} catch {
|
||||
cond.lock()
|
||||
failure = error
|
||||
cond.broadcast()
|
||||
cond.unlock()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func learnLength(_ total: Int64) {
|
||||
cond.lock()
|
||||
if length != total {
|
||||
length = total
|
||||
bits.truncate(MediaCache.blocksFor(total))
|
||||
}
|
||||
cond.unlock()
|
||||
}
|
||||
|
||||
private func saveMeta() {
|
||||
cond.lock()
|
||||
unsaved = 0
|
||||
let m = CacheMeta(length: length, bits: bits.base64, pinned: pinnedFlag, access: Int64(Date().timeIntervalSince1970 * 1000))
|
||||
let bytes = min(Int64(bits.count) * Int64(Self.block), length < 0 ? 0 : length)
|
||||
cond.unlock()
|
||||
cache.writeMeta(metaFile, m)
|
||||
cache.update(id, bytes: bytes, access: m.access, pinned: m.pinned)
|
||||
}
|
||||
|
||||
func close() {
|
||||
cond.lock()
|
||||
closed = true
|
||||
cond.broadcast()
|
||||
cond.unlock()
|
||||
saveMeta()
|
||||
ioLock.lock()
|
||||
try? handle?.close()
|
||||
handle = nil
|
||||
ioLock.unlock()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user