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.