first commit

This commit is contained in:
ndmg 2026-11-07 11:40:36 +00:00
commit ca2a68e37d
48 changed files with 2233 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
venv
__pycache__
.env
lib
bin
include
*.cfg
coockies.txt
media
audio
yt
channels.json

0
README.md Normal file
View File

0
core/__init__.py Normal file
View File

40
core/authx.py Normal file
View File

@ -0,0 +1,40 @@
# core/authx.py
from authx import AuthX, AuthXConfig
from .config import settings
config = AuthXConfig(
JWT_SECRET_KEY=settings.SECRET_KEY,
JWT_ALGORITHM=settings.ALGORITHM,
# откуда берём токен
JWT_TOKEN_LOCATION=["cookies"],
# access
JWT_ACCESS_TOKEN_EXPIRES=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
JWT_ACCESS_COOKIE_NAME="access_token",
JWT_ACCESS_COOKIE_PATH="/",
JWT_ACCESS_CSRF_HEADER_NAME="X-CSRF-Token",
JWT_ACCESS_CSRF_COOKIE_NAME="csrf_access_token",
JWT_ACCESS_CSRF_COOKIE_PATH="/",
# refresh
JWT_REFRESH_TOKEN_EXPIRES=settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60,
JWT_REFRESH_COOKIE_NAME="refresh_token",
JWT_REFRESH_COOKIE_PATH="/auth/refresh",
JWT_REFRESH_CSRF_HEADER_NAME="X-CSRF-Token",
JWT_REFRESH_CSRF_COOKIE_NAME="csrf_refresh_token",
JWT_REFRESH_CSRF_COOKIE_PATH="/auth/refresh",
# cookies
JWT_COOKIE_SECURE=settings.COOKIE_SECURE,
JWT_COOKIE_SAMESITE="lax",
JWT_COOKIE_HTTP_ONLY=True,
# CSRF
JWT_COOKIE_CSRF_PROTECT=True,
JWT_CSRF_IN_COOKIES=True,
JWT_CSRF_METHODS=["POST", "PUT", "PATCH", "DELETE"],
)
auth = AuthX(config=config)

42
core/config.py Normal file
View File

@ -0,0 +1,42 @@
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent # RSS/
# ---- видео (YouTube) ----
YT_DIR_NAME = "yt"
YT_DIR = BASE_DIR / "media" / YT_DIR_NAME # RSS/media/
THUMBS = "_thumbs"
THUMBS_DIR = YT_DIR / THUMBS # RSS/media/yt/_thumbs
# ---- фото (если /photos отдаёт RSS/media/media) ----
PHOTOS_DIR = BASE_DIR / "media" / "media" # RSS/media/media
PHOTOS_DIR_RESPNS = BASE_DIR / "media"
# ---- шаблоны, статика ----
TEMPLATES_DIR = BASE_DIR / "templates"
STATIC_DIR = BASE_DIR / "static"
# ---- расширения видео ----
VIDEO_EXTENSIONS = {".mp4", ".mkv", ".webm", ".avi", ".mov", ".flv", ".m4v"}
AUDIO_EXTENSIONS = {".mp3"}
AUDIO_DIR = BASE_DIR / "media" / "audio"
COVERS = "_covers"
COVERS_DIR = AUDIO_DIR / COVERS
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
SECRET_KEY: str = "CHANGE_ME_TO_RANDOM_STRING"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
COOKIE_SECURE: bool = False # True на продакшене (HTTPS)
COOKIE_SAMESITE: str = "lax"
COOKIE_DOMAIN: str | None = None
class Config:
env_file = ".env"
settings = Settings()

26
core/db.py Normal file
View File

@ -0,0 +1,26 @@
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
DB_URL = "postgresql+psycopg://ndmg:marselina5$@127.0.0.1:5432/rssdb"
engine = create_async_engine(DB_URL, echo=True)
SessionLocal = async_sessionmaker(
bind=engine,
expire_on_commit=False,
class_=AsyncSession,
)
async def get_db():
async with SessionLocal() as session:
yield session

11
core/security.py Normal file
View File

@ -0,0 +1,11 @@
from pwdlib import PasswordHash
password_hash = PasswordHash.recommended()
def hash_password(password: str) -> str:
return password_hash.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return password_hash.verify(plain, hashed)

25
docker-compose.yml Normal file
View File

@ -0,0 +1,25 @@
services:
db:
image: postgres:16
restart: unless-stopped
environment:
- POSTGRES_DB=rssdb
- POSTGRES_USER=ndmg
- POSTGRES_PASSWORD=marselina5$
ports:
- "127.0.0.1:5432:5432"
volumes:
- data:/var/lib/postgresql/data
meilisearch:
image: getmeili/meilisearch:v1.10
ports: ["7700:7700"]
environment:
MEILI_MASTER_KEY: "super-secret-master-key-change-me"
MEILI_ENV: "development" # production требует master key
MEILI_NO_ANALYTICS: "true"
volumes: ["meili_data:/meili_data"]
volumes:
data:
meili_data:

62
main.py Normal file
View File

@ -0,0 +1,62 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from .core.config import (
AUDIO_DIR,
COVERS_DIR,
PHOTOS_DIR_RESPNS,
STATIC_DIR,
THUMBS_DIR,
YT_DIR,
)
from .core.db import Base, engine
from .routers import audio, auth, channel, photo, playlist, search, video
from .search.setup import setup_videos_index
@asynccontextmanager
async def lifespan(app: FastAPI):
setup_videos_index()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
await engine.dispose()
app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://127.0.0.1:5173",
"http://localhost:5173",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
app.mount("/yt", StaticFiles(directory=YT_DIR), name="yt")
app.mount("/media", StaticFiles(directory=PHOTOS_DIR_RESPNS), name="media")
app.mount("/_thumbs", StaticFiles(directory=THUMBS_DIR), name="_thumbs")
app.mount("/_covers", StaticFiles(directory=COVERS_DIR), name="_covers")
app.mount("/audio", StaticFiles(directory=AUDIO_DIR), name="audio")
@app.get("/favicon.ico", include_in_schema=False)
async def favicon():
return FileResponse(STATIC_DIR / "favicon.ico")
app.include_router(photo.router)
app.include_router(video.router)
app.include_router(playlist.router)
app.include_router(channel.router)
app.include_router(audio.router)
app.include_router(auth.router)
app.include_router(search.router)

24
models/audio_model.py Normal file
View File

@ -0,0 +1,24 @@
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..core.db import Base
class AudioPlaylist(Base):
__tablename__ = "audioplaylists"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(64))
audios: Mapped[list["Audio"]] = relationship(back_populates="playlist")
class Audio(Base):
__tablename__ = "audios"
id: Mapped[int] = mapped_column(primary_key=True)
filename: Mapped[str] = mapped_column(String(255), unique=True)
path: Mapped[str] = mapped_column(String(512))
preview_path: Mapped[str] = mapped_column(String(256))
name: Mapped[str] = mapped_column(String(255))
playlist_id: Mapped[int | None] = mapped_column(ForeignKey("audioplaylists.id"))
playlist: Mapped[AudioPlaylist | None] = relationship(back_populates="audios")

23
models/photo_model.py Normal file
View File

@ -0,0 +1,23 @@
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..core.db import Base
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(64))
photos: Mapped[list[Photo]] = relationship(back_populates="category") # noqa: F821
class Photo(Base):
__tablename__ = "photos"
id: Mapped[int] = mapped_column(primary_key=True)
filename: Mapped[str] = mapped_column(String(255), unique=True)
path: Mapped[str] = mapped_column(String(512))
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"))
category: Mapped[Category] = relationship(back_populates="photos")

13
models/users.py Normal file
View File

@ -0,0 +1,13 @@
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from ..core.db import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(128), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
isAdmin: Mapped[bool] = mapped_column()

46
models/video_model.py Normal file
View File

@ -0,0 +1,46 @@
from datetime import date
from sqlalchemy import Date, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..core.db import Base
class Channel(Base):
__tablename__ = "channels"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128))
playlists: Mapped[list["Playlist"]] = relationship(back_populates="channel")
videos: Mapped[list["Video"]] = relationship(back_populates="channel")
class Playlist(Base):
__tablename__ = "playlists"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128))
channel_id: Mapped[int] = mapped_column(ForeignKey("channels.id"))
channel: Mapped[Channel] = relationship(back_populates="playlists")
videos: Mapped[list["Video"]] = relationship(back_populates="playlist")
class Video(Base):
__tablename__ = "videos"
__table_args__ = (
UniqueConstraint("channel_id", "filename", name="uq_video_channel_filename"),
)
id: Mapped[int] = mapped_column(primary_key=True)
filename: Mapped[str] = mapped_column(String(255))
path: Mapped[str] = mapped_column(String(1024))
preview_path: Mapped[str] = mapped_column(String(1024))
playlist_id: Mapped[int | None] = mapped_column(ForeignKey("playlists.id"))
playlist: Mapped[Playlist | None] = relationship(back_populates="videos")
channel_id: Mapped[int] = mapped_column(ForeignKey("channels.id"))
channel: Mapped[Channel] = relationship(back_populates="videos")
title: Mapped[str | None] = mapped_column(String(256), nullable=True)
date: Mapped[date | None] = mapped_column(Date, nullable=True)

72
requriments.txt Normal file
View File

@ -0,0 +1,72 @@
alembic==1.20.1
annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.15.1
argon2-cffi==25.1.0
argon2-cffi-bindings==26.1.0
asyncio==4.0.0
authx==1.7.1
authx_extra==1.2.0
bcrypt==5.0.0
camel-converter==6.0.0
certifi==2026.10.23
cffi==2.1.1
charset-normalizer==3.5.2
click==8.5.0
cryptography==50.0.2
dnspython==2.9.2
ecdsa==0.19.2
email-validator==2.3.0
fastapi==0.162.0
fastapi-users==15.0.5
fastapi-users-db-sqlalchemy==7.0.0
greenlet==3.5.6
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
httpx-oauth==0.17.0
idna==3.20
itsdangerous==2.2.0
Jinja2==3.1.6
makefun==1.16.0
Mako==1.4.3
MarkupSafe==3.0.4
meilisearch==0.43.0
numpy==2.5.4
opentelemetry-api==1.45.2
pandas==3.1.0
passlib==1.7.4
prometheus_client==0.26.0
psycopg==3.3.6
psycopg-binary==3.3.6
psycopg-pool==3.3.3
pwdlib==0.3.0
pyaes==1.6.1
pyasn1==0.6.5
pycopg==1.2.0
pycparser==3.12
pydantic==2.14.2
pydantic-settings==2.16.2
pydantic_core==2.50.2
pyinstrument==5.0.3
PyJWT==2.15.1
python-dateutil==2.9.0.post0
python-dotenv==1.2.4
python-jose==3.5.0
python-multipart==0.0.33
pytz==2026.6
qrcode==8.2
redis==5.2.1
requests==2.34.2
rsa==4.9.1
six==1.17.0
SQLAlchemy==2.0.57
starlette==1.10.0
Telethon==1.45.0
tenacity==9.2.1
typing-inspection==0.4.4
typing_extensions==4.16.0
tzdata==2026.6
urllib3==2.8.0
uvicorn==0.54.0
yt-dlp==2026.10.26

62
routers/audio.py Normal file
View File

@ -0,0 +1,62 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from fastapi.exceptions import HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.authx import auth
from ..core.db import get_db
from ..models.audio_model import Audio, AudioPlaylist
from ..schemas.audio_schema import Audios, AudioSchema, Playlists
router = APIRouter()
@router.get("/audio", response_model=Audios, dependencies=[Depends(auth.access_token_required)])
async def audio(session: AsyncSession = Depends(get_db), id: int = Query(...), page: int = Query(1, ge=1), # noqa: B008
per_page: int = Query(24, ge=1)):
offset = (page - 1) * per_page
query = select(Audio).where(Audio.playlist_id == id).order_by(func.random()).offset(offset).limit(per_page)
result = await session.execute(query)
audio = result.scalars().all()
return {"audios": audio}
@router.get("/audio_file", response_model=AudioSchema)
async def audio_file(session: AsyncSession = Depends(get_db), id: int = Query(...)): # noqa: B008
query = select(Audio).where(Audio.id==id)
result = await session.execute(query)
audio = result.scalar()
if audio is None:
raise HTTPException(status_code=404, detail="Audio not found")
return { "id": audio.id, "path": audio.path, "name": audio.name, "filename": audio.filename, "preview_path": audio.preview_path }
@router.get('/audioplaylists', response_model=Playlists, dependencies=[Depends(auth.access_token_required)])
async def audioplaylists(session: AsyncSession = Depends(get_db)): # noqa: B008
stmt = (
select(
AudioPlaylist.id,
AudioPlaylist.title,
func.count(Audio.id).label("audios_count")
)
.outerjoin(Audio, Audio.playlist_id == AudioPlaylist.id)
.group_by(AudioPlaylist.id, AudioPlaylist.title)
.order_by(AudioPlaylist.title)
)
rows = (await session.execute(stmt)).all()
return {
"playlists": [
{
"id": r.id,
"title": r.title,
"audios_count": r.audios_count
}
for r in rows
]
}

82
routers/auth.py Normal file
View File

@ -0,0 +1,82 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.authx import auth
from ..core.db import get_db
from ..core.security import hash_password, verify_password
from ..models.users import User
from ..schemas.user_schema import UserCreate, UserLogin, UserRead
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserRead, status_code=201)
async def register(
data: UserCreate,
response: Response,
db: AsyncSession = Depends(get_db), # noqa: B008
):
existing = await db.execute(select(User).where(User.username == data.username))
if existing.scalar_one_or_none():
raise HTTPException(400, "Username already taken")
user = User(
username=data.username,
hashed_password=hash_password(data.password),
isAdmin=False,
)
db.add(user)
await db.commit()
await db.refresh(user)
access = auth.create_access_token(uid=user.username)
refresh = auth.create_refresh_token(uid=user.username)
auth.set_access_cookies(access, response)
auth.set_refresh_cookies(refresh, response)
return user
@router.post("/login", response_model=UserRead)
async def login(
data: UserLogin,
response: Response,
db: AsyncSession = Depends(get_db), # noqa: B008
):
result = await db.execute(select(User).where(User.username == data.username))
user = result.scalar_one_or_none()
if not user or not verify_password(data.password, user.hashed_password):
raise HTTPException(401, "Invalid credentials")
access = auth.create_access_token(uid=user.username)
refresh = auth.create_refresh_token(uid=user.username)
auth.set_access_cookies(access, response)
auth.set_refresh_cookies(refresh, response)
return user
@router.post("/refresh")
async def refresh(
request: Request,
response: Response,
db: AsyncSession = Depends(get_db), # noqa: B008
):
payload = await auth.get_refresh_token_from_request(request)
result = await db.execute(select(User).where(User.username == payload.sub))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(401, "User not found")
new_access = auth.create_access_token(uid=user.username)
auth.set_access_cookies(new_access, response)
return {"status": "ok"}
@router.post("/logout")
async def logout(response: Response):
auth.unset_cookies(response)
return {"status": "ok"}

85
routers/channel.py Normal file
View File

@ -0,0 +1,85 @@
from fastapi import APIRouter, Depends, Query
from fastapi.templating import Jinja2Templates
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.authx import auth
from ..core.config import TEMPLATES_DIR
from ..core.db import get_db
from ..models.video_model import Channel, Playlist, Video
from ..schemas.channel_schemas import ChannelOut, ChannelsOut
router = APIRouter()
templates = Jinja2Templates(directory=TEMPLATES_DIR)
@router.get("/channels", response_model=ChannelsOut, dependencies=[Depends(auth.access_token_required)])
async def channels(session: AsyncSession = Depends(get_db)): # noqa: B008
stmt = (
select(Channel.id, Channel.name, func.count(Video.id).label("video_count"))
.outerjoin(Channel.videos)
.group_by(Channel.id, Channel.name)
.order_by(Channel.name)
)
rows = (await session.execute(stmt)).all()
return {
"channels": [
{"id": r.id, "name": r.name, "videos_count": r.video_count} for r in rows
]
}
@router.get("/channel", response_model=ChannelOut, dependencies=[Depends(auth.access_token_required)])
async def channel(
session: AsyncSession = Depends(get_db), # noqa: B008
id: int = Query(...),
page: int = Query(1, ge=1),
per_page: int = Query(24, ge=1),
):
offset = (page - 1) * per_page
query = (
select(Video)
.join(Channel, Channel.id == Video.channel_id)
.where(Channel.id == id, Video.playlist == None)
.order_by(Video.id)
.offset(offset)
.limit(per_page)
)
videos = (await session.execute(query)).scalars().all()
videos_count = len(videos)
channel_name = (
await session.execute(select(Channel.name).where(Channel.id == id))
).scalar()
p_query = (
select(Playlist, func.min(Video.preview_path).label("preview_path"))
.outerjoin(Video, Video.playlist_id == Playlist.id)
.where(Playlist.channel_id == id)
.group_by(Playlist.id)
.order_by(Playlist.id)
)
playlists_rows = (await session.execute(p_query)).all()
return {
"videos": [
{
"id": v.id,
"title": getattr(v, "title", None),
"path": v.path,
"preview_path": v.preview_path,
"date": v.date,
}
for v in videos
],
"channel_name": channel_name,
"playlists": [
{"id": p.id, "name": p.name, "preview_path": prev}
for p, prev in playlists_rows
],
"videos_count": videos_count,
"playlists_count": len(playlists_rows),
}

76
routers/photo.py Normal file
View File

@ -0,0 +1,76 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.authx import auth
from ..core.db import get_db
from ..models.photo_model import Category, Photo
from ..schemas.photo_schemas import CategoriesOut, PhotosOut, PhotosPaginatedOut
router = APIRouter()
@router.get("/photos", response_model=PhotosPaginatedOut, dependencies=[Depends(auth.access_token_required)])
async def get_photos(
page: int = Query(1, ge=1),
per_page: int = Query(24, ge=1),
session: AsyncSession = Depends(get_db), # noqa: B008
category: int | None = None,
):
offset = (page - 1) * per_page
query = select(Photo)
if category is not None:
query = query.where(Photo.category_id == category)
query = query.order_by(Photo.id).offset(offset).limit(per_page)
result = await session.execute(query)
photos = result.scalars().all()
return {
"photos": photos,
"page_inc": f"/photos?page={page + 1}&per_page={per_page}&category={category}",
"page_dec": f"/photos?page={max(1, page - 1)}&per_page={per_page}&category={category}",
}
@router.get("/categories", response_model=CategoriesOut, dependencies=[Depends(auth.access_token_required)])
async def categories(session: AsyncSession = Depends(get_db)): # noqa: B008
stmt = (
select(Category.id, Category.title, func.count(Photo.id).label("photos_count"))
.select_from(Category)
.outerjoin(Category.photos)
.group_by(Category.id, Category.title)
.order_by(Category.title)
)
rows = (await session.execute(stmt)).all()
return {
"categories": [
{"id": r.id, "title": r.title, "photos_count": r.photos_count} for r in rows
]
}
@router.get("/category", response_model=PhotosOut)
async def category(
page: int = Query(1, ge=1),
per_page: int = Query(24, ge=1),
session: AsyncSession = Depends(get_db), # noqa: B008
id: int = Query(...),
):
offset = (page - 1) * per_page
query = (
select(Photo)
.where(Photo.category_id == id)
.order_by(Photo.id)
.offset(offset)
.limit(per_page)
)
result = await session.execute(query)
photos = result.scalars().all()
return {"photos": photos}

36
routers/playlist.py Normal file
View File

@ -0,0 +1,36 @@
from fastapi import APIRouter, Depends, Query
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.authx import auth
from ..core.config import TEMPLATES_DIR
from ..core.db import get_db
from ..models.video_model import Video
from ..schemas.playlist_schema import PlaylistOut
router = APIRouter()
templates = Jinja2Templates(directory=TEMPLATES_DIR)
@router.get("/playlist", response_model=PlaylistOut, dependencies=[Depends(auth.access_token_required)])
async def playlist(session: AsyncSession = Depends(get_db), id: int = Query(...)): # noqa: B008
query = select(Video).where(Video.playlist_id == id)
result = await session.execute(query)
videos = result.scalars().all()
return {
"videos": [
{
"id": v.id,
"title": getattr(v, "title", None),
"path": v.path,
"preview_path": v.preview_path,
"date": v.date,
"channel_id": v.channel_id,
}
for v in videos
],
"video_count": len(videos),
}

71
routers/search.py Normal file
View File

@ -0,0 +1,71 @@
# routers/search.py
from fastapi import APIRouter, Query
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from ..search.search_client import videos_index
router = APIRouter(prefix="/search", tags=["search"])
class VideoHit(BaseModel):
id: int
title: str
filename: str
date: str | None
channel_id: int
channel_name: str
playlist_id: int | None
playlist_name: str | None
preview_path: str
_formatted: dict | None = None
class SearchResponse(BaseModel):
query: str
total: int
took_ms: int
items: list[VideoHit]
@router.get("/videos", response_model=SearchResponse)
async def search_videos(
q: str = Query("", max_length=200, description="Поисковый запрос; пусто = показать всё"),
channel_id: int | None = None,
playlist_id: int | None = None,
date_from: str | None = Query(None, pattern=r"^\d{4}-\d{2}-\d{2}$"),
date_to: str | None = Query(None, pattern=r"^\d{4}-\d{2}-\d{2}$"),
sort: str | None = Query(None, description="date:desc | date:asc | id:desc"),
limit: int = Query(20, le=100),
offset: int = Query(0, ge=0),
):
filters = []
if channel_id is not None:
filters.append(f"channel_id = {channel_id}")
if playlist_id is not None:
filters.append(f"playlist_id = {playlist_id}")
if date_from:
filters.append(f'date >= "{date_from}"')
if date_to:
filters.append(f'date <= "{date_to}"')
params = {
"limit": limit,
"offset": offset,
"attributesToHighlight": ["title", "filename"],
"highlightPreTag": "<mark>",
"highlightPostTag": "</mark>",
}
if filters:
params["filter"] = " AND ".join(filters)
if sort:
params["sort"] = [sort]
result = await run_in_threadpool(videos_index().search, q, params)
return {
"query": q,
"total": result["estimatedTotalHits"],
"took_ms": result["processingTimeMs"],
"items": result["hits"],
}

57
routers/video.py Normal file
View File

@ -0,0 +1,57 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from fastapi.exceptions import HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.authx import auth
from ..core.db import get_db
from ..models.video_model import Channel, Video
from ..schemas.video_schemas import VideoDetailOut, VideoOut, VideosResponse
router = APIRouter()
@router.get("/videos", response_model=VideosResponse, dependencies=[Depends(auth.access_token_required)])
async def videos(
page: int = Query(1, ge=1),
per_page: int = Query(24, ge=1),
session: AsyncSession = Depends(get_db), # noqa: B008
):
offset = (page - 1) * per_page
query = (
select(Video, Channel.name.label("channel_name"))
.join(Channel, Channel.id == Video.channel_id)
.order_by(func.random())
.offset(offset)
.limit(per_page)
)
result = await session.execute(query)
rows = result.all()
videos = []
for v, channel_name in rows:
v_out = VideoOut.model_validate(v)
v_out.channel_name = channel_name
videos.append(v_out)
return {
"videos": videos,
"page_inc": f"/videos?page={page + 1}&per_page={per_page}",
"page_dec": f"/videos?page={max(1, page - 1)}&per_page={per_page}",
}
@router.get("/video", response_model=VideoDetailOut, dependencies=[Depends(auth.access_token_required)])
async def video(id: int = Query(...), session: AsyncSession = Depends(get_db)): # noqa: B008
query = select(Video).where(Video.id == id)
result = await session.execute(query)
video = result.scalar()
if video is None:
raise HTTPException(status_code=404, detail="Video not found")
return video

48
schemas/audio_schema.py Normal file
View File

@ -0,0 +1,48 @@
from urllib.parse import quote
from pydantic import BaseModel, ConfigDict, field_validator
def encode_path(path: str) -> str:
if not path:
return ""
return "/".join(quote(part, safe="") for part in path.split("/"))
class AudioSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
path: str
name: str
filename: str
preview_path: str
playlist_id: int | None = None
@field_validator("path", "preview_path", mode="before")
@classmethod
def encode_urls(cls, v):
return encode_path(v)
@field_validator("path", mode="after")
@classmethod
def add_audio_to_url(cls, v):
if not v: return v
clean_v = v.lstrip('/')
if clean_v.startswith('audio/'):
return f"{clean_v}"
return f"audio/{clean_v}"
class Audios(BaseModel):
audios: list[AudioSchema]
class Playlist(BaseModel):
id: int
title: str
audios_count: int
class Playlists(BaseModel):
playlists: list[Playlist]

View File

@ -0,0 +1,48 @@
from datetime import date as DateType
from urllib.parse import quote
from pydantic import BaseModel, field_validator
def encode_path(path: str) -> str:
if not path:
return ""
return "/".join(quote(part, safe="") for part in path.split("/"))
class ChannelItem(BaseModel):
id: int
name: str
videos_count: int
class ChannelsOut(BaseModel):
channels: list[ChannelItem]
class VideoItem(BaseModel):
id: int
title: str
path: str
preview_path: str
date: DateType | None = None
@field_validator("path", "preview_path", mode="before")
@classmethod
def encode_urls(cls, v):
return encode_path(v)
class PlaylistItem(BaseModel):
id: int
name: str
preview_path: str
class ChannelOut(BaseModel):
videos: list[VideoItem]
playlists: list[PlaylistItem]
channel_name: str
videos_count: int | None = None
playlists_count: int | None = None

58
schemas/photo_schemas.py Normal file
View File

@ -0,0 +1,58 @@
from __future__ import annotations
from urllib.parse import quote
from pydantic import BaseModel, ConfigDict, field_validator
def encode_path(path: str) -> str:
if not path:
return ""
return "/".join(quote(part, safe="") for part in path.split("/"))
class PhotoItem(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
path: str
filename: str
category_id: int
@field_validator("path", mode="before")
@classmethod
def encode_urls(cls, v):
return encode_path(v)
@field_validator("path", mode="after")
@classmethod
def add_media(cls, v: str) -> str:
if not v: return v
clean_v = v.lstrip('/')
if clean_v.startswith('media/'): return f"{clean_v}"
return f"media/{clean_v}"
class Category(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
title: str
photos: list[PhotoItem] = []
class PhotosOut(BaseModel):
photos: list[PhotoItem]
class CategoryItem(BaseModel):
id: int
title: str
photos_count: int
class CategoriesOut(BaseModel):
categories: list[CategoryItem]
class PhotosPaginatedOut(BaseModel):
photos: list[PhotoItem]
page_inc: str
page_dec: str

View File

@ -0,0 +1,37 @@
from datetime import date as DateType
from urllib.parse import quote
from pydantic import BaseModel, field_validator
def encode_path(path: str) -> str:
if not path:
return ""
return "/".join(quote(part, safe="") for part in path.split("/"))
class VideoItem(BaseModel):
id: int
title: str
path: str
preview_path: str | None = None
date: DateType | None = None
channel_id: int
@field_validator("path", "preview_path", mode="before")
@classmethod
def encode_urls(cls, v):
return encode_path(v)
@field_validator("path", mode="after")
@classmethod
def add_yt(cls, v: str) -> str:
if not v: return v
clean_v = v.lstrip('/')
if clean_v.startswith('yt/'): return f"/{clean_v}"
return f"yt/{clean_v}"
class PlaylistOut(BaseModel):
videos: list[VideoItem]
video_count: int

27
schemas/user_schema.py Normal file
View File

@ -0,0 +1,27 @@
from pydantic import BaseModel, Field, model_validator
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=128)
password: str = Field(min_length=6, max_length=128)
password_confirm: str
@model_validator(mode="after")
def passwords_match(self):
if self.password != self.password_confirm:
raise ValueError("Passwords do not match")
return self
class UserRead(BaseModel):
id: int
username: str
isAdmin: bool
class Config:
from_attributes = True
class UserLogin(BaseModel):
username: str
password: str

63
schemas/video_schemas.py Normal file
View File

@ -0,0 +1,63 @@
from datetime import date as DateType
from urllib.parse import quote
from pydantic import BaseModel, ConfigDict, field_validator
def encode_path(path: str) -> str:
if not path:
return ""
return "/".join(quote(part, safe="") for part in path.split("/"))
class VideoOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
path: str
preview_path: str
channel_id: int
title: str | None = None
date: DateType | None = None
channel_name: str | None = None
@field_validator("path", "preview_path", mode="before")
@classmethod
def encode_urls(cls, v):
return encode_path(v)
@field_validator("path", mode="after")
@classmethod
def add_yt(cls, v: str) -> str:
if not v: return v
clean_v = v.lstrip('/')
if clean_v.startswith('yt/'): return f"/{clean_v}"
return f"yt/{clean_v}"
class VideoDetailOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
path: str
preview_path: str
title: str | None = None
date: DateType | None = None
channel_id: int
channel_name: str | None = None
@field_validator("path", "preview_path", mode="before")
@classmethod
def encode_urls(cls, v):
return encode_path(v)
@field_validator("path", mode="after")
@classmethod
def add_yt(cls, v: str) -> str:
if not v: return v
clean_v = v.lstrip('/')
if clean_v.startswith('yt/'): return f"/{clean_v}"
return f"yt/{clean_v}"
class VideosResponse(BaseModel):
videos: list[VideoOut]
page_inc: str
page_dec: str

19
search/documents.py Normal file
View File

@ -0,0 +1,19 @@
from ..models.video_model import Video
def video_to_document(video: Video) -> dict:
"""
Требует, чтобы video.channel и video.playlist были загружены
(selectinload), иначе будет ленивая загрузка в синхронном контексте → ошибка.
"""
return {
"id": video.id,
"title": video.title or video.filename,
"filename": video.filename,
"date": video.date.isoformat() if video.date else None,
"channel_id": video.channel_id,
"channel_name": video.channel.name,
"playlist_id": video.playlist_id,
"playlist_name": video.playlist.name if video.playlist else None,
"preview_path": video.preview_path,
}

44
search/reindex.py Normal file
View File

@ -0,0 +1,44 @@
# search/reindex.py
import asyncio
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from ..core.db import SessionLocal
from ..models.video_model import Video
from .documents import video_to_document
from .search_client import client, videos_index
BATCH = 1000
async def full_reindex():
async with SessionLocal() as db:
stmt = (
select(Video)
.options(selectinload(Video.channel), selectinload(Video.playlist))
.order_by(Video.id)
)
result = await db.stream(stmt)
batch: list[dict] = []
total = 0
async for video in result.scalars():
batch.append(video_to_document(video))
if len(batch) >= BATCH:
await _push(batch)
total += len(batch)
print(f"Indexed {total}...")
batch = []
if batch:
await _push(batch)
total += len(batch)
print(f"Done. Indexed {total} videos.")
async def _push(docs: list[dict]):
task = videos_index().add_documents(docs)
# дожидаемся, чтобы видеть ошибки и гарантировать применение
client.wait_for_task(task.task_uid)
if __name__ == "__main__":
asyncio.run(full_reindex())

10
search/search_client.py Normal file
View File

@ -0,0 +1,10 @@
import meilisearch
MEILI_URL = "http://localhost:7700"
MEILI_KEY = "super-secret-master-key-change-me"
INDEX = "videos"
client = meilisearch.Client(MEILI_URL, MEILI_KEY)
def videos_index():
return client.index(INDEX)

40
search/setup.py Normal file
View File

@ -0,0 +1,40 @@
from .search_client import INDEX, client, videos_index
def setup_videos_index():
try:
client.create_index(INDEX, {"primaryKey": "id"})
except Exception:
print(Exception)
index = videos_index()
# Порядок = приоритет в ранжировании (attribute rule)
index.update_searchable_attributes([
"title", "filename", "channel_name", "playlist_name",
])
index.update_filterable_attributes([
"channel_id", "playlist_id", "date",
])
index.update_sortable_attributes(["date", "id"])
index.update_displayed_attributes([
"id", "title", "filename", "date",
"channel_id", "channel_name",
"playlist_id", "playlist_name",
"preview_path",
])
# Опционально: свежие выше при прочих равных
index.update_ranking_rules([
"words", "typo", "proximity", "attribute",
"date:desc", "exactness",
])
index.update_synonyms({
"python": ["питон", "пайтон"],
"js": ["javascript", "джаваскрипт"],
})

15
services/videos.py Normal file
View File

@ -0,0 +1,15 @@
# services/videos.py
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from models import Video
async def load_video_with_relations(db: AsyncSession, video_id: int) -> Video:
stmt = (
select(Video)
.options(selectinload(Video.channel), selectinload(Video.playlist))
.where(Video.id == video_id)
)
return (await db.execute(stmt)).scalar_one()

21
static/css/style.css Normal file
View File

@ -0,0 +1,21 @@
@font-face {
font-family: "Roboto Mono";
src: url("/static/fonts/RobotoMono-Bold.ttf");
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: "Roboto Mono";
}
.wrapper {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
width: 100%;
}
.photo > img {
width: 100%;
}

0
static/favicon.ico Normal file
View File

Binary file not shown.

View File

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<title>Document</title>
</head>
<body>
<div class="wrapper">
{% for playlist in playlists %}
<a href="/playlist?id={{playlist.id}}">
<div class="playlist">
<div class="preview">
</div>
<h3>{{playlist.name}}</h3>
</div>
</a>
{% endfor %}
<hr>
{% for video in videos %}
<a href="/video?id={{video.id}}">
<div class="video">
<div class="preview">
<img src="{{video.preview_path}}" alt="">
</div>
{% if video.title %}
<h3>{{video.title}}</h3>
<small>{{video.date}}</small>
{% else %}
<h3>{{video.filename}}</h3>
{% endif %}
</div>
</a>
{% endfor %}
</div>
</body>
</html>

21
templates/channels.html Normal file
View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div class="wrapper">
{% for channel_id, name, video_count in channels %}
<a href="/channel?id={{channel_id}}">
<div class="channel">
<h3>{{name}}</h3>
<p>videos: {{video_count}}</p>
</div>
</a>
{% endfor %}
</div>
</body>
</html>

23
templates/index.html Normal file
View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=, initial-scale=1.0">
<link rel="stylesheet" href="/static/css/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<title>Document</title>
</head>
<body>
<div class="wrapper">
{% for photo in photos %}
<div class="photo">
<img src="{{photo.path}}" alt="">
</div>
{% endfor %}
<a href="{{page_dec}}">page-1</a>
<a href="{{page_inc}}">page+1</a>
</div>
</body>
</html>

25
templates/playlist.html Normal file
View File

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<title>Document</title>
</head>
<body>
<div class="wrapper">
<h2>count: {{video_count}}</h2>
{% for video in videos %}
<a href="/video?id={{video.id}}">
<div class="video">
<div class="preview">
<img src="{{video.preview_path}}" alt="">
</div>
<h3>{{video.filename}}</h3>
</div>
</a>
{% endfor %}
</div>
</body>
</html>

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<title>Document</title>
</head>
<body>
<div class="video">
<video controls>
<source src="{{path}}" type="video/mp4">
</video>
</div>
</body>
</html>

29
templates/videos.html Normal file
View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<title>Document</title>
</head>
<body>
<div class="wrapper">
{% for video in videos %}
<a href="/video?id={{video.id}}">
<div class="video">
<div class="preview">
<img src="{{video.preview_path}}" alt="">
</div>
{% if video.title %}
<h3>{{video.title}}</h3>
<small>{{video.date}}</small>
{% else %}
<h3>{{video.filename}}</h3>
{% endif %}
</div>
</a>
{% endfor %}
</div>
</body>
</html>

197
utils/cookies.txt Normal file
View File

@ -0,0 +1,197 @@
# Netscape HTTP Cookie File
# This file is generated by yt-dlp. Do not edit.
.workspace.google.com TRUE /intl/ru/gmail/ FALSE 1827813945 __utma 61317162.519317584.1793244578.1793244578.1793253945.2
.workspace.google.com TRUE /intl/ru/gmail/ FALSE 1809021945 __utmz 61317162.1793253945.2.2.utmcsr=google|utmccn=(organic)|utmcmd=organic|utmctr=(not%20provided)
.workspace.google.com TRUE / FALSE 1801020578 _gcl_au 1.1.1680277224.1793244578
.workspace.google.com TRUE / FALSE 1827813946 _ga_FWCBRW1RY8 GS2.1.s1793253946$o1$g0$t1793253946$j60$l0$h0
ubuntu.com FALSE /download FALSE 1824779632 user_id 4b795a8d-755e-4068-937b-91e52d06631d
ubuntu.com FALSE / FALSE 1824779632 _cookies_accepted all
ubuntu.com FALSE / FALSE 1824779632 _cookies_set_offline true
mail.google.com FALSE /mail/u/0 TRUE 1794108662 COMPASS gmail_ps=CrMBAAlriVefAAQBnSxVi4ChQiuid557O8qOl2OjlHZND32vw5xl6FOmLYFh3-Nc4mIvlIkDNqfJW8unKn_l5i4dXIbZ0Sjf-JuHeX-EFxUGvNP4QCA9vXqHsSryRNld9A0NzC7p5czhGRKJH0z_TuhK_cAqccrXGfU4OFHmR0hDd2DANEePyy6j0iw87UmGPTguiMBqouR-2GasKvNL0fIszbfzw1mNd_PsEp9rWpbOVLPEpGwQ1J-Q1wYa8AEACWuJV8fIkwIJPJu_Qf4DiRkRdmk3XL1onAPGidqbB-Cjc_xG2eBSUlD2nGymklxHmMPI_K3HwFs7B1NYgwk4wfHXNtVsVODO9pqjHDaIiOqv4qnTmjOoZt2KVtSM8pOcXRCnoXmWoAE-IvWyAcOKBsyvJ5cqafo7f73ptwTzMici6MjB07k_erZw7QR0NKsZsc-BeI8CPxPvfCqnQDBRseIZh4yVi7ovIREbNat5gf9o6iMFILSfEPlf8liqnC355znBXMtj0tsJANVb-F0nxj8ejpeklmXrBCG737QFaSiqqNdi48b2pmFW6VvCuggwAQ:gmail=CsIBAAlriVfWF8Ql3QS28hYzG62d-5-dSYHoMXXooHu5M6bXIaz51xudKM1L4VdCYpe6AGKfm-K1zF1wN_-c3_xUcmqwKGTg-_20ZFzNEpDA3cXdpM_w8iDvmjUZaMYP_ksHf2Sgi4vbA63geo5x8flADcpwrqfuOZBx0GVV1FLGK_3dtazhLBU02DE_pYB8CQoQS9S4B8IRYwoEhzIP0_TEzExt_ccADTaHpnobeskMF9qhFdtHk_UAJQBQ2KuulTt_74EQ25mQ1wYa_wEACWuJV2xojKMCAmKg0ZnCsjHzxGgykaPQrX2muiG35keLpb4KaoWa2VTTjAWyKSLiqRFxjAQDf5Nhl-bt1NDJ8addAwnzVToge8OZMb5RJKvsN8Q009j2Y9du2eSDQyr--vLewPjEd0IKsEQ80YOnFYfenM9BTGlC-OBMMC5zZo7Df_5K-4wNTRpCA6h2ErdSz2KOJB--5kQxuq9A-eVVc8YLAoyqjhr2lD-wGbLKwc0OVX-8FPncIsOTwyv3iinqKehMb83KccgjMt2HsKIKrRjK8INh6a1rdlvpikVWM_-_fO0fBDrTVJ6SGcEaUGan5PD0N5Q1i_kxdNQheecwAQ
mail.google.com FALSE /sync/u/0 TRUE 1794108673 COMPASS bigtop-sync=CsMBAAlriVf6XqF2sN1CQ0tMcO8zsbykPUc7Zc7uhWSUCkF-c8Jf-ArcFqQQeQ-i0FyQABqhMK2TOoq2XNyptG68Z1LOM7IvDCSIxRqbkyHK8wIrRD3BxI0pCgBQOBiytnBCeGKdkoWGAKEsW9Un07Oe7WTVOYiVu1fn3O43Xt-gVfkIznYIfoAqcmICGGnrMtRZdhUoL9dQwSA73L_Q7KHlY5K4oe61utZmmumWDx7GipFltFIZ0tb0b40dSCDnlzrOzSowEO6jkNcGGoACAAlriVcdbjfP07TEoitiLIEukviJ2exbnUMCiiQ-9tCbILHx6chFqrtGYuTQH9hFf3qG5be5htarouEhOKtnM83QnatgUy1Nr8Zd7ZUbMfKtZ3eD_Y3-tpidkwSv_zIpXUkMjw_VRbneEa4DMe7Vayg2qCdSvtsoZtTH0OCwmJ5kHrevWG8QSVKlkB_o0V4T-vsg9Nu62cymamSBc4vkb6VUHCIoulgwBjZzeH5L4h4dBYj9ygYcWZPCYAdPQ-4TpEYTQciG3n6kQOf78JaC7nJyB98NLNFRf7t69Y-LutcyS4b5xQM3UPzvIuSYkjFEC6kTC_nw1wcuBd92w2yNzzAB
mail.google.com FALSE /mail TRUE 1794108655 COMPASS gmail_ps=CrMBAAlriVefAAQBnSxVi4ChQiuid557O8qOl2OjlHZND32vw5xl6FOmLYFh3-Nc4mIvlIkDNqfJW8unKn_l5i4dXIbZ0Sjf-JuHeX-EFxUGvNP4QCA9vXqHsSryRNld9A0NzC7p5czhGRKJH0z_TuhK_cAqccrXGfU4OFHmR0hDd2DANEePyy6j0iw87UmGPTguiMBqouR-2GasKvNL0fIszbfzw1mNd_PsEp9rWpbOVLPEpGwQ1J-Q1wYa8AEACWuJV8fIkwIJPJu_Qf4DiRkRdmk3XL1onAPGidqbB-Cjc_xG2eBSUlD2nGymklxHmMPI_K3HwFs7B1NYgwk4wfHXNtVsVODO9pqjHDaIiOqv4qnTmjOoZt2KVtSM8pOcXRCnoXmWoAE-IvWyAcOKBsyvJ5cqafo7f73ptwTzMici6MjB07k_erZw7QR0NKsZsc-BeI8CPxPvfCqnQDBRseIZh4yVi7ovIREbNat5gf9o6iMFILSfEPlf8liqnC355znBXMtj0tsJANVb-F0nxj8ejpeklmXrBCG737QFaSiqqNdi48b2pmFW6VvCuggwAQ
mail.google.com FALSE / TRUE 1827813947 OSID g.a000EAnGNAZ1KGrblnWHVtbcdHF7fjkAR6nlOVfarDWgUoi84oKXKx_OvzuUCbyI0leD9VjSrAACgYKAfUSARESFQHGX2Mi_bte6DVRKE12uvK5Nb1npBoVAUF8yKrfdCe0sNjfHnPl2R0uzVYn0076
mail.google.com FALSE / TRUE 1827813947 __Secure-OSID g.a000EAnGNAZ1KGrblnWHVtbcdHF7fjkAR6nlOVfarDWgUoi84oKX3SOB_p-BiouyNKbA60378QACgYKAY8SARESFQHGX2Mi95CvT4VPtQ7x-N_SkN9AKBoVAUF8yKoDrLLsK3e3-7veJoMz9atq0076
mail.google.com FALSE / TRUE 1794108677 COMPASS appsfrontendserver=CgAQlZiL1wYagQEACWuJV2-1E7klMhb9f9nSo9XnKMzdxTmjc8shRCls8HnSFyOvCGpGa-0omqJaap7UKUZcELW8Hp2epJFlYqxzq6ubATLxX_TyAV5tVXuijNKC6p-92rLQ7dQ0djUQJud3yluTC4TmY-sBQp5ZNRX9liEDVsF-_y064vL2GDdLmFUgATAB
osdev.fandom.com FALSE / FALSE 1793630559 fanid_expiry 1
osdev.fandom.com FALSE / FALSE 1796136161 pbjs_unifiedid %7B%22TDID_LOOKUP%22%3A%22FALSE%22%2C%22TDID_CREATED_AT%22%3A%222026-11-01T14%3A42%3A34%22%7D
osdev.fandom.com FALSE / FALSE 1796136161 pbjs_unifiedid_cst znv0HA%3D%3D
osdev.fandom.com FALSE / FALSE 1825080163 gamera_user_id 222a41fd-b352-4575-891e-f3add16cc8f6
osdev.fandom.com FALSE / FALSE 1825080168 idw-fe-id 7346aefb-3df6-4ce8-bd96-7f14040d8c1a
.google.com TRUE /verify TRUE 1809343679 SNID ANBxh3jhM442TJXYjKfLwc2hN7uW1f92kn92cOkR9uIka9saU1eAg-1ygmvKLGSBsOcRjPB6SJpDKJ_Ag7L-kmIFgVdEe3bi29I
.google.com TRUE / FALSE 1808795617 SEARCH_SAMESITE CgQIk6IB
.google.com TRUE / TRUE 1808795622 AEC Aaa9EJrU5a5u3fA64lEtFIkphdoxRgBMBl41q-XoqriAqPyvkC9AeLgG1g
.google.com TRUE / TRUE 1809343679 NID Cs4DCAESgAMBM9Agl-X6GrtNiGN1hdp9tfs8Clkitae8h3FudmDUNdb0uuy336BLs15jdJ6TRE9IjYsk-C0wCPxjMnG9m58MH4LMncu3adbw7bp0gc-VrXzsSfrzYMIRcqYuhtfIH3IoDdzopkwReoIal4Nn1GQ6aPTid2nOrLQMnlFLw7nmuFGCcYvc9_a2GBjf7G-JNAAGGLI7ujdK8WeSy-PfigbwBvArFFNR7XV2ylkb6TyLqrG6e1by94inuKBErvxHZsoVSdkrGhnLq2p2aDlQpMcCKGQprMHC18N6OVXUH1D53bQGjxcy8WkqvrKx3Rng43OnkG4X68xh4PsA51BB0u8Be7DGmF0RKuXu8RVWPCKacqssgZX--hzmp9sHNZRt-7IwEkB01Y1OuxX00OVmUe7yjk9EHpYLfn5Flu3z91k5NQGZL4TDSQ8rQwk62MVybS3PzsDZOLTOimelI02-IlUBGjyGRGMjcYi7cOEjFC6gMRJXv7ZUEeegVcIR6rE5RSAoATJFAXBKQs8xGi1nasgrK2UqceIa_2wRxvrvhZaS0N_Eaa2Ro-GsWqhdJeBDIKT2JPn8Tluv6zXrn1VQiA-uMivIjcCijMIB
.google.com TRUE / FALSE 1827804650 SID g.a000EAnGNB0WmAKACI1xxFZFRPMqOxCffokWZ8xjwgJriO7y39RHOJDEbzcDbB4CeWyeMWwkFwACgYKAQoSARESFQHGX2MiDUEU_2sjvGbRgjngmhQdkxoVAUF8yKrGF7SPJ7y3Kcak88Gh9Z_o0076
.google.com TRUE / TRUE 1827804650 __Secure-1PSID g.a000EAnGNB0WmAKACI1xxFZFRPMqOxCffokWZ8xjwgJriO7y39RHNE_zv2iKbf0sfzopWx9OswACgYKAfESARESFQHGX2MigLrJfrclCmdzwxKrjWyEmxoVAUF8yKrLI9dgcin6VzAwMD41DJ_b0076
.google.com TRUE / TRUE 1827804650 __Secure-3PSID g.a000EAnGNB0WmAKACI1xxFZFRPMqOxCffokWZ8xjwgJriO7y39RHthDgvQyHS3S2EMmjA8DrXgACgYKARMSARESFQHGX2MiLrwX-yYljZr-SoV-ywBkUBoVAUF8yKqY8mdbHqSLAxrUzIc8xOT20076
.google.com TRUE / FALSE 1827804650 HSID AXYmcliY47gZfU1Uw
.google.com TRUE / TRUE 1827804650 SSID AvgfjReLF18ivzBVy
.google.com TRUE / FALSE 1827804650 APISID iiVE1FGlsGq_SIe3/AhE-5-0txwwoIWUTC
.google.com TRUE / TRUE 1827804650 SAPISID Ge3Zs4CbZc0poi9T/ArPlRl005gM0xNfTG
.google.com TRUE / TRUE 1827804650 __Secure-1PAPISID Ge3Zs4CbZc0poi9T/ArPlRl005gM0xNfTG
.google.com TRUE / TRUE 1827804650 __Secure-3PAPISID Ge3Zs4CbZc0poi9T/ArPlRl005gM0xNfTG
.google.com TRUE / FALSE 1825138646 SIDCC APD_SKH2BBdnuDX52BjbdVHpIuYiZrR2P6C5Bfjz47mU7j6weRAgbJeV3B3WTNIMpVtJPbP2X4w
.google.com TRUE / TRUE 1825138646 __Secure-1PSIDCC APD_SKEL8lDVf5BEzl0Kq5XGMDUIMxtASUoMjMorGRpP3JY5lT5WJVhlLsL6jArVquu2rAmqMOw
.google.com TRUE / TRUE 1825138646 __Secure-3PSIDCC APD_SKEBX_c_zDuvNS1Cc8gRPpQhD8RQCYo7ZcpZTOVNsd7J6y3mWqk1WBEP4lUVrForptLQBQ
.google.com TRUE / TRUE 1825138646 __Secure-1PSIDTS sidts-CjIBnJZzal3kzqnx9tpyQs0IK1W3B4nbqamKhA5tvAjw1KbpPZGtYBD0lVxXa8lZ6bmvWBAA
.google.com TRUE / TRUE 1825138646 __Secure-3PSIDTS sidts-CjIBnJZzal3kzqnx9tpyQs0IK1W3B4nbqamKhA5tvAjw1KbpPZGtYBD0lVxXa8lZ6bmvWBAA
.google.com TRUE / TRUE 1827468307 __Secure-ENID CsQCCAIS9gEBY3DFeo0V6JMS1jp5HqXZ56ME8K9X8XTLF9MJ9LuCPmDyFQOAdcZLzTCyJbou2sKNtELOKb-Py-S2nevCay0d2y3ds6QKSr6PRiV3WpO2XIESo1DBjkXZA42eHvK6K0YnfWPaUlblw7DAzfDwsI6ch4ci88_1rKfQ9LmIZIugId2JHmXvDfgcxcTxXEQAA5l0TIwihS_Os8Ffz6kAbwW1WCyvrKfHqkpsHR3sdbjtDQSi_M-syjIOnafsfIu0FW3aolyGcypnqlJ4reu9Nf4Z19pE84QbXdg1Asy0JhVOgqPVfzmyO08CJ3gtrDH5RYBJUgeYMEMoAjJFAfsI_Gp75UHBzvWf6C-hjgOstTh-OuCJ_nKj2Jzgya6W8jq7dLiGxqArd8tpqbV2tSe0WmsoXiMVRR_TN0H3GUIUwAMB
.ubuntu.com TRUE / FALSE 1801019632 _gcl_au 1.1.1548112440.1793243632.-.-.1793243632.106378654.1793243632.1793253939
.ubuntu.com TRUE / FALSE 1827813939 _ga GA1.1.1330533117.1793243627
.ubuntu.com TRUE / FALSE 1827813995 _ga_5LTL1CNEJM GS2.1.s1793253939$o2$g0$t1793253995$j4$l0$h0
.ubuntu.com TRUE / FALSE 1827813938 _mkto_trk id:066-EOV-335&token:_mch-ubuntu.com-f498e3790978fdbe21a2df5b098e9cb6
.ubuntu.com TRUE / FALSE 1824779636 _clck j2u2nq%5E2%5Egap%5E0%5E2493
.ubuntu.com TRUE / FALSE 1826949939 _uetvid c2dbafd0d34611f1a69191ebe9e1bbc6
.ubuntu.com TRUE / FALSE 1824789995 _ce.s v~9c107265864dbd9886875d4ed5467185eb73dafd~vir~new~lva~1793253940224~vpv~0~v11.cs~30754~v11.s~c5229300-d35e-11f1-a4c0-1794269abf09~v11.vs~9c107265864dbd9886875d4ed5467185eb73dafd~v11.sla~1793253941296~v11.wss~1793253941297~v11ls~c5229300-d35e-11f1-a4c0-1794269abf09~gtrk.la~mvt54m6h~lcw~1793253995081
.ubuntu.com TRUE / TRUE 1824789940 _zitok acb46eddb5a116e77ffd1793253940
.ubuntu.com TRUE / FALSE 1824789941 _CEFT Q%3D%3D%3D
.ubuntu.com TRUE / FALSE 1824789941 _ce.seg.targeting NoRgNA3gRA7FBc0B2BXAtgIwKYCcEAYwoBjFHHLJYgTwSgFUBlAESgF82xgosBDEAGwBWAGYAOXgFoxAZiFZJAFiyKATNJABOBfny8hAjKsX5FYgVAC6YVABtbNlPctA
accounts.google.com FALSE / TRUE 1828161786 __Host-GAPS 1:jnoihk8gbE6CBiUPcWwpzbLPKhMYNxasuXvyvfRE1y0TDpS6DqW9HWN9RDRZcmFeZbzst1J3YOr5lntj09QIZeloa055ew:6qnuAH3zV9nHvwJD
accounts.google.com FALSE / TRUE 1795836582 OTZ 8849010_56_56__56_
accounts.google.com FALSE / TRUE 1828161786 LSID o.drive.google.com|o.mail.google.com|s.RU|s.youtube:g.a000EAnGNBDeRvYKezr-KTZeRNavu2y5LpdYW9YPZAeLM7zS9IVRPNM-VrdSMsdt4ioiKWRsjAACgYKAWESARESFQHGX2MidbR0mS-9ghpHeRuys9vFwhoVAUF8yKpHWkGsUIV_shGw7whdC75z0076
accounts.google.com FALSE / TRUE 1828161786 __Host-1PLSID o.drive.google.com|o.mail.google.com|s.RU|s.youtube:g.a000EAnGNBDeRvYKezr-KTZeRNavu2y5LpdYW9YPZAeLM7zS9IVRod1pswUUA9vyWHX4vvovRwACgYKAfkSARESFQHGX2Mi6Fdaa_wYIztv28rqCYYffhoVAUF8yKprssV4QIWW5B91Nog9-wCw0076
accounts.google.com FALSE / TRUE 1828161786 __Host-3PLSID o.drive.google.com|o.mail.google.com|s.RU|s.youtube:g.a000EAnGNBDeRvYKezr-KTZeRNavu2y5LpdYW9YPZAeLM7zS9IVRhqHRMSWHWhfyz4PahcnXCgACgYKAYwSARESFQHGX2MiOUOBhsXWXBBRcMGVTHeogxoVAUF8yKonQHmeaHGQZWAnfZhg4btQ0076
accounts.google.com FALSE / TRUE 1827804650 ACCOUNT_CHOOSER AFx_qI5PWL0zfgtZGh8MJXuixY8M1w0rhdsbvfbB0C3JIKDj-oItaMA2t315QusePGY_z2hIcy-Dtg40G1K8tv9RLd5CPsgyABXtfhAXoEmTrdONRfPzluiNW6Jban6KvzsPNPNX2dIj
appsgenaiserver-pa.clients6.google.com FALSE / TRUE 1794117950 COMPASS apps-genai=CgAQzuCL1wYaaQAJa4lXc0WN2_7F_X4FimXuhol9ch-azTZAYcL744P5UTRixon6YskA-C82NfzInAlu-M8wi-V2rKrpx1SZ7uhZEZ_A4-c5QtsI1VpOrp51JWcYeg2poabmqJVq0LvGktEuQviLtlNx_CABMAE
studio.workspace.google.com FALSE / TRUE 1795836674 OTZ 8849011_56_56__56_
contacts.google.com FALSE / TRUE 1795836680 OTZ 8849011_56_56__56_
.deepseek.com TRUE / FALSE 1824897197 ds_cookie_preference %257B%2522level%2522%253A%2522all%2522%257D
chat.deepseek.com FALSE / FALSE 1827804686 .thumbcache_6b2e5483f9d858d7c661c5e276b6a6ae h1dhz9daSCHp56Z2dbmTGhYdAzh4JHwLcEUQVn2ODJlthBcETanPJF+phvSza7+C1Sd+iD0Ezav5jVIoAo66jQ%3D%3D
chat.deepseek.com FALSE / FALSE 1827804684 smidV2 202610290331247f5222ed8be6d979709d920dc788083b00871f6667ee41f70
chat.deepseek.com FALSE / TRUE 0 ds_session_id 6b0269f39cc04c6fa0e234a2ab21bb9f
.github.com TRUE / TRUE 1824781109 _octo GH1.1.921897397.1793245109
.github.com TRUE / TRUE 1824781109 logged_in no
m-17e4e9e7a8d9-3w5hsuiikq-ez.a.run.app FALSE / FALSE 1793849927 a_trpm eyJ1dG1fc291cmNlIjoiZ2l0aHViIiwidXRtX2NhbXBhaWduIjoiYW1uZXppYV9idXR0b24tcmVhZG1lLWVuLW1pcnJvdyJ9
m-17e4e9e7a8d9-3w5hsuiikq-ez.a.run.app FALSE / FALSE 1809013127 _pk_ref.1.063c %5B%22amnezia_button-readme-en-mirrow%22%2C%22%22%2C1793245128%2C%22https%3A%2F%2Fstorage.googleapis.com%2F%22%5D
m-17e4e9e7a8d9-3w5hsuiikq-ez.a.run.app FALSE / FALSE 1827200328 _pk_id.1.063c e10510dd467fca87.1793245128.
ru.pinterest.com FALSE / TRUE 1824781324 csrftoken f7a6bacbec666f4723325abe2b29fbf8
ru.pinterest.com FALSE / FALSE 1808797343 g_state {"i_l":1,"i_ll":1793245343858,"i_e":{"enable_itp_optimization":24},"i_et":1793245343858,"i_b":"hcAMZAcT/5jQDJD13Xh8uEl/l+3nObXR0Z98ozktHDo"}
.pinterest.com TRUE / TRUE 1824349341 _pinterest_sess TWc9PSZqQlV5aW9Kc09jT0dVYUtkNlo4QlZZa294dkJwZzhSaUE1N0VPVHB6U0hkdjFvMkYvbGVsY0MvQ2ZBdlQ2MW02UzB0Y3hSbHdLemxPZmFadm9Sa0t5cFZGc1oydWJ6TXJFVE0xZzRFWGNRbVBVWG5zOEFocXhURnJ0OGZRMVV4RXA4N3gybXFjOHRPM3Z3SUxpZlVtMElpMmhtQlNWK1lpTDJ6cWduQnBRaGJIQXpXOWZ3b3J1OWxsTWpqZzJYVVl6NjIwMU9nVzZMUmo1Nm1STVR6ZHgxS1RRd0FMZkhVV1Zsb0xXblJSQTZxcVl0eERLNTJ4Z2o0M3ZnRGE1MnNSUnZFczU1Wm84WVpoQzd6TXVWSVM1SWljTk9sL05MZGJEeGNPbW9CM2RYR2NFV1NZUW1Rb3EyekpiczFvdHVLVjhYRXlTTWNCQXQzY0QxYUFaQXNvNGh1ekhiRFQzdm1jOHc2WEpOMTdTZTQxa3RIdnhjZWhKZm1iOXQwU1FHZkFqODNNazJLcUVKZDVpRzU3blpLanhzVnZmcElGMTROaUhLd0JiMjFTTHB2NFZkR1Zady9HRGMrcVRRd2xBQm8xOEowVWJ6Z3U1eWExOTFHUjVHRExtRjVKQ05FZTA1akEwWm9YaThKWnRINTNFYWpSWXZsbUw3aFYxczYvK1pkN3lTYVRHN3FjNXFrSzYzSTlUT3Q2MVFMTFl0TnN6RngvNkVJbmswNHhXQUw0WHVCQmxZSmloUncwSE1aQk9lWGZQMVJ3dTFtakN3c0lRTHQvaUhBb01XbnU4SHN2Rm05OTBjNTlHTjA3MkZLTVNpSEFXcVQ1c2RFaXF2WW5OdFdIYWJnQWJPK2tnMFltbXVCUHp3YjJRM2Juaml4blpETi85NkNsYVNLNnBWbm1zNWIwNDlVZzlnck5HTU83K3U4VXc4bjJ4Y0N4SUY3WFJwTFBKUnJISThHWUdnby9NV0diZVBJYnZHU3c5dXJBOWNTeXJPZUxHVnJXVjFFSVE5dHcxZE5qZVhtUFRVYmx1Nk9Pd2tUZUg1ZXU0UmFZd3ZFN3FSalB4cHE0dkZCNm10K0QxMjV1T2pkcWd1RTNJOG1CUGcwNnV3VXJlRXBhVlZRWWZqWWZKWHZhcE9JdmlpWFVHdVJRZEQ0K0YwY0JJdmF6NG9Qb2dIMnRjNitlYXF3eDNkU1lCT1FyOWxUQ3NoQ3pJSnIzOG52OHhZcmRQRStTMlcxaG01UlZ1MWNZWmd6NTR3d1Qxanpub2RpS2tKcGRORFFQZmJKSjhVOUpON2E5MXNSRWdiRUpZVlJocFIveHdLVS9ScHVuNW0wUHlDMXVDd3pya2pkQnJ6azd0MTJsK0hrWThVckl4Sll2a1V6UWRzTWxNSHNmZDNHYmpjeFVvQ0Y5dHNRZ0VZQ1JnRTJRaXlBdUJKUzlCaHJSTXhJZlFjbEdKRVBNT1VhWW1XaXY5b0hQWnVvem5UWVNteVlKNkk0KzlBVW0zKzdaVUhkMHU0WFpHdTRKN0hZWHNHTFNWR1YwTXFjU2VKR3ZWN3ptRklIQldpUUxPNjBORFVRSzFrRHlyRHBOUGJwUEc1Q0dERk5JK0VqbU4zRU1namxQbXFDZGRKcVRDRWlsUFd5NmpXNTRTdTQydmEvVC9yS2hxeXoxcWlrdUVmMGRtSTB5M096U25WWTBkZ2E5WnppdXhZTHd3QjlCV2EvRkIyZzVFMVZMNVpicTVuSkFIY1ovZ2gxdDc2Qkp2Zms3d3FPTWJKR0Z3RHorMFV0bHlLaVcmUFAxNzJBdjBHa2h0Ky9PN2RDTS90aDBpTjBJPQ==
.pinterest.com TRUE / TRUE 1824349341 _auth 1
.pinterest.com TRUE / FALSE 1827805327 _b "AZlGMz4Io4NG9b/6exHx0JfKsIdQcgM4C2J2nN9RJj8FkZUQHzO0bjsTI3P1CoPUguM="
.pinterest.com TRUE / TRUE 1827805341 l_o Lmh7Obmv3QO/uNzFPN5zG+fIX/9rWLR1mgN/LfJvEOd+iLaoBxx1zUecH1OzRwxD3uVWXaHKxCMvnHj5EfaGXxkUtjkdP8A2Hqvf5eoVIt86LsRX0bEHbT3XNidF6BMoOaI=
.pinterest.com TRUE / TRUE 1824349341 __Secure-s_a S3gyazBOUjFQR3Y3SVAwQ09qcHZYb0Zzd0VuMUtObVRPWjRacWFVRjBHUDF0MFQ3bE9QWFRnYzc5YW81eEFmTHdBSUZuOUtFZ1NHYTRyUmsybVZhZUlqYklEajhsYTRiZGtvaWFLWTdOVDFRNWQwbkFveGNteW5HTlJ2bnNYbzZyL1hxWDRJM2FZZFF6UXdveFVMQmtHSDRMSXY2YlM0TWRmWlJBQTBmM3N5emhWTHN6UWY5d2JXek9UUTc0OVBTNFFXVVNZVkZKVXhocHlmR3lyVkRzd0dTZUVoVEE3ZkJNL3AxbzZNc2g2QmFGMzBJYXd5dmFNWHcycEs5Z1pGVGd1a0FYK3hxMFozYXhnS1F1V3d6OXhYOXNmWjVkKzJHTWdudThHMWUvMUtlV1RVcEZYOW1iUWxpR2RKUllzL01ONE5YakV1WHc4ZXBpMjA4RXFZMC8zK09NV3RtTkdmd3VnM1R4WUNzd3hzR1VrY2ZQUWtTNGplRmZKVU9Pa0k0WExGaWlIc1h2Mm5oZWZEVlVrNXNSYjkyZklqcUR6MW11VVNCK2hOTU1TZWZPRWdaMFU4R1pNVXprNnBuOHdFa1pOWG5KSWxsazBOL0p6NGNiRzdkcTFQQzZLcnJib1RlRnJOQXcvYVkxcks0V2V4ZzNpSGxBV3VaNUVNSDlCUWFhLzV4VlNBNklnRnpMcm83Q01SWEoyTTNkWFpmdjRIOElwTjZBSk81WG5kVzJkTU5Cak12SDRIRHpBeXRUMWphbUtzYVM5ZU5WMDZjTjA5WUZNTTAxV2JMUTZZVnl4L1ZISXBhdksrL0FqV2VGTHVqVCs1T0lvaWZxMzRTckVZTGFwMEh0elluNkJRSFlMdHVMTjN0ckxYNVNwODVVTE1SenViMzJ5U3ZlYVJmQktqVVVaaHd1U2RtYW9USnJYdG14ZytweWNzWncvd3BVTjRCU1Z1UzRiRDQ1ODlhVHFZeFFFVEF1SmM2NlMzM1dObVQrVzdhT0VIOTlkK2JHL2czTXgrMlNOaUJuci9hb3p1L3F2VUJnYklOb0IxeXNDaVJvdGxtWXdDYnI4L3ZsZ3Q4dDNOSXFLK0tzV3NXV05mdERLMjZYNTRBN3VSMm92LytnZWxZRWJ6dEhuQ2QvWVVHbENHdmRuenBxM3FOcUQ2UUg4Y2haMmp5S3ZHYkVJSVJYb2VNYkhNdTlqUjUydVByMWJsK1Q1TnhJeWl3eDlYRWVqZCtHbGhHZDdoamFoNTZiOStEck12YlJweUlkVm1tMzJJWEl2bTdCQlBKWXlWTFFCdGZHVkpmMnZXSEdiTUpKZEFXaXhVRml3Zz0mKzNZWWM3djAyc3FwWml2THBnSUE0WGYyUjlzPQ==
drive.google.com FALSE / TRUE 1827813954 OSID g.a000EAnGNAsYWbTguZf9vGkLNPa_LRN9ah4cCo2MN6zUE5Q5flURXZXsfbp6VHUZVvHzgtgZKQACgYKAekSARESFQHGX2Miu5Cekh6uhZHhZDGiQy8-pBoVAUF8yKpDvPJFvCCLpdVXumldUN_H0076
drive.google.com FALSE / TRUE 1827813954 __Secure-OSID g.a000EAnGNAsYWbTguZf9vGkLNPa_LRN9ah4cCo2MN6zUE5Q5flURKJEi6AuJ986neSkM7nLdJgACgYKAccSARESFQHGX2MiUwZic0D2XfP53_k8-06gnRoVAUF8yKpOW82_PGDQlRnBZ3Jb3EER0076
drive.google.com FALSE / TRUE 1794212374 COMPASS photos-fife=CgAQo8KR1wYaXgAJa4lXi60XCseUjuTo5bWuVoA_p1m7upt88sJM7hwx6GuDpzwJ6FrvbpK7-rcvodFvcAavg2KgdLqG6I0M41sBFeB_h9Vwu_MXzsvzX3E6PMUoIUa3pYNiT6_BKswwAQ
ogs.google.com FALSE / TRUE 1795845959 OTZ 8849166_56_56__56_
play2go.cloud FALSE / FALSE 1824804880 locale en
play2go.cloud FALSE / TRUE 1795860918 access_token eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo1MTEzNSwic2lkIjoiYU1qeEZNZTV5YmJOZU44TUd3dHAyblJqUnhtQzRqbnJOakYwNFlzblU4USIsInJvbGUiOiJVU0VSIiwiaWF0IjoxNzkzMjY4OTE3LCJleHAiOjE3OTU4NjA5MTd9.ajWKJmu8IX5W8Xsd84KiJSa6oMevAdJzR7omTkaF1hQ
.play2go.cloud TRUE / FALSE 1824804885 _ym_uid 1793268885852625543
.play2go.cloud TRUE / FALSE 1824804885 _ym_d 1793268885
.mozilla.org TRUE / FALSE 1828162669 _ga_B9CY1C9VBC GS2.1.s1793602651$o3$g0$t1793602668$j43$l0$h0
.mozilla.org TRUE / FALSE 1828162651 _ga GA1.1.752939076.1793280122
.nerdfonts.com TRUE / FALSE 1827850534 _ga_FZ2KV65PN8 GS2.1.s1793290094$o1$g1$t1793290534$j40$l0$h0
.nerdfonts.com TRUE / FALSE 1827850454 _ga GA1.1.1472696158.1793290094
.nerdfonts.com TRUE / FALSE 1827850454 __utma 170037823.1472696158.1793290094.1793290095.1793290095.1
.nerdfonts.com TRUE / FALSE 1809058454 __utmz 170037823.1793290095.1.1.utmcsr=google|utmccn=(organic)|utmcmd=organic|utmctr=(not%20provided)
.pornhub.com TRUE / TRUE 1793965750 platform pc
.pornhub.com TRUE / TRUE 1824896950 ss 921797941037255564
.pornhub.com TRUE / TRUE 1795952950 comp_detect-cookies 37066.100000
.pornhub.com TRUE / TRUE 1824917876 __l 6AE48433-42FE722901BB1F8664-B058802
.pornhub.com TRUE / TRUE 1824896951 cookieConsent 3
.pornhub.com TRUE / FALSE 1827921017 _ga GA1.1.362951938.1793360953
.pornhub.com TRUE / FALSE 1827921107 _ga_B39RFFWGYY GS2.1.s1793360953$o1$g1$t1793361107$j60$l0$h0
.pornhub.com TRUE / TRUE 1824896956 bs 000000000000000057707e19457a9bc3
.pornhub.com TRUE / TRUE 1824896956 bsdd 000000000000000057707e19457a9bc3
.pornhub.com TRUE / TRUE 1824896956 sessid 334607879777154788
.pornhub.com TRUE / TRUE 1795952956 fg_afaf12e314c5419a855ddc0bf120670f 84976.100000
.pornhub.com TRUE / TRUE 1795952956 fg_55e3b6f0afd46366d6fa797544b15af2 1904.100000
.pornhub.com TRUE / TRUE 1795952956 fg_7e663154ded3d3eac4af77cfff94b32a 51750.100000
.pornhub.com TRUE / FALSE 1808913017 g_state {"i_l":1,"i_ll":1793361017631,"i_b":"u8UvXXbaugiAdRwEeAOzWvbyTvbhRJjhpGoum5hueAw","i_e":{"enable_itp_optimization":24},"i_et":1793361017631}
.pornhub.com TRUE / TRUE 1795952964 fg_439f2555043a44b8bd91161b5deddd29 18136.100000
.pornhub.com TRUE / TRUE 1824897015 lvv 342506477690527253
.pornhub.com TRUE / TRUE 1824897015 vlc 740349612543925605
.www.pornhub.com TRUE / TRUE 1795953016 tj_UUID ChA6-Jjt7EdMzZhRsxWUd1FNEgsIwoiS1wYQ-vHzOBgEKgwI9IiS1wYQx-mxlQNKECdNNe8gp0DpkJL6cUNVJ7Y=
.www.pornhub.com TRUE / TRUE 1795952964 tj_UUID_v2 ChA6-Jjt7EdMzZhRsxWUd1FNEgsIwoiS1wYQ-vHzOBgEKgsIwoiS1wYQgoX0OEoQJ0017yCnQOmQkvpxQ1Untg==
.chatgpt.com TRUE / TRUE 1824909683 oai-did 0c32ca2d-2d74-401c-bab0-059bdc4bde89
.chatgpt.com TRUE / TRUE 1824909683 oai-did-first-seen v1.0c32ca2d-2d74-401c-bab0-059bdc4bde89.1793369753
.chatgpt.com TRUE / TRUE 1793974556 oai-mweb-route 1
.chatgpt.com TRUE / TRUE 1825079853 oai-sc 0gAAAAABq506mo3kWVeFyzwDyT6n2Qu2-H4FC_DaT8lqBjmelHCTapr2PvAaXSb89moG6acJ3z44EENdPEuQ3pAkLoGZt6pU_c5uRx_COLSfHPIMkRarpfOhlnrhO8wjVqBZDZO5nC1atVuT48KWJuuIJMHPM99Yt7VKTCjoHFAqH8qqCMgpN_pqZ4fspF8JUKbufm29D5Yox1QyWy4R8CdWtyM7dKsZNTL0Bgdl89xJG7AKr_IGlpjU
.chatgpt.com TRUE / TRUE 1808921944 oai_consent_analytics true
.chatgpt.com TRUE / TRUE 1808921944 oai_consent_marketing true
.chatgpt.com TRUE / TRUE 1808921944 oai-allow-ne true
.chatgpt.com TRUE / TRUE 1801313300 __Secure-next-auth.session-token.0 eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..DwbWJZzyt2qY49xf.CZWlzqlm629eCTtkKa8FMGDPzKM9gF57pJDNcC2HkDTDH9tl1j6VNWzH10LnNYvV9opJY-Gy0-y7vsa_c-gj6BInKIgrsabC_ICvVrayy3eZoDU8YgG_Jl59i2qX8Ro1JlEMjFPfspaNCwAyB2rm5f-KJpjqxtOR0b3HSnABxYJbfUSSvX8FZlHJS9OVyarpLeFc9Ajakb7uhtTudp_yA5AwMedGo65yCzNpgBScSNXd0e1-Nz8vnGHNdWqbQ7pZfsdrWTqN4Qo4-uCq8T1EddAc5tMCxqypRf6G2IomYxRRJxa-MFmZgBh9sBYB1dFK0C85iS_ywasz9sbv9GNgDpV-l4FXB0HY4-cZq61i8UzxrJ95SY1gLJha80mCsPU9ZYd2h6MysgYmOb5nBPsdiQv1LHi8nGq9pEj5Yc0MTXgppxm5asZA_b1NxJPCe0Pse4w6r3nY4kkdIvyoon8d9p9e2hzyzYPTGagrSO9lwZH4zkP4zACGb9AI9EB2r3NeYb2T-gG68THFEcH6Fz3p-TVZMCq1gxAPx7cDFVIwoiK0dgFReZmUcv5i3jAFkjfvr59wGxil80QqQd3fuBXrJZYNbyXJV_wSZvjDTt3YhYSNUjKgv4OdL-N7yExSZjHbqiNHnZD3kKEY3yhQEJcksZk3IbsD2ECVD6TlrqYYU_UZ0vC2lnTSNKR9L-AH7G3JO7eBzrVxULvq2FrESoZwEAOkGB4b0zVjUq70Lx1EmVcTiWc_E2PiPbJl4WNLh_hvq7yY8fxkHcDreZVWDUYCOIi3pRLelcQTuQGn1li9ZnQGKWkbdd2YeTapM8I6ifT76BvyoZZt-J77HbbWJZMHhB8DnDtbD2dVEs8TMnfdJ3kZOiclPJpgvQv9s_aME5eHXMJKYYFfS26-EyKtKprgCn42Np44wjo229opDUJaxDAQegAJDBtTHqupsQGYZVIuGXHqSbMRRLITmjhG3KF9hy0uuwjXodMm2lswf8EfAFng6pQuZQ1qksJwykcXVH4cH1y_XEn1k3ZpRz1CIAC_aVdmMlHKRw1NiqVVQ2X-JDSgz71qEbuh2Ulg1vopM_ZAP0bmuKR1dSVx4zWDLKlTFXP8yaW2WDfOCB8p3XzEBYfSl1C29YVco6hzwSFEkZAof2M5XFPm16aZIkglXqildY2xNeFzNFdKHOTglzHFfXdQa7qGOxOP4MgMtlLslH2EIlyCLjdc7owr65zJUybh9AP0bINTBNzkrwGlvDjGq9tmt42YmDligH3r-Hn32xw0D4DSqFzTcMWtYjd-xl9jOrTalsM_phpQUkUgxYXDoS7RzBrERMraZYXmGceSxzQOIlw64q186TkVmTN-tdIXRTZ-ClF8rR5WgNCl3ESbES-X-x3VDQVW-2Gm1RAaEYtG_YjSCWKsqbUYyUhlA6kLpUXRNl_gZqBJAYu3kCuo108XQILoZRh0DTgG-uCa22nooR1rxe2xa-6qm0KCjZ3C6WRzeDlWaCq3Mr_8lAwu5GRBlJx7nXMuOVxA6J1av-z6rw1INAK2AwHdKkKHqRecO7Vborfms4uHXutsrJwZ-OOjVcU5683MOjsOKmazDRBly7CSZ3BnY9imkCWVu6Hr1SoSOMaFRerrNaqBD6qQmPfuVn2CpxQdlsKS35Zww_dNuefd7uz1BlX27XXyMZl0fEG5ALOM7FdQUDdDLdEbvqsKhtOwbEO3H9CFw9kp05dkjFqmBO76aEfl904Ghjfcmk_NLRiRJPwN-34MPJe7JAD5P2U7a6KQfV7rKtVV-V5WrZY4vIc19iShyL321hbuy9lTCbxIjokBdbRPBx1H01WpdRw-eibQ-hbNKvAWUeVJkq9gdHuP2x7YCiXEDpbp-fqGEckPyM9BbhlSFBWr_YwaBrtQLQGhDsiHDZi75ly8kegygyMV_GzzelO3gYZt14XRQnScya6Bt8YpNo-fMAtlZbSVzEX2T-7xgsb99kTGwe31IhKyEy6f9RlhMPiN_rlDPJiNfsw_9m6d5TqNlypf083uwwkTsDO3PyUOzVskdUqKDK77SVzDahAok9fHq-utZWqbVoRsRgc4y5wV-nJr3kcT7s8hTa8enM-ZFkFyHsqtW7n-BhYrRPyw4C4lViLW2TtuVdBoOXRoaN3_0wG547gs_ScCCfkvg0STlRDxDxJPLsdwDR2wc_3TJYjxwIZuNsYK3DALJWLsplpgDOgvEKYy3w-gkq6EthfRkb1Ae3WN0NQAFt0wQ8loAB9nHt3xSNiB397VDfknDiSsDjayyOh8taSf2qbPE0hD8jT6bZF1nu2VdDHcGoUf99UKKaQ99cwbtGFZxcYTIItxYv58YUd_egErgjbnjv7tv49iqWF044USxaQNLSZCtwRTlWOguTHvwkIoNwkx3e6axfrajHq2Sy8oCWfESAz4-64ojTYKXM6tRTRnpo0ZaH8VKNZ_4zw25y5pvFdQ_lRAIErnMzbL6IbnaAFW2QLHkxPqd55Rj5Njc26WBREWT5uj7AuXXPLc8ppHknj7OAQnrhY0aoTfvsmOVug-8RGKLuL4rsTnYl4um8-QFH8woIxWBxSuzk0czW02icP8HEFECwb_CKa04-mZbPZ5JH6YdNJxJj6Be3vcajRURRZOjJp_EAjzoDXru-iIrAnhHt-kBJ3O4fwPPh4eppY_su2Hnr0IXsjXVecoQ12DfOHNHkNh8v0eokUgoLWIdmgPJ4aspLVvPcxEaZgyKH1PE0HgjhxAstbFAlFfGt9qiRGbT7xDP6EApB1ukExenM5VjkqddV69xGQdT_xA4e2sKtNmnLkz_01d_aOzhBlKqBW_S4Ja1pEqvJWbt3HgaXdMPuetek4OqC9RHVWp7azykCoqOsU82QHfqJ28Ku3TJi5lFjlM7BfR8exhcaTBklp6VqSu6oVSH3979hVkeuJ7UQPv2_wcIiISBwz7g9nqKShaAK_Tqv4BnKUZ3Ahi5g9goSGAJD_486kmaKyZzt5m9y_SMClOWVqbjutMTuSiKfPMJhLPDrI69SAdI5jLwuDYXXUmHYSNVYxAmkAX0Lx3tQGT_1ll-A5Q2D_KKMUMrjVtsjncPZ8XXkTInek_fjTJS6p4ATXdJqqOp1KfiXDBLSUiqRIXnCOfiFWGbR3heEszPTgdWQcpg1YpS8s81ZPjo3VpW9g5N0PVITP5iCI9pnz3rqNq4EVfjddAlyUNtcaWVw15Z5bKeDLBCmvhrOSwpyJ0DVutqgnl_fVe-zRc9gneaz8BJFcy_rlQQVmHk9DnHF-4BjeAqfzRMQ4pwXnRYjUwavslG6p53qh40AlFC7TQL5l5SMtCTB0y_0kRFEFS8Bebyd6tb_PVRXKdCosBOS3vLKqlqmC-afiM1i13CyUAKAMnZWs2knlrAB2u9lnUFOmGcGxi-g9ThAl-shOiQHxc2q5IWzkzfFcApjSm_5SkJx0ZGYMiJ6sPBNmYC5cphhOUFYStI3g8kExMsff0KCnpj75uOv3fV_2Qd4N2fA4FJT_pwEWHeuP5JKn3USRjEi4EzlEQLKWBbIz25cjN3kOPW1JFbzV5uKwRBSGkllTz9BJLbBXD2zUkuprxUuDBr33xxgkzmP1fes6-RJ8dIskcmGP_G8l4tuoejuurPmrfYShbBRIC1dGMRqW0xETu6BRiJu_XbTelCzgZCFkwIxRFPvEECm171DQnxX3WSAo1SKaLMsODQctt0auHwcBC68Jd_bLl6nYdSzQC7Qh54aIpmc8dTFpFPLn2Y2lPKwKS-JJ8MOTCeBVPsLKs80bHhS5hIQ3ABnHDWCiZUxqkOq_YfWBtFKzNx1c88MY_2745oYlZlNdgq2MLWINFOe3EILL4RA12uHZboCk8N4o
.chatgpt.com TRUE / TRUE 1801313300 __Secure-next-auth.session-token.1 GhhnWCvbm-RpOpViaaPM5nxsVWAedVh1163YjjQh3xDcHU40Vp0Le5-032kfjgDsIY3FDvHOt2Y4B2d8m-CVZIm2-4WMVeY3R5gsXJ_10Zro7l5s-VdYm8230xiVBSmTLB0RuCB3CZsnZfzlHmuEfKyDm3l8FZxh3lUuXYyLCFAthft2H_9BcWaMJ_hLJIhhvi5BxcRDHlVVq_gfmfjOcBAPZ-W75cvNuBI7ZJyxR2PUKS6CZGClshl0-f3MF26I7smqZqg8ox56SarcrtPaHtt8v51GsevTweUboCfpYyxJthBUPrSGujqJTGatHWYj55PB0DF7OqcTgwXBa98RHjGIzHWNUESZ3MPHd8Y6at3OG_Rp_61Xzn20BM3tTX_wqefCoCI5-9POpg13USBwwV5BZTE8MdE4giBHHCR6oTAyCJ2xulOQts1jPsxJb2dTHZfEYh9k_fafIIX2Sjs9SZRUa4jlaIkft5Vbofb2EZnNy-Z2mi5YJXjcotCbVcT2M57LNpwKNEX9F0xGzbZ1_5mMC9_sMyhUQc8_K7wQztKQxBoQ_kFkvt653Ln3CL29nFLueS4kBnY29_LdhVMIUQpMBXdjPADNYz_B_qNMkwKJYeeuvd2JFhKTHbPRI2O5ninIgue_f7WElYrh3GDTQ1lRHNPkKN5zo4I6EU9xmZMi_sFWmHPCP6tmus2logQ4mBcj9VD9WE1J5XHQdb3gtolhuUH4D8VEaTuszywkXVxTqOohp-VYeYH2kPC2UvPWiUiG5c1-g8XdUlohWKgh0EgkyF_H1PrfKyZVNx_9FiLK5qm5XjBoSk9hU5Q9-5qaIdU8PYg7LZ9EvuS01PFr5KrC5i_xR0EADiZs3bL5qGQBAqwTZnEKIaENNf39YTTnfCRXWrAiHQjisalXEKYG9_UOz1yFCFQfG2v05XGxeI7zAkwS8Jia78KF1EKYPd1dgi7XGPHLfeQDVAelY0fktMoRL0JFQGBW8zzguypxbzVQDZ2HokZla2ftJJ00U7iAQSXEcNrXCfcwt9yVE5rGroFop0SSEJFPEW7CYbYZaqvmjUVfZWlaMRZ9dbPdJfLqeSvD781PZo5YWhpVB87eNiYKBG7oYHJ0EIRPkPCpl1HZwvpjOKa2j2rdQhhSIEO__3xyeuKER0QHLjnOGRKt_pSmb1jt9ALiQE5_PdxRIvKyLmvUGmg6ht7QphU-_UTTRTbnHKof4lHTKIdDLcH21gA1TvvlRqkKu38B8CC8GkFRtB4ZfPryAGioLaV5AbtypbHMXX7HWhYFp3dHJwolnR5NBIrbJyufLHMtkjbyoq2x80R9ZznMhRo-6OvZ9G2r78Ja5YpkCa2ujDM7GCi4KQV-f9vsjJWM1ZEpSyzlPGqPINqg9ldiG_kTwQID4K6aAhgraAUgi56SkjhuUJ82RSc8pRplJxgUJmTDCq6IXDs1pMdLuXqIMH2V7Oof2--jUGuTesObl3GU-cuim-44-co6C6xFFf-cbndaFCbSLRf4iPkUSe8WKLWhPc7MHTGzg_15qfwAFPL6HNuFMAG_ZrdTVtgWt32xkjgwGkX8tme-PIbYd9-Y_W2XIkPTnax_G3tiO2A.LnMy01EwaudEGsvaRUyHhw
.chatgpt.com TRUE / TRUE 1801313300 oai-client-session-epoch 2b3bbca4-657f-475f-9b9d-f6bccd23cd28
.chatgpt.com TRUE / TRUE 1824911256 __obi k5MxotcOMz8Od-B36lQhnw
chatgpt.com FALSE / FALSE 1808925684 g_state {"i_l":2,"i_ll":1793373684015,"i_b":"fHmWi8YYGxF69feHLENHdOYj6NjWQmIfQ/en2iOx4QI","i_e":{"enable_itp_optimization":24},"i_et":1793373684015,"i_p":1793456715849}
chatgpt.com FALSE / FALSE 1801151236 _account personal
chatgpt.com FALSE / FALSE 1801151236 _account_is_fedramp false
chatgpt.com FALSE / FALSE 1824479237 oai-hlib true
chatgpt.com FALSE / TRUE 1825078604 _dd_s_v2 isExpired=1&aid=8851ccad-84a0-41c9-9e51-12138091d13f&c=0
chatgpt.com FALSE / TRUE 1824911245 codex_sidebar_width 340
chatgpt.com FALSE / TRUE 1825073599 codex_sidebar_state expanded
auth.openai.com FALSE / TRUE 1827935236 __Host-usc_MfcvSjhtHJ4Vjs2U4Nw0Qtxi eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXMiLCJwcm90b2NvbF92ZXJzaW9uIjoiMyIsImNvb2tpZV9pZCI6Ik1mY3ZTamh0SEo0VmpzMlU0TncwUXR4aSIsImVuY3J5cHRlZF9hdXRoZW50aWNhdGVkX2RhdGEiOiJ1c19KeEg4eDA0Tm01QU5sRVBEQnpFeUE3S0YiLCJjbGllbnRfc2VjcmV0IjoibUpvSnYydDJwOHRPS3c3d3VHVWdCZDZGIn0.kAwjBkd9FlWaq7Z5tvR2_-rT23UJ8Ld1xQc-qKiqQ6aFhF8XMvtr_BzVMvpQaGLnvfVc7t_AY3Dogx16elz-rQ
auth.openai.com FALSE / TRUE 1827935236 __Host-unified_session_manifest eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJ2ZXJzaW9uIjozLCJpYXQiOjE3OTMzNzUyMzMsInNlc3Npb25zIjpbeyJjb29raWVfaWQiOiJNZmN2U2podEhKNFZqczJVNE53MFF0eGkiLCJ1bmlmaWVkX3Nlc3Npb25faWQiOiJ1c19KeEg4eDA0Tm01QU5sRVBEQnpFeUE3S0YifV19.hhS9p7t7nSo8v3EiHW0PI76POG7Z-ql3124u8e53op8XzqAInpsILk6ZtgQa_aP_mgrsaHrCnjOb1vayzdhV4Q
.openai.com TRUE / FALSE 1824911233 oai-did 0c32ca2d-2d74-401c-bab0-059bdc4bde89
.openai.com TRUE / TRUE 1824911233 oaicom-stable-id 33c211e8-9ac9-4e54-871a-863178efbef7
.auth.openai.com TRUE / TRUE 1795967236 oai-client-auth-info eyJsYXN0X2xvZ2luX2lkZW50aWZpZXIiOnsidHlwZSI6ImVtYWlsIiwidmFsdWUiOiJkaW1ldHlsaHlkcmF6aW5lQGdtYWlsLmNvbSJ9LCJsYXN0X2xvZ2luX3N0cmF0ZWd5IjoiZ29vZ2xlLW9hdXRoMiJ9.auS8AQ.8u7Dcly5k9TFFwKhrIBmrvk2IRu_8NtS_ZGIHzKTC60
.arduino.cc TRUE / TRUE 1824949595 _iub_previous_preference_id %7B%2211225532%22%3A%222026%2F10%2F31%2F02%2F26%2F35%2F507%2F83c8bf%22%7D
.arduino.cc TRUE / FALSE 1801189595 _gcl_au 1.1.1885983490.1793413596
.arduino.cc TRUE / FALSE 1827973595 _ga GA1.1.180986840.1793413595
.arduino.cc TRUE / FALSE 1827973595 _ga_NEXN8H46L5 GS2.1.s1793413592$o1$g0$t1793413595$j60$l0$h564715914
.arduino.cc TRUE / FALSE 1824949595 ajs_anonymous_id 76d835f1-8aac-45c2-a138-257760e513d7
.arduino.cc TRUE / TRUE 1827973595 FPID FPID2.2.uyDYHJgMO%2BYjjCE7bFqSbMqbj9nbqgU9FROhFHNTn9M%3D.1793413595
.arduino.cc TRUE / FALSE 1801189598 _fbp fb.1.1793413592985.2059045086
.arduino.cc TRUE / TRUE 1827109647 _twsid 1793413596179-460853959.1.1793413647812
.arduino.cc TRUE / TRUE 1827109647 _twpid tw.1793413596179.190626824812220358
.arduino.cc TRUE / TRUE 1824949596 _iub_cs-11225532 %7B%22timestamp%22%3A%222026-10-31T02%3A26%3A35.507Z%22%2C%22version%22%3A%221.109.0%22%2C%22purposes%22%3A%7B%221%22%3Atrue%2C%224%22%3Atrue%2C%225%22%3Atrue%7D%2C%22id%22%3A11225532%2C%22cons%22%3A%7B%22rand%22%3A%2283c8bf%22%7D%7D
.arduino.cc TRUE / FALSE 1827109598 _ttp A3HkwgiIiGlV54wJD7hIM2iCiGr.tt.1
.arduino.cc TRUE / TRUE 1801189596 _gtmeec-tt e30%3D
.arduino.cc TRUE / FALSE 1827109596 _uetvid 7f560920d4d211f1bd49cb6e3d4f19e1
.arduino.cc TRUE / TRUE 1801189597 _rdt_uuid 1793413597290.7ad6f7fe-8e3d-48fa-92e6-1af057d5a051
.arduino.cc TRUE / FALSE 1824949598 _clck jl8ycm%5E2%5Egar%5E1%5E2495
.arduino.cc TRUE / FALSE 1827109598 _tt_enable_cookie 1
.arduino.cc TRUE / FALSE 1827109614 ttcsid_CO1UDRRC77UEH42ABQ4G 1793413598348::t8WlNlYJwlqWdxMB3lyP.1.1793413614247.1
.arduino.cc TRUE / FALSE 1827109614 ttcsid 1793413598348::M1h267UHcXM2lslp1GLM.1.1793413614245.0::1.-8505.0::1380.1.1252.885::0.0.0
.fonts.google.com TRUE / FALSE 1828006220 _ga_XPW1QSKFW4 GS2.1.s1793446218$o2$g0$t1793446220$j58$l0$h2063114273
.fonts.google.com TRUE / FALSE 1827997522 _ga GA1.1.505137514.1793437523
.osdev.su TRUE / FALSE 1825080143 phpbb3_14fmv_u 1
.osdev.su TRUE / FALSE 1825080143 phpbb3_14fmv_k
.osdev.su TRUE / FALSE 1825080143 phpbb3_14fmv_sid 94f8f29f3a2afc0987f6915af932ff72
.fandom.com TRUE / FALSE 1828104153 exp_bucket v9-24
.fandom.com TRUE / FALSE 1828104153 exp_bucket_2 v5-82
.fandom.com TRUE / FALSE 1825080159 _pubcid 6833a2df-6ac0-403b-83e2-2ddc49d174c6
.fandom.com TRUE / FALSE 1825080159 _pubcid_cst znv0HA%3D%3D
.fandom.com TRUE / FALSE 1825080164 OptanonConsent isGpcEnabled=0&datestamp=Sun+Nov+01+2026+14%3A42%3A44+GMT%2B0000+(GMT%2B00%3A00)&version=202609.2.0&browserGpcFlag=0&isDntEnabled=0&isIABGlobal=false&hosts=&consentId=cbd2df03-5ed2-459a-83ce-d85060d68b2a&interactionCount=1&isAnonUser=1&prevHadToken=0&landingPath=NotLandingPage&groups=C0005%3A1%2CC0004%3A1%2CC0001%3A1%2CC0003%3A1%2CC0002%3A1%2CV2STACK42%3A1&fclco=&lastConsentTs=1793544162&intType=1&crTime=1793544164421
.fandom.com TRUE / FALSE 1825080162 OptanonAlertBoxClosed 2026-11-01T14:42:42.902Z
.fandom.com TRUE / FALSE 1825080162 eupubconsent-v2 CQtAQoAQtAQoAAcABBENC3FsAP_gAEPgACiQM_tR_C7dbWlD-bZ3abskeYxP1_ho6sQwBgaAkmIFzJOQZJwGgmEaJAyINCACCBAAITDBIQNkHBBUQUAgIIAFIABMYEwE4RJKIABAgAMZIwpACAAOiIEAcQCZggBXlkQkmBgoQMrGSFwAgCAAAACJKIAhAIAFAQOAAAEgZAIAAUAAwkgIkQhAiEIAAGEAQBCMAAAAIAc6Yns6dBmxs-Tyff6Pn_f71fS7X_ve_n3zv8oXn7rr6-__f3-7t__ubf-___f-__7Z_zM_37_v_ftb-_34M9gAmChUQAlEQKBAoCEECABQVhAAQIAgAASAogIATBAQZAwAEGESAEAIAAAAAAAAAQABAAAIAAhEAEABAIAQIBAIAAwAIAgIAEBAACABQgAAAAAOgIAAQQCBAABEAEAggQgAAAACSFAAACAAAACECIAAAIgAAAAAEAAgAAAAACAQgkAIgAAAiAIAAEAAABCMEAAAIAYAooDIFoKwIOAyNMAQfMEiSHQRAEwQkZBkQkqCQeKYogQQ5AbFJMANPEFADLpZIQ_1As2ALkS7DqSsJAyAAAAA.f_wACHwAAAAA.IM_tR_C7dbWlH-bZ36bskeYxf1_ho6sQwBgaAsmIFzJOQZJwGkmEaJEyINCACCBAAITDBIQNsHBhUQUAhIIAVIBBMYEwE4RpKIEBAgCMZYwpACAAOiIEAcQCZggBXlkQkmRgoQMrGSFwAgCAAAACNKIAhAIgFAQOAEAEgZAIAAWAQwkgIkQhAiEIAAGEAQBCMEAAAIAc64vv6dpuxs-Tyff6f__f73fS7X__e__33_8qX3_r76-___3__t__vff_________9__-___________34AAA
.fandom.com TRUE / FALSE 1825080162 OTAdditionalConsentString 2~20.57.61.70.83.89.93.108.122.124.144.149.161.184.196.230.236.255.259.272.311.313.314.320.323.340.358.370.385.415.424.429.445.469.486.494.495.550.574.576.621.723.797.798.864.955.959.981.986.1046.1047.1048.1051.1067.1095.1097.1109.1126.1166.1171.1192.1205.1220.1276.1301.1329.1342.1362.1415.1421.1423.1449.1514.1567.1570.1577.1598.1651.1659.1661.1716.1720.1725.1735.1753.1794.1808.1870.1878.1882.1889.1898.1911.1928.1942.1958.1985.1987.2016.2038.2068.2069.2072.2074.2084.2107.2109.2135.2141.2213.2219.2223.2224.2251.2253.2262.2271.2275.2295.2316.2328.2331.2373.2406.2415.2416.2427.2453.2501.2504.2506.2526.2531.2559.2567.2568.2571.2575.2577.2579.2589.2595.2624.2627.2639.2657.2660.2677.2689.2699.2778.2813.2822.2824.2826.2832.2833.2869.2872.2878.2908.2920.2963.2972.3001.3005.3023.3031.3051.3100.3107.3126.3133.3137.3153.3169.3177.3186.3200.3201.3213.3218.3233.3234.3235.3253.3254.3266.3286.3289.3299.3303.3309.3323.3331.3731.4131.4531.4631.4731.4831.5231.6731.6931.7131.7235.7831.7931.8931.10231.10631.10831.11031.11531.11631.13431.13632.13731.14034.14133.14237.15731.16831.16931.21233.21731.23031.25131.25931.26031.26631.27731.27831.28031.28332.28731.29631.30331.30532.30732.32531.33931.34231.34631.34731.36831.38732.39131.39531.40632.41131.41531.43631.43731.43831.45931.47232.47531.48131.49231.49332.49431.50831.52831.54231.55631.56131.56831.56931.57131.57231.57531.57931.58131.58631.59831.59832.60731.60831.60931.61531.61931.63831.63931.64031.64631.66531.66631.67031~dv
.fandom.com TRUE / FALSE 1809096162 eb 56
.fandom.com TRUE / FALSE 1809096162 wikia_beacon_id jrNvJKRteD
.fandom.com TRUE / FALSE 1828104162 _b2 9V7qem8_X4.1793544147498
.fandom.com TRUE / FALSE 1809096162 wikia_session_id bu-MHlgPls
.fandom.com TRUE / TRUE 1825080162 fan_visited_wikis 63539
.fandom.com TRUE / TRUE 1827240165 _scor_uid 5bbfc6fa3553450bbe88e8b0773c0f71
.fandom.com TRUE / FALSE 1825080165 FCNEC %5B%5B%22AKsRol-xXf_bPdvw_2sXo7k7ui83NlVD-5fOIgQfOyRAdy2aGTcyfAW9SsoCaOyeKaAMyOxFAXleWZAy_Oj5g9pyA3OVxhDbPw%3D%3D%22%5D%5D
.fandom.com TRUE / TRUE 1827240159 __gads ID=fa7b95ba97aec478:T=1793544159:RT=1793544159:S=ALNI_MZ6kDKscgIR2hcjPfeTU_pJZz7acg
.fandom.com TRUE / TRUE 1827240159 __gpi UID=000015dad35c29c9:T=1793544159:RT=1793544159:S=ALNI_MZ4GwR9KtAgAYv6plEEelR4FWr4fQ
.fandom.com TRUE / TRUE 1809096159 __eoi ID=7d6d8cec9f3fbf35:T=1793544159:RT=1793544159:S=AA-AfjYewsD92810X0-I7-wdLVgx
.fandom.com TRUE / TRUE 1825080166 _au_1d AU1D-0100-001793544166-AGQHV70A-JH51
.fandom.com TRUE / FALSE 1797864177 __bm_m
.fandom.com TRUE / FALSE 1816872167 _cc_id 81152dcb30a7059e042ab9c302e75872
.fandom.com TRUE / FALSE 1794148961 panoramaId_expiry 1794148961325
.fandom.com TRUE / FALSE 1794148961 panoramaId 27cfb0e6c446dfdd49e5815414f8185ca02cfe281ef4292a9b690cf8485b61da
.fandom.com TRUE / FALSE 1794148961 panoramaIdType panoDevice
.fandom.com TRUE / FALSE 1809096168 nol_fpid yxiewvbygxucbjwp3cd0sxgpjfncn1793544168|1793544168334|1793544168334|1793544168334
.fandom.com TRUE / FALSE 1825080171 _ce.s v~04ae0497a12f3ff16b8e8f09d8720be927b1ff28~vir~new~lva~1793544169396~vpv~0~v11.cs~462205~v11.s~838d24e0-d602-11f1-a177-53dff29a62af~v11.vs~04ae0497a12f3ff16b8e8f09d8720be927b1ff28~v11.sla~1793544171055~v11.wss~1793544171056~v11ls~838d24e0-d602-11f1-a177-53dff29a62af~lcw~1793544171067
.fandom.com TRUE / FALSE 1827412970 __qca P1-c9a21ed1-8fb9-4296-9078-4ccb7e6c321e
.fandom.com TRUE / FALSE 1793630571 _ce.clock_data 6530%2C177.3.221.184%2C1%2C23adcc7665e8a01746a0182a1b26b389%2CFirefox%2CDE%2CDirect%20Traffic%2C
auth.fandom.com FALSE / FALSE 1809096166 g_state {"i_l":1,"i_ll":1793544166434,"i_b":"X4p4vKuHrITMEpRuzJ6XdHY8v9FWObCS7yH5/l6sdk0","i_e":{"enable_itp_optimization":24},"i_et":1793544166434,"i_p":1793630566705}
auth.fandom.com FALSE / FALSE 1793630566 fanid_expiry 1
addons.mozilla.org FALSE / TRUE 0 taarId 9d5453042692a11ce787e3ca0879c6db142fa04f856be0194474f0af47c31bf5
.youtube.com TRUE / TRUE 1809167513 VISITOR_PRIVACY_METADATA CgJERRIEEgAgRg%3D%3D
.youtube.com TRUE / FALSE 0 PREF f6=40000000&tz=UTC&hl=en
.youtube.com TRUE / TRUE 1825138673 __Secure-1PSIDTS sidts-CjUBnJZzahHtW_M8Jwed7pJPsTJzpHH5lwgM7SD-fmCnaN1ktUVyi1xv3ZIwmB_4dU68jEqxnBAA
.youtube.com TRUE / TRUE 1825138673 __Secure-3PSIDTS sidts-CjUBnJZzahHtW_M8Jwed7pJPsTJzpHH5lwgM7SD-fmCnaN1ktUVyi1xv3ZIwmB_4dU68jEqxnBAA
.youtube.com TRUE / TRUE 1828161786 __Secure-3PAPISID Ge3Zs4CbZc0poi9T/ArPlRl005gM0xNfTG
.youtube.com TRUE / TRUE 1828161786 __Secure-3PSID g.a000EAnGNB0WmAKACI1xxFZFRPMqOxCffokWZ8xjwgJriO7y39RHthDgvQyHS3S2EMmjA8DrXgACgYKARMSARESFQHGX2MiLrwX-yYljZr-SoV-ywBkUBoVAUF8yKqY8mdbHqSLAxrUzIc8xOT20076
.youtube.com TRUE / TRUE 1825139779 __Secure-3PSIDCC APD_SKFAG6jAEKHrPFquSgH40ZXSzRx5wpmfAtlL5q93TaUGJ9S1ZYAz7jado-H5eWGQEJYprw
.youtube.com TRUE / TRUE 1809154741 __Secure-ROLLOUT_TOKEN CJiGs7i30fGbqAEQmd_o7drolwMYn82Y8trolwM%3D
.youtube.com TRUE / TRUE 0 SOCS CAI
.youtube.com TRUE / TRUE 1809159263 __Secure-YNID CuwBCAUSngEBDB6PDEsxr5FbktSlQYVc0LpWASLD7Bq8XAPxaZgPRjtPEHWenshRELjdaVKG6dV0IlGbFlNJDWQ_4vQtYiiVKyOAoFt3Sg0Wg7fyF27aYSxBGYX02pfxzEELB-sS6asfSiOnxTT8_jMr45t6_tFdtKnlpPC2sGCiL1zJGL4GtwKWvrF_N4v82KqJRV8_u8HPdKEa4oGALTa_CTmP4CgFMkUBHFj1agwx7RlJczonIpL5pBWTuRCPUMlnmkfiZk2jC16LEYgHItCUgtVLYlHpkI3GmY3vnGoc2E6x2fppJCYtjZYleQg
.youtube.com TRUE / TRUE 1809167513 VISITOR_INFO1_LIVE wAcMOSKNcvM
.youtube.com TRUE / TRUE 1793616537 GPS 1

71
utils/downloader.py Normal file
View File

@ -0,0 +1,71 @@
import asyncio
import os
from pathlib import Path
import qrcode
from dotenv import load_dotenv
from telethon import TelegramClient
from ..core.config import PHOTOS_DIR
ENV_PATH = Path(__file__).resolve().parent / ".env"
load_dotenv(ENV_PATH)
API_ID = os.getenv("API_ID")
API_HASH = os.getenv("API_HASH")
if not API_ID or not API_HASH:
raise ValueError("API_ID or API_HASH is not defined in .env")
API_ID = int(API_ID)
BASE_DIR = Path(__file__).resolve().parent
SOURCE = str(input("channel username:")).strip().lstrip("@")
CHANNEL =SOURCE
DOWNLOAD_DIR = SOURCE
MEDIA_ROOT = PHOTOS_DIR
async def download_photos(client):
download_path = BASE_DIR / MEDIA_ROOT / DOWNLOAD_DIR
download_path.mkdir(parents=True, exist_ok=True)
async for msg in client.iter_messages(CHANNEL):
if not msg.photo:
continue
filename = f"{msg.photo.id}.jpg"
full_path = download_path / filename
if full_path.is_file() and full_path.stat().st_size > 0:
continue
try:
await msg.download_media(file=full_path)
except Exception as e: # noqa: BLE001
print(e)
async def main():
client = TelegramClient("qr_session", API_ID, API_HASH)
await client.connect()
try:
if not await client.is_user_authorized():
qr = await client.qr_login()
image = qrcode.QRCode()
image.add_data(qr.url)
image.make(fit=True)
image.print_ascii(invert=True)
# user = await qr.wait()
await download_photos(client)
finally:
await client.disconnect()
asyncio.run(main())

119
utils/sync_yt.py Normal file
View File

@ -0,0 +1,119 @@
import argparse
import json
import subprocess
import sys
from pathlib import Path
from ..core.config import BASE_DIR, YT_DIR
COOKIES_PATH = Path(f"{BASE_DIR}/utils/cookies.txt") # или из config
GLOBAL_ARGS = [
"--socket-timeout", "20",
"--retries", "10",
"--fragment-retries", "10",
"--js-runtimes", "deno",
"--remote-components", "ejs:github",
"--embed-thumbnail",
"--convert-thumbnails", "jpg",
"--merge-output-format", "mp4",
"--sleep-interval", "5",
"--max-sleep-interval", "15",
"--sleep-requests", "3",
"--limit-rate", "3M",
]
DEFAULT_FORMAT = (
"bestvideo[height<=720][height>=480]+bestaudio/"
# "bestvideo[height<=1080][height>=480]+bestaudio/"
# "best[height>=480]"
)
def load_channels(config_path: Path, yt_dir: Path) -> list[dict]:
if not config_path.exists():
print(f"not found: {config_path}")
sys.exit(1)
data = json.loads(config_path.read_text())
channels = []
for raw in data:
name = raw["name"]
print(raw)
folder = raw.get("dir", name)
channels.append({
"name": name,
"link": raw["link"],
"path": yt_dir / folder,
"enabled_sync": raw.get("enabled_sync", True),
"extra_args": raw.get("extra_args", []),
"format": DEFAULT_FORMAT,
})
return channels
def build_cmd(ch: dict, dry_run: bool) -> list[str]:
channel_dir: Path = ch["path"]
archive = channel_dir / "archive.txt"
return [
"yt-dlp",
"-P", str(channel_dir),
"-o", "%(upload_date)s - %(title)s.%(ext)s",
"--download-archive", str(archive),
"-f", ch["format"],
"--cookies", str(COOKIES_PATH),
*GLOBAL_ARGS,
*ch["extra_args"],
ch["link"],
]
def sync_channel(ch: dict, dry_run: bool) -> int:
if not ch["enabled_sync"]:
print (f"skip disabled: {ch["name"]}")
return 0
ch["path"].mkdir(parents=True, exist_ok=True)
cmd = build_cmd(ch, dry_run)
print(f"\n=== {ch['name']} ===")
print(" ".join(cmd))
if dry_run:
return 0
return subprocess.run(cmd, check=False).returncode
def parse_args():
p = argparse.ArgumentParser(description="Sync channels via yt-dlp")
p.add_argument("--channels", "-c", type=Path, default=YT_DIR / "channels.json")
p.add_argument("--dry-run", action="store_true")
p.add_argument("ytdlp_args", nargs=argparse.REMAINDER,
help="extra yt-dlp args (after --)")
return p.parse_args()
def main():
args = parse_args()
channels = load_channels(args.channels, YT_DIR)
global_extra = args.ytdlp_args
if global_extra:
print(f"global extra args: {global_extra}")
enabled = sum(1 for c in channels if c["enabled_sync"])
print(f"channels: {len(channels)}, enabled: {enabled}")
failed = []
for ch in channels:
if global_extra:
ch["extra_args"] = global_extra + ch["extra_args"]
code = sync_channel(ch, args.dry_run)
if code != 0:
failed.append(ch["name"])
if failed:
print(f"\nFAILED: {failed}")
sys.exit(1)
if __name__ == "__main__":
main()

114
utils/update_audio.py Normal file
View File

@ -0,0 +1,114 @@
import asyncio
import re
from datetime import datetime
from pathlib import Path
from sqlalchemy import select
from ..core.config import AUDIO_DIR, AUDIO_EXTENSIONS, COVERS
from ..core.db import Base, SessionLocal, engine
from ..models.audio_model import Audio, AudioPlaylist
def parse_preview_path(path: str) -> tuple[str, datetime | None]:
p = Path(path)
stem = p.stem
match = re.match(r"^(\d{8})\s*-\s*(.+)$", stem)
if not match:
return stem, None
date_str, title = match.group(1), match.group(2).strip()
try:
date = datetime.strptime(date_str, "%Y%m%d")
except ValueError:
return stem, None
return title, date
DIRECTORY = AUDIO_DIR
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def update_yt():
async with SessionLocal() as session:
cached_playlists = {
(pl.title): pl for pl in (await session.execute(select(AudioPlaylist))).scalars()
}
existing_audios: set[str] = set(
(await session.execute(select(Audio.filename))).scalars()
)
audios = []
for file_path in DIRECTORY.rglob("*"):
if not file_path.is_file():
continue
if file_path.suffix.lower() not in AUDIO_EXTENSIONS:
continue
relative_path = str(file_path.relative_to(DIRECTORY))
data = relative_path.split('/')
length = len(data)
if data[0] == "_covers":
continue
if length < 2:
print(f"error invalid path: {relative_path}")
continue
filename = data[-1]
name = Path(filename).stem
playlist_name = data[0] if length >= 2 else None
if playlist_name:
preview_path = f"{COVERS}/{playlist_name}/{name}.jpg"
else:
preview_path = f"{COVERS}/{name}.jpg"
path = str(relative_path)
title, date = parse_preview_path(preview_path) # noqa: RUF059
if filename in existing_audios:
continue
playlist = None
if playlist_name:
playlist = cached_playlists.get(playlist_name)
if playlist is None:
playlist = AudioPlaylist(title=playlist_name)
session.add(playlist)
await session.flush()
cached_playlists[playlist_name] = playlist
audio = Audio(
filename=filename,
path=path,
preview_path=preview_path,
name=name,
playlist=playlist
)
audios.append(audio)
existing_audios.add(filename)
if audios:
session.add_all(audios)
await session.commit()
async def main():
await init_db()
await update_yt()
if __name__ == "__main__":
asyncio.run(main())

67
utils/update_tg_photos.py Normal file
View File

@ -0,0 +1,67 @@
import asyncio
from sqlalchemy import select
from ..core.config import BASE_DIR, PHOTOS_DIR
from ..core.db import Base, SessionLocal, engine
from ..models.photo_model import Category, Photo
DIRECTORY = PHOTOS_DIR
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def update_media():
if not DIRECTORY.is_dir():
print (f"{DIRECTORY} not exists")
return
async with SessionLocal() as session:
stmt = select(Photo.filename)
result = await session.execute(stmt)
existing_filenames = set(result.scalars().all())
cached_categories = {ct.title:ct for ct in (await session.execute(select(Category))).scalars()}
new_photos = []
for file_path in DIRECTORY.rglob("*"):
if file_path.is_file():
relative_path = file_path.relative_to(DIRECTORY)
if file_path.suffix.lower() != ".jpg":
continue
if file_path.name in existing_filenames:
continue
category_name = relative_path.parts[0] if len(relative_path.parts) > 1 else None
category = cached_categories.get(category_name)
if category is None:
category = Category(title=category_name)
session.add(category)
await session.flush()
cached_categories[category_name] = category
photo = Photo(
filename = file_path.name,
path = str(file_path.relative_to(BASE_DIR)),
category = category,
)
new_photos.append(photo)
existing_filenames.add(file_path.name)
if new_photos:
session.add_all(new_photos)
await session.commit()
async def main():
await init_db()
await update_media()
if __name__ == "__main__":
asyncio.run(main())

83
utils/update_thumbs.py Normal file
View File

@ -0,0 +1,83 @@
import argparse
import asyncio
from pathlib import Path
from ..core.config import THUMBS_DIR, YT_DIR
async def run (cmd: list[str]) -> tuple[int, bytes]:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_, err = await proc.communicate()
return proc.returncode, err
async def extract_cover(video: Path, out: Path, sem: asyncio.Semaphore) -> bool:
if ".Trash" in str(video) or any(part.startswith('.') for part in video.parts):
return False
# 1. Пропускаем временные файлы и аудио-дорожки yt-dlp
if ".temp." in video.name or ".f" in video.name and video.suffix == ".webm":
return False
async with sem:
out.parent.mkdir(parents=True, exist_ok=True)
if out.exists() and out.stat().st_size > 0:
return True
# 2. Пытаемся взять ВТОРУЮ дорожку (с вопросительным знаком, чтобы не падало)
code, err = await run([
"ffmpeg", "-y", "-loglevel", "error",
"-i", str(video),
"-map", "0:v:1?",
"-c", "copy",
str(out),
])
if code != 0:
# 3. Если второй нет, пытаемся взять ПЕРВУЮ (тоже с вопросительным знаком)
code, err = await run([
"ffmpeg", "-y", "-loglevel", "error",
"-i", str(video),
"-map", "0:v:0?",
"-c", "copy",
str(out),
])
if code != 0:
print(f"FAIL {video.name}: {err.decode()[:300]}")
return False
return True
async def main(workers: int):
SEM = asyncio.Semaphore(workers)
videos = [p for p in YT_DIR.rglob("*") if p.is_file() and p.suffix.lower() in {".mp4", ".mkv", ".webm"}]
print(f"videos: {len(videos)}")
tasks = []
for video in videos:
rel = video.relative_to(YT_DIR)
out = THUMBS_DIR / rel.with_suffix(".jpg")
tasks.append(extract_cover(video, out, SEM))
results = await asyncio.gather(*tasks)
ok = sum(1 for r in results if r)
print(f"done: {ok}/{len(results)}")
def parse_args():
parser = argparse.ArgumentParser(description="Extract video previews")
parser.add_argument(
"-w", "--workers",
type=int,
default=8,
help="number of parallel ffmpeg processes (default: 8)",
)
return parser.parse_args()
if __name__ == "__main__":
args = parse_args()
asyncio.run(main(args.workers))

134
utils/update_yt_videos.py Normal file
View File

@ -0,0 +1,134 @@
import asyncio
import re
from datetime import datetime
from pathlib import Path
from sqlalchemy import select
from ..core.config import BASE_DIR, THUMBS, VIDEO_EXTENSIONS, YT_DIR
from ..core.db import Base, SessionLocal, engine
from ..models.video_model import Channel, Playlist, Video
def parse_preview_path(path: str) -> tuple[str, datetime | None]:
p = Path(path)
stem = p.stem
match = re.match(r"^(\d{8})\s*-\s*(.+)$", stem)
if not match:
return stem, None
date_str, title = match.group(1), match.group(2).strip()
try:
date = datetime.strptime(date_str, "%Y%m%d")
except ValueError:
return stem, None
return title, date
DIRECTORY = BASE_DIR / YT_DIR
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def update_yt():
async with SessionLocal() as session:
cached_channels = {ch.name:ch for ch in (await session.execute(select(Channel))).scalars()}
cached_playlists = {(pl.channel_id, pl.name): pl for pl in (await session.execute(select(Playlist))).scalars()}
existing_videos: set[tuple[int, str]] = set(
(await session.execute(select(Video.channel_id, Video.filename))).all()
)
videos = []
for file_path in DIRECTORY.rglob("*"):
if file_path.is_file():
relative_path = str(file_path.relative_to(DIRECTORY))
data = relative_path.split('/')
length = len(data)
if file_path.suffix.lower() not in VIDEO_EXTENSIONS:
continue
if length < 2:
print ("error invalid channel")
continue
elif length == 2:
if data[0] == "_thumbs":
continue
channel_name = data[0]
filename = data[1]
name = Path(filename).stem
playlist_name = None
preview_path = f"{THUMBS}/{channel_name}/{name}.jpg"
path = str(relative_path)
title, date = parse_preview_path(preview_path)
elif length == 3:
if data[0] == "_thumbs":
continue
channel_name = data[0]
filename = data[2]
name = Path(filename).stem
playlist_name = data[1]
preview_path = f"{THUMBS}/{channel_name}/{playlist_name}/{name}.jpg"
path = str(relative_path)
title, date = parse_preview_path(preview_path)
else:
print(f"skip (too deep): {relative_path}")
channel = cached_channels.get(channel_name)
if channel is None:
channel = Channel(name=channel_name)
session.add(channel)
await session.flush()
cached_channels[channel_name] = channel
if not playlist_name is None:
playlist = cached_playlists.get((channel.id, playlist_name))
if playlist is None:
playlist = Playlist(name=playlist_name, channel=channel)
session.add(playlist)
await session.flush()
cached_playlists[(channel.id, playlist_name)] = playlist
vkey = (channel.id, filename)
if vkey in existing_videos:
continue
video = Video(filename=filename, path=path, preview_path=preview_path,
playlist=playlist, channel=channel, title=title, date=date)
videos.append(video)
existing_videos.add(vkey)
else:
video = Video(filename=filename, path=path, preview_path=preview_path,
playlist=None, channel=channel, title=title, date=date)
videos.append(video)
if videos:
session.add_all(videos)
await session.commit()
async def main():
await init_db()
await update_yt()
if __name__ == "__main__":
asyncio.run(main())