//! Native bridge handlers for YT Player. //! //! These run on the Zig side and are invoked from the web UI via //! `window.zero.invoke(command, payload)`. They: //! * yt.search — run yt-dlp search and return a slim result list //! * yt.streams — run yt-dlp on one video and return playable stream URLs //! * store.load — read the local playlists/history/settings JSON //! * store.save — write it back //! //! Handler signature follows the zero-native bridge contract: //! fn(context: *anyopaque, invocation: bridge.Invocation, output: []u8) anyerror![]const u8 //! The returned slice MUST point inside `output`. The bridge is size-limited, //! so handlers parse yt-dlp's large JSON and emit only the compact fields the //! UI needs. //! //! Targets Zig 0.14 std. If your installed zero-native exposes the Invocation //! type under a different path, only the `payloadOf` helper and the handler //! parameter type need adjusting — the logic below is self-contained. const std = @import("std"); const zero_native = @import("zero_native"); const Invocation = zero_native.bridge.Invocation; const MAX_OUTPUT = 4 * 1024 * 1024; // yt-dlp -J can be large; give it room. const SEARCH_LIMIT = 25; // --------------------------------------------------------------------------- // Small JSON string escaper writing into any writer. // --------------------------------------------------------------------------- fn writeJsonString(w: anytype, s: []const u8) !void { try w.writeByte('"'); for (s) |c| { switch (c) { '"' => try w.writeAll("\\\""), '\\' => try w.writeAll("\\\\"), '\n' => try w.writeAll("\\n"), '\r' => try w.writeAll("\\r"), '\t' => try w.writeAll("\\t"), 0x00...0x08, 0x0b, 0x0c, 0x0e...0x1f => try w.print("\\u{x:0>4}", .{c}), else => try w.writeByte(c), } } try w.writeByte('"'); } fn jsonStr(v: ?std.json.Value) []const u8 { if (v) |val| { return switch (val) { .string => |s| s, else => "", }; } return ""; } fn jsonNum(v: ?std.json.Value) f64 { if (v) |val| { return switch (val) { .integer => |i| @floatFromInt(i), .float => |f| f, .number_string => |s| std.fmt.parseFloat(f64, s) catch 0, else => 0, }; } return 0; } // --------------------------------------------------------------------------- // Locate the yt-dlp binary: prefer the bundled ./bin copy, fall back to PATH. // --------------------------------------------------------------------------- fn ytDlpPath(allocator: std.mem.Allocator) []const u8 { const candidates = [_][]const u8{ "bin/yt-dlp", "./bin/yt-dlp", "yt-dlp" }; for (candidates) |c| { if (std.mem.eql(u8, c, "yt-dlp")) return c; // PATH fallback std.fs.cwd().access(c, .{}) catch continue; return allocator.dupe(u8, c) catch c; } return "yt-dlp"; } fn runYtDlp(allocator: std.mem.Allocator, argv: []const []const u8) ![]const u8 { const result = try std.process.Child.run(.{ .allocator = allocator, .argv = argv, .max_output_bytes = MAX_OUTPUT, }); if (result.term != .Exited or result.term.Exited != 0) { // Surface yt-dlp's stderr to the caller. if (result.stderr.len > 0) return error.YtDlpFailed; return error.YtDlpFailed; } return result.stdout; } // --------------------------------------------------------------------------- // Payload helpers — extract a string field from the invocation payload JSON. // --------------------------------------------------------------------------- fn payloadField(allocator: std.mem.Allocator, payload: []const u8, key: []const u8) !?[]const u8 { if (payload.len == 0) return null; var parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch return null; defer parsed.deinit(); if (parsed.value != .object) return null; const v = parsed.value.object.get(key) orelse return null; if (v != .string) return null; return try allocator.dupe(u8, v.string); } // =========================================================================== // Handler: yt.search // =========================================================================== pub fn ytSearch(context: *anyopaque, invocation: Invocation, output: []u8) anyerror![]const u8 { _ = context; var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const a = arena.allocator(); const query = (try payloadField(a, invocation.request.payload, "query")) orelse ""; if (query.len == 0) return errorJson(output, "empty query"); const exe = ytDlpPath(a); const search_arg = try std.fmt.allocPrint(a, "ytsearch{d}:{s}", .{ SEARCH_LIMIT, query }); const argv = [_][]const u8{ exe, search_arg, "--dump-json", "--flat-playlist", "--no-warnings", "--ignore-errors", }; const out = runYtDlp(a, &argv) catch |e| { return errorJson(output, @errorName(e)); }; var fbs = std.io.fixedBufferStream(output); const w = fbs.writer(); try w.writeAll("{\"ok\":true,\"results\":["); var first = true; var it = std.mem.splitScalar(u8, out, '\n'); while (it.next()) |line| { const trimmed = std.mem.trim(u8, line, " \r\t"); if (trimmed.len == 0) continue; var parsed = std.json.parseFromSlice(std.json.Value, a, trimmed, .{}) catch continue; defer parsed.deinit(); if (parsed.value != .object) continue; const obj = parsed.value.object; const id = jsonStr(obj.get("id")); if (id.len == 0) continue; if (!first) try w.writeByte(','); first = false; try w.writeAll("{\"id\":"); try writeJsonString(w, id); try w.writeAll(",\"title\":"); try writeJsonString(w, jsonStr(obj.get("title"))); try w.writeAll(",\"channel\":"); const ch = if (jsonStr(obj.get("channel")).len > 0) jsonStr(obj.get("channel")) else jsonStr(obj.get("uploader")); try writeJsonString(w, ch); try w.print(",\"duration\":{d}", .{jsonNum(obj.get("duration"))}); try w.writeAll(",\"thumbnail\":"); const thumb = try std.fmt.allocPrint(a, "https://i.ytimg.com/vi/{s}/mqdefault.jpg", .{id}); try writeJsonString(w, thumb); try w.writeByte('}'); } try w.writeAll("]}"); return fbs.getWritten(); } // =========================================================================== // Handler: yt.streams // =========================================================================== pub fn ytStreams(context: *anyopaque, invocation: Invocation, output: []u8) anyerror![]const u8 { _ = context; var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const a = arena.allocator(); const video_id = (try payloadField(a, invocation.request.payload, "videoId")) orelse ""; if (video_id.len == 0) return errorJson(output, "missing videoId"); const exe = ytDlpPath(a); const url = try std.fmt.allocPrint(a, "https://www.youtube.com/watch?v={s}", .{video_id}); const argv = [_][]const u8{ exe, "-J", "--no-warnings", url }; const out = runYtDlp(a, &argv) catch |e| { return errorJson(output, @errorName(e)); }; var parsed = std.json.parseFromSlice(std.json.Value, a, out, .{}) catch { return errorJson(output, "parse error"); }; defer parsed.deinit(); if (parsed.value != .object) return errorJson(output, "bad info json"); const info = parsed.value.object; const title = jsonStr(info.get("title")); const channel = if (jsonStr(info.get("channel")).len > 0) jsonStr(info.get("channel")) else jsonStr(info.get("uploader")); const duration = jsonNum(info.get("duration")); var fbs = std.io.fixedBufferStream(output); const w = fbs.writer(); try w.writeAll("{\"ok\":true,\"data\":{\"meta\":{\"id\":"); try writeJsonString(w, video_id); try w.writeAll(",\"title\":"); try writeJsonString(w, title); try w.writeAll(",\"channel\":"); try writeJsonString(w, channel); try w.print(",\"duration\":{d}", .{duration}); try w.writeAll(",\"thumbnail\":"); const thumb = try std.fmt.allocPrint(a, "https://i.ytimg.com/vi/{s}/hqdefault.jpg", .{video_id}); try writeJsonString(w, thumb); try w.writeByte('}'); // Walk formats. Track best audio, and emit video/progressive qualities. var best_audio_url: []const u8 = ""; var best_audio_abr: f64 = -1; const formats = info.get("formats"); if (formats) |fv| { if (fv == .array) { // First pass: best audio-only stream (prefer m4a/mp4a). for (fv.array.items) |item| { if (item != .object) continue; const f = item.object; const vcodec = jsonStr(f.get("vcodec")); const acodec = jsonStr(f.get("acodec")); const furl = jsonStr(f.get("url")); if (furl.len == 0) continue; const has_video = vcodec.len > 0 and !std.mem.eql(u8, vcodec, "none"); const has_audio = acodec.len > 0 and !std.mem.eql(u8, acodec, "none"); if (!has_video and has_audio) { var score = jsonNum(f.get("abr")); if (std.mem.indexOf(u8, acodec, "mp4a") != null) score += 1000; if (score > best_audio_abr) { best_audio_abr = score; best_audio_url = furl; } } } } } try w.writeAll(",\"audioUrl\":"); if (best_audio_url.len > 0) try writeJsonString(w, best_audio_url) else try w.writeAll("null"); try w.writeAll(",\"qualities\":["); var firstq = true; // Track which heights we've already emitted to dedupe. var seen = std.AutoHashMap(i64, void).init(a); if (formats) |fv| { if (fv == .array) { // Adaptive video-only, then progressive — both keyed by height. const passes = [_]bool{ false, true }; // false=video-only, true=progressive for (passes) |want_progressive| { for (fv.array.items) |item| { if (item != .object) continue; const f = item.object; const vcodec = jsonStr(f.get("vcodec")); const acodec = jsonStr(f.get("acodec")); const furl = jsonStr(f.get("url")); if (furl.len == 0) continue; const has_video = vcodec.len > 0 and !std.mem.eql(u8, vcodec, "none"); const has_audio = acodec.len > 0 and !std.mem.eql(u8, acodec, "none"); if (!has_video) continue; const is_progressive = has_audio; if (is_progressive != want_progressive) continue; const height: i64 = @intFromFloat(jsonNum(f.get("height"))); if (height <= 0) continue; if (seen.contains(height)) continue; seen.put(height, {}) catch {}; if (!firstq) try w.writeByte(','); firstq = false; try w.print("{{\"label\":\"{d}p\",\"height\":{d},\"hasAudio\":{s},\"url\":", .{ height, height, if (is_progressive) "true" else "false", }); try writeJsonString(w, furl); try w.writeAll(",\"ext\":"); try writeJsonString(w, jsonStr(f.get("ext"))); try w.writeByte('}'); } } } } try w.writeAll("]}}"); return fbs.getWritten(); } // =========================================================================== // Handlers: store.load / store.save (local JSON in the app data dir) // =========================================================================== fn dataFilePath(a: std.mem.Allocator) ![]const u8 { const dir = std.fs.getAppDataDir(a, "ytplayer") catch "."; std.fs.cwd().makePath(dir) catch {}; return std.fs.path.join(a, &.{ dir, "ytplayer-data.json" }); } pub fn storeLoad(context: *anyopaque, invocation: Invocation, output: []u8) anyerror![]const u8 { _ = context; _ = invocation; var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const a = arena.allocator(); const path = try dataFilePath(a); const file = std.fs.cwd().openFile(path, .{}) catch { const def = "{\"playlists\":[],\"history\":[],\"settings\":{\"quality\":\"auto\",\"volume\":1,\"audioOnly\":false}}"; if (def.len > output.len) return error.NoSpaceLeft; @memcpy(output[0..def.len], def); return output[0..def.len]; }; defer file.close(); const n = try file.readAll(output); return output[0..n]; } pub fn storeSave(context: *anyopaque, invocation: Invocation, output: []u8) anyerror![]const u8 { _ = context; var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const a = arena.allocator(); const blob = (try payloadField(a, invocation.request.payload, "data")) orelse { // payload.data may itself be an object; re-serialize the whole payload's "data". return errorJson(output, "missing data"); }; const path = try dataFilePath(a); const tmp = try std.fmt.allocPrint(a, "{s}.tmp", .{path}); { const file = try std.fs.cwd().createFile(tmp, .{ .truncate = true }); defer file.close(); try file.writeAll(blob); } try std.fs.cwd().rename(tmp, path); const ok = "{\"ok\":true}"; @memcpy(output[0..ok.len], ok); return output[0..ok.len]; } fn errorJson(output: []u8, msg: []const u8) []const u8 { var fbs = std.io.fixedBufferStream(output); const w = fbs.writer(); w.writeAll("{\"ok\":false,\"error\":") catch return "{\"ok\":false}"; writeJsonString(w, msg) catch return "{\"ok\":false}"; w.writeByte('}') catch return "{\"ok\":false}"; return fbs.getWritten(); }