Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ lib/
├── features/ # collections, search, settings, splash, statistics,
│ # tier_lists, mood_grids, wishlist, releases,
│ # recommendations, genre_cloud, personalization,
│ # home, welcome
│ # showcase, likes, home, welcome
└── shared/
├── constants/ # media_type_theme, platform_features, *_ui extensions
├── extensions/ gamepad/ keyboard/ navigation/ services/ utils/
Expand Down
8 changes: 5 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -190,18 +190,19 @@ jobs:
--dart-define=SCREENSCRAPER_DEV_PASSWORD=${{ secrets.SCREENSCRAPER_DEV_PASSWORD }}
--dart-define=PODCASTINDEX_API_KEY=${{ secrets.PODCASTINDEX_API_KEY }}
--dart-define=PODCASTINDEX_API_SECRET=${{ secrets.PODCASTINDEX_API_SECRET }}
--dart-define=SIMKL_CLIENT_ID=${{ secrets.SIMKL_CLIENT_ID }}

- name: Archive Linux build
run: |
VERSION="${{ github.ref_name }}"
cd build/linux/x64/release/bundle
tar czf "$GITHUB_WORKSPACE/tonkatsu-box-${VERSION}-linux-experimental.tar.gz" .
tar czf "$GITHUB_WORKSPACE/tonkatsu-box-${VERSION}-linux.tar.gz" .

- name: Upload Linux artifact
uses: actions/upload-artifact@v4
with:
name: linux-build
path: tonkatsu-box-${{ github.ref_name }}-linux-experimental.tar.gz
path: tonkatsu-box-${{ github.ref_name }}-linux.tar.gz
retention-days: 1

build-macos:
Expand Down Expand Up @@ -231,6 +232,7 @@ jobs:
--dart-define=SCREENSCRAPER_DEV_PASSWORD=${{ secrets.SCREENSCRAPER_DEV_PASSWORD }}
--dart-define=PODCASTINDEX_API_KEY=${{ secrets.PODCASTINDEX_API_KEY }}
--dart-define=PODCASTINDEX_API_SECRET=${{ secrets.PODCASTINDEX_API_SECRET }}
--dart-define=SIMKL_CLIENT_ID=${{ secrets.SIMKL_CLIENT_ID }}

# Unsigned/not notarized: Gatekeeper warns until an Apple Developer ID is set up.
- name: Package macOS DMG
Expand Down Expand Up @@ -307,5 +309,5 @@ jobs:
"tonkatsu-box-${VERSION}-android-arm64-v8a.apk" \
"tonkatsu-box-${VERSION}-android-armeabi-v7a.apk" \
"tonkatsu-box-${VERSION}-android-x86_64.apk" \
"tonkatsu-box-${VERSION}-linux-experimental.tar.gz" \
"tonkatsu-box-${VERSION}-linux.tar.gz" \
"tonkatsu-box-${VERSION}-macos-experimental.dmg"
445 changes: 445 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,9 @@ The whole interface is localized with runtime switching. Pick your language in *
| macOS | [**Download .dmg**](https://github.com/hacan359/tonkatsu_box/releases/latest) |
| Android | [**Download .apk**](https://github.com/hacan359/tonkatsu_box/releases/latest) or [**RuStore**](https://www.rustore.ru/catalog/app/com.hacan359.tonkatsubox) |

> Linux and macOS support is experimental. The macOS build has not been tested by the maintainers yet, so expect rough edges. It is also unsigned, so macOS will warn about an unidentified developer on first launch.
> The Linux build ships as a plain bundle: unpack it anywhere and run `tonkatsu_box`. It needs GTK 3 and SQLite from your distribution, plus `zenity` for the file dialogs and `xdg-utils` for opening links.

> macOS support is experimental. That build has not been tested by the maintainers yet, so expect rough edges. It is also unsigned, so macOS will warn about an unidentified developer on first launch.

> On Android you have three options: grab the APK from Releases, install from [RuStore](https://www.rustore.ru/catalog/app/com.hacan359.tonkatsubox), or set up Obtainium for auto-updates (below). RuStore handles updates for you through its own store.

Expand Down
26 changes: 13 additions & 13 deletions assets/whats_new.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
# 0.43.0
# 0.44.0

**Audio joins the library.** Music albums from MusicBrainz and podcasts from Podcast Index live in one new tab, with
cover art, new releases and trending rows, and listened marks per track and per episode — albums get the edition
picker and its track list, podcasts a dated episode checklist.
**だってばよ 0.44**

The rest of the release, in short:
**The personalization hub got a rebuild.** It opens on cards for Statistics, Recommendations, Showcase and Likes,
each with a live preview. Showcase is new: release boards for anime, films, episodes, games and albums.

- A new "Ignored" status for titles you keep but deliberately park.
- Five collection banner styles — Classic, Comic, Sticker album, Brutalist and Strips — each showing the collection's
status breakdown and carrying its title.
- The status filter takes several statuses at once.
- Tag dialogs unified: search, quick-create, in-place editing and a remembered sort order.
- On wide screens tags moved to a chip bar above the grid, with per-collection counts.
- Faster sorting on large collections, instant covers from the cache, less animation work on phones.
- Self-hosted web build: ScreenScraper works, and collection background images can be picked in the browser.
- Search the library by details: genre, studio, author, tag, label or year. Chips on an item card run that search.
- Likes, notes and replays: everything you hearted, noted or went through twice, grouped by title.
- "Started and finished this day" on the item card fills both dates and marks the title Completed.
- Anime search by studio on the AniList tab.
- Text size slider in Settings.
- A collection name on the All items screen opens that collection.
- Search opens empty; poster feeds moved to the showcase.
- Search inside a collection matches album artists and book authors.
- IGDB search finds titles made of common words, such as "Until Then".

A friendly reminder: our cozy [Discord](https://discord.gg/JZVNPF7cS2) server is where you can directly influence
which features get built. And if you use the app but have not starred it on
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ Each feature is a self-contained folder with three subdirectories:

A feature may add folders when it needs them: `statistics` also has `models/`, plus `views/` and `layout/` for its per-form-factor split. Where desktop and mobile lay a page out differently enough that one widget tree with branches inside would be harder to read than two, each form factor gets its own file (`statistics_view_desktop.dart` / `statistics_view_mobile.dart`, `stats_hero_desktop.dart` / `stats_hero_mobile.dart`), the parts that do not differ stay in a shared `*_common.dart`, and the numbers each layout feeds its sections live in an immutable spec (`StatsLayout`) with one `const` per form factor, published to the sections through an `InheritedWidget`. The page picks the file by measured content width via `LayoutBuilder`, not `MediaQuery` — the nav shell makes the window width overstate the room the content gets.

Current features: `collections` (main module — collection screens, ItemDetail, canvas, panels), `search` (universal search via `SearchSource` over 7 backends), `tier_lists` (Tier list + Mood Grid), `wishlist`, `home` (All Items), `personalization` (hub over `statistics`, `genre_cloud`, `recommendations`), `statistics` ("my library in numbers": SQL aggregates via `StatsDao`, share card), `settings` (19 screens: credentials, imports, debug), `welcome` (6-step onboarding), `splash`.
Current features: `collections` (main module — collection screens, ItemDetail, canvas, panels), `search` (universal search via `SearchSource` over 7 backends), `tier_lists` (Tier list + Mood Grid), `wishlist`, `home` (All Items), `personalization` (hub over `statistics`, `genre_cloud`, `recommendations`, `showcase`, `likes`), `statistics` ("my library in numbers": SQL aggregates via `StatsDao`, share card), `settings` (19 screens: credentials, imports, debug), `welcome` (6-step onboarding), `splash`.

### `shared/`

Expand Down
126 changes: 117 additions & 9 deletions docs/RCOLL_FORMAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,20 +42,41 @@ Tonkatsu Box supports two file formats for sharing collections.
"media_type": "movie",
"external_id": 550
},
{
"media_type": "tv_show",
"external_id": 42987,
"source": "tvmaze"
},
{
"media_type": "animation",
"external_id": 246,
"platform_id": 1
},
{
"media_type": "visual_novel",
"external_id": 17
},
{
"media_type": "tv_show",
"external_id": 42987,
"source": "tvmaze"
"media_type": "manga",
"external_id": 30002,
"source": "anilist"
},
{
"media_type": "anime",
"external_id": 1535,
"source": "anilist"
},
{
"media_type": "book",
"external_id": 8193465,
"source": "openLibrary",
"native_id": "OL8193465W"
},
{
"media_type": "audio",
"external_id": 6820149371025,
"source": "musicBrainz",
"native_id": "b1392450-e666-3926-a536-22c65589de3d"
}
]
}
Expand Down Expand Up @@ -167,10 +188,10 @@ Includes everything from light export plus `canvas`, `images`, and `media`:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| media_type | string | yes | `"game"`, `"movie"`, `"tv_show"`, `"animation"`, `"visual_novel"`, `"manga"`, `"anime"`, `"book"`, or `"custom"` |
| external_id | number | yes | IGDB ID (games), TMDB ID (movies/TV), VNDB numeric ID (visual novels), or provider ID (manga / anime: AniList, MangaBaka, MangaDex, Kitsu) |
| source | string | no | Provider discriminator for multi-source media (manga, anime, book, tv_show): identity is `(external_id, source)`. Absent/`null` for single-source media and legacy files; defaults per type: manga/anime `"anilist"`, books `"openLibrary"`, TV shows `"tmdb"`, audio `"musicBrainz"` |
| native_id | string | no | The provider's own id, when `external_id` can't reproduce it: books (`"OL8193465W"`, `"4050-86463"`) and MangaDex manga (its UUID), whose `external_id` is a hash. A light import needs it to refetch the item; files written before it exist leave those items unresolved |
| media_type | string | yes | `"game"`, `"movie"`, `"tv_show"`, `"animation"`, `"visual_novel"`, `"manga"`, `"anime"`, `"book"`, `"audio"`, or `"custom"` |
| external_id | number | yes | The catalogue id, a number by contract: IGDB (games), TMDB (movies, TV, animation), VNDB (visual novels), AniList / MangaBaka / MangaDex / Kitsu (manga, anime), the five book providers (OpenLibrary, Fantlab, Google Books, ComicVine, Hardcover), MusicBrainz or Podcast Index (audio). A catalogue that keys by a string does not fit this field: OpenLibrary keeps the digits of `OL8193465W`, while Google Books, MangaDex and MusicBrainz store an fnv hash of the id. Neither reverses, so those items refetch by `native_id` |
| source | string | no | Provider discriminator for multi-source media (manga, anime, book, tv_show, audio): identity is `(external_id, source)`. Absent/`null` for single-source media and legacy files; defaults per type: manga/anime `"anilist"`, books `"openLibrary"`, TV shows `"tmdb"`, audio `"musicBrainz"` |
| native_id | string | no | The provider's own id, when `external_id` can't reproduce it: books (`"OL8193465W"`, `"4050-86463"`), MangaDex manga (its UUID) and MusicBrainz albums (the release-group MBID). Podcasts need none, Podcast Index keying a feed by a number. A light import needs it to refetch the item; files written before it exist leave those items unresolved |
| platform_id | number | no | IGDB platform ID (games) or AnimationSource (animation: 0=movie, 1=tvShow) |
| comment | string | no | Author's comment |
| user_rating | number | no | User rating (1.0–10.0, one decimal). Integers from v2 files load as doubles |
Expand All @@ -181,6 +202,14 @@ Includes everything from light export plus `canvas`, `images`, and `media`:
| _watched_episodes | array | no | Watched-episode marks of a TV/animation item (full + `user_data` only). Each entry: `{season, episode, watched_at}` with `watched_at` in Unix seconds or `null`. Re-scoped to the target collection on import; conflict-ignoring, so re-import merges. Absent in older files |
| _listened_tracks | array | no | Listened-track marks of an audio item (full + `user_data` only). Each entry: `{disc, track, listened_at}` with `listened_at` in Unix seconds or `null`; podcast episodes store `disc = 0` and the Podcast Index episode id as `track`. Re-scoped to the target collection on import; conflict-ignoring, so re-import merges. Absent in older files |

The app writes `platform_id`, `source`, `comment` and `user_rating` on every
item whether they hold anything or not, so a real file carries explicit
`null`s. A reader has to treat a missing key and a `null` the same way.

`user_data` is not the full export's privilege: a light export made with it
carries the fields below too, and the reader restores them from either
variant.

**User data fields** (present only when top-level `user_data` is `true`):

| Field | Type | Description |
Expand All @@ -197,6 +226,85 @@ Includes everything from light export plus `canvas`, `images`, and `media`:
| last_activity_at | number | Unix timestamp (seconds) of last activity |
| rewatch_count | number | Rewatch counter (MAL/AniList semantics: `0` = completed once, `N` = repeats). Absent/`null` = not tracked; never overwrites a locally tracked value on re-import |

### Source Values

Which catalogues a `media_type` accepts in `source`. A writer outside the app
needs the keyless column: those APIs answer an id lookup with no registration,
so an exporter can fill the field for those types without asking its user for
credentials.

| media_type | Accepted `source` | Default | Keyless |
|------------|-------------------|---------|---------|
| game | `igdb` | `igdb` | no (Twitch OAuth) |
| movie | `tmdb`, `tvdb` | `tmdb` | no |
| tv_show | `tmdb`, `tvmaze`, `tvdb` | `tmdb` | `tvmaze` only |
| animation | same as movie / tv_show, picked by `platform_id` | `tmdb` | `tvmaze` only |
| visual_novel | `vndb` | `vndb` | yes |
| manga | `anilist`, `mangabaka`, `mangadex`, `kitsu` | `anilist` | yes |
| anime | `anilist`, `kitsu` | `anilist` | yes |
| book | `openLibrary`, `fantlab`, `googleBooks`, `comicVine`, `hardcover` | `openLibrary` | `openLibrary`, `fantlab` |
| audio | `musicBrainz`, `podcastIndex` | `musicBrainz` | `musicBrainz` only |
| custom | none, light import skips these items | | |

Spelling is the enum name, case included: `openLibrary`, not `openlibrary`. An
unknown value falls back to the type's default rather than failing the import,
so a typo silently resolves the id against the wrong catalogue.

> [!IMPORTANT]
> `anime` is Japanese anime on AniList or Kitsu. `animation` is a TMDB cartoon
> (Pixar, Disney) and carries `platform_id` `0` for a film or `1` for a series.
> Filing Naruto under `animation` puts a TMDB show where the anime belongs, and
> its AniList metadata never arrives.

### Numeric Ids from String Keys

`external_id` is a number by contract, but half the catalogues key their
records by a string. Three conversions cover every provider, and only the
third one loses the original, which is what `native_id` exists for.

| Source | `external_id` | `native_id` | Example |
|--------|---------------|-------------|---------|
| `igdb`, `tmdb`, `tvdb`, `tvmaze`, `vndb`, `anilist`, `mangabaka`, `kitsu` | the catalogue's own number | absent | IGDB `1234` |
| `fantlab` | `work_id` as-is | the same number as a string | work `4050` → `4050` / `"4050"` |
| `comicVine` | the volume number | prefixed with the entity type | volume `86463` → `86463` / `"4050-86463"` |
| `podcastIndex` | the feed id | the feed's GUID, unused on import | feed `920666` → `920666` |
| `openLibrary` | first run of digits in the OLID | the whole OLID | `/works/OL27448W` → `27448` / `"OL27448W"` |
| `hardcover` | the book id when numeric, else `fnv1a64` | the id as a string | `42` → `42` / `"42"` |
| `googleBooks` | `fnv1a64(volumeId)` | the volume id | `"zyTCAlFPjgYC"` → `1287593495342004525` |
| `mangadex` | `fnv1a64(uuid)` | the UUID | `"a1b2c3d4-…"` → `58456258415466591` |
| `musicBrainz` | `fnv1a53(mbid)` | the release-group MBID | `"b1a9c0e4-…"` → `381424520416013` |

A hash does not reverse, so an item from the last three rows is unresolvable
without `native_id`: the import declines it rather than guessing (see the
`native_id` row above). Digits pulled out of an OLID do not reverse either,
since the `OL…W` shape is not reconstructible from `27448`.

Both hashes are FNV-1a over the id's UTF-16 code units, offset basis
`0xcbf29ce484222325`, prime `0x100000001b3`, multiply wrapping mod 2^64:

- `fnv1a64` masks the result to 63 bits (`& 0x7fffffffffffffff`) so it fits
SQLite's signed INTEGER.
- `fnv1a53` xor-folds that down to 53 bits (`(h ^ (h >>> 53)) & 0x1fffffffffffff`),
which a JS double holds exactly. Every id source added since uses this one.

Vectors to check an implementation against, including the three ids used as
examples above:

| Input | `fnv1a64` | `fnv1a53` |
|-------|-----------|-----------|
| `""` | `5472609002491880229` | `5239054864097658` |
| `"OL123"` | `7112701132336913138` | `6020920346270183` |
| `"Тонкацу"` | `5074763067217480705` | `3709886798302770` |
| `"zyTCAlFPjgYC"` | `1287593495342004525` | `8571201168783779` |
| `"a1b2c3d4-e5f6-7890-abcd-ef1234567890"` | `58456258415466591` | `4413062887020633` |
| `"b1a9c0e4-1f0e-4c6b-8e2a-77e5b3b9f2f1"` | `6350456899112815052` | `381424520416013` |

> [!WARNING]
> An `fnv1a64` value exceeds 2^53, so a JSON reader that parses numbers as
> doubles (any browser, `JSON.parse`) rounds it and the id stops matching.
> Read those files with a 64-bit integer parser, or the Google Books and
> MangaDex items land under an id nothing resolves.

### Item Marks

Each element of an item's `_marks` array is one like and/or note on a single
Expand Down Expand Up @@ -315,13 +423,13 @@ When `media` is absent (light export or older full exports), the app refetches e

## How Import Works

### v2 Light (`.xcoll`)
### Light (`.xcoll`)

1. App reads the file and creates a collection
2. Inserts items with their metadata (comments)
3. Fetches full game/movie/TV/VN/manga data from IGDB/TMDB/VNDB/AniList using IDs

### v2 Full (`.xcollx`)
### Full (`.xcollx`)

1. If `media` section is present — restores Game/Movie/TvShow/VisualNovel/Manga/TvSeason/TvEpisode data from embedded data (offline)
2. If `media` section is absent — fetches data from IGDB/TMDB/VNDB/AniList APIs (online, same as light import)
Expand Down
Loading
Loading