Merge improvement/backend-consolidation
Maintenance epic phase 1: backend duplication cleanup + one correctness fix. - fix: maintenance no longer scans the removed status=="saved" (under-counted impact) - DRY: shared feed-row projection, playlist mirror/position/add-item helpers, token hashing, email validation, admin_user dep + count_admins, quota.measured, is_messageable_user, Timestamp/UpdatedAt model mixins. Verified: compile + full app import + zero alembic schema drift + localdev smoke (demo login, feed, playlist CRUD, sync status all green).
This commit is contained in:
commit
239a298992
15 changed files with 270 additions and 279 deletions
|
|
@ -1,6 +1,4 @@
|
||||||
import hashlib
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
|
@ -8,7 +6,7 @@ import httpx
|
||||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
from authlib.integrations.starlette_client import OAuth, OAuthError
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import email as email_mod
|
from app import email as email_mod
|
||||||
|
|
@ -17,7 +15,8 @@ from app.config import settings
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
|
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
|
||||||
from app.ratelimit import RateLimiter
|
from app.ratelimit import RateLimiter
|
||||||
from app.security import decrypt, encrypt, hash_password, verify_password
|
from app.security import decrypt, encrypt, hash_password, hash_token, verify_password
|
||||||
|
from app.utils import valid_email
|
||||||
|
|
||||||
# Email+password auth tuning.
|
# Email+password auth tuning.
|
||||||
PASSWORD_MIN_LEN = 10
|
PASSWORD_MIN_LEN = 10
|
||||||
|
|
@ -43,8 +42,6 @@ def _notify_suspended(background: BackgroundTasks, email: str) -> None:
|
||||||
if _suspend_email_limiter.allow(email):
|
if _suspend_email_limiter.allow(email):
|
||||||
background.add_task(email_mod.send_account_suspended, email, operator_contact())
|
background.add_task(email_mod.send_account_suspended, email, operator_contact())
|
||||||
|
|
||||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
||||||
|
|
||||||
# The single shared demo account's stable identity. The row is created lazily on first
|
# The single shared demo account's stable identity. The row is created lazily on first
|
||||||
# demo login (see get_or_create_demo_user); these are just its sentinel keys.
|
# demo login (see get_or_create_demo_user); these are just its sentinel keys.
|
||||||
DEMO_GOOGLE_SUB = "__demo__"
|
DEMO_GOOGLE_SUB = "__demo__"
|
||||||
|
|
@ -184,7 +181,7 @@ def upsert_pending_invite(db: Session, email: str) -> Invite | None:
|
||||||
"""Idempotently record an access request. Returns the Invite if a *new* pending row
|
"""Idempotently record an access request. Returns the Invite if a *new* pending row
|
||||||
was created (so the caller can notify admins), else None for already-known emails."""
|
was created (so the caller can notify admins), else None for already-known emails."""
|
||||||
email = (email or "").lower()
|
email = (email or "").lower()
|
||||||
if not _EMAIL_RE.match(email):
|
if not valid_email(email):
|
||||||
return None
|
return None
|
||||||
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
||||||
if inv is not None:
|
if inv is not None:
|
||||||
|
|
@ -443,7 +440,7 @@ async def request_access(
|
||||||
"""Public: ask for access. Idempotent — repeat requests for the same email don't
|
"""Public: ask for access. Idempotent — repeat requests for the same email don't
|
||||||
duplicate or re-spam admins (upsert returns None for an already-pending row)."""
|
duplicate or re-spam admins (upsert returns None for an already-pending row)."""
|
||||||
email = (payload.get("email") or "").strip().lower()
|
email = (payload.get("email") or "").strip().lower()
|
||||||
if not _EMAIL_RE.match(email):
|
if not valid_email(email):
|
||||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||||
if is_allowed(db, email):
|
if is_allowed(db, email):
|
||||||
return {"status": "approved"} # already allowed — just sign in
|
return {"status": "approved"} # already allowed — just sign in
|
||||||
|
|
@ -465,7 +462,7 @@ def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) -
|
||||||
if not _demo_limiter.allow(_client_ip(request)):
|
if not _demo_limiter.allow(_client_ip(request)):
|
||||||
return {"authenticated": False}
|
return {"authenticated": False}
|
||||||
email = (payload.get("email") or "").strip().lower()
|
email = (payload.get("email") or "").strip().lower()
|
||||||
if _EMAIL_RE.match(email) and is_demo_allowed(db, email):
|
if valid_email(email) and is_demo_allowed(db, email):
|
||||||
demo = get_or_create_demo_user(db)
|
demo = get_or_create_demo_user(db)
|
||||||
request.session["user_id"] = demo.id
|
request.session["user_id"] = demo.id
|
||||||
log.info("Demo login (key=%s, demo_id=%s)", email, demo.id)
|
log.info("Demo login (key=%s, demo_id=%s)", email, demo.id)
|
||||||
|
|
@ -487,10 +484,6 @@ def _app_base() -> str:
|
||||||
return settings.app_base
|
return settings.app_base
|
||||||
|
|
||||||
|
|
||||||
def _hash_token(raw: str) -> str:
|
|
||||||
return hashlib.sha256(raw.encode()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str:
|
def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str:
|
||||||
"""Create a single-use token of `kind`; only its hash is stored. Returns the raw token
|
"""Create a single-use token of `kind`; only its hash is stored. Returns the raw token
|
||||||
(goes only into the emailed link)."""
|
(goes only into the emailed link)."""
|
||||||
|
|
@ -499,7 +492,7 @@ def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str:
|
||||||
AuthToken(
|
AuthToken(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
kind=kind,
|
kind=kind,
|
||||||
token_hash=_hash_token(raw),
|
token_hash=hash_token(raw),
|
||||||
expires_at=datetime.now(timezone.utc) + ttl,
|
expires_at=datetime.now(timezone.utc) + ttl,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -513,7 +506,7 @@ def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
|
||||||
return None
|
return None
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
select(AuthToken).where(
|
select(AuthToken).where(
|
||||||
AuthToken.token_hash == _hash_token(raw), AuthToken.kind == kind
|
AuthToken.token_hash == hash_token(raw), AuthToken.kind == kind
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if row is None or row.used_at is not None or row.expires_at < datetime.now(timezone.utc):
|
if row is None or row.used_at is not None or row.expires_at < datetime.now(timezone.utc):
|
||||||
|
|
@ -537,7 +530,7 @@ def register(
|
||||||
email = (payload.get("email") or "").strip().lower()
|
email = (payload.get("email") or "").strip().lower()
|
||||||
password = payload.get("password") or ""
|
password = payload.get("password") or ""
|
||||||
# Input validation is safe to reveal (independent of whether the email exists).
|
# Input validation is safe to reveal (independent of whether the email exists).
|
||||||
if not _EMAIL_RE.match(email):
|
if not valid_email(email):
|
||||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||||
if len(password) < PASSWORD_MIN_LEN:
|
if len(password) < PASSWORD_MIN_LEN:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -633,7 +626,7 @@ def password_reset_request(
|
||||||
if not _reset_limiter.allow(_client_ip(request)):
|
if not _reset_limiter.allow(_client_ip(request)):
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
email = (payload.get("email") or "").strip().lower()
|
email = (payload.get("email") or "").strip().lower()
|
||||||
if _EMAIL_RE.match(email):
|
if valid_email(email):
|
||||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||||
if user is not None and user.password_hash and not user.is_demo:
|
if user is not None and user.password_hash and not user.is_demo:
|
||||||
raw = _issue_token(db, user, "reset", RESET_TTL)
|
raw = _issue_token(db, user, "reset", RESET_TTL)
|
||||||
|
|
@ -694,6 +687,23 @@ def require_human(user: User = Depends(current_user)) -> User:
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def admin_user(user: User = Depends(current_user)) -> User:
|
||||||
|
"""Dependency: require the signed-in user to be an admin. Single source of truth for the
|
||||||
|
admin gate — every admin route/action depends on this rather than re-checking the role."""
|
||||||
|
if user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def count_admins(db: Session, *, active_only: bool = False) -> int:
|
||||||
|
"""How many admins exist (optionally only un-suspended ones). Used by the 'can't remove /
|
||||||
|
suspend / delete the last admin' guards so they don't each re-spell the count query."""
|
||||||
|
q = select(func.count()).select_from(User).where(User.role == "admin")
|
||||||
|
if active_only:
|
||||||
|
q = q.where(User.is_suspended.is_(False))
|
||||||
|
return db.scalar(q) or 0
|
||||||
|
|
||||||
|
|
||||||
@router.get("/link")
|
@router.get("/link")
|
||||||
async def link_google(
|
async def link_google(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,24 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class TimestampMixin:
|
||||||
|
"""Adds a server-defaulted `created_at`. Models that index created_at or order it
|
||||||
|
differently (QuotaEvent, Notification, Message) keep their own column on purpose."""
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatedAtMixin:
|
||||||
|
"""Adds an `updated_at` that server-defaults on insert and bumps on every UPDATE."""
|
||||||
|
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base, TimestampMixin):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
|
@ -57,9 +74,6 @@ class User(Base):
|
||||||
)
|
)
|
||||||
# Free-form UI preferences (theme, color scheme, font scale, default filters…).
|
# Free-form UI preferences (theme, color scheme, font scale, default filters…).
|
||||||
preferences: Mapped[dict | None] = mapped_column(JSON)
|
preferences: Mapped[dict | None] = mapped_column(JSON)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
token: Mapped["OAuthToken | None"] = relationship(
|
token: Mapped["OAuthToken | None"] = relationship(
|
||||||
back_populates="user", uselist=False, cascade="all, delete-orphan"
|
back_populates="user", uselist=False, cascade="all, delete-orphan"
|
||||||
|
|
@ -69,7 +83,7 @@ class User(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class OAuthToken(Base):
|
class OAuthToken(Base, UpdatedAtMixin):
|
||||||
__tablename__ = "oauth_tokens"
|
__tablename__ = "oauth_tokens"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
|
@ -81,14 +95,11 @@ class OAuthToken(Base):
|
||||||
access_token: Mapped[str | None] = mapped_column(String)
|
access_token: Mapped[str | None] = mapped_column(String)
|
||||||
expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
scopes: Mapped[str | None] = mapped_column(String)
|
scopes: Mapped[str | None] = mapped_column(String)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
user: Mapped["User"] = relationship(back_populates="token")
|
user: Mapped["User"] = relationship(back_populates="token")
|
||||||
|
|
||||||
|
|
||||||
class AuthToken(Base):
|
class AuthToken(Base, TimestampMixin):
|
||||||
"""Single-use, expiring token for email verification or password reset. Only the SHA-256
|
"""Single-use, expiring token for email verification or password reset. Only the SHA-256
|
||||||
HASH of the token is stored, so a DB leak can't be used to verify/reset; the raw token
|
HASH of the token is stored, so a DB leak can't be used to verify/reset; the raw token
|
||||||
lives only in the emailed link. Consumed (used_at set) on first valid use."""
|
lives only in the emailed link. Consumed (used_at set) on first valid use."""
|
||||||
|
|
@ -103,9 +114,6 @@ class AuthToken(Base):
|
||||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Invite(Base):
|
class Invite(Base):
|
||||||
|
|
@ -128,7 +136,7 @@ class Invite(Base):
|
||||||
decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email
|
decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email
|
||||||
|
|
||||||
|
|
||||||
class DemoWhitelist(Base):
|
class DemoWhitelist(Base, TimestampMixin):
|
||||||
"""Emails that may enter the shared demo account from the login page (no Google OAuth).
|
"""Emails that may enter the shared demo account from the login page (no Google OAuth).
|
||||||
A hidden, admin-curated list of "keys to the same door" — every entry logs into the one
|
A hidden, admin-curated list of "keys to the same door" — every entry logs into the one
|
||||||
shared demo user. Distinct from Invite (which gates real Google sign-in)."""
|
shared demo user. Distinct from Invite (which gates real Google sign-in)."""
|
||||||
|
|
@ -139,12 +147,9 @@ class DemoWhitelist(Base):
|
||||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||||
note: Mapped[str | None] = mapped_column(Text)
|
note: Mapped[str | None] = mapped_column(Text)
|
||||||
added_by: Mapped[str | None] = mapped_column(String(320)) # admin email
|
added_by: Mapped[str | None] = mapped_column(String(320)) # admin email
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Channel(Base):
|
class Channel(Base, TimestampMixin):
|
||||||
"""A YouTube channel. Shared across all users (one channel's videos are the same
|
"""A YouTube channel. Shared across all users (one channel's videos are the same
|
||||||
for everyone), so its expensive metadata is fetched and stored only once."""
|
for everyone), so its expensive metadata is fetched and stored only once."""
|
||||||
|
|
||||||
|
|
@ -170,15 +175,12 @@ class Channel(Base):
|
||||||
backfill_done: Mapped[bool] = mapped_column(
|
backfill_done: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False, server_default="false"
|
Boolean, default=False, server_default="false"
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
videos: Mapped[list["Video"]] = relationship(back_populates="channel")
|
videos: Mapped[list["Video"]] = relationship(back_populates="channel")
|
||||||
subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel")
|
subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel")
|
||||||
|
|
||||||
|
|
||||||
class Subscription(Base):
|
class Subscription(Base, TimestampMixin):
|
||||||
"""Per-user link to a channel, with the user's own priority/visibility overrides."""
|
"""Per-user link to a channel, with the user's own priority/visibility overrides."""
|
||||||
|
|
||||||
__tablename__ = "subscriptions"
|
__tablename__ = "subscriptions"
|
||||||
|
|
@ -202,15 +204,12 @@ class Subscription(Base):
|
||||||
Boolean, default=False, server_default="false"
|
Boolean, default=False, server_default="false"
|
||||||
)
|
)
|
||||||
subscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
subscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
user: Mapped["User"] = relationship(back_populates="subscriptions")
|
user: Mapped["User"] = relationship(back_populates="subscriptions")
|
||||||
channel: Mapped["Channel"] = relationship(back_populates="subscriptions")
|
channel: Mapped["Channel"] = relationship(back_populates="subscriptions")
|
||||||
|
|
||||||
|
|
||||||
class Video(Base):
|
class Video(Base, TimestampMixin):
|
||||||
"""A YouTube video. Shared across users; per-user state lives elsewhere."""
|
"""A YouTube video. Shared across users; per-user state lives elsewhere."""
|
||||||
|
|
||||||
__tablename__ = "videos"
|
__tablename__ = "videos"
|
||||||
|
|
@ -260,14 +259,11 @@ class Video(Base):
|
||||||
DateTime(timezone=True), index=True
|
DateTime(timezone=True), index=True
|
||||||
)
|
)
|
||||||
unavailable_reason: Mapped[str | None] = mapped_column(String(24))
|
unavailable_reason: Mapped[str | None] = mapped_column(String(24))
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
channel: Mapped["Channel"] = relationship(back_populates="videos")
|
channel: Mapped["Channel"] = relationship(back_populates="videos")
|
||||||
|
|
||||||
|
|
||||||
class Tag(Base):
|
class Tag(Base, TimestampMixin):
|
||||||
"""A label. System tags (user_id NULL) are computed automatically and shared by all
|
"""A label. System tags (user_id NULL) are computed automatically and shared by all
|
||||||
users; user tags (user_id set) are personal. category groups them in the UI."""
|
users; user tags (user_id set) are personal. category groups them in the UI."""
|
||||||
|
|
||||||
|
|
@ -283,9 +279,6 @@ class Tag(Base):
|
||||||
category: Mapped[str] = mapped_column(
|
category: Mapped[str] = mapped_column(
|
||||||
String(16), default="other", server_default="other"
|
String(16), default="other", server_default="other"
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelTag(Base):
|
class ChannelTag(Base):
|
||||||
|
|
@ -312,7 +305,7 @@ class ChannelTag(Base):
|
||||||
confidence: Mapped[float | None] = mapped_column(Float)
|
confidence: Mapped[float | None] = mapped_column(Float)
|
||||||
|
|
||||||
|
|
||||||
class VideoState(Base):
|
class VideoState(Base, UpdatedAtMixin):
|
||||||
"""Per-user watch state for a video. Rows exist only for non-default states; the
|
"""Per-user watch state for a video. Rows exist only for non-default states; the
|
||||||
absence of a row means 'new/unseen'."""
|
absence of a row means 'new/unseen'."""
|
||||||
|
|
||||||
|
|
@ -326,7 +319,7 @@ class VideoState(Base):
|
||||||
video_id: Mapped[str] = mapped_column(
|
video_id: Mapped[str] = mapped_column(
|
||||||
ForeignKey("videos.id", ondelete="CASCADE"), index=True
|
ForeignKey("videos.id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
# new | watched | saved | hidden
|
# new | watched | hidden ("saved" is now Watch-later playlist membership, not a status)
|
||||||
status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
|
status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
|
||||||
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
# Resume position (seconds into the video) for the in-app player. Orthogonal to
|
# Resume position (seconds into the video) for the in-app player. Orthogonal to
|
||||||
|
|
@ -337,9 +330,6 @@ class VideoState(Base):
|
||||||
Integer, default=0, server_default="0", nullable=False
|
Integer, default=0, server_default="0", nullable=False
|
||||||
)
|
)
|
||||||
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ApiQuotaUsage(Base):
|
class ApiQuotaUsage(Base):
|
||||||
|
|
@ -422,7 +412,7 @@ class SystemConfig(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Playlist(Base):
|
class Playlist(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
"""A per-user named, ordered collection of videos from the shared catalog.
|
"""A per-user named, ordered collection of videos from the shared catalog.
|
||||||
|
|
||||||
`kind`: 'user' (normal) or 'watch_later' (built-in, undeletable, one per user).
|
`kind`: 'user' (normal) or 'watch_later' (built-in, undeletable, one per user).
|
||||||
|
|
@ -450,12 +440,6 @@ class Playlist(Base):
|
||||||
# baseline `dirty` is derived against. NULL = no baseline yet (treated as dirty).
|
# baseline `dirty` is derived against. NULL = no baseline yet (treated as dirty).
|
||||||
synced_fingerprint: Mapped[str | None] = mapped_column(String)
|
synced_fingerprint: Mapped[str | None] = mapped_column(String)
|
||||||
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
items: Mapped[list["PlaylistItem"]] = relationship(
|
items: Mapped[list["PlaylistItem"]] = relationship(
|
||||||
back_populates="playlist",
|
back_populates="playlist",
|
||||||
|
|
@ -560,7 +544,7 @@ class Message(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class MessageKey(Base):
|
class MessageKey(Base, TimestampMixin):
|
||||||
"""A user's end-to-end-encryption key material for direct messaging (phase 2).
|
"""A user's end-to-end-encryption key material for direct messaging (phase 2).
|
||||||
|
|
||||||
One row per user. `public_key` (base64 SPKI, ECDH P-256) is shared with others so they can
|
One row per user. `public_key` (base64 SPKI, ECDH P-256) is shared with others so they can
|
||||||
|
|
@ -581,6 +565,3 @@ class MessageKey(Base):
|
||||||
salt: Mapped[str] = mapped_column(String(64))
|
salt: Mapped[str] = mapped_column(String(64))
|
||||||
wrap_iv: Mapped[str] = mapped_column(String(32))
|
wrap_iv: Mapped[str] = mapped_column(String(32))
|
||||||
key_check: Mapped[str] = mapped_column(Text)
|
key_check: Mapped[str] = mapped_column(Text)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,26 @@ def attribute(actor_id: int | None, action: str):
|
||||||
_action.reset(t2)
|
_action.reset(t2)
|
||||||
|
|
||||||
|
|
||||||
|
class Measure:
|
||||||
|
"""Result handle from `measured()` — read .used / .remaining after the block exits."""
|
||||||
|
|
||||||
|
used = 0
|
||||||
|
remaining = 0
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def measured(db: Session, actor_id: int | None, action: str):
|
||||||
|
"""Like `attribute()`, but also reports how much quota the block spent. Yields a `Measure`
|
||||||
|
whose `.used` (units charged inside the block) and `.remaining` (today's remaining after)
|
||||||
|
are populated on exit — so manual sync routes don't each re-spell the before/after diff."""
|
||||||
|
m = Measure()
|
||||||
|
before = units_used_today(db)
|
||||||
|
with attribute(actor_id, action):
|
||||||
|
yield m
|
||||||
|
m.used = units_used_today(db) - before
|
||||||
|
m.remaining = remaining_today(db)
|
||||||
|
|
||||||
|
|
||||||
def pacific_today():
|
def pacific_today():
|
||||||
return datetime.now(_PACIFIC).date()
|
return datetime.now(_PACIFIC).date()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add.
|
"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add.
|
||||||
Also the demo-account admin surface: the demo email whitelist + a manual state reset."""
|
Also the demo-account admin surface: the demo email whitelist + a manual state reset."""
|
||||||
import re
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
|
|
@ -8,7 +7,13 @@ from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import email as email_mod
|
from app import email as email_mod
|
||||||
from app.auth import current_user, get_or_create_demo_user, operator_contact, purge_user
|
from app.auth import (
|
||||||
|
admin_user,
|
||||||
|
count_admins,
|
||||||
|
get_or_create_demo_user,
|
||||||
|
operator_contact,
|
||||||
|
purge_user,
|
||||||
|
)
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
DemoWhitelist,
|
DemoWhitelist,
|
||||||
|
|
@ -20,17 +25,10 @@ from app.models import (
|
||||||
Video,
|
Video,
|
||||||
VideoState,
|
VideoState,
|
||||||
)
|
)
|
||||||
|
from app.utils import valid_email
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
||||||
|
|
||||||
|
|
||||||
def admin_user(user: User = Depends(current_user)) -> User:
|
|
||||||
if user.role != "admin":
|
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize(inv: Invite) -> dict:
|
def _serialize(inv: Invite) -> dict:
|
||||||
return {
|
return {
|
||||||
|
|
@ -110,7 +108,7 @@ def add_invite(
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Manually whitelist an email (approved straight away). Convenience for the admin."""
|
"""Manually whitelist an email (approved straight away). Convenience for the admin."""
|
||||||
email = (payload.get("email") or "").strip().lower()
|
email = (payload.get("email") or "").strip().lower()
|
||||||
if "@" not in email:
|
if not valid_email(email):
|
||||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||||
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
||||||
if inv is None:
|
if inv is None:
|
||||||
|
|
@ -168,9 +166,7 @@ def set_user_role(
|
||||||
raise HTTPException(status_code=400, detail="The demo account's role can't be changed.")
|
raise HTTPException(status_code=400, detail="The demo account's role can't be changed.")
|
||||||
if target.id == admin.id:
|
if target.id == admin.id:
|
||||||
raise HTTPException(status_code=400, detail="You can't change your own role.")
|
raise HTTPException(status_code=400, detail="You can't change your own role.")
|
||||||
if target.role == "admin" and role == "user":
|
if target.role == "admin" and role == "user" and count_admins(db) <= 1:
|
||||||
admin_count = db.scalar(select(func.count()).select_from(User).where(User.role == "admin"))
|
|
||||||
if (admin_count or 0) <= 1:
|
|
||||||
raise HTTPException(status_code=400, detail="Can't remove the last admin.")
|
raise HTTPException(status_code=400, detail="Can't remove the last admin.")
|
||||||
changed = target.role != role
|
changed = target.role != role
|
||||||
target.role = role
|
target.role = role
|
||||||
|
|
@ -201,13 +197,12 @@ def set_user_suspended(
|
||||||
raise HTTPException(status_code=400, detail="The demo account can't be suspended.")
|
raise HTTPException(status_code=400, detail="The demo account can't be suspended.")
|
||||||
if target.id == admin.id:
|
if target.id == admin.id:
|
||||||
raise HTTPException(status_code=400, detail="You can't suspend your own account.")
|
raise HTTPException(status_code=400, detail="You can't suspend your own account.")
|
||||||
if suspended and target.role == "admin" and not target.is_suspended:
|
if (
|
||||||
active_admins = db.scalar(
|
suspended
|
||||||
select(func.count())
|
and target.role == "admin"
|
||||||
.select_from(User)
|
and not target.is_suspended
|
||||||
.where(User.role == "admin", User.is_suspended.is_(False))
|
and count_admins(db, active_only=True) <= 1
|
||||||
)
|
):
|
||||||
if (active_admins or 0) <= 1:
|
|
||||||
raise HTTPException(status_code=400, detail="Can't suspend the last active admin.")
|
raise HTTPException(status_code=400, detail="Can't suspend the last active admin.")
|
||||||
was_suspended = target.is_suspended
|
was_suspended = target.is_suspended
|
||||||
target.is_suspended = suspended
|
target.is_suspended = suspended
|
||||||
|
|
@ -238,11 +233,7 @@ def admin_delete_user(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail="Delete your own account from Settings → Account."
|
status_code=400, detail="Delete your own account from Settings → Account."
|
||||||
)
|
)
|
||||||
if target.role == "admin":
|
if target.role == "admin" and count_admins(db) <= 1:
|
||||||
admin_count = db.scalar(
|
|
||||||
select(func.count()).select_from(User).where(User.role == "admin")
|
|
||||||
)
|
|
||||||
if (admin_count or 0) <= 1:
|
|
||||||
raise HTTPException(status_code=400, detail="Can't delete the last admin.")
|
raise HTTPException(status_code=400, detail="Can't delete the last admin.")
|
||||||
purge_user(db, target, background)
|
purge_user(db, target, background)
|
||||||
return {"deleted": user_id}
|
return {"deleted": user_id}
|
||||||
|
|
@ -281,7 +272,7 @@ def add_demo_whitelist(
|
||||||
"""Add an email that may enter the shared demo account from the login page (no Google
|
"""Add an email that may enter the shared demo account from the login page (no Google
|
||||||
sign-in). Idempotent — re-adding an existing email just returns it."""
|
sign-in). Idempotent — re-adding an existing email just returns it."""
|
||||||
email = (payload.get("email") or "").strip().lower()
|
email = (payload.get("email") or "").strip().lower()
|
||||||
if not _EMAIL_RE.match(email):
|
if not valid_email(email):
|
||||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
select(DemoWhitelist).where(DemoWhitelist.email == email)
|
select(DemoWhitelist).where(DemoWhitelist.email == email)
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,29 @@ def _saved_expr(user: User):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def feed_columns(user: User, state) -> list:
|
||||||
|
"""The shared feed-row projection (joined Channel fields + per-user watch state +
|
||||||
|
`saved`), consumed by both the feed query and the playlist-detail query so a new column
|
||||||
|
is added in exactly one place. `state` is the caller's aliased VideoState (outer-joined)."""
|
||||||
|
return [
|
||||||
|
Video.id,
|
||||||
|
Video.title,
|
||||||
|
Video.channel_id,
|
||||||
|
Channel.title.label("channel_title"),
|
||||||
|
Channel.thumbnail_url.label("channel_thumbnail"),
|
||||||
|
Channel.handle.label("channel_handle"),
|
||||||
|
Video.published_at,
|
||||||
|
Video.thumbnail_url,
|
||||||
|
Video.duration_seconds,
|
||||||
|
Video.view_count,
|
||||||
|
Video.is_short,
|
||||||
|
Video.live_status,
|
||||||
|
func.coalesce(state.status, "new").label("status"),
|
||||||
|
func.coalesce(state.position_seconds, 0).label("position_seconds"),
|
||||||
|
_saved_expr(user),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _serialize(row) -> dict:
|
def _serialize(row) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": row.id,
|
"id": row.id,
|
||||||
|
|
@ -115,23 +138,9 @@ def _filtered_query(
|
||||||
status_expr = func.coalesce(state.status, "new")
|
status_expr = func.coalesce(state.status, "new")
|
||||||
position_expr = func.coalesce(state.position_seconds, 0)
|
position_expr = func.coalesce(state.position_seconds, 0)
|
||||||
|
|
||||||
query = select(
|
query = select(*feed_columns(user, state)).join(
|
||||||
Video.id,
|
Channel, Channel.id == Video.channel_id
|
||||||
Video.title,
|
)
|
||||||
Video.channel_id,
|
|
||||||
Channel.title.label("channel_title"),
|
|
||||||
Channel.thumbnail_url.label("channel_thumbnail"),
|
|
||||||
Channel.handle.label("channel_handle"),
|
|
||||||
Video.published_at,
|
|
||||||
Video.thumbnail_url,
|
|
||||||
Video.duration_seconds,
|
|
||||||
Video.view_count,
|
|
||||||
Video.is_short,
|
|
||||||
Video.live_status,
|
|
||||||
status_expr.label("status"),
|
|
||||||
position_expr.label("position_seconds"),
|
|
||||||
_saved_expr(user),
|
|
||||||
).join(Channel, Channel.id == Video.channel_id)
|
|
||||||
|
|
||||||
if scope == "all":
|
if scope == "all":
|
||||||
# Whole shared catalog; subscription is optional (only for priority sort).
|
# Whole shared catalog; subscription is optional (only for priority sort).
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import (
|
from app.auth import (
|
||||||
|
count_admins,
|
||||||
current_user,
|
current_user,
|
||||||
google_enabled,
|
google_enabled,
|
||||||
has_read_scope,
|
has_read_scope,
|
||||||
|
|
@ -111,9 +112,7 @@ def delete_my_account(
|
||||||
(that would lock everyone out of the admin surfaces)."""
|
(that would lock everyone out of the admin surfaces)."""
|
||||||
if user.is_demo:
|
if user.is_demo:
|
||||||
raise HTTPException(status_code=403, detail="The shared demo account can't be deleted.")
|
raise HTTPException(status_code=403, detail="The shared demo account can't be deleted.")
|
||||||
if user.role == "admin":
|
if user.role == "admin" and count_admins(db) <= 1:
|
||||||
admins = db.scalar(select(func.count()).select_from(User).where(User.role == "admin")) or 0
|
|
||||||
if admins <= 1:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="You're the only admin — promote another admin before deleting your account.",
|
detail="You're the only admin — promote another admin before deleting your account.",
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ def _serialize_msg(m: Message) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def _messageable() -> list:
|
def _messageable() -> list:
|
||||||
|
"""SQL clauses for "a real, active, non-suspended human" — for directory/recipient queries."""
|
||||||
return [
|
return [
|
||||||
User.is_demo.is_(False),
|
User.is_demo.is_(False),
|
||||||
User.is_active.is_(True),
|
User.is_active.is_(True),
|
||||||
|
|
@ -110,6 +111,11 @@ def _messageable() -> list:
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def is_messageable_user(u: User | None) -> bool:
|
||||||
|
"""Python mirror of `_messageable()` for an already-loaded row (WS auth, send recipient)."""
|
||||||
|
return bool(u and not u.is_demo and u.is_active and not u.is_suspended)
|
||||||
|
|
||||||
|
|
||||||
def ensure_welcome(db: Session, user: User) -> None:
|
def ensure_welcome(db: Session, user: User) -> None:
|
||||||
"""Create the one-time system welcome message for a (human) user the first time it's needed.
|
"""Create the one-time system welcome message for a (human) user the first time it's needed.
|
||||||
Idempotent: guarded on whether any system message already exists for them."""
|
Idempotent: guarded on whether any system message already exists for them."""
|
||||||
|
|
@ -315,7 +321,7 @@ def send_message(
|
||||||
if payload.recipient_id == user.id:
|
if payload.recipient_id == user.id:
|
||||||
raise HTTPException(status_code=400, detail="You can't message yourself.")
|
raise HTTPException(status_code=400, detail="You can't message yourself.")
|
||||||
recipient = db.get(User, payload.recipient_id)
|
recipient = db.get(User, payload.recipient_id)
|
||||||
if recipient is None or recipient.is_demo or not recipient.is_active or recipient.is_suspended:
|
if not is_messageable_user(recipient):
|
||||||
raise HTTPException(status_code=404, detail="Recipient not available.")
|
raise HTTPException(status_code=404, detail="Recipient not available.")
|
||||||
if db.get(MessageKey, recipient.id) is None:
|
if db.get(MessageKey, recipient.id) is None:
|
||||||
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.")
|
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.")
|
||||||
|
|
@ -363,8 +369,7 @@ async def messages_ws(ws: WebSocket) -> None:
|
||||||
return
|
return
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
u = db.get(User, uid)
|
ok = is_messageable_user(db.get(User, uid))
|
||||||
ok = bool(u and not u.is_demo and not u.is_suspended and u.is_active)
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
if not ok:
|
if not ok:
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from app import quota
|
||||||
from app.auth import current_user, has_read_scope, has_write_scope
|
from app.auth import current_user, has_read_scope, has_write_scope
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
||||||
from app.routes.feed import _saved_expr, _serialize
|
from app.routes.feed import _serialize, feed_columns
|
||||||
from app.sync.playlists import (
|
from app.sync.playlists import (
|
||||||
plan_push,
|
plan_push,
|
||||||
push_playlist,
|
push_playlist,
|
||||||
|
|
@ -29,6 +29,51 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||||
return pl
|
return pl
|
||||||
|
|
||||||
|
|
||||||
|
def _next_playlist_position(db: Session, user_id: int) -> int:
|
||||||
|
"""Append position for a new playlist in this user's rail."""
|
||||||
|
return (
|
||||||
|
db.scalar(
|
||||||
|
select(func.coalesce(func.max(Playlist.position), -1)).where(
|
||||||
|
Playlist.user_id == user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_item_position(db: Session, playlist_id: int) -> int:
|
||||||
|
"""Append position for a new item at the end of a playlist."""
|
||||||
|
return (
|
||||||
|
db.scalar(
|
||||||
|
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
|
||||||
|
PlaylistItem.playlist_id == playlist_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
|
||||||
|
"""Append a video to a playlist if not already present (idempotent). Returns whether it
|
||||||
|
was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
|
||||||
|
exists = db.scalar(
|
||||||
|
select(PlaylistItem).where(
|
||||||
|
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if exists is not None:
|
||||||
|
return False
|
||||||
|
db.add(
|
||||||
|
PlaylistItem(
|
||||||
|
playlist_id=pl.id, video_id=video_id, position=_next_item_position(db, pl.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if mark_dirty:
|
||||||
|
recompute_dirty(db, pl)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _summary(
|
def _summary(
|
||||||
db: Session,
|
db: Session,
|
||||||
pl: Playlist,
|
pl: Playlist,
|
||||||
|
|
@ -142,15 +187,7 @@ def create_playlist(
|
||||||
name = (payload.get("name") or "").strip()
|
name = (payload.get("name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="name is required")
|
raise HTTPException(status_code=400, detail="name is required")
|
||||||
next_pos = (
|
pl = Playlist(user_id=user.id, name=name, position=_next_playlist_position(db, user.id))
|
||||||
db.scalar(
|
|
||||||
select(func.coalesce(func.max(Playlist.position), -1)).where(
|
|
||||||
Playlist.user_id == user.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
+ 1
|
|
||||||
)
|
|
||||||
pl = Playlist(user_id=user.id, name=name, position=next_pos)
|
|
||||||
db.add(pl)
|
db.add(pl)
|
||||||
db.commit()
|
db.commit()
|
||||||
return _summary(db, pl, 0)
|
return _summary(db, pl, 0)
|
||||||
|
|
@ -164,16 +201,11 @@ def _watch_later(db: Session, user: User) -> Playlist:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if pl is None:
|
if pl is None:
|
||||||
next_pos = (
|
|
||||||
db.scalar(
|
|
||||||
select(func.coalesce(func.max(Playlist.position), -1)).where(
|
|
||||||
Playlist.user_id == user.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
+ 1
|
|
||||||
)
|
|
||||||
pl = Playlist(
|
pl = Playlist(
|
||||||
user_id=user.id, name="Watch later", kind="watch_later", position=next_pos
|
user_id=user.id,
|
||||||
|
name="Watch later",
|
||||||
|
kind="watch_later",
|
||||||
|
position=_next_playlist_position(db, user.id),
|
||||||
)
|
)
|
||||||
db.add(pl)
|
db.add(pl)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -191,22 +223,7 @@ def add_watch_later(
|
||||||
if not video_id or db.get(Video, video_id) is None:
|
if not video_id or db.get(Video, video_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown video")
|
raise HTTPException(status_code=404, detail="Unknown video")
|
||||||
pl = _watch_later(db, user)
|
pl = _watch_later(db, user)
|
||||||
exists = db.scalar(
|
_add_item(db, pl, video_id, mark_dirty=False)
|
||||||
select(PlaylistItem).where(
|
|
||||||
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if exists is None:
|
|
||||||
next_pos = (
|
|
||||||
db.scalar(
|
|
||||||
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
|
|
||||||
PlaylistItem.playlist_id == pl.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
+ 1
|
|
||||||
)
|
|
||||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos))
|
|
||||||
db.commit()
|
|
||||||
return {"saved": True, "playlist_id": pl.id}
|
return {"saved": True, "playlist_id": pl.id}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -344,23 +361,7 @@ def get_playlist(
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
pl = _own_playlist(db, user, playlist_id)
|
||||||
state = aliased(VideoState)
|
state = aliased(VideoState)
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
select(
|
select(*feed_columns(user, state))
|
||||||
Video.id,
|
|
||||||
Video.title,
|
|
||||||
Video.channel_id,
|
|
||||||
Channel.title.label("channel_title"),
|
|
||||||
Channel.thumbnail_url.label("channel_thumbnail"),
|
|
||||||
Channel.handle.label("channel_handle"),
|
|
||||||
Video.published_at,
|
|
||||||
Video.thumbnail_url,
|
|
||||||
Video.duration_seconds,
|
|
||||||
Video.view_count,
|
|
||||||
Video.is_short,
|
|
||||||
Video.live_status,
|
|
||||||
func.coalesce(state.status, "new").label("status"),
|
|
||||||
func.coalesce(state.position_seconds, 0).label("position_seconds"),
|
|
||||||
_saved_expr(user),
|
|
||||||
)
|
|
||||||
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
|
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
|
||||||
.join(Channel, Channel.id == Video.channel_id)
|
.join(Channel, Channel.id == Video.channel_id)
|
||||||
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
|
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
|
||||||
|
|
@ -439,25 +440,7 @@ def add_item(
|
||||||
video_id = payload.get("video_id")
|
video_id = payload.get("video_id")
|
||||||
if not video_id or db.get(Video, video_id) is None:
|
if not video_id or db.get(Video, video_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown video")
|
raise HTTPException(status_code=404, detail="Unknown video")
|
||||||
exists = db.scalar(
|
_add_item(db, pl, video_id, mark_dirty=True)
|
||||||
select(PlaylistItem).where(
|
|
||||||
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if exists is None:
|
|
||||||
next_pos = (
|
|
||||||
db.scalar(
|
|
||||||
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
|
|
||||||
PlaylistItem.playlist_id == pl.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
+ 1
|
|
||||||
)
|
|
||||||
db.add(
|
|
||||||
PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos)
|
|
||||||
)
|
|
||||||
recompute_dirty(db, pl)
|
|
||||||
db.commit()
|
|
||||||
return {"playlist_id": pl.id, "video_id": video_id}
|
return {"playlist_id": pl.id, "video_id": video_id}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from sqlalchemy import and_, case, func, select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import quota, state
|
from app import quota, state
|
||||||
from app.auth import current_user, has_read_scope, require_human
|
from app.auth import admin_user, current_user, has_read_scope, require_human
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Channel, Subscription, User, Video
|
from app.models import Channel, Subscription, User, Video
|
||||||
from app.sync.runner import (
|
from app.sync.runner import (
|
||||||
|
|
@ -40,11 +40,10 @@ def sync_subscriptions(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail="Connect your YouTube account (read access) to import subscriptions.",
|
detail="Connect your YouTube account (read access) to import subscriptions.",
|
||||||
)
|
)
|
||||||
before = quota.units_used_today(db)
|
with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL):
|
|
||||||
result = import_subscriptions(db, user)
|
result = import_subscriptions(db, user)
|
||||||
result["quota_used_estimate"] = quota.units_used_today(db) - before
|
result["quota_used_estimate"] = m.used
|
||||||
result["quota_remaining_today"] = quota.remaining_today(db)
|
result["quota_remaining_today"] = m.remaining
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -62,10 +61,9 @@ def sync_backfill(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
max_channels: int = 25,
|
max_channels: int = 25,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
before = quota.units_used_today(db)
|
with quota.measured(db, user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT) as m:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
|
||||||
result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels)
|
result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels)
|
||||||
result["quota_used_estimate"] = quota.units_used_today(db) - before
|
result["quota_used_estimate"] = m.used
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -73,13 +71,9 @@ def sync_backfill(
|
||||||
def sync_enrich(
|
def sync_enrich(
|
||||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
before = quota.units_used_today(db)
|
with quota.measured(db, user.id, quota.QuotaAction.VIDEOS_ENRICH) as m:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH):
|
|
||||||
enriched = run_enrich(db)
|
enriched = run_enrich(db)
|
||||||
return {
|
return {"enriched": enriched, "quota_used_estimate": m.used}
|
||||||
"enriched": enriched,
|
|
||||||
"quota_used_estimate": quota.units_used_today(db) - before,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
|
|
@ -194,20 +188,12 @@ def deep_all(
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pause")
|
@router.post("/pause")
|
||||||
def pause_sync(
|
def pause_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
||||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
|
||||||
) -> dict:
|
|
||||||
if user.role != "admin":
|
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
|
||||||
state.set_sync_paused(db, True)
|
state.set_sync_paused(db, True)
|
||||||
return {"paused": True}
|
return {"paused": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/resume")
|
@router.post("/resume")
|
||||||
def resume_sync(
|
def resume_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
||||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
|
||||||
) -> dict:
|
|
||||||
if user.role != "admin":
|
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
|
||||||
state.set_sync_paused(db, False)
|
state.set_sync_paused(db, False)
|
||||||
return {"paused": False}
|
return {"paused": False}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import current_user
|
from app.auth import admin_user, current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import ChannelTag, Tag, User
|
from app.models import ChannelTag, Tag, User
|
||||||
from app.sync.autotag import run_autotag_all
|
from app.sync.autotag import run_autotag_all
|
||||||
|
|
@ -103,10 +103,8 @@ def delete_tag(
|
||||||
|
|
||||||
@router.post("/recompute")
|
@router.post("/recompute")
|
||||||
def recompute(
|
def recompute(
|
||||||
user: User = Depends(current_user),
|
_: User = Depends(admin_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
only_missing: bool = False,
|
only_missing: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if user.role != "admin":
|
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
|
||||||
return run_autotag_all(db, only_missing=only_missing)
|
return run_autotag_all(db, only_missing=only_missing)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import hashlib
|
||||||
|
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
|
@ -7,6 +9,12 @@ from app.config import settings
|
||||||
_ph = PasswordHasher()
|
_ph = PasswordHasher()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(raw: str) -> str:
|
||||||
|
"""SHA-256 hex digest for storing single-use tokens (email-link tokens, the setup token)
|
||||||
|
by hash rather than in cleartext. Not a password hash — these are high-entropy randoms."""
|
||||||
|
return hashlib.sha256(raw.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
return _ph.hash(password)
|
return _ph.hash(password)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
"""Global admin-controlled app state (e.g. pausing background sync, first-run setup)."""
|
"""Global admin-controlled app state (e.g. pausing background sync, first-run setup)."""
|
||||||
import hashlib
|
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
|
|
@ -7,6 +6,7 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import AppState
|
from app.models import AppState
|
||||||
|
from app.security import hash_token
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.state")
|
log = logging.getLogger("siftlode.state")
|
||||||
|
|
||||||
|
|
@ -73,16 +73,12 @@ def mark_configured(db: Session) -> None:
|
||||||
log.info("Instance marked configured; setup wizard disabled.")
|
log.info("Instance marked configured; setup wizard disabled.")
|
||||||
|
|
||||||
|
|
||||||
def _hash_token(raw: str) -> str:
|
|
||||||
return hashlib.sha256(raw.encode()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def rotate_setup_token(db: Session) -> str:
|
def rotate_setup_token(db: Session) -> str:
|
||||||
"""Generate a fresh one-time setup token, persist only its hash, and return the raw token
|
"""Generate a fresh one-time setup token, persist only its hash, and return the raw token
|
||||||
(logged at startup so the admin can open the wizard)."""
|
(logged at startup so the admin can open the wizard)."""
|
||||||
raw = secrets.token_urlsafe(24)
|
raw = secrets.token_urlsafe(24)
|
||||||
row = _row(db)
|
row = _row(db)
|
||||||
row.setup_token_hash = _hash_token(raw)
|
row.setup_token_hash = hash_token(raw)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
return raw
|
return raw
|
||||||
|
|
@ -93,7 +89,7 @@ def check_setup_token(db: Session, raw: str | None) -> bool:
|
||||||
if not raw:
|
if not raw:
|
||||||
return False
|
return False
|
||||||
stored = _row(db).setup_token_hash
|
stored = _row(db).setup_token_hash
|
||||||
return stored is not None and secrets.compare_digest(stored, _hash_token(raw))
|
return stored is not None and secrets.compare_digest(stored, hash_token(raw))
|
||||||
|
|
||||||
|
|
||||||
def get_maintenance_batch(db: Session) -> int:
|
def get_maintenance_batch(db: Session) -> int:
|
||||||
|
|
|
||||||
|
|
@ -20,18 +20,19 @@ Quota: phase 1 costs ~(flagged / 50) units, phase 2 ~(batch / 50). Both respect
|
||||||
backfill reserve as the other jobs so interactive syncs never starve.
|
backfill reserve as the other jobs so interactive syncs never starve.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import timedelta
|
||||||
|
|
||||||
from sqlalchemy import or_, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import progress, quota, sysconfig
|
from app import progress, quota, sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import Playlist, PlaylistItem, Video, VideoState
|
from app.models import Playlist, PlaylistItem, Video
|
||||||
from app.notifications import create_notification
|
from app.notifications import create_notification
|
||||||
from app.state import get_maintenance_batch
|
from app.state import get_maintenance_batch
|
||||||
from app.sync.runner import get_service_user
|
from app.sync.runner import get_service_user
|
||||||
from app.sync.videos import apply_video_details, parse_dt
|
from app.sync.videos import apply_video_details, parse_dt
|
||||||
|
from app.utils import now_utc as _now
|
||||||
from app.youtube.client import YouTubeClient
|
from app.youtube.client import YouTubeClient
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.maintenance")
|
log = logging.getLogger("siftlode.maintenance")
|
||||||
|
|
@ -45,10 +46,6 @@ GRACE_DAYS_BY_REASON = {
|
||||||
DEFAULT_GRACE_DAYS = settings.maintenance_grace_days
|
DEFAULT_GRACE_DAYS = settings.maintenance_grace_days
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _grace_days(reason: str | None) -> int:
|
def _grace_days(reason: str | None) -> int:
|
||||||
return GRACE_DAYS_BY_REASON.get(reason or "", DEFAULT_GRACE_DAYS)
|
return GRACE_DAYS_BY_REASON.get(reason or "", DEFAULT_GRACE_DAYS)
|
||||||
|
|
||||||
|
|
@ -91,14 +88,9 @@ def _collect_user_impact(db: Session, videos: list[Video]) -> dict[int, list[dic
|
||||||
impact: dict[int, set[str]] = {}
|
impact: dict[int, set[str]] = {}
|
||||||
if not ids:
|
if not ids:
|
||||||
return {}
|
return {}
|
||||||
# Saved (VideoState.status == "saved").
|
# Anyone who had it in a playlist — which now also covers "saved": Watch later became a
|
||||||
for user_id, video_id in db.execute(
|
# built-in playlist, so "saved" is no longer a VideoState status (the old status=="saved"
|
||||||
select(VideoState.user_id, VideoState.video_id).where(
|
# scan matched nothing and under-counted impacted users).
|
||||||
VideoState.video_id.in_(ids), VideoState.status == "saved"
|
|
||||||
)
|
|
||||||
):
|
|
||||||
impact.setdefault(user_id, set()).add(video_id)
|
|
||||||
# In any of the user's playlists (covers watch-later, which is a playlist).
|
|
||||||
for user_id, video_id in db.execute(
|
for user_id, video_id in db.execute(
|
||||||
select(Playlist.user_id, PlaylistItem.video_id)
|
select(Playlist.user_id, PlaylistItem.video_id)
|
||||||
.join(PlaylistItem, PlaylistItem.playlist_id == Playlist.id)
|
.join(PlaylistItem, PlaylistItem.playlist_id == Playlist.id)
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,34 @@ def _ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_items_from_youtube(
|
||||||
|
db: Session, yt: YouTubeClient, pl: Playlist, ids: list[str]
|
||||||
|
) -> list[str]:
|
||||||
|
"""Mirror a YouTube playlist's ordered video ids into `pl`'s local items (authoritative
|
||||||
|
replace): ensure the videos exist in the catalog, keep only those present in their YT
|
||||||
|
order (de-duplicated), swap the items out, and reset the synced fingerprint + dirty flag.
|
||||||
|
`pl.name` must already be set to the desired name. The caller commits. Returns the final
|
||||||
|
ordered ids."""
|
||||||
|
_ensure_videos(db, yt, ids)
|
||||||
|
present = (
|
||||||
|
set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all())
|
||||||
|
if ids
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
ordered: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for v in ids:
|
||||||
|
if v in present and v not in seen:
|
||||||
|
seen.add(v)
|
||||||
|
ordered.append(v)
|
||||||
|
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id))
|
||||||
|
for pos, vid in enumerate(ordered):
|
||||||
|
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
|
||||||
|
pl.synced_fingerprint = fingerprint(pl.name, ordered)
|
||||||
|
pl.dirty = False
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
def sync_user_playlists(db: Session, user: User) -> dict:
|
def sync_user_playlists(db: Session, user: User) -> dict:
|
||||||
"""Mirror the user's YouTube playlists into local source='youtube' playlists. Leaves
|
"""Mirror the user's YouTube playlists into local source='youtube' playlists. Leaves
|
||||||
the user's local playlists and the built-in Watch later untouched."""
|
the user's local playlists and the built-in Watch later untouched."""
|
||||||
|
|
@ -152,25 +180,9 @@ def sync_user_playlists(db: Session, user: User) -> dict:
|
||||||
db.flush()
|
db.flush()
|
||||||
else:
|
else:
|
||||||
pl.name = p.get("title") or pl.name
|
pl.name = p.get("title") or pl.name
|
||||||
ids = list(yt.iter_my_playlist_video_ids(ytid))
|
|
||||||
_ensure_videos(db, yt, ids)
|
|
||||||
present = (
|
|
||||||
set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all())
|
|
||||||
if ids
|
|
||||||
else set()
|
|
||||||
)
|
|
||||||
ordered: list[str] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for v in ids:
|
|
||||||
if v in present and v not in seen:
|
|
||||||
seen.add(v)
|
|
||||||
ordered.append(v)
|
|
||||||
# Mirror is authoritative: replace the items to match YouTube's order.
|
# Mirror is authoritative: replace the items to match YouTube's order.
|
||||||
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id))
|
ids = list(yt.iter_my_playlist_video_ids(ytid))
|
||||||
for pos, vid in enumerate(ordered):
|
_replace_items_from_youtube(db, yt, pl, ids)
|
||||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
|
|
||||||
pl.synced_fingerprint = fingerprint(pl.name, ordered)
|
|
||||||
pl.dirty = False
|
|
||||||
db.commit()
|
db.commit()
|
||||||
synced += 1
|
synced += 1
|
||||||
return {"synced": synced}
|
return {"synced": synced}
|
||||||
|
|
@ -349,25 +361,9 @@ def repull_playlist(db: Session, user: User, pl: Playlist) -> dict:
|
||||||
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
|
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
|
||||||
if snippet is None:
|
if snippet is None:
|
||||||
raise YouTubeError("Playlist no longer exists on YouTube")
|
raise YouTubeError("Playlist no longer exists on YouTube")
|
||||||
ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id))
|
|
||||||
_ensure_videos(db, yt, ids)
|
|
||||||
present = (
|
|
||||||
set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all())
|
|
||||||
if ids
|
|
||||||
else set()
|
|
||||||
)
|
|
||||||
ordered: list[str] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for v in ids:
|
|
||||||
if v in present and v not in seen:
|
|
||||||
seen.add(v)
|
|
||||||
ordered.append(v)
|
|
||||||
pl.name = snippet.get("title") or pl.name
|
pl.name = snippet.get("title") or pl.name
|
||||||
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id))
|
ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id))
|
||||||
for pos, vid in enumerate(ordered):
|
ordered = _replace_items_from_youtube(db, yt, pl, ids)
|
||||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
|
|
||||||
pl.synced_fingerprint = fingerprint(pl.name, ordered)
|
|
||||||
pl.dirty = False
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"items": len(ordered)}
|
return {"items": len(ordered)}
|
||||||
|
|
||||||
|
|
|
||||||
17
backend/app/utils.py
Normal file
17
backend/app/utils.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
"""Small cross-cutting helpers shared across modules (kept dependency-light on purpose)."""
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
# Single source of truth for "looks like an email" — used by every endpoint that accepts an
|
||||||
|
# address (auth register/reset/demo, admin invites + demo whitelist, the setup wizard). Keep
|
||||||
|
# the strictness in one place so routes don't drift between this and a looser "@"-in check.
|
||||||
|
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def valid_email(value: str | None) -> bool:
|
||||||
|
return bool(value and _EMAIL_RE.match(value))
|
||||||
|
|
||||||
|
|
||||||
|
def now_utc() -> datetime:
|
||||||
|
"""Current time as a timezone-aware UTC datetime."""
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue