target-supabase-sdk 0.2.16 → 0.2.18
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 +20 -0
- package/dist/browser.d.ts +2 -0
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +1 -1
- package/dist/chunks/{task-queue-Gi4DSq74.js → task-queue-DufG7PlX.js} +162 -2
- package/dist/chunks/task-queue-DufG7PlX.js.map +1 -0
- package/dist/node/telegram/api-client.d.ts +12 -0
- package/dist/node/telegram/api-client.d.ts.map +1 -0
- package/dist/node/telegram/chunk-locator.d.ts +10 -0
- package/dist/node/telegram/chunk-locator.d.ts.map +1 -0
- package/dist/node/telegram/chunk.d.ts +4 -0
- package/dist/node/telegram/chunk.d.ts.map +1 -0
- package/dist/node/telegram/index.d.ts +8 -0
- package/dist/node/telegram/index.d.ts.map +1 -0
- package/dist/node/telegram/storage-provider.d.ts +32 -0
- package/dist/node/telegram/storage-provider.d.ts.map +1 -0
- package/dist/node/telegram/types.d.ts +53 -0
- package/dist/node/telegram/types.d.ts.map +1 -0
- package/dist/node.d.ts +4 -0
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +503 -8
- package/dist/node.js.map +1 -1
- package/dist/shared/media/index.d.ts +14 -0
- package/dist/shared/media/index.d.ts.map +1 -0
- package/dist/shared/media/media.build.d.ts +20 -0
- package/dist/shared/media/media.build.d.ts.map +1 -0
- package/dist/shared/media/media.interface.d.ts +38 -0
- package/dist/shared/media/media.interface.d.ts.map +1 -0
- package/dist/shared/media/media.mime.d.ts +4 -0
- package/dist/shared/media/media.mime.d.ts.map +1 -0
- package/dist/shared/media/media.name.d.ts +6 -0
- package/dist/shared/media/media.name.d.ts.map +1 -0
- package/dist/shared/media/media.query.d.ts +8 -0
- package/dist/shared/media/media.query.d.ts.map +1 -0
- package/dist/shared/media/node/index.d.ts +9 -0
- package/dist/shared/media/node/index.d.ts.map +1 -0
- package/dist/shared/media/node/media.access.d.ts +40 -0
- package/dist/shared/media/node/media.access.d.ts.map +1 -0
- package/dist/shared/media/node/media.service.d.ts +30 -0
- package/dist/shared/media/node/media.service.d.ts.map +1 -0
- package/package.json +8 -1
- package/src/browser.ts +11 -0
- package/src/node/telegram/api-client.ts +146 -0
- package/src/node/telegram/chunk-locator.ts +23 -0
- package/src/node/telegram/chunk.ts +33 -0
- package/src/node/telegram/index.ts +20 -0
- package/src/node/telegram/storage-provider.ts +182 -0
- package/src/node/telegram/types.ts +82 -0
- package/src/node.ts +36 -0
- package/src/shared/media/README.md +109 -0
- package/src/shared/media/index.ts +19 -0
- package/src/shared/media/media.build.ts +69 -0
- package/src/shared/media/media.interface.ts +45 -0
- package/src/shared/media/media.mime.ts +64 -0
- package/src/shared/media/media.name.ts +33 -0
- package/src/shared/media/media.query.ts +13 -0
- package/src/shared/media/node/index.ts +13 -0
- package/src/shared/media/node/media.access.ts +148 -0
- package/src/shared/media/node/media.service.ts +129 -0
- package/dist/chunks/task-queue-Gi4DSq74.js.map +0 -1
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { CreateUploadAdapterOptions, Parcel, StorageProviderModule, UploadTracker } from "../../browser";
|
|
2
|
+
import { isOpaqueChunkUrl } from "../../browser";
|
|
3
|
+
import { callTelegramApi } from "./api-client";
|
|
4
|
+
import { downloadTelegramChunk } from "./chunk";
|
|
5
|
+
import { encodeTelegramChunkUrl, parseTelegramChunkUrl } from "./chunk-locator";
|
|
6
|
+
import {
|
|
7
|
+
type ResolvedTelegramStorageOptions,
|
|
8
|
+
resolveTelegramStorageOptions,
|
|
9
|
+
type TelegramMessage,
|
|
10
|
+
type TelegramStorageProviderOptions,
|
|
11
|
+
type TelegramUploadResult,
|
|
12
|
+
} from "./types";
|
|
13
|
+
|
|
14
|
+
export const PROVIDER_TELEGRAM = "telegram";
|
|
15
|
+
|
|
16
|
+
export interface TelegramChunkDeleteResult {
|
|
17
|
+
deleted: number;
|
|
18
|
+
failed: number;
|
|
19
|
+
skipped: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TelegramStorageProvider extends StorageProviderModule {
|
|
23
|
+
readonly provider: typeof PROVIDER_TELEGRAM;
|
|
24
|
+
/** Low-level upload for probes/CLIs. */
|
|
25
|
+
uploadChunk(data: ArrayBuffer, filename: string): Promise<TelegramUploadResult>;
|
|
26
|
+
/** Best-effort deleteMessage for rollback / probe cleanup. */
|
|
27
|
+
deleteMessage(messageId: number): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Best-effort delete Telegram messages for parcel chunks.
|
|
30
|
+
* Chunks without an encoded messageId (legacy bare file_id) are skipped.
|
|
31
|
+
*/
|
|
32
|
+
deleteChunkBlobs(parcel: Parcel): Promise<TelegramChunkDeleteResult>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function uploadChunkToTelegram(
|
|
36
|
+
options: ResolvedTelegramStorageOptions,
|
|
37
|
+
data: ArrayBuffer,
|
|
38
|
+
filename: string,
|
|
39
|
+
): Promise<TelegramUploadResult> {
|
|
40
|
+
const sizeMb = (data.byteLength / (1024 * 1024)).toFixed(2);
|
|
41
|
+
|
|
42
|
+
const message = await callTelegramApi<TelegramMessage>(
|
|
43
|
+
options,
|
|
44
|
+
"sendDocument",
|
|
45
|
+
() => {
|
|
46
|
+
const form = new FormData();
|
|
47
|
+
form.append("chat_id", options.chatId);
|
|
48
|
+
form.append("document", new Blob([data]), filename);
|
|
49
|
+
return { method: "POST", body: form };
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
timeoutMs: options.uploadTimeoutMs,
|
|
53
|
+
retryAttempts: options.uploadRetryAttempts,
|
|
54
|
+
retryBaseMs: options.uploadRetryBaseMs,
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const fileId = message.document?.file_id;
|
|
59
|
+
if (fileId == null || fileId === "") {
|
|
60
|
+
throw new Error(`Telegram sendDocument: missing file_id (chunk ${filename}, ${sizeMb} MB)`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { fileId, messageId: message.message_id };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function deleteTelegramMessage(options: ResolvedTelegramStorageOptions, messageId: number): Promise<void> {
|
|
67
|
+
await callTelegramApi<boolean>(
|
|
68
|
+
options,
|
|
69
|
+
"deleteMessage",
|
|
70
|
+
() => {
|
|
71
|
+
const form = new FormData();
|
|
72
|
+
form.append("chat_id", options.chatId);
|
|
73
|
+
form.append("message_id", String(messageId));
|
|
74
|
+
return { method: "POST", body: form };
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
retryAttempts: 3,
|
|
78
|
+
retryBaseMs: 1_000,
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function rollbackTelegramUploads(options: ResolvedTelegramStorageOptions, handles: unknown[]): Promise<void> {
|
|
84
|
+
for (const item of handles) {
|
|
85
|
+
const upload = item as TelegramUploadResult;
|
|
86
|
+
if (upload?.messageId == null) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
await deleteTelegramMessage(options, upload.messageId);
|
|
91
|
+
} catch {
|
|
92
|
+
// Best-effort cleanup
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function assertTelegramParcel(parcel: Parcel): void {
|
|
98
|
+
const chunks = parcel.details.chunkList;
|
|
99
|
+
if (chunks.length === 0) {
|
|
100
|
+
throw new Error("Parcel has no chunks");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const nonTelegram = chunks.filter((chunk) => chunk.provider != null && chunk.provider !== PROVIDER_TELEGRAM);
|
|
104
|
+
if (nonTelegram.length > 0) {
|
|
105
|
+
const providers = [...new Set(nonTelegram.map((chunk) => chunk.provider))];
|
|
106
|
+
throw new Error(
|
|
107
|
+
`Parcel is not telegram-only (found providers: ${providers.join(", ")}). ` +
|
|
108
|
+
"Multi-provider restore is not yet supported for this parcel.",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build a Telegram {@link StorageProviderModule}. Caller registers it — no side effects.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ```ts
|
|
118
|
+
* import { createStorageProviderRegistry, createTelegramStorageProvider } from "target-supabase-sdk/node";
|
|
119
|
+
* const registry = createStorageProviderRegistry();
|
|
120
|
+
* registry.register(createTelegramStorageProvider({ botToken, chatId, proxyUrl }));
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
export function createTelegramStorageProvider(input: TelegramStorageProviderOptions): TelegramStorageProvider {
|
|
124
|
+
const options = resolveTelegramStorageOptions(input);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
provider: PROVIDER_TELEGRAM,
|
|
128
|
+
uploadChunk: (data, filename) => uploadChunkToTelegram(options, data, filename),
|
|
129
|
+
deleteMessage: (messageId) => deleteTelegramMessage(options, messageId),
|
|
130
|
+
async deleteChunkBlobs(parcel) {
|
|
131
|
+
let deleted = 0;
|
|
132
|
+
let failed = 0;
|
|
133
|
+
let skipped = 0;
|
|
134
|
+
for (const chunk of parcel.details.chunkList) {
|
|
135
|
+
if (chunk.provider != null && chunk.provider !== PROVIDER_TELEGRAM) {
|
|
136
|
+
skipped += 1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const { messageId } = parseTelegramChunkUrl(chunk.url);
|
|
140
|
+
if (messageId == null) {
|
|
141
|
+
skipped += 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
await deleteTelegramMessage(options, messageId);
|
|
146
|
+
deleted += 1;
|
|
147
|
+
} catch {
|
|
148
|
+
failed += 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { deleted, failed, skipped };
|
|
152
|
+
},
|
|
153
|
+
createUploadAdapter(adapterOptions?: CreateUploadAdapterOptions) {
|
|
154
|
+
const tracker = adapterOptions?.tracker as UploadTracker<TelegramUploadResult> | undefined;
|
|
155
|
+
return {
|
|
156
|
+
provider: PROVIDER_TELEGRAM,
|
|
157
|
+
async upload(data, pathOrKey) {
|
|
158
|
+
const result = await uploadChunkToTelegram(options, data, pathOrKey);
|
|
159
|
+
tracker?.push(result);
|
|
160
|
+
// Persist messageId so later deleteParcel can call deleteMessage.
|
|
161
|
+
return { url: encodeTelegramChunkUrl(result.messageId, result.fileId) };
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
resolveChunk: (locator) => {
|
|
166
|
+
const { fileId } = parseTelegramChunkUrl(locator);
|
|
167
|
+
return downloadTelegramChunk(options, fileId);
|
|
168
|
+
},
|
|
169
|
+
matchesOpaqueUrl: isOpaqueChunkUrl,
|
|
170
|
+
async probe() {
|
|
171
|
+
try {
|
|
172
|
+
const me = await callTelegramApi<{ username?: string }>(options, "getMe", { method: "GET" });
|
|
173
|
+
return { ok: true as const, detail: { username: me.username ?? "unknown" } };
|
|
174
|
+
} catch (error) {
|
|
175
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
176
|
+
return { ok: false as const, error: message };
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
rollbackUpload: (handles) => rollbackTelegramUploads(options, handles),
|
|
180
|
+
assertParcelRestorable: assertTelegramParcel,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export interface TelegramApiResponse<T> {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
description?: string;
|
|
4
|
+
result?: T;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface TelegramDocument {
|
|
8
|
+
file_id: string;
|
|
9
|
+
file_unique_id: string;
|
|
10
|
+
file_size?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TelegramMessage {
|
|
14
|
+
message_id: number;
|
|
15
|
+
document?: TelegramDocument;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TelegramFile {
|
|
19
|
+
file_id: string;
|
|
20
|
+
file_unique_id: string;
|
|
21
|
+
file_size?: number;
|
|
22
|
+
file_path?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TelegramUploadResult {
|
|
26
|
+
fileId: string;
|
|
27
|
+
messageId: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Injected config for Telegram Bot API storage — do not read process.env inside SDK. */
|
|
31
|
+
export interface TelegramStorageProviderOptions {
|
|
32
|
+
botToken: string;
|
|
33
|
+
chatId: string;
|
|
34
|
+
/** Default `https://api.telegram.org`. */
|
|
35
|
+
apiBaseUrl?: string;
|
|
36
|
+
/** HTTP(S) proxy for Telegram only (e.g. Clash). Supabase stays direct. */
|
|
37
|
+
proxyUrl?: string;
|
|
38
|
+
/** Default 30s — getMe / getFile. */
|
|
39
|
+
fetchTimeoutMs?: number;
|
|
40
|
+
/** Default 120s — sendDocument / file download. */
|
|
41
|
+
uploadTimeoutMs?: number;
|
|
42
|
+
/** Default 3. */
|
|
43
|
+
uploadRetryAttempts?: number;
|
|
44
|
+
/** Default 2000 — backoff base for attempt N: base * 2^(N-1). */
|
|
45
|
+
uploadRetryBaseMs?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ResolvedTelegramStorageOptions {
|
|
49
|
+
botToken: string;
|
|
50
|
+
chatId: string;
|
|
51
|
+
apiBaseUrl: string;
|
|
52
|
+
proxyUrl?: string;
|
|
53
|
+
fetchTimeoutMs: number;
|
|
54
|
+
uploadTimeoutMs: number;
|
|
55
|
+
uploadRetryAttempts: number;
|
|
56
|
+
uploadRetryBaseMs: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function resolveTelegramStorageOptions(options: TelegramStorageProviderOptions): ResolvedTelegramStorageOptions {
|
|
60
|
+
const botToken = options.botToken.trim();
|
|
61
|
+
const chatId = options.chatId.trim();
|
|
62
|
+
if (botToken === "") {
|
|
63
|
+
throw new Error("TelegramStorageProviderOptions.botToken is required");
|
|
64
|
+
}
|
|
65
|
+
if (chatId === "") {
|
|
66
|
+
throw new Error("TelegramStorageProviderOptions.chatId is required");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const apiBaseUrl = (options.apiBaseUrl?.trim() || "https://api.telegram.org").replace(/\/$/, "");
|
|
70
|
+
const proxyUrl = options.proxyUrl?.trim() || undefined;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
botToken,
|
|
74
|
+
chatId,
|
|
75
|
+
apiBaseUrl,
|
|
76
|
+
proxyUrl: proxyUrl !== "" ? proxyUrl : undefined,
|
|
77
|
+
fetchTimeoutMs: options.fetchTimeoutMs ?? 30_000,
|
|
78
|
+
uploadTimeoutMs: options.uploadTimeoutMs ?? 120_000,
|
|
79
|
+
uploadRetryAttempts: options.uploadRetryAttempts ?? 3,
|
|
80
|
+
uploadRetryBaseMs: options.uploadRetryBaseMs ?? 2_000,
|
|
81
|
+
};
|
|
82
|
+
}
|
package/src/node.ts
CHANGED
|
@@ -134,6 +134,29 @@ export type {
|
|
|
134
134
|
SingleProcessServiceOptions,
|
|
135
135
|
} from "./node/service-host";
|
|
136
136
|
export { createL3ChildLauncher, createServiceHost, runSingleProcessService } from "./node/service-host";
|
|
137
|
+
export type {
|
|
138
|
+
CallTelegramOptions,
|
|
139
|
+
ResolvedTelegramStorageOptions,
|
|
140
|
+
TelegramApiResponse,
|
|
141
|
+
TelegramChunkDeleteResult,
|
|
142
|
+
TelegramDocument,
|
|
143
|
+
TelegramFile,
|
|
144
|
+
TelegramMessage,
|
|
145
|
+
TelegramStorageProvider,
|
|
146
|
+
TelegramStorageProviderOptions,
|
|
147
|
+
TelegramUploadResult,
|
|
148
|
+
} from "./node/telegram";
|
|
149
|
+
export {
|
|
150
|
+
callTelegramApi,
|
|
151
|
+
createTelegramStorageProvider,
|
|
152
|
+
downloadTelegramChunk,
|
|
153
|
+
encodeTelegramChunkUrl,
|
|
154
|
+
PROVIDER_TELEGRAM,
|
|
155
|
+
parseTelegramChunkUrl,
|
|
156
|
+
resolveTelegramStorageOptions,
|
|
157
|
+
telegramDownloadBinary,
|
|
158
|
+
telegramFileDownloadUrl,
|
|
159
|
+
} from "./node/telegram";
|
|
137
160
|
export { RepoManager } from "./repo/repo-manager";
|
|
138
161
|
export { runCollectLogTick } from "./shared/log/spool/collector";
|
|
139
162
|
export { LOG_SPOOL_SERVICE_ID_ENV } from "./shared/log/spool/config";
|
|
@@ -165,6 +188,19 @@ export {
|
|
|
165
188
|
shutdownLogSpool,
|
|
166
189
|
} from "./shared/log/spool/service-lifecycle";
|
|
167
190
|
export type { LogBatchMeta, LogPersistLane } from "./shared/log/upload/interface";
|
|
191
|
+
export type {
|
|
192
|
+
CheckMediaAvailabilityInput,
|
|
193
|
+
FindMediaLinkInput,
|
|
194
|
+
MediaAvailabilityResult,
|
|
195
|
+
RegisterMediaLinkInput,
|
|
196
|
+
RegisterMediaLinkResult,
|
|
197
|
+
} from "./shared/media/node";
|
|
198
|
+
export {
|
|
199
|
+
checkMediaAvailability,
|
|
200
|
+
findMediaLink,
|
|
201
|
+
probeMediaOriginal,
|
|
202
|
+
registerMediaLink,
|
|
203
|
+
} from "./shared/media/node";
|
|
168
204
|
export type {
|
|
169
205
|
ConfigSchema,
|
|
170
206
|
LoadCachedJsConfigOptions,
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Media Links (`shared/media`)
|
|
2
|
+
|
|
3
|
+
Media is a **convention on `Link`**, not a new Target category (`category` stays `link`).
|
|
4
|
+
|
|
5
|
+
All media code lives under **`src/shared/media/`**. Node-only modules are kept in the **`node/`** subdirectory so the filesystem path matches the package entry split.
|
|
6
|
+
|
|
7
|
+
## Package entry split (important)
|
|
8
|
+
|
|
9
|
+
| Consumer import | What you get | Source |
|
|
10
|
+
|-----------------|--------------|--------|
|
|
11
|
+
| `from "target-supabase-sdk"` | Browser-safe helpers only | `shared/media/index.ts` → `browser.ts` |
|
|
12
|
+
| `from "target-supabase-sdk/node"` | + register / availability / probe | `shared/media/node/` → `node.ts` |
|
|
13
|
+
|
|
14
|
+
There is **no** public subpath like `target-supabase-sdk/shared/media/node` — consumers always use the package `/node` entry.
|
|
15
|
+
|
|
16
|
+
**Do not** re-export anything from `node/` in `index.ts` / `browser.ts`. Those files import `node:fs` / `node:crypto` / `node:path`. Pulling them into the default entry breaks Chrome/Webpack bundles (`verify-browser-entry` will fail).
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
src/shared/media/
|
|
20
|
+
index.ts ← browser-safe barrel ONLY (never export ./node)
|
|
21
|
+
media.interface.ts
|
|
22
|
+
media.build.ts
|
|
23
|
+
media.mime.ts
|
|
24
|
+
media.name.ts
|
|
25
|
+
media.query.ts
|
|
26
|
+
README.md
|
|
27
|
+
node/ ← Node-only (fs / register)
|
|
28
|
+
index.ts ← barrel for src/node.ts only
|
|
29
|
+
media.access.ts
|
|
30
|
+
media.service.ts
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Correct consumer imports
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
// Default / browser — draft, mime, name, filters
|
|
37
|
+
import {
|
|
38
|
+
buildMediaLinkDraft,
|
|
39
|
+
buildMediaNameFromLocator,
|
|
40
|
+
MEDIA_VALUES,
|
|
41
|
+
mediaLinkDedupFilters,
|
|
42
|
+
} from "target-supabase-sdk";
|
|
43
|
+
|
|
44
|
+
// Node workers / CLI — dedup + availability + register
|
|
45
|
+
import {
|
|
46
|
+
findMediaLink,
|
|
47
|
+
checkMediaAvailability,
|
|
48
|
+
registerMediaLink,
|
|
49
|
+
} from "target-supabase-sdk/node";
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Incorrect
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
// BAD: Node-only APIs are not on the default entry
|
|
56
|
+
import { registerMediaLink } from "target-supabase-sdk";
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Internal SDK code:
|
|
60
|
+
|
|
61
|
+
- Browser-safe leaves: `from "../media.build"` (etc.)
|
|
62
|
+
- Node leaves: `from "./media.access"` inside `node/`, or `from "../shared/media/node"` from `src/node.ts`
|
|
63
|
+
- Never pull `./node` through the parent `index.ts`
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Link field rules
|
|
68
|
+
|
|
69
|
+
| Field | Rule |
|
|
70
|
+
|-------|------|
|
|
71
|
+
| `value` | `video` \| `audio` \| `image` \| `image-list` |
|
|
72
|
+
| `details.loaderKey` | Always equals `value` |
|
|
73
|
+
| `name` | Caller-provided unique key (or `buildMediaNameFromLocator`) |
|
|
74
|
+
| `tagList` / `description` | Caller-defined (e.g. producer / scene tags) |
|
|
75
|
+
| `details.manifestVersion` | `0` (via `buildLinkTargetDraft`) |
|
|
76
|
+
| `details.preview` | `""` |
|
|
77
|
+
|
|
78
|
+
## `details.original`
|
|
79
|
+
|
|
80
|
+
| Key | Single file (`video`/`audio`/`image`) | `image-list` |
|
|
81
|
+
|-----|----------------------------------------|--------------|
|
|
82
|
+
| `storageProvider` | Host / backend id (e.g. `windows-storage:HOSTNAME`) | same |
|
|
83
|
+
| `locator` | Absolute file path | Absolute **directory** path |
|
|
84
|
+
| `contentHash` | SHA-256 hex (`MEDIA_CONTENT_HASH_ALGORITHM`) | `""` |
|
|
85
|
+
| `size` | Byte length | File count (immediate children only, non-recursive) |
|
|
86
|
+
| `mimeType` | e.g. `video/mp4` | `""` |
|
|
87
|
+
|
|
88
|
+
`image-list` rules:
|
|
89
|
+
|
|
90
|
+
1. `locator` must be an **absolute** directory path.
|
|
91
|
+
2. Only **one level** under that directory is considered (no recursion).
|
|
92
|
+
3. `size` counts **files** in that level (directories among children are ignored).
|
|
93
|
+
|
|
94
|
+
## Register flow (`registerMediaLink`)
|
|
95
|
+
|
|
96
|
+
1. **Dedup** — `category` + `value` + `name` via `findMediaLink`. If present → `{ id, created: false }`.
|
|
97
|
+
2. **Availability** — `storageProvider` must equal `localStorageProvider` (typically `LOCAL_STORAGE_PROVIDER` from the service `.env`); `locator` absolute and reachable.
|
|
98
|
+
3. **Probe** — stream SHA-256 / `stat` / MIME (or directory file count for `image-list`).
|
|
99
|
+
4. **Insert** — `buildMediaLinkDraft` → `postTarget` (race → re-find).
|
|
100
|
+
|
|
101
|
+
## Env (Node hosts)
|
|
102
|
+
|
|
103
|
+
| Variable | Role |
|
|
104
|
+
|----------|------|
|
|
105
|
+
| `LOCAL_STORAGE_PROVIDER` | This machine’s storage id; pass as `localStorageProvider` into availability / register |
|
|
106
|
+
|
|
107
|
+
Cross-host `storageProvider` cannot be verified on the current process — availability returns `ok: false`.
|
|
108
|
+
|
|
109
|
+
See `.cursor/skills/media-link/SKILL.md`.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media Link — browser-safe barrel only.
|
|
3
|
+
*
|
|
4
|
+
* Node-only code lives in `./node/` and is exported from `src/node.ts`,
|
|
5
|
+
* never from this file (see README.md).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type { BuildMediaLinkDraftInput } from "./media.build";
|
|
9
|
+
export { buildMediaLinkDraft } from "./media.build";
|
|
10
|
+
export type { MediaOriginal, MediaValue } from "./media.interface";
|
|
11
|
+
export {
|
|
12
|
+
isMediaValue,
|
|
13
|
+
MEDIA_CONTENT_HASH_ALGORITHM,
|
|
14
|
+
MEDIA_CONTENT_HASH_PREFIX_LENGTH,
|
|
15
|
+
MEDIA_VALUES,
|
|
16
|
+
} from "./media.interface";
|
|
17
|
+
export { guessMediaMime } from "./media.mime";
|
|
18
|
+
export { buildMediaNameFromLocator } from "./media.name";
|
|
19
|
+
export { mediaLinkDedupFilters } from "./media.query";
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { TargetDraft } from "../../core.interface";
|
|
2
|
+
import { buildLinkTargetDraft } from "../../link/link.build";
|
|
3
|
+
import type { Link } from "../../link/link.interface";
|
|
4
|
+
import { isMediaValue, type MediaOriginal, type MediaValue } from "./media.interface";
|
|
5
|
+
|
|
6
|
+
export interface BuildMediaLinkDraftInput {
|
|
7
|
+
value: MediaValue;
|
|
8
|
+
name: string;
|
|
9
|
+
storageProvider: string;
|
|
10
|
+
locator: string;
|
|
11
|
+
contentHash: string;
|
|
12
|
+
size: number;
|
|
13
|
+
mimeType: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
tagList?: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Assemble a media {@link Link} draft (`category=link`, `loaderKey === value`).
|
|
20
|
+
* Does not validate filesystem or write to Supabase.
|
|
21
|
+
*/
|
|
22
|
+
export function buildMediaLinkDraft(input: BuildMediaLinkDraftInput): TargetDraft<Link> {
|
|
23
|
+
if (!isMediaValue(input.value)) {
|
|
24
|
+
throw new Error(`buildMediaLinkDraft: unsupported media value: ${String(input.value)}`);
|
|
25
|
+
}
|
|
26
|
+
const name = input.name.trim();
|
|
27
|
+
if (name === "") {
|
|
28
|
+
throw new Error("buildMediaLinkDraft: name is empty");
|
|
29
|
+
}
|
|
30
|
+
const storageProvider = input.storageProvider.trim();
|
|
31
|
+
if (storageProvider === "") {
|
|
32
|
+
throw new Error("buildMediaLinkDraft: storageProvider is empty");
|
|
33
|
+
}
|
|
34
|
+
const locator = input.locator.trim();
|
|
35
|
+
if (locator === "") {
|
|
36
|
+
throw new Error("buildMediaLinkDraft: locator is empty");
|
|
37
|
+
}
|
|
38
|
+
if (!Number.isFinite(input.size) || input.size < 0) {
|
|
39
|
+
throw new Error("buildMediaLinkDraft: size must be a non-negative number");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isList = input.value === "image-list";
|
|
43
|
+
const contentHash = isList ? "" : input.contentHash.trim().toLowerCase();
|
|
44
|
+
const mimeType = isList ? "" : input.mimeType.trim();
|
|
45
|
+
if (!isList && contentHash === "") {
|
|
46
|
+
throw new Error("buildMediaLinkDraft: contentHash is required for single-file media");
|
|
47
|
+
}
|
|
48
|
+
if (!isList && mimeType === "") {
|
|
49
|
+
throw new Error("buildMediaLinkDraft: mimeType is required for single-file media");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const original: MediaOriginal = {
|
|
53
|
+
storageProvider,
|
|
54
|
+
locator,
|
|
55
|
+
contentHash,
|
|
56
|
+
size: Math.floor(input.size),
|
|
57
|
+
mimeType,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return buildLinkTargetDraft({
|
|
61
|
+
name,
|
|
62
|
+
value: input.value,
|
|
63
|
+
description: input.description ?? "",
|
|
64
|
+
preview: "",
|
|
65
|
+
loaderKey: input.value,
|
|
66
|
+
tagList: input.tagList ?? [],
|
|
67
|
+
original,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media Link protocol — Link rows with value/loaderKey in {@link MEDIA_VALUES}.
|
|
3
|
+
* No separate Target category; category remains `link`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Fixed digest algorithm for single-file media `contentHash`. */
|
|
7
|
+
export const MEDIA_CONTENT_HASH_ALGORITHM = "sha256" as const;
|
|
8
|
+
|
|
9
|
+
/** Hex prefix length for {@link buildMediaNameFromLocator}. */
|
|
10
|
+
export const MEDIA_CONTENT_HASH_PREFIX_LENGTH = 6;
|
|
11
|
+
|
|
12
|
+
export const MEDIA_VALUES = ["video", "audio", "image", "image-list"] as const;
|
|
13
|
+
|
|
14
|
+
/** Discriminator stored in both `Link.value` and `details.loaderKey`. */
|
|
15
|
+
export type MediaValue = (typeof MEDIA_VALUES)[number];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `Link.details.original` for media Links.
|
|
19
|
+
*
|
|
20
|
+
* - `locator` — absolute filesystem path, or (future) network URL keyed by `storageProvider`.
|
|
21
|
+
* - `image-list`: `locator` is an absolute directory path; only the **immediate** children
|
|
22
|
+
* (one level, non-recursive) are counted. `contentHash` and `mimeType` are `""`;
|
|
23
|
+
* `size` is the number of files in that directory (not bytes).
|
|
24
|
+
* - other values: `size` is byte length; `contentHash` is SHA-256 hex; `mimeType` from the file.
|
|
25
|
+
*/
|
|
26
|
+
export interface MediaOriginal {
|
|
27
|
+
storageProvider: string;
|
|
28
|
+
locator: string;
|
|
29
|
+
/**
|
|
30
|
+
* SHA-256 hex of file bytes, or `""` when `value === "image-list"`.
|
|
31
|
+
* Algorithm is always {@link MEDIA_CONTENT_HASH_ALGORITHM}.
|
|
32
|
+
*/
|
|
33
|
+
contentHash: string;
|
|
34
|
+
/**
|
|
35
|
+
* Single-file media: byte length.
|
|
36
|
+
* `image-list`: number of files in the directory (one level).
|
|
37
|
+
*/
|
|
38
|
+
size: number;
|
|
39
|
+
/** e.g. `video/mp4`, or `""` when `value === "image-list"`. */
|
|
40
|
+
mimeType: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isMediaValue(value: string): value is MediaValue {
|
|
44
|
+
return (MEDIA_VALUES as readonly string[]).includes(value);
|
|
45
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { MediaValue } from "./media.interface";
|
|
2
|
+
|
|
3
|
+
const VIDEO_EXT_MIME: Record<string, string> = {
|
|
4
|
+
mp4: "video/mp4",
|
|
5
|
+
m4v: "video/mp4",
|
|
6
|
+
mov: "video/quicktime",
|
|
7
|
+
mkv: "video/x-matroska",
|
|
8
|
+
webm: "video/webm",
|
|
9
|
+
avi: "video/x-msvideo",
|
|
10
|
+
mpeg: "video/mpeg",
|
|
11
|
+
mpg: "video/mpeg",
|
|
12
|
+
wmv: "video/x-ms-wmv",
|
|
13
|
+
flv: "video/x-flv",
|
|
14
|
+
ts: "video/mp2t",
|
|
15
|
+
m2ts: "video/mp2t",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const AUDIO_EXT_MIME: Record<string, string> = {
|
|
19
|
+
wav: "audio/wav",
|
|
20
|
+
mp3: "audio/mpeg",
|
|
21
|
+
m4a: "audio/mp4",
|
|
22
|
+
aac: "audio/aac",
|
|
23
|
+
flac: "audio/flac",
|
|
24
|
+
ogg: "audio/ogg",
|
|
25
|
+
oga: "audio/ogg",
|
|
26
|
+
opus: "audio/opus",
|
|
27
|
+
wma: "audio/x-ms-wma",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const IMAGE_EXT_MIME: Record<string, string> = {
|
|
31
|
+
jpg: "image/jpeg",
|
|
32
|
+
jpeg: "image/jpeg",
|
|
33
|
+
png: "image/png",
|
|
34
|
+
webp: "image/webp",
|
|
35
|
+
gif: "image/gif",
|
|
36
|
+
bmp: "image/bmp",
|
|
37
|
+
tif: "image/tiff",
|
|
38
|
+
tiff: "image/tiff",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function extensionOf(pathOrUrl: string): string {
|
|
42
|
+
const cleaned = pathOrUrl.split(/[?#]/)[0] ?? pathOrUrl;
|
|
43
|
+
const base = cleaned.replace(/\\/g, "/").split("/").pop() ?? cleaned;
|
|
44
|
+
const dot = base.lastIndexOf(".");
|
|
45
|
+
if (dot <= 0 || dot === base.length - 1) {
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
48
|
+
return base.slice(dot + 1).toLowerCase();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Guess MIME from path extension; `null` when unknown. */
|
|
52
|
+
export function guessMediaMime(locator: string, value: Exclude<MediaValue, "image-list">): string | null {
|
|
53
|
+
const ext = extensionOf(locator);
|
|
54
|
+
if (ext === "") {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (value === "video") {
|
|
58
|
+
return VIDEO_EXT_MIME[ext] ?? null;
|
|
59
|
+
}
|
|
60
|
+
if (value === "audio") {
|
|
61
|
+
return AUDIO_EXT_MIME[ext] ?? null;
|
|
62
|
+
}
|
|
63
|
+
return IMAGE_EXT_MIME[ext] ?? null;
|
|
64
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { MEDIA_CONTENT_HASH_PREFIX_LENGTH } from "./media.interface";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `{basename}.{hash6}.{ext}` from an absolute (or path-like) locator and full content hash.
|
|
5
|
+
* Does not touch the filesystem.
|
|
6
|
+
*/
|
|
7
|
+
export function buildMediaNameFromLocator(locator: string, contentHash: string): string {
|
|
8
|
+
const trimmedLocator = locator.trim();
|
|
9
|
+
if (trimmedLocator === "") {
|
|
10
|
+
throw new Error("buildMediaNameFromLocator: locator is empty");
|
|
11
|
+
}
|
|
12
|
+
const hash = contentHash.trim().toLowerCase();
|
|
13
|
+
if (hash.length < MEDIA_CONTENT_HASH_PREFIX_LENGTH) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`buildMediaNameFromLocator: contentHash must be at least ${MEDIA_CONTENT_HASH_PREFIX_LENGTH} hex chars`,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
const hash6 = hash.slice(0, MEDIA_CONTENT_HASH_PREFIX_LENGTH);
|
|
19
|
+
|
|
20
|
+
const normalized = trimmedLocator.replace(/\\/g, "/");
|
|
21
|
+
const base = normalized.split("/").pop() ?? normalized;
|
|
22
|
+
if (base === "" || base === "." || base === "..") {
|
|
23
|
+
throw new Error(`buildMediaNameFromLocator: invalid basename from locator: ${locator}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const dot = base.lastIndexOf(".");
|
|
27
|
+
if (dot <= 0 || dot === base.length - 1) {
|
|
28
|
+
return `${base}.${hash6}`;
|
|
29
|
+
}
|
|
30
|
+
const stem = base.slice(0, dot);
|
|
31
|
+
const ext = base.slice(dot + 1);
|
|
32
|
+
return `${stem}.${hash6}.${ext}`;
|
|
33
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { QueryFilter } from "../../core.interface";
|
|
2
|
+
import { CategoryLink } from "../../link/link.interface";
|
|
3
|
+
import type { MediaValue } from "./media.interface";
|
|
4
|
+
|
|
5
|
+
/** Dedup key: `category=link` + `value` + `name` (matches register / find). */
|
|
6
|
+
export function mediaLinkDedupFilters(input: { value: MediaValue; name: string }): QueryFilter[] {
|
|
7
|
+
const name = input.name.trim();
|
|
8
|
+
return [
|
|
9
|
+
{ field: "category", operator: "eq", value: CategoryLink.LINK },
|
|
10
|
+
{ field: "value", operator: "eq", value: input.value },
|
|
11
|
+
{ field: "name", operator: "eq", value: name },
|
|
12
|
+
];
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-only media surface — for `src/node.ts` / `target-supabase-sdk/node` only.
|
|
3
|
+
* Never re-export from `../index.ts` or `browser.ts`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type { CheckMediaAvailabilityInput, MediaAvailabilityResult } from "./media.access";
|
|
7
|
+
export { checkMediaAvailability, probeMediaOriginal } from "./media.access";
|
|
8
|
+
export type {
|
|
9
|
+
FindMediaLinkInput,
|
|
10
|
+
RegisterMediaLinkInput,
|
|
11
|
+
RegisterMediaLinkResult,
|
|
12
|
+
} from "./media.service";
|
|
13
|
+
export { findMediaLink, registerMediaLink } from "./media.service";
|