diff --git a/backend/src/utils/read/models.py b/backend/src/utils/read/models.py index d409080..88b801a 100644 --- a/backend/src/utils/read/models.py +++ b/backend/src/utils/read/models.py @@ -282,6 +282,7 @@ class TrashItemView(BaseModel): class TrashOutcome(BaseModel): files: int = 0 bytes: int = 0 + keys: list[str] = [] blocked: list[int] = [] diff --git a/backend/src/utils/read/storage.py b/backend/src/utils/read/storage.py index a43e994..bdf9819 100644 --- a/backend/src/utils/read/storage.py +++ b/backend/src/utils/read/storage.py @@ -70,7 +70,7 @@ _TRASH = """ SELECT storage_key, chat_id, message_id, kind, mime, file_name, file_size, trashed_at FROM media_trash WHERE account_id = $1 -ORDER BY trashed_at DESC, storage_key +ORDER BY file_size DESC NULLS LAST, storage_key """ _TRASH_REFS = """ diff --git a/backend/src/utils/trash.py b/backend/src/utils/trash.py index 0ed6e57..30d30f5 100644 --- a/backend/src/utils/trash.py +++ b/backend/src/utils/trash.py @@ -80,6 +80,7 @@ async def move_to_trash( media.file_size, ) storage.trash(key) + outcome.keys.append(key) outcome.files += 1 outcome.bytes += media.file_size or 0 return outcome diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 220f062..891c6b7 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -95,6 +95,19 @@ export async function request( return response.json() as Promise; } +function streamUrl(path: string): string { + const query = auth.token ? `?token=${encodeURIComponent(auth.token)}` : ""; + return `${BASE}${path}${query}`; +} + +export function mediaUrl(mediaId: number): string { + return streamUrl(`/media/${mediaId}`); +} + +export function trashedUrl(storageKey: string): string { + return streamUrl(`/storage/trash/${storageKey}/file`); +} + export type MediaResult = | { state: "ready"; url: string; mime: string | null } | { state: "not-downloaded" } diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts index 91c34c6..68be46d 100644 --- a/frontend/src/lib/api/endpoints.ts +++ b/frontend/src/lib/api/endpoints.ts @@ -31,7 +31,12 @@ import type { ReactionView, ResponseStats, SearchHit, + StorageFile, + StorageGroupName, + StorageSummary, StoryView, + TrashItem, + TrashOutcome, VolumeBucket, Watch, WatchedBot, @@ -581,3 +586,42 @@ export function addWatchedBot(token: string): Promise { export function removeWatchedBot(botId: number): Promise { return request(`/bots/${botId}`, { method: "DELETE" }); } + +export function getStorageSummary(): Promise { + return request("/storage/summary", { account: true }); +} + +export function getStorageFiles( + group: StorageGroupName | null, + page: Page = {} +): Promise { + return request("/storage/files", { + account: true, + query: { group, ...page }, + }); +} + +export function getTrash(): Promise { + return request("/storage/trash", { account: true }); +} + +export function moveToTrash(mediaIds: number[]): Promise { + return request("/storage/trash", { + body: { account_id: accounts.selectedId, media_ids: mediaIds }, + method: "POST", + }); +} + +export function restoreFromTrash(keys: string[]): Promise { + return request("/storage/trash/restore", { + body: { account_id: accounts.selectedId, keys }, + method: "POST", + }); +} + +export function purgeTrash(keys: string[] | null): Promise { + return request("/storage/trash/purge", { + body: { account_id: accounts.selectedId, keys }, + method: "POST", + }); +} diff --git a/frontend/src/lib/api/media.ts b/frontend/src/lib/api/media.ts index bad03dc..d4a01a4 100644 --- a/frontend/src/lib/api/media.ts +++ b/frontend/src/lib/api/media.ts @@ -26,6 +26,7 @@ export interface ViewerItem { mediaId: number | null; messageId: number; mime: string | null; + src?: string; } export function viewerItemsFrom( @@ -102,6 +103,16 @@ export function visualKind(kind: string): VisualKind { return "other"; } +const STREAM_KINDS = new Set(["video", "video_note", "voice", "audio"]); +const STREAM_MIME_PREFIXES = ["video/", "audio/"]; + +export function isStreamable(kind: string, mime: string | null): boolean { + return ( + STREAM_KINDS.has(kind) || + STREAM_MIME_PREFIXES.some((prefix) => mime?.startsWith(prefix)) + ); +} + const MAX_CACHED = 200; const ready = new Map(); @@ -130,6 +141,26 @@ function cacheKey(account: number, chatId: number, messageId: number): string { return `${account}:${chatId}:${messageId}`; } +function forget(cache: Map, key: string) { + const stale = cache.get(key); + cache.delete(key); + if (stale?.state === "ready") { + URL.revokeObjectURL(stale.url); + } +} + +export function forgetMedia( + chatId: number, + messageId: number, + mediaId: number +): void { + const account = accounts.selectedId; + if (account !== null) { + forget(ready, cacheKey(account, chatId, messageId)); + forget(byId, `${account}:${mediaId}`); + } +} + async function resolve( chatId: number, messageId: number diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 4831f44..8c1535a 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -560,3 +560,55 @@ export interface WatchedBot { title: string | null; username: string | null; } + +export type StorageGroupName = "video" | "file" | "voice" | "audio" | "other"; + +export interface StorageGroup { + bytes: number; + files: number; + group: StorageGroupName; +} + +export interface StorageSummary { + bytes: number; + files: number; + groups: StorageGroup[]; + heavy_bytes: number; + heavy_files: number; + heavy_threshold: number; + trash_bytes: number; + trash_files: number; +} + +export interface StorageFile { + chat_id: number; + date: string | null; + file_name: string; + file_size: number; + kind: string; + media_id: number; + message_id: number; + mime: string | null; + refetchable: boolean; + refs: number; +} + +export interface TrashItem { + chat_id: number; + file_name: string; + file_size: number | null; + kind: string; + message_id: number; + mime: string | null; + purge_at: string; + refetchable: boolean; + storage_key: string; + trashed_at: string; +} + +export interface TrashOutcome { + blocked: number[]; + bytes: number; + files: number; + keys: string[]; +} diff --git a/frontend/src/lib/components/MediaViewer.svelte b/frontend/src/lib/components/MediaViewer.svelte index b8b131b..414615a 100644 --- a/frontend/src/lib/components/MediaViewer.svelte +++ b/frontend/src/lib/components/MediaViewer.svelte @@ -1,10 +1,14 @@
- + {#if parent} + + {:else} + + {/if}

{ui.rightPanel ? titles[ui.rightPanel] : ""}

@@ -82,6 +105,14 @@ {:else if ui.rightPanel === "bots"} + {:else if ui.rightPanel === "storage"} + {#key accounts.selectedId} + + {/key} + {:else if ui.rightPanel === "storage-trash"} + {#key accounts.selectedId} + + {/key} {/if} diff --git a/frontend/src/lib/components/disk/SelectionBar.svelte b/frontend/src/lib/components/disk/SelectionBar.svelte new file mode 100644 index 0000000..ce11a2d --- /dev/null +++ b/frontend/src/lib/components/disk/SelectionBar.svelte @@ -0,0 +1,67 @@ + + +
+
+
{title}
+ {#if note} +
{note}
+ {/if} +
+ {@render children()} +
+ + diff --git a/frontend/src/lib/components/disk/StoragePanel.svelte b/frontend/src/lib/components/disk/StoragePanel.svelte new file mode 100644 index 0000000..e4a3aa6 --- /dev/null +++ b/frontend/src/lib/components/disk/StoragePanel.svelte @@ -0,0 +1,622 @@ + + +
+ {#if summary} +
+
+ {formatBytes(summary.bytes) || "0 B"} + {filesLabel(summary.files)} на диске +
+ {#if segments.length > 0} +
+ {#each segments as item (item.group)} + + {/each} +
+ {/if} +
+ {#each summary.groups as item (item.group)} + + {/each} +
+
+ + + {/if} + +
+

Самые тяжёлые

+ {#if summary && group === null && summary.heavy_files > 0} + + {filesLabel(summary.heavy_files)} + крупнее {formatBytes(summary.heavy_threshold)} + = {formatBytes(summary.heavy_bytes)} + + {/if} +
+ +
+ {#each files as file, index (file.media_id)} + open(index)} + ontoggle={() => toggle(file.media_id)} + refetchable={file.refetchable} + selected={selected.has(file.media_id)} + size={file.file_size} + src={mediaUrl(file.media_id)} + sub={chatTitle(file.chat_id)} + tail={tail(file)} + weight={heaviest > 0 ? file.file_size / heaviest : 0} + > + {#snippet menu()} + open(index)}> + Открыть + + jump(file)}> + Перейти к сообщению + + save(file)}> + Скачать файл + + trash([file.media_id])} + > + В корзину + + {/snippet} + + {/each} + + {#if loading} +
+ {:else if failed} +
+

Не удалось загрузить хранилище.

+ +
+ {:else if files.length === 0} +
+

+ {group === null + ? "На диске нет скачанных файлов." + : "В этой группе нет файлов."} +

+
+ {:else if !done} + + {/if} +
+ + {#if chosen.length > 0} + 0 ? `${chosenGone} не скачать заново` : undefined} + title="{filesLabel(chosen.length)} · {formatBytes(chosenBytes)}" + warn + > + + + + {:else if undo} + + + + {/if} +
+ + { + if (item.mediaId !== null) { + trash([item.mediaId]); + } + }} + bind:index={viewerIndex} + bind:open={viewerOpen} +/> + + diff --git a/frontend/src/lib/components/disk/StorageRow.svelte b/frontend/src/lib/components/disk/StorageRow.svelte new file mode 100644 index 0000000..0d4efa7 --- /dev/null +++ b/frontend/src/lib/components/disk/StorageRow.svelte @@ -0,0 +1,250 @@ + + +
+ + + + + + {#snippet children({ + props, + })} + + {/snippet} + +
+ + diff --git a/frontend/src/lib/components/disk/StorageThumb.svelte b/frontend/src/lib/components/disk/StorageThumb.svelte new file mode 100644 index 0000000..ec5fce6 --- /dev/null +++ b/frontend/src/lib/components/disk/StorageThumb.svelte @@ -0,0 +1,144 @@ + + + { + near = true; + }} +> + {#if extension} + {extension} + {:else} + + {/if} + {#if loadable && preview === "image"} + + { + failed = true; + }} + onload={() => { + shown = true; + }} + {src} + class:shown + > + {:else if loadable && preview === "video"} + + {#if shown} + + {/if} + {/if} + + + diff --git a/frontend/src/lib/components/disk/TrashPanel.svelte b/frontend/src/lib/components/disk/TrashPanel.svelte new file mode 100644 index 0000000..91da7f5 --- /dev/null +++ b/frontend/src/lib/components/disk/TrashPanel.svelte @@ -0,0 +1,421 @@ + + +
+

+ Файлы лежат здесь 7 дней, потом удаляются с диска. До этого их можно + вернуть. +

+ + {#if items.length > 0} + + {/if} + +
+ {#each items as item, index (item.storage_key)} + open(index)} + ontoggle={() => toggle(item.storage_key)} + refetchable={item.refetchable} + selected={selected.has(item.storage_key)} + size={item.file_size} + src={trashedUrl(item.storage_key)} + sub={chatTitle(item.chat_id)} + weight={heaviest > 0 ? (item.file_size ?? 0) / heaviest : 0} + > + {#snippet menu()} + open(index)}> + Открыть + + restore([item])}> + Вернуть + + jump(item)}> + Перейти к сообщению + + { + doomed = [item]; + }} + > + Удалить насовсем + + {/snippet} + + {/each} + + {#if loading} +
+ {:else if failed} +
+

Не удалось загрузить корзину.

+ +
+ {:else if items.length === 0} +

Корзина пуста.

+ {/if} +
+ + {#if chosen.length > 0} + 0 ? `${chosenGone} не скачать заново` : undefined} + title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}" + warn + > + + + + {/if} +
+ + { + if (!next) { + doomed = null; + } + }} + open={doomed !== null} +> + + + +
+ + {doomed?.length === items.length + ? "Очистить корзину?" + : "Удалить насовсем?"} + + + + +
+ {#if doomed} +
+

+ {filesLabel(doomed.length)}, {formatBytes(bytesOf(doomed))}, + {plural( + doomed.length, + "будет удалён", + "будут удалены", + "будут удалены" + )} + с диска. + {#if doomedGone < doomed.length} + {doomedGone === 0 ? "Потом их" : "Остальные потом"} + можно скачать из Telegram заново. + {/if} +

+ {#if doomedGone > 0} +

+ + + {filesLabel(doomedGone)} + скачать заново нельзя: в Telegram + {doomedGone === 1 ? "его" : "их"} + уже нет. + +

+ {/if} +
+ {/if} +
+ + {#snippet child({ + props, + })} + + {/snippet} + + +
+
+
+
+ + + + diff --git a/frontend/src/lib/components/settings/Settings.svelte b/frontend/src/lib/components/settings/Settings.svelte index 03c5732..66574b4 100644 --- a/frontend/src/lib/components/settings/Settings.svelte +++ b/frontend/src/lib/components/settings/Settings.svelte @@ -43,10 +43,15 @@ onclick={() => ui.openPanel("policy")} /> ui.openPanel("jobs")} /> + ui.openPanel("storage")} + /> = { + audio: "Аудио", + file: "Файлы", + other: "Фото и прочее", + video: "Видео", + voice: "Кружки и голосовые", +}; + +const KIND_GROUPS: Record = { + audio: "audio", + document: "file", + video: "video", + video_note: "voice", + voice: "voice", +}; + +export function groupOf(kind: string): StorageGroupName { + return KIND_GROUPS[kind] ?? "other"; +} + +export function filesLabel(count: number): string { + return `${count.toLocaleString("ru-RU")} ${plural(count, "файл", "файла", "файлов")}`; +} + +export function messagesLabel(count: number): string { + return `в ${count} ${plural(count, "сообщении", "сообщениях", "сообщениях")}`; +} + +export function fileExtension(fileName: string): string { + const tail = fileName.includes(".") ? (fileName.split(".").pop() ?? "") : ""; + return tail.length > 0 && tail.length <= EXTENSION_LIMIT + ? tail.toUpperCase() + : ""; +} + +export function purgeLabel(iso: string): string { + const days = Math.ceil((new Date(iso).getTime() - Date.now()) / DAY_MS); + if (days <= 0) { + return "Удалится сегодня"; + } + if (days === 1) { + return "Удалится завтра"; + } + return `Удалится через ${days} дн.`; +} diff --git a/frontend/src/lib/stores/ui.svelte.ts b/frontend/src/lib/stores/ui.svelte.ts index b29535a..fbf2863 100644 --- a/frontend/src/lib/stores/ui.svelte.ts +++ b/frontend/src/lib/stores/ui.svelte.ts @@ -14,7 +14,9 @@ export type RightPanel = | "watches" | "alerts" | "shares" - | "bots"; + | "bots" + | "storage" + | "storage-trash"; export type LeftView = "main" | "settings"; diff --git a/frontend/src/lib/styles/dark-theme.scss b/frontend/src/lib/styles/dark-theme.scss index 64776ca..d199592 100644 --- a/frontend/src/lib/styles/dark-theme.scss +++ b/frontend/src/lib/styles/dark-theme.scss @@ -1,4 +1,11 @@ html.theme-dark { + --color-storage-video: #8774e1; + --color-storage-file: #d95926; + --color-storage-voice: #199e70; + --color-storage-audio: #c98500; + --color-storage-other: #3987e5; + --color-storage-warning: #fb8c00; + --storage-tile-mix: 78%; --color-primary: #8774e1; --color-primary-opacity: #8378db1e; --color-primary-opacity-hover: #8378db40; diff --git a/frontend/src/lib/styles/variables.scss b/frontend/src/lib/styles/variables.scss index 08ffb26..76a7a8b 100644 --- a/frontend/src/lib/styles/variables.scss +++ b/frontend/src/lib/styles/variables.scss @@ -133,6 +133,14 @@ --color-warning: #{$color-warning}; + --color-storage-video: #3390ec; + --color-storage-file: #eb6834; + --color-storage-voice: #1baf7a; + --color-storage-audio: #eda100; + --color-storage-other: #6250d6; + --color-storage-warning: #a85a00; + --storage-tile-mix: 62%; + --color-yellow: #{$color-yellow}; --color-orange: #{$color-orange};