.daniel's musings - Building My Blog An Update


Building My Blog An Update

September 7, 2024 3:08 PM

A few months ago I decided to rebuild my website. I made a short and quite frankly, a terrible post about it here.

The blog was to some degree an attempt to learn Go and how to use it to build web applications. I should dedicate more time to learning best practices and how to write idiomatic Go code but I'm happy with the progress I've made so far.

A took some time to compartmentalise the codebase and make it more modular. I also added a few features that I think are quite cool. Lets go through them.

The blog is now organised like this:

├── Procfile
├── bin
│   └── start
├── blog/
├── cmd
│   └── dnlm
├── config
│   └── config.yml
└── ui
    ├── static
    └── templates

The file tree now shows a Procfile which is used by Heroku to start the application. The bin directory contains a script that starts the application for development. The cmd directory contains the main package for the application. The config directory contains the configuration file for the application. The ui directory contains the static files and templates for the application.

A lot of the things in this application are a tad self explanatory so I won't go into too much detail. I'll just highlight a few things that I think are cool.

The all seeing watcher

func (a *app) watchFiles(watchDir string) {
	var err error
	a.watcher, err = fsnotify.NewWatcher()
	if err != nil {
		a.errorLog.Fatal(err)
	}

	go func() {
		for {
			select {
			case event, ok := <-a.watcher.Events:
				if !ok {
					return
				}
				switch event.Op {
				case fsnotify.Write, fsnotify.Create, fsnotify.Remove, fsnotify.Rename:
					a.infoLog.Printf("%s: %s\n", event.Op, event.Name)
					// Event name here is the path the file
					a.updateURLToFileMap(event.Name)
				case fsnotify.Chmod:
					// Ignore CHMOD events
				}
			case err, ok := <-a.watcher.Errors:
				if !ok {
					return
				}
				a.errorLog.Println("error:", err)
			}
		}
	}()

	err = a.watcher.Add(watchDir)
	if err != nil {
		a.errorLog.Println(err.Error())
	}
}

The watchFiles function watches a directory for changes. When a change is detected, the function calls the updateURLToFileMap function which rebuilds the paths to the blog posts. This allows for the blog to be updated dynamically without having to restart the application. In the first iteration of the blog, I had to restart the application every time I added a new post or made a change to an existing one. This was a pain and was one of the first things I wanted to fix. A lot of the logic was also hardcoded so there was no real way to watch multiple directories. This has been fixed in the new version as multiple directories can be watched though not in the most elegant way (code duplication).

Time is of the essence

func (a *app) getCreationDate(info os.FileInfo) (time.Time, error) {
	if info == nil {
		return time.Time{}, errors.New("file information is nil")
	}

	nativeInfo, ok := info.Sys().(*syscall.Stat_t)
	if !ok {
		return time.Time{}, errors.New("failed to get native file information")
	}

	birthTime, ok := a.getFileCreationTime(nativeInfo)
	if !ok {
		return time.Time{}, errors.New("file system doesn't support creation time")
	}

	return birthTime, nil
}

func (a *app) getFileCreationTime(nativeInfo *syscall.Stat_t) (time.Time, bool) {
	birthTime := a.getBirthTime(nativeInfo)
	if !birthTime.IsZero() {
		return birthTime, true
	}

	modTime := a.getModTime(nativeInfo)
	if !modTime.IsZero() {
		return modTime, false
	}

	return time.Time{}, false
}

func getTimeFromTimespec(_ *syscall.Stat_t, specField unsafe.Pointer) time.Time {
	tspec := (*syscall.Timespec)(specField)
	if tspec == nil || (tspec.Sec == 0 && tspec.Nsec == 0) {
		return time.Time{}
	}
	return time.Unix(tspec.Sec, int64(tspec.Nsec))
}

Because of how the blog is written, I wanted to display the creation date of the blog post. This was a bit tricky because the creation date of a file is not a standard attribute in the file system (I understand why but still, POSIX save us). I had to use the syscall package to get the creation date of the file. This involved a bit of unsafe code which I quite frankly don't understand all that well. I'm not sure if this is idiomatic Go but hey, it works.

An extension of this is this file:

//go:build darwin
// +build darwin

func (a *app) getBirthTime(nativeInfo *syscall.Stat_t) time.Time {
	return getTimeFromTimespec(nativeInfo, unsafe.Pointer(&nativeInfo.Ctimespec))
}

func (a *app) getModTime(nativeInfo *syscall.Stat_t) time.Time {
	return getTimeFromTimespec(nativeInfo, unsafe.Pointer(&nativeInfo.Mtimespec))
}

This file is only built on Darwin systems (macOS) and contains the functions to get the creation and modification times of a file. This is because the Stat_t struct on macOS contains the Ctimespec field which is the creation time of the file whereas on Linux systems, this field is not present but instead the Ctim field is present.

Music to my ears

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"sync"
	"time"

	"github.com/zmb3/spotify/v2"
	spotifyauth "github.com/zmb3/spotify/v2/auth"
)

var (
	auth = spotifyauth.New(
		spotifyauth.WithRedirectURL(redirectURI),
		spotifyauth.WithScopes(
			spotifyauth.ScopeUserReadCurrentlyPlaying,
			spotifyauth.ScopeUserReadPlaybackState,
		),
	)
	ch                  = make(chan *spotify.Client)
	state               = os.Getenv("STATE")
	redirectURI         = os.Getenv("REDIRECT_URI")
	currentlyPlayingMsg = ""
	isSpotifyActive     bool
	spotifyMutex        sync.RWMutex
)

func (a *app) initSpotify() {
	var client *spotify.Client
	var playerState *spotify.PlayerState

	go func() {
		url := auth.AuthURL(state)
		a.infoLog.Println("Please log in to Spotify by visiting the following page in your browser:", url)

		client = <-ch

		user, err := client.CurrentUser(context.Background())
		if err != nil {
			log.Fatal(err)
		}
		fmt.Println("You are logged in as:", user.ID)

		go a.pollCurrentlyPlaying(client)

		playerState, err = client.PlayerState(context.Background())
		if err != nil {
			log.Fatal(err)
		}
		a.infoLog.Printf("Found your %s (%s)\n", playerState.Device.Type, playerState.Device.Name)
	}()
}

func (a *app) completeAuth(w http.ResponseWriter, r *http.Request) {
	if r.URL.Query().Get("code") == "" || r.URL.Query().Get("state") == "" {
		log.Println("Missing code or state parameter")
		http.Redirect(w, r, "/", http.StatusSeeOther)
		return
	}

	tok, err := auth.Token(r.Context(), state, r)
	if err != nil {
		log.Println("Error getting token:", err)
		http.Redirect(w, r, "/", http.StatusSeeOther)
		return
	}

	if st := r.FormValue("state"); st != state {
		log.Printf("State mismatch: %s != %s\n", st, state)
		http.Redirect(w, r, "/", http.StatusSeeOther)
		return
	}

	client := spotify.New(auth.Client(r.Context(), tok))
	w.Header().Set("Content-Type", "text/html")
	ch <- client

	// Redirect to home page on success
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

func (a *app) pollCurrentlyPlaying(client *spotify.Client) {
	for {
		ctx := context.Background()
		currentlyPlaying, err := client.PlayerCurrentlyPlaying(ctx)

		spotifyMutex.Lock()
		if err != nil {
			a.errorLog.Printf("Error retrieving currently playing track: %v", err)
			currentlyPlayingMsg = "Error retrieving currently playing track"
			isSpotifyActive = false
		} else if currentlyPlaying != nil && currentlyPlaying.Item != nil {
			currentlyPlayingMsg = fmt.Sprintf("Listening to: %s by %s", currentlyPlaying.Item.Name, currentlyPlaying.Item.Artists[0].Name)
			isSpotifyActive = true
		} else {
			currentlyPlayingMsg = ""
			isSpotifyActive = false
		}
		spotifyMutex.Unlock()

		time.Sleep(5 * time.Second)
	}
}

I added a feature that displays the currently playing track on Spotify. This was a bit tricky because I had to use the Spotify API to get the currently playing track. I used the spotify package by zmb3. The initSpotify function is called when the application starts and it starts the authentication process. The completeAuth function is called when the user has authenticated and it completes the authentication process. Once the user is authenticated, the pollCurrentlyPlaying function is called which polls the Spotify API every 5 seconds to get the currently playing track. This is then displayed on the blog.

I had some concerns about polling the Spotify API every 5 seconds but I couldn't find a way to get a webhook or a push notification when the currently playing track changes. I'm open to suggestions on how to improve this.

To tie everything together, I this in the head of the blog:

<script>
    const evtSource = new EventSource("/sse");
    evtSource.onmessage = function(event) {
        document.getElementById("currentlyPlaying").innerText = event.data;
    };
</script>

And in the body:

<p id="currentlyPlaying" class="music"></p>

This listens for server sent events from the server and updates the currently playing track on the blog. This is a bit hacky and I do intend to improve this even if it means building a custom solution.

I talk more about this in my post about server sent events here.

The repository for the blog is open source and can be found here.