Compare commits

..
13 Commits
191 changed files with 5346 additions and 982 deletions

No files matched your search

+1
View File
@@ -19,3 +19,4 @@ __pycache__
*.log
.DS_Store
Thumbs.db
data/lastfm
+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=
+2 -1
View File
@@ -25,7 +25,8 @@ vite.config.ts.timestamp-*
bun.lock
__pycache__/
.cache/
data/
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"]
+72 -17
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,53 +100,104 @@ 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
Site content lives in `content/`.
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, 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 |
| `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`; fill in the name field to skip the lookup. Books are scraped from Goodreads once when added and saved to `books.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` | Books scraped from Goodreads for the about page |
| `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 |
| File in `data/` | Holds |
| --- | --- |
| `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
```sh
+1 -38
View File
@@ -33,43 +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" }
],
"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/" },
{ "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/" },
{ "name": "Spider-Man: Across the Spider-Verse", "rating": 9, "url": "https://www.imdb.com/title/tt9362722/" },
{ "name": "Avatar", "rating": 10, "url": "https://www.imdb.com/title/tt0499549/" },
{ "name": "Avatar: The Way of Water", "rating": 10, "url": "https://www.imdb.com/title/tt1630029/" },
{ "name": "Avatar: Fire and Ash", "rating": 8, "url": "https://www.imdb.com/title/tt1757678/" },
{ "name": "Blade Runner 2049", "rating": 10, "url": "https://www.imdb.com/title/tt1856101/" }
{ "name": "Robotics", "icon": "proicons:robot", "description": "Building and programming robots to solve problems is fun.", "folder": "robotics" }
]
}
-379
View File
@@ -1,379 +0,0 @@
[
{ "goodreadsId": "170448", "url": "https://www.goodreads.com/book/show/170448", "title": "Animal Farm", "authors": ["George Orwell"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1325861570i/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, "shelf": "read", "rating": 10, "added": "2026-11-01" },
{ "goodreadsId": "61439040", "url": "https://www.goodreads.com/book/show/61439040", "title": "1984", "authors": ["George Orwell"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1657781256i/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, "shelf": "read", "rating": 10, "added": "2026-11-01" },
{
"goodreadsId": "13228487",
"url": "https://www.goodreads.com/book/show/13228487",
"title": "The Dragonet Prophecy (Wings of Fire, #1)",
"authors": ["Tui T. Sutherland"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1339198070i/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,
"shelf": "read",
"added": "2026-11-01",
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 1" },
"rating": 7
},
{
"goodreadsId": "15809617",
"url": "https://www.goodreads.com/book/show/15809617",
"title": "The Lost Heir (Wings of Fire, #2)",
"authors": ["Tui T. Sutherland"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1358266376i/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,
"shelf": "read",
"added": "2026-11-01",
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 2" },
"rating": 8
},
{
"goodreadsId": "16100976",
"url": "https://www.goodreads.com/book/show/16100976",
"title": "The Hidden Kingdom (Wings of Fire, #3)",
"authors": ["Tui T. Sutherland"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1364066230i/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,
"shelf": "read",
"added": "2026-11-01",
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 3" },
"rating": 9
},
{
"goodreadsId": "17675467",
"url": "https://www.goodreads.com/book/show/17675467",
"title": "The Dark Secret (Wings of Fire, #4)",
"authors": ["Tui T. Sutherland"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1368813111i/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,
"shelf": "read",
"added": "2026-11-01",
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 4" },
"rating": 8
},
{
"goodreadsId": "18527497",
"url": "https://www.goodreads.com/book/show/18527497",
"title": "The Brightest Night (Wings of Fire, #5)",
"authors": ["Tui T. Sutherland"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1389053585i/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,
"shelf": "read",
"added": "2026-11-01",
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 5" },
"rating": 10
},
{
"goodreadsId": "22323659",
"url": "https://www.goodreads.com/book/show/22323659",
"title": "Moon Rising (Wings of Fire, #6)",
"authors": ["Tui T. Sutherland"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1421370732i/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,
"shelf": "read",
"added": "2026-11-01",
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 6" },
"rating": 7
},
{
"goodreadsId": "23604435",
"url": "https://www.goodreads.com/book/show/23604435",
"title": "Winter Turning (Wings of Fire, #7)",
"authors": ["Tui T. Sutherland"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1419320915i/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,
"shelf": "read",
"added": "2026-11-01",
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 7" },
"rating": 7
},
{
"goodreadsId": "25711360",
"url": "https://www.goodreads.com/book/show/25711360",
"title": "Escaping Peril (Wings of Fire, #8)",
"authors": ["Tui T. Sutherland"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1437899850i/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,
"shelf": "read",
"added": "2026-11-01",
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 8" },
"rating": 8
},
{
"goodreadsId": "29550388",
"url": "https://www.goodreads.com/book/show/29550388",
"title": "Talons of Power (Wings of Fire, #9)",
"authors": ["Tui T. Sutherland"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1468226830i/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,
"shelf": "read",
"added": "2026-11-01",
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 9" },
"rating": 7
},
{
"goodreadsId": "33198183",
"url": "https://www.goodreads.com/book/show/33198183",
"title": "Darkness of Dragons (Wings of Fire, #10)",
"authors": ["Tui T. Sutherland"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1481665982i/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,
"shelf": "read",
"added": "2026-11-01",
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 10" },
"rating": 9
},
{
"goodreadsId": "35965782",
"url": "https://www.goodreads.com/book/show/35965782",
"title": "The Lost Continent (Wings of Fire, #11)",
"authors": ["Tui T. Sutherland"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1518644206i/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,
"shelf": "read",
"added": "2026-11-01",
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 11" },
"rating": 7
},
{
"goodreadsId": "39983543",
"url": "https://www.goodreads.com/book/show/39983543",
"title": "The Hive Queen (Wings of Fire, #12)",
"authors": ["Tui T. Sutherland"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1537623438i/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,
"shelf": "read",
"added": "2026-11-01",
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 12" },
"rating": 8
},
{
"goodreadsId": "35965828",
"url": "https://www.goodreads.com/book/show/35965828",
"title": "The Poison Jungle (Wings of Fire, #13)",
"authors": ["Tui T. Sutherland"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1549002227i/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,
"shelf": "read",
"added": "2026-11-01",
"series": { "name": "Wings of Fire", "url": "https://www.goodreads.com/series/92037", "position": "Book 13" },
"rating": 10
},
{
"goodreadsId": "49090511",
"url": "https://www.goodreads.com/book/show/49090511",
"title": "The Dangerous Gift (Wings of Fire, #14)",
"authors": ["Tui T. Sutherland"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1602613751i/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,
"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",
"url": "https://www.goodreads.com/book/show/57468679",
"title": "The Flames of Hope (Wings of Fire, #15)",
"authors": ["Tui T. Sutherland"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1648003520i/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,
"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",
"url": "https://www.goodreads.com/book/show/232700898",
"title": "The Hybrid Prince (Wings of Fire #16)",
"authors": ["Tui T. Sutherland"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1777635896i/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,
"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",
"url": "https://www.goodreads.com/book/show/13453029",
"title": "Wool Omnibus (Silo, #1)",
"authors": ["Hugh Howey"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1349614200i/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,
"shelf": "want-to-read",
"added": "2026-11-01",
"series": { "name": "Silo", "url": "https://www.goodreads.com/series/70647", "position": "Book 1" }
},
{
"goodreadsId": "17306293",
"url": "https://www.goodreads.com/book/show/17306293",
"title": "Shift (Silo, #2)",
"authors": ["Hugh Howey"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1379003176i/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,
"shelf": "want-to-read",
"added": "2026-11-01",
"series": { "name": "Silo", "url": "https://www.goodreads.com/series/70647", "position": "Book 2" }
},
{
"goodreadsId": "18866705",
"url": "https://www.goodreads.com/book/show/18866705",
"title": "Dust (Silo, #3)",
"authors": ["Hugh Howey"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1436806122i/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,
"shelf": "want-to-read",
"added": "2026-11-01",
"series": { "name": "Silo", "url": "https://www.goodreads.com/series/70647", "position": "Book 3" }
},
{
"goodreadsId": "37781",
"url": "https://www.goodreads.com/book/show/37781",
"title": "Things Fall Apart (The African Trilogy, #1)",
"authors": ["Chinua Achebe"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1708114013i/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,
"shelf": "read",
"added": "2026-11-01",
"series": { "name": "The African Trilogy", "url": "https://www.goodreads.com/series/168203", "position": "Book 1" },
"rating": 9
},
{
"goodreadsId": "37774",
"url": "https://www.goodreads.com/book/show/37774",
"title": "Arrow of God (The African Trilogy, #2)",
"authors": ["Chinua Achebe"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1708114068i/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,
"shelf": "read",
"added": "2026-11-01",
"series": { "name": "The African Trilogy", "url": "https://www.goodreads.com/series/168203", "position": "Book 2" },
"rating": 8
},
{
"goodreadsId": "111085",
"url": "https://www.goodreads.com/book/show/111085",
"title": "No Longer at Ease (The African Trilogy, #3)",
"authors": ["Chinua Achebe"],
"cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1708114041i/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,
"shelf": "read",
"added": "2026-11-01",
"series": { "name": "The African Trilogy", "url": "https://www.goodreads.com/series/168203", "position": "Book 3" },
"rating": 8
},
{ "goodreadsId": "22875069", "url": "https://www.goodreads.com/book/show/22875069", "title": "I Will Always Write Back: How One Letter Changed Two Lives", "authors": ["Caitlin Alifirenka", "Liz Welch", "Martin Ganda"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1415206347i/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, "shelf": "read", "rating": 9, "added": "2026-11-01" },
{ "goodreadsId": "33118312", "url": "https://www.goodreads.com/book/show/33118312", "title": "Refugee", "authors": ["Alan Gratz"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1487620933i/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, "shelf": "read", "rating": 7, "added": "2026-11-01" },
{ "goodreadsId": "1381", "url": "https://www.goodreads.com/book/show/1381", "title": "The Odyssey", "authors": ["Homer"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1711957706i/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, "shelf": "read", "rating": 9, "added": "2026-11-01" },
{ "goodreadsId": "38631641", "url": "https://www.goodreads.com/book/show/38631641", "title": "The lliad", "authors": ["Homer"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1519449125i/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, "shelf": "read", "rating": 7, "added": "2026-11-01" },
{ "goodreadsId": "35031085", "url": "https://www.goodreads.com/book/show/35031085", "title": "Frankenstein", "authors": ["Mary Wollstonecraft Shelley"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1631088473i/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, "shelf": "read", "rating": 10, "added": "2026-11-01" },
{ "goodreadsId": "41733839", "url": "https://www.goodreads.com/book/show/41733839", "title": "The Great Gatsby", "authors": ["F. Scott Fitzgerald"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1650033243i/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, "shelf": "read", "rating": 6, "added": "2026-11-01" },
{ "goodreadsId": "192566704", "url": "https://www.goodreads.com/book/show/192566704", "title": "The Outsiders", "authors": ["S.E. Hinton"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1689626520i/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, "shelf": "read", "rating": 9, "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" } }
]
},
{
+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:
+3
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",
+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'
}
});
+2
View File
@@ -61,12 +61,14 @@ export function bookStats(books: Book[]): BookStats | null {
export function screenStats(titles: RatedTitle[]): ScreenStats {
const ratings = titles.flatMap((title) => (title.rating === undefined ? [] : [title.rating]));
const watching = titles.filter((title) => title.status === 'watching').map((title) => title.name);
const planningCount = titles.filter((title) => title.status === 'planning').length;
const groups = new Set(titles.flatMap((title) => (title.collection ? [title.collection] : [])));
return {
rows: [
{ label: 'Titles', value: `${titles.length}` },
{ label: 'Watching', value: `${watching.length}` },
{ label: 'Planning', value: `${planningCount}` },
{ label: 'Groups', value: `${groups.size}` },
{ label: 'Avg rating', value: average(ratings) }
],
+106 -9
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">
{#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>
{#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;
+12 -3
View File
@@ -22,7 +22,9 @@
{/if}
{#if item.status === 'watching'}
<span class="watching"><Icon icon="mdi:play" width="13" />watching</span>
<span class="status watching"><Icon icon="mdi:play" width="13" />watching</span>
{:else if item.status === 'planning'}
<span class="status planning"><Icon icon="mdi:bookmark-outline" width="13" />planning</span>
{/if}
</div>
@@ -89,7 +91,7 @@
font-size: 0.78rem;
}
.watching {
.status {
position: absolute;
bottom: 0.4rem;
left: 0.4rem;
@@ -97,12 +99,19 @@
align-items: center;
gap: 0.2rem;
padding: 0 0.4rem;
background: var(--blue);
color: var(--background);
font-size: 0.75rem;
font-weight: 700;
}
.status.watching {
background: var(--blue);
}
.status.planning {
background: var(--magenta);
}
.text {
display: flex;
flex-direction: column;
@@ -1,6 +1,6 @@
<script lang="ts">
import type { MediaCollection } from '#lib/media/collections';
import { isWatching } from '#lib/media/collections';
import { isPlanning, isWatching, statusTone } from '#lib/media/collections';
import type { MediaDetails } from '#lib/media/types';
import CollectionGroup from '#lib/components/ui/CollectionGroup.svelte';
import MediaCard from '../MediaCard.svelte';
@@ -13,6 +13,7 @@
const icon = $derived(kind === 'show' ? 'mdi:television-classic' : 'mdi:movie-open-outline');
const watchingCount = $derived(collection.items.filter(isWatching).length);
const planningCount = $derived(collection.items.filter(isPlanning).length);
const rated = $derived(collection.items.filter((item) => item.rating !== undefined));
const average = $derived(rated.length ? rated.reduce((total, item) => total + (item.rating ?? 0), 0) / rated.length : null);
const covers = $derived(
@@ -21,7 +22,7 @@
href: item.url,
title: item.name,
image: mediaDetails[item.url]?.poster ?? null,
tone: isWatching(item) ? 'blue' : 'green'
tone: statusTone(item)
}))
);
</script>
@@ -32,6 +33,9 @@
{#if watchingCount}
<span style:color="var(--blue)">{watchingCount} watching</span>
{/if}
{#if planningCount}
<span style:color="var(--magenta)">{planningCount} planning</span>
{/if}
{#if average !== null}
<span style:color="var(--yellow)">avg {average.toFixed(1)}/10</span>
{/if}
@@ -4,7 +4,6 @@
import type { MediaDetails } from '#lib/media/types';
import { groupMedia } from '#lib/media/collections';
import MediaGrid from '../MediaGrid.svelte';
import MediaCard from '../MediaCard.svelte';
import MediaCollectionGroup from './MediaCollectionGroup.svelte';
let {
@@ -17,15 +16,6 @@
const collectionLabel = $derived(kind === 'show' ? 'Series' : 'Collections');
</script>
{#if grouped.watching.length}
<h3 class="subheading"><Icon icon="mdi:play-circle-outline" width="16" /> Currently watching <span class="muted">{grouped.watching.length}</span></h3>
<div class="watching-grid">
{#each grouped.watching as item (item.url)}
<MediaCard {item} details={mediaDetails[item.url]} {kind} />
{/each}
</div>
{/if}
{#if grouped.collections.length}
<h3 class="subheading"><Icon icon="mdi:layers-outline" width="16" /> {collectionLabel} <span class="muted">{grouped.collections.length}</span></h3>
<div class="collection-list">
@@ -36,8 +26,8 @@
{/if}
{#if grouped.standalone.length}
{#if grouped.watching.length || grouped.collections.length}
<h3 class="subheading"><Icon icon="mdi:check-circle-outline" width="16" /> Watched <span class="muted">{grouped.standalone.length}</span></h3>
{#if grouped.collections.length}
<h3 class="subheading"><Icon icon="mdi:view-grid-outline" width="16" /> All {kind === 'show' ? 'shows' : 'movies'} <span class="muted">{grouped.standalone.length}</span></h3>
{/if}
<MediaGrid items={grouped.standalone} {mediaDetails} {kind} />
{/if}
@@ -56,22 +46,10 @@
margin-top: 0;
}
.watching-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem 0.85rem;
}
.collection-list {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.75rem;
}
@container (max-width: 520px) {
.watching-grid {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
}
}
</style>
+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);
-3
View File
@@ -11,12 +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 wallpapersData from '#content/wallpapers.json';
import type {
AboutContent,
BlogSettings,
Book,
DesktopContent,
Experience,
Leadership,
@@ -41,7 +39,6 @@ export const blogSettings: BlogSettings = blogData;
export const playlist: Playlist = musicData;
export const researchGroups: ResearchGroup[] = researchData;
export const tcgDecks: TcgDeck[] = tcgDecksData as TcgDeck[];
export const books: Book[] = booksData as Book[];
export const wallpapers: Wallpaper[] = wallpapersData as Wallpaper[];
function featuredFirst(projects: Project[]): Project[] {
+33 -7
View File
@@ -125,15 +125,37 @@ 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';
export type WatchStatus = 'watched' | 'watching' | 'planning';
export interface RatedTitle {
name: string;
@@ -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: [] };
+19 -4
View File
@@ -7,19 +7,35 @@ export interface MediaCollection {
}
export interface GroupedMedia {
watching: RatedTitle[];
collections: MediaCollection[];
standalone: RatedTitle[];
}
const statusOrder = { watching: 0, watched: 1, planning: 2 };
export function isWatching(item: RatedTitle): boolean {
return item.status === 'watching';
}
export function isPlanning(item: RatedTitle): boolean {
return item.status === 'planning';
}
export function statusTone(item: RatedTitle): string {
if (isWatching(item)) return 'blue';
if (isPlanning(item)) return 'magenta';
return 'green';
}
export function ratingOf(item: RatedTitle): number {
return item.rating ?? -1;
}
function standaloneOrder(first: RatedTitle, second: RatedTitle): number {
const statusDifference = statusOrder[first.status ?? 'watched'] - statusOrder[second.status ?? 'watched'];
return statusDifference || ratingOf(second) - ratingOf(first);
}
function releaseYear(item: RatedTitle, details: Record<string, MediaDetails>): number {
const year = Number.parseInt(details[item.url]?.year ?? '', 10);
return Number.isNaN(year) ? Number.MAX_SAFE_INTEGER : year;
@@ -37,7 +53,7 @@ export function groupMedia(items: RatedTitle[], details: Record<string, MediaDet
for (const item of items) {
if (item.collection) {
groups.set(item.collection, [...(groups.get(item.collection) ?? []), item]);
} else if (!isWatching(item)) {
} else {
standalone.push(item);
}
}
@@ -50,8 +66,7 @@ export function groupMedia(items: RatedTitle[], details: Record<string, MediaDet
.sort((first, second) => averageRating(second.items) - averageRating(first.items) || first.name.localeCompare(second.name));
return {
watching: items.filter(isWatching),
collections,
standalone: [...standalone].sort((first, second) => ratingOf(second) - ratingOf(first))
standalone: [...standalone].sort(standaloneOrder)
};
}
+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;
}
-70
View File
@@ -1,70 +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';
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 fetchDetails(imdbId: string): Promise<MediaDetails | null> {
const cached = detailsCache.get(imdbId);
if (cached && Date.now() - cached.fetchedAt < cacheDuration) return cached.details;
try {
const params = new URLSearchParams({ apikey: OMDB_API_KEY ?? '', i: imdbId, plot: 'short' });
const response = await fetch(`https://www.omdbapi.com/?${params}`);
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 -5
View File
@@ -1,21 +1,20 @@
import { about } from '#lib/content';
import { getAniListProfile } from '#lib/server/anilist';
import { getMediaDetails } from '#lib/server/omdb';
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 [anime, manga, mediaDetails, listening] = await Promise.all([
const [anime, manga, library, listening] = await Promise.all([
anilistUsername ? getAniListProfile(anilistUsername, 'ANIME') : null,
anilistUsername ? getAniListProfile(anilistUsername, 'MANGA') : null,
getMediaDetails(mediaUrls),
getLibrary(),
about.lastfmUsername ? getListeningProfile(about.lastfmUsername) : null
]);
const hasManga = !!manga?.statusCounts.some((item) => item.count > 0);
return { anime, manga: hasManga ? manga : null, mediaDetails, listening };
return { anime, manga: hasManga ? manga : null, library, listening };
};
+39 -9
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}
{#if games.length}
<Section title="Games" id="games">
<div class="game-grid">
{#each about.games as game (game.name)}
{#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: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff. Show more