Add games library with IGDB, file explorer and static files script

This commit is contained in:
SirBlob committed 2026-11-03 00:27:20 -05:00
1 parent a16444451a
commit ec57bd73b3
65 files changed
+2230 -352

No files matched your search

+2
View File
@@ -2,6 +2,8 @@ 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
+1
View File
@@ -18,6 +18,7 @@ COPY package.json ./
RUN bun install --production --ignore-scripts
COPY --from=build /app/build ./build
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
+43 -23
View File
@@ -22,11 +22,12 @@ 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, apps (tcg, movies, shows, books), games 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.
- **Library apps** - movies, shows and books open as their own windows with status filters, search, sorting, details and a monthly watch and read history. Entries in the same series or collection are grouped together.
- **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.
@@ -70,7 +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 |
| `movies` / `shows` / `books` | open the library apps |
| `thunar` / `files` | browse `static/files` |
| `movies` / `shows` / `books` / `games library` | open the library apps |
| `pacman -S <pkg>` | install things (not really) |
## Running
@@ -99,67 +101,74 @@ Copy `.env.example` to `.env` and fill in what you need. Every value is optional
| --- | --- |
| `DISCORD_TOKEN`, `DISCORD_USER_ID`, `DISCORD_GUILD_ID` | Live Discord status in the bar |
| `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 and books. Set the same value on the server and locally |
| `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_DATA_DIR` | Folder for `library.db`, `data/library` by default |
| `LASTFM_DATA_DIR` | Folder for `lastfm.db`, `data/lastfm` by default |
## Content
Personal content and config live in `content/`. Shows, movies and books are the exception: they live in a SQLite database at `data/library/library.db` (set `LIBRARY_DATA_DIR` to move it) that the server owns, so adding one never needs a commit. Last.fm scrobbles and album covers are collected at runtime into `data/lastfm/lastfm.db` (set `LASTFM_DATA_DIR` to move it). On first start an older `scrobbles.jsonl`, `covers.json`, `track-covers.json` and `images/` in that folder are moved into the database, and those files can be deleted afterwards. Both databases stay out of git.
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 and book, public |
| `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 |
| `GET /api/library/images/<id>` | A saved poster or cover |
| `PUT /api/library/games/<igdbId>` | Add or change a game |
| `DELETE /api/library/games/<igdbId>` | Remove a game |
| `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 or reading the start date is set, and to watched or read the finish date, unless the request sends its own. Posters and covers are downloaded by the server and kept in the database.
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) |
| `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 and books) |
| `g` | Put a show or movie in a group, which the library apps and about page show together |
| `h` | Set the started and finished dates for a show, movie or book |
| `f` | Toggle featured on a project |
| `e` / `Enter` | Edit the selected entry (or change the rating for shows, movies, books and games) |
| `g` | Put a show or movie in a group, which the library apps and about page show together, or set a game's tags and note |
| `h` | Set the started and finished dates for a show, movie, book or game |
| `l` | Change a game's store link or image |
| `f` | Toggle featured on a project, or make a game your favorite |
| `k` | Add a game to the games you like (up to 20) |
| `p` | Preview a theme in the editor |
| `t` | Switch the editor to the next theme |
| `s` | Move a book to another shelf, set a show or movie to watched, watching or planning, or make a theme the site default |
| `s` | Move a book to another shelf, set a show, movie or game status (a game can also be replayable), or make a theme the site default |
| `d` | Delete |
| `K` / `J` | Move a song or entry up or down |
| `u` / `U` | Refresh the selected book, or every book, from Goodreads; `u` also refreshes a show or movie from OMDb |
| `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 |
| `q` | Quit |
The Shows, Movies and Books tabs read and write the library through the API at `LIBRARY_API_URL` using `LIBRARY_API_KEY`, so point it at the live site to update it from anywhere. Show and movie titles are looked up on OMDb with `OMDB_API_KEY` from `.env` when added; fill in the name field to skip the lookup. Books are scraped from Goodreads when added. To check a Goodreads link on its own, run `python3 tools/content/goodreads.py <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. 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>`.
A game in `about.json` can have a `mobygames` link. Run `python3 tools/content/mobygames.py <url> --save` to scrape its release date, platforms, developers, genres, scores, description and images into `data/gameDetails.json`, which the game card shows. Leave out `--save` to only print the result, or use `--debug` if MobyGames blocks the request.
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 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 |
@@ -170,9 +179,20 @@ A game in `about.json` can have a `mobygames` link. Run `python3 tools/content/m
| File in `data/` | Holds |
| --- | --- |
| `library/library.db` | Shows, movies, books, their history dates and saved images |
| `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 |
| `gameDetails.json` | Video game details scraped from MobyGames |
### Files
Files you drop into `static/files` (any folders inside it too) show up in the file explorer and are served at `/files/...`. Hidden files are skipped.
```sh
bun run files
```
Converts every HEIC or HEIF photo in `static/files` to a JPEG, turned the right way up and without metadata, and removes GPS location data from any other photo in there since the folder is public. Converted originals are moved to `tools/content/.cache/heic-originals`; pass `--delete-originals` to delete them instead, or `--keep-location` to leave location data alone. Run `bun run content:setup` again first to install `pillow-heif`.
A hobby in `about.json` can have a `folder`, such as `"folder": "robotics"`. Clicking that hobby on the about page opens the file explorer in `static/files/robotics`.
### Wallpapers
+1 -12
View File
@@ -33,17 +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/" },
{ "name": "Hades II", "url": "https://store.steampowered.com/app/1145350/Hades_II/", "mobygames": "https://www.mobygames.com/game/223472/hades-ii/" }
{ "name": "Robotics", "icon": "proicons:robot", "description": "Building and programming robots to solve problems is fun.", "folder": "robotics" }
]
}
+3 -1
View File
@@ -37,10 +37,12 @@
"name": "apps",
"side": "right",
"icons": [
{ "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": "books", "icon": "mdi:bookshelf", "open": { "app": "books" } },
{ "label": "games", "icon": "mdi:gamepad-variant-outline", "open": { "app": "games" } }
]
},
{
-1
View File
@@ -1 +0,0 @@
{}
+3 -2
View File
@@ -10,6 +10,8 @@
"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",
"content:setup":"python3 -m venv tools/content/.venv && tools/content/.venv/bin/pip install -r tools/content/requirements.txt",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
@@ -38,7 +40,6 @@
"imports": {
"#lib": "./src/lib/index.js",
"#lib/*": "./src/lib/*",
"#content/*": "./content/*",
"#data/*": "./data/*"
"#content/*": "./content/*"
}
}
+69 -30
View File
@@ -1,51 +1,53 @@
<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 details = $derived(game.details);
const image = $derived(game.image ?? details?.screenshot ?? details?.cover ?? null);
const releaseYear = $derived(details?.released?.match(/\d{4}/)?.[0] ?? null);
const metaLine = $derived([releaseYear, details?.developers[0]].filter(Boolean).join(' · '));
const score = $derived(
details?.criticScore != null ? `${details.criticScore}%` : details?.mobyScore != null ? `${details.mobyScore}` : null
);
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" title={details?.description ?? game.name}>
<a class="game-card" href={link || undefined} target="_blank" rel="noopener noreferrer" title={game.summary ?? game.name}>
<div class="cover">
{#if image}
<img src={image} alt="" loading="lazy" />
{#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 score}
<span class="score" title={details?.criticScore != null ? 'Critic score' : 'Moby score'}><Icon icon="mdi:star" width="13" />{score}</span>
{#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}
<span class="play"><Icon icon="mdi:play" width="16" /> Play</span>
</div>
<div class="details">
<span class="name">{game.name}</span>
<span class="store"><Icon icon={store.icon} width="14" />{store.label}</span>
{#if link}
<span class="store"><Icon icon={store.icon} width="14" />{store.label}</span>
{/if}
</div>
{#if metaLine}
<span class="meta">{metaLine}</span>
{/if}
{#if details?.genres.length}
<span class="meta">{details.genres.slice(0, 3).join(' · ')}</span>
{/if}
{#if game.mobygames}
<span class="meta source"><Icon icon="mdi:database-outline" width="12" />MobyGames</span>
{#if game.genres.length}
<span class="meta">{game.genres.slice(0, 3).join(' · ')}</span>
{/if}
</a>
@@ -109,10 +111,47 @@
text-overflow: ellipsis;
}
.source {
.favorite {
position: absolute;
top: 0.4rem;
left: 0.4rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
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 {
+13
View File
@@ -13,6 +13,7 @@
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';
@@ -192,6 +193,18 @@
</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>
@@ -3,6 +3,7 @@
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,
@@ -48,6 +49,18 @@
<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)}
@@ -162,6 +175,20 @@
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;
+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);
+1 -14
View File
@@ -12,14 +12,11 @@ import researchData from '#content/projects/research.json';
import organizationsData from '#content/projects/organizations.json';
import tcgDecksData from '#content/tcgDecks.json';
import wallpapersData from '#content/wallpapers.json';
import gameDetailsData from '#data/gameDetails.json';
import type {
AboutContent,
BlogSettings,
DesktopContent,
Experience,
Game,
GameDetails,
Leadership,
Playlist,
Profile,
@@ -36,17 +33,7 @@ export type * from './types';
export const profile: Profile = profileData;
export const experience: Experience[] = experienceData;
export const leadership: Leadership[] = leadershipData;
const gameDetails = gameDetailsData as Record<string, GameDetails>;
const mobygamesIdPattern = /mobygames\.com\/game\/(\d+)/;
function withGameDetails(game: Game): Game {
const details = gameDetails[game.mobygames?.match(mobygamesIdPattern)?.[1] ?? ''];
return details ? { ...game, details } : game;
}
const aboutContent = aboutData as AboutContent;
export const about: AboutContent = { ...aboutContent, games: aboutContent.games.map(withGameDetails) };
export const about: AboutContent = aboutData as AboutContent;
export const desktop: DesktopContent = desktopData;
export const blogSettings: BlogSettings = blogData;
export const playlist: Playlist = musicData;
+23 -20
View File
@@ -125,30 +125,34 @@ export interface Hobby {
name: string;
icon: string;
description: string;
folder?: string;
}
export interface GameDetails {
url: string;
title: string;
export type PlayStatus = 'played' | 'playing' | 'planning' | 'replayable';
export interface Game {
igdbId: string;
name: 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[];
mobyScore: number | null;
criticScore: number | null;
playerScore: number | null;
description: string | null;
cover: string | null;
screenshot: string | null;
}
export interface Game {
name: string;
image?: string;
url: string;
mobygames?: string;
details?: GameDetails;
summary: string | null;
score: number | null;
favorite: boolean;
liked: boolean;
tags: string[];
note: string | null;
added?: string;
started?: string;
finished?: string;
}
export type WatchStatus = 'watched' | 'watching' | 'planning';
@@ -212,14 +216,13 @@ export interface AboutContent {
lastfmUsername?: string;
setup?: DesktopSetup;
hobbies: Hobby[];
games: Game[];
}
export type GameName = 'snake' | 'breakout';
export type LibraryAppName = 'movies' | 'shows' | 'books';
export type LibraryAppName = 'movies' | 'shows' | 'books' | 'games';
export type DesktopApp = 'btop' | 'tcg' | 'pet' | GameName | LibraryAppName;
export type DesktopApp = 'btop' | 'tcg' | 'pet' | 'files' | GameName | LibraryAppName;
export type DesktopAction = { page: string } | { terminal: string } | { url: string } | { app: DesktopApp };
+2 -1
View File
@@ -20,7 +20,7 @@ export function openPage(path: string) {
goto(path);
}
const libraryAppNames: DesktopApp[] = ['movies', 'shows', 'books'];
const libraryAppNames: DesktopApp[] = ['movies', 'shows', 'books', 'games'];
function isLibraryApp(app: DesktopApp): app is LibraryAppName {
return libraryAppNames.includes(app);
@@ -30,6 +30,7 @@ export function openApp(app: DesktopApp) {
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);
}
+16 -1
View File
@@ -26,6 +26,11 @@ export interface TcgBrowserWindow {
id: string;
}
export interface FileExplorerWindow {
id: string;
startPath: string;
}
export interface LibraryWindow {
id: string;
app: LibraryAppName;
@@ -50,6 +55,7 @@ class WindowManager {
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);
@@ -61,6 +67,7 @@ class WindowManager {
private nextGameNumber = 1;
private nextTcgBrowserNumber = 1;
private nextLibraryNumber = 1;
private nextFileExplorerNumber = 1;
get extraWindowIds(): string[] {
return [
@@ -69,7 +76,8 @@ class WindowManager {
...this.systemMonitors.map((monitor) => monitor.id),
...this.games.map((gameWindow) => gameWindow.id),
...this.tcgBrowsers.map((browser) => browser.id),
...this.libraryWindows.map((libraryWindow) => libraryWindow.id)
...this.libraryWindows.map((libraryWindow) => libraryWindow.id),
...this.fileExplorers.map((explorer) => explorer.id)
];
}
@@ -115,6 +123,12 @@ class WindowManager {
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);
@@ -122,6 +136,7 @@ class WindowManager {
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('/');
}
+17 -1
View File
@@ -1,6 +1,6 @@
import type { LibraryAppName } from '#lib/content';
import type { LibraryData } from './types';
import { bookEntries, mediaEntries, type LibraryEntry } from './entries';
import { bookEntries, gameEntries, mediaEntries, type LibraryEntry } from './entries';
export interface LibraryStatus {
id: string;
@@ -24,6 +24,13 @@ const watchStatuses: LibraryStatus[] = [
{ 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' },
@@ -57,6 +64,15 @@ export const libraryApps: Record<LibraryAppName, LibraryApp> = {
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)
}
};
+35 -1
View File
@@ -1,4 +1,4 @@
import type { Book, RatedTitle } from '#lib/content';
import type { Book, Game, RatedTitle } from '#lib/content';
import type { MediaDetails, MediaKind } from '#lib/media/types';
export interface LibraryEntry {
@@ -16,6 +16,8 @@ export interface LibraryEntry {
groupPosition: string | null;
groupOrder: number;
facts: string[];
tags: string[];
note: string | null;
added: string | null;
started: string | null;
finished: string | null;
@@ -58,6 +60,8 @@ export function mediaEntries(items: RatedTitle[], details: Record<string, MediaD
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
@@ -81,8 +85,38 @@ export function bookEntries(books: Book[]): LibraryEntry[] {
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
}));
}
+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';
}
+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' };
}
}
+3 -2
View File
@@ -1,4 +1,4 @@
import type { Book, RatedTitle } from '#lib/content';
import type { Book, Game, RatedTitle } from '#lib/content';
import type { MediaDetails } from '#lib/media/types';
export interface LibraryMedia {
@@ -9,6 +9,7 @@ export interface LibraryMedia {
export interface LibraryData extends LibraryMedia {
books: Book[];
games: Game[];
}
export const emptyLibrary: LibraryData = { shows: [], movies: [], mediaDetails: {}, books: [] };
export const emptyLibrary: LibraryData = { shows: [], movies: [], mediaDetails: {}, books: [], games: [] };
+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))
};
}
+1 -10
View File
@@ -2,7 +2,7 @@ import { error } from '@sveltejs/kit';
import type { Book, BookSeries, BookShelf } from '#lib/content';
import { timestamp, type SqlDatabase } from './database';
import { hasImage, imageUrl, removeImage, syncImage } from './images';
import { readChoice, readHistory, readNumber, readRating, readText, readTextList, type RequestBody } from './input';
import { parseList, readChoice, readHistory, readNumber, readRating, readText, readTextList, type RequestBody } from './input';
interface BookRow {
goodreads_id: string;
@@ -36,15 +36,6 @@ function coverImageId(goodreadsId: string): string {
return `book-${goodreadsId}`;
}
function parseList(text: string): string[] {
try {
const value = JSON.parse(text);
return Array.isArray(value) ? value : [];
} catch {
return [];
}
}
function seriesOf(row: BookRow): BookSeries | undefined {
if (!row.series_name) return undefined;
return { name: row.series_name, url: row.series_url ?? '', ...(row.series_position && { position: row.series_position }) };
+40 -2
View File
@@ -1,5 +1,5 @@
import path from 'node:path';
import { openSqlite, type SqlDatabase } from '../sqlite';
import { addMissingColumns, openSqlite, type SqlDatabase } from '../sqlite';
export type { SqlDatabase };
@@ -45,6 +45,31 @@ const schema = `
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,
@@ -54,10 +79,23 @@ const schema = `
);
`;
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 ??= openSqlite(databasePath, schema);
opening ??= openLibraryDatabase();
return opening;
}
+178
View File
@@ -0,0 +1,178 @@
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 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))));
}
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;
}
+5 -2
View File
@@ -33,8 +33,11 @@ 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> {
if (!/^https?:\/\//.test(source)) throw new Error(`${source} is not a web address`);
const response = await fetch(source, { headers: requestHeaders });
if (!response.ok) throw new Error(`image request returned ${response.status}`);
@@ -47,7 +50,7 @@ async function downloadImage(source: string): Promise<StoredImage> {
}
export async function syncImage(database: SqlDatabase, id: string, source: string | null) {
if (!source) {
if (!source || !isWebAddress(source)) {
removeImage(database, id);
return;
}
+2 -1
View File
@@ -3,9 +3,10 @@ import { libraryDatabase } from './database';
import { storedImageIds } from './images';
import { listMedia } from './media';
import { listBooks } from './books';
import { listGames } from './games';
export async function getLibrary(): Promise<LibraryData> {
const database = await libraryDatabase();
const imageIds = storedImageIds(database);
return { ...listMedia(database, imageIds), books: listBooks(database, imageIds) };
return { ...listMedia(database, imageIds), books: listBooks(database, imageIds), games: listGames(database, imageIds) };
}
+15
View File
@@ -49,6 +49,21 @@ export function readTextList(body: RequestBody, key: string, current: string[]):
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`);
+7
View File
@@ -28,6 +28,13 @@ export async function openSqlite(filePath: string, schema: string): Promise<SqlD
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 {
+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')));
}
};
+6
View File
@@ -140,6 +140,12 @@ 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',
+38 -9
View File
@@ -1,5 +1,7 @@
<script lang="ts">
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';
@@ -16,6 +18,16 @@
let { data }: PageProps = $props();
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 }
@@ -24,7 +36,7 @@
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' }] : []),
@@ -43,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>
@@ -56,13 +75,15 @@
</Section>
{/if}
<Section title="Games" id="games">
<div class="game-grid">
{#each about.games as game (game.name)}
<GameCard {game} />
{/each}
</div>
</Section>
{#if games.length}
<Section title="Games" id="games">
<div class="game-grid">
{#each games as game (game.igdbId)}
<GameCard {game} />
{/each}
</div>
</Section>
{/if}
{#each mediaGroups as group (group.id)}
<Section title={group.title} id={group.id}>
@@ -100,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' } });
};
@@ -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 });
};
View File
Whitespace-only changes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

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

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

+14 -6
View File
@@ -7,6 +7,7 @@ from textual.widgets import DataTable, Footer, Header, TabbedContent, TabPane
import portfolio
from tui.books_pane import BooksPane
from tui.games_pane import GamesPane
from tui.media_pane import MediaPane
from tui.music_pane import MusicPane
from tui.profile_pane import ProfilePane
@@ -20,6 +21,7 @@ TABS = [
("shows", "Shows"),
("movies", "Movies"),
("books", "Books"),
("games", "Games"),
("profile", "Profile"),
("experience", "Experience"),
("leadership", "Leadership"),
@@ -28,6 +30,10 @@ TABS = [
]
def tab_key(number: int) -> str:
return str(number % 10)
class ContentApp(App):
TITLE = "sirblob@engineering"
SUB_TITLE = "~/content"
@@ -35,7 +41,7 @@ class ContentApp(App):
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("t", "next_theme", "Theme"),
*[Binding(str(number), f"show_tab('{tab_id}')", label, show=False) for number, (tab_id, label) in enumerate(TABS, start=1)],
*[Binding(tab_key(number), f"show_tab('{tab_id}')", label, show=False) for number, (tab_id, label) in enumerate(TABS, start=1)],
]
def __init__(self):
@@ -74,15 +80,17 @@ class ContentApp(App):
yield MediaPane("movie")
with TabPane("4 Books", id="books"):
yield BooksPane()
with TabPane("5 Profile", id="profile"):
with TabPane("5 Games", id="games"):
yield GamesPane()
with TabPane("6 Profile", id="profile"):
yield ProfilePane()
with TabPane("6 Experience", id="experience"):
with TabPane("7 Experience", id="experience"):
yield RecordListPane(portfolio.EXPERIENCE)
with TabPane("7 Leadership", id="leadership"):
with TabPane("8 Leadership", id="leadership"):
yield RecordListPane(portfolio.LEADERSHIP)
with TabPane("8 Projects", id="projects"):
with TabPane("9 Projects", id="projects"):
yield ProjectsPane()
with TabPane("9 Themes", id="themes"):
with TabPane("0 Themes", id="themes"):
yield ThemesPane()
yield Footer()
+105
View File
@@ -0,0 +1,105 @@
import igdb
import library_api
STATUSES = ["played", "playing", "planning", "replayable"]
STATUS_ORDER = {"playing": 0, "replayable": 1, "played": 2, "planning": 3}
PROFILE_FLAGS = {"favorite": "favorite", "liked": "liked"}
SUGGESTED_TAGS = ["Casual", "Competitive", "Love it", "Open to play", "Looking to discuss"]
def check_rating(rating: int | None) -> int | None:
if rating is not None and not 0 <= rating <= 10:
raise ValueError("Rating must be between 0 and 10")
return rating
def check_status(status: str) -> str:
if status not in STATUSES:
raise ValueError(f"Status must be one of {', '.join(STATUSES)}")
return status
def list_games() -> list[dict]:
games = library_api.fetch_library()["games"]
rating = lambda game: game["rating"] if game.get("rating") is not None else -1
return sorted(games, key=lambda game: (STATUS_ORDER[game["status"]], -rating(game), game["name"].lower()))
def lookup(link: str | None, name: str | None) -> dict:
return igdb.details(igdb.find_game(link or None, name or None))
def add_game(link: str | None, name: str | None, status: str = "played", rating: int | None = None, image: str | None = None) -> dict:
if not link and not name:
raise ValueError("Enter a store link, an IGDB link or a name")
check_status(status)
check_rating(rating)
found = lookup(link, name)
identifier = found.pop("igdbId")
if any(game["igdbId"] == identifier for game in list_games()):
raise ValueError(f"{found['name']} is already listed")
is_store_link = bool(link) and not igdb.IGDB_SLUG_PATTERN.search(link)
fields = {
**found,
"name": name or found["name"],
"url": link if is_store_link else None,
"status": status,
"rating": rating,
}
if image:
fields["imageSource"] = image
return library_api.save_game(identifier, fields)
def update_game(identifier: str, fields: dict) -> dict:
return library_api.save_game(identifier, fields)
def rate_game(identifier: str, rating: int | None) -> dict:
return update_game(identifier, {"rating": check_rating(rating)})
def set_status(identifier: str, status: str) -> dict:
return update_game(identifier, {"status": check_status(status)})
def set_dates(identifier: str, started: str | None, finished: str | None) -> dict:
return update_game(identifier, {"started": started, "finished": finished})
def set_links(identifier: str, url: str | None, image: str | None) -> dict:
fields = {"url": url}
if image:
fields["imageSource"] = image
return update_game(identifier, fields)
def toggle_flag(game: dict, flag: str) -> dict:
if flag not in PROFILE_FLAGS:
raise ValueError(f"{flag} is not one of {', '.join(PROFILE_FLAGS)}")
return update_game(game["igdbId"], {flag: not game.get(flag, False)})
def set_tags(identifier: str, tags_text: str | None, note: str | None) -> dict:
tags = [tag.strip() for tag in (tags_text or "").split(",") if tag.strip()]
return update_game(identifier, {"tags": tags, "note": note or None})
def profile_text(game: dict) -> str:
return ", ".join(label for flag, label in PROFILE_FLAGS.items() if game.get(flag)) or "-"
def remove_game(identifier: str) -> None:
library_api.delete_game(identifier)
def refresh_game(game: dict) -> dict:
result = igdb.game_by_id(game["igdbId"])
if not result:
raise ValueError(f"{game['name']} is no longer on IGDB")
found = igdb.details(result)
found.pop("igdbId", None)
found.pop("name", None)
if game.get("image"):
found.pop("imageSource", None)
return update_game(game["igdbId"], found)
+47
View File
@@ -0,0 +1,47 @@
import sys
import time
import games
import jsonfile
ABOUT_FILE = "about.json"
REQUEST_DELAY_SECONDS = 0.3
def main() -> int:
about = jsonfile.load(ABOUT_FILE)
listed_games = about.get("games", [])
if not listed_games:
print("about.json has no games to import")
return 0
try:
already_listed = {game["name"].lower() for game in games.list_games()}
except (OSError, ValueError) as error:
print(f"Import stopped: {error}", file=sys.stderr)
return 1
failed = []
for game in listed_games:
if game["name"].lower() in already_listed:
print(f"Already listed {game['name']}")
continue
try:
saved = games.add_game(game.get("url"), game["name"], "played", None, game.get("image"))
print(f"Imported {saved['name']} (IGDB {saved['igdbId']})")
except (OSError, ValueError) as error:
print(f"Skipped {game['name']}: {error}", file=sys.stderr)
failed.append(game)
time.sleep(REQUEST_DELAY_SECONDS)
if failed:
print(f"{len(failed)} games were not imported, so about.json was left as it is. Fix them and run this again.", file=sys.stderr)
return 1
del about["games"]
jsonfile.save(ABOUT_FILE, about)
print(f"Imported {len(listed_games)} games and removed them from about.json")
return 0
if __name__ == "__main__":
sys.exit(main())
+215
View File
@@ -0,0 +1,215 @@
import argparse
import datetime
import json
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from envfile import env_value
from jsonfile import PROJECT_ROOT
TOKEN_URL = "https://id.twitch.tv/oauth2/token"
GAMES_URL = "https://api.igdb.com/v4/games"
EXTERNAL_GAMES_URL = "https://api.igdb.com/v4/external_games"
IMAGE_URL = "https://images.igdb.com/igdb/image/upload/{size}/{image_id}.jpg"
TOKEN_CACHE_PATH = PROJECT_ROOT / "tools" / "content" / ".cache" / "igdb_token.json"
TOKEN_EXPIRY_MARGIN_SECONDS = 300
SUMMARY_MAX_LENGTH = 600
GAME_FIELDS = ", ".join([
"name",
"slug",
"url",
"summary",
"first_release_date",
"total_rating",
"genres.name",
"platforms.name",
"involved_companies.developer",
"involved_companies.publisher",
"involved_companies.company.name",
"cover.image_id",
"artworks.image_id",
"screenshots.image_id",
])
IGDB_SLUG_PATTERN = re.compile(r"igdb\.com/games/([\w-]+)")
STORE_ID_PATTERNS = {
"store.steampowered.com": re.compile(r"store\.steampowered\.com/app/(\d+)"),
"xbox.com": re.compile(r"xbox\.com/.*?/([0-9A-Za-z]{12})(?:[/?#]|$)"),
}
LINK_NOISE_SEGMENTS = {"store", "products", "product", "games", "game", "app", "p", "us", "en-us"}
SEGMENT_ID_PATTERN = re.compile(r"\d+|[0-9A-Z]{12}")
EPIC_SUFFIX_PATTERN = re.compile(r"-(?=[0-9a-f]*\d)[0-9a-f]{6}$")
NAME_NOISE_PATTERN = re.compile(r"\b(switch|ps4|ps5|pc|xbox)\b", re.I)
class IgdbError(ValueError):
pass
def credentials() -> tuple[str, str]:
client_id = env_value("IGDB_CLIENT_ID")
client_secret = env_value("IGDB_CLIENT_SECRET")
if not client_id or not client_secret:
raise IgdbError("Set IGDB_CLIENT_ID and IGDB_CLIENT_SECRET in .env to look up games")
return client_id, client_secret
def cached_token() -> str | None:
try:
cached = json.loads(TOKEN_CACHE_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
return cached["token"] if cached.get("expiresAt", 0) > time.time() else None
def fetch_token(client_id: str, client_secret: str) -> str:
parameters = urllib.parse.urlencode({"client_id": client_id, "client_secret": client_secret, "grant_type": "client_credentials"})
request = urllib.request.Request(f"{TOKEN_URL}?{parameters}", method="POST")
try:
with urllib.request.urlopen(request, timeout=15) as response:
result = json.load(response)
except urllib.error.HTTPError as error:
raise IgdbError(f"Twitch refused the IGDB keys ({error.code}). Check IGDB_CLIENT_ID and IGDB_CLIENT_SECRET") from error
TOKEN_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
expires_at = time.time() + result["expires_in"] - TOKEN_EXPIRY_MARGIN_SECONDS
TOKEN_CACHE_PATH.write_text(json.dumps({"token": result["access_token"], "expiresAt": expires_at}), encoding="utf-8")
return result["access_token"]
def query(url: str, body: str) -> list[dict]:
client_id, client_secret = credentials()
token = cached_token() or fetch_token(client_id, client_secret)
headers = {"Client-ID": client_id, "Authorization": f"Bearer {token}", "Accept": "application/json"}
request = urllib.request.Request(url, data=body.encode("utf-8"), headers=headers, method="POST")
try:
with urllib.request.urlopen(request, timeout=15) as response:
return json.load(response)
except urllib.error.HTTPError as error:
if error.code == 401:
TOKEN_CACHE_PATH.unlink(missing_ok=True)
raise IgdbError(f"IGDB returned {error.code}: {error.read().decode('utf-8', errors='replace')[:200]}") from error
def quoted(text: str) -> str:
return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
def game_by_id(identifier: str) -> dict | None:
results = query(GAMES_URL, f"fields {GAME_FIELDS}; where id = {int(identifier)};")
return results[0] if results else None
def game_by_slug(slug: str) -> dict | None:
results = query(GAMES_URL, f"fields {GAME_FIELDS}; where slug = {quoted(slug)};")
return results[0] if results else None
def game_by_store_link(link: str) -> dict | None:
for host, pattern in STORE_ID_PATTERNS.items():
match = pattern.search(link)
if not match:
continue
uid = match.group(1).upper() if host == "xbox.com" else match.group(1)
matches = query(EXTERNAL_GAMES_URL, f"fields game, url; where uid = {quoted(uid)}; limit 50;")
same_store = [item for item in matches if host in (item.get("url") or "")]
chosen = same_store or (matches if len(matches) == 1 else [])
if chosen and chosen[0].get("game"):
return game_by_id(str(chosen[0]["game"]))
return None
def search_games(name: str) -> list[dict]:
return query(GAMES_URL, f"search {quoted(name)}; fields {GAME_FIELDS}; limit 10;")
def search_game(name: str) -> dict | None:
results = search_games(name)
exact = [game for game in results if game.get("name", "").lower() == name.lower()]
return (exact or results or [None])[0]
def name_from_link(link: str) -> str:
segments = [segment for segment in urllib.parse.urlparse(link).path.split("/") if segment]
words = [segment for segment in segments if segment.lower() not in LINK_NOISE_SEGMENTS and not SEGMENT_ID_PATTERN.fullmatch(segment)]
guess = EPIC_SUFFIX_PATTERN.sub("", words[-1]) if words else ""
guess = NAME_NOISE_PATTERN.sub("", re.sub(r"[-_]+", " ", guess))
return " ".join(guess.split())
def find_game(link: str | None, name: str | None) -> dict:
game = None
if link and IGDB_SLUG_PATTERN.search(link):
game = game_by_slug(IGDB_SLUG_PATTERN.search(link).group(1))
if not game and link:
game = game_by_store_link(link)
search_name = name or (name_from_link(link) if link else "")
if not game and search_name:
game = search_game(search_name)
if not game:
raise IgdbError(f"Could not find {name or link} on IGDB. Try an IGDB link like https://www.igdb.com/games/hades-ii")
return game
def image_url(image: dict | None, size: str) -> str | None:
return IMAGE_URL.format(size=size, image_id=image["image_id"]) if image and image.get("image_id") else None
def first_image(game: dict, key: str) -> dict | None:
images = game.get(key) or []
return images[0] if images else None
def company_names(game: dict, role: str) -> list[str]:
return [item["company"]["name"] for item in game.get("involved_companies") or [] if item.get(role) and item.get("company")]
def short_summary(text: str | None) -> str | None:
if not text or len(text) <= SUMMARY_MAX_LENGTH:
return text
return text[:SUMMARY_MAX_LENGTH].rsplit(" ", 1)[0] + "..."
def release_date(game: dict) -> str | None:
seconds = game.get("first_release_date")
if not isinstance(seconds, (int, float)):
return None
return datetime.datetime.fromtimestamp(seconds, tz=datetime.timezone.utc).date().isoformat()
def details(game: dict) -> dict:
wide_image = first_image(game, "artworks") or first_image(game, "screenshots")
return {
"igdbId": str(game["id"]),
"name": game.get("name"),
"igdbUrl": game.get("url"),
"released": release_date(game),
"platforms": [platform["name"] for platform in game.get("platforms") or [] if platform.get("name")],
"developers": company_names(game, "developer"),
"publishers": company_names(game, "publisher"),
"genres": [genre["name"] for genre in game.get("genres") or [] if genre.get("name")],
"summary": short_summary(game.get("summary")),
"score": round(game["total_rating"]) if game.get("total_rating") is not None else None,
"imageSource": image_url(wide_image, "t_1080p"),
"coverSource": image_url(game.get("cover"), "t_cover_big"),
}
def main() -> int:
parser = argparse.ArgumentParser(description="Look up a video game on IGDB")
parser.add_argument("text", help="a store link, an IGDB link or a game name")
arguments = parser.parse_args()
is_link = arguments.text.startswith("http")
try:
result = details(find_game(arguments.text if is_link else None, None if is_link else arguments.text))
except (OSError, ValueError) as error:
print(f"Error: {error}", file=sys.stderr)
return 1
print(json.dumps(result, indent="\t", ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
+9
View File
@@ -75,5 +75,14 @@ def delete_book(goodreads_id: str) -> None:
send("DELETE", f"/api/library/books/{quoted(goodreads_id)}")
def save_game(igdb_id: str, fields: dict) -> dict:
return send("PUT", f"/api/library/games/{quoted(igdb_id)}", fields)
def delete_game(igdb_id: str) -> None:
send("DELETE", f"/api/library/games/{quoted(igdb_id)}")
def is_stored_image(path: str | None) -> bool:
return bool(path) and path.startswith(IMAGE_PATH_PREFIX)
-209
View File
@@ -1,209 +0,0 @@
import argparse
import html
import json
import re
import sys
from pathlib import Path
import jsonfile
from html_page import download_page, meta_tags, page_title, plain_text
GAME_URL_PATTERN = re.compile(r"mobygames\.com/game/(\d+)")
HEADING_PATTERN = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S | re.I)
METADATA_PATTERN = re.compile(r"<dt[^>]*>(.*?)</dt>\s*<dd[^>]*>(.*?)</dd>", re.S | re.I)
LINK_PATTERN = re.compile(r"<a\s[^>]*href=[\"']([^\"']*)[\"'][^>]*>(.*?)</a>", re.S | re.I)
DESCRIPTION_PATTERN = re.compile(r"<div[^>]*id=[\"']description-text[\"'][^>]*>(.*?)</div>", re.S | re.I)
COVER_PATTERN = re.compile(r"https://cdn\.mobygames\.com/covers/[^\"'\s)]+")
SCREENSHOT_PATTERN = re.compile(r"https://cdn\.mobygames\.com/screenshots/[^\"'\s)]+")
NUMBER_PATTERN = re.compile(r"\d+(?:\.\d+)?")
PERCENT_PATTERN = re.compile(r"(\d+)\s*%")
BLOCKED_PAGE_HINTS = ["just a moment", "cf-chl", "captcha", "access denied", "are you a human"]
DETAILS_FILE = "gameDetails.json"
DESCRIPTION_MAX_LENGTH = 600
class BlockedError(ValueError):
pass
def game_id(url: str) -> str | None:
match = GAME_URL_PATTERN.search(url)
return match.group(1) if match else None
def blocked_reason(page: str) -> str | None:
lowered = page.lower()
hint = next((hint for hint in BLOCKED_PAGE_HINTS if hint in lowered), None)
if hint and not METADATA_PATTERN.search(page):
return f"the page looks like a bot check (found '{hint}')"
return None
def metadata(page: str) -> dict[str, str]:
fields = {}
for label, value in METADATA_PATTERN.findall(page):
name = (plain_text(label) or "").strip().lower()
if name and name not in fields:
fields[name] = value
return fields
def field_starting_with(fields: dict[str, str], *prefixes: str) -> str:
return next((value for name, value in fields.items() if name.startswith(prefixes)), "")
def link_texts(fragment: str, path_part: str | None = None) -> list[str]:
names = []
for href, text in LINK_PATTERN.findall(fragment):
name = plain_text(text)
if name and (path_part is None or path_part in href) and name not in names:
names.append(name)
return names
def first_number(fragment: str) -> float | None:
match = NUMBER_PATTERN.search(plain_text(fragment) or "")
return float(match.group(0)) if match else None
def first_percent(fragment: str) -> int | None:
match = PERCENT_PATTERN.search(plain_text(fragment) or "")
return int(match.group(1)) if match else None
def short_description(text: str | None) -> str | None:
if not text or len(text) <= DESCRIPTION_MAX_LENGTH:
return text
return text[:DESCRIPTION_MAX_LENGTH].rsplit(" ", 1)[0] + "..."
def description_of(page: str, meta: dict) -> str | None:
match = DESCRIPTION_PATTERN.search(page)
text = plain_text(match.group(1)) if match else None
return short_description(text or meta.get("og:description") or meta.get("description"))
def title_of(page: str, meta: dict) -> str:
heading = HEADING_PATTERN.search(page)
title = plain_text(heading.group(1)) if heading else None
return title or meta.get("og:title") or page_title(page).split(" - ")[0]
def first_match(pattern: re.Pattern, page: str) -> str | None:
match = pattern.search(page)
return html.unescape(match.group(0)) if match else None
def parse_game(page: str, identifier: str, url: str) -> dict:
meta = meta_tags(page)
fields = metadata(page)
released = field_starting_with(fields, "released")
release_fields = "".join(value for name, value in fields.items() if name.startswith(("released", "releases", "platform")))
genre_fields = "".join(fields.get(name, "") for name in ["genre", "perspective", "gameplay", "setting"])
return {
"mobygamesId": identifier,
"url": url,
"title": title_of(page, meta),
"released": (link_texts(released) or [plain_text(released)])[0] if released else None,
"platforms": link_texts(release_fields, "/platform/"),
"developers": link_texts(field_starting_with(fields, "developer")),
"publishers": link_texts(field_starting_with(fields, "publisher")),
"genres": link_texts(genre_fields),
"mobyScore": first_number(field_starting_with(fields, "moby score")),
"criticScore": first_percent(field_starting_with(fields, "critic")),
"playerScore": first_number(field_starting_with(fields, "player")),
"description": description_of(page, meta),
"cover": first_match(COVER_PATTERN, page) or meta.get("og:image"),
"screenshot": first_match(SCREENSHOT_PATTERN, page),
}
def scrape_game(url: str) -> dict:
identifier = game_id(url)
if not identifier:
raise ValueError(f"not a MobyGames game url: {url}")
page = download_page(url)
reason = blocked_reason(page.text)
if reason:
raise BlockedError(f"MobyGames blocked the request ({reason}), wait a few minutes and try again")
return parse_game(page.text, identifier, url)
def save_game(game: dict) -> None:
details = jsonfile.load_data(DETAILS_FILE, {})
details[game["mobygamesId"]] = {key: value for key, value in game.items() if key != "mobygamesId"}
jsonfile.save_data(DETAILS_FILE, dict(sorted(details.items())))
def print_diagnostics(url: str, save_path: Path | None):
page = download_page(url)
print(f"Status {page.status}")
print(f"Final url {page.final_url}")
print(f"Size {len(page.text):,} characters")
print(f"Title {page_title(page.text) or '-'}")
print(f"Fields {', '.join(metadata(page.text)) or '-'}")
print(f"Meta tags {len(meta_tags(page.text))}")
print(f"Blocked {blocked_reason(page.text) or 'no sign of a bot check'}")
if save_path:
save_path.write_text(page.text, encoding="utf-8")
print(f"Saved html to {save_path}")
def print_summary(game: dict):
print(game["title"] or "unknown title")
print("-" * 60)
for label, key in [
("Released", "released"),
("Platforms", "platforms"),
("Developers", "developers"),
("Publishers", "publishers"),
("Genres", "genres"),
("Moby score", "mobyScore"),
("Critics", "criticScore"),
("Players", "playerScore"),
("Cover", "cover"),
("Screenshot", "screenshot"),
]:
value = game[key]
text = ", ".join(value) if isinstance(value, list) else value
print(f"{label:<11} {text if text not in (None, '') else '-'}")
if game["description"]:
print(f"\n{game['description']}")
missing = [key for key, value in game.items() if value in (None, "", [])]
if missing:
print(f"\nNot found on the page: {', '.join(missing)}")
def main() -> int:
parser = argparse.ArgumentParser(description="Scrape video game info from MobyGames")
parser.add_argument("urls", nargs="+", help="MobyGames game urls, for example https://www.mobygames.com/game/223472/hades-ii/")
parser.add_argument("--json", action="store_true", help="print the raw result as JSON")
parser.add_argument("--save", action="store_true", help=f"save the result into data/{DETAILS_FILE} for the site")
parser.add_argument("--debug", action="store_true", help="show what the downloaded page contains instead of parsing it")
parser.add_argument("--save-html", type=Path, help="with --debug, save the downloaded page to this file")
arguments = parser.parse_args()
exit_code = 0
for url in arguments.urls:
try:
if arguments.debug:
print_diagnostics(url, arguments.save_html)
continue
game = scrape_game(url)
except (OSError, ValueError) as error:
print(f"Error for {url}: {error}", file=sys.stderr)
exit_code = 1
continue
if arguments.save:
save_game(game)
if arguments.json:
print(json.dumps(game, indent="\t", ensure_ascii=False))
else:
print_summary(game)
print()
return exit_code
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -1,2 +1,3 @@
textual>=8.2,<9
pillow>=11
pillow-heif>=0.18
+106
View File
@@ -0,0 +1,106 @@
import argparse
import shutil
import sys
from pathlib import Path
from PIL import Image, ImageOps
from pillow_heif import register_heif_opener
from jsonfile import PROJECT_ROOT
FILES_DIR = PROJECT_ROOT / "static" / "files"
ORIGINALS_DIR = PROJECT_ROOT / "tools" / "content" / ".cache" / "heic-originals"
HEIC_EXTENSIONS = {".heic", ".heif"}
PHOTO_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
JPEG_QUALITY = 90
GPS_TAG = 0x8825
register_heif_opener()
def visible_files(folder: Path, extensions: set[str]) -> list[Path]:
return sorted(
path
for path in folder.rglob("*")
if path.is_file() and path.suffix.lower() in extensions and not any(part.startswith(".") for part in path.relative_to(folder).parts)
)
def save_without_metadata(image: Image.Image, target: Path) -> None:
upright = ImageOps.exif_transpose(image)
if target.suffix.lower() in {".jpg", ".jpeg"}:
upright.convert("RGB").save(target, "JPEG", quality=JPEG_QUALITY, optimize=True)
else:
upright.save(target)
def keep_original(source: Path) -> Path:
backup = ORIGINALS_DIR / source.relative_to(FILES_DIR)
backup.parent.mkdir(parents=True, exist_ok=True)
shutil.move(source, backup)
return backup
def convert_heic(source: Path, delete_originals: bool) -> str:
target = source.with_suffix(".jpg")
if target.exists():
return f"skipped {source.relative_to(FILES_DIR)}, {target.name} already exists"
with Image.open(source) as image:
save_without_metadata(image, target)
if delete_originals:
source.unlink()
return f"converted {target.relative_to(FILES_DIR)}"
return f"converted {target.relative_to(FILES_DIR)}, original kept in {keep_original(source).relative_to(PROJECT_ROOT)}"
def has_location(path: Path) -> bool:
try:
with Image.open(path) as image:
return bool(image.getexif().get_ifd(GPS_TAG))
except OSError:
return False
def strip_location(path: Path) -> str:
with Image.open(path) as image:
image.load()
save_without_metadata(image, path)
return f"removed location from {path.relative_to(FILES_DIR)}"
def report(action) -> int:
try:
print(action())
return 0
except (OSError, ValueError) as error:
print(f"Error: {error}", file=sys.stderr)
return 1
def main() -> int:
parser = argparse.ArgumentParser(description="Convert HEIC photos in static/files to JPEG and remove location data from photos")
parser.add_argument("--delete-originals", action="store_true", help="delete converted HEIC files instead of moving them to tools/content/.cache")
parser.add_argument("--keep-location", action="store_true", help="leave GPS data in existing photos")
arguments = parser.parse_args()
if not FILES_DIR.exists():
print(f"{FILES_DIR.relative_to(PROJECT_ROOT)} does not exist", file=sys.stderr)
return 1
heic_files = visible_files(FILES_DIR, HEIC_EXTENSIONS)
located_photos = [] if arguments.keep_location else [photo for photo in visible_files(FILES_DIR, PHOTO_EXTENSIONS) if has_location(photo)]
if not heic_files and not located_photos:
print("Nothing to do: no HEIC files and no photos with location data")
return 0
failed = 0
for source in heic_files:
failed += report(lambda: convert_heic(source, arguments.delete_originals))
for photo in located_photos:
failed += report(lambda: strip_location(photo))
print(f"Done: {len(heic_files) + len(located_photos) - failed} changed, {failed} failed")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
+195
View File
@@ -0,0 +1,195 @@
from rich.text import Text
from textual.app import ComposeResult
from textual.binding import Binding
from textual.widgets import DataTable
import games
from tui.cache_indicator import cache_state, cache_text
from tui.dialogs import ConfirmDialog, FormDialog, FormField
from tui.library_pane import LibraryPane
STATUS_STYLES = {"playing": "blue", "played": "green", "planning": "magenta", "replayable": "cyan"}
def rating_text(game: dict) -> str:
return f"{game['rating']}/10" if game.get("rating") is not None else "-"
def image_state(game: dict) -> str:
has_details = bool(game.get("released") or game.get("genres"))
return cache_state(has_details, bool(game.get("image")))
class GamesPane(LibraryPane):
BINDINGS = [
Binding("a", "add", "Add"),
Binding("e", "rate", "Rate"),
Binding("s", "status", "Status"),
Binding("l", "links", "Links"),
Binding("f", "toggle('favorite')", "Favorite"),
Binding("k", "toggle('liked')", "Like"),
Binding("g", "tags", "Tags"),
Binding("h", "dates", "Dates"),
Binding("d", "delete", "Delete"),
Binding("u", "refresh", "Refresh"),
Binding("r", "reload", "Reload"),
]
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.border_subtitle = "games"
self.games: dict[str, dict] = {}
def compose(self) -> ComposeResult:
yield DataTable(cursor_type="row", zebra_stripes=True)
def on_mount(self):
table = self.table()
table.add_column("Status", key="status")
table.add_column("Rating", key="rating")
table.add_column("Name", key="name")
table.add_column("Released", key="released")
table.add_column("Developer", key="developer")
table.add_column("Profile", key="profile")
table.add_column("Details", key="details")
table.add_column("IGDB", key="igdb")
self.reload()
def reload(self, focus_key: str | None = None):
table = self.table()
table.clear()
loaded = self.load_entries(games.list_games)
self.games = {game["igdbId"]: game for game in loaded}
for game in loaded:
table.add_row(
Text(game["status"], style=STATUS_STYLES[game["status"]]),
rating_text(game),
game["name"],
game.get("released") or "-",
(game.get("developers") or ["-"])[0],
games.profile_text(game),
cache_text(image_state(game)),
game["igdbId"],
key=game["igdbId"],
)
self.restore_cursor(focus_key)
def selected_game(self) -> dict | None:
return self.games.get(self.selected_key())
def action_add(self):
fields = [
FormField("link", "Store or IGDB link", placeholder="https://store.steampowered.com/app/2868840/Slay_the_Spire_2/"),
FormField("name", "Name (searched on IGDB when the link has no match)"),
FormField("status", "Status", kind="select", options=games.STATUSES, value="played"),
FormField("rating", "Your rating (0 to 10, optional)", kind="rating"),
FormField("image", "Image (optional site path or URL, IGDB artwork when empty)", placeholder="/img/minecraft.png"),
]
self.app.push_screen(FormDialog("Add a game", fields, "Add"), self.add_game)
def add_game(self, values: dict | None):
if not values:
return
def added(game: dict):
self.reload(game["igdbId"])
self.notify(f"Added {game['name']} ({game['status']})")
self.run_in_background(
"Looking up the game on IGDB...",
lambda: games.add_game(values["link"], values["name"], values["status"], values["rating"], values["image"]),
added,
)
def action_rate(self):
game = self.selected_game()
if not game:
return
current = "" if game.get("rating") is None else str(game["rating"])
fields = [FormField("rating", f"Rating for {game['name']} (empty to clear)", kind="rating", value=current)]
def rated(values: dict | None):
if values is not None:
self.run_now(lambda: games.rate_game(game["igdbId"], values["rating"]), f"Updated {game['name']}", game["igdbId"])
self.app.push_screen(FormDialog("Change rating", fields), rated)
def action_status(self):
game = self.selected_game()
if not game:
return
fields = [FormField("status", f"Status for {game['name']}", kind="select", options=games.STATUSES, value=game["status"])]
def changed(values: dict | None):
if values:
status = values["status"]
self.run_now(lambda: games.set_status(game["igdbId"], status), f"{game['name']} is now {status}", game["igdbId"])
self.app.push_screen(FormDialog("Change status", fields, "Save"), changed)
def action_links(self):
game = self.selected_game()
if not game:
return
fields = [
FormField("url", "Store link (empty to remove)", value=game.get("url") or ""),
FormField("image", "New image (site path or URL, empty keeps the current one)", placeholder="/img/minecraft.png"),
]
def saved(values: dict | None):
if values is not None:
self.run_now(lambda: games.set_links(game["igdbId"], values["url"], values["image"]), f"Saved links for {game['name']}", game["igdbId"])
self.app.push_screen(FormDialog("Store link and image", fields), saved)
def action_toggle(self, flag: str):
game = self.selected_game()
if not game:
return
label = games.PROFILE_FLAGS[flag]
message = f"{game['name']} is no longer {label}" if game.get(flag) else f"{game['name']} is now {label}"
self.run_now(lambda: games.toggle_flag(game, flag), message, game["igdbId"])
def action_tags(self):
game = self.selected_game()
if not game:
return
fields = [
FormField("tags", "Tags, separated by commas", value=", ".join(game.get("tags") or []), placeholder=", ".join(games.SUGGESTED_TAGS)),
FormField("note", "Why you like it (shown with your favorite)", value=game.get("note") or ""),
]
def saved(values: dict | None):
if values is not None:
self.run_now(lambda: games.set_tags(game["igdbId"], values["tags"], values["note"]), f"Saved tags for {game['name']}", game["igdbId"])
self.app.push_screen(FormDialog("Tags and note", fields), saved)
def action_dates(self):
game = self.selected_game()
if not game:
return
identifier = game["igdbId"]
self.edit_dates(game["name"], game, lambda started, finished: games.set_dates(identifier, started, finished), identifier)
def action_delete(self):
game = self.selected_game()
if not game:
return
def confirmed(should_delete: bool | None):
if should_delete:
self.run_now(lambda: games.remove_game(game["igdbId"]), f"Removed {game['name']}")
self.app.push_screen(ConfirmDialog(f"Remove {game['name']}?"), confirmed)
def action_refresh(self):
game = self.selected_game()
if not game:
return
def refreshed(_saved: dict):
self.reload(game["igdbId"])
self.notify(f"Refreshed {game['name']} from IGDB")
self.run_in_background(f"Refreshing {game['name']}...", lambda: games.refresh_game(game), refreshed)