From ca2a68e37d9e32b5301b3bcf78de84bf69356b09 Mon Sep 17 00:00:00 2001 From: ndmg Date: Sat, 7 Nov 2026 11:40:36 +0000 Subject: [PATCH] first commit --- .gitignore | 12 ++ README.md | 0 core/__init__.py | 0 core/authx.py | 40 +++++++ core/config.py | 42 +++++++ core/db.py | 26 ++++ core/security.py | 11 ++ docker-compose.yml | 25 ++++ main.py | 62 ++++++++++ models/audio_model.py | 24 ++++ models/photo_model.py | 23 ++++ models/users.py | 13 ++ models/video_model.py | 46 ++++++++ requriments.txt | 72 +++++++++++ routers/audio.py | 62 ++++++++++ routers/auth.py | 82 +++++++++++++ routers/channel.py | 85 +++++++++++++ routers/photo.py | 76 ++++++++++++ routers/playlist.py | 36 ++++++ routers/search.py | 71 +++++++++++ routers/video.py | 57 +++++++++ schemas/audio_schema.py | 48 ++++++++ schemas/channel_schemas.py | 48 ++++++++ schemas/photo_schemas.py | 58 +++++++++ schemas/playlist_schema.py | 37 ++++++ schemas/user_schema.py | 27 +++++ schemas/video_schemas.py | 63 ++++++++++ search/documents.py | 19 +++ search/reindex.py | 44 +++++++ search/search_client.py | 10 ++ search/setup.py | 40 +++++++ services/videos.py | 15 +++ static/css/style.css | 21 ++++ static/favicon.ico | 0 static/fonts/RobotoMono-Bold.ttf | Bin 0 -> 87696 bytes templates/channel_detail.html | 39 ++++++ templates/channels.html | 21 ++++ templates/index.html | 23 ++++ templates/playlist.html | 25 ++++ templates/video_detail.html | 16 +++ templates/videos.html | 29 +++++ utils/cookies.txt | 197 +++++++++++++++++++++++++++++++ utils/downloader.py | 71 +++++++++++ utils/sync_yt.py | 119 +++++++++++++++++++ utils/update_audio.py | 114 ++++++++++++++++++ utils/update_tg_photos.py | 67 +++++++++++ utils/update_thumbs.py | 83 +++++++++++++ utils/update_yt_videos.py | 134 +++++++++++++++++++++ 48 files changed, 2233 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 core/__init__.py create mode 100644 core/authx.py create mode 100644 core/config.py create mode 100644 core/db.py create mode 100644 core/security.py create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100644 models/audio_model.py create mode 100644 models/photo_model.py create mode 100644 models/users.py create mode 100644 models/video_model.py create mode 100644 requriments.txt create mode 100644 routers/audio.py create mode 100644 routers/auth.py create mode 100644 routers/channel.py create mode 100644 routers/photo.py create mode 100644 routers/playlist.py create mode 100644 routers/search.py create mode 100644 routers/video.py create mode 100644 schemas/audio_schema.py create mode 100644 schemas/channel_schemas.py create mode 100644 schemas/photo_schemas.py create mode 100644 schemas/playlist_schema.py create mode 100644 schemas/user_schema.py create mode 100644 schemas/video_schemas.py create mode 100644 search/documents.py create mode 100644 search/reindex.py create mode 100644 search/search_client.py create mode 100644 search/setup.py create mode 100644 services/videos.py create mode 100644 static/css/style.css create mode 100644 static/favicon.ico create mode 100644 static/fonts/RobotoMono-Bold.ttf create mode 100644 templates/channel_detail.html create mode 100644 templates/channels.html create mode 100644 templates/index.html create mode 100644 templates/playlist.html create mode 100644 templates/video_detail.html create mode 100644 templates/videos.html create mode 100644 utils/cookies.txt create mode 100644 utils/downloader.py create mode 100644 utils/sync_yt.py create mode 100644 utils/update_audio.py create mode 100644 utils/update_tg_photos.py create mode 100644 utils/update_thumbs.py create mode 100644 utils/update_yt_videos.py 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 0000000000000000000000000000000000000000..bef439f3b9a884d4377c193ea52f104e820afc98 GIT binary patch literal 87696 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 Z?uN)pxi0>?VH-^Ty3==XU7v${{}1B`zwrP7 literal 0 HcmV?d00001 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 + + + + + \ 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 + + + + + \ 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