@mekuru/youtube 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.jp.md ADDED
@@ -0,0 +1,19 @@
1
+ # @mekuru/youtube
2
+
3
+ [English](README.md)
4
+
5
+ Mekuru Movie Maker の動画を YouTube にアップロードします。動画のパス、タイトル、説明文、タグ、公開設定、任意のサムネイルを YAML で指定します。Bun 1.4 以上が必要です。
6
+
7
+ [投稿ガイド](../../docs/reference/Upload.jp.md) に従って `configs/youtube.yaml` と OAuth クライアントの設定を準備し、プロジェクトのフォルダから実行します。
8
+
9
+ ```sh
10
+ bun add -d @mekuru/youtube
11
+ bunx mekuru-youtube --dry-run
12
+ bunx mekuru-youtube
13
+ ```
14
+
15
+ - 既定の設定ファイルは `configs/youtube.yaml` です。引数に別の YAML のパスを渡せます。複数渡すと順にアップロードします。
16
+ - 設定内のファイルパスはプロジェクトのフォルダからの相対パスです。
17
+ - `--dry-run` はログインやアップロードをせずに設定を表示します。初回のアップロードでは Google にログインするためのブラウザが開きます。
18
+
19
+ [設定例](../../example/configs/youtube.yaml)
package/README.md CHANGED
@@ -1,3 +1,19 @@
1
- # Temporary Holding Version
1
+ # @mekuru/youtube
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [日本語](README.jp.md)
4
+
5
+ Uploads Mekuru Movie Maker videos to YouTube using a YAML file for the video path, title, description, tags, visibility, and optional thumbnail. Requires Bun 1.4 or later.
6
+
7
+ Prepare `configs/youtube.yaml` and the OAuth client configuration described in the [upload guide](../../docs/reference/Upload.md), then run from the project folder:
8
+
9
+ ```sh
10
+ bun add -d @mekuru/youtube
11
+ bunx mekuru-youtube --dry-run
12
+ bunx mekuru-youtube
13
+ ```
14
+
15
+ - The default configuration file is `configs/youtube.yaml`; pass other YAML paths as arguments to upload multiple videos in order.
16
+ - File paths in the configuration are relative to the project folder.
17
+ - `--dry-run` displays the upload settings without signing in or uploading. The first upload opens a browser for Google sign-in.
18
+
19
+ See the [example configuration](../../example/configs/youtube.yaml).
package/package.json CHANGED
@@ -1,6 +1,18 @@
1
1
  {
2
2
  "name": "@mekuru/youtube",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.2",
4
+ "description": "Uploads a video of Mekuru Movie Maker to YouTube with the title, description, ... written in youtube.yaml",
5
+ "type": "module",
6
+ "bin": {
7
+ "mekuru-youtube": "./src/cli.ts"
8
+ },
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "engines": {
13
+ "bun": ">=1.4"
14
+ },
15
+ "dependencies": {
16
+ "commander": "^14.0.3"
17
+ }
18
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,120 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const SCOPE = "https://www.googleapis.com/auth/youtube.upload";
6
+ const SIGN_IN_TIMEOUT = 5 * 60_000;
7
+
8
+ interface Client {
9
+ client_id: string;
10
+ client_secret: string;
11
+ auth_uri: string;
12
+ token_uri: string;
13
+ }
14
+
15
+ export const configDir = (): string => {
16
+ if (process.env.MEKURU_CONFIG_DIR) return process.env.MEKURU_CONFIG_DIR;
17
+ if (process.platform === "win32") return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "mekuru");
18
+ return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "mekuru");
19
+ };
20
+
21
+ const readClient = async (file: string): Promise<Client> => {
22
+ if (!(await Bun.file(file).exists())) {
23
+ throw new Error(`no OAuth client: save the JSON of the OAuth client (a desktop app) of your Google Cloud project as ${file}`);
24
+ }
25
+ const c = ((await Bun.file(file).json()) as { installed?: Partial<Client> }).installed;
26
+ if (!c?.client_id || !c.client_secret) throw new Error(`${file} is not the JSON of an OAuth client of a desktop app`);
27
+ return {
28
+ client_id: c.client_id,
29
+ client_secret: c.client_secret,
30
+ auth_uri: c.auth_uri ?? "https://accounts.google.com/o/oauth2/auth",
31
+ token_uri: c.token_uri ?? "https://oauth2.googleapis.com/token",
32
+ };
33
+ };
34
+
35
+ interface TokenResponse {
36
+ ok: boolean;
37
+ access_token?: string;
38
+ refresh_token?: string;
39
+ error?: string;
40
+ error_description?: string;
41
+ }
42
+
43
+ const requestToken = async (client: Client, params: Record<string, string>): Promise<TokenResponse> => {
44
+ const res = await fetch(client.token_uri, {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
47
+ body: new URLSearchParams({ client_id: client.client_id, client_secret: client.client_secret, ...params }),
48
+ });
49
+ return { ok: res.ok, ...((await res.json().catch(() => ({}))) as Omit<TokenResponse, "ok">) };
50
+ };
51
+
52
+ const signIn = async (client: Client, open: (url: string) => void): Promise<{ access: string; refresh: string }> => {
53
+ const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url");
54
+ const state = crypto.randomUUID();
55
+ let server: ReturnType<typeof Bun.serve> | undefined;
56
+ let timer: ReturnType<typeof setTimeout> | undefined;
57
+ let redirect = "";
58
+ try {
59
+ const code = await new Promise<string>((done, fail) => {
60
+ server = Bun.serve({
61
+ hostname: "127.0.0.1",
62
+ port: 0,
63
+ fetch(req) {
64
+ const got = new URL(req.url).searchParams;
65
+ if (got.get("state") !== state) return new Response(null, { status: 404 });
66
+ clearTimeout(timer);
67
+ const code = got.get("code");
68
+ if (code) done(code);
69
+ else fail(new Error(`the sign-in failed: ${got.get("error") ?? "no code"}`));
70
+ return new Response(
71
+ code
72
+ ? "<p>Mekuru Movie Maker: signed in to YouTube. You can close this tab.</p>"
73
+ : "<p>Mekuru Movie Maker: the sign-in failed. See the terminal.</p>",
74
+ { headers: { "content-type": "text/html; charset=utf-8", connection: "close" } },
75
+ );
76
+ },
77
+ });
78
+ redirect = `http://127.0.0.1:${server.port}`;
79
+ timer = setTimeout(() => fail(new Error("timed out waiting for the sign-in in the browser")), SIGN_IN_TIMEOUT);
80
+ const url = new URL(client.auth_uri);
81
+ url.search = new URLSearchParams({
82
+ client_id: client.client_id,
83
+ redirect_uri: redirect,
84
+ response_type: "code",
85
+ scope: SCOPE,
86
+ access_type: "offline",
87
+ prompt: "consent",
88
+ state,
89
+ code_challenge: new Bun.CryptoHasher("sha256").update(verifier).digest("base64url"),
90
+ code_challenge_method: "S256",
91
+ }).toString();
92
+ console.error(`Sign in to YouTube in the browser:\n ${url}`);
93
+ open(url.toString());
94
+ });
95
+ const t = await requestToken(client, { code, code_verifier: verifier, grant_type: "authorization_code", redirect_uri: redirect });
96
+ if (!t.ok || !t.access_token || !t.refresh_token) throw new Error(`the sign-in failed: ${t.error_description ?? t.error ?? "no token"}`);
97
+ return { access: t.access_token, refresh: t.refresh_token };
98
+ } finally {
99
+ clearTimeout(timer);
100
+ await server?.stop();
101
+ }
102
+ };
103
+
104
+ export const accessToken = async (open: (url: string) => void): Promise<string> => {
105
+ const dir = configDir();
106
+ const client = await readClient(join(dir, "youtube-client.json"));
107
+ const tokenFile = join(dir, "youtube-token.json");
108
+ if (await Bun.file(tokenFile).exists()) {
109
+ const { refresh_token } = (await Bun.file(tokenFile).json()) as { refresh_token: string };
110
+ const t = await requestToken(client, { refresh_token, grant_type: "refresh_token" });
111
+ if (t.ok && t.access_token) return t.access_token;
112
+ if (t.error !== "invalid_grant") throw new Error(`cannot renew the sign-in: ${t.error_description ?? t.error ?? "no token"}`);
113
+ console.error("The sign-in to YouTube has expired.");
114
+ }
115
+ const { access, refresh } = await signIn(client, open);
116
+ await mkdir(dir, { recursive: true });
117
+ // Bun.write does not reliably apply mode when creating a file; keep the refresh token private.
118
+ await writeFile(tokenFile, `${JSON.stringify({ refresh_token: refresh })}\n`, { mode: 0o600 });
119
+ return access;
120
+ };
package/src/cli.ts ADDED
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { existsSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { Command } from "commander";
6
+ import { accessToken, configDir } from "./auth.ts";
7
+ import { readSettings, videoResource, type VideoResource } from "./settings.ts";
8
+ import { setThumbnail, uploadVideo } from "./upload.ts";
9
+
10
+ const VERSION: string = (await Bun.file(new URL("../package.json", import.meta.url)).json()).version;
11
+ const DEFAULT = "configs/youtube.yaml";
12
+
13
+ const openBrowser = (url: string): void => {
14
+ const wsl = process.platform === "linux" && (!!process.env.WSL_INTEROP || existsSync("/proc/sys/fs/binfmt_misc/WSLInterop"));
15
+ const tries: string[][] = wsl
16
+ ? [["wslview"], ["cmd.exe", "/c", "start", ""]]
17
+ : process.platform === "darwin"
18
+ ? [["open"]]
19
+ : process.platform === "win32"
20
+ ? [["cmd", "/c", "start", ""]]
21
+ : [["xdg-open"]];
22
+ for (const [cmd, ...args] of tries) {
23
+ const target = cmd.startsWith("cmd") ? url.replaceAll("&", "^&") : url;
24
+ try {
25
+ Bun.spawn([cmd, ...args, target], { stdin: "ignore", stdout: "ignore", stderr: "ignore", cwd: wsl ? "/mnt/c" : undefined }).unref();
26
+ break;
27
+ } catch {}
28
+ }
29
+ };
30
+
31
+ const main = async (configs: string[], { dryRun = false }: { dryRun?: boolean }): Promise<void> => {
32
+ const uploads = await Promise.all(
33
+ (configs.length ? configs : [DEFAULT]).map(async (config) => {
34
+ const settings = await readSettings(config);
35
+ const file = resolve(settings.video ?? "video.mp4");
36
+ const thumbnail = settings.thumbnail === undefined ? undefined : resolve(settings.thumbnail);
37
+ for (const f of [file, thumbnail]) if (f && !(await Bun.file(f).exists())) throw new Error(`${f} does not exist (in ${config})`);
38
+ return { config, file, thumbnail, video: videoResource(settings) };
39
+ }),
40
+ );
41
+ if (dryRun) {
42
+ for (const { config, file, thumbnail, video } of uploads) console.log(JSON.stringify({ config, file, thumbnail, ...video }, null, 2));
43
+ console.log(`(the sign-in is kept in ${configDir()})`);
44
+ return;
45
+ }
46
+
47
+ const token = await accessToken(openBrowser);
48
+ for (const { config, file, thumbnail, video } of uploads) {
49
+ if (uploads.length > 1) console.log(`${config}:`);
50
+ await uploadOne(file, thumbnail, video, token);
51
+ }
52
+ };
53
+
54
+ const uploadOne = async (file: string, thumbnail: string | undefined, video: VideoResource, token: string): Promise<void> => {
55
+ const tty = process.stderr.isTTY;
56
+ const uploaded = await uploadVideo(file, video, token, {
57
+ progress: (sent, total) =>
58
+ tty && process.stderr.write(`\ruploading ${Math.floor((sent / total) * 100)}% (${(sent / 2 ** 20).toFixed(1)} / ${(total / 2 ** 20).toFixed(1)} MiB)`),
59
+ });
60
+ if (tty) process.stderr.write("\n");
61
+ console.log(`uploaded: https://youtu.be/${uploaded.id}`);
62
+ console.log(`edit: https://studio.youtube.com/video/${uploaded.id}/edit`);
63
+ const privacy = uploaded.status?.privacyStatus;
64
+ if (privacy && privacy !== video.status.privacyStatus) {
65
+ console.error(
66
+ `note: the video is ${privacy}, not ${video.status.privacyStatus} (YouTube makes the uploads of an API project that has not passed its audit private: change it in YouTube Studio)`,
67
+ );
68
+ }
69
+ if (thumbnail) {
70
+ try {
71
+ await setThumbnail(uploaded.id, thumbnail, token);
72
+ } catch (e) {
73
+ console.error(`warning: ${(e as Error).message}`);
74
+ process.exitCode = 1;
75
+ }
76
+ }
77
+ };
78
+
79
+ new Command("mekuru-youtube")
80
+ .description(
81
+ "Uploads videos to YouTube, one for each YAML file with its settings: the video file, the title, description, tags, ... " +
82
+ "The paths are relative to the current folder (the project). The first time, it opens the browser to sign in to Google.",
83
+ )
84
+ .version(VERSION)
85
+ .argument("[config...]", `the YAML files of the settings (default: ${DEFAULT})`)
86
+ .option("--dry-run", "show what it would upload, without signing in or uploading")
87
+ .showHelpAfterError("(add --help for more information)")
88
+ .action(main)
89
+ .parseAsync()
90
+ .catch((e: Error) => {
91
+ console.error(`error: ${e.message}`);
92
+ process.exit(1);
93
+ });
@@ -0,0 +1,75 @@
1
+ export type Privacy = "private" | "unlisted" | "public";
2
+
3
+ export interface Settings {
4
+ video?: string;
5
+ title: string;
6
+ description?: string | string[];
7
+ tags?: string[];
8
+ category?: string | number;
9
+ privacy?: Privacy;
10
+ publishAt?: string;
11
+ madeForKids?: boolean;
12
+ thumbnail?: string;
13
+ }
14
+
15
+ export interface VideoResource {
16
+ snippet: { title: string; description: string; tags?: string[]; categoryId: string };
17
+ status: { privacyStatus: Privacy; publishAt?: string; selfDeclaredMadeForKids?: boolean };
18
+ }
19
+
20
+ const KEYS = ["video", "title", "description", "tags", "category", "privacy", "publishAt", "madeForKids", "thumbnail"];
21
+ const PRIVACY = ["private", "unlisted", "public"];
22
+
23
+ const isStrings = (v: unknown): v is string[] => Array.isArray(v) && v.every((s) => typeof s === "string");
24
+ const descriptionOf = (d: Settings["description"]): string => (Array.isArray(d) ? d.join("\n") : (d ?? "")).trimEnd();
25
+
26
+ const checkSettings = (value: unknown, name = "youtube.yaml"): Settings => {
27
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${name} must have the settings (title: …, description: …)`);
28
+ const s = value as Record<string, unknown>;
29
+ const errors: string[] = [];
30
+ for (const key of Object.keys(s)) if (!KEYS.includes(key)) errors.push(`unknown setting "${key}" (${KEYS.join(", ")})`);
31
+ if (s.video !== undefined && typeof s.video !== "string") errors.push(`"video" must be the path of the video file`);
32
+ if (typeof s.title !== "string" || !s.title.trim()) errors.push(`"title" is required`);
33
+ else if ([...s.title].length > 100) errors.push(`"title" is longer than 100 characters`);
34
+ else if (/[<>]/.test(s.title)) errors.push(`"title" cannot have < or >`);
35
+ if (s.description !== undefined) {
36
+ if (typeof s.description !== "string" && !isStrings(s.description)) errors.push(`"description" must be a string or an array of lines`);
37
+ else {
38
+ const text = descriptionOf(s.description);
39
+ if (Buffer.byteLength(text) > 5000) errors.push(`"description" is longer than 5000 bytes`);
40
+ if (/[<>]/.test(text)) errors.push(`"description" cannot have < or >`);
41
+ }
42
+ }
43
+ if (s.tags !== undefined) {
44
+ if (!isStrings(s.tags)) errors.push(`"tags" must be an array of strings`);
45
+ else if (s.tags.reduce((n, t) => n + [...t].length + (t.includes(" ") ? 2 : 0), Math.max(0, s.tags.length - 1)) > 500)
46
+ errors.push(`"tags" are longer than 500 characters in all`);
47
+ }
48
+ if (s.category !== undefined && !/^\d+$/.test(String(s.category))) errors.push(`"category" must be a category ID (a number such as 27)`);
49
+ if (s.privacy !== undefined && !PRIVACY.includes(s.privacy as string)) errors.push(`"privacy" must be ${PRIVACY.join(", ")}`);
50
+ if (s.publishAt !== undefined) {
51
+ if (typeof s.publishAt !== "string" || Number.isNaN(Date.parse(s.publishAt)))
52
+ errors.push(`"publishAt" must be a date and time (ISO 8601, such as 2026-11-01T18:00:00+09:00)`);
53
+ else if ((s.privacy ?? "private") !== "private") errors.push(`"publishAt" needs "privacy": "private" (the video is private until then)`);
54
+ }
55
+ if (s.madeForKids !== undefined && typeof s.madeForKids !== "boolean") errors.push(`"madeForKids" must be true or false`);
56
+ if (s.thumbnail !== undefined && typeof s.thumbnail !== "string") errors.push(`"thumbnail" must be the path of an image`);
57
+ if (errors.length) throw new Error(`${name}:\n ${errors.join("\n ")}`);
58
+ return s as unknown as Settings;
59
+ };
60
+
61
+ export const readSettings = async (file: string): Promise<Settings> => {
62
+ if (!(await Bun.file(file).exists())) throw new Error(`no ${file} (the video, its title, description, ... to upload)`);
63
+ let value: unknown;
64
+ try {
65
+ value = Bun.YAML.parse(await Bun.file(file).text());
66
+ } catch (e) {
67
+ throw new Error(`cannot read ${file}: ${(e as Error).message}`);
68
+ }
69
+ return checkSettings(value, file);
70
+ };
71
+
72
+ export const videoResource = (s: Settings): VideoResource => ({
73
+ snippet: { title: s.title, description: descriptionOf(s.description), tags: s.tags, categoryId: String(s.category ?? 22) },
74
+ status: { privacyStatus: s.privacy ?? "private", publishAt: s.publishAt, selfDeclaredMadeForKids: s.madeForKids },
75
+ });
package/src/upload.ts ADDED
@@ -0,0 +1,95 @@
1
+ import type { Privacy, VideoResource } from "./settings.ts";
2
+
3
+ const CHUNK = 8 * 1024 * 1024;
4
+ const RETRIES = 5;
5
+
6
+ const API = "https://www.googleapis.com";
7
+
8
+ const failure = async (what: string, res: Response): Promise<Error> => {
9
+ const text = await res.text();
10
+ let message = text.trim().slice(0, 500);
11
+ try {
12
+ const { error } = JSON.parse(text) as { error?: { message?: string; errors?: { reason?: string }[] } };
13
+ if (error?.message) message = error.message;
14
+ if (error?.errors?.[0]?.reason) message += ` (${error.errors[0].reason})`;
15
+ } catch {}
16
+ return new Error(`${what} failed: HTTP ${res.status}: ${message}`);
17
+ };
18
+
19
+ export interface UploadOptions {
20
+ progress?: (sent: number, total: number) => void;
21
+ }
22
+
23
+ export interface Uploaded {
24
+ id: string;
25
+ status?: { privacyStatus?: Privacy };
26
+ }
27
+
28
+ export const uploadVideo = async (file: string, video: VideoResource, token: string, options: UploadOptions = {}): Promise<Uploaded> => {
29
+ const { progress } = options;
30
+ const data = Bun.file(file);
31
+ if (!(await data.exists())) throw new Error(`${file} does not exist`);
32
+ const total = data.size;
33
+ if (total === 0) throw new Error(`${file} is empty`);
34
+ const type = data.type;
35
+ const start = await fetch(`${API}/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status`, {
36
+ method: "POST",
37
+ headers: {
38
+ Authorization: `Bearer ${token}`,
39
+ "Content-Type": "application/json; charset=UTF-8",
40
+ "X-Upload-Content-Length": String(total),
41
+ "X-Upload-Content-Type": type,
42
+ },
43
+ body: JSON.stringify(video),
44
+ });
45
+ if (!start.ok) throw await failure("the upload", start);
46
+ const session = start.headers.get("location");
47
+ if (!session) throw new Error("the upload failed: YouTube gave no upload URL");
48
+
49
+ const put = (range: string, body?: Buffer): Promise<Response> =>
50
+ fetch(session, {
51
+ method: "PUT",
52
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": type, "Content-Range": range },
53
+ body: body ?? Buffer.alloc(0),
54
+ });
55
+ let offset = 0;
56
+ let retries = 0;
57
+ let query = false;
58
+ for (;;) {
59
+ let res: Response | undefined;
60
+ try {
61
+ if (query) res = await put(`bytes */${total}`);
62
+ else {
63
+ const size = Math.min(CHUNK, total - offset);
64
+ const body = Buffer.from(await data.slice(offset, offset + size).arrayBuffer());
65
+ res = await put(`bytes ${offset}-${offset + size - 1}/${total}`, body);
66
+ }
67
+ } catch {}
68
+ if (res?.status === 200 || res?.status === 201) {
69
+ progress?.(total, total);
70
+ return (await res.json()) as Uploaded;
71
+ }
72
+ if (res?.status === 308) {
73
+ const last = res.headers.get("range")?.match(/-(\d+)$/)?.[1];
74
+ const arrived = last === undefined ? 0 : Number(last) + 1;
75
+ if (arrived > offset) retries = 0;
76
+ offset = arrived;
77
+ query = false;
78
+ progress?.(offset, total);
79
+ continue;
80
+ }
81
+ if (res && res.status < 500) throw await failure("the upload", res);
82
+ if (++retries > RETRIES) throw res ? await failure("the upload", res) : new Error("the upload failed: the connection to YouTube failed");
83
+ await new Promise((go) => setTimeout(go, 1000 * 2 ** (retries - 1)));
84
+ query = true;
85
+ }
86
+ };
87
+
88
+ export const setThumbnail = async (id: string, file: string, token: string): Promise<void> => {
89
+ const res = await fetch(`${API}/upload/youtube/v3/thumbnails/set?videoId=${encodeURIComponent(id)}&uploadType=media`, {
90
+ method: "POST",
91
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": Bun.file(file).type },
92
+ body: Bun.file(file),
93
+ });
94
+ if (!res.ok) throw await failure("the thumbnail", res);
95
+ };