mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
desktop sync support
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildImportIndex, matchSyncEntry } from './importMatching.ts';
|
||||
import type { DbTrack } from '../types/library.ts';
|
||||
|
||||
function track(overrides: Partial<DbTrack> & Pick<DbTrack, 'path' | 'title' | 'artist' | 'album' | 'file_name'>): DbTrack {
|
||||
return {
|
||||
id: 1,
|
||||
folder_id: 1,
|
||||
album_artist: null,
|
||||
album_identity_key: 'k',
|
||||
album_display_artist: null,
|
||||
duration: 200,
|
||||
track_number: null,
|
||||
disc_number: null,
|
||||
year: null,
|
||||
genre: null,
|
||||
artwork_hash: null,
|
||||
format: 'flac',
|
||||
sample_rate: null,
|
||||
bit_depth: null,
|
||||
bitrate: null,
|
||||
channels: null,
|
||||
codec: null,
|
||||
bpm: null,
|
||||
musical_key: null,
|
||||
source_type: 'local',
|
||||
source_id: null,
|
||||
source_track_id: null,
|
||||
source_path: null,
|
||||
artwork_source_id: null,
|
||||
size: null,
|
||||
mtime: 0,
|
||||
added_at: 0,
|
||||
modified_at: 0,
|
||||
play_count: 0,
|
||||
last_played_at: null,
|
||||
...overrides,
|
||||
} as DbTrack;
|
||||
}
|
||||
|
||||
function safUri(docPath: string): string {
|
||||
return `content://com.android.externalstorage.documents/document/${encodeURIComponent(`primary:${docPath}`)}`;
|
||||
}
|
||||
|
||||
const NEBULA = track({
|
||||
path: safUri('Music/Nova/Drift/nebula.flac'),
|
||||
title: 'Nebula',
|
||||
artist: 'Nova',
|
||||
album: 'Drift',
|
||||
file_name: 'nebula.flac',
|
||||
});
|
||||
const NEBULA_LIVE = track({
|
||||
path: safUri('Music/Nova/Live/nebula.flac'),
|
||||
title: 'Nebula',
|
||||
artist: 'Nova',
|
||||
album: 'Live at Dawn',
|
||||
file_name: 'nebula.flac',
|
||||
});
|
||||
const EMBER = track({
|
||||
path: safUri('Music/Cinder/Ash/ember.flac'),
|
||||
title: 'Ember',
|
||||
artist: 'Cinder',
|
||||
album: 'Ash',
|
||||
file_name: 'ember.flac',
|
||||
});
|
||||
|
||||
test('matches by title+artist+album with case and whitespace variance', () => {
|
||||
const index = buildImportIndex([NEBULA, NEBULA_LIVE, EMBER]);
|
||||
const match = matchSyncEntry(
|
||||
{ title: ' NEBULA ', artist: 'nova', album: 'Drift ' },
|
||||
index
|
||||
);
|
||||
assert.equal(match.kind, 'matched');
|
||||
assert.equal(match.kind === 'matched' && match.track.path, NEBULA.path);
|
||||
});
|
||||
|
||||
test('unique source-path file name wins before metadata', () => {
|
||||
const index = buildImportIndex([EMBER, NEBULA]);
|
||||
const match = matchSyncEntry(
|
||||
{ title: 'Wrong Title', artist: 'Wrong', album: 'Wrong', sourcePath: 'D:/Music/Cinder/Ash/ember.flac' },
|
||||
index
|
||||
);
|
||||
assert.equal(match.kind, 'matched');
|
||||
assert.equal(match.kind === 'matched' && match.track.path, EMBER.path);
|
||||
});
|
||||
|
||||
test('duplicate file names resolve by trailing path-segment overlap', () => {
|
||||
const index = buildImportIndex([NEBULA, NEBULA_LIVE, EMBER]);
|
||||
const match = matchSyncEntry(
|
||||
{ title: 'Nebula', artist: 'Nova', album: 'Nonexistent', sourcePath: 'D:/Library/Nova/Live/nebula.flac' },
|
||||
index
|
||||
);
|
||||
assert.equal(match.kind, 'matched');
|
||||
assert.equal(match.kind === 'matched' && match.track.path, NEBULA_LIVE.path);
|
||||
});
|
||||
|
||||
test('ambiguous file-name rung falls through to metadata instead of giving up', () => {
|
||||
// Same file name, same trailing segment depth -> tied overlap; the
|
||||
// title+artist+album rung must still resolve it.
|
||||
const a = track({
|
||||
path: safUri('One/song.flac'),
|
||||
title: 'Song',
|
||||
artist: 'A',
|
||||
album: 'First',
|
||||
file_name: 'song.flac',
|
||||
});
|
||||
const b = track({
|
||||
path: safUri('Two/song.flac'),
|
||||
title: 'Song',
|
||||
artist: 'A',
|
||||
album: 'Second',
|
||||
file_name: 'song.flac',
|
||||
});
|
||||
const index = buildImportIndex([a, b]);
|
||||
const match = matchSyncEntry(
|
||||
{ title: 'Song', artist: 'A', album: 'Second', sourcePath: 'Z:/Elsewhere/song.flac' },
|
||||
index
|
||||
);
|
||||
assert.equal(match.kind, 'matched');
|
||||
assert.equal(match.kind === 'matched' && match.track.path, b.path);
|
||||
});
|
||||
|
||||
test('duplicate metadata identity reports ambiguous, not a guess', () => {
|
||||
const a = track({
|
||||
path: safUri('One/x.flac'),
|
||||
title: 'Twin',
|
||||
artist: 'Dup',
|
||||
album: 'Same',
|
||||
file_name: 'x.flac',
|
||||
});
|
||||
const b = track({
|
||||
path: safUri('Two/y.flac'),
|
||||
title: 'Twin',
|
||||
artist: 'Dup',
|
||||
album: 'Same',
|
||||
file_name: 'y.flac',
|
||||
});
|
||||
const index = buildImportIndex([a, b]);
|
||||
const match = matchSyncEntry({ title: 'Twin', artist: 'Dup', album: 'Same' }, index);
|
||||
assert.equal(match.kind, 'ambiguous');
|
||||
});
|
||||
|
||||
test('falls back to title+artist then title-only rungs', () => {
|
||||
const index = buildImportIndex([NEBULA, EMBER]);
|
||||
// Album mismatch -> title+artist rung; but 'Nebula' by 'Nova' exists twice
|
||||
// in the library? No — only NEBULA here, so title+artist matches.
|
||||
const byTitleArtist = matchSyncEntry({ title: 'Nebula', artist: 'Nova', album: 'Renamed Album' }, index);
|
||||
assert.equal(byTitleArtist.kind, 'matched');
|
||||
|
||||
const byTitle = matchSyncEntry({ title: 'Ember', artist: 'Different Artist', album: '' }, index);
|
||||
assert.equal(byTitle.kind, 'matched');
|
||||
assert.equal(byTitle.kind === 'matched' && byTitle.track.path, EMBER.path);
|
||||
});
|
||||
|
||||
test('no title means no identity', () => {
|
||||
const index = buildImportIndex([NEBULA]);
|
||||
assert.equal(matchSyncEntry({ title: ' ', artist: 'Nova', album: 'Drift' }, index).kind, 'none');
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
// Track matching ladders (pure — node-testable, no expo imports):
|
||||
// - M3U import: exact content URI -> decoded SAF path -> file name (+
|
||||
// path-suffix overlap) -> metadata (title+artist -> title, unique-or-null).
|
||||
// - Desktop sync: source-path file name -> title+artist+album (shared
|
||||
// whitespace-collapsing normalization) -> the M3U metadata rungs.
|
||||
// Ported in spirit from the desktop playlist importer (library.ts).
|
||||
// Runtime imports are relative (not '@/') so this module runs under plain
|
||||
// `node --test`; type-only '@/' imports are erased by strip-types.
|
||||
|
||||
import type { DbTrack } from '@/types/library';
|
||||
import type { M3uEntry } from '@/lib/m3u';
|
||||
import { decodedSafDocumentPath } from './folderTree.ts';
|
||||
import { normalizeSyncKeyPart } from '../shared/sync/identity.ts';
|
||||
|
||||
export const decodedDocPath = decodedSafDocumentPath;
|
||||
|
||||
interface IndexedTrack {
|
||||
track: DbTrack;
|
||||
/** Lowercased decoded SAF path, for suffix-overlap scoring. */
|
||||
decodedPath: string | null;
|
||||
}
|
||||
|
||||
export interface ImportMatchIndex {
|
||||
byContentUri: Map<string, DbTrack>;
|
||||
byDecodedPath: Map<string, DbTrack>;
|
||||
byFileName: Map<string, IndexedTrack[]>;
|
||||
/** Metadata maps are unique-or-null: null marks a collision (ambiguous). */
|
||||
byTitleArtist: Map<string, DbTrack | null>;
|
||||
byTitle: Map<string, DbTrack | null>;
|
||||
/** Desktop-sync rung, keyed with the shared whitespace-collapsing
|
||||
* normalization (normalizeSyncKeyPart) unlike the M3U maps above. */
|
||||
byTitleArtistAlbum: Map<string, DbTrack | null>;
|
||||
}
|
||||
|
||||
function upsertUnique(map: Map<string, DbTrack | null>, key: string, track: DbTrack): void {
|
||||
if (!key) return;
|
||||
map.set(key, map.has(key) ? null : track);
|
||||
}
|
||||
|
||||
export function buildImportIndex(tracks: DbTrack[]): ImportMatchIndex {
|
||||
const index: ImportMatchIndex = {
|
||||
byContentUri: new Map(),
|
||||
byDecodedPath: new Map(),
|
||||
byFileName: new Map(),
|
||||
byTitleArtist: new Map(),
|
||||
byTitle: new Map(),
|
||||
byTitleArtistAlbum: new Map(),
|
||||
};
|
||||
for (const track of tracks) {
|
||||
index.byContentUri.set(track.path, track);
|
||||
|
||||
const decoded = decodedDocPath(track.path)?.toLocaleLowerCase() ?? null;
|
||||
if (decoded && !index.byDecodedPath.has(decoded)) {
|
||||
index.byDecodedPath.set(decoded, track);
|
||||
}
|
||||
|
||||
const fileName = track.file_name.toLocaleLowerCase();
|
||||
const bucket = index.byFileName.get(fileName);
|
||||
if (bucket) {
|
||||
bucket.push({ track, decodedPath: decoded });
|
||||
} else {
|
||||
index.byFileName.set(fileName, [{ track, decodedPath: decoded }]);
|
||||
}
|
||||
|
||||
const title = track.title.trim().toLocaleLowerCase();
|
||||
const artist = track.artist.trim().toLocaleLowerCase();
|
||||
upsertUnique(index.byTitleArtist, `${title}\n${artist}`, track);
|
||||
upsertUnique(index.byTitle, title, track);
|
||||
|
||||
const syncTitle = normalizeSyncKeyPart(track.title);
|
||||
if (syncTitle) {
|
||||
upsertUnique(
|
||||
index.byTitleArtistAlbum,
|
||||
`${syncTitle}\n${normalizeSyncKeyPart(track.artist)}\n${normalizeSyncKeyPart(track.album)}`,
|
||||
track
|
||||
);
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/** Foreign playlist paths: strip file://, unify slashes, percent-decode. */
|
||||
export function normalizeEntryPath(path: string): string {
|
||||
let value = path.trim().replace(/\\/g, '/');
|
||||
if (/^file:\/\//i.test(value)) value = value.slice('file://'.length);
|
||||
if (value.includes('%')) {
|
||||
try {
|
||||
value = decodeURIComponent(value);
|
||||
} catch {
|
||||
// keep the raw value
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export type ImportMatch =
|
||||
| { kind: 'matched'; track: DbTrack; via: 'path' | 'metadata' }
|
||||
| { kind: 'ambiguous' }
|
||||
| { kind: 'none' };
|
||||
|
||||
/** Trailing path-segment overlap between an entry path and a track's decoded path. */
|
||||
function suffixOverlap(entrySegments: string[], decodedPath: string | null): number {
|
||||
if (!decodedPath) return 1; // file name matched, nothing more to compare
|
||||
const trackSegments = decodedPath.split('/');
|
||||
let overlap = 0;
|
||||
while (
|
||||
overlap < entrySegments.length &&
|
||||
overlap < trackSegments.length &&
|
||||
entrySegments[entrySegments.length - 1 - overlap] ===
|
||||
trackSegments[trackSegments.length - 1 - overlap]
|
||||
) {
|
||||
overlap += 1;
|
||||
}
|
||||
return overlap;
|
||||
}
|
||||
|
||||
export function matchImportEntry(entry: M3uEntry, index: ImportMatchIndex): ImportMatch {
|
||||
const raw = entry.path.trim();
|
||||
|
||||
// 1. Exact SAF content URI (our own exports never write these, but be safe).
|
||||
const exact = index.byContentUri.get(raw);
|
||||
if (exact) return { kind: 'matched', track: exact, via: 'path' };
|
||||
|
||||
const normalized = normalizeEntryPath(raw).toLocaleLowerCase();
|
||||
|
||||
// 2. Decoded SAF path ("Music/Artist/Album/file.flac" — our export format).
|
||||
const byPath = index.byDecodedPath.get(normalized);
|
||||
if (byPath) return { kind: 'matched', track: byPath, via: 'path' };
|
||||
|
||||
// 3. File name bucket, disambiguated by longest trailing-segment overlap.
|
||||
const entrySegments = normalized.split('/');
|
||||
const fileName = entrySegments[entrySegments.length - 1];
|
||||
const candidates = index.byFileName.get(fileName) ?? [];
|
||||
if (candidates.length === 1) {
|
||||
return { kind: 'matched', track: candidates[0].track, via: 'path' };
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
let best: IndexedTrack | null = null;
|
||||
let bestScore = 0;
|
||||
let tied = false;
|
||||
for (const candidate of candidates) {
|
||||
const score = suffixOverlap(entrySegments, candidate.decodedPath);
|
||||
if (score > bestScore) {
|
||||
best = candidate;
|
||||
bestScore = score;
|
||||
tied = false;
|
||||
} else if (score === bestScore) {
|
||||
tied = true;
|
||||
}
|
||||
}
|
||||
if (best && !tied) return { kind: 'matched', track: best.track, via: 'path' };
|
||||
return { kind: 'ambiguous' };
|
||||
}
|
||||
|
||||
// 4. Metadata from EXTINF: title+artist, then title (unique-or-null).
|
||||
const title = entry.title?.trim().toLocaleLowerCase();
|
||||
if (title) {
|
||||
const artist = entry.artist?.trim().toLocaleLowerCase();
|
||||
if (artist) {
|
||||
const hit = index.byTitleArtist.get(`${title}\n${artist}`);
|
||||
if (hit) return { kind: 'matched', track: hit, via: 'metadata' };
|
||||
if (hit === null) return { kind: 'ambiguous' };
|
||||
}
|
||||
const hit = index.byTitle.get(title);
|
||||
if (hit) return { kind: 'matched', track: hit, via: 'metadata' };
|
||||
if (hit === null) return { kind: 'ambiguous' };
|
||||
}
|
||||
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
export interface SyncEntryQuery {
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
/** The peer's file path, useful only for its file name / trailing segments. */
|
||||
sourcePath?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop-sync matching ladder: source-path file name (unique or best
|
||||
* suffix-overlap) -> title+artist+album -> title+artist -> title. Unlike M3U
|
||||
* import, sync entries always carry metadata, so an ambiguous file-name rung
|
||||
* falls through to metadata instead of giving up.
|
||||
*/
|
||||
export function matchSyncEntry(query: SyncEntryQuery, index: ImportMatchIndex): ImportMatch {
|
||||
const sourcePath = query.sourcePath?.trim();
|
||||
if (sourcePath) {
|
||||
const normalized = normalizeEntryPath(sourcePath).toLocaleLowerCase();
|
||||
const entrySegments = normalized.split('/');
|
||||
const fileName = entrySegments[entrySegments.length - 1];
|
||||
const candidates = index.byFileName.get(fileName) ?? [];
|
||||
if (candidates.length === 1) {
|
||||
return { kind: 'matched', track: candidates[0].track, via: 'path' };
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
let best: DbTrack | null = null;
|
||||
let bestScore = 0;
|
||||
let tied = false;
|
||||
for (const candidate of candidates) {
|
||||
const score = suffixOverlap(entrySegments, candidate.decodedPath);
|
||||
if (score > bestScore) {
|
||||
best = candidate.track;
|
||||
bestScore = score;
|
||||
tied = false;
|
||||
} else if (score === bestScore) {
|
||||
tied = true;
|
||||
}
|
||||
}
|
||||
if (best && !tied) return { kind: 'matched', track: best, via: 'path' };
|
||||
}
|
||||
}
|
||||
|
||||
const title = normalizeSyncKeyPart(query.title);
|
||||
if (!title) return { kind: 'none' };
|
||||
const artist = normalizeSyncKeyPart(query.artist);
|
||||
const album = normalizeSyncKeyPart(query.album);
|
||||
|
||||
if (artist && album) {
|
||||
const hit = index.byTitleArtistAlbum.get(`${title}\n${artist}\n${album}`);
|
||||
if (hit) return { kind: 'matched', track: hit, via: 'metadata' };
|
||||
if (hit === null) return { kind: 'ambiguous' };
|
||||
}
|
||||
|
||||
// The M3U maps use plain trim+lowercase keys — query them the same way.
|
||||
const m3uTitle = query.title.trim().toLocaleLowerCase();
|
||||
const m3uArtist = query.artist.trim().toLocaleLowerCase();
|
||||
if (m3uArtist) {
|
||||
const hit = index.byTitleArtist.get(`${m3uTitle}\n${m3uArtist}`);
|
||||
if (hit) return { kind: 'matched', track: hit, via: 'metadata' };
|
||||
if (hit === null) return { kind: 'ambiguous' };
|
||||
}
|
||||
const hit = index.byTitle.get(m3uTitle);
|
||||
if (hit) return { kind: 'matched', track: hit, via: 'metadata' };
|
||||
if (hit === null) return { kind: 'ambiguous' };
|
||||
|
||||
return { kind: 'none' };
|
||||
}
|
||||
+13
-151
@@ -1,7 +1,6 @@
|
||||
// M3U file IO (SAF export, document-picker import) + the import matching
|
||||
// ladder: exact content URI -> decoded SAF path -> file name (+ path-suffix
|
||||
// overlap) -> metadata (title+artist -> title, unique-or-null), ported in
|
||||
// spirit from the desktop playlist importer.
|
||||
// M3U file IO (SAF export, document-picker import). The matching ladders live
|
||||
// in importMatching.ts (pure, node-testable) and are re-exported here for the
|
||||
// existing import sites.
|
||||
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import {
|
||||
@@ -9,155 +8,18 @@ import {
|
||||
readAsStringAsync,
|
||||
writeAsStringAsync,
|
||||
} from 'expo-file-system/legacy';
|
||||
import type { DbTrack } from '@/types/library';
|
||||
import { decodedSafDocumentPath } from './folderTree';
|
||||
import { parseM3u, serializeM3u, type M3uEntry, type M3uExportEntry } from '@/lib/m3u';
|
||||
|
||||
export const decodedDocPath = decodedSafDocumentPath;
|
||||
|
||||
// --- Import matching ---------------------------------------------------------
|
||||
|
||||
interface IndexedTrack {
|
||||
track: DbTrack;
|
||||
/** Lowercased decoded SAF path, for suffix-overlap scoring. */
|
||||
decodedPath: string | null;
|
||||
}
|
||||
|
||||
export interface ImportMatchIndex {
|
||||
byContentUri: Map<string, DbTrack>;
|
||||
byDecodedPath: Map<string, DbTrack>;
|
||||
byFileName: Map<string, IndexedTrack[]>;
|
||||
/** Metadata maps are unique-or-null: null marks a collision (ambiguous). */
|
||||
byTitleArtist: Map<string, DbTrack | null>;
|
||||
byTitle: Map<string, DbTrack | null>;
|
||||
}
|
||||
|
||||
function upsertUnique(map: Map<string, DbTrack | null>, key: string, track: DbTrack): void {
|
||||
if (!key) return;
|
||||
map.set(key, map.has(key) ? null : track);
|
||||
}
|
||||
|
||||
export function buildImportIndex(tracks: DbTrack[]): ImportMatchIndex {
|
||||
const index: ImportMatchIndex = {
|
||||
byContentUri: new Map(),
|
||||
byDecodedPath: new Map(),
|
||||
byFileName: new Map(),
|
||||
byTitleArtist: new Map(),
|
||||
byTitle: new Map(),
|
||||
};
|
||||
for (const track of tracks) {
|
||||
index.byContentUri.set(track.path, track);
|
||||
|
||||
const decoded = decodedDocPath(track.path)?.toLocaleLowerCase() ?? null;
|
||||
if (decoded && !index.byDecodedPath.has(decoded)) {
|
||||
index.byDecodedPath.set(decoded, track);
|
||||
}
|
||||
|
||||
const fileName = track.file_name.toLocaleLowerCase();
|
||||
const bucket = index.byFileName.get(fileName);
|
||||
if (bucket) {
|
||||
bucket.push({ track, decodedPath: decoded });
|
||||
} else {
|
||||
index.byFileName.set(fileName, [{ track, decodedPath: decoded }]);
|
||||
}
|
||||
|
||||
const title = track.title.trim().toLocaleLowerCase();
|
||||
const artist = track.artist.trim().toLocaleLowerCase();
|
||||
upsertUnique(index.byTitleArtist, `${title}\n${artist}`, track);
|
||||
upsertUnique(index.byTitle, title, track);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/** Foreign playlist paths: strip file://, unify slashes, percent-decode. */
|
||||
export function normalizeEntryPath(path: string): string {
|
||||
let value = path.trim().replace(/\\/g, '/');
|
||||
if (/^file:\/\//i.test(value)) value = value.slice('file://'.length);
|
||||
if (value.includes('%')) {
|
||||
try {
|
||||
value = decodeURIComponent(value);
|
||||
} catch {
|
||||
// keep the raw value
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export type ImportMatch =
|
||||
| { kind: 'matched'; track: DbTrack; via: 'path' | 'metadata' }
|
||||
| { kind: 'ambiguous' }
|
||||
| { kind: 'none' };
|
||||
|
||||
/** Trailing path-segment overlap between an entry path and a track's decoded path. */
|
||||
function suffixOverlap(entrySegments: string[], decodedPath: string | null): number {
|
||||
if (!decodedPath) return 1; // file name matched, nothing more to compare
|
||||
const trackSegments = decodedPath.split('/');
|
||||
let overlap = 0;
|
||||
while (
|
||||
overlap < entrySegments.length &&
|
||||
overlap < trackSegments.length &&
|
||||
entrySegments[entrySegments.length - 1 - overlap] ===
|
||||
trackSegments[trackSegments.length - 1 - overlap]
|
||||
) {
|
||||
overlap += 1;
|
||||
}
|
||||
return overlap;
|
||||
}
|
||||
|
||||
export function matchImportEntry(entry: M3uEntry, index: ImportMatchIndex): ImportMatch {
|
||||
const raw = entry.path.trim();
|
||||
|
||||
// 1. Exact SAF content URI (our own exports never write these, but be safe).
|
||||
const exact = index.byContentUri.get(raw);
|
||||
if (exact) return { kind: 'matched', track: exact, via: 'path' };
|
||||
|
||||
const normalized = normalizeEntryPath(raw).toLocaleLowerCase();
|
||||
|
||||
// 2. Decoded SAF path ("Music/Artist/Album/file.flac" — our export format).
|
||||
const byPath = index.byDecodedPath.get(normalized);
|
||||
if (byPath) return { kind: 'matched', track: byPath, via: 'path' };
|
||||
|
||||
// 3. File name bucket, disambiguated by longest trailing-segment overlap.
|
||||
const entrySegments = normalized.split('/');
|
||||
const fileName = entrySegments[entrySegments.length - 1];
|
||||
const candidates = index.byFileName.get(fileName) ?? [];
|
||||
if (candidates.length === 1) {
|
||||
return { kind: 'matched', track: candidates[0].track, via: 'path' };
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
let best: IndexedTrack | null = null;
|
||||
let bestScore = 0;
|
||||
let tied = false;
|
||||
for (const candidate of candidates) {
|
||||
const score = suffixOverlap(entrySegments, candidate.decodedPath);
|
||||
if (score > bestScore) {
|
||||
best = candidate;
|
||||
bestScore = score;
|
||||
tied = false;
|
||||
} else if (score === bestScore) {
|
||||
tied = true;
|
||||
}
|
||||
}
|
||||
if (best && !tied) return { kind: 'matched', track: best.track, via: 'path' };
|
||||
return { kind: 'ambiguous' };
|
||||
}
|
||||
|
||||
// 4. Metadata from EXTINF: title+artist, then title (unique-or-null).
|
||||
const title = entry.title?.trim().toLocaleLowerCase();
|
||||
if (title) {
|
||||
const artist = entry.artist?.trim().toLocaleLowerCase();
|
||||
if (artist) {
|
||||
const hit = index.byTitleArtist.get(`${title}\n${artist}`);
|
||||
if (hit) return { kind: 'matched', track: hit, via: 'metadata' };
|
||||
if (hit === null) return { kind: 'ambiguous' };
|
||||
}
|
||||
const hit = index.byTitle.get(title);
|
||||
if (hit) return { kind: 'matched', track: hit, via: 'metadata' };
|
||||
if (hit === null) return { kind: 'ambiguous' };
|
||||
}
|
||||
|
||||
return { kind: 'none' };
|
||||
}
|
||||
export {
|
||||
buildImportIndex,
|
||||
decodedDocPath,
|
||||
matchImportEntry,
|
||||
matchSyncEntry,
|
||||
normalizeEntryPath,
|
||||
type ImportMatch,
|
||||
type ImportMatchIndex,
|
||||
type SyncEntryQuery,
|
||||
} from './importMatching';
|
||||
|
||||
// --- File IO -------------------------------------------------------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user