diff --git a/.env.example b/.env.example index ebea37b..27079f2 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,5 @@ DISCORD_USER_ID= DISCORD_GUILD_ID= OMDB_API_KEY= LASTFM_API_KEY= +LIBRARY_API_KEY= +LIBRARY_API_URL=http://localhost:5000 diff --git a/.gitignore b/.gitignore index 3534e59..bc08095 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ bun.lock __pycache__/ .cache/ data/lastfm/ +data/library/ Update.md PROJECT.md blogs/ diff --git a/Dockerfile b/Dockerfile index e273478..e957867 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,11 +18,11 @@ COPY package.json ./ RUN bun install --production --ignore-scripts COPY --from=build /app/build ./build -RUN mkdir -p /app/.cache/music /app/data/lastfm && chown -R bun:bun /app/.cache /app/data +RUN mkdir -p /app/.cache/music /app/data/lastfm /app/data/library && chown -R bun:bun /app/.cache /app/data ENV NODE_ENV=production -ENV PORT=3000 -EXPOSE 3000 +ENV PORT=5000 +EXPOSE 5000 USER bun CMD ["bun", "./build/index.js"] diff --git a/README.md b/README.md index d9862b0..c936083 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,11 @@ The site recreates my [blomarchy](https://git.sirblob.co/SirBlob/blomarchy) setu - **btop** - a system monitor window with my real GitHub commit graph, repos as processes and some very scientific CPU and caffeine readings. - **Image viewer** - click any image to open it in an imv window with zoom, pan, rotate, pinch and swipe. - **Kitty terminals** - a real command line with history and tab completion. Try `fastfetch`, `whoami` or `cowsay`. -- **Desktop icons** - grouped into pages, terminal scripts and socials (GitHub, LinkedIn, Devpost, Discord). +- **Desktop icons** - grouped into pages, terminal scripts, apps (tcg, movies, shows, books), games and socials (GitHub, LinkedIn, Devpost, Discord). - **13 themes** - Cerberus, Flats, Tokyo Night, Night Cat, Catppuccin, Arch Linux, Gruvbox, Nord, Kanagawa, Wintry, Rose, Catppuccin Latte and Rose Pine Dawn, each with its own wallpaper. - **Portfolio** - about, portfolio, projects (open source, packages, hackathons, research, organizations), blog with RSS and links with QR codes. -- **Live data** - GitHub stars and forks, npm downloads, AniList anime stats and OMDb posters for shows and movies. +- **Library apps** - movies, shows and books open as their own windows with status filters, search, sorting, details and a monthly watch and read history. Entries in the same series or collection are grouped together. +- **Live data** - GitHub stars and forks, npm downloads, AniList anime stats and Last.fm listening stats collected into SQLite. - **Mobile friendly** - on small screens windows go fullscreen with tabs to switch between them. ## Keybinds @@ -69,6 +70,7 @@ Alt stands in for Super, since browsers can't use it. | `theme [name]` | list or switch themes | | `cowsay ` | moo | | `btop` | system monitor with my GitHub stats | +| `movies` / `shows` / `books` | open the library apps | | `pacman -S ` | install things (not really) | ## Running @@ -87,7 +89,7 @@ The music player needs `ffmpeg` installed on the server. docker compose up -d --build ``` -The image builds the site with Bun and includes `ffmpeg`. It reads `.env` and serves on port 3000; set `HOST_PORT` to publish it on a different port. +The image builds the site with Bun and includes `ffmpeg`. It reads `.env` and serves on port 5000; set `HOST_PORT` to publish it on a different port. Shows, movies and books live in the `library-data` volume and Last.fm data in the `lastfm-data` volume. ## Configuration @@ -96,11 +98,29 @@ Copy `.env.example` to `.env` and fill in what you need. Every value is optional | Variable | Used for | | --- | --- | | `DISCORD_TOKEN`, `DISCORD_USER_ID`, `DISCORD_GUILD_ID` | Live Discord status in the bar | -| `OMDB_API_KEY` | Posters and details for shows and movies | +| `OMDB_API_KEY` | Looking up shows and movies in the content editor | +| `LASTFM_API_KEY` | Collecting Last.fm scrobbles for the listening stats | +| `LIBRARY_API_KEY` | Secret the content editor sends to change shows, movies and books. Set the same value on the server and locally | +| `LIBRARY_API_URL` | Site the content editor talks to, `http://localhost:5000` by default | +| `LIBRARY_DATA_DIR` | Folder for `library.db`, `data/library` by default | +| `LASTFM_DATA_DIR` | Folder for `lastfm.db`, `data/lastfm` by default | ## Content -Personal content and config live in `content/`. Data the tools fetch or generate from it (scraped book details and OMDb details) lives in `data/`, and downloaded images go in `static/`. Both are committed so the site never has to call Goodreads or OMDb for saved entries. `data/lastfm` is the exception: the server fills it at runtime and it stays out of git. +Personal content and config live in `content/`. Shows, movies and books are the exception: they live in a SQLite database at `data/library/library.db` (set `LIBRARY_DATA_DIR` to move it) that the server owns, so adding one never needs a commit. Last.fm scrobbles and album covers are collected at runtime into `data/lastfm/lastfm.db` (set `LASTFM_DATA_DIR` to move it). On first start an older `scrobbles.jsonl`, `covers.json`, `track-covers.json` and `images/` in that folder are moved into the database, and those files can be deleted afterwards. Both databases stay out of git. + +### Library API + +| Request | Does | +| --- | --- | +| `GET /api/library` | Every show, movie and book, public | +| `PUT /api/library/media/` | Add or change a show or movie | +| `DELETE /api/library/media/` | Remove a show or movie | +| `PUT /api/library/books/` | Add or change a book | +| `DELETE /api/library/books/` | Remove a book | +| `GET /api/library/images/` | A saved poster or cover | + +Changes need `Authorization: Bearer `. A `PUT` only changes the fields it sends, and `null` clears one. When a status changes to watching or reading the start date is set, and to watched or read the finish date, unless the request sends its own. Posters and covers are downloaded by the server and kept in the database. ### Content editor @@ -117,7 +137,10 @@ bun run content | --- | --- | | `1` to `9` | Switch between Music, Shows, Movies, Books, Profile, Experience, Leadership, Projects and Themes | | `a` | Add (YouTube link, IMDb link or Goodreads link) | +| `S` | Add books from a Goodreads series | | `e` / `Enter` | Edit the selected entry (or change the rating for shows, movies and books) | +| `g` | Put a show or movie in a group, which the library apps and about page show together | +| `h` | Set the started and finished dates for a show, movie or book | | `f` | Toggle featured on a project | | `p` | Preview a theme in the editor | | `t` | Switch the editor to the next theme | @@ -125,29 +148,31 @@ bun run content | `d` | Delete | | `K` / `J` | Move a song or entry up or down | | `u` / `U` | Refresh the selected book, or every book, from Goodreads; `u` also refreshes a show or movie from OMDb | -| `C` | Download every missing book cover, or every missing show and movie poster and details | -| `r` | Reload from disk | +| `C` | Save every missing book cover, or every missing show and movie poster and details, into the library | +| `r` | Reload from disk or the library | | `q` | Quit | -Show and movie titles are looked up on OMDb with `OMDB_API_KEY` from `.env` once when added. The details go into `data/mediaDetails.json` and the poster into `static/covers/media`, so the site only asks OMDb about titles that have nothing saved; fill in the name field to skip the lookup. Book covers are saved into `static/covers/books`. Books are scraped from Goodreads once when added: your shelf, rating and series go into `content/books.json`, and the scraped details into `data/bookDetails.json`. To check a Goodreads link on its own, run `python3 tools/content/goodreads.py `. +The Shows, Movies and Books tabs read and write the library through the API at `LIBRARY_API_URL` using `LIBRARY_API_KEY`, so point it at the live site to update it from anywhere. Show and movie titles are looked up on OMDb with `OMDB_API_KEY` from `.env` when added; fill in the name field to skip the lookup. Books are scraped from Goodreads when added. To check a Goodreads link on its own, run `python3 tools/content/goodreads.py `. + +A game in `about.json` can have a `mobygames` link. Run `python3 tools/content/mobygames.py --save` to scrape its release date, platforms, developers, genres, scores, description and images into `data/gameDetails.json`, which the game card shows. Leave out `--save` to only print the result, or use `--debug` if MobyGames blocks the request. | File | Holds | | --- | --- | | `profile.json` | Name, bio, socials, skills and photos | -| `about.json` | Hobbies, games, shows, movies and the AniList username | +| `about.json` | Hobbies, games and the AniList username | | `experience.json` / `leadership.json` | Work experience and leadership roles | | `projects/*.json` | Open source, packages, hackathons, research groups and organizations | | `music.json` | YouTube links for the music player | -| `books.json` | Your books: Goodreads link, shelf, rating and series | | `wallpapers.json` | Wallpapers for the picker, written by `bun run wallpapers` | | `desktop.json` | Workspaces, desktop icons, the startup command and the default theme | | `themes.json` | Color themes for the site and the content editor | | `blog/*.md` | Blog posts | -| Generated file in `data/` | Holds | +| File in `data/` | Holds | | --- | --- | -| `bookDetails.json` | Title, authors, cover, description, pages and genres scraped from Goodreads | -| `mediaDetails.json` | Year, genre, runtime, plot and poster path from OMDb | +| `library/library.db` | Shows, movies, books, their history dates and saved images | +| `lastfm/lastfm.db` | Last.fm scrobbles, album and track cover links and saved cover images | +| `gameDetails.json` | Video game details scraped from MobyGames | ### Wallpapers diff --git a/content/about.json b/content/about.json index 6dbd5be..d4be88a 100644 --- a/content/about.json +++ b/content/about.json @@ -43,51 +43,7 @@ { "name": "Mini Motorways", "image": "/img/mini.png", "url": "https://store.steampowered.com/app/1127500/Mini_Motorways/" }, { "name": "Risk", "image": "/img/risk.png", "url": "https://store.steampowered.com/app/1128810/RISK_Global_Domination/" }, { "name": "Slay the Spire", "image": "/img/slay_the_spire.png", "url": "https://store.steampowered.com/app/646570/Slay_the_Spire/" }, - { "name": "Slay the Spire 2", "image": "/img/slay_the_spire_2.png", "url": "https://store.steampowered.com/app/2868840/Slay_the_Spire_2/" } - ], - "shows": [ - { "name": "Silo", "rating": 10, "url": "https://www.imdb.com/title/tt14688458/" }, - { "name": "D.P. (KDrama)", "rating": 10, "url": "https://www.imdb.com/title/tt13024974/" }, - { "name": "Andor", "rating": 9, "url": "https://www.imdb.com/title/tt9253284/" }, - { "name": "Severance", "rating": 9, "url": "https://www.imdb.com/title/tt11280740/" }, - { "name": "The Mandalorian", "rating": 7, "url": "https://www.imdb.com/title/tt8111088/" }, - { "name": "BEEF", "rating": 9, "url": "https://www.imdb.com/title/tt14403178/" }, - { "name": "Loki", "rating": 10, "url": "https://www.imdb.com/title/tt9140554/", "status": "watching" }, - { "name": "Alice in Borderland", "url": "https://www.imdb.com/title/tt10795658/", "status": "planning" }, - { "name": "Spider-Noir", "url": "https://www.imdb.com/title/tt30460310/", "status": "planning" }, - { "name": "Arcane", "url": "https://www.imdb.com/title/tt11126994/", "rating": 10 }, - { "name": "Gravity Falls", "url": "https://www.imdb.com/title/tt1865718/", "rating": 10 }, - { "name": "Invincible", "url": "https://www.imdb.com/title/tt6741278/", "rating": 10 }, - { "name": "Cyberpunk: Edgerunners", "url": "https://www.imdb.com/title/tt12590266/", "rating": 10 } - ], - "movies": [ - { "name": "Interstellar", "rating": 10, "url": "https://www.imdb.com/title/tt0816692/" }, - { "name": "How to Train Your Dragon", "rating": 10, "url": "https://www.imdb.com/title/tt0892769/", "collection": "HTTYD" }, - { "name": "12 Angry Men", "rating": 10, "url": "https://www.imdb.com/title/tt0050083/" }, - { "name": "The Wild Robot", "rating": 10, "url": "https://www.imdb.com/title/tt29623480/" }, - { "name": "Whiplash", "rating": 10, "url": "https://www.imdb.com/title/tt2582802/" }, - { "name": "Predestination", "rating": 10, "url": "https://www.imdb.com/title/tt2397535/" }, - { "name": "Project Hail Mary", "rating": 10, "url": "https://www.imdb.com/title/tt12042730/" }, - { "name": "Gravity", "rating": 8, "url": "https://www.imdb.com/title/tt1454468/" }, - { "name": "The Shawshank Redemption", "url": "https://www.imdb.com/title/tt0111161/", "rating": 8 }, - { "name": "Oldboy", "rating": 9, "url": "https://www.imdb.com/title/tt0364569/" }, - { "name": "Spider-Man: Into the Spider-Verse", "rating": 10, "url": "https://www.imdb.com/title/tt4633694/", "collection": "Spider-Man" }, - { "name": "Spider-Man: Across the Spider-Verse", "rating": 9, "url": "https://www.imdb.com/title/tt9362722/", "collection": "Spider-Man" }, - { "name": "Avatar", "rating": 10, "url": "https://www.imdb.com/title/tt0499549/", "collection": "Avatar" }, - { "name": "Avatar: The Way of Water", "rating": 10, "url": "https://www.imdb.com/title/tt1630029/", "collection": "Avatar" }, - { "name": "Avatar: Fire and Ash", "rating": 8, "url": "https://www.imdb.com/title/tt1757678/", "collection": "Avatar" }, - { "name": "Blade Runner 2049", "rating": 10, "url": "https://www.imdb.com/title/tt1856101/" }, - { "name": "How to Train Your Dragon 2", "url": "https://www.imdb.com/title/tt1646971/", "rating": 9, "collection": "HTTYD" }, - { "name": "How to Train Your Dragon: The Hidden World", "url": "https://www.imdb.com/title/tt2386490/", "rating": 9, "collection": "HTTYD" }, - { "name": "Oppenheimer", "url": "https://www.imdb.com/title/tt15398776/", "rating": 10 }, - { "name": "The Martian", "url": "https://www.imdb.com/title/tt3659388/", "rating": 8 }, - { "name": "Baby Driver", "url": "https://www.imdb.com/title/tt3890160/", "rating": 10 }, - { "name": "Ford v Ferrari", "url": "https://www.imdb.com/title/tt1950186/", "rating": 9 }, - { "name": "Gran Turismo", "url": "https://www.imdb.com/title/tt4495098/", "rating": 9 }, - { "name": "F1: The Movie", "url": "https://www.imdb.com/title/tt16311594/", "rating": 8 }, - { "name": "Rush", "url": "https://www.imdb.com/title/tt1979320/", "status": "planning" }, - { "name": "Blade Runner", "url": "https://www.imdb.com/title/tt0083658/", "rating": 9 }, - { "name": "Dune: Part One", "url": "https://www.imdb.com/title/tt1160419/", "rating": 8, "collection": "Dune" }, - { "name": "Dune: Part Two", "url": "https://www.imdb.com/title/tt15239678/", "rating": 8 } + { "name": "Slay the Spire 2", "image": "/img/slay_the_spire_2.png", "url": "https://store.steampowered.com/app/2868840/Slay_the_Spire_2/" }, + { "name": "Hades II", "url": "https://store.steampowered.com/app/1145350/Hades_II/", "mobygames": "https://www.mobygames.com/game/223472/hades-ii/" } ] } diff --git a/content/books.json b/content/books.json deleted file mode 100644 index 3bc8b17..0000000 --- a/content/books.json +++ /dev/null @@ -1,233 +0,0 @@ -[ - { "goodreadsId": "170448", "title": "Animal Farm", "url": "https://www.goodreads.com/book/show/170448", "shelf": "read", "rating": 10, "added": "2026-11-01" }, - { "goodreadsId": "61439040", "title": "1984", "url": "https://www.goodreads.com/book/show/61439040", "shelf": "read", "rating": 10, "added": "2026-11-01" }, - { - "goodreadsId": "13228487", - "title": "The Dragonet Prophecy (Wings of Fire, #1)", - "url": "https://www.goodreads.com/book/show/13228487", - "shelf": "read", - "rating": 7, - "added": "2026-11-01", - "series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 1" } - }, - { - "goodreadsId": "15809617", - "title": "The Lost Heir (Wings of Fire, #2)", - "url": "https://www.goodreads.com/book/show/15809617", - "shelf": "read", - "rating": 8, - "added": "2026-11-01", - "series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 2" } - }, - { - "goodreadsId": "16100976", - "title": "The Hidden Kingdom (Wings of Fire, #3)", - "url": "https://www.goodreads.com/book/show/16100976", - "shelf": "read", - "rating": 9, - "added": "2026-11-01", - "series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 3" } - }, - { - "goodreadsId": "17675467", - "title": "The Dark Secret (Wings of Fire, #4)", - "url": "https://www.goodreads.com/book/show/17675467", - "shelf": "read", - "rating": 8, - "added": "2026-11-01", - "series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 4" } - }, - { - "goodreadsId": "18527497", - "title": "The Brightest Night (Wings of Fire, #5)", - "url": "https://www.goodreads.com/book/show/18527497", - "shelf": "read", - "rating": 10, - "added": "2026-11-01", - "series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 5" } - }, - { - "goodreadsId": "22323659", - "title": "Moon Rising (Wings of Fire, #6)", - "url": "https://www.goodreads.com/book/show/22323659", - "shelf": "read", - "rating": 7, - "added": "2026-11-01", - "series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 6" } - }, - { - "goodreadsId": "23604435", - "title": "Winter Turning (Wings of Fire, #7)", - "url": "https://www.goodreads.com/book/show/23604435", - "shelf": "read", - "rating": 7, - "added": "2026-11-01", - "series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 7" } - }, - { - "goodreadsId": "25711360", - "title": "Escaping Peril (Wings of Fire, #8)", - "url": "https://www.goodreads.com/book/show/25711360", - "shelf": "read", - "rating": 8, - "added": "2026-11-01", - "series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 8" } - }, - { - "goodreadsId": "29550388", - "title": "Talons of Power (Wings of Fire, #9)", - "url": "https://www.goodreads.com/book/show/29550388", - "shelf": "read", - "rating": 7, - "added": "2026-11-01", - "series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 9" } - }, - { - "goodreadsId": "33198183", - "title": "Darkness of Dragons (Wings of Fire, #10)", - "url": "https://www.goodreads.com/book/show/33198183", - "shelf": "read", - "rating": 9, - "added": "2026-11-01", - "series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 10" } - }, - { - "goodreadsId": "35965782", - "title": "The Lost Continent (Wings of Fire, #11)", - "url": "https://www.goodreads.com/book/show/35965782", - "shelf": "read", - "rating": 7, - "added": "2026-11-01", - "series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 11" } - }, - { - "goodreadsId": "39983543", - "title": "The Hive Queen (Wings of Fire, #12)", - "url": "https://www.goodreads.com/book/show/39983543", - "shelf": "read", - "rating": 8, - "added": "2026-11-01", - "series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 12" } - }, - { - "goodreadsId": "35965828", - "title": "The Poison Jungle (Wings of Fire, #13)", - "url": "https://www.goodreads.com/book/show/35965828", - "shelf": "read", - "rating": 10, - "added": "2026-11-01", - "series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 13" } - }, - { - "goodreadsId": "49090511", - "title": "The Dangerous Gift (Wings of Fire, #14)", - "url": "https://www.goodreads.com/book/show/49090511", - "shelf": "want-to-read", - "added": "2026-11-01", - "series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 14" } - }, - { - "goodreadsId": "57468679", - "title": "The Flames of Hope (Wings of Fire, #15)", - "url": "https://www.goodreads.com/book/show/57468679", - "shelf": "want-to-read", - "added": "2026-11-01", - "series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 15" } - }, - { - "goodreadsId": "232700898", - "title": "The Hybrid Prince (Wings of Fire #16)", - "url": "https://www.goodreads.com/book/show/232700898", - "shelf": "want-to-read", - "added": "2026-11-01", - "series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 16" } - }, - { - "goodreadsId": "13453029", - "title": "Wool Omnibus (Silo, #1)", - "url": "https://www.goodreads.com/book/show/13453029", - "shelf": "want-to-read", - "added": "2026-11-01", - "series": { "name": "Silo", "url": "https://www.goodreads.com/series/70647", "position": "Book 1" } - }, - { - "goodreadsId": "17306293", - "title": "Shift (Silo, #2)", - "url": "https://www.goodreads.com/book/show/17306293", - "shelf": "want-to-read", - "added": "2026-11-01", - "series": { "name": "Silo", "url": "https://www.goodreads.com/series/70647", "position": "Book 2" } - }, - { - "goodreadsId": "18866705", - "title": "Dust (Silo, #3)", - "url": "https://www.goodreads.com/book/show/18866705", - "shelf": "want-to-read", - "added": "2026-11-01", - "series": { "name": "Silo", "url": "https://www.goodreads.com/series/70647", "position": "Book 3" } - }, - { - "goodreadsId": "37781", - "title": "Things Fall Apart (The African Trilogy, #1)", - "url": "https://www.goodreads.com/book/show/37781", - "shelf": "read", - "rating": 9, - "added": "2026-11-01", - "series": { "name": "The African Trilogy", "url": "https://www.goodreads.com/series/168203", "position": "Book 1" } - }, - { - "goodreadsId": "37774", - "title": "Arrow of God (The African Trilogy, #2)", - "url": "https://www.goodreads.com/book/show/37774", - "shelf": "read", - "rating": 8, - "added": "2026-11-01", - "series": { "name": "The African Trilogy", "url": "https://www.goodreads.com/series/168203", "position": "Book 2" } - }, - { - "goodreadsId": "111085", - "title": "No Longer at Ease (The African Trilogy, #3)", - "url": "https://www.goodreads.com/book/show/111085", - "shelf": "read", - "rating": 8, - "added": "2026-11-01", - "series": { "name": "The African Trilogy", "url": "https://www.goodreads.com/series/168203", "position": "Book 3" } - }, - { "goodreadsId": "22875069", "title": "I Will Always Write Back: How One Letter Changed Two Lives", "url": "https://www.goodreads.com/book/show/22875069", "shelf": "read", "rating": 9, "added": "2026-11-01" }, - { "goodreadsId": "33118312", "title": "Refugee", "url": "https://www.goodreads.com/book/show/33118312", "shelf": "read", "rating": 7, "added": "2026-11-01" }, - { "goodreadsId": "1381", "title": "The Odyssey", "url": "https://www.goodreads.com/book/show/1381", "shelf": "read", "rating": 9, "added": "2026-11-01" }, - { "goodreadsId": "38631641", "title": "The lliad", "url": "https://www.goodreads.com/book/show/38631641", "shelf": "read", "rating": 7, "added": "2026-11-01" }, - { "goodreadsId": "35031085", "title": "Frankenstein", "url": "https://www.goodreads.com/book/show/35031085", "shelf": "read", "rating": 10, "added": "2026-11-01" }, - { "goodreadsId": "41733839", "title": "The Great Gatsby", "url": "https://www.goodreads.com/book/show/41733839", "shelf": "read", "rating": 6, "added": "2026-11-01" }, - { "goodreadsId": "192566704", "title": "The Outsiders", "url": "https://www.goodreads.com/book/show/192566704", "shelf": "read", "rating": 9, "added": "2026-11-01" }, - { - "goodreadsId": "6186357", - "title": "The Maze Runner (The Maze Runner, #1)", - "url": "https://www.goodreads.com/book/show/6186357", - "shelf": "read", - "rating": 10, - "added": "2026-11-01", - "series": { "name": "The Maze Runner", "url": "https://www.goodreads.com/series/110451", "position": "Book 1" } - }, - { - "goodreadsId": "7631105", - "title": "The Scorch Trials (The Maze Runner, #2)", - "url": "https://www.goodreads.com/book/show/7631105", - "shelf": "read", - "rating": 7, - "added": "2026-11-01", - "series": { "name": "The Maze Runner", "url": "https://www.goodreads.com/series/110451", "position": "Book 2" } - }, - { - "goodreadsId": "7864437", - "title": "The Death Cure (The Maze Runner, #3)", - "url": "https://www.goodreads.com/book/show/7864437", - "shelf": "read", - "rating": 10, - "added": "2026-11-01", - "series": { "name": "The Maze Runner", "url": "https://www.goodreads.com/series/110451", "position": "Book 3" } - }, - { "goodreadsId": "40495148", "title": "Blindness", "url": "https://www.goodreads.com/book/show/40495148", "shelf": "read", "rating": 8, "added": "2026-11-01" }, - { "goodreadsId": "61102615", "title": "Stone Blind", "url": "https://www.goodreads.com/book/show/61102615", "shelf": "want-to-read", "added": "2026-11-01" }, - { "goodreadsId": "34454589", "title": "The Handmaid's Tale", "url": "https://www.goodreads.com/book/show/34454589", "shelf": "read", "rating": 8, "added": "2026-11-01" } -] diff --git a/content/desktop.json b/content/desktop.json index 9751e39..94cbb87 100644 --- a/content/desktop.json +++ b/content/desktop.json @@ -37,7 +37,10 @@ "name": "apps", "side": "right", "icons": [ - { "label": "tcg", "icon": "mdi:pokeball", "open": { "app": "tcg" } } + { "label": "tcg", "icon": "mdi:pokeball", "open": { "app": "tcg" } }, + { "label": "movies", "icon": "mdi:movie-open-outline", "open": { "app": "movies" } }, + { "label": "shows", "icon": "mdi:television-classic", "open": { "app": "shows" } }, + { "label": "books", "icon": "mdi:bookshelf", "open": { "app": "books" } } ] }, { diff --git a/data/bookDetails.json b/data/bookDetails.json deleted file mode 100644 index 56a190d..0000000 --- a/data/bookDetails.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "170448": { "authors": ["George Orwell"], "cover": "/covers/books/170448.jpg", "description": "Animal Farm is the most famous by far of all twentieth-century political allegories. Its account of a group of barnyard animals who revolt against their vicious human master, only to submit to a tyranny erected by their own kind, can fairly be said to have become a universal drama. Orwell is one of the very few modern satirists comparable to Jonathan Swift in power, artistry, and moral authority; in animal farm his spare prose and the logic of his dark comedy brilliantly highlight his stark message.\n \nTaking as his starting point the betrayed promise of the Russian Revolution, Orwell lays out...", "pages": 141, "published": "1996-04-01", "genres": ["Classics", "Fiction", "Dystopia", "Fantasy", "School"], "averageRating": 4.03, "ratingCount": 4853138, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1325861570i/170448.jpg" }, - "61439040": { "authors": ["George Orwell"], "cover": "/covers/books/61439040.jpg", "description": "A masterpiece of rebellion and imprisonment where war is peace freedom is slavery and Big Brother is watching. Thought Police, Big Brother, Orwellian - these words have entered our vocabulary because of George Orwell's classic dystopian novel 1984. The story of one man's Nightmare Odyssey as he pursues a forbidden love affair through a world ruled by warring states and a power structure that controls not only information but also individual thought and memory 1984 is a prophetic haunting tale More relevant than ever before 1984 exposes the worst crimes imaginable the destruction of truth...", "pages": 368, "published": "2022-07-01", "genres": ["Classics", "Fiction", "Science Fiction", "Dystopia", "Literature"], "averageRating": 4.21, "ratingCount": 5815805, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1657781256i/61439040.jpg" }, - "13228487": { "authors": ["Tui T. Sutherland"], "cover": "/covers/books/13228487.jpg", "description": "A thrilling new series soars above the competition and redefines middle-grade fantasy fiction for a new generation!\n\nThe seven dragon tribes have been at war for generations, locked in an endless battle over an ancient, lost treasure. A secret movement called the Talons of Peace is determined to bring an end to the fighting, with the help of a prophecy -- a foretelling that calls for great sacrifice.\n\n\n\nFive dragonets are collected to fulfill the prophecy, raised in a hidden cave and enlisted, against their will, to end the terrible war.\n\n\n\nBut not every dragonet wants a destiny. And when the...", "pages": 336, "published": "2012-07-01", "genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"], "averageRating": 4.22, "ratingCount": 88779, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1339198070i/13228487.jpg" }, - "15809617": { "authors": ["Tui T. Sutherland"], "cover": "/covers/books/15809617.jpg", "description": "The WINGS OF FIRE saga continues with a thrilling underwater adventure -- and a mystery that will change everything!The lost heir to the SeaWing throne is going home at last...She can’t believe it’s finally happening. Tsunami and her fellow dragonets of destiny are journeying under the water to the great SeaWing Kingdom. Stolen as an egg from the royal hatchery, Tsunami is eager to meet her future subjects and reunite with her mother, Queen Coral.But Tsunami’s triumphant return doesn’t go quite the way she’d imagined. Queen Coral welcomes her with open wings, but a mysterious assassin has...", "pages": 296, "published": "2013-01-01", "genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"], "averageRating": 4.4, "ratingCount": 53440, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1358266376i/15809617.jpg" }, - "16100976": { "authors": ["Tui T. Sutherland"], "cover": "/covers/books/16100976.jpg", "description": "The WINGS OF FIRE saga continues!\n\nDeep in the rain forest, danger awaits...\n\nGlory knows the dragon world is wrong about her tribe. After all, she isn't \"as lazy as a RainWing\" -- she isn't lazy at all! Maybe she wasn't meant to be one of the dragonets of destiny, as the older dragons constantly remind her, but Glory is sharp and her venom is deadly... except, of course, no one knows it.\n\nWhen the dragonets seek shelter in the rain forest, Glory is devastated to find that the treetops are full of RainWings that no dragon could ever call dangerous. They nap all day and know nothing of the...", "pages": 336, "published": "2013-05-28", "genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"], "averageRating": 4.48, "ratingCount": 47654, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1364066230i/16100976.jpg" }, - "17675467": { "authors": ["Tui T. Sutherland"], "cover": "/covers/books/17675467.jpg", "description": "The WINGS OF FIRE saga continues with a visit to the mysterious land of the NightWings, where Starflight must face a terrible choice---his home, or his friends?\n\nLike all the dragonets of destiny, Starflight has always wanted to see his home -- but he's also been afraid of his fellow NightWings. Starflight doesn't have mindreading powers like his tribe, and he doesn't understand why they're so secretive. No one has ever even seen the NightWing queen.\n\nBut now Starflight is the dragonets' only hope -- he must find a way to negotiate with his fellow NightWings to free the RainWing dragons...", "pages": 336, "published": "2013-10-29", "genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"], "averageRating": 4.36, "ratingCount": 43095, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1368813111i/17675467.jpg" }, - "18527497": { "authors": ["Tui T. Sutherland"], "cover": "/covers/books/18527497.jpg", "description": "The dragonets struggle to fulfill the prophecy and -- somehow -- end the war in this thrilling new installment of the bestselling WINGS OF FIRE series!\n\nIt all comes down to this: The Dragonets of Destiny must finally bring the epic war to an end, reconcile the seven tribes, and choose the next queen of Pyrrhia... and make it out alive.", "pages": 336, "published": "2014-03-25", "genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"], "averageRating": 4.52, "ratingCount": 40874, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1389053585i/18527497.jpg" }, - "22323659": { "authors": ["Tui T. Sutherland"], "cover": "/covers/books/22323659.jpg", "description": "The New York Times-bestselling series soars to even greater heights with a new prophecy and five new dragonets ready to claim their destiny!\n\nPeace has come to Pyrrhia . . . for now.The war between the tribes is finally over, and now the dragonets of the prophecy have a plan for lasting Jade Mountain Academy, a school that will gather dragonets from all the tribes and teach them to live together, perhaps even as friends.Moonwatcher isn’t sure how she feels about school, however. Hidden in the rainforest for most of her life, the young NightWing has an awful secret. She can read minds, and...", "pages": 336, "published": "2014-12-30", "genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"], "averageRating": 4.55, "ratingCount": 36854, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1421370732i/22323659.jpg" }, - "23604435": { "authors": ["Tui T. Sutherland"], "cover": "/covers/books/23604435.jpg", "description": "Daring mission... or deadly mistake?\n\nWinter has been a disappointment to his royal IceWing family his whole life. When his sister, Icicle, runs away from Jade Mountain Academy, fleeing terrible crimes and possibly planning to commit more, Winter knows that they both need a second chance to make things right -- if only he can find her. \n\nWinter’s new clawmates, Moon, Qibli, and Kinkajou, won’t let him make this dangerous journey alone. They don’t seem to understand that IceWings, the most superior of all dragon tribes, can fix their own problems. When their search leads the dragonets straight...", "pages": 336, "published": "2015-06-30", "genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"], "averageRating": 4.44, "ratingCount": 34278, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1419320915i/23604435.jpg" }, - "25711360": { "authors": ["Tui T. Sutherland"], "cover": "/covers/books/25711360.jpg", "description": "The New York Times and USA Today bestselling series soars to even greater heights with a new prophecy and five new dragonets ready to claim their destiny!Peril has been loyal to Queen Scarlet, who used her fatal firescales to kill countless dragons in the SkyWing arena. Now, Peril is loyal to Clay, the only dragonet who has ever been her friend. So when Scarlet threatens Jade Mountain Academy, Peril sets off to find her former queen, stop her, and save the day, no matter what it takes.There's just one a strangely persistent SeaWing, Turtle, insists on coming along, too. Turtle is worried...", "pages": 260, "published": "2015-12-29", "genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"], "averageRating": 4.46, "ratingCount": 32456, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1437899850i/25711360.jpg" }, - "29550388": { "authors": ["Tui T. Sutherland"], "cover": "/covers/books/29550388.jpg", "description": "The war is over. The false prophecy has been fulfilled. But the dragonets still have enemies. A dark evil, buried for centuries, is stirring.\n\nAnd a young NightWing may have had the first true prophecy in generations . . .\n\nSomething is coming to shake the earth\nSomething is coming to scorch the ground\nJade Mountain will fall beneath thunder and ice\nUnless the lost city of night can be found.\n\nDon't miss the next chapter in the epic, bestselling Wings of Fire series!", "pages": 336, "published": "2016-12-27", "genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"], "averageRating": 4.48, "ratingCount": 29715, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1468226830i/29550388.jpg" }, - "33198183": { "authors": ["Tui T. Sutherland"], "cover": "/covers/books/33198183.jpg", "description": "An ancient evil, a terrible prophecy -- and five dragonets who could save the world...Don't miss the most thrilling adventure yet in the New York Times bestselling Wings of Fire series! Qibli knows Darkstalker must be stopped. And he knows he could stop him -- if he had magic. With even a sliver of the ancient dragon's all-powerful scroll, Qibli could rewrite history the right end war forever; make every dragon happy; perhaps even cast a very small spell so that everyone would like him...Instead, as Darkstalker's dangerous influence spreads across Pyrrhia, entrancing or killing every dragon...", "pages": 336, "published": "2017-07-25", "genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"], "averageRating": 4.53, "ratingCount": 27543, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1481665982i/33198183.jpg" }, - "35965782": { "authors": ["Tui T. Sutherland"], "cover": "/covers/books/35965782.jpg", "description": "For centuries there have been rumors of another continent on the dragons' planet -- another land far across the ocean, populated by tribes of dragons very different from those we know. But there's never been any evidence, and most dragons dismissed the rumors as fairy tales. Until now. Because it turns out the stories are true. And the other tribes are coming.", "pages": null, "published": null, "genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"], "averageRating": 4.41, "ratingCount": 23048, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1518644206i/35965782.jpg" }, - "39983543": { "authors": ["Tui T. Sutherland"], "cover": "/covers/books/39983543.jpg", "description": "Growing up in the hives, Cricket has always had a million questions. Why are trees forbidden, even in art? Why do her parents seem to hate her? And the biggest, most dangerous and secret question of all: why is Cricket immune to Queen Wasp's powers? Whenever the queen takes control of all the HiveWings, speaking through their mouths and seeing through their eyes, Cricket has to hide, terrified of being discovered. Now she's hiding again, wanted for stealing the Book of Clearsight along with her new SilkWing friends, Blue and Swordtail, and the fierce LeafWing, Sundew. The fugitives need...", "pages": 286, "published": "2018-12-26", "genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Audiobook"], "averageRating": 4.43, "ratingCount": 20949, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1537623438i/39983543.jpg" }, - "35965828": { "authors": ["Tui T. Sutherland"], "cover": "/covers/books/35965828.jpg", "description": "Some secrets are deadly.\n\nIt's no secret that Sundew wants to destroy the HiveWings. It's her life's mission to exact revenge on the tribe that tried to wipe out the LeafWings and ripped every tree from the surface of Pantala.\n\nEvery tree, that is, except the wild and dangerous Poison Jungle, where the surviving LeafWings have been hiding since the war. Hiding, plotting, and waiting for a dragon like Sundew, who is uniquely qualified to bring down the Hives.\n\nThere are dark secrets in the jungle, though-some that Sundew is keeping, and some that she's only just beginning to discover. And now...", "pages": 336, "published": "2019-06-25", "genres": ["Fantasy", "Middle Grade", "Dragons", "Fiction", "Young Adult"], "averageRating": 4.38, "ratingCount": 23462, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1549002227i/35965828.jpg" }, - "49090511": { "authors": ["Tui T. Sutherland"], "cover": "/covers/books/49090511.jpg", "description": "The #1 New York Times bestselling series continues!\n\nSnowfall didn't expect to be queen of the IceWings at such a young age, but now that she is, she's going to be the best queen ever. All she has to do is keep her tribe within IceWing territory, where it's safe -- while keeping every other tribe out, where they belong.\n\nIt's a perfect and simple plan, backed up by all the IceWing magic Snowfall can find. That is, until a storm of unidentified dragons arrives on her shore, looking for asylum.\n\nThe foreigners are completely strange and, Snowfall is certain, utterly untrustworthy. But as she...", "pages": 316, "published": "2021-03-02", "genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Childrens"], "averageRating": 4.42, "ratingCount": 16385, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1602613751i/49090511.jpg" }, - "57468679": { "authors": ["Tui T. Sutherland"], "cover": "/covers/books/57468679.jpg", "description": "The #1 New York Times bestselling series is hotter than ever, and this thrilling conclusion to the Lost Continent Prophecy arc is a must-read!\n\nIn the last installment of the third arc of the Wings of Fire series, tensions are higher than ever as we prepare for a fight for the survival of all dragonkind!", "pages": 330, "published": "2022-04-05", "genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Audiobook"], "averageRating": 4.29, "ratingCount": 13974, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1648003520i/57468679.jpg" }, - "232700898": { "authors": ["Tui T. Sutherland"], "cover": "/covers/books/232700898.jpg", "description": "The #1 New York Times bestselling series is back with a brand new arc! Discover what happens next in this thrilling addition to the Wings of Fire saga.Umber was never supposed to be a hero . . . .As the youngest sibling of his MudWing hatching, Umber doesn't have the responsibilities of his bigwings, Reed, nor the heroic destiny prophesied for his brother, Clay. He's always been content with his role as the cheerful, goofy, little brother. But when his sister, Sora, causes a tragedy at Jade Mountain Academy, Umber finds himself on the run and thrown into a whole new role--that of...", "pages": 341, "published": "2026-03-03", "genres": ["Fantasy", "Dragons", "Middle Grade", "Fiction", "Audiobook"], "averageRating": 4.36, "ratingCount": 3484, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1777635896i/232700898.jpg" }, - "13453029": { "authors": ["Hugh Howey"], "cover": "/covers/books/13453029.jpg", "description": "This Omnibus Edition collects the five Wool books into a single volume. \n\nThe first Wool story was released as a standalone short in July of 2011. Due to reviewer demand, the rest of the story was released over the next six months. \n\nThis is the story of mankind clawing for survival, of mankind on the edge. The world outside has grown unkind, the view of it limited, talk of it forbidden. But there are always those who hope, who dream. These are the dangerous people, the residents who infect others with their optimism. Their punishment is simple. They are given the very thing they profess to...", "pages": 509, "published": "2012-01-25", "genres": ["Science Fiction", "Fiction", "Dystopia", "Post Apocalyptic", "Audiobook"], "averageRating": 4.22, "ratingCount": 297608, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1349614200i/13453029.jpg" }, - "17306293": { "authors": ["Hugh Howey"], "cover": "/covers/books/17306293.jpg", "description": "Shift goes back to show the first days of the Silo, and the beginning of the end In 2007, the Center for Automation in Nanobiotech (CAN) outlined the hardware and software platforms that would one day allow robots smaller than human cells to make medical diagnoses, conduct repairs, and even self-propagate. In the same year, the CBS network re-aired a program about the effects of propranolol on sufferers of extreme trauma. A simple pill, it had been discovered, could wipe out the memory of any traumatic event. At almost the same moment in humanity’s broad history, humankind discovered the...", "pages": 579, "published": "2013-01-28", "genres": ["Science Fiction", "Fiction", "Dystopia", "Audiobook", "Post Apocalyptic"], "averageRating": 4.12, "ratingCount": 171992, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1379003176i/17306293.jpg" }, - "18866705": { "authors": ["Hugh Howey"], "cover": "/covers/books/18866705.jpg", "description": "The much-anticipated final instalment of the Wool trilogy.\n\nIn the aftermath of the uprising, the people of Silo 18 are coming to terms with a new order. \n\nSome embrace the change, others fear the unknown; none have control of their fate.\n\nThe Silo is still in danger.\n\nThere are those set on its destruction.\n\nJules knows they must be stopped.\n\nThe battle has been won.\n\nThe war is just beginning.", "pages": 416, "published": "2013-08-17", "genres": ["Science Fiction", "Fiction", "Dystopia", "Post Apocalyptic", "Audiobook"], "averageRating": 4.22, "ratingCount": 153493, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1436806122i/18866705.jpg" }, - "37781": { "authors": ["Chinua Achebe"], "cover": "/covers/books/37781.jpg", "description": "“A true classic of world literature . . . A masterpiece that has inspired generations of writers in Nigeria, across Africa, and around the world.” —Barack Obama\n\n“African literature is incomplete and unthinkable without the works of Chinua Achebe.” —Toni Morrison\n\n\"A magical writer—one of the greats of the twentieth century.\" —Margaret Atwood\n\nNamed one of America's most-loved novels by PBS’s The Great American Read\n\nThings Fall Apart is the first of three novels in Chinua Achebe's critically acclaimed African Trilogy. It is a classic narrative about Africa's cataclysmic encounter with Europe...", "pages": 215, "published": "1994-10-01", "genres": ["Fiction", "Classics", "Historical Fiction", "Africa", "School"], "averageRating": 3.75, "ratingCount": 428943, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1708114013i/37781.jpg" }, - "37774": { "authors": ["Chinua Achebe"], "cover": "/covers/books/37774.jpg", "description": "Set in the Igbo heartland of eastern Nigeria, one of Africa's best-known writers describes the conflict between old and new in its most poignant aspect: the personal struggle between father and son.\n\nEzeulu, the headstrong chief priest of the god Ulu, is worshipped by the six villages of Umuaro. But his authority is increasingly under threat—from rivals within his tribe, from functionaries of the colonial government, and even from his own family members. Yet he believes himself to be untouchable: surely he is an arrow in the bow of his God? Armed with this belief, he is prepared to lead his...", "pages": 230, "published": "1989-01-01", "genres": ["Fiction", "Africa", "Classics", "Historical Fiction", "African Literature"], "averageRating": 3.79, "ratingCount": 9927, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1708114068i/37774.jpg" }, - "111085": { "authors": ["Chinua Achebe"], "cover": "/covers/books/111085.jpg", "description": "An alternate cover for this ISBN can be found here.\n\nObi Okonkwo is an idealistic young man who has now returned to Nigeria for a job in the civil service. However in his new role he finds that the way of government seems to be corruption. Obi manages to resist the bribes offered to him, but when he falls in love with an unsuitable girl, he sinks further into emotional and financial turmoil.", "pages": 196, "published": "1994-09-16", "genres": ["Fiction", "Africa", "Classics", "Historical Fiction", "Nigeria"], "averageRating": 3.87, "ratingCount": 13698, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1708114041i/111085.jpg" }, - "22875069": { "authors": ["Caitlin Alifirenka", "Liz Welch", "Martin Ganda"], "cover": "/covers/books/22875069.jpg", "description": "The true story of an all-American girl and a boy from an impoverished city in Zimbabwe and the letter that changed both of their lives forever.\n\nIt started as an assignment. Everyone in Caitlin's class wrote to an unknown student somewhere in a distant place. All the other kids picked countries like France or Germany, but when Caitlin saw Zimbabwe written on the board, it sounded like the most exotic place she had ever heard of--so she chose it. \nMartin was lucky to even receive a pen pal letter. There were only ten letters, and forty kids in his class. But he was the top student, so he got...", "pages": 392, "published": "2015-04-14", "genres": ["Nonfiction", "Memoir", "Book Club", "Audiobook", "Biography"], "averageRating": 4.38, "ratingCount": 31650, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1415206347i/22875069.jpg" }, - "33118312": { "authors": ["Alan Gratz"], "cover": "/covers/books/33118312.jpg", "description": "JOSEF is a Jewish boy living in 1930s Nazi Germany. With the threat of concentration camps looming, he and his family board a ship bound for the other side of the world...\n\nISABEL is a Cuban girl in 1994. With riots and unrest plaguing her country, she and her family set out on a raft, hoping to find safety in America...\n\nMAHMOUD is a Syrian boy in 2015. With his homeland torn apart by violence and destruction, he and his family begin a long trek toward Europe...\n\nAll three kids go on harrowing journeys in search of refuge. All will face unimaginable dangers -- from drownings to bombings to...", "pages": 352, "published": "2017-07-25", "genres": ["Historical Fiction", "Young Adult", "Middle Grade", "Fiction", "Historical"], "averageRating": 4.35, "ratingCount": 87957, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1487620933i/33118312.jpg" }, - "1381": { "authors": ["Homer"], "cover": "/covers/books/1381.jpg", "description": "Sing to me of the man, Muse, the man of twists and turns\ndriven time and again off course, once he had plundered\nthe hallowed heights of Troy.\n\nSo begins Robert Fagles's magnificent translation of the Odyssey.\n\nIf The Iliad is the world's greatest war epic, The Odyssey is literature's grandest evocation of the everyman's journey through life. During his ten-year voyage home to Ithaca after the Trojan War, Odysseus relies on his wit and wiles for survival in his encounters with divine and natural forces.\n\nIn the myths and legends retold here, Fagles has captured the energy and poetry of...", "pages": 541, "published": "2006-10-31", "genres": ["Classics", "Fiction", "Poetry", "Mythology", "Fantasy"], "averageRating": 3.86, "ratingCount": 1299443, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1711957706i/1381.jpg" }, - "38631641": { "authors": ["Homer"], "cover": "/covers/books/38631641.jpg", "description": "The Iliad Ancient Greek: Ilias, pronounced [i?.li.as] in Classical Attic; sometimes referred to as the Song of Ilion or Song of Ilium) is an ancient Greek epic poem in dactylic hexameter, traditionally attributed to Homer. Set during the Trojan War, the ten-year siege of the city of Troy (Ilium) by a coalition of Greek states, it tells of the battles and events during the weeks of a quarrel between King Agamemnon and the warrior Achilles.", "pages": 734, "published": "2018-02-17", "genres": ["Classics", "Poetry", "Fiction", "Mythology", "Literature"], "averageRating": 3.94, "ratingCount": 542114, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1519449125i/38631641.jpg" }, - "35031085": { "authors": ["Mary Wollstonecraft Shelley"], "cover": "/covers/books/35031085.jpg", "description": "Mary Shelley's seminal novel of the scientist whose creation becomes a monster. \n\nThis edition is the original 1818 text, which preserves the hard-hitting and politically charged aspects of Shelley's original writing, as well as her unflinching wit and strong female voice. This edition also includes a new introduction and suggestions for further reading by author and Shelley expert Charlotte Gordon, literary excerpts and reviews selected by Gordon and a chronology and essay by preeminent Shelley scholar Charles E. Robinson.", "pages": 260, "published": "2018-01-16", "genres": ["Classics", "Horror", "Fiction", "Science Fiction", "Gothic"], "averageRating": 3.93, "ratingCount": 2031053, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1631088473i/35031085.jpg" }, - "41733839": { "authors": ["F. Scott Fitzgerald"], "cover": "/covers/books/41733839.jpg", "description": "Alternate covers of this ISBN can be found here, here and here.\n\nJames L. W. West III to include the author’s final revisions and features a note on the composition and text, a personal foreword by Fitzgerald’s granddaughter, Eleanor Lanahan—and a new introduction by two-time National Book Award winner Jesmyn Ward. \n\nThe Great Gatsby, F. Scott Fitzgerald’s third book, stands as the supreme achievement of his career. First published in 1925, this quintessential novel of the Jazz Age has been acclaimed by generations of readers. The story of the mysteriously wealthy Jay Gatsby and his love for...", "pages": 180, "published": "2018-04-01", "genres": ["Classics", "Fiction", "School", "Historical Fiction", "Romance"], "averageRating": 3.92, "ratingCount": 6178246, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1650033243i/41733839.jpg" }, - "192566704": { "authors": ["S.E. Hinton"], "cover": "/covers/books/192566704.jpg", "description": "No one ever said life was easy. But Ponyboy is pretty sure that he's got things figured out. He knows that he can count on his brothers, Darry and Sodapop. And he knows that he can count on his friends - true friends who would do anything for him, like Johnny and Two-Bit. And when it comes to the beating up on \"greasers\" like him and his friends - he knows that he can count on them for trouble. But one night someone takes things too far, and Ponyboy's world is turned upside down...", "pages": 214, "published": "2021-01-01", "genres": ["Classics", "Young Adult", "Fiction", "School", "Read For School"], "averageRating": 4.15, "ratingCount": 1702819, "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1689626520i/192566704.jpg" }, - "6186357": { "authors": ["James Dashner"], "cover": "/covers/books/6186357.jpg", "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1375596592i/6186357.jpg", "description": "There are alternate cover editions for this ASIN here and here.\n\nIf you ain’t scared, you ain’t human.\n \nWhen Thomas wakes up in the lift, the only thing he can remember is his name. He’s surrounded by strangers—boys whose memories are also gone.\n \nNice to meet ya, shank. Welcome to the Glade.\n \nOutside the towering stone walls that surround the Glade is a limitless, ever-changing maze. It’s the only way out—and no one’s ever made it through alive.\n \nEverything is going to change.\n \nThen a girl arrives. The first girl ever. And the message she delivers is terrifying.\n \nRemember. Survive. Run.", "pages": 384, "published": "2009-10-06", "genres": ["Young Adult", "Dystopia", "Science Fiction", "Fiction", "Fantasy"], "averageRating": 4.06, "ratingCount": 1757932 }, - "7631105": { "authors": ["James Dashner"], "cover": "/covers/books/7631105.jpg", "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1388240248i/7631105.jpg", "description": "Solving the Maze was supposed to be the end. \n  \n Thomas was sure that escape from the Maze would mean freedom for him and the Gladers. But WICKED isn’t done yet. Phase Two has just begun. The Scorch. \n  \nThere are no rules. There is no help. You either make it or you die.\n  \n The Gladers have two weeks to cross through the Scorch—the most burned-out section of the world. And WICKED has made sure to adjust the variables and stack the odds against them. \n  \nFriendships will be tested. Loyalties will be broken. All bets are off. \n  \n There are others now. Their survival depends on the Gladers’...", "pages": 362, "published": "2010-10-12", "genres": ["Young Adult", "Dystopia", "Science Fiction", "Fiction", "Fantasy"], "averageRating": 3.9, "ratingCount": 670952 }, - "7864437": { "authors": ["James Dashner"], "cover": "/covers/books/7864437.jpg", "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1303997647i/7864437.jpg", "description": "It’s the end of the line.\n \nWICKED has taken everything from Thomas: his life, his memories, and now his only friends—the Gladers. But it’s finally over. The trials are complete, after one final test.\n \nWill anyone survive?\n \nWhat WICKED doesn’t know is that Thomas remembers far more than they think. And it’s enough to prove that he can’t believe a word of what they say.\n \nThe truth will be terrifying.\n \nThomas beat the Maze. He survived the Scorch. He’ll risk anything to save his friends. But the truth might be what ends it all. \n \nThe time for lies is over.", "pages": 325, "published": "2011-10-11", "genres": ["Young Adult", "Dystopia", "Science Fiction", "Fiction", "Fantasy"], "averageRating": 3.79, "ratingCount": 519019 }, - "40495148": { "authors": ["José Saramago"], "cover": "/covers/books/40495148.jpg", "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1528481068i/40495148.jpg", "description": "From Nobel Prize–winning author José Saramago, a magnificent, mesmerizing parable of loss\n\nA city is hit by an epidemic of \"white blindness\" that spares no one. Authorities confine the blind to an empty mental hospital, but there the criminal element holds everyone captive, stealing food rations, and assaulting women. There is one eyewitness to this nightmare who guides her charges—among them a boy with no mother, a girl with dark glasses, a dog of tears—through the barren streets, and their procession becomes as uncanny as the surroundings are harrowing. As Blindness reclaims the age-old...", "pages": 349, "published": "2013-08-23", "genres": ["Fiction", "Dystopia", "Classics", "Science Fiction", "Portugal"], "averageRating": 4.19, "ratingCount": 368227 }, - "61102615": { "authors": ["Natalie Haynes"], "cover": "/covers/books/61102615.jpg", "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1664995844i/61102615.jpg", "description": "A fresh take on the story of Medusa, the original monstered woman.\n\nThey will fear you and flee you and call you a monster. \n\nThe only mortal in a family of gods, Medusa is the youngest of the Gorgon sisters. Unlike her siblings, Medusa grows older, experiences change, feels weakness. Her mortal lifespan gives her an urgency that her family will never know.\n\nWhen the sea god Poseidon assaults Medusa in Athene's temple, the goddess is enraged. Furious by the violation of her sacred space, Athene takes revenge--on the young woman. Punished for Poseidon's actions, Medusa is forever transformed....", "pages": 373, "published": "2023-02-07", "genres": ["Mythology", "Fantasy", "Greek Mythology", "Fiction", "Historical Fiction"], "averageRating": 3.77, "ratingCount": 86690 }, - "34454589": { "authors": ["Margaret Atwood"], "cover": "/covers/books/34454589.jpg", "coverSource": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1488552336i/34454589.jpg", "description": "Now a Hulu series starring Elizabeth Moss. The Handmaid's Tale is an instant classic and eerily prescient cultural phenomenon, from \"the patron saint of feminist dystopian fiction\" (New York Times)\n\nThe Handmaid’s Tale is a novel of such power that the reader will be unable to forget its images and its forecast. Set in the near future, it describes life in what was once the United States and is now called the Republic of Gilead, a monotheocracy that has reacted to social unrest and a sharply declining birthrate by reverting to, and going beyond, the repressive intolerance of the original...", "pages": 320, "published": "2017-04-25", "genres": ["Fiction", "Classics", "Dystopia", "Science Fiction", "Feminism"], "averageRating": 4.16, "ratingCount": 2583718 } -} diff --git a/data/gameDetails.json b/data/gameDetails.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/data/gameDetails.json @@ -0,0 +1 @@ +{} diff --git a/data/mediaDetails.json b/data/mediaDetails.json deleted file mode 100644 index 28ca82d..0000000 --- a/data/mediaDetails.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "tt0050083": { "title": "12 Angry Men", "poster": "/covers/media/tt0050083.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BYjE4NzdmOTYtYjc5Yi00YzBiLWEzNDEtNTgxZGQ2MWVkN2NiXkEyXkFqcGc@._V1_QL75_UX380_CR0,11,380,562_.jpg", "year": "1957", "genre": "Crime, Drama", "runtime": "96 min", "plot": "The jury in a New York City murder trial is frustrated by a single member whose skeptical caution forces them to more carefully consider the evidence before jumping to a hasty verdict." }, - "tt0083658": { "title": "Blade Runner", "poster": "/covers/media/tt0083658.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BOWQ4YTBmNTQtMDYxMC00NGFjLTkwOGQtNzdhNmY1Nzc1MzUxXkEyXkFqcGc@._V1_QL75_UX380_CR0,2,380,562_.jpg", "year": "1982", "genre": "Action, Drama, Sci-Fi", "runtime": "117 min", "plot": "A blade runner must pursue and terminate four replicants who stole a ship in space and have returned to Earth to find their creator." }, - "tt0111161": { "title": "The Shawshank Redemption", "poster": "/covers/media/tt0111161.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMDAyY2FhYjctNDc5OS00MDNlLThiMGUtY2UxYWVkNGY2ZjljXkEyXkFqcGc@._V1_QL75_UX380_CR0,4,380,562_.jpg", "year": "1994", "genre": "Drama", "runtime": "142 min", "plot": "After a banker is sentenced to life in Shawshank Prison, he forms an unlikely friendship with a seasoned inmate and clings to hope amid cruelty and corruption." }, - "tt0364569": { "title": "Oldboy", "poster": "/covers/media/tt0364569.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMTAwNzNjYWItZmI0Ni00ZTcyLWIwNWMtZjlmNGMxZTEyYTJmXkEyXkFqcGc@._V1_QL75_UX380_CR0,4,380,562_.jpg", "year": "2003", "genre": "Action, Drama, Mystery", "runtime": "120 min", "plot": "After being kidnapped and imprisoned for fifteen years, Oh Dae-Su is released, only to find that he must track down his captor in five days." }, - "tt0499549": { "title": "Avatar", "poster": "/covers/media/tt0499549.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMDEzMmQwZjctZWU2My00MWNlLWE0NjItMDJlYTRlNGJiZjcyXkEyXkFqcGc@._V1_QL75_UY562_CR1,0,380,562_.jpg", "year": "2009", "genre": "Action, Adventure, Fantasy", "runtime": "162 min", "plot": "A paraplegic Marine dispatched to the moon Pandora on a unique mission becomes torn between following his orders and protecting the world he feels is his home." }, - "tt0816692": { "title": "Interstellar", "poster": "/covers/media/tt0816692.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BYzdjMDAxZGItMjI2My00ODA1LTlkNzItOWFjMDU5ZDJlYWY3XkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2014", "genre": "Adventure, Sci-Fi", "runtime": "169 min", "plot": "In a dystopian future where Earth has become near-uninhabitable, a team of astronauts embark on a mission to find a new home for humanity." }, - "tt0892769": { "title": "How to Train Your Dragon", "poster": "/covers/media/tt0892769.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMjA5NDQyMjc2NF5BMl5BanBnXkFtZTcwMjg5ODcyMw@@._V1_QL75_UX380_CR0,1,380,562_.jpg", "year": "2010", "genre": "Animation, Action, Adventure", "runtime": "98 min", "plot": "A hapless young Viking who aspires to hunt dragons becomes the unlikely friend of a young dragon himself, and learns there may be more to the creatures than he assumed." }, - "tt10795658": { "title": "Alice in Borderland", "poster": "/covers/media/tt10795658.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BYmMzODA4M2MtMjBmNC00YjFhLTg4NzgtODgyNzE4NWQxMThmXkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2020–2025", "genre": "Action, Drama, Horror", "runtime": "50 min", "plot": "Arisu - a listless, jobless and video-game-obsessed young man - suddenly finds himself in a strange, emptied-out version of Tokyo in which he and his friends must compete in dangerous games in order to survive." }, - "tt11126994": { "title": "Arcane", "poster": "/covers/media/tt11126994.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BYjA2NzhlMDItNWRmZC00MzRjLWE3ZjAtZjBlZDAwOWY2ODdjXkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2021–2024", "genre": "Animation, Action, Adventure", "runtime": null, "plot": "Amid the stark discord of twin cities Piltover and Zaun, two sisters fight on rival sides of a war between magic technologies and clashing convictions." }, - "tt11280740": { "title": "Severance", "poster": "/covers/media/tt11280740.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BZDI5YzJhODQtMzQyNy00YWNmLWIxMjUtNDBjNjA5YWRjMzExXkEyXkFqcGc@._V1_QL75_UX380_CR0,4,380,562_.jpg", "year": "2022–", "genre": "Drama, Mystery, Sci-Fi", "runtime": null, "plot": "Mark leads a team of office workers whose memories have been surgically divided between their work and personal lives. When a mysterious colleague appears outside of work, it begins a journey to discover the truth about their jobs." }, - "tt1160419": { "title": "Dune", "poster": "/covers/media/tt1160419.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BNWIyNmU5MGYtZDZmNi00ZjAwLWJlYjgtZTc0ZGIxMDE4ZGYwXkEyXkFqcGc@._V1_QL75_UY562_CR1,0,380,562_.jpg", "year": "2021", "genre": "Action, Adventure, Drama", "runtime": "155 min", "plot": "Paul Atreides arrives on Arrakis after his father accepts the stewardship of the dangerous planet. However, chaos ensues after a betrayal as forces clash to control melange, a precious resource." }, - "tt12042730": { "title": "Project Hail Mary", "poster": "/covers/media/tt12042730.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BNTkwNzJiYTctNzI3NC00NjE1LTlhYjktY2Q5MTdmMWFmNzcxXkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2026", "genre": "Adventure, Comedy, Sci-Fi", "runtime": "156 min", "plot": "A science teacher wakes up alone on a spaceship. As his memory returns, he uncovers a mission to stop a mysterious substance killing Earth's sun, and realizes that an unexpected friendship may be the key." }, - "tt12590266": { "title": "Cyberpunk: Edgerunners", "poster": "/covers/media/tt12590266.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BNGNiZjJkZmItYjJhYy00N2M5LTg4ZmYtMzUxZWI0ZTU2MmEzXkEyXkFqcGc@._V1_QL75_UY562_CR35,0,380,562_.jpg", "year": "2022–", "genre": "Animation, Action, Adventure", "runtime": null, "plot": "A Street Kid trying to survive in a technology and body modification-obsessed city of the future. Having everything to lose, he chooses to stay alive by becoming an Edgerunner, a Mercenary outlaw also known as a Cyberpunk." }, - "tt13024974": { "title": "D.P.", "poster": "/covers/media/tt13024974.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BZjE3OWU5ZDctYTE0My00ODQ4LWE1MGUtYjgwYzg1YTUxMmY3XkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2021–", "genre": "Action, Crime, Drama", "runtime": null, "plot": "A young private's assignment to capture army deserters reveals the painful reality endured by each enlistee during his compulsory call of duty." }, - "tt14403178": { "title": "Beef", "poster": "/covers/media/tt14403178.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMGNlNDkyYzItNTg5Yy00NGFiLWJhYTctODE5NjNkYjk4ZDUwXkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2023–", "genre": "Comedy, Drama", "runtime": null, "plot": "Minor conflicts spiral into consuming feuds between individuals, exposing pride, resentment, and hidden pain." }, - "tt1454468": { "title": "Gravity", "poster": "/covers/media/tt1454468.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BNjE5MzYwMzYxMF5BMl5BanBnXkFtZTcwOTk4MTk0OQ@@._V1_QL75_UY562_CR0,0,380,562_.jpg", "year": "2013", "genre": "Drama, Sci-Fi, Thriller", "runtime": "91 min", "plot": "Two astronauts must survive in space after their shuttle is hit by debris during a spacewalk." }, - "tt14688458": { "title": "Silo", "poster": "/covers/media/tt14688458.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BN2E5ODJmOTUtYTk0NS00NmFiLTk0MTgtZDRlNzE1YmExMjk4XkEyXkFqcGc@._V1_QL75_UX380_CR0,4,380,562_.jpg", "year": "2023–", "genre": "Adventure, Drama, Mystery", "runtime": null, "plot": "Men and women live in a giant silo underground with several regulations which they believe are in place to protect them from the toxic and ruined world on the surface." }, - "tt15239678": { "title": "Dune: Part Two", "poster": "/covers/media/tt15239678.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BNTc0YmQxMjEtODI5MC00NjFiLTlkMWUtOGQ5NjFmYWUyZGJhXkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2024", "genre": "Action, Adventure, Drama", "runtime": "166 min", "plot": "Paul Atreides unites with the Fremen while on a warpath of revenge against the conspirators who destroyed his family. Facing a choice between the love of his life and the fate of the universe, he endeavors to prevent a terrible fu..." }, - "tt15398776": { "title": "Oppenheimer", "poster": "/covers/media/tt15398776.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BN2JkMDc5MGQtZjg3YS00NmFiLWIyZmQtZTJmNTM5MjVmYTQ4XkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2023", "genre": "Biography, Drama, History", "runtime": "180 min", "plot": "A dramatization of the life story of J. Robert Oppenheimer, the physicist who had a large hand in the development of the atomic bombs that brought an end to World War II." }, - "tt1630029": { "title": "Avatar: The Way of Water", "poster": "/covers/media/tt1630029.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BNWI0Y2NkOWEtMmM2OC00MjQ3LWI1YzItZGQxYzQ3NzI4NWZmXkEyXkFqcGc@._V1_QL75_UY562_CR6,0,380,562_.jpg", "year": "2022", "genre": "Action, Adventure, Fantasy", "runtime": "192 min", "plot": "Jake Sully lives with his newfound family formed on the extrasolar moon Pandora. Once a familiar threat returns to finish what was previously started, Jake must work with Neytiri and the army of the Na'vi race to protect their home." }, - "tt16311594": { "title": "F1: The Movie", "poster": "/covers/media/tt16311594.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BNGI0MDI4NjEtOWU3ZS00ODQyLWFhYTgtNGYxM2ZkM2Q2YjE3XkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2025", "genre": "Drama, Sport", "runtime": "155 min", "plot": "A Formula One driver comes out of retirement to mentor and team up with a younger driver." }, - "tt1646971": { "title": "How to Train Your Dragon 2", "poster": "/covers/media/tt1646971.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMzMwMTAwODczN15BMl5BanBnXkFtZTgwMDk2NDA4MTE@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2014", "genre": "Animation, Action, Adventure", "runtime": "102 min", "plot": "When Hiccup and Toothless discover an ice cave that is home to hundreds of new wild dragons and the mysterious Dragon Rider, the two friends find themselves at the center of a battle to protect the peace." }, - "tt1757678": { "title": "Avatar: Fire and Ash", "poster": "/covers/media/tt1757678.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BZDYxY2I1OGMtN2Y4MS00ZmU1LTgyNDAtODA0MzAyYjI0N2Y2XkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2025", "genre": "Action, Adventure, Drama", "runtime": "197 min", "plot": "Jake and Neytiri's family grapples with grief, encountering a new, aggressive Na'vi tribe, the Ash People, who are led by the fiery Varang, as the conflict on Pandora escalates and a new moral focus emerges." }, - "tt1856101": { "title": "Blade Runner 2049", "poster": "/covers/media/tt1856101.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BNzA1Njg4NzYxOV5BMl5BanBnXkFtZTgwODk5NjU3MzI@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2017", "genre": "Action, Drama, Mystery", "runtime": "164 min", "plot": "Young Blade Runner K's discovery of a long-buried secret leads him to track down former Blade Runner Rick Deckard, who's been missing for thirty years." }, - "tt1865718": { "title": "Gravity Falls", "poster": "/covers/media/tt1865718.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMTEzNDc3MDQ2NzNeQTJeQWpwZ15BbWU4MDYzMzUwMDIx._V1_QL75_UX380_CR0,15,380,562_.jpg", "year": "2012–2016", "genre": "Animation, Adventure, Comedy", "runtime": "23 min", "plot": "Twin siblings Dipper and Mabel Pines spend the summer at their great-uncle's tourist trap in the enigmatic Gravity Falls, Oregon." }, - "tt1950186": { "title": "Ford v Ferrari", "poster": "/covers/media/tt1950186.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BOTBjNTEyNjYtYjdkNi00YzE5LTljYzUtZjVlYmYwZmJmZWYxXkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2019", "genre": "Action, Biography, Drama", "runtime": "152 min", "plot": "American car designer Carroll Shelby and driver Ken Miles battle corporate interference and the laws of physics to build a revolutionary race car for Ford in order to defeat Ferrari at the 24 Hours of Le Mans in 1966." }, - "tt1979320": { "title": "Rush", "poster": "/covers/media/tt1979320.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMTZhOGQxM2ItNGQyYy00YzE5LWI5MjMtNmMzNGQzNDE1OTUzXkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2013", "genre": "Biography, Drama, Sport", "runtime": "123 min", "plot": "James Hunt and Niki Lauda, two extremely skilled Formula One racers, have an intense rivalry with each other. However, it is their enmity that pushes them to their limits." }, - "tt2386490": { "title": "How to Train Your Dragon: The Hidden World", "poster": "/covers/media/tt2386490.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMjIwMDIwNjAyOF5BMl5BanBnXkFtZTgwNDE1MDc2NTM@._V1_QL75_UX380_CR0,20,380,562_.jpg", "year": "2019", "genre": "Animation, Action, Adventure", "runtime": "104 min", "plot": "When Hiccup discovers Toothless isn't the only Night Fury, he must seek the Hidden World, a secret Dragon Utopia before a hired tyrant named Grimmel finds it first." }, - "tt2397535": { "title": "Predestination", "poster": "/covers/media/tt2397535.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BY2VhODM5OTUtZDJhMi00MTc5LThjNjYtZWY1M2NlNWU0N2NjXkEyXkFqcGc@._V1_QL75_UX380_CR0,1,380,562_.jpg", "year": "2015", "genre": "Action, Drama, Sci-Fi", "runtime": "97 min", "plot": "As his last assignment, a temporal agent is tasked to travel back in time and prevent a bomb attack in New York in 1975. The hunt, however, turns out to be beyond the bounds of possibility." }, - "tt2582802": { "title": "Whiplash", "poster": "/covers/media/tt2582802.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMDFjOWFkYzktYzhhMC00NmYyLTkwY2EtYjViMDhmNzg0OGFkXkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2014", "genre": "Drama, Music", "runtime": "106 min", "plot": "A promising young drummer enrolls at a cut-throat music conservatory where his dreams of greatness are mentored by an instructor who will stop at nothing to realize a student's potential." }, - "tt29623480": { "title": "The Wild Robot", "poster": "/covers/media/tt29623480.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BZWNiZjVlZTUtNGUwYi00MjJmLTg2MDctNWEzYTJiMzY1ODc4XkEyXkFqcGc@._V1_QL75_UX380_CR0,20,380,562_.jpg", "year": "2024", "genre": "Animation, Adventure, Family", "runtime": "102 min", "plot": "A sentient robot stranded on an uninhabited island raises an orphaned gosling while trying to fit in with the local wildlife." }, - "tt30460310": { "title": "Spider-Noir", "poster": "/covers/media/tt30460310.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BYjU3NjEwNTItMzc1Mi00Y2QyLTljNDItOTQ4YjU4NjZjN2EyXkEyXkFqcGc@._V1_QL75_UX380_CR0,4,380,562_.jpg", "year": "2026", "genre": "Action, Adventure, Crime", "runtime": null, "plot": "Ben Reilly, a seasoned, down-on-his-luck private investigator in 1930s New York, is forced to grapple with his past life, following a deeply personal tragedy, as the city's one and only superhero." }, - "tt3659388": { "title": "The Martian", "poster": "/covers/media/tt3659388.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMTc2MTQ3MDA1Nl5BMl5BanBnXkFtZTgwODA3OTI4NjE@._V1_QL75_UY562_CR0,0,380,562_.jpg", "year": "2015", "genre": "Adventure, Drama, Sci-Fi", "runtime": "144 min", "plot": "An astronaut stranded on Mars must rely on his ingenuity to survive and arrange a potential rescue." }, - "tt3890160": { "title": "Baby Driver", "poster": "/covers/media/tt3890160.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMjM3MjQ1MzkxNl5BMl5BanBnXkFtZTgwODk1ODgyMjI@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2017", "genre": "Action, Crime, Drama", "runtime": "113 min", "plot": "Coerced into working for a crime boss, a young getaway driver must face the music when a doomed heist threatens his life, love, and freedom." }, - "tt4495098": { "title": "Gran Turismo", "poster": "/covers/media/tt4495098.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMjA0N2YyNmYtZDk4Ny00ODE2LThmZWQtNGJiMDk0YzhiNzE5XkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2023", "genre": "Action, Adventure, Drama", "runtime": "134 min", "plot": "Based on the unbelievable, inspiring true story of a team of underdogs - a struggling, working-class gamer, a failed former race car driver, and an idealistic motorsport exec - who risk it all to take on the most elite sport in th..." }, - "tt4633694": { "title": "Spider-Man: Into the Spider-Verse", "poster": "/covers/media/tt4633694.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BMjMwNDkxMTgzOF5BMl5BanBnXkFtZTgwNTkwNTQ3NjM@._V1_QL75_UX380_CR0,1,380,562_.jpg", "year": "2018", "genre": "Animation, Action, Adventure", "runtime": "117 min", "plot": "Teen Miles Morales becomes the Spider-Man of his universe and must join with five spider-powered individuals from other dimensions to stop a threat for all realities." }, - "tt6741278": { "title": "Invincible", "poster": "/covers/media/tt6741278.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BZGM5MmM2ZDAtMzBjNS00NjMxLWE4ZTMtNzFjMWMxYzU2ZThjXkEyXkFqcGc@._V1_QL75_UY562_CR35,0,380,562_.jpg", "year": "2021–", "genre": "Animation, Action, Adventure", "runtime": null, "plot": "When 17-year-old Mark Grayson inherits superpowers, he becomes the superhero Invincible." }, - "tt8111088": { "title": "The Mandalorian", "poster": "/covers/media/tt8111088.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BNjgxZGM0OWUtZGY1MS00MWRmLTk2N2ItYjQyZTI1OThlZDliXkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2019–2023", "genre": "Action, Adventure, Fantasy", "runtime": "40 min", "plot": "The travels of a lone bounty hunter in the outer reaches of the galaxy, far from the authority of the New Republic." }, - "tt9140554": { "title": "Loki", "poster": "/covers/media/tt9140554.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BYzA2YjM2ZWQtYTZhMS00OTI3LTlhYzQtZjBiZWZkMDdlNjA5XkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2021–2023", "genre": "Action, Adventure, Fantasy", "runtime": null, "plot": "The mercurial villain Loki resumes his role as the God of Mischief in a new series that takes place after the events of “Avengers: Endgame.”" }, - "tt9253284": { "title": "Andor", "poster": "/covers/media/tt9253284.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BNGI2MTJjMjUtMTJhOC00YTY2LTg1NjUtMTdmMjg4YTk2YjM5XkEyXkFqcGc@._V1_QL75_UX380_CR0,0,380,562_.jpg", "year": "2022–2025", "genre": "Adventure, Drama, Fantasy", "runtime": null, "plot": "In an era filled with danger, deception, and intrigue, Cassian Andor embarks on a path that is destined to turn him into a Rebel hero." }, - "tt9362722": { "title": "Spider-Man: Across the Spider-Verse", "poster": "/covers/media/tt9362722.jpg", "posterSource": "https://m.media-amazon.com/images/M/MV5BNThiZjA3MjItZGY5Ni00ZmJhLWEwN2EtOTBlYTA4Y2E0M2ZmXkEyXkFqcGc@._V1_QL75_UX380_CR0,1,380,562_.jpg", "year": "2023", "genre": "Animation, Action, Adventure", "runtime": "140 min", "plot": "Traveling across the multiverse, Miles Morales meets a new team of Spider-People, made up of heroes from different dimensions. But when the heroes clash over how to deal with a new threat, Miles finds himself at a crossroads." } -} diff --git a/docker-compose.yml b/docker-compose.yml index 6820bdf..a0e9b57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,11 +6,13 @@ services: env_file: - .env environment: - PORT: 3000 + PORT: 5000 ports: - - "${HOST_PORT:-3000}:3000" + - "${HOST_PORT:-5000}:5000" volumes: - lastfm-data:/app/data/lastfm + - library-data:/app/data/library volumes: lastfm-data: + library-data: diff --git a/src/env.ts b/src/env.ts index 77624b0..30f750a 100644 --- a/src/env.ts +++ b/src/env.ts @@ -17,10 +17,14 @@ export const variables = defineEnvVars({ }, OMDB_API_KEY: { schema: optional, - description: 'OMDb API key used to load posters and details for shows and movies' + description: 'OMDb API key the content tool uses to look up shows and movies' }, LASTFM_API_KEY: { schema: optional, description: 'Last.fm API key used to collect scrobbles for the listening stats' + }, + LIBRARY_API_KEY: { + schema: optional, + description: 'Secret key the content tool sends to add and change shows, movies and books' } }); diff --git a/src/lib/components/about/GameCard.svelte b/src/lib/components/about/GameCard.svelte index fb2753d..831ebe2 100644 --- a/src/lib/components/about/GameCard.svelte +++ b/src/lib/components/about/GameCard.svelte @@ -12,17 +12,41 @@ const host = new URL(game.url).hostname; return storeNames[host] ?? { label: host.replace(/^www\./, ''), icon: 'mdi:web' }; }); + + const details = $derived(game.details); + const image = $derived(game.image ?? details?.screenshot ?? details?.cover ?? null); + const releaseYear = $derived(details?.released?.match(/\d{4}/)?.[0] ?? null); + const metaLine = $derived([releaseYear, details?.developers[0]].filter(Boolean).join(' · ')); + const score = $derived( + details?.criticScore != null ? `${details.criticScore}%` : details?.mobyScore != null ? `${details.mobyScore}` : null + ); - +
- + {#if image} + + {:else} + + {/if} + {#if score} + {score} + {/if} Play
{game.name} {store.label}
+ {#if metaLine} + {metaLine} + {/if} + {#if details?.genres.length} + {details.genres.slice(0, 3).join(' · ')} + {/if} + {#if game.mobygames} + MobyGames + {/if}
diff --git a/src/lib/components/library/LibraryCard.svelte b/src/lib/components/library/LibraryCard.svelte new file mode 100644 index 0000000..87b5c69 --- /dev/null +++ b/src/lib/components/library/LibraryCard.svelte @@ -0,0 +1,126 @@ + + + + + diff --git a/src/lib/components/library/LibraryDetail.svelte b/src/lib/components/library/LibraryDetail.svelte new file mode 100644 index 0000000..1161256 --- /dev/null +++ b/src/lib/components/library/LibraryDetail.svelte @@ -0,0 +1,193 @@ + + + + + diff --git a/src/lib/components/library/LibraryGroup.svelte b/src/lib/components/library/LibraryGroup.svelte new file mode 100644 index 0000000..0ee3cad --- /dev/null +++ b/src/lib/components/library/LibraryGroup.svelte @@ -0,0 +1,180 @@ + + +
+ + + {#if expanded} +
+ {#each group.entries as entry (entry.id)} + onselect(entry.id)} + /> + {/each} +
+ {:else} +
+ {#each group.entries as entry, index (entry.id)} + + {/each} +
+ {/if} +
+ + diff --git a/src/lib/components/library/LibraryHistory.svelte b/src/lib/components/library/LibraryHistory.svelte new file mode 100644 index 0000000..af642e8 --- /dev/null +++ b/src/lib/components/library/LibraryHistory.svelte @@ -0,0 +1,115 @@ + + +{#if months.length} +
    + {#each months as month (month.key)} +
  1. +

    {month.label} {month.events.length}

    +
      + {#each month.events as event (event.kind + event.entry.id)} +
    • + +
    • + {/each} +
    +
  2. + {/each} +
+{:else} +

No history yet. Dates are saved when something is started or finished.

+{/if} + + diff --git a/src/lib/components/library/LibraryToolbar.svelte b/src/lib/components/library/LibraryToolbar.svelte new file mode 100644 index 0000000..f31e31c --- /dev/null +++ b/src/lib/components/library/LibraryToolbar.svelte @@ -0,0 +1,120 @@ + + +
+
+ + {#each statuses as status (status.id)} + + {/each} +
+ + + + +
+ + diff --git a/src/lib/content/index.ts b/src/lib/content/index.ts index c7d3e99..1e777c0 100644 --- a/src/lib/content/index.ts +++ b/src/lib/content/index.ts @@ -11,15 +11,15 @@ import hackathonsData from '#content/projects/hackathons.json'; import researchData from '#content/projects/research.json'; import organizationsData from '#content/projects/organizations.json'; import tcgDecksData from '#content/tcgDecks.json'; -import booksData from '#content/books.json'; -import bookDetailsData from '#data/bookDetails.json'; import wallpapersData from '#content/wallpapers.json'; +import gameDetailsData from '#data/gameDetails.json'; import type { AboutContent, BlogSettings, - Book, DesktopContent, Experience, + Game, + GameDetails, Leadership, Playlist, Profile, @@ -36,15 +36,22 @@ export type * from './types'; export const profile: Profile = profileData; export const experience: Experience[] = experienceData; export const leadership: Leadership[] = leadershipData; -export const about: AboutContent = aboutData as AboutContent; +const gameDetails = gameDetailsData as Record; +const mobygamesIdPattern = /mobygames\.com\/game\/(\d+)/; + +function withGameDetails(game: Game): Game { + const details = gameDetails[game.mobygames?.match(mobygamesIdPattern)?.[1] ?? '']; + return details ? { ...game, details } : game; +} + +const aboutContent = aboutData as AboutContent; + +export const about: AboutContent = { ...aboutContent, games: aboutContent.games.map(withGameDetails) }; export const desktop: DesktopContent = desktopData; export const blogSettings: BlogSettings = blogData; export const playlist: Playlist = musicData; export const researchGroups: ResearchGroup[] = researchData; export const tcgDecks: TcgDeck[] = tcgDecksData as TcgDeck[]; -const bookDetails = bookDetailsData as Record>; - -export const books: Book[] = booksData.map((book) => ({ ...bookDetails[book.goodreadsId], ...book }) as Book); export const wallpapers: Wallpaper[] = wallpapersData as Wallpaper[]; function featuredFirst(projects: Project[]): Project[] { diff --git a/src/lib/content/types.ts b/src/lib/content/types.ts index 61eed6e..7b21e25 100644 --- a/src/lib/content/types.ts +++ b/src/lib/content/types.ts @@ -127,10 +127,28 @@ export interface Hobby { description: string; } +export interface GameDetails { + url: string; + title: string; + released: string | null; + platforms: string[]; + developers: string[]; + publishers: string[]; + genres: string[]; + mobyScore: number | null; + criticScore: number | null; + playerScore: number | null; + description: string | null; + cover: string | null; + screenshot: string | null; +} + export interface Game { name: string; - image: string; + image?: string; url: string; + mobygames?: string; + details?: GameDetails; } export type WatchStatus = 'watched' | 'watching' | 'planning'; @@ -141,6 +159,9 @@ export interface RatedTitle { url: string; status?: WatchStatus; collection?: string; + added?: string; + started?: string; + finished?: string; } export type BookShelf = 'read' | 'reading' | 'want-to-read'; @@ -161,6 +182,8 @@ export interface Book { shelf: BookShelf; rating?: number; added?: string; + started?: string; + finished?: string; series?: BookSeries; } @@ -190,13 +213,13 @@ export interface AboutContent { setup?: DesktopSetup; hobbies: Hobby[]; games: Game[]; - shows: RatedTitle[]; - movies: RatedTitle[]; } export type GameName = 'snake' | 'breakout'; -export type DesktopApp = 'btop' | 'tcg' | 'pet' | GameName; +export type LibraryAppName = 'movies' | 'shows' | 'books'; + +export type DesktopApp = 'btop' | 'tcg' | 'pet' | GameName | LibraryAppName; export type DesktopAction = { page: string } | { terminal: string } | { url: string } | { app: DesktopApp }; diff --git a/src/lib/desktop/actions.ts b/src/lib/desktop/actions.ts index 9a51704..9f7097f 100644 --- a/src/lib/desktop/actions.ts +++ b/src/lib/desktop/actions.ts @@ -1,5 +1,5 @@ import { goto } from '$app/navigation'; -import { desktop, type DesktopAction, type DesktopApp, type Workspace } from '#lib/content'; +import { desktop, type DesktopAction, type DesktopApp, type LibraryAppName, type Workspace } from '#lib/content'; import { petState } from '#lib/pet/petState.svelte'; import { pageWindowId, windowManager } from './windows.svelte'; @@ -20,8 +20,15 @@ export function openPage(path: string) { goto(path); } +const libraryAppNames: DesktopApp[] = ['movies', 'shows', 'books']; + +function isLibraryApp(app: DesktopApp): app is LibraryAppName { + return libraryAppNames.includes(app); +} + export function openApp(app: DesktopApp) { - if (app === 'btop') windowManager.openSystemMonitor(); + if (isLibraryApp(app)) windowManager.openLibrary(app); + else if (app === 'btop') windowManager.openSystemMonitor(); else if (app === 'tcg') windowManager.openTcgBrowser(); else if (app === 'pet') petState.toggle(); else windowManager.openGame(app); diff --git a/src/lib/desktop/windows.svelte.ts b/src/lib/desktop/windows.svelte.ts index d4f9334..a72d266 100644 --- a/src/lib/desktop/windows.svelte.ts +++ b/src/lib/desktop/windows.svelte.ts @@ -1,4 +1,4 @@ -import type { GameName } from '#lib/content'; +import type { GameName, LibraryAppName } from '#lib/content'; export const pageWindowId = 'page'; @@ -26,6 +26,11 @@ export interface TcgBrowserWindow { id: string; } +export interface LibraryWindow { + id: string; + app: LibraryAppName; +} + export interface GameWindow { id: string; game: GameName; @@ -44,6 +49,7 @@ class WindowManager { systemMonitors = $state([]); games = $state([]); tcgBrowsers = $state([]); + libraryWindows = $state([]); floatingRects = $state>({}); focusedId = $state(pageWindowId); maximizedId = $state(null); @@ -54,6 +60,7 @@ class WindowManager { private nextSystemMonitorNumber = 1; private nextGameNumber = 1; private nextTcgBrowserNumber = 1; + private nextLibraryNumber = 1; get extraWindowIds(): string[] { return [ @@ -61,7 +68,8 @@ class WindowManager { ...this.imageViewers.map((viewer) => viewer.id), ...this.systemMonitors.map((monitor) => monitor.id), ...this.games.map((gameWindow) => gameWindow.id), - ...this.tcgBrowsers.map((browser) => browser.id) + ...this.tcgBrowsers.map((browser) => browser.id), + ...this.libraryWindows.map((libraryWindow) => libraryWindow.id) ]; } @@ -101,12 +109,19 @@ class WindowManager { this.focus(browser.id); } + openLibrary(app: LibraryAppName) { + const libraryWindow = { id: `${app}-${this.nextLibraryNumber++}`, app }; + this.libraryWindows.push(libraryWindow); + this.focus(libraryWindow.id); + } + closeWindow(id: string) { this.terminals = this.terminals.filter((terminal) => terminal.id !== id); this.imageViewers = this.imageViewers.filter((viewer) => viewer.id !== id); this.systemMonitors = this.systemMonitors.filter((monitor) => monitor.id !== id); this.games = this.games.filter((gameWindow) => gameWindow.id !== id); this.tcgBrowsers = this.tcgBrowsers.filter((browser) => browser.id !== id); + this.libraryWindows = this.libraryWindows.filter((libraryWindow) => libraryWindow.id !== id); delete this.floatingRects[id]; if (this.maximizedId === id) this.maximizedId = null; if (this.focusedId === id) this.focusedId = this.extraWindowIds.at(-1) ?? pageWindowId; diff --git a/src/lib/library/apps.ts b/src/lib/library/apps.ts new file mode 100644 index 0000000..f16dc89 --- /dev/null +++ b/src/lib/library/apps.ts @@ -0,0 +1,65 @@ +import type { LibraryAppName } from '#lib/content'; +import type { LibraryData } from './types'; +import { bookEntries, mediaEntries, type LibraryEntry } from './entries'; + +export interface LibraryStatus { + id: string; + label: string; + color: string; +} + +export interface LibraryApp { + name: LibraryAppName; + title: string; + icon: string; + itemLabel: string; + groupLabel: string; + statuses: LibraryStatus[]; + entries: (library: LibraryData) => LibraryEntry[]; +} + +const watchStatuses: LibraryStatus[] = [ + { id: 'watching', label: 'watching', color: 'blue' }, + { id: 'watched', label: 'watched', color: 'green' }, + { id: 'planning', label: 'planning', color: 'magenta' } +]; + +const readStatuses: LibraryStatus[] = [ + { id: 'reading', label: 'reading', color: 'blue' }, + { id: 'read', label: 'read', color: 'green' }, + { id: 'want-to-read', label: 'want to read', color: 'magenta' } +]; + +export const libraryApps: Record = { + movies: { + name: 'movies', + title: 'Movies', + icon: 'mdi:movie-open-outline', + itemLabel: 'movies', + groupLabel: 'Collections', + statuses: watchStatuses, + entries: (library) => mediaEntries(library.movies, library.mediaDetails, 'movie') + }, + shows: { + name: 'shows', + title: 'TV Shows', + icon: 'mdi:television-classic', + itemLabel: 'shows', + groupLabel: 'Series', + statuses: watchStatuses, + entries: (library) => mediaEntries(library.shows, library.mediaDetails, 'show') + }, + books: { + name: 'books', + title: 'Books', + icon: 'mdi:bookshelf', + itemLabel: 'books', + groupLabel: 'Series', + statuses: readStatuses, + entries: (library) => bookEntries(library.books) + } +}; + +export function statusOf(app: LibraryApp, id: string): LibraryStatus { + return app.statuses.find((status) => status.id === id) ?? app.statuses[1]; +} diff --git a/src/lib/library/entries.ts b/src/lib/library/entries.ts new file mode 100644 index 0000000..696a425 --- /dev/null +++ b/src/lib/library/entries.ts @@ -0,0 +1,88 @@ +import type { Book, RatedTitle } from '#lib/content'; +import type { MediaDetails, MediaKind } from '#lib/media/types'; + +export interface LibraryEntry { + id: string; + title: string; + subtitle: string; + image: string | null; + url: string; + status: string; + rating: number | null; + year: string | null; + genres: string[]; + description: string | null; + group: string | null; + groupPosition: string | null; + groupOrder: number; + facts: string[]; + added: string | null; + started: string | null; + finished: string | null; +} + +const unknownOrder = Number.MAX_SAFE_INTEGER; +const rangeOffset = 1_000_000; + +function seriesOrder(position: string | undefined): number { + const match = position?.match(/\d+(?:\.\d+)?/); + if (!match) return unknownOrder; + const isRange = /\d\s*[-–]\s*\d/.test(position ?? ''); + return Number(match[0]) + (isRange ? rangeOffset : 0); +} + +function releaseOrder(year: string | null | undefined): number { + const parsed = Number.parseInt(year ?? '', 10); + return Number.isNaN(parsed) ? unknownOrder : parsed; +} + +function splitGenres(genre: string | null | undefined): string[] { + return genre ? genre.split(',').map((name) => name.trim()).filter(Boolean) : []; +} + +export function mediaEntries(items: RatedTitle[], details: Record, kind: MediaKind): LibraryEntry[] { + return items.map((item) => { + const itemDetails = details[item.url]; + return { + id: item.url, + title: item.name, + subtitle: [itemDetails?.year, itemDetails?.runtime].filter(Boolean).join(' · '), + image: itemDetails?.poster ?? null, + url: item.url, + status: item.status ?? 'watched', + rating: item.rating ?? null, + year: itemDetails?.year?.slice(0, 4) ?? null, + genres: splitGenres(itemDetails?.genre), + description: itemDetails?.plot ?? null, + group: item.collection ?? null, + groupPosition: null, + groupOrder: releaseOrder(itemDetails?.year), + facts: [kind === 'show' ? 'TV show' : 'Movie', itemDetails?.runtime ?? ''].filter(Boolean), + added: item.added ?? null, + started: item.started ?? null, + finished: item.finished ?? null + }; + }); +} + +export function bookEntries(books: Book[]): LibraryEntry[] { + return books.map((book) => ({ + id: book.goodreadsId, + title: book.title, + subtitle: book.authors.join(', '), + image: book.cover, + url: book.url, + status: book.shelf, + rating: book.rating ?? null, + year: book.published?.slice(0, 4) ?? null, + genres: book.genres, + description: book.description, + group: book.series?.name ?? null, + groupPosition: book.series?.position ?? null, + groupOrder: seriesOrder(book.series?.position), + facts: [book.pages ? `${book.pages} pages` : '', book.averageRating ? `Goodreads ${book.averageRating}` : ''].filter(Boolean), + added: book.added ?? null, + started: book.started ?? null, + finished: book.finished ?? null + })); +} diff --git a/src/lib/library/filter.ts b/src/lib/library/filter.ts new file mode 100644 index 0000000..19b83ac --- /dev/null +++ b/src/lib/library/filter.ts @@ -0,0 +1,109 @@ +import type { LibraryEntry } from './entries'; + +export type LibrarySort = 'recent' | 'rating' | 'title' | 'year'; + +export interface LibraryFilter { + status: string | null; + query: string; + sort: LibrarySort; +} + +export interface HistoryEvent { + date: string; + kind: 'started' | 'finished'; + entry: LibraryEntry; +} + +export interface HistoryMonth { + key: string; + label: string; + events: HistoryEvent[]; +} + +export interface EntryGroup { + name: string; + entries: LibraryEntry[]; +} + +export interface GroupedEntries { + groups: EntryGroup[]; + standalone: LibraryEntry[]; +} + +export const sortOptions: { id: LibrarySort; label: string }[] = [ + { id: 'recent', label: 'recent' }, + { id: 'rating', label: 'rating' }, + { id: 'title', label: 'title' }, + { id: 'year', label: 'year' } +]; + +export function lastActivity(entry: LibraryEntry): string { + return entry.finished ?? entry.started ?? entry.added ?? ''; +} + +const sorters: Record number> = { + recent: (first, second) => lastActivity(second).localeCompare(lastActivity(first)), + rating: (first, second) => (second.rating ?? -1) - (first.rating ?? -1), + title: (first, second) => first.title.localeCompare(second.title), + year: (first, second) => (second.year ?? '').localeCompare(first.year ?? '') +}; + +function matchesQuery(entry: LibraryEntry, query: string): boolean { + const text = [entry.title, entry.subtitle, entry.group ?? '', ...entry.genres].join(' ').toLowerCase(); + return text.includes(query.trim().toLowerCase()); +} + +export function filterEntries(entries: LibraryEntry[], filter: LibraryFilter): LibraryEntry[] { + return entries + .filter((entry) => !filter.status || entry.status === filter.status) + .filter((entry) => !filter.query.trim() || matchesQuery(entry, filter.query)) + .sort((first, second) => sorters[filter.sort](first, second) || first.title.localeCompare(second.title)); +} + +function groupOrder(first: LibraryEntry, second: LibraryEntry): number { + return first.groupOrder - second.groupOrder || first.title.localeCompare(second.title); +} + +export function groupEntries(entries: LibraryEntry[]): GroupedEntries { + const members = new Map(); + for (const entry of entries) { + if (entry.group) members.set(entry.group, [...(members.get(entry.group) ?? []), entry]); + } + + const groups = [...members.entries()] + .filter(([, groupEntries]) => groupEntries.length > 1) + .map(([name, groupEntries]) => ({ name, entries: [...groupEntries].sort(groupOrder) })); + const groupedIds = new Set(groups.flatMap((group) => group.entries.map((entry) => entry.id))); + + return { groups, standalone: entries.filter((entry) => !groupedIds.has(entry.id)) }; +} + +function monthLabel(key: string): string { + const [year, month] = key.split('-').map(Number); + return new Date(Date.UTC(year, month - 1, 1)).toLocaleString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' }); +} + +export function historyByMonth(entries: LibraryEntry[]): HistoryMonth[] { + const events = entries.flatMap((entry): HistoryEvent[] => [ + ...(entry.finished ? [{ date: entry.finished, kind: 'finished' as const, entry }] : []), + ...(entry.started ? [{ date: entry.started, kind: 'started' as const, entry }] : []) + ]); + events.sort((first, second) => second.date.localeCompare(first.date)); + + const months = new Map(); + for (const event of events) { + const key = event.date.slice(0, 7); + months.set(key, [...(months.get(key) ?? []), event]); + } + return [...months.entries()].map(([key, monthEvents]) => ({ key, label: monthLabel(key), events: monthEvents })); +} + +export function averageRating(entries: LibraryEntry[]): string { + const ratings = entries.flatMap((entry) => (entry.rating === null ? [] : [entry.rating])); + return ratings.length ? (ratings.reduce((total, rating) => total + rating, 0) / ratings.length).toFixed(1) : '-'; +} + +export function finishedThisYear(entries: LibraryEntry[]): number { + const year = `${new Date().getFullYear()}`; + return entries.filter((entry) => entry.finished?.startsWith(year)).length; +} diff --git a/src/lib/library/store.svelte.ts b/src/lib/library/store.svelte.ts new file mode 100644 index 0000000..1d6a275 --- /dev/null +++ b/src/lib/library/store.svelte.ts @@ -0,0 +1,35 @@ +import { emptyLibrary, type LibraryData } from './types'; + +type LoadState = 'idle' | 'loading' | 'ready' | 'error'; + +class LibraryStore { + data = $state(emptyLibrary); + state = $state('idle'); + + private loading: Promise | null = null; + + load(): Promise { + this.loading ??= this.fetchLibrary(); + return this.loading; + } + + reload(): Promise { + this.loading = this.fetchLibrary(); + return this.loading; + } + + private async fetchLibrary() { + this.state = 'loading'; + try { + const response = await fetch('/api/library'); + if (!response.ok) throw new Error(`library returned ${response.status}`); + this.data = await response.json(); + this.state = 'ready'; + } catch { + this.state = 'error'; + this.loading = null; + } + } +} + +export const libraryStore = new LibraryStore(); diff --git a/src/lib/library/types.ts b/src/lib/library/types.ts new file mode 100644 index 0000000..4b9a17c --- /dev/null +++ b/src/lib/library/types.ts @@ -0,0 +1,14 @@ +import type { Book, RatedTitle } from '#lib/content'; +import type { MediaDetails } from '#lib/media/types'; + +export interface LibraryMedia { + shows: RatedTitle[]; + movies: RatedTitle[]; + mediaDetails: Record; +} + +export interface LibraryData extends LibraryMedia { + books: Book[]; +} + +export const emptyLibrary: LibraryData = { shows: [], movies: [], mediaDetails: {}, books: [] }; diff --git a/src/lib/media/stored.ts b/src/lib/media/stored.ts deleted file mode 100644 index e81fd0d..0000000 --- a/src/lib/media/stored.ts +++ /dev/null @@ -1,28 +0,0 @@ -import mediaDetailsData from '#data/mediaDetails.json'; -import type { MediaDetails } from './types'; - -interface StoredMediaDetails extends MediaDetails { - title?: string | null; - posterSource?: string | null; -} - -const imdbIdPattern = /tt\d+/; -const storedDetails = mediaDetailsData as Record; - -function toMediaDetails(stored: StoredMediaDetails): MediaDetails { - return { - poster: stored.poster ?? null, - year: stored.year ?? null, - genre: stored.genre ?? null, - runtime: stored.runtime ?? null, - plot: stored.plot ?? null - }; -} - -export function storedDetailsFor(urls: string[]): Record { - const entries = urls.flatMap((url) => { - const stored = storedDetails[url.match(imdbIdPattern)?.[0] ?? '']; - return stored ? [[url, toMediaDetails(stored)] as const] : []; - }); - return Object.fromEntries(entries); -} diff --git a/src/lib/media/types.ts b/src/lib/media/types.ts index 4175887..9be5eb6 100644 --- a/src/lib/media/types.ts +++ b/src/lib/media/types.ts @@ -5,3 +5,5 @@ export interface MediaDetails { runtime: string | null; plot: string | null; } + +export type MediaKind = 'show' | 'movie'; diff --git a/src/lib/server/btopHobbies.ts b/src/lib/server/btopHobbies.ts index e73dd7e..0b88438 100644 --- a/src/lib/server/btopHobbies.ts +++ b/src/lib/server/btopHobbies.ts @@ -1,12 +1,14 @@ -import { about, books } from '#lib/content'; +import { about } from '#lib/content'; import type { HobbyStats } from '#lib/btop/types'; import { aniListStats, bookStats, listeningStats, screenStats } from '#lib/btop/hobbies'; import { getAniListProfile } from './anilist'; import { getListeningProfile } from './lastfm/profile'; +import { getLibrary } from './library'; export async function getHobbyStats(): Promise { const anilistUsername = about.anilistUsername; - const [listening, anime, manga] = await Promise.all([ + const [library, listening, anime, manga] = await Promise.all([ + getLibrary(), about.lastfmUsername ? getListeningProfile(about.lastfmUsername).catch(() => null) : null, anilistUsername ? getAniListProfile(anilistUsername, 'ANIME').catch(() => null) : null, anilistUsername ? getAniListProfile(anilistUsername, 'MANGA').catch(() => null) : null @@ -15,9 +17,9 @@ export async function getHobbyStats(): Promise { return { listening: listening ? listeningStats(listening) : null, - books: bookStats(books), - shows: screenStats(about.shows), - movies: screenStats(about.movies), + books: bookStats(library.books), + shows: screenStats(library.shows), + movies: screenStats(library.movies), anime: anime ? aniListStats(anime) : null, manga: manga && hasManga ? aniListStats(manga) : null }; diff --git a/src/lib/server/lastfm/covers.ts b/src/lib/server/lastfm/covers.ts index 26d153b..8c91f51 100644 --- a/src/lib/server/lastfm/covers.ts +++ b/src/lib/server/lastfm/covers.ts @@ -1,19 +1,20 @@ import { createHash } from 'node:crypto'; -import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; -import path from 'node:path'; +import { inTransaction, type SqlDatabase } from '../sqlite'; import { fetchTrackImage } from './api'; +import { lastfmDatabase } from './database'; + +export interface CoverImage { + contentType: string; + data: Uint8Array; +} -const dataDirectory = path.resolve(process.env.LASTFM_DATA_DIR ?? 'data/lastfm'); -const albumCoversPath = path.join(dataDirectory, 'covers.json'); -const trackCoversPath = path.join(dataDirectory, 'track-covers.json'); -const imageDirectory = path.join(dataDirectory, 'images'); const imageIdPattern = /^[0-9a-f]{16}$/; const lookupDelay = 300; const notFound = ''; -let albumCovers: Record = {}; -let trackCovers: Record = {}; -const savedImages = new Map(); +const albumCovers = new Map(); +const trackCovers = new Map(); +const savedImageIds = new Set(); const queuedDownloads = new Set(); const queuedLookups = new Map(); let loading: Promise | null = null; @@ -24,25 +25,23 @@ const wait = (milliseconds: number) => new Promise((resolve) => setTimeout(resol const pairKey = (first: string, second: string) => `${first}\n${second}`; const imageId = (url: string) => createHash('sha1').update(url).digest('hex').slice(0, 16); -async function readJson(filePath: string): Promise> { - try { - return JSON.parse(await readFile(filePath, 'utf8')); - } catch { - return {}; +function loadPairs(database: SqlDatabase, sql: string, target: Map) { + for (const row of database.prepare(sql).all() as { first: string; second: string; image: string }[]) { + target.set(pairKey(row.first, row.second), row.image); } } -async function loadFromDisk() { - await mkdir(imageDirectory, { recursive: true }); - albumCovers = await readJson(albumCoversPath); - trackCovers = await readJson(trackCoversPath); - for (const fileName of await readdir(imageDirectory)) { - savedImages.set(path.parse(fileName).name, fileName); +async function loadFromDatabase() { + const database = await lastfmDatabase(); + loadPairs(database, 'SELECT artist AS first, album AS second, image FROM album_covers', albumCovers); + loadPairs(database, 'SELECT artist AS first, track AS second, image FROM track_covers', trackCovers); + for (const row of database.prepare('SELECT id FROM cover_images').all() as { id: string }[]) { + savedImageIds.add(row.id); } } export function loadCovers(): Promise { - loading ??= loadFromDisk(); + loading ??= loadFromDatabase(); return loading; } @@ -52,34 +51,34 @@ export function coversVersion(): number { function servedImage(url: string): string { const id = imageId(url); - if (savedImages.has(id)) return `/api/lastfm/covers/${id}`; + if (savedImageIds.has(id)) return `/api/lastfm/covers/${id}`; queuedDownloads.add(url); runWorker(); return url; } export function coverFor(artist: string, album: string, track?: string): string | null { - const url = (album && albumCovers[pairKey(artist, album)]) || (track && trackCovers[pairKey(artist, track)]) || null; + const url = (album && albumCovers.get(pairKey(artist, album))) || (track && trackCovers.get(pairKey(artist, track))) || null; return url ? servedImage(url) : null; } export async function rememberAlbumCovers(entries: { artist: string; album: string; image: string | null }[]) { - let changed = false; - for (const { artist, album, image } of entries) { - const key = pairKey(artist, album); - if (!album || !image || albumCovers[key] === image) continue; - albumCovers[key] = image; - changed = true; - } - if (!changed) return; + const changed = entries.filter(({ artist, album, image }) => album && image && albumCovers.get(pairKey(artist, album)) !== image); + if (!changed.length) return; + + const database = await lastfmDatabase(); + const upsert = database.prepare( + 'INSERT INTO album_covers (artist, album, image) VALUES (?, ?, ?) ON CONFLICT(artist, album) DO UPDATE SET image = excluded.image' + ); + inTransaction(database, () => changed.forEach(({ artist, album, image }) => upsert.run(artist, album, image))); + changed.forEach(({ artist, album, image }) => albumCovers.set(pairKey(artist, album), image!)); version++; - await writeFile(albumCoversPath, JSON.stringify(albumCovers)); } export function requestTrackCovers(tracks: { artist: string; track: string }[]) { for (const { artist, track } of tracks) { const key = pairKey(artist, track); - if (key in trackCovers || queuedLookups.has(key)) continue; + if (trackCovers.has(key) || queuedLookups.has(key)) continue; queuedLookups.set(key, { artist, track }); } runWorker(); @@ -87,21 +86,26 @@ export function requestTrackCovers(tracks: { artist: string; track: string }[]) async function downloadImage(url: string) { const id = imageId(url); - if (savedImages.has(id)) return; + if (savedImageIds.has(id)) return; const response = await fetch(url); if (!response.ok) throw new Error(`cover download returned ${response.status}`); + const contentType = response.headers.get('content-type')?.split(';')[0] ?? 'image/jpeg'; + const data = new Uint8Array(await response.arrayBuffer()); - const fileName = `${id}${path.extname(new URL(url).pathname) || '.jpg'}`; - await writeFile(path.join(imageDirectory, fileName), Buffer.from(await response.arrayBuffer())); - savedImages.set(id, fileName); + const database = await lastfmDatabase(); + database.prepare('INSERT OR REPLACE INTO cover_images (id, content_type, data) VALUES (?, ?, ?)').run(id, contentType, data); + savedImageIds.add(id); version++; } async function lookUpTrackCover(key: string, artist: string, track: string) { - const image = await fetchTrackImage(artist, track); - trackCovers[key] = image ?? notFound; - await writeFile(trackCoversPath, JSON.stringify(trackCovers)); + const image = (await fetchTrackImage(artist, track)) ?? notFound; + const database = await lastfmDatabase(); + database + .prepare('INSERT INTO track_covers (artist, track, image) VALUES (?, ?, ?) ON CONFLICT(artist, track) DO UPDATE SET image = excluded.image') + .run(artist, track, image); + trackCovers.set(key, image); if (image) version++; } @@ -134,7 +138,12 @@ function runWorker() { .finally(() => (workerRunning = false)); } -export function savedCoverPath(id: string): string | null { - const fileName = imageIdPattern.test(id) ? savedImages.get(id) : undefined; - return fileName ? path.join(imageDirectory, fileName) : null; +export async function readCoverImage(id: string): Promise { + if (!imageIdPattern.test(id)) return null; + const database = await lastfmDatabase(); + const row = database.prepare('SELECT content_type, data FROM cover_images WHERE id = ?').get(id) as + | { content_type: string; data: Uint8Array } + | null + | undefined; + return row ? { contentType: row.content_type, data: row.data } : null; } diff --git a/src/lib/server/lastfm/database.ts b/src/lib/server/lastfm/database.ts new file mode 100644 index 0000000..be1a8f5 --- /dev/null +++ b/src/lib/server/lastfm/database.ts @@ -0,0 +1,49 @@ +import path from 'node:path'; +import { openSqlite, type SqlDatabase } from '../sqlite'; +import { importLegacyFiles } from './legacy'; + +export type { SqlDatabase }; + +export const lastfmDataDirectory = path.resolve(process.env.LASTFM_DATA_DIR ?? 'data/lastfm'); +const databasePath = path.join(lastfmDataDirectory, 'lastfm.db'); + +const schema = ` + CREATE TABLE IF NOT EXISTS scrobbles ( + played_at INTEGER NOT NULL, + artist TEXT NOT NULL, + track TEXT NOT NULL, + album TEXT NOT NULL, + UNIQUE (played_at, artist, track) + ); + CREATE INDEX IF NOT EXISTS scrobbles_played_at ON scrobbles (played_at); + CREATE TABLE IF NOT EXISTS album_covers ( + artist TEXT NOT NULL, + album TEXT NOT NULL, + image TEXT NOT NULL, + PRIMARY KEY (artist, album) + ); + CREATE TABLE IF NOT EXISTS track_covers ( + artist TEXT NOT NULL, + track TEXT NOT NULL, + image TEXT NOT NULL, + PRIMARY KEY (artist, track) + ); + CREATE TABLE IF NOT EXISTS cover_images ( + id TEXT PRIMARY KEY, + content_type TEXT NOT NULL, + data BLOB NOT NULL + ); +`; + +let opening: Promise | null = null; + +async function openLastfmDatabase(): Promise { + const database = await openSqlite(databasePath, schema); + await importLegacyFiles(database, lastfmDataDirectory); + return database; +} + +export function lastfmDatabase(): Promise { + opening ??= openLastfmDatabase(); + return opening; +} diff --git a/src/lib/server/lastfm/legacy.ts b/src/lib/server/lastfm/legacy.ts new file mode 100644 index 0000000..36c4cf4 --- /dev/null +++ b/src/lib/server/lastfm/legacy.ts @@ -0,0 +1,84 @@ +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { inTransaction, type SqlDatabase } from '../sqlite'; + +const contentTypes: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp', + '.gif': 'image/gif' +}; + +async function readText(filePath: string): Promise { + try { + return await readFile(filePath, 'utf8'); + } catch { + return ''; + } +} + +async function readPairs(filePath: string): Promise<[string, string, string][]> { + try { + const covers: Record = JSON.parse(await readText(filePath)); + return Object.entries(covers).map(([key, image]) => { + const [first, second] = key.split('\n'); + return [first, second ?? '', image]; + }); + } catch { + return []; + } +} + +async function readImageFiles(directory: string) { + try { + const fileNames = await readdir(directory); + return Promise.all( + fileNames.map(async (fileName) => ({ + id: path.parse(fileName).name, + contentType: contentTypes[path.extname(fileName)] ?? 'image/jpeg', + data: new Uint8Array(await readFile(path.join(directory, fileName))) + })) + ); + } catch { + return []; + } +} + +function isEmpty(database: SqlDatabase, table: string): boolean { + return !database.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get(); +} + +async function importScrobbles(database: SqlDatabase, directory: string) { + const lines = (await readText(path.join(directory, 'scrobbles.jsonl'))).split('\n').filter(Boolean); + if (!lines.length) return; + const insert = database.prepare('INSERT OR IGNORE INTO scrobbles (played_at, artist, track, album) VALUES (?, ?, ?, ?)'); + inTransaction(database, () => { + for (const line of lines) { + const scrobble = JSON.parse(line); + insert.run(scrobble.playedAt, scrobble.artist, scrobble.track, scrobble.album); + } + }); + console.log(`Last.fm moved ${lines.length} scrobbles into SQLite`); +} + +async function importCovers(database: SqlDatabase, directory: string, fileName: string, table: string, column: string) { + const pairs = await readPairs(path.join(directory, fileName)); + if (!pairs.length) return; + const insert = database.prepare(`INSERT OR IGNORE INTO ${table} (artist, ${column}, image) VALUES (?, ?, ?)`); + inTransaction(database, () => pairs.forEach((pair) => insert.run(...pair))); +} + +async function importImages(database: SqlDatabase, directory: string) { + const images = await readImageFiles(path.join(directory, 'images')); + if (!images.length) return; + const insert = database.prepare('INSERT OR IGNORE INTO cover_images (id, content_type, data) VALUES (?, ?, ?)'); + inTransaction(database, () => images.forEach((image) => insert.run(image.id, image.contentType, image.data))); +} + +export async function importLegacyFiles(database: SqlDatabase, directory: string) { + if (isEmpty(database, 'scrobbles')) await importScrobbles(database, directory); + if (isEmpty(database, 'album_covers')) await importCovers(database, directory, 'covers.json', 'album_covers', 'album'); + if (isEmpty(database, 'track_covers')) await importCovers(database, directory, 'track-covers.json', 'track_covers', 'track'); + if (isEmpty(database, 'cover_images')) await importImages(database, directory); +} diff --git a/src/lib/server/lastfm/store.ts b/src/lib/server/lastfm/store.ts index 3fb77fa..9115b3a 100644 --- a/src/lib/server/lastfm/store.ts +++ b/src/lib/server/lastfm/store.ts @@ -1,5 +1,5 @@ -import { appendFile, mkdir, readFile } from 'node:fs/promises'; -import path from 'node:path'; +import { inTransaction } from '../sqlite'; +import { lastfmDatabase } from './database'; export interface Scrobble { playedAt: number; @@ -8,30 +8,19 @@ export interface Scrobble { album: string; } -const dataDirectory = path.resolve(process.env.LASTFM_DATA_DIR ?? 'data/lastfm'); -const scrobblesPath = path.join(dataDirectory, 'scrobbles.jsonl'); - const scrobbles: Scrobble[] = []; let loading: Promise | null = null; -async function readTextFile(filePath: string): Promise { - try { - return await readFile(filePath, 'utf8'); - } catch { - return ''; - } -} - -async function loadFromDisk() { - await mkdir(dataDirectory, { recursive: true }); - - const lines = (await readTextFile(scrobblesPath)).split('\n').filter(Boolean); - scrobbles.push(...lines.map((line) => JSON.parse(line) as Scrobble)); - scrobbles.sort((first, second) => first.playedAt - second.playedAt); +async function loadFromDatabase() { + const database = await lastfmDatabase(); + const rows = database + .prepare('SELECT played_at AS playedAt, artist, track, album FROM scrobbles ORDER BY played_at') + .all() as Scrobble[]; + for (const row of rows) scrobbles.push(row); } export function loadScrobbleStore(): Promise { - loading ??= loadFromDisk(); + loading ??= loadFromDatabase(); return loading; } @@ -50,8 +39,9 @@ export async function appendScrobbles(newScrobbles: Scrobble[]) { .sort((first, second) => first.playedAt - second.playedAt); if (!fresh.length) return 0; - const lines = fresh.map((scrobble) => JSON.stringify(scrobble)).join('\n'); - await appendFile(scrobblesPath, `${lines}\n`); + const database = await lastfmDatabase(); + const insert = database.prepare('INSERT OR IGNORE INTO scrobbles (played_at, artist, track, album) VALUES (?, ?, ?, ?)'); + inTransaction(database, () => fresh.forEach((scrobble) => insert.run(scrobble.playedAt, scrobble.artist, scrobble.track, scrobble.album))); scrobbles.push(...fresh); return fresh.length; } diff --git a/src/lib/server/library/auth.ts b/src/lib/server/library/auth.ts new file mode 100644 index 0000000..910f2c0 --- /dev/null +++ b/src/lib/server/library/auth.ts @@ -0,0 +1,11 @@ +import { timingSafeEqual } from 'node:crypto'; +import { error } from '@sveltejs/kit'; +import { LIBRARY_API_KEY } from '$app/env/private'; + +export function requireApiKey(request: Request) { + if (!LIBRARY_API_KEY) error(503, 'LIBRARY_API_KEY is not set on the server'); + + const given = Buffer.from(request.headers.get('authorization')?.replace(/^Bearer\s+/i, '') ?? ''); + const expected = Buffer.from(LIBRARY_API_KEY); + if (given.length !== expected.length || !timingSafeEqual(given, expected)) error(401, 'Invalid API key'); +} diff --git a/src/lib/server/library/books.ts b/src/lib/server/library/books.ts new file mode 100644 index 0000000..dfa74ac --- /dev/null +++ b/src/lib/server/library/books.ts @@ -0,0 +1,176 @@ +import { error } from '@sveltejs/kit'; +import type { Book, BookSeries, BookShelf } from '#lib/content'; +import { timestamp, type SqlDatabase } from './database'; +import { hasImage, imageUrl, removeImage, syncImage } from './images'; +import { readChoice, readHistory, readNumber, readRating, readText, readTextList, type RequestBody } from './input'; + +interface BookRow { + goodreads_id: string; + title: string; + authors: string; + description: string | null; + pages: number | null; + published: string | null; + genres: string; + average_rating: number | null; + rating_count: number | null; + cover_source: string | null; + shelf: BookShelf; + rating: number | null; + series_name: string | null; + series_url: string | null; + series_position: string | null; + added: string | null; + started: string | null; + finished: string | null; +} + +const bookShelves: BookShelf[] = ['read', 'reading', 'want-to-read']; +const goodreadsIdPattern = /^\d+$/; + +export function isGoodreadsId(text: string): boolean { + return goodreadsIdPattern.test(text); +} + +function coverImageId(goodreadsId: string): string { + return `book-${goodreadsId}`; +} + +function parseList(text: string): string[] { + try { + const value = JSON.parse(text); + return Array.isArray(value) ? value : []; + } catch { + return []; + } +} + +function seriesOf(row: BookRow): BookSeries | undefined { + if (!row.series_name) return undefined; + return { name: row.series_name, url: row.series_url ?? '', ...(row.series_position && { position: row.series_position }) }; +} + +function toBook(row: BookRow, hasCover: boolean): Book { + const series = seriesOf(row); + return { + goodreadsId: row.goodreads_id, + url: `https://www.goodreads.com/book/show/${row.goodreads_id}`, + title: row.title, + authors: parseList(row.authors), + cover: hasCover ? imageUrl(coverImageId(row.goodreads_id)) : row.cover_source, + coverSource: row.cover_source, + description: row.description, + pages: row.pages, + published: row.published, + genres: parseList(row.genres), + averageRating: row.average_rating, + ratingCount: row.rating_count, + shelf: row.shelf, + ...(row.rating !== null && { rating: row.rating }), + ...(row.added && { added: row.added }), + ...(row.started && { started: row.started }), + ...(row.finished && { finished: row.finished }), + ...(series && { series }) + }; +} + +function findRow(database: SqlDatabase, goodreadsId: string): BookRow | null { + return (database.prepare('SELECT * FROM books WHERE goodreads_id = ?').get(goodreadsId) as BookRow | undefined) ?? null; +} + +export function listBooks(database: SqlDatabase, imageIds: Set): Book[] { + const rows = database.prepare('SELECT * FROM books ORDER BY title COLLATE NOCASE').all() as BookRow[]; + return rows.map((row) => toBook(row, imageIds.has(coverImageId(row.goodreads_id)))); +} + +function readSeries(body: RequestBody, existing: BookRow | null): Pick { + const current = { + series_name: existing?.series_name ?? null, + series_url: existing?.series_url ?? null, + series_position: existing?.series_position ?? null + }; + if (!('series' in body)) return current; + if (body.series === null) return { series_name: null, series_url: null, series_position: null }; + if (typeof body.series !== 'object' || Array.isArray(body.series)) error(400, 'series must be an object or null'); + + const series = body.series as RequestBody; + const name = readText(series, 'name', null); + if (!name) error(400, 'series needs a name'); + return { series_name: name, series_url: readText(series, 'url', null), series_position: readText(series, 'position', null) }; +} + +function mergeRow(goodreadsId: string, existing: BookRow | null, body: RequestBody): BookRow { + const title = readText(body, 'title', existing?.title ?? null); + if (!title) error(400, 'title is required'); + const shelf = readChoice(body, 'shelf', bookShelves, existing?.shelf ?? 'read'); + const pages = readNumber(body, 'pages', existing?.pages ?? null); + + return { + goodreads_id: goodreadsId, + title, + authors: JSON.stringify(readTextList(body, 'authors', parseList(existing?.authors ?? '[]'))), + description: readText(body, 'description', existing?.description ?? null), + pages: pages === null ? null : Math.round(pages), + published: readText(body, 'published', existing?.published ?? null), + genres: JSON.stringify(readTextList(body, 'genres', parseList(existing?.genres ?? '[]'))), + average_rating: readNumber(body, 'averageRating', existing?.average_rating ?? null), + rating_count: readNumber(body, 'ratingCount', existing?.rating_count ?? null), + cover_source: readText(body, 'coverSource', existing?.cover_source ?? null), + shelf, + rating: readRating(body, existing?.rating ?? null), + ...readSeries(body, existing), + ...readHistory( + body, + { added: existing?.added ?? null, started: existing?.started ?? null, finished: existing?.finished ?? null }, + { isNew: !existing, previousStatus: existing?.shelf ?? null, status: shelf, activeStatus: 'reading', doneStatus: 'read' } + ) + }; +} + +export async function saveBook(database: SqlDatabase, goodreadsId: string, body: RequestBody): Promise { + const row = mergeRow(goodreadsId, findRow(database, goodreadsId), body); + + database + .prepare( + `INSERT INTO books (goodreads_id, title, authors, description, pages, published, genres, average_rating, rating_count, cover_source, + shelf, rating, series_name, series_url, series_position, added, started, finished, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(goodreads_id) DO UPDATE SET title = excluded.title, authors = excluded.authors, description = excluded.description, + pages = excluded.pages, published = excluded.published, genres = excluded.genres, average_rating = excluded.average_rating, + rating_count = excluded.rating_count, cover_source = excluded.cover_source, shelf = excluded.shelf, rating = excluded.rating, + series_name = excluded.series_name, series_url = excluded.series_url, series_position = excluded.series_position, + added = excluded.added, started = excluded.started, finished = excluded.finished, updated_at = excluded.updated_at` + ) + .run( + row.goodreads_id, + row.title, + row.authors, + row.description, + row.pages, + row.published, + row.genres, + row.average_rating, + row.rating_count, + row.cover_source, + row.shelf, + row.rating, + row.series_name, + row.series_url, + row.series_position, + row.added, + row.started, + row.finished, + timestamp() + ); + + if ('coverSource' in body) await syncImage(database, coverImageId(goodreadsId), row.cover_source); + + return toBook(row, hasImage(database, coverImageId(goodreadsId))); +} + +export function deleteBook(database: SqlDatabase, goodreadsId: string): boolean { + if (!findRow(database, goodreadsId)) return false; + database.prepare('DELETE FROM books WHERE goodreads_id = ?').run(goodreadsId); + removeImage(database, coverImageId(goodreadsId)); + return true; +} diff --git a/src/lib/server/library/database.ts b/src/lib/server/library/database.ts new file mode 100644 index 0000000..7940683 --- /dev/null +++ b/src/lib/server/library/database.ts @@ -0,0 +1,70 @@ +import path from 'node:path'; +import { openSqlite, type SqlDatabase } from '../sqlite'; + +export type { SqlDatabase }; + +const dataDirectory = path.resolve(process.env.LIBRARY_DATA_DIR ?? 'data/library'); +const databasePath = path.join(dataDirectory, 'library.db'); + +const schema = ` + CREATE TABLE IF NOT EXISTS media ( + imdb_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'watched', + rating INTEGER, + collection TEXT, + year TEXT, + genre TEXT, + runtime TEXT, + plot TEXT, + poster_source TEXT, + added TEXT, + started TEXT, + finished TEXT, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS books ( + goodreads_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + authors TEXT NOT NULL DEFAULT '[]', + description TEXT, + pages INTEGER, + published TEXT, + genres TEXT NOT NULL DEFAULT '[]', + average_rating REAL, + rating_count INTEGER, + cover_source TEXT, + shelf TEXT NOT NULL DEFAULT 'read', + rating INTEGER, + series_name TEXT, + series_url TEXT, + series_position TEXT, + added TEXT, + started TEXT, + finished TEXT, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS images ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + content_type TEXT NOT NULL, + data BLOB NOT NULL, + updated_at TEXT NOT NULL + ); +`; + +let opening: Promise | null = null; + +export function libraryDatabase(): Promise { + opening ??= openSqlite(databasePath, schema); + return opening; +} + +export function timestamp(): string { + return new Date().toISOString(); +} + +export function today(): string { + return timestamp().slice(0, 10); +} diff --git a/src/lib/server/library/images.ts b/src/lib/server/library/images.ts new file mode 100644 index 0000000..fa21eb8 --- /dev/null +++ b/src/lib/server/library/images.ts @@ -0,0 +1,68 @@ +import { timestamp, type SqlDatabase } from './database'; + +export interface StoredImage { + contentType: string; + data: Uint8Array; +} + +const requestHeaders = { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0' }; +const maxImageBytes = 8 * 1024 * 1024; + +export function imageUrl(id: string): string { + return `/api/library/images/${id}`; +} + +export function storedImageIds(database: SqlDatabase): Set { + const rows = database.prepare('SELECT id FROM images').all() as { id: string }[]; + return new Set(rows.map((row) => row.id)); +} + +export function hasImage(database: SqlDatabase, id: string): boolean { + return !!database.prepare('SELECT id FROM images WHERE id = ?').get(id); +} + +export function readImage(database: SqlDatabase, id: string): StoredImage | null { + const row = database.prepare('SELECT content_type, data FROM images WHERE id = ?').get(id) as + | { content_type: string; data: Uint8Array } + | null + | undefined; + return row ? { contentType: row.content_type, data: row.data } : null; +} + +export function removeImage(database: SqlDatabase, id: string) { + database.prepare('DELETE FROM images WHERE id = ?').run(id); +} + +async function downloadImage(source: string): Promise { + if (!/^https?:\/\//.test(source)) throw new Error(`${source} is not a web address`); + const response = await fetch(source, { headers: requestHeaders }); + if (!response.ok) throw new Error(`image request returned ${response.status}`); + + const contentType = response.headers.get('content-type')?.split(';')[0] ?? 'image/jpeg'; + if (!contentType.startsWith('image/')) throw new Error(`${source} is not an image`); + + const data = new Uint8Array(await response.arrayBuffer()); + if (data.byteLength > maxImageBytes) throw new Error(`${source} is larger than 8 MB`); + return { contentType, data }; +} + +export async function syncImage(database: SqlDatabase, id: string, source: string | null) { + if (!source) { + removeImage(database, id); + return; + } + const stored = database.prepare('SELECT source FROM images WHERE id = ?').get(id) as { source: string } | null | undefined; + if (stored?.source === source) return; + + try { + const image = await downloadImage(source); + database + .prepare( + `INSERT INTO images (id, source, content_type, data, updated_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET source = excluded.source, content_type = excluded.content_type, data = excluded.data, updated_at = excluded.updated_at` + ) + .run(id, source, image.contentType, image.data, timestamp()); + } catch (downloadError) { + console.error(`Could not save image ${id}:`, downloadError); + } +} diff --git a/src/lib/server/library/index.ts b/src/lib/server/library/index.ts new file mode 100644 index 0000000..5fd98eb --- /dev/null +++ b/src/lib/server/library/index.ts @@ -0,0 +1,11 @@ +import type { LibraryData } from '#lib/library/types'; +import { libraryDatabase } from './database'; +import { storedImageIds } from './images'; +import { listMedia } from './media'; +import { listBooks } from './books'; + +export async function getLibrary(): Promise { + const database = await libraryDatabase(); + const imageIds = storedImageIds(database); + return { ...listMedia(database, imageIds), books: listBooks(database, imageIds) }; +} diff --git a/src/lib/server/library/input.ts b/src/lib/server/library/input.ts new file mode 100644 index 0000000..804073b --- /dev/null +++ b/src/lib/server/library/input.ts @@ -0,0 +1,86 @@ +import { error } from '@sveltejs/kit'; +import { today } from './database'; + +export type RequestBody = Record; + +const datePattern = /^\d{4}-\d{2}-\d{2}$/; + +export async function readBody(request: Request): Promise { + const body = await request.json().catch(() => null); + if (!body || typeof body !== 'object' || Array.isArray(body)) error(400, 'Body must be a JSON object'); + return body as RequestBody; +} + +export function readText(body: RequestBody, key: string, current: string | null): string | null { + if (!(key in body)) return current; + const value = body[key]; + if (value === null || value === undefined) return null; + if (typeof value !== 'string') error(400, `${key} must be text`); + return value.trim() || null; +} + +export function readNumber(body: RequestBody, key: string, current: number | null): number | null { + if (!(key in body)) return current; + const value = body[key]; + if (value === null || value === undefined || value === '') return null; + const number = Number(value); + if (!Number.isFinite(number)) error(400, `${key} must be a number`); + return number; +} + +export function readRating(body: RequestBody, current: number | null): number | null { + const rating = readNumber(body, 'rating', current); + if (rating !== null && (!Number.isInteger(rating) || rating < 0 || rating > 10)) error(400, 'rating must be a whole number from 0 to 10'); + return rating; +} + +export function readChoice(body: RequestBody, key: string, choices: readonly Choice[], current: Choice): Choice { + if (!(key in body)) return current; + const value = body[key]; + if (!choices.includes(value as Choice)) error(400, `${key} must be one of ${choices.join(', ')}`); + return value as Choice; +} + +export function readTextList(body: RequestBody, key: string, current: string[]): string[] { + if (!(key in body)) return current; + const value = body[key]; + if (value === null || value === undefined) return []; + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) error(400, `${key} must be a list of text`); + return (value as string[]).map((item) => item.trim()).filter(Boolean); +} + +export function readDate(body: RequestBody, key: string, current: string | null): string | null { + const value = readText(body, key, current); + if (value !== null && !datePattern.test(value)) error(400, `${key} must be a date like 2026-01-31`); + return value; +} + +export interface HistoryDates { + added: string | null; + started: string | null; + finished: string | null; +} + +export interface StatusChange { + isNew: boolean; + previousStatus: Status | null; + status: Status; + activeStatus: Status; + doneStatus: Status; +} + +export function readHistory(body: RequestBody, current: HistoryDates, change: StatusChange): HistoryDates { + const statusChanged = change.status !== change.previousStatus; + const autoDate = (key: keyof HistoryDates, status: Status, value: string | null) => + !(key in body) && statusChanged && change.status === status && !value ? today() : value; + + const added = readDate(body, 'added', current.added); + const started = readDate(body, 'started', current.started); + const finished = readDate(body, 'finished', current.finished); + + return { + added: !('added' in body) && change.isNew ? today() : added, + started: autoDate('started', change.activeStatus, started), + finished: autoDate('finished', change.doneStatus, finished) + }; +} diff --git a/src/lib/server/library/media.ts b/src/lib/server/library/media.ts new file mode 100644 index 0000000..49b1357 --- /dev/null +++ b/src/lib/server/library/media.ts @@ -0,0 +1,153 @@ +import { error } from '@sveltejs/kit'; +import type { RatedTitle, WatchStatus } from '#lib/content'; +import type { MediaDetails, MediaKind } from '#lib/media/types'; +import type { LibraryMedia } from '#lib/library/types'; +import { timestamp, type SqlDatabase } from './database'; +import { hasImage, imageUrl, removeImage, syncImage } from './images'; +import { readChoice, readHistory, readRating, readText, type RequestBody } from './input'; + +interface MediaRow { + imdb_id: string; + kind: MediaKind; + name: string; + status: WatchStatus; + rating: number | null; + collection: string | null; + year: string | null; + genre: string | null; + runtime: string | null; + plot: string | null; + poster_source: string | null; + added: string | null; + started: string | null; + finished: string | null; +} + +export interface SavedMedia { + kind: MediaKind; + item: RatedTitle; + details: MediaDetails; +} + +const mediaKinds: MediaKind[] = ['show', 'movie']; +const watchStatuses: WatchStatus[] = ['watched', 'watching', 'planning']; +const imdbIdPattern = /^tt\d{7,9}$/; + +export function isImdbId(text: string): boolean { + return imdbIdPattern.test(text); +} + +function posterImageId(imdbId: string): string { + return `media-${imdbId}`; +} + +function titleUrl(imdbId: string): string { + return `https://www.imdb.com/title/${imdbId}/`; +} + +function toRatedTitle(row: MediaRow): RatedTitle { + return { + name: row.name, + url: titleUrl(row.imdb_id), + ...(row.rating !== null && { rating: row.rating }), + ...(row.status !== 'watched' && { status: row.status }), + ...(row.collection && { collection: row.collection }), + ...(row.added && { added: row.added }), + ...(row.started && { started: row.started }), + ...(row.finished && { finished: row.finished }) + }; +} + +function toMediaDetails(row: MediaRow, hasPoster: boolean): MediaDetails { + return { + poster: hasPoster ? imageUrl(posterImageId(row.imdb_id)) : row.poster_source, + year: row.year, + genre: row.genre, + runtime: row.runtime, + plot: row.plot + }; +} + +function findRow(database: SqlDatabase, imdbId: string): MediaRow | null { + return (database.prepare('SELECT * FROM media WHERE imdb_id = ?').get(imdbId) as MediaRow | undefined) ?? null; +} + +export function listMedia(database: SqlDatabase, imageIds: Set): LibraryMedia { + const rows = database.prepare('SELECT * FROM media ORDER BY name COLLATE NOCASE').all() as MediaRow[]; + return { + shows: rows.filter((row) => row.kind === 'show').map(toRatedTitle), + movies: rows.filter((row) => row.kind === 'movie').map(toRatedTitle), + mediaDetails: Object.fromEntries( + rows.map((row) => [titleUrl(row.imdb_id), toMediaDetails(row, imageIds.has(posterImageId(row.imdb_id)))]) + ) + }; +} + +function mergeRow(imdbId: string, existing: MediaRow | null, body: RequestBody): MediaRow { + const name = readText(body, 'name', existing?.name ?? null); + if (!name) error(400, 'name is required'); + if (!existing && !('kind' in body)) error(400, 'kind is required'); + const status = readChoice(body, 'status', watchStatuses, existing?.status ?? 'watched'); + + return { + imdb_id: imdbId, + kind: readChoice(body, 'kind', mediaKinds, existing?.kind ?? 'movie'), + name, + status, + rating: readRating(body, existing?.rating ?? null), + collection: readText(body, 'collection', existing?.collection ?? null), + year: readText(body, 'year', existing?.year ?? null), + genre: readText(body, 'genre', existing?.genre ?? null), + runtime: readText(body, 'runtime', existing?.runtime ?? null), + plot: readText(body, 'plot', existing?.plot ?? null), + poster_source: readText(body, 'posterSource', existing?.poster_source ?? null), + ...readHistory( + body, + { added: existing?.added ?? null, started: existing?.started ?? null, finished: existing?.finished ?? null }, + { isNew: !existing, previousStatus: existing?.status ?? null, status, activeStatus: 'watching', doneStatus: 'watched' } + ) + }; +} + +export async function saveMedia(database: SqlDatabase, imdbId: string, body: RequestBody): Promise { + const row = mergeRow(imdbId, findRow(database, imdbId), body); + + database + .prepare( + `INSERT INTO media (imdb_id, kind, name, status, rating, collection, year, genre, runtime, plot, poster_source, added, started, finished, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(imdb_id) DO UPDATE SET kind = excluded.kind, name = excluded.name, status = excluded.status, rating = excluded.rating, + collection = excluded.collection, year = excluded.year, genre = excluded.genre, runtime = excluded.runtime, plot = excluded.plot, + poster_source = excluded.poster_source, added = excluded.added, started = excluded.started, finished = excluded.finished, + updated_at = excluded.updated_at` + ) + .run( + row.imdb_id, + row.kind, + row.name, + row.status, + row.rating, + row.collection, + row.year, + row.genre, + row.runtime, + row.plot, + row.poster_source, + row.added, + row.started, + row.finished, + timestamp() + ); + + if ('posterSource' in body) await syncImage(database, posterImageId(imdbId), row.poster_source); + + return { kind: row.kind, item: toRatedTitle(row), details: toMediaDetails(row, hasImage(database, posterImageId(imdbId))) }; +} + +export function deleteMedia(database: SqlDatabase, imdbId: string): boolean { + const existing = findRow(database, imdbId); + if (!existing) return false; + database.prepare('DELETE FROM media WHERE imdb_id = ?').run(imdbId); + removeImage(database, posterImageId(imdbId)); + return true; +} diff --git a/src/lib/server/omdb.ts b/src/lib/server/omdb.ts deleted file mode 100644 index 8079abb..0000000 --- a/src/lib/server/omdb.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { OMDB_API_KEY } from '$app/env/private'; -import type { MediaDetails } from '#lib/media/types'; - -const cacheDuration = 24 * 60 * 60 * 1000; -const imdbIdPattern = /tt\d+/; -const missingValue = 'N/A'; -const omdbUrl = 'https://www.omdbapi.com/'; -const omdbDemoUrl = 'http://www.omdbapi.com/demo.aspx/'; -const rateLimitedStatus = 401; - -interface OmdbResponse { - Response: 'True' | 'False'; - Error?: string; - Year?: string; - Genre?: string; - Runtime?: string; - Plot?: string; - Poster?: string; -} - -const detailsCache = new Map(); - -function imdbIdFromUrl(url: string): string | null { - return url.match(imdbIdPattern)?.[0] ?? null; -} - -function presentValue(value: string | undefined): string | null { - return value && value !== missingValue ? value : null; -} - -function toMediaDetails(result: OmdbResponse): MediaDetails { - return { - poster: presentValue(result.Poster), - year: presentValue(result.Year), - genre: presentValue(result.Genre), - runtime: presentValue(result.Runtime), - plot: presentValue(result.Plot) - }; -} - -async function requestOmdb(imdbId: string): Promise { - const params = new URLSearchParams({ apikey: OMDB_API_KEY ?? '', i: imdbId, plot: 'short' }); - const response = await fetch(`${omdbUrl}?${params}`); - if (response.status !== rateLimitedStatus) return response; - - const demoParams = new URLSearchParams({ i: imdbId, token: 'demo' }); - return fetch(`${omdbDemoUrl}?${demoParams}`); -} - -async function fetchDetails(imdbId: string): Promise { - const cached = detailsCache.get(imdbId); - if (cached && Date.now() - cached.fetchedAt < cacheDuration) return cached.details; - - try { - const response = await requestOmdb(imdbId); - if (!response.ok) throw new Error(`OMDb returned ${response.status}`); - - const result: OmdbResponse = await response.json(); - if (result.Response === 'False') throw new Error(result.Error ?? 'OMDb found no result'); - - const details = toMediaDetails(result); - detailsCache.set(imdbId, { details, fetchedAt: Date.now() }); - return details; - } catch (error) { - console.error(`OMDb lookup failed for ${imdbId}:`, error); - return cached?.details ?? null; - } -} - -export async function getMediaDetails(urls: string[]): Promise> { - if (!OMDB_API_KEY) return {}; - - const results = await Promise.all( - urls.map(async (url) => { - const imdbId = imdbIdFromUrl(url); - const details = imdbId ? await fetchDetails(imdbId) : null; - return [url, details] as const; - }) - ); - return Object.fromEntries(results.filter((result): result is readonly [string, MediaDetails] => result[1] !== null)); -} diff --git a/src/lib/server/sqlite.ts b/src/lib/server/sqlite.ts new file mode 100644 index 0000000..802da50 --- /dev/null +++ b/src/lib/server/sqlite.ts @@ -0,0 +1,40 @@ +import { mkdirSync } from 'node:fs'; +import path from 'node:path'; + +export type SqlValue = string | number | null | Uint8Array; + +export interface SqlStatement { + all(...params: SqlValue[]): unknown[]; + get(...params: SqlValue[]): unknown; + run(...params: SqlValue[]): unknown; +} + +export interface SqlDatabase { + exec(sql: string): void; + prepare(sql: string): SqlStatement; +} + +async function importModule(name: string) { + return import(/* @vite-ignore */ name); +} + +export async function openSqlite(filePath: string, schema: string): Promise { + mkdirSync(path.dirname(filePath), { recursive: true }); + const database: SqlDatabase = process.versions.bun + ? new (await importModule('bun:sqlite')).Database(filePath, { create: true }) + : new (await importModule('node:sqlite')).DatabaseSync(filePath); + database.exec('PRAGMA journal_mode = WAL;'); + database.exec(schema); + return database; +} + +export function inTransaction(database: SqlDatabase, work: () => void) { + database.exec('BEGIN'); + try { + work(); + database.exec('COMMIT'); + } catch (error) { + database.exec('ROLLBACK'); + throw error; + } +} diff --git a/src/lib/terminal/commands/system.ts b/src/lib/terminal/commands/system.ts index 6709c1e..ea6a617 100644 --- a/src/lib/terminal/commands/system.ts +++ b/src/lib/terminal/commands/system.ts @@ -140,6 +140,23 @@ export const systemCommands: Command[] = [ description: 'Open the Pokemon TCG card browser', run: (_args, context) => context.openApp('tcg') }, + { + name: 'movies', + description: 'Open my movie watch history', + run: (_args, context) => context.openApp('movies') + }, + { + name: 'shows', + aliases: ['tv'], + description: 'Open my TV show watch history', + run: (_args, context) => context.openApp('shows') + }, + { + name: 'books', + aliases: ['library'], + description: 'Open my reading history', + run: (_args, context) => context.openApp('books') + }, { name: 'pwd', hidden: true, diff --git a/src/routes/about/+page.server.ts b/src/routes/about/+page.server.ts index 38660f7..caa5da6 100644 --- a/src/routes/about/+page.server.ts +++ b/src/routes/about/+page.server.ts @@ -1,26 +1,20 @@ import { about } from '#lib/content'; import { getAniListProfile } from '#lib/server/anilist'; -import { getMediaDetails } from '#lib/server/omdb'; -import { storedDetailsFor } from '#lib/media/stored'; +import { getLibrary } from '#lib/server/library'; import { getListeningProfile } from '#lib/server/lastfm/profile'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async () => { - const mediaUrls = [...about.shows, ...about.movies].map((item) => item.url); const anilistUsername = about.anilistUsername; - const storedDetails = storedDetailsFor(mediaUrls); - const unsavedUrls = mediaUrls.filter((url) => !storedDetails[url]); - const [anime, manga, fetchedDetails, listening] = await Promise.all([ + const [anime, manga, library, listening] = await Promise.all([ anilistUsername ? getAniListProfile(anilistUsername, 'ANIME') : null, anilistUsername ? getAniListProfile(anilistUsername, 'MANGA') : null, - unsavedUrls.length ? getMediaDetails(unsavedUrls) : {}, + getLibrary(), about.lastfmUsername ? getListeningProfile(about.lastfmUsername) : null ]); const hasManga = !!manga?.statusCounts.some((item) => item.count > 0); - const mediaDetails = { ...fetchedDetails, ...storedDetails }; - - return { anime, manga: hasManga ? manga : null, mediaDetails, listening }; + return { anime, manga: hasManga ? manga : null, library, listening }; }; diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index 2d2870e..ccc5b70 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -1,5 +1,5 @@ ", re.S | re.I) NEXT_DATA_PATTERN = re.compile(r"]*id=[\"']__NEXT_DATA__[\"'][^>]*>(.*?)", re.S | re.I) -META_TAG_PATTERN = re.compile(r"]*>", re.S | re.I) -ATTRIBUTE_PATTERN = re.compile(r"([\w:-]+)=\"([^\"]*)\"|([\w:-]+)='([^']*)'") -PAGE_TITLE_PATTERN = re.compile(r"]*>(.*?)", re.S | re.I) -TAG_PATTERN = re.compile(r"<[^>]+>") BLOCKED_PAGE_HINTS = ["captcha", "awswaf", "robot check", "access denied", "are you a human", "unusual traffic"] -REQUEST_HEADERS = { - "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0", - "Accept": "text/html,application/xhtml+xml", - "Accept-Language": "en-US,en;q=0.9", -} class BlockedError(ValueError): pass -class PageResult: - def __init__(self, status: int, final_url: str, text: str): - self.status = status - self.final_url = final_url - self.text = text - - def book_id(url: str) -> str | None: match = BOOK_URL_PATTERN.search(url) return match.group(1) if match else None -def download_page(url: str) -> PageResult: - request = urllib.request.Request(url, headers=REQUEST_HEADERS) - with urllib.request.urlopen(request, timeout=15) as response: - return PageResult(response.status, response.geturl(), response.read().decode("utf-8", errors="replace")) - - -def page_title(page: str) -> str: - match = PAGE_TITLE_PATTERN.search(page) - return html.unescape(match.group(1)).strip() if match else "" - - def blocked_reason(page: str) -> str | None: lowered = page.lower() hint = next((hint for hint in BLOCKED_PAGE_HINTS if hint in lowered), None) @@ -71,25 +45,6 @@ def json_ld_book(page: str) -> dict: return {} -def meta_tags(page: str) -> dict: - tags = {} - for tag in META_TAG_PATTERN.findall(page): - attributes = {} - for double_name, double_value, single_name, single_value in ATTRIBUTE_PATTERN.findall(tag): - attributes[(double_name or single_name).lower()] = double_value or single_value - name = attributes.get("property") or attributes.get("name") - if name and "content" in attributes: - tags[name] = html.unescape(attributes["content"]) - return tags - - -def plain_text(text: str | None) -> str | None: - if not text: - return None - with_breaks = re.sub(r"", "\n", text) - return html.unescape(TAG_PATTERN.sub("", with_breaks)).strip() or None - - def apollo_state(page: str) -> dict: match = NEXT_DATA_PATTERN.search(page) if not match: diff --git a/tools/content/html_page.py b/tools/content/html_page.py new file mode 100644 index 0000000..261aecb --- /dev/null +++ b/tools/content/html_page.py @@ -0,0 +1,51 @@ +import html +import re +import urllib.request + +META_TAG_PATTERN = re.compile(r"]*>", re.S | re.I) +ATTRIBUTE_PATTERN = re.compile(r"([\w:-]+)=\"([^\"]*)\"|([\w:-]+)='([^']*)'") +PAGE_TITLE_PATTERN = re.compile(r"]*>(.*?)", re.S | re.I) +TAG_PATTERN = re.compile(r"<[^>]+>") + +REQUEST_HEADERS = { + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0", + "Accept": "text/html,application/xhtml+xml", + "Accept-Language": "en-US,en;q=0.9", +} + + +class PageResult: + def __init__(self, status: int, final_url: str, text: str): + self.status = status + self.final_url = final_url + self.text = text + + +def download_page(url: str) -> PageResult: + request = urllib.request.Request(url, headers=REQUEST_HEADERS) + with urllib.request.urlopen(request, timeout=15) as response: + return PageResult(response.status, response.geturl(), response.read().decode("utf-8", errors="replace")) + + +def page_title(page: str) -> str: + match = PAGE_TITLE_PATTERN.search(page) + return html.unescape(match.group(1)).strip() if match else "" + + +def meta_tags(page: str) -> dict: + tags = {} + for tag in META_TAG_PATTERN.findall(page): + attributes = {} + for double_name, double_value, single_name, single_value in ATTRIBUTE_PATTERN.findall(tag): + attributes[(double_name or single_name).lower()] = double_value or single_value + name = attributes.get("property") or attributes.get("name") + if name and "content" in attributes: + tags[name] = html.unescape(attributes["content"]) + return tags + + +def plain_text(text: str | None) -> str | None: + if not text: + return None + with_breaks = re.sub(r"", "\n", text) + return html.unescape(TAG_PATTERN.sub("", with_breaks)).strip() or None diff --git a/tools/content/imdb.py b/tools/content/imdb.py index 50c283e..b61e318 100644 --- a/tools/content/imdb.py +++ b/tools/content/imdb.py @@ -1,17 +1,16 @@ import json -import os import re import urllib.error import urllib.parse import urllib.request -from jsonfile import PROJECT_ROOT +from envfile import env_value IMDB_ID_PATTERN = re.compile(r"tt\d{7,9}") -ENV_FILE = PROJECT_ROOT / ".env" OMDB_URL = "https://www.omdbapi.com/" OMDB_DEMO_URL = "http://www.omdbapi.com/demo.aspx/" RATE_LIMITED_STATUS = 401 +MISSING_VALUE = "N/A" def imdb_id(text: str) -> str | None: @@ -23,25 +22,13 @@ def title_url(identifier: str) -> str: return f"https://www.imdb.com/title/{identifier}/" -def omdb_api_key() -> str | None: - if os.environ.get("OMDB_API_KEY"): - return os.environ["OMDB_API_KEY"] - if not ENV_FILE.exists(): - return None - for line in ENV_FILE.read_text(encoding="utf-8").splitlines(): - key, _, value = line.partition("=") - if key.strip() == "OMDB_API_KEY" and value.strip(): - return value.strip().strip("\"'") - return None - - def request_json(url: str, parameters: dict) -> dict: with urllib.request.urlopen(f"{url}?{urllib.parse.urlencode(parameters)}", timeout=10) as response: return json.load(response) def lookup(identifier: str) -> dict | None: - api_key = omdb_api_key() + api_key = env_value("OMDB_API_KEY") if not api_key: return None try: @@ -54,3 +41,21 @@ def lookup(identifier: str) -> dict | None: except (OSError, ValueError): return None return result if result.get("Response") == "True" else None + + +def present(value: str | None) -> str | None: + return value if value and value != MISSING_VALUE else None + + +def details(identifier: str) -> dict: + result = lookup(identifier) + if not result: + raise ValueError(f"Could not look up {identifier} on OMDb. Check OMDB_API_KEY in .env") + return { + "name": present(result.get("Title")), + "year": present(result.get("Year")), + "genre": present(result.get("Genre")), + "runtime": present(result.get("Runtime")), + "plot": present(result.get("Plot")), + "posterSource": present(result.get("Poster")), + } diff --git a/tools/content/library_api.py b/tools/content/library_api.py new file mode 100644 index 0000000..ed3a5d3 --- /dev/null +++ b/tools/content/library_api.py @@ -0,0 +1,79 @@ +import json +import urllib.error +import urllib.parse +import urllib.request + +from envfile import env_value + +DEFAULT_API_URL = "http://localhost:5000" +IMAGE_PATH_PREFIX = "/api/library/images/" +REQUEST_TIMEOUT_SECONDS = 60 + + +class LibraryError(ValueError): + pass + + +def api_url() -> str: + return (env_value("LIBRARY_API_URL") or DEFAULT_API_URL).rstrip("/") + + +def api_key() -> str: + key = env_value("LIBRARY_API_KEY") + if not key: + raise LibraryError("Set LIBRARY_API_KEY in .env to change the library") + return key + + +def error_message(error: urllib.error.HTTPError) -> str: + try: + return json.loads(error.read().decode("utf-8")).get("message") or error.reason + except ValueError: + return str(error.reason) + + +def send(method: str, path: str, body: dict | None = None, signed: bool = True): + headers = {"Accept": "application/json"} + data = None + if signed: + headers["Authorization"] = f"Bearer {api_key()}" + if body is not None: + headers["Content-Type"] = "application/json" + data = json.dumps(body).encode("utf-8") + request = urllib.request.Request(f"{api_url()}{path}", data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response: + text = response.read().decode("utf-8") + except urllib.error.HTTPError as error: + raise LibraryError(f"{api_url()} said {error.code}: {error_message(error)}") from error + except urllib.error.URLError as error: + raise LibraryError(f"Could not reach {api_url()}: {error.reason}") from error + return json.loads(text) if text else None + + +def quoted(identifier: str) -> str: + return urllib.parse.quote(identifier, safe="") + + +def fetch_library() -> dict: + return send("GET", "/api/library", signed=False) + + +def save_media(imdb_id: str, fields: dict) -> dict: + return send("PUT", f"/api/library/media/{quoted(imdb_id)}", fields) + + +def delete_media(imdb_id: str) -> None: + send("DELETE", f"/api/library/media/{quoted(imdb_id)}") + + +def save_book(goodreads_id: str, fields: dict) -> dict: + return send("PUT", f"/api/library/books/{quoted(goodreads_id)}", fields) + + +def delete_book(goodreads_id: str) -> None: + send("DELETE", f"/api/library/books/{quoted(goodreads_id)}") + + +def is_stored_image(path: str | None) -> bool: + return bool(path) and path.startswith(IMAGE_PATH_PREFIX) diff --git a/tools/content/media.py b/tools/content/media.py index e37f21a..c615b6f 100644 --- a/tools/content/media.py +++ b/tools/content/media.py @@ -1,8 +1,6 @@ import imdb -import jsonfile -import media_cache +import library_api -ABOUT_FILE = "about.json" MEDIA_KINDS = { "show": {"key": "shows", "label": "TV show", "omdb_type": "series"}, "movie": {"key": "movies", "label": "movie", "omdb_type": "movie"}, @@ -23,125 +21,101 @@ def check_status(status: str) -> str: return status -def load_items(kind: str) -> tuple[dict, list[dict]]: - data = jsonfile.load(ABOUT_FILE) - return data, data.setdefault(MEDIA_KINDS[kind]["key"], []) - - def status_of(item: dict) -> str: return item.get("status", "watched") +def with_details(item: dict, details: dict) -> dict: + return {**item, "imdbId": imdb.imdb_id(item["url"]), "details": details.get(item["url"]) or {}} + + def list_items(kind: str) -> list[dict]: - _, items = load_items(kind) + library = library_api.fetch_library() + items = [with_details(item, library["mediaDetails"]) for item in library[MEDIA_KINDS[kind]["key"]]] rating = lambda item: item["rating"] if item.get("rating") is not None else -1 return sorted(items, key=lambda item: (STATUS_ORDER[status_of(item)], -rating(item))) -def collection_names(kind: str) -> list[str]: - _, items = load_items(kind) - return sorted({item["collection"] for item in items if item.get("collection")}) - - -def find_index(items: list[dict], text: str) -> int | None: +def find_item(items: list[dict], text: str) -> dict | None: identifier = imdb.imdb_id(text) - for index, item in enumerate(items): - if identifier and imdb.imdb_id(item.get("url", "")) == identifier: - return index + for item in items: + if identifier and item["imdbId"] == identifier: + return item if item.get("name", "").lower() == text.lower(): - return index + return item return None -def details_for(identifier: str, name: str | None) -> dict: +def listed_identifier(kind: str, text: str) -> str: + item = find_item(list_items(kind), text) + if not item: + raise ValueError(f"{text} is not listed") + return item["imdbId"] + + +def lookup_details(identifier: str, name: str | None) -> dict: try: - return media_cache.cached_details(identifier) - except (OSError, ValueError): + return imdb.details(identifier) + except ValueError: if name: return {} raise ValueError("Could not look up the title on OMDb. Enter a name, or set OMDB_API_KEY in .env") -def with_optional(item: dict, key: str, value) -> dict: - updated = {name: field for name, field in item.items() if name != key} - if value is not None and value != "": - updated[key] = value - return updated - - -def build_item(name: str, url: str, rating: int | None, status: str, collection: str | None) -> dict: - item = {"name": name, "url": url} - item = with_optional(item, "rating", rating) - if status != "watched": - item["status"] = status - return with_optional(item, "collection", collection) - - def add_item(kind: str, text: str, rating: int | None, name: str | None = None, status: str = "watched", collection: str | None = None) -> dict: identifier = imdb.imdb_id(text) if not identifier: raise ValueError(f"{text} is not an IMDb link or ID") check_rating(rating) check_status(status) - data, items = load_items(kind) - if find_index(items, identifier) is not None: + if find_item(list_items(kind), identifier): raise ValueError(f"{identifier} is already listed") - details = details_for(identifier, name) - item = build_item(name or details.get("title") or identifier, imdb.title_url(identifier), rating, status, collection) - items.append(item) - jsonfile.save(ABOUT_FILE, data) - return item + details = lookup_details(identifier, name) + fields = { + **details, + "kind": kind, + "name": name or details.get("name") or identifier, + "status": status, + "rating": rating, + "collection": collection or None, + } + return library_api.save_media(identifier, fields)["item"] -def update_item(kind: str, text: str, change) -> dict: - data, items = load_items(kind) - index = find_index(items, text) - if index is None: - raise ValueError(f"{text} is not listed") - items[index] = change(items[index]) - jsonfile.save(ABOUT_FILE, data) - return items[index] +def update_item(kind: str, text: str, fields: dict) -> dict: + return library_api.save_media(listed_identifier(kind, text), fields)["item"] -def remove_item(kind: str, text: str) -> dict: - data, items = load_items(kind) - index = find_index(items, text) - if index is None: - raise ValueError(f"{text} is not listed") - removed = items.pop(index) - jsonfile.save(ABOUT_FILE, data) - identifier = imdb.imdb_id(removed.get("url", "")) - if identifier: - media_cache.remove_details(identifier) - return removed +def remove_item(kind: str, text: str) -> None: + library_api.delete_media(listed_identifier(kind, text)) def rate_item(kind: str, text: str, rating: int | None) -> dict: check_rating(rating) - return update_item(kind, text, lambda item: with_optional(item, "rating", rating)) + return update_item(kind, text, {"rating": rating}) def set_status(kind: str, text: str, status: str) -> dict: check_status(status) - return update_item(kind, text, lambda item: with_optional(item, "status", None if status == "watched" else status)) + return update_item(kind, text, {"status": status}) def set_collection(kind: str, text: str, collection: str | None) -> dict: - return update_item(kind, text, lambda item: with_optional(item, "collection", collection)) + return update_item(kind, text, {"collection": collection or None}) -def item_identifiers(kind: str) -> list[str]: - _, items = load_items(kind) - return [identifier for identifier in (imdb.imdb_id(item.get("url", "")) for item in items) if identifier] +def set_dates(kind: str, text: str, started: str | None, finished: str | None) -> dict: + return update_item(kind, text, {"started": started, "finished": finished}) -def missing_details(kind: str) -> list[str]: - return media_cache.missing_identifiers(item_identifiers(kind)) +def has_details(item: dict) -> bool: + return any(item["details"].get(key) for key in ["year", "genre", "plot"]) + + +def has_stored_poster(item: dict) -> bool: + return library_api.is_stored_image(item["details"].get("poster")) def refresh_details(identifier: str) -> dict: - return media_cache.cached_details(identifier, fresh=True) - - -def stored_details(identifier: str) -> dict | None: - return media_cache.load_details().get(identifier) + details = {key: value for key, value in imdb.details(identifier).items() if key != "name"} + return library_api.save_media(identifier, details) diff --git a/tools/content/media_cache.py b/tools/content/media_cache.py deleted file mode 100644 index e8bfbab..0000000 --- a/tools/content/media_cache.py +++ /dev/null @@ -1,92 +0,0 @@ -import urllib.request - -import imdb -import jsonfile -from jsonfile import PROJECT_ROOT - -DETAILS_FILE = "mediaDetails.json" -POSTERS_DIR = PROJECT_ROOT / "static" / "covers" / "media" -POSTERS_PUBLIC_PATH = "/covers/media" -MISSING_VALUE = "N/A" -REQUEST_HEADERS = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"} - - -def load_details() -> dict[str, dict]: - return jsonfile.load_data(DETAILS_FILE, {}) - - -def save_details(details: dict[str, dict]) -> None: - jsonfile.save_data(DETAILS_FILE, dict(sorted(details.items()))) - - -def present(value: str | None) -> str | None: - return value if value and value != MISSING_VALUE else None - - -def poster_path(identifier: str): - return POSTERS_DIR / f"{identifier}.jpg" - - -def download_poster(identifier: str, source_url: str | None) -> str | None: - if not source_url: - return None - request = urllib.request.Request(source_url, headers=REQUEST_HEADERS) - with urllib.request.urlopen(request, timeout=15) as response: - image = response.read() - POSTERS_DIR.mkdir(parents=True, exist_ok=True) - poster_path(identifier).write_bytes(image) - return f"{POSTERS_PUBLIC_PATH}/{identifier}.jpg" - - -def has_local_poster(identifier: str, stored: dict) -> bool: - poster = stored.get("poster") - return poster is None or (poster.startswith(POSTERS_PUBLIC_PATH) and poster_path(identifier).exists()) - - -def has_details(identifier: str) -> bool: - stored = load_details().get(identifier) - return bool(stored) and has_local_poster(identifier, stored) - - -def fetch_details(identifier: str) -> dict: - result = imdb.lookup(identifier) - if not result: - raise ValueError(f"Could not look up {identifier} on OMDb. Check OMDB_API_KEY in .env") - - poster_source = present(result.get("Poster")) - try: - poster = download_poster(identifier, poster_source) - except OSError: - poster = poster_source - - stored = { - "title": present(result.get("Title")), - "poster": poster, - "posterSource": poster_source, - "year": present(result.get("Year")), - "genre": present(result.get("Genre")), - "runtime": present(result.get("Runtime")), - "plot": present(result.get("Plot")), - } - details = load_details() - details[identifier] = stored - save_details(details) - return stored - - -def cached_details(identifier: str, fresh: bool = False) -> dict: - if not fresh and has_details(identifier): - return load_details()[identifier] - return fetch_details(identifier) - - -def remove_details(identifier: str) -> None: - details = load_details() - if details.pop(identifier, None) is not None: - save_details(details) - if poster_path(identifier).exists(): - poster_path(identifier).unlink() - - -def missing_identifiers(identifiers: list[str]) -> list[str]: - return [identifier for identifier in identifiers if not has_details(identifier)] diff --git a/tools/content/mobygames.py b/tools/content/mobygames.py new file mode 100644 index 0000000..2319aed --- /dev/null +++ b/tools/content/mobygames.py @@ -0,0 +1,209 @@ +import argparse +import html +import json +import re +import sys +from pathlib import Path + +import jsonfile +from html_page import download_page, meta_tags, page_title, plain_text + +GAME_URL_PATTERN = re.compile(r"mobygames\.com/game/(\d+)") +HEADING_PATTERN = re.compile(r"]*>(.*?)", re.S | re.I) +METADATA_PATTERN = re.compile(r"]*>(.*?)\s*]*>(.*?)", re.S | re.I) +LINK_PATTERN = re.compile(r"]*href=[\"']([^\"']*)[\"'][^>]*>(.*?)", re.S | re.I) +DESCRIPTION_PATTERN = re.compile(r"]*id=[\"']description-text[\"'][^>]*>(.*?)", re.S | re.I) +COVER_PATTERN = re.compile(r"https://cdn\.mobygames\.com/covers/[^\"'\s)]+") +SCREENSHOT_PATTERN = re.compile(r"https://cdn\.mobygames\.com/screenshots/[^\"'\s)]+") +NUMBER_PATTERN = re.compile(r"\d+(?:\.\d+)?") +PERCENT_PATTERN = re.compile(r"(\d+)\s*%") +BLOCKED_PAGE_HINTS = ["just a moment", "cf-chl", "captcha", "access denied", "are you a human"] +DETAILS_FILE = "gameDetails.json" +DESCRIPTION_MAX_LENGTH = 600 + + +class BlockedError(ValueError): + pass + + +def game_id(url: str) -> str | None: + match = GAME_URL_PATTERN.search(url) + return match.group(1) if match else None + + +def blocked_reason(page: str) -> str | None: + lowered = page.lower() + hint = next((hint for hint in BLOCKED_PAGE_HINTS if hint in lowered), None) + if hint and not METADATA_PATTERN.search(page): + return f"the page looks like a bot check (found '{hint}')" + return None + + +def metadata(page: str) -> dict[str, str]: + fields = {} + for label, value in METADATA_PATTERN.findall(page): + name = (plain_text(label) or "").strip().lower() + if name and name not in fields: + fields[name] = value + return fields + + +def field_starting_with(fields: dict[str, str], *prefixes: str) -> str: + return next((value for name, value in fields.items() if name.startswith(prefixes)), "") + + +def link_texts(fragment: str, path_part: str | None = None) -> list[str]: + names = [] + for href, text in LINK_PATTERN.findall(fragment): + name = plain_text(text) + if name and (path_part is None or path_part in href) and name not in names: + names.append(name) + return names + + +def first_number(fragment: str) -> float | None: + match = NUMBER_PATTERN.search(plain_text(fragment) or "") + return float(match.group(0)) if match else None + + +def first_percent(fragment: str) -> int | None: + match = PERCENT_PATTERN.search(plain_text(fragment) or "") + return int(match.group(1)) if match else None + + +def short_description(text: str | None) -> str | None: + if not text or len(text) <= DESCRIPTION_MAX_LENGTH: + return text + return text[:DESCRIPTION_MAX_LENGTH].rsplit(" ", 1)[0] + "..." + + +def description_of(page: str, meta: dict) -> str | None: + match = DESCRIPTION_PATTERN.search(page) + text = plain_text(match.group(1)) if match else None + return short_description(text or meta.get("og:description") or meta.get("description")) + + +def title_of(page: str, meta: dict) -> str: + heading = HEADING_PATTERN.search(page) + title = plain_text(heading.group(1)) if heading else None + return title or meta.get("og:title") or page_title(page).split(" - ")[0] + + +def first_match(pattern: re.Pattern, page: str) -> str | None: + match = pattern.search(page) + return html.unescape(match.group(0)) if match else None + + +def parse_game(page: str, identifier: str, url: str) -> dict: + meta = meta_tags(page) + fields = metadata(page) + released = field_starting_with(fields, "released") + release_fields = "".join(value for name, value in fields.items() if name.startswith(("released", "releases", "platform"))) + genre_fields = "".join(fields.get(name, "") for name in ["genre", "perspective", "gameplay", "setting"]) + + return { + "mobygamesId": identifier, + "url": url, + "title": title_of(page, meta), + "released": (link_texts(released) or [plain_text(released)])[0] if released else None, + "platforms": link_texts(release_fields, "/platform/"), + "developers": link_texts(field_starting_with(fields, "developer")), + "publishers": link_texts(field_starting_with(fields, "publisher")), + "genres": link_texts(genre_fields), + "mobyScore": first_number(field_starting_with(fields, "moby score")), + "criticScore": first_percent(field_starting_with(fields, "critic")), + "playerScore": first_number(field_starting_with(fields, "player")), + "description": description_of(page, meta), + "cover": first_match(COVER_PATTERN, page) or meta.get("og:image"), + "screenshot": first_match(SCREENSHOT_PATTERN, page), + } + + +def scrape_game(url: str) -> dict: + identifier = game_id(url) + if not identifier: + raise ValueError(f"not a MobyGames game url: {url}") + page = download_page(url) + reason = blocked_reason(page.text) + if reason: + raise BlockedError(f"MobyGames blocked the request ({reason}), wait a few minutes and try again") + return parse_game(page.text, identifier, url) + + +def save_game(game: dict) -> None: + details = jsonfile.load_data(DETAILS_FILE, {}) + details[game["mobygamesId"]] = {key: value for key, value in game.items() if key != "mobygamesId"} + jsonfile.save_data(DETAILS_FILE, dict(sorted(details.items()))) + + +def print_diagnostics(url: str, save_path: Path | None): + page = download_page(url) + print(f"Status {page.status}") + print(f"Final url {page.final_url}") + print(f"Size {len(page.text):,} characters") + print(f"Title {page_title(page.text) or '-'}") + print(f"Fields {', '.join(metadata(page.text)) or '-'}") + print(f"Meta tags {len(meta_tags(page.text))}") + print(f"Blocked {blocked_reason(page.text) or 'no sign of a bot check'}") + if save_path: + save_path.write_text(page.text, encoding="utf-8") + print(f"Saved html to {save_path}") + + +def print_summary(game: dict): + print(game["title"] or "unknown title") + print("-" * 60) + for label, key in [ + ("Released", "released"), + ("Platforms", "platforms"), + ("Developers", "developers"), + ("Publishers", "publishers"), + ("Genres", "genres"), + ("Moby score", "mobyScore"), + ("Critics", "criticScore"), + ("Players", "playerScore"), + ("Cover", "cover"), + ("Screenshot", "screenshot"), + ]: + value = game[key] + text = ", ".join(value) if isinstance(value, list) else value + print(f"{label:<11} {text if text not in (None, '') else '-'}") + if game["description"]: + print(f"\n{game['description']}") + missing = [key for key, value in game.items() if value in (None, "", [])] + if missing: + print(f"\nNot found on the page: {', '.join(missing)}") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Scrape video game info from MobyGames") + parser.add_argument("urls", nargs="+", help="MobyGames game urls, for example https://www.mobygames.com/game/223472/hades-ii/") + parser.add_argument("--json", action="store_true", help="print the raw result as JSON") + parser.add_argument("--save", action="store_true", help=f"save the result into data/{DETAILS_FILE} for the site") + parser.add_argument("--debug", action="store_true", help="show what the downloaded page contains instead of parsing it") + parser.add_argument("--save-html", type=Path, help="with --debug, save the downloaded page to this file") + arguments = parser.parse_args() + + exit_code = 0 + for url in arguments.urls: + try: + if arguments.debug: + print_diagnostics(url, arguments.save_html) + continue + game = scrape_game(url) + except (OSError, ValueError) as error: + print(f"Error for {url}: {error}", file=sys.stderr) + exit_code = 1 + continue + if arguments.save: + save_game(game) + if arguments.json: + print(json.dumps(game, indent="\t", ensure_ascii=False)) + else: + print_summary(game) + print() + return exit_code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/content/tui/books_pane.py b/tools/content/tui/books_pane.py index 5b6d4e2..70d96c9 100644 --- a/tools/content/tui/books_pane.py +++ b/tools/content/tui/books_pane.py @@ -7,13 +7,13 @@ from textual.binding import Binding from textual.containers import Horizontal, VerticalScroll from textual.widgets import DataTable, Static -import book_cache import books import goodreads import goodreads_series +import library_api from tui.cache_indicator import cache_state, cache_text -from tui.content_pane import ContentPane from tui.dialogs import ConfirmDialog, FormDialog, FormField +from tui.library_pane import LibraryPane from tui.series_picker import SeriesPicker REQUEST_DELAY_SECONDS = 3 @@ -29,6 +29,8 @@ def book_details(book: dict) -> str: ("Published", book.get("published")), ("Genres", ", ".join(book.get("genres") or [])), ("Added", book.get("added")), + ("Started", book.get("started")), + ("Finished", book.get("finished")), ("Cache", book_cache_state(book)), ] lines = [f"[b]{escape(book.get('title', ''))}[/b]", escape(", ".join(book.get("authors") or [])), ""] @@ -41,15 +43,16 @@ def book_details(book: dict) -> str: def book_cache_state(book: dict) -> str: has_details = bool(book.get("authors")) or bool(book.get("description")) - return cache_state(has_details, book_cache.is_local_cover(book.get("cover"))) + return cache_state(has_details, library_api.is_stored_image(book.get("cover"))) -class BooksPane(ContentPane): +class BooksPane(LibraryPane): BINDINGS = [ Binding("a", "add", "Add"), Binding("S", "add_series", "Add series"), Binding("e", "rate", "Rate"), Binding("s", "shelf", "Shelf"), + Binding("h", "dates", "Dates"), Binding("d", "delete", "Delete"), Binding("u", "refresh", "Refresh"), Binding("U", "refresh_all", "Refresh all"), @@ -57,7 +60,9 @@ class BooksPane(ContentPane): Binding("r", "reload", "Reload"), ] - content_file = "books.json" + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.books: dict[str, dict] = {} def compose(self) -> ComposeResult: with Horizontal(classes="split-layout"): @@ -74,14 +79,19 @@ class BooksPane(ContentPane): table.add_column("Cache", key="cache") self.reload() + def current_shelves(self) -> dict[str, str]: + return {identifier: book.get("shelf", "read") for identifier, book in self.books.items()} + def book_by_id(self, identifier: str | None) -> dict | None: - return next((book for book in books.load_books() if book.get("goodreadsId") == identifier), None) + return self.books.get(identifier) if identifier else None def reload(self, focus_key: str | None = None): table = self.table() table.clear() shelf_order = {shelf: index for index, shelf in enumerate(books.SHELVES)} - sorted_books = sorted(books.load_books(), key=lambda book: (shelf_order.get(book.get("shelf"), 99), -(book.get("rating") or -1))) + loaded = self.load_entries(books.load_books) + self.books = {book["goodreadsId"]: book for book in loaded} + sorted_books = sorted(loaded, key=lambda book: (shelf_order.get(book.get("shelf"), 99), -(book.get("rating") or -1))) for book in sorted_books: rating = f"{book['rating']}/10" if book.get("rating") is not None else "-" authors = ", ".join(book.get("authors") or []) @@ -141,6 +151,13 @@ class BooksPane(ContentPane): self.app.push_screen(FormDialog("Move to shelf", fields, "Move"), moved) + def action_dates(self): + book = self.book_by_id(self.selected_key()) + if not book: + return + identifier = book["goodreadsId"] + self.edit_dates(book["title"], book, lambda started, finished: books.set_dates(identifier, started, finished), identifier) + def action_delete(self): book = self.book_by_id(self.selected_key()) if not book: @@ -173,7 +190,7 @@ class BooksPane(ContentPane): @work(thread=True, exclusive=True, group="refresh") def refresh_every_book(self): refreshed = 0 - all_books = books.load_books() + all_books = list(self.books.values()) for index, book in enumerate(all_books): if index: time.sleep(REQUEST_DELAY_SECONDS) @@ -190,9 +207,9 @@ class BooksPane(ContentPane): self.app.call_from_thread(self.notify, f"Refreshed {refreshed} of {len(all_books)} books") def action_cache_covers(self): - missing = books.books_missing_local_covers() + missing = [book for book in self.books.values() if book.get("coverSource") and not library_api.is_stored_image(book.get("cover"))] if not missing: - self.notify("Every cover is already saved in static/covers/books") + self.notify("Every cover is already saved in the library") return self.notify(f"Saving {len(missing)} covers...") self.cache_missing_covers(missing) @@ -202,7 +219,7 @@ class BooksPane(ContentPane): saved = 0 for book in missing: try: - books.cache_book_cover(book["goodreadsId"]) + books.store_book_cover(book) except (OSError, ValueError) as error: self.app.call_from_thread(self.notify, f"{book['title']}: {error}", title="Skipped", severity="warning") continue @@ -225,12 +242,12 @@ class BooksPane(ContentPane): self.notify(f"Adding {len(choices)} books from {series['name']}...") self.add_series_books(series, choices) - self.app.push_screen(SeriesPicker(series, books.library_shelves()), picked) + self.app.push_screen(SeriesPicker(series, self.current_shelves()), picked) @work(thread=True, exclusive=True, group="series") def add_series_books(self, series: dict, choices: dict[str, str]): added = 0 - already_listed = set(books.library_shelves()) + already_listed = set(self.current_shelves()) fetched_any = False for entry in series["books"]: shelf = choices.get(entry["goodreadsId"]) diff --git a/tools/content/tui/content_pane.py b/tools/content/tui/content_pane.py index 633da04..354dfb1 100644 --- a/tools/content/tui/content_pane.py +++ b/tools/content/tui/content_pane.py @@ -7,6 +7,7 @@ from textual.widgets import DataTable class ContentPane(Vertical): content_file = "" + reload_message = "Reloaded from disk" def __init__(self, **kwargs): super().__init__(**kwargs) @@ -40,7 +41,7 @@ class ContentPane(Vertical): def action_reload(self): self.reload(self.selected_key()) - self.notify("Reloaded from disk") + self.notify(self.reload_message) def run_now(self, task: Callable[[], Any], message: str | Callable[[Any], str], focus_key: str | None = None): try: diff --git a/tools/content/tui/library_pane.py b/tools/content/tui/library_pane.py new file mode 100644 index 0000000..a8491da --- /dev/null +++ b/tools/content/tui/library_pane.py @@ -0,0 +1,33 @@ +from typing import Any, Callable + +import library_api +from dates import check_date +from tui.content_pane import ContentPane +from tui.dialogs import FormDialog, FormField + + +class LibraryPane(ContentPane): + reload_message = "Reloaded from the library" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.border_title = f"[b]kitty[/b] {library_api.api_url()}/api/library" + + def load_entries(self, load: Callable[[], list[dict]]) -> list[dict]: + try: + return load() + except (OSError, ValueError) as error: + self.notify(str(error), title="Library", severity="error", timeout=10) + return [] + + def edit_dates(self, title: str, entry: dict, save: Callable[[str | None, str | None], Any], focus_key: str): + fields = [ + FormField("started", "Started (YYYY-MM-DD, empty to clear)", value=entry.get("started", ""), placeholder="2026-01-31"), + FormField("finished", "Finished (YYYY-MM-DD, empty to clear)", value=entry.get("finished", ""), placeholder="2026-02-14"), + ] + + def saved(values: dict | None): + if values is not None: + self.run_now(lambda: save(check_date(values["started"]), check_date(values["finished"])), f"Saved dates for {title}", focus_key) + + self.app.push_screen(FormDialog(f"History for {title}", fields), saved) diff --git a/tools/content/tui/media_pane.py b/tools/content/tui/media_pane.py index b40d520..c953809 100644 --- a/tools/content/tui/media_pane.py +++ b/tools/content/tui/media_pane.py @@ -8,10 +8,9 @@ from textual.widgets import DataTable import imdb import media -import media_cache from tui.cache_indicator import cache_state, cache_text -from tui.content_pane import ContentPane from tui.dialogs import ConfirmDialog, FormDialog, FormField +from tui.library_pane import LibraryPane REQUEST_DELAY_SECONDS = 0.5 STATUS_STYLES = {"watching": "blue", "watched": "green", "planning": "magenta"} @@ -21,25 +20,25 @@ def rating_text(item: dict) -> str: return f"{item['rating']}/10" if item.get("rating") is not None else "-" -class MediaPane(ContentPane): +class MediaPane(LibraryPane): BINDINGS = [ Binding("a", "add", "Add"), Binding("e", "rate", "Rate"), Binding("s", "status", "Status"), Binding("g", "collection", "Group"), + Binding("h", "dates", "Dates"), Binding("d", "delete", "Delete"), Binding("u", "refresh", "Refresh"), Binding("C", "cache_all", "Cache all"), Binding("r", "reload", "Reload"), ] - content_file = "about.json" - def __init__(self, kind: str, **kwargs): super().__init__(**kwargs) self.kind = kind self.label = media.MEDIA_KINDS[kind]["label"] self.border_subtitle = media.MEDIA_KINDS[kind]["key"] + self.items: dict[str, dict] = {} def compose(self) -> ComposeResult: yield DataTable(cursor_type="row", zebra_stripes=True) @@ -51,6 +50,7 @@ class MediaPane(ContentPane): table.add_column("Title", key="title") table.add_column("Group", key="collection") table.add_column("Year", key="year") + table.add_column("Finished", key="finished") table.add_column("Cache", key="cache") table.add_column("IMDb", key="imdb") self.reload() @@ -58,30 +58,29 @@ class MediaPane(ContentPane): def reload(self, focus_key: str | None = None): table = self.table() table.clear() - stored = media_cache.load_details() - for item in media.list_items(self.kind): - identifier = imdb.imdb_id(item.get("url", "")) or item.get("name", "") + items = self.load_entries(lambda: media.list_items(self.kind)) + self.items = {item["imdbId"]: item for item in items} + for item in items: status = media.status_of(item) - details = stored.get(identifier) - state = cache_state(bool(details), bool(details) and media_cache.has_local_poster(identifier, details)) + state = cache_state(media.has_details(item), media.has_stored_poster(item)) table.add_row( Text(status, style=STATUS_STYLES[status]), rating_text(item), item.get("name", ""), item.get("collection", ""), - (details or {}).get("year") or "-", + item["details"].get("year") or "-", + item.get("finished") or "-", cache_text(state), - identifier, - key=identifier, + item["imdbId"], + key=item["imdbId"], ) self.restore_cursor(focus_key) def selected_item(self) -> dict | None: - identifier = self.selected_key() - return next((item for item in media.list_items(self.kind) if imdb.imdb_id(item.get("url", "")) == identifier), None) + return self.items.get(self.selected_key()) def collection_hint(self) -> str: - names = media.collection_names(self.kind) + names = sorted({item["collection"] for item in self.items.values() if item.get("collection")}) return f"Existing: {', '.join(names)}" if names else "e.g. Star Wars" def action_add(self): @@ -113,7 +112,7 @@ class MediaPane(ContentPane): item = self.selected_item() if not item: return - identifier = imdb.imdb_id(item["url"]) + identifier = item["imdbId"] current = "" if item.get("rating") is None else str(item["rating"]) fields = [FormField("rating", f"Rating for {item['name']} (empty to clear)", kind="rating", value=current)] @@ -127,7 +126,7 @@ class MediaPane(ContentPane): item = self.selected_item() if not item: return - identifier = imdb.imdb_id(item["url"]) + identifier = item["imdbId"] fields = [FormField("status", f"Status for {item['name']}", kind="select", options=media.STATUSES, value=media.status_of(item))] def changed(values: dict | None): @@ -141,7 +140,7 @@ class MediaPane(ContentPane): item = self.selected_item() if not item: return - identifier = imdb.imdb_id(item["url"]) + identifier = item["imdbId"] fields = [FormField("collection", f"Group for {item['name']} (empty to remove)", value=item.get("collection", ""), placeholder=self.collection_hint())] def grouped(values: dict | None): @@ -152,32 +151,39 @@ class MediaPane(ContentPane): self.app.push_screen(FormDialog("Group titles", fields), grouped) - def action_delete(self): - identifier = self.selected_key() - if not identifier: + def action_dates(self): + item = self.selected_item() + if not item: return - name = self.table().get_cell(identifier, "title") + identifier = item["imdbId"] + self.edit_dates(item["name"], item, lambda started, finished: media.set_dates(self.kind, identifier, started, finished), identifier) + + def action_delete(self): + item = self.selected_item() + if not item: + return + identifier = item["imdbId"] def confirmed(should_delete: bool | None): if should_delete: - self.run_now(lambda: media.remove_item(self.kind, identifier), f"Removed {name}") + self.run_now(lambda: media.remove_item(self.kind, identifier), f"Removed {item['name']}") - self.app.push_screen(ConfirmDialog(f"Remove {name}?"), confirmed) + self.app.push_screen(ConfirmDialog(f"Remove {item['name']}?"), confirmed) def action_refresh(self): item = self.selected_item() if not item: return - identifier = imdb.imdb_id(item["url"]) + identifier = item["imdbId"] - def refreshed(details: dict): + def refreshed(_saved: dict): self.reload(identifier) self.notify(f"Saved details and poster for {item['name']}") self.run_in_background(f"Looking up {item['name']} on OMDb...", lambda: media.refresh_details(identifier), refreshed) def action_cache_all(self): - missing = media.missing_details(self.kind) + missing = [identifier for identifier, item in self.items.items() if not media.has_details(item) or not media.has_stored_poster(item)] if not missing: self.notify(f"Every {self.label} already has saved details and a poster") return diff --git a/vite.config.ts b/vite.config.ts index 5733554..bd43a17 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,5 +14,11 @@ export default defineConfig({ }, adapter: adapter() }) - ] + ], + server: { + port: 5000 + }, + preview: { + port: 5000 + } });