Compare commits

..
5 Commits
119 changed files with 5181 additions and 1059 deletions

No files matched your search

+6
View File
@@ -2,4 +2,10 @@ DISCORD_TOKEN=
DISCORD_USER_ID=
DISCORD_GUILD_ID=
OMDB_API_KEY=
IGDB_CLIENT_ID=
IGDB_CLIENT_SECRET=
LASTFM_API_KEY=
LIBRARY_API_KEY=
LIBRARY_API_URL=http://localhost:5000
LIBRARY_PUSH_URL=https://dev.sirblob.co
LIBRARY_PUSH_KEY=
+1
View File
@@ -26,6 +26,7 @@ bun.lock
__pycache__/
.cache/
data/lastfm/
data/library/
Update.md
PROJECT.md
blogs/
+4 -3
View File
@@ -18,11 +18,12 @@ 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
COPY --from=build /app/static ./static
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"]
+70 -21
View File
@@ -22,11 +22,13 @@ The site recreates my [blomarchy](https://git.sirblob.co/SirBlob/blomarchy) setu
- **Floating windows** - Alt + V or the title bar button pops a window out of the tiling layout so it can be dragged and resized.
- **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.
- **File explorer** - a thunar window that browses `static/files` with grid and list views, a gallery of image thumbnails, previews for images, video and audio, and the image viewer for opening pictures.
- **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 (files, tcg, movies, shows, books, games), 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, books and games 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. Games can also be marked as a favorite or liked, with tags and a note, and the about page shows those starred games.
- **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 +71,8 @@ Alt stands in for Super, since browsers can't use it.
| `theme [name]` | list or switch themes |
| `cowsay <text>` | moo |
| `btop` | system monitor with my GitHub stats |
| `thunar` / `files` | browse `static/files` |
| `movies` / `shows` / `books` / `games library` | open the library apps |
| `pacman -S <pkg>` | install things (not really) |
## Running
@@ -87,7 +91,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,58 +100,103 @@ 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 |
| `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Looking up games on IGDB in the content editor, from a Confidential app in the [Twitch developer console](https://dev.twitch.tv/console) |
| `LASTFM_API_KEY` | Collecting Last.fm scrobbles for the listening stats |
| `LIBRARY_API_KEY` | Secret the content editor sends to change shows, movies, books and games. Set the same value on the server and locally |
| `LIBRARY_API_URL` | Site the content editor talks to, `http://localhost:5000` by default |
| `LIBRARY_PUSH_URL` | Live site the Push to website button copies the library to, for example `https://dev.sirblob.co` |
| `LIBRARY_PUSH_KEY` | `LIBRARY_API_KEY` of that live site, when it differs from the local one |
| `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, books and games 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, book and game, public |
| `PUT /api/library/media/<imdbId>` | Add or change a show or movie |
| `DELETE /api/library/media/<imdbId>` | Remove a show or movie |
| `PUT /api/library/books/<goodreadsId>` | Add or change a book |
| `DELETE /api/library/books/<goodreadsId>` | Remove a book |
| `PUT /api/library/games/<igdbId>` | Add or change a game |
| `DELETE /api/library/games/<igdbId>` | Remove a game |
| `GET /api/library/export` | Every entry with all its fields and image sources, needs the API key |
| `GET /api/library/images/<id>` | A saved poster, cover or game image |
Changes need `Authorization: Bearer <LIBRARY_API_KEY>`. A `PUT` only changes the fields it sends, and `null` clears one. When a status changes to watching, reading or playing the start date is set, and to watched, read or played the finish date, unless the request sends its own. Posters, covers and game images are downloaded by the server and kept in the database. A site path such as `/img/minecraft.png` is used as it is.
### Content editor
A terminal app built with [Textual](https://textual.textualize.io/) edits the playlist, shows, movies, books, portfolio, projects and themes without touching the JSON by hand. It uses the site's own themes.
A terminal app built with [Textual](https://textual.textualize.io/) edits the playlist, shows, movies, books, games, portfolio, projects and themes without touching the JSON by hand. It uses the site's own themes.
```bash
bun run content:setup
bun run content
```
`content:setup` creates a virtual environment in `tools/content/.venv` and installs Textual and Pillow. Run it once.
`content:setup` creates a virtual environment in `tools/content/.venv` and installs Textual, Pillow and pillow-heif. Run it once, and again when `requirements.txt` changes.
| Key | Action |
| --- | --- |
| `1` to `9` | Switch between Music, Shows, Movies, Books, Profile, Experience, Leadership, Projects and Themes |
| `a` | Add (YouTube link, IMDb link or Goodreads link) |
| `e` / `Enter` | Edit the selected entry (or change the rating for shows, movies and books) |
| `f` | Toggle featured on a project |
| `1` to `9`, `0` | Switch between Music, Shows, Movies, Books, Games, Profile, Experience, Leadership, Projects and Themes |
| `a` | Add (YouTube link, IMDb link, Goodreads link, or a store link, IGDB link or name for a game) |
| `S` | Add books from a Goodreads series |
| `e` / `Enter` | Edit the selected entry (or change the rating for shows, movies, books and games) |
| `g` | Put a show or movie in a group, which the library apps and about page show together, or set a game's tags and note |
| `h` | Set the started and finished dates for a show, movie, book or game |
| `l` | Change a game's store link or image |
| `f` | Toggle featured on a project, or make a game your favorite |
| `k` | Add a game to the games you like (up to 20) |
| `p` | Preview a theme in the editor |
| `t` | Switch the editor to the next theme |
| `s` | Move a book to another shelf, set a show or movie to watched, watching or planning, or make a theme the site default |
| `s` | Move a book to another shelf, set a show, movie or game status (a game can also be replayable), or make a theme the site default |
| `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 |
| `u` / `U` | Refresh the selected book, or every book, from Goodreads; `u` also refreshes a show or movie from OMDb, or a game from IGDB |
| `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 |
| `P` | Push the whole library to the live site |
| `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 <url>`.
The Shows, Movies, Books and Games 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. To work on a local copy instead, keep `LIBRARY_API_URL` on `localhost` and press `P` (or run `bun run library:push`) to copy every show, movie, book and game to `LIBRARY_PUSH_URL`. It asks first, overwrites the entries there with the local ones, removes entries that only exist there, and refuses to push an empty library. Images are downloaded again by the live site from their original sources. 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 <url>`.
Games are looked up on IGDB with `IGDB_CLIENT_ID` and `IGDB_CLIENT_SECRET`. A Steam or Xbox link is matched to its IGDB entry directly; any other store link (Nintendo, Epic, GOG and so on) is searched by the name in the link, or by the name you enter. An IGDB link such as `https://www.igdb.com/games/hades-ii` always matches exactly. IGDB fills in the release date, platforms, developers, publishers, genres, summary, rating and a 16:9 artwork image; enter your own image to use it instead. To check a lookup on its own, run `python3 tools/content/igdb.py <link or name>`.
To move the games that used to be listed in `about.json` into the library once, run `bun run games:import`. It removes them from `about.json` only when every game was imported.
| File | Holds |
| --- | --- |
| `profile.json` | Name, bio, socials, skills and photos |
| `about.json` | Hobbies, games, shows, movies and the AniList username |
| `about.json` | Hobbies, setup and the AniList and Last.fm usernames |
| `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, games, their history dates and saved images |
| `lastfm/lastfm.db` | Last.fm scrobbles, album and track cover links and saved cover images |
### Files
Files you drop into `static/files` (any folders inside it too) show up in the file explorer and are served at `/files/...`. Hidden files are skipped.
```sh
bun run files
```
Converts every HEIC or HEIF photo in `static/files` to a JPEG, turned the right way up and without metadata, and removes GPS location data from any other photo in there since the folder is public. Converted originals are moved to `tools/content/.cache/heic-originals`; pass `--delete-originals` to delete them instead, or `--keep-location` to leave location data alone. Run `bun run content:setup` again first to install `pillow-heif`.
A hobby in `about.json` can have a `folder`, such as `"folder": "robotics"`. Clicking that hobby on the about page opens the file explorer in `static/files/robotics`.
### Wallpapers
+1 -46
View File
@@ -33,51 +33,6 @@
"hobbies": [
{ "name": "Swimming", "icon": "proicons:person-swim", "description": "I enjoy swimming as a way to relax and stay active. It is my favorite sport." },
{ "name": "Baking", "icon": "proicons:cake", "description": "I love baking pastries and experimenting with new dessert recipes." },
{ "name": "Robotics", "icon": "proicons:robot", "description": "Building and programming robots to solve problems is fun." }
],
"games": [
{ "name": "Minecraft", "image": "/img/minecraft.png", "url": "https://minecraft.net" },
{ "name": "Pokemon TCG Online", "image": "/img/pokemontcg.png", "url": "https://www.pokemon.com/us/pokemon-tcg" },
{ "name": "Fire Watch", "image": "/img/firewatch.png", "url": "https://firewatchgame.com" },
{ "name": "Expedition 33", "image": "/img/e33.png", "url": "https://expedition33.com" },
{ "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 }
],
"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", "rating": 11, "url": "https://www.imdb.com/title/tt0111161/" },
{ "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": "Robotics", "icon": "proicons:robot", "description": "Building and programming robots to solve problems is fun.", "folder": "robotics" }
]
}
-233
View File
@@ -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" }
]
+6 -1
View File
@@ -37,7 +37,12 @@
"name": "apps",
"side": "right",
"icons": [
{ "label": "tcg", "icon": "mdi:pokeball", "open": { "app": "tcg" } }
{ "label": "files", "icon": "mdi:folder-open-outline", "open": { "app": "files" } },
{ "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" } },
{ "label": "games", "icon": "mdi:gamepad-variant-outline", "open": { "app": "games" } }
]
},
{
-39
View File
@@ -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 }
}
-33
View File
@@ -1,33 +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." },
"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." },
"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." },
"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." },
"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." },
"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." },
"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." },
"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." }
}
+4 -2
View File
@@ -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:
+4 -2
View File
@@ -10,6 +10,9 @@
"start": "bun --bun vite build && bun ./build/index.js",
"content": "tools/content/.venv/bin/python tools/content/app.py",
"wallpapers": "tools/content/.venv/bin/python tools/content/wallpapers.py",
"games:import": "tools/content/.venv/bin/python tools/content/games_import.py",
"files": "tools/content/.venv/bin/python tools/content/static_files.py",
"library:push": "tools/content/.venv/bin/python tools/content/library_push.py",
"content:setup":"python3 -m venv tools/content/.venv && tools/content/.venv/bin/pip install -r tools/content/requirements.txt",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
@@ -38,7 +41,6 @@
"imports": {
"#lib": "./src/lib/index.js",
"#lib/*": "./src/lib/*",
"#content/*": "./content/*",
"#data/*": "./data/*"
"#content/*": "./content/*"
}
}
+5 -1
View File
@@ -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'
}
});
+109 -12
View File
@@ -1,28 +1,54 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { Game } from '#lib/content';
import { storeFor } from '#lib/library/stores';
let { game }: { game: Game } = $props();
const storeNames: Record<string, { label: string; icon: string }> = {
'store.steampowered.com': { label: 'Steam', icon: 'mdi:steam' }
};
const store = $derived.by(() => {
const host = new URL(game.url).hostname;
return storeNames[host] ?? { label: host.replace(/^www\./, ''), icon: 'mdi:web' };
});
const link = $derived(game.url ?? game.igdbUrl ?? '');
const store = $derived(storeFor(link));
const releaseYear = $derived(game.released?.slice(0, 4) ?? null);
const metaLine = $derived([releaseYear, game.developers[0]].filter(Boolean).join(' · '));
</script>
<a class="game-card" href={game.url} target="_blank" rel="noopener noreferrer">
<a class="game-card" href={link || undefined} target="_blank" rel="noopener noreferrer" title={game.summary ?? game.name}>
<div class="cover">
<img src={game.image} alt="" loading="lazy" />
<span class="play"><Icon icon="mdi:play" width="16" /> Play</span>
{#if game.image}
<img src={game.image} alt="" loading="lazy" />
{:else}
<span class="placeholder"><Icon icon="mdi:gamepad-variant-outline" width="36" /></span>
{/if}
{#if game.favorite}
<span class="favorite"><Icon icon="mdi:star" width="13" />favorite</span>
{/if}
{#if game.rating !== undefined}
<span class="score"><Icon icon="mdi:star" width="13" />{game.rating}/10</span>
{:else if game.score !== null}
<span class="score community" title="IGDB rating"><Icon icon="mdi:star-outline" width="13" />{game.score}</span>
{/if}
{#if game.status === 'playing'}
<span class="status playing"><Icon icon="mdi:play" width="13" />playing</span>
{:else if game.status === 'planning'}
<span class="status planning"><Icon icon="mdi:bookmark-outline" width="13" />planning</span>
{:else if game.status === 'replayable'}
<span class="status replayable"><Icon icon="mdi:repeat" width="13" />replayable</span>
{/if}
{#if link}
<span class="play"><Icon icon="mdi:play" width="16" /> Play</span>
{/if}
</div>
<div class="details">
<span class="name">{game.name}</span>
<span class="store"><Icon icon={store.icon} width="14" />{store.label}</span>
{#if link}
<span class="store"><Icon icon={store.icon} width="14" />{store.label}</span>
{/if}
</div>
{#if metaLine}
<span class="meta">{metaLine}</span>
{/if}
{#if game.genres.length}
<span class="meta">{game.genres.slice(0, 3).join(' · ')}</span>
{/if}
</a>
<style>
@@ -57,6 +83,77 @@
transform: scale(1.05);
}
.placeholder {
display: grid;
place-items: center;
height: 100%;
color: var(--muted);
}
.score {
position: absolute;
top: 0.4rem;
right: 0.4rem;
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0 0.4rem;
background: var(--background);
color: var(--yellow);
font-size: 0.78rem;
}
.meta {
overflow: hidden;
color: var(--muted);
font-size: 0.75rem;
white-space: nowrap;
text-overflow: ellipsis;
}
.favorite {
position: absolute;
top: 0.4rem;
left: 0.4rem;
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0 0.4rem;
background: var(--yellow);
color: var(--background);
font-size: 0.75rem;
font-weight: 700;
}
.score.community {
color: var(--muted);
}
.status {
position: absolute;
bottom: 0.4rem;
left: 0.4rem;
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0 0.4rem;
color: var(--background);
font-size: 0.75rem;
font-weight: 700;
}
.status.playing {
background: var(--blue);
}
.status.planning {
background: var(--magenta);
}
.status.replayable {
background: var(--cyan);
}
.play {
position: absolute;
right: 0.5rem;
+27
View File
@@ -12,6 +12,9 @@
import Breakout from '../games/Breakout.svelte';
import DesktopPet from '../pet/DesktopPet.svelte';
import TcgBrowser from '../tcg/TcgBrowser.svelte';
import LibraryApp from '../library/LibraryApp.svelte';
import FileExplorer from '../files/FileExplorer.svelte';
import { libraryApps } from '#lib/library/apps';
import DesktopIcons from './DesktopIcons.svelte';
import Window from './Window.svelte';
@@ -178,6 +181,30 @@
</Window>
{/each}
{#each windowManager.libraryWindows as libraryWindow (libraryWindow.id)}
<Window
title={libraryWindow.app}
path="library"
icon={libraryApps[libraryWindow.app].icon}
{...windowProps(libraryWindow.id)}
onclose={() => windowManager.closeWindow(libraryWindow.id)}
>
<LibraryApp name={libraryWindow.app} />
</Window>
{/each}
{#each windowManager.fileExplorers as explorer (explorer.id)}
<Window
title="thunar"
path={explorer.startPath ? `~/files/${explorer.startPath}` : '~/files'}
icon="mdi:folder-open-outline"
{...windowProps(explorer.id)}
onclose={() => windowManager.closeWindow(explorer.id)}
>
<FileExplorer startPath={explorer.startPath} />
</Window>
{/each}
</div>
</div>
@@ -0,0 +1,137 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ExplorerState } from '#lib/files/explorer.svelte';
import { formatSize } from '#lib/files/types';
import FileToolbar from './FileToolbar.svelte';
import FileSidebar from './FileSidebar.svelte';
import FileGrid from './FileGrid.svelte';
import FileList from './FileList.svelte';
import FilePreview from './FilePreview.svelte';
let { startPath = '' }: { startPath?: string } = $props();
const explorer = new ExplorerState(startPath);
const fileCount = $derived(explorer.shownEntries.filter((entry) => entry.kind !== 'folder').length);
const folderCount = $derived(explorer.shownEntries.length - fileCount);
const totalSize = $derived(explorer.shownEntries.reduce((total, entry) => total + entry.size, 0));
const keyActions: Record<string, () => void> = {
ArrowLeft: () => explorer.moveSelection(-1),
ArrowUp: () => explorer.moveSelection(-1),
ArrowRight: () => explorer.moveSelection(1),
ArrowDown: () => explorer.moveSelection(1),
Enter: () => explorer.selected && explorer.open(explorer.selected),
Backspace: () => explorer.up()
};
function handleKeydown(event: KeyboardEvent) {
if (event.target instanceof HTMLInputElement) return;
const action = keyActions[event.key];
if (!action) return;
event.preventDefault();
action();
}
onMount(() => explorer.start());
</script>
<div class="explorer" role="application" aria-label="File explorer">
<FileToolbar {explorer} />
<div class="body">
<FileSidebar {explorer} />
<div class="files" tabindex="0" role="listbox" aria-label="Files" onkeydown={handleKeydown}>
{#if explorer.state === 'error'}
<p class="message">Could not open this folder.</p>
{:else if explorer.state === 'loading' && !explorer.entries.length}
<p class="message muted">Loading...</p>
{:else if !explorer.shownEntries.length}
<p class="message muted">{explorer.query ? 'No files match.' : 'This folder is empty.'}</p>
{:else if explorer.view === 'grid'}
<FileGrid {explorer} />
{:else}
<FileList {explorer} />
{/if}
</div>
{#if explorer.selected}
<FilePreview entry={explorer.selected} onopen={() => explorer.selected && explorer.open(explorer.selected)} />
{/if}
</div>
<footer class="status muted">
<span>{folderCount} folders, {fileCount} files ({formatSize(totalSize)})</span>
{#if explorer.selected}
<span class="selected-name">{explorer.selected.name}</span>
{/if}
</footer>
</div>
<style>
.explorer {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
container-type: inline-size;
}
.body {
display: flex;
flex: 1;
min-height: 0;
}
.files {
flex: 1;
min-width: 0;
overflow-y: auto;
outline: none;
}
.message {
margin: 0;
padding: 1rem;
}
.status {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.25rem 0.75rem;
border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
font-size: 0.75rem;
}
.selected-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@container (max-width: 720px) {
.body > :global(.sidebar) {
display: none;
}
}
@container (max-width: 560px) {
.body > :global(.preview) {
position: absolute;
right: 0;
bottom: 1.6rem;
left: 0;
max-height: 55%;
background: var(--background);
width: auto;
border-top: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
border-left: none;
}
.explorer {
position: relative;
}
}
</style>
+96
View File
@@ -0,0 +1,96 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { ExplorerState } from '#lib/files/explorer.svelte';
import { fileUrl, kindIcon } from '#lib/files/types';
let { explorer }: { explorer: ExplorerState } = $props();
</script>
<div class="grid">
{#each explorer.shownEntries as entry (entry.path)}
<button
class="item"
class:selected={entry.path === explorer.selectedPath}
title={entry.name}
onclick={() => explorer.select(entry)}
ondblclick={() => explorer.open(entry)}
>
<span class="thumb" class:folder={entry.kind === 'folder'}>
{#if entry.kind === 'image'}
<img src={fileUrl(entry.path)} alt="" loading="lazy" decoding="async" />
{:else}
<Icon icon={kindIcon(entry.kind)} width="44" />
{/if}
</span>
<span class="name">{entry.name}</span>
</button>
{/each}
</div>
<style>
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 0.5rem;
padding: 0.6rem;
}
.item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
min-width: 0;
padding: 0.4rem;
background: none;
border: 1px solid transparent;
color: var(--foreground);
}
.item:hover {
background: color-mix(in srgb, var(--foreground) 6%, transparent);
}
.item.selected {
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: var(--accent);
}
.thumb {
display: grid;
place-items: center;
width: 100%;
aspect-ratio: 1;
overflow: hidden;
color: var(--muted);
}
.thumb.folder {
color: var(--accent);
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
background: var(--dark-background);
}
.name {
display: -webkit-box;
width: 100%;
overflow: hidden;
font-size: 0.75rem;
text-align: center;
word-break: break-all;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
@container (max-width: 560px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
}
}
</style>
+98
View File
@@ -0,0 +1,98 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { ExplorerState } from '#lib/files/explorer.svelte';
import { formatSize, kindIcon, type FileEntry } from '#lib/files/types';
let { explorer }: { explorer: ExplorerState } = $props();
function sizeText(entry: FileEntry): string {
return entry.kind === 'folder' ? `${entry.itemCount ?? 0} items` : formatSize(entry.size);
}
</script>
<table class="list">
<thead>
<tr>
<th>Name</th>
<th class="size">Size</th>
<th class="modified">Modified</th>
</tr>
</thead>
<tbody>
{#each explorer.shownEntries as entry (entry.path)}
<tr
class:selected={entry.path === explorer.selectedPath}
onclick={() => explorer.select(entry)}
ondblclick={() => explorer.open(entry)}
>
<td class="name">
<Icon icon={kindIcon(entry.kind)} width="16" color={entry.kind === 'folder' ? 'var(--accent)' : undefined} />
<span>{entry.name}</span>
</td>
<td class="size">{sizeText(entry)}</td>
<td class="modified">{entry.modified.slice(0, 10)}</td>
</tr>
{/each}
</tbody>
</table>
<style>
.list {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
th {
position: sticky;
top: 0;
padding: 0.35rem 0.6rem;
background: var(--background);
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
color: var(--muted);
font-weight: 400;
text-align: left;
}
td {
padding: 0.25rem 0.6rem;
white-space: nowrap;
}
tr {
cursor: default;
}
tbody tr:hover {
background: color-mix(in srgb, var(--foreground) 6%, transparent);
}
tr.selected {
background: color-mix(in srgb, var(--accent) 18%, transparent);
}
.name {
display: flex;
align-items: center;
gap: 0.5rem;
max-width: 0;
width: 100%;
}
.name span {
overflow: hidden;
text-overflow: ellipsis;
}
.size,
.modified {
color: var(--muted);
text-align: right;
}
@container (max-width: 560px) {
.modified {
display: none;
}
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { fileUrl, formatSize, kindIcon, type FileEntry } from '#lib/files/types';
let { entry, onopen }: { entry: FileEntry; onopen: () => void } = $props();
const rows = $derived([
['Type', entry.kind],
['Size', entry.kind === 'folder' ? `${entry.itemCount ?? 0} items` : formatSize(entry.size)],
['Modified', entry.modified.slice(0, 10)],
['Path', fileUrl(entry.path)]
]);
</script>
<aside class="preview" aria-label="{entry.name} preview">
<div class="media">
{#if entry.kind === 'image'}
<img src={fileUrl(entry.path)} alt={entry.name} />
{:else if entry.kind === 'video'}
<video src={fileUrl(entry.path)} controls muted preload="metadata"></video>
{:else if entry.kind === 'audio'}
<audio src={fileUrl(entry.path)} controls preload="none"></audio>
{:else}
<Icon icon={kindIcon(entry.kind)} width="64" />
{/if}
</div>
<h3>{entry.name}</h3>
<dl>
{#each rows as [label, value] (label)}
<dt>{label}</dt>
<dd>{value}</dd>
{/each}
</dl>
<button class="open" onclick={onopen}>
<Icon icon={entry.kind === 'folder' ? 'mdi:folder-open-outline' : 'mdi:open-in-new'} width="15" />
{entry.kind === 'folder' ? 'Open folder' : entry.kind === 'image' ? 'Open in viewer' : 'Open file'}
</button>
</aside>
<style>
.preview {
display: flex;
flex-direction: column;
gap: 0.6rem;
width: 240px;
padding: 0.75rem;
overflow-y: auto;
border-left: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
font-size: 0.8rem;
}
.media {
display: grid;
place-items: center;
min-height: 120px;
background: var(--dark-background);
color: var(--muted);
}
.media img,
.media video {
max-width: 100%;
max-height: 220px;
object-fit: contain;
}
.media audio {
width: 100%;
}
h3 {
margin: 0;
color: var(--accent);
font-size: 0.9rem;
word-break: break-all;
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.6rem;
margin: 0;
}
dt {
color: var(--blue);
font-weight: 700;
}
dd {
margin: 0;
word-break: break-all;
}
.open {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.3rem 0.6rem;
background: none;
border: 1px solid color-mix(in srgb, var(--foreground) 30%, transparent);
color: var(--foreground);
}
.open:hover {
border-color: var(--accent);
color: var(--accent);
}
</style>
@@ -0,0 +1,63 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { ExplorerState } from '#lib/files/explorer.svelte';
let { explorer }: { explorer: ExplorerState } = $props();
const topFolder = $derived(explorer.path.split('/')[0]);
</script>
<aside class="sidebar" aria-label="Places">
<span class="heading">Places</span>
<button class:active={explorer.path === ''} onclick={() => explorer.navigate('')}>
<Icon icon="mdi:folder-home-outline" width="16" />files
</button>
{#each explorer.places as place (place.path)}
<button class:active={topFolder === place.path} onclick={() => explorer.navigate(place.path)}>
<Icon icon="mdi:folder-outline" width="16" />{place.name}
</button>
{/each}
</aside>
<style>
.sidebar {
display: flex;
flex-direction: column;
gap: 0.1rem;
width: 170px;
padding: 0.5rem 0.4rem;
overflow-y: auto;
border-right: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
.heading {
padding: 0 0.4rem 0.3rem;
color: var(--muted);
font-size: 0.72rem;
text-transform: uppercase;
}
button {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.25rem 0.4rem;
overflow: hidden;
background: none;
border: none;
color: var(--foreground);
font-size: 0.82rem;
text-align: left;
white-space: nowrap;
text-overflow: ellipsis;
}
button:hover {
background: color-mix(in srgb, var(--foreground) 8%, transparent);
}
button.active {
background: color-mix(in srgb, var(--foreground) 14%, transparent);
color: var(--accent);
}
</style>
+131
View File
@@ -0,0 +1,131 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { ExplorerState } from '#lib/files/explorer.svelte';
let { explorer }: { explorer: ExplorerState } = $props();
const navButtons = $derived([
{ label: 'Back', icon: 'mdi:arrow-left', enabled: explorer.canGoBack, run: () => explorer.back() },
{ label: 'Forward', icon: 'mdi:arrow-right', enabled: explorer.canGoForward, run: () => explorer.forward() },
{ label: 'Up', icon: 'mdi:arrow-up', enabled: explorer.canGoUp, run: () => explorer.up() },
{ label: 'Reload', icon: 'mdi:refresh', enabled: true, run: () => explorer.reload() }
]);
</script>
<div class="toolbar">
<div class="nav">
{#each navButtons as button (button.label)}
<button title={button.label} aria-label={button.label} disabled={!button.enabled} onclick={button.run}>
<Icon icon={button.icon} width="16" />
</button>
{/each}
</div>
<nav class="crumbs" aria-label="Folder path">
<button onclick={() => explorer.navigate('')}><Icon icon="mdi:folder-home-outline" width="15" />files</button>
{#each explorer.crumbs as crumb (crumb.path)}
<span class="separator">/</span>
<button onclick={() => explorer.navigate(crumb.path)}>{crumb.name}</button>
{/each}
</nav>
<label class="search">
<Icon icon="mdi:magnify" width="15" />
<input bind:value={explorer.query} placeholder="Filter" aria-label="Filter files" />
</label>
<div class="views" role="group" aria-label="View">
<button class:active={explorer.view === 'grid'} title="Grid" aria-label="Grid view" onclick={() => (explorer.view = 'grid')}>
<Icon icon="mdi:view-grid-outline" width="16" />
</button>
<button class:active={explorer.view === 'list'} title="List" aria-label="List view" onclick={() => (explorer.view = 'list')}>
<Icon icon="mdi:view-list-outline" width="16" />
</button>
</div>
</div>
<style>
.toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
.nav,
.views {
display: flex;
flex-shrink: 0;
}
button {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem;
background: none;
border: none;
color: var(--muted);
}
button:hover:not(:disabled),
button.active {
color: var(--accent);
}
button:disabled {
opacity: 0.35;
}
.crumbs {
display: flex;
flex: 1;
align-items: center;
min-width: 0;
padding: 0.1rem 0.4rem;
overflow-x: auto;
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
font-size: 0.82rem;
white-space: nowrap;
}
.crumbs button {
color: var(--foreground);
}
.separator {
color: var(--muted);
}
.search {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.1rem 0.4rem;
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
color: var(--muted);
}
.search:focus-within {
border-color: var(--accent);
}
.search input {
width: 8rem;
padding: 0;
background: none;
border: none;
outline: none;
box-shadow: none;
color: var(--foreground);
font: inherit;
font-size: 0.8rem;
}
@container (max-width: 560px) {
.search {
display: none;
}
}
</style>
@@ -0,0 +1,233 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from '@iconify/svelte';
import type { LibraryAppName } from '#lib/content';
import { libraryApps, statusOf } from '#lib/library/apps';
import { averageRating, filterEntries, finishedThisYear, groupEntries, type LibraryFilter } from '#lib/library/filter';
import { libraryStore } from '#lib/library/store.svelte';
import LibraryToolbar from './LibraryToolbar.svelte';
import LibraryCard from './LibraryCard.svelte';
import LibraryDetail from './LibraryDetail.svelte';
import LibraryHistory from './LibraryHistory.svelte';
import LibraryGroup from './LibraryGroup.svelte';
type LibraryTab = 'shelf' | 'history';
let { name }: { name: LibraryAppName } = $props();
const tabs: { id: LibraryTab; label: string; icon: string }[] = [
{ id: 'shelf', label: 'shelf', icon: 'mdi:view-grid-outline' },
{ id: 'history', label: 'history', icon: 'mdi:timeline-clock-outline' }
];
let tab = $state<LibraryTab>('shelf');
let filter = $state<LibraryFilter>({ status: null, query: '', sort: 'recent' });
let selectedId = $state<string | null>(null);
const app = $derived(libraryApps[name]);
const finishedLabel = $derived(app.statuses[1].label);
const entries = $derived(app.entries(libraryStore.data));
const shownEntries = $derived(filterEntries(entries, filter));
const grouped = $derived(groupEntries(shownEntries));
const selected = $derived(entries.find((entry) => entry.id === selectedId) ?? null);
const counts = $derived(
Object.fromEntries(app.statuses.map((status) => [status.id, entries.filter((entry) => entry.status === status.id).length]))
);
function toggleSelected(id: string) {
selectedId = id === selectedId ? null : id;
}
onMount(() => {
libraryStore.load();
});
</script>
<div class="library-app">
<nav class="tabs" aria-label="{app.title} sections">
{#each tabs as item (item.id)}
<button class:active={tab === item.id} onclick={() => (tab = item.id)}>
<Icon icon={item.icon} width="16" />{item.label}
</button>
{/each}
<span class="summary muted">
{entries.length} {app.itemLabel} · avg {averageRating(entries)} · {finishedThisYear(entries)} {finishedLabel} this year
</span>
<button class="reload" title="Reload" aria-label="Reload" onclick={() => libraryStore.reload()}>
<Icon icon="mdi:refresh" width="16" />
</button>
</nav>
<div class="body" class:has-detail={!!selected}>
<div class="content">
{#if libraryStore.state === 'error'}
<p class="message">Could not load the library. Try reloading.</p>
{:else if libraryStore.state !== 'ready' && !entries.length}
<p class="message muted">Loading {app.itemLabel}...</p>
{:else if tab === 'history'}
<LibraryHistory {entries} {finishedLabel} onselect={(entry) => (selectedId = entry.id)} />
{:else}
<LibraryToolbar bind:filter statuses={app.statuses} {counts} total={entries.length} />
{#if grouped.groups.length}
<h3 class="subheading">
<Icon icon="mdi:layers-outline" width="16" />{app.groupLabel}
<span class="muted">{grouped.groups.length}</span>
</h3>
<div class="groups">
{#each grouped.groups as group (group.name)}
<LibraryGroup {group} {app} {selectedId} onselect={toggleSelected} />
{/each}
</div>
{/if}
{#if grouped.standalone.length}
{#if grouped.groups.length}
<h3 class="subheading">
<Icon icon="mdi:view-grid-outline" width="16" />All {app.itemLabel}
<span class="muted">{grouped.standalone.length}</span>
</h3>
{/if}
<div class="grid">
{#each grouped.standalone as entry (entry.id)}
<LibraryCard
{entry}
status={statusOf(app, entry.status)}
fallbackIcon={app.icon}
selected={entry.id === selectedId}
onselect={() => toggleSelected(entry.id)}
/>
{/each}
</div>
{/if}
{#if !shownEntries.length}
<p class="message muted">No {app.itemLabel} match.</p>
{/if}
{/if}
</div>
{#if selected}
<LibraryDetail entry={selected} status={statusOf(app, selected.status)} fallbackIcon={app.icon} onclose={() => (selectedId = null)} />
{/if}
</div>
</div>
<style>
.library-app {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
container-type: inline-size;
}
.tabs {
display: flex;
align-items: stretch;
gap: 0.25rem;
padding: 0 0.75rem;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
.tabs button {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.8rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--muted);
font-size: 0.85rem;
}
.tabs button:hover {
color: var(--foreground);
}
.tabs button.active {
border-bottom-color: var(--accent);
color: var(--accent);
}
.summary {
align-self: center;
margin-left: auto;
overflow: hidden;
font-size: 0.75rem;
white-space: nowrap;
text-overflow: ellipsis;
}
.tabs .reload {
padding: 0.5rem 0.3rem;
}
.body {
position: relative;
display: grid;
flex: 1;
grid-template-columns: minmax(0, 1fr);
min-height: 0;
}
.body.has-detail {
grid-template-columns: minmax(0, 1fr) 320px;
}
.content {
min-height: 0;
overflow-y: auto;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.9rem 0.75rem;
padding: 0 0.75rem 0.75rem;
}
.subheading {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0.25rem 0.75rem 0.6rem;
color: var(--foreground);
font-size: 0.85rem;
}
.groups {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.75rem;
padding: 0 0.75rem 1rem;
}
.message {
margin: 0;
padding: 1rem 0.75rem;
}
@container (max-width: 640px) {
.summary {
display: none;
}
.tabs .reload {
margin-left: auto;
}
.body.has-detail {
grid-template-columns: minmax(0, 1fr);
}
.body.has-detail > :global(.detail) {
position: absolute;
inset: 0;
border-left: none;
}
.grid {
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
}
}
</style>
@@ -0,0 +1,126 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { LibraryEntry } from '#lib/library/entries';
import type { LibraryStatus } from '#lib/library/apps';
let {
entry,
caption,
status,
fallbackIcon,
selected,
onselect
}: {
entry: LibraryEntry;
caption?: string;
status: LibraryStatus;
fallbackIcon: string;
selected: boolean;
onselect: () => void;
} = $props();
let imageFailed = $state(false);
const subtitle = $derived(caption ?? entry.subtitle);
</script>
<button class="library-card" class:selected title={entry.title} onclick={onselect}>
<span class="cover">
{#if entry.image && !imageFailed}
<img src={entry.image} alt="" loading="lazy" onerror={() => (imageFailed = true)} />
{:else}
<span class="placeholder"><Icon icon={fallbackIcon} width="32" /></span>
{/if}
{#if entry.rating !== null}
<span class="rating"><Icon icon="mdi:star" width="12" />{entry.rating}</span>
{/if}
<span class="status" style:background="var(--{status.color})">{status.label}</span>
</span>
<span class="title">{entry.title}</span>
{#if subtitle}
<span class="subtitle">{subtitle}</span>
{/if}
</button>
<style>
.library-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
padding: 0;
background: none;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
}
.cover {
position: relative;
aspect-ratio: 2 / 3;
overflow: hidden;
background: var(--dark-background);
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
transition: border-color 0.15s ease;
}
.library-card:hover .cover,
.library-card.selected .cover {
border-color: var(--accent);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.placeholder {
display: grid;
place-items: center;
height: 100%;
color: var(--muted);
}
.rating {
position: absolute;
top: 0.3rem;
right: 0.3rem;
display: inline-flex;
align-items: center;
gap: 0.15rem;
padding: 0 0.3rem;
background: var(--background);
color: var(--yellow);
font-size: 0.72rem;
}
.status {
position: absolute;
bottom: 0.3rem;
left: 0.3rem;
padding: 0 0.3rem;
color: var(--background);
font-size: 0.68rem;
font-weight: 700;
}
.title,
.subtitle {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.title {
color: var(--accent);
font-size: 0.82rem;
font-weight: 700;
}
.subtitle {
color: var(--muted);
font-size: 0.72rem;
}
</style>
@@ -0,0 +1,220 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { LibraryEntry } from '#lib/library/entries';
import type { LibraryStatus } from '#lib/library/apps';
import Tag from '#lib/components/ui/Tag.svelte';
import { tagIcon } from '#lib/library/gameProfile';
let {
entry,
status,
fallbackIcon,
onclose
}: { entry: LibraryEntry; status: LibraryStatus; fallbackIcon: string; onclose: () => void } = $props();
const dates = $derived(
[
['Added', entry.added],
['Started', entry.started],
['Finished', entry.finished]
].filter((row): row is [string, string] => !!row[1])
);
</script>
<aside class="detail" aria-label="{entry.title} details">
<button class="close" aria-label="Close details" onclick={onclose}><Icon icon="mdi:close" width="16" /></button>
<div class="head">
<span class="cover">
{#if entry.image}
<img src={entry.image} alt="" />
{:else}
<Icon icon={fallbackIcon} width="36" />
{/if}
</span>
<div class="head-text">
<h3>{entry.title}</h3>
{#if entry.subtitle}<span class="muted">{entry.subtitle}</span>{/if}
<span class="status" style:color="var(--{status.color})">{status.label}</span>
{#if entry.rating !== null}
<span class="rating"><Icon icon="mdi:star" width="14" />{entry.rating}/10</span>
{/if}
</div>
</div>
{#if entry.group}
<div class="row"><Icon icon="mdi:layers-outline" width="15" />{[entry.group, entry.groupPosition].filter(Boolean).join(' ')}</div>
{/if}
{#each entry.facts as fact (fact)}
<div class="row muted">{fact}</div>
{/each}
{#if entry.note}
<p class="note">{entry.note}</p>
{/if}
{#if entry.tags.length}
<div class="genres">
{#each entry.tags as tag (tag)}
<span class="player-tag"><Icon icon={tagIcon(tag)} width="13" />{tag}</span>
{/each}
</div>
{/if}
{#if entry.genres.length}
<div class="genres">
{#each entry.genres as genre, index (genre)}
<Tag label={genre} colorIndex={index} />
{/each}
</div>
{/if}
{#if dates.length}
<dl class="dates">
{#each dates as [label, value] (label)}
<dt>{label}</dt>
<dd>{value}</dd>
{/each}
</dl>
{/if}
{#if entry.description}
<p class="description">{entry.description}</p>
{/if}
<a class="link" href={entry.url} target="_blank" rel="noopener noreferrer">
<Icon icon="mdi:open-in-new" width="14" />{entry.url.includes('imdb') ? 'IMDb' : 'Goodreads'}
</a>
</aside>
<style>
.detail {
position: relative;
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.9rem;
overflow-y: auto;
background: var(--background);
border-left: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
font-size: 0.85rem;
}
.close {
position: absolute;
top: 0.4rem;
right: 0.4rem;
display: grid;
place-items: center;
padding: 0.2rem;
background: none;
border: none;
color: var(--muted);
}
.close:hover {
color: var(--red);
}
.head {
display: flex;
gap: 0.75rem;
}
.cover {
display: grid;
flex-shrink: 0;
place-items: center;
width: 96px;
aspect-ratio: 2 / 3;
overflow: hidden;
background: var(--dark-background);
color: var(--muted);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.head-text {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
padding-right: 1.2rem;
}
h3 {
margin: 0;
color: var(--accent);
font-size: 1rem;
}
.status {
font-weight: 700;
}
.rating {
display: inline-flex;
align-items: center;
gap: 0.2rem;
color: var(--yellow);
}
.row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.genres {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.note {
margin: 0;
font-style: italic;
}
.player-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.05rem 0.45rem;
border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
font-size: 0.75rem;
}
.dates {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.15rem 0.75rem;
margin: 0;
}
dt {
color: var(--blue);
font-weight: 700;
}
dd {
margin: 0;
}
.description {
margin: 0;
color: var(--muted);
line-height: 1.5;
}
.link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--accent);
}
</style>
@@ -0,0 +1,180 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { statusOf, type LibraryApp } from '#lib/library/apps';
import { averageRating, type EntryGroup } from '#lib/library/filter';
import LibraryCard from './LibraryCard.svelte';
let {
group,
app,
selectedId,
onselect
}: { group: EntryGroup; app: LibraryApp; selectedId: string | null; onselect: (id: string) => void } = $props();
let expanded = $state(false);
const total = $derived(group.entries.length);
const average = $derived(averageRating(group.entries));
const statusCounts = $derived(
app.statuses
.map((status) => ({ status, count: group.entries.filter((entry) => entry.status === status.id).length }))
.filter((item) => item.count > 0)
);
</script>
<section class="group" class:expanded>
<button class="header" aria-expanded={expanded} onclick={() => (expanded = !expanded)}>
<Icon icon={expanded ? 'mdi:chevron-down' : 'mdi:chevron-right'} width="16" />
<span class="name">{group.name}</span>
<span class="summary">
<span class="muted">{total} {app.itemLabel}</span>
{#each statusCounts as item (item.status.id)}
<span style:color="var(--{item.status.color})">{item.count} {item.status.label}</span>
{/each}
{#if average !== '-'}
<span class="rating">avg {average}</span>
{/if}
</span>
</button>
{#if expanded}
<div class="grid">
{#each group.entries as entry (entry.id)}
<LibraryCard
{entry}
caption={entry.groupPosition ?? undefined}
status={statusOf(app, entry.status)}
fallbackIcon={app.icon}
selected={entry.id === selectedId}
onselect={() => onselect(entry.id)}
/>
{/each}
</div>
{:else}
<div class="stack">
{#each group.entries as entry, index (entry.id)}
<button
class="stacked"
class:selected={entry.id === selectedId}
style:z-index={total - index}
style:border-bottom-color="var(--{statusOf(app, entry.status).color})"
title={[entry.groupPosition, entry.title].filter(Boolean).join(' ')}
onclick={() => onselect(entry.id)}
>
{#if entry.image}
<img src={entry.image} alt={entry.title} loading="lazy" />
{:else}
<span class="fallback"><Icon icon={app.icon} width="20" /></span>
{/if}
</button>
{/each}
</div>
{/if}
</section>
<style>
.group {
flex: 1 1 auto;
min-width: min(100%, 260px);
max-width: 100%;
padding: 0.6rem;
border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
}
.group.expanded {
flex-basis: 100%;
}
.header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem 0.5rem;
width: 100%;
padding: 0;
background: none;
border: none;
color: var(--muted);
text-align: left;
}
.name {
color: var(--accent);
font-weight: 700;
}
.summary {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-left: auto;
font-size: 0.75rem;
}
.rating {
color: var(--yellow);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.9rem 0.75rem;
padding-top: 0.6rem;
}
.stack {
display: flex;
padding: 0.6rem 0 0.2rem;
overflow-x: auto;
}
.stacked {
position: relative;
flex-shrink: 0;
width: 72px;
height: 108px;
padding: 0;
background: none;
border: none;
border-bottom: 3px solid var(--muted);
box-shadow: 4px 0 10px color-mix(in srgb, black 45%, transparent);
transition: transform 0.15s ease;
}
.stacked + .stacked {
margin-left: -44px;
}
.stacked:hover,
.stacked.selected {
z-index: 1000 !important;
transform: translateY(-6px);
}
.stacked img,
.fallback {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.fallback {
display: grid;
place-items: center;
background: color-mix(in srgb, var(--foreground) 8%, var(--background));
color: var(--muted);
}
@container (max-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
.stacked {
transition: none;
}
}
</style>
@@ -0,0 +1,115 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { LibraryEntry } from '#lib/library/entries';
import { historyByMonth } from '#lib/library/filter';
let {
entries,
finishedLabel,
onselect
}: { entries: LibraryEntry[]; finishedLabel: string; onselect: (entry: LibraryEntry) => void } = $props();
const months = $derived(historyByMonth(entries));
</script>
{#if months.length}
<ol class="timeline">
{#each months as month (month.key)}
<li class="month">
<h3>{month.label} <span class="muted">{month.events.length}</span></h3>
<ul>
{#each month.events as event (event.kind + event.entry.id)}
<li>
<button class="event" onclick={() => onselect(event.entry)}>
<span class="date">{event.date.slice(5)}</span>
<Icon
icon={event.kind === 'finished' ? 'mdi:check-circle-outline' : 'mdi:play-circle-outline'}
width="15"
color={event.kind === 'finished' ? 'var(--green)' : 'var(--blue)'}
/>
<span class="kind">{event.kind === 'finished' ? finishedLabel : 'started'}</span>
<span class="title">{event.entry.title}</span>
{#if event.kind === 'finished' && event.entry.rating !== null}
<span class="rating">{event.entry.rating}/10</span>
{/if}
</button>
</li>
{/each}
</ul>
</li>
{/each}
</ol>
{:else}
<p class="empty muted">No history yet. Dates are saved when something is started or finished.</p>
{/if}
<style>
.timeline,
ul {
margin: 0;
padding: 0;
list-style: none;
}
.timeline {
padding: 0.25rem 0.75rem 0.75rem;
}
.month + .month {
margin-top: 1rem;
}
h3 {
margin: 0 0 0.35rem;
color: var(--foreground);
font-size: 0.9rem;
}
ul {
border-left: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
}
.event {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.25rem 0.6rem;
background: none;
border: none;
color: inherit;
font-size: 0.82rem;
text-align: left;
}
.event:hover {
background: color-mix(in srgb, var(--foreground) 8%, transparent);
}
.date,
.kind {
flex-shrink: 0;
color: var(--muted);
}
.kind {
width: 4.5rem;
}
.title {
overflow: hidden;
color: var(--accent);
white-space: nowrap;
text-overflow: ellipsis;
}
.rating {
flex-shrink: 0;
margin-left: auto;
color: var(--yellow);
}
.empty {
padding: 1rem 0.75rem;
}
</style>
@@ -0,0 +1,120 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { LibraryStatus } from '#lib/library/apps';
import { sortOptions, type LibraryFilter } from '#lib/library/filter';
let {
filter = $bindable(),
statuses,
counts,
total
}: { filter: LibraryFilter; statuses: LibraryStatus[]; counts: Record<string, number>; total: number } = $props();
</script>
<div class="toolbar">
<div class="statuses" role="group" aria-label="Filter by status">
<button class:active={filter.status === null} onclick={() => (filter.status = null)}>all <span class="count">{total}</span></button>
{#each statuses as status (status.id)}
<button
class:active={filter.status === status.id}
style:--status-color="var(--{status.color})"
onclick={() => (filter.status = filter.status === status.id ? null : status.id)}
>
{status.label} <span class="count">{counts[status.id] ?? 0}</span>
</button>
{/each}
</div>
<label class="search">
<Icon icon="mdi:magnify" width="16" />
<input bind:value={filter.query} placeholder="Search" aria-label="Search" />
</label>
<label class="sort">
<Icon icon="mdi:sort" width="16" />
<select bind:value={filter.sort} aria-label="Sort by">
{#each sortOptions as option (option.id)}
<option value={option.id}>{option.label}</option>
{/each}
</select>
</label>
</div>
<style>
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
}
.statuses {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-right: auto;
}
.statuses button {
--status-color: var(--accent);
padding: 0.1rem 0.5rem;
background: none;
border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
color: var(--muted);
font-size: 0.78rem;
}
.statuses button:hover {
color: var(--foreground);
}
.statuses button.active {
border-color: var(--status-color);
color: var(--status-color);
}
.count {
opacity: 0.7;
}
.search,
.sort {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.1rem 0.45rem;
border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
color: var(--muted);
}
.search:focus-within,
.sort:focus-within {
border-color: var(--accent);
}
.search input,
.sort select {
padding: 0;
background: none;
border: none;
outline: none;
box-shadow: none;
color: var(--foreground);
font: inherit;
font-size: 0.8rem;
}
.search input {
width: 9rem;
}
.sort select {
padding-right: 1.4rem;
background-position: right 0 center;
}
.sort option {
background: var(--background);
}
</style>
+22 -2
View File
@@ -2,14 +2,30 @@
import type { Snippet } from 'svelte';
import Icon from '@iconify/svelte';
let { title, icon, href, children }: { title?: string; icon?: string; href?: string; children?: Snippet } = $props();
let {
title,
icon,
href,
onclick,
children
}: { title?: string; icon?: string; href?: string; onclick?: () => void; children?: Snippet } = $props();
function handleKeydown(event: KeyboardEvent) {
if (!onclick || (event.key !== 'Enter' && event.key !== ' ')) return;
event.preventDefault();
onclick();
}
</script>
<svelte:element
this={href ? 'a' : 'article'}
class="panel"
class:linked={!!href}
class:linked={!!href || !!onclick}
{href}
role={onclick ? 'button' : undefined}
tabindex={onclick ? 0 : undefined}
{onclick}
onkeydown={onclick ? handleKeydown : undefined}
target={href?.startsWith('http') ? '_blank' : undefined}
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
>
@@ -34,6 +50,10 @@
background: color-mix(in srgb, var(--foreground) 3%, transparent);
}
.linked {
cursor: pointer;
}
.linked:hover {
border-color: var(--accent);
background: color-mix(in srgb, var(--foreground) 7%, transparent);
-6
View File
@@ -11,13 +11,10 @@ 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 type {
AboutContent,
BlogSettings,
Book,
DesktopContent,
Experience,
Leadership,
@@ -42,9 +39,6 @@ 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<string, Partial<Book>>;
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[] {
+32 -6
View File
@@ -125,12 +125,34 @@ export interface Hobby {
name: string;
icon: string;
description: string;
folder?: string;
}
export type PlayStatus = 'played' | 'playing' | 'planning' | 'replayable';
export interface Game {
igdbId: string;
name: string;
image: string;
url: string;
url: string | null;
igdbUrl: string | null;
image: string | null;
cover: string | null;
status: PlayStatus;
rating?: number;
released: string | null;
platforms: string[];
developers: string[];
publishers: string[];
genres: string[];
summary: string | null;
score: number | null;
favorite: boolean;
liked: boolean;
tags: string[];
note: string | null;
added?: string;
started?: string;
finished?: string;
}
export type WatchStatus = 'watched' | 'watching' | 'planning';
@@ -141,6 +163,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 +186,8 @@ export interface Book {
shelf: BookShelf;
rating?: number;
added?: string;
started?: string;
finished?: string;
series?: BookSeries;
}
@@ -189,14 +216,13 @@ export interface AboutContent {
lastfmUsername?: string;
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' | 'games';
export type DesktopApp = 'btop' | 'tcg' | 'pet' | 'files' | GameName | LibraryAppName;
export type DesktopAction = { page: string } | { terminal: string } | { url: string } | { app: DesktopApp };
+10 -2
View File
@@ -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,9 +20,17 @@ export function openPage(path: string) {
goto(path);
}
const libraryAppNames: DesktopApp[] = ['movies', 'shows', 'books', 'games'];
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 === 'files') windowManager.openFileExplorer();
else if (app === 'pet') petState.toggle();
else windowManager.openGame(app);
}
+32 -2
View File
@@ -1,4 +1,4 @@
import type { GameName } from '#lib/content';
import type { GameName, LibraryAppName } from '#lib/content';
export const pageWindowId = 'page';
@@ -26,6 +26,16 @@ export interface TcgBrowserWindow {
id: string;
}
export interface FileExplorerWindow {
id: string;
startPath: string;
}
export interface LibraryWindow {
id: string;
app: LibraryAppName;
}
export interface GameWindow {
id: string;
game: GameName;
@@ -44,6 +54,8 @@ class WindowManager {
systemMonitors = $state<SystemMonitorWindow[]>([]);
games = $state<GameWindow[]>([]);
tcgBrowsers = $state<TcgBrowserWindow[]>([]);
libraryWindows = $state<LibraryWindow[]>([]);
fileExplorers = $state<FileExplorerWindow[]>([]);
floatingRects = $state<Record<string, FloatingRect | null>>({});
focusedId = $state<string>(pageWindowId);
maximizedId = $state<string | null>(null);
@@ -54,6 +66,8 @@ class WindowManager {
private nextSystemMonitorNumber = 1;
private nextGameNumber = 1;
private nextTcgBrowserNumber = 1;
private nextLibraryNumber = 1;
private nextFileExplorerNumber = 1;
get extraWindowIds(): string[] {
return [
@@ -61,7 +75,9 @@ 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),
...this.fileExplorers.map((explorer) => explorer.id)
];
}
@@ -101,12 +117,26 @@ class WindowManager {
this.focus(browser.id);
}
openLibrary(app: LibraryAppName) {
const libraryWindow = { id: `${app}-${this.nextLibraryNumber++}`, app };
this.libraryWindows.push(libraryWindow);
this.focus(libraryWindow.id);
}
openFileExplorer(startPath = '') {
const explorer = { id: `thunar-${this.nextFileExplorerNumber++}`, startPath };
this.fileExplorers.push(explorer);
this.focus(explorer.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);
this.fileExplorers = this.fileExplorers.filter((explorer) => explorer.id !== id);
delete this.floatingRects[id];
if (this.maximizedId === id) this.maximizedId = null;
if (this.focusedId === id) this.focusedId = this.extraWindowIds.at(-1) ?? pageWindowId;
+127
View File
@@ -0,0 +1,127 @@
import { windowManager } from '#lib/desktop/windows.svelte';
import { fileUrl, parentPath, type FileEntry, type FolderListing } from './types';
export type ExplorerView = 'grid' | 'list';
type LoadState = 'loading' | 'ready' | 'error';
export class ExplorerState {
path = $state('');
entries = $state<FileEntry[]>([]);
state = $state<LoadState>('loading');
selectedPath = $state<string | null>(null);
view = $state<ExplorerView>('grid');
query = $state('');
places = $state<FileEntry[]>([]);
private history = $state<string[]>([]);
private historyIndex = $state(0);
constructor(startPath = '') {
this.history = [startPath];
this.path = startPath;
}
shownEntries = $derived(
this.query.trim()
? this.entries.filter((entry) => entry.name.toLowerCase().includes(this.query.trim().toLowerCase()))
: this.entries
);
selected = $derived(this.entries.find((entry) => entry.path === this.selectedPath) ?? null);
canGoBack = $derived(this.historyIndex > 0);
canGoForward = $derived(this.historyIndex < this.history.length - 1);
canGoUp = $derived(this.path !== '');
crumbs = $derived(
this.path
.split('/')
.filter(Boolean)
.map((name, index, parts) => ({ name, path: parts.slice(0, index + 1).join('/') }))
);
private async fetchListing(path: string): Promise<FolderListing> {
const response = await fetch(`/api/files?path=${encodeURIComponent(path)}`);
if (!response.ok) throw new Error(`files returned ${response.status}`);
return response.json();
}
private async load(path: string) {
this.state = 'loading';
this.selectedPath = null;
this.query = '';
try {
const listing = await this.fetchListing(path);
this.path = listing.path;
this.entries = listing.entries;
this.state = 'ready';
} catch {
this.state = 'error';
}
}
private async loadPlaces() {
try {
const root = await this.fetchListing('');
this.places = root.entries.filter((entry) => entry.kind === 'folder');
} catch {
this.places = [];
}
}
start() {
this.load(this.path);
this.loadPlaces();
}
navigate(path: string) {
if (path === this.path && this.state === 'ready') return;
this.history = [...this.history.slice(0, this.historyIndex + 1), path];
this.historyIndex = this.history.length - 1;
this.load(path);
}
back() {
if (!this.canGoBack) return;
this.historyIndex--;
this.load(this.history[this.historyIndex]);
}
forward() {
if (!this.canGoForward) return;
this.historyIndex++;
this.load(this.history[this.historyIndex]);
}
up() {
if (this.canGoUp) this.navigate(parentPath(this.path));
}
reload() {
this.load(this.path);
}
select(entry: FileEntry) {
this.selectedPath = entry.path;
}
open(entry: FileEntry) {
if (entry.kind === 'folder') {
this.navigate(entry.path);
return;
}
if (entry.kind === 'image') {
const images = this.shownEntries.filter((item) => item.kind === 'image');
windowManager.openImageViewer(
images.map((image) => ({ src: fileUrl(image.path), alt: image.name })),
Math.max(0, images.indexOf(entry))
);
return;
}
window.open(fileUrl(entry.path), '_blank', 'noopener,noreferrer');
}
moveSelection(step: number) {
if (!this.shownEntries.length) return;
const index = this.shownEntries.findIndex((entry) => entry.path === this.selectedPath);
const nextIndex = index === -1 ? 0 : Math.min(this.shownEntries.length - 1, Math.max(0, index + step));
this.selectedPath = this.shownEntries[nextIndex].path;
}
}
+51
View File
@@ -0,0 +1,51 @@
export type FileKind = 'folder' | 'image' | 'video' | 'audio' | 'text' | 'font' | 'document' | 'other';
export interface FileEntry {
name: string;
path: string;
kind: FileKind;
size: number;
modified: string;
itemCount: number | null;
}
export interface FolderListing {
path: string;
entries: FileEntry[];
}
const kindIcons: Record<FileKind, string> = {
folder: 'mdi:folder',
image: 'mdi:file-image-outline',
video: 'mdi:file-video-outline',
audio: 'mdi:file-music-outline',
text: 'mdi:file-document-outline',
font: 'mdi:format-font',
document: 'mdi:file-pdf-box',
other: 'mdi:file-outline'
};
export function kindIcon(kind: FileKind): string {
return kindIcons[kind];
}
export const filesFolder = 'files';
export function fileUrl(path: string): string {
return `/${filesFolder}/${path.split('/').map(encodeURIComponent).join('/')}`;
}
export function formatSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return unitIndex === 0 ? `${size} ${units[0]}` : `${size.toFixed(1)} ${units[unitIndex]}`;
}
export function parentPath(path: string): string {
return path.split('/').slice(0, -1).join('/');
}
+81
View File
@@ -0,0 +1,81 @@
import type { LibraryAppName } from '#lib/content';
import type { LibraryData } from './types';
import { bookEntries, gameEntries, 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 playStatuses: LibraryStatus[] = [
{ id: 'playing', label: 'playing', color: 'blue' },
{ id: 'played', label: 'played', color: 'green' },
{ id: 'planning', label: 'planning', color: 'magenta' },
{ id: 'replayable', label: 'replayable', color: 'cyan' }
];
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<LibraryAppName, LibraryApp> = {
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)
},
games: {
name: 'games',
title: 'Games',
icon: 'mdi:gamepad-variant-outline',
itemLabel: 'games',
groupLabel: 'Series',
statuses: playStatuses,
entries: (library) => gameEntries(library.games)
}
};
export function statusOf(app: LibraryApp, id: string): LibraryStatus {
return app.statuses.find((status) => status.id === id) ?? app.statuses[1];
}
+122
View File
@@ -0,0 +1,122 @@
import type { Book, Game, 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[];
tags: string[];
note: string | null;
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<string, MediaDetails>, 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),
tags: [],
note: null,
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),
tags: [],
note: null,
added: book.added ?? null,
started: book.started ?? null,
finished: book.finished ?? null
}));
}
function profileFacts(game: Game): string[] {
return [game.favorite ? 'Favorite game' : '', game.liked ? 'Liked' : ''].filter(Boolean);
}
export function gameEntries(games: Game[]): LibraryEntry[] {
return games.map((game) => ({
id: game.igdbId,
title: game.name,
subtitle: game.developers.join(', '),
image: game.cover ?? game.image,
url: game.url ?? game.igdbUrl ?? '',
status: game.status,
rating: game.rating ?? null,
year: game.released?.slice(0, 4) ?? null,
genres: game.genres,
description: game.summary,
group: null,
groupPosition: null,
groupOrder: 0,
facts: [...profileFacts(game), game.platforms.join(', '), game.score !== null ? `IGDB ${game.score}` : ''].filter(Boolean),
tags: game.tags,
note: game.note,
added: game.added ?? null,
started: game.started ?? null,
finished: game.finished ?? null
}));
}
+109
View File
@@ -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<LibrarySort, (first: LibraryEntry, second: LibraryEntry) => 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<string, LibraryEntry[]>();
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<string, HistoryEvent[]>();
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;
}
+13
View File
@@ -0,0 +1,13 @@
export const likedLimit = 20;
export const suggestedTags: { label: string; icon: string }[] = [
{ label: 'Casual', icon: 'mdi:sofa-outline' },
{ label: 'Competitive', icon: 'mdi:sword-cross' },
{ label: 'Love it', icon: 'mdi:thumb-up-outline' },
{ label: 'Open to play', icon: 'mdi:account-multiple-plus-outline' },
{ label: 'Looking to discuss', icon: 'mdi:chat-outline' }
];
export function tagIcon(tag: string): string {
return suggestedTags.find((suggested) => suggested.label.toLowerCase() === tag.toLowerCase())?.icon ?? 'mdi:tag-outline';
}
+35
View File
@@ -0,0 +1,35 @@
import { emptyLibrary, type LibraryData } from './types';
type LoadState = 'idle' | 'loading' | 'ready' | 'error';
class LibraryStore {
data = $state<LibraryData>(emptyLibrary);
state = $state<LoadState>('idle');
private loading: Promise<void> | null = null;
load(): Promise<void> {
this.loading ??= this.fetchLibrary();
return this.loading;
}
reload(): Promise<void> {
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();
+24
View File
@@ -0,0 +1,24 @@
export interface GameStore {
label: string;
icon: string;
}
const knownStores: Record<string, GameStore> = {
'store.steampowered.com': { label: 'Steam', icon: 'mdi:steam' },
'www.xbox.com': { label: 'Xbox', icon: 'mdi:microsoft-xbox' },
'www.nintendo.com': { label: 'Nintendo', icon: 'mdi:nintendo-switch' },
'store.epicgames.com': { label: 'Epic', icon: 'simple-icons:epicgames' },
'www.gog.com': { label: 'GOG', icon: 'simple-icons:gogdotcom' },
'store.playstation.com': { label: 'PlayStation', icon: 'mdi:sony-playstation' },
'www.igdb.com': { label: 'IGDB', icon: 'mdi:database-outline' }
};
export function storeFor(url: string): GameStore {
try {
const host = new URL(url).hostname;
if (host.endsWith('itch.io')) return { label: 'itch.io', icon: 'simple-icons:itchdotio' };
return knownStores[host] ?? { label: host.replace(/^www\./, ''), icon: 'mdi:web' };
} catch {
return { label: 'link', icon: 'mdi:web' };
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { Book, Game, RatedTitle } from '#lib/content';
import type { MediaDetails } from '#lib/media/types';
export interface LibraryMedia {
shows: RatedTitle[];
movies: RatedTitle[];
mediaDetails: Record<string, MediaDetails>;
}
export interface LibraryData extends LibraryMedia {
books: Book[];
games: Game[];
}
export const emptyLibrary: LibraryData = { shows: [], movies: [], mediaDetails: {}, books: [], games: [] };
-28
View File
@@ -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<string, StoredMediaDetails>;
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<string, MediaDetails> {
const entries = urls.flatMap((url) => {
const stored = storedDetails[url.match(imdbIdPattern)?.[0] ?? ''];
return stored ? [[url, toMediaDetails(stored)] as const] : [];
});
return Object.fromEntries(entries);
}
+2
View File
@@ -5,3 +5,5 @@ export interface MediaDetails {
runtime: string | null;
plot: string | null;
}
export type MediaKind = 'show' | 'movie';
+7 -5
View File
@@ -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<HobbyStats> {
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<HobbyStats> {
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
};
+75
View File
@@ -0,0 +1,75 @@
import { readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { error } from '@sveltejs/kit';
import { filesFolder, type FileEntry, type FileKind, type FolderListing } from '#lib/files/types';
const filesRoot = path.resolve('static', filesFolder);
const extensionKinds: Record<string, FileKind> = {
'.png': 'image',
'.jpg': 'image',
'.jpeg': 'image',
'.gif': 'image',
'.webp': 'image',
'.avif': 'image',
'.svg': 'image',
'.ico': 'image',
'.mp4': 'video',
'.webm': 'video',
'.mov': 'video',
'.mp3': 'audio',
'.ogg': 'audio',
'.wav': 'audio',
'.flac': 'audio',
'.txt': 'text',
'.md': 'text',
'.json': 'text',
'.csv': 'text',
'.xml': 'text',
'.ttf': 'font',
'.otf': 'font',
'.woff': 'font',
'.woff2': 'font',
'.pdf': 'document'
};
function resolveInsideRoot(relativePath: string): string {
const cleaned = relativePath.replace(/^\/+|\/+$/g, '');
const absolute = path.resolve(filesRoot, cleaned);
if (absolute !== filesRoot && !absolute.startsWith(`${filesRoot}${path.sep}`)) error(400, 'Path is outside the files folder');
return absolute;
}
function isHidden(name: string): boolean {
return name.startsWith('.');
}
async function toEntry(folder: string, relativeFolder: string, name: string): Promise<FileEntry | null> {
const details = await stat(path.join(folder, name)).catch(() => null);
if (!details || (!details.isFile() && !details.isDirectory())) return null;
const isFolder = details.isDirectory();
const children = isFolder ? await readdir(path.join(folder, name)).catch(() => []) : [];
return {
name,
path: relativeFolder ? `${relativeFolder}/${name}` : name,
kind: isFolder ? 'folder' : (extensionKinds[path.extname(name).toLowerCase()] ?? 'other'),
size: isFolder ? 0 : details.size,
modified: details.mtime.toISOString(),
itemCount: isFolder ? children.filter((child) => !isHidden(child)).length : null
};
}
export async function listFolder(relativePath: string): Promise<FolderListing> {
const folder = resolveInsideRoot(relativePath);
const names = await readdir(folder).catch(() => error(404, 'Folder not found'));
const relativeFolder = path.relative(filesRoot, folder).split(path.sep).join('/');
const entries = await Promise.all(names.filter((name) => !isHidden(name)).map((name) => toEntry(folder, relativeFolder, name)));
return {
path: relativeFolder,
entries: entries
.filter((entry): entry is FileEntry => entry !== null)
.sort((first, second) => Number(second.kind === 'folder') - Number(first.kind === 'folder') || first.name.localeCompare(second.name))
};
}
+52 -43
View File
@@ -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<string, string> = {};
let trackCovers: Record<string, string> = {};
const savedImages = new Map<string, string>();
const albumCovers = new Map<string, string>();
const trackCovers = new Map<string, string>();
const savedImageIds = new Set<string>();
const queuedDownloads = new Set<string>();
const queuedLookups = new Map<string, { artist: string; track: string }>();
let loading: Promise<void> | 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<Record<string, string>> {
try {
return JSON.parse(await readFile(filePath, 'utf8'));
} catch {
return {};
function loadPairs(database: SqlDatabase, sql: string, target: Map<string, string>) {
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<void> {
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<CoverImage | null> {
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;
}
+49
View File
@@ -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<SqlDatabase> | null = null;
async function openLastfmDatabase(): Promise<SqlDatabase> {
const database = await openSqlite(databasePath, schema);
await importLegacyFiles(database, lastfmDataDirectory);
return database;
}
export function lastfmDatabase(): Promise<SqlDatabase> {
opening ??= openLastfmDatabase();
return opening;
}
+84
View File
@@ -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<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif'
};
async function readText(filePath: string): Promise<string> {
try {
return await readFile(filePath, 'utf8');
} catch {
return '';
}
}
async function readPairs(filePath: string): Promise<[string, string, string][]> {
try {
const covers: Record<string, string> = 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);
}
+12 -22
View File
@@ -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<void> | null = null;
async function readTextFile(filePath: string): Promise<string> {
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<void> {
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;
}
+11
View File
@@ -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');
}
+191
View File
@@ -0,0 +1,191 @@
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 { parseList, readChoice, readHistory, readNumber, readRating, readText, readTextList, type ExportedEntry, 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 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<string>): 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))));
}
export function exportBooks(database: SqlDatabase): ExportedEntry[] {
const rows = database.prepare('SELECT * FROM books ORDER BY goodreads_id').all() as BookRow[];
return rows.map((row) => ({
id: row.goodreads_id,
fields: {
title: row.title,
authors: parseList(row.authors),
description: row.description,
pages: row.pages,
published: row.published,
genres: parseList(row.genres),
averageRating: row.average_rating,
ratingCount: row.rating_count,
coverSource: row.cover_source,
shelf: row.shelf,
rating: row.rating,
series: row.series_name ? { name: row.series_name, url: row.series_url, position: row.series_position } : null,
added: row.added,
started: row.started,
finished: row.finished
}
}));
}
function readSeries(body: RequestBody, existing: BookRow | null): Pick<BookRow, 'series_name' | 'series_url' | 'series_position'> {
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<Book> {
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;
}
+108
View File
@@ -0,0 +1,108 @@
import path from 'node:path';
import { addMissingColumns, 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 games (
igdb_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
store_url TEXT,
igdb_url TEXT,
status TEXT NOT NULL DEFAULT 'played',
rating INTEGER,
released TEXT,
platforms TEXT NOT NULL DEFAULT '[]',
developers TEXT NOT NULL DEFAULT '[]',
publishers TEXT NOT NULL DEFAULT '[]',
genres TEXT NOT NULL DEFAULT '[]',
summary TEXT,
score INTEGER,
image_source TEXT,
cover_source TEXT,
favorite INTEGER NOT NULL DEFAULT 0,
liked INTEGER NOT NULL DEFAULT 0,
tags TEXT NOT NULL DEFAULT '[]',
note 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
);
`;
const addedGameColumns = {
favorite: 'INTEGER NOT NULL DEFAULT 0',
liked: 'INTEGER NOT NULL DEFAULT 0',
tags: "TEXT NOT NULL DEFAULT '[]'",
note: 'TEXT'
};
let opening: Promise<SqlDatabase> | null = null;
async function openLibraryDatabase(): Promise<SqlDatabase> {
const database = await openSqlite(databasePath, schema);
addMissingColumns(database, 'games', addedGameColumns);
return database;
}
export function libraryDatabase(): Promise<SqlDatabase> {
opening ??= openLibraryDatabase();
return opening;
}
export function timestamp(): string {
return new Date().toISOString();
}
export function today(): string {
return timestamp().slice(0, 10);
}
+208
View File
@@ -0,0 +1,208 @@
import { error } from '@sveltejs/kit';
import type { Game, PlayStatus } from '#lib/content';
import { likedLimit } from '#lib/library/gameProfile';
import { timestamp, type SqlDatabase } from './database';
import { hasImage, imageUrl, removeImage, syncImage } from './images';
import { parseList, readChoice, readFlag, readHistory, readNumber, readRating, readText, readTextList, type ExportedEntry, type RequestBody } from './input';
interface GameRow {
igdb_id: string;
name: string;
store_url: string | null;
igdb_url: string | null;
status: PlayStatus;
rating: number | null;
released: string | null;
platforms: string;
developers: string;
publishers: string;
genres: string;
summary: string | null;
score: number | null;
image_source: string | null;
cover_source: string | null;
favorite: number;
liked: number;
tags: string;
note: string | null;
added: string | null;
started: string | null;
finished: string | null;
}
const playStatuses: PlayStatus[] = ['played', 'playing', 'planning', 'replayable'];
const igdbIdPattern = /^\d+$/;
export function isIgdbId(text: string): boolean {
return igdbIdPattern.test(text);
}
function gameImageId(igdbId: string): string {
return `game-${igdbId}`;
}
function toGame(row: GameRow, hasStoredImage: boolean): Game {
return {
igdbId: row.igdb_id,
name: row.name,
url: row.store_url,
igdbUrl: row.igdb_url,
image: hasStoredImage ? imageUrl(gameImageId(row.igdb_id)) : row.image_source,
cover: row.cover_source,
status: row.status,
released: row.released,
platforms: parseList(row.platforms),
developers: parseList(row.developers),
publishers: parseList(row.publishers),
genres: parseList(row.genres),
summary: row.summary,
score: row.score,
favorite: !!row.favorite,
liked: !!row.liked,
tags: parseList(row.tags),
note: row.note,
...(row.rating !== null && { rating: row.rating }),
...(row.added && { added: row.added }),
...(row.started && { started: row.started }),
...(row.finished && { finished: row.finished })
};
}
function findRow(database: SqlDatabase, igdbId: string): GameRow | null {
return (database.prepare('SELECT * FROM games WHERE igdb_id = ?').get(igdbId) as GameRow | undefined) ?? null;
}
export function listGames(database: SqlDatabase, imageIds: Set<string>): Game[] {
const rows = database.prepare('SELECT * FROM games ORDER BY name COLLATE NOCASE').all() as GameRow[];
return rows.map((row) => toGame(row, imageIds.has(gameImageId(row.igdb_id))));
}
export function exportGames(database: SqlDatabase): ExportedEntry[] {
const rows = database.prepare('SELECT * FROM games ORDER BY igdb_id').all() as GameRow[];
return rows.map((row) => ({
id: row.igdb_id,
fields: {
name: row.name,
url: row.store_url,
igdbUrl: row.igdb_url,
status: row.status,
rating: row.rating,
released: row.released,
platforms: parseList(row.platforms),
developers: parseList(row.developers),
publishers: parseList(row.publishers),
genres: parseList(row.genres),
summary: row.summary,
score: row.score,
imageSource: row.image_source,
coverSource: row.cover_source,
favorite: !!row.favorite,
liked: !!row.liked,
tags: parseList(row.tags),
note: row.note,
added: row.added,
started: row.started,
finished: row.finished
}
}));
}
function readList(body: RequestBody, key: string, current: string | undefined): string {
return JSON.stringify(readTextList(body, key, parseList(current ?? '[]')));
}
function mergeRow(igdbId: string, existing: GameRow | null, body: RequestBody): GameRow {
const name = readText(body, 'name', existing?.name ?? null);
if (!name) error(400, 'name is required');
const status = readChoice(body, 'status', playStatuses, existing?.status ?? 'played');
const score = readNumber(body, 'score', existing?.score ?? null);
return {
igdb_id: igdbId,
name,
store_url: readText(body, 'url', existing?.store_url ?? null),
igdb_url: readText(body, 'igdbUrl', existing?.igdb_url ?? null),
status,
rating: readRating(body, existing?.rating ?? null),
released: readText(body, 'released', existing?.released ?? null),
platforms: readList(body, 'platforms', existing?.platforms),
developers: readList(body, 'developers', existing?.developers),
publishers: readList(body, 'publishers', existing?.publishers),
genres: readList(body, 'genres', existing?.genres),
summary: readText(body, 'summary', existing?.summary ?? null),
score: score === null ? null : Math.round(score),
image_source: readText(body, 'imageSource', existing?.image_source ?? null),
cover_source: readText(body, 'coverSource', existing?.cover_source ?? null),
favorite: Number(readFlag(body, 'favorite', !!existing?.favorite)),
liked: Number(readFlag(body, 'liked', !!existing?.liked)),
tags: readList(body, 'tags', existing?.tags),
note: readText(body, 'note', existing?.note ?? null),
...readHistory(
body,
{ added: existing?.added ?? null, started: existing?.started ?? null, finished: existing?.finished ?? null },
{ isNew: !existing, previousStatus: existing?.status ?? null, status, activeStatus: 'playing', doneStatus: 'played' }
)
};
}
function checkLikedLimit(database: SqlDatabase, row: GameRow) {
if (!row.liked) return;
const others = database.prepare('SELECT COUNT(*) AS total FROM games WHERE liked = 1 AND igdb_id != ?').get(row.igdb_id) as { total: number };
if (Number(others.total) >= likedLimit) error(400, `Only ${likedLimit} games can be liked`);
}
export async function saveGame(database: SqlDatabase, igdbId: string, body: RequestBody): Promise<Game> {
const row = mergeRow(igdbId, findRow(database, igdbId), body);
checkLikedLimit(database, row);
if (row.favorite) database.prepare('UPDATE games SET favorite = 0 WHERE igdb_id != ?').run(igdbId);
database
.prepare(
`INSERT INTO games (igdb_id, name, store_url, igdb_url, status, rating, released, platforms, developers, publishers, genres, summary,
score, image_source, cover_source, favorite, liked, tags, note, added, started, finished, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(igdb_id) DO UPDATE SET name = excluded.name, store_url = excluded.store_url, igdb_url = excluded.igdb_url,
status = excluded.status, rating = excluded.rating, released = excluded.released, platforms = excluded.platforms,
developers = excluded.developers, publishers = excluded.publishers, genres = excluded.genres, summary = excluded.summary,
score = excluded.score, image_source = excluded.image_source, cover_source = excluded.cover_source,
favorite = excluded.favorite, liked = excluded.liked, tags = excluded.tags,
note = excluded.note, added = excluded.added,
started = excluded.started, finished = excluded.finished, updated_at = excluded.updated_at`
)
.run(
row.igdb_id,
row.name,
row.store_url,
row.igdb_url,
row.status,
row.rating,
row.released,
row.platforms,
row.developers,
row.publishers,
row.genres,
row.summary,
row.score,
row.image_source,
row.cover_source,
row.favorite,
row.liked,
row.tags,
row.note,
row.added,
row.started,
row.finished,
timestamp()
);
if ('imageSource' in body) await syncImage(database, gameImageId(igdbId), row.image_source);
return toGame(row, hasImage(database, gameImageId(igdbId)));
}
export function deleteGame(database: SqlDatabase, igdbId: string): boolean {
if (!findRow(database, igdbId)) return false;
database.prepare('DELETE FROM games WHERE igdb_id = ?').run(igdbId);
removeImage(database, gameImageId(igdbId));
return true;
}
+71
View File
@@ -0,0 +1,71 @@
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<string> {
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);
}
function isWebAddress(source: string): boolean {
return /^https?:\/\//.test(source);
}
async function downloadImage(source: string): Promise<StoredImage> {
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 || !isWebAddress(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);
}
}
+24
View File
@@ -0,0 +1,24 @@
import type { LibraryData } from '#lib/library/types';
import { libraryDatabase } from './database';
import { storedImageIds } from './images';
import { exportMedia, listMedia } from './media';
import { exportBooks, listBooks } from './books';
import { exportGames, listGames } from './games';
import type { ExportedEntry } from './input';
export async function getLibrary(): Promise<LibraryData> {
const database = await libraryDatabase();
const imageIds = storedImageIds(database);
return { ...listMedia(database, imageIds), books: listBooks(database, imageIds), games: listGames(database, imageIds) };
}
export interface LibraryExport {
media: ExportedEntry[];
books: ExportedEntry[];
games: ExportedEntry[];
}
export async function exportLibrary(): Promise<LibraryExport> {
const database = await libraryDatabase();
return { media: exportMedia(database), books: exportBooks(database), games: exportGames(database) };
}
+106
View File
@@ -0,0 +1,106 @@
import { error } from '@sveltejs/kit';
import { today } from './database';
export type RequestBody = Record<string, unknown>;
export interface ExportedEntry {
id: string;
fields: RequestBody;
}
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
export async function readBody(request: Request): Promise<RequestBody> {
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<Choice extends string>(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 readFlag(body: RequestBody, key: string, current: boolean): boolean {
if (!(key in body)) return current;
if (typeof body[key] !== 'boolean') error(400, `${key} must be true or false`);
return body[key] as boolean;
}
export function parseList(text: string): string[] {
try {
const value = JSON.parse(text);
return Array.isArray(value) ? value : [];
} catch {
return [];
}
}
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<Status extends string> {
isNew: boolean;
previousStatus: Status | null;
status: Status;
activeStatus: Status;
doneStatus: Status;
}
export function readHistory<Status extends string>(body: RequestBody, current: HistoryDates, change: StatusChange<Status>): 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)
};
}
+175
View File
@@ -0,0 +1,175 @@
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 ExportedEntry, 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<string>): 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)))])
)
};
}
export function exportMedia(database: SqlDatabase): ExportedEntry[] {
const rows = database.prepare('SELECT * FROM media ORDER BY imdb_id').all() as MediaRow[];
return rows.map((row) => ({
id: row.imdb_id,
fields: {
kind: row.kind,
name: row.name,
status: row.status,
rating: row.rating,
collection: row.collection,
year: row.year,
genre: row.genre,
runtime: row.runtime,
plot: row.plot,
posterSource: row.poster_source,
added: row.added,
started: row.started,
finished: row.finished
}
}));
}
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<SavedMedia> {
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;
}
-81
View File
@@ -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<string, { details: MediaDetails | null; fetchedAt: number }>();
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<Response> {
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<MediaDetails | null> {
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<Record<string, MediaDetails>> {
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));
}
+47
View File
@@ -0,0 +1,47 @@
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<SqlDatabase> {
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 addMissingColumns(database: SqlDatabase, table: string, columns: Record<string, string>) {
const existing = new Set((database.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((column) => column.name));
for (const [name, definition] of Object.entries(columns)) {
if (!existing.has(name)) database.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
}
}
export function inTransaction(database: SqlDatabase, work: () => void) {
database.exec('BEGIN');
try {
work();
database.exec('COMMIT');
} catch (error) {
database.exec('ROLLBACK');
throw error;
}
}
+7 -2
View File
@@ -19,9 +19,13 @@ const launchCommands: Command[] = gameNames.map((game) => ({
const gamesCommand: Command = {
name: 'games',
description: 'List or launch the games',
usage: 'games [name]',
argumentOptions: () => gameNames,
usage: 'games [name | library]',
argumentOptions: () => [...gameNames, 'library'],
run: (args, context) => {
if (args[0]?.toLowerCase() === 'library') {
context.openApp('games');
return;
}
const requestedGame = gameNames.find((game) => game === args[0]?.toLowerCase());
if (requestedGame) {
context.openApp(requestedGame);
@@ -30,6 +34,7 @@ const gamesCommand: Command = {
gameNames.forEach((game) =>
context.print(line(span(game.padEnd(12), 'accent', { bold: true }), gameDescriptions[game]))
);
context.print(line(span('library'.padEnd(12), 'accent', { bold: true }), 'Games I play, my favorite and the ones I like'));
context.print(line(span('Run ', 'muted'), span('games <name>', 'accent'), span(' to play', 'muted')));
}
};
+23
View File
@@ -140,6 +140,29 @@ export const systemCommands: Command[] = [
description: 'Open the Pokemon TCG card browser',
run: (_args, context) => context.openApp('tcg')
},
{
name: 'thunar',
aliases: ['files', 'explorer'],
description: 'Open the file explorer',
run: (_args, context) => context.openApp('files')
},
{
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,
+4 -10
View File
@@ -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 };
};
+45 -15
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { about, books, profile } from '#lib/content';
import Icon from '@iconify/svelte';
import { about, profile } from '#lib/content';
import { windowManager } from '#lib/desktop/windows.svelte';
import PageHeader from '#lib/components/ui/PageHeader.svelte';
import ProfileIntro from '#lib/components/ui/ProfileIntro.svelte';
import SectionNav from '#lib/components/ui/SectionNav.svelte';
@@ -15,15 +17,26 @@
let { data }: PageProps = $props();
const mediaGroups = [
{ id: 'shows', title: 'TV Shows', items: about.shows, kind: 'show' as const },
{ id: 'movies', title: 'Movies', items: about.movies, kind: 'movie' as const }
];
const books = $derived(data.library.books);
const playOrder = { playing: 0, replayable: 1, played: 2, planning: 3 };
const starredGames = $derived(data.library.games.filter((game) => game.favorite || game.liked));
const games = $derived(
[...(starredGames.length ? starredGames : data.library.games)].sort(
(first, second) =>
Number(second.favorite) - Number(first.favorite) ||
playOrder[first.status] - playOrder[second.status] ||
(second.rating ?? -1) - (first.rating ?? -1)
)
);
const mediaGroups = $derived([
{ id: 'shows', title: 'TV Shows', items: data.library.shows, kind: 'show' as const },
{ id: 'movies', title: 'Movies', items: data.library.movies, kind: 'movie' as const }
]);
const sections = $derived([
{ id: 'hobbies', label: 'Hobbies', color: 'blue' },
...(about.setup ? [{ id: 'setup', label: 'Setup', color: 'cyan' }] : []),
{ id: 'games', label: 'Games', color: 'orange' },
...(games.length ? [{ id: 'games', label: 'Games', color: 'orange' }] : []),
{ id: 'shows', label: 'Shows', color: 'yellow' },
{ id: 'movies', label: 'Movies', color: 'green' },
...(books.length ? [{ id: 'books', label: 'Books', color: 'cyan' }] : []),
@@ -42,8 +55,15 @@
<Section title="Hobbies" id="hobbies">
<div class="card-grid">
{#each about.hobbies as hobby (hobby.name)}
<Panel title={hobby.name} icon={hobby.icon}>
<Panel
title={hobby.name}
icon={hobby.icon}
onclick={hobby.folder ? () => windowManager.openFileExplorer(hobby.folder) : undefined}
>
<p class="muted">{hobby.description}</p>
{#if hobby.folder}
<span class="hobby-files"><Icon icon="mdi:folder-open-outline" width="14" />Browse ~/files/{hobby.folder}</span>
{/if}
</Panel>
{/each}
</div>
@@ -55,17 +75,19 @@
</Section>
{/if}
<Section title="Games" id="games">
<div class="game-grid">
{#each about.games as game (game.name)}
<GameCard {game} />
{/each}
</div>
</Section>
{#if games.length}
<Section title="Games" id="games">
<div class="game-grid">
{#each games as game (game.igdbId)}
<GameCard {game} />
{/each}
</div>
</Section>
{/if}
{#each mediaGroups as group (group.id)}
<Section title={group.title} id={group.id}>
<MediaSection items={group.items} mediaDetails={data.mediaDetails} kind={group.kind} />
<MediaSection items={group.items} mediaDetails={data.library.mediaDetails} kind={group.kind} />
</Section>
{/each}
@@ -99,6 +121,14 @@
margin: 0;
}
.hobby-files {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--accent);
font-size: 0.8rem;
}
.game-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+7
View File
@@ -0,0 +1,7 @@
import { json } from '@sveltejs/kit';
import { listFolder } from '#lib/server/files';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url }) => {
return json(await listFolder(url.searchParams.get('path') ?? ''), { headers: { 'Cache-Control': 'no-cache' } });
};
+5 -16
View File
@@ -1,25 +1,14 @@
import { error } from '@sveltejs/kit';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { loadCovers, savedCoverPath } from '#lib/server/lastfm/covers';
import { readCoverImage } from '#lib/server/lastfm/covers';
import type { RequestHandler } from './$types';
const contentTypes: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif'
};
export const GET: RequestHandler = async ({ params }) => {
await loadCovers();
const filePath = savedCoverPath(params.id);
if (!filePath) error(404, 'Cover not found');
const image = await readCoverImage(params.id);
if (!image) error(404, 'Cover not found');
return new Response(await readFile(filePath), {
return new Response(new Uint8Array(image.data), {
headers: {
'Content-Type': contentTypes[path.extname(filePath)] ?? 'image/jpeg',
'Content-Type': image.contentType,
'Cache-Control': 'public, max-age=31536000, immutable'
}
});
+7
View File
@@ -0,0 +1,7 @@
import { json } from '@sveltejs/kit';
import { getLibrary } from '#lib/server/library';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return json(await getLibrary(), { headers: { 'Cache-Control': 'no-cache' } });
};
@@ -0,0 +1,24 @@
import { error, json } from '@sveltejs/kit';
import { requireApiKey } from '#lib/server/library/auth';
import { libraryDatabase } from '#lib/server/library/database';
import { readBody } from '#lib/server/library/input';
import { deleteBook, isGoodreadsId, saveBook } from '#lib/server/library/books';
import type { RequestHandler } from './$types';
function checkedId(goodreadsId: string): string {
if (!isGoodreadsId(goodreadsId)) error(400, `${goodreadsId} is not a Goodreads ID`);
return goodreadsId;
}
export const PUT: RequestHandler = async ({ params, request }) => {
requireApiKey(request);
const goodreadsId = checkedId(params.goodreadsId);
return json(await saveBook(await libraryDatabase(), goodreadsId, await readBody(request)));
};
export const DELETE: RequestHandler = async ({ params, request }) => {
requireApiKey(request);
const goodreadsId = checkedId(params.goodreadsId);
if (!deleteBook(await libraryDatabase(), goodreadsId)) error(404, `${goodreadsId} is not listed`);
return new Response(null, { status: 204 });
};
+9
View File
@@ -0,0 +1,9 @@
import { json } from '@sveltejs/kit';
import { exportLibrary } from '#lib/server/library';
import { requireApiKey } from '#lib/server/library/auth';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ request }) => {
requireApiKey(request);
return json(await exportLibrary(), { headers: { 'Cache-Control': 'no-store' } });
};
@@ -0,0 +1,24 @@
import { error, json } from '@sveltejs/kit';
import { requireApiKey } from '#lib/server/library/auth';
import { libraryDatabase } from '#lib/server/library/database';
import { readBody } from '#lib/server/library/input';
import { deleteGame, isIgdbId, saveGame } from '#lib/server/library/games';
import type { RequestHandler } from './$types';
function checkedId(igdbId: string): string {
if (!isIgdbId(igdbId)) error(400, `${igdbId} is not an IGDB ID`);
return igdbId;
}
export const PUT: RequestHandler = async ({ params, request }) => {
requireApiKey(request);
const igdbId = checkedId(params.igdbId);
return json(await saveGame(await libraryDatabase(), igdbId, await readBody(request)));
};
export const DELETE: RequestHandler = async ({ params, request }) => {
requireApiKey(request);
const igdbId = checkedId(params.igdbId);
if (!deleteGame(await libraryDatabase(), igdbId)) error(404, `${igdbId} is not listed`);
return new Response(null, { status: 204 });
};
@@ -0,0 +1,16 @@
import { error } from '@sveltejs/kit';
import { libraryDatabase } from '#lib/server/library/database';
import { readImage } from '#lib/server/library/images';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => {
const image = readImage(await libraryDatabase(), params.id);
if (!image) error(404, 'Image not found');
return new Response(new Uint8Array(image.data), {
headers: {
'Content-Type': image.contentType,
'Cache-Control': 'public, max-age=86400'
}
});
};
@@ -0,0 +1,24 @@
import { error, json } from '@sveltejs/kit';
import { requireApiKey } from '#lib/server/library/auth';
import { libraryDatabase } from '#lib/server/library/database';
import { readBody } from '#lib/server/library/input';
import { deleteMedia, isImdbId, saveMedia } from '#lib/server/library/media';
import type { RequestHandler } from './$types';
function checkedId(imdbId: string): string {
if (!isImdbId(imdbId)) error(400, `${imdbId} is not an IMDb ID`);
return imdbId;
}
export const PUT: RequestHandler = async ({ params, request }) => {
requireApiKey(request);
const imdbId = checkedId(params.imdbId);
return json(await saveMedia(await libraryDatabase(), imdbId, await readBody(request)));
};
export const DELETE: RequestHandler = async ({ params, request }) => {
requireApiKey(request);
const imdbId = checkedId(params.imdbId);
if (!deleteMedia(await libraryDatabase(), imdbId)) error(404, `${imdbId} is not listed`);
return new Response(null, { status: 204 });
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File
Whitespace-only changes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File
Whitespace-only changes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

+48 -7
View File
@@ -1,12 +1,15 @@
import sys
from textual import on
from textual import on, work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import DataTable, Footer, Header, TabbedContent, TabPane
import library_push
import portfolio
from tui.books_pane import BooksPane
from tui.dialogs import ConfirmDialog
from tui.games_pane import GamesPane
from tui.media_pane import MediaPane
from tui.music_pane import MusicPane
from tui.profile_pane import ProfilePane
@@ -20,6 +23,7 @@ TABS = [
("shows", "Shows"),
("movies", "Movies"),
("books", "Books"),
("games", "Games"),
("profile", "Profile"),
("experience", "Experience"),
("leadership", "Leadership"),
@@ -28,6 +32,10 @@ TABS = [
]
def tab_key(number: int) -> str:
return str(number % 10)
class ContentApp(App):
TITLE = "sirblob@engineering"
SUB_TITLE = "~/content"
@@ -35,7 +43,8 @@ class ContentApp(App):
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("t", "next_theme", "Theme"),
*[Binding(str(number), f"show_tab('{tab_id}')", label, show=False) for number, (tab_id, label) in enumerate(TABS, start=1)],
Binding("P", "push_library", "Push to website"),
*[Binding(tab_key(number), f"show_tab('{tab_id}')", label, show=False) for number, (tab_id, label) in enumerate(TABS, start=1)],
]
def __init__(self):
@@ -74,21 +83,53 @@ class ContentApp(App):
yield MediaPane("movie")
with TabPane("4 Books", id="books"):
yield BooksPane()
with TabPane("5 Profile", id="profile"):
with TabPane("5 Games", id="games"):
yield GamesPane()
with TabPane("6 Profile", id="profile"):
yield ProfilePane()
with TabPane("6 Experience", id="experience"):
with TabPane("7 Experience", id="experience"):
yield RecordListPane(portfolio.EXPERIENCE)
with TabPane("7 Leadership", id="leadership"):
with TabPane("8 Leadership", id="leadership"):
yield RecordListPane(portfolio.LEADERSHIP)
with TabPane("8 Projects", id="projects"):
with TabPane("9 Projects", id="projects"):
yield ProjectsPane()
with TabPane("9 Themes", id="themes"):
with TabPane("0 Themes", id="themes"):
yield ThemesPane()
yield Footer()
def on_mount(self):
self.focus_active_table()
def action_push_library(self):
self.notify("Comparing the library with the website...")
self.prepare_push()
@work(thread=True, exclusive=True, group="push")
def prepare_push(self):
try:
plan = library_push.plan_push()
except (OSError, ValueError) as error:
self.call_from_thread(self.notify, str(error), title="Push", severity="error", timeout=10)
return
self.call_from_thread(self.confirm_push, plan)
def confirm_push(self, plan: library_push.PushPlan):
def confirmed(should_push: bool | None):
if should_push:
self.push_library(plan)
self.push_screen(ConfirmDialog(plan.describe(), "Push", "primary"), confirmed)
@work(thread=True, exclusive=True, group="push")
def push_library(self, plan: library_push.PushPlan):
progress = lambda message: self.call_from_thread(self.notify, message)
result = library_push.run_push(plan, progress)
for message in result.errors[:5]:
self.call_from_thread(self.notify, message, title="Push", severity="warning", timeout=10)
summary = f"Pushed {result.pushed} and removed {result.removed} on {plan.target.url}"
severity = "warning" if result.errors else "information"
self.call_from_thread(self.notify, f"{summary}, {len(result.errors)} failed", title="Push", severity=severity, timeout=10)
def action_show_tab(self, tab_id: str):
self.query_one(TabbedContent).active = tab_id
-39
View File
@@ -1,14 +1,9 @@
import json
import re
import urllib.request
import goodreads
from jsonfile import PROJECT_ROOT
SCRAPE_CACHE_DIR = PROJECT_ROOT / "tools" / "content" / ".cache" / "goodreads"
COVERS_DIR = PROJECT_ROOT / "static" / "covers" / "books"
COVERS_PUBLIC_PATH = "/covers/books"
IMAGE_EXTENSION_PATTERN = re.compile(r"\.(jpe?g|png|webp|gif)(?:$|\?)", re.I)
def scrape_cache_path(identifier: str):
@@ -39,37 +34,3 @@ def scrape_book(url: str, fresh: bool = False) -> dict:
if scraped.get("title"):
store_scrape(scraped)
return scraped
def is_local_cover(cover: str | None) -> bool:
return bool(cover) and cover.startswith(COVERS_PUBLIC_PATH)
def existing_cover(identifier: str):
return next(iter(sorted(COVERS_DIR.glob(f"{identifier}.*"))), None) if COVERS_DIR.exists() else None
def cache_cover(identifier: str, source_url: str | None, fresh: bool = False) -> str | None:
existing = existing_cover(identifier)
if existing and not fresh:
return f"{COVERS_PUBLIC_PATH}/{existing.name}"
if not source_url or is_local_cover(source_url):
return f"{COVERS_PUBLIC_PATH}/{existing.name}" if existing else None
match = IMAGE_EXTENSION_PATTERN.search(source_url)
extension = match.group(1).lower().replace("jpeg", "jpg") if match else "jpg"
request = urllib.request.Request(source_url, headers=goodreads.REQUEST_HEADERS)
with urllib.request.urlopen(request, timeout=15) as response:
image = response.read()
COVERS_DIR.mkdir(parents=True, exist_ok=True)
if existing:
existing.unlink()
(COVERS_DIR / f"{identifier}.{extension}").write_bytes(image)
return f"{COVERS_PUBLIC_PATH}/{identifier}.{extension}"
def remove_cover(identifier: str) -> None:
existing = existing_cover(identifier)
if existing:
existing.unlink()
+39 -104
View File
@@ -1,12 +1,7 @@
import datetime
import book_cache
import goodreads
import jsonfile
import library_api
BOOKS_FILE = "books.json"
BOOK_INFO_FILE = "bookDetails.json"
PERSONAL_KEYS = ["goodreadsId", "title", "url", "shelf", "rating", "added", "series"]
SHELVES = ["read", "reading", "want-to-read"]
DESCRIPTION_MAX_LENGTH = 600
GENRE_LIMIT = 5
@@ -25,55 +20,36 @@ def check_shelf(shelf: str) -> str:
def load_books() -> list[dict]:
if not jsonfile.content_path(BOOKS_FILE).exists():
return []
info = jsonfile.load_data(BOOK_INFO_FILE, {})
return [{**info.get(book["goodreadsId"], {}), **book} for book in jsonfile.load(BOOKS_FILE)]
return library_api.fetch_library()["books"]
def personal_fields(book: dict) -> dict:
return {key: book[key] for key in PERSONAL_KEYS if key in book}
def info_fields(book: dict) -> dict:
return {key: value for key, value in book.items() if key not in PERSONAL_KEYS}
def save_books(books: list[dict]) -> None:
jsonfile.save(BOOKS_FILE, [personal_fields(book) for book in books])
jsonfile.save_data(BOOK_INFO_FILE, {book["goodreadsId"]: info_fields(book) for book in books})
def find_index(books: list[dict], text: str) -> int | None:
def find_book(books: list[dict], text: str) -> dict | None:
identifier = goodreads.book_id(text) or (text if text.isdigit() else None)
for index, book in enumerate(books):
for book in books:
if identifier and book.get("goodreadsId") == identifier:
return index
return book
if book.get("title", "").lower() == text.lower():
return index
return book
return None
def listed_identifier(text: str) -> str:
book = find_book(load_books(), text)
if not book:
raise ValueError(f"{text} is not listed")
return book["goodreadsId"]
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 local_cover(scraped: dict) -> str | None:
try:
return book_cache.cache_cover(scraped["goodreadsId"], scraped["cover"]) or scraped["cover"]
except OSError:
return scraped["cover"]
def stored_fields(scraped: dict) -> dict:
def scraped_fields(scraped: dict) -> dict:
return {
"goodreadsId": scraped["goodreadsId"],
"url": scraped["url"],
"title": scraped["title"],
"authors": scraped["authors"],
"cover": local_cover(scraped),
"coverSource": scraped["cover"],
"description": short_description(scraped["description"]),
"pages": scraped["pages"],
@@ -84,14 +60,8 @@ def stored_fields(scraped: dict) -> dict:
}
def update_book(text: str, change) -> dict:
books = load_books()
index = find_index(books, text)
if index is None:
raise ValueError(f"{text} is not listed")
books[index] = change(books[index])
save_books(books)
return books[index]
def update_book(text: str, fields: dict) -> dict:
return library_api.save_book(listed_identifier(text), fields)
def series_tag(series: dict, entry: dict) -> dict:
@@ -104,92 +74,57 @@ def series_tag(series: dict, entry: dict) -> dict:
def add_series_book(series: dict, entry: dict, shelf: str) -> dict:
check_shelf(shelf)
tag = series_tag(series, entry)
books = load_books()
index = find_index(books, entry["goodreadsId"])
if index is not None:
books[index] = {**books[index], "shelf": shelf, "series": tag}
save_books(books)
return books[index]
book = add_book(entry["url"], shelf)
return update_book(book["goodreadsId"], lambda stored: {**stored, "series": tag})
if find_book(load_books(), entry["goodreadsId"]):
return library_api.save_book(entry["goodreadsId"], {"shelf": shelf, "series": tag})
return add_book(entry["url"], shelf, series=tag)
def library_shelves() -> dict[str, str]:
return {book["goodreadsId"]: book.get("shelf", "read") for book in load_books()}
def add_book(url: str, shelf: str = "read", rating: int | None = None) -> dict:
def add_book(url: str, shelf: str = "read", rating: int | None = None, series: dict | None = None) -> dict:
check_shelf(shelf)
if rating is not None:
check_rating(rating)
identifier = goodreads.book_id(url)
if not identifier:
raise ValueError(f"{url} is not a Goodreads book link")
if find_index(load_books(), identifier) is not None:
if find_book(load_books(), identifier):
raise ValueError(f"{identifier} is already listed")
scraped = book_cache.scrape_book(url)
if not scraped["title"]:
raise ValueError("Could not read the book from Goodreads")
book = stored_fields(scraped)
book["shelf"] = shelf
if rating is not None:
book["rating"] = rating
book["added"] = datetime.date.today().isoformat()
books = load_books()
books.append(book)
save_books(books)
return book
fields = {**scraped_fields(scraped), "shelf": shelf, "rating": rating}
if series:
fields["series"] = series
return library_api.save_book(identifier, fields)
def remove_book(text: str) -> dict:
books = load_books()
index = find_index(books, text)
if index is None:
raise ValueError(f"{text} is not listed")
removed = books.pop(index)
save_books(books)
book_cache.remove_cover(removed["goodreadsId"])
return removed
def remove_book(text: str) -> None:
library_api.delete_book(listed_identifier(text))
def rate_book(text: str, rating: int | None) -> dict:
if rating is not None:
check_rating(rating)
def change(book: dict) -> dict:
updated = {key: value for key, value in book.items() if key != "rating"}
if rating is not None:
updated["rating"] = rating
return updated
return update_book(text, change)
return update_book(text, {"rating": rating})
def move_book(text: str, shelf: str) -> dict:
check_shelf(shelf)
return update_book(text, lambda book: {**book, "shelf": shelf})
return update_book(text, {"shelf": shelf})
def set_dates(text: str, started: str | None, finished: str | None) -> dict:
return update_book(text, {"started": started, "finished": finished})
def refresh_book(text: str) -> dict:
books = load_books()
index = find_index(books, text)
if index is None:
book = find_book(load_books(), text)
if not book:
raise ValueError(f"{text} is not listed")
scraped = book_cache.scrape_book(books[index]["url"], fresh=True)
return update_book(text, lambda book: {**book, **stored_fields(scraped)})
scraped = book_cache.scrape_book(book["url"], fresh=True)
return library_api.save_book(book["goodreadsId"], scraped_fields(scraped))
def books_missing_local_covers() -> list[dict]:
return [book for book in load_books() if not book_cache.is_local_cover(book.get("cover"))]
def cache_book_cover(identifier: str) -> dict:
def change(book: dict) -> dict:
source = book.get("coverSource") or book.get("cover")
cover = book_cache.cache_cover(identifier, source)
return {**book, "cover": cover or book.get("cover"), "coverSource": source}
return update_book(identifier, change)
def store_book_cover(book: dict) -> dict:
return library_api.save_book(book["goodreadsId"], {"coverSource": book["coverSource"]})
+10
View File
@@ -0,0 +1,10 @@
import datetime
def check_date(text: str | None) -> str | None:
if not text:
return None
try:
return datetime.date.fromisoformat(text).isoformat()
except ValueError:
raise ValueError(f"{text} is not a date like 2026-01-31") from None
+17
View File
@@ -0,0 +1,17 @@
import os
from jsonfile import PROJECT_ROOT
ENV_FILE = PROJECT_ROOT / ".env"
def env_value(name: str, default: str | None = None) -> str | None:
if os.environ.get(name):
return os.environ[name]
if not ENV_FILE.exists():
return default
for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
key, _, value = line.partition("=")
if key.strip() == name and value.strip():
return value.strip().strip("\"'")
return default
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more