diff --git a/Checks/app-startup.py b/Checks/app-startup.py index 1965a0e..4c830f4 100644 --- a/Checks/app-startup.py +++ b/Checks/app-startup.py @@ -331,6 +331,48 @@ def check_cli_instances(root, app, fixtures): wait_states(count, lambda _: True, "original players survive independent resolved GUI quits") print("PASS actual resolved CLI handoff: two isolated GUI episodes, authenticated encrypted HLS, independent default VTT, v2 progress and quit") + def check_stream_queue_instance(): + # DEBUG-queue-startup: distinguish queue startup from ordinary empty startup. + invoke(["--new-instance", "--no-config"]) + empty_states = wait_states(1, lambda _: True, "diagnostic empty startup creates its window") + quit_instance(next(iter(empty_states)), 1) + wait_states(0, lambda _: True, "diagnostic empty startup quits") + session = (root / "stream-queue-session").resolve() + session.mkdir(mode=0o700) + paths = [session / "first.jsonl", session / "second.jsonl"] + urls = [reported_urls[0], canonical] # Deliberately not alphabetical URL order. + manifest = session / "queue.json" + manifest.write_text(json.dumps({"version": 1, "items": [ + {"url": url, "progress_file": str(path)} for url, path in zip(urls, paths)]})) + invoke(["--new-instance", "--no-config", "--queue-file", str(manifest)]) + states = wait_states(1, lambda snapshots: all( + state["queueURLs"] == urls and state["queueIndex"] == 0 and not state["busy"] + and state["status"] == 3 and state["hasVideo"] and state["position"] > 0.2 + for state in snapshots.values()), "actual queue CLI delivers ordered native stream playback") + pid = next(iter(states)) + if paths[1].exists(): + raise RuntimeError("unselected queue entry must not create or write its report") + input_request(pid, 1, lambda state: state["queueIndex"] == 1 and not state["busy"] + and state["status"] == 3, "native queue manual next", action="queueAdvance", offset=1) + input_request(pid, 1, lambda state: state["queueIndex"] == 0 and not state["busy"] + and state["status"] == 3, "native queue previous revisits owned report", action="queueAdvance", offset=-1) + states = input_request(pid, 1, lambda state: state["status"] == 2 and not state["busy"], + "pause queue first item", action="pause") + target = states[pid]["duration"] - 0.3 + input_request(pid, 1, lambda state: at_position(state, target), + "acknowledged queue end seek", action="seek", position=target) + input_request(pid, 1, lambda state: state["queueIndex"] == 1 and not state["busy"] + and state["status"] == 3 and state["queueURLs"] == urls, + "natural end advances without losing supplied queue", action="play") + quit_instance(pid, 1) + for path, url in zip(paths, urls): + rows = wait_report(path, {url}, lambda rows: bool(rows) and rows[-1]["event"] == "closed", + "queue quit drains independent stable report") + if len(rows) < 4: + raise RuntimeError(f"queue revisit must append without clobbering earlier records: {path}: {rows}") + wait_states(0, lambda _: True, "queue GUI quit leaves no stale instance") + print("PASS actual queue CLI: ordered lazy streams, manual next/previous, natural end advance and private report revisits") + resolved_fixtures(fixtures) with StartupStreamingServer(fixtures) as server: if extractor and runtime: @@ -365,9 +407,16 @@ def check_cli_instances(root, app, fixtures): runtime_settings = {"directory": str(root), "path": str(root) + os.pathsep + os.environ.get("PATH", "")} (app / "Contents/Resources/cli-check.json").write_text(json.dumps(runtime_settings)) subprocess.run(["codesign", "--force", "--sign", "-", str(app)], check=True) + if os.environ.get("TEREBI_CHECK_STREAM_QUEUE_ONLY") == "1": + if not extractor or not runtime: + raise RuntimeError("stream queue CLI smoke requires bundled extractor and runtime fixtures") + check_stream_queue_instance() + return if os.environ.get("TEREBI_CHECK_RESOLVED_ONLY") == "1": check_resolved_instances(server, 0) return + if extractor and runtime: + check_stream_queue_instance() movie = fixtures / "cache.mp4" audio = root / "audio with spaces.wav" shutil.copy2(fixtures / "audio.wav", audio) @@ -721,6 +770,9 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory: descriptor = os.open(pipe, os.O_RDONLY | os.O_NONBLOCK) launch = None try: + if os.environ.get("TEREBI_CHECK_STREAM_QUEUE_ONLY") == "1": + check_cli_instances(root, app, fixtures) + sys.exit(0) with selectors.DefaultSelector() as selector: selector.register(descriptor, selectors.EVENT_READ) launch = subprocess.Popen( diff --git a/Checks/app-startup.swift b/Checks/app-startup.swift index 8a38edd..c8e3e18 100644 --- a/Checks/app-startup.swift +++ b/Checks/app-startup.swift @@ -925,6 +925,8 @@ private enum CLIInstanceCheck { case "queue": model.showsQueue = command["visible"] as! Bool model.revealControls() + case "queueAdvance": + model.advanceQueue(command["offset"] as! Int) case "caption": guard let view = window.contentView, let selector = captionSelector(in: view), let cell = selector.cell as? NSPopUpButtonCell, let menu = selector.menu else { @@ -1013,6 +1015,8 @@ private enum CLIInstanceCheck { "canChangeQuality": model.canChangeYouTubeQuality, "qualityHeights": model.youtubeHeights, "windows": windows.count, + "allWindows": NSApp.windows.map { ["class": NSStringFromClass(type(of: $0)), + "visible": $0.isVisible, "main": $0.canBecomeMain, "content": $0.contentView != nil] as [String: Any] }, "hasAudio": model.state.has_audio, "hasVideo": model.state.has_video, "error": model.errorText ?? "", @@ -1031,6 +1035,8 @@ private enum CLIInstanceCheck { "editorText": editorText, "compact": model.isCompact, "showsQueue": model.showsQueue, + "queueURLs": model.queue.items.map(\.url.absoluteString), + "queueIndex": model.queue.items.firstIndex { $0.id == model.queue.currentID } ?? -1, "window": windowSnapshot(windows.first), "onTop": model.isOnTop, ] diff --git a/Checks/playback-queue.swift b/Checks/playback-queue.swift index cb058a6..c67b4eb 100644 --- a/Checks/playback-queue.swift +++ b/Checks/playback-queue.swift @@ -105,6 +105,19 @@ struct PlaybackQueueChecks { "replace discards previous queue and starts new selection") queue.replace([]) require(queue.items.isEmpty && queue.currentID == nil, "empty replacement clears queue") + let youtube = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")! + let miruro = URL(string: "https://barelystarted.miruro.tv/watch/fixture/episode?ep=2")! + queue.replaceStreams([(url: miruro, label: "Episode 2"), (url: youtube, label: "YouTube")]) + require(queue.items.map(\.url) == [miruro, youtube], "stream order is preserved without file filtering or sorting") + require(queue.currentItem?.displayName == "Episode 2", "stream rows have descriptive labels") + let streamID = queue.currentID! + queue.updateCurrentLabel("Resolved episode title") + require(queue.currentID == streamID && queue.currentItem?.displayName == "Resolved episode title", + "metadata updates labels without changing queue identity") + require(queue.advance(1) == youtube && queue.advance(-1) == miruro, "stream navigation uses supplied order") + queue.move(fromOffsets: IndexSet(integer: 0), toOffset: 2) + require(queue.currentID == streamID && queue.items.map(\.url) == [youtube, miruro], "stream reorder preserves selection") + require(queue.remove(streamID) == nil && queue.items.map(\.url) == [youtube], "removing current final stream stops") print("playback queue checks passed") } } diff --git a/Checks/player-command.swift b/Checks/player-command.swift index 227e1cc..467a48f 100644 --- a/Checks/player-command.swift +++ b/Checks/player-command.swift @@ -134,6 +134,69 @@ struct PlayerCommandChecks { fatalError("FAIL: FIFO source accepted") } catch {} + let queueFile = directory.appendingPathComponent("queue.json") + let resolvedDirectory = realpath(directory.path, nil)! + defer { free(resolvedDirectory) } + let privateDirectory = URL(fileURLWithPath: String(cString: resolvedDirectory), isDirectory: true) + require(chmod(privateDirectory.path, 0o700) == 0, "queue reports require a private owned parent") + let youtubeReport = privateDirectory.appendingPathComponent("youtube.jsonl").path + let miruroReport = privateDirectory.appendingPathComponent("miruro.jsonl").path + let entries: [[String: Any]] = [ + ["url": miruro.absoluteString, "progress_file": miruroReport], + ["url": canonical.absoluteString, "progress_file": youtubeReport] + ] + func saveQueue(_ value: [String: Any]) throws { + try JSONSerialization.data(withJSONObject: value).write(to: queueFile) + } + try saveQueue(["version": 1, "items": entries]) + guard case .launchQueue(let deliveredQueue, let queueIndependent, let queueBypass) = + try PlayerCommand.parse(["--new-instance", "--no-config", "--queue-file", queueFile.path]) else { + fatalError("FAIL: expected stream queue launch") + } + require(deliveredQueue == queueFile && queueIndependent && queueBypass, "queue CLI preserves independent delivery") + let parsedQueue = try StreamQueue.load(from: queueFile) + require(parsedQueue.entries.map(\.url) == [miruro, canonical], "stream queue order is supplied, never filename-sorted") + require(parsedQueue.entries[0].identity.hasPrefix("miruro:") && + parsedQueue.entries[1].identity == canonical.absoluteString, "stable identities exclude slugs and transport URLs") + let duplicateYouTube = ["url": "https://youtu.be/jNQXAC9IVRw", "progress_file": miruroReport] + let duplicateMiruro = ["url": miruro.absoluteString.replacingOccurrences(of: "the-apothecary-diaries-season-3", with: "different-slug"), + "progress_file": youtubeReport] + let invalidQueues: [[String: Any]] = [ + ["version": 2, "items": entries], ["version": true, "items": entries], + ["version": 1, "items": []], ["version": 1], + ["version": 1, "items": Array(repeating: entries[0], count: 4097)], + ["version": 1, "items": [entries[1], duplicateYouTube]], + ["version": 1, "items": [entries[0], duplicateMiruro]], + ["version": 1, "items": [entries[0], ["url": canonical.absoluteString, "progress_file": miruroReport]]], + ["version": 1, "items": [entries[0], ["url": canonical.absoluteString, + "progress_file": privateDirectory.resolvingSymlinksInPath().appendingPathComponent("miruro.jsonl").path]]], + ["version": 1, "items": [["url": "https://cdn.example/expired.m3u8", "progress_file": youtubeReport]]], + ["version": 1, "items": [["url": canonical.absoluteString, "progress_file": "relative.jsonl"]]], + ["version": 1, "items": [["url": canonical.absoluteString, "progress_file": movie.path]]] + ] + for document in invalidQueues { + try saveQueue(document) + do { + _ = try PlayerCommand.parse(["--new-instance", "--queue-file", queueFile.path]) + fatalError("FAIL: malformed queue must never launch or degrade to single playback") + } catch {} + } + try saveQueue(["version": 1, "items": entries]) + for arguments in [ + ["--queue-file", queueFile.path], ["--new-instance", "--queue-file"], + ["--new-instance", "--queue-file", "queue.json"], + ["--new-instance", "--queue-file", queueFile.path, "--url", canonical.absoluteString], + ["--new-instance", "--queue-file", queueFile.path, "--progress-file", report.path], + ["--new-instance", "--queue-file", queueFile.path, "--queue-file", queueFile.path] + ] { + do { _ = try PlayerCommand.parse(arguments); fatalError("FAIL: invalid queue option combination") } + catch {} + } + require(chmod(privateDirectory.path, 0o755) == 0, "make report parent nonprivate for rejection check") + do { _ = try StreamQueue.load(from: queueFile); fatalError("FAIL: public report directory accepted") } + catch {} + require(chmod(privateDirectory.path, 0o700) == 0, "restore private check directory") + for arguments in [["--url"], ["--url", canonical.absoluteString, movie.path], ["--"], [movie.path, movie.path], ["--unknown"], ["missing.mp4"], [directory.path], ["file://remote-host/video.mp4"], ["https://example.com/video.mp4"], diff --git a/Checks/progress-reporting.mm b/Checks/progress-reporting.mm index 777210c..0714904 100644 --- a/Checks/progress-reporting.mm +++ b/Checks/progress-reporting.mm @@ -314,6 +314,41 @@ int main(int argc, char **argv) { require(symlink(existingPath.c_str(), linkPath.c_str()) == 0 && rejected(linkPath), "symlink destination fails initialization"); require([[NSData dataWithContentsOfFile:nativePath(existingPath)] isEqual:sentinel], "rejection never changes an existing destination"); + const auto queueFirst = root + "/queue-first.jsonl", queueSecond = root + "/queue-second.jsonl"; + { + playback::ProgressReports reports; + reports.registerFile(firstURL, queueFirst); + reports.registerFile(secondURL, queueSecond); + reports.begin(firstURL); + reports.observe(sample(PLAYER_PAUSED, 12)); + reports.begin(secondURL); + reports.observe(sample(PLAYER_ENDED, 100)); + reports.begin(firstURL); + reports.observe(sample(PLAYER_PAUSED, 24)); + reports.begin("miruro:unregistered:1"); + reports.observe(sample(PLAYER_PAUSED, 55)); + reports.finish(); + require(reports.error().empty(), "queue reports can revisit their owned session without reopening arbitrary files"); + } + auto firstRows = records(queueFirst), secondRows = records(queueSecond); + require(firstRows.count == 4 && secondRows.count == 2, "queue switches drain old reports and revisits append"); + expect(firstRows[0], @"progress", firstURL, 12, true); + expect(firstRows[2], @"progress", firstURL, 24, true); + expect(secondRows[0], @"ended", secondURL, 100, true); + const auto replacedPath = root + "/replaced.jsonl"; + { + ProgressReporter reporter(replacedPath, [] { return 100.; }); + reporter.begin(firstURL); + reporter.observe(sample(PLAYER_PAUSED, 1)); + reporter.finish(); + require(rename(replacedPath.c_str(), (replacedPath + ".original").c_str()) == 0, "retain original inode while replacing path"); + require([sentinel writeToFile:nativePath(replacedPath) atomically:NO], "replace report with unrelated sentinel"); + bool refused = false; + try { reporter.resume(); } catch (...) { refused = true; } + require(refused && [[NSData dataWithContentsOfFile:nativePath(replacedPath)] isEqual:sentinel], + "revisiting never appends to a replaced report inode"); + } + const auto concurrentPath = root + "/concurrent.jsonl"; std::atomic ready{0}, successes{0}, failures{0}; std::atomic start{false}; diff --git a/Checks/resolved-playback.swift b/Checks/resolved-playback.swift index 36e45bb..e671902 100644 --- a/Checks/resolved-playback.swift +++ b/Checks/resolved-playback.swift @@ -94,6 +94,113 @@ struct ResolvedPlaybackCheck { } print("PASS: default VTT selected, forward/backward seek and acknowledged cue timing") if !input.hasPrefix("https://") { try await segmentSkips(model, clock: clock, file: file) } + if !input.hasPrefix("https://") { + model.shutdown() + try await streamQueueLifecycle(source: source) + } + } + + static func streamQueueLifecycle(source: ResolvedSource) async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("terebi.queue-check." + UUID().uuidString).resolvingSymlinksInPath() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + defer { try? FileManager.default.removeItem(at: directory) } + let youtubeURL = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")! + let episodeURL = URL(string: "https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1")! + let firstReport = directory.appendingPathComponent("youtube.jsonl") + let secondReport = directory.appendingPathComponent("miruro.jsonl") + let queue = StreamQueue(entries: [ + .init(url: youtubeURL, identity: youtubeURL.absoluteString, progressFile: firstReport, label: "First"), + .init(url: episodeURL, identity: source.identity, progressFile: secondReport, label: "Second") + ]) + let suite = "terebi.queue-check." + UUID().uuidString + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + var youtubeRequests = 0 + var episodeRequests = 0 + var stale: CheckedContinuation? + let video = YouTubeVideo(title: "Queue YouTube fixture", + video: .init(url: source.url, headers: source.headers), audio: nil, + duration: 0, isLive: false, webpageURL: youtubeURL, height: nil, availableHeights: []) + let model = PlayerModel(defaults: defaults, streamQueue: queue, youtubeResolver: { _, _ in + youtubeRequests += 1 + if youtubeRequests == 1 { + return await withCheckedContinuation { stale = $0 } + } + return video + }, miruroResolver: { _, _ in + episodeRequests += 1 + return source + }) + defer { model.shutdown() } + require(model.progressInitializationError == nil, "queue report registration") + model.openStreamQueue(queue) + try await until("only selected first stream resolves", model: model) { stale != nil } + require(episodeRequests == 0 && model.queue.items.map(\.url) == [youtubeURL, episodeURL], + "stream queue is lazy and preserves supplied order") + let firstID = model.queue.items[0].id + let secondID = model.queue.items[1].id + model.selectQueueItem(secondID) + try await until("manual stream selection decodes native media", model: model) { + !model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2 + } + stale!.resume(returning: video) + stale = nil + // Yield to the canceled resolver; it must not open or redirect its report. + for _ in 0..<10 { await Task.yield() } + require(model.queue.currentID == secondID && model.fileName == source.title && + !FileManager.default.fileExists(atPath: firstReport.path), "stale extraction cannot replace selected media or report") + model.advanceQueue(-1) + try await until("previous resolves YouTube afresh", model: model) { + !model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == video.title + } + require(youtubeRequests == 2 && model.queue.currentID == firstID, "manual previous preserves queue and resolves current item") + require(model.setPlayback(playing: false), "pause first queued stream") + try await until("queue pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED } + require(model.seekRelative(max(0, model.duration - 0.3) - model.state.position), "seek first stream near natural end") + try await until("end seek acknowledged", model: model) { + !model.state.seek_pending && !model.isBusy && abs(model.state.position - max(0, model.duration - 0.3)) < 0.1 + } + require(model.setPlayback(playing: true), "play first stream to natural end") + try await until("natural end advances and freshly resolves second stream", model: model) { + model.queue.currentID == secondID && !model.isBusy && model.state.status == PLAYER_PLAYING && + model.fileName == source.title && episodeRequests == 2 + } + require(model.setPlayback(playing: false), "pause second stream for restoration") + try await until("second paused", model: model) { model.state.status == PLAYER_PAUSED } + require(model.seekRelative(0.8 - model.state.position), "save an acknowledged second-stream bookmark") + try await until("second bookmark acknowledged", model: model) { + !model.state.seek_pending && abs(model.state.position - 0.8) < 0.1 + } + model.advanceQueue(-1) + try await until("revisit first report session", model: model) { + !model.isBusy && model.fileName == video.title && model.state.status == PLAYER_PLAYING + } + model.advanceQueue(1) + try await until("second revisit resumes acknowledged bookmark", model: model) { + !model.isBusy && model.fileName == source.title && model.state.status == PLAYER_PLAYING && + model.state.position >= 0.7 + } + require(youtubeRequests == 3 && episodeRequests == 3, "every revisit resolves fresh transport metadata") + model.shutdown() + func records(_ file: URL) throws -> [[String: Any]] { + try String(contentsOf: file, encoding: .utf8).split(separator: "\n").map { + try JSONSerialization.jsonObject(with: Data($0.utf8)) as! [String: Any] + } + } + let youtubeRows = try records(firstReport) + let episodeRows = try records(secondReport) + require(youtubeRows.count >= 4 && youtubeRows.allSatisfy { + $0["version"] as? Int == 1 && $0["media_url"] as? String == youtubeURL.absoluteString + }, "YouTube report appends on revisit without another identity") + require(youtubeRows.contains { $0["event"] as? String == "ended" }, "natural end retains its acknowledged completion snapshot") + require(episodeRows.count >= 6 && episodeRows.allSatisfy { + let identity = $0["media_identity"] as? [String: Any] + return $0["version"] as? Int == 2 && identity?["media_id"] as? String == "fixture-ID" && + identity?["episode"] as? Int == 1 + }, "Miruro report is isolated through selection, advance and revisits") + print("PASS: ordered lazy mixed stream queue, stale cancellation, native end advance, fresh revisits/resume and report isolation") } static func segmentSkips(_ model: PlayerModel, clock: Clock, file: URL) async throws { @@ -107,10 +214,14 @@ struct ResolvedPlaybackCheck { } model.supplyMetadataForCheck(metadata, episode: 1) require(model.segmentToSkip == opening, "late metadata offers opening at acknowledged paused position") + require(model.skipOfferRemainingFraction == 1, "late entry starts with a full countdown background") + clock.now = 105 + require(model.skipOfferRemainingFraction == 0.5, "paused countdown drains by elapsed offer time, not media position") clock.now = 109.999 require(model.segmentToSkip == opening, "opening remains available before ten seconds") clock.now = 110 require(model.segmentToSkip == nil && !model.skipSegment(), "offer expires at ten seconds while paused") + require(model.skipOfferRemainingFraction == 0, "expired offer has no remaining background") clock.now = 111 try await Task.sleep(for: .milliseconds(100)) require(model.segmentToSkip == nil, "polling does not reoffer ignored opening") diff --git a/README.md b/README.md index d8b93c7..120e524 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Terebi -A native macOS player for local media, YouTube videos and Miruro episodes, with a local-file queue, saved playback positions, compact mode and Chromecast support. +A native macOS player for local media, YouTube videos and Miruro episodes, with ordered playback queues, saved playback positions, compact mode and Chromecast support. ## Build and install @@ -23,8 +23,9 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self - **Local files:** use **⌘O**, drag files onto the player, or choose Finder's **Open With**. Multiple files form a queue in natural filename order, with automatic advance. Use **View → Show Queue** to play, reorder or remove items; **+** or dropping files onto the sidebar appends them. The queue lasts only for the session; opening other media replaces it. - **Streams:** use **Open Stream…** (**⇧⌘O**) or drop a supported link. YouTube supports individual videos, not playlists or channels. Miruro links must use `https://barelystarted.miruro.tv/watch//?ep=` with an explicit positive episode number; arbitrary streaming sites are not supported. +- **Stream queues:** integrations can supply an ordered YouTube/Miruro queue with `--new-instance --queue-file /absolute/queue.json`. The same sidebar supports play, reorder, remove, previous/next and automatic advance. Only the selected item resolves, including fresh resolution on revisits; local-file batches still use natural filename order. - **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints. -- **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider. Inside a segment, **Skip Opening** or **Skip Ending** appears at the top-right below the titlebar for up to ten seconds or until the segment ends; clicking seeks to its end. Ignored offers stay dismissed until the media is closed or replaced. Missing/invalid metadata leaves playback usable, and timings may differ between cuts. Nothing skips automatically. +- **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider. Inside a segment, **Skip Opening** or **Skip Ending** appears at the top-right below the titlebar for up to ten seconds or until the segment ends; clicking seeks to its end. Its background fill drains as the offer expires, with a short entrance/exit transition; Reduce Motion uses stepped countdown updates without the transition. Ignored offers stay dismissed until the media is closed or replaced. Missing/invalid metadata leaves playback usable, and timings may differ between cuts. Nothing skips automatically. - **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress. - **Compact mode:** press **P** for a floating player; double-click it to restore. Drag noninteractive areas to move the window. Use the titlebar pin to keep the normal window on top. - **Quality:** YouTube defaults to SDR up to 1080p. Use **gear → Quality** for other available resolutions or automatic quality. @@ -64,7 +65,7 @@ terebi --help The CLI opens one file or supported stream, reusing the running player by default. With no arguments, it opens or activates the player. `--new-instance` starts a separate player; `--no-config` uses built-in settings on a fresh launch. Put these options before the media argument. Before installation, use `.build/terebi` instead of `terebi`. -For native episode-URL playback, external resolved sources and progress reporting, see the [integration contract](docs/miruro-playback-handoff.md). +For native episode-URL playback, ordered stream queues, external resolved sources and progress reporting, see the [integration contract](docs/miruro-playback-handoff.md). ## Configuration diff --git a/Sources/App/StreamQueue.swift b/Sources/App/StreamQueue.swift index be9b138..ad6aca1 100644 --- a/Sources/App/StreamQueue.swift +++ b/Sources/App/StreamQueue.swift @@ -35,6 +35,7 @@ struct StreamQueue: Sendable { 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 @@ -56,12 +57,15 @@ struct StreamQueue: Sendable { 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() } + 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(report.path, &existing) != 0, errno == ENOENT else { throw InvalidQueue() } - return Entry(url: url, identity: identity, progressFile: report, label: label) + 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() } diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index b278f25..a02f12b 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -25,7 +25,9 @@ func compactProgressText(position: Double, duration: Double, format: CompactTitl final class PlayerModel: ObservableObject { static var startupConfiguration = PlayerConfiguration() static var startupProgressFile: String? - static let shared = PlayerModel(configuration: startupConfiguration, progressFile: startupProgressFile) + static var startupStreamQueue: StreamQueue? + static let shared = PlayerModel(configuration: startupConfiguration, progressFile: startupProgressFile, + streamQueue: startupStreamQueue) @Published private(set) var state = PlayerState() @Published private(set) var fileName: String? @@ -77,6 +79,10 @@ final class PlayerModel: ObservableObject { currentSkipInterval == skipOffer.interval else { return nil } return skipOffer.interval } + var skipOfferRemainingFraction: Double { + guard segmentToSkip != nil, let skipOffer else { return 0 } + return min(1, max(0, (skipOffer.deadline - skipClock()) / 10)) + } private func updateSegmentSkip() { guard let interval = currentSkipInterval else { @@ -170,16 +176,22 @@ final class PlayerModel: ObservableObject { private var reportingEnabled = false private var reportingErrorShown = false private var reportingErrorBuffer = [CChar]() + private let youtubeResolver: @MainActor (URL, Int?) async throws -> YouTubeVideo + private let miruroResolver: @MainActor (String, Int) async throws -> ResolvedSource private var reportingErrorMessage: String { reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) } } init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(), - progressFile: String? = nil, + progressFile: String? = nil, streamQueue: StreamQueue? = nil, + youtubeResolver: @escaping @MainActor (URL, Int?) async throws -> YouTubeVideo = { try await YouTubeResolver.resolve($0, height: $1) }, + miruroResolver: @escaping @MainActor (String, Int) async throws -> ResolvedSource = { try await MiruroResolver.resolve(mediaID: $0, episode: $1) }, qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }, skipClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) { self.qualityClock = qualityClock self.skipClock = skipClock + self.youtubeResolver = youtubeResolver + self.miruroResolver = miruroResolver keyBindings = configuration.keys compactTitleProgress = configuration.compactTitleProgress automaticYouTubeQuality = configuration.automaticQuality @@ -206,6 +218,25 @@ final class PlayerModel: ObservableObject { progressInitializationError = "Could not create the media player to initialize progress reporting." } } + if let streamQueue { + reportingErrorBuffer = [CChar](repeating: 0, count: 512) + for entry in streamQueue.entries { + guard let player else { + progressInitializationError = "Could not create the media player to initialize queue reports." + break + } + let registered = entry.identity.withCString { identity in + entry.progressFile.path.withCString { path in + player_register_progress_file(player, identity, path, &reportingErrorBuffer) + } + } + if !registered { + progressInitializationError = reportingErrorMessage + break + } + reportingEnabled = true + } + } compactWindow.onChange = { [weak self] in guard let self else { return } if !self.compactWindow.isTransitioning { @@ -349,9 +380,23 @@ final class PlayerModel: ObservableObject { } showsYouTubeInput = false clearQueue() + resolveStream(stream, change: .open) + } + + func openStreamQueue(_ streamQueue: StreamQueue) { + guard canOpen, progressInitializationError == nil else { return } + queueCastDevice = nil + queue.replaceStreams(streamQueue.entries.map { (url: $0.url, label: $0.label) }) + showsQueue = queue.items.count > 1 + showsYouTubeInput = false + openQueueSelection(keepingReceiver: false) + } + + private func resolveStream(_ stream: StreamURL, change: MediaChange) { + replaceMetadata(for: nil) switch stream { - case .youtube(let url): resolveYouTube(url, height: nil) - case .miruro(let mediaID, let episode): resolveMiruro(mediaID: mediaID, episode: episode) + case .youtube(let url): resolveYouTube(url, height: nil, change: change) + case .miruro(let mediaID, let episode): resolveMiruro(mediaID: mediaID, episode: episode, change: change) } } @@ -427,7 +472,7 @@ final class PlayerModel: ObservableObject { resolveYouTube(url, height: height, change: change) } - private func resolveMiruro(mediaID: String, episode: Int) { + private func resolveMiruro(mediaID: String, episode: Int, change: MediaChange = .open) { guard let handle else { return } if castDevice != nil { stopCasting(resume: false) } cancelStreamResolution() @@ -438,13 +483,17 @@ final class PlayerModel: ObservableObject { return } objectWillChange.send() - let request = transition.beginResolution(.open) + let request = transition.beginResolution(change) systemPlayback?.update() commandError = nil revealControls() + let resolver = miruroResolver let task = Task { [weak self] in do { - let source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode) + let source = try await resolver(mediaID, episode) + guard source.identity == "miruro:\(mediaID):\(episode)" else { + throw CocoaError(.fileReadCorruptFile) + } guard !Task.isCancelled, let self else { return } self.objectWillChange.send() guard let change = self.transition.finishResolution(request) else { return } @@ -475,9 +524,11 @@ final class PlayerModel: ObservableObject { systemPlayback?.update() commandError = nil revealControls() + let resolver = youtubeResolver let task = Task { [weak self] in do { - let video = try await YouTubeResolver.resolve(url, height: height) + let video = try await resolver(url, height) + guard video.webpageURL == url else { throw CocoaError(.fileReadCorruptFile) } guard !Task.isCancelled, let self, let handle = self.handle else { return } self.objectWillChange.send() guard var change = self.transition.finishResolution(request) else { return } @@ -544,6 +595,7 @@ final class PlayerModel: ObservableObject { self.fileName = title self.window?.title = title self.casting.updateTitle(title) + if self.queue.currentID != nil { self.queue.updateCurrentLabel(title) } self.systemPlayback?.update() } self.metadataTask = nil @@ -635,6 +687,7 @@ final class PlayerModel: ObservableObject { youtubeHeight = nil youtubeQuality = nil } + if let fileName, !source.url.isFileURL { queue.updateCurrentLabel(fileName) } selectSubtitle(nil) if case .resolved(let resolved) = source { subtitleTracks = resolved.subtitles @@ -706,7 +759,13 @@ final class PlayerModel: ObservableObject { queuePlaybackRevision = nil queueCompletionHandled = true cancelStreamResolution() - openMedia(.file(url), change: .queue(playing: receiver == nil)) + let change = MediaChange.queue(playing: receiver == nil) + if url.isFileURL { + openMedia(.file(url), change: change) + } else { + do { resolveStream(try StreamURL.parse(url.absoluteString), change: change) } + catch { commandError = error.localizedDescription } + } queueCastDevice = receiver } @@ -2905,15 +2964,15 @@ private struct PlayerContent: View { } .buttonStyle(.plain) .help("Remove from Queue") - .accessibilityLabel("Remove \(item.url.lastPathComponent) from Queue") + .accessibilityLabel("Remove \(item.displayName) from Queue") } VStack(alignment: .leading, spacing: 3) { - Text(item.url.deletingPathExtension().lastPathComponent) + Text(item.url.isFileURL ? item.url.deletingPathExtension().lastPathComponent : item.displayName) .font(.system(size: 13, weight: .medium)) .lineLimit(2) .lineSpacing(2) .truncationMode(.middle) - if !item.url.pathExtension.isEmpty { + if item.url.isFileURL && !item.url.pathExtension.isEmpty { Text(item.url.pathExtension.uppercased()) .font(.system(size: 10, weight: .medium)) .tracking(0.6) @@ -2933,7 +2992,7 @@ private struct PlayerContent: View { } .contentShape(Rectangle()) .onTapGesture { model.selectQueueItem(item.id) } - .help(item.url.path + "\nClick to play; drag to reorder.") + .help((item.url.isFileURL ? item.url.path : item.displayName) + "\nClick to play; drag to reorder.") .contextMenu { Button("Play") { model.selectQueueItem(item.id) } Button("Remove from Queue") { model.removeQueueItem(item.id) } @@ -3276,24 +3335,44 @@ private struct PlayerContent: View { .allowsHitTesting(!model.isCompact && model.controlsVisible) .accessibilityHidden(model.isCompact || !model.controlsVisible) } - if let interval = model.segmentToSkip { - Button { - model.skipSegment() - } label: { - Label(interval.kind == .intro ? "Skip Opening" : "Skip Ending", - systemImage: "forward.end.fill") - .font(.system(size: model.isCompact ? 11 : 13, weight: .semibold)) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .contentShape(RoundedRectangle(cornerRadius: 8)) + ZStack(alignment: .topTrailing) { + if let interval = model.segmentToSkip { + Button { + model.skipSegment() + } label: { + Label(interval.kind == .intro ? "Skip Opening" : "Skip Ending", + systemImage: "forward.end.fill") + .font(.system(size: model.isCompact ? 11 : 13, weight: .semibold)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .foregroundStyle(Color(SunsetPalette.text)) + .background { + ZStack(alignment: .leading) { + PlayerGlass(cornerRadius: 8) + TimelineView(.animation(minimumInterval: reduceMotion ? 1 : 1.0 / 30)) { _ in + GeometryReader { geometry in + let remaining = model.skipOfferRemainingFraction + Color(SunsetPalette.accent).opacity(0.25) + .frame(width: geometry.size.width * + (reduceMotion ? ceil(remaining * 10) / 10 : remaining)) + } + } + } + .clipShape(RoundedRectangle(cornerRadius: 8)) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + .transition(reduceMotion ? .identity : .opacity.combined(with: .move(edge: .top))) } - .buttonStyle(.plain) - .foregroundStyle(Color(SunsetPalette.text)) - .background(PlayerGlass(cornerRadius: 8).clipShape(RoundedRectangle(cornerRadius: 8))) - .padding(.trailing, model.isCompact ? 8 : 16) - .padding(.top, 44) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) } + .padding(.trailing, model.isCompact ? 8 : 16) + .padding(.top, 44) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: model.segmentToSkip?.kind) + .allowsHitTesting(model.segmentToSkip != nil) if dropTargeted { RoundedRectangle(cornerRadius: 10) .strokeBorder(Color(SunsetPalette.accent), lineWidth: 2) @@ -3467,6 +3546,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if let index = arguments.firstIndex(of: "--source-file"), index + 1 < arguments.count { PlayerModel.shared.openResolvedSource(URL(fileURLWithPath: arguments[index + 1])) } + if let streamQueue = PlayerModel.startupStreamQueue { + PlayerModel.shared.openStreamQueue(streamQueue) + } } // Let SwiftUI create the initial window for a Finder/document launch. @@ -3521,6 +3603,24 @@ public func terebiAppMain() { exit(78) } let arguments = Array(CommandLine.arguments.dropFirst()) + if let index = arguments.firstIndex(of: "--queue-file") { + do { + guard index + 1 < arguments.count, arguments[index + 1].hasPrefix("/"), + arguments.filter({ $0 == "--queue-file" }).count == 1, + !arguments.contains("--progress-file"), !arguments.contains("--source-file"), + !arguments.contains("--open-media") else { + throw CocoaError(.fileReadCorruptFile) + } + PlayerModel.startupStreamQueue = try StreamQueue.load(from: URL(fileURLWithPath: arguments[index + 1])) + _ = NSApplication.shared + if let error = PlayerModel.shared.progressInitializationError { + throw NSError(domain: "Terebi.Queue", code: 73, userInfo: [NSLocalizedDescriptionKey: error]) + } + } catch { + FileHandle.standardError.write(Data("terebi: \(error.localizedDescription)\n".utf8)) + exit(73) + } + } if let index = arguments.firstIndex(of: "--progress-file") { guard index + 1 < arguments.count, arguments[index + 1].hasPrefix("/"), arguments.filter({ $0 == "--progress-file" }).count == 1 else { diff --git a/Sources/Core/progress_reporter.cpp b/Sources/Core/progress_reporter.cpp index a78b807..65de3d5 100644 --- a/Sources/Core/progress_reporter.cpp +++ b/Sources/Core/progress_reporter.cpp @@ -293,12 +293,12 @@ ProgressReports::ProgressReports(const std::string &path) : single(std::make_unique(path)), active(single.get()) {} void ProgressReports::registerFile(const std::string &identity, const std::string &path) { + std::lock_guard lock(mutex); if (single || !ProgressReporter::validMediaIdentity(identity) || entries.size() >= 4096 || path.empty() || path.front() != '/' || path.find('\0') != std::string::npos || entries.contains(identity)) throw std::runtime_error("Invalid or duplicate queue progress registration"); - for (const auto &[key, entry] : entries) - if (entry.path == path) throw std::runtime_error("Duplicate queue progress report path"); + if (!paths.insert(path).second) throw std::runtime_error("Duplicate queue progress report path"); entries.emplace(identity, Entry{path, nullptr}); } diff --git a/Sources/Core/progress_reporter.hpp b/Sources/Core/progress_reporter.hpp index 946fddd..bc7c099 100644 --- a/Sources/Core/progress_reporter.hpp +++ b/Sources/Core/progress_reporter.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -82,6 +83,7 @@ private: struct Entry { std::string path; std::unique_ptr reporter; }; std::unique_ptr single; std::map entries; + std::set paths; ProgressReporter *active = nullptr; mutable std::mutex mutex; std::string failure; diff --git a/TODO.md b/TODO.md index 1ee93f9..ecc6202 100644 --- a/TODO.md +++ b/TODO.md @@ -22,7 +22,7 @@ - audited SDK 27.0 / Swift 6.4 with the macOS 15 minimum retained; migrated Cast discovery to NWBrowser and production activation to activate(). Real Bonjour identity/name/update/order/deduplication/removal/restart and routable-endpoint checks, loopback Cast transitions, focused startup-error alerts and a cooperating-launcher production smoke for cold start/CLI reuse/document handoff/keyboard playback passed. The existing full CLI snapshot-harness timeout remains; macOS 15 runtime and physical-Cast verification remain unverified. Observation, glass and the existing native bridges were left unchanged. - [x] fix the readme. it's currently bloated with irrelevant implementation detail and other such nonsense. it should do the job of a readme only, nothing more. - [x] add a skip button when current playback is inside either an opening or an ending segment. - - Skip Opening/Ending uses validated metadata, seeks to the segment end and disappears after ten seconds (including while paused) or on leaving the segment; ignored segments stay dismissed until close/media replacement. Native HLS, exact-EOF, controlled-clock dismissal and loopback Cast checks passed. Button placement and physical-Cast verification remain unverified; rapid play/seek probes encountered Core Audio startup errors on Bluetooth output. + - Skip Opening/Ending uses validated metadata, seeks to the segment end and disappears after ten seconds (including while paused) or on leaving the segment; ignored segments stay dismissed until close/media replacement. A draining background shows remaining offer time, with 200 ms fade/slide transitions; Reduce Motion uses stepped countdown updates without transitions. Native HLS, exact-EOF, controlled-clock countdown/dismissal and loopback Cast checks passed. Button placement, animation feel and physical-Cast verification remain unverified; rapid play/seek probes encountered Core Audio startup errors on Bluetooth output. - [ ] improve: subtitles. - decrease padding on captions - decrease font size of captions by 10 percent \ No newline at end of file diff --git a/docs/miruro-playback-handoff.md b/docs/miruro-playback-handoff.md index 5ec33bd..80bb3f3 100644 --- a/docs/miruro-playback-handoff.md +++ b/docs/miruro-playback-handoff.md @@ -25,6 +25,32 @@ The slug `episode` is deliberately non-identifying. Use the returned episode num Create a private reporting-session directory (`0700`) and supply a **nonexistent** progress-file destination. Terebi exclusively creates the report (`0600`), rejecting existing destinations and symlinks. CLI success means macOS accepted the launch, not that playback succeeded. Tubular consumes reporting independently of launcher lifetime; launcher exit, a final record or Tubular shutdown does not establish that the player stopped. Preserve retained sessions and restart/partial-line recovery. +### Ordered stream queues + +```sh +terebi --new-instance --queue-file '/absolute/private/session/queue.json' +``` + +The manifest is bounded to 8 MiB, version 1, and 1–4096 entries in playback order: + +```json +{ + "version": 1, + "items": [ + {"url": "https://www.youtube.com/watch?v=jNQXAC9IVRw", "progress_file": "/absolute/private/youtube-session/progress.jsonl"}, + {"url": "https://barelystarted.miruro.tv/watch/OPAQUE_ID/episode?ep=1", "progress_file": "/absolute/private/episode-session/progress.jsonl"} + ] +} +``` + +Use canonical YouTube video pages or supported Miruro episode pages, never expiring source addresses. Stable identities must be unique even across alternate YouTube forms or different Miruro slugs. Reports must have distinct absolute nonexistent destinations, each under a canonical, nonsymlink, user-owned `0700` directory. Tubular retains each item's existing identity registration/session and stores the manifest in the first session. The CLI validates the complete document before launching; the GUI validates it again before opening any media. Invalid queues fail rather than falling back to a single stream. Queue launch requires `--new-instance`, accepts `--no-config`, and cannot combine with `--url`, `--source-file` or `--progress-file`. + +The supplied order is preserved (local-file batches still use natural filename order). Existing sidebar reorder/remove/play and previous/next navigation apply. Natural end advances without clearing the queue. Sources resolve only when selected, resolve afresh on revisits, and canceled/stale results cannot replace a newer selection. Labels start with descriptive page information and update from resolved titles/optional metadata. + +Each report is immutably bound to its stable identity in the native decoder. Switching entries drains/closes the previous writer before beginning the next; revisits append only after verifying the original private report inode, owner, permissions and link count. An unrelated or replaced file is never reopened as an owned session, and an unregistered identity is never written to the previously selected report. Only the active item owns a running writer/open report descriptor. A reporting failure is shown separately without rerouting facts or changing acknowledged playback/completion semantics. Single-stream `--progress-file` remains unchanged. + +Runnable queue checks: `make check-cli check-queue check-reporting check-resolved`. The existing `check-resolved` local HTTP/HLS/VTT harness also drives a real native `PlayerModel` through a mixed YouTube/Miruro queue with injected fixture resolution, stale-result cancellation, manual navigation, natural-end advancement, fresh revisits/resume and isolated v1/v2 reports. Run `TEREBI_CHECK_STREAM_QUEUE_ONLY=1 make check-startup` for focused actual CLI→LaunchServices→GUI queue delivery, native decode, manual previous/next, natural-end advancement and per-item report revisits using the existing bundled-extractor/local-media fixture. These checks require no browser or live streaming service. Full `make check-startup` includes this queue scenario too. These additions have not yet been executed as part of this change. + ### Optional native metadata The native resolver requests watch-route data and safely follows bounded same-origin, same-ID, same-episode canonicalization envelopes, including HTTP-200 redirects. It accepts JSON and newline-delimited `text/sveltekit-data`, decoding only the needed response-local tables and episode promise. Bodies and traversal are bounded; unrelated source promises are not used for playback.