first commit
This commit is contained in:
commit
ca2a68e37d
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
venv
|
||||
__pycache__
|
||||
.env
|
||||
lib
|
||||
bin
|
||||
include
|
||||
*.cfg
|
||||
coockies.txt
|
||||
media
|
||||
audio
|
||||
yt
|
||||
channels.json
|
||||
0
core/__init__.py
Normal file
0
core/__init__.py
Normal file
40
core/authx.py
Normal file
40
core/authx.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# core/authx.py
|
||||
from authx import AuthX, AuthXConfig
|
||||
|
||||
from .config import settings
|
||||
|
||||
config = AuthXConfig(
|
||||
JWT_SECRET_KEY=settings.SECRET_KEY,
|
||||
JWT_ALGORITHM=settings.ALGORITHM,
|
||||
|
||||
# откуда берём токен
|
||||
JWT_TOKEN_LOCATION=["cookies"],
|
||||
|
||||
# access
|
||||
JWT_ACCESS_TOKEN_EXPIRES=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
JWT_ACCESS_COOKIE_NAME="access_token",
|
||||
JWT_ACCESS_COOKIE_PATH="/",
|
||||
JWT_ACCESS_CSRF_HEADER_NAME="X-CSRF-Token",
|
||||
JWT_ACCESS_CSRF_COOKIE_NAME="csrf_access_token",
|
||||
JWT_ACCESS_CSRF_COOKIE_PATH="/",
|
||||
|
||||
# refresh
|
||||
JWT_REFRESH_TOKEN_EXPIRES=settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60,
|
||||
JWT_REFRESH_COOKIE_NAME="refresh_token",
|
||||
JWT_REFRESH_COOKIE_PATH="/auth/refresh",
|
||||
JWT_REFRESH_CSRF_HEADER_NAME="X-CSRF-Token",
|
||||
JWT_REFRESH_CSRF_COOKIE_NAME="csrf_refresh_token",
|
||||
JWT_REFRESH_CSRF_COOKIE_PATH="/auth/refresh",
|
||||
|
||||
# cookies
|
||||
JWT_COOKIE_SECURE=settings.COOKIE_SECURE,
|
||||
JWT_COOKIE_SAMESITE="lax",
|
||||
JWT_COOKIE_HTTP_ONLY=True,
|
||||
|
||||
# CSRF
|
||||
JWT_COOKIE_CSRF_PROTECT=True,
|
||||
JWT_CSRF_IN_COOKIES=True,
|
||||
JWT_CSRF_METHODS=["POST", "PUT", "PATCH", "DELETE"],
|
||||
)
|
||||
|
||||
auth = AuthX(config=config)
|
||||
42
core/config.py
Normal file
42
core/config.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent # RSS/
|
||||
|
||||
# ---- видео (YouTube) ----
|
||||
YT_DIR_NAME = "yt"
|
||||
YT_DIR = BASE_DIR / "media" / YT_DIR_NAME # RSS/media/
|
||||
THUMBS = "_thumbs"
|
||||
THUMBS_DIR = YT_DIR / THUMBS # RSS/media/yt/_thumbs
|
||||
|
||||
# ---- фото (если /photos отдаёт RSS/media/media) ----
|
||||
PHOTOS_DIR = BASE_DIR / "media" / "media" # RSS/media/media
|
||||
PHOTOS_DIR_RESPNS = BASE_DIR / "media"
|
||||
|
||||
# ---- шаблоны, статика ----
|
||||
TEMPLATES_DIR = BASE_DIR / "templates"
|
||||
STATIC_DIR = BASE_DIR / "static"
|
||||
|
||||
# ---- расширения видео ----
|
||||
VIDEO_EXTENSIONS = {".mp4", ".mkv", ".webm", ".avi", ".mov", ".flv", ".m4v"}
|
||||
|
||||
AUDIO_EXTENSIONS = {".mp3"}
|
||||
AUDIO_DIR = BASE_DIR / "media" / "audio"
|
||||
COVERS = "_covers"
|
||||
COVERS_DIR = AUDIO_DIR / COVERS
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
SECRET_KEY: str = "CHANGE_ME_TO_RANDOM_STRING"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
COOKIE_SECURE: bool = False # True на продакшене (HTTPS)
|
||||
COOKIE_SAMESITE: str = "lax"
|
||||
COOKIE_DOMAIN: str | None = None
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
settings = Settings()
|
||||
26
core/db.py
Normal file
26
core/db.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
DB_URL = "postgresql+psycopg://ndmg:marselina5$@127.0.0.1:5432/rssdb"
|
||||
|
||||
engine = create_async_engine(DB_URL, echo=True)
|
||||
|
||||
SessionLocal = async_sessionmaker(
|
||||
bind=engine,
|
||||
expire_on_commit=False,
|
||||
class_=AsyncSession,
|
||||
)
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
11
core/security.py
Normal file
11
core/security.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from pwdlib import PasswordHash
|
||||
|
||||
password_hash = PasswordHash.recommended()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return password_hash.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return password_hash.verify(plain, hashed)
|
||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
services:
|
||||
db:
|
||||
image: postgres:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_DB=rssdb
|
||||
- POSTGRES_USER=ndmg
|
||||
- POSTGRES_PASSWORD=marselina5$
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
volumes:
|
||||
- data:/var/lib/postgresql/data
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.10
|
||||
ports: ["7700:7700"]
|
||||
environment:
|
||||
MEILI_MASTER_KEY: "super-secret-master-key-change-me"
|
||||
MEILI_ENV: "development" # production требует master key
|
||||
MEILI_NO_ANALYTICS: "true"
|
||||
volumes: ["meili_data:/meili_data"]
|
||||
|
||||
volumes:
|
||||
data:
|
||||
meili_data:
|
||||
62
main.py
Normal file
62
main.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .core.config import (
|
||||
AUDIO_DIR,
|
||||
COVERS_DIR,
|
||||
PHOTOS_DIR_RESPNS,
|
||||
STATIC_DIR,
|
||||
THUMBS_DIR,
|
||||
YT_DIR,
|
||||
)
|
||||
from .core.db import Base, engine
|
||||
from .routers import audio, auth, channel, photo, playlist, search, video
|
||||
from .search.setup import setup_videos_index
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
setup_videos_index()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
await engine.dispose()
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:5173",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
app.mount("/yt", StaticFiles(directory=YT_DIR), name="yt")
|
||||
app.mount("/media", StaticFiles(directory=PHOTOS_DIR_RESPNS), name="media")
|
||||
app.mount("/_thumbs", StaticFiles(directory=THUMBS_DIR), name="_thumbs")
|
||||
app.mount("/_covers", StaticFiles(directory=COVERS_DIR), name="_covers")
|
||||
app.mount("/audio", StaticFiles(directory=AUDIO_DIR), name="audio")
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def favicon():
|
||||
return FileResponse(STATIC_DIR / "favicon.ico")
|
||||
|
||||
|
||||
app.include_router(photo.router)
|
||||
app.include_router(video.router)
|
||||
app.include_router(playlist.router)
|
||||
app.include_router(channel.router)
|
||||
app.include_router(audio.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(search.router)
|
||||
|
||||
24
models/audio_model.py
Normal file
24
models/audio_model.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ..core.db import Base
|
||||
|
||||
|
||||
class AudioPlaylist(Base):
|
||||
__tablename__ = "audioplaylists"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(64))
|
||||
|
||||
audios: Mapped[list["Audio"]] = relationship(back_populates="playlist")
|
||||
|
||||
|
||||
class Audio(Base):
|
||||
__tablename__ = "audios"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
filename: Mapped[str] = mapped_column(String(255), unique=True)
|
||||
path: Mapped[str] = mapped_column(String(512))
|
||||
preview_path: Mapped[str] = mapped_column(String(256))
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
|
||||
playlist_id: Mapped[int | None] = mapped_column(ForeignKey("audioplaylists.id"))
|
||||
playlist: Mapped[AudioPlaylist | None] = relationship(back_populates="audios")
|
||||
23
models/photo_model.py
Normal file
23
models/photo_model.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ..core.db import Base
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(64))
|
||||
|
||||
photos: Mapped[list[Photo]] = relationship(back_populates="category") # noqa: F821
|
||||
|
||||
|
||||
class Photo(Base):
|
||||
__tablename__ = "photos"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
filename: Mapped[str] = mapped_column(String(255), unique=True)
|
||||
path: Mapped[str] = mapped_column(String(512))
|
||||
|
||||
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"))
|
||||
category: Mapped[Category] = relationship(back_populates="photos")
|
||||
13
models/users.py
Normal file
13
models/users.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from sqlalchemy import Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from ..core.db import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
username: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255))
|
||||
isAdmin: Mapped[bool] = mapped_column()
|
||||
46
models/video_model.py
Normal file
46
models/video_model.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from datetime import date
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ..core.db import Base
|
||||
|
||||
|
||||
class Channel(Base):
|
||||
__tablename__ = "channels"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
playlists: Mapped[list["Playlist"]] = relationship(back_populates="channel")
|
||||
videos: Mapped[list["Video"]] = relationship(back_populates="channel")
|
||||
|
||||
|
||||
class Playlist(Base):
|
||||
__tablename__ = "playlists"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
|
||||
channel_id: Mapped[int] = mapped_column(ForeignKey("channels.id"))
|
||||
channel: Mapped[Channel] = relationship(back_populates="playlists")
|
||||
|
||||
videos: Mapped[list["Video"]] = relationship(back_populates="playlist")
|
||||
|
||||
|
||||
class Video(Base):
|
||||
__tablename__ = "videos"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("channel_id", "filename", name="uq_video_channel_filename"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
path: Mapped[str] = mapped_column(String(1024))
|
||||
preview_path: Mapped[str] = mapped_column(String(1024))
|
||||
|
||||
playlist_id: Mapped[int | None] = mapped_column(ForeignKey("playlists.id"))
|
||||
playlist: Mapped[Playlist | None] = relationship(back_populates="videos")
|
||||
|
||||
channel_id: Mapped[int] = mapped_column(ForeignKey("channels.id"))
|
||||
channel: Mapped[Channel] = relationship(back_populates="videos")
|
||||
|
||||
title: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
72
requriments.txt
Normal file
72
requriments.txt
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
alembic==1.20.1
|
||||
annotated-doc==0.0.5
|
||||
annotated-types==0.8.0
|
||||
anyio==4.15.1
|
||||
argon2-cffi==25.1.0
|
||||
argon2-cffi-bindings==26.1.0
|
||||
asyncio==4.0.0
|
||||
authx==1.7.1
|
||||
authx_extra==1.2.0
|
||||
bcrypt==5.0.0
|
||||
camel-converter==6.0.0
|
||||
certifi==2026.10.23
|
||||
cffi==2.1.1
|
||||
charset-normalizer==3.5.2
|
||||
click==8.5.0
|
||||
cryptography==50.0.2
|
||||
dnspython==2.9.2
|
||||
ecdsa==0.19.2
|
||||
email-validator==2.3.0
|
||||
fastapi==0.162.0
|
||||
fastapi-users==15.0.5
|
||||
fastapi-users-db-sqlalchemy==7.0.0
|
||||
greenlet==3.5.6
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
httpx-oauth==0.17.0
|
||||
idna==3.20
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
makefun==1.16.0
|
||||
Mako==1.4.3
|
||||
MarkupSafe==3.0.4
|
||||
meilisearch==0.43.0
|
||||
numpy==2.5.4
|
||||
opentelemetry-api==1.45.2
|
||||
pandas==3.1.0
|
||||
passlib==1.7.4
|
||||
prometheus_client==0.26.0
|
||||
psycopg==3.3.6
|
||||
psycopg-binary==3.3.6
|
||||
psycopg-pool==3.3.3
|
||||
pwdlib==0.3.0
|
||||
pyaes==1.6.1
|
||||
pyasn1==0.6.5
|
||||
pycopg==1.2.0
|
||||
pycparser==3.12
|
||||
pydantic==2.14.2
|
||||
pydantic-settings==2.16.2
|
||||
pydantic_core==2.50.2
|
||||
pyinstrument==5.0.3
|
||||
PyJWT==2.15.1
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.2.4
|
||||
python-jose==3.5.0
|
||||
python-multipart==0.0.33
|
||||
pytz==2026.6
|
||||
qrcode==8.2
|
||||
redis==5.2.1
|
||||
requests==2.34.2
|
||||
rsa==4.9.1
|
||||
six==1.17.0
|
||||
SQLAlchemy==2.0.57
|
||||
starlette==1.10.0
|
||||
Telethon==1.45.0
|
||||
tenacity==9.2.1
|
||||
typing-inspection==0.4.4
|
||||
typing_extensions==4.16.0
|
||||
tzdata==2026.6
|
||||
urllib3==2.8.0
|
||||
uvicorn==0.54.0
|
||||
yt-dlp==2026.10.26
|
||||
62
routers/audio.py
Normal file
62
routers/audio.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.exceptions import HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..core.authx import auth
|
||||
from ..core.db import get_db
|
||||
from ..models.audio_model import Audio, AudioPlaylist
|
||||
from ..schemas.audio_schema import Audios, AudioSchema, Playlists
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/audio", response_model=Audios, dependencies=[Depends(auth.access_token_required)])
|
||||
async def audio(session: AsyncSession = Depends(get_db), id: int = Query(...), page: int = Query(1, ge=1), # noqa: B008
|
||||
per_page: int = Query(24, ge=1)):
|
||||
|
||||
offset = (page - 1) * per_page
|
||||
query = select(Audio).where(Audio.playlist_id == id).order_by(func.random()).offset(offset).limit(per_page)
|
||||
result = await session.execute(query)
|
||||
|
||||
audio = result.scalars().all()
|
||||
|
||||
return {"audios": audio}
|
||||
|
||||
@router.get("/audio_file", response_model=AudioSchema)
|
||||
async def audio_file(session: AsyncSession = Depends(get_db), id: int = Query(...)): # noqa: B008
|
||||
query = select(Audio).where(Audio.id==id)
|
||||
result = await session.execute(query)
|
||||
audio = result.scalar()
|
||||
|
||||
if audio is None:
|
||||
raise HTTPException(status_code=404, detail="Audio not found")
|
||||
|
||||
return { "id": audio.id, "path": audio.path, "name": audio.name, "filename": audio.filename, "preview_path": audio.preview_path }
|
||||
|
||||
@router.get('/audioplaylists', response_model=Playlists, dependencies=[Depends(auth.access_token_required)])
|
||||
async def audioplaylists(session: AsyncSession = Depends(get_db)): # noqa: B008
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
AudioPlaylist.id,
|
||||
AudioPlaylist.title,
|
||||
func.count(Audio.id).label("audios_count")
|
||||
)
|
||||
.outerjoin(Audio, Audio.playlist_id == AudioPlaylist.id)
|
||||
.group_by(AudioPlaylist.id, AudioPlaylist.title)
|
||||
.order_by(AudioPlaylist.title)
|
||||
)
|
||||
|
||||
rows = (await session.execute(stmt)).all()
|
||||
return {
|
||||
"playlists": [
|
||||
{
|
||||
"id": r.id,
|
||||
"title": r.title,
|
||||
"audios_count": r.audios_count
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
}
|
||||
82
routers/auth.py
Normal file
82
routers/auth.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..core.authx import auth
|
||||
from ..core.db import get_db
|
||||
from ..core.security import hash_password, verify_password
|
||||
from ..models.users import User
|
||||
from ..schemas.user_schema import UserCreate, UserLogin, UserRead
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserRead, status_code=201)
|
||||
async def register(
|
||||
data: UserCreate,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db), # noqa: B008
|
||||
):
|
||||
|
||||
existing = await db.execute(select(User).where(User.username == data.username))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(400, "Username already taken")
|
||||
|
||||
user = User(
|
||||
username=data.username,
|
||||
hashed_password=hash_password(data.password),
|
||||
isAdmin=False,
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
access = auth.create_access_token(uid=user.username)
|
||||
refresh = auth.create_refresh_token(uid=user.username)
|
||||
auth.set_access_cookies(access, response)
|
||||
auth.set_refresh_cookies(refresh, response)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=UserRead)
|
||||
async def login(
|
||||
data: UserLogin,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db), # noqa: B008
|
||||
):
|
||||
result = await db.execute(select(User).where(User.username == data.username))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not verify_password(data.password, user.hashed_password):
|
||||
raise HTTPException(401, "Invalid credentials")
|
||||
|
||||
access = auth.create_access_token(uid=user.username)
|
||||
refresh = auth.create_refresh_token(uid=user.username)
|
||||
auth.set_access_cookies(access, response)
|
||||
auth.set_refresh_cookies(refresh, response)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh(
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db), # noqa: B008
|
||||
):
|
||||
payload = await auth.get_refresh_token_from_request(request)
|
||||
|
||||
result = await db.execute(select(User).where(User.username == payload.sub))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(401, "User not found")
|
||||
|
||||
new_access = auth.create_access_token(uid=user.username)
|
||||
auth.set_access_cookies(new_access, response)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(response: Response):
|
||||
auth.unset_cookies(response)
|
||||
return {"status": "ok"}
|
||||
85
routers/channel.py
Normal file
85
routers/channel.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..core.authx import auth
|
||||
from ..core.config import TEMPLATES_DIR
|
||||
from ..core.db import get_db
|
||||
from ..models.video_model import Channel, Playlist, Video
|
||||
from ..schemas.channel_schemas import ChannelOut, ChannelsOut
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory=TEMPLATES_DIR)
|
||||
|
||||
|
||||
@router.get("/channels", response_model=ChannelsOut, dependencies=[Depends(auth.access_token_required)])
|
||||
async def channels(session: AsyncSession = Depends(get_db)): # noqa: B008
|
||||
|
||||
stmt = (
|
||||
select(Channel.id, Channel.name, func.count(Video.id).label("video_count"))
|
||||
.outerjoin(Channel.videos)
|
||||
.group_by(Channel.id, Channel.name)
|
||||
.order_by(Channel.name)
|
||||
)
|
||||
rows = (await session.execute(stmt)).all()
|
||||
|
||||
return {
|
||||
"channels": [
|
||||
{"id": r.id, "name": r.name, "videos_count": r.video_count} for r in rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/channel", response_model=ChannelOut, dependencies=[Depends(auth.access_token_required)])
|
||||
async def channel(
|
||||
session: AsyncSession = Depends(get_db), # noqa: B008
|
||||
id: int = Query(...),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(24, ge=1),
|
||||
):
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
query = (
|
||||
select(Video)
|
||||
.join(Channel, Channel.id == Video.channel_id)
|
||||
.where(Channel.id == id, Video.playlist == None)
|
||||
.order_by(Video.id)
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
)
|
||||
videos = (await session.execute(query)).scalars().all()
|
||||
videos_count = len(videos)
|
||||
|
||||
channel_name = (
|
||||
await session.execute(select(Channel.name).where(Channel.id == id))
|
||||
).scalar()
|
||||
|
||||
p_query = (
|
||||
select(Playlist, func.min(Video.preview_path).label("preview_path"))
|
||||
.outerjoin(Video, Video.playlist_id == Playlist.id)
|
||||
.where(Playlist.channel_id == id)
|
||||
.group_by(Playlist.id)
|
||||
.order_by(Playlist.id)
|
||||
)
|
||||
playlists_rows = (await session.execute(p_query)).all()
|
||||
|
||||
return {
|
||||
"videos": [
|
||||
{
|
||||
"id": v.id,
|
||||
"title": getattr(v, "title", None),
|
||||
"path": v.path,
|
||||
"preview_path": v.preview_path,
|
||||
"date": v.date,
|
||||
}
|
||||
for v in videos
|
||||
],
|
||||
"channel_name": channel_name,
|
||||
"playlists": [
|
||||
{"id": p.id, "name": p.name, "preview_path": prev}
|
||||
for p, prev in playlists_rows
|
||||
],
|
||||
"videos_count": videos_count,
|
||||
"playlists_count": len(playlists_rows),
|
||||
}
|
||||
76
routers/photo.py
Normal file
76
routers/photo.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..core.authx import auth
|
||||
from ..core.db import get_db
|
||||
from ..models.photo_model import Category, Photo
|
||||
from ..schemas.photo_schemas import CategoriesOut, PhotosOut, PhotosPaginatedOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/photos", response_model=PhotosPaginatedOut, dependencies=[Depends(auth.access_token_required)])
|
||||
async def get_photos(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(24, ge=1),
|
||||
session: AsyncSession = Depends(get_db), # noqa: B008
|
||||
category: int | None = None,
|
||||
):
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
query = select(Photo)
|
||||
if category is not None:
|
||||
query = query.where(Photo.category_id == category)
|
||||
|
||||
query = query.order_by(Photo.id).offset(offset).limit(per_page)
|
||||
|
||||
result = await session.execute(query)
|
||||
photos = result.scalars().all()
|
||||
|
||||
return {
|
||||
"photos": photos,
|
||||
"page_inc": f"/photos?page={page + 1}&per_page={per_page}&category={category}",
|
||||
"page_dec": f"/photos?page={max(1, page - 1)}&per_page={per_page}&category={category}",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/categories", response_model=CategoriesOut, dependencies=[Depends(auth.access_token_required)])
|
||||
async def categories(session: AsyncSession = Depends(get_db)): # noqa: B008
|
||||
stmt = (
|
||||
select(Category.id, Category.title, func.count(Photo.id).label("photos_count"))
|
||||
.select_from(Category)
|
||||
.outerjoin(Category.photos)
|
||||
.group_by(Category.id, Category.title)
|
||||
.order_by(Category.title)
|
||||
)
|
||||
rows = (await session.execute(stmt)).all()
|
||||
|
||||
return {
|
||||
"categories": [
|
||||
{"id": r.id, "title": r.title, "photos_count": r.photos_count} for r in rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/category", response_model=PhotosOut)
|
||||
async def category(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(24, ge=1),
|
||||
session: AsyncSession = Depends(get_db), # noqa: B008
|
||||
id: int = Query(...),
|
||||
):
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
query = (
|
||||
select(Photo)
|
||||
.where(Photo.category_id == id)
|
||||
.order_by(Photo.id)
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
photos = result.scalars().all()
|
||||
|
||||
return {"photos": photos}
|
||||
36
routers/playlist.py
Normal file
36
routers/playlist.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..core.authx import auth
|
||||
from ..core.config import TEMPLATES_DIR
|
||||
from ..core.db import get_db
|
||||
from ..models.video_model import Video
|
||||
from ..schemas.playlist_schema import PlaylistOut
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory=TEMPLATES_DIR)
|
||||
|
||||
|
||||
@router.get("/playlist", response_model=PlaylistOut, dependencies=[Depends(auth.access_token_required)])
|
||||
async def playlist(session: AsyncSession = Depends(get_db), id: int = Query(...)): # noqa: B008
|
||||
query = select(Video).where(Video.playlist_id == id)
|
||||
result = await session.execute(query)
|
||||
|
||||
videos = result.scalars().all()
|
||||
|
||||
return {
|
||||
"videos": [
|
||||
{
|
||||
"id": v.id,
|
||||
"title": getattr(v, "title", None),
|
||||
"path": v.path,
|
||||
"preview_path": v.preview_path,
|
||||
"date": v.date,
|
||||
"channel_id": v.channel_id,
|
||||
}
|
||||
for v in videos
|
||||
],
|
||||
"video_count": len(videos),
|
||||
}
|
||||
71
routers/search.py
Normal file
71
routers/search.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# routers/search.py
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..search.search_client import videos_index
|
||||
|
||||
router = APIRouter(prefix="/search", tags=["search"])
|
||||
|
||||
|
||||
class VideoHit(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
filename: str
|
||||
date: str | None
|
||||
channel_id: int
|
||||
channel_name: str
|
||||
playlist_id: int | None
|
||||
playlist_name: str | None
|
||||
preview_path: str
|
||||
_formatted: dict | None = None
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
query: str
|
||||
total: int
|
||||
took_ms: int
|
||||
items: list[VideoHit]
|
||||
|
||||
|
||||
@router.get("/videos", response_model=SearchResponse)
|
||||
async def search_videos(
|
||||
q: str = Query("", max_length=200, description="Поисковый запрос; пусто = показать всё"),
|
||||
channel_id: int | None = None,
|
||||
playlist_id: int | None = None,
|
||||
date_from: str | None = Query(None, pattern=r"^\d{4}-\d{2}-\d{2}$"),
|
||||
date_to: str | None = Query(None, pattern=r"^\d{4}-\d{2}-\d{2}$"),
|
||||
sort: str | None = Query(None, description="date:desc | date:asc | id:desc"),
|
||||
limit: int = Query(20, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
filters = []
|
||||
if channel_id is not None:
|
||||
filters.append(f"channel_id = {channel_id}")
|
||||
if playlist_id is not None:
|
||||
filters.append(f"playlist_id = {playlist_id}")
|
||||
if date_from:
|
||||
filters.append(f'date >= "{date_from}"')
|
||||
if date_to:
|
||||
filters.append(f'date <= "{date_to}"')
|
||||
|
||||
params = {
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"attributesToHighlight": ["title", "filename"],
|
||||
"highlightPreTag": "<mark>",
|
||||
"highlightPostTag": "</mark>",
|
||||
}
|
||||
if filters:
|
||||
params["filter"] = " AND ".join(filters)
|
||||
if sort:
|
||||
params["sort"] = [sort]
|
||||
|
||||
result = await run_in_threadpool(videos_index().search, q, params)
|
||||
|
||||
return {
|
||||
"query": q,
|
||||
"total": result["estimatedTotalHits"],
|
||||
"took_ms": result["processingTimeMs"],
|
||||
"items": result["hits"],
|
||||
}
|
||||
57
routers/video.py
Normal file
57
routers/video.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.exceptions import HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..core.authx import auth
|
||||
from ..core.db import get_db
|
||||
from ..models.video_model import Channel, Video
|
||||
from ..schemas.video_schemas import VideoDetailOut, VideoOut, VideosResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/videos", response_model=VideosResponse, dependencies=[Depends(auth.access_token_required)])
|
||||
async def videos(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(24, ge=1),
|
||||
session: AsyncSession = Depends(get_db), # noqa: B008
|
||||
):
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
query = (
|
||||
select(Video, Channel.name.label("channel_name"))
|
||||
.join(Channel, Channel.id == Video.channel_id)
|
||||
.order_by(func.random())
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
videos = []
|
||||
for v, channel_name in rows:
|
||||
v_out = VideoOut.model_validate(v)
|
||||
v_out.channel_name = channel_name
|
||||
videos.append(v_out)
|
||||
|
||||
return {
|
||||
"videos": videos,
|
||||
"page_inc": f"/videos?page={page + 1}&per_page={per_page}",
|
||||
"page_dec": f"/videos?page={max(1, page - 1)}&per_page={per_page}",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/video", response_model=VideoDetailOut, dependencies=[Depends(auth.access_token_required)])
|
||||
async def video(id: int = Query(...), session: AsyncSession = Depends(get_db)): # noqa: B008
|
||||
query = select(Video).where(Video.id == id)
|
||||
result = await session.execute(query)
|
||||
video = result.scalar()
|
||||
|
||||
if video is None:
|
||||
raise HTTPException(status_code=404, detail="Video not found")
|
||||
|
||||
return video
|
||||
48
schemas/audio_schema.py
Normal file
48
schemas/audio_schema.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from urllib.parse import quote
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
|
||||
def encode_path(path: str) -> str:
|
||||
if not path:
|
||||
return ""
|
||||
return "/".join(quote(part, safe="") for part in path.split("/"))
|
||||
|
||||
|
||||
class AudioSchema(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
path: str
|
||||
name: str
|
||||
filename: str
|
||||
preview_path: str
|
||||
playlist_id: int | None = None
|
||||
|
||||
@field_validator("path", "preview_path", mode="before")
|
||||
@classmethod
|
||||
def encode_urls(cls, v):
|
||||
return encode_path(v)
|
||||
|
||||
@field_validator("path", mode="after")
|
||||
@classmethod
|
||||
def add_audio_to_url(cls, v):
|
||||
if not v: return v
|
||||
clean_v = v.lstrip('/')
|
||||
if clean_v.startswith('audio/'):
|
||||
return f"{clean_v}"
|
||||
return f"audio/{clean_v}"
|
||||
|
||||
|
||||
|
||||
|
||||
class Audios(BaseModel):
|
||||
audios: list[AudioSchema]
|
||||
|
||||
class Playlist(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
audios_count: int
|
||||
|
||||
class Playlists(BaseModel):
|
||||
playlists: list[Playlist]
|
||||
48
schemas/channel_schemas.py
Normal file
48
schemas/channel_schemas.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from datetime import date as DateType
|
||||
from urllib.parse import quote
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
def encode_path(path: str) -> str:
|
||||
if not path:
|
||||
return ""
|
||||
return "/".join(quote(part, safe="") for part in path.split("/"))
|
||||
|
||||
|
||||
class ChannelItem(BaseModel):
|
||||
|
||||
id: int
|
||||
name: str
|
||||
videos_count: int
|
||||
|
||||
class ChannelsOut(BaseModel):
|
||||
|
||||
channels: list[ChannelItem]
|
||||
|
||||
|
||||
class VideoItem(BaseModel):
|
||||
|
||||
id: int
|
||||
title: str
|
||||
path: str
|
||||
preview_path: str
|
||||
date: DateType | None = None
|
||||
|
||||
@field_validator("path", "preview_path", mode="before")
|
||||
@classmethod
|
||||
def encode_urls(cls, v):
|
||||
return encode_path(v)
|
||||
|
||||
class PlaylistItem(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
preview_path: str
|
||||
|
||||
class ChannelOut(BaseModel):
|
||||
|
||||
videos: list[VideoItem]
|
||||
playlists: list[PlaylistItem]
|
||||
channel_name: str
|
||||
videos_count: int | None = None
|
||||
playlists_count: int | None = None
|
||||
58
schemas/photo_schemas.py
Normal file
58
schemas/photo_schemas.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
|
||||
def encode_path(path: str) -> str:
|
||||
if not path:
|
||||
return ""
|
||||
return "/".join(quote(part, safe="") for part in path.split("/"))
|
||||
|
||||
|
||||
class PhotoItem(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
path: str
|
||||
filename: str
|
||||
category_id: int
|
||||
|
||||
@field_validator("path", mode="before")
|
||||
@classmethod
|
||||
def encode_urls(cls, v):
|
||||
return encode_path(v)
|
||||
|
||||
@field_validator("path", mode="after")
|
||||
@classmethod
|
||||
def add_media(cls, v: str) -> str:
|
||||
if not v: return v
|
||||
clean_v = v.lstrip('/')
|
||||
if clean_v.startswith('media/'): return f"{clean_v}"
|
||||
return f"media/{clean_v}"
|
||||
|
||||
|
||||
class Category(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
title: str
|
||||
photos: list[PhotoItem] = []
|
||||
|
||||
|
||||
class PhotosOut(BaseModel):
|
||||
photos: list[PhotoItem]
|
||||
|
||||
class CategoryItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
photos_count: int
|
||||
|
||||
class CategoriesOut(BaseModel):
|
||||
categories: list[CategoryItem]
|
||||
|
||||
class PhotosPaginatedOut(BaseModel):
|
||||
photos: list[PhotoItem]
|
||||
page_inc: str
|
||||
page_dec: str
|
||||
37
schemas/playlist_schema.py
Normal file
37
schemas/playlist_schema.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from datetime import date as DateType
|
||||
from urllib.parse import quote
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
def encode_path(path: str) -> str:
|
||||
if not path:
|
||||
return ""
|
||||
return "/".join(quote(part, safe="") for part in path.split("/"))
|
||||
|
||||
|
||||
class VideoItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
path: str
|
||||
preview_path: str | None = None
|
||||
date: DateType | None = None
|
||||
channel_id: int
|
||||
|
||||
@field_validator("path", "preview_path", mode="before")
|
||||
@classmethod
|
||||
def encode_urls(cls, v):
|
||||
return encode_path(v)
|
||||
|
||||
@field_validator("path", mode="after")
|
||||
@classmethod
|
||||
def add_yt(cls, v: str) -> str:
|
||||
if not v: return v
|
||||
clean_v = v.lstrip('/')
|
||||
if clean_v.startswith('yt/'): return f"/{clean_v}"
|
||||
return f"yt/{clean_v}"
|
||||
|
||||
|
||||
class PlaylistOut(BaseModel):
|
||||
videos: list[VideoItem]
|
||||
video_count: int
|
||||
27
schemas/user_schema.py
Normal file
27
schemas/user_schema.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=128)
|
||||
password: str = Field(min_length=6, max_length=128)
|
||||
password_confirm: str
|
||||
|
||||
@model_validator(mode="after")
|
||||
def passwords_match(self):
|
||||
if self.password != self.password_confirm:
|
||||
raise ValueError("Passwords do not match")
|
||||
return self
|
||||
|
||||
|
||||
class UserRead(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
isAdmin: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
63
schemas/video_schemas.py
Normal file
63
schemas/video_schemas.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from datetime import date as DateType
|
||||
from urllib.parse import quote
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
|
||||
def encode_path(path: str) -> str:
|
||||
if not path:
|
||||
return ""
|
||||
return "/".join(quote(part, safe="") for part in path.split("/"))
|
||||
|
||||
class VideoOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
path: str
|
||||
preview_path: str
|
||||
channel_id: int
|
||||
title: str | None = None
|
||||
date: DateType | None = None
|
||||
channel_name: str | None = None
|
||||
|
||||
@field_validator("path", "preview_path", mode="before")
|
||||
@classmethod
|
||||
def encode_urls(cls, v):
|
||||
return encode_path(v)
|
||||
|
||||
@field_validator("path", mode="after")
|
||||
@classmethod
|
||||
def add_yt(cls, v: str) -> str:
|
||||
if not v: return v
|
||||
clean_v = v.lstrip('/')
|
||||
if clean_v.startswith('yt/'): return f"/{clean_v}"
|
||||
return f"yt/{clean_v}"
|
||||
|
||||
class VideoDetailOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
path: str
|
||||
preview_path: str
|
||||
title: str | None = None
|
||||
date: DateType | None = None
|
||||
channel_id: int
|
||||
channel_name: str | None = None
|
||||
|
||||
@field_validator("path", "preview_path", mode="before")
|
||||
@classmethod
|
||||
def encode_urls(cls, v):
|
||||
return encode_path(v)
|
||||
|
||||
@field_validator("path", mode="after")
|
||||
@classmethod
|
||||
def add_yt(cls, v: str) -> str:
|
||||
if not v: return v
|
||||
clean_v = v.lstrip('/')
|
||||
if clean_v.startswith('yt/'): return f"/{clean_v}"
|
||||
return f"yt/{clean_v}"
|
||||
|
||||
class VideosResponse(BaseModel):
|
||||
videos: list[VideoOut]
|
||||
page_inc: str
|
||||
page_dec: str
|
||||
19
search/documents.py
Normal file
19
search/documents.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from ..models.video_model import Video
|
||||
|
||||
|
||||
def video_to_document(video: Video) -> dict:
|
||||
"""
|
||||
Требует, чтобы video.channel и video.playlist были загружены
|
||||
(selectinload), иначе будет ленивая загрузка в синхронном контексте → ошибка.
|
||||
"""
|
||||
return {
|
||||
"id": video.id,
|
||||
"title": video.title or video.filename,
|
||||
"filename": video.filename,
|
||||
"date": video.date.isoformat() if video.date else None,
|
||||
"channel_id": video.channel_id,
|
||||
"channel_name": video.channel.name,
|
||||
"playlist_id": video.playlist_id,
|
||||
"playlist_name": video.playlist.name if video.playlist else None,
|
||||
"preview_path": video.preview_path,
|
||||
}
|
||||
44
search/reindex.py
Normal file
44
search/reindex.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# search/reindex.py
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ..core.db import SessionLocal
|
||||
from ..models.video_model import Video
|
||||
from .documents import video_to_document
|
||||
from .search_client import client, videos_index
|
||||
|
||||
BATCH = 1000
|
||||
|
||||
async def full_reindex():
|
||||
async with SessionLocal() as db:
|
||||
stmt = (
|
||||
select(Video)
|
||||
.options(selectinload(Video.channel), selectinload(Video.playlist))
|
||||
.order_by(Video.id)
|
||||
)
|
||||
result = await db.stream(stmt)
|
||||
|
||||
batch: list[dict] = []
|
||||
total = 0
|
||||
async for video in result.scalars():
|
||||
batch.append(video_to_document(video))
|
||||
if len(batch) >= BATCH:
|
||||
await _push(batch)
|
||||
total += len(batch)
|
||||
print(f"Indexed {total}...")
|
||||
batch = []
|
||||
if batch:
|
||||
await _push(batch)
|
||||
total += len(batch)
|
||||
|
||||
print(f"Done. Indexed {total} videos.")
|
||||
|
||||
async def _push(docs: list[dict]):
|
||||
task = videos_index().add_documents(docs)
|
||||
# дожидаемся, чтобы видеть ошибки и гарантировать применение
|
||||
client.wait_for_task(task.task_uid)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(full_reindex())
|
||||
10
search/search_client.py
Normal file
10
search/search_client.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import meilisearch
|
||||
|
||||
MEILI_URL = "http://localhost:7700"
|
||||
MEILI_KEY = "super-secret-master-key-change-me"
|
||||
INDEX = "videos"
|
||||
|
||||
client = meilisearch.Client(MEILI_URL, MEILI_KEY)
|
||||
|
||||
def videos_index():
|
||||
return client.index(INDEX)
|
||||
40
search/setup.py
Normal file
40
search/setup.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from .search_client import INDEX, client, videos_index
|
||||
|
||||
|
||||
def setup_videos_index():
|
||||
try:
|
||||
client.create_index(INDEX, {"primaryKey": "id"})
|
||||
|
||||
except Exception:
|
||||
print(Exception)
|
||||
|
||||
index = videos_index()
|
||||
|
||||
# Порядок = приоритет в ранжировании (attribute rule)
|
||||
index.update_searchable_attributes([
|
||||
"title", "filename", "channel_name", "playlist_name",
|
||||
])
|
||||
|
||||
index.update_filterable_attributes([
|
||||
"channel_id", "playlist_id", "date",
|
||||
])
|
||||
|
||||
index.update_sortable_attributes(["date", "id"])
|
||||
|
||||
index.update_displayed_attributes([
|
||||
"id", "title", "filename", "date",
|
||||
"channel_id", "channel_name",
|
||||
"playlist_id", "playlist_name",
|
||||
"preview_path",
|
||||
])
|
||||
|
||||
# Опционально: свежие выше при прочих равных
|
||||
index.update_ranking_rules([
|
||||
"words", "typo", "proximity", "attribute",
|
||||
"date:desc", "exactness",
|
||||
])
|
||||
|
||||
index.update_synonyms({
|
||||
"python": ["питон", "пайтон"],
|
||||
"js": ["javascript", "джаваскрипт"],
|
||||
})
|
||||
15
services/videos.py
Normal file
15
services/videos.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# services/videos.py
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from models import Video
|
||||
|
||||
|
||||
async def load_video_with_relations(db: AsyncSession, video_id: int) -> Video:
|
||||
stmt = (
|
||||
select(Video)
|
||||
.options(selectinload(Video.channel), selectinload(Video.playlist))
|
||||
.where(Video.id == video_id)
|
||||
)
|
||||
return (await db.execute(stmt)).scalar_one()
|
||||
21
static/css/style.css
Normal file
21
static/css/style.css
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
@font-face {
|
||||
font-family: "Roboto Mono";
|
||||
src: url("/static/fonts/RobotoMono-Bold.ttf");
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: "Roboto Mono";
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.photo > img {
|
||||
width: 100%;
|
||||
}
|
||||
0
static/favicon.ico
Normal file
0
static/favicon.ico
Normal file
BIN
static/fonts/RobotoMono-Bold.ttf
Normal file
BIN
static/fonts/RobotoMono-Bold.ttf
Normal file
Binary file not shown.
39
templates/channel_detail.html
Normal file
39
templates/channel_detail.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
{% for playlist in playlists %}
|
||||
<a href="/playlist?id={{playlist.id}}">
|
||||
<div class="playlist">
|
||||
<div class="preview">
|
||||
|
||||
</div>
|
||||
<h3>{{playlist.name}}</h3>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
<hr>
|
||||
{% for video in videos %}
|
||||
<a href="/video?id={{video.id}}">
|
||||
<div class="video">
|
||||
<div class="preview">
|
||||
<img src="{{video.preview_path}}" alt="">
|
||||
</div>
|
||||
{% if video.title %}
|
||||
<h3>{{video.title}}</h3>
|
||||
<small>{{video.date}}</small>
|
||||
{% else %}
|
||||
<h3>{{video.filename}}</h3>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
21
templates/channels.html
Normal file
21
templates/channels.html
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
{% for channel_id, name, video_count in channels %}
|
||||
<a href="/channel?id={{channel_id}}">
|
||||
<div class="channel">
|
||||
<h3>{{name}}</h3>
|
||||
<p>videos: {{video_count}}</p>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
23
templates/index.html
Normal file
23
templates/index.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
{% for photo in photos %}
|
||||
<div class="photo">
|
||||
<img src="{{photo.path}}" alt="">
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<a href="{{page_dec}}">page-1</a>
|
||||
<a href="{{page_inc}}">page+1</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
25
templates/playlist.html
Normal file
25
templates/playlist.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=, initial-scale=1.0">
|
||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<h2>count: {{video_count}}</h2>
|
||||
{% for video in videos %}
|
||||
<a href="/video?id={{video.id}}">
|
||||
<div class="video">
|
||||
<div class="preview">
|
||||
<img src="{{video.preview_path}}" alt="">
|
||||
</div>
|
||||
<h3>{{video.filename}}</h3>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
16
templates/video_detail.html
Normal file
16
templates/video_detail.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="video">
|
||||
<video controls>
|
||||
<source src="{{path}}" type="video/mp4">
|
||||
</video>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
29
templates/videos.html
Normal file
29
templates/videos.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
{% for video in videos %}
|
||||
<a href="/video?id={{video.id}}">
|
||||
<div class="video">
|
||||
<div class="preview">
|
||||
<img src="{{video.preview_path}}" alt="">
|
||||
</div>
|
||||
{% if video.title %}
|
||||
<h3>{{video.title}}</h3>
|
||||
<small>{{video.date}}</small>
|
||||
{% else %}
|
||||
<h3>{{video.filename}}</h3>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
197
utils/cookies.txt
Normal file
197
utils/cookies.txt
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
# Netscape HTTP Cookie File
|
||||
# This file is generated by yt-dlp. Do not edit.
|
||||
|
||||
.workspace.google.com TRUE /intl/ru/gmail/ FALSE 1827813945 __utma 61317162.519317584.1793244578.1793244578.1793253945.2
|
||||
.workspace.google.com TRUE /intl/ru/gmail/ FALSE 1809021945 __utmz 61317162.1793253945.2.2.utmcsr=google|utmccn=(organic)|utmcmd=organic|utmctr=(not%20provided)
|
||||
.workspace.google.com TRUE / FALSE 1801020578 _gcl_au 1.1.1680277224.1793244578
|
||||
.workspace.google.com TRUE / FALSE 1827813946 _ga_FWCBRW1RY8 GS2.1.s1793253946$o1$g0$t1793253946$j60$l0$h0
|
||||
ubuntu.com FALSE /download FALSE 1824779632 user_id 4b795a8d-755e-4068-937b-91e52d06631d
|
||||
ubuntu.com FALSE / FALSE 1824779632 _cookies_accepted all
|
||||
ubuntu.com FALSE / FALSE 1824779632 _cookies_set_offline true
|
||||
mail.google.com FALSE /mail/u/0 TRUE 1794108662 COMPASS gmail_ps=CrMBAAlriVefAAQBnSxVi4ChQiuid557O8qOl2OjlHZND32vw5xl6FOmLYFh3-Nc4mIvlIkDNqfJW8unKn_l5i4dXIbZ0Sjf-JuHeX-EFxUGvNP4QCA9vXqHsSryRNld9A0NzC7p5czhGRKJH0z_TuhK_cAqccrXGfU4OFHmR0hDd2DANEePyy6j0iw87UmGPTguiMBqouR-2GasKvNL0fIszbfzw1mNd_PsEp9rWpbOVLPEpGwQ1J-Q1wYa8AEACWuJV8fIkwIJPJu_Qf4DiRkRdmk3XL1onAPGidqbB-Cjc_xG2eBSUlD2nGymklxHmMPI_K3HwFs7B1NYgwk4wfHXNtVsVODO9pqjHDaIiOqv4qnTmjOoZt2KVtSM8pOcXRCnoXmWoAE-IvWyAcOKBsyvJ5cqafo7f73ptwTzMici6MjB07k_erZw7QR0NKsZsc-BeI8CPxPvfCqnQDBRseIZh4yVi7ovIREbNat5gf9o6iMFILSfEPlf8liqnC355znBXMtj0tsJANVb-F0nxj8ejpeklmXrBCG737QFaSiqqNdi48b2pmFW6VvCuggwAQ:gmail=CsIBAAlriVfWF8Ql3QS28hYzG62d-5-dSYHoMXXooHu5M6bXIaz51xudKM1L4VdCYpe6AGKfm-K1zF1wN_-c3_xUcmqwKGTg-_20ZFzNEpDA3cXdpM_w8iDvmjUZaMYP_ksHf2Sgi4vbA63geo5x8flADcpwrqfuOZBx0GVV1FLGK_3dtazhLBU02DE_pYB8CQoQS9S4B8IRYwoEhzIP0_TEzExt_ccADTaHpnobeskMF9qhFdtHk_UAJQBQ2KuulTt_74EQ25mQ1wYa_wEACWuJV2xojKMCAmKg0ZnCsjHzxGgykaPQrX2muiG35keLpb4KaoWa2VTTjAWyKSLiqRFxjAQDf5Nhl-bt1NDJ8addAwnzVToge8OZMb5RJKvsN8Q009j2Y9du2eSDQyr--vLewPjEd0IKsEQ80YOnFYfenM9BTGlC-OBMMC5zZo7Df_5K-4wNTRpCA6h2ErdSz2KOJB--5kQxuq9A-eVVc8YLAoyqjhr2lD-wGbLKwc0OVX-8FPncIsOTwyv3iinqKehMb83KccgjMt2HsKIKrRjK8INh6a1rdlvpikVWM_-_fO0fBDrTVJ6SGcEaUGan5PD0N5Q1i_kxdNQheecwAQ
|
||||
mail.google.com FALSE /sync/u/0 TRUE 1794108673 COMPASS bigtop-sync=CsMBAAlriVf6XqF2sN1CQ0tMcO8zsbykPUc7Zc7uhWSUCkF-c8Jf-ArcFqQQeQ-i0FyQABqhMK2TOoq2XNyptG68Z1LOM7IvDCSIxRqbkyHK8wIrRD3BxI0pCgBQOBiytnBCeGKdkoWGAKEsW9Un07Oe7WTVOYiVu1fn3O43Xt-gVfkIznYIfoAqcmICGGnrMtRZdhUoL9dQwSA73L_Q7KHlY5K4oe61utZmmumWDx7GipFltFIZ0tb0b40dSCDnlzrOzSowEO6jkNcGGoACAAlriVcdbjfP07TEoitiLIEukviJ2exbnUMCiiQ-9tCbILHx6chFqrtGYuTQH9hFf3qG5be5htarouEhOKtnM83QnatgUy1Nr8Zd7ZUbMfKtZ3eD_Y3-tpidkwSv_zIpXUkMjw_VRbneEa4DMe7Vayg2qCdSvtsoZtTH0OCwmJ5kHrevWG8QSVKlkB_o0V4T-vsg9Nu62cymamSBc4vkb6VUHCIoulgwBjZzeH5L4h4dBYj9ygYcWZPCYAdPQ-4TpEYTQciG3n6kQOf78JaC7nJyB98NLNFRf7t69Y-LutcyS4b5xQM3UPzvIuSYkjFEC6kTC_nw1wcuBd92w2yNzzAB
|
||||
mail.google.com FALSE /mail TRUE 1794108655 COMPASS gmail_ps=CrMBAAlriVefAAQBnSxVi4ChQiuid557O8qOl2OjlHZND32vw5xl6FOmLYFh3-Nc4mIvlIkDNqfJW8unKn_l5i4dXIbZ0Sjf-JuHeX-EFxUGvNP4QCA9vXqHsSryRNld9A0NzC7p5czhGRKJH0z_TuhK_cAqccrXGfU4OFHmR0hDd2DANEePyy6j0iw87UmGPTguiMBqouR-2GasKvNL0fIszbfzw1mNd_PsEp9rWpbOVLPEpGwQ1J-Q1wYa8AEACWuJV8fIkwIJPJu_Qf4DiRkRdmk3XL1onAPGidqbB-Cjc_xG2eBSUlD2nGymklxHmMPI_K3HwFs7B1NYgwk4wfHXNtVsVODO9pqjHDaIiOqv4qnTmjOoZt2KVtSM8pOcXRCnoXmWoAE-IvWyAcOKBsyvJ5cqafo7f73ptwTzMici6MjB07k_erZw7QR0NKsZsc-BeI8CPxPvfCqnQDBRseIZh4yVi7ovIREbNat5gf9o6iMFILSfEPlf8liqnC355znBXMtj0tsJANVb-F0nxj8ejpeklmXrBCG737QFaSiqqNdi48b2pmFW6VvCuggwAQ
|
||||
mail.google.com FALSE / TRUE 1827813947 OSID g.a000EAnGNAZ1KGrblnWHVtbcdHF7fjkAR6nlOVfarDWgUoi84oKXKx_OvzuUCbyI0leD9VjSrAACgYKAfUSARESFQHGX2Mi_bte6DVRKE12uvK5Nb1npBoVAUF8yKrfdCe0sNjfHnPl2R0uzVYn0076
|
||||
mail.google.com FALSE / TRUE 1827813947 __Secure-OSID g.a000EAnGNAZ1KGrblnWHVtbcdHF7fjkAR6nlOVfarDWgUoi84oKX3SOB_p-BiouyNKbA60378QACgYKAY8SARESFQHGX2Mi95CvT4VPtQ7x-N_SkN9AKBoVAUF8yKoDrLLsK3e3-7veJoMz9atq0076
|
||||
mail.google.com FALSE / TRUE 1794108677 COMPASS appsfrontendserver=CgAQlZiL1wYagQEACWuJV2-1E7klMhb9f9nSo9XnKMzdxTmjc8shRCls8HnSFyOvCGpGa-0omqJaap7UKUZcELW8Hp2epJFlYqxzq6ubATLxX_TyAV5tVXuijNKC6p-92rLQ7dQ0djUQJud3yluTC4TmY-sBQp5ZNRX9liEDVsF-_y064vL2GDdLmFUgATAB
|
||||
osdev.fandom.com FALSE / FALSE 1793630559 fanid_expiry 1
|
||||
osdev.fandom.com FALSE / FALSE 1796136161 pbjs_unifiedid %7B%22TDID_LOOKUP%22%3A%22FALSE%22%2C%22TDID_CREATED_AT%22%3A%222026-11-01T14%3A42%3A34%22%7D
|
||||
osdev.fandom.com FALSE / FALSE 1796136161 pbjs_unifiedid_cst znv0HA%3D%3D
|
||||
osdev.fandom.com FALSE / FALSE 1825080163 gamera_user_id 222a41fd-b352-4575-891e-f3add16cc8f6
|
||||
osdev.fandom.com FALSE / FALSE 1825080168 idw-fe-id 7346aefb-3df6-4ce8-bd96-7f14040d8c1a
|
||||
.google.com TRUE /verify TRUE 1809343679 SNID ANBxh3jhM442TJXYjKfLwc2hN7uW1f92kn92cOkR9uIka9saU1eAg-1ygmvKLGSBsOcRjPB6SJpDKJ_Ag7L-kmIFgVdEe3bi29I
|
||||
.google.com TRUE / FALSE 1808795617 SEARCH_SAMESITE CgQIk6IB
|
||||
.google.com TRUE / TRUE 1808795622 AEC Aaa9EJrU5a5u3fA64lEtFIkphdoxRgBMBl41q-XoqriAqPyvkC9AeLgG1g
|
||||
.google.com TRUE / TRUE 1809343679 NID Cs4DCAESgAMBM9Agl-X6GrtNiGN1hdp9tfs8Clkitae8h3FudmDUNdb0uuy336BLs15jdJ6TRE9IjYsk-C0wCPxjMnG9m58MH4LMncu3adbw7bp0gc-VrXzsSfrzYMIRcqYuhtfIH3IoDdzopkwReoIal4Nn1GQ6aPTid2nOrLQMnlFLw7nmuFGCcYvc9_a2GBjf7G-JNAAGGLI7ujdK8WeSy-PfigbwBvArFFNR7XV2ylkb6TyLqrG6e1by94inuKBErvxHZsoVSdkrGhnLq2p2aDlQpMcCKGQprMHC18N6OVXUH1D53bQGjxcy8WkqvrKx3Rng43OnkG4X68xh4PsA51BB0u8Be7DGmF0RKuXu8RVWPCKacqssgZX--hzmp9sHNZRt-7IwEkB01Y1OuxX00OVmUe7yjk9EHpYLfn5Flu3z91k5NQGZL4TDSQ8rQwk62MVybS3PzsDZOLTOimelI02-IlUBGjyGRGMjcYi7cOEjFC6gMRJXv7ZUEeegVcIR6rE5RSAoATJFAXBKQs8xGi1nasgrK2UqceIa_2wRxvrvhZaS0N_Eaa2Ro-GsWqhdJeBDIKT2JPn8Tluv6zXrn1VQiA-uMivIjcCijMIB
|
||||
.google.com TRUE / FALSE 1827804650 SID g.a000EAnGNB0WmAKACI1xxFZFRPMqOxCffokWZ8xjwgJriO7y39RHOJDEbzcDbB4CeWyeMWwkFwACgYKAQoSARESFQHGX2MiDUEU_2sjvGbRgjngmhQdkxoVAUF8yKrGF7SPJ7y3Kcak88Gh9Z_o0076
|
||||
.google.com TRUE / TRUE 1827804650 __Secure-1PSID g.a000EAnGNB0WmAKACI1xxFZFRPMqOxCffokWZ8xjwgJriO7y39RHNE_zv2iKbf0sfzopWx9OswACgYKAfESARESFQHGX2MigLrJfrclCmdzwxKrjWyEmxoVAUF8yKrLI9dgcin6VzAwMD41DJ_b0076
|
||||
.google.com TRUE / TRUE 1827804650 __Secure-3PSID g.a000EAnGNB0WmAKACI1xxFZFRPMqOxCffokWZ8xjwgJriO7y39RHthDgvQyHS3S2EMmjA8DrXgACgYKARMSARESFQHGX2MiLrwX-yYljZr-SoV-ywBkUBoVAUF8yKqY8mdbHqSLAxrUzIc8xOT20076
|
||||
.google.com TRUE / FALSE 1827804650 HSID AXYmcliY47gZfU1Uw
|
||||
.google.com TRUE / TRUE 1827804650 SSID AvgfjReLF18ivzBVy
|
||||
.google.com TRUE / FALSE 1827804650 APISID iiVE1FGlsGq_SIe3/AhE-5-0txwwoIWUTC
|
||||
.google.com TRUE / TRUE 1827804650 SAPISID Ge3Zs4CbZc0poi9T/ArPlRl005gM0xNfTG
|
||||
.google.com TRUE / TRUE 1827804650 __Secure-1PAPISID Ge3Zs4CbZc0poi9T/ArPlRl005gM0xNfTG
|
||||
.google.com TRUE / TRUE 1827804650 __Secure-3PAPISID Ge3Zs4CbZc0poi9T/ArPlRl005gM0xNfTG
|
||||
.google.com TRUE / FALSE 1825138646 SIDCC APD_SKH2BBdnuDX52BjbdVHpIuYiZrR2P6C5Bfjz47mU7j6weRAgbJeV3B3WTNIMpVtJPbP2X4w
|
||||
.google.com TRUE / TRUE 1825138646 __Secure-1PSIDCC APD_SKEL8lDVf5BEzl0Kq5XGMDUIMxtASUoMjMorGRpP3JY5lT5WJVhlLsL6jArVquu2rAmqMOw
|
||||
.google.com TRUE / TRUE 1825138646 __Secure-3PSIDCC APD_SKEBX_c_zDuvNS1Cc8gRPpQhD8RQCYo7ZcpZTOVNsd7J6y3mWqk1WBEP4lUVrForptLQBQ
|
||||
.google.com TRUE / TRUE 1825138646 __Secure-1PSIDTS sidts-CjIBnJZzal3kzqnx9tpyQs0IK1W3B4nbqamKhA5tvAjw1KbpPZGtYBD0lVxXa8lZ6bmvWBAA
|
||||
.google.com TRUE / TRUE 1825138646 __Secure-3PSIDTS sidts-CjIBnJZzal3kzqnx9tpyQs0IK1W3B4nbqamKhA5tvAjw1KbpPZGtYBD0lVxXa8lZ6bmvWBAA
|
||||
.google.com TRUE / TRUE 1827468307 __Secure-ENID CsQCCAIS9gEBY3DFeo0V6JMS1jp5HqXZ56ME8K9X8XTLF9MJ9LuCPmDyFQOAdcZLzTCyJbou2sKNtELOKb-Py-S2nevCay0d2y3ds6QKSr6PRiV3WpO2XIESo1DBjkXZA42eHvK6K0YnfWPaUlblw7DAzfDwsI6ch4ci88_1rKfQ9LmIZIugId2JHmXvDfgcxcTxXEQAA5l0TIwihS_Os8Ffz6kAbwW1WCyvrKfHqkpsHR3sdbjtDQSi_M-syjIOnafsfIu0FW3aolyGcypnqlJ4reu9Nf4Z19pE84QbXdg1Asy0JhVOgqPVfzmyO08CJ3gtrDH5RYBJUgeYMEMoAjJFAfsI_Gp75UHBzvWf6C-hjgOstTh-OuCJ_nKj2Jzgya6W8jq7dLiGxqArd8tpqbV2tSe0WmsoXiMVRR_TN0H3GUIUwAMB
|
||||
.ubuntu.com TRUE / FALSE 1801019632 _gcl_au 1.1.1548112440.1793243632.-.-.1793243632.106378654.1793243632.1793253939
|
||||
.ubuntu.com TRUE / FALSE 1827813939 _ga GA1.1.1330533117.1793243627
|
||||
.ubuntu.com TRUE / FALSE 1827813995 _ga_5LTL1CNEJM GS2.1.s1793253939$o2$g0$t1793253995$j4$l0$h0
|
||||
.ubuntu.com TRUE / FALSE 1827813938 _mkto_trk id:066-EOV-335&token:_mch-ubuntu.com-f498e3790978fdbe21a2df5b098e9cb6
|
||||
.ubuntu.com TRUE / FALSE 1824779636 _clck j2u2nq%5E2%5Egap%5E0%5E2493
|
||||
.ubuntu.com TRUE / FALSE 1826949939 _uetvid c2dbafd0d34611f1a69191ebe9e1bbc6
|
||||
.ubuntu.com TRUE / FALSE 1824789995 _ce.s v~9c107265864dbd9886875d4ed5467185eb73dafd~vir~new~lva~1793253940224~vpv~0~v11.cs~30754~v11.s~c5229300-d35e-11f1-a4c0-1794269abf09~v11.vs~9c107265864dbd9886875d4ed5467185eb73dafd~v11.sla~1793253941296~v11.wss~1793253941297~v11ls~c5229300-d35e-11f1-a4c0-1794269abf09~gtrk.la~mvt54m6h~lcw~1793253995081
|
||||
.ubuntu.com TRUE / TRUE 1824789940 _zitok acb46eddb5a116e77ffd1793253940
|
||||
.ubuntu.com TRUE / FALSE 1824789941 _CEFT Q%3D%3D%3D
|
||||
.ubuntu.com TRUE / FALSE 1824789941 _ce.seg.targeting NoRgNA3gRA7FBc0B2BXAtgIwKYCcEAYwoBjFHHLJYgTwSgFUBlAESgF82xgosBDEAGwBWAGYAOXgFoxAZiFZJAFiyKATNJABOBfny8hAjKsX5FYgVAC6YVABtbNlPctA
|
||||
accounts.google.com FALSE / TRUE 1828161786 __Host-GAPS 1:jnoihk8gbE6CBiUPcWwpzbLPKhMYNxasuXvyvfRE1y0TDpS6DqW9HWN9RDRZcmFeZbzst1J3YOr5lntj09QIZeloa055ew:6qnuAH3zV9nHvwJD
|
||||
accounts.google.com FALSE / TRUE 1795836582 OTZ 8849010_56_56__56_
|
||||
accounts.google.com FALSE / TRUE 1828161786 LSID o.drive.google.com|o.mail.google.com|s.RU|s.youtube:g.a000EAnGNBDeRvYKezr-KTZeRNavu2y5LpdYW9YPZAeLM7zS9IVRPNM-VrdSMsdt4ioiKWRsjAACgYKAWESARESFQHGX2MidbR0mS-9ghpHeRuys9vFwhoVAUF8yKpHWkGsUIV_shGw7whdC75z0076
|
||||
accounts.google.com FALSE / TRUE 1828161786 __Host-1PLSID o.drive.google.com|o.mail.google.com|s.RU|s.youtube:g.a000EAnGNBDeRvYKezr-KTZeRNavu2y5LpdYW9YPZAeLM7zS9IVRod1pswUUA9vyWHX4vvovRwACgYKAfkSARESFQHGX2Mi6Fdaa_wYIztv28rqCYYffhoVAUF8yKprssV4QIWW5B91Nog9-wCw0076
|
||||
accounts.google.com FALSE / TRUE 1828161786 __Host-3PLSID o.drive.google.com|o.mail.google.com|s.RU|s.youtube:g.a000EAnGNBDeRvYKezr-KTZeRNavu2y5LpdYW9YPZAeLM7zS9IVRhqHRMSWHWhfyz4PahcnXCgACgYKAYwSARESFQHGX2MiOUOBhsXWXBBRcMGVTHeogxoVAUF8yKonQHmeaHGQZWAnfZhg4btQ0076
|
||||
accounts.google.com FALSE / TRUE 1827804650 ACCOUNT_CHOOSER AFx_qI5PWL0zfgtZGh8MJXuixY8M1w0rhdsbvfbB0C3JIKDj-oItaMA2t315QusePGY_z2hIcy-Dtg40G1K8tv9RLd5CPsgyABXtfhAXoEmTrdONRfPzluiNW6Jban6KvzsPNPNX2dIj
|
||||
appsgenaiserver-pa.clients6.google.com FALSE / TRUE 1794117950 COMPASS apps-genai=CgAQzuCL1wYaaQAJa4lXc0WN2_7F_X4FimXuhol9ch-azTZAYcL744P5UTRixon6YskA-C82NfzInAlu-M8wi-V2rKrpx1SZ7uhZEZ_A4-c5QtsI1VpOrp51JWcYeg2poabmqJVq0LvGktEuQviLtlNx_CABMAE
|
||||
studio.workspace.google.com FALSE / TRUE 1795836674 OTZ 8849011_56_56__56_
|
||||
contacts.google.com FALSE / TRUE 1795836680 OTZ 8849011_56_56__56_
|
||||
.deepseek.com TRUE / FALSE 1824897197 ds_cookie_preference %257B%2522level%2522%253A%2522all%2522%257D
|
||||
chat.deepseek.com FALSE / FALSE 1827804686 .thumbcache_6b2e5483f9d858d7c661c5e276b6a6ae h1dhz9daSCHp56Z2dbmTGhYdAzh4JHwLcEUQVn2ODJlthBcETanPJF+phvSza7+C1Sd+iD0Ezav5jVIoAo66jQ%3D%3D
|
||||
chat.deepseek.com FALSE / FALSE 1827804684 smidV2 202610290331247f5222ed8be6d979709d920dc788083b00871f6667ee41f70
|
||||
chat.deepseek.com FALSE / TRUE 0 ds_session_id 6b0269f39cc04c6fa0e234a2ab21bb9f
|
||||
.github.com TRUE / TRUE 1824781109 _octo GH1.1.921897397.1793245109
|
||||
.github.com TRUE / TRUE 1824781109 logged_in no
|
||||
m-17e4e9e7a8d9-3w5hsuiikq-ez.a.run.app FALSE / FALSE 1793849927 a_trpm eyJ1dG1fc291cmNlIjoiZ2l0aHViIiwidXRtX2NhbXBhaWduIjoiYW1uZXppYV9idXR0b24tcmVhZG1lLWVuLW1pcnJvdyJ9
|
||||
m-17e4e9e7a8d9-3w5hsuiikq-ez.a.run.app FALSE / FALSE 1809013127 _pk_ref.1.063c %5B%22amnezia_button-readme-en-mirrow%22%2C%22%22%2C1793245128%2C%22https%3A%2F%2Fstorage.googleapis.com%2F%22%5D
|
||||
m-17e4e9e7a8d9-3w5hsuiikq-ez.a.run.app FALSE / FALSE 1827200328 _pk_id.1.063c e10510dd467fca87.1793245128.
|
||||
ru.pinterest.com FALSE / TRUE 1824781324 csrftoken f7a6bacbec666f4723325abe2b29fbf8
|
||||
ru.pinterest.com FALSE / FALSE 1808797343 g_state {"i_l":1,"i_ll":1793245343858,"i_e":{"enable_itp_optimization":24},"i_et":1793245343858,"i_b":"hcAMZAcT/5jQDJD13Xh8uEl/l+3nObXR0Z98ozktHDo"}
|
||||
.pinterest.com TRUE / TRUE 1824349341 _pinterest_sess TWc9PSZqQlV5aW9Kc09jT0dVYUtkNlo4QlZZa294dkJwZzhSaUE1N0VPVHB6U0hkdjFvMkYvbGVsY0MvQ2ZBdlQ2MW02UzB0Y3hSbHdLemxPZmFadm9Sa0t5cFZGc1oydWJ6TXJFVE0xZzRFWGNRbVBVWG5zOEFocXhURnJ0OGZRMVV4RXA4N3gybXFjOHRPM3Z3SUxpZlVtMElpMmhtQlNWK1lpTDJ6cWduQnBRaGJIQXpXOWZ3b3J1OWxsTWpqZzJYVVl6NjIwMU9nVzZMUmo1Nm1STVR6ZHgxS1RRd0FMZkhVV1Zsb0xXblJSQTZxcVl0eERLNTJ4Z2o0M3ZnRGE1MnNSUnZFczU1Wm84WVpoQzd6TXVWSVM1SWljTk9sL05MZGJEeGNPbW9CM2RYR2NFV1NZUW1Rb3EyekpiczFvdHVLVjhYRXlTTWNCQXQzY0QxYUFaQXNvNGh1ekhiRFQzdm1jOHc2WEpOMTdTZTQxa3RIdnhjZWhKZm1iOXQwU1FHZkFqODNNazJLcUVKZDVpRzU3blpLanhzVnZmcElGMTROaUhLd0JiMjFTTHB2NFZkR1Zady9HRGMrcVRRd2xBQm8xOEowVWJ6Z3U1eWExOTFHUjVHRExtRjVKQ05FZTA1akEwWm9YaThKWnRINTNFYWpSWXZsbUw3aFYxczYvK1pkN3lTYVRHN3FjNXFrSzYzSTlUT3Q2MVFMTFl0TnN6RngvNkVJbmswNHhXQUw0WHVCQmxZSmloUncwSE1aQk9lWGZQMVJ3dTFtakN3c0lRTHQvaUhBb01XbnU4SHN2Rm05OTBjNTlHTjA3MkZLTVNpSEFXcVQ1c2RFaXF2WW5OdFdIYWJnQWJPK2tnMFltbXVCUHp3YjJRM2Juaml4blpETi85NkNsYVNLNnBWbm1zNWIwNDlVZzlnck5HTU83K3U4VXc4bjJ4Y0N4SUY3WFJwTFBKUnJISThHWUdnby9NV0diZVBJYnZHU3c5dXJBOWNTeXJPZUxHVnJXVjFFSVE5dHcxZE5qZVhtUFRVYmx1Nk9Pd2tUZUg1ZXU0UmFZd3ZFN3FSalB4cHE0dkZCNm10K0QxMjV1T2pkcWd1RTNJOG1CUGcwNnV3VXJlRXBhVlZRWWZqWWZKWHZhcE9JdmlpWFVHdVJRZEQ0K0YwY0JJdmF6NG9Qb2dIMnRjNitlYXF3eDNkU1lCT1FyOWxUQ3NoQ3pJSnIzOG52OHhZcmRQRStTMlcxaG01UlZ1MWNZWmd6NTR3d1Qxanpub2RpS2tKcGRORFFQZmJKSjhVOUpON2E5MXNSRWdiRUpZVlJocFIveHdLVS9ScHVuNW0wUHlDMXVDd3pya2pkQnJ6azd0MTJsK0hrWThVckl4Sll2a1V6UWRzTWxNSHNmZDNHYmpjeFVvQ0Y5dHNRZ0VZQ1JnRTJRaXlBdUJKUzlCaHJSTXhJZlFjbEdKRVBNT1VhWW1XaXY5b0hQWnVvem5UWVNteVlKNkk0KzlBVW0zKzdaVUhkMHU0WFpHdTRKN0hZWHNHTFNWR1YwTXFjU2VKR3ZWN3ptRklIQldpUUxPNjBORFVRSzFrRHlyRHBOUGJwUEc1Q0dERk5JK0VqbU4zRU1namxQbXFDZGRKcVRDRWlsUFd5NmpXNTRTdTQydmEvVC9yS2hxeXoxcWlrdUVmMGRtSTB5M096U25WWTBkZ2E5WnppdXhZTHd3QjlCV2EvRkIyZzVFMVZMNVpicTVuSkFIY1ovZ2gxdDc2Qkp2Zms3d3FPTWJKR0Z3RHorMFV0bHlLaVcmUFAxNzJBdjBHa2h0Ky9PN2RDTS90aDBpTjBJPQ==
|
||||
.pinterest.com TRUE / TRUE 1824349341 _auth 1
|
||||
.pinterest.com TRUE / FALSE 1827805327 _b "AZlGMz4Io4NG9b/6exHx0JfKsIdQcgM4C2J2nN9RJj8FkZUQHzO0bjsTI3P1CoPUguM="
|
||||
.pinterest.com TRUE / TRUE 1827805341 l_o Lmh7Obmv3QO/uNzFPN5zG+fIX/9rWLR1mgN/LfJvEOd+iLaoBxx1zUecH1OzRwxD3uVWXaHKxCMvnHj5EfaGXxkUtjkdP8A2Hqvf5eoVIt86LsRX0bEHbT3XNidF6BMoOaI=
|
||||
.pinterest.com TRUE / TRUE 1824349341 __Secure-s_a S3gyazBOUjFQR3Y3SVAwQ09qcHZYb0Zzd0VuMUtObVRPWjRacWFVRjBHUDF0MFQ3bE9QWFRnYzc5YW81eEFmTHdBSUZuOUtFZ1NHYTRyUmsybVZhZUlqYklEajhsYTRiZGtvaWFLWTdOVDFRNWQwbkFveGNteW5HTlJ2bnNYbzZyL1hxWDRJM2FZZFF6UXdveFVMQmtHSDRMSXY2YlM0TWRmWlJBQTBmM3N5emhWTHN6UWY5d2JXek9UUTc0OVBTNFFXVVNZVkZKVXhocHlmR3lyVkRzd0dTZUVoVEE3ZkJNL3AxbzZNc2g2QmFGMzBJYXd5dmFNWHcycEs5Z1pGVGd1a0FYK3hxMFozYXhnS1F1V3d6OXhYOXNmWjVkKzJHTWdudThHMWUvMUtlV1RVcEZYOW1iUWxpR2RKUllzL01ONE5YakV1WHc4ZXBpMjA4RXFZMC8zK09NV3RtTkdmd3VnM1R4WUNzd3hzR1VrY2ZQUWtTNGplRmZKVU9Pa0k0WExGaWlIc1h2Mm5oZWZEVlVrNXNSYjkyZklqcUR6MW11VVNCK2hOTU1TZWZPRWdaMFU4R1pNVXprNnBuOHdFa1pOWG5KSWxsazBOL0p6NGNiRzdkcTFQQzZLcnJib1RlRnJOQXcvYVkxcks0V2V4ZzNpSGxBV3VaNUVNSDlCUWFhLzV4VlNBNklnRnpMcm83Q01SWEoyTTNkWFpmdjRIOElwTjZBSk81WG5kVzJkTU5Cak12SDRIRHpBeXRUMWphbUtzYVM5ZU5WMDZjTjA5WUZNTTAxV2JMUTZZVnl4L1ZISXBhdksrL0FqV2VGTHVqVCs1T0lvaWZxMzRTckVZTGFwMEh0elluNkJRSFlMdHVMTjN0ckxYNVNwODVVTE1SenViMzJ5U3ZlYVJmQktqVVVaaHd1U2RtYW9USnJYdG14ZytweWNzWncvd3BVTjRCU1Z1UzRiRDQ1ODlhVHFZeFFFVEF1SmM2NlMzM1dObVQrVzdhT0VIOTlkK2JHL2czTXgrMlNOaUJuci9hb3p1L3F2VUJnYklOb0IxeXNDaVJvdGxtWXdDYnI4L3ZsZ3Q4dDNOSXFLK0tzV3NXV05mdERLMjZYNTRBN3VSMm92LytnZWxZRWJ6dEhuQ2QvWVVHbENHdmRuenBxM3FOcUQ2UUg4Y2haMmp5S3ZHYkVJSVJYb2VNYkhNdTlqUjUydVByMWJsK1Q1TnhJeWl3eDlYRWVqZCtHbGhHZDdoamFoNTZiOStEck12YlJweUlkVm1tMzJJWEl2bTdCQlBKWXlWTFFCdGZHVkpmMnZXSEdiTUpKZEFXaXhVRml3Zz0mKzNZWWM3djAyc3FwWml2THBnSUE0WGYyUjlzPQ==
|
||||
drive.google.com FALSE / TRUE 1827813954 OSID g.a000EAnGNAsYWbTguZf9vGkLNPa_LRN9ah4cCo2MN6zUE5Q5flURXZXsfbp6VHUZVvHzgtgZKQACgYKAekSARESFQHGX2Miu5Cekh6uhZHhZDGiQy8-pBoVAUF8yKpDvPJFvCCLpdVXumldUN_H0076
|
||||
drive.google.com FALSE / TRUE 1827813954 __Secure-OSID g.a000EAnGNAsYWbTguZf9vGkLNPa_LRN9ah4cCo2MN6zUE5Q5flURKJEi6AuJ986neSkM7nLdJgACgYKAccSARESFQHGX2MiUwZic0D2XfP53_k8-06gnRoVAUF8yKpOW82_PGDQlRnBZ3Jb3EER0076
|
||||
drive.google.com FALSE / TRUE 1794212374 COMPASS photos-fife=CgAQo8KR1wYaXgAJa4lXi60XCseUjuTo5bWuVoA_p1m7upt88sJM7hwx6GuDpzwJ6FrvbpK7-rcvodFvcAavg2KgdLqG6I0M41sBFeB_h9Vwu_MXzsvzX3E6PMUoIUa3pYNiT6_BKswwAQ
|
||||
ogs.google.com FALSE / TRUE 1795845959 OTZ 8849166_56_56__56_
|
||||
play2go.cloud FALSE / FALSE 1824804880 locale en
|
||||
play2go.cloud FALSE / TRUE 1795860918 access_token eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo1MTEzNSwic2lkIjoiYU1qeEZNZTV5YmJOZU44TUd3dHAyblJqUnhtQzRqbnJOakYwNFlzblU4USIsInJvbGUiOiJVU0VSIiwiaWF0IjoxNzkzMjY4OTE3LCJleHAiOjE3OTU4NjA5MTd9.ajWKJmu8IX5W8Xsd84KiJSa6oMevAdJzR7omTkaF1hQ
|
||||
.play2go.cloud TRUE / FALSE 1824804885 _ym_uid 1793268885852625543
|
||||
.play2go.cloud TRUE / FALSE 1824804885 _ym_d 1793268885
|
||||
.mozilla.org TRUE / FALSE 1828162669 _ga_B9CY1C9VBC GS2.1.s1793602651$o3$g0$t1793602668$j43$l0$h0
|
||||
.mozilla.org TRUE / FALSE 1828162651 _ga GA1.1.752939076.1793280122
|
||||
.nerdfonts.com TRUE / FALSE 1827850534 _ga_FZ2KV65PN8 GS2.1.s1793290094$o1$g1$t1793290534$j40$l0$h0
|
||||
.nerdfonts.com TRUE / FALSE 1827850454 _ga GA1.1.1472696158.1793290094
|
||||
.nerdfonts.com TRUE / FALSE 1827850454 __utma 170037823.1472696158.1793290094.1793290095.1793290095.1
|
||||
.nerdfonts.com TRUE / FALSE 1809058454 __utmz 170037823.1793290095.1.1.utmcsr=google|utmccn=(organic)|utmcmd=organic|utmctr=(not%20provided)
|
||||
.pornhub.com TRUE / TRUE 1793965750 platform pc
|
||||
.pornhub.com TRUE / TRUE 1824896950 ss 921797941037255564
|
||||
.pornhub.com TRUE / TRUE 1795952950 comp_detect-cookies 37066.100000
|
||||
.pornhub.com TRUE / TRUE 1824917876 __l 6AE48433-42FE722901BB1F8664-B058802
|
||||
.pornhub.com TRUE / TRUE 1824896951 cookieConsent 3
|
||||
.pornhub.com TRUE / FALSE 1827921017 _ga GA1.1.362951938.1793360953
|
||||
.pornhub.com TRUE / FALSE 1827921107 _ga_B39RFFWGYY GS2.1.s1793360953$o1$g1$t1793361107$j60$l0$h0
|
||||
.pornhub.com TRUE / TRUE 1824896956 bs 000000000000000057707e19457a9bc3
|
||||
.pornhub.com TRUE / TRUE 1824896956 bsdd 000000000000000057707e19457a9bc3
|
||||
.pornhub.com TRUE / TRUE 1824896956 sessid 334607879777154788
|
||||
.pornhub.com TRUE / TRUE 1795952956 fg_afaf12e314c5419a855ddc0bf120670f 84976.100000
|
||||
.pornhub.com TRUE / TRUE 1795952956 fg_55e3b6f0afd46366d6fa797544b15af2 1904.100000
|
||||
.pornhub.com TRUE / TRUE 1795952956 fg_7e663154ded3d3eac4af77cfff94b32a 51750.100000
|
||||
.pornhub.com TRUE / FALSE 1808913017 g_state {"i_l":1,"i_ll":1793361017631,"i_b":"u8UvXXbaugiAdRwEeAOzWvbyTvbhRJjhpGoum5hueAw","i_e":{"enable_itp_optimization":24},"i_et":1793361017631}
|
||||
.pornhub.com TRUE / TRUE 1795952964 fg_439f2555043a44b8bd91161b5deddd29 18136.100000
|
||||
.pornhub.com TRUE / TRUE 1824897015 lvv 342506477690527253
|
||||
.pornhub.com TRUE / TRUE 1824897015 vlc 740349612543925605
|
||||
.www.pornhub.com TRUE / TRUE 1795953016 tj_UUID ChA6-Jjt7EdMzZhRsxWUd1FNEgsIwoiS1wYQ-vHzOBgEKgwI9IiS1wYQx-mxlQNKECdNNe8gp0DpkJL6cUNVJ7Y=
|
||||
.www.pornhub.com TRUE / TRUE 1795952964 tj_UUID_v2 ChA6-Jjt7EdMzZhRsxWUd1FNEgsIwoiS1wYQ-vHzOBgEKgsIwoiS1wYQgoX0OEoQJ0017yCnQOmQkvpxQ1Untg==
|
||||
.chatgpt.com TRUE / TRUE 1824909683 oai-did 0c32ca2d-2d74-401c-bab0-059bdc4bde89
|
||||
.chatgpt.com TRUE / TRUE 1824909683 oai-did-first-seen v1.0c32ca2d-2d74-401c-bab0-059bdc4bde89.1793369753
|
||||
.chatgpt.com TRUE / TRUE 1793974556 oai-mweb-route 1
|
||||
.chatgpt.com TRUE / TRUE 1825079853 oai-sc 0gAAAAABq506mo3kWVeFyzwDyT6n2Qu2-H4FC_DaT8lqBjmelHCTapr2PvAaXSb89moG6acJ3z44EENdPEuQ3pAkLoGZt6pU_c5uRx_COLSfHPIMkRarpfOhlnrhO8wjVqBZDZO5nC1atVuT48KWJuuIJMHPM99Yt7VKTCjoHFAqH8qqCMgpN_pqZ4fspF8JUKbufm29D5Yox1QyWy4R8CdWtyM7dKsZNTL0Bgdl89xJG7AKr_IGlpjU
|
||||
.chatgpt.com TRUE / TRUE 1808921944 oai_consent_analytics true
|
||||
.chatgpt.com TRUE / TRUE 1808921944 oai_consent_marketing true
|
||||
.chatgpt.com TRUE / TRUE 1808921944 oai-allow-ne true
|
||||
.chatgpt.com TRUE / TRUE 1801313300 __Secure-next-auth.session-token.0 eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..DwbWJZzyt2qY49xf.CZWlzqlm629eCTtkKa8FMGDPzKM9gF57pJDNcC2HkDTDH9tl1j6VNWzH10LnNYvV9opJY-Gy0-y7vsa_c-gj6BInKIgrsabC_ICvVrayy3eZoDU8YgG_Jl59i2qX8Ro1JlEMjFPfspaNCwAyB2rm5f-KJpjqxtOR0b3HSnABxYJbfUSSvX8FZlHJS9OVyarpLeFc9Ajakb7uhtTudp_yA5AwMedGo65yCzNpgBScSNXd0e1-Nz8vnGHNdWqbQ7pZfsdrWTqN4Qo4-uCq8T1EddAc5tMCxqypRf6G2IomYxRRJxa-MFmZgBh9sBYB1dFK0C85iS_ywasz9sbv9GNgDpV-l4FXB0HY4-cZq61i8UzxrJ95SY1gLJha80mCsPU9ZYd2h6MysgYmOb5nBPsdiQv1LHi8nGq9pEj5Yc0MTXgppxm5asZA_b1NxJPCe0Pse4w6r3nY4kkdIvyoon8d9p9e2hzyzYPTGagrSO9lwZH4zkP4zACGb9AI9EB2r3NeYb2T-gG68THFEcH6Fz3p-TVZMCq1gxAPx7cDFVIwoiK0dgFReZmUcv5i3jAFkjfvr59wGxil80QqQd3fuBXrJZYNbyXJV_wSZvjDTt3YhYSNUjKgv4OdL-N7yExSZjHbqiNHnZD3kKEY3yhQEJcksZk3IbsD2ECVD6TlrqYYU_UZ0vC2lnTSNKR9L-AH7G3JO7eBzrVxULvq2FrESoZwEAOkGB4b0zVjUq70Lx1EmVcTiWc_E2PiPbJl4WNLh_hvq7yY8fxkHcDreZVWDUYCOIi3pRLelcQTuQGn1li9ZnQGKWkbdd2YeTapM8I6ifT76BvyoZZt-J77HbbWJZMHhB8DnDtbD2dVEs8TMnfdJ3kZOiclPJpgvQv9s_aME5eHXMJKYYFfS26-EyKtKprgCn42Np44wjo229opDUJaxDAQegAJDBtTHqupsQGYZVIuGXHqSbMRRLITmjhG3KF9hy0uuwjXodMm2lswf8EfAFng6pQuZQ1qksJwykcXVH4cH1y_XEn1k3ZpRz1CIAC_aVdmMlHKRw1NiqVVQ2X-JDSgz71qEbuh2Ulg1vopM_ZAP0bmuKR1dSVx4zWDLKlTFXP8yaW2WDfOCB8p3XzEBYfSl1C29YVco6hzwSFEkZAof2M5XFPm16aZIkglXqildY2xNeFzNFdKHOTglzHFfXdQa7qGOxOP4MgMtlLslH2EIlyCLjdc7owr65zJUybh9AP0bINTBNzkrwGlvDjGq9tmt42YmDligH3r-Hn32xw0D4DSqFzTcMWtYjd-xl9jOrTalsM_phpQUkUgxYXDoS7RzBrERMraZYXmGceSxzQOIlw64q186TkVmTN-tdIXRTZ-ClF8rR5WgNCl3ESbES-X-x3VDQVW-2Gm1RAaEYtG_YjSCWKsqbUYyUhlA6kLpUXRNl_gZqBJAYu3kCuo108XQILoZRh0DTgG-uCa22nooR1rxe2xa-6qm0KCjZ3C6WRzeDlWaCq3Mr_8lAwu5GRBlJx7nXMuOVxA6J1av-z6rw1INAK2AwHdKkKHqRecO7Vborfms4uHXutsrJwZ-OOjVcU5683MOjsOKmazDRBly7CSZ3BnY9imkCWVu6Hr1SoSOMaFRerrNaqBD6qQmPfuVn2CpxQdlsKS35Zww_dNuefd7uz1BlX27XXyMZl0fEG5ALOM7FdQUDdDLdEbvqsKhtOwbEO3H9CFw9kp05dkjFqmBO76aEfl904Ghjfcmk_NLRiRJPwN-34MPJe7JAD5P2U7a6KQfV7rKtVV-V5WrZY4vIc19iShyL321hbuy9lTCbxIjokBdbRPBx1H01WpdRw-eibQ-hbNKvAWUeVJkq9gdHuP2x7YCiXEDpbp-fqGEckPyM9BbhlSFBWr_YwaBrtQLQGhDsiHDZi75ly8kegygyMV_GzzelO3gYZt14XRQnScya6Bt8YpNo-fMAtlZbSVzEX2T-7xgsb99kTGwe31IhKyEy6f9RlhMPiN_rlDPJiNfsw_9m6d5TqNlypf083uwwkTsDO3PyUOzVskdUqKDK77SVzDahAok9fHq-utZWqbVoRsRgc4y5wV-nJr3kcT7s8hTa8enM-ZFkFyHsqtW7n-BhYrRPyw4C4lViLW2TtuVdBoOXRoaN3_0wG547gs_ScCCfkvg0STlRDxDxJPLsdwDR2wc_3TJYjxwIZuNsYK3DALJWLsplpgDOgvEKYy3w-gkq6EthfRkb1Ae3WN0NQAFt0wQ8loAB9nHt3xSNiB397VDfknDiSsDjayyOh8taSf2qbPE0hD8jT6bZF1nu2VdDHcGoUf99UKKaQ99cwbtGFZxcYTIItxYv58YUd_egErgjbnjv7tv49iqWF044USxaQNLSZCtwRTlWOguTHvwkIoNwkx3e6axfrajHq2Sy8oCWfESAz4-64ojTYKXM6tRTRnpo0ZaH8VKNZ_4zw25y5pvFdQ_lRAIErnMzbL6IbnaAFW2QLHkxPqd55Rj5Njc26WBREWT5uj7AuXXPLc8ppHknj7OAQnrhY0aoTfvsmOVug-8RGKLuL4rsTnYl4um8-QFH8woIxWBxSuzk0czW02icP8HEFECwb_CKa04-mZbPZ5JH6YdNJxJj6Be3vcajRURRZOjJp_EAjzoDXru-iIrAnhHt-kBJ3O4fwPPh4eppY_su2Hnr0IXsjXVecoQ12DfOHNHkNh8v0eokUgoLWIdmgPJ4aspLVvPcxEaZgyKH1PE0HgjhxAstbFAlFfGt9qiRGbT7xDP6EApB1ukExenM5VjkqddV69xGQdT_xA4e2sKtNmnLkz_01d_aOzhBlKqBW_S4Ja1pEqvJWbt3HgaXdMPuetek4OqC9RHVWp7azykCoqOsU82QHfqJ28Ku3TJi5lFjlM7BfR8exhcaTBklp6VqSu6oVSH3979hVkeuJ7UQPv2_wcIiISBwz7g9nqKShaAK_Tqv4BnKUZ3Ahi5g9goSGAJD_486kmaKyZzt5m9y_SMClOWVqbjutMTuSiKfPMJhLPDrI69SAdI5jLwuDYXXUmHYSNVYxAmkAX0Lx3tQGT_1ll-A5Q2D_KKMUMrjVtsjncPZ8XXkTInek_fjTJS6p4ATXdJqqOp1KfiXDBLSUiqRIXnCOfiFWGbR3heEszPTgdWQcpg1YpS8s81ZPjo3VpW9g5N0PVITP5iCI9pnz3rqNq4EVfjddAlyUNtcaWVw15Z5bKeDLBCmvhrOSwpyJ0DVutqgnl_fVe-zRc9gneaz8BJFcy_rlQQVmHk9DnHF-4BjeAqfzRMQ4pwXnRYjUwavslG6p53qh40AlFC7TQL5l5SMtCTB0y_0kRFEFS8Bebyd6tb_PVRXKdCosBOS3vLKqlqmC-afiM1i13CyUAKAMnZWs2knlrAB2u9lnUFOmGcGxi-g9ThAl-shOiQHxc2q5IWzkzfFcApjSm_5SkJx0ZGYMiJ6sPBNmYC5cphhOUFYStI3g8kExMsff0KCnpj75uOv3fV_2Qd4N2fA4FJT_pwEWHeuP5JKn3USRjEi4EzlEQLKWBbIz25cjN3kOPW1JFbzV5uKwRBSGkllTz9BJLbBXD2zUkuprxUuDBr33xxgkzmP1fes6-RJ8dIskcmGP_G8l4tuoejuurPmrfYShbBRIC1dGMRqW0xETu6BRiJu_XbTelCzgZCFkwIxRFPvEECm171DQnxX3WSAo1SKaLMsODQctt0auHwcBC68Jd_bLl6nYdSzQC7Qh54aIpmc8dTFpFPLn2Y2lPKwKS-JJ8MOTCeBVPsLKs80bHhS5hIQ3ABnHDWCiZUxqkOq_YfWBtFKzNx1c88MY_2745oYlZlNdgq2MLWINFOe3EILL4RA12uHZboCk8N4o
|
||||
.chatgpt.com TRUE / TRUE 1801313300 __Secure-next-auth.session-token.1 GhhnWCvbm-RpOpViaaPM5nxsVWAedVh1163YjjQh3xDcHU40Vp0Le5-032kfjgDsIY3FDvHOt2Y4B2d8m-CVZIm2-4WMVeY3R5gsXJ_10Zro7l5s-VdYm8230xiVBSmTLB0RuCB3CZsnZfzlHmuEfKyDm3l8FZxh3lUuXYyLCFAthft2H_9BcWaMJ_hLJIhhvi5BxcRDHlVVq_gfmfjOcBAPZ-W75cvNuBI7ZJyxR2PUKS6CZGClshl0-f3MF26I7smqZqg8ox56SarcrtPaHtt8v51GsevTweUboCfpYyxJthBUPrSGujqJTGatHWYj55PB0DF7OqcTgwXBa98RHjGIzHWNUESZ3MPHd8Y6at3OG_Rp_61Xzn20BM3tTX_wqefCoCI5-9POpg13USBwwV5BZTE8MdE4giBHHCR6oTAyCJ2xulOQts1jPsxJb2dTHZfEYh9k_fafIIX2Sjs9SZRUa4jlaIkft5Vbofb2EZnNy-Z2mi5YJXjcotCbVcT2M57LNpwKNEX9F0xGzbZ1_5mMC9_sMyhUQc8_K7wQztKQxBoQ_kFkvt653Ln3CL29nFLueS4kBnY29_LdhVMIUQpMBXdjPADNYz_B_qNMkwKJYeeuvd2JFhKTHbPRI2O5ninIgue_f7WElYrh3GDTQ1lRHNPkKN5zo4I6EU9xmZMi_sFWmHPCP6tmus2logQ4mBcj9VD9WE1J5XHQdb3gtolhuUH4D8VEaTuszywkXVxTqOohp-VYeYH2kPC2UvPWiUiG5c1-g8XdUlohWKgh0EgkyF_H1PrfKyZVNx_9FiLK5qm5XjBoSk9hU5Q9-5qaIdU8PYg7LZ9EvuS01PFr5KrC5i_xR0EADiZs3bL5qGQBAqwTZnEKIaENNf39YTTnfCRXWrAiHQjisalXEKYG9_UOz1yFCFQfG2v05XGxeI7zAkwS8Jia78KF1EKYPd1dgi7XGPHLfeQDVAelY0fktMoRL0JFQGBW8zzguypxbzVQDZ2HokZla2ftJJ00U7iAQSXEcNrXCfcwt9yVE5rGroFop0SSEJFPEW7CYbYZaqvmjUVfZWlaMRZ9dbPdJfLqeSvD781PZo5YWhpVB87eNiYKBG7oYHJ0EIRPkPCpl1HZwvpjOKa2j2rdQhhSIEO__3xyeuKER0QHLjnOGRKt_pSmb1jt9ALiQE5_PdxRIvKyLmvUGmg6ht7QphU-_UTTRTbnHKof4lHTKIdDLcH21gA1TvvlRqkKu38B8CC8GkFRtB4ZfPryAGioLaV5AbtypbHMXX7HWhYFp3dHJwolnR5NBIrbJyufLHMtkjbyoq2x80R9ZznMhRo-6OvZ9G2r78Ja5YpkCa2ujDM7GCi4KQV-f9vsjJWM1ZEpSyzlPGqPINqg9ldiG_kTwQID4K6aAhgraAUgi56SkjhuUJ82RSc8pRplJxgUJmTDCq6IXDs1pMdLuXqIMH2V7Oof2--jUGuTesObl3GU-cuim-44-co6C6xFFf-cbndaFCbSLRf4iPkUSe8WKLWhPc7MHTGzg_15qfwAFPL6HNuFMAG_ZrdTVtgWt32xkjgwGkX8tme-PIbYd9-Y_W2XIkPTnax_G3tiO2A.LnMy01EwaudEGsvaRUyHhw
|
||||
.chatgpt.com TRUE / TRUE 1801313300 oai-client-session-epoch 2b3bbca4-657f-475f-9b9d-f6bccd23cd28
|
||||
.chatgpt.com TRUE / TRUE 1824911256 __obi k5MxotcOMz8Od-B36lQhnw
|
||||
chatgpt.com FALSE / FALSE 1808925684 g_state {"i_l":2,"i_ll":1793373684015,"i_b":"fHmWi8YYGxF69feHLENHdOYj6NjWQmIfQ/en2iOx4QI","i_e":{"enable_itp_optimization":24},"i_et":1793373684015,"i_p":1793456715849}
|
||||
chatgpt.com FALSE / FALSE 1801151236 _account personal
|
||||
chatgpt.com FALSE / FALSE 1801151236 _account_is_fedramp false
|
||||
chatgpt.com FALSE / FALSE 1824479237 oai-hlib true
|
||||
chatgpt.com FALSE / TRUE 1825078604 _dd_s_v2 isExpired=1&aid=8851ccad-84a0-41c9-9e51-12138091d13f&c=0
|
||||
chatgpt.com FALSE / TRUE 1824911245 codex_sidebar_width 340
|
||||
chatgpt.com FALSE / TRUE 1825073599 codex_sidebar_state expanded
|
||||
auth.openai.com FALSE / TRUE 1827935236 __Host-usc_MfcvSjhtHJ4Vjs2U4Nw0Qtxi eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXMiLCJwcm90b2NvbF92ZXJzaW9uIjoiMyIsImNvb2tpZV9pZCI6Ik1mY3ZTamh0SEo0VmpzMlU0TncwUXR4aSIsImVuY3J5cHRlZF9hdXRoZW50aWNhdGVkX2RhdGEiOiJ1c19KeEg4eDA0Tm01QU5sRVBEQnpFeUE3S0YiLCJjbGllbnRfc2VjcmV0IjoibUpvSnYydDJwOHRPS3c3d3VHVWdCZDZGIn0.kAwjBkd9FlWaq7Z5tvR2_-rT23UJ8Ld1xQc-qKiqQ6aFhF8XMvtr_BzVMvpQaGLnvfVc7t_AY3Dogx16elz-rQ
|
||||
auth.openai.com FALSE / TRUE 1827935236 __Host-unified_session_manifest eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJ2ZXJzaW9uIjozLCJpYXQiOjE3OTMzNzUyMzMsInNlc3Npb25zIjpbeyJjb29raWVfaWQiOiJNZmN2U2podEhKNFZqczJVNE53MFF0eGkiLCJ1bmlmaWVkX3Nlc3Npb25faWQiOiJ1c19KeEg4eDA0Tm01QU5sRVBEQnpFeUE3S0YifV19.hhS9p7t7nSo8v3EiHW0PI76POG7Z-ql3124u8e53op8XzqAInpsILk6ZtgQa_aP_mgrsaHrCnjOb1vayzdhV4Q
|
||||
.openai.com TRUE / FALSE 1824911233 oai-did 0c32ca2d-2d74-401c-bab0-059bdc4bde89
|
||||
.openai.com TRUE / TRUE 1824911233 oaicom-stable-id 33c211e8-9ac9-4e54-871a-863178efbef7
|
||||
.auth.openai.com TRUE / TRUE 1795967236 oai-client-auth-info eyJsYXN0X2xvZ2luX2lkZW50aWZpZXIiOnsidHlwZSI6ImVtYWlsIiwidmFsdWUiOiJkaW1ldHlsaHlkcmF6aW5lQGdtYWlsLmNvbSJ9LCJsYXN0X2xvZ2luX3N0cmF0ZWd5IjoiZ29vZ2xlLW9hdXRoMiJ9.auS8AQ.8u7Dcly5k9TFFwKhrIBmrvk2IRu_8NtS_ZGIHzKTC60
|
||||
.arduino.cc TRUE / TRUE 1824949595 _iub_previous_preference_id %7B%2211225532%22%3A%222026%2F10%2F31%2F02%2F26%2F35%2F507%2F83c8bf%22%7D
|
||||
.arduino.cc TRUE / FALSE 1801189595 _gcl_au 1.1.1885983490.1793413596
|
||||
.arduino.cc TRUE / FALSE 1827973595 _ga GA1.1.180986840.1793413595
|
||||
.arduino.cc TRUE / FALSE 1827973595 _ga_NEXN8H46L5 GS2.1.s1793413592$o1$g0$t1793413595$j60$l0$h564715914
|
||||
.arduino.cc TRUE / FALSE 1824949595 ajs_anonymous_id 76d835f1-8aac-45c2-a138-257760e513d7
|
||||
.arduino.cc TRUE / TRUE 1827973595 FPID FPID2.2.uyDYHJgMO%2BYjjCE7bFqSbMqbj9nbqgU9FROhFHNTn9M%3D.1793413595
|
||||
.arduino.cc TRUE / FALSE 1801189598 _fbp fb.1.1793413592985.2059045086
|
||||
.arduino.cc TRUE / TRUE 1827109647 _twsid 1793413596179-460853959.1.1793413647812
|
||||
.arduino.cc TRUE / TRUE 1827109647 _twpid tw.1793413596179.190626824812220358
|
||||
.arduino.cc TRUE / TRUE 1824949596 _iub_cs-11225532 %7B%22timestamp%22%3A%222026-10-31T02%3A26%3A35.507Z%22%2C%22version%22%3A%221.109.0%22%2C%22purposes%22%3A%7B%221%22%3Atrue%2C%224%22%3Atrue%2C%225%22%3Atrue%7D%2C%22id%22%3A11225532%2C%22cons%22%3A%7B%22rand%22%3A%2283c8bf%22%7D%7D
|
||||
.arduino.cc TRUE / FALSE 1827109598 _ttp A3HkwgiIiGlV54wJD7hIM2iCiGr.tt.1
|
||||
.arduino.cc TRUE / TRUE 1801189596 _gtmeec-tt e30%3D
|
||||
.arduino.cc TRUE / FALSE 1827109596 _uetvid 7f560920d4d211f1bd49cb6e3d4f19e1
|
||||
.arduino.cc TRUE / TRUE 1801189597 _rdt_uuid 1793413597290.7ad6f7fe-8e3d-48fa-92e6-1af057d5a051
|
||||
.arduino.cc TRUE / FALSE 1824949598 _clck jl8ycm%5E2%5Egar%5E1%5E2495
|
||||
.arduino.cc TRUE / FALSE 1827109598 _tt_enable_cookie 1
|
||||
.arduino.cc TRUE / FALSE 1827109614 ttcsid_CO1UDRRC77UEH42ABQ4G 1793413598348::t8WlNlYJwlqWdxMB3lyP.1.1793413614247.1
|
||||
.arduino.cc TRUE / FALSE 1827109614 ttcsid 1793413598348::M1h267UHcXM2lslp1GLM.1.1793413614245.0::1.-8505.0::1380.1.1252.885::0.0.0
|
||||
.fonts.google.com TRUE / FALSE 1828006220 _ga_XPW1QSKFW4 GS2.1.s1793446218$o2$g0$t1793446220$j58$l0$h2063114273
|
||||
.fonts.google.com TRUE / FALSE 1827997522 _ga GA1.1.505137514.1793437523
|
||||
.osdev.su TRUE / FALSE 1825080143 phpbb3_14fmv_u 1
|
||||
.osdev.su TRUE / FALSE 1825080143 phpbb3_14fmv_k
|
||||
.osdev.su TRUE / FALSE 1825080143 phpbb3_14fmv_sid 94f8f29f3a2afc0987f6915af932ff72
|
||||
.fandom.com TRUE / FALSE 1828104153 exp_bucket v9-24
|
||||
.fandom.com TRUE / FALSE 1828104153 exp_bucket_2 v5-82
|
||||
.fandom.com TRUE / FALSE 1825080159 _pubcid 6833a2df-6ac0-403b-83e2-2ddc49d174c6
|
||||
.fandom.com TRUE / FALSE 1825080159 _pubcid_cst znv0HA%3D%3D
|
||||
.fandom.com TRUE / FALSE 1825080164 OptanonConsent isGpcEnabled=0&datestamp=Sun+Nov+01+2026+14%3A42%3A44+GMT%2B0000+(GMT%2B00%3A00)&version=202609.2.0&browserGpcFlag=0&isDntEnabled=0&isIABGlobal=false&hosts=&consentId=cbd2df03-5ed2-459a-83ce-d85060d68b2a&interactionCount=1&isAnonUser=1&prevHadToken=0&landingPath=NotLandingPage&groups=C0005%3A1%2CC0004%3A1%2CC0001%3A1%2CC0003%3A1%2CC0002%3A1%2CV2STACK42%3A1&fclco=&lastConsentTs=1793544162&intType=1&crTime=1793544164421
|
||||
.fandom.com TRUE / FALSE 1825080162 OptanonAlertBoxClosed 2026-11-01T14:42:42.902Z
|
||||
.fandom.com TRUE / FALSE 1825080162 eupubconsent-v2 CQtAQoAQtAQoAAcABBENC3FsAP_gAEPgACiQM_tR_C7dbWlD-bZ3abskeYxP1_ho6sQwBgaAkmIFzJOQZJwGgmEaJAyINCACCBAAITDBIQNkHBBUQUAgIIAFIABMYEwE4RJKIABAgAMZIwpACAAOiIEAcQCZggBXlkQkmBgoQMrGSFwAgCAAAACJKIAhAIAFAQOAAAEgZAIAAUAAwkgIkQhAiEIAAGEAQBCMAAAAIAc6Yns6dBmxs-Tyff6Pn_f71fS7X_ve_n3zv8oXn7rr6-__f3-7t__ubf-___f-__7Z_zM_37_v_ftb-_34M9gAmChUQAlEQKBAoCEECABQVhAAQIAgAASAogIATBAQZAwAEGESAEAIAAAAAAAAAQABAAAIAAhEAEABAIAQIBAIAAwAIAgIAEBAACABQgAAAAAOgIAAQQCBAABEAEAggQgAAAACSFAAACAAAACECIAAAIgAAAAAEAAgAAAAACAQgkAIgAAAiAIAAEAAABCMEAAAIAYAooDIFoKwIOAyNMAQfMEiSHQRAEwQkZBkQkqCQeKYogQQ5AbFJMANPEFADLpZIQ_1As2ALkS7DqSsJAyAAAAA.f_wACHwAAAAA.IM_tR_C7dbWlH-bZ36bskeYxf1_ho6sQwBgaAsmIFzJOQZJwGkmEaJEyINCACCBAAITDBIQNsHBhUQUAhIIAVIBBMYEwE4RpKIEBAgCMZYwpACAAOiIEAcQCZggBXlkQkmRgoQMrGSFwAgCAAAACNKIAhAIgFAQOAEAEgZAIAAWAQwkgIkQhAiEIAAGEAQBCMEAAAIAc64vv6dpuxs-Tyff6f__f73fS7X__e__33_8qX3_r76-___3__t__vff_________9__-___________34AAA
|
||||
.fandom.com TRUE / FALSE 1825080162 OTAdditionalConsentString 2~20.57.61.70.83.89.93.108.122.124.144.149.161.184.196.230.236.255.259.272.311.313.314.320.323.340.358.370.385.415.424.429.445.469.486.494.495.550.574.576.621.723.797.798.864.955.959.981.986.1046.1047.1048.1051.1067.1095.1097.1109.1126.1166.1171.1192.1205.1220.1276.1301.1329.1342.1362.1415.1421.1423.1449.1514.1567.1570.1577.1598.1651.1659.1661.1716.1720.1725.1735.1753.1794.1808.1870.1878.1882.1889.1898.1911.1928.1942.1958.1985.1987.2016.2038.2068.2069.2072.2074.2084.2107.2109.2135.2141.2213.2219.2223.2224.2251.2253.2262.2271.2275.2295.2316.2328.2331.2373.2406.2415.2416.2427.2453.2501.2504.2506.2526.2531.2559.2567.2568.2571.2575.2577.2579.2589.2595.2624.2627.2639.2657.2660.2677.2689.2699.2778.2813.2822.2824.2826.2832.2833.2869.2872.2878.2908.2920.2963.2972.3001.3005.3023.3031.3051.3100.3107.3126.3133.3137.3153.3169.3177.3186.3200.3201.3213.3218.3233.3234.3235.3253.3254.3266.3286.3289.3299.3303.3309.3323.3331.3731.4131.4531.4631.4731.4831.5231.6731.6931.7131.7235.7831.7931.8931.10231.10631.10831.11031.11531.11631.13431.13632.13731.14034.14133.14237.15731.16831.16931.21233.21731.23031.25131.25931.26031.26631.27731.27831.28031.28332.28731.29631.30331.30532.30732.32531.33931.34231.34631.34731.36831.38732.39131.39531.40632.41131.41531.43631.43731.43831.45931.47232.47531.48131.49231.49332.49431.50831.52831.54231.55631.56131.56831.56931.57131.57231.57531.57931.58131.58631.59831.59832.60731.60831.60931.61531.61931.63831.63931.64031.64631.66531.66631.67031~dv
|
||||
.fandom.com TRUE / FALSE 1809096162 eb 56
|
||||
.fandom.com TRUE / FALSE 1809096162 wikia_beacon_id jrNvJKRteD
|
||||
.fandom.com TRUE / FALSE 1828104162 _b2 9V7qem8_X4.1793544147498
|
||||
.fandom.com TRUE / FALSE 1809096162 wikia_session_id bu-MHlgPls
|
||||
.fandom.com TRUE / TRUE 1825080162 fan_visited_wikis 63539
|
||||
.fandom.com TRUE / TRUE 1827240165 _scor_uid 5bbfc6fa3553450bbe88e8b0773c0f71
|
||||
.fandom.com TRUE / FALSE 1825080165 FCNEC %5B%5B%22AKsRol-xXf_bPdvw_2sXo7k7ui83NlVD-5fOIgQfOyRAdy2aGTcyfAW9SsoCaOyeKaAMyOxFAXleWZAy_Oj5g9pyA3OVxhDbPw%3D%3D%22%5D%5D
|
||||
.fandom.com TRUE / TRUE 1827240159 __gads ID=fa7b95ba97aec478:T=1793544159:RT=1793544159:S=ALNI_MZ6kDKscgIR2hcjPfeTU_pJZz7acg
|
||||
.fandom.com TRUE / TRUE 1827240159 __gpi UID=000015dad35c29c9:T=1793544159:RT=1793544159:S=ALNI_MZ4GwR9KtAgAYv6plEEelR4FWr4fQ
|
||||
.fandom.com TRUE / TRUE 1809096159 __eoi ID=7d6d8cec9f3fbf35:T=1793544159:RT=1793544159:S=AA-AfjYewsD92810X0-I7-wdLVgx
|
||||
.fandom.com TRUE / TRUE 1825080166 _au_1d AU1D-0100-001793544166-AGQHV70A-JH51
|
||||
.fandom.com TRUE / FALSE 1797864177 __bm_m
|
||||
.fandom.com TRUE / FALSE 1816872167 _cc_id 81152dcb30a7059e042ab9c302e75872
|
||||
.fandom.com TRUE / FALSE 1794148961 panoramaId_expiry 1794148961325
|
||||
.fandom.com TRUE / FALSE 1794148961 panoramaId 27cfb0e6c446dfdd49e5815414f8185ca02cfe281ef4292a9b690cf8485b61da
|
||||
.fandom.com TRUE / FALSE 1794148961 panoramaIdType panoDevice
|
||||
.fandom.com TRUE / FALSE 1809096168 nol_fpid yxiewvbygxucbjwp3cd0sxgpjfncn1793544168|1793544168334|1793544168334|1793544168334
|
||||
.fandom.com TRUE / FALSE 1825080171 _ce.s v~04ae0497a12f3ff16b8e8f09d8720be927b1ff28~vir~new~lva~1793544169396~vpv~0~v11.cs~462205~v11.s~838d24e0-d602-11f1-a177-53dff29a62af~v11.vs~04ae0497a12f3ff16b8e8f09d8720be927b1ff28~v11.sla~1793544171055~v11.wss~1793544171056~v11ls~838d24e0-d602-11f1-a177-53dff29a62af~lcw~1793544171067
|
||||
.fandom.com TRUE / FALSE 1827412970 __qca P1-c9a21ed1-8fb9-4296-9078-4ccb7e6c321e
|
||||
.fandom.com TRUE / FALSE 1793630571 _ce.clock_data 6530%2C177.3.221.184%2C1%2C23adcc7665e8a01746a0182a1b26b389%2CFirefox%2CDE%2CDirect%20Traffic%2C
|
||||
auth.fandom.com FALSE / FALSE 1809096166 g_state {"i_l":1,"i_ll":1793544166434,"i_b":"X4p4vKuHrITMEpRuzJ6XdHY8v9FWObCS7yH5/l6sdk0","i_e":{"enable_itp_optimization":24},"i_et":1793544166434,"i_p":1793630566705}
|
||||
auth.fandom.com FALSE / FALSE 1793630566 fanid_expiry 1
|
||||
addons.mozilla.org FALSE / TRUE 0 taarId 9d5453042692a11ce787e3ca0879c6db142fa04f856be0194474f0af47c31bf5
|
||||
.youtube.com TRUE / TRUE 1809167513 VISITOR_PRIVACY_METADATA CgJERRIEEgAgRg%3D%3D
|
||||
.youtube.com TRUE / FALSE 0 PREF f6=40000000&tz=UTC&hl=en
|
||||
.youtube.com TRUE / TRUE 1825138673 __Secure-1PSIDTS sidts-CjUBnJZzahHtW_M8Jwed7pJPsTJzpHH5lwgM7SD-fmCnaN1ktUVyi1xv3ZIwmB_4dU68jEqxnBAA
|
||||
.youtube.com TRUE / TRUE 1825138673 __Secure-3PSIDTS sidts-CjUBnJZzahHtW_M8Jwed7pJPsTJzpHH5lwgM7SD-fmCnaN1ktUVyi1xv3ZIwmB_4dU68jEqxnBAA
|
||||
.youtube.com TRUE / TRUE 1828161786 __Secure-3PAPISID Ge3Zs4CbZc0poi9T/ArPlRl005gM0xNfTG
|
||||
.youtube.com TRUE / TRUE 1828161786 __Secure-3PSID g.a000EAnGNB0WmAKACI1xxFZFRPMqOxCffokWZ8xjwgJriO7y39RHthDgvQyHS3S2EMmjA8DrXgACgYKARMSARESFQHGX2MiLrwX-yYljZr-SoV-ywBkUBoVAUF8yKqY8mdbHqSLAxrUzIc8xOT20076
|
||||
.youtube.com TRUE / TRUE 1825139779 __Secure-3PSIDCC APD_SKFAG6jAEKHrPFquSgH40ZXSzRx5wpmfAtlL5q93TaUGJ9S1ZYAz7jado-H5eWGQEJYprw
|
||||
.youtube.com TRUE / TRUE 1809154741 __Secure-ROLLOUT_TOKEN CJiGs7i30fGbqAEQmd_o7drolwMYn82Y8trolwM%3D
|
||||
.youtube.com TRUE / TRUE 0 SOCS CAI
|
||||
.youtube.com TRUE / TRUE 1809159263 __Secure-YNID CuwBCAUSngEBDB6PDEsxr5FbktSlQYVc0LpWASLD7Bq8XAPxaZgPRjtPEHWenshRELjdaVKG6dV0IlGbFlNJDWQ_4vQtYiiVKyOAoFt3Sg0Wg7fyF27aYSxBGYX02pfxzEELB-sS6asfSiOnxTT8_jMr45t6_tFdtKnlpPC2sGCiL1zJGL4GtwKWvrF_N4v82KqJRV8_u8HPdKEa4oGALTa_CTmP4CgFMkUBHFj1agwx7RlJczonIpL5pBWTuRCPUMlnmkfiZk2jC16LEYgHItCUgtVLYlHpkI3GmY3vnGoc2E6x2fppJCYtjZYleQg
|
||||
.youtube.com TRUE / TRUE 1809167513 VISITOR_INFO1_LIVE wAcMOSKNcvM
|
||||
.youtube.com TRUE / TRUE 1793616537 GPS 1
|
||||
71
utils/downloader.py
Normal file
71
utils/downloader.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import qrcode
|
||||
from dotenv import load_dotenv
|
||||
from telethon import TelegramClient
|
||||
|
||||
from ..core.config import PHOTOS_DIR
|
||||
|
||||
ENV_PATH = Path(__file__).resolve().parent / ".env"
|
||||
load_dotenv(ENV_PATH)
|
||||
|
||||
API_ID = os.getenv("API_ID")
|
||||
API_HASH = os.getenv("API_HASH")
|
||||
|
||||
|
||||
if not API_ID or not API_HASH:
|
||||
raise ValueError("API_ID or API_HASH is not defined in .env")
|
||||
|
||||
API_ID = int(API_ID)
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
SOURCE = str(input("channel username:")).strip().lstrip("@")
|
||||
CHANNEL =SOURCE
|
||||
|
||||
DOWNLOAD_DIR = SOURCE
|
||||
MEDIA_ROOT = PHOTOS_DIR
|
||||
|
||||
async def download_photos(client):
|
||||
download_path = BASE_DIR / MEDIA_ROOT / DOWNLOAD_DIR
|
||||
download_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async for msg in client.iter_messages(CHANNEL):
|
||||
if not msg.photo:
|
||||
continue
|
||||
|
||||
filename = f"{msg.photo.id}.jpg"
|
||||
full_path = download_path / filename
|
||||
|
||||
if full_path.is_file() and full_path.stat().st_size > 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
await msg.download_media(file=full_path)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(e)
|
||||
|
||||
async def main():
|
||||
client = TelegramClient("qr_session", API_ID, API_HASH)
|
||||
|
||||
await client.connect()
|
||||
|
||||
try:
|
||||
if not await client.is_user_authorized():
|
||||
|
||||
qr = await client.qr_login()
|
||||
|
||||
image = qrcode.QRCode()
|
||||
image.add_data(qr.url)
|
||||
image.make(fit=True)
|
||||
image.print_ascii(invert=True)
|
||||
# user = await qr.wait()
|
||||
|
||||
await download_photos(client)
|
||||
|
||||
finally:
|
||||
await client.disconnect()
|
||||
|
||||
asyncio.run(main())
|
||||
119
utils/sync_yt.py
Normal file
119
utils/sync_yt.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.config import BASE_DIR, YT_DIR
|
||||
|
||||
COOKIES_PATH = Path(f"{BASE_DIR}/utils/cookies.txt") # или из config
|
||||
|
||||
|
||||
GLOBAL_ARGS = [
|
||||
"--socket-timeout", "20",
|
||||
"--retries", "10",
|
||||
"--fragment-retries", "10",
|
||||
"--js-runtimes", "deno",
|
||||
"--remote-components", "ejs:github",
|
||||
"--embed-thumbnail",
|
||||
"--convert-thumbnails", "jpg",
|
||||
"--merge-output-format", "mp4",
|
||||
"--sleep-interval", "5",
|
||||
"--max-sleep-interval", "15",
|
||||
"--sleep-requests", "3",
|
||||
"--limit-rate", "3M",
|
||||
]
|
||||
|
||||
DEFAULT_FORMAT = (
|
||||
"bestvideo[height<=720][height>=480]+bestaudio/"
|
||||
# "bestvideo[height<=1080][height>=480]+bestaudio/"
|
||||
# "best[height>=480]"
|
||||
)
|
||||
|
||||
def load_channels(config_path: Path, yt_dir: Path) -> list[dict]:
|
||||
if not config_path.exists():
|
||||
print(f"not found: {config_path}")
|
||||
sys.exit(1)
|
||||
|
||||
data = json.loads(config_path.read_text())
|
||||
channels = []
|
||||
for raw in data:
|
||||
name = raw["name"]
|
||||
print(raw)
|
||||
folder = raw.get("dir", name)
|
||||
channels.append({
|
||||
"name": name,
|
||||
"link": raw["link"],
|
||||
"path": yt_dir / folder,
|
||||
"enabled_sync": raw.get("enabled_sync", True),
|
||||
"extra_args": raw.get("extra_args", []),
|
||||
"format": DEFAULT_FORMAT,
|
||||
})
|
||||
|
||||
return channels
|
||||
|
||||
def build_cmd(ch: dict, dry_run: bool) -> list[str]:
|
||||
channel_dir: Path = ch["path"]
|
||||
archive = channel_dir / "archive.txt"
|
||||
|
||||
return [
|
||||
"yt-dlp",
|
||||
"-P", str(channel_dir),
|
||||
"-o", "%(upload_date)s - %(title)s.%(ext)s",
|
||||
"--download-archive", str(archive),
|
||||
"-f", ch["format"],
|
||||
"--cookies", str(COOKIES_PATH),
|
||||
*GLOBAL_ARGS,
|
||||
*ch["extra_args"],
|
||||
ch["link"],
|
||||
]
|
||||
|
||||
def sync_channel(ch: dict, dry_run: bool) -> int:
|
||||
if not ch["enabled_sync"]:
|
||||
print (f"skip disabled: {ch["name"]}")
|
||||
return 0
|
||||
|
||||
ch["path"].mkdir(parents=True, exist_ok=True)
|
||||
cmd = build_cmd(ch, dry_run)
|
||||
|
||||
print(f"\n=== {ch['name']} ===")
|
||||
print(" ".join(cmd))
|
||||
|
||||
if dry_run:
|
||||
return 0
|
||||
return subprocess.run(cmd, check=False).returncode
|
||||
|
||||
def parse_args():
|
||||
p = argparse.ArgumentParser(description="Sync channels via yt-dlp")
|
||||
p.add_argument("--channels", "-c", type=Path, default=YT_DIR / "channels.json")
|
||||
p.add_argument("--dry-run", action="store_true")
|
||||
p.add_argument("ytdlp_args", nargs=argparse.REMAINDER,
|
||||
help="extra yt-dlp args (after --)")
|
||||
return p.parse_args()
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
channels = load_channels(args.channels, YT_DIR)
|
||||
|
||||
global_extra = args.ytdlp_args
|
||||
if global_extra:
|
||||
print(f"global extra args: {global_extra}")
|
||||
|
||||
enabled = sum(1 for c in channels if c["enabled_sync"])
|
||||
print(f"channels: {len(channels)}, enabled: {enabled}")
|
||||
|
||||
failed = []
|
||||
for ch in channels:
|
||||
if global_extra:
|
||||
ch["extra_args"] = global_extra + ch["extra_args"]
|
||||
code = sync_channel(ch, args.dry_run)
|
||||
if code != 0:
|
||||
failed.append(ch["name"])
|
||||
|
||||
if failed:
|
||||
print(f"\nFAILED: {failed}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
114
utils/update_audio.py
Normal file
114
utils/update_audio.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import asyncio
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..core.config import AUDIO_DIR, AUDIO_EXTENSIONS, COVERS
|
||||
from ..core.db import Base, SessionLocal, engine
|
||||
from ..models.audio_model import Audio, AudioPlaylist
|
||||
|
||||
|
||||
def parse_preview_path(path: str) -> tuple[str, datetime | None]:
|
||||
p = Path(path)
|
||||
stem = p.stem
|
||||
|
||||
match = re.match(r"^(\d{8})\s*-\s*(.+)$", stem)
|
||||
if not match:
|
||||
return stem, None
|
||||
|
||||
date_str, title = match.group(1), match.group(2).strip()
|
||||
|
||||
try:
|
||||
date = datetime.strptime(date_str, "%Y%m%d")
|
||||
except ValueError:
|
||||
return stem, None
|
||||
|
||||
return title, date
|
||||
|
||||
|
||||
DIRECTORY = AUDIO_DIR
|
||||
|
||||
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def update_yt():
|
||||
async with SessionLocal() as session:
|
||||
cached_playlists = {
|
||||
(pl.title): pl for pl in (await session.execute(select(AudioPlaylist))).scalars()
|
||||
}
|
||||
|
||||
existing_audios: set[str] = set(
|
||||
(await session.execute(select(Audio.filename))).scalars()
|
||||
)
|
||||
|
||||
audios = []
|
||||
|
||||
for file_path in DIRECTORY.rglob("*"):
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
if file_path.suffix.lower() not in AUDIO_EXTENSIONS:
|
||||
continue
|
||||
|
||||
relative_path = str(file_path.relative_to(DIRECTORY))
|
||||
data = relative_path.split('/')
|
||||
length = len(data)
|
||||
|
||||
if data[0] == "_covers":
|
||||
continue
|
||||
|
||||
if length < 2:
|
||||
print(f"error invalid path: {relative_path}")
|
||||
continue
|
||||
|
||||
filename = data[-1]
|
||||
name = Path(filename).stem
|
||||
playlist_name = data[0] if length >= 2 else None
|
||||
|
||||
if playlist_name:
|
||||
preview_path = f"{COVERS}/{playlist_name}/{name}.jpg"
|
||||
else:
|
||||
preview_path = f"{COVERS}/{name}.jpg"
|
||||
|
||||
path = str(relative_path)
|
||||
title, date = parse_preview_path(preview_path) # noqa: RUF059
|
||||
|
||||
if filename in existing_audios:
|
||||
continue
|
||||
|
||||
playlist = None
|
||||
if playlist_name:
|
||||
playlist = cached_playlists.get(playlist_name)
|
||||
if playlist is None:
|
||||
playlist = AudioPlaylist(title=playlist_name)
|
||||
session.add(playlist)
|
||||
await session.flush()
|
||||
cached_playlists[playlist_name] = playlist
|
||||
|
||||
audio = Audio(
|
||||
filename=filename,
|
||||
path=path,
|
||||
preview_path=preview_path,
|
||||
name=name,
|
||||
playlist=playlist
|
||||
)
|
||||
audios.append(audio)
|
||||
existing_audios.add(filename)
|
||||
|
||||
if audios:
|
||||
session.add_all(audios)
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def main():
|
||||
await init_db()
|
||||
await update_yt()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
67
utils/update_tg_photos.py
Normal file
67
utils/update_tg_photos.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import asyncio
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..core.config import BASE_DIR, PHOTOS_DIR
|
||||
from ..core.db import Base, SessionLocal, engine
|
||||
from ..models.photo_model import Category, Photo
|
||||
|
||||
DIRECTORY = PHOTOS_DIR
|
||||
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async def update_media():
|
||||
if not DIRECTORY.is_dir():
|
||||
print (f"{DIRECTORY} not exists")
|
||||
return
|
||||
|
||||
async with SessionLocal() as session:
|
||||
|
||||
stmt = select(Photo.filename)
|
||||
result = await session.execute(stmt)
|
||||
existing_filenames = set(result.scalars().all())
|
||||
|
||||
cached_categories = {ct.title:ct for ct in (await session.execute(select(Category))).scalars()}
|
||||
|
||||
new_photos = []
|
||||
|
||||
for file_path in DIRECTORY.rglob("*"):
|
||||
if file_path.is_file():
|
||||
relative_path = file_path.relative_to(DIRECTORY)
|
||||
|
||||
if file_path.suffix.lower() != ".jpg":
|
||||
continue
|
||||
|
||||
if file_path.name in existing_filenames:
|
||||
continue
|
||||
|
||||
category_name = relative_path.parts[0] if len(relative_path.parts) > 1 else None
|
||||
|
||||
category = cached_categories.get(category_name)
|
||||
if category is None:
|
||||
category = Category(title=category_name)
|
||||
session.add(category)
|
||||
await session.flush()
|
||||
cached_categories[category_name] = category
|
||||
|
||||
photo = Photo(
|
||||
filename = file_path.name,
|
||||
path = str(file_path.relative_to(BASE_DIR)),
|
||||
category = category,
|
||||
)
|
||||
|
||||
new_photos.append(photo)
|
||||
existing_filenames.add(file_path.name)
|
||||
|
||||
if new_photos:
|
||||
session.add_all(new_photos)
|
||||
await session.commit()
|
||||
|
||||
async def main():
|
||||
await init_db()
|
||||
await update_media()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
83
utils/update_thumbs.py
Normal file
83
utils/update_thumbs.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import argparse
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.config import THUMBS_DIR, YT_DIR
|
||||
|
||||
|
||||
async def run (cmd: list[str]) -> tuple[int, bytes]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
_, err = await proc.communicate()
|
||||
return proc.returncode, err
|
||||
|
||||
async def extract_cover(video: Path, out: Path, sem: asyncio.Semaphore) -> bool:
|
||||
if ".Trash" in str(video) or any(part.startswith('.') for part in video.parts):
|
||||
return False
|
||||
|
||||
# 1. Пропускаем временные файлы и аудио-дорожки yt-dlp
|
||||
if ".temp." in video.name or ".f" in video.name and video.suffix == ".webm":
|
||||
return False
|
||||
|
||||
async with sem:
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
if out.exists() and out.stat().st_size > 0:
|
||||
return True
|
||||
|
||||
# 2. Пытаемся взять ВТОРУЮ дорожку (с вопросительным знаком, чтобы не падало)
|
||||
code, err = await run([
|
||||
"ffmpeg", "-y", "-loglevel", "error",
|
||||
"-i", str(video),
|
||||
"-map", "0:v:1?",
|
||||
"-c", "copy",
|
||||
str(out),
|
||||
])
|
||||
|
||||
if code != 0:
|
||||
# 3. Если второй нет, пытаемся взять ПЕРВУЮ (тоже с вопросительным знаком)
|
||||
code, err = await run([
|
||||
"ffmpeg", "-y", "-loglevel", "error",
|
||||
"-i", str(video),
|
||||
"-map", "0:v:0?",
|
||||
"-c", "copy",
|
||||
str(out),
|
||||
])
|
||||
if code != 0:
|
||||
print(f"FAIL {video.name}: {err.decode()[:300]}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def main(workers: int):
|
||||
SEM = asyncio.Semaphore(workers)
|
||||
videos = [p for p in YT_DIR.rglob("*") if p.is_file() and p.suffix.lower() in {".mp4", ".mkv", ".webm"}]
|
||||
print(f"videos: {len(videos)}")
|
||||
|
||||
tasks = []
|
||||
for video in videos:
|
||||
rel = video.relative_to(YT_DIR)
|
||||
out = THUMBS_DIR / rel.with_suffix(".jpg")
|
||||
tasks.append(extract_cover(video, out, SEM))
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
ok = sum(1 for r in results if r)
|
||||
print(f"done: {ok}/{len(results)}")
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Extract video previews")
|
||||
parser.add_argument(
|
||||
"-w", "--workers",
|
||||
type=int,
|
||||
default=8,
|
||||
help="number of parallel ffmpeg processes (default: 8)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
asyncio.run(main(args.workers))
|
||||
134
utils/update_yt_videos.py
Normal file
134
utils/update_yt_videos.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import asyncio
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..core.config import BASE_DIR, THUMBS, VIDEO_EXTENSIONS, YT_DIR
|
||||
from ..core.db import Base, SessionLocal, engine
|
||||
from ..models.video_model import Channel, Playlist, Video
|
||||
|
||||
|
||||
def parse_preview_path(path: str) -> tuple[str, datetime | None]:
|
||||
p = Path(path)
|
||||
|
||||
stem = p.stem
|
||||
|
||||
match = re.match(r"^(\d{8})\s*-\s*(.+)$", stem)
|
||||
if not match:
|
||||
return stem, None
|
||||
|
||||
date_str, title = match.group(1), match.group(2).strip()
|
||||
|
||||
try:
|
||||
date = datetime.strptime(date_str, "%Y%m%d")
|
||||
except ValueError:
|
||||
return stem, None
|
||||
|
||||
return title, date
|
||||
|
||||
DIRECTORY = BASE_DIR / YT_DIR
|
||||
|
||||
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async def update_yt():
|
||||
|
||||
async with SessionLocal() as session:
|
||||
|
||||
cached_channels = {ch.name:ch for ch in (await session.execute(select(Channel))).scalars()}
|
||||
cached_playlists = {(pl.channel_id, pl.name): pl for pl in (await session.execute(select(Playlist))).scalars()}
|
||||
|
||||
existing_videos: set[tuple[int, str]] = set(
|
||||
(await session.execute(select(Video.channel_id, Video.filename))).all()
|
||||
)
|
||||
|
||||
videos = []
|
||||
|
||||
for file_path in DIRECTORY.rglob("*"):
|
||||
if file_path.is_file():
|
||||
relative_path = str(file_path.relative_to(DIRECTORY))
|
||||
|
||||
data = relative_path.split('/')
|
||||
|
||||
length = len(data)
|
||||
|
||||
if file_path.suffix.lower() not in VIDEO_EXTENSIONS:
|
||||
continue
|
||||
|
||||
if length < 2:
|
||||
print ("error invalid channel")
|
||||
continue
|
||||
|
||||
elif length == 2:
|
||||
if data[0] == "_thumbs":
|
||||
continue
|
||||
|
||||
channel_name = data[0]
|
||||
filename = data[1]
|
||||
name = Path(filename).stem
|
||||
playlist_name = None
|
||||
preview_path = f"{THUMBS}/{channel_name}/{name}.jpg"
|
||||
path = str(relative_path)
|
||||
title, date = parse_preview_path(preview_path)
|
||||
|
||||
elif length == 3:
|
||||
if data[0] == "_thumbs":
|
||||
continue
|
||||
|
||||
channel_name = data[0]
|
||||
filename = data[2]
|
||||
name = Path(filename).stem
|
||||
playlist_name = data[1]
|
||||
preview_path = f"{THUMBS}/{channel_name}/{playlist_name}/{name}.jpg"
|
||||
path = str(relative_path)
|
||||
title, date = parse_preview_path(preview_path)
|
||||
|
||||
else:
|
||||
print(f"skip (too deep): {relative_path}")
|
||||
|
||||
channel = cached_channels.get(channel_name)
|
||||
if channel is None:
|
||||
channel = Channel(name=channel_name)
|
||||
session.add(channel)
|
||||
await session.flush()
|
||||
cached_channels[channel_name] = channel
|
||||
|
||||
if not playlist_name is None:
|
||||
playlist = cached_playlists.get((channel.id, playlist_name))
|
||||
if playlist is None:
|
||||
playlist = Playlist(name=playlist_name, channel=channel)
|
||||
session.add(playlist)
|
||||
await session.flush()
|
||||
cached_playlists[(channel.id, playlist_name)] = playlist
|
||||
|
||||
vkey = (channel.id, filename)
|
||||
if vkey in existing_videos:
|
||||
continue
|
||||
|
||||
video = Video(filename=filename, path=path, preview_path=preview_path,
|
||||
playlist=playlist, channel=channel, title=title, date=date)
|
||||
videos.append(video)
|
||||
existing_videos.add(vkey)
|
||||
|
||||
else:
|
||||
|
||||
video = Video(filename=filename, path=path, preview_path=preview_path,
|
||||
playlist=None, channel=channel, title=title, date=date)
|
||||
videos.append(video)
|
||||
|
||||
if videos:
|
||||
session.add_all(videos)
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def main():
|
||||
await init_db()
|
||||
await update_yt()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Loading…
Reference in New Issue
Block a user