<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Oauth on despatches</title><link>https://icle.es/tags/oauth/</link><description>Recent content in Oauth on despatches</description><generator>Hugo</generator><language>en</language><lastBuildDate>Mon, 07 Jul 2025 20:18:49 +0100</lastBuildDate><atom:link href="https://icle.es/tags/oauth/index.xml" rel="self" type="application/rss+xml"/><item><title>Projector: Keep YouTube Descriptions synced</title><link>https://icle.es/2025/07/07/projector-keep-youtube-descriptions-synced/</link><pubDate>Mon, 07 Jul 2025 20:08:21 +0100</pubDate><guid>https://icle.es/2025/07/07/projector-keep-youtube-descriptions-synced/</guid><description>&lt;p>In my &lt;a href="https://icle.es/projector-Hugo.md">previous post&lt;/a>, I used &lt;a href="https://gohugo.io/">hugo&lt;/a> to
generate correctly linked, always up to date descriptions for my YouTube Videos.&lt;/p>
&lt;p>But if I&amp;rsquo;m generating the descriptions automatically&amp;hellip; I&amp;rsquo;m hardly going to be
excited about copying and pasting them into YouTube - right? right!&lt;/p>
&lt;p>Automating this process brings up a few design choices.&lt;/p>
&lt;h2 id="planning">Planning&lt;/h2>
&lt;h3 id="which-language">Which language&lt;/h3>
&lt;p>There were a few contenders, and here&amp;rsquo;s how I thought them through:&lt;/p>
&lt;h4 id="zig">Zig&lt;/h4>
&lt;p>I’m currently learning &lt;a href="https://ziglang.org/">Zig&lt;/a>, and I love using it for my
game development. But it doesn’t yet have mature libraries for working with the
YouTube Data API - and I don’t feel like writing one. So, sadly, Zig’s out for
this one.&lt;/p></description><content:encoded><![CDATA[<p>In my <a href="https://icle.es/projector-Hugo.md">previous post</a>, I used <a href="https://gohugo.io/">hugo</a> to
generate correctly linked, always up to date descriptions for my YouTube Videos.</p>
<p>But if I&rsquo;m generating the descriptions automatically&hellip; I&rsquo;m hardly going to be
excited about copying and pasting them into YouTube - right? right!</p>
<p>Automating this process brings up a few design choices.</p>
<h2 id="planning">Planning</h2>
<h3 id="which-language">Which language</h3>
<p>There were a few contenders, and here&rsquo;s how I thought them through:</p>
<h4 id="zig">Zig</h4>
<p>I’m currently learning <a href="https://ziglang.org/">Zig</a>, and I love using it for my
game development. But it doesn’t yet have mature libraries for working with the
YouTube Data API - and I don’t feel like writing one. So, sadly, Zig’s out for
this one.</p>
<h4 id="python">Python</h4>
<p>I used Python for <a href="https://icle.es/despatches.md">despatches</a> and it was the right fit there -
good libraries for BlueSky and Reddit.</p>
<p>However, I did not enjoy the experience:</p>
<ul>
<li>
<p><code>bazel</code> was a constant struggle</p>
</li>
<li>
<p><code>poetry</code> is nice… but still a bit of a nightmare. It just makes the pain more
structured</p>
</li>
<li>
<p>Worst of all: blusky failed after reddit succeeded caused a <em>partial success</em>,
which broke the Git commit and silently caused a post to be repeated
(embarrassing!)</p>
<p>That kind of problem <em>can</em> happen in Go (nil pointer), though it wouldn’t in
Zig. But at least with Go, most handleable errors <em>stay</em> errors — they don’t
crash the whole tool.</p>
</li>
</ul>
<h4 id="java">java</h4>
<p>Sure, I could do this in Java - but I really don’t want to mess with the JVM.
And more importantly, I’m doing this for fun. Java doesn’t feel like that
anymore.</p>
<h4 id="golang">golang</h4>
<p>Not quite my favourite any more, but still a close second. It&rsquo;s <em>fast</em>, has
YouTube libraries and it somehow seems fitting that Hugo is also a go baby.</p>
<p>Even though I’m not wiring the two directly, the ecosystem fit is nice.</p>
<h2 id="overall-plan">Overall Plan</h2>
<ul>
<li>
<p>Let <code>Hugo</code> render the YouTube description as plain text</p>
</li>
<li>
<p>Traverse the <code>youtube/*.md</code> files in the source directory</p>
<ul>
<li>Skip videos that are too old to update (maybe older than 30 days?)</li>
<li>Hash the rendered output (title, description, tags, etc.)</li>
<li>Compare that hash with the one stored in the frontmatter</li>
<li>If it doesn’t match,
<ul>
<li>Update the metadata on YouTube</li>
<li>Update the hash</li>
</ul>
</li>
<li>commit and push any updates (should be only hash changes)</li>
</ul>
</li>
</ul>
<h2 id="validation">Validation</h2>
<p>One thing worth being careful about is whether the metadata is valid. We do not
want the sync to fail during its scheduled run - when it won&rsquo;t have many choices
on how to resolve it.</p>
<p>In a bid to mitigate this, we&rsquo;ll add a command to validate the source and
rendered files.</p>
<p>The validation would expect the rendered files to be generated as well, which
seems reasonable since Hugo is probably running as <code>hugo serve</code> while the
content files are being updated.</p>
```go
func validate(sourcePath string, renderedPath string) error {
	targetSourceDir, err := getTargetDir(sourcePath)
	if err != nil {
		return err
	}

	targetRenderedDir, err := getTargetDir(renderedPath)
	if err != nil {
		return nil
	}

	videos, err := findRecentVideos(targetSourceDir)
	for _, video := range videos {
		_, err := video.getDescription(targetRenderedDir)
		if err != nil {
			slog.Warn("unable to find rendered file", "file", video.renderedPath)
		}
	}
	return nil
}
```
<p>The validate function will retrieve the relevant files and check that there is a
corresponding rendered description.</p>
<p>If it errors in that process, we know that it would error out in the sync.</p>
<p>We can&rsquo;t catch errors around the API though at this stage, and that&rsquo;s
unavoidable.</p>
<h2 id="sync">Sync</h2>
<h3 id="hashing-the-description">Hashing the Description</h3>
<p>This part was surprisingly easy:</p>
```go
bdesc, err := video.getDescription(targetRenderedDir)
if err != nil {
    slog.Warn("unable to find rendered file", "file", video.renderedPath)
}

// We want to hash the contents of description
// Check with the hash in the metadata to see if it matches
hash := md5.Sum(bdesc)
strHash := hex.EncodeToString(hash[:])
```
<p>The challenge was trying to write the updated yaml frontmatter back. I was using
the <code>adrg/frontmatter</code> library to read the frontmatter, but it does not support
writing it back.</p>
<h3 id="detour-write-a-small-frontmatter-library">Detour: Write a small frontmatter Library</h3>
<p>I took a little detour to build
<a href="https://icle.es/golang/inscribe.md">inscribe, a little frontmatter library that supports reading and writing back in yaml</a>.</p>
<h2 id="auth">Auth</h2>
<p>We need the YouTube Client to have an OAuth Token, which we can retrieve by:</p>
<ul>
<li><a href="https://console.cloud.google.com/auth/clients">Create a new OAuth Client</a> -
Type of desktop is probably the easiest</li>
<li>add your user account to
<a href="https://console.cloud.google.com/auth/audience">test users</a>:</li>
<li>go to
<a href="https://accounts.google.com/o/oauth2/v2/auth?client_id=YOUR_CLIENT_ID&amp;redirect_uri=urn:ietf:wg:oauth:2.0:oob&amp;response_type=code&amp;scope=https://www.googleapis.com/auth/youtube">https://accounts.google.com/o/oauth2/v2/auth?client_id=YOUR_CLIENT_ID&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=https://www.googleapis.com/auth/youtube</a>
<ul>
<li>Remember to substitute your actual client_id</li>
<li>Add any other scopes you might want</li>
<li>Go through the flow steps - it&rsquo;ll warn you that the app is unreleased, which
is expected</li>
</ul>
</li>
<li>Take the code that it provides</li>
<li>Call the following curl command</li>
</ul>
```bash
curl -X POST https://oauth2.googleapis.com/token \
  -d client_id=YOUR_CLIENT_ID \
  -d client_secret=YOUR_CLIENT_SECRET \
  -d code=PASTE_THE_CODE_HERE \
  -d grant_type=authorization_code \
  -d redirect_uri=urn:ietf:wg:oauth:2.0:oob
```
<p>You should finally get something like:</p>
```json
{
  "access_token": "ya29...",
  "expires_in": 3599,
  "refresh_token": "1//0g...",
  "scope": "https://www.googleapis.com/auth/youtube",
  "token_type": "Bearer"
}
```
<p>The <code>refresh_token</code> is what you want to save / use as the <code>access_token</code> will
expire (after an hour in this example).</p>
<p>The authentication was a bit more involved with a refresh token, but the
<code>oauth2</code> library helps us out:</p>
```go
func NewYouTube(ClientId string, ClientSecret string, RefreshToken string) (YouTube, error) {

	conf := &oauth2.Config{
		ClientID:     ClientId,
		ClientSecret: ClientSecret,
		Endpoint:     google.Endpoint,
		Scopes:       []string{"https://www.googleapis.com/auth/youtube"},
	}

	// Construct a token from just the refresh token
	token := &oauth2.Token{RefreshToken: RefreshToken}

	ctx := context.Background()

	// Create an authenticated client
	httpClient := conf.Client(ctx, token)

	ytService, err := youtube.NewService(ctx, option.WithHTTPClient(httpClient))
	if err != nil {
		return YouTube{}, err
	}

	return YouTube{
		service: ytService,
	}, nil

}
```
<h2 id="updating-the-description">Updating the description</h2>
<p>Setting the description is a little more complicated because you can&rsquo;t set just
the description.</p>
<p>Everything defined in the <code>VideoSnippet</code> gets updated.</p>
<p>To support this, what we need to do is get the current snippet for the video,
then update it:</p>
```go
vListCall := ytService.Videos.List([]string{"snippet"})
vListCall = vListCall.Id(videoId)
res, err := vListCall.Do()
if err != nil {
    return err
}

if len(res.Items) != 1 {
    return fmt.Errorf("wrong number of videos returned: %d", len(res.Items))
}

ytVideo := res.Items[0]
ytVideo.Snippet.Description = desc

vUpdateCall := ytService.Videos.Update([]string{"snippet"}, ytVideo)
_, err = vUpdateCall.Do()
```
<h2 id="github-action">GitHub Action</h2>
<p>The GitHub Action is fairly straightforward, mostly a copy of the Hugo one,
then:</p>
<ul>
<li>Add Bazel</li>
<li>Run projector sync</li>
<li>Commit if changed</li>
</ul>
```yaml
- uses: bazel-contrib/setup-bazel@0.15.0
  with:
    # Avoid downloading Bazel every time.
    bazelisk-cache: true
    # Store build cache per workflow.
    disk-cache: ${{ github.workflow }}
    # Share repository cache between workflows.
    repository-cache: true
- name: Run projector sync
  env:
    GOOGLE_CLIENT_ID: ${{ secrets.PROJECTOR_GOOGLE_CLIENT_ID }}
    GOOGLE_CLIENT_SECRET: ${{ secrets.PROJECTOR_GOOGLE_CLIENT_SECRET }}
    GOOGLE_REFRESH_TOKEN: ${{ secrets.PROJECTOR_GOOGLE_REFRESH_TOKEN }}
  continue-on-error: true
  run:
    bazel run //tools/projector:projector -- sync -source blog/content/youtube
    -rendered blog/public/youtube
- name: Commit and push if changed
  run: |
    git config user.name "drone-ah bot"
    git config user.email "github.actions@drone-ah.com"

    if ! git diff --quiet; then
      git add -u
      git commit -m "auto: log youtube updates"
      git push
    else
      echo "No changes to commit"
    fi
```
<p>We also needed to upgrade one permission - <code>contents</code></p>
```yaml
permissions:
  contents: write
```
<h2 id="conclusion">Conclusion</h2>
<p>The Google/YouTube documentation was the hardest part here in that it was pretty
obtuse and hard to understand.</p>
<p>Writing a little frontmatter library was unexpected, but while it took a little
time, was straightforward.</p>
<p>Once I got a handle on that, the rest of it was pretty straightforward, partly
because I was reusing parts from before.</p>
<h2 id="links">Links</h2>
<ul>
<li><a href="https://icle.es/projector-Hugo.md">Part 1: Outputting YouTube Descriptions from Hugo</a></li>
<li><a href="https://icle.es/golang/inscribe.md">inscribe: simple frontmatter yaml library</a></li>
</ul>
]]></content:encoded></item></channel></rss>