hlassiege/darylpublic Fork
⑂ 783b8eb
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/daryl.git
git clone ssh://git@rickub.com/hlassiege/daryl.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(publish): keep the A/B variants to add in YouTube Studio after upload

The YouTube API cannot create Test & compare experiments, so only variant A is uploaded. Once the long video is sent, show variants B and C with a link to Studio, a thumbnail download and a title copy button.
hlassiege committed 2026-10-13T16:24:21+02:00 Browse files
783b8eb parent: 65f4be9
modified web/src/i18n/locales/en/upload.ts +7 -0
@@ -37,6 +37,13 @@ export default {
3737 thumbManual: "set the thumbnail manually",
3838 failed: "Failed: {error}",
3939 schedule: "Schedule",
40+ ab: {
41+ todo: "A/B test to finish in YouTube Studio: the API only uploads variant A. Add these variants under “Test & compare”, next to the thumbnail.",
42+ openStudio: "Open in YouTube Studio →",
43+ copy: "Copy title",
44+ copied: "Title copied",
45+ download: "Download thumbnail",
46+ },
4047 allSent: "All videos are uploaded. The project moves to “Published”.",
4148 auditNote: "Until Google audits your Google Cloud project, YouTube keeps videos uploaded through the API private: switch them to public in YouTube Studio. That’s also where you add variants B and C of the A/B test.",
4249 };
@@ -37,6 +37,13 @@ export default {
37 thumbManual: "set the thumbnail manually",37 thumbManual: "set the thumbnail manually",
38 failed: "Failed: {error}",38 failed: "Failed: {error}",
39 schedule: "Schedule",39 schedule: "Schedule",
40+ ab: {
41+ todo: "A/B test to finish in YouTube Studio: the API only uploads variant A. Add these variants under “Test & compare”, next to the thumbnail.",
42+ openStudio: "Open in YouTube Studio →",
43+ copy: "Copy title",
44+ copied: "Title copied",
45+ download: "Download thumbnail",
46+ },
40 allSent: "All videos are uploaded. The project moves to “Published”.",47 allSent: "All videos are uploaded. The project moves to “Published”.",
41 auditNote: "Until Google audits your Google Cloud project, YouTube keeps videos uploaded through the API private: switch them to public in YouTube Studio. That’s also where you add variants B and C of the A/B test.",48 auditNote: "Until Google audits your Google Cloud project, YouTube keeps videos uploaded through the API private: switch them to public in YouTube Studio. That’s also where you add variants B and C of the A/B test.",
42 };49 };
modified web/src/i18n/locales/fr/upload.ts +7 -0
@@ -37,6 +37,13 @@ export default {
3737 thumbManual: "miniature à poser à la main",
3838 failed: "Échec : {error}",
3939 schedule: "Programmer",
40+ ab: {
41+ todo: "Test A/B à finir dans YouTube Studio : l’API n’envoie que la variante A. Ajoute ces variantes dans « Tester et comparer », à côté de la miniature.",
42+ openStudio: "Ouvrir dans YouTube Studio →",
43+ copy: "Copier le titre",
44+ copied: "Titre copié",
45+ download: "Télécharger la miniature",
46+ },
4047 allSent: "Toutes les vidéos sont envoyées. Le projet passe dans « Publiées ».",
4148 auditNote: "Tant que Google n’a pas audité ton projet Google Cloud, YouTube garde en privé les vidéos envoyées par l’API : passe-les en public dans YouTube Studio. Les variantes B et C du test A/B s’y ajoutent aussi.",
4249 };
@@ -37,6 +37,13 @@ export default {
37 thumbManual: "miniature à poser à la main",37 thumbManual: "miniature à poser à la main",
38 failed: "Échec : {error}",38 failed: "Échec : {error}",
39 schedule: "Programmer",39 schedule: "Programmer",
40+ ab: {
41+ todo: "Test A/B à finir dans YouTube Studio : l’API n’envoie que la variante A. Ajoute ces variantes dans « Tester et comparer », à côté de la miniature.",
42+ openStudio: "Ouvrir dans YouTube Studio →",
43+ copy: "Copier le titre",
44+ copied: "Titre copié",
45+ download: "Télécharger la miniature",
46+ },
40 allSent: "Toutes les vidéos sont envoyées. Le projet passe dans « Publiées ».",47 allSent: "Toutes les vidéos sont envoyées. Le projet passe dans « Publiées ».",
41 auditNote: "Tant que Google n’a pas audité ton projet Google Cloud, YouTube garde en privé les vidéos envoyées par l’API : passe-les en public dans YouTube Studio. Les variantes B et C du test A/B s’y ajoutent aussi.",48 auditNote: "Tant que Google n’a pas audité ton projet Google Cloud, YouTube garde en privé les vidéos envoyées par l’API : passe-les en public dans YouTube Studio. Les variantes B et C du test A/B s’y ajoutent aussi.",
42 };49 };
modified web/src/project/steps/UploadStep.vue +37 -1
@@ -2,13 +2,15 @@
22 /**
33 * Upload : chaque vidéo (la longue, les shorts) s'envoie à part sur YouTube par l'API, avec sa
44 * visibilité (ou sa date de publication). Une vidéo ne part que si ses vérifications passent :
5- * vignette, titre, chapitres, test A/B, rendu à jour.
5+ * vignette, titre, chapitres, test A/B, rendu à jour. L'API ne crée pas le test A/B : une fois la
6+ * vidéo longue envoyée, ses variantes B et C restent affichées pour les poser dans YouTube Studio.
67 */
78 import { computed } from "vue";
89 import { useRouter } from "vue-router";
910 import { useI18n } from "vue-i18n";
1011 import { usePref } from "../../lib/composables";
1112 import { mmss } from "../../lib/format";
13+import { toast } from "../../lib/toast";
1214 import { store } from "../store";
1315 import type { Visibility } from "../types";
1416 import { checkLabel, missing, thumbUrl, uploading, usePublish, VISIBILITY, type Video } from "./publish/usePublish";
@@ -39,6 +41,20 @@ function state(v: Video): { label: string; tone: "" | "done" | "red" } {
3941 }
4042
4143 const done = (v: Video) => v.upload?.status === "done";
44+
45+// la vidéo longue envoyée avec un test A/B : ses variantes B et C restent à poser à la main
46+const abLeft = computed(() => {
47+ const v = P.videos.value.find((x) => x.kind === "long");
48+ if (!v || !done(v) || !v.meta.ab_on || !v.meta.ab?.length) return null;
49+ return {
50+ studio: v.upload?.video_id ? `https://studio.youtube.com/video/${v.upload.video_id}/edit` : null,
51+ variants: v.meta.ab.map((a, i) => ({ ...a, l: "BC"[i], img: thumbUrl(project.value, a.thumb) })),
52+ };
53+});
54+async function copyTitle(title: string) {
55+ await navigator.clipboard.writeText(title);
56+ toast(t("upload.ab.copied"));
57+}
4258 const visLabel = (vis: Visibility) => { const key = VISIBILITY.find(([k]) => k === vis)?.[1]; return key ? t(key) : vis; };
4359 </script>
4460
@@ -106,6 +122,22 @@ const visLabel = (vis: Visibility) => { const key = VISIBILITY.find(([k]) => k =
106122 </div>
107123 </div>
108124
125+ <section v-if="abLeft" class="ab">
126+ <div class="ab-head">
127+ <span>{{ t("upload.ab.todo") }}</span>
128+ <span class="spacer" />
129+ <a v-if="abLeft.studio" :href="abLeft.studio" target="_blank" rel="noopener" class="small">{{ t("upload.ab.openStudio") }}</a>
130+ </div>
131+ <div v-for="a in abLeft.variants" :key="a.l" class="ab-row">
132+ <span class="badge mono">{{ a.l }}</span>
133+ <a v-if="a.img" :href="a.img" download><img :src="a.img" alt="" class="img wide" :title="t('upload.ab.download')"></a>
134+ <div v-else class="img wide hatch" />
135+ <span class="title">{{ a.title }}</span>
136+ <button class="link-btn small" @click="copyTitle(a.title)">{{ t("upload.ab.copy") }}</button>
137+ <a v-if="a.img" :href="a.img" download class="small">{{ t("upload.ab.download") }}</a>
138+ </div>
139+ </section>
140+
109141 <p v-if="allSent" class="end">{{ t("upload.allSent") }}</p>
110142 <p v-else-if="P.account.value?.connected" class="note">
111143 {{ t("upload.auditNote") }}
@@ -146,6 +178,10 @@ const visLabel = (vis: Visibility) => { const key = VISIBILITY.find(([k]) => k =
146178 .send .done { color: var(--ink); }
147179 .track { height: 3px; width: 100%; background: var(--line-soft); }
148180 .track > div { height: 3px; background: var(--red); transition: width .4s; }
181+.ab { display: flex; flex-direction: column; gap: 8px; padding: 14px 16px; border: 1px solid var(--red); border-radius: 4px; }
182+.ab-head { display: flex; align-items: baseline; gap: 12px; font-size: 13.5px; color: var(--red-dark); }
183+.ab-row { display: grid; grid-template-columns: 22px 71px minmax(0, 1fr) auto auto; gap: 14px; align-items: center; }
184+.badge { width: 20px; height: 20px; border-radius: 2px; background: var(--red); color: #fff; font-size: 11px; font-weight: 700; display: grid; place-items: center; }
149185 .end, .note { margin: 6px 0 0; font-size: 13.5px; color: var(--muted); line-height: 1.5; }
150186 .note { font-size: 12.5px; color: var(--muted-2); }
151187 </style>
@@ -2,13 +2,15 @@
2 /**2 /**
3 * Upload : chaque vidéo (la longue, les shorts) s'envoie à part sur YouTube par l'API, avec sa3 * Upload : chaque vidéo (la longue, les shorts) s'envoie à part sur YouTube par l'API, avec sa
4 * visibilité (ou sa date de publication). Une vidéo ne part que si ses vérifications passent :4 * visibilité (ou sa date de publication). Une vidéo ne part que si ses vérifications passent :
5- * vignette, titre, chapitres, test A/B, rendu à jour.5+ * vignette, titre, chapitres, test A/B, rendu à jour. L'API ne crée pas le test A/B : une fois la
6+ * vidéo longue envoyée, ses variantes B et C restent affichées pour les poser dans YouTube Studio.
6 */7 */
7 import { computed } from "vue";8 import { computed } from "vue";
8 import { useRouter } from "vue-router";9 import { useRouter } from "vue-router";
9 import { useI18n } from "vue-i18n";10 import { useI18n } from "vue-i18n";
10 import { usePref } from "../../lib/composables";11 import { usePref } from "../../lib/composables";
11 import { mmss } from "../../lib/format";12 import { mmss } from "../../lib/format";
13+import { toast } from "../../lib/toast";
12 import { store } from "../store";14 import { store } from "../store";
13 import type { Visibility } from "../types";15 import type { Visibility } from "../types";
14 import { checkLabel, missing, thumbUrl, uploading, usePublish, VISIBILITY, type Video } from "./publish/usePublish";16 import { checkLabel, missing, thumbUrl, uploading, usePublish, VISIBILITY, type Video } from "./publish/usePublish";
@@ -39,6 +41,20 @@ function state(v: Video): { label: string; tone: "" | "done" | "red" } {
39 }41 }
40 42
41 const done = (v: Video) => v.upload?.status === "done";43 const done = (v: Video) => v.upload?.status === "done";
44+
45+// la vidéo longue envoyée avec un test A/B : ses variantes B et C restent à poser à la main
46+const abLeft = computed(() => {
47+ const v = P.videos.value.find((x) => x.kind === "long");
48+ if (!v || !done(v) || !v.meta.ab_on || !v.meta.ab?.length) return null;
49+ return {
50+ studio: v.upload?.video_id ? `https://studio.youtube.com/video/${v.upload.video_id}/edit` : null,
51+ variants: v.meta.ab.map((a, i) => ({ ...a, l: "BC"[i], img: thumbUrl(project.value, a.thumb) })),
52+ };
53+});
54+async function copyTitle(title: string) {
55+ await navigator.clipboard.writeText(title);
56+ toast(t("upload.ab.copied"));
57+}
42 const visLabel = (vis: Visibility) => { const key = VISIBILITY.find(([k]) => k === vis)?.[1]; return key ? t(key) : vis; };58 const visLabel = (vis: Visibility) => { const key = VISIBILITY.find(([k]) => k === vis)?.[1]; return key ? t(key) : vis; };
43 </script>59 </script>
44 60
@@ -106,6 +122,22 @@ const visLabel = (vis: Visibility) => { const key = VISIBILITY.find(([k]) => k =
106 </div>122 </div>
107 </div>123 </div>
108 124
125+ <section v-if="abLeft" class="ab">
126+ <div class="ab-head">
127+ <span>{{ t("upload.ab.todo") }}</span>
128+ <span class="spacer" />
129+ <a v-if="abLeft.studio" :href="abLeft.studio" target="_blank" rel="noopener" class="small">{{ t("upload.ab.openStudio") }}</a>
130+ </div>
131+ <div v-for="a in abLeft.variants" :key="a.l" class="ab-row">
132+ <span class="badge mono">{{ a.l }}</span>
133+ <a v-if="a.img" :href="a.img" download><img :src="a.img" alt="" class="img wide" :title="t('upload.ab.download')"></a>
134+ <div v-else class="img wide hatch" />
135+ <span class="title">{{ a.title }}</span>
136+ <button class="link-btn small" @click="copyTitle(a.title)">{{ t("upload.ab.copy") }}</button>
137+ <a v-if="a.img" :href="a.img" download class="small">{{ t("upload.ab.download") }}</a>
138+ </div>
139+ </section>
140+
109 <p v-if="allSent" class="end">{{ t("upload.allSent") }}</p>141 <p v-if="allSent" class="end">{{ t("upload.allSent") }}</p>
110 <p v-else-if="P.account.value?.connected" class="note">142 <p v-else-if="P.account.value?.connected" class="note">
111 {{ t("upload.auditNote") }}143 {{ t("upload.auditNote") }}
@@ -146,6 +178,10 @@ const visLabel = (vis: Visibility) => { const key = VISIBILITY.find(([k]) => k =
146 .send .done { color: var(--ink); }178 .send .done { color: var(--ink); }
147 .track { height: 3px; width: 100%; background: var(--line-soft); }179 .track { height: 3px; width: 100%; background: var(--line-soft); }
148 .track > div { height: 3px; background: var(--red); transition: width .4s; }180 .track > div { height: 3px; background: var(--red); transition: width .4s; }
181+.ab { display: flex; flex-direction: column; gap: 8px; padding: 14px 16px; border: 1px solid var(--red); border-radius: 4px; }
182+.ab-head { display: flex; align-items: baseline; gap: 12px; font-size: 13.5px; color: var(--red-dark); }
183+.ab-row { display: grid; grid-template-columns: 22px 71px minmax(0, 1fr) auto auto; gap: 14px; align-items: center; }
184+.badge { width: 20px; height: 20px; border-radius: 2px; background: var(--red); color: #fff; font-size: 11px; font-weight: 700; display: grid; place-items: center; }
149 .end, .note { margin: 6px 0 0; font-size: 13.5px; color: var(--muted); line-height: 1.5; }185 .end, .note { margin: 6px 0 0; font-size: 13.5px; color: var(--muted); line-height: 1.5; }
150 .note { font-size: 12.5px; color: var(--muted-2); }186 .note { font-size: 12.5px; color: var(--muted-2); }
151 </style>187 </style>