diff --git a/.gitignore b/.gitignore
index 238970a..01b535c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,4 @@
-node_modules/
-output/
+posters/
*.sqlite
-.vscode
\ No newline at end of file
+.vscode
+main
\ No newline at end of file
diff --git a/README.md b/README.md
index 3c0cac8..5c2634e 100644
--- a/README.md
+++ b/README.md
@@ -1,19 +1,19 @@
# List App
-A web application for managing and organizing lists of movies, series, and games. The app allows users to add, view, and delete media items, with support for sorting and filtering.
+A web application for managing and organizing lists of movies, series, and games. The app allows users to add and view media items, with support for sorting and filtering.
## Features
-- **Media Management**: Add, view, and delete movies, series, and games
+- **Media Management**: Add and view movies, series, and games
- **Sorting Options**: Sort by title, year, or date added
- **Image Caching**: Automatically downloads and caches poster images
-- **Authentication**: Password-protected actions for adding/deleting media
+- **Authentication**: Password-protected for adding media
- **Responsive Design**: Works on mobile and desktop devices
+- **No client-side JavaScript**: Pages are fully rendered server-side; the only script on the page is Bootstrap's own bundle (for the collapsible nav/dropdown), not anything authored by this app
## Technologies
-- **Backend**: TypeScript, Express, Bun runtime
-- **Frontend**: TypeScript, Bootstrap 5
+- **Backend**: Go, standard library `net/http` and `html/template`, `modernc.org/sqlite` (pure Go, cgo-free)
- **Database**: SQLite
- **APIs**: OMDB API (movies/series), Twitch IGDB API (games)
@@ -21,43 +21,39 @@ A web application for managing and organizing lists of movies, series, and games
```
list_app/
-├── backend/ # Backend server code
-│ ├── controllers/ # Business logic
-│ ├── models/ # Database models
-│ ├── routes/ # API routes
-│ ├── miscellaneous/ # Utilities and middleware
-│ └── app.ts # Main server entry
+├── controllers/ # Business logic (OMDB/IGDB lookups, poster downloads)
+├── models/ # Database models
+├── routes/ # Route registration
+├── web/ # Server-rendered page + HTML form handling
+│ ├── page.html # Go html/template for the page
+│ ├── page.go # Grouping/sorting logic, GET / and POST /add handlers
+│ └── templates.go # go:embed for page.html
+├── db/ # DB connection and schema
+├── main.go # Main server entry
│
-├── frontend/ # Frontend code
-│ ├── list/ # Main list components
-│ ├── utils/ # Type definitions
-│ └── elementcreate.tsx # Custom element creation
+├── public/ # Static assets, embedded into the Go binary
+│ ├── embed.go # go:embed directive
+│ ├── logo.ico # Favicon
+│ └── no_poster.jpg # Fallback poster image
│
-├── public/ # Static files
-│ ├── index.html # Main HTML file
-│ ├── logo.ico # Favicon
-│ └── no_poster.jpg # Fallback poster image
-│
-├── package.json # Project dependencies
-└── README.md # This file
+├── go.mod # Go module
+└── README.md # This file
```
## Setup
-1. Install Bun runtime: https://bun.sh/
-2. Install dependencies:
- ```bash
- bun install
- ```
-3. Set up your API keys in the database:
+1. Install Go: https://go.dev/
+2. Set up your API keys in the database:
- OMDB API key for movies/series
- Twitch client ID and secret for games
-## API Endpoints
+## Routes
-- `GET /api/media/:mediaType` - List all media of a type (movies, series, games)
-- `POST /api/media/:mediaType` - Add new media (requires password)
-- `DELETE /api/media/:mediaType` - Delete media (requires password)
+- `GET /` - Renders the media list page. Query params: `listType` (`movies`, `series`, `games`; defaults to `movies`) and `sortType` (`title`, `year`, `id`; defaults to `title`)
+- `POST /add` - Plain HTML form submission to add media (`pass`, `code`, plus hidden `listType`/`sortType` to redirect back to the right view). Always redirects back to `/` with `?error=...` set on failure
+- `GET /api/media/:mediaType` - JSON listing of all media of a type (movies, series, games); not used by the page itself, kept as a small read-only API
+
+There is no delete endpoint or UI — removing media isn't supported; use `sqlite3 mydb.sqlite` directly if you need to remove an entry.
## Configuration
@@ -68,18 +64,18 @@ The application uses SQLite for data storage. The database file `mydb.sqlite` wi
- `games` - Stores game information
- `userData` - Stores configuration including API keys and password
+The compiled binary is self-contained: `logo.ico` and `no_poster.jpg` are embedded into it via `public/embed.go`, and the page markup lives in a Go `html/template` embedded via `web/templates.go` — so only the binary (plus the SQLite file) needs to be deployed. Poster images are fetched from OMDB/IGDB at runtime and can't be embedded, so they're cached on disk under a `posters/` directory created next to wherever the binary runs, and served at `/poster/...`. Whether a poster has actually downloaded yet is checked server-side on each page render — if it's missing, the fallback image is used directly in the rendered ``, so no client-side JS is needed for that either.
+
## Usage
1. Navigate to `http://localhost:4080` in your browser
-2. Use the navigation buttons to switch between movies, series, and games
+2. Use the navigation links to switch between movies, series, and games
3. Use the "Sort" dropdown to change sorting method
-4. Enter your password and media ID in the form to add new items
-5. Click "Edit" then use delete buttons to remove items
+4. Enter your password and media ID in the form and submit to add new items
## Building
-To build the frontend:
+To build the binary at `output/app`:
```bash
./build.sh
```
-
diff --git a/backend/app.ts b/backend/app.ts
deleted file mode 100644
index 81ddcec..0000000
--- a/backend/app.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import express from "express";
-
-const hostname = '127.0.0.1';
-const httpPort = 4080;
-
-const app = express();
-
-// import morgan from 'morgan'
-// app.use(morgan('dev'));
-app.use(express.json());
-app.use(express.urlencoded({ extended: false }));
-app.use(express.static('public'));
-
-import apiRouter from './routes/api/apiRouter';
-
-app.use('/api', apiRouter);
-
-app.listen(httpPort, () => {
- console.log(`Server running at http://${hostname}:${httpPort}/`);
-});
-
-import mediaController from "./controllers/mediaController";
-
-await mediaController.checkImages();
\ No newline at end of file
diff --git a/backend/controllers/mediaController.ts b/backend/controllers/mediaController.ts
deleted file mode 100644
index 14a63b7..0000000
--- a/backend/controllers/mediaController.ts
+++ /dev/null
@@ -1,252 +0,0 @@
-import { type Request, type Response } from "express";
-import UserModel, { values } from '../models/userModel';
-import MediaModel, { Table, Media } from '../models/mediaModel';
-
-interface omdbRes {
- Title: string,
- Released: string,
- Response: string,
- Poster: string,
- Type: string,
- imdbID: string,
- Year: string,
-}
-
-function fromStringToTable(input: string | string[]): (Table | undefined) {
- var value = "";
- if (Array.isArray(input)) {
- if (input.length > 0)
- value = input[0];
- }
- else {
- value = input;
- }
- if (value.localeCompare("games") == 0) return Table.games;
- if (value.localeCompare("movies") == 0) return Table.movies;
- if (value.localeCompare("series") == 0) return Table.series;
- return;
-}
-
-async function downloadImage(mData: Media, type: Table) {
- // Specify the path where you want to save the image
- const outputPath = '/poster/' + type + '/' + mData.code + '.jpg';
-
- // Use Bun's built-in fetch to download the image
- const response = await fetch(mData.poster);
-
- // Check if the request was successful
- if (!response.ok) {
- console.log("fetch image error");
- console.log(mData.title);
- return;
- }
-
- // Convert the response to a blob
- const imageBlob = await response.blob();
- // Use Bun's write to save the image to a file
- await Bun.write('./public/' + outputPath, await imageBlob.arrayBuffer());
- MediaModel.updateWebImg(type, mData.code, outputPath);
-
-}
-
-async function createMed(req: Request, res: Response) {
- const mediaCode: string = req.body.code;
-
- const omdb_key = UserModel.getValue(values.omdb_key);
-
- if (!omdb_key) {
- return res.status(500).json({ message: 'Error when creating media' });
- }
-
- try {
-
- const uri = `http://www.omdbapi.com/?i=${mediaCode}&apikey=${omdb_key}`;
- const mJson = await fetch(uri);
- const mData: omdbRes = await mJson.json();
-
- if (mData.Response == 'False') {
- return res.status(404).json({ message: 'wrong code' });
- }
-
- const media: Media = {
- id: 0,
- code: mData.imdbID,
- title: mData.Title,
- released: mData.Released,
- webImg: "",
- poster: mData.Poster,
- year: mData.Year
- };
-
- var tableType = Table.series;
-
- if (mData.Type.localeCompare("movie") == 0) {
- tableType = Table.movies;
- }
-
- const found = MediaModel.findOne(tableType, mediaCode);
- if (found.length != 0) {
- res.status(409).json({ message: 'Media already exists' });
- await downloadImage(media, tableType);
- return;
- }
-
-
- const savedMedia = MediaModel.save(tableType, mData.imdbID, mData.Title, mData.Released, "", mData.Poster, mData.Year);
- await downloadImage(media, tableType);
-
- res.status(201).json(media);
- } catch (err) {
- return res.status(500).json({ message: 'Error when creating media' });
- }
-}
-
-async function createGame(req: Request, res: Response) {
- var gameCode = req.body.code;
-
- const twitch_client_id = UserModel.getValue(values.twitch_client_id);
- const twitch_client_secret = UserModel.getValue(values.twitch_client_secret);
-
- if (!twitch_client_id || !twitch_client_secret) {
- return res.status(500).json({ message: 'Error when creating game' });
- }
-
- try {
- const gameFound = MediaModel.findOne(Table.games, gameCode);
- if (gameFound) {
- return res.status(409).json({ message: 'Game already exists' });
- }
-
- const uri = "https://id.twitch.tv/oauth2/token?client_id=" + twitch_client_id + "&client_secret=" + twitch_client_secret + "&grant_type=client_credentials";
- var response = await fetch(uri, { method: 'POST' });
- const mData = await response.json();
-
- const mheaders: HeadersInit = {
- 'Accept': 'application/json',
- 'Client-ID': twitch_client_id,
- 'Authorization': 'Bearer ' + mData.access_token
- }
-
- gameCode = parseInt(gameCode)
-
- response = await fetch(
- "https://api.igdb.com/v4/games",
- {
- method: 'POST',
- headers: mheaders,
- body: `fields name, first_release_date; where id = ${gameCode};`
- }
- )
- const gameData = await response.json()
- if (gameData.length == 0) {
- return res.status(404).json({ message: 'wrong code' });
- }
-
- const date = new Date(gameData[0].first_release_date * 1000);
- const options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', year: 'numeric' }
- const dateStr = date.toLocaleDateString(undefined, options);
-
-
- response = await fetch(
- "https://api.igdb.com/v4/covers",
- {
- method: 'POST',
- headers: mheaders,
- body: `fields image_id; where game = ${gameCode};`
- }
- )
- const coverData = await response.json()
- const game: Media = {
- id: 0,
- code: gameCode,
- title: gameData[0].name,
- released: dateStr,
- webImg: "",
- poster: `https://images.igdb.com/igdb/image/upload/t_cover_big/${coverData[0].image_id}.jpg`,
- year: date.getFullYear().toString(),
- };
-
- const savedGame = MediaModel.save(Table.games, game.code, game.title, game.released, game.webImg, game.poster, game.year);
- await downloadImage(game, Table.games);
- return res.status(201).json(game);
-
- } catch (error) {
-
- return res.status(500).json({ message: 'Error when creating game', error: error });
- }
-}
-
-function list(req: Request, res: Response) {
- const mediaTable = fromStringToTable(req.params.mediaType);
- if (!mediaTable) {
- return res.status(500).json({
- message: 'Error when getting media.'
- });
- }
-
- const media = MediaModel.find(mediaTable);
- return res.json(media);
-}
-
-async function create(req: Request, res: Response) {
- const mediaCode: string = req.body.code;
- if (mediaCode.startsWith("tt")) {
- return await createMed(req, res);
- } else {
- return await createGame(req, res);
- }
-}
-function remove(req: Request, res: Response) {
- const mediaTable = fromStringToTable(req.params.mediaType);
- if (!mediaTable) {
- return res.status(500).json({
- message: 'Error when deleting the media.'
- });
- }
-
- const code = req.body.code;
-
- try {
- const media = MediaModel.findOneAndDelete(mediaTable, code);
- if (!media) {
- return res.status(404).json({ message: 'No such media' });
- }
-
- return res.status(204).json();
- }
- catch (err) {
- return res.status(500).json({ message: 'Error when deleting the media.' });
- }
-}
-
-async function checkImages() {
- await checkTableImages(Table.games);
- await checkTableImages(Table.movies);
- await checkTableImages(Table.series);
-}
-
-function delay(time: number) {
- return new Promise(resolve => setTimeout(resolve, time));
-}
-
-async function checkTableImages(table: Table) {
- const list = MediaModel.find(table);
-
- for (const element of list) {
- const path = "./public/" + element.webImg;
- const f = Bun.file(path);
- const exists = await f.exists();
- if (!exists) {
- console.log(element.title);
- await downloadImage(element, table);
- await delay(1000);
- }
- }
-}
-
-export default {
- list,
- create,
- remove,
- checkImages
-};
diff --git a/backend/miscellaneous/checkAuthenticated.ts b/backend/miscellaneous/checkAuthenticated.ts
deleted file mode 100644
index 016a62f..0000000
--- a/backend/miscellaneous/checkAuthenticated.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { type NextFunction, type Request, type Response } from "express";
-import userModel, { values } from 'backend/models/userModel';
-
-function checkAuthenticated(req: Request, res: Response, next: NextFunction) {
- const pass = req.body.pass;
- const password = userModel.getValue(values.pass);
- if (pass && password) {
- if (pass == password) {
- return next();
- }
- }
- return res.status(500).json({ message: 'Error when getting transactions.' });
-}
-
-export default checkAuthenticated;
\ No newline at end of file
diff --git a/backend/models/mediaModel.ts b/backend/models/mediaModel.ts
deleted file mode 100644
index ddf1f63..0000000
--- a/backend/models/mediaModel.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import pool from 'backend/miscellaneous/db'
-
-
-export class Media {
- id!: number;
- code!: string
- title!: string;
- released!: string;
- webImg!: string;
- poster!: string;
- year!: string;
-}
-
-export enum Table {
- movies = "movies",
- series = "series",
- games = "games",
-}
-
-function save(table: Table, code: string, title: string, released:string, webImg:string, poster: string, year: string): number {
- try {
- const sql = "INSERT INTO " + table + " (code, title, released, webImg, poster, year) VALUES (?,?,?,?,?,?)";
-
- const result = pool.query(sql).run(code, title, released, webImg, poster, year);
- return result.changes;
- }
- catch (err) {
- console.log(err);
- }
- return 0;
-}
-
-function updateWebImg(table: Table, code: string, webImg: string): number {
- try {
- const sql = "UPDATE " + table + " SET webImg = ? WHERE code = ?;";
- const result = pool.query(sql).run(webImg, code);
- return result.changes;
- }
- catch (err) {
- console.log(err);
- }
- return 0;
-}
-
-function findOneAndDelete(table: Table, code: string): number {
- try {
- const result = pool.query("DELETE FROM " + table + " WHERE code = ?;").run(code);
- return result.changes;
- }
- catch (err) {
- console.log(err);
- }
- return 0;
-}
-
-function findOne(table: Table, code: string): Media[] {
- try {
- const rows = pool.query("SELECT * FROM " + table + " WHERE code = ?;").as(Media).all(code);
- return rows;
- }
- catch (err) {
- console.log(err);
- }
- return [];
-}
-
-function find(table: Table): Media[] {
- try {
- const rows = pool.query("SELECT * FROM " + table + ";").as(Media).all();
- return rows;
- }
- catch (err) {
- console.log(err);
- }
- return [];
-}
-
-export default {
- save,
- updateWebImg,
- findOneAndDelete,
- findOne,
- find
-};
\ No newline at end of file
diff --git a/backend/models/userModel.ts b/backend/models/userModel.ts
deleted file mode 100644
index 70e181c..0000000
--- a/backend/models/userModel.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import pool from 'backend/miscellaneous/db'
-
-class UserD {
- name?: string;
- value?: string;
-}
-
-export enum values {
- pass = 1,
- omdb_key,
- twitch_client_id,
- twitch_client_secret,
-}
-
-
-function getValue(name: values): string | undefined {
- try {
- const rows = pool.query("SELECT name, value FROM userData where id = ?;").as(UserD).all(name);
- if (rows.length > 0)
- return rows[0].value;
- }
- catch (err) {
- console.log(err);
- }
- return;
-}
-
-
-export default {
- getValue
-};
diff --git a/backend/routes/api/apiRouter.ts b/backend/routes/api/apiRouter.ts
deleted file mode 100644
index b9144a9..0000000
--- a/backend/routes/api/apiRouter.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import express, { type Request, type Response } from "express";
-import mediaRouter from './mediaRouter';
-
-const router = express.Router();
-
-router.use('/media', mediaRouter);
-
-router.get('/', function (req: Request, res: Response) {
- res.status(200).json({ message: 'API is working' });
-});
-
-export default router;
\ No newline at end of file
diff --git a/backend/routes/api/mediaRouter.ts b/backend/routes/api/mediaRouter.ts
deleted file mode 100644
index a69bc68..0000000
--- a/backend/routes/api/mediaRouter.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import express from "express";
-import mediaController from '../../controllers/mediaController.js';
-import checkAuthenticated from '../../miscellaneous/checkAuthenticated.js';
-
-const router = express.Router();
-
-router.get('/:mediaType', mediaController.list);
-
-router.post('/:mediaType', checkAuthenticated, mediaController.create);
-
-router.delete('/:mediaType', checkAuthenticated, mediaController.remove);
-
-export default router;
\ No newline at end of file
diff --git a/build.sh b/build.sh
deleted file mode 100755
index 0be3725..0000000
--- a/build.sh
+++ /dev/null
@@ -1,5 +0,0 @@
-bun build ./backend/app.ts --outfile=output/app --target=bun --minify --compile
-bun build ./frontend/list/list.tsx --outfile=output/public/list.js --minify
-
-cp -r public/* output/public/
-cp mydb.sqlite output/
\ No newline at end of file
diff --git a/bun.lock b/bun.lock
deleted file mode 100644
index 645d917..0000000
--- a/bun.lock
+++ /dev/null
@@ -1,190 +0,0 @@
-{
- "lockfileVersion": 1,
- "configVersion": 1,
- "workspaces": {
- "": {
- "name": "web",
- "dependencies": {
- "@types/express": "^5.0.3",
- "@types/morgan": "^1.9.10",
- "bun-types": "^1.2.22",
- "express": "^5.1.0",
- "morgan": "~1.10.1",
- "typescript": "^5.9.2",
- },
- },
- },
- "packages": {
- "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
-
- "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
-
- "@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="],
-
- "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.1", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A=="],
-
- "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
-
- "@types/morgan": ["@types/morgan@1.9.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA=="],
-
- "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
-
- "@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="],
-
- "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
-
- "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
-
- "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
-
- "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
-
- "basic-auth": ["basic-auth@2.0.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg=="],
-
- "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
-
- "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
-
- "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
-
- "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
-
- "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
-
- "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
-
- "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
-
- "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
-
- "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
-
- "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
-
- "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
-
- "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
-
- "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
-
- "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
-
- "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
-
- "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
-
- "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
-
- "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
-
- "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
-
- "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
-
- "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
-
- "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
-
- "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
-
- "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
-
- "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
-
- "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
-
- "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
-
- "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
-
- "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
-
- "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
-
- "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
-
- "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
-
- "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
-
- "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
-
- "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
-
- "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
-
- "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
-
- "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
-
- "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
-
- "morgan": ["morgan@1.10.1", "", { "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", "on-finished": "~2.3.0", "on-headers": "~1.1.0" } }, "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A=="],
-
- "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
-
- "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
-
- "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
-
- "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
-
- "on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="],
-
- "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
-
- "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
-
- "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
-
- "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
-
- "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
-
- "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
-
- "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
-
- "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
-
- "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
-
- "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
-
- "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
-
- "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
-
- "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
-
- "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
-
- "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
-
- "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
-
- "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
-
- "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
-
- "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
-
- "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
-
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
-
- "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
-
- "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
-
- "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
-
- "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
-
- "morgan/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
-
- "morgan/on-finished": ["on-finished@2.3.0", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww=="],
-
- "morgan/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
- }
-}
diff --git a/controllers/mediaController.go b/controllers/mediaController.go
new file mode 100644
index 0000000..09bd0d7
--- /dev/null
+++ b/controllers/mediaController.go
@@ -0,0 +1,277 @@
+package controllers
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "list_app/models"
+)
+
+// PostersDir is where dynamically fetched poster images are cached on disk.
+// Unlike the static frontend assets, these are downloaded at runtime and
+// can't be embedded into the binary, so they live in a real directory next
+// to it, served separately (see routes.New).
+const PostersDir = "posters"
+
+func PosterDiskPath(webImg string) string {
+ rel := strings.TrimPrefix(webImg, "/poster/")
+ return filepath.Join(PostersDir, rel)
+}
+
+type omdbRes struct {
+ Title string `json:"Title"`
+ Released string `json:"Released"`
+ Response string `json:"Response"`
+ Poster string `json:"Poster"`
+ Type string `json:"Type"`
+ ImdbID string `json:"imdbID"`
+ Year string `json:"Year"`
+}
+
+func writeJSON(w http.ResponseWriter, status int, body any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(body)
+}
+
+func fromStringToTable(input string) (models.Table, bool) {
+ switch input {
+ case "games":
+ return models.Games, true
+ case "movies":
+ return models.Movies, true
+ case "series":
+ return models.Series, true
+ }
+ return "", false
+}
+
+func downloadImage(media models.Media, table models.Table) {
+ outputPath := "/poster/" + string(table) + "/" + media.Code + ".jpg"
+
+ response, err := http.Get(media.Poster)
+ if err != nil || !(response.StatusCode >= 200 && response.StatusCode < 300) {
+ log.Println("fetch image error")
+ log.Println(media.Title)
+ return
+ }
+ defer response.Body.Close()
+
+ imageBytes, err := io.ReadAll(response.Body)
+ if err != nil {
+ log.Println("fetch image error")
+ log.Println(media.Title)
+ return
+ }
+
+ destPath := PosterDiskPath(outputPath)
+ if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
+ log.Println(err)
+ return
+ }
+ if err := os.WriteFile(destPath, imageBytes, 0644); err != nil {
+ log.Println(err)
+ return
+ }
+
+ models.UpdateWebImg(table, media.Code, outputPath)
+}
+
+func createMed(mediaCode string) (models.Media, error) {
+ omdbKey, ok := models.GetValue(models.OmdbKey)
+ if !ok || omdbKey == "" {
+ return models.Media{}, fmt.Errorf("error when creating media")
+ }
+
+ uri := "http://www.omdbapi.com/?i=" + url.QueryEscape(mediaCode) + "&apikey=" + url.QueryEscape(omdbKey)
+ resp, err := http.Get(uri)
+ if err != nil {
+ return models.Media{}, fmt.Errorf("error when creating media")
+ }
+ defer resp.Body.Close()
+
+ var mData omdbRes
+ if err := json.NewDecoder(resp.Body).Decode(&mData); err != nil {
+ return models.Media{}, fmt.Errorf("error when creating media")
+ }
+
+ if mData.Response == "False" {
+ return models.Media{}, fmt.Errorf("wrong code")
+ }
+
+ media := models.Media{
+ ID: 0,
+ Code: mData.ImdbID,
+ Title: mData.Title,
+ Released: mData.Released,
+ WebImg: "",
+ Poster: mData.Poster,
+ Year: mData.Year,
+ }
+
+ tableType := models.Series
+ if strings.EqualFold(mData.Type, "movie") {
+ tableType = models.Movies
+ }
+
+ found := models.FindOne(tableType, mediaCode)
+ if len(found) != 0 {
+ downloadImage(media, tableType)
+ return models.Media{}, fmt.Errorf("media already exists")
+ }
+
+ models.Save(tableType, mData.ImdbID, mData.Title, mData.Released, "", mData.Poster, mData.Year)
+ downloadImage(media, tableType)
+
+ return media, nil
+}
+
+func createGame(gameCodeStr string) (models.Media, error) {
+ twitchClientID, ok1 := models.GetValue(models.TwitchClientID)
+ twitchClientSecret, ok2 := models.GetValue(models.TwitchClientSecret)
+ if !ok1 || !ok2 || twitchClientID == "" || twitchClientSecret == "" {
+ return models.Media{}, fmt.Errorf("error when creating game")
+ }
+
+ gameFound := models.FindOne(models.Games, gameCodeStr)
+ if len(gameFound) != 0 {
+ return models.Media{}, fmt.Errorf("game already exists")
+ }
+
+ tokenURI := "https://id.twitch.tv/oauth2/token?client_id=" + url.QueryEscape(twitchClientID) +
+ "&client_secret=" + url.QueryEscape(twitchClientSecret) + "&grant_type=client_credentials"
+ tokenResp, err := http.Post(tokenURI, "application/x-www-form-urlencoded", nil)
+ if err != nil {
+ return models.Media{}, fmt.Errorf("error when creating game: %w", err)
+ }
+ defer tokenResp.Body.Close()
+
+ var tokenData struct {
+ AccessToken string `json:"access_token"`
+ }
+ if err := json.NewDecoder(tokenResp.Body).Decode(&tokenData); err != nil {
+ return models.Media{}, fmt.Errorf("error when creating game: %w", err)
+ }
+
+ gameCode, err := strconv.Atoi(gameCodeStr)
+ if err != nil {
+ return models.Media{}, fmt.Errorf("wrong code")
+ }
+
+ doIgdbRequest := func(path, body string) ([]byte, error) {
+ req, err := http.NewRequest(http.MethodPost, "https://api.igdb.com/v4/"+path, strings.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Client-ID", twitchClientID)
+ req.Header.Set("Authorization", "Bearer "+tokenData.AccessToken)
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ return io.ReadAll(resp.Body)
+ }
+
+ gamesBody, err := doIgdbRequest("games", fmt.Sprintf("fields name, first_release_date; where id = %d;", gameCode))
+ if err != nil {
+ return models.Media{}, fmt.Errorf("error when creating game: %w", err)
+ }
+
+ var gameData []struct {
+ Name string `json:"name"`
+ FirstReleaseDate int64 `json:"first_release_date"`
+ }
+ if err := json.Unmarshal(gamesBody, &gameData); err != nil {
+ return models.Media{}, fmt.Errorf("error when creating game: %w", err)
+ }
+ if len(gameData) == 0 {
+ return models.Media{}, fmt.Errorf("wrong code")
+ }
+
+ releaseDate := time.Unix(gameData[0].FirstReleaseDate, 0).UTC()
+ dateStr := releaseDate.Format("2 Jan 2006")
+
+ coversBody, err := doIgdbRequest("covers", fmt.Sprintf("fields image_id; where game = %d;", gameCode))
+ if err != nil {
+ return models.Media{}, fmt.Errorf("error when creating game: %w", err)
+ }
+
+ var coverData []struct {
+ ImageID string `json:"image_id"`
+ }
+ if err := json.Unmarshal(coversBody, &coverData); err != nil {
+ return models.Media{}, fmt.Errorf("error when creating game: %w", err)
+ }
+
+ imageID := ""
+ if len(coverData) > 0 {
+ imageID = coverData[0].ImageID
+ }
+
+ game := models.Media{
+ ID: 0,
+ Code: gameCodeStr,
+ Title: gameData[0].Name,
+ Released: dateStr,
+ WebImg: "",
+ Poster: fmt.Sprintf("https://images.igdb.com/igdb/image/upload/t_cover_big/%s.jpg", imageID),
+ Year: strconv.Itoa(releaseDate.Year()),
+ }
+
+ models.Save(models.Games, game.Code, game.Title, game.Released, game.WebImg, game.Poster, game.Year)
+ downloadImage(game, models.Games)
+
+ return game, nil
+}
+
+// Create adds a movie, series, or game by its OMDB (imdb "tt...") or IGDB
+// (numeric) code, dispatching to the right lookup based on the code's shape.
+func Create(code string) (models.Media, error) {
+ if strings.HasPrefix(code, "tt") {
+ return createMed(code)
+ }
+ return createGame(code)
+}
+
+func List(w http.ResponseWriter, r *http.Request) {
+ mediaTable, ok := fromStringToTable(r.PathValue("mediaType"))
+ if !ok {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "Error when getting media."})
+ return
+ }
+
+ media := models.Find(mediaTable)
+ writeJSON(w, http.StatusOK, media)
+}
+
+func CheckImages() {
+ checkTableImages(models.Games)
+ checkTableImages(models.Movies)
+ checkTableImages(models.Series)
+}
+
+func checkTableImages(table models.Table) {
+ list := models.Find(table)
+
+ for _, element := range list {
+ path := PosterDiskPath(element.WebImg)
+ if _, err := os.Stat(path); err == nil {
+ continue
+ }
+ log.Println(element.Title)
+ downloadImage(element, table)
+ time.Sleep(1 * time.Second)
+ }
+}
diff --git a/backend/miscellaneous/db.ts b/db/db.go
similarity index 50%
rename from backend/miscellaneous/db.ts
rename to db/db.go
index 3c79a63..5dfd4b1 100644
--- a/backend/miscellaneous/db.ts
+++ b/db/db.go
@@ -1,8 +1,22 @@
-import { Database } from "bun:sqlite";
+package db
-const pool = new Database("mydb.sqlite", { strict: true });
+import (
+ "database/sql"
+ "log"
-pool.run(`
+ _ "modernc.org/sqlite"
+)
+
+var Pool *sql.DB
+
+func init() {
+ pool, err := sql.Open("sqlite", "mydb.sqlite")
+ if err != nil {
+ log.Fatal(err)
+ }
+ Pool = pool
+
+ mustExec(`
CREATE TABLE IF NOT EXISTS series (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL,
@@ -12,9 +26,9 @@ CREATE TABLE IF NOT EXISTS series (
poster TEXT NOT NULL,
year TEXT NOT NULL
);
-`);
+`)
-pool.run(`
+ mustExec(`
CREATE TABLE IF NOT EXISTS movies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL,
@@ -24,9 +38,9 @@ CREATE TABLE IF NOT EXISTS movies (
poster TEXT NOT NULL,
year TEXT NOT NULL
);
-`);
+`)
-pool.run(`
+ mustExec(`
CREATE TABLE IF NOT EXISTS games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL,
@@ -36,33 +50,44 @@ CREATE TABLE IF NOT EXISTS games (
poster TEXT NOT NULL,
year TEXT NOT NULL
);
-`);
+`)
-pool.run(`
+ mustExec(`
CREATE TABLE IF NOT EXISTS userData (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
value TEXT NOT NULL
);
-`);
+`)
-function inset_keys() {
- class co {
- count!: number;
- }
- const result = pool.query("SELECT count(*) as count FROM userData;").as(co).get();
- if(result && result.count >= 4){
- return;
- }
-
- const stmt = pool.prepare("INSERT INTO userData (name, value) VALUES (?, ?)");
- stmt.run("pass", "");
- stmt.run("omdb_key", "");
- stmt.run("twitch_client_id", "");
- stmt.run("twitch_client_secret", "");
- stmt.finalize();
+ insertKeys()
}
-inset_keys();
+func mustExec(query string) {
+ if _, err := Pool.Exec(query); err != nil {
+ log.Fatal(err)
+ }
+}
-export default pool;
\ No newline at end of file
+func insertKeys() {
+ var count int
+ if err := Pool.QueryRow("SELECT count(*) FROM userData;").Scan(&count); err != nil {
+ log.Println(err)
+ return
+ }
+ if count >= 4 {
+ return
+ }
+
+ stmt, err := Pool.Prepare("INSERT INTO userData (name, value) VALUES (?, ?)")
+ if err != nil {
+ log.Println(err)
+ return
+ }
+ defer stmt.Close()
+
+ stmt.Exec("pass", "")
+ stmt.Exec("omdb_key", "")
+ stmt.Exec("twitch_client_id", "")
+ stmt.Exec("twitch_client_secret", "")
+}
diff --git a/frontend/elementcreate.tsx b/frontend/elementcreate.tsx
deleted file mode 100644
index a59a7f0..0000000
--- a/frontend/elementcreate.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-///
{attributes['released']}
-{{.Released}}
+