@yoonion/mimi-seed-mcp 0.12.1 → 0.13.0
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.md +1 -1
- package/assets/agent-guide.md +3 -2
- package/dist/auth/scopes.d.ts +5 -0
- package/dist/auth/scopes.js +5 -0
- package/dist/registers/auth.js +7 -0
- package/dist/registers/video.js +41 -0
- package/dist/video/youtube-publish.d.ts +88 -0
- package/dist/video/youtube-publish.js +179 -0
- package/package.json +1 -1
- package/tool-manifest.json +6 -3
package/README.md
CHANGED
|
@@ -96,7 +96,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
|
|
|
96
96
|
| Android 서명 | 3 | `android_signing_setup` / `android_generate_keystore` / `jenkins_upload_playstore_sa` |
|
|
97
97
|
| 인증 | 4 | `mimi_seed_status` / `mimi_seed_auth_start` / `mimi_seed_auth_status` / `mimi_seed_remote_sync_credentials` |
|
|
98
98
|
| AI (Claude) | 2 | `generate_release_notes_from_commits` / `generate_review_reply` |
|
|
99
|
-
| 영상
|
|
99
|
+
| 영상 제작·YouTube | 14 | `youtube_upload_video` / `youtube_get_video_status` / `youtube_update_video_privacy` / `video_plan_from_story` / `video_render` |
|
|
100
100
|
|
|
101
101
|
> 인앱 결제(IAP·구독) 도구는 위 Play Store·App Store 카운트에 포함됩니다 — `appstore_create_inapp_purchase` · `appstore_update_product_review_note` · `appstore_upload_product_review_screenshot` 등.
|
|
102
102
|
> 전체 카탈로그(항상 최신): [`docs/domain/tool-catalog.md`](../../docs/domain/tool-catalog.md)
|
package/assets/agent-guide.md
CHANGED
|
@@ -57,6 +57,7 @@ tools directly — but the *call order* and *safety rules* below still apply.
|
|
|
57
57
|
| CI (GitHub/GitLab) | `select:ci_save_config,ci_list_workflows,ci_trigger_build,ci_get_build_status,ci_list_recent_builds` |
|
|
58
58
|
| Service account end-to-end | `select:iam_create_service_account,iam_create_key,iam_add_iam_policy_binding,playstore_register_service_account,playstore_verify_service_account` |
|
|
59
59
|
| Story → researched video | `select:video_plan_from_story,video_research_youtube,video_search_stock_assets,video_synthesize_research,video_download_stock_assets,video_generate_image,video_add_local_asset,video_build_timeline,video_render,video_job_status,video_validate` |
|
|
60
|
+
| YouTube upload / publish | `select:youtube_upload_video,youtube_get_video_status,youtube_update_video_privacy,mimi_seed_auth_start` |
|
|
60
61
|
|
|
61
62
|
---
|
|
62
63
|
|
|
@@ -80,7 +81,7 @@ every credential, which also tells them where to obtain each token
|
|
|
80
81
|
|
|
81
82
|
| Service | Fix |
|
|
82
83
|
|---------|-----|
|
|
83
|
-
| Google (Firebase/AdMob/Play/Ads/GSC/GA4/IAM/BigQuery) | tool `mimi_seed_auth_start` → give the user the OAuth URL, **or** `mimi-seed auth login`. Both accept a **domain subset** (`domains=["
|
|
84
|
+
| Google (Firebase/AdMob/Play/Ads/GSC/GA4/IAM/BigQuery/YouTube) | tool `mimi_seed_auth_start` → give the user the OAuth URL, **or** `mimi-seed auth login`. Both accept a **domain subset** (`domains=["youtube"]` / `--domains youtube`) — request only what the task needs; re-auth keeps prior grants (incremental). Omit for all domains |
|
|
84
85
|
| App Store Connect | `mimi-seed auth appstore` → verify with `appstore_verify_credentials` |
|
|
85
86
|
| Play service account | `mimi-seed auth playstore`, or register per-package with `playstore_register_service_account`. **Optional** — the OAuth token carries `androidpublisher`, so this is only needed for headless/CI |
|
|
86
87
|
| BigQuery | `mimi-seed auth bigquery` (optional — OAuth works too) |
|
|
@@ -153,7 +154,7 @@ per-domain inventory is [`docs/domain/tool-catalog.md`](domain/tool-catalog.md).
|
|
|
153
154
|
| **Facebook / Instagram / Threads** | `facebook_post_photo` · `instagram_post_carousel` · `threads_post` · `threads_refresh_token` |
|
|
154
155
|
| **Checks** | `playstore_check_submission_risks` · `appstore_check_submission_risks` · `screenshot_validate` · `release_status` |
|
|
155
156
|
| **AI / Auth** | `generate_release_notes_from_commits` · `generate_review_reply` · `mimi_seed_status` · `mimi_seed_auth_start` · `mimi_seed_auth_status` · `mimi_seed_remote_sync_credentials` |
|
|
156
|
-
| **Video production** | `video_plan_from_story` · `video_research_youtube` · `video_search_stock_assets` · `video_synthesize_research` · `video_generate_image` · `video_build_timeline` · `video_render` · `video_job_status` · `video_validate` |
|
|
157
|
+
| **Video production** | `youtube_upload_video` · `youtube_get_video_status` · `youtube_update_video_privacy` · `video_plan_from_story` · `video_research_youtube` · `video_search_stock_assets` · `video_synthesize_research` · `video_generate_image` · `video_build_timeline` · `video_render` · `video_job_status` · `video_validate` |
|
|
157
158
|
|
|
158
159
|
---
|
|
159
160
|
|
package/dist/auth/scopes.d.ts
CHANGED
|
@@ -59,6 +59,11 @@ export declare const AUTH_DOMAINS: {
|
|
|
59
59
|
readonly scopes: readonly ["https://www.googleapis.com/auth/analytics.edit", "https://www.googleapis.com/auth/analytics.readonly"];
|
|
60
60
|
readonly summary: "ga4_* — property/data stream 생성·조회 + 리포트";
|
|
61
61
|
};
|
|
62
|
+
readonly youtube: {
|
|
63
|
+
readonly label: "YouTube";
|
|
64
|
+
readonly scopes: readonly ["https://www.googleapis.com/auth/youtube.force-ssl"];
|
|
65
|
+
readonly summary: "youtube_* — 영상 업로드·처리 상태 조회·공개 상태 변경";
|
|
66
|
+
};
|
|
62
67
|
};
|
|
63
68
|
export type AuthDomainId = keyof typeof AUTH_DOMAINS;
|
|
64
69
|
/** z.enum 등 튜플이 필요한 자리에 쓰는 도메인 id 목록 (선언 순서 유지). */
|
package/dist/auth/scopes.js
CHANGED
|
@@ -61,6 +61,11 @@ export const AUTH_DOMAINS = {
|
|
|
61
61
|
],
|
|
62
62
|
summary: 'ga4_* — property/data stream 생성·조회 + 리포트',
|
|
63
63
|
},
|
|
64
|
+
youtube: {
|
|
65
|
+
label: 'YouTube',
|
|
66
|
+
scopes: ['https://www.googleapis.com/auth/youtube.force-ssl'],
|
|
67
|
+
summary: 'youtube_* — 영상 업로드·처리 상태 조회·공개 상태 변경',
|
|
68
|
+
},
|
|
64
69
|
};
|
|
65
70
|
/** z.enum 등 튜플이 필요한 자리에 쓰는 도메인 id 목록 (선언 순서 유지). */
|
|
66
71
|
export const DOMAIN_IDS = Object.keys(AUTH_DOMAINS);
|
package/dist/registers/auth.js
CHANGED
|
@@ -132,6 +132,13 @@ export function registerAuthTools(server) {
|
|
|
132
132
|
else {
|
|
133
133
|
lines.push('❌ Google OAuth — 미연결 → mimi_seed_auth_start');
|
|
134
134
|
}
|
|
135
|
+
const youtubeGranted = summarizeGrantedDomains(getStoredTokens()?.scope).granted.includes('youtube');
|
|
136
|
+
if (youtubeGranted && (oauthResult.status === 'fresh' || oauthResult.status === 'refreshed')) {
|
|
137
|
+
lines.push('✅ YouTube — 업로드·상태 관리 권한 연결됨');
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
lines.push('❌ YouTube — 미연결 → mimi_seed_auth_start(domains=["youtube"]) (선택)');
|
|
141
|
+
}
|
|
135
142
|
// 2. Play Store SA
|
|
136
143
|
const saInfo = listRegisteredServiceAccounts();
|
|
137
144
|
const saCount = saInfo.perPackage.length + (saInfo.default ? 1 : 0);
|
package/dist/registers/video.js
CHANGED
|
@@ -3,6 +3,8 @@ import { addLocalAsset, buildTimeline, loadProject, loadTimeline, planStory, } f
|
|
|
3
3
|
import { downloadStockAssets, generateImage, researchYouTube, searchStock, } from '../video/providers.js';
|
|
4
4
|
import { getRenderJob, startRender, validateVideo } from '../video/render.js';
|
|
5
5
|
import { synthesizeResearch } from '../video/research.js';
|
|
6
|
+
import { requireAuth } from '../helpers.js';
|
|
7
|
+
import { YOUTUBE_SCOPE, getYouTubeVideoStatus, updateYouTubeVideoPrivacy, uploadYouTubeVideo, } from '../video/youtube-publish.js';
|
|
6
8
|
function text(value) {
|
|
7
9
|
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
|
|
8
10
|
}
|
|
@@ -12,6 +14,45 @@ const httpUrl = z.string().url().refine((value) => {
|
|
|
12
14
|
return protocol === 'https:' || protocol === 'http:';
|
|
13
15
|
}, 'HTTP(S) URL만 허용합니다.');
|
|
14
16
|
export function registerVideoTools(server) {
|
|
17
|
+
server.tool('youtube_upload_video', [
|
|
18
|
+
'로컬 영상 파일을 현재 Google OAuth 계정의 YouTube 채널에 업로드합니다.',
|
|
19
|
+
'기본 공개 상태는 private이며 public/unlisted는 같은 턴의 명시 승인 후 confirmVisible=true가 필요합니다.',
|
|
20
|
+
'업로드 후 youtube_get_video_status로 처리 완료 여부와 실제 공개 상태를 확인하세요.',
|
|
21
|
+
'요청이 타임아웃되면 업로드가 뒤늦게 완료될 수 있으므로 YouTube Studio를 먼저 확인하고 중복 재시도하지 마세요.',
|
|
22
|
+
'YouTube OAuth 권한이 없으면 mimi_seed_auth_start(domains=["youtube"])로 증분 연결하세요.',
|
|
23
|
+
].join(' '), {
|
|
24
|
+
filePath: absolutePath.describe('업로드할 .mp4/.mov/.webm 영상 절대경로'),
|
|
25
|
+
title: z.string().min(1).max(100).describe('YouTube 영상 제목'),
|
|
26
|
+
description: z.string().max(5_000).optional().describe('영상 설명과 음원 출처/라이선스 표기'),
|
|
27
|
+
tags: z.array(z.string().min(1).max(500)).max(100).optional(),
|
|
28
|
+
categoryId: z.string().regex(/^\d+$/).default('24').describe('YouTube 카테고리 ID'),
|
|
29
|
+
privacyStatus: z.enum(['private', 'unlisted', 'public']).default('private'),
|
|
30
|
+
madeForKids: z.boolean().default(false),
|
|
31
|
+
containsSyntheticMedia: z.boolean().optional().describe('현실적으로 보이는 AI 생성·변형 콘텐츠 여부를 YouTube에 고지'),
|
|
32
|
+
notifySubscribers: z.boolean().default(false),
|
|
33
|
+
shortsOnly: z.boolean().default(false).describe('true면 세로/정사각형·180초 이하 쇼츠 규격을 강제'),
|
|
34
|
+
confirmVisible: z.boolean().default(false).describe('public/unlisted 게시에 대한 명시 확인'),
|
|
35
|
+
}, async (input) => {
|
|
36
|
+
const auth = await requireAuth(YOUTUBE_SCOPE);
|
|
37
|
+
return text(await uploadYouTubeVideo(auth, input));
|
|
38
|
+
});
|
|
39
|
+
server.tool('youtube_get_video_status', '내 YouTube 영상의 업로드 처리 상태와 현재 공개 상태를 조회합니다.', {
|
|
40
|
+
videoId: z.string().min(1).max(64).describe('YouTube video ID'),
|
|
41
|
+
}, async ({ videoId }) => {
|
|
42
|
+
const auth = await requireAuth(YOUTUBE_SCOPE);
|
|
43
|
+
return text(await getYouTubeVideoStatus(auth, videoId));
|
|
44
|
+
});
|
|
45
|
+
server.tool('youtube_update_video_privacy', [
|
|
46
|
+
'내 YouTube 영상의 공개 상태를 변경하고 실제 반영 상태를 다시 조회합니다.',
|
|
47
|
+
'public/unlisted 변경은 같은 턴의 명시 승인 후 confirmVisible=true가 필요합니다.',
|
|
48
|
+
].join(' '), {
|
|
49
|
+
videoId: z.string().min(1).max(64).describe('YouTube video ID'),
|
|
50
|
+
privacyStatus: z.enum(['private', 'unlisted', 'public']),
|
|
51
|
+
confirmVisible: z.boolean().default(false).describe('public/unlisted 변경에 대한 명시 확인'),
|
|
52
|
+
}, async (input) => {
|
|
53
|
+
const auth = await requireAuth(YOUTUBE_SCOPE);
|
|
54
|
+
return text(await updateYouTubeVideoPrivacy(auth, input));
|
|
55
|
+
});
|
|
15
56
|
server.tool('video_plan_from_story', [
|
|
16
57
|
'정리된 story를 장면별 스토리보드로 변환하고 로컬 영상 프로젝트를 만듭니다.',
|
|
17
58
|
'ANTHROPIC_API_KEY가 필요합니다. projectDir에는 project.json과 자산/리서치/렌더 폴더가 생성됩니다.',
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type youtube_v3 } from 'googleapis';
|
|
2
|
+
import type { OAuth2Client } from 'google-auth-library';
|
|
3
|
+
import { validateVideo } from './render.js';
|
|
4
|
+
export declare const YOUTUBE_SCOPE = "https://www.googleapis.com/auth/youtube.force-ssl";
|
|
5
|
+
export type YouTubePrivacyStatus = 'private' | 'unlisted' | 'public';
|
|
6
|
+
export interface UploadYouTubeVideoInput {
|
|
7
|
+
filePath: string;
|
|
8
|
+
title: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
tags?: string[];
|
|
11
|
+
categoryId?: string;
|
|
12
|
+
privacyStatus?: YouTubePrivacyStatus;
|
|
13
|
+
madeForKids?: boolean;
|
|
14
|
+
containsSyntheticMedia?: boolean;
|
|
15
|
+
notifySubscribers?: boolean;
|
|
16
|
+
shortsOnly?: boolean;
|
|
17
|
+
confirmVisible?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface UpdateYouTubePrivacyInput {
|
|
20
|
+
videoId: string;
|
|
21
|
+
privacyStatus: YouTubePrivacyStatus;
|
|
22
|
+
confirmVisible?: boolean;
|
|
23
|
+
}
|
|
24
|
+
declare function assertVisibilityConfirmed(privacyStatus: YouTubePrivacyStatus, confirmVisible?: boolean): void;
|
|
25
|
+
declare function assertUploadFile(filePath: string): {
|
|
26
|
+
size: number;
|
|
27
|
+
mimeType: string;
|
|
28
|
+
};
|
|
29
|
+
declare function assertTags(tags?: string[]): void;
|
|
30
|
+
declare function videoFacts(validation: Awaited<ReturnType<typeof validateVideo>>): {
|
|
31
|
+
width?: number;
|
|
32
|
+
height?: number;
|
|
33
|
+
durationSec?: number;
|
|
34
|
+
};
|
|
35
|
+
declare function assertShortsCompatible(facts: ReturnType<typeof videoFacts>): void;
|
|
36
|
+
export declare function getYouTubeVideoStatus(auth: OAuth2Client, videoId: string): Promise<{
|
|
37
|
+
videoId: string | null;
|
|
38
|
+
title: string | null;
|
|
39
|
+
privacyStatus: string | null;
|
|
40
|
+
uploadStatus: string | null;
|
|
41
|
+
processingStatus: string | null;
|
|
42
|
+
processingProgress: youtube_v3.Schema$VideoProcessingDetailsProcessingProgress | null;
|
|
43
|
+
madeForKids: boolean | null;
|
|
44
|
+
containsSyntheticMedia: boolean | null;
|
|
45
|
+
watchUrl: string | null;
|
|
46
|
+
studioUrl: string | null;
|
|
47
|
+
}>;
|
|
48
|
+
export declare function uploadYouTubeVideo(auth: OAuth2Client, input: UploadYouTubeVideoInput): Promise<{
|
|
49
|
+
requestedPrivacyStatus: YouTubePrivacyStatus;
|
|
50
|
+
file: {
|
|
51
|
+
width?: number;
|
|
52
|
+
height?: number;
|
|
53
|
+
durationSec?: number;
|
|
54
|
+
path: string;
|
|
55
|
+
size: number;
|
|
56
|
+
};
|
|
57
|
+
validationWarnings: string[];
|
|
58
|
+
videoId: string | null;
|
|
59
|
+
title: string | null;
|
|
60
|
+
privacyStatus: string | null;
|
|
61
|
+
uploadStatus: string | null;
|
|
62
|
+
processingStatus: string | null;
|
|
63
|
+
processingProgress: youtube_v3.Schema$VideoProcessingDetailsProcessingProgress | null;
|
|
64
|
+
madeForKids: boolean | null;
|
|
65
|
+
containsSyntheticMedia: boolean | null;
|
|
66
|
+
watchUrl: string | null;
|
|
67
|
+
studioUrl: string | null;
|
|
68
|
+
}>;
|
|
69
|
+
export declare function updateYouTubeVideoPrivacy(auth: OAuth2Client, input: UpdateYouTubePrivacyInput): Promise<{
|
|
70
|
+
videoId: string | null;
|
|
71
|
+
title: string | null;
|
|
72
|
+
privacyStatus: string | null;
|
|
73
|
+
uploadStatus: string | null;
|
|
74
|
+
processingStatus: string | null;
|
|
75
|
+
processingProgress: youtube_v3.Schema$VideoProcessingDetailsProcessingProgress | null;
|
|
76
|
+
madeForKids: boolean | null;
|
|
77
|
+
containsSyntheticMedia: boolean | null;
|
|
78
|
+
watchUrl: string | null;
|
|
79
|
+
studioUrl: string | null;
|
|
80
|
+
}>;
|
|
81
|
+
export declare const __testing: {
|
|
82
|
+
assertVisibilityConfirmed: typeof assertVisibilityConfirmed;
|
|
83
|
+
assertUploadFile: typeof assertUploadFile;
|
|
84
|
+
assertTags: typeof assertTags;
|
|
85
|
+
assertShortsCompatible: typeof assertShortsCompatible;
|
|
86
|
+
videoFacts: typeof videoFacts;
|
|
87
|
+
};
|
|
88
|
+
export {};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { createReadStream, existsSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { google } from 'googleapis';
|
|
4
|
+
import { friendlyGoogleError } from '../lib/google-errors.js';
|
|
5
|
+
import { validateVideo } from './render.js';
|
|
6
|
+
export const YOUTUBE_SCOPE = 'https://www.googleapis.com/auth/youtube.force-ssl';
|
|
7
|
+
function assertVisibilityConfirmed(privacyStatus, confirmVisible) {
|
|
8
|
+
if (privacyStatus !== 'private' && confirmVisible !== true) {
|
|
9
|
+
throw new Error(`${privacyStatus} 게시에는 confirmVisible=true가 필요합니다.`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function assertUploadFile(filePath) {
|
|
13
|
+
if (!path.isAbsolute(filePath) || !existsSync(filePath)) {
|
|
14
|
+
throw new Error('filePath는 존재하는 절대경로여야 합니다.');
|
|
15
|
+
}
|
|
16
|
+
const stat = statSync(filePath);
|
|
17
|
+
if (!stat.isFile() || stat.size <= 0)
|
|
18
|
+
throw new Error('업로드할 영상 파일이 비어 있거나 파일이 아닙니다.');
|
|
19
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
20
|
+
const mimeType = ext === '.mp4'
|
|
21
|
+
? 'video/mp4'
|
|
22
|
+
: ext === '.mov'
|
|
23
|
+
? 'video/quicktime'
|
|
24
|
+
: ext === '.webm'
|
|
25
|
+
? 'video/webm'
|
|
26
|
+
: null;
|
|
27
|
+
if (!mimeType)
|
|
28
|
+
throw new Error('지원 영상 형식은 .mp4, .mov, .webm입니다.');
|
|
29
|
+
return { size: stat.size, mimeType };
|
|
30
|
+
}
|
|
31
|
+
function assertTags(tags = []) {
|
|
32
|
+
if (tags.join(',').length > 500)
|
|
33
|
+
throw new Error('tags의 전체 길이는 쉼표를 포함해 500자 이하여야 합니다.');
|
|
34
|
+
}
|
|
35
|
+
function videoFacts(validation) {
|
|
36
|
+
const stream = validation.streams.find((item) => typeof item === 'object' && item !== null && item.codec_type === 'video');
|
|
37
|
+
const format = validation.format;
|
|
38
|
+
const width = Number(stream?.width);
|
|
39
|
+
const height = Number(stream?.height);
|
|
40
|
+
const durationSec = Number(format.duration);
|
|
41
|
+
return {
|
|
42
|
+
width: Number.isFinite(width) ? width : undefined,
|
|
43
|
+
height: Number.isFinite(height) ? height : undefined,
|
|
44
|
+
durationSec: Number.isFinite(durationSec) ? durationSec : undefined,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function assertShortsCompatible(facts) {
|
|
48
|
+
if (!facts.width || !facts.height || !facts.durationSec) {
|
|
49
|
+
throw new Error('쇼츠 규격을 확인할 영상 정보를 읽지 못했습니다.');
|
|
50
|
+
}
|
|
51
|
+
if (facts.width > facts.height)
|
|
52
|
+
throw new Error('shortsOnly=true일 때 영상은 정사각형 또는 세로형이어야 합니다.');
|
|
53
|
+
if (facts.durationSec > 180)
|
|
54
|
+
throw new Error('YouTube Shorts 영상은 180초 이하여야 합니다.');
|
|
55
|
+
}
|
|
56
|
+
function safeStatus(video) {
|
|
57
|
+
return {
|
|
58
|
+
videoId: video.id ?? null,
|
|
59
|
+
title: video.snippet?.title ?? null,
|
|
60
|
+
privacyStatus: video.status?.privacyStatus ?? null,
|
|
61
|
+
uploadStatus: video.status?.uploadStatus ?? null,
|
|
62
|
+
processingStatus: video.processingDetails?.processingStatus ?? null,
|
|
63
|
+
processingProgress: video.processingDetails?.processingProgress ?? null,
|
|
64
|
+
madeForKids: video.status?.madeForKids ?? null,
|
|
65
|
+
containsSyntheticMedia: video.status?.containsSyntheticMedia ?? null,
|
|
66
|
+
watchUrl: video.id ? `https://youtu.be/${video.id}` : null,
|
|
67
|
+
studioUrl: video.id ? `https://studio.youtube.com/video/${video.id}/edit` : null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export async function getYouTubeVideoStatus(auth, videoId) {
|
|
71
|
+
const youtube = google.youtube({ version: 'v3', auth });
|
|
72
|
+
try {
|
|
73
|
+
const response = await youtube.videos.list({
|
|
74
|
+
part: ['snippet', 'status', 'processingDetails'],
|
|
75
|
+
id: [videoId],
|
|
76
|
+
});
|
|
77
|
+
const video = response.data.items?.[0];
|
|
78
|
+
if (!video)
|
|
79
|
+
throw new Error(`YouTube 영상을 찾지 못했습니다: ${videoId}`);
|
|
80
|
+
return safeStatus(video);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error instanceof Error && error.message.startsWith('YouTube 영상을 찾지 못했습니다'))
|
|
84
|
+
throw error;
|
|
85
|
+
throw friendlyGoogleError(error);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export async function uploadYouTubeVideo(auth, input) {
|
|
89
|
+
const privacyStatus = input.privacyStatus ?? 'private';
|
|
90
|
+
assertVisibilityConfirmed(privacyStatus, input.confirmVisible);
|
|
91
|
+
const file = assertUploadFile(input.filePath);
|
|
92
|
+
if (!input.title.trim() || input.title.length > 100)
|
|
93
|
+
throw new Error('title은 1~100자여야 합니다.');
|
|
94
|
+
if ((input.description?.length ?? 0) > 5_000)
|
|
95
|
+
throw new Error('description은 5,000자 이하여야 합니다.');
|
|
96
|
+
assertTags(input.tags);
|
|
97
|
+
const validation = await validateVideo(input.filePath);
|
|
98
|
+
const facts = videoFacts(validation);
|
|
99
|
+
if (!validation.streams.some((item) => typeof item === 'object' && item !== null && item.codec_type === 'video')) {
|
|
100
|
+
throw new Error('업로드 파일에 비디오 스트림이 없습니다.');
|
|
101
|
+
}
|
|
102
|
+
if (input.shortsOnly)
|
|
103
|
+
assertShortsCompatible(facts);
|
|
104
|
+
const youtube = google.youtube({ version: 'v3', auth });
|
|
105
|
+
try {
|
|
106
|
+
const response = await youtube.videos.insert({
|
|
107
|
+
part: ['snippet', 'status'],
|
|
108
|
+
notifySubscribers: input.notifySubscribers ?? false,
|
|
109
|
+
requestBody: {
|
|
110
|
+
snippet: {
|
|
111
|
+
title: input.title.trim(),
|
|
112
|
+
description: input.description ?? '',
|
|
113
|
+
tags: input.tags ?? [],
|
|
114
|
+
categoryId: input.categoryId ?? '24',
|
|
115
|
+
},
|
|
116
|
+
status: {
|
|
117
|
+
privacyStatus,
|
|
118
|
+
selfDeclaredMadeForKids: input.madeForKids ?? false,
|
|
119
|
+
...(typeof input.containsSyntheticMedia === 'boolean'
|
|
120
|
+
? { containsSyntheticMedia: input.containsSyntheticMedia }
|
|
121
|
+
: {}),
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
media: {
|
|
125
|
+
mimeType: file.mimeType,
|
|
126
|
+
body: createReadStream(input.filePath),
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
const videoId = response.data.id;
|
|
130
|
+
if (!videoId)
|
|
131
|
+
throw new Error('YouTube 업로드 응답에 videoId가 없습니다.');
|
|
132
|
+
return {
|
|
133
|
+
...safeStatus(response.data),
|
|
134
|
+
requestedPrivacyStatus: privacyStatus,
|
|
135
|
+
file: { path: input.filePath, size: file.size, ...facts },
|
|
136
|
+
validationWarnings: validation.issues,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (error instanceof Error && error.message.includes('videoId'))
|
|
141
|
+
throw error;
|
|
142
|
+
throw friendlyGoogleError(error);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
export async function updateYouTubeVideoPrivacy(auth, input) {
|
|
146
|
+
assertVisibilityConfirmed(input.privacyStatus, input.confirmVisible);
|
|
147
|
+
const youtube = google.youtube({ version: 'v3', auth });
|
|
148
|
+
try {
|
|
149
|
+
const currentResponse = await youtube.videos.list({ part: ['status'], id: [input.videoId] });
|
|
150
|
+
const current = currentResponse.data.items?.[0];
|
|
151
|
+
if (!current)
|
|
152
|
+
throw new Error(`YouTube 영상을 찾지 못했습니다: ${input.videoId}`);
|
|
153
|
+
await youtube.videos.update({
|
|
154
|
+
part: ['status'],
|
|
155
|
+
requestBody: {
|
|
156
|
+
id: input.videoId,
|
|
157
|
+
status: {
|
|
158
|
+
privacyStatus: input.privacyStatus,
|
|
159
|
+
embeddable: current.status?.embeddable ?? true,
|
|
160
|
+
license: current.status?.license ?? 'youtube',
|
|
161
|
+
publicStatsViewable: current.status?.publicStatsViewable ?? true,
|
|
162
|
+
selfDeclaredMadeForKids: current.status?.selfDeclaredMadeForKids ?? false,
|
|
163
|
+
containsSyntheticMedia: current.status?.containsSyntheticMedia ?? false,
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
return await getYouTubeVideoStatus(auth, input.videoId);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
throw friendlyGoogleError(error);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
export const __testing = {
|
|
174
|
+
assertVisibilityConfirmed,
|
|
175
|
+
assertUploadFile,
|
|
176
|
+
assertTags,
|
|
177
|
+
assertShortsCompatible,
|
|
178
|
+
videoFacts,
|
|
179
|
+
};
|
package/package.json
CHANGED
package/tool-manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "등록된 MCP 도구 + 도메인 메타데이터(label·credential·summary)의 SSOT. src/__tests__/tool-manifest.test.ts 가 실제 서버 등록 목록과 diff 하고 메타데이터 정합성을 검사한다. 도구 추가/삭제/개명 시 이 파일을 함께 갱신할 것 — 산문 문서에는 정확한 개수를 쓰지 말고 이 파일을 가리킬 것. mimi-seed://tools/catalog 리소스가 이 파일을 그대로 서빙한다.",
|
|
3
|
-
"total":
|
|
3
|
+
"total": 177,
|
|
4
4
|
"domains": {
|
|
5
5
|
"admob": {
|
|
6
6
|
"label": "AdMob",
|
|
@@ -293,9 +293,12 @@
|
|
|
293
293
|
},
|
|
294
294
|
"video": {
|
|
295
295
|
"label": "영상 제작",
|
|
296
|
-
"credential": "
|
|
297
|
-
"summary": "story
|
|
296
|
+
"credential": "기능별 API 키 + Google OAuth(YouTube 게시) + FFmpeg",
|
|
297
|
+
"summary": "story 스토리보드·영상 리서치/제작·YouTube 업로드와 공개 상태 관리",
|
|
298
298
|
"tools": [
|
|
299
|
+
"youtube_upload_video",
|
|
300
|
+
"youtube_get_video_status",
|
|
301
|
+
"youtube_update_video_privacy",
|
|
299
302
|
"video_plan_from_story",
|
|
300
303
|
"video_research_youtube",
|
|
301
304
|
"video_search_stock_assets",
|