Compare commits

..
7 Commits
27 changed files with 2650 additions and 29 deletions

No files matched your search

@@ -0,0 +1,57 @@
"""media trash
Revision ID: c9e2a7f4b1d8
Revises: b6d1f4a82e57
Create Date: 2026-10-14 03:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "c9e2a7f4b1d8"
down_revision: str | None = "b6d1f4a82e57"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_TRASH = """
CREATE TABLE media_trash (
storage_key text PRIMARY KEY,
account_id integer NOT NULL,
chat_id bigint NOT NULL,
message_id bigint NOT NULL,
kind text NOT NULL,
mime text,
file_name text NOT NULL,
file_size bigint,
trashed_at timestamptz NOT NULL DEFAULT now()
)
"""
def upgrade() -> None:
op.execute(
"ALTER TABLE media ADD COLUMN evicted_at timestamptz, "
"ADD COLUMN evicted_key text"
)
op.execute(
"CREATE INDEX ix_media_evicted_unique ON media (unique_id) "
"WHERE evicted_at IS NOT NULL"
)
op.execute(
"CREATE INDEX ix_media_evicted_key ON media (evicted_key) "
"WHERE evicted_key IS NOT NULL"
)
op.execute(_TRASH)
op.execute(
"CREATE INDEX ix_media_trash_account ON media_trash "
"(account_id, trashed_at DESC)"
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS media_trash")
op.execute("DROP INDEX IF EXISTS ix_media_evicted_key")
op.execute("DROP INDEX IF EXISTS ix_media_evicted_unique")
op.execute("ALTER TABLE media DROP COLUMN evicted_key, DROP COLUMN evicted_at")
+9
View File
@@ -1,3 +1,4 @@
import asyncio
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -34,12 +35,15 @@ from api.routers import (
search, search,
shares, shares,
social, social,
storage,
stories, stories,
watches, watches,
) )
from dependencies.container import container from dependencies.container import container
from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS, NO_STORE_HEADERS from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS, NO_STORE_HEADERS
from utils.env import env from utils.env import env
from utils.storage import ContentAddressedStorage
from utils.trash import purge_loop
if env.auth.token is None: if env.auth.token is None:
msg = "AUTH__TOKEN is required for the api process" msg = "AUTH__TOKEN is required for the api process"
@@ -55,7 +59,11 @@ mcp_app = mcp.http_app(path="/")
async def lifespan(app_: Starlette) -> AsyncGenerator[None]: async def lifespan(app_: Starlette) -> AsyncGenerator[None]:
pool = await container.get(asyncpg.Pool) pool = await container.get(asyncpg.Pool)
await hub.start(pool) await hub.start(pool)
purger = asyncio.create_task(
purge_loop(pool, await container.get(ContentAddressedStorage))
)
yield yield
purger.cancel()
await hub.stop() await hub.stop()
await app_.state.dishka_container.close() await app_.state.dishka_container.close()
@@ -96,6 +104,7 @@ app.include_router(discover.router)
app.include_router(annotations.router) app.include_router(annotations.router)
app.include_router(watches.router) app.include_router(watches.router)
app.include_router(shares.router) app.include_router(shares.router)
app.include_router(storage.router)
app.include_router(files.router) app.include_router(files.router)
app.mount("/mcp", mcp_app) app.mount("/mcp", mcp_app)
+3 -1
View File
@@ -54,7 +54,7 @@ async def serve_media_version(
download: Download = False, download: Download = False,
) -> FileResponse: ) -> FileResponse:
version = await get_media_version(pool, version_id) version = await get_media_version(pool, version_id)
if version is None: if version is None or not storage.exists(version.storage_key):
raise HTTPException(status_code=404, detail="media version not found") raise HTTPException(status_code=404, detail="media version not found")
headers = IMMUTABLE_HEADERS headers = IMMUTABLE_HEADERS
if download: if download:
@@ -96,6 +96,8 @@ async def serve_media(
status_code=409, status_code=409,
detail="media not downloaded; enqueue fetch via POST /api/media/fetch", detail="media not downloaded; enqueue fetch via POST /api/media/fetch",
) )
if not storage.exists(media.storage_key):
raise HTTPException(status_code=404, detail="media file is missing")
headers = DAY_HEADERS headers = DAY_HEADERS
if download: if download:
headers = _attachment(headers, media.file_name or f"media_{media_id}") headers = _attachment(headers, media.file_name or f"media_{media_id}")
+107
View File
@@ -0,0 +1,107 @@
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse
from pydantic import BaseModel
from utils import trash
from utils.cache import NO_STORE_HEADERS
from utils.files import resolve_mime
from utils.read import storage as storage_read
from utils.read.models import (
DEFAULT_LIMIT,
Page,
StorageFileView,
StorageSummaryView,
TrashItemView,
TrashOutcome,
)
from utils.storage import ContentAddressedStorage
router = APIRouter(prefix="/api/storage", tags=["storage"], route_class=DishkaRoute)
class TrashRequest(BaseModel):
account_id: int
media_ids: list[int]
class RestoreRequest(BaseModel):
account_id: int
keys: list[str]
class PurgeRequest(BaseModel):
account_id: int
keys: list[str] | None = None
@router.get("/summary")
async def summary(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> StorageSummaryView:
return await storage_read.get_summary(pool, account_id)
@router.get("/files")
async def files(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
group: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[StorageFileView]:
page = Page(limit=limit, offset=offset)
return await storage_read.list_files(pool, account_id, group, page)
@router.get("/trash")
async def trash_items(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> list[TrashItemView]:
return await storage_read.list_trash(pool, account_id)
@router.post("/trash")
async def move_to_trash(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
body: TrashRequest,
) -> TrashOutcome:
return await trash.move_to_trash(pool, storage, body.account_id, body.media_ids)
@router.post("/trash/restore")
async def restore(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
body: RestoreRequest,
) -> TrashOutcome:
return await trash.restore(pool, storage, body.account_id, body.keys)
@router.post("/trash/purge")
async def purge(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
body: PurgeRequest,
) -> TrashOutcome:
return await trash.purge(pool, storage, body.account_id, body.keys)
@router.get("/trash/{storage_key}/file")
async def serve_trashed(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
storage_key: str,
) -> FileResponse:
item = await storage_read.get_trash_item(pool, storage_key)
if item is None:
raise HTTPException(status_code=404, detail="file is not in trash")
return FileResponse(
storage.trash_url(storage_key),
media_type=resolve_mime(item["kind"], item["mime"], item["file_name"]),
headers=NO_STORE_HEADERS,
)
@@ -88,8 +88,8 @@ ON CONFLICT (account_id, chat_id, message_id, storage_key) DO NOTHING
_INSERT_MEDIA = """ _INSERT_MEDIA = """
INSERT INTO media INSERT INTO media
(account_id, chat_id, message_id, kind, storage_key, file_size, mime, (account_id, chat_id, message_id, kind, storage_key, file_size, mime,
ttl_seconds, downloaded, unique_id) ttl_seconds, downloaded, unique_id, evicted_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, CASE WHEN $11 THEN now() END)
ON CONFLICT (account_id, chat_id, message_id) DO UPDATE SET ON CONFLICT (account_id, chat_id, message_id) DO UPDATE SET
kind = EXCLUDED.kind, kind = EXCLUDED.kind,
storage_key = EXCLUDED.storage_key, storage_key = EXCLUDED.storage_key,
@@ -97,7 +97,27 @@ ON CONFLICT (account_id, chat_id, message_id) DO UPDATE SET
mime = EXCLUDED.mime, mime = EXCLUDED.mime,
ttl_seconds = EXCLUDED.ttl_seconds, ttl_seconds = EXCLUDED.ttl_seconds,
downloaded = EXCLUDED.downloaded, downloaded = EXCLUDED.downloaded,
unique_id = EXCLUDED.unique_id unique_id = EXCLUDED.unique_id,
evicted_at = CASE
WHEN EXCLUDED.downloaded THEN NULL
WHEN media.unique_id IS DISTINCT FROM EXCLUDED.unique_id
THEN EXCLUDED.evicted_at
ELSE coalesce(media.evicted_at, EXCLUDED.evicted_at) END,
evicted_key = CASE WHEN EXCLUDED.downloaded
OR media.unique_id IS DISTINCT FROM EXCLUDED.unique_id
THEN NULL ELSE media.evicted_key END
"""
_IS_EVICTED = """
SELECT EXISTS (
SELECT 1 FROM media WHERE unique_id = $1 AND evicted_at IS NOT NULL
)
"""
_UNEVICT = """
UPDATE media SET storage_key = $2, file_size = $3, downloaded = true,
evicted_at = NULL, evicted_key = NULL
WHERE evicted_key = $2 OR (unique_id = $1 AND evicted_at IS NOT NULL)
""" """
@@ -229,6 +249,7 @@ async def insert_media( # noqa: PLR0913
unique_id: str | None, unique_id: str | None,
*, *,
downloaded: bool, downloaded: bool,
evicted: bool = False,
) -> None: ) -> None:
async with pool.acquire() as conn, conn.transaction(): async with pool.acquire() as conn, conn.transaction():
await conn.execute( await conn.execute(
@@ -243,6 +264,7 @@ async def insert_media( # noqa: PLR0913
ttl_seconds, ttl_seconds,
downloaded, downloaded,
unique_id, unique_id,
evicted,
) )
if storage_key is not None: if storage_key is not None:
await conn.execute( await conn.execute(
@@ -259,6 +281,22 @@ async def insert_media( # noqa: PLR0913
) )
async def is_evicted(pool: asyncpg.Pool, unique_id: str | None) -> bool:
return unique_id is not None and await pool.fetchval(_IS_EVICTED, unique_id)
async def unevict(
pool: asyncpg.Pool, unique_id: str | None, storage_key: str, file_size: int | None
) -> bool:
async with pool.acquire() as conn, conn.transaction():
await conn.execute(_UNEVICT, unique_id, storage_key, file_size)
dropped = await conn.fetchval(
"DELETE FROM media_trash WHERE storage_key = $1 RETURNING storage_key",
storage_key,
)
return dropped is not None
async def insert_callbacks( async def insert_callbacks(
pool: asyncpg.Pool, pool: asyncpg.Pool,
account_id: int, account_id: int,
@@ -21,7 +21,9 @@ async def fetch_media(ctx: JobContext) -> None:
msg = f"message {chat_id}/{message_id} is unavailable" msg = f"message {chat_id}/{message_id} is unavailable"
raise RuntimeError(msg) raise RuntimeError(msg)
toggles = CaptureToggles(media=True, self_destruct_media=True) toggles = CaptureToggles(media=True, self_destruct_media=True)
ok = await capture_media(client, message, capture, chat_id, message_id, toggles) ok = await capture_media(
client, message, capture, chat_id, message_id, toggles, force=True
)
if not ok: if not ok:
msg = f"media download failed for {chat_id}/{message_id}" msg = f"media download failed for {chat_id}/{message_id}"
raise RuntimeError(msg) raise RuntimeError(msg)
@@ -106,6 +106,8 @@ async def capture_media( # noqa: PLR0913
chat_id: int, chat_id: int,
message_id: int, message_id: int,
toggles: CaptureToggles, toggles: CaptureToggles,
*,
force: bool = False,
) -> bool: ) -> bool:
kind, obj = media_object(message) kind, obj = media_object(message)
if obj is None: if obj is None:
@@ -117,6 +119,7 @@ async def capture_media( # noqa: PLR0913
mime = getattr(obj, "mime_type", None) mime = getattr(obj, "mime_type", None)
storage_key: str | None = None storage_key: str | None = None
downloaded = False downloaded = False
evicted = False
flood: FloodWait | FloodPremiumWait | None = None flood: FloodWait | FloodPremiumWait | None = None
if want: if want:
existing = await repository.current_media( existing = await repository.current_media(
@@ -131,7 +134,7 @@ async def capture_media( # noqa: PLR0913
storage_key = existing["storage_key"] storage_key = existing["storage_key"]
file_size = existing["file_size"] file_size = existing["file_size"]
downloaded = True downloaded = True
else: elif force or not await repository.is_evicted(ctx.pool, unique_id):
target = _download_target(message) target = _download_target(message)
try: try:
data = await _download(client, target, chat_id, message_id) data = await _download(client, target, chat_id, message_id)
@@ -142,6 +145,8 @@ async def capture_media( # noqa: PLR0913
storage_key = ctx.storage.put(data) storage_key = ctx.storage.put(data)
file_size = len(data) file_size = len(data)
downloaded = True downloaded = True
else:
evicted = True
await repository.insert_media( await repository.insert_media(
ctx.pool, ctx.pool,
ctx.account_id, ctx.account_id,
@@ -154,7 +159,14 @@ async def capture_media( # noqa: PLR0913
ttl, ttl,
unique_id, unique_id,
downloaded=downloaded, downloaded=downloaded,
evicted=evicted,
) )
if (
force
and storage_key is not None
and await repository.unevict(ctx.pool, unique_id, storage_key, file_size)
):
ctx.storage.purge(storage_key)
if flood is not None: if flood is not None:
raise flood raise flood
return downloaded or not want return downloaded or not want
+51
View File
@@ -236,6 +236,57 @@ class MediaVersionView(BaseModel):
observed_at: datetime observed_at: datetime
class StorageGroupView(BaseModel):
group: str
files: int
bytes: int
class StorageSummaryView(BaseModel):
files: int
bytes: int
heavy_files: int
heavy_bytes: int
heavy_threshold: int
trash_files: int
trash_bytes: int
groups: list[StorageGroupView]
class StorageFileView(BaseModel):
media_id: int
chat_id: int
message_id: int
kind: str
mime: str | None
file_name: str
file_size: int
date: datetime | None
refs: int
refetchable: bool
class TrashItemView(BaseModel):
storage_key: str
chat_id: int
message_id: int
kind: str
mime: str | None
file_name: str
file_size: int | None
trashed_at: datetime
purge_at: datetime
refs: int
refetchable: bool
class TrashOutcome(BaseModel):
files: int = 0
bytes: int = 0
keys: list[str] = []
blocked: list[int] = []
class AvatarRef(BaseModel): class AvatarRef(BaseModel):
unique_id: str unique_id: str
storage_key: str | None storage_key: str | None
+208
View File
@@ -0,0 +1,208 @@
import asyncio
from collections import defaultdict
import asyncpg
from utils.files import media_file_name
from utils.read.models import (
Page,
StorageFileView,
StorageGroupView,
StorageSummaryView,
TrashItemView,
)
from utils.trash import RETENTION
HEAVY_BYTES = 100 * 1024 * 1024
REFS_CHECKED = 5
LOOKUP_CONCURRENCY = 8
OTHER_GROUP = "other"
GROUP_KINDS: dict[str, tuple[str, ...]] = {
"video": ("video",),
"file": ("document",),
"voice": ("video_note", "voice"),
"audio": ("audio",),
}
_KIND_GROUP = {kind: group for group, kinds in GROUP_KINDS.items() for kind in kinds}
_SUMMARY = """
WITH blobs AS (
SELECT storage_key, max(file_size) AS size, min(kind) AS kind
FROM media
WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL
GROUP BY storage_key
)
SELECT kind, count(*) AS files, coalesce(sum(size), 0)::bigint AS bytes,
count(*) FILTER (WHERE size >= $2) AS heavy_files,
coalesce(sum(size) FILTER (WHERE size >= $2), 0)::bigint AS heavy_bytes
FROM blobs GROUP BY kind
"""
_TRASH_TOTAL = """
SELECT count(*) AS files, coalesce(sum(file_size), 0)::bigint AS bytes
FROM media_trash WHERE account_id = $1
"""
_FILES = """
WITH top AS MATERIALIZED (
SELECT storage_key, max(file_size) AS file_size, count(*) AS refs
FROM media
WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL
AND ($2::text[] IS NULL OR (kind = ANY($2)) = $3)
GROUP BY storage_key
ORDER BY 2 DESC NULLS LAST, 1
LIMIT $4 OFFSET $5
)
SELECT * FROM (
SELECT t.storage_key, t.file_size, t.refs, m.id, m.chat_id, m.message_id,
m.kind, m.mime, m.ttl_seconds,
row_number() OVER (PARTITION BY t.storage_key ORDER BY m.id) AS n
FROM top t
JOIN media m ON m.storage_key = t.storage_key
AND m.account_id = $1 AND m.downloaded
) ranked
WHERE n <= $6
ORDER BY file_size DESC NULLS LAST, storage_key, id
"""
_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 file_size DESC NULLS LAST, storage_key
"""
_TRASH_REFS = """
SELECT * FROM (
SELECT evicted_key AS storage_key, chat_id, message_id, kind, ttl_seconds,
count(*) OVER (PARTITION BY evicted_key) AS refs,
row_number() OVER (PARTITION BY evicted_key ORDER BY id) AS n
FROM media
WHERE account_id = $1 AND evicted_key = ANY($2::text[])
) ranked
WHERE n <= $3
"""
_MESSAGE = """
SELECT date, deleted_at, raw->$4::text->>'file_name' AS original_name
FROM messages
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3
ORDER BY date DESC LIMIT 1
"""
def _group_filter(group: str | None) -> tuple[list[str] | None, bool]:
if group is None:
return None, True
if group == OTHER_GROUP:
return list(_KIND_GROUP), False
return list(GROUP_KINDS.get(group, ())), True
async def get_summary(pool: asyncpg.Pool, account_id: int) -> StorageSummaryView:
rows = await pool.fetch(_SUMMARY, account_id, HEAVY_BYTES)
trash = await pool.fetchrow(_TRASH_TOTAL, account_id)
groups = {
name: StorageGroupView(group=name, files=0, bytes=0)
for name in (*GROUP_KINDS, OTHER_GROUP)
}
for row in rows:
group = groups[_KIND_GROUP.get(row["kind"], OTHER_GROUP)]
group.files += row["files"]
group.bytes += row["bytes"]
return StorageSummaryView(
files=sum(row["files"] for row in rows),
bytes=sum(row["bytes"] for row in rows),
heavy_files=sum(row["heavy_files"] for row in rows),
heavy_bytes=sum(row["heavy_bytes"] for row in rows),
heavy_threshold=HEAVY_BYTES,
trash_files=trash["files"],
trash_bytes=trash["bytes"],
groups=list(groups.values()),
)
async def _messages(
pool: asyncpg.Pool, account_id: int, refs: list[asyncpg.Record]
) -> list[asyncpg.Record | None]:
gate = asyncio.Semaphore(LOOKUP_CONCURRENCY)
async def lookup(ref: asyncpg.Record) -> asyncpg.Record | None:
async with gate:
return await pool.fetchrow(
_MESSAGE, account_id, ref["chat_id"], ref["message_id"], ref["kind"]
)
return await asyncio.gather(*(lookup(ref) for ref in refs))
def _alive(ref: asyncpg.Record, message: asyncpg.Record | None) -> bool:
return (
message is not None
and message["deleted_at"] is None
and ref["ttl_seconds"] is None
)
async def list_files(
pool: asyncpg.Pool, account_id: int, group: str | None, page: Page
) -> list[StorageFileView]:
kinds, include = _group_filter(group)
refs = await pool.fetch(
_FILES, account_id, kinds, include, page.capped_limit, page.offset, REFS_CHECKED
)
messages = await _messages(pool, account_id, refs)
files: dict[str, StorageFileView] = {}
for ref, message in zip(refs, messages, strict=True):
alive = _alive(ref, message)
known = files.get(ref["storage_key"])
if known is not None and (known.refetchable or not alive):
continue
files[ref["storage_key"]] = StorageFileView(
media_id=ref["id"],
chat_id=ref["chat_id"],
message_id=ref["message_id"],
kind=ref["kind"],
mime=ref["mime"],
file_name=media_file_name(
ref["kind"],
ref["mime"],
ref["message_id"],
message["original_name"] if message else None,
),
file_size=ref["file_size"] or 0,
date=message["date"] if message else None,
refs=ref["refs"],
refetchable=alive,
)
return list(files.values())
async def list_trash(pool: asyncpg.Pool, account_id: int) -> list[TrashItemView]:
rows = await pool.fetch(_TRASH, account_id)
refs = await pool.fetch(
_TRASH_REFS, account_id, [row["storage_key"] for row in rows], REFS_CHECKED
)
messages = await _messages(pool, account_id, refs)
alive: dict[str, bool] = defaultdict(bool)
counts: dict[str, int] = {}
for ref, message in zip(refs, messages, strict=True):
alive[ref["storage_key"]] |= _alive(ref, message)
counts[ref["storage_key"]] = ref["refs"]
return [
TrashItemView(
**dict(row),
purge_at=row["trashed_at"] + RETENTION,
refs=counts.get(row["storage_key"], 0),
refetchable=alive.get(row["storage_key"], True),
)
for row in rows
]
async def get_trash_item(pool: asyncpg.Pool, storage_key: str) -> asyncpg.Record | None:
return await pool.fetchrow(
"SELECT kind, mime, file_name FROM media_trash WHERE storage_key = $1",
storage_key,
)
+28
View File
@@ -1,6 +1,8 @@
import hashlib import hashlib
from pathlib import Path from pathlib import Path
TRASH_DIR = ".trash"
class ContentAddressedStorage: class ContentAddressedStorage:
def __init__(self, root: str | Path, shard_depth: int = 2) -> None: def __init__(self, root: str | Path, shard_depth: int = 2) -> None:
@@ -27,3 +29,29 @@ class ContentAddressedStorage:
def url(self, key: str) -> str: def url(self, key: str) -> str:
return str(self._path(key)) return str(self._path(key))
def _trash_path(self, key: str) -> Path:
return self._root / TRASH_DIR / key
def trash(self, key: str) -> None:
target = self._trash_path(key)
target.parent.mkdir(parents=True, exist_ok=True)
self._path(key).replace(target)
def restore(self, key: str) -> bool:
source = self._trash_path(key)
target = self._path(key)
if not source.exists():
return target.exists()
if target.exists():
source.unlink()
else:
target.parent.mkdir(parents=True, exist_ok=True)
source.replace(target)
return True
def purge(self, key: str) -> None:
self._trash_path(key).unlink(missing_ok=True)
def trash_url(self, key: str) -> str:
return str(self._trash_path(key))
+167
View File
@@ -0,0 +1,167 @@
import asyncio
import logging
from datetime import timedelta
import asyncpg
from utils.read.media import get_media
from utils.read.models import TrashOutcome
from utils.storage import ContentAddressedStorage
logger = logging.getLogger(__name__)
RETENTION = timedelta(days=7)
PURGE_INTERVAL_SECONDS = 3600
_SHARED_ELSEWHERE = """
SELECT EXISTS (SELECT 1 FROM stories WHERE storage_key = $1)
OR EXISTS (SELECT 1 FROM avatars WHERE storage_key = $1)
OR EXISTS (SELECT 1 FROM custom_emoji WHERE storage_key = $1)
"""
_EVICT = """
UPDATE media SET storage_key = NULL, downloaded = false,
evicted_at = now(), evicted_key = $2
WHERE storage_key = $1
"""
_INSERT = """
INSERT INTO media_trash
(storage_key, account_id, chat_id, message_id, kind, mime, file_name, file_size)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (storage_key) DO UPDATE SET trashed_at = now()
"""
_RESTORE = """
UPDATE media SET storage_key = $1, downloaded = true,
evicted_at = NULL, evicted_key = NULL
WHERE evicted_key = $1 OR (
evicted_at IS NOT NULL AND unique_id IN (
SELECT unique_id FROM media
WHERE evicted_key = $1 AND unique_id IS NOT NULL
)
)
"""
_FORGET_KEYS = """
UPDATE media SET evicted_key = NULL WHERE evicted_key = ANY($1::text[])
"""
async def move_to_trash(
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
account_id: int,
media_ids: list[int],
) -> TrashOutcome:
outcome = TrashOutcome()
for media_id in media_ids:
media = await get_media(pool, media_id)
if media is None or media.account_id != account_id or not media.storage_key:
continue
key = media.storage_key
on_disk = storage.exists(key)
async with pool.acquire() as conn, conn.transaction():
if await conn.fetchval(_SHARED_ELSEWHERE, key):
outcome.blocked.append(media_id)
continue
await conn.execute(_EVICT, key, key if on_disk else None)
if not on_disk:
continue
await conn.execute(
_INSERT,
key,
account_id,
media.chat_id,
media.message_id,
media.kind,
media.mime,
media.file_name,
media.file_size,
)
storage.trash(key)
outcome.keys.append(key)
outcome.files += 1
outcome.bytes += media.file_size or 0
return outcome
async def restore(
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
account_id: int,
keys: list[str],
) -> TrashOutcome:
outcome = TrashOutcome()
for key in keys:
async with pool.acquire() as conn, conn.transaction():
row = await conn.fetchrow(
"DELETE FROM media_trash WHERE storage_key = $1 AND account_id = $2 "
"RETURNING file_size",
key,
account_id,
)
if row is None:
continue
if not storage.restore(key):
await conn.execute(_FORGET_KEYS, [key])
continue
await conn.execute(_RESTORE, key)
outcome.files += 1
outcome.bytes += row["file_size"] or 0
return outcome
async def _purge(
pool: asyncpg.Pool, storage: ContentAddressedStorage, rows: list[asyncpg.Record]
) -> TrashOutcome:
keys = [row["storage_key"] for row in rows]
for key in keys:
storage.purge(key)
async with pool.acquire() as conn, conn.transaction():
await conn.execute(
"DELETE FROM media_trash WHERE storage_key = ANY($1::text[])", keys
)
await conn.execute(_FORGET_KEYS, keys)
return TrashOutcome(
files=len(rows), bytes=sum(row["file_size"] or 0 for row in rows)
)
async def purge(
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
account_id: int,
keys: list[str] | None,
) -> TrashOutcome:
rows = await pool.fetch(
"SELECT storage_key, file_size FROM media_trash WHERE account_id = $1 "
"AND ($2::text[] IS NULL OR storage_key = ANY($2))",
account_id,
keys,
)
return await _purge(pool, storage, rows)
async def purge_expired(
pool: asyncpg.Pool, storage: ContentAddressedStorage
) -> TrashOutcome:
rows = await pool.fetch(
"SELECT storage_key, file_size FROM media_trash "
"WHERE trashed_at < now() - $1::interval",
RETENTION,
)
return await _purge(pool, storage, rows)
async def purge_loop(pool: asyncpg.Pool, storage: ContentAddressedStorage) -> None:
while True:
try:
outcome = await purge_expired(pool, storage)
if outcome.files:
logger.info(
"Trash purged: %s files, %s bytes", outcome.files, outcome.bytes
)
except Exception:
logger.exception("Trash purge failed")
await asyncio.sleep(PURGE_INTERVAL_SECONDS)
+13
View File
@@ -95,6 +95,19 @@ export async function request<T>(
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
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 = export type MediaResult =
| { state: "ready"; url: string; mime: string | null } | { state: "ready"; url: string; mime: string | null }
| { state: "not-downloaded" } | { state: "not-downloaded" }
+44
View File
@@ -31,7 +31,12 @@ import type {
ReactionView, ReactionView,
ResponseStats, ResponseStats,
SearchHit, SearchHit,
StorageFile,
StorageGroupName,
StorageSummary,
StoryView, StoryView,
TrashItem,
TrashOutcome,
VolumeBucket, VolumeBucket,
Watch, Watch,
WatchedBot, WatchedBot,
@@ -581,3 +586,42 @@ export function addWatchedBot(token: string): Promise<WatchedBot> {
export function removeWatchedBot(botId: number): Promise<void> { export function removeWatchedBot(botId: number): Promise<void> {
return request<void>(`/bots/${botId}`, { method: "DELETE" }); return request<void>(`/bots/${botId}`, { method: "DELETE" });
} }
export function getStorageSummary(): Promise<StorageSummary> {
return request<StorageSummary>("/storage/summary", { account: true });
}
export function getStorageFiles(
group: StorageGroupName | null,
page: Page = {}
): Promise<StorageFile[]> {
return request<StorageFile[]>("/storage/files", {
account: true,
query: { group, ...page },
});
}
export function getTrash(): Promise<TrashItem[]> {
return request<TrashItem[]>("/storage/trash", { account: true });
}
export function moveToTrash(mediaIds: number[]): Promise<TrashOutcome> {
return request<TrashOutcome>("/storage/trash", {
body: { account_id: accounts.selectedId, media_ids: mediaIds },
method: "POST",
});
}
export function restoreFromTrash(keys: string[]): Promise<TrashOutcome> {
return request<TrashOutcome>("/storage/trash/restore", {
body: { account_id: accounts.selectedId, keys },
method: "POST",
});
}
export function purgeTrash(keys: string[] | null): Promise<TrashOutcome> {
return request<TrashOutcome>("/storage/trash/purge", {
body: { account_id: accounts.selectedId, keys },
method: "POST",
});
}
+32
View File
@@ -19,6 +19,7 @@ export type InlineMedia =
| { state: "missing" }; | { state: "missing" };
export interface ViewerItem { export interface ViewerItem {
caption?: string;
downloaded: boolean; downloaded: boolean;
fileName: string | null; fileName: string | null;
fileSize: number | null; fileSize: number | null;
@@ -26,6 +27,7 @@ export interface ViewerItem {
mediaId: number | null; mediaId: number | null;
messageId: number; messageId: number;
mime: string | null; mime: string | null;
src?: string;
} }
export function viewerItemsFrom( export function viewerItemsFrom(
@@ -102,6 +104,16 @@ export function visualKind(kind: string): VisualKind {
return "other"; 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 MAX_CACHED = 200;
const ready = new Map<string, InlineMedia>(); const ready = new Map<string, InlineMedia>();
@@ -130,6 +142,26 @@ function cacheKey(account: number, chatId: number, messageId: number): string {
return `${account}:${chatId}:${messageId}`; return `${account}:${chatId}:${messageId}`;
} }
function forget(cache: Map<string, InlineMedia>, 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( async function resolve(
chatId: number, chatId: number,
messageId: number messageId: number
+53
View File
@@ -560,3 +560,56 @@ export interface WatchedBot {
title: string | null; title: string | null;
username: 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;
refs: number;
storage_key: string;
trashed_at: string;
}
export interface TrashOutcome {
blocked: number[];
bytes: number;
files: number;
keys: string[];
}
+76 -10
View File
@@ -1,10 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Dialog } from "bits-ui"; import { Dialog } from "bits-ui";
import { untrack } from "svelte"; import { untrack } from "svelte";
import { type MediaResult, requestMedia } from "#lib/api/client.js"; import { type MediaResult, mediaUrl, requestMedia } from "#lib/api/client.js";
import { downloadMedia } from "#lib/api/download.js"; import { downloadMedia } from "#lib/api/download.js";
import { fetchMedia, getMessageMedia } from "#lib/api/endpoints.js"; import { fetchMedia, getMessageMedia } from "#lib/api/endpoints.js";
import { isPreviewable, type ViewerItem } from "#lib/api/media.js"; import {
isPreviewable,
isStreamable,
type ViewerItem,
} from "#lib/api/media.js";
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
@@ -19,6 +23,7 @@
chatId: number; chatId: number;
index: number; index: number;
items: ViewerItem[]; items: ViewerItem[];
ontrash?: (item: ViewerItem) => void;
open: boolean; open: boolean;
} }
@@ -27,14 +32,18 @@
index = $bindable(), index = $bindable(),
chatId, chatId,
items, items,
ontrash,
}: Props = $props(); }: Props = $props();
const STREAM_BYTES = 32 * 1024 * 1024;
let kind = $state(""); let kind = $state("");
let messageId = $state<number | null>(null); let messageId = $state<number | null>(null);
let currentMediaId = $state<number | null>(null); let currentMediaId = $state<number | null>(null);
let fileName = $state<string | null>(null); let fileName = $state<string | null>(null);
let fileSize = $state<number | null>(null); let fileSize = $state<number | null>(null);
let fileOnly = $state(false); let fileOnly = $state(false);
let streamed = $state(false);
let result = $state<MediaResult | null>(null); let result = $state<MediaResult | null>(null);
let loading = $state(false); let loading = $state(false);
let saving = $state(false); let saving = $state(false);
@@ -53,35 +62,54 @@
currentMediaId !== null && (fileOnly || result?.state === "ready") currentMediaId !== null && (fileOnly || result?.state === "ready")
); );
const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа"); const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа");
const caption = $derived(items[index]?.caption);
function revoke() { function revoke() {
if (result?.state === "ready" && result.url) { if (result?.state === "ready" && result.url && !streamed) {
URL.revokeObjectURL(result.url); URL.revokeObjectURL(result.url);
} }
} }
function streamSource(
item: ViewerItem,
mediaId: number | null,
size: number | null
): string | null {
if (item.src) {
return item.src;
}
const heavy = size !== null && size > STREAM_BYTES;
return mediaId !== null && heavy && isStreamable(kind, item.mime)
? mediaUrl(mediaId)
: null;
}
async function load(item: ViewerItem) { async function load(item: ViewerItem) {
revoke(); revoke();
loading = true; loading = true;
result = null; result = null;
currentMediaId = null; currentMediaId = null;
fileOnly = false; fileOnly = false;
streamed = false;
({ kind, messageId, fileName, fileSize } = item); ({ kind, messageId, fileName, fileSize } = item);
token += 1; token += 1;
const current = token; const current = token;
try { try {
let { mediaId, downloaded, mime: mimeType } = item; let { mediaId, downloaded, mime: mimeType } = item;
let { fileName: name, fileSize: size } = item; let { fileName: name, fileSize: size } = item;
if (mediaId === null) { if (mediaId === null && !item.src) {
const meta = await getMessageMedia(chatId, item.messageId); const meta = await getMessageMedia(chatId, item.messageId);
({ id: mediaId, downloaded, kind, mime: mimeType } = meta); ({ id: mediaId, downloaded, kind, mime: mimeType } = meta);
({ file_name: name, file_size: size } = meta); ({ file_name: name, file_size: size } = meta);
} }
const plainFile = downloaded && !isPreviewable(kind, mimeType); const plainFile = downloaded && !isPreviewable(kind, mimeType);
const source = downloaded ? streamSource(item, mediaId, size) : null;
let next: MediaResult; let next: MediaResult;
if (plainFile) { if (plainFile) {
next = { mime: mimeType, state: "ready", url: "" }; next = { mime: mimeType, state: "ready", url: "" };
} else if (downloaded) { } else if (source !== null) {
next = { mime: mimeType, state: "ready", url: source };
} else if (downloaded && mediaId !== null) {
next = await requestMedia(mediaId); next = await requestMedia(mediaId);
} else { } else {
next = { state: "not-downloaded" } as MediaResult; next = { state: "not-downloaded" } as MediaResult;
@@ -92,6 +120,7 @@
fileName = name; fileName = name;
fileSize = size; fileSize = size;
fileOnly = plainFile; fileOnly = plainFile;
streamed = source !== null && !plainFile;
} }
} catch { } catch {
if (current === token) { if (current === token) {
@@ -136,6 +165,14 @@
} }
} }
function trash() {
const item = items[index];
if (item && ontrash) {
open = false;
ontrash(item);
}
}
function step(delta: number) { function step(delta: number) {
const next = index + delta; const next = index + delta;
if (next >= 0 && next < items.length) { if (next >= 0 && next < items.length) {
@@ -181,8 +218,21 @@
<Dialog.Overlay class="media-overlay" /> <Dialog.Overlay class="media-overlay" />
<Dialog.Content class="media-content"> <Dialog.Content class="media-content">
<Dialog.Title class="media-title">{title}</Dialog.Title> <Dialog.Title class="media-title">{title}</Dialog.Title>
{#if caption}
<span class="media-caption">{caption}</span>
{/if}
<div class="media-actions"> <div class="media-actions">
{#if canSave} {#if canSave}
{#if ontrash}
<button
aria-label="В корзину"
class="media-action"
onclick={trash}
type="button"
>
<Icon name="delete" size="1.375rem" />
</button>
{/if}
<ContextMenu> <ContextMenu>
{#snippet children({ {#snippet children({
props, props,
@@ -234,7 +284,7 @@
class="media-video" class="media-video"
controls controls
playsinline playsinline
preload="auto" preload={streamed ? "metadata" : "auto"}
src={result.url} src={result.url}
use:poster use:poster
></video> ></video>
@@ -248,10 +298,12 @@
{#if fileSize} {#if fileSize}
<p class="file-size">{formatBytes(fileSize)}</p> <p class="file-size">{formatBytes(fileSize)}</p>
{/if} {/if}
<button class="media-download" onclick={save} type="button"> {#if currentMediaId !== null}
<Icon name="download" /> <button class="media-download" onclick={save} type="button">
Скачать файл <Icon name="download" />
</button> Скачать файл
</button>
{/if}
</div> </div>
{:else if result?.state === "not-downloaded"} {:else if result?.state === "not-downloaded"}
<div class="media-message"> <div class="media-message">
@@ -323,6 +375,20 @@
white-space: nowrap; white-space: nowrap;
} }
.media-caption {
position: absolute;
top: 2.75rem;
left: 1.25rem;
overflow: hidden;
max-width: min(24rem, calc(100% - 14rem));
font-size: 0.8125rem;
color: rgba(255, 255, 255, 0.6);
text-overflow: ellipsis;
white-space: nowrap;
}
.media-counter { .media-counter {
position: absolute; position: absolute;
top: 1rem; top: 1rem;
+41 -10
View File
@@ -2,6 +2,8 @@
import AlertsPanel from "#lib/components/alerts/AlertsPanel.svelte"; import AlertsPanel from "#lib/components/alerts/AlertsPanel.svelte";
import AnnotationsPanel from "#lib/components/annotations/AnnotationsPanel.svelte"; import AnnotationsPanel from "#lib/components/annotations/AnnotationsPanel.svelte";
import BotsPanel from "#lib/components/bots/BotsPanel.svelte"; import BotsPanel from "#lib/components/bots/BotsPanel.svelte";
import StoragePanel from "#lib/components/disk/StoragePanel.svelte";
import TrashPanel from "#lib/components/disk/TrashPanel.svelte";
import JobsPanel from "#lib/components/jobs/JobsPanel.svelte"; import JobsPanel from "#lib/components/jobs/JobsPanel.svelte";
import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte"; import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte";
import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte"; import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte";
@@ -16,6 +18,7 @@
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte"; import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js"; import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
const titles: Record<RightPanel, string> = { const titles: Record<RightPanel, string> = {
@@ -23,7 +26,7 @@
annotations: "Заметки", annotations: "Заметки",
bots: "Боты", bots: "Боты",
callbacks: "Callback-данные", callbacks: "Callback-данные",
jobs: "Данные и хранилище", jobs: "Бэкфилл и задачи",
links: "Ссылки", links: "Ссылки",
policy: "Политика захвата", policy: "Политика захвата",
presence: "Аналитика", presence: "Аналитика",
@@ -31,23 +34,43 @@
reactions: "Реакции", reactions: "Реакции",
search: "Поиск", search: "Поиск",
shares: "Файлы по ссылке", shares: "Файлы по ссылке",
storage: "Хранилище",
"storage-trash": "Корзина",
stories: "Сторис", stories: "Сторис",
"stories-all": "Все сторис", "stories-all": "Все сторис",
versions: "Версии", versions: "Версии",
watches: "Отслеживания", watches: "Отслеживания",
}; };
const parents: Partial<Record<RightPanel, RightPanel>> = {
"storage-trash": "storage",
};
const parent = $derived(ui.rightPanel ? parents[ui.rightPanel] : undefined);
</script> </script>
<header class="right-header"> <header class="right-header">
<Button {#if parent}
aria-label="Закрыть" <Button
onclick={() => ui.closePanel()} aria-label="Назад"
round onclick={() => ui.openPanel(parent)}
smaller round
variant="translucent" smaller
> variant="translucent"
<Icon name="close" /> >
</Button> <Icon name="arrow-left" />
</Button>
{:else}
<Button
aria-label="Закрыть"
onclick={() => ui.closePanel()}
round
smaller
variant="translucent"
>
<Icon name="close" />
</Button>
{/if}
<h2>{ui.rightPanel ? titles[ui.rightPanel] : ""}</h2> <h2>{ui.rightPanel ? titles[ui.rightPanel] : ""}</h2>
</header> </header>
@@ -82,6 +105,14 @@
<SharesPanel /> <SharesPanel />
{:else if ui.rightPanel === "bots"} {:else if ui.rightPanel === "bots"}
<BotsPanel /> <BotsPanel />
{:else if ui.rightPanel === "storage"}
{#key accounts.selectedId}
<StoragePanel />
{/key}
{:else if ui.rightPanel === "storage-trash"}
{#key accounts.selectedId}
<TrashPanel />
{/key}
{/if} {/if}
</div> </div>
@@ -0,0 +1,75 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
failure?: string | null;
note?: string;
title: string;
warn?: boolean;
}
let { title, note, warn = false, failure, children }: Props = $props();
</script>
<div class="bar">
<div class="info">
<div class="title">{title}</div>
{#if note}
<div class="note" class:warn>{note}</div>
{/if}
{#if failure}
<div class="note failed">{failure}</div>
{/if}
</div>
{@render children()}
</div>
<style lang="scss">
.bar {
position: sticky;
z-index: 1;
bottom: 0;
display: flex;
gap: 0.5rem;
align-items: center;
min-height: 4rem;
padding: 0.625rem 0.75rem calc(0.625rem + env(safe-area-inset-bottom)) 1rem;
border-top: 1px solid var(--color-borders);
background-color: var(--color-background);
box-shadow: 0 -0.5rem 1.5rem var(--color-light-shadow);
}
.bar :global(.Button) {
gap: 0.375rem;
padding-inline: 0.875rem;
}
.info {
flex: 1;
min-width: 0;
}
.title {
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
font-variant-numeric: tabular-nums;
}
.note {
margin-top: 0.125rem;
font-size: 0.8125rem;
color: var(--color-text-secondary);
&.warn {
color: var(--color-storage-warning);
}
&.failed {
color: var(--color-error);
}
}
</style>
@@ -0,0 +1,681 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import { mediaUrl } from "#lib/api/client.js";
import { downloadMedia } from "#lib/api/download.js";
import {
getStorageFiles,
getStorageSummary,
moveToTrash,
restoreFromTrash,
} from "#lib/api/endpoints.js";
import { forgetMedia, type ViewerItem } from "#lib/api/media.js";
import type {
StorageFile,
StorageGroupName,
StorageSummary,
TrashOutcome,
} from "#lib/api/types.js";
import SelectionBar from "#lib/components/disk/SelectionBar.svelte";
import StorageRow from "#lib/components/disk/StorageRow.svelte";
import MediaViewer from "#lib/components/MediaViewer.svelte";
import Button from "#lib/components/ui/Button.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatListDate } from "#lib/format/datetime.js";
import { formatBytes } from "#lib/format/media.js";
import {
filesLabel,
GROUP_LABELS,
messagesLabel,
} from "#lib/format/storage.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js";
import { goto } from "$app/navigation";
const PAGE = 30;
const UNDO_MS = 12_000;
const UNDO_GUARD_MS = 600;
let summary = $state<StorageSummary | null>(null);
let files = $state<StorageFile[]>([]);
let group = $state<StorageGroupName | null>(null);
let loading = $state(false);
let failed = $state(false);
let done = $state(false);
let busy = $state(false);
let undo = $state<{ files: StorageFile[]; outcome: TrashOutcome } | null>(
null
);
let problem = $state<string | null>(null);
let viewerOpen = $state(false);
let viewerIndex = $state(0);
const selected = new SvelteSet<number>();
let token = 0;
let undoTimer: ReturnType<typeof setTimeout> | undefined;
let undoShownAt = 0;
const heaviest = $derived(files[0]?.file_size ?? 0);
const chosen = $derived(files.filter((file) => selected.has(file.media_id)));
const chosenBytes = $derived(
chosen.reduce((sum, file) => sum + file.file_size, 0)
);
const chosenGone = $derived(
chosen.filter((file) => !file.refetchable).length
);
const groups = $derived(
summary?.groups.filter((item) => item.files > 0 || item.group === group) ??
[]
);
const viewerItems = $derived<ViewerItem[]>(
files.map((file) => ({
caption: `${formatBytes(file.file_size)} · ${chatTitle(file.chat_id)}`,
downloaded: true,
fileName: file.file_name,
fileSize: file.file_size,
kind: file.kind,
mediaId: file.media_id,
messageId: file.message_id,
mime: file.mime,
}))
);
function chatTitle(chatId: number): string {
return chats.byId(chatId)?.title ?? `Чат ${chatId}`;
}
async function loadSummary() {
try {
summary = await getStorageSummary();
} catch {
failed = true;
}
}
async function loadMore() {
if (loading || done) {
return;
}
const current = token;
loading = true;
failed = false;
try {
const batch = await getStorageFiles(group, {
limit: PAGE,
offset: files.length,
});
if (current !== token) {
return;
}
chats.ensureMany(batch.map((file) => file.chat_id));
files = [...files, ...batch];
done = batch.length < PAGE;
} catch {
if (current === token) {
failed = true;
}
} finally {
if (current === token) {
loading = false;
}
}
}
function reload() {
token += 1;
files = [];
done = false;
loading = false;
selected.clear();
loadSummary();
loadMore();
}
function toggle(mediaId: number) {
problem = null;
if (!selected.delete(mediaId)) {
selected.add(mediaId);
}
}
function hideUndo() {
clearTimeout(undoTimer);
undo = null;
}
function showUndo(outcome: TrashOutcome, trashed: StorageFile[]) {
hideUndo();
if (outcome.files > 0) {
undo = { files: trashed, outcome };
undoShownAt = Date.now();
undoTimer = setTimeout(hideUndo, UNDO_MS);
}
}
async function trash(mediaIds: number[]) {
if (busy || mediaIds.length === 0) {
return;
}
busy = true;
problem = null;
try {
const outcome = await moveToTrash(mediaIds);
const moved = new Set(
mediaIds.filter((id) => !outcome.blocked.includes(id))
);
const trashed = files.filter((file) => moved.has(file.media_id));
for (const file of trashed) {
forgetMedia(file.chat_id, file.message_id, file.media_id);
}
files = files.filter((file) => !moved.has(file.media_id));
selected.clear();
showUndo(outcome, trashed);
if (outcome.blocked.length > 0) {
problem = "Файл занят в сторис или аватаре и остался на диске";
}
await loadSummary();
} catch {
problem = "Не удалось переместить в корзину";
} finally {
busy = false;
}
}
async function restoreLast() {
if (undo === null || Date.now() - undoShownAt < UNDO_GUARD_MS) {
return;
}
const restored = undo;
hideUndo();
try {
await restoreFromTrash(restored.outcome.keys);
files = [...files, ...restored.files].sort(
(a, b) => b.file_size - a.file_size
);
await loadSummary();
} catch {
problem = "Не удалось вернуть файлы";
}
}
async function save(file: StorageFile) {
try {
await downloadMedia(file.media_id);
} catch {
problem = "Не удалось скачать файл";
}
}
function open(index: number) {
viewerIndex = index;
viewerOpen = true;
}
function jump(file: StorageFile) {
ui.requestJump(file.chat_id, file.message_id);
goto(`/app/${file.chat_id}`);
if (isMobile()) {
ui.closePanel();
}
}
function pick(next: StorageGroupName) {
group = group === next ? null : next;
problem = null;
reload();
}
onMount(reload);
onDestroy(() => clearTimeout(undoTimer));
</script>
<div class="storage">
{#if summary}
{#if summary.files > 0}
<section class="summary">
<div class="hero">
<span class="total">{formatBytes(summary.bytes)}</span>
<span class="count">
{filesLabel(summary.files)}
{summary.trash_bytes > 0
? `· ещё ${formatBytes(summary.trash_bytes)} в корзине`
: "на диске"}
</span>
</div>
<div class="stack">
{#each groups as item (item.group)}
<span
title="{GROUP_LABELS[item.group]}: {formatBytes(item.bytes)}"
style:background-color="var(--color-storage-{item.group})"
style:flex-grow={item.bytes}
></span>
{/each}
{#if summary.trash_bytes > 0}
<span
class="trashed"
title="Корзина: {formatBytes(summary.trash_bytes)}"
style:flex-grow={summary.trash_bytes}
></span>
{/if}
</div>
<div class="legend">
{#each groups as item (item.group)}
<button
aria-pressed={group === item.group}
class="chip"
onclick={() => pick(item.group)}
type="button"
class:on={group === item.group}
>
<span
class="dot"
style:background-color="var(--color-storage-{item.group})"
></span>
{GROUP_LABELS[item.group]}
<span class="value">{formatBytes(item.bytes)}</span>
</button>
{/each}
</div>
</section>
{/if}
<button
class="trash-link"
onclick={() => ui.openPanel("storage-trash")}
type="button"
>
<Icon name="delete" size="1.375rem" />
<span class="label">Корзина</span>
<span class="value">
{#if summary.trash_files > 0}
<span class="swatch"></span>
{filesLabel(summary.trash_files)}
· {formatBytes(summary.trash_bytes)}
{:else}
пусто
{/if}
</span>
<Icon name="next" size="1.125rem" />
</button>
{/if}
{#if files.length > 0}
<div class="section-title">
<h3>Самые тяжёлые</h3>
{#if summary && group === null && summary.heavy_files > 0}
<span>
{filesLabel(summary.heavy_files)}
крупнее {formatBytes(summary.heavy_threshold)}
= {formatBytes(summary.heavy_bytes)}
</span>
{/if}
</div>
{/if}
<div class="list">
{#each files as file, index (file.media_id)}
<StorageRow
fileName={file.file_name}
kind={file.kind}
mime={file.mime}
note={file.refs > 1 ? messagesLabel(file.refs) : undefined}
onopen={() => 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={formatListDate(file.date)}
weight={heaviest > 0 ? file.file_size / heaviest : 0}
>
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}>
Открыть
</ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => jump(file)}>
Перейти к сообщению
</ContextMenuItem>
<ContextMenuItem icon="download" onselect={() => save(file)}>
Скачать файл
</ContextMenuItem>
<ContextMenuItem
icon="delete"
onselect={() => trash([file.media_id])}
>
В корзину
</ContextMenuItem>
{/snippet}
</StorageRow>
{/each}
{#if loading}
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>Не удалось загрузить хранилище.</p>
<Button onclick={reload} smaller variant="text">Повторить</Button>
</div>
{:else if files.length === 0}
<div class="state">
<p>
{group === null
? "На диске нет скачанных файлов."
: "В этой группе нет файлов."}
</p>
</div>
{:else if !done}
<button class="more" onclick={loadMore} type="button">
Показать ещё
</button>
{/if}
</div>
{#if chosen.length > 0}
<SelectionBar
failure={problem}
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined}
title="{filesLabel(chosen.length)} · {formatBytes(chosenBytes)}"
warn
>
<Button onclick={() => selected.clear()} smaller variant="text">
Снять
</Button>
<Button
disabled={busy}
loading={busy}
onclick={() => trash([...selected])}
pill
smaller
>
<Icon name="delete" size="1.25rem" />
<span>В корзину</span>
</Button>
</SelectionBar>
{:else if undo}
<SelectionBar
failure={problem}
note="{formatBytes(undo.outcome.bytes)} освободится через 7 дней"
title="{filesLabel(undo.outcome.files)} в корзине"
>
<Button onclick={restoreLast} smaller variant="text">
<Icon name="undo" size="1.25rem" />
<span>Вернуть</span>
</Button>
</SelectionBar>
{:else if problem}
<SelectionBar failure={problem} title="Не получилось">
<Button
onclick={() => {
problem = null;
}}
smaller
variant="text"
>
Понятно
</Button>
</SelectionBar>
{/if}
</div>
<MediaViewer
chatId={files[viewerIndex]?.chat_id ?? 0}
items={viewerItems}
ontrash={(item) => {
if (item.mediaId !== null) {
trash([item.mediaId]);
}
}}
bind:index={viewerIndex}
bind:open={viewerOpen}
/>
<style lang="scss">
.storage {
display: flex;
flex-direction: column;
min-height: 100%;
}
.summary {
padding: 1rem 1rem 0.875rem;
}
.hero {
display: flex;
flex-wrap: wrap;
gap: 0 0.625rem;
align-items: baseline;
}
.total {
font-size: 1.875rem;
font-weight: var(--font-weight-medium);
font-variant-numeric: tabular-nums;
line-height: 1.2;
letter-spacing: -0.01em;
}
.count {
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.stack {
display: flex;
gap: 2px;
height: 0.625rem;
margin-top: 0.875rem;
span {
min-width: 0.1875rem;
border-radius: 2px;
&.trashed {
background-color: var(--color-storage-trash);
}
&:first-child {
border-start-start-radius: 0.3125rem;
border-end-start-radius: 0.3125rem;
}
&:last-child {
border-start-end-radius: 0.3125rem;
border-end-end-radius: 0.3125rem;
}
}
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.375rem 0.25rem;
margin-top: 0.75rem;
}
.chip {
cursor: pointer;
display: inline-flex;
gap: 0.375rem;
align-items: center;
height: 1.75rem;
padding: 0 0.5625rem 0 0.4375rem;
border: 0;
border-radius: 0.875rem;
font: inherit;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text);
background-color: var(--color-chat-hover);
transition:
background-color 0.15s ease-out,
box-shadow 0.15s ease-out;
&.on {
background-color: color-mix(
in srgb,
var(--color-primary) 12%,
transparent
);
box-shadow: inset 0 0 0 1px var(--color-primary);
}
@media (pointer: coarse) {
height: 2.25rem;
padding: 0 0.75rem 0 0.625rem;
border-radius: 1.125rem;
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
@media (hover: hover) {
&:not(.on):hover {
background-color: var(--color-background-secondary-accent);
}
}
}
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
.value {
color: var(--color-text-secondary);
}
.trash-link {
cursor: pointer;
display: flex;
flex-shrink: 0;
gap: 1rem;
align-items: center;
width: 100%;
height: 3.25rem;
padding: 0 1rem 0 1.125rem;
border: 0;
border-top: 1px solid var(--color-borders);
border-bottom: 1px solid var(--color-borders);
font: inherit;
font-size: 0.9375rem;
color: var(--color-text);
text-align: start;
background: none;
:global(.icon) {
color: var(--color-text-secondary);
}
.label {
flex: 1;
}
.value {
display: flex;
gap: 0.375rem;
align-items: center;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
}
.swatch {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: var(--color-storage-trash);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.section-title {
display: flex;
gap: 0.75rem;
align-items: baseline;
justify-content: space-between;
padding: 0.875rem 1rem 0.375rem;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
h3 {
margin: 0;
font-size: inherit;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
white-space: nowrap;
}
}
.list {
flex: 1;
padding-bottom: 0.5rem;
}
.state {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
padding: 2rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
text-align: center;
p {
margin: 0;
}
}
.more {
cursor: pointer;
width: calc(100% - 1rem);
margin: 0.5rem;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.875rem;
color: var(--color-primary);
background-color: var(--color-background-secondary);
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
</style>
@@ -0,0 +1,260 @@
<script lang="ts">
import type { Snippet } from "svelte";
import StorageThumb from "#lib/components/disk/StorageThumb.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatBytes } from "#lib/format/media.js";
import { groupOf } from "#lib/format/storage.js";
interface Props {
fileName: string;
kind: string;
lead?: string;
menu: Snippet;
mime: string | null;
note?: string;
onopen: () => void;
ontoggle: () => void;
refetchable: boolean;
selected: boolean;
size: number | null;
src: string;
sub: string;
tail?: string;
weight: number;
}
let {
fileName,
kind,
mime,
src,
lead,
sub,
tail,
note,
size,
weight,
refetchable,
selected,
onopen,
ontoggle,
menu,
}: Props = $props();
const MIN_WEIGHT = 0.02;
const scale = $derived(Math.max(MIN_WEIGHT, Math.min(1, weight)));
</script>
<div
class="row"
style:--group="var(--color-storage-{groupOf(kind)})"
class:selected
>
<span class="check">
<input
aria-label="Выбрать {fileName}"
checked={selected}
onchange={ontoggle}
type="checkbox"
>
<span class="circle"><Icon name="check" size="0.875rem" /></span>
</span>
<ContextMenu {menu}>
{#snippet children({
props,
})}
<button {...props} class="main" onclick={onopen} type="button">
<StorageThumb {fileName} {kind} {mime} {src} />
<span class="text">
<span class="name">{fileName}</span>
<span class="sub">
{#if lead}
<span class="rigid">{`${lead} · `}</span>
{/if}
<span class="fluid">{sub}</span>
{#if tail}
<span class="rigid">{` · ${tail}`}</span>
{/if}
</span>
{#if note}
<span class="note">{note}</span>
{/if}
{#if !refetchable}
<span class="gone">Не скачать заново</span>
{/if}
</span>
<span class="size">{formatBytes(size)}</span>
<span class="weight"><span style:--scale={scale}></span></span>
</button>
{/snippet}
</ContextMenu>
</div>
<style lang="scss">
.row {
display: flex;
align-items: center;
padding-left: 0.4375rem;
&.selected {
background-color: var(--color-chat-hover);
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.check {
cursor: pointer;
position: relative;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
align-self: stretch;
width: 2.5rem;
min-height: 2.5rem;
input {
cursor: pointer;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
}
}
.circle {
display: flex;
align-items: center;
justify-content: center;
width: 1.375rem;
height: 1.375rem;
border: 2px solid var(--color-borders-input);
border-radius: 50%;
color: transparent;
transition:
background-color 0.15s ease-out,
border-color 0.15s ease-out;
input:checked + & {
border-color: var(--color-primary);
color: var(--color-white);
background-color: var(--color-primary);
}
input:focus-visible + & {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
.main {
cursor: pointer;
display: grid;
flex: 1;
grid-template-columns: 3rem minmax(0, 1fr) auto;
column-gap: 0.75rem;
align-items: center;
min-width: 0;
padding: 0.5rem 1rem 0.625rem 0.3125rem;
border: 0;
font: inherit;
color: var(--color-text);
text-align: start;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
}
.text {
display: flex;
flex-direction: column;
min-width: 0;
}
.name,
.fluid {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
display: flex;
min-width: 0;
}
.rigid {
flex-shrink: 0;
white-space: pre;
}
.name {
font-size: 0.9375rem;
}
.sub,
.note,
.gone {
margin-top: 0.125rem;
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.gone {
color: var(--color-storage-warning);
}
.size {
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.weight {
grid-column: 2 / 4;
height: 0.1875rem;
margin-top: 0.4375rem;
border-radius: 0.125rem;
background-color: rgba(var(--color-text-secondary-rgb), 0.16);
span {
transform: scaleX(var(--scale));
transform-origin: 0 50%;
display: block;
height: 100%;
border-radius: inherit;
background-color: var(--group);
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
}
</style>
@@ -0,0 +1,152 @@
<script lang="ts">
import { visible } from "#lib/actions/visible.js";
import { visualKind } from "#lib/api/media.js";
import Icon from "#lib/components/ui/Icon.svelte";
import { fileExtension, groupOf } from "#lib/format/storage.js";
import { poster } from "#lib/media/poster.js";
interface Props {
fileName: string;
kind: string;
mime: string | null;
src: string;
}
let { src, kind, mime, fileName }: Props = $props();
const GLYPHS: Record<string, string> = {
audio: "volume-2",
other: "photo",
video: "video",
voice: "microphone",
};
let near = $state(false);
let shown = $state(false);
let failed = $state(false);
const group = $derived(groupOf(kind));
const extension = $derived(group === "file" ? fileExtension(fileName) : "");
const glyph = $derived(
kind === "video_note" ? "video" : (GLYPHS[group] ?? "document")
);
const preview = $derived.by(() => {
if (mime?.startsWith("image/") || kind === "photo") {
return "image";
}
if (mime?.startsWith("video/") || visualKind(kind) === "video") {
return "video";
}
return "none";
});
const loadable = $derived(near && !failed);
</script>
<span
class="thumb"
style:--group="var(--color-storage-{group})"
use:visible={() => {
near = true;
}}
>
{#if extension}
<span class="extension">{extension}</span>
{:else}
<Icon name={glyph} size="1.375rem" />
{/if}
{#if loadable && preview === "image"}
<!-- biome-ignore lint/a11y/noNoninteractiveElementInteractions: onload is not a user interaction -->
<img
alt=""
decoding="async"
onerror={() => {
failed = true;
}}
onload={() => {
shown = true;
}}
{src}
class:shown
>
{:else if loadable && preview === "video"}
<video
muted
onerror={() => {
failed = true;
}}
onloadeddata={() => {
shown = true;
}}
playsinline
preload="metadata"
{src}
class:shown
use:poster
></video>
{#if shown}
<span class="play"><Icon name="play" size="0.875rem" /></span>
{/if}
{/if}
</span>
<style lang="scss">
.thumb {
position: relative;
overflow: hidden;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 0.5rem;
color: var(--color-white);
background-color: color-mix(
in srgb,
var(--group) var(--storage-tile-mix),
black
);
}
.extension {
font-size: 0.6875rem;
font-weight: var(--font-weight-medium);
letter-spacing: 0.04em;
}
img,
video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
object-fit: cover;
transition: opacity 0.2s ease-out;
&.shown {
opacity: 1;
}
}
.play {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background-color: rgb(0 0 0 / 45%);
}
</style>
@@ -0,0 +1,458 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { onMount } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import { trashedUrl } from "#lib/api/client.js";
import {
getTrash,
purgeTrash,
restoreFromTrash,
} from "#lib/api/endpoints.js";
import type { ViewerItem } from "#lib/api/media.js";
import type { TrashItem } from "#lib/api/types.js";
import SelectionBar from "#lib/components/disk/SelectionBar.svelte";
import StorageRow from "#lib/components/disk/StorageRow.svelte";
import MediaViewer from "#lib/components/MediaViewer.svelte";
import Button from "#lib/components/ui/Button.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatBytes } from "#lib/format/media.js";
import { plural } from "#lib/format/shares.js";
import {
filesLabel,
messagesLabel,
purgeLabel,
} from "#lib/format/storage.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js";
import { goto } from "$app/navigation";
let items = $state<TrashItem[]>([]);
let loading = $state(true);
let failed = $state(false);
let busy = $state(false);
let doomed = $state<TrashItem[] | null>(null);
let problem = $state<string | null>(null);
let viewerOpen = $state(false);
let viewerIndex = $state(0);
const selected = new SvelteSet<string>();
const heaviest = $derived(
Math.max(0, ...items.map((item) => item.file_size ?? 0))
);
const totalBytes = $derived(bytesOf(items));
const chosen = $derived(
items.filter((item) => selected.has(item.storage_key))
);
const chosenGone = $derived(
chosen.filter((item) => !item.refetchable).length
);
const doomedGone = $derived(
doomed?.filter((item) => !item.refetchable).length ?? 0
);
const viewerItems = $derived<ViewerItem[]>(
items.map((item) => ({
caption: `${formatBytes(item.file_size)} · ${chatTitle(item.chat_id)}`,
downloaded: true,
fileName: item.file_name,
fileSize: item.file_size,
kind: item.kind,
mediaId: null,
messageId: item.message_id,
mime: item.mime,
src: trashedUrl(item.storage_key),
}))
);
function bytesOf(list: TrashItem[]): number {
return list.reduce((sum, item) => sum + (item.file_size ?? 0), 0);
}
function chatTitle(chatId: number): string {
return chats.byId(chatId)?.title ?? `Чат ${chatId}`;
}
async function load() {
loading = true;
failed = false;
try {
items = await getTrash();
chats.ensureMany(items.map((item) => item.chat_id));
} catch {
failed = true;
} finally {
loading = false;
}
}
function toggle(key: string) {
problem = null;
if (!selected.delete(key)) {
selected.add(key);
}
}
function drop(keys: string[]) {
const gone = new Set(keys);
items = items.filter((item) => !gone.has(item.storage_key));
selected.clear();
}
async function restore(list: TrashItem[]) {
if (busy || list.length === 0) {
return;
}
busy = true;
problem = null;
const keys = list.map((item) => item.storage_key);
try {
await restoreFromTrash(keys);
drop(keys);
} catch {
problem = "Не удалось вернуть файлы";
} finally {
busy = false;
}
}
async function purge() {
if (busy || doomed === null) {
return;
}
busy = true;
problem = null;
const keys = doomed.map((item) => item.storage_key);
try {
await purgeTrash(keys);
drop(keys);
doomed = null;
} catch {
problem = "Не удалось удалить файлы";
} finally {
busy = false;
}
}
function open(index: number) {
viewerIndex = index;
viewerOpen = true;
}
function jump(item: TrashItem) {
ui.requestJump(item.chat_id, item.message_id);
goto(`/app/${item.chat_id}`);
if (isMobile()) {
ui.closePanel();
}
}
onMount(load);
</script>
<div class="trash">
<p class="note">
Файлы лежат здесь 7 дней, потом удаляются с диска. До этого их можно
вернуть.
</p>
{#if items.length > 0}
<button
class="clear"
onclick={() => {
doomed = items;
}}
type="button"
>
<Icon name="delete" size="1.375rem" />
<span class="label">Очистить корзину</span>
<span class="value">{formatBytes(totalBytes)}</span>
</button>
{/if}
<div class="list">
{#each items as item, index (item.storage_key)}
<StorageRow
fileName={item.file_name}
kind={item.kind}
lead={purgeLabel(item.purge_at)}
mime={item.mime}
note={item.refs > 1 ? messagesLabel(item.refs) : undefined}
onopen={() => 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()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}>
Открыть
</ContextMenuItem>
<ContextMenuItem icon="undo" onselect={() => restore([item])}>
Вернуть
</ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => jump(item)}>
Перейти к сообщению
</ContextMenuItem>
<ContextMenuItem
icon="delete"
onselect={() => {
doomed = [item];
}}
>
Удалить насовсем
</ContextMenuItem>
{/snippet}
</StorageRow>
{/each}
{#if loading}
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>Не удалось загрузить корзину.</p>
<Button onclick={load} smaller variant="text">Повторить</Button>
</div>
{:else if items.length === 0}
<div class="state"><p>Корзина пуста.</p></div>
{/if}
</div>
{#if chosen.length > 0}
<SelectionBar
failure={doomed === null ? problem : null}
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined}
title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}"
warn
>
<Button
onclick={() => {
doomed = chosen;
}}
pill
smaller
variant="danger"
>
Удалить
</Button>
<Button
disabled={busy}
loading={busy}
onclick={() => restore(chosen)}
pill
smaller
>
<Icon name="undo" size="1.25rem" />
<span>Вернуть</span>
</Button>
</SelectionBar>
{:else if problem && doomed === null}
<SelectionBar failure={problem} title="Не получилось">
<Button
onclick={() => {
problem = null;
}}
smaller
variant="text"
>
Понятно
</Button>
</SelectionBar>
{/if}
</div>
<Dialog.Root
onOpenChange={(next) => {
if (!next) {
doomed = null;
problem = null;
}
}}
open={doomed !== null}
>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">
{doomed?.length === items.length
? "Очистить корзину?"
: "Удалить насовсем?"}
</Dialog.Title>
<Dialog.Close aria-label="Закрыть" class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
{#if doomed}
<div class="dialog-body">
<p class="confirm">
{filesLabel(doomed.length)}, {formatBytes(bytesOf(doomed))},
{plural(
doomed.length,
"будет удалён",
"будут удалены",
"будут удалены"
)}
с диска.
</p>
{#if doomedGone > 0}
<p class="confirm warn">
<Icon name="warning" size="1.25rem" />
<span>
{filesLabel(doomedGone)}
скачать заново нельзя: в Telegram
{doomedGone === 1 ? "его" : "их"}
уже нет.
</span>
</p>
{/if}
{#if doomedGone < doomed.length}
<p class="confirm rest">
{doomedGone === 0 ? "Потом их" : "Остальные"}
можно скачать из Telegram заново.
</p>
{/if}
{#if problem}
<p class="confirm failed">{problem}.</p>
{/if}
</div>
{/if}
<div class="dialog-actions">
<Dialog.Close>
{#snippet child({
props,
})}
<Button {...props} pill variant="text">Отмена</Button>
{/snippet}
</Dialog.Close>
<Button
disabled={busy}
loading={busy}
onclick={purge}
pill
variant="danger"
>
Удалить
</Button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<MediaViewer
chatId={items[viewerIndex]?.chat_id ?? 0}
items={viewerItems}
bind:index={viewerIndex}
bind:open={viewerOpen}
/>
<style lang="scss">
.trash {
display: flex;
flex-direction: column;
min-height: 100%;
}
.note {
margin: 0;
padding: 0.75rem 1rem;
font-size: 0.8125rem;
line-height: 1.4;
color: var(--color-text-secondary);
}
.clear {
cursor: pointer;
display: flex;
flex-shrink: 0;
gap: 0.875rem;
align-items: center;
width: 100%;
height: 3rem;
padding: 0 1rem 0 1.125rem;
border: 0;
border-bottom: 1px solid var(--color-borders);
font: inherit;
font-size: 0.9375rem;
color: var(--color-error);
text-align: start;
background: none;
.label {
flex: 1;
}
.value {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
&:focus-visible {
outline: 2px solid var(--color-error);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.list {
flex: 1;
padding: 0.25rem 0 0.5rem;
}
.state {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
padding: 2rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
text-align: center;
p {
margin: 0;
}
}
.confirm {
margin: 0;
font-size: 0.9375rem;
line-height: 1.45;
&.rest,
&.failed {
margin-top: 0.75rem;
}
&.failed {
color: var(--color-error);
}
&.warn {
display: flex;
gap: 0.625rem;
align-items: flex-start;
margin-top: 0.75rem;
color: var(--color-storage-warning);
}
}
</style>
@@ -43,10 +43,15 @@
onclick={() => ui.openPanel("policy")} onclick={() => ui.openPanel("policy")}
/> />
<SettingsItem <SettingsItem
icon="data" icon="cloud-download"
label="Данные и хранилище" label="Бэкфилл и задачи"
onclick={() => ui.openPanel("jobs")} onclick={() => ui.openPanel("jobs")}
/> />
<SettingsItem
icon="data"
label="Хранилище"
onclick={() => ui.openPanel("storage")}
/>
<SettingsItem <SettingsItem
icon="play-story" icon="play-story"
label="Сторис" label="Сторис"
+51
View File
@@ -0,0 +1,51 @@
import type { StorageGroupName } from "#lib/api/types.js";
import { plural } from "#lib/format/shares.js";
const DAY_MS = 86_400_000;
const EXTENSION_LIMIT = 4;
export const GROUP_LABELS: Record<StorageGroupName, string> = {
audio: "Аудио",
file: "Файлы",
other: "Фото и прочее",
video: "Видео",
voice: "Кружки и голосовые",
};
const KIND_GROUPS: Record<string, StorageGroupName> = {
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} дн.`;
}
+3 -1
View File
@@ -14,7 +14,9 @@ export type RightPanel =
| "watches" | "watches"
| "alerts" | "alerts"
| "shares" | "shares"
| "bots"; | "bots"
| "storage"
| "storage-trash";
export type LeftView = "main" | "settings"; export type LeftView = "main" | "settings";
+8
View File
@@ -1,4 +1,12 @@
html.theme-dark { 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;
--color-storage-trash: #6c6c6c;
--storage-tile-mix: 90%;
--color-primary: #8774e1; --color-primary: #8774e1;
--color-primary-opacity: #8378db1e; --color-primary-opacity: #8378db1e;
--color-primary-opacity-hover: #8378db40; --color-primary-opacity-hover: #8378db40;
+9
View File
@@ -133,6 +133,15 @@
--color-warning: #{$color-warning}; --color-warning: #{$color-warning};
--color-storage-video: #6250d6;
--color-storage-file: #c4501c;
--color-storage-voice: #12805a;
--color-storage-audio: #a36a00;
--color-storage-other: #2a78c8;
--color-storage-warning: #a85a00;
--color-storage-trash: #939799;
--storage-tile-mix: 100%;
--color-yellow: #{$color-yellow}; --color-yellow: #{$color-yellow};
--color-orange: #{$color-orange}; --color-orange: #{$color-orange};