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 used stubber-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:

Image

using the old table layout

Image

adding posters and a logo

Image

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 :)