commit ca2a68e37d9e32b5301b3bcf78de84bf69356b09 Author: ndmg Date: Sat Nov 7 11:40:36 2026 +0000 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a58c36 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +venv +__pycache__ +.env +lib +bin +include +*.cfg +coockies.txt +media +audio +yt +channels.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/authx.py b/core/authx.py new file mode 100644 index 0000000..34e5b51 --- /dev/null +++ b/core/authx.py @@ -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) \ No newline at end of file diff --git a/core/config.py b/core/config.py new file mode 100644 index 0000000..77c8ef9 --- /dev/null +++ b/core/config.py @@ -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() \ No newline at end of file diff --git a/core/db.py b/core/db.py new file mode 100644 index 0000000..d217857 --- /dev/null +++ b/core/db.py @@ -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 diff --git a/core/security.py b/core/security.py new file mode 100644 index 0000000..3c03c42 --- /dev/null +++ b/core/security.py @@ -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) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c2cbe21 --- /dev/null +++ b/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..5f17640 --- /dev/null +++ b/main.py @@ -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) + diff --git a/models/audio_model.py b/models/audio_model.py new file mode 100644 index 0000000..6e777c8 --- /dev/null +++ b/models/audio_model.py @@ -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") \ No newline at end of file diff --git a/models/photo_model.py b/models/photo_model.py new file mode 100644 index 0000000..68d01aa --- /dev/null +++ b/models/photo_model.py @@ -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") diff --git a/models/users.py b/models/users.py new file mode 100644 index 0000000..83d3e60 --- /dev/null +++ b/models/users.py @@ -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() \ No newline at end of file diff --git a/models/video_model.py b/models/video_model.py new file mode 100644 index 0000000..bd66089 --- /dev/null +++ b/models/video_model.py @@ -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) diff --git a/requriments.txt b/requriments.txt new file mode 100644 index 0000000..c1c3f24 --- /dev/null +++ b/requriments.txt @@ -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 diff --git a/routers/audio.py b/routers/audio.py new file mode 100644 index 0000000..9f415ea --- /dev/null +++ b/routers/audio.py @@ -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 + ] + } \ No newline at end of file diff --git a/routers/auth.py b/routers/auth.py new file mode 100644 index 0000000..336b75e --- /dev/null +++ b/routers/auth.py @@ -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"} diff --git a/routers/channel.py b/routers/channel.py new file mode 100644 index 0000000..dc180e8 --- /dev/null +++ b/routers/channel.py @@ -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), + } diff --git a/routers/photo.py b/routers/photo.py new file mode 100644 index 0000000..9454225 --- /dev/null +++ b/routers/photo.py @@ -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} diff --git a/routers/playlist.py b/routers/playlist.py new file mode 100644 index 0000000..b345db5 --- /dev/null +++ b/routers/playlist.py @@ -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), + } diff --git a/routers/search.py b/routers/search.py new file mode 100644 index 0000000..9e80117 --- /dev/null +++ b/routers/search.py @@ -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": "", + "highlightPostTag": "", + } + 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"], + } \ No newline at end of file diff --git a/routers/video.py b/routers/video.py new file mode 100644 index 0000000..896b2c6 --- /dev/null +++ b/routers/video.py @@ -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 diff --git a/schemas/audio_schema.py b/schemas/audio_schema.py new file mode 100644 index 0000000..70fbd69 --- /dev/null +++ b/schemas/audio_schema.py @@ -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] \ No newline at end of file diff --git a/schemas/channel_schemas.py b/schemas/channel_schemas.py new file mode 100644 index 0000000..a2babe5 --- /dev/null +++ b/schemas/channel_schemas.py @@ -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 \ No newline at end of file diff --git a/schemas/photo_schemas.py b/schemas/photo_schemas.py new file mode 100644 index 0000000..a744e09 --- /dev/null +++ b/schemas/photo_schemas.py @@ -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 \ No newline at end of file diff --git a/schemas/playlist_schema.py b/schemas/playlist_schema.py new file mode 100644 index 0000000..ba0f4cf --- /dev/null +++ b/schemas/playlist_schema.py @@ -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 \ No newline at end of file diff --git a/schemas/user_schema.py b/schemas/user_schema.py new file mode 100644 index 0000000..6cf1ca7 --- /dev/null +++ b/schemas/user_schema.py @@ -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 \ No newline at end of file diff --git a/schemas/video_schemas.py b/schemas/video_schemas.py new file mode 100644 index 0000000..061f04d --- /dev/null +++ b/schemas/video_schemas.py @@ -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 \ No newline at end of file diff --git a/search/documents.py b/search/documents.py new file mode 100644 index 0000000..06b92dd --- /dev/null +++ b/search/documents.py @@ -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, + } \ No newline at end of file diff --git a/search/reindex.py b/search/reindex.py new file mode 100644 index 0000000..d14bf2b --- /dev/null +++ b/search/reindex.py @@ -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()) \ No newline at end of file diff --git a/search/search_client.py b/search/search_client.py new file mode 100644 index 0000000..4a17347 --- /dev/null +++ b/search/search_client.py @@ -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) \ No newline at end of file diff --git a/search/setup.py b/search/setup.py new file mode 100644 index 0000000..58ef01f --- /dev/null +++ b/search/setup.py @@ -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", "джаваскрипт"], + }) \ No newline at end of file diff --git a/services/videos.py b/services/videos.py new file mode 100644 index 0000000..bb73e43 --- /dev/null +++ b/services/videos.py @@ -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() \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..bf64aaf --- /dev/null +++ b/static/css/style.css @@ -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%; +} \ No newline at end of file diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/static/fonts/RobotoMono-Bold.ttf b/static/fonts/RobotoMono-Bold.ttf new file mode 100644 index 0000000..bef439f Binary files /dev/null and b/static/fonts/RobotoMono-Bold.ttf differ diff --git a/templates/channel_detail.html b/templates/channel_detail.html new file mode 100644 index 0000000..62e25db --- /dev/null +++ b/templates/channel_detail.html @@ -0,0 +1,39 @@ + + + + + + + Document + + +
+ {% for playlist in playlists %} + +
+
+ +
+

{{playlist.name}}

+
+
+ {% endfor %} +
+ {% for video in videos %} + +
+
+ +
+ {% if video.title %} +

{{video.title}}

+ {{video.date}} + {% else %} +

{{video.filename}}

+ {% endif %} +
+
+ {% endfor %} +
+ + \ No newline at end of file diff --git a/templates/channels.html b/templates/channels.html new file mode 100644 index 0000000..d06d27a --- /dev/null +++ b/templates/channels.html @@ -0,0 +1,21 @@ + + + + + + + Document + + +
+ {% for channel_id, name, video_count in channels %} + +
+

{{name}}

+

videos: {{video_count}}

+
+
+ {% endfor %} +
+ + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..5f85549 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,23 @@ + + + + + + + + + Document + + +
+ {% for photo in photos %} +
+ +
+ {% endfor %} + + page-1 + page+1 +
+ + \ No newline at end of file diff --git a/templates/playlist.html b/templates/playlist.html new file mode 100644 index 0000000..61eb551 --- /dev/null +++ b/templates/playlist.html @@ -0,0 +1,25 @@ + + + + + + + Document + + +
+

count: {{video_count}}

+ {% for video in videos %} + +
+
+ +
+

{{video.filename}}

+
+
+ + {% endfor %} +
+ + \ No newline at end of file diff --git a/templates/video_detail.html b/templates/video_detail.html new file mode 100644 index 0000000..2b565e6 --- /dev/null +++ b/templates/video_detail.html @@ -0,0 +1,16 @@ + + + + + + + Document + + +
+ +
+ + \ No newline at end of file diff --git a/templates/videos.html b/templates/videos.html new file mode 100644 index 0000000..f120fd0 --- /dev/null +++ b/templates/videos.html @@ -0,0 +1,29 @@ + + + + + + + Document + + +
+ {% for video in videos %} + +
+
+ +
+ {% if video.title %} +

{{video.title}}

+ {{video.date}} + {% else %} +

{{video.filename}}

+ {% endif %} +
+
+ + {% endfor %} +
+ + \ No newline at end of file diff --git a/utils/cookies.txt b/utils/cookies.txt new file mode 100644 index 0000000..5a0e48b --- /dev/null +++ b/utils/cookies.txt @@ -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 diff --git a/utils/downloader.py b/utils/downloader.py new file mode 100644 index 0000000..b75d4f7 --- /dev/null +++ b/utils/downloader.py @@ -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()) diff --git a/utils/sync_yt.py b/utils/sync_yt.py new file mode 100644 index 0000000..cb31364 --- /dev/null +++ b/utils/sync_yt.py @@ -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() \ No newline at end of file diff --git a/utils/update_audio.py b/utils/update_audio.py new file mode 100644 index 0000000..5c6cf71 --- /dev/null +++ b/utils/update_audio.py @@ -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()) \ No newline at end of file diff --git a/utils/update_tg_photos.py b/utils/update_tg_photos.py new file mode 100644 index 0000000..f8d939d --- /dev/null +++ b/utils/update_tg_photos.py @@ -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()) \ No newline at end of file diff --git a/utils/update_thumbs.py b/utils/update_thumbs.py new file mode 100644 index 0000000..858aa06 --- /dev/null +++ b/utils/update_thumbs.py @@ -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)) \ No newline at end of file diff --git a/utils/update_yt_videos.py b/utils/update_yt_videos.py new file mode 100644 index 0000000..6fe2921 --- /dev/null +++ b/utils/update_yt_videos.py @@ -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()) \ No newline at end of file