feat: receive ordered stream playback queues

This commit is contained in:
2026-10-22 14:06:47 +02:00
parent e7a33ff68e
commit 70672321a1
9 changed files with 239 additions and 7 deletions
+15 -1
View File
@@ -4,11 +4,14 @@ struct PlaybackQueue {
struct Item: Identifiable {
let id = UUID()
let url: URL
var label: String? = nil
var displayName: String { label ?? url.lastPathComponent }
}
private(set) var items: [Item] = []
private(set) var currentID: UUID?
var currentURL: URL? { items.first { $0.id == currentID }?.url }
var currentItem: Item? { items.first { $0.id == currentID } }
var currentURL: URL? { currentItem?.url }
private var currentIndex: Int? { items.firstIndex { $0.id == currentID } }
var canPrevious: Bool { currentIndex.map { $0 > 0 } ?? false }
var canNext: Bool { currentIndex.map { $0 + 1 < items.count } ?? false }
@@ -18,6 +21,17 @@ struct PlaybackQueue {
currentID = items.first?.id
}
// Unlike file batches, stream entries already have an intentional playback order.
mutating func replaceStreams(_ entries: [(url: URL, label: String)]) {
items = entries.map { Item(url: $0.url, label: $0.label) }
currentID = items.first?.id
}
mutating func updateCurrentLabel(_ label: String) {
guard let index = currentIndex else { return }
items[index].label = label
}
mutating func append(_ urls: [URL]) {
items.append(contentsOf: sortedFiles(urls).map { Item(url: $0) })
}
+69
View File
@@ -0,0 +1,69 @@
import Foundation
import Darwin
struct StreamQueue: Sendable {
struct Entry: Sendable {
let url: URL
let identity: String
let progressFile: URL
let label: String
}
let entries: [Entry]
private struct Document: Decodable {
struct Item: Decodable { let url: String; let progress_file: String }
let version: Int
let items: [Item]
}
private struct InvalidQueue: LocalizedError {
var errorDescription: String? { "Invalid stream queue: expected version 1, 1–4096 unique supported videos and new absolute report files in private owned directories." }
}
static func load(from file: URL) throws -> StreamQueue {
do {
guard file.isFileURL, file.path.hasPrefix("/") else { throw InvalidQueue() }
let descriptor = Darwin.open(file.path, O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC)
guard descriptor >= 0 else { throw InvalidQueue() }
let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
defer { try? handle.close() }
var info = stat()
guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG else { throw InvalidQueue() }
let data = try handle.read(upToCount: 8 * 1024 * 1024 + 1) ?? Data()
guard data.count <= 8 * 1024 * 1024 else { throw InvalidQueue() }
let document = try JSONDecoder().decode(Document.self, from: data)
guard document.version == 1, (1...4096).contains(document.items.count) else { throw InvalidQueue() }
var identities = Set<String>()
var reports = Set<String>()
let entries = try document.items.map { item -> Entry in
let stream = try StreamURL.parse(item.url)
let url: URL
let identity: String
let label: String
switch stream {
case .youtube(let canonical):
url = canonical
identity = canonical.absoluteString
label = "YouTube · " + (URLComponents(url: canonical, resolvingAgainstBaseURL: false)?.queryItems?.first?.value ?? "Video")
case .miruro(let mediaID, let episode):
url = URL(string: item.url)!
identity = "miruro:\(mediaID):\(episode)"
label = "\(url.lastPathComponent.replacingOccurrences(of: "-", with: " ")) · Episode \(episode)"
}
guard identities.insert(identity).inserted,
item.progress_file.hasPrefix("/"), !item.progress_file.contains("\0") else { throw InvalidQueue() }
let report = URL(fileURLWithPath: item.progress_file).standardizedFileURL
let parent = report.deletingLastPathComponent()
var directoryInfo = stat()
guard lstat(parent.path, &directoryInfo) == 0,
(directoryInfo.st_mode & S_IFMT) == S_IFDIR,
directoryInfo.st_uid == geteuid(), (directoryInfo.st_mode & 0o777) == 0o700,
parent.resolvingSymlinksInPath().path == parent.path,
reports.insert(report.path).inserted else { throw InvalidQueue() }
var existing = stat()
guard lstat(report.path, &existing) != 0, errno == ENOENT else { throw InvalidQueue() }
return Entry(url: url, identity: identity, progressFile: report, label: label)
}
return StreamQueue(entries: entries)
} catch { throw InvalidQueue() }
}
}