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() var reports = Set() 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)" case .anilist(let mediaID, let episode): url = URL(string: item.url)! identity = "anilist:\(mediaID):\(episode)" label = "AniList \(mediaID) · Episode \(episode) (sub)" } 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() } } }