@mekuru/youtube 0.1.1 → 0.1.3
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 +19 -0
- package/README.md +19 -0
- package/package.json +3 -2
- package/src/auth.ts +2 -7
- package/src/cli.ts +1 -75
- package/src/command.ts +55 -0
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
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# @mekuru/youtube
|
|
2
|
+
|
|
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,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mekuru/youtube",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Uploads a video of Mekuru Movie Maker to YouTube with the title, description, ... written in youtube.yaml",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"bun": ">=1.4"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"commander": "^14.0.3"
|
|
16
|
+
"commander": "^14.0.3",
|
|
17
|
+
"@mekuru/platform": "^0.1.3"
|
|
17
18
|
}
|
|
18
19
|
}
|
package/src/auth.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import {
|
|
2
|
+
import { configDir } from "@mekuru/platform";
|
|
3
|
+
export { configDir } from "@mekuru/platform";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
|
|
5
6
|
const SCOPE = "https://www.googleapis.com/auth/youtube.upload";
|
|
@@ -12,12 +13,6 @@ interface Client {
|
|
|
12
13
|
token_uri: string;
|
|
13
14
|
}
|
|
14
15
|
|
|
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
16
|
const readClient = async (file: string): Promise<Client> => {
|
|
22
17
|
if (!(await Bun.file(file).exists())) {
|
|
23
18
|
throw new Error(`no OAuth client: save the JSON of the OAuth client (a desktop app) of your Google Cloud project as ${file}`);
|
package/src/cli.ts
CHANGED
|
@@ -1,81 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
4
|
-
import { resolve } from "node:path";
|
|
5
2
|
import { Command } from "commander";
|
|
6
|
-
import {
|
|
7
|
-
import { readSettings, videoResource, type VideoResource } from "./settings.ts";
|
|
8
|
-
import { setThumbnail, uploadVideo } from "./upload.ts";
|
|
9
|
-
|
|
3
|
+
import { main, DEFAULT } from "./command.ts";
|
|
10
4
|
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
5
|
new Command("mekuru-youtube")
|
|
80
6
|
.description(
|
|
81
7
|
"Uploads videos to YouTube, one for each YAML file with its settings: the video file, the title, description, tags, ... " +
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { openBrowser } from "@mekuru/platform";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { accessToken, configDir } from "./auth.ts";
|
|
4
|
+
import { readSettings, videoResource, type VideoResource } from "./settings.ts";
|
|
5
|
+
import { setThumbnail, uploadVideo } from "./upload.ts";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT = "configs/youtube.yaml";
|
|
8
|
+
|
|
9
|
+
export const main = async (configs: string[], { dryRun = false }: { dryRun?: boolean }): Promise<void> => {
|
|
10
|
+
const uploads = await Promise.all(
|
|
11
|
+
(configs.length ? configs : [DEFAULT]).map(async (config) => {
|
|
12
|
+
const settings = await readSettings(config);
|
|
13
|
+
const file = resolve(settings.video ?? "video.mp4");
|
|
14
|
+
const thumbnail = settings.thumbnail === undefined ? undefined : resolve(settings.thumbnail);
|
|
15
|
+
for (const f of [file, thumbnail]) if (f && !(await Bun.file(f).exists())) throw new Error(`${f} does not exist (in ${config})`);
|
|
16
|
+
return { config, file, thumbnail, video: videoResource(settings) };
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
if (dryRun) {
|
|
20
|
+
for (const { config, file, thumbnail, video } of uploads) console.log(JSON.stringify({ config, file, thumbnail, ...video }, null, 2));
|
|
21
|
+
console.log(`(the sign-in is kept in ${configDir()})`);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const token = await accessToken(openBrowser);
|
|
26
|
+
for (const { config, file, thumbnail, video } of uploads) {
|
|
27
|
+
if (uploads.length > 1) console.log(`${config}:`);
|
|
28
|
+
await uploadOne(file, thumbnail, video, token);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const uploadOne = async (file: string, thumbnail: string | undefined, video: VideoResource, token: string): Promise<void> => {
|
|
33
|
+
const tty = process.stderr.isTTY;
|
|
34
|
+
const uploaded = await uploadVideo(file, video, token, {
|
|
35
|
+
progress: (sent, total) =>
|
|
36
|
+
tty && process.stderr.write(`\ruploading ${Math.floor((sent / total) * 100)}% (${(sent / 2 ** 20).toFixed(1)} / ${(total / 2 ** 20).toFixed(1)} MiB)`),
|
|
37
|
+
});
|
|
38
|
+
if (tty) process.stderr.write("\n");
|
|
39
|
+
console.log(`uploaded: https://youtu.be/${uploaded.id}`);
|
|
40
|
+
console.log(`edit: https://studio.youtube.com/video/${uploaded.id}/edit`);
|
|
41
|
+
const privacy = uploaded.status?.privacyStatus;
|
|
42
|
+
if (privacy && privacy !== video.status.privacyStatus) {
|
|
43
|
+
console.error(
|
|
44
|
+
`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)`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (thumbnail) {
|
|
48
|
+
try {
|
|
49
|
+
await setThumbnail(uploaded.id, thumbnail, token);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
console.error(`warning: ${(e as Error).message}`);
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|