Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/sync-shorts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Sync YouTube Shorts to Sanity

on:
workflow_dispatch:
inputs:
dataset:
description: "Target Sanity Dataset"
required: true
default: "production"
type: choice
options:
- production
- dev
schedule:
- cron: "30 6 * * *"

jobs:
sync-shorts:
runs-on: ubuntu-latest
environment: production

steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
cache: "pnpm"

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Sync Shorts from Automated Videos
run: node scripts/sync-shorts-from-automated-videos.mjs
env:
SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }}
SANITY_API_WRITE_TOKEN: ${{ secrets.SANITY_API_WRITE_TOKEN }}
SANITY_STUDIO_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID || 'hfh83o0w' }}
SANITY_STUDIO_DATASET: ${{ inputs.dataset || 'production' }}
YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }}
128 changes: 98 additions & 30 deletions apps/sanity/extract.json
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,36 @@
}
}
},
{
"type": "type",
"name": "automatedVideo.reference",
"value": {
"type": "object",
"attributes": {
"_ref": {
"type": "objectAttribute",
"value": {
"type": "string"
}
},
"_type": {
"type": "objectAttribute",
"value": {
"type": "string",
"value": "reference"
}
},
"_weak": {
"type": "objectAttribute",
"value": {
"type": "boolean"
},
"optional": true
}
},
"dereferencesTo": "automatedVideo"
}
},
{
"type": "type",
"name": "category.reference",
Expand Down Expand Up @@ -782,13 +812,27 @@
},
"optional": true
},
"description": {
"type": "objectAttribute",
"value": {
"type": "string"
},
"optional": true
},
"youtube": {
"type": "objectAttribute",
"value": {
"type": "string"
},
"optional": true
},
"shortVideoUrl": {
"type": "objectAttribute",
"value": {
"type": "string"
},
"optional": true
},
"thumbnail": {
"type": "objectAttribute",
"value": {
Expand Down Expand Up @@ -844,6 +888,14 @@
},
"optional": true
},
"sourceAutomatedVideo": {
"type": "objectAttribute",
"value": {
"type": "inline",
"name": "automatedVideo.reference"
},
"optional": true
},
"publishedAt": {
"type": "objectAttribute",
"value": {
Expand All @@ -858,6 +910,52 @@
},
"optional": true
},
"statistics": {
"type": "objectAttribute",
"value": {
"type": "object",
"attributes": {
"youtube": {
"type": "objectAttribute",
"value": {
"type": "object",
"attributes": {
"viewCount": {
"type": "objectAttribute",
"value": {
"type": "number"
},
"optional": true
},
"likeCount": {
"type": "objectAttribute",
"value": {
"type": "number"
},
"optional": true
},
"commentCount": {
"type": "objectAttribute",
"value": {
"type": "number"
},
"optional": true
},
"favoriteCount": {
"type": "objectAttribute",
"value": {
"type": "number"
},
"optional": true
}
}
},
"optional": true
}
}
},
"optional": true
},
"categories": {
"type": "objectAttribute",
"value": {
Expand Down Expand Up @@ -1183,36 +1281,6 @@
}
}
},
{
"type": "type",
"name": "automatedVideo.reference",
"value": {
"type": "object",
"attributes": {
"_ref": {
"type": "objectAttribute",
"value": {
"type": "string"
}
},
"_type": {
"type": "objectAttribute",
"value": {
"type": "string",
"value": "reference"
}
},
"_weak": {
"type": "objectAttribute",
"value": {
"type": "boolean"
},
"optional": true
}
},
"dereferencesTo": "automatedVideo"
}
},
{
"name": "videoAnalytics",
"type": "document",
Expand Down
60 changes: 58 additions & 2 deletions apps/sanity/functions/sync-youtube-analytics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,69 @@ export const handler = scheduledEventHandler(async ({ context }) => {
useCdn: false,
});

// Query all posts and podcasts with YouTube URLs
// Step 0: Sync any published automatedVideo Shorts into standalone `_type == "short"` documents
const autoShortDocs = await client.fetch<
Array<{
_id: string;
_createdAt: string;
title: string;
slug?: { current?: string };
youtubeShortId: string;
shortUrl?: string;
hook?: string;
}>
>(
`*[_type == "automatedVideo" && defined(youtubeShortId) && !(_id in path("drafts.**"))]{
_id,
_createdAt,
title,
slug,
youtubeShortId,
shortUrl,
"hook": script.hook
}`,
);

for (const autoShort of autoShortDocs) {
const shortId = `short-${autoShort.youtubeShortId}`;
const slugCurrent =
autoShort.slug?.current ||
autoShort.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
.slice(0, 90);
await client
.createIfNotExists({
_id: shortId,
_type: "short",
title: autoShort.title,
slug: { _type: "slug", current: slugCurrent },
description: autoShort.hook || undefined,
youtube: `https://www.youtube.com/shorts/${autoShort.youtubeShortId}`,
shortVideoUrl: autoShort.shortUrl || undefined,
sourceAutomatedVideo: {
_type: "reference",
_ref: autoShort._id,
},
publishedAt: autoShort._createdAt,
duration: 60,
})
.catch((err) => {
console.warn(
`[Sync YouTube Analytics] Could not createIfNotExists short ${shortId}:`,
err,
);
});
}

// Query all posts, podcasts, and shorts with YouTube URLs
console.log(
`[Sync YouTube Analytics] Fetching documents with YouTube URLs in project ${projectId}, dataset ${dataset}...`,
);

const docs = await client.fetch<TargetVideoDoc[]>(
`*[_type in ["post", "podcast"] && (defined(youtube) || defined(listenLinks.youtube)) && !(_id in path("drafts.**"))]{
`*[_type in ["post", "podcast", "short"] && (defined(youtube) || defined(listenLinks.youtube)) && !(_id in path("drafts.**"))]{
_id,
_type,
title,
Expand Down
38 changes: 38 additions & 0 deletions apps/sanity/schemas/documents/short.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,25 @@ export default defineType({
},
validation: (rule) => rule.required(),
}),
defineField({
name: "description",
title: "Description / Hook",
type: "text",
rows: 3,
description: "Short hook or summary for SEO/AEO and card previews",
}),
defineField({
name: "youtube",
title: "YouTube",
type: "string",
description: "YouTube Shorts video ID or URL",
}),
defineField({
name: "shortVideoUrl",
title: "Direct MP4 Video URL",
type: "url",
description: "Direct Sanity CDN or R2 MP4 URL for native vertical video playback",
}),
defineField({
name: "thumbnail",
title: "Thumbnail",
Expand All @@ -49,6 +62,13 @@ export default defineType({
to: [{ type: "podcast" }],
description: "Long-form episode this Short was clipped from",
}),
defineField({
name: "sourceAutomatedVideo",
title: "Source Automated Video",
type: "reference",
to: [{ type: "automatedVideo" }],
description: "Automated video pipeline run that produced this Short",
}),
defineField({
name: "publishedAt",
title: "Published At",
Expand All @@ -60,6 +80,24 @@ export default defineType({
type: "number",
description: "Duration in seconds",
}),
defineField({
name: "statistics",
title: "Statistics",
type: "object",
fields: [
defineField({
name: "youtube",
title: "YouTube Statistics",
type: "object",
fields: [
defineField({ name: "viewCount", title: "Views", type: "number" }),
defineField({ name: "likeCount", title: "Likes", type: "number" }),
defineField({ name: "commentCount", title: "Comments", type: "number" }),
defineField({ name: "favoriteCount", title: "Favorites", type: "number" }),
],
}),
],
}),
defineField({
name: "categories",
title: "Categories",
Expand Down
4 changes: 2 additions & 2 deletions apps/sanity/schemas/singletons/engineConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,8 +327,8 @@ export default defineType({
type: 'string',
fieldset: 'aiGeneration',
description:
'The Google Gemini model used for script generation and content analysis',
initialValue: 'gemini-2.0-flash',
'The Google Gemini model used for script generation and content analysis (e.g., gemini-2.5-flash, gemini-2.5-pro)',
initialValue: 'gemini-2.5-flash',
}),
defineField({
name: 'infographicModel',
Expand Down
4 changes: 2 additions & 2 deletions apps/sanity/schemas/singletons/pipelineConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ export default defineType({
name: "geminiModel",
title: "Gemini Model",
type: "string",
description: "The Google Gemini model used for script generation and content analysis (e.g., gemini-2.0-flash, gemini-2.5-pro)",
initialValue: "gemini-2.0-flash",
description: "The Google Gemini model used for script generation and content analysis (e.g., gemini-2.5-flash, gemini-2.5-pro)",
initialValue: "gemini-2.5-flash",
}),
defineField({
name: "elevenLabsVoiceId",
Expand Down
8 changes: 7 additions & 1 deletion apps/site/src/components/Header.astro
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import ThemeToggle from "./ThemeToggle.astro";
import SearchModal from "./SearchModal.astro";

const settings = await Astro.locals.sanity.loadQuery(settingsQuery);
const navLinks = settings?.navLinks ?? [];
const rawNavLinks = settings?.navLinks ?? [];
const navLinks = rawNavLinks.some((link) => link?.path === "/shorts")
? rawNavLinks
: [
...rawNavLinks,
{ _key: "shorts-nav", path: "/shorts", title: "Shorts", sideOnly: false },
];

// The Next header split these across a desktop <nav> and a mobile Sheet;
// `sideOnly` links appear only in the drawer.
Expand Down
Loading
Loading