Building a Self Hosted Movie Ranking App
A while ago I started this project that I sporadically worked on throughout the course of a year. The original motivation was for me to do something hands on with go. As I went further along, I kept adding more features and used this project as an opportunity to learn new technologies. At this point I believe I enough to show, which motivated me to write this post along with outlining some of the challenges/learning curves I came across while developing this project.
Starting out with go
I saw Go being used widely for web apps and figured it would be a good idea to build something with it as a way of learning the language. At the same time, I wanted to learn more about building CRUD (Create Read Update Delete) APIs that interact with a database so I wanted my project to be a simple way of tracking data. I enjoy watching movies and often times think of recent films I’ve seen and how they rank against each other - so I chose to work on building a database that represented movies along with my ratings of them.
There were plenty of helpful tutorials online that helped me get started with a barebones API in Go. I used the gorilla mux library to handle serving different functions to their respective endpoints (although at the time of writing this I see that it is has been archived which may force me to use something else in the future):
func handleRequests() {
myRouter := mux.NewRouter().StrictSlash(true)
// myRouter.HandleFunc("/", homePage)
myRouter.HandleFunc("/api/v1/movies", controller.MoviesByRating).Methods("GET")
myRouter.HandleFunc("/api/v1/movies/{id}", controller.GetMovie).Methods("GET")
myRouter.HandleFunc("/api/v1/movies", controller.InsertMovie).Methods("POST", "OPTIONS")
myRouter.HandleFunc("/api/v1/movies", controller.UpdateMovie).Methods("POST")
myRouter.HandleFunc("/api/v1/movies/delete/{id}", controller.DeleteMovie).Methods("DELETE", "OPTIONS")
log.Fatal(http.ListenAndServe(":9876", myRouter))
}
I define each function in another file called controller.go
. For example, looking at the above code a request to the endpoint /api/v1/movies/
will call the function MoviesByRating
in my controller package that returns a list of movies in json format.
Connecting with the database
Many of the tutorials I followed were examples of creating and serving data as an array or struct in Go. This means the values are stored in memory and if my application crashes or restarts, all my data is gone. Designing my application to write and read from an external database ensures that data persists. I originally started with mysql as the database, but later switched to postgres. They’re both fairly similar as far as I’m concerned. My main focus will be on how they work as containers.
Even at the beginning I knew I would have to containerize everything in my project. I use docker-compose to self host all my services in my homelab and the end goal is for me to have built a tool I can self host. Therefore the ability to spin up my project with a single docker-compose up
command was mandatory.
Here’s the snippet from my docker-compose.yml
for the postgres container:
db:
image: postgres
restart: always
container_name: stubber-db
user: ${DB_UID}:${DB_GID}
volumes:
- ${PG_DATA}:/var/lib/postgresql/data
- ./data/database:/docker-entrypoint-initdb.d
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_USER: ${POSTGRES_USER}
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 1s
timeout: 5s
retries: 10
ports:
- 5432:5432
A few notable things I learned about containers while trying to get communication between my app container and db container are:
Networking inside a container is not the same as the host, i.e. “localhost” inside a container resolves to the container’s own localhost, not the host it is running on. This meant that when I tried to connect to the postgres container within my app container using
localhost
as the hostname the connection was refused. Docker however has a convenient way of letting containers use each other’s name to resolve themselves if they share a network. In other words, I usedstubber-db
(my postgres container’s name) as the hostname to connect to from within my app.My code assumes a table is already present in the database called
movies
to read and write to, but if the container is starting for the first time I need to manually go into the database and create the table. Alternatively, there’s a/docker-entrypoint-initdb.d/
directory that I can mount a .sql file to that creates the table for me. Any .sql files in that directory are automatically run when the container starts.I tried out other container tools like podman and colima. However, there are some permissions issues because they are designed to run rootless. I make use of the
user
mapping to have the container run as a specific user so they have access to my mounted volumes on the host machine.
Building an interface
Glossing over a lot of trial and error, I finally had a semi-reliable API that could do the bare minimum - get a list of movies, create a movie, and delete a movie. Here’s an example of the barebones data my API was originally returning:
{
"status": 200,
"message": "Success",
"Data": [
{
"Id": 1,
"Title": "The Batman",
"Rating": 10
}
]
}
As shown above, the reponse consists of an array of objects called “Data”. Each object has their own “Id”, “Title”, and “Rating” field + value respectively.
To do a conditional rendering of a React Component you can update the state and render accordingly - here is a code snippet of how I implemented it on the main page:
render() {
const view = this.state.view;
let movieBody;
switch(view) {
case "card":
movieBody = <Ranking/>
break;
case "table":
movieBody = <TableView/>
break;
default:
movieBody = <Ranking/>
break;
}
return(
<div className='App'>
<Stack spacing={5}>
<AppBar position="fixed">
<this.appBarLabel/>
</AppBar>
<Form/>
<Container className='App-container'>
{movieBody}
</Container>
</Stack>
</div>
)
}
The frontend went through multiple iterations when developing it. Originally it looked like the grid layout you get when selecting Table View
, only with pure html. I was just trying to play around with fetching and displaying data from my API, and I ended up making a sort of visual representation of my database.
To make it more visually appealing, I relied on the mui: Material UI library to give my frontend a more modern look. I also refrained from displaying all the data returned by my API. For instance, the id field is used as only a reference behind the scenes and doesn’t need to be visible to the user.
handleDelete(id, title){
// event.preventDefault();
const params = {
method: "DELETE"
}
const url = process.env.REACT_APP_API_ENDPOINT
+ "/api/v1/movies/delete/" + id;
let conf = window.confirm("really delete " + title + "?");
if(conf){
fetch(url, params).then((response)=> {
response.json();
window.location.reload(true);
}).catch((error) => {
console.log(error);
alert('Error: ' + error);
});
}
}
example of how the id field is used to delete a specific item
I also added some functionality, allowing the user to add and delete movies via forms. I ended up not changing much with the inserting of movies - it’s just a form for now where you fill out the movie title, rating, etc.
However for deleting movies, it was at first another form where the user enters the id of the movie they want to delete. To make it a little cleaner, I wrote a button that displays next to each movie in the table that you simply click to delete instead.
<IconButton
aria-label="delete"
onClick={() => this.handleDelete(item.Id, item.Title)}>
<DeleteIcon/>
</IconButton>
snippet of the button
After a while, I decided I wanted a better looking layout. Something that would go a long way towards that, in my opinion, was displaying the poster for each movie. Luckily, there’s the TMDB API that allows me to fetch movie metadata based on a title. I wrote some code in the backend to search and store the url for a movie’s poster image, as well as return it as part of a movie
object. Then, it was only a matter of writing logic in the frontend to fetch that url and display it as an image.
func GenerateMetadata(name string, movie_id int) int64 {
// use movie title to search with tmdb and create entries in metadata table
connect()
result, err := tmdbClient.GetSearchMovies(name, nil)
HandleError(err)
movie := result.SearchMoviesResults.Results[0]
id := movie.ID
release := movie.ReleaseDate
poster := movie.PosterPath
db := data.Connect()
defer db.Close()
_, db_err := db.Query("INSERT INTO metadata (id, movie_id, release_date, poster) VALUES($1, $2, $3, $4)", id, movie_id, release, poster)
HandleError(db_err)
return id
}
function call where I gather metadata
There’s a lot more data that can be grabbed from TMDB, but for now I’m only using their poster path and release date information.
<TableCell>
<img
src={"https://image.tmdb.org/t/p/original" + item.Poster}
alt={item.Poster}
/>
</TableCell>
referencing the image and putting it in a table cell
Then I had the idea to make each movie entry a card instead of rows in the table. The user could also click on an “info” button to see the details of each movie. This led me to writing a PosterCard
class that would function a lot like how I had written individual rows in the table view, only this time with different React components.
Polishing up the frontend had me experimenting with a lot of different components from the mui library. I’ve only gone over the high level stuff here to keep it concise. Maybe later I’ll come back to this section and flesh out more details as neccessary.
Here’s a few screenshots to give an idea of how my ui progressed:
using the old table layout
adding posters and a logo
using cards, titlebar, and much more
Deploying to the cloud
In my homelab I run all my apps as containers. It’s how I learned to do things when I started self hosting stuff, and I’ve grown to like using docker compose as a simple way of spinning up a group of containers with one docker-compose up
command.
For this reason, it felt right to make a docker-compose.yaml
in my repo so I could clone it to a virtual machine and have everything spin up smoothly like on my local machine - or so I thought. There turned out to be some complications that proved my assumptions going into this project wrong.
When I containerized my frontend, I unknowingly was writing a dockerfile for development builds. So when I tried to deploy and access my app using a hostname I got errors. Apparently, I needed to write a separate dockerfile for production builds because of how React worked. After searching through a lot of online guides, I found a solution that worked for me. I should note a lot of guides I looked at seemed to follow a standard approach of first building the React app and then using nginx to serve it.
FROM node:alpine AS builder
ENV NODE_ENV production
WORKDIR /app
COPY ./package.json ./
RUN npm install
COPY . .
RUN npm run build
RUN export $(cat .env | xargs)
FROM nginx
COPY --from=builder /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
the dockerfile for production
server {
listen 80;
location / {
root /usr/share/nginx/html/;
include /etc/nginx/mime.types;
try_files $uri $uri/ /index.html;
}
}
and the nginx config file
There were also some errors I got between the frontend and backend. They were related to CORS requests. The solution was to make sure my API returned appropriate headers in the response to avoid these errors. I didn’t need them before because during development everything was running locally on my machine, which led to a lot of confusion.
To create a demo for Stubber, I used Linode to create a VM to host it since it seemed like the easiest and cheapest option. I say demo because there’s no concept of user authentication at the moment so anyone can go and edit this instance. However, I wanted something to show off to friends and family. It was extremely rewarding for me to see a fullstack application I had written become publicly accessible on the internet. At the time of writing this, the app is still up here.
I don’t neccessarily consider this project “complete”, there’s still a lot of things I can think of that I can add to this. But at this point I think I have enough to write an entry about it here. I expect to continue coming back to this project to add features or improvements periodically and I’ll end this entry by listing some of the things I want to work on in the future.
https for the demo
creating the concept of “users” so different people create their own rankings on the same instance of Stubber
exporting and importing rankings via a csv file
automating deployment of the demo
and anything else I can think of :)