94 lines
4.9 KiB
Swift
94 lines
4.9 KiB
Swift
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." }
|
||
}
|
||
// Explicit-app URL delivery reaches both cold launches and already running players.
|
||
static func enqueueURL(for file: URL) -> URL {
|
||
var components = URLComponents()
|
||
components.scheme = "terebi-enqueue"
|
||
components.host = "queue"
|
||
components.queryItems = [URLQueryItem(name: "file", value: file.path)]
|
||
return components.url!
|
||
}
|
||
|
||
static func enqueueFile(from url: URL) throws -> URL {
|
||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||
components.scheme == "terebi-enqueue", components.host == "queue",
|
||
components.path.isEmpty, components.user == nil, components.password == nil,
|
||
components.port == nil, components.fragment == nil,
|
||
let query = components.queryItems, query.count == 1, query[0].name == "file",
|
||
let path = query[0].value, path.hasPrefix("/"), !path.contains("\0") else {
|
||
throw InvalidQueue()
|
||
}
|
||
return URL(fileURLWithPath: path).standardizedFileURL
|
||
}
|
||
|
||
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
|
||
guard item.url == item.url.trimmingCharacters(in: .whitespacesAndNewlines) else { throw InvalidQueue() }
|
||
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 = canonical.absoluteString
|
||
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 & 0o7777) == 0o700,
|
||
let resolvedParent = realpath(parent.path, nil) else { throw InvalidQueue() }
|
||
defer { free(resolvedParent) }
|
||
let canonicalReport = URL(fileURLWithPath: String(cString: resolvedParent), isDirectory: true)
|
||
.appendingPathComponent(report.lastPathComponent)
|
||
guard reports.insert(canonicalReport.path).inserted else { throw InvalidQueue() }
|
||
var existing = stat()
|
||
guard lstat(canonicalReport.path, &existing) != 0, errno == ENOENT else { throw InvalidQueue() }
|
||
return Entry(url: url, identity: identity, progressFile: canonicalReport, label: label)
|
||
}
|
||
return StreamQueue(entries: entries)
|
||
} catch { throw InvalidQueue() }
|
||
}
|
||
}
|