+ baseline to the git

This commit is contained in:
Itsigo
2025-11-04 13:52:36 +01:00
commit b3193a10f9
29 changed files with 2079 additions and 0 deletions

51
.air.toml Normal file
View File

@@ -0,0 +1,51 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "make build"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = ["internal/database/db.go", "internal/database/models.go"]
exclude_regex = ["_test.go", ".*_templ.go", ".sql.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html", "templ", "css", "js", "sql"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = true
[screen]
clear_on_rebuild = false
keep_scroll = true
[proxy]
enabled = true
proxy_port = 8383
app_port = 3000

2
.env.sample Normal file
View File

@@ -0,0 +1,2 @@
PORT=3000
TMDB_API_KEY=YOUR_KEY

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
.env
data/
tmp/
main
pico.min.css
datastar.js
*_templ.go
db.go
models.go
*.sql.go
jersey15*

12
Makefile Normal file
View File

@@ -0,0 +1,12 @@
run:
@templ generate
@go run cmd/api/main.go
build:
@templ generate
@sqlc generate
@go build -o tmp/main cmd/api/main.go
setup:
@curl -o cmd/web/assets/js/datastar.js https://cdn.jsdelivr.net/gh/starfederation/datastar@main/bundles/datastar.js
@curl -o cmd/web/assets/css/fonts/jersey15.woff2 https://fonts.gstatic.com/s/jersey15/v3/_6_9EDzuROGsUuk2TWjiZYAg.woff2

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# PocketMovie
A small database to keep track of the movies you have seen or want to see.

36
cmd/api/main.go Normal file
View File

@@ -0,0 +1,36 @@
package main
import (
"fmt"
"os"
"strconv"
"git.itsigo.dev/istigo/pocketmovie/internal/server"
"github.com/gofiber/fiber/v3"
_ "github.com/joho/godotenv/autoload"
)
func initial() {
if _, err := os.Stat("./data"); os.IsNotExist(err) {
os.Mkdir("./data/", 0755)
fmt.Println("Data created")
os.Mkdir("./data/img/", 0755)
fmt.Println("Img created")
}
}
func main() {
initial()
app := server.New()
app.RegisterFiberRoutes()
port, _ := strconv.Atoi(os.Getenv("PORT"))
err := app.Listen(fmt.Sprintf(":%d", port), fiber.ListenConfig{
DisableStartupMessage: true,
})
if err != nil {
panic(fmt.Sprintf("http server error: %s", err))
}
}

39
cmd/web/apiinput.templ Normal file
View File

@@ -0,0 +1,39 @@
package web
templ Apikeyinput(apierror string) {
@Html("Please input your TMDB Api key - PocketMovie") {
<div class="flex-center" style="height:100vh">
<div style="width: 1000px">
<h1>API Read Access Token needed</h1>
<p>To use this application you need to enter your TMDB API Read Access Token. If you do not have a Token you can get one by creating a TMDB account.</p>
<p>It is needed to search movies, pull metadata and stream providers.</p>
<form method="POST">
<fieldset
if apierror != "" {
aria-invalid="true"
aria-describedby="invalid-helper"
}
role="group"
>
<input
type="password"
name="apikey"
placeholder="API Read Access Token"
required
if apierror != "" {
aria-invalid="true"
}
/>
<input
type="submit"
value="Submit"
/>
</fieldset>
if apierror != "" {
<small id="invalid-helper">{ apierror }</small>
}
</form>
</div>
</div>
}
}

View File

@@ -0,0 +1,80 @@
:root {
--ctp-macchiato-rosewater: #f4dbd6;
--ctp-macchiato-rosewater-rgb: 244 219 214;
--ctp-macchiato-rosewater-hsl: 10.000 57.692% 89.804%;
--ctp-macchiato-flamingo: #f0c6c6;
--ctp-macchiato-flamingo-rgb: 240 198 198;
--ctp-macchiato-flamingo-hsl: 0.000 58.333% 85.882%;
--ctp-macchiato-pink: #f5bde6;
--ctp-macchiato-pink-rgb: 245 189 230;
--ctp-macchiato-pink-hsl: 316.071 73.684% 85.098%;
--ctp-macchiato-mauve: #c6a0f6;
--ctp-macchiato-mauve-rgb: 198 160 246;
--ctp-macchiato-mauve-hsl: 266.512 82.692% 79.608%;
--ctp-macchiato-red: #ed8796;
--ctp-macchiato-red-rgb: 237 135 150;
--ctp-macchiato-red-hsl: 351.176 73.913% 72.941%;
--ctp-macchiato-maroon: #ee99a0;
--ctp-macchiato-maroon-rgb: 238 153 160;
--ctp-macchiato-maroon-hsl: 355.059 71.429% 76.667%;
--ctp-macchiato-peach: #f5a97f;
--ctp-macchiato-peach-rgb: 245 169 127;
--ctp-macchiato-peach-hsl: 21.356 85.507% 72.941%;
--ctp-macchiato-yellow: #eed49f;
--ctp-macchiato-yellow-rgb: 238 212 159;
--ctp-macchiato-yellow-hsl: 40.253 69.912% 77.843%;
--ctp-macchiato-green: #a6da95;
--ctp-macchiato-green-rgb: 166 218 149;
--ctp-macchiato-green-hsl: 105.217 48.252% 71.961%;
--ctp-macchiato-teal: #8bd5ca;
--ctp-macchiato-teal-rgb: 139 213 202;
--ctp-macchiato-teal-hsl: 171.081 46.835% 69.020%;
--ctp-macchiato-sky: #91d7e3;
--ctp-macchiato-sky-rgb: 145 215 227;
--ctp-macchiato-sky-hsl: 188.780 59.420% 72.941%;
--ctp-macchiato-sapphire: #7dc4e4;
--ctp-macchiato-sapphire-rgb: 125 196 228;
--ctp-macchiato-sapphire-hsl: 198.641 65.605% 69.216%;
--ctp-macchiato-blue: #8aadf4;
--ctp-macchiato-blue-rgb: 138 173 244;
--ctp-macchiato-blue-hsl: 220.189 82.813% 74.902%;
--ctp-macchiato-lavender: #b7bdf8;
--ctp-macchiato-lavender-rgb: 183 189 248;
--ctp-macchiato-lavender-hsl: 234.462 82.278% 84.510%;
--ctp-macchiato-text: #cad3f5;
--ctp-macchiato-text-rgb: 202 211 245;
--ctp-macchiato-text-hsl: 227.442 68.254% 87.647%;
--ctp-macchiato-subtext1: #b8c0e0;
--ctp-macchiato-subtext1-rgb: 184 192 224;
--ctp-macchiato-subtext1-hsl: 228.000 39.216% 80.000%;
--ctp-macchiato-subtext0: #a5adcb;
--ctp-macchiato-subtext0-rgb: 165 173 203;
--ctp-macchiato-subtext0-hsl: 227.368 26.761% 72.157%;
--ctp-macchiato-overlay2: #939ab7;
--ctp-macchiato-overlay2-rgb: 147 154 183;
--ctp-macchiato-overlay2-hsl: 228.333 20.000% 64.706%;
--ctp-macchiato-overlay1: #8087a2;
--ctp-macchiato-overlay1-rgb: 128 135 162;
--ctp-macchiato-overlay1-hsl: 227.647 15.455% 56.863%;
--ctp-macchiato-overlay0: #6e738d;
--ctp-macchiato-overlay0-rgb: 110 115 141;
--ctp-macchiato-overlay0-hsl: 230.323 12.351% 49.216%;
--ctp-macchiato-surface2: #5b6078;
--ctp-macchiato-surface2-rgb: 91 96 120;
--ctp-macchiato-surface2-hsl: 229.655 13.744% 41.373%;
--ctp-macchiato-surface1: #494d64;
--ctp-macchiato-surface1-rgb: 73 77 100;
--ctp-macchiato-surface1-hsl: 231.111 15.607% 33.922%;
--ctp-macchiato-surface0: #363a4f;
--ctp-macchiato-surface0-rgb: 54 58 79;
--ctp-macchiato-surface0-hsl: 230.400 18.797% 26.078%;
--ctp-macchiato-base: #24273a;
--ctp-macchiato-base-rgb: 36 39 58;
--ctp-macchiato-base-hsl: 231.818 23.404% 18.431%;
--ctp-macchiato-mantle: #1e2030;
--ctp-macchiato-mantle-rgb: 30 32 48;
--ctp-macchiato-mantle-hsl: 233.333 23.077% 15.294%;
--ctp-macchiato-crust: #181926;
--ctp-macchiato-crust-rgb: 24 25 38;
--ctp-macchiato-crust-hsl: 235.714 22.581% 12.157%;
}

View File

@@ -0,0 +1,10 @@
/* jersey-15-regular - latin */
@font-face {
font-display: swap;
/* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Jersey 15';
font-style: normal;
font-weight: 400;
src: url('./fonts/jersey15.woff2') format('woff2');
/* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}

182
cmd/web/assets/css/main.css Normal file
View File

@@ -0,0 +1,182 @@
@import "fonts.css";
@import "catppuccin.css";
:root {
--pico-form-element-spacing-vertical: 0.5rem;
--pico-form-element-spacing-horizontal: 0.75rem;
--pico-color: var(--ctp-macchiato-text);
--pico-h1-color: var(--ctp-macchiato-text);
--pico-primary-inverse: var(--ctp-macchiato-base);
--pico-primary: var(--ctp-macchiato-blue);
--pico-primary-underline: var(--ctp-macchiato-blue);
--pico-primary-background: var(--ctp-macchiato-blue);
--pico-primary-hover: var(--ctp-macchiato-mauve);
--pico-primary-hover-background: var(--ctp-macchiato-mauve);
--pico-background-color: var(--ctp-macchiato-crust);
}
.bg-red{
color: hsl(351deg, 74%, 40%);
background-color: var(--ctp-macchiato-red) !important;
}
.bg-green{
color: hsl(105deg, 48%, 40%);
background-color: var(--ctp-macchiato-green) !important;
}
.red-link{
color: var(--ctp-macchiato-red);
text-decoration-color: var(--ctp-macchiato-red);
}
h1 {
font-family: "Jersey 15";
}
#search-dialog {
& article {
max-height: 700px;
}
& tr {
cursor: pointer;
& :hover {
background-color: var(--pico-hover-background);
}
}
}
.hidden {
display: none;
}
.center {
text-align: center;
}
.flex-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.m-0 {
margin: 0;
}
.grid-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: 1fr;
grid-column-gap: 1rem;
grid-row-gap: 0px;
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: 1fr;
grid-column-gap: 1rem;
grid-row-gap: 0px;
}
.movie-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-template-rows: 1fr;
grid-column-gap: 1.5rem;
grid-row-gap: 1.5rem;
& .movie-tile {
& div {
position: absolute;
top: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.7);
padding: 0.25rem;
border-bottom-right-radius: var(--pico-border-radius);
border-top-left-radius: var(--pico-border-radius);
& svg {
width: 2rem;
}
}
& img {
border-radius: var(--pico-border-radius);
}
}
}
.watched {
text-align: center;
cursor: pointer;
& svg {
width: 3rem;
color: var(--pico-primary);
}
}
.ratingBox {
text-align: center;
i {
background-color: var(--pico-secondary);
cursor: pointer;
}
.rated {
background-color: var(--pico-primary);
}
>i {
&:hover .icon,
&:hover~.icon {
background-color: var(--pico-secondary) !important;
}
}
&:hover>.icon {
background-color: var(--pico-primary);
}
}
.listRating {
i {
width: 1.25rem;
height: 1.25rem;
background-color: var(--pico-secondary);
}
.rated {
background-color: var(--pico-primary);
}
}
.icon {
display: inline-block;
font-size: var(--pico-font-size);
width: 2rem;
height: 2rem;
&.star-full {
mask: url('data:image/svg+xml,<svg viewBox="0 0 24 24" fill="%231C274C" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path d="M9.15316 5.40838C10.4198 3.13613 11.0531 2 12 2C12.9469 2 13.5802 3.13612 14.8468 5.40837L15.1745 5.99623C15.5345 6.64193 15.7144 6.96479 15.9951 7.17781C16.2757 7.39083 16.6251 7.4699 17.3241 7.62805L17.9605 7.77203C20.4201 8.32856 21.65 8.60682 21.9426 9.54773C22.2352 10.4886 21.3968 11.4691 19.7199 13.4299L19.2861 13.9372C18.8096 14.4944 18.5713 14.773 18.4641 15.1177C18.357 15.4624 18.393 15.8341 18.465 16.5776L18.5306 17.2544C18.7841 19.8706 18.9109 21.1787 18.1449 21.7602C17.3788 22.3417 16.2273 21.8115 13.9243 20.7512L13.3285 20.4768C12.6741 20.1755 12.3469 20.0248 12 20.0248C11.6531 20.0248 11.3259 20.1755 10.6715 20.4768L10.0757 20.7512C7.77268 21.8115 6.62118 22.3417 5.85515 21.7602C5.08912 21.1787 5.21588 19.8706 5.4694 17.2544L5.53498 16.5776C5.60703 15.8341 5.64305 15.4624 5.53586 15.1177C5.42868 14.773 5.19043 14.4944 4.71392 13.9372L4.2801 13.4299C2.60325 11.4691 1.76482 10.4886 2.05742 9.54773C2.35002 8.60682 3.57986 8.32856 6.03954 7.77203L6.67589 7.62805C7.37485 7.4699 7.72433 7.39083 8.00494 7.17781C8.28555 6.96479 8.46553 6.64194 8.82547 5.99623L9.15316 5.40838Z" ></path></g></svg>');
}
}

View File

@@ -0,0 +1,60 @@
/*
* Modal
*
* Pico.css - https://picocss.com
* Copyright 2019-2024 - Licensed under MIT
*/
// Config
const isOpenClass = "modal-is-open";
const openingClass = "modal-is-opening";
const closingClass = "modal-is-closing";
const scrollbarWidthCssVar = "--pico-scrollbar-width";
const animationDuration = 400; // ms
let visibleModal = null;
// Toggle modal
const toggleModal = (event) => {
event.preventDefault();
const modal = document.getElementById(event.currentTarget.dataset.target);
if (!modal) return;
modal && (modal.open ? closeModal(modal) : openModal(modal));
};
// Open modal
const openModal = (modal) => {
const { documentElement: html } = document;
const scrollbarWidth = getScrollbarWidth();
if (scrollbarWidth) {
html.style.setProperty(scrollbarWidthCssVar, `${scrollbarWidth}px`);
}
html.classList.add(isOpenClass, openingClass);
setTimeout(() => {
visibleModal = modal;
html.classList.remove(openingClass);
}, animationDuration);
modal.showModal();
};
// Close modal
const closeModal = (modal) => {
visibleModal = null;
const { documentElement: html } = document;
html.classList.add(closingClass);
setTimeout(() => {
html.classList.remove(closingClass, isOpenClass);
html.style.removeProperty(scrollbarWidthCssVar);
modal.close();
}, animationDuration);
};
// Get scrollbar width
const getScrollbarWidth = () => {
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
return scrollbarWidth;
};
// Is scrollbar visible
const isScrollbarVisible = () => {
return document.body.scrollHeight > screen.height;
};

156
cmd/web/base.templ Normal file
View File

@@ -0,0 +1,156 @@
package web
import "strings"
import "git.itsigo.dev/istigo/pocketmovie/internal/apis"
import "fmt"
templ Html(title string) {
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎬</text></svg>"/>
<link rel="stylesheet" href="/assets/css/pico.min.css"/>
<link rel="stylesheet" href="/assets/css/main.css"/>
<script type="module" src="/assets/js/datastar.js"></script>
<script src="/assets/js/modal.js"></script>
<title>{ title }</title>
</head>
<body>
{ children... }
</body>
</html>
}
templ Base(title string) {
@Html(title) {
<header>
@header()
</header>
<main class="container">
{ children... }
</main>
// <footer>
// @footer()
// </footer>
<dialog
id="search-dialog"
data-signals="{id: ''}"
data-on-keydown__window="
if(evt.key !== 'Escape' || visibleModal === null) return;
closeModal(visibleModal);
$id = '';
"
>
<article
data-on-click__outside="
if (visibleModal === null) return;
closeModal(visibleModal);
$id = '';
"
>
<header>
<button
aria-label="Close"
rel="prev"
data-target="search-dialog"
data-on-click="$id = ''; toggleModal(event); "
></button>
<p>
<strong>🎬 Add Movie</strong>
</p>
</header>
<div data-show="$id == ''" id="search-container">
<form data-on-submit="@post('/apis/tmdb/searchMovie', {contentType: 'form'})" role="search">
<input type="search" id="search" name="search" placeholder="Search"/>
<input type="submit" value="Search"/>
</form>
<table class="striped">
<tbody id="search-results"></tbody>
</table>
</div>
<div data-show="$id != ''" id="add-options">
@AddMovieConfig()
</div>
</article>
</dialog>
}
}
templ header() {
<nav class="container">
<ul>
<li><strong style="font-family: 'Jersey 15'; font-size: 2rem">Pocket Movie</strong></li>
</ul>
<ul>
<li><a href="/">Movies</a></li>
<li><a href="/watchlist">Watchlist</a></li>
<li><a href="/settings">Settings</a></li>
</ul>
</nav>
}
templ SearchMovie(movies []apis.Movie) {
<tbody id="search-results">
for _, v := range movies {
<tr>
<td
class="add-movie"
data-on-click={ fmt.Sprintf("$id = '%d'", v.Id) }
>
{ v.Title }
if v.ReleaseDate != "" {
({ strings.Split(v.ReleaseDate, "-")[0] })
}
</td>
</tr>
}
</tbody>
}
templ AddMovieConfig() {
<form
id="addMovieForm"
class="add-movie"
data-on-submit="@post('/db/addMovie/' + $id, {contentType: 'form'}); closeModal(visibleModal)"
>
<fieldset>
<legend>
General
</legend>
<input type="checkbox" name="watched" id="watched"/>
<label style="padding-bottom: 0.5rem" htmlFor="watched">Watched</label>
<div class="grid">
<input type="number" name="watchcount" placeholder="Watchcount" aria-label="watchcount"/>
<input type="number" name="rating" placeholder="Rating" aria-label="rating"/>
</div>
</fieldset>
<fieldset>
<legend>
Physical data
</legend>
<input type="checkbox" name="owned" id="owned"/>
<label>Owned</label>
<input type="checkbox" name="ripped" id="ripped"/>
<label>Ripped</label>
</fieldset>
<select name="version" id="version" aria-label="Select physical version..">
@MediaSelectOptions()
</select>
<input id="submitMovieButton" type="submit" value="Add Movie"/>
</form>
}
templ addMovieButton() {
<button id="show-modal-dialog" data-target="search-dialog" onCLick="toggleModal(event);" type="button">Add Movie</button>
}
templ MediaSelectOptions() {
<option selected disabled>Select..</option>
<option value="">None</option>
<option value="DVD">DVD</option>
<option value="BD">BD</option>
<option value="4KBD">4KBD</option>
<option value="DL">DL</option>
}

6
cmd/web/efs.go Normal file
View File

@@ -0,0 +1,6 @@
package web
import "embed"
//go:embed assets/*
var Files embed.FS

138
cmd/web/home.templ Normal file
View File

@@ -0,0 +1,138 @@
package web
import "git.itsigo.dev/istigo/pocketmovie/internal/database"
import "strings"
import "fmt"
func getConfVal(settings []database.Setting, name string) string {
for _, s := range settings {
if s.Name == name {
return s.Value
}
}
return ""
}
templ Show(movies []database.Movie, settings []database.Setting) {
@Base("Movies - PocketMovie") {
//<pre id="data"></pre>
//<script>
// var data = JSON.parse({{ data }});
// var formated_data = JSON.stringify(data, null, 2);
// document.getElementById("data").textContent = formated_data;
//</script>
@addMovieButton()
{{ list, grid := "1", "" }}
if getConfVal(settings, "HOME_GRID_VIEW" ) == "true" {
{{ list, grid = "", "1" }}
}
<div
data-signals={ fmt.Sprintf("{list : '%s', grid : '%s'}", list, grid) }
style="float:right"
>
<button
data-show="$list != ''"
data-on-click="
@post('/db/updateTableView')
$list = '';
$grid = '1'
"
>List &#9776;</button>
<button
data-show="$grid != ''"
data-on-click="
@post('/db/updateTableView')
$list = '1';
$grid = ''
"
>Grid 田</button>
</div>
if grid != "" {
@MovieTiles(movies)
} else {
@MovieList(movies)
}
}
}
templ MovieList(movies []database.Movie) {
<table id="movie-list" class="striped">
<tr>
<th>Titel</th>
<th>Year</th>
<th>Rating</th>
<th>Watched</th>
<th>Owned</th>
<th></th>
</tr>
for _, item := range movies {
<tr>
<td><a href={ fmt.Sprintf("/movie/%d", item.ID) }>{ item.Title }</a></td>
<td>{ strings.Split(item.Year, "-")[0] }</td>
<td>
<div class="listRating">
{{ class := "icon star-full" }}
for i := range(5) {
<i
if int64(i) < item.Rating {
class={ class + " rated" }
} else {
class={ class }
}
></i>
}
</div>
</td>
{{ statusClasses := "bg-red" }}
if item.Status == 1 {
{{ statusClasses = "bg-green" }}
}
<td
class={ statusClasses }
>
{ item.Status != 0 }
</td>
{{ ownedClasses := "bg-red" }}
if item.Owned == 1 {
{{ ownedClasses = "bg-green" }}
}
<td
class={ ownedClasses }
>
{ item.Owned != 0 }
</td>
<td>
<a
class="red-link"
href="#"
data-on-click={ fmt.Sprintf("confirm('Are you sure?') && @delete('/db/deleteMovie/%d')", item.ID) }
>Delete</a>
</td>
</tr>
}
</table>
}
templ MovieTiles(movies []database.Movie) {
<div id="movie-list" class="movie-grid">
for _, item := range movies {
<a
class="movie-tile"
data-tooltip={ item.Title }
data-placement="bottom"
href={ fmt.Sprintf("/movie/%d", item.ID) }
title={ item.Title }
style="position:relative"
>
<div>
if item.Status == 1 {
@fullEye()
} else {
@closedEye()
}
</div>
<img alt={ item.Title } src={ fmt.Sprintf("/movie/posters/%d.jpg", item.ID) }/>
</a>
}
</div>
}

200
cmd/web/moviedetails.templ Normal file

File diff suppressed because one or more lines are too long

95
cmd/web/settings.templ Normal file
View File

@@ -0,0 +1,95 @@
package web
import "fmt"
import "git.itsigo.dev/istigo/pocketmovie/internal/database"
import "git.itsigo.dev/istigo/pocketmovie/internal/apis"
type SettingsConfig struct {
Providers []database.StreamingService
AvailableProviders []string
Regions []apis.Region
SelectedRegion string
APIKey string
}
templ Settings(settings SettingsConfig) {
@Base("Settings - PocketMovie") {
<h1>Settings</h1>
<div class="grid">
<div>
<h3>Stream Region</h3>
@Region(settings)
<h3>Stream Providers</h3>
@ProviderTable(settings)
</div>
<div>
<h3>API Token</h3>
@ApiKey(settings)
</div>
</div>
}
}
templ ApiKey(settings SettingsConfig) {
<form id="apikeyinput">
<fieldset role="group">
<input type="text" value={ settings.APIKey } name="apikey" placeholder="TMDB Read Access Token"/>
<input type="submit" value="Change" data-on-click="@post('/db/updateApiKey', {contentType: 'form'})"/>
</fieldset>
</form>
}
templ ProviderTable(settings SettingsConfig) {
<div id="providers">
<table class="striped">
<tr>
<th>Name</th>
<th></th>
</tr>
for _, p := range(settings.Providers) {
<tr>
<td>{ p.Title }</td>
<td class="center">
<a
class="red-link"
href="#"
data-on-click={ fmt.Sprintf("confirm('Are you sure?') && @delete('/db/deleteStreamingService/%d')", p.ID) }
>Delete</a>
</td>
</tr>
}
</table>
<form>
<fieldset role="group">
<select name="service" aria-label="Services" required>
<option selected disabled value="">Select</option>
for _, p := range(settings.AvailableProviders) {
<option>{ p }</option>
}
</select>
<input type="submit" value="Add" data-on-click="@post('/db/addStreamingService', {contentType: 'form'})"/>
</fieldset>
</form>
</div>
}
templ Region(settings SettingsConfig) {
<form id="regionselector">
<fieldset role="group">
<select
data-on-change={ fmt.Sprintf("@post('/db/updateRegion', {contentType: 'form'})") }
name="region"
aria-label="Regions"
>
for _, r := range(settings.Regions) {
<option
if settings.SelectedRegion == r.Iso {
selected
}
value={ r.Iso }
>{ r.EnglishName }</option>
}
</select>
</fieldset>
</form>
}

52
cmd/web/watchlist.templ Normal file
View File

@@ -0,0 +1,52 @@
package web
import "git.itsigo.dev/istigo/pocketmovie/internal/database"
import "fmt"
import "strings"
templ WatchList(movies []database.Movie) {
@Base("Watchlist - PocketMovie") {
@addMovieButton()
<button
data-on-click="@post('/db/updateStreamingServices')"
data-tooltip="Refresh streaming services"
type="button"
>
@refresh()
</button>
@List(movies)
}
}
templ List(movies []database.Movie) {
<table id="movie-list" class="striped">
<tr>
<th>Titel</th>
<th>Year</th>
<th>Genre</th>
<th>Owned</th>
<th>Streaming service</th>
</tr>
for _, item := range movies {
<tr>
<td><a href={ fmt.Sprintf("/movie/%d", item.ID) }>{ item.Title }</a></td>
<td>{ strings.Split(item.Year, "-")[0] }</td>
<td>{ item.Genre }</td>
{{ ownedClasses := "bg-red" }}
if item.Owned == 1 {
{{ ownedClasses = "bg-green" }}
}
<td
class={ ownedClasses }
>
{ item.Owned != 0 }
</td>
<td>{ item.StreamingServices }</td>
</tr>
}
</table>
}
templ refresh() {
<svg width="1em" height="1em" viewBox="0 0 24 24" stroke="var(--pico-primary-inverse)" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path d="M12 21C16.9706 21 21 16.9706 21 12C21 9.69494 20.1334 7.59227 18.7083 6L16 3M12 3C7.02944 3 3 7.02944 3 12C3 14.3051 3.86656 16.4077 5.29168 18L8 21M21 3H16M16 3V8M3 21H8M8 21V16" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path> </g></svg>
}

28
go.mod Normal file
View File

@@ -0,0 +1,28 @@
module git.itsigo.dev/istigo/pocketmovie
go 1.25.0
require (
github.com/a-h/templ v0.3.943
github.com/gofiber/fiber/v3 v3.0.0-rc.1
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.32
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/gofiber/schema v1.6.0 // indirect
github.com/gofiber/utils/v2 v2.0.0-rc.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/tinylib/msgp v1.4.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.65.0 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
)

57
go.sum Normal file
View File

@@ -0,0 +1,57 @@
github.com/a-h/templ v0.3.943 h1:o+mT/4yqhZ33F3ootBiHwaY4HM5EVaOJfIshvd5UNTY=
github.com/a-h/templ v0.3.943/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gofiber/fiber/v3 v3.0.0-rc.1 h1:034MxesK6bqGkidP+QR+Ysc1ukOacBWOHCarCKC1xfg=
github.com/gofiber/fiber/v3 v3.0.0-rc.1/go.mod h1:hFdT00oT0XVuQH1/z2i5n1pl/msExHDUie1SsLOkCuM=
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
github.com/gofiber/utils/v2 v2.0.0-rc.1 h1:b77K5Rk9+Pjdxz4HlwEBnS7u5nikhx7armQB8xPds4s=
github.com/gofiber/utils/v2 v2.0.0-rc.1/go.mod h1:Y1g08g7gvST49bbjHJ1AVqcsmg93912R/tbKWhn6V3E=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/shamaton/msgpack/v2 v2.3.0 h1:eawIa7lQmwRv0V6rdmL/5Ev9KdJHk07eQH3ceJi3BUw=
github.com/shamaton/msgpack/v2 v2.3.0/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8=
github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8=
github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

178
internal/apis/tmdb.go Normal file
View File

@@ -0,0 +1,178 @@
package apis
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
_ "github.com/joho/godotenv/autoload"
)
var (
token = os.Getenv("TMDB_API_KEY")
apiurl = "https://api.themoviedb.org/3/"
)
func request(requrl string) (*http.Response, []byte) {
req, _ := http.NewRequest("GET", apiurl+requrl, nil)
req.Header.Add("authorization", "Bearer "+token)
req.Header.Add("accept", "application/json")
res, _ := http.DefaultClient.Do(req)
body, _ := io.ReadAll(res.Body)
res.Body.Close()
return res, body
}
func buildQuery(opts map[string]string) string {
query := url.Values{}
for k, v := range opts {
query.Add(k, v)
}
return query.Encode()
}
type Response struct {
Results []Movie `json:"results"`
}
type Movie struct {
Id int `json:"id"`
Title string `json:"title"`
OriginalTitle string `json:"original_title"`
PosterPath string `json:"poster_path"`
ReleaseDate string `json:"release_date"`
ImdbID string `json:"imdb_id"`
Lenght int `json:"runtime"`
Genres []Genre `json:"genres"`
Overview string `json:"overview"`
Director string
}
type Genre struct {
Name string `json:"name"`
}
type Crew struct {
Person []Person `json:"crew"`
}
type Person struct {
Name string `json:"name"`
Department string `json:"job"`
}
type WatchProviders struct {
ID int `json:"id"`
Results map[string]Country `json:"results"`
}
type Country struct {
Link string `json:"link"`
Flatrate []Provider `json:"flatrate,omitempty"`
Free []Provider `json:"free,omitempty"`
Rent []Provider `json:"rent,omitempty"`
Buy []Provider `json:"buy,omitempty"`
}
type Provider struct {
LogoPath string `json:"logo_path"`
ProviderID int `json:"provider_id"`
ProviderName string `json:"provider_name"`
DisplayPriority int `json:"display_priority"`
}
func SearchTmdbMovie(search string) []Movie {
opts := map[string]string{"query": search, "page": "1"}
url := fmt.Sprintf("search/movie?%s", buildQuery(opts))
_, body := request(url)
var responseObjekt Response
json.Unmarshal(body, &responseObjekt)
return responseObjekt.Results
}
func getMovieDirector(id string) string {
url := fmt.Sprintf("movie/%s/credits", id)
_, body := request(url)
var crewObjekt Crew
json.Unmarshal(body, &crewObjekt)
for _, v := range crewObjekt.Person {
if v.Department == "Director" {
return v.Name
}
}
return ""
}
func GetMovieStreamingServices(id int, lang string) Country {
url := fmt.Sprintf("movie/%d/watch/providers", id)
_, body := request(url)
var responseObjekt WatchProviders
json.Unmarshal(body, &responseObjekt)
// Returning the available streaming services
return responseObjekt.Results[lang]
}
func GetTmdbMovie(id string) Movie {
url := fmt.Sprintf("movie/%s", id)
_, body := request(url)
getMovieDirector(id)
var responseObjekt Movie
json.Unmarshal(body, &responseObjekt)
responseObjekt.Director = getMovieDirector(id)
return responseObjekt
}
type WatchResults struct {
Results []Provider `json:"results,omitempty"`
}
func GetMovieProviders(region string) []Provider {
opts := map[string]string{"language": "en_US", "watch_region": region}
url := fmt.Sprintf("watch/providers/movie?%s", buildQuery(opts))
_, body := request(url)
var responseObjekt WatchResults
json.Unmarshal(body, &responseObjekt)
return responseObjekt.Results
}
type RegionResults struct {
Results []Region `json:"results"`
}
type Region struct {
Iso string `json:"iso_3166_1"`
EnglishName string `json:"english_name"`
NativeName string `json:"native_name"`
}
func GetAvailableRegions() []Region {
url := "watch/providers/regions"
_, body := request(url)
var responseObjekt RegionResults
json.Unmarshal(body, &responseObjekt)
return responseObjekt.Results
}

View File

@@ -0,0 +1,32 @@
package database
import (
"context"
"database/sql"
_ "embed"
"log"
_ "github.com/mattn/go-sqlite3"
)
var (
dburl = "./data/data.db"
)
//go:embed schema.sql
var ddl string
func Init() *Queries {
ctx := context.Background()
db, err := sql.Open("sqlite3", dburl)
if err != nil {
log.Fatal(err)
}
if _, err := db.ExecContext(ctx, ddl); err != nil {
log.Fatal(err)
}
return New(db)
}

View File

@@ -0,0 +1,73 @@
-- name: ListMovies :many
SELECT * FROM movie
ORDER BY title ASC;
-- name: ListMovie :one
SELECT * FROM movie
WHERE id = ?;
-- name: ListWatchlist :many
SELECT * FROM movie
WHERE status = 0
ORDER BY
CASE WHEN streaming_services = '' OR streaming_services IS NULL THEN 1 ELSE 0 END,
title;
-- name: ListSettings :many
SELECT * FROM settings;
-- name: ListSetting :one
SELECT * FROM settings
WHERE id = ?;
-- name: ListSreamingServices :many
SELECT * FROM streaming_services;
-- name: CreateMovie :one
INSERT INTO movie (
title, original_title, imdbid, tmdbid, length, genre, streaming_services, director, year, watchcount, rating, status, owned, owned_type, ripped, review, overview
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
RETURNING *;
-- name: AddSreamingService :exec
INSERT INTO streaming_services (title) VALUES ( ? );
-- name: DeleteSreamingService :exec
DELETE FROM streaming_services
WHERE id = ?;
-- name: DeleteMovie :exec
DELETE FROM movie
WHERE id = ?;
-- name: ChangeMovieStatus :exec
UPDATE movie
SET status = ?
WHERE id = ?;
-- name: ChangeMovieRating :exec
UPDATE movie
SET rating = ?
WHERE id = ?;
-- name: ChangeMovieOwned :exec
UPDATE movie
SET owned = ?, owned_type = ?
WHERE id = ?;
-- name: ChangeMovieStreamingServices :exec
UPDATE movie
SET streaming_services = ?
WHERE id = ?;
-- name: ChangeMovieRipped :exec
UPDATE movie
SET ripped = ?
WHERE id = ?;
-- name: ChangeSettingValue :exec
UPDATE settings
SET value = ?
WHERE id = ?;

View File

@@ -0,0 +1,51 @@
CREATE TABLE IF NOT EXISTS movie (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title VARCHAR(255) NOT NULL,
original_title VARCHAR(255) NOT NULL,
imdbid VARCHAR(25) NOT NULL,
tmdbid INTEGER NOT NULL,
length INTEGER NOT NULL,
genre VARCHAR(255) NOT NULL,
streaming_services VARCHAR(255) NOT NULL,
director VARCHAR(255) NOT NULL,
year VARCHAR(10) NOT NULL,
watchcount INTEGER NOT NULL,
rating INTEGER NOT NULL,
status INTEGER NOT NULL,
owned INTEGER NOT NULL,
owned_type VARCHAR(255) NOT NULL,
ripped INTEGER NOT NULL,
review TEXT NOT NULL,
overview TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS genres (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title VARCHAR(30) NOT NULL
);
CREATE TABLE IF NOT EXISTS streaming_services (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title VARCHAR(30) NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
value VARCHAR(100) NOT NULL,
description VARCHAR(255) NOT NULL
);
INSERT OR IGNORE INTO streaming_services
VALUES
("1", "Netflix"),
("2", "Disney Plus"),
("3", "Amazon Prime Video"),
("4", "Apple TV+");
INSERT OR IGNORE INTO settings
VALUES
("1", "HOME_GRID_VIEW", "false", "Grid or no grid on the Homepage"),
("2", "TMDB_API_KEY", "", "Your TMDB api key"),
("3", "REGION", "DE", "Your Region");

View File

@@ -0,0 +1,28 @@
package apikeychecker
import (
"strings"
"git.itsigo.dev/istigo/pocketmovie/internal/database"
"github.com/gofiber/fiber/v3"
)
type Config struct {
DB database.Queries
}
func configDefault(config ...Config) Config {
cfg := config[0]
return cfg
}
func New(config Config) fiber.Handler {
return func(c fiber.Ctx) error {
setting, _ := config.DB.ListSetting(c, 2)
referer := string(c.Request().Header.Referer())
if setting.Value == "" && !strings.Contains(referer, "apikey") {
return c.Redirect().Status(fiber.StatusMovedPermanently).To("/apikey")
}
return c.Next()
}
}

40
internal/server/index.go Normal file
View File

@@ -0,0 +1,40 @@
package server
import (
"slices"
"git.itsigo.dev/istigo/pocketmovie/cmd/web"
"git.itsigo.dev/istigo/pocketmovie/internal/apis"
"github.com/gofiber/fiber/v3"
)
func (s *FiberServer) Index(c fiber.Ctx) error {
movies, _ := s.db.ListMovies(c)
settings, _ := s.db.ListSettings(c)
return render(c, web.Show(movies, settings))
}
func (s *FiberServer) Watchlist(c fiber.Ctx) error {
movies, _ := s.db.ListWatchlist(c)
return render(c, web.WatchList(movies))
}
func (s *FiberServer) Settings(c fiber.Ctx) error {
ls, _ := s.db.ListSreamingServices(c)
_, ts := s.getAllStreamingServices(c)
slices.Sort(ts)
reg := apis.GetAvailableRegions()
selectedreg, _ := s.db.ListSetting(c, 3)
key, _ := s.db.ListSetting(c, 2)
config := web.SettingsConfig{
Providers: ls,
AvailableProviders: ts,
Regions: reg,
SelectedRegion: selectedreg.Value,
APIKey: key.Value,
}
return render(c, web.Settings(config))
}

357
internal/server/routes.go Normal file
View File

@@ -0,0 +1,357 @@
package server
import (
"fmt"
"image/jpeg"
"net/http"
"os"
"slices"
"strconv"
"strings"
"git.itsigo.dev/istigo/pocketmovie/cmd/web"
"git.itsigo.dev/istigo/pocketmovie/internal/apis"
"git.itsigo.dev/istigo/pocketmovie/internal/database"
"git.itsigo.dev/istigo/pocketmovie/internal/middleware/apikeychecker"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/log"
"github.com/gofiber/fiber/v3/middleware/static"
)
func (s *FiberServer) RegisterFiberRoutes() {
s.App.Use("/apikey", s.apikeyinput)
s.App.Use("/assets", static.New("./assets", static.Config{
FS: web.Files,
Browse: false,
}))
s.App.Use("/movie/posters", static.New("./data/img"))
s.App.Use(
//basicauth.New(basicauth.Config{
// Users: map[string]string{
// // "doe" hashed using SHA-256
// "john": "{SHA256}eZ75KhGvkY4/t0HfQpNPO1aO0tk6wd908bjUGieTKm8=",
// },
//}),
//compress.New(compress.Config{
// Level: compress.LevelBestSpeed, // 1
//}),
apikeychecker.New(apikeychecker.Config{DB: *s.db}),
)
s.App.Get("/", s.Index)
s.App.Get("/watchlist", s.Watchlist)
s.App.Get("/settings", s.Settings)
s.App.Get("/movie/:id", s.movieDetails)
s.App.Get("/components/mediaTypeSelect/:id", s.mediaTypeSelect)
s.App.Post("/apis/tmdb/searchMovie", s.searchMovie)
s.App.Post("/db/addMovie/:id", s.addMovieToDb)
s.App.Post("/db/changeMovieStatus/:id.:status", s.changeMovieStatus)
s.App.Post("/db/changeMovieRating/:id.:rating", s.changeMovieRating)
s.App.Post("/db/changeMovieOwned/:id", s.changeMovieOwned)
s.App.Post("/db/changeMovieRipped/:id.:ripped", s.changeMovieRipped)
s.App.Post("/db/updateStreamingServices", s.updateMovieStreamingServices)
s.App.Post("/db/updateTableView", s.updateTableView)
s.App.Post("/db/updateRegion", s.updateRegion)
s.App.Post("/db/updateApiKey", s.updateApiKey)
s.App.Delete("/db/deleteMovie/:id", s.deleteMovie)
s.App.Post("/db/addStreamingService", s.addStreamingService)
s.App.Delete("/db/deleteStreamingService/:id", s.deleteStreamingService)
}
func (s *FiberServer) apikeyinput(c fiber.Ctx) error {
key := c.FormValue("apikey")
apierror := ""
if key != "" {
url := "https://api.themoviedb.org/3/authentication"
req, _ := http.NewRequest("GET", url, nil)
req.Header.Add("accept", "application/json")
req.Header.Add("Authorization", "Bearer "+key)
res, _ := http.DefaultClient.Do(req)
if res.StatusCode == 200 {
s.db.ChangeSettingValue(c, database.ChangeSettingValueParams{
ID: 2,
Value: key,
})
c.Redirect().Status(fiber.StatusMovedPermanently).To("/")
}
apierror = "Could not certify API Read Access Token"
}
return render(c, web.Apikeyinput(apierror))
}
func (s *FiberServer) updateApiKey(c fiber.Ctx) error {
key := c.FormValue("apikey")
s.db.ChangeSettingValue(c, database.ChangeSettingValueParams{
ID: 2,
Value: key,
})
config := web.SettingsConfig{
APIKey: key,
}
return render(c, web.ApiKey(config))
}
func (s *FiberServer) movieDetails(c fiber.Ctx) error {
id, _ := strconv.ParseInt(c.Params("id"), 10, 64)
movie, _ := s.db.ListMovie(c, id)
return render(c, web.MovieDetails(movie))
}
func (s *FiberServer) mediaTypeSelect(c fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
return render(c, web.MovieDetailsOwnedSelect(int8(id)))
}
func (s *FiberServer) searchMovie(c fiber.Ctx) error {
res := apis.SearchTmdbMovie(c.FormValue("search"))
return render(c, web.SearchMovie(res))
}
func (s *FiberServer) changeMovieStatus(c fiber.Ctx) error {
id, _ := strconv.ParseInt(c.Params("id"), 10, 64)
status, _ := strconv.ParseInt(c.Params("status"), 10, 64)
s.db.ChangeMovieStatus(c, database.ChangeMovieStatusParams{
ID: id,
Status: status,
})
return render(c, web.MovieDetailsWatched(id, status))
}
func (s *FiberServer) changeMovieRating(c fiber.Ctx) error {
id, _ := strconv.ParseInt(c.Params("id"), 10, 64)
rating, _ := strconv.ParseInt(c.Params("rating"), 10, 64)
s.db.ChangeMovieRating(c, database.ChangeMovieRatingParams{
ID: id,
Rating: rating,
})
return render(c, web.MovieDetailsRating(id, rating))
}
func (s *FiberServer) changeMovieOwned(c fiber.Ctx) error {
id, _ := strconv.ParseInt(c.Params("id"), 10, 64)
option := c.FormValue("option")
var owned int64 = 0
if option != "" {
owned = 1
}
s.db.ChangeMovieOwned(c, database.ChangeMovieOwnedParams{
ID: id,
Owned: owned,
OwnedType: option,
})
return render(c, web.MovieDetailsOwned(id, option))
}
func (s *FiberServer) changeMovieRipped(c fiber.Ctx) error {
id, _ := strconv.ParseInt(c.Params("id"), 10, 64)
ripped, _ := strconv.ParseInt(c.Params("ripped"), 10, 64)
s.db.ChangeMovieRipped(c, database.ChangeMovieRippedParams{
ID: id,
Ripped: ripped,
})
return render(c, web.MovieDetailsRipped(id, ripped))
}
func (s *FiberServer) updateMovieStreamingServices(c fiber.Ctx) error {
movies, _ := s.db.ListMovies(c)
for _, v := range movies {
s.db.ChangeMovieStreamingServices(c, database.ChangeMovieStreamingServicesParams{
ID: v.ID,
StreamingServices: s.getStreamingServicesForMovie(c, v.Tmdbid),
})
}
movies, _ = s.db.ListWatchlist(c)
return render(c, web.List(movies))
}
func (s *FiberServer) updateTableView(c fiber.Ctx) error {
setting, _ := s.db.ListSetting(c, 1)
v := "false"
if setting.Value == "false" {
v = "true"
}
s.db.ChangeSettingValue(c, database.ChangeSettingValueParams{
Value: v,
ID: 1,
})
movies, _ := s.db.ListMovies(c)
if v == "true" {
return render(c, web.MovieTiles(movies))
}
return render(c, web.MovieList(movies))
}
func (s *FiberServer) updateRegion(c fiber.Ctx) error {
region := c.FormValue("region")
s.db.ChangeSettingValue(c, database.ChangeSettingValueParams{
Value: region,
ID: 3,
})
regions := apis.GetAvailableRegions()
config := web.SettingsConfig{
Regions: regions,
SelectedRegion: region,
}
return render(c, web.Region(config))
}
func (s *FiberServer) addStreamingService(c fiber.Ctx) error {
title := c.FormValue("service")
s.db.AddSreamingService(c, title)
ls, _ := s.db.ListSreamingServices(c)
_, ts := s.getAllStreamingServices(c)
slices.Sort(ts)
config := web.SettingsConfig{
Providers: ls,
AvailableProviders: ts,
}
return render(c, web.ProviderTable(config))
}
func (s *FiberServer) deleteStreamingService(c fiber.Ctx) error {
id := fiber.Params[int64](c, "id")
s.db.DeleteSreamingService(c, id)
ls, _ := s.db.ListSreamingServices(c)
_, ts := s.getAllStreamingServices(c)
slices.Sort(ts)
config := web.SettingsConfig{
Providers: ls,
AvailableProviders: ts,
}
return render(c, web.ProviderTable(config))
}
func (s *FiberServer) deleteMovie(c fiber.Ctx) error {
id := fiber.Params[int64](c, "id")
s.db.DeleteMovie(c, id)
movies, _ := s.db.ListMovies(c)
return render(c, web.MovieList(movies))
}
func (s *FiberServer) addMovieToDb(c fiber.Ctx) error {
res := apis.GetTmdbMovie(c.Params("id"))
referer := string(c.Request().Header.Referer())
// Combine Genres
genres := []string{}
for _, v := range res.Genres {
genres = append(genres, v.Name)
}
// Get the streaming providers
streamingservices := s.getStreamingServicesForMovie(c, int64(res.Id))
rating := 0
if c.FormValue("rating") != "" {
rating, _ = strconv.Atoi(c.FormValue("rating"))
}
watched := 0
if c.FormValue("watched") == "on" {
watched = 1
}
watchcount := 0
if c.FormValue("watchcount") != "" {
watchcount, _ = strconv.Atoi(c.FormValue("watchcount"))
}
owned := 0
if c.FormValue("owned") == "on" {
owned = 1
}
ownedType := ""
if c.FormValue("version") != "" {
ownedType = c.FormValue("version")
}
ripped := 0
if c.FormValue("ripped") == "on" {
ripped = 1
}
movie, err := s.db.CreateMovie(c, database.CreateMovieParams{
Title: res.Title,
OriginalTitle: res.OriginalTitle,
Imdbid: res.ImdbID,
Tmdbid: int64(res.Id),
Length: int64(res.Lenght),
Genre: strings.Join(genres, ", "),
StreamingServices: streamingservices,
Director: res.Director,
Year: res.ReleaseDate,
Watchcount: int64(watchcount),
Rating: int64(rating),
Status: int64(watched),
Owned: int64(owned),
OwnedType: ownedType,
Ripped: int64(ripped),
Review: "",
Overview: res.Overview,
})
if err != nil {
log.Error(err)
}
// Get the movie poster
resp, err := http.Get("https://image.tmdb.org/t/p/original" + res.PosterPath)
if err != nil {
log.Error(err)
}
defer resp.Body.Close()
tmp, err := jpeg.Decode(resp.Body)
if err == nil {
path := fmt.Sprintf("./data/img/%d.jpg", movie.ID)
log.Info(path)
outputFile, err := os.Create(path)
if err != nil {
log.Error(err)
}
jpeg.Encode(outputFile, tmp, nil)
outputFile.Close()
}
if strings.Contains(referer, "watchlist") {
movies, _ := s.db.ListWatchlist(c)
return render(c, web.List(movies))
}
gridSetting, _ := s.db.ListSetting(c, 1)
movies, _ := s.db.ListMovies(c)
componet := web.MovieList(movies)
if gridSetting.Value == "true" {
componet = web.MovieTiles(movies)
}
return render(c, componet)
}

23
internal/server/server.go Normal file
View File

@@ -0,0 +1,23 @@
package server
import (
"git.itsigo.dev/istigo/pocketmovie/internal/database"
"github.com/gofiber/fiber/v3"
)
type FiberServer struct {
*fiber.App
db *database.Queries
}
func New() *FiberServer {
server := &FiberServer{
App: fiber.New(fiber.Config{
ServerHeader: "PocketMovie",
AppName: "PocketMovie",
}),
db: database.Init(),
}
return server
}

70
internal/server/util.go Normal file
View File

@@ -0,0 +1,70 @@
package server
import (
"slices"
"strings"
"git.itsigo.dev/istigo/pocketmovie/internal/apis"
"github.com/a-h/templ"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/session"
)
var store = session.New()
func render(c fiber.Ctx, component templ.Component) error {
// or templ wil bork...
c.Set("Content-type", "text/html")
return component.Render(c, c.Response().BodyWriter())
}
// Gets the Streaming services for a movie as a string (service 1, service 2, ...)
func (s *FiberServer) getStreamingServicesForMovie(c fiber.Ctx, id int64) string {
// Get the streaming providers
dbServicesSlice := s.getLocalStreamingServices(c)
region, _ := s.db.ListSetting(c, 3)
flatrate := apis.GetMovieStreamingServices(int(id), region.Value).Flatrate
free := apis.GetMovieStreamingServices(int(id), region.Value).Free
flatrate = append(flatrate, free...)
streaningservices := []string{}
for _, v := range flatrate {
if slices.Contains(dbServicesSlice, v.ProviderName) {
streaningservices = append(streaningservices, v.ProviderName)
}
}
return strings.Join(streaningservices, ", ")
}
func (s *FiberServer) getAllStreamingServices(c fiber.Ctx) ([]string, []string) {
ls := s.getLocalStreamingServices(c)
ts := s.getTmdbStreamingServices(c, ls)
return ls, ts
}
func (s *FiberServer) getLocalStreamingServices(c fiber.Ctx) []string {
services, _ := s.db.ListSreamingServices(c)
sl := []string{}
for _, p := range services {
sl = append(sl, p.Title)
}
return sl
}
func (s *FiberServer) getTmdbStreamingServices(c fiber.Ctx, localservices []string) []string {
region, _ := s.db.ListSetting(c, 3)
services := apis.GetMovieProviders(region.Value)
sl := []string{}
for _, p := range services {
if !slices.Contains(localservices, p.ProviderName) {
sl = append(sl, p.ProviderName)
}
}
return sl
}

9
sqlc.yaml Normal file
View File

@@ -0,0 +1,9 @@
version: "2"
sql:
- engine: "sqlite"
queries: "internal/database/movies.sql"
schema: "internal/database/schema.sql"
gen:
go:
package: "database"
out: "internal/database"