feat(blacklist): ukrywanie stron per urzadzenie
Zgloszenie 3910b19b: brak przycisku ukrywania stron. Tabela hidden_sites
(0054), POST/DELETE /blacklist/sites/{origin}, pole sites w GET /blacklist.
Scena znika z list tylko gdy wszystkie jej zywe zrodla sa z ukrytych stron
(ta sama scena z innego serwisu zostaje); w szczegolach sceny przyciski
ukrytych stron znikaja, o ile zostaje inne zrodlo.
Cache pustosci blacklist trzyma teraz, ktore listy sa niepuste, wiec ukryte
strony nie doklejaja trzech pustych NOT EXISTS, i jest czyszczony przy kazdej
zmianie (wczesniej pierwszy wpis dzialal po 5 minutach). Kod synchronizacji
kopiuje hidden_sites oraz favorite_sites, ktorych tam brakowalo.
Sandbox na produkcyjnej bazie: strona porndoe 42 z 60 scen tylko z porndoe
przed ukryciem, 0 po; feed 0,34 s, duze tagi 0,3 do 0,8 s z dwiema ukrytymi.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2feef620a1
commit
6540d4a009
6 changed files with 215 additions and 37 deletions
31
alembic/versions/20261006_0054_hidden_sites.py
Normal file
31
alembic/versions/20261006_0054_hidden_sites.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""hidden_sites: strony ukryte przez uzytkownika (per urzadzenie).
|
||||||
|
|
||||||
|
Zgloszenie 3910b19b: brak przycisku ukrywania stron. Lustro favorite_sites (0034).
|
||||||
|
|
||||||
|
Revision ID: 0054_hidden_sites
|
||||||
|
Revises: 0053_playback_events_via
|
||||||
|
Create Date: 2026-10-06
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0054_hidden_sites"
|
||||||
|
down_revision: str | None = "0053_playback_events_via"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS hidden_sites (
|
||||||
|
device_id varchar(64) NOT NULL,
|
||||||
|
origin varchar(64) NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (device_id, origin)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TABLE IF EXISTS hidden_sites")
|
||||||
|
|
@ -9,18 +9,23 @@ Endpointy:
|
||||||
DELETE /blacklist/{kind}/{entity_id} usuń
|
DELETE /blacklist/{kind}/{entity_id} usuń
|
||||||
|
|
||||||
`kind` ∈ {performer, studio, tag}.
|
`kind` ∈ {performer, studio, tag}.
|
||||||
|
|
||||||
|
Ukryte strony (2026-10-06, zgloszenie 3910b19b): origin to napis, nie UUID, wiec osobne
|
||||||
|
trasy `/blacklist/sites/{origin}`, zadeklarowane PRZED `/{kind}/{entity_id}`.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import exists, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.device import get_device_id
|
from app.api.device import get_device_id
|
||||||
|
from app.api.scenes import invalidate_blacklist_cache
|
||||||
from app.auth import require_api_key
|
from app.auth import require_api_key
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models.blacklist import (
|
from app.models.blacklist import (
|
||||||
|
|
@ -28,7 +33,9 @@ from app.models.blacklist import (
|
||||||
BlacklistedStudio,
|
BlacklistedStudio,
|
||||||
BlacklistedTag,
|
BlacklistedTag,
|
||||||
)
|
)
|
||||||
|
from app.models.hidden_site import HiddenSite
|
||||||
from app.models.performer import Performer
|
from app.models.performer import Performer
|
||||||
|
from app.models.playback_source import PlaybackSource
|
||||||
from app.models.studio import Studio
|
from app.models.studio import Studio
|
||||||
from app.models.tag import Tag
|
from app.models.tag import Tag
|
||||||
|
|
||||||
|
|
@ -49,6 +56,8 @@ class BlacklistOut(BaseModel):
|
||||||
performers: list[BlacklistEntry]
|
performers: list[BlacklistEntry]
|
||||||
studios: list[BlacklistEntry]
|
studios: list[BlacklistEntry]
|
||||||
tags: list[BlacklistEntry]
|
tags: list[BlacklistEntry]
|
||||||
|
# Ukryte strony: same originy ('tube:porndoecom'), nazwy apka bierze z /sources.
|
||||||
|
sites: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=BlacklistOut)
|
@router.get("", response_model=BlacklistOut)
|
||||||
|
|
@ -74,13 +83,54 @@ def list_blacklist(
|
||||||
.where(BlacklistedTag.device_id == device_id)
|
.where(BlacklistedTag.device_id == device_id)
|
||||||
.order_by(Tag.name)
|
.order_by(Tag.name)
|
||||||
).all()
|
).all()
|
||||||
|
sites = session.execute(
|
||||||
|
select(HiddenSite.origin).where(HiddenSite.device_id == device_id).order_by(HiddenSite.origin)
|
||||||
|
).scalars().all()
|
||||||
return BlacklistOut(
|
return BlacklistOut(
|
||||||
performers=[BlacklistEntry(id=r[0], name=r[1], slug=r[2]) for r in perfs],
|
performers=[BlacklistEntry(id=r[0], name=r[1], slug=r[2]) for r in perfs],
|
||||||
studios=[BlacklistEntry(id=r[0], name=r[1], slug=r[2]) for r in studios],
|
studios=[BlacklistEntry(id=r[0], name=r[1], slug=r[2]) for r in studios],
|
||||||
tags=[BlacklistEntry(id=r[0], name=r[1], slug=r[2]) for r in tags],
|
tags=[BlacklistEntry(id=r[0], name=r[1], slug=r[2]) for r in tags],
|
||||||
|
sites=list(sites),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_ORIGIN_RE = re.compile(r"^[a-z0-9][a-z0-9:._-]{1,63}$")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sites/{origin}", status_code=status.HTTP_200_OK)
|
||||||
|
def hide_site(
|
||||||
|
origin: str,
|
||||||
|
session: Annotated[Session, Depends(get_session)],
|
||||||
|
device_id: Annotated[str, Depends(get_device_id)],
|
||||||
|
) -> dict:
|
||||||
|
if not _ORIGIN_RE.match(origin):
|
||||||
|
raise HTTPException(status_code=400, detail="bad origin")
|
||||||
|
if session.get(HiddenSite, (device_id, origin)) is not None:
|
||||||
|
return {"origin": origin, "created": False}
|
||||||
|
known = session.execute(
|
||||||
|
select(exists(select(1).where(PlaybackSource.origin == origin)))
|
||||||
|
).scalar_one()
|
||||||
|
if not known:
|
||||||
|
raise HTTPException(status_code=404, detail="site not found")
|
||||||
|
session.add(HiddenSite(device_id=device_id, origin=origin))
|
||||||
|
session.commit()
|
||||||
|
invalidate_blacklist_cache(device_id)
|
||||||
|
return {"origin": origin, "created": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/sites/{origin}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def unhide_site(
|
||||||
|
origin: str,
|
||||||
|
session: Annotated[Session, Depends(get_session)],
|
||||||
|
device_id: Annotated[str, Depends(get_device_id)],
|
||||||
|
) -> None:
|
||||||
|
row = session.get(HiddenSite, (device_id, origin))
|
||||||
|
if row is not None:
|
||||||
|
session.delete(row)
|
||||||
|
session.commit()
|
||||||
|
invalidate_blacklist_cache(device_id)
|
||||||
|
|
||||||
|
|
||||||
def _kind_to_entity(kind: Kind):
|
def _kind_to_entity(kind: Kind):
|
||||||
if kind == "performer":
|
if kind == "performer":
|
||||||
return BlacklistedPerformer, Performer, "performer_id"
|
return BlacklistedPerformer, Performer, "performer_id"
|
||||||
|
|
@ -105,6 +155,7 @@ def add_blacklist(
|
||||||
return {"kind": kind, "id": str(entity_id), "created": False}
|
return {"kind": kind, "id": str(entity_id), "created": False}
|
||||||
session.add(bl_model(**{"device_id": device_id, fk: entity_id}))
|
session.add(bl_model(**{"device_id": device_id, fk: entity_id}))
|
||||||
session.commit()
|
session.commit()
|
||||||
|
invalidate_blacklist_cache(device_id)
|
||||||
return {"kind": kind, "id": str(entity_id), "created": True}
|
return {"kind": kind, "id": str(entity_id), "created": True}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -121,3 +172,4 @@ def remove_blacklist(
|
||||||
return # idempotent
|
return # idempotent
|
||||||
session.delete(row)
|
session.delete(row)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
invalidate_blacklist_cache(device_id)
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,10 @@ class ImportOut(BaseModel):
|
||||||
_COPY_TABLES: list[tuple[str, str]] = _TABLES + [
|
_COPY_TABLES: list[tuple[str, str]] = _TABLES + [
|
||||||
("saved_searches", "query"),
|
("saved_searches", "query"),
|
||||||
("clip_blacklist", "value"),
|
("clip_blacklist", "value"),
|
||||||
|
# Przypiete i ukryte strony (bez FK, kluczem jest napis origin). favorite_sites
|
||||||
|
# brakowalo tu od poczatku, wiec przypiecia nie przechodzily kodem synchronizacji.
|
||||||
|
("favorite_sites", "origin"),
|
||||||
|
("hidden_sites", "origin"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -187,31 +187,47 @@ def _jav_source_exists():
|
||||||
# Cache'ujemy emptiness (TTL 5 min); gdy ktoś doda blacklist-wpis, w ciągu 5 min klauzule
|
# Cache'ujemy emptiness (TTL 5 min); gdy ktoś doda blacklist-wpis, w ciągu 5 min klauzule
|
||||||
# wracają. Patrz reference_scenes_list_perf / task #22.
|
# wracają. Patrz reference_scenes_list_perf / task #22.
|
||||||
# Cache per device_id (blacklisty są teraz device-scoped, bug 2026-06-08).
|
# Cache per device_id (blacklisty są teraz device-scoped, bug 2026-06-08).
|
||||||
_BLACKLIST_EMPTY_CACHE: dict[str, tuple[float, bool]] = {}
|
# Trzymamy, KTORE listy sa niepuste, nie tylko "czy cokolwiek": ukryte strony (2026-10-06)
|
||||||
|
# nie powinny doklejac trzech pustych NOT EXISTS po performerach/studiach/tagach.
|
||||||
|
# Dodanie i usuniecie wpisu czysci cache (invalidate_blacklist_cache), wczesniej pierwszy
|
||||||
|
# wpis dzialal dopiero po 5 minutach.
|
||||||
|
_BLACKLIST_EMPTY_CACHE: dict[str, tuple[float, frozenset[str]]] = {}
|
||||||
_BLACKLIST_EMPTY_TTL = 300.0
|
_BLACKLIST_EMPTY_TTL = 300.0
|
||||||
|
|
||||||
|
|
||||||
def _blacklists_empty(session: Session, device_id: str) -> bool:
|
def invalidate_blacklist_cache(device_id: str) -> None:
|
||||||
"""True gdy WSZYSTKIE 3 blacklisty TEGO device puste → pomiń NOT EXISTS klauzule."""
|
_BLACKLIST_EMPTY_CACHE.pop(device_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _blacklist_kinds(session: Session, device_id: str) -> frozenset[str]:
|
||||||
|
"""Niepuste listy TEGO urzadzenia: podzbior {'performer', 'studio', 'tag', 'site'}."""
|
||||||
import time as _time
|
import time as _time
|
||||||
from app.models.blacklist import (
|
from app.models.blacklist import (
|
||||||
BlacklistedPerformer,
|
BlacklistedPerformer,
|
||||||
BlacklistedStudio,
|
BlacklistedStudio,
|
||||||
BlacklistedTag,
|
BlacklistedTag,
|
||||||
)
|
)
|
||||||
|
from app.models.hidden_site import HiddenSite
|
||||||
now = _time.monotonic()
|
now = _time.monotonic()
|
||||||
cached = _BLACKLIST_EMPTY_CACHE.get(device_id)
|
cached = _BLACKLIST_EMPTY_CACHE.get(device_id)
|
||||||
if cached and (now - cached[0]) < _BLACKLIST_EMPTY_TTL:
|
if cached and (now - cached[0]) < _BLACKLIST_EMPTY_TTL:
|
||||||
return cached[1]
|
return cached[1]
|
||||||
has_any = session.execute(
|
row = session.execute(
|
||||||
select(
|
select(
|
||||||
exists(select(1).select_from(BlacklistedPerformer).where(BlacklistedPerformer.device_id == device_id))
|
exists(select(1).select_from(BlacklistedPerformer).where(BlacklistedPerformer.device_id == device_id)),
|
||||||
| exists(select(1).select_from(BlacklistedStudio).where(BlacklistedStudio.device_id == device_id))
|
exists(select(1).select_from(BlacklistedStudio).where(BlacklistedStudio.device_id == device_id)),
|
||||||
| exists(select(1).select_from(BlacklistedTag).where(BlacklistedTag.device_id == device_id))
|
exists(select(1).select_from(BlacklistedTag).where(BlacklistedTag.device_id == device_id)),
|
||||||
|
exists(select(1).select_from(HiddenSite).where(HiddenSite.device_id == device_id)),
|
||||||
)
|
)
|
||||||
).scalar_one()
|
).one()
|
||||||
_BLACKLIST_EMPTY_CACHE[device_id] = (now, not has_any)
|
kinds = frozenset(k for k, present in zip(("performer", "studio", "tag", "site"), row) if present)
|
||||||
return not has_any
|
_BLACKLIST_EMPTY_CACHE[device_id] = (now, kinds)
|
||||||
|
return kinds
|
||||||
|
|
||||||
|
|
||||||
|
def _blacklists_empty(session: Session, device_id: str) -> bool:
|
||||||
|
"""True gdy WSZYSTKIE listy TEGO device puste → pomiń NOT EXISTS klauzule."""
|
||||||
|
return not _blacklist_kinds(session, device_id)
|
||||||
|
|
||||||
|
|
||||||
# Szukanie po tytule (GOON-73/75, pomiary 2026-09-22). Sa dwa dobre plany i planer nie
|
# Szukanie po tytule (GOON-73/75, pomiary 2026-09-22). Sa dwa dobre plany i planer nie
|
||||||
|
|
@ -417,9 +433,17 @@ def live_playback_exists():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def hidden_origins_subquery(device_id: str):
|
||||||
|
"""SELECT origin z ukrytych stron urzadzenia (do IN / NOT IN)."""
|
||||||
|
from app.models.hidden_site import HiddenSite
|
||||||
|
|
||||||
|
return select(HiddenSite.origin).where(HiddenSite.device_id == device_id)
|
||||||
|
|
||||||
|
|
||||||
def blacklist_clauses(session: Session, device_id: str) -> list:
|
def blacklist_clauses(session: Session, device_id: str) -> list:
|
||||||
"""NOT-EXISTS klauzule blacklist device (performer/studio/tag). [] gdy wszystkie puste."""
|
"""Klauzule blacklist device (performer/studio/tag) i ukrytych stron. [] gdy wszystko puste."""
|
||||||
if _blacklists_empty(session, device_id):
|
kinds = _blacklist_kinds(session, device_id)
|
||||||
|
if not kinds:
|
||||||
return []
|
return []
|
||||||
from app.models.blacklist import (
|
from app.models.blacklist import (
|
||||||
BlacklistedPerformer,
|
BlacklistedPerformer,
|
||||||
|
|
@ -427,31 +451,63 @@ def blacklist_clauses(session: Session, device_id: str) -> list:
|
||||||
BlacklistedTag,
|
BlacklistedTag,
|
||||||
)
|
)
|
||||||
|
|
||||||
return [
|
out: list = []
|
||||||
~exists(
|
if "performer" in kinds:
|
||||||
select(1)
|
out.append(
|
||||||
.select_from(ScenePerformer)
|
~exists(
|
||||||
.join(
|
select(1)
|
||||||
BlacklistedPerformer,
|
.select_from(ScenePerformer)
|
||||||
(BlacklistedPerformer.performer_id == ScenePerformer.performer_id)
|
.join(
|
||||||
& (BlacklistedPerformer.device_id == device_id),
|
BlacklistedPerformer,
|
||||||
|
(BlacklistedPerformer.performer_id == ScenePerformer.performer_id)
|
||||||
|
& (BlacklistedPerformer.device_id == device_id),
|
||||||
|
)
|
||||||
|
.where(ScenePerformer.scene_id == Scene.id)
|
||||||
)
|
)
|
||||||
.where(ScenePerformer.scene_id == Scene.id)
|
)
|
||||||
),
|
if "studio" in kinds:
|
||||||
~Scene.studio_id.in_(
|
out.append(
|
||||||
select(BlacklistedStudio.studio_id).where(BlacklistedStudio.device_id == device_id)
|
~Scene.studio_id.in_(
|
||||||
),
|
select(BlacklistedStudio.studio_id).where(BlacklistedStudio.device_id == device_id)
|
||||||
~exists(
|
|
||||||
select(1)
|
|
||||||
.select_from(SceneTag)
|
|
||||||
.join(
|
|
||||||
BlacklistedTag,
|
|
||||||
(BlacklistedTag.tag_id == SceneTag.tag_id)
|
|
||||||
& (BlacklistedTag.device_id == device_id),
|
|
||||||
)
|
)
|
||||||
.where(SceneTag.scene_id == Scene.id)
|
)
|
||||||
),
|
if "tag" in kinds:
|
||||||
]
|
out.append(
|
||||||
|
~exists(
|
||||||
|
select(1)
|
||||||
|
.select_from(SceneTag)
|
||||||
|
.join(
|
||||||
|
BlacklistedTag,
|
||||||
|
(BlacklistedTag.tag_id == SceneTag.tag_id)
|
||||||
|
& (BlacklistedTag.device_id == device_id),
|
||||||
|
)
|
||||||
|
.where(SceneTag.scene_id == Scene.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if "site" in kinds:
|
||||||
|
# Ukryta strona chowa scene tylko wtedy, gdy wszystkie jej zywe zrodla sa z ukrytych
|
||||||
|
# stron: ta sama scena z innego serwisu dalej jest do obejrzenia. Sceny bez zywego
|
||||||
|
# zrodla przepuszczamy, o nich decyduje filtr has_playback.
|
||||||
|
hidden = hidden_origins_subquery(device_id)
|
||||||
|
out.append(
|
||||||
|
~(
|
||||||
|
exists(
|
||||||
|
select(1).where(
|
||||||
|
PlaybackSource.scene_id == Scene.id,
|
||||||
|
PlaybackSource.dead_at.is_(None),
|
||||||
|
PlaybackSource.origin.in_(hidden),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
& ~exists(
|
||||||
|
select(1).where(
|
||||||
|
PlaybackSource.scene_id == Scene.id,
|
||||||
|
PlaybackSource.dead_at.is_(None),
|
||||||
|
PlaybackSource.origin.not_in(hidden),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _studio_sorted_select(base, inner_limit: int, studio_ids=None):
|
def _studio_sorted_select(base, inner_limit: int, studio_ids=None):
|
||||||
|
|
@ -1147,7 +1203,15 @@ def get_scene(
|
||||||
scene = session.get(Scene, scene_id)
|
scene = session.get(Scene, scene_id)
|
||||||
if scene is None:
|
if scene is None:
|
||||||
raise HTTPException(status_code=404, detail="scene not found")
|
raise HTTPException(status_code=404, detail="scene not found")
|
||||||
return _build_scene_out(session, scene, device_id=device_id)
|
out = _build_scene_out(session, scene, device_id=device_id)
|
||||||
|
# Zrodla z ukrytych stron chowamy, o ile zostaje jakies inne. Scena tylko z ukrytych
|
||||||
|
# stron (np. z historii albo ulubionych) zostaje grywalna, zamiast pustej sekcji Watch.
|
||||||
|
if "site" in _blacklist_kinds(session, device_id):
|
||||||
|
hidden = set(session.execute(hidden_origins_subquery(device_id)).scalars())
|
||||||
|
visible = [p for p in out.playback_sources if p.origin not in hidden]
|
||||||
|
if visible and len(visible) < len(out.playback_sources):
|
||||||
|
out.playback_sources = visible
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{scene_id}/similar", response_model=SceneListOut)
|
@router.get("/{scene_id}/similar", response_model=SceneListOut)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from app.models.favorite_movie import FavoriteMovie
|
||||||
from app.models.favorite_performer import FavoritePerformer
|
from app.models.favorite_performer import FavoritePerformer
|
||||||
from app.models.favorite_scene import FavoriteScene
|
from app.models.favorite_scene import FavoriteScene
|
||||||
from app.models.favorite_site import FavoriteSite
|
from app.models.favorite_site import FavoriteSite
|
||||||
|
from app.models.hidden_site import HiddenSite
|
||||||
from app.models.favorite_studio import FavoriteStudio
|
from app.models.favorite_studio import FavoriteStudio
|
||||||
from app.models.ingest_run import IngestRun
|
from app.models.ingest_run import IngestRun
|
||||||
from app.models.merge_candidate import MergeCandidate
|
from app.models.merge_candidate import MergeCandidate
|
||||||
|
|
@ -58,6 +59,7 @@ __all__ = [
|
||||||
"FavoritePerformer",
|
"FavoritePerformer",
|
||||||
"FavoriteScene",
|
"FavoriteScene",
|
||||||
"FavoriteSite",
|
"FavoriteSite",
|
||||||
|
"HiddenSite",
|
||||||
"FavoriteStudio",
|
"FavoriteStudio",
|
||||||
"IngestRun",
|
"IngestRun",
|
||||||
"MergeCandidate",
|
"MergeCandidate",
|
||||||
|
|
|
||||||
25
app/models/hidden_site.py
Normal file
25
app/models/hidden_site.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""Ukryte strony (origins) per urzadzenie. Prosba ze zgloszenia 3910b19b (2026-10-05):
|
||||||
|
"nie ma przycisku ukrywania stron". Lustro FavoriteSite.
|
||||||
|
|
||||||
|
Scena z listy znika tylko wtedy, gdy WSZYSTKIE jej zywe zrodla sa z ukrytych stron
|
||||||
|
(app/api/scenes.py blacklist_clauses); w szczegolach sceny przyciski ukrytych stron
|
||||||
|
znikaja, o ile zostaje inne zrodlo. Przechodzi kodem synchronizacji (app/api/me.py).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class HiddenSite(Base):
|
||||||
|
__tablename__ = "hidden_sites"
|
||||||
|
|
||||||
|
device_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
origin: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
Loading…
Add table
Reference in a new issue