Files
ytplayer/legacy/src/bridge.zig
Jonathan Sykes 4efa1d1182 feat: convert to PWA — OPFS storage, service worker, Bun/Hono server
- Move native shells (Zig/src, Tauri/src-tauri, app.zon, releases) to legacy/
- Add Bun + Hono server with yt-dlp proxy endpoints (search, channel, streams,
  download), libsql (concurrent SQLite fork) for fingerprint-keyed playlist/
  history sync, and static file serving for the frontend
- Add Dockerfile + docker-compose.yml (single container, volume-mounted DB)
- Add frontend/sw.js: app-shell cache-first, /api/* network-only,
  thumbnails stale-while-revalidate, SW_UPDATE_AVAILABLE broadcast,
  SKIP_WAITING message handler for seamless auto-update
- Add frontend/manifest.webmanifest: standalone PWA, vermilion theme,
  search/history shortcuts
- Add frontend/icons/icon-{192,512}.png: generated PWA icons
- Add frontend/fingerprint.js: canvas+UA djb2 fingerprint, localStorage-cached,
  exposes window.getFingerprint() for server-side playlist keying
- Add frontend/opfs.js: full OPFS video store (writeFromResponse streams
  directly without full-file buffering), exposes window.OPFS
- Add scripts/make-pwa-icons.js: regenerate icons without external deps
- Patch frontend/app.js: WEB mode detection, webFetch + opfs* bridge wrappers,
  API object routes to WEB helpers when no native bridge present,
  Player.loadVideo handles OPFS blob URLs + revokes them on next load,
  SW registration + update banner in boot()
- Patch frontend/index.html: manifest link, theme-color, Apple PWA meta,
  CSP blob:/worker-src, fingerprint.js + opfs.js script tags
- Patch frontend/styles.css: .toast-update + .toast-reload-btn for update banner
- Native Tauri/Zig builds unchanged — all new code is additive via WEB flag
2026-06-30 06:43:23 +08:00

465 lines
18 KiB
Zig
Executable File

//! 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;
}
// Channel display name, URL and id, falling back to the uploader_* variants.
fn pickChannel(obj: std.json.ObjectMap) []const u8 {
const c = jsonStr(obj.get("channel"));
return if (c.len > 0) c else jsonStr(obj.get("uploader"));
}
fn pickChannelUrl(obj: std.json.ObjectMap) []const u8 {
const u = jsonStr(obj.get("channel_url"));
return if (u.len > 0) u else jsonStr(obj.get("uploader_url"));
}
fn pickChannelId(obj: std.json.ObjectMap) []const u8 {
const id = jsonStr(obj.get("channel_id"));
return if (id.len > 0) id else jsonStr(obj.get("uploader_id"));
}
// Write one slim video card object into `w` from a flat-playlist record.
fn writeCard(w: anytype, a: std.mem.Allocator, obj: std.json.ObjectMap) !bool {
const id = jsonStr(obj.get("id"));
if (id.len == 0) return false;
try w.writeAll("{\"id\":");
try writeJsonString(w, id);
try w.writeAll(",\"title\":");
try writeJsonString(w, jsonStr(obj.get("title")));
try w.writeAll(",\"channel\":");
try writeJsonString(w, pickChannel(obj));
try w.writeAll(",\"channelId\":");
try writeJsonString(w, pickChannelId(obj));
try w.writeAll(",\"channelUrl\":");
try writeJsonString(w, pickChannelUrl(obj));
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('}');
return true;
}
// ---------------------------------------------------------------------------
// 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;
if (jsonStr(obj.get("id")).len == 0) continue;
if (!first) try w.writeByte(',');
const wrote = try writeCard(w, a, obj);
if (wrote) first = false;
}
try w.writeAll("]}");
return fbs.getWritten();
}
// ===========================================================================
// Handler: yt.channel — list a channel's recent uploads
// ===========================================================================
pub fn ytChannel(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 channel = (try payloadField(a, invocation.request.payload, "channel")) orelse "";
if (channel.len == 0) return errorJson(output, "missing channel");
// Resolve to a "/videos" tab URL whether we were handed a URL, @handle,
// UC… id, or a bare name.
var base: []const u8 = undefined;
if (std.mem.startsWith(u8, channel, "http")) {
base = std.mem.trimRight(u8, channel, "/");
} else if (std.mem.startsWith(u8, channel, "@")) {
base = try std.fmt.allocPrint(a, "https://www.youtube.com/{s}", .{channel});
} else if (std.mem.startsWith(u8, channel, "UC")) {
base = try std.fmt.allocPrint(a, "https://www.youtube.com/channel/{s}", .{channel});
} else {
base = try std.fmt.allocPrint(a, "https://www.youtube.com/@{s}", .{channel});
}
const url = if (std.mem.endsWith(u8, base, "/videos"))
base
else
try std.fmt.allocPrint(a, "{s}/videos", .{base});
const exe = ytDlpPath(a);
const argv = [_][]const u8{
exe, url, "--dump-json", "--flat-playlist", "--no-warnings", "--ignore-errors", "--playlist-end", "60",
};
const out = runYtDlp(a, &argv) catch |e| {
return errorJson(output, @errorName(e));
};
var fbs = std.io.fixedBufferStream(output);
const w = fbs.writer();
// Two passes: first scan for the channel name/url, then stream the cards.
var name: []const u8 = "";
var chan_url: []const u8 = "";
{
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;
if (name.len == 0) name = a.dupe(u8, pickChannel(obj)) catch "";
if (chan_url.len == 0) chan_url = a.dupe(u8, pickChannelUrl(obj)) catch "";
if (name.len > 0 and chan_url.len > 0) break;
}
}
try w.writeAll("{\"ok\":true,\"channel\":");
try writeJsonString(w, name);
try w.writeAll(",\"channelUrl\":");
try writeJsonString(w, chan_url);
try w.writeAll(",\"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;
if (jsonStr(obj.get("id")).len == 0) continue;
if (!first) try w.writeByte(',');
const wrote = try writeCard(w, a, obj);
if (wrote) first = false;
}
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 = pickChannel(info);
const channel_id = pickChannelId(info);
const channel_url = pickChannelUrl(info);
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.writeAll(",\"channelId\":");
try writeJsonString(w, channel_id);
try w.writeAll(",\"channelUrl\":");
try writeJsonString(w, channel_url);
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();
}