- 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
465 lines
18 KiB
Zig
Executable File
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();
|
|
}
|