taglib-wasm 1.6.1 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/dist/index.browser.js +1 -1
- package/dist/simple.browser.js +1 -1
- package/dist/src/folder-api/group-albums.d.ts +122 -0
- package/dist/src/folder-api/group-albums.d.ts.map +1 -0
- package/dist/src/folder-api/group-albums.js +540 -0
- package/dist/src/folder-api/index.d.ts +2 -0
- package/dist/src/folder-api/index.d.ts.map +1 -1
- package/dist/src/folder-api/index.js +8 -0
- package/dist/src/folder-api/scan-for-albums.d.ts +20 -0
- package/dist/src/folder-api/scan-for-albums.d.ts.map +1 -0
- package/dist/src/folder-api/scan-for-albums.js +22 -0
- package/dist/src/runtime/loader-types.d.ts +11 -0
- package/dist/src/runtime/loader-types.d.ts.map +1 -1
- package/dist/src/runtime/loader-types.js +10 -0
- package/dist/src/runtime/module-loader.d.ts.map +1 -1
- package/dist/src/runtime/module-loader.js +9 -0
- package/dist/src/runtime/unified-loader/loader.d.ts.map +1 -1
- package/dist/src/runtime/unified-loader/loader.js +5 -0
- package/dist/src/runtime/wasi-adapter/file-handle.d.ts.map +1 -1
- package/dist/src/runtime/wasi-adapter/file-handle.js +15 -2
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/dist/taglib-wasi.wasm +0 -0
- package/dist/taglib-web.wasm +0 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -196,7 +196,7 @@ file.save();
|
|
|
196
196
|
Process entire music collections efficiently:
|
|
197
197
|
|
|
198
198
|
```typescript
|
|
199
|
-
import { findDuplicates, scanFolder } from "taglib-wasm";
|
|
199
|
+
import { findDuplicates, scanFolder, scanForAlbums } from "taglib-wasm";
|
|
200
200
|
|
|
201
201
|
// Scan a music library
|
|
202
202
|
const result = await scanFolder("/path/to/music", {
|
|
@@ -227,6 +227,18 @@ const duplicates = await findDuplicates("/path/to/music", {
|
|
|
227
227
|
criteria: ["artist", "title"],
|
|
228
228
|
});
|
|
229
229
|
console.log(`Found ${duplicates.length} groups of duplicates`);
|
|
230
|
+
|
|
231
|
+
// Group into albums with disc subdivisions (tags are authority, folder
|
|
232
|
+
// names are evidence)
|
|
233
|
+
const { albums, singles, unmatched } = await scanForAlbums("/path/to/music");
|
|
234
|
+
for (const album of albums) {
|
|
235
|
+
console.log(
|
|
236
|
+
`${
|
|
237
|
+
album.albumArtist ?? ""
|
|
238
|
+
} - ${album.album}: ${album.discs.length} disc(s)`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
console.log(`${singles.length} singles, ${unmatched.length} unmatched`);
|
|
230
242
|
```
|
|
231
243
|
|
|
232
244
|
### Working with Cover Art
|
package/dist/index.browser.js
CHANGED
package/dist/simple.browser.js
CHANGED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Pure album grouping over a FolderScanResult (2026-08-03 spec).
|
|
3
|
+
*
|
|
4
|
+
* Given a folder scan, produce albums with disc subdivisions, using embedded
|
|
5
|
+
* tags as authority and folder/filename structure as evidence. Synchronous,
|
|
6
|
+
* runtime-agnostic, no wasm, no I/O: everything the algorithm needs arrives
|
|
7
|
+
* in the scan result (tags, paths, statuses).
|
|
8
|
+
*
|
|
9
|
+
* Model contract (invariants, enforced by tests):
|
|
10
|
+
* 1. Every ok item appears exactly once across albums[*].items, singles,
|
|
11
|
+
* and unmatched; errors is disjoint.
|
|
12
|
+
* 2. discs >= 1 per album; items >= 1 per disc.
|
|
13
|
+
* 3. discNumber === tagDiscNumber ?? folderDiscNumber on every disc.
|
|
14
|
+
* 4. key is stable for identical input.
|
|
15
|
+
* 5. Every item's albumDir/discNumber match the disc it was assigned to.
|
|
16
|
+
* 6. compilation is true/false only under full tag agreement, else undefined.
|
|
17
|
+
* 7. singles holds exactly the ok items whose resolved album has one file.
|
|
18
|
+
*/
|
|
19
|
+
import type { AudioFileMetadata, FolderScanResult } from "./types.js";
|
|
20
|
+
export type AlbumGroupKey = string & {
|
|
21
|
+
__brand: "AlbumGroupKey";
|
|
22
|
+
};
|
|
23
|
+
export type DiscConfidence = "high" | "medium" | "low";
|
|
24
|
+
/** A file inside an album, carrying its own resolution. */
|
|
25
|
+
export interface AlbumGroupItem extends AudioFileMetadata {
|
|
26
|
+
/** The album directory this file resolves to: its own directory, or one
|
|
27
|
+
* level up when that directory is a confirmed disc folder. */
|
|
28
|
+
albumDir: string;
|
|
29
|
+
/** This file's resolved disc number — the containing disc's resolved
|
|
30
|
+
* number; undefined for discs with no number. */
|
|
31
|
+
discNumber: number | undefined;
|
|
32
|
+
}
|
|
33
|
+
export interface AlbumDisc {
|
|
34
|
+
/** Resolved disc number: tag value when files agree, else folder-implied. */
|
|
35
|
+
discNumber: number | undefined;
|
|
36
|
+
/** Total discs: common tag totalDiscs, else "of N" from folder, else max sibling number. */
|
|
37
|
+
totalDiscs: number | undefined;
|
|
38
|
+
/** Disc number parsed from the folder name; absent when no folder evidence. */
|
|
39
|
+
folderDiscNumber: number | undefined;
|
|
40
|
+
/** Subtitle after the marker, e.g. "Bonus Tracks" from "Disc 2 (Bonus Tracks)". */
|
|
41
|
+
folderDiscTitle: string | undefined;
|
|
42
|
+
/** Disc number from embedded tags; present only when all tagged files in the disc agree. */
|
|
43
|
+
tagDiscNumber: number | undefined;
|
|
44
|
+
/** Confidence in the folder-name evidence; "high" for tag-derived discs. */
|
|
45
|
+
confidence: DiscConfidence;
|
|
46
|
+
/** Files sorted by (track, filename), each carrying its own resolution. */
|
|
47
|
+
items: AlbumGroupItem[];
|
|
48
|
+
}
|
|
49
|
+
export interface AlbumGroup {
|
|
50
|
+
/** Opaque, stable identity. See the key construction rule (identity step). */
|
|
51
|
+
key: AlbumGroupKey;
|
|
52
|
+
albumArtist: string | undefined;
|
|
53
|
+
album: string | undefined;
|
|
54
|
+
/** Where the album identity came from. */
|
|
55
|
+
source: "tags" | "folder";
|
|
56
|
+
/** Compilation evidence from embedded tags (COMPILATION/TCMP/cpil): true
|
|
57
|
+
* when the group's files agree and the flag is set, false when they agree
|
|
58
|
+
* and it is unset, undefined when tags are absent or disagree. */
|
|
59
|
+
compilation: boolean | undefined;
|
|
60
|
+
/** The album's directory: the common directory of all items when they
|
|
61
|
+
* share one (folder-derived albums always do), else undefined. */
|
|
62
|
+
directory: string | undefined;
|
|
63
|
+
/** One entry per resolved disc number; a single-disc album has one entry. */
|
|
64
|
+
discs: AlbumDisc[];
|
|
65
|
+
/** All files across discs, sorted by (discNumber, track, filename). */
|
|
66
|
+
items: AlbumGroupItem[];
|
|
67
|
+
}
|
|
68
|
+
export interface AlbumGroupingResult {
|
|
69
|
+
albums: AlbumGroup[];
|
|
70
|
+
/** Ok items that resolve to an album of exactly one file — a single, not
|
|
71
|
+
* an album. Cardinality is the only library rule. */
|
|
72
|
+
singles: AudioFileMetadata[];
|
|
73
|
+
/** Ok items not attributable to any album (untagged, no folder title evidence). */
|
|
74
|
+
unmatched: AudioFileMetadata[];
|
|
75
|
+
/** Per-file scan errors, surfaced exactly as the scan produced them. */
|
|
76
|
+
errors: Array<{
|
|
77
|
+
path: string;
|
|
78
|
+
error: Error;
|
|
79
|
+
}>;
|
|
80
|
+
}
|
|
81
|
+
export interface GroupAlbumsOptions {
|
|
82
|
+
/** Drop folder disc evidence below this tier. Default "low" (accept all). */
|
|
83
|
+
minFolderConfidence?: DiscConfidence;
|
|
84
|
+
/** Parse flat disc prefixes (1-01) from filenames. Default true. */
|
|
85
|
+
flatDiscPrefixes?: boolean;
|
|
86
|
+
/** Group untagged files by folder into albums. Default true. */
|
|
87
|
+
folderFallback?: boolean;
|
|
88
|
+
/** The directory the scan started at. When set, a bare disc folder
|
|
89
|
+
* (CD1/) directly under it is unmatched instead of an album named after
|
|
90
|
+
* the root. Default: no guard — the scan root cannot be inferred from a
|
|
91
|
+
* bare FolderScanResult (the common ancestor is the album folder, not the
|
|
92
|
+
* scanned root). scanForAlbums always passes the folder path it scanned. */
|
|
93
|
+
scanRoot?: string;
|
|
94
|
+
}
|
|
95
|
+
/** The standalone disc-folder recognizer result. */
|
|
96
|
+
export interface DiscFolderInfo {
|
|
97
|
+
/** Which grammar form matched. */
|
|
98
|
+
kind: "exact" | "embedded" | "volume" | "bonus" | "bare";
|
|
99
|
+
/** True when corroboration is required (title-word markers like "Tape 4",
|
|
100
|
+
* side letters like "CD D", bonus, bare). */
|
|
101
|
+
gated: boolean;
|
|
102
|
+
/** Disc number; undefined for side letters, bonus, and bare letters. */
|
|
103
|
+
number: number | undefined;
|
|
104
|
+
/** "of N" total from "CD 1 of 2". */
|
|
105
|
+
total: number | undefined;
|
|
106
|
+
/** Title text before an embedded marker ("Album (Disc 1)" -> "Album"). */
|
|
107
|
+
title: string | undefined;
|
|
108
|
+
/** Trailing text after the number ("Bonus Tracks" from "Disc 2 (Bonus Tracks)"). */
|
|
109
|
+
discTitle: string | undefined;
|
|
110
|
+
/** Base confidence tier; sibling corroboration may upgrade embedded to
|
|
111
|
+
* "high" inside groupAlbums. Gated names stay "low" even corroborated. */
|
|
112
|
+
confidence: DiscConfidence;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Recognize a directory name as a disc folder. Standalone — it only
|
|
116
|
+
* classifies the name; it does not resolve the folder to its parent (use
|
|
117
|
+
* the album result's per-file `albumDir` for that). Returns undefined for
|
|
118
|
+
* non-disc names. Plain "Bonus" and "Extras" are never discs by name.
|
|
119
|
+
*/
|
|
120
|
+
export declare function discFolderInfo(name: string): DiscFolderInfo | undefined;
|
|
121
|
+
export declare function groupAlbums(result: FolderScanResult, options?: GroupAlbumsOptions): AlbumGroupingResult;
|
|
122
|
+
//# sourceMappingURL=group-albums.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"group-albums.d.ts","sourceRoot":"","sources":["../../../src/folder-api/group-albums.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { basename } from "../utils/path.js";
|
|
2
|
+
function discFolderInfo(name) {
|
|
3
|
+
const parse = parseDiscName(name);
|
|
4
|
+
if (!parse) return void 0;
|
|
5
|
+
return {
|
|
6
|
+
kind: parse.kind,
|
|
7
|
+
gated: parse.gated,
|
|
8
|
+
number: parse.number,
|
|
9
|
+
total: parse.total,
|
|
10
|
+
title: parse.title,
|
|
11
|
+
discTitle: parse.discTitle,
|
|
12
|
+
confidence: parse.gated ? "low" : baseConfidence(parse)
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function dirname(path) {
|
|
16
|
+
const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
17
|
+
return i === -1 ? path : path.slice(0, i);
|
|
18
|
+
}
|
|
19
|
+
const UNCONDITIONAL_MARKERS = [
|
|
20
|
+
"cd",
|
|
21
|
+
"disc",
|
|
22
|
+
"disk",
|
|
23
|
+
"dvd",
|
|
24
|
+
"sacd",
|
|
25
|
+
"blu-ray",
|
|
26
|
+
"bd",
|
|
27
|
+
"digital media"
|
|
28
|
+
];
|
|
29
|
+
const GATED_MARKERS = ["tape", "vinyl", "cassette", "lp", "record"];
|
|
30
|
+
const MARKERS = [...UNCONDITIONAL_MARKERS, ...GATED_MARKERS];
|
|
31
|
+
const GATED_MARKER_SET = new Set(GATED_MARKERS);
|
|
32
|
+
const WORD_NUMBERS = {
|
|
33
|
+
one: 1,
|
|
34
|
+
two: 2,
|
|
35
|
+
three: 3,
|
|
36
|
+
four: 4,
|
|
37
|
+
five: 5,
|
|
38
|
+
six: 6,
|
|
39
|
+
seven: 7,
|
|
40
|
+
eight: 8,
|
|
41
|
+
nine: 9,
|
|
42
|
+
ten: 10,
|
|
43
|
+
eleven: 11,
|
|
44
|
+
twelve: 12
|
|
45
|
+
};
|
|
46
|
+
function romanToInt(s) {
|
|
47
|
+
const values = {
|
|
48
|
+
i: 1,
|
|
49
|
+
v: 5,
|
|
50
|
+
x: 10,
|
|
51
|
+
l: 50,
|
|
52
|
+
c: 100,
|
|
53
|
+
d: 500,
|
|
54
|
+
m: 1e3
|
|
55
|
+
};
|
|
56
|
+
let total = 0;
|
|
57
|
+
let prev = 0;
|
|
58
|
+
for (let i = s.length - 1; i >= 0; i--) {
|
|
59
|
+
const v = values[s[i]];
|
|
60
|
+
if (v === void 0) return NaN;
|
|
61
|
+
total += v < prev ? -v : v;
|
|
62
|
+
prev = v;
|
|
63
|
+
}
|
|
64
|
+
return total;
|
|
65
|
+
}
|
|
66
|
+
function parseNumberToken(token) {
|
|
67
|
+
if (/^\d+$/.test(token)) return parseInt(token, 10);
|
|
68
|
+
const word = WORD_NUMBERS[token.toLowerCase()];
|
|
69
|
+
if (word) return word;
|
|
70
|
+
const roman = romanToInt(token.toLowerCase());
|
|
71
|
+
return Number.isFinite(roman) ? roman : void 0;
|
|
72
|
+
}
|
|
73
|
+
const SEP = String.raw`[\s._#-]*`;
|
|
74
|
+
const NUM = String.raw`(?:\d+|[ivxlcdm]+|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|[a-z])`;
|
|
75
|
+
function parseDiscName(name) {
|
|
76
|
+
const trimmed = name.trim();
|
|
77
|
+
if (/^(?:extras?|bonus)$/i.test(trimmed)) return void 0;
|
|
78
|
+
for (const marker of MARKERS) {
|
|
79
|
+
const left = String.raw`(?:^|[^a-z0-9])`;
|
|
80
|
+
const esc = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
81
|
+
const re = new RegExp(left + String.raw`(${esc})${SEP}(${NUM})`, "i");
|
|
82
|
+
const m = trimmed.match(re);
|
|
83
|
+
if (!m) continue;
|
|
84
|
+
const sideLetter = /^[a-z]$/i.test(m[2]);
|
|
85
|
+
const number = sideLetter ? void 0 : parseNumberToken(m[2]);
|
|
86
|
+
if (number === void 0 && !sideLetter) continue;
|
|
87
|
+
const isGated = GATED_MARKER_SET.has(marker.toLowerCase()) || sideLetter;
|
|
88
|
+
const markerStart = m.index + m[1].length - marker.length;
|
|
89
|
+
const rawPrefix = trimmed.slice(0, markerStart).trim();
|
|
90
|
+
const title = rawPrefix.replace(/[()[\]]/g, "").replace(/[\s._#-]+$/, "") || void 0;
|
|
91
|
+
const isExact = title === void 0;
|
|
92
|
+
const after = trimmed.slice(m.index + m[0].length).trim();
|
|
93
|
+
const discTitle = after.replace(/^[()[\],:;\-–—\s]+/, "").replace(/[)\]\[,:;\-–—\s]+$/, "").trim() || void 0;
|
|
94
|
+
const ofMatch = after.trim().match(/^of\s+(\d+)\b/i);
|
|
95
|
+
const total = ofMatch ? parseInt(ofMatch[1], 10) : void 0;
|
|
96
|
+
return {
|
|
97
|
+
kind: isExact ? "exact" : "embedded",
|
|
98
|
+
number: sideLetter ? void 0 : number,
|
|
99
|
+
total,
|
|
100
|
+
title,
|
|
101
|
+
discTitle,
|
|
102
|
+
gated: isGated,
|
|
103
|
+
sideLetter,
|
|
104
|
+
digit: /^\d+$/.test(m[2])
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const volume = trimmed.match(
|
|
108
|
+
/^(?:volume|vol\.?|part|pt\.?)\s*(\d+)(?:\s*of\s*(\d+))?$/i
|
|
109
|
+
);
|
|
110
|
+
if (volume) {
|
|
111
|
+
return {
|
|
112
|
+
kind: "volume",
|
|
113
|
+
number: parseInt(volume[1], 10),
|
|
114
|
+
total: volume[2] ? parseInt(volume[2], 10) : void 0,
|
|
115
|
+
title: void 0,
|
|
116
|
+
discTitle: void 0,
|
|
117
|
+
gated: false,
|
|
118
|
+
sideLetter: false,
|
|
119
|
+
digit: true
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (/^bonus\s+disc$/i.test(trimmed)) {
|
|
123
|
+
return {
|
|
124
|
+
kind: "bonus",
|
|
125
|
+
number: void 0,
|
|
126
|
+
total: void 0,
|
|
127
|
+
title: void 0,
|
|
128
|
+
discTitle: void 0,
|
|
129
|
+
gated: true,
|
|
130
|
+
sideLetter: false,
|
|
131
|
+
digit: false
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (/^(?:\d+|v\d+|p\d+|[ab])$/i.test(trimmed)) {
|
|
135
|
+
return {
|
|
136
|
+
kind: "bare",
|
|
137
|
+
number: /^\d+$/i.test(trimmed) ? parseInt(trimmed, 10) : void 0,
|
|
138
|
+
total: void 0,
|
|
139
|
+
title: void 0,
|
|
140
|
+
discTitle: void 0,
|
|
141
|
+
gated: true,
|
|
142
|
+
sideLetter: false,
|
|
143
|
+
digit: false
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return void 0;
|
|
147
|
+
}
|
|
148
|
+
function baseConfidence(parse) {
|
|
149
|
+
switch (parse.kind) {
|
|
150
|
+
case "exact":
|
|
151
|
+
return parse.digit ? "high" : "low";
|
|
152
|
+
case "embedded":
|
|
153
|
+
return "medium";
|
|
154
|
+
case "volume":
|
|
155
|
+
return "medium";
|
|
156
|
+
case "bonus":
|
|
157
|
+
case "bare":
|
|
158
|
+
return "low";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function corroborated(parse, siblings) {
|
|
162
|
+
if (!parse.gated && parse.kind === "exact") return true;
|
|
163
|
+
if (siblings.length === 0) return false;
|
|
164
|
+
const siblingParses = siblings.map((s) => parseDiscName(basename(s))).filter((p) => p !== void 0);
|
|
165
|
+
const hasExactSibling = siblingParses.some(
|
|
166
|
+
(p) => !p.gated && p.kind === "exact"
|
|
167
|
+
);
|
|
168
|
+
const hasNumberedSibling = siblingParses.some(
|
|
169
|
+
(p) => p.number !== void 0
|
|
170
|
+
);
|
|
171
|
+
if (parse.number !== void 0 && hasNumberedSibling) return true;
|
|
172
|
+
if (!parse.gated && parse.kind === "embedded" && hasExactSibling) return true;
|
|
173
|
+
if (!parse.gated && parse.kind === "volume" && hasNumberedSibling) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
if (parse.sideLetter) {
|
|
177
|
+
return siblingParses.some((p) => p.sideLetter);
|
|
178
|
+
}
|
|
179
|
+
if (parse.kind === "bonus" || parse.kind === "bare") {
|
|
180
|
+
const numberedCount = siblingParses.filter((p) => p.number !== void 0).length;
|
|
181
|
+
if (numberedCount >= 2) return true;
|
|
182
|
+
if (parse.number !== void 0 && hasNumberedSibling) return true;
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
function flatPrefix(filename) {
|
|
187
|
+
const separated = filename.match(/^(\d{1,2})[\s._-](\d{1,2})/);
|
|
188
|
+
if (separated) return { disc: parseInt(separated[1], 10), form: "separated" };
|
|
189
|
+
const compact = filename.match(/^(\d)(\d{2})(?:\D|$)/);
|
|
190
|
+
if (compact) return { disc: parseInt(compact[1], 10), form: "compact" };
|
|
191
|
+
return void 0;
|
|
192
|
+
}
|
|
193
|
+
function normalizeKey(s) {
|
|
194
|
+
return s.trim().toLowerCase().replace(/\s+/g, " ");
|
|
195
|
+
}
|
|
196
|
+
function isGenericAlbumArtist(albumArtist) {
|
|
197
|
+
if (!albumArtist || albumArtist.trim() === "") return true;
|
|
198
|
+
const n = normalizeKey(albumArtist);
|
|
199
|
+
return n === "various artists" || n === "va";
|
|
200
|
+
}
|
|
201
|
+
const CONFIDENCE_RANK = {
|
|
202
|
+
low: 0,
|
|
203
|
+
medium: 1,
|
|
204
|
+
high: 2
|
|
205
|
+
};
|
|
206
|
+
function groupAlbums(result, options = {}) {
|
|
207
|
+
const minRank = CONFIDENCE_RANK[options.minFolderConfidence ?? "low"];
|
|
208
|
+
const useFlatPrefixes = options.flatDiscPrefixes ?? true;
|
|
209
|
+
const useFolderFallback = options.folderFallback ?? true;
|
|
210
|
+
const okItems = [];
|
|
211
|
+
const errors = [];
|
|
212
|
+
for (const item of result.items) {
|
|
213
|
+
if (item.status === "ok") {
|
|
214
|
+
okItems.push({ path: item.path, tags: item.tags, metadata: item });
|
|
215
|
+
} else {
|
|
216
|
+
errors.push({ path: item.path, error: item.error });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (okItems.length === 0) {
|
|
220
|
+
return { albums: [], singles: [], unmatched: [], errors };
|
|
221
|
+
}
|
|
222
|
+
const scanRoot = options.scanRoot;
|
|
223
|
+
const tree = /* @__PURE__ */ new Map();
|
|
224
|
+
for (const item of okItems) {
|
|
225
|
+
const dir = dirname(item.path);
|
|
226
|
+
let node = tree.get(dir);
|
|
227
|
+
if (!node) {
|
|
228
|
+
node = { files: [], parent: dirname(dir) };
|
|
229
|
+
tree.set(dir, node);
|
|
230
|
+
}
|
|
231
|
+
node.files.push(item);
|
|
232
|
+
}
|
|
233
|
+
const confirmedDiscs = /* @__PURE__ */ new Map();
|
|
234
|
+
for (const [dir, node] of tree) {
|
|
235
|
+
if (node.files.length === 0) continue;
|
|
236
|
+
const parse = parseDiscName(basename(dir));
|
|
237
|
+
if (!parse) continue;
|
|
238
|
+
const siblings = [...tree.keys()].filter(
|
|
239
|
+
(d) => d !== dir && dirname(d) === dirname(dir)
|
|
240
|
+
);
|
|
241
|
+
const corroboratedBySiblings = corroborated(parse, siblings);
|
|
242
|
+
let confidence = parse.gated ? "low" : baseConfidence(parse);
|
|
243
|
+
if (parse.gated) {
|
|
244
|
+
if (!corroboratedBySiblings) continue;
|
|
245
|
+
} else if (parse.kind === "bonus" || parse.kind === "bare") {
|
|
246
|
+
if (!corroboratedBySiblings) continue;
|
|
247
|
+
} else if (corroboratedBySiblings) {
|
|
248
|
+
confidence = "high";
|
|
249
|
+
}
|
|
250
|
+
if (CONFIDENCE_RANK[confidence] < minRank) continue;
|
|
251
|
+
confirmedDiscs.set(dir, { parse, confidence });
|
|
252
|
+
}
|
|
253
|
+
const flatSubdivisions = /* @__PURE__ */ new Map();
|
|
254
|
+
if (useFlatPrefixes) {
|
|
255
|
+
for (const [dir, node] of tree) {
|
|
256
|
+
if (node.files.length === 0 || confirmedDiscs.has(dir)) continue;
|
|
257
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
258
|
+
const forms = /* @__PURE__ */ new Set();
|
|
259
|
+
for (const item of node.files) {
|
|
260
|
+
const prefix = flatPrefix(basename(item.path));
|
|
261
|
+
if (prefix) {
|
|
262
|
+
byFile.set(item.path, prefix.disc);
|
|
263
|
+
forms.add(prefix.form);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (byFile.size < 2) continue;
|
|
267
|
+
const distinct = [...new Set(byFile.values())].sort((a, b) => a - b);
|
|
268
|
+
const allCompact = forms.size === 1 && forms.has("compact");
|
|
269
|
+
const plausible = allCompact ? distinct.every((d) => d >= 1 && d <= distinct.length) : true;
|
|
270
|
+
if (plausible) flatSubdivisions.set(dir, byFile);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const albums = /* @__PURE__ */ new Map();
|
|
274
|
+
const unmatched = [];
|
|
275
|
+
const evidenceFor = (item) => {
|
|
276
|
+
const dir = dirname(item.path);
|
|
277
|
+
const own = confirmedDiscs.get(dir);
|
|
278
|
+
if (own) {
|
|
279
|
+
return {
|
|
280
|
+
folderDiscNumber: own.parse.number,
|
|
281
|
+
total: own.parse.total,
|
|
282
|
+
folderDiscTitle: own.parse.discTitle,
|
|
283
|
+
confidence: own.confidence,
|
|
284
|
+
albumDir: dirname(dir)
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
let cursor = dirname(dir);
|
|
288
|
+
while (cursor !== void 0) {
|
|
289
|
+
const entry = confirmedDiscs.get(cursor);
|
|
290
|
+
if (entry) {
|
|
291
|
+
return {
|
|
292
|
+
folderDiscNumber: entry.parse.number,
|
|
293
|
+
total: entry.parse.total,
|
|
294
|
+
folderDiscTitle: entry.parse.discTitle,
|
|
295
|
+
confidence: entry.confidence,
|
|
296
|
+
albumDir: dirname(cursor)
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
const parent = dirname(cursor);
|
|
300
|
+
if (parent === cursor) break;
|
|
301
|
+
cursor = parent;
|
|
302
|
+
}
|
|
303
|
+
const flat = flatSubdivisions.get(dir)?.get(item.path);
|
|
304
|
+
if (flat !== void 0) {
|
|
305
|
+
return {
|
|
306
|
+
folderDiscNumber: flat,
|
|
307
|
+
total: void 0,
|
|
308
|
+
folderDiscTitle: void 0,
|
|
309
|
+
confidence: "medium",
|
|
310
|
+
albumDir: dir
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
return void 0;
|
|
314
|
+
};
|
|
315
|
+
const folderIdentity = (dir) => {
|
|
316
|
+
const own = confirmedDiscs.get(dir);
|
|
317
|
+
if (!own) {
|
|
318
|
+
const title2 = basename(dir);
|
|
319
|
+
return title2 ? { title: title2, titleSource: dir } : void 0;
|
|
320
|
+
}
|
|
321
|
+
if (own.parse.title !== void 0) {
|
|
322
|
+
return { title: own.parse.title, titleSource: dirname(dir) };
|
|
323
|
+
}
|
|
324
|
+
const source = dirname(dir);
|
|
325
|
+
if (source === scanRoot || source === dir) return void 0;
|
|
326
|
+
const title = basename(source);
|
|
327
|
+
return title ? { title, titleSource: source } : void 0;
|
|
328
|
+
};
|
|
329
|
+
for (const item of okItems) {
|
|
330
|
+
const dir = dirname(item.path);
|
|
331
|
+
const albumTag = item.tags.album?.[0]?.trim();
|
|
332
|
+
const albumArtistTag = item.tags.albumArtist?.[0]?.trim();
|
|
333
|
+
if (albumTag) {
|
|
334
|
+
const artistPart = isGenericAlbumArtist(albumArtistTag) ? "" : normalizeKey(albumArtistTag ?? "");
|
|
335
|
+
const key2 = `${artistPart}::${normalizeKey(albumTag)}`;
|
|
336
|
+
let acc2 = albums.get(key2);
|
|
337
|
+
if (!acc2) {
|
|
338
|
+
acc2 = {
|
|
339
|
+
key: key2,
|
|
340
|
+
album: albumTag,
|
|
341
|
+
albumArtist: isGenericAlbumArtist(albumArtistTag) ? void 0 : albumArtistTag,
|
|
342
|
+
source: "tags",
|
|
343
|
+
directory: void 0,
|
|
344
|
+
items: [],
|
|
345
|
+
evidence: /* @__PURE__ */ new Map()
|
|
346
|
+
};
|
|
347
|
+
albums.set(key2, acc2);
|
|
348
|
+
}
|
|
349
|
+
const evidence2 = evidenceFor(item);
|
|
350
|
+
acc2.evidence.set(item.path, evidence2);
|
|
351
|
+
acc2.items.push({
|
|
352
|
+
...item.metadata,
|
|
353
|
+
albumDir: evidence2?.albumDir ?? dir,
|
|
354
|
+
discNumber: evidence2?.folderDiscNumber
|
|
355
|
+
});
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (!useFolderFallback) {
|
|
359
|
+
unmatched.push(item.metadata);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const identity = folderIdentity(dir);
|
|
363
|
+
if (!identity) {
|
|
364
|
+
unmatched.push(item.metadata);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
const key = `${identity.titleSource}::${normalizeKey(identity.title)}`;
|
|
368
|
+
let acc = albums.get(key);
|
|
369
|
+
if (!acc) {
|
|
370
|
+
acc = {
|
|
371
|
+
key,
|
|
372
|
+
album: identity.title,
|
|
373
|
+
albumArtist: void 0,
|
|
374
|
+
source: "folder",
|
|
375
|
+
directory: identity.titleSource,
|
|
376
|
+
items: [],
|
|
377
|
+
evidence: /* @__PURE__ */ new Map()
|
|
378
|
+
};
|
|
379
|
+
albums.set(key, acc);
|
|
380
|
+
}
|
|
381
|
+
const evidence = evidenceFor(item);
|
|
382
|
+
acc.evidence.set(item.path, evidence);
|
|
383
|
+
acc.items.push({
|
|
384
|
+
...item.metadata,
|
|
385
|
+
albumDir: evidence?.albumDir ?? dir,
|
|
386
|
+
discNumber: evidence?.folderDiscNumber
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
for (const [key, acc] of [...albums]) {
|
|
390
|
+
if (acc.source === "tags") continue;
|
|
391
|
+
for (const other of albums.values()) {
|
|
392
|
+
if (other === acc || other.source !== "tags") continue;
|
|
393
|
+
if (normalizeKey(other.album) !== normalizeKey(acc.album)) continue;
|
|
394
|
+
const albumDir = acc.directory;
|
|
395
|
+
const sharesChain = albumDir !== void 0 && other.items.some((o) => {
|
|
396
|
+
const od = dirname(o.path);
|
|
397
|
+
return od === albumDir || od.startsWith(albumDir + "/");
|
|
398
|
+
});
|
|
399
|
+
if (!sharesChain) continue;
|
|
400
|
+
for (const item of acc.items) {
|
|
401
|
+
other.items.push(item);
|
|
402
|
+
other.evidence.set(item.path, acc.evidence.get(item.path));
|
|
403
|
+
}
|
|
404
|
+
albums.delete(key);
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const groups = [];
|
|
409
|
+
for (const acc of albums.values()) {
|
|
410
|
+
const dirTagCommon = /* @__PURE__ */ new Map();
|
|
411
|
+
const resolvedByFile = /* @__PURE__ */ new Map();
|
|
412
|
+
for (const item of acc.items) {
|
|
413
|
+
const dir = dirname(item.path);
|
|
414
|
+
if (!dirTagCommon.has(dir)) {
|
|
415
|
+
const tagged = acc.items.filter((i) => dirname(i.path) === dir).map((i) => i.tags.discNumber).filter((d) => d !== void 0 && d > 0);
|
|
416
|
+
dirTagCommon.set(
|
|
417
|
+
dir,
|
|
418
|
+
tagged.length > 0 && tagged.every((d) => d === tagged[0]) ? tagged[0] : void 0
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
const evidence = acc.evidence.get(item.path);
|
|
422
|
+
const tagCommon = dirTagCommon.get(dir);
|
|
423
|
+
resolvedByFile.set(
|
|
424
|
+
item.path,
|
|
425
|
+
tagCommon ?? evidence?.folderDiscNumber
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
const discs = /* @__PURE__ */ new Map();
|
|
429
|
+
const order = [];
|
|
430
|
+
const noEvidenceItems = [];
|
|
431
|
+
for (const item of acc.items) {
|
|
432
|
+
const evidence = acc.evidence.get(item.path);
|
|
433
|
+
if (evidence === void 0) {
|
|
434
|
+
noEvidenceItems.push(item);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const resolved = resolvedByFile.get(item.path);
|
|
438
|
+
const discKey = resolved === void 0 ? `dir:${dirname(item.path)}` : String(resolved);
|
|
439
|
+
let disc = discs.get(discKey);
|
|
440
|
+
if (!disc) {
|
|
441
|
+
disc = {
|
|
442
|
+
discNumber: resolved,
|
|
443
|
+
totalDiscs: evidence.total,
|
|
444
|
+
folderDiscNumber: evidence.folderDiscNumber,
|
|
445
|
+
folderDiscTitle: evidence.folderDiscTitle,
|
|
446
|
+
tagDiscNumber: void 0,
|
|
447
|
+
confidence: evidence.confidence,
|
|
448
|
+
items: []
|
|
449
|
+
};
|
|
450
|
+
discs.set(discKey, disc);
|
|
451
|
+
order.push(discKey);
|
|
452
|
+
}
|
|
453
|
+
disc.items.push(item);
|
|
454
|
+
}
|
|
455
|
+
if (noEvidenceItems.length > 0) {
|
|
456
|
+
const numbered = [...discs.values()].filter((d) => d.folderDiscNumber !== void 0).sort((a, b) => a.folderDiscNumber - b.folderDiscNumber);
|
|
457
|
+
if (numbered.length > 0) {
|
|
458
|
+
numbered[0].items.push(...noEvidenceItems);
|
|
459
|
+
} else {
|
|
460
|
+
const key = "dir:__none__";
|
|
461
|
+
let disc = discs.get(key);
|
|
462
|
+
if (!disc) {
|
|
463
|
+
disc = {
|
|
464
|
+
discNumber: void 0,
|
|
465
|
+
totalDiscs: void 0,
|
|
466
|
+
folderDiscNumber: void 0,
|
|
467
|
+
folderDiscTitle: void 0,
|
|
468
|
+
tagDiscNumber: void 0,
|
|
469
|
+
confidence: "high",
|
|
470
|
+
items: []
|
|
471
|
+
};
|
|
472
|
+
discs.set(key, disc);
|
|
473
|
+
order.push(key);
|
|
474
|
+
}
|
|
475
|
+
disc.items.push(...noEvidenceItems);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
for (const disc of discs.values()) {
|
|
479
|
+
const tagged = disc.items.map((i) => i.tags.discNumber).filter((d) => d !== void 0 && d > 0);
|
|
480
|
+
const common = tagged.length > 0 && tagged.every((d) => d === tagged[0]) ? tagged[0] : void 0;
|
|
481
|
+
disc.tagDiscNumber = common;
|
|
482
|
+
disc.discNumber = common ?? disc.folderDiscNumber;
|
|
483
|
+
for (const item of disc.items) item.discNumber = disc.discNumber;
|
|
484
|
+
}
|
|
485
|
+
const albumTaggedTotals = acc.items.map((i) => i.tags.totalDiscs).filter((t) => t !== void 0 && t > 0);
|
|
486
|
+
const commonTotal = albumTaggedTotals.length > 0 && albumTaggedTotals.every((t) => t === albumTaggedTotals[0]) ? albumTaggedTotals[0] : void 0;
|
|
487
|
+
const ofN = [...discs.values()].map((d) => d.totalDiscs).find(
|
|
488
|
+
(t) => t !== void 0
|
|
489
|
+
);
|
|
490
|
+
const maxSibling = [...discs.values()].map((d) => d.discNumber).filter((n) => n !== void 0).reduce((a, b) => Math.max(a, b), 0) || void 0;
|
|
491
|
+
for (const disc of discs.values()) {
|
|
492
|
+
disc.totalDiscs = commonTotal ?? ofN ?? maxSibling;
|
|
493
|
+
}
|
|
494
|
+
const sortedDiscs = [...discs.values()].sort((a, b) => {
|
|
495
|
+
const an = a.discNumber === void 0 ? -1 : a.discNumber;
|
|
496
|
+
const bn = b.discNumber === void 0 ? -1 : b.discNumber;
|
|
497
|
+
if (an !== bn) return an - bn;
|
|
498
|
+
return (a.folderDiscTitle ?? "").localeCompare(b.folderDiscTitle ?? "");
|
|
499
|
+
});
|
|
500
|
+
for (const disc of sortedDiscs) {
|
|
501
|
+
disc.items.sort((a, b) => {
|
|
502
|
+
const at = a.tags.track ?? Infinity;
|
|
503
|
+
const bt = b.tags.track ?? Infinity;
|
|
504
|
+
if (at !== bt) return at - bt;
|
|
505
|
+
return a.path.localeCompare(b.path);
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
const flags = acc.items.map((i) => i.tags.compilation).filter((c) => c !== void 0);
|
|
509
|
+
const compilation = flags.length > 0 && flags.every((f) => f === flags[0]) ? flags[0] : void 0;
|
|
510
|
+
const albumDirs = new Set(acc.items.map((i) => i.albumDir));
|
|
511
|
+
const directory = albumDirs.size === 1 ? [...albumDirs][0] : acc.directory;
|
|
512
|
+
groups.push({
|
|
513
|
+
key: acc.key,
|
|
514
|
+
albumArtist: acc.albumArtist,
|
|
515
|
+
album: acc.album,
|
|
516
|
+
source: acc.source,
|
|
517
|
+
compilation,
|
|
518
|
+
directory,
|
|
519
|
+
discs: sortedDiscs,
|
|
520
|
+
items: sortedDiscs.flatMap((d) => d.items)
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
const albumsOut = [];
|
|
524
|
+
const singles = [];
|
|
525
|
+
for (const group of groups) {
|
|
526
|
+
if (group.items.length === 1) {
|
|
527
|
+
singles.push(group.items[0]);
|
|
528
|
+
} else {
|
|
529
|
+
albumsOut.push(group);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
albumsOut.sort(
|
|
533
|
+
(a, b) => (a.albumArtist ?? "").localeCompare(b.albumArtist ?? "") || (a.album ?? "").localeCompare(b.album ?? "")
|
|
534
|
+
);
|
|
535
|
+
return { albums: albumsOut, singles, unmatched, errors };
|
|
536
|
+
}
|
|
537
|
+
export {
|
|
538
|
+
discFolderInfo,
|
|
539
|
+
groupAlbums
|
|
540
|
+
};
|
|
@@ -5,4 +5,6 @@
|
|
|
5
5
|
export type { AudioDynamics, AudioFileMetadata, DuplicateGroup, FolderScanItem, FolderScanOptions, FolderScanResult, FolderUpdateItem, FolderUpdateResult, } from "./types.js";
|
|
6
6
|
export { scanFolder } from "./scan-operations.js";
|
|
7
7
|
export { exportFolderMetadata, findDuplicates, updateFolderTags, } from "./folder-operations.js";
|
|
8
|
+
export { type AlbumDisc, type AlbumGroup, type AlbumGroupingResult, type AlbumGroupItem, type AlbumGroupKey, type DiscConfidence, type DiscFolderInfo, discFolderInfo, groupAlbums, type GroupAlbumsOptions, } from "./group-albums.js";
|
|
9
|
+
export { scanForAlbums, type ScanForAlbumsOptions } from "./scan-for-albums.js";
|
|
8
10
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/folder-api/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,YAAY,EACV,aAAa,EACb,iBAAiB,EACjB,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,kBAAkB,GACnB,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAElD,OAAO,EACL,oBAAoB,EACpB,cAAc,EACd,gBAAgB,GACjB,MAAM,wBAAwB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/folder-api/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,YAAY,EACV,aAAa,EACb,iBAAiB,EACjB,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,kBAAkB,GACnB,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAElD,OAAO,EACL,oBAAoB,EACpB,cAAc,EACd,gBAAgB,GACjB,MAAM,wBAAwB,CAAC;AAEhC,OAAO,EACL,KAAK,SAAS,EACd,KAAK,UAAU,EACf,KAAK,mBAAmB,EACxB,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,cAAc,EACd,WAAW,EACX,KAAK,kBAAkB,GACxB,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAE,aAAa,EAAE,KAAK,oBAAoB,EAAE,MAAM,sBAAsB,CAAC"}
|
|
@@ -4,9 +4,17 @@ import {
|
|
|
4
4
|
findDuplicates,
|
|
5
5
|
updateFolderTags
|
|
6
6
|
} from "./folder-operations.js";
|
|
7
|
+
import {
|
|
8
|
+
discFolderInfo,
|
|
9
|
+
groupAlbums
|
|
10
|
+
} from "./group-albums.js";
|
|
11
|
+
import { scanForAlbums } from "./scan-for-albums.js";
|
|
7
12
|
export {
|
|
13
|
+
discFolderInfo,
|
|
8
14
|
exportFolderMetadata,
|
|
9
15
|
findDuplicates,
|
|
16
|
+
groupAlbums,
|
|
10
17
|
scanFolder,
|
|
18
|
+
scanForAlbums,
|
|
11
19
|
updateFolderTags
|
|
12
20
|
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Thin async wrapper: scan a folder, then group the scan into
|
|
3
|
+
* albums. Deno/Node/Bun only, like scanFolder — groupAlbums itself is pure
|
|
4
|
+
* and runtime-agnostic.
|
|
5
|
+
*/
|
|
6
|
+
import type { FolderScanOptions } from "./types.js";
|
|
7
|
+
import { type AlbumGroupingResult, type GroupAlbumsOptions } from "./group-albums.js";
|
|
8
|
+
export type ScanForAlbumsOptions = FolderScanOptions & GroupAlbumsOptions;
|
|
9
|
+
/**
|
|
10
|
+
* Scan a folder and group the result into albums with disc subdivisions.
|
|
11
|
+
*
|
|
12
|
+
* Forwards every {@link FolderScanOptions} (recursive, extensions, maxFiles,
|
|
13
|
+
* onProgress, includeProperties, continueOnError, criteria, signal) to the
|
|
14
|
+
* scan; grouping options (minFolderConfidence, flatDiscPrefixes,
|
|
15
|
+
* folderFallback) apply to {@link groupAlbums}.
|
|
16
|
+
*
|
|
17
|
+
* @throws the same errors scanFolder throws (permission, missing path, abort).
|
|
18
|
+
*/
|
|
19
|
+
export declare function scanForAlbums(folderPath: string, options?: ScanForAlbumsOptions): Promise<AlbumGroupingResult>;
|
|
20
|
+
//# sourceMappingURL=scan-for-albums.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scan-for-albums.d.ts","sourceRoot":"","sources":["../../../src/folder-api/scan-for-albums.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEpD,OAAO,EACL,KAAK,mBAAmB,EAExB,KAAK,kBAAkB,EACxB,MAAM,mBAAmB,CAAC;AAE3B,MAAM,MAAM,oBAAoB,GAAG,iBAAiB,GAAG,kBAAkB,CAAC;AAE1E;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CACjC,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,mBAAmB,CAAC,CAc9B"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { scanFolder } from "./scan-operations.js";
|
|
2
|
+
import {
|
|
3
|
+
groupAlbums
|
|
4
|
+
} from "./group-albums.js";
|
|
5
|
+
async function scanForAlbums(folderPath, options = {}) {
|
|
6
|
+
const {
|
|
7
|
+
minFolderConfidence,
|
|
8
|
+
flatDiscPrefixes,
|
|
9
|
+
folderFallback,
|
|
10
|
+
...scanOptions
|
|
11
|
+
} = options;
|
|
12
|
+
const scan = await scanFolder(folderPath, scanOptions);
|
|
13
|
+
return groupAlbums(scan, {
|
|
14
|
+
minFolderConfidence,
|
|
15
|
+
flatDiscPrefixes,
|
|
16
|
+
folderFallback,
|
|
17
|
+
scanRoot: folderPath
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export {
|
|
21
|
+
scanForAlbums
|
|
22
|
+
};
|
|
@@ -32,4 +32,15 @@ export interface LoadTagLibOptions {
|
|
|
32
32
|
*/
|
|
33
33
|
disableOptimizations?: boolean;
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Describe why `wasmBinary` is not a WebAssembly module, or `null` if it is.
|
|
37
|
+
*
|
|
38
|
+
* Both loader entry points validate this before handing the buffer to
|
|
39
|
+
* Emscripten. Left to the glue, a bad buffer surfaces as an asynchronous
|
|
40
|
+
* `Aborted(CompileError: ...)` written straight to the console AFTER the
|
|
41
|
+
* returned promise settles — so a caller can neither attach it to their own
|
|
42
|
+
* failure nor suppress it. The two entry points raise different error types,
|
|
43
|
+
* hence a shared predicate rather than a shared throw.
|
|
44
|
+
*/
|
|
45
|
+
export declare function describeNonWasmBinary(binary: ArrayBuffer | Uint8Array): string | null;
|
|
35
46
|
//# sourceMappingURL=loader-types.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"loader-types.d.ts","sourceRoot":"","sources":["../../../src/runtime/loader-types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,WAAW,GAAG,UAAU,CAAC;IAEtC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,GAAG,YAAY,CAAC;IAEtC;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC"}
|
|
1
|
+
{"version":3,"file":"loader-types.d.ts","sourceRoot":"","sources":["../../../src/runtime/loader-types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,WAAW,GAAG,UAAU,CAAC;IAEtC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,GAAG,YAAY,CAAC;IAEtC;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,WAAW,GAAG,UAAU,GAC/B,MAAM,GAAG,IAAI,CASf"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
function describeNonWasmBinary(binary) {
|
|
2
|
+
const bytes = binary instanceof Uint8Array ? binary : new Uint8Array(binary);
|
|
3
|
+
const MAGIC = [0, 97, 115, 109];
|
|
4
|
+
if (bytes.length >= 4 && MAGIC.every((b, i) => bytes[i] === b)) return null;
|
|
5
|
+
const found = Array.from(bytes.slice(0, 4)).map((b) => b.toString(16).padStart(2, "0")).join(" ");
|
|
6
|
+
return `wasmBinary is not a WebAssembly module: expected magic bytes 00 61 73 6d, found ${found || "(empty)"}. Size: ${bytes.length} bytes`;
|
|
7
|
+
}
|
|
8
|
+
export {
|
|
9
|
+
describeNonWasmBinary
|
|
10
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"module-loader.d.ts","sourceRoot":"","sources":["../../../src/runtime/module-loader.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"module-loader.d.ts","sourceRoot":"","sources":["../../../src/runtime/module-loader.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAE3D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAS/C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,CAAC,EAAE,iBAAiB,GAC1B,OAAO,CAAC,YAAY,CAAC,CA8EvB"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { describeNonWasmBinary } from "./loader-types.js";
|
|
1
2
|
import {
|
|
2
3
|
EnvironmentError,
|
|
3
4
|
errorMessage,
|
|
@@ -18,6 +19,14 @@ async function loadTagLibModule(options) {
|
|
|
18
19
|
{ forceWasmType: "wasi" }
|
|
19
20
|
);
|
|
20
21
|
}
|
|
22
|
+
if (options?.wasmBinary) {
|
|
23
|
+
const problem = describeNonWasmBinary(options.wasmBinary);
|
|
24
|
+
if (problem) {
|
|
25
|
+
throw new TagLibInitializationError(problem, {
|
|
26
|
+
byteLength: options.wasmBinary.byteLength
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
21
30
|
if (!options?.wasmBinary && !options?.wasmUrl && !options?.forceWasmType && isDenoCompiled()) {
|
|
22
31
|
const wasmBinary = await tryLoadEmbeddedWasm();
|
|
23
32
|
if (!wasmBinary) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../../../../src/runtime/unified-loader/loader.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../../../../src/runtime/unified-loader/loader.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAM5E,wBAAsB,uBAAuB,CAC3C,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,mBAAmB,CAAC,CAiD9B"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { detectRuntime } from "../detector.js";
|
|
2
2
|
import { ModuleLoadError } from "./types.js";
|
|
3
|
+
import { describeNonWasmBinary } from "../loader-types.js";
|
|
3
4
|
import { selectWasmType } from "./module-selection.js";
|
|
4
5
|
import { loadModule } from "./module-loading.js";
|
|
5
6
|
async function loadUnifiedTagLibModule(options = {}) {
|
|
@@ -9,6 +10,10 @@ async function loadUnifiedTagLibModule(options = {}) {
|
|
|
9
10
|
"wasi"
|
|
10
11
|
);
|
|
11
12
|
}
|
|
13
|
+
if (options.wasmBinary) {
|
|
14
|
+
const problem = describeNonWasmBinary(options.wasmBinary);
|
|
15
|
+
if (problem) throw new ModuleLoadError(problem, "emscripten");
|
|
16
|
+
}
|
|
12
17
|
const startTime = performance.now();
|
|
13
18
|
const runtime = detectRuntime();
|
|
14
19
|
if (options.debug) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-handle.d.ts","sourceRoot":"","sources":["../../../../src/runtime/wasi-adapter/file-handle.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EACV,UAAU,EACV,UAAU,EACV,aAAa,EACb,SAAS,EACT,UAAU,EACX,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,KAAK,EAEV,eAAe,EAEhB,MAAM,gBAAgB,CAAC;AACxB,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,+BAA+B,CAAC;
|
|
1
|
+
{"version":3,"file":"file-handle.d.ts","sourceRoot":"","sources":["../../../../src/runtime/wasi-adapter/file-handle.ts"],"names":[],"mappings":"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"}
|
|
@@ -54,6 +54,15 @@ const INTERNAL_KEYS = /* @__PURE__ */ new Set([
|
|
|
54
54
|
// Exact MP4 atom names for the write path, not a readable property.
|
|
55
55
|
"_mp4ItemNames"
|
|
56
56
|
]);
|
|
57
|
+
function preserveEmptyValues(data) {
|
|
58
|
+
for (const [key, value] of Object.entries(data)) {
|
|
59
|
+
if (value !== "") continue;
|
|
60
|
+
if (AUDIO_KEYS.has(key) || INTERNAL_KEYS.has(key)) continue;
|
|
61
|
+
if (key.startsWith("----:")) continue;
|
|
62
|
+
data[key] = [""];
|
|
63
|
+
}
|
|
64
|
+
return data;
|
|
65
|
+
}
|
|
57
66
|
const CONTAINER_TO_FORMAT = {
|
|
58
67
|
MP3: "MP3",
|
|
59
68
|
MP4: "MP4",
|
|
@@ -92,14 +101,18 @@ class WasiFileHandle {
|
|
|
92
101
|
this.checkNotDestroyed();
|
|
93
102
|
this.fileData = buffer;
|
|
94
103
|
const msgpackData = readTagsFromWasm(this.wasi, buffer);
|
|
95
|
-
this.tagData =
|
|
104
|
+
this.tagData = preserveEmptyValues(
|
|
105
|
+
decodeTagData(msgpackData)
|
|
106
|
+
);
|
|
96
107
|
return true;
|
|
97
108
|
}
|
|
98
109
|
loadFromPath(path) {
|
|
99
110
|
this.checkNotDestroyed();
|
|
100
111
|
this.filePath = path;
|
|
101
112
|
const msgpackData = readTagsFromWasmPath(this.wasi, path);
|
|
102
|
-
this.tagData =
|
|
113
|
+
this.tagData = preserveEmptyValues(
|
|
114
|
+
decodeTagData(msgpackData)
|
|
115
|
+
);
|
|
103
116
|
return true;
|
|
104
117
|
}
|
|
105
118
|
isValid() {
|
package/dist/src/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION = "1.
|
|
1
|
+
export declare const VERSION = "1.7.1";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/src/version.js
CHANGED
package/dist/taglib-wasi.wasm
CHANGED
|
Binary file
|
package/dist/taglib-web.wasm
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taglib-wasm",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1",
|
|
4
4
|
"description": "TagLib-Wasm is the universal tagging library for TypeScript/JavaScript platforms: Browsers, Node.js, Deno, Bun, Cloudflare Workers, and Electron apps",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|