feat(userbot,api,frontend): file links, media downloads, story hold-pause, drop scheduled dupes
This commit is contained in:
1 parent
683b9a31a3
commit
3e08698b62
38 files changed
+2889
-134
No files matched your search
@@ -0,0 +1,61 @@
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { auth } from "$lib/stores/auth.svelte";
|
||||
|
||||
const BASE = import.meta.env.VITE_API_BASE ?? "/api";
|
||||
const FILENAME_STAR = /filename\*=UTF-8''([^;]+)/i;
|
||||
const FILENAME_PLAIN = /filename="?([^";]+)"?/i;
|
||||
|
||||
function nameFromHeader(header: string | null, fallback: string): string {
|
||||
if (!header) {
|
||||
return fallback;
|
||||
}
|
||||
const encoded = FILENAME_STAR.exec(header);
|
||||
if (encoded) {
|
||||
return decodeURIComponent(encoded[1]);
|
||||
}
|
||||
const plain = FILENAME_PLAIN.exec(header);
|
||||
return plain ? plain[1] : fallback;
|
||||
}
|
||||
|
||||
function saveBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
async function save(path: string, fallback: string): Promise<void> {
|
||||
const response = await fetch(`${BASE}${path}`, {
|
||||
headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : {},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`download failed: ${response.status}`);
|
||||
}
|
||||
const fileName = nameFromHeader(
|
||||
response.headers.get("content-disposition"),
|
||||
fallback
|
||||
);
|
||||
saveBlob(await response.blob(), fileName);
|
||||
}
|
||||
|
||||
export function downloadMedia(mediaId: number): Promise<void> {
|
||||
return save(`/media/${mediaId}?download=true`, `media_${mediaId}`);
|
||||
}
|
||||
|
||||
export function downloadMediaVersion(versionId: number): Promise<void> {
|
||||
return save(
|
||||
`/media/version/${versionId}?download=true`,
|
||||
`media_${versionId}`
|
||||
);
|
||||
}
|
||||
|
||||
export function downloadStory(peerId: number, storyId: number): Promise<void> {
|
||||
return save(
|
||||
`/stories/${peerId}/${storyId}/media?download=true&account_id=${accounts.selectedId}`,
|
||||
`story_${peerId}_${storyId}`
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export type InlineMedia =
|
||||
|
||||
export interface ViewerItem {
|
||||
downloaded: boolean;
|
||||
fileName: string | null;
|
||||
kind: string;
|
||||
mediaId: number | null;
|
||||
messageId: number;
|
||||
@@ -30,13 +31,16 @@ export function viewerItemsFrom(
|
||||
media: MediaRef[]
|
||||
): ViewerItem[] {
|
||||
if (media.length === 0) {
|
||||
return [{ messageId, mediaId: null, kind: "", downloaded: false }];
|
||||
return [
|
||||
{ messageId, mediaId: null, kind: "", downloaded: false, fileName: null },
|
||||
];
|
||||
}
|
||||
return media.map((item) => ({
|
||||
messageId: item.message_id,
|
||||
mediaId: item.id,
|
||||
kind: item.kind,
|
||||
downloaded: item.downloaded,
|
||||
fileName: item.file_name,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { request } from "$lib/api/client";
|
||||
import type { FileShare, FileShareHit, ShareSubject } from "$lib/api/types";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
|
||||
export interface ShareSettings {
|
||||
expiresInSeconds: number | null;
|
||||
keepExpiry?: boolean;
|
||||
maxDownloads: number | null;
|
||||
}
|
||||
|
||||
function subjectQuery(subject: ShareSubject): Record<string, number | string> {
|
||||
const query: Record<string, number | string> = { kind: subject.kind };
|
||||
if (subject.mediaId !== undefined) {
|
||||
query.media_id = subject.mediaId;
|
||||
}
|
||||
if (subject.versionId !== undefined) {
|
||||
query.version_id = subject.versionId;
|
||||
}
|
||||
if (subject.peerId !== undefined) {
|
||||
query.peer_id = subject.peerId;
|
||||
}
|
||||
if (subject.storyId !== undefined) {
|
||||
query.story_id = subject.storyId;
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
export function listShares(activeOnly = false): Promise<FileShare[]> {
|
||||
return request<FileShare[]>("/shares", {
|
||||
account: true,
|
||||
query: { active_only: activeOnly, limit: 200 },
|
||||
});
|
||||
}
|
||||
|
||||
export function lookupShare(subject: ShareSubject): Promise<FileShare | null> {
|
||||
return request<FileShare | null>("/shares/lookup", {
|
||||
account: true,
|
||||
query: subjectQuery(subject),
|
||||
});
|
||||
}
|
||||
|
||||
export function createShare(
|
||||
subject: ShareSubject,
|
||||
settings: ShareSettings
|
||||
): Promise<FileShare> {
|
||||
return request<FileShare>("/shares", {
|
||||
method: "POST",
|
||||
body: {
|
||||
account_id: accounts.selectedId,
|
||||
kind: subject.kind,
|
||||
media_id: subject.mediaId ?? null,
|
||||
version_id: subject.versionId ?? null,
|
||||
peer_id: subject.peerId ?? null,
|
||||
story_id: subject.storyId ?? null,
|
||||
expires_in_seconds: settings.expiresInSeconds,
|
||||
max_downloads: settings.maxDownloads,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function updateShare(
|
||||
id: number,
|
||||
settings: ShareSettings
|
||||
): Promise<FileShare> {
|
||||
return request<FileShare>(`/shares/${id}`, {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
expires_in_seconds: settings.expiresInSeconds,
|
||||
keep_expiry: settings.keepExpiry ?? false,
|
||||
max_downloads: settings.maxDownloads,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function revokeShare(id: number): Promise<FileShare> {
|
||||
return request<FileShare>(`/shares/${id}/revoke`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function reissueShare(id: number): Promise<FileShare> {
|
||||
return request<FileShare>(`/shares/${id}/reissue`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function deleteShare(id: number): Promise<void> {
|
||||
return request<void>(`/shares/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function listShareHits(id: number): Promise<FileShareHit[]> {
|
||||
return request<FileShareHit[]>(`/shares/${id}/hits`, {
|
||||
query: { limit: 50 },
|
||||
});
|
||||
}
|
||||
|
||||
const TRAILING_SLASH = /\/$/;
|
||||
|
||||
export function shareUrl(share: FileShare): string {
|
||||
const origin =
|
||||
typeof window === "undefined"
|
||||
? ""
|
||||
: window.location.origin.replace(TRAILING_SLASH, "");
|
||||
return `${origin}/f/${share.token}`;
|
||||
}
|
||||
|
||||
export function shareState(
|
||||
share: FileShare
|
||||
): "active" | "revoked" | "expired" | "exhausted" {
|
||||
if (share.revoked_at) {
|
||||
return "revoked";
|
||||
}
|
||||
if (share.expires_at && Date.parse(share.expires_at) <= Date.now()) {
|
||||
return "expired";
|
||||
}
|
||||
if (
|
||||
share.max_downloads !== null &&
|
||||
share.download_count >= share.max_downloads
|
||||
) {
|
||||
return "exhausted";
|
||||
}
|
||||
return "active";
|
||||
}
|
||||
@@ -92,6 +92,7 @@ export interface ForwardView {
|
||||
export interface MediaRef {
|
||||
downloaded: boolean;
|
||||
duration: number | null;
|
||||
file_name: string | null;
|
||||
file_size: number | null;
|
||||
height: number | null;
|
||||
id: number | null;
|
||||
@@ -242,6 +243,7 @@ export interface MediaView {
|
||||
created_at: string;
|
||||
downloaded: boolean;
|
||||
extracted_text: string | null;
|
||||
file_name: string | null;
|
||||
file_size: number | null;
|
||||
id: number;
|
||||
kind: string;
|
||||
@@ -506,3 +508,40 @@ export type LiveEvent =
|
||||
| LiveDeleteEvent
|
||||
| LivePresenceEvent
|
||||
| LiveReceiptEvent;
|
||||
|
||||
export interface FileShare {
|
||||
account_id: number;
|
||||
chat_id: number | null;
|
||||
created_at: string;
|
||||
download_count: number;
|
||||
expires_at: string | null;
|
||||
file_name: string;
|
||||
file_size: number | null;
|
||||
id: number;
|
||||
kind: string;
|
||||
last_download_at: string | null;
|
||||
max_downloads: number | null;
|
||||
message_id: number | null;
|
||||
mime: string | null;
|
||||
peer_id: number | null;
|
||||
revoked_at: string | null;
|
||||
story_id: number | null;
|
||||
title: string | null;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface FileShareHit {
|
||||
counted: boolean;
|
||||
ip: string | null;
|
||||
method: string;
|
||||
ts: string;
|
||||
user_agent: string | null;
|
||||
}
|
||||
|
||||
export interface ShareSubject {
|
||||
kind: "media" | "media_version" | "story";
|
||||
mediaId?: number;
|
||||
peerId?: number;
|
||||
storyId?: number;
|
||||
versionId?: number;
|
||||
}
|
||||
Reference in new issue
Block a user