funoteka 0.1.0
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/.env.example +90 -0
- package/CHANGELOG.md +104 -0
- package/DEPLOY.md +375 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/deploy/answers.schema.json +151 -0
- package/deploy/build.mjs +88 -0
- package/deploy/check-publishable.mjs +246 -0
- package/deploy/smoke.mjs +681 -0
- package/deploy/systemd/funoteka.env.example +41 -0
- package/deploy/systemd/funoteka.service +77 -0
- package/deploy/windows/funoteka-service.xml +58 -0
- package/deploy/windows/install-service.ps1 +158 -0
- package/dist/annotation/store.js +67 -0
- package/dist/api/admin-export.js +227 -0
- package/dist/api/admin-guard.js +181 -0
- package/dist/api/admin-library.js +113 -0
- package/dist/api/admin.js +1162 -0
- package/dist/api/annotation.js +193 -0
- package/dist/api/artistinfo.js +140 -0
- package/dist/api/audit.js +32 -0
- package/dist/api/auth.js +145 -0
- package/dist/api/bookmark.js +108 -0
- package/dist/api/browse.js +1241 -0
- package/dist/api/config-file.js +202 -0
- package/dist/api/config.js +285 -0
- package/dist/api/cover.js +304 -0
- package/dist/api/download.js +112 -0
- package/dist/api/envelope.js +162 -0
- package/dist/api/extensions.js +43 -0
- package/dist/api/genre.js +81 -0
- package/dist/api/health.js +35 -0
- package/dist/api/history.js +374 -0
- package/dist/api/idempotency.js +66 -0
- package/dist/api/keys.js +90 -0
- package/dist/api/log-file.js +65 -0
- package/dist/api/meta.js +1410 -0
- package/dist/api/playlist.js +311 -0
- package/dist/api/rescan.js +196 -0
- package/dist/api/roots.js +161 -0
- package/dist/api/router.js +133 -0
- package/dist/api/scanner.js +163 -0
- package/dist/api/search.js +82 -0
- package/dist/api/server.js +643 -0
- package/dist/api/settings.js +67 -0
- package/dist/api/stream.js +442 -0
- package/dist/api/stubs.js +183 -0
- package/dist/api/transcode.js +678 -0
- package/dist/api/user.js +73 -0
- package/dist/api/virtual.js +362 -0
- package/dist/api/visibility.js +21 -0
- package/dist/artist/apply.js +752 -0
- package/dist/artist/credit.js +95 -0
- package/dist/artist/folder.js +65 -0
- package/dist/artist/name.js +205 -0
- package/dist/artist/nfo.js +101 -0
- package/dist/artist/translit.js +79 -0
- package/dist/bookmark/store.js +54 -0
- package/dist/classify/classify.js +405 -0
- package/dist/classify/collision-name.js +109 -0
- package/dist/classify/folder-name.js +466 -0
- package/dist/classify/roles.js +277 -0
- package/dist/classify/shelf-name.js +178 -0
- package/dist/classify/tree.js +43 -0
- package/dist/cli/args.js +169 -0
- package/dist/cli/daemon.js +192 -0
- package/dist/cli/entry.js +25 -0
- package/dist/cli/keys.js +52 -0
- package/dist/cli.js +638 -0
- package/dist/cover/pick.js +58 -0
- package/dist/cover/picture.js +111 -0
- package/dist/cue/engine.js +759 -0
- package/dist/cue/match.js +142 -0
- package/dist/cue/parse.js +182 -0
- package/dist/cue/plan.js +286 -0
- package/dist/cue/track-name.js +120 -0
- package/dist/db/index.js +163 -0
- package/dist/db/issue.js +73 -0
- package/dist/db/ledger.js +84 -0
- package/dist/db/migrations/001_init.sql +196 -0
- package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
- package/dist/db/migrations/003_cue_metadata.sql +14 -0
- package/dist/db/migrations/004_cue_performer.sql +12 -0
- package/dist/db/migrations/005_incremental.sql +23 -0
- package/dist/db/migrations/006_tags.sql +57 -0
- package/dist/db/migrations/007_artist_credit.sql +35 -0
- package/dist/db/migrations/008_issue_stage.sql +39 -0
- package/dist/db/migrations/009_cue_title.sql +20 -0
- package/dist/db/migrations/010_duration_source.sql +31 -0
- package/dist/db/migrations/011_track_title_source.sql +28 -0
- package/dist/db/migrations/012_file_container.sql +25 -0
- package/dist/db/migrations/013_cover_art.sql +28 -0
- package/dist/db/migrations/014_album_year.sql +17 -0
- package/dist/db/migrations/015_probe_method.sql +14 -0
- package/dist/db/migrations/016_tags_method.sql +20 -0
- package/dist/db/migrations/017_cover_indirect.sql +23 -0
- package/dist/db/migrations/018_release_year.sql +14 -0
- package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
- package/dist/db/migrations/020_analyze_after_index.sql +16 -0
- package/dist/db/migrations/021_cue_index_files.sql +15 -0
- package/dist/db/migrations/022_sidecar_text.sql +19 -0
- package/dist/db/migrations/023_track_credit.sql +23 -0
- package/dist/db/migrations/024_playlists.sql +63 -0
- package/dist/db/migrations/025_playlist_sequence.sql +27 -0
- package/dist/db/migrations/026_playlist_source_file.sql +19 -0
- package/dist/db/migrations/027_lookup_indexes.sql +30 -0
- package/dist/db/migrations/028_annotations.sql +47 -0
- package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
- package/dist/db/migrations/030_history.sql +108 -0
- package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
- package/dist/db/migrations/032_bookmarks.sql +27 -0
- package/dist/db/migrations/033_bookmark_order.sql +14 -0
- package/dist/db/migrations/034_junk.sql +33 -0
- package/dist/db/migrations/035_api_key.sql +38 -0
- package/dist/db/migrations/036_file_tag_first.sql +88 -0
- package/dist/db/migrations/037_admin_idempotency.sql +31 -0
- package/dist/db/migrations/038_cue_audio_index.sql +24 -0
- package/dist/db/sweep.js +109 -0
- package/dist/history/store.js +151 -0
- package/dist/inventory/inventory.js +294 -0
- package/dist/junk/marks.js +143 -0
- package/dist/junk/rule.js +93 -0
- package/dist/mcp/client.js +48 -0
- package/dist/mcp/server.js +175 -0
- package/dist/mcp/tools.js +348 -0
- package/dist/playlist/files.js +116 -0
- package/dist/playlist/import.js +260 -0
- package/dist/playlist/store.js +240 -0
- package/dist/probe/ffprobe.js +179 -0
- package/dist/run.js +120 -0
- package/dist/scan/kinds.js +49 -0
- package/dist/scan/scan.js +503 -0
- package/dist/scan/schedule.js +132 -0
- package/dist/scan/settle.js +172 -0
- package/dist/scan/walk.js +132 -0
- package/dist/search/index.js +41 -0
- package/dist/search/query.js +28 -0
- package/dist/stream/flac.js +541 -0
- package/dist/stream/mpeg.js +179 -0
- package/dist/stream/recode.js +393 -0
- package/dist/stream/rewrite.js +163 -0
- package/dist/stream/segment.js +624 -0
- package/dist/tags/apply.js +703 -0
- package/dist/tags/encode.js +164 -0
- package/dist/tags/first.js +130 -0
- package/dist/tags/flac.js +196 -0
- package/dist/tags/genres.js +123 -0
- package/dist/tags/id3v1.js +209 -0
- package/dist/tags/id3v2.js +754 -0
- package/dist/tags/mp4.js +534 -0
- package/dist/tags/mpeg.js +355 -0
- package/dist/tags/ogg.js +463 -0
- package/dist/tags/picture.js +71 -0
- package/dist/tags/read.js +269 -0
- package/dist/tags/types.js +51 -0
- package/dist/tags/vorbis-comment.js +155 -0
- package/dist/text/encoding.js +384 -0
- package/dist/text/site-name.js +37 -0
- package/dist/util/child.js +21 -0
- package/dist/util/names.js +97 -0
- package/funoteka.json.example +27 -0
- package/package.json +55 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { withTransaction } from "../db/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* What the listener did: plays that happened, what is on now, and the queue they
|
|
4
|
+
* left behind.
|
|
5
|
+
*
|
|
6
|
+
* The third part of the listener's own layer — playlists are what they arranged,
|
|
7
|
+
* stars are what they thought — and the same rule holds: nothing on disk states
|
|
8
|
+
* any of it, so no scan writes here, and what a scan takes away goes through the
|
|
9
|
+
* cascade (`030_history.sql`).
|
|
10
|
+
*
|
|
11
|
+
* The five tables are the protocol's own split and not an accident of storage:
|
|
12
|
+
* a **play** is history and the row is the only record it happened; **now
|
|
13
|
+
* playing** is state, one row per player, replaced as the player moves; the
|
|
14
|
+
* **queue** is state too, but the listener's rather than a player's, which is
|
|
15
|
+
* why `getPlayQueue` takes no player at all; the queue's entries are their own
|
|
16
|
+
* table beside it; and **player** is the row that turns a client's own name into
|
|
17
|
+
* the integer the protocol asks for.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* The row for a client, by the name it gave.
|
|
21
|
+
*
|
|
22
|
+
* The protocol requires a `playerId` — an integer — on every now-playing entry
|
|
23
|
+
* and no endpoint accepts one, so the server derives it. A client names itself
|
|
24
|
+
* with the protocol's `c` parameter; this turns that name into the row the
|
|
25
|
+
* integer is, and finds it again on the next call.
|
|
26
|
+
*/
|
|
27
|
+
export function player(db, name) {
|
|
28
|
+
const found = db.prepare('SELECT id FROM player WHERE name = ?').get(name);
|
|
29
|
+
if (found !== undefined)
|
|
30
|
+
return found.id;
|
|
31
|
+
const inserted = db.prepare('INSERT INTO player (name) VALUES (?)').run(name);
|
|
32
|
+
return Number(inserted.lastInsertRowid);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* That a track was played, in the history and in the rollup.
|
|
36
|
+
*
|
|
37
|
+
* Both, because they answer different questions and neither can answer the
|
|
38
|
+
* other's: `play` is what happened (which track, which client, when) and
|
|
39
|
+
* `track_play` is what the protocol *shows* — `playCount` and `played` are
|
|
40
|
+
* fields on every `Child`, and a field on every Child cannot be an aggregate
|
|
41
|
+
* over a listing (`031_queue_position_and_plays.sql`).
|
|
42
|
+
*
|
|
43
|
+
* One transaction around the pair, so the history and the rollup cannot
|
|
44
|
+
* disagree: a scrobble that wrote one and not the other would leave a count that
|
|
45
|
+
* does not match the rows behind it.
|
|
46
|
+
*/
|
|
47
|
+
export function recordPlay(db, playerId, played) {
|
|
48
|
+
withTransaction(db, () => {
|
|
49
|
+
const insert = db.prepare('INSERT INTO play (track_id, player_id, played_at) VALUES (?, ?, ?)');
|
|
50
|
+
const roll = db.prepare(`INSERT INTO track_play (track_id, play_count, played_at) VALUES (?, 1, ?)
|
|
51
|
+
ON CONFLICT (track_id) DO UPDATE SET
|
|
52
|
+
play_count = track_play.play_count + 1,
|
|
53
|
+
played_at = excluded.played_at`);
|
|
54
|
+
// Each play carries its own time: the protocol pairs `time` with `id` one
|
|
55
|
+
// for one, so a client uploading a day of offline listening dates each of
|
|
56
|
+
// them separately and they must not all be stamped now.
|
|
57
|
+
for (const play of played) {
|
|
58
|
+
insert.run(play.trackId, playerId, play.at);
|
|
59
|
+
roll.run(play.trackId, play.at);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* What a player is on, and how far in.
|
|
65
|
+
*
|
|
66
|
+
* One row per player, replaced rather than accumulated: the protocol's question
|
|
67
|
+
* is what is playing *now*, and a table that kept every track a client had
|
|
68
|
+
* opened would be a history with a different name.
|
|
69
|
+
*
|
|
70
|
+
* The three fields beside the track come from `reportPlayback` and are all the
|
|
71
|
+
* server's to remember — the client is told them back by a *different* client
|
|
72
|
+
* through `getNowPlaying`, which is the whole reason they are stored rather than
|
|
73
|
+
* answered from the request.
|
|
74
|
+
*/
|
|
75
|
+
export function markPlaying(db, playerId, trackId, at, details = {}) {
|
|
76
|
+
db.prepare(`INSERT INTO now_playing (player_id, track_id, updated_at, state, position_ms, playback_rate)
|
|
77
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
78
|
+
ON CONFLICT (player_id) DO UPDATE SET
|
|
79
|
+
track_id = excluded.track_id,
|
|
80
|
+
updated_at = excluded.updated_at,
|
|
81
|
+
state = excluded.state,
|
|
82
|
+
position_ms = excluded.position_ms,
|
|
83
|
+
playback_rate = excluded.playback_rate`).run(playerId, trackId, at, details.state ?? null, details.positionMs ?? null, details.playbackRate ?? null);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* What every player is on, most recently updated first.
|
|
87
|
+
*
|
|
88
|
+
* A row whose track is gone is gone with it — the cascade — so nothing here has
|
|
89
|
+
* to be filtered: what comes back is a list of tracks that are still in the
|
|
90
|
+
* collection.
|
|
91
|
+
*/
|
|
92
|
+
export function nowPlaying(db) {
|
|
93
|
+
return db
|
|
94
|
+
.prepare(`SELECT np.player_id AS playerId,
|
|
95
|
+
p.name AS playerName,
|
|
96
|
+
np.track_id AS trackId,
|
|
97
|
+
np.updated_at AS updatedAt,
|
|
98
|
+
np.state AS state,
|
|
99
|
+
np.position_ms AS positionMs,
|
|
100
|
+
np.playback_rate AS playbackRate
|
|
101
|
+
FROM now_playing np
|
|
102
|
+
JOIN player p ON p.id = np.player_id
|
|
103
|
+
ORDER BY np.updated_at DESC, np.player_id`)
|
|
104
|
+
.all();
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The queue, seat by seat.
|
|
108
|
+
*
|
|
109
|
+
* The seats are returned with their numbers rather than as a bare list, because
|
|
110
|
+
* the current track is stored as one of those numbers and a caller has to be
|
|
111
|
+
* able to find it. Reading the list and indexing it would work until a deletion
|
|
112
|
+
* ahead of the current seat moved everything after it — which is what happened
|
|
113
|
+
* (task:2863).
|
|
114
|
+
*/
|
|
115
|
+
export function queue(db) {
|
|
116
|
+
const row = db
|
|
117
|
+
.prepare(`SELECT current_position AS currentPosition, position_ms AS positionMs,
|
|
118
|
+
changed_at AS changedAt, changed_by AS changedBy
|
|
119
|
+
FROM play_queue WHERE only_row = 1`)
|
|
120
|
+
.get();
|
|
121
|
+
if (row === undefined)
|
|
122
|
+
return null;
|
|
123
|
+
const entries = db
|
|
124
|
+
.prepare('SELECT position, track_id AS trackId FROM play_queue_entry ORDER BY position')
|
|
125
|
+
.all();
|
|
126
|
+
return { ...row, entries };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Replace the queue, whole.
|
|
130
|
+
*
|
|
131
|
+
* Every save lays the positions out from zero again, the rule every other
|
|
132
|
+
* ordered write in this project follows: a queue is addressed by index, so a
|
|
133
|
+
* save that appended would leave the numbers meaning something the client did
|
|
134
|
+
* not send. An empty list is a queue with nothing in it — how a client says
|
|
135
|
+
* "forget this" — and it is saved rather than deleted, so `changedBy` still says
|
|
136
|
+
* who did it.
|
|
137
|
+
*/
|
|
138
|
+
export function saveQueue(db, input, at) {
|
|
139
|
+
withTransaction(db, () => {
|
|
140
|
+
db.prepare('DELETE FROM play_queue_entry').run();
|
|
141
|
+
const insert = db.prepare('INSERT INTO play_queue_entry (position, track_id) VALUES (?, ?)');
|
|
142
|
+
input.trackIds.forEach((trackId, position) => insert.run(position, trackId));
|
|
143
|
+
db.prepare(`INSERT INTO play_queue (only_row, current_position, position_ms, changed_at, changed_by)
|
|
144
|
+
VALUES (1, ?, ?, ?, ?)
|
|
145
|
+
ON CONFLICT (only_row) DO UPDATE SET
|
|
146
|
+
current_position = excluded.current_position,
|
|
147
|
+
position_ms = excluded.position_ms,
|
|
148
|
+
changed_at = excluded.changed_at,
|
|
149
|
+
changed_by = excluded.changed_by`).run(input.currentPosition, input.positionMs, at, input.changedBy);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { unchangedCount } from "../db/ledger.js";
|
|
2
|
+
import { hidden } from "../junk/marks.js";
|
|
3
|
+
/**
|
|
4
|
+
* Render the classified collection as a document a human reads.
|
|
5
|
+
*
|
|
6
|
+
* This is the acceptance instrument (requirements:39 §9): every check v1 is
|
|
7
|
+
* judged by — pressings staying separate albums, a box reading as one release
|
|
8
|
+
* with its discs, the same album in two roots staying two, a cue album listing
|
|
9
|
+
* N tracks rather than one, Cyrillic surviving the trip, and the counters
|
|
10
|
+
* adding up — has to be visible here, or it is not checkable by the person the
|
|
11
|
+
* contract answers to.
|
|
12
|
+
*
|
|
13
|
+
* Read-only, and deterministic by construction: same database, same bytes.
|
|
14
|
+
* Two dumps of an untouched collection differ only in the lines naming the run
|
|
15
|
+
* they describe, which is what makes "the rescan changed nothing" something one
|
|
16
|
+
* looks at rather than something one believes. The ledger line says how much of
|
|
17
|
+
* the collection the last walk found unmoved, so that claim is legible from the
|
|
18
|
+
* dump alone.
|
|
19
|
+
*
|
|
20
|
+
* The machine-readable form remains the database itself.
|
|
21
|
+
*/
|
|
22
|
+
/** Minutes and seconds, or an explicit dash when nothing measured it. */
|
|
23
|
+
function duration(ms) {
|
|
24
|
+
if (ms === null || ms < 0)
|
|
25
|
+
return '--:--';
|
|
26
|
+
const total = Math.round(ms / 1000);
|
|
27
|
+
const seconds = total % 60;
|
|
28
|
+
const minutes = Math.floor(total / 60) % 60;
|
|
29
|
+
const hours = Math.floor(total / 3600);
|
|
30
|
+
const tail = `${minutes}:${String(seconds).padStart(2, '0')}`;
|
|
31
|
+
return hours > 0
|
|
32
|
+
? `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
|
33
|
+
: tail;
|
|
34
|
+
}
|
|
35
|
+
/** `label total (key n, key n)`, omitting the keys that are zero. */
|
|
36
|
+
function breakdown(total, label, counts = {}) {
|
|
37
|
+
const parts = Object.entries(counts)
|
|
38
|
+
.filter(([, n]) => n > 0)
|
|
39
|
+
.map(([key, n]) => `${key} ${n}`);
|
|
40
|
+
return ` ${label.padEnd(9)}${String(total).padEnd(7)}${parts.length === 0 ? '' : `(${parts.join(', ')})`}`;
|
|
41
|
+
}
|
|
42
|
+
function tally(rows) {
|
|
43
|
+
return Object.fromEntries(rows.map((row) => [row.key ?? 'none', row.n]));
|
|
44
|
+
}
|
|
45
|
+
function count(db, sql, ...args) {
|
|
46
|
+
return db.prepare(sql).get(...args).n;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Every row of an album-keyed query, gathered under its album.
|
|
50
|
+
*
|
|
51
|
+
* **One query for the collection rather than one per album.** Three of the things
|
|
52
|
+
* the dump asks for are per-album reads, and asking them inside the loop below
|
|
53
|
+
* is what made `GET /inventory` cost **233.9 ms** on a 472-album collection: 1416
|
|
54
|
+
* statement executions, three for every album. Grouped here, the same three
|
|
55
|
+
* queries are executed three times in all.
|
|
56
|
+
*
|
|
57
|
+
* The order inside a group is the order the query was written with, which is
|
|
58
|
+
* what keeps the dump byte-identical — the promise this file opens with.
|
|
59
|
+
*/
|
|
60
|
+
function byAlbum(rows) {
|
|
61
|
+
const out = new Map();
|
|
62
|
+
for (const row of rows) {
|
|
63
|
+
const list = out.get(row.albumId);
|
|
64
|
+
if (list === undefined)
|
|
65
|
+
out.set(row.albumId, [row]);
|
|
66
|
+
else
|
|
67
|
+
list.push(row);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
export function inventory(db, options = {}) {
|
|
72
|
+
const lines = [];
|
|
73
|
+
const where = options.dbPath === undefined ? '' : ` — ${options.dbPath}`;
|
|
74
|
+
lines.push(`funoteka inventory${where}`);
|
|
75
|
+
const run = db
|
|
76
|
+
.prepare('SELECT id, status, started_at FROM scan_run ORDER BY id DESC LIMIT 1')
|
|
77
|
+
.get();
|
|
78
|
+
if (run === undefined) {
|
|
79
|
+
lines.push('', 'No scan has run against this database.', '');
|
|
80
|
+
return lines.join('\n');
|
|
81
|
+
}
|
|
82
|
+
lines.push(`run ${run.id} · ${run.status} · ${run.started_at}`, '');
|
|
83
|
+
const roots = db.prepare('SELECT id, path FROM root ORDER BY id').all();
|
|
84
|
+
// Albums carry the root they belong to, and identity is root-qualified, so a
|
|
85
|
+
// short marker is what lets two roots hold the same relative path and stay
|
|
86
|
+
// legibly two albums.
|
|
87
|
+
const marks = new Map(roots.map((root, index) => [root.id, `#${index + 1}`]));
|
|
88
|
+
const perRoot = (sql) => new Map(db.prepare(sql).all().map((row) => [row.root_id, row.n]));
|
|
89
|
+
const foldersByRoot = perRoot('SELECT root_id, COUNT(*) AS n FROM folder GROUP BY root_id');
|
|
90
|
+
const filesByRoot = perRoot('SELECT root_id, COUNT(*) AS n FROM file GROUP BY root_id');
|
|
91
|
+
const albumsByRoot = perRoot('SELECT root_id, COUNT(*) AS n FROM album GROUP BY root_id');
|
|
92
|
+
lines.push('roots');
|
|
93
|
+
for (const root of roots) {
|
|
94
|
+
lines.push(` ${(marks.get(root.id) ?? '?').padEnd(4)}${root.path}`, ` folders ${foldersByRoot.get(root.id) ?? 0} files ${filesByRoot.get(root.id) ?? 0} albums ${albumsByRoot.get(root.id) ?? 0}`);
|
|
95
|
+
}
|
|
96
|
+
const fileTotal = count(db, 'SELECT COUNT(*) AS n FROM file');
|
|
97
|
+
const unmoved = unchangedCount(db, run.id);
|
|
98
|
+
lines.push('', 'counters');
|
|
99
|
+
lines.push(breakdown(count(db, 'SELECT COUNT(*) AS n FROM folder'), 'folders', tally(db.prepare('SELECT role AS key, COUNT(*) AS n FROM folder GROUP BY role').all())));
|
|
100
|
+
lines.push(breakdown(fileTotal, 'files', tally(db.prepare('SELECT kind AS key, COUNT(*) AS n FROM file GROUP BY kind').all())));
|
|
101
|
+
// `scanned = visible + hidden`, stated here because the dump is where the
|
|
102
|
+
// contract promised it would be (Q14, brainstorm:190) and because a filter
|
|
103
|
+
// nobody can see the size of is a filter nobody can check. What is hidden is
|
|
104
|
+
// named below the counters, with its reason — a count alone cannot tell a rule
|
|
105
|
+
// that overreached from a folder somebody marked.
|
|
106
|
+
const hiddenAlbums = hidden(db);
|
|
107
|
+
lines.push(breakdown(count(db, 'SELECT COUNT(*) AS n FROM album'), 'albums', {
|
|
108
|
+
hidden: hiddenAlbums.length,
|
|
109
|
+
}));
|
|
110
|
+
lines.push(breakdown(count(db, 'SELECT COUNT(*) AS n FROM track'), 'tracks'));
|
|
111
|
+
lines.push(breakdown(count(db, 'SELECT COUNT(*) AS n FROM release'), 'releases'));
|
|
112
|
+
lines.push(breakdown(count(db, 'SELECT COUNT(*) AS n FROM artist'), 'artists', {
|
|
113
|
+
ambiguous: count(db, 'SELECT COUNT(*) AS n FROM artist WHERE ambiguous = 1'),
|
|
114
|
+
}));
|
|
115
|
+
lines.push(breakdown(count(db, 'SELECT COUNT(*) AS n FROM issue WHERE scan_run_id = ?', run.id), 'issues', tally(db
|
|
116
|
+
.prepare('SELECT severity AS key, COUNT(*) AS n FROM issue WHERE scan_run_id = ? GROUP BY severity')
|
|
117
|
+
.all(run.id))));
|
|
118
|
+
// What the last walk found unmoved. Without it the dump cannot say whether a
|
|
119
|
+
// rescan did anything, which is half of what it is for.
|
|
120
|
+
lines.push(` ${'ledger'.padEnd(9)}${unmoved} of ${fileTotal} files unmoved since the last walk`);
|
|
121
|
+
// "No silent loss", stated as the things that must be zero. An audio file is
|
|
122
|
+
// accounted for when the meta layer can name it: a whole-file track points at
|
|
123
|
+
// it, or it is the image a cue splits.
|
|
124
|
+
lines.push('', 'unaccounted');
|
|
125
|
+
const unaccounted = [
|
|
126
|
+
['folders with no role', count(db, 'SELECT COUNT(*) AS n FROM folder WHERE role IS NULL')],
|
|
127
|
+
[
|
|
128
|
+
'albums with no track',
|
|
129
|
+
count(db, 'SELECT COUNT(*) AS n FROM album a WHERE NOT EXISTS (SELECT 1 FROM track t WHERE t.album_id = a.id)'),
|
|
130
|
+
],
|
|
131
|
+
[
|
|
132
|
+
'audio files with no track or cue',
|
|
133
|
+
count(db, `SELECT COUNT(*) AS n FROM file f
|
|
134
|
+
WHERE f.kind = 'audio'
|
|
135
|
+
AND NOT EXISTS (SELECT 1 FROM track t WHERE t.file_id = f.id)
|
|
136
|
+
AND NOT EXISTS (SELECT 1 FROM cue c WHERE c.audio_file_id = f.id)`),
|
|
137
|
+
],
|
|
138
|
+
];
|
|
139
|
+
for (const [label, n] of unaccounted)
|
|
140
|
+
lines.push(` ${label.padEnd(34)}${n}`);
|
|
141
|
+
// What the filter keeps out, by name. Printed before the collection rather
|
|
142
|
+
// than after, because it is the one part of the dump that is an *absence*:
|
|
143
|
+
// everything below is what is there, and a reader who does not see this
|
|
144
|
+
// section cannot tell a hidden folder from a folder that never existed.
|
|
145
|
+
lines.push('', `hidden (${hiddenAlbums.length})`);
|
|
146
|
+
if (hiddenAlbums.length === 0) {
|
|
147
|
+
lines.push(' none');
|
|
148
|
+
}
|
|
149
|
+
for (const row of hiddenAlbums) {
|
|
150
|
+
const where = row.relPath === '' ? '(the root itself)' : row.relPath;
|
|
151
|
+
lines.push(` ${(row.source === 'hand' ? 'hand' : 'scan').padEnd(6)}${(marks.get(row.rootId) ?? '?').padEnd(4)}${where.padEnd(44)}${row.title ?? ''} — ${row.junkReason}`);
|
|
152
|
+
}
|
|
153
|
+
const albums = db
|
|
154
|
+
.prepare(`SELECT a.id AS id, a.root_id AS root_id, a.rel_path AS rel_path, a.title AS title,
|
|
155
|
+
a.disc_number AS disc_number, rel.title AS release_title,
|
|
156
|
+
a.credit_raw AS credit_raw,
|
|
157
|
+
a.artist_id AS artist_id, ar.name AS artist_name, ar.name_key AS artist_key
|
|
158
|
+
FROM album a
|
|
159
|
+
LEFT JOIN artist ar ON ar.id = a.artist_id
|
|
160
|
+
LEFT JOIN release rel ON rel.id = a.release_id
|
|
161
|
+
ORDER BY (ar.name_key IS NULL), ar.name_key, a.root_id, a.rel_path`)
|
|
162
|
+
.all();
|
|
163
|
+
// Counted once rather than rescanning the album list per artist, and counted
|
|
164
|
+
// by id rather than by name: two artists can share a name — that is the whole
|
|
165
|
+
// point of the folder split — and counting by name would show them as one
|
|
166
|
+
// artist owning both, hiding the split exactly where a human looks for it.
|
|
167
|
+
const ownedByArtist = new Map();
|
|
168
|
+
for (const album of albums) {
|
|
169
|
+
ownedByArtist.set(album.artist_id, (ownedByArtist.get(album.artist_id) ?? 0) + 1);
|
|
170
|
+
}
|
|
171
|
+
// Only ever read to show a credit that is more than one name: a single name
|
|
172
|
+
// is already on the album's line, and repeating it would be noise on every
|
|
173
|
+
// one of the collection's albums.
|
|
174
|
+
const creditOf = byAlbum(db
|
|
175
|
+
.prepare(`SELECT ac.album_id AS albumId, ar.name AS name, ac.join_phrase AS join_phrase
|
|
176
|
+
FROM artist_credit ac JOIN artist ar ON ar.id = ac.artist_id
|
|
177
|
+
ORDER BY ac.album_id, ac.position`)
|
|
178
|
+
.all());
|
|
179
|
+
// A track's credit, resolved to the rows the artist stage made of it. Written
|
|
180
|
+
// only where the track says something the record does not — see that stage —
|
|
181
|
+
// so a track with no row here is one whose record already speaks for it.
|
|
182
|
+
const trackCreditOf = byAlbum(db
|
|
183
|
+
.prepare(`SELECT t.album_id AS albumId, tc.track_id AS track_id, ar.name AS name,
|
|
184
|
+
tc.join_phrase AS join_phrase
|
|
185
|
+
FROM track_credit tc
|
|
186
|
+
JOIN track t ON t.id = tc.track_id
|
|
187
|
+
JOIN artist ar ON ar.id = tc.artist_id
|
|
188
|
+
ORDER BY t.album_id, tc.track_id, tc.position`)
|
|
189
|
+
.all());
|
|
190
|
+
// A track's own PERFORMER, reached through the cue bound to the file it plays
|
|
191
|
+
// from. That binding is the one `applyCues` settled: a second cue naming the
|
|
192
|
+
// same audio is diagnosed and left unbound (`cue-unmatched`), so there is one
|
|
193
|
+
// answer here rather than a choice between documents.
|
|
194
|
+
//
|
|
195
|
+
// The raw string and not an artist: `artist/apply.ts` argues why
|
|
196
|
+
// `track.artist_id` stays empty, and what a reader needs to see is that the
|
|
197
|
+
// parser did not drop what the cue said. A compilation is where that matters
|
|
198
|
+
// — the record is credited `Various Artists`, which is true of the record and
|
|
199
|
+
// false of every track on it (task:2727 §6).
|
|
200
|
+
const tracksOf = byAlbum(db
|
|
201
|
+
.prepare(`SELECT t.album_id AS albumId, t.id AS id, t.ordinal AS ordinal, t.title AS title,
|
|
202
|
+
t.segment_start_ms AS start_ms, t.segment_end_ms AS end_ms,
|
|
203
|
+
t.duration_ms AS duration_ms, f.rel_path AS file_rel_path,
|
|
204
|
+
(SELECT ct.performer
|
|
205
|
+
FROM cue c JOIN cue_track ct ON ct.cue_id = c.id
|
|
206
|
+
WHERE c.audio_file_id = t.file_id AND ct.ordinal = t.ordinal
|
|
207
|
+
ORDER BY c.id LIMIT 1) AS performer
|
|
208
|
+
FROM track t JOIN file f ON f.id = t.file_id
|
|
209
|
+
ORDER BY t.album_id, t.ordinal`)
|
|
210
|
+
.all());
|
|
211
|
+
lines.push('', 'artists');
|
|
212
|
+
let currentArtist;
|
|
213
|
+
for (const album of albums) {
|
|
214
|
+
if (album.artist_id !== currentArtist) {
|
|
215
|
+
currentArtist = album.artist_id;
|
|
216
|
+
const owned = ownedByArtist.get(currentArtist) ?? 0;
|
|
217
|
+
// A suffixed key is the one mark that tells two same-named artists apart,
|
|
218
|
+
// and the dump shows only the display name — so without this a split
|
|
219
|
+
// prints as two identical blocks and the reader cannot tell which is
|
|
220
|
+
// which. The key is noise for every artist that won its name outright,
|
|
221
|
+
// so it is shown only when it carries the split's `#`.
|
|
222
|
+
const key = album.artist_key;
|
|
223
|
+
const mark = key === null || !key.includes('#') ? '' : ` [${key}]`;
|
|
224
|
+
lines.push('', ` ${(album.artist_name ?? '(unattributed)').padEnd(40)} ${owned} album${owned === 1 ? '' : 's'}${mark}`);
|
|
225
|
+
}
|
|
226
|
+
const tracks = tracksOf.get(album.id) ?? [];
|
|
227
|
+
const split = tracks.some((track) => track.start_ms !== null);
|
|
228
|
+
const label = album.rel_path === '' ? '(the root itself)' : album.rel_path;
|
|
229
|
+
const disc = album.disc_number === null ? '' : ` disc ${album.disc_number}`;
|
|
230
|
+
const box = album.release_title === null ? '' : ` of ${album.release_title}`;
|
|
231
|
+
// What varies in length — the path, the title, the release — goes last.
|
|
232
|
+
// Padded columns read better until a name outgrows them, at which point
|
|
233
|
+
// they run into the next column and the dump stops being readable exactly
|
|
234
|
+
// where the interesting records are.
|
|
235
|
+
const facts = `${tracks.length} track${tracks.length === 1 ? '' : 's'}${split ? ' split' : ''}${disc}${box}`;
|
|
236
|
+
lines.push(` ${(marks.get(album.root_id) ?? '?').padEnd(4)}${facts.padEnd(30)} · ${label} — ${album.title ?? ''}`);
|
|
237
|
+
const credit = creditOf.get(album.id) ?? [];
|
|
238
|
+
if (credit.length > 1) {
|
|
239
|
+
// Rebuilt from the parts rather than echoed from `credit_raw`, so what is
|
|
240
|
+
// printed proves the list and its phrases really do reassemble.
|
|
241
|
+
const rebuilt = credit.map((entry) => entry.join_phrase + entry.name).join('');
|
|
242
|
+
lines.push(` credit ${rebuilt}`);
|
|
243
|
+
}
|
|
244
|
+
// The tracks whose credit is not the record's, resolved to people. A track
|
|
245
|
+
// line that showed the cue's own string would be showing a string where the
|
|
246
|
+
// meta layer holds an identity — and where the stage declined to resolve one
|
|
247
|
+
// (the 120 names only a track states), the string is what is left and is
|
|
248
|
+
// shown instead, so a refusal is visible rather than an empty column
|
|
249
|
+
// (task:2729).
|
|
250
|
+
const trackCredits = new Map();
|
|
251
|
+
for (const row of trackCreditOf.get(album.id) ?? []) {
|
|
252
|
+
const soFar = trackCredits.get(row.track_id) ?? '';
|
|
253
|
+
trackCredits.set(row.track_id, soFar + row.join_phrase + row.name);
|
|
254
|
+
}
|
|
255
|
+
for (const track of tracks) {
|
|
256
|
+
const segment = track.start_ms === null ? '' : ` [${duration(track.start_ms)}–${duration(track.end_ms)}]`;
|
|
257
|
+
// Only where the track says something the record does not. Eleven of the
|
|
258
|
+
// sample's albums state one performer and it is the record's own; printing
|
|
259
|
+
// it on every line is the repetition the credit has already refused to
|
|
260
|
+
// make, and it would bury the tracks that do differ — a compilation's
|
|
261
|
+
// every track, a live disc's `Публика` — in a column of the same name.
|
|
262
|
+
//
|
|
263
|
+
// The resolved credit is what is shown when there is one, because that is
|
|
264
|
+
// what the meta layer holds: a row in `artist`, which search and the tree
|
|
265
|
+
// can follow. The cue's own string is the fallback — a name the stage
|
|
266
|
+
// declined to resolve, or one it refused as a note about the room — and it
|
|
267
|
+
// is shown rather than dropped so the refusal is visible here too.
|
|
268
|
+
const credit = trackCredits.get(track.id);
|
|
269
|
+
const performer = credit !== undefined
|
|
270
|
+
? ` · ${credit}`
|
|
271
|
+
: track.performer !== null &&
|
|
272
|
+
track.performer.trim() !== '' &&
|
|
273
|
+
track.performer !== album.credit_raw
|
|
274
|
+
? ` · ${track.performer}`
|
|
275
|
+
: '';
|
|
276
|
+
lines.push(` ${`${String(track.ordinal).padStart(2, '0')}.`.padEnd(5)}${duration(track.duration_ms).padEnd(7)}` +
|
|
277
|
+
`· ${track.title ?? '(untitled)'} · ${track.file_rel_path}${segment}${performer}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const issues = db
|
|
281
|
+
.prepare(`SELECT kind, severity, rel_path, root_id, detail FROM issue
|
|
282
|
+
WHERE scan_run_id = ?
|
|
283
|
+
ORDER BY severity DESC, kind, root_id, rel_path`)
|
|
284
|
+
.all(run.id);
|
|
285
|
+
lines.push('', `issues (run ${run.id})`);
|
|
286
|
+
if (issues.length === 0)
|
|
287
|
+
lines.push(' none');
|
|
288
|
+
for (const issue of issues) {
|
|
289
|
+
const mark = issue.root_id === null ? '' : (marks.get(issue.root_id) ?? '');
|
|
290
|
+
lines.push(` ${issue.severity.padEnd(6)}${issue.kind.padEnd(24)}${mark.padEnd(4)}${(issue.rel_path ?? '').padEnd(40)}${issue.detail ?? ''}`);
|
|
291
|
+
}
|
|
292
|
+
lines.push('');
|
|
293
|
+
return lines.join('\n');
|
|
294
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { withTransaction } from "../db/index.js";
|
|
2
|
+
import { junkReason } from "./rule.js";
|
|
3
|
+
/**
|
|
4
|
+
* A filesystem path, as the root and relative path the meta layer keys on.
|
|
5
|
+
*
|
|
6
|
+
* The operator names a folder the way the disk does — `D:\music\Telegram
|
|
7
|
+
* Desktop` — and identity here is `(root, rel_path)`. Resolution is by the
|
|
8
|
+
* deepest root that contains the path, so a root inside another root still
|
|
9
|
+
* answers about itself, and the comparison is on separators normalised to `/`
|
|
10
|
+
* because that is what `scan/walk.ts` writes.
|
|
11
|
+
*
|
|
12
|
+
* Nothing is resolved by guessing: a path under no root is not an error about
|
|
13
|
+
* the collection, it is a path this server has never heard of, and the caller is
|
|
14
|
+
* told so by `undefined` rather than by a mark on something else.
|
|
15
|
+
*/
|
|
16
|
+
export function resolvePath(db, path) {
|
|
17
|
+
const normal = path.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
18
|
+
const roots = db.prepare('SELECT id, path FROM root ORDER BY LENGTH(path) DESC').all();
|
|
19
|
+
for (const root of roots) {
|
|
20
|
+
const prefix = root.path.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
21
|
+
if (normal === prefix)
|
|
22
|
+
return { rootId: root.id, relPath: '' };
|
|
23
|
+
if (normal.toLowerCase().startsWith(`${prefix.toLowerCase()}/`)) {
|
|
24
|
+
return { rootId: root.id, relPath: normal.slice(prefix.length + 1) };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The album a mark is about, and what the rule makes of the files under it.
|
|
31
|
+
*
|
|
32
|
+
* Both verbs below open with exactly this and differ only in the verdict they
|
|
33
|
+
* hand the rule: a person's word for `mark`, nothing for `unmark`, which is how
|
|
34
|
+
* the rule gets to speak again. It is written once because the refusal in it is
|
|
35
|
+
* the part that must not drift — a path that names no album is a statement about
|
|
36
|
+
* nothing, and both verbs owe the operator that sentence rather than a mark
|
|
37
|
+
* quietly landing on something else.
|
|
38
|
+
*/
|
|
39
|
+
function albumAndReason(db, rootId, relPath, mark) {
|
|
40
|
+
const row = db
|
|
41
|
+
.prepare('SELECT id FROM album WHERE root_id = ? AND rel_path = ?')
|
|
42
|
+
.get(rootId, relPath);
|
|
43
|
+
if (row === undefined) {
|
|
44
|
+
throw new Error(`no album at that path: ${relPath === '' ? '(the root itself)' : relPath}`);
|
|
45
|
+
}
|
|
46
|
+
const files = db
|
|
47
|
+
.prepare('SELECT kind FROM file WHERE root_id = ? AND folder_rel_path = ?')
|
|
48
|
+
.all(rootId, relPath);
|
|
49
|
+
return { albumId: row.id, reason: junkReason(files, mark) };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Record what a person said about a folder, and make it so at once.
|
|
53
|
+
*
|
|
54
|
+
* Returns the reason the album now carries, or null when there is none — so a
|
|
55
|
+
* caller reads the outcome off one value instead of two: `trust` on a folder the
|
|
56
|
+
* rule would hide gives null, and `junk` on anything at all gives the sentence
|
|
57
|
+
* the listings are keeping it out by. What differs between the two verdicts is
|
|
58
|
+
* *which* answer comes back, not whether one does.
|
|
59
|
+
*
|
|
60
|
+
* A folder that is not an album at all is refused by name: the mark is keyed on
|
|
61
|
+
* a path, and a path with no album row under it is a statement about nothing —
|
|
62
|
+
* a typo the operator would otherwise never hear about.
|
|
63
|
+
*/
|
|
64
|
+
export function mark(db, rootId, relPath, verdict, note) {
|
|
65
|
+
const { albumId, reason } = albumAndReason(db, rootId, relPath, verdict);
|
|
66
|
+
withTransaction(db, () => {
|
|
67
|
+
db.prepare(`INSERT INTO junk_mark (root_id, rel_path, verdict, note, marked_at)
|
|
68
|
+
VALUES (?, ?, ?, ?, ?)
|
|
69
|
+
ON CONFLICT (root_id, rel_path) DO UPDATE SET
|
|
70
|
+
verdict = excluded.verdict,
|
|
71
|
+
note = excluded.note,
|
|
72
|
+
marked_at = excluded.marked_at`).run(rootId, relPath, verdict, note, new Date().toISOString());
|
|
73
|
+
db.prepare('UPDATE album SET junk_reason = ? WHERE id = ?').run(reason, albumId);
|
|
74
|
+
});
|
|
75
|
+
return reason;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Take a hand mark back, and let the rule speak again.
|
|
79
|
+
*
|
|
80
|
+
* The third verb, and the one that makes the other two reversible: `block` and
|
|
81
|
+
* `allow` write a person's word over the rule's, and without this there is no
|
|
82
|
+
* way to withdraw it — the album would carry "marked junk by hand" for ever, in
|
|
83
|
+
* a database nobody remembers to edit.
|
|
84
|
+
*
|
|
85
|
+
* The row is re-derived here exactly as `mark` re-derives it, from the same
|
|
86
|
+
* function and in the same transaction, so the two cannot disagree about what a
|
|
87
|
+
* folder is: one rule, one caller of it, whichever way the mark moved.
|
|
88
|
+
*/
|
|
89
|
+
export function unmark(db, rootId, relPath) {
|
|
90
|
+
const { albumId, reason } = albumAndReason(db, rootId, relPath);
|
|
91
|
+
withTransaction(db, () => {
|
|
92
|
+
db.prepare('DELETE FROM junk_mark WHERE root_id = ? AND rel_path = ?').run(rootId, relPath);
|
|
93
|
+
db.prepare('UPDATE album SET junk_reason = ? WHERE id = ?').run(reason, albumId);
|
|
94
|
+
});
|
|
95
|
+
return reason;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The hand edits themselves, whether or not they hid anything.
|
|
99
|
+
*
|
|
100
|
+
* `hidden` answers "what is being kept out", which is not the same list: an
|
|
101
|
+
* `allow` on a folder the rule would have hidden keeps nothing out, and it is
|
|
102
|
+
* exactly the mark an operator wants to find again to take back. Listing the
|
|
103
|
+
* marks is also how a deployment's own judgement becomes readable — the rule's
|
|
104
|
+
* answers are derivable from the files, and these are not.
|
|
105
|
+
*/
|
|
106
|
+
export function marks(db) {
|
|
107
|
+
return db
|
|
108
|
+
.prepare(`SELECT m.root_id AS rootId, r.path AS rootPath, m.rel_path AS relPath,
|
|
109
|
+
m.verdict AS verdict, m.note AS note, m.marked_at AS markedAt
|
|
110
|
+
FROM junk_mark m JOIN root r ON r.id = m.root_id
|
|
111
|
+
ORDER BY r.path, m.rel_path`)
|
|
112
|
+
.all();
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Every album the filter is keeping out, and why it is out.
|
|
116
|
+
*
|
|
117
|
+
* The reason and the source both, because "hidden" alone is not checkable: a
|
|
118
|
+
* rule that overreached and a person who marked something look identical from a
|
|
119
|
+
* count, and the difference is the whole of what an operator checking the
|
|
120
|
+
* filter's work needs. `source` is the album's own reason matching the hand
|
|
121
|
+
* mark's text — the two readings are distinguishable because `rule.ts` writes
|
|
122
|
+
* one sentence and the mark writes another, and neither is ever a substring of
|
|
123
|
+
* the other.
|
|
124
|
+
*/
|
|
125
|
+
export function hidden(db) {
|
|
126
|
+
return db
|
|
127
|
+
.prepare(`SELECT al.root_id AS rootId, r.path AS rootPath, al.rel_path AS relPath,
|
|
128
|
+
al.title AS title, al.junk_reason AS junkReason,
|
|
129
|
+
EXISTS (SELECT 1 FROM junk_mark m
|
|
130
|
+
WHERE m.root_id = al.root_id AND m.rel_path = al.rel_path
|
|
131
|
+
AND m.verdict = 'junk') AS marked
|
|
132
|
+
FROM album al JOIN root r ON r.id = al.root_id
|
|
133
|
+
WHERE al.junk_reason IS NOT NULL
|
|
134
|
+
ORDER BY r.path, al.rel_path`)
|
|
135
|
+
.all().map((row) => ({
|
|
136
|
+
rootId: row.rootId,
|
|
137
|
+
rootPath: row.rootPath,
|
|
138
|
+
relPath: row.relPath,
|
|
139
|
+
title: row.title,
|
|
140
|
+
junkReason: row.junkReason,
|
|
141
|
+
source: row.marked === 1 ? 'hand' : 'scan',
|
|
142
|
+
}));
|
|
143
|
+
}
|