diff --git a/backend/src/api/routers/stories.py b/backend/src/api/routers/stories.py index dde49ac..7370294 100644 --- a/backend/src/api/routers/stories.py +++ b/backend/src/api/routers/stories.py @@ -5,7 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka from fastapi import APIRouter, HTTPException, Query from fastapi.responses import FileResponse -from utils.files import content_disposition, story_file_name +from utils.files import content_disposition, sniff_media_kind, story_file_name from utils.read import peers from utils.read.models import DEFAULT_LIMIT, Page, StoryView from utils.storage import ContentAddressedStorage @@ -17,17 +17,34 @@ AccountId = Annotated[int, Query()] _STORY_MIME = {"photo": "image/jpeg", "video": "video/mp4"} +async def _with_kind( + pool: asyncpg.Pool, + storage: ContentAddressedStorage, + account_id: int, + story: StoryView, +) -> StoryView: + if story.media_kind or not story.storage_key: + return story + if not storage.exists(story.storage_key): + return story + kind = sniff_media_kind(storage.head(story.storage_key)) + await peers.set_story_kind(pool, account_id, story.peer_id, story.story_id, kind) + return story.model_copy(update={"media_kind": kind}) + + @router.get("/stories") async def list_stories( pool: FromDishka[asyncpg.Pool], + storage: FromDishka[ContentAddressedStorage], account_id: AccountId, peer_id: Annotated[int | None, Query()] = None, limit: Annotated[int, Query()] = DEFAULT_LIMIT, offset: Annotated[int, Query()] = 0, ) -> list[StoryView]: - return await peers.get_stories( + stories = await peers.get_stories( pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id ) + return [await _with_kind(pool, storage, account_id, story) for story in stories] @router.get("/stories/{peer_id}/{story_id}/media") diff --git a/backend/src/userbot/modules/stories/repository.py b/backend/src/userbot/modules/stories/repository.py index 06a72de..2dcbf9a 100644 --- a/backend/src/userbot/modules/stories/repository.py +++ b/backend/src/userbot/modules/stories/repository.py @@ -6,7 +6,7 @@ _UPSERT_STORY = """ INSERT INTO stories (account_id, peer_id, story_id, date, expire_date, caption, media_kind, storage_key, file_size, downloaded, views, pinned, deleted, raw, observed_at) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb, now()) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, false, $13::jsonb, now()) ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET date = EXCLUDED.date, expire_date = EXCLUDED.expire_date, @@ -17,10 +17,17 @@ ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET downloaded = stories.downloaded OR EXCLUDED.downloaded, views = EXCLUDED.views, pinned = EXCLUDED.pinned, - deleted = EXCLUDED.deleted, + deleted = false, raw = EXCLUDED.raw """ +_MARK_DELETED = """ +INSERT INTO stories + (account_id, peer_id, story_id, downloaded, pinned, deleted, raw, observed_at) +VALUES ($1, $2, $3, false, false, true, '{}'::jsonb, now()) +ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET deleted = true +""" + async def is_downloaded( pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int @@ -51,7 +58,6 @@ async def upsert_story( # noqa: PLR0913 raw: str, *, pinned: bool, - deleted: bool, downloaded: bool, ) -> None: await pool.execute( @@ -68,6 +74,11 @@ async def upsert_story( # noqa: PLR0913 downloaded, views, pinned, - deleted, raw, ) + + +async def mark_deleted( + pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int +) -> None: + await pool.execute(_MARK_DELETED, account_id, peer_id, story_id) diff --git a/backend/src/userbot/modules/stories/service.py b/backend/src/userbot/modules/stories/service.py index 6954d37..dbcfcea 100644 --- a/backend/src/userbot/modules/stories/service.py +++ b/backend/src/userbot/modules/stories/service.py @@ -17,13 +17,18 @@ def story_peer_id(story: Story) -> int: async def save_story(client: Client, capture: CaptureContext, story: Story) -> None: peer_id = story_peer_id(story) + if story.deleted: + await repository.mark_deleted( + capture.pool, capture.account_id, peer_id, story.id + ) + return storage_key: str | None = None file_size: int | None = None downloaded = False stored = await repository.is_downloaded( capture.pool, capture.account_id, peer_id, story.id ) - if not (stored or story.deleted or story.media is None): + if not (stored or story.media is None): data = await download_bytes(client, story) if data is not None: storage_key = capture.storage.put(data) @@ -43,6 +48,5 @@ async def save_story(client: Client, capture: CaptureContext, story: Story) -> N story.views, raw_json(story.raw), pinned=bool(story.pinned), - deleted=bool(story.deleted), downloaded=downloaded, ) diff --git a/backend/src/utils/files.py b/backend/src/utils/files.py index 48664be..fd62ee9 100644 --- a/backend/src/utils/files.py +++ b/backend/src/utils/files.py @@ -191,6 +191,11 @@ def story_file_name(peer_id: int, story_id: int, media_kind: str | None) -> str: return f"story_{peer_id}_{story_id}{extension_for(media_kind or 'photo', None)}" +def sniff_media_kind(head: bytes) -> str: + is_video = head[4:8] == b"ftyp" or head.startswith(b"\x1aE\xdf\xa3") + return "video" if is_video else "photo" + + def resolve_mime( kind: str | None, mime: str | None, file_name: str | None = None ) -> str: diff --git a/backend/src/utils/read/peers.py b/backend/src/utils/read/peers.py index 65b36a8..fe16418 100644 --- a/backend/src/utils/read/peers.py +++ b/backend/src/utils/read/peers.py @@ -76,7 +76,7 @@ async def get_stories( "SELECT peer_id, story_id, date, expire_date, caption, media_kind, " # noqa: S608 "storage_key, downloaded, views, pinned, deleted FROM stories " f"WHERE {where} " - f"ORDER BY date DESC NULLS LAST LIMIT ${len(params) - 1} " + f"ORDER BY date DESC NULLS LAST, story_id DESC LIMIT ${len(params) - 1} " f"OFFSET ${len(params)}", *params, ) @@ -95,3 +95,16 @@ async def get_story( story_id, ) return StoryView(**dict(row)) if row else None + + +async def set_story_kind( + pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int, kind: str +) -> None: + await pool.execute( + "UPDATE stories SET media_kind = $4 " + "WHERE account_id = $1 AND peer_id = $2 AND story_id = $3", + account_id, + peer_id, + story_id, + kind, + ) diff --git a/backend/src/utils/storage.py b/backend/src/utils/storage.py index 9456e8c..8e5d650 100644 --- a/backend/src/utils/storage.py +++ b/backend/src/utils/storage.py @@ -24,6 +24,10 @@ class ContentAddressedStorage: def get(self, key: str) -> bytes: return self._path(key).read_bytes() + def head(self, key: str, size: int = 16) -> bytes: + with self._path(key).open("rb") as file: + return file.read(size) + def exists(self, key: str) -> bool: return self._path(key).exists() diff --git a/frontend/src/lib/styles/global.scss b/frontend/src/lib/styles/global.scss index 11009a9..13bdf0e 100644 --- a/frontend/src/lib/styles/global.scss +++ b/frontend/src/lib/styles/global.scss @@ -159,6 +159,12 @@ html.theme-transition * { -webkit-user-select: none; user-select: none; } + + input, + textarea, + select { + font-size: max(1em, 1rem) !important; + } } .bg-menu-content {