S3 Media Server: Pt. 4

Since Last Time

So I had some free time in the evening and felt like extending my code a bit to serve up video, but I didn’t have it in me to write the blog post to go with it. The code changes I made were pretty small, so I’ll go through those now.

Episode Template

Before this, I had only needed one template for displaying a basic HTML list of links. To actually serve the video, I need a different one. I simply add another const at the top of my Go file:

	episodeTemplateHTML = `
<html>
<head>
	<title>{{ .Title }}</title>
	<style>
		body {
			font-family: sans-serif;
		}
	</style>
</head>
<body>
<h1>{{ .Show }} - {{ .Season }} - {{ .Episode }}</h1>
<video src="{{ .Url }}" height="480px" width="640px" controls>
</body>
</head>
</html>
`

Note that I’ve chosen to use the <video> tag instead of the <embed> tag. In googling for how to do this, it seems that <video> is the HTML5 way of displaying a video without requiring a plugin, like <embed> used to.

Also of interest: the data I pass in is essentially the meta-data about the video (show name, season and episode name). This can be used to show the user what’s being displayed.

Compiling the Template

This is a simple one-line addition to the main() function:

episodeTemplate = template.Must(template.New("episode").Parse(episodeTemplateHTML))

Serving the Video

This will look familiar, because it’s very similar to the browsing code. There are a couple of small differences, as we’ll see.

func getEpisode(w http.ResponseWriter, r *http.Request) {
	vars := mux.Vars(r)
	show := vars["Show"]
	season := vars["Season"]
	episode := vars["Episode"]

	log.Printf("Getting episode %v of %v, %v", episode, show, season)
	sess, err := session.NewSession()
	if err != nil {
		log.Fatalf("Unable to create an AWS session: %v", err)
	}

	// S3 service client
	svc := s3.New(sess)

	req, _ := svc.GetObjectRequest(&s3.GetObjectInput{
		Bucket: aws.String(MEDIA_BUCKET_NAME),
		Key: aws.String(path.Join(show, season, episode)),
	})
	url, err := req.Presign(time.Hour * 1)
	if err != nil {
		log.Fatalf("unable to generate presigned URL: %v", err)
	}

	data := struct {
		Title   string
		Show    string
		Season  string
		Episode string
		Url     string
	}{
		Title:   "Episode",
		Show:    show,
		Season:  season,
		Episode: episode,
		Url:     url,
	}

	episodeTemplate.Execute(w, data)
}

Requesting the object from S3 is pretty straightforward; because the user has browsed this far, we know the complete path of the video, so we simply join it all together and request that object. Next, we presign it.

Presigning the request basically allows us to grant permission to someone for a given amount of time. This can be used for a number of reasons, but in this case, we’re allowing the web client access to the given object for a certain amount of time. I’ve somewhat arbitrarily chosen one hour as the time limit because the shows I have are almost all less than an hour long, and I generally watch them straight-through. If for some reason I pause for an hour and come back to it, I may have to refresh the page to re-request it. This potentially could be used for rate-limiting in the future?

Also of interest is the new struct used to pass data into the template execution; I’ve broken down the information a bit more, to allow the template to do more of the work. I’m going to have to take a clean-up pass on that area of code sometime soon, since it’s pretty ugly and ad-hoc right now.

Run Testing

Let’s put it all together and run it!

First Served Video

Woo, it works! Now I can run this program on my laptop wherever I am (no more carrying around the external drive). That’s pretty handy.

Some small clean-up

There are some things that are bugging me that are easy to clean up. I’m going to add some light CSS to make the browsing a little easier, separate the HTML templates into function-specific and refactor out some code into common functions.

CSS

A tiny bit of CSS can go a long way. While this project is simply for personal use, it’d be nice for it not to be totally hideous. I’m going to add some alternating colors to the table rows on each page:

body {
	font-family: sans-serif;
	background-color: white;
}

tr:nth-child(even) {
	background-color: lightgray;
}

a {
	font-size: 1.2em;
}

a:visited {
	font-size: 1.2em;
}

Refactor S3 Code

In doing this project, I’ve noticed that there are two fields we’re looking at in AWS responses: CommonPrefixes and Contents. CommonPrefixes is used when we’re listing “sub-directory” contents where we are indeed looking for Common Prefixes of objects. Contents is used when we run a query on actual objects (e.g. listing episodes with a known path).

I’ve factored out the specifics of the S3 access into two functions: listS3ObjectsAtPath() and listS3CommonPrefixes(); I don’t love the names but they will do for now. These functions return a string slice containing their respective results (or an error if something went wrong). These functions take care of the specifics of where to get their results from the response to the AWS query. I further extracted the actual query construction into another function, s3ListObjects() which handles the specifics of creating a session and checking for truncated results. For truncated results, I added some code to log & blow up; this will force me to extend this eventually.

func s3ListObjects(bucket, delimiter, prefix string) (*s3.ListObjectsOutput, error) {
	sess, err := session.NewSession()
	if err != nil {
		return nil, err
	}

	svc := s3.New(sess)

	resp, err := svc.ListObjects(&s3.ListObjectsInput{
		Bucket: aws.String(bucket),
		Delimiter: aws.String(delimiter),
		Prefix: aws.String(prefix),
	})
	if err != nil {
		return nil, err
	}

	if *resp.IsTruncated {
		log.Fatalf("response is truncated!")
	}

	return resp, nil
}

func listS3CommonPrefixes(bucket, delimiter, prefix string) ([]string, error) {
	resp, err := s3ListObjects(bucket, delimiter, prefix)
	if err != nil {
		return nil, err
	}

	items := make([]string, 0, len(resp.CommonPrefixes))
	for _, prefix := range resp.CommonPrefixes {
		items = append(items, *prefix.Prefix)
	}

	return items, nil
}

func listS3ObjectsAtPath(bucket string, delimiter string, prefix string) ([]string, error) {
	resp, err := s3ListObjects(bucket, delimiter, prefix)
	if err != nil {
		return nil, err
	}

	items := make([]string, 0, len(resp.Contents))

	for _, item := range resp.Contents {
		items = append(items, *item.Key)
	}

	return items, nil
}

Next Steps

Some good next steps would be to make this a bit more user friendly (maybe not just super simple HTML), and to clean up some of the logic a bit (e.g. structs for passing data into the template engine). That said, I may not touch this again for awhile – this is mostly function over form, and I’ve accomplished the basics of what I’ve set out to do!

Final Code

package main

import (
	"html/template"
	"log"
	"net/http"
	"path"
	"strings"
	"time"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/s3"
	"github.com/gorilla/mux"
)

const (
	MEDIA_BUCKET_NAME = "jfisher-media-library"
)

var (
	showsTemplate    *template.Template
	seasonsTemplate  *template.Template
	episodesTemplate *template.Template
	episodeTemplate  *template.Template
)

const (
	showListTemplateHTML = `
<html>
<head>
	<title>Shows of Shows</title>
	<style>
		body {
			font-family: sans-serif;
			background-color: white;
		}

		tr:nth-child(even) {
			background-color: lightgray;
		}

		a {
			font-size: 1.2em;
		}

		a:visited {
			font-size: 1.2em;
		}
	</style>
</head>
<body>
	<h1>TV Shows</h1>
	<table>
		{{ range .Shows }}
			<tr>
				<td>
					<a href="{{.}}/">
					{{ . }}
					</a>
				</td>
			</tr>
		{{ end }}
	</table>
</body>
</html>
`

	seasonsTemplateHTML = `
<html>
<head>
	<title>{{ .Title }}</title>
	<style>
		body {
			font-family: sans-serif;
			background-color: white;
		}

		tr:nth-child(even) {
			background-color: lightgray;
		}

		a {
			font-size: 1.2em;
		}

		a:visited {
			font-size: 1.2em;
		}
	</style>
</head>
<body>
	<h1>Seasons of {{ .Show }}</h1>
	<table border=0>
		{{ range .Seasons }}
			<tr>
				<td>
					<a href="{{.}}/">
					{{ . }}
					</a>
				</td>
			</tr>
		{{ end }}
	</table>

	<p>
	<h4><a href="/">Home</a></h4>
</body>
</html>
`

	episodesTemplateHTML = `
<html>
<head>
	<title>{{ .Title }}</title>
	<style>
		body {
			font-family: sans-serif;
			background-color: white;
		}

		tr:nth-child(even) {
			background-color: lightgray;
		}

		a {
			font-size: 1.2em;
		}

		a:visited {
			font-size: 1.2em;
		}
	</style>
</head>
<body>
	<h1>Episodes of {{ .Show }} - {{ .Season }}</h1>
	<table border=0>
		{{ range .Episodes }}
			<tr>
				<td>
					<a href="{{.}}">
					{{ . }}
					</a>
				</td>
			</tr>
		{{ end }}
	</table>

	<p>
	<h4><a href="/">Home</a></h4>
</body>
</html>
`

	episodeTemplateHTML = `
<html>
<head>
	<title>{{ .Title }}</title>
	<style>
		body {
			font-family: sans-serif;
			background-color: dimgray;
		}
	</style>
</head>
<body>
<h1>{{ .Show }} - {{ .Season }} - {{ .Episode }}</h1>
<p>
<video src="{{ .Url }}" height="480px" width="640px" controls />

<p>
<h4><a href="/">Home</a></h4>
</body>
</html>
`
)

func s3ListObjects(bucket, delimiter, prefix string) (*s3.ListObjectsOutput, error) {
	sess, err := session.NewSession()
	if err != nil {
		return nil, err
	}

	svc := s3.New(sess)

	resp, err := svc.ListObjects(&s3.ListObjectsInput{
		Bucket: aws.String(bucket),
		Delimiter: aws.String(delimiter),
		Prefix: aws.String(prefix),
	})
	if err != nil {
		return nil, err
	}

	if *resp.IsTruncated {
		log.Fatalf("response is truncated!")
	}

	return resp, nil
}

func listS3CommonPrefixes(bucket, delimiter, prefix string) ([]string, error) {
	resp, err := s3ListObjects(bucket, delimiter, prefix)
	if err != nil {
		return nil, err
	}

	items := make([]string, 0, len(resp.CommonPrefixes))
	for _, prefix := range resp.CommonPrefixes {
		items = append(items, *prefix.Prefix)
	}

	return items, nil
}

func listS3ObjectsAtPath(bucket string, delimiter string, prefix string) ([]string, error) {
	resp, err := s3ListObjects(bucket, delimiter, prefix)
	if err != nil {
		return nil, err
	}

	items := make([]string, 0, len(resp.Contents))

	for _, item := range resp.Contents {
		items = append(items, *item.Key)
	}

	return items, nil
}

func getEpisode(w http.ResponseWriter, r *http.Request) {
	vars := mux.Vars(r)
	show := vars["Show"]
	season := vars["Season"]
	episode := vars["Episode"]

	log.Printf("Getting episode %v of %v, %v", episode, show, season)
	sess, err := session.NewSession()
	if err != nil {
		log.Fatalf("Unable to create an AWS session: %v", err)
	}

	svc := s3.New(sess)

	req, _ := svc.GetObjectRequest(&s3.GetObjectInput{
		Bucket: aws.String(MEDIA_BUCKET_NAME),
		Key: aws.String(path.Join(show, season, episode)),
	})
	url, err := req.Presign(time.Hour * 1)
	if err != nil {
		log.Fatalf("unable to generate presigned URL: %v", err)
	}

	data := struct {
		Title   string
		Show    string
		Season  string
		Episode string
		Url     string
	}{
		Title:   "Episode",
		Show:    show,
		Season:  season,
		Episode: episode,
		Url:     url,
	}

	episodeTemplate.Execute(w, data)
}

func getEpisodes(w http.ResponseWriter, r *http.Request) {
	vars := mux.Vars(r)
	show := vars["Show"]
	season := vars["Season"]
	log.Printf("Getting a list of episodes for season %v of %v", season, show)

	prefix := path.Join(show, season) + "/"

	raw_episodes, err := listS3ObjectsAtPath(MEDIA_BUCKET_NAME, "/", prefix)
	if err != nil {
		log.Fatalf("failed to get episode list for show %v: %v", show, err)
	}

	episodes := make([]string, 0, len(raw_episodes))
	for _, item := range raw_episodes {
		parts := strings.Split(item, "/")
		name := parts[len(parts) - 1]
		if strings.HasPrefix(name, ".") {
			continue
		}
		episodes = append(episodes, name)
	}

	data := struct {
		Title    string
		Show     string
		Season   string
		Episodes []string
	}{
		Title:    "Shows of Episodes",
		Show:     vars["Show"],
		Season:   vars["Season"],
		Episodes: episodes,
	}
	episodesTemplate.Execute(w, data)
}

func getSeasons(w http.ResponseWriter, r *http.Request) {
	vars := mux.Vars(r)
	show := vars["Show"] + "/"
	log.Printf("Getting a list of seasons available for %v", show)

	raw_seasons, err := listS3CommonPrefixes(MEDIA_BUCKET_NAME, "/", show)
	if err != nil {
		log.Fatalf("failed to list seasons of show %v: %v", show, err)
	}
	seasons := make([]string, 0, len(raw_seasons))
	for _, prefix := range raw_seasons {
		parts := strings.Split(prefix, "/")
		seasons = append(seasons, parts[1])
	}

	data := struct {
		Title   string
		Show    string
		Seasons []string
	}{
		Title:   "Shows of Seasons",
		Show:    vars["Show"],
		Seasons: seasons,
	}
	seasonsTemplate.Execute(w, data)
}

func getShows(w http.ResponseWriter, r *http.Request) {
	log.Printf("Getting a list of shows available")

	rawShows, err := listS3CommonPrefixes(MEDIA_BUCKET_NAME, "/", "")
	if err != nil {
		log.Fatalf("failed to retrieve objects: %v", err)
	}

	shows := make([]string, 0, len(rawShows))
	for _, show := range rawShows {
		shows = append(shows, strings.TrimSuffix(show, "/"))
	}

	data := struct {
		Title string
		Shows []string
	}{
		Shows: shows,
	}
	showsTemplate.Execute(w, data)
}

func main() {
	// Pre-compile the HTML templates
	showsTemplate = template.Must(template.New("showList").Parse(showListTemplateHTML))
	seasonsTemplate = template.Must(template.New("seasons").Parse(seasonsTemplateHTML))
	episodesTemplate = template.Must(template.New("episodes").Parse(episodesTemplateHTML))
	episodeTemplate = template.Must(template.New("episode").Parse(episodeTemplateHTML))

	// Set up the HTTP endpoint handlers. Note that I am explicitly handling the cases where there is an end-slash.
	r := mux.NewRouter()
	r.HandleFunc("/", getShows)
	r.HandleFunc("/{Show}", getSeasons)
	r.HandleFunc("/{Show}/", getSeasons)
	r.HandleFunc("/{Show}/{Season}", getEpisodes)
	r.HandleFunc("/{Show}/{Season}/", getEpisodes)
	r.HandleFunc("/{Show}/{Season}/{Episode}", getEpisode)

	// Launch the application. All handlers will be called by the http library when a request is made on an
	// endpoint that we've selected.
	log.Fatal(http.ListenAndServe(":8080", r))
}