Titles v2
### For Users
- **Smarter title display** — each series now picks a primary title per language automatically, based on the best available source. No more guessing which title is "the" title.
- **Stacked language flags** — when the same title text applies to multiple languages, flags stack together instead of showing duplicate rows.
- **Language priority** — you can reorder which languages you prefer for title display. Default order: English, Romanized Japanese, Japanese, Romanized Korean.
- **See all titles** — series pages now have a "show all titles" toggle to reveal every known title across all languages.
### For Moderators
- **Title review work zone** — a new `/mod/series/titles` page lets you browse all titles needing attention, filter by issue type (conflicts, low-confidence detections), media type, and language. Each title links to a focused review page with cover art for context.
- **Keyboard-driven review** — resolve titles without touching the mouse. Default shortcuts: `A` approve, `X` reject, `S` skip, `C` change language, `R` set romanized. All shortcuts are customizable and saved to local storage.
- **Conflict resolution** — when multiple metadata sources disagree on a title's text or language, the review page shows all conflicting values with one-click "Use this" buttons. Resolved conflicts auto-reopen if sources change later.
- **Language detection suggestions** — titles with unknown language are automatically analyzed. High-confidence detections are applied instantly; low-confidence ones appear as suggestions you can approve or dismiss.
- **Reworked title editor** — the series edit form now shows source attributions, primary/hidden/manual status, conflict warnings, and detection suggestions inline. Add, edit, hide, unhide, set primary, and delete titles without leaving the page.
- **Submission diff summaries** — title change submissions now show a semantic diff (added/removed/changed) so you can see exactly what a user is proposing before approving.
### For API Users
- **New `titles` field** on series responses — an array of title objects with this shape:
```json
[
{
"language": "en",
"traits": ["official"],
"title": "Re:Zero -Starting Life in Another World-",
"note": null,
"is_primary": true
},
{
"language": "ja",
"traits": ["native", "official"],
"title": "\u308a\u305c\u308d\u304b\u3089\u59cb\u3081\u308b\u7570\u4e16\u754c\u751f\u6d3b",
"note": null,
"is_primary": true
},
{
"language": "ja-Latn",
"traits": ["official"],
"title": "Re:Zero kara Hajimeru Isekai Seikatsu",
"note": null,
"is_primary": false
}
]
```
- **Deprecation notice** — the old title fields (`title`, `native_title`, `romanized_title`, `secondary_titles`) are deprecated. They still work exactly as before and their behavior is unchanged — but they are now **frozen**. Moderators can no longer edit data through the v1 system; all title management happens through v2. The v1 and v2 fields are independent; changes to v2 titles do not affect v1 fields and vice versa.
Removal of the deprecated fields will happen May 1st - in the meantime, please start migrating to `titles`.
- **Key differences from v1:**
- `titles` is a flat array of title objects — not keyed by locale like `secondary_titles` was. Every entry has a non-empty `title` string; there are no `null` or empty placeholders. If a language has no title, it simply isn't in the array.
- `traits` is an array (e.g. `["native", "official"]`), not a single type enum
- Romanized languages use script codes: `ja-Latn`, `ko-Latn`, `zh-Latn` (replaces the old `-ro` suffix convention)
- `is_primary` flag indicates the preferred title for each language, this does not make then "official" or "native"
- **Title length limit** increased from 500 to 1,000 characters.
#### Language codes
The `language` field uses [BCP 47](https://www.rfc-editor.org/info/bcp47) language tags. Most codes are plain ISO 639-1 two-letter codes (`en`, `ja`, `ko`, `zh`, `fr`, `de`, ...).
We extend the standard in a few ways:
- **Romanized scripts** use the `-Latn` script subtag per BCP 47: `ja-Latn` (romaji), `ko-Latn` (romanized Korean), `zh-Latn` (pinyin). This replaces the old `-ro` suffix (`ja-ro`, `ko-ro`) from v1.
- **Regional variants** use a lowercased region subtag: `es-la` (Latin American Spanish), `pt-br` (Brazilian Portuguese), `zh-hk` (Hong Kong Chinese).
- **`unknown`** is a special value for titles whose language hasn't been identified yet.
Common codes you'll encounter:
| Code | Language |
| --------- | --------------------------- |
| `en` | English |
| `ja` | Japanese |
| `ja-Latn` | Japanese Romanized (romaji) |
| `ko` | Korean |
| `ko-Latn` | Korean Romanized |
| `zh` | Chinese |
| `zh-Latn` | Chinese Romanized (pinyin) |
| `zh-hk` | Chinese (Hong Kong) |
| `es-la` | Spanish (Latin America) |
| `pt-br` | Portuguese (Brazil) |
#### `is_primary`
Each language can have at most one primary title. The `is_primary` flag marks the single best title for that language — it's what the site displays by default.
- There is exactly one `is_primary: true` title per language (enforced by the database).
- Primary titles are elected automatically from metadata sources, but moderators can override the choice manually.
- When picking which title to show, `is_primary` always wins over trait-based ranking. If you only need one title per language, filter by `is_primary: true` and you're done.
- A series will always have at least one primary title across all its languages.
#### Traits
Each title carries one or more traits describing its role:
| Trait | Meaning |
| ------------- | -------------------------------------------------------------------------------------------- |
| `official` | The officially licensed or publisher-sanctioned title for that language. |
| `native` | The title in the work's original language and script (e.g. Japanese kanji/kana for a manga). |
| `alternative` | Any other known title — abbreviations, alternate romanizations, cover texts, etc. |
A single title can have multiple traits. For a more detailed setup of traits, see: https://mangabaka.org/pages/1#title-settings-overview-table
#### Finding the title you want
The simplest approach: filter by `is_primary` and your preferred language.
```js
// Get the primary English title
const en = titles.find((t) => t.is_primary && t.language === 'en')
// Get the primary title in the work's native script
const native = titles.find((t) => t.is_primary && t.traits.includes('native'))
```
If you want to replicate the site's display logic, walk a priority list and pick the first match:
```js
const priority = ['en', 'ja-Latn', 'ja', 'ko-Latn']
function best_title(titles) {
// Group by language, pick the best in each group
const best_per_lang = new Map()
for (const lang of priority) {
const candidates = titles.filter((t) => t.language === lang)
if (candidates.length === 0) continue
// is_primary wins, then official > native > alternative
candidates.sort((a, b) => score(a) - score(b))
best_per_lang.set(lang, candidates[0])
}
// First language with a match becomes the primary
for (const lang of priority) {
if (best_per_lang.has(lang)) return best_per_lang.get(lang).title
}
// Fallback: any title
return titles[0]?.title ?? null
}
function score(t) {
if (t.is_primary) return 0
if (t.traits.includes('official')) return 1
if (t.traits.includes('native')) return 2
return 3
}
```
the full implementation can be found here
```ts
import type { MediaType, TitleLanguage_v2, TitleLanguagePriority } from '$lib/types/mangabaka'
import { VIRTUAL_LANGUAGE_CODES } from '$lib/types/mangabaka'
/** Input title shape — accepts both TitleInput and SeriesTitleFormEntry */
type TitleInput = {
language: TitleLanguage_v2
title: string
traits: string[]
is_primary?: boolean
note?: string | null
}
export type LanguageAttribution = {
language: TitleLanguage_v2
traits: string[]
}
export type ResolvedTitle = {
language: TitleLanguage_v2
title: string
traits: string[]
/** All languages whose best title shares this text (for stacked flag rendering) */
languages: LanguageAttribution[]
}
export type ResolvedTitles = {
primary: string
/** All languages whose best title matches the primary text */
primary_languages: LanguageAttribution[]
/** Titles from the user's configured language priority list */
secondary: ResolvedTitle[]
/** Titles from languages NOT in the user's priority list */
other: ResolvedTitle[]
}
/**
* Internal fallback for callers that don't pass a language_priority option.
* Used by mod/admin pages and model classes (e.g. resolve_titles(titles) with no options).
*
* Uses virtual codes which expand based on context:
* - With media_type: resolves to the single matching language (e.g. _native → ja for manga)
* - Without media_type: expands to ALL possible languages (ja, ko, zh, en for _native),
* letting the resolution algorithm pick whichever the series has titles for.
*
* Matches the user-facing default in ListConfiguration.prefault().
*/
const DEFAULT_LANGUAGE_PRIORITY: TitleLanguagePriority[] = ['en', '_romanized', '_native']
export const RESOLVE_TITLES_FALLBACK = 'unknown title (please report on Discord)'
const FALLBACK_TITLES: ResolvedTitles = {
primary: RESOLVE_TITLES_FALLBACK,
primary_languages: [],
secondary: [],
other: [],
}
const VIRTUAL_NATIVE_MAP: Record<MediaType, TitleLanguage_v2 | null> = {
manga: 'ja',
manhwa: 'ko',
manhua: 'zh',
novel: 'ja',
oel: 'en',
other: null,
}
const VIRTUAL_ROMANIZED_MAP: Record<MediaType, TitleLanguage_v2 | null> = {
manga: 'ja-Latn',
manhwa: 'ko-Latn',
manhua: 'zh-Latn',
novel: 'ja-Latn',
oel: null,
other: null,
}
// All unique non-null values from each virtual map, used as fallback when media_type is unknown
const VIRTUAL_NATIVE_ALL: TitleLanguage_v2[] = [
...new Set(Object.values(VIRTUAL_NATIVE_MAP).filter((v): v is TitleLanguage_v2 => v !== null)),
]
const VIRTUAL_ROMANIZED_ALL: TitleLanguage_v2[] = [
...new Set(Object.values(VIRTUAL_ROMANIZED_MAP).filter((v): v is TitleLanguage_v2 => v !== null)),
]
/**
* Expand virtual language codes (_native, _romanized) into real language codes
* based on the series media type. Deduplicates against codes already in the list.
*
* When media_type is provided, each virtual code resolves to the single matching language.
* When media_type is absent, each virtual code expands to ALL possible languages it could
* resolve to (ja, ko, zh, en for _native; ja-Latn, ko-Latn, zh-Latn for _romanized),
* letting the resolution algorithm pick whichever the series has titles for.
*/
function expand_virtual_languages(priority: TitleLanguagePriority[], media_type?: MediaType): TitleLanguage_v2[] {
const result: TitleLanguage_v2[] = []
const seen = new Set<TitleLanguage_v2>()
for (const code of priority) {
if (!VIRTUAL_LANGUAGE_CODES.has(code)) {
const real = code as TitleLanguage_v2
if (!seen.has(real)) {
seen.add(real)
result.push(real)
}
continue
}
if (media_type) {
const map = code === '_native' ? VIRTUAL_NATIVE_MAP : VIRTUAL_ROMANIZED_MAP
const resolved = map[media_type]
if (resolved && !seen.has(resolved)) {
seen.add(resolved)
result.push(resolved)
}
} else {
const all = code === '_native' ? VIRTUAL_NATIVE_ALL : VIRTUAL_ROMANIZED_ALL
for (const lang of all) {
if (!seen.has(lang)) {
seen.add(lang)
result.push(lang)
}
}
}
}
return result
}
/**
* Sort score for a title within a language group.
* Lower score = higher priority.
*/
function sort_score(t: TitleInput): number {
if (t.is_primary) return 0
if (t.traits.includes('official')) return 1
if (t.traits.includes('native')) return 2
if (t.traits.includes('alternative')) return 3
return 4
}
function best_in_group(titles: TitleInput[]): TitleInput | undefined {
if (titles.length === 0) return undefined
let best = titles[0]
let best_score = sort_score(best)
for (let i = 1; i < titles.length; i++) {
const score = sort_score(titles[i])
if (score < best_score) {
best = titles[i]
best_score = score
}
}
return best
}
/**
* Resolve titles from a v2 titles array into a primary + ordered secondary lists.
*
* Instead of deduplicating identical title text, groups languages that share the same
* best title into stacked language attributions (like stacked source favicons on ratings).
*
* Primary: the best title found by walking language_priority groups, then native-trait titles, then any title.
* primary_languages: all languages whose best title matches the primary text.
* Secondary: grouped titles from priority languages with different text than primary.
* Other: grouped titles from non-priority languages with different text than primary.
*/
export function resolve_titles(
titles: TitleInput[] | null | undefined,
options?: { language_priority?: TitleLanguagePriority[]; media_type?: MediaType },
): ResolvedTitles {
if (!titles || titles.length === 0) {
return FALLBACK_TITLES
}
const raw_priority = options?.language_priority ?? DEFAULT_LANGUAGE_PRIORITY
const priority = expand_virtual_languages(raw_priority, options?.media_type)
const priority_set = new Set(priority)
// Step 1: Find the best title per language
const titles_by_lang = new Map<TitleLanguage_v2, TitleInput[]>()
for (const t of titles) {
const group = titles_by_lang.get(t.language)
if (group) {
group.push(t)
} else {
titles_by_lang.set(t.language, [t])
}
}
const best_per_lang = new Map<TitleLanguage_v2, TitleInput>()
for (const [lang, group] of titles_by_lang) {
const best = best_in_group(group)
if (best) {
best_per_lang.set(lang, best)
}
}
// Step 2: Find primary title text by walking priority
let primary_title: TitleInput | undefined
for (const lang of priority) {
const best = best_per_lang.get(lang)
if (best) {
primary_title = best
break
}
}
// Fallback: titles with 'native' trait
if (!primary_title) {
const native_group = titles.filter((t) => t.traits.includes('native'))
primary_title = best_in_group(native_group)
}
// Fallback: any title
if (!primary_title) {
primary_title = best_in_group(titles)
}
if (!primary_title) {
return FALLBACK_TITLES
}
const primary_str = primary_title.title
// Step 3: Group best-per-lang entries by title text
const text_groups = new Map<string, LanguageAttribution[]>()
for (const [lang, best] of best_per_lang) {
const existing = text_groups.get(best.title) || []
existing.push({ language: lang, traits: best.traits })
text_groups.set(best.title, existing)
}
// Step 4: Sort languages within each group by priority order
const priority_index = new Map<TitleLanguage_v2, number>()
for (let i = 0; i < priority.length; i++) {
priority_index.set(priority[i], i)
}
const sort_by_priority = (a: LanguageAttribution, b: LanguageAttribution) => {
const a_idx = priority_index.get(a.language) ?? priority.length
const b_idx = priority_index.get(b.language) ?? priority.length
return a_idx - b_idx
}
// Step 5: Build primary_languages
const primary_languages = text_groups.get(primary_str) || [
{ language: primary_title.language, traits: primary_title.traits },
]
primary_languages.sort(sort_by_priority)
// Step 6: Build secondary and other from remaining text groups
const secondary: ResolvedTitle[] = []
const other: ResolvedTitle[] = []
for (const [text, langs] of text_groups) {
if (text === primary_str) continue
langs.sort(sort_by_priority)
const entry: ResolvedTitle = {
title: text,
language: langs[0].language,
traits: langs[0].traits,
languages: langs,
}
if (langs.some((l) => priority_set.has(l.language))) {
secondary.push(entry)
} else {
other.push(entry)
}
}
// Sort secondary by priority order of first language in each group
secondary.sort((a, b) => {
const a_idx = priority_index.get(a.language) ?? priority.length
const b_idx = priority_index.get(b.language) ?? priority.length
return a_idx - b_idx
})
return {
primary: primary_str,
primary_languages,
secondary,
other,
}
}
```