mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
workaround non latin character db bug
This commit is contained in:
+47
-7
@@ -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
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user