Skip to content
Draft
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: 2 additions & 0 deletions app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,8 @@ <h3 id="credits-title">Credits and data sources</h3>
<script src="../shared/series-metadata.js"></script>
<script src="../shared/search-ranking.js"></script>
<script src="../shared/live-tv.js"></script>
<script src="../shared/anime-metadata.js"></script>
<script src="../shared/anime-catalog.js"></script>
<script src="../shared/watch-browse.js"></script>
<script src="../shared/playback-providers.js"></script>
<script src="../shared/user-state.js"></script>
Expand Down
48 changes: 37 additions & 11 deletions app/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -408,14 +408,16 @@ const TMDB_BASE = 'https://api.themoviedb.org/3';
const seriesMetadataApi = window.HarborSeriesMetadata;
const allMovieFilter = { id: 'all-movies', label: 'All movies', source: 'provider-catalog', mediaType: 'movie' };
const sharedWatchBrowseApi = window.HarborWatchBrowse;
const animeCatalogApi = window.HarborAnimeCatalog;
const watchBrowseApi = {
...sharedWatchBrowseApi,
getFilters: section => section === 'Movies'
? [allMovieFilter, ...sharedWatchBrowseApi.getFilters(section)]
: sharedWatchBrowseApi.getFilters(section),
: section === 'Anime' && !TMDB_API_KEY ? animeCatalogApi.filters() : sharedWatchBrowseApi.getFilters(section),
getFilter: (section, id) => section === 'Movies' && (!id || id === 'all-movies')
? allMovieFilter : sharedWatchBrowseApi.getFilter(section, id),
defaultFilterId: section => section === 'Movies' ? 'all-movies' : sharedWatchBrowseApi.defaultFilterId(section)
? allMovieFilter : section === 'Anime' && !TMDB_API_KEY
? animeCatalogApi.filters().find(filter => filter.id === id) || animeCatalogApi.filters()[0] : sharedWatchBrowseApi.getFilter(section, id),
defaultFilterId: section => section === 'Movies' ? 'all-movies' : section === 'Anime' && !TMDB_API_KEY ? 'library' : sharedWatchBrowseApi.defaultFilterId(section)
};

// Massive Comprehensive Master Media Database (120+ Titles across Watch, Read, Listen, Play)
Expand All @@ -441,7 +443,9 @@ streamServerSelect.replaceChildren(...playbackProvidersApi.providers.map((provid
return option;
}));

const WATCH_CATALOG = EXPANDED_MASTER_CATALOG.filter((item) => item.category === 'Watch');
const BUILTIN_WATCH_CATALOG = EXPANDED_MASTER_CATALOG.filter((item) => item.category === 'Watch');
const NO_KEY_ANIME_CATALOG = !TMDB_API_KEY ? animeCatalogApi.items(BUILTIN_WATCH_CATALOG, 'desktop') : [];
const WATCH_CATALOG = BUILTIN_WATCH_CATALOG;
let directoryLinkCatalog = [];
let activeCategory = 'Home';
let activeSubcategory = 'All';
Expand Down Expand Up @@ -491,7 +495,7 @@ let liveStreamIndex = 0;
let liveStreamRecoveryAttempts = 0;
let liveGuideGeneration = 0;
const catalogTotals = {
Watch: { All: null, Movies: null, 'TV Shows': null, Anime: null, Sports: null, 'Live TV': 'Live' },
Watch: { All: null, Movies: null, 'TV Shows': null, Anime: !TMDB_API_KEY ? NO_KEY_ANIME_CATALOG.length : null, Sports: null, 'Live TV': 'Live' },
Listen: { All: 0, Music: 0 },
Read: { All: 0, Comics: 0, Manga: 0, 'eBooks & Audiobooks': 0 },
Play: { All: 0, Games: 0 }
Expand Down Expand Up @@ -789,6 +793,9 @@ const isCurrentSearchRequest = (term, sequence, scope) => (
);

const localSearchResults = (term, scope = getSearchScope()) => {
if (scope.category === 'Watch' && scope.subcategory === 'Anime' && !TMDB_API_KEY) {
return animeCatalogApi.page(NO_KEY_ANIME_CATALOG.filter(item => itemMatchesSearchScope(item, scope)), scope.watchFilter, 1, term).items;
}
if (scope.category === 'Watch' && scope.subcategory === 'TV Shows' && !TMDB_API_KEY) {
return rankSearchResults([...loadedProviderShows.values(), ...WATCH_CATALOG]
.filter(item => itemMatchesSearchScope(item, scope)), term);
Expand Down Expand Up @@ -970,7 +977,12 @@ const loadActiveWatchBrowse = async ({ append = false, requestedPage = 1 } = {})
try {
let items = [];
let canLoadMore = false;
if (filter.source === 'iptv' || filter.source === 'free-events') {
if (filter.source === 'anime-metadata') {
const result = animeCatalogApi.page(NO_KEY_ANIME_CATALOG, filter.id, page, query.trim());
items = result.items;
canLoadMore = result.canLoadMore;
catalogTotals.Watch.Anime = NO_KEY_ANIME_CATALOG.length;
} else if (filter.source === 'iptv' || filter.source === 'free-events') {
const directory = await watchBrowseApi.loadLiveWindowDirectory(activeSubcategory, activeWatchFilter, {
limit: 240,
country: activeLiveCountry,
Expand Down Expand Up @@ -1012,7 +1024,7 @@ const loadActiveWatchBrowse = async ({ append = false, requestedPage = 1 } = {})
? [...watchBrowseItems, ...items.filter((item) => !watchBrowseItems.some((existing) => watchBrowseItemKey(existing) === watchBrowseItemKey(item)))]
: items;
watchBrowsePage = page;
watchBrowseCanLoadMore = (filter.source === 'tmdb' || filter.source === 'provider-shows' || activeSubcategory === 'Movies') && canLoadMore;
watchBrowseCanLoadMore = (filter.source === 'tmdb' || filter.source === 'provider-shows' || filter.source === 'anime-metadata' || activeSubcategory === 'Movies') && canLoadMore;
watchBrowseLoading = false;
watchBrowseLoaded = true;
if ((filter.source === 'iptv' || filter.source === 'free-events') && query.trim()) {
Expand Down Expand Up @@ -1219,7 +1231,7 @@ const applyCatalogTotalValues = (values) => {

setNumber('Watch', 'Movies', 'movies');
setNumber('Watch', 'TV Shows', 'shows');
setNumber('Watch', 'Anime', 'anime');
if (TMDB_API_KEY) setNumber('Watch', 'Anime', 'anime');
setNumber('Watch', 'Sports', 'sports');
if (typeof catalogTotals.Watch.Movies === 'number' && typeof catalogTotals.Watch['TV Shows'] === 'number') {
catalogTotals.Watch.All = catalogTotals.Watch.Movies + catalogTotals.Watch['TV Shows'];
Expand All @@ -1236,6 +1248,7 @@ const loadCachedCatalogTotals = () => {
if (section !== 'Watch') return;
if (!catalogTotals[section] || !values) return;
Object.entries(values).forEach(([subcategory, value]) => {
if (!TMDB_API_KEY && subcategory === 'Anime') return;
if (value !== null && catalogTotals[section][subcategory] !== undefined) {
catalogTotals[section][subcategory] = value;
}
Expand Down Expand Up @@ -1333,6 +1346,13 @@ const applySearchBatch = (term, sequence, scope, localResults, batches, totals,
const searchGlobalMedia = async (term, sequence, scope, page = 1, append = false) => {
const normalizedTerm = normalizeSearchText(term);
if (normalizedTerm.length < 2 || !isCurrentSearchRequest(term, sequence, scope)) return;
if (scope.category === 'Watch' && scope.subcategory === 'Anime' && !TMDB_API_KEY) {
const result = animeCatalogApi.page(NO_KEY_ANIME_CATALOG, scope.watchFilter, page, term);
currentMediaList = append ? [...currentMediaList, ...result.items.filter(item => !currentMediaList.some(existing => animeCatalogApi.identity(existing) === animeCatalogApi.identity(item)))] : result.items;
searchState = { term: normalizedTerm, loading: false, total: result.total, page, canLoadMore: result.canLoadMore, partial: false };
renderResources();
return;
}
if (scope.category === 'Watch' && ['Movies', 'TV Shows', 'Anime'].includes(scope.subcategory) && !TMDB_API_KEY) {
currentMediaList = localSearchResults(term, scope);
searchState = { term: normalizedTerm, loading: false, total: currentMediaList.length, page: 1, canLoadMore: false, partial: false };
Expand Down Expand Up @@ -2203,6 +2223,12 @@ const loadLiveGuide = async (item) => {
detailGuideWeekEmpty.hidden = groups.laterThisWeek.length > 0;
};

const watchMetadataText = (item, sections = item.sections || []) => [
item.year || '',
Number(item.rating) > 0 ? '★ ' + item.rating : '',
...sections
].filter(Boolean).join(' · ');

// Media Detail Dialog (Episodes, live guide, and sources)
const openDetailDialog = async (item) => {
activeMedia = item;
Expand All @@ -2218,7 +2244,7 @@ const openDetailDialog = async (item) => {
detailTitle.textContent = item.name;
detailSubtitle.textContent = isLive
? [item.year || 'Live', item.countryFlag, item.countryName, ...(item.languageNames || []), item.streamCandidates?.[0]?.quality].filter(Boolean).join(' · ')
: item.year + ' · ★ ' + item.rating + ' · ' + (item.sections || []).join(', ');
: watchMetadataText(item);
detailOverview.textContent = item.overview;
syncSaveButton(detailSaveBtn, item);
detailPlayBtn.hidden = isSeries;
Expand Down Expand Up @@ -2382,7 +2408,7 @@ const buildCard = (item) => {
? (item.domain || 'Website') + ' · ' + (item.sections || []).slice(0, 2).join(' · ')
: (item.type === 'live'
? 'Live · ' + (item.sections || []).slice(0, 2).join(' · ')
: item.year + ' · ★ ' + item.rating + ' · ' + (item.sections || []).slice(0, 2).join(' · '))
: watchMetadataText(item, (item.sections || []).slice(0, 2)))
)
);
openButton.append(art, copy);
Expand Down Expand Up @@ -2808,7 +2834,7 @@ const renderResources = () => {
? (heroMedia.domain || 'Website') + ' · YarrList'
: (heroMedia.type === 'live'
? 'Live · ' + (heroMedia.sections || []).slice(0, 2).join(' · ')
: heroMedia.year + ' · ' + (heroMedia.sections || []).slice(0, 2).join(' · '));
: [heroMedia.year, ...(heroMedia.sections || []).slice(0, 2)].filter(Boolean).join(' · '));
heroFeature.setAttribute('aria-label', 'Open featured title ' + heroMedia.name);
}
syncSaveButton(heroLibraryButton, heroMedia);
Expand Down
36 changes: 36 additions & 0 deletions docs/anime-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Offline anime metadata catalog

Without a TMDB key, Anime opens a **Library** of 4,132 titles: 3,131 series and 1,001 films. **Movies** selects the films. Pages contain 40 titles sorted by name; this is not labelled popularity ranking. English scoped library search folds accents, so Pokemon matches Pokémon. Japanese labels/titles remain provenance and are not advertised as native-script desktop search support. Configured TMDB browsing retains its existing filters. New titles are marked **Metadata only** and playback availability is unverified. No provider listing is inferred from an ID/classification or preference.

## Source and eligibility

[Wikidata's licensing declaration](https://www.wikidata.org/wiki/Wikidata:Licensing) releases structured entity/property data under CC0. This records its declaration, not an independent legal guarantee. The bounded [query](../research/anime-metadata/query.sparql) selects direct P31 anime TV Q63952888 with P4983, and anime film Q20650540 with P4947. No prose, posters or other images are imported. English/Japanese labels retain their language; a Japanese label is not a work's original language or audio. Raw language IDs and publication/start dates remain provenance, not invented year/genre/rating/episode fields.

The 2026-10-05 response has 4,173 mappings for 4,172 entities; conservative normalization retains 4,145 candidates after missing-English and ambiguous-identity exclusions. Response/query hashes are embedded in the asset. Truthy wdt statements omit qualifiers, references and per-entity revisions; the response hash identifies the captured query result, not an immutable entity revision.

The [official TMDB daily exports](https://developer.themoviedb.org/docs/daily-id-exports) were publicly accessible without authentication. All joined exports are dated **2026-10-04**. Movies require explicit adult=false AND video=false; missing/null flags or absent IDs are excluded. Ordinary TV has 232,988 IDs and adult TV has 2,316, with zero overlap in this capture. The inferred valid TV universe is their union; eligibility requires ordinary membership and absence from the adult set. The documentation identifies the adult dataset, without promising perfect completeness: this is a dated producer-partition inference, not a recovered raw flag or child-safety claim. Adult wins overlap. Missing sets, mixed dates, malformed/truncated data and out-of-universe IDs remain unknown/excluded. No TV video=false is inferred.

Gzip streams are decoded through EOF/CRC validation, checked for positive unique IDs and TV name/popularity schema, and hashed. The manual generator binds exact reviewed bytes to their dated URLs. Source normalization plus eligibility exclusions produce **4,132 rows**: nine movie and four TV candidates fail eligibility. Each retained row carries its entity, explicit class, source URL/date and eligibility origin.

Reviewed SHA-256 values:

- Wikidata response: 9d6e44d5980d19685acabc9f5c541fd272b05848f6289ada73cd5ffee0ba8456 (UTF-8 BOM included).
- Movie export: d6f857c72cb67a45068b0a4f2d3c8159593eb75ad1fa474be433aa8d6c275e9c.
- Ordinary TV: b5f2435e229f7b920bc331ed3734dc6e72b6c084d9561502a267a59dfb829598.
- Adult TV: 2ca3a5b923772d394794aa37a8e01aea13b064e70e95b1ffaabc526a17857a13.

## Identity, freshness and regeneration

Seventeen included movie roots overlap mapped TV numeric IDs; types remain separate. Existing desktop anime IDs w-19 through w-24 and w-bleach are reused. TV retains anime-1429 and anime-30984. New desktop records follow existing typed vidsrc-movie-/vidsrc-tv- formatter IDs, without availability claims. TV uses movie-/anime-. Saved IDs, favorites, progress and profile schema are not migrated. Existing snapshot fields preserve empty year/rating and metadata-only sections/overview.

This manually refreshed snapshot is incomplete and community mappings can be wrong. OVA/ONA-only works, indirect classes, unmapped/missing-English works and ambiguity groups are excluded. Explicit anime classes do not classify every animation or settle every cultural definition. No runtime dataset request, startup network fetch, background refresh or account action is added.

Run the offline compiler with the captured query response and reviewed exports:

```text
node scripts/compile-anime-metadata.cjs <wikidata-response.json> <movie.gz> <ordinary-tv.gz> <adult-tv.gz> shared/anime-metadata.js
```

Changed source dates/hashes require manifest review. Compressed/expanded bytes and line lengths are bounded. JSON/script characters are escaped. The inert UMD asset loads before helpers; all TV staging targets receive it, and LG uses literal callback substitution to preserve dollar sequences. Raw/intermediate files remain ignored.

Source/catalog fixtures cover explicit classes, adult/video exclusions, TV overlap/mixed-date/missing-set/out-of-universe gates, missing fields, typed collisions, canonical IDs, paging/deduplication, aliases and known new titles. UI smoke covers keyless filters, pagination, search, honest metadata and film controls. Packaged/physical-device verification belongs to the release owner.
2 changes: 2 additions & 0 deletions research/anime-metadata/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
snapshot*.json
*.gz
31 changes: 31 additions & 0 deletions research/anime-metadata/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Review-only no-key anime metadata proof

This folder preserves the initial source proof and offline generator normalization. Runtime imports only the compiled shared asset. The raw and intermediate JSON files are ignored; production behavior and eligibility are described in `docs/anime-catalog.md`.

## Source declaration and primary documentation

Wikidata's [licensing policy](https://www.wikidata.org/wiki/Wikidata:Licensing) states that structured data in its main, Property, Lexeme and EntitySchema namespaces is released under CC0. This records the publisher's public declaration; it is not an independent legal guarantee. Do not copy policy prose, Wikipedia articles, images, posters, or Commons media into the dataset.

The query uses Wikidata's [documented public SPARQL endpoint](https://www.wikidata.org/wiki/Wikidata:Data_access#Wikidata_Query_Service), with no account or API key. The [RDF format documentation](https://www.mediawiki.org/wiki/Wikibase/Indexing/RDF_Dump_Format#Truthy_statements) explains the `wdt:` truthy/best-rank projection; it excludes deprecated statements but omits statement qualifiers/references. This proof does not preserve per-entity revisions and must not claim an immutable source revision. A production generator should record retrieval UTC timestamp, exact query, response hash, schema version and source declarations.

Explicit classification comes from direct `P31` [anime television series Q63952888](https://www.wikidata.org/wiki/Q63952888) or [anime film Q20650540](https://www.wikidata.org/wiki/Q20650540). Use [TMDB TV series ID P4983](https://www.wikidata.org/wiki/Property:P4983) only for TV, and [TMDB movie ID P4947](https://www.wikidata.org/wiki/Property:P4947) only for movies. `type + '/' + tmdbId` is the identity key. The property documents warn completeness is always incomplete.

English and Japanese labels retain their languages. A Japanese label is not necessarily the original title. [P1476](https://www.wikidata.org/wiki/Property:P1476) is a language-tagged title; this proof retrieves its Japanese values. [P364](https://www.wikidata.org/wiki/Property:P364) is original language of film or TV show: Q5287 means Japanese. Missing language stays missing. Publication/start timestamps P577/P580 stay raw and may include regional releases, different precision and multiple values. No invented `original_language`, year, rating, overview, artwork, season count or episode count is produced.

## Retrieval and measured scope

Retrieved 2026-10-05 using a single capped 5,000-row full query after one 300-row proof and one aggregate count. Response contained 4,173 rows, 4,172 entities: 3,161 TV mappings and 1,012 movie mappings. Thirteen rows lack English labels; 49 lack P364; 245 lack publication/start dates. The raw response SHA-256 is `9d6e44d5980d19685acabc9f5c541fd272b05848f6289ada73cd5ffee0ba8456` (UTF-8 BOM included).

The conservative prototype accepts 4,145 rows: 3,135 TV and 1,010 movies. It excludes the 13 missing-English rows and quarantines 14 rows in six ambiguous same-type TMDB groups: TV IDs 13549, 20451, 29540, 37508, 39379 and 74191. Some source entities are series subdivisions mapped to the same TMDB root; choosing a title arbitrarily would change identity. Tenchi in Tokyo Q5227700 has both TV/20451 and TV/20485: both are quarantined, accounting for one further rejected row beyond the six collision groups.

Known new examples include Death Note TV/13916 Q718624, Cowboy Bebop TV/30991 Q101244908, Frieren TV/209867 Q115792176, SSSS.Dynazenon TV/96451 Q100050258, Spirited Away movie/129 Q155653, Princess Mononoke movie/128 Q186572, My Neighbor Totoro movie/8392 Q39571 and Made in Abyss: Dawn of the Deep Soul movie/573730 Q100156151. Their English and Japanese labels are in the retrieved response. Spirited Away demonstrates a localized English label; it does not solve aliases for all movie titles.

All seven desktop built-in anime TMDB mappings appear in the accepted set: w-19, w-20, w-21, w-22, w-23, w-24 and w-bleach. Production should index existing built-ins by typed TMDB identity and reuse those IDs and metadata when matched. TV's two built-ins can likewise retain anime-1429 and anime-30984. Do not migrate stored IDs. New metadata identity should follow existing dynamic TMDB conventions after runtime ownership review.

## Limits and proposal

This is explicit Japanese anime classification, not all worldwide animation or all culturally contested anime. OVA/ONA-only, indirect subclass classifications, unmapped works and missing-English works are outside the proof. It is a community-maintained source and can contain classification/mapping errors. No live playback availability was checked or implied; an identifier is metadata, not a licensed stream or provider-listing guarantee.

The resulting implementation uses a separate offline generator, a shared inert metadata snapshot and desktop/TV adapters. No runtime SPARQL, account changes or new dependencies are required. Original classification provenance is retained rather than inventing Japanese-language fields to satisfy TMDB-specific anime predicates. Search can use English/Japanese labels and actual title values; comprehensive aliases need a separate bounded source query and review.

Run `node research/anime-metadata/normalize.test.cjs` for isolated fixtures. They verify movie/TV ID separation, ordinary animation and live-action exclusion, missing field preservation and ambiguous mapping quarantine. Production tests also cover export eligibility and catalog behavior.
Loading
Loading