workaround non latin character db bug

This commit is contained in:
Boof2015
2026-06-14 14:22:18 -04:00
parent 3d30a09713
commit 51b733a293
175 changed files with 178 additions and 102 deletions
+47 -7
View File
@@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import { Text as RNText, type TextProps as RNTextProps, StyleSheet } from 'react-native';
import { colors, fonts, fontSize } from '@/theme';
@@ -8,11 +9,56 @@ interface TextProps extends RNTextProps {
color?: string;
}
// Inter / JetBrains Mono are Latin-only, and a custom TTF Typeface on Android does
// not chain to the system Noto fallback, so CJK/other non-Latin glyphs render blank.
// When a string contains characters outside the ranges Inter actually covers, we drop
// the custom fontFamily so the platform default (full Noto CJK/emoji coverage) renders
// it, keeping the variant's weight via numeric fontWeight.
// Allowed (stays Inter): Latin + Latin-1/Ext-A/B (0000-024F), Greek (0370-03FF),
// Cyrillic (0400-04FF), general punctuation (2000-206F), currency (20A0-20CF),
// letterlike symbols (2100-214F). Anything else -> system font.
const NON_LATIN =
/[^\u0000-\u024F\u0370-\u03FF\u0400-\u04FF\u2000-\u206F\u20A0-\u20CF\u2100-\u214F]/;
const VARIANT_FAMILY: Record<Variant, string> = {
title: fonts.sans.bold,
heading: fonts.sans.semibold,
body: fonts.sans.regular,
label: fonts.sans.medium,
caption: fonts.sans.regular,
mono: fonts.mono.regular,
};
const VARIANT_WEIGHT: Record<Variant, '400' | '500' | '600' | '700'> = {
title: '700',
heading: '600',
body: '400',
label: '500',
caption: '400',
mono: '400',
};
function collectText(node: ReactNode): string {
if (typeof node === 'string') return node;
if (typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(collectText).join('');
return ''; // nested elements render their own <Text> and self-detect
}
/** Themed Text — applies Astra fonts/colors. Import this instead of RN's Text. */
export function Text({ variant = 'body', color, style, ...rest }: TextProps) {
const fallback = NON_LATIN.test(collectText(rest.children));
return (
<RNText
style={[styles[variant], color ? { color } : null, style]}
style={[
styles[variant],
{
fontFamily: fallback ? undefined : VARIANT_FAMILY[variant],
fontWeight: fallback ? VARIANT_WEIGHT[variant] : undefined,
},
color ? { color } : null,
style,
]}
{...rest}
/>
);
@@ -20,32 +66,26 @@ export function Text({ variant = 'body', color, style, ...rest }: TextProps) {
const styles = StyleSheet.create({
title: {
fontFamily: fonts.sans.bold,
fontSize: fontSize.xxl,
color: colors.textPrimary,
},
heading: {
fontFamily: fonts.sans.semibold,
fontSize: fontSize.lg,
color: colors.textPrimary,
},
body: {
fontFamily: fonts.sans.regular,
fontSize: fontSize.base,
color: colors.textPrimary,
},
label: {
fontFamily: fonts.sans.medium,
fontSize: fontSize.sm,
color: colors.textSecondary,
},
caption: {
fontFamily: fonts.sans.regular,
fontSize: fontSize.xs,
color: colors.textTertiary,
},
mono: {
fontFamily: fonts.mono.regular,
fontSize: fontSize.sm,
color: colors.textSecondary,
},
+47 -3
View File
@@ -11,6 +11,50 @@ interface Executor {
execute: (query: string, params?: Scalar[]) => Promise<QueryResult>;
}
// op-sqlite (16.2.x under RN 0.85 / Hermes) truncates each UTF-16 code unit of a
// bound *string* parameter to its low byte, corrupting any non-Latin1 text (CJK,
// emoji, accents beyond U+00FF). Work around it by binding the UTF-8 bytes mapped
// 1:1 into a Latin-1 string: the low-byte truncation then yields exactly those
// bytes — i.e. valid UTF-8 in the column. op-sqlite's read path decodes UTF-8
// correctly, so reads need no change; applying this to every string param (stored
// values and WHERE comparisons alike) keeps writes and lookups consistent.
// Remove if/when op-sqlite binds UTF-8 strings correctly on this RN/Hermes ABI.
function toUtf8Latin1(s: string): string {
let out = '';
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
if (c < 0x80) {
out += String.fromCharCode(c);
} else if (c < 0x800) {
out += String.fromCharCode(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
} else if (c >= 0xd800 && c <= 0xdbff) {
const lo = s.charCodeAt(++i); // surrogate pair → astral code point (emoji)
const cp = 0x10000 + ((c & 0x3ff) << 10) + (lo & 0x3ff);
out += String.fromCharCode(
0xf0 | (cp >> 18),
0x80 | ((cp >> 12) & 0x3f),
0x80 | ((cp >> 6) & 0x3f),
0x80 | (cp & 0x3f)
);
} else {
out += String.fromCharCode(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
}
}
return out;
}
function encodeParams(params: SqlParams): SqlParams {
let hasString = false;
for (const p of params) {
if (typeof p === 'string') {
hasString = true;
break;
}
}
if (!hasString) return params;
return params.map((p) => (typeof p === 'string' ? toUtf8Latin1(p) : p));
}
export class LibraryDatabase {
constructor(
private readonly executor: Executor,
@@ -18,7 +62,7 @@ export class LibraryDatabase {
) {}
async run(sql: string, params: SqlParams = []): Promise<{ changes: number; lastInsertRowid: number }> {
const result = await this.executor.execute(sql, params);
const result = await this.executor.execute(sql, encodeParams(params));
return { changes: result.rowsAffected, lastInsertRowid: result.insertId ?? 0 };
}
@@ -27,12 +71,12 @@ export class LibraryDatabase {
}
async get<T>(sql: string, params: SqlParams = []): Promise<T | undefined> {
const result = await this.executor.execute(sql, params);
const result = await this.executor.execute(sql, encodeParams(params));
return result.rows[0] as T | undefined;
}
async all<T>(sql: string, params: SqlParams = []): Promise<T[]> {
const result = await this.executor.execute(sql, params);
const result = await this.executor.execute(sql, encodeParams(params));
return result.rows as T[];
}
+7 -2
View File
@@ -1,10 +1,11 @@
// Library schema — a trimmed port of the desktop schema (astra
// src/main/services/library.ts). v1 covers M1 (local scan + browse);
// v2 adds playlists + favorites (M2); metadata overrides / lyrics later.
// v2 adds playlists + favorites (M2); v3 forces re-extraction of tracks whose
// non-ASCII tags were truncated by the pre-fix op-sqlite binding (see database.ts).
import type { LibraryDatabase } from './database';
export const SCHEMA_VERSION = 2;
export const SCHEMA_VERSION = 3;
// One statement per entry — op-sqlite executes single statements.
const MIGRATIONS: readonly (readonly string[])[] = [
@@ -77,6 +78,10 @@ const MIGRATIONS: readonly (readonly string[])[] = [
added_at INTEGER NOT NULL
)`,
],
// v2 -> v3 — pre-fix builds stored truncated non-ASCII tags (op-sqlite bind bug,
// see database.ts). The damage is irreversible in place, so mark every track
// stale; libraryStore re-extracts them on next launch now that binding is fixed.
[`UPDATE tracks SET mtime = -1`],
];
export async function migrate(db: LibraryDatabase): Promise<void> {
+40
View File
@@ -0,0 +1,40 @@
// Repairs mojibake produced by MediaMetadataRetriever mis-decoding legacy ID3v2
// text frames — the classic case being Japanese MP3s with Shift-JIS bytes in an
// ISO-8859-1-flagged frame. MMR decodes each raw byte to a Latin-1 char, so the
// original bytes survive in the low byte of every code unit and can be recovered
// and re-decoded here. Desktop avoids this entirely via music-metadata, which
// honours the frame's encoding byte.
import * as Encoding from 'encoding-japanese';
// Characters that prove a recovered string is real Japanese/CJK text (kana, CJK
// unified ideographs, hangul, full/half-width forms). Used as the final gate so a
// mis-detection of accented Latin-1 (e.g. "Beyoncé") can never corrupt good text.
const CJK = /[\u3040-\u30FF\u3400-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF00-\uFFEF]/;
/**
* Returns `value` re-decoded from a Japanese multibyte encoding when it is
* recoverable mojibake; otherwise returns `value` unchanged. Safe to call on any
* tag string — it is a no-op for ASCII, accented Latin, and already-correct
* Unicode.
*/
export function repairMojibakeTag(value: string): string {
let hasHighByte = false;
const bytes = new Array<number>(value.length);
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
// A code unit above 0xFF means the string already holds real Unicode (e.g.
// MMR decoded a UTF-16 frame correctly) — there is nothing to recover.
if (code > 0xff) return value;
if (code >= 0x80) hasHighByte = true;
bytes[i] = code;
}
if (!hasHighByte) return value; // pure ASCII/Latin — nothing to recover
const detected = Encoding.detect(bytes);
if (detected !== 'SJIS' && detected !== 'EUCJP') return value;
const repaired = Encoding.convert(bytes, { to: 'UNICODE', from: detected, type: 'string' });
// Only accept the conversion when it actually produced CJK text.
return CJK.test(repaired) ? repaired : value;
}
+2 -1
View File
@@ -6,6 +6,7 @@ import type { DbTrack } from '@/types/library';
import type { TrackUpsert } from '@/db/queries';
import type { ExtractedMetadata, ScannedFile } from '../../modules/astra-library-scanner';
import { artworkUri } from './artwork';
import { repairMojibakeTag } from './tagEncoding';
const UNKNOWN_ARTIST = 'Unknown Artist';
const UNKNOWN_ALBUM = 'Unknown Album';
@@ -60,7 +61,7 @@ function fileExtension(name: string): string {
function cleanTag(value: string | null | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
return trimmed ? repairMojibakeTag(trimmed) : null;
}
export function metadataToUpsertRow(
+9 -1
View File
@@ -87,9 +87,17 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
initialize: () => {
if (!initPromise) {
initPromise = (async () => {
await openLibraryDb();
const db = await openLibraryDb();
await get().refresh();
set({ initialized: true });
// One-time recovery: the v3 migration marks tracks stale (mtime = -1)
// whose non-ASCII tags were truncated by the pre-fix op-sqlite binding.
// Re-extract them now that binding is fixed. Fire-and-forget so startup
// isn't blocked; rescan manages its own progress + refresh.
const stale = await db.get<{ n: number }>('SELECT COUNT(*) AS n FROM tracks WHERE mtime = -1');
if ((stale?.n ?? 0) > 0) {
void get().rescan();
}
})().catch((err) => {
initPromise = null; // allow retry on genuine failure
throw err;