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.
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.
This is a simple one-line addition to the main()
function:
episodeTemplate = template.Must(template.New("episode").Parse(episodeTemplateHTML))
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.
Let’s put it all together and run it!
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.
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.
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;
}
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
}
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!
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))
}