@aphrody/frames 0.2.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/README.md +161 -0
- package/package.json +45 -0
- package/src/descriptor.ts +134 -0
- package/src/ffmpeg.ts +298 -0
- package/src/index.ts +346 -0
- package/src/search.ts +164 -0
- package/src/store.ts +259 -0
- package/src/trace-moe.ts +368 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* L'index local : une base SQLite de vecteurs, un par trame échantillonnée.
|
|
4
|
+
*
|
|
5
|
+
* Un vecteur ColorLayout tient sur 33 octets, donc un épisode de 24 minutes
|
|
6
|
+
* échantillonné à 1 image/s pèse une cinquantaine de kilo-octets — les 412
|
|
7
|
+
* épisodes du catalogue IETV tiennent dans une vingtaine de méga-octets. À
|
|
8
|
+
* cette taille, la recherche par force brute est plus rapide que n'importe
|
|
9
|
+
* quel index approché, et surtout elle reste exacte : pas de base vectorielle,
|
|
10
|
+
* pas de service à faire tourner.
|
|
11
|
+
*
|
|
12
|
+
* La table `frames` est `WITHOUT ROWID` : sa clé primaire (média, horodatage)
|
|
13
|
+
* est déjà l'ordre de lecture du balayage, autant s'en servir comme stockage.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Database } from "bun:sqlite";
|
|
17
|
+
import { mkdirSync } from "node:fs";
|
|
18
|
+
import { dirname, resolve } from "node:path";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { CL_DIMS } from "./descriptor.ts";
|
|
21
|
+
|
|
22
|
+
/** Ce qu'on sait d'un média avant de l'indexer. */
|
|
23
|
+
export interface MediaMeta {
|
|
24
|
+
/** Chemin ou URL du média — identifiant unique dans l'index. */
|
|
25
|
+
source: string;
|
|
26
|
+
title: string;
|
|
27
|
+
season?: number | null;
|
|
28
|
+
episode?: number | null;
|
|
29
|
+
/** Durée du média en millisecondes (0 si inconnue). */
|
|
30
|
+
durationMs?: number;
|
|
31
|
+
/** Cadence d'échantillonnage retenue, en trames par seconde. */
|
|
32
|
+
fps: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Une ligne de la table `media`. */
|
|
36
|
+
export interface MediaRow extends MediaMeta {
|
|
37
|
+
id: number;
|
|
38
|
+
durationMs: number;
|
|
39
|
+
frameCount: number;
|
|
40
|
+
indexedAt: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Une trame indexée. */
|
|
44
|
+
export interface FrameRow {
|
|
45
|
+
mediaId: number;
|
|
46
|
+
tMs: number;
|
|
47
|
+
vector: Uint8Array;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Compteurs de l'index. */
|
|
51
|
+
export interface IndexStats {
|
|
52
|
+
media: number;
|
|
53
|
+
frames: number;
|
|
54
|
+
/** Durée cumulée des médias indexés, en millisecondes. */
|
|
55
|
+
durationMs: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Chemin par défaut de l'index (surchargé par `BXC_FRAMES_DB`). */
|
|
59
|
+
export function defaultIndexPath(): string {
|
|
60
|
+
const fromEnv = process.env.BXC_FRAMES_DB;
|
|
61
|
+
if (fromEnv) return resolve(fromEnv);
|
|
62
|
+
return resolve(homedir(), ".cache/bxc/frames.db");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class FrameIndex {
|
|
66
|
+
private readonly db: Database;
|
|
67
|
+
public readonly path: string;
|
|
68
|
+
|
|
69
|
+
constructor(path: string = defaultIndexPath()) {
|
|
70
|
+
this.path = path === ":memory:" ? path : resolve(path.replace(/^~(?=\/|$)/, homedir()));
|
|
71
|
+
if (this.path !== ":memory:") mkdirSync(dirname(this.path), { recursive: true });
|
|
72
|
+
this.db = new Database(this.path, { create: true });
|
|
73
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
74
|
+
this.db.exec("PRAGMA synchronous = NORMAL");
|
|
75
|
+
this.initSchema();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private initSchema(): void {
|
|
79
|
+
this.db.exec(`
|
|
80
|
+
CREATE TABLE IF NOT EXISTS media (
|
|
81
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
82
|
+
source TEXT UNIQUE NOT NULL,
|
|
83
|
+
title TEXT NOT NULL,
|
|
84
|
+
season INTEGER,
|
|
85
|
+
episode INTEGER,
|
|
86
|
+
durationMs INTEGER NOT NULL DEFAULT 0,
|
|
87
|
+
fps REAL NOT NULL,
|
|
88
|
+
frameCount INTEGER NOT NULL DEFAULT 0,
|
|
89
|
+
indexedAt INTEGER NOT NULL
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
CREATE TABLE IF NOT EXISTS frames (
|
|
93
|
+
mediaId INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE,
|
|
94
|
+
tMs INTEGER NOT NULL,
|
|
95
|
+
cl BLOB NOT NULL,
|
|
96
|
+
PRIMARY KEY (mediaId, tMs)
|
|
97
|
+
) WITHOUT ROWID;
|
|
98
|
+
|
|
99
|
+
CREATE INDEX IF NOT EXISTS idx_media_episode ON media(season, episode);
|
|
100
|
+
`);
|
|
101
|
+
this.db.exec("PRAGMA foreign_keys = ON");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Crée ou met à jour l'entrée d'un média et rend son identifiant. */
|
|
105
|
+
upsertMedia(meta: MediaMeta, now: number = Date.now()): number {
|
|
106
|
+
const row = this.db
|
|
107
|
+
.query<{ id: number }, [string]>("SELECT id FROM media WHERE source = ?")
|
|
108
|
+
.get(meta.source);
|
|
109
|
+
if (row) {
|
|
110
|
+
this.db.run(
|
|
111
|
+
"UPDATE media SET title = ?, season = ?, episode = ?, durationMs = ?, fps = ?, indexedAt = ? WHERE id = ?",
|
|
112
|
+
[
|
|
113
|
+
meta.title,
|
|
114
|
+
meta.season ?? null,
|
|
115
|
+
meta.episode ?? null,
|
|
116
|
+
meta.durationMs ?? 0,
|
|
117
|
+
meta.fps,
|
|
118
|
+
now,
|
|
119
|
+
row.id,
|
|
120
|
+
],
|
|
121
|
+
);
|
|
122
|
+
return row.id;
|
|
123
|
+
}
|
|
124
|
+
this.db.run(
|
|
125
|
+
"INSERT INTO media (source, title, season, episode, durationMs, fps, frameCount, indexedAt) VALUES (?, ?, ?, ?, ?, ?, 0, ?)",
|
|
126
|
+
[
|
|
127
|
+
meta.source,
|
|
128
|
+
meta.title,
|
|
129
|
+
meta.season ?? null,
|
|
130
|
+
meta.episode ?? null,
|
|
131
|
+
meta.durationMs ?? 0,
|
|
132
|
+
meta.fps,
|
|
133
|
+
now,
|
|
134
|
+
],
|
|
135
|
+
);
|
|
136
|
+
return Number(
|
|
137
|
+
this.db.query<{ id: number }, []>("SELECT last_insert_rowid() AS id").get()?.id ?? 0,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Insère un lot de trames dans une seule transaction et rend le nombre de
|
|
143
|
+
* lignes écrites. Réindexer un média écrase ses trames : l'opération est
|
|
144
|
+
* donc rejouable telle quelle après une interruption.
|
|
145
|
+
*/
|
|
146
|
+
insertFrames(mediaId: number, frames: Iterable<{ tMs: number; vector: Uint8Array }>): number {
|
|
147
|
+
const stmt = this.db.prepare(
|
|
148
|
+
"INSERT OR REPLACE INTO frames (mediaId, tMs, cl) VALUES (?, ?, ?)",
|
|
149
|
+
);
|
|
150
|
+
let written = 0;
|
|
151
|
+
const tx = this.db.transaction((batch: Iterable<{ tMs: number; vector: Uint8Array }>) => {
|
|
152
|
+
for (const frame of batch) {
|
|
153
|
+
if (frame.vector.length !== CL_DIMS) {
|
|
154
|
+
throw new Error(`vecteur de ${CL_DIMS} octets attendu, reçu ${frame.vector.length}`);
|
|
155
|
+
}
|
|
156
|
+
stmt.run(mediaId, frame.tMs, frame.vector);
|
|
157
|
+
written++;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
tx(frames);
|
|
161
|
+
this.db.run(
|
|
162
|
+
"UPDATE media SET frameCount = (SELECT COUNT(*) FROM frames WHERE mediaId = ?) WHERE id = ?",
|
|
163
|
+
[mediaId, mediaId],
|
|
164
|
+
);
|
|
165
|
+
return written;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Efface les trames d'un média sans toucher à son entrée. */
|
|
169
|
+
clearFrames(mediaId: number): void {
|
|
170
|
+
this.db.run("DELETE FROM frames WHERE mediaId = ?", [mediaId]);
|
|
171
|
+
this.db.run("UPDATE media SET frameCount = 0 WHERE id = ?", [mediaId]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Supprime un média et ses trames. */
|
|
175
|
+
deleteMedia(mediaId: number): void {
|
|
176
|
+
this.db.run("DELETE FROM frames WHERE mediaId = ?", [mediaId]);
|
|
177
|
+
this.db.run("DELETE FROM media WHERE id = ?", [mediaId]);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Tous les médias indexés, du plus récent au plus ancien. */
|
|
181
|
+
listMedia(): MediaRow[] {
|
|
182
|
+
return this.db
|
|
183
|
+
.query<MediaRow, []>("SELECT * FROM media ORDER BY season, episode, id")
|
|
184
|
+
.all();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Le média d'identifiant donné, ou `null`. */
|
|
188
|
+
getMedia(mediaId: number): MediaRow | null {
|
|
189
|
+
return (
|
|
190
|
+
this.db.query<MediaRow, [number]>("SELECT * FROM media WHERE id = ?").get(mediaId) ?? null
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Le média d'une source donnée, ou `null`. */
|
|
195
|
+
findMedia(source: string): MediaRow | null {
|
|
196
|
+
return (
|
|
197
|
+
this.db.query<MediaRow, [string]>("SELECT * FROM media WHERE source = ?").get(source) ?? null
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Balaie les trames dans l'ordre (média, horodatage).
|
|
203
|
+
*
|
|
204
|
+
* L'itération va par pages : une recherche ne charge jamais l'index entier
|
|
205
|
+
* en mémoire, même sur un catalogue de plusieurs centaines d'épisodes.
|
|
206
|
+
*/
|
|
207
|
+
*iterateFrames(mediaId?: number, pageSize = 50_000): Generator<FrameRow> {
|
|
208
|
+
const stmt = mediaId
|
|
209
|
+
? this.db.query<{ mediaId: number; tMs: number; cl: Uint8Array }, [number, number, number]>(
|
|
210
|
+
"SELECT mediaId, tMs, cl FROM frames WHERE mediaId = ? AND tMs > ? ORDER BY tMs LIMIT ?",
|
|
211
|
+
)
|
|
212
|
+
: this.db.query<
|
|
213
|
+
{ mediaId: number; tMs: number; cl: Uint8Array },
|
|
214
|
+
[number, number, number]
|
|
215
|
+
>(
|
|
216
|
+
"SELECT mediaId, tMs, cl FROM frames WHERE (mediaId, tMs) > (?, ?) ORDER BY mediaId, tMs LIMIT ?",
|
|
217
|
+
);
|
|
218
|
+
let lastMedia = mediaId ?? 0;
|
|
219
|
+
let lastT = -1;
|
|
220
|
+
for (;;) {
|
|
221
|
+
const rows = mediaId
|
|
222
|
+
? stmt.all(mediaId, lastT, pageSize)
|
|
223
|
+
: stmt.all(lastMedia, lastT, pageSize);
|
|
224
|
+
if (!rows.length) return;
|
|
225
|
+
for (const row of rows) {
|
|
226
|
+
yield { mediaId: row.mediaId, tMs: row.tMs, vector: row.cl };
|
|
227
|
+
lastMedia = row.mediaId;
|
|
228
|
+
lastT = row.tMs;
|
|
229
|
+
}
|
|
230
|
+
if (rows.length < pageSize) return;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Les trames d'un média sur une plage de temps, bornes incluses. */
|
|
235
|
+
framesBetween(mediaId: number, fromMs: number, toMs: number): FrameRow[] {
|
|
236
|
+
return this.db
|
|
237
|
+
.query<
|
|
238
|
+
{ mediaId: number; tMs: number; cl: Uint8Array },
|
|
239
|
+
[number, number, number]
|
|
240
|
+
>("SELECT mediaId, tMs, cl FROM frames WHERE mediaId = ? AND tMs BETWEEN ? AND ? ORDER BY tMs")
|
|
241
|
+
.all(mediaId, fromMs, toMs)
|
|
242
|
+
.map((row) => ({ mediaId: row.mediaId, tMs: row.tMs, vector: row.cl }));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Compteurs globaux de l'index. */
|
|
246
|
+
stats(): IndexStats {
|
|
247
|
+
const row = this.db
|
|
248
|
+
.query<
|
|
249
|
+
{ media: number; frames: number; durationMs: number },
|
|
250
|
+
[]
|
|
251
|
+
>("SELECT (SELECT COUNT(*) FROM media) AS media, (SELECT COUNT(*) FROM frames) AS frames, (SELECT COALESCE(SUM(durationMs), 0) FROM media) AS durationMs")
|
|
252
|
+
.get();
|
|
253
|
+
return row ?? { media: 0, frames: 0, durationMs: 0 };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
close(): void {
|
|
257
|
+
this.db.close();
|
|
258
|
+
}
|
|
259
|
+
}
|
package/src/trace-moe.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Client de l'API publique trace.moe — le recours, pas le chemin par défaut.
|
|
4
|
+
*
|
|
5
|
+
* Deux raisons de ne l'appeler qu'en second : le quota (100 recherches par
|
|
6
|
+
* 24 h sans clé, une seule requête à la fois) et la confidentialité — envoyer
|
|
7
|
+
* une image à un tiers, c'est lui confier ce qu'on cherche. D'où
|
|
8
|
+
* {@link TraceMoeClient.searchByVector}, le chemin privilégié ici : le
|
|
9
|
+
* descripteur est calculé en local et seuls 33 entiers partent sur le réseau,
|
|
10
|
+
* jamais l'image. C'est aussi le plus rapide, puisque le serveur n'a plus à
|
|
11
|
+
* télécharger ni décoder quoi que ce soit.
|
|
12
|
+
*
|
|
13
|
+
* Les trois freins de l'API (quota glissant, concurrence 1, file d'attente
|
|
14
|
+
* priorisée) se traduisent ici par une file interne stricte et une taxonomie
|
|
15
|
+
* d'erreurs : ce qui se retente ({@link TraceMoeError.retryable}) et ce qui ne
|
|
16
|
+
* se retente pas. Horloge, attente et aléa sont injectables — les budgets se
|
|
17
|
+
* testent sans attendre.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { encodeVector } from "./descriptor.ts";
|
|
21
|
+
|
|
22
|
+
/** Point d'entrée public de l'API. */
|
|
23
|
+
export const TRACE_MOE_ENDPOINT = "https://api.trace.moe";
|
|
24
|
+
|
|
25
|
+
/** Nature d'un échec, pour décider quoi faire ensuite. */
|
|
26
|
+
export type TraceMoeErrorKind =
|
|
27
|
+
| "quota"
|
|
28
|
+
| "concurrency"
|
|
29
|
+
| "rate-limit"
|
|
30
|
+
| "busy"
|
|
31
|
+
| "bad-request"
|
|
32
|
+
| "too-large"
|
|
33
|
+
| "server"
|
|
34
|
+
| "network";
|
|
35
|
+
|
|
36
|
+
/** Échec d'un appel à l'API, classé. */
|
|
37
|
+
export class TraceMoeError extends Error {
|
|
38
|
+
constructor(
|
|
39
|
+
public readonly kind: TraceMoeErrorKind,
|
|
40
|
+
message: string,
|
|
41
|
+
public readonly status?: number,
|
|
42
|
+
public readonly retryAfterMs?: number,
|
|
43
|
+
) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "TraceMoeError";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Vrai si réessayer plus tard a une chance d'aboutir. */
|
|
49
|
+
get retryable(): boolean {
|
|
50
|
+
return (
|
|
51
|
+
this.kind === "concurrency" ||
|
|
52
|
+
this.kind === "rate-limit" ||
|
|
53
|
+
this.kind === "busy" ||
|
|
54
|
+
this.kind === "server" ||
|
|
55
|
+
this.kind === "network"
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Fiche AniList renvoyée quand `anilistInfo` est demandé. */
|
|
61
|
+
export interface AnilistInfo {
|
|
62
|
+
id: number;
|
|
63
|
+
idMal?: number | null;
|
|
64
|
+
title?: { native?: string | null; romaji?: string | null; english?: string | null };
|
|
65
|
+
synonyms?: string[];
|
|
66
|
+
isAdult?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Une correspondance renvoyée par l'API. */
|
|
70
|
+
export interface TraceMoeResult {
|
|
71
|
+
anilist: number | AnilistInfo;
|
|
72
|
+
filename: string;
|
|
73
|
+
episode: number | number[] | null;
|
|
74
|
+
episode_start?: number | null;
|
|
75
|
+
episode_end?: number | null;
|
|
76
|
+
duration?: number;
|
|
77
|
+
from: number;
|
|
78
|
+
at: number;
|
|
79
|
+
to: number;
|
|
80
|
+
similarity: number;
|
|
81
|
+
video: string;
|
|
82
|
+
image: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Réponse de `/search`. */
|
|
86
|
+
export interface TraceMoeResponse {
|
|
87
|
+
frameCount: number;
|
|
88
|
+
error: string;
|
|
89
|
+
quota: number;
|
|
90
|
+
quotaUsed: number;
|
|
91
|
+
result: TraceMoeResult[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Réponse de `/me` : l'état du quota. */
|
|
95
|
+
export interface TraceMoeQuota {
|
|
96
|
+
id: string;
|
|
97
|
+
priority: number;
|
|
98
|
+
concurrency: number;
|
|
99
|
+
quota: number;
|
|
100
|
+
quotaUsed: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Points d'injection et réglages du client. */
|
|
104
|
+
export interface TraceMoeOptions {
|
|
105
|
+
endpoint?: string;
|
|
106
|
+
/** Clé d'API, envoyée en en-tête `x-trace-key` (jamais en query string). */
|
|
107
|
+
apiKey?: string;
|
|
108
|
+
fetch?: typeof fetch;
|
|
109
|
+
now?: () => number;
|
|
110
|
+
sleep?: (ms: number) => Promise<void>;
|
|
111
|
+
random?: () => number;
|
|
112
|
+
/** Nombre de reprises après une erreur retentable (défaut : 3). */
|
|
113
|
+
maxRetries?: number;
|
|
114
|
+
/** Attente minimale entre deux requêtes, en ms (défaut : 250). */
|
|
115
|
+
minDelayMs?: number;
|
|
116
|
+
/** Base du délai exponentiel de reprise, en ms (défaut : 1000). */
|
|
117
|
+
backoffBaseMs?: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Options communes aux recherches. */
|
|
121
|
+
export interface TraceMoeSearchOptions {
|
|
122
|
+
/** Joindre la fiche AniList (plus lent côté serveur). */
|
|
123
|
+
anilistInfo?: boolean;
|
|
124
|
+
/** Restreindre la recherche à un anime. */
|
|
125
|
+
anilistID?: number;
|
|
126
|
+
/** Rogner les bandes noires — sans effet sur une recherche par vecteur. */
|
|
127
|
+
cutBorders?: boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const defaultSleep = (ms: number): Promise<void> =>
|
|
131
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
132
|
+
|
|
133
|
+
export class TraceMoeClient {
|
|
134
|
+
private readonly endpoint: string;
|
|
135
|
+
private readonly apiKey?: string;
|
|
136
|
+
private readonly fetchFn: typeof fetch;
|
|
137
|
+
private readonly now: () => number;
|
|
138
|
+
private readonly sleep: (ms: number) => Promise<void>;
|
|
139
|
+
private readonly random: () => number;
|
|
140
|
+
private readonly maxRetries: number;
|
|
141
|
+
private readonly minDelayMs: number;
|
|
142
|
+
private readonly backoffBaseMs: number;
|
|
143
|
+
|
|
144
|
+
/** File d'attente : une requête à la fois, c'est la limite du service. */
|
|
145
|
+
private tail: Promise<unknown> = Promise.resolve();
|
|
146
|
+
/** −∞ tant qu'aucun appel n'est parti : la première requête ne s'espace de rien. */
|
|
147
|
+
private lastCallAt = Number.NEGATIVE_INFINITY;
|
|
148
|
+
private lastQuota: TraceMoeQuota | null = null;
|
|
149
|
+
|
|
150
|
+
constructor(opts: TraceMoeOptions = {}) {
|
|
151
|
+
this.endpoint = (opts.endpoint ?? TRACE_MOE_ENDPOINT).replace(/\/$/, "");
|
|
152
|
+
this.apiKey = opts.apiKey ?? process.env.TRACE_MOE_KEY ?? undefined;
|
|
153
|
+
this.fetchFn = opts.fetch ?? globalThis.fetch;
|
|
154
|
+
this.now = opts.now ?? Date.now;
|
|
155
|
+
this.sleep = opts.sleep ?? defaultSleep;
|
|
156
|
+
this.random = opts.random ?? Math.random;
|
|
157
|
+
this.maxRetries = opts.maxRetries ?? 3;
|
|
158
|
+
this.minDelayMs = opts.minDelayMs ?? 250;
|
|
159
|
+
this.backoffBaseMs = opts.backoffBaseMs ?? 1000;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Dernier état de quota observé, `null` tant qu'aucun appel n'a abouti. */
|
|
163
|
+
get quota(): TraceMoeQuota | null {
|
|
164
|
+
return this.lastQuota;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Recherche par vecteur : rien d'autre que le descripteur ne quitte la
|
|
169
|
+
* machine. Accepte un vecteur, son encodage base64, ou un lot (10 au plus,
|
|
170
|
+
* chacun décompté du quota).
|
|
171
|
+
*/
|
|
172
|
+
searchByVector(
|
|
173
|
+
vector: number[] | string | Array<number[] | string>,
|
|
174
|
+
opts: TraceMoeSearchOptions = {},
|
|
175
|
+
): Promise<TraceMoeResponse> {
|
|
176
|
+
const encode = (v: number[] | string): string =>
|
|
177
|
+
typeof v === "string" ? v : encodeVector(v);
|
|
178
|
+
const payload = Array.isArray(vector) && Array.isArray(vector[0])
|
|
179
|
+
? (vector as Array<number[] | string>).map(encode)
|
|
180
|
+
: Array.isArray(vector) && typeof vector[0] === "string"
|
|
181
|
+
? (vector as string[])
|
|
182
|
+
: encode(vector as number[] | string);
|
|
183
|
+
return this.request<TraceMoeResponse>("/search", {
|
|
184
|
+
method: "POST",
|
|
185
|
+
query: this.searchQuery(opts),
|
|
186
|
+
headers: { "Content-Type": "application/json" },
|
|
187
|
+
body: JSON.stringify({ vector: payload }),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Recherche en téléversant l'image (25 Mo au plus). */
|
|
192
|
+
searchByImage(
|
|
193
|
+
bytes: Uint8Array | ArrayBuffer,
|
|
194
|
+
opts: TraceMoeSearchOptions & { contentType?: string } = {},
|
|
195
|
+
): Promise<TraceMoeResponse> {
|
|
196
|
+
const body = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
197
|
+
return this.request<TraceMoeResponse>("/search", {
|
|
198
|
+
method: "POST",
|
|
199
|
+
query: this.searchQuery(opts),
|
|
200
|
+
headers: { "Content-Type": opts.contentType ?? "image/jpeg" },
|
|
201
|
+
body,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Recherche en confiant au serveur le soin de télécharger l'image. */
|
|
206
|
+
searchByUrl(url: string, opts: TraceMoeSearchOptions = {}): Promise<TraceMoeResponse> {
|
|
207
|
+
return this.request<TraceMoeResponse>("/search", {
|
|
208
|
+
method: "GET",
|
|
209
|
+
query: { ...this.searchQuery(opts), url },
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** État du quota du compte (ou de l'adresse IP, sans clé). */
|
|
214
|
+
async me(): Promise<TraceMoeQuota> {
|
|
215
|
+
const quota = await this.request<TraceMoeQuota>("/me", { method: "GET" });
|
|
216
|
+
this.lastQuota = quota;
|
|
217
|
+
return quota;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** État de l'index public, ou la liste des fichiers indexés d'un anime. */
|
|
221
|
+
status(anilistID?: number): Promise<unknown> {
|
|
222
|
+
return this.request("/status", {
|
|
223
|
+
method: "GET",
|
|
224
|
+
query: anilistID ? { id: String(anilistID) } : {},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Recherche d'un anime par titre dans l'index public. */
|
|
229
|
+
anilist(query: string): Promise<unknown> {
|
|
230
|
+
return this.request("/anilist", { method: "GET", query: { q: query } });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private searchQuery(opts: TraceMoeSearchOptions): Record<string, string> {
|
|
234
|
+
const query: Record<string, string> = {};
|
|
235
|
+
if (opts.anilistInfo) query.anilistInfo = "";
|
|
236
|
+
if (opts.cutBorders) query.cutBorders = "";
|
|
237
|
+
if (opts.anilistID !== undefined) query.anilistID = String(opts.anilistID);
|
|
238
|
+
return query;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Sérialise les appels, espace les requêtes et reprend ce qui mérite de
|
|
243
|
+
* l'être. Le 402 est ambigu côté serveur — quota épuisé *ou* requête
|
|
244
|
+
* concurrente ; on tranche avec le quota connu, sinon on retente une fois.
|
|
245
|
+
*/
|
|
246
|
+
private request<T>(
|
|
247
|
+
path: string,
|
|
248
|
+
init: {
|
|
249
|
+
method: string;
|
|
250
|
+
query?: Record<string, string>;
|
|
251
|
+
headers?: Record<string, string>;
|
|
252
|
+
body?: Uint8Array | string;
|
|
253
|
+
},
|
|
254
|
+
): Promise<T> {
|
|
255
|
+
const run = async (): Promise<T> => {
|
|
256
|
+
for (let attempt = 0; ; attempt++) {
|
|
257
|
+
const wait = this.minDelayMs - (this.now() - this.lastCallAt);
|
|
258
|
+
if (wait > 0) await this.sleep(wait);
|
|
259
|
+
try {
|
|
260
|
+
return await this.call<T>(path, init);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
const error =
|
|
263
|
+
err instanceof TraceMoeError
|
|
264
|
+
? err
|
|
265
|
+
: new TraceMoeError("network", String((err as Error)?.message ?? err));
|
|
266
|
+
if (!error.retryable || attempt >= this.maxRetries) throw error;
|
|
267
|
+
const backoff =
|
|
268
|
+
error.retryAfterMs ??
|
|
269
|
+
this.backoffBaseMs * 2 ** attempt * (1 + this.random());
|
|
270
|
+
await this.sleep(Math.round(backoff));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
const queued = this.tail.then(run, run);
|
|
275
|
+
// La file ne doit pas se rompre sur un échec : on la fait avancer quoi qu'il arrive.
|
|
276
|
+
this.tail = queued.then(
|
|
277
|
+
() => undefined,
|
|
278
|
+
() => undefined,
|
|
279
|
+
);
|
|
280
|
+
return queued;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private async call<T>(
|
|
284
|
+
path: string,
|
|
285
|
+
init: {
|
|
286
|
+
method: string;
|
|
287
|
+
query?: Record<string, string>;
|
|
288
|
+
headers?: Record<string, string>;
|
|
289
|
+
body?: Uint8Array | string;
|
|
290
|
+
},
|
|
291
|
+
): Promise<T> {
|
|
292
|
+
const url = new URL(this.endpoint + path);
|
|
293
|
+
for (const [key, value] of Object.entries(init.query ?? {})) {
|
|
294
|
+
url.searchParams.set(key, value);
|
|
295
|
+
}
|
|
296
|
+
const headers: Record<string, string> = { ...init.headers };
|
|
297
|
+
if (this.apiKey) headers["x-trace-key"] = this.apiKey;
|
|
298
|
+
|
|
299
|
+
let response: Response;
|
|
300
|
+
try {
|
|
301
|
+
response = await this.fetchFn(url.toString(), {
|
|
302
|
+
method: init.method,
|
|
303
|
+
headers,
|
|
304
|
+
body: init.body as BodyInit | undefined,
|
|
305
|
+
});
|
|
306
|
+
} catch (err) {
|
|
307
|
+
throw new TraceMoeError("network", `appel ${path} impossible : ${String(err)}`);
|
|
308
|
+
} finally {
|
|
309
|
+
this.lastCallAt = this.now();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const text = await response.text();
|
|
313
|
+
const payload = safeJson(text);
|
|
314
|
+
if (!response.ok) {
|
|
315
|
+
throw this.classify(response, payload, text);
|
|
316
|
+
}
|
|
317
|
+
if (payload && typeof payload === "object" && "quota" in payload) {
|
|
318
|
+
const body = payload as { quota: number; quotaUsed: number };
|
|
319
|
+
this.lastQuota = {
|
|
320
|
+
...(this.lastQuota ?? { id: "", priority: 0, concurrency: 1 }),
|
|
321
|
+
quota: body.quota,
|
|
322
|
+
quotaUsed: body.quotaUsed,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
return payload as T;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private classify(response: Response, payload: unknown, text: string): TraceMoeError {
|
|
329
|
+
const message =
|
|
330
|
+
(payload as { error?: string } | null)?.error?.trim() || text.trim() || response.statusText;
|
|
331
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
332
|
+
const retryAfterMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : undefined;
|
|
333
|
+
switch (response.status) {
|
|
334
|
+
case 400:
|
|
335
|
+
return new TraceMoeError("bad-request", message, 400);
|
|
336
|
+
case 402: {
|
|
337
|
+
// Quota épuisé, ou deuxième requête envoyée trop tôt : le service
|
|
338
|
+
// répond pareil dans les deux cas. Le quota connu tranche.
|
|
339
|
+
const exhausted = this.lastQuota
|
|
340
|
+
? this.lastQuota.quotaUsed >= this.lastQuota.quota
|
|
341
|
+
: /quota/i.test(message);
|
|
342
|
+
return exhausted
|
|
343
|
+
? new TraceMoeError("quota", message || "quota épuisé", 402)
|
|
344
|
+
: new TraceMoeError("concurrency", message || "requête concurrente refusée", 402);
|
|
345
|
+
}
|
|
346
|
+
case 403:
|
|
347
|
+
return new TraceMoeError("bad-request", message || "clé refusée", 403);
|
|
348
|
+
case 413:
|
|
349
|
+
return new TraceMoeError("too-large", message || "image trop lourde (25 Mo max)", 413);
|
|
350
|
+
case 429:
|
|
351
|
+
return new TraceMoeError("rate-limit", message, 429, retryAfterMs);
|
|
352
|
+
case 503:
|
|
353
|
+
return new TraceMoeError("busy", message || "file d'attente pleine", 503, retryAfterMs);
|
|
354
|
+
default:
|
|
355
|
+
return response.status >= 500
|
|
356
|
+
? new TraceMoeError("server", message, response.status, retryAfterMs)
|
|
357
|
+
: new TraceMoeError("bad-request", message, response.status);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function safeJson(text: string): unknown {
|
|
363
|
+
try {
|
|
364
|
+
return JSON.parse(text);
|
|
365
|
+
} catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|