cross-seed 6.13.6-2 → 7.0.0-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 +12 -13
- package/dist/webui/assets/FieldInfo-Bxj_j8SJ.js +1 -0
- package/dist/webui/assets/Page-C3rteCZt.js +1 -0
- package/dist/webui/assets/array-field-DVSC6nHP.js +1 -0
- package/dist/webui/assets/badge-DTZMtS0e.js +1 -0
- package/dist/webui/assets/check-Bu3ldi63.js +1 -0
- package/dist/webui/assets/chevron-down-CRy8M0kJ.js +1 -0
- package/dist/webui/assets/clients-CW8oEZoQ.js +1 -0
- package/dist/webui/assets/connect-YBNsnjWT.js +1 -0
- package/dist/webui/assets/debug-mz8-WYZj.js +1 -0
- package/dist/webui/assets/directories-BSK28RgR.js +1 -0
- package/dist/webui/assets/duration-field-C6xoSlJg.js +1 -0
- package/dist/webui/assets/general-lJJxZhH7.js +1 -0
- package/dist/webui/assets/health-CXbsVrie.js +1 -0
- package/dist/webui/assets/index-Bi48hI2z.js +54 -0
- package/dist/webui/assets/index-C-Ul7GNg.css +1 -0
- package/dist/webui/assets/index-C2cH1Gst.js +1 -0
- package/dist/webui/assets/index-Cc5bDmJr.js +1 -0
- package/dist/webui/assets/jobs-CxmNab9w.js +1 -0
- package/dist/webui/assets/library-vaj2W8sE.js +1 -0
- package/dist/webui/assets/loader-circle-M0gu1gZ-.js +1 -0
- package/dist/webui/assets/logs-Cu9RyKS0.js +1 -0
- package/dist/webui/assets/search-2R5sIdT8.js +1 -0
- package/dist/webui/assets/select-field-BCqNLDrJ.js +1 -0
- package/dist/webui/assets/select-zHgqMzLj.js +1 -0
- package/dist/webui/assets/settings-CMYjpTbZ.js +1 -0
- package/dist/webui/assets/submit-button-BtcnyggQ.js +1 -0
- package/dist/webui/assets/switch-G0W3uJVN.js +1 -0
- package/dist/webui/assets/switch-field-IBd9ORNq.js +1 -0
- package/dist/webui/assets/table-DvgJU7Gh.js +1 -0
- package/dist/webui/assets/test-tube-BIwmoM45.js +1 -0
- package/dist/webui/assets/text-field-DruSbGhy.js +1 -0
- package/dist/webui/assets/time-BSMZjmyW.js +1 -0
- package/dist/webui/assets/trackers-D-OpAe63.js +7 -0
- package/dist/webui/assets/use-form-validation-context-BkAfWAh0.js +1 -0
- package/dist/webui/assets/use-settings-form-submit-CDRh-E9U.js +2 -0
- package/dist/webui/assets/useQuery-A4Hv_4uX.js +1 -0
- package/dist/webui/index.html +13 -0
- package/node_modules/@cross-seed/shared/dist/configSchema.d.ts +261 -0
- package/node_modules/@cross-seed/shared/dist/configSchema.d.ts.map +1 -0
- package/node_modules/@cross-seed/shared/dist/configSchema.js +53 -0
- package/node_modules/@cross-seed/shared/dist/configSchema.js.map +1 -0
- package/node_modules/@cross-seed/shared/dist/constants.d.ts +122 -0
- package/node_modules/@cross-seed/shared/dist/constants.d.ts.map +1 -0
- package/node_modules/@cross-seed/shared/dist/constants.js +127 -0
- package/node_modules/@cross-seed/shared/dist/constants.js.map +1 -0
- package/node_modules/@cross-seed/shared/dist/tsconfig.tsbuildinfo +1 -0
- package/node_modules/@cross-seed/shared/dist/utils.d.ts +6 -0
- package/node_modules/@cross-seed/shared/dist/utils.d.ts.map +1 -0
- package/node_modules/@cross-seed/shared/dist/utils.js +9 -0
- package/node_modules/@cross-seed/shared/dist/utils.js.map +1 -0
- package/node_modules/@cross-seed/shared/package.json +22 -0
- package/package.json +35 -11
- package/dist/Result.js +0 -64
- package/dist/Result.js.map +0 -1
- package/dist/action.js +0 -693
- package/dist/action.js.map +0 -1
- package/dist/arr.js +0 -199
- package/dist/arr.js.map +0 -1
- package/dist/auth.js +0 -25
- package/dist/auth.js.map +0 -1
- package/dist/clients/Deluge.js +0 -698
- package/dist/clients/Deluge.js.map +0 -1
- package/dist/clients/QBittorrent.js +0 -785
- package/dist/clients/QBittorrent.js.map +0 -1
- package/dist/clients/RTorrent.js +0 -654
- package/dist/clients/RTorrent.js.map +0 -1
- package/dist/clients/TorrentClient.js +0 -272
- package/dist/clients/TorrentClient.js.map +0 -1
- package/dist/clients/Transmission.js +0 -404
- package/dist/clients/Transmission.js.map +0 -1
- package/dist/cmd.js +0 -196
- package/dist/cmd.js.map +0 -1
- package/dist/config.template.cjs +0 -353
- package/dist/config.template.cjs.map +0 -1
- package/dist/configSchema.js +0 -667
- package/dist/configSchema.js.map +0 -1
- package/dist/configuration.js +0 -82
- package/dist/configuration.js.map +0 -1
- package/dist/constants.js +0 -281
- package/dist/constants.js.map +0 -1
- package/dist/dataFiles.js +0 -208
- package/dist/dataFiles.js.map +0 -1
- package/dist/db.js +0 -216
- package/dist/db.js.map +0 -1
- package/dist/decide.js +0 -553
- package/dist/decide.js.map +0 -1
- package/dist/diff.js +0 -24
- package/dist/diff.js.map +0 -1
- package/dist/errors.js +0 -16
- package/dist/errors.js.map +0 -1
- package/dist/indexers.js +0 -180
- package/dist/indexers.js.map +0 -1
- package/dist/inject.js +0 -594
- package/dist/inject.js.map +0 -1
- package/dist/jobs.js +0 -146
- package/dist/jobs.js.map +0 -1
- package/dist/logger.js +0 -143
- package/dist/logger.js.map +0 -1
- package/dist/migrations/00-initialSchema.js +0 -30
- package/dist/migrations/00-initialSchema.js.map +0 -1
- package/dist/migrations/01-jobs.js +0 -12
- package/dist/migrations/01-jobs.js.map +0 -1
- package/dist/migrations/02-timestamps.js +0 -21
- package/dist/migrations/02-timestamps.js.map +0 -1
- package/dist/migrations/03-rateLimits.js +0 -14
- package/dist/migrations/03-rateLimits.js.map +0 -1
- package/dist/migrations/04-auth.js +0 -13
- package/dist/migrations/04-auth.js.map +0 -1
- package/dist/migrations/05-caps.js +0 -16
- package/dist/migrations/05-caps.js.map +0 -1
- package/dist/migrations/06-uniqueDecisions.js +0 -29
- package/dist/migrations/06-uniqueDecisions.js.map +0 -1
- package/dist/migrations/07-limits.js +0 -12
- package/dist/migrations/07-limits.js.map +0 -1
- package/dist/migrations/08-rss.js +0 -15
- package/dist/migrations/08-rss.js.map +0 -1
- package/dist/migrations/09-clientAndDataSearchees.js +0 -34
- package/dist/migrations/09-clientAndDataSearchees.js.map +0 -1
- package/dist/migrations/10-indexerNameAudioBookCaps.js +0 -18
- package/dist/migrations/10-indexerNameAudioBookCaps.js.map +0 -1
- package/dist/migrations/11-trackers.js +0 -38
- package/dist/migrations/11-trackers.js.map +0 -1
- package/dist/migrations/migrations.js +0 -31
- package/dist/migrations/migrations.js.map +0 -1
- package/dist/parseTorrent.js +0 -128
- package/dist/parseTorrent.js.map +0 -1
- package/dist/pipeline.js +0 -527
- package/dist/pipeline.js.map +0 -1
- package/dist/preFilter.js +0 -250
- package/dist/preFilter.js.map +0 -1
- package/dist/pushNotifier.js +0 -137
- package/dist/pushNotifier.js.map +0 -1
- package/dist/runtimeConfig.js +0 -11
- package/dist/runtimeConfig.js.map +0 -1
- package/dist/searchee.js +0 -658
- package/dist/searchee.js.map +0 -1
- package/dist/server.js +0 -456
- package/dist/server.js.map +0 -1
- package/dist/startup.js +0 -203
- package/dist/startup.js.map +0 -1
- package/dist/torrent.js +0 -637
- package/dist/torrent.js.map +0 -1
- package/dist/torznab.js +0 -777
- package/dist/torznab.js.map +0 -1
- package/dist/utils.js +0 -637
- package/dist/utils.js.map +0 -1
package/dist/action.js
DELETED
|
@@ -1,693 +0,0 @@
|
|
|
1
|
-
import chalk from "chalk";
|
|
2
|
-
import fs from "fs";
|
|
3
|
-
import { copyFile, link, lstat, mkdir, readlink, rm, stat, symlink, utimes, writeFile, } from "fs/promises";
|
|
4
|
-
import { dirname, join, resolve } from "path";
|
|
5
|
-
import { getClients, shouldRecheck, } from "./clients/TorrentClient.js";
|
|
6
|
-
import { Action, ALL_EXTENSIONS, Decision, InjectionResult, LinkType, SaveResult, } from "./constants.js";
|
|
7
|
-
import { CrossSeedError } from "./errors.js";
|
|
8
|
-
import { Label, logger } from "./logger.js";
|
|
9
|
-
import { resultOf, resultOfErr } from "./Result.js";
|
|
10
|
-
import { getRuntimeConfig } from "./runtimeConfig.js";
|
|
11
|
-
import { createSearcheeFromPath, getMediaType, getRoot, getRootFolder, getSearcheeSource, } from "./searchee.js";
|
|
12
|
-
import { getTorrentSavePath } from "./torrent.js";
|
|
13
|
-
import { exists, filterAsync, findAFileWithExt, findAsync, formatAsList, getLogString, mapAsync, Mutex, notExists, verifyDir, withMutex, } from "./utils.js";
|
|
14
|
-
const linkDirSrcName = "linkDirSrc.cross-seed";
|
|
15
|
-
const linkDirDestName = "linkDirDest.cross-seed";
|
|
16
|
-
const clientDestName = "torrentClientDest.cross-seed";
|
|
17
|
-
async function linkAllFilesInMetafile(searchee, newMeta, decision, destinationDir, options) {
|
|
18
|
-
const availableFiles = searchee.files.slice();
|
|
19
|
-
const paths = decision === Decision.MATCH && options.savePath
|
|
20
|
-
? newMeta.files.map((file) => [
|
|
21
|
-
join(options.savePath, file.path),
|
|
22
|
-
join(destinationDir, file.path),
|
|
23
|
-
])
|
|
24
|
-
: newMeta.files.reduce((acc, newFile) => {
|
|
25
|
-
let matchedSearcheeFiles = availableFiles.filter((searcheeFile) => searcheeFile.length === newFile.length);
|
|
26
|
-
if (matchedSearcheeFiles.length > 1) {
|
|
27
|
-
matchedSearcheeFiles = matchedSearcheeFiles.filter((searcheeFile) => searcheeFile.name === newFile.name);
|
|
28
|
-
}
|
|
29
|
-
if (!matchedSearcheeFiles.length)
|
|
30
|
-
return acc;
|
|
31
|
-
const index = availableFiles.indexOf(matchedSearcheeFiles[0]);
|
|
32
|
-
availableFiles.splice(index, 1);
|
|
33
|
-
const srcFilePath = options.savePath
|
|
34
|
-
? join(options.savePath, matchedSearcheeFiles[0].path)
|
|
35
|
-
: matchedSearcheeFiles[0].path; // Absolute path
|
|
36
|
-
acc.push([srcFilePath, join(destinationDir, newFile.path)]);
|
|
37
|
-
return acc;
|
|
38
|
-
}, []);
|
|
39
|
-
let alreadyExisted = false;
|
|
40
|
-
let linkedNewFiles = false;
|
|
41
|
-
try {
|
|
42
|
-
logger.verbose({
|
|
43
|
-
label: searchee.label,
|
|
44
|
-
message: `Linking ${getLogString(newMeta)} from ${getLogString(searchee)} to ${destinationDir}`,
|
|
45
|
-
});
|
|
46
|
-
const validPaths = await filterAsync(paths, async ([srcFilePath, destFilePath]) => {
|
|
47
|
-
if (await exists(destFilePath)) {
|
|
48
|
-
logger.verbose({
|
|
49
|
-
label: searchee.label,
|
|
50
|
-
message: `--- Skipping ${srcFilePath} -> ${destFilePath}, already exists`,
|
|
51
|
-
});
|
|
52
|
-
alreadyExisted = true;
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
if (await exists(srcFilePath))
|
|
56
|
-
return true;
|
|
57
|
-
if (options.ignoreMissing)
|
|
58
|
-
return false;
|
|
59
|
-
throw new Error(`Linking failed, ${srcFilePath} not found.`);
|
|
60
|
-
});
|
|
61
|
-
for (const [srcFilePath, destFilePath] of validPaths) {
|
|
62
|
-
const destFileParentPath = dirname(destFilePath);
|
|
63
|
-
if (await notExists(destFileParentPath)) {
|
|
64
|
-
await mkdir(destFileParentPath, { recursive: true });
|
|
65
|
-
}
|
|
66
|
-
try {
|
|
67
|
-
if (await linkFile(srcFilePath, destFilePath)) {
|
|
68
|
-
linkedNewFiles = true;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
catch (e) {
|
|
72
|
-
logger.error({
|
|
73
|
-
label: searchee.label,
|
|
74
|
-
message: `--- Linking failed, ${srcFilePath} -> ${destFilePath}: ${e.message}`,
|
|
75
|
-
});
|
|
76
|
-
throw e;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
catch (e) {
|
|
81
|
-
return resultOfErr(e);
|
|
82
|
-
}
|
|
83
|
-
return resultOf({ alreadyExisted, linkedNewFiles });
|
|
84
|
-
}
|
|
85
|
-
async function unlinkMetafile(meta, destinationDir, searcheeLabel) {
|
|
86
|
-
const roots = [];
|
|
87
|
-
for (const file of meta.files) {
|
|
88
|
-
const res = getRoot(file);
|
|
89
|
-
if (res.isErr()) {
|
|
90
|
-
const err = res.unwrapErr();
|
|
91
|
-
logger.error({
|
|
92
|
-
label: searcheeLabel,
|
|
93
|
-
message: `Unable to unlink ${getLogString(meta)} in ${destinationDir}: ${err.message}`,
|
|
94
|
-
});
|
|
95
|
-
logger.debug(err);
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
roots.push(join(destinationDir, res.unwrap()));
|
|
99
|
-
}
|
|
100
|
-
const destinationDirIno = (await stat(destinationDir)).ino;
|
|
101
|
-
for (const root of roots) {
|
|
102
|
-
if (await notExists(root))
|
|
103
|
-
continue;
|
|
104
|
-
if (!root.startsWith(destinationDir))
|
|
105
|
-
continue; // assert: root is within destinationDir
|
|
106
|
-
if (resolve(root) === resolve(destinationDir))
|
|
107
|
-
continue; // assert: root is not destinationDir
|
|
108
|
-
if ((await stat(root)).ino === destinationDirIno)
|
|
109
|
-
continue; // assert: root is not destinationDir
|
|
110
|
-
logger.verbose(`Unlinking ${root}`);
|
|
111
|
-
await rm(root, { recursive: true });
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
async function getSavePath(searchee, options) {
|
|
115
|
-
if (searchee.path) {
|
|
116
|
-
if (await notExists(searchee.path)) {
|
|
117
|
-
logger.error({
|
|
118
|
-
label: searchee.label,
|
|
119
|
-
message: `Linking failed, ${searchee.path} not found.`,
|
|
120
|
-
});
|
|
121
|
-
return resultOfErr("INVALID_DATA");
|
|
122
|
-
}
|
|
123
|
-
const result = await createSearcheeFromPath(searchee.path);
|
|
124
|
-
if (result.isErr()) {
|
|
125
|
-
return resultOfErr("TORRENT_NOT_FOUND");
|
|
126
|
-
}
|
|
127
|
-
const refreshedSearchee = result.unwrap();
|
|
128
|
-
if (options.onlyCompleted &&
|
|
129
|
-
(searchee.mtimeMs !== refreshedSearchee.mtimeMs ||
|
|
130
|
-
searchee.length !== refreshedSearchee.length)) {
|
|
131
|
-
return resultOfErr("TORRENT_NOT_COMPLETE");
|
|
132
|
-
}
|
|
133
|
-
return resultOf(dirname(searchee.path));
|
|
134
|
-
}
|
|
135
|
-
else if (!searchee.infoHash) {
|
|
136
|
-
for (const file of searchee.files) {
|
|
137
|
-
if (await notExists(file.path)) {
|
|
138
|
-
logger.error(`Linking failed, ${file.path} not found.`);
|
|
139
|
-
return resultOfErr("INVALID_DATA");
|
|
140
|
-
}
|
|
141
|
-
if (options.onlyCompleted) {
|
|
142
|
-
const f = await stat(file.path);
|
|
143
|
-
if (searchee.mtimeMs < f.mtimeMs || file.length !== f.size) {
|
|
144
|
-
return resultOfErr("TORRENT_NOT_COMPLETE");
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
return resultOf(undefined);
|
|
149
|
-
}
|
|
150
|
-
const clients = getClients();
|
|
151
|
-
const client = clients.length === 1
|
|
152
|
-
? clients[0]
|
|
153
|
-
: clients.find((c) => c.clientHost === searchee.clientHost);
|
|
154
|
-
let savePath;
|
|
155
|
-
if (searchee.savePath) {
|
|
156
|
-
if (searchee.label !== Label.INJECT) {
|
|
157
|
-
// This check is too slow for the number of searchees from the inject job
|
|
158
|
-
const refreshedSearchee = (await client.getClientSearchees({
|
|
159
|
-
newSearcheesOnly: true,
|
|
160
|
-
refresh: [searchee.infoHash],
|
|
161
|
-
})).newSearchees.find((s) => s.infoHash === searchee.infoHash);
|
|
162
|
-
if (!refreshedSearchee)
|
|
163
|
-
return resultOfErr("TORRENT_NOT_FOUND");
|
|
164
|
-
Object.assign(searchee, refreshedSearchee);
|
|
165
|
-
}
|
|
166
|
-
if (!(await client.isTorrentComplete(searchee.infoHash)).orElse(false)) {
|
|
167
|
-
return resultOfErr("TORRENT_NOT_COMPLETE");
|
|
168
|
-
}
|
|
169
|
-
savePath = searchee.savePath;
|
|
170
|
-
}
|
|
171
|
-
else {
|
|
172
|
-
const downloadDirResult = await client.getDownloadDir(searchee, { onlyCompleted: options.onlyCompleted });
|
|
173
|
-
if (downloadDirResult.isErr()) {
|
|
174
|
-
return downloadDirResult.mapErr((e) => e === "NOT_FOUND" || e === "UNKNOWN_ERROR"
|
|
175
|
-
? "TORRENT_NOT_FOUND"
|
|
176
|
-
: e);
|
|
177
|
-
}
|
|
178
|
-
savePath = downloadDirResult.unwrap();
|
|
179
|
-
}
|
|
180
|
-
const rootFolderRes = getRootFolder(searchee.files[0]);
|
|
181
|
-
if (rootFolderRes.isErr()) {
|
|
182
|
-
logger.error({
|
|
183
|
-
label: searchee.label,
|
|
184
|
-
message: `Linking failed, ${rootFolderRes.unwrapErr().message}`,
|
|
185
|
-
});
|
|
186
|
-
return resultOfErr("INVALID_DATA");
|
|
187
|
-
}
|
|
188
|
-
const rootFolder = rootFolderRes.unwrap();
|
|
189
|
-
const sourceRootOrSavePath = searchee.files.length === 1
|
|
190
|
-
? join(savePath, searchee.files[0].path)
|
|
191
|
-
: rootFolder
|
|
192
|
-
? join(savePath, rootFolder)
|
|
193
|
-
: savePath;
|
|
194
|
-
if (await notExists(sourceRootOrSavePath)) {
|
|
195
|
-
logger.error({
|
|
196
|
-
label: searchee.label,
|
|
197
|
-
message: `Linking failed, ${sourceRootOrSavePath} not found.`,
|
|
198
|
-
});
|
|
199
|
-
return resultOfErr("INVALID_DATA");
|
|
200
|
-
}
|
|
201
|
-
return resultOf(savePath);
|
|
202
|
-
}
|
|
203
|
-
async function getClientAndDestinationDir(client, searchee, savePath, newMeta, tracker) {
|
|
204
|
-
const { flatLinking, linkType } = getRuntimeConfig();
|
|
205
|
-
if (!client) {
|
|
206
|
-
let srcPath;
|
|
207
|
-
let srcDev;
|
|
208
|
-
try {
|
|
209
|
-
srcPath = !savePath
|
|
210
|
-
? (await findAsync(searchee.files, (f) => exists(f.path))).path
|
|
211
|
-
: join(savePath, (await findAsync(searchee.files, (f) => exists(join(savePath, f.path)))).path);
|
|
212
|
-
srcDev = (await stat(srcPath)).dev;
|
|
213
|
-
}
|
|
214
|
-
catch (e) {
|
|
215
|
-
logger.debug(e);
|
|
216
|
-
return null;
|
|
217
|
-
}
|
|
218
|
-
let error;
|
|
219
|
-
for (const testClient of getClients().filter((c) => !c.readonly)) {
|
|
220
|
-
const torrentSavePaths = new Set((await testClient.getAllDownloadDirs({
|
|
221
|
-
metas: [],
|
|
222
|
-
onlyCompleted: false,
|
|
223
|
-
})).values());
|
|
224
|
-
if (!torrentSavePaths.size) {
|
|
225
|
-
error = new Error(`No save paths found to test with for ${testClient.label}, add at least one torrent to the client.`);
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
for (const torrentSavePath of torrentSavePaths) {
|
|
229
|
-
try {
|
|
230
|
-
if (srcDev &&
|
|
231
|
-
(await stat(torrentSavePath)).dev === srcDev) {
|
|
232
|
-
client = testClient;
|
|
233
|
-
break;
|
|
234
|
-
}
|
|
235
|
-
const testPath = join(torrentSavePath, clientDestName);
|
|
236
|
-
await linkFile(srcPath, testPath, linkType === LinkType.REFLINK ||
|
|
237
|
-
linkType === LinkType.REFLINK_OR_COPY
|
|
238
|
-
? linkType
|
|
239
|
-
: LinkType.HARDLINK);
|
|
240
|
-
await rm(testPath);
|
|
241
|
-
client = testClient;
|
|
242
|
-
break;
|
|
243
|
-
}
|
|
244
|
-
catch (e) {
|
|
245
|
-
error = e;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
if (client)
|
|
249
|
-
break;
|
|
250
|
-
}
|
|
251
|
-
if (!client) {
|
|
252
|
-
logger.debug(error);
|
|
253
|
-
return null;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
let destinationDir;
|
|
257
|
-
let destinationDirFromClient = false;
|
|
258
|
-
const clientSavePathRes = await client.getDownloadDir(newMeta, {
|
|
259
|
-
onlyCompleted: false,
|
|
260
|
-
});
|
|
261
|
-
if (clientSavePathRes.isOk()) {
|
|
262
|
-
destinationDir = clientSavePathRes.unwrap();
|
|
263
|
-
destinationDirFromClient = true;
|
|
264
|
-
}
|
|
265
|
-
else {
|
|
266
|
-
if (clientSavePathRes.unwrapErr() === "INVALID_DATA") {
|
|
267
|
-
return null;
|
|
268
|
-
}
|
|
269
|
-
const linkDir = savePath
|
|
270
|
-
? await getLinkDir(savePath)
|
|
271
|
-
: await getLinkDirVirtual(searchee);
|
|
272
|
-
if (!linkDir)
|
|
273
|
-
return null;
|
|
274
|
-
destinationDir = flatLinking ? linkDir : join(linkDir, tracker);
|
|
275
|
-
}
|
|
276
|
-
return { client, destinationDir, destinationDirFromClient };
|
|
277
|
-
}
|
|
278
|
-
function logActionResultImpl(result, newMeta, decision, searchee, tracker, infoOrVerbose, warnOrVerbose) {
|
|
279
|
-
const metaLog = getLogString(newMeta, chalk.green.bold);
|
|
280
|
-
const searcheeLog = getLogString(searchee, chalk.magenta.bold);
|
|
281
|
-
const source = `${getSearcheeSource(searchee)} (${searcheeLog})`;
|
|
282
|
-
const foundBy = `Found ${metaLog} on ${chalk.bold(tracker)} by`;
|
|
283
|
-
switch (result) {
|
|
284
|
-
case SaveResult.SAVED:
|
|
285
|
-
infoOrVerbose({
|
|
286
|
-
label: searchee.label,
|
|
287
|
-
message: `${foundBy} ${chalk.green.bold(decision)} from ${source} - saved`,
|
|
288
|
-
});
|
|
289
|
-
break;
|
|
290
|
-
case InjectionResult.SUCCESS:
|
|
291
|
-
infoOrVerbose({
|
|
292
|
-
label: searchee.label,
|
|
293
|
-
message: `${foundBy} ${chalk.green.bold(decision)} from ${source} - injected`,
|
|
294
|
-
});
|
|
295
|
-
break;
|
|
296
|
-
case InjectionResult.ALREADY_EXISTS:
|
|
297
|
-
infoOrVerbose({
|
|
298
|
-
label: searchee.label,
|
|
299
|
-
message: `${foundBy} ${chalk.yellow(decision)} from ${source} - exists`,
|
|
300
|
-
});
|
|
301
|
-
break;
|
|
302
|
-
case InjectionResult.TORRENT_NOT_COMPLETE:
|
|
303
|
-
warnOrVerbose({
|
|
304
|
-
label: searchee.label,
|
|
305
|
-
message: `${foundBy} ${chalk.yellow(decision)} from ${source} - source is incomplete, saving...`,
|
|
306
|
-
});
|
|
307
|
-
break;
|
|
308
|
-
case InjectionResult.FAILURE:
|
|
309
|
-
default:
|
|
310
|
-
logger.error({
|
|
311
|
-
label: searchee.label,
|
|
312
|
-
message: `${foundBy} ${chalk.red(decision)} from ${source} - failed to inject, saving...`,
|
|
313
|
-
});
|
|
314
|
-
break;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
async function saveToOutputDir(newMeta, decision, searchee, tracker) {
|
|
318
|
-
const { outputDir } = getRuntimeConfig();
|
|
319
|
-
try {
|
|
320
|
-
const filePath = getTorrentSavePath(newMeta, getMediaType(newMeta), tracker, outputDir, { cached: false });
|
|
321
|
-
if (await exists(filePath)) {
|
|
322
|
-
await utimes(filePath, new Date(), (await stat(filePath)).mtime);
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
await writeFile(filePath, new Uint8Array(newMeta.encode()));
|
|
326
|
-
}
|
|
327
|
-
catch (e) {
|
|
328
|
-
logger.error({
|
|
329
|
-
label: searchee.label,
|
|
330
|
-
message: `Failed to save ${getLogString(newMeta, chalk.green.bold)} on ${chalk.bold(tracker)} to outputDir while processing ${chalk.bold(decision)} with ${getLogString(searchee, chalk.magenta.bold)}: ${e.message}`,
|
|
331
|
-
});
|
|
332
|
-
logger.debug(e);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
export async function performAction(newMeta, decision, searchee, tracker) {
|
|
336
|
-
return withMutex(Mutex.CLIENT_INJECTION, { useQueue: true }, async () => {
|
|
337
|
-
return performActionWithoutMutex(newMeta, decision, searchee, tracker);
|
|
338
|
-
});
|
|
339
|
-
}
|
|
340
|
-
export async function performActionWithoutMutex(newMeta, decision, searchee, tracker, injectClient, options = { onlyCompleted: true }) {
|
|
341
|
-
const { action, linkDirs } = getRuntimeConfig();
|
|
342
|
-
const warnOrVerbose = searchee.label !== Label.INJECT ? logger.warn : logger.verbose;
|
|
343
|
-
const infoOrVerbose = searchee.label !== Label.INJECT ? logger.info : logger.verbose;
|
|
344
|
-
const logActionResult = (r) => logActionResultImpl(r, newMeta, decision, searchee, tracker, infoOrVerbose, warnOrVerbose);
|
|
345
|
-
const saveTorrent = () => saveToOutputDir(newMeta, decision, searchee, tracker);
|
|
346
|
-
if (action === Action.SAVE) {
|
|
347
|
-
const actionResult = SaveResult.SAVED;
|
|
348
|
-
logActionResult(actionResult);
|
|
349
|
-
await saveTorrent();
|
|
350
|
-
return { actionResult };
|
|
351
|
-
}
|
|
352
|
-
let savePath;
|
|
353
|
-
let destinationDir;
|
|
354
|
-
let destinationDirFromClient = false;
|
|
355
|
-
let unlinkOk = false;
|
|
356
|
-
let linkedNewFiles = false;
|
|
357
|
-
try {
|
|
358
|
-
const clients = getClients();
|
|
359
|
-
let client = clients.length === 1
|
|
360
|
-
? clients[0]
|
|
361
|
-
: clients.find((c) => c.clientHost === searchee.clientHost && !c.readonly);
|
|
362
|
-
const readonlySource = !client && !!searchee.clientHost;
|
|
363
|
-
if (linkDirs.length) {
|
|
364
|
-
const savePathRes = await getSavePath(searchee, options);
|
|
365
|
-
if (savePathRes.isErr()) {
|
|
366
|
-
const result = savePathRes.unwrapErr();
|
|
367
|
-
if (result === "TORRENT_NOT_COMPLETE") {
|
|
368
|
-
const actionResult = InjectionResult.TORRENT_NOT_COMPLETE;
|
|
369
|
-
logActionResult(actionResult);
|
|
370
|
-
await saveTorrent();
|
|
371
|
-
return { client, actionResult, linkedNewFiles };
|
|
372
|
-
}
|
|
373
|
-
const actionResult = InjectionResult.FAILURE;
|
|
374
|
-
logger.error({
|
|
375
|
-
label: searchee.label,
|
|
376
|
-
message: `Failed to link files for ${getLogString(newMeta)} from ${getLogString(searchee)}: ${result}`,
|
|
377
|
-
});
|
|
378
|
-
logActionResult(actionResult);
|
|
379
|
-
await saveTorrent();
|
|
380
|
-
return { actionResult, linkedNewFiles };
|
|
381
|
-
}
|
|
382
|
-
savePath = savePathRes.unwrap();
|
|
383
|
-
const res = await getClientAndDestinationDir(client, searchee, savePath, newMeta, tracker);
|
|
384
|
-
if (res) {
|
|
385
|
-
client = res.client;
|
|
386
|
-
destinationDir = res.destinationDir;
|
|
387
|
-
destinationDirFromClient = res.destinationDirFromClient;
|
|
388
|
-
}
|
|
389
|
-
else {
|
|
390
|
-
client = undefined;
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
if (!client) {
|
|
394
|
-
logger.error({
|
|
395
|
-
label: searchee.label,
|
|
396
|
-
message: `Failed to find a torrent client for ${getLogString(searchee)}`,
|
|
397
|
-
});
|
|
398
|
-
const actionResult = InjectionResult.FAILURE;
|
|
399
|
-
logActionResult(actionResult);
|
|
400
|
-
await saveTorrent();
|
|
401
|
-
return { actionResult, linkedNewFiles };
|
|
402
|
-
}
|
|
403
|
-
for (const otherClient of clients) {
|
|
404
|
-
if (otherClient.clientHost === client.clientHost)
|
|
405
|
-
continue;
|
|
406
|
-
const existsRes = await otherClient.isTorrentInClient(newMeta.infoHash);
|
|
407
|
-
if (existsRes.isOk()) {
|
|
408
|
-
if (!existsRes.unwrap())
|
|
409
|
-
continue;
|
|
410
|
-
warnOrVerbose({
|
|
411
|
-
label: searchee.label,
|
|
412
|
-
message: `Skipping ${getLogString(newMeta)} injection into ${client.clientHost} - already exists in ${otherClient.clientHost}`,
|
|
413
|
-
});
|
|
414
|
-
}
|
|
415
|
-
else {
|
|
416
|
-
logger.error({
|
|
417
|
-
label: searchee.label,
|
|
418
|
-
message: `Failed to check if ${getLogString(newMeta)} exists in ${otherClient.clientHost}: ${existsRes.unwrapErr()}`,
|
|
419
|
-
});
|
|
420
|
-
}
|
|
421
|
-
const actionResult = InjectionResult.FAILURE;
|
|
422
|
-
logActionResult(actionResult);
|
|
423
|
-
return { actionResult, linkedNewFiles };
|
|
424
|
-
}
|
|
425
|
-
if (injectClient && injectClient.clientHost !== client.clientHost) {
|
|
426
|
-
warnOrVerbose({
|
|
427
|
-
label: searchee.label,
|
|
428
|
-
message: `Skipping ${getLogString(newMeta)} injection into ${client.clientHost} - existing match is using ${injectClient.clientHost}`,
|
|
429
|
-
});
|
|
430
|
-
const actionResult = InjectionResult.FAILURE;
|
|
431
|
-
logActionResult(actionResult);
|
|
432
|
-
return { actionResult, linkedNewFiles };
|
|
433
|
-
}
|
|
434
|
-
if (linkDirs.length) {
|
|
435
|
-
const res = await linkAllFilesInMetafile(searchee, newMeta, decision, destinationDir, { savePath, ignoreMissing: !options.onlyCompleted });
|
|
436
|
-
if (res.isErr()) {
|
|
437
|
-
logger.error({
|
|
438
|
-
label: searchee.label,
|
|
439
|
-
message: `Failed to link files for ${getLogString(newMeta)} from ${getLogString(searchee)}: ${res.unwrapErr().message}`,
|
|
440
|
-
});
|
|
441
|
-
const actionResult = InjectionResult.FAILURE;
|
|
442
|
-
logActionResult(actionResult);
|
|
443
|
-
await saveTorrent();
|
|
444
|
-
return { actionResult, linkedNewFiles };
|
|
445
|
-
}
|
|
446
|
-
const linkResult = res.unwrap();
|
|
447
|
-
unlinkOk = !linkResult.alreadyExisted;
|
|
448
|
-
linkedNewFiles = linkResult.linkedNewFiles;
|
|
449
|
-
}
|
|
450
|
-
else if (searchee.path) {
|
|
451
|
-
destinationDir = dirname(searchee.path);
|
|
452
|
-
}
|
|
453
|
-
else if (readonlySource) {
|
|
454
|
-
const savePathRes = await getSavePath(searchee, options);
|
|
455
|
-
savePath = savePathRes.orElse(undefined);
|
|
456
|
-
if (!savePath) {
|
|
457
|
-
logger.error({
|
|
458
|
-
label: searchee.label,
|
|
459
|
-
message: `Failed to find a save path for ${getLogString(searchee)}`,
|
|
460
|
-
});
|
|
461
|
-
const actionResult = InjectionResult.FAILURE;
|
|
462
|
-
logActionResult(actionResult);
|
|
463
|
-
await saveTorrent();
|
|
464
|
-
return { actionResult, linkedNewFiles };
|
|
465
|
-
}
|
|
466
|
-
destinationDir = savePath;
|
|
467
|
-
}
|
|
468
|
-
const actionResult = destinationDirFromClient
|
|
469
|
-
? InjectionResult.ALREADY_EXISTS
|
|
470
|
-
: await client.inject(newMeta, readonlySource
|
|
471
|
-
? { ...searchee, infoHash: undefined } // treat as data-based
|
|
472
|
-
: searchee, decision, {
|
|
473
|
-
onlyCompleted: options.onlyCompleted,
|
|
474
|
-
destinationDir,
|
|
475
|
-
});
|
|
476
|
-
logActionResult(actionResult);
|
|
477
|
-
if (actionResult === InjectionResult.SUCCESS) {
|
|
478
|
-
// cross-seed may need to process these with the inject job
|
|
479
|
-
if (shouldRecheck(newMeta, decision) || !searchee.infoHash) {
|
|
480
|
-
await saveTorrent();
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
else if (actionResult === InjectionResult.ALREADY_EXISTS) {
|
|
484
|
-
if (linkedNewFiles) {
|
|
485
|
-
infoOrVerbose({
|
|
486
|
-
label: client.label,
|
|
487
|
-
message: `Rechecking ${getLogString(newMeta)} as new files were linked from ${getLogString(searchee)}`,
|
|
488
|
-
});
|
|
489
|
-
await client.recheckTorrent(newMeta.infoHash);
|
|
490
|
-
void client.resumeInjection(newMeta, decision, {
|
|
491
|
-
checkOnce: false,
|
|
492
|
-
});
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
else {
|
|
496
|
-
await saveTorrent();
|
|
497
|
-
if (unlinkOk && destinationDir) {
|
|
498
|
-
await unlinkMetafile(newMeta, destinationDir, searchee.label);
|
|
499
|
-
linkedNewFiles = false;
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
if (actionResult === InjectionResult.FAILURE) {
|
|
503
|
-
return { actionResult, linkedNewFiles };
|
|
504
|
-
}
|
|
505
|
-
return {
|
|
506
|
-
client,
|
|
507
|
-
actionResult,
|
|
508
|
-
destinationDir: destinationDir ?? searchee.savePath,
|
|
509
|
-
linkedNewFiles,
|
|
510
|
-
};
|
|
511
|
-
}
|
|
512
|
-
catch (e) {
|
|
513
|
-
logger.error({
|
|
514
|
-
label: searchee.label,
|
|
515
|
-
message: e,
|
|
516
|
-
});
|
|
517
|
-
const actionResult = InjectionResult.FAILURE;
|
|
518
|
-
logActionResult(actionResult);
|
|
519
|
-
await saveTorrent();
|
|
520
|
-
return { actionResult, linkedNewFiles };
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
export async function performActions(searchee, matches) {
|
|
524
|
-
const results = [];
|
|
525
|
-
for (const { tracker, assessment } of matches) {
|
|
526
|
-
const { actionResult } = await performAction(assessment.metafile, assessment.decision, searchee, tracker);
|
|
527
|
-
results.push(actionResult);
|
|
528
|
-
}
|
|
529
|
-
return results;
|
|
530
|
-
}
|
|
531
|
-
async function getLinkDir(pathStr) {
|
|
532
|
-
const { linkDirs, linkType } = getRuntimeConfig();
|
|
533
|
-
const pathStat = await stat(pathStr);
|
|
534
|
-
const pathDev = pathStat.dev; // Windows always returns 0
|
|
535
|
-
if (pathDev) {
|
|
536
|
-
const devs = await mapAsync(linkDirs, async (d) => (await stat(d)).dev);
|
|
537
|
-
if (new Set(devs).size === linkDirs.length) {
|
|
538
|
-
for (const [index, linkDir] of linkDirs.entries()) {
|
|
539
|
-
if (devs[index] === pathDev)
|
|
540
|
-
return linkDir;
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
let srcFile = pathStat.isFile()
|
|
545
|
-
? pathStr
|
|
546
|
-
: pathStat.isDirectory()
|
|
547
|
-
? await findAFileWithExt(pathStr, ALL_EXTENSIONS)
|
|
548
|
-
: null;
|
|
549
|
-
let tempFile;
|
|
550
|
-
if (!srcFile) {
|
|
551
|
-
tempFile = pathStat.isDirectory()
|
|
552
|
-
? join(pathStr, linkDirSrcName)
|
|
553
|
-
: join(dirname(pathStr), linkDirSrcName);
|
|
554
|
-
try {
|
|
555
|
-
await writeFile(tempFile, "");
|
|
556
|
-
srcFile = tempFile;
|
|
557
|
-
}
|
|
558
|
-
catch (e) {
|
|
559
|
-
logger.debug(e);
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
if (srcFile) {
|
|
563
|
-
for (const linkDir of linkDirs) {
|
|
564
|
-
try {
|
|
565
|
-
const testPath = join(linkDir, linkDirDestName);
|
|
566
|
-
await linkFile(srcFile, testPath, linkType === LinkType.REFLINK ||
|
|
567
|
-
linkType === LinkType.REFLINK_OR_COPY
|
|
568
|
-
? linkType
|
|
569
|
-
: LinkType.HARDLINK);
|
|
570
|
-
await rm(testPath);
|
|
571
|
-
if (tempFile && (await exists(tempFile)))
|
|
572
|
-
await rm(tempFile);
|
|
573
|
-
return linkDir;
|
|
574
|
-
}
|
|
575
|
-
catch {
|
|
576
|
-
continue;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
if (tempFile && (await exists(tempFile)))
|
|
581
|
-
await rm(tempFile);
|
|
582
|
-
if (linkType !== LinkType.SYMLINK) {
|
|
583
|
-
logger.error(`Cannot find any linkDir from linkDirs on the same drive to ${linkType} ${pathStr}`);
|
|
584
|
-
return null;
|
|
585
|
-
}
|
|
586
|
-
if (linkDirs.length > 1) {
|
|
587
|
-
logger.warn(`Cannot find any linkDir from linkDirs on the same drive, using first linkDir for symlink: ${pathStr}`);
|
|
588
|
-
}
|
|
589
|
-
return linkDirs[0];
|
|
590
|
-
}
|
|
591
|
-
async function getLinkDirVirtual(searchee) {
|
|
592
|
-
const linkDir = await getLinkDir(searchee.files[0].path);
|
|
593
|
-
if (!linkDir)
|
|
594
|
-
return null;
|
|
595
|
-
for (let i = 1; i < searchee.files.length; i++) {
|
|
596
|
-
if ((await getLinkDir(searchee.files[i].path)) !== linkDir) {
|
|
597
|
-
logger.error(`Cannot link files to multiple linkDirs for seasonFromEpisodes aggregation, source episodes are spread across multiple drives.`);
|
|
598
|
-
return null;
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
return linkDir;
|
|
602
|
-
}
|
|
603
|
-
async function linkFile(oldPath, newPath, linkType) {
|
|
604
|
-
if (!linkType)
|
|
605
|
-
linkType = getRuntimeConfig().linkType;
|
|
606
|
-
try {
|
|
607
|
-
const ogFileResolvedPath = await unwrapSymlinks(oldPath);
|
|
608
|
-
switch (linkType) {
|
|
609
|
-
case LinkType.HARDLINK:
|
|
610
|
-
await link(ogFileResolvedPath, newPath);
|
|
611
|
-
break;
|
|
612
|
-
case LinkType.SYMLINK:
|
|
613
|
-
// we need to resolve because symlinks are resolved outside
|
|
614
|
-
// the context of cross-seed's working directory
|
|
615
|
-
await symlink(ogFileResolvedPath, resolve(newPath));
|
|
616
|
-
break;
|
|
617
|
-
case LinkType.REFLINK:
|
|
618
|
-
await copyFile(ogFileResolvedPath, newPath, fs.constants.COPYFILE_FICLONE_FORCE);
|
|
619
|
-
break;
|
|
620
|
-
case LinkType.REFLINK_OR_COPY:
|
|
621
|
-
// this will silently fall back to copy if it can't reflink
|
|
622
|
-
await copyFile(ogFileResolvedPath, newPath, fs.constants.COPYFILE_FICLONE);
|
|
623
|
-
break;
|
|
624
|
-
default:
|
|
625
|
-
throw new Error(`Unsupported linkType: ${linkType}`);
|
|
626
|
-
}
|
|
627
|
-
return true;
|
|
628
|
-
}
|
|
629
|
-
catch (e) {
|
|
630
|
-
if (e.code === "EEXIST")
|
|
631
|
-
return false;
|
|
632
|
-
throw e;
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
/**
|
|
636
|
-
* Recursively resolves symlinks to the original file. Differs from realpath
|
|
637
|
-
* in that it will not resolve directory symlinks in the middle of the path.
|
|
638
|
-
* @param path
|
|
639
|
-
*/
|
|
640
|
-
async function unwrapSymlinks(path) {
|
|
641
|
-
for (let i = 0; i < 16; i++) {
|
|
642
|
-
if (!(await lstat(path)).isSymbolicLink())
|
|
643
|
-
return path;
|
|
644
|
-
path = resolve(dirname(path), await readlink(path));
|
|
645
|
-
}
|
|
646
|
-
throw new Error(`too many levels of symbolic links at ${path}`);
|
|
647
|
-
}
|
|
648
|
-
/**
|
|
649
|
-
* Tests if srcDir supports linkType.
|
|
650
|
-
* @param srcDir The directory to link from
|
|
651
|
-
* @param testSrcName A unique name.cross-seed to create in srcDir if necessary
|
|
652
|
-
* @param testDestName A unique name.cross-seed to create in the linkDir
|
|
653
|
-
* @returns true if the test was successful, false if it failed (or throws)
|
|
654
|
-
*/
|
|
655
|
-
export async function testLinking(srcDir, testSrcName, testDestName) {
|
|
656
|
-
const { linkDirs, linkType } = getRuntimeConfig();
|
|
657
|
-
let tempFile;
|
|
658
|
-
try {
|
|
659
|
-
let srcFile = await findAFileWithExt(srcDir, ALL_EXTENSIONS);
|
|
660
|
-
if (!srcFile) {
|
|
661
|
-
if (!(await verifyDir(srcDir, testSrcName, fs.constants.R_OK | fs.constants.W_OK))) {
|
|
662
|
-
logger.error(`cross-seed is unable to verify linking for ${srcDir} (likely due to incorrect/insufficient volume mounts https://www.cross-seed.org/docs/tutorials/linking#configuring-linkdirs).`);
|
|
663
|
-
return false;
|
|
664
|
-
}
|
|
665
|
-
tempFile = join(srcDir, testSrcName);
|
|
666
|
-
try {
|
|
667
|
-
await writeFile(tempFile, testSrcName);
|
|
668
|
-
srcFile = tempFile;
|
|
669
|
-
}
|
|
670
|
-
catch (e) {
|
|
671
|
-
logger.debug(e);
|
|
672
|
-
logger.error(`Failed to create test file in ${srcDir} for linking test.`);
|
|
673
|
-
return false;
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
const linkDir = await getLinkDir(srcDir);
|
|
677
|
-
if (!linkDir)
|
|
678
|
-
throw new Error(`No valid linkDir found for ${srcDir}`);
|
|
679
|
-
const testPath = join(linkDir, testDestName);
|
|
680
|
-
await linkFile(srcFile, testPath);
|
|
681
|
-
await rm(testPath);
|
|
682
|
-
return true;
|
|
683
|
-
}
|
|
684
|
-
catch (e) {
|
|
685
|
-
logger.error(e);
|
|
686
|
-
throw new CrossSeedError(`Failed to create a test ${linkType} from ${srcDir} in any linkDirs: [${formatAsList(linkDirs.map((d) => `"${d}"`), { sort: false, style: "short", type: "unit" })}]. Ensure that ${linkType} is supported between these paths (hardlink/reflink requires same drive, partition, and volume).`);
|
|
687
|
-
}
|
|
688
|
-
finally {
|
|
689
|
-
if (tempFile && (await exists(tempFile)))
|
|
690
|
-
await rm(tempFile);
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
//# sourceMappingURL=action.js.map
|