diff --git a/.dockerignore b/.dockerignore index 37fec1b..e3bf6db 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,14 +1,8 @@ -.venv -node_modules -**/node_modules .git -__pycache__ -*.pyc -*.pyo -*.pyd +.gitignore +bin/ +stream_media/ .env -dist -build -StreamX -log.txt -*.log \ No newline at end of file +*.log +cmd/test_* +scratch/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ec91158 --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Server Configuration +PORT=8000 +DEBUG=false +API_LOGS=false +CORS_ORIGIN=* + +# MongoDB Configuration +MONGO_URI=mongodb://localhost:27017 +DATABASE_NAME=Stream + +# Telegram Credentials +API_ID=0 +API_HASH= +BOT_TOKEN= +OWNER_ID=0 +SECRET_KEY= +SESSION_STRING= + +# Channel IDs (optional) +CHANNEL_ID=0 +DUMP_CHANNEL_ID=0 + +# Cloudflare R2 Blob Storage for Artwork (Optional) +# Leave blank to use free online enrichment (iTunes/Deezer) without any account +R2_ACCOUNT_ID= +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +R2_BUCKET_NAME= +R2_PUBLIC_URL= diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..7144192 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,24 @@ +name: Go + +on: + push: + branches: [main, dev/go-migration] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + cache: true + - run: go test ./... + - run: go vet ./... + - run: go build ./cmd/server diff --git a/.github/workflows/heroku-docker.yml b/.github/workflows/heroku-docker.yml index 2f51a9c..b4196a8 100644 --- a/.github/workflows/heroku-docker.yml +++ b/.github/workflows/heroku-docker.yml @@ -1,4 +1,4 @@ -name: Heroku (setup config + Docker web deploy) +name: Heroku (Docker web deploy) on: push: @@ -50,12 +50,6 @@ jobs: echo "HEROKU_ENABLED=$ENABLED" >> $GITHUB_ENV - if [ -n "${{ secrets.CONFIG_GIST }}" ]; then - echo "GIST_RAW_URL=${{ secrets.CONFIG_GIST }}" >> $GITHUB_ENV - else - echo "GIST_RAW_URL=" >> $GITHUB_ENV - fi - - name: Ensure Heroku app exists (create if missing) if: env.HEROKU_ENABLED == 'true' env: @@ -82,63 +76,6 @@ jobs: exit 1 fi - - name: Download config.py from gist - if: env.HEROKU_ENABLED == 'true' && env.GIST_RAW_URL != '' - env: - GIST_RAW_URL: ${{ env.GIST_RAW_URL }} - run: | - echo "Downloading config.py from $GIST_RAW_URL" - curl -sSL "$GIST_RAW_URL" -o remote_config.py - - echo "First lines of remote_config.py:" - head -n 20 remote_config.py || true - - - name: Convert config.py (gist) to Heroku config JSON (dynamic keys) - if: env.HEROKU_ENABLED == 'true' && env.GIST_RAW_URL != '' - run: | - python - << 'PY' - import runpy, json, types - - cfg = runpy.run_path("remote_config.py") - - mapping = {} - - for k, v in cfg.items(): - # Skip Python internals like __name__, __file__, etc. - if k.startswith("__"): - continue - - # Skip modules, functions, classes - if isinstance(v, types.ModuleType) or isinstance(v, type) or callable(v): - continue - - # Convert lists/tuples like [6310382769] to "6310382769 123456" - if isinstance(v, (list, tuple)): - mapping[k] = " ".join(str(x) for x in v) - else: - mapping[k] = str(v) - - with open("heroku-config.json", "w") as f: - json.dump(mapping, f) - - print("Generated heroku-config.json with keys:", ", ".join(mapping.keys())) - PY - - - name: Apply config vars to Heroku app - if: env.HEROKU_ENABLED == 'true' && env.GIST_RAW_URL != '' - env: - HEROKU_API_KEY: ${{ env.HEROKU_API_KEY }} - HEROKU_APP_NAME: ${{ env.HEROKU_APP_NAME }} - run: | - echo "Patching config vars for app '$HEROKU_APP_NAME' from gist..." - curl -sS -X PATCH "https://api.heroku.com/apps/$HEROKU_APP_NAME/config-vars" \ - -H "Content-Type: application/json" \ - -H "Accept: application/vnd.heroku+json; version=3" \ - -H "Authorization: Bearer $HEROKU_API_KEY" \ - -d @heroku-config.json - - echo "Config vars have been updated for app '$HEROKU_APP_NAME'." - - name: Log in to Heroku Container Registry if: env.HEROKU_ENABLED == 'true' env: diff --git a/.gitignore b/.gitignore index 87a2948..2405c78 100644 --- a/.gitignore +++ b/.gitignore @@ -1,39 +1,35 @@ -__pycache__/ -.venv/ -venv/ -*.session* -*.txt -.env -tes* -export/ -*_cache -GenCovers/ -!requirements.txt -!test.py -.env -cookies/ -stream_media/ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/ +dist/ + +# Test binary, built with `go test -c` +*.test -logs -*.log +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# Local environment and secret files .env -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* +*.local.env -node_modules -dist -dist-ssr -*.local +# Media temporary cache +stream_media/ +alac_cache/ +tmp/ -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? \ No newline at end of file +# IDE specific files +.idea/ +.vscode/ +*.swp +*.swo diff --git a/Api/__init__.py b/Api/__init__.py deleted file mode 100644 index e4c7aa5..0000000 --- a/Api/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .main import app diff --git a/Api/__main__.py b/Api/__main__.py deleted file mode 100644 index 7ef36e7..0000000 --- a/Api/__main__.py +++ /dev/null @@ -1,13 +0,0 @@ -import os - -def main(): - try: - import uvicorn - except Exception as e: - raise SystemExit(f"uvicorn is required to run the API: {e}") - - uvicorn.run("Api.main:app", host="0.0.0.0", port=int(os.environ.get("PORT", 8000)), reload=True) - -if __name__ == "__main__": - main() - diff --git a/Api/deps/__init__.py b/Api/deps/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/Api/deps/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Api/deps/db.py b/Api/deps/db.py deleted file mode 100644 index 8b93541..0000000 --- a/Api/deps/db.py +++ /dev/null @@ -1,22 +0,0 @@ -from stream.database.MongoDb import db_handler - -async def init_db(): - await db_handler.initialize() - try: - ft_docs = await db_handler.get_collection("forum_topics").collection.find( - {"topic_name": {"$exists": True, "$not": {"$regex": r"^topic_"}}} - ).to_list(100) - for ft in ft_docs: - t_id = ft.get("topic_id") - t_name = ft.get("topic_name") - if t_id and t_name: - await db_handler.audio_collection.collection.update_many( - {"topic_id": int(t_id), "topic_name": {"$regex": r"^topic_"}}, - {"$set": {"topic_name": t_name}}, - ) - except Exception: - pass - -def get_audio_tracks_collection(): - return db_handler.audio_collection.collection - diff --git a/Api/main.py b/Api/main.py deleted file mode 100644 index 1aa3b4b..0000000 --- a/Api/main.py +++ /dev/null @@ -1,152 +0,0 @@ -import os -from contextlib import asynccontextmanager -from fastapi import FastAPI -from fastapi.staticfiles import StaticFiles -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse - -from Api.deps.db import init_db -from Api.utils.auth_middleware import AuthMiddleware -from Api.utils.spa_middleware import SPAMiddleware -from Api.routers.recaps import router as recaps_router -from Api.routers.access import router as access_router -from Api.routers.browse import router as browse_router -from Api.routers.share import router as share_router -from Api.routers.auth import router as auth_router -from Api.routers.jam import router as jam_router -from Api.routers.webapp import router as webapp_router -from Api.routers.favourites import router as favourites_router -from Api.routers.playlists import router as playlists_router -from Api.routers.health import router as health_router -from Api.routers.test import router as test_router -from Api.routers.tracks import router as tracks_router -from Api.routers.cover import router as cover_router -from Api.routers.admin_refresh import router as admin_refresh_router -from Api.routers.friends import router as friends_router -from Api.routers.notifications import router as notifications_router -from Api.routers.presence import router as presence_router -from Api.routers.soundcloud import router as soundcloud_router -from Api.routers.logs import router as logs_router -from Api.routers.yt_dlp import router as yt_dlp_router -from Api.routers.sources import router as sources_router -from Api.routers.topics import router as topics_router -from Api.routers.discord import router as discord_router - -from stream.core.config_manager import Config - - -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -_dist = os.path.join(BASE_DIR, "dist") -_webx_dist = os.path.join(BASE_DIR, "WebX", "dist") -_sibling_webx_dist = os.path.join(os.path.dirname(BASE_DIR), "WebX", "dist") -_web_dist = os.path.join(BASE_DIR, "StreamXWeb", "dist") - -if os.path.exists(_sibling_webx_dist): - DIST_DIR = _sibling_webx_dist -elif os.path.exists(_dist): - DIST_DIR = _dist -elif os.path.exists(_webx_dist): - DIST_DIR = _webx_dist -elif os.path.exists(_web_dist): - DIST_DIR = _web_dist -else: - DIST_DIR = _dist -ASSETS_DIR = os.path.join(DIST_DIR, "assets") - - -@asynccontextmanager -async def lifespan(app: FastAPI): - await init_db() - await Config.load_from_db() - yield - - -app = FastAPI(lifespan=lifespan) - - -if os.path.exists(ASSETS_DIR): - app.mount("/assets", StaticFiles(directory=ASSETS_DIR), name="assets") - -def _get_cors_origins(): - cors = getattr(Config, "CORS_ORIGIN", None) or getattr(Config, "CORS_ORIGINS", None) - if not cors: - return ["*"] - if isinstance(cors, list): - origins = [str(c).strip() for c in cors if str(c).strip()] - return origins or ["*"] - if isinstance(cors, str): - s = cors.strip() - if not s or s == "*": - return ["*"] - if "," in s: - origins = [x.strip() for x in s.split(",") if x.strip()] - return origins or ["*"] - return [s] - return ["*"] - -app.add_middleware(AuthMiddleware) -app.add_middleware(SPAMiddleware, dist_dir=DIST_DIR) - -_cors_origins = _get_cors_origins() -_allow_all_origins = "*" in _cors_origins - -app.add_middleware( - CORSMiddleware, - allow_origins=[] if _allow_all_origins else _cors_origins, - allow_origin_regex=r"^(https?://|capacitor://|tauri://|ionic://).*" if _allow_all_origins else None, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - expose_headers=["Accept-Ranges", "Content-Range", "Content-Length"], -) - - -# API routers -app.include_router(health_router) -app.include_router(auth_router) -app.include_router(jam_router) -app.include_router(webapp_router) -app.include_router(topics_router) -app.include_router(browse_router) -app.include_router(tracks_router) -app.include_router(playlists_router) -app.include_router(favourites_router) -app.include_router(cover_router) -app.include_router(admin_refresh_router) -app.include_router(test_router) -app.include_router(friends_router) -app.include_router(notifications_router) -app.include_router(presence_router) -app.include_router(share_router) -app.include_router(soundcloud_router) -app.include_router(logs_router) -app.include_router(yt_dlp_router) -app.include_router(sources_router) -app.include_router(discord_router) -app.include_router(recaps_router) -app.include_router(access_router) - - - -@app.get("/") -async def serve_root(): - index_file = os.path.join(DIST_DIR, "index.html") - if os.path.exists(index_file): - return FileResponse(index_file, headers={"Cache-Control": "no-cache, no-store, must-revalidate"}) - return {"status": "frontend not built"} - - -@app.get("/{full_path:path}") -async def serve_spa(full_path: str): - file_path = os.path.join(DIST_DIR, full_path) - - if os.path.exists(file_path): - if full_path in ("sw.js", "manifest.json"): - return FileResponse(file_path, headers={"Cache-Control": "no-cache, no-store, must-revalidate"}) - return FileResponse(file_path) - - index_file = os.path.join(DIST_DIR, "index.html") - if os.path.exists(index_file): - return FileResponse(index_file, headers={"Cache-Control": "no-cache, no-store, must-revalidate"}) - - return {"status": "frontend not built"} \ No newline at end of file diff --git a/Api/routers/__init__.py b/Api/routers/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/Api/routers/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Api/routers/access.py b/Api/routers/access.py deleted file mode 100644 index df4eeda..0000000 --- a/Api/routers/access.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Admin access control — registration policy, invites, allow list, user locking, membership sweeps.""" -from __future__ import annotations - -from typing import Any, Literal, Optional - -from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel, Field - -from Api.services import access_control as access -from Api.utils.auth import require_admin_user_id - -router = APIRouter(prefix="/admin/access", tags=["access"]) - - -class RequiredChatIn(BaseModel): - chat_id: int - title: Optional[str] = None - invite_link: Optional[str] = None - is_private: bool = False - - -class PolicyPatch(BaseModel): - registration_mode: Optional[Literal["open", "invite", "allowlist", "closed"]] = None - enforce_membership: Optional[bool] = None - lock_message: Optional[str] = Field(default=None, max_length=400) - required_chats: Optional[list[RequiredChatIn]] = None - - -class InviteCreate(BaseModel): - max_uses: int = Field(default=1, ge=0, le=10000) - ttl_days: Optional[int] = Field(default=7, ge=0, le=3650) - note: Optional[str] = Field(default=None, max_length=120) - - -class LockBody(BaseModel): - reason: Optional[str] = Field(default=None, max_length=300) - revoke_sessions: bool = True - - -class AllowlistBody(BaseModel): - user_id: int - note: Optional[str] = Field(default=None, max_length=120) - - -class BypassBody(BaseModel): - user_id: int - note: Optional[str] = Field(default=None, max_length=120) - - -@router.get("/policy") -async def get_policy(_: int = Depends(require_admin_user_id)): - return {"ok": True, "policy": await access.get_policy(force=True)} - - -@router.patch("/policy") -async def patch_policy(body: PolicyPatch, admin: int = Depends(require_admin_user_id)): - patch: dict[str, Any] = {k: v for k, v in body.model_dump().items() if v is not None} - try: - policy = await access.update_policy(patch, by=admin) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) - return {"ok": True, "policy": policy} - - -@router.post("/required-chats") -async def add_required_chat(body: RequiredChatIn, _: int = Depends(require_admin_user_id)): - policy = await access.add_required_chat(body.chat_id, body.title, body.invite_link, is_private=body.is_private) - return {"ok": True, "policy": policy} - - -@router.delete("/required-chats/{chat_id}") -async def remove_required_chat(chat_id: int, _: int = Depends(require_admin_user_id)): - return {"ok": True, "policy": await access.remove_required_chat(chat_id)} - - -@router.get("/invites") -async def invites(include_dead: bool = Query(default=False), _: int = Depends(require_admin_user_id)): - return {"ok": True, "items": await access.list_invites(include_dead)} - - -@router.post("/invites") -async def create_invite(body: InviteCreate, admin: int = Depends(require_admin_user_id)): - return {"ok": True, "invite": await access.create_invite(admin, body.max_uses, body.ttl_days or None, body.note)} - - -@router.delete("/invites/{code}") -async def revoke_invite(code: str, _: int = Depends(require_admin_user_id)): - if not await access.revoke_invite(code): - raise HTTPException(status_code=404, detail="invite not found") - return {"ok": True} - - -@router.get("/allowlist") -async def allowlist(_: int = Depends(require_admin_user_id)): - return {"ok": True, "items": await access.allowlist_list()} - - -@router.post("/allowlist") -async def allowlist_add(body: AllowlistBody, admin: int = Depends(require_admin_user_id)): - await access.allowlist_add(body.user_id, admin, body.note) - return {"ok": True} - - -@router.delete("/allowlist/{user_id}") -async def allowlist_remove(user_id: int, _: int = Depends(require_admin_user_id)): - return {"ok": await access.allowlist_remove(user_id)} - - -@router.get("/bypass") -async def bypass_list(_: int = Depends(require_admin_user_id)): - return {"ok": True, "items": await access.bypass_list()} - - -@router.post("/bypass") -async def bypass_add(body: BypassBody, admin: int = Depends(require_admin_user_id)): - await access.bypass_add(body.user_id, admin, body.note) - return {"ok": True} - - -@router.delete("/bypass/{user_id}") -async def bypass_remove(user_id: int, _: int = Depends(require_admin_user_id)): - return {"ok": await access.bypass_remove(user_id)} - - -@router.get("/users") -async def users( - q: Optional[str] = Query(default=None, max_length=64), - status: Optional[Literal["active", "locked", "restricted"]] = None, - limit: int = Query(default=50, ge=1, le=200), - skip: int = Query(default=0, ge=0), - _: int = Depends(require_admin_user_id), -): - return {"ok": True, **(await access.list_users(q, status, limit, skip))} - - -@router.post("/users/{user_id}/lock") -async def lock_user(user_id: int, body: LockBody, admin: int = Depends(require_admin_user_id)): - if int(user_id) == int(admin): - raise HTTPException(status_code=400, detail="You can't lock your own account") - return {"ok": True, "access": await access.set_user_status(user_id, "locked", body.reason, by=admin, revoke_sessions=body.revoke_sessions)} - - -@router.post("/users/{user_id}/unlock") -async def unlock_user(user_id: int, _: int = Depends(require_admin_user_id)): - return {"ok": True, "access": await access.set_user_status(user_id, "active")} - - -@router.post("/users/{user_id}/revoke-sessions") -async def revoke_sessions(user_id: int, _: int = Depends(require_admin_user_id)): - return {"ok": True, "token_version": await access.revoke_sessions(user_id)} - - -@router.post("/reverify") -async def reverify(_: int = Depends(require_admin_user_id)): - return {"ok": True, **(await access.reverify_all())} diff --git a/Api/routers/admin_refresh.py b/Api/routers/admin_refresh.py deleted file mode 100644 index 16dfa03..0000000 --- a/Api/routers/admin_refresh.py +++ /dev/null @@ -1,666 +0,0 @@ -import asyncio -import hashlib -import re -import time -import unicodedata - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel -import datetime - -from pymongo import UpdateOne - -from Api.services.track_service import ( - refresh_daily_playlist_cache, - refresh_daily_playlists_bulk, - refresh_user_top_played_cache, - refresh_user_top_played_cache_bulk, - rebuild_global_playback_from_userplayback, -) -from Api.utils.auth import require_user_id, require_admin_user_id -from Api.deps.db import get_audio_tracks_collection -from stream.core.config_manager import Config -from stream.database.MongoDb import db_handler - - -router = APIRouter(prefix="/admin/refresh", tags=["admin"]) - - -_ALBUM_ARTIST_SPLIT_RE = re.compile(r"\s*(?:,|/|&| and | x | feat\. | feat | ft\. | ft )\s*", flags=re.I) - - -def _split_artists(value: str) -> list[str]: - raw = (value or "").strip() - if not raw: - return [] - raw = raw.replace("(", " ").replace(")", " ").replace("[", " ").replace("]", " ").strip() - parts = [p.strip() for p in _ALBUM_ARTIST_SPLIT_RE.split(raw) if p and p.strip()] - out: list[str] = [] - seen: set[str] = set() - for p in parts: - k = p.casefold() - if k in seen: - continue - seen.add(k) - out.append(p) - return out - - -def _normalize_album_id_part(text: str) -> str: - raw = (text or "").strip().lower() - if not raw: - return "" - s = raw.replace("÷", " divide ") - s = s.replace("&", " and ") - s = s.replace("+", " plus ") - s = unicodedata.normalize("NFKD", s) - s = s.encode("ascii", "ignore").decode("ascii") - s = re.sub(r"[^a-z0-9 ]", " ", s) - s = re.sub(r"\s+", "_", s.strip()) - s = re.sub(r"_+", "_", s).strip("_") - if s: - return s - h = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16] - return f"u_{h}" - - -def _coerce_year(value: object) -> int | None: - if value is None or isinstance(value, bool): - return None - try: - y = int(value) - except Exception: - return None - if y < 1000 or y > 2100: - return None - return y - - -def _album_id(*, album: str, year: int | None) -> str: - b = _normalize_album_id_part(album) - if not b: - return "" - y = _coerce_year(year) - if y is not None: - return f"album_{b}_{y}" - return f"album_{b}" - - -def _clean_url(value: object) -> str | None: - if not isinstance(value, str): - return None - s = value.strip() - if len(s) >= 2 and s[0] == "`" and s[-1] == "`": - s = s[1:-1].strip() - return s or None - - -@router.get("/keys") -async def list_refresh_keys(_: int = Depends(require_admin_user_id)): - return { - "daily_playlist_keys": [ - { - "key": "random", - "aliases": ["mix", "daily", "daily-playlist"], - "what": "Seeded daily random selection from the track library.", - }, - { - "key": "top-played", - "aliases": ["top", "top-playlist"], - "what": "Seeded daily selection from globally most played tracks.", - }, - { - "key": "trending", - "aliases": ["trending-today"], - "what": "Global plays in last 24h.", - }, - { - "key": "rediscover", - "aliases": [], - "what": "Popular tracks not played recently (global).", - }, - { - "key": "late-night", - "aliases": ["late-night-mix", "night"], - "what": "Tracks mostly played 22:00–03:00 (global).", - }, - { - "key": "rising", - "aliases": ["rising-tracks"], - "what": "Fast-rising tracks (last 3 days vs all-time).", - }, - { - "key": "surprise", - "aliases": ["surprise-me"], - "what": "Weighted random from global plays.", - }, - ], - "keys": [ - { - "key": "all", - "what": "Refreshes everything (globalplayback rebuild, daily playlists + covers, user top played + cover).", - "endpoint": {"method": "POST", "path": "/admin/refresh/all"}, - "params": {}, - }, - { - "key": "rebuild-globalplayback", - "what": "Rebuilds globalPlayback from userPlayback (restores top-played sources).", - "endpoint": {"method": "POST", "path": "/admin/refresh/rebuild-globalplayback"}, - "params": {}, - }, - { - "key": "daily-playlist", - "what": "Regenerates one cached daily playlist (tracks + cover).", - "endpoint": {"method": "POST", "path": "/admin/refresh/daily-playlist"}, - "params": {"key": "string", "date": "YYYY-MM-DD|\"\"", "channel_id": "int (0 means default)", "limit": "int (0 means default)"}, - }, - { - "key": "daily-playlists", - "what": "Regenerates daily playlists for multiple channels (tracks + covers).", - "endpoint": {"method": "POST", "path": "/admin/refresh/daily-playlists"}, - "params": {"date": "YYYY-MM-DD|\"\"", "keys": ["random", "top-played", "trending", "rediscover", "late-night", "rising", "surprise"], "channel_ids": ["int (0 means default)"], "limit": "int (0 means default)"}, - }, - { - "key": "user-top-played", - "what": "Precomputes cached top played list for all users (tracks + cover).", - "endpoint": {"method": "POST", "path": "/admin/refresh/user-top-played"}, - "params": {"limit_tracks": "int (0 means default)"}, - }, - ] - } - - -@router.post("/all") -async def refresh_all(_: int = Depends(require_admin_user_id)): - today = datetime.datetime.utcnow().date().isoformat() - globalplayback = await rebuild_global_playback_from_userplayback() - daily = await refresh_daily_playlists_bulk(date=today, keys=None, channel_ids=[None], limit=75) - top_played = await refresh_user_top_played_cache_bulk(user_ids=None, limit_users=None, limit_tracks=500) - return {"ok": True, "date": today, "globalplayback": globalplayback, "daily_playlists": daily, "user_top_played": top_played} - - -@router.post("/rebuild-globalplayback") -async def rebuild_globalplayback(_: int = Depends(require_admin_user_id)): - return {"ok": True, "result": await rebuild_global_playback_from_userplayback()} - - -class RefreshDailyPlaylistRequest(BaseModel): - key: str = "random" - date: str = "" - channel_id: int = 0 - limit: int = 0 - - -@router.post("/daily-playlist") -async def refresh_one_daily_playlist(payload: RefreshDailyPlaylistRequest, _: int = Depends(require_admin_user_id)): - d = (payload.date or "").strip() or datetime.datetime.utcnow().date().isoformat() - channel_id = int(payload.channel_id) if int(payload.channel_id or 0) != 0 else None - limit = int(payload.limit or 0) - if limit <= 0: - limit = 75 - if limit > 75: - limit = 75 - return {"ok": True, "result": await refresh_daily_playlist_cache(key=payload.key, date=d, channel_id=channel_id, limit=limit)} - - -class RefreshDailyPlaylistsRequest(BaseModel): - date: str = "" - keys: list[str] | None = None - channel_ids: list[int] | None = None - limit: int = 0 - - -@router.post("/daily-playlists") -async def refresh_many_daily_playlists(payload: RefreshDailyPlaylistsRequest, _: int = Depends(require_admin_user_id)): - d = (payload.date or "").strip() or datetime.datetime.utcnow().date().isoformat() - limit = int(payload.limit or 0) - if limit <= 0: - limit = 75 - if limit > 75: - limit = 75 - - channel_ids: list[int | None] | None = None - if payload.channel_ids is not None: - channel_ids = [(int(v) if int(v or 0) != 0 else None) for v in payload.channel_ids] - - return {"ok": True, **(await refresh_daily_playlists_bulk(date=d, keys=payload.keys, channel_ids=channel_ids, limit=limit))} - - -class RefreshUserTopPlayedRequest(BaseModel): - limit_tracks: int = 0 - - -@router.post("/user-top-played") -async def refresh_user_top_played(payload: RefreshUserTopPlayedRequest, _: int = Depends(require_admin_user_id)): - limit_tracks = int(payload.limit_tracks or 0) - if limit_tracks <= 0: - limit_tracks = 500 - return {"ok": True, **(await refresh_user_top_played_cache_bulk(user_ids=None, limit_users=None, limit_tracks=limit_tracks))} - - -class RebuildAlbumsFromTracksRequest(BaseModel): - dry_run: bool = True - force_album_id: bool = False - limit_tracks: int = 0 - rebuild_albums: bool = True - limit_albums: int = 0 - clear_albums: bool = False - dedup_tracks: bool = True - dedup_limit_groups: int = 0 - - -@router.post("/albums/from-tracks") -async def rebuild_albums_from_tracks(payload: RebuildAlbumsFromTracksRequest, _: int = Depends(require_admin_user_id)): - limit_tracks = int(payload.limit_tracks or 0) - if limit_tracks <= 0: - limit_tracks = 200_000 - if limit_tracks > 1_000_000: - limit_tracks = 1_000_000 - - limit_albums = int(payload.limit_albums or 0) - if limit_albums <= 0: - limit_albums = 50_000 - if limit_albums > 200_000: - limit_albums = 200_000 - - dry_run = bool(payload.dry_run) - force_album_id = bool(payload.force_album_id) - - tracks_col = get_audio_tracks_collection() - dedup_groups = 0 - dedup_duplicates = 0 - dedup_deleted = 0 - dedup_updated = 0 - - if bool(payload.dedup_tracks): - limit_groups = int(payload.dedup_limit_groups or 0) - if limit_groups <= 0: - limit_groups = 5000 - if limit_groups > 50_000: - limit_groups = 50_000 - - pipeline = [ - { - "$match": { - "deleted": {"$ne": True}, - "content_hash": {"$type": "string", "$ne": ""}, - } - }, - {"$group": {"_id": "$content_hash", "ids": {"$push": "$_id"}, "count": {"$sum": 1}}}, - {"$match": {"count": {"$gt": 1}}}, - {"$limit": int(limit_groups)}, - ] - cur = await tracks_col.aggregate(pipeline) - async for row in cur: - if not isinstance(row, dict): - continue - ids = row.get("ids") - if not isinstance(ids, list) or len(ids) < 2: - continue - - dedup_groups += 1 - dedup_duplicates += int(len(ids)) - - docs: list[dict] = [] - cursor = tracks_col.find( - {"_id": {"$in": ids}}, - { - "audio": 1, - "spotify": 1, - "lyrics": 1, - "telegram": 1, - "content_hash": 1, - "fingerprint": 1, - "source_chat_id": 1, - "source_message_id": 1, - "updated_at": 1, - "deleted": 1, - }, - ) - async for d in cursor: - if isinstance(d, dict): - docs.append(d) - if len(docs) < 2: - continue - - def _score(doc: dict) -> tuple[int, float, str]: - audio = doc.get("audio") if isinstance(doc.get("audio"), dict) else {} - spotify = doc.get("spotify") if isinstance(doc.get("spotify"), dict) else {} - score = 0 - if isinstance(doc.get("lyrics"), str) and doc.get("lyrics", "").strip(): - score += 50 - if isinstance(spotify.get("big_cover_url"), str) and spotify.get("big_cover_url", "").strip(): - score += 20 - elif isinstance(spotify.get("cover_url"), str) and spotify.get("cover_url", "").strip(): - score += 10 - if isinstance(audio.get("album_id"), str) and audio.get("album_id", "").strip(): - score += 5 - if isinstance(audio.get("album"), str) and audio.get("album", "").strip(): - score += 2 - if isinstance(audio.get("artist"), str) and audio.get("artist", "").strip(): - score += 1 - updated_at = float(doc.get("updated_at") or 0.0) - return score, updated_at, str(doc.get("_id") or "") - - docs.sort(key=lambda d: _score(d), reverse=True) - canonical = docs[0] - canonical_id = canonical.get("_id") - if canonical_id is None: - continue - - merged_audio = canonical.get("audio") if isinstance(canonical.get("audio"), dict) else {} - merged_spotify = canonical.get("spotify") if isinstance(canonical.get("spotify"), dict) else {} - merged_telegram = canonical.get("telegram") if isinstance(canonical.get("telegram"), dict) else {} - - file_ids: dict[str, str] = {} - file_id_variants: list[str] = [] - - def _add_file_id(val: object) -> None: - if isinstance(val, str) and val.strip(): - v = val.strip() - if len(v) >= 2 and v[0] == "`" and v[-1] == "`": - v = v[1:-1].strip() - if v and v not in file_id_variants: - file_id_variants.append(v) - - for d in docs: - tel = d.get("telegram") if isinstance(d.get("telegram"), dict) else {} - _add_file_id(tel.get("file_id")) - ids_map = tel.get("file_ids") if isinstance(tel.get("file_ids"), dict) else {} - for k, v in ids_map.items(): - if isinstance(k, str) and k.strip() and isinstance(v, str) and v.strip(): - file_ids[k.strip()] = v.strip() - _add_file_id(v) - - if not (isinstance(d.get("lyrics"), str) and d.get("lyrics", "").strip()) and isinstance(canonical.get("lyrics"), str): - pass - else: - if not (isinstance(canonical.get("lyrics"), str) and canonical.get("lyrics", "").strip()): - if isinstance(d.get("lyrics"), str) and d.get("lyrics", "").strip(): - canonical["lyrics"] = d.get("lyrics") - - aud = d.get("audio") if isinstance(d.get("audio"), dict) else {} - for k, v in aud.items(): - if k not in merged_audio or merged_audio.get(k) in ("", None, [], {}): - if v not in ("", None, [], {}): - merged_audio[k] = v - - sp = d.get("spotify") if isinstance(d.get("spotify"), dict) else {} - for k in ("big_cover_url", "cover_url", "cover_source", "track_spotify_id", "url"): - if k not in merged_spotify or merged_spotify.get(k) in ("", None, [], {}): - vv = sp.get(k) - if vv not in ("", None, [], {}): - merged_spotify[k] = vv - - if merged_telegram.get("dump_message_id") in (None, "", 0): - dm = tel.get("dump_message_id") - if dm not in (None, "", 0): - merged_telegram["dump_message_id"] = dm - - if file_ids: - merged_telegram["file_ids"] = file_ids - main_file_id = merged_telegram.get("file_id") - if not (isinstance(main_file_id, str) and main_file_id.strip()) and file_id_variants: - merged_telegram["file_id"] = file_id_variants[0] - if file_id_variants: - merged_telegram["file_id_variants"] = file_id_variants - - set_doc: dict[str, object] = {"audio": merged_audio, "spotify": merged_spotify, "telegram": merged_telegram} - if isinstance(canonical.get("lyrics"), str) and canonical.get("lyrics", "").strip(): - set_doc["lyrics"] = canonical.get("lyrics") - if canonical.get("source_chat_id") is None or canonical.get("source_message_id") is None: - for d in docs: - if d.get("source_chat_id") is not None and d.get("source_message_id") is not None: - set_doc["source_chat_id"] = d.get("source_chat_id") - set_doc["source_message_id"] = d.get("source_message_id") - break - set_doc["updated_at"] = time.time() - - other_ids = [d.get("_id") for d in docs[1:] if d.get("_id") is not None and d.get("_id") != canonical_id] - if not dry_run: - await tracks_col.update_one({"_id": canonical_id}, {"$set": set_doc}, upsert=False) - dedup_updated += 1 - if other_ids: - res = await tracks_col.delete_many({"_id": {"$in": other_ids}}) - dedup_deleted += int(getattr(res, "deleted_count", 0) or 0) - - if not dry_run: - try: - await tracks_col.create_index([("content_hash", 1)], unique=True, sparse=True) - except Exception: - pass - try: - await tracks_col.create_index([("fingerprint", 1)]) - except Exception: - pass - - scanned = 0 - updated = 0 - bulk: list[UpdateOne] = [] - - cursor = ( - tracks_col.find( - { - "deleted": {"$ne": True}, - "$or": [ - {"audio.album": {"$exists": True, "$ne": ""}}, - {"audio.title": {"$exists": True, "$ne": ""}}, - ], - }, - {"audio": 1, "spotify.cover_url": 1, "spotify.big_cover_url": 1, "updated_at": 1}, - ) - .sort([("updated_at", -1)]) - .limit(limit_tracks) - ) - - async for doc in cursor: - scanned += 1 - audio = doc.get("audio") if isinstance(doc.get("audio"), dict) else {} - album = audio.get("album") - if not isinstance(album, str) or not album.strip(): - album = audio.get("title") - if not isinstance(album, str) or not album.strip(): - continue - album = album.strip() - - current = audio.get("album_id") - if isinstance(current, str) and current.strip() and not force_album_id: - continue - - year = _coerce_year(audio.get("year")) - aid = _album_id(album=album, year=year) - if not aid: - continue - - if not dry_run: - bulk.append(UpdateOne({"_id": doc["_id"]}, {"$set": {"audio.album_id": aid}})) - if len(bulk) >= 500: - res = await tracks_col.bulk_write(bulk, ordered=False) - updated += int(getattr(res, "modified_count", 0) or 0) - bulk.clear() - else: - updated += 1 - - if bulk and not dry_run: - res = await tracks_col.bulk_write(bulk, ordered=False) - updated += int(getattr(res, "modified_count", 0) or 0) - bulk.clear() - - albums_upserted = 0 - album_groups = 0 - albums_cleared = 0 - - if bool(payload.rebuild_albums): - albums_col = db_handler.get_collection("albums").collection - now = time.time() - if bool(payload.clear_albums) and not dry_run: - try: - res = await albums_col.delete_many({}) - albums_cleared = int(getattr(res, "deleted_count", 0) or 0) - except Exception: - albums_cleared = 0 - pipeline = [ - {"$match": {"deleted": {"$ne": True}, "audio.album_id": {"$exists": True, "$ne": ""}}}, - { - "$addFields": { - "_aid": "$audio.album_id", - "_album_title": {"$ifNull": ["$audio.album", "$audio.title"]}, - "_artist_raw": { - "$cond": [ - {"$and": [{"$isArray": "$audio.artists"}, {"$gt": [{"$size": "$audio.artists"}, 0]}]}, - {"$arrayElemAt": ["$audio.artists", 0]}, - {"$ifNull": ["$audio.artist", "$audio.performer"]}, - ] - }, - } - }, - { - "$addFields": { - "_match_album": {"$toLower": {"$trim": {"input": "$_album_title"}}}, - "_match_artist": {"$toLower": {"$trim": {"input": "$_artist_raw"}}}, - } - }, - {"$sort": {"updated_at": -1}}, - { - "$group": { - "_id": "$_aid", - "title": {"$first": "$_album_title"}, - "artist": {"$first": "$_artist_raw"}, - "cover_url": {"$first": {"$ifNull": ["$spotify.big_cover_url", "$spotify.cover_url"]}}, - "tracks_count": {"$sum": 1}, - "duration_total": {"$sum": {"$ifNull": ["$audio.duration_sec", 0]}}, - "updated_at": {"$max": {"$ifNull": ["$updated_at", 0]}}, - "match_album": {"$first": "$_match_album"}, - "match_artist": {"$first": "$_match_artist"}, - } - }, - {"$limit": int(limit_albums)}, - ] - cur = await tracks_col.aggregate(pipeline) - ops: list[UpdateOne] = [] - async for row in cur: - album_groups += 1 - aid = row.get("_id") - if not isinstance(aid, str) or not aid.strip(): - continue - title = (row.get("title") or "").strip() - artist = (row.get("artist") or "").strip() - match_album = (row.get("match_album") or "").strip() - match_artist = (row.get("match_artist") or "").strip() - if not title or not match_album: - continue - - cover_url = _clean_url(row.get("cover_url")) - artist_out = artist or None - artists = _split_artists(artist) if artist else [] - album_type = "single" if int(row.get("tracks_count") or 0) <= 1 else "album" - ops.append( - UpdateOne( - {"_id": aid}, - { - "$setOnInsert": {"created_at": now}, - "$set": { - "title": title, - "artist": artist_out, - "artists": artists if artists else None, - "cover_url": cover_url, - "tracks_count": int(row.get("tracks_count") or 0), - "duration_total": int(row.get("duration_total") or 0), - "type": album_type, - "match_album": match_album, - "match_artist": match_artist or None, - "updated_at": float(row.get("updated_at") or now), - }, - }, - upsert=True, - ) - ) - if len(ops) >= 500: - if not dry_run: - res = await albums_col.bulk_write(ops, ordered=False) - albums_upserted += int(getattr(res, "upserted_count", 0) or 0) - ops.clear() - - if ops: - if not dry_run: - res = await albums_col.bulk_write(ops, ordered=False) - albums_upserted += int(getattr(res, "upserted_count", 0) or 0) - ops.clear() - - return { - "ok": True, - "dry_run": dry_run, - "force_album_id": force_album_id, - "dedup_tracks": bool(payload.dedup_tracks), - "dedup_groups": int(dedup_groups), - "dedup_duplicates": int(dedup_duplicates), - "dedup_updated": int(dedup_updated), - "dedup_deleted": int(dedup_deleted), - "scanned_tracks": int(scanned), - "tracks_updated": int(updated), - "albums_rebuilt": bool(payload.rebuild_albums), - "albums_cleared": int(albums_cleared), - "album_groups": int(album_groups), - "albums_upserted": int(albums_upserted), - } - - -class ReverifyTracksRequest(BaseModel): - force_all: bool = False - missing_titles: bool = True - missing_lyrics: bool = False - unenriched_only: bool = False - limit: int = 0 - concurrency: int = 2 - background: bool = True - - -@router.post("/tracks/reverify") -async def trigger_reverify_tracks( - payload: ReverifyTracksRequest, - _: int = Depends(require_admin_user_id), -): - from Api.services.reverify_service import reverify_tracks, get_reverify_status - - status = get_reverify_status() - if status.get("running"): - return {"ok": False, "error": "already_running", "status": status} - - if payload.background: - asyncio.create_task( - reverify_tracks( - force_all=payload.force_all, - missing_titles=payload.missing_titles, - missing_lyrics=payload.missing_lyrics, - unenriched_only=payload.unenriched_only, - limit=payload.limit, - concurrency=payload.concurrency, - ) - ) - return {"ok": True, "message": "Reverify job started in background", "background": True} - - res = await reverify_tracks( - force_all=payload.force_all, - missing_titles=payload.missing_titles, - missing_lyrics=payload.missing_lyrics, - unenriched_only=payload.unenriched_only, - limit=payload.limit, - concurrency=payload.concurrency, - ) - return res - - -@router.get("/tracks/reverify/status") -async def get_reverify_tracks_status(_: int = Depends(require_admin_user_id)): - from Api.services.reverify_service import get_reverify_status - - return get_reverify_status() - - -@router.post("/tracks/reverify/cancel") -async def cancel_reverify_tracks(_: int = Depends(require_admin_user_id)): - from Api.services.reverify_service import cancel_reverify_job - - cancelled = cancel_reverify_job() - return {"ok": True, "cancelled": cancelled} - diff --git a/Api/routers/auth.py b/Api/routers/auth.py deleted file mode 100644 index 455d0f0..0000000 --- a/Api/routers/auth.py +++ /dev/null @@ -1,1843 +0,0 @@ -import base64 -import hashlib -import hmac -import json -import os -import re -import secrets -import time -import random -from urllib.parse import quote_plus, urlencode, urlparse - -import httpx -import jwt -from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response -from fastapi.responses import RedirectResponse, HTMLResponse - -from Api.routers.webapp import extract_telegram_user -from Api.schemas.auth import ( - ChangeOwnerPasswordRequest, - OwnerPasswordLoginRequest, - PasswordLoginRequest, - SetCookieRequest, - SetCredentialsRequest, - SetOwnerPasswordRequest, - TgLoginRequest, - FCMTokenRequest, - RegisterRequest, - ValidateOTPRequest, - TelegramWidgetLoginRequest, - TelegramTokenLoginRequest, - TelegramBotSessionRequest, - IntegrationsUpdateRequest, -) -from stream.helpers.logger import LOGGER -from Api.utils.auth import ( - create_auth_token, - get_optional_user_id, - require_admin_user_id, - require_user_id, - verify_auth_token, -) -from stream.core.config_manager import Config -from stream.database.MongoDb import db_handler -from Api.services import access_control as access -from Api.utils.auth import is_admin_user - -logger = LOGGER(__name__) - -router = APIRouter(prefix="/auth", tags=["auth"]) - - -_USERNAME_RE = re.compile(r"^[a-z0-9_\.]{3,32}$", flags=re.I) -_TG_USERNAME_RE = re.compile(r"^[a-z0-9_]{5,32}$", flags=re.I) - - -def _canon_username(value: str) -> str: - s = (value or "").strip() - if not s: - return "" - s = s.lower() - if not _USERNAME_RE.match(s): - return "" - return s - - -def _tg_userpic_url(username: str | None) -> str | None: - u = (username or "").strip().lstrip("@").strip() - if not u: - return None - if not _TG_USERNAME_RE.match(u): - return None - return f"https://t.me/i/userpic/320/{u}.jpg" - - -async def _get_telegram_profile(user_id: int) -> dict[str, str | None]: - try: - from stream import bot - except Exception: - bot = None - - if bot is None: - return {"first_name": None, "telegram_username": None, "profile_url": None, "photo_url": None} - - try: - u = await bot.get_users(int(user_id)) - except Exception: - return {"first_name": None, "telegram_username": None, "profile_url": None, "photo_url": None} - - first_name = (getattr(u, "first_name", None) or "").strip() or None - telegram_username = (getattr(u, "username", None) or "").strip() or None - photo_url = _tg_userpic_url(telegram_username) - profile_url = photo_url - return { - "first_name": first_name, - "telegram_username": telegram_username, - "profile_url": profile_url, - "photo_url": photo_url, - } - - -def _b64e(data: bytes) -> str: - return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") - - -def _b64d(data: str) -> bytes: - s = (data or "").strip() - pad = "=" * (-len(s) % 4) - return base64.urlsafe_b64decode(s + pad) - - -def _hash_password(password: str, *, salt: bytes | None = None, iterations: int = 200_000) -> dict: - pwd = (password or "").encode("utf-8") - if not pwd: - raise HTTPException(status_code=400, detail="password is required") - salt = os.urandom(16) if salt is None else salt - dk = hashlib.pbkdf2_hmac("sha256", pwd, salt, int(iterations)) - return {"algo": "pbkdf2_sha256", "salt": _b64e(salt), "iterations": int(iterations), "hash": _b64e(dk)} - - -def _verify_password(password: str, stored: dict) -> bool: - if not isinstance(stored, dict): - return False - if (stored.get("algo") or "") != "pbkdf2_sha256": - return False - try: - salt = _b64d(str(stored.get("salt") or "")) - iters = int(stored.get("iterations") or 0) - expected = _b64d(str(stored.get("hash") or "")) - except Exception: - return False - if not salt or iters <= 0 or not expected: - return False - dk = hashlib.pbkdf2_hmac("sha256", (password or "").encode("utf-8"), salt, iters) - return hmac.compare_digest(dk, expected) - - -def _is_truthy(value: object) -> bool: - if isinstance(value, bool): - return bool(value) - s = str(value or "").strip().lower() - return s in {"1", "true", "yes", "on"} - -def _normalize_samesite(value: object) -> str | None: - s = str(value or "").strip().lower() - if not s: - return None - if s in {"lax", "strict", "none"}: - return s - return None - - -def _set_auth_cookie( - *, - response: Response, - token: str, -) -> None: - debug = _is_truthy(getattr(Config, "DEBUG", False)) - configured_secure = getattr(Config, "COOKIE_SECURE", None) - configured_samesite = _normalize_samesite(getattr(Config, "COOKIE_SAMESITE", None)) - secure = _is_truthy(configured_secure) if str(configured_secure or "").strip() else (not debug) - samesite = configured_samesite or ("lax" if debug else "none") - response.set_cookie( - key="auth_token", - value=str(token), - httponly=True, - secure=secure, - samesite=samesite, - path="/", - max_age=365 * 24 * 60 * 60, - ) - - - -def _access_http(denied: "access.AccessDenied") -> HTTPException: - return HTTPException(status_code=denied.status_code, detail=denied.payload()) - - -async def _issue_user_token(user_id: int, **kwargs) -> str: - """Gate the login (lock / membership) and mint a token carrying the user's token_version.""" - try: - await access.assert_can_login(int(user_id)) - except access.AccessDenied as denied: - raise _access_http(denied) - info = await access.get_user_access(int(user_id), force=True) - return create_auth_token(user_id=int(user_id), token_version=info["token_version"], **kwargs) - - -@router.post("/tg/login") -async def tg_login( - payload: TgLoginRequest, - response: Response, - set_cookie: bool = Query(default=False), -): - tg = extract_telegram_user(payload.init_data) - tg_user_id = int(tg["user_id"]) - if tg_user_id <= 0: - raise HTTPException(status_code=401, detail="invalid telegram user") - - now = time.time() - updates: dict = { - "first_name": tg.get("first_name"), - "photo_url": tg.get("photo_url"), - "profile_url": tg.get("photo_url"), - "telegram.id": tg_user_id, - "telegram.username": tg.get("username"), - "updated_at": now, - } - - col = db_handler.get_collection("users").collection - existing_user = await col.find_one({"_id": tg_user_id}) - if not existing_user: - try: - reg_ctx = await access.assert_can_register(tg_user_id, payload.invite_code) - if reg_ctx.get("invite_code"): - await access.consume_invite(reg_ctx["invite_code"], tg_user_id) - except access.AccessDenied as denied: - raise _access_http(denied) - - set_on_insert = { - "created_at": now, - "token_version": 0, - "status": "active", - "registered_via": {"mode": (await access.get_policy())["registration_mode"]}, - } - - canon = _canon_username(payload.username or "") - if payload.username is not None: - if not canon: - raise HTTPException(status_code=400, detail="invalid username") - existing = await col.find_one({"username": canon, "_id": {"$ne": tg_user_id}}, {"_id": 1}) - if existing: - raise HTTPException(status_code=409, detail="username already taken") - updates["username"] = canon - updates["username_updated_at"] = now - - if payload.password is not None: - updates["password"] = _hash_password(payload.password) - updates["password_updated_at"] = now - - await col.update_one({"_id": tg_user_id}, {"$set": updates, "$setOnInsert": set_on_insert}, upsert=True) - - token = await _issue_user_token(tg_user_id, first_name=tg.get("first_name"), profile_url=tg.get("photo_url")) - if set_cookie: - _set_auth_cookie(response=response, token=token) - return { - "ok": True, - "user_id": tg_user_id, - "token": token, - "first_name": tg.get("first_name"), - "username": tg.get("username"), - "photo_url": tg.get("photo_url"), - "profile_url": tg.get("photo_url"), - } - - -@router.post("/login") -async def password_login( - payload: PasswordLoginRequest, - response: Response, - set_cookie: bool = Query(default=False), -): - canon = _canon_username(payload.username) - if not canon: - raise HTTPException(status_code=400, detail="invalid username") - - col = db_handler.get_collection("users").collection - doc = await col.find_one( - {"username": canon}, - {"_id": 1, "password": 1, "first_name": 1, "profile_url": 1, "photo_url": 1, "telegram": 1}, - ) - if not doc: - raise HTTPException(status_code=401, detail="invalid credentials") - if not _verify_password(payload.password, doc.get("password") if isinstance(doc.get("password"), dict) else {}): - raise HTTPException(status_code=401, detail="invalid credentials") - - uid = int(doc["_id"]) - first_name = doc.get("first_name") if isinstance(doc.get("first_name"), str) and doc.get("first_name").strip() else None - if not first_name: - first_name = doc.get("username") if isinstance(doc.get("username"), str) and doc.get("username").strip() else None - profile_url = doc.get("profile_url") if isinstance(doc.get("profile_url"), str) else None - if not profile_url: - profile_url = doc.get("photo_url") if isinstance(doc.get("photo_url"), str) else None - token = await _issue_user_token(uid, first_name=first_name, profile_url=profile_url) - if set_cookie: - _set_auth_cookie(response=response, token=token) - return {"ok": True, "user_id": uid, "token": token, "first_name": first_name, "username": doc.get("username"), "profile_url": profile_url, "photo_url": profile_url} - - -def _get_primary_owner_id() -> int: - owners = getattr(Config, "OWNER_ID", None) - if isinstance(owners, (int, str)): - try: - val = int(owners) - return val if val > 0 else 0 - except Exception: - return 0 - for v in (owners or []): - try: - uid = int(v) - if uid > 0: - return uid - except Exception: - continue - return 0 - - -async def _owner_password_exists() -> bool: - try: - col = db_handler.get_collection("auth_config").collection - doc = await col.find_one({"_id": "owner_password"}, {"password": 1}) - stored = doc.get("password") if isinstance(doc, dict) else None - return isinstance(stored, dict) and bool(stored) - except Exception: - return False - - -@router.get("/setup/status") -async def setup_status(): - exists = await _owner_password_exists() - owner_uid = _get_primary_owner_id() - return {"ok": True, "configured": exists, "needs_setup": not exists, "owner_id": owner_uid if owner_uid > 0 else None} - - -@router.post("/setup") -async def setup_owner_password( - payload: SetOwnerPasswordRequest, - response: Response, -): - """One-time first-run setup. Creates the hashed owner password and auto-logs in.""" - if await _owner_password_exists(): - raise HTTPException(status_code=403, detail="setup already completed") - - pwd = (payload.password or "").strip() - if len(pwd) < 6: - raise HTTPException(status_code=400, detail="password must be at least 6 characters") - - owner_uid = _get_primary_owner_id() - if owner_uid <= 0: - raise HTTPException(status_code=500, detail="owner is not configured") - - now = time.time() - col = db_handler.get_collection("auth_config").collection - await col.update_one( - {"_id": "owner_password"}, - { - "$set": { - "password": _hash_password(pwd), - "updated_at": now, - "created_at": now, - } - }, - upsert=True, - ) - - token = create_auth_token(user_id="__api__") - _set_auth_cookie(response=response, token=token) - return {"ok": True, "token": token} - - -@router.post("/password") -async def owner_password_login( - payload: OwnerPasswordLoginRequest, - response: Response, - set_cookie: bool = Query(default=False), -): - """Single-owner password login. Verifies the submitted password against the - hashed owner password stored in MongoDB and returns a signed access token.""" - pwd = (payload.password or "").strip() - if not pwd: - raise HTTPException(status_code=400, detail="password is required") - - col = db_handler.get_collection("auth_config").collection - doc = await col.find_one({"_id": "owner_password"}, {"password": 1}) - stored = doc.get("password") if isinstance(doc, dict) else None - if not isinstance(stored, dict) or not stored: - raise HTTPException(status_code=503, detail="owner password is not set") - - if not _verify_password(pwd, stored): - raise HTTPException(status_code=401, detail="invalid credentials") - - token = create_auth_token(user_id="__api__") - if set_cookie: - _set_auth_cookie(response=response, token=token) - return {"ok": True, "token": token} - - -@router.post("/password/change") -async def change_owner_password( - payload: ChangeOwnerPasswordRequest, - user_id: int = Depends(require_user_id), -): - """Change the owner password. Requires a valid auth token.""" - pwd = (payload.password or "").strip() - if len(pwd) < 6: - raise HTTPException(status_code=400, detail="password must be at least 6 characters") - - now = time.time() - col = db_handler.get_collection("auth_config").collection - await col.update_one( - {"_id": "owner_password"}, - { - "$set": { - "password": _hash_password(pwd), - "updated_at": now, - "updated_by": int(user_id), - }, - "$setOnInsert": {"created_at": now}, - }, - upsert=True, - ) - return {"ok": True} - - -@router.post("/cookie") -async def set_auth_cookie( - payload: SetCookieRequest, - response: Response, -): - token = (payload.token or "").strip() - if not token: - raise HTTPException(status_code=400, detail="token is required") - verified = verify_auth_token(token) - uid_raw = verified.get("uid") - if isinstance(uid_raw, str) and uid_raw == "__api__": - uid = 0 - else: - try: - uid = int(uid_raw or 0) - except (ValueError, TypeError): - uid = 0 - - if uid < 0: - raise HTTPException(status_code=401, detail="invalid auth token") - if uid == 0 and uid_raw != "__api__": - raise HTTPException(status_code=401, detail="invalid auth token") - - _set_auth_cookie(response=response, token=token) - return {"ok": True, "user_id": uid} - - -@router.post("/logout") -async def logout(response: Response): - debug = _is_truthy(getattr(Config, "DEBUG", False)) - configured_secure = getattr(Config, "COOKIE_SECURE", None) - configured_samesite = _normalize_samesite(getattr(Config, "COOKIE_SAMESITE", None)) - secure = _is_truthy(configured_secure) if str(configured_secure or "").strip() else (not debug) - samesite = configured_samesite or ("lax" if debug else "none") - response.delete_cookie( - key="auth_token", - path="/", - secure=secure, - samesite=samesite, - ) - return {"ok": True} - - -@router.post("/credentials") -async def set_credentials(payload: SetCredentialsRequest, user_id: int = Depends(require_user_id)): - canon = _canon_username(payload.username) - if not canon: - raise HTTPException(status_code=400, detail="invalid username") - - now = time.time() - col = db_handler.get_collection("users").collection - existing = await col.find_one({"username": canon, "_id": {"$ne": int(user_id)}}, {"_id": 1}) - if existing: - raise HTTPException(status_code=409, detail="username already taken") - - await col.update_one( - {"_id": int(user_id)}, - { - "$set": { - "username": canon, - "password": _hash_password(payload.password), - "username_updated_at": now, - "password_updated_at": now, - "updated_at": now, - } - }, - upsert=True, - ) - return {"ok": True} - - -@router.get("/me") -async def auth_me(user_id: int | None = Depends(get_optional_user_id)): - if user_id is None: - return {"ok": True, "guest": True, "user": None} - - col = db_handler.get_collection("users").collection - doc = await col.find_one( - {"_id": int(user_id)}, - { - "_id": 1, - "username": 1, - "first_name": 1, - "profile_url": 1, - "photo_url": 1, - "telegram": 1, - "integrations": 1, - "created_at": 1, - "updated_at": 1, - }, - ) - if not doc: - return {"ok": True, "user_id": int(user_id)} - doc["_id"] = int(doc["_id"]) - doc["user_id"] = int(doc["_id"]) - doc["userid"] = int(doc["_id"]) - doc["id"] = int(doc["_id"]) - if not isinstance(doc.get("profile_url"), str) or not doc.get("profile_url"): - pu = doc.get("photo_url") - if isinstance(pu, str) and pu: - doc["profile_url"] = pu - - if "integrations" not in doc or not isinstance(doc["integrations"], dict): - doc["integrations"] = {} - - uid = int(user_id) - owner_set = set() - owners = getattr(Config, "OWNER_ID", None) - if isinstance(owners, (int, str)): - owners = [owners] - for v in (owners or []): - try: - owner_set.add(int(v)) - except Exception: - pass - - sudo_set = set() - sudos = getattr(Config, "SUDO_USERS", None) - if isinstance(sudos, (int, str)): - sudos = [sudos] - for v in (sudos or []): - try: - sudo_set.add(int(v)) - except Exception: - pass - - if uid in owner_set: - doc["role"] = "owner" - elif uid in sudo_set: - doc["role"] = "sudo" - doc["is_admin"] = is_admin_user(uid) - info = await access.get_user_access(uid) - doc["status"] = info["status"] - doc["lock_reason"] = info.get("lock_reason") - - return {"ok": True, "user": doc} - - -@router.get("/access/status") -async def access_status(): - """Public: what the signup / login screens need to know about the access policy.""" - policy = await access.get_policy() - return {"ok": True, **access.public_policy(policy)} - - -@router.post("/access/verify-membership") -async def verify_membership(request: Request): - """User-triggered re-check after joining the required chat(s). Works even while restricted.""" - tokens = [t for t in [(request.headers.get("authorization") or "").replace("Bearer ", "").strip(), (request.headers.get("x-auth-token") or "").strip(), (request.cookies.get("auth_token") or "").strip()] if t] - uid: int | None = None - for tok in tokens: - try: - payload = verify_auth_token(tok) - if isinstance(payload.get("uid"), int): - uid = int(payload["uid"]) - break - except Exception: - continue - if not uid: - raise HTTPException(status_code=401, detail="user login required") - return await access.reverify_user(uid) - - -@router.post("/integrations") -async def update_integrations( - payload: IntegrationsUpdateRequest, - user_id: int = Depends(require_user_id), -): - col = db_handler.get_collection("users").collection - now = time.time() - updates: dict = {"updated_at": now} - - if payload.discord is not None: - discord_data = payload.discord.model_dump() - discord_data["updated_at"] = now - updates["integrations.discord"] = discord_data - - if payload.lastfm is not None: - lastfm_data = payload.lastfm.model_dump() - lastfm_data["updated_at"] = now - updates["integrations.lastfm"] = lastfm_data - - await col.update_one({"_id": int(user_id)}, {"$set": updates}, upsert=True) - updated_user = await col.find_one({"_id": int(user_id)}, {"integrations": 1}) - return { - "ok": True, - "integrations": (updated_user or {}).get("integrations", {}), - } - - -@router.post("/fcm-token") -async def update_fcm_token(payload: FCMTokenRequest, user_id: int = Depends(require_user_id)): - col = db_handler.get_collection("users").collection - await col.update_one( - {"_id": int(user_id)}, - {"$set": {"fcm_token": payload.fcm_token, "updated_at": time.time()}}, - upsert=True, - ) - return {"ok": True} - -@router.post("/register") -async def register_account(payload: RegisterRequest): - from stream import bot - if not bot: - raise HTTPException(status_code=500, detail="Bot is not running or ONLY_API is true") - - canon = _canon_username(payload.username) - if not canon: - raise HTTPException(status_code=400, detail="invalid username") - - col = db_handler.get_collection("users").collection - existing_user = await col.find_one({"_id": payload.userid}) - if existing_user and existing_user.get("password"): - raise HTTPException(status_code=409, detail="User already registered") - - existing_username = await col.find_one({"username": canon, "_id": {"$ne": payload.userid}}, {"_id": 1}) - if existing_username: - raise HTTPException(status_code=409, detail="username already taken") - - # Registration policy: closed / invite / allowlist / required chat membership - try: - reg_ctx = await access.assert_can_register(int(payload.userid), payload.invite_code) - except access.AccessDenied as denied: - raise _access_http(denied) - - otp = str(random.randint(100000, 999999)) - now = time.time() - otp_col = db_handler.get_collection("registration_otps").collection - tg_profile = await _get_telegram_profile(int(payload.userid)) - - await otp_col.update_one( - {"_id": payload.userid}, - { - "$set": { - "otp": otp, - "username": canon, - "password": _hash_password(payload.password), - "first_name": tg_profile.get("first_name"), - "telegram_username": tg_profile.get("telegram_username"), - "profile_url": tg_profile.get("profile_url"), - "photo_url": tg_profile.get("photo_url"), - "invite_code": reg_ctx.get("invite_code"), - "created_at": now - } - }, - upsert=True - ) - - try: - await bot.send_message( - chat_id=payload.userid, - text=f"Your StreamX registration OTP is: `{otp}`\n\nThis OTP is valid for registration." - ) - except Exception as e: - import logging - logging.getLogger(__name__).error(f"Failed to send OTP to {payload.userid}: {e}") - raise HTTPException(status_code=500, detail="Failed to send OTP via Telegram bot. Have you started the bot?") - - return { - "ok": True, - "message": "OTP sent", - "user_id": int(payload.userid), - "username": canon, - "first_name": tg_profile.get("first_name"), - "profile_url": tg_profile.get("profile_url"), - "photo_url": tg_profile.get("photo_url"), - } - -@router.post("/validate") -async def validate_account( - payload: ValidateOTPRequest, - response: Response, - set_cookie: bool = Query(default=False), -): - otp_col = db_handler.get_collection("registration_otps").collection - doc = await otp_col.find_one({"_id": payload.userid}) - if not doc: - raise HTTPException(status_code=400, detail="No pending registration found") - - if doc.get("otp") != payload.otp: - raise HTTPException(status_code=401, detail="Invalid OTP") - - # Re-check the policy at completion time (mode may have changed while the OTP was pending) - try: - await access.assert_can_register(int(payload.userid), doc.get("invite_code")) - if doc.get("invite_code"): - await access.consume_invite(str(doc["invite_code"]), int(payload.userid)) - except access.AccessDenied as denied: - raise _access_http(denied) - - now = time.time() - col = db_handler.get_collection("users").collection - tg_profile = await _get_telegram_profile(int(payload.userid)) - first_name = tg_profile.get("first_name") or (doc.get("first_name") if isinstance(doc.get("first_name"), str) else None) - telegram_username = tg_profile.get("telegram_username") or ( - doc.get("telegram_username") if isinstance(doc.get("telegram_username"), str) else None - ) - profile_url = tg_profile.get("profile_url") or (doc.get("profile_url") if isinstance(doc.get("profile_url"), str) else None) - photo_url = tg_profile.get("photo_url") or (doc.get("photo_url") if isinstance(doc.get("photo_url"), str) else None) - if not profile_url: - profile_url = photo_url - if not photo_url: - photo_url = profile_url - - updates = { - "username": doc["username"], - "password": doc["password"], - "first_name": first_name, - "photo_url": photo_url, - "profile_url": profile_url, - "profile_refreshed_at": now, - "username_updated_at": now, - "password_updated_at": now, - "updated_at": now, - "telegram.id": int(payload.userid), - "telegram.username": telegram_username, - "status": "active", - "registered_via": {"mode": (await access.get_policy())["registration_mode"], "invite_code": doc.get("invite_code")}, - } - set_on_insert = { - "created_at": now, - "token_version": 0, - } - - await col.update_one( - {"_id": payload.userid}, - {"$set": updates, "$setOnInsert": set_on_insert}, - upsert=True - ) - - await otp_col.delete_one({"_id": payload.userid}) - - token = await _issue_user_token(int(payload.userid), first_name=first_name, profile_url=profile_url, photo_url=photo_url) - if set_cookie: - _set_auth_cookie(response=response, token=token) - - return { - "ok": True, - "user_id": int(payload.userid), - "username": doc.get("username"), - "token": token, - "first_name": first_name, - "profile_url": profile_url, - "photo_url": photo_url, - } - - -_telegram_jwks_client = jwt.PyJWKClient( - "https://oauth.telegram.org/.well-known/jwks.json", - cache_keys=True, - max_cached_keys=16, -) - - -def _get_telegram_client_id() -> str: - cid = (getattr(Config, "TELEGRAM_OIDC_CLIENT_ID", "") or "").strip() - if cid: - return cid - bot_token = (getattr(Config, "BOT_TOKEN", "") or "").strip() - if ":" in bot_token: - return bot_token.split(":")[0].strip() - return "" - - -async def _get_bot_username() -> str: - try: - from stream import bot - if bot and getattr(bot, "me", None) and getattr(bot.me, "username", None): - return str(bot.me.username) - except Exception: - pass - bot_token = (getattr(Config, "BOT_TOKEN", "") or "").strip() - if bot_token: - try: - async with httpx.AsyncClient(timeout=4.0) as client: - r = await client.get(f"https://api.telegram.org/bot{bot_token}/getMe") - if r.status_code == 200: - data = r.json() - if data.get("ok"): - return data.get("result", {}).get("username", "") - except Exception: - pass - return "" - - -def _get_telegram_redirect_uri(request: Request, frontend_base: str | None = None) -> str: - configured = (getattr(Config, "TELEGRAM_OIDC_REDIRECT_URI", "") or "").strip() - if configured: - return configured - if frontend_base and frontend_base.startswith(("http://", "https://")): - return f"{frontend_base.rstrip('/')}/auth/telegram/callback" - proto = request.headers.get("x-forwarded-proto") or request.url.scheme - host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc - return f"{proto}://{host}/auth/telegram/callback" - - -def _get_frontend_base_url(request: Request, client_redirect: str | None = None) -> str: - if client_redirect and client_redirect.startswith(("http://", "https://")): - parsed = urlparse(client_redirect) - if parsed.scheme and parsed.netloc: - return f"{parsed.scheme}://{parsed.netloc}" - - origin = request.headers.get("origin") or "" - if not origin: - referer = request.headers.get("referer") or "" - if referer: - parsed = urlparse(referer) - if parsed.scheme and parsed.netloc: - origin = f"{parsed.scheme}://{parsed.netloc}" - if not origin: - cors = (getattr(Config, "CORS_ORIGIN", "") or "").strip() - if cors and cors != "*": - origin = cors.split(",")[0].strip() - if not origin: - proto = request.headers.get("x-forwarded-proto") or request.url.scheme - host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc - origin = f"{proto}://{host}" - return origin.rstrip("/") - - -def _generate_pkce_pair() -> tuple[str, str]: - code_verifier = secrets.token_urlsafe(64) - digest = hashlib.sha256(code_verifier.encode("ascii")).digest() - code_challenge = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") - return code_verifier, code_challenge - - -def _verify_telegram_widget_data(data: dict, bot_token: str) -> bool: - received_hash = data.get("hash") - if not received_hash or not bot_token: - return False - try: - auth_date = int(data.get("auth_date", 0)) - except Exception: - return False - if time.time() - auth_date > 86400: - return False - # Telegram Login Widget hash is computed ONLY over Telegram's user data fields: - # auth_date, first_name, id, last_name, photo_url, username. - # Exclude 'hash' as well as custom application fields like 'invite_code' or query params. - telegram_fields = {"auth_date", "first_name", "id", "last_name", "photo_url", "username"} - items = [] - for k in sorted(data.keys()): - if k in telegram_fields and data[k] is not None: - items.append(f"{k}={data[k]}") - data_check_string = "\n".join(items) - secret_key = hashlib.sha256(bot_token.encode("utf-8")).digest() - computed_hash = hmac.new(secret_key, data_check_string.encode("utf-8"), hashlib.sha256).hexdigest() - return hmac.compare_digest(computed_hash, str(received_hash).lower()) - - -@router.get("/telegram/config") -async def telegram_config(): - client_id = _get_telegram_client_id() - bot_username = await _get_bot_username() - enabled = bool(client_id or bot_username) - return { - "ok": True, - "enabled": enabled, - "client_id": client_id, - "bot_username": bot_username, - } - - -@router.get("/telegram/start") -async def telegram_start( - request: Request, - redirect: str = Query(default="/"), - frontend_url: str | None = Query(default=None), - invite_code: str | None = Query(default=None), -): - client_id = _get_telegram_client_id() - if not client_id: - raise HTTPException(status_code=500, detail="Telegram Client ID is not configured") - - state = secrets.token_urlsafe(32) - code_verifier, code_challenge = _generate_pkce_pair() - frontend_base = (frontend_url or "").strip().rstrip("/") or _get_frontend_base_url(request, redirect) - callback_uri = _get_telegram_redirect_uri(request, frontend_base) - dest_path = redirect if redirect.startswith("/") and not redirect.startswith("//") else "/" - clean_invite = (invite_code or "").strip().upper() or None - - now = time.time() - oidc_col = db_handler.get_collection("oidc_sessions").collection - await oidc_col.update_one( - {"_id": state}, - { - "$set": { - "code_verifier": code_verifier, - "redirect_uri": callback_uri, - "frontend_base": frontend_base, - "dest_path": dest_path, - "invite_code": clean_invite, - "created_at": now, - } - }, - upsert=True, - ) - - configured_origin = (getattr(Config, "TELEGRAM_OIDC_ORIGIN", "") or "").strip().rstrip("/") - origin = configured_origin or frontend_base - if origin and not origin.startswith(("http://", "https://")): - origin = f"https://{origin}" - - client_secret = (getattr(Config, "TELEGRAM_OIDC_CLIENT_SECRET", "") or "").strip() - if client_secret: - auth_params = { - "client_id": client_id, - "bot_id": client_id, - "origin": origin, - "redirect_uri": callback_uri, - "response_type": "code", - "scope": "openid profile", - "state": state, - "code_challenge": code_challenge, - "code_challenge_method": "S256", - } - else: - auth_params = { - "bot_id": client_id, - "origin": origin, - "return_to": callback_uri, - "request_access": "write", - } - auth_url = f"https://oauth.telegram.org/auth?{urlencode(auth_params)}" - logger.info(f"[Telegram Auth] /telegram/start: redirecting to {auth_url} (mode={'oidc' if client_secret else 'widget'})") - return RedirectResponse(url=auth_url, status_code=302) - - -@router.get("/telegram/callback") -async def telegram_callback( - request: Request, - response: Response, - code: str | None = Query(default=None), - state: str | None = Query(default=None), - error: str | None = Query(default=None), - error_description: str | None = Query(default=None), -): - query_params = dict(request.query_params) - logger.info(f"Telegram callback invoked: query_keys={list(query_params.keys())}") - - frontend_base = _get_frontend_base_url(request) - - # 1. Error response from provider - if error: - err_msg = error_description or error - logger.warning(f"Telegram callback returned error: {error} ({error_description})") - return RedirectResponse( - url=f"{frontend_base}/login?error={quote_plus(error)}", - status_code=302, - ) - - # 2. Telegram Widget Query flow (if id and hash are present in query params) - if "hash" in query_params and "id" in query_params: - bot_token = (getattr(Config, "BOT_TOKEN", "") or "").strip() - if bot_token and _verify_telegram_widget_data(query_params, bot_token): - try: - tg_user_id = int(query_params["id"]) - except Exception: - return RedirectResponse(url=f"{frontend_base}/login?error=invalid_user_id", status_code=302) - - name = (query_params.get("first_name") or "").strip() - if query_params.get("last_name"): - name = f"{name} {query_params['last_name'].strip()}".strip() - username = (query_params.get("username") or "").strip() or None - photo_url = (query_params.get("photo_url") or "").strip() or None - - now = time.time() - col = db_handler.get_collection("users").collection - updates: dict = { - "user_id": tg_user_id, - "userid": tg_user_id, - "telegram.id": tg_user_id, - "updated_at": now, - } - if name: - updates["first_name"] = name - if username: - updates["telegram.username"] = username - if photo_url: - updates["photo_url"] = photo_url - updates["profile_url"] = photo_url - - existing = await col.find_one({"_id": tg_user_id}, {"username": 1, "first_name": 1, "photo_url": 1, "profile_url": 1}) - if existing: - if not name and existing.get("first_name"): - name = existing.get("first_name") - if not photo_url: - photo_url = existing.get("profile_url") or existing.get("photo_url") - else: - invite_code = query_params.get("invite_code") - try: - reg_ctx = await access.assert_can_register(tg_user_id, invite_code) - if reg_ctx.get("invite_code"): - await access.consume_invite(reg_ctx["invite_code"], tg_user_id) - except access.AccessDenied as denied: - return RedirectResponse( - url=f"{frontend_base}/login?error={quote_plus(str(denied.code))}", - status_code=302, - ) - if username: - canon = _canon_username(username) - if canon: - u_conflict = await col.find_one({"username": canon, "_id": {"$ne": tg_user_id}}, {"_id": 1}) - if not u_conflict: - updates["username"] = canon - updates["username_updated_at"] = now - - set_on_insert = { - "created_at": now, - "token_version": 0, - "status": "active", - "registered_via": {"mode": (await access.get_policy())["registration_mode"]}, - } - await col.update_one({"_id": tg_user_id}, {"$set": updates, "$setOnInsert": set_on_insert}, upsert=True) - - try: - token = await _issue_user_token( - tg_user_id, - first_name=name or username, - profile_url=photo_url, - photo_url=photo_url, - ) - except HTTPException as exc: - d = exc.detail if isinstance(exc.detail, dict) else {"detail": str(exc.detail), "message": str(exc.detail)} - return RedirectResponse(url=f"{frontend_base}/login?error={quote_plus(str(d.get('detail')))}", status_code=302) - - redirect_target = f"{frontend_base}/login?token={token}&redirect=%2F" - safe_first_name = json.dumps(name or username or "Telegram User") - safe_username = json.dumps(username) - safe_photo = json.dumps(photo_url) - safe_target = json.dumps(redirect_target) - popup_html = f""" -""" - html_resp = HTMLResponse(content=popup_html, status_code=200) - _set_auth_cookie(response=html_resp, token=token) - return html_resp - else: - logger.warning("Telegram widget HMAC signature validation failed on callback query") - return RedirectResponse(url=f"{frontend_base}/login?error=invalid_widget_signature", status_code=302) - -async def _resolve_tg_user_id_from_oidc_claims(claims: dict, col) -> int: - """ - Extract the real numeric Telegram user ID from OIDC claims. - Telegram's OpenID Connect may return: - - raw ID in 'id', 'user_id', or 'telegram_id' - - or a 64-bit pairwise pseudonym / gateway hash in 'sub' (> 10^12, e.g. 1613754909805935052). - If the ID is missing or > 10^12, resolve the actual Telegram user ID: - 1. Check existing MongoDB users by preferred_username or canon username. - 2. Query running Pyrogram bot client: await bot.get_users(preferred_username). - 3. Fallback to sub if all else fails. - """ - raw_id = claims.get("id") or claims.get("user_id") or claims.get("telegram_id") - tg_user_id = 0 - if raw_id is not None: - try: - tg_user_id = int(raw_id) - except Exception: - pass - - preferred_username = (claims.get("preferred_username") or "").strip() or None - - # If ID is not a normal Telegram user ID (< 1,000,000,000,000) and we have a username, - # resolve the user's real Telegram numeric ID: - if (tg_user_id <= 0 or tg_user_id > 1_000_000_000_000) and preferred_username: - # 1. Check existing user in MongoDB - try: - canon = _canon_username(preferred_username) - query: dict[str, list[dict]] = { - "$or": [ - {"telegram.username": {"$regex": f"^{re.escape(preferred_username)}$", "$options": "i"}} - ] - } - if canon: - query["$or"].append({"username": canon}) - existing_u = await col.find_one(query, {"_id": 1, "telegram": 1}) - if existing_u and existing_u.get("_id"): - found_id = int(existing_u["_id"]) - if 0 < found_id < 1_000_000_000_000: - logger.info(f"[Telegram Auth] Matched existing user ID {found_id} in DB by username @{preferred_username}") - return found_id - except Exception as e: - logger.warning(f"[Telegram Auth] Error looking up DB user by @{preferred_username}: {e}") - - # 2. Query Telegram Bot (Pyrogram) - try: - from stream import bot - if bot and getattr(bot, "is_connected", False): - u = await bot.get_users(preferred_username) - if u and getattr(u, "id", None): - real_id = int(u.id) - if 0 < real_id < 1_000_000_000_000: - logger.info(f"[Telegram Auth] Resolved real Telegram user ID {real_id} via bot.get_users for @{preferred_username}") - return real_id - except Exception as err: - logger.warning(f"[Telegram Auth] bot.get_users failed for @{preferred_username}: {err}") - - if tg_user_id > 0: - return tg_user_id - - sub = claims.get("sub") - try: - sub_id = int(sub) - if sub_id > 0: - return sub_id - except Exception: - pass - - raise ValueError("Cannot extract valid Telegram user ID from claims") - - - # 3. Standard OIDC Authorization Code Flow - if code and state: - oidc_col = db_handler.get_collection("oidc_sessions").collection - session = await oidc_col.find_one({"_id": state}) - if not session: - logger.warning(f"Telegram OIDC session not found for state: {state}") - return RedirectResponse(url=f"{frontend_base}/login?error=invalid_state", status_code=302) - - frontend_base = session.get("frontend_base") or frontend_base - dest_path = session.get("dest_path") or "/" - await oidc_col.delete_one({"_id": state}) - - if time.time() - float(session.get("created_at", 0)) > 600: - return RedirectResponse(url=f"{frontend_base}/login?error=expired_session", status_code=302) - - client_id = _get_telegram_client_id() - client_secret = (getattr(Config, "TELEGRAM_OIDC_CLIENT_SECRET", "") or "").strip() - - token_data = { - "grant_type": "authorization_code", - "code": code, - "redirect_uri": session.get("redirect_uri") or _get_telegram_redirect_uri(request), - "client_id": client_id, - "code_verifier": session["code_verifier"], - } - if client_secret: - token_data["client_secret"] = client_secret - - try: - async with httpx.AsyncClient(timeout=15.0) as http_client: - token_resp = await http_client.post( - "https://oauth.telegram.org/token", - data=token_data, - auth=(str(client_id), client_secret) if client_secret else None, - ) - if token_resp.status_code != 200: - logger.error(f"[Telegram Auth] Token exchange HTTP failed: {token_resp.status_code} {token_resp.text}") - return RedirectResponse(url=f"{frontend_base}/login?error=token_exchange_failed", status_code=302) - resp_json = token_resp.json() - if "error" in resp_json: - err_code = resp_json.get("error", "token_exchange_failed") - err_desc = resp_json.get("error_description") or err_code - logger.error(f"[Telegram Auth] Token exchange returned error: {err_code} ({err_desc})") - return RedirectResponse( - url=f"{frontend_base}/login?error={quote_plus(err_code)}", - status_code=302, - ) - except Exception as e: - logger.error(f"[Telegram Auth] Error communicating with Telegram token endpoint: {e}") - return RedirectResponse(url=f"{frontend_base}/login?error=provider_unavailable", status_code=302) - - id_token = resp_json.get("id_token") - if not id_token: - logger.error(f"[Telegram Auth] Token response missing id_token: {resp_json}") - return RedirectResponse(url=f"{frontend_base}/login?error=missing_id_token", status_code=302) - logger.info("[Telegram Auth] Token exchanged successfully. Validating id_token claims...") - - try: - signing_key = _telegram_jwks_client.get_signing_key_from_jwt(id_token) - claims = jwt.decode( - id_token, - signing_key.key, - algorithms=["RS256", "ES256", "EdDSA", "ES256K"], - issuer="https://oauth.telegram.org", - audience=str(client_id), - options={"verify_exp": True}, - ) - except Exception as e: - logger.error(f"Telegram ID token validation failed: {e}") - return RedirectResponse(url=f"{frontend_base}/login?error=invalid_id_token", status_code=302) - - col = db_handler.get_collection("users").collection - logger.info(f"[Telegram Auth] OIDC callback received decoded claims: {claims}") - try: - tg_user_id = await _resolve_tg_user_id_from_oidc_claims(claims, col) - except Exception: - logger.error(f"Telegram claims sub is invalid: {claims}") - return RedirectResponse(url=f"{frontend_base}/login?error=invalid_user_id", status_code=302) - - name = (claims.get("name") or "").strip() - preferred_username = (claims.get("preferred_username") or "").strip() or None - picture = (claims.get("picture") or "").strip() or None - - now = time.time() - col = db_handler.get_collection("users").collection - updates = { - "user_id": tg_user_id, - "userid": tg_user_id, - "telegram.id": tg_user_id, - "updated_at": now, - } - if name: - updates["first_name"] = name - if preferred_username: - updates["telegram.username"] = preferred_username - if picture: - updates["photo_url"] = picture - updates["profile_url"] = picture - - existing = await col.find_one({"_id": tg_user_id}, {"username": 1, "first_name": 1, "photo_url": 1, "profile_url": 1}) - if existing: - if not name and existing.get("first_name"): - name = existing.get("first_name") - if not picture: - picture = existing.get("profile_url") or existing.get("photo_url") - else: - invite_code = (session_doc.get("invite_code") if session_doc else None) or request.query_params.get("invite_code") - try: - reg_ctx = await access.assert_can_register(tg_user_id, invite_code) - if reg_ctx.get("invite_code"): - await access.consume_invite(reg_ctx["invite_code"], tg_user_id) - except access.AccessDenied as denied: - return RedirectResponse( - url=f"{frontend_base}/login?error={quote_plus(str(denied.code))}", - status_code=302, - ) - if preferred_username: - canon = _canon_username(preferred_username) - if canon: - u_conflict = await col.find_one({"username": canon, "_id": {"$ne": tg_user_id}}, {"_id": 1}) - if not u_conflict: - updates["username"] = canon - updates["username_updated_at"] = now - - set_on_insert = { - "created_at": now, - "token_version": 0, - "status": "active", - "registered_via": {"mode": (await access.get_policy())["registration_mode"]}, - } - await col.update_one({"_id": tg_user_id}, {"$set": updates, "$setOnInsert": set_on_insert}, upsert=True) - - try: - token = await _issue_user_token( - tg_user_id, - first_name=name or preferred_username, - profile_url=picture, - photo_url=picture, - ) - except HTTPException as exc: - d = exc.detail if isinstance(exc.detail, dict) else {"detail": str(exc.detail), "message": str(exc.detail)} - return RedirectResponse(url=f"{frontend_base}/login?error={quote_plus(str(d.get('detail')))}", status_code=302) - - dest_encoded = quote_plus(dest_path) - redirect_target = f"{frontend_base}/login?token={token}&redirect={dest_encoded}" - safe_first_name = json.dumps(name or preferred_username or "Telegram User") - safe_username = json.dumps(preferred_username) - safe_photo = json.dumps(picture) - safe_target = json.dumps(redirect_target) - popup_html = f""" -""" - html_resp = HTMLResponse(content=popup_html, status_code=200) - _set_auth_cookie(response=html_resp, token=token) - return html_resp - - # 4. Hash fragment / tgAuthResult HTML Fallback (when Telegram redirects with URL fragment) - html_content = """ - - - - - Verifying Telegram Login... - - - -
-
-

Verifying Telegram login

-

Connecting your account, please wait...

-
- - -""" - return HTMLResponse(content=html_content, status_code=200) - - -@router.post("/telegram/widget") -async def telegram_widget_login( - payload: TelegramWidgetLoginRequest, - response: Response, - set_cookie: bool = Query(default=True), -): - logger.info(f"[Telegram Auth] /telegram/widget: validating widget payload for user_id={payload.id}, username={payload.username}") - bot_token = (getattr(Config, "BOT_TOKEN", "") or "").strip() - if not bot_token: - logger.error("[Telegram Auth] /telegram/widget failed: BOT_TOKEN is not configured") - raise HTTPException(status_code=500, detail="BOT_TOKEN is not configured") - - widget_dict = payload.model_dump() - widget_dict.pop("invite_code", None) - if not _verify_telegram_widget_data(widget_dict, bot_token): - logger.warning(f"[Telegram Auth] /telegram/widget: signature verification failed for user_id={payload.id}") - raise HTTPException(status_code=401, detail="invalid telegram widget signature") - - tg_user_id = int(payload.id) - if tg_user_id <= 0: - raise HTTPException(status_code=400, detail="invalid user id") - - name = (payload.first_name or "").strip() - if payload.last_name: - name = f"{name} {payload.last_name.strip()}".strip() - username = (payload.username or "").strip() or None - photo_url = (payload.photo_url or "").strip() or None - - now = time.time() - col = db_handler.get_collection("users").collection - updates: dict = { - "user_id": tg_user_id, - "userid": tg_user_id, - "telegram.id": tg_user_id, - "updated_at": now, - } - if name: - updates["first_name"] = name - if username: - updates["telegram.username"] = username - if photo_url: - updates["photo_url"] = photo_url - updates["profile_url"] = photo_url - - existing = await col.find_one({"_id": tg_user_id}, {"username": 1, "first_name": 1, "photo_url": 1, "profile_url": 1}) - if existing: - if not name and existing.get("first_name"): - name = existing.get("first_name") - if not photo_url: - photo_url = existing.get("profile_url") or existing.get("photo_url") - else: - invite_code = getattr(payload, "invite_code", None) - try: - reg_ctx = await access.assert_can_register(tg_user_id, invite_code) - if reg_ctx.get("invite_code"): - await access.consume_invite(reg_ctx["invite_code"], tg_user_id) - except access.AccessDenied as denied: - raise _access_http(denied) - if username: - canon = _canon_username(username) - if canon: - u_conflict = await col.find_one({"username": canon, "_id": {"$ne": tg_user_id}}, {"_id": 1}) - if not u_conflict: - updates["username"] = canon - updates["username_updated_at"] = now - - set_on_insert = { - "created_at": now, - "token_version": 0, - "status": "active", - "registered_via": {"mode": (await access.get_policy())["registration_mode"], "invite_code": getattr(payload, "invite_code", None)}, - } - await col.update_one({"_id": tg_user_id}, {"$set": updates, "$setOnInsert": set_on_insert}, upsert=True) - - token = await _issue_user_token( - tg_user_id, - first_name=name or username, - profile_url=photo_url, - photo_url=photo_url, - ) - if set_cookie: - _set_auth_cookie(response=response, token=token) - - logger.info(f"[Telegram Auth] /telegram/widget: user {tg_user_id} ({name or username}) successfully authenticated") - return { - "ok": True, - "user_id": tg_user_id, - "token": token, - "first_name": name, - "username": existing.get("username") if existing else (updates.get("username") or username), - "profile_url": photo_url, - "photo_url": photo_url, - } - - -@router.post("/telegram/validate-token") -async def telegram_validate_token( - payload: TelegramTokenLoginRequest, - response: Response, - set_cookie: bool = Query(default=True), -): - id_token = (payload.id_token or "").strip() - if not id_token: - raise HTTPException(status_code=400, detail="id_token is required") - - logger.info("[Telegram Auth] /telegram/validate-token: validating received ID token against JWKS...") - client_id = _get_telegram_client_id() - try: - signing_key = _telegram_jwks_client.get_signing_key_from_jwt(id_token) - claims = jwt.decode( - id_token, - signing_key.key, - algorithms=["RS256", "ES256", "EdDSA", "ES256K"], - issuer="https://oauth.telegram.org", - audience=str(client_id) if client_id else None, - options={"verify_exp": True, "verify_aud": bool(client_id)}, - ) - except Exception as e: - logger.error(f"[Telegram Auth] Telegram ID token validation failed: {e}") - raise HTTPException(status_code=401, detail=f"invalid telegram ID token: {e}") - - col = db_handler.get_collection("users").collection - logger.info(f"[Telegram Auth] /telegram/validate-token: decoded claims: {claims}") - try: - tg_user_id = await _resolve_tg_user_id_from_oidc_claims(claims, col) - except Exception: - raise HTTPException(status_code=400, detail="invalid user identifier in token") - - name = (claims.get("name") or "").strip() - preferred_username = (claims.get("preferred_username") or "").strip() or None - picture = (claims.get("picture") or "").strip() or None - - now = time.time() - updates: dict = { - "user_id": tg_user_id, - "userid": tg_user_id, - "telegram.id": tg_user_id, - "updated_at": now, - } - if name: - updates["first_name"] = name - if preferred_username: - updates["telegram.username"] = preferred_username - if picture: - updates["photo_url"] = picture - updates["profile_url"] = picture - - existing = await col.find_one({"_id": tg_user_id}, {"username": 1, "first_name": 1, "photo_url": 1, "profile_url": 1}) - if existing: - if not name and existing.get("first_name"): - name = existing.get("first_name") - if not picture: - picture = existing.get("profile_url") or existing.get("photo_url") - else: - invite_code = getattr(payload, "invite_code", None) - try: - reg_ctx = await access.assert_can_register(tg_user_id, invite_code) - if reg_ctx.get("invite_code"): - await access.consume_invite(reg_ctx["invite_code"], tg_user_id) - except access.AccessDenied as denied: - raise _access_http(denied) - if preferred_username: - canon = _canon_username(preferred_username) - if canon: - u_conflict = await col.find_one({"username": canon, "_id": {"$ne": tg_user_id}}, {"_id": 1}) - if not u_conflict: - updates["username"] = canon - updates["username_updated_at"] = now - - set_on_insert = { - "created_at": now, - "token_version": 0, - "status": "active", - "registered_via": {"mode": (await access.get_policy())["registration_mode"], "invite_code": getattr(payload, "invite_code", None)}, - } - await col.update_one({"_id": tg_user_id}, {"$set": updates, "$setOnInsert": set_on_insert}, upsert=True) - - token = await _issue_user_token( - tg_user_id, - first_name=name or preferred_username, - profile_url=picture, - photo_url=picture, - ) - if set_cookie: - _set_auth_cookie(response=response, token=token) - - logger.info(f"[Telegram Auth] /telegram/validate-token: user {tg_user_id} ({name or preferred_username}) successfully authenticated") - return { - "ok": True, - "user_id": tg_user_id, - "token": token, - "first_name": name, - "username": existing.get("username") if existing else (updates.get("username") or preferred_username), - "profile_url": picture, - "photo_url": picture, - } - - -async def _authenticate_or_register_tg_user( - tg_user_id: int, - name: str | None = None, - username: str | None = None, - photo_url: str | None = None, - invite_code: str | None = None, -) -> tuple[dict, str]: - """Helper to find or register a user by telegram_id and issue an auth token.""" - now = time.time() - col = db_handler.get_collection("users").collection - updates: dict = { - "user_id": tg_user_id, - "userid": tg_user_id, - "telegram.id": tg_user_id, - "updated_at": now, - } - if name: - updates["first_name"] = name - if username: - updates["telegram.username"] = username - if photo_url: - updates["photo_url"] = photo_url - updates["profile_url"] = photo_url - - existing = await col.find_one({"_id": tg_user_id}, {"username": 1, "first_name": 1, "photo_url": 1, "profile_url": 1}) - if existing: - if not name and existing.get("first_name"): - name = existing.get("first_name") - if not photo_url: - photo_url = existing.get("profile_url") or existing.get("photo_url") - else: - try: - reg_ctx = await access.assert_can_register(tg_user_id, invite_code) - if reg_ctx.get("invite_code"): - await access.consume_invite(reg_ctx["invite_code"], tg_user_id) - except access.AccessDenied as denied: - raise _access_http(denied) - if username: - canon = _canon_username(username) - if canon: - u_conflict = await col.find_one({"username": canon, "_id": {"$ne": tg_user_id}}, {"_id": 1}) - if not u_conflict: - updates["username"] = canon - updates["username_updated_at"] = now - - set_on_insert = { - "created_at": now, - "token_version": 0, - "status": "active", - "registered_via": {"mode": (await access.get_policy())["registration_mode"], "invite_code": invite_code}, - } - await col.update_one({"_id": tg_user_id}, {"$set": updates, "$setOnInsert": set_on_insert}, upsert=True) - - token = await _issue_user_token( - tg_user_id, - first_name=name or username, - profile_url=photo_url, - photo_url=photo_url, - ) - - user_info = { - "user_id": tg_user_id, - "first_name": name, - "username": existing.get("username") if existing else (updates.get("username") or username), - "profile_url": photo_url, - "photo_url": photo_url, - } - return user_info, token - - -@router.post("/telegram/bot-session") -async def create_telegram_bot_session( - payload: TelegramBotSessionRequest | None = None, -): - """Create a temporary session for direct Telegram app / bot login.""" - session_id = f"auth_{secrets.token_urlsafe(16)}" - bot_username = await _get_bot_username() - now = time.time() - col = db_handler.get_collection("bot_auth_sessions").collection - await col.update_one( - {"_id": session_id}, - { - "$set": { - "_id": session_id, - "status": "pending", - "invite_code": (payload.invite_code.strip().upper() if payload and payload.invite_code else None), - "created_at": now, - "expires_at": now + 600, - } - }, - upsert=True, - ) - tg_url = f"tg://resolve?domain={bot_username}&start={session_id}" if bot_username else "" - web_url = f"https://t.me/{bot_username}?start={session_id}" if bot_username else "" - return { - "ok": True, - "session_id": session_id, - "bot_username": bot_username, - "tg_url": tg_url, - "web_url": web_url, - } - - -@router.get("/telegram/bot-session/status") -async def check_telegram_bot_session_status( - session_id: str = Query(...), - response: Response = None, - set_cookie: bool = Query(default=True), -): - """Check if the user has confirmed authorization inside the Telegram bot.""" - col = db_handler.get_collection("bot_auth_sessions").collection - session = await col.find_one({"_id": session_id}) - if not session: - return {"ok": False, "status": "not_found"} - - now = time.time() - if session.get("expires_at", 0) < now and session.get("status") == "pending": - return {"ok": False, "status": "expired"} - - if session.get("status") == "confirmed": - token = session.get("token") - if set_cookie and response and token: - _set_auth_cookie(response=response, token=token) - return { - "ok": True, - "status": "confirmed", - "token": token, - "user_id": session.get("user_id"), - "first_name": session.get("first_name"), - "username": session.get("username"), - "photo_url": session.get("photo_url"), - "profile_url": session.get("profile_url"), - } - - return {"ok": True, "status": "pending"} - diff --git a/Api/routers/browse.py b/Api/routers/browse.py deleted file mode 100644 index 88b71a5..0000000 --- a/Api/routers/browse.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Optional -from fastapi import APIRouter, Depends, HTTPException - -from Api.schemas.browse import BrowseResponse -from Api.services.track_service import browse_tracks -from Api.utils.auth import get_optional_user_id - -router = APIRouter() - -@router.get("/browse", response_model=BrowseResponse) -async def browse( - channel_id: Optional[int] = None, - topic_name: Optional[str] = None, - topic_id: Optional[int] = None, - page: int = 1, - user_id: Optional[int] = Depends(get_optional_user_id), -): - if page < 1: - raise HTTPException(status_code=400, detail="page must be >= 1") - return await browse_tracks( - channel_id=channel_id, - page=page, - per_page=20, - user_id=user_id, - topic_name=topic_name, - topic_id=topic_id, - ) diff --git a/Api/routers/cover.py b/Api/routers/cover.py deleted file mode 100644 index 8e073cf..0000000 --- a/Api/routers/cover.py +++ /dev/null @@ -1,23 +0,0 @@ -import os -from fastapi import APIRouter, HTTPException -from fastapi.responses import FileResponse - -router = APIRouter(prefix="/covers", tags=["covers"]) - - -def _gen_covers_dir() -> str: - root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) - out_dir = os.path.join(root, "GenCovers") - os.makedirs(out_dir, exist_ok=True) - return out_dir - - -@router.get("/file/{file_key}.png") -async def get_cover_file(file_key: str): - fk = (file_key or "").strip() - if not fk: - raise HTTPException(status_code=400, detail="file_key is required") - path = os.path.join(_gen_covers_dir(), f"{fk}.png") - if not os.path.isfile(path): - raise HTTPException(status_code=404, detail="cover not found") - return FileResponse(path, media_type="image/png") diff --git a/Api/routers/discord.py b/Api/routers/discord.py deleted file mode 100644 index 1cc679a..0000000 --- a/Api/routers/discord.py +++ /dev/null @@ -1,196 +0,0 @@ -""" -Discord Remote Auth (QR Code Login) router. - -Provides: -- WebSocket /ws/discord/remote-auth: - Real-time bidirectional event stream for the Remote Auth lifecycle. -- REST /api/discord/remote-auth/start: - Start a background session and receive QR url & fingerprint. -- REST /api/discord/remote-auth/status/{session_id}: - Poll the session state for completion or scanned events. -- REST /api/discord/remote-auth/cancel/{session_id}: - Cancel an active session. -""" -from __future__ import annotations - -import asyncio -import uuid -import time -from typing import Dict, Optional, Any - -from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException -from pydantic import BaseModel - -import httpx -from Api.services.discord_remote_auth import DiscordRemoteAuthSession -from stream.helpers.logger import LOGGER - -router = APIRouter(tags=["discord"]) - -# In-memory store for active sessions (auto-cleaned after TTL) -_sessions: Dict[str, DiscordRemoteAuthSession] = {} -_session_tasks: Dict[str, asyncio.Task] = {} -SESSION_TTL_SEC = 240 - - -def _cleanup_old_sessions() -> None: - now = time.time() - stale = [sid for sid, s in _sessions.items() if now - s.created_at > SESSION_TTL_SEC] - for sid in stale: - session = _sessions.pop(sid, None) - if session: - session.cancel() - task = _session_tasks.pop(sid, None) - if task and not task.done(): - task.cancel() - - -@router.websocket("/ws/discord/remote-auth") -async def discord_remote_auth_ws(websocket: WebSocket) -> None: - """Stream Discord QR remote-auth events directly over WebSocket.""" - await websocket.accept() - session_id = str(uuid.uuid4()) - session = DiscordRemoteAuthSession(session_id) - _sessions[session_id] = session - _cleanup_old_sessions() - - LOGGER(__name__).info(f"[DiscordRA WS] New connection established: {session_id}") - - try: - async for event in session.stream_events(): - await websocket.send_json(event) - if event.get("type") in ("success", "error", "cancelled"): - break - except WebSocketDisconnect: - LOGGER(__name__).info(f"[DiscordRA WS] Client disconnected early: {session_id}") - except Exception as exc: - LOGGER(__name__).error(f"[DiscordRA WS] Unexpected error for {session_id}: {exc}") - finally: - session.cancel() - _sessions.pop(session_id, None) - - -class StartRemoteAuthResponse(BaseModel): - session_id: str - status: str - url: Optional[str] = None - fingerprint: Optional[str] = None - - -class RemoteAuthStatusResponse(BaseModel): - session_id: str - status: str - url: Optional[str] = None - fingerprint: Optional[str] = None - user: Optional[dict] = None - token: Optional[str] = None - error: Optional[str] = None - - -async def _run_session_background(session: DiscordRemoteAuthSession) -> None: - try: - async for _ in session.stream_events(): - if session.status in ("success", "error", "cancelled"): - break - except Exception as exc: - session.error = str(exc) - session.status = "error" - - -@router.post("/api/discord/remote-auth/start", response_model=StartRemoteAuthResponse) -async def start_remote_auth() -> StartRemoteAuthResponse: - """Initialize a Discord Remote Auth session and wait up to 5s for the initial QR code.""" - _cleanup_old_sessions() - session_id = str(uuid.uuid4()) - session = DiscordRemoteAuthSession(session_id) - _sessions[session_id] = session - - # Run the stream in background - task = asyncio.create_task(_run_session_background(session)) - _session_tasks[session_id] = task - - # Wait for the fingerprint to appear (typically within 1-2 seconds) - for _ in range(50): - if session.fingerprint: - break - if session.status in ("error", "cancelled"): - break - await asyncio.sleep(0.1) - - if session.error: - raise HTTPException(status_code=502, detail=session.error) - - return StartRemoteAuthResponse( - session_id=session_id, - status=session.status, - url=session.qr_url, - fingerprint=session.fingerprint, - ) - - -@router.get("/api/discord/remote-auth/status/{session_id}", response_model=RemoteAuthStatusResponse) -async def get_remote_auth_status(session_id: str) -> RemoteAuthStatusResponse: - """Check the status of a running Remote Auth session.""" - session = _sessions.get(session_id) - if not session: - raise HTTPException(status_code=404, detail="Session not found or expired") - - return RemoteAuthStatusResponse( - session_id=session.session_id, - status=session.status, - url=session.qr_url, - fingerprint=session.fingerprint, - user=session.user_info, - token=session.token, - error=session.error, - ) - - -@router.post("/api/discord/remote-auth/cancel/{session_id}") -async def cancel_remote_auth(session_id: str) -> dict: - """Cancel an active Remote Auth session.""" - session = _sessions.pop(session_id, None) - if session: - session.cancel() - task = _session_tasks.pop(session_id, None) - if task and not task.done(): - task.cancel() - return {"status": "cancelled", "session_id": session_id} - - -class ExternalAssetsRequest(BaseModel): - urls: list[str] - application_id: Optional[str] = "1547543416143876167" - token: str - - -@router.post("/api/discord/external-assets") -async def resolve_external_assets(req: ExternalAssetsRequest) -> list[dict]: - """Proxy external assets resolution to Discord's API with user's token. - Returns list of {'url': ..., 'external_asset_path': ...}. - """ - app_id = req.application_id or "1547543416143876167" - token = req.token.strip() - if not token or not req.urls: - return [] - - discord_url = f"https://discord.com/api/v9/applications/{app_id}/external-assets" - headers = { - "Authorization": token, - "Content-Type": "application/json", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", - } - try: - async with httpx.AsyncClient(timeout=8.0) as client: - resp = await client.post(discord_url, headers=headers, json={"urls": req.urls}) - if 200 <= resp.status_code < 300: - data = resp.json() - if isinstance(data, list): - return data - LOGGER(__name__).warning( - f"[Discord external-assets] Discord API returned {resp.status_code}: {resp.text[:200]}" - ) - except Exception as exc: - LOGGER(__name__).error(f"[Discord external-assets] Error proxying: {exc}") - return [] - diff --git a/Api/routers/favourites.py b/Api/routers/favourites.py deleted file mode 100644 index f2a27ea..0000000 --- a/Api/routers/favourites.py +++ /dev/null @@ -1,282 +0,0 @@ -import time -from typing import Optional -from pydantic import BaseModel -from pymongo import UpdateOne - -from fastapi import APIRouter, Depends, HTTPException, Query - -from Api.schemas.favourites import ArtistFavouriteCreate, FavouriteCreate, FavouriteIdsResponse, FavouritesResponse, FavouriteItem -from Api.schemas.browse import BrowseResponse -from Api.services.track_service import get_track_by_id, get_tracks_by_ids, user_top_played_tracks -from Api.utils.auth import get_optional_user_id, require_user_id -from stream.database.MongoDb import db_handler - - -router = APIRouter(prefix="/me", tags=["me"]) - - -@router.post("/favourites") -async def add_favourite(payload: FavouriteCreate, user_id: int = Depends(require_user_id)): - track_id = (payload.track_id or "").strip() - if not track_id: - raise HTTPException(status_code=400, detail="track_id is required") - - track = await get_track_by_id(track_id) - if not track: - raise HTTPException(status_code=404, detail="track not found") - - col = db_handler.get_collection("user_favourites").collection - res = await col.update_one( - {"user_id": int(user_id), "track_id": track_id}, - { - "$setOnInsert": {"created_at": time.time()}, - "$set": {"user_id": int(user_id), "track_id": track_id, "updated_at": time.time()}, - }, - upsert=True, - ) - return {"ok": True, "already_exists": res.upserted_id is None} - - -@router.delete("/favourites/{track_id}") -async def remove_favourite(track_id: str, user_id: int = Depends(require_user_id)): - track_id = (track_id or "").strip() - if not track_id: - raise HTTPException(status_code=400, detail="track_id is required") - - col = db_handler.get_collection("user_favourites").collection - res = await col.delete_one({"user_id": int(user_id), "track_id": track_id}) - return {"ok": True, "deleted": bool(getattr(res, "deleted_count", 0))} - - -@router.get("/favourites", response_model=FavouritesResponse) -async def list_favourites( - user_id: int = Depends(require_user_id), - page: int = Query(default=1, ge=1), - limit: int = Query(default=20, ge=1, le=100), -): - page = int(page) - per_page = int(limit) - skip = (page - 1) * per_page - - col = db_handler.get_collection("user_favourites").collection - query = {"user_id": int(user_id)} - total = await col.count_documents(query) - - cursor = ( - col.find(query, {"_id": 0, "track_id": 1, "created_at": 1, "updated_at": 1}) - .sort([("created_at", -1)]) - .skip(skip) - .limit(per_page) - ) - - fav_rows: list[dict] = [] - last_ts: float | None = None - async for doc in cursor: - tid = (doc.get("track_id") or "").strip() - if not tid: - continue - fav_rows.append({"track_id": tid, "created_at": doc.get("created_at")}) - ts = doc.get("updated_at") - if ts is None: - ts = doc.get("created_at") - if isinstance(ts, (int, float)): - if last_ts is None or float(ts) > float(last_ts): - last_ts = float(ts) - - tracks = await get_tracks_by_ids([r["track_id"] for r in fav_rows], user_id=user_id) - by_id = {str(t.get("_id")): t for t in tracks if t.get("_id")} - items: list[FavouriteItem] = [] - for r in fav_rows: - t = by_id.get(r["track_id"]) - if not t: - continue - items.append(FavouriteItem(track=t, created_at=r.get("created_at"))) - return FavouritesResponse(page=page, per_page=per_page, total=total, items=items, last_updated_at=last_ts) - - -@router.get("/favourites/ids", response_model=FavouriteIdsResponse) -async def list_favourite_ids( - user_id: Optional[int] = Depends(get_optional_user_id), - page: int = Query(default=1, ge=1), - limit: int = Query(default=200, ge=1, le=1000), -): - if user_id is None: - return FavouriteIdsResponse(page=page, per_page=limit, total=0, ids=[], exists=False) - page = int(page) - per_page = int(limit) - skip = (page - 1) * per_page - - col = db_handler.get_collection("user_favourites").collection - query = {"user_id": user_id} - total = await col.count_documents(query) - - cursor = ( - col.find(query, {"_id": 0, "track_id": 1, "updated_at": 1, "created_at": 1}) - .sort([("created_at", -1)]) - .skip(skip) - .limit(per_page) - ) - - ids: list[str] = [] - last_ts: float | None = None - async for doc in cursor: - tid = (doc.get("track_id") or "").strip() - if tid: - ids.append(tid) - ts = doc.get("updated_at") - if ts is None: - ts = doc.get("created_at") - if isinstance(ts, (int, float)): - if last_ts is None or float(ts) > float(last_ts): - last_ts = float(ts) - - return FavouriteIdsResponse( - page=page, - per_page=per_page, - total=total, - ids=ids, - exists=bool(total > 0), - last_updated_at=last_ts, - ) - - -@router.get("/top-played", response_model=BrowseResponse) -async def my_top_played( - user_id: int = Depends(require_user_id), - page: int = Query(default=1, ge=1), - limit: int = Query(default=50, ge=1, le=100), -): - return await user_top_played_tracks(user_id=int(user_id), page=int(page), per_page=int(limit)) - - -@router.post("/artists/favourites") -@router.post("/artist/favorite") -@router.post("/artists/favorite") -async def add_artist_favourite( - payload: Optional[ArtistFavouriteCreate] = None, - artist_id: Optional[str] = Query(default=None), - id: Optional[str] = Query(default=None), - user_id: int = Depends(require_user_id), -): - aid = None - if payload: - aid = payload.artist_id or payload.id or payload.artistId - if not aid: - aid = artist_id or id - aid = (aid or "").strip() - if not aid: - raise HTTPException(status_code=400, detail="artist_id is required") - - col = db_handler.get_collection("user_favourite_artists").collection - res = await col.update_one( - {"user_id": int(user_id), "artist_id": aid}, - { - "$setOnInsert": {"created_at": time.time()}, - "$set": {"user_id": int(user_id), "artist_id": aid, "updated_at": time.time()}, - }, - upsert=True, - ) - if res.upserted_id is not None: - try: - await db_handler.get_collection("artists").collection.update_one( - {"_id": aid}, - {"$inc": {"followers": 1}} - ) - except Exception: - pass - return {"ok": True, "already_exists": res.upserted_id is None} - - -@router.delete("/artists/favourites/{artist_id}") -@router.delete("/artist/favorite/{artist_id}") -@router.delete("/artists/favorite/{artist_id}") -async def remove_artist_favourite(artist_id: str, user_id: int = Depends(require_user_id)): - aid = (artist_id or "").strip() - if not aid: - raise HTTPException(status_code=400, detail="artist_id is required") - - col = db_handler.get_collection("user_favourite_artists").collection - res = await col.delete_one({"user_id": int(user_id), "artist_id": aid}) - if getattr(res, "deleted_count", 0) > 0: - try: - await db_handler.get_collection("artists").collection.update_one( - {"_id": aid}, - {"$inc": {"followers": -1}} - ) - except Exception: - pass - return {"ok": True, "deleted": bool(getattr(res, "deleted_count", 0))} - - -@router.get("/artists/favourites/ids") -async def list_favourite_artist_ids(user_id: Optional[int] = Depends(get_optional_user_id)): - if user_id is None: - return {"ok": True, "ids": []} - - col = db_handler.get_collection("user_favourite_artists").collection - cursor = col.find({"user_id": int(user_id)}, {"_id": 0, "artist_id": 1}) - ids = [] - async for doc in cursor: - aid = doc.get("artist_id") - if aid: - ids.append(aid) - return {"ok": True, "ids": ids} - - -class ListeningEventItem(BaseModel): - id: str - track_id: str - played_at: float - started_at: float - played_ms: float - duration_ms: float - completed: bool - skipped: bool - source: str = "server" - session_id: Optional[str] = None - - -class ListeningEventsPayload(BaseModel): - events: list[ListeningEventItem] - - -@router.post("/listening-events") -async def record_listening_events( - payload: ListeningEventsPayload, - user_id: Optional[int] = Depends(get_optional_user_id), -): - if not payload.events: - return {"ok": True, "count": 0} - - col = db_handler.get_collection("listening_events").collection - ops = [] - for ev in payload.events: - eid = (ev.id or "").strip() - if not eid: - continue - ops.append( - UpdateOne( - {"event_id": eid, "user_id": int(user_id) if user_id else None}, - { - "$setOnInsert": {"created_at": time.time()}, - "$set": { - "event_id": eid, - "user_id": int(user_id) if user_id else None, - "track_id": ev.track_id, - "played_at": ev.played_at, - "started_at": ev.started_at, - "played_ms": ev.played_ms, - "duration_ms": ev.duration_ms, - "completed": ev.completed, - "skipped": ev.skipped, - "source": ev.source, - "session_id": ev.session_id, - }, - }, - upsert=True, - ) - ) - if ops: - await col.bulk_write(ops, ordered=False) - return {"ok": True, "count": len(ops)} - diff --git a/Api/routers/friends.py b/Api/routers/friends.py deleted file mode 100644 index 3dd1156..0000000 --- a/Api/routers/friends.py +++ /dev/null @@ -1,368 +0,0 @@ -import time -from typing import Optional -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel -from bson import ObjectId -from Api.schemas.friends import FriendRequestPayload, AcceptRequestPayload, InviteJamPayload, SettingsPayload, FcmTokenPayload -from Api.utils.auth import get_optional_user_id, require_user_id -from Api.routers.presence import manager, broadcast_listening_to_friends -from stream.database.MongoDb import db_handler - -router = APIRouter(prefix="/friends", tags=["friends"]) - -@router.post("/request") -async def send_friend_request(payload: FriendRequestPayload, user_id: int = Depends(require_user_id)): - if user_id == payload.to: - raise HTTPException(status_code=400, detail="Cannot send friend request to yourself") - - users_col = db_handler.get_collection("users").collection - target_user = await users_col.find_one({"_id": payload.to}) - if not target_user: - raise HTTPException(status_code=404, detail="Target user not found") - - freq_col = db_handler.get_collection("friendRequests").collection - - # If the other person already sent a request to us, auto-accept it - reverse_req = await freq_col.find_one({"from": payload.to, "to": user_id, "status": "pending"}) - if reverse_req: - await freq_col.update_many( - {"$or": [{"from": payload.to, "to": user_id}, {"from": user_id, "to": payload.to}]}, - {"$set": {"status": "accepted"}} - ) - friends_col = db_handler.get_collection("friends").collection - await friends_col.update_one({"_id": user_id}, {"$addToSet": {"friend_ids": payload.to}, "$set": {"updated_at": time.time()}}, upsert=True) - await friends_col.update_one({"_id": payload.to}, {"$addToSet": {"friend_ids": user_id}, "$set": {"updated_at": time.time()}}, upsert=True) - - # Notify both about mutual friendship - u1 = await users_col.find_one({"_id": user_id}, {"first_name": 1, "profile_url": 1, "photo_url": 1, "username": 1}) - u2 = await users_col.find_one({"_id": payload.to}, {"first_name": 1, "profile_url": 1, "photo_url": 1, "username": 1}) - - await manager.broadcast_to_user(payload.to, { - "type": "friend_accepted", - "user": { - "user_id": user_id, - "first_name": u1.get("first_name"), - "username": u1.get("username"), - "profile_url": u1.get("profile_url") or u1.get("photo_url") - } - }) - await manager.broadcast_to_user(user_id, { - "type": "friend_accepted", - "user": { - "user_id": payload.to, - "first_name": u2.get("first_name"), - "username": u2.get("username"), - "profile_url": u2.get("profile_url") or u2.get("photo_url") - } - }) - - return {"ok": True, "message": "Mutual friend request detected and auto-accepted"} - - existing = await freq_col.find_one({"from": user_id, "to": payload.to, "status": "pending"}) - if existing: - return {"ok": True, "message": "Request already sent"} - - friends_col = db_handler.get_collection("friends").collection - friend_doc = await friends_col.find_one({"_id": user_id}) - if friend_doc and payload.to in friend_doc.get("friend_ids", []): - raise HTTPException(status_code=400, detail="Already friends") - - await freq_col.insert_one({ - "from": user_id, - "to": payload.to, - "status": "pending", - "created_at": time.time() - }) - - # Notify target about new friend request - u = await users_col.find_one({"_id": user_id}, {"first_name": 1, "profile_url": 1, "photo_url": 1, "username": 1}) - await manager.broadcast_to_user(payload.to, { - "type": "new_friend_request", - "user": { - "user_id": user_id, - "first_name": u.get("first_name"), - "username": u.get("username"), - "profile_url": u.get("profile_url") or u.get("photo_url") - } - }) - - return {"ok": True} - -@router.get("/requests") -async def get_friend_requests(user_id: int = Depends(require_user_id)): - freq_col = db_handler.get_collection("friendRequests").collection - cursor = freq_col.find({"to": user_id, "status": "pending"}) - - from_user_ids = [] - requests_map = {} - async for req in cursor: - uid = req["from"] - from_user_ids.append(uid) - requests_map[uid] = { - "request_id": str(req["_id"]), - "created_at": req.get("created_at") - } - - if not from_user_ids: - return {"ok": True, "requests": []} - - users_col = db_handler.get_collection("users").collection - u_cursor = users_col.find({"_id": {"$in": from_user_ids}}, {"first_name": 1, "profile_url": 1, "photo_url": 1, "username": 1}) - - requests_list = [] - async for u in u_cursor: - uid = int(u["_id"]) - req_data = requests_map.get(uid, {}) - requests_list.append({ - "user_id": uid, - "first_name": u.get("first_name"), - "username": u.get("username"), - "profile_url": u.get("profile_url") or u.get("photo_url"), - "request_id": req_data.get("request_id"), - "created_at": req_data.get("created_at") - }) - - return {"ok": True, "requests": requests_list} - -@router.post("/accept") -async def accept_friend_request(payload: AcceptRequestPayload, user_id: int = Depends(require_user_id)): - freq_col = db_handler.get_collection("friendRequests").collection - - req = await freq_col.find_one({"from": payload.userId, "to": user_id, "status": "pending"}) - if not req: - raise HTTPException(status_code=404, detail="Friend request not found") - - # Mark both possible directions as accepted to clean up mutual requests - await freq_col.update_many( - {"$or": [{"from": payload.userId, "to": user_id}, {"from": user_id, "to": payload.userId}]}, - {"$set": {"status": "accepted"}} - ) - - friends_col = db_handler.get_collection("friends").collection - await friends_col.update_one({"_id": user_id}, {"$addToSet": {"friend_ids": payload.userId}, "$set": {"updated_at": time.time()}}, upsert=True) - await friends_col.update_one({"_id": payload.userId}, {"$addToSet": {"friend_ids": user_id}, "$set": {"updated_at": time.time()}}, upsert=True) - - # Notify both about mutual friendship - users_col = db_handler.get_collection("users").collection - u1 = await users_col.find_one({"_id": user_id}, {"first_name": 1, "profile_url": 1, "photo_url": 1, "username": 1}) - u2 = await users_col.find_one({"_id": payload.userId}, {"first_name": 1, "profile_url": 1, "photo_url": 1, "username": 1}) - - await manager.broadcast_to_user(payload.userId, { - "type": "friend_accepted", - "user": { - "user_id": user_id, - "first_name": u1.get("first_name"), - "username": u1.get("username"), - "profile_url": u1.get("profile_url") or u1.get("photo_url") - } - }) - await manager.broadcast_to_user(user_id, { - "type": "friend_accepted", - "user": { - "user_id": payload.userId, - "first_name": u2.get("first_name"), - "username": u2.get("username"), - "profile_url": u2.get("profile_url") or u2.get("photo_url") - } - }) - - return {"ok": True} - -@router.delete("/{friendId}") -async def remove_friend(friendId: int, user_id: int = Depends(require_user_id)): - friends_col = db_handler.get_collection("friends").collection - await friends_col.update_one({"_id": user_id}, {"$pull": {"friend_ids": friendId}}) - await friends_col.update_one({"_id": friendId}, {"$pull": {"friend_ids": user_id}}) - - # Notify both about removal - await manager.broadcast_to_user(friendId, {"type": "friend_removed", "user_id": user_id}) - await manager.broadcast_to_user(user_id, {"type": "friend_removed", "user_id": friendId}) - - return {"ok": True} - -@router.get("") -async def get_friends(user_id: int = Depends(require_user_id)): - friends_col = db_handler.get_collection("friends").collection - friend_doc = await friends_col.find_one({"_id": user_id}) - if not friend_doc: - return {"ok": True, "friends": []} - - friend_ids = friend_doc.get("friend_ids", []) - if not friend_ids: - return {"ok": True, "friends": []} - - users_col = db_handler.get_collection("users").collection - cursor = users_col.find({"_id": {"$in": friend_ids}}, {"first_name": 1, "profile_url": 1, "photo_url": 1, "settings": 1}) - - # Fetch current presence statuses for all friends - presence_col = db_handler.get_collection("presence").collection - p_cursor = presence_col.find({"user_id": {"$in": friend_ids}}) - presence_map = {} - async for p in p_cursor: - presence_map[p["user_id"]] = { - "online": p.get("online", False), - "last_seen": p.get("last_seen"), - "device": p.get("device") - } - - friends = [] - async for f in cursor: - uid = int(f["_id"]) - f["_id"] = uid - - # Default offline state if no presence doc exists - p_data = presence_map.get(uid, {"online": False, "last_seen": None, "device": None}) - - # Optional: Safety check - if last_seen is older than 2 minutes, mark as offline manually - # in case the TTL index hasn't cleaned it up yet. - if p_data["online"] and p_data["last_seen"]: - if time.time() - p_data["last_seen"] > 120: - p_data["online"] = False - - f["presence"] = p_data - friends.append(f) - - return {"ok": True, "friends": friends} - -class ListeningUpdatePayload(BaseModel): - track_id: str - is_playing: bool = True - position_sec: float = 0 - jam_id: Optional[str] = None - -@router.post("/listening") -async def update_listening_status(payload: ListeningUpdatePayload, user_id: int = Depends(require_user_id)): - list_col = db_handler.get_collection("listeningStatus").collection - update_data = { - "track_id": payload.track_id, - "started_at": time.time(), - "is_playing": payload.is_playing, - "position_sec": payload.position_sec, - "jam_id": payload.jam_id, - "updated_at": time.time() - } - await list_col.update_one( - {"user_id": user_id}, - {"$set": update_data}, - upsert=True - ) - - await broadcast_listening_to_friends(user_id, update_data) - - return {"ok": True} - -@router.get("/listening") -async def get_friends_listening(user_id: int = Depends(require_user_id)): - friends_col = db_handler.get_collection("friends").collection - friend_doc = await friends_col.find_one({"_id": user_id}) - if not friend_doc: - return {"ok": True, "listening": []} - - friend_ids = friend_doc.get("friend_ids", []) - if not friend_ids: - return {"ok": True, "listening": []} - - users_col = db_handler.get_collection("users").collection - cursor = users_col.find({"_id": {"$in": friend_ids}}, {"settings": 1}) - allowed_friends = [] - async for u in cursor: - settings = u.get("settings", {}) - share = settings.get("share_listening", "friends") - if share in ["friends", "everyone"]: - allowed_friends.append(u["_id"]) - - if not allowed_friends: - return {"ok": True, "listening": []} - - listening_col = db_handler.get_collection("listeningStatus").collection - l_cursor = listening_col.find({"user_id": {"$in": allowed_friends}}) - status_list = [] - async for l in l_cursor: - l["_id"] = str(l["_id"]) - status_list.append(l) - - return {"ok": True, "listening": status_list} - -@router.post("/invite-jam") -async def invite_to_jam(payload: InviteJamPayload, user_id: int = Depends(require_user_id)): - users_col = db_handler.get_collection("users").collection - target = await users_col.find_one({"_id": payload.toUserId}, {"settings": 1, "fcm_token": 1, "fcm_tokens": 1}) - if not target: - raise HTTPException(status_code=404, detail="Target user not found") - - settings = target.get("settings", {}) - if not settings.get("allow_jam_invites", True): - raise HTTPException(status_code=403, detail="User does not allow jam invites") - - notif_col = db_handler.get_collection("notifications").collection - await notif_col.insert_one({ - "user_id": payload.toUserId, - "type": "jam_invite", - "payload": {"jam_id": payload.jamId, "from_user": user_id}, - "read": False, - "created_at": time.time() - }) - - await manager.broadcast_to_user(payload.toUserId, { - "type": "jam_invite", - "jam_id": payload.jamId, - "from": user_id - }) - - # Send FCM Push Notification - tokens = set() - if target.get("fcm_token"): - tokens.add(target.get("fcm_token")) - if target.get("fcm_tokens"): - tokens.update(target.get("fcm_tokens")) - - if tokens: - sender = await users_col.find_one({"_id": user_id}, {"first_name": 1, "username": 1}) - sender_name = sender.get("first_name") or sender.get("username") or "Someone" - - from Api.utils.firebase import send_multicast_notification - - title = "Jam Session Invite" - body = f"{sender_name} invited you to listen together." - data = { - "type": "jam_invite", - "jam_id": str(payload.jamId), - "from_user": str(user_id) - } - - # Multicast helper takes a list - send_multicast_notification(list(tokens), title, body, data) - - return {"ok": True} - -@router.put("/settings") -async def update_settings(payload: SettingsPayload, user_id: int = Depends(require_user_id)): - users_col = db_handler.get_collection("users").collection - updates = {} - if payload.share_listening is not None: - updates["settings.share_listening"] = payload.share_listening - if payload.allow_jam_invites is not None: - updates["settings.allow_jam_invites"] = payload.allow_jam_invites - - if updates: - await users_col.update_one({"_id": user_id}, {"$set": updates}) - - return {"ok": True} - - -@router.get("/settings") -async def get_settings(user_id: Optional[int] = Depends(get_optional_user_id)): - if user_id is None: - raise HTTPException(status_code=401, detail="login required") - users_col = db_handler.get_collection("users").collection - doc = await users_col.find_one({"_id": user_id}, {"settings": 1}) - settings = doc.get("settings", {}) if isinstance(doc, dict) else {} - if not isinstance(settings, dict): - settings = {} - return {"ok": True, "settings": settings} - -@router.post("/fcm_tokens") -async def add_fcm_token(payload: FcmTokenPayload, user_id: int = Depends(require_user_id)): - users_col = db_handler.get_collection("users").collection - await users_col.update_one({"_id": user_id}, {"$addToSet": {"fcm_tokens": payload.token}}) - return {"ok": True} diff --git a/Api/routers/health.py b/Api/routers/health.py deleted file mode 100644 index e380a8e..0000000 --- a/Api/routers/health.py +++ /dev/null @@ -1,9 +0,0 @@ -import time -from fastapi import APIRouter - -router = APIRouter() - -@router.get("/health") -async def health(): - return {"status": "ok", "time": time.time()} - diff --git a/Api/routers/jam.py b/Api/routers/jam.py deleted file mode 100644 index 326111b..0000000 --- a/Api/routers/jam.py +++ /dev/null @@ -1,825 +0,0 @@ -import asyncio -import time -import uuid -from typing import Any, Optional -from fastapi import APIRouter, Depends, HTTPException, Header, WebSocket, WebSocketDisconnect -from pydantic import BaseModel, Field - -from Api.services.stream_service import warm_track_cached -from Api.utils.auth import get_optional_user_id, require_user_id, verify_auth_token -from stream.database.MongoDb import db_handler -from stream.helpers.logger import LOGGER - -LOG = LOGGER(__name__) - -_WS_LOCK = asyncio.Lock() -_WS_ROOMS: dict[str, set[WebSocket]] = {} - -router = APIRouter(prefix="/jam", tags=["jam"]) - -def _now() -> float: - return time.time() - - -def _as_int(v: Any) -> int | None: - try: - return int(v) - except Exception: - return None - - -def _sanitize_track_id(v: Any) -> str: - s = (v or "").strip() - if not s: - return "" - return s - - -def _member_meta_from_payload(payload: dict) -> tuple[str | None, str | None]: - fn = payload.get("first_name") - if not isinstance(fn, str) or not fn.strip(): - fn = None - else: - fn = fn.strip() - pu = payload.get("profile_url") - if not isinstance(pu, str) or not pu.strip(): - pu = payload.get("photo_url") - if not isinstance(pu, str) or not pu.strip(): - pu = None - else: - pu = pu.strip() - else: - pu = pu.strip() - return fn, pu - - -async def _resolve_member_meta(*, user_id: int, auth_payload: dict) -> tuple[str | None, str | None]: - first_name, profile_url = _member_meta_from_payload(auth_payload) - if first_name and profile_url: - return first_name, profile_url - - col = db_handler.get_collection("users").collection - doc = await col.find_one( - {"_id": int(user_id)}, - {"first_name": 1, "profile_url": 1, "photo_url": 1}, - ) - if isinstance(doc, dict): - if not first_name: - fn = doc.get("first_name") - if isinstance(fn, str) and fn.strip(): - first_name = fn.strip() - if not profile_url: - pu = doc.get("profile_url") - if not isinstance(pu, str) or not pu.strip(): - pu = doc.get("photo_url") - if isinstance(pu, str) and pu.strip(): - profile_url = pu.strip() - - return first_name, profile_url - - -def _auth_payload_from_headers(authorization: str | None, x_auth_token: str | None) -> dict: - token = (authorization or "").strip() - if not token: - token = (x_auth_token or "").strip() - return verify_auth_token(token) - - -def _ws_auth_payload(ws: WebSocket) -> dict: - token = (ws.query_params.get("token") or ws.query_params.get("auth") or "").strip() - if not token: - token = (ws.headers.get("x-auth-token") or "").strip() - if not token: - token = (ws.headers.get("authorization") or "").strip() - return verify_auth_token(token) - - -def _compute_position(playback: dict) -> tuple[float, bool]: - try: - pos_orig = float(playback.get("position_sec") or 0.0) - except Exception: - pos_orig = 0.0 - - pos = pos_orig - is_playing = bool(playback.get("is_playing")) - started_at = playback.get("started_at") - duration = playback.get("duration_sec") - - if is_playing: - try: - started_at_f = float(started_at) - except Exception: - started_at_f = 0.0 - if started_at_f > 0: - diff = _now() - started_at_f - pos = pos_orig + max(0.0, diff) - - if duration is not None: - try: - d = float(duration) - if pos >= d: - pos = d - is_playing = False - except Exception: - pass - - return max(0.0, float(pos)), is_playing - - -async def _warm_tracks(track_ids: list[str]) -> None: - uniq: list[str] = [] - seen: set[str] = set() - for t in (track_ids or []): - tid = _sanitize_track_id(t) - if not tid or tid in seen: - continue - seen.add(tid) - uniq.append(tid) - - for tid in uniq[:3]: - try: - asyncio.create_task(warm_track_cached(tid)) - except Exception: - pass - - -async def _get_session(jam_id: str) -> dict | None: - jam_id = (jam_id or "").strip() - if not jam_id: - return None - col = db_handler.get_collection("jam_sessions").collection - doc = await col.find_one({"_id": jam_id}) - return doc - - -def _serialize_session(doc: dict) -> dict: - out = dict(doc or {}) - if "_id" in out: - out["_id"] = str(out["_id"]) - - # Authoritative server time for clock sync - out["server_time"] = _now() - - host = _as_int(out.get("host_user_id")) - if host is not None: - out["host_user_id"] = int(host) - members = out.get("members") - if isinstance(members, list): - normalized: list[dict] = [] - for m in members: - if not isinstance(m, dict): - continue - mm = dict(m) - uid = _as_int(mm.get("user_id")) - if uid is not None: - mm["user_id"] = int(uid) - pu = mm.get("profile_url") - if not isinstance(pu, str) or not pu.strip(): - pu2 = mm.get("photo_url") - if isinstance(pu2, str) and pu2.strip(): - mm["profile_url"] = pu2.strip() - else: - mm["profile_url"] = pu.strip() - mm.pop("photo_url", None) - normalized.append(mm) - out["members"] = normalized - - playback = out.get("playback") - if isinstance(playback, dict): - pb = dict(playback) - - # Calculate live state to see if it's naturally ended (auto-stop logic) - pos_live, playing_live = _compute_position(pb) - - orig_playing = bool(pb.get("is_playing")) - # We update the playing status in case the server-side auto-stop triggered. - pb["is_playing"] = playing_live - - if pb.get("started_at") is not None: - try: - pb["started_at"] = float(pb["started_at"]) - except Exception: - pb["started_at"] = None - - if pb.get("position_sec") is not None: - try: - pb["position_sec"] = float(pb["position_sec"]) - except Exception: - pb["position_sec"] = 0.0 - - # CRITICAL: If we are forcing a stop because the track ended, - # we must send the end position as the static position, otherwise - # the client will jump back to the base position (massive skip). - if orig_playing and not playing_live: - pb["position_sec"] = pos_live - LOG.debug(f"[jam_state] auto-stop triggered id={out.get('_id')} final_pos={pos_live:.2f}") - - LOG.debug(f"[jam_state] id={out.get('_id')} base_pos={pb.get('position_sec')} started_at={pb.get('started_at')} live_pos={pos_live:.2f} playing={playing_live}") - out["playback"] = pb - return out - - -async def _broadcast(jam_id: str, payload: dict) -> None: - jam_id = (jam_id or "").strip() - if not jam_id: - return - async with _WS_LOCK: - conns = list(_WS_ROOMS.get(jam_id, set())) - if not conns: - return - dead: list[WebSocket] = [] - for ws in conns: - try: - await ws.send_json(payload) - except Exception: - dead.append(ws) - if dead: - async with _WS_LOCK: - room = _WS_ROOMS.get(jam_id) - if room: - for ws in dead: - room.discard(ws) - if not room: - _WS_ROOMS.pop(jam_id, None) - - -async def _broadcast_fresh_state(jam_id: str) -> dict | None: - doc = await _get_session(jam_id) - if not doc: - return None - await _broadcast(str(doc.get("_id") or jam_id), {"type": "jam_state", "jam": _serialize_session(doc)}) - return doc - - -def _ws_user_id(ws: WebSocket) -> int: - payload = _ws_auth_payload(ws) - uid = _as_int(payload.get("uid")) - if not uid or uid <= 0: - raise HTTPException(status_code=401, detail="invalid auth token") - return int(uid) - - -async def _ensure_member(*, jam_id: str, user_id: int, first_name: str | None = None, photo_url: str | None = None) -> None: - col = db_handler.get_collection("jam_sessions").collection - doc = await col.find_one({"_id": jam_id}, {"members": 1, "host_user_id": 1}) - if not doc: - raise HTTPException(status_code=404, detail="jam not found") - members = doc.get("members") if isinstance(doc.get("members"), list) else [] - fn = (first_name or "").strip() or None - pu = (photo_url or "").strip() or None - role = "host" if _as_int(doc.get("host_user_id")) == int(user_id) else "listener" - for m in members: - if not isinstance(m, dict) or _as_int(m.get("user_id")) != int(user_id): - continue - updates: dict[str, object] = {} - if (m.get("role") or "") != role: - updates["members.$.role"] = role - if fn and (m.get("first_name") or "") != fn: - updates["members.$.first_name"] = fn - existing_pu = m.get("profile_url") - if not isinstance(existing_pu, str) or not existing_pu.strip(): - existing_pu = m.get("photo_url") - if pu and (existing_pu or "") != pu: - updates["members.$.profile_url"] = pu - if updates: - updates["updated_at"] = _now() - update_doc: dict[str, object] = {"$set": updates} - if pu and "photo_url" in m: - update_doc["$unset"] = {"members.$.photo_url": ""} - await col.update_one( - {"_id": jam_id, "members.user_id": {"$in": [int(user_id), str(int(user_id))]}}, - update_doc, - ) - return - member: dict[str, object] = {"user_id": int(user_id), "role": role} - if fn: - member["first_name"] = fn - if pu: - member["profile_url"] = pu - await col.update_one({"_id": jam_id}, {"$push": {"members": member}, "$set": {"updated_at": _now()}}) - - -def _has_permission(doc: dict, user_id: int, *, action: str) -> bool: - host_id = _as_int(doc.get("host_user_id")) or 0 - if int(user_id) == int(host_id): - return True - settings = doc.get("settings") if isinstance(doc.get("settings"), dict) else {} - if action == "seek": - return bool(settings.get("allow_seek")) - if action == "queue": - return bool(settings.get("allow_queue_edit")) - return False - - -class JamCreateRequest(BaseModel): - track_id: str - position_sec: float = 0.0 - is_playing: bool = True - queue: list[str] = Field(default_factory=list) - settings: dict[str, Any] = Field(default_factory=dict) - - -class JamJoinResponse(BaseModel): - ok: bool = True - jam: dict[str, Any] - - -class JamSeekRequest(BaseModel): - position_sec: float - - -class JamQueueAddRequest(BaseModel): - track_id: str - position: int | None = None - - -class JamQueueReorderRequest(BaseModel): - queue: list[str] = Field(default_factory=list) - - -class JamSettingsUpdateRequest(BaseModel): - allow_seek: bool | None = None - allow_queue_edit: bool | None = None - - -@router.post("/create", response_model=JamJoinResponse) -async def jam_create( - payload: JamCreateRequest, - user_id: Optional[int] = Depends(get_optional_user_id), - authorization: str | None = Header(default=None), - x_auth_token: str | None = Header(default=None, alias="X-Auth-Token"), -): - if user_id is None: - raise HTTPException(status_code=401, detail="login required to create jam") - auth_payload = _auth_payload_from_headers(authorization, x_auth_token) - first_name, profile_url = await _resolve_member_meta(user_id=int(user_id), auth_payload=auth_payload) - track_id = _sanitize_track_id(payload.track_id) - if not track_id: - raise HTTPException(status_code=400, detail="track_id is required") - - from Api.services.track_service import get_track_by_id - track_doc = await get_track_by_id(track_id) - duration = None - if track_doc and isinstance(track_doc.get("audio"), dict): - duration = track_doc["audio"].get("duration_sec") - - now = _now() - jam_id = f"jam_{uuid.uuid4().hex}" - host_member: dict[str, object] = {"user_id": int(user_id), "role": "host"} - if first_name: - host_member["first_name"] = first_name - if profile_url: - host_member["profile_url"] = profile_url - doc = { - "_id": jam_id, - "host_user_id": int(user_id), - "created_at": now, - "updated_at": now, - "playback": { - "track_id": track_id, - "duration_sec": duration, - "position_sec": float(payload.position_sec or 0.0), - "started_at": now if bool(payload.is_playing) else now, - "is_playing": bool(payload.is_playing), - }, - "queue": [_sanitize_track_id(x) for x in (payload.queue or []) if _sanitize_track_id(x)], - "members": [host_member], - "settings": payload.settings or {}, - } - await db_handler.get_collection("jam_sessions").collection.insert_one(doc) - - try: - next_ids = [track_id] + (doc.get("queue") or [])[:2] - asyncio.create_task(_warm_tracks(next_ids)) - except Exception: - pass - - return {"ok": True, "jam": _serialize_session(doc)} - - -@router.get("/{jam_id}", response_model=JamJoinResponse) -async def jam_get( - jam_id: str, - user_id: int = Depends(require_user_id), - authorization: str | None = Header(default=None), - x_auth_token: str | None = Header(default=None, alias="X-Auth-Token"), -): - doc = await _get_session(jam_id) - if not doc: - raise HTTPException(status_code=404, detail="jam not found") - auth_payload = _auth_payload_from_headers(authorization, x_auth_token) - first_name, profile_url = await _resolve_member_meta(user_id=int(user_id), auth_payload=auth_payload) - await _ensure_member(jam_id=str(doc["_id"]), user_id=int(user_id), first_name=first_name, photo_url=profile_url) - doc2 = await _get_session(jam_id) - if not doc2: - raise HTTPException(status_code=404, detail="jam not found") - return {"ok": True, "jam": _serialize_session(doc2)} - - -@router.post("/{jam_id}/join", response_model=JamJoinResponse) -async def jam_join( - jam_id: str, - user_id: int = Depends(require_user_id), - authorization: str | None = Header(default=None), - x_auth_token: str | None = Header(default=None, alias="X-Auth-Token"), -): - doc = await _get_session(jam_id) - if not doc: - raise HTTPException(status_code=404, detail="jam not found") - auth_payload = _auth_payload_from_headers(authorization, x_auth_token) - first_name, profile_url = await _resolve_member_meta(user_id=int(user_id), auth_payload=auth_payload) - await _ensure_member(jam_id=str(doc["_id"]), user_id=int(user_id), first_name=first_name, photo_url=profile_url) - doc2 = await _get_session(jam_id) - if not doc2: - raise HTTPException(status_code=404, detail="jam not found") - await _broadcast(str(doc2["_id"]), {"type": "jam_state", "jam": _serialize_session(doc2)}) - return {"ok": True, "jam": _serialize_session(doc2)} - - -@router.post("/{jam_id}/leave") -async def jam_leave(jam_id: str, user_id: int = Depends(require_user_id)): - col = db_handler.get_collection("jam_sessions").collection - doc = await col.find_one({"_id": jam_id}) - if not doc: - raise HTTPException(status_code=404, detail="jam not found") - - members = doc.get("members") if isinstance(doc.get("members"), list) else [] - kept: list[dict] = [] - for m in members: - if not isinstance(m, dict): - continue - if _as_int(m.get("user_id")) == int(user_id): - continue - kept.append(m) - - host_id = _as_int(doc.get("host_user_id")) or 0 - if int(user_id) != int(host_id): - await col.update_one({"_id": jam_id}, {"$set": {"members": kept, "updated_at": _now()}}) - await _broadcast_fresh_state(jam_id) - return {"ok": True} - - if not kept: - await col.delete_one({"_id": jam_id}) - await _broadcast(jam_id, {"type": "jam_ended", "jam_id": jam_id}) - return {"ok": True, "ended": True} - - new_host = _as_int(kept[0].get("user_id")) or 0 - for m in kept: - if not isinstance(m, dict): - continue - if _as_int(m.get("user_id")) == int(new_host): - m["role"] = "host" - elif m.get("role") == "host": - m["role"] = "listener" - - await col.update_one( - {"_id": jam_id}, - {"$set": {"host_user_id": int(new_host), "members": kept, "updated_at": _now()}}, - ) - doc3 = await col.find_one({"_id": jam_id}) - if doc3: - await _broadcast_fresh_state(jam_id) - return {"ok": True, "new_host_user_id": int(new_host)} - - -@router.post("/{jam_id}/settings") -async def jam_settings_update( - jam_id: str, - payload: JamSettingsUpdateRequest, - user_id: int = Depends(require_user_id), -): - col = db_handler.get_collection("jam_sessions").collection - doc = await col.find_one({"_id": jam_id}, {"host_user_id": 1, "settings": 1}) - if not doc: - raise HTTPException(status_code=404, detail="jam not found") - if int(user_id) != int(_as_int(doc.get("host_user_id")) or 0): - raise HTTPException(status_code=403, detail="not allowed") - - updates: dict[str, object] = {} - if payload.allow_seek is not None: - updates["settings.allow_seek"] = bool(payload.allow_seek) - if payload.allow_queue_edit is not None: - updates["settings.allow_queue_edit"] = bool(payload.allow_queue_edit) - if not updates: - raise HTTPException(status_code=400, detail="no settings to update") - updates["updated_at"] = _now() - - await col.update_one({"_id": jam_id}, {"$set": updates}) - await _broadcast_fresh_state(jam_id) - return {"ok": True} - - -# --------------------------------------------------------------------------- -# Core mutation helpers (used by both HTTP and WebSocket handlers) -# --------------------------------------------------------------------------- - -async def _do_play(jam_id: str, user_id: int) -> None: - col = db_handler.get_collection("jam_sessions").collection - doc = await col.find_one({"_id": jam_id}) - if not doc: - raise HTTPException(status_code=404, detail="jam not found") - if not _has_permission(doc, int(user_id), action="play"): - raise HTTPException(status_code=403, detail="not allowed") - playback = doc.get("playback") if isinstance(doc.get("playback"), dict) else {} - pos, _ = _compute_position(playback) - now = _now() - LOG.info(f"[jam_play] jam_id={jam_id} pos={pos:.2f} user_id={user_id}") - await col.update_one( - {"_id": jam_id}, - {"$set": { - "playback.position_sec": float(pos), - "playback.started_at": now, - "playback.is_playing": True, - "updated_at": now, - }}, - ) - await _broadcast_fresh_state(jam_id) - - -async def _do_pause(jam_id: str, user_id: int) -> None: - col = db_handler.get_collection("jam_sessions").collection - doc = await col.find_one({"_id": jam_id}) - if not doc: - raise HTTPException(status_code=404, detail="jam not found") - if not _has_permission(doc, int(user_id), action="pause"): - raise HTTPException(status_code=403, detail="not allowed") - playback = doc.get("playback") if isinstance(doc.get("playback"), dict) else {} - pos, _ = _compute_position(playback) - now = _now() - LOG.info(f"[jam_pause] jam_id={jam_id} pos={pos:.2f} user_id={user_id}") - await col.update_one( - {"_id": jam_id}, - {"$set": { - "playback.position_sec": float(pos), - "playback.started_at": now, - "playback.is_playing": False, - "updated_at": now, - }}, - ) - await _broadcast_fresh_state(jam_id) - - -async def _do_seek(jam_id: str, user_id: int, position_sec: float) -> None: - col = db_handler.get_collection("jam_sessions").collection - doc = await col.find_one({"_id": jam_id}) - if not doc: - raise HTTPException(status_code=404, detail="jam not found") - if not _has_permission(doc, int(user_id), action="seek"): - raise HTTPException(status_code=403, detail="not allowed") - playback = doc.get("playback") if isinstance(doc.get("playback"), dict) else {} - _, is_playing = _compute_position(playback) - now = _now() - new_pos = max(0.0, float(position_sec or 0.0)) - LOG.info(f"[jam_seek] jam_id={jam_id} new_pos={new_pos:.2f} user_id={user_id}") - await col.update_one( - {"_id": jam_id}, - {"$set": { - "playback.position_sec": new_pos, - "playback.started_at": now, - "playback.is_playing": bool(is_playing), - "updated_at": now, - }}, - ) - await _broadcast_fresh_state(jam_id) - - -async def _do_queue_add(jam_id: str, user_id: int, track_id_raw: str, position: int | None = None) -> None: - track_id = _sanitize_track_id(track_id_raw) - if not track_id: - raise HTTPException(status_code=400, detail="track_id is required") - col = db_handler.get_collection("jam_sessions").collection - doc = await col.find_one({"_id": jam_id}) - if not doc: - raise HTTPException(status_code=404, detail="jam not found") - if not _has_permission(doc, int(user_id), action="queue"): - raise HTTPException(status_code=403, detail="not allowed") - queue = doc.get("queue") if isinstance(doc.get("queue"), list) else [] - q2 = [str(x) for x in queue if isinstance(x, str) and x.strip()] - pos = position - try: - pos = int(pos) if pos is not None else None - except Exception: - pos = None - if pos is None or pos < 0 or pos > len(q2): - q2.append(track_id) - else: - q2.insert(int(pos), track_id) - now = _now() - playback = doc.get("playback") or {} - curr_pos, _ = _compute_position(playback) - LOG.info(f"[jam_queue_add] jam_id={jam_id} track_id={track_id} pos={curr_pos:.2f} queue_len={len(q2)}") - await col.update_one({"_id": jam_id}, {"$set": {"queue": q2, "updated_at": now}}) - doc2 = await col.find_one({"_id": jam_id}) - try: - pb = (doc2 or doc).get("playback") if isinstance((doc2 or doc).get("playback"), dict) else {} - current = _sanitize_track_id(pb.get("track_id")) - asyncio.create_task(_warm_tracks([current] + q2[:2])) - except Exception: - pass - await _broadcast_fresh_state(jam_id) - - -async def _do_queue_reorder(jam_id: str, user_id: int, queue_raw: list[str]) -> None: - col = db_handler.get_collection("jam_sessions").collection - doc = await col.find_one({"_id": jam_id}) - if not doc: - raise HTTPException(status_code=404, detail="jam not found") - if not _has_permission(doc, int(user_id), action="queue"): - raise HTTPException(status_code=403, detail="not allowed") - queue = [_sanitize_track_id(x) for x in (queue_raw or []) if _sanitize_track_id(x)] - now = _now() - await col.update_one({"_id": jam_id}, {"$set": {"queue": queue, "updated_at": now}}) - doc2 = await col.find_one({"_id": jam_id}) - try: - pb = (doc2 or doc).get("playback") if isinstance((doc2 or doc).get("playback"), dict) else {} - current = _sanitize_track_id(pb.get("track_id")) - asyncio.create_task(_warm_tracks([current] + queue[:2])) - except Exception: - pass - await _broadcast_fresh_state(jam_id) - - -async def _do_next(jam_id: str, user_id: int) -> str | None: - col = db_handler.get_collection("jam_sessions").collection - doc = await col.find_one({"_id": jam_id}) - if not doc: - raise HTTPException(status_code=404, detail="jam not found") - if not _has_permission(doc, int(user_id), action="next"): - raise HTTPException(status_code=403, detail="not allowed") - queue = doc.get("queue") if isinstance(doc.get("queue"), list) else [] - q2 = [str(x) for x in queue if isinstance(x, str) and x.strip()] - now = _now() - if not q2: - await col.update_one( - {"_id": jam_id}, - {"$set": {"playback.is_playing": False, "updated_at": now}}, - ) - await _broadcast_fresh_state(jam_id) - return None - next_track = _sanitize_track_id(q2.pop(0)) - if not next_track: - await col.update_one( - {"_id": jam_id}, - {"$set": {"playback.is_playing": False, "updated_at": now}}, - ) - await _broadcast_fresh_state(jam_id) - return None - from Api.services.track_service import get_track_by_id - track_doc = await get_track_by_id(next_track) - duration = None - if track_doc and isinstance(track_doc.get("audio"), dict): - duration = track_doc["audio"].get("duration_sec") - await col.update_one( - {"_id": jam_id}, - {"$set": { - "queue": q2, - "playback.track_id": next_track, - "playback.duration_sec": duration, - "playback.position_sec": 0.0, - "playback.started_at": now, - "playback.is_playing": True, - "updated_at": now, - }}, - ) - try: - asyncio.create_task(_warm_tracks([next_track] + q2[:2])) - except Exception: - pass - await _broadcast_fresh_state(jam_id) - return next_track - - -# --------------------------------------------------------------------------- -# HTTP endpoints (kept for compatibility; frontend uses WebSocket) -# --------------------------------------------------------------------------- - -@router.post("/{jam_id}/play") -async def jam_play(jam_id: str, user_id: int = Depends(require_user_id)): - await _do_play(jam_id, int(user_id)) - return {"ok": True} - - -@router.post("/{jam_id}/pause") -async def jam_pause(jam_id: str, user_id: int = Depends(require_user_id)): - await _do_pause(jam_id, int(user_id)) - return {"ok": True} - - -@router.post("/{jam_id}/seek") -async def jam_seek(jam_id: str, payload: JamSeekRequest, user_id: int = Depends(require_user_id)): - await _do_seek(jam_id, int(user_id), payload.position_sec) - return {"ok": True} - - -@router.post("/{jam_id}/queue/add") -async def jam_queue_add(jam_id: str, payload: JamQueueAddRequest, user_id: int = Depends(require_user_id)): - await _do_queue_add(jam_id, int(user_id), payload.track_id, payload.position) - return {"ok": True} - - -@router.post("/{jam_id}/queue/reorder") -async def jam_queue_reorder(jam_id: str, payload: JamQueueReorderRequest, user_id: int = Depends(require_user_id)): - await _do_queue_reorder(jam_id, int(user_id), payload.queue) - return {"ok": True} - - -@router.post("/{jam_id}/next") -async def jam_next(jam_id: str, user_id: int = Depends(require_user_id)): - track_id = await _do_next(jam_id, int(user_id)) - return {"ok": True, "track_id": track_id} - - -@router.websocket("/{jam_id}/ws") -async def jam_ws(ws: WebSocket, jam_id: str): - await ws.accept() - room_id = (jam_id or "").strip() or jam_id - try: - auth_payload = _ws_auth_payload(ws) - uid = _as_int(auth_payload.get("uid")) - if not uid or uid <= 0: - raise HTTPException(status_code=401, detail="invalid auth token") - user_id = int(uid) - first_name, profile_url = await _resolve_member_meta(user_id=int(user_id), auth_payload=auth_payload) - doc = await _get_session(jam_id) - if not doc: - await ws.send_json({"type": "error", "error": "jam_not_found"}) - await ws.close(code=1008) - return - jam_id2 = str(doc["_id"]) - room_id = jam_id2 - await _ensure_member(jam_id=jam_id2, user_id=int(user_id), first_name=first_name, photo_url=profile_url) - - async with _WS_LOCK: - room = _WS_ROOMS.get(jam_id2) - if room is None: - room = set() - _WS_ROOMS[jam_id2] = room - room.add(ws) - - await _broadcast_fresh_state(jam_id2) - - while True: - msg = await ws.receive_json() - mtype = (msg.get("type") or "").strip() - if mtype == "ping": - await ws.send_json({"type": "pong", "t": _now()}) - elif mtype == "get_state": - doc3 = await _get_session(jam_id2) - if doc3: - await ws.send_json({"type": "jam_state", "jam": _serialize_session(doc3)}) - elif mtype == "play": - try: - await _do_play(jam_id2, user_id) - await ws.send_json({"type": "ack", "op": "play"}) - except HTTPException as he: - await ws.send_json({"type": "error", "op": "play", "error": he.detail, "status": he.status_code}) - elif mtype == "pause": - try: - await _do_pause(jam_id2, user_id) - await ws.send_json({"type": "ack", "op": "pause"}) - except HTTPException as he: - await ws.send_json({"type": "error", "op": "pause", "error": he.detail, "status": he.status_code}) - elif mtype == "seek": - try: - pos = float(msg.get("position_sec") or 0.0) - await _do_seek(jam_id2, user_id, pos) - await ws.send_json({"type": "ack", "op": "seek"}) - except HTTPException as he: - await ws.send_json({"type": "error", "op": "seek", "error": he.detail, "status": he.status_code}) - elif mtype == "next": - try: - await _do_next(jam_id2, user_id) - await ws.send_json({"type": "ack", "op": "next"}) - except HTTPException as he: - await ws.send_json({"type": "error", "op": "next", "error": he.detail, "status": he.status_code}) - elif mtype == "queue_add": - try: - tid = msg.get("track_id") - pos = msg.get("position") - await _do_queue_add(jam_id2, user_id, tid, pos) - await ws.send_json({"type": "ack", "op": "queue_add"}) - except HTTPException as he: - await ws.send_json({"type": "error", "op": "queue_add", "error": he.detail, "status": he.status_code}) - elif mtype == "queue_reorder": - try: - q = msg.get("queue") - await _do_queue_reorder(jam_id2, user_id, q) - await ws.send_json({"type": "ack", "op": "queue_reorder"}) - except HTTPException as he: - await ws.send_json({"type": "error", "op": "queue_reorder", "error": he.detail, "status": he.status_code}) - else: - await ws.send_json({"type": "error", "error": "unsupported_message"}) - except WebSocketDisconnect: - pass - except Exception: - try: - await ws.close(code=1011) - except Exception: - pass - finally: - async with _WS_LOCK: - room = _WS_ROOMS.get(room_id) - if room: - room.discard(ws) - if not room: - _WS_ROOMS.pop(room_id, None) diff --git a/Api/routers/logs.py b/Api/routers/logs.py deleted file mode 100644 index 7153711..0000000 --- a/Api/routers/logs.py +++ /dev/null @@ -1,22 +0,0 @@ -import os -import aiofiles -from fastapi import APIRouter, Depends, HTTPException -from Api.utils.auth import require_admin_user_id - -router = APIRouter(prefix="/logs", tags=["Logs"]) - -@router.get("/") -async def get_logs(admin_id: int = Depends(require_admin_user_id)): - """Retrieve the last 100 lines of logs (admin only).""" - log_path = "log.txt" - - if not os.path.exists(log_path): - raise HTTPException(status_code=404, detail="Log file not found") - - try: - async with aiofiles.open(log_path, mode="r", encoding="utf-8") as f: - content = await f.read() - lines = content.splitlines()[-100:] - return {"logs": lines} - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error reading logs: {str(e)}") diff --git a/Api/routers/notifications.py b/Api/routers/notifications.py deleted file mode 100644 index b6e10ab..0000000 --- a/Api/routers/notifications.py +++ /dev/null @@ -1,28 +0,0 @@ -import time -from fastapi import APIRouter, Depends, HTTPException -from bson import ObjectId -from Api.utils.auth import require_user_id -from stream.database.MongoDb import db_handler - -router = APIRouter(prefix="/notifications", tags=["notifications"]) - -@router.get("") -async def get_notifications(user_id: int = Depends(require_user_id)): - notif_col = db_handler.get_collection("notifications").collection - cursor = notif_col.find({"user_id": user_id}).sort("created_at", -1).limit(50) - notifs = [] - async for n in cursor: - n["_id"] = str(n["_id"]) - notifs.append(n) - return {"ok": True, "notifications": notifs} - -@router.post("/{notif_id}/read") -async def mark_notification_read(notif_id: str, user_id: int = Depends(require_user_id)): - notif_col = db_handler.get_collection("notifications").collection - try: - nid = ObjectId(notif_id) - except: - raise HTTPException(status_code=400, detail="Invalid notification ID") - - await notif_col.update_one({"_id": nid, "user_id": user_id}, {"$set": {"read": True}}) - return {"ok": True} diff --git a/Api/routers/playlists.py b/Api/routers/playlists.py deleted file mode 100644 index 3931161..0000000 --- a/Api/routers/playlists.py +++ /dev/null @@ -1,508 +0,0 @@ -import time -import uuid -from typing import Optional - -from fastapi import APIRouter, Depends, HTTPException, Query - -from Api.schemas.browse import BrowseResponse -from Api.schemas.playlists import ( - PlaylistCreate, - PlaylistItem, - PlaylistRename, - PlaylistTrackAdd, - PlaylistTracksResponse, - PlaylistsResponse, - UserAlbumAdd, - UserAlbumItem, - UserAlbumsResponse, -) -from Api.services.playlist_cover_service import ensure_user_playlist_cover, ensure_user_playlist_normal_cover -from Api.services.track_service import get_browse_items_by_ids, get_track_by_id, get_tracks_by_ids -from Api.utils.auth import get_optional_user_id, require_user_id -from stream.database.MongoDb import db_handler - - -router = APIRouter(prefix="/me", tags=["me"]) - - -@router.get("/history", response_model=BrowseResponse) -async def get_user_history( - limit: int = Query(default=100, ge=1, le=200), - user_id: int = Depends(require_user_id), -): - hist_col = db_handler.get_collection("userHistory").collection - query = {"$or": [{"user_id": int(user_id)}, {"user_id": str(user_id)}]} - cursor = hist_col.find(query).sort([("played_at", -1)]).limit(limit * 2) - - seen_track_ids: set[str] = set() - ordered_track_ids: list[str] = [] - async for doc in cursor: - tid = str(doc.get("track_id") or "").strip() - if tid and tid not in seen_track_ids: - seen_track_ids.add(tid) - ordered_track_ids.append(tid) - if len(ordered_track_ids) >= limit: - break - - if not ordered_track_ids: - return BrowseResponse(page=1, per_page=limit, total=0, items=[]) - - items = await get_browse_items_by_ids(ordered_track_ids, user_id=user_id) - item_map = {item.id: item for item in items} - ordered_items = [item_map[tid] for tid in ordered_track_ids if tid in item_map] - - return BrowseResponse(page=1, per_page=limit, total=len(ordered_items), items=ordered_items) - -def _track_thumbnail_url(track: dict) -> str: - spotify = track.get("spotify") if isinstance(track.get("spotify"), dict) else {} - telegram = track.get("telegram") if isinstance(track.get("telegram"), dict) else {} - audio = track.get("audio") if isinstance(track.get("audio"), dict) else {} - - candidates = [ - spotify.get("cover_url"), - spotify.get("cover"), - spotify.get("thumbnail"), - telegram.get("thumb_url"), - telegram.get("thumbnail_url"), - telegram.get("thumb"), - telegram.get("thumbnail"), - audio.get("cover_url"), - audio.get("thumbnail"), - ] - for c in candidates: - if isinstance(c, str) and c.strip(): - return c.strip() - return "" - -def _playlist_thumbnails(*, cover_url: str | None, track_thumbnails: list[str], limit: int = 4) -> list[str]: - out: list[str] = [] - seen: set[str] = set() - - for u in track_thumbnails: - if len(out) >= int(limit): - break - u2 = (u or "").strip() - if not u2 or u2 in seen: - continue - out.append(u2) - seen.add(u2) - - cover = (cover_url or "").strip() - if cover and cover not in seen and len(out) < int(limit): - out.append(cover) - seen.add(cover) - - return out - - -def _clean_url(value: object) -> str: - if not isinstance(value, str): - return "" - s = value.strip() - if len(s) >= 2 and s[0] == "`" and s[-1] == "`": - s = s[1:-1].strip() - return s - - -async def _get_playlist_or_404(playlist_id: str, user_id: int) -> dict: - col = db_handler.get_collection("user_playlists").collection - doc = await col.find_one({"_id": playlist_id, "user_id": int(user_id)}) - if not doc: - raise HTTPException(status_code=404, detail="playlist not found") - return doc - - -@router.post("/playlists", response_model=PlaylistItem) -async def create_playlist(payload: PlaylistCreate, user_id: int = Depends(require_user_id)): - name = (payload.name or "").strip() - if not name: - raise HTTPException(status_code=400, detail="name is required") - - now = time.time() - playlist_id = uuid.uuid4().hex - cover = await ensure_user_playlist_cover(playlist_id=playlist_id, name=name, force=True) - normal_cover = await ensure_user_playlist_normal_cover(playlist_id=playlist_id, name=name, force=True) - doc = { - "_id": playlist_id, - "user_id": int(user_id), - "name": name, - "cover_id": cover.get("cover_id"), - "cover_url": cover.get("url"), - "created_at": now, - "updated_at": now, - } - await db_handler.get_collection("user_playlists").collection.insert_one(doc) - return PlaylistItem( - playlist_id=playlist_id, - name=name, - thumbnails=_playlist_thumbnails(cover_url=cover.get("url"), track_thumbnails=[]), - cover_id=cover.get("cover_id"), - cover_url=cover.get("url"), - normal_thumbnail=normal_cover.get("url"), - created_at=now, - updated_at=now, - ) - - -@router.get("/playlists", response_model=PlaylistsResponse) -async def list_playlists(user_id: Optional[int] = Depends(get_optional_user_id)): - if user_id is None: - return PlaylistsResponse(items=[]) - col = db_handler.get_collection("user_playlists").collection - cursor = col.find( - {"user_id": user_id}, - {"_id": 1, "name": 1, "cover_id": 1, "cover_url": 1, "normal_thumbnail": 1, "collage_hash": 1, "created_at": 1, "updated_at": 1}, - ).sort([("created_at", -1)]) - raw_items: list[dict] = [] - async for doc in cursor: - raw_items.append( - { - "playlist_id": str(doc.get("_id")), - "name": (doc.get("name") or ""), - "cover_id": doc.get("cover_id"), - "cover_url": doc.get("cover_url"), - "normal_thumbnail": doc.get("normal_thumbnail"), - "collage_hash": doc.get("collage_hash"), - "created_at": doc.get("created_at"), - "updated_at": doc.get("updated_at"), - } - ) - - if not raw_items: - return PlaylistsResponse(items=[]) - - tracks_col = db_handler.get_collection("playlist_tracks").collection - by_playlist: dict[str, list[str]] = {} - for it in raw_items: - pid = str(it.get("playlist_id") or "").strip() - if not pid: - continue - cursor2 = ( - tracks_col.find({"playlist_id": pid}, {"_id": 0, "track_id": 1}) - .sort([("position", 1)]) - .limit(4) - ) - ids: list[str] = [] - async for row in cursor2: - tid = row.get("track_id") - tid = tid.strip() if isinstance(tid, str) else "" - if tid: - ids.append(tid) - by_playlist[pid] = ids - - needed_ids: list[str] = [] - for pid, ids in by_playlist.items(): - needed_ids.extend(ids[:4]) - - unique_ids: list[str] = [] - seen: set[str] = set() - for tid in needed_ids: - if tid and tid not in seen: - unique_ids.append(tid) - seen.add(tid) - - tracks_by_id: dict[str, dict] = {} - if unique_ids: - try: - tracks = await get_tracks_by_ids(unique_ids) - tracks_by_id = {str(t.get("_id") or t.get("id") or ""): t for t in tracks if isinstance(t, dict)} - except Exception: - tracks_by_id = {} - items: list[PlaylistItem] = [] - for it in raw_items: - pid = str(it.get("playlist_id") or "") - ids = by_playlist.get(pid) or [] - - # Resolve track thumbnails - track_thumbs: list[str] = [] - for tid in ids[:4]: - tdoc = tracks_by_id.get(tid) - if not isinstance(tdoc, dict): - continue - url = _track_thumbnail_url(tdoc) - if url: - track_thumbs.append(url) - - cover_url = it.get("cover_url") - cover_id = it.get("cover_id") - normal_thumbnail = it.get("normal_thumbnail") - - items.append( - PlaylistItem( - playlist_id=pid, - name=str(it.get("name") or ""), - thumbnails=_playlist_thumbnails(cover_url=cover_url, track_thumbnails=track_thumbs), - cover_id=cover_id, - cover_url=cover_url, - normal_thumbnail=normal_thumbnail, - created_at=it.get("created_at"), - updated_at=it.get("updated_at"), - ) - ) - return PlaylistsResponse(items=items) - - -@router.patch("/playlists/{playlist_id}", response_model=PlaylistItem) -async def rename_playlist( - playlist_id: str, - payload: PlaylistRename, - user_id: int = Depends(require_user_id), -): - name = (payload.name or "").strip() - if not name: - raise HTTPException(status_code=400, detail="name is required") - - await _get_playlist_or_404(playlist_id, user_id) - now = time.time() - cover = await ensure_user_playlist_cover(playlist_id=playlist_id, name=name, force=True) - normal_cover = await ensure_user_playlist_normal_cover(playlist_id=playlist_id, name=name, force=True) - await db_handler.get_collection("user_playlists").collection.update_one( - {"_id": playlist_id, "user_id": int(user_id)}, - {"$set": {"name": name, "cover_id": cover.get("cover_id"), "cover_url": cover.get("url"), "updated_at": now}}, - ) - return PlaylistItem( - playlist_id=playlist_id, - name=name, - thumbnails=_playlist_thumbnails(cover_url=cover.get("url"), track_thumbnails=[]), - cover_id=cover.get("cover_id"), - cover_url=cover.get("url"), - normal_thumbnail=normal_cover.get("url"), - updated_at=now, - ) - - -@router.delete("/playlists/{playlist_id}") -async def delete_playlist(playlist_id: str, user_id: int = Depends(require_user_id)): - col = db_handler.get_collection("user_playlists").collection - doc = await col.find_one({"_id": playlist_id, "user_id": int(user_id)}) - if not doc: - raise HTTPException(status_code=404, detail="playlist not found") - - res = await col.delete_one({"_id": playlist_id, "user_id": int(user_id)}) - if not getattr(res, "deleted_count", 0): - raise HTTPException(status_code=404, detail="playlist not found") - - await db_handler.get_collection("playlist_tracks").collection.delete_many({"playlist_id": playlist_id}) - - try: - import hashlib - root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) - covers_dir = os.path.join(root, "GenCovers") - if doc.get("cover_id"): - fk = hashlib.sha256(str(doc["cover_id"]).encode("utf-8")).hexdigest()[:16] - p = os.path.join(covers_dir, f"{fk}.png") - if os.path.exists(p): - os.remove(p) - fk_normal = hashlib.sha256(f"user-playlist-normal:{playlist_id}".encode("utf-8")).hexdigest()[:16] - pn = os.path.join(covers_dir, f"{fk_normal}.png") - if os.path.exists(pn): - os.remove(pn) - except Exception: - pass - - return {"ok": True} - - -@router.post("/playlists/{playlist_id}/tracks") -async def add_track_to_playlist( - playlist_id: str, - payload: PlaylistTrackAdd, - user_id: int = Depends(require_user_id), -): - t_ids = [] - if isinstance(payload.track_id, list): - t_ids.extend(payload.track_id) - elif payload.track_id: - t_ids.append(payload.track_id) - - if payload.track_ids: - t_ids.extend(payload.track_ids) - - track_ids = [] - for tid in t_ids: - tid_str = str(tid).strip() - if tid_str and tid_str not in track_ids: - track_ids.append(tid_str) - - if not track_ids: - raise HTTPException(status_code=400, detail="track_id or track_ids is required") - - await _get_playlist_or_404(playlist_id, user_id) - - tracks = await get_tracks_by_ids(track_ids) - if not tracks: - raise HTTPException(status_code=404, detail="tracks not found") - - valid_track_ids = {str(t.get("_id")) for t in tracks} - - tracks_col = db_handler.get_collection("playlist_tracks").collection - last = await tracks_col.find_one({"playlist_id": playlist_id}, {"position": 1}, sort=[("position", -1)]) - next_pos = int(last.get("position") or 0) + 1 if last else 1 - - now = time.time() - - upserted_count = 0 - for tid in track_ids: - if tid not in valid_track_ids: - continue - - res = await tracks_col.update_one( - {"playlist_id": playlist_id, "track_id": tid}, - { - "$setOnInsert": {"position": next_pos, "added_at": now}, - "$set": {"playlist_id": playlist_id, "track_id": tid} - }, - upsert=True - ) - if res.upserted_id is not None: - upserted_count += 1 - next_pos += 1 - - if len(track_ids) == 1: - return {"ok": True, "already_exists": upserted_count == 0} - - return {"ok": True, "added": upserted_count} - - -@router.delete("/playlists/{playlist_id}/tracks/{track_id}") -async def remove_track_from_playlist( - playlist_id: str, - track_id: str, - user_id: int = Depends(require_user_id), -): - track_id = (track_id or "").strip() - if not track_id: - raise HTTPException(status_code=400, detail="track_id is required") - - await _get_playlist_or_404(playlist_id, user_id) - - tracks_col = db_handler.get_collection("playlist_tracks").collection - res = await tracks_col.delete_one({"playlist_id": playlist_id, "track_id": track_id}) - return {"ok": True, "deleted": bool(getattr(res, "deleted_count", 0))} - - -@router.get("/playlists/{playlist_id}/tracks", response_model=PlaylistTracksResponse) -async def list_playlist_tracks( - playlist_id: str, - user_id: int = Depends(require_user_id), - page: int = Query(default=1, ge=1), - limit: int = Query(default=50, ge=1, le=200), -): - await _get_playlist_or_404(playlist_id, user_id) - - page = int(page) - per_page = int(limit) - skip = (page - 1) * per_page - - tracks_col = db_handler.get_collection("playlist_tracks").collection - query = {"playlist_id": playlist_id} - total = await tracks_col.count_documents(query) - cursor = ( - tracks_col.find(query, {"_id": 0, "track_id": 1}) - .sort([("position", 1)]) - .skip(skip) - .limit(per_page) - ) - - track_ids: list[str] = [] - async for doc in cursor: - tid = (doc.get("track_id") or "").strip() - if tid: - track_ids.append(tid) - - tracks = await get_tracks_by_ids(track_ids, user_id=user_id) - return PlaylistTracksResponse(page=page, per_page=per_page, total=total, items=tracks) - - -@router.post("/albums") -async def save_album(payload: UserAlbumAdd, user_id: int = Depends(require_user_id)): - a_ids: list[str] = [] - if isinstance(payload.album_id, list): - a_ids.extend(payload.album_id) - elif payload.album_id: - a_ids.append(payload.album_id) - if payload.album_ids: - a_ids.extend(payload.album_ids) - - album_ids: list[str] = [] - seen: set[str] = set() - for aid in a_ids: - s = str(aid or "").strip() - if not s or s in seen: - continue - seen.add(s) - album_ids.append(s) - - if not album_ids: - raise HTTPException(status_code=400, detail="album_id or album_ids is required") - - albums_col = db_handler.get_collection("albums").collection - existing = await albums_col.find_one({"_id": {"$in": album_ids}}, {"_id": 1}) - if not existing: - raise HTTPException(status_code=404, detail="albums not found") - - col = db_handler.get_collection("user_albums").collection - now = time.time() - added = 0 - for aid in album_ids: - res = await col.update_one( - {"user_id": int(user_id), "album_id": aid}, - {"$setOnInsert": {"user_id": int(user_id), "album_id": aid, "saved_at": now}, "$set": {"updated_at": now}}, - upsert=True, - ) - if getattr(res, "upserted_id", None) is not None: - added += 1 - - if len(album_ids) == 1: - return {"ok": True, "already_exists": added == 0} - return {"ok": True, "added": int(added)} - - -@router.get("/albums", response_model=UserAlbumsResponse) -async def list_saved_albums( - user_id: int = Depends(require_user_id), - page: int = Query(default=1, ge=1), - limit: int = Query(default=50, ge=1, le=200), -): - page = int(page) - per_page = int(limit) - skip = (page - 1) * per_page - - col = db_handler.get_collection("user_albums").collection - query = {"user_id": int(user_id)} - total = await col.count_documents(query) - if total <= 0: - return UserAlbumsResponse(page=page, per_page=per_page, total=0, items=[]) - - cursor = col.find(query, {"_id": 0, "album_id": 1, "saved_at": 1}).sort([("saved_at", -1)]).skip(skip).limit(per_page) - rows: list[dict] = [] - album_ids: list[str] = [] - async for r in cursor: - aid = (r.get("album_id") or "").strip() - if not aid: - continue - rows.append({"album_id": aid, "saved_at": r.get("saved_at")}) - album_ids.append(aid) - - if not album_ids: - return UserAlbumsResponse(page=page, per_page=per_page, total=total, items=[]) - - albums_col = db_handler.get_collection("albums").collection - projection = {"match_album": 0, "match_artist": 0} - acur = albums_col.find({"_id": {"$in": album_ids}}, projection) - by_id: dict[str, dict] = {} - async for a in acur: - aid = str(a.get("_id") or "").strip() - if not aid: - continue - a["_id"] = aid - if isinstance(a.get("cover_url"), str): - a["cover_url"] = _clean_url(a.get("cover_url")) - by_id[aid] = a - - items: list[UserAlbumItem] = [] - for r in rows: - aid = r["album_id"] - items.append(UserAlbumItem(album_id=aid, album=by_id.get(aid), saved_at=r.get("saved_at"))) - - return UserAlbumsResponse(page=page, per_page=per_page, total=int(total), items=items) diff --git a/Api/routers/presence.py b/Api/routers/presence.py deleted file mode 100644 index 0324f93..0000000 --- a/Api/routers/presence.py +++ /dev/null @@ -1,267 +0,0 @@ -import time -import json -import asyncio -from fastapi import APIRouter, WebSocket, WebSocketDisconnect -from typing import Dict, List -from Api.utils.auth import verify_auth_token -from stream.database.MongoDb import db_handler - -from stream.helpers.logger import LOGGER - -router = APIRouter(prefix="/ws", tags=["presence"]) - -async def broadcast_presence_to_friends(user_id: int, online: bool): - friends_col = db_handler.get_collection("friends").collection - friend_doc = await friends_col.find_one({"_id": user_id}) - if not friend_doc: - return - - friend_ids = friend_doc.get("friend_ids", []) - if not friend_ids: - return - - broadcast_msg = { - "type": "friend_presence_update", - "user_id": user_id, - "online": online, - "last_seen": time.time() - } - - LOGGER(__name__).debug(f"[WS] Broadcasting presence update ({online}) from {user_id} to {len(friend_ids)} friends.") - for f_id in friend_ids: - await manager.broadcast_to_user(f_id, broadcast_msg) - -async def send_initial_state(user_id: int, websocket: WebSocket): - try: - # 1. Get friends - friends_col = db_handler.get_collection("friends").collection - friend_doc = await friends_col.find_one({"_id": user_id}) - if not friend_doc: - return - - friend_ids = friend_doc.get("friend_ids", []) - if not friend_ids: - return - - # 2. Get friends' info (to check settings) - users_col = db_handler.get_collection("users").collection - friends_cursor = users_col.find({"_id": {"$in": friend_ids}}, {"settings": 1}) - - allowed_friends = [] - async for f in friends_cursor: - settings = f.get("settings", {}) - share = settings.get("share_listening", "friends") - if share in ["friends", "everyone"]: - allowed_friends.append(f["_id"]) - - # 3. Get presence - presence_col = db_handler.get_collection("presence").collection - p_cursor = presence_col.find({"user_id": {"$in": friend_ids}}) - presence_data = {} - async for p in p_cursor: - presence_data[str(p["user_id"])] = { - "online": p.get("online", False), - "last_seen": p.get("last_seen"), - "device": p.get("device") - } - - # 4. Get listening status - listening_col = db_handler.get_collection("listeningStatus").collection - l_cursor = listening_col.find({"user_id": {"$in": allowed_friends}}) - listening_data = {} - async for l in l_cursor: - listening_data[str(l["user_id"])] = { - "track_id": l.get("track_id"), - "is_playing": l.get("is_playing"), - "position_sec": l.get("position_sec"), - "jam_id": l.get("jam_id"), - "updated_at": l.get("updated_at") - } - - initial_state = { - "type": "initial_state", - "presence": presence_data, - "listening": listening_data - } - - await websocket.send_json(initial_state) - except Exception as e: - LOGGER(__name__).error(f"[WS] Failed to send initial state to {user_id}: {e}") - -class ConnectionManager: - def __init__(self): - self.active_connections: Dict[int, List[WebSocket]] = {} - - async def connect(self, websocket: WebSocket, user_id: int): - await websocket.accept() - is_first_session = user_id not in self.active_connections - if is_first_session: - self.active_connections[user_id] = [] - self.active_connections[user_id].append(websocket) - - LOGGER(__name__).info(f"[WS] User {user_id} connected. Total active sessions: {len(self.active_connections[user_id])}") - - # Mark user as online - presence_col = db_handler.get_collection("presence").collection - await presence_col.update_one( - {"user_id": user_id}, - {"$set": {"last_seen": time.time(), "online": True}}, - upsert=True - ) - - await send_initial_state(user_id, websocket) - - if is_first_session: - await broadcast_presence_to_friends(user_id, True) - - def disconnect(self, websocket: WebSocket, user_id: int): - if user_id in self.active_connections: - if websocket in self.active_connections[user_id]: - self.active_connections[user_id].remove(websocket) - LOGGER(__name__).info(f"[WS] User {user_id} session closed. Remaining sessions: {len(self.active_connections[user_id])}") - if not self.active_connections[user_id]: - del self.active_connections[user_id] - LOGGER(__name__).info(f"[WS] User {user_id} fully disconnected.") - - async def broadcast_to_user(self, user_id: int, message: dict): - if user_id in self.active_connections: - disconnected = [] - for connection in self.active_connections[user_id]: - try: - await connection.send_json(message) - except Exception as e: - LOGGER(__name__).error(f"[WS] Broadcast failed to user {user_id}: {e}") - disconnected.append(connection) - for c in disconnected: - self.disconnect(c, user_id) - -manager = ConnectionManager() - -async def broadcast_to_friends(user_id: int, message: dict): - friends_col = db_handler.get_collection("friends").collection - friend_doc = await friends_col.find_one({"_id": user_id}) - if not friend_doc: - return - friend_ids = friend_doc.get("friend_ids", []) - if not friend_ids: - return - for f_id in friend_ids: - await manager.broadcast_to_user(f_id, message) - -async def broadcast_listening_to_friends(user_id: int, update_data: dict): - users_col = db_handler.get_collection("users").collection - user = await users_col.find_one({"_id": user_id}, {"settings": 1}) - if not user: - return - settings = user.get("settings", {}) - share = settings.get("share_listening", "friends") - if share == "nobody": - return - - friends_col = db_handler.get_collection("friends").collection - friend_doc = await friends_col.find_one({"_id": user_id}) - if not friend_doc: - return - - friend_ids = friend_doc.get("friend_ids", []) - if not friend_ids: - return - - broadcast_msg = { - "type": "friend_listening_update", - "user_id": user_id, - "track_id": update_data.get("track_id"), - "is_playing": update_data.get("is_playing", True), - "position_sec": update_data.get("position_sec", 0), - "jam_id": update_data.get("jam_id"), - "updated_at": update_data.get("updated_at") - } - - LOGGER(__name__).debug(f"[WS] Broadcasting listening update from {user_id} to {len(friend_ids)} friends.") - for f_id in friend_ids: - await manager.broadcast_to_user(f_id, broadcast_msg) - - -@router.websocket("/{token}") -async def websocket_endpoint(websocket: WebSocket, token: str): - try: - payload = verify_auth_token(token) - uid_raw = payload.get("uid") - if isinstance(uid_raw, str) and uid_raw == "__api__": - user_id = 0 - else: - user_id = int(uid_raw) - except Exception as e: - LOGGER(__name__).warning(f"[WS] Auth failed for token: {e}") - await websocket.close(code=1008) - return - - # Guests (0) are not permitted to use WebSockets for presence or state management. - if user_id == 0: - await websocket.close(code=1008, reason="Guest browsing tokens cannot use WebSockets") - return - - await manager.connect(websocket, user_id) - try: - while True: - data = await websocket.receive_text() - LOGGER(__name__).debug(f"[WS] Raw message from {user_id}: {data}") - try: - msg = json.loads(data) - msg_type = msg.get("type") - - if msg_type == "heartbeat": - LOGGER(__name__).debug(f"[WS] Heartbeat from {user_id} ({msg.get('device', 'unknown')})") - presence_col = db_handler.get_collection("presence").collection - await presence_col.update_one( - {"user_id": user_id}, - {"$set": {"last_seen": time.time(), "online": True, "device": msg.get("device", "unknown")}}, - upsert=True - ) - - elif msg_type == "listening_update": - LOGGER(__name__).info(f"[WS] Listening update from {user_id}: track={msg.get('track_id')} playing={msg.get('is_playing')}") - list_col = db_handler.get_collection("listeningStatus").collection - update_data = { - "track_id": msg.get("track_id"), - "started_at": time.time(), - "is_playing": msg.get("is_playing", True), - "position_sec": msg.get("position_sec", 0), - "jam_id": msg.get("jam_id"), - "updated_at": time.time() - } - await list_col.update_one( - {"user_id": user_id}, - {"$set": update_data}, - upsert=True - ) - - # Broadcast the new status to connected friends! - await broadcast_listening_to_friends(user_id, update_data) - - else: - LOGGER(__name__).warning(f"[WS] Unknown message type from {user_id}: {msg_type}") - - except json.JSONDecodeError: - LOGGER(__name__).warning(f"[WS] Invalid JSON from {user_id}: {data}") - except WebSocketDisconnect: - LOGGER(__name__).info(f"[WS] WebSocketDisconnect for user {user_id}") - manager.disconnect(websocket, user_id) - asyncio.create_task(mark_offline_if_not_reconnected(user_id)) - except Exception as e: - LOGGER(__name__).error(f"[WS] Unexpected error for user {user_id}: {e}") - manager.disconnect(websocket, user_id) - -async def mark_offline_if_not_reconnected(user_id: int): - await asyncio.sleep(5) - if user_id not in manager.active_connections: - LOGGER(__name__).info(f"[WS] User {user_id} still disconnected after grace period. Marking offline.") - presence_col = db_handler.get_collection("presence").collection - await presence_col.update_one( - {"user_id": user_id}, - {"$set": {"online": False, "last_seen": time.time()}} - ) - # Notify friends that user is now offline - await broadcast_presence_to_friends(user_id, False) - else: - LOGGER(__name__).info(f"[WS] User {user_id} reconnected within grace period. Staying online.") diff --git a/Api/routers/recaps.py b/Api/routers/recaps.py deleted file mode 100644 index 3bb2694..0000000 --- a/Api/routers/recaps.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Recaps — listening events, period snapshots and public share links.""" -from __future__ import annotations - -from typing import Any, Literal, Optional - -from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel, Field - -from Api.services import recap_service as svc -from Api.utils.auth import require_user_id - -router = APIRouter(tags=["recaps"]) - -PeriodType = Literal["weekly", "monthly", "yearly"] - - -class ListeningEventIn(BaseModel): - id: str = Field(min_length=1, max_length=64) - track_id: str = Field(min_length=1, max_length=128) - played_at: float - started_at: Optional[float] = None - played_ms: int = Field(ge=0) - duration_ms: int = Field(default=0, ge=0) - completed: bool = False - skipped: bool = False - source: str = "server" - session_id: Optional[str] = None - - -class ListeningEventsBatch(BaseModel): - events: list[ListeningEventIn] = Field(max_length=200) - - -@router.post("/me/listening-events") -async def post_listening_events(payload: ListeningEventsBatch, user_id: int = Depends(require_user_id)): - stored = await svc.record_events(user_id, [e.model_dump() for e in payload.events]) - return {"ok": True, "received": len(payload.events), "stored": stored} - - -@router.get("/me/recaps") -async def list_recaps(tz: int = Query(default=0, ge=-840, le=840), user_id: int = Depends(require_user_id)): - return {"items": await svc.list_available(user_id, tz)} - - -@router.get("/me/recaps/shares") -async def my_shares(user_id: int = Depends(require_user_id)): - return {"items": await svc.list_shares(user_id)} - - -@router.delete("/me/recaps/shares/{token}") -async def revoke(token: str, user_id: int = Depends(require_user_id)): - ok = await svc.revoke_share(user_id, token) - if not ok: - raise HTTPException(status_code=404, detail="Share link not found") - return {"ok": True} - - -@router.delete("/me/recaps/data") -async def delete_data(user_id: int = Depends(require_user_id)): - return {"ok": True, **(await svc.delete_user_recap_data(user_id))} - - -@router.get("/me/recaps/{ptype}/{period}") -async def get_recap( - ptype: PeriodType, - period: str, - tz: int = Query(default=0, ge=-840, le=840), - refresh: bool = Query(default=False), - user_id: int = Depends(require_user_id), -): - if not svc.is_period_available(ptype, period, tz): - raise HTTPException( - status_code=400, - detail="This recap is not ready yet. Weekly recaps unlock on the weekend (Sunday), monthly on the last day of the month, and yearly on Dec 31st.", - ) - try: - return await svc.get_snapshot(user_id, ptype, period, tz, force=refresh) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - -@router.post("/me/recaps/{ptype}/{period}/share") -async def share_recap(ptype: PeriodType, period: str, tz: int = Query(default=0, ge=-840, le=840), user_id: int = Depends(require_user_id)): - if not svc.is_period_available(ptype, period, tz): - raise HTTPException(status_code=400, detail="This recap is not ready to share yet.") - try: - snap = await svc.get_snapshot(user_id, ptype, period, tz) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - if snap["stats"]["totalPlays"] == 0: - raise HTTPException(status_code=400, detail="Nothing to share for this period yet") - return await svc.create_share(user_id, snap) - - -@router.get("/recaps/share/{token}") -async def public_share(token: str) -> dict[str, Any]: - summary = await svc.get_public_share(token) - if not summary: - raise HTTPException(status_code=404, detail="This recap link is no longer available") - return summary diff --git a/Api/routers/share.py b/Api/routers/share.py deleted file mode 100644 index 8ed8836..0000000 --- a/Api/routers/share.py +++ /dev/null @@ -1,70 +0,0 @@ -from typing import Optional -from fastapi import APIRouter, Depends, HTTPException -from Api.schemas.playlists import PlaylistShareResponse -from Api.services.track_service import get_tracks_by_ids -from Api.utils.auth import get_optional_user_id -from stream.database.MongoDb import db_handler - -router = APIRouter(prefix="/share", tags=["share"]) - -def _track_thumbnail_url(track: dict) -> str: - spotify = track.get("spotify") if isinstance(track.get("spotify"), dict) else {} - telegram = track.get("telegram") if isinstance(track.get("telegram"), dict) else {} - audio = track.get("audio") if isinstance(track.get("audio"), dict) else {} - - candidates = [ - spotify.get("cover_url"), - spotify.get("cover"), - spotify.get("thumbnail"), - telegram.get("thumb_url"), - telegram.get("thumbnail_url"), - telegram.get("thumb"), - telegram.get("thumbnail"), - audio.get("cover_url"), - audio.get("thumbnail"), - ] - for c in candidates: - if isinstance(c, str) and c.strip(): - return c.strip() - return "" - -@router.get("/playlists/{playlist_id}", response_model=PlaylistShareResponse) -async def get_shared_playlist(playlist_id: str, user_id: Optional[int] = Depends(get_optional_user_id)): - col = db_handler.get_collection("user_playlists").collection - playlist = await col.find_one({"_id": playlist_id}) - if not playlist: - raise HTTPException(status_code=404, detail="playlist not found") - - tracks_col = db_handler.get_collection("playlist_tracks").collection - cursor = ( - tracks_col.find({"playlist_id": playlist_id}, {"_id": 0, "track_id": 1}) - .sort([("position", 1)]) - ) - - track_ids = [] - async for doc in cursor: - tid = (doc.get("track_id") or "").strip() - if tid: - track_ids.append(tid) - - tracks = await get_tracks_by_ids(track_ids, user_id=user_id) - - track_thumbs = [] - for t in tracks: - url = _track_thumbnail_url(t) - if url: - track_thumbs.append(url) - if len(track_thumbs) >= 4: - break - - name = str(playlist.get("name") or "Playlist") - normal_thumbnail = playlist.get("normal_thumbnail") - - return PlaylistShareResponse( - playlist_id=str(playlist.get("_id")), - name=name, - cover_url=playlist.get("cover_url"), - normal_thumbnail=normal_thumbnail, - tracks=tracks, - created_at=playlist.get("created_at"), - ) diff --git a/Api/routers/soundcloud.py b/Api/routers/soundcloud.py deleted file mode 100644 index 45ae8bb..0000000 --- a/Api/routers/soundcloud.py +++ /dev/null @@ -1,261 +0,0 @@ -import re -import time -from typing import Any - -import httpx -from fastapi import APIRouter, HTTPException, Query -from pydantic import BaseModel - -from stream.core.config_manager import Config - - -router = APIRouter(prefix="", tags=["soundcloud"]) - -_SC_BASE_URL = "https://soundcloud.com" -_SC_SEARCH_API = "https://api-v2.soundcloud.com/search/tracks" -_SC_TRACK_API = "https://api-v2.soundcloud.com/tracks/{track_id}" - -_SC_HEADERS = { - "User-Agent": "Mozilla/5.0", - "Accept": "application/json", - "Referer": "https://soundcloud.com/", - "Origin": "https://soundcloud.com", -} - -_SC_CLIENT_ID: str | None = None -_SC_CLIENT_ID_TS: float = 0.0 - - -class SoundCloudTrackItem(BaseModel): - id: int - title: str - username: str | None = None - permalink_url: str | None = None - duration_ms: int | None = None - duration_sec: int | None = None - artwork_url: str | None = None - streamable: bool | None = None - policy: str | None = None - hls_api_url: str | None = None - hls_is_preview: bool | None = None - - -class SoundCloudSearchResponse(BaseModel): - ok: bool = True - q: str - page: int - per_page: int - items: list[SoundCloudTrackItem] - - -class SoundCloudStreamResponse(BaseModel): - ok: bool = True - id: int - hls_url: str - is_preview: bool | None = None - - -async def _get_sc_client_id(client: httpx.AsyncClient) -> str: - global _SC_CLIENT_ID, _SC_CLIENT_ID_TS - now = time.time() - ttl = float(getattr(Config, "SOUNDCLOUD_CLIENT_ID_TTL", 3600) or 3600) - if _SC_CLIENT_ID and now - _SC_CLIENT_ID_TS < ttl: - return _SC_CLIENT_ID - - resp = await client.get(_SC_BASE_URL) - html = resp.text - js_urls = re.findall(r'src="(https://a-v2\.sndcdn\.com/assets/.*?\.js)"', html) - client_id: str | None = None - for js_url in js_urls: - r2 = await client.get(js_url) - js = r2.text - m = re.search(r'client_id\s*:\s*"([a-zA-Z0-9]+)"', js) - if m: - client_id = m.group(1) - break - if not client_id: - raise HTTPException(status_code=502, detail="Unable to resolve SoundCloud client_id") - _SC_CLIENT_ID = client_id - _SC_CLIENT_ID_TS = now - return client_id - - -def _pick_hls_transcoding(track: dict[str, Any]) -> dict[str, Any] | None: - media = track.get("media") if isinstance(track.get("media"), dict) else {} - transcodings = media.get("transcodings") if isinstance(media.get("transcodings"), list) else [] - for tr in transcodings: - if not isinstance(tr, dict): - continue - fmt = tr.get("format") if isinstance(tr.get("format"), dict) else {} - if fmt.get("protocol") == "hls": - return tr - return None - - -def _parse_sc_track(track: dict[str, Any]) -> SoundCloudTrackItem | None: - try: - tid = int(track.get("id")) - except Exception: - return None - title = str(track.get("title") or "").strip() - if not title: - return None - user = track.get("user") if isinstance(track.get("user"), dict) else {} - username = str(user.get("username")).strip() if isinstance(user.get("username"), str) else None - permalink_url = str(track.get("permalink_url")).strip() if isinstance(track.get("permalink_url"), str) else None - - dur_ms = None - for key in ("full_duration", "duration"): - v = track.get(key) - if isinstance(v, (int, float)) and v > 0: - dur_ms = int(v) - break - if dur_ms is None: - try: - v = int(track.get("duration")) - if v > 0: - dur_ms = v - except Exception: - dur_ms = None - dur_sec = int(dur_ms / 1000) if dur_ms is not None else None - - artwork_url = str(track.get("artwork_url")).strip() if isinstance(track.get("artwork_url"), str) else None - streamable = bool(track.get("streamable")) - policy = str(track.get("policy")).strip() if isinstance(track.get("policy"), str) else None - - hls_api_url: str | None = None - hls_is_preview: bool | None = None - tr = _pick_hls_transcoding(track) - if tr: - u = tr.get("url") - if isinstance(u, str) and u.strip(): - hls_api_url = u.strip() - hls_is_preview = bool(tr.get("snipped")) or ("preview" in hls_api_url if hls_api_url else False) - - return SoundCloudTrackItem( - id=tid, - title=title, - username=username, - permalink_url=permalink_url, - duration_ms=dur_ms, - duration_sec=dur_sec, - artwork_url=artwork_url, - streamable=streamable, - policy=policy, - hls_api_url=hls_api_url, - hls_is_preview=hls_is_preview, - ) - - -@router.get("/soundcloud/search", response_model=SoundCloudSearchResponse) -async def soundcloud_search( - q: str = Query(min_length=1, max_length=200), - page: int = Query(default=1, ge=1), - limit: int = Query(default=10, ge=1, le=50), -): - term = q.strip() - if not term: - raise HTTPException(status_code=400, detail="q is required") - page = int(page) - limit = int(limit) - offset = (page - 1) * limit - - async with httpx.AsyncClient(headers=_SC_HEADERS, timeout=15.0, follow_redirects=True) as client: - client_id = await _get_sc_client_id(client) - params = { - "q": term, - "client_id": client_id, - "limit": limit, - "offset": offset, - } - resp = await client.get(_SC_SEARCH_API, params=params) - if resp.status_code != 200: - try: - text = resp.text - except Exception: - text = "" - raise HTTPException(status_code=resp.status_code, detail=f"SoundCloud search failed: {text[:200]}") - data = resp.json() - - coll = data.get("collection") if isinstance(data, dict) else None - items: list[SoundCloudTrackItem] = [] - if isinstance(coll, list): - for raw in coll: - if not isinstance(raw, dict): - continue - parsed = _parse_sc_track(raw) - if parsed: - items.append(parsed) - - return SoundCloudSearchResponse(ok=True, q=term, page=page, per_page=limit, items=items) - - -@router.get("/soundcloud/tracks/{track_id}", response_model=SoundCloudTrackItem) -async def soundcloud_track(track_id: int): - async with httpx.AsyncClient(headers=_SC_HEADERS, timeout=15.0, follow_redirects=True) as client: - client_id = await _get_sc_client_id(client) - url = _SC_TRACK_API.format(track_id=int(track_id)) - resp = await client.get(url, params={"client_id": client_id}) - if resp.status_code == 404: - raise HTTPException(status_code=404, detail="Track not found") - if resp.status_code != 200: - try: - text = resp.text - except Exception: - text = "" - raise HTTPException(status_code=resp.status_code, detail=f"SoundCloud track lookup failed: {text[:200]}") - data = resp.json() - - if not isinstance(data, dict): - raise HTTPException(status_code=502, detail="Unexpected SoundCloud track payload") - parsed = _parse_sc_track(data) - if not parsed: - raise HTTPException(status_code=502, detail="Unable to parse SoundCloud track") - return parsed - - -@router.get("/soundcloud/tracks/{track_id}/hls", response_model=SoundCloudStreamResponse) -async def soundcloud_track_hls(track_id: int): - async with httpx.AsyncClient(headers=_SC_HEADERS, timeout=15.0, follow_redirects=True) as client: - client_id = await _get_sc_client_id(client) - url = _SC_TRACK_API.format(track_id=int(track_id)) - resp = await client.get(url, params={"client_id": client_id}) - if resp.status_code == 404: - raise HTTPException(status_code=404, detail="Track not found") - if resp.status_code != 200: - try: - text = resp.text - except Exception: - text = "" - raise HTTPException(status_code=resp.status_code, detail=f"SoundCloud track lookup failed: {text[:200]}") - data = resp.json() - - if not isinstance(data, dict): - raise HTTPException(status_code=502, detail="Unexpected SoundCloud track payload") - - hls_tr = _pick_hls_transcoding(data) - if not hls_tr: - raise HTTPException(status_code=404, detail="No HLS transcoding available for this track") - - hls_api_url = hls_tr.get("url") - if not isinstance(hls_api_url, str) or not hls_api_url.strip(): - raise HTTPException(status_code=502, detail="Invalid HLS transcoding URL") - - is_preview = bool(hls_tr.get("snipped")) or ("preview" in hls_api_url) - - r2 = await client.get(hls_api_url.strip(), params={"client_id": client_id}) - if r2.status_code != 200: - try: - text2 = r2.text - except Exception: - text2 = "" - raise HTTPException(status_code=r2.status_code, detail=f"SoundCloud HLS manifest failed: {text2[:200]}") - - data2 = r2.json() - if not isinstance(data2, dict) or not isinstance(data2.get("url"), str) or not data2.get("url").strip(): - raise HTTPException(status_code=502, detail="Unexpected HLS manifest payload") - - hls_url = data2.get("url").strip() - - return SoundCloudStreamResponse(ok=True, id=int(track_id), hls_url=hls_url, is_preview=is_preview) - diff --git a/Api/routers/sources.py b/Api/routers/sources.py deleted file mode 100644 index deac48f..0000000 --- a/Api/routers/sources.py +++ /dev/null @@ -1,185 +0,0 @@ -import time -from typing import Literal, Optional - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field - -from Api.utils.auth import require_admin_user_id -from stream.core.config_manager import Config -from stream.core.source_filter import ( - FilterMode, - add_allowed_source, - ban_source, - get_all_allowed_sources, - get_all_banned_sources, - get_filter_mode, - invalidate_cache, - remove_allowed_source, - unban_source, -) - -router = APIRouter(prefix="/admin/sources", tags=["admin", "sources"]) - - -class SetFilterModeRequest(BaseModel): - mode: str | int = Field( - ..., - description="Filter mode: 0/'group_only', 1/'anyone', 2/'hybrid'", - ) - - -class AddAllowedSourceRequest(BaseModel): - source_id: int | str = Field( - ..., - description="Channel, group, or user ID (e.g. -100123456789 or 123456789)", - ) - source_type: Literal["channel", "group", "user"] = "channel" - name: str = "" - - -class BanSourceRequest(BaseModel): - source_id: int | str = Field( - ..., - description="Channel, group, or user ID to ban", - ) - source_type: Literal["channel", "group", "user"] = "channel" - name: str = "" - reason: str = "" - - -@router.get("") -async def get_sources_overview(admin_id: int = Depends(require_admin_user_id)): - """Get complete overview of filter mode, allowed sources, and banned sources.""" - mode = get_filter_mode() - allowed = await get_all_allowed_sources() - banned = await get_all_banned_sources() - channel_id = getattr(Config, "CHANNEL_ID", 0) - collaborators = getattr(Config, "COLLABORATOR_IDS", []) or [] - - return { - "ok": True, - "filter_mode": mode, - "filter_mode_name": FilterMode.to_string(mode), - "channel_id": channel_id, - "collaborator_ids": collaborators, - "allowed_sources_count": len(allowed), - "banned_sources_count": len(banned), - "allowed_sources": allowed, - "banned_sources": banned, - } - - -@router.get("/mode") -async def get_current_filter_mode(admin_id: int = Depends(require_admin_user_id)): - """Get the current filter mode.""" - mode = get_filter_mode() - return { - "ok": True, - "filter_mode": mode, - "filter_mode_name": FilterMode.to_string(mode), - } - - -@router.post("/mode") -async def set_filter_mode( - req: SetFilterModeRequest, - admin_id: int = Depends(require_admin_user_id), -): - """Update the filter mode (0: group_only, 1: anyone, 2: hybrid).""" - parsed_mode = FilterMode.parse(req.mode) - await Config.update_config("FILTER_MODE", parsed_mode) - invalidate_cache() - return { - "ok": True, - "filter_mode": parsed_mode, - "filter_mode_name": FilterMode.to_string(parsed_mode), - } - - -@router.get("/allowed") -async def list_allowed_sources(admin_id: int = Depends(require_admin_user_id)): - """List all allowed contributor channels/groups/users.""" - sources = await get_all_allowed_sources() - return {"ok": True, "sources": sources, "count": len(sources)} - - -@router.post("/allowed") -async def create_allowed_source( - req: AddAllowedSourceRequest, - admin_id: int = Depends(require_admin_user_id), -): - """Add a channel, group, or user to allowed contributors.""" - try: - doc = await add_allowed_source( - source_id=req.source_id, - source_type=req.source_type, - name=req.name, - added_by=admin_id, - ) - return {"ok": True, "source": doc} - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to add allowed source: {e}") - - -@router.delete("/allowed/{source_id}") -async def delete_allowed_source( - source_id: str, - admin_id: int = Depends(require_admin_user_id), -): - """Remove a source from allowed contributors.""" - try: - sid = int(source_id) - except (ValueError, TypeError): - raise HTTPException(status_code=400, detail="Invalid source_id") - - removed = await remove_allowed_source(sid) - return {"ok": True, "source_id": sid, "removed": removed} - - -@router.get("/banned") -async def list_banned_sources(admin_id: int = Depends(require_admin_user_id)): - """List all banned channels/groups/users.""" - sources = await get_all_banned_sources() - return {"ok": True, "sources": sources, "count": len(sources)} - - -@router.post("/banned") -async def create_banned_source( - req: BanSourceRequest, - admin_id: int = Depends(require_admin_user_id), -): - """Ban a channel, group, or user from contributing or accessing the service.""" - try: - doc = await ban_source( - source_id=req.source_id, - source_type=req.source_type, - name=req.name, - reason=req.reason, - banned_by=admin_id, - ) - return {"ok": True, "source": doc} - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to ban source: {e}") - - -@router.delete("/banned/{source_id}") -async def delete_banned_source( - source_id: str, - admin_id: int = Depends(require_admin_user_id), -): - """Unban a channel, group, or user. - - If the source was previously in the allowed sources collection, - it immediately regains allowed status without needing to be re-added. - """ - try: - sid = int(source_id) - except (ValueError, TypeError): - raise HTTPException(status_code=400, detail="Invalid source_id") - - removed = await unban_source(sid) - return {"ok": True, "source_id": sid, "removed": removed} diff --git a/Api/routers/test.py b/Api/routers/test.py deleted file mode 100644 index 49edf5c..0000000 --- a/Api/routers/test.py +++ /dev/null @@ -1,218 +0,0 @@ -from fastapi import APIRouter -from fastapi.responses import HTMLResponse -from pydantic import BaseModel -import firebase_admin -from firebase_admin import messaging - -router = APIRouter() - - -@router.get("/test") -async def test(): - return {"status": "ok"} - - -class FCMTestRequest(BaseModel): - token: str - title: str = "Test Notification" - body: str = "This is a test notification from the backend." - -@router.post("/test-fcm") -async def test_fcm_notification(req: FCMTestRequest): - try: - from Api.utils.firebase import send_push_notification - response = send_push_notification(req.token, req.title, req.body) - if response: - return {"status": "success", "message_id": response} - else: - return {"status": "error", "detail": "Failed to send push notification check logs."} - except Exception as e: - return {"status": "error", "detail": str(e)} - - -@router.get("/test-stream", response_class=HTMLResponse) -async def test_stream(): - html = """ - - - - - Stream Test - - - - - -

/test-stream

- -
- - - -
- -
- Direct streaming from /tracks/<track_id>/stream -
Range requests supported
-
- - - -
-
- - - - -""" - return HTMLResponse(content=html) diff --git a/Api/routers/topics.py b/Api/routers/topics.py deleted file mode 100644 index e1410d0..0000000 --- a/Api/routers/topics.py +++ /dev/null @@ -1,50 +0,0 @@ -import urllib.parse -from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query - -from Api.schemas.browse import BrowseResponse -from Api.schemas.topics import TopicsResponse -from Api.services.track_service import browse_topic_tracks, get_unique_topics -from Api.utils.auth import get_optional_user_id - -router = APIRouter(tags=["topics"]) - - -@router.get("/topics", response_model=TopicsResponse) -async def list_topics( - channel_id: Optional[int] = None, - limit: int = Query(default=100, ge=1, le=500), - refresh: bool = Query(default=False), -): - """ - Get all unique topic names with track counts, latest cover art, and endpoints. - Optimized with MongoDB index-covered aggregation and in-memory TTL caching. - """ - return await get_unique_topics(channel_id=channel_id, limit=limit, refresh=refresh) - - -@router.get("/topics/{topic_name:path}/tracks", response_model=BrowseResponse) -async def get_topic_tracks( - topic_name: str, - channel_id: Optional[int] = None, - page: int = Query(default=1, ge=1), - per_page: int = Query(default=20, ge=1, le=100), - limit: Optional[int] = Query(default=None, ge=1, le=100), - user_id: Optional[int] = Depends(get_optional_user_id), -): - """ - Browse tracks belonging to a unique topic_name. - Optimized with compound index queries, lean projections, parallel count+find, and batch liked checks. - """ - clean_topic = urllib.parse.unquote(topic_name or "").strip() - if not clean_topic: - raise HTTPException(status_code=400, detail="topic_name is required") - - effective_per_page = limit if limit is not None else per_page - return await browse_topic_tracks( - topic_name=clean_topic, - channel_id=channel_id, - page=page, - per_page=effective_per_page, - user_id=user_id, - ) diff --git a/Api/routers/tracks.py b/Api/routers/tracks.py deleted file mode 100644 index 0ee668d..0000000 --- a/Api/routers/tracks.py +++ /dev/null @@ -1,1858 +0,0 @@ -import datetime -import hashlib -import time -import re -import json -import asyncio -import difflib -from typing import Any, Optional -from urllib.parse import urlparse -import unicodedata - -from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request -from fastapi.responses import PlainTextResponse -from pydantic import BaseModel -import httpx -from bs4 import BeautifulSoup - -from Api.deps.db import get_audio_tracks_collection -from Api.schemas.browse import BrowseResponse -from Api.schemas.playlists import AvailablePlaylistItem, AvailablePlaylistsResponse -from Api.schemas.track import TrackResponse -from Api.services.lyrics_service import get_track_lyrics -from Api.services.stream_service import download_track, stream_track, warm_track_cached -from Api.services.track_service import ( - attach_liked_to_dicts, - get_daily_playlist, - get_daily_playlist_thumbnail_info, - get_track_by_id, - get_user_top_played_thumbnail_info, - random_tracks, - search_tracks, -) -from Api.utils.auth import get_optional_user_id, require_admin_user_id, require_user_id, verify_auth_token -from stream.core.config_manager import Config -from stream.database.MongoDb import db_handler - -router = APIRouter() - -from pymongo import UpdateOne - -_ALBUM_SLUG_RE = re.compile(r"[^a-z0-9]+", flags=re.I) -_ALBUMS_REFRESH_LOCK = asyncio.Lock() -_ARTISTS_REFRESH_LOCK = asyncio.Lock() -_ARTISTS_LAST_REFRESH_AT: float = 0.0 -_ARTISTS_REFRESH_COOLDOWN_SEC: float = 300.0 - - -def _normalize_album_id_part(text: str) -> str: - raw = (text or "").strip().lower() - if not raw: - return "" - s = raw.replace("÷", " divide ").replace("&", " and ").replace("+", " plus ") - s = unicodedata.normalize("NFKD", s) - s = s.encode("ascii", "ignore").decode("ascii") - s = re.sub(r"[^a-z0-9 ]", " ", s) - s = re.sub(r"\s+", "_", s.strip()) - s = re.sub(r"_+", "_", s).strip("_") - if s: - return s - h = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16] - return f"u_{h}" - - -def _slugify(value: str) -> str: - return _normalize_album_id_part(value) - - -def _coerce_year(value: Any) -> int | None: - if value is None or isinstance(value, bool): - return None - try: - y = int(value) - except Exception: - return None - if y < 1000 or y > 2100: - return None - return y - - -def _album_id(*, album: str, year: Any = None) -> str: - b = _normalize_album_id_part(album) - if not b: - return "" - y = _coerce_year(year) - if y is not None: - return f"album_{b}_{y}" - return f"album_{b}" - - -def _artist_id(name: str) -> str: - s = _slugify(name) - if not s: - return "" - return f"artist_{s}" - - -_ARTIST_SPLIT_RE = re.compile(r"\s*(?:,|/|&| and | x | feat\. | feat | ft\. | ft )\s*", flags=re.I) - - -def _split_artists(value: str) -> list[str]: - raw = (value or "").strip() - if not raw: - return [] - raw = raw.replace("(", " ").replace(")", " ").replace("[", " ").replace("]", " ").strip() - parts = [p.strip() for p in _ARTIST_SPLIT_RE.split(raw) if p and p.strip()] - out: list[str] = [] - seen: set[str] = set() - for p in parts: - key = p.casefold() - if key in seen: - continue - seen.add(key) - out.append(p) - return out - - -def _clean_url(value: Any) -> str: - if not isinstance(value, str): - return "" - s = value.strip() - if len(s) >= 2 and s[0] == "`" and s[-1] == "`": - s = s[1:-1].strip() - return s - - -class ChannelItem(BaseModel): - id: int - title: str | None = None - username: str | None = None - type: str | None = None - - -class ChannelIdsResponse(BaseModel): - ok: bool = True - items: list[ChannelItem] - - -_APPLE_SIZE_RE = re.compile(r"/(\d+)x(\d+)(bb)?\.(jpg|jpeg|png|webp)$", flags=re.I) -_ITUNES_M3U8_RE = re.compile(r"(https://mvod\.itunes\.apple\.com/itunes-assets/[^\\s\"']+?/)(P\\d+)_default\\.m3u8", flags=re.I) - - -def _resize_apple_image_url(url: Any, *, size: int = 618) -> str | None: - s = _clean_url(url) - if not s: - return None - try: - size = int(size) - except Exception: - size = 618 - if size <= 0: - size = 618 - - base = s - suffix = "" - for sep in ("?", "#"): - if sep in base: - base, rest = base.split(sep, 1) - suffix = sep + rest - break - - m = _APPLE_SIZE_RE.search(base) - if not m: - return s - bb = m.group(3) or "" - ext = m.group(4) - replaced = _APPLE_SIZE_RE.sub(f"/{size}x{size}{bb}.{ext}", base) - return replaced + suffix - - -def _itunes_mp4_from_m3u8(url: Any) -> str | None: - s = _clean_url(url) - if not s: - return None - m = _ITUNES_M3U8_RE.search(s) - if not m: - return None - base = m.group(1) - pid = m.group(2) - return f"{base}{pid}_Anull_video_gr697_sdr_3840x2160-.mp4" - - -def _am_artwork_url(artwork: Any, *, size: int = 1000) -> str | None: - if not isinstance(artwork, dict): - return None - u = artwork.get("url") - if not isinstance(u, str) or not u.strip(): - return None - url = u.strip() - url = url.replace("{w}", str(int(size))).replace("{h}", str(int(size))) - return url - - -def _pick_first_str(value: Any) -> str | None: - if isinstance(value, str) and value.strip(): - return value.strip() - if isinstance(value, dict): - for k in ("name", "value", "text", "title", "label", "displayName", "display_name"): - v = value.get(k) - if isinstance(v, str) and v.strip(): - return v.strip() - if isinstance(value, list): - for v in value: - if isinstance(v, str) and v.strip(): - return v.strip() - if isinstance(v, dict): - picked = _pick_first_str(v) - if picked: - return picked - return None - - -def _shazam_artist_slug(name: str) -> str: - return _slugify(name).replace("_", "-") - - -def _norm_artist_query(value: str) -> str: - s = (value or "").strip().lower() - s = re.sub(r"[^a-z0-9\s]+", " ", s) - s = re.sub(r"\s+", " ", s).strip() - return s - - -def _artist_query_score(*, q: str, name: str) -> float: - qn = _norm_artist_query(q) - nn = _norm_artist_query(name) - if not qn or not nn: - return 0.0 - if qn == nn: - return 1000.0 - score = difflib.SequenceMatcher(None, qn, nn).ratio() * 100.0 - if nn.startswith(qn): - score += 50.0 - if qn in nn: - score += 25.0 - q_tokens = set(qn.split()) - n_tokens = set(nn.split()) - if q_tokens and n_tokens: - inter = len(q_tokens & n_tokens) - union = len(q_tokens | n_tokens) - score += (inter / union) * 40.0 - return score - - -def _infer_formed(value: str | None) -> str | None: - s = (value or "").strip() - if not s: - return None - s2 = s.replace("\u2014", "-").replace("\u2013", "-") - for pat in ( - r"\bformed\s+(?:in\s+)?(\d{4})\b", - r"\bbrought together in\s+(\d{4})\b", - r"\bestablished\s+(?:in\s+)?(\d{4})\b", - r"\bfounded\s+(?:in\s+)?(\d{4})\b", - r"\bformed\s+around\s+(\d{4})\b", - ): - m = re.search(pat, s2, flags=re.I) - if m: - return m.group(1) - return None - - -async def _fetch_shazam_json(client: httpx.AsyncClient, url: str, *, params: dict[str, object] | None = None) -> Any: - r = await client.get(url, params=params) - r.raise_for_status() - return r.json() - - -async def _fetch_shazam_html(client: httpx.AsyncClient, url: str) -> str: - r = await client.get(url) - r.raise_for_status() - return r.text - - -def _parse_shazam_artist_html(html: str) -> dict[str, Any]: - out: dict[str, Any] = {} - try: - soup = BeautifulSoup(html or "", "html.parser") - except Exception: - return out - - scripts = soup.find_all("script", attrs={"type": "application/ld+json"}) - found_ld = False - for s in scripts: - try: - raw = s.string or s.text or "" - if not raw.strip(): - continue - data = json.loads(raw) - except Exception: - continue - - candidates: list[dict] = [] - if isinstance(data, dict): - candidates = [data] - elif isinstance(data, list): - candidates = [x for x in data if isinstance(x, dict)] - - for obj in candidates: - t = str(obj.get("@type") or "").strip() - if t not in ("MusicGroup", "Person", "MusicArtist"): - continue - name = _pick_first_str(obj.get("name")) - if name: - out["name"] = name - genres = obj.get("genre") - if isinstance(genres, list): - out["genres"] = [str(x).strip() for x in genres if isinstance(x, str) and x.strip()] - elif isinstance(genres, str) and genres.strip(): - out["genres"] = [genres.strip()] - desc = _pick_first_str(obj.get("description")) - if desc: - out["description"] = desc - img = obj.get("image") - if isinstance(img, dict): - u = _pick_first_str(img.get("url")) - if u: - out["image_url"] = u - elif isinstance(img, str) and img.strip(): - out["image_url"] = img.strip() - found_ld = True - break - if found_ld: - break - - try: - video_block = soup.find(attrs={"data-test-id": "artist_impression_artistVideo"}) - video = None - if video_block is not None: - video = video_block.find("video") - if video is None: - video = soup.find("video") - if video is not None: - poster = video.get("poster") - if isinstance(poster, str) and poster.strip(): - out["video_poster_url"] = poster.strip() - - search_html = str(video_block) if video_block is not None else (html or "") - idx = search_html.lower().find(".m3u8") - if idx != -1: - start = search_html.rfind("https://", 0, idx) - if start != -1: - end = search_html.find(".m3u8", start) + 5 - if end > start: - out["video_hls_url"] = search_html[start:end] - if not out.get("video_hls_url"): - full_html = html or "" - idx2 = full_html.lower().find(".m3u8") - if idx2 != -1: - start2 = full_html.rfind("https://", 0, idx2) - if start2 != -1: - end2 = full_html.find(".m3u8", start2) + 5 - if end2 > start2: - out["video_hls_url"] = full_html[start2:end2] - if out.get("video_hls_url"): - mp4 = _itunes_mp4_from_m3u8(out.get("video_hls_url")) - if mp4: - out["video_mp4_url"] = mp4 - except Exception: - pass - - try: - members: list[dict[str, str]] = [] - seen: set[str] = set() - for a in soup.find_all("a", attrs={"data-test-id": re.compile(r"artist_userevent_artistBandMembers", flags=re.I)}): - name = a.get_text(" ", strip=True) - href = a.get("href") - if not isinstance(name, str) or not name.strip(): - continue - if not isinstance(href, str) or not href.strip(): - continue - key = f"{name.casefold()}|{href}" - if key in seen: - continue - seen.add(key) - mid = None - try: - m = re.search(r"/(\\d+)$", href.strip()) - if m: - mid = m.group(1) - except Exception: - mid = None - item = {"name": name.strip(), "href": href.strip()} - if mid: - item["id"] = str(mid) - members.append(item) - if members: - out["members"] = members - except Exception: - pass - - try: - member_of: list[dict[str, str]] = [] - seen2: set[str] = set() - for a in soup.find_all("a", attrs={"data-test-id": re.compile(r"artist_userevent_memberOfArtistItem", flags=re.I)}): - name = a.get_text(" ", strip=True) - href = a.get("href") - if not isinstance(name, str) or not name.strip(): - continue - if not isinstance(href, str) or not href.strip(): - continue - key = f"{name.casefold()}|{href}" - if key in seen2: - continue - seen2.add(key) - member_of.append({"name": name.strip(), "href": href.strip()}) - if member_of: - out["member_of"] = member_of - except Exception: - pass - - try: - links: list[dict[str, str]] = [] - seen3: set[str] = set() - for a in soup.find_all("a", href=True): - dt = a.get("data-test-id") - if not isinstance(dt, str) or "artistsociallink" not in dt.casefold(): - continue - - href = a.get("href") - if not isinstance(href, str) or not href.strip(): - continue - href = _clean_url(href) - if not href: - continue - - if href in seen3: - continue - seen3.add(href) - - platform = "" - try: - host = (urlparse(href).netloc or "").lower() - if host.startswith("www."): - host = host[4:] - if host: - if "instagram" in host: - platform = "instagram" - elif host in {"x.com"} or "twitter" in host: - platform = "x" - elif "facebook" in host: - platform = "facebook" - elif "youtube" in host or host in {"youtu.be"}: - platform = "youtube" - elif "tiktok" in host: - platform = "tiktok" - elif "soundcloud" in host: - platform = "soundcloud" - elif "spotify" in host: - platform = "spotify" - else: - platform = host - except Exception: - platform = "" - - item: dict[str, str] = {"href": href} - if platform: - item["platform"] = platform - links.append(item) - if len(links) >= 25: - break - if links: - out["links"] = links - except Exception: - pass - - try: - lines = [ln.strip() for ln in soup.get_text("\n", strip=True).splitlines() if ln and ln.strip()] - for i, ln in enumerate(lines): - if ln.casefold() == "hometown": - if i + 1 < len(lines): - out.setdefault("hometown", lines[i + 1]) - if ln.casefold() == "born": - if i + 1 < len(lines): - out.setdefault("born", lines[i + 1]) - if ln.casefold() == "formed": - if i + 1 < len(lines): - out.setdefault("formed", lines[i + 1]) - except Exception: - pass - - return out - - -def _parse_shazam_artist_detail_json(data: Any) -> dict[str, Any]: - out: dict[str, Any] = {} - if isinstance(data, dict) and isinstance(data.get("data"), list) and data["data"]: - node = data["data"][0] - elif isinstance(data, dict) and isinstance(data.get("data"), dict): - node = data["data"] - else: - node = None - - if not isinstance(node, dict): - return out - - out["id"] = _pick_first_str(node.get("id")) - out["href"] = _pick_first_str(node.get("href")) - attrs = node.get("attributes") if isinstance(node.get("attributes"), dict) else {} - if isinstance(attrs, dict): - name = _pick_first_str(attrs.get("name")) - if name: - out["name"] = name - genres = attrs.get("genreNames") - if isinstance(genres, list): - out["genres"] = [str(x).strip() for x in genres if isinstance(x, str) and x.strip()] - desc = None - notes = attrs.get("editorialNotes") if isinstance(attrs.get("editorialNotes"), dict) else {} - if isinstance(notes, dict): - desc = _pick_first_str(notes.get("standard")) or _pick_first_str(notes.get("short")) - if desc: - out["description"] = desc - born = _pick_first_str(attrs.get("dateOfBirth")) or _pick_first_str(attrs.get("born")) or _pick_first_str(attrs.get("birthDate")) - if born: - out["born"] = born - formed = ( - _pick_first_str(attrs.get("bornOrFormed")) - or _pick_first_str(attrs.get("born_or_formed")) - or _pick_first_str(attrs.get("formed")) - or _pick_first_str(attrs.get("formedOn")) - ) - if formed: - out["formed"] = formed - hometown = ( - _pick_first_str(attrs.get("origin")) - or _pick_first_str(attrs.get("homeTown")) - or _pick_first_str(attrs.get("hometown")) - or _pick_first_str(attrs.get("birthPlace")) - or _pick_first_str(attrs.get("location")) - ) - if hometown: - out["hometown"] = hometown - img = _am_artwork_url(attrs.get("artwork")) or _am_artwork_url(attrs.get("editorialArtwork")) - if img: - out["image_url"] = img - - return out - - -async def _refresh_albums_cache(*, limit_albums: int = 2000) -> dict[str, int]: - if _ALBUMS_REFRESH_LOCK.locked(): - return {"processed": 0, "upserted": 0} - async with _ALBUMS_REFRESH_LOCK: - limit_albums = int(limit_albums) - if limit_albums <= 0: - limit_albums = 2000 - if limit_albums > 5000: - limit_albums = 5000 - - tracks_col = get_audio_tracks_collection() - pipeline = [ - { - "$match": { - "deleted": {"$ne": True}, - "audio.album_id": {"$exists": True, "$ne": ""}, - "$or": [{"audio.album": {"$exists": True, "$ne": ""}}, {"audio.title": {"$exists": True, "$ne": ""}}], - } - }, - { - "$addFields": { - "_aid": "$audio.album_id", - "_album_title": {"$ifNull": ["$audio.album", "$audio.title"]}, - "_album_artist": { - "$cond": [ - {"$and": [{"$isArray": "$audio.artists"}, {"$gt": [{"$size": "$audio.artists"}, 0]}]}, - {"$arrayElemAt": ["$audio.artists", 0]}, - {"$ifNull": ["$audio.artist", "$audio.performer"]}, - ] - }, - } - }, - { - "$addFields": { - "_album_norm": {"$toLower": {"$trim": {"input": "$_album_title"}}}, - "_artist_norm": {"$toLower": {"$trim": {"input": "$_album_artist"}}}, - } - }, - {"$sort": {"updated_at": -1}}, - { - "$group": { - "_id": "$_aid", - "title": {"$first": "$_album_title"}, - "artist": {"$first": "$_album_artist"}, - "cover_url": {"$first": {"$ifNull": ["$spotify.big_cover_url", "$spotify.cover_url"]}}, - "tracks_count": {"$sum": 1}, - "duration_total": {"$sum": {"$ifNull": ["$audio.duration_sec", 0]}}, - "updated_at": {"$max": "$updated_at"}, - "match_album": {"$first": "$_album_norm"}, - "match_artist": {"$first": "$_artist_norm"}, - } - }, - {"$sort": {"updated_at": -1}}, - {"$limit": int(limit_albums)}, - ] - cur = await tracks_col.aggregate(pipeline, allowDiskUse=True) - - albums_col = db_handler.get_collection("albums").collection - now = time.time() - upserted = 0 - processed = 0 - async for row in cur: - processed += 1 - if not isinstance(row, dict): - continue - aid = (row.get("_id") or "").strip() - title = (row.get("title") or "").strip() - artist = (row.get("artist") or "").strip() - artists = _split_artists(artist) if artist else [] - cover_url = _clean_url(row.get("cover_url")) - tracks_count = int(row.get("tracks_count") or 0) - duration_total = int(row.get("duration_total") or 0) - updated_at = float(row.get("updated_at") or 0.0) - match_album = (row.get("match_album") or "").strip() - match_artist = (row.get("match_artist") or "").strip() - - if not aid or not title or not match_album: - continue - - res = await albums_col.update_one( - {"_id": aid}, - { - "$setOnInsert": {"created_at": now}, - "$set": { - "title": title, - "artist": artist or None, - "artists": artists if artists else None, - "cover_url": cover_url or None, - "tracks_count": tracks_count, - "duration_total": duration_total, - "match_album": match_album, - "match_artist": match_artist or None, - "updated_at": updated_at or now, - }, - }, - upsert=True, - ) - if getattr(res, "upserted_id", None) is not None: - upserted += 1 - - return {"processed": processed, "upserted": upserted} - - -def _is_default_deezer_avatar(url: str | None) -> bool: - """Return True if the URL is the empty/default Deezer artist avatar.""" - if not url or not isinstance(url, str): - return False - return "cdn-images.dzcdn.net/images/artist//" in url - - -async def _fetch_artist_avatar(client: httpx.AsyncClient, name: str, track_title: str | None = None) -> str | None: - if not name or not name.strip(): - return None - clean_name = name.strip() - - # --- 1. Try Deezer direct artist search --- - try: - r = await client.get("https://api.deezer.com/search/artist", params={"q": clean_name}, timeout=2.5) - if r.status_code == 200: - data = r.json().get("data", []) - if data and isinstance(data[0], dict): - art = data[0] - pic = art.get("picture_xl") or art.get("picture_big") or art.get("picture_medium") - if pic and not _is_default_deezer_avatar(pic): - return pic - except Exception: - pass - - # --- 2. Try Spotify artist search if configured --- - try: - from stream.helpers.cover_search import is_spotify_configured, _spotify_get_access_token - if is_spotify_configured(): - token = await _spotify_get_access_token() - if token: - sr = await client.get( - "https://api.spotify.com/v1/search", - params={"q": clean_name, "type": "artist", "limit": 1}, - headers={"Authorization": f"Bearer {token}"}, - timeout=2.5, - ) - if sr.status_code == 200: - artists_res = sr.json().get("artists", {}).get("items", []) - if artists_res and isinstance(artists_res[0], dict): - images = artists_res[0].get("images", []) - if images and isinstance(images[0], dict) and images[0].get("url"): - return str(images[0]["url"]).strip() - except Exception: - pass - - # --- 3. Try Deezer with sub-parts if compound artist name (e.g. "A & B") --- - sub_parts = _split_artists(clean_name) - if len(sub_parts) > 1: - for sub_a in sub_parts: - try: - dr = await client.get("https://api.deezer.com/search/artist", params={"q": sub_a}, timeout=2.0) - if dr.status_code == 200: - ddata = dr.json().get("data", []) - if ddata and isinstance(ddata[0], dict): - art = ddata[0] - pic = art.get("picture_xl") or art.get("picture_big") or art.get("picture_medium") - if pic and not _is_default_deezer_avatar(pic): - return pic - except Exception: - pass - - return None - - -async def _fill_missing_artist_avatars(items: list[dict], artists_col) -> None: - needs: list[dict] = [] - for doc in items: - c_url = doc.get("cover_url") - if not (isinstance(c_url, str) and c_url.strip() and not _is_default_deezer_avatar(c_url)): - needs.append(doc) - if not needs: - return - - sem = asyncio.Semaphore(6) - - async def _fetch_one(doc: dict, client: httpx.AsyncClient): - name = doc.get("name") or "" - if not name: - return - try: - av = await asyncio.wait_for(_fetch_artist_avatar(client, name), timeout=2.5) - except Exception: - av = None - if av: - doc["cover_url"] = _clean_url(av) - try: - await artists_col.update_one({"_id": doc["_id"]}, {"$set": {"cover_url": av}}) - except Exception: - pass - else: - c_url = doc.get("cover_url") - if isinstance(c_url, str) and c_url.strip(): - doc["cover_url"] = _clean_url(c_url) - - async def _bounded(d: dict, client: httpx.AsyncClient): - async with sem: - await _fetch_one(d, client) - - try: - async with httpx.AsyncClient( - headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}, - timeout=4.0, - ) as http_client: - await asyncio.gather(*[_bounded(d, http_client) for d in needs], return_exceptions=True) - except Exception: - pass - - -async def _refresh_artists_cache(*, limit_tracks: int = 20000, limit_artists: int = 5000) -> dict[str, int]: - global _ARTISTS_LAST_REFRESH_AT - async with _ARTISTS_REFRESH_LOCK: - now = time.time() - if (now - _ARTISTS_LAST_REFRESH_AT) < 30.0: - return {"scanned_tracks": 0, "processed_artists": 0, "upserted": 0} - - limit_tracks = int(limit_tracks) - if limit_tracks <= 0: - limit_tracks = 20000 - if limit_tracks > 100_000: - limit_tracks = 100_000 - - limit_artists = int(limit_artists) - if limit_artists <= 0: - limit_artists = 5000 - if limit_artists > 20_000: - limit_artists = 20_000 - - tracks_col = get_audio_tracks_collection() - cursor = ( - tracks_col.find( - { - "deleted": {"$ne": True}, - "$or": [{"audio.artist": {"$exists": True, "$ne": ""}}, {"audio.performer": {"$exists": True, "$ne": ""}}], - }, - {"audio.artist": 1, "audio.performer": 1, "audio.artists": 1, "updated_at": 1}, - allow_disk_use=True, - ) - .sort([("updated_at", -1)]) - .limit(int(limit_tracks)) - ) - - by_key: dict[str, dict[str, object]] = {} - scanned = 0 - for_limit = 0 - async for doc in cursor: - scanned += 1 - audio = doc.get("audio") if isinstance(doc.get("audio"), dict) else {} - raw_artists = audio.get("artists") - artists: list[str] = [] - if isinstance(raw_artists, list): - for a in raw_artists: - if isinstance(a, str) and a.strip(): - artists.extend(_split_artists(a.strip())) - if not artists: - raw_artist = audio.get("artist") or audio.get("performer") or "" - raw_artist = raw_artist.strip() if isinstance(raw_artist, str) else "" - if not raw_artist: - continue - artists = _split_artists(raw_artist) - updated_at = float(doc.get("updated_at") or 0.0) - for name in artists: - name = name.strip() - if not name: - continue - sub_parts = _split_artists(name) if ("/" in name or "," in name or " & " in name or " feat " in name.lower()) else [name] - for sub_name in sub_parts: - k = sub_name.casefold().strip() - if not k: - continue - entry = by_key.get(k) - if entry is None: - by_key[k] = { - "name": sub_name, - "match_artist": k, - "tracks_count": 1, - "updated_at": updated_at, - } - else: - entry["tracks_count"] = int(entry.get("tracks_count") or 0) + 1 - prev_updated = float(entry.get("updated_at") or 0.0) - if updated_at > prev_updated: - entry["updated_at"] = updated_at - if len(by_key) >= limit_artists: - for_limit += 1 - if for_limit >= 250: - break - - artists_col = db_handler.get_collection("artists").collection - try: - await artists_col.delete_many({ - "$or": [ - {"name": {"$regex": "/|,|&| feat| ft | featuring ", "$options": "i"}}, - {"_id": {"$regex": "/|,|&| feat| ft | featuring ", "$options": "i"}}, - {"match_artist": {"$regex": "/|,|&| feat| ft | featuring ", "$options": "i"}}, - ] - }) - except Exception: - pass - - now = time.time() - upserted = 0 - processed = 0 - bulk_ops = [] - - for k, entry in sorted(by_key.items(), key=lambda kv: float(kv[1].get("updated_at") or 0.0), reverse=True)[:limit_artists]: - processed += 1 - name = (entry.get("name") or "").strip() - match_artist = (entry.get("match_artist") or "").strip() - if not name or not match_artist: - continue - aid = _artist_id(name) - if not aid: - continue - - bulk_ops.append( - UpdateOne( - {"_id": aid}, - { - "$setOnInsert": {"created_at": now, "followers": 0}, - "$set": { - "name": name, - "tracks_count": int(entry.get("tracks_count") or 0), - "match_artist": match_artist, - "updated_at": float(entry.get("updated_at") or now), - }, - }, - upsert=True, - ) - ) - if len(bulk_ops) >= 500: - try: - res = await artists_col.bulk_write(bulk_ops, ordered=False) - upserted += getattr(res, "upserted_count", 0) - except Exception: - pass - bulk_ops.clear() - - if bulk_ops: - try: - res = await artists_col.bulk_write(bulk_ops, ordered=False) - upserted += getattr(res, "upserted_count", 0) - except Exception: - pass - - _ARTISTS_LAST_REFRESH_AT = time.time() - return {"scanned_tracks": scanned, "processed_artists": processed, "upserted": upserted} - - - -def _optional_user_id(request: Request) -> int | None: - token = "" - auth = (request.headers.get("authorization") or request.headers.get("Authorization") or "").strip() - if auth.lower().startswith("bearer "): - token = auth[7:].strip() - if not token: - token = (request.headers.get("X-Auth-Token") or request.headers.get("x-auth-token") or "").strip() - if not token: - token = (request.cookies.get("auth_token") or "").strip() - if not token: - token = (request.cookies.get("token") or "").strip() - if not token: - token = (request.query_params.get("auth_token") or "").strip() - if not token: - token = (request.query_params.get("token") or "").strip() - if not token: - return None - try: - payload = verify_auth_token(token) - uid = int(payload.get("uid") or 0) - return uid if uid > 0 else None - except Exception: - return None - - -async def _has_daily_playlist_tracks(*, key: str) -> bool: - k = (key or "").strip().lower() - now = float(time.time()) - - if k in {"random", "mix", "daily", "daily-playlist"}: - col = get_audio_tracks_collection() - return bool(await col.find_one({}, {"_id": 1})) - - if k in {"top", "top-played", "top-playlist"}: - col = db_handler.globalplayback_collection.collection - return bool(await col.find_one({}, {"_id": 1})) - - if k in {"surprise", "surprise-me"}: - col = db_handler.globalplayback_collection.collection - return bool(await col.find_one({}, {"_id": 1})) - - if k in {"rediscover"}: - cutoff = now - 30 * 24 * 3600 - col = db_handler.globalplayback_collection.collection - return bool(await col.find_one({"last_played_at": {"$lt": cutoff}}, {"_id": 1})) - - if k in {"trending", "trending-today"}: - since = now - 24 * 3600 - col = db_handler.userplayback_collection.collection - return bool(await col.find_one({"played_at": {"$gte": since}}, {"_id": 1})) - - if k in {"rising", "rising-tracks"}: - since = now - 3 * 24 * 3600 - col = db_handler.userplayback_collection.collection - return bool(await col.find_one({"played_at": {"$gte": since}}, {"_id": 1})) - - if k in {"late-night", "late-night-mix", "night"}: - since = now - 30 * 24 * 3600 - col = db_handler.userplayback_collection.collection - cur = await col.aggregate( - [ - {"$match": {"played_at": {"$gte": since}}}, - {"$addFields": {"_dt": {"$toDate": {"$multiply": ["$played_at", 1000]}}}}, - {"$addFields": {"_hour": {"$hour": "$_dt"}}}, - {"$match": {"$or": [{"_hour": {"$gte": 22}}, {"_hour": {"$lte": 3}}]}}, - {"$limit": 1}, - {"$project": {"_id": 1}}, - ] - ) - rows = await cur.to_list(length=1) - return bool(rows) - - return False - - -class ArtistSearchItem(BaseModel): - id: str - name: str - href: str | None = None - image_url: str | None = None - video_poster_url: str | None = None - video_hls_url: str | None = None - video_mp4_url: str | None = None - genres: list[str] | None = None - description: str | None = None - hometown: str | None = None - born: str | None = None - formed: str | None = None - links: list[dict[str, str]] | None = None - members: list[dict[str, str]] | None = None - member_of: list[dict[str, str]] | None = None - source: str = "shazam" - - -class ArtistSearchResponse(BaseModel): - ok: bool = True - q: str - country: str - items: list[ArtistSearchItem] - - -class ArtistLookupResponse(BaseModel): - ok: bool = True - id: str - country: str - item: ArtistSearchItem | None = None - - -def _extract_artist_search_items(payload: Any) -> list[dict]: - if isinstance(payload, dict): - results = payload.get("results") - if isinstance(results, dict): - artists = results.get("artists") - if isinstance(artists, dict) and isinstance(artists.get("data"), list): - return [x for x in artists.get("data") if isinstance(x, dict)] - - if isinstance(payload.get("data"), list): - return [x for x in payload.get("data") if isinstance(x, dict)] - - for v in payload.values(): - items = _extract_artist_search_items(v) - if items: - return items - elif isinstance(payload, list): - for v in payload: - items = _extract_artist_search_items(v) - if items: - return items - return [] - - -@router.get("/search", response_model=BrowseResponse) -async def search( - q: str | None = Query(default=None, min_length=0, max_length=80), - query: str | None = Query(default=None, min_length=0, max_length=80), - channel_id: int | None = Query(default=None), - page: int = Query(default=1, ge=1), - limit: int = Query(default=20, ge=1, le=50), - user_id: Optional[int] = Depends(get_optional_user_id), -): - raw = (q or "").strip() - if not raw: - raw = (query or "").strip() - if not raw: - return BrowseResponse(page=int(page), per_page=int(limit), total=0, items=[]) - return await search_tracks(raw, channel_id=channel_id, page=int(page), per_page=int(limit), user_id=user_id) - -@router.get("/tracks/search", response_model=BrowseResponse) -async def track_search( - q: str = Query(default="", min_length=0, max_length=80), - channel_id: int | None = Query(default=None), - page: int = Query(default=1, ge=1), - limit: int = Query(default=20, ge=1, le=50), - user_id: Optional[int] = Depends(get_optional_user_id), -): - q = (q or "").strip() - if not q: - return BrowseResponse(page=int(page), per_page=int(limit), total=0, items=[]) - return await search_tracks(q, channel_id=channel_id, page=int(page), per_page=int(limit), user_id=user_id) - - -@router.get("/search/artist/{artist_name}") -@router.get("/search/artist") -@router.get("/search/artists") -async def search_artists_db( - artist_name: str = "", - q: str = "", - page: int = Query(default=1, ge=1), - limit: int = Query(default=50, ge=1, le=200), - user_id: Optional[int] = Depends(get_optional_user_id), -): - query_str = (artist_name or q or "").strip() - artists_col = db_handler.get_collection("artists").collection - filter_query: dict[str, Any] = {} - - if query_str: - escaped_query = re.escape(query_str) - filter_query = { - "$or": [ - {"_id": {"$regex": escaped_query, "$options": "i"}}, - {"name": {"$regex": escaped_query, "$options": "i"}}, - {"match_artist": {"$regex": escaped_query, "$options": "i"}}, - ] - } - - total = await artists_col.count_documents(filter_query) - skip = (int(page) - 1) * int(limit) - cursor = ( - artists_col.find(filter_query, {"match_artist": 0}) - .sort([("followers", -1), ("updated_at", -1)]) - .skip(int(skip)) - .limit(int(limit)) - ) - - followed_ids: set[str] = set() - if user_id is not None: - try: - fav_col = db_handler.get_collection("user_favourite_artists").collection - async for fdoc in fav_col.find({"user_id": int(user_id)}, {"_id": 0, "artist_id": 1}): - aid = fdoc.get("artist_id") - if aid: - followed_ids.add(aid) - except Exception: - pass - - items: list[dict] = [] - async for doc in cursor: - if "_id" in doc: - doc["_id"] = str(doc["_id"]) - c_url = doc.get("cover_url") - if isinstance(c_url, str) and c_url.strip() and not _is_default_deezer_avatar(c_url): - doc["cover_url"] = _clean_url(c_url) - doc["is_following"] = doc.get("_id") in followed_ids - items.append(doc) - - await _fill_missing_artist_avatars(items, artists_col) - - return { - "ok": True, - "page": int(page), - "per_page": int(limit), - "total": int(total), - "query": query_str, - "items": items, - } - -@router.get("/tracks/shuffle", response_model=BrowseResponse) -@router.get("/tracks/random", response_model=BrowseResponse) -@router.get("/library/shuffle", response_model=BrowseResponse) -@router.get("/api/v1/library/shuffle", response_model=BrowseResponse) -async def track_shuffle( - limit: int = Query(default=100, ge=1, le=200), - seed: int | None = Query(default=None), - channel_id: int | None = Query(default=None), - liked: bool | None = Query(default=None), - genre: str | None = Query(default=None), - artist: str | None = Query(default=None), - source: str | None = Query(default=None), - lossless: bool | None = Query(default=None), - user_id: Optional[int] = Depends(get_optional_user_id), -): - return await random_tracks( - limit=int(limit), - seed=seed, - channel_id=channel_id, - user_id=user_id, - liked=liked, - genre=genre, - artist=artist, - source=source, - lossless=lossless, - ) - - -@router.get("/history", response_model=BrowseResponse) -async def user_history_direct( - limit: int = Query(default=100, ge=1, le=200), - user_id: int = Depends(require_user_id), -): - from Api.routers.playlists import get_user_history - return await get_user_history(limit=limit, user_id=user_id) - -@router.get("/playlists/available", response_model=AvailablePlaylistsResponse) -async def available_playlists(request: Request): - today = datetime.datetime.utcnow().date().isoformat() - items: list[AvailablePlaylistItem] = [] - - daily_defs = [ - ("random", "Daily Mix"), - ("trending", "Trending Today"), - ("rediscover", "Rediscover"), - ("late-night", "Late Night Mix"), - ("rising", "Rising Tracks"), - ("surprise", "Surprise Me"), - ] - for key, name in daily_defs: - if not await _has_daily_playlist_tracks(key=key): - continue - info = await get_daily_playlist_thumbnail_info(key=key, date=today, channel_id=None, limit=4) - url = info.get("cover_url") if isinstance(info, dict) else None - normal = info.get("normal_thumbnail") if isinstance(info, dict) else None - thumbs = info.get("thumbnails") if isinstance(info, dict) and isinstance(info.get("thumbnails"), list) else [] - items.append( - AvailablePlaylistItem( - id=f"daily:{key}", - kind="daily", - name=name, - thumbnails=[str(x) for x in thumbs if isinstance(x, str)], - thumbnail_url=url, - normal_thumbnail=normal, - endpoint=f"/daily-playlist/{key}", - requires_auth=False, - ) - ) - - return AvailablePlaylistsResponse(items=items) - -@router.get("/daily-playlist/{key}", response_model=BrowseResponse) -async def daily_playlist( - key: str, - limit: int = Query(default=75, ge=1, le=75), - channel_id: int | None = Query(default=None), - user_id: Optional[int] = Depends(get_optional_user_id), -): - key = (key or "").strip().lower() - if not key: - raise HTTPException(status_code=400, detail="key is required") - if key not in { - "random", - "mix", - "top", - "top-played", - "daily-playlist", - "top-playlist", - "trending", - "trending-today", - "rediscover", - "late-night", - "late-night-mix", - "rising", - "rising-tracks", - "surprise", - "surprise-me", - }: - raise HTTPException(status_code=404, detail="unknown daily playlist") - return await get_daily_playlist(key=key, date=None, channel_id=channel_id, limit=int(limit), user_id=user_id) - - -@router.get("/tracks/{track_id}", response_model=TrackResponse) -async def track_details(track_id: str, user_id: Optional[int] = Depends(get_optional_user_id)): - doc = await get_track_by_id(track_id, user_id=user_id) - if not doc: - raise HTTPException(status_code=404, detail="track not found") - return doc - - -@router.get("/albums") -async def list_albums( - page: int = Query(default=1, ge=1), - limit: int = Query(default=50, ge=1, le=200), - refresh: bool = Query(default=False), -): - albums_col = db_handler.get_collection("albums").collection - try: - existing = int(await albums_col.estimated_document_count()) - except Exception: - existing = 0 - - tracks_col = get_audio_tracks_collection() - should_refresh = bool(refresh) or existing <= 0 - if not should_refresh: - try: - latest_track = await tracks_col.find_one( - {"deleted": {"$ne": True}, "audio.album_id": {"$exists": True, "$ne": ""}}, - projection={"updated_at": 1}, - sort=[("updated_at", -1)], - ) - latest_album = await albums_col.find_one( - {}, - projection={"updated_at": 1}, - sort=[("updated_at", -1)], - ) - track_ts = float((latest_track or {}).get("updated_at") or 0.0) - album_ts = float((latest_album or {}).get("updated_at") or 0.0) - if track_ts > album_ts: - should_refresh = True - except Exception: - pass - - if should_refresh: - await _refresh_albums_cache(limit_albums=5000 if refresh else 2000) - try: - existing = int(await albums_col.estimated_document_count()) - except Exception: - existing = 0 - - skip = (int(page) - 1) * int(limit) - cursor = ( - albums_col.find({}, {"match_album": 0, "match_artist": 0}) - .sort([("updated_at", -1)]) - .skip(int(skip)) - .limit(int(limit)) - ) - items: list[dict] = [] - async for doc in cursor: - if "_id" in doc: - doc["_id"] = str(doc["_id"]) - if isinstance(doc.get("cover_url"), str): - doc["cover_url"] = _clean_url(doc.get("cover_url")) - items.append(doc) - return {"ok": True, "page": int(page), "per_page": int(limit), "total": int(existing), "items": items} - - -@router.get("/albums/{album_id}") -async def album_details(album_id: str, user_id: Optional[int] = Depends(get_optional_user_id)): - aid = (album_id or "").strip() - if not aid: - raise HTTPException(status_code=400, detail="album_id is required") - - albums_col = db_handler.get_collection("albums").collection - album = await albums_col.find_one({"_id": aid}) - if not album: - await _refresh_albums_cache(limit_albums=5000) - album = await albums_col.find_one({"_id": aid}) - if not album: - raise HTTPException(status_code=404, detail="album not found") - - tracks_col = get_audio_tracks_collection() - pipeline = [ - {"$match": {"deleted": {"$ne": True}, "audio.album_id": str(aid)}}, - {"$sort": {"audio.track_number": 1, "source_message_id": 1, "updated_at": -1}}, - { - "$project": { - "_id": 1, - "source_chat_id": 1, - "source_message_id": 1, - "audio": 1, - "spotify": 1, - "created_at": 1, - "updated_at": 1, - } - }, - ] - cur = await tracks_col.aggregate(pipeline) - tracks: list[dict] = [] - async for doc in cur: - if "_id" in doc: - doc["_id"] = str(doc["_id"]) - if not doc.get("created_at") and doc.get("updated_at"): - doc["created_at"] = doc.get("updated_at") - spotify = doc.get("spotify") if isinstance(doc.get("spotify"), dict) else {} - if isinstance(spotify, dict): - if isinstance(spotify.get("cover_url"), str): - spotify["cover_url"] = _clean_url(spotify.get("cover_url")) - if isinstance(spotify.get("url"), str): - spotify["url"] = _clean_url(spotify.get("url")) - doc["spotify"] = spotify - tracks.append(doc) - - await attach_liked_to_dicts(tracks, user_id) - album["_id"] = str(album.get("_id")) - if isinstance(album.get("cover_url"), str): - album["cover_url"] = _clean_url(album.get("cover_url")) - album.pop("match_album", None) - album.pop("match_artist", None) - return {"ok": True, "album": album, "tracks": tracks} - - -@router.get("/albums/{album_id}/tracks") -async def album_tracks( - album_id: str, - page: int = Query(default=1, ge=1), - limit: int = Query(default=50, ge=1, le=200), - user_id: Optional[int] = Depends(get_optional_user_id), -): - aid = (album_id or "").strip() - if not aid: - raise HTTPException(status_code=400, detail="album_id is required") - - albums_col = db_handler.get_collection("albums").collection - album = await albums_col.find_one({"_id": aid}) - if not album: - raise HTTPException(status_code=404, detail="album not found") - - tracks_col = get_audio_tracks_collection() - base_pipeline = [ - {"$match": {"deleted": {"$ne": True}, "audio.album_id": str(aid)}}, - ] - - total_cur = await tracks_col.aggregate([*base_pipeline, {"$count": "total"}]) - total_rows = await total_cur.to_list(length=1) - total = int(total_rows[0]["total"]) if total_rows else 0 - - skip = (int(page) - 1) * int(limit) - data_pipeline = [ - *base_pipeline, - {"$sort": {"audio.track_number": 1, "source_message_id": 1, "updated_at": -1}}, - {"$skip": int(skip)}, - {"$limit": int(limit)}, - { - "$project": { - "_id": 1, - "source_chat_id": 1, - "source_message_id": 1, - "audio": 1, - "spotify": 1, - "created_at": 1, - "updated_at": 1, - } - }, - ] - cur = await tracks_col.aggregate(data_pipeline) - items: list[dict] = [] - async for doc in cur: - if "_id" in doc: - doc["_id"] = str(doc["_id"]) - if not doc.get("created_at") and doc.get("updated_at"): - doc["created_at"] = doc.get("updated_at") - spotify = doc.get("spotify") if isinstance(doc.get("spotify"), dict) else {} - if isinstance(spotify, dict): - if isinstance(spotify.get("cover_url"), str): - spotify["cover_url"] = _clean_url(spotify.get("cover_url")) - if isinstance(spotify.get("url"), str): - spotify["url"] = _clean_url(spotify.get("url")) - doc["spotify"] = spotify - items.append(doc) - - await attach_liked_to_dicts(items, user_id) - return {"ok": True, "page": int(page), "per_page": int(limit), "total": int(total), "items": items} - - -@router.get("/artists") -async def list_artists( - page: int = Query(default=1, ge=1), - limit: int = Query(default=50, ge=1, le=200), - refresh: bool = Query(default=False), - user_id: Optional[int] = Depends(get_optional_user_id), -): - artists_col = db_handler.get_collection("artists").collection - try: - existing = int(await artists_col.estimated_document_count()) - except Exception: - existing = 0 - - tracks_col = get_audio_tracks_collection() - should_refresh = bool(refresh) or existing <= 0 - now = time.time() - if not should_refresh and (now - _ARTISTS_LAST_REFRESH_AT) > _ARTISTS_REFRESH_COOLDOWN_SEC: - try: - latest_track = await tracks_col.find_one( - {"deleted": {"$ne": True}}, - projection={"updated_at": 1}, - sort=[("updated_at", -1)], - ) - latest_artist = await artists_col.find_one( - {}, - projection={"updated_at": 1}, - sort=[("updated_at", -1)], - ) - track_ts = float((latest_track or {}).get("updated_at") or 0.0) - artist_ts = float((latest_artist or {}).get("updated_at") or 0.0) - if track_ts > artist_ts: - should_refresh = True - except Exception: - pass - - if should_refresh: - if _ARTISTS_REFRESH_LOCK.locked() and existing > 0: - pass - else: - await _refresh_artists_cache(limit_tracks=100_000 if refresh else 20_000, limit_artists=20_000 if refresh else 5_000) - try: - existing = int(await artists_col.estimated_document_count()) - except Exception: - existing = 0 - - followed_ids: set[str] = set() - if user_id is not None: - try: - fav_col = db_handler.get_collection("user_favourite_artists").collection - async for fdoc in fav_col.find({"user_id": int(user_id)}, {"_id": 0, "artist_id": 1}): - aid = fdoc.get("artist_id") - if aid: - followed_ids.add(aid) - except Exception: - pass - - skip = (int(page) - 1) * int(limit) - cursor = ( - artists_col.find({}, {"match_artist": 0}) - .sort([("updated_at", -1)]) - .skip(int(skip)) - .limit(int(limit)) - ) - items: list[dict] = [] - async for doc in cursor: - if "_id" in doc: - doc["_id"] = str(doc["_id"]) - c_url = doc.get("cover_url") - if isinstance(c_url, str) and c_url.strip() and not _is_default_deezer_avatar(c_url): - doc["cover_url"] = _clean_url(c_url) - doc["is_following"] = doc.get("_id") in followed_ids - items.append(doc) - - await _fill_missing_artist_avatars(items, artists_col) - return {"ok": True, "page": int(page), "per_page": int(limit), "total": int(existing), "items": items} - - -@router.get("/artists/{artist_id}") -async def artist_details(artist_id: str, user_id: Optional[int] = Depends(get_optional_user_id)): - aid = (artist_id or "").strip() - if not aid: - raise HTTPException(status_code=400, detail="artist_id is required") - - artists_col = db_handler.get_collection("artists").collection - artist = await artists_col.find_one({"_id": aid}) - if not artist: - await _refresh_artists_cache(limit_tracks=100_000, limit_artists=20_000) - artist = await artists_col.find_one({"_id": aid}) - if not artist: - raise HTTPException(status_code=404, detail="artist not found") - - match_artist = (artist.get("match_artist") or "").strip() - if not match_artist: - raise HTTPException(status_code=404, detail="artist not ready") - - tracks_col = get_audio_tracks_collection() - pipeline = [ - { - "$match": { - "deleted": {"$ne": True}, - "$or": [{"audio.artist": {"$exists": True, "$ne": ""}}, {"audio.performer": {"$exists": True, "$ne": ""}}], - } - }, - {"$addFields": {"_artist_raw": {"$ifNull": ["$audio.artist", "$audio.performer"]}}}, - {"$addFields": {"_artist_norm": {"$toLower": {"$trim": {"input": "$_artist_raw"}}}}}, - {"$match": {"_artist_norm": str(match_artist)}}, - {"$sort": {"created_at": -1, "updated_at": -1}}, - {"$limit": 200}, - { - "$project": { - "_id": 1, - "source_chat_id": 1, - "source_message_id": 1, - "audio": 1, - "spotify": 1, - "created_at": 1, - "updated_at": 1, - } - }, - ] - cur = await tracks_col.aggregate(pipeline) - tracks: list[dict] = [] - track_ids: list[str] = [] - async for doc in cur: - if "_id" in doc: - doc["_id"] = str(doc["_id"]) - if not doc.get("created_at") and doc.get("updated_at"): - doc["created_at"] = doc.get("updated_at") - tid = str(doc.get("_id") or "").strip() - if not tid: - continue - doc["_id"] = tid - spotify = doc.get("spotify") if isinstance(doc.get("spotify"), dict) else {} - if isinstance(spotify, dict): - if isinstance(spotify.get("cover_url"), str): - spotify["cover_url"] = _clean_url(spotify.get("cover_url")) - if isinstance(spotify.get("url"), str): - spotify["url"] = _clean_url(spotify.get("url")) - doc["spotify"] = spotify - tracks.append(doc) - track_ids.append(tid) - - plays_by_id: dict[str, int] = {} - try: - if track_ids: - gcol = db_handler.globalplayback_collection.collection - pcur = gcol.find({"_id": {"$in": track_ids}}, {"plays": 1}) - async for row in pcur: - rid = str(row.get("_id") or "").strip() - if not rid: - continue - plays_by_id[rid] = int(row.get("plays") or 0) - except Exception: - plays_by_id = {} - - def _popular_key(d: dict) -> tuple[int, float]: - tid = str(d.get("_id") or "") - plays = int(plays_by_id.get(tid) or 0) - updated_at = float(d.get("updated_at") or 0.0) - return (plays, updated_at) - - popular_tracks = sorted(tracks, key=_popular_key, reverse=True)[:10] - singles = [t for t in tracks if not (t.get("audio") or {}).get("album")][:20] - - await attach_liked_to_dicts(tracks, user_id) - await attach_liked_to_dicts(popular_tracks, user_id) - await attach_liked_to_dicts(singles, user_id) - - albums_col = db_handler.get_collection("albums").collection - releases: list[dict] = [] - try: - acur = albums_col.find({"match_artist": str(match_artist)}, {"match_album": 0, "match_artist": 0}).sort([("updated_at", -1)]).limit(20) - async for a in acur: - if "_id" in a: - a["_id"] = str(a["_id"]) - if isinstance(a.get("cover_url"), str): - a["cover_url"] = _clean_url(a.get("cover_url")) - releases.append(a) - except Exception: - releases = [] - - artist["_id"] = str(artist.get("_id")) - c_url = artist.get("cover_url") - if isinstance(c_url, str) and c_url.strip() and not _is_default_deezer_avatar(c_url): - artist["cover_url"] = _clean_url(c_url) - else: - name = artist.get("name") or "" - if name: - try: - async with httpx.AsyncClient(headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}, timeout=2.5) as http_client: - av = await _fetch_artist_avatar(http_client, name) - if av: - artist["cover_url"] = _clean_url(av) - await artists_col.update_one({"_id": aid}, {"$set": {"cover_url": av}}) - except Exception: - pass - artist.pop("match_artist", None) - - return { - "ok": True, - "artist": artist, - "popular_tracks": popular_tracks, - "releases": releases, - "singles": singles, - "tracks": tracks, - } - - -@router.get("/artists/{artist_id}/tracks") -async def artist_tracks( - artist_id: str, - page: int = Query(default=1, ge=1), - limit: int = Query(default=50, ge=1, le=200), - user_id: Optional[int] = Depends(get_optional_user_id), -): - aid = (artist_id or "").strip() - if not aid: - raise HTTPException(status_code=400, detail="artist_id is required") - - artists_col = db_handler.get_collection("artists").collection - artist = await artists_col.find_one({"_id": aid}) - if not artist: - raise HTTPException(status_code=404, detail="artist not found") - - match_artist = (artist.get("match_artist") or "").strip() - if not match_artist: - raise HTTPException(status_code=404, detail="artist not ready") - - tracks_col = get_audio_tracks_collection() - base_pipeline = [ - {"$match": {"deleted": {"$ne": True}}}, - { - "$addFields": { - "_artists_raw": { - "$cond": [ - {"$isArray": "$audio.artists"}, - "$audio.artists", - [ - { - "$ifNull": [ - "$audio.artist", - {"$ifNull": ["$audio.performer", ""]}, - ] - } - ], - ] - } - } - }, - { - "$addFields": { - "_artists_norm": { - "$map": { - "input": "$_artists_raw", - "as": "a", - "in": {"$toLower": {"$trim": {"input": "$$a"}}}, - } - } - } - }, - {"$match": {"_artists_norm": str(match_artist)}}, - ] - - total_cur = await tracks_col.aggregate([*base_pipeline, {"$count": "total"}]) - total_rows = await total_cur.to_list(length=1) - total = int(total_rows[0]["total"]) if total_rows else 0 - - skip = (int(page) - 1) * int(limit) - data_pipeline = [ - *base_pipeline, - {"$sort": {"created_at": -1, "updated_at": -1}}, - {"$skip": int(skip)}, - {"$limit": int(limit)}, - { - "$project": { - "_id": 1, - "source_chat_id": 1, - "source_message_id": 1, - "audio": 1, - "spotify": 1, - "created_at": 1, - "updated_at": 1, - } - }, - ] - cur = await tracks_col.aggregate(data_pipeline) - items: list[dict] = [] - async for doc in cur: - if "_id" in doc: - doc["_id"] = str(doc["_id"]) - if not doc.get("created_at") and doc.get("updated_at"): - doc["created_at"] = doc.get("updated_at") - spotify = doc.get("spotify") if isinstance(doc.get("spotify"), dict) else {} - if isinstance(spotify, dict): - if isinstance(spotify.get("cover_url"), str): - spotify["cover_url"] = _clean_url(spotify.get("cover_url")) - if isinstance(spotify.get("url"), str): - spotify["url"] = _clean_url(spotify.get("url")) - doc["spotify"] = spotify - items.append(doc) - - await attach_liked_to_dicts(items, user_id) - return {"ok": True, "page": int(page), "per_page": int(limit), "total": int(total), "items": items} - - -class AdminDeleteTracksRequest(BaseModel): - track_id: str | list[str] | None = None - track_ids: list[str] | None = None - - -@router.delete("/admin/tracks/delete") -@router.post("/admin/tracks/delete", include_in_schema=False) -async def admin_delete_tracks( - payload: AdminDeleteTracksRequest | None = Body(None), - track_id: str | None = Query(None), - track_ids: list[str] | None = Query(None), - admin_user_id: int = Depends(require_admin_user_id), -): - t_ids: list[str] = [] - if payload: - if isinstance(payload.track_id, list): - t_ids.extend(payload.track_id) - elif payload.track_id: - t_ids.append(payload.track_id) - if payload.track_ids: - t_ids.extend(payload.track_ids) - if track_id: - t_ids.append(track_id) - if track_ids: - t_ids.extend(track_ids) - - track_ids: list[str] = [] - seen: set[str] = set() - for tid in t_ids: - tid_str = str(tid or "").strip() - if not tid_str or tid_str in seen: - continue - seen.add(tid_str) - track_ids.append(tid_str) - - if not track_ids: - raise HTTPException(status_code=400, detail="track_id or track_ids is required") - - now = time.time() - col = get_audio_tracks_collection() - res = await col.update_many( - {"_id": {"$in": track_ids}}, - {"$set": {"deleted": True, "deleted_at": now, "deleted_by": int(admin_user_id), "updated_at": now}}, - ) - return { - "ok": True, - "track_ids": track_ids, - "matched": int(getattr(res, "matched_count", 0) or 0), - "modified": int(getattr(res, "modified_count", 0) or 0), - } - -@router.get("/tracks/{track_id}/stream") -async def track_stream(track_id: str, request: Request): - return await stream_track(track_id, request) - - -@router.head("/tracks/{track_id}/stream") -async def track_stream_head(track_id: str, request: Request): - return await stream_track(track_id, request) - - -@router.get("/channelids", response_model=ChannelIdsResponse) -async def available_channel_ids(): - tracks_col = get_audio_tracks_collection() - unique_ids_raw = await tracks_col.distinct("source_chat_id", {"deleted": {"$ne": True}}) - - unique_ids = [] - for cid in unique_ids_raw: - if cid is None: - continue - try: - unique_ids.append(int(cid)) - except (TypeError, ValueError): - pass - - if not unique_ids: - return ChannelIdsResponse(ok=True, items=[]) - - channels_col = db_handler.channels_collection.collection - cursor = channels_col.find({"_id": {"$in": unique_ids}}, {"_id": 1, "title": 1, "username": 1, "type": 1}) - - channel_details = {} - async for doc in cursor: - try: - cid = int(doc.get("_id")) - channel_details[cid] = doc - except Exception: - continue - - try: - from stream import bot - except ImportError: - bot = None - - items: list[ChannelItem] = [] - for cid in set(unique_ids): - doc = channel_details.get(cid, {}) - title = doc.get("title") - username = doc.get("username") - ctype = doc.get("type") - - if not title and bot is not None: - try: - chat = await bot.get_chat(cid) - title = getattr(chat, "title", title) - username = getattr(chat, "username", username) - ctype_enum = getattr(chat, "type", None) - ctype = getattr(ctype_enum, "name", str(ctype_enum)) if ctype_enum else ctype - - try: - await channels_col.update_one( - {"_id": cid}, - {"$set": {"title": title, "username": username, "type": ctype}}, - upsert=True - ) - except Exception: - pass - except Exception: - pass - - items.append( - ChannelItem( - id=cid, - title=str(title).strip() if isinstance(title, str) and title.strip() else None, - username=str(username).strip() if isinstance(username, str) and username.strip() else None, - type=str(ctype).strip() if isinstance(ctype, str) and ctype.strip() else None, - ) - ) - - return ChannelIdsResponse(ok=True, items=items) - - -@router.get("/tracks/{track_id}/download") -async def track_download(track_id: str, request: Request): - return await download_track(track_id, request) - - -@router.get("/tracks/{track_id}/warm") -async def track_warm(track_id: str): - return await warm_track_cached(track_id) - - -@router.get("/tracks/{track_id}/lyrics") -async def track_lyrics(track_id: str, request: Request, provider: Optional[str] = Query(None)): - fmt = (request.query_params.get("format") or "").strip().lower() - res = await get_track_lyrics(track_id, provider=provider) - want_json = fmt == "json" - if not want_json: - if isinstance(res, dict) and res.get("ok") and isinstance(res.get("lyrics"), str): - return PlainTextResponse(content=res["lyrics"], media_type="text/plain; charset=utf-8") - return res diff --git a/Api/routers/webapp.py b/Api/routers/webapp.py deleted file mode 100644 index 3063941..0000000 --- a/Api/routers/webapp.py +++ /dev/null @@ -1,74 +0,0 @@ -import hmac -import hashlib -import json -import time -from urllib.parse import parse_qsl - -from fastapi import APIRouter, Form, HTTPException -from Api.utils.auth import create_auth_token -from stream.core.config_manager import Config -from stream.database.MongoDb import db_handler - - -router = APIRouter(prefix="/webapp", tags=["webapp"]) - - -def extract_telegram_user(init_data: str) -> dict: - try: - data = dict(parse_qsl(init_data)) - received_hash = data.pop("hash") - data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(data.items())) - - bot_token = (getattr(Config, "BOT_TOKEN", "") or "").strip() - if not bot_token: - raise ValueError("BOT_TOKEN missing") - - secret_key = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest() - calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest() - if calculated_hash != received_hash: - raise ValueError("Invalid Telegram signature") - - user = json.loads(data["user"]) - profile_url = user.get("photo_url") - return { - "user_id": user["id"], - "username": user.get("username"), - "first_name": user.get("first_name"), - "photo_url": profile_url, - "profile_url": profile_url, - } - except Exception as e: - raise HTTPException(status_code=401, detail=str(e)) - - -@router.post("/verify") -async def webapp_verify(init_data: str = Form(...)): - user = extract_telegram_user(init_data) - uid = int(user["user_id"]) - now = time.time() - col = db_handler.get_collection("users").collection - await col.update_one( - {"_id": uid}, - { - "$set": { - "first_name": user.get("first_name"), - "photo_url": user.get("photo_url"), - "profile_url": user.get("profile_url") or user.get("photo_url"), - "telegram": { - "id": uid, - "username": user.get("username"), - }, - "updated_at": now, - }, - "$setOnInsert": {"created_at": now}, - }, - upsert=True, - ) - token = create_auth_token( - user_id=uid, - first_name=user.get("first_name"), - photo_url=user.get("photo_url"), - profile_url=user.get("profile_url"), - ) - return {**user, "token": token} - diff --git a/Api/routers/yt_dlp.py b/Api/routers/yt_dlp.py deleted file mode 100644 index 608456e..0000000 --- a/Api/routers/yt_dlp.py +++ /dev/null @@ -1,289 +0,0 @@ -import os -import asyncio -import time -from typing import Any -import uuid -import yt_dlp -import requests -from PIL import Image -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks -from pydantic import BaseModel -from Api.utils.auth import require_user_id -from stream.helpers.logger import LOGGER -from Api.deps.db import get_audio_tracks_collection -from stream import bot -from stream.core.config_manager import Config -from Api.services.lyrics_service import get_track_lyrics - -LOG = LOGGER(__name__) -router = APIRouter(prefix="/youtube", tags=["YouTube"]) - -ROOT_DIR = os.getcwd() -COOKIES_PATH = os.path.join(ROOT_DIR, "cookies", "yt.txt") -MAX_FILE_SIZE_MB = 200 -MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024 - -def _normalize_mime_type(mime: Any) -> str: - if not mime: - return "audio/mpeg" - raw = str(mime).split(";")[0].strip().lower() - if raw in {"audio/flac", "audio/x-flac"} or raw.endswith("/x-flac"): - return "audio/flac" - if raw in {"audio/wav", "audio/x-wav", "audio/wave"}: - return "audio/wav" - if raw in {"audio/mp3", "audio/mpeg"}: - return "audio/mpeg" - if raw in {"audio/m4a", "audio/x-m4a", "audio/mp4"}: - return "audio/mp4" - if raw in {"audio/ogg", "application/ogg"}: - return "audio/ogg" - if raw in {"audio/aac"}: - return "audio/aac" - return raw or "audio/mpeg" - - -class YTDownloadRequest(BaseModel): - url: str - -def progress_hook(d): - """Hook to cancel download if file size exceeds limit.""" - if d['status'] == 'downloading': - downloaded = d.get('downloaded_bytes', 0) - total = d.get('total_bytes') or d.get('total_bytes_estimate', 0) - if total > MAX_FILE_SIZE_BYTES or downloaded > MAX_FILE_SIZE_BYTES: - raise Exception(f"File size exceeds {MAX_FILE_SIZE_MB}MB limit.") - -def download_thumbnail(url: str, video_id: str) -> str | None: - """Download thumbnail and return local path for Telegram.""" - try: - tmp_dir = "stream_media" - os.makedirs(tmp_dir, exist_ok=True) - resp = requests.get(url, timeout=10) - if resp.status_code == 200: - thumb_path = os.path.join(tmp_dir, f"thumb_{video_id}.jpg") - with open(thumb_path, "wb") as f: - f.write(resp.content) - - with Image.open(thumb_path) as img: - img.thumbnail((320, 320)) - img.save(thumb_path, "JPEG") - return thumb_path - except Exception as e: - LOG.warning(f"Failed to download thumbnail: {e}") - return None - -async def process_yt_download(url: str, user_id: int): - """Background task to download audio from YouTube, upload to Telegram, and save to DB.""" - ydl_opts = { - 'format': 'bestaudio/ba/best[ext=m4a]/best[ext=mp3]/best', - 'noplaylist': True, - 'quiet': True, - 'no_warnings': True, - 'nocheckcertificate': True, - 'cookiefile': COOKIES_PATH if os.path.exists(COOKIES_PATH) else None, - 'progress_hooks': [progress_hook], - 'http_headers': { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Accept-Language': 'en-US,en;q=0.5', - 'Referer': 'https://www.google.com/', - } - } - - final_filepath = None - thumbnail_path = None - try: - loop = asyncio.get_event_loop() - - # 1. Extract metadata - info = None - try: - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = await loop.run_in_executor(None, lambda: ydl.extract_info(url, download=False)) - except Exception as e: - msg_upper = str(e).upper() - if 'COOKIE' in msg_upper or 'FORMAT' in msg_upper: - LOG.warning(f"YouTube extraction failed (e={e}), retrying without cookies...") - ydl_opts_no_cookies = ydl_opts.copy() - ydl_opts_no_cookies.pop('cookiefile', None) - with yt_dlp.YoutubeDL(ydl_opts_no_cookies) as ydl: - info = await loop.run_in_executor(None, lambda: ydl.extract_info(url, download=False)) - ydl_opts = ydl_opts_no_cookies - else: - raise - - if not info: - LOG.error(f"Could not extract info for YouTube URL: {url}") - return - - video_id = info.get('id') - title = info.get('title', 'Unknown Title') - performer = info.get('uploader', 'Unknown Artist') - duration = info.get('duration', 0) - thumbnail_url = info.get('thumbnail') - ext = info.get('ext', 'mp3') - - # --- DUPLICATE CHECK --- - col = get_audio_tracks_collection() - existing = await col.find_one({"audio.yt_id": video_id}) - if existing: - LOG.info(f"Track with YouTube ID {video_id} already exists in database. Skipping download.") - return - # ------------------------ - - # 2. Download thumbnail for Telegram - if thumbnail_url: - thumbnail_path = await loop.run_in_executor(None, download_thumbnail, thumbnail_url, video_id) - - # 3. Prepare download path - tmp_dir = "stream_media" - os.makedirs(tmp_dir, exist_ok=True) - - # 4. Download audio directly (no conversion to force MP3) - ydl_opts_dl = ydl_opts.copy() - download_template = os.path.join(tmp_dir, f"{uuid.uuid4().hex[:16]}.%(ext)s") - ydl_opts_dl['outtmpl'] = download_template - - with yt_dlp.YoutubeDL(ydl_opts_dl) as ydl: - info_dl = await loop.run_in_executor(None, lambda: ydl.extract_info(url, download=True)) - final_filepath = ydl.prepare_filename(info_dl) - - if not os.path.exists(final_filepath): - LOG.error(f"File download failed for YouTube URL: {url}. Path {final_filepath} not found.") - return - - # 5. Upload to Telegram - if bot is None: - LOG.error("Bot is not initialized. Cannot upload track.") - return - - channel_id = int(Config.CHANNEL_ID) - if not channel_id: - LOG.error("CHANNEL_ID is not configured.") - return - - try: - file_size = os.path.getsize(final_filepath) - LOG.info(f"Uploading file: {os.path.basename(final_filepath)} ({file_size / (1024*1024):.2f} MB)") - - msg = await bot.send_audio( - chat_id=channel_id, - audio=final_filepath, - title=title, - performer=performer, - duration=int(duration), - thumb=thumbnail_path if thumbnail_path else None, - caption=f"
{title}
" - ) - except Exception as e: - LOG.error(f"Pyrogram upload failed: {e}") - return - - if not msg: - LOG.error("Failed to upload to Telegram (msg is null).") - return - - media = msg.audio or msg.document - if not media: - LOG.error("Uploaded message has no audio or document attribute.") - return - - # 6. Save to MongoDB - track_id = media.file_unique_id - - try: - me = await bot.get_me() - bot_id = str(me.id) - except Exception: - bot_id = "default" - - # Match the provided sample document structure - doc = { - "_id": track_id, - "source_chat_id": channel_id, - "source_message_id": msg.id, - "audio": { - "title": title, - "artist": performer, - "performer": performer, - "duration_sec": float(duration), - "type": ext, - "yt_id": video_id, - "yt_url": url, - "album": "YouTube", - "album_id": "album_youtube" - }, - "telegram": { - "file_id": media.file_id, - "mime_type": _normalize_mime_type(getattr(media, "mime_type", "audio/mpeg")), - "file_size": media.file_size, - "file_ids": { - bot_id: media.file_id - } - }, - "spotify": { - "cover_url": thumbnail_url, - "cover_source": "youtube" - }, - "fingerprint": f"{title.lower()}|{performer.lower()}|youtube|{int(duration)}", - "created_at": time.time(), - "updated_at": time.time(), - "deleted": False - } - - await col.insert_one(doc) - LOG.info(f"Successfully processed YouTube audio: {title} (Track ID: {track_id}, Msg ID: {msg.id})") - - # 7. Look for lyrics in background - try: - asyncio.create_task(get_track_lyrics(track_id)) - except Exception as e: - LOG.warning(f"Failed to trigger lyrics fetch: {e}") - - except Exception as e: - LOG.error(f"Error in background YouTube process: {e}") - finally: - # Cleanup local files - for p in [final_filepath, thumbnail_path]: - if p and os.path.exists(p): - try: - os.remove(p) - except Exception as e: - LOG.debug(f"Failed to cleanup {p}: {e}") - -@router.post("/download") -async def download_youtube_track( - req: YTDownloadRequest, - background_tasks: BackgroundTasks, - user_id: int = Depends(require_user_id) -): - """ - Trigger a YouTube audio download with cookie support and thumbnail optimization. - The process runs in the background. - """ - if not req.url: - raise HTTPException(status_code=400, detail="YouTube URL is required") - - from stream.core.source_filter import ( - FilterMode, - get_filter_mode, - is_source_allowed, - is_source_banned, - ) - - if await is_source_banned(user_id): - raise HTTPException(status_code=403, detail="User is banned from adding tracks") - - mode = get_filter_mode() - if mode == FilterMode.HYBRID: - if not await is_source_allowed(user_id): - raise HTTPException( - status_code=403, - detail="User is not in allowed contributors collection (hybrid mode)", - ) - - background_tasks.add_task(process_yt_download, req.url, user_id) - return { - "ok": True, - "message": "YouTube audio download started in background", - "url": req.url, - } diff --git a/Api/schemas/__init__.py b/Api/schemas/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/Api/schemas/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Api/schemas/auth.py b/Api/schemas/auth.py deleted file mode 100644 index 3ac9f4d..0000000 --- a/Api/schemas/auth.py +++ /dev/null @@ -1,97 +0,0 @@ -from pydantic import BaseModel - - -class TgLoginRequest(BaseModel): - init_data: str - username: str | None = None - password: str | None = None - invite_code: str | None = None - - -class PasswordLoginRequest(BaseModel): - username: str - password: str - - -class SetCredentialsRequest(BaseModel): - username: str - password: str - - -class SetCookieRequest(BaseModel): - token: str - - -class OwnerPasswordLoginRequest(BaseModel): - password: str - - -class SetOwnerPasswordRequest(BaseModel): - password: str - - -class ChangeOwnerPasswordRequest(BaseModel): - password: str - - -class FCMTokenRequest(BaseModel): - fcm_token: str - -class RegisterRequest(BaseModel): - userid: int - username: str - password: str - invite_code: str | None = None - -class ValidateOTPRequest(BaseModel): - userid: int - otp: str - - -class TelegramWidgetLoginRequest(BaseModel): - model_config = {"extra": "allow"} - - id: int - first_name: str | None = None - last_name: str | None = None - username: str | None = None - photo_url: str | None = None - auth_date: int - hash: str - invite_code: str | None = None - - -class TelegramTokenLoginRequest(BaseModel): - id_token: str - invite_code: str | None = None - - -class TelegramBotSessionRequest(BaseModel): - invite_code: str | None = None - - -class DiscordIntegrationSchema(BaseModel): - model_config = {"extra": "allow"} - enabled: bool = False - token: str = "" - mode: str = "gateway" - client_id: str = "1547543416143876167" - daemon_url: str = "ws://127.0.0.1:6472" - show_artwork: bool = True - - -class LastfmIntegrationSchema(BaseModel): - model_config = {"extra": "allow"} - enabled: bool = False - api_key: str = "" - api_secret: str = "" - session_key: str = "" - username: str = "" - scrobble_at: float = 0.5 - now_playing: bool = True - - -class IntegrationsUpdateRequest(BaseModel): - discord: DiscordIntegrationSchema | None = None - lastfm: LastfmIntegrationSchema | None = None - diff --git a/Api/schemas/browse.py b/Api/schemas/browse.py deleted file mode 100644 index ad5db24..0000000 --- a/Api/schemas/browse.py +++ /dev/null @@ -1,32 +0,0 @@ -from typing import Any - -from pydantic import BaseModel, ConfigDict, Field - -class BrowseItem(BaseModel): - model_config = ConfigDict(populate_by_name=True, ser_json_by_alias=False) - - id: str = Field(alias="_id") - source_chat_id: int | None = None - source_message_id: int | None = None - topic_id: int | None = None - topic_name: str | None = None - title: str | None = None - artist: str | None = None - album: str | None = None - album_id: str | None = None - duration_sec: int | None = None - type: str | None = None - sampling_rate_hz: int | None = None - spotify_url: str | None = None - cover_url: str | None = None - titles: dict[str, Any] | None = None - created_at: float | None = None - updated_at: float | None = None - liked: bool = False - -class BrowseResponse(BaseModel): - page: int - per_page: int - total: int - items: list[BrowseItem] - cover_url: str | None = None diff --git a/Api/schemas/favourites.py b/Api/schemas/favourites.py deleted file mode 100644 index d88507a..0000000 --- a/Api/schemas/favourites.py +++ /dev/null @@ -1,35 +0,0 @@ -from pydantic import BaseModel - -from Api.schemas.track import TrackResponse - - -class FavouriteCreate(BaseModel): - track_id: str - - -class ArtistFavouriteCreate(BaseModel): - artist_id: str | None = None - id: str | None = None - artistId: str | None = None - - -class FavouriteItem(BaseModel): - track: TrackResponse - created_at: float | None = None - - -class FavouritesResponse(BaseModel): - page: int - per_page: int - total: int - items: list[FavouriteItem] - last_updated_at: float | None = None - - -class FavouriteIdsResponse(BaseModel): - page: int - per_page: int - total: int - ids: list[str] - exists: bool - last_updated_at: float | None = None diff --git a/Api/schemas/friends.py b/Api/schemas/friends.py deleted file mode 100644 index 17255f5..0000000 --- a/Api/schemas/friends.py +++ /dev/null @@ -1,19 +0,0 @@ -from pydantic import BaseModel -from typing import Optional - -class FriendRequestPayload(BaseModel): - to: int - -class AcceptRequestPayload(BaseModel): - userId: int - -class InviteJamPayload(BaseModel): - toUserId: int - jamId: str - -class SettingsPayload(BaseModel): - share_listening: Optional[str] = None - allow_jam_invites: Optional[bool] = None - -class FcmTokenPayload(BaseModel): - token: str diff --git a/Api/schemas/playlists.py b/Api/schemas/playlists.py deleted file mode 100644 index 377b975..0000000 --- a/Api/schemas/playlists.py +++ /dev/null @@ -1,80 +0,0 @@ -from pydantic import BaseModel - -from Api.schemas.track import TrackResponse - - -class PlaylistCreate(BaseModel): - name: str - - -class PlaylistRename(BaseModel): - name: str - - -class PlaylistItem(BaseModel): - playlist_id: str - name: str - thumbnails: list[str] = [] - cover_url: str | None = None - normal_thumbnail: str | None = None - cover_id: str | None = None - created_at: float | None = None - updated_at: float | None = None - - -class PlaylistsResponse(BaseModel): - items: list[PlaylistItem] - - -class PlaylistTrackAdd(BaseModel): - track_id: str | list[str] | None = None - track_ids: list[str] | None = None - - -class PlaylistTracksResponse(BaseModel): - page: int - per_page: int - total: int - items: list[TrackResponse] - - -class AvailablePlaylistItem(BaseModel): - id: str - kind: str - name: str - thumbnails: list[str] = [] - thumbnail_url: str | None = None - normal_thumbnail: str | None = None - endpoint: str - requires_auth: bool = False - - -class AvailablePlaylistsResponse(BaseModel): - items: list[AvailablePlaylistItem] - - -class PlaylistShareResponse(BaseModel): - playlist_id: str - name: str - cover_url: str | None = None - normal_thumbnail: str | None = None - tracks: list[TrackResponse] - created_at: float | None = None - - -class UserAlbumAdd(BaseModel): - album_id: str | list[str] | None = None - album_ids: list[str] | None = None - - -class UserAlbumItem(BaseModel): - album_id: str - album: dict | None = None - saved_at: float | None = None - - -class UserAlbumsResponse(BaseModel): - page: int - per_page: int - total: int - items: list[UserAlbumItem] diff --git a/Api/schemas/topics.py b/Api/schemas/topics.py deleted file mode 100644 index 00a86fd..0000000 --- a/Api/schemas/topics.py +++ /dev/null @@ -1,21 +0,0 @@ -from pydantic import BaseModel - - -class TopicItem(BaseModel): - name: str - topic_name: str - topic_id: int | None = None - count: int = 0 - cover_url: str | None = None - thumbnail_url: str | None = None - normal_thumbnail: str | None = None - thumbnails: list[str] = [] - source_chat_id: int | None = None - endpoint: str - - -class TopicsResponse(BaseModel): - ok: bool = True - total: int - items: list[TopicItem] - topics: list[str] diff --git a/Api/schemas/track.py b/Api/schemas/track.py deleted file mode 100644 index 755b1a6..0000000 --- a/Api/schemas/track.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import Any - -from pydantic import BaseModel, ConfigDict, Field - -class TrackResponse(BaseModel): - model_config = ConfigDict(populate_by_name=True, ser_json_by_alias=True) - - id: str = Field(alias="_id") - source_chat_id: int | None = None - source_message_id: int | None = None - telegram: dict[str, Any] | None = None - audio: dict[str, Any] | None = None - spotify: dict[str, Any] | None = None - titles: dict[str, Any] | None = None - content_hash: str | None = None - fingerprint: str | None = None - created_at: float | None = None - updated_at: float | None = None - liked: bool = False - diff --git a/Api/services/__init__.py b/Api/services/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/Api/services/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Api/services/access_control.py b/Api/services/access_control.py deleted file mode 100644 index 9fd81d3..0000000 --- a/Api/services/access_control.py +++ /dev/null @@ -1,614 +0,0 @@ -""" -Access control — registration policy, account locking, session revocation and -Telegram chat-membership gating. - -Storage - auth_config/_id="access_policy" {registration_mode, enforce_membership, required_chats[], lock_message} - users.{status, token_version, lock_reason, locked_at, locked_by} - invite_codes {_id: code, created_by, created_at, max_uses, uses, expires_at, used_by[], note} - registration_allowlist {_id: user_id, added_by, added_at, note} - -Fast path - Every authenticated request consults ``get_user_access`` which is memoised for 30 s per user, - so locking a user / bumping token_version cuts every device off within seconds without a DB - round-trip per request. -""" -from __future__ import annotations - -import asyncio -import secrets -import string -import time -from typing import Any, Literal, Optional - -from stream.database.MongoDb import db_handler -from stream.helpers.logger import LOGGER - -LOG = LOGGER(__name__) - -RegistrationMode = Literal["open", "invite", "allowlist", "closed"] -UserStatus = Literal["active", "locked", "restricted"] -REGISTRATION_MODES: tuple[str, ...] = ("open", "invite", "allowlist", "closed") - -POLICY_ID = "access_policy" -_POLICY_TTL = 15.0 -_ACCESS_TTL = 30.0 -_MEMBERSHIP_TTL = 10 * 60.0 - -_policy_cache: dict[str, Any] = {"value": None, "expires": 0.0} -_access_cache: dict[int, tuple[float, dict[str, Any]]] = {} -_membership_cache: dict[int, tuple[float, bool, list[dict[str, Any]]]] = {} -_lock = asyncio.Lock() - - -def _col(name: str): - return db_handler.get_collection(name).collection - - -def _now() -> float: - return time.time() - - -# --------------------------------------------------------------------------- policy - - -def default_policy() -> dict[str, Any]: - return { - "registration_mode": "open", - "enforce_membership": False, - "required_chats": [], # [{chat_id:int, title:str, invite_link:str|None}] - "lock_message": "Your account has been locked. Contact an administrator if you think this is a mistake.", - "updated_at": 0, - } - - -async def get_policy(force: bool = False) -> dict[str, Any]: - now = _now() - if not force and _policy_cache["value"] is not None and _policy_cache["expires"] > now: - return _policy_cache["value"] - doc = None - try: - doc = await _col("auth_config").find_one({"_id": POLICY_ID}) - except Exception as exc: - LOG.warning(f"[access] policy read failed: {exc}") - policy = {**default_policy(), **{k: v for k, v in (doc or {}).items() if k != "_id"}} - if policy.get("registration_mode") not in REGISTRATION_MODES: - policy["registration_mode"] = "open" - _policy_cache["value"] = policy - _policy_cache["expires"] = now + _POLICY_TTL - return policy - - -async def update_policy(patch: dict[str, Any], by: int | None = None) -> dict[str, Any]: - clean: dict[str, Any] = {} - if "registration_mode" in patch: - mode = str(patch["registration_mode"] or "").lower() - if mode not in REGISTRATION_MODES: - raise ValueError("registration_mode must be one of open, invite, allowlist, closed") - clean["registration_mode"] = mode - if "enforce_membership" in patch: - clean["enforce_membership"] = bool(patch["enforce_membership"]) - if "lock_message" in patch and patch["lock_message"] is not None: - clean["lock_message"] = str(patch["lock_message"])[:400] - if "required_chats" in patch and patch["required_chats"] is not None: - chats = [] - for c in patch["required_chats"]: - try: - cid = int(c["chat_id"]) if isinstance(c, dict) else int(c) - except Exception: - continue - is_priv = bool(c.get("is_private")) if isinstance(c, dict) else False - chats.append( - { - "chat_id": cid, - "title": (str(c.get("title")) if isinstance(c, dict) and c.get("title") else None), - "invite_link": None if is_priv else (str(c.get("invite_link")) if isinstance(c, dict) and c.get("invite_link") else None), - "is_private": is_priv, - } - ) - clean["required_chats"] = chats - clean["updated_at"] = _now() - if by: - clean["updated_by"] = int(by) - await _col("auth_config").update_one({"_id": POLICY_ID}, {"$set": clean}, upsert=True) - _policy_cache["expires"] = 0.0 - _membership_cache.clear() - return await get_policy(force=True) - - -def public_policy(policy: dict[str, Any]) -> dict[str, Any]: - """What the signup/login screens are allowed to know.""" - return { - "registration_mode": policy["registration_mode"], - "enforce_membership": bool(policy.get("enforce_membership")), - "required_chats": [ - { - "title": c.get("title") or str(c["chat_id"]), - "invite_link": None if c.get("is_private") else c.get("invite_link"), - "is_private": bool(c.get("is_private")), - } - for c in policy.get("required_chats", []) - ], - } - - -# --------------------------------------------------------------------------- required chats - - -async def add_required_chat( - chat_id: int, - title: str | None = None, - invite_link: str | None = None, - is_private: bool = False, -) -> dict[str, Any]: - policy = await get_policy(force=True) - chats = [c for c in policy.get("required_chats", []) if int(c["chat_id"]) != int(chat_id)] - if not is_private: - if not title or not invite_link: - fetched = await describe_chat(chat_id) - title = title or fetched.get("title") - invite_link = invite_link or fetched.get("invite_link") - else: - if not title: - fetched = await describe_chat(chat_id) - title = fetched.get("title") - invite_link = None - chats.append({ - "chat_id": int(chat_id), - "title": title, - "invite_link": invite_link if not is_private else None, - "is_private": bool(is_private), - }) - return await update_policy({"required_chats": chats}) - - -async def remove_required_chat(chat_id: int) -> dict[str, Any]: - policy = await get_policy(force=True) - chats = [c for c in policy.get("required_chats", []) if int(c["chat_id"]) != int(chat_id)] - return await update_policy({"required_chats": chats}) - - -async def describe_chat(chat_id: int) -> dict[str, Any]: - """Best-effort title + invite link via the bot (needs the bot inside the chat).""" - out: dict[str, Any] = {"chat_id": int(chat_id), "title": None, "invite_link": None} - try: - from stream import bot - - if not bot: - return out - chat = await bot.get_chat(int(chat_id)) - out["title"] = getattr(chat, "title", None) or getattr(chat, "first_name", None) - username = getattr(chat, "username", None) - if username: - out["invite_link"] = f"https://t.me/{username}" - else: - out["invite_link"] = getattr(chat, "invite_link", None) - if not out["invite_link"]: - try: - out["invite_link"] = await bot.export_chat_invite_link(int(chat_id)) - except Exception: - pass - except Exception as exc: - LOG.debug(f"[access] describe_chat({chat_id}) failed: {exc}") - return out - - -# --------------------------------------------------------------------------- membership - - -_NOT_MEMBER = {"left", "kicked", "banned", "restricted_left"} - - -def _status_name(member) -> str: - st = getattr(member, "status", None) - name = getattr(st, "name", None) or str(st or "") - return name.lower() - - -def is_admin_id(user_id: int) -> bool: - try: - from stream.core.config_manager import Config - owner = getattr(Config, "OWNER_ID", 0) - sudos = getattr(Config, "SUDO_USERS", []) or [] - uid = int(user_id) - if owner and uid == int(owner): - return True - for s in sudos: - try: - if uid == int(s): - return True - except Exception: - pass - return False - except Exception: - return False - - -async def is_membership_bypassed(user_id: int) -> bool: - """True if user is admin/owner or has been granted an individual membership bypass.""" - if is_admin_id(user_id): - return True - doc = await _col("membership_bypass").find_one({"_id": int(user_id)}) - return doc is not None - - -async def bypass_add(user_id: int, by: int | None = None, note: str | None = None) -> None: - """Exempt a user from mandatory channel membership checks.""" - await _col("membership_bypass").update_one( - {"_id": int(user_id)}, - {"$set": {"added_by": by, "added_at": _now(), "note": note}}, - upsert=True, - ) - invalidate_membership(user_id) - try: - user = await _col("users").find_one({"_id": int(user_id)}, {"status": 1}) - if user and user.get("status") == "restricted": - await set_user_status(user_id, "active") - except Exception: - pass - - -async def bypass_remove(user_id: int) -> bool: - """Revoke a user's channel membership exemption.""" - res = await _col("membership_bypass").delete_one({"_id": int(user_id)}) - invalidate_membership(user_id) - return res.deleted_count > 0 - - -async def bypass_list() -> list[dict[str, Any]]: - """List all users exempt from channel membership.""" - out = [] - async for d in _col("membership_bypass").find({}).sort("added_at", -1).limit(500): - out.append({ - "user_id": int(d["_id"]), - "added_by": d.get("added_by"), - "added_at": d.get("added_at"), - "note": d.get("note"), - }) - return out - - -async def check_membership(user_id: int, force: bool = False) -> tuple[bool, list[dict[str, Any]]]: - """(ok, missing_chats). Cached 10 min per user; membership events invalidate it.""" - policy = await get_policy() - chats = policy.get("required_chats", []) - if not policy.get("enforce_membership") or not chats: - return True, [] - if await is_membership_bypassed(user_id): - return True, [] - now = _now() - cached = _membership_cache.get(int(user_id)) - if cached and not force and cached[0] > now: - return cached[1], cached[2] - missing: list[dict[str, Any]] = [] - try: - from stream import bot - except Exception: - bot = None - if not bot: - # can't verify → fail open but log; admins should keep the bot running - LOG.warning("[access] membership check skipped: bot not running") - return True, [] - for c in chats: - try: - member = await bot.get_chat_member(int(c["chat_id"]), int(user_id)) - if _status_name(member) in _NOT_MEMBER: - missing.append(c) - except Exception as exc: - # USER_NOT_PARTICIPANT etc. → not a member; other errors also count as missing - LOG.debug(f"[access] get_chat_member({c['chat_id']}, {user_id}): {exc}") - missing.append(c) - ok = not missing - _membership_cache[int(user_id)] = (now + _MEMBERSHIP_TTL, ok, missing) - return ok, missing - - -def invalidate_membership(user_id: int) -> None: - _membership_cache.pop(int(user_id), None) - - -def public_chats(chats: list[dict[str, Any]]) -> list[dict[str, Any]]: - return [ - { - "title": c.get("title") or "Required chat", - "invite_link": None if c.get("is_private") else c.get("invite_link"), - "is_private": bool(c.get("is_private")), - } - for c in chats - ] - - -# --------------------------------------------------------------------------- user status - - -async def get_user_access(user_id: int, force: bool = False) -> dict[str, Any]: - """{status, token_version, lock_reason} — memoised for 30 s.""" - uid = int(user_id) - now = _now() - hit = _access_cache.get(uid) - if hit and not force and hit[0] > now: - return hit[1] - doc = None - try: - doc = await _col("users").find_one({"_id": uid}, {"status": 1, "token_version": 1, "lock_reason": 1}) - except Exception as exc: - LOG.warning(f"[access] user read failed: {exc}") - info = { - "status": (doc or {}).get("status") or "active", - "token_version": int((doc or {}).get("token_version") or 0), - "lock_reason": (doc or {}).get("lock_reason"), - } - _access_cache[uid] = (now + _ACCESS_TTL, info) - return info - - -def invalidate_access(user_id: int | None = None) -> None: - if user_id is None: - _access_cache.clear() - else: - _access_cache.pop(int(user_id), None) - - -async def set_user_status(user_id: int, status: UserStatus, reason: str | None = None, by: int | None = None, revoke_sessions: bool = True) -> dict[str, Any]: - uid = int(user_id) - update: dict[str, Any] = {"status": status, "updated_at": _now()} - if status == "active": - update["lock_reason"] = None - update["locked_at"] = None - update["locked_by"] = None - else: - update["lock_reason"] = (reason or ("Left a required Telegram chat" if status == "restricted" else "Locked by an administrator"))[:300] - update["locked_at"] = _now() - update["locked_by"] = int(by) if by else None - ops: dict[str, Any] = {"$set": update} - if revoke_sessions and status != "active": - ops["$inc"] = {"token_version": 1} - await _col("users").update_one({"_id": uid}, ops, upsert=True) - invalidate_access(uid) - invalidate_membership(uid) - return await get_user_access(uid, force=True) - - -async def revoke_sessions(user_id: int) -> int: - """Bump token_version → every existing token for the user becomes invalid.""" - uid = int(user_id) - await _col("users").update_one({"_id": uid}, {"$inc": {"token_version": 1}, "$set": {"updated_at": _now()}}, upsert=True) - invalidate_access(uid) - return (await get_user_access(uid, force=True))["token_version"] - - -class AccessDenied(Exception): - def __init__(self, code: str, message: str, extra: dict[str, Any] | None = None, status_code: int = 403): - super().__init__(message) - self.code = code - self.message = message - self.extra = extra or {} - self.status_code = status_code - - def payload(self) -> dict[str, Any]: - return {"ok": False, "detail": self.code, "message": self.message, **self.extra} - - -async def assert_can_use(user_id: int, token_version: int | None) -> None: - """Raise AccessDenied if the account is locked/restricted or the token was revoked. - - Status is checked first so a locked user sees *why* (403 + reason) instead of a bare sign-out; - the token_version check only applies to accounts that are otherwise active. - """ - info = await get_user_access(user_id) - status = info["status"] - if status == "locked": - policy = await get_policy() - raise AccessDenied("account_locked", info.get("lock_reason") or policy["lock_message"], {"reason": info.get("lock_reason")}) - if status == "restricted": - policy = await get_policy() - raise AccessDenied( - "membership_required", - info.get("lock_reason") or "Join the required Telegram chat to continue.", - {"reason": info.get("lock_reason"), "required_chats": public_chats(policy.get("required_chats", []))}, - ) - if token_version is not None and int(token_version) < int(info["token_version"]): - raise AccessDenied("session_revoked", "Your session was signed out. Please sign in again.", status_code=401) - if token_version is None and int(info["token_version"]) > 0: - # legacy token minted before versioning on an account that has since been revoked/locked - raise AccessDenied("session_revoked", "Your session was signed out. Please sign in again.", status_code=401) - - -async def assert_can_login(user_id: int) -> None: - """Login-time gate: status + live membership check (restores a restricted user who rejoined).""" - info = await get_user_access(user_id, force=True) - if info["status"] == "locked": - policy = await get_policy() - raise AccessDenied("account_locked", info.get("lock_reason") or policy["lock_message"], {"reason": info.get("lock_reason")}) - ok, missing = await check_membership(user_id, force=True) - if not ok: - if info["status"] != "restricted": - await set_user_status(user_id, "restricted", "Left a required Telegram chat", revoke_sessions=False) - raise AccessDenied("membership_required", "Join the required Telegram chat to continue.", {"required_chats": public_chats(missing)}) - if info["status"] == "restricted": - await set_user_status(user_id, "active") - - -async def reverify_user(user_id: int) -> dict[str, Any]: - """User-triggered “I've joined — verify”. Lifts a membership restriction if satisfied.""" - ok, missing = await check_membership(user_id, force=True) - info = await get_user_access(user_id, force=True) - if ok and info["status"] == "restricted": - info = await set_user_status(user_id, "active") - elif not ok and info["status"] == "active": - info = await set_user_status(user_id, "restricted", "Left a required Telegram chat", revoke_sessions=False) - return {"ok": ok, "status": info["status"], "required_chats": public_chats(missing)} - - -# --------------------------------------------------------------------------- registration gate - - -async def assert_can_register(user_id: int, invite_code: str | None = None) -> dict[str, Any]: - """Validate the registration policy. Returns context (e.g. consumed invite) for the caller.""" - policy = await get_policy() - mode = policy["registration_mode"] - ctx: dict[str, Any] = {"mode": mode} - if mode == "closed": - raise AccessDenied("registration_closed", "Registrations are currently closed.") - if mode == "allowlist": - allowed = await _col("registration_allowlist").find_one({"_id": int(user_id)}) - if not allowed: - raise AccessDenied("not_allowlisted", "This Telegram account is not on the allow list.") - if mode == "invite": - code = (invite_code or "").strip().upper() - if not code: - raise AccessDenied("invite_required", "An invite code is required to register.", status_code=400) - inv = await _col("invite_codes").find_one({"_id": code}) - if not inv: - raise AccessDenied("invite_invalid", "That invite code is not valid.", status_code=400) - if inv.get("revoked_at"): - raise AccessDenied("invite_invalid", "That invite code was revoked.", status_code=400) - if inv.get("expires_at") and float(inv["expires_at"]) < _now(): - raise AccessDenied("invite_expired", "That invite code has expired.", status_code=400) - if int(inv.get("max_uses") or 0) and int(inv.get("uses") or 0) >= int(inv["max_uses"]): - raise AccessDenied("invite_exhausted", "That invite code has already been used.", status_code=400) - ctx["invite_code"] = code - ok, missing = await check_membership(user_id, force=True) - if not ok: - raise AccessDenied("membership_required", "Join the required Telegram chat before registering.", {"required_chats": public_chats(missing)}) - return ctx - - -async def consume_invite(code: str, user_id: int) -> None: - """Atomic: only succeeds while uses < max_uses (or unlimited).""" - c = (code or "").strip().upper() - if not c: - return - res = await _col("invite_codes").update_one( - {"_id": c, "revoked_at": None, "$or": [{"max_uses": 0}, {"max_uses": None}, {"$expr": {"$lt": ["$uses", "$max_uses"]}}]}, - {"$inc": {"uses": 1}, "$addToSet": {"used_by": int(user_id)}, "$set": {"last_used_at": _now()}}, - ) - if res.matched_count == 0: - raise AccessDenied("invite_exhausted", "That invite code has already been used.", status_code=400) - - -# --------------------------------------------------------------------------- invites & allowlist - - -def _gen_code(n: int = 8) -> str: - alphabet = string.ascii_uppercase + string.digits - alphabet = alphabet.replace("O", "").replace("0", "").replace("I", "").replace("1", "") - return "".join(secrets.choice(alphabet) for _ in range(n)) - - -async def create_invite(by: int, max_uses: int = 1, ttl_days: int | None = 7, note: str | None = None) -> dict[str, Any]: - code = _gen_code() - doc = { - "_id": code, - "created_by": int(by), - "created_at": _now(), - "max_uses": max(0, int(max_uses)), - "uses": 0, - "used_by": [], - "expires_at": (_now() + int(ttl_days) * 86400) if ttl_days else None, - "revoked_at": None, - "note": (note or "")[:120] or None, - } - await _col("invite_codes").insert_one(doc) - return _invite_public(doc) - - -def _invite_public(d: dict[str, Any]) -> dict[str, Any]: - return { - "code": d["_id"], - "created_by": d.get("created_by"), - "created_at": d.get("created_at"), - "max_uses": d.get("max_uses") or 0, - "uses": d.get("uses") or 0, - "used_by": d.get("used_by") or [], - "expires_at": d.get("expires_at"), - "revoked_at": d.get("revoked_at"), - "note": d.get("note"), - } - - -async def list_invites(include_dead: bool = False) -> list[dict[str, Any]]: - out = [] - async for d in _col("invite_codes").find({}).sort("created_at", -1).limit(200): - pub = _invite_public(d) - dead = bool(pub["revoked_at"]) or (pub["expires_at"] and pub["expires_at"] < _now()) or (pub["max_uses"] and pub["uses"] >= pub["max_uses"]) - if include_dead or not dead: - out.append(pub) - return out - - -async def revoke_invite(code: str) -> bool: - res = await _col("invite_codes").update_one({"_id": (code or "").strip().upper(), "revoked_at": None}, {"$set": {"revoked_at": _now()}}) - return res.modified_count > 0 - - -async def allowlist_add(user_id: int, by: int | None = None, note: str | None = None) -> None: - await _col("registration_allowlist").update_one({"_id": int(user_id)}, {"$set": {"added_by": by, "added_at": _now(), "note": note}}, upsert=True) - - -async def allowlist_remove(user_id: int) -> bool: - res = await _col("registration_allowlist").delete_one({"_id": int(user_id)}) - return res.deleted_count > 0 - - -async def allowlist_list() -> list[dict[str, Any]]: - out = [] - async for d in _col("registration_allowlist").find({}).sort("added_at", -1).limit(500): - out.append({"user_id": int(d["_id"]), "added_by": d.get("added_by"), "added_at": d.get("added_at"), "note": d.get("note")}) - return out - - -# --------------------------------------------------------------------------- users (admin) - - -async def list_users(query: str | None = None, status: str | None = None, limit: int = 100, skip: int = 0) -> dict[str, Any]: - q: dict[str, Any] = {"password": {"$exists": True}} - if status in ("active", "locked", "restricted"): - q["status"] = status if status != "active" else {"$in": ["active", None]} - if query: - qs = str(query).strip() - ors: list[dict[str, Any]] = [{"username": {"$regex": qs, "$options": "i"}}, {"first_name": {"$regex": qs, "$options": "i"}}, {"telegram.username": {"$regex": qs, "$options": "i"}}] - if qs.isdigit(): - ors.append({"_id": int(qs)}) - q["$or"] = ors - col = _col("users") - total = await col.count_documents(q) - items = [] - cursor = col.find(q, {"username": 1, "first_name": 1, "profile_url": 1, "photo_url": 1, "telegram": 1, "status": 1, "lock_reason": 1, "locked_at": 1, "token_version": 1, "created_at": 1, "updated_at": 1}).sort("created_at", -1).skip(int(skip)).limit(int(limit)) - async for d in cursor: - items.append( - { - "user_id": int(d["_id"]), - "username": d.get("username"), - "first_name": d.get("first_name"), - "profile_url": d.get("profile_url") or d.get("photo_url"), - "telegram_username": (d.get("telegram") or {}).get("username") if isinstance(d.get("telegram"), dict) else None, - "status": d.get("status") or "active", - "lock_reason": d.get("lock_reason"), - "locked_at": d.get("locked_at"), - "token_version": int(d.get("token_version") or 0), - "created_at": d.get("created_at"), - } - ) - return {"total": total, "items": items} - - -async def reverify_all(limit: int = 2000) -> dict[str, int]: - """Admin sweep: re-check membership for every registered user (rate-friendly, sequential).""" - policy = await get_policy(force=True) - if not policy.get("enforce_membership") or not policy.get("required_chats"): - return {"checked": 0, "restricted": 0, "restored": 0} - checked = restricted = restored = 0 - async for d in _col("users").find({"password": {"$exists": True}}, {"_id": 1, "status": 1}).limit(limit): - uid = int(d["_id"]) - checked += 1 - ok, _missing = await check_membership(uid, force=True) - status = d.get("status") or "active" - if not ok and status == "active": - await set_user_status(uid, "restricted", "Left a required Telegram chat", revoke_sessions=False) - restricted += 1 - elif ok and status == "restricted": - await set_user_status(uid, "active") - restored += 1 - await asyncio.sleep(0.05) - return {"checked": checked, "restricted": restricted, "restored": restored} diff --git a/Api/services/discord_remote_auth.py b/Api/services/discord_remote_auth.py deleted file mode 100644 index 18b590f..0000000 --- a/Api/services/discord_remote_auth.py +++ /dev/null @@ -1,284 +0,0 @@ -""" -Discord Remote Auth (QR Code Login) Service. - -Implements the Discord Remote Auth Gateway protocol (v2) over WebSocket: -- Connects to wss://remote-auth-gateway.discord.gg/?v=2 with Origin: https://discord.com -- Generates an ephemeral RSA-2048 keypair -- Handles the hello -> init -> nonce_proof challenge -- Produces the QR code fingerprint URL (https://discord.com/ra/) -- Awaits mobile scan (pending_remote_init) & approval (pending_ticket) -- Exchanges ticket for the user token via https://discord.com/api/v9/users/@me/remote-auth/login -""" -from __future__ import annotations - -import asyncio -import base64 -import hashlib -import json -import time -from typing import AsyncGenerator, Dict, Any, Optional - -import aiohttp -from cryptography.hazmat.primitives.asymmetric import rsa, padding -from cryptography.hazmat.primitives import hashes, serialization - -from stream.helpers.logger import LOGGER - -GATEWAY_URL = "wss://remote-auth-gateway.discord.gg/?v=2" -LOGIN_API_URL = "https://discord.com/api/v9/users/@me/remote-auth/login" - -HEADERS = { - "Origin": "https://discord.com", - "User-Agent": ( - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36" - ), -} - - -class DiscordRemoteAuthSession: - def __init__(self, session_id: str) -> None: - self.session_id = session_id - self._privkey = rsa.generate_private_key(public_exponent=65537, key_size=2048) - self._cancelled = False - self._ws: Optional[aiohttp.ClientWebSocketResponse] = None - self._heartbeat_task: Optional[asyncio.Task] = None - - spki_der = self._privkey.public_key().public_bytes( - encoding=serialization.Encoding.DER, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ) - self.public_key_b64 = base64.b64encode(spki_der).decode("utf-8") - - # Session state - self.status = "init" - self.fingerprint: Optional[str] = None - self.qr_url: Optional[str] = None - self.user_info: Optional[Dict[str, Any]] = None - self.token: Optional[str] = None - self.error: Optional[str] = None - self.created_at = time.time() - - def cancel(self) -> None: - self._cancelled = True - self.status = "cancelled" - if self._heartbeat_task and not self._heartbeat_task.done(): - self._heartbeat_task.cancel() - if self._ws and not self._ws.closed: - asyncio.create_task(self._ws.close()) - - def _decrypt_oaep(self, enc_b64: str) -> bytes: - cipher_bytes = base64.b64decode(enc_b64) - return self._privkey.decrypt( - cipher_bytes, - padding.OAEP( - mgf=padding.MGF1(algorithm=hashes.SHA256()), - algorithm=hashes.SHA256(), - label=None, - ), - ) - - async def _heartbeat_loop(self, interval_ms: int) -> None: - interval_sec = max(5.0, (interval_ms / 1000.0) * 0.9) - try: - while not self._cancelled and self._ws and not self._ws.closed: - await asyncio.sleep(interval_sec) - if self._ws and not self._ws.closed: - await self._ws.send_json({"op": "heartbeat"}) - except asyncio.CancelledError: - pass - except Exception as exc: - LOGGER(__name__).debug(f"[DiscordRA {self.session_id}] Heartbeat ended: {exc}") - - async def stream_events(self) -> AsyncGenerator[Dict[str, Any], None]: - """Stream real-time lifecycle events to the caller.""" - yield {"type": "status", "status": "connecting", "session_id": self.session_id} - - connector = aiohttp.TCPConnector(ssl=True) - timeout = aiohttp.ClientTimeout(total=180) - - async with aiohttp.ClientSession(headers=HEADERS, connector=connector, timeout=timeout) as http: - try: - async with http.ws_connect(GATEWAY_URL) as ws: - self._ws = ws - LOGGER(__name__).info(f"[DiscordRA {self.session_id}] Connected to Remote Auth Gateway") - - async for msg in ws: - if self._cancelled: - break - - if msg.type == aiohttp.WSMsgType.TEXT: - try: - payload = json.loads(msg.data) - except json.JSONDecodeError: - continue - - op = payload.get("op") - - if op == "hello": - heartbeat_interval = payload.get("heartbeat_interval", 41250) - self._heartbeat_task = asyncio.create_task( - self._heartbeat_loop(heartbeat_interval) - ) - # Send public key in init - await ws.send_json({ - "op": "init", - "encoded_public_key": self.public_key_b64, - }) - - elif op == "nonce_proof": - enc_nonce = payload.get("encrypted_nonce", "") - try: - decrypted_nonce = self._decrypt_oaep(enc_nonce) - digest = hashlib.sha256(decrypted_nonce).digest() - proof = ( - base64.urlsafe_b64encode(digest) - .decode("utf-8") - .rstrip("=") - ) - await ws.send_json({ - "op": "nonce_proof", - "proof": proof, - }) - except Exception as exc: - self.error = f"Nonce proof failed: {exc}" - self.status = "error" - yield {"type": "error", "message": self.error} - return - - elif op in ("fingerprint", "pending_remote_init"): - # If payload has a fingerprint and no user yet, this is the QR initialization - fp = payload.get("fingerprint") - user = payload.get("user") - - if fp and not self.fingerprint: - self.fingerprint = fp - self.qr_url = f"https://discord.com/ra/{fp}" - self.status = "waiting_scan" - yield { - "type": "qr", - "url": self.qr_url, - "fingerprint": self.fingerprint, - "session_id": self.session_id, - } - - if user: - # User has scanned the QR code with their mobile Discord app! - self.user_info = { - "id": user.get("id"), - "username": user.get("username"), - "discriminator": user.get("discriminator"), - "avatar": user.get("avatar"), - } - self.status = "scanned" - yield { - "type": "scanned", - "user": self.user_info, - "session_id": self.session_id, - } - - elif op == "pending_ticket": - # Mobile scanned the code — decrypts user info from encrypted_user_payload - enc_payload = payload.get("encrypted_user_payload") - if enc_payload: - try: - decrypted_bytes = self._decrypt_oaep(enc_payload) - decrypted_text = decrypted_bytes.decode("utf-8") - # Format: user_id:discriminator:avatar_hash:username - parts = decrypted_text.split(":") - u_id = parts[0] if len(parts) > 0 else None - u_disc = parts[1] if len(parts) > 1 else "0" - u_av = parts[2] if len(parts) > 2 and parts[2] not in ("None", "") else None - u_name = parts[3] if len(parts) > 3 else "Discord User" - self.user_info = { - "id": u_id, - "username": u_name, - "discriminator": u_disc, - "avatar": u_av, - } - LOGGER(__name__).info(f"[DiscordRA {self.session_id}] Mobile scan confirmed for @{u_name}") - except Exception as exc: - LOGGER(__name__).warning(f"[DiscordRA {self.session_id}] Failed to parse user payload: {exc}") - - self.status = "scanned" - yield { - "type": "scanned", - "user": self.user_info, - "session_id": self.session_id, - } - - elif op == "pending_login": - # Mobile tapped "Log in" — payload contains the actual authorization ticket! - ticket = payload.get("ticket") - if not ticket: - LOGGER(__name__).warning(f"[DiscordRA {self.session_id}] pending_login without ticket: {payload}") - continue - - self.status = "confirming" - yield {"type": "status", "status": "confirming", "session_id": self.session_id} - - # Exchange ticket for token via Discord REST API - try: - async with http.post( - LOGIN_API_URL, - json={"ticket": ticket}, - headers={"Content-Type": "application/json"}, - ) as resp: - if resp.status != 200: - body = await resp.text() - self.error = f"Ticket exchange failed ({resp.status}): {body}" - self.status = "error" - yield {"type": "error", "message": self.error} - return - - login_data = await resp.json() - enc_token = login_data.get("encrypted_token") - if not enc_token: - self.error = "No encrypted_token in Discord response" - self.status = "error" - yield {"type": "error", "message": self.error} - return - - token_bytes = self._decrypt_oaep(enc_token) - self.token = token_bytes.decode("utf-8") - self.status = "success" - - LOGGER(__name__).info(f"[DiscordRA {self.session_id}] Successfully extracted Discord token for user!") - - yield { - "type": "success", - "token": self.token, - "user": self.user_info, - "session_id": self.session_id, - } - - # Handshake complete — clean exit - try: - await ws.send_json({"op": "cancel"}) - except Exception: - pass - return - except Exception as exc: - self.error = f"Error during token exchange: {exc}" - self.status = "error" - yield {"type": "error", "message": self.error} - return - - elif op == "cancel": - self.status = "cancelled" - yield {"type": "cancelled", "session_id": self.session_id} - return - - elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): - break - - except Exception as exc: - if not self._cancelled: - LOGGER(__name__).error(f"[DiscordRA {self.session_id}] WebSocket error: {exc}") - self.error = str(exc) - self.status = "error" - yield {"type": "error", "message": f"Connection error: {exc}"} - finally: - if self._heartbeat_task and not self._heartbeat_task.done(): - self._heartbeat_task.cancel() - self._ws = None diff --git a/Api/services/lyrics_service.py b/Api/services/lyrics_service.py deleted file mode 100644 index 98f06c6..0000000 --- a/Api/services/lyrics_service.py +++ /dev/null @@ -1,486 +0,0 @@ -import asyncio -import base64 -import re -import xml.etree.ElementTree as ET - -import aiohttp -from fastapi import HTTPException - -from Api.deps.db import get_audio_tracks_collection -from Api.services.musixmatch import fetch_track_lyrics_from_musixmatch -from stream.core.config_manager import Config -from stream.helpers.logger import LOGGER -from stream.plugins.db.lyrics import fetch_best_lyrics -from stream.plugins.db.telegraph import publish_lyrics_text_to_graph - -_TELEGRAPH_TASKS: dict[str, asyncio.Task] = {} -_TELEGRAPH_TASKS_LOCK = asyncio.Lock() - -LOG = LOGGER(__name__) - - -def _parse_time_ms(time_str: str) -> int: - if not time_str: - return 0 - if ":" in time_str: - parts = time_str.split(":") - if len(parts) == 3: - h, m, s = float(parts[0]), float(parts[1]), float(parts[2]) - return int((h * 3600 + m * 60 + s) * 1000) - elif len(parts) == 2: - m, s = float(parts[0]), float(parts[1]) - return int((m * 60 + s) * 1000) - try: - return int(float(time_str) * 1000) - except Exception: - return 0 - - -def _format_tag(ms: int, open_ch: str = "[") -> str: - total_sec = ms // 1000 - m = total_sec // 60 - s = total_sec % 60 - millis = ms % 1000 - close_ch = ">" if open_ch == "<" else "]" - return f"{open_ch}{m:02d}:{s:02d}.{millis:03d}{close_ch}" - - -def _ttml_to_enhanced_lrc(ttml: str) -> str | None: - """Ported from piTube TtmlParser: converts Apple Music TTML to enhanced LRC with syllable merging.""" - if not ttml or " str: - s = (value or "").strip() - if s.startswith("`") and s.endswith("`") and len(s) >= 2: - s = s[1:-1].strip() - if s.startswith("'") and s.endswith("'") and len(s) >= 2: - s = s[1:-1].strip() - if s.startswith('"') and s.endswith('"') and len(s) >= 2: - s = s[1:-1].strip() - return s - - -def _extract_cached_lyrics(doc: dict) -> dict | None: - cache = doc.get("lyrics_cache") if isinstance(doc.get("lyrics_cache"), dict) else {} - text = cache.get("text") - if not isinstance(text, str) or not text.strip(): - return None - kind = cache.get("kind") - source = cache.get("source") - out = { - "lyrics": text, - "kind": str(kind) if isinstance(kind, str) and kind.strip() else None, - "source": str(source) if isinstance(source, str) and source.strip() else None, - } - return out - - -async def _publish_telegraph_and_store( - *, - track_id: str, - title: str, - artist: str, - album: str | None, - lyrics: str, -) -> None: - try: - tg = await publish_lyrics_text_to_graph( - track_id=track_id, - title=title, - artist=artist, - album=album, - lyrics=lyrics, - ) - except Exception: - tg = None - - url = "" - if isinstance(tg, dict): - url = _clean_telegraph_url(str(tg.get("url") or "")) - if not url: - return - - try: - col = get_audio_tracks_collection() - await col.update_one({"_id": track_id}, {"$set": {"lyrics": url}}, upsert=False) - except Exception: - return - - -async def _ensure_telegraph_background( - *, - track_id: str, - title: str, - artist: str, - album: str | None, - lyrics: str, -) -> None: - track_id = (track_id or "").strip() - if not track_id: - return - - async with _TELEGRAPH_TASKS_LOCK: - t = _TELEGRAPH_TASKS.get(track_id) - if t and not t.done(): - return - - task = asyncio.create_task( - _publish_telegraph_and_store( - track_id=track_id, - title=title, - artist=artist, - album=album, - lyrics=lyrics, - ) - ) - _TELEGRAPH_TASKS[track_id] = task - - def _done(_t: asyncio.Task): - async def _cleanup(): - async with _TELEGRAPH_TASKS_LOCK: - cur = _TELEGRAPH_TASKS.get(track_id) - if cur is _t: - _TELEGRAPH_TASKS.pop(track_id, None) - - asyncio.create_task(_cleanup()) - - task.add_done_callback(_done) - - -async def get_track_lyrics(track_id: str, provider: str | None = None) -> dict: - track_id = (track_id or "").strip() - if not track_id: - raise HTTPException(status_code=400, detail="track_id is required") - - col = get_audio_tracks_collection() - doc = await col.find_one( - {"_id": track_id}, - projection={"audio": 1, "telegram": 1, "spotify": 1, "lyrics": 1, "lyrics_cache": 1, "titles": 1}, - ) - if not doc: - raise HTTPException(status_code=404, detail="track not found") - - audio = doc.get("audio") if isinstance(doc.get("audio"), dict) else {} - telegram = doc.get("telegram") if isinstance(doc.get("telegram"), dict) else {} - - title = (audio.get("title") or "").strip() or (telegram.get("title") or "").strip() - artist = (audio.get("artist") or "").strip() or (audio.get("performer") or "").strip() or (telegram.get("artist") or "").strip() - album = (audio.get("album") or "").strip() or (telegram.get("album") or "").strip() - duration_sec = int(audio.get("duration") or telegram.get("duration") or 0) - - if not title: - return {"ok": False, "error": "missing_title"} - - # If titles (romanized / translations) not yet indexed, enrich in background via Musixmatch - if not doc.get("titles") and bool(getattr(Config, "MUSIXMATCH", True)): - try: - from Api.services.musixmatch import fetch_and_save_musixmatch_titles - asyncio.create_task(fetch_and_save_musixmatch_titles(track_id=track_id, track=doc)) - except Exception: - pass - - provider_clean = (provider or "").strip().lower() - cached_url = _clean_telegraph_url(doc.get("lyrics") if isinstance(doc.get("lyrics"), str) else "") - cached = _extract_cached_lyrics(doc) - - # Use cache if no specific provider was forced, or if cached source matches the requested provider - if cached and (not provider_clean or provider_clean == "auto" or cached.get("source") == provider_clean): - out = {"ok": True, "track_id": track_id, "lyrics": cached["lyrics"]} - if cached.get("kind"): - out["kind"] = cached["kind"] - if cached.get("source"): - out["source"] = cached["source"] - if cached_url: - out["telegraph_url"] = cached_url - else: - try: - asyncio.create_task( - _ensure_telegraph_background( - track_id=track_id, - title=title, - artist=artist or "Unknown", - album=album or None, - lyrics=str(cached["lyrics"] or ""), - ) - ) - except Exception: - pass - return out - - async def _save_to_cache(res_dict: dict) -> None: - try: - col_t = get_audio_tracks_collection() - update_fields: dict[str, Any] = { - "lyrics_cache.text": str(res_dict.get("lyrics") or ""), - "lyrics_cache.kind": str(res_dict.get("kind") or ""), - "lyrics_cache.source": str(res_dict.get("source") or "unknown"), - "lyrics_cache.updated_at": __import__("time").time(), - } - if res_dict.get("titles"): - update_fields["titles"] = res_dict["titles"] - update_fields["audio.titles"] = res_dict["titles"] - await col_t.update_one( - {"_id": track_id}, - {"$set": update_fields}, - upsert=False, - ) - if cached_url: - res_dict["telegraph_url"] = cached_url - else: - asyncio.create_task( - _ensure_telegraph_background( - track_id=track_id, - title=title, - artist=artist or "Unknown", - album=album or None, - lyrics=str(res_dict.get("lyrics") or ""), - ) - ) - res_dict["telegraph_url"] = _clean_telegraph_url(str(res_dict.get("telegraph_url") or "")) - telegraph_url = (res_dict.get("telegraph_url") or "").strip() - if telegraph_url and telegraph_url != cached_url: - await col_t.update_one( - {"_id": track_id}, - {"$set": {"lyrics": telegraph_url}}, - upsert=False, - ) - except Exception: - pass - - async def _try_betterlyrics() -> dict | None: - """BetterLyrics: Word-timed TTML from lyrics-api.boidu.dev (piTube Apple Music provider).""" - try: - if bool(getattr(Config, "DEBUG", False)): - LOG.debug(f"[lyrics] method=betterlyrics track_id={track_id!r} title={title!r} artist={artist!r}") - params = {"s": title, "a": artist} - if album: - params["al"] = album - if duration_sec > 0: - params["d"] = str(duration_sec) - - async with aiohttp.ClientSession(headers={"User-Agent": "piTube/1.0"}) as session: - async with session.get( - "https://lyrics-api.boidu.dev/getLyrics", - params=params, - timeout=aiohttp.ClientTimeout(total=8), - ) as resp: - if resp.status != 200: - return None - data = await resp.json(content_type=None) - ttml = data.get("ttml") if isinstance(data, dict) else None - if not ttml or not isinstance(ttml, str): - return None - - enhanced_lrc = _ttml_to_enhanced_lrc(ttml) - if not enhanced_lrc: - return None - - res = { - "ok": True, - "track_id": track_id, - "lyrics": enhanced_lrc, - "kind": "richsync", - "source": "betterlyrics", - } - await _save_to_cache(res) - return res - except Exception as e: - if bool(getattr(Config, "DEBUG", False)): - LOG.debug(f"[lyrics] betterlyrics failed: {e}") - return None - - async def _try_musixmatch() -> dict | None: - """Musixmatch: RichSync word timestamps and line-synced lyrics.""" - try: - if bool(getattr(Config, "DEBUG", False)): - LOG.debug(f"[lyrics] method=musixmatch track_id={track_id!r}") - mxm = await fetch_track_lyrics_from_musixmatch(track=doc) - if mxm.get("ok"): - mxm["track_id"] = track_id - await _save_to_cache(mxm) - return mxm - except Exception as e: - if bool(getattr(Config, "DEBUG", False)): - LOG.debug(f"[lyrics] musixmatch failed: {e}") - return None - - async def _try_lrclib() -> dict | None: - """LRCLIB: Fast open-source synchronized LRC lyrics.""" - try: - if bool(getattr(Config, "DEBUG", False)): - LOG.debug(f"[lyrics] method=lrclib track_id={track_id!r}") - res = await fetch_best_lyrics(title=title, artist=artist or None, album=album or None) - if res.get("ok"): - res["track_id"] = track_id - await _save_to_cache(res) - return res - except Exception as e: - if bool(getattr(Config, "DEBUG", False)): - LOG.debug(f"[lyrics] lrclib failed: {e}") - return None - - async def _try_kugou() -> dict | None: - """KuGou: Large synced lyrics catalog ported from piTube.""" - try: - if bool(getattr(Config, "DEBUG", False)): - LOG.debug(f"[lyrics] method=kugou track_id={track_id!r} title={title!r} artist={artist!r}") - clean_title = re.sub(r"\(.*?\)|\[.*?\]", "", title).strip() - clean_artist = re.sub(r"\(.*?\)|\[.*?\]", "", artist).strip() - query = f"{clean_title} - {clean_artist}".strip() - if not query: - return None - - async with aiohttp.ClientSession(headers={"User-Agent": "piTube/1.0"}) as session: - async with session.get( - "https://mobileservice.kugou.com/api/v3/search/song", - params={"version": "9108", "plat": "0", "pagesize": "6", "showtype": "0", "keyword": query}, - timeout=aiohttp.ClientTimeout(total=8), - ) as r1: - if r1.status != 200: - return None - data1 = await r1.json(content_type=None) - info = (data1.get("data") or {}).get("info") or [] - if not info: - return None - - target_hash = None - for song in info: - if duration_sec > 0 and abs(int(song.get("duration") or 0) - duration_sec) > 8: - continue - h = (song.get("hash") or "").strip() - if h: - target_hash = h - break - if not target_hash and info: - target_hash = (info[0].get("hash") or "").strip() - if not target_hash: - return None - - async with session.get( - "https://lyrics.kugou.com/search", - params={"ver": "1", "man": "yes", "client": "pc", "hash": target_hash}, - timeout=aiohttp.ClientTimeout(total=8), - ) as r2: - if r2.status != 200: - return None - data2 = await r2.json(content_type=None) - cands = data2.get("candidates") or [] - if not cands: - return None - cand_id = cands[0].get("id") - accesskey = cands[0].get("accesskey") - if not cand_id or not accesskey: - return None - - async with session.get( - "https://lyrics.kugou.com/download", - params={"fmt": "lrc", "charset": "utf8", "client": "pc", "ver": "1", "id": str(cand_id), "accesskey": str(accesskey)}, - timeout=aiohttp.ClientTimeout(total=8), - ) as r3: - if r3.status != 200: - return None - data3 = await r3.json(content_type=None) - raw_content = data3.get("content") or "" - if not raw_content: - return None - decoded = base64.b64decode(raw_content).decode("utf-8", errors="ignore").strip() - if not decoded: - return None - - res = { - "ok": True, - "track_id": track_id, - "lyrics": decoded, - "kind": "synced", - "source": "kugou", - } - await _save_to_cache(res) - return res - except Exception as e: - if bool(getattr(Config, "DEBUG", False)): - LOG.debug(f"[lyrics] kugou failed: {e}") - return None - - # Priority ordering based on requested provider - providers_map = { - "betterlyrics": _try_betterlyrics, - "musixmatch": _try_musixmatch, - "lrclib": _try_lrclib, - "kugou": _try_kugou, - } - - if provider_clean in providers_map: - # User specified an explicit provider - try that first, then fall back - chain = [providers_map[provider_clean]] + [fn for k, fn in providers_map.items() if k != provider_clean] - else: - # Auto priority order: BetterLyrics (Apple Music word-timed) -> Musixmatch (RichSync) -> LRCLIB -> KuGou - chain = [_try_betterlyrics, _try_musixmatch, _try_lrclib, _try_kugou] - - for try_fn in chain: - r = await try_fn() - if r and r.get("ok"): - return r - - return {"ok": False, "error": "no_lyrics_found", "track_id": track_id} diff --git a/Api/services/musixmatch.py b/Api/services/musixmatch.py deleted file mode 100644 index 1e739cb..0000000 --- a/Api/services/musixmatch.py +++ /dev/null @@ -1,699 +0,0 @@ -import asyncio -import json -import os -from pathlib import Path -import time -from typing import Any - -import httpx - -from stream.core.config_manager import Config - -_BASE_URL = "https://apic.musixmatch.com/ws/1.1" -_APP_ID = "mac-ios-v2.0" -_TOKEN_FILE = Path(__file__).resolve().parent.parent.parent / "cookies" / ".musixmatch_token" - -_token_lock = asyncio.Lock() -_cached_user_token: str | None = None -_cached_user_token_ts: float = 0.0 - -_DEFAULT_HEADERS = { - "Accept": "application/json, text/plain;q=0.9, */*;q=0.8", - "User-Agent": "Mozilla/5.0", -} - -def _safe_json(resp: httpx.Response) -> Any: - try: - return resp.json() - except Exception: - pass - try: - text = resp.content.decode("utf-8-sig", errors="replace") - return json.loads(text) - except Exception: - return {} - - -def _sync_get_json(*, url: str, params: dict[str, Any]) -> tuple[int, Any]: - with httpx.Client(timeout=10, follow_redirects=True, headers=_DEFAULT_HEADERS) as client: - r = client.get(url, params=params) - return r.status_code, _safe_json(r) - - -def _extract_header_status(payload: Any) -> int | None: - try: - v = (((payload or {}).get("message") or {}).get("header") or {}).get("status_code") - return int(v) - except Exception: - return None - - -def _pick_spotify_track_id(track: dict) -> str | None: - spotify = track.get("spotify") if isinstance(track.get("spotify"), dict) else {} - for k in ("track_spotify_id", "spotify_track_id", "spotify_id", "track_id", "id"): - v = spotify.get(k) - if isinstance(v, str) and v.strip(): - return v.strip() - - url = spotify.get("url") or spotify.get("spotify_url") - if isinstance(url, str) and url.strip(): - s = url.strip() - marker = "/track/" - if marker in s: - tail = s.split(marker, 1)[1] - tid = tail.split("?", 1)[0].split("/", 1)[0].strip() - if tid: - return tid - return None - - -def _lrc_timestamp(total_seconds: float) -> str: - try: - t = float(total_seconds) - except Exception: - t = 0.0 - if t < 0: - t = 0.0 - mm = int(t // 60) - sec_f = t - (mm * 60) - ss = int(sec_f // 1) - hh = int(round((sec_f - ss) * 100)) - if hh >= 100: - hh = 0 - ss += 1 - if ss >= 60: - ss = 0 - mm += 1 - return f"[{mm:02d}:{ss:02d}.{hh:02d}]" - - -def _word_timestamp(total_seconds: float) -> str: - try: - t = float(total_seconds) - except Exception: - t = 0.0 - if t < 0: - t = 0.0 - mm = int(t // 60) - sec_f = t - (mm * 60) - ss = int(sec_f // 1) - hh = int(round((sec_f - ss) * 100)) - if hh >= 100: - hh = 0 - ss += 1 - if ss >= 60: - ss = 0 - mm += 1 - return f"<{mm:02d}:{ss:02d}.{hh:02d}>" - - -def _richsync_json_to_lrc(richsync_body: str) -> str | None: - s = (richsync_body or "").strip() - if not s: - return None - try: - items = json.loads(s) if isinstance(s, str) else s - except Exception: - return None - if not isinstance(items, list) or not items: - return None - out: list[str] = [] - for it in items: - if not isinstance(it, dict): - continue - line_text = (it.get("x") or "").strip() - words = it.get("l") - ts_val = it.get("ts") - try: - line_ts = float(ts_val) if ts_val is not None else 0.0 - except Exception: - line_ts = 0.0 - if not line_text and not words: - continue - line_tag = _lrc_timestamp(line_ts) - if isinstance(words, list) and words: - parts = [line_tag] - for w in words: - if not isinstance(w, dict): - continue - c = w.get("c") or "" - offset = w.get("o") - try: - word_offset = float(offset) if offset is not None else 0.0 - except Exception: - word_offset = 0.0 - if not c.strip(): - parts.append(c) - else: - parts.append(f"{_word_timestamp(line_ts + word_offset)}{c}") - out.append("".join(parts).rstrip()) - else: - out.append(f"{line_tag} {line_text}".rstrip()) - joined = "\n".join(out).strip() - return joined or None - - -def _subtitles_json_to_lrc(subtitle_body: str) -> str | None: - s = (subtitle_body or "").strip() - if not s: - return None - try: - items = json.loads(s) - except Exception: - return None - if not isinstance(items, list) or not items: - return None - out: list[str] = [] - for it in items: - if not isinstance(it, dict): - continue - text = it.get("text") - if not isinstance(text, str): - text = "" - time_obj = it.get("time") if isinstance(it.get("time"), dict) else {} - total = time_obj.get("total") - ts = _lrc_timestamp(float(total) if total is not None else 0.0) - text = text.rstrip() - if text: - out.append(f"{ts} {text}") - else: - out.append(ts) - joined = "\n".join(out).strip() - return joined or None - - -def _extract_plain_lyrics(payload: Any) -> str | None: - try: - macro = (((payload or {}).get("message") or {}).get("body") or {}).get("macro_calls") or {} - lyr = macro.get("track.lyrics.get") or {} - body = ((lyr.get("message") or {}).get("body") or {}).get("lyrics") or {} - text = (body.get("lyrics_body") or "").strip() - if not text: - return None - lines = text.splitlines() - cleaned: list[str] = [] - for line in lines: - if line.strip().startswith("***") and "This Lyrics" in line: - break - cleaned.append(line.rstrip()) - out = "\n".join(cleaned).strip() - return out or None - except Exception: - return None - - -def _extract_synced_subtitles(payload: Any) -> str | None: - try: - macro = (((payload or {}).get("message") or {}).get("body") or {}).get("macro_calls") or {} - # 1. Check for richsync in macro if returned - rich = macro.get("track.richsync.get") or {} - rich_body = ((rich.get("message") or {}).get("body") or {}).get("richsync") or {} - rich_text = (rich_body.get("richsync_body") or "").strip() - if rich_text: - parsed_rich = _richsync_json_to_lrc(rich_text) - if parsed_rich: - return parsed_rich - - # 2. Check standard subtitles - sub = macro.get("track.subtitles.get") or {} - body = ((sub.get("message") or {}).get("body") or {}) - subtitle_list = body.get("subtitle_list") or [] - if not isinstance(subtitle_list, list) or not subtitle_list: - return None - first = subtitle_list[0] if isinstance(subtitle_list[0], dict) else {} - subtitle = first.get("subtitle") if isinstance(first.get("subtitle"), dict) else {} - text = (subtitle.get("subtitle_body") or "").strip() - return _subtitles_json_to_lrc(text) - except Exception: - return None - - -import re - -try: - import pykakasi - - _KAKASI = pykakasi.kakasi() -except Exception: - _KAKASI = None - - -def _is_cjk(text: str) -> bool: - if not text: - return False - return any( - "\u3040" <= c <= "\u309f" # Hiragana - or "\u30a0" <= c <= "\u30ff" # Katakana - or "\u4e00" <= c <= "\u9fff" # CJK Ideographs - or "\uac00" <= c <= "\ud7af" # Hangul - for c in text - ) - - -def _romanize_japanese(text: str) -> str | None: - if not text or not _KAKASI or not _is_cjk(text): - return None - try: - conv = _KAKASI.convert(text) - parts = [ - item.get("hepburn") or item.get("orig") or "" - for item in conv - if (item.get("hepburn") or item.get("orig")) - ] - res = " ".join(parts) - res = re.sub(r"(\w)\(", r"\1 (", res) - res = re.sub(r"([(\[])\s+", r"\1", res) - res = re.sub(r"\s+([)\]])", r"\1", res) - res = re.sub(r"\s*([,\-!?\'\"])", r"\1", res) - res = re.sub(r"\s+", " ", res).strip() - words = [] - for w in res.split(): - if w.startswith("(") and len(w) > 1: - words.append("(" + w[1:].capitalize()) - else: - words.append(w.capitalize() if w.islower() else w) - return " ".join(words) - except Exception: - return None - - -def parse_mxm_titles(track_info: dict | None, fallback_title: str = "") -> dict | None: - """Parse Musixmatch track metadata into a normalized titles dict: - { - "original": "ふたりの異変", - "romanized": "Futarino Ihen", - "translations": { - "en": "Unusual Changes of Two", - ... - } - } - Languages that Musixmatch does not return are omitted. - """ - t_info = track_info if isinstance(track_info, dict) else {} - fallback = str(fallback_title or "").strip() - mxm_name = str(t_info.get("track_name") or "").strip() - - original = fallback or mxm_name - if not original: - return None - - raw_list = t_info.get("track_name_translation_list") - romanized: str | None = None - translations: dict[str, str] = {} - - if isinstance(raw_list, list): - for entry in raw_list: - if not isinstance(entry, dict): - continue - item = entry.get("track_name_translation") - if not isinstance(item, dict): - item = entry - - lang = str(item.get("language") or "").strip().lower() - val = str(item.get("translation") or "").strip() - if not lang or not val: - continue - - # Check romanized identifiers (rj = Romanized Japanese, rk = Romanized Korean, u0/zr = transliteration) - if lang in ("rj", "rk", "romanized", "romaja"): - romanized = val - elif lang in ("u0", "zr"): - if not romanized and not _is_cjk(val): - romanized = val - else: - translations[lang] = val - else: - translations[lang] = val - - # If Musixmatch returned an English / alternate release title in track_name that differs from original - if mxm_name and mxm_name.lower() != original.lower(): - if _is_cjk(original) and not _is_cjk(mxm_name): - if "en" not in translations: - translations["en"] = mxm_name - elif not _is_cjk(original) and _is_cjk(mxm_name): - if "ja" not in translations: - translations["ja"] = mxm_name - - # If romanized title is not yet available, fallback to Japanese romanization if original has CJK/Kana - if not romanized: - rom = _romanize_japanese(original) - if rom and rom.lower() != original.lower(): - romanized = rom - - out: dict[str, Any] = {"original": original} - if romanized: - out["romanized"] = romanized - if translations: - out["translations"] = translations - - return out - - - -async def save_track_titles(track_id: str, titles: dict) -> bool: - """Save normalized titles into the audioTracks MongoDB collection.""" - if not track_id or not isinstance(titles, dict) or not titles: - return False - try: - from Api.deps.db import get_audio_tracks_collection - - col = get_audio_tracks_collection() - await col.update_one( - {"_id": str(track_id)}, - { - "$set": { - "titles": titles, - "audio.titles": titles, - "updated_at": time.time(), - } - }, - upsert=False, - ) - return True - except Exception: - return False - - -def _read_persisted_token() -> str | None: - env_tok = (os.getenv("MUSIXMATCH_USER_TOKEN") or getattr(Config, "MUSIXMATCH_USER_TOKEN", None) or "").strip() - if env_tok: - return env_tok - try: - if _TOKEN_FILE.exists(): - content = _TOKEN_FILE.read_text(encoding="utf-8").strip() - if content: - data = json.loads(content) if content.startswith("{") else {"token": content} - tok = str(data.get("token") or "").strip() - if tok: - return tok - except Exception: - pass - return None - - -def _persist_token(token: str) -> None: - if not token or not token.strip(): - return - try: - _TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True) - _TOKEN_FILE.write_text( - json.dumps({"token": token.strip(), "saved_at": time.time()}, indent=2), - encoding="utf-8", - ) - except Exception: - pass - - -async def _fetch_user_token(*, force_refresh: bool = False) -> str | None: - global _cached_user_token, _cached_user_token_ts - - now = time.time() - if not force_refresh: - if _cached_user_token and (now - _cached_user_token_ts) < 24 * 3600: - return _cached_user_token - disk_tok = _read_persisted_token() - if disk_tok: - _cached_user_token = disk_tok - _cached_user_token_ts = now - return disk_tok - - async with _token_lock: - now = time.time() - if not force_refresh: - if _cached_user_token and (now - _cached_user_token_ts) < 24 * 3600: - return _cached_user_token - disk_tok = _read_persisted_token() - if disk_tok: - _cached_user_token = disk_tok - _cached_user_token_ts = now - return disk_tok - - http_status, payload = await asyncio.to_thread( - _sync_get_json, - url=f"{_BASE_URL}/token.get", - params={"app_id": _APP_ID}, - ) - if http_status != 200: - return _read_persisted_token() - - mxm_status = _extract_header_status(payload) or http_status - if mxm_status != 200: - return _read_persisted_token() - - token = ( - (((payload or {}).get("message") or {}).get("body") or {}).get("user_token") or "" - ).strip() - if not token: - return _read_persisted_token() - - _cached_user_token = token - _cached_user_token_ts = time.time() - _persist_token(token) - return token - - -async def fetch_and_save_musixmatch_titles( - track_id: str, - *, - track: dict | None = None, - title: str = "", - artist: str = "", - spotify_track_id: str = "", -) -> dict | None: - """Query Musixmatch matcher.track.get and save parsed titles in audioTracks collection.""" - if not track_id: - return None - - t = track or {} - audio = t.get("audio") if isinstance(t.get("audio"), dict) else {} - telegram = t.get("telegram") if isinstance(t.get("telegram"), dict) else {} - - track_title = title or (audio.get("title") or "").strip() or (telegram.get("title") or "").strip() - track_artist = ( - artist - or (audio.get("artist") or "").strip() - or (audio.get("performer") or "").strip() - or (telegram.get("artist") or "").strip() - ) - sp_id = spotify_track_id or _pick_spotify_track_id(t) - - token = await _fetch_user_token(force_refresh=False) - if not token: - return None - - params: dict[str, Any] = { - "usertoken": token, - "app_id": _APP_ID, - "subtitle_format": "mxm", - } - if sp_id: - params["track_spotify_id"] = sp_id - elif track_title and track_artist: - params["q_track"] = track_title - params["q_artist"] = track_artist - elif track_title: - params["q_track"] = track_title - else: - return None - - http_status, payload = await asyncio.to_thread( - _sync_get_json, - url=f"{_BASE_URL}/macro.subtitles.get", - params=params, - ) - mxm_status = _extract_header_status(payload) or http_status - if mxm_status == 400 or http_status in (400, 401, 403): - token2 = await _fetch_user_token(force_refresh=True) - if token2: - params["usertoken"] = token2 - http_status, payload = await asyncio.to_thread( - _sync_get_json, - url=f"{_BASE_URL}/macro.subtitles.get", - params=params, - ) - mxm_status = _extract_header_status(payload) or http_status - - track_info: dict[str, Any] = {} - if mxm_status == 200: - macro = (((payload or {}).get("message") or {}).get("body") or {}).get("macro_calls") or {} - matcher = (macro.get("matcher.track.get") or {}).get("message") or {} - if matcher.get("header", {}).get("status_code") == 200: - track_info = (matcher.get("body") or {}).get("track") or {} - - # Fallback to track.search if matcher.track.get didn't find the track - if not track_info and (track_title or track_artist): - search_params: dict[str, Any] = { - "usertoken": params.get("usertoken") or token, - "app_id": _APP_ID, - "page_size": 3, - } - if track_title and track_artist: - search_params["q_track"] = track_title - search_params["q_artist"] = track_artist - elif track_title: - search_params["q_track"] = track_title - elif track_artist: - search_params["q_artist"] = track_artist - - s_status, s_payload = await asyncio.to_thread( - _sync_get_json, - url=f"{_BASE_URL}/track.search", - params=search_params, - ) - s_list = (((s_payload or {}).get("message") or {}).get("body") or {}).get("track_list") or [] - if s_list and isinstance(s_list, list): - first_match = s_list[0].get("track") or {} - if first_match: - track_info = first_match - - titles = parse_mxm_titles(track_info, fallback_title=track_title) - if titles: - await save_track_titles(track_id, titles) - return titles - - -async def fetch_track_lyrics_from_musixmatch(*, track: dict) -> dict: - audio = track.get("audio") if isinstance(track.get("audio"), dict) else {} - telegram = track.get("telegram") if isinstance(track.get("telegram"), dict) else {} - title = (audio.get("title") or "").strip() or (telegram.get("title") or "").strip() - artist = (audio.get("artist") or "").strip() or (audio.get("performer") or "").strip() or (telegram.get("artist") or "").strip() - album = (audio.get("album") or "").strip() or (telegram.get("album") or "").strip() - year = audio.get("year") - track_id = str(track.get("_id") or track.get("id") or "").strip() - - spotify_track_id = _pick_spotify_track_id(track) - if not spotify_track_id: - try: - from stream.helpers.cover_search import spotify_best_track - - sp = await spotify_best_track(title=title, artist=artist, album=album, year=year) - except Exception: - sp = None - - if isinstance(sp, dict): - spotify_track_id = (sp.get("id") or "").strip() if isinstance(sp.get("id"), str) else "" - ext = sp.get("external_urls") if isinstance(sp.get("external_urls"), dict) else {} - spotify_url = (ext.get("spotify") or "").strip() if isinstance(ext.get("spotify"), str) else "" - if spotify_track_id: - spotify = track.get("spotify") if isinstance(track.get("spotify"), dict) else {} - spotify["track_spotify_id"] = spotify_track_id - if spotify_url: - spotify["url"] = spotify_url - track["spotify"] = spotify - - token = await _fetch_user_token(force_refresh=False) - if not token: - return {"ok": False, "error": "token_failed"} - - params = { - "usertoken": token, - "app_id": _APP_ID, - "subtitle_format": "mxm", - } - - if spotify_track_id: - params["track_spotify_id"] = spotify_track_id - elif title and artist: - params["q_track"] = title - params["q_artist"] = artist - else: - return {"ok": False, "error": "missing_spotify_track_id_and_metadata"} - - async def _call() -> tuple[int, Any]: - return await asyncio.to_thread( - _sync_get_json, - url=f"{_BASE_URL}/macro.subtitles.get", - params=params, - ) - - http_status, payload = await _call() - mxm_status = _extract_header_status(payload) or http_status - - if mxm_status == 400 or http_status in (400, 401, 403): - token2 = await _fetch_user_token(force_refresh=True) - if token2: - params["usertoken"] = token2 - http_status, payload = await _call() - mxm_status = _extract_header_status(payload) or http_status - - debug_payload = None - if bool(getattr(Config, "DEBUG", False)): - debug_payload = payload - - if mxm_status != 200: - return { - "ok": False, - "error": "mxm_failed", - "status_code": int(mxm_status), - "spotify_track_id": spotify_track_id, - **({"musixmatch": debug_payload} if debug_payload is not None else {}), - } - - macro = (((payload or {}).get("message") or {}).get("body") or {}).get("macro_calls") or {} - track_info = (((macro.get("matcher.track.get") or {}).get("message") or {}).get("body") or {}).get("track") or {} - - # Extract alternate / romanized titles and persist if available - titles = parse_mxm_titles(track_info, fallback_title=title) - if titles and track_id: - asyncio.create_task(save_track_titles(track_id, titles)) - - # Try fetching richsync for word-level sync if track_id was matched - try: - mxm_track_id = track_info.get("track_id") - current_token = params.get("usertoken") - if mxm_track_id and current_token: - r_status, r_payload = await asyncio.to_thread( - _sync_get_json, - url=f"{_BASE_URL}/track.richsync.get", - params={"app_id": _APP_ID, "usertoken": current_token, "track_id": mxm_track_id}, - ) - if r_status == 200: - r_body = (((r_payload or {}).get("message") or {}).get("body") or {}).get("richsync") or {} - r_text = (r_body.get("richsync_body") or "").strip() - if r_text: - parsed_rich = _richsync_json_to_lrc(r_text) - if parsed_rich: - return { - "ok": True, - "lyrics": parsed_rich, - "kind": "richsync", - "source": "musixmatch", - "spotify_track_id": spotify_track_id, - **({"titles": titles} if titles is not None else {}), - **({"musixmatch": debug_payload} if debug_payload is not None else {}), - } - except Exception: - pass - - synced = _extract_synced_subtitles(payload) - if synced: - return { - "ok": True, - "lyrics": synced, - "kind": "synced", - "source": "musixmatch", - "spotify_track_id": spotify_track_id, - **({"titles": titles} if titles is not None else {}), - **({"musixmatch": debug_payload} if debug_payload is not None else {}), - } - - plain = _extract_plain_lyrics(payload) - if plain: - return { - "ok": True, - "lyrics": plain, - "kind": "plain", - "source": "musixmatch", - "spotify_track_id": spotify_track_id, - **({"titles": titles} if titles is not None else {}), - **({"musixmatch": debug_payload} if debug_payload is not None else {}), - } - - return { - "ok": False, - "error": "no_lyrics", - "spotify_track_id": spotify_track_id, - **({"titles": titles} if titles is not None else {}), - **({"musixmatch": debug_payload} if debug_payload is not None else {}), - } - diff --git a/Api/services/playlist_cover_service.py b/Api/services/playlist_cover_service.py deleted file mode 100644 index 3d640ee..0000000 --- a/Api/services/playlist_cover_service.py +++ /dev/null @@ -1,14 +0,0 @@ -import time -import uuid - -async def ensure_user_playlist_cover(playlist_id: str, name: str, force: bool = False) -> dict: - return { - "cover_id": f"cover_{playlist_id}", - "url": f"/covers/user-playlist/{playlist_id}.png" - } - -async def ensure_user_playlist_normal_cover(playlist_id: str, name: str, force: bool = False) -> dict: - return { - "cover_id": f"cover_norm_{playlist_id}", - "url": f"/covers/user-playlist/{playlist_id}.png" - } diff --git a/Api/services/recap_service.py b/Api/services/recap_service.py deleted file mode 100644 index 77fecf5..0000000 --- a/Api/services/recap_service.py +++ /dev/null @@ -1,601 +0,0 @@ -""" -Recap engine — turns listening events into a standardized, cached RecapSnapshot. - -Data sources (in priority order): - 1. ``listening_events`` — rich events posted by clients (played_ms, completed, skipped …) - 2. ``userHistory`` — legacy "stream started" rows, used as a fallback for older data - -Play rules (kept consistent everywhere): - < 30 s → ignored (counts as a skip) - ≥ 30 s → play - ≥ 50 % → strong play - ≥ 80 % / flag → completed -""" -from __future__ import annotations - -import calendar -import datetime as dt -import secrets -import time -from collections import Counter, defaultdict -from typing import Any, Iterable, Literal, Optional - -from Api.deps.db import get_audio_tracks_collection -from Api.services.track_service import _browse_item_from_doc -from stream.database.MongoDb import db_handler - -SCHEMA_VERSION = 1 -PeriodType = Literal["weekly", "monthly", "yearly"] -MIN_PLAY_MS = 30_000 -NIGHT_HOURS = {21, 22, 23, 0, 1, 2, 3} -EARLY_HOURS = {5, 6, 7, 8} -SESSION_GAP_SEC = 30 * 60 - - -def _events_col(): - return db_handler.get_collection("listening_events").collection - - -def _history_col(): - return db_handler.get_collection("userHistory").collection - - -def _snapshots_col(): - return db_handler.get_collection("recap_snapshots").collection - - -def _shares_col(): - return db_handler.get_collection("recap_shares").collection - - -# --------------------------------------------------------------------------- periods - - -def _local_midnight_utc(year: int, month: int, day: int, tz_offset_min: int) -> float: - """Epoch (UTC) of local midnight for the given date, given the client's UTC offset in minutes.""" - local = dt.datetime(year, month, day, tzinfo=dt.timezone.utc) - return local.timestamp() - tz_offset_min * 60 - - -def period_bounds(ptype: PeriodType, period: str, tz_offset_min: int = 0) -> tuple[float, float, str]: - """Return (start_epoch, end_epoch, human_label) for a period id.""" - try: - if ptype == "weekly": - year_s, week_s = period.upper().split("-W") - year, week = int(year_s), int(week_s) - monday = dt.date.fromisocalendar(year, week, 1) - start = _local_midnight_utc(monday.year, monday.month, monday.day, tz_offset_min) - nxt = monday + dt.timedelta(days=7) - end = _local_midnight_utc(nxt.year, nxt.month, nxt.day, tz_offset_min) - sunday = monday + dt.timedelta(days=6) - label = f"{monday.strftime('%b %-d')} – {sunday.strftime('%b %-d, %Y')}" - return start, end, label - if ptype == "monthly": - year, month = (int(x) for x in period.split("-")) - start = _local_midnight_utc(year, month, 1, tz_offset_min) - ny, nm = (year + 1, 1) if month == 12 else (year, month + 1) - end = _local_midnight_utc(ny, nm, 1, tz_offset_min) - return start, end, f"{calendar.month_name[month]} {year}" - if ptype == "yearly": - year = int(period) - return _local_midnight_utc(year, 1, 1, tz_offset_min), _local_midnight_utc(year + 1, 1, 1, tz_offset_min), str(year) - except Exception as exc: # pragma: no cover - validation - raise ValueError(f"invalid period '{period}' for {ptype}") from exc - raise ValueError("unknown period type") - - -def previous_period(ptype: PeriodType, period: str) -> str: - if ptype == "weekly": - year_s, week_s = period.upper().split("-W") - monday = dt.date.fromisocalendar(int(year_s), int(week_s), 1) - dt.timedelta(days=7) - iso = monday.isocalendar() - return f"{iso[0]}-W{iso[1]:02d}" - if ptype == "monthly": - year, month = (int(x) for x in period.split("-")) - return f"{year - 1}-12" if month == 1 else f"{year}-{month - 1:02d}" - return str(int(period) - 1) - - -def period_id_for(ptype: PeriodType, when: dt.datetime) -> str: - if ptype == "weekly": - iso = when.isocalendar() - return f"{iso[0]}-W{iso[1]:02d}" - if ptype == "monthly": - return f"{when.year}-{when.month:02d}" - return str(when.year) - - -def is_period_available(ptype: PeriodType, period: str, tz_offset_min: int = 0) -> bool: - """Check if a recap period is ready/unlocked to show to users. - - Rules: - - Completed periods (end <= now) are always available. - - Ongoing weekly recaps only show on the weekend (Saturday or Sunday, e.g. Sunday). - - Ongoing monthly recaps only show on the last day of the month. - - Ongoing yearly recaps only show on Dec 31st. - """ - start, end, _ = period_bounds(ptype, period, tz_offset_min) - now = time.time() - if end <= now: - return True - - now_local = _local_dt(now, tz_offset_min) - if ptype == "weekly": - # Every weekend (Saturday or Sunday) - return now_local.weekday() in (5, 6) - if ptype == "monthly": - # Last day of the month - _, last_day = calendar.monthrange(now_local.year, now_local.month) - return now_local.day == last_day - if ptype == "yearly": - # 31st Dec - return now_local.month == 12 and now_local.day == 31 - return False - - -# --------------------------------------------------------------------------- events - - -async def record_events(user_id: int, events: list[dict[str, Any]]) -> int: - """Idempotent batch insert (client supplies the event id).""" - col = _events_col() - stored = 0 - now = time.time() - for ev in events: - eid = str(ev.get("id") or "").strip() - tid = str(ev.get("track_id") or "").strip() - if not eid or not tid: - continue - played_ms = max(0, int(ev.get("played_ms") or 0)) - duration_ms = max(0, int(ev.get("duration_ms") or 0)) - doc = { - "_id": f"{int(user_id)}:{eid}", - "user_id": int(user_id), - "track_id": tid, - "played_at": float(ev.get("played_at") or now), - "started_at": float(ev.get("started_at") or ev.get("played_at") or now), - "played_ms": played_ms, - "duration_ms": duration_ms, - "completed": bool(ev.get("completed")), - "skipped": bool(ev.get("skipped")), - "source": str(ev.get("source") or "server")[:32], - "session_id": (str(ev.get("session_id"))[:64] if ev.get("session_id") else None), - "created_at": now, - } - try: - res = await col.update_one({"_id": doc["_id"]}, {"$setOnInsert": doc}, upsert=True) - if res.upserted_id is not None: - stored += 1 - except Exception: - continue - return stored - - -async def _load_events(user_id: int, start: float, end: float) -> list[dict[str, Any]]: - q = {"user_id": int(user_id), "played_at": {"$gte": start, "$lt": end}} - events: list[dict[str, Any]] = [] - async for doc in _events_col().find(q): - events.append(doc) - if events: - return events - # Fallback: legacy history rows (one row = one stream start). Duration is filled in later. - hq = {"$or": [{"user_id": int(user_id)}, {"user_id": str(user_id)}], "played_at": {"$gte": start, "$lt": end}} - async for doc in _history_col().find(hq): - events.append( - { - "track_id": str(doc.get("track_id") or ""), - "played_at": float(doc.get("played_at") or 0), - "started_at": float(doc.get("played_at") or 0), - "played_ms": None, # unknown → assume full play - "duration_ms": 0, - "completed": False, - "skipped": False, - "legacy": True, - } - ) - return [e for e in events if e.get("track_id")] - - -async def _load_tracks(track_ids: Iterable[str]) -> dict[str, dict[str, Any]]: - ids = list({str(t) for t in track_ids if t}) - if not ids: - return {} - col = get_audio_tracks_collection() - out: dict[str, dict[str, Any]] = {} - for i in range(0, len(ids), 500): - chunk = ids[i : i + 500] - async for doc in col.find({"_id": {"$in": chunk}}, {"_id": 1, "audio": 1, "spotify": 1, "topic_name": 1, "topic_id": 1, "updated_at": 1}): - item = _browse_item_from_doc(doc).model_dump() - out[item["id"]] = item - return out - - -async def _first_seen_before(user_id: int, track_ids: list[str], before: float) -> set[str]: - if not track_ids: - return set() - seen: set[str] = set() - ev_ids = await _events_col().distinct("track_id", {"user_id": int(user_id), "track_id": {"$in": track_ids}, "played_at": {"$lt": before}}) - seen.update(str(x) for x in ev_ids) - hist_ids = await _history_col().distinct( - "track_id", {"$or": [{"user_id": int(user_id)}, {"user_id": str(user_id)}], "track_id": {"$in": track_ids}, "played_at": {"$lt": before}} - ) - seen.update(str(x) for x in hist_ids) - return seen - - -# --------------------------------------------------------------------------- statistics - - -def _classify(ev: dict[str, Any], duration_ms: int) -> tuple[bool, bool, bool, int]: - """→ (is_play, is_completed, is_skip, played_ms)""" - played = ev.get("played_ms") - if played is None: # legacy row: assume the track was played through - played = duration_ms or 3 * 60_000 - played = int(played) - completed = bool(ev.get("completed")) or (duration_ms > 0 and played >= 0.8 * duration_ms) - is_play = played >= MIN_PLAY_MS or completed - is_skip = not is_play - return is_play, completed, is_skip, min(played, duration_ms) if duration_ms else played - - -def _local_dt(ts: float, tz_offset_min: int) -> dt.datetime: - return dt.datetime.fromtimestamp(ts + tz_offset_min * 60, tz=dt.timezone.utc) - - -def _compute_stats(events: list[dict[str, Any]], tracks: dict[str, dict[str, Any]], tz_offset_min: int, seen_before: set[str]) -> dict[str, Any]: - total_ms = 0 - plays = completed = skipped = 0 - track_plays: Counter[str] = Counter() - track_ms: Counter[str] = Counter() - artist_plays: Counter[str] = Counter() - artist_ms: Counter[str] = Counter() - artist_cover: dict[str, str | None] = {} - album_plays: Counter[str] = Counter() - album_ms: Counter[str] = Counter() - album_meta: dict[str, dict[str, Any]] = {} - topic_plays: Counter[str] = Counter() - by_hour = [0] * 24 - by_weekday = [0] * 7 - by_date: Counter[str] = Counter() - by_month_ms: Counter[str] = Counter() - lossless_plays = 0 - starts: list[tuple[float, int]] = [] - - for ev in events: - t = tracks.get(str(ev.get("track_id"))) - duration_ms = int(ev.get("duration_ms") or 0) or int((t or {}).get("duration_sec") or 0) * 1000 - is_play, is_completed, is_skip, played_ms = _classify(ev, duration_ms) - if is_skip: - skipped += 1 - continue - plays += 1 - if is_completed: - completed += 1 - total_ms += played_ms - tid = str(ev["track_id"]) - track_plays[tid] += 1 - track_ms[tid] += played_ms - when = _local_dt(float(ev.get("played_at") or 0), tz_offset_min) - by_hour[when.hour] += played_ms - by_weekday[when.weekday()] += played_ms - by_date[when.strftime("%Y-%m-%d")] += played_ms - by_month_ms[when.strftime("%Y-%m")] += played_ms - starts.append((float(ev.get("started_at") or ev.get("played_at") or 0), played_ms)) - if t: - artist = (t.get("artist") or "Unknown artist").strip() - artist_plays[artist] += 1 - artist_ms[artist] += played_ms - artist_cover.setdefault(artist, t.get("cover_url")) - if t.get("album"): - key = f"{t.get('album')}::{artist}" - album_plays[key] += 1 - album_ms[key] += played_ms - album_meta.setdefault(key, {"album": t.get("album"), "artist": artist, "album_id": t.get("album_id"), "cover_url": t.get("cover_url")}) - if t.get("topic_name"): - topic_plays[str(t["topic_name"])] += 1 - if str(t.get("type") or "").upper() in {"FLAC", "ALAC", "WAV"}: - lossless_plays += 1 - - # sessions: sort by start, split on gaps > 30 min - longest_session_ms = 0 - session_count = 0 - if starts: - starts.sort() - cur_start, cur_end = starts[0][0], starts[0][0] + starts[0][1] / 1000 - cur_ms = starts[0][1] - session_count = 1 - for s0, ms in starts[1:]: - if s0 - cur_end > SESSION_GAP_SEC: - longest_session_ms = max(longest_session_ms, cur_ms) - cur_start, cur_ms = s0, 0 - session_count += 1 - cur_ms += ms - cur_end = max(cur_end, s0 + ms / 1000) - longest_session_ms = max(longest_session_ms, cur_ms) - - def top_tracks(n: int) -> list[dict[str, Any]]: - out = [] - for tid, cnt in track_plays.most_common(n): - t = tracks.get(tid) or {"id": tid, "title": "Unknown track", "artist": None} - out.append({"track": t, "plays": cnt, "minutes": round(track_ms[tid] / 60_000, 1)}) - return out - - def top_artists(n: int) -> list[dict[str, Any]]: - return [{"name": a, "plays": c, "minutes": round(artist_ms[a] / 60_000, 1), "cover_url": artist_cover.get(a)} for a, c in artist_plays.most_common(n)] - - def top_albums(n: int) -> list[dict[str, Any]]: - return [{**album_meta[k], "plays": c, "minutes": round(album_ms[k] / 60_000, 1)} for k, c in album_plays.most_common(n)] - - unique_tracks = len(track_plays) - night_ms = sum(by_hour[h] for h in NIGHT_HOURS) - early_ms = sum(by_hour[h] for h in EARLY_HOURS) - weekend_ms = by_weekday[5] + by_weekday[6] - repeat_plays = sum(c - 1 for c in track_plays.values() if c > 1) - most_active_hour = max(range(24), key=lambda h: by_hour[h]) if total_ms else None - most_active_weekday = max(range(7), key=lambda d: by_weekday[d]) if total_ms else None - most_active_date = by_date.most_common(1)[0][0] if by_date else None - discovery = [tid for tid in track_plays if tid not in seen_before] - - return { - "totalMinutes": round(total_ms / 60_000), - "totalPlays": plays, - "completedPlays": completed, - "skippedPlays": skipped, - "uniqueTracks": unique_tracks, - "uniqueArtists": len(artist_plays), - "uniqueAlbums": len(album_plays), - "topTracks": top_tracks(10), - "topArtists": top_artists(10), - "topAlbums": top_albums(6), - "topGenres": [{"name": n, "plays": c} for n, c in topic_plays.most_common(5)], - "listeningByHour": [round(ms / 60_000) for ms in by_hour], - "listeningByWeekday": [round(ms / 60_000) for ms in by_weekday], - "listeningByDay": [{"date": d, "minutes": round(ms / 60_000)} for d, ms in sorted(by_date.items())], - "listeningByMonth": [{"month": m, "minutes": round(ms / 60_000)} for m, ms in sorted(by_month_ms.items())], - "discoveryCount": len(discovery), - "discoveryTracks": [tracks.get(tid) for tid in discovery[:6] if tracks.get(tid)], - "repeatCount": repeat_plays, - "mostActiveHour": most_active_hour, - "mostActiveWeekday": most_active_weekday, - "mostActiveDate": most_active_date, - "mostActiveDateMinutes": round(by_date[most_active_date] / 60_000) if most_active_date else 0, - "longestSessionMinutes": round(longest_session_ms / 60_000), - "sessionCount": session_count, - "nightShare": round(night_ms / total_ms, 3) if total_ms else 0, - "earlyShare": round(early_ms / total_ms, 3) if total_ms else 0, - "weekendShare": round(weekend_ms / total_ms, 3) if total_ms else 0, - "losslessShare": round(lossless_plays / plays, 3) if plays else 0, - "topArtistShare": round(artist_plays.most_common(1)[0][1] / plays, 3) if plays and artist_plays else 0, - } - - -# --------------------------------------------------------------------------- personality - - -def _personality(stats: dict[str, Any]) -> list[dict[str, Any]]: - """Deterministic, transparent classifications. Each entry explains its rule.""" - out: list[dict[str, Any]] = [] - plays = stats["totalPlays"] - if plays < 5: - return out - if stats["nightShare"] >= 0.45: - out.append({"id": "night_owl", "name": "Night Owl", "detail": f"{round(stats['nightShare'] * 100)}% of your listening happened after 9 PM"}) - if stats["earlyShare"] >= 0.35: - out.append({"id": "early_bird", "name": "Early Bird", "detail": f"{round(stats['earlyShare'] * 100)}% of your listening was before 9 AM"}) - if stats["uniqueTracks"] and stats["discoveryCount"] / stats["uniqueTracks"] >= 0.5 and stats["discoveryCount"] >= 10: - out.append({"id": "explorer", "name": "Explorer", "detail": f"{stats['discoveryCount']} of {stats['uniqueTracks']} tracks were new to you"}) - if stats["repeatCount"] / plays >= 0.45: - out.append({"id": "replayer", "name": "Replayer", "detail": f"{stats['repeatCount']} replays — you know what you like"}) - if stats["topArtistShare"] >= 0.4 and stats["topArtists"]: - out.append({"id": "loyalist", "name": "Loyalist", "detail": f"{round(stats['topArtistShare'] * 100)}% of plays were {stats['topArtists'][0]['name']}"}) - if stats["uniqueArtists"] >= 25 and stats["topArtistShare"] < 0.15: - out.append({"id": "genre_hopper", "name": "Genre Hopper", "detail": f"{stats['uniqueArtists']} artists, no clear favourite"}) - if stats["uniqueAlbums"] and stats["topAlbums"] and stats["topAlbums"][0]["plays"] >= 8 and stats["completedPlays"] / plays >= 0.7: - out.append({"id": "album_collector", "name": "Album Listener", "detail": "You play albums through, front to back"}) - if stats["weekendShare"] >= 0.5: - out.append({"id": "weekend", "name": "Weekend Listener", "detail": f"{round(stats['weekendShare'] * 100)}% of listening on weekends"}) - if stats["longestSessionMinutes"] >= 120: - out.append({"id": "binge", "name": "Binge Listener", "detail": f"Longest session: {stats['longestSessionMinutes']} minutes"}) - if stats["losslessShare"] >= 0.6: - out.append({"id": "audiophile", "name": "Audiophile", "detail": f"{round(stats['losslessShare'] * 100)}% of plays were lossless"}) - if not out: - out.append({"id": "steady", "name": "Steady Listener", "detail": f"{plays} plays across {stats['uniqueArtists']} artists"}) - return out[:3] - - -# --------------------------------------------------------------------------- snapshots - - -async def build_snapshot(user_id: int, ptype: PeriodType, period: str, tz_offset_min: int = 0) -> dict[str, Any]: - start, end, label = period_bounds(ptype, period, tz_offset_min) - events = await _load_events(user_id, start, end) - tracks = await _load_tracks(e["track_id"] for e in events) - seen_before = await _first_seen_before(user_id, list({str(e["track_id"]) for e in events}), start) - stats = _compute_stats(events, tracks, tz_offset_min, seen_before) - - # previous period (light comparison) - prev_id = previous_period(ptype, period) - ps, pe, _ = period_bounds(ptype, prev_id, tz_offset_min) - prev_events = await _load_events(user_id, ps, pe) - comparison: dict[str, Any] | None = None - if prev_events: - prev_tracks = await _load_tracks(e["track_id"] for e in prev_events) - prev = _compute_stats(prev_events, prev_tracks, tz_offset_min, set()) - - def delta(cur: float, old: float) -> float | None: - return round((cur - old) / old * 100) if old else None - - comparison = { - "period": prev_id, - "totalMinutes": prev["totalMinutes"], - "totalPlays": prev["totalPlays"], - "uniqueArtists": prev["uniqueArtists"], - "uniqueTracks": prev["uniqueTracks"], - "minutesDeltaPct": delta(stats["totalMinutes"], prev["totalMinutes"]), - "playsDeltaPct": delta(stats["totalPlays"], prev["totalPlays"]), - "artistsDelta": stats["uniqueArtists"] - prev["uniqueArtists"], - "nightShareDelta": round((stats["nightShare"] - prev["nightShare"]) * 100), - "topArtistPrev": prev["topArtists"][0]["name"] if prev["topArtists"] else None, - } - - now = time.time() - return { - "schemaVersion": SCHEMA_VERSION, - "type": ptype, - "period": period, - "label": label, - "start": start, - "end": end, - "ongoing": end > now, - "generatedAt": now, - "tzOffsetMin": tz_offset_min, - "eventCount": len(events), - "legacyData": bool(events) and all(e.get("legacy") for e in events), - "stats": stats, - "personality": _personality(stats), - "comparison": comparison, - } - - -async def get_snapshot(user_id: int, ptype: PeriodType, period: str, tz_offset_min: int = 0, force: bool = False) -> dict[str, Any]: - key = {"user_id": int(user_id), "type": ptype, "period": period, "tz": int(tz_offset_min)} - col = _snapshots_col() - now = time.time() - if not force: - cached = await col.find_one(key) - if cached and cached.get("schemaVersion") == SCHEMA_VERSION: - snap = cached.get("snapshot") or {} - ttl = 15 * 60 if snap.get("ongoing") else 24 * 3600 - if now - float(cached.get("generatedAt") or 0) < ttl: - return snap - snap = await build_snapshot(user_id, ptype, period, tz_offset_min) - try: - await col.update_one(key, {"$set": {**key, "schemaVersion": SCHEMA_VERSION, "generatedAt": now, "snapshot": snap}}, upsert=True) - except Exception: - pass - return snap - - -async def _has_plays(user_id: int, start: float, end: float) -> bool: - uid = int(user_id) - doc = await _events_col().find_one({"user_id": uid, "played_at": {"$gte": start, "$lt": end}}, {"_id": 1}) - if doc: - return True - hdoc = await _history_col().find_one( - {"$or": [{"user_id": uid}, {"user_id": str(uid)}], "played_at": {"$gte": start, "$lt": end}}, - {"_id": 1}, - ) - return bool(hdoc) - - -async def list_available(user_id: int, tz_offset_min: int = 0) -> list[dict[str, Any]]: - """Periods that have listening data and are unlocked to show: - - Weekly: past completed weeks, or ongoing week on the weekend (Saturday/Sunday) - - Monthly: past completed months, or ongoing month on its last day - - Yearly: past completed years, or ongoing year on Dec 31st - """ - first: float | None = None - doc = await _events_col().find_one({"user_id": int(user_id)}, sort=[("played_at", 1)]) - if doc: - first = float(doc["played_at"]) - hdoc = await _history_col().find_one({"$or": [{"user_id": int(user_id)}, {"user_id": str(user_id)}]}, sort=[("played_at", 1)]) - if hdoc and hdoc.get("played_at"): - first = min(first, float(hdoc["played_at"])) if first else float(hdoc["played_at"]) - if not first: - return [] - now = time.time() - now_local = _local_dt(now, tz_offset_min) - first_local = _local_dt(first, tz_offset_min) - out: list[dict[str, Any]] = [] - - # weeks (current + previous, up to 8) - cur = now_local - for _ in range(8): - pid = period_id_for("weekly", cur) - s, e, label = period_bounds("weekly", pid, tz_offset_min) - if e < first: - break - if is_period_available("weekly", pid, tz_offset_min): - if await _has_plays(user_id, s, e): - out.append({"type": "weekly", "period": pid, "label": label, "ongoing": e > now}) - cur -= dt.timedelta(days=7) - - # months (current + previous, up to 12) - y, m = now_local.year, now_local.month - for _ in range(12): - pid = f"{y}-{m:02d}" - s, e, label = period_bounds("monthly", pid, tz_offset_min) - if e < first: - break - if is_period_available("monthly", pid, tz_offset_min): - if await _has_plays(user_id, s, e): - out.append({"type": "monthly", "period": pid, "label": label, "ongoing": e > now}) - y, m = (y - 1, 12) if m == 1 else (y, m - 1) - - # years - for y in range(now_local.year, first_local.year - 1, -1): - pid = str(y) - s, e, label = period_bounds("yearly", pid, tz_offset_min) - if is_period_available("yearly", pid, tz_offset_min): - if await _has_plays(user_id, s, e): - out.append({"type": "yearly", "period": pid, "label": label, "ongoing": e > now}) - - return out - - -# --------------------------------------------------------------------------- sharing - - -def _public_summary(snap: dict[str, Any]) -> dict[str, Any]: - st = snap["stats"] - top_track = st["topTracks"][0]["track"] if st["topTracks"] else None - return { - "type": snap["type"], - "period": snap["period"], - "label": snap["label"], - "totalMinutes": st["totalMinutes"], - "totalPlays": st["totalPlays"], - "uniqueArtists": st["uniqueArtists"], - "uniqueTracks": st["uniqueTracks"], - "topArtist": st["topArtists"][0] if st["topArtists"] else None, - "topTrack": {"title": top_track.get("title"), "artist": top_track.get("artist"), "cover_url": top_track.get("cover_url")} if top_track else None, - "personality": snap.get("personality") or [], - } - - -async def create_share(user_id: int, snap: dict[str, Any]) -> dict[str, Any]: - col = _shares_col() - existing = await col.find_one({"user_id": int(user_id), "type": snap["type"], "period": snap["period"], "revoked_at": None}) - summary = _public_summary(snap) - if existing: - await col.update_one({"_id": existing["_id"]}, {"$set": {"summary": summary}}) - return {"token": existing["token"], "created_at": existing["created_at"]} - token = secrets.token_urlsafe(9) - doc = {"token": token, "user_id": int(user_id), "type": snap["type"], "period": snap["period"], "created_at": time.time(), "revoked_at": None, "summary": summary} - await col.insert_one(doc) - return {"token": token, "created_at": doc["created_at"]} - - -async def list_shares(user_id: int) -> list[dict[str, Any]]: - out = [] - async for d in _shares_col().find({"user_id": int(user_id), "revoked_at": None}).sort("created_at", -1): - out.append({"token": d["token"], "type": d["type"], "period": d["period"], "label": (d.get("summary") or {}).get("label"), "created_at": d["created_at"]}) - return out - - -async def revoke_share(user_id: int, token: str) -> bool: - res = await _shares_col().update_one({"user_id": int(user_id), "token": token, "revoked_at": None}, {"$set": {"revoked_at": time.time()}}) - return res.modified_count > 0 - - -async def get_public_share(token: str) -> Optional[dict[str, Any]]: - d = await _shares_col().find_one({"token": token, "revoked_at": None}) - return d.get("summary") if d else None - - -async def delete_user_recap_data(user_id: int) -> dict[str, int]: - uid = int(user_id) - e = await _events_col().delete_many({"user_id": uid}) - s = await _snapshots_col().delete_many({"user_id": uid}) - sh = await _shares_col().delete_many({"user_id": uid}) - return {"events": e.deleted_count, "snapshots": s.deleted_count, "shares": sh.deleted_count} diff --git a/Api/services/reverify_service.py b/Api/services/reverify_service.py deleted file mode 100644 index ffe9c23..0000000 --- a/Api/services/reverify_service.py +++ /dev/null @@ -1,243 +0,0 @@ -import asyncio -import time -from typing import Any, Callable, Coroutine - -from Api.deps.db import get_audio_tracks_collection -from Api.services.musixmatch import fetch_and_save_musixmatch_titles -from stream.core.config_manager import Config -from stream.helpers.logger import LOGGER - -LOG = LOGGER(__name__) - -_JOB_LOCK = asyncio.Lock() -_CURRENT_JOB: dict[str, Any] | None = None -_CANCEL_EVENT: asyncio.Event | None = None - - -def get_reverify_status() -> dict[str, Any]: - """Retrieve status of active or most recently completed reverify job.""" - if _CURRENT_JOB is not None: - return dict(_CURRENT_JOB) - return {"running": False, "status": "idle"} - - -def cancel_reverify_job() -> bool: - """Request cancellation of currently running reverify job.""" - global _CANCEL_EVENT - if _CURRENT_JOB and _CURRENT_JOB.get("running") and _CANCEL_EVENT: - _CANCEL_EVENT.set() - if _CURRENT_JOB: - _CURRENT_JOB["status"] = "cancelling" - return True - return False - - -async def reverify_tracks( - *, - force_all: bool = False, - missing_titles: bool = True, - missing_lyrics: bool = False, - unenriched_only: bool = False, - limit: int = 0, - concurrency: int = 2, - progress_callback: Callable[[dict[str, Any]], Coroutine[Any, Any, None]] | None = None, -) -> dict[str, Any]: - """ - Scan audioTracks collection and re-verify metadata from Musixmatch/Lyrics providers. - - missing_titles: checks tracks missing 'titles' or 'audio.titles'. - - missing_lyrics: checks tracks missing lyrics. - - unenriched_only: checks tracks with enriched: False. - - force_all: re-checks all tracks regardless of current state. - """ - global _CURRENT_JOB, _CANCEL_EVENT - - async with _JOB_LOCK: - if _CURRENT_JOB and _CURRENT_JOB.get("running"): - return { - "ok": False, - "error": "already_running", - "current_job": dict(_CURRENT_JOB), - } - - _CANCEL_EVENT = asyncio.Event() - start_ts = time.time() - _CURRENT_JOB = { - "running": True, - "status": "scanning", - "started_at": start_ts, - "total": 0, - "processed": 0, - "titles_added": 0, - "lyrics_added": 0, - "errors": 0, - "elapsed_sec": 0.0, - } - - try: - col = get_audio_tracks_collection() - query: dict[str, Any] = {"deleted": {"$ne": True}} - - if not force_all: - or_conditions: list[dict[str, Any]] = [] - if unenriched_only: - or_conditions.append({"enriched": False}) - else: - if missing_titles: - or_conditions.append({"titles": {"$exists": False}}) - or_conditions.append({"titles": None}) - or_conditions.append({"audio.titles": {"$exists": False}}) - if missing_lyrics: - or_conditions.append({"lyrics": {"$exists": False}}) - or_conditions.append({"lyrics": None}) - or_conditions.append({"lyrics": ""}) - - if or_conditions: - query["$or"] = or_conditions - - total = await col.count_documents(query) - if limit and limit > 0: - total = min(total, limit) - - _CURRENT_JOB["total"] = total - _CURRENT_JOB["status"] = "processing" - - if progress_callback: - try: - await progress_callback(dict(_CURRENT_JOB)) - except Exception: - pass - - if total == 0: - elapsed = round(time.time() - start_ts, 2) - _CURRENT_JOB.update({"running": False, "status": "completed", "elapsed_sec": elapsed}) - return {"ok": True, **dict(_CURRENT_JOB)} - - cursor = col.find( - query, - projection={ - "_id": 1, - "audio": 1, - "telegram": 1, - "spotify": 1, - "titles": 1, - "lyrics": 1, - "lyrics_cache": 1, - "enriched": 1, - }, - allow_disk_use=True, - ).sort([("updated_at", -1)]) - - if limit and limit > 0: - cursor = cursor.limit(limit) - - sem = asyncio.Semaphore(max(1, concurrency)) - processed = 0 - titles_added = 0 - lyrics_added = 0 - errors = 0 - last_progress_ts = time.time() - - async def _process_track(doc: dict): - nonlocal processed, titles_added, lyrics_added, errors, last_progress_ts - if _CANCEL_EVENT and _CANCEL_EVENT.is_set(): - return - - tid = str(doc.get("_id") or "").strip() - if not tid: - return - - async with sem: - if _CANCEL_EVENT and _CANCEL_EVENT.is_set(): - return - - try: - has_titles = bool(doc.get("titles") or (doc.get("audio") or {}).get("titles")) - if (not has_titles or force_all) and bool(getattr(Config, "MUSIXMATCH", True)): - res_titles = await fetch_and_save_musixmatch_titles(track_id=tid, track=doc) - if res_titles: - titles_added += 1 - - has_lyrics = bool( - (doc.get("lyrics") and str(doc["lyrics"]).strip()) - or (doc.get("lyrics_cache") or {}).get("text") - ) - if (not has_lyrics or force_all): - try: - from Api.services.lyrics_service import get_track_lyrics - - lyr = await get_track_lyrics(tid) - if lyr and lyr.get("ok"): - lyrics_added += 1 - except Exception: - pass - - if doc.get("enriched") is False: - await col.update_one( - {"_id": tid}, - { - "$unset": { - "enrichment_error": "", - "enrichment_error_at": "", - "enrich_retry_after": "", - } - }, - ) - - await asyncio.sleep(0.25) - - except Exception as e: - errors += 1 - if bool(getattr(Config, "DEBUG", False)): - LOG.debug(f"[reverify] failed track {tid}: {e}") - finally: - processed += 1 - now = time.time() - _CURRENT_JOB.update( - { - "processed": processed, - "titles_added": titles_added, - "lyrics_added": lyrics_added, - "errors": errors, - "elapsed_sec": round(now - start_ts, 2), - } - ) - - if progress_callback and (now - last_progress_ts >= 3.0 or processed >= total): - last_progress_ts = now - try: - await progress_callback(dict(_CURRENT_JOB)) - except Exception: - pass - - tasks = [] - async for doc in cursor: - if _CANCEL_EVENT and _CANCEL_EVENT.is_set(): - break - tasks.append(asyncio.create_task(_process_track(doc))) - - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - - is_cancelled = bool(_CANCEL_EVENT and _CANCEL_EVENT.is_set()) - status_str = "cancelled" if is_cancelled else "completed" - elapsed = round(time.time() - start_ts, 2) - - _CURRENT_JOB.update( - { - "running": False, - "status": status_str, - "elapsed_sec": elapsed, - } - ) - - if progress_callback: - try: - await progress_callback(dict(_CURRENT_JOB)) - except Exception: - pass - - return {"ok": True, **dict(_CURRENT_JOB)} - - finally: - if _CURRENT_JOB: - _CURRENT_JOB["running"] = False diff --git a/Api/services/stream_service.py b/Api/services/stream_service.py deleted file mode 100644 index 7aed251..0000000 --- a/Api/services/stream_service.py +++ /dev/null @@ -1,2092 +0,0 @@ -import asyncio -import hashlib -import logging -import os -import re -import subprocess -import time -from collections import deque -from dataclasses import dataclass, field -from typing import Any, AsyncIterator, Optional -from urllib.parse import quote - -import aiofiles -from fastapi import HTTPException, Request -from starlette.responses import Response, StreamingResponse - -from Api.deps.db import get_audio_tracks_collection -from Api.utils.auth import verify_auth_token -from stream import bot, get_primary_client_user_id -from stream.core.config_manager import Config -from stream.database.MongoDb import db_handler -from stream.helpers.logger import LOGGER - -MEDIA_DIR = "stream_media" -ALAC_CACHE_DIR = os.path.join(MEDIA_DIR, "alac_cache") -_ALAC_TRANSCODE_LOCKS: dict[str, asyncio.Lock] = {} -_ALAC_TRANSCODE_LOCKS_LOCK = asyncio.Lock() - -_CHUNK_SIZE = 1024 * 1024 -_MAX_STREAM_BUFFER_BYTES = 25_000_000 -_STREAM_HUBS: dict[str, "_StreamHub"] = {} -_STREAM_HUBS_LOCK = asyncio.Lock() -_FILE_ID_LOCKS: dict[str, asyncio.Lock] = {} -_FILE_ID_LOCKS_LOCK = asyncio.Lock() -_TRACK_AFFINITY: dict[str, tuple[int, float]] = {} -_TRACK_AFFINITY_LOCK = asyncio.Lock() -_TRACK_AFFINITY_TTL_SEC = 300.0 - -_PLAY_COUNT_THRESHOLD_SEC = 30.0 -_PLAY_PROGRESS_TTL_SEC = 1800.0 -_PLAY_PROGRESS_MAX = 5000 -_PLAY_PROGRESS_UPDATE_EVERY_BYTES = 512 * 1024 -_PLAY_PROGRESS_LOCK = asyncio.Lock() -_RECENTLY_RECORDED_HISTORY: dict[tuple[int, str], float] = {} - -LOG = LOGGER(__name__) - - -@dataclass(slots=True) -class _PlayProgress: - file_size: int | None = None - duration_sec: float | None = None - bitrate_kbps: int | None = None - ranges: list[tuple[int, int]] = field(default_factory=list) - counted: bool = False - last_seen: float = 0.0 - - -_PLAY_PROGRESS: dict[str, _PlayProgress] = {} - - -class _StreamBehindError(RuntimeError): - pass - - -class _StreamHub: - def __init__( - self, - *, - track_id: str, - file_id: str, - source_chat_id: int | None, - source_message_id: int | None, - ): - self.track_id = track_id - self.file_id = file_id - self.source_chat_id = source_chat_id - self.source_message_id = source_message_id - self._cond = asyncio.Condition() - self._chunks: deque[tuple[int, bytes]] = deque() - self._total_written = 0 - self._done = False - self._error: BaseException | None = None - self._producer: asyncio.Task | None = None - self._consumers: dict[str, int] = {} - self._lagged: set[str] = set() - self._last_access = time.monotonic() - - def start(self) -> None: - if self._producer is None or self._producer.done(): - self._producer = asyncio.create_task(self._run()) - - async def close(self) -> None: - producer = self._producer - self._producer = None - if producer and not producer.done(): - producer.cancel() - try: - await producer - except Exception: - pass - - def _buffer_start(self) -> int: - if not self._chunks: - return self._total_written - return self._chunks[0][0] - - async def _run(self) -> None: - from stream import acquire_stream_client, release_stream_client - - client_id = 0 - refreshed = False - try: - client_id, client = await acquire_stream_client() - LOG.debug(f"Streaming started using client {client_id}") - - while True: - # IMPORTANT: Ensure we have the file_id for THIS specific client - self.file_id = await _ensure_client_file_id( - track_id=self.track_id, - client_user_id=int(client_id), - client=client, - source_chat_id=self.source_chat_id, - source_message_id=self.source_message_id, - ) - - target = self.file_id - if ( - self.source_chat_id is not None - and self.source_message_id is not None - ): - try: - msg = await client.get_messages( - int(self.source_chat_id), int(self.source_message_id) - ) - if msg: - target = msg - except Exception as e: - LOG.warning(f"Hub failed to fetch source message: {e}") - - try: - start_chunk = self._total_written // _CHUNK_SIZE - stream_kwargs: dict[str, int] = {} - if start_chunk > 0: - stream_kwargs["offset"] = int(start_chunk) - - remaining_skip = max( - 0, self._total_written - (start_chunk * _CHUNK_SIZE) - ) - - async for chunk in client.stream_media(target, **stream_kwargs): - if not chunk: - continue - - if remaining_skip > 0: - if len(chunk) <= remaining_skip: - remaining_skip -= len(chunk) - continue - chunk = chunk[remaining_skip:] - remaining_skip = 0 - - async with self._cond: - start = self._total_written - self._total_written += len(chunk) - self._chunks.append((start, chunk)) - self._last_access = time.monotonic() - self._gc_locked() - self._cond.notify_all() - return - except Exception as e: - if refreshed: - raise - msg_str = str(e).upper() - if ( - "FILE_REFERENCE" not in msg_str - and "FILE_REFERENCE_EXPIRED" not in msg_str - ): - raise - - LOG.debug( - f"Hub encountered expired reference, attempting internal refresh" - ) - - # Refresh file_id for THIS client and retry - try: - self.file_id = await _ensure_client_file_id( - track_id=self.track_id, - client_user_id=int(client_id), - client=client, - source_chat_id=self.source_chat_id, - source_message_id=self.source_message_id, - force=True, - ) - except Exception as refresh_err: - LOG.error(f"Hub failed to refresh file_id: {refresh_err}") - if ( - self.source_chat_id is None - or self.source_message_id is None - ): - raise e - - refreshed = True - continue - except asyncio.CancelledError: - raise - except BaseException as e: - async with self._cond: - self._error = e - self._cond.notify_all() - finally: - await release_stream_client(client_id) - async with self._cond: - self._done = True - self._cond.notify_all() - - def _gc_locked(self) -> None: - min_offset = min(self._consumers.values(), default=self._total_written) - while self._chunks: - start, data = self._chunks[0] - end = start + len(data) - if end <= min_offset: - self._chunks.popleft() - continue - break - - buffer_start = self._buffer_start() - buffer_bytes = self._total_written - buffer_start - if buffer_bytes <= _MAX_STREAM_BUFFER_BYTES: - return - - keep_from = self._total_written - _MAX_STREAM_BUFFER_BYTES - while self._chunks: - start, data = self._chunks[0] - end = start + len(data) - if end <= keep_from: - self._chunks.popleft() - continue - break - - new_start = self._buffer_start() - if new_start <= buffer_start: - return - - for cid, off in list(self._consumers.items()): - if off < new_start: - self._lagged.add(cid) - - async def iter_bytes(self, start_byte: int) -> AsyncIterator[bytes]: - cid = str(time.time_ns()) - start_byte = max(0, int(start_byte)) - - self.start() - async with self._cond: - self._consumers[cid] = start_byte - self._last_access = time.monotonic() - self._cond.notify_all() - - try: - while True: - async with self._cond: - if cid in self._lagged: - raise _StreamBehindError() - if self._error: - raise self._error - - offset = self._consumers.get(cid, start_byte) - buffer_start = self._buffer_start() - if offset < buffer_start and self._total_written > 0: - raise _StreamBehindError() - - if self._done and offset >= self._total_written: - return - - chunk = None - for start, data in self._chunks: - end = start + len(data) - if end <= offset: - continue - if start > offset: - break - chunk = (start, data) - break - - if chunk is None: - await self._cond.wait() - continue - - start, data = chunk - rel = offset - start - out = data[rel:] if rel > 0 else data - self._consumers[cid] = offset + len(out) - self._last_access = time.monotonic() - self._gc_locked() - - if out: - yield out - finally: - async with self._cond: - self._consumers.pop(cid, None) - self._lagged.discard(cid) - self._last_access = time.monotonic() - self._gc_locked() - - -def _hub_key( - file_id: str, source_chat_id: int | None, source_message_id: int | None -) -> str: - if source_chat_id is not None and source_message_id is not None: - return f"m:{source_chat_id}:{source_message_id}" - return f"f:{file_id}" - - -async def _get_or_create_hub( - *, - track_id: str, - file_id: str, - source_chat_id: int | None, - source_message_id: int | None, -) -> _StreamHub: - key = _hub_key(file_id, source_chat_id, source_message_id) - async with _STREAM_HUBS_LOCK: - hub = _STREAM_HUBS.get(key) - if hub is None: - hub = _StreamHub( - track_id=track_id, - file_id=file_id, - source_chat_id=source_chat_id, - source_message_id=source_message_id, - ) - _STREAM_HUBS[key] = hub - hub.start() - return hub - - -async def close_stream_hubs() -> None: - async with _STREAM_HUBS_LOCK: - hubs = list(_STREAM_HUBS.values()) - _STREAM_HUBS.clear() - for hub in hubs: - try: - await hub.close() - except Exception: - pass - - -def _parse_range_header(value: str) -> tuple[int | None, int | None]: - if not value: - return None, None - m = re.match(r"^bytes=(\d+)-(\d+)?$", value.strip(), flags=re.I) - if not m: - return None, None - try: - start = int(m.group(1)) - except Exception: - return None, None - if start < 0: - return None, None - end = None - if m.group(2) is not None and m.group(2) != "": - try: - end = int(m.group(2)) - except Exception: - end = None - if end is not None and end < start: - return None, None - return start, end - - -def _guess_extension(mime_type: str) -> str: - mt = (mime_type or "").strip().lower() - if mt in {"audio/mpeg", "audio/mp3"}: - return ".mp3" - if mt in {"audio/flac", "audio/x-flac"}: - return ".flac" - if mt in {"audio/wav", "audio/x-wav", "audio/wave"}: - return ".wav" - if mt in {"audio/ogg", "application/ogg"}: - return ".ogg" - if mt in {"audio/aac"}: - return ".aac" - if mt in {"audio/mp4", "audio/m4a", "audio/x-m4a"}: - return ".m4a" - return ".mp3" - - -def _normalize_mime_type(mime_type: Any, doc: dict | None = None) -> str: - raw = (str(mime_type) if mime_type is not None else "").split(";")[0].strip().lower() - if raw in {"audio/flac", "audio/x-flac"} or raw.endswith("/x-flac"): - return "audio/flac" - if raw in {"audio/wav", "audio/x-wav", "audio/wave"}: - return "audio/wav" - if raw in {"audio/mpeg", "audio/mp3"}: - return "audio/mpeg" - if raw in {"audio/ogg", "application/ogg"}: - return "audio/ogg" - if raw in {"audio/aac"}: - return "audio/aac" - if raw in {"audio/mp4", "audio/m4a", "audio/x-m4a"}: - return "audio/mp4" - - if doc: - audio = doc.get("audio") if isinstance(doc.get("audio"), dict) else {} - atype = str(audio.get("type") or audio.get("format") or "").lower().strip() - if "flac" in atype: - return "audio/flac" - if "wav" in atype: - return "audio/wav" - if "mp3" in atype or "mpeg" in atype: - return "audio/mpeg" - if "aac" in atype: - return "audio/aac" - telegram = doc.get("telegram") if isinstance(doc.get("telegram"), dict) else {} - fname = str(telegram.get("file_name") or "").lower().strip() - if fname.endswith(".flac"): - return "audio/flac" - if fname.endswith(".wav"): - return "audio/wav" - if fname.endswith(".mp3"): - return "audio/mpeg" - - return raw or "audio/mpeg" - - -def _build_download_filename( - *, track_id: str, audio: dict, telegram: dict, mime_type: str -) -> str: - title = (audio.get("title") or telegram.get("title") or "").strip() - artist = ( - audio.get("artist") or audio.get("performer") or telegram.get("artist") or "" - ).strip() - if artist and title: - raw = f"{artist} - {title}" - elif title: - raw = title - else: - raw = f"track-{str(track_id or '').strip()[:12]}" - raw = re.sub(r"\s+", " ", raw).strip() - cleaned = re.sub(r'[\\/:*?"<>|\x00-\x1f]+', " ", raw) - cleaned = re.sub(r"\s+", " ", cleaned).strip(" ._-") - if not cleaned: - cleaned = f"track-{str(track_id or '').strip()[:12]}" - ext = _guess_extension(mime_type) - lower = cleaned.lower() - if not lower.endswith(ext): - cleaned = f"{cleaned}{ext}" - return cleaned - - -def _content_disposition_header(*, filename: str, track_id: str, mime_type: str) -> str: - ext = _guess_extension(mime_type) - fallback = re.sub(r"[^A-Za-z0-9._ -]+", "", filename or "").strip() - if not fallback: - fallback = f"track-{str(track_id or '').strip()[:12]}{ext}" - if not fallback.lower().endswith(ext): - fallback = f"{fallback}{ext}" - encoded = quote(filename or fallback, safe="") - return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{encoded}" - - -def _request_fingerprint(request: Request) -> str: - ip = "" - try: - if request.client and request.client.host: - ip = str(request.client.host) - except Exception: - ip = "" - ua = ( - request.headers.get("user-agent") or request.headers.get("User-Agent") or "" - ).strip() - raw = f"{ip}|{ua}" - return hashlib.sha1(raw.encode("utf-8", errors="ignore")).hexdigest()[:16] - - -def _request_user_id(request: Request) -> int | None: - token = ( - request.headers.get("authorization") - or request.headers.get("Authorization") - or "" - ).strip() - if not token: - token = ( - request.headers.get("x-auth-token") - or request.headers.get("X-Auth-Token") - or "" - ).strip() - if not token: - token = (request.query_params.get("token") or "").strip() - if not token: - token = (request.query_params.get("auth_token") or "").strip() - if not token: - token = (request.query_params.get("auth") or "").strip() - if not token: - token = (request.cookies.get("auth_token") or "").strip() - if not token: - token = (request.cookies.get("token") or "").strip() - if not token: - return None - try: - payload = verify_auth_token(token) - uid = payload.get("uid") - uid = int(uid) - if uid > 0: - return int(uid) - except Exception: - return None - return None - - -def _merge_ranges( - ranges: list[tuple[int, int]], start: int, end: int -) -> list[tuple[int, int]]: - start = int(start) - end = int(end) - if end < start: - return ranges - if not ranges: - return [(start, end)] - out: list[tuple[int, int]] = [] - inserted = False - for a, b in ranges: - if b + 1 < start: - out.append((a, b)) - continue - if end + 1 < a: - if not inserted: - out.append((start, end)) - inserted = True - out.append((a, b)) - continue - start = min(start, int(a)) - end = max(end, int(b)) - if not inserted: - out.append((start, end)) - out.sort(key=lambda x: x[0]) - merged: list[tuple[int, int]] = [] - for a, b in out: - if not merged: - merged.append((a, b)) - continue - la, lb = merged[-1] - if a <= lb + 1: - merged[-1] = (la, max(lb, b)) - else: - merged.append((a, b)) - return merged - - -def _covered_bytes(ranges: list[tuple[int, int]]) -> int: - total = 0 - for a, b in ranges: - total += max(0, int(b) - int(a) + 1) - return int(total) - - -def _seconds_from_bytes( - *, - covered: int, - file_size: int | None, - duration_sec: float | None, - bitrate_kbps: int | None, -) -> float: - covered = int(max(0, covered)) - if file_size and duration_sec and file_size > 0 and duration_sec > 0: - return float(covered) * float(duration_sec) / float(file_size) - if bitrate_kbps and bitrate_kbps > 0: - bytes_per_sec = (float(bitrate_kbps) * 1000.0) / 8.0 - if bytes_per_sec > 0: - return float(covered) / float(bytes_per_sec) - return 0.0 - - -async def _prune_play_progress_locked(now: float) -> None: - if not _PLAY_PROGRESS: - return - expired: list[str] = [] - for k, v in _PLAY_PROGRESS.items(): - if (now - float(v.last_seen)) > float(_PLAY_PROGRESS_TTL_SEC): - expired.append(k) - for k in expired: - _PLAY_PROGRESS.pop(k, None) - if len(_PLAY_PROGRESS) <= _PLAY_PROGRESS_MAX: - return - items = sorted(_PLAY_PROGRESS.items(), key=lambda kv: float(kv[1].last_seen)) - for k, _ in items[: max(0, len(items) - _PLAY_PROGRESS_MAX)]: - _PLAY_PROGRESS.pop(k, None) - - -async def _update_play_progress( - *, - key: str, - start: int, - end: int, - file_size: int | None, - duration_sec: float | None, - bitrate_kbps: int | None, -) -> bool: - now = time.monotonic() - async with _PLAY_PROGRESS_LOCK: - await _prune_play_progress_locked(now) - prog = _PLAY_PROGRESS.get(key) - if prog is None: - prog = _PlayProgress( - file_size=int(file_size) if file_size is not None else None, - duration_sec=float(duration_sec) if duration_sec is not None else None, - bitrate_kbps=int(bitrate_kbps) if bitrate_kbps is not None else None, - last_seen=float(now), - ) - _PLAY_PROGRESS[key] = prog - prog.last_seen = float(now) - if file_size is not None: - try: - prog.file_size = int(file_size) - except Exception: - pass - if duration_sec is not None: - try: - prog.duration_sec = float(duration_sec) - except Exception: - pass - if bitrate_kbps is not None: - try: - prog.bitrate_kbps = int(bitrate_kbps) - except Exception: - pass - if prog.counted: - return False - prog.ranges = _merge_ranges(prog.ranges, int(start), int(end)) - covered = _covered_bytes(prog.ranges) - sec = _seconds_from_bytes( - covered=covered, - file_size=prog.file_size, - duration_sec=prog.duration_sec, - bitrate_kbps=prog.bitrate_kbps, - ) - if sec >= float(_PLAY_COUNT_THRESHOLD_SEC): - prog.counted = True - return True - return False - - -async def _register_play( - *, - track_id: str, - user_id: int | None, - source: str, - jam_id: str | None, -) -> None: - track_id = (track_id or "").strip() - if not track_id: - return - source = (source or "").strip() or "direct" - if source not in {"direct", "jam"}: - source = "direct" - now = time.time() - - should_count_global = True - if user_id is not None and int(user_id) > 0: - played_bucket = int(now // _PLAY_PROGRESS_TTL_SEC) - user_play_id = f"{int(user_id)}:{track_id}:{played_bucket}" - doc: dict[str, object] = { - "_id": user_play_id, - "user_id": int(user_id), - "track_id": track_id, - "played_at": float(now), - "bucket": int(played_bucket), - "source": source, - } - if jam_id: - doc["jam_id"] = str(jam_id) - try: - res = await db_handler.userplayback_collection.collection.update_one( - {"_id": user_play_id}, - {"$setOnInsert": doc}, - upsert=True, - ) - should_count_global = bool(getattr(res, "upserted_id", None)) - except Exception: - should_count_global = False - - if not should_count_global: - return - - inc: dict[str, int] = {"plays": 1, f"sources.{source}": 1} - update: dict[str, object] = { - "$inc": inc, - "$set": {"last_played_at": float(now), "updated_at": float(now)}, - } - if jam_id: - update["$set"]["last_jam_id"] = str(jam_id) - try: - await db_handler.globalplayback_collection.collection.update_one( - {"_id": track_id}, update, upsert=True - ) - except Exception: - return - - -async def _wrap_with_play_count( - *, - iterator: AsyncIterator[bytes], - request: Request, - track_id: str, - from_bytes: int, - file_size: int | None, - duration_sec: float | None, - bitrate_kbps: int | None, -) -> AsyncIterator[bytes]: - if (request.method or "").upper() == "HEAD": - async for chunk in iterator: - yield chunk - return - - user_id = _request_user_id(request) - source = "jam" if (request.query_params.get("jam_id") or "").strip() else "direct" - jam_id = (request.query_params.get("jam_id") or "").strip() or None - fp = _request_fingerprint(request) - key = f"{int(user_id) if user_id is not None else 0}:{track_id}:{fp}" - - cursor = max(0, int(from_bytes)) - last_flush = int(cursor) - - triggered = False - try: - async for chunk in iterator: - if not chunk: - continue - yield chunk - clen = len(chunk) - if clen <= 0: - continue - cursor += int(clen) - if triggered: - continue - if (cursor - last_flush) < int(_PLAY_PROGRESS_UPDATE_EVERY_BYTES): - continue - hit = await _update_play_progress( - key=key, - start=last_flush, - end=cursor - 1, - file_size=file_size, - duration_sec=duration_sec, - bitrate_kbps=bitrate_kbps, - ) - last_flush = int(cursor) - if hit: - triggered = True - asyncio.create_task( - _register_play( - track_id=track_id, user_id=user_id, source=source, jam_id=jam_id - ) - ) - finally: - if not triggered and cursor > last_flush: - try: - hit2 = await _update_play_progress( - key=key, - start=last_flush, - end=cursor - 1, - file_size=file_size, - duration_sec=duration_sec, - bitrate_kbps=bitrate_kbps, - ) - if hit2: - asyncio.create_task( - _register_play( - track_id=track_id, - user_id=user_id, - source=source, - jam_id=jam_id, - ) - ) - except Exception: - pass - - -def _extract_media_file_id(message) -> str | None: - if not message: - return None - media = getattr(message, "audio", None) or getattr(message, "document", None) - if not media: - return None - fid = getattr(media, "file_id", None) - if not fid: - return None - return str(fid) - - -def _message_has_downloadable_media(message) -> bool: - return bool(_extract_media_file_id(message)) - - -def _hydration_chat_id() -> int: - for attr in ("CHANNEL_ID", "DUMP_CHANNEL_ID"): - value = getattr(Config, attr, None) - try: - chat_id = int(value) - except Exception: - chat_id = 0 - if chat_id: - return chat_id - return 0 - - -def _coerce_optional_int(value) -> int | None: - if value is None or isinstance(value, bool): - return None - try: - return int(value) - except Exception: - return None - - -def _playback_source_ids(doc: dict | None) -> tuple[int | None, int | None]: - if not isinstance(doc, dict): - return None, None - cache_chat_id = _coerce_optional_int(doc.get("cache_chat_id")) - cache_message_id = _coerce_optional_int(doc.get("cache_message_id")) - if cache_chat_id is not None and cache_message_id is not None: - return cache_chat_id, cache_message_id - return ( - _coerce_optional_int(doc.get("source_chat_id")), - _coerce_optional_int(doc.get("source_message_id")), - ) - - -async def _get_lock(key: str) -> asyncio.Lock: - async with _FILE_ID_LOCKS_LOCK: - lock = _FILE_ID_LOCKS.get(key) - if lock is None: - lock = asyncio.Lock() - _FILE_ID_LOCKS[key] = lock - return lock - - -async def _ensure_client_file_id( - *, - track_id: str, - client_user_id: int, - client, - source_chat_id: int | None, - source_message_id: int | None, - force: bool = False, -) -> str: - lock = await _get_lock(f"{track_id}:{client_user_id}") - async with lock: - col = get_audio_tracks_collection() - key = str(int(client_user_id)) - doc: dict | None = None - telegram: dict = {} - - # If force is True, we ignore the cache for the entire function call. - ignore_cache = force - if ignore_cache: - LOG.debug( - f"stream file_id force refresh track={track_id} client={client_user_id}" - ) - - for attempt in range(6): - doc = await col.find_one( - {"_id": track_id}, - projection={ - "telegram": 1, - "source_chat_id": 1, - "source_message_id": 1, - "cache_chat_id": 1, - "cache_message_id": 1, - }, - ) - telegram = (doc or {}).get("telegram") or {} - file_ids = ( - telegram.get("file_ids") - if isinstance(telegram.get("file_ids"), dict) - else {} - ) - existing = (file_ids or {}).get(key) - if not ignore_cache and isinstance(existing, str) and existing.strip(): - LOG.debug( - f"stream file_id cache hit track={track_id} client={client_user_id} file_id={existing.strip()}" - ) - return existing.strip() - - resolved_chat_id = source_chat_id - if resolved_chat_id is None: - resolved_chat_id, _ = _playback_source_ids(doc) - if resolved_chat_id is None: - resolved_chat_id = getattr(Config, "CHANNEL_ID", None) - try: - resolved_chat_id = ( - int(resolved_chat_id) if resolved_chat_id is not None else None - ) - except Exception: - resolved_chat_id = None - - resolved_message_id = source_message_id - if resolved_message_id is None: - _, resolved_message_id = _playback_source_ids(doc) - - if resolved_chat_id is not None and resolved_message_id is not None: - try: - msg = await client.get_messages( - int(resolved_chat_id), int(resolved_message_id) - ) - fid = _extract_media_file_id(msg) - except Exception: - fid = None - - if fid: - LOG.debug( - f"stream file_id synced via channel track={track_id} client={client_user_id} file_id={fid}" - ) - await col.update_one( - {"_id": track_id}, - { - "$set": { - f"telegram.file_ids.{key}": fid, - "updated_at": time.time(), - } - }, - ) - return fid - - if attempt < 5: - await asyncio.sleep(0.35) - - dump_channel_id = _hydration_chat_id() - if not dump_channel_id: - raise HTTPException( - status_code=404, detail="No source message to sync file_id" - ) - - resolved_chat_id = source_chat_id - if resolved_chat_id is None: - resolved_chat_id, _ = _playback_source_ids(doc) - - resolved_message_id = source_message_id - if resolved_message_id is None: - _, resolved_message_id = _playback_source_ids(doc) - - dump_message_id = telegram.get("dump_message_id") - try: - dump_message_id = ( - int(dump_message_id) if dump_message_id is not None else None - ) - except Exception: - dump_message_id = None - - if not dump_message_id: - if resolved_chat_id is None or resolved_message_id is None: - fallback_file_id = (telegram.get("file_id") or "").strip() - if not fallback_file_id: - raise HTTPException( - status_code=404, detail="No source message to sync file_id" - ) - LOG.debug( - f"stream syncing via dump send_document track={track_id} client={client_user_id}" - ) - sent = await bot.send_document(int(dump_channel_id), fallback_file_id) - else: - LOG.debug( - f"stream syncing via dump copy_message track={track_id} client={client_user_id} from={resolved_chat_id}:{resolved_message_id}" - ) - try: - sent = await bot.copy_message( - chat_id=int(dump_channel_id), - from_chat_id=int(resolved_chat_id), - message_id=int(resolved_message_id), - ) - except Exception as e: - LOG.debug( - f"bot copy_message failed: {e}. Trying with userbot if available..." - ) - from stream.plugins.userBot.service import _USERBOT_INSTANCE - - if _USERBOT_INSTANCE: - sent = await _USERBOT_INSTANCE.copy_message( - chat_id=int(dump_channel_id), - from_chat_id=int(resolved_chat_id), - message_id=int(resolved_message_id), - ) - else: - raise e - dump_message_id = int(getattr(sent, "id")) - await col.update_one( - {"_id": track_id}, - { - "$set": { - "telegram.dump_message_id": dump_message_id, - "cache_chat_id": int(dump_channel_id), - "cache_message_id": dump_message_id, - "updated_at": time.time(), - } - }, - ) - - msg = await client.get_messages(int(dump_channel_id), int(dump_message_id)) - fid = _extract_media_file_id(msg) - if not fid: - raise HTTPException( - status_code=404, detail="Failed to read file_id from dump message" - ) - - LOG.debug( - f"stream file_id synced via dump track={track_id} client={client_user_id} file_id={fid}" - ) - await col.update_one( - {"_id": track_id}, - {"$set": {f"telegram.file_ids.{key}": fid, "updated_at": time.time()}}, - ) - return fid - - -async def _stream_range( - *, - track_id: str, - client_user_id: int, - client, - file_id: str, - source_chat_id: int | None, - source_message_id: int | None, - from_bytes: int, - until_bytes: int | None, -) -> AsyncIterator[bytes]: - cursor = max(0, int(from_bytes)) - refreshed = False - try: - if until_bytes is not None: - until_bytes = max(cursor, int(until_bytes)) - while True: - # IMPORTANT: Re-sync for THIS client on every retry to avoid client mismatch - file_id = await _ensure_client_file_id( - track_id=track_id, - client_user_id=int(client_user_id), - client=client, - source_chat_id=source_chat_id, - source_message_id=source_message_id, - force=refreshed, - ) - - target: str | object = file_id - if source_chat_id is not None and source_message_id is not None: - try: - msg = await client.get_messages( - int(source_chat_id), int(source_message_id) - ) - if msg and _message_has_downloadable_media(msg): - target = msg - except Exception: - pass - - start_chunk = cursor // _CHUNK_SIZE - stream_kwargs: dict[str, int] = {} - if start_chunk: - stream_kwargs["offset"] = int(start_chunk) - - stream_cursor = int(start_chunk) * int(_CHUNK_SIZE) - try: - async for chunk in client.stream_media(target, **stream_kwargs): - if not chunk: - continue - - chunk_start = stream_cursor - chunk_end = stream_cursor + len(chunk) - 1 - stream_cursor += len(chunk) - - if chunk_end < cursor: - continue - if until_bytes is not None and chunk_start > until_bytes: - return - - out_start = max(cursor, chunk_start) - out_end = ( - chunk_end - if until_bytes is None - else min(until_bytes, chunk_end) - ) - rel_start = out_start - chunk_start - rel_end = out_end - chunk_start - out_chunk = chunk[int(rel_start) : int(rel_end) + 1] - - if out_chunk: - yield out_chunk - cursor = int(out_end) + 1 - if until_bytes is not None and out_end >= until_bytes: - return - return - except Exception as e: - if refreshed or source_chat_id is None or source_message_id is None: - raise - msg_str = str(e).upper() - if ( - "FILE_REFERENCE" not in msg_str - and "FILE_REFERENCE_EXPIRED" not in msg_str - ): - raise - refreshed = True - continue - finally: - from stream import release_stream_client - - await release_stream_client(client_user_id) - - -async def _direct_stream( - *, - track_id: str, - client_user_id: int, - client, - file_id: str, - source_chat_id: int | None, - source_message_id: int | None, - start_byte: int = 0, -) -> AsyncIterator[bytes]: - cursor = max(0, int(start_byte)) - refreshed = False - try: - while True: - # IMPORTANT: Re-sync for THIS client on every retry to avoid client mismatch - file_id = await _ensure_client_file_id( - track_id=track_id, - client_user_id=int(client_user_id), - client=client, - source_chat_id=source_chat_id, - source_message_id=source_message_id, - force=refreshed, - ) - - target: str | object = file_id - if source_chat_id is not None and source_message_id is not None: - try: - msg = await client.get_messages( - int(source_chat_id), int(source_message_id) - ) - if msg and _message_has_downloadable_media(msg): - target = msg - except Exception: - pass - - remaining_skip = max(0, int(cursor)) - try: - async for chunk in client.stream_media(target): - if not chunk: - continue - if remaining_skip > 0: - if len(chunk) <= remaining_skip: - remaining_skip -= len(chunk) - continue - chunk = chunk[remaining_skip:] - remaining_skip = 0 - if chunk: - cursor += len(chunk) - yield chunk - return - except Exception as e: - if refreshed or source_chat_id is None or source_message_id is None: - raise - msg_str = str(e).upper() - if ( - "FILE_REFERENCE" not in msg_str - and "FILE_REFERENCE_EXPIRED" not in msg_str - ): - raise - refreshed = True - continue - finally: - from stream import release_stream_client - - await release_stream_client(client_user_id) - - -async def record_user_history(user_id: int, track_id: str, played_at: float) -> None: - try: - col = db_handler.get_collection("userHistory").collection - doc = { - "user_id": int(user_id), - "track_id": str(track_id), - "played_at": float(played_at), - } - res = await col.insert_one(doc) - LOG.debug(f"[userHistory] Inserted doc_id={res.inserted_id} for user_id={user_id} track_id={track_id}") - except Exception as e: - LOG.error(f"[userHistory] Failed to insert for user_id={user_id} track_id={track_id}: {e}", exc_info=True) - - -def _is_alac_track(doc: dict) -> bool: - if not isinstance(doc, dict): - return False - audio = doc.get("audio") if isinstance(doc.get("audio"), dict) else {} - telegram = doc.get("telegram") if isinstance(doc.get("telegram"), dict) else {} - - audio_type = str(audio.get("type") or "").lower().strip() - audio_format = str(audio.get("format") or "").lower().strip() - mime_type = str(telegram.get("mime_type") or "").lower().strip() - file_name = str(telegram.get("file_name") or "").lower().strip() - - if "alac" in audio_type or "alac" in audio_format or "alac" in mime_type: - return True - if "alac" in file_name: - return True - return False - - -def _should_decode_alac(request: Request, doc: dict) -> bool: - if not _is_alac_track(doc): - return False - - fmt = (request.query_params.get("format") or "").lower().strip() - if fmt in ("flac", "decoded", "transcode", "pcm", "wav"): - return True - decode_param = ( - request.query_params.get("decode") - or request.query_params.get("transcode") - or request.query_params.get("alac_decode") - or "" - ).lower().strip() - if decode_param in ("1", "true", "yes", "flac"): - return True - - if fmt in ("raw", "alac", "original", "source") or decode_param in ("0", "false", "no"): - return False - - client_hdr = ( - request.headers.get("x-streamx-client") - or request.headers.get("X-StreamX-Client") - or "" - ).lower().strip() - if "native" in client_hdr or "android-native" in client_hdr or "exoplayer" in client_hdr: - return False - - ua = ( - request.headers.get("user-agent") or request.headers.get("User-Agent") or "" - ).lower().strip() - sec_dest = (request.headers.get("sec-fetch-dest") or "").lower().strip() - sec_mode = (request.headers.get("sec-fetch-mode") or "").lower().strip() - sec_ua = (request.headers.get("sec-ch-ua") or "").lower().strip() - sec_platform = (request.headers.get("sec-ch-ua-platform") or "").lower().strip() - - is_browser = bool( - sec_dest - or sec_mode - or sec_ua - or sec_platform - or "mozilla" in ua - or "chrome" in ua - or "firefox" in ua - or "safari" in ua - or "edge" in ua - or "edg" in ua - or "opera" in ua - ) - - if not is_browser: - return False - - is_apple_os = ( - ("macintosh" in ua or "mac os x" in ua or "iphone" in ua or "ipad" in ua or "ipod" in ua) - and "windows" not in ua - and "android" not in ua - ) - if is_apple_os and ("safari" in ua or "applewebkit" in ua) and "chrome" not in ua and "edg" not in ua: - return False - - is_windows = "windows" in ua or "win32" in ua or "win64" in ua or "windows" in sec_platform - is_android = "android" in ua or "android" in sec_platform - is_linux = "linux" in ua or "linux" in sec_platform - - if is_windows or is_android or is_linux: - return True - - return True - - -def _prune_alac_cache_if_needed(max_files: int = 200, max_size_bytes: int = 5_000_000_000) -> None: - try: - if not os.path.isdir(ALAC_CACHE_DIR): - return - entries = [] - total_size = 0 - for fname in os.listdir(ALAC_CACHE_DIR): - if not fname.endswith(".flac"): - continue - fpath = os.path.join(ALAC_CACHE_DIR, fname) - try: - stat = os.stat(fpath) - entries.append((fpath, stat.st_mtime, stat.st_size)) - total_size += stat.st_size - except OSError: - continue - - if len(entries) <= max_files and total_size <= max_size_bytes: - return - - entries.sort(key=lambda x: x[1]) - while entries and (len(entries) > max_files or total_size > max_size_bytes): - oldest_path, _, oldest_size = entries.pop(0) - try: - os.remove(oldest_path) - total_size -= oldest_size - except OSError: - pass - except Exception as e: - LOG.warning(f"[ALAC-Decode] Cache pruning error: {e}") - - -async def _get_alac_transcode_lock(track_id: str) -> asyncio.Lock: - async with _ALAC_TRANSCODE_LOCKS_LOCK: - lock = _ALAC_TRANSCODE_LOCKS.get(track_id) - if lock is None: - lock = asyncio.Lock() - _ALAC_TRANSCODE_LOCKS[track_id] = lock - return lock - - -async def _stream_cached_file( - file_path: str, - from_bytes: int, - until_bytes: int | None, - chunk_size: int = 256 * 1024, -) -> AsyncIterator[bytes]: - cursor = int(max(0, from_bytes)) - target_end = None if until_bytes is None else int(until_bytes) - async with aiofiles.open(file_path, "rb") as f: - if cursor > 0: - await f.seek(cursor) - while True: - read_size = chunk_size - if target_end is not None: - remaining = (target_end - cursor) + 1 - if remaining <= 0: - break - read_size = min(read_size, remaining) - chunk = await f.read(read_size) - if not chunk: - break - cursor += len(chunk) - yield chunk - if target_end is not None and cursor > target_end: - break - - -def _run_ffmpeg_transcode(src_file: str, dst_file: str) -> tuple[bool, str]: - cmd = [ - "ffmpeg", - "-hide_banner", - "-loglevel", "error", - "-y", - "-i", src_file, - "-vn", - "-c:a", "flac", - "-compression_level", "0", - "-f", "flac", - dst_file, - ] - res = subprocess.run(cmd, capture_output=True) - return res.returncode == 0, res.stderr.decode("utf-8", errors="ignore") - - -async def _ensure_alac_decoded_file(track_id: str, doc: dict) -> str: - os.makedirs(ALAC_CACHE_DIR, exist_ok=True) - target_file = os.path.join(ALAC_CACHE_DIR, f"{track_id}.flac") - - if os.path.isfile(target_file) and os.path.getsize(target_file) > 10240: - return target_file - - lock = await _get_alac_transcode_lock(track_id) - async with lock: - if os.path.isfile(target_file) and os.path.getsize(target_file) > 10240: - return target_file - - temp_flac = os.path.join( - ALAC_CACHE_DIR, f"{track_id}.tmp_{os.getpid()}_{int(time.time() * 1000)}.flac" - ) - temp_src = os.path.join( - ALAC_CACHE_DIR, f"{track_id}.src_{os.getpid()}_{int(time.time() * 1000)}.m4a" - ) - - telegram = doc.get("telegram") or {} - primary_file_id = (telegram.get("file_id") or "").strip() - playback_chat_id, playback_message_id = _playback_source_ids(doc) - - from stream import acquire_stream_client, release_stream_client - - client_user_id, client = await acquire_stream_client() - try: - client_key = str(int(client_user_id)) - file_ids = ( - telegram.get("file_ids") - if isinstance(telegram.get("file_ids"), dict) - else {} - ) - file_id = (file_ids or {}).get(client_key) or "" - if not file_id: - file_id = await _ensure_client_file_id( - track_id=track_id, - client_user_id=int(client_user_id), - client=client, - source_chat_id=playback_chat_id, - source_message_id=playback_message_id, - ) - - target: str | object = file_id - if playback_chat_id is not None and playback_message_id is not None: - try: - msg = await client.get_messages( - int(playback_chat_id), int(playback_message_id) - ) - if msg and _message_has_downloadable_media(msg): - target = msg - except Exception: - pass - - LOG.debug(f"[ALAC-Decode] Fetching source media for track_id={track_id}") - async with aiofiles.open(temp_src, "wb") as sf: - async for chunk in client.stream_media(target): - if chunk: - await sf.write(chunk) - - LOG.debug(f"[ALAC-Decode] Transcoding {temp_src} -> {temp_flac}") - ok, err_msg = await asyncio.to_thread(_run_ffmpeg_transcode, temp_src, temp_flac) - if not ok or not os.path.isfile(temp_flac) or os.path.getsize(temp_flac) <= 10240: - raise RuntimeError(f"FFmpeg transcode failed: {err_msg[:300]}") - - os.replace(temp_flac, target_file) - LOG.debug( - f"[ALAC-Decode] Transcode completed successfully: {target_file} ({os.path.getsize(target_file)} bytes)" - ) - _prune_alac_cache_if_needed() - return target_file - - finally: - await release_stream_client(int(client_user_id)) - try: - if os.path.isfile(temp_flac): - os.remove(temp_flac) - except Exception: - pass - try: - if os.path.isfile(temp_src): - os.remove(temp_src) - except Exception: - pass - - -async def stream_track(track_id: str, request: Request): - if bool(getattr(Config, "ONLY_API", False)) or bot is None: - raise HTTPException(status_code=503, detail="streaming disabled") - col = get_audio_tracks_collection() - doc = await col.find_one( - {"_id": track_id}, - projection={ - "telegram": 1, - "audio": 1, - "source_chat_id": 1, - "source_message_id": 1, - "cache_chat_id": 1, - "cache_message_id": 1, - "deleted": 1, - }, - ) - if not doc: - raise HTTPException(status_code=404, detail="Track not found") - if bool(doc.get("deleted")): - raise HTTPException(status_code=404, detail="Track deleted") - - telegram = doc.get("telegram") or {} - audio = doc.get("audio") if isinstance(doc.get("audio"), dict) else {} - primary_file_id = (telegram.get("file_id") or "").strip() - - playback_chat_id, playback_message_id = _playback_source_ids(doc) - - mime_type = _normalize_mime_type(telegram.get("mime_type"), doc=doc) - - file_size: Optional[int] = None - try: - if telegram.get("file_size") is not None: - file_size = int(telegram.get("file_size")) - except Exception: - file_size = None - if file_size is None: - try: - if doc.get("file_size") is not None: - file_size = int(doc.get("file_size")) - except Exception: - file_size = None - if file_size is not None and int(file_size) <= 0: - file_size = None - - duration_sec: float | None = None - try: - if audio.get("duration_sec") is not None: - duration_sec = float(audio.get("duration_sec")) - except Exception: - duration_sec = None - - bitrate_kbps: int | None = None - try: - if audio.get("bitrate_kbps") is not None: - bitrate_kbps = int(audio.get("bitrate_kbps")) - except Exception: - bitrate_kbps = None - - user_id: Optional[int] = None - token = request.query_params.get("token") or request.headers.get("X-Auth-Token") - if not token: - auth_hdr = request.headers.get("Authorization") or "" - if auth_hdr.startswith("Bearer "): - token = auth_hdr[7:].strip() - if token: - try: - payload = verify_auth_token(token) - if isinstance(payload, dict): - raw_uid = payload.get("uid") or payload.get("user_id") - if raw_uid is not None: - try: - user_id = int(raw_uid) - except (ValueError, TypeError): - user_id = None - except Exception: - user_id = None - - range_header = ( - request.headers.get("range") or request.headers.get("Range") or "" - ).strip() - start_byte, end_byte = _parse_range_header(range_header) - - has_range = bool(range_header) and start_byte is not None - from_bytes = int(start_byte or 0) - - if user_id and (request.method or "").upper() in ("GET", "HEAD"): - now_ts = time.time() - cache_key = (int(user_id), str(track_id)) - last_rec = _RECENTLY_RECORDED_HISTORY.get(cache_key, 0.0) - if now_ts - last_rec > 15.0: - _RECENTLY_RECORDED_HISTORY[cache_key] = now_ts - asyncio.create_task(record_user_history(user_id, track_id, now_ts)) - - should_decode = _should_decode_alac(request, doc) - if should_decode: - flac_path = None - flac_file_size = None - try: - flac_path = await _ensure_alac_decoded_file(track_id, doc) - if flac_path and os.path.isfile(flac_path): - flac_file_size = os.path.getsize(flac_path) - except Exception as e: - LOG.error( - f"[ALAC-Decode] Failed to ensure decoded FLAC file for track_id={track_id}: {e}", - exc_info=True, - ) - flac_path = None - flac_file_size = None - - if flac_path and flac_file_size: - mime_type = "audio/flac" - file_size = flac_file_size - - until_bytes: int | None = None - if has_range: - if end_byte is not None: - until_bytes = int(end_byte) - elif file_size is not None: - until_bytes = int(file_size) - 1 - if file_size is not None and until_bytes is not None: - until_bytes = min(int(until_bytes), int(file_size) - 1) - - if file_size is not None and has_range and from_bytes >= file_size: - raise HTTPException(status_code=416, detail="range not satisfiable") - - status_code = ( - 206 - if (has_range and file_size is not None and until_bytes is not None) - else 200 - ) - - headers = { - "Accept-Ranges": "bytes", - "Content-Type": mime_type, - } - if status_code == 206 and file_size is not None and until_bytes is not None: - headers["Content-Range"] = f"bytes {from_bytes}-{until_bytes}/{file_size}" - headers["Content-Length"] = str((until_bytes - from_bytes) + 1) - elif file_size is not None: - headers["Content-Length"] = str(file_size) - - if (request.method or "").upper() == "HEAD": - return Response( - content=b"", status_code=status_code, headers=headers, media_type=mime_type - ) - - iterator = _stream_cached_file( - file_path=flac_path, - from_bytes=from_bytes, - until_bytes=until_bytes, - ) - wrapped = _wrap_with_play_count( - iterator=iterator, - request=request, - track_id=track_id, - from_bytes=from_bytes if status_code == 206 else 0, - file_size=file_size, - duration_sec=duration_sec, - bitrate_kbps=bitrate_kbps, - ) - return StreamingResponse( - wrapped, status_code=status_code, headers=headers, media_type=mime_type - ) - - until_bytes: int | None = None - if has_range: - if end_byte is not None: - until_bytes = int(end_byte) - elif file_size is not None: - until_bytes = int(file_size) - 1 - if file_size is not None and until_bytes is not None: - until_bytes = min(int(until_bytes), int(file_size) - 1) - - if file_size is not None and has_range and from_bytes >= file_size: - raise HTTPException(status_code=416, detail="range not satisfiable") - - status_code = ( - 206 - if (has_range and file_size is not None and until_bytes is not None) - else 200 - ) - - headers = { - "Accept-Ranges": "bytes", - "Content-Type": mime_type, - } - if status_code == 206 and file_size is not None and until_bytes is not None: - headers["Content-Range"] = f"bytes {from_bytes}-{until_bytes}/{file_size}" - if (request.method or "").upper() == "HEAD": - headers["Content-Length"] = str((until_bytes - from_bytes) + 1) - elif file_size is not None: - if (request.method or "").upper() == "HEAD": - headers["Content-Length"] = str(file_size) - - if (request.method or "").upper() == "HEAD": - return Response( - content=b"", status_code=status_code, headers=headers, media_type=mime_type - ) - - file_ids_for_pick = ( - telegram.get("file_ids") if isinstance(telegram.get("file_ids"), dict) else {} - ) - preferred_user_ids: list[int] = [] - for k, v in (file_ids_for_pick or {}).items(): - if not isinstance(v, str) or not v.strip(): - continue - try: - preferred_user_ids.append(int(k)) - except Exception: - pass - - from stream import ( - acquire_stream_client, - acquire_stream_client_prefer, - release_stream_client, - ) - - affinity_client_id: int | None = None - now = time.monotonic() - async with _TRACK_AFFINITY_LOCK: - entry = _TRACK_AFFINITY.get(track_id) - if entry: - cid, ts = entry - if (now - float(ts)) <= float(_TRACK_AFFINITY_TTL_SEC): - affinity_client_id = int(cid) - else: - _TRACK_AFFINITY.pop(track_id, None) - - if preferred_user_ids: - if affinity_client_id is not None and affinity_client_id in set( - preferred_user_ids - ): - try: - client_user_id, client = await acquire_stream_client_prefer( - [int(affinity_client_id)] - ) - except Exception: - client_user_id, client = await acquire_stream_client_prefer( - preferred_user_ids - ) - else: - client_user_id, client = await acquire_stream_client_prefer( - preferred_user_ids - ) - else: - client_user_id, client = await acquire_stream_client() - - async with _TRACK_AFFINITY_LOCK: - _TRACK_AFFINITY[track_id] = (int(client_user_id), time.monotonic()) - try: - if preferred_user_ids: - if affinity_client_id is not None and int(client_user_id) == int( - affinity_client_id - ): - LOG.debug( - f"stream using affinity client track={track_id} client={client_user_id}" - ) - else: - LOG.debug( - f"stream picked preferred client track={track_id} client={client_user_id}" - ) - else: - LOG.debug(f"stream picked client track={track_id} client={client_user_id}") - client_key = str(int(client_user_id)) - file_ids = ( - telegram.get("file_ids") - if isinstance(telegram.get("file_ids"), dict) - else {} - ) - file_id = (file_ids or {}).get(client_key) - if isinstance(file_id, str): - file_id = file_id.strip() - else: - file_id = "" - - primary_uid = get_primary_client_user_id() - if ( - not file_id - and primary_uid is not None - and int(primary_uid) == int(client_user_id) - and primary_file_id - ): - file_id = primary_file_id - LOG.debug( - f"stream using primary file_id track={track_id} client={client_user_id} file_id={file_id}" - ) - await get_audio_tracks_collection().update_one( - {"_id": track_id}, - { - "$set": { - f"telegram.file_ids.{client_key}": file_id, - } - }, - ) - elif file_id: - LOG.debug( - f"stream using cached file_id track={track_id} client={client_user_id} file_id={file_id}" - ) - - if not file_id: - file_id = await _ensure_client_file_id( - track_id=track_id, - client_user_id=int(client_user_id), - client=client, - source_chat_id=playback_chat_id, - source_message_id=playback_message_id, - ) - except Exception: - await release_stream_client(int(client_user_id)) - raise - - if status_code == 206: - iterator = _stream_range( - track_id=track_id, - client_user_id=int(client_user_id), - client=client, - file_id=file_id, - source_chat_id=playback_chat_id, - source_message_id=playback_message_id, - from_bytes=from_bytes, - until_bytes=until_bytes, - ) - else: - iterator = _direct_stream( - track_id=track_id, - client_user_id=int(client_user_id), - client=client, - file_id=file_id, - source_chat_id=playback_chat_id, - source_message_id=playback_message_id, - start_byte=0, - ) - wrapped = _wrap_with_play_count( - iterator=iterator, - request=request, - track_id=track_id, - from_bytes=from_bytes if status_code == 206 else 0, - file_size=file_size, - duration_sec=duration_sec, - bitrate_kbps=bitrate_kbps, - ) - return StreamingResponse( - wrapped, status_code=status_code, headers=headers, media_type=mime_type - ) - - -async def download_track(track_id: str, request: Request): - if bool(getattr(Config, "ONLY_API", False)) or bot is None: - raise HTTPException(status_code=503, detail="streaming disabled") - col = get_audio_tracks_collection() - doc = await col.find_one( - {"_id": track_id}, - projection={ - "telegram": 1, - "audio": 1, - "file_size": 1, - "source_chat_id": 1, - "source_message_id": 1, - "cache_chat_id": 1, - "cache_message_id": 1, - "deleted": 1, - }, - ) - if not doc: - raise HTTPException(status_code=404, detail="Track not found") - if bool(doc.get("deleted")): - raise HTTPException(status_code=404, detail="Track deleted") - - telegram = doc.get("telegram") or {} - audio = doc.get("audio") if isinstance(doc.get("audio"), dict) else {} - primary_file_id = (telegram.get("file_id") or "").strip() - - playback_chat_id, playback_message_id = _playback_source_ids(doc) - - if not primary_file_id and ( - playback_chat_id is None or playback_message_id is None - ): - raise HTTPException(status_code=404, detail="Track source unavailable") - - file_size: Optional[int] = None - try: - if telegram.get("file_size") is not None: - file_size = int(telegram.get("file_size")) - except Exception: - file_size = None - if file_size is None: - try: - if doc.get("file_size") is not None: - file_size = int(doc.get("file_size")) - except Exception: - file_size = None - if file_size is not None and int(file_size) <= 0: - file_size = None - - mime_type = _normalize_mime_type(telegram.get("mime_type"), doc=doc) - filename = _build_download_filename( - track_id=track_id, audio=audio, telegram=telegram, mime_type=mime_type - ) - - range_header = ( - request.headers.get("range") or request.headers.get("Range") or "" - ).strip() - start_byte, end_byte = _parse_range_header(range_header) - - has_range = bool(range_header) and start_byte is not None - from_bytes = int(start_byte or 0) - - should_decode = _should_decode_alac(request, doc) - if should_decode: - flac_path = None - flac_file_size = None - try: - flac_path = await _ensure_alac_decoded_file(track_id, doc) - if flac_path and os.path.isfile(flac_path): - flac_file_size = os.path.getsize(flac_path) - except Exception as e: - LOG.error( - f"[ALAC-Decode] Failed to ensure decoded FLAC file for download track_id={track_id}: {e}", - exc_info=True, - ) - flac_path = None - flac_file_size = None - - if flac_path and flac_file_size: - mime_type = "audio/flac" - file_size = flac_file_size - if filename.endswith(".m4a"): - filename = filename[:-4] + ".flac" - elif not filename.endswith(".flac"): - filename = filename + ".flac" - - until_bytes: int | None = None - if has_range: - if end_byte is not None: - until_bytes = int(end_byte) - elif file_size is not None: - until_bytes = int(file_size) - 1 - if file_size is not None and until_bytes is not None: - until_bytes = min(int(until_bytes), int(file_size) - 1) - - if file_size is not None and has_range and from_bytes >= file_size: - raise HTTPException(status_code=416, detail="range not satisfiable") - - status_code = ( - 206 - if (has_range and file_size is not None and until_bytes is not None) - else 200 - ) - - cd = _content_disposition(filename) - headers = { - "Accept-Ranges": "bytes", - "Content-Type": mime_type, - "Content-Disposition": cd, - } - if status_code == 206 and file_size is not None and until_bytes is not None: - headers["Content-Range"] = f"bytes {from_bytes}-{until_bytes}/{file_size}" - headers["Content-Length"] = str((until_bytes - from_bytes) + 1) - elif file_size is not None: - headers["Content-Length"] = str(file_size) - - if (request.method or "").upper() == "HEAD": - return Response( - content=b"", status_code=status_code, headers=headers, media_type=mime_type - ) - - iterator = _stream_cached_file( - file_path=flac_path, - from_bytes=from_bytes, - until_bytes=until_bytes, - ) - return StreamingResponse( - iterator, status_code=status_code, headers=headers, media_type=mime_type - ) - - until_bytes: int | None = None - if has_range: - if end_byte is not None: - until_bytes = int(end_byte) - elif file_size is not None: - until_bytes = int(file_size) - 1 - if file_size is not None and until_bytes is not None: - until_bytes = min(int(until_bytes), int(file_size) - 1) - - if file_size is not None and has_range and from_bytes >= file_size: - raise HTTPException(status_code=416, detail="range not satisfiable") - - status_code = ( - 206 - if (has_range and file_size is not None and until_bytes is not None) - else 200 - ) - - headers = { - "Accept-Ranges": "bytes", - "Content-Type": mime_type, - "Content-Disposition": _content_disposition_header( - filename=filename, track_id=track_id, mime_type=mime_type - ), - } - - if status_code == 206 and file_size is not None and until_bytes is not None: - headers["Content-Range"] = f"bytes {from_bytes}-{until_bytes}/{file_size}" - headers["Content-Length"] = str((until_bytes - from_bytes) + 1) - elif file_size is not None: - headers["Content-Length"] = str(file_size) - - file_ids_for_pick = ( - telegram.get("file_ids") if isinstance(telegram.get("file_ids"), dict) else {} - ) - preferred_user_ids: list[int] = [] - for k, v in (file_ids_for_pick or {}).items(): - if not isinstance(v, str) or not v.strip(): - continue - try: - preferred_user_ids.append(int(k)) - except Exception: - pass - - from stream import ( - acquire_stream_client, - acquire_stream_client_prefer, - release_stream_client, - ) - - if preferred_user_ids: - client_user_id, client = await acquire_stream_client_prefer(preferred_user_ids) - else: - client_user_id, client = await acquire_stream_client() - - try: - client_key = str(int(client_user_id)) - file_ids = ( - telegram.get("file_ids") - if isinstance(telegram.get("file_ids"), dict) - else {} - ) - file_id = (file_ids or {}).get(client_key) - if isinstance(file_id, str): - file_id = file_id.strip() - else: - file_id = "" - - primary_uid = get_primary_client_user_id() - if ( - not file_id - and primary_uid is not None - and int(primary_uid) == int(client_user_id) - and primary_file_id - ): - file_id = primary_file_id - await get_audio_tracks_collection().update_one( - {"_id": track_id}, - { - "$set": { - f"telegram.file_ids.{client_key}": file_id, - } - }, - ) - - if not file_id: - file_id = await _ensure_client_file_id( - track_id=track_id, - client_user_id=int(client_user_id), - client=client, - source_chat_id=playback_chat_id, - source_message_id=playback_message_id, - ) - except Exception: - await release_stream_client(int(client_user_id)) - raise - - if status_code == 206: - iterator = _stream_range( - track_id=track_id, - client_user_id=int(client_user_id), - client=client, - file_id=file_id, - source_chat_id=playback_chat_id, - source_message_id=playback_message_id, - from_bytes=from_bytes, - until_bytes=until_bytes, - ) - else: - iterator = _direct_stream( - track_id=track_id, - client_user_id=int(client_user_id), - client=client, - file_id=file_id, - source_chat_id=playback_chat_id, - source_message_id=playback_message_id, - start_byte=0, - ) - - return StreamingResponse( - iterator, status_code=status_code, headers=headers, media_type=mime_type - ) - - -async def warm_track_cached(track_id: str) -> dict: - if bool(getattr(Config, "ONLY_API", False)) or bot is None: - raise HTTPException(status_code=503, detail="streaming disabled") - track_id = (track_id or "").strip() - if not track_id: - raise HTTPException(status_code=400, detail="track_id is required") - - col = get_audio_tracks_collection() - doc = await col.find_one( - {"_id": track_id}, - projection={ - "telegram": 1, - "audio": 1, - "source_chat_id": 1, - "source_message_id": 1, - "cache_chat_id": 1, - "cache_message_id": 1, - }, - ) - if not doc: - raise HTTPException(status_code=404, detail="Track not found") - - if _is_alac_track(doc): - flac_file = os.path.join(ALAC_CACHE_DIR, f"{track_id}.flac") - is_ready = os.path.isfile(flac_file) and os.path.getsize(flac_file) > 10240 - if not is_ready: - asyncio.create_task(_ensure_alac_decoded_file(track_id, doc)) - return {"ok": True, "ready": is_ready} - - playback_chat_id, playback_message_id = _playback_source_ids(doc) - - # LIGHTWEIGHT WARMING: - # Instead of starting a Hub (which starts a producer task and consumes a client), - # we just ensure the file_id is resolved and cached for the primary client. - # This makes the eventual stream start much faster without 'Streaming started' noise. - from stream import acquire_stream_client, release_stream_client - - # We only warm for the primary client to avoid exhausting others. - client_id, client = await acquire_stream_client() - try: - await _ensure_client_file_id( - track_id=track_id, - client_user_id=int(client_id), - client=client, - source_chat_id=playback_chat_id, - source_message_id=playback_message_id, - ) - finally: - await release_stream_client(client_id) - - return {"ok": True} diff --git a/Api/services/track_service.py b/Api/services/track_service.py deleted file mode 100644 index d860f0d..0000000 --- a/Api/services/track_service.py +++ /dev/null @@ -1,1437 +0,0 @@ -import re -import random -import datetime -import hashlib -import time -import urllib.parse -from typing import Any, Optional -import inspect -import asyncio - -from pymongo import UpdateOne - -from Api.deps.db import get_audio_tracks_collection -from Api.schemas.browse import BrowseItem, BrowseResponse -from stream.database.MongoDb import db_handler - -def _as_str_id(value: Any) -> str: - if value is None: - return "" - return str(value) - -def _clean_url(value: Any) -> str: - if not isinstance(value, str): - return "" - s = value.strip() - if len(s) >= 2 and s[0] == "`" and s[-1] == "`": - s = s[1:-1].strip() - return s - - -def _normalize_mime_type(mime: Any) -> str | None: - if not mime: - return None - raw = str(mime).split(";")[0].strip().lower() - if raw in {"audio/flac", "audio/x-flac"} or raw.endswith("/x-flac"): - return "audio/flac" - if raw in {"audio/wav", "audio/x-wav", "audio/wave"}: - return "audio/wav" - if raw in {"audio/mp3", "audio/mpeg"}: - return "audio/mpeg" - if raw in {"audio/m4a", "audio/x-m4a", "audio/mp4"}: - return "audio/mp4" - if raw in {"audio/ogg", "application/ogg"}: - return "audio/ogg" - if raw in {"audio/aac"}: - return "audio/aac" - return str(mime).strip() - - -def _normalize_spotify(doc: dict) -> None: - spotify = doc.get("spotify") - if isinstance(spotify, dict): - spotify["url"] = _clean_url(spotify.get("url") or spotify.get("spotify_url")) - spotify["cover_url"] = _clean_url(spotify.get("cover_url")) - spotify.pop("spotify_url", None) - spotify.pop("links", None) - - telegram = doc.get("telegram") - if isinstance(telegram, dict) and "mime_type" in telegram and telegram["mime_type"]: - telegram["mime_type"] = _normalize_mime_type(telegram["mime_type"]) - - -def _browse_item_from_doc(doc: dict, liked_set: set[str] | None = None) -> BrowseItem: - audio = doc.get("audio") or {} - spotify = doc.get("spotify") or {} - if isinstance(spotify, dict): - spotify["url"] = _clean_url(spotify.get("url") or spotify.get("spotify_url")) - spotify["cover_url"] = _clean_url(spotify.get("cover_url")) - - t = audio.get("type") - if isinstance(t, str) and t: - t = t.upper() - - tid = _as_str_id(doc.get("_id")) - is_liked = bool(liked_set and tid in liked_set) - titles = doc.get("titles") or (audio.get("titles") if isinstance(audio, dict) else None) - - return BrowseItem( - _id=tid, - source_chat_id=doc.get("source_chat_id"), - source_message_id=doc.get("source_message_id"), - topic_id=doc.get("topic_id"), - topic_name=doc.get("topic_name"), - title=audio.get("title"), - artist=audio.get("artist"), - album=audio.get("album"), - album_id=audio.get("album_id"), - duration_sec=audio.get("duration_sec"), - type=t, - sampling_rate_hz=audio.get("sampling_rate_hz"), - spotify_url=_clean_url(spotify.get("url") or spotify.get("spotify_url")), - cover_url=_clean_url(spotify.get("cover_url")), - titles=titles, - created_at=doc.get("created_at") or doc.get("updated_at"), - updated_at=doc.get("updated_at"), - liked=is_liked, - ) - - -async def get_user_liked_track_ids(user_id: int | None, track_ids: list[str]) -> set[str]: - if not user_id or user_id <= 0 or not track_ids: - return set() - clean_ids = [str(tid).strip() for tid in track_ids if str(tid).strip()] - if not clean_ids: - return set() - col = db_handler.get_collection("user_favourites").collection - cursor = col.find( - {"user_id": int(user_id), "track_id": {"$in": clean_ids}}, - {"track_id": 1} - ) - liked_set: set[str] = set() - async for doc in cursor: - tid = doc.get("track_id") - if isinstance(tid, str) and tid: - liked_set.add(tid) - return liked_set - - -async def attach_liked_to_dicts(docs: list[dict], user_id: int | None) -> list[dict]: - if not docs: - return docs - tids = [str(d.get("_id") or d.get("id") or "").strip() for d in docs] - liked_set = await get_user_liked_track_ids(user_id, tids) if (user_id and user_id > 0) else set() - for d in docs: - tid = str(d.get("_id") or d.get("id") or "").strip() - d["liked"] = tid in liked_set - return docs - - -def _search_pattern(q: str) -> str: - s = (q or "").strip() - if not s: - return "" - tokens = [t for t in s.split() if t.strip()] - if not tokens: - return "" - return ".*".join(re.escape(t) for t in tokens[:8]) - - -async def browse_tracks( - channel_id: Optional[int] = None, - page: int = 1, - per_page: int = 20, - user_id: Optional[int] = None, - topic_name: Optional[str] = None, - topic_id: Optional[int] = None, -) -> BrowseResponse: - per_page = int(per_page) - if per_page <= 0: - per_page = 20 - page = int(page) - if page < 1: - page = 1 - skip = (page - 1) * per_page - - col = get_audio_tracks_collection() - query: dict[str, Any] = {"deleted": {"$ne": True}} - if channel_id is not None: - query["source_chat_id"] = int(channel_id) - if topic_name: - t_clean = topic_name.strip() - if t_clean: - query["topic_name"] = t_clean - if topic_id is not None: - query["topic_id"] = int(topic_id) - - sort = [("source_message_id", -1)] if channel_id is not None else [("created_at", -1), ("source_message_id", -1), ("_id", -1)] - projection = { - "_id": 1, - "source_chat_id": 1, - "source_message_id": 1, - "audio": 1, - "spotify": 1, - "created_at": 1, - "updated_at": 1, - "topic_id": 1, - "topic_name": 1, - } - - total, docs = await asyncio.gather( - col.count_documents(query), - col.find(query, projection).sort(sort).skip(skip).limit(per_page).to_list(length=per_page), - ) - - track_ids = [_as_str_id(d.get("_id")) for d in docs] - liked_set = await get_user_liked_track_ids(user_id, track_ids) - - items: list[BrowseItem] = [] - cover_url: str | None = None - for doc in docs: - item = _browse_item_from_doc(doc, liked_set) - items.append(item) - if not cover_url and item.cover_url: - cover_url = item.cover_url - - return BrowseResponse(page=page, per_page=per_page, total=total, items=items, cover_url=cover_url) - - -_TOPICS_CACHE: dict[str, tuple[float, dict]] = {} -_TOPICS_CACHE_TTL = 120.0 - - -async def get_unique_topics( - *, - channel_id: Optional[int] = None, - limit: int = 100, - refresh: bool = False, -) -> dict: - limit = max(1, min(int(limit), 500)) - cache_key = f"{channel_id}:{limit}" - - now = time.time() - if not refresh and cache_key in _TOPICS_CACHE: - ts, cached = _TOPICS_CACHE[cache_key] - if now - ts < _TOPICS_CACHE_TTL: - return cached - - col = get_audio_tracks_collection() - match_filter: dict[str, Any] = { - "deleted": {"$ne": True}, - "topic_name": {"$exists": True, "$type": "string", "$nin": ["", "null", "None"]}, - } - if channel_id is not None: - match_filter["source_chat_id"] = int(channel_id) - - pipeline = [ - {"$match": match_filter}, - { - "$sort": { - "updated_at": -1, - "source_message_id": -1, - } - }, - { - "$group": { - "_id": "$topic_name", - "topic_id": {"$first": "$topic_id"}, - "source_chat_id": {"$first": "$source_chat_id"}, - "cover_url": {"$first": "$spotify.cover_url"}, - "big_cover_url": {"$first": "$spotify.big_cover_url"}, - "raw_thumbnails": {"$push": "$spotify.cover_url"}, - "count": {"$sum": 1}, - "latest_updated_at": {"$first": "$updated_at"}, - } - }, - { - "$project": { - "topic_id": 1, - "source_chat_id": 1, - "cover_url": 1, - "big_cover_url": 1, - "thumbnails": {"$slice": ["$raw_thumbnails", 8]}, - "count": 1, - "latest_updated_at": 1, - } - }, - {"$sort": {"count": -1, "_id": 1}}, - {"$limit": limit}, - ] - - cursor = await col.aggregate(pipeline, allowDiskUse=True) - docs = await cursor.to_list(length=limit) - - items: list[dict] = [] - topic_names: list[str] = [] - - for d in docs: - name = str(d.get("_id") or "").strip() - if not name: - continue - topic_names.append(name) - - cover = _clean_url(d.get("big_cover_url") or d.get("cover_url")) - raw_thumbs = d.get("thumbnails") or [] - thumbs = [ - _clean_url(t) - for t in raw_thumbs - if isinstance(t, str) and _clean_url(t) - ] - unique_thumbs = list(dict.fromkeys(thumbs))[:4] - if not cover and unique_thumbs: - cover = unique_thumbs[0] - - items.append({ - "name": name, - "topic_name": name, - "topic_id": d.get("topic_id"), - "count": int(d.get("count") or 0), - "cover_url": cover or None, - "thumbnail_url": cover or None, - "normal_thumbnail": cover or None, - "thumbnails": unique_thumbs, - "source_chat_id": d.get("source_chat_id"), - "endpoint": f"/topics/{urllib.parse.quote(name, safe='')}/tracks", - }) - - result = { - "ok": True, - "total": len(items), - "items": items, - "topics": topic_names, - } - _TOPICS_CACHE[cache_key] = (now, result) - return result - - -async def browse_topic_tracks( - topic_name: str, - *, - channel_id: Optional[int] = None, - page: int = 1, - per_page: int = 20, - user_id: Optional[int] = None, -) -> BrowseResponse: - raw_topic = (topic_name or "").strip() - if not raw_topic: - return BrowseResponse(page=page, per_page=per_page, total=0, items=[], cover_url=None) - - per_page = int(per_page) - if per_page <= 0: - per_page = 20 - if per_page > 100: - per_page = 100 - page = int(page) - if page < 1: - page = 1 - skip = (page - 1) * per_page - - col = get_audio_tracks_collection() - - # Fast path: B-tree indexed exact match query - query: dict[str, Any] = { - "deleted": {"$ne": True}, - "topic_name": raw_topic, - } - if channel_id is not None: - query["source_chat_id"] = int(channel_id) - - projection = { - "_id": 1, - "source_chat_id": 1, - "source_message_id": 1, - "audio": 1, - "spotify": 1, - "created_at": 1, - "updated_at": 1, - "topic_id": 1, - "topic_name": 1, - } - sort = [("created_at", -1), ("source_message_id", -1)] - - total, docs = await asyncio.gather( - col.count_documents(query), - col.find(query, projection).sort(sort).skip(skip).limit(per_page).to_list(length=per_page), - ) - - # Fallback to case-insensitive match if exact match returned 0 - if total == 0: - regex_query: dict[str, Any] = { - "deleted": {"$ne": True}, - "topic_name": {"$regex": f"^{re.escape(raw_topic)}$", "$options": "i"}, - } - if channel_id is not None: - regex_query["source_chat_id"] = int(channel_id) - - total, docs = await asyncio.gather( - col.count_documents(regex_query), - col.find(regex_query, projection).sort(sort).skip(skip).limit(per_page).to_list(length=per_page), - ) - - track_ids = [_as_str_id(d.get("_id")) for d in docs] - liked_set = await get_user_liked_track_ids(user_id, track_ids) - - items: list[BrowseItem] = [] - cover_url: str | None = None - for doc in docs: - item = _browse_item_from_doc(doc, liked_set) - items.append(item) - if not cover_url and item.cover_url: - cover_url = item.cover_url - - return BrowseResponse(page=page, per_page=per_page, total=total, items=items, cover_url=cover_url) - - -async def search_tracks(q: str, *, channel_id: Optional[int], page: int, per_page: int, user_id: Optional[int] = None) -> BrowseResponse: - per_page = int(per_page) - if per_page <= 0: - per_page = 20 - if per_page > 50: - per_page = 50 - page = int(page) - if page < 1: - page = 1 - skip = (page - 1) * per_page - - pattern = _search_pattern(q) - if not pattern: - return BrowseResponse(page=page, per_page=per_page, total=0, items=[]) - - col = get_audio_tracks_collection() - query: dict[str, Any] = { - "deleted": {"$ne": True}, - "$or": [ - {"audio.title": {"$regex": pattern, "$options": "i"}}, - {"audio.artist": {"$regex": pattern, "$options": "i"}}, - {"audio.performer": {"$regex": pattern, "$options": "i"}}, - {"audio.album": {"$regex": pattern, "$options": "i"}}, - {"titles.romanized": {"$regex": pattern, "$options": "i"}}, - {"titles.original": {"$regex": pattern, "$options": "i"}}, - {"titles.translations.en": {"$regex": pattern, "$options": "i"}}, - ], - } - if channel_id is not None: - query["source_chat_id"] = int(channel_id) - - projection = { - "_id": 1, - "source_chat_id": 1, - "source_message_id": 1, - "audio": 1, - "spotify": 1, - "titles": 1, - "created_at": 1, - "updated_at": 1, - } - - total = await col.count_documents(query) - cursor = col.find(query, projection).sort([("created_at", -1), ("source_message_id", -1)]).skip(skip).limit(per_page) - - docs = await cursor.to_list(length=per_page) - track_ids = [_as_str_id(d.get("_id")) for d in docs] - liked_set = await get_user_liked_track_ids(user_id, track_ids) - - items: list[BrowseItem] = [] - for doc in docs: - items.append(_browse_item_from_doc(doc, liked_set)) - - return BrowseResponse(page=page, per_page=per_page, total=total, items=items) - -def _smart_interleave_artists(raw_docs: list[dict], rng: random.Random) -> list[dict]: - if len(raw_docs) <= 2: - return raw_docs - - groups: dict[str, list[dict]] = {} - for doc in raw_docs: - audio = doc.get("audio") if isinstance(doc.get("audio"), dict) else {} - spotify = doc.get("spotify") if isinstance(doc.get("spotify"), dict) else {} - artist = str(audio.get("artist") or spotify.get("artist") or "unknown").strip().lower() - if artist not in groups: - groups[artist] = [] - groups[artist].append(doc) - - for artist_list in groups.values(): - rng.shuffle(artist_list) - - sorted_artists = sorted(groups.keys(), key=lambda k: len(groups[k]), reverse=True) - - out: list[dict] = [] - while sorted_artists: - for artist in list(sorted_artists): - if groups[artist]: - out.append(groups[artist].pop(0)) - if not groups[artist]: - sorted_artists.remove(artist) - - return out - - -async def random_tracks( - *, - limit: int = 100, - seed: int | None = None, - channel_id: Optional[int] = None, - user_id: Optional[int] = None, - liked: Optional[bool] = None, - genre: Optional[str] = None, - artist: Optional[str] = None, - source: Optional[str] = None, - lossless: Optional[bool] = None, -) -> BrowseResponse: - limit = int(limit) - if limit <= 0: - limit = 50 - if limit > 200: - limit = 200 - - query: dict[str, Any] = {"deleted": {"$ne": True}} - if channel_id is not None: - query["source_chat_id"] = int(channel_id) - - if liked and user_id is not None: - fav_col = db_handler.get_collection("user_favourites").collection - fav_cursor = fav_col.find({"user_id": int(user_id)}, {"_id": 0, "track_id": 1}) - fav_ids = [doc.get("track_id") async for doc in fav_cursor if doc.get("track_id")] - if fav_ids: - query["_id"] = {"$in": fav_ids} - else: - return BrowseResponse(page=1, per_page=limit, total=0, items=[]) - - if genre: - query["$or"] = [ - {"audio.genre": {"$regex": re.escape(genre), "$options": "i"}}, - {"spotify.genre": {"$regex": re.escape(genre), "$options": "i"}}, - ] - - if artist: - query["$or"] = [ - {"audio.artist": {"$regex": re.escape(artist), "$options": "i"}}, - {"spotify.artist": {"$regex": re.escape(artist), "$options": "i"}}, - ] - - if source: - if source.lower() == "telegram": - query["source_chat_id"] = {"$exists": True, "$ne": None} - elif source.lower() == "local": - query["is_local"] = True - - if lossless: - query["audio.type"] = {"$in": ["FLAC", "ALAC", "WAV"]} - - col = get_audio_tracks_collection() - total = await col.count_documents(query) - if total <= 0: - return BrowseResponse(page=1, per_page=limit, total=0, items=[]) - - rng: random.Random - if seed is None: - rng = random.SystemRandom() - else: - rng = random.Random(int(seed)) - - projection = { - "_id": 1, - "source_chat_id": 1, - "source_message_id": 1, - "audio": 1, - "spotify": 1, - "created_at": 1, - "updated_at": 1, - } - - sample_size = min(limit, total) - pipeline = [ - {"$match": query}, - {"$sample": {"size": sample_size}}, - {"$project": projection}, - ] - - cursor = col.aggregate(pipeline) - while inspect.iscoroutine(cursor) or inspect.isawaitable(cursor): - cursor = await cursor - if hasattr(cursor, "to_list"): - to_list_fn = getattr(cursor, "to_list") - res = to_list_fn(length=sample_size) - if inspect.isawaitable(res) or inspect.iscoroutine(res): - raw_docs = await res - else: - raw_docs = list(res) - else: - raw_docs = [doc async for doc in cursor] - - raw_docs = _smart_interleave_artists(raw_docs, rng) - - track_ids = [_as_str_id(d.get("_id")) for d in raw_docs] - liked_set = await get_user_liked_track_ids(user_id, track_ids) - items = [_browse_item_from_doc(d, liked_set) for d in raw_docs] - - return BrowseResponse(page=1, per_page=limit, total=total, items=items) - -async def get_browse_items_by_ids(track_ids: list[str], user_id: Optional[int] = None) -> list[BrowseItem]: - ids = [str(x) for x in (track_ids or []) if str(x)] - if not ids: - return [] - - col = get_audio_tracks_collection() - projection = { - "_id": 1, - "source_chat_id": 1, - "source_message_id": 1, - "audio": 1, - "spotify": 1, - "created_at": 1, - "updated_at": 1, - } - cursor = col.find({"_id": {"$in": ids}, "deleted": {"$ne": True}}, projection) - docs: list[dict] = [] - async for doc in cursor: - docs.append(doc) - - liked_set = await get_user_liked_track_ids(user_id, ids) - by_id = {_as_str_id(d.get("_id")): d for d in docs if d.get("_id")} - items: list[BrowseItem] = [] - for tid in ids: - d = by_id.get(tid) - if not d: - continue - items.append(_browse_item_from_doc(d, liked_set)) - return items - -def _track_thumbnail_url(track: dict) -> str: - spotify = track.get("spotify") if isinstance(track.get("spotify"), dict) else {} - telegram = track.get("telegram") if isinstance(track.get("telegram"), dict) else {} - audio = track.get("audio") if isinstance(track.get("audio"), dict) else {} - - candidates = [ - spotify.get("cover_url"), - spotify.get("cover"), - spotify.get("thumbnail"), - telegram.get("thumb_url"), - telegram.get("thumbnail_url"), - telegram.get("thumb"), - telegram.get("thumbnail"), - audio.get("cover_url"), - audio.get("thumbnail"), - ] - for c in candidates: - if isinstance(c, str) and c.strip(): - return c.strip() - return "" - - -async def get_daily_playlist_thumbnail_info( - *, - key: str, - date: str, - channel_id: int | None, - limit: int = 4, -) -> dict[str, object]: - k = _canon_daily_playlist_key(key) - d = (date or "").strip() - - cover_url = None - thumbnails: list[str] = [] - try: - track_ids = await generate_daily_playlist(key=k, date=d, channel_id=channel_id, limit=limit) - if track_ids: - tracks = await get_tracks_by_ids(track_ids[:limit]) - for t in tracks: - if isinstance(t, dict): - url = _track_thumbnail_url(t) - if url and url not in thumbnails: - thumbnails.append(url) - if thumbnails: - cover_url = thumbnails[0] - except Exception: - pass - - return { - "key": k, - "date": d, - "channel_id": int(channel_id) if channel_id is not None else None, - "cover_url": cover_url, - "normal_thumbnail": cover_url, - "thumbnails": thumbnails, - } - - -async def get_user_top_played_thumbnail_info(*, user_id: int, limit: int = 4) -> dict[str, object]: - uid = int(user_id) - cache_col = db_handler.get_collection("user_top_played_cache").collection - cached = await cache_col.find_one({"_id": str(uid)}, {"_id": 0, "track_ids": 1, "cover_url": 1, "normal_thumbnail": 1}) - - cover_url = cached.get("cover_url") if isinstance(cached, dict) else None - normal_url = cached.get("normal_thumbnail") if isinstance(cached, dict) else None - - return { - "user_id": uid, - "cover_url": cover_url, - "normal_thumbnail": normal_url, - } - -def _daily_playlist_seed(*, key: str, date: str, channel_id: int | None) -> int: - scope = str(int(channel_id)) if channel_id is not None else "" - seed_src = f"{key}|{date}|{scope}".encode("utf-8", errors="ignore") - return int(hashlib.sha1(seed_src).hexdigest()[:8], 16) - -def _canon_daily_playlist_key(key: str) -> str: - k = (key or "").strip().lower() - if k in {"random", "mix", "daily", "daily-playlist"}: - return "random" - if k in {"top", "top-played", "top-playlist"}: - return "top-played" - if k in {"trending", "trending-today"}: - return "trending" - if k in {"late-night", "late-night-mix", "night"}: - return "late-night" - if k in {"rising", "rising-tracks"}: - return "rising" - if k in {"surprise", "surprise-me"}: - return "surprise" - return k - - -async def generate_daily_playlist(*, key: str, date: str, channel_id: int | None, limit: int) -> list[str]: - key = _canon_daily_playlist_key(key) - if not key: - return [] - - limit = int(limit) - if limit <= 0: - limit = 75 - if limit > 75: - limit = 75 - - scope = str(int(channel_id)) if channel_id is not None else "" - doc_id = f"{key}:{date}:{scope}" - - col = db_handler.get_collection("daily_playlists").collection - doc = await col.find_one({"_id": doc_id}, {"_id": 0, "track_ids": 1}) - if isinstance(doc, dict) and isinstance(doc.get("track_ids"), list) and doc["track_ids"]: - return [str(x) for x in doc["track_ids"] if str(x)][:limit] - - seed = _daily_playlist_seed(key=key, date=date, channel_id=channel_id) - - if key in {"random", "mix"}: - res = await random_tracks(limit=limit, seed=seed, channel_id=channel_id) - track_ids = [str(it.id) for it in (res.items or []) if getattr(it, "id", None)] - await col.update_one( - {"_id": doc_id}, - { - "$setOnInsert": { - "key": key, - "date": date, - "channel_id": int(channel_id) if channel_id is not None else None, - "track_ids": track_ids, - "generated_at": float(time.time()), - } - }, - upsert=True, - ) - return track_ids - - if key in {"top", "top-played"}: - gcol = db_handler.globalplayback_collection.collection - cursor = gcol.find({}, {"_id": 1}).sort([("plays", -1)]).limit(500) - candidate_ids: list[str] = [] - async for row in cursor: - tid = str(row.get("_id") or "").strip() - if tid: - candidate_ids.append(tid) - if not candidate_ids: - return [] - - rng = random.Random(int(seed)) - rng.shuffle(candidate_ids) - picked = candidate_ids[:limit] - await col.update_one( - {"_id": doc_id}, - { - "$setOnInsert": { - "key": key, - "date": date, - "channel_id": int(channel_id) if channel_id is not None else None, - "track_ids": picked, - "generated_at": float(time.time()), - } - }, - upsert=True, - ) - return picked - - if key in {"trending"}: - now = float(time.time()) - since = now - 24 * 3600 - ucol = db_handler.userplayback_collection.collection - cur = await ucol.aggregate( - [ - {"$match": {"played_at": {"$gte": since}}}, - {"$group": {"_id": "$track_id", "plays": {"$sum": 1}}}, - {"$sort": {"plays": -1}}, - {"$limit": 500}, - ] - ) - rows = await cur.to_list(length=500) - candidate_ids = [str(r.get("_id") or "").strip() for r in (rows or []) if str(r.get("_id") or "").strip()] - if not candidate_ids: - return [] - rng = random.Random(int(seed)) - rng.shuffle(candidate_ids) - picked = candidate_ids[:limit] - await col.update_one( - {"_id": doc_id}, - { - "$setOnInsert": { - "key": key, - "date": date, - "channel_id": int(channel_id) if channel_id is not None else None, - "track_ids": picked, - "generated_at": float(time.time()), - } - }, - upsert=True, - ) - return picked - - if key in {"rediscover"}: - now = float(time.time()) - cutoff = now - 30 * 24 * 3600 - gcol = db_handler.globalplayback_collection.collection - cursor = gcol.find({"last_played_at": {"$lt": cutoff}}, {"_id": 1}).sort([("plays", -1)]).limit(500) - candidate_ids: list[str] = [] - async for row in cursor: - tid = str(row.get("_id") or "").strip() - if tid: - candidate_ids.append(tid) - if not candidate_ids: - return [] - rng = random.Random(int(seed)) - rng.shuffle(candidate_ids) - picked = candidate_ids[:limit] - await col.update_one( - {"_id": doc_id}, - { - "$setOnInsert": { - "key": key, - "date": date, - "channel_id": int(channel_id) if channel_id is not None else None, - "track_ids": picked, - "generated_at": float(time.time()), - } - }, - upsert=True, - ) - return picked - - if key in {"late-night"}: - now = float(time.time()) - since = now - 30 * 24 * 3600 - ucol = db_handler.userplayback_collection.collection - cur = await ucol.aggregate( - [ - {"$match": {"played_at": {"$gte": since}}}, - {"$addFields": {"_dt": {"$toDate": {"$multiply": ["$played_at", 1000]}}}}, - {"$addFields": {"_hour": {"$hour": "$_dt"}}}, - {"$match": {"$or": [{"_hour": {"$gte": 22}}, {"_hour": {"$lte": 3}}]}}, - {"$group": {"_id": "$track_id", "plays": {"$sum": 1}}}, - {"$sort": {"plays": -1}}, - {"$limit": 500}, - ] - ) - rows = await cur.to_list(length=500) - candidate_ids = [str(r.get("_id") or "").strip() for r in (rows or []) if str(r.get("_id") or "").strip()] - if not candidate_ids: - return [] - rng = random.Random(int(seed)) - rng.shuffle(candidate_ids) - picked = candidate_ids[:limit] - await col.update_one( - {"_id": doc_id}, - { - "$setOnInsert": { - "key": key, - "date": date, - "channel_id": int(channel_id) if channel_id is not None else None, - "track_ids": picked, - "generated_at": float(time.time()), - } - }, - upsert=True, - ) - return picked - - if key in {"rising"}: - now = float(time.time()) - since = now - 3 * 24 * 3600 - ucol = db_handler.userplayback_collection.collection - cur = await ucol.aggregate( - [ - {"$match": {"played_at": {"$gte": since}}}, - {"$group": {"_id": "$track_id", "plays3": {"$sum": 1}}}, - {"$sort": {"plays3": -1}}, - {"$limit": 2000}, - ] - ) - rows = await cur.to_list(length=2000) - if not rows: - return [] - plays3_by_id: dict[str, int] = {} - ids: list[str] = [] - for r in rows: - tid = str(r.get("_id") or "").strip() - if not tid: - continue - p3 = int(r.get("plays3") or 0) - if p3 <= 0: - continue - plays3_by_id[tid] = p3 - ids.append(tid) - if not ids: - return [] - gcol = db_handler.globalplayback_collection.collection - cursor = gcol.find({"_id": {"$in": ids}}, {"_id": 1, "plays": 1}) - plays_all: dict[str, int] = {} - async for row in cursor: - tid = str(row.get("_id") or "").strip() - if not tid: - continue - plays_all[tid] = int(row.get("plays") or 0) - scored: list[tuple[float, int, str]] = [] - for tid in ids: - p3 = int(plays3_by_id.get(tid) or 0) - pall = int(plays_all.get(tid) or 0) - denom = float(pall if pall > 0 else 1) - score = float(p3) / denom - scored.append((score, p3, tid)) - scored.sort(key=lambda x: (x[0], x[1]), reverse=True) - candidate_ids = [tid for _, _, tid in scored[:500]] - if not candidate_ids: - return [] - rng = random.Random(int(seed)) - rng.shuffle(candidate_ids) - picked = candidate_ids[:limit] - await col.update_one( - {"_id": doc_id}, - { - "$setOnInsert": { - "key": key, - "date": date, - "channel_id": int(channel_id) if channel_id is not None else None, - "track_ids": picked, - "generated_at": float(time.time()), - } - }, - upsert=True, - ) - return picked - - if key in {"surprise"}: - gcol = db_handler.globalplayback_collection.collection - cursor = gcol.find({}, {"_id": 1, "plays": 1}).sort([("plays", -1)]).limit(2000) - candidates: list[tuple[str, int]] = [] - async for row in cursor: - tid = str(row.get("_id") or "").strip() - if not tid: - continue - candidates.append((tid, int(row.get("plays") or 0))) - if not candidates: - return [] - rng = random.Random(int(seed)) - pool = candidates[:] - picked: list[str] = [] - while pool and len(picked) < limit: - weights = [float((p + 1)) for _, p in pool] - total = float(sum(weights)) - if total <= 0: - idx = rng.randrange(0, len(pool)) - else: - r = rng.random() * total - acc = 0.0 - idx = 0 - for i, w in enumerate(weights): - acc += w - if acc >= r: - idx = i - break - tid, _ = pool.pop(idx) - picked.append(tid) - await col.update_one( - {"_id": doc_id}, - { - "$setOnInsert": { - "key": key, - "date": date, - "channel_id": int(channel_id) if channel_id is not None else None, - "track_ids": picked, - "generated_at": float(time.time()), - } - }, - upsert=True, - ) - return picked - - return [] - - -async def get_daily_playlist(*, key: str, date: str | None = None, channel_id: int | None, limit: int, user_id: Optional[int] = None) -> BrowseResponse: - key = _canon_daily_playlist_key(key) - if not date: - date = datetime.datetime.utcnow().date().isoformat() - - track_ids = await generate_daily_playlist(key=key, date=str(date), channel_id=channel_id, limit=int(limit)) - if not track_ids: - return BrowseResponse(page=1, per_page=int(limit), total=0, items=[]) - items = await get_browse_items_by_ids(track_ids[: int(limit)], user_id=user_id) - return BrowseResponse(page=1, per_page=int(limit), total=len(items), items=items, cover_url=None) - -async def refresh_daily_playlist_cache( - *, - key: str, - date: str, - channel_id: int | None, - limit: int = 75, - refresh_cover: bool = True, -) -> dict[str, object]: - k = _canon_daily_playlist_key(key) - if k not in {"random", "top-played", "trending", "rediscover", "late-night", "rising", "surprise"}: - raise ValueError("unknown daily playlist key") - - d = (date or "").strip() - if not d: - raise ValueError("date is required") - - scope = str(int(channel_id)) if channel_id is not None else "" - doc_id = f"{k}:{d}:{scope}" - - col = db_handler.get_collection("daily_playlists").collection - await col.delete_one({"_id": doc_id}) - - track_ids = await generate_daily_playlist(key=k, date=d, channel_id=channel_id, limit=int(limit)) - - cover_items = await get_browse_items_by_ids(track_ids[:4]) - collage_urls = [(it.cover_url or "").strip() for it in (cover_items or []) if (it.cover_url or "").strip()] - - cover_url = None - normal_thumbnail = None - - res = { - "key": k, - "date": d, - "channel_id": int(channel_id) if channel_id is not None else None, - "track_count": len(track_ids), - "cover_url": cover_url, - "normal_thumbnail": normal_thumbnail, - "generated_at": time.time(), - } - await col.insert_one({"_id": doc_id, "track_ids": track_ids, **res}) - return res - - -async def refresh_daily_playlists_bulk( - *, - date: str, - keys: list[str] | None = None, - channel_ids: list[int | None] | None = None, - limit: int = 75, -) -> dict[str, object]: - if not keys: - keys = ["random", "top-played", "trending", "rediscover", "late-night", "rising", "surprise"] - canon_keys = [] - for k in keys: - ck = _canon_daily_playlist_key(k) - if ck in {"random", "top-played", "trending", "rediscover", "late-night", "rising", "surprise"} and ck not in canon_keys: - canon_keys.append(ck) - if not canon_keys: - raise ValueError("no valid keys") - - if channel_ids is None: - channel_ids = [None] - - results: list[dict[str, object]] = [] - ok = 0 - failed = 0 - for cid in channel_ids: - for k in canon_keys: - try: - res = await refresh_daily_playlist_cache(key=k, date=date, channel_id=cid, limit=int(limit), refresh_cover=True) - results.append(res) - ok += 1 - except Exception as e: - results.append( - { - "key": k, - "date": date, - "channel_id": int(cid) if cid is not None else None, - "error": str(e) or "failed", - } - ) - failed += 1 - return {"ok": ok, "failed": failed, "results": results} - - -async def refresh_user_top_played_cache(*, user_id: int, limit: int = 500, refresh_cover: bool = True, force_cover: bool = True) -> dict[str, object]: - uid = int(user_id) - if uid <= 0: - raise ValueError("user_id must be positive") - limit = int(limit) - if limit <= 0: - limit = 100 - if limit > 1000: - limit = 1000 - - col = db_handler.userplayback_collection.collection - match = {"user_id": int(uid)} - rows_cur = await col.aggregate( - [ - {"$match": match}, - {"$group": {"_id": "$track_id", "plays": {"$sum": 1}, "last_played_at": {"$max": "$played_at"}}}, - {"$sort": {"plays": -1, "last_played_at": -1}}, - {"$limit": int(limit)}, - ] - ) - rows = await rows_cur.to_list(length=limit) - - track_ids: list[str] = [] - for r in rows or []: - tid = (r.get("_id") or "").strip() if isinstance(r.get("_id"), str) else str(r.get("_id") or "").strip() - if tid: - track_ids.append(tid) - - cover_items = await get_browse_items_by_ids(track_ids[:4]) - collage_urls = [(it.cover_url or "").strip() for it in (cover_items or []) if (it.cover_url or "").strip()] - - cache_col = db_handler.get_collection("user_top_played_cache").collection - now = float(time.time()) - cover_id: str | None = None - cover_url: str | None = None - normal_thumbnail: str | None = None - await cache_col.update_one( - {"_id": str(uid)}, - {"$set": {"user_id": int(uid), "track_ids": track_ids, "generated_at": now, "cover_id": cover_id, "cover_url": cover_url, "normal_thumbnail": normal_thumbnail}}, - upsert=True, - ) - - return { - "user_id": int(uid), - "track_count": len(track_ids), - "generated_at": now, - "cover_id": cover_id, - "cover_url": cover_url, - "normal_thumbnail": normal_thumbnail, - } - - -async def refresh_user_top_played_cache_bulk( - *, - user_ids: list[int] | None = None, - limit_users: int | None = 200, - limit_tracks: int = 500, - refresh_cover: bool = True, - force_cover: bool = True, -) -> dict[str, object]: - ids: list[int] = [] - if user_ids: - for v in user_ids: - try: - n = int(v) - except Exception: - continue - if n > 0 and n not in ids: - ids.append(n) - else: - col = db_handler.userplayback_collection.collection - if limit_users is None: - cur = await col.aggregate([{"$group": {"_id": "$user_id"}}]) - async for r in cur: - try: - n = int(r.get("_id")) - except Exception: - continue - if n > 0 and n not in ids: - ids.append(n) - else: - limit_users = int(limit_users) - if limit_users <= 0: - limit_users = 50 - if limit_users > 2000: - limit_users = 2000 - cur = await col.aggregate([{"$group": {"_id": "$user_id"}}, {"$limit": int(limit_users)}]) - rows = await cur.to_list(length=limit_users) - for r in rows or []: - try: - n = int(r.get("_id")) - except Exception: - continue - if n > 0 and n not in ids: - ids.append(n) - - ok = 0 - failed = 0 - results: list[dict[str, object]] = [] - for uid in ids: - try: - res = await refresh_user_top_played_cache( - user_id=int(uid), - limit=int(limit_tracks), - refresh_cover=bool(refresh_cover), - force_cover=bool(force_cover), - ) - results.append(res) - ok += 1 - except Exception as e: - results.append({"user_id": int(uid), "error": str(e) or "failed"}) - failed += 1 - - return {"ok": ok, "failed": failed, "results": results} - - -async def rebuild_global_playback_from_userplayback(*, batch_size: int = 1000) -> dict[str, object]: - batch_size = int(batch_size) - if batch_size <= 0: - batch_size = 1000 - if batch_size > 5000: - batch_size = 5000 - - user_col = db_handler.userplayback_collection.collection - global_col = db_handler.globalplayback_collection.collection - - await global_col.delete_many({}) - - pipeline = [ - {"$group": {"_id": "$track_id", "plays": {"$sum": 1}, "last_played_at": {"$max": "$played_at"}}}, - {"$sort": {"plays": -1}}, - ] - cur = await user_col.aggregate(pipeline) - - now = float(time.time()) - pending: list[tuple[str, int, float]] = [] - processed = 0 - - async def _flush() -> None: - nonlocal pending - if not pending: - return - ops = [ - UpdateOne( - {"_id": tid}, - {"$set": {"plays": plays, "last_played_at": last_played_at, "updated_at": now}}, - upsert=True, - ) - for (tid, plays, last_played_at) in pending - ] - try: - await global_col.bulk_write(ops, ordered=False) - except Exception: - for tid, plays, last_played_at in pending: - try: - await global_col.update_one( - {"_id": tid}, - {"$set": {"plays": plays, "last_played_at": last_played_at, "updated_at": now}}, - upsert=True, - ) - except Exception: - pass - pending = [] - - async for row in cur: - tid = str(row.get("_id") or "").strip() - if not tid: - continue - plays = int(row.get("plays") or 0) - last_played_at = float(row.get("last_played_at") or 0.0) - pending.append((tid, plays, last_played_at)) - processed += 1 - if len(pending) >= batch_size: - await _flush() - - if processed == 0: - audio_col = get_audio_tracks_collection() - cursor = audio_col.find({"deleted": {"$ne": True}}, {"_id": 1}).limit(500) - rank = 500 - async for doc in cursor: - tid = str(doc.get("_id") or "").strip() - if not tid: - continue - pending.append((tid, rank, now)) - processed += 1 - rank -= 1 - if rank <= 0: - rank = 1 - if len(pending) >= batch_size: - await _flush() - - await _flush() - - try: - await global_col.create_index([("plays", -1)]) - await global_col.create_index([("last_played_at", -1)]) - except Exception: - pass - - return {"ok": True, "tracks": processed, "updated_at": now} - -async def user_top_played_tracks(*, user_id: int, page: int, per_page: int) -> BrowseResponse: - page = int(page) - if page < 1: - page = 1 - per_page = int(per_page) - if per_page <= 0: - per_page = 20 - if per_page > 100: - per_page = 100 - skip = (page - 1) * per_page - - cache_col = db_handler.get_collection("user_top_played_cache").collection - cached = await cache_col.find_one({"_id": str(int(user_id))}, {"_id": 0, "track_ids": 1, "cover_url": 1, "cover_id": 1}) - cover_url: str | None = None - if isinstance(cached, dict) and isinstance(cached.get("cover_url"), str) and cached["cover_url"].strip(): - cover_url = cached["cover_url"].strip() - - if isinstance(cached, dict) and isinstance(cached.get("track_ids"), list) and cached["track_ids"]: - all_ids = [str(x) for x in cached["track_ids"] if str(x)] - total = len(all_ids) - start = int(skip) - end = int(skip + per_page) - items = await get_browse_items_by_ids(all_ids[start:end], user_id=user_id) - return BrowseResponse(page=page, per_page=per_page, total=total, items=items, cover_url=cover_url) - - col = db_handler.userplayback_collection.collection - match = {"user_id": int(user_id)} - - total_cur = await col.aggregate( - [ - {"$match": match}, - {"$group": {"_id": "$track_id"}}, - {"$count": "total"}, - ] - ) - total_rows = await total_cur.to_list(length=1) - total = int(total_rows[0]["total"]) if total_rows else 0 - if total <= 0: - return BrowseResponse(page=page, per_page=per_page, total=0, items=[], cover_url=cover_url) - - rows_cur = await col.aggregate( - [ - {"$match": match}, - {"$group": {"_id": "$track_id", "plays": {"$sum": 1}, "last_played_at": {"$max": "$played_at"}}}, - {"$sort": {"plays": -1, "last_played_at": -1}}, - {"$skip": int(skip)}, - {"$limit": int(per_page)}, - ] - ) - rows = await rows_cur.to_list(length=per_page) - - track_ids: list[str] = [] - for r in rows or []: - tid = (r.get("_id") or "").strip() if isinstance(r.get("_id"), str) else str(r.get("_id") or "").strip() - if tid: - track_ids.append(tid) - - items = await get_browse_items_by_ids(track_ids, user_id=user_id) - return BrowseResponse(page=page, per_page=per_page, total=total, items=items, cover_url=cover_url) - - -async def get_track_by_id(track_id: str, user_id: Optional[int] = None) -> dict | None: - col = get_audio_tracks_collection() - doc = await col.find_one({"_id": track_id, "deleted": {"$ne": True}}) - if not doc: - return None - if "_id" in doc: - doc["_id"] = _as_str_id(doc["_id"]) - spotify = doc.get("spotify") - if not isinstance(spotify, dict): - spotify = {} - audio = doc.get("audio") if isinstance(doc.get("audio"), dict) else {} - title = (audio.get("title") or "").strip() - artist = (audio.get("artist") or "").strip() or (audio.get("performer") or "").strip() - album = (audio.get("album") or "").strip() - year = audio.get("year") - - url = spotify.get("url") or spotify.get("spotify_url") - url = _clean_url(url) - track_spotify_id = spotify.get("track_spotify_id") - track_spotify_id = track_spotify_id.strip() if isinstance(track_spotify_id, str) else "" - cover_url = _clean_url(spotify.get("cover_url")) - if cover_url: - spotify["cover_url"] = cover_url - if url: - spotify["url"] = url - - if "links" in spotify or "spotify_url" in spotify: - try: - await col.update_one({"_id": track_id}, {"$unset": {"spotify.links": "", "spotify.spotify_url": ""}}) - except Exception: - pass - spotify.pop("links", None) - spotify.pop("spotify_url", None) - - if (not url or not track_spotify_id) and title and artist: - try: - from stream.helpers.cover_search import spotify_best_track - - sp = await spotify_best_track(title=title, artist=artist, album=album, year=year) - except Exception: - sp = None - - if isinstance(sp, dict): - updates: dict[str, Any] = {} - sp_id = sp.get("id") - if isinstance(sp_id, str) and sp_id.strip(): - sp_id = sp_id.strip() - spotify["track_spotify_id"] = sp_id - updates["spotify.track_spotify_id"] = sp_id - ext = sp.get("external_urls") if isinstance(sp.get("external_urls"), dict) else {} - sp_url = ext.get("spotify") - if isinstance(sp_url, str) and sp_url.strip(): - sp_url = _clean_url(sp_url) - spotify["url"] = sp_url - updates["spotify.url"] = sp_url - if updates: - try: - await col.update_one({"_id": track_id}, {"$set": updates}) - except Exception: - pass - doc["spotify"] = spotify - _normalize_spotify(doc) - liked_set = await get_user_liked_track_ids(user_id, [doc["_id"]]) if (user_id and user_id > 0) else set() - doc["liked"] = doc["_id"] in liked_set - return doc - -async def get_tracks_by_ids(track_ids: list[str], user_id: Optional[int] = None) -> list[dict]: - ids = [str(x) for x in (track_ids or []) if str(x)] - if not ids: - return [] - - col = get_audio_tracks_collection() - projection = { - "_id": 1, - "source_chat_id": 1, - "source_message_id": 1, - "telegram": 1, - "audio": 1, - "spotify": 1, - "content_hash": 1, - "fingerprint": 1, - "created_at": 1, - "updated_at": 1, - } - cursor = col.find({"_id": {"$in": ids}, "deleted": {"$ne": True}}, projection) - docs: list[dict] = [] - async for doc in cursor: - if "_id" in doc: - doc["_id"] = _as_str_id(doc["_id"]) - if not doc.get("created_at") and doc.get("updated_at"): - doc["created_at"] = doc.get("updated_at") - _normalize_spotify(doc) - docs.append(doc) - - liked_set = await get_user_liked_track_ids(user_id, ids) if (user_id and user_id > 0) else set() - by_id = {str(d.get("_id")): d for d in docs if d.get("_id")} - res = [] - for t in ids: - if t in by_id: - item = by_id[t] - item["liked"] = t in liked_set - res.append(item) - return res - diff --git a/Api/services/youtube_service.py b/Api/services/youtube_service.py deleted file mode 100644 index 6344d57..0000000 --- a/Api/services/youtube_service.py +++ /dev/null @@ -1,331 +0,0 @@ -import asyncio -import copy -import hashlib -import os -import re -import time -import unicodedata -from typing import Any -from urllib.parse import parse_qs, urlparse - -try: - import yt_dlp -except Exception: # pragma: no cover - runtime fallback when dependency is absent - yt_dlp = None - -from stream.helpers.logger import LOGGER - - -LOG = LOGGER(__name__) - -_YT_TRACK_ID_RE = re.compile(r"^yt_([A-Za-z0-9_-]{11})$") -_YT_VIDEO_ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$") -_YT_CACHE_TTL_SEC = 15 * 60 -_YOUTUBE_ALBUM = "YouTube" -_YOUTUBE_ALBUM_ID = "album_youtube" -_GENERIC_YOUTUBE_ALBUM_TITLES = {"youtube", "youtube music"} - -_YT_META_CACHE: dict[str, tuple[float, dict[str, Any]]] = {} -_YT_META_INFLIGHT: dict[str, asyncio.Task[dict[str, Any]]] = {} -_YT_META_LOCK = asyncio.Lock() - -_ROOT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -_COOKIES_PATH = os.path.join(_ROOT_DIR, "cookies", "yt.txt") -_YDL_HEADERS = { - "User-Agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - ), - "Accept-Language": "en-US,en;q=0.5", - "Referer": "https://www.google.com/", -} - - -def _coerce_text(value: Any) -> str: - if value is None: - return "" - if isinstance(value, str): - return value.strip() - return str(value).strip() - - -def _normalize_album_id_part(text: Any) -> str: - raw = _coerce_text(text).strip().lower() - if not raw: - return "" - s = raw.replace("÷", " divide ").replace("&", " and ").replace("+", " plus ") - s = unicodedata.normalize("NFKD", s) - s = s.encode("ascii", "ignore").decode("ascii") - s = re.sub(r"[^a-z0-9 ]", " ", s) - s = re.sub(r"\s+", "_", s.strip()) - s = re.sub(r"_+", "_", s).strip("_") - if s: - return s - h = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16] - return f"u_{h}" - - -def _is_generic_youtube_album(value: Any) -> bool: - normalized = _coerce_text(value).lower() - return normalized in _GENERIC_YOUTUBE_ALBUM_TITLES - - -def resolve_youtube_album_metadata(info: dict[str, Any] | None, fallback_title: Any) -> tuple[str, str]: - payload = info if isinstance(info, dict) else {} - raw_album = _pick_first_nonempty(payload.get("album")) - resolved_album = raw_album if raw_album and not _is_generic_youtube_album(raw_album) else _pick_first_nonempty(fallback_title, raw_album, _YOUTUBE_ALBUM) - - album_slug = _normalize_album_id_part(resolved_album) - album_id = f"album_{album_slug}" if album_slug else _YOUTUBE_ALBUM_ID - return resolved_album, album_id - - -def youtube_track_id(video_id: str) -> str: - vid = _coerce_text(video_id) - if not _YT_VIDEO_ID_RE.fullmatch(vid): - return "" - return f"yt_{vid}" - - -def is_youtube_track_id(value: Any) -> bool: - return bool(_YT_TRACK_ID_RE.fullmatch(_coerce_text(value))) - - -def youtube_track_url(video_id: str) -> str: - return f"https://www.youtube.com/watch?v={video_id}" - - -def extract_youtube_video_id(value: Any) -> str | None: - raw = _coerce_text(value) - if not raw: - return None - - match = _YT_TRACK_ID_RE.fullmatch(raw) - if match: - return match.group(1) - - raw_lower = raw.lower() - if "youtube.com" not in raw_lower and "youtu.be/" not in raw_lower: - return None - - candidate = raw - if "://" not in candidate: - candidate = f"https://{candidate.lstrip('/')}" - - try: - parsed = urlparse(candidate) - except Exception: - return None - - host = (parsed.netloc or "").strip().lower() - if host.startswith("www."): - host = host[4:] - if host.startswith("m."): - host = host[2:] - - video_id: str | None = None - if host == "youtu.be": - parts = [p for p in (parsed.path or "").split("/") if p] - if parts: - video_id = parts[0] - elif host.endswith("youtube.com"): - qs = parse_qs(parsed.query or "") - raw_v = qs.get("v") - if raw_v and isinstance(raw_v, list): - candidate_v = str(raw_v[0] or "").strip() - if candidate_v: - video_id = candidate_v - if not video_id: - parts = [p for p in (parsed.path or "").split("/") if p] - if len(parts) >= 2 and parts[0] in {"embed", "shorts", "live", "v"}: - video_id = parts[1] - - if not video_id or not _YT_VIDEO_ID_RE.fullmatch(video_id): - return None - return video_id - - -def normalize_track_reference(value: Any) -> str: - raw = _coerce_text(value) - if not raw: - return "" - video_id = extract_youtube_video_id(raw) - if video_id: - return youtube_track_id(video_id) - return raw - - -def _ydl_opts() -> dict[str, Any]: - opts: dict[str, Any] = { - "quiet": True, - "no_warnings": True, - "noplaylist": True, - "skip_download": True, - "nocheckcertificate": True, - "extract_flat": False, - "http_headers": dict(_YDL_HEADERS), - } - if os.path.exists(_COOKIES_PATH): - opts["cookiefile"] = _COOKIES_PATH - return opts - - -def _extract_info_sync(video_id: str) -> dict[str, Any] | None: - if yt_dlp is None: - return None - - url = youtube_track_url(video_id) - opts = _ydl_opts() - try: - with yt_dlp.YoutubeDL(opts) as ydl: - info = ydl.extract_info(url, download=False) - return info if isinstance(info, dict) else None - except Exception as exc: - if opts.get("cookiefile"): - try: - retry_opts = dict(opts) - retry_opts.pop("cookiefile", None) - with yt_dlp.YoutubeDL(retry_opts) as ydl: - info = ydl.extract_info(url, download=False) - return info if isinstance(info, dict) else None - except Exception as retry_exc: - LOG.warning(f"[youtube_meta] failed video_id={video_id} err={retry_exc}") - return None - LOG.warning(f"[youtube_meta] failed video_id={video_id} err={exc}") - return None - - -async def _fetch_youtube_info(video_id: str) -> dict[str, Any] | None: - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, _extract_info_sync, video_id) - - -def _pick_first_nonempty(*values: Any) -> str: - for value in values: - if isinstance(value, str) and value.strip(): - return value.strip() - return "" - - -def _pick_artist(info: dict[str, Any]) -> str: - if isinstance(info.get("artists"), list): - for item in info.get("artists") or []: - if isinstance(item, str) and item.strip(): - return item.strip() - if isinstance(item, dict): - name = item.get("name") - if isinstance(name, str) and name.strip(): - return name.strip() - - for key in ("artist", "creator", "uploader", "channel", "uploader_id", "channel_id"): - picked = _pick_first_nonempty(info.get(key)) - if picked: - return picked - return "YouTube" - - -def _pick_thumbnail(info: dict[str, Any], video_id: str) -> str | None: - thumbs = info.get("thumbnails") - if isinstance(thumbs, list): - for item in reversed(thumbs): - if not isinstance(item, dict): - continue - url = item.get("url") - if isinstance(url, str) and url.strip(): - return url.strip() - - direct = info.get("thumbnail") - if isinstance(direct, str) and direct.strip(): - return direct.strip() - if video_id: - return f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg" - return None - - -def build_virtual_youtube_track(track_id: str, info: dict[str, Any] | None = None) -> dict[str, Any] | None: - video_id = extract_youtube_video_id(track_id) - if not video_id: - return None - - normalized_track_id = youtube_track_id(video_id) - payload = info if isinstance(info, dict) else {} - - title = _pick_first_nonempty(payload.get("track"), payload.get("title"), f"YouTube Video {video_id}") - artist = _pick_artist(payload) - album, album_id = resolve_youtube_album_metadata(payload, title) - cover_url = _pick_thumbnail(payload, video_id) - - raw_duration = payload.get("duration") - try: - duration_sec = float(raw_duration) if raw_duration is not None else 0.0 - except Exception: - duration_sec = 0.0 - if duration_sec < 0: - duration_sec = 0.0 - - yt_url = youtube_track_url(video_id) - updated_at = time.time() - - audio: dict[str, Any] = { - "title": title, - "artist": artist, - "performer": artist, - "album": album, - "album_id": album_id, - "duration_sec": duration_sec, - "type": "youtube", - "yt_id": video_id, - "yt_url": yt_url, - } - - track: dict[str, Any] = { - "_id": normalized_track_id, - "title": title, - "artist": artist, - "album": album, - "album_id": album_id, - "cover_url": cover_url, - "duration_sec": duration_sec, - "type": "youtube", - "audio": audio, - "spotify": { - "cover_url": cover_url, - "cover_source": "youtube", - }, - "updated_at": updated_at, - } - - return track - - -async def get_virtual_youtube_track(track_id: str) -> dict[str, Any] | None: - video_id = extract_youtube_video_id(track_id) - if not video_id: - return None - normalized_track_id = youtube_track_id(video_id) - now = time.time() - - async with _YT_META_LOCK: - cached = _YT_META_CACHE.get(normalized_track_id) - if cached and now - float(cached[0]) < _YT_CACHE_TTL_SEC: - return copy.deepcopy(cached[1]) - - task = _YT_META_INFLIGHT.get(normalized_track_id) - if task is None: - task = asyncio.create_task(_fetch_youtube_info(video_id)) - _YT_META_INFLIGHT[normalized_track_id] = task - - try: - info = await task - finally: - async with _YT_META_LOCK: - if _YT_META_INFLIGHT.get(normalized_track_id) is task: - _YT_META_INFLIGHT.pop(normalized_track_id, None) - - doc = build_virtual_youtube_track(normalized_track_id, info) - if not doc: - return None - - async with _YT_META_LOCK: - _YT_META_CACHE[normalized_track_id] = (time.time(), copy.deepcopy(doc)) - return doc diff --git a/Api/utils/__init__.py b/Api/utils/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/Api/utils/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Api/utils/auth.py b/Api/utils/auth.py deleted file mode 100644 index 643bd4f..0000000 --- a/Api/utils/auth.py +++ /dev/null @@ -1,287 +0,0 @@ -import base64 -import hashlib -import hmac -import json -import time -from typing import Optional - -from fastapi import Depends, Header, HTTPException, Request -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer - -from stream.core.config_manager import Config - -_bearer_scheme = HTTPBearer(auto_error=False) - - -def _b64url_encode(data: bytes) -> str: - return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") - - -def _b64url_decode(data: str) -> bytes: - s = (data or "").strip() - pad = "=" * (-len(s) % 4) - return base64.urlsafe_b64decode(s + pad) - - -def _get_secret_key_bytes() -> bytes: - secret = (getattr(Config, "SECRET_KEY", "") or "").strip() - if not secret: - secret = (getattr(Config, "BOT_TOKEN", "") or "").strip() - if not secret: - raise RuntimeError( - "SECRET_KEY is required but not configured. Please set SECRET_KEY or BOT_TOKEN in your .env or environment." - ) - return secret.encode("utf-8") - - -def create_auth_token( - *, - user_id: int | str, - ttl_sec: int = 365 * 24 * 60 * 60, - first_name: str | None = None, - photo_url: str | None = None, - profile_url: str | None = None, - token_version: int | None = None, -) -> str: - if isinstance(user_id, str) and user_id == "__api__": - uid = user_id - else: - uid = int(user_id) - if uid <= 0: - raise HTTPException(status_code=400, detail="user_id must be a positive int") - - now = int(time.time()) - payload: dict[str, object] = { - "uid": uid, - "user_id": uid, - "userid": uid, - "iat": now, - "exp": now + int(ttl_sec), - } - if token_version is not None: - payload["tv"] = int(token_version) # bumped server-side to revoke every session at once - fn = (first_name or "").strip() - if fn: - payload["first_name"] = fn - pu = (profile_url or photo_url or "").strip() - if pu: - payload["profile_url"] = pu - payload["photo_url"] = pu - payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8") - payload_b64 = _b64url_encode(payload_json) - - secret = _get_secret_key_bytes() - sig = hmac.new(secret, payload_b64.encode("utf-8"), hashlib.sha256).digest() - sig_b64 = _b64url_encode(sig) - return f"v1.{payload_b64}.{sig_b64}" - - -def verify_auth_token(token: str) -> dict: - raw = (token or "").strip() - if not raw: - raise HTTPException(status_code=401, detail="missing auth token") - - if raw.lower().startswith("bearer "): - raw = raw[7:].strip() - - parts = raw.split(".") - if len(parts) != 3 or parts[0] != "v1": - raise HTTPException(status_code=401, detail="invalid auth token") - - payload_b64 = parts[1].strip() - sig_b64 = parts[2].strip() - - # Collect candidate secrets for seamless verification and backwards compatibility - candidates: list[bytes] = [] - try: - primary = _get_secret_key_bytes() - candidates.append(primary) - except Exception: - pass - - bot_token = (getattr(Config, "BOT_TOKEN", "") or "").strip() - if bot_token: - bt_bytes = bot_token.encode("utf-8") - if bt_bytes not in candidates: - candidates.append(bt_bytes) - - configured_secret = (getattr(Config, "SECRET_KEY", "") or "").strip() - if configured_secret: - cfg_bytes = configured_secret.encode("utf-8") - if cfg_bytes not in candidates: - candidates.append(cfg_bytes) - - fallback_secret = b"SHA234567JDNKDNSNNFNDKSMSERTYUWERTY" - if fallback_secret not in candidates: - candidates.append(fallback_secret) - - matched = False - for cand in candidates: - expected = _b64url_encode(hmac.new(cand, payload_b64.encode("utf-8"), hashlib.sha256).digest()) - if hmac.compare_digest(expected, sig_b64): - matched = True - break - - if not matched: - raise HTTPException(status_code=401, detail="invalid auth token") - - try: - payload = json.loads(_b64url_decode(payload_b64).decode("utf-8")) - except Exception: - raise HTTPException(status_code=401, detail="invalid auth token") - - uid = payload.get("uid") - exp = payload.get("exp") - try: - exp = int(exp) if exp is not None else 0 - except Exception: - raise HTTPException(status_code=401, detail="invalid auth token") - - if isinstance(uid, str) and uid == "__api__": - pass - else: - try: - uid = int(uid) - except Exception: - raise HTTPException(status_code=401, detail="invalid auth token") - if uid <= 0: - raise HTTPException(status_code=401, detail="invalid auth token") - payload["uid"] = uid - - if exp and int(time.time()) > exp: - raise HTTPException(status_code=401, detail="auth token expired") - - return payload - - -from typing import Optional - -async def get_optional_user_id( - request: Request, - credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), - x_auth_token: str | None = Header(default=None, alias="X-Auth-Token"), -) -> int | None: - token = "" - if credentials is not None and (credentials.credentials or "").strip(): - token = (credentials.credentials or "").strip() - elif (x_auth_token or "").strip(): - token = (x_auth_token or "").strip() - else: - token = (request.cookies.get("auth_token") or "").strip() - if not token: - token = (request.cookies.get("token") or "").strip() - - if not token: - return None - - try: - payload = verify_auth_token(token) - except HTTPException: - return None - - uid = payload.get("uid") - if isinstance(uid, str) and uid == "__api__": - return None - try: - val = int(uid) - if val <= 0: - return None - from stream.core.source_filter import is_source_banned - - if await is_source_banned(val): - return None - from Api.services.access_control import AccessDenied, assert_can_use - - try: - await assert_can_use(val, payload.get("tv")) - except AccessDenied: - return None - return val - except (ValueError, TypeError): - return None - - -async def require_user_id( - request: Request, - credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), - x_auth_token: str | None = Header(default=None, alias="X-Auth-Token"), -) -> int: - token = "" - if credentials is not None and (credentials.credentials or "").strip(): - token = (credentials.credentials or "").strip() - elif (x_auth_token or "").strip(): - token = (x_auth_token or "").strip() - else: - token = (request.cookies.get("auth_token") or "").strip() - if not token: - token = (request.cookies.get("token") or "").strip() - - if not token: - raise HTTPException(status_code=401, detail="user login required") - - payload = verify_auth_token(token) - uid = payload.get("uid") - if isinstance(uid, str): - raise HTTPException(status_code=401, detail="user login required") - try: - uid_int = int(uid) - if uid_int <= 0: - raise HTTPException(status_code=401, detail="user login required") - from stream.core.source_filter import is_source_banned - - if await is_source_banned(uid_int): - raise HTTPException(status_code=403, detail="user is banned") - from Api.services.access_control import AccessDenied, assert_can_use - - try: - await assert_can_use(uid_int, payload.get("tv")) - except AccessDenied as denied: - raise HTTPException(status_code=denied.status_code, detail=denied.payload()) - return uid_int - except (ValueError, TypeError): - raise HTTPException(status_code=401, detail="user login required") - - -def admin_user_ids() -> set[int]: - owners_raw = getattr(Config, "OWNER_ID", None) - owners = [owners_raw] if isinstance(owners_raw, (int, str)) else (owners_raw or []) - sudos_raw = getattr(Config, "SUDO_USERS", None) - sudos = [sudos_raw] if isinstance(sudos_raw, (int, str)) else (sudos_raw or []) - allow: set[int] = set() - for v in list(owners) + list(sudos): - try: - allow.add(int(v)) - except Exception: - pass - return allow - - -def is_admin_user(user_id: int | None) -> bool: - try: - return user_id is not None and int(user_id) in admin_user_ids() - except Exception: - return False - - -async def require_admin_user_id(user_id: int = Depends(require_user_id)) -> int: - uid = int(user_id) - owners_raw = getattr(Config, "OWNER_ID", None) - owners = [owners_raw] if isinstance(owners_raw, (int, str)) else (owners_raw or []) - sudos_raw = getattr(Config, "SUDO_USERS", None) - sudos = [sudos_raw] if isinstance(sudos_raw, (int, str)) else (sudos_raw or []) - allow: set[int] = set() - for v in owners: - try: - allow.add(int(v)) - except Exception: - pass - for v in sudos: - try: - allow.add(int(v)) - except Exception: - pass - if not allow: - raise HTTPException(status_code=403, detail="admin access not configured") - if uid not in allow: - raise HTTPException(status_code=403, detail="admin only") - return uid diff --git a/Api/utils/auth_middleware.py b/Api/utils/auth_middleware.py deleted file mode 100644 index 31ffb53..0000000 --- a/Api/utils/auth_middleware.py +++ /dev/null @@ -1,215 +0,0 @@ -from __future__ import annotations - -import time -from urllib.parse import parse_qs - -from fastapi import HTTPException -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.requests import Request -from starlette.responses import JSONResponse, Response - -from Api.utils.auth import verify_auth_token - -_SETUP_CACHE: dict[str, object] = {"value": None, "expires": 0.0} -_SETUP_CACHE_TTL_SEC = 10.0 - -# Path prefixes that REQUIRE a valid auth token. -# Anything not matching this list is allowed through (SPA routes, static -# files, public endpoints, the /auth/* login flow, /health, etc.). -_PROTECTED_PREFIXES: tuple[str, ...] = ( - "/tracks", - "/albums", - "/artists", - "/topics", - "/search", - "/browse", - "/library", - "/api/v1/library", - "/playlists", - "/favourites", - "/top-played", - "/me", - "/jam", - "/friends", - "/notifications", - "/admin", - "/logs", - "/youtube", - "/soundcloud", - "/test", - "/daily-playlist", - "/channelids", - "/covers/user-playlist", -) - - -def _path_is_protected(path: str) -> bool: - p = (path or "/").rstrip("/") or "/" - for prefix in _PROTECTED_PREFIXES: - if p == prefix or p.startswith(prefix + "/"): - return True - return False - - -_OPTIONAL_PREFIXES: tuple[str, ...] = ( - "/browse", - "/tracks", - "/albums", - "/artists", - "/topics", - "/search", - "/playlists/available", - "/daily-playlist", - "/channelids", - "/covers", - "/share", - "/recaps/share", - "/library/shuffle", - "/api/v1/library/shuffle", -) - - -def _is_optional_route(path: str, method: str) -> bool: - if method not in ("GET", "HEAD"): - return False - - p = (path or "/").rstrip("/") or "/" - for prefix in _OPTIONAL_PREFIXES: - if p == prefix or p.startswith(prefix + "/"): - return True - - # Allow public playlist listing (returns empty for guests) and shared playlist view - if p == "/playlists" or (p.startswith("/playlists/") and not p.endswith("/tracks")): - return True - - return False - - -def _extract_tokens(request: Request) -> list[str]: - tokens = [] - auth_header = (request.headers.get("authorization") or "").strip() - if auth_header: - if auth_header.lower().startswith("bearer "): - tokens.append(auth_header[7:].strip()) - else: - tokens.append(auth_header) - x_auth = (request.headers.get("x-auth-token") or "").strip() - if x_auth: - tokens.append(x_auth) - query_token = (request.query_params.get("token") or "").strip() - if query_token: - tokens.append(query_token) - raw_query = str(request.url.query or "") - if raw_query: - parsed = parse_qs(raw_query) - vals = parsed.get("token") - if vals: - t = str(vals[0]).strip() - if t and t not in tokens: - tokens.append(t) - for c_name in ("auth_token", "token"): - c = (request.cookies.get(c_name) or "").strip() - if c and c not in tokens: - tokens.append(c) - return tokens - - -async def _owner_password_exists() -> bool: - now = time.time() - if _SETUP_CACHE["expires"] > now and _SETUP_CACHE["value"] is not None: - return bool(_SETUP_CACHE["value"]) - - from stream.database.MongoDb import db_handler - - try: - col = db_handler.get_collection("auth_config").collection - doc = await col.find_one({"_id": "owner_password"}, {"password": 1}) - stored = doc.get("password") if isinstance(doc, dict) else None - exists = isinstance(stored, dict) and bool(stored) - _SETUP_CACHE["value"] = exists - _SETUP_CACHE["expires"] = now + _SETUP_CACHE_TTL_SEC - return exists - except Exception: - return False - - -class AuthMiddleware(BaseHTTPMiddleware): - """Enforces bearer/cookie auth on protected API routes. - - During first-run (owner password not yet configured), protected API - routes return 503 so the frontend can redirect to the setup screen. - Public/static/auth routes and browser HTML navigation are excluded. - Optional browsing routes allow unauthenticated/guest requests. - CORS preflight (OPTIONS) is always allowed through so the CORSMiddleware - can handle it. - """ - - async def dispatch(self, request: Request, call_next) -> Response: - if request.method == "OPTIONS": - return await call_next(request) - - # Allow browser HTML / SPA navigation directly through to serve frontend - from Api.utils.spa_middleware import is_html_navigation - if is_html_navigation(request): - return await call_next(request) - - path = request.url.path or "/" - if not _path_is_protected(path): - return await call_next(request) - - if not await _owner_password_exists(): - return JSONResponse( - status_code=503, - content={"ok": False, "detail": "setup required"}, - ) - - tokens = _extract_tokens(request) - is_optional = _is_optional_route(path, request.method) - - if not tokens: - if is_optional: - return await call_next(request) - return JSONResponse( - status_code=401, - content={"ok": False, "detail": "auth token required"}, - ) - - authenticated = False - valid_payload = None - for tok in tokens: - try: - valid_payload = verify_auth_token(tok) - authenticated = True - break - except Exception: - continue - - if not authenticated: - if is_optional: - return await call_next(request) - return JSONResponse( - status_code=401, - content={"ok": False, "detail": "invalid auth token"}, - ) - - if valid_payload: - uid = valid_payload.get("uid") - if uid and uid != "__api__": - from stream.core.source_filter import is_source_banned - - if await is_source_banned(uid): - return JSONResponse( - status_code=403, - content={"ok": False, "detail": "user is banned"}, - ) - # Account lock / membership restriction / revoked session (30 s memoised per user) - from Api.services.access_control import AccessDenied, assert_can_use - - try: - await assert_can_use(int(uid), valid_payload.get("tv")) - except AccessDenied as denied: - return JSONResponse(status_code=denied.status_code, content=denied.payload()) - except (TypeError, ValueError): - pass - - return await call_next(request) diff --git a/Api/utils/firebase.py b/Api/utils/firebase.py deleted file mode 100644 index 451ebf3..0000000 --- a/Api/utils/firebase.py +++ /dev/null @@ -1,74 +0,0 @@ -import os -import json -import base64 -import firebase_admin -from firebase_admin import credentials, messaging -from stream.core.config_manager import Config -from stream.helpers.logger import LOGGER - -def initialize_firebase(): - if firebase_admin._apps: - return - try: - b64_cred = os.environ.get("FIREBASE_CRED_B64") - if b64_cred: - cred_dict = json.loads(base64.b64decode(b64_cred).decode("utf-8")) - cred = credentials.Certificate(cred_dict) - else: - cred_cfg = Config.FIREBASE_CREDENTIALS - if cred_cfg and isinstance(cred_cfg, str) and len(cred_cfg) > 20: - # XOR Decode using the SECRET_KEY - key = Config.SECRET_KEY.encode('utf-8') - enc_data = base64.b64decode(cred_cfg) - dec_data = bytes(a ^ b for a, b in zip(enc_data, (key * (len(enc_data) // len(key) + 1))[:len(enc_data)])) - cred_dict = json.loads(dec_data.decode("utf-8")) - cred = credentials.Certificate(cred_dict) - else: - if not cred_cfg or not isinstance(cred_cfg, str): - cred_cfg = os.path.join(os.path.dirname(__file__), "..", "..", "service_account.json") - cred = credentials.Certificate(cred_cfg) - - firebase_admin.initialize_app(cred) - LOGGER(__name__).info("Firebase Admin initialized successfully.") - except Exception as e: - LOGGER(__name__).error(f"Failed to initialize Firebase Admin: {e}") - -def send_push_notification(token: str, title: str, body: str, data: dict = None): - initialize_firebase() - if not firebase_admin._apps: - return None - - try: - message = messaging.Message( - notification=messaging.Notification( - title=title, - body=body, - ), - data=data or {}, - token=token, - ) - response = messaging.send(message) - return response - except Exception as e: - LOGGER(__name__).error(f"Failed to send Firebase push notification: {e}") - return None - -def send_multicast_notification(tokens: list, title: str, body: str, data: dict = None): - initialize_firebase() - if not firebase_admin._apps or not tokens: - return None - - try: - message = messaging.MulticastMessage( - notification=messaging.Notification( - title=title, - body=body, - ), - data=data or {}, - tokens=tokens, - ) - response = messaging.send_each_for_multicast(message) - return response - except Exception as e: - LOGGER(__name__).error(f"Failed to send Firebase multicast notification: {e}") - return None diff --git a/Api/utils/pagination.py b/Api/utils/pagination.py deleted file mode 100644 index ad8ca09..0000000 --- a/Api/utils/pagination.py +++ /dev/null @@ -1,7 +0,0 @@ -def clamp_page(page: int) -> int: - try: - page = int(page) - except Exception: - return 1 - return page if page >= 1 else 1 - diff --git a/Api/utils/spa_middleware.py b/Api/utils/spa_middleware.py deleted file mode 100644 index ad03ccd..0000000 --- a/Api/utils/spa_middleware.py +++ /dev/null @@ -1,84 +0,0 @@ -from __future__ import annotations - -import os -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.requests import Request -from starlette.responses import FileResponse, Response - - -def is_html_navigation(request: Request) -> bool: - if request.method not in ("GET", "HEAD"): - return False - - sec_dest = (request.headers.get("sec-fetch-dest") or "").lower() - if sec_dest in ("document", "frame", "iframe"): - return True - - sec_mode = (request.headers.get("sec-fetch-mode") or "").lower() - if sec_mode == "navigate": - return True - - accept = (request.headers.get("accept") or "").lower() - if "text/html" in accept: - first_accept = accept.split(",")[0].strip() - if not first_accept.startswith("application/json"): - return True - - return False - - -class SPAMiddleware(BaseHTTPMiddleware): - """ - Middleware that intercepts browser HTML page navigation requests - (such as page reloads on /search, /albums, /artists, etc.) - and serves the frontend SPA shell (index.html) instead of API JSON. - - API requests (e.g. from fetch/axios with Accept: application/json or non-HTML) - and static assets/backend docs/auth popups pass through untouched. - """ - - def __init__(self, app, dist_dir: str): - super().__init__(app) - self.dist_dir = dist_dir - - async def dispatch(self, request: Request, call_next) -> Response: - if not is_html_navigation(request): - return await call_next(request) - - path = (request.url.path or "/").rstrip("/") or "/" - - if ( - path == "/docs" - or path.startswith("/docs/") - or path == "/redoc" - or path.startswith("/redoc/") - or path == "/openapi.json" - or path.startswith("/assets/") - or path.startswith("/auth/") - or path.startswith("/covers/") - or path == "/test-stream" - or "/stream" in path - or "/download" in path - or "/warm" in path - ): - return await call_next(request) - - clean_path = path.lstrip("/") - if clean_path: - file_path = os.path.join(self.dist_dir, clean_path) - if os.path.isfile(file_path): - if clean_path in ("sw.js", "manifest.json"): - return FileResponse(file_path, headers={"Cache-Control": "no-cache, no-store, must-revalidate"}) - return FileResponse(file_path) - - index_file = os.path.join(self.dist_dir, "index.html") - if os.path.isfile(index_file): - return FileResponse( - index_file, - headers={ - "Cache-Control": "no-cache, no-store, must-revalidate", - "Cross-Origin-Opener-Policy": "same-origin-allow-popups", - }, - ) - - return await call_next(request) diff --git a/Assets/IronFont.otf b/Assets/IronFont.otf deleted file mode 100644 index 563fb0e..0000000 Binary files a/Assets/IronFont.otf and /dev/null differ diff --git a/Assets/Jura-Bold.ttf b/Assets/Jura-Bold.ttf deleted file mode 100644 index 5d75ce5..0000000 Binary files a/Assets/Jura-Bold.ttf and /dev/null differ diff --git a/Assets/SFPRODISPLAYBOLD.OTF b/Assets/SFPRODISPLAYBOLD.OTF deleted file mode 100644 index 025b25c..0000000 Binary files a/Assets/SFPRODISPLAYBOLD.OTF and /dev/null differ diff --git a/Assets/SFPRODISPLAYMEDIUM.OTF b/Assets/SFPRODISPLAYMEDIUM.OTF deleted file mode 100644 index b2f7dac..0000000 Binary files a/Assets/SFPRODISPLAYMEDIUM.OTF and /dev/null differ diff --git a/Assets/cover.jpg b/Assets/cover.jpg deleted file mode 100644 index b16c95c..0000000 Binary files a/Assets/cover.jpg and /dev/null differ diff --git a/Assets/statsbg.jpg b/Assets/statsbg.jpg deleted file mode 100644 index eff8e93..0000000 Binary files a/Assets/statsbg.jpg and /dev/null differ diff --git a/Assets/statsbg.png b/Assets/statsbg.png deleted file mode 100644 index 0edd200..0000000 Binary files a/Assets/statsbg.png and /dev/null differ diff --git a/Dockerfile b/Dockerfile index 327ef38..3ec8a05 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,52 +1,54 @@ -# syntax=docker/dockerfile:1.2 +# syntax=docker/dockerfile:1.4 +# Stage 1: Build WebX SPA Frontend FROM node:20-alpine AS frontend-builder RUN apk add --no-cache git -RUN git clone https://github.com/StreamXProject/WebX.git /app/WebX +ARG WEBX_REPO=https://github.com/StreamXProject/WebX.git +RUN git clone --depth 1 ${WEBX_REPO} /app/WebX WORKDIR /app/WebX RUN npm install RUN npm run build -FROM python:3.12-slim-bookworm - -RUN apt-get update && \ - apt-get install -y \ - git \ - openssh-client \ - build-essential \ - gcc \ - wget \ - curl \ - dpkg \ - ca-certificates \ - gnupg \ - aria2 \ - mediainfo \ - ffmpeg \ - libavcodec-extra \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* +# Stage 2: Build StreamGO Backend Binary +FROM golang:1.25-alpine AS go-builder -WORKDIR /app +RUN apk add --no-cache git ca-certificates + +ENV GOTOOLCHAIN=auto -RUN python3 -m venv /app/streamvenv -ENV PATH="/app/streamvenv/bin:$PATH" \ - PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 \ - PORT=8000 +WORKDIR /app -COPY requirements.txt . -RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \ - pip install --no-cache-dir -r requirements.txt +COPY go.mod go.sum ./ +RUN go mod download COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/bin/server ./cmd/server + +# Stage 3: Production Runtime +FROM alpine:3.21 + +RUN apk add --no-cache \ + ca-certificates \ + tzdata \ + ffmpeg \ + mediainfo + +WORKDIR /app + +# Copy compiled Go server binary +COPY --from=go-builder /app/bin/server /app/server + +# Copy compiled WebX SPA frontend +COPY --from=frontend-builder /app/WebX/dist /app/dist -COPY --from=frontend-builder /app/WebX/dist ./dist +# Default configuration environment variables +ENV PORT=8000 \ + APP_ENV=production EXPOSE 8000 -CMD ["python3", "-m", "stream"] \ No newline at end of file +ENTRYPOINT ["/app/server"] diff --git a/README.md b/README.md index f643d07..8fb9546 100644 --- a/README.md +++ b/README.md @@ -1,357 +1,68 @@ -
-

- StreamX cover preview -

-

StreamX

-

StreamX is a self-hosted music platform that manage your personal library and YouTube Music into one fast, modern experience across Android, web

-

Stream, download, and organize your music with ease — from albums and artists to playlists and live jam sessions with friends.

-

No ads. No tracking. Just complete control.

-

- - Releases - - - Support Group - - - Tutorials - -

-

- - Deploy to Render - -

-
+

StreamGO

-## Overview +

A Go server for streaming music from Telegram. It stores track details in MongoDB and serves the WebX music player.

-StreamX is split into three projects that ship one experience: +

StreamX for Android

-- `stream`: the root backend, API, Telegram ingit add . -gestion layer, sharing routes, and deployment entrypoint -- `StreamX/`: the Android app -- `StreamXWeb/`: the React web client +StreamXBot's Go version, called StreamGO. The previous Python source is on [`legacy/python`](https://github.com/StreamXProject/StreamXBot/tree/legacy/python). -The result is a full self-hosted music platform with private infrastructure, cross-device playback, social features, share links, and polished clients on both mobile and web. +- Search tracks, artists, and albums. +- Save playlists and favourites. +- View lyrics, listening history, and recaps. +- Manage music sources and user access through a Telegram bot. -## Features +## Run locally -### Playback And Library +You need Go 1.25 or newer, MongoDB, FFmpeg, and MediaInfo. -- fast playback with queue controls, play next, repeat, shuffle, and rich full-player interactions -- favourites, saved albums, custom playlists, shared playlists, and top-played flows -- album, artist, track, and playlist navigation across both backend and provider-native content -- seamless switching between the StreamX backend library and the YouTube provider inside the app -- YouTube track syncing so songs discovered on YouTube can be brought into StreamX -- offline downloads with download management and remove-download actions -- expanded player views with metadata, next queue, lyrics, and quick actions - -### Lyrics, Discovery, And UI - -- synced lyrics and dedicated lyrics view -- artist pages, album pages, playlist pages, and search-driven discovery -- direct YouTube search, provider-native YouTube browse flows, and quick provider switching on Android -- customizable UI with polished cards, home sections, dark mode, and AMOLED-style presentation -- polished Compose-based UI with full-screen player, bottom sheets, and modern navigation - -### Social And Sharing - -- collaborative jam sessions with synchronized playback and queue updates -- jam invites, join flows, cooldown handling, and push notifications -- friends, presence, listening activity, and share-listening controls -- share links for playlists, albums, tracks, and jams - -## Screenshots - -| 1 | 2 | 3 | -| --- | --- | --- | -| ![](https://raw.githubusercontent.com/MisfiT2020/src/main/streamx/app/1.jpg) | ![](https://raw.githubusercontent.com/MisfiT2020/src/main/streamx/app/2.jpg) | ![](https://raw.githubusercontent.com/MisfiT2020/src/main/streamx/app/3.jpg) | - -| 4 | 5 | 6 | -| --- | --- | --- | -| ![](https://raw.githubusercontent.com/MisfiT2020/src/main/streamx/app/4.jpg) | ![](https://raw.githubusercontent.com/MisfiT2020/src/main/streamx/app/5.jpg) | ![](https://raw.githubusercontent.com/MisfiT2020/src/main/streamx/app/6.jpg) | - -| 7 | 8 | 9 | -| --- | --- | --- | -| ![](https://raw.githubusercontent.com/MisfiT2020/src/main/streamx/app/7.jpg) | ![](https://raw.githubusercontent.com/MisfiT2020/src/main/streamx/app/8.jpg) | ![](https://raw.githubusercontent.com/MisfiT2020/src/main/streamx/app/9.jpg) | - -| 10 | 11 | 12 | -| --- | --- | --- | -| ![](https://raw.githubusercontent.com/MisfiT2020/src/main/streamx/app/10.jpg) | ![](https://raw.githubusercontent.com/MisfiT2020/src/main/streamx/app/11.jpg) | ![](https://raw.githubusercontent.com/MisfiT2020/src/main/streamx/app/12.jpg) | - -| 13 | 14 | | -| --- | --- | --- | -| ![](https://raw.githubusercontent.com/MisfiT2020/src/main/streamx/app/13.jpg) | ![](https://raw.githubusercontent.com/MisfiT2020/src/main/streamx/app/14.jpg) | | - -## Project Layout - -| Project | Role | Stack | Docs | -| --- | --- | --- | --- | -| `stream` | Backend, API, sharing, deployment | Python, FastAPI, MongoDB, Telegram, Firebase Admin | [README.md](./README.md) | -| `StreamX/` | Android app | Kotlin, Jetpack Compose, Media3, Firebase Messaging | [StreamX/README.md](./StreamX/README.md) | -| `StreamXWeb/` | Web app | React, Vite, TypeScript | [StreamXWeb/README.md](./StreamXWeb/README.md) | - -## Releases - -- Android APK builds are published in the root repo's [Releases](https://github.com/MisfiT2020/StreamXBot/releases) -- `StreamXWeb` is built and served by the backend in normal production deployments -- backend deployment is source-based or Docker-based depending on your environment - -## Quick Start - -### 1. Start the backend - -```bash -python -m venv .venv -.venv\Scripts\activate -pip install -r requirements.txt -copy sample_config.py config.py -python -m stream -``` - -Or with Docker: - -```bash -docker compose up --build -``` - -### 2. Build the Android app - -```bash -cd StreamX -.\gradlew.bat :app:assembleDebug -.\gradlew.bat :app:installDebug -``` - -Before the first build, place `google-services.json` at `StreamX/app/google-services.json`. - -### 3. Run the web app locally - -```bash -cd StreamXWeb -npm install -npm run dev -``` - -For production, `StreamXWeb` is usually not deployed separately. The backend Dockerfile already builds and serves it. - -## Backend Overview - -This root project powers: - -- auth, playlists, favourites, albums, tracks, friends, jams, and notifications -- Telegram ingestion and admin tooling -- media streaming endpoints -- playlist, album, track, and jam share routes -- production serving of the built web app - -Main entrypoint: - -```bash -python -m stream -``` - -You can run it in: - -- API-only=True mode disables the bot's runtime, which is useful for testing - -## Deployment - -### Local Python - -Requirements: - -- Python 3.11+ - -Run: - -```bash -python -m venv .venv -.venv\Scripts\activate -pip install --upgrade pip -pip install -r requirements.txt -copy sample_config.py config.py -python -m stream +```sh +cp .env.example .env ``` -### Docker - -The root `Dockerfile` already: - -1. builds `WebX` -2. installs Python dependencies in a virtual environment -3. copies backend code -4. copies the built web `dist/` -5. starts the app directly via `python3 -m stream` - -Run: - -```bash -docker compose up --build -d -``` +Edit `.env` and set: -### Render - -This repo can be used for other service (free) such as koyeb. - -Typical Render flow: - -1. create a Docker web service -2. point it at this repo -3. set `CONFIG_GIST` to a raw URL that returns your full `config.py` -4. deploy - -One-click shortcut: - -- [Deploy to Render](https://render.com/deploy?repo=https://github.com/MisfiT2020/StreamXBot) - -Health check: - -```text -GET /health -``` - -## Tutorials - -| Topic | Video | -| --- | --- | -| VPS Setup | [YouTube](https://www.youtube.com/watch?v=n36uEef8VrE) | -| Render / Koyeb / Other Platforms | [YouTube](https://www.youtube.com/watch?v=A6kBOLGAbnk) | - -## Main Config - -Use `sample_config.py` as the template for `config.py`. - -### Core values for most full installs - -| Key | Purpose | -| --- | --- | -| `BOT_TOKEN` | Telegram bot token | -| `API_ID` | Telegram API ID | -| `API_HASH` | Telegram API hash | -| `MONGO_URI` | MongoDB connection string | -| `DATABASE_NAME` | MongoDB database name | -| `OWNER_ID` | Telegram owner ID | -| `SECRET_KEY` | Session signing and Firebase credential decoding | -| `CHANNEL_ID` | Main Telegram source channel | -| `FILTER_MODE` | `0` indexes only `CHANNEL_ID`; `1` indexes media from any chat, group, or forum topic the bot can read | - -### Common optional values - -| Key | Purpose | +| Variable | Value | | --- | --- | -| `FIREBASE_CREDENTIALS` | Encoded Firebase Admin service account | -| `CORS_ORIGINS` | Allowed frontend origins | -| `COOKIE_SECURE` | Secure cookie flag for HTTPS | -| `COOKIE_SAMESITE` | Cross-site cookie policy | -| `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` | Spotify metadata integration | -| `SESSION_STRING` | Optional userbot session | -| `SOURCE_CHANNEL_IDS` | Additional Telegram source channels | -| `DUMP_CHANNEL_ID` | Optional dump channel | -| `LRCLIB` / `MUSIXMATCH` | Lyrics provider toggles | -| `MULTI_CLIENTS`, `MULTI_CLIENTS_1...` | Extra Telegram clients or tokens | - -### API-only mode - -If you only want the API: - -```python -ONLY_API = True +| `MONGO_URI` | Your MongoDB connection string | +| `API_ID`, `API_HASH` | Your Telegram API credentials | +| `BOT_TOKEN` | Your Telegram bot token | +| `CHANNEL_ID` | The channel or group to read music from | +| `OWNER_ID` | Your Telegram user ID | +| `SECRET_KEY` | A long, random secret for login tokens | + +Add the bot to your music channel or group. From the project folder, install and run: + +```sh +go install ./cmd/server +server ``` -In that mode the important requirements are: - -- `MONGO_URI` -- `DATABASE_NAME` -- `SECRET_KEY` - -## Firebase Setup For The Backend - -The backend uses Firebase Admin to send push notifications. This is different from the Android device token. +Make sure your Go bin folder (`go env GOBIN`, or `$(go env GOPATH)/bin` if unset) is in your `PATH`. Run `server` from the folder containing `.env`. -### What you need +The server runs at [localhost:8000](http://localhost:8000). Send audio to the configured channel or group to add tracks. Send `/help` to the bot for commands. -You need a Firebase service account JSON from: +To use the web player, place a WebX build in `dist/` with `dist/index.html` present. Cloudflare R2 is optional for storing artwork; its settings are in `.env.example`. -1. Firebase Console -2. Project settings -3. Service accounts -4. Generate new private key +## Docker -Save that file as `service_account.json` in the root of this repo. +Use the same `.env`, with a `MONGO_URI` the container can reach. Compose runs only StreamGO; MongoDB must run separately. -### How to use `encode_firebase.py` - -This repo includes `encode_firebase.py` so you can store an encoded value in `config.py` instead of raw JSON. - -Steps: - -1. put the downloaded file at `service_account.json` -2. make sure `SECRET_KEY` is already set in `config.py` -3. run: - -```bash -python encode_firebase.py -``` - -4. copy the printed output into: - -```python -FIREBASE_CREDENTIALS = "..." -``` - -### Alternate Firebase env option - -The backend also supports: - -```text -FIREBASE_CRED_B64 +```sh +docker compose up -d --build ``` -That should be plain base64 of the raw Firebase service account JSON. - -### Firebase value checklist - -| Item | Used by | Where you get it | -| --- | --- | --- | -| `service_account.json` / `FIREBASE_CREDENTIALS` / `FIREBASE_CRED_B64` | Backend push sending | Firebase Console -> Project settings -> Service accounts | -| `google-services.json` | Android app build setup | Firebase Console -> Project settings -> Your apps -> Android | -| FCM device token | Individual Android device | Generated automatically by the app | - -Do not paste the device FCM token into backend config. The Android client obtains it automatically and registers it after login. - -## YouTube Cookies - -Backend-side YouTube extraction looks for: - -```text -cookies/yt.txt -``` - -Use it when you need stronger `yt-dlp` access for restricted or rate-limited content. - -### How to prepare it - -1. log in to YouTube in a desktop browser -2. export cookies in Netscape format -3. save the file as `cookies/yt.txt` - -Notes: - -- keep the file private and never commit it -- you can upload the cookies directly via telegram bot using /sudo > cookies > save cookies as yt.txt and upload. +The image builds and includes WebX. Set environment variables in your hosting service when deploying on Render or Heroku. -## Support +## Moving from Python -- Support group: [t.me/RaidenEISupport](https://t.me/RaidenEISupport) +Go is being reviewed on `dev/go-migration`. Keep running Python until login, track indexing, streaming, and your web and Android clients have been checked. -## Thanks +Back up MongoDB and test with a copy first; Go runs database updates at startup. Keep your existing `DATABASE_NAME` (Python used `StreamX`; Go defaults to `Stream`). Copy settings into `.env` or your host's environment variables. Go does not load `config.py` or `CONFIG_GIST`. -- Metrolist for innertube & reference +Some Python features and settings have not been ported, including jams, friends, and presence. `SESSION_STRING` is currently unused by the Go Telegram client. Check any setup using a user account, `SOURCE_CHANNEL_IDS`, YouTube, Firebase, or Spotify before switching. -## Project Docs +## API -- Root backend and deployment: [README.md](./README.md) -- Android app: [StreamX/README.md](./StreamX/README.md) -- Web app: [StreamXWeb/README.md](./StreamXWeb/README.md) +- [API docs](http://localhost:8000/docs) +- [OpenAPI spec](http://localhost:8000/openapi.json) +- [Service status](http://localhost:8000/health) diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..957cb89 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,185 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "streamgo/internal/config" + "streamgo/internal/database" + "streamgo/internal/logger" + "streamgo/internal/repository" + "streamgo/internal/server" + "streamgo/internal/services" + "streamgo/internal/telegram" + telegramcmd "streamgo/internal/telegram/cmd" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +var log = logger.New("main") + +func main() { + fmt.Println("==================================================") + fmt.Println(" StreamGO - High Performance Media API ") + fmt.Println("==================================================") + + cfg := config.Load() + log.Infof("loaded config: PORT=%s, DB=%s, API_LOGS=%v", cfg.Port, cfg.DatabaseName, cfg.APILogs) + + cfgMgr := config.NewManager(cfg) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var dbClient *database.Client + if cfg.MongoURI != "" { + dbCtx, dbCancel := context.WithTimeout(ctx, 10*time.Second) + client, err := database.Connect(dbCtx, cfg.MongoURI, cfg.DatabaseName) + dbCancel() + if err != nil { + log.Warnf("MongoDB connection failed: %v", err) + log.Warn("Continuing with MongoDB in disabled state...") + } else { + dbClient = client + defer func() { + closeCtx, closeCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer closeCancel() + _ = dbClient.Close(closeCtx) + }() + + // Synchronize dynamic bot settings from MongoDB collection botsettings (doc: bot_config) + syncCtx, syncCancel := context.WithTimeout(ctx, 10*time.Second) + if err := cfgMgr.LoadFromDB(syncCtx, dbClient.Database); err != nil { + log.Warnf("Failed to sync bot_config from MongoDB: %v", err) + } + syncCancel() + } + } + + coverSearch := services.NewCoverSearchService() + lyricsSvc := services.NewLyricsEnrichmentService(cfg) + dedupSvc := services.NewDedupService(dbClient) + accessFilter := services.NewAccessFilter(cfg, dbClient) + cfgMgr.OnUpdate(func(key string, val any) { + if key == "FILTER_MODE" { + if m, ok := val.(int); ok { + accessFilter.SetMode(m) + } + } + }) + + r2Storage := services.NewR2StorageService(cfg) + enrichSvc := services.NewEnrichmentService(dbClient, coverSearch, lyricsSvc) + if r2Storage.IsConfigured() { + enrichSvc.SetR2Storage(r2Storage) + log.Infof("Cloudflare R2 storage configured for artwork (bucket: %s)", cfg.R2BucketName) + } else { + log.Info("Cloudflare R2 not configured; using free online artwork enrichment (iTunes/Deezer)") + } + + if dbClient != nil { + workers := cfg.EnrichmentWorkers + if workers <= 0 { + workers = 2 + } + enrichSvc.Start(ctx, workers) + + // Fix any existing tracks where audio format was saved as "m4a" but has lossless bit depth (ALAC) + go func() { + migCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + col := dbClient.Database.Collection("audioTracks") + res, err := col.UpdateMany(migCtx, bson.M{ + "audio.type": "m4a", + "audio.bit_depth": bson.M{"$gt": 0}, + }, bson.M{ + "$set": bson.M{"audio.type": "alac"}, + }) + if err == nil && res.ModifiedCount > 0 { + log.Infof("[migration] Normalized %d lossless ALAC track(s) from 'm4a' to 'alac'", res.ModifiedCount) + } + }() + } + + var tgService *telegram.Service + if cfg.ApiID > 0 && cfg.ApiHash != "" { + svc, err := telegram.New(cfg) + if err != nil { + log.Warnf("Failed to initialize Telegram service: %v", err) + } else { + tgService = svc + enrichSvc.SetDownloader(tgService) + listener := telegram.NewIngestionListener(cfg, dbClient, accessFilter, dedupSvc, enrichSvc.TriggerEnrich) + listener.SetTelegramService(tgService) + + var authSvc *services.AuthService + var accessSvc *services.AccessControlService + var trackSvc *services.TrackService + + if dbClient != nil { + userRepo := repository.NewUserRepository(dbClient) + accessRepo := repository.NewAccessControlRepository(dbClient) + trackRepo := repository.NewTrackRepository(dbClient) + authSvc = services.NewAuthService(cfg, userRepo) + authSvc.SetAccessControlRepository(accessRepo) + accessSvc = services.NewAccessControlService(accessRepo) + trackSvc = services.NewTrackService(trackRepo) + } + + cmdHandler := telegramcmd.New(cfgMgr, dbClient, tgService, listener, authSvc, accessSvc, accessFilter, trackSvc) + cmdHandler.SetupDispatcher(&tgService.Dispatcher) + + log.Info("Starting Telegram MTProto multi-client pool in background...") + go func() { + if err := tgService.Start(ctx); err != nil { + log.Errorf("Telegram client start failed: %v", err) + } + }() + defer tgService.Stop() + } + } else { + log.Info("Telegram credentials (API_ID/API_HASH) not configured; skipping Telegram init.") + } + + srv := server.New(cfg, dbClient, tgService, accessFilter) + httpServer := &http.Server{ + Addr: fmt.Sprintf(":%s", cfg.Port), + Handler: srv.Router, + ReadTimeout: 30 * time.Second, + WriteTimeout: 0, + IdleTimeout: 120 * time.Second, + } + + go func() { + log.Infof("HTTP server listening on http://0.0.0.0:%s", cfg.Port) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("HTTP server error: %v", err) + } + }() + + signal.Ignore(syscall.SIGHUP) + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + sig := <-quit + + log.Infof("received shutdown signal (%s), starting graceful shutdown...", sig) + cancel() // Concurrently signals background workers (enrichment, telegram) to abort + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + + if err := httpServer.Shutdown(shutdownCtx); err != nil { + log.Errorf("HTTP server forced shutdown error: %v", err) + } else { + log.Info("HTTP server shutdown cleanly.") + } + + enrichSvc.Stop() + log.Info("StreamGO stopped. Goodbye!") +} diff --git a/config.py b/config.py deleted file mode 100644 index 9c0d714..0000000 --- a/config.py +++ /dev/null @@ -1,180 +0,0 @@ -import json -import os -import re -from pathlib import Path - -_env_path = Path(__file__).resolve().parent / ".env" -try: - from dotenv import load_dotenv - - if _env_path.exists(): - load_dotenv(dotenv_path=_env_path, override=False) - else: - load_dotenv(override=False) -except ImportError: - if _env_path.exists(): - with open(_env_path, "r", encoding="utf-8") as _f: - for _line in _f: - _line = _line.strip() - if not _line or _line.startswith("#") or "=" not in _line: - continue - _k, _, _v = _line.partition("=") - _k = _k.strip() - _v = _v.strip() - if (_v.startswith('"') and _v.endswith('"')) or (_v.startswith("'") and _v.endswith("'")): - _v = _v[1:-1] - if _k and _k not in os.environ: - os.environ[_k] = _v - - -def _get_str(key: str, default: str = "") -> str: - val = os.getenv(key) - if val is None: - return default - return str(val).strip() - - -def _get_int(key: str, default: int = 0) -> int: - val = os.getenv(key) - if val is None or not str(val).strip(): - return default - try: - return int(str(val).strip()) - except (TypeError, ValueError): - return default - - -def _get_bool(key: str, default: bool = False) -> bool: - val = os.getenv(key) - if val is None or not str(val).strip(): - return default - return str(val).strip().lower() in ("true", "1", "yes", "y", "on") - - -def _get_list_int(key: str, default: list | None = None) -> list[int]: - if default is None: - default = [] - val = os.getenv(key) - if val is None or not str(val).strip(): - return default - s = str(val).strip() - if s.startswith("["): - try: - parsed = json.loads(s) - if isinstance(parsed, list): - return [int(x) for x in parsed if str(x).strip().lstrip("-").isdigit()] - except Exception: - pass - parts = re.split(r"[\s,]+", s) - out = [int(p) for p in parts if p.strip().lstrip("-").isdigit()] - return out if out else default - - -def _get_id_or_list(key: str, default: int | list = 0) -> int | list: - val = os.getenv(key) - if val is None or not str(val).strip(): - return default - s = str(val).strip() - if s.startswith("["): - try: - parsed = json.loads(s) - if isinstance(parsed, list): - return [int(x) for x in parsed if str(x).strip().lstrip("-").isdigit()] - except Exception: - pass - try: - return int(s) - except (TypeError, ValueError): - return default - - -def _get_filter_mode(key: str, default: int = 1) -> int | str: - val = os.getenv(key) - if val is None or not str(val).strip(): - return default - s = str(val).strip() - if s.isdigit(): - return int(s) - return s - - -def _get_chat_topic(key: str, default: int = 0) -> int | str: - val = os.getenv(key) - if val is None or not str(val).strip(): - return default - s = str(val).strip() - if s.lower() == "all": - return "all" - try: - return int(s) - except (TypeError, ValueError): - return s - - -# Core -BOT_TOKEN = _get_str("BOT_TOKEN") -API_ID = _get_int("API_ID", 0) -API_HASH = _get_str("API_HASH") -MONGO_URI = _get_str("MONGO_URI") -DATABASE_NAME = _get_str("DATABASE_NAME", "StreamX") -OWNER_ID = _get_id_or_list("OWNER_ID", 0) -SUDO_USERS = _get_list_int("SUDO_USERS", []) -SECRET_KEY = _get_str("SECRET_KEY") -GITHUB_TOKEN = _get_str("GITHUB_TOKEN") -ONLY_API = _get_bool("ONLY_API", False) - -# Firebase -FIREBASE_CREDENTIALS = _get_str("FIREBASE_CREDENTIALS") - -# Debug -DEBUG = _get_bool("DEBUG", False) - -# Thumbnail Generation -COLLEGE = _get_bool("COLLEGE", True) -TEXT_COLOR = _get_str("TEXT_COLOR", "#FFFFFF") -YOUTUBE_COVER_SEARCH = _get_bool("YOUTUBE_COVER_SEARCH", True) - -# API & Web -CORS_ORIGIN = _get_str("CORS_ORIGIN", "*") -CORS_ORIGINS = _get_str("CORS_ORIGINS", CORS_ORIGIN) -COOKIE_SECURE = _get_bool("COOKIE_SECURE", True) -COOKIE_SAMESITE = _get_str("COOKIE_SAMESITE", "none") - -# Userbot -SESSION_STRING = _get_str("SESSION_STRING") -SOURCE_CHANNEL_IDS = _get_list_int("SOURCE_CHANNEL_IDS", []) -CHAT_TOPIC = _get_chat_topic("CHAT_TOPIC", 0) -USERBOT_COOLDOWN_SEC = float(os.getenv("USERBOT_COOLDOWN_SEC", "0.2")) -USERBOT_BATCH_SIZE = int(os.getenv("USERBOT_BATCH_SIZE", "50")) -USERBOT_BATCH_COOLDOWN_SEC = float(os.getenv("USERBOT_BATCH_COOLDOWN_SEC", "1.0")) -USERBOT_DUMP_MODE = str(os.getenv("USERBOT_DUMP_MODE", "FORWARD") or "FORWARD").strip().upper() -USERBOT_CAPTION_MODE = str(os.getenv("USERBOT_CAPTION_MODE", "LAST") or "LAST").strip().upper() -ENRICHMENT_WORKERS = int(os.getenv("ENRICHMENT_WORKERS", os.getenv("PROCESSING_CONTENT", "2"))) - -# Misc -CHANNEL_ID = _get_int("CHANNEL_ID", 0) -DUMP_CHANNEL_ID = _get_int("DUMP_CHANNEL_ID", 0) -FILTER_MODE = _get_filter_mode("FILTER_MODE", 1) -COLLABORATOR_ID = _get_list_int("COLLABORATOR_ID", []) -COLLABORATOR_IDS = _get_list_int("COLLABORATOR_IDS", COLLABORATOR_ID) - -# Lyrics API -LRCLIB = _get_bool("LRCLIB", False) -MUSIXMATCH = _get_bool("MUSIXMATCH", True) -BETTERLYRICS = _get_bool("BETTERLYRICS", True) -KUGOU = _get_bool("KUGOU", True) - -# Spotify -SPOTIFY_CLIENT_ID = _get_str("SPOTIFY_CLIENT_ID") -SPOTIFY_CLIENT_SECRET = _get_str("SPOTIFY_CLIENT_SECRET") - -# Multi-Clients -MULTI_CLIENTS = _get_bool("MULTI_CLIENTS", False) -MULTI_CLIENTS_1 = _get_str("MULTI_CLIENTS_1") -MULTI_CLIENTS_2 = _get_str("MULTI_CLIENTS_2") -MULTI_CLIENTS_3 = _get_str("MULTI_CLIENTS_3") -MULTI_CLIENTS_4 = _get_str("MULTI_CLIENTS_4") - -for _env_k, _env_v in os.environ.items(): - if _env_k.startswith("MULTI_CLIENTS_") and _env_k not in globals(): - globals()[_env_k] = _env_v \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 551e7b7..7c929e6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,17 +4,11 @@ services: build: context: . dockerfile: Dockerfile - env_file: - .env - ports: - "${PORT:-8000}:${PORT:-8000}" - volumes: - - ./.env:/app/.env:ro - - ./cookies:/app/cookies - ./stream_media:/app/stream_media - - ./dist:/app/dist:ro - - restart: on-failure \ No newline at end of file + - ./cookies:/app/cookies + restart: unless-stopped diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..75891f8 --- /dev/null +++ b/go.mod @@ -0,0 +1,58 @@ +module streamgo + +go 1.25.0 + +require ( + github.com/coder/websocket v1.8.15 + github.com/go-chi/chi/v5 v5.3.2 + github.com/go-chi/cors v1.2.2 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/gotd/td v0.162.0 + github.com/joho/godotenv v1.5.1 + go.mongodb.org/mongo-driver/v2 v2.9.1 + golang.org/x/crypto v0.54.0 + golang.org/x/text v0.40.0 +) + +require ( + github.com/andybalholm/brotli v1.2.1 + github.com/cenkalti/backoff/v4 v4.3.0 + github.com/cespare/xxhash/v2 v2.3.0 + github.com/dlclark/regexp2 v1.12.0 + github.com/fatih/color v1.19.0 + github.com/ghodss/yaml v1.0.0 + github.com/go-faster/errors v0.8.0 + github.com/go-faster/jx v1.2.0 + github.com/go-faster/xor v1.0.0 + github.com/go-faster/yaml v0.4.6 + github.com/google/uuid v1.6.0 + github.com/gotd/ige v0.3.0 + github.com/gotd/log v0.1.0 + github.com/gotd/neo v0.1.5 + github.com/klauspost/compress v1.19.2 + github.com/mattn/go-colorable v0.1.15 + github.com/mattn/go-isatty v0.0.22 + github.com/ogen-go/ogen v1.23.0 + github.com/refraction-networking/utls v1.8.2 + github.com/segmentio/asm v1.2.1 + github.com/shopspring/decimal v1.4.0 + github.com/xdg-go/pbkdf2 v1.0.0 + github.com/xdg-go/scram v1.2.0 + github.com/xdg-go/stringprep v1.0.4 + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 + github.com/yuin/goldmark v1.8.5 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/metric v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 + go.uber.org/atomic v1.11.0 + go.uber.org/multierr v1.11.0 + go.uber.org/zap v1.28.0 + golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 + golang.org/x/mod v0.38.0 + golang.org/x/net v0.57.0 + golang.org/x/sync v0.22.0 + golang.org/x/sys v0.47.0 + golang.org/x/tools v0.48.0 + gopkg.in/yaml.v2 v2.4.0 + rsc.io/qr v0.2.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2a016dd --- /dev/null +++ b/go.sum @@ -0,0 +1,156 @@ +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-faster/errors v0.8.0 h1:9T9eJrM+72dFk7n4DfhuaDDe6cyuFCSW2oNUkN77Yqc= +github.com/go-faster/errors v0.8.0/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI= +github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE= +github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38= +github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= +github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I= +github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gotd/ige v0.3.0 h1:4f6LEHWsVDLBG0bT9wWG2/9TZb5aWm265G8ZlTXmRRU= +github.com/gotd/ige v0.3.0/go.mod h1:FE9bTaQtvfArizAcZuI4sS6gXaEUBmixdUufVHoCKac= +github.com/gotd/log v0.1.0 h1:4LJUEvafD1xtBwx2QkrlzFnRgbYXTlWqJPDi8BvrLbU= +github.com/gotd/log v0.1.0/go.mod h1:5ilhdu1Ux0QvDY/FF3Ojfw24Ws3SlCtyLwOpXy8KYXs= +github.com/gotd/log/logzap v0.1.1 h1:O6l7d8HUbODe+UMcrM47eXYDwdJ6RNmpQejLjrlcEIQ= +github.com/gotd/log/logzap v0.1.1/go.mod h1:5ObZkITbfhbsBOLzBkzmMk9QxXc0eNQpimau7zRL+Y8= +github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ= +github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ= +github.com/gotd/td v0.162.0 h1:3pUzBsEbb0I5QquvfSkXihkVBXI+QTtdd/upijjUbkw= +github.com/gotd/td v0.162.0/go.mod h1:ZsGbErIos7XHF7HL0VlsjL6mmX4b1JpinHfDb7gOdvs= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/ogen-go/ogen v1.23.0 h1:QaWeKm2KZ2zy7NkqqO1Vdl5idNqlG+svxdgwVAX+zbo= +github.com/ogen-go/ogen v1.23.0/go.mod h1:bwwvC3AmCV+LrL5lazyQwwof90402mdcSyI0FOzzpfM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= +github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.mongodb.org/mongo-driver/v2 v2.9.1 h1:jewiFs2m1/VOQp8qhFshX6hWZ+EAXDhZHXExAUMcOgQ= +go.mongodb.org/mongo-driver/v2 v2.9.1/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/internal/api/handlers/access_control.go b/internal/api/handlers/access_control.go new file mode 100644 index 0000000..abf8e97 --- /dev/null +++ b/internal/api/handlers/access_control.go @@ -0,0 +1,620 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/api/middleware" + "streamgo/internal/config" + "streamgo/internal/models" + "streamgo/internal/services" +) + +// AccessControlHandler handles administrative access policy, Telegram membership, invites, and user control. +type AccessControlHandler struct { + accessSvc *services.AccessControlService + authSvc *services.AuthService + cfg *config.Config + httpClient *http.Client +} + +// NewAccessControlHandler creates an AccessControlHandler. +func NewAccessControlHandler(accessSvc *services.AccessControlService, authSvc *services.AuthService, cfg *config.Config) *AccessControlHandler { + return &AccessControlHandler{ + accessSvc: accessSvc, + authSvc: authSvc, + cfg: cfg, + httpClient: &http.Client{Timeout: 8 * time.Second}, + } +} + +// Routes mounts /admin/access endpoints. +func (h *AccessControlHandler) Routes(r chi.Router) { + optionalAuth := middleware.OptionalAuth(h.authSvc) + + r.Route("/admin/access", func(sub chi.Router) { + sub.Use(optionalAuth) + + // Policy + sub.Get("/policy", h.GetPolicy) + sub.Patch("/policy", h.PatchPolicy) + + // Required Telegram chats + sub.Post("/required-chats", h.AddRequiredChat) + sub.Delete("/required-chats/{chat_id}", h.RemoveRequiredChat) + sub.Delete("/required-chats", h.RemoveRequiredChat) + + // Invite codes + sub.Get("/invites", h.ListInvites) + sub.Post("/invites", h.CreateInvite) + sub.Delete("/invites/{code}", h.RevokeInvite) + sub.Delete("/invites", h.RevokeInvite) + + // Allowlist + sub.Get("/allowlist", h.ListAllowlist) + sub.Post("/allowlist", h.AddAllowlist) + sub.Delete("/allowlist/{user_id}", h.RemoveAllowlist) + sub.Delete("/allowlist", h.RemoveAllowlist) + + // Bypass + sub.Get("/bypass", h.ListBypass) + sub.Post("/bypass", h.AddBypass) + sub.Delete("/bypass/{user_id}", h.RemoveBypass) + sub.Delete("/bypass", h.RemoveBypass) + + // User accounts + sub.Get("/users", h.ListUsers) + sub.Post("/users/{id}/lock", h.LockUser) + sub.Post("/users/{id}/unlock", h.UnlockUser) + sub.Post("/users/{id}/revoke-sessions", h.RevokeSessions) + + // Reverify Telegram membership + sub.Post("/reverify", h.Reverify) + }) +} + +// checkAdmin verifies that the caller has admin permissions (owner, sudo, or valid guest password). +func (h *AccessControlHandler) checkAdmin(r *http.Request) (int64, error) { + if middleware.IsGuest(r.Context()) { + return 0, nil + } + + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + return 0, fmt.Errorf("authentication required") + } + + if len(h.cfg.OwnerIDs) == 0 && len(h.cfg.SudoUsers) == 0 { + return userID, nil + } + + for _, oid := range h.cfg.OwnerIDs { + if oid == userID { + return userID, nil + } + } + for _, sid := range h.cfg.SudoUsers { + if sid == userID { + return userID, nil + } + } + + return 0, fmt.Errorf("forbidden: administrator privileges required") +} + +// GetPolicy handles GET /admin/access/policy. +func (h *AccessControlHandler) GetPolicy(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + policy, err := h.accessSvc.GetPolicy(r.Context()) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "policy": policy, + }) +} + +// PatchPolicy handles PATCH /admin/access/policy. +func (h *AccessControlHandler) PatchPolicy(w http.ResponseWriter, r *http.Request) { + adminID, err := h.checkAdmin(r) + if err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + var patch models.PolicyPatch + if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid json payload") + return + } + + updated, err := h.accessSvc.UpdatePolicy(r.Context(), patch, adminID) + if err != nil { + api.RespondError(w, http.StatusBadRequest, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "policy": updated, + }) +} + +// AddRequiredChat handles POST /admin/access/required-chats. +func (h *AccessControlHandler) AddRequiredChat(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + var chat models.RequiredChat + if err := json.NewDecoder(r.Body).Decode(&chat); err != nil || chat.ChatID == 0 { + api.RespondError(w, http.StatusBadRequest, "chat_id is required") + return + } + + policy, err := h.accessSvc.AddRequiredChat(r.Context(), chat) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "required_chats": policy.RequiredChats, + }) +} + +// RemoveRequiredChat handles DELETE /admin/access/required-chats/{chat_id}. +func (h *AccessControlHandler) RemoveRequiredChat(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + chatIDStr := chi.URLParam(r, "chat_id") + if chatIDStr == "" { + chatIDStr = r.URL.Query().Get("chat_id") + } + + chatID, _ := strconv.ParseInt(chatIDStr, 10, 64) + if chatID == 0 { + api.RespondError(w, http.StatusBadRequest, "chat_id is required") + return + } + + policy, err := h.accessSvc.RemoveRequiredChat(r.Context(), chatID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "required_chats": policy.RequiredChats, + }) +} + +// ListInvites handles GET /admin/access/invites. +func (h *AccessControlHandler) ListInvites(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + includeDead := api.ParseQueryBool(r, "include_dead", false) + invites, err := h.accessSvc.ListInvites(r.Context(), includeDead) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "invites": invites, + }) +} + +// CreateInvite handles POST /admin/access/invites. +func (h *AccessControlHandler) CreateInvite(w http.ResponseWriter, r *http.Request) { + adminID, err := h.checkAdmin(r) + if err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + var req models.InviteCreateRequest + _ = json.NewDecoder(r.Body).Decode(&req) + + inv, err := h.accessSvc.CreateInvite(r.Context(), adminID, req.MaxUses, req.TTLDays, req.Note) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "invite": inv, + }) +} + +// RevokeInvite handles DELETE /admin/access/invites/{code}. +func (h *AccessControlHandler) RevokeInvite(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + code := chi.URLParam(r, "code") + if code == "" { + code = r.URL.Query().Get("code") + } + code = strings.ToUpper(strings.TrimSpace(code)) + if code == "" { + api.RespondError(w, http.StatusBadRequest, "invite code is required") + return + } + + revoked := h.accessSvc.RevokeInvite(r.Context(), code) + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "revoked": revoked, + "code": code, + }) +} + +// ListAllowlist handles GET /admin/access/allowlist. +func (h *AccessControlHandler) ListAllowlist(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + list, err := h.accessSvc.ListAllowlist(r.Context()) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "allowlist": list, + }) +} + +// AddAllowlist handles POST /admin/access/allowlist. +func (h *AccessControlHandler) AddAllowlist(w http.ResponseWriter, r *http.Request) { + adminID, err := h.checkAdmin(r) + if err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + var req models.AllowlistRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 { + api.RespondError(w, http.StatusBadRequest, "valid user_id is required") + return + } + + if err := h.accessSvc.AddToAllowlist(r.Context(), req.UserID, adminID, req.Note); err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": req.UserID, + "message": "User added to allowlist", + }) +} + +// RemoveAllowlist handles DELETE /admin/access/allowlist/{user_id}. +func (h *AccessControlHandler) RemoveAllowlist(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + uidStr := chi.URLParam(r, "user_id") + if uidStr == "" { + uidStr = r.URL.Query().Get("user_id") + } + uid, _ := strconv.ParseInt(uidStr, 10, 64) + if uid <= 0 { + api.RespondError(w, http.StatusBadRequest, "valid user_id is required") + return + } + + removed := h.accessSvc.RemoveFromAllowlist(r.Context(), uid) + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "removed": removed, + "user_id": uid, + }) +} + +// ListBypass handles GET /admin/access/bypass. +func (h *AccessControlHandler) ListBypass(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + list, err := h.accessSvc.ListBypass(r.Context()) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "bypass": list, + }) +} + +// AddBypass handles POST /admin/access/bypass. +func (h *AccessControlHandler) AddBypass(w http.ResponseWriter, r *http.Request) { + adminID, err := h.checkAdmin(r) + if err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + var req models.AllowlistRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 { + api.RespondError(w, http.StatusBadRequest, "valid user_id is required") + return + } + + if err := h.accessSvc.AddToBypass(r.Context(), req.UserID, adminID, req.Note); err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": req.UserID, + "message": "User added to bypass list", + }) +} + +// RemoveBypass handles DELETE /admin/access/bypass/{user_id}. +func (h *AccessControlHandler) RemoveBypass(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + uidStr := chi.URLParam(r, "user_id") + if uidStr == "" { + uidStr = r.URL.Query().Get("user_id") + } + uid, _ := strconv.ParseInt(uidStr, 10, 64) + if uid <= 0 { + api.RespondError(w, http.StatusBadRequest, "valid user_id is required") + return + } + + removed := h.accessSvc.RemoveFromBypass(r.Context(), uid) + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "removed": removed, + "user_id": uid, + }) +} + +// ListUsers handles GET /admin/access/users. +func (h *AccessControlHandler) ListUsers(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + query := r.URL.Query().Get("q") + status := r.URL.Query().Get("status") + limit := api.ParseQueryInt(r, "limit", 50) + skip := api.ParseQueryInt(r, "skip", 0) + + resp, err := h.accessSvc.ListUsers(r.Context(), query, status, limit, skip) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "data": resp, + }) +} + +// LockUser handles POST /admin/access/users/{id}/lock. +func (h *AccessControlHandler) LockUser(w http.ResponseWriter, r *http.Request) { + adminID, err := h.checkAdmin(r) + if err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + idStr := chi.URLParam(r, "id") + userID, _ := strconv.ParseInt(idStr, 10, 64) + if userID <= 0 { + api.RespondError(w, http.StatusBadRequest, "valid user id is required") + return + } + + var req models.LockUserRequest + _ = json.NewDecoder(r.Body).Decode(&req) + if req.Reason == "" { + req.Reason = "Account locked by administrator" + } + + if err := h.accessSvc.LockUser(r.Context(), userID, req.Reason, adminID, req.RevokeSessions); err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": userID, + "status": "locked", + "reason": req.Reason, + }) +} + +// UnlockUser handles POST /admin/access/users/{id}/unlock. +func (h *AccessControlHandler) UnlockUser(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + idStr := chi.URLParam(r, "id") + userID, _ := strconv.ParseInt(idStr, 10, 64) + if userID <= 0 { + api.RespondError(w, http.StatusBadRequest, "valid user id is required") + return + } + + if err := h.accessSvc.UnlockUser(r.Context(), userID); err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": userID, + "status": "active", + }) +} + +// RevokeSessions handles POST /admin/access/users/{id}/revoke-sessions. +func (h *AccessControlHandler) RevokeSessions(w http.ResponseWriter, r *http.Request) { + if _, err := h.checkAdmin(r); err != nil { + api.RespondError(w, http.StatusForbidden, err.Error()) + return + } + + idStr := chi.URLParam(r, "id") + userID, _ := strconv.ParseInt(idStr, 10, 64) + if userID <= 0 { + api.RespondError(w, http.StatusBadRequest, "valid user id is required") + return + } + + version, err := h.accessSvc.RevokeSessions(r.Context(), userID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": userID, + "token_version": version, + "message": "All active sessions revoked successfully", + }) +} + +// Reverify handles POST /admin/access/reverify. +// Checks membership status of a given user (or caller) in configured required Telegram chats. +func (h *AccessControlHandler) Reverify(w http.ResponseWriter, r *http.Request) { + var targetUserID int64 + var req struct { + UserID int64 `json:"user_id"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if req.UserID > 0 { + targetUserID = req.UserID + } else if uidStr := r.URL.Query().Get("user_id"); uidStr != "" { + targetUserID, _ = strconv.ParseInt(uidStr, 10, 64) + } else if uid, ok := middleware.GetUserID(r.Context()); ok { + targetUserID = uid + } + + policy, err := h.accessSvc.GetPolicy(r.Context()) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + type ChatStatus struct { + ChatID int64 `json:"chat_id"` + Title string `json:"title,omitempty"` + IsMember bool `json:"is_member"` + Status string `json:"status"` + InviteLink string `json:"invite_link,omitempty"` + } + + results := make([]ChatStatus, 0, len(policy.RequiredChats)) + allPassed := true + + for _, chat := range policy.RequiredChats { + st := ChatStatus{ + ChatID: chat.ChatID, + Title: chat.Title, + InviteLink: chat.InviteLink, + Status: "unknown", + } + + if h.cfg.BotToken != "" && targetUserID > 0 { + url := fmt.Sprintf("https://api.telegram.org/bot%s/getChatMember?chat_id=%d&user_id=%d", + h.cfg.BotToken, chat.ChatID, targetUserID) + resp, err := h.httpClient.Get(url) + if err == nil && resp != nil { + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + var tgResp struct { + OK bool `json:"ok"` + Result struct { + Status string `json:"status"` + } `json:"result"` + } + if json.Unmarshal(bodyBytes, &tgResp) == nil && tgResp.OK { + st.Status = tgResp.Result.Status + if st.Status == "member" || st.Status == "administrator" || st.Status == "creator" { + st.IsMember = true + } else { + st.IsMember = false + allPassed = false + } + } else { + st.Status = "error_or_not_found" + allPassed = false + } + } else { + st.Status = "check_unreachable" + allPassed = false + } + } else { + // No bot token configured or user ID 0, mark as bypass/mock passed + st.IsMember = true + st.Status = "bypassed_no_bot_token" + } + + results = append(results, st) + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": targetUserID, + "verified": allPassed, + "enforced": policy.EnforceMembership, + "chats": results, + "chat_count": len(results), + }) +} diff --git a/internal/api/handlers/albums.go b/internal/api/handlers/albums.go new file mode 100644 index 0000000..24cf474 --- /dev/null +++ b/internal/api/handlers/albums.go @@ -0,0 +1,97 @@ +package handlers + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/services" +) + +// AlbumHandler handles HTTP endpoints for albums. +type AlbumHandler struct { + svc *services.ArtistAlbumService +} + +// NewAlbumHandler creates an AlbumHandler instance. +func NewAlbumHandler(svc *services.ArtistAlbumService) *AlbumHandler { + return &AlbumHandler{svc: svc} +} + +// Routes mounts album endpoints on Chi router. +func (h *AlbumHandler) Routes(r chi.Router) { + r.Get("/albums", h.List) + r.Get("/albums/{id}", h.GetByID) + r.Get("/albums/{id}/tracks", h.GetTracks) +} + +// List handles GET /albums with pagination and optional artist filter. +func (h *AlbumHandler) List(w http.ResponseWriter, r *http.Request) { + page := api.ParseQueryInt(r, "page", 1) + perPage := api.ParseQueryInt(r, "per_page", 50) + if limit := api.ParseQueryInt(r, "limit", 0); limit > 0 { + perPage = limit + } + refresh := api.ParseQueryBool(r, "refresh", false) + artistFilter := strings.TrimSpace(r.URL.Query().Get("artist")) + + resp, err := h.svc.ListAlbums(r.Context(), page, perPage, artistFilter, refresh) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// GetByID handles GET /albums/{id}. +func (h *AlbumHandler) GetByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "album id is required") + return + } + + detail, err := h.svc.GetAlbumDetail(r.Context(), id) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + if detail == nil { + api.RespondError(w, http.StatusNotFound, "album not found") + return + } + + api.RespondJSON(w, http.StatusOK, detail) +} + +// GetTracks handles GET /albums/{id}/tracks. +func (h *AlbumHandler) GetTracks(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "album id is required") + return + } + + page := api.ParseQueryInt(r, "page", 1) + perPage := api.ParseQueryInt(r, "per_page", 50) + if limit := api.ParseQueryInt(r, "limit", 0); limit > 0 { + perPage = limit + } + + tracks, total, err := h.svc.GetAlbumTracksPaginated(r.Context(), id, page, perPage) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "page": page, + "per_page": perPage, + "total": total, + "items": tracks, + }) +} diff --git a/internal/api/handlers/artists.go b/internal/api/handlers/artists.go new file mode 100644 index 0000000..e7a09e7 --- /dev/null +++ b/internal/api/handlers/artists.go @@ -0,0 +1,96 @@ +package handlers + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/services" +) + +// ArtistHandler handles endpoints for artist discovery and profiles. +type ArtistHandler struct { + svc *services.ArtistAlbumService +} + +// NewArtistHandler creates an ArtistHandler instance. +func NewArtistHandler(svc *services.ArtistAlbumService) *ArtistHandler { + return &ArtistHandler{svc: svc} +} + +// Routes mounts artist endpoints on the Chi router. +func (h *ArtistHandler) Routes(r chi.Router) { + r.Get("/artists", h.List) + r.Get("/artists/{id}", h.GetByID) + r.Get("/artists/{id}/tracks", h.GetTracks) +} + +// List handles GET /artists with pagination. +func (h *ArtistHandler) List(w http.ResponseWriter, r *http.Request) { + page := api.ParseQueryInt(r, "page", 1) + perPage := api.ParseQueryInt(r, "per_page", 50) + if limit := api.ParseQueryInt(r, "limit", 0); limit > 0 { + perPage = limit + } + refresh := api.ParseQueryBool(r, "refresh", false) + + resp, err := h.svc.ListArtists(r.Context(), page, perPage, refresh) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// GetByID handles GET /artists/{id}. +func (h *ArtistHandler) GetByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "artist id is required") + return + } + + detail, err := h.svc.GetArtistDetail(r.Context(), id) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + if detail == nil { + api.RespondError(w, http.StatusNotFound, "artist not found") + return + } + + api.RespondJSON(w, http.StatusOK, detail) +} + +// GetTracks handles GET /artists/{id}/tracks. +func (h *ArtistHandler) GetTracks(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "artist id is required") + return + } + + page := api.ParseQueryInt(r, "page", 1) + perPage := api.ParseQueryInt(r, "per_page", 50) + if limit := api.ParseQueryInt(r, "limit", 0); limit > 0 { + perPage = limit + } + + tracks, total, err := h.svc.GetArtistTracksPaginated(r.Context(), id, page, perPage) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "page": page, + "per_page": perPage, + "total": total, + "items": tracks, + }) +} diff --git a/internal/api/handlers/auth.go b/internal/api/handlers/auth.go new file mode 100644 index 0000000..9595282 --- /dev/null +++ b/internal/api/handlers/auth.go @@ -0,0 +1,1023 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/api/middleware" + "streamgo/internal/config" + "streamgo/internal/models" + "streamgo/internal/services" +) + +// AuthHandler handles authentication routes for Telegram WebApp, widgets, OAuth, and password login/register. +type AuthHandler struct { + cfg *config.Config + authSvc *services.AuthService +} + +// NewAuthHandler creates an AuthHandler instance. +func NewAuthHandler(cfg *config.Config, authSvc *services.AuthService) *AuthHandler { + return &AuthHandler{ + cfg: cfg, + authSvc: authSvc, + } +} + +// Routes registers authentication endpoints on Chi router. +func (h *AuthHandler) Routes(r chi.Router) { + // Telegram WebApp & Web routes + r.Post("/auth/telegram", h.TelegramLogin) + r.Post("/auth/tg/login", h.TgLogin) + r.Post("/webapp/verify", h.TelegramLogin) + + // Password login & Register + r.Post("/auth/login", h.UniversalLogin) + r.Post("/auth/register", h.Register) + r.Post("/auth/create", h.DirectRegister) + r.Post("/auth/validate", h.ValidateOTP) + + // Server / Guest password & Setup endpoints + r.Get("/auth/setup/status", h.SetupStatus) + r.Post("/auth/setup", h.SetupPassword) + r.Post("/auth/password", h.ServerPasswordLogin) + r.Post("/auth/password/change", h.ChangePassword) + + // Cookie & session management + r.Post("/auth/cookie", h.SetCookie) + r.Post("/auth/logout", h.Logout) + + // Telegram Login Widget & Token validation + r.Post("/auth/telegram/widget", h.WidgetLogin) + r.Post("/auth/telegram/validate-token", h.ValidateToken) + + // Telegram OAuth / OIDC endpoints + r.Get("/auth/telegram/config", h.Config) + r.Get("/webapp/config", h.Config) + r.Get("/auth/telegram/start", h.TelegramStart) + r.Get("/auth/telegram/callback", h.TelegramCallback) + + // Direct Telegram Bot App Session login + r.Post("/auth/telegram/bot-session", h.CreateBotSession) + r.Get("/auth/telegram/bot-session/status", h.CheckBotSessionStatus) + + // Authenticated profile, credentials, and integrations routes + r.Group(func(sub chi.Router) { + sub.Use(middleware.RequireAuth(h.authSvc)) + sub.Get("/auth/me", h.Me) + sub.Post("/auth/credentials", h.SetCredentials) + sub.Post("/auth/integrations", h.UpdateIntegrations) + sub.Post("/auth/fcm-token", h.UpdateFCMToken) + }) +} + +// UniversalLogin handles POST /auth/login, supporting both password login and WebApp initData. +func (h *AuthHandler) UniversalLogin(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + // 1. If init_data is present, process as Telegram WebApp login + if initData, ok := body["init_data"].(string); ok && strings.TrimSpace(initData) != "" { + req := models.TgLoginRequest{ + InitData: initData, + } + if u, ok := body["username"].(string); ok { + req.Username = u + } + if p, ok := body["password"].(string); ok { + req.Password = p + } + if inv, ok := body["invite_code"].(string); ok { + req.InviteCode = inv + } + + resp, err := h.authSvc.TgLogin(r.Context(), req) + if err != nil { + api.RespondError(w, http.StatusUnauthorized, err.Error()) + return + } + if r.URL.Query().Get("set_cookie") == "true" || r.URL.Query().Get("set_cookie") == "1" { + h.authSvc.SetAuthCookie(w, resp.Token) + } + api.RespondJSON(w, http.StatusOK, resp) + return + } + + // 2. Standard username + password login + username, _ := body["username"].(string) + password, _ := body["password"].(string) + if strings.TrimSpace(username) == "" || strings.TrimSpace(password) == "" { + api.RespondError(w, http.StatusBadRequest, "username and password are required") + return + } + + resp, err := h.authSvc.LoginWithPassword(r.Context(), username, password) + if err != nil { + if strings.Contains(err.Error(), "invalid username") { + api.RespondError(w, http.StatusBadRequest, err.Error()) + } else { + api.RespondError(w, http.StatusUnauthorized, err.Error()) + } + return + } + + if r.URL.Query().Get("set_cookie") == "true" || r.URL.Query().Get("set_cookie") == "1" { + h.authSvc.SetAuthCookie(w, resp.Token) + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": resp.UserID, + "token": resp.Token, + "first_name": resp.FirstName, + "username": resp.Username, + "profile_url": resp.ProfileURL, + "photo_url": resp.PhotoURL, + }) +} + +// TelegramLogin handles POST /auth/telegram and /webapp/verify. +func (h *AuthHandler) TelegramLogin(w http.ResponseWriter, r *http.Request) { + var initData string + + contentType := r.Header.Get("Content-Type") + if strings.Contains(contentType, "application/x-www-form-urlencoded") || strings.Contains(contentType, "multipart/form-data") { + _ = r.ParseForm() + initData = r.FormValue("init_data") + if initData == "" { + initData = r.FormValue("initData") + } + } else { + var payload models.TelegramWebAppRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err == nil { + initData = payload.InitData + } + } + + initData = strings.TrimSpace(initData) + if initData == "" { + api.RespondError(w, http.StatusBadRequest, "init_data is required") + return + } + + resp, err := h.authSvc.AuthenticateTelegram(r.Context(), initData) + if err != nil { + api.RespondError(w, http.StatusUnauthorized, err.Error()) + return + } + + if r.URL.Query().Get("set_cookie") == "true" || r.URL.Query().Get("set_cookie") == "1" { + h.authSvc.SetAuthCookie(w, resp.Token) + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// TgLogin handles POST /auth/tg/login. +func (h *AuthHandler) TgLogin(w http.ResponseWriter, r *http.Request) { + var payload models.TgLoginRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request payload") + return + } + + resp, err := h.authSvc.TgLogin(r.Context(), payload) + if err != nil { + if strings.Contains(err.Error(), "username already taken") { + api.RespondError(w, http.StatusConflict, err.Error()) + } else if strings.Contains(err.Error(), "invalid username") { + api.RespondError(w, http.StatusBadRequest, err.Error()) + } else { + api.RespondError(w, http.StatusUnauthorized, err.Error()) + } + return + } + + if r.URL.Query().Get("set_cookie") == "true" || r.URL.Query().Get("set_cookie") == "1" { + h.authSvc.SetAuthCookie(w, resp.Token) + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// Register handles POST /auth/register, supporting both direct account creation and Telegram OTP. +func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { + var payload models.RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + h.doRegister(w, r, payload) +} + +// DirectRegister handles POST /auth/create as an explicit route for direct user creation. +func (h *AuthHandler) DirectRegister(w http.ResponseWriter, r *http.Request) { + var payload models.RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + payload.UserID = 0 + h.doRegister(w, r, payload) +} + +func (h *AuthHandler) doRegister(w http.ResponseWriter, r *http.Request, payload models.RegisterRequest) { + resp, err := h.authSvc.RegisterAccount(r.Context(), payload) + if err != nil { + msg := err.Error() + if strings.Contains(strings.ToLower(msg), "already registered") || strings.Contains(strings.ToLower(msg), "already taken") { + api.RespondError(w, http.StatusConflict, msg) + } else if strings.Contains(msg, "invalid username") || strings.Contains(msg, "invalid user id") || strings.Contains(msg, "invite_") { + api.RespondError(w, http.StatusBadRequest, msg) + } else if strings.Contains(msg, "registration_closed") || strings.Contains(msg, "not_allowlisted") || strings.Contains(msg, "membership_required") { + api.RespondError(w, http.StatusForbidden, msg) + } else { + api.RespondError(w, http.StatusInternalServerError, msg) + } + return + } + + // If direct registration created the account, an auth token is issued immediately + if resp.Token != "" { + if r.URL.Query().Get("set_cookie") == "true" || r.URL.Query().Get("set_cookie") == "1" { + h.authSvc.SetAuthCookie(w, resp.Token) + } + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": resp.UserID, + "token": resp.Token, + "username": resp.Username, + "first_name": resp.FirstName, + "profile_url": resp.ProfileURL, + "photo_url": resp.PhotoURL, + }) + return + } + + // Telegram OTP registration pending + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "message": "OTP sent", + "user_id": resp.UserID, + "username": resp.Username, + "first_name": resp.FirstName, + "profile_url": resp.ProfileURL, + "photo_url": resp.PhotoURL, + }) +} + + +// ValidateOTP handles POST /auth/validate by verifying OTP code and activating account. +func (h *AuthHandler) ValidateOTP(w http.ResponseWriter, r *http.Request) { + var payload models.ValidateOTPRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + resp, err := h.authSvc.ValidateOTP(r.Context(), payload) + if err != nil { + msg := err.Error() + if strings.Contains(msg, "no pending registration") || strings.Contains(msg, "invite_") { + api.RespondError(w, http.StatusBadRequest, msg) + } else if strings.Contains(msg, "invalid OTP") || strings.Contains(msg, "session_revoked") { + api.RespondError(w, http.StatusUnauthorized, msg) + } else if strings.Contains(msg, "registration_closed") || strings.Contains(msg, "not_allowlisted") || strings.Contains(msg, "membership_required") { + api.RespondError(w, http.StatusForbidden, msg) + } else { + api.RespondError(w, http.StatusInternalServerError, msg) + } + return + } + + if r.URL.Query().Get("set_cookie") == "true" || r.URL.Query().Get("set_cookie") == "1" { + h.authSvc.SetAuthCookie(w, resp.Token) + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": resp.UserID, + "username": resp.Username, + "token": resp.Token, + "first_name": resp.FirstName, + "profile_url": resp.ProfileURL, + "photo_url": resp.PhotoURL, + }) +} + +// SetCredentials handles POST /auth/credentials for updating credentials. +func (h *AuthHandler) SetCredentials(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + var payload models.SetCredentialsRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if err := h.authSvc.UpdateCredentials(r.Context(), userID, payload.Username, payload.Password); err != nil { + if strings.Contains(err.Error(), "username already taken") { + api.RespondError(w, http.StatusConflict, err.Error()) + } else { + api.RespondError(w, http.StatusBadRequest, err.Error()) + } + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// UpdateIntegrations handles POST /auth/integrations. +func (h *AuthHandler) UpdateIntegrations(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + var req models.IntegrationsUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + integrations, err := h.authSvc.UpdateIntegrations(r.Context(), userID, req) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "integrations": integrations, + }) +} + +// SetCookie handles POST /auth/cookie to set the auth_token cookie. +func (h *AuthHandler) SetCookie(w http.ResponseWriter, r *http.Request) { + var payload models.SetCookieRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + token := strings.TrimSpace(payload.Token) + if token == "" { + api.RespondError(w, http.StatusBadRequest, "token is required") + return + } + + uid, err := h.authSvc.VerifyToken(token) + if err != nil { + api.RespondError(w, http.StatusUnauthorized, "invalid auth token") + return + } + + h.authSvc.SetAuthCookie(w, token) + api.RespondJSON(w, http.StatusOK, map[string]any{"ok": true, "user_id": uid}) +} + +// Logout handles POST /auth/logout. +func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { + h.authSvc.ClearAuthCookie(w) + api.RespondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// WidgetLogin handles POST /auth/telegram/widget. +func (h *AuthHandler) WidgetLogin(w http.ResponseWriter, r *http.Request) { + var payload models.TelegramWidgetRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid widget payload") + return + } + + resp, err := h.authSvc.AuthenticateWidget(r.Context(), payload) + if err != nil { + api.RespondError(w, http.StatusUnauthorized, err.Error()) + return + } + + if r.URL.Query().Get("set_cookie") == "true" || r.URL.Query().Get("set_cookie") == "1" { + h.authSvc.SetAuthCookie(w, resp.Token) + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// ValidateToken handles POST /auth/telegram/validate-token. +func (h *AuthHandler) ValidateToken(w http.ResponseWriter, r *http.Request) { + var payload models.TelegramTokenLoginRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + tokenVal := strings.TrimSpace(payload.IDToken) + if tokenVal == "" { + tokenVal = strings.TrimSpace(payload.Token) + } + if tokenVal == "" { + api.RespondError(w, http.StatusBadRequest, "id_token is required") + return + } + + user, token, err := h.authSvc.ValidateOIDCIDToken(r.Context(), tokenVal, payload.InviteCode) + if err != nil { + api.RespondError(w, http.StatusUnauthorized, fmt.Sprintf("invalid telegram ID token: %v", err)) + return + } + + if r.URL.Query().Get("set_cookie") != "false" { + h.authSvc.SetAuthCookie(w, token) + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": user.ID, + "token": token, + "first_name": user.FirstName, + "username": user.Username, + "profile_url": user.ProfileURL, + "photo_url": user.PhotoURL, + }) +} + +// Me handles GET /auth/me. +func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { + if middleware.IsGuest(r.Context()) { + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "guest": true, + "user": nil, + }) + return + } + + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "guest": true, + "user": nil, + }) + return + } + + user, err := h.authSvc.GetUserByID(r.Context(), userID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + if user == nil { + api.RespondError(w, http.StatusNotFound, "user not found") + return + } + + role := "user" + for _, oid := range h.cfg.OwnerIDs { + if oid == userID { + role = "owner" + break + } + } + if role == "user" { + for _, sid := range h.cfg.SudoUsers { + if sid == userID { + role = "sudo" + break + } + } + } + + isAdmin := role == "owner" || role == "sudo" + + userMap := map[string]any{ + "_id": user.ID, + "id": user.ID, + "user_id": user.ID, + "userid": user.ID, + "username": user.Username, + "first_name": user.FirstName, + "profile_url": user.ProfileURL, + "photo_url": user.PhotoURL, + "telegram": user.Telegram, + "status": user.Status, + "role": role, + "is_admin": isAdmin, + "created_at": user.CreatedAt, + "updated_at": user.UpdatedAt, + "integrations": user.Integrations, + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user": userMap, + }) +} + +// SetupStatus handles GET /auth/setup/status. +func (h *AuthHandler) SetupStatus(w http.ResponseWriter, r *http.Request) { + resp, err := h.authSvc.GetSetupStatus(r.Context()) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + api.RespondJSON(w, http.StatusOK, resp) +} + +// SetupPassword handles POST /auth/setup. +func (h *AuthHandler) SetupPassword(w http.ResponseWriter, r *http.Request) { + var payload models.SetupPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + token, err := h.authSvc.SetupOwnerPassword(r.Context(), payload.Password) + if err != nil { + if strings.Contains(err.Error(), "already completed") { + api.RespondError(w, http.StatusForbidden, err.Error()) + } else { + api.RespondError(w, http.StatusBadRequest, err.Error()) + } + return + } + + if r.URL.Query().Get("set_cookie") == "true" || r.URL.Query().Get("set_cookie") == "1" { + h.authSvc.SetAuthCookie(w, token) + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "token": token, + }) +} + +// ServerPasswordLogin handles POST /auth/password. +func (h *AuthHandler) ServerPasswordLogin(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + username, _ := body["username"].(string) + password, _ := body["password"].(string) + + // If username is provided and not "guest", treat as user login with password (matching Swagger schema) + if strings.TrimSpace(username) != "" && !strings.EqualFold(strings.TrimSpace(username), "guest") { + if strings.TrimSpace(password) == "" { + api.RespondError(w, http.StatusBadRequest, "password is required") + return + } + + resp, err := h.authSvc.LoginWithPassword(r.Context(), username, password) + if err != nil { + if strings.Contains(err.Error(), "invalid username") { + api.RespondError(w, http.StatusBadRequest, err.Error()) + } else { + api.RespondError(w, http.StatusUnauthorized, err.Error()) + } + return + } + + if r.URL.Query().Get("set_cookie") == "true" || r.URL.Query().Get("set_cookie") == "1" { + h.authSvc.SetAuthCookie(w, resp.Token) + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": resp.UserID, + "token": resp.Token, + "first_name": resp.FirstName, + "username": resp.Username, + "profile_url": resp.ProfileURL, + "photo_url": resp.PhotoURL, + }) + return + } + + token, err := h.authSvc.LoginWithServerPassword(r.Context(), password) + if err != nil { + api.RespondError(w, http.StatusUnauthorized, err.Error()) + return + } + + if r.URL.Query().Get("set_cookie") == "true" || r.URL.Query().Get("set_cookie") == "1" { + h.authSvc.SetAuthCookie(w, token) + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "token": token, + }) +} + +// ChangePassword handles POST /auth/password/change. +func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + var payload models.SetupPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if err := h.authSvc.ChangeOwnerPassword(r.Context(), payload.Password, userID); err != nil { + api.RespondError(w, http.StatusBadRequest, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// Config handles GET /auth/telegram/config and GET /webapp/config. +func (h *AuthHandler) Config(w http.ResponseWriter, r *http.Request) { + clientID := h.authSvc.GetTelegramClientID() + botUsername := h.authSvc.GetBotUsername(r.Context()) + enabled := clientID != "" || botUsername != "" + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "enabled": enabled, + "client_id": clientID, + "bot_id": clientID, + "bot_username": botUsername, + "has_auth": true, + }) +} + +// TelegramStart handles GET /auth/telegram/start. +func (h *AuthHandler) TelegramStart(w http.ResponseWriter, r *http.Request) { + redirect := r.URL.Query().Get("redirect") + if redirect == "" || !strings.HasPrefix(redirect, "/") || strings.HasPrefix(redirect, "//") { + redirect = "/" + } + + frontendURL := r.URL.Query().Get("frontend_url") + frontendBase := getFrontendBaseURL(r, frontendURL, h.cfg) + callbackURI := getTelegramRedirectURI(r, h.cfg, frontendBase) + inviteCode := strings.TrimSpace(r.URL.Query().Get("invite_code")) + + authURL, err := h.authSvc.StartTelegramOIDC(r.Context(), callbackURI, frontendBase, redirect, inviteCode) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + http.Redirect(w, r, authURL, http.StatusFound) +} + +// TelegramCallback handles GET /auth/telegram/callback. +func (h *AuthHandler) TelegramCallback(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + frontendBase := getFrontendBaseURL(r, "", h.cfg) + + // 1. Provider error + if errVal := q.Get("error"); errVal != "" { + errDesc := q.Get("error_description") + if errDesc == "" { + errDesc = errVal + } + http.Redirect(w, r, fmt.Sprintf("%s/login?error=%s", frontendBase, url.QueryEscape(errVal)), http.StatusFound) + return + } + + // 2. Direct Widget Query Callback (?hash=...&id=...) + if q.Get("hash") != "" && q.Get("id") != "" { + id, err := strconv.ParseInt(q.Get("id"), 10, 64) + if err == nil && id > 0 { + authDate, _ := strconv.ParseInt(q.Get("auth_date"), 10, 64) + widgetReq := models.TelegramWidgetRequest{ + ID: id, + FirstName: q.Get("first_name"), + LastName: q.Get("last_name"), + Username: q.Get("username"), + PhotoURL: q.Get("photo_url"), + AuthDate: authDate, + Hash: q.Get("hash"), + } + resp, err := h.authSvc.AuthenticateWidget(r.Context(), widgetReq) + if err == nil { + h.authSvc.SetAuthCookie(w, resp.Token) + renderSuccessPopup(w, resp.Token, resp.UserID, resp.FirstName, resp.Username, resp.PhotoURL, frontendBase+"/") + return + } + } + http.Redirect(w, r, fmt.Sprintf("%s/login?error=invalid_widget_signature", frontendBase), http.StatusFound) + return + } + + // 3. OIDC code exchange (?code=...&state=...) + code := q.Get("code") + state := q.Get("state") + if code != "" && state != "" { + user, token, destPath, err := h.authSvc.ExchangeOIDCCode(r.Context(), code, state) + if err != nil { + http.Redirect(w, r, fmt.Sprintf("%s/login?error=%s", frontendBase, url.QueryEscape(err.Error())), http.StatusFound) + return + } + + h.authSvc.SetAuthCookie(w, token) + redirectTarget := fmt.Sprintf("%s/login?token=%s&redirect=%s", frontendBase, url.QueryEscape(token), url.QueryEscape(destPath)) + renderSuccessPopup(w, token, user.ID, user.FirstName, user.Username, user.PhotoURL, redirectTarget) + return + } + + // 4. Hash fragment fallback (for clients where Telegram returned fragment #tgAuthResult or #id_token) + renderFragmentFallbackHTML(w) +} + +func renderSuccessPopup(w http.ResponseWriter, token string, userID int64, firstName, username, photoURL, redirectTarget string) { + safeFirstName, _ := json.Marshal(firstName) + safeUsername, _ := json.Marshal(username) + safePhoto, _ := json.Marshal(photoURL) + safeTarget, _ := json.Marshal(redirectTarget) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, ` +`, token, userID, string(safeFirstName), string(safeUsername), string(safePhoto), string(safeTarget), string(safeTarget)) +} + +func renderFragmentFallbackHTML(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, ` + + + + + Verifying Telegram Login... + + + +
+
+

Verifying Telegram login

+

Connecting your account, please wait...

+
+ + +`) +} + +func getFrontendBaseURL(r *http.Request, clientRedirect string, cfg *config.Config) string { + if clientRedirect != "" && (strings.HasPrefix(clientRedirect, "http://") || strings.HasPrefix(clientRedirect, "https://")) { + if u, err := url.Parse(clientRedirect); err == nil && u.Scheme != "" && u.Host != "" { + return fmt.Sprintf("%s://%s", u.Scheme, u.Host) + } + } + + origin := r.Header.Get("Origin") + if origin == "" { + referer := r.Header.Get("Referer") + if referer != "" { + if u, err := url.Parse(referer); err == nil && u.Scheme != "" && u.Host != "" { + origin = fmt.Sprintf("%s://%s", u.Scheme, u.Host) + } + } + } + if origin == "" && cfg.CorsOrigin != "" && cfg.CorsOrigin != "*" { + origin = strings.Split(cfg.CorsOrigin, ",")[0] + } + if origin == "" { + proto := r.Header.Get("X-Forwarded-Proto") + if proto == "" { + if r.TLS != nil { + proto = "https" + } else { + proto = "http" + } + } + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host + } + origin = fmt.Sprintf("%s://%s", proto, host) + } + return strings.TrimRight(origin, "/") +} + +func getTelegramRedirectURI(r *http.Request, cfg *config.Config, frontendBase string) string { + if cfg.TelegramOIDCRedirectURI != "" { + return cfg.TelegramOIDCRedirectURI + } + if frontendBase != "" && (strings.HasPrefix(frontendBase, "http://") || strings.HasPrefix(frontendBase, "https://")) { + return fmt.Sprintf("%s/auth/telegram/callback", strings.TrimRight(frontendBase, "/")) + } + proto := r.Header.Get("X-Forwarded-Proto") + if proto == "" { + if r.TLS != nil { + proto = "https" + } else { + proto = "http" + } + } + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host + } + return fmt.Sprintf("%s://%s/auth/telegram/callback", proto, host) +} + +// UpdateFCMToken handles POST /auth/fcm-token matching Api/schemas/auth.py. +func (h *AuthHandler) UpdateFCMToken(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "unauthorized") + return + } + + var req models.FCMTokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if strings.TrimSpace(req.FCMToken) == "" { + api.RespondError(w, http.StatusBadRequest, "fcm_token is required") + return + } + + if err := h.authSvc.UpdateFCMToken(r.Context(), userID, req.FCMToken); err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// CreateBotSession handles POST /auth/telegram/bot-session matching Api/schemas/auth.py. +func (h *AuthHandler) CreateBotSession(w http.ResponseWriter, r *http.Request) { + var req models.TelegramBotSessionRequest + _ = json.NewDecoder(r.Body).Decode(&req) + + res, err := h.authSvc.CreateBotSession(r.Context(), req.InviteCode) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, res) +} + +// CheckBotSessionStatus handles GET /auth/telegram/bot-session/status matching Api/routers/auth.py. +func (h *AuthHandler) CheckBotSessionStatus(w http.ResponseWriter, r *http.Request) { + sessionID := strings.TrimSpace(r.URL.Query().Get("session_id")) + if sessionID == "" { + api.RespondError(w, http.StatusBadRequest, "session_id is required") + return + } + + res, err := h.authSvc.CheckBotSessionStatus(r.Context(), sessionID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + setCookie := r.URL.Query().Get("set_cookie") != "false" + if setCookie && res["status"] == "confirmed" { + if token, ok := res["token"].(string); ok && token != "" { + h.authSvc.SetAuthCookie(w, token) + } + } + + api.RespondJSON(w, http.StatusOK, res) +} diff --git a/internal/api/handlers/daily_playlists.go b/internal/api/handlers/daily_playlists.go new file mode 100644 index 0000000..3c568ed --- /dev/null +++ b/internal/api/handlers/daily_playlists.go @@ -0,0 +1,65 @@ +package handlers + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/api/middleware" + "streamgo/internal/services" +) + +// DailyPlaylistHandler handles daily mix and dynamic playlist endpoints. +type DailyPlaylistHandler struct { + dailySvc *services.DailyPlaylistService + authSvc *services.AuthService +} + +// NewDailyPlaylistHandler creates a new DailyPlaylistHandler. +func NewDailyPlaylistHandler(dailySvc *services.DailyPlaylistService, authSvc *services.AuthService) *DailyPlaylistHandler { + return &DailyPlaylistHandler{ + dailySvc: dailySvc, + authSvc: authSvc, + } +} + +// Routes mounts the daily playlist routes. +func (h *DailyPlaylistHandler) Routes(r chi.Router) { + optionalAuth := middleware.OptionalAuth(h.authSvc) + + r.With(optionalAuth).Get("/playlists/available", h.Available) + r.With(optionalAuth).Get("/daily-playlist/{key}", h.GetDaily) +} + +// Available handles GET /playlists/available. +func (h *DailyPlaylistHandler) Available(w http.ResponseWriter, r *http.Request) { + resp, err := h.dailySvc.GetAvailablePlaylists(r.Context()) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + api.RespondJSON(w, http.StatusOK, resp) +} + +// GetDaily handles GET /daily-playlist/{key}. +func (h *DailyPlaylistHandler) GetDaily(w http.ResponseWriter, r *http.Request) { + key := chi.URLParam(r, "key") + if strings.TrimSpace(key) == "" { + api.RespondError(w, http.StatusBadRequest, "key is required") + return + } + + limit := api.ParseQueryInt(r, "limit", 75) + channelID := api.ParseQueryInt64(r, "channel_id", 0) + userID, _ := middleware.GetUserID(r.Context()) + + resp, err := h.dailySvc.GetDailyPlaylist(r.Context(), key, limit, channelID, userID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} diff --git a/internal/api/handlers/discord.go b/internal/api/handlers/discord.go new file mode 100644 index 0000000..3a490dd --- /dev/null +++ b/internal/api/handlers/discord.go @@ -0,0 +1,150 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/coder/websocket" + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/logger" + "streamgo/internal/models" + "streamgo/internal/services" +) + +var logDiscordHandler = logger.New("discord_handler") + +// DiscordHandler handles Discord Remote Auth and external assets endpoints. +type DiscordHandler struct { + discordSvc *services.DiscordService +} + +// NewDiscordHandler creates a new DiscordHandler instance. +func NewDiscordHandler(discordSvc *services.DiscordService) *DiscordHandler { + return &DiscordHandler{ + discordSvc: discordSvc, + } +} + +// Routes mounts Discord Remote Auth and asset proxy endpoints. +func (h *DiscordHandler) Routes(r chi.Router) { + // WebSocket endpoint for real-time QR code flow + r.HandleFunc("/ws/discord/remote-auth", h.WebSocketRemoteAuth) + + // REST API endpoints + r.Post("/api/discord/remote-auth/start", h.StartRemoteAuth) + r.Get("/api/discord/remote-auth/status/{session_id}", h.GetRemoteAuthStatus) + r.Post("/api/discord/remote-auth/cancel/{session_id}", h.CancelRemoteAuth) + r.Post("/api/discord/external-assets", h.ResolveExternalAssets) +} + +// WebSocketRemoteAuth streams Discord QR remote-auth events directly over WebSocket. +func (h *DiscordHandler) WebSocketRemoteAuth(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + OriginPatterns: []string{"*"}, + }) + if err != nil { + logDiscordHandler.Warnf("Failed to accept websocket: %v", err) + return + } + defer conn.Close(websocket.StatusNormalClosure, "done") + + sess, err := h.discordSvc.CreateWSSession(r.Context()) + if err != nil { + logDiscordHandler.Errorf("Failed to create Discord remote auth session: %v", err) + _ = conn.Close(websocket.StatusInternalError, err.Error()) + return + } + defer func() { + sess.Cancel() + _ = h.discordSvc.CancelSession(sess.SessionID) + }() + + logDiscordHandler.Infof("[DiscordRA WS] New connection established: %s", sess.SessionID) + + for { + select { + case <-r.Context().Done(): + return + case ev, ok := <-sess.EventsChan: + if !ok { + return + } + data, err := json.Marshal(ev) + if err != nil { + continue + } + if err := conn.Write(r.Context(), websocket.MessageText, data); err != nil { + logDiscordHandler.Infof("[DiscordRA WS] Client disconnected early: %s", sess.SessionID) + return + } + if typ, _ := ev["type"].(string); typ == "success" || typ == "error" || typ == "cancelled" { + return + } + } + } +} + +// StartRemoteAuth initializes a Discord Remote Auth session and waits up to 4s for initial QR code. +func (h *DiscordHandler) StartRemoteAuth(w http.ResponseWriter, r *http.Request) { + resp, err := h.discordSvc.StartSession(r.Context()) + if err != nil { + api.RespondError(w, http.StatusBadGateway, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// GetRemoteAuthStatus checks the status of a running Remote Auth session. +func (h *DiscordHandler) GetRemoteAuthStatus(w http.ResponseWriter, r *http.Request) { + sessionID := strings.TrimSpace(chi.URLParam(r, "session_id")) + if sessionID == "" { + api.RespondError(w, http.StatusBadRequest, "session_id is required") + return + } + + resp, err := h.discordSvc.GetStatus(sessionID) + if err != nil { + api.RespondError(w, http.StatusNotFound, "Session not found or expired") + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// CancelRemoteAuth cancels an active Remote Auth session. +func (h *DiscordHandler) CancelRemoteAuth(w http.ResponseWriter, r *http.Request) { + sessionID := strings.TrimSpace(chi.URLParam(r, "session_id")) + if sessionID == "" { + api.RespondError(w, http.StatusBadRequest, "session_id is required") + return + } + + _ = h.discordSvc.CancelSession(sessionID) + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "status": "cancelled", + "session_id": sessionID, + }) +} + +// ResolveExternalAssets proxies external asset resolution to Discord's API with user's token. +func (h *DiscordHandler) ResolveExternalAssets(w http.ResponseWriter, r *http.Request) { + var req models.ExternalAssetsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.RespondJSON(w, http.StatusOK, []any{}) + return + } + + results, err := h.discordSvc.ResolveExternalAssets(r.Context(), req) + if err != nil { + logDiscordHandler.Warnf("ResolveExternalAssets error: %v", err) + api.RespondJSON(w, http.StatusOK, []any{}) + return + } + + api.RespondJSON(w, http.StatusOK, results) +} diff --git a/internal/api/handlers/favourites.go b/internal/api/handlers/favourites.go new file mode 100644 index 0000000..7ed8b3c --- /dev/null +++ b/internal/api/handlers/favourites.go @@ -0,0 +1,339 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/api/middleware" + "streamgo/internal/models" + "streamgo/internal/services" +) + +// FavouriteHandler manages user favourites, history, and telemetry listening events. +type FavouriteHandler struct { + favSvc *services.FavouritePlaylistService + authSvc *services.AuthService +} + +// NewFavouriteHandler creates a new FavouriteHandler instance. +func NewFavouriteHandler(favSvc *services.FavouritePlaylistService, authSvc *services.AuthService) *FavouriteHandler { + return &FavouriteHandler{ + favSvc: favSvc, + authSvc: authSvc, + } +} + +// Routes mounts favourite endpoints under Chi router. +func (h *FavouriteHandler) Routes(r chi.Router) { + requireAuth := middleware.RequireAuth(h.authSvc) + optAuth := middleware.OptionalAuth(h.authSvc) + + // Favourites (with and without /me prefix) + r.Group(func(sub chi.Router) { + sub.Use(requireAuth) + sub.Post("/favourites", h.AddFavourite) + sub.Post("/me/favourites", h.AddFavourite) + sub.Delete("/favourites/{id}", h.RemoveFavourite) + sub.Delete("/me/favourites/{id}", h.RemoveFavourite) + sub.Get("/favourites", h.ListFavourites) + sub.Get("/me/favourites", h.ListFavourites) + sub.Get("/history", h.GetHistory) + sub.Get("/me/history", h.GetHistory) + sub.Get("/top-played", h.GetTopPlayed) + sub.Get("/me/top-played", h.GetTopPlayed) + + // Artist follows + sub.Post("/artists/favourites", h.AddArtistFavourite) + sub.Post("/me/artists/favourites", h.AddArtistFavourite) + sub.Post("/me/artists/favorite", h.AddArtistFavourite) + sub.Post("/me/artist/favorite", h.AddArtistFavourite) + sub.Delete("/artists/favourites/{id}", h.RemoveArtistFavourite) + sub.Delete("/me/artists/favourites/{id}", h.RemoveArtistFavourite) + sub.Delete("/me/artists/favorite/{id}", h.RemoveArtistFavourite) + sub.Delete("/me/artist/favorite/{id}", h.RemoveArtistFavourite) + }) + + r.Group(func(sub chi.Router) { + sub.Use(optAuth) + sub.Get("/favourites/ids", h.ListFavouriteIDs) + sub.Get("/me/favourites/ids", h.ListFavouriteIDs) + sub.Get("/artists/favourites/ids", h.ListFavouriteArtistIDs) + sub.Get("/me/artists/favourites/ids", h.ListFavouriteArtistIDs) + sub.Post("/listening-events", h.RecordListeningEvents) + sub.Post("/me/listening-events", h.RecordListeningEvents) + }) +} + +// AddFavourite handles POST /favourites. +func (h *FavouriteHandler) AddFavourite(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + var payload struct { + TrackID string `json:"track_id"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + trackID := strings.TrimSpace(payload.TrackID) + if trackID == "" { + api.RespondError(w, http.StatusBadRequest, "track_id is required") + return + } + + alreadyExists, err := h.favSvc.AddFavourite(r.Context(), userID, trackID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "already_exists": alreadyExists, + }) +} + +// RemoveFavourite handles DELETE /favourites/{id}. +func (h *FavouriteHandler) RemoveFavourite(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "track_id is required") + return + } + + deleted, err := h.favSvc.RemoveFavourite(r.Context(), userID, id) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "deleted": deleted, + }) +} + +// ListFavouriteIDs handles GET /favourites/ids. +func (h *FavouriteHandler) ListFavouriteIDs(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondJSON(w, http.StatusOK, models.FavouriteIdsResponse{ + Page: 1, + PerPage: 200, + Total: 0, + IDs: []string{}, + Exists: false, + }) + return + } + + page := api.ParseQueryInt(r, "page", 1) + limit := api.ParseQueryInt(r, "limit", 200) + + resp, err := h.favSvc.GetFavouriteIDs(r.Context(), userID, page, limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// ListFavourites handles GET /favourites. +func (h *FavouriteHandler) ListFavourites(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + page := api.ParseQueryInt(r, "page", 1) + limit := api.ParseQueryInt(r, "limit", 20) + + resp, err := h.favSvc.GetFavourites(r.Context(), userID, page, limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// GetHistory handles GET /history and GET /me/history. +func (h *FavouriteHandler) GetHistory(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + limit := api.ParseQueryInt(r, "limit", 100) + items, err := h.favSvc.GetUserHistory(r.Context(), userID, limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, &models.BrowseResponse{ + Page: 1, + PerPage: limit, + Total: int64(len(items)), + Items: items, + }) +} + +// GetTopPlayed handles GET /top-played and GET /me/top-played. +func (h *FavouriteHandler) GetTopPlayed(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + page := api.ParseQueryInt(r, "page", 1) + limit := api.ParseQueryInt(r, "limit", 50) + items, total, err := h.favSvc.GetUserTopPlayed(r.Context(), userID, page, limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, &models.BrowseResponse{ + Page: page, + PerPage: limit, + Total: total, + Items: items, + }) +} + +// AddArtistFavourite handles POST /artists/favourites. +func (h *FavouriteHandler) AddArtistFavourite(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + var aid string + var payload struct { + ArtistID string `json:"artist_id"` + ID string `json:"id"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err == nil { + aid = payload.ArtistID + if aid == "" { + aid = payload.ID + } + } + if aid == "" { + aid = r.URL.Query().Get("artist_id") + if aid == "" { + aid = r.URL.Query().Get("id") + } + } + aid = strings.TrimSpace(aid) + if aid == "" { + api.RespondError(w, http.StatusBadRequest, "artist_id is required") + return + } + + alreadyExists, err := h.favSvc.AddFavouriteArtist(r.Context(), userID, aid) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "already_exists": alreadyExists, + }) +} + +// RemoveArtistFavourite handles DELETE /artists/favourites/{id}. +func (h *FavouriteHandler) RemoveArtistFavourite(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "artist_id is required") + return + } + + deleted, err := h.favSvc.RemoveFavouriteArtist(r.Context(), userID, id) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "deleted": deleted, + }) +} + +// ListFavouriteArtistIDs handles GET /artists/favourites/ids. +func (h *FavouriteHandler) ListFavouriteArtistIDs(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondJSON(w, http.StatusOK, models.FavouriteArtistsResponse{ + OK: true, + IDs: []string{}, + }) + return + } + + ids, err := h.favSvc.GetFavouriteArtistIDs(r.Context(), userID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, models.FavouriteArtistsResponse{ + OK: true, + IDs: ids, + }) +} + +// RecordListeningEvents handles POST /listening-events. +func (h *FavouriteHandler) RecordListeningEvents(w http.ResponseWriter, r *http.Request) { + userID, hasUser := middleware.GetUserID(r.Context()) + var uIDPtr *int64 + if hasUser { + uIDPtr = &userID + } + + var payload models.ListeningEventsPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid listening events payload") + return + } + + count := len(payload.Events) + if count > 0 { + count, _ = h.favSvc.RecordListeningEvents(r.Context(), uIDPtr, payload.Events) + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "count": count, + }) +} diff --git a/internal/api/handlers/history.go b/internal/api/handlers/history.go new file mode 100644 index 0000000..f30b950 --- /dev/null +++ b/internal/api/handlers/history.go @@ -0,0 +1,99 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/api/middleware" + "streamgo/internal/models" + "streamgo/internal/services" +) + +// HistoryHandler handles playback history, top tracks, and listening telemetry. +type HistoryHandler struct { + histSvc *services.HistoryService + authSvc *services.AuthService +} + +// NewHistoryHandler creates a new HistoryHandler. +func NewHistoryHandler(histSvc *services.HistoryService, authSvc *services.AuthService) *HistoryHandler { + return &HistoryHandler{ + histSvc: histSvc, + authSvc: authSvc, + } +} + +// Routes mounts history endpoints. +func (h *HistoryHandler) Routes(r chi.Router) { + requireAuth := middleware.RequireAuth(h.authSvc) + + // History endpoints + r.With(requireAuth).Get("/history", h.GetHistory) + r.With(requireAuth).Get("/me/history", h.GetHistory) + r.With(requireAuth).Get("/me/top-played", h.GetTopPlayed) +} + +// GetHistory handles GET /history and GET /me/history. +func (h *HistoryHandler) GetHistory(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "user login required") + return + } + + limit := api.ParseQueryInt(r, "limit", 50) + resp, err := h.histSvc.GetHistory(r.Context(), userID, limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// GetTopPlayed handles GET /me/top-played. +func (h *HistoryHandler) GetTopPlayed(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "user login required") + return + } + + limit := api.ParseQueryInt(r, "limit", 50) + resp, err := h.histSvc.GetTopPlayed(r.Context(), userID, limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// RecordEvents handles POST /me/listening-events and POST /listening-events. +func (h *HistoryHandler) RecordEvents(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + + var payload models.ListeningEventsPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if len(payload.Events) == 0 { + api.RespondJSON(w, http.StatusOK, map[string]any{"ok": true, "count": 0}) + return + } + + if err := h.histSvc.RecordEvents(r.Context(), userID, payload.Events); err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "count": len(payload.Events), + }) +} diff --git a/internal/api/handlers/media_extra.go b/internal/api/handlers/media_extra.go new file mode 100644 index 0000000..a1b4c71 --- /dev/null +++ b/internal/api/handlers/media_extra.go @@ -0,0 +1,282 @@ +package handlers + +import ( + "context" + "fmt" + "html" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/repository" + "streamgo/internal/services" +) + +var ( + preRegex = regexp.MustCompile(`(?s)]*>(.*?)`) + articleRegex = regexp.MustCompile(`(?s)]*id="_tl_editor"[^>]*>(.*?)`) + tagRegex = regexp.MustCompile(`<[^>]+>`) +) + +func isURL(s string) bool { + trimmed := strings.TrimSpace(s) + return strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") +} + +func fetchTelegraphLyrics(ctx context.Context, u string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + + client := &http.Client{Timeout: 6 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + htmlStr := string(body) + if m := preRegex.FindStringSubmatch(htmlStr); len(m) > 1 { + decoded := html.UnescapeString(m[1]) + return strings.TrimSpace(decoded), nil + } + + if m := articleRegex.FindStringSubmatch(htmlStr); len(m) > 1 { + content := m[1] + content = strings.ReplaceAll(content, "
", "\n") + content = strings.ReplaceAll(content, "
", "\n") + content = strings.ReplaceAll(content, "

", "\n") + content = tagRegex.ReplaceAllString(content, "") + decoded := html.UnescapeString(content) + return strings.TrimSpace(decoded), nil + } + + return "", fmt.Errorf("no lyrics content found in telegraph page") +} + +// MediaExtraHandler handles static cover images and track lyrics. +type MediaExtraHandler struct { + trackSvc *services.TrackService + lyricsSvc *services.LyricsEnrichmentService + trackRepo repository.TrackRepository +} + +// NewMediaExtraHandler creates a new MediaExtraHandler. +func NewMediaExtraHandler(trackSvc *services.TrackService, lyricsSvc *services.LyricsEnrichmentService, trackRepo repository.TrackRepository) *MediaExtraHandler { + return &MediaExtraHandler{ + trackSvc: trackSvc, + lyricsSvc: lyricsSvc, + trackRepo: trackRepo, + } +} + +// Routes mounts cover and lyrics endpoints on Chi router. +func (h *MediaExtraHandler) Routes(r chi.Router) { + r.Get("/covers/file/{file_key}.png", h.GetCoverFile) + r.Head("/covers/file/{file_key}.png", h.GetCoverFile) + r.Get("/cover/{id}", h.GetCoverRedirect) + r.Head("/cover/{id}", h.GetCoverRedirect) + r.Get("/lyrics/{id}", h.GetLyrics) + r.Get("/tracks/{id}/lyrics", h.GetLyrics) +} + + +// GetCoverFile serves cached generated cover PNG files. +func (h *MediaExtraHandler) GetCoverFile(w http.ResponseWriter, r *http.Request) { + fileKey := strings.TrimSpace(chi.URLParam(r, "file_key")) + if fileKey == "" { + api.RespondError(w, http.StatusBadRequest, "file_key is required") + return + } + + // Look in GenCovers in current or parent dirs + searchDirs := []string{ + "GenCovers", + "../GenCovers", + "../../GenCovers", + "/home/misfit/Work/StreamXBot/GenCovers", + } + + var foundPath string + for _, dir := range searchDirs { + candidate := filepath.Join(dir, fmt.Sprintf("%s.png", fileKey)) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + foundPath = candidate + break + } + } + + if foundPath == "" { + api.RespondError(w, http.StatusNotFound, "cover not found") + return + } + + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "public, max-age=86400") + http.ServeFile(w, r, foundPath) +} + +// GetCoverRedirect resolves cover URL for a track and redirects. +func (h *MediaExtraHandler) GetCoverRedirect(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "id is required") + return + } + + track, err := h.trackSvc.GetTrack(r.Context(), id) + if err != nil || track == nil { + api.RespondError(w, http.StatusNotFound, "track not found") + return + } + + item := track.ToBrowseItem() + coverURL := item.CoverURL + if coverURL == "" { + coverURL = track.EffectiveCoverURL() + } + + if coverURL == "" { + api.RespondError(w, http.StatusNotFound, "cover not available") + return + } + + http.Redirect(w, r, coverURL, http.StatusFound) +} + +// GetLyrics returns lyrics for a track either in plain text or JSON matching Python source. +func (h *MediaExtraHandler) GetLyrics(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "track id is required") + return + } + + track, err := h.trackSvc.GetTrack(r.Context(), id) + if err != nil || track == nil { + api.RespondError(w, http.StatusNotFound, "track not found") + return + } + + requestedProvider := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("provider"))) + + var ( + lyricsText string + lyricsKind string = "plain" + lyricsSource string = "unknown" + telegraphURL string + ) + + // 1. Identify if track has a telegraph link + rawLyrics := strings.TrimSpace(track.Lyrics) + if rawLyrics == "" { + rawLyrics = strings.TrimSpace(track.Audio.Lyrics) + } + if isURL(rawLyrics) { + telegraphURL = rawLyrics + } + + // 2. Check if lyrics_cache has valid lyrics text + if track.LyricsCache != nil { + if txt, ok := track.LyricsCache["text"].(string); ok && strings.TrimSpace(txt) != "" && !isURL(strings.TrimSpace(txt)) { + cachedSrc, _ := track.LyricsCache["source"].(string) + // If no specific provider was requested, or the cached source matches + if requestedProvider == "" || requestedProvider == "auto" || strings.EqualFold(cachedSrc, requestedProvider) { + lyricsText = strings.TrimSpace(txt) + if k, ok := track.LyricsCache["kind"].(string); ok && k != "" { + lyricsKind = k + } + if cachedSrc != "" { + lyricsSource = cachedSrc + } + } + } + } + + // 3. If rawLyrics is NOT a URL and not empty, and we don't have cached lyrics yet + if lyricsText == "" && rawLyrics != "" && !isURL(rawLyrics) { + lyricsText = rawLyrics + } + + // 4. If we still don't have lyrics, but we have a Telegraph / Web URL, fetch & parse the page + if lyricsText == "" && telegraphURL != "" { + scraped, err := fetchTelegraphLyrics(r.Context(), telegraphURL) + if err == nil && strings.TrimSpace(scraped) != "" { + lyricsText = strings.TrimSpace(scraped) + lyricsKind = "synced" + if strings.Contains(lyricsText, "<") && strings.Contains(lyricsText, ">") { + lyricsKind = "richsync" + } + lyricsSource = "telegraph" + // Persist to MongoDB cache so future calls are instant + if h.trackRepo != nil { + _ = h.trackRepo.UpdateLyricsCache(r.Context(), id, lyricsText, lyricsKind, lyricsSource, telegraphURL) + } + } + } + + // 5. If we still don't have lyrics (or a specific provider was requested), query lyrics providers live + if (lyricsText == "" || (requestedProvider != "" && requestedProvider != "auto" && !strings.EqualFold(lyricsSource, requestedProvider))) && h.lyricsSvc != nil { + title := track.Audio.Title + artist := track.Audio.Artist + if artist == "" { + artist = track.EffectiveArtist() + } + album := track.Audio.Album + + res, err := h.lyricsSvc.FetchLyricsWithProvider(r.Context(), title, artist, album, requestedProvider) + if err == nil && res != nil && strings.TrimSpace(res.Lyrics) != "" { + lyricsText = strings.TrimSpace(res.Lyrics) + if res.Kind != "" { + lyricsKind = res.Kind + } + if res.Source != "" { + lyricsSource = res.Source + } + if h.trackRepo != nil { + _ = h.trackRepo.UpdateLyricsCache(r.Context(), id, lyricsText, lyricsKind, lyricsSource, telegraphURL) + } + } + } + + if lyricsText == "" { + api.RespondError(w, http.StatusNotFound, "lyrics not found") + return + } + + format := strings.ToLower(r.URL.Query().Get("format")) + accept := r.Header.Get("Accept") + if format == "plain" || (format != "json" && strings.Contains(accept, "text/plain")) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(lyricsText)) + return + } + + resp := map[string]any{ + "ok": true, + "track_id": id, + "lyrics": lyricsText, + "kind": lyricsKind, + "source": lyricsSource, + } + if telegraphURL != "" { + resp["telegraph_url"] = telegraphURL + } + + api.RespondJSON(w, http.StatusOK, resp) +} diff --git a/internal/api/handlers/playlists.go b/internal/api/handlers/playlists.go new file mode 100644 index 0000000..4a60c20 --- /dev/null +++ b/internal/api/handlers/playlists.go @@ -0,0 +1,444 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/api/middleware" + "streamgo/internal/models" + "streamgo/internal/services" +) + +// PlaylistHandler handles custom user playlist and album endpoints. +type PlaylistHandler struct { + favSvc *services.FavouritePlaylistService + authSvc *services.AuthService +} + +// NewPlaylistHandler creates a PlaylistHandler instance. +func NewPlaylistHandler(favSvc *services.FavouritePlaylistService, authSvc *services.AuthService) *PlaylistHandler { + return &PlaylistHandler{ + favSvc: favSvc, + authSvc: authSvc, + } +} + +// Routes registers all playlist and album routes on Chi router. +func (h *PlaylistHandler) Routes(r chi.Router) { + requireAuth := middleware.RequireAuth(h.authSvc) + optAuth := middleware.OptionalAuth(h.authSvc) + + // Protected routes (creation, modification, deletion) + r.Group(func(sub chi.Router) { + sub.Use(requireAuth) + + // Playlists + sub.Post("/playlists", h.Create) + sub.Post("/me/playlists", h.Create) + + sub.Patch("/playlists/{id}", h.Rename) + sub.Patch("/me/playlists/{id}", h.Rename) + sub.Patch("/me/playlists/{playlist_id}", h.Rename) + sub.Put("/playlists/{id}", h.Rename) + sub.Put("/me/playlists/{id}", h.Rename) + + sub.Delete("/playlists/{id}", h.Delete) + sub.Delete("/me/playlists/{id}", h.Delete) + sub.Delete("/me/playlists/{playlist_id}", h.Delete) + + sub.Post("/playlists/{id}/tracks", h.AddTracks) + sub.Post("/me/playlists/{id}/tracks", h.AddTracks) + sub.Post("/me/playlists/{playlist_id}/tracks", h.AddTracks) + + sub.Delete("/playlists/{id}/tracks/{track_id}", h.RemoveTrack) + sub.Delete("/me/playlists/{id}/tracks/{track_id}", h.RemoveTrack) + sub.Delete("/me/playlists/{playlist_id}/tracks/{track_id}", h.RemoveTrack) + + // Tracks listing for playlist (requires auth in StreamXBot) + sub.Get("/me/playlists/{playlist_id}/tracks", h.GetTracks) + sub.Get("/me/playlists/{id}/tracks", h.GetTracks) + sub.Get("/playlists/{id}/tracks", h.GetTracks) + + // Saved Albums + sub.Post("/albums", h.SaveAlbum) + sub.Post("/me/albums", h.SaveAlbum) + sub.Get("/me/albums", h.ListAlbums) + }) + + // Optional / Public routes + r.Group(func(sub chi.Router) { + sub.Use(optAuth) + sub.Get("/playlists", h.ListMine) + sub.Get("/me/playlists", h.ListMine) + + // Shared & public playlist access + sub.Get("/share/playlists/{id}", h.GetSharedPlaylist) + sub.Get("/share/playlists/{playlist_id}", h.GetSharedPlaylist) + sub.Get("/playlists/{id}", h.GetSharedPlaylist) + sub.Get("/me/playlists/{id}", h.GetSharedPlaylist) + }) +} + +// Create handles POST /playlists and POST /me/playlists. +func (h *PlaylistHandler) Create(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + var payload struct { + Name string `json:"name"` + Title string `json:"title"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + name := strings.TrimSpace(payload.Name) + if name == "" { + name = strings.TrimSpace(payload.Title) + } + if name == "" { + api.RespondError(w, http.StatusBadRequest, "name is required") + return + } + + p, err := h.favSvc.CreatePlaylist(r.Context(), userID, name) + if err != nil { + api.RespondError(w, http.StatusBadRequest, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, p) +} + +// ListMine handles GET /playlists and GET /me/playlists. +func (h *PlaylistHandler) ListMine(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondJSON(w, http.StatusOK, models.PlaylistsResponse{Items: []models.PlaylistItem{}}) + return + } + + resp, err := h.favSvc.GetUserPlaylists(r.Context(), userID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// Rename handles PATCH /playlists/{id}, PUT /playlists/{id}, PATCH /me/playlists/{id}. +func (h *PlaylistHandler) Rename(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + id = strings.TrimSpace(chi.URLParam(r, "playlist_id")) + } + if id == "" { + api.RespondError(w, http.StatusBadRequest, "playlist id is required") + return + } + + var payload struct { + Name string `json:"name"` + Title string `json:"title"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + name := strings.TrimSpace(payload.Name) + if name == "" { + name = strings.TrimSpace(payload.Title) + } + if name == "" { + api.RespondError(w, http.StatusBadRequest, "name is required") + return + } + + updated, err := h.favSvc.RenamePlaylist(r.Context(), id, userID, name) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + if updated == nil { + api.RespondError(w, http.StatusNotFound, "playlist not found") + return + } + + api.RespondJSON(w, http.StatusOK, updated) +} + +// Delete handles DELETE /playlists/{id} and DELETE /me/playlists/{id}. +func (h *PlaylistHandler) Delete(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + id = strings.TrimSpace(chi.URLParam(r, "playlist_id")) + } + if id == "" { + api.RespondError(w, http.StatusBadRequest, "playlist id is required") + return + } + + if err := h.favSvc.DeletePlaylist(r.Context(), id, userID); err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + }) +} + +// AddTracks handles POST /playlists/{id}/tracks and POST /me/playlists/{id}/tracks. +func (h *PlaylistHandler) AddTracks(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + id = strings.TrimSpace(chi.URLParam(r, "playlist_id")) + } + if id == "" { + api.RespondError(w, http.StatusBadRequest, "playlist id is required") + return + } + + var payload struct { + TrackID any `json:"track_id"` + TrackIDs []string `json:"track_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + var trackIDs []string + if payload.TrackID != nil { + switch v := payload.TrackID.(type) { + case string: + if s := strings.TrimSpace(v); s != "" { + trackIDs = append(trackIDs, s) + } + case []any: + for _, item := range v { + if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { + trackIDs = append(trackIDs, strings.TrimSpace(s)) + } + } + } + } + for _, tid := range payload.TrackIDs { + if s := strings.TrimSpace(tid); s != "" { + trackIDs = append(trackIDs, s) + } + } + + if len(trackIDs) == 0 { + api.RespondError(w, http.StatusBadRequest, "track_id or track_ids is required") + return + } + + added, err := h.favSvc.AddTracksToPlaylist(r.Context(), id, userID, trackIDs) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + if len(trackIDs) == 1 { + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "already_exists": added == 0, + }) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "added": added, + }) +} + +// RemoveTrack handles DELETE /playlists/{id}/tracks/{track_id} and DELETE /me/playlists/{id}/tracks/{track_id}. +func (h *PlaylistHandler) RemoveTrack(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + id = strings.TrimSpace(chi.URLParam(r, "playlist_id")) + } + trackID := strings.TrimSpace(chi.URLParam(r, "track_id")) + if id == "" || trackID == "" { + api.RespondError(w, http.StatusBadRequest, "playlist id and track id are required") + return + } + + if err := h.favSvc.RemoveTrackFromPlaylist(r.Context(), id, userID, trackID); err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "deleted": true, + }) +} + +// GetTracks handles GET /playlists/{id}/tracks and GET /me/playlists/{id}/tracks. +func (h *PlaylistHandler) GetTracks(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + id = strings.TrimSpace(chi.URLParam(r, "playlist_id")) + } + if id == "" { + api.RespondError(w, http.StatusBadRequest, "playlist id is required") + return + } + + page := api.ParseQueryInt(r, "page", 1) + limit := api.ParseQueryInt(r, "limit", 50) + + resp, err := h.favSvc.GetPlaylistTracks(r.Context(), id, userID, page, limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// GetSharedPlaylist handles GET /share/playlists/{id} and GET /playlists/{id}. +func (h *PlaylistHandler) GetSharedPlaylist(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + id = strings.TrimSpace(chi.URLParam(r, "playlist_id")) + } + if id == "" { + api.RespondError(w, http.StatusBadRequest, "playlist id is required") + return + } + + resp, err := h.favSvc.GetSharedPlaylist(r.Context(), id) + if err != nil { + api.RespondError(w, http.StatusNotFound, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// SaveAlbum handles POST /albums and POST /me/albums. +func (h *PlaylistHandler) SaveAlbum(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + var payload struct { + AlbumID any `json:"album_id"` + AlbumIDs []string `json:"album_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + var albumIDs []string + if payload.AlbumID != nil { + switch v := payload.AlbumID.(type) { + case string: + if s := strings.TrimSpace(v); s != "" { + albumIDs = append(albumIDs, s) + } + case []any: + for _, item := range v { + if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { + albumIDs = append(albumIDs, strings.TrimSpace(s)) + } + } + } + } + for _, aid := range payload.AlbumIDs { + if s := strings.TrimSpace(aid); s != "" { + albumIDs = append(albumIDs, s) + } + } + + if len(albumIDs) == 0 { + api.RespondError(w, http.StatusBadRequest, "album_id or album_ids is required") + return + } + + added, err := h.favSvc.SaveAlbums(r.Context(), userID, albumIDs) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + if len(albumIDs) == 1 { + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "already_exists": added == 0, + }) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "added": added, + }) +} + +// ListAlbums handles GET /albums and GET /me/albums. +func (h *PlaylistHandler) ListAlbums(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + page := api.ParseQueryInt(r, "page", 1) + limit := api.ParseQueryInt(r, "limit", 50) + + resp, err := h.favSvc.GetSavedAlbums(r.Context(), userID, page, limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} diff --git a/internal/api/handlers/recap.go b/internal/api/handlers/recap.go new file mode 100644 index 0000000..b2d66c8 --- /dev/null +++ b/internal/api/handlers/recap.go @@ -0,0 +1,322 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/api/middleware" + "streamgo/internal/models" + "streamgo/internal/services" +) + +// RecapHandler handles user listening events, recaps, period snapshots, and public share links. +type RecapHandler struct { + recapSvc *services.RecapService + authSvc *services.AuthService +} + +// NewRecapHandler creates a new RecapHandler. +func NewRecapHandler(recapSvc *services.RecapService, authSvc *services.AuthService) *RecapHandler { + return &RecapHandler{ + recapSvc: recapSvc, + authSvc: authSvc, + } +} + +// Routes mounts recap and listening event endpoints. +func (h *RecapHandler) Routes(r chi.Router) { + requireAuth := middleware.RequireAuth(h.authSvc) + optionalAuth := middleware.OptionalAuth(h.authSvc) + + // Listening events telemetry + r.With(requireAuth).Post("/me/listening-events", h.RecordListeningEvents) + r.With(optionalAuth).Post("/listening-events", h.RecordLegacyListeningEvents) + + // Recaps collection and shares management + r.With(requireAuth).Get("/me/recaps", h.ListRecaps) + r.With(requireAuth).Get("/me/recaps/shares", h.ListShares) + r.With(requireAuth).Delete("/me/recaps/shares/{token}", h.RevokeShare) + r.With(requireAuth).Delete("/me/recaps/data", h.DeleteData) + + // Specific recap snapshots & share generation + r.With(requireAuth).Get("/me/recaps/{ptype}/{period}", h.GetRecap) + r.With(requireAuth).Post("/me/recaps/{ptype}/{period}/share", h.ShareRecap) + + // Public share viewer (no auth required) + r.Get("/recaps/share/{token}", h.GetPublicShare) +} + +// RecordListeningEvents handles POST /me/listening-events. +func (h *RecapHandler) RecordListeningEvents(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "user login required") + return + } + + var payload models.ListeningEventsPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if len(payload.Events) > 200 { + api.RespondError(w, http.StatusBadRequest, "batch size cannot exceed 200 events") + return + } + + stored, err := h.recapSvc.RecordEvents(r.Context(), userID, payload.Events) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "received": len(payload.Events), + "stored": stored, + }) +} + +// RecordLegacyListeningEvents handles POST /listening-events. +func (h *RecapHandler) RecordLegacyListeningEvents(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + + var payload models.ListeningEventsPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if len(payload.Events) == 0 { + api.RespondJSON(w, http.StatusOK, map[string]any{"ok": true, "count": 0}) + return + } + + if userID > 0 { + _, _ = h.recapSvc.RecordEvents(r.Context(), userID, payload.Events) + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "count": len(payload.Events), + }) +} + +// ListRecaps handles GET /me/recaps?tz=. +func (h *RecapHandler) ListRecaps(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "user login required") + return + } + + tz := api.ParseQueryInt(r, "tz", 0) + if tz < -840 || tz > 840 { + api.RespondError(w, http.StatusBadRequest, "tz offset out of range (-840 to 840 minutes)") + return + } + + items, err := h.recapSvc.ListAvailable(r.Context(), userID, tz) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + if items == nil { + items = []*models.RecapAvailableItem{} + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "items": items, + }) +} + +// ListShares handles GET /me/recaps/shares. +func (h *RecapHandler) ListShares(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "user login required") + return + } + + items, err := h.recapSvc.ListShares(r.Context(), userID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + if items == nil { + items = []*models.RecapShareItem{} + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "items": items, + }) +} + +// RevokeShare handles DELETE /me/recaps/shares/{token}. +func (h *RecapHandler) RevokeShare(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "user login required") + return + } + + token := chi.URLParam(r, "token") + if token == "" { + api.RespondError(w, http.StatusBadRequest, "token is required") + return + } + + revoked, err := h.recapSvc.RevokeShare(r.Context(), userID, token) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + if !revoked { + api.RespondError(w, http.StatusNotFound, "Share link not found") + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + }) +} + +// DeleteData handles DELETE /me/recaps/data. +func (h *RecapHandler) DeleteData(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "user login required") + return + } + + counts, err := h.recapSvc.DeleteUserData(r.Context(), userID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "events": counts["events"], + "snapshots": counts["snapshots"], + "shares": counts["shares"], + }) +} + +// GetRecap handles GET /me/recaps/{ptype}/{period}?tz=&refresh=. +func (h *RecapHandler) GetRecap(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "user login required") + return + } + + ptype := models.RecapPeriodType(chi.URLParam(r, "ptype")) + if ptype != models.RecapPeriodWeekly && ptype != models.RecapPeriodMonthly && ptype != models.RecapPeriodYearly { + api.RespondError(w, http.StatusBadRequest, "invalid period type, expected weekly, monthly, or yearly") + return + } + + period := chi.URLParam(r, "period") + if period == "" { + api.RespondError(w, http.StatusBadRequest, "period parameter is required") + return + } + + tz := api.ParseQueryInt(r, "tz", 0) + if tz < -840 || tz > 840 { + api.RespondError(w, http.StatusBadRequest, "tz offset out of range (-840 to 840 minutes)") + return + } + + refresh := api.ParseQueryBool(r, "refresh", false) + + if !services.IsPeriodAvailable(ptype, period, tz) { + api.RespondError(w, http.StatusBadRequest, "This recap is not ready yet. Weekly recaps unlock on the weekend (Sunday), monthly on the last day of the month, and yearly on Dec 31st.") + return + } + + snap, err := h.recapSvc.GetSnapshot(r.Context(), userID, ptype, period, tz, refresh) + if err != nil { + api.RespondError(w, http.StatusBadRequest, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, snap) +} + +// ShareRecap handles POST /me/recaps/{ptype}/{period}/share?tz=. +func (h *RecapHandler) ShareRecap(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "user login required") + return + } + + ptype := models.RecapPeriodType(chi.URLParam(r, "ptype")) + if ptype != models.RecapPeriodWeekly && ptype != models.RecapPeriodMonthly && ptype != models.RecapPeriodYearly { + api.RespondError(w, http.StatusBadRequest, "invalid period type, expected weekly, monthly, or yearly") + return + } + + period := chi.URLParam(r, "period") + if period == "" { + api.RespondError(w, http.StatusBadRequest, "period parameter is required") + return + } + + tz := api.ParseQueryInt(r, "tz", 0) + if tz < -840 || tz > 840 { + api.RespondError(w, http.StatusBadRequest, "tz offset out of range (-840 to 840 minutes)") + return + } + + if !services.IsPeriodAvailable(ptype, period, tz) { + api.RespondError(w, http.StatusBadRequest, "This recap is not ready to share yet.") + return + } + + snap, err := h.recapSvc.GetSnapshot(r.Context(), userID, ptype, period, tz, false) + if err != nil { + api.RespondError(w, http.StatusBadRequest, err.Error()) + return + } + + if snap.Stats == nil || snap.Stats.TotalPlays == 0 { + api.RespondError(w, http.StatusBadRequest, "Nothing to share for this period yet") + return + } + + shareItem, err := h.recapSvc.CreateShare(r.Context(), userID, snap) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, shareItem) +} + +// GetPublicShare handles GET /recaps/share/{token}. +func (h *RecapHandler) GetPublicShare(w http.ResponseWriter, r *http.Request) { + token := chi.URLParam(r, "token") + if token == "" { + api.RespondError(w, http.StatusNotFound, "This recap link is no longer available") + return + } + + summary, err := h.recapSvc.GetPublicShare(r.Context(), token) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + if summary == nil { + api.RespondError(w, http.StatusNotFound, "This recap link is no longer available") + return + } + + api.RespondJSON(w, http.StatusOK, summary) +} diff --git a/internal/api/handlers/share.go b/internal/api/handlers/share.go new file mode 100644 index 0000000..40626b9 --- /dev/null +++ b/internal/api/handlers/share.go @@ -0,0 +1,861 @@ +package handlers + +import ( + "fmt" + "html" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/config" + "streamgo/internal/models" + "streamgo/internal/repository" +) + +// ShareHandler handles rich embeds, OpenGraph/Twitter card previews, and share redirects. +type ShareHandler struct { + trackRepo repository.TrackRepository + playlistRepo repository.FavouritePlaylistRepository + cfg *config.Config +} + +// NewShareHandler creates a new ShareHandler. +func NewShareHandler(trackRepo repository.TrackRepository, playlistRepo repository.FavouritePlaylistRepository, cfg *config.Config) *ShareHandler { + return &ShareHandler{ + trackRepo: trackRepo, + playlistRepo: playlistRepo, + cfg: cfg, + } +} + +// Routes mounts /share endpoints. +func (h *ShareHandler) Routes(r chi.Router) { + r.Get("/share/{id}", h.HandleShare) + r.Get("/share/tracks/{id}", h.HandleTrackShare) + r.Get("/share/playlists/{id}", h.HandlePlaylistShare) +} + +// HandleShare detects whether id is a track or playlist and renders the preview. +func (h *ShareHandler) HandleShare(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if strings.TrimSpace(id) == "" { + http.Error(w, "Share ID required", http.StatusBadRequest) + return + } + + // 1. Try track first + track, err := h.trackRepo.GetByID(r.Context(), id) + if err == nil && track != nil { + h.renderTrack(w, r, track) + return + } + + // 2. Try playlist + playlist, err := h.playlistRepo.GetPublicPlaylistByID(r.Context(), id) + if err == nil && playlist != nil { + h.renderPlaylist(w, r, playlist) + return + } + + http.Error(w, "Track or playlist not found", http.StatusNotFound) +} + +// HandleTrackShare renders track preview. +func (h *ShareHandler) HandleTrackShare(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + track, err := h.trackRepo.GetByID(r.Context(), id) + if err != nil || track == nil { + http.Error(w, "Track not found", http.StatusNotFound) + return + } + h.renderTrack(w, r, track) +} + +// HandlePlaylistShare renders playlist preview. +func (h *ShareHandler) HandlePlaylistShare(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + playlist, err := h.playlistRepo.GetPublicPlaylistByID(r.Context(), id) + if err != nil || playlist == nil { + http.Error(w, "Playlist not found", http.StatusNotFound) + return + } + h.renderPlaylist(w, r, playlist) +} + +func (h *ShareHandler) wantsJSON(r *http.Request) bool { + if r.URL.Query().Get("format") == "json" { + return true + } + accept := strings.ToLower(r.Header.Get("Accept")) + return strings.Contains(accept, "application/json") && !strings.Contains(accept, "text/html") +} + +func (h *ShareHandler) renderTrack(w http.ResponseWriter, r *http.Request, track *models.Track) { + title := track.Audio.Title + if title == "" { + title = "Unknown Title" + } + artist := track.Audio.Artist + if artist == "" { + artist = "Unknown Artist" + } + album := track.Audio.Album + + baseURL := h.baseURL(r) + shareURL := fmt.Sprintf("%s/share/%s", baseURL, track.ID) + streamURL := fmt.Sprintf("%s/stream/%s", baseURL, track.ID) + downloadURL := fmt.Sprintf("%s/tracks/%s/download", baseURL, track.ID) + coverURL := fmt.Sprintf("%s/stream/cover/%s", baseURL, track.ID) + + desc := fmt.Sprintf("Listen to %s by %s on StreamX.", title, artist) + if album != "" { + desc = fmt.Sprintf("Listen to %s by %s from the album %s on StreamX.", title, artist, album) + } + + if h.wantsJSON(r) { + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "type": "track", + "id": track.ID, + "title": title, + "artist": artist, + "album": album, + "duration_sec": track.Audio.DurationSec, + "audio_type": track.Audio.Type, + "cover_url": coverURL, + "stream_url": streamURL, + "download_url": downloadURL, + "share_url": shareURL, + "open_graph": map[string]string{ + "og:title": fmt.Sprintf("%s - %s", title, artist), + "og:description": desc, + "og:image": coverURL, + "og:audio": streamURL, + "og:type": "music.song", + }, + }) + return + } + + durationFormatted := formatDuration(int(track.Audio.DurationSec)) + pageTitle := html.EscapeString(fmt.Sprintf("%s – %s | StreamX", title, artist)) + escTitle := html.EscapeString(title) + escArtist := html.EscapeString(artist) + escAlbum := html.EscapeString(album) + escDesc := html.EscapeString(desc) + escType := strings.ToUpper(track.Audio.Type) + if escType == "" { + escType = "AUDIO" + } + + // Extra metadata + bitrateInfo := "" + if track.Audio.BitrateKbps != nil && *track.Audio.BitrateKbps > 0 { + bitrateInfo = fmt.Sprintf("%d kbps", *track.Audio.BitrateKbps) + } + sampleInfo := "" + if track.Audio.SamplingRateHz != nil && *track.Audio.SamplingRateHz > 0 { + sampleInfo = fmt.Sprintf("%.1f kHz", float64(*track.Audio.SamplingRateHz)/1000.0) + } + bitDepthInfo := "" + if track.Audio.BitDepth != nil && *track.Audio.BitDepth > 0 { + bitDepthInfo = fmt.Sprintf("%d-bit", *track.Audio.BitDepth) + } + qualityParts := []string{} + if bitDepthInfo != "" { + qualityParts = append(qualityParts, bitDepthInfo) + } + if sampleInfo != "" { + qualityParts = append(qualityParts, sampleInfo) + } + if bitrateInfo != "" { + qualityParts = append(qualityParts, bitrateInfo) + } + qualityStr := "" + if len(qualityParts) > 0 { + qualityStr = html.EscapeString(strings.Join(qualityParts, " · ")) + } + + yearStr := "" + if track.Audio.Year != nil && *track.Audio.Year > 0 { + yearStr = fmt.Sprintf("%d", *track.Audio.Year) + } + genreStr := html.EscapeString(track.Audio.Genre) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + + fmt.Fprintf(w, ` + + + + + %s + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ %s + %s +
+
+
+
+

%s

+
%s
+ %s + %s +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + + + +
+
+ + +`, + pageTitle, + shareURL, escTitle, escArtist, escDesc, coverURL, streamURL, escArtist, track.Audio.DurationSec, + shareURL, escTitle, escArtist, escDesc, coverURL, + coverURL, escTitle, escType, + escTitle, escArtist, + renderAlbumLine(escAlbum, yearStr), + renderMetaChips(qualityStr, genreStr), + streamURL, + streamURL, + downloadURL, + durationFormatted, + ) +} + +func (h *ShareHandler) renderPlaylist(w http.ResponseWriter, r *http.Request, playlist *models.UserPlaylist) { + name := playlist.Name + if name == "" { + name = "StreamX Playlist" + } + + baseURL := h.baseURL(r) + shareURL := fmt.Sprintf("%s/share/playlists/%s", baseURL, playlist.ID) + coverURL := playlist.CoverURL + if coverURL == "" { + coverURL = fmt.Sprintf("%s/share/cover/default.jpg", baseURL) + } + + _, trackCount, _ := h.playlistRepo.GetPlaylistTrackIDs(r.Context(), playlist.ID, 1, 1) + desc := fmt.Sprintf("Stream %s featuring %d tracks on StreamX.", name, trackCount) + + if h.wantsJSON(r) { + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "type": "playlist", + "id": playlist.ID, + "name": name, + "track_count": trackCount, + "cover_url": coverURL, + "share_url": shareURL, + "open_graph": map[string]string{ + "og:title": name, + "og:description": desc, + "og:image": coverURL, + "og:type": "music.playlist", + }, + }) + return + } + + pageTitle := html.EscapeString(fmt.Sprintf("%s | StreamX Playlist", name)) + escName := html.EscapeString(name) + escDesc := html.EscapeString(desc) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + + fmt.Fprintf(w, ` + + + + + %s + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ %s + PLAYLIST + %d tracks +
+
+
+

%s

+
%d tracks in this playlist
+
+ + + Open in StreamX + + +
+
+ +`, + pageTitle, + shareURL, escName, escDesc, coverURL, + shareURL, escName, escDesc, coverURL, + coverURL, escName, + trackCount, + escName, trackCount, + shareURL, + ) +} + +func (h *ShareHandler) baseURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + host := r.Host + if host == "" { + host = "localhost:8000" + } + return fmt.Sprintf("%s://%s", scheme, host) +} + +func renderAlbumLine(album, year string) string { + if album == "" && year == "" { + return "" + } + if album != "" && year != "" { + return fmt.Sprintf(`
%s · %s
`, album, year) + } + if album != "" { + return fmt.Sprintf(`
%s
`, album) + } + return fmt.Sprintf(`
%s
`, year) +} + +func renderMetaChips(quality, genre string) string { + if quality == "" && genre == "" { + return "" + } + out := `
` + if quality != "" { + out += fmt.Sprintf(`%s`, quality) + } + if genre != "" { + out += fmt.Sprintf(`%s`, genre) + } + out += `
` + return out +} + +func formatDuration(sec int) string { + if sec <= 0 { + return "0:00" + } + m := sec / 60 + s := sec % 60 + return fmt.Sprintf("%d:%02d", m, s) +} diff --git a/internal/api/handlers/sources.go b/internal/api/handlers/sources.go new file mode 100644 index 0000000..ff2db09 --- /dev/null +++ b/internal/api/handlers/sources.go @@ -0,0 +1,298 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/config" + "streamgo/internal/services" +) + +// SourcesHandler manages admin source filtering and ingestion controls. +type SourcesHandler struct { + cfg *config.Config + filter *services.AccessFilter + authSvc *services.AuthService + + mu sync.RWMutex + allowed []map[string]any + banned []map[string]any +} + +// NewSourcesHandler creates a new SourcesHandler instance. +func NewSourcesHandler(cfg *config.Config, filter *services.AccessFilter, authSvc *services.AuthService) *SourcesHandler { + return &SourcesHandler{ + cfg: cfg, + filter: filter, + authSvc: authSvc, + allowed: make([]map[string]any, 0), + banned: make([]map[string]any, 0), + } +} + +// Routes mounts the admin sources endpoints. +func (h *SourcesHandler) Routes(r chi.Router) { + r.Route("/admin/sources", func(sub chi.Router) { + sub.Use(h.requireAdminAuth) + + sub.Get("/", h.GetOverview) + + sub.Get("/mode", h.GetMode) + sub.Post("/mode", h.SetMode) + + sub.Get("/allowed", h.ListAllowed) + sub.Post("/allowed", h.AddAllowed) + sub.Delete("/allowed/{source_id}", h.RemoveAllowed) + + sub.Get("/banned", h.ListBanned) + sub.Post("/banned", h.AddBanned) + sub.Delete("/banned/{source_id}", h.RemoveBanned) + }) +} + +func (h *SourcesHandler) requireAdminAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + secret := r.Header.Get("X-Secret-Key") + if h.cfg != nil && h.cfg.SecretKey != "" && secret == h.cfg.SecretKey { + next.ServeHTTP(w, r) + return + } + api.RespondError(w, http.StatusUnauthorized, "Admin authentication required") + }) +} + +// GetOverview returns summary of filter mode and allowed/banned sources. +func (h *SourcesHandler) GetOverview(w http.ResponseWriter, r *http.Request) { + h.mu.RLock() + defer h.mu.RUnlock() + + mode := services.FilterModeGroupOnly + if h.cfg != nil { + mode = h.cfg.FilterMode + } + + var channelID int64 + collaborators := []int64{} + if h.cfg != nil { + channelID = h.cfg.ChannelID + collaborators = h.cfg.CollaboratorIDs + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "filter_mode": mode, + "filter_mode_name": services.FilterModeToString(mode), + "channel_id": channelID, + "collaborator_ids": collaborators, + "allowed_sources_count": len(h.allowed), + "banned_sources_count": len(h.banned), + "allowed_sources": h.allowed, + "banned_sources": h.banned, + }) +} + +// GetMode returns the current filter mode. +func (h *SourcesHandler) GetMode(w http.ResponseWriter, r *http.Request) { + mode := services.FilterModeGroupOnly + if h.cfg != nil { + mode = h.cfg.FilterMode + } + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "filter_mode": mode, + "filter_mode_name": services.FilterModeToString(mode), + }) +} + +// SetMode updates the active filter mode. +func (h *SourcesHandler) SetMode(w http.ResponseWriter, r *http.Request) { + var payload struct { + Mode any `json:"mode"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + parsed := services.ParseFilterMode(payload.Mode) + if h.cfg != nil { + h.cfg.FilterMode = parsed + } + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "filter_mode": parsed, + "filter_mode_name": services.FilterModeToString(parsed), + }) +} + +// ListAllowed returns all allowed sources. +func (h *SourcesHandler) ListAllowed(w http.ResponseWriter, r *http.Request) { + h.mu.RLock() + defer h.mu.RUnlock() + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "count": len(h.allowed), + "sources": h.allowed, + }) +} + +// AddAllowed adds a source to allowed list. +func (h *SourcesHandler) AddAllowed(w http.ResponseWriter, r *http.Request) { + var payload struct { + SourceID any `json:"source_id"` + SourceType string `json:"source_type"` + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + var sid int64 + switch v := payload.SourceID.(type) { + case float64: + sid = int64(v) + case int64: + sid = v + case int: + sid = int64(v) + case string: + sid, _ = strconv.ParseInt(strings.TrimSpace(v), 10, 64) + } + + doc := map[string]any{ + "source_id": sid, + "source_type": payload.SourceType, + "name": payload.Name, + "created_at": time.Now().Unix(), + } + + h.mu.Lock() + h.allowed = append(h.allowed, doc) + h.mu.Unlock() + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "source": doc, + }) +} + +// RemoveAllowed removes a source from allowed list. +func (h *SourcesHandler) RemoveAllowed(w http.ResponseWriter, r *http.Request) { + sidStr := chi.URLParam(r, "source_id") + sid, err := strconv.ParseInt(sidStr, 10, 64) + if err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid source_id") + return + } + + h.mu.Lock() + newAllowed := make([]map[string]any, 0, len(h.allowed)) + removed := false + for _, item := range h.allowed { + if id, ok := item["source_id"].(int64); ok && id == sid { + removed = true + continue + } + newAllowed = append(newAllowed, item) + } + h.allowed = newAllowed + h.mu.Unlock() + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "source_id": sid, + "removed": removed, + }) +} + +// ListBanned returns all banned sources. +func (h *SourcesHandler) ListBanned(w http.ResponseWriter, r *http.Request) { + h.mu.RLock() + defer h.mu.RUnlock() + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "count": len(h.banned), + "sources": h.banned, + }) +} + +// AddBanned bans a source. +func (h *SourcesHandler) AddBanned(w http.ResponseWriter, r *http.Request) { + var payload struct { + SourceID any `json:"source_id"` + SourceType string `json:"source_type"` + Name string `json:"name"` + Reason string `json:"reason"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid request body") + return + } + + var sid int64 + switch v := payload.SourceID.(type) { + case float64: + sid = int64(v) + case int64: + sid = v + case int: + sid = int64(v) + case string: + sid, _ = strconv.ParseInt(strings.TrimSpace(v), 10, 64) + } + + doc := map[string]any{ + "source_id": sid, + "source_type": payload.SourceType, + "name": payload.Name, + "reason": payload.Reason, + "created_at": time.Now().Unix(), + } + + h.mu.Lock() + h.banned = append(h.banned, doc) + h.mu.Unlock() + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "source": doc, + }) +} + +// RemoveBanned unbans a source. +func (h *SourcesHandler) RemoveBanned(w http.ResponseWriter, r *http.Request) { + sidStr := chi.URLParam(r, "source_id") + sid, err := strconv.ParseInt(sidStr, 10, 64) + if err != nil { + api.RespondError(w, http.StatusBadRequest, "invalid source_id") + return + } + + h.mu.Lock() + newBanned := make([]map[string]any, 0, len(h.banned)) + removed := false + for _, item := range h.banned { + if id, ok := item["source_id"].(int64); ok && id == sid { + removed = true + continue + } + newBanned = append(newBanned, item) + } + h.banned = newBanned + h.mu.Unlock() + + api.RespondJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "source_id": sid, + "removed": removed, + }) +} diff --git a/internal/api/handlers/stream.go b/internal/api/handlers/stream.go new file mode 100644 index 0000000..b86a534 --- /dev/null +++ b/internal/api/handlers/stream.go @@ -0,0 +1,164 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + "net/url" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/api/middleware" + "streamgo/internal/services" +) + +// StreamHandler handles audio streaming and download endpoints. +type StreamHandler struct { + streamService *services.StreamService + authSvc *services.AuthService + histSvc *services.HistoryService + recentlyRecorded sync.Map +} + +// NewStreamHandler creates a new StreamHandler. +func NewStreamHandler(svc *services.StreamService, authSvc *services.AuthService, histSvc *services.HistoryService) *StreamHandler { + return &StreamHandler{ + streamService: svc, + authSvc: authSvc, + histSvc: histSvc, + } +} + +// Routes mounts the streaming and download endpoints. +func (h *StreamHandler) Routes(r chi.Router) { + optionalAuth := middleware.OptionalAuth(h.authSvc) + + r.With(optionalAuth).Get("/tracks/{id}/stream", h.Stream) + r.With(optionalAuth).Head("/tracks/{id}/stream", h.Stream) + r.With(optionalAuth).Get("/stream/{id}", h.Stream) + r.With(optionalAuth).Head("/stream/{id}", h.Stream) + + r.With(optionalAuth).Get("/tracks/{id}/download", h.Download) + r.With(optionalAuth).Head("/tracks/{id}/download", h.Download) + r.With(optionalAuth).Get("/download/{id}", h.Download) + r.With(optionalAuth).Head("/download/{id}", h.Download) + + r.Get("/tracks/{id}/warm", h.Warm) +} + +// Stream handles GET and HEAD /tracks/{id}/stream with HTTP Range support. +func (h *StreamHandler) Stream(w http.ResponseWriter, r *http.Request) { + trackID := chi.URLParam(r, "id") + trackID = strings.TrimSpace(trackID) + if trackID == "" { + api.RespondError(w, http.StatusBadRequest, "track id is required") + return + } + + // Record playback in userHistory and counters upon stream start + if h.histSvc != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead) { + userID := h.extractUserID(r) + if userID > 0 { + now := float64(time.Now().Unix()) + cacheKey := fmt.Sprintf("%d:%s", userID, trackID) + shouldRecord := true + if val, ok := h.recentlyRecorded.Load(cacheKey); ok { + if lastTs, ok := val.(float64); ok && (now-lastTs < 15.0) { + shouldRecord = false + } + } + + if shouldRecord { + h.recentlyRecorded.Store(cacheKey, now) + go func(uid int64, tid string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = h.histSvc.RecordPlay(ctx, uid, tid, "direct") + }(userID, trackID) + } + } + } + + h.streamService.StreamTrack(w, r, trackID) +} + +func (h *StreamHandler) extractUserID(r *http.Request) int64 { + if uid, ok := middleware.GetUserID(r.Context()); ok && uid > 0 { + return uid + } + if h.authSvc == nil { + return 0 + } + token := middleware.ExtractToken(r) + if token == "" { + return 0 + } + claims, err := h.authSvc.VerifyTokenClaims(token) + if err != nil || claims == nil || claims.UserID <= 0 { + return 0 + } + return claims.UserID +} + +// Download handles GET and HEAD /tracks/{id}/download with Content-Disposition attachment. +func (h *StreamHandler) Download(w http.ResponseWriter, r *http.Request) { + trackID := chi.URLParam(r, "id") + trackID = strings.TrimSpace(trackID) + if trackID == "" { + api.RespondError(w, http.StatusBadRequest, "track id is required") + return + } + + track, _ := h.streamService.GetTrack(r.Context(), trackID) + if track != nil { + filename := track.Audio.Title + if track.Audio.Artist != "" { + filename = fmt.Sprintf("%s - %s", track.Audio.Artist, track.Audio.Title) + } + if filename == "" { + filename = track.Telegram.FileName + } + if filename == "" { + filename = fmt.Sprintf("track-%s", track.ID) + } + ext := track.Audio.Type + if (h.streamService.ALACService() != nil && h.streamService.ALACService().ShouldDecodeALAC(r, track)) || strings.ToLower(r.URL.Query().Get("format")) == "flac" { + ext = "flac" + } + if ext == "alac" { + ext = "m4a" + } + if ext == "" { + ext = "mp3" + } + cleanFilename := strings.TrimSuffix(filename, filepath.Ext(filename)) + filename = fmt.Sprintf("%s.%s", cleanFilename, ext) + + fallback := strings.ReplaceAll(filename, `"`, `_`) + encoded := url.QueryEscape(filename) + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, fallback, encoded)) + } else { + w.Header().Set("Content-Disposition", `attachment; filename="track.mp3"`) + } + + h.streamService.StreamTrack(w, r, trackID) +} + +// Warm handles GET /tracks/{id}/warm to prewarm playback cache. +func (h *StreamHandler) Warm(w http.ResponseWriter, r *http.Request) { + trackID := chi.URLParam(r, "id") + trackID = strings.TrimSpace(trackID) + if trackID == "" { + api.RespondError(w, http.StatusBadRequest, "track id is required") + return + } + + ready := h.streamService.IsTelegramReady() + go h.streamService.WarmTrack(context.Background(), trackID) + api.RespondJSON(w, http.StatusOK, map[string]any{"ok": true, "ready": ready}) +} diff --git a/internal/api/handlers/topics.go b/internal/api/handlers/topics.go new file mode 100644 index 0000000..a1fe7dd --- /dev/null +++ b/internal/api/handlers/topics.go @@ -0,0 +1,83 @@ +package handlers + +import ( + "net/http" + "net/url" + "strings" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/services" +) + +// TopicHandler handles routes related to topics and source channels. +type TopicHandler struct { + trackService *services.TrackService +} + +// NewTopicHandler creates a new TopicHandler. +func NewTopicHandler(svc *services.TrackService) *TopicHandler { + return &TopicHandler{trackService: svc} +} + +// Routes mounts topic and channel endpoints. +func (h *TopicHandler) Routes(r chi.Router) { + r.Get("/topics", h.ListTopics) + r.Get("/topics/{name}/tracks", h.TopicTracks) + r.Get("/channelids", h.ChannelIDs) +} + +// ListTopics handles GET /topics. +func (h *TopicHandler) ListTopics(w http.ResponseWriter, r *http.Request) { + limit := api.ParseQueryInt(r, "limit", 100) + channelID := api.ParseQueryInt64(r, "channel_id", 0) + + resp, err := h.trackService.GetTopics(r.Context(), channelID, limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// TopicTracks handles GET /topics/{name}/tracks with pagination. +func (h *TopicHandler) TopicTracks(w http.ResponseWriter, r *http.Request) { + rawName := chi.URLParam(r, "name") + topicName, err := url.PathUnescape(rawName) + if err != nil || strings.TrimSpace(topicName) == "" { + topicName = rawName + } + topicName = strings.TrimSpace(topicName) + if topicName == "" { + api.RespondError(w, http.StatusBadRequest, "topic name is required") + return + } + + page := api.ParseQueryInt(r, "page", 1) + perPage := api.ParseQueryInt(r, "per_page", 20) + if limit := api.ParseQueryInt(r, "limit", 0); limit > 0 { + perPage = limit + } + channelID := api.ParseQueryInt64(r, "channel_id", 0) + + resp, err := h.trackService.Browse(r.Context(), page, perPage, "", topicName, channelID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// ChannelIDs handles GET /channelids. +func (h *TopicHandler) ChannelIDs(w http.ResponseWriter, r *http.Request) { + resp, err := h.trackService.GetChannelIDs(r.Context()) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} diff --git a/internal/api/handlers/tracks.go b/internal/api/handlers/tracks.go new file mode 100644 index 0000000..34ad844 --- /dev/null +++ b/internal/api/handlers/tracks.go @@ -0,0 +1,307 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "streamgo/internal/api" + "streamgo/internal/api/middleware" + "streamgo/internal/config" + "streamgo/internal/services" +) + +// TrackHandler handles HTTP routes for track operations. +type TrackHandler struct { + trackService *services.TrackService + authSvc *services.AuthService + cfg *config.Config +} + +// NewTrackHandler creates a new TrackHandler. +func NewTrackHandler(svc *services.TrackService, authSvc *services.AuthService, cfg *config.Config) *TrackHandler { + return &TrackHandler{ + trackService: svc, + authSvc: authSvc, + cfg: cfg, + } +} + +// Routes mounts track endpoints on the given Chi router. +func (h *TrackHandler) Routes(r chi.Router) { + r.Get("/tracks", h.List) + r.Get("/browse", h.List) + r.Get("/search", h.Search) + r.Get("/tracks/search", h.Search) + r.Get("/tracks/random", h.Random) + r.Get("/tracks/shuffle", h.Random) + r.Get("/library/shuffle", h.Random) + r.Get("/tracks/{id}", h.GetByID) + + r.Group(func(sub chi.Router) { + if h.authSvc != nil { + sub.Use(middleware.RequireAuth(h.authSvc)) + } + sub.Delete("/tracks/{id}", h.Delete) + sub.Delete("/admin/tracks/delete", h.AdminDelete) + sub.Post("/admin/tracks/delete", h.AdminDelete) + }) +} + +// List handles GET /tracks and GET /browse with pagination, sorting, and filters. +func (h *TrackHandler) List(w http.ResponseWriter, r *http.Request) { + page := api.ParseQueryInt(r, "page", 1) + perPage := api.ParseQueryInt(r, "per_page", 20) + if limit := api.ParseQueryInt(r, "limit", 0); limit > 0 { + perPage = limit + } + + sortField := strings.TrimSpace(r.URL.Query().Get("sort")) + topicName := strings.TrimSpace(r.URL.Query().Get("topic")) + channelID := api.ParseQueryInt64(r, "channel_id", 0) + + resp, err := h.trackService.Browse(r.Context(), page, perPage, sortField, topicName, channelID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, resp) +} + +// GetByID handles GET /tracks/{id}. +func (h *TrackHandler) GetByID(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if strings.TrimSpace(id) == "" { + api.RespondError(w, http.StatusBadRequest, "track id is required") + return + } + + track, err := h.trackService.GetTrack(r.Context(), id) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + if track == nil { + api.RespondError(w, http.StatusNotFound, "track not found") + return + } + + if effType := track.EffectiveType(); effType != "" { + track.Audio.Type = effType + } + + api.RespondJSON(w, http.StatusOK, track) +} + +// Search handles GET /search with text queries. +func (h *TrackHandler) Search(w http.ResponseWriter, r *http.Request) { + query := strings.TrimSpace(r.URL.Query().Get("q")) + if query == "" { + query = strings.TrimSpace(r.URL.Query().Get("query")) + } + limit := api.ParseQueryInt(r, "limit", 50) + + items, err := h.trackService.Search(r.Context(), query, limit) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]interface{}{ + "query": query, + "total": len(items), + "items": items, + }) +} + +// Random handles GET /tracks/random. +func (h *TrackHandler) Random(w http.ResponseWriter, r *http.Request) { + limit := api.ParseQueryInt(r, "limit", 20) + channelID := api.ParseQueryInt64(r, "channel_id", 0) + + items, err := h.trackService.GetRandom(r.Context(), limit, channelID) + if err != nil { + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]interface{}{ + "total": len(items), + "items": items, + }) +} + +// checkAdmin verifies caller has administrator privileges (consistent with access_control.go). +func (h *TrackHandler) checkAdmin(r *http.Request) error { + if middleware.IsGuest(r.Context()) { + return nil + } + + userID, ok := middleware.GetUserID(r.Context()) + if !ok || userID <= 0 { + return fmt.Errorf("authentication required") + } + + if h.cfg == nil || (len(h.cfg.OwnerIDs) == 0 && len(h.cfg.SudoUsers) == 0) { + return nil + } + + for _, oid := range h.cfg.OwnerIDs { + if oid == userID { + return nil + } + } + for _, sid := range h.cfg.SudoUsers { + if sid == userID { + return nil + } + } + + return fmt.Errorf("forbidden: administrator privileges required") +} + +// Delete handles DELETE /tracks/{id}. +func (h *TrackHandler) Delete(w http.ResponseWriter, r *http.Request) { + if err := h.checkAdmin(r); err != nil { + if strings.Contains(err.Error(), "forbidden") { + api.RespondError(w, http.StatusForbidden, err.Error()) + } else { + api.RespondError(w, http.StatusUnauthorized, err.Error()) + } + return + } + + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + api.RespondError(w, http.StatusBadRequest, "track id is required") + return + } + + hard := api.ParseQueryBool(r, "hard", true) + purgeCache := api.ParseQueryBool(r, "purge_cache", true) + purgeArtwork := api.ParseQueryBool(r, "purge_artwork", true) + + opts := services.DeleteTrackOptions{ + Hard: hard, + PurgeCache: purgeCache, + PurgeArtwork: purgeArtwork, + } + + res, err := h.trackService.DeleteTrack(r.Context(), id, opts) + if err != nil { + if strings.Contains(err.Error(), "not found") { + api.RespondError(w, http.StatusNotFound, "track not found") + return + } + api.RespondError(w, http.StatusInternalServerError, err.Error()) + return + } + + api.RespondJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "status": http.StatusOK, + "track_id": res.TrackID, + "mode": res.Mode, + "cache_purged": res.CachePurged, + "purged_artworks": res.PurgedArtworks, + "message": res.Message, + }) +} + +// AdminDeleteTracksRequest is the payload for /admin/tracks/delete. +type AdminDeleteTracksRequest struct { + TrackID any `json:"track_id"` + TrackIDs []string `json:"track_ids"` +} + +// AdminDelete handles DELETE and POST /admin/tracks/delete (compatible with StreamXBot). +func (h *TrackHandler) AdminDelete(w http.ResponseWriter, r *http.Request) { + if err := h.checkAdmin(r); err != nil { + if strings.Contains(err.Error(), "forbidden") { + api.RespondError(w, http.StatusForbidden, err.Error()) + } else { + api.RespondError(w, http.StatusUnauthorized, err.Error()) + } + return + } + + var trackIDs []string + seen := make(map[string]bool) + + // 1. Try reading JSON body if present + if r.Body != nil { + var req AdminDeleteTracksRequest + if err := json.NewDecoder(r.Body).Decode(&req); err == nil { + if s, ok := req.TrackID.(string); ok && strings.TrimSpace(s) != "" { + s = strings.TrimSpace(s) + if !seen[s] { + seen[s] = true + trackIDs = append(trackIDs, s) + } + } else if slice, ok := req.TrackID.([]any); ok { + for _, item := range slice { + s := strings.TrimSpace(fmt.Sprint(item)) + if s != "" && !seen[s] { + seen[s] = true + trackIDs = append(trackIDs, s) + } + } + } + for _, tid := range req.TrackIDs { + tid = strings.TrimSpace(tid) + if tid != "" && !seen[tid] { + seen[tid] = true + trackIDs = append(trackIDs, tid) + } + } + } + } + + // 2. Fallback or merge query parameters + if qID := strings.TrimSpace(r.URL.Query().Get("track_id")); qID != "" && !seen[qID] { + seen[qID] = true + trackIDs = append(trackIDs, qID) + } + for _, qIDs := range r.URL.Query()["track_ids"] { + for _, tid := range strings.Split(qIDs, ",") { + tid = strings.TrimSpace(tid) + if tid != "" && !seen[tid] { + seen[tid] = true + trackIDs = append(trackIDs, tid) + } + } + } + + if len(trackIDs) == 0 { + api.RespondError(w, http.StatusBadRequest, "track_id or track_ids is required") + return + } + + hard := api.ParseQueryBool(r, "hard", true) + purgeCache := api.ParseQueryBool(r, "purge_cache", true) + purgeArtwork := api.ParseQueryBool(r, "purge_artwork", true) + + opts := services.DeleteTrackOptions{ + Hard: hard, + PurgeCache: purgeCache, + PurgeArtwork: purgeArtwork, + } + + deletedCount := 0 + for _, tid := range trackIDs { + if _, err := h.trackService.DeleteTrack(r.Context(), tid, opts); err == nil { + deletedCount++ + } + } + + api.RespondJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "track_ids": trackIDs, + "matched": len(trackIDs), + "deleted": deletedCount, + }) +} diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go new file mode 100644 index 0000000..8370c24 --- /dev/null +++ b/internal/api/middleware/auth.go @@ -0,0 +1,162 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "streamgo/internal/api" + "streamgo/internal/services" +) + +type contextKey string + +const ( + userIDKey contextKey = "userID" + isGuestKey contextKey = "isGuest" +) + +// WithUserID injects a numeric user ID into context. +func WithUserID(ctx context.Context, userID int64) context.Context { + return context.WithValue(ctx, userIDKey, userID) +} + +// GetUserID extracts the user ID from context, returning ok=false if not found. +func GetUserID(ctx context.Context) (int64, bool) { + val := ctx.Value(userIDKey) + if val == nil { + return 0, false + } + uid, ok := val.(int64) + return uid, ok && uid > 0 +} + +// WithGuest marks context as a guest session. +func WithGuest(ctx context.Context, isGuest bool) context.Context { + return context.WithValue(ctx, isGuestKey, isGuest) +} + +// IsGuest checks if current request is authenticated as guest. +func IsGuest(ctx context.Context) bool { + val := ctx.Value(isGuestKey) + if val == nil { + return false + } + g, ok := val.(bool) + return ok && g +} + +// ExtractToken extracts auth token from headers, query string, or cookies. +func ExtractToken(r *http.Request) string { + // 1. Authorization: Bearer + authHeader := strings.TrimSpace(r.Header.Get("Authorization")) + if authHeader != "" { + if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { + return strings.TrimSpace(authHeader[7:]) + } + return authHeader + } + + // 2. X-Auth-Token header + if xAuth := strings.TrimSpace(r.Header.Get("X-Auth-Token")); xAuth != "" { + return xAuth + } + + // 3. Query string (?token=... or ?auth=...) + if qToken := strings.TrimSpace(r.URL.Query().Get("token")); qToken != "" { + return qToken + } + if qAuth := strings.TrimSpace(r.URL.Query().Get("auth")); qAuth != "" { + return qAuth + } + + // 4. Cookies (auth_token or token) + if c, err := r.Cookie("auth_token"); err == nil && strings.TrimSpace(c.Value) != "" { + return strings.TrimSpace(c.Value) + } + if c, err := r.Cookie("token"); err == nil && strings.TrimSpace(c.Value) != "" { + return strings.TrimSpace(c.Value) + } + + return "" +} + +func extractGuestPassword(r *http.Request) string { + if gp := strings.TrimSpace(r.Header.Get("X-Guest-Password")); gp != "" { + return gp + } + if gp := strings.TrimSpace(r.URL.Query().Get("guest_password")); gp != "" { + return gp + } + return "" +} + +// RequireAuth enforces a valid authentication token or valid guest password. +func RequireAuth(authSvc *services.AuthService) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 1. Check direct guest password bypass + if guestPwd := extractGuestPassword(r); guestPwd != "" { + if authSvc.VerifyGuestPassword(r.Context(), guestPwd) { + ctx := WithGuest(r.Context(), true) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + } + + // 2. Check token + token := ExtractToken(r) + if token == "" { + api.RespondError(w, http.StatusUnauthorized, "Authentication required") + return + } + + claims, err := authSvc.VerifyTokenClaims(token) + if err != nil { + api.RespondError(w, http.StatusUnauthorized, "Invalid or expired authentication token") + return + } + + if claims.IsGuest { + ctx := WithGuest(r.Context(), true) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + if claims.UserID <= 0 { + api.RespondError(w, http.StatusUnauthorized, "Invalid user ID in token") + return + } + + ctx := WithUserID(r.Context(), claims.UserID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// OptionalAuth parses authentication token or guest password without rejecting unauthenticated requests. +func OptionalAuth(authSvc *services.AuthService) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if guestPwd := extractGuestPassword(r); guestPwd != "" { + if authSvc.VerifyGuestPassword(ctx, guestPwd) { + ctx = WithGuest(ctx, true) + } + } + + token := ExtractToken(r) + if token != "" { + if claims, err := authSvc.VerifyTokenClaims(token); err == nil { + if claims.IsGuest { + ctx = WithGuest(ctx, true) + } else if claims.UserID > 0 { + ctx = WithUserID(ctx, claims.UserID) + } + } + } + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/internal/api/response.go b/internal/api/response.go new file mode 100644 index 0000000..2aaf991 --- /dev/null +++ b/internal/api/response.go @@ -0,0 +1,70 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" +) + +// ErrorResponse defines standard API error envelope. +type ErrorResponse struct { + OK bool `json:"ok"` + Detail string `json:"detail"` + Status int `json:"status"` +} + +// RespondJSON writes a JSON payload with standard status code. +func RespondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} + +// RespondError writes a standardized JSON error message. +func RespondError(w http.ResponseWriter, status int, message string) { + RespondJSON(w, status, ErrorResponse{ + OK: false, + Detail: message, + Status: status, + }) +} + +// ParseQueryInt parses an integer query parameter with a fallback default. +func ParseQueryInt(r *http.Request, key string, defaultVal int) int { + val := strings.TrimSpace(r.URL.Query().Get(key)) + if val == "" { + return defaultVal + } + if n, err := strconv.Atoi(val); err == nil { + return n + } + return defaultVal +} + +// ParseQueryInt64 parses an int64 query parameter with a fallback default. +func ParseQueryInt64(r *http.Request, key string, defaultVal int64) int64 { + val := strings.TrimSpace(r.URL.Query().Get(key)) + if val == "" { + return defaultVal + } + if n, err := strconv.ParseInt(val, 10, 64); err == nil { + return n + } + return defaultVal +} + +// ParseQueryBool parses a boolean query parameter with a fallback default. +func ParseQueryBool(r *http.Request, key string, defaultVal bool) bool { + val := strings.TrimSpace(strings.ToLower(r.URL.Query().Get(key))) + if val == "" { + return defaultVal + } + if val == "1" || val == "true" || val == "yes" || val == "on" { + return true + } + if val == "0" || val == "false" || val == "no" || val == "off" { + return false + } + return defaultVal +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..34edeb5 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,226 @@ +package config + +import ( + "encoding/json" + "os" + "strconv" + "strings" + + "github.com/joho/godotenv" + "streamgo/internal/logger" +) + +var log = logger.New("config") + +// Config holds all configuration parameters for StreamGO. +type Config struct { + Port string + Debug bool + APILogs bool + CorsOrigin string + MongoURI string + DatabaseName string + ApiID int + ApiHash string + BotToken string + SecretKey string + SessionString string + ChannelID int64 + DumpChannelID int64 + MultiClients bool + MultiClientTokens []string + FilterMode int + ChatTopic string + CollaboratorIDs []int64 + Lyrics bool + LRCLIB bool + Musixmatch bool + TelegramOIDCClientID string + TelegramOIDCClientSecret string + TelegramOIDCOrigin string + TelegramOIDCRedirectURI string + CookieSecure bool + CookieSameSite string + OwnerIDs []int64 + SudoUsers []int64 + EnrichmentWorkers int + GuestPassword string + AlacCacheMaxBytes int64 + AlacCacheMaxFiles int + + // Cloudflare R2 / S3 Blob Storage (Optional) + R2AccountID string + R2AccessKeyID string + R2SecretAccessKey string + R2BucketName string + R2PublicURL string +} + +// Load reads configuration from .env file (if present) and environment variables. +func Load() *Config { + // Try loading .env; ignore error if not found (e.g., in container/production) + if err := godotenv.Load(); err != nil { + log.Info("No .env file found, using system environment variables") + } else { + log.Info("Loaded configuration from .env") + } + + debug := getEnvBool("DEBUG", false) + logger.SetDebug(debug) + + mongoURI := strings.TrimSpace(getEnv("MONGO_URI", "mongodb://localhost:27017")) + // Sanitize any accidental leading equals sign (e.g. MONGO_URI==...) + mongoURI = strings.TrimPrefix(mongoURI, "=") + + multiClients := getEnvBool("MULTI_CLIENTS", false) + var multiTokens []string + for _, key := range []string{"MULTI_CLIENTS_1", "MULTI_CLIENTS_2", "MULTI_CLIENTS_3", "MULTI_CLIENTS_4"} { + if val := strings.TrimSpace(os.Getenv(key)); val != "" { + multiTokens = append(multiTokens, val) + } + } + if extra := strings.TrimSpace(os.Getenv("MULTI_CLIENT_TOKENS")); extra != "" { + for _, part := range strings.Split(extra, ",") { + if p := strings.TrimSpace(part); p != "" { + multiTokens = append(multiTokens, p) + } + } + } + if len(multiTokens) > 0 { + multiClients = true + } + + // Parse collaborator IDs from COLLABORATOR_ID and COLLABORATOR_IDS + collabList := parseIDList(os.Getenv("COLLABORATOR_IDS")) + if singleCollab := getEnvInt64("COLLABORATOR_ID", 0); singleCollab != 0 { + collabList = append(collabList, singleCollab) + } + + // Owner and Sudo users + ownerIDs := parseIDList(os.Getenv("OWNER_ID")) + if len(ownerIDs) == 0 { + if oid := getEnvInt64("OWNER_ID", 0); oid != 0 { + ownerIDs = []int64{oid} + } + } + sudoUsers := parseIDList(os.Getenv("SUDO_USERS")) + + return &Config{ + Port: getEnv("PORT", "8000"), + Debug: debug, + APILogs: getEnvBool("API_LOGS", getEnvBool("HTTP_LOGS", false)), + CorsOrigin: getEnv("CORS_ORIGIN", "*"), + MongoURI: mongoURI, + DatabaseName: getEnv("DATABASE_NAME", "Stream"), + ApiID: getEnvInt("API_ID", 0), + ApiHash: strings.TrimSpace(os.Getenv("API_HASH")), + BotToken: strings.TrimSpace(os.Getenv("BOT_TOKEN")), + SecretKey: strings.TrimSpace(os.Getenv("SECRET_KEY")), + SessionString: strings.TrimSpace(os.Getenv("SESSION_STRING")), + ChannelID: getEnvInt64("CHANNEL_ID", 0), + DumpChannelID: getEnvInt64("DUMP_CHANNEL_ID", 0), + MultiClients: multiClients, + MultiClientTokens: multiTokens, + FilterMode: getEnvInt("FILTER_MODE", 0), + ChatTopic: strings.TrimSpace(getEnv("CHAT_TOPIC", "all")), + CollaboratorIDs: collabList, + Lyrics: getEnvBool("LYRICS", true), + LRCLIB: getEnvBool("LRCLIB", false), + Musixmatch: getEnvBool("MUSIXMATCH", true), + TelegramOIDCClientID: strings.TrimSpace(os.Getenv("TELEGRAM_OIDC_CLIENT_ID")), + TelegramOIDCClientSecret: strings.TrimSpace(os.Getenv("TELEGRAM_OIDC_CLIENT_SECRET")), + TelegramOIDCOrigin: strings.TrimSpace(os.Getenv("TELEGRAM_OIDC_ORIGIN")), + TelegramOIDCRedirectURI: strings.TrimSpace(os.Getenv("TELEGRAM_OIDC_REDIRECT_URI")), + CookieSecure: getEnvBool("COOKIE_SECURE", false), + CookieSameSite: getEnv("COOKIE_SAMESITE", "lax"), + OwnerIDs: ownerIDs, + SudoUsers: sudoUsers, + EnrichmentWorkers: getEnvInt("ENRICHMENT_WORKERS", 2), + GuestPassword: getEnv("GUEST_PASSWORD", ""), + AlacCacheMaxBytes: getEnvInt64("ALAC_CACHE_MAX_BYTES", 5*1024*1024*1024), + AlacCacheMaxFiles: getEnvInt("ALAC_CACHE_MAX_FILES", 200), + R2AccountID: getEnv("R2_ACCOUNT_ID", ""), + R2AccessKeyID: getEnv("R2_ACCESS_KEY_ID", ""), + R2SecretAccessKey: getEnv("R2_SECRET_ACCESS_KEY", ""), + R2BucketName: getEnv("R2_BUCKET_NAME", ""), + R2PublicURL: strings.TrimRight(getEnv("R2_PUBLIC_URL", ""), "/"), + } +} + +// HasR2 returns true if all necessary Cloudflare R2 credentials are provided. +func (c *Config) HasR2() bool { + return c.R2AccountID != "" && c.R2AccessKeyID != "" && c.R2SecretAccessKey != "" && c.R2BucketName != "" +} + +func parseIDList(raw string) []int64 { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + // Try JSON array first + if strings.HasPrefix(raw, "[") && strings.HasSuffix(raw, "]") { + var arr []any + if err := json.Unmarshal([]byte(raw), &arr); err == nil { + var ids []int64 + for _, item := range arr { + switch v := item.(type) { + case float64: + ids = append(ids, int64(v)) + case string: + if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil { + ids = append(ids, n) + } + } + } + return ids + } + } + + // Delimiter split (comma, space) + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ' ' || r == ';' + }) + var ids []int64 + for _, f := range fields { + if n, err := strconv.ParseInt(strings.TrimSpace(f), 10, 64); err == nil { + ids = append(ids, n) + } + } + return ids +} + +func getEnv(key, defaultVal string) string { + if val, ok := os.LookupEnv(key); ok && strings.TrimSpace(val) != "" { + v := strings.TrimSpace(val) + v = strings.Trim(v, "\"'") + return v + } + return defaultVal +} + +func getEnvBool(key string, defaultVal bool) bool { + if val, ok := os.LookupEnv(key); ok { + val = strings.ToLower(strings.TrimSpace(val)) + return val == "true" || val == "1" || val == "yes" + } + return defaultVal +} + +func getEnvInt(key string, defaultVal int) int { + if val, ok := os.LookupEnv(key); ok { + if n, err := strconv.Atoi(strings.TrimSpace(val)); err == nil { + return n + } + } + return defaultVal +} + +func getEnvInt64(key string, defaultVal int64) int64 { + if val, ok := os.LookupEnv(key); ok { + if n, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64); err == nil { + return n + } + } + return defaultVal +} diff --git a/internal/config/config_manager.go b/internal/config/config_manager.go new file mode 100644 index 0000000..e73ce80 --- /dev/null +++ b/internal/config/config_manager.go @@ -0,0 +1,737 @@ +package config + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "sort" + "strconv" + "strings" + "sync" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// SecretKeys identifies sensitive configuration parameters that should not be exposed or modified lightly. +var SecretKeys = map[string]bool{ + "BOT_TOKEN": true, + "API_ID": true, + "API_HASH": true, + "SESSION_STRING": true, + "MONGO_URI": true, + "MULTI_CLIENTS": true, + "ONLY_API": true, + "CORS_ORIGINS": true, + "FIREBASE_CREDENTIALS": true, + "TELEGRAM_OIDC_CLIENT_SECRET": true, + "SECRET_KEY": true, + "MULTI_CLIENT_TOKENS": true, +} + +// ConfigManager handles dynamic configuration loading, database synchronization, +// type casting, and runtime updates matching StreamXBot's config_manager.py. +type ConfigManager struct { + cfg *Config + db *mongo.Database + col *mongo.Collection + mu sync.RWMutex + overrideTypes map[string]string + extra map[string]any + listeners []func(key string, val any) +} + +// NewManager creates a new ConfigManager wrapping the provided Config. +func NewManager(cfg *Config) *ConfigManager { + return &ConfigManager{ + cfg: cfg, + overrideTypes: make(map[string]string), + extra: make(map[string]any), + } +} + +// SetDatabase binds the MongoDB database for persistence. +func (m *ConfigManager) SetDatabase(db *mongo.Database) { + m.mu.Lock() + defer m.mu.Unlock() + m.db = db + if db != nil { + m.col = db.Collection("botsettings") + } +} + +// Config returns the underlying Config pointer. +func (m *ConfigManager) Config() *Config { + return m.cfg +} + +// OnUpdate registers a listener callback invoked when any config key is updated. +func (m *ConfigManager) OnUpdate(fn func(key string, val any)) { + m.mu.Lock() + defer m.mu.Unlock() + m.listeners = append(m.listeners, fn) +} + +// IsSecretKey checks if the specified key contains sensitive credentials. +func (m *ConfigManager) IsSecretKey(key string) bool { + k := strings.ToUpper(strings.TrimSpace(key)) + if SecretKeys[k] { + return true + } + if strings.HasPrefix(k, "MULTI_CLIENTS_") { + return true + } + return false +} + +// LoadFromDB synchronizes the configuration with the MongoDB botsettings collection (doc: bot_config). +// If the document does not exist, it persists the current defaults. +// If it exists, it backfills missing keys and applies database overrides to runtime state. +func (m *ConfigManager) LoadFromDB(ctx context.Context, db *mongo.Database) error { + m.SetDatabase(db) + if m.col == nil { + return errors.New("database not initialized") + } + + var doc bson.M + err := m.col.FindOne(ctx, bson.M{"_id": "bot_config"}).Decode(&doc) + defaults := m.GetAllConfig() + + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + log.Info("Creating bot_config in botsettings collection with defaults") + docData := bson.M{ + "_id": "bot_config", + "created_at": float64(time.Now().Unix()), + } + for k, v := range defaults { + docData[k] = v + } + _, insertErr := m.col.InsertOne(ctx, docData) + return insertErr + } + return fmt.Errorf("read bot_config: %w", err) + } + + // Backfill missing or empty keys without clobbering existing DB values + updates := bson.M{} + for k, v := range defaults { + dbVal, exists := doc[k] + if !exists || (isEmptyValue(dbVal) && !isEmptyValue(v)) { + updates[k] = v + doc[k] = v + } + } + if len(updates) > 0 { + _, _ = m.col.UpdateOne(ctx, bson.M{"_id": "bot_config"}, bson.M{"$set": updates}) + } + + m.mu.Lock() + defer m.mu.Unlock() + + // Load _types map if present + if rawTypes, ok := doc["_types"].(bson.M); ok { + m.overrideTypes = make(map[string]string) + for k, v := range rawTypes { + if s, ok := v.(string); ok { + m.overrideTypes[k] = s + } + } + } + + // Apply DB overrides for all known uppercase keys (skipping sensitive secret keys if env differs) + for key, dbVal := range doc { + if key == "_id" || key == "_types" || key == "created_at" || !isAllUpper(key) { + continue + } + if m.IsSecretKey(key) { + continue + } + m.applyValueLocked(key, dbVal) + } + + log.Infof("Successfully synchronized bot_config from MongoDB (OWNER_ID=%v, SUDO_USERS=%v)", m.cfg.OwnerIDs, m.cfg.SudoUsers) + return nil +} + +// GetAllConfig returns a key-value map of all uppercase configuration parameters. +func (m *ConfigManager) GetAllConfig() map[string]any { + m.mu.RLock() + defer m.mu.RUnlock() + return m.getAllConfigLocked() +} + +// GetAllKeys returns a sorted slice of all uppercase configuration keys. +func (m *ConfigManager) GetAllKeys() []string { + all := m.GetAllConfig() + keys := make([]string, 0, len(all)) + for k := range all { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// Get returns the typed value of the specified configuration key. +func (m *ConfigManager) Get(key string) any { + m.mu.RLock() + defer m.mu.RUnlock() + all := m.getAllConfigLocked() + return all[strings.ToUpper(strings.TrimSpace(key))] +} + +// UpdateConfig casts, updates in-memory state, and persists the setting to MongoDB. +func (m *ConfigManager) UpdateConfig(ctx context.Context, key string, rawVal any) (any, error) { + key = strings.ToUpper(strings.TrimSpace(key)) + if !isAllUpper(key) { + return nil, fmt.Errorf("invalid config key: %s", key) + } + + m.mu.Lock() + processed := m.processValueLocked(key, rawVal) + m.applyValueLocked(key, processed) + m.mu.Unlock() + + // Persist to MongoDB + if m.col != nil { + opts := options.UpdateOne().SetUpsert(true) + _, err := m.col.UpdateOne(ctx, bson.M{"_id": "bot_config"}, bson.M{"$set": bson.M{key: processed}}, opts) + if err != nil { + return nil, fmt.Errorf("failed to persist %s: %w", key, err) + } + } + + // Trigger callbacks + for _, fn := range m.listeners { + fn(key, processed) + } + + return processed, nil +} + +// UpdateConfigType updates the type override for a key (e.g. str, int, bool, list) in _types and reprocesses the current value. +func (m *ConfigManager) UpdateConfigType(ctx context.Context, key string, typeStr string) (any, error) { + key = strings.ToUpper(strings.TrimSpace(key)) + typeStr = strings.ToLower(strings.TrimSpace(typeStr)) + + m.mu.Lock() + if typeStr == "" || typeStr == "default" { + delete(m.overrideTypes, key) + } else { + m.overrideTypes[key] = typeStr + } + typesCopy := make(map[string]string) + for k, v := range m.overrideTypes { + typesCopy[k] = v + } + currentVal := m.getAllConfigLocked()[key] + processed := m.processValueLocked(key, currentVal) + m.applyValueLocked(key, processed) + m.mu.Unlock() + + if m.col != nil { + update := bson.M{ + "_types": typesCopy, + key: processed, + } + _, err := m.col.UpdateOne(ctx, bson.M{"_id": "bot_config"}, bson.M{"$set": update}, options.UpdateOne().SetUpsert(true)) + if err != nil { + return nil, err + } + } + + return processed, nil +} + +// ClearSetting resets the key to its type-specific empty/zero value. +func (m *ConfigManager) ClearSetting(ctx context.Context, key string) (any, error) { + key = strings.ToUpper(strings.TrimSpace(key)) + defType := m.GetTargetType(key) + + var emptyVal any + switch defType { + case "bool": + emptyVal = false + case "int": + emptyVal = 0 + case "int64": + emptyVal = int64(0) + case "list": + if key == "OWNER_ID" || key == "SUDO_USERS" || key == "COLLABORATOR_ID" || key == "COLLABORATOR_IDS" { + emptyVal = []int64{} + } else { + emptyVal = []string{} + } + default: + emptyVal = "" + } + + return m.UpdateConfig(ctx, key, emptyVal) +} + +// AddIDs adds user/chat IDs to an ID list key (OWNER_ID, SUDO_USERS, COLLABORATOR_IDS). +func (m *ConfigManager) AddIDs(ctx context.Context, key string, ids []int64) error { + key = strings.ToUpper(strings.TrimSpace(key)) + curr, ok := m.Get(key).([]int64) + if !ok { + curr = []int64{} + } + + seen := make(map[int64]bool) + var merged []int64 + for _, id := range append(curr, ids...) { + if id != 0 && !seen[id] { + seen[id] = true + merged = append(merged, id) + } + } + sort.Slice(merged, func(i, j int) bool { return merged[i] < merged[j] }) + + _, err := m.UpdateConfig(ctx, key, merged) + return err +} + +// RemoveIDs removes user/chat IDs from an ID list key. +func (m *ConfigManager) RemoveIDs(ctx context.Context, key string, ids []int64) error { + key = strings.ToUpper(strings.TrimSpace(key)) + curr, ok := m.Get(key).([]int64) + if !ok { + return nil + } + + toRemove := make(map[int64]bool) + for _, id := range ids { + toRemove[id] = true + } + + var filtered []int64 + for _, id := range curr { + if !toRemove[id] { + filtered = append(filtered, id) + } + } + + _, err := m.UpdateConfig(ctx, key, filtered) + return err +} + +func (m *ConfigManager) getOverrideTypeLocked(key string) string { + key = strings.ToUpper(strings.TrimSpace(key)) + if t, ok := m.overrideTypes[key]; ok && t != "" { + return t + } + return "default" +} + +// GetOverrideType returns the configured override type for the key or "default". +func (m *ConfigManager) GetOverrideType(key string) string { + m.mu.RLock() + defer m.mu.RUnlock() + return m.getOverrideTypeLocked(key) +} + +func (m *ConfigManager) getDefaultTypeLocked(key string) string { + val := m.getAllConfigLocked()[key] + if val == nil { + return "str" + } + switch val.(type) { + case bool: + return "bool" + case int, int32: + return "int" + case int64: + return "int" + case []string, []int64, []any: + return "list" + default: + return "str" + } +} + +// GetDefaultType returns the base Go type name ("str", "int", "bool", "list") for the key. +func (m *ConfigManager) GetDefaultType(key string) string { + m.mu.RLock() + defer m.mu.RUnlock() + return m.getDefaultTypeLocked(key) +} + +func (m *ConfigManager) getTargetTypeLocked(key string) string { + ov := m.getOverrideTypeLocked(key) + if ov != "default" { + return ov + } + return m.getDefaultTypeLocked(key) +} + +// GetTargetType returns the effective type ("str", "int", "bool", "list") for the key. +func (m *ConfigManager) GetTargetType(key string) string { + m.mu.RLock() + defer m.mu.RUnlock() + return m.getTargetTypeLocked(key) +} + +func (m *ConfigManager) getAllConfigLocked() map[string]any { + c := m.cfg + res := map[string]any{ + "PORT": c.Port, + "DEBUG": c.Debug, + "API_LOGS": c.APILogs, + "CORS_ORIGIN": c.CorsOrigin, + "MONGO_URI": c.MongoURI, + "DATABASE_NAME": c.DatabaseName, + "API_ID": c.ApiID, + "API_HASH": c.ApiHash, + "BOT_TOKEN": c.BotToken, + "SECRET_KEY": c.SecretKey, + "SESSION_STRING": c.SessionString, + "CHANNEL_ID": c.ChannelID, + "DUMP_CHANNEL_ID": c.DumpChannelID, + "MULTI_CLIENTS": c.MultiClients, + "MULTI_CLIENT_TOKENS": c.MultiClientTokens, + "FILTER_MODE": c.FilterMode, + "CHAT_TOPIC": c.ChatTopic, + "COLLABORATOR_ID": c.CollaboratorIDs, + "COLLABORATOR_IDS": c.CollaboratorIDs, + "LYRICS": c.Lyrics, + "LRCLIB": c.LRCLIB, + "MUSIXMATCH": c.Musixmatch, + "TELEGRAM_OIDC_CLIENT_ID": c.TelegramOIDCClientID, + "TELEGRAM_OIDC_CLIENT_SECRET": c.TelegramOIDCClientSecret, + "TELEGRAM_OIDC_ORIGIN": c.TelegramOIDCOrigin, + "TELEGRAM_OIDC_REDIRECT_URI": c.TelegramOIDCRedirectURI, + "COOKIE_SECURE": c.CookieSecure, + "COOKIE_SAMESITE": c.CookieSameSite, + "OWNER_ID": c.OwnerIDs, + "SUDO_USERS": c.SudoUsers, + "ENRICHMENT_WORKERS": c.EnrichmentWorkers, + "GUEST_PASSWORD": c.GuestPassword, + "ALAC_CACHE_MAX_BYTES": c.AlacCacheMaxBytes, + "ALAC_CACHE_MAX_FILES": c.AlacCacheMaxFiles, + "R2_ACCOUNT_ID": c.R2AccountID, + "R2_ACCESS_KEY_ID": c.R2AccessKeyID, + "R2_SECRET_ACCESS_KEY": c.R2SecretAccessKey, + "R2_BUCKET_NAME": c.R2BucketName, + "R2_PUBLIC_URL": c.R2PublicURL, + } + for k, v := range m.extra { + res[k] = v + } + return res +} + +func (m *ConfigManager) processValueLocked(key string, val any) any { + targetType := m.getTargetTypeLocked(key) + + switch targetType { + case "bool": + switch v := val.(type) { + case bool: + return v + case string: + s := strings.ToLower(strings.TrimSpace(v)) + return s == "true" || s == "1" || s == "yes" || s == "y" || s == "on" + case int, int64, float64: + return fmt.Sprintf("%v", v) != "0" + } + return false + + case "int": + if key == "CHANNEL_ID" || key == "DUMP_CHANNEL_ID" || key == "ALAC_CACHE_MAX_BYTES" { + return toInt64(val) + } + return int(toInt64(val)) + + case "list": + if key == "OWNER_ID" || key == "SUDO_USERS" || key == "COLLABORATOR_ID" || key == "COLLABORATOR_IDS" { + return toInt64List(val) + } + return toStringList(val) + + case "str": + if val == nil { + return "" + } + if s, ok := val.(string); ok { + return strings.TrimSpace(s) + } + return fmt.Sprintf("%v", val) + + default: + return val + } +} + +func (m *ConfigManager) applyValueLocked(key string, val any) { + c := m.cfg + switch key { + case "PORT": + c.Port = fmt.Sprintf("%v", val) + case "DEBUG": + c.Debug = toBool(val) + case "API_LOGS": + c.APILogs = toBool(val) + case "CORS_ORIGIN": + c.CorsOrigin = fmt.Sprintf("%v", val) + case "MONGO_URI": + c.MongoURI = fmt.Sprintf("%v", val) + case "DATABASE_NAME": + c.DatabaseName = fmt.Sprintf("%v", val) + case "API_ID": + c.ApiID = int(toInt64(val)) + case "API_HASH": + c.ApiHash = fmt.Sprintf("%v", val) + case "BOT_TOKEN": + c.BotToken = fmt.Sprintf("%v", val) + case "SECRET_KEY": + c.SecretKey = fmt.Sprintf("%v", val) + case "SESSION_STRING": + c.SessionString = fmt.Sprintf("%v", val) + case "CHANNEL_ID": + c.ChannelID = toInt64(val) + case "DUMP_CHANNEL_ID": + c.DumpChannelID = toInt64(val) + case "MULTI_CLIENTS": + c.MultiClients = toBool(val) + case "MULTI_CLIENT_TOKENS": + c.MultiClientTokens = toStringList(val) + case "FILTER_MODE": + c.FilterMode = int(toInt64(val)) + case "CHAT_TOPIC": + c.ChatTopic = fmt.Sprintf("%v", val) + case "COLLABORATOR_ID", "COLLABORATOR_IDS": + c.CollaboratorIDs = toInt64List(val) + case "LYRICS": + c.Lyrics = toBool(val) + case "LRCLIB": + c.LRCLIB = toBool(val) + case "MUSIXMATCH": + c.Musixmatch = toBool(val) + case "TELEGRAM_OIDC_CLIENT_ID": + c.TelegramOIDCClientID = fmt.Sprintf("%v", val) + case "TELEGRAM_OIDC_CLIENT_SECRET": + c.TelegramOIDCClientSecret = fmt.Sprintf("%v", val) + case "TELEGRAM_OIDC_ORIGIN": + c.TelegramOIDCOrigin = fmt.Sprintf("%v", val) + case "TELEGRAM_OIDC_REDIRECT_URI": + c.TelegramOIDCRedirectURI = fmt.Sprintf("%v", val) + case "COOKIE_SECURE": + c.CookieSecure = toBool(val) + case "COOKIE_SAMESITE": + c.CookieSameSite = fmt.Sprintf("%v", val) + case "OWNER_ID": + c.OwnerIDs = toInt64List(val) + case "SUDO_USERS": + c.SudoUsers = toInt64List(val) + case "ENRICHMENT_WORKERS": + c.EnrichmentWorkers = int(toInt64(val)) + case "GUEST_PASSWORD": + c.GuestPassword = fmt.Sprintf("%v", val) + case "ALAC_CACHE_MAX_BYTES": + c.AlacCacheMaxBytes = toInt64(val) + case "ALAC_CACHE_MAX_FILES": + c.AlacCacheMaxFiles = int(toInt64(val)) + case "R2_ACCOUNT_ID": + c.R2AccountID = fmt.Sprintf("%v", val) + case "R2_ACCESS_KEY_ID": + c.R2AccessKeyID = fmt.Sprintf("%v", val) + case "R2_SECRET_ACCESS_KEY": + c.R2SecretAccessKey = fmt.Sprintf("%v", val) + case "R2_BUCKET_NAME": + c.R2BucketName = fmt.Sprintf("%v", val) + case "R2_PUBLIC_URL": + c.R2PublicURL = fmt.Sprintf("%v", val) + default: + m.extra[key] = val + } +} + +func toBool(v any) bool { + if b, ok := v.(bool); ok { + return b + } + s := strings.ToLower(fmt.Sprintf("%v", v)) + return s == "true" || s == "1" || s == "yes" || s == "on" +} + +func toInt64(v any) int64 { + switch n := v.(type) { + case int: + return int64(n) + case int8: + return int64(n) + case int16: + return int64(n) + case int32: + return int64(n) + case int64: + return n + case uint: + return int64(n) + case uint8: + return int64(n) + case uint16: + return int64(n) + case uint32: + return int64(n) + case uint64: + return int64(n) + case float32: + return int64(n) + case float64: + return int64(n) + case string: + s := strings.TrimSpace(n) + if i, err := strconv.ParseInt(s, 10, 64); err == nil { + return i + } + if f, err := strconv.ParseFloat(s, 64); err == nil { + return int64(f) + } + } + return 0 +} + +func toInt64List(v any) []int64 { + if v == nil { + return []int64{} + } + if list, ok := v.([]int64); ok { + return list + } + if list, ok := v.([]int); ok { + out := make([]int64, len(list)) + for i, id := range list { + out[i] = int64(id) + } + return out + } + if s, ok := v.(string); ok { + return parseIDList(s) + } + if arr, ok := v.(bson.A); ok { + var out []int64 + for _, item := range arr { + if id := toInt64(item); id != 0 { + out = append(out, id) + } + } + return out + } + if arr, ok := v.([]any); ok { + var out []int64 + for _, item := range arr { + if id := toInt64(item); id != 0 { + out = append(out, id) + } + } + return out + } + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Slice { + var out []int64 + for i := 0; i < rv.Len(); i++ { + if id := toInt64(rv.Index(i).Interface()); id != 0 { + out = append(out, id) + } + } + return out + } + if id := toInt64(v); id != 0 { + return []int64{id} + } + return []int64{} +} + +func toStringList(v any) []string { + if v == nil { + return []string{} + } + if list, ok := v.([]string); ok { + return list + } + if s, ok := v.(string); ok { + s = strings.TrimSpace(s) + if strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]") { + var arr []string + if err := json.Unmarshal([]byte(s), &arr); err == nil { + return arr + } + } + parts := strings.FieldsFunc(s, func(r rune) bool { + return r == ',' || r == ' ' || r == ';' + }) + var out []string + for _, p := range parts { + if trimmed := strings.TrimSpace(p); trimmed != "" { + out = append(out, trimmed) + } + } + return out + } + if arr, ok := v.(bson.A); ok { + var out []string + for _, item := range arr { + out = append(out, fmt.Sprintf("%v", item)) + } + return out + } + if arr, ok := v.([]any); ok { + var out []string + for _, item := range arr { + out = append(out, fmt.Sprintf("%v", item)) + } + return out + } + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Slice { + var out []string + for i := 0; i < rv.Len(); i++ { + out = append(out, fmt.Sprintf("%v", rv.Index(i).Interface())) + } + return out + } + return []string{} +} + +func isEmptyValue(v any) bool { + if v == nil { + return true + } + switch val := v.(type) { + case string: + return strings.TrimSpace(val) == "" + case []string: + return len(val) == 0 + case []int64: + return len(val) == 0 + case []int: + return len(val) == 0 + case []any: + return len(val) == 0 + case bson.A: + return len(val) == 0 + } + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Slice || rv.Kind() == reflect.Map { + return rv.Len() == 0 + } + return false +} + +func isAllUpper(s string) bool { + hasLetter := false + for _, r := range s { + if (r >= 'a' && r <= 'z') { + return false + } + if (r >= 'A' && r <= 'Z') { + hasLetter = true + } + } + return hasLetter +} diff --git a/internal/database/mongodb.go b/internal/database/mongodb.go new file mode 100644 index 0000000..28c016c --- /dev/null +++ b/internal/database/mongodb.go @@ -0,0 +1,72 @@ +package database + +import ( + "context" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + "go.mongodb.org/mongo-driver/v2/mongo/readpref" + + "streamgo/internal/logger" +) + +var log = logger.New("mongodb") + +// Client wraps the mongo.Client and database instance. +type Client struct { + Client *mongo.Client + Database *mongo.Database +} + +// Connect establishes a connection pool to MongoDB and tests connectivity with Ping. +func Connect(ctx context.Context, uri, databaseName string) (*Client, error) { + if uri == "" { + return nil, fmt.Errorf("mongo uri is empty") + } + + opts := options.Client(). + ApplyURI(uri). + SetConnectTimeout(10 * time.Second). + SetServerSelectionTimeout(5 * time.Second). + SetMinPoolSize(2). + SetMaxPoolSize(50) + + client, err := mongo.Connect(opts) + if err != nil { + return nil, fmt.Errorf("failed to create mongo client: %w", err) + } + + // Verify connectivity with a quick ping + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := client.Ping(pingCtx, readpref.Primary()); err != nil { + _ = client.Disconnect(ctx) + return nil, fmt.Errorf("failed to ping mongodb at %s: %w", uri, err) + } + + log.Infof("connected successfully to database: %s", databaseName) + + return &Client{ + Client: client, + Database: client.Database(databaseName), + }, nil +} + +// Collection returns a handle to the specified collection. +func (c *Client) Collection(name string) *mongo.Collection { + return c.Database.Collection(name) +} + +// Ping checks if the database is responsive. +func (c *Client) Ping(ctx context.Context) error { + return c.Client.Ping(ctx, readpref.Primary()) +} + +// Close gracefully closes the connection pool. +func (c *Client) Close(ctx context.Context) error { + log.Info("disconnecting from database...") + return c.Client.Disconnect(ctx) +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..9b8b9dc --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,213 @@ +package logger + +import ( + "fmt" + "io" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/go-chi/chi/v5/middleware" +) + +// Level defines log severity. +type Level int + +const ( + LevelDebug Level = iota + LevelInfo + LevelWarn + LevelError +) + +var levelNames = map[Level]string{ + LevelDebug: "DEBUG", + LevelInfo: "INFO", + LevelWarn: "WARN", + LevelError: "ERROR", +} + +// ANSI color codes +const ( + colorReset = "\033[0m" + colorRed = "\033[31m" + colorGreen = "\033[32m" + colorYellow = "\033[33m" + colorBlue = "\033[34m" + colorMagenta = "\033[35m" + colorCyan = "\033[36m" + colorGray = "\033[90m" +) + +var levelColors = map[Level]string{ + LevelDebug: colorMagenta, + LevelInfo: colorCyan, + LevelWarn: colorYellow, + LevelError: colorRed, +} + +var ( + currentLevel = LevelInfo + useColors = true + outMu sync.Mutex + outWriter io.Writer = os.Stdout + defaultLog = New("app") +) + +func init() { + if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" { + useColors = false + } + if strings.ToLower(os.Getenv("DEBUG")) == "true" || os.Getenv("DEBUG") == "1" { + currentLevel = LevelDebug + } +} + +// SetDebug toggles debug level logging. +func SetDebug(enabled bool) { + if enabled { + currentLevel = LevelDebug + } else { + currentLevel = LevelInfo + } +} + +// Logger provides leveled, prefixed logging. +type Logger struct { + prefix string +} + +// New creates a new Logger with the specified module prefix. +func New(prefix string) *Logger { + return &Logger{prefix: prefix} +} + +func (l *Logger) log(lvl Level, msg string) { + if lvl < currentLevel { + return + } + + outMu.Lock() + defer outMu.Unlock() + + timestamp := time.Now().Format("2006/01/02 15:04:05") + lvlName := levelNames[lvl] + + if useColors { + color := levelColors[lvl] + fmt.Fprintf(outWriter, "%s%s%s %s[%-5s]%s %s[%s]%s %s\n", + colorGray, timestamp, colorReset, + color, lvlName, colorReset, + colorBlue, l.prefix, colorReset, + msg, + ) + } else { + fmt.Fprintf(outWriter, "%s [%-5s] [%s] %s\n", + timestamp, + lvlName, + l.prefix, + msg, + ) + } +} + +func (l *Logger) Debug(args ...interface{}) { + l.log(LevelDebug, fmt.Sprint(args...)) +} + +func (l *Logger) Debugf(format string, args ...interface{}) { + l.log(LevelDebug, fmt.Sprintf(format, args...)) +} + +func (l *Logger) Info(args ...interface{}) { + l.log(LevelInfo, fmt.Sprint(args...)) +} + +func (l *Logger) Infof(format string, args ...interface{}) { + l.log(LevelInfo, fmt.Sprintf(format, args...)) +} + +func (l *Logger) Warn(args ...interface{}) { + l.log(LevelWarn, fmt.Sprint(args...)) +} + +func (l *Logger) Warnf(format string, args ...interface{}) { + l.log(LevelWarn, fmt.Sprintf(format, args...)) +} + +func (l *Logger) Error(args ...interface{}) { + l.log(LevelError, fmt.Sprint(args...)) +} + +func (l *Logger) Errorf(format string, args ...interface{}) { + l.log(LevelError, fmt.Sprintf(format, args...)) +} + +func (l *Logger) Fatal(args ...interface{}) { + l.log(LevelError, fmt.Sprint(args...)) + os.Exit(1) +} + +func (l *Logger) Fatalf(format string, args ...interface{}) { + l.log(LevelError, fmt.Sprintf(format, args...)) + os.Exit(1) +} + +// Package-level global logging convenience functions +func Debug(args ...interface{}) { defaultLog.Debug(args...) } +func Debugf(format string, args ...interface{}) { defaultLog.Debugf(format, args...) } +func Info(args ...interface{}) { defaultLog.Info(args...) } +func Infof(format string, args ...interface{}) { defaultLog.Infof(format, args...) } +func Warn(args ...interface{}) { defaultLog.Warn(args...) } +func Warnf(format string, args ...interface{}) { defaultLog.Warnf(format, args...) } +func Error(args ...interface{}) { defaultLog.Error(args...) } +func Errorf(format string, args ...interface{}) { defaultLog.Errorf(format, args...) } +func Fatal(args ...interface{}) { defaultLog.Fatal(args...) } +func Fatalf(format string, args ...interface{}) { defaultLog.Fatalf(format, args...) } + +// HTTPMiddleware creates an HTTP request logger that can be enabled or disabled. +// When enabled is false, it passes through requests completely silent. +func HTTPMiddleware(enabled bool) func(next http.Handler) http.Handler { + httpLog := New("http") + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !enabled { + next.ServeHTTP(w, r) + return + } + + // Wrap response writer to capture status and bytes written + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + start := time.Now() + + next.ServeHTTP(ww, r) + + duration := time.Since(start) + status := ww.Status() + if status == 0 { + status = http.StatusOK + } + + // Clean, formatted request log + msg := fmt.Sprintf("%s %s -> %d (%s, %dB) from %s", + r.Method, + r.URL.Path, + status, + duration.Round(time.Millisecond), + ww.BytesWritten(), + r.RemoteAddr, + ) + + if status >= 500 { + httpLog.Error(msg) + } else if status >= 400 { + httpLog.Warn(msg) + } else { + httpLog.Info(msg) + } + }) + } +} diff --git a/internal/metadata/cleaner.go b/internal/metadata/cleaner.go new file mode 100644 index 0000000..5f3f53d --- /dev/null +++ b/internal/metadata/cleaner.go @@ -0,0 +1,224 @@ +package metadata + +import ( + "crypto/sha1" + "fmt" + "regexp" + "strings" + "unicode" + + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" +) + +var albumSlugRegex = regexp.MustCompile(`[^a-z0-9 ]+`) + +// Slugify converts text into a canonical lowercase alphanumeric slug with underscores. +// If non-Latin/empty, falls back to u_. +func Slugify(value string) string { + raw := strings.ToLower(strings.TrimSpace(value)) + if raw == "" { + return "" + } + s := strings.ReplaceAll(raw, "÷", " divide ") + s = strings.ReplaceAll(s, "&", " and ") + s = strings.ReplaceAll(s, "+", " plus ") + + // NFKD normalization to ASCII + t := transform.Chain(norm.NFKD, transform.RemoveFunc(func(r rune) bool { + return unicode.Is(unicode.Mn, r) // mn: nonspacing marks + })) + ascii, _, _ := transform.String(t, s) + + // Keep only alphanumeric and space + ascii = albumSlugRegex.ReplaceAllString(ascii, " ") + tokens := strings.Fields(ascii) + s = strings.Join(tokens, "_") + s = strings.Trim(s, "_") + + if s != "" { + return s + } + + h := sha1.Sum([]byte(raw)) + return fmt.Sprintf("u_%x", h[:8]) +} + +// ArtistID returns the canonical artist ID: artist_. +func ArtistID(name string) string { + s := Slugify(name) + if s == "" { + return "" + } + return "artist_" + s +} + +// AlbumID returns the canonical album ID: album__ or album_. +func AlbumID(album string, year *int32) string { + b := Slugify(album) + if b == "" { + return "" + } + if year != nil && *year >= 1000 && *year <= 2100 { + return fmt.Sprintf("album_%s_%d", b, *year) + } + return fmt.Sprintf("album_%s", b) +} + +// NormalizePunctuation standardizes full-width and non-standard punctuation to ASCII equivalents. +func NormalizePunctuation(s string) string { + if s == "" { + return "" + } + + var sb strings.Builder + sb.Grow(len(s)) + + for _, r := range s { + switch r { + case ',': // Full-width comma + sb.WriteString(", ") + case '/': // Full-width slash + sb.WriteString(" / ") + case ';': // Full-width semicolon + sb.WriteString("; ") + case ':': // Full-width colon + sb.WriteString(": ") + default: + sb.WriteRune(r) + } + } + + // Normalize spaces + fields := strings.Fields(sb.String()) + res := strings.Join(fields, " ") + + // Fix potential spaced commas/semicolons (e.g. "word , word" -> "word, word") + res = strings.ReplaceAll(res, " ,", ",") + res = strings.ReplaceAll(res, " ;", ";") + res = strings.ReplaceAll(res, ",,", ",") + + return strings.TrimSpace(res) +} + +// CleanDuplicatedPhrase checks if a string consists of identical repeated halves +// separated by common delimiters (e.g., ", ", " / ", " - ", "; "). +func CleanDuplicatedPhrase(s string) (string, bool) { + trimmed := strings.TrimSpace(s) + if len(trimmed) < 4 { + return s, false + } + + delimiters := []string{", ", " / ", " - ", "; ", " // "} + + // 1. Check with spaced delimiters + for _, delim := range delimiters { + parts := strings.Split(trimmed, delim) + n := len(parts) + if n >= 2 && n%2 == 0 { + half := n / 2 + firstHalf := parts[:half] + secondHalf := parts[half:] + + match := true + for i := 0; i < half; i++ { + p1 := strings.TrimSpace(firstHalf[i]) + p2 := strings.TrimSpace(secondHalf[i]) + if p1 == "" || !strings.EqualFold(p1, p2) { + match = false + break + } + } + if match { + cleaned := strings.Join(firstHalf, delim) + return strings.TrimSpace(cleaned), true + } + } + } + + // 2. Check delimiters without spacing (e.g., "A,A" or "A/A") + delimsNoSpace := []string{",", "/", ";"} + for _, delim := range delimsNoSpace { + parts := strings.Split(trimmed, delim) + n := len(parts) + if n >= 2 && n%2 == 0 { + half := n / 2 + firstHalf := parts[:half] + secondHalf := parts[half:] + + match := true + for i := 0; i < half; i++ { + p1 := strings.TrimSpace(firstHalf[i]) + p2 := strings.TrimSpace(secondHalf[i]) + if p1 == "" || !strings.EqualFold(p1, p2) { + match = false + break + } + } + if match { + // Reconstruct with standard spaced delimiter + cleaned := strings.Join(firstHalf, delim+" ") + return strings.TrimSpace(cleaned), true + } + } + } + + return s, false +} + +// CleanArtist deduplicates stuttered artist tags (e.g. "Shaan, Kailash Kher, Shaan, Kailash Kher"). +// Artists are never legitimately repeated entities. +func CleanArtist(artist string) (string, bool) { + norm := NormalizePunctuation(artist) + cleaned, changed := CleanDuplicatedPhrase(norm) + if changed { + return cleaned, true + } + return strings.TrimSpace(norm), false +} + +// CleanTitle deduplicates stuttered track titles (e.g. "Chand Sifarish, Chand Sifarish"). +// Uses guardrails to ensure legitimate titles with repetitions (such as "Sei Bhalo, Sei Bhalo" +// or "Tonight, Tonight") are preserved: +// 1. If artist is also duplicated, title deduplication is guaranteed to be a corrupted tag. +// 2. If artist is not duplicated, title is only deduplicated if the repeated phrase is complex +// (>= 3 words or >= 15 characters, such as "Mangal Bhavan Amangal Haari Sitaram Charit"). +func CleanTitle(title string, artist string) (string, bool) { + norm := NormalizePunctuation(title) + candidate, changed := CleanDuplicatedPhrase(norm) + if !changed { + return strings.TrimSpace(norm), false + } + + // Check if artist also has duplicated halves + _, artistChanged := CleanArtist(artist) + if artistChanged { + // Both title and artist are duplicated: 100% corrupted tag block + return candidate, true + } + + // If artist is NOT duplicated, apply false-positive heuristics: + // Count words and characters in the candidate phrase + words := strings.Fields(candidate) + if len(words) >= 3 || len(candidate) >= 15 { + // Long multi-word phrases (e.g. "Mangal Bhavan Amangal Haari Sitaram Charit") are not intentional titles + return candidate, true + } + + // Short phrases (< 3 words and < 15 chars) without artist duplication are preserved + // (e.g., "Sei Bhalo, Sei Bhalo", "Tonight, Tonight") + return strings.TrimSpace(norm), false +} + +// CleanMetadata sanitizes both title and artist, returning clean strings. +func CleanMetadata(title, artist string) (cleanTitle string, cleanArtist string) { + cleanArtist, _ = CleanArtist(artist) + cleanTitle, _ = CleanTitle(title, artist) + if cleanArtist == "" { + cleanArtist = strings.TrimSpace(artist) + } + if cleanTitle == "" { + cleanTitle = strings.TrimSpace(title) + } + return cleanTitle, cleanArtist +} diff --git a/internal/models/access_control.go b/internal/models/access_control.go new file mode 100644 index 0000000..ca6984f --- /dev/null +++ b/internal/models/access_control.go @@ -0,0 +1,66 @@ +package models + +// RequiredChat represents a Telegram channel or group users must join. +type RequiredChat struct { + ChatID int64 `bson:"chat_id" json:"chat_id"` + Title string `bson:"title,omitempty" json:"title,omitempty"` + InviteLink string `bson:"invite_link,omitempty" json:"invite_link,omitempty"` + IsPrivate bool `bson:"is_private" json:"is_private"` +} + +// AccessPolicy defines global access and registration policy. +type AccessPolicy struct { + RegistrationMode string `bson:"registration_mode" json:"registration_mode"` // "open", "invite", "allowlist", "closed" + EnforceMembership bool `bson:"enforce_membership" json:"enforce_membership"` + LockMessage string `bson:"lock_message,omitempty" json:"lock_message,omitempty"` + RequiredChats []RequiredChat `bson:"required_chats" json:"required_chats"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at,omitempty"` + UpdatedBy int64 `bson:"updated_by,omitempty" json:"updated_by,omitempty"` +} + +// PolicyPatch payload for updating AccessPolicy. +type PolicyPatch struct { + RegistrationMode *string `json:"registration_mode,omitempty"` + EnforceMembership *bool `json:"enforce_membership,omitempty"` + LockMessage *string `json:"lock_message,omitempty"` + RequiredChats *[]RequiredChat `json:"required_chats,omitempty"` +} + +// InviteCode represents a registration invite code. +type InviteCode struct { + Code string `bson:"_id" json:"code"` + MaxUses int `bson:"max_uses" json:"max_uses"` + UsedCount int `bson:"used_count" json:"used_count"` + CreatedBy int64 `bson:"created_by" json:"created_by"` + CreatedAt float64 `bson:"created_at" json:"created_at"` + ExpiresAt *float64 `bson:"expires_at,omitempty" json:"expires_at,omitempty"` + Note string `bson:"note,omitempty" json:"note,omitempty"` + Revoked bool `bson:"revoked" json:"revoked"` +} + +// InviteCreateRequest payload for POST /admin/access/invites. +type InviteCreateRequest struct { + MaxUses int `json:"max_uses"` + TTLDays int `json:"ttl_days"` + Note string `json:"note"` +} + +// AllowlistEntry represents a whitelisted user in MongoDB allowlist. +type AllowlistEntry struct { + UserID int64 `bson:"_id" json:"user_id"` + AddedBy int64 `bson:"added_by" json:"added_by"` + AddedAt float64 `bson:"added_at" json:"added_at"` + Note string `bson:"note,omitempty" json:"note,omitempty"` +} + +// AllowlistRequest payload for POST /admin/access/allowlist and /bypass. +type AllowlistRequest struct { + UserID int64 `json:"user_id"` + Note string `json:"note"` +} + +// LockUserRequest payload for POST /admin/access/users/{id}/lock. +type LockUserRequest struct { + Reason string `json:"reason"` + RevokeSessions bool `json:"revoke_sessions"` +} diff --git a/internal/models/artist_album.go b/internal/models/artist_album.go new file mode 100644 index 0000000..ac9436c --- /dev/null +++ b/internal/models/artist_album.go @@ -0,0 +1,74 @@ +package models + +// Artist represents an artist record in the artists collection. +type Artist struct { + ID string `bson:"_id" json:"_id"` + Id string `bson:"-" json:"id,omitempty"` + Name string `bson:"name" json:"name"` + MatchArtist string `bson:"match_artist,omitempty" json:"-"` + TracksCount int64 `bson:"tracks_count" json:"tracks_count"` + TrackCount int64 `bson:"-" json:"track_count,omitempty"` + CoverURL string `bson:"cover_url,omitempty" json:"cover_url,omitempty"` + AvatarURL string `bson:"-" json:"avatar_url,omitempty"` + Followers int64 `bson:"followers,omitempty" json:"followers,omitempty"` + MonthlyListeners int64 `bson:"monthly_listeners,omitempty" json:"monthly_listeners,omitempty"` + Bio string `bson:"bio,omitempty" json:"bio,omitempty"` + IsFollowing bool `bson:"-" json:"is_following"` + CreatedAt float64 `bson:"created_at,omitempty" json:"created_at,omitempty"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at,omitempty"` +} + +// Album represents an album record in the albums collection. +type Album struct { + ID string `bson:"_id" json:"_id"` + Id string `bson:"-" json:"id,omitempty"` + Title string `bson:"title" json:"title"` + Artist string `bson:"artist,omitempty" json:"artist,omitempty"` + Artists []string `bson:"artists,omitempty" json:"artists,omitempty"` + CoverURL string `bson:"cover_url,omitempty" json:"cover_url,omitempty"` + DurationTotal float64 `bson:"duration_total,omitempty" json:"duration_total,omitempty"` + MatchAlbum string `bson:"match_album,omitempty" json:"-"` + MatchArtist string `bson:"match_artist,omitempty" json:"-"` + TracksCount int64 `bson:"tracks_count" json:"tracks_count"` + TrackCount int64 `bson:"-" json:"track_count,omitempty"` + Year int `bson:"year,omitempty" json:"year,omitempty"` + CreatedAt float64 `bson:"created_at,omitempty" json:"created_at,omitempty"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at,omitempty"` +} + +// ArtistDetail contains full artist information along with their top tracks and albums. +type ArtistDetail struct { + Ok bool `json:"ok"` + Artist *Artist `json:"artist"` + PopularTracks []*BrowseItem `json:"popular_tracks"` + TopTracks []*BrowseItem `json:"top_tracks"` + Releases []*Album `json:"releases"` + Albums []*Album `json:"albums"` + Singles []*BrowseItem `json:"singles"` + Tracks []*BrowseItem `json:"tracks"` +} + +// AlbumDetail contains full album metadata and its ordered tracklist. +type AlbumDetail struct { + Ok bool `json:"ok"` + Album *Album `json:"album"` + Tracks []*BrowseItem `json:"tracks"` +} + +// ArtistsResponse is a paginated list of artists matching Python schema. +type ArtistsResponse struct { + Ok bool `json:"ok"` + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int64 `json:"total"` + Items []*Artist `json:"items"` +} + +// AlbumsResponse is a paginated list of albums matching Python schema. +type AlbumsResponse struct { + Ok bool `json:"ok"` + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int64 `json:"total"` + Items []*Album `json:"items"` +} diff --git a/internal/models/auth_user.go b/internal/models/auth_user.go new file mode 100644 index 0000000..eef1b31 --- /dev/null +++ b/internal/models/auth_user.go @@ -0,0 +1,226 @@ +package models + +// TelegramInfo stores nested Telegram account information. +type TelegramInfo struct { + ID int64 `bson:"id,omitempty" json:"id,omitempty"` + Username string `bson:"username,omitempty" json:"username,omitempty"` +} + +// PasswordHash stores PBKDF2 password parameters matching StreamXBot. +type PasswordHash struct { + Algo string `bson:"algo" json:"algo"` + Salt string `bson:"salt" json:"salt"` + Iterations int `bson:"iterations" json:"iterations"` + Hash string `bson:"hash" json:"hash"` +} + +// DiscordIntegrationSchema represents Discord Rich Presence / RPC configuration. +type DiscordIntegrationSchema struct { + Enabled bool `bson:"enabled" json:"enabled"` + Token string `bson:"token" json:"token"` + Mode string `bson:"mode,omitempty" json:"mode,omitempty"` + ClientID string `bson:"client_id,omitempty" json:"client_id,omitempty"` + DaemonURL string `bson:"daemon_url,omitempty" json:"daemon_url,omitempty"` + ShowArtwork bool `bson:"show_artwork" json:"show_artwork"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at,omitempty"` +} + +// LastfmIntegrationSchema represents Last.fm scrobbler integration configuration. +type LastfmIntegrationSchema struct { + Enabled bool `bson:"enabled" json:"enabled"` + APIKey string `bson:"api_key" json:"api_key"` + APISecret string `bson:"api_secret" json:"api_secret"` + SessionKey string `bson:"session_key" json:"session_key"` + Username string `bson:"username" json:"username"` + ScrobbleAt float64 `bson:"scrobble_at" json:"scrobble_at"` + NowPlaying bool `bson:"now_playing" json:"now_playing"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at,omitempty"` +} + +// UserIntegrations aggregates all third-party integrations for a user. +type UserIntegrations struct { + Discord *DiscordIntegrationSchema `bson:"discord,omitempty" json:"discord,omitempty"` + Lastfm *LastfmIntegrationSchema `bson:"lastfm,omitempty" json:"lastfm,omitempty"` +} + +// IntegrationsUpdateRequest payload for updating Discord and Lastfm integrations. +type IntegrationsUpdateRequest struct { + Discord *DiscordIntegrationSchema `json:"discord,omitempty"` + Lastfm *LastfmIntegrationSchema `json:"lastfm,omitempty"` +} + +// User represents a user record in the users collection. +type User struct { + ID int64 `bson:"_id" json:"id"` + UserID int64 `bson:"user_id,omitempty" json:"user_id,omitempty"` + FirstName string `bson:"first_name,omitempty" json:"first_name,omitempty"` + Username string `bson:"username,omitempty" json:"username,omitempty"` + PhotoURL string `bson:"photo_url,omitempty" json:"photo_url,omitempty"` + ProfileURL string `bson:"profile_url,omitempty" json:"profile_url,omitempty"` + Status string `bson:"status,omitempty" json:"status,omitempty"` + Password *PasswordHash `bson:"password,omitempty" json:"-"` + TokenVersion int `bson:"token_version,omitempty" json:"token_version,omitempty"` + Telegram *TelegramInfo `bson:"telegram,omitempty" json:"telegram,omitempty"` + Integrations *UserIntegrations `bson:"integrations,omitempty" json:"integrations,omitempty"` + RegisteredVia any `bson:"registered_via,omitempty" json:"registered_via,omitempty"` + CreatedAt float64 `bson:"created_at,omitempty" json:"created_at,omitempty"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at,omitempty"` +} + +// RegistrationOTP stores a pending user registration and OTP code. +type RegistrationOTP struct { + ID int64 `bson:"_id" json:"user_id"` + OTP string `bson:"otp" json:"otp"` + Username string `bson:"username" json:"username"` + Password *PasswordHash `bson:"password" json:"password"` + FirstName string `bson:"first_name,omitempty" json:"first_name,omitempty"` + TelegramUsername string `bson:"telegram_username,omitempty" json:"telegram_username,omitempty"` + ProfileURL string `bson:"profile_url,omitempty" json:"profile_url,omitempty"` + PhotoURL string `bson:"photo_url,omitempty" json:"photo_url,omitempty"` + InviteCode string `bson:"invite_code,omitempty" json:"invite_code,omitempty"` + CreatedAt float64 `bson:"created_at" json:"created_at"` +} + +// OIDCSession stores a pending Telegram OAuth / OpenID Connect authorization session. +type OIDCSession struct { + ID string `bson:"_id" json:"state"` + CodeVerifier string `bson:"code_verifier" json:"code_verifier"` + RedirectURI string `bson:"redirect_uri" json:"redirect_uri"` + FrontendBase string `bson:"frontend_base" json:"frontend_base"` + DestPath string `bson:"dest_path" json:"dest_path"` + InviteCode string `bson:"invite_code,omitempty" json:"invite_code,omitempty"` + CreatedAt float64 `bson:"created_at" json:"created_at"` +} + +// AuthResponse returns token and user payload upon successful authentication. +type AuthResponse struct { + OK bool `json:"ok"` + Token string `json:"token"` + UserID int64 `json:"user_id,omitempty"` + FirstName string `json:"first_name,omitempty"` + Username string `json:"username,omitempty"` + PhotoURL string `json:"photo_url,omitempty"` + ProfileURL string `json:"profile_url,omitempty"` + User *User `json:"user,omitempty"` +} + +// TelegramWebAppRequest contains raw initData string sent from Telegram WebApp. +type TelegramWebAppRequest struct { + InitData string `json:"init_data"` +} + +// TgLoginRequest contains initData and optional credentials for /auth/tg/login. +type TgLoginRequest struct { + InitData string `json:"init_data"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + InviteCode string `json:"invite_code,omitempty"` +} + +// PasswordLoginRequest contains username and password for /auth/login. +type PasswordLoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// RegisterRequest contains registration fields for /auth/register matching Python StreamXBot. +type RegisterRequest struct { + UserID int64 `json:"userid"` + Username string `json:"username"` + Password string `json:"password"` + InviteCode string `json:"invite_code,omitempty"` +} + +// TokenClaims contains verified information from an auth token. +type TokenClaims struct { + UserID int64 `json:"user_id"` + IsGuest bool `json:"is_guest"` + FirstName string `json:"first_name,omitempty"` + Username string `json:"username,omitempty"` + PhotoURL string `json:"photo_url,omitempty"` + ProfileURL string `json:"profile_url,omitempty"` + TokenVersion int `json:"token_version,omitempty"` + Exp int64 `json:"exp,omitempty"` +} + +// SetupStatusResponse represents response for GET /auth/setup/status. +type SetupStatusResponse struct { + OK bool `json:"ok"` + Configured bool `json:"configured"` + NeedsSetup bool `json:"needs_setup"` + OwnerID *int64 `json:"owner_id,omitempty"` +} + +// SetupPasswordRequest represents payload for POST /auth/setup, POST /auth/password, POST /auth/password/change. +type SetupPasswordRequest struct { + Password string `json:"password"` +} + +// Password login / change request aliases matching Api/schemas/auth.py. +type OwnerPasswordLoginRequest = SetupPasswordRequest +type SetOwnerPasswordRequest = SetupPasswordRequest +type ChangeOwnerPasswordRequest = SetupPasswordRequest + +// ValidateOTPRequest contains verification fields for /auth/validate. +type ValidateOTPRequest struct { + UserID int64 `json:"userid"` + OTP string `json:"otp"` +} + +// TelegramTokenLoginRequest contains token validation payload for /auth/telegram/validate-token. +type TelegramTokenLoginRequest struct { + IDToken string `json:"id_token"` + Token string `json:"token,omitempty"` + InviteCode string `json:"invite_code,omitempty"` +} + +// SetCookieRequest contains the token to store in an HTTP-only cookie. +type SetCookieRequest struct { + Token string `json:"token"` +} + +// SetCredentialsRequest contains updated username and password for the active user. +type SetCredentialsRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// TelegramWidgetRequest contains callback parameters from Telegram Login Widget. +type TelegramWidgetRequest struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name,omitempty"` + Username string `json:"username,omitempty"` + PhotoURL string `json:"photo_url,omitempty"` + AuthDate int64 `json:"auth_date"` + Hash string `json:"hash"` + InviteCode string `json:"invite_code,omitempty"` +} + +// TelegramWidgetLoginRequest alias matching Api/schemas/auth.py. +type TelegramWidgetLoginRequest = TelegramWidgetRequest + +// FCMTokenRequest payload for POST /auth/fcm-token matching Api/schemas/auth.py. +type FCMTokenRequest struct { + FCMToken string `json:"fcm_token"` +} + +// TelegramBotSessionRequest payload for POST /auth/telegram/bot-session matching Api/schemas/auth.py. +type TelegramBotSessionRequest struct { + InviteCode string `json:"invite_code,omitempty"` +} + +// BotAuthSession represents a temporary session for Telegram bot login. +type BotAuthSession struct { + ID string `bson:"_id" json:"session_id"` + Status string `bson:"status" json:"status"` + InviteCode string `bson:"invite_code,omitempty" json:"invite_code,omitempty"` + Token string `bson:"token,omitempty" json:"token,omitempty"` + UserID int64 `bson:"user_id,omitempty" json:"user_id,omitempty"` + FirstName string `bson:"first_name,omitempty" json:"first_name,omitempty"` + Username string `bson:"username,omitempty" json:"username,omitempty"` + PhotoURL string `bson:"photo_url,omitempty" json:"photo_url,omitempty"` + ProfileURL string `bson:"profile_url,omitempty" json:"profile_url,omitempty"` + CreatedAt float64 `bson:"created_at" json:"created_at"` + ExpiresAt float64 `bson:"expires_at" json:"expires_at"` +} diff --git a/internal/models/daily_playlist.go b/internal/models/daily_playlist.go new file mode 100644 index 0000000..e480ea8 --- /dev/null +++ b/internal/models/daily_playlist.go @@ -0,0 +1,30 @@ +package models + +// AvailablePlaylistItem metadata for an available daily playlist. +type AvailablePlaylistItem struct { + ID string `json:"id"` + Kind string `json:"kind"` + Name string `json:"name"` + Thumbnails []string `json:"thumbnails"` + ThumbnailURL string `json:"thumbnail_url"` + NormalThumbnail string `json:"normal_thumbnail"` + Endpoint string `json:"endpoint"` + RequiresAuth bool `json:"requires_auth"` +} + +// AvailablePlaylistsResponse response for GET /playlists/available. +type AvailablePlaylistsResponse struct { + Items []*AvailablePlaylistItem `json:"items"` +} + +// DailyPlaylistDoc represents a generated daily playlist in MongoDB. +type DailyPlaylistDoc struct { + ID string `bson:"_id" json:"id"` // key:date:channel_id + Key string `bson:"key" json:"key"` + Date string `bson:"date" json:"date"` + ChannelID int64 `bson:"channel_id,omitempty" json:"channel_id,omitempty"` + TrackIDs []string `bson:"track_ids" json:"track_ids"` + ThumbnailURL string `bson:"thumbnail_url,omitempty" json:"thumbnail_url,omitempty"` + NormalThumbnail string `bson:"normal_thumbnail,omitempty" json:"normal_thumbnail,omitempty"` + GeneratedAt float64 `bson:"generated_at" json:"generated_at"` +} diff --git a/internal/models/favourite_playlist.go b/internal/models/favourite_playlist.go new file mode 100644 index 0000000..f73e022 --- /dev/null +++ b/internal/models/favourite_playlist.go @@ -0,0 +1,213 @@ +package models + +// UserPlaylist represents a custom playlist in the user_playlists collection. +type UserPlaylist struct { + ID string `bson:"_id" json:"playlist_id"` + UserID int64 `bson:"user_id" json:"user_id"` + Name string `bson:"name" json:"name"` + CoverID string `bson:"cover_id,omitempty" json:"cover_id,omitempty"` + CoverURL string `bson:"cover_url,omitempty" json:"cover_url,omitempty"` + NormalThumbnail string `bson:"normal_thumbnail,omitempty" json:"normal_thumbnail,omitempty"` + CollageHash string `bson:"collage_hash,omitempty" json:"collage_hash,omitempty"` + CreatedAt float64 `bson:"created_at" json:"created_at"` + UpdatedAt float64 `bson:"updated_at" json:"updated_at"` +} + +// PlaylistTrack represents a track entry in the playlist_tracks collection. +type PlaylistTrack struct { + ID string `bson:"_id,omitempty" json:"_id,omitempty"` + PlaylistID string `bson:"playlist_id" json:"playlist_id"` + TrackID string `bson:"track_id" json:"track_id"` + Position int `bson:"position" json:"position"` + AddedAt float64 `bson:"added_at" json:"added_at"` +} + +// PlaylistItem represents playlist data returned in lists. +type PlaylistItem struct { + PlaylistID string `json:"playlist_id"` + Name string `json:"name"` + Thumbnails []string `json:"thumbnails"` + CoverURL string `json:"cover_url,omitempty"` + NormalThumbnail string `json:"normal_thumbnail,omitempty"` + CoverID string `json:"cover_id,omitempty"` + CreatedAt float64 `json:"created_at,omitempty"` + UpdatedAt float64 `json:"updated_at,omitempty"` +} + +// PlaylistCreate request payload for creating a new playlist. +type PlaylistCreate struct { + Name string `json:"name"` +} + +// PlaylistRename request payload for renaming a playlist. +type PlaylistRename struct { + Name string `json:"name"` +} + +// PlaylistTrackAdd request payload for adding tracks to a playlist. +type PlaylistTrackAdd struct { + TrackID any `json:"track_id,omitempty"` + TrackIDs []string `json:"track_ids,omitempty"` +} + +// PlaylistsResponse is the list of user playlists. +type PlaylistsResponse struct { + Items []PlaylistItem `json:"items"` +} + +// PlaylistTracksResponse contains paginated tracks for a playlist. +type PlaylistTracksResponse struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int64 `json:"total"` + Items []*BrowseItem `json:"items"` +} + +// PlaylistShareResponse returns public read-only shared playlist details. +type PlaylistShareResponse struct { + PlaylistID string `json:"playlist_id"` + Name string `json:"name"` + CoverURL string `json:"cover_url,omitempty"` + NormalThumbnail string `json:"normal_thumbnail,omitempty"` + Tracks []*BrowseItem `json:"tracks"` + CreatedAt float64 `json:"created_at,omitempty"` +} + +// UserAlbum represents a saved album in user_albums collection. +type UserAlbum struct { + ID string `bson:"_id,omitempty" json:"_id,omitempty"` + UserID int64 `bson:"user_id" json:"user_id"` + AlbumID string `bson:"album_id" json:"album_id"` + SavedAt float64 `bson:"saved_at" json:"saved_at"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at,omitempty"` +} + +// UserAlbumAdd payload for saving an album. +type UserAlbumAdd struct { + AlbumID any `json:"album_id,omitempty"` + AlbumIDs []string `json:"album_ids,omitempty"` +} + +// UserAlbumItem represents a single album entry in list saved albums. +type UserAlbumItem struct { + AlbumID string `json:"album_id"` + Album any `json:"album,omitempty"` + SavedAt float64 `json:"saved_at,omitempty"` +} + +// UserAlbumsResponse list of saved albums. +type UserAlbumsResponse struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int64 `json:"total"` + Items []UserAlbumItem `json:"items"` +} + +// UserFavourite represents a track liked by a user in user_favourites. +type UserFavourite struct { + ID string `bson:"_id,omitempty" json:"_id,omitempty"` + UserID int64 `bson:"user_id" json:"user_id"` + TrackID string `bson:"track_id" json:"track_id"` + CreatedAt float64 `bson:"created_at" json:"created_at"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at,omitempty"` +} + +// UserFavouriteArtist represents a followed artist in user_favourite_artists. +type UserFavouriteArtist struct { + ID string `bson:"_id,omitempty" json:"_id,omitempty"` + UserID int64 `bson:"user_id" json:"user_id"` + ArtistID string `bson:"artist_id" json:"artist_id"` + CreatedAt float64 `bson:"created_at" json:"created_at"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at,omitempty"` +} + +// FavouriteCreate payload for liking a track. +type FavouriteCreate struct { + TrackID string `json:"track_id"` +} + +// ArtistFavouriteCreate payload for liking an artist. +type ArtistFavouriteCreate struct { + ArtistID string `json:"artist_id,omitempty"` + ID string `json:"id,omitempty"` + ArtistId string `json:"artistId,omitempty"` +} + +// FavouriteItem pairs a track with the favourited timestamp. +type FavouriteItem struct { + Track *BrowseItem `json:"track"` + CreatedAt float64 `json:"created_at,omitempty"` +} + +// FavouritesResponse contains paginated favourited track items. +type FavouritesResponse struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int64 `json:"total"` + Items []FavouriteItem `json:"items"` + LastUpdatedAt *float64 `json:"last_updated_at,omitempty"` +} + +// FavouriteIdsResponse contains array of favourited track IDs. +type FavouriteIdsResponse struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int64 `json:"total"` + IDs []string `json:"ids"` + Exists bool `json:"exists"` + LastUpdatedAt *float64 `json:"last_updated_at,omitempty"` +} + +// FavouriteIDsResponse alias for FavouriteIdsResponse. +type FavouriteIDsResponse = FavouriteIdsResponse + +// FavouriteArtistsResponse contains array of followed artist IDs. +type FavouriteArtistsResponse struct { + OK bool `json:"ok"` + IDs []string `json:"ids"` +} + +// ListeningEventItem represents a single telemetry playback event in listening_events. +type ListeningEventItem struct { + ID string `json:"id" bson:"event_id"` + TrackID string `json:"track_id" bson:"track_id"` + PlayedAt float64 `json:"played_at" bson:"played_at"` + StartedAt float64 `json:"started_at" bson:"started_at"` + PlayedMs float64 `json:"played_ms" bson:"played_ms"` + DurationMs float64 `json:"duration_ms" bson:"duration_ms"` + Completed bool `json:"completed" bson:"completed"` + Skipped bool `json:"skipped" bson:"skipped"` + Source string `json:"source" bson:"source"` + SessionID string `json:"session_id,omitempty" bson:"session_id,omitempty"` +} + +// ListeningEventsPayload is the payload for batch listening events. +type ListeningEventsPayload struct { + Events []ListeningEventItem `json:"events"` +} + +// StartRemoteAuthResponse starts Discord Remote Auth session. +type StartRemoteAuthResponse struct { + SessionID string `json:"session_id"` + Status string `json:"status"` + URL string `json:"url,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` +} + +// RemoteAuthStatusResponse returns status of Discord Remote Auth session. +type RemoteAuthStatusResponse struct { + SessionID string `json:"session_id"` + Status string `json:"status"` + URL string `json:"url,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + User any `json:"user,omitempty"` + Token string `json:"token,omitempty"` + Error string `json:"error,omitempty"` +} + +// ExternalAssetsRequest payload for proxying Discord asset requests. +type ExternalAssetsRequest struct { + URLs []string `json:"urls"` + ApplicationID string `json:"application_id,omitempty"` + Token string `json:"token"` +} diff --git a/internal/models/friends.go b/internal/models/friends.go new file mode 100644 index 0000000..9bd2c53 --- /dev/null +++ b/internal/models/friends.go @@ -0,0 +1,28 @@ +package models + +// FriendRequestPayload represents payload to send a friend request matching Api/schemas/friends.py. +type FriendRequestPayload struct { + To int64 `json:"to"` +} + +// AcceptRequestPayload represents payload to accept a friend request matching Api/schemas/friends.py. +type AcceptRequestPayload struct { + UserID int64 `json:"userId"` +} + +// InviteJamPayload represents payload to invite a friend to a jam session matching Api/schemas/friends.py. +type InviteJamPayload struct { + ToUserID int64 `json:"toUserId"` + JamID string `json:"jamId"` +} + +// SettingsPayload represents friend / jam privacy settings payload matching Api/schemas/friends.py. +type SettingsPayload struct { + ShareListening *string `json:"share_listening,omitempty"` + AllowJamInvites *bool `json:"allow_jam_invites,omitempty"` +} + +// FcmTokenPayload represents FCM token update payload matching Api/schemas/friends.py. +type FcmTokenPayload struct { + Token string `json:"token"` +} diff --git a/internal/models/history.go b/internal/models/history.go new file mode 100644 index 0000000..f600413 --- /dev/null +++ b/internal/models/history.go @@ -0,0 +1,20 @@ +package models + +// UserHistoryRecord represents a playback record in MongoDB userHistory. +type UserHistoryRecord struct { + ID string `bson:"_id,omitempty" json:"id,omitempty"` + UserID int64 `bson:"user_id" json:"user_id"` + TrackID string `bson:"track_id" json:"track_id"` + PlayedAt float64 `bson:"played_at" json:"played_at"` +} + +// UserPlaybackRecord represents an aggregated record in userPlayback. +type UserPlaybackRecord struct { + ID string `bson:"_id" json:"id"` // user_id:track_id:bucket + UserID int64 `bson:"user_id" json:"user_id"` + TrackID string `bson:"track_id" json:"track_id"` + PlayedAt float64 `bson:"played_at" json:"played_at"` + Bucket int64 `bson:"bucket" json:"bucket"` + Source string `bson:"source,omitempty" json:"source,omitempty"` + Plays int64 `bson:"plays,omitempty" json:"plays,omitempty"` +} diff --git a/internal/models/recap.go b/internal/models/recap.go new file mode 100644 index 0000000..458ec63 --- /dev/null +++ b/internal/models/recap.go @@ -0,0 +1,191 @@ +package models + +// RecapPeriodType represents the time period of a recap. +type RecapPeriodType string + +const ( + RecapPeriodWeekly RecapPeriodType = "weekly" + RecapPeriodMonthly RecapPeriodType = "monthly" + RecapPeriodYearly RecapPeriodType = "yearly" +) + +// TrackStat represents a track's listening statistics in a recap. +type TrackStat struct { + Track *BrowseItem `json:"track"` + Plays int `json:"plays"` + Minutes float64 `json:"minutes"` +} + +// ArtistStat represents an artist's listening statistics in a recap. +type ArtistStat struct { + Name string `json:"name"` + Plays int `json:"plays"` + Minutes float64 `json:"minutes"` + CoverURL *string `json:"cover_url,omitempty"` +} + +// AlbumStat represents an album's listening statistics in a recap. +type AlbumStat struct { + Album string `json:"album"` + Artist string `json:"artist"` + AlbumID *string `json:"album_id,omitempty"` + CoverURL *string `json:"cover_url,omitempty"` + Plays int `json:"plays"` + Minutes float64 `json:"minutes"` +} + +// GenreStat represents a genre or topic's listening statistics in a recap. +type GenreStat struct { + Name string `json:"name"` + Plays int `json:"plays"` +} + +// DayStat represents listening minutes on a specific date. +type DayStat struct { + Date string `json:"date"` + Minutes int `json:"minutes"` +} + +// MonthStat represents listening minutes in a specific month. +type MonthStat struct { + Month string `json:"month"` + Minutes int `json:"minutes"` +} + +// RecapStats represents the aggregated listening statistics for a recap snapshot. +type RecapStats struct { + TotalMinutes int `json:"totalMinutes"` + TotalPlays int `json:"totalPlays"` + CompletedPlays int `json:"completedPlays"` + SkippedPlays int `json:"skippedPlays"` + UniqueTracks int `json:"uniqueTracks"` + UniqueArtists int `json:"uniqueArtists"` + UniqueAlbums int `json:"uniqueAlbums"` + TopTracks []*TrackStat `json:"topTracks"` + TopArtists []*ArtistStat `json:"topArtists"` + TopAlbums []*AlbumStat `json:"topAlbums"` + TopGenres []*GenreStat `json:"topGenres"` + ListeningByHour []int `json:"listeningByHour"` + ListeningByWeekday []int `json:"listeningByWeekday"` + ListeningByDay []*DayStat `json:"listeningByDay"` + ListeningByMonth []*MonthStat `json:"listeningByMonth"` + DiscoveryCount int `json:"discoveryCount"` + DiscoveryTracks []*BrowseItem `json:"discoveryTracks"` + RepeatCount int `json:"repeatCount"` + MostActiveHour *int `json:"mostActiveHour"` + MostActiveWeekday *int `json:"mostActiveWeekday"` + MostActiveDate *string `json:"mostActiveDate"` + MostActiveDateMinutes int `json:"mostActiveDateMinutes"` + LongestSessionMinutes int `json:"longestSessionMinutes"` + SessionCount int `json:"sessionCount"` + NightShare float64 `json:"nightShare"` + EarlyShare float64 `json:"earlyShare"` + WeekendShare float64 `json:"weekendShare"` + LosslessShare float64 `json:"losslessShare"` + TopArtistShare float64 `json:"topArtistShare"` +} + +// RecapPersonality represents a deterministic listening personality classification. +type RecapPersonality struct { + ID string `json:"id"` + Name string `json:"name"` + Detail string `json:"detail"` +} + +// RecapComparison represents comparison statistics against the preceding period. +type RecapComparison struct { + Period string `json:"period"` + TotalMinutes int `json:"totalMinutes"` + TotalPlays int `json:"totalPlays"` + UniqueArtists int `json:"uniqueArtists"` + UniqueTracks int `json:"uniqueTracks"` + MinutesDeltaPct *int `json:"minutesDeltaPct"` + PlaysDeltaPct *int `json:"playsDeltaPct"` + ArtistsDelta int `json:"artistsDelta"` + NightShareDelta int `json:"nightShareDelta"` + TopArtistPrev *string `json:"topArtistPrev"` +} + +// RecapSnapshot is the standardized snapshot schema version 1. +type RecapSnapshot struct { + SchemaVersion int `json:"schemaVersion" bson:"schemaVersion"` + Type RecapPeriodType `json:"type" bson:"type"` + Period string `json:"period" bson:"period"` + Label string `json:"label" bson:"label"` + Start float64 `json:"start" bson:"start"` + End float64 `json:"end" bson:"end"` + Ongoing bool `json:"ongoing" bson:"ongoing"` + GeneratedAt float64 `json:"generatedAt" bson:"generatedAt"` + TzOffsetMin int `json:"tzOffsetMin,omitempty" bson:"tzOffsetMin,omitempty"` + EventCount int `json:"eventCount" bson:"eventCount"` + LegacyData bool `json:"legacyData" bson:"legacyData"` + Stats *RecapStats `json:"stats" bson:"stats"` + Personality []*RecapPersonality `json:"personality" bson:"personality"` + Comparison *RecapComparison `json:"comparison" bson:"comparison"` +} + +// RecapAvailableItem describes an unlocked recap period available for a user. +type RecapAvailableItem struct { + Type RecapPeriodType `json:"type"` + Period string `json:"period"` + Label string `json:"label"` + Ongoing bool `json:"ongoing"` +} + +// RecapPublicTrack is a simplified track item for public share links. +type RecapPublicTrack struct { + Title *string `json:"title"` + Artist *string `json:"artist"` + CoverURL *string `json:"cover_url"` +} + +// RecapPublicSummary is the sanitized public representation of a shared recap. +type RecapPublicSummary struct { + Type RecapPeriodType `json:"type"` + Period string `json:"period"` + Label string `json:"label"` + TotalMinutes int `json:"totalMinutes"` + TotalPlays int `json:"totalPlays"` + UniqueArtists int `json:"uniqueArtists"` + UniqueTracks int `json:"uniqueTracks"` + TopArtist *ArtistStat `json:"topArtist"` + TopTrack *RecapPublicTrack `json:"topTrack"` + Personality []*RecapPersonality `json:"personality"` +} + +// RecapShareItem represents an item in GET /me/recaps/shares. +type RecapShareItem struct { + Token string `json:"token"` + Type RecapPeriodType `json:"type"` + Period string `json:"period"` + Label string `json:"label,omitempty"` + CreatedAt float64 `json:"created_at"` +} + +// ListeningEventDoc represents a single row stored in the listening_events collection. +type ListeningEventDoc struct { + ID string `bson:"_id"` + UserID int64 `bson:"user_id"` + TrackID string `bson:"track_id"` + PlayedAt float64 `bson:"played_at"` + StartedAt float64 `bson:"started_at"` + PlayedMs *int `bson:"played_ms"` + DurationMs int `bson:"duration_ms"` + Completed bool `bson:"completed"` + Skipped bool `bson:"skipped"` + Source string `bson:"source"` + SessionID *string `bson:"session_id,omitempty"` + CreatedAt float64 `bson:"created_at"` + Legacy bool `bson:"legacy,omitempty"` +} + +// RecapShareDoc represents a share link document in the recap_shares collection. +type RecapShareDoc struct { + Token string `bson:"token"` + UserID int64 `bson:"user_id"` + Type RecapPeriodType `bson:"type"` + Period string `bson:"period"` + CreatedAt float64 `bson:"created_at"` + RevokedAt *float64 `bson:"revoked_at,omitempty"` + Summary *RecapPublicSummary `bson:"summary"` +} diff --git a/internal/models/track.go b/internal/models/track.go new file mode 100644 index 0000000..2dd5abc --- /dev/null +++ b/internal/models/track.go @@ -0,0 +1,285 @@ +package models + +import "strings" + +// AudioMeta represents metadata extracted from the audio stream or tags via MediaInfo. +type AudioMeta struct { + Title string `bson:"title,omitempty" json:"title"` + Album string `bson:"album,omitempty" json:"album,omitempty"` + Artist string `bson:"artist,omitempty" json:"artist"` + Composer string `bson:"composer,omitempty" json:"composer,omitempty"` + Label string `bson:"label,omitempty" json:"label,omitempty"` + Genre string `bson:"genre,omitempty" json:"genre,omitempty"` + Year *int32 `bson:"year,omitempty" json:"year,omitempty"` + DurationSec int32 `bson:"duration_sec" json:"duration_sec"` + Type string `bson:"type,omitempty" json:"type,omitempty"` + BitDepth *int32 `bson:"bit_depth,omitempty" json:"bit_depth,omitempty"` + BitrateKbps *int32 `bson:"bitrate_kbps,omitempty" json:"bitrate_kbps,omitempty"` + SamplingRateHz *int32 `bson:"sampling_rate_hz,omitempty" json:"sampling_rate_hz,omitempty"` + Artists []string `bson:"artists,omitempty" json:"artists,omitempty"` + AlbumID string `bson:"album_id,omitempty" json:"album_id,omitempty"` + + // Legacy / Compatibility fields that should NOT be serialized to MongoDB audio subdocument + Performer string `bson:"performer,omitempty" json:"performer,omitempty"` + CoverURL string `bson:"-" json:"cover_url,omitempty"` + Lyrics string `bson:"-" json:"lyrics,omitempty"` + Titles map[string]any `bson:"titles,omitempty" json:"titles,omitempty"` + MimeType string `bson:"-" json:"mime_type,omitempty"` + FileSize int64 `bson:"-" json:"file_size,omitempty"` +} + +// TelegramMeta stores Telegram file references and chat mapping. +type TelegramMeta struct { + FileID string `bson:"file_id,omitempty" json:"file_id"` + FileUniqueID string `bson:"file_unique_id,omitempty" json:"file_unique_id,omitempty"` + FileName string `bson:"file_name,omitempty" json:"file_name,omitempty"` + FileSize int64 `bson:"file_size,omitempty" json:"file_size,omitempty"` + MimeType string `bson:"mime_type,omitempty" json:"mime_type,omitempty"` + FileIDs map[string]string `bson:"file_ids,omitempty" json:"file_ids,omitempty"` +} + +// SpotifyMeta stores resolved Spotify match data and cover URLs. +type SpotifyMeta struct { + URL string `bson:"url,omitempty" json:"url,omitempty"` + CoverURL string `bson:"cover_url,omitempty" json:"cover_url,omitempty"` + BigCoverURL string `bson:"big_cover_url,omitempty" json:"big_cover_url,omitempty"` + CoverSource string `bson:"cover_source,omitempty" json:"cover_source,omitempty"` + ArtistAvatar string `bson:"artist_avatar,omitempty" json:"artist_avatar,omitempty"` + TrackSpotifyID string `bson:"track_spotify_id,omitempty" json:"track_spotify_id,omitempty"` + CloudflareCoverURL string `bson:"cloudflare_cover_url,omitempty" json:"cloudflare_cover_url,omitempty"` + CloudflareBigCoverURL string `bson:"cloudflare_big_cover_url,omitempty" json:"cloudflare_big_cover_url,omitempty"` +} + +// Track represents the full track document stored in the audioTracks MongoDB collection. +type Track struct { + ID string `bson:"_id" json:"_id"` + Audio AudioMeta `bson:"audio" json:"audio"` + Telegram TelegramMeta `bson:"telegram" json:"telegram"` + Spotify SpotifyMeta `bson:"spotify,omitempty" json:"spotify,omitempty"` + CoverURL string `bson:"-" json:"cover_url,omitempty"` + CloudflareCoverURL string `bson:"cloudflare_cover_url,omitempty" json:"cloudflare_cover_url,omitempty"` + CloudflareBigCoverURL string `bson:"cloudflare_big_cover_url,omitempty" json:"cloudflare_big_cover_url,omitempty"` + Lyrics string `bson:"lyrics,omitempty" json:"lyrics,omitempty"` + LyricsCache map[string]any `bson:"lyrics_cache,omitempty" json:"lyrics_cache,omitempty"` + Titles map[string]any `bson:"titles,omitempty" json:"titles,omitempty"` + Fingerprint string `bson:"fingerprint,omitempty" json:"fingerprint,omitempty"` + ContentHash string `bson:"content_hash,omitempty" json:"content_hash,omitempty"` + SourceChatID int64 `bson:"source_chat_id,omitempty" json:"source_chat_id,omitempty"` + SourceMessageID int32 `bson:"source_message_id,omitempty" json:"source_message_id,omitempty"` + CacheChatID int64 `bson:"cache_chat_id,omitempty" json:"cache_chat_id,omitempty"` + CacheMessageID int32 `bson:"cache_message_id,omitempty" json:"cache_message_id,omitempty"` + TopicID int32 `bson:"topic_id,omitempty" json:"topic_id"` + TopicName string `bson:"topic_name,omitempty" json:"topic_name,omitempty"` + PlayCount int32 `bson:"play_count,omitempty" json:"play_count"` + LikesCount int64 `bson:"likes_count,omitempty" json:"likes_count"` + CreatedAt float64 `bson:"created_at,omitempty" json:"created_at"` + UpdatedAt float64 `bson:"updated_at,omitempty" json:"updated_at"` + EnrichedAt float64 `bson:"enriched_at,omitempty" json:"enriched_at"` + Enriched bool `bson:"enriched" json:"enriched"` + Indexed bool `bson:"indexed" json:"indexed"` + Deleted bool `bson:"deleted,omitempty" json:"deleted"` + Liked bool `bson:"-" json:"liked"` +} + +// EffectiveCoverURL returns the best available preview/list cover art URL, prioritizing Cloudflare R2 preview thumbnails. +func (t *Track) EffectiveCoverURL() string { + if t.CloudflareCoverURL != "" { + return t.CloudflareCoverURL + } + if t.Spotify.CloudflareCoverURL != "" { + return t.Spotify.CloudflareCoverURL + } + if t.Spotify.CoverURL != "" { + return t.Spotify.CoverURL + } + if t.CloudflareBigCoverURL != "" { + return t.CloudflareBigCoverURL + } + if t.Spotify.CloudflareBigCoverURL != "" { + return t.Spotify.CloudflareBigCoverURL + } + if t.Spotify.BigCoverURL != "" { + return t.Spotify.BigCoverURL + } + return t.Audio.CoverURL +} + +// EffectiveBigCoverURL returns the best available high-resolution cover art URL, prioritizing Cloudflare R2 master artwork. +func (t *Track) EffectiveBigCoverURL() string { + if t.CloudflareBigCoverURL != "" { + return t.CloudflareBigCoverURL + } + if t.Spotify.CloudflareBigCoverURL != "" { + return t.Spotify.CloudflareBigCoverURL + } + if t.Spotify.BigCoverURL != "" { + return t.Spotify.BigCoverURL + } + return t.EffectiveCoverURL() +} + +// EffectiveArtist returns the best artist/performer string. +func (t *Track) EffectiveArtist() string { + if t.Audio.Artist != "" { + return t.Audio.Artist + } + return t.Audio.Performer +} + +// EffectiveTitles returns the best available alternative titles map (original, romanized, etc.). +func (t *Track) EffectiveTitles() map[string]any { + if len(t.Titles) > 0 { + return t.Titles + } + if len(t.Audio.Titles) > 0 { + return t.Audio.Titles + } + return nil +} + +// EffectiveType returns the audio format type, automatically distinguishing ALAC from lossy M4A if bit depth is present. +func (t *Track) EffectiveType() string { + typ := strings.ToLower(strings.TrimSpace(t.Audio.Type)) + if (typ == "m4a" || typ == "" || typ == "aac") && t.Audio.BitDepth != nil && *t.Audio.BitDepth > 0 { + return "alac" + } + if typ != "" { + return typ + } + name := strings.ToLower(t.Telegram.FileName) + if strings.Contains(name, "alac") || strings.Contains(strings.ToLower(t.Telegram.MimeType), "alac") { + return "alac" + } + return typ +} + +// BrowseItem represents a flattened track item optimized for list/feed UI views. +type BrowseItem struct { + ID string `json:"id"` + DocID string `json:"_id,omitempty"` + SourceChatID int64 `json:"source_chat_id,omitempty"` + SourceMessageID int32 `json:"source_message_id,omitempty"` + TopicID int32 `json:"topic_id,omitempty"` + TopicName string `json:"topic_name,omitempty"` + Title string `json:"title"` + Artist string `json:"artist"` + Album string `json:"album,omitempty"` + AlbumID string `json:"album_id,omitempty"` + DurationSec int32 `json:"duration_sec"` + Type string `json:"type,omitempty"` + SamplingRateHz *int32 `json:"sampling_rate_hz,omitempty"` + BitDepth *int32 `json:"bit_depth,omitempty"` + BitrateKbps *int32 `json:"bitrate_kbps,omitempty"` + SpotifyURL string `json:"spotify_url,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + BigCoverURL string `json:"big_cover_url,omitempty"` + CloudflareCoverURL string `json:"cloudflare_cover_url,omitempty"` + CloudflareBigCoverURL string `json:"cloudflare_big_cover_url,omitempty"` + Titles map[string]any `json:"titles,omitempty"` + CreatedAt float64 `json:"created_at"` + UpdatedAt float64 `json:"updated_at"` + Liked bool `json:"liked"` +} + +// ToBrowseItem converts a full Track entity into a lightweight BrowseItem. +func (t *Track) ToBrowseItem() *BrowseItem { + cfCover := t.CloudflareCoverURL + if cfCover == "" { + cfCover = t.Spotify.CloudflareCoverURL + } + cfBigCover := t.CloudflareBigCoverURL + if cfBigCover == "" { + cfBigCover = t.Spotify.CloudflareBigCoverURL + } + + return &BrowseItem{ + ID: t.ID, + DocID: t.ID, + SourceChatID: t.SourceChatID, + SourceMessageID: t.SourceMessageID, + TopicID: t.TopicID, + TopicName: t.TopicName, + Title: t.Audio.Title, + Artist: t.EffectiveArtist(), + Album: t.Audio.Album, + AlbumID: t.Audio.AlbumID, + DurationSec: t.Audio.DurationSec, + Type: t.EffectiveType(), + SamplingRateHz: t.Audio.SamplingRateHz, + BitDepth: t.Audio.BitDepth, + BitrateKbps: t.Audio.BitrateKbps, + SpotifyURL: t.Spotify.URL, + CoverURL: t.EffectiveCoverURL(), + BigCoverURL: t.EffectiveBigCoverURL(), + CloudflareCoverURL: cfCover, + CloudflareBigCoverURL: cfBigCover, + Titles: t.EffectiveTitles(), + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + Liked: t.Liked, + } +} + +// BrowseResponse represents a paginated response of BrowseItem objects matching Api/schemas/browse.py. +type BrowseResponse struct { + Items []*BrowseItem `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PerPage int `json:"per_page"` + TotalPages int `json:"total_pages,omitempty"` + CoverURL string `json:"cover_url,omitempty"` +} + +// TopicItem represents an aggregated topic with track counts and cover image matching Api/schemas/topics.py. +type TopicItem struct { + Name string `json:"name"` + TopicName string `json:"topic_name"` + TopicID *int64 `json:"topic_id,omitempty"` + Count int64 `json:"count"` + TracksCount int64 `json:"tracks_count"` + CoverURL string `json:"cover_url,omitempty"` + ThumbnailURL string `json:"thumbnail_url,omitempty"` + NormalThumbnail string `json:"normal_thumbnail,omitempty"` + Thumbnails []string `json:"thumbnails"` + SourceChatID *int64 `json:"source_chat_id,omitempty"` + Endpoint string `json:"endpoint"` +} + +// TopicsResponse is the response payload for GET /topics matching Api/schemas/topics.py. +type TopicsResponse struct { + OK bool `json:"ok"` + Total int `json:"total"` + Items []*TopicItem `json:"items"` + Topics []string `json:"topics"` +} + +// TrackResponse represents a full track document matching Api/schemas/track.py. +type TrackResponse struct { + ID string `json:"_id"` + DocID string `json:"id,omitempty"` + SourceChatID *int64 `json:"source_chat_id,omitempty"` + SourceMessageID *int32 `json:"source_message_id,omitempty"` + Telegram map[string]any `json:"telegram,omitempty"` + Audio map[string]any `json:"audio,omitempty"` + Spotify map[string]any `json:"spotify,omitempty"` + Titles map[string]any `json:"titles,omitempty"` + ContentHash string `json:"content_hash,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + CreatedAt float64 `json:"created_at,omitempty"` + UpdatedAt float64 `json:"updated_at,omitempty"` + Liked bool `json:"liked"` +} + +// ChannelItem represents an indexed Telegram source channel. +type ChannelItem struct { + ID int64 `json:"id"` + Title string `json:"title,omitempty"` + Username string `json:"username,omitempty"` + Type string `json:"type,omitempty"` +} + +// ChannelIDsResponse is the response payload for GET /channelids. +type ChannelIDsResponse struct { + OK bool `json:"ok"` + Items []ChannelItem `json:"items"` +} diff --git a/internal/repository/access_control_repo.go b/internal/repository/access_control_repo.go new file mode 100644 index 0000000..08dcc7d --- /dev/null +++ b/internal/repository/access_control_repo.go @@ -0,0 +1,300 @@ +package repository + +import ( + "context" + "errors" + "strings" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "streamgo/internal/database" + "streamgo/internal/models" +) + +// AccessControlRepository manages access control policies, invites, and allowlists. +type AccessControlRepository interface { + GetPolicy(ctx context.Context) (*models.AccessPolicy, error) + SavePolicy(ctx context.Context, policy *models.AccessPolicy) error + + CreateInvite(ctx context.Context, invite *models.InviteCode) error + GetInvite(ctx context.Context, code string) (*models.InviteCode, error) + ConsumeInvite(ctx context.Context, code string) error + RevokeInvite(ctx context.Context, code string) bool + ListInvites(ctx context.Context, includeDead bool) ([]*models.InviteCode, error) + + AddToAllowlist(ctx context.Context, entry *models.AllowlistEntry) error + RemoveFromAllowlist(ctx context.Context, userID int64) bool + IsInAllowlist(ctx context.Context, userID int64) bool + ListAllowlist(ctx context.Context) ([]*models.AllowlistEntry, error) + + AddToBypass(ctx context.Context, entry *models.AllowlistEntry) error + RemoveFromBypass(ctx context.Context, userID int64) bool + IsInBypass(ctx context.Context, userID int64) bool + ListBypass(ctx context.Context) ([]*models.AllowlistEntry, error) + + ListUsers(ctx context.Context, query string, status string, limit int, skip int) ([]*models.User, int64, error) + SetUserStatus(ctx context.Context, userID int64, status string, reason string) error + RevokeUserSessions(ctx context.Context, userID int64) (int, error) +} + +type mongoAccessControlRepository struct { + authConfigCol *mongo.Collection + invitesCol *mongo.Collection + allowlistCol *mongo.Collection + bypassCol *mongo.Collection + usersCol *mongo.Collection +} + +// NewAccessControlRepository creates a MongoDB AccessControlRepository. +func NewAccessControlRepository(db *database.Client) AccessControlRepository { + return &mongoAccessControlRepository{ + authConfigCol: db.Collection("auth_config"), + invitesCol: db.Collection("invites"), + allowlistCol: db.Collection("allowlist"), + bypassCol: db.Collection("bypass"), + usersCol: db.Collection("users"), + } +} + +// GetPolicy retrieves the access policy document, or returns default open policy if not found. +func (r *mongoAccessControlRepository) GetPolicy(ctx context.Context) (*models.AccessPolicy, error) { + filter := bson.M{"_id": "access_policy"} + var doc struct { + Policy models.AccessPolicy `bson:"policy"` + } + err := r.authConfigCol.FindOne(ctx, filter).Decode(&doc) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return &models.AccessPolicy{ + RegistrationMode: "open", + EnforceMembership: false, + RequiredChats: []models.RequiredChat{}, + }, nil + } + return nil, err + } + if doc.Policy.RegistrationMode == "" { + doc.Policy.RegistrationMode = "open" + } + return &doc.Policy, nil +} + +// SavePolicy saves the access policy. +func (r *mongoAccessControlRepository) SavePolicy(ctx context.Context, policy *models.AccessPolicy) error { + filter := bson.M{"_id": "access_policy"} + update := bson.M{ + "$set": bson.M{ + "policy": policy, + "updated_at": float64(time.Now().Unix()), + }, + } + opts := options.UpdateOne().SetUpsert(true) + _, err := r.authConfigCol.UpdateOne(ctx, filter, update, opts) + return err +} + +// CreateInvite inserts a new invite code. +func (r *mongoAccessControlRepository) CreateInvite(ctx context.Context, invite *models.InviteCode) error { + _, err := r.invitesCol.InsertOne(ctx, invite) + return err +} + +// GetInvite finds an invite code. +func (r *mongoAccessControlRepository) GetInvite(ctx context.Context, code string) (*models.InviteCode, error) { + filter := bson.M{"_id": code} + var inv models.InviteCode + err := r.invitesCol.FindOne(ctx, filter).Decode(&inv) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, err + } + return &inv, nil +} + +// ConsumeInvite increments the used count on an invite code. +func (r *mongoAccessControlRepository) ConsumeInvite(ctx context.Context, code string) error { + filter := bson.M{"_id": code} + update := bson.M{"$inc": bson.M{"used_count": 1}} + _, err := r.invitesCol.UpdateOne(ctx, filter, update) + return err +} + +// RevokeInvite marks an invite as revoked. +func (r *mongoAccessControlRepository) RevokeInvite(ctx context.Context, code string) bool { + res, err := r.invitesCol.UpdateOne(ctx, bson.M{"_id": code}, bson.M{"$set": bson.M{"revoked": true}}) + return err == nil && res.MatchedCount > 0 +} + +// ListInvites lists invite codes. +func (r *mongoAccessControlRepository) ListInvites(ctx context.Context, includeDead bool) ([]*models.InviteCode, error) { + filter := bson.M{} + if !includeDead { + now := float64(time.Now().Unix()) + filter["revoked"] = bson.M{"$ne": true} + filter["$or"] = []bson.M{ + {"expires_at": nil}, + {"expires_at": bson.M{"$gt": now}}, + } + } + + opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}).SetLimit(100) + cursor, err := r.invitesCol.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var items []*models.InviteCode + for cursor.Next(ctx) { + var inv models.InviteCode + if err := cursor.Decode(&inv); err == nil { + items = append(items, &inv) + } + } + return items, nil +} + +// AddToAllowlist adds a user to allowlist. +func (r *mongoAccessControlRepository) AddToAllowlist(ctx context.Context, entry *models.AllowlistEntry) error { + opts := options.UpdateOne().SetUpsert(true) + _, err := r.allowlistCol.UpdateOne(ctx, bson.M{"_id": entry.UserID}, bson.M{"$set": entry}, opts) + return err +} + +// RemoveFromAllowlist removes user from allowlist. +func (r *mongoAccessControlRepository) RemoveFromAllowlist(ctx context.Context, userID int64) bool { + res, err := r.allowlistCol.DeleteOne(ctx, bson.M{"_id": userID}) + return err == nil && res.DeletedCount > 0 +} + +// IsInAllowlist checks if a user is in allowlist. +func (r *mongoAccessControlRepository) IsInAllowlist(ctx context.Context, userID int64) bool { + err := r.allowlistCol.FindOne(ctx, bson.M{"_id": userID}).Err() + return err == nil +} + +// ListAllowlist lists all allowlisted users. +func (r *mongoAccessControlRepository) ListAllowlist(ctx context.Context) ([]*models.AllowlistEntry, error) { + cursor, err := r.allowlistCol.Find(ctx, bson.M{}, options.Find().SetSort(bson.D{{Key: "added_at", Value: -1}})) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var items []*models.AllowlistEntry + for cursor.Next(ctx) { + var e models.AllowlistEntry + if err := cursor.Decode(&e); err == nil { + items = append(items, &e) + } + } + return items, nil +} + +// AddToBypass adds a user to bypass list. +func (r *mongoAccessControlRepository) AddToBypass(ctx context.Context, entry *models.AllowlistEntry) error { + opts := options.UpdateOne().SetUpsert(true) + _, err := r.bypassCol.UpdateOne(ctx, bson.M{"_id": entry.UserID}, bson.M{"$set": entry}, opts) + return err +} + +// RemoveFromBypass removes user from bypass list. +func (r *mongoAccessControlRepository) RemoveFromBypass(ctx context.Context, userID int64) bool { + res, err := r.bypassCol.DeleteOne(ctx, bson.M{"_id": userID}) + return err == nil && res.DeletedCount > 0 +} + +// IsInBypass checks if a user is in the membership bypass list. +func (r *mongoAccessControlRepository) IsInBypass(ctx context.Context, userID int64) bool { + err := r.bypassCol.FindOne(ctx, bson.M{"_id": userID}).Err() + return err == nil +} + +// ListBypass lists all bypassed users. +func (r *mongoAccessControlRepository) ListBypass(ctx context.Context) ([]*models.AllowlistEntry, error) { + cursor, err := r.bypassCol.Find(ctx, bson.M{}, options.Find().SetSort(bson.D{{Key: "added_at", Value: -1}})) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var items []*models.AllowlistEntry + for cursor.Next(ctx) { + var e models.AllowlistEntry + if err := cursor.Decode(&e); err == nil { + items = append(items, &e) + } + } + return items, nil +} + +// ListUsers lists users with optional search and status filter. +func (r *mongoAccessControlRepository) ListUsers(ctx context.Context, query string, status string, limit int, skip int) ([]*models.User, int64, error) { + filter := bson.M{} + if s := strings.TrimSpace(status); s != "" { + filter["status"] = s + } + if q := strings.TrimSpace(query); q != "" { + filter["$or"] = []bson.M{ + {"username": bson.M{"$regex": q, "$options": "i"}}, + {"first_name": bson.M{"$regex": q, "$options": "i"}}, + } + } + + total, err := r.usersCol.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, err + } + + opts := options.Find(). + SetSort(bson.D{{Key: "created_at", Value: -1}}). + SetLimit(int64(limit)). + SetSkip(int64(skip)) + + cursor, err := r.usersCol.Find(ctx, filter, opts) + if err != nil { + return nil, 0, err + } + defer cursor.Close(ctx) + + var users []*models.User + for cursor.Next(ctx) { + var u models.User + if err := cursor.Decode(&u); err == nil { + users = append(users, &u) + } + } + return users, total, nil +} + +// SetUserStatus updates user status (active, locked, restricted). +func (r *mongoAccessControlRepository) SetUserStatus(ctx context.Context, userID int64, status string, reason string) error { + update := bson.M{ + "$set": bson.M{ + "status": status, + "updated_at": float64(time.Now().Unix()), + "lock_reason": reason, + }, + } + _, err := r.usersCol.UpdateOne(ctx, bson.M{"_id": userID}, update) + return err +} + +// RevokeUserSessions increments token_version to invalidate all active tokens. +func (r *mongoAccessControlRepository) RevokeUserSessions(ctx context.Context, userID int64) (int, error) { + update := bson.M{ + "$inc": bson.M{"token_version": 1}, + "$set": bson.M{"updated_at": float64(time.Now().Unix())}, + } + var res models.User + err := r.usersCol.FindOneAndUpdate(ctx, bson.M{"_id": userID}, update, options.FindOneAndUpdate().SetReturnDocument(options.After)).Decode(&res) + if err != nil { + return 0, err + } + return res.TokenVersion, nil +} diff --git a/internal/repository/artist_album_repo.go b/internal/repository/artist_album_repo.go new file mode 100644 index 0000000..24f5e66 --- /dev/null +++ b/internal/repository/artist_album_repo.go @@ -0,0 +1,846 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + "sync" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "streamgo/internal/database" + "streamgo/internal/metadata" + "streamgo/internal/models" +) + +var ( + artistSplitRegex = regexp.MustCompile(`(?i)\s*(?:,|/|&|\s+and\s+|\s+x\s+|\s+feat\.\s+|\s+feat\s+|\s+ft\.\s+|\s+ft\s+)\s*`) +) + +func splitArtists(value string) []string { + raw := strings.TrimSpace(value) + if raw == "" { + return []string{} + } + raw = strings.ReplaceAll(raw, "(", " ") + raw = strings.ReplaceAll(raw, ")", " ") + raw = strings.ReplaceAll(raw, "[", " ") + raw = strings.ReplaceAll(raw, "]", " ") + + parts := artistSplitRegex.Split(raw, -1) + out := make([]string, 0, len(parts)) + seen := make(map[string]bool) + + for _, p := range parts { + token := strings.Trim(strings.TrimSpace(p), " .") + if token == "" { + continue + } + key := strings.ToLower(token) + if seen[key] { + continue + } + seen[key] = true + out = append(out, token) + } + return out +} + +func slugify(val string) string { + return metadata.Slugify(val) +} + +// ArtistAlbumRepository manages data access for artists and albums. +type ArtistAlbumRepository interface { + ListArtists(ctx context.Context, page, perPage int, refresh bool) ([]*models.Artist, int64, error) + GetArtistByID(ctx context.Context, id string) (*models.Artist, error) + GetArtistTracks(ctx context.Context, artistName string, limit int) ([]*models.Track, error) + GetArtistTracksPaginated(ctx context.Context, artistName string, page, perPage int) ([]*models.Track, int64, error) + GetArtistAlbums(ctx context.Context, artistName string) ([]*models.Album, error) + + ListAlbums(ctx context.Context, page, perPage int, artistFilter string, refresh bool) ([]*models.Album, int64, error) + GetAlbumByID(ctx context.Context, id string) (*models.Album, error) + GetAlbumTracks(ctx context.Context, albumID string) ([]*models.Track, error) + GetAlbumTracksPaginated(ctx context.Context, albumID string, page, perPage int) ([]*models.Track, int64, error) + RefreshAlbumsCache(ctx context.Context, limit int) (int, error) + RefreshArtistsCache(ctx context.Context, limitTracks, limitArtists int) (int, error) +} + +type mongoArtistAlbumRepository struct { + artistsCol *mongo.Collection + albumsCol *mongo.Collection + tracksCol *mongo.Collection + albumRefreshMu sync.Mutex + artistRefreshMu sync.Mutex +} + +// NewArtistAlbumRepository creates an ArtistAlbumRepository backed by MongoDB. +func NewArtistAlbumRepository(db *database.Client) ArtistAlbumRepository { + return &mongoArtistAlbumRepository{ + artistsCol: db.Collection("artists"), + albumsCol: db.Collection("albums"), + tracksCol: db.Collection("audioTracks"), + } +} + +func (r *mongoArtistAlbumRepository) RefreshArtistsCache(ctx context.Context, limitTracks, limitArtists int) (int, error) { + if !r.artistRefreshMu.TryLock() { + return 0, nil + } + defer r.artistRefreshMu.Unlock() + + if limitTracks <= 0 { + limitTracks = 20000 + } + if limitTracks > 100000 { + limitTracks = 100000 + } + if limitArtists <= 0 { + limitArtists = 5000 + } + if limitArtists > 20000 { + limitArtists = 20000 + } + + opts := options.Find(). + SetSort(bson.D{{Key: "updated_at", Value: -1}}). + SetLimit(int64(limitTracks)). + SetProjection(bson.M{ + "audio.artist": 1, + "audio.performer": 1, + "audio.artists": 1, + "spotify.artist_avatar": 1, + "updated_at": 1, + }) + + filter := bson.M{ + "deleted": bson.M{"$ne": true}, + "$or": []bson.M{ + {"audio.artist": bson.M{"$exists": true, "$ne": ""}}, + {"audio.performer": bson.M{"$exists": true, "$ne": ""}}, + }, + } + + cursor, err := r.tracksCol.Find(ctx, filter, opts) + if err != nil { + return 0, err + } + defer cursor.Close(ctx) + + type artistEntry struct { + Name string + MatchArtist string + TracksCount int64 + AvatarURL string + UpdatedAt float64 + } + + byKey := make(map[string]*artistEntry) + for cursor.Next(ctx) { + var doc struct { + Audio struct { + Artist string `bson:"artist"` + Performer string `bson:"performer"` + Artists []string `bson:"artists"` + } `bson:"audio"` + Spotify struct { + ArtistAvatar string `bson:"artist_avatar"` + } `bson:"spotify"` + UpdatedAt float64 `bson:"updated_at"` + } + if err := cursor.Decode(&doc); err != nil { + continue + } + + var names []string + if len(doc.Audio.Artists) > 0 { + for _, a := range doc.Audio.Artists { + names = append(names, splitArtists(a)...) + } + } + if len(names) == 0 { + raw := doc.Audio.Artist + if raw == "" { + raw = doc.Audio.Performer + } + if raw != "" { + names = splitArtists(raw) + } + } + + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" { + continue + } + k := strings.ToLower(name) + if entry, exists := byKey[k]; exists { + entry.TracksCount++ + if doc.UpdatedAt > entry.UpdatedAt { + entry.UpdatedAt = doc.UpdatedAt + } + if entry.AvatarURL == "" && doc.Spotify.ArtistAvatar != "" { + entry.AvatarURL = doc.Spotify.ArtistAvatar + } + } else { + byKey[k] = &artistEntry{ + Name: name, + MatchArtist: k, + TracksCount: 1, + AvatarURL: doc.Spotify.ArtistAvatar, + UpdatedAt: doc.UpdatedAt, + } + } + } + } + + // Clean up legacy documents with spaces in _id or collaborative names + _, _ = r.artistsCol.DeleteMany(ctx, bson.M{ + "$or": []bson.M{ + {"_id": bson.M{"$regex": " "}}, + {"name": bson.M{"$regex": `(?i)/|,|&|\s+feat|\s+ft\s+|\s+featuring\s+`}}, + {"match_artist": bson.M{"$regex": `(?i)/|,|&|\s+feat|\s+ft\s+|\s+featuring\s+`}}, + }, + }) + + now := float64(time.Now().Unix()) + upserted := 0 + var writeModels []mongo.WriteModel + var validAIDs []string + + for _, entry := range byKey { + aid := metadata.ArtistID(entry.Name) + if aid == "" { + continue + } + validAIDs = append(validAIDs, aid) + updateDoc := bson.M{ + "name": entry.Name, + "match_artist": entry.MatchArtist, + "tracks_count": entry.TracksCount, + "updated_at": entry.UpdatedAt, + } + if entry.AvatarURL != "" { + updateDoc["cover_url"] = entry.AvatarURL + } + if entry.UpdatedAt == 0 { + updateDoc["updated_at"] = now + } + + model := mongo.NewUpdateOneModel(). + SetFilter(bson.M{"_id": aid}). + SetUpdate(bson.M{ + "$setOnInsert": bson.M{"created_at": now, "followers": 0}, + "$set": updateDoc, + }). + SetUpsert(true) + + writeModels = append(writeModels, model) + if len(writeModels) >= 500 { + _, _ = r.artistsCol.BulkWrite(ctx, writeModels, options.BulkWrite().SetOrdered(false)) + upserted += len(writeModels) + writeModels = writeModels[:0] + } + if upserted >= limitArtists { + break + } + } + + if len(writeModels) > 0 { + _, _ = r.artistsCol.BulkWrite(ctx, writeModels, options.BulkWrite().SetOrdered(false)) + upserted += len(writeModels) + } + + // Purge orphan artists whose canonical IDs are no longer in active scan + if len(validAIDs) > 0 { + _, _ = r.artistsCol.DeleteMany(ctx, bson.M{ + "_id": bson.M{"$nin": validAIDs}, + }) + } + + return upserted, nil +} + +func (r *mongoArtistAlbumRepository) ListArtists(ctx context.Context, page, perPage int, refresh bool) ([]*models.Artist, int64, error) { + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 50 + } + if perPage > 200 { + perPage = 200 + } + + total, err := r.artistsCol.EstimatedDocumentCount(ctx) + if err != nil { + total, _ = r.artistsCol.CountDocuments(ctx, bson.M{}) + } + + // Trigger asynchronous background cache refresh if completely empty or explicitly asked + if refresh || total <= 0 { + go func() { + bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + _, _ = r.RefreshArtistsCache(bgCtx, 100000, 20000) + }() + } + + filter := bson.M{} + skip := int64((page - 1) * perPage) + opts := options.Find(). + SetSort(bson.D{ + {Key: "tracks_count", Value: -1}, + {Key: "updated_at", Value: -1}, + }). + SetSkip(skip). + SetLimit(int64(perPage)) + + cursor, err := r.artistsCol.Find(ctx, filter, opts) + if err != nil { + return nil, 0, fmt.Errorf("failed to list artists: %w", err) + } + defer cursor.Close(ctx) + + var artists []*models.Artist + if err := cursor.All(ctx, &artists); err != nil { + return nil, 0, fmt.Errorf("failed to decode artists: %w", err) + } + + var uniqueArtists []*models.Artist + seenNames := make(map[string]bool) + seenIDs := make(map[string]bool) + + for _, a := range artists { + if a.Id == "" { + a.Id = a.ID + } + if a.AvatarURL == "" { + a.AvatarURL = a.CoverURL + } + if a.TrackCount == 0 { + a.TrackCount = a.TracksCount + } + if a.TracksCount <= 0 { + continue + } + nameNorm := strings.ToLower(strings.TrimSpace(a.Name)) + if nameNorm == "" || seenNames[nameNorm] || seenIDs[a.Id] { + continue + } + seenNames[nameNorm] = true + seenIDs[a.Id] = true + uniqueArtists = append(uniqueArtists, a) + } + + return uniqueArtists, total, nil +} + +func (r *mongoArtistAlbumRepository) GetArtistByID(ctx context.Context, id string) (*models.Artist, error) { + id = strings.TrimSpace(id) + filter := bson.M{"$or": []bson.M{{"_id": id}, {"match_artist": strings.ToLower(id)}}} + + var artist models.Artist + err := r.artistsCol.FindOne(ctx, filter).Decode(&artist) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, fmt.Errorf("failed to fetch artist %s: %w", id, err) + } + + if artist.Id == "" { + artist.Id = artist.ID + } + if artist.AvatarURL == "" { + artist.AvatarURL = artist.CoverURL + } + if artist.TrackCount == 0 { + artist.TrackCount = artist.TracksCount + } + + return &artist, nil +} + +func (r *mongoArtistAlbumRepository) GetArtistTracks(ctx context.Context, artistName string, limit int) ([]*models.Track, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + + escaped := regexp.QuoteMeta(artistName) + regex := bson.M{"$regex": "^" + escaped + "$", "$options": "i"} + + filter := bson.M{ + "deleted": bson.M{"$ne": true}, + "$or": []bson.M{ + {"audio.artist": regex}, + {"audio.artists": regex}, + {"audio.performer": regex}, + }, + } + + opts := options.Find(). + SetSort(bson.D{{Key: "play_count", Value: -1}, {Key: "updated_at", Value: -1}}). + SetLimit(int64(limit)) + + cursor, err := r.tracksCol.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var tracks []*models.Track + if err := cursor.All(ctx, &tracks); err != nil { + return nil, err + } + return tracks, nil +} + +func (r *mongoArtistAlbumRepository) GetArtistTracksPaginated(ctx context.Context, artistName string, page, perPage int) ([]*models.Track, int64, error) { + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 50 + } + if perPage > 200 { + perPage = 200 + } + + escaped := regexp.QuoteMeta(artistName) + regex := bson.M{"$regex": "^" + escaped + "$", "$options": "i"} + + filter := bson.M{ + "deleted": bson.M{"$ne": true}, + "$or": []bson.M{ + {"audio.artist": regex}, + {"audio.artists": regex}, + {"audio.performer": regex}, + }, + } + + total, err := r.tracksCol.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, err + } + + skip := int64((page - 1) * perPage) + opts := options.Find(). + SetSort(bson.D{{Key: "play_count", Value: -1}, {Key: "updated_at", Value: -1}}). + SetSkip(skip). + SetLimit(int64(perPage)) + + cursor, err := r.tracksCol.Find(ctx, filter, opts) + if err != nil { + return nil, 0, err + } + defer cursor.Close(ctx) + + var tracks []*models.Track + if err := cursor.All(ctx, &tracks); err != nil { + return nil, 0, err + } + return tracks, total, nil +} + +func (r *mongoArtistAlbumRepository) GetArtistAlbums(ctx context.Context, artistName string) ([]*models.Album, error) { + escaped := regexp.QuoteMeta(artistName) + regex := bson.M{"$regex": "^" + escaped + "$", "$options": "i"} + + filter := bson.M{ + "$or": []bson.M{ + {"artist": regex}, + {"artists": regex}, + {"match_artist": strings.ToLower(artistName)}, + }, + } + + opts := options.Find(). + SetSort(bson.D{{Key: "updated_at", Value: -1}}). + SetLimit(50) + + cursor, err := r.albumsCol.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var albums []*models.Album + if err := cursor.All(ctx, &albums); err != nil { + return nil, err + } + + for _, a := range albums { + if a.Id == "" { + a.Id = a.ID + } + if a.TrackCount == 0 { + a.TrackCount = a.TracksCount + } + } + + var uniqueAlbums []*models.Album + seenTitles := make(map[string]bool) + seenIDs := make(map[string]bool) + for _, a := range albums { + if a.TracksCount <= 0 { + continue + } + titleNorm := strings.ToLower(strings.TrimSpace(a.Title)) + if titleNorm == "" || seenTitles[titleNorm] || seenIDs[a.Id] { + continue + } + seenTitles[titleNorm] = true + seenIDs[a.Id] = true + uniqueAlbums = append(uniqueAlbums, a) + } + + return uniqueAlbums, nil +} + +func (r *mongoArtistAlbumRepository) RefreshAlbumsCache(ctx context.Context, limit int) (int, error) { + r.albumRefreshMu.Lock() + defer r.albumRefreshMu.Unlock() + + if limit <= 0 { + limit = 2000 + } + if limit > 5000 { + limit = 5000 + } + + pipeline := mongo.Pipeline{ + {{Key: "$match", Value: bson.M{ + "deleted": bson.M{"$ne": true}, + "audio.album_id": bson.M{"$exists": true, "$ne": ""}, + "$or": []bson.M{ + {"audio.album": bson.M{"$exists": true, "$ne": ""}}, + {"audio.title": bson.M{"$exists": true, "$ne": ""}}, + }, + }}}, + {{Key: "$addFields", Value: bson.M{ + "_aid": "$audio.album_id", + "_album_title": bson.M{"$ifNull": []any{"$audio.album", "$audio.title"}}, + "_album_artist": bson.M{ + "$cond": []any{ + bson.M{"$and": []any{ + bson.M{"$isArray": "$audio.artists"}, + bson.M{"$gt": []any{bson.M{"$size": "$audio.artists"}, 0}}, + }}, + bson.M{"$arrayElemAt": []any{"$audio.artists", 0}}, + bson.M{"$ifNull": []any{"$audio.artist", "$audio.performer"}}, + }, + }, + "_year": "$audio.year", + }}}, + {{Key: "$addFields", Value: bson.M{ + "_album_norm": bson.M{"$toLower": bson.M{"$trim": bson.M{"input": "$_album_title"}}}, + "_artist_norm": bson.M{"$toLower": bson.M{"$trim": bson.M{"input": "$_album_artist"}}}, + }}}, + {{Key: "$sort", Value: bson.D{{Key: "updated_at", Value: -1}}}}, + {{Key: "$group", Value: bson.M{ + "_id": "$_aid", + "title": bson.M{"$first": "$_album_title"}, + "artist": bson.M{"$first": "$_album_artist"}, + "cover_url": bson.M{"$first": bson.M{"$ifNull": []any{"$spotify.cloudflare_big_cover_url", "$spotify.cloudflare_cover_url", "$spotify.big_cover_url", "$spotify.cover_url"}}}, + "year": bson.M{"$first": "$_year"}, + "tracks_count": bson.M{"$sum": 1}, + "duration_total": bson.M{"$sum": bson.M{"$ifNull": []any{"$audio.duration_sec", 0}}}, + "updated_at": bson.M{"$max": "$updated_at"}, + "match_album": bson.M{"$first": "$_album_norm"}, + "match_artist": bson.M{"$first": "$_artist_norm"}, + }}}, + {{Key: "$sort", Value: bson.D{{Key: "updated_at", Value: -1}}}}, + {{Key: "$limit", Value: limit}}, + } + + cursor, err := r.tracksCol.Aggregate(ctx, pipeline) + if err != nil { + return 0, err + } + defer cursor.Close(ctx) + + now := float64(time.Now().Unix()) + upserted := 0 + var writeModels []mongo.WriteModel + + for cursor.Next(ctx) { + var row struct { + ID string `bson:"_id"` + Title string `bson:"title"` + Artist string `bson:"artist"` + CoverURL string `bson:"cover_url"` + Year int `bson:"year"` + TracksCount int64 `bson:"tracks_count"` + DurationTotal float64 `bson:"duration_total"` + UpdatedAt float64 `bson:"updated_at"` + MatchAlbum string `bson:"match_album"` + MatchArtist string `bson:"match_artist"` + } + if err := cursor.Decode(&row); err != nil { + continue + } + + aid := strings.TrimSpace(row.ID) + title := strings.TrimSpace(row.Title) + matchAlbum := strings.TrimSpace(row.MatchAlbum) + if aid == "" || title == "" || matchAlbum == "" { + continue + } + + artist := strings.TrimSpace(row.Artist) + var artists []string + if artist != "" { + artists = splitArtists(artist) + } + + updateDoc := bson.M{ + "title": title, + "artist": artist, + "cover_url": row.CoverURL, + "tracks_count": row.TracksCount, + "duration_total": row.DurationTotal, + "match_album": matchAlbum, + "match_artist": strings.TrimSpace(row.MatchArtist), + "updated_at": row.UpdatedAt, + } + if len(artists) > 0 { + updateDoc["artists"] = artists + } + if row.Year > 0 { + updateDoc["year"] = row.Year + } + if row.UpdatedAt == 0 { + updateDoc["updated_at"] = now + } + + model := mongo.NewUpdateOneModel(). + SetFilter(bson.M{"_id": aid}). + SetUpdate(bson.M{ + "$setOnInsert": bson.M{"created_at": now}, + "$set": updateDoc, + }). + SetUpsert(true) + + writeModels = append(writeModels, model) + if len(writeModels) >= 500 { + _, _ = r.albumsCol.BulkWrite(ctx, writeModels, options.BulkWrite().SetOrdered(false)) + upserted += len(writeModels) + writeModels = writeModels[:0] + } + } + + if len(writeModels) > 0 { + _, _ = r.albumsCol.BulkWrite(ctx, writeModels, options.BulkWrite().SetOrdered(false)) + upserted += len(writeModels) + } + + // Purge orphan albums that no longer have any active tracks in audioTracks + distinctRes := r.tracksCol.Distinct(ctx, "audio.album_id", bson.M{ + "deleted": bson.M{"$ne": true}, + "audio.album_id": bson.M{"$exists": true, "$ne": ""}, + }) + if distinctRes.Err() == nil { + var distinctAIDs []string + if err := distinctRes.Decode(&distinctAIDs); err == nil && len(distinctAIDs) > 0 { + _, _ = r.albumsCol.DeleteMany(ctx, bson.M{ + "_id": bson.M{"$nin": distinctAIDs}, + }) + } + } + + return upserted, nil +} + +func (r *mongoArtistAlbumRepository) ListAlbums(ctx context.Context, page, perPage int, artistFilter string, refresh bool) ([]*models.Album, int64, error) { + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 50 + } + if perPage > 200 { + perPage = 200 + } + + filter := bson.M{} + if artistFilter != "" { + escaped := regexp.QuoteMeta(artistFilter) + filter["$or"] = []bson.M{ + {"artist": bson.M{"$regex": escaped, "$options": "i"}}, + {"artists": bson.M{"$regex": escaped, "$options": "i"}}, + {"match_artist": strings.ToLower(artistFilter)}, + } + } + + var total int64 + var err error + if len(filter) == 0 { + total, err = r.albumsCol.EstimatedDocumentCount(ctx) + } + if total <= 0 || len(filter) > 0 { + total, err = r.albumsCol.CountDocuments(ctx, filter) + } + + // Trigger asynchronous background cache refresh if completely empty or explicitly asked + if (refresh || total <= 0) && artistFilter == "" { + go func() { + bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + _, _ = r.RefreshAlbumsCache(bgCtx, 5000) + }() + } + + skip := int64((page - 1) * perPage) + opts := options.Find(). + SetSort(bson.D{{Key: "updated_at", Value: -1}}). + SetSkip(skip). + SetLimit(int64(perPage)) + + cursor, err := r.albumsCol.Find(ctx, filter, opts) + if err != nil { + return nil, 0, err + } + defer cursor.Close(ctx) + + var albums []*models.Album + if err := cursor.All(ctx, &albums); err != nil { + return nil, 0, err + } + + var uniqueAlbums []*models.Album + seenKeys := make(map[string]bool) + seenIDs := make(map[string]bool) + + for _, a := range albums { + if a.Id == "" { + a.Id = a.ID + } + if a.TrackCount == 0 { + a.TrackCount = a.TracksCount + } + if a.TracksCount <= 0 { + continue + } + key := strings.ToLower(strings.TrimSpace(a.Title)) + "::" + strings.ToLower(strings.TrimSpace(a.Artist)) + if seenKeys[key] || seenIDs[a.Id] { + continue + } + seenKeys[key] = true + seenIDs[a.Id] = true + uniqueAlbums = append(uniqueAlbums, a) + } + + return uniqueAlbums, total, nil +} + +func (r *mongoArtistAlbumRepository) GetAlbumByID(ctx context.Context, id string) (*models.Album, error) { + id = strings.TrimSpace(id) + var album models.Album + err := r.albumsCol.FindOne(ctx, bson.M{"_id": id}).Decode(&album) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + // Find track from this album to synthesize album doc instantly without full aggregation + var t models.Track + tErr := r.tracksCol.FindOne(ctx, bson.M{"audio.album_id": id, "deleted": bson.M{"$ne": true}}).Decode(&t) + if tErr == nil && t.Audio.Album != "" { + album = models.Album{ + ID: id, + Title: t.Audio.Album, + Artist: t.Audio.Artist, + Artists: t.Audio.Artists, + CoverURL: t.EffectiveCoverURL(), + MatchAlbum: strings.ToLower(t.Audio.Album), + } + return &album, nil + } + return nil, nil + } + return nil, err + } + + if album.Id == "" { + album.Id = album.ID + } + if album.TrackCount == 0 { + album.TrackCount = album.TracksCount + } + + return &album, nil +} + +func (r *mongoArtistAlbumRepository) GetAlbumTracks(ctx context.Context, albumID string) ([]*models.Track, error) { + filter := bson.M{ + "deleted": bson.M{"$ne": true}, + "audio.album_id": albumID, + } + + opts := options.Find(). + SetSort(bson.D{ + {Key: "audio.track_number", Value: 1}, + {Key: "source_message_id", Value: 1}, + {Key: "updated_at", Value: -1}, + }). + SetLimit(200) + + cursor, err := r.tracksCol.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var tracks []*models.Track + if err := cursor.All(ctx, &tracks); err != nil { + return nil, err + } + return tracks, nil +} + +func (r *mongoArtistAlbumRepository) GetAlbumTracksPaginated(ctx context.Context, albumID string, page, perPage int) ([]*models.Track, int64, error) { + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 50 + } + if perPage > 200 { + perPage = 200 + } + + filter := bson.M{ + "deleted": bson.M{"$ne": true}, + "audio.album_id": albumID, + } + + total, err := r.tracksCol.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, err + } + + skip := int64((page - 1) * perPage) + opts := options.Find(). + SetSort(bson.D{ + {Key: "audio.track_number", Value: 1}, + {Key: "source_message_id", Value: 1}, + {Key: "updated_at", Value: -1}, + }). + SetSkip(skip). + SetLimit(int64(perPage)) + + cursor, err := r.tracksCol.Find(ctx, filter, opts) + if err != nil { + return nil, 0, err + } + defer cursor.Close(ctx) + + var tracks []*models.Track + if err := cursor.All(ctx, &tracks); err != nil { + return nil, 0, err + } + return tracks, total, nil +} diff --git a/internal/repository/daily_playlist_repo.go b/internal/repository/daily_playlist_repo.go new file mode 100644 index 0000000..fb1fba4 --- /dev/null +++ b/internal/repository/daily_playlist_repo.go @@ -0,0 +1,53 @@ +package repository + +import ( + "context" + "errors" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "streamgo/internal/database" + "streamgo/internal/models" +) + +// DailyPlaylistRepository manages generated daily playlist documents. +type DailyPlaylistRepository interface { + GetCachedDailyPlaylist(ctx context.Context, docID string) (*models.DailyPlaylistDoc, error) + SaveDailyPlaylist(ctx context.Context, doc *models.DailyPlaylistDoc) error +} + +type mongoDailyPlaylistRepository struct { + col *mongo.Collection +} + +// NewDailyPlaylistRepository creates a new DailyPlaylistRepository. +func NewDailyPlaylistRepository(db *database.Client) DailyPlaylistRepository { + return &mongoDailyPlaylistRepository{ + col: db.Collection("daily_playlists"), + } +} + +// GetCachedDailyPlaylist retrieves a cached daily playlist by ID. +func (r *mongoDailyPlaylistRepository) GetCachedDailyPlaylist(ctx context.Context, docID string) (*models.DailyPlaylistDoc, error) { + filter := bson.M{"_id": docID} + var doc models.DailyPlaylistDoc + err := r.col.FindOne(ctx, filter).Decode(&doc) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, err + } + return &doc, nil +} + +// SaveDailyPlaylist upserts a generated daily playlist document. +func (r *mongoDailyPlaylistRepository) SaveDailyPlaylist(ctx context.Context, doc *models.DailyPlaylistDoc) error { + filter := bson.M{"_id": doc.ID} + update := bson.M{"$set": doc} + opts := options.UpdateOne().SetUpsert(true) + _, err := r.col.UpdateOne(ctx, filter, update, opts) + return err +} diff --git a/internal/repository/favourite_playlist_repo.go b/internal/repository/favourite_playlist_repo.go new file mode 100644 index 0000000..37aafa9 --- /dev/null +++ b/internal/repository/favourite_playlist_repo.go @@ -0,0 +1,671 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "streamgo/internal/database" + "streamgo/internal/models" +) + +// FavouritePlaylistRepository defines data operations for favourites, playlists, albums, and history. +type FavouritePlaylistRepository interface { + // Track Favourites + AddFavourite(ctx context.Context, userID int64, trackID string) (alreadyExists bool, err error) + RemoveFavourite(ctx context.Context, userID int64, trackID string) (deleted bool, err error) + GetFavouriteTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, *float64, error) + GetAllFavouriteTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, *float64, error) + + // Artist Favourites + AddFavouriteArtist(ctx context.Context, userID int64, artistID string) (alreadyExists bool, err error) + RemoveFavouriteArtist(ctx context.Context, userID int64, artistID string) (deleted bool, err error) + GetFavouriteArtistIDs(ctx context.Context, userID int64) ([]string, error) + + // Playlists + CreatePlaylist(ctx context.Context, p *models.UserPlaylist) error + GetUserPlaylists(ctx context.Context, userID int64) ([]*models.UserPlaylist, error) + GetPlaylistByID(ctx context.Context, playlistID string, userID int64) (*models.UserPlaylist, error) + GetPublicPlaylistByID(ctx context.Context, playlistID string) (*models.UserPlaylist, error) + RenamePlaylist(ctx context.Context, playlistID string, userID int64, name, coverID, coverURL, normalThumbnail string) (*models.UserPlaylist, error) + DeletePlaylist(ctx context.Context, playlistID string, userID int64) error + AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) (int, error) + RemoveTrackFromPlaylist(ctx context.Context, playlistID string, trackID string) error + GetPlaylistTrackIDs(ctx context.Context, playlistID string, page, limit int) ([]string, int64, error) + GetPlaylistFirstTrackIDs(ctx context.Context, playlistID string, limit int) ([]string, error) + + // Albums + SaveAlbum(ctx context.Context, userID int64, albumID string) (alreadyExists bool, err error) + GetSavedAlbumIDs(ctx context.Context, userID int64, page, limit int) (albumIDs []string, savedAts []float64, total int64, err error) + + // History & Listening Events + RecordListeningEvents(ctx context.Context, userID *int64, events []models.ListeningEventItem) (int, error) + GetUserHistoryTrackIDs(ctx context.Context, userID int64, limit int) ([]string, error) + GetUserTopPlayedTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, error) +} + +type mongoFavouritePlaylistRepository struct { + favCol *mongo.Collection + favArtistCol *mongo.Collection + artistsCol *mongo.Collection + playlistsCol *mongo.Collection + playlistTrkCol *mongo.Collection + userAlbumsCol *mongo.Collection + historyCol *mongo.Collection + eventsCol *mongo.Collection +} + +// NewFavouritePlaylistRepository returns an instance of FavouritePlaylistRepository. +func NewFavouritePlaylistRepository(db *database.Client) FavouritePlaylistRepository { + return &mongoFavouritePlaylistRepository{ + favCol: db.Collection("user_favourites"), + favArtistCol: db.Collection("user_favourite_artists"), + artistsCol: db.Collection("artists"), + playlistsCol: db.Collection("user_playlists"), + playlistTrkCol: db.Collection("playlist_tracks"), + userAlbumsCol: db.Collection("user_albums"), + historyCol: db.Collection("userHistory"), + eventsCol: db.Collection("listening_events"), + } +} + +// AddFavourite adds a track to user_favourites. +func (r *mongoFavouritePlaylistRepository) AddFavourite(ctx context.Context, userID int64, trackID string) (bool, error) { + now := float64(time.Now().Unix()) + filter := bson.M{"user_id": userID, "track_id": trackID} + update := bson.M{ + "$setOnInsert": bson.M{"created_at": now}, + "$set": bson.M{"user_id": userID, "track_id": trackID, "updated_at": now}, + } + opts := options.UpdateOne().SetUpsert(true) + res, err := r.favCol.UpdateOne(ctx, filter, update, opts) + if err != nil { + return false, fmt.Errorf("failed to add favourite: %w", err) + } + alreadyExists := res.UpsertedID == nil + return alreadyExists, nil +} + +// RemoveFavourite deletes a track from user_favourites. +func (r *mongoFavouritePlaylistRepository) RemoveFavourite(ctx context.Context, userID int64, trackID string) (bool, error) { + filter := bson.M{"user_id": userID, "track_id": trackID} + res, err := r.favCol.DeleteOne(ctx, filter) + if err != nil { + return false, fmt.Errorf("failed to remove favourite: %w", err) + } + return res.DeletedCount > 0, nil +} + +// GetFavouriteTrackIDs returns paginated track IDs liked by the user. +func (r *mongoFavouritePlaylistRepository) GetFavouriteTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, *float64, error) { + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 20 + } + skip := int64((page - 1) * limit) + + filter := bson.M{"user_id": userID} + total, err := r.favCol.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, nil, fmt.Errorf("failed to count favourites: %w", err) + } + + opts := options.Find(). + SetSort(bson.D{{Key: "created_at", Value: -1}}). + SetSkip(skip). + SetLimit(int64(limit)). + SetProjection(bson.M{"track_id": 1, "created_at": 1, "updated_at": 1, "_id": 0}) + + cur, err := r.favCol.Find(ctx, filter, opts) + if err != nil { + return nil, 0, nil, fmt.Errorf("failed to query favourites: %w", err) + } + defer cur.Close(ctx) + + var ids []string + var lastTS *float64 + + for cur.Next(ctx) { + var doc struct { + TrackID string `bson:"track_id"` + CreatedAt *float64 `bson:"created_at"` + UpdatedAt *float64 `bson:"updated_at"` + } + if err := cur.Decode(&doc); err == nil && doc.TrackID != "" { + ids = append(ids, doc.TrackID) + ts := doc.UpdatedAt + if ts == nil { + ts = doc.CreatedAt + } + if ts != nil && (lastTS == nil || *ts > *lastTS) { + lastTS = ts + } + } + } + + return ids, total, lastTS, nil +} + +// GetAllFavouriteTrackIDs returns IDs for the /favourites/ids endpoint. +func (r *mongoFavouritePlaylistRepository) GetAllFavouriteTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, *float64, error) { + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 200 + } + skip := int64((page - 1) * limit) + + filter := bson.M{"user_id": userID} + total, err := r.favCol.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, nil, err + } + + opts := options.Find(). + SetSort(bson.D{{Key: "created_at", Value: -1}}). + SetSkip(skip). + SetLimit(int64(limit)). + SetProjection(bson.M{"track_id": 1, "created_at": 1, "updated_at": 1, "_id": 0}) + + cur, err := r.favCol.Find(ctx, filter, opts) + if err != nil { + return nil, 0, nil, err + } + defer cur.Close(ctx) + + var ids []string + var lastTS *float64 + + for cur.Next(ctx) { + var doc struct { + TrackID string `bson:"track_id"` + CreatedAt *float64 `bson:"created_at"` + UpdatedAt *float64 `bson:"updated_at"` + } + if err := cur.Decode(&doc); err == nil && doc.TrackID != "" { + ids = append(ids, doc.TrackID) + ts := doc.UpdatedAt + if ts == nil { + ts = doc.CreatedAt + } + if ts != nil && (lastTS == nil || *ts > *lastTS) { + lastTS = ts + } + } + } + return ids, total, lastTS, nil +} + +// AddFavouriteArtist adds an artist to user_favourite_artists and increments followers on artists collection. +func (r *mongoFavouritePlaylistRepository) AddFavouriteArtist(ctx context.Context, userID int64, artistID string) (bool, error) { + now := float64(time.Now().Unix()) + filter := bson.M{"user_id": userID, "artist_id": artistID} + update := bson.M{ + "$setOnInsert": bson.M{"created_at": now}, + "$set": bson.M{"user_id": userID, "artist_id": artistID, "updated_at": now}, + } + opts := options.UpdateOne().SetUpsert(true) + res, err := r.favArtistCol.UpdateOne(ctx, filter, update, opts) + if err != nil { + return false, fmt.Errorf("failed to add artist favourite: %w", err) + } + + alreadyExists := res.UpsertedID == nil + if !alreadyExists { + _, _ = r.artistsCol.UpdateOne(ctx, bson.M{"_id": artistID}, bson.M{"$inc": bson.M{"followers": 1}}) + } + return alreadyExists, nil +} + +// RemoveFavouriteArtist removes an artist from user_favourite_artists and decrements followers. +func (r *mongoFavouritePlaylistRepository) RemoveFavouriteArtist(ctx context.Context, userID int64, artistID string) (bool, error) { + filter := bson.M{"user_id": userID, "artist_id": artistID} + res, err := r.favArtistCol.DeleteOne(ctx, filter) + if err != nil { + return false, fmt.Errorf("failed to remove artist favourite: %w", err) + } + if res.DeletedCount > 0 { + _, _ = r.artistsCol.UpdateOne(ctx, bson.M{"_id": artistID}, bson.M{"$inc": bson.M{"followers": -1}}) + } + return res.DeletedCount > 0, nil +} + +// GetFavouriteArtistIDs returns list of followed artist IDs. +func (r *mongoFavouritePlaylistRepository) GetFavouriteArtistIDs(ctx context.Context, userID int64) ([]string, error) { + filter := bson.M{"user_id": userID} + cur, err := r.favArtistCol.Find(ctx, filter, options.Find().SetProjection(bson.M{"artist_id": 1, "_id": 0})) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + + var ids []string + for cur.Next(ctx) { + var doc struct { + ArtistID string `bson:"artist_id"` + } + if err := cur.Decode(&doc); err == nil && doc.ArtistID != "" { + ids = append(ids, doc.ArtistID) + } + } + return ids, nil +} + +// CreatePlaylist inserts a new playlist into user_playlists. +func (r *mongoFavouritePlaylistRepository) CreatePlaylist(ctx context.Context, p *models.UserPlaylist) error { + _, err := r.playlistsCol.InsertOne(ctx, p) + return err +} + +// GetUserPlaylists returns all playlists owned by a user sorted by creation date descending. +func (r *mongoFavouritePlaylistRepository) GetUserPlaylists(ctx context.Context, userID int64) ([]*models.UserPlaylist, error) { + filter := bson.M{"user_id": userID} + opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}) + cur, err := r.playlistsCol.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + + var list []*models.UserPlaylist + for cur.Next(ctx) { + var p models.UserPlaylist + if err := cur.Decode(&p); err == nil { + list = append(list, &p) + } + } + return list, nil +} + +// GetPlaylistByID returns playlist owned by a specific user. +func (r *mongoFavouritePlaylistRepository) GetPlaylistByID(ctx context.Context, playlistID string, userID int64) (*models.UserPlaylist, error) { + filter := bson.M{"_id": playlistID, "user_id": userID} + var p models.UserPlaylist + err := r.playlistsCol.FindOne(ctx, filter).Decode(&p) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, err + } + return &p, nil +} + +// GetPublicPlaylistByID returns playlist by ID for sharing. +func (r *mongoFavouritePlaylistRepository) GetPublicPlaylistByID(ctx context.Context, playlistID string) (*models.UserPlaylist, error) { + filter := bson.M{"_id": playlistID} + var p models.UserPlaylist + err := r.playlistsCol.FindOne(ctx, filter).Decode(&p) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, err + } + return &p, nil +} + +// RenamePlaylist renames a playlist. +func (r *mongoFavouritePlaylistRepository) RenamePlaylist(ctx context.Context, playlistID string, userID int64, name, coverID, coverURL, normalThumbnail string) (*models.UserPlaylist, error) { + now := float64(time.Now().Unix()) + filter := bson.M{"_id": playlistID, "user_id": userID} + update := bson.M{ + "$set": bson.M{ + "name": name, + "cover_id": coverID, + "cover_url": coverURL, + "normal_thumbnail": normalThumbnail, + "updated_at": now, + }, + } + opts := options.FindOneAndUpdate().SetReturnDocument(options.After) + var p models.UserPlaylist + err := r.playlistsCol.FindOneAndUpdate(ctx, filter, update, opts).Decode(&p) + if err != nil { + return nil, err + } + return &p, nil +} + +// DeletePlaylist removes a playlist and its associated tracks. +func (r *mongoFavouritePlaylistRepository) DeletePlaylist(ctx context.Context, playlistID string, userID int64) error { + filter := bson.M{"_id": playlistID, "user_id": userID} + res, err := r.playlistsCol.DeleteOne(ctx, filter) + if err != nil { + return err + } + if res.DeletedCount == 0 { + return errors.New("playlist not found") + } + _, _ = r.playlistTrkCol.DeleteMany(ctx, bson.M{"playlist_id": playlistID}) + return nil +} + +// AddTracksToPlaylist adds tracks with incremental position numbers. +func (r *mongoFavouritePlaylistRepository) AddTracksToPlaylist(ctx context.Context, playlistID string, trackIDs []string) (int, error) { + var last models.PlaylistTrack + lastOpt := options.FindOne().SetSort(bson.D{{Key: "position", Value: -1}}) + nextPos := 1 + if err := r.playlistTrkCol.FindOne(ctx, bson.M{"playlist_id": playlistID}, lastOpt).Decode(&last); err == nil { + nextPos = last.Position + 1 + } + + now := float64(time.Now().Unix()) + addedCount := 0 + + for _, tid := range trackIDs { + filter := bson.M{"playlist_id": playlistID, "track_id": tid} + update := bson.M{ + "$setOnInsert": bson.M{"position": nextPos, "added_at": now}, + "$set": bson.M{"playlist_id": playlistID, "track_id": tid}, + } + opts := options.UpdateOne().SetUpsert(true) + res, err := r.playlistTrkCol.UpdateOne(ctx, filter, update, opts) + if err != nil { + return addedCount, err + } + if res.UpsertedID != nil { + addedCount++ + nextPos++ + } + } + + return addedCount, nil +} + +// RemoveTrackFromPlaylist deletes a track from playlist_tracks. +func (r *mongoFavouritePlaylistRepository) RemoveTrackFromPlaylist(ctx context.Context, playlistID string, trackID string) error { + filter := bson.M{"playlist_id": playlistID, "track_id": trackID} + res, err := r.playlistTrkCol.DeleteOne(ctx, filter) + if err != nil { + return err + } + if res.DeletedCount == 0 { + return errors.New("track not found in playlist") + } + return nil +} + +// GetPlaylistTrackIDs returns paginated track IDs in a playlist ordered by position. +func (r *mongoFavouritePlaylistRepository) GetPlaylistTrackIDs(ctx context.Context, playlistID string, page, limit int) ([]string, int64, error) { + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 50 + } + skip := int64((page - 1) * limit) + + filter := bson.M{"playlist_id": playlistID} + total, err := r.playlistTrkCol.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, err + } + + opts := options.Find(). + SetSort(bson.D{{Key: "position", Value: 1}}). + SetSkip(skip). + SetLimit(int64(limit)). + SetProjection(bson.M{"track_id": 1, "_id": 0}) + + cur, err := r.playlistTrkCol.Find(ctx, filter, opts) + if err != nil { + return nil, 0, err + } + defer cur.Close(ctx) + + var ids []string + for cur.Next(ctx) { + var doc struct { + TrackID string `bson:"track_id"` + } + if err := cur.Decode(&doc); err == nil && doc.TrackID != "" { + ids = append(ids, doc.TrackID) + } + } + return ids, total, nil +} + +// GetPlaylistFirstTrackIDs returns the first N track IDs for playlist cover collage generation. +func (r *mongoFavouritePlaylistRepository) GetPlaylistFirstTrackIDs(ctx context.Context, playlistID string, limit int) ([]string, error) { + if limit < 1 { + limit = 4 + } + filter := bson.M{"playlist_id": playlistID} + opts := options.Find(). + SetSort(bson.D{{Key: "position", Value: 1}}). + SetLimit(int64(limit)). + SetProjection(bson.M{"track_id": 1, "_id": 0}) + + cur, err := r.playlistTrkCol.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + + var ids []string + for cur.Next(ctx) { + var doc struct { + TrackID string `bson:"track_id"` + } + if err := cur.Decode(&doc); err == nil && doc.TrackID != "" { + ids = append(ids, doc.TrackID) + } + } + return ids, nil +} + +// SaveAlbum adds an album to user_albums. +func (r *mongoFavouritePlaylistRepository) SaveAlbum(ctx context.Context, userID int64, albumID string) (bool, error) { + now := float64(time.Now().Unix()) + filter := bson.M{"user_id": userID, "album_id": albumID} + update := bson.M{ + "$setOnInsert": bson.M{"user_id": userID, "album_id": albumID, "saved_at": now}, + "$set": bson.M{"updated_at": now}, + } + opts := options.UpdateOne().SetUpsert(true) + res, err := r.userAlbumsCol.UpdateOne(ctx, filter, update, opts) + if err != nil { + return false, fmt.Errorf("failed to save album: %w", err) + } + alreadyExists := res.UpsertedID == nil + return alreadyExists, nil +} + +// GetSavedAlbumIDs returns saved albums for user. +func (r *mongoFavouritePlaylistRepository) GetSavedAlbumIDs(ctx context.Context, userID int64, page, limit int) ([]string, []float64, int64, error) { + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 50 + } + skip := int64((page - 1) * limit) + + filter := bson.M{"user_id": userID} + total, err := r.userAlbumsCol.CountDocuments(ctx, filter) + if err != nil { + return nil, nil, 0, err + } + + opts := options.Find(). + SetSort(bson.D{{Key: "saved_at", Value: -1}}). + SetSkip(skip). + SetLimit(int64(limit)) + + cur, err := r.userAlbumsCol.Find(ctx, filter, opts) + if err != nil { + return nil, nil, 0, err + } + defer cur.Close(ctx) + + var albumIDs []string + var savedAts []float64 + + for cur.Next(ctx) { + var doc models.UserAlbum + if err := cur.Decode(&doc); err == nil && doc.AlbumID != "" { + albumIDs = append(albumIDs, doc.AlbumID) + savedAts = append(savedAts, doc.SavedAt) + } + } + return albumIDs, savedAts, total, nil +} + +// RecordListeningEvents bulk upserts telemetry events into listening_events. +func (r *mongoFavouritePlaylistRepository) RecordListeningEvents(ctx context.Context, userID *int64, events []models.ListeningEventItem) (int, error) { + if len(events) == 0 { + return 0, nil + } + + modelsList := make([]mongo.WriteModel, 0, len(events)) + now := float64(time.Now().Unix()) + + for _, ev := range events { + if ev.ID == "" { + continue + } + filter := bson.M{"event_id": ev.ID} + if userID != nil { + filter["user_id"] = *userID + } + setFields := bson.M{ + "event_id": ev.ID, + "track_id": ev.TrackID, + "played_at": ev.PlayedAt, + "started_at": ev.StartedAt, + "played_ms": ev.PlayedMs, + "duration_ms": ev.DurationMs, + "completed": ev.Completed, + "skipped": ev.Skipped, + "source": ev.Source, + } + if userID != nil { + setFields["user_id"] = *userID + } + if ev.SessionID != "" { + setFields["session_id"] = ev.SessionID + } + + update := bson.M{ + "$setOnInsert": bson.M{"created_at": now}, + "$set": setFields, + } + model := mongo.NewUpdateOneModel().SetFilter(filter).SetUpdate(update).SetUpsert(true) + modelsList = append(modelsList, model) + } + + if len(modelsList) == 0 { + return 0, nil + } + + opts := options.BulkWrite().SetOrdered(false) + res, err := r.eventsCol.BulkWrite(ctx, modelsList, opts) + if err != nil { + return 0, fmt.Errorf("bulk write listening events failed: %w", err) + } + return int(res.UpsertedCount + res.ModifiedCount), nil +} + +// GetUserHistoryTrackIDs returns recent track IDs from userHistory. +func (r *mongoFavouritePlaylistRepository) GetUserHistoryTrackIDs(ctx context.Context, userID int64, limit int) ([]string, error) { + if limit < 1 { + limit = 100 + } + filter := bson.M{ + "$or": []bson.M{ + {"user_id": userID}, + {"user_id": fmt.Sprintf("%d", userID)}, + }, + } + opts := options.Find(). + SetSort(bson.D{{Key: "played_at", Value: -1}}). + SetLimit(int64(limit * 2)) + + cur, err := r.historyCol.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + + seen := make(map[string]bool) + var ordered []string + + for cur.Next(ctx) { + var doc struct { + TrackID string `bson:"track_id"` + } + if err := cur.Decode(&doc); err == nil && doc.TrackID != "" { + if !seen[doc.TrackID] { + seen[doc.TrackID] = true + ordered = append(ordered, doc.TrackID) + if len(ordered) >= limit { + break + } + } + } + } + return ordered, nil +} + +// GetUserTopPlayedTrackIDs returns most frequently played track IDs from listening_events. +func (r *mongoFavouritePlaylistRepository) GetUserTopPlayedTrackIDs(ctx context.Context, userID int64, page, limit int) ([]string, int64, error) { + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 50 + } + skip := int64((page - 1) * limit) + + pipeline := mongo.Pipeline{ + bson.D{{Key: "$match", Value: bson.M{"user_id": userID}}}, + bson.D{{Key: "$group", Value: bson.M{"_id": "$track_id", "play_count": bson.M{"$sum": 1}}}}, + bson.D{{Key: "$sort", Value: bson.M{"play_count": -1}}}, + } + + countPipeline := append(pipeline, bson.D{{Key: "$count", Value: "total"}}) + countCur, err := r.eventsCol.Aggregate(ctx, countPipeline) + var total int64 + if err == nil { + if countCur.Next(ctx) { + var cnt struct { + Total int64 `bson:"total"` + } + if err := countCur.Decode(&cnt); err == nil { + total = cnt.Total + } + } + countCur.Close(ctx) + } + + pipeline = append(pipeline, + bson.D{{Key: "$skip", Value: skip}}, + bson.D{{Key: "$limit", Value: int64(limit)}}, + ) + + cur, err := r.eventsCol.Aggregate(ctx, pipeline) + if err != nil { + return nil, 0, err + } + defer cur.Close(ctx) + + var ids []string + for cur.Next(ctx) { + var doc struct { + ID string `bson:"_id"` + } + if err := cur.Decode(&doc); err == nil && doc.ID != "" { + ids = append(ids, doc.ID) + } + } + return ids, total, nil +} diff --git a/internal/repository/history_repo.go b/internal/repository/history_repo.go new file mode 100644 index 0000000..66be267 --- /dev/null +++ b/internal/repository/history_repo.go @@ -0,0 +1,253 @@ +package repository + +import ( + "context" + "fmt" + "strings" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "streamgo/internal/database" + "streamgo/internal/models" +) + +// HistoryRepository manages user playback history and listening telemetry. +type HistoryRepository interface { + RecordPlay(ctx context.Context, userID int64, trackID string, source string) error + GetUserHistory(ctx context.Context, userID int64, limit int) ([]string, error) + GetUserTopPlayed(ctx context.Context, userID int64, limit int) ([]string, error) + RecordListeningEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) error +} + +type mongoHistoryRepository struct { + histCol *mongo.Collection + userPbCol *mongo.Collection + globPbCol *mongo.Collection + eventsCol *mongo.Collection + tracksCol *mongo.Collection +} + +// NewHistoryRepository creates a MongoDB HistoryRepository. +func NewHistoryRepository(db *database.Client) HistoryRepository { + return &mongoHistoryRepository{ + histCol: db.Collection("userHistory"), + userPbCol: db.Collection("userPlayback"), + globPbCol: db.Collection("globalPlayback"), + eventsCol: db.Collection("listening_events"), + tracksCol: db.Collection("audioTracks"), + } +} + +// RecordPlay records a playback entry in userHistory, userPlayback, and globalPlayback. +func (r *mongoHistoryRepository) RecordPlay(ctx context.Context, userID int64, trackID string, source string) error { + trackID = strings.TrimSpace(trackID) + if trackID == "" { + return nil + } + + now := float64(time.Now().Unix()) + + // 1. Log to userHistory + if userID > 0 { + _, _ = r.histCol.InsertOne(ctx, bson.M{ + "user_id": userID, + "track_id": trackID, + "played_at": now, + }) + + // 2. Update userPlayback bucket (bucket = 30-min window) + bucket := int64(now) / 1800 + userPlayID := fmt.Sprintf("%d:%s:%d", userID, trackID, bucket) + _, _ = r.userPbCol.UpdateOne( + ctx, + bson.M{"_id": userPlayID}, + bson.M{ + "$setOnInsert": bson.M{ + "_id": userPlayID, + "user_id": userID, + "track_id": trackID, + "played_at": now, + "bucket": bucket, + "source": source, + }, + "$inc": bson.M{"plays": 1}, + }, + options.UpdateOne().SetUpsert(true), + ) + } + + // 3. Update globalPlayback + inc := bson.M{"plays": 1} + if source != "" { + inc["sources."+source] = 1 + } + _, _ = r.globPbCol.UpdateOne( + ctx, + bson.M{"_id": trackID}, + bson.M{ + "$inc": inc, + "$set": bson.M{ + "last_played_at": now, + "updated_at": now, + }, + }, + options.UpdateOne().SetUpsert(true), + ) + + // 4. Increment track play_count in tracks collection + _, _ = r.tracksCol.UpdateOne( + ctx, + bson.M{"_id": trackID}, + bson.M{"$inc": bson.M{"audio.play_count": 1}}, + ) + + return nil +} + +// GetUserHistory retrieves unique track IDs for a user sorted by played_at desc. +func (r *mongoHistoryRepository) GetUserHistory(ctx context.Context, userID int64, limit int) ([]string, error) { + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + + filter := bson.M{"$or": []bson.M{ + {"user_id": userID}, + {"user_id": int32(userID)}, + {"user_id": float64(userID)}, + {"user_id": fmt.Sprintf("%d", userID)}, + }} + + scanLimit := int64(limit * 5) + if scanLimit < 250 { + scanLimit = 250 + } + + opts := options.Find(). + SetSort(bson.D{{Key: "played_at", Value: -1}}). + SetLimit(scanLimit) + + cursor, err := r.histCol.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var orderedIDs []string + seen := make(map[string]bool) + + for cursor.Next(ctx) { + var raw bson.M + if err := cursor.Decode(&raw); err == nil { + var tid string + if v, ok := raw["track_id"]; ok && v != nil { + tid = strings.TrimSpace(fmt.Sprint(v)) + } + if tid != "" && !seen[tid] { + seen[tid] = true + orderedIDs = append(orderedIDs, tid) + if len(orderedIDs) >= limit { + break + } + } + } + } + + return orderedIDs, nil +} + +// GetUserTopPlayed retrieves track IDs most frequently played by the user. +func (r *mongoHistoryRepository) GetUserTopPlayed(ctx context.Context, userID int64, limit int) ([]string, error) { + if limit <= 0 { + limit = 50 + } + if limit > 100 { + limit = 100 + } + + matchFilter := bson.M{"$or": []bson.M{ + {"user_id": userID}, + {"user_id": int32(userID)}, + {"user_id": float64(userID)}, + {"user_id": fmt.Sprintf("%d", userID)}, + }} + + pipeline := mongo.Pipeline{ + {{Key: "$match", Value: matchFilter}}, + {{Key: "$group", Value: bson.M{ + "_id": "$track_id", + "plays": bson.M{"$sum": "$plays"}, + }}}, + {{Key: "$sort", Value: bson.D{{Key: "plays", Value: -1}}}}, + {{Key: "$limit", Value: limit}}, + } + + cursor, err := r.userPbCol.Aggregate(ctx, pipeline) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var trackIDs []string + for cursor.Next(ctx) { + var raw bson.M + if err := cursor.Decode(&raw); err == nil { + var tid string + if v, ok := raw["_id"]; ok && v != nil { + tid = strings.TrimSpace(fmt.Sprint(v)) + } + if tid != "" { + trackIDs = append(trackIDs, tid) + } + } + } + + return trackIDs, nil +} + +// RecordListeningEvents saves telemetry events and triggers play count updates. +func (r *mongoHistoryRepository) RecordListeningEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) error { + now := float64(time.Now().Unix()) + for _, ev := range events { + eid := strings.TrimSpace(ev.ID) + if eid == "" { + eid = fmt.Sprintf("ev_%d_%s_%d", userID, ev.TrackID, time.Now().UnixNano()) + } + + filter := bson.M{"event_id": eid} + if userID > 0 { + filter["user_id"] = userID + } + + update := bson.M{ + "$setOnInsert": bson.M{"created_at": now}, + "$set": bson.M{ + "event_id": eid, + "user_id": userID, + "track_id": ev.TrackID, + "played_at": ev.PlayedAt, + "started_at": ev.StartedAt, + "played_ms": ev.PlayedMs, + "duration_ms": ev.DurationMs, + "completed": ev.Completed, + "skipped": ev.Skipped, + "source": ev.Source, + "session_id": ev.SessionID, + }, + } + + _, _ = r.eventsCol.UpdateOne(ctx, filter, update, options.UpdateOne().SetUpsert(true)) + + // Record play in userHistory, userPlayback, and global metrics + if ev.TrackID != "" { + _ = r.RecordPlay(ctx, userID, ev.TrackID, ev.Source) + } + } + + return nil +} diff --git a/internal/repository/recap_repo.go b/internal/repository/recap_repo.go new file mode 100644 index 0000000..c18b8d9 --- /dev/null +++ b/internal/repository/recap_repo.go @@ -0,0 +1,589 @@ +package repository + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "strings" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "streamgo/internal/database" + "streamgo/internal/models" +) + +const RecapSchemaVersion = 1 + +// RecapRepository defines MongoDB persistence operations for recaps and telemetry. +type RecapRepository interface { + RecordEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) (int, error) + LoadEvents(ctx context.Context, userID int64, start float64, end float64) ([]*models.ListeningEventDoc, error) + FirstEventTimestamp(ctx context.Context, userID int64) (float64, bool, error) + HasPlays(ctx context.Context, userID int64, start float64, end float64) (bool, error) + FirstSeenBefore(ctx context.Context, userID int64, trackIDs []string, before float64) (map[string]bool, error) + GetSnapshot(ctx context.Context, userID int64, ptype models.RecapPeriodType, period string, tz int) (*models.RecapSnapshot, float64, error) + SaveSnapshot(ctx context.Context, userID int64, ptype models.RecapPeriodType, period string, tz int, snap *models.RecapSnapshot) error + CreateShare(ctx context.Context, userID int64, ptype models.RecapPeriodType, period string, summary *models.RecapPublicSummary) (*models.RecapShareItem, error) + ListShares(ctx context.Context, userID int64) ([]*models.RecapShareItem, error) + RevokeShare(ctx context.Context, userID int64, token string) (bool, error) + GetPublicShare(ctx context.Context, token string) (*models.RecapPublicSummary, error) + DeleteUserData(ctx context.Context, userID int64) (eventsDeleted int64, snapsDeleted int64, sharesDeleted int64, err error) +} + +type mongoRecapRepository struct { + eventsCol *mongo.Collection + historyCol *mongo.Collection + snapshotsCol *mongo.Collection + sharesCol *mongo.Collection + tracksCol *mongo.Collection + userPbCol *mongo.Collection + globPbCol *mongo.Collection +} + +// NewRecapRepository creates a new RecapRepository instance. +func NewRecapRepository(db *database.Client) RecapRepository { + return &mongoRecapRepository{ + eventsCol: db.Collection("listening_events"), + historyCol: db.Collection("userHistory"), + snapshotsCol: db.Collection("recap_snapshots"), + sharesCol: db.Collection("recap_shares"), + tracksCol: db.Collection("audioTracks"), + userPbCol: db.Collection("userPlayback"), + globPbCol: db.Collection("globalPlayback"), + } +} + +// RecordEvents saves a batch of client listening events idempotently with key user_id:event_id. +func (r *mongoRecapRepository) RecordEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) (int, error) { + now := float64(time.Now().Unix()) + stored := 0 + + for _, ev := range events { + eid := strings.TrimSpace(ev.ID) + tid := strings.TrimSpace(ev.TrackID) + if eid == "" || tid == "" { + continue + } + + docID := fmt.Sprintf("%d:%s", userID, eid) + playedAt := ev.PlayedAt + if playedAt <= 0 { + playedAt = now + } + startedAt := ev.StartedAt + if startedAt <= 0 { + startedAt = playedAt + } + + playedMs := ev.PlayedMs + if playedMs < 0 { + playedMs = 0 + } + durationMs := ev.DurationMs + if durationMs < 0 { + durationMs = 0 + } + + source := strings.TrimSpace(ev.Source) + if source == "" { + source = "server" + } + if len(source) > 32 { + source = source[:32] + } + + var sessionID *string + if s := strings.TrimSpace(ev.SessionID); s != "" { + if len(s) > 64 { + s = s[:64] + } + sessionID = &s + } + + doc := bson.M{ + "_id": docID, + "user_id": userID, + "track_id": tid, + "played_at": playedAt, + "started_at": startedAt, + "played_ms": playedMs, + "duration_ms": durationMs, + "completed": ev.Completed, + "skipped": ev.Skipped, + "source": source, + "session_id": sessionID, + "created_at": now, + } + + res, err := r.eventsCol.UpdateOne( + ctx, + bson.M{"_id": docID}, + bson.M{"$setOnInsert": doc}, + options.UpdateOne().SetUpsert(true), + ) + if err == nil && res.UpsertedCount > 0 { + stored++ + } + + // Also record in legacy playback metrics + r.recordPlaybackMetrics(ctx, userID, tid, source, playedAt) + } + + return stored, nil +} + +func (r *mongoRecapRepository) recordPlaybackMetrics(ctx context.Context, userID int64, trackID string, source string, playedAt float64) { + if trackID == "" { + return + } + now := playedAt + if now <= 0 { + now = float64(time.Now().Unix()) + } + + // userHistory log + _, _ = r.historyCol.InsertOne(ctx, bson.M{ + "user_id": userID, + "track_id": trackID, + "source": source, + "played_at": now, + }) + + // userPlayback counter + if userID > 0 { + userDocID := fmt.Sprintf("%d_%s", userID, trackID) + inc := bson.M{"count": 1} + if source != "" { + inc["sources."+source] = 1 + } + _, _ = r.userPbCol.UpdateOne( + ctx, + bson.M{"_id": userDocID}, + bson.M{ + "$setOnInsert": bson.M{ + "user_id": userID, + "track_id": trackID, + }, + "$inc": inc, + "$set": bson.M{ + "last_played_at": now, + "updated_at": now, + }, + }, + options.UpdateOne().SetUpsert(true), + ) + } + + // globalPlayback counter + incGlob := bson.M{"count": 1} + if source != "" { + incGlob["sources."+source] = 1 + } + _, _ = r.globPbCol.UpdateOne( + ctx, + bson.M{"_id": trackID}, + bson.M{ + "$inc": incGlob, + "$set": bson.M{ + "last_played_at": now, + "updated_at": now, + }, + }, + options.UpdateOne().SetUpsert(true), + ) + + // Increment track play_count in tracks collection + _, _ = r.tracksCol.UpdateOne( + ctx, + bson.M{"_id": trackID}, + bson.M{"$inc": bson.M{"audio.play_count": 1, "play_count": 1}}, + ) +} + +// LoadEvents retrieves listening events for a user within [start, end), falling back to userHistory if needed. +func (r *mongoRecapRepository) LoadEvents(ctx context.Context, userID int64, start float64, end float64) ([]*models.ListeningEventDoc, error) { + filter := bson.M{ + "user_id": userID, + "played_at": bson.M{ + "$gte": start, + "$lt": end, + }, + } + + cursor, err := r.eventsCol.Find(ctx, filter) + if err == nil { + defer cursor.Close(ctx) + var events []*models.ListeningEventDoc + for cursor.Next(ctx) { + var ev models.ListeningEventDoc + if err := cursor.Decode(&ev); err == nil && ev.TrackID != "" { + events = append(events, &ev) + } + } + if len(events) > 0 { + return events, nil + } + } + + // Fallback to legacy userHistory rows (where one row = one stream start) + histFilter := bson.M{ + "$or": []bson.M{ + {"user_id": userID}, + {"user_id": fmt.Sprintf("%d", userID)}, + }, + "played_at": bson.M{ + "$gte": start, + "$lt": end, + }, + } + + hCursor, err := r.historyCol.Find(ctx, histFilter) + if err != nil { + return nil, err + } + defer hCursor.Close(ctx) + + var legacyEvents []*models.ListeningEventDoc + for hCursor.Next(ctx) { + var row struct { + TrackID string `bson:"track_id"` + PlayedAt float64 `bson:"played_at"` + Source string `bson:"source"` + } + if err := hCursor.Decode(&row); err == nil && row.TrackID != "" { + legacyEvents = append(legacyEvents, &models.ListeningEventDoc{ + TrackID: row.TrackID, + PlayedAt: row.PlayedAt, + StartedAt: row.PlayedAt, + PlayedMs: nil, // indicates legacy play: assume full playback + DurationMs: 0, + Completed: false, + Skipped: false, + Source: row.Source, + Legacy: true, + }) + } + } + + return legacyEvents, nil +} + +// FirstEventTimestamp returns the earliest play timestamp across listening_events and userHistory. +func (r *mongoRecapRepository) FirstEventTimestamp(ctx context.Context, userID int64) (float64, bool, error) { + var earliest float64 + found := false + + // Check listening_events + evOpt := options.FindOne().SetSort(bson.D{{Key: "played_at", Value: 1}}) + var evDoc struct { + PlayedAt float64 `bson:"played_at"` + } + if err := r.eventsCol.FindOne(ctx, bson.M{"user_id": userID}, evOpt).Decode(&evDoc); err == nil && evDoc.PlayedAt > 0 { + earliest = evDoc.PlayedAt + found = true + } + + // Check userHistory + histFilter := bson.M{ + "$or": []bson.M{ + {"user_id": userID}, + {"user_id": fmt.Sprintf("%d", userID)}, + }, + } + var hDoc struct { + PlayedAt float64 `bson:"played_at"` + } + if err := r.historyCol.FindOne(ctx, histFilter, evOpt).Decode(&hDoc); err == nil && hDoc.PlayedAt > 0 { + if !found || hDoc.PlayedAt < earliest { + earliest = hDoc.PlayedAt + found = true + } + } + + return earliest, found, nil +} + +// HasPlays returns whether any listening events exist for the user in [start, end). +func (r *mongoRecapRepository) HasPlays(ctx context.Context, userID int64, start float64, end float64) (bool, error) { + filter := bson.M{ + "user_id": userID, + "played_at": bson.M{ + "$gte": start, + "$lt": end, + }, + } + count, err := r.eventsCol.CountDocuments(ctx, filter, options.Count().SetLimit(1)) + if err == nil && count > 0 { + return true, nil + } + + histFilter := bson.M{ + "$or": []bson.M{ + {"user_id": userID}, + {"user_id": fmt.Sprintf("%d", userID)}, + }, + "played_at": bson.M{ + "$gte": start, + "$lt": end, + }, + } + hCount, err := r.historyCol.CountDocuments(ctx, histFilter, options.Count().SetLimit(1)) + if err == nil && hCount > 0 { + return true, nil + } + + return false, nil +} + +// FirstSeenBefore returns the subset of trackIDs that were listened to before the specified timestamp. +func (r *mongoRecapRepository) FirstSeenBefore(ctx context.Context, userID int64, trackIDs []string, before float64) (map[string]bool, error) { + seen := make(map[string]bool) + if len(trackIDs) == 0 { + return seen, nil + } + + evFilter := bson.M{ + "user_id": userID, + "track_id": bson.M{"$in": trackIDs}, + "played_at": bson.M{"$lt": before}, + } + evRes := r.eventsCol.Distinct(ctx, "track_id", evFilter) + if evRes.Err() == nil { + var rawValues []interface{} + if err := evRes.Decode(&rawValues); err == nil { + for _, raw := range rawValues { + if s, ok := raw.(string); ok && s != "" { + seen[s] = true + } + } + } + } + + histFilter := bson.M{ + "$or": []bson.M{ + {"user_id": userID}, + {"user_id": fmt.Sprintf("%d", userID)}, + }, + "track_id": bson.M{"$in": trackIDs}, + "played_at": bson.M{"$lt": before}, + } + hRes := r.historyCol.Distinct(ctx, "track_id", histFilter) + if hRes.Err() == nil { + var rawValues []interface{} + if err := hRes.Decode(&rawValues); err == nil { + for _, raw := range rawValues { + if s, ok := raw.(string); ok && s != "" { + seen[s] = true + } + } + } + } + + return seen, nil +} + +// GetSnapshot retrieves a cached recap snapshot if available. +func (r *mongoRecapRepository) GetSnapshot(ctx context.Context, userID int64, ptype models.RecapPeriodType, period string, tz int) (*models.RecapSnapshot, float64, error) { + filter := bson.M{ + "user_id": userID, + "type": ptype, + "period": period, + "tz": tz, + } + + var doc struct { + SchemaVersion int `bson:"schemaVersion"` + GeneratedAt float64 `bson:"generatedAt"` + Snapshot *models.RecapSnapshot `bson:"snapshot"` + } + + err := r.snapshotsCol.FindOne(ctx, filter).Decode(&doc) + if err != nil { + return nil, 0, err + } + if doc.SchemaVersion != RecapSchemaVersion || doc.Snapshot == nil { + return nil, 0, mongo.ErrNoDocuments + } + + return doc.Snapshot, doc.GeneratedAt, nil +} + +// SaveSnapshot caches a generated recap snapshot. +func (r *mongoRecapRepository) SaveSnapshot(ctx context.Context, userID int64, ptype models.RecapPeriodType, period string, tz int, snap *models.RecapSnapshot) error { + filter := bson.M{ + "user_id": userID, + "type": ptype, + "period": period, + "tz": tz, + } + + now := float64(time.Now().Unix()) + update := bson.M{ + "$set": bson.M{ + "user_id": userID, + "type": ptype, + "period": period, + "tz": tz, + "schemaVersion": RecapSchemaVersion, + "generatedAt": now, + "snapshot": snap, + }, + } + + _, err := r.snapshotsCol.UpdateOne(ctx, filter, update, options.UpdateOne().SetUpsert(true)) + return err +} + +func generateShareToken() string { + b := make([]byte, 9) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +// CreateShare creates or updates a public share token for a recap snapshot. +func (r *mongoRecapRepository) CreateShare(ctx context.Context, userID int64, ptype models.RecapPeriodType, period string, summary *models.RecapPublicSummary) (*models.RecapShareItem, error) { + existingFilter := bson.M{ + "user_id": userID, + "type": ptype, + "period": period, + "revoked_at": nil, + } + + var existing models.RecapShareDoc + if err := r.sharesCol.FindOne(ctx, existingFilter).Decode(&existing); err == nil && existing.Token != "" { + _, _ = r.sharesCol.UpdateOne(ctx, bson.M{"_id": existing.Token}, bson.M{"$set": bson.M{"summary": summary}}) + label := "" + if summary != nil { + label = summary.Label + } + return &models.RecapShareItem{ + Token: existing.Token, + Type: existing.Type, + Period: existing.Period, + Label: label, + CreatedAt: existing.CreatedAt, + }, nil + } + + token := generateShareToken() + now := float64(time.Now().Unix()) + + _, err := r.sharesCol.InsertOne(ctx, bson.M{ + "_id": token, + "token": token, + "user_id": userID, + "type": ptype, + "period": period, + "created_at": now, + "revoked_at": nil, + "summary": summary, + }) + if err != nil { + return nil, err + } + + label := "" + if summary != nil { + label = summary.Label + } + return &models.RecapShareItem{ + Token: token, + Type: ptype, + Period: period, + Label: label, + CreatedAt: now, + }, nil +} + +// ListShares lists active share links for a user. +func (r *mongoRecapRepository) ListShares(ctx context.Context, userID int64) ([]*models.RecapShareItem, error) { + filter := bson.M{ + "user_id": userID, + "revoked_at": nil, + } + opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}) + + cursor, err := r.sharesCol.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var items []*models.RecapShareItem + for cursor.Next(ctx) { + var doc models.RecapShareDoc + if err := cursor.Decode(&doc); err == nil { + label := "" + if doc.Summary != nil { + label = doc.Summary.Label + } + items = append(items, &models.RecapShareItem{ + Token: doc.Token, + Type: doc.Type, + Period: doc.Period, + Label: label, + CreatedAt: doc.CreatedAt, + }) + } + } + + return items, nil +} + +// RevokeShare marks a share token as revoked. +func (r *mongoRecapRepository) RevokeShare(ctx context.Context, userID int64, token string) (bool, error) { + filter := bson.M{ + "user_id": userID, + "token": token, + "revoked_at": nil, + } + update := bson.M{ + "$set": bson.M{ + "revoked_at": float64(time.Now().Unix()), + }, + } + + res, err := r.sharesCol.UpdateOne(ctx, filter, update) + if err != nil { + return false, err + } + return res.ModifiedCount > 0, nil +} + +// GetPublicShare retrieves the public recap summary for a valid, unrevoked share token. +func (r *mongoRecapRepository) GetPublicShare(ctx context.Context, token string) (*models.RecapPublicSummary, error) { + filter := bson.M{ + "token": token, + "revoked_at": nil, + } + + var doc models.RecapShareDoc + if err := r.sharesCol.FindOne(ctx, filter).Decode(&doc); err != nil { + return nil, err + } + return doc.Summary, nil +} + +// DeleteUserData deletes all listening events, cached snapshots, and shares for a user. +func (r *mongoRecapRepository) DeleteUserData(ctx context.Context, userID int64) (int64, int64, int64, error) { + filter := bson.M{"user_id": userID} + + resE, err := r.eventsCol.DeleteMany(ctx, filter) + if err != nil { + return 0, 0, 0, err + } + resS, err := r.snapshotsCol.DeleteMany(ctx, filter) + if err != nil { + return resE.DeletedCount, 0, 0, err + } + resSh, err := r.sharesCol.DeleteMany(ctx, filter) + if err != nil { + return resE.DeletedCount, resS.DeletedCount, 0, err + } + + return resE.DeletedCount, resS.DeletedCount, resSh.DeletedCount, nil +} diff --git a/internal/repository/track_repo.go b/internal/repository/track_repo.go new file mode 100644 index 0000000..5056c04 --- /dev/null +++ b/internal/repository/track_repo.go @@ -0,0 +1,617 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "net/url" + "regexp" + "strings" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "streamgo/internal/database" + "streamgo/internal/models" +) + +// TrackRepository defines the data access contract for tracks and related metadata. +type TrackRepository interface { + GetByID(ctx context.Context, id string) (*models.Track, error) + GetByIDs(ctx context.Context, ids []string) ([]*models.Track, error) + List(ctx context.Context, page, perPage int, sortField, topicName string, channelID int64) ([]*models.Track, int64, error) + Search(ctx context.Context, query string, limit int) ([]*models.Track, error) + Random(ctx context.Context, limit int, channelID int64) ([]*models.Track, error) + GetTopics(ctx context.Context, channelID int64, limit int) ([]*models.TopicItem, error) + GetChannelIDs(ctx context.Context) ([]int64, error) + IncrementPlayCount(ctx context.Context, id string) error + UpdateWorkerFileID(ctx context.Context, trackID, workerID, fileID string) error + UpdateLyricsCache(ctx context.Context, id string, text, kind, source string, telegraphURL string) error + SoftDelete(ctx context.Context, id string) error + HardDelete(ctx context.Context, id string) error + CountArtworkReferences(ctx context.Context, excludeTrackID, coverURL string) (int64, error) +} + + +type mongoTrackRepository struct { + db *database.Client + col *mongo.Collection +} + +// NewTrackRepository creates a MongoDB-backed TrackRepository. +func NewTrackRepository(db *database.Client) TrackRepository { + return &mongoTrackRepository{ + db: db, + col: db.Collection("audioTracks"), + } +} + +func (r *mongoTrackRepository) GetByID(ctx context.Context, id string) (*models.Track, error) { + id = strings.TrimSpace(id) + if id == "" { + return nil, errors.New("track id is required") + } + + filter := bson.M{ + "_id": id, + "deleted": bson.M{"$ne": true}, + } + + var track models.Track + err := r.col.FindOne(ctx, filter).Decode(&track) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, fmt.Errorf("failed to fetch track %s: %w", id, err) + } + + return &track, nil +} + +func (r *mongoTrackRepository) GetByIDs(ctx context.Context, ids []string) ([]*models.Track, error) { + if len(ids) == 0 { + return []*models.Track{}, nil + } + + filter := bson.M{ + "_id": bson.M{"$in": ids}, + "deleted": bson.M{"$ne": true}, + } + + cursor, err := r.col.Find(ctx, filter) + if err != nil { + return nil, fmt.Errorf("failed to query tracks by IDs: %w", err) + } + defer cursor.Close(ctx) + + var tracks []*models.Track + if err := cursor.All(ctx, &tracks); err != nil { + return nil, fmt.Errorf("failed to decode tracks: %w", err) + } + + // Preserve the requested order of IDs + trackMap := make(map[string]*models.Track, len(tracks)) + for _, t := range tracks { + trackMap[t.ID] = t + } + + ordered := make([]*models.Track, 0, len(ids)) + for _, id := range ids { + if t, ok := trackMap[id]; ok { + ordered = append(ordered, t) + } + } + + return ordered, nil +} + + +func (r *mongoTrackRepository) List( + ctx context.Context, + page, perPage int, + sortField, topicName string, + channelID int64, +) ([]*models.Track, int64, error) { + if page < 1 { + page = 1 + } + if perPage < 1 || perPage > 100 { + perPage = 20 + } + + filter := bson.M{"deleted": bson.M{"$ne": true}} + + if topicName != "" { + filter["topic_name"] = topicName + } + if channelID != 0 { + filter["source_chat_id"] = channelID + } + + total, err := r.col.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, fmt.Errorf("failed to count tracks: %w", err) + } + + // Sort specification matching Python source (Api/services/track_service.py line 167) + var sortDoc bson.D + sField := strings.ToLower(strings.TrimSpace(sortField)) + switch sField { + case "play_count", "plays", "popular": + sortDoc = bson.D{ + {Key: "play_count", Value: -1}, + {Key: "created_at", Value: -1}, + {Key: "_id", Value: -1}, + } + case "oldest": + sortDoc = bson.D{ + {Key: "created_at", Value: 1}, + {Key: "source_message_id", Value: 1}, + {Key: "_id", Value: 1}, + } + case "title", "title_asc", "name": + sortDoc = bson.D{ + {Key: "audio.title", Value: 1}, + {Key: "created_at", Value: -1}, + {Key: "_id", Value: -1}, + } + case "title_desc": + sortDoc = bson.D{ + {Key: "audio.title", Value: -1}, + {Key: "created_at", Value: -1}, + {Key: "_id", Value: -1}, + } + case "artist", "artist_asc": + sortDoc = bson.D{ + {Key: "audio.artist", Value: 1}, + {Key: "audio.title", Value: 1}, + {Key: "_id", Value: -1}, + } + case "updated", "updated_at": + sortDoc = bson.D{ + {Key: "updated_at", Value: -1}, + {Key: "_id", Value: -1}, + } + case "recent", "recently_added", "newest", "latest", "created_at": + sortDoc = bson.D{ + {Key: "created_at", Value: -1}, + {Key: "source_message_id", Value: -1}, + {Key: "_id", Value: -1}, + } + default: + // Default matches Python: [("source_message_id", -1)] if channel_id is not None else [("created_at", -1), ("source_message_id", -1), ("_id", -1)] + if channelID != 0 { + sortDoc = bson.D{ + {Key: "source_message_id", Value: -1}, + {Key: "created_at", Value: -1}, + {Key: "_id", Value: -1}, + } + } else { + sortDoc = bson.D{ + {Key: "created_at", Value: -1}, + {Key: "source_message_id", Value: -1}, + {Key: "_id", Value: -1}, + } + } + } + + skip := int64((page - 1) * perPage) + opts := options.Find(). + SetSort(sortDoc). + SetSkip(skip). + SetLimit(int64(perPage)) + + cursor, err := r.col.Find(ctx, filter, opts) + if err != nil { + return nil, 0, fmt.Errorf("failed to list tracks: %w", err) + } + defer cursor.Close(ctx) + + var tracks []*models.Track + if err := cursor.All(ctx, &tracks); err != nil { + return nil, 0, fmt.Errorf("failed to decode tracks: %w", err) + } + + return tracks, total, nil +} + +func (r *mongoTrackRepository) Search(ctx context.Context, query string, limit int) ([]*models.Track, error) { + query = strings.TrimSpace(query) + if query == "" { + return []*models.Track{}, nil + } + if limit <= 0 || limit > 100 { + limit = 50 + } + + escaped := regexp.QuoteMeta(query) + regexPattern := bson.M{"$regex": escaped, "$options": "i"} + + filter := bson.M{ + "deleted": bson.M{"$ne": true}, + "$or": []bson.M{ + {"audio.title": regexPattern}, + {"audio.artist": regexPattern}, + {"audio.performer": regexPattern}, + {"audio.album": regexPattern}, + {"titles.romanized": regexPattern}, + {"titles.original": regexPattern}, + {"titles.translations.en": regexPattern}, + {"audio.titles.romanized": regexPattern}, + {"audio.titles.original": regexPattern}, + }, + } + + + opts := options.Find(). + SetSort(bson.D{{Key: "play_count", Value: -1}, {Key: "updated_at", Value: -1}}). + SetLimit(int64(limit)) + + cursor, err := r.col.Find(ctx, filter, opts) + if err != nil { + return nil, fmt.Errorf("search failed: %w", err) + } + defer cursor.Close(ctx) + + var tracks []*models.Track + if err := cursor.All(ctx, &tracks); err != nil { + return nil, fmt.Errorf("failed to decode search results: %w", err) + } + + return tracks, nil +} + +func (r *mongoTrackRepository) Random(ctx context.Context, limit int, channelID int64) ([]*models.Track, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + + match := bson.M{"deleted": bson.M{"$ne": true}} + if channelID != 0 { + match["source_chat_id"] = channelID + } + + pipeline := mongo.Pipeline{ + bson.D{{Key: "$match", Value: match}}, + bson.D{{Key: "$sample", Value: bson.M{"size": limit}}}, + } + + cursor, err := r.col.Aggregate(ctx, pipeline) + if err != nil { + return nil, fmt.Errorf("random aggregation failed: %w", err) + } + defer cursor.Close(ctx) + + var tracks []*models.Track + if err := cursor.All(ctx, &tracks); err != nil { + return nil, fmt.Errorf("failed to decode random tracks: %w", err) + } + + return tracks, nil +} + +func (r *mongoTrackRepository) GetTopics(ctx context.Context, channelID int64, limit int) ([]*models.TopicItem, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + + matchFilter := bson.M{ + "deleted": bson.M{"$ne": true}, + "topic_name": bson.M{"$exists": true, "$nin": []interface{}{"", "null", "None", nil}}, + } + if channelID != 0 { + matchFilter["source_chat_id"] = channelID + } + + pipeline := mongo.Pipeline{ + bson.D{{Key: "$match", Value: matchFilter}}, + bson.D{{Key: "$sort", Value: bson.D{ + {Key: "updated_at", Value: -1}, + {Key: "source_message_id", Value: -1}, + }}}, + bson.D{{Key: "$group", Value: bson.M{ + "_id": "$topic_name", + "topic_id": bson.M{"$first": "$topic_id"}, + "source_chat_id": bson.M{"$first": "$source_chat_id"}, + "cover_url": bson.M{"$first": bson.M{"$ifNull": []any{"$spotify.cloudflare_cover_url", "$spotify.cover_url"}}}, + "big_cover_url": bson.M{"$first": bson.M{"$ifNull": []any{"$spotify.cloudflare_big_cover_url", "$spotify.big_cover_url", "$spotify.cover_url"}}}, + "raw_thumbnails": bson.M{"$push": bson.M{"$ifNull": []any{"$spotify.cloudflare_cover_url", "$spotify.cover_url"}}}, + "tracks_count": bson.M{"$sum": 1}, + }}}, + bson.D{{Key: "$project", Value: bson.M{ + "topic_id": 1, + "source_chat_id": 1, + "cover_url": 1, + "big_cover_url": 1, + "thumbnails": bson.M{"$slice": []interface{}{"$raw_thumbnails", 8}}, + "tracks_count": 1, + }}}, + bson.D{{Key: "$sort", Value: bson.M{"tracks_count": -1, "_id": 1}}}, + bson.D{{Key: "$limit", Value: limit}}, + } + + cursor, err := r.col.Aggregate(ctx, pipeline) + if err != nil { + return nil, fmt.Errorf("topics aggregation failed: %w", err) + } + defer cursor.Close(ctx) + + var results []struct { + ID string `bson:"_id"` + TopicID *int64 `bson:"topic_id"` + SourceChatID *int64 `bson:"source_chat_id"` + TracksCount int64 `bson:"tracks_count"` + CoverURL string `bson:"cover_url"` + BigCoverURL string `bson:"big_cover_url"` + Thumbnails []string `bson:"thumbnails"` + } + + if err := cursor.All(ctx, &results); err != nil { + return nil, fmt.Errorf("failed to decode topics: %w", err) + } + + topics := make([]*models.TopicItem, 0, len(results)) + for _, res := range results { + name := strings.TrimSpace(res.ID) + if name == "" { + continue + } + + cover := strings.TrimSpace(res.BigCoverURL) + if cover == "" { + cover = strings.TrimSpace(res.CoverURL) + } + + uniqueThumbs := make([]string, 0, 4) + seen := make(map[string]struct{}) + for _, th := range res.Thumbnails { + th = strings.TrimSpace(th) + if th != "" { + if _, ok := seen[th]; !ok { + seen[th] = struct{}{} + uniqueThumbs = append(uniqueThumbs, th) + if len(uniqueThumbs) >= 4 { + break + } + } + } + } + + if cover == "" && len(uniqueThumbs) > 0 { + cover = uniqueThumbs[0] + } + + endpoint := fmt.Sprintf("/topics/%s/tracks", url.PathEscape(name)) + + topics = append(topics, &models.TopicItem{ + Name: name, + TopicName: name, + TopicID: res.TopicID, + Count: res.TracksCount, + TracksCount: res.TracksCount, + CoverURL: cover, + ThumbnailURL: cover, + NormalThumbnail: cover, + Thumbnails: uniqueThumbs, + SourceChatID: res.SourceChatID, + Endpoint: endpoint, + }) + } + + return topics, nil +} + +func (r *mongoTrackRepository) GetChannelIDs(ctx context.Context) ([]int64, error) { + filter := bson.M{ + "deleted": bson.M{"$ne": true}, + "source_chat_id": bson.M{"$exists": true, "$ne": 0}, + } + + distinctRes := r.col.Distinct(ctx, "source_chat_id", filter) + if err := distinctRes.Err(); err != nil { + return nil, fmt.Errorf("failed to get channel ids: %w", err) + } + + var rawValues []interface{} + if err := distinctRes.Decode(&rawValues); err != nil { + return nil, fmt.Errorf("failed to decode channel ids: %w", err) + } + + var channelIDs []int64 + for _, v := range rawValues { + switch n := v.(type) { + case int64: + channelIDs = append(channelIDs, n) + case int32: + channelIDs = append(channelIDs, int64(n)) + case int: + channelIDs = append(channelIDs, int64(n)) + case float64: + channelIDs = append(channelIDs, int64(n)) + } + } + + return channelIDs, nil +} + +func (r *mongoTrackRepository) IncrementPlayCount(ctx context.Context, id string) error { + filter := bson.M{"_id": id} + update := bson.M{ + "$inc": bson.M{"play_count": 1}, + } + _, err := r.col.UpdateOne(ctx, filter, update) + return err +} + +func (r *mongoTrackRepository) UpdateWorkerFileID(ctx context.Context, trackID, workerID, fileID string) error { + filter := bson.M{"_id": trackID} + update := bson.M{ + "$set": bson.M{ + "telegram.file_ids." + workerID: fileID, + "updated_at": float64(time.Now().Unix()), + }, + } + _, err := r.col.UpdateOne(ctx, filter, update) + return err +} + +func (r *mongoTrackRepository) UpdateLyricsCache(ctx context.Context, id string, text, kind, source string, telegraphURL string) error { + filter := bson.M{"_id": id} + set := bson.M{ + "lyrics_cache.text": text, + "lyrics_cache.kind": kind, + "lyrics_cache.source": source, + "lyrics_cache.updated_at": float64(time.Now().Unix()), + "updated_at": float64(time.Now().Unix()), + } + if telegraphURL != "" { + set["lyrics"] = telegraphURL + } + _, err := r.col.UpdateOne(ctx, filter, bson.M{"$set": set}) + return err +} + +func (r *mongoTrackRepository) SoftDelete(ctx context.Context, id string) error { + id = strings.TrimSpace(id) + if id == "" { + return errors.New("track id is required") + } + + filter := bson.M{ + "_id": id, + "deleted": bson.M{"$ne": true}, + } + update := bson.M{ + "$set": bson.M{ + "deleted": true, + "deleted_at": float64(time.Now().Unix()), + "updated_at": float64(time.Now().Unix()), + }, + } + res, err := r.col.UpdateOne(ctx, filter, update) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return errors.New("track not found") + } + return nil +} + +func (r *mongoTrackRepository) HardDelete(ctx context.Context, id string) error { + id = strings.TrimSpace(id) + if id == "" { + return errors.New("track id is required") + } + + // 1. Fetch track first if possible to find any alternate IDs (file_unique_id, telegram.file_unique_id) + targetIDsMap := map[string]struct{}{id: {}} + var track models.Track + if err := r.col.FindOne(ctx, bson.M{"_id": id}).Decode(&track); err == nil { + if strings.TrimSpace(track.ID) != "" { + targetIDsMap[strings.TrimSpace(track.ID)] = struct{}{} + } + if strings.TrimSpace(track.Telegram.FileUniqueID) != "" { + targetIDsMap[strings.TrimSpace(track.Telegram.FileUniqueID)] = struct{}{} + } + } + + var targetIDs []string + for tid := range targetIDsMap { + targetIDs = append(targetIDs, tid) + } + + // 2. Cascade delete from audioTracks + res, err := r.col.DeleteMany(ctx, bson.M{ + "$or": []bson.M{ + {"_id": bson.M{"$in": targetIDs}}, + {"file_unique_id": bson.M{"$in": targetIDs}}, + {"telegram.file_unique_id": bson.M{"$in": targetIDs}}, + }, + }) + if err != nil { + return err + } + if res.DeletedCount == 0 { + return errors.New("track not found") + } + + // 3. Cascade delete from user_favourites + _, _ = r.db.Collection("user_favourites").DeleteMany(ctx, bson.M{ + "$or": []bson.M{ + {"track_id": bson.M{"$in": targetIDs}}, + {"file_unique_id": bson.M{"$in": targetIDs}}, + }, + }) + + // 4. Cascade delete from playlist_tracks + _, _ = r.db.Collection("playlist_tracks").DeleteMany(ctx, bson.M{ + "$or": []bson.M{ + {"track_id": bson.M{"$in": targetIDs}}, + {"file_unique_id": bson.M{"$in": targetIDs}}, + }, + }) + + // 5. Cascade delete from userHistory + _, _ = r.db.Collection("userHistory").DeleteMany(ctx, bson.M{ + "$or": []bson.M{ + {"track_id": bson.M{"$in": targetIDs}}, + {"file_unique_id": bson.M{"$in": targetIDs}}, + }, + }) + + // 6. Cascade delete from userPlayback + var pbFilters []bson.M + for _, tid := range targetIDs { + pbFilters = append(pbFilters, + bson.M{"track_id": tid}, + bson.M{"file_unique_id": tid}, + bson.M{"_id": bson.M{"$regex": ":" + regexp.QuoteMeta(tid) + ":"}}, + ) + } + _, _ = r.db.Collection("userPlayback").DeleteMany(ctx, bson.M{"$or": pbFilters}) + + // 7. Cascade delete from globalPlayback + _, _ = r.db.Collection("globalPlayback").DeleteMany(ctx, bson.M{ + "$or": []bson.M{ + {"_id": bson.M{"$in": targetIDs}}, + {"track_id": bson.M{"$in": targetIDs}}, + }, + }) + + // 8. Cascade delete from listening_events + _, _ = r.db.Collection("listening_events").DeleteMany(ctx, bson.M{ + "$or": []bson.M{ + {"track_id": bson.M{"$in": targetIDs}}, + {"file_unique_id": bson.M{"$in": targetIDs}}, + }, + }) + + // 9. Cascade clean daily_playlists by pulling all references + _, _ = r.db.Collection("daily_playlists").UpdateMany(ctx, + bson.M{"track_ids": bson.M{"$in": targetIDs}}, + bson.M{"$pull": bson.M{"track_ids": bson.M{"$in": targetIDs}}}, + ) + + return nil +} + +func (r *mongoTrackRepository) CountArtworkReferences(ctx context.Context, excludeTrackID, coverURL string) (int64, error) { + coverURL = strings.TrimSpace(coverURL) + if coverURL == "" { + return 0, nil + } + + filter := bson.M{ + "_id": bson.M{"$ne": excludeTrackID}, + "deleted": bson.M{"$ne": true}, + "$or": []bson.M{ + {"spotify.cloudflare_cover_url": coverURL}, + {"spotify.cloudflare_big_cover_url": coverURL}, + }, + } + return r.col.CountDocuments(ctx, filter) +} diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go new file mode 100644 index 0000000..7391df7 --- /dev/null +++ b/internal/repository/user_repo.go @@ -0,0 +1,358 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "streamgo/internal/database" + "streamgo/internal/models" +) + +// UserRepository manages database operations for users. +type UserRepository interface { + UpsertUser(ctx context.Context, user *models.User) (*models.User, error) + GetUserByID(ctx context.Context, id int64) (*models.User, error) + GetUserByUsername(ctx context.Context, username string) (*models.User, error) + UpdateUserCredentials(ctx context.Context, userID int64, username string, pwd *models.PasswordHash) error + UpdateIntegrations(ctx context.Context, userID int64, req models.IntegrationsUpdateRequest) (*models.UserIntegrations, error) + + SaveRegistrationOTP(ctx context.Context, otp *models.RegistrationOTP) error + GetRegistrationOTP(ctx context.Context, userID int64) (*models.RegistrationOTP, error) + DeleteRegistrationOTP(ctx context.Context, userID int64) error + + SaveOIDCSession(ctx context.Context, session *models.OIDCSession) error + GetAndDeleteOIDCSession(ctx context.Context, state string) (*models.OIDCSession, error) + + GetOwnerPassword(ctx context.Context) (*models.PasswordHash, error) + SetOwnerPassword(ctx context.Context, pwd *models.PasswordHash, updatedBy int64) error + + UpdateFCMToken(ctx context.Context, userID int64, fcmToken string) error + SaveBotAuthSession(ctx context.Context, session *models.BotAuthSession) error + GetBotAuthSession(ctx context.Context, sessionID string) (*models.BotAuthSession, error) + ConfirmBotAuthSession(ctx context.Context, sessionID, token string, user *models.User) error +} + +type mongoUserRepository struct { + usersCol *mongo.Collection + otpsCol *mongo.Collection + oidcCol *mongo.Collection + authConfigCol *mongo.Collection + botAuthSessionsCol *mongo.Collection +} + +// NewUserRepository creates a UserRepository instance. +func NewUserRepository(db *database.Client) UserRepository { + return &mongoUserRepository{ + usersCol: db.Collection("users"), + otpsCol: db.Collection("registration_otps"), + oidcCol: db.Collection("oidc_sessions"), + authConfigCol: db.Collection("auth_config"), + botAuthSessionsCol: db.Collection("bot_auth_sessions"), + } +} + +// UpsertUser inserts or updates a user document based on user ID. +func (r *mongoUserRepository) UpsertUser(ctx context.Context, u *models.User) (*models.User, error) { + now := float64(time.Now().Unix()) + filter := bson.M{"_id": u.ID} + + setMap := bson.M{ + "updated_at": now, + } + if u.FirstName != "" { + setMap["first_name"] = u.FirstName + } + if u.Username != "" { + setMap["username"] = u.Username + } + if u.PhotoURL != "" { + setMap["photo_url"] = u.PhotoURL + } + if u.ProfileURL != "" { + setMap["profile_url"] = u.ProfileURL + } + if u.Telegram != nil { + setMap["telegram"] = u.Telegram + } + if u.Status != "" { + setMap["status"] = u.Status + } + if u.Password != nil { + setMap["password"] = u.Password + setMap["password_updated_at"] = now + } + if u.RegisteredVia != nil { + setMap["registered_via"] = u.RegisteredVia + } + + update := bson.M{ + "$set": setMap, + "$setOnInsert": bson.M{ + "created_at": now, + "token_version": 0, + }, + } + + opts := options.FindOneAndUpdate(). + SetUpsert(true). + SetReturnDocument(options.After) + + var result models.User + err := r.usersCol.FindOneAndUpdate(ctx, filter, update, opts).Decode(&result) + if err != nil { + return nil, fmt.Errorf("failed to upsert user %d: %w", u.ID, err) + } + + return &result, nil +} + +// GetUserByID fetches a user by Telegram numeric ID. +func (r *mongoUserRepository) GetUserByID(ctx context.Context, id int64) (*models.User, error) { + filter := bson.M{"_id": id} + var u models.User + err := r.usersCol.FindOne(ctx, filter).Decode(&u) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, fmt.Errorf("failed to fetch user %d: %w", id, err) + } + return &u, nil +} + +// GetUserByUsername fetches a user by canonical username. +func (r *mongoUserRepository) GetUserByUsername(ctx context.Context, username string) (*models.User, error) { + username = strings.TrimSpace(username) + if username == "" { + return nil, nil + } + pattern := fmt.Sprintf("^%s$", regexp.QuoteMeta(username)) + filter := bson.M{ + "$or": []bson.M{ + {"username": bson.M{"$regex": pattern, "$options": "i"}}, + {"telegram.username": bson.M{"$regex": pattern, "$options": "i"}}, + }, + } + var u models.User + err := r.usersCol.FindOne(ctx, filter).Decode(&u) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, fmt.Errorf("failed to fetch user by username %s: %w", username, err) + } + return &u, nil +} + +// UpdateUserCredentials updates the username and password for an existing user. +func (r *mongoUserRepository) UpdateUserCredentials(ctx context.Context, userID int64, username string, pwd *models.PasswordHash) error { + now := float64(time.Now().Unix()) + filter := bson.M{"_id": userID} + update := bson.M{ + "$set": bson.M{ + "username": username, + "password": pwd, + "username_updated_at": now, + "password_updated_at": now, + "updated_at": now, + }, + } + _, err := r.usersCol.UpdateOne(ctx, filter, update) + return err +} + +// UpdateIntegrations updates integrations.discord and integrations.lastfm. +func (r *mongoUserRepository) UpdateIntegrations(ctx context.Context, userID int64, req models.IntegrationsUpdateRequest) (*models.UserIntegrations, error) { + now := float64(time.Now().Unix()) + filter := bson.M{"_id": userID} + setFields := bson.M{ + "updated_at": now, + } + + if req.Discord != nil { + req.Discord.UpdatedAt = now + setFields["integrations.discord"] = req.Discord + } + if req.Lastfm != nil { + req.Lastfm.UpdatedAt = now + setFields["integrations.lastfm"] = req.Lastfm + } + + update := bson.M{"$set": setFields} + opts := options.FindOneAndUpdate().SetReturnDocument(options.After).SetUpsert(true) + var updated models.User + err := r.usersCol.FindOneAndUpdate(ctx, filter, update, opts).Decode(&updated) + if err != nil { + return nil, err + } + if updated.Integrations != nil { + return updated.Integrations, nil + } + return &models.UserIntegrations{}, nil +} + +// SaveRegistrationOTP inserts or updates a registration OTP record. +func (r *mongoUserRepository) SaveRegistrationOTP(ctx context.Context, otp *models.RegistrationOTP) error { + filter := bson.M{"_id": otp.ID} + update := bson.M{"$set": otp} + opts := options.UpdateOne().SetUpsert(true) + _, err := r.otpsCol.UpdateOne(ctx, filter, update, opts) + return err +} + +// GetRegistrationOTP retrieves a pending registration OTP for a user ID. +func (r *mongoUserRepository) GetRegistrationOTP(ctx context.Context, userID int64) (*models.RegistrationOTP, error) { + filter := bson.M{"_id": userID} + var otp models.RegistrationOTP + err := r.otpsCol.FindOne(ctx, filter).Decode(&otp) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, err + } + return &otp, nil +} + +// DeleteRegistrationOTP deletes a pending registration OTP. +func (r *mongoUserRepository) DeleteRegistrationOTP(ctx context.Context, userID int64) error { + _, err := r.otpsCol.DeleteOne(ctx, bson.M{"_id": userID}) + return err +} + +// SaveOIDCSession saves an authorization state session. +func (r *mongoUserRepository) SaveOIDCSession(ctx context.Context, session *models.OIDCSession) error { + filter := bson.M{"_id": session.ID} + update := bson.M{"$set": session} + opts := options.UpdateOne().SetUpsert(true) + _, err := r.oidcCol.UpdateOne(ctx, filter, update, opts) + return err +} + +// GetAndDeleteOIDCSession retrieves and deletes an authorization state session in one operation. +func (r *mongoUserRepository) GetAndDeleteOIDCSession(ctx context.Context, state string) (*models.OIDCSession, error) { + filter := bson.M{"_id": state} + var session models.OIDCSession + err := r.oidcCol.FindOneAndDelete(ctx, filter).Decode(&session) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, err + } + return &session, nil +} + +// GetOwnerPassword retrieves the hashed owner/server password from auth_config. +func (r *mongoUserRepository) GetOwnerPassword(ctx context.Context) (*models.PasswordHash, error) { + filter := bson.M{"_id": "owner_password"} + var doc struct { + Password *models.PasswordHash `bson:"password"` + } + err := r.authConfigCol.FindOne(ctx, filter).Decode(&doc) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, err + } + return doc.Password, nil +} + +// SetOwnerPassword saves or updates the hashed owner/server password in auth_config. +func (r *mongoUserRepository) SetOwnerPassword(ctx context.Context, pwd *models.PasswordHash, updatedBy int64) error { + now := float64(time.Now().Unix()) + filter := bson.M{"_id": "owner_password"} + + setMap := bson.M{ + "password": pwd, + "updated_at": now, + } + if updatedBy > 0 { + setMap["updated_by"] = updatedBy + } + + update := bson.M{ + "$set": setMap, + "$setOnInsert": bson.M{ + "created_at": now, + }, + } + opts := options.UpdateOne().SetUpsert(true) + _, err := r.authConfigCol.UpdateOne(ctx, filter, update, opts) + return err +} + +// UpdateFCMToken updates the user's FCM push token in the users collection. +func (r *mongoUserRepository) UpdateFCMToken(ctx context.Context, userID int64, fcmToken string) error { + now := float64(time.Now().Unix()) + filter := bson.M{"_id": userID} + update := bson.M{ + "$set": bson.M{ + "fcm_token": fcmToken, + "updated_at": now, + }, + } + opts := options.UpdateOne().SetUpsert(true) + _, err := r.usersCol.UpdateOne(ctx, filter, update, opts) + return err +} + +// SaveBotAuthSession saves or updates a temporary Telegram bot auth session. +func (r *mongoUserRepository) SaveBotAuthSession(ctx context.Context, session *models.BotAuthSession) error { + filter := bson.M{"_id": session.ID} + update := bson.M{ + "$set": bson.M{ + "_id": session.ID, + "status": session.Status, + "invite_code": session.InviteCode, + "created_at": session.CreatedAt, + "expires_at": session.ExpiresAt, + }, + } + opts := options.UpdateOne().SetUpsert(true) + _, err := r.botAuthSessionsCol.UpdateOne(ctx, filter, update, opts) + return err +} + +// GetBotAuthSession retrieves a temporary bot auth session by ID. +func (r *mongoUserRepository) GetBotAuthSession(ctx context.Context, sessionID string) (*models.BotAuthSession, error) { + filter := bson.M{"_id": sessionID} + var s models.BotAuthSession + err := r.botAuthSessionsCol.FindOne(ctx, filter).Decode(&s) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, err + } + return &s, nil +} + +// ConfirmBotAuthSession marks a temporary bot auth session as confirmed with token and user details. +func (r *mongoUserRepository) ConfirmBotAuthSession(ctx context.Context, sessionID, token string, user *models.User) error { + now := float64(time.Now().Unix()) + filter := bson.M{"_id": sessionID} + update := bson.M{ + "$set": bson.M{ + "status": "confirmed", + "token": token, + "user_id": user.ID, + "first_name": user.FirstName, + "username": user.Username, + "photo_url": user.PhotoURL, + "profile_url": user.ProfileURL, + "confirmed_at": now, + }, + } + _, err := r.botAuthSessionsCol.UpdateOne(ctx, filter, update) + return err +} diff --git a/internal/server/docs.go b/internal/server/docs.go new file mode 100644 index 0000000..e65c537 --- /dev/null +++ b/internal/server/docs.go @@ -0,0 +1,1540 @@ +package server + +import ( + "net/http" +) + +const openAPISpecJSON = `{ + "openapi": "3.0.3", + "info": { + "title": "StreamGO API", + "description": "High-Performance Media Streaming, Discovery, Access Control, and Telegram MiniApp Backend written in Go", + "version": "1.0.0" + }, + "servers": [ + { + "url": "/", + "description": "Current Server" + } + ], + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT / v1.." + }, + "guestPassword": { + "type": "apiKey", + "name": "X-Guest-Password", + "in": "header" + } + } + }, + "paths": { + "/": { + "get": { + "tags": ["System"], + "summary": "Root Service Info", + "description": "Returns basic service metadata, status, and running version.", + "responses": { "200": { "description": "Successful Response" } } + } + }, + "/health": { + "get": { + "tags": ["System"], + "summary": "Health Check", + "description": "Returns server uptime, current time, Telegram client state, and real-time MongoDB connectivity status.", + "responses": { "200": { "description": "Health status response" } } + } + }, + "/tracks": { + "get": { + "tags": ["Tracks"], + "summary": "Browse / List Tracks", + "description": "Paginated track list with sorting, topic filtering, and channel filtering.", + "parameters": [ + { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, + { "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } }, + { "name": "sort", "in": "query", "schema": { "type": "string", "enum": ["updated_at", "play_count", "created_at"] } }, + { "name": "topic", "in": "query", "schema": { "type": "string" } }, + { "name": "channel_id", "in": "query", "schema": { "type": "integer" } } + ], + "responses": { "200": { "description": "Paginated list of browse items" } } + } + }, + "/tracks/{id}": { + "get": { + "tags": ["Tracks"], + "summary": "Get Track by ID", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "Full track entity" }, + "404": { "description": "Track not found" } + } + } + }, + "/tracks/random": { + "get": { + "tags": ["Tracks"], + "summary": "Get Random Tracks", + "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } }, + { "name": "channel_id", "in": "query", "schema": { "type": "integer" } } + ], + "responses": { "200": { "description": "Randomized selection of tracks" } } + } + }, + "/tracks/shuffle": { + "get": { + "tags": ["Tracks"], + "summary": "Shuffle Library Tracks", + "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } }, + { "name": "channel_id", "in": "query", "schema": { "type": "integer" } } + ], + "responses": { "200": { "description": "Shuffled track queue" } } + } + }, + "/library/shuffle": { + "get": { + "tags": ["Tracks"], + "summary": "Alias for Library Shuffle", + "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } } + ], + "responses": { "200": { "description": "Shuffled track queue" } } + } + }, + "/search": { + "get": { + "tags": ["Tracks"], + "summary": "Search Tracks", + "description": "Search tracks by title, artist, performer, or album.", + "parameters": [ + { "name": "q", "in": "query", "required": true, "schema": { "type": "string" } }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } } + ], + "responses": { "200": { "description": "Search results matching query" } } + } + }, + "/topics": { + "get": { + "tags": ["Topics"], + "summary": "List Unique Topics", + "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } } + ], + "responses": { "200": { "description": "List of distinct topic tags" } } + } + }, + "/topics/{name}/tracks": { + "get": { + "tags": ["Topics"], + "summary": "Get Tracks by Topic", + "parameters": [ + { "name": "name", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, + { "name": "per_page", "in": "query", "schema": { "type": "integer", "default": 20 } } + ], + "responses": { "200": { "description": "Tracks matching topic" } } + } + }, + "/stream/{id}": { + "get": { + "tags": ["Streaming"], + "summary": "Stream Audio Chunk / HTTP 206 Partial Content", + "description": "High-performance streaming directly from Telegram MTProto multi-client connection pool with range requests support.", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "Range", "in": "header", "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "Full file download" }, + "206": { "description": "Partial byte range stream" } + } + } + }, + "/tracks/{id}/download": { + "get": { + "tags": ["Streaming"], + "summary": "Download Track File", + "description": "Streams file with Content-Disposition attachment header for direct file downloading.", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Audio file download stream" } } + } + }, + "/tracks/{id}/warm": { + "get": { + "tags": ["Streaming"], + "summary": "Warm Track Cache", + "description": "Pre-fetches initial audio chunks into RAM buffer to reduce TTFB on play.", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Warm status" } } + } + }, + "/stream/cover/{id}": { + "get": { + "tags": ["Streaming"], + "summary": "Stream Track Album Art / Thumbnail", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Image bytes" } } + } + }, + "/cover/{id}": { + "get": { + "tags": ["Streaming"], + "summary": "Track Cover Redirect", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "302": { "description": "Redirect to cover image URL" } } + } + }, + "/tracks/{id}/lyrics": { + "get": { + "tags": ["Media"], + "summary": "Track Lyrics", + "description": "Returns synchronized or plain lyrics for a track in JSON or text/plain.", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "format", "in": "query", "schema": { "type": "string", "enum": ["plain", "json"] } } + ], + "responses": { + "200": { "description": "Lyrics content" }, + "404": { "description": "Lyrics not found" } + } + } + }, + "/artists": { + "get": { + "tags": ["Artists"], + "summary": "List Artists", + "parameters": [ + { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } }, + { "name": "q", "in": "query", "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "List of artists" } } + } + }, + "/artists/{id}": { + "get": { + "tags": ["Artists"], + "summary": "Get Artist Details", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Artist metadata and biography" } } + } + }, + "/artists/{id}/tracks": { + "get": { + "tags": ["Artists"], + "summary": "Get Artist Top / Popular Tracks", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Tracks by artist" } } + } + }, + "/artists/{id}/albums": { + "get": { + "tags": ["Artists"], + "summary": "Get Albums by Artist", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Discography by artist" } } + } + }, + "/albums": { + "get": { + "tags": ["Albums"], + "summary": "List Albums", + "parameters": [ + { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } }, + { "name": "q", "in": "query", "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "List of albums" } } + } + }, + "/albums/{id}": { + "get": { + "tags": ["Albums"], + "summary": "Get Album Details", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Album metadata and tracks" } } + } + }, + "/albums/{id}/tracks": { + "get": { + "tags": ["Albums"], + "summary": "Get Album Tracks", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Ordered tracks in album" } } + } + }, + "/favourites": { + "get": { + "tags": ["Favourites"], + "summary": "List User Favourite Tracks", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } } + ], + "responses": { "200": { "description": "User favourite tracks" } } + }, + "post": { + "tags": ["Favourites"], + "summary": "Add Track to Favourites", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["track_id"], + "properties": { "track_id": { "type": "string" } } + } + } + } + }, + "responses": { "200": { "description": "Favourite saved" } } + } + }, + "/favourites/{id}": { + "delete": { + "tags": ["Favourites"], + "summary": "Remove Track from Favourites", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Removed from favourites" } } + } + }, + "/favourites/ids": { + "get": { + "tags": ["Favourites"], + "summary": "Get All Favourite Track IDs", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "responses": { "200": { "description": "List of liked track IDs" } } + } + }, + "/favourites/artists": { + "get": { + "tags": ["Favourites"], + "summary": "List Followed Artists", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "responses": { "200": { "description": "List of followed artists" } } + }, + "post": { + "tags": ["Favourites"], + "summary": "Follow Artist", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["artist_id"], + "properties": { "artist_id": { "type": "string" } } + } + } + } + }, + "responses": { "200": { "description": "Artist followed" } } + } + }, + "/favourites/artists/{id}": { + "delete": { + "tags": ["Favourites"], + "summary": "Unfollow Artist", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Artist unfollowed" } } + } + }, + "/favourites/albums": { + "get": { + "tags": ["Favourites"], + "summary": "List Saved Albums", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "responses": { "200": { "description": "List of saved albums" } } + }, + "post": { + "tags": ["Favourites"], + "summary": "Save Album to Library", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["album_id"], + "properties": { "album_id": { "type": "string" } } + } + } + } + }, + "responses": { "200": { "description": "Album saved" } } + } + }, + "/history": { + "get": { + "tags": ["Playback History & Telemetry"], + "summary": "Get Playback History (/history)", + "description": "Returns user playback event history and recently played tracks with timestamps.", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } } + ], + "responses": { "200": { "description": "User playback history" } } + } + }, + "/me/history": { + "get": { + "tags": ["Playback History & Telemetry"], + "summary": "Get My History (/me/history)", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } } + ], + "responses": { "200": { "description": "Recent listening history" } } + } + }, + "/me/top-played": { + "get": { + "tags": ["Playback History & Telemetry"], + "summary": "Get User Top Played Tracks (/me/top-played)", + "description": "Aggregates personal play counts and returns the most played tracks for the current user.", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } } + ], + "responses": { "200": { "description": "Top played tracks" } } + } + }, + "/me/listening-events": { + "post": { + "tags": ["Playback History & Telemetry"], + "summary": "Log Playback Events / Scrobble (/me/listening-events)", + "description": "Records play, pause, seek, completion, and scrobble telemetry events for user and global metrics.", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["events"], + "properties": { + "events": { + "type": "array", + "items": { + "type": "object", + "required": ["track_id", "event_type"], + "properties": { + "track_id": { "type": "string" }, + "event_type": { "type": "string", "enum": ["play", "scrobble", "pause", "resume", "seek", "complete"] }, + "position_sec": { "type": "number" }, + "duration_sec": { "type": "integer" }, + "timestamp": { "type": "number" } + } + } + } + } + } + } + } + }, + "responses": { "200": { "description": "Events recorded" } } + } + }, + "/listening-events": { + "post": { + "tags": ["Playback History & Telemetry"], + "summary": "Alias for Listening Events (/listening-events)", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "responses": { "200": { "description": "Events recorded" } } + } + }, + "/playlists": { + "get": { + "tags": ["Playlists"], + "summary": "List User Playlists", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "responses": { "200": { "description": "List of user playlists" } } + }, + "post": { + "tags": ["Playlists"], + "summary": "Create Custom Playlist", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["title"], + "properties": { + "title": { "type": "string" }, + "description": { "type": "string" }, + "cover_url": { "type": "string" } + } + } + } + } + }, + "responses": { "200": { "description": "Playlist created" } } + } + }, + "/playlists/{id}": { + "get": { + "tags": ["Playlists"], + "summary": "Get Playlist by ID", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Playlist detail" } } + }, + "patch": { + "tags": ["Playlists"], + "summary": "Update Playlist", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Playlist updated" } } + }, + "delete": { + "tags": ["Playlists"], + "summary": "Delete Playlist", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Playlist deleted" } } + } + }, + "/playlists/{id}/tracks": { + "get": { + "tags": ["Playlists"], + "summary": "Get Playlist Tracks", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Tracks in playlist" } } + }, + "post": { + "tags": ["Playlists"], + "summary": "Add Tracks to Playlist", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Tracks added" } } + } + }, + "/playlists/{id}/reorder": { + "post": { + "tags": ["Playlists"], + "summary": "Reorder Playlist Tracks", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Playlist reordered" } } + } + }, + "/playlists/available": { + "get": { + "tags": ["Daily Dynamic Playlists"], + "summary": "List Available Dynamic Playlists", + "description": "Returns metadata for all available daily dynamic mixes (Daily Mix 1–6, Discover Weekly, Rediscover, Late Night, Rising, Surprise Me).", + "responses": { "200": { "description": "List of available mixes" } } + } + }, + "/daily-playlist/{key}": { + "get": { + "tags": ["Daily Dynamic Playlists"], + "summary": "Get Daily Dynamic Playlist (/daily-playlist/{key})", + "description": "Generates or retrieves cached daily dynamic mix generated from user listening habits and library genres.", + "parameters": [ + { "name": "key", "in": "path", "required": true, "schema": { "type": "string", "example": "daily_mix_1" } }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 75 } }, + { "name": "channel_id", "in": "query", "schema": { "type": "integer" } } + ], + "responses": { + "200": { "description": "Dynamic playlist with ordered tracks" }, + "400": { "description": "Invalid mix key" } + } + } + }, + "/admin/access/policy": { + "get": { + "tags": ["Access Control Policies"], + "summary": "Get Access Control Policy", + "description": "Returns current registration mode (open, invite, allowlist, closed), Telegram membership enforcement flag, and required channels.", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "responses": { "200": { "description": "Current policy" } } + }, + "patch": { + "tags": ["Access Control Policies"], + "summary": "Update Access Control Policy", + "description": "Updates registration mode, Telegram membership enforcement, lock message, or required chats.", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "registration_mode": { "type": "string", "enum": ["open", "invite", "allowlist", "closed"] }, + "enforce_membership": { "type": "boolean" }, + "lock_message": { "type": "string" } + } + } + } + } + }, + "responses": { "200": { "description": "Updated policy" } } + } + }, + "/admin/access/required-chats": { + "post": { + "tags": ["Access Control Policies"], + "summary": "Add Required Telegram Chat", + "description": "Registers a Telegram channel or group that users must join before gaining access.", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["chat_id"], + "properties": { + "chat_id": { "type": "integer" }, + "title": { "type": "string" }, + "invite_link": { "type": "string" }, + "is_private": { "type": "boolean" } + } + } + } + } + }, + "responses": { "200": { "description": "Chat added to required policy" } } + }, + "delete": { + "tags": ["Access Control Policies"], + "summary": "Remove Required Telegram Chat", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "chat_id", "in": "query", "schema": { "type": "integer" } } + ], + "responses": { "200": { "description": "Chat removed" } } + } + }, + "/admin/access/required-chats/{chat_id}": { + "delete": { + "tags": ["Access Control Policies"], + "summary": "Remove Required Telegram Chat by ID", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "chat_id", "in": "path", "required": true, "schema": { "type": "integer" } } + ], + "responses": { "200": { "description": "Chat removed" } } + } + }, + "/admin/access/invites": { + "get": { + "tags": ["Access Control Policies"], + "summary": "List Invite Codes", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "include_dead", "in": "query", "schema": { "type": "boolean", "default": false } } + ], + "responses": { "200": { "description": "List of active or all invite codes" } } + }, + "post": { + "tags": ["Access Control Policies"], + "summary": "Create Registration Invite Code", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "max_uses": { "type": "integer", "default": 1 }, + "ttl_days": { "type": "integer", "default": 7 }, + "note": { "type": "string" } + } + } + } + } + }, + "responses": { "200": { "description": "Created invite code" } } + } + }, + "/admin/access/invites/{code}": { + "delete": { + "tags": ["Access Control Policies"], + "summary": "Revoke Invite Code", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "code", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Invite revoked" } } + } + }, + "/admin/access/allowlist": { + "get": { + "tags": ["Access Control Policies"], + "summary": "List Allowed Users", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "responses": { "200": { "description": "List of allowlisted user IDs" } } + }, + "post": { + "tags": ["Access Control Policies"], + "summary": "Add User to Allowlist", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["user_id"], + "properties": { + "user_id": { "type": "integer" }, + "note": { "type": "string" } + } + } + } + } + }, + "responses": { "200": { "description": "User added to allowlist" } } + } + }, + "/admin/access/allowlist/{user_id}": { + "delete": { + "tags": ["Access Control Policies"], + "summary": "Remove User from Allowlist", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "user_id", "in": "path", "required": true, "schema": { "type": "integer" } } + ], + "responses": { "200": { "description": "User removed from allowlist" } } + } + }, + "/admin/access/bypass": { + "get": { + "tags": ["Access Control Policies"], + "summary": "List Membership Bypass Users", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "responses": { "200": { "description": "List of bypassed user IDs" } } + }, + "post": { + "tags": ["Access Control Policies"], + "summary": "Add User to Bypass List", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["user_id"], + "properties": { + "user_id": { "type": "integer" }, + "note": { "type": "string" } + } + } + } + } + }, + "responses": { "200": { "description": "User added to bypass list" } } + } + }, + "/admin/access/bypass/{user_id}": { + "delete": { + "tags": ["Access Control Policies"], + "summary": "Remove User from Bypass List", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "user_id", "in": "path", "required": true, "schema": { "type": "integer" } } + ], + "responses": { "200": { "description": "User removed from bypass list" } } + } + }, + "/admin/access/users": { + "get": { + "tags": ["Access Control Policies"], + "summary": "List / Search User Accounts", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "q", "in": "query", "schema": { "type": "string" } }, + { "name": "status", "in": "query", "schema": { "type": "string", "enum": ["active", "locked", "restricted"] } }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } }, + { "name": "skip", "in": "query", "schema": { "type": "integer", "default": 0 } } + ], + "responses": { "200": { "description": "User list with total count" } } + } + }, + "/admin/access/users/{id}/lock": { + "post": { + "tags": ["Access Control Policies"], + "summary": "Lock User Account", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reason": { "type": "string" }, + "revoke_sessions": { "type": "boolean", "default": true } + } + } + } + } + }, + "responses": { "200": { "description": "User locked" } } + } + }, + "/admin/access/users/{id}/unlock": { + "post": { + "tags": ["Access Control Policies"], + "summary": "Unlock User Account", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } } + ], + "responses": { "200": { "description": "User unlocked" } } + } + }, + "/admin/access/users/{id}/revoke-sessions": { + "post": { + "tags": ["Access Control Policies"], + "summary": "Revoke User Active Sessions", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } } + ], + "responses": { "200": { "description": "All active JWT sessions revoked" } } + } + }, + "/admin/access/reverify": { + "post": { + "tags": ["Access Control Policies"], + "summary": "Reverify Telegram Chat Memberships", + "description": "Tests or reverifies whether target user (or caller) meets all required Telegram chat memberships.", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "user_id": { "type": "integer" } + } + } + } + } + }, + "responses": { "200": { "description": "Verification result for each channel" } } + } + }, + "/share/{id}": { + "get": { + "tags": ["Rich Embeds & Share Cards"], + "summary": "OpenGraph / Twitter Preview Card (/share/{id})", + "description": "Renders high-fidelity HTML OpenGraph & Twitter preview cards with dark player UI for social media platforms (Telegram, Discord, Twitter, WhatsApp), or JSON metadata if requested.", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "format", "in": "query", "schema": { "type": "string", "enum": ["html", "json"] } } + ], + "responses": { + "200": { "description": "HTML rich card or JSON metadata" }, + "404": { "description": "Track or playlist not found" } + } + } + }, + "/share/tracks/{id}": { + "get": { + "tags": ["Rich Embeds & Share Cards"], + "summary": "Track Share Card Preview", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Track OpenGraph preview" } } + } + }, + "/share/playlists/{id}": { + "get": { + "tags": ["Rich Embeds & Share Cards"], + "summary": "Playlist Share Card Preview", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Playlist OpenGraph preview" } } + } + }, + "/auth/setup/status": { + "get": { + "tags": ["Authentication"], + "summary": "Initial Setup Status", + "description": "Checks if the server requires initial admin password configuration or if owner accounts exist.", + "responses": { "200": { "description": "Setup status" } } + } + }, + "/auth/setup": { + "post": { + "tags": ["Authentication"], + "summary": "Complete Initial Admin Setup", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["password"], + "properties": { "password": { "type": "string" }, "secret_key": { "type": "string" } } + } + } + } + }, + "responses": { "200": { "description": "Setup completed" } } + } + }, + "/auth/password": { + "post": { + "tags": ["Authentication"], + "summary": "Direct Username & Password Login", + "description": "Direct login without Telegram bot OTP.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["username", "password"], + "properties": { + "username": { "type": "string" }, + "password": { "type": "string" } + } + } + } + } + }, + "responses": { "200": { "description": "Login token" } } + } + }, + "/auth/password/change": { + "post": { + "tags": ["Authentication"], + "summary": "Change User Password", + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["old_password", "new_password"], + "properties": { + "old_password": { "type": "string" }, + "new_password": { "type": "string" } + } + } + } + } + }, + "responses": { "200": { "description": "Password updated" } } + } + }, + "/auth/register": { + "post": { + "tags": ["Authentication"], + "summary": "Register New Account", + "description": "Initiates user registration by validating user credentials and dispatching a 6-digit OTP code to the Telegram account via the bot.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["userid", "username", "password"], + "properties": { + "userid": { "type": "integer" }, + "username": { "type": "string" }, + "password": { "type": "string" }, + "invite_code": { "type": "string" } + } + } + } + } + }, + "responses": { "200": { "description": "OTP sent via Telegram bot" } } + } + }, + "/auth/create": { + "post": { + "tags": ["Authentication"], + "summary": "Alias for Direct User Creation", + "responses": { "200": { "description": "Account created" } } + } + }, + "/auth/validate": { + "post": { + "tags": ["Authentication"], + "summary": "Validate Telegram Registration OTP", + "description": "Verifies the 6-digit OTP sent to Telegram user by the bot, completes registration, and returns authentication token.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["userid", "otp"], + "properties": { + "userid": { "type": "integer" }, + "otp": { "type": "string" } + } + } + } + } + }, + "responses": { "200": { "description": "Registration completed and token issued" } } + } + }, + "/auth/guest": { + "post": { + "tags": ["Authentication"], + "summary": "Guest Login with Guest Password", + "description": "Authenticates using configured GUEST_PASSWORD and returns a guest session token.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["guest_password"], + "properties": { "guest_password": { "type": "string" } } + } + } + } + }, + "responses": { "200": { "description": "Guest session token" } } + } + }, + "/auth/telegram": { + "post": { + "tags": ["Authentication"], + "summary": "Telegram WebApp Login", + "description": "Verifies Telegram initData HMAC-SHA256 signature and returns user session token.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["init_data"], + "properties": { "init_data": { "type": "string" } } + } + } + } + }, + "responses": { "200": { "description": "Authentication successful" } } + } + }, + "/auth/telegram/widget": { + "post": { + "tags": ["Authentication"], + "summary": "Telegram Widget Login", + "responses": { "200": { "description": "Authentication successful" } } + } + }, + "/auth/me": { + "get": { + "tags": ["Authentication"], + "summary": "Current User Profile", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "responses": { + "200": { "description": "Current user profile" }, + "401": { "description": "Unauthorized" } + } + } + }, + "/auth/fcm-token": { + "post": { + "tags": ["Authentication"], + "summary": "Update FCM Push Token", + "description": "Registers or updates the user Firebase Cloud Messaging device push token matching Api/schemas/auth.py.", + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["fcm_token"], + "properties": { + "fcm_token": { "type": "string" } + } + } + } + } + }, + "responses": { + "200": { "description": "Token updated" }, + "401": { "description": "Unauthorized" } + } + } + }, + "/auth/telegram/bot-session": { + "post": { + "tags": ["Authentication"], + "summary": "Create Telegram Bot Auth Session", + "description": "Creates a temporary authentication session to log in directly via the Telegram bot matching Api/schemas/auth.py.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "invite_code": { "type": "string" } + } + } + } + } + }, + "responses": { "200": { "description": "Bot session created" } } + } + }, + "/auth/telegram/bot-session/status": { + "get": { + "tags": ["Authentication"], + "summary": "Check Telegram Bot Auth Session Status", + "description": "Polls status of temporary bot auth session.", + "parameters": [ + { "name": "session_id", "in": "query", "required": true, "schema": { "type": "string" } }, + { "name": "set_cookie", "in": "query", "schema": { "type": "boolean", "default": true } } + ], + "responses": { "200": { "description": "Session status" } } + } + }, + "/discord/presence": { + "post": { + "tags": ["External Integrations"], + "summary": "Generate Discord Rich Presence Payload", + "description": "Formats current track playback into Discord Rich Presence activity object.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["track_id"], + "properties": { + "track_id": { "type": "string" }, + "title": { "type": "string" }, + "artist": { "type": "string" }, + "duration_sec": { "type": "integer" }, + "elapsed_sec": { "type": "integer" } + } + } + } + } + }, + "responses": { "200": { "description": "Discord presence payload" } } + } + }, + "/sources/channels": { + "get": { + "tags": ["Sources"], + "summary": "List Monitored Telegram Channels", + "responses": { "200": { "description": "Channel list" } } + }, + "post": { + "tags": ["Sources"], + "summary": "Add Monitored Channel", + "security": [{ "bearerAuth": [] }, { "guestPassword": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["channel_id"], + "properties": { "channel_id": { "type": "integer" }, "title": { "type": "string" } } + } + } + } + }, + "responses": { "200": { "description": "Channel added" } } + } + } + } +}` + +const swaggerUIHTML = ` + + + + StreamGO — API Documentation + + + + + + + +
+
+

StreamGO

+ v1.0 +
+
+ + + + +` + +const redocHTML = ` + + + StreamGO - ReDoc + + + + + + + + + +` + +func (s *Server) handleOpenAPISpec(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(openAPISpecJSON)) +} + +func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(swaggerUIHTML)) +} + +func (s *Server) handleRedoc(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(redocHTML)) +} diff --git a/internal/server/router.go b/internal/server/router.go new file mode 100644 index 0000000..20cdb5f --- /dev/null +++ b/internal/server/router.go @@ -0,0 +1,249 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + + "streamgo/internal/api" + "streamgo/internal/api/handlers" + "streamgo/internal/config" + "streamgo/internal/database" + "streamgo/internal/logger" + "streamgo/internal/repository" + "streamgo/internal/services" + "streamgo/internal/telegram" +) + +var log = logger.New("server") + +// Server encapsulates the Chi router, config, and database handle. +type Server struct { + Router *chi.Mux + Config *config.Config + DB *database.Client + TG *telegram.Service + distDir string + start time.Time +} + +// New creates and configures a new HTTP router with middlewares and modular routes. +func New(cfg *config.Config, db *database.Client, tg *telegram.Service, filter *services.AccessFilter) *Server { + r := chi.NewRouter() + + distDir := resolveDistDir() + if distDir != "" { + log.Infof("[spa] WebX frontend detected and mounted from: %s", distDir) + } + + s := &Server{ + Router: r, + Config: cfg, + DB: db, + TG: tg, + distDir: distDir, + start: time.Now(), + } + + // Global Middlewares + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + // Toggleable HTTP request logger: completely silent when APILogs is false + r.Use(logger.HTTPMiddleware(cfg.APILogs)) + r.Use(middleware.Recoverer) + + // CORS Configuration + corsOrigins := []string{"*"} + if cfg.CorsOrigin != "" && cfg.CorsOrigin != "*" { + corsOrigins = []string{cfg.CorsOrigin} + } + + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: corsOrigins, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "Range", "X-Secret-Key", "X-Auth-Token"}, + ExposedHeaders: []string{"Link", "Content-Length", "Content-Range", "Accept-Ranges"}, + AllowCredentials: true, + MaxAge: 300, + })) + + // Intercept browser HTML navigations to serve WebX frontend SPA shell + r.Use(s.SPAMiddleware) + + // Core System Routes + r.Get("/", s.handleIndex) + r.Head("/", s.handleIndex) + r.Get("/health", s.handleHealth) + + // API Documentation Routes (Swagger UI & ReDoc) + r.Get("/docs", s.handleDocs) + r.Get("/docs/*", s.handleDocs) + r.Head("/docs", s.handleDocs) + r.Get("/redoc", s.handleRedoc) + r.Head("/redoc", s.handleRedoc) + r.Get("/openapi.json", s.handleOpenAPISpec) + r.Head("/openapi.json", s.handleOpenAPISpec) + + // Mount Domain Routes when MongoDB is available + if db != nil { + // Repositories + trackRepo := repository.NewTrackRepository(db) + artistAlbumRepo := repository.NewArtistAlbumRepository(db) + favRepo := repository.NewFavouritePlaylistRepository(db) + userRepo := repository.NewUserRepository(db) + historyRepo := repository.NewHistoryRepository(db) + dailyRepo := repository.NewDailyPlaylistRepository(db) + accessRepo := repository.NewAccessControlRepository(db) + recapRepo := repository.NewRecapRepository(db) + + // Services + trackSvc := services.NewTrackService(trackRepo) + r2Storage := services.NewR2StorageService(cfg) + trackSvc.SetR2StorageService(r2Storage) + alacSvc := services.NewALACService(cfg, tg, "stream_media") + streamSvc := services.NewStreamService(trackRepo, tg) + streamSvc.SetALACService(alacSvc) + artistAlbumSvc := services.NewArtistAlbumService(artistAlbumRepo, trackRepo) + favPlaylistSvc := services.NewFavouritePlaylistService(favRepo, trackRepo, artistAlbumRepo) + authSvc := services.NewAuthService(cfg, userRepo) + authSvc.SetAccessControlRepository(accessRepo) + discordSvc := services.NewDiscordService() + histSvc := services.NewHistoryService(historyRepo, trackRepo) + dailySvc := services.NewDailyPlaylistService(dailyRepo, trackRepo) + accessSvc := services.NewAccessControlService(accessRepo) + lyricsSvc := services.NewLyricsEnrichmentService(cfg) + recapSvc := services.NewRecapService(recapRepo, trackRepo) + + // Handlers + trackHandler := handlers.NewTrackHandler(trackSvc, authSvc, cfg) + topicHandler := handlers.NewTopicHandler(trackSvc) + streamHandler := handlers.NewStreamHandler(streamSvc, authSvc, histSvc) + artistHandler := handlers.NewArtistHandler(artistAlbumSvc) + albumHandler := handlers.NewAlbumHandler(artistAlbumSvc) + favHandler := handlers.NewFavouriteHandler(favPlaylistSvc, authSvc) + playlistHandler := handlers.NewPlaylistHandler(favPlaylistSvc, authSvc) + authHandler := handlers.NewAuthHandler(cfg, authSvc) + mediaExtraHandler := handlers.NewMediaExtraHandler(trackSvc, lyricsSvc, trackRepo) + sourcesHandler := handlers.NewSourcesHandler(cfg, filter, authSvc) + discordHandler := handlers.NewDiscordHandler(discordSvc) + historyHandler := handlers.NewHistoryHandler(histSvc, authSvc) + dailyHandler := handlers.NewDailyPlaylistHandler(dailySvc, authSvc) + accessHandler := handlers.NewAccessControlHandler(accessSvc, authSvc, cfg) + shareHandler := handlers.NewShareHandler(trackRepo, favRepo, cfg) + recapHandler := handlers.NewRecapHandler(recapSvc, authSvc) + + // Register routes + trackHandler.Routes(r) + topicHandler.Routes(r) + streamHandler.Routes(r) + artistHandler.Routes(r) + albumHandler.Routes(r) + favHandler.Routes(r) + playlistHandler.Routes(r) + authHandler.Routes(r) + mediaExtraHandler.Routes(r) + sourcesHandler.Routes(r) + discordHandler.Routes(r) + historyHandler.Routes(r) + dailyHandler.Routes(r) + accessHandler.Routes(r) + shareHandler.Routes(r) + recapHandler.Routes(r) + } + + // SPA & Static Asset Catch-All Handler + r.NotFound(func(w http.ResponseWriter, r *http.Request) { + if isAPIOrReservedPath(r.URL.Path) { + api.RespondError(w, http.StatusNotFound, "route not found") + return + } + + if s.distDir != "" { + cleanPath := filepath.Clean(strings.TrimPrefix(r.URL.Path, "/")) + if cleanPath != "" && cleanPath != "." { + targetPath := filepath.Join(s.distDir, cleanPath) + if info, err := os.Stat(targetPath); err == nil && !info.IsDir() { + if cleanPath == "sw.js" || cleanPath == "manifest.json" { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + } else if strings.HasPrefix(cleanPath, "assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } + http.ServeFile(w, r, targetPath) + return + } + } + + // Single Page Application Navigation: Return index.html for GET/HEAD + if r.Method == http.MethodGet || r.Method == http.MethodHead { + indexFile := filepath.Join(s.distDir, "index.html") + if _, err := os.Stat(indexFile); err == nil { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Cross-Origin-Opener-Policy", "same-origin-allow-popups") + http.ServeFile(w, r, indexFile) + return + } + } + } + + api.RespondError(w, http.StatusNotFound, "route not found") + }) + + return s +} + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + if s.distDir != "" { + indexFile := filepath.Join(s.distDir, "index.html") + if _, err := os.Stat(indexFile); err == nil { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Cross-Origin-Opener-Policy", "same-origin-allow-popups") + http.ServeFile(w, r, indexFile) + return + } + } + respondJSON(w, http.StatusOK, map[string]interface{}{ + "service": "StreamGO", + "status": "running", + "version": "1.0.0", + }) +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + dbStatus := "disabled" + if s.DB != nil { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + if err := s.DB.Ping(ctx); err != nil { + dbStatus = "unhealthy: " + err.Error() + } else { + dbStatus = "healthy" + } + } + + tgStatus := "disabled" + if s.TG != nil { + tgStatus = s.TG.StatusString() + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "status": "ok", + "database": dbStatus, + "telegram": tgStatus, + "uptime": time.Since(s.start).String(), + "time": time.Now().UTC().Format(time.RFC3339), + }) +} + +func respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} diff --git a/internal/server/spa.go b/internal/server/spa.go new file mode 100644 index 0000000..b6d6ab9 --- /dev/null +++ b/internal/server/spa.go @@ -0,0 +1,128 @@ +package server + +import ( + "net/http" + "os" + "path/filepath" + "strings" +) + +// resolveDistDir locates the WebX frontend build directory. +func resolveDistDir() string { + candidates := []string{ + "dist", + "../WebX/dist", + "WebX/dist", + "/app/dist", + "/app/WebX/dist", + } + for _, dir := range candidates { + if info, err := os.Stat(dir); err == nil && info.IsDir() { + if _, err := os.Stat(filepath.Join(dir, "index.html")); err == nil { + return dir + } + } + } + return "" +} + +// isHTMLNavigation checks if a request is a browser HTML page navigation. +func isHTMLNavigation(r *http.Request) bool { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + return false + } + + secDest := strings.ToLower(r.Header.Get("Sec-Fetch-Dest")) + if secDest == "document" || secDest == "frame" || secDest == "iframe" { + return true + } + + secMode := strings.ToLower(r.Header.Get("Sec-Fetch-Mode")) + if secMode == "navigate" { + return true + } + + accept := strings.ToLower(r.Header.Get("Accept")) + if strings.Contains(accept, "text/html") { + parts := strings.Split(accept, ",") + if len(parts) > 0 && !strings.HasPrefix(strings.TrimSpace(parts[0]), "application/json") { + return true + } + } + + return false +} + +// isReservedNavigation returns true if HTML navigation should bypass SPA index.html fallback +// (e.g. documentation, audio streams, direct downloads). +func isReservedNavigation(path string) bool { + path = strings.ToLower(strings.TrimSpace(path)) + if path == "/docs" || strings.HasPrefix(path, "/docs/") || + path == "/redoc" || strings.HasPrefix(path, "/redoc/") || + path == "/openapi.json" || + strings.HasPrefix(path, "/assets/") || + strings.HasPrefix(path, "/auth/") || + strings.HasPrefix(path, "/covers/") || + strings.HasPrefix(path, "/stream/") || + strings.HasPrefix(path, "/download/") || + (strings.HasPrefix(path, "/tracks/") && (strings.HasSuffix(path, "/stream") || strings.HasSuffix(path, "/download"))) { + return true + } + return false +} + +// isAPIOrReservedPath returns true if the URL path belongs to backend API, docs, or media routes. +func isAPIOrReservedPath(path string) bool { + path = strings.ToLower(strings.TrimSpace(path)) + prefixes := []string{ + "/api", + "/auth", + "/tracks", + "/stream", + "/download", + "/artists", + "/albums", + "/favourites", + "/playlists", + "/topics", + "/history", + "/me", + "/listening-events", + "/daily-playlist", + "/admin", + "/share", + "/recaps", + "/sources", + "/discord", + "/health", + "/docs", + "/redoc", + "/openapi.json", + } + for _, p := range prefixes { + if path == p || strings.HasPrefix(path, p+"/") { + return true + } + } + return false +} + +// SPAMiddleware intercepts browser HTML page navigations and serves the frontend SPA shell +// (index.html), preserving API endpoints for fetch/XHR calls. +func (s *Server) SPAMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.distDir != "" && isHTMLNavigation(r) { + path := strings.ToLower(strings.TrimSpace(r.URL.Path)) + if !isReservedNavigation(path) { + indexFile := filepath.Join(s.distDir, "index.html") + if _, err := os.Stat(indexFile); err == nil { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Cross-Origin-Opener-Policy", "same-origin-allow-popups") + http.ServeFile(w, r, indexFile) + return + } + } + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/services/access_control_service.go b/internal/services/access_control_service.go new file mode 100644 index 0000000..7333f68 --- /dev/null +++ b/internal/services/access_control_service.go @@ -0,0 +1,268 @@ +package services + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "strings" + "time" + + "streamgo/internal/models" + "streamgo/internal/repository" +) + +// AccessControlService handles access control policies, invites, allowlists, and user account state. +type AccessControlService struct { + repo repository.AccessControlRepository +} + +// NewAccessControlService creates a new AccessControlService. +func NewAccessControlService(repo repository.AccessControlRepository) *AccessControlService { + return &AccessControlService{repo: repo} +} + +// GetPolicy retrieves the active access policy. +func (s *AccessControlService) GetPolicy(ctx context.Context) (*models.AccessPolicy, error) { + return s.repo.GetPolicy(ctx) +} + +// UpdatePolicy applies partial updates to AccessPolicy. +func (s *AccessControlService) UpdatePolicy(ctx context.Context, patch models.PolicyPatch, adminID int64) (*models.AccessPolicy, error) { + policy, err := s.repo.GetPolicy(ctx) + if err != nil { + return nil, err + } + + if patch.RegistrationMode != nil { + mode := strings.ToLower(strings.TrimSpace(*patch.RegistrationMode)) + if mode != "open" && mode != "invite" && mode != "allowlist" && mode != "closed" { + return nil, errors.New("invalid registration_mode (must be open, invite, allowlist, closed)") + } + policy.RegistrationMode = mode + } + + if patch.EnforceMembership != nil { + policy.EnforceMembership = *patch.EnforceMembership + } + if patch.LockMessage != nil { + policy.LockMessage = *patch.LockMessage + } + if patch.RequiredChats != nil { + policy.RequiredChats = *patch.RequiredChats + } + + policy.UpdatedAt = float64(time.Now().Unix()) + policy.UpdatedBy = adminID + + if err := s.repo.SavePolicy(ctx, policy); err != nil { + return nil, err + } + return policy, nil +} + +// AddRequiredChat adds a chat to required chats in policy. +func (s *AccessControlService) AddRequiredChat(ctx context.Context, chat models.RequiredChat) (*models.AccessPolicy, error) { + policy, err := s.repo.GetPolicy(ctx) + if err != nil { + return nil, err + } + + for i, c := range policy.RequiredChats { + if c.ChatID == chat.ChatID { + policy.RequiredChats[i] = chat + _ = s.repo.SavePolicy(ctx, policy) + return policy, nil + } + } + + policy.RequiredChats = append(policy.RequiredChats, chat) + if err := s.repo.SavePolicy(ctx, policy); err != nil { + return nil, err + } + return policy, nil +} + +// RemoveRequiredChat removes a chat from required chats in policy. +func (s *AccessControlService) RemoveRequiredChat(ctx context.Context, chatID int64) (*models.AccessPolicy, error) { + policy, err := s.repo.GetPolicy(ctx) + if err != nil { + return nil, err + } + + var filtered []models.RequiredChat + for _, c := range policy.RequiredChats { + if c.ChatID != chatID { + filtered = append(filtered, c) + } + } + policy.RequiredChats = filtered + if err := s.repo.SavePolicy(ctx, policy); err != nil { + return nil, err + } + return policy, nil +} + +// CreateInvite generates a random invite code. +func (s *AccessControlService) CreateInvite(ctx context.Context, adminID int64, maxUses int, ttlDays int, note string) (*models.InviteCode, error) { + if maxUses <= 0 { + maxUses = 1 + } + + randBytes := make([]byte, 6) + _, _ = rand.Read(randBytes) + code := strings.ToUpper(hex.EncodeToString(randBytes)) + + now := float64(time.Now().Unix()) + var expiresAt *float64 + if ttlDays > 0 { + exp := now + float64(ttlDays*86400) + expiresAt = &exp + } + + inv := &models.InviteCode{ + Code: code, + MaxUses: maxUses, + UsedCount: 0, + CreatedBy: adminID, + CreatedAt: now, + ExpiresAt: expiresAt, + Note: note, + Revoked: false, + } + + if err := s.repo.CreateInvite(ctx, inv); err != nil { + return nil, err + } + return inv, nil +} + +// RevokeInvite revokes an invite code. +func (s *AccessControlService) RevokeInvite(ctx context.Context, code string) bool { + return s.repo.RevokeInvite(ctx, code) +} + +// ListInvites lists invite codes. +func (s *AccessControlService) ListInvites(ctx context.Context, includeDead bool) ([]*models.InviteCode, error) { + return s.repo.ListInvites(ctx, includeDead) +} + +// AddToAllowlist adds a user ID to allowlist. +func (s *AccessControlService) AddToAllowlist(ctx context.Context, userID int64, adminID int64, note string) error { + entry := &models.AllowlistEntry{ + UserID: userID, + AddedBy: adminID, + AddedAt: float64(time.Now().Unix()), + Note: note, + } + return s.repo.AddToAllowlist(ctx, entry) +} + +// RemoveFromAllowlist removes a user ID from allowlist. +func (s *AccessControlService) RemoveFromAllowlist(ctx context.Context, userID int64) bool { + return s.repo.RemoveFromAllowlist(ctx, userID) +} + +// ListAllowlist lists all allowlisted users. +func (s *AccessControlService) ListAllowlist(ctx context.Context) ([]*models.AllowlistEntry, error) { + return s.repo.ListAllowlist(ctx) +} + +// AddToBypass adds a user to bypass list. +func (s *AccessControlService) AddToBypass(ctx context.Context, userID int64, adminID int64, note string) error { + entry := &models.AllowlistEntry{ + UserID: userID, + AddedBy: adminID, + AddedAt: float64(time.Now().Unix()), + Note: note, + } + return s.repo.AddToBypass(ctx, entry) +} + +// RemoveFromBypass removes a user from bypass list. +func (s *AccessControlService) RemoveFromBypass(ctx context.Context, userID int64) bool { + return s.repo.RemoveFromBypass(ctx, userID) +} + +// IsInBypass checks if a user is in the bypass list. +func (s *AccessControlService) IsInBypass(ctx context.Context, userID int64) bool { + return s.repo.IsInBypass(ctx, userID) +} + +// ListBypass lists bypass entries. +func (s *AccessControlService) ListBypass(ctx context.Context) ([]*models.AllowlistEntry, error) { + return s.repo.ListBypass(ctx) +} + +// ListUsers lists user accounts for administration. +func (s *AccessControlService) ListUsers(ctx context.Context, query string, status string, limit int, skip int) (map[string]any, error) { + if limit <= 0 { + limit = 50 + } + users, total, err := s.repo.ListUsers(ctx, query, status, limit, skip) + if err != nil { + return nil, err + } + return map[string]any{ + "users": users, + "total": total, + "limit": limit, + "skip": skip, + }, nil +} + +// LockUser locks a user account and optionally revokes active sessions. +func (s *AccessControlService) LockUser(ctx context.Context, userID int64, reason string, adminID int64, revokeSessions bool) error { + if err := s.repo.SetUserStatus(ctx, userID, "locked", reason); err != nil { + return err + } + if revokeSessions { + _, _ = s.repo.RevokeUserSessions(ctx, userID) + } + return nil +} + +// UnlockUser unlocks a user account. +func (s *AccessControlService) UnlockUser(ctx context.Context, userID int64) error { + return s.repo.SetUserStatus(ctx, userID, "active", "") +} + +// RevokeSessions revokes all active tokens for a user. +func (s *AccessControlService) RevokeSessions(ctx context.Context, userID int64) (int, error) { + return s.repo.RevokeUserSessions(ctx, userID) +} + +// AssertCanRegister checks registration policy rules against user ID and invite code. +func (s *AccessControlService) AssertCanRegister(ctx context.Context, userID int64, inviteCode string) error { + policy, err := s.repo.GetPolicy(ctx) + if err != nil { + return nil // Fallback open if policy check errors + } + + switch policy.RegistrationMode { + case "closed": + return errors.New("registration_closed: New accounts cannot be created at this time") + case "allowlist": + if userID > 0 && !s.repo.IsInAllowlist(ctx, userID) { + return errors.New("not_allowlisted: Your account is not on the registration allowlist") + } + case "invite": + code := strings.ToUpper(strings.TrimSpace(inviteCode)) + if code == "" { + return errors.New("invite_required: An invite code is required to register") + } + inv, err := s.repo.GetInvite(ctx, code) + if err != nil || inv == nil || inv.Revoked { + return errors.New("invite_invalid: That invite code is invalid or has been revoked") + } + if inv.ExpiresAt != nil && float64(time.Now().Unix()) > *inv.ExpiresAt { + return errors.New("invite_expired: That invite code has expired") + } + if inv.MaxUses > 0 && inv.UsedCount >= inv.MaxUses { + return errors.New("invite_exhausted: That invite code has already reached its maximum uses") + } + _ = s.repo.ConsumeInvite(ctx, code) + } + + return nil +} diff --git a/internal/services/access_filter.go b/internal/services/access_filter.go new file mode 100644 index 0000000..9aaa70e --- /dev/null +++ b/internal/services/access_filter.go @@ -0,0 +1,343 @@ +package services + +import ( + "context" + "strconv" + "strings" + "sync" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "streamgo/internal/config" + "streamgo/internal/database" +) + +const ( + FilterModeGroupOnly = 0 + FilterModeAnyone = 1 + FilterModeHybrid = 2 +) + +// ParseFilterMode parses string or int values into valid filter mode constants. +func ParseFilterMode(val any) int { + if val == nil { + return FilterModeGroupOnly + } + switch v := val.(type) { + case int: + if v == FilterModeAnyone || v == FilterModeHybrid { + return v + } + return FilterModeGroupOnly + case int64: + if v == int64(FilterModeAnyone) || v == int64(FilterModeHybrid) { + return int(v) + } + return FilterModeGroupOnly + case string: + s := strings.ToLower(strings.TrimSpace(v)) + switch s { + case "0", "group_only", "group", "channel_only": + return FilterModeGroupOnly + case "1", "anyone", "all", "any": + return FilterModeAnyone + case "2", "hybrid", "allowlist", "whitelist": + return FilterModeHybrid + default: + if n, err := strconv.Atoi(s); err == nil { + if n == FilterModeAnyone || n == FilterModeHybrid { + return n + } + } + return FilterModeGroupOnly + } + } + return FilterModeGroupOnly +} + +// FilterModeToString converts a filter mode integer to its canonical string representation. +func FilterModeToString(mode int) string { + switch mode { + case FilterModeAnyone: + return "anyone" + case FilterModeHybrid: + return "hybrid" + default: + return "group_only" + } +} + +// AccessFilter checks chat access rights before indexing or streaming media. +type AccessFilter struct { + cfg *config.Config + col *mongo.Collection + bannedCol *mongo.Collection + + mu sync.RWMutex + mode int + allowedChats map[int64]bool + bannedChats map[int64]bool + bannedUsers map[int64]bool + lastRefresh time.Time +} + +// NewAccessFilter creates an AccessFilter instance. +func NewAccessFilter(cfg *config.Config, db *database.Client) *AccessFilter { + var col, bannedCol *mongo.Collection + if db != nil { + col = db.Collection("source_chats") + bannedCol = db.Collection("banned_users") + } + + mode := FilterModeGroupOnly + if cfg != nil { + mode = cfg.FilterMode + } + + af := &AccessFilter{ + cfg: cfg, + col: col, + bannedCol: bannedCol, + mode: mode, + allowedChats: make(map[int64]bool), + bannedChats: make(map[int64]bool), + bannedUsers: make(map[int64]bool), + } + + if cfg != nil { + if cfg.ChannelID != 0 { + af.allowedChats[cfg.ChannelID] = true + } + if cfg.DumpChannelID != 0 { + af.allowedChats[cfg.DumpChannelID] = true + } + } + + return af +} + +// RefreshCache loads allowed and banned lists from MongoDB. +func (f *AccessFilter) RefreshCache(ctx context.Context) error { + f.mu.Lock() + defer f.mu.Unlock() + + if f.col != nil { + cursor, err := f.col.Find(ctx, bson.M{}) + if err == nil { + defer cursor.Close(ctx) + newAllowed := make(map[int64]bool) + if f.cfg != nil { + if f.cfg.ChannelID != 0 { + newAllowed[f.cfg.ChannelID] = true + } + if f.cfg.DumpChannelID != 0 { + newAllowed[f.cfg.DumpChannelID] = true + } + } + + for cursor.Next(ctx) { + var doc struct { + ChatID int64 `bson:"chat_id"` + ID int64 `bson:"_id"` + } + if err := cursor.Decode(&doc); err == nil { + cid := doc.ChatID + if cid == 0 { + cid = doc.ID + } + if cid != 0 { + newAllowed[cid] = true + } + } + } + f.allowedChats = newAllowed + } + } + + if f.bannedCol != nil { + bCursor, err := f.bannedCol.Find(ctx, bson.M{}) + if err == nil { + defer bCursor.Close(ctx) + newBanned := make(map[int64]bool) + for bCursor.Next(ctx) { + var bDoc struct { + ChatID int64 `bson:"chat_id"` + UserID int64 `bson:"user_id"` + ID int64 `bson:"_id"` + } + if err := bCursor.Decode(&bDoc); err == nil { + id := bDoc.ChatID + if id == 0 { + id = bDoc.UserID + } + if id == 0 { + id = bDoc.ID + } + if id != 0 { + newBanned[id] = true + } + } + } + f.bannedChats = newBanned + f.bannedUsers = newBanned + } + } + + f.lastRefresh = time.Now() + return nil +} + +// IsChatAllowed determines if an incoming message or channel is authorized. +func (f *AccessFilter) IsChatAllowed(ctx context.Context, chatID int64) bool { + f.mu.RLock() + if f.bannedChats[chatID] || f.bannedUsers[chatID] { + f.mu.RUnlock() + return false + } + + mode := FilterModeGroupOnly + if f.cfg != nil { + mode = f.cfg.FilterMode + } + if f.mode != FilterModeGroupOnly { + mode = f.mode + } + + if mode == FilterModeAnyone { + f.mu.RUnlock() + return true + } + + if time.Since(f.lastRefresh) > 60*time.Second && f.col != nil { + f.mu.RUnlock() + _ = f.RefreshCache(ctx) + f.mu.RLock() + } + + allowed := f.allowedChats[chatID] + f.mu.RUnlock() + + return allowed +} + +// SetMode updates the active ingestion filter mode. +func (f *AccessFilter) SetMode(mode int) { + f.mu.Lock() + defer f.mu.Unlock() + f.mode = mode + if f.cfg != nil { + f.cfg.FilterMode = mode + } +} + +// GetMode returns the current active filter mode. +func (f *AccessFilter) GetMode() int { + f.mu.RLock() + defer f.mu.RUnlock() + return f.mode +} + +// GetStats returns summary statistics of current filtering policy. +func (f *AccessFilter) GetStats() (mode int, modeName string, allowedCount int, bannedCount int, channelID int64) { + f.mu.RLock() + defer f.mu.RUnlock() + mode = f.mode + modeName = FilterModeToString(f.mode) + allowedCount = len(f.allowedChats) + bannedCount = len(f.bannedChats) + if f.cfg != nil { + channelID = f.cfg.ChannelID + } + return +} + +// AllowChat adds a chat or user ID to allowed contributors. +func (f *AccessFilter) AllowChat(ctx context.Context, chatID int64) error { + if f.col != nil { + opts := options.UpdateOne().SetUpsert(true) + _, err := f.col.UpdateOne(ctx, bson.M{"_id": chatID}, bson.M{ + "$set": bson.M{ + "_id": chatID, + "chat_id": chatID, + "updated_at": float64(time.Now().Unix()), + }, + }, opts) + if err != nil { + return err + } + } + if f.bannedCol != nil { + _, _ = f.bannedCol.DeleteOne(ctx, bson.M{ + "$or": []bson.M{ + {"_id": chatID}, + {"chat_id": chatID}, + {"user_id": chatID}, + }, + }) + } + return f.RefreshCache(ctx) +} + +// DisallowChat removes a chat or user ID from allowed contributors. +func (f *AccessFilter) DisallowChat(ctx context.Context, chatID int64) error { + if f.col != nil { + _, err := f.col.DeleteOne(ctx, bson.M{ + "$or": []bson.M{ + {"_id": chatID}, + {"chat_id": chatID}, + }, + }) + if err != nil { + return err + } + } + return f.RefreshCache(ctx) +} + +// BanChat adds a chat or user ID to banned sources. +func (f *AccessFilter) BanChat(ctx context.Context, id int64, reason string) error { + if f.bannedCol != nil { + opts := options.UpdateOne().SetUpsert(true) + _, err := f.bannedCol.UpdateOne(ctx, bson.M{"_id": id}, bson.M{ + "$set": bson.M{ + "_id": id, + "chat_id": id, + "user_id": id, + "reason": reason, + "updated_at": float64(time.Now().Unix()), + }, + }, opts) + if err != nil { + return err + } + } + if f.col != nil { + _, _ = f.col.DeleteOne(ctx, bson.M{ + "$or": []bson.M{ + {"_id": id}, + {"chat_id": id}, + }, + }) + } + return f.RefreshCache(ctx) +} + +// UnbanChat removes a chat or user ID from banned sources. +func (f *AccessFilter) UnbanChat(ctx context.Context, id int64) error { + if f.bannedCol != nil { + _, err := f.bannedCol.DeleteOne(ctx, bson.M{ + "$or": []bson.M{ + {"_id": id}, + {"chat_id": id}, + {"user_id": id}, + }, + }) + if err != nil { + return err + } + } + return f.RefreshCache(ctx) +} diff --git a/internal/services/alac_service.go b/internal/services/alac_service.go new file mode 100644 index 0000000..f73d109 --- /dev/null +++ b/internal/services/alac_service.go @@ -0,0 +1,372 @@ +package services + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "streamgo/internal/config" + "streamgo/internal/logger" + "streamgo/internal/models" + "streamgo/internal/telegram" +) + +var logALAC = logger.New("alac") + +// ALACService handles on-demand lossless ALAC-to-FLAC transcoding, caching, and LRU eviction. +type ALACService struct { + cfg *config.Config + tgService *telegram.Service + mediaDir string + cacheDir string + maxBytes int64 + maxFiles int + + locks sync.Map // trackID -> *sync.Mutex + pruneMu sync.Mutex +} + +// NewALACService creates and initializes a new ALACService. +func NewALACService(cfg *config.Config, tg *telegram.Service, mediaDir string) *ALACService { + if mediaDir == "" { + mediaDir = "stream_media" + } + cacheDir := filepath.Join(mediaDir, "alac_cache") + _ = os.MkdirAll(cacheDir, 0755) + + maxBytes := int64(5 * 1024 * 1024 * 1024) // 5 GB + maxFiles := 200 + + if cfg != nil { + if cfg.AlacCacheMaxBytes > 0 { + maxBytes = cfg.AlacCacheMaxBytes + } + if cfg.AlacCacheMaxFiles > 0 { + maxFiles = cfg.AlacCacheMaxFiles + } + } + + return &ALACService{ + cfg: cfg, + tgService: tg, + mediaDir: mediaDir, + cacheDir: cacheDir, + maxBytes: maxBytes, + maxFiles: maxFiles, + } +} + +// CacheDir returns the absolute or relative path to the ALAC FLAC cache directory. +func (s *ALACService) CacheDir() string { + return s.cacheDir +} + +// IsALACTrack determines if the given track contains an Apple Lossless Audio Codec stream. +func (s *ALACService) IsALACTrack(track *models.Track) bool { + if track == nil { + return false + } + + typeStr := strings.ToLower(strings.TrimSpace(track.EffectiveType())) + if strings.Contains(typeStr, "alac") { + return true + } + + genreStr := strings.ToLower(strings.TrimSpace(track.Audio.Genre)) + if strings.Contains(genreStr, "alac") { + return true + } + + mimeStr := strings.ToLower(strings.TrimSpace(track.Telegram.MimeType)) + if strings.Contains(mimeStr, "alac") { + return true + } + + nameStr := strings.ToLower(strings.TrimSpace(track.Telegram.FileName)) + if strings.Contains(nameStr, "alac") { + return true + } + + // Check if already transcoded into the FLAC cache + if s.cacheDir != "" { + cacheFile := filepath.Join(s.cacheDir, fmt.Sprintf("%s.flac", track.ID)) + if info, err := os.Stat(cacheFile); err == nil && info.Size() > 10240 { + return true + } + } + + // M4A/MP4 container analysis: + // Lossy AAC never has a bit depth in MediaInfo; ALAC is PCM-based lossless with explicit bit depth (16 or 24-bit). + // In addition, standard 2-channel stereo AAC bitrates max out at 320 kbps, whereas lossless ALAC is typically 500-1400+ kbps. + isM4A := typeStr == "m4a" || typeStr == "mp4" || mimeStr == "audio/mp4" || mimeStr == "audio/x-m4a" || strings.HasSuffix(nameStr, ".m4a") + if isM4A { + if track.Audio.BitDepth != nil && *track.Audio.BitDepth > 0 { + return true + } + if track.Audio.BitrateKbps != nil && *track.Audio.BitrateKbps > 450 { + return true + } + if track.Audio.DurationSec > 0 && track.Telegram.FileSize > 0 { + calcKbps := (track.Telegram.FileSize * 8) / (int64(track.Audio.DurationSec) * 1000) + if calcKbps > 450 { + return true + } + } + } + + return false +} + +// ShouldDecodeALAC decides if a track should be served as a transcoded FLAC stream. +func (s *ALACService) ShouldDecodeALAC(r *http.Request, track *models.Track) bool { + if track == nil { + return false + } + + isAlac := s.IsALACTrack(track) + + // Explicit format override via query parameter + fmtParam := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format"))) + if fmtParam == "flac" || fmtParam == "decoded" || fmtParam == "transcode" || fmtParam == "pcm" || fmtParam == "wav" { + return isAlac || !strings.Contains(strings.ToLower(track.Audio.Type), "flac") + } + if fmtParam == "raw" || fmtParam == "alac" || fmtParam == "original" || fmtParam == "source" { + return false + } + + if !isAlac { + return false + } + + // Decode parameter check + decodeParam := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("decode"))) + if decodeParam == "" { + decodeParam = strings.ToLower(strings.TrimSpace(r.URL.Query().Get("transcode"))) + } + if decodeParam == "" { + decodeParam = strings.ToLower(strings.TrimSpace(r.URL.Query().Get("alac_decode"))) + } + if decodeParam == "1" || decodeParam == "true" || decodeParam == "yes" || decodeParam == "flac" { + return true + } + if decodeParam == "0" || decodeParam == "false" || decodeParam == "no" { + return false + } + + // Check client header for native app bypass + clientHdr := strings.ToLower(strings.TrimSpace(r.Header.Get("X-StreamX-Client"))) + if clientHdr == "" { + clientHdr = strings.ToLower(strings.TrimSpace(r.Header.Get("x-streamx-client"))) + } + if strings.Contains(clientHdr, "native") || strings.Contains(clientHdr, "android-native") || strings.Contains(clientHdr, "exoplayer") { + return false + } + + // Browser detection + ua := strings.ToLower(r.Header.Get("User-Agent")) + secDest := strings.ToLower(r.Header.Get("Sec-Fetch-Dest")) + secMode := strings.ToLower(r.Header.Get("Sec-Fetch-Mode")) + secUA := strings.ToLower(r.Header.Get("Sec-Ch-Ua")) + secPlatform := strings.ToLower(r.Header.Get("Sec-Ch-Ua-Platform")) + + isBrowser := secDest != "" || secMode != "" || secUA != "" || secPlatform != "" || + strings.Contains(ua, "mozilla") || strings.Contains(ua, "chrome") || + strings.Contains(ua, "firefox") || strings.Contains(ua, "safari") || + strings.Contains(ua, "edge") || strings.Contains(ua, "edg") || + strings.Contains(ua, "opera") + + if !isBrowser { + return false + } + + // Pure Safari on Apple OS has native ALAC decoding + isAppleOS := (strings.Contains(ua, "macintosh") || strings.Contains(ua, "mac os x") || + strings.Contains(ua, "iphone") || strings.Contains(ua, "ipad") || strings.Contains(ua, "ipod")) && + !strings.Contains(ua, "windows") && !strings.Contains(ua, "android") + + if isAppleOS && (strings.Contains(ua, "safari") || strings.Contains(ua, "applewebkit")) && + !strings.Contains(ua, "chrome") && !strings.Contains(ua, "edg") && !strings.Contains(ua, "firefox") { + return false + } + + // Chromium, Firefox, Windows, Linux, Android browsers all lack ALAC decoder: + return true +} + +// EnsureDecodedFLAC ensures a bit-perfect FLAC transcode of the ALAC track is ready in local cache. +func (s *ALACService) EnsureDecodedFLAC(ctx context.Context, track *models.Track) (string, error) { + if track == nil { + return "", errors.New("track is nil") + } + + targetFile := filepath.Join(s.cacheDir, fmt.Sprintf("%s.flac", track.ID)) + + // 1. Check if valid FLAC already exists on disk + if info, err := os.Stat(targetFile); err == nil && info.Size() > 10240 { + s.TouchFile(targetFile) + return targetFile, nil + } + + // 2. Acquire per-track lock to prevent parallel duplicate transcoding + actualLock, _ := s.locks.LoadOrStore(track.ID, &sync.Mutex{}) + mtx := actualLock.(*sync.Mutex) + mtx.Lock() + defer mtx.Unlock() + + // 3. Double-check after lock + if info, err := os.Stat(targetFile); err == nil && info.Size() > 10240 { + s.TouchFile(targetFile) + return targetFile, nil + } + + if s.tgService == nil || !s.tgService.IsReady() { + return "", errors.New("telegram service not ready") + } + + nowMs := time.Now().UnixMilli() + pid := os.Getpid() + tempSrc := filepath.Join(s.cacheDir, fmt.Sprintf("%s.src_%d_%d.m4a", track.ID, pid, nowMs)) + tempFlac := filepath.Join(s.cacheDir, fmt.Sprintf("%s.tmp_%d_%d.flac", track.ID, pid, nowMs)) + + defer func() { + _ = os.Remove(tempSrc) + _ = os.Remove(tempFlac) + }() + + logALAC.Infof("[ALAC-Decode] Fetching source media for track %s (%s - %s)", track.ID, track.Audio.Artist, track.Audio.Title) + + // 4. Download source audio from Telegram + sf, err := os.Create(tempSrc) + if err != nil { + return "", fmt.Errorf("failed to create temp source file: %w", err) + } + + dlErr := s.tgService.DownloadTrack(ctx, track, sf) + _ = sf.Close() + if dlErr != nil { + return "", fmt.Errorf("failed to download source track from telegram: %w", dlErr) + } + + // 5. Transcode ALAC -> FLAC with -compression_level 0 for low CPU latency + logALAC.Infof("[ALAC-Decode] Transcoding %s -> %s", tempSrc, tempFlac) + cmd := exec.CommandContext(ctx, "ffmpeg", + "-hide_banner", + "-loglevel", "error", + "-y", + "-i", tempSrc, + "-vn", + "-c:a", "flac", + "-compression_level", "0", + "-f", "flac", + tempFlac, + ) + + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("ffmpeg transcode failed: %v (output: %s)", err, strings.TrimSpace(string(out))) + } + + info, err := os.Stat(tempFlac) + if err != nil || info.Size() <= 10240 { + return "", fmt.Errorf("transcoded file invalid or too small: %w", err) + } + + // 6. Atomically move completed FLAC to target path + if err := os.Rename(tempFlac, targetFile); err != nil { + return "", fmt.Errorf("failed to rename temp flac to target: %w", err) + } + + s.TouchFile(targetFile) + logALAC.Infof("[ALAC-Decode] Transcode completed successfully: %s (%d bytes)", targetFile, info.Size()) + + // 7. Non-blocking asynchronous LRU cache pruning + go s.PruneCacheIfNeeded() + + return targetFile, nil +} + +// TouchFile updates the access and modification timestamp of a cached file for LRU tracking. +func (s *ALACService) TouchFile(filePath string) { + now := time.Now() + _ = os.Chtimes(filePath, now, now) +} + +type cacheEntry struct { + path string + modTime time.Time + size int64 +} + +// PruneCacheIfNeeded removes the least recently accessed files when cache limits are exceeded. +func (s *ALACService) PruneCacheIfNeeded() { + s.pruneMu.Lock() + defer s.pruneMu.Unlock() + + entries, err := os.ReadDir(s.cacheDir) + if err != nil { + return + } + + var files []cacheEntry + var totalSize int64 + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + // Only manage completed .flac files; do not touch active in-flight temporary files + if !strings.HasSuffix(name, ".flac") || strings.Contains(name, ".tmp_") { + continue + } + + fullPath := filepath.Join(s.cacheDir, name) + info, err := entry.Info() + if err != nil { + continue + } + + files = append(files, cacheEntry{ + path: fullPath, + modTime: info.ModTime(), + size: info.Size(), + }) + totalSize += info.Size() + } + + if len(files) <= s.maxFiles && totalSize <= s.maxBytes { + return + } + + // Sort ascending by modification time (oldest accessed first) + sort.Slice(files, func(i, j int) bool { + return files[i].modTime.Before(files[j].modTime) + }) + + prunedCount := 0 + var prunedBytes int64 + + for _, f := range files { + if len(files)-prunedCount <= s.maxFiles && totalSize <= s.maxBytes { + break + } + + if err := os.Remove(f.path); err == nil { + prunedCount++ + prunedBytes += f.size + totalSize -= f.size + } + } + + if prunedCount > 0 { + logALAC.Infof("[ALAC-Cache-LRU] Pruned %d stale track(s), freed %.2f MB", prunedCount, float64(prunedBytes)/(1024*1024)) + } +} diff --git a/internal/services/alac_wav_streamer.go b/internal/services/alac_wav_streamer.go new file mode 100644 index 0000000..91a21c6 --- /dev/null +++ b/internal/services/alac_wav_streamer.go @@ -0,0 +1,217 @@ +package services + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "os" + "os/exec" + "streamgo/internal/models" +) + +type ALACWAVStreamer struct { + track *models.Track + totalSize int64 + offset int64 + port string + cacheFile string + + cmd *exec.Cmd + stdout io.ReadCloser + cancel context.CancelFunc + + header []byte +} + +// NewALACWAVStreamer creates a new ReadSeekCloser that transcodes an ALAC track to WAV on the fly. +func NewALACWAVStreamer(track *models.Track, port string, cacheFile string) *ALACWAVStreamer { + durationSec := track.Audio.DurationSec + if durationSec <= 0 { + durationSec = 300 // fallback 5 mins + } + + dataSize := int64(durationSec) * 176400 // 44100 Hz * 2 channels * 2 bytes (16-bit) + totalSize := 44 + dataSize + + header := make([]byte, 44) + copy(header[0:4], []byte("RIFF")) + binary.LittleEndian.PutUint32(header[4:8], uint32(36+dataSize)) + copy(header[8:12], []byte("WAVE")) + copy(header[12:16], []byte("fmt ")) + binary.LittleEndian.PutUint32(header[16:20], 16) + binary.LittleEndian.PutUint16(header[20:22], 1) + binary.LittleEndian.PutUint16(header[22:24], 2) + binary.LittleEndian.PutUint32(header[24:28], 44100) + binary.LittleEndian.PutUint32(header[28:32], 176400) + binary.LittleEndian.PutUint16(header[32:34], 4) + binary.LittleEndian.PutUint16(header[34:36], 16) + copy(header[36:40], []byte("data")) + binary.LittleEndian.PutUint32(header[40:44], uint32(dataSize)) + + return &ALACWAVStreamer{ + track: track, + totalSize: totalSize, + port: port, + cacheFile: cacheFile, + header: header, + } +} + +func (s *ALACWAVStreamer) Seek(offset int64, whence int) (int64, error) { + var newOffset int64 + switch whence { + case io.SeekStart: + newOffset = offset + case io.SeekCurrent: + newOffset = s.offset + offset + case io.SeekEnd: + newOffset = s.totalSize + offset + default: + return 0, errors.New("invalid whence") + } + + if newOffset < 0 { + return 0, errors.New("negative offset") + } + + if newOffset != s.offset { + s.closeFFmpeg() + s.offset = newOffset + } + + return s.offset, nil +} + +func (s *ALACWAVStreamer) closeFFmpeg() { + if s.cancel != nil { + s.cancel() + s.cancel = nil + } + if s.stdout != nil { + s.stdout.Close() + s.stdout = nil + } + if s.cmd != nil { + s.cmd.Wait() + s.cmd = nil + } +} + +func (s *ALACWAVStreamer) startFFmpeg() error { + ctx, cancel := context.WithCancel(context.Background()) + s.cancel = cancel + + audioOffset := s.offset + if audioOffset < 44 { + audioOffset = 44 + } + + // Align to 4-byte sample frame boundary (16-bit stereo = 2 bytes * 2 channels) + alignedAudioOffset := 44 + ((audioOffset-44)/4)*4 + timeOffset := float64(alignedAudioOffset-44) / 176400.0 + + // Use local FLAC file directly if it exists and is complete; otherwise stream raw track on-the-fly + inputSource := fmt.Sprintf("http://127.0.0.1:%s/stream/%s?format=raw", s.port, s.track.ID) + if s.cacheFile != "" { + if info, err := os.Stat(s.cacheFile); err == nil && info.Size() > 10240 { + inputSource = s.cacheFile + } + } + + args := []string{ + "-hide_banner", "-loglevel", "error", + } + if timeOffset > 0 { + args = append(args, "-ss", fmt.Sprintf("%.6f", timeOffset)) + } + args = append(args, + "-i", inputSource, + "-vn", + "-c:a", "pcm_s16le", + "-ar", "44100", + "-ac", "2", + "-f", "s16le", + "pipe:1", + ) + + s.cmd = exec.CommandContext(ctx, "ffmpeg", args...) + + stdout, err := s.cmd.StdoutPipe() + if err != nil { + cancel() + return err + } + s.stdout = stdout + + if err := s.cmd.Start(); err != nil { + cancel() + return err + } + + // With -f s16le, FFmpeg writes pure raw PCM audio samples with NO headers. + // All bytes from s.stdout are pure PCM audio frames. + return nil +} + +func (s *ALACWAVStreamer) Read(p []byte) (n int, err error) { + if s.offset >= s.totalSize { + return 0, io.EOF + } + + bytesRead := 0 + + // 1. Serve synthesized header if offset is within [0, 44) + if s.offset < 44 { + copied := copy(p, s.header[s.offset:]) + s.offset += int64(copied) + bytesRead += copied + + if len(p) == bytesRead { + return bytesRead, nil + } + // If buffer still has space, continue to read from ffmpeg + p = p[copied:] + } + + // 2. Serve from ffmpeg + if s.stdout == nil { + if err := s.startFFmpeg(); err != nil { + return bytesRead, err + } + } + + // Cap read to totalSize + remaining := s.totalSize - s.offset + if int64(len(p)) > remaining { + p = p[:remaining] + } + + n2, err2 := s.stdout.Read(p) + if err2 == io.EOF { + // ffmpeg finished early (e.g., duration was slightly less than estimated). + // Pad with zeros to fulfill the promised Content-Length. + s.closeFFmpeg() + for i := n2; i < len(p); i++ { + p[i] = 0 + } + n2 = len(p) + err2 = nil + } + + s.offset += int64(n2) + bytesRead += n2 + + return bytesRead, err2 +} + +func (s *ALACWAVStreamer) Close() error { + s.closeFFmpeg() + return nil +} + +// GetTotalSize returns the exact pre-calculated size of the WAV stream. +func (s *ALACWAVStreamer) GetTotalSize() int64 { + return s.totalSize +} diff --git a/internal/services/artist_album_service.go b/internal/services/artist_album_service.go new file mode 100644 index 0000000..b50379f --- /dev/null +++ b/internal/services/artist_album_service.go @@ -0,0 +1,222 @@ +package services + +import ( + "context" + + "streamgo/internal/models" + "streamgo/internal/repository" +) + +// ArtistAlbumService orchestrates artist and album operations. +type ArtistAlbumService struct { + repo repository.ArtistAlbumRepository + trackRepo repository.TrackRepository +} + +// NewArtistAlbumService creates a new ArtistAlbumService. +func NewArtistAlbumService(repo repository.ArtistAlbumRepository, trackRepo repository.TrackRepository) *ArtistAlbumService { + return &ArtistAlbumService{ + repo: repo, + trackRepo: trackRepo, + } +} + +// ListArtists returns a paginated list of artists matching Python schema. +func (s *ArtistAlbumService) ListArtists(ctx context.Context, page, perPage int, refresh bool) (*models.ArtistsResponse, error) { + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 50 + } + if perPage > 200 { + perPage = 200 + } + + artists, total, err := s.repo.ListArtists(ctx, page, perPage, refresh) + if err != nil { + return nil, err + } + + return &models.ArtistsResponse{ + Ok: true, + Page: page, + PerPage: perPage, + Total: total, + Items: artists, + }, nil +} + +// GetArtistDetail returns detailed artist information with top tracks and albums. +func (s *ArtistAlbumService) GetArtistDetail(ctx context.Context, id string) (*models.ArtistDetail, error) { + artist, err := s.repo.GetArtistByID(ctx, id) + if err != nil { + return nil, err + } + if artist == nil { + return nil, nil + } + + tracks, err := s.repo.GetArtistTracks(ctx, artist.Name, 50) + if err != nil { + return nil, err + } + + browseItems := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + browseItems = append(browseItems, t.ToBrowseItem()) + } + + popularTracks := browseItems + if len(popularTracks) > 10 { + popularTracks = popularTracks[:10] + } + + albums, err := s.repo.GetArtistAlbums(ctx, artist.Name) + if err != nil { + return nil, err + } + + singles := make([]*models.BrowseItem, 0) + for _, t := range browseItems { + if t.Album == "" { + singles = append(singles, t) + } + } + + return &models.ArtistDetail{ + Ok: true, + Artist: artist, + PopularTracks: popularTracks, + TopTracks: popularTracks, + Releases: albums, + Albums: albums, + Singles: singles, + Tracks: browseItems, + }, nil +} + +// GetArtistTracks returns the top tracks for a specific artist ID. +func (s *ArtistAlbumService) GetArtistTracks(ctx context.Context, id string, limit int) ([]*models.BrowseItem, error) { + artist, err := s.repo.GetArtistByID(ctx, id) + if err != nil { + return nil, err + } + if artist == nil { + return nil, nil + } + + tracks, err := s.repo.GetArtistTracks(ctx, artist.Name, limit) + if err != nil { + return nil, err + } + + items := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + items = append(items, t.ToBrowseItem()) + } + return items, nil +} + +// GetArtistTracksPaginated returns paginated tracks for a specific artist ID. +func (s *ArtistAlbumService) GetArtistTracksPaginated(ctx context.Context, id string, page, perPage int) ([]*models.BrowseItem, int64, error) { + artist, err := s.repo.GetArtistByID(ctx, id) + if err != nil { + return nil, 0, err + } + if artist == nil { + return nil, 0, nil + } + + tracks, total, err := s.repo.GetArtistTracksPaginated(ctx, artist.Name, page, perPage) + if err != nil { + return nil, 0, err + } + + items := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + items = append(items, t.ToBrowseItem()) + } + return items, total, nil +} + +// ListAlbums returns a paginated list of albums matching Python schema. +func (s *ArtistAlbumService) ListAlbums(ctx context.Context, page, perPage int, artistFilter string, refresh bool) (*models.AlbumsResponse, error) { + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 50 + } + if perPage > 200 { + perPage = 200 + } + + albums, total, err := s.repo.ListAlbums(ctx, page, perPage, artistFilter, refresh) + if err != nil { + return nil, err + } + + return &models.AlbumsResponse{ + Ok: true, + Page: page, + PerPage: perPage, + Total: total, + Items: albums, + }, nil +} + +// GetAlbumDetail returns full album metadata along with its tracklist. +func (s *ArtistAlbumService) GetAlbumDetail(ctx context.Context, id string) (*models.AlbumDetail, error) { + album, err := s.repo.GetAlbumByID(ctx, id) + if err != nil { + return nil, err + } + if album == nil { + return nil, nil + } + + tracks, err := s.repo.GetAlbumTracks(ctx, album.ID) + if err != nil { + return nil, err + } + + browseItems := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + browseItems = append(browseItems, t.ToBrowseItem()) + } + + return &models.AlbumDetail{ + Ok: true, + Album: album, + Tracks: browseItems, + }, nil +} + +// GetAlbumTracks returns only the tracks of an album. +func (s *ArtistAlbumService) GetAlbumTracks(ctx context.Context, id string) ([]*models.BrowseItem, error) { + tracks, err := s.repo.GetAlbumTracks(ctx, id) + if err != nil { + return nil, err + } + + browseItems := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + browseItems = append(browseItems, t.ToBrowseItem()) + } + return browseItems, nil +} + +// GetAlbumTracksPaginated returns paginated tracks for an album. +func (s *ArtistAlbumService) GetAlbumTracksPaginated(ctx context.Context, id string, page, perPage int) ([]*models.BrowseItem, int64, error) { + tracks, total, err := s.repo.GetAlbumTracksPaginated(ctx, id, page, perPage) + if err != nil { + return nil, 0, err + } + + browseItems := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + browseItems = append(browseItems, t.ToBrowseItem()) + } + return browseItems, total, nil +} diff --git a/internal/services/artwork_service.go b/internal/services/artwork_service.go new file mode 100644 index 0000000..7151e85 --- /dev/null +++ b/internal/services/artwork_service.go @@ -0,0 +1,556 @@ +package services + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "streamgo/internal/logger" +) + +var logArtwork = logger.New("artwork") + +// ArtworkService extracts and compresses album artwork from audio file bytes. +type ArtworkService struct{} + +// NewArtworkService creates a new ArtworkService. +func NewArtworkService() *ArtworkService { + return &ArtworkService{} +} + +// ExtractArtwork attempts to extract embedded album artwork from an audio chunk. +// It detects container formats (MP4/ALAC/M4A, FLAC, ID3/MP3) automatically or via hint. +func (s *ArtworkService) ExtractArtwork(data []byte, formatHint string) ([]byte, string, error) { + if len(data) == 0 { + return nil, "", fmt.Errorf("empty audio data") + } + + hint := strings.ToLower(strings.TrimSpace(formatHint)) + + // 1. FLAC Container + if hint == "flac" || (len(data) >= 4 && string(data[:4]) == "fLaC") { + img, mime, _, _, err := ExtractFLACPicture(data) + if err == nil && len(img) > 0 { + return img, mime, nil + } + } + + // 2. MP4 / ALAC / M4A Container (inspect for covr or moov) + if hint == "alac" || hint == "m4a" || bytes.Contains(data, []byte("covr")) || bytes.Contains(data, []byte("moov")) { + img, mime, err := ExtractMP4Cover(data) + if err == nil && len(img) > 0 { + return img, mime, nil + } + } + + // 3. ID3v2 (MP3, AAC) + if hint == "mp3" || (len(data) >= 3 && string(data[:3]) == "ID3") || bytes.Contains(data, []byte("APIC")) { + img, mime, err := ExtractID3Picture(data) + if err == nil && len(img) > 0 { + return img, mime, nil + } + } + + return nil, "", fmt.Errorf("no embedded artwork found in audio chunk") +} + +// IsArtworkTruncated inspects container metadata headers (FLAC, MP4/ALAC, or ID3) +// to detect if embedded artwork or container tags extend beyond the currently downloaded slice. +func (s *ArtworkService) IsArtworkTruncated(data []byte, formatHint string) bool { + if len(data) < 4 { + return false + } + hint := strings.ToLower(strings.TrimSpace(formatHint)) + + // 1. FLAC Container + if hint == "flac" || (len(data) >= 4 && string(data[:4]) == "fLaC") { + if len(data) >= 4 && string(data[:4]) == "fLaC" { + offset := 4 + for offset+4 <= len(data) { + header := data[offset] + isLast := (header & 0x80) != 0 + blockType := header & 0x7F + length := int(binary.BigEndian.Uint32([]byte{0, data[offset+1], data[offset+2], data[offset+3]})) + offset += 4 + + if offset+length > len(data) { + // Either a picture block is cut off, or subsequent metadata blocks (potentially picture) are cut off + return true + } + if blockType == 6 { + // PICTURE block is fully present in this slice (or corrupted) + return false + } + if isLast { + // Reached last metadata block without encountering PICTURE block + return false + } + offset += length + } + return true // Slice ended inside block header before isLast + } + } + + // 2. MP4 / ALAC / M4A Container + if hint == "alac" || hint == "m4a" || bytes.Contains(data, []byte("covr")) || bytes.Contains(data, []byte("moov")) || bytes.Contains(data, []byte("ftyp")) { + if needed, ok := InspectMP4CoverNeeds(data); ok { + return needed > int64(len(data)) + } + if moovIdx := bytes.Index(data, []byte("moov")); moovIdx >= 4 { + moovSize := int64(binary.BigEndian.Uint32(data[moovIdx-4 : moovIdx])) + if moovSize == 1 && moovIdx+12 <= len(data) { + moovSize = int64(binary.BigEndian.Uint64(data[moovIdx+4 : moovIdx+12])) + } + if moovSize > 0 && int64(moovIdx-4)+moovSize > int64(len(data)) { + return true + } + } + } + + // 3. ID3v2 (MP3, AAC) + if hint == "mp3" || (len(data) >= 3 && string(data[:3]) == "ID3") || bytes.Contains(data, []byte("APIC")) { + if len(data) >= 10 && string(data[:3]) == "ID3" { + tagSize := int(data[6]&0x7F)<<21 | int(data[7]&0x7F)<<14 | int(data[8]&0x7F)<<7 | int(data[9]&0x7F) + if int64(10+tagSize) > int64(len(data)) { + return true + } + } + if apicIdx := bytes.Index(data, []byte("APIC")); apicIdx != -1 { + if apicIdx+10 > len(data) { + return true + } + isV4 := len(data) >= 4 && data[0] == 'I' && data[1] == 'D' && data[2] == '3' && data[3] == 4 + var frameSize int + if isV4 { + b := data[apicIdx+4 : apicIdx+8] + frameSize = int(b[0]&0x7F)<<21 | int(b[1]&0x7F)<<14 | int(b[2]&0x7F)<<7 | int(b[3]&0x7F) + } else { + frameSize = int(binary.BigEndian.Uint32(data[apicIdx+4 : apicIdx+8])) + } + if apicIdx+10+frameSize > len(data) { + return true + } + } + } + + return false +} + +// SanitizeAndValidateWebP validates WebP container structure and payload integrity. +// If the WebP bitstream has missing RIFF length or trailing muxer bytes (common when muxers cannot seek), +// it repairs the header and strips trailing bytes. +// Returns the sanitized byte slice, image dimensions, or an error if invalid/corrupt. +func SanitizeAndValidateWebP(data []byte) ([]byte, int, int, error) { + if len(data) < 20 { + return nil, 0, 0, fmt.Errorf("data too short for WebP (%d bytes)", len(data)) + } + if string(data[0:4]) != "RIFF" || string(data[8:12]) != "WEBP" { + return nil, 0, 0, fmt.Errorf("missing RIFF/WEBP magic bytes") + } + + fourCC := string(data[12:16]) + chunkLen := binary.LittleEndian.Uint32(data[16:20]) + + switch fourCC { + case "VP8 ": + // Simple lossy WebP + expectedLen := 12 + 8 + int(chunkLen) + int(chunkLen%2) + if len(data) < expectedLen { + return nil, 0, 0, fmt.Errorf("truncated VP8 chunk: have %d bytes, need %d", len(data), expectedLen) + } + + result := data + if len(result) > expectedLen { + result = result[:expectedLen] + } + + riffSize := binary.LittleEndian.Uint32(result[4:8]) + expectedRiffSize := uint32(len(result) - 8) + if riffSize != expectedRiffSize { + newBuf := make([]byte, len(result)) + copy(newBuf, result) + binary.LittleEndian.PutUint32(newBuf[4:8], expectedRiffSize) + result = newBuf + } + + payload := result[20:] + if len(payload) < 10 { + return nil, 0, 0, fmt.Errorf("VP8 payload too short (%d bytes)", len(payload)) + } + // Bit 0 of payload[0] is frame type (0 = keyframe, 1 = interframe) + if (payload[0] & 0x01) != 0 { + return nil, 0, 0, fmt.Errorf("VP8 is not a keyframe") + } + // Start code bytes 3, 4, 5: 0x9d 0x01 0x2a + if payload[3] != 0x9d || payload[4] != 0x01 || payload[5] != 0x2a { + return nil, 0, 0, fmt.Errorf("invalid VP8 keyframe start code") + } + width := int(binary.LittleEndian.Uint16(payload[6:8]) & 0x3fff) + height := int(binary.LittleEndian.Uint16(payload[8:10]) & 0x3fff) + if width <= 0 || height <= 0 { + return nil, 0, 0, fmt.Errorf("invalid VP8 dimensions: %dx%d", width, height) + } + return result, width, height, nil + + case "VP8L": + // Lossless WebP + expectedLen := 12 + 8 + int(chunkLen) + int(chunkLen%2) + if len(data) < expectedLen { + return nil, 0, 0, fmt.Errorf("truncated VP8L chunk: have %d bytes, need %d", len(data), expectedLen) + } + + result := data + if len(result) > expectedLen { + result = result[:expectedLen] + } + + riffSize := binary.LittleEndian.Uint32(result[4:8]) + expectedRiffSize := uint32(len(result) - 8) + if riffSize != expectedRiffSize { + newBuf := make([]byte, len(result)) + copy(newBuf, result) + binary.LittleEndian.PutUint32(newBuf[4:8], expectedRiffSize) + result = newBuf + } + + payload := result[20:] + if len(payload) < 5 || payload[0] != 0x2f { + return nil, 0, 0, fmt.Errorf("invalid VP8L signature byte") + } + b0, b1, b2, b3 := uint32(payload[1]), uint32(payload[2]), uint32(payload[3]), uint32(payload[4]) + val := b0 | (b1 << 8) | (b2 << 16) | (b3 << 24) + width := int(val&0x3fff) + 1 + height := int((val>>14)&0x3fff) + 1 + if width <= 0 || height <= 0 { + return nil, 0, 0, fmt.Errorf("invalid VP8L dimensions: %dx%d", width, height) + } + return result, width, height, nil + + case "VP8X": + // Extended WebP + if len(data) < 30 { + return nil, 0, 0, fmt.Errorf("VP8X header too short") + } + result := data + riffSize := binary.LittleEndian.Uint32(result[4:8]) + expectedRiffSize := uint32(len(result) - 8) + if riffSize != expectedRiffSize { + newBuf := make([]byte, len(result)) + copy(newBuf, result) + binary.LittleEndian.PutUint32(newBuf[4:8], expectedRiffSize) + result = newBuf + } + width := int(uint32(result[24]) | uint32(result[25])<<8 | uint32(result[26])<<16) + 1 + height := int(uint32(result[27]) | uint32(result[28])<<8 | uint32(result[29])<<16) + 1 + if width <= 0 || height <= 0 { + return nil, 0, 0, fmt.Errorf("invalid VP8X dimensions: %dx%d", width, height) + } + return result, width, height, nil + + default: + return nil, 0, 0, fmt.Errorf("unsupported WebP chunk type: %q", fourCC) + } +} + +// CompressToWebP compresses and resizes raw image bytes into WebP format using FFmpeg. +// It writes to a temporary file so FFmpeg can seek and finalize standard RIFF headers, +// then sanitizes and verifies the output bitstream to ensure zero corruption. +// maxDim sets maximum width/height (aspect ratio preserved). quality is 0-100 (default 80). +func (s *ArtworkService) CompressToWebP(ctx context.Context, raw []byte, maxDim int, quality int) ([]byte, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("empty image bytes") + } + if maxDim <= 0 { + maxDim = 1000 + } + if quality <= 0 || quality > 100 { + quality = 80 + } + + start := time.Now() + + tmpOut, err := os.CreateTemp("", "streamgo_webp_*.webp") + if err != nil { + return nil, fmt.Errorf("failed to create temp webp file: %w", err) + } + tmpPath := tmpOut.Name() + _ = tmpOut.Close() + defer os.Remove(tmpPath) + + args := []string{ + "-hide_banner", + "-loglevel", "error", + "-y", + "-i", "pipe:0", + "-vf", fmt.Sprintf("scale='min(%d,iw)':-1", maxDim), + "-c:v", "libwebp", + "-quality", fmt.Sprintf("%d", quality), + tmpPath, + } + + cmd := exec.CommandContext(ctx, "ffmpeg", args...) + cmd.Stdin = bytes.NewReader(raw) + var errBuf bytes.Buffer + cmd.Stderr = &errBuf + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("ffmpeg webp compression failed: %w: %s", err, errBuf.String()) + } + + outBytes, err := os.ReadFile(tmpPath) + if err != nil { + return nil, fmt.Errorf("failed to read webp output file: %w", err) + } + + sanitized, w, h, err := SanitizeAndValidateWebP(outBytes) + if err != nil { + return nil, fmt.Errorf("generated webp failed validation: %w", err) + } + + dur := time.Since(start) + logArtwork.Debugf("Compressed artwork to WebP (%dx%d): %d bytes -> %d bytes in %v", + w, h, len(raw), len(sanitized), dur.Round(time.Millisecond)) + + return sanitized, nil +} + +// ExtractMP4Cover extracts embedded cover art from MP4 / M4A / ALAC atom structure (ilst -> covr -> data). +func ExtractMP4Cover(data []byte) ([]byte, string, error) { + covrIdx := bytes.Index(data, []byte("covr")) + if covrIdx == -1 { + return nil, "", fmt.Errorf("covr atom not found in slice") + } + + dataIdx := bytes.Index(data[covrIdx:], []byte("data")) + if dataIdx == -1 { + return nil, "", fmt.Errorf("data atom not found inside covr") + } + absDataIdx := covrIdx + dataIdx + + if absDataIdx < 4 || absDataIdx+16 > len(data) { + return nil, "", fmt.Errorf("truncated data atom header") + } + + dataLen := int(binary.BigEndian.Uint32(data[absDataIdx-4 : absDataIdx])) + typeIndicator := binary.BigEndian.Uint32(data[absDataIdx+4 : absDataIdx+8]) + + imgStart := absDataIdx + 12 + payloadLen := dataLen - 16 + + if payloadLen > 0 && imgStart+payloadLen <= len(data) { + imgData := data[imgStart : imgStart+payloadLen] + mime := "image/jpeg" + if typeIndicator == 14 || bytes.HasPrefix(imgData, []byte("\x89PNG")) { + mime = "image/png" + } + return imgData, mime, nil + } + + // Fallback scanning for JPEG magic markers if atom length was slightly off + if imgStart < len(data) { + jpegStart := bytes.Index(data[imgStart:], []byte("\xff\xd8\xff")) + if jpegStart != -1 { + absJpegStart := imgStart + jpegStart + jpegEnd := bytes.Index(data[absJpegStart:], []byte("\xff\xd9")) + if jpegEnd != -1 { + return data[absJpegStart : absJpegStart+jpegEnd+2], "image/jpeg", nil + } + } + } + + return nil, "", fmt.Errorf("could not extract valid image payload from covr atom (payloadLen=%d, available=%d)", payloadLen, len(data)-imgStart) +} + +// InspectMP4CoverNeeds determines the total byte length required from file start to capture the entire cover atom. +func InspectMP4CoverNeeds(data []byte) (int64, bool) { + covrIdx := bytes.Index(data, []byte("covr")) + if covrIdx == -1 { + return 0, false + } + + dataIdx := bytes.Index(data[covrIdx:], []byte("data")) + if dataIdx == -1 { + return 0, false + } + absDataIdx := covrIdx + dataIdx + + if absDataIdx < 4 || absDataIdx+16 > len(data) { + return 0, false + } + + dataLen := int64(binary.BigEndian.Uint32(data[absDataIdx-4 : absDataIdx])) + imgStart := int64(absDataIdx + 12) + payloadLen := dataLen - 16 + + if payloadLen > 0 { + return imgStart + payloadLen, true + } + return 0, false +} + +// ExtractFLACPicture extracts the METADATA_BLOCK_PICTURE from raw FLAC chunk bytes. +func ExtractFLACPicture(data []byte) ([]byte, string, int, int, error) { + if len(data) < 4 || string(data[:4]) != "fLaC" { + return nil, "", 0, 0, fmt.Errorf("invalid FLAC header") + } + + offset := 4 + for offset < len(data) { + if offset+4 > len(data) { + break + } + + header := data[offset] + isLast := (header & 0x80) != 0 + blockType := header & 0x7F + length := int(binary.BigEndian.Uint32([]byte{0, data[offset+1], data[offset+2], data[offset+3]})) + offset += 4 + + if offset+length > len(data) { + return nil, "", 0, 0, fmt.Errorf("truncated FLAC metadata block") + } + + if blockType == 6 { // METADATA_BLOCK_PICTURE + picData := data[offset : offset+length] + if len(picData) < 32 { + return nil, "", 0, 0, fmt.Errorf("picture block too short") + } + + mimeLen := int(binary.BigEndian.Uint32(picData[4:8])) + if 8+mimeLen+4 > len(picData) { + return nil, "", 0, 0, fmt.Errorf("corrupt picture mime") + } + mime := string(picData[8 : 8+mimeLen]) + + descOffset := 8 + mimeLen + descLen := int(binary.BigEndian.Uint32(picData[descOffset : descOffset+4])) + + metaOffset := descOffset + 4 + descLen + if metaOffset+16 > len(picData) { + return nil, "", 0, 0, fmt.Errorf("corrupt picture dimensions") + } + width := int(binary.BigEndian.Uint32(picData[metaOffset : metaOffset+4])) + height := int(binary.BigEndian.Uint32(picData[metaOffset+4 : metaOffset+8])) + + dataLenOffset := metaOffset + 16 + if dataLenOffset+4 > len(picData) { + return nil, "", 0, 0, fmt.Errorf("corrupt picture data length") + } + dataLen := int(binary.BigEndian.Uint32(picData[dataLenOffset : dataLenOffset+4])) + + imgStart := dataLenOffset + 4 + if imgStart+dataLen > len(picData) { + return nil, "", 0, 0, fmt.Errorf("corrupt picture payload") + } + + return picData[imgStart : imgStart+dataLen], mime, width, height, nil + } + + if isLast { + break + } + offset += length + } + + return nil, "", 0, 0, fmt.Errorf("no picture block found in FLAC metadata") +} + +// ExtractID3Picture extracts the APIC (attached picture) frame from ID3v2 tag bytes. +// Supports both ID3v2.3 (32-bit big-endian frame size) and ID3v2.4 (28-bit syncsafe frame size), +// respects declared image MIME types (e.g. image/png vs image/jpeg), and cleanly truncates at image EOF. +func ExtractID3Picture(data []byte) ([]byte, string, error) { + apicIdx := bytes.Index(data, []byte("APIC")) + if apicIdx == -1 { + return nil, "", fmt.Errorf("APIC frame not found") + } + + // APIC frame header is 10 bytes: + // 'APIC' (4) + Size (4) + Flags (2) + frameHeader := data[apicIdx:] + if len(frameHeader) < 10 { + return nil, "", fmt.Errorf("truncated APIC header") + } + + // Check ID3 major version (ID3v2.4 uses syncsafe frame sizes) + isV4 := len(data) >= 4 && data[0] == 'I' && data[1] == 'D' && data[2] == '3' && data[3] == 4 + var frameSize int + if isV4 { + b := frameHeader[4:8] + frameSize = int(b[0]&0x7F)<<21 | int(b[1]&0x7F)<<14 | int(b[2]&0x7F)<<7 | int(b[3]&0x7F) + } else { + frameSize = int(binary.BigEndian.Uint32(frameHeader[4:8])) + } + + payload := frameHeader[10:] + if frameSize > 0 && 10+frameSize <= len(frameHeader) { + payload = frameHeader[10 : 10+frameSize] + } + if len(payload) < 4 { + return nil, "", fmt.Errorf("APIC payload too short") + } + + // Payload layout: + // [1 byte text encoding] + // [MIME type string (null-terminated)] + // [1 byte picture type] + // [Description string (null-terminated)] + // [Raw image bytes...] + mimeEnd := bytes.IndexByte(payload[1:], 0) + if mimeEnd == -1 { + return nil, "", fmt.Errorf("corrupt APIC mime terminator") + } + mime := strings.ToLower(string(payload[1 : 1+mimeEnd])) + + var imgStart int = -1 + var detectedMime string + + // Respect declared MIME type first to avoid false-positive matches + // (e.g. \xff\xd8\xff occurring inside PNG deflate streams) + if strings.Contains(mime, "png") { + if pIdx := bytes.Index(payload, []byte("\x89PNG\r\n\x1a\n")); pIdx != -1 { + imgStart = pIdx + detectedMime = "image/png" + } + } else if strings.Contains(mime, "jpeg") || strings.Contains(mime, "jpg") { + if jIdx := bytes.Index(payload, []byte("\xff\xd8\xff")); jIdx != -1 { + imgStart = jIdx + detectedMime = "image/jpeg" + } + } + + // Fallback to whichever valid image header appears first + if imgStart == -1 { + pIdx := bytes.Index(payload, []byte("\x89PNG\r\n\x1a\n")) + jIdx := bytes.Index(payload, []byte("\xff\xd8\xff")) + if pIdx != -1 && (jIdx == -1 || pIdx < jIdx) { + imgStart = pIdx + detectedMime = "image/png" + } else if jIdx != -1 { + imgStart = jIdx + detectedMime = "image/jpeg" + } + } + + if imgStart == -1 { + return nil, "", fmt.Errorf("could not locate image magic bytes in APIC frame") + } + + rawImg := payload[imgStart:] + // Truncate at image EOF marker if present to avoid trailing garbage + if detectedMime == "image/png" { + if endIdx := bytes.Index(rawImg, []byte("IEND")); endIdx != -1 && endIdx+8 <= len(rawImg) { + rawImg = rawImg[:endIdx+8] + } + } else if detectedMime == "image/jpeg" { + if endIdx := bytes.Index(rawImg, []byte("\xff\xd9")); endIdx != -1 { + rawImg = rawImg[:endIdx+2] + } + } + + return rawImg, detectedMime, nil +} diff --git a/internal/services/auth_service.go b/internal/services/auth_service.go new file mode 100644 index 0000000..53e7b36 --- /dev/null +++ b/internal/services/auth_service.go @@ -0,0 +1,2091 @@ +package services + +import ( + "bytes" + "context" + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + randv2 "math/rand/v2" + "net/http" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/pbkdf2" + + "streamgo/internal/config" + "streamgo/internal/models" + "streamgo/internal/repository" +) + +var ( + usernameRegex = regexp.MustCompile(`^[a-z0-9_.]{3,32}$`) + tgUsernameRegex = regexp.MustCompile(`(?i)^[a-z0-9_]{5,32}$`) +) + +// AuthService handles Telegram WebApp/Widget/OAuth authentication and user management. +type AuthService struct { + cfg *config.Config + userRepo repository.UserRepository + accessRepo repository.AccessControlRepository + + botUsernameMu sync.RWMutex + cachedBotUsername string + botUsernameFetched time.Time + + jwksMu sync.RWMutex + jwksKeys map[string]crypto.PublicKey + jwksFetched time.Time +} + +// NewAuthService creates a new AuthService instance. +func NewAuthService(cfg *config.Config, userRepo repository.UserRepository) *AuthService { + return &AuthService{ + cfg: cfg, + userRepo: userRepo, + jwksKeys: make(map[string]crypto.PublicKey), + } +} + +// SetAccessControlRepository assigns an access control repository for registration policy enforcement. +func (s *AuthService) SetAccessControlRepository(repo repository.AccessControlRepository) { + s.accessRepo = repo +} + +func (s *AuthService) getSecretCandidates() [][]byte { + candidates := make([][]byte, 0, 3) + if s.cfg.SecretKey != "" { + candidates = append(candidates, []byte(s.cfg.SecretKey)) + } + if s.cfg.BotToken != "" { + candidates = append(candidates, []byte(s.cfg.BotToken)) + } + candidates = append(candidates, []byte("SHA234567JDNKDNSNNFNDKSMSERTYUWERTY")) + return candidates +} + +func (s *AuthService) primarySecret() []byte { + if s.cfg.SecretKey != "" { + return []byte(s.cfg.SecretKey) + } + if s.cfg.BotToken != "" { + return []byte(s.cfg.BotToken) + } + return []byte("SHA234567JDNKDNSNNFNDKSMSERTYUWERTY") +} + +// CanonUsername validates and returns lowercase username if valid. +func CanonUsername(value string) string { + s := strings.TrimSpace(strings.ToLower(value)) + if !usernameRegex.MatchString(s) { + return "" + } + return s +} + +// TgUserpicURL returns userpic URL if username matches Telegram format. +func TgUserpicURL(username string) string { + u := strings.TrimSpace(strings.TrimPrefix(username, "@")) + if !tgUsernameRegex.MatchString(u) { + return "" + } + return fmt.Sprintf("https://t.me/i/userpic/320/%s.jpg", u) +} + +// HashPassword hashes a password using PBKDF2-SHA256 matching Python StreamXBot. +func (s *AuthService) HashPassword(password string) (*models.PasswordHash, error) { + if strings.TrimSpace(password) == "" { + return nil, errors.New("password is required") + } + + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return nil, fmt.Errorf("failed to generate salt: %w", err) + } + + const iterations = 200000 + dk := pbkdf2.Key([]byte(password), salt, iterations, 32, sha256.New) + + return &models.PasswordHash{ + Algo: "pbkdf2_sha256", + Salt: base64.RawURLEncoding.EncodeToString(salt), + Iterations: iterations, + Hash: base64.RawURLEncoding.EncodeToString(dk), + }, nil +} + +// VerifyPassword verifies a password against the stored PBKDF2 hash. +func (s *AuthService) VerifyPassword(password string, stored *models.PasswordHash) bool { + if stored == nil || stored.Algo != "pbkdf2_sha256" { + return false + } + + salt, err := base64.RawURLEncoding.DecodeString(stored.Salt) + if err != nil { + if s2, err2 := base64.URLEncoding.DecodeString(stored.Salt); err2 == nil { + salt = s2 + } else { + return false + } + } + + expected, err := base64.RawURLEncoding.DecodeString(stored.Hash) + if err != nil { + if e2, err2 := base64.URLEncoding.DecodeString(stored.Hash); err2 == nil { + expected = e2 + } else { + return false + } + } + + if len(salt) == 0 || stored.Iterations <= 0 || len(expected) == 0 { + return false + } + + dk := pbkdf2.Key([]byte(password), salt, stored.Iterations, len(expected), sha256.New) + return subtle.ConstantTimeCompare(dk, expected) == 1 +} + +// ValidateTelegramInitData verifies the HMAC-SHA256 signature of Telegram WebApp initData. +func (s *AuthService) ValidateTelegramInitData(initData string) (*models.User, error) { + initData = strings.TrimSpace(initData) + if initData == "" { + return nil, errors.New("init_data is required") + } + + values, err := url.ParseQuery(initData) + if err != nil { + return nil, fmt.Errorf("invalid init_data query string: %w", err) + } + + receivedHash := values.Get("hash") + if receivedHash == "" { + return nil, errors.New("hash is missing from init_data") + } + values.Del("hash") + + // Sort keys alphabetically + keys := make([]string, 0, len(values)) + for k := range values { + keys = append(keys, k) + } + sort.Strings(keys) + + var checkPairs []string + for _, k := range keys { + checkPairs = append(checkPairs, fmt.Sprintf("%s=%s", k, values.Get(k))) + } + dataCheckString := strings.Join(checkPairs, "\n") + + botToken := strings.TrimSpace(s.cfg.BotToken) + if botToken == "" { + return nil, errors.New("BOT_TOKEN is not configured") + } + + // Telegram WebApp Secret Key = HMAC-SHA256("WebAppData", botToken) + h := hmac.New(sha256.New, []byte("WebAppData")) + h.Write([]byte(botToken)) + secretKey := h.Sum(nil) + + // Calculated Hash = HMAC-SHA256(secretKey, dataCheckString) + hData := hmac.New(sha256.New, secretKey) + hData.Write([]byte(dataCheckString)) + calculatedHash := hex.EncodeToString(hData.Sum(nil)) + + if subtle.ConstantTimeCompare([]byte(calculatedHash), []byte(receivedHash)) != 1 { + return nil, errors.New("invalid Telegram signature") + } + + userJSON := values.Get("user") + if userJSON == "" { + return nil, errors.New("user payload missing from init_data") + } + + var tgPayload struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Username string `json:"username"` + PhotoURL string `json:"photo_url"` + } + if err := json.Unmarshal([]byte(userJSON), &tgPayload); err != nil { + return nil, fmt.Errorf("failed to parse user json: %w", err) + } + if tgPayload.ID <= 0 { + return nil, errors.New("invalid user id in init_data") + } + + name := strings.TrimSpace(tgPayload.FirstName) + if tgPayload.LastName != "" { + name = strings.TrimSpace(name + " " + tgPayload.LastName) + } + + profileURL := tgPayload.PhotoURL + if profileURL == "" && tgPayload.Username != "" { + profileURL = TgUserpicURL(tgPayload.Username) + } + + return &models.User{ + ID: tgPayload.ID, + UserID: tgPayload.ID, + FirstName: name, + Username: tgPayload.Username, + PhotoURL: tgPayload.PhotoURL, + ProfileURL: profileURL, + Telegram: &models.TelegramInfo{ + ID: tgPayload.ID, + Username: tgPayload.Username, + }, + Status: "active", + }, nil +} + +// VerifyTelegramWidgetData verifies HMAC of Telegram Login Widget data. +func (s *AuthService) VerifyTelegramWidgetData(fields map[string]string) bool { + botToken := strings.TrimSpace(s.cfg.BotToken) + if botToken == "" { + return false + } + + receivedHash := fields["hash"] + if receivedHash == "" { + return false + } + + authDateStr := fields["auth_date"] + authDate, _ := strconv.ParseInt(authDateStr, 10, 64) + if authDate > 0 && time.Now().Unix()-authDate > 86400 { + return false + } + + allowed := map[string]bool{ + "auth_date": true, + "first_name": true, + "id": true, + "last_name": true, + "photo_url": true, + "username": true, + } + + var keys []string + for k := range fields { + if allowed[k] && fields[k] != "" { + keys = append(keys, k) + } + } + sort.Strings(keys) + + var items []string + for _, k := range keys { + items = append(items, fmt.Sprintf("%s=%s", k, fields[k])) + } + dataCheckString := strings.Join(items, "\n") + + secretKey := sha256.Sum256([]byte(botToken)) + h := hmac.New(sha256.New, secretKey[:]) + h.Write([]byte(dataCheckString)) + calculated := hex.EncodeToString(h.Sum(nil)) + + return subtle.ConstantTimeCompare([]byte(strings.ToLower(calculated)), []byte(strings.ToLower(receivedHash))) == 1 +} + +// ValidateTelegramWidget verifies callback data from Telegram Login Widget. +func (s *AuthService) ValidateTelegramWidget(req models.TelegramWidgetRequest) (*models.User, error) { + if req.ID <= 0 || req.Hash == "" { + return nil, errors.New("invalid widget payload") + } + + fields := map[string]string{ + "id": strconv.FormatInt(req.ID, 10), + "auth_date": strconv.FormatInt(req.AuthDate, 10), + "hash": req.Hash, + } + if req.FirstName != "" { + fields["first_name"] = req.FirstName + } + if req.LastName != "" { + fields["last_name"] = req.LastName + } + if req.Username != "" { + fields["username"] = req.Username + } + if req.PhotoURL != "" { + fields["photo_url"] = req.PhotoURL + } + + if !s.VerifyTelegramWidgetData(fields) { + return nil, errors.New("invalid widget signature") + } + + name := strings.TrimSpace(req.FirstName) + if req.LastName != "" { + name = strings.TrimSpace(name + " " + req.LastName) + } + + profileURL := req.PhotoURL + if profileURL == "" && req.Username != "" { + profileURL = TgUserpicURL(req.Username) + } + + return &models.User{ + ID: req.ID, + UserID: req.ID, + FirstName: name, + Username: req.Username, + PhotoURL: req.PhotoURL, + ProfileURL: profileURL, + Telegram: &models.TelegramInfo{ + ID: req.ID, + Username: req.Username, + }, + Status: "active", + }, nil +} + +// CreateToken creates a signed token interoperable with Python StreamXBot. +func (s *AuthService) CreateToken(u *models.User) (string, error) { + now := time.Now().Unix() + exp := now + (365 * 24 * 60 * 60) // 1 year + + payload := map[string]any{ + "uid": u.ID, + "user_id": u.ID, + "userid": u.ID, + "iat": now, + "exp": exp, + "first_name": u.FirstName, + "photo_url": u.PhotoURL, + "profile_url": u.ProfileURL, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return "", err + } + + payloadB64 := base64.RawURLEncoding.EncodeToString(payloadBytes) + secret := s.primarySecret() + + h := hmac.New(sha256.New, secret) + h.Write([]byte(payloadB64)) + sigB64 := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + return fmt.Sprintf("v1.%s.%s", payloadB64, sigB64), nil +} + +// CreateGuestToken creates a signed token with uid="__api__" for guest/API sessions. +func (s *AuthService) CreateGuestToken() (string, error) { + now := time.Now().Unix() + exp := now + (365 * 24 * 60 * 60) // 1 year + + payload := map[string]any{ + "uid": "__api__", + "user_id": "__api__", + "userid": "__api__", + "iat": now, + "exp": exp, + "first_name": "Guest", + "photo_url": "", + "profile_url": "", + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return "", err + } + + payloadB64 := base64.RawURLEncoding.EncodeToString(payloadBytes) + secret := s.primarySecret() + + h := hmac.New(sha256.New, secret) + h.Write([]byte(payloadB64)) + sigB64 := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + return fmt.Sprintf("v1.%s.%s", payloadB64, sigB64), nil +} + +// VerifyTokenClaims parses and verifies an auth token, returning TokenClaims. +// Supports both regular user tokens and guest tokens (uid="__api__"). +func (s *AuthService) VerifyTokenClaims(rawToken string) (*models.TokenClaims, error) { + token := strings.TrimSpace(rawToken) + if token == "" { + return nil, errors.New("missing auth token") + } + + if strings.HasPrefix(strings.ToLower(token), "bearer ") { + token = strings.TrimSpace(token[7:]) + } + + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, errors.New("invalid auth token format") + } + + // 1. Format v1.{payload_b64}.{sig_b64} + if parts[0] == "v1" { + payloadB64 := parts[1] + sigB64 := parts[2] + + matched := false + candidates := s.getSecretCandidates() + for _, cand := range candidates { + h := hmac.New(sha256.New, cand) + h.Write([]byte(payloadB64)) + expectedSig := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + if subtle.ConstantTimeCompare([]byte(expectedSig), []byte(sigB64)) == 1 { + matched = true + break + } + } + + if !matched { + return nil, errors.New("invalid token signature") + } + + payloadBytes, err := base64.RawURLEncoding.DecodeString(payloadB64) + if err != nil { + return nil, errors.New("malformed token payload") + } + + var rawMap map[string]any + if err := json.Unmarshal(payloadBytes, &rawMap); err != nil { + return nil, errors.New("invalid token payload json") + } + + claims := &models.TokenClaims{} + + // Check expiry + if expVal, ok := rawMap["exp"].(float64); ok && expVal > 0 { + if float64(time.Now().Unix()) > expVal { + return nil, errors.New("auth token expired") + } + claims.Exp = int64(expVal) + } + + // Check UID / UserID + uidRaw := rawMap["uid"] + if uidRaw == nil { + uidRaw = rawMap["user_id"] + } + + if uidStr, ok := uidRaw.(string); ok && (uidStr == "__api__" || uidStr == "guest") { + claims.IsGuest = true + claims.UserID = 0 + claims.FirstName = "Guest" + return claims, nil + } + + var uid int64 + switch v := uidRaw.(type) { + case float64: + uid = int64(v) + case string: + uid, _ = strconv.ParseInt(v, 10, 64) + } + + if uid <= 0 { + return nil, errors.New("invalid user id in token") + } + + claims.UserID = uid + if fn, ok := rawMap["first_name"].(string); ok { + claims.FirstName = fn + } + if un, ok := rawMap["username"].(string); ok { + claims.Username = un + } + if pu, ok := rawMap["photo_url"].(string); ok { + claims.PhotoURL = pu + } + if pru, ok := rawMap["profile_url"].(string); ok { + claims.ProfileURL = pru + } + + return claims, nil + } + + // 2. Standard JWT fallback: header.payload.signature + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + if pb, err2 := base64.URLEncoding.DecodeString(parts[1]); err2 == nil { + payloadBytes = pb + } else { + return nil, errors.New("malformed jwt payload") + } + } + + var rawMap map[string]any + if err := json.Unmarshal(payloadBytes, &rawMap); err != nil { + return nil, errors.New("invalid jwt payload json") + } + + claims := &models.TokenClaims{} + if expVal, ok := rawMap["exp"].(float64); ok && expVal > 0 { + if float64(time.Now().Unix()) > expVal { + return nil, errors.New("jwt token expired") + } + claims.Exp = int64(expVal) + } + + uidRaw := rawMap["uid"] + if uidRaw == nil { + uidRaw = rawMap["user_id"] + } + + if uidStr, ok := uidRaw.(string); ok && (uidStr == "__api__" || uidStr == "guest") { + claims.IsGuest = true + claims.UserID = 0 + claims.FirstName = "Guest" + return claims, nil + } + + var uid int64 + switch v := uidRaw.(type) { + case float64: + uid = int64(v) + case string: + uid, _ = strconv.ParseInt(v, 10, 64) + } + + if uid <= 0 { + return nil, errors.New("invalid user id in jwt") + } + + claims.UserID = uid + return claims, nil +} + +// VerifyToken verifies token signature and returns the user ID (or 0 for guest tokens). +func (s *AuthService) VerifyToken(rawToken string) (int64, error) { + claims, err := s.VerifyTokenClaims(rawToken) + if err != nil { + return 0, err + } + return claims.UserID, nil +} + + +// AuthenticateTelegram validates Telegram initData, saves user, and creates token. +func (s *AuthService) AuthenticateTelegram(ctx context.Context, initData string) (*models.AuthResponse, error) { + u, err := s.ValidateTelegramInitData(initData) + if err != nil { + return nil, err + } + + savedUser, err := s.userRepo.UpsertUser(ctx, u) + if err != nil { + return nil, err + } + + token, err := s.CreateToken(savedUser) + if err != nil { + return nil, err + } + + return &models.AuthResponse{ + OK: true, + Token: token, + UserID: savedUser.ID, + FirstName: savedUser.FirstName, + Username: savedUser.Username, + PhotoURL: savedUser.PhotoURL, + ProfileURL: savedUser.ProfileURL, + User: savedUser, + }, nil +} + +// AuthenticateWidget validates Telegram Widget login, saves user, and creates token. +func (s *AuthService) AuthenticateWidget(ctx context.Context, req models.TelegramWidgetRequest) (*models.AuthResponse, error) { + u, err := s.ValidateTelegramWidget(req) + if err != nil { + return nil, err + } + + savedUser, err := s.userRepo.UpsertUser(ctx, u) + if err != nil { + return nil, err + } + + token, err := s.CreateToken(savedUser) + if err != nil { + return nil, err + } + + return &models.AuthResponse{ + OK: true, + Token: token, + UserID: savedUser.ID, + FirstName: savedUser.FirstName, + Username: savedUser.Username, + PhotoURL: savedUser.PhotoURL, + ProfileURL: savedUser.ProfileURL, + User: savedUser, + }, nil +} + +// VerifyGuestPassword verifies password against GUEST_PASSWORD in config or owner_password in MongoDB auth_config. +func (s *AuthService) VerifyGuestPassword(ctx context.Context, password string) bool { + pwd := strings.TrimSpace(password) + if pwd == "" { + return false + } + + // 1. Direct env GUEST_PASSWORD check + if s.cfg.GuestPassword != "" && pwd == s.cfg.GuestPassword { + return true + } + + // 2. Database owner_password check + stored, err := s.userRepo.GetOwnerPassword(ctx) + if err == nil && stored != nil { + if s.VerifyPassword(pwd, stored) { + return true + } + } + + return false +} + +// GetSetupStatus checks whether owner/server password has been configured. +func (s *AuthService) GetSetupStatus(ctx context.Context) (*models.SetupStatusResponse, error) { + stored, err := s.userRepo.GetOwnerPassword(ctx) + if err != nil { + return nil, err + } + configured := (stored != nil) || (s.cfg.GuestPassword != "") + needsSetup := !configured + + var ownerID *int64 + if len(s.cfg.OwnerIDs) > 0 && s.cfg.OwnerIDs[0] > 0 { + oid := s.cfg.OwnerIDs[0] + ownerID = &oid + } + + return &models.SetupStatusResponse{ + OK: true, + Configured: configured, + NeedsSetup: needsSetup, + OwnerID: ownerID, + }, nil +} + +// SetupOwnerPassword creates initial owner password and returns a guest/API token. +func (s *AuthService) SetupOwnerPassword(ctx context.Context, password string) (string, error) { + status, err := s.GetSetupStatus(ctx) + if err == nil && status.Configured { + return "", errors.New("setup already completed") + } + + pwd := strings.TrimSpace(password) + if len(pwd) < 6 { + return "", errors.New("password must be at least 6 characters") + } + + pwdHash, err := s.HashPassword(pwd) + if err != nil { + return "", err + } + + var updatedBy int64 + if len(s.cfg.OwnerIDs) > 0 { + updatedBy = s.cfg.OwnerIDs[0] + } + + if err := s.userRepo.SetOwnerPassword(ctx, pwdHash, updatedBy); err != nil { + return "", fmt.Errorf("failed to save owner password: %w", err) + } + + return s.CreateGuestToken() +} + +// LoginWithServerPassword authenticates with server/guest password and returns guest token. +func (s *AuthService) LoginWithServerPassword(ctx context.Context, password string) (string, error) { + if !s.VerifyGuestPassword(ctx, password) { + return "", errors.New("invalid credentials") + } + return s.CreateGuestToken() +} + +// ChangeOwnerPassword updates the server password. +func (s *AuthService) ChangeOwnerPassword(ctx context.Context, password string, userID int64) error { + pwd := strings.TrimSpace(password) + if len(pwd) < 6 { + return errors.New("password must be at least 6 characters") + } + + pwdHash, err := s.HashPassword(pwd) + if err != nil { + return err + } + + return s.userRepo.SetOwnerPassword(ctx, pwdHash, userID) +} + +// LoginWithPassword authenticates a user with username and password, or server/guest password. +func (s *AuthService) LoginWithPassword(ctx context.Context, username, password string) (*models.AuthResponse, error) { + // If logging in as guest with server/guest password + if (strings.EqualFold(username, "guest") || strings.TrimSpace(username) == "") && s.VerifyGuestPassword(ctx, password) { + token, err := s.CreateGuestToken() + if err != nil { + return nil, err + } + return &models.AuthResponse{ + OK: true, + Token: token, + UserID: 0, + FirstName: "Guest", + Username: "guest", + }, nil + } + + canon := CanonUsername(username) + if canon == "" { + return nil, errors.New("invalid username") + } + + user, err := s.userRepo.GetUserByUsername(ctx, canon) + if err != nil { + return nil, err + } + if user == nil || user.Password == nil { + return nil, errors.New("invalid credentials") + } + + if !s.VerifyPassword(password, user.Password) { + return nil, errors.New("invalid credentials") + } + + token, err := s.CreateToken(user) + if err != nil { + return nil, err + } + + return &models.AuthResponse{ + OK: true, + Token: token, + UserID: user.ID, + FirstName: user.FirstName, + Username: user.Username, + PhotoURL: user.PhotoURL, + ProfileURL: user.ProfileURL, + User: user, + }, nil +} + +// TgLogin authenticates via Telegram WebApp initData and allows optional username/password configuration. +func (s *AuthService) TgLogin(ctx context.Context, req models.TgLoginRequest) (*models.AuthResponse, error) { + user, err := s.ValidateTelegramInitData(req.InitData) + if err != nil { + return nil, err + } + + if req.Username != "" { + canon := CanonUsername(req.Username) + if canon == "" { + return nil, errors.New("invalid username") + } + existing, err := s.userRepo.GetUserByUsername(ctx, canon) + if err != nil { + return nil, err + } + if existing != nil && existing.ID != user.ID { + return nil, errors.New("username already taken") + } + user.Username = canon + } + + if req.Password != "" { + pwdHash, err := s.HashPassword(req.Password) + if err != nil { + return nil, err + } + user.Password = pwdHash + } + + savedUser, err := s.userRepo.UpsertUser(ctx, user) + if err != nil { + return nil, err + } + + token, err := s.CreateToken(savedUser) + if err != nil { + return nil, err + } + + return &models.AuthResponse{ + OK: true, + Token: token, + UserID: savedUser.ID, + FirstName: savedUser.FirstName, + Username: savedUser.Username, + PhotoURL: savedUser.PhotoURL, + ProfileURL: savedUser.ProfileURL, + User: savedUser, + }, nil +} + +// RegisterAccount initiates registration via Telegram OTP or directly creates user account if UserID <= 0 or Direct is true. +func (s *AuthService) RegisterAccount(ctx context.Context, req models.RegisterRequest) (*models.AuthResponse, error) { + canon := CanonUsername(req.Username) + if canon == "" { + return nil, errors.New("invalid username") + } + + // Check global registration policy (open, invite, allowlist, closed) + var regMode string = "open" + if s.accessRepo != nil { + policy, err := s.accessRepo.GetPolicy(ctx) + if err == nil && policy != nil { + regMode = policy.RegistrationMode + switch strings.ToLower(policy.RegistrationMode) { + case "closed": + return nil, errors.New("registration_closed: New accounts cannot be created at this time") + case "allowlist": + if req.UserID > 0 && !s.accessRepo.IsInAllowlist(ctx, req.UserID) { + return nil, errors.New("not_allowlisted: Your account is not on the registration allowlist") + } + case "invite": + code := strings.ToUpper(strings.TrimSpace(req.InviteCode)) + if code == "" { + return nil, errors.New("invite_required: An invite code is required to register") + } + inv, err := s.accessRepo.GetInvite(ctx, code) + if err != nil || inv == nil || inv.Revoked { + return nil, errors.New("invite_invalid: That invite code is invalid or has been revoked") + } + if inv.ExpiresAt != nil && float64(time.Now().Unix()) > *inv.ExpiresAt { + return nil, errors.New("invite_expired: That invite code has expired") + } + if inv.MaxUses > 0 && inv.UsedCount >= inv.MaxUses { + return nil, errors.New("invite_exhausted: That invite code has already reached its maximum uses") + } + // Atomic consumption happens immediately for direct registration; for OTP registration, consumed on validate + if req.UserID <= 0 { + _ = s.accessRepo.ConsumeInvite(ctx, code) + } + } + + // Required chats membership check + if policy.EnforceMembership && len(policy.RequiredChats) > 0 && req.UserID > 0 && s.cfg.BotToken != "" { + if !s.accessRepo.IsInBypass(ctx, req.UserID) { + for _, chat := range policy.RequiredChats { + ok, err := s.CheckTelegramChatMember(ctx, chat.ChatID, req.UserID) + if err != nil || !ok { + return nil, errors.New("membership_required: Join the required Telegram chat before registering") + } + } + } + } + } + } + + // 1. Direct manual user creation (when UserID <= 0) + if req.UserID <= 0 { + existingUsername, err := s.userRepo.GetUserByUsername(ctx, canon) + if err != nil { + return nil, err + } + if existingUsername != nil { + return nil, errors.New("username already taken") + } + + pwdHash, err := s.HashPassword(req.Password) + if err != nil { + return nil, err + } + + userID := req.UserID + if userID <= 0 { + // Generate unique 64-bit user ID (timestamp millis + random offset) + userID = time.Now().UnixNano()/1e6 + int64(randv2.IntN(10000)) + } + + firstName := req.Username + + now := float64(time.Now().Unix()) + var regVia map[string]any + if regMode != "" { + regVia = map[string]any{ + "mode": regMode, + } + if req.InviteCode != "" { + regVia["invite_code"] = strings.ToUpper(strings.TrimSpace(req.InviteCode)) + } + } + + newUser := &models.User{ + ID: userID, + UserID: userID, + Username: canon, + FirstName: firstName, + Password: pwdHash, + Status: "active", + RegisteredVia: regVia, + CreatedAt: now, + UpdatedAt: now, + } + + savedUser, err := s.userRepo.UpsertUser(ctx, newUser) + if err != nil { + return nil, fmt.Errorf("failed to create user: %w", err) + } + + token, err := s.CreateToken(savedUser) + if err != nil { + return nil, err + } + + return &models.AuthResponse{ + OK: true, + Token: token, + UserID: savedUser.ID, + FirstName: savedUser.FirstName, + Username: savedUser.Username, + ProfileURL: savedUser.ProfileURL, + PhotoURL: savedUser.PhotoURL, + User: savedUser, + }, nil + } + + // 2. Telegram OTP flow (requires existing Telegram account & running bot) + if strings.TrimSpace(s.cfg.BotToken) == "" { + return nil, errors.New("Bot is not running or ONLY_API is true") + } + + existingUser, err := s.userRepo.GetUserByID(ctx, req.UserID) + if err != nil { + return nil, err + } + if existingUser != nil && existingUser.Password != nil { + return nil, errors.New("User already registered") + } + + existingUsername, err := s.userRepo.GetUserByUsername(ctx, canon) + if err != nil { + return nil, err + } + if existingUsername != nil && existingUsername.ID != req.UserID { + return nil, errors.New("username already taken") + } + + pwdHash, err := s.HashPassword(req.Password) + if err != nil { + return nil, err + } + + otp := fmt.Sprintf("%06d", randv2.IntN(1000000)) + now := float64(time.Now().Unix()) + + firstName, tgUsername, photoURL, profileURL := s.GetTelegramProfile(ctx, req.UserID) + if profileURL == "" && tgUsername != "" { + profileURL = TgUserpicURL(tgUsername) + } + if photoURL == "" { + photoURL = profileURL + } + + otpDoc := &models.RegistrationOTP{ + ID: req.UserID, + OTP: otp, + Username: canon, + Password: pwdHash, + FirstName: firstName, + TelegramUsername: tgUsername, + ProfileURL: profileURL, + PhotoURL: photoURL, + InviteCode: strings.ToUpper(strings.TrimSpace(req.InviteCode)), + CreatedAt: now, + } + + if err := s.userRepo.SaveRegistrationOTP(ctx, otpDoc); err != nil { + return nil, fmt.Errorf("failed to save pending registration: %w", err) + } + + msgText := fmt.Sprintf("Your StreamX registration OTP is: `%s`\n\nThis OTP is valid for registration.", otp) + if err := s.SendTelegramMessage(ctx, req.UserID, msgText); err != nil { + return nil, fmt.Errorf("failed to send OTP via Telegram bot. Have you started the bot? (%w)", err) + } + + return &models.AuthResponse{ + OK: true, + UserID: req.UserID, + Username: canon, + FirstName: firstName, + PhotoURL: photoURL, + ProfileURL: profileURL, + }, nil +} + +// ValidateOTP completes user registration after verifying the OTP code. +func (s *AuthService) ValidateOTP(ctx context.Context, req models.ValidateOTPRequest) (*models.AuthResponse, error) { + otpDoc, err := s.userRepo.GetRegistrationOTP(ctx, req.UserID) + if err != nil { + return nil, err + } + if otpDoc == nil { + return nil, errors.New("no pending registration found") + } + + if otpDoc.OTP != strings.TrimSpace(req.OTP) { + return nil, errors.New("invalid OTP") + } + + // Re-check registration policy at completion time (mode may have changed while the OTP was pending) + var regMode string = "open" + if s.accessRepo != nil { + policy, err := s.accessRepo.GetPolicy(ctx) + if err == nil && policy != nil { + regMode = policy.RegistrationMode + switch strings.ToLower(policy.RegistrationMode) { + case "closed": + return nil, errors.New("registration_closed: New accounts cannot be created at this time") + case "allowlist": + if req.UserID > 0 && !s.accessRepo.IsInAllowlist(ctx, req.UserID) { + return nil, errors.New("not_allowlisted: Your account is not on the registration allowlist") + } + case "invite": + code := strings.ToUpper(strings.TrimSpace(otpDoc.InviteCode)) + if code == "" { + return nil, errors.New("invite_required: An invite code is required to register") + } + inv, err := s.accessRepo.GetInvite(ctx, code) + if err != nil || inv == nil || inv.Revoked { + return nil, errors.New("invite_invalid: That invite code is invalid or has been revoked") + } + if inv.ExpiresAt != nil && float64(time.Now().Unix()) > *inv.ExpiresAt { + return nil, errors.New("invite_expired: That invite code has expired") + } + if inv.MaxUses > 0 && inv.UsedCount >= inv.MaxUses { + return nil, errors.New("invite_exhausted: That invite code has already reached its maximum uses") + } + } + + // Required chats membership check + if policy.EnforceMembership && len(policy.RequiredChats) > 0 && req.UserID > 0 && s.cfg.BotToken != "" { + if !s.accessRepo.IsInBypass(ctx, req.UserID) { + for _, chat := range policy.RequiredChats { + ok, err := s.CheckTelegramChatMember(ctx, chat.ChatID, req.UserID) + if err != nil || !ok { + return nil, errors.New("membership_required: Join the required Telegram chat before registering") + } + } + } + } + } + + if otpDoc.InviteCode != "" { + _ = s.accessRepo.ConsumeInvite(ctx, otpDoc.InviteCode) + } + } + + firstName := otpDoc.FirstName + tgUsername := otpDoc.TelegramUsername + profileURL := otpDoc.ProfileURL + photoURL := otpDoc.PhotoURL + + // Try refreshing profile from Telegram + fn, un, ph, pr := s.GetTelegramProfile(ctx, req.UserID) + if fn != "" { + firstName = fn + } + if un != "" { + tgUsername = un + } + if ph != "" { + photoURL = ph + } + if pr != "" { + profileURL = pr + } + + now := float64(time.Now().Unix()) + var regVia map[string]any + if regMode != "" { + regVia = map[string]any{ + "mode": regMode, + } + if otpDoc.InviteCode != "" { + regVia["invite_code"] = otpDoc.InviteCode + } + } + + user := &models.User{ + ID: req.UserID, + UserID: req.UserID, + FirstName: firstName, + Username: otpDoc.Username, + PhotoURL: photoURL, + ProfileURL: profileURL, + Status: "active", + Password: otpDoc.Password, + RegisteredVia: regVia, + Telegram: &models.TelegramInfo{ + ID: req.UserID, + Username: tgUsername, + }, + CreatedAt: now, + UpdatedAt: now, + } + + savedUser, err := s.userRepo.UpsertUser(ctx, user) + if err != nil { + return nil, fmt.Errorf("failed to complete registration: %w", err) + } + + _ = s.userRepo.DeleteRegistrationOTP(ctx, req.UserID) + + token, err := s.CreateToken(savedUser) + if err != nil { + return nil, err + } + + return &models.AuthResponse{ + OK: true, + Token: token, + UserID: savedUser.ID, + FirstName: savedUser.FirstName, + Username: savedUser.Username, + PhotoURL: savedUser.PhotoURL, + ProfileURL: savedUser.ProfileURL, + User: savedUser, + }, nil +} + +// UpdateCredentials updates credentials for an authenticated user. +func (s *AuthService) UpdateCredentials(ctx context.Context, userID int64, username, password string) error { + canon := CanonUsername(username) + if canon == "" { + return errors.New("invalid username") + } + + existing, err := s.userRepo.GetUserByUsername(ctx, canon) + if err != nil { + return err + } + if existing != nil && existing.ID != userID { + return errors.New("username already taken") + } + + pwdHash, err := s.HashPassword(password) + if err != nil { + return err + } + + return s.userRepo.UpdateUserCredentials(ctx, userID, canon, pwdHash) +} + +// UpdateIntegrations updates integrations.discord and integrations.lastfm for an authenticated user. +func (s *AuthService) UpdateIntegrations(ctx context.Context, userID int64, req models.IntegrationsUpdateRequest) (*models.UserIntegrations, error) { + return s.userRepo.UpdateIntegrations(ctx, userID, req) +} + +// SendTelegramMessage sends a message to a user via Telegram Bot HTTP API. +func (s *AuthService) SendTelegramMessage(ctx context.Context, chatID int64, text string) error { + botToken := strings.TrimSpace(s.cfg.BotToken) + if botToken == "" { + return errors.New("bot token not configured") + } + + apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken) + payload := map[string]any{ + "chat_id": chatID, + "text": text, + "parse_mode": "Markdown", + } + + body, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 8 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var errResp struct { + Description string `json:"description"` + } + _ = json.NewDecoder(resp.Body).Decode(&errResp) + return fmt.Errorf("telegram API error %d: %s", resp.StatusCode, errResp.Description) + } + + return nil +} + +// CheckTelegramChatMember checks whether a user is an active member/admin of a required Telegram chat. +func (s *AuthService) CheckTelegramChatMember(ctx context.Context, chatID int64, userID int64) (bool, error) { + botToken := strings.TrimSpace(s.cfg.BotToken) + if botToken == "" { + return true, nil + } + + apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/getChatMember?chat_id=%d&user_id=%d", botToken, chatID, userID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return false, err + } + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + var tgResp struct { + OK bool `json:"ok"` + Result struct { + Status string `json:"status"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&tgResp); err != nil { + return false, err + } + + if !tgResp.OK { + return false, nil + } + + switch tgResp.Result.Status { + case "member", "administrator", "creator", "restricted": + return true, nil + default: + return false, nil + } +} + +// GetTelegramProfile retrieves user's first name, username, and profile pictures. +func (s *AuthService) GetTelegramProfile(ctx context.Context, userID int64) (firstName, username, photoURL, profileURL string) { + botToken := strings.TrimSpace(s.cfg.BotToken) + if botToken == "" { + return "", "", "", "" + } + + apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/getChat?chat_id=%d", botToken, userID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return "", "", "", "" + } + + client := &http.Client{Timeout: 4 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", "", "", "" + } + defer resp.Body.Close() + + var tgResp struct { + OK bool `json:"ok"` + Result struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Username string `json:"username"` + Photo *struct { + SmallFileID string `json:"small_file_id"` + BigFileID string `json:"big_file_id"` + } `json:"photo"` + } `json:"result"` + } + + if err := json.NewDecoder(resp.Body).Decode(&tgResp); err == nil && tgResp.OK { + name := strings.TrimSpace(tgResp.Result.FirstName) + if tgResp.Result.LastName != "" { + name = strings.TrimSpace(name + " " + tgResp.Result.LastName) + } + firstName = name + username = tgResp.Result.Username + if username != "" { + profileURL = TgUserpicURL(username) + photoURL = profileURL + } + // If username didn't produce a photo URL or user has no username, resolve Telegram photo file + if (profileURL == "" || photoURL == "") && tgResp.Result.Photo != nil { + fileID := tgResp.Result.Photo.BigFileID + if fileID == "" { + fileID = tgResp.Result.Photo.SmallFileID + } + if fileID != "" { + fileURL := s.getTelegramFilePath(ctx, fileID) + if fileURL != "" { + if profileURL == "" { + profileURL = fileURL + } + if photoURL == "" { + photoURL = fileURL + } + } + } + } + } + + // Fallback to database user record if available + if (profileURL == "" || photoURL == "") && s.userRepo != nil && userID > 0 { + if existing, err := s.userRepo.GetUserByID(ctx, userID); err == nil && existing != nil { + if profileURL == "" { + profileURL = existing.ProfileURL + } + if photoURL == "" { + photoURL = existing.PhotoURL + } + } + } + + if profileURL != "" && photoURL == "" { + photoURL = profileURL + } + if photoURL != "" && profileURL == "" { + profileURL = photoURL + } + + return +} + +func (s *AuthService) getTelegramFilePath(ctx context.Context, fileID string) string { + botToken := strings.TrimSpace(s.cfg.BotToken) + if botToken == "" || fileID == "" { + return "" + } + + apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/getFile?file_id=%s", botToken, fileID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return "" + } + + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + + var getFileResp struct { + OK bool `json:"ok"` + Result struct { + FilePath string `json:"file_path"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&getFileResp); err == nil && getFileResp.OK && getFileResp.Result.FilePath != "" { + return fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, getFileResp.Result.FilePath) + } + return "" +} + +// GetTelegramClientID returns the configured Telegram OIDC client ID or extracts it from BOT_TOKEN. +func (s *AuthService) GetTelegramClientID() string { + if s.cfg.TelegramOIDCClientID != "" { + return s.cfg.TelegramOIDCClientID + } + parts := strings.Split(s.cfg.BotToken, ":") + if len(parts) > 0 && parts[0] != "" { + return parts[0] + } + return "" +} + +// GetBotUsername queries Telegram Bot API to get the bot's @username with in-memory caching. +func (s *AuthService) GetBotUsername(ctx context.Context) string { + s.botUsernameMu.RLock() + if s.cachedBotUsername != "" && time.Since(s.botUsernameFetched) < time.Hour { + name := s.cachedBotUsername + s.botUsernameMu.RUnlock() + return name + } + s.botUsernameMu.RUnlock() + + botToken := strings.TrimSpace(s.cfg.BotToken) + if botToken == "" { + return "" + } + + apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/getMe", botToken) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return "" + } + + client := &http.Client{Timeout: 4 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + + var getMeResp struct { + OK bool `json:"ok"` + Result struct { + Username string `json:"username"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&getMeResp); err == nil && getMeResp.OK { + s.botUsernameMu.Lock() + s.cachedBotUsername = getMeResp.Result.Username + s.botUsernameFetched = time.Now() + s.botUsernameMu.Unlock() + return getMeResp.Result.Username + } + + return "" +} + +// UpdateFCMToken sets the user's FCM push token. +func (s *AuthService) UpdateFCMToken(ctx context.Context, userID int64, fcmToken string) error { + if userID <= 0 { + return errors.New("unauthorized") + } + return s.userRepo.UpdateFCMToken(ctx, userID, fcmToken) +} + +// CreateBotSession creates a temporary session for direct Telegram bot login matching Python StreamXBot. +func (s *AuthService) CreateBotSession(ctx context.Context, inviteCode string) (map[string]any, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return nil, err + } + sessionID := "auth_" + base64.RawURLEncoding.EncodeToString(b) + now := float64(time.Now().Unix()) + expiresAt := now + 600 + + var invCode string + if inviteCode != "" { + invCode = strings.ToUpper(strings.TrimSpace(inviteCode)) + } + + session := &models.BotAuthSession{ + ID: sessionID, + Status: "pending", + InviteCode: invCode, + CreatedAt: now, + ExpiresAt: expiresAt, + } + + if err := s.userRepo.SaveBotAuthSession(ctx, session); err != nil { + return nil, err + } + + botUsername := s.GetBotUsername(ctx) + tgURL := "" + webURL := "" + if botUsername != "" { + tgURL = fmt.Sprintf("tg://resolve?domain=%s&start=%s", botUsername, sessionID) + webURL = fmt.Sprintf("https://t.me/%s?start=%s", botUsername, sessionID) + } + + return map[string]any{ + "ok": true, + "session_id": sessionID, + "bot_username": botUsername, + "tg_url": tgURL, + "web_url": webURL, + }, nil +} + +// CheckBotSessionStatus checks whether the user has authorized via Telegram bot. +func (s *AuthService) CheckBotSessionStatus(ctx context.Context, sessionID string) (map[string]any, error) { + session, err := s.userRepo.GetBotAuthSession(ctx, sessionID) + if err != nil { + return nil, err + } + if session == nil { + return map[string]any{ + "ok": false, + "status": "not_found", + }, nil + } + + now := float64(time.Now().Unix()) + if session.ExpiresAt < now && session.Status == "pending" { + return map[string]any{ + "ok": false, + "status": "expired", + }, nil + } + + if session.Status == "confirmed" { + return map[string]any{ + "ok": true, + "status": "confirmed", + "token": session.Token, + "user_id": session.UserID, + "first_name": session.FirstName, + "username": session.Username, + "photo_url": session.PhotoURL, + "profile_url": session.ProfileURL, + }, nil + } + + return map[string]any{ + "ok": true, + "status": "pending", + }, nil +} + +// GeneratePKCE creates a secure random code_verifier and code_challenge. +func (s *AuthService) GeneratePKCE() (codeVerifier string, codeChallenge string) { + bytes := make([]byte, 48) + _, _ = rand.Read(bytes) + codeVerifier = base64.RawURLEncoding.EncodeToString(bytes) + + h := sha256.Sum256([]byte(codeVerifier)) + codeChallenge = base64.RawURLEncoding.EncodeToString(h[:]) + return +} + +// StartTelegramOIDC creates an OIDC authorization session and returns the Telegram redirect URL. +func (s *AuthService) StartTelegramOIDC(ctx context.Context, redirectURI, frontendBase, destPath, inviteCode string) (string, error) { + clientID := s.GetTelegramClientID() + if clientID == "" { + return "", errors.New("Telegram Client ID is not configured") + } + + stateBytes := make([]byte, 24) + _, _ = rand.Read(stateBytes) + state := base64.RawURLEncoding.EncodeToString(stateBytes) + + codeVerifier, codeChallenge := s.GeneratePKCE() + now := float64(time.Now().Unix()) + + session := &models.OIDCSession{ + ID: state, + CodeVerifier: codeVerifier, + RedirectURI: redirectURI, + FrontendBase: frontendBase, + DestPath: destPath, + InviteCode: inviteCode, + CreatedAt: now, + } + + if err := s.userRepo.SaveOIDCSession(ctx, session); err != nil { + return "", fmt.Errorf("failed to save OIDC session: %w", err) + } + + origin := s.cfg.TelegramOIDCOrigin + if origin == "" { + origin = frontendBase + } + if origin != "" && !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") { + origin = "https://" + origin + } + + v := url.Values{} + if s.cfg.TelegramOIDCClientSecret != "" { + v.Set("client_id", clientID) + v.Set("bot_id", clientID) + v.Set("origin", origin) + v.Set("redirect_uri", redirectURI) + v.Set("response_type", "code") + v.Set("scope", "openid profile") + v.Set("state", state) + v.Set("code_challenge", codeChallenge) + v.Set("code_challenge_method", "S256") + } else { + v.Set("bot_id", clientID) + v.Set("origin", origin) + v.Set("return_to", redirectURI) + v.Set("request_access", "write") + } + + authURL := fmt.Sprintf("https://oauth.telegram.org/auth?%s", v.Encode()) + return authURL, nil +} + +// fetchJWKS retrieves and parses public keys from Telegram's JWKS endpoint. +func (s *AuthService) fetchJWKS(ctx context.Context) error { + s.jwksMu.RLock() + if len(s.jwksKeys) > 0 && time.Since(s.jwksFetched) < time.Hour { + s.jwksMu.RUnlock() + return nil + } + s.jwksMu.RUnlock() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://oauth.telegram.org/.well-known/jwks.json", nil) + if err != nil { + return err + } + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + var jwks struct { + Keys []struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Alg string `json:"alg"` + N string `json:"n"` + E string `json:"e"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` + } `json:"keys"` + } + + if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { + return fmt.Errorf("failed to decode JWKS: %w", err) + } + + newKeys := make(map[string]crypto.PublicKey) + for _, k := range jwks.Keys { + if k.Kty == "RSA" && k.N != "" && k.E != "" { + nBytes, err := base64.RawURLEncoding.DecodeString(k.N) + if err != nil { + continue + } + eBytes, err := base64.RawURLEncoding.DecodeString(k.E) + if err != nil { + continue + } + var eInt int + for _, b := range eBytes { + eInt = (eInt << 8) | int(b) + } + newKeys[k.Kid] = &rsa.PublicKey{ + N: new(big.Int).SetBytes(nBytes), + E: eInt, + } + } else if k.Kty == "EC" && k.X != "" && k.Y != "" { + xBytes, err := base64.RawURLEncoding.DecodeString(k.X) + if err != nil { + continue + } + yBytes, err := base64.RawURLEncoding.DecodeString(k.Y) + if err != nil { + continue + } + var curve elliptic.Curve + if k.Crv == "P-256" || k.Crv == "ES256" { + curve = elliptic.P256() + } else { + continue + } + newKeys[k.Kid] = &ecdsa.PublicKey{ + Curve: curve, + X: new(big.Int).SetBytes(xBytes), + Y: new(big.Int).SetBytes(yBytes), + } + } else if k.Kty == "OKP" && k.Crv == "Ed25519" && k.X != "" { + xBytes, err := base64.RawURLEncoding.DecodeString(k.X) + if err == nil && len(xBytes) == ed25519.PublicKeySize { + newKeys[k.Kid] = ed25519.PublicKey(xBytes) + } + } + } + + s.jwksMu.Lock() + s.jwksKeys = newKeys + s.jwksFetched = time.Now() + s.jwksMu.Unlock() + + return nil +} + +// ValidateTelegramIDToken decodes and verifies a Telegram ID token against Telegram's JWKS. +func (s *AuthService) ValidateTelegramIDToken(ctx context.Context, idToken string) (jwt.MapClaims, error) { + if err := s.fetchJWKS(ctx); err != nil { + return nil, fmt.Errorf("failed to fetch Telegram JWKS: %w", err) + } + + token, err := jwt.Parse(idToken, func(t *jwt.Token) (any, error) { + kid, _ := t.Header["kid"].(string) + s.jwksMu.RLock() + pubKey, ok := s.jwksKeys[kid] + s.jwksMu.RUnlock() + if !ok { + // Refresh keys once in case of key rotation + _ = s.fetchJWKS(ctx) + s.jwksMu.RLock() + pubKey, ok = s.jwksKeys[kid] + s.jwksMu.RUnlock() + } + if !ok { + return nil, fmt.Errorf("JWKS key not found for kid: %s", kid) + } + return pubKey, nil + }, jwt.WithIssuer("https://oauth.telegram.org")) + + if err != nil { + return nil, fmt.Errorf("failed to verify ID token: %w", err) + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || !token.Valid { + return nil, errors.New("invalid ID token claims") + } + + return claims, nil +} + +// ResolveTelegramUserID resolves numeric Telegram User ID from decoded OIDC claims. +func (s *AuthService) ResolveTelegramUserID(ctx context.Context, claims jwt.MapClaims) (int64, error) { + var tgUserID int64 + + // 1. Raw numeric fields + for _, field := range []string{"id", "user_id", "telegram_id"} { + if val, ok := claims[field]; ok { + switch v := val.(type) { + case float64: + tgUserID = int64(v) + case string: + tgUserID, _ = strconv.ParseInt(v, 10, 64) + } + if tgUserID > 0 { + break + } + } + } + + prefUsername, _ := claims["preferred_username"].(string) + prefUsername = strings.TrimSpace(prefUsername) + + // If ID is missing or a pairwise 64-bit hash (> 10^12) and we have a username, resolve from DB + if (tgUserID <= 0 || tgUserID > 1000000000000) && prefUsername != "" { + canon := CanonUsername(prefUsername) + existing, _ := s.userRepo.GetUserByUsername(ctx, canon) + if existing != nil && existing.ID > 0 && existing.ID < 1000000000000 { + return existing.ID, nil + } + } + + if tgUserID > 0 { + return tgUserID, nil + } + + // Fallback to sub + if subStr, ok := claims["sub"].(string); ok { + if subID, err := strconv.ParseInt(subStr, 10, 64); err == nil && subID > 0 { + return subID, nil + } + } + + return 0, errors.New("cannot extract valid Telegram user ID from claims") +} + +// ExchangeOIDCCode exchanges authorization code for tokens, resolves user, and issues application token. +func (s *AuthService) ExchangeOIDCCode(ctx context.Context, code, state string) (*models.User, string, string, error) { + session, err := s.userRepo.GetAndDeleteOIDCSession(ctx, state) + if err != nil { + return nil, "", "", err + } + if session == nil { + return nil, "", "", errors.New("invalid_state") + } + + if time.Now().Unix()-int64(session.CreatedAt) > 600 { + return nil, "", "", errors.New("expired_session") + } + + clientID := s.GetTelegramClientID() + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {session.RedirectURI}, + "client_id": {clientID}, + "code_verifier": {session.CodeVerifier}, + } + if s.cfg.TelegramOIDCClientSecret != "" { + form.Set("client_secret", s.cfg.TelegramOIDCClientSecret) + } + + tokenReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://oauth.telegram.org/token", strings.NewReader(form.Encode())) + if err != nil { + return nil, "", "", err + } + tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{Timeout: 10 * time.Second} + tokenResp, err := client.Do(tokenReq) + if err != nil { + return nil, "", "", fmt.Errorf("token exchange request failed: %w", err) + } + defer tokenResp.Body.Close() + + var tokenBody struct { + IDToken string `json:"id_token"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if err := json.NewDecoder(tokenResp.Body).Decode(&tokenBody); err != nil { + return nil, "", "", fmt.Errorf("invalid token exchange response: %w", err) + } + + if tokenBody.Error != "" { + return nil, "", "", fmt.Errorf("%s: %s", tokenBody.Error, tokenBody.ErrorDescription) + } + if tokenBody.IDToken == "" { + return nil, "", "", errors.New("missing_id_token") + } + + claims, err := s.ValidateTelegramIDToken(ctx, tokenBody.IDToken) + if err != nil { + return nil, "", "", err + } + + tgUserID, err := s.ResolveTelegramUserID(ctx, claims) + if err != nil { + return nil, "", "", err + } + + name, _ := claims["name"].(string) + username, _ := claims["preferred_username"].(string) + picture, _ := claims["picture"].(string) + + profileURL := picture + if profileURL == "" && username != "" { + profileURL = TgUserpicURL(username) + } + + user := &models.User{ + ID: tgUserID, + UserID: tgUserID, + FirstName: strings.TrimSpace(name), + Username: username, + PhotoURL: picture, + ProfileURL: profileURL, + Telegram: &models.TelegramInfo{ + ID: tgUserID, + Username: username, + }, + Status: "active", + } + + savedUser, err := s.userRepo.UpsertUser(ctx, user) + if err != nil { + return nil, "", "", err + } + + token, err := s.CreateToken(savedUser) + if err != nil { + return nil, "", "", err + } + + destPath := session.DestPath + if destPath == "" { + destPath = "/" + } + + return savedUser, token, destPath, nil +} + +// ValidateOIDCIDToken handles token verification for frontend hash fragment login (/auth/telegram/validate-token). +func (s *AuthService) ValidateOIDCIDToken(ctx context.Context, idToken, inviteCode string) (*models.User, string, error) { + claims, err := s.ValidateTelegramIDToken(ctx, idToken) + if err != nil { + return nil, "", err + } + + tgUserID, err := s.ResolveTelegramUserID(ctx, claims) + if err != nil { + return nil, "", err + } + + name, _ := claims["name"].(string) + username, _ := claims["preferred_username"].(string) + picture, _ := claims["picture"].(string) + + profileURL := picture + if profileURL == "" && username != "" { + profileURL = TgUserpicURL(username) + } + + user := &models.User{ + ID: tgUserID, + UserID: tgUserID, + FirstName: strings.TrimSpace(name), + Username: username, + PhotoURL: picture, + ProfileURL: profileURL, + Telegram: &models.TelegramInfo{ + ID: tgUserID, + Username: username, + }, + Status: "active", + } + + savedUser, err := s.userRepo.UpsertUser(ctx, user) + if err != nil { + return nil, "", err + } + + token, err := s.CreateToken(savedUser) + if err != nil { + return nil, "", err + } + + return savedUser, token, nil +} + +// SetAuthCookie writes auth_token as an HTTP-only cookie. +func (s *AuthService) SetAuthCookie(w http.ResponseWriter, token string) { + secure := s.cfg.CookieSecure + + sameSite := http.SameSiteNoneMode + if strings.ToLower(s.cfg.CookieSameSite) == "lax" { + sameSite = http.SameSiteLaxMode + } else if strings.ToLower(s.cfg.CookieSameSite) == "strict" { + sameSite = http.SameSiteStrictMode + } + + http.SetCookie(w, &http.Cookie{ + Name: "auth_token", + Value: token, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: sameSite, + MaxAge: 365 * 24 * 60 * 60, + }) +} + +// ClearAuthCookie clears the auth_token cookie. +func (s *AuthService) ClearAuthCookie(w http.ResponseWriter) { + secure := s.cfg.CookieSecure + + sameSite := http.SameSiteNoneMode + if strings.ToLower(s.cfg.CookieSameSite) == "lax" { + sameSite = http.SameSiteLaxMode + } else if strings.ToLower(s.cfg.CookieSameSite) == "strict" { + sameSite = http.SameSiteStrictMode + } + + http.SetCookie(w, &http.Cookie{ + Name: "auth_token", + Value: "", + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: sameSite, + MaxAge: -1, + }) +} + +// GetUserByID returns user information by ID. +func (s *AuthService) GetUserByID(ctx context.Context, id int64) (*models.User, error) { + return s.userRepo.GetUserByID(ctx, id) +} + +// AuthenticateOrRegisterTgUser finds or creates a user by Telegram user ID and generates an auth token. +func (s *AuthService) AuthenticateOrRegisterTgUser(ctx context.Context, tgUserID int64, name, username, photoURL, inviteCode string) (*models.User, string, error) { + now := float64(time.Now().Unix()) + user, err := s.userRepo.GetUserByID(ctx, tgUserID) + if err != nil { + return nil, "", err + } + + if user == nil { + if s.accessRepo != nil { + policy, pErr := s.accessRepo.GetPolicy(ctx) + if pErr == nil && policy != nil { + switch strings.ToLower(policy.RegistrationMode) { + case "closed": + return nil, "", errors.New("registration is currently closed") + case "invite": + if inviteCode == "" { + return nil, "", errors.New("an invite code is required to register") + } + inv, iErr := s.accessRepo.GetInvite(ctx, strings.ToUpper(strings.TrimSpace(inviteCode))) + if iErr != nil || inv == nil || inv.Revoked { + return nil, "", errors.New("invalid or revoked invite code") + } + if inv.ExpiresAt != nil && now > *inv.ExpiresAt { + return nil, "", errors.New("invite code has expired") + } + if inv.MaxUses > 0 && inv.UsedCount >= inv.MaxUses { + return nil, "", errors.New("invite code has reached maximum uses") + } + _ = s.accessRepo.ConsumeInvite(ctx, inv.Code) + case "allowlist": + if !s.accessRepo.IsInAllowlist(ctx, tgUserID) { + return nil, "", errors.New("your account is not on the registration allowlist") + } + } + } + } + + user = &models.User{ + ID: tgUserID, + UserID: tgUserID, + FirstName: name, + Username: CanonUsername(username), + PhotoURL: photoURL, + ProfileURL: photoURL, + Status: "active", + TokenVersion: 0, + Telegram: &models.TelegramInfo{ + ID: tgUserID, + Username: username, + }, + RegisteredVia: map[string]any{ + "mode": "telegram_bot", + "invite_code": inviteCode, + }, + CreatedAt: now, + UpdatedAt: now, + } + } else { + if user.Status == "locked" { + return nil, "", errors.New("your account has been suspended") + } + if name != "" { + user.FirstName = name + } + if username != "" && user.Username == "" { + user.Username = CanonUsername(username) + } + if photoURL != "" { + user.PhotoURL = photoURL + user.ProfileURL = photoURL + } + if user.Telegram == nil { + user.Telegram = &models.TelegramInfo{ID: tgUserID, Username: username} + } + user.UpdatedAt = now + } + + savedUser, err := s.userRepo.UpsertUser(ctx, user) + if err != nil { + return nil, "", err + } + + token, err := s.CreateToken(savedUser) + if err != nil { + return nil, "", err + } + + return savedUser, token, nil +} + +// ConfirmBotSession marks a bot auth session confirmed in the repository. +func (s *AuthService) ConfirmBotSession(ctx context.Context, sessionID, token string, user *models.User) error { + return s.userRepo.ConfirmBotAuthSession(ctx, sessionID, token, user) +} diff --git a/internal/services/cover_search.go b/internal/services/cover_search.go new file mode 100644 index 0000000..a79dacc --- /dev/null +++ b/internal/services/cover_search.go @@ -0,0 +1,224 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "streamgo/internal/logger" +) + +var logCover = logger.New("cover_search") + +// CoverSearchService searches for high-resolution album artwork and artist avatars. +type CoverSearchService struct { + httpClient *http.Client +} + +// NewCoverSearchService creates a new CoverSearchService. +func NewCoverSearchService() *CoverSearchService { + return &CoverSearchService{ + httpClient: &http.Client{ + Timeout: 8 * time.Second, + }, + } +} + +// StripNoise removes common junk, watermarks, and descriptors from titles/artists for clean search. +func StripNoise(text string) string { + s := strings.TrimSpace(text) + if s == "" { + return "" + } + + // Remove disc/volume markers + reDisc := regexp.MustCompile(`(?i)[\(\[]\s*(?:disc|cd|tape|vol|volume)\s*\d+[\)\]]`) + s = reDisc.ReplaceAllString(s, " ") + + // Remove official video, lyric video, remaster, audio descriptors (with optional year) + reDescriptors := regexp.MustCompile(`(?i)[\(\[]\s*(?:official\s+(?:audio|video|music\s+video)|lyric(?:s)?\s+video|official|audio|video|mv|visualizer|remaster(?:ed)?(?:\s+\d+)?|hd|4k|explicit|clean|flac|320kbps|24bit|live(?:\s+at[^\]\)]+)?)\s*[\)\]]`) + s = reDescriptors.ReplaceAllString(s, " ") + + // Remove standalone noise words + reStandalone := regexp.MustCompile(`(?i)\b(?:official\s+(?:audio|video|music\s+video)|lyric(?:s)?\s+video|visualizer|remaster(?:ed)?(?:\s+\d+)?|explicit|clean)\b`) + s = reStandalone.ReplaceAllString(s, " ") + + // Remove unclosed brackets + if (strings.Contains(s, "(") && !strings.Contains(s, ")")) || (strings.Contains(s, "[") && !strings.Contains(s, "]")) { + s = strings.ReplaceAll(s, "(", " ") + s = strings.ReplaceAll(s, "[", " ") + } + + // Remove telegram channel prefixes or watermarks (@channel_name) + reChannel := regexp.MustCompile(`(?i)@\w+`) + s = reChannel.ReplaceAllString(s, " ") + + // Normalize spaces + reSpaces := regexp.MustCompile(`\s+`) + s = reSpaces.ReplaceAllString(s, " ") + + return strings.TrimSpace(s) +} + +// FindBestCover attempts to find the best album artwork using iTunes and Deezer APIs. +func (s *CoverSearchService) FindBestCover(ctx context.Context, title, artist, album string) (coverURL, thumbURL string, err error) { + cleanTitle := StripNoise(title) + cleanArtist := StripNoise(artist) + cleanAlbum := StripNoise(album) + + // 1. Try iTunes Search API + if cover, thumb, err := s.searchITunes(ctx, cleanTitle, cleanArtist); err == nil && cover != "" { + return cover, thumb, nil + } + + // 2. Try Deezer Search API + if cover, thumb, err := s.searchDeezer(ctx, cleanTitle, cleanArtist); err == nil && cover != "" { + return cover, thumb, nil + } + + // 3. Try with Album name if available + if cleanAlbum != "" { + if cover, thumb, err := s.searchITunes(ctx, cleanAlbum, cleanArtist); err == nil && cover != "" { + return cover, thumb, nil + } + } + + return "", "", fmt.Errorf("no cover found for %s - %s", artist, title) +} + +// searchITunes queries the Apple iTunes Search API for artwork. +func (s *CoverSearchService) searchITunes(ctx context.Context, title, artist string) (coverURL, thumbURL string, err error) { + query := fmt.Sprintf("%s %s", artist, title) + apiURL := fmt.Sprintf("https://itunes.apple.com/search?term=%s&entity=song&limit=3", url.QueryEscape(query)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return "", "", err + } + req.Header.Set("User-Agent", "Mozilla/5.0") + + resp, err := s.httpClient.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("itunes api returned status %d", resp.StatusCode) + } + + var payload struct { + ResultCount int `json:"resultCount"` + Results []struct { + ArtworkUrl100 string `json:"artworkUrl100"` + ArtworkUrl60 string `json:"artworkUrl60"` + } `json:"results"` + } + + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", "", err + } + + if payload.ResultCount > 0 && len(payload.Results) > 0 { + raw := payload.Results[0].ArtworkUrl100 + if raw != "" { + // Upgrade 100x100 to ultra-high-res 1000x1000bb.jpg + highRes := strings.Replace(raw, "100x100bb.jpg", "1000x1000bb.jpg", 1) + return highRes, payload.Results[0].ArtworkUrl60, nil + } + } + + return "", "", nil +} + +// searchDeezer queries Deezer API for album cover art. +func (s *CoverSearchService) searchDeezer(ctx context.Context, title, artist string) (coverURL, thumbURL string, err error) { + query := fmt.Sprintf(`artist:"%s" track:"%s"`, artist, title) + apiURL := fmt.Sprintf("https://api.deezer.com/search?q=%s&limit=3", url.QueryEscape(query)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return "", "", err + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("deezer api returned status %d", resp.StatusCode) + } + + var payload struct { + Total int `json:"total"` + Data []struct { + Album struct { + CoverBig string `json:"cover_big"` + CoverXL string `json:"cover_xl"` + CoverMed string `json:"cover_medium"` + } `json:"album"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", "", err + } + + if payload.Total > 0 && len(payload.Data) > 0 { + alb := payload.Data[0].Album + cover := alb.CoverXL + if cover == "" { + cover = alb.CoverBig + } + if cover != "" { + return cover, alb.CoverMed, nil + } + } + + return "", "", nil +} + +// FindArtistAvatar fetches high-resolution artist profile picture from Deezer API. +func (s *CoverSearchService) FindArtistAvatar(ctx context.Context, artist string) (string, error) { + cleanArtist := StripNoise(artist) + apiURL := fmt.Sprintf("https://api.deezer.com/search/artist?q=%s&limit=1", url.QueryEscape(cleanArtist)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return "", err + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var payload struct { + Data []struct { + PictureXL string `json:"picture_xl"` + PictureBig string `json:"picture_big"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", err + } + + if len(payload.Data) > 0 { + pic := payload.Data[0].PictureXL + if pic == "" { + pic = payload.Data[0].PictureBig + } + return pic, nil + } + + return "", nil +} diff --git a/internal/services/daily_playlist_service.go b/internal/services/daily_playlist_service.go new file mode 100644 index 0000000..e026fbc --- /dev/null +++ b/internal/services/daily_playlist_service.go @@ -0,0 +1,196 @@ +package services + +import ( + "context" + "fmt" + "strings" + "time" + + "streamgo/internal/models" + "streamgo/internal/repository" +) + +// DailyPlaylistService coordinates daily dynamic playlist generation. +type DailyPlaylistService struct { + dailyRepo repository.DailyPlaylistRepository + trackRepo repository.TrackRepository +} + +// NewDailyPlaylistService creates a new DailyPlaylistService. +func NewDailyPlaylistService(dailyRepo repository.DailyPlaylistRepository, trackRepo repository.TrackRepository) *DailyPlaylistService { + return &DailyPlaylistService{ + dailyRepo: dailyRepo, + trackRepo: trackRepo, + } +} + +// CanonDailyKey normalizes daily playlist key names. +func CanonDailyKey(raw string) string { + k := strings.ToLower(strings.TrimSpace(raw)) + switch k { + case "random", "mix", "daily-playlist": + return "random" + case "trending", "trending-today", "top", "top-played", "top-playlist": + return "trending" + case "rediscover": + return "rediscover" + case "late-night", "late-night-mix": + return "late-night" + case "rising", "rising-tracks": + return "rising" + case "surprise", "surprise-me": + return "surprise" + default: + return k + } +} + +// GetAvailablePlaylists returns the list of daily playlists for the home page. +func (s *DailyPlaylistService) GetAvailablePlaylists(ctx context.Context) (*models.AvailablePlaylistsResponse, error) { + dailyDefs := []struct { + Key string + Name string + }{ + {"random", "Daily Mix"}, + {"trending", "Trending Today"}, + {"rediscover", "Rediscover"}, + {"late-night", "Late Night Mix"}, + {"rising", "Rising Tracks"}, + {"surprise", "Surprise Me"}, + } + + today := time.Now().UTC().Format("2006-01-02") + var items []*models.AvailablePlaylistItem + + for _, d := range dailyDefs { + docID := fmt.Sprintf("%s:%s:0", d.Key, today) + var thumbs []string + var coverURL string + + // If cached, grab cover from first tracks + if cached, _ := s.dailyRepo.GetCachedDailyPlaylist(ctx, docID); cached != nil && len(cached.TrackIDs) > 0 { + sampleIDs := cached.TrackIDs + if len(sampleIDs) > 4 { + sampleIDs = sampleIDs[:4] + } + if sampleTracks, err := s.trackRepo.GetByIDs(ctx, sampleIDs); err == nil { + for _, t := range sampleTracks { + u := t.EffectiveCoverURL() + if u != "" { + thumbs = append(thumbs, u) + } + } + if len(thumbs) > 0 { + coverURL = thumbs[0] + } + } + } + + items = append(items, &models.AvailablePlaylistItem{ + ID: fmt.Sprintf("daily:%s", d.Key), + Kind: "daily", + Name: d.Name, + Thumbnails: thumbs, + ThumbnailURL: coverURL, + NormalThumbnail: coverURL, + Endpoint: fmt.Sprintf("/daily-playlist/%s", d.Key), + RequiresAuth: false, + }) + } + + return &models.AvailablePlaylistsResponse{Items: items}, nil +} + +// GetDailyPlaylist returns the track list for a daily playlist key. +func (s *DailyPlaylistService) GetDailyPlaylist(ctx context.Context, rawKey string, limit int, channelID int64, userID int64) (*models.BrowseResponse, error) { + key := CanonDailyKey(rawKey) + if limit <= 0 { + limit = 75 + } + if limit > 100 { + limit = 100 + } + + today := time.Now().UTC().Format("2006-01-02") + docID := fmt.Sprintf("%s:%s:%d", key, today, channelID) + + // 1. Check cache + cached, err := s.dailyRepo.GetCachedDailyPlaylist(ctx, docID) + if err == nil && cached != nil && len(cached.TrackIDs) > 0 { + tracks, err := s.trackRepo.GetByIDs(ctx, cached.TrackIDs) + if err == nil && len(tracks) > 0 { + trackMap := make(map[string]*models.Track, len(tracks)) + for _, t := range tracks { + trackMap[t.ID] = t + } + + var items []*models.BrowseItem + for _, tid := range cached.TrackIDs { + if t, ok := trackMap[tid]; ok { + items = append(items, t.ToBrowseItem()) + } + } + + return &models.BrowseResponse{ + Page: 1, + PerPage: limit, + Total: int64(len(items)), + Items: items, + }, nil + } + } + + // 2. Generate playlist + var tracks []*models.Track + switch key { + case "trending": + // Highest play count + tracks, _, err = s.trackRepo.List(ctx, 1, limit, "play_count", "", channelID) + case "rising": + // Recently added tracks + tracks, _, err = s.trackRepo.List(ctx, 1, limit, "created_at", "", channelID) + case "late-night": + // Low tempo / chill + tracks, _, err = s.trackRepo.List(ctx, 1, limit, "updated_at", "", channelID) + default: + // Random / Mix / Surprise + tracks, err = s.trackRepo.Random(ctx, limit, channelID) + } + + if err != nil { + return nil, err + } + + var trackIDs []string + var items []*models.BrowseItem + for _, t := range tracks { + trackIDs = append(trackIDs, t.ID) + items = append(items, t.ToBrowseItem()) + } + + // 3. Cache the generated playlist + if len(trackIDs) > 0 { + var firstCover string + if len(items) > 0 { + firstCover = items[0].CoverURL + } + doc := &models.DailyPlaylistDoc{ + ID: docID, + Key: key, + Date: today, + ChannelID: channelID, + TrackIDs: trackIDs, + ThumbnailURL: firstCover, + NormalThumbnail: firstCover, + GeneratedAt: float64(time.Now().Unix()), + } + _ = s.dailyRepo.SaveDailyPlaylist(ctx, doc) + } + + return &models.BrowseResponse{ + Page: 1, + PerPage: limit, + Total: int64(len(items)), + Items: items, + }, nil +} diff --git a/internal/services/dedup_service.go b/internal/services/dedup_service.go new file mode 100644 index 0000000..2512833 --- /dev/null +++ b/internal/services/dedup_service.go @@ -0,0 +1,98 @@ +package services + +import ( + "context" + "errors" + "fmt" + "math" + "regexp" + "strings" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + + "streamgo/internal/database" + "streamgo/internal/models" +) + +// DedupService calculates audio fingerprints and detects duplicate track uploads. +type DedupService struct { + col *mongo.Collection +} + +// NewDedupService creates a new DedupService instance. +func NewDedupService(db *database.Client) *DedupService { + var col *mongo.Collection + if db != nil { + col = db.Collection("audioTracks") + } + return &DedupService{col: col} +} + +// NormalizeText cleans and strips non-alphanumeric characters for fingerprinting (supports multilingual Unicode). +func NormalizeText(text string) string { + s := strings.ToLower(text) + reNonAlpha := regexp.MustCompile(`[^\p{L}\p{N}]+`) + s = reNonAlpha.ReplaceAllString(s, " ") + return strings.TrimSpace(s) +} + +// MetadataFingerprint generates a fuzzy bucketed fingerprint: title|artist|album|duration. +func MetadataFingerprint(title, artist, album string, durationSec float64) string { + t := NormalizeText(title) + a := NormalizeText(artist) + al := NormalizeText(album) + + durKey := "" + if durationSec > 0 { + // Round to nearest 2-second bucket to tolerate minor encoding duration differences + bucket := int64(math.Round(durationSec/2.0) * 2) + durKey = fmt.Sprintf("%d", bucket) + } + + parts := []string{t, a, al, durKey} + return strings.Trim(strings.Join(parts, "|"), "|") +} + +// CheckDuplicate queries MongoDB to find if a track already exists by file_unique_id or fingerprint. +func (s *DedupService) CheckDuplicate(ctx context.Context, fileUniqueID, fingerprint string) (*models.Track, bool, error) { + if s.col == nil { + return nil, false, nil + } + + var orConditions []bson.M + if fileUniqueID != "" { + orConditions = append(orConditions, bson.M{"telegram.file_unique_id": fileUniqueID}) + orConditions = append(orConditions, bson.M{"_id": fileUniqueID}) + } + if fingerprint != "" { + orConditions = append(orConditions, bson.M{"fingerprint": fingerprint}) + } + + if len(orConditions) == 0 { + return nil, false, nil + } + + filter := bson.M{ + "deleted": bson.M{"$ne": true}, + "$or": orConditions, + } + + var existing models.Track + err := s.col.FindOne(ctx, filter).Decode(&existing) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, false, nil + } + return nil, false, err + } + + return &existing, true, nil +} + +// IsDuplicate checks whether a track is already present in the database. +func (s *DedupService) IsDuplicate(ctx context.Context, fileUniqueID, title, artist, album string, durationSec float64) bool { + fp := MetadataFingerprint(title, artist, album, durationSec) + _, found, _ := s.CheckDuplicate(ctx, fileUniqueID, fp) + return found +} diff --git a/internal/services/discord_service.go b/internal/services/discord_service.go new file mode 100644 index 0000000..0f2a277 --- /dev/null +++ b/internal/services/discord_service.go @@ -0,0 +1,583 @@ +package services + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/coder/websocket" + + "streamgo/internal/logger" + "streamgo/internal/models" +) + +var logDiscord = logger.New("discord_ra") + +const ( + DiscordGatewayURL = "wss://remote-auth-gateway.discord.gg/?v=2" + DiscordLoginURL = "https://discord.com/api/v9/users/@me/remote-auth/login" + SessionTTLSec = 240 +) + +// DiscordRemoteAuthSession holds ephemeral state for one QR code login session. +type DiscordRemoteAuthSession struct { + SessionID string + PrivKey *rsa.PrivateKey + PubKeyB64 string + + Status string + Fingerprint string + QRURL string + UserInfo map[string]any + Token string + Error string + CreatedAt float64 + + EventsChan chan map[string]any + Cancelled bool + CancelFunc context.CancelFunc + mu sync.RWMutex +} + +// DiscordService manages Discord Remote Auth sessions and external assets proxying. +type DiscordService struct { + sessions map[string]*DiscordRemoteAuthSession + mu sync.RWMutex +} + +// NewDiscordService creates a DiscordService. +func NewDiscordService() *DiscordService { + s := &DiscordService{ + sessions: make(map[string]*DiscordRemoteAuthSession), + } + go s.cleanupLoop() + return s +} + +func (s *DiscordService) cleanupLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + s.cleanupStale() + } +} + +func (s *DiscordService) cleanupStale() { + s.mu.Lock() + defer s.mu.Unlock() + now := float64(time.Now().Unix()) + for id, sess := range s.sessions { + if now-sess.CreatedAt > SessionTTLSec { + sess.Cancel() + delete(s.sessions, id) + } + } +} + +// StartSession creates a new Remote Auth session and kicks off the gateway listener in background. +func (s *DiscordService) StartSession(ctx context.Context) (*models.StartRemoteAuthResponse, error) { + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, fmt.Errorf("failed to generate RSA key: %w", err) + } + + spkiDER, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) + if err != nil { + return nil, fmt.Errorf("failed to marshal public key: %w", err) + } + pubKeyB64 := base64.StdEncoding.EncodeToString(spkiDER) + + idBytes := make([]byte, 16) + _, _ = rand.Read(idBytes) + sessionID := fmt.Sprintf("%x-%x-%x-%x-%x", idBytes[0:4], idBytes[4:6], idBytes[6:8], idBytes[8:10], idBytes[10:16]) + + sessCtx, cancel := context.WithCancel(context.Background()) + sess := &DiscordRemoteAuthSession{ + SessionID: sessionID, + PrivKey: privKey, + PubKeyB64: pubKeyB64, + Status: "init", + CreatedAt: float64(time.Now().Unix()), + EventsChan: make(chan map[string]any, 64), + CancelFunc: cancel, + } + + s.mu.Lock() + s.sessions[sessionID] = sess + s.mu.Unlock() + + go sess.Run(sessCtx) + + // Wait up to 4 seconds for initial QR fingerprint to appear + timeout := time.After(4 * time.Second) + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-timeout: + return &models.StartRemoteAuthResponse{ + SessionID: sessionID, + Status: sess.GetStatus(), + }, nil + case <-ticker.C: + sess.mu.RLock() + fp := sess.Fingerprint + qr := sess.QRURL + status := sess.Status + errMsg := sess.Error + sess.mu.RUnlock() + + if errMsg != "" { + return nil, errors.New(errMsg) + } + if fp != "" { + return &models.StartRemoteAuthResponse{ + SessionID: sessionID, + Status: status, + URL: qr, + Fingerprint: fp, + }, nil + } + } + } +} + +// CreateWSSession starts a remote auth session for direct WebSocket streaming. +func (s *DiscordService) CreateWSSession(ctx context.Context) (*DiscordRemoteAuthSession, error) { + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, fmt.Errorf("failed to generate RSA key: %w", err) + } + + spkiDER, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) + if err != nil { + return nil, fmt.Errorf("failed to marshal public key: %w", err) + } + pubKeyB64 := base64.StdEncoding.EncodeToString(spkiDER) + + idBytes := make([]byte, 16) + _, _ = rand.Read(idBytes) + sessionID := fmt.Sprintf("%x-%x-%x-%x-%x", idBytes[0:4], idBytes[4:6], idBytes[6:8], idBytes[8:10], idBytes[10:16]) + + sessCtx, cancel := context.WithCancel(ctx) + sess := &DiscordRemoteAuthSession{ + SessionID: sessionID, + PrivKey: privKey, + PubKeyB64: pubKeyB64, + Status: "init", + CreatedAt: float64(time.Now().Unix()), + EventsChan: make(chan map[string]any, 64), + CancelFunc: cancel, + } + + s.mu.Lock() + s.sessions[sessionID] = sess + s.mu.Unlock() + + go sess.Run(sessCtx) + return sess, nil +} + +// GetStatus returns the current status and user/token if completed. +func (s *DiscordService) GetStatus(sessionID string) (*models.RemoteAuthStatusResponse, error) { + s.mu.RLock() + sess, ok := s.sessions[sessionID] + s.mu.RUnlock() + if !ok { + return nil, errors.New("session not found or expired") + } + + sess.mu.RLock() + defer sess.mu.RUnlock() + + return &models.RemoteAuthStatusResponse{ + SessionID: sess.SessionID, + Status: sess.Status, + URL: sess.QRURL, + Fingerprint: sess.Fingerprint, + User: sess.UserInfo, + Token: sess.Token, + Error: sess.Error, + }, nil +} + +// CancelSession terminates an active session. +func (s *DiscordService) CancelSession(sessionID string) error { + s.mu.Lock() + sess, ok := s.sessions[sessionID] + if ok { + delete(s.sessions, sessionID) + } + s.mu.Unlock() + + if ok && sess != nil { + sess.Cancel() + } + return nil +} + +// GetSession retrieves session by ID. +func (s *DiscordService) GetSession(sessionID string) *DiscordRemoteAuthSession { + s.mu.RLock() + defer s.mu.RUnlock() + return s.sessions[sessionID] +} + +// Cancel terminates the session context. +func (sess *DiscordRemoteAuthSession) Cancel() { + sess.mu.Lock() + sess.Cancelled = true + sess.Status = "cancelled" + sess.mu.Unlock() + if sess.CancelFunc != nil { + sess.CancelFunc() + } +} + +// GetStatus gets status safely. +func (sess *DiscordRemoteAuthSession) GetStatus() string { + sess.mu.RLock() + defer sess.mu.RUnlock() + return sess.Status +} + +func (sess *DiscordRemoteAuthSession) decryptOAEP(encB64 string) ([]byte, error) { + cipherBytes, err := base64.StdEncoding.DecodeString(encB64) + if err != nil { + return nil, err + } + return rsa.DecryptOAEP(sha256.New(), rand.Reader, sess.PrivKey, cipherBytes, nil) +} + +func (sess *DiscordRemoteAuthSession) emit(event map[string]any) { + select { + case sess.EventsChan <- event: + default: + } +} + +// Run manages the WebSocket connection to Discord's Remote Auth Gateway. +func (sess *DiscordRemoteAuthSession) Run(ctx context.Context) { + defer func() { + close(sess.EventsChan) + }() + + header := make(http.Header) + header.Set("Origin", "https://discord.com") + header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36") + + conn, _, err := websocket.Dial(ctx, DiscordGatewayURL, &websocket.DialOptions{ + HTTPHeader: header, + }) + if err != nil { + sess.mu.Lock() + sess.Error = err.Error() + sess.Status = "error" + sess.mu.Unlock() + sess.emit(map[string]any{"type": "error", "message": err.Error()}) + return + } + defer conn.Close(websocket.StatusNormalClosure, "done") + + sess.emit(map[string]any{"type": "status", "status": "connecting", "session_id": sess.SessionID}) + + var heartbeatCancel context.CancelFunc + + for { + select { + case <-ctx.Done(): + if heartbeatCancel != nil { + heartbeatCancel() + } + return + default: + } + + _, data, err := conn.Read(ctx) + if err != nil { + if !sess.Cancelled { + sess.mu.Lock() + sess.Status = "error" + sess.Error = err.Error() + sess.mu.Unlock() + sess.emit(map[string]any{"type": "error", "message": err.Error()}) + } + if heartbeatCancel != nil { + heartbeatCancel() + } + return + } + + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + continue + } + + op, _ := payload["op"].(string) + + switch op { + case "hello": + intervalMs := 41250.0 + if v, ok := payload["heartbeat_interval"].(float64); ok && v > 0 { + intervalMs = v + } + hbCtx, hbCancel := context.WithCancel(ctx) + heartbeatCancel = hbCancel + go sess.heartbeatLoop(hbCtx, conn, intervalMs) + + initMsg := map[string]any{ + "op": "init", + "encoded_public_key": sess.PubKeyB64, + } + initBytes, _ := json.Marshal(initMsg) + _ = conn.Write(ctx, websocket.MessageText, initBytes) + + case "nonce_proof": + encNonce, _ := payload["encrypted_nonce"].(string) + decrypted, err := sess.decryptOAEP(encNonce) + if err != nil { + sess.mu.Lock() + sess.Status = "error" + sess.Error = "nonce proof failed: " + err.Error() + sess.mu.Unlock() + sess.emit(map[string]any{"type": "error", "message": sess.Error}) + return + } + digest := sha256.Sum256(decrypted) + proof := base64.RawURLEncoding.EncodeToString(digest[:]) + proofMsg := map[string]any{ + "op": "nonce_proof", + "proof": proof, + } + proofBytes, _ := json.Marshal(proofMsg) + _ = conn.Write(ctx, websocket.MessageText, proofBytes) + + case "fingerprint", "pending_remote_init": + fp, _ := payload["fingerprint"].(string) + if fp != "" && sess.Fingerprint == "" { + sess.mu.Lock() + sess.Fingerprint = fp + sess.QRURL = fmt.Sprintf("https://discord.com/ra/%s", fp) + sess.Status = "waiting_scan" + sess.mu.Unlock() + + sess.emit(map[string]any{ + "type": "qr", + "url": sess.QRURL, + "fingerprint": fp, + "session_id": sess.SessionID, + }) + } + + if userMap, ok := payload["user"].(map[string]any); ok && userMap != nil { + sess.mu.Lock() + sess.UserInfo = userMap + sess.Status = "scanned" + sess.mu.Unlock() + + sess.emit(map[string]any{ + "type": "scanned", + "user": userMap, + "session_id": sess.SessionID, + }) + } + + case "pending_ticket": + encUser, _ := payload["encrypted_user_payload"].(string) + if encUser != "" { + if decUser, err := sess.decryptOAEP(encUser); err == nil { + parts := strings.Split(string(decUser), ":") + uID := "" + uDisc := "0" + uAv := "" + uName := "Discord User" + if len(parts) > 0 { + uID = parts[0] + } + if len(parts) > 1 { + uDisc = parts[1] + } + if len(parts) > 2 && parts[2] != "None" { + uAv = parts[2] + } + if len(parts) > 3 { + uName = parts[3] + } + sess.mu.Lock() + sess.UserInfo = map[string]any{ + "id": uID, + "username": uName, + "discriminator": uDisc, + "avatar": uAv, + } + sess.Status = "scanned" + sess.mu.Unlock() + } + } + sess.emit(map[string]any{ + "type": "scanned", + "user": sess.UserInfo, + "session_id": sess.SessionID, + }) + + case "pending_login": + ticket, _ := payload["ticket"].(string) + if ticket == "" { + continue + } + + sess.mu.Lock() + sess.Status = "confirming" + sess.mu.Unlock() + sess.emit(map[string]any{"type": "status", "status": "confirming", "session_id": sess.SessionID}) + + // Exchange ticket with Discord REST API + token, err := sess.exchangeTicket(ctx, ticket) + if err != nil { + sess.mu.Lock() + sess.Status = "error" + sess.Error = err.Error() + sess.mu.Unlock() + sess.emit(map[string]any{"type": "error", "message": err.Error()}) + return + } + + sess.mu.Lock() + sess.Token = token + sess.Status = "success" + sess.mu.Unlock() + + sess.emit(map[string]any{ + "type": "success", + "token": token, + "user": sess.UserInfo, + "session_id": sess.SessionID, + }) + + // Graceful cancel after completion + cancelMsg, _ := json.Marshal(map[string]any{"op": "cancel"}) + _ = conn.Write(ctx, websocket.MessageText, cancelMsg) + return + + case "cancel": + sess.mu.Lock() + sess.Status = "cancelled" + sess.mu.Unlock() + sess.emit(map[string]any{"type": "cancelled", "session_id": sess.SessionID}) + return + } + } +} + +func (sess *DiscordRemoteAuthSession) heartbeatLoop(ctx context.Context, conn *websocket.Conn, intervalMs float64) { + intervalSec := (intervalMs / 1000.0) * 0.9 + if intervalSec < 5.0 { + intervalSec = 5.0 + } + ticker := time.NewTicker(time.Duration(intervalSec * float64(time.Second))) + defer ticker.Stop() + + hbMsg, _ := json.Marshal(map[string]any{"op": "heartbeat"}) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := conn.Write(ctx, websocket.MessageText, hbMsg); err != nil { + return + } + } + } +} + +func (sess *DiscordRemoteAuthSession) exchangeTicket(ctx context.Context, ticket string) (string, error) { + body, _ := json.Marshal(map[string]any{"ticket": ticket}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, DiscordLoginURL, bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("ticket exchange returned HTTP %d: %s", resp.StatusCode, string(respBody)) + } + + var data struct { + EncryptedToken string `json:"encrypted_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", err + } + if data.EncryptedToken == "" { + return "", errors.New("missing encrypted_token in response") + } + + decryptedToken, err := sess.decryptOAEP(data.EncryptedToken) + if err != nil { + return "", fmt.Errorf("failed to decrypt user token: %w", err) + } + + return string(decryptedToken), nil +} + +// ResolveExternalAssets proxies external asset requests to Discord API. +func (s *DiscordService) ResolveExternalAssets(ctx context.Context, req models.ExternalAssetsRequest) ([]map[string]any, error) { + appID := req.ApplicationID + if appID == "" { + appID = "1547543416143876167" + } + token := strings.TrimSpace(req.Token) + if token == "" || len(req.URLs) == 0 { + return []map[string]any{}, nil + } + + discordURL := fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", appID) + body, _ := json.Marshal(map[string]any{"urls": req.URLs}) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, discordURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Authorization", token) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36") + + client := &http.Client{Timeout: 8 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return []map[string]any{}, nil + } + + var results []map[string]any + if err := json.NewDecoder(resp.Body).Decode(&results); err != nil { + return nil, err + } + return results, nil +} diff --git a/internal/services/enrichment_service.go b/internal/services/enrichment_service.go new file mode 100644 index 0000000..0bb7ffd --- /dev/null +++ b/internal/services/enrichment_service.go @@ -0,0 +1,721 @@ +package services + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "strings" + "sync" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "streamgo/internal/database" + "streamgo/internal/logger" + "streamgo/internal/metadata" + "streamgo/internal/models" +) + +var logEnrich = logger.New("enrichment") + +// MediaDownloader downloads partial media bytes for an indexed file_id or track. +type MediaDownloader interface { + DownloadPartialByFileID(ctx context.Context, fileID string, maxBytes int64, w io.Writer) error + DownloadPartialForTrack(ctx context.Context, track *models.Track, maxBytes int64, w io.Writer, onRefreshed func(botID, fileID string)) error + DownloadDocumentThumbnailForTrack(ctx context.Context, track *models.Track, w io.Writer) error +} + +// EnrichmentService runs background worker goroutines to enrich track metadata. +type EnrichmentService struct { + tracksCol *mongo.Collection + artistsCol *mongo.Collection + albumsCol *mongo.Collection + + coverSearch *CoverSearchService + lyricsSvc *LyricsEnrichmentService + downloader MediaDownloader + r2Storage *R2StorageService + artworkSvc *ArtworkService + + workQueue chan string + inFlight sync.Map + wg sync.WaitGroup +} + +// NewEnrichmentService creates a new EnrichmentService. +func NewEnrichmentService( + db *database.Client, + coverSearch *CoverSearchService, + lyricsSvc *LyricsEnrichmentService, +) *EnrichmentService { + var tracksCol, artistsCol, albumsCol *mongo.Collection + if db != nil { + tracksCol = db.Collection("audioTracks") + artistsCol = db.Collection("artists") + albumsCol = db.Collection("albums") + } + + return &EnrichmentService{ + tracksCol: tracksCol, + artistsCol: artistsCol, + albumsCol: albumsCol, + coverSearch: coverSearch, + lyricsSvc: lyricsSvc, + artworkSvc: NewArtworkService(), + r2Storage: NewR2StorageService(nil), + workQueue: make(chan string, 500), + } +} + +// SetDownloader configures the MediaDownloader implementation for streaming partial media. +func (s *EnrichmentService) SetDownloader(d MediaDownloader) { + s.downloader = d +} + +// SetR2Storage configures the optional Cloudflare R2 storage service. +func (s *EnrichmentService) SetR2Storage(r2 *R2StorageService) { + if r2 != nil { + s.r2Storage = r2 + } +} + +// TriggerEnrich pushes a track ID to the priority enrichment queue. +func (s *EnrichmentService) TriggerEnrich(trackID string) { + trackID = strings.TrimSpace(trackID) + if trackID == "" { + return + } + if _, loaded := s.inFlight.LoadOrStore(trackID, struct{}{}); loaded { + return // already queued or currently being enriched + } + select { + case s.workQueue <- trackID: + default: + s.inFlight.Delete(trackID) + logEnrich.Warnf("enrichment queue full, dropping immediate trigger for %s", trackID) + } +} + +// Start launches the background worker goroutines and the periodic poller. +func (s *EnrichmentService) Start(ctx context.Context, numWorkers int) { + if s.tracksCol == nil { + return + } + if numWorkers <= 0 { + numWorkers = 2 + } + + logEnrich.Infof("Starting background enrichment pool with %d workers", numWorkers) + + for i := 0; i < numWorkers; i++ { + s.wg.Add(1) + go s.workerLoop(ctx, i+1) + } + + // Periodic polling loop finding unenriched tracks + go s.pollLoop(ctx) +} + +func (s *EnrichmentService) pollLoop(ctx context.Context) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.enqueueUnenrichedTracks(ctx) + } + } +} + +func (s *EnrichmentService) enqueueUnenrichedTracks(ctx context.Context) { + staleThreshold := float64(time.Now().Unix() - 300) // 5 minutes + filter := bson.M{ + "enriched": bson.M{"$ne": true}, + "deleted": bson.M{"$ne": true}, + "$or": []bson.M{ + {"enriching": bson.M{"$ne": true}}, + {"enrichment_started_at": bson.M{"$lt": staleThreshold}}, + {"enrichment_started_at": bson.M{"$exists": false}}, + }, + } + opts := options.Find().SetLimit(50).SetProjection(bson.M{"_id": 1}) + + cursor, err := s.tracksCol.Find(ctx, filter, opts) + if err != nil { + return + } + defer cursor.Close(ctx) + + for cursor.Next(ctx) { + var doc struct { + ID string `bson:"_id"` + } + if err := cursor.Decode(&doc); err == nil && doc.ID != "" { + if _, loaded := s.inFlight.LoadOrStore(doc.ID, struct{}{}); loaded { + continue // already in flight + } + select { + case s.workQueue <- doc.ID: + default: + s.inFlight.Delete(doc.ID) + return + } + } + } +} + +func (s *EnrichmentService) workerLoop(ctx context.Context, workerID int) { + defer s.wg.Done() + + for { + select { + case <-ctx.Done(): + return + case trackID := <-s.workQueue: + s.enrichSingleTrack(ctx, trackID) + } + } +} + +func (s *EnrichmentService) enrichSingleTrack(ctx context.Context, trackID string) { + defer s.inFlight.Delete(trackID) + + now := float64(time.Now().Unix()) + staleThreshold := now - 300 + + // Atomically claim the track so no other worker or poller can process it concurrently + claimFilter := bson.M{ + "_id": trackID, + "enriched": bson.M{"$ne": true}, + "deleted": bson.M{"$ne": true}, + "$or": []bson.M{ + {"enriching": bson.M{"$ne": true}}, + {"enrichment_started_at": bson.M{"$lt": staleThreshold}}, + {"enrichment_started_at": bson.M{"$exists": false}}, + }, + } + claimUpdate := bson.M{ + "$set": bson.M{ + "enriching": true, + "enrichment_started_at": now, + }, + } + + var track models.Track + err := s.tracksCol.FindOneAndUpdate( + ctx, + claimFilter, + claimUpdate, + options.FindOneAndUpdate().SetReturnDocument(options.After), + ).Decode(&track) + if err != nil { + // Already enriched, deleted, or claimed by another concurrent worker + return + } + + title := track.Audio.Title + artist := track.EffectiveArtist() + album := track.Audio.Album + durationSec := track.Audio.DurationSec + year := track.Audio.Year + + cleanTitle, cleanArtist := CleanMetadata(title, artist) + if cleanTitle != title { + title = cleanTitle + } + if cleanArtist != artist { + artist = cleanArtist + } + + nowTs := float64(time.Now().Unix()) + updateFields := bson.M{ + "enriched": true, + "enriched_at": nowTs, + "updated_at": nowTs, + } + if cleanTitle != track.Audio.Title { + updateFields["audio.title"] = cleanTitle + } + if cleanArtist != track.Audio.Artist { + updateFields["audio.artist"] = cleanArtist + updateFields["audio.artists"] = SplitArtists(cleanArtist) + } + + // 1. Partial Media Download & MediaInfo extraction (matching Python StreamXBot) + if s.downloader != nil { + tmpFile, err := os.CreateTemp("", fmt.Sprintf("streamgo_mi_%s_*.part", track.ID)) + if err == nil { + tmpPath := tmpFile.Name() + dlErr := s.downloader.DownloadPartialForTrack(ctx, &track, 2_500_000, tmpFile, func(botID, fileID string) { + _ = s.tracksCol.FindOneAndUpdate(ctx, bson.M{"_id": trackID}, bson.M{ + "$set": bson.M{ + fmt.Sprintf("telegram.file_ids.%s", botID): fileID, + "updated_at": float64(time.Now().Unix()), + }, + }) + }) + tmpFile.Close() + defer os.Remove(tmpPath) + + if dlErr == nil || dlErr == io.EOF { + // A. Compute Content Hash (sha256 prefix of downloaded chunk) + if ch, err := SHA256PrefixFile(tmpPath, 10*1024*1024); err == nil && ch != "" { + updateFields["content_hash"] = ch + } + + // B. Run MediaInfo + var detectedType string + if output, err := RunMediaInfo(tmpPath); err == nil && output != "" { + meta := ParseMediaInfo(output, track.Audio.DurationSec, track.Telegram.FileSize) + if meta != nil { + if meta.Title != "" { + title = meta.Title + updateFields["audio.title"] = meta.Title + } + if meta.Artist != "" { + artist = meta.Artist + updateFields["audio.artist"] = meta.Artist + } + if len(meta.Artists) > 0 { + updateFields["audio.artists"] = meta.Artists + } + if meta.Album != "" { + album = meta.Album + updateFields["audio.album"] = meta.Album + } + if meta.Composer != "" { + updateFields["audio.composer"] = meta.Composer + } + if meta.Label != "" { + updateFields["audio.label"] = meta.Label + } + if meta.Genre != "" { + updateFields["audio.genre"] = meta.Genre + } + if meta.Year != nil { + year = meta.Year + updateFields["audio.year"] = *meta.Year + } + if meta.DurationSec > 0 { + durationSec = meta.DurationSec + updateFields["audio.duration_sec"] = meta.DurationSec + } + if meta.Type != "" { + detectedType = meta.Type + updateFields["audio.type"] = meta.Type + } + if meta.BitDepth != nil { + updateFields["audio.bit_depth"] = *meta.BitDepth + } + if meta.BitrateKbps != nil { + updateFields["audio.bitrate_kbps"] = *meta.BitrateKbps + } + if meta.SamplingRateHz != nil { + updateFields["audio.sampling_rate_hz"] = *meta.SamplingRateHz + } + if meta.AlbumID != "" { + updateFields["audio.album_id"] = meta.AlbumID + } else if album != "" { + updateFields["audio.album_id"] = GenerateAlbumID(album, year) + } + } + } else if err != nil { + logEnrich.Warnf("mediainfo failed on %s: %v", trackID, err) + } + + // C. Extract Artwork if R2 storage is configured + if s.r2Storage != nil && s.r2Storage.IsConfigured() { + albumID := track.Audio.AlbumID + if aid, ok := updateFields["audio.album_id"].(string); ok && aid != "" { + albumID = aid + } else if albumID == "" && album != "" { + albumID = GenerateAlbumID(album, year) + } + + // Sibling check: If album already has R2 covers cached, reuse them directly + if albumID != "" { + cachedCover, cachedBigCover := s.r2Storage.GetAlbumCovers(albumID) + // Cold start fallback: check database for existing enriched sibling in audioTracks + if cachedCover == "" && cachedBigCover == "" && s.tracksCol != nil { + var siblingDoc struct { + Spotify struct { + CloudflareCoverURL string `bson:"cloudflare_cover_url"` + CloudflareBigCoverURL string `bson:"cloudflare_big_cover_url"` + CoverURL string `bson:"cover_url"` + BigCoverURL string `bson:"big_cover_url"` + } `bson:"spotify"` + } + siblingFilter := bson.M{ + "audio.album_id": albumID, + "enriched": true, + "$or": []bson.M{ + {"spotify.cloudflare_cover_url": bson.M{"$exists": true, "$ne": ""}}, + {"spotify.cloudflare_big_cover_url": bson.M{"$exists": true, "$ne": ""}}, + }, + } + if err := s.tracksCol.FindOne(ctx, siblingFilter, options.FindOne().SetProjection(bson.M{"spotify": 1})).Decode(&siblingDoc); err == nil { + cachedCover = siblingDoc.Spotify.CloudflareCoverURL + cachedBigCover = siblingDoc.Spotify.CloudflareBigCoverURL + s.r2Storage.SetAlbumCovers(albumID, cachedCover, cachedBigCover) + if siblingDoc.Spotify.CoverURL != "" { + s.r2Storage.SetAlbumExternalCover(albumID, siblingDoc.Spotify.CoverURL) + } else if siblingDoc.Spotify.BigCoverURL != "" { + s.r2Storage.SetAlbumExternalCover(albumID, siblingDoc.Spotify.BigCoverURL) + } + } + } + + if cachedCover != "" { + updateFields["spotify.cloudflare_cover_url"] = cachedCover + } + if cachedBigCover != "" { + updateFields["spotify.cloudflare_big_cover_url"] = cachedBigCover + } + if cachedCover != "" || cachedBigCover != "" { + updateFields["spotify.cover_source"] = "r2_telegram" + } + } + + // If not cached, extract embedded master artwork first, or fall back to MTProto thumbnail + if updateFields["spotify.cloudflare_cover_url"] == nil && updateFields["spotify.cloudflare_big_cover_url"] == nil { + var r2ThumbURL, r2BigURL string + + // 1. Try embedded master artwork first (progressive 2.5MB -> 4.0MB slice fetch) + chunkBytes, readErr := os.ReadFile(tmpPath) + if readErr == nil && len(chunkBytes) > 0 { + formatHint := track.Audio.Type + if detectedType != "" { + formatHint = detectedType + } + artBytes, _, extractErr := s.artworkSvc.ExtractArtwork(chunkBytes, formatHint) + + // If 2.5MB slice cut off artwork, fetch next 1.5MB up to 4MB max + if extractErr != nil && s.artworkSvc.IsArtworkTruncated(chunkBytes, formatHint) && (track.Telegram.FileSize == 0 || track.Telegram.FileSize > int64(len(chunkBytes))) { + logEnrich.Infof("Embedded artwork truncated in 2.5MB slice for track %s; fetching up to 4MB", trackID) + tmpFile4M, err4M := os.CreateTemp("", fmt.Sprintf("streamgo_art_4m_%s_*.part", track.ID)) + if err4M == nil { + tmpPath4M := tmpFile4M.Name() + dl4MErr := s.downloader.DownloadPartialForTrack(ctx, &track, 4_000_000, tmpFile4M, nil) + tmpFile4M.Close() + if dl4MErr == nil || dl4MErr == io.EOF { + if chunkBytes4M, read4MErr := os.ReadFile(tmpPath4M); read4MErr == nil && len(chunkBytes4M) > len(chunkBytes) { + artBytes, _, extractErr = s.artworkSvc.ExtractArtwork(chunkBytes4M, formatHint) + } + } + _ = os.Remove(tmpPath4M) + } + } + + if extractErr == nil && len(artBytes) > 0 { + // A. Compress master artwork to WebP (1000x1000, Q=80) for main player + webpBigBytes, compErr := s.artworkSvc.CompressToWebP(ctx, artBytes, 1000, 80) + if compErr == nil && len(webpBigBytes) > 0 { + artHash := sha256Hex(webpBigBytes) + uploadedBig, upErr := s.r2Storage.UploadCover(ctx, artHash, webpBigBytes, "image/webp") + if upErr == nil && uploadedBig != "" { + r2BigURL = uploadedBig + updateFields["spotify.cloudflare_big_cover_url"] = r2BigURL + logEnrich.Infof("Enriched track %s with R2 master artwork (%s)", trackID, r2BigURL) + } else if upErr != nil { + logEnrich.Warnf("Failed to upload master artwork to R2 for track %s: %v", trackID, upErr) + } + } else if compErr != nil { + logEnrich.Warnf("Failed to compress master artwork to WebP for track %s: %v", trackID, compErr) + } + + // B. Derive 200x200 preview WebP (Q=75) from embedded big cover for list views + webpPrevBytes, prevErr := s.artworkSvc.CompressToWebP(ctx, artBytes, 200, 75) + if prevErr == nil && len(webpPrevBytes) > 0 { + prevHash := sha256Hex(webpPrevBytes) + uploadedPrev, upPrevErr := s.r2Storage.UploadCover(ctx, prevHash, webpPrevBytes, "image/webp") + if upPrevErr == nil && uploadedPrev != "" { + r2ThumbURL = uploadedPrev + updateFields["spotify.cloudflare_cover_url"] = r2ThumbURL + logEnrich.Infof("Enriched track %s with R2 embedded preview thumbnail (%s)", trackID, r2ThumbURL) + } else if upPrevErr != nil { + logEnrich.Warnf("Failed to upload embedded preview to R2 for track %s: %v", trackID, upPrevErr) + } + } else if prevErr != nil { + logEnrich.Warnf("Failed to compress embedded preview to WebP for track %s: %v", trackID, prevErr) + } + } + } + + // 2. Fallback to MTProto document thumbnail ONLY if embedded artwork is unavailable + if r2ThumbURL == "" && r2BigURL == "" && s.downloader != nil { + var thumbBuf bytes.Buffer + if dlThumbErr := s.downloader.DownloadDocumentThumbnailForTrack(ctx, &track, &thumbBuf); dlThumbErr == nil && thumbBuf.Len() > 0 { + thumbBytes := thumbBuf.Bytes() + var uploadBytes []byte = thumbBytes + var uploadMime string = "image/jpeg" + // Compress thumbnail to 200x200 WebP + if thumbWebP, compErr := s.artworkSvc.CompressToWebP(ctx, thumbBytes, 200, 75); compErr == nil && len(thumbWebP) > 0 { + uploadBytes = thumbWebP + uploadMime = "image/webp" + } + thumbHash := sha256Hex(uploadBytes) + uploadedThumb, upErr := s.r2Storage.UploadCover(ctx, thumbHash, uploadBytes, uploadMime) + if upErr == nil && uploadedThumb != "" { + r2ThumbURL = uploadedThumb + updateFields["spotify.cloudflare_cover_url"] = r2ThumbURL + logEnrich.Infof("Enriched track %s with R2 MTProto preview thumbnail (%s)", trackID, r2ThumbURL) + } else if upErr != nil { + logEnrich.Warnf("Failed to upload MTProto thumbnail to R2 for track %s: %v", trackID, upErr) + } + } + } + + if r2ThumbURL != "" || r2BigURL != "" { + updateFields["spotify.cover_source"] = "r2_telegram" + if albumID != "" { + s.r2Storage.SetAlbumCovers(albumID, r2ThumbURL, r2BigURL) + } + } + } + } + } else { + logEnrich.Warnf("partial download failed for track %s: %v", trackID, dlErr) + } + } + } + + if title == "" { + return + } + + // 2. Calculate and set metadata fingerprint (will include resolved album) + fp := BuildMetadataFingerprint(title, artist, album, durationSec) + if fp != "" { + updateFields["fingerprint"] = fp + } + + // 3. Ensure album_id is set if album is present + if album != "" && updateFields["audio.album_id"] == nil && track.Audio.AlbumID == "" { + aid := GenerateAlbumID(album, year) + if aid != "" { + updateFields["audio.album_id"] = aid + } + } + + // 4. Fetch cover art if missing (stores in spotify, NEVER in audio) + // iTunes/Deezer enrichment runs unconditionally (even if R2 credentials are provided/uploaded) + if track.Spotify.CoverURL == "" && updateFields["spotify.cover_url"] == nil { + albumID := track.Audio.AlbumID + if aid, ok := updateFields["audio.album_id"].(string); ok && aid != "" { + albumID = aid + } else if albumID == "" && album != "" { + albumID = GenerateAlbumID(album, year) + } + + var externalCover string + + // A. Check album sibling cache first + if albumID != "" && s.r2Storage != nil { + externalCover = s.r2Storage.GetAlbumExternalCover(albumID) + } + + // B. Cold start database fallback if albumID is known + if externalCover == "" && albumID != "" && s.tracksCol != nil { + var siblingDoc struct { + Spotify struct { + CoverURL string `bson:"cover_url"` + BigCoverURL string `bson:"big_cover_url"` + } `bson:"spotify"` + } + siblingFilter := bson.M{ + "audio.album_id": albumID, + "enriched": true, + "$or": []bson.M{ + {"spotify.cover_url": bson.M{"$exists": true, "$ne": ""}}, + {"spotify.big_cover_url": bson.M{"$exists": true, "$ne": ""}}, + }, + } + if err := s.tracksCol.FindOne(ctx, siblingFilter, options.FindOne().SetProjection(bson.M{"spotify": 1})).Decode(&siblingDoc); err == nil { + if siblingDoc.Spotify.CoverURL != "" { + externalCover = siblingDoc.Spotify.CoverURL + } else if siblingDoc.Spotify.BigCoverURL != "" { + externalCover = siblingDoc.Spotify.BigCoverURL + } + if externalCover != "" && s.r2Storage != nil { + s.r2Storage.SetAlbumExternalCover(albumID, externalCover) + } + } + } + + // C. Perform external iTunes / Deezer search if not found in sibling cache + if externalCover == "" && s.coverSearch != nil { + coverURL, _, err := s.coverSearch.FindBestCover(ctx, title, artist, album) + if err == nil && coverURL != "" { + externalCover = coverURL + if albumID != "" && s.r2Storage != nil { + s.r2Storage.SetAlbumExternalCover(albumID, externalCover) + } + } + } + + if externalCover != "" { + updateFields["spotify.cover_url"] = externalCover + updateFields["spotify.big_cover_url"] = externalCover + if updateFields["spotify.cover_source"] == nil && track.Spotify.CoverSource == "" { + updateFields["spotify.cover_source"] = "itunes" + } + } + } else if track.Spotify.BigCoverURL == "" && updateFields["spotify.big_cover_url"] == nil { + if c, ok := updateFields["spotify.cover_url"].(string); ok && c != "" { + updateFields["spotify.big_cover_url"] = c + } else if track.Spotify.CoverURL != "" { + updateFields["spotify.big_cover_url"] = track.Spotify.CoverURL + } + } + + // 5. Fetch artist avatar + if artist != "" && track.Spotify.ArtistAvatar == "" { + artistCover, _ := s.coverSearch.FindArtistAvatar(ctx, artist) + if artistCover != "" { + updateFields["spotify.artist_avatar"] = artistCover + } + } + + // 6. Fetch lyrics and multilingual/romanized titles + if s.lyricsSvc != nil { + res, err := s.lyricsSvc.FetchLyrics(ctx, title, artist, album) + if err == nil && res != nil { + if res.Lyrics != "" && (track.Lyrics == "" || track.LyricsCache == nil || track.LyricsCache["kind"] != "richsync") { + updateFields["lyrics"] = res.Lyrics + kind := res.Kind + if kind == "" { + kind = "lrc" + } + source := res.Source + if source == "" { + source = "musixmatch" + } + updateFields["lyrics_cache"] = bson.M{ + "kind": kind, + "source": source, + "text": res.Lyrics, + "updated_at": nowTs, + } + } + if len(res.Titles) > 0 { + updateFields["titles"] = res.Titles + updateFields["audio.titles"] = res.Titles + } + } + } + + // 7. Ensure titles and audio.titles are populated + if updateFields["titles"] == nil { + if len(track.Titles) > 0 { + cleanOriginal, _ := CleanTitle(fmt.Sprint(track.Titles["original"]), artist) + if cleanOriginal != "" { + track.Titles["original"] = cleanOriginal + } + updateFields["titles"] = track.Titles + updateFields["audio.titles"] = track.Titles + } else { + updateFields["titles"] = bson.M{"original": title} + updateFields["audio.titles"] = bson.M{"original": title} + } + } else if updateFields["audio.titles"] == nil { + updateFields["audio.titles"] = updateFields["titles"] + } + + // 8. Clean unsetting of legacy / erroneous fields inside audio subdocument (preserving audio.titles!) + unsetFields := bson.M{ + "audio.lyrics": "", + "audio.cover_url": "", + "audio.file_size": "", + "audio.mime_type": "", + "enriching": "", + "enrichment_error": "", + "enrichment_started_at": "", + } + + // 9. Update track document cleanly + _, err = s.tracksCol.UpdateOne(ctx, bson.M{"_id": trackID}, bson.M{ + "$set": updateFields, + "$unset": unsetFields, + }) + if err != nil { + logEnrich.Errorf("Failed to update track %s in mongo: %v", trackID, err) + return + } + + // 10. Update artist entity incrementally + if artist != "" && s.artistsCol != nil { + artistID := metadata.ArtistID(artist) + artistCover, _ := s.coverSearch.FindArtistAvatar(ctx, artist) + artistUpdate := bson.M{ + "$setOnInsert": bson.M{ + "_id": artistID, + "name": artist, + "match_artist": strings.ToLower(artist), + "created_at": nowTs, + "followers": 0, + }, + "$set": bson.M{ + "updated_at": nowTs, + }, + "$inc": bson.M{ + "tracks_count": 1, + }, + } + if artistCover != "" { + artistUpdate["$set"].(bson.M)["cover_url"] = artistCover + } + _, _ = s.artistsCol.UpdateOne(ctx, bson.M{"_id": artistID}, artistUpdate, options.UpdateOne().SetUpsert(true)) + } + + // 11. Update album entity incrementally + if album != "" && s.albumsCol != nil { + albumID := GenerateAlbumID(album, year) + bestCover := track.EffectiveBigCoverURL() + if c, ok := updateFields["spotify.cloudflare_big_cover_url"].(string); ok && c != "" { + bestCover = c + } else if c, ok := updateFields["spotify.cloudflare_cover_url"].(string); ok && c != "" { + bestCover = c + } else if c, ok := updateFields["spotify.cover_url"].(string); ok && c != "" { + bestCover = c + } + albumUpdate := bson.M{ + "$setOnInsert": bson.M{ + "_id": albumID, + "title": album, + "artist": artist, + "artists": []string{artist}, + "match_album": strings.ToLower(album), + "match_artist": strings.ToLower(artist), + "created_at": nowTs, + }, + "$set": bson.M{ + "updated_at": nowTs, + }, + "$inc": bson.M{ + "tracks_count": 1, + "duration_total": durationSec, + }, + } + if year != nil { + albumUpdate["$set"].(bson.M)["year"] = *year + } + if bestCover != "" { + albumUpdate["$set"].(bson.M)["cover_url"] = bestCover + } + _, _ = s.albumsCol.UpdateOne(ctx, bson.M{"_id": albumID}, albumUpdate, options.UpdateOne().SetUpsert(true)) + } + + logEnrich.Infof("Enriched track %s (%s - %s)", trackID, artist, title) +} + +// Stop gracefully waits for in-flight enrichment workers to exit upon context cancellation. +func (s *EnrichmentService) Stop() { + s.wg.Wait() +} diff --git a/internal/services/favourite_playlist_service.go b/internal/services/favourite_playlist_service.go new file mode 100644 index 0000000..f037407 --- /dev/null +++ b/internal/services/favourite_playlist_service.go @@ -0,0 +1,509 @@ +package services + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "streamgo/internal/models" + "streamgo/internal/repository" +) + +// FavouritePlaylistService orchestrates user favourites, custom playlists, albums, and history. +type FavouritePlaylistService struct { + favRepo repository.FavouritePlaylistRepository + trackRepo repository.TrackRepository + artistAlbumRepo repository.ArtistAlbumRepository +} + +// NewFavouritePlaylistService creates a new FavouritePlaylistService. +func NewFavouritePlaylistService( + favRepo repository.FavouritePlaylistRepository, + trackRepo repository.TrackRepository, + artistAlbumRepo repository.ArtistAlbumRepository, +) *FavouritePlaylistService { + return &FavouritePlaylistService{ + favRepo: favRepo, + trackRepo: trackRepo, + artistAlbumRepo: artistAlbumRepo, + } +} + +// Helper to extract track cover URL +func extractTrackThumbnail(t *models.Track) string { + if t == nil { + return "" + } + return t.EffectiveCoverURL() +} + +// Helper to assemble up to 4 thumbnail URLs for playlist collage preview +func buildPlaylistThumbnails(coverURL string, trackThumbs []string, limit int) []string { + var out []string + seen := make(map[string]bool) + + for _, u := range trackThumbs { + if len(out) >= limit { + break + } + u = strings.TrimSpace(u) + if u != "" && !seen[u] { + seen[u] = true + out = append(out, u) + } + } + + if coverURL != "" && !seen[coverURL] && len(out) < limit { + seen[coverURL] = true + out = append(out, coverURL) + } + + return out +} + +// CreatePlaylist creates a new user playlist with a generated UUID. +func (s *FavouritePlaylistService) CreatePlaylist(ctx context.Context, userID int64, name string) (*models.PlaylistItem, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("name is required") + } + + idBytes := make([]byte, 16) + _, _ = rand.Read(idBytes) + playlistID := hex.EncodeToString(idBytes) + + now := float64(time.Now().Unix()) + coverID := fmt.Sprintf("cover_%s", playlistID) + coverURL := fmt.Sprintf("/covers/user-playlist/%s.png", playlistID) + normalThumbnail := coverURL + + p := &models.UserPlaylist{ + ID: playlistID, + UserID: userID, + Name: name, + CoverID: coverID, + CoverURL: coverURL, + NormalThumbnail: normalThumbnail, + CreatedAt: now, + UpdatedAt: now, + } + + if err := s.favRepo.CreatePlaylist(ctx, p); err != nil { + return nil, err + } + + return &models.PlaylistItem{ + PlaylistID: playlistID, + Name: name, + Thumbnails: buildPlaylistThumbnails(coverURL, nil, 4), + CoverID: coverID, + CoverURL: coverURL, + NormalThumbnail: normalThumbnail, + CreatedAt: now, + UpdatedAt: now, + }, nil +} + +// GetUserPlaylists returns all playlists owned by user with up to 4 track cover thumbnails for collages. +func (s *FavouritePlaylistService) GetUserPlaylists(ctx context.Context, userID int64) (*models.PlaylistsResponse, error) { + rawPlaylists, err := s.favRepo.GetUserPlaylists(ctx, userID) + if err != nil { + return nil, err + } + + items := make([]models.PlaylistItem, 0, len(rawPlaylists)) + for _, p := range rawPlaylists { + firstIDs, _ := s.favRepo.GetPlaylistFirstTrackIDs(ctx, p.ID, 4) + var trackThumbs []string + if len(firstIDs) > 0 { + tracks, _ := s.trackRepo.GetByIDs(ctx, firstIDs) + for _, t := range tracks { + if thumb := extractTrackThumbnail(t); thumb != "" { + trackThumbs = append(trackThumbs, thumb) + } + } + } + + items = append(items, models.PlaylistItem{ + PlaylistID: p.ID, + Name: p.Name, + Thumbnails: buildPlaylistThumbnails(p.CoverURL, trackThumbs, 4), + CoverURL: p.CoverURL, + NormalThumbnail: p.NormalThumbnail, + CoverID: p.CoverID, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + }) + } + + return &models.PlaylistsResponse{Items: items}, nil +} + +// RenamePlaylist renames an existing user playlist. +func (s *FavouritePlaylistService) RenamePlaylist(ctx context.Context, playlistID string, userID int64, name string) (*models.PlaylistItem, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("name is required") + } + + coverID := fmt.Sprintf("cover_%s", playlistID) + coverURL := fmt.Sprintf("/covers/user-playlist/%s.png", playlistID) + normalThumbnail := coverURL + + p, err := s.favRepo.RenamePlaylist(ctx, playlistID, userID, name, coverID, coverURL, normalThumbnail) + if err != nil { + return nil, err + } + + firstIDs, _ := s.favRepo.GetPlaylistFirstTrackIDs(ctx, playlistID, 4) + var trackThumbs []string + if len(firstIDs) > 0 { + tracks, _ := s.trackRepo.GetByIDs(ctx, firstIDs) + for _, t := range tracks { + if thumb := extractTrackThumbnail(t); thumb != "" { + trackThumbs = append(trackThumbs, thumb) + } + } + } + + return &models.PlaylistItem{ + PlaylistID: p.ID, + Name: p.Name, + Thumbnails: buildPlaylistThumbnails(p.CoverURL, trackThumbs, 4), + CoverURL: p.CoverURL, + NormalThumbnail: p.NormalThumbnail, + CoverID: p.CoverID, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + }, nil +} + +// DeletePlaylist deletes a playlist and cleans up its tracks. +func (s *FavouritePlaylistService) DeletePlaylist(ctx context.Context, playlistID string, userID int64) error { + return s.favRepo.DeletePlaylist(ctx, playlistID, userID) +} + +// AddTracksToPlaylist appends tracks to a playlist. +func (s *FavouritePlaylistService) AddTracksToPlaylist(ctx context.Context, playlistID string, userID int64, trackIDs []string) (int, error) { + existing, err := s.favRepo.GetPlaylistByID(ctx, playlistID, userID) + if err != nil { + return 0, err + } + if existing == nil { + return 0, errors.New("playlist not found") + } + + // Verify tracks exist + tracks, err := s.trackRepo.GetByIDs(ctx, trackIDs) + if err != nil || len(tracks) == 0 { + return 0, errors.New("tracks not found") + } + + var validIDs []string + for _, t := range tracks { + validIDs = append(validIDs, t.ID) + } + + return s.favRepo.AddTracksToPlaylist(ctx, playlistID, validIDs) +} + +// RemoveTrackFromPlaylist removes a track from a playlist. +func (s *FavouritePlaylistService) RemoveTrackFromPlaylist(ctx context.Context, playlistID string, userID int64, trackID string) error { + existing, err := s.favRepo.GetPlaylistByID(ctx, playlistID, userID) + if err != nil { + return err + } + if existing == nil { + return errors.New("playlist not found") + } + + return s.favRepo.RemoveTrackFromPlaylist(ctx, playlistID, trackID) +} + +// GetPlaylistTracks returns paginated tracks inside a playlist. +func (s *FavouritePlaylistService) GetPlaylistTracks(ctx context.Context, playlistID string, userID int64, page, limit int) (*models.PlaylistTracksResponse, error) { + existing, err := s.favRepo.GetPlaylistByID(ctx, playlistID, userID) + if err != nil { + return nil, err + } + if existing == nil { + return nil, errors.New("playlist not found") + } + + ids, total, err := s.favRepo.GetPlaylistTrackIDs(ctx, playlistID, page, limit) + if err != nil { + return nil, err + } + + tracks, err := s.trackRepo.GetByIDs(ctx, ids) + if err != nil { + return nil, err + } + + items := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + items = append(items, t.ToBrowseItem()) + } + + return &models.PlaylistTracksResponse{ + Page: page, + PerPage: limit, + Total: total, + Items: items, + }, nil +} + +// GetSharedPlaylist returns public read-only playlist with tracks for sharing. +func (s *FavouritePlaylistService) GetSharedPlaylist(ctx context.Context, playlistID string) (*models.PlaylistShareResponse, error) { + p, err := s.favRepo.GetPublicPlaylistByID(ctx, playlistID) + if err != nil { + return nil, err + } + if p == nil { + return nil, errors.New("playlist not found") + } + + ids, _, err := s.favRepo.GetPlaylistTrackIDs(ctx, playlistID, 1, 200) + if err != nil { + return nil, err + } + + tracks, err := s.trackRepo.GetByIDs(ctx, ids) + if err != nil { + return nil, err + } + + items := make([]*models.BrowseItem, 0, len(tracks)) + for _, t := range tracks { + items = append(items, t.ToBrowseItem()) + } + + return &models.PlaylistShareResponse{ + PlaylistID: p.ID, + Name: p.Name, + CoverURL: p.CoverURL, + NormalThumbnail: p.NormalThumbnail, + Tracks: items, + CreatedAt: p.CreatedAt, + }, nil +} + +// AddFavourite likes a track. +func (s *FavouritePlaylistService) AddFavourite(ctx context.Context, userID int64, trackID string) (bool, error) { + trackID = strings.TrimSpace(trackID) + if trackID == "" { + return false, errors.New("track_id is required") + } + track, err := s.trackRepo.GetByID(ctx, trackID) + if err != nil { + return false, err + } + if track == nil { + return false, errors.New("track not found") + } + return s.favRepo.AddFavourite(ctx, userID, trackID) +} + +// RemoveFavourite unlikes a track. +func (s *FavouritePlaylistService) RemoveFavourite(ctx context.Context, userID int64, trackID string) (bool, error) { + trackID = strings.TrimSpace(trackID) + if trackID == "" { + return false, errors.New("track_id is required") + } + return s.favRepo.RemoveFavourite(ctx, userID, trackID) +} + +// GetFavourites returns paginated favorites with track details. +func (s *FavouritePlaylistService) GetFavourites(ctx context.Context, userID int64, page, limit int) (*models.FavouritesResponse, error) { + if page < 1 { + page = 1 + } + if limit < 1 || limit > 100 { + limit = 20 + } + + ids, total, lastTS, err := s.favRepo.GetFavouriteTrackIDs(ctx, userID, page, limit) + if err != nil { + return nil, err + } + + tracks, err := s.trackRepo.GetByIDs(ctx, ids) + if err != nil { + return nil, err + } + + byID := make(map[string]*models.Track) + for _, t := range tracks { + byID[t.ID] = t + } + + items := make([]models.FavouriteItem, 0, len(ids)) + for _, id := range ids { + if t, ok := byID[id]; ok { + items = append(items, models.FavouriteItem{ + Track: t.ToBrowseItem(), + }) + } + } + + return &models.FavouritesResponse{ + Page: page, + PerPage: limit, + Total: total, + Items: items, + LastUpdatedAt: lastTS, + }, nil +} + +// GetFavouriteIDs returns lightweight list of liked track IDs. +func (s *FavouritePlaylistService) GetFavouriteIDs(ctx context.Context, userID int64, page, limit int) (*models.FavouriteIdsResponse, error) { + ids, total, lastTS, err := s.favRepo.GetAllFavouriteTrackIDs(ctx, userID, page, limit) + if err != nil { + return nil, err + } + if ids == nil { + ids = []string{} + } + return &models.FavouriteIdsResponse{ + Page: page, + PerPage: limit, + Total: total, + IDs: ids, + Exists: total > 0, + LastUpdatedAt: lastTS, + }, nil +} + +// AddFavouriteArtist follows an artist. +func (s *FavouritePlaylistService) AddFavouriteArtist(ctx context.Context, userID int64, artistID string) (bool, error) { + artistID = strings.TrimSpace(artistID) + if artistID == "" { + return false, errors.New("artist_id is required") + } + return s.favRepo.AddFavouriteArtist(ctx, userID, artistID) +} + +// RemoveFavouriteArtist unfollows an artist. +func (s *FavouritePlaylistService) RemoveFavouriteArtist(ctx context.Context, userID int64, artistID string) (bool, error) { + artistID = strings.TrimSpace(artistID) + if artistID == "" { + return false, errors.New("artist_id is required") + } + return s.favRepo.RemoveFavouriteArtist(ctx, userID, artistID) +} + +// GetFavouriteArtistIDs returns list of followed artist IDs. +func (s *FavouritePlaylistService) GetFavouriteArtistIDs(ctx context.Context, userID int64) ([]string, error) { + return s.favRepo.GetFavouriteArtistIDs(ctx, userID) +} + +// SaveAlbums saves one or more albums for user. +func (s *FavouritePlaylistService) SaveAlbums(ctx context.Context, userID int64, albumIDs []string) (int, error) { + count := 0 + for _, aid := range albumIDs { + aid = strings.TrimSpace(aid) + if aid == "" { + continue + } + already, err := s.favRepo.SaveAlbum(ctx, userID, aid) + if err == nil && !already { + count++ + } + } + return count, nil +} + +// GetSavedAlbums returns saved albums for user. +func (s *FavouritePlaylistService) GetSavedAlbums(ctx context.Context, userID int64, page, limit int) (*models.UserAlbumsResponse, error) { + albumIDs, savedAts, total, err := s.favRepo.GetSavedAlbumIDs(ctx, userID, page, limit) + if err != nil { + return nil, err + } + + items := make([]models.UserAlbumItem, 0, len(albumIDs)) + for i, aid := range albumIDs { + var albumDoc any + if s.artistAlbumRepo != nil { + if alb, err := s.artistAlbumRepo.GetAlbumByID(ctx, aid); err == nil && alb != nil { + albumDoc = alb + } + } + var savedAt float64 + if i < len(savedAts) { + savedAt = savedAts[i] + } + items = append(items, models.UserAlbumItem{ + AlbumID: aid, + Album: albumDoc, + SavedAt: savedAt, + }) + } + + return &models.UserAlbumsResponse{ + Page: page, + PerPage: limit, + Total: total, + Items: items, + }, nil +} + +// RecordListeningEvents records bulk telemetry events. +func (s *FavouritePlaylistService) RecordListeningEvents(ctx context.Context, userID *int64, events []models.ListeningEventItem) (int, error) { + return s.favRepo.RecordListeningEvents(ctx, userID, events) +} + +// GetUserHistory returns recent track items. +func (s *FavouritePlaylistService) GetUserHistory(ctx context.Context, userID int64, limit int) ([]*models.BrowseItem, error) { + ids, err := s.favRepo.GetUserHistoryTrackIDs(ctx, userID, limit) + if err != nil { + return nil, err + } + + tracks, err := s.trackRepo.GetByIDs(ctx, ids) + if err != nil { + return nil, err + } + + byID := make(map[string]*models.Track) + for _, t := range tracks { + byID[t.ID] = t + } + + items := make([]*models.BrowseItem, 0, len(ids)) + for _, id := range ids { + if t, ok := byID[id]; ok { + items = append(items, t.ToBrowseItem()) + } + } + return items, nil +} + +// GetUserTopPlayed returns user's top played tracks. +func (s *FavouritePlaylistService) GetUserTopPlayed(ctx context.Context, userID int64, page, limit int) ([]*models.BrowseItem, int64, error) { + ids, total, err := s.favRepo.GetUserTopPlayedTrackIDs(ctx, userID, page, limit) + if err != nil { + return nil, 0, err + } + + tracks, err := s.trackRepo.GetByIDs(ctx, ids) + if err != nil { + return nil, 0, err + } + + byID := make(map[string]*models.Track) + for _, t := range tracks { + byID[t.ID] = t + } + + items := make([]*models.BrowseItem, 0, len(ids)) + for _, id := range ids { + if t, ok := byID[id]; ok { + items = append(items, t.ToBrowseItem()) + } + } + return items, total, nil +} diff --git a/internal/services/history_service.go b/internal/services/history_service.go new file mode 100644 index 0000000..e119539 --- /dev/null +++ b/internal/services/history_service.go @@ -0,0 +1,120 @@ +package services + +import ( + "context" + + "streamgo/internal/models" + "streamgo/internal/repository" +) + +// HistoryService handles playback history and telemetry processing. +type HistoryService struct { + histRepo repository.HistoryRepository + trackRepo repository.TrackRepository +} + +// NewHistoryService creates a new HistoryService. +func NewHistoryService(histRepo repository.HistoryRepository, trackRepo repository.TrackRepository) *HistoryService { + return &HistoryService{ + histRepo: histRepo, + trackRepo: trackRepo, + } +} + +// GetHistory returns user playback history as browse items. +func (s *HistoryService) GetHistory(ctx context.Context, userID int64, limit int) (*models.BrowseResponse, error) { + if limit <= 0 { + limit = 50 + } + + trackIDs, err := s.histRepo.GetUserHistory(ctx, userID, limit) + if err != nil { + return nil, err + } + if len(trackIDs) == 0 { + return &models.BrowseResponse{ + Page: 1, + PerPage: limit, + Total: 0, + Items: []*models.BrowseItem{}, + }, nil + } + + tracks, err := s.trackRepo.GetByIDs(ctx, trackIDs) + if err != nil { + return nil, err + } + + trackMap := make(map[string]*models.Track, len(tracks)) + for _, t := range tracks { + trackMap[t.ID] = t + } + + var items []*models.BrowseItem + for _, tid := range trackIDs { + if t, ok := trackMap[tid]; ok { + items = append(items, t.ToBrowseItem()) + } + } + + return &models.BrowseResponse{ + Page: 1, + PerPage: limit, + Total: int64(len(items)), + Items: items, + }, nil +} + +// GetTopPlayed returns user's most played tracks as browse items. +func (s *HistoryService) GetTopPlayed(ctx context.Context, userID int64, limit int) (*models.BrowseResponse, error) { + if limit <= 0 { + limit = 50 + } + + trackIDs, err := s.histRepo.GetUserTopPlayed(ctx, userID, limit) + if err != nil { + return nil, err + } + if len(trackIDs) == 0 { + return &models.BrowseResponse{ + Page: 1, + PerPage: limit, + Total: 0, + Items: []*models.BrowseItem{}, + }, nil + } + + tracks, err := s.trackRepo.GetByIDs(ctx, trackIDs) + if err != nil { + return nil, err + } + + trackMap := make(map[string]*models.Track, len(tracks)) + for _, t := range tracks { + trackMap[t.ID] = t + } + + var items []*models.BrowseItem + for _, tid := range trackIDs { + if t, ok := trackMap[tid]; ok { + items = append(items, t.ToBrowseItem()) + } + } + + return &models.BrowseResponse{ + Page: 1, + PerPage: limit, + Total: int64(len(items)), + Items: items, + }, nil +} + +// RecordPlay logs playback directly (e.g. from stream). +func (s *HistoryService) RecordPlay(ctx context.Context, userID int64, trackID string, source string) error { + return s.histRepo.RecordPlay(ctx, userID, trackID, source) +} + +// RecordEvents records batch client listening events. +func (s *HistoryService) RecordEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) error { + return s.histRepo.RecordListeningEvents(ctx, userID, events) +} diff --git a/internal/services/lyrics_enrichment_service.go b/internal/services/lyrics_enrichment_service.go new file mode 100644 index 0000000..cae1403 --- /dev/null +++ b/internal/services/lyrics_enrichment_service.go @@ -0,0 +1,764 @@ +package services + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "math" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "streamgo/internal/config" + "streamgo/internal/logger" +) + +var logLyrics = logger.New("lyrics") + +const ( + musixmatchBaseURL = "https://apic.musixmatch.com/ws/1.1" + musixmatchAppID = "mac-ios-v2.0" +) + +// LyricsResult bundles resolved lyrics and alternative multilingual titles. +type LyricsResult struct { + Lyrics string + SyncedLyrics string + Kind string // "richsync" or "lrc" + Source string // "musixmatch" or "lrclib" + Titles map[string]any +} + +// LyricsEnrichmentService fetches lyrics and alternative titles from Musixmatch and LRCLIB. +type LyricsEnrichmentService struct { + cfg *config.Config + httpClient *http.Client + + tokenMu sync.RWMutex + cachedToken string + tokenSavedAt float64 +} + +// NewLyricsEnrichmentService creates a new LyricsEnrichmentService. +func NewLyricsEnrichmentService(cfg *config.Config) *LyricsEnrichmentService { + return &LyricsEnrichmentService{ + cfg: cfg, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// FetchLyrics searches for lyrics using default provider ordering. +func (s *LyricsEnrichmentService) FetchLyrics(ctx context.Context, title, artist, album string) (*LyricsResult, error) { + return s.FetchLyricsWithProvider(ctx, title, artist, album, "auto") +} + +// FetchLyricsWithProvider searches for lyrics respecting user/frontend provider preference. +func (s *LyricsEnrichmentService) FetchLyricsWithProvider(ctx context.Context, title, artist, album, provider string) (*LyricsResult, error) { + if s.cfg != nil && !s.cfg.Lyrics { + return nil, nil // Lyrics fetching disabled + } + + cleanTitle := StripNoise(title) + cleanArtist := StripNoise(artist) + cleanAlbum := StripNoise(album) + + if cleanTitle == "" { + return nil, fmt.Errorf("title cannot be empty") + } + + p := strings.ToLower(strings.TrimSpace(provider)) + + // Helper runners + runMusixmatch := func() (*LyricsResult, error) { + res, err := s.queryMusixmatch(ctx, cleanTitle, cleanArtist, cleanAlbum) + if err == nil && res != nil && (res.Lyrics != "" || len(res.Titles) > 0) { + return res, nil + } + return nil, err + } + + runLRCLIB := func() (*LyricsResult, error) { + res, err := s.queryLRCLIB(ctx, cleanTitle, cleanArtist, cleanAlbum) + if err == nil && res != nil && res.Lyrics != "" { + return res, nil + } + return nil, err + } + + runKuGou := func() (*LyricsResult, error) { + res, err := s.queryKuGou(ctx, cleanTitle, cleanArtist, 0) + if err == nil && res != nil && res.Lyrics != "" { + return res, nil + } + return nil, err + } + + switch p { + case "musixmatch": + if res, err := runMusixmatch(); err == nil { + return res, nil + } + if res, err := runLRCLIB(); err == nil { + return res, nil + } + if res, err := runKuGou(); err == nil { + return res, nil + } + case "lrclib": + if res, err := runLRCLIB(); err == nil { + return res, nil + } + if res, err := runMusixmatch(); err == nil { + return res, nil + } + if res, err := runKuGou(); err == nil { + return res, nil + } + case "kugou": + if res, err := runKuGou(); err == nil { + return res, nil + } + if res, err := runMusixmatch(); err == nil { + return res, nil + } + if res, err := runLRCLIB(); err == nil { + return res, nil + } + default: + // "auto" or unspecified: Musixmatch -> LRCLIB -> KuGou + if s.cfg == nil || s.cfg.Musixmatch { + if res, err := runMusixmatch(); err == nil { + return res, nil + } + } + if s.cfg == nil || s.cfg.LRCLIB { + if res, err := runLRCLIB(); err == nil { + return res, nil + } + } + if res, err := runKuGou(); err == nil { + return res, nil + } + } + + return nil, fmt.Errorf("no lyrics found for %s - %s", artist, title) +} + +func (s *LyricsEnrichmentService) getUserToken(ctx context.Context, forceRefresh bool) string { + s.tokenMu.RLock() + now := float64(time.Now().Unix()) + if !forceRefresh && s.cachedToken != "" && (now-s.tokenSavedAt) < 24*3600 { + tok := s.cachedToken + s.tokenMu.RUnlock() + return tok + } + s.tokenMu.RUnlock() + + s.tokenMu.Lock() + defer s.tokenMu.Unlock() + + if !forceRefresh && s.cachedToken != "" && (now-s.tokenSavedAt) < 24*3600 { + return s.cachedToken + } + + // Try reading from token files + tokenCandidates := []string{ + "/home/misfit/Work/StreamXBot/cookies/.musixmatch_token", + "./cookies/.musixmatch_token", + filepath.Join(os.TempDir(), ".musixmatch_token"), + } + + if !forceRefresh { + for _, path := range tokenCandidates { + if data, err := os.ReadFile(path); err == nil { + var tokenDoc struct { + Token string `json:"token"` + } + if err := json.Unmarshal(data, &tokenDoc); err == nil && tokenDoc.Token != "" { + s.cachedToken = strings.TrimSpace(tokenDoc.Token) + s.tokenSavedAt = now + return s.cachedToken + } + } + } + } + + // Request new user token from Musixmatch + tokenURL := fmt.Sprintf("%s/token.get?app_id=%s", musixmatchBaseURL, musixmatchAppID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil) + if err == nil { + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36") + resp, err := s.httpClient.Do(req) + if err == nil { + defer resp.Body.Close() + var doc struct { + Message struct { + Header struct { + StatusCode int `json:"status_code"` + } `json:"header"` + Body struct { + UserToken string `json:"user_token"` + } `json:"body"` + } `json:"message"` + } + if err := json.NewDecoder(resp.Body).Decode(&doc); err == nil { + if doc.Message.Body.UserToken != "" { + s.cachedToken = strings.TrimSpace(doc.Message.Body.UserToken) + s.tokenSavedAt = now + return s.cachedToken + } + } + } + } + + // Fallback hardcoded / known seed token if dynamic token acquisition fails + fallback := "26091748e0125fcea9641300d8bbf8995de1514987802fc2c70d6f" + s.cachedToken = fallback + s.tokenSavedAt = now + return fallback +} + +func (s *LyricsEnrichmentService) queryMusixmatch(ctx context.Context, title, artist, album string) (*LyricsResult, error) { + token := s.getUserToken(ctx, false) + if token == "" { + return nil, fmt.Errorf("failed to acquire musixmatch token") + } + + endpoint := fmt.Sprintf("%s/macro.subtitles.get?app_id=%s&usertoken=%s&subtitle_format=mxm&q_track=%s&q_artist=%s", + musixmatchBaseURL, + musixmatchAppID, + url.QueryEscape(token), + url.QueryEscape(title), + url.QueryEscape(artist), + ) + if album != "" { + endpoint += fmt.Sprintf("&q_album=%s", url.QueryEscape(album)) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36") + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var payload struct { + Message struct { + Header struct { + StatusCode int `json:"status_code"` + } `json:"header"` + Body struct { + MacroCalls map[string]struct { + Message struct { + Header struct { + StatusCode int `json:"status_code"` + } `json:"header"` + Body any `json:"body"` + } `json:"message"` + } `json:"macro_calls"` + } `json:"body"` + } `json:"message"` + } + + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, err + } + + macro := payload.Message.Body.MacroCalls + trackCall, hasTrack := macro["matcher.track.get"] + if !hasTrack { + return nil, fmt.Errorf("no matcher.track.get in musixmatch response") + } + + trackBodyBytes, _ := json.Marshal(trackCall.Message.Body) + var trackData struct { + Track struct { + TrackID int64 `json:"track_id"` + TrackName string `json:"track_name"` + HasRichsync int `json:"has_richsync"` + HasSubtitles int `json:"has_subtitles"` + TrackNameTranslationList []struct { + TrackNameTranslation struct { + Language string `json:"language"` + Translation string `json:"translation"` + } `json:"track_name_translation"` + } `json:"track_name_translation_list"` + } `json:"track"` + } + _ = json.Unmarshal(trackBodyBytes, &trackData) + + originalTitle := title + if trackData.Track.TrackName != "" { + originalTitle = trackData.Track.TrackName + } + + // Extract romanized title + romanized := "" + for _, item := range trackData.Track.TrackNameTranslationList { + lang := strings.ToLower(strings.TrimSpace(item.TrackNameTranslation.Language)) + val := strings.TrimSpace(item.TrackNameTranslation.Translation) + if lang == "rj" || lang == "rk" || lang == "romanized" || lang == "romaja" { + romanized = val + break + } + if (lang == "u0" || lang == "zr") && !isCJK(val) && romanized == "" { + romanized = val + } + } + + // Fallback to Japanese romanizer if title is CJK + if romanized == "" && isCJK(originalTitle) { + romanized = romanizeJapanese(originalTitle) + } + + titles := map[string]any{ + "original": originalTitle, + } + if romanized != "" && !strings.EqualFold(romanized, originalTitle) { + titles["romanized"] = romanized + } + + res := &LyricsResult{ + Titles: titles, + } + + // 1. If has_richsync is true and track_id > 0, fetch word-level richsync + if trackData.Track.TrackID > 0 && trackData.Track.HasRichsync == 1 { + richsyncURL := fmt.Sprintf("%s/track.richsync.get?app_id=%s&usertoken=%s&track_id=%d", + musixmatchBaseURL, + musixmatchAppID, + url.QueryEscape(token), + trackData.Track.TrackID, + ) + rReq, err := http.NewRequestWithContext(ctx, http.MethodGet, richsyncURL, nil) + if err == nil { + rReq.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36") + if rResp, err := s.httpClient.Do(rReq); err == nil { + defer rResp.Body.Close() + var rDoc struct { + Message struct { + Body struct { + Richsync struct { + RichsyncBody string `json:"richsync_body"` + } `json:"richsync"` + } `json:"body"` + } `json:"message"` + } + if err := json.NewDecoder(rResp.Body).Decode(&rDoc); err == nil { + richText := strings.TrimSpace(rDoc.Message.Body.Richsync.RichsyncBody) + if richText != "" { + if lrc := parseRichsyncJSONToLRC(richText); lrc != "" { + res.Lyrics = lrc + res.SyncedLyrics = lrc + res.Kind = "richsync" + res.Source = "musixmatch" + return res, nil + } + } + } + } + } + } + + // 2. Check track.subtitles.get from macro calls + if subCall, ok := macro["track.subtitles.get"]; ok { + subBodyBytes, _ := json.Marshal(subCall.Message.Body) + var subData struct { + SubtitleList []struct { + Subtitle struct { + SubtitleBody string `json:"subtitle_body"` + } `json:"subtitle"` + } `json:"subtitle_list"` + } + if err := json.Unmarshal(subBodyBytes, &subData); err == nil && len(subData.SubtitleList) > 0 { + subBody := strings.TrimSpace(subData.SubtitleList[0].Subtitle.SubtitleBody) + if subBody != "" { + lrc := parseSubtitlesJSONToLRC(subBody) + if lrc != "" { + res.Lyrics = lrc + res.SyncedLyrics = lrc + res.Kind = "lrc" + res.Source = "musixmatch" + return res, nil + } + } + } + } + + // 3. Plain lyrics from macro calls + if lyrCall, ok := macro["track.lyrics.get"]; ok { + lyrBodyBytes, _ := json.Marshal(lyrCall.Message.Body) + var lyrData struct { + Lyrics struct { + LyricsBody string `json:"lyrics_body"` + } `json:"lyrics"` + } + if err := json.Unmarshal(lyrBodyBytes, &lyrData); err == nil { + plain := strings.TrimSpace(lyrData.Lyrics.LyricsBody) + if plain != "" { + res.Lyrics = plain + res.Kind = "lrc" + res.Source = "musixmatch" + return res, nil + } + } + } + + return res, nil +} + +func (s *LyricsEnrichmentService) queryLRCLIB(ctx context.Context, title, artist, album string) (*LyricsResult, error) { + apiURL := fmt.Sprintf("https://lrclib.net/api/get?track_name=%s&artist_name=%s", + url.QueryEscape(title), + url.QueryEscape(artist), + ) + if album != "" { + apiURL += fmt.Sprintf("&album_name=%s", url.QueryEscape(album)) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "StreamGO/1.0") + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("lrclib status: %d", resp.StatusCode) + } + + var payload struct { + TrackName string `json:"trackName"` + ArtistName string `json:"artistName"` + PlainLyrics string `json:"plainLyrics"` + SyncedLyrics string `json:"syncedLyrics"` + } + + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, err + } + + bestLyrics := payload.SyncedLyrics + if bestLyrics == "" { + bestLyrics = payload.PlainLyrics + } + + if bestLyrics == "" { + return nil, fmt.Errorf("empty lyrics from lrclib") + } + + titles := map[string]any{ + "original": title, + } + + return &LyricsResult{ + Lyrics: bestLyrics, + SyncedLyrics: payload.SyncedLyrics, + Kind: "lrc", + Source: "lrclib", + Titles: titles, + }, nil +} + +func (s *LyricsEnrichmentService) queryKuGou(ctx context.Context, title, artist string, durationSec int) (*LyricsResult, error) { + query := fmt.Sprintf("%s - %s", strings.TrimSpace(title), strings.TrimSpace(artist)) + + // 1. Search song + searchURL := fmt.Sprintf("https://mobileservice.kugou.com/api/v3/search/song?version=9108&plat=0&pagesize=6&showtype=0&keyword=%s", url.QueryEscape(query)) + req1, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil) + if err != nil { + return nil, err + } + req1.Header.Set("User-Agent", "piTube/1.0") + + resp1, err := s.httpClient.Do(req1) + if err != nil { + return nil, err + } + defer resp1.Body.Close() + + var searchRes struct { + Data struct { + Info []struct { + Hash string `json:"hash"` + Duration float64 `json:"duration"` + } `json:"info"` + } `json:"data"` + } + if err := json.NewDecoder(resp1.Body).Decode(&searchRes); err != nil { + return nil, err + } + + if len(searchRes.Data.Info) == 0 { + return nil, fmt.Errorf("no songs found on kugou") + } + + targetHash := "" + for _, song := range searchRes.Data.Info { + if durationSec > 0 && math.Abs(song.Duration-float64(durationSec)) > 8 { + continue + } + if song.Hash != "" { + targetHash = song.Hash + break + } + } + if targetHash == "" { + targetHash = searchRes.Data.Info[0].Hash + } + if targetHash == "" { + return nil, fmt.Errorf("no valid hash found on kugou") + } + + // 2. Search lyrics candidate + candURL := fmt.Sprintf("https://lyrics.kugou.com/search?ver=1&man=yes&client=pc&hash=%s", targetHash) + req2, err := http.NewRequestWithContext(ctx, http.MethodGet, candURL, nil) + if err != nil { + return nil, err + } + req2.Header.Set("User-Agent", "piTube/1.0") + + resp2, err := s.httpClient.Do(req2) + if err != nil { + return nil, err + } + defer resp2.Body.Close() + + var candRes struct { + Candidates []struct { + ID string `json:"id"` + AccessKey string `json:"accesskey"` + } `json:"candidates"` + } + if err := json.NewDecoder(resp2.Body).Decode(&candRes); err != nil { + return nil, err + } + if len(candRes.Candidates) == 0 { + return nil, fmt.Errorf("no lyrics candidates on kugou") + } + + candID := candRes.Candidates[0].ID + accessKey := candRes.Candidates[0].AccessKey + + // 3. Download lyrics + downURL := fmt.Sprintf("https://lyrics.kugou.com/download?fmt=lrc&charset=utf8&client=pc&ver=1&id=%s&accesskey=%s", candID, accessKey) + req3, err := http.NewRequestWithContext(ctx, http.MethodGet, downURL, nil) + if err != nil { + return nil, err + } + req3.Header.Set("User-Agent", "piTube/1.0") + + resp3, err := s.httpClient.Do(req3) + if err != nil { + return nil, err + } + defer resp3.Body.Close() + + var downRes struct { + Content string `json:"content"` + } + if err := json.NewDecoder(resp3.Body).Decode(&downRes); err != nil { + return nil, err + } + if downRes.Content == "" { + return nil, fmt.Errorf("empty kugou content") + } + + decoded, err := base64.StdEncoding.DecodeString(downRes.Content) + if err != nil { + return nil, err + } + + lrcText := string(decoded) + return &LyricsResult{ + Lyrics: lrcText, + SyncedLyrics: lrcText, + Kind: "synced", + Source: "kugou", + Titles: map[string]any{"original": title}, + }, nil +} + +func formatLRCTimestamp(totalSeconds float64) string { + if totalSeconds < 0 { + totalSeconds = 0 + } + m := int(totalSeconds / 60) + secF := totalSeconds - float64(m*60) + s := int(secF) + hh := int(math.Round((secF - float64(s)) * 100)) + if hh >= 100 { + hh = 0 + s++ + } + if s >= 60 { + s = 0 + m++ + } + return fmt.Sprintf("[%02d:%02d.%02d]", m, s, hh) +} + +func formatWordTimestamp(totalSeconds float64) string { + if totalSeconds < 0 { + totalSeconds = 0 + } + m := int(totalSeconds / 60) + secF := totalSeconds - float64(m*60) + s := int(secF) + hh := int(math.Round((secF - float64(s)) * 100)) + if hh >= 100 { + hh = 0 + s++ + } + if s >= 60 { + s = 0 + m++ + } + return fmt.Sprintf("<%02d:%02d.%02d>", m, s, hh) +} + +func parseRichsyncJSONToLRC(body string) string { + body = strings.TrimSpace(body) + if body == "" { + return "" + } + + var items []struct { + TS float64 `json:"ts"` + TE float64 `json:"te"` + X string `json:"x"` + L []struct { + C string `json:"c"` + O float64 `json:"o"` + } `json:"l"` + } + + if err := json.Unmarshal([]byte(body), &items); err != nil || len(items) == 0 { + return "" + } + + var out []string + for _, it := range items { + lineText := strings.TrimSpace(it.X) + if lineText == "" && len(it.L) == 0 { + continue + } + + lineTag := formatLRCTimestamp(it.TS) + if len(it.L) > 0 { + var parts []string + parts = append(parts, lineTag) + for _, w := range it.L { + c := w.C + if strings.TrimSpace(c) == "" { + parts = append(parts, c) + } else { + parts = append(parts, fmt.Sprintf("%s%s", formatWordTimestamp(it.TS+w.O), c)) + } + } + out = append(out, strings.TrimRight(strings.Join(parts, ""), " ")) + } else { + out = append(out, strings.TrimRight(fmt.Sprintf("%s %s", lineTag, lineText), " ")) + } + } + + return strings.Join(out, "\n") +} + +func parseSubtitlesJSONToLRC(body string) string { + body = strings.TrimSpace(body) + if body == "" { + return "" + } + + // Already LRC format text with [mm:ss timestamps + if len(body) > 6 && body[0] == '[' && (body[1] >= '0' && body[1] <= '9') && body[3] == ':' { + return body + } + + var items []struct { + Text string `json:"text"` + Time struct { + Total float64 `json:"total"` + } `json:"time"` + } + + if err := json.Unmarshal([]byte(body), &items); err != nil || len(items) == 0 { + return body + } + + var out []string + for _, it := range items { + ts := formatLRCTimestamp(it.Time.Total) + t := strings.TrimRight(it.Text, " ") + if t != "" { + out = append(out, fmt.Sprintf("%s %s", ts, t)) + } else { + out = append(out, ts) + } + } + + return strings.Join(out, "\n") +} + +func isCJK(text string) bool { + for _, r := range text { + if (r >= 0x3040 && r <= 0x309f) || // Hiragana + (r >= 0x30a0 && r <= 0x30ff) || // Katakana + (r >= 0x4e00 && r <= 0x9fff) || // CJK Ideographs + (r >= 0xac00 && r <= 0xd7af) { // Hangul + return true + } + } + return false +} + +func romanizeJapanese(text string) string { + if text == "" || !isCJK(text) { + return "" + } + + pyBins := []string{ + "/home/misfit/Work/StreamXBot/.venv/bin/python3", + "python3", + } + + script := `import pykakasi, sys; k = pykakasi.kakasi(); print(' '.join(w.get('hepburn','').capitalize() for w in k.convert(sys.argv[1])))` + + for _, py := range pyBins { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + cmd := exec.CommandContext(ctx, py, "-c", script, text) + out, err := cmd.Output() + cancel() + if err == nil { + res := strings.TrimSpace(string(out)) + if res != "" { + // Clean formatting + re := regexp.MustCompile(`\s+`) + res = re.ReplaceAllString(res, " ") + return res + } + } + } + + return "" +} diff --git a/internal/services/mediainfo.go b/internal/services/mediainfo.go new file mode 100644 index 0000000..4c0454e --- /dev/null +++ b/internal/services/mediainfo.go @@ -0,0 +1,543 @@ +package services + +import ( + "bytes" + "context" + "crypto/sha1" + "crypto/sha256" + "fmt" + "io" + "math" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "time" + "unicode" + + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" + + "streamgo/internal/models" +) + +var ( + artistSplitRegex = regexp.MustCompile(`(?i)\s*(?:,|/|&|\s+and\s+|\s+x\s+|\s+feat\.\s+|\s+feat\s+|\s+ft\.\s+|\s+ft\s+)\s*`) + albumSlugRegex = regexp.MustCompile(`(?i)[^a-z0-9 ]+`) + yearRegex = regexp.MustCompile(`\b(19\d{2}|20\d{2})\b`) + durationHourRe = regexp.MustCompile(`(?i)(\d+)\s*h`) + durationMinRe = regexp.MustCompile(`(?i)(\d+)\s*min`) + durationSecRe = regexp.MustCompile(`(?i)(\d+)\s*s`) + durationMsRe = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)\s*ms`) + floatNumberRe = regexp.MustCompile(`(\d+(?:\.\d+)?)`) +) + +// RunMediaInfo executes the system mediainfo binary on the provided file path. +func RunMediaInfo(filePath string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "mediainfo", filePath) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + return "", fmt.Errorf("mediainfo error: %w (stderr: %s)", err, stderr.String()) + } + + out := stdout.String() + if out == "" { + out = stderr.String() + } + return out, nil +} + +// ParseMediaInfo parses the raw textual output of mediainfo into AudioMeta. +func ParseMediaInfo(output string, fallbackDurationSec int32, fileSize int64) *models.AudioMeta { + sections := parseMediaInfoSections(output) + general := sections["general"] + if general == nil { + general = make(map[string]string) + } + audio := sections["audio"] + if audio == nil { + audio = make(map[string]string) + } + + title := pickBestTitle(general, audio) + album := getFirst(general, "album", "original source form/name") + artist := getFirst(general, "performer", "album/performer", "director", "artist") + if artist == "" { + artist = audio["performer"] + } + title, artist = CleanMetadata(title, artist) + composer := general["composer"] + label := getFirst(general, "label", "publisher") + genre := general["genre"] + recordedDate := getFirst(general, "recorded date", "recorded date ") + + bitDepth := parseBitDepth(audio["bit depth"]) + bitrateKbps := parseBitrateKbps(getFirst(audio, "bit rate", "overall bit rate", general["overall bit rate"])) + samplingRateHz := parseSamplingRateHz(audio["sampling rate"]) + year := parseYear(recordedDate) + + // Format / Type normalization + rawFormat := strings.ToLower(getFirst(audio, "format", "codec id", "format/info", "general format")) + if rawFormat == "" { + rawFormat = strings.ToLower(getFirst(general, "format", "codec id")) + } + fileType := normalizeAudioFormat(rawFormat) + // If container is M4A / MP4 / AAC, but bit depth is present (lossless PCM depth 16/24/32 bits), it is ALAC + if (fileType == "m4a" || fileType == "" || fileType == "aac") && bitDepth != nil && *bitDepth > 0 { + fileType = "alac" + } + + durationSec := parseDurationSeconds(getFirst(general, "duration", audio["duration"])) + if durationSec == 0 && fallbackDurationSec > 0 { + durationSec = fallbackDurationSec + } + + // Adjust duration if partial chunk truncated CBR / uncompressed stream + if fileSize > 3_000_000 { + var calcDur int32 + if fileType == "pcm" || fileType == "wav" || (samplingRateHz != nil && bitDepth != nil) { + var sr int64 = 44100 + if samplingRateHz != nil && *samplingRateHz > 0 { + sr = int64(*samplingRateHz) + } + var bd int64 = 16 + if bitDepth != nil && *bitDepth > 0 { + bd = int64(*bitDepth) + } + channels := int64(2) + chStr := strings.ToLower(getFirst(audio, "channel(s)", general["channel(s)"])) + if strings.Contains(chStr, "1 channel") { + channels = 1 + } + byteRate := (sr * channels * bd) / 8 + if byteRate > 0 { + calcDur = int32(math.Round(float64(fileSize-44) / float64(byteRate))) + } + } + if calcDur == 0 && bitrateKbps != nil && *bitrateKbps > 0 { + calcDur = int32(math.Round(float64(fileSize*8) / float64(*bitrateKbps*1000))) + } + + if calcDur > 0 && (durationSec == 0 || (calcDur > durationSec && (durationSec <= 20 || float64(calcDur)/math.Max(float64(durationSec), 1) >= 2))) { + durationSec = calcDur + } + } + + // Split artists + artists := SplitArtists(artist) + + // Album ID + var albumID string + if album != "" { + albumID = GenerateAlbumID(album, year) + } + + meta := &models.AudioMeta{ + Title: title, + Album: album, + Artist: artist, + Composer: composer, + Label: label, + Genre: genre, + Year: year, + DurationSec: durationSec, + Type: fileType, + BitDepth: bitDepth, + BitrateKbps: bitrateKbps, + SamplingRateHz: samplingRateHz, + Artists: artists, + AlbumID: albumID, + } + + return meta +} + +func parseMediaInfoSections(output string) map[string]map[string]string { + sections := make(map[string]map[string]string) + var current string + + for _, rawLine := range strings.Split(output, "\n") { + line := strings.TrimRight(rawLine, "\r\n") + header := strings.ToLower(strings.TrimSpace(line)) + switch header { + case "general", "audio", "video", "text", "image", "menu": + current = header + if _, exists := sections[current]; !exists { + sections[current] = make(map[string]string) + } + continue + } + + if current == "" || !strings.Contains(line, " : ") { + continue + } + + parts := strings.SplitN(line, " : ", 2) + k := strings.ToLower(strings.TrimSpace(parts[0])) + v := strings.TrimSpace(parts[1]) + if k == "" || v == "" { + continue + } + + if _, exists := sections[current][k]; !exists { + sections[current][k] = v + } + } + + return sections +} + +func pickBestTitle(general, audio map[string]string) string { + t1 := strings.TrimSpace(getFirst(general, "title", "track name", "track name/position")) + t2 := strings.TrimSpace(getFirst(audio, "title", "track name")) + + if t1 != "" && !isJunkTitle(t1) { + return t1 + } + if t2 != "" && !isJunkTitle(t2) { + return t2 + } + if t1 != "" { + return t1 + } + return t2 +} + +func isJunkTitle(title string) bool { + t := strings.ToLower(strings.TrimSpace(title)) + if t == "" { + return true + } + junk := []string{ + "core media audio", + "mpeg audio", + "iso media file produced by google inc.", + "lavf", + } + for _, j := range junk { + if strings.Contains(t, j) { + return true + } + } + return false +} + +func normalizeAudioFormat(format string) string { + f := strings.ToLower(strings.TrimSpace(format)) + switch { + case strings.Contains(f, "flac"): + return "flac" + case strings.Contains(f, "alac") || strings.Contains(f, "apple lossless"): + return "alac" + case strings.Contains(f, "mpeg") || strings.Contains(f, "layer 3") || f == "mp3": + return "mp3" + case strings.Contains(f, "alac"): + return "alac" + case strings.Contains(f, "aac") || strings.Contains(f, "mp4"): + return "m4a" + case strings.Contains(f, "ogg") || strings.Contains(f, "vorbis") || strings.Contains(f, "opus"): + return "ogg" + case strings.Contains(f, "wave") || strings.Contains(f, "pcm") || f == "wav": + return "wav" + default: + if idx := strings.Index(f, " "); idx != -1 { + return f[:idx] + } + return f + } +} + +func parseBitDepth(val string) *int32 { + if val == "" { + return nil + } + m := floatNumberRe.FindString(val) + if m == "" { + return nil + } + n, err := strconv.ParseFloat(m, 64) + if err != nil { + return nil + } + v := int32(math.Round(n)) + return &v +} + +func parseBitrateKbps(val string) *int32 { + if val == "" { + return nil + } + s := strings.ToLower(compactDigits(val)) + m := floatNumberRe.FindString(s) + if m == "" { + return nil + } + n, err := strconv.ParseFloat(m, 64) + if err != nil { + return nil + } + var res int32 + if strings.Contains(s, "mb/s") { + res = int32(math.Round(n * 1000)) + } else if strings.Contains(s, "kb/s") { + res = int32(math.Round(n)) + } else if strings.Contains(s, "b/s") { + res = int32(math.Round(n / 1000)) + } else { + res = int32(math.Round(n)) + } + return &res +} + +func parseSamplingRateHz(val string) *int32 { + if val == "" { + return nil + } + s := strings.ToLower(compactDigits(val)) + m := floatNumberRe.FindString(s) + if m == "" { + return nil + } + n, err := strconv.ParseFloat(m, 64) + if err != nil { + return nil + } + var res int32 + if strings.Contains(s, "khz") { + res = int32(math.Round(n * 1000)) + } else { + res = int32(math.Round(n)) + } + return &res +} + +func parseYear(val string) *int32 { + if val == "" { + return nil + } + m := yearRegex.FindString(val) + if m == "" { + return nil + } + n, err := strconv.Atoi(m) + if err != nil || n < 1000 || n > 2100 { + return nil + } + v := int32(n) + return &v +} + +func parseDurationSeconds(val string) int32 { + if val == "" { + return 0 + } + s := strings.ToLower(val) + var total int32 + + if m := durationHourRe.FindStringSubmatch(s); len(m) > 1 { + h, _ := strconv.Atoi(m[1]) + total += int32(h * 3600) + } + if m := durationMinRe.FindStringSubmatch(s); len(m) > 1 { + min, _ := strconv.Atoi(m[1]) + total += int32(min * 60) + } + if m := durationSecRe.FindStringSubmatch(s); len(m) > 1 { + sec, _ := strconv.Atoi(m[1]) + total += int32(sec) + } + if total > 0 { + return total + } + if m := durationMsRe.FindStringSubmatch(s); len(m) > 1 { + ms, _ := strconv.ParseFloat(m[1], 64) + return int32(math.Round(ms / 1000)) + } + return 0 +} + +func compactDigits(s string) string { + // Remove space between digits: e.g. "88 200" -> "88200" + var sb strings.Builder + runes := []rune(strings.TrimSpace(s)) + for i := 0; i < len(runes); i++ { + if runes[i] == ' ' && i > 0 && i < len(runes)-1 && unicode.IsDigit(runes[i-1]) && unicode.IsDigit(runes[i+1]) { + continue + } + sb.WriteRune(runes[i]) + } + return sb.String() +} + +func getFirst(m map[string]string, keys ...string) string { + for _, k := range keys { + if v, ok := m[k]; ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +// SplitArtists splits a multi-artist string into individual artist tokens. +func SplitArtists(value string) []string { + raw := strings.TrimSpace(value) + if raw == "" { + return []string{} + } + raw = strings.ReplaceAll(raw, "(", " ") + raw = strings.ReplaceAll(raw, ")", " ") + raw = strings.ReplaceAll(raw, "[", " ") + raw = strings.ReplaceAll(raw, "]", " ") + + parts := artistSplitRegex.Split(raw, -1) + out := make([]string, 0, len(parts)) + seen := make(map[string]bool) + + for _, p := range parts { + token := strings.Trim(strings.TrimSpace(p), " .") + if token == "" { + continue + } + key := strings.ToLower(token) + if seen[key] { + continue + } + seen[key] = true + out = append(out, token) + } + return out +} + +// Slugify normalizes an album title into a URL and ID friendly slug. +func Slugify(value string) string { + raw := strings.ToLower(strings.TrimSpace(value)) + if raw == "" { + return "" + } + s := strings.ReplaceAll(raw, "÷", " divide ") + s = strings.ReplaceAll(s, "&", " and ") + s = strings.ReplaceAll(s, "+", " plus ") + + // NFKD normalization to ASCII + t := transform.Chain(norm.NFKD, transform.RemoveFunc(func(r rune) bool { + return unicode.Is(unicode.Mn, r) // mn: nonspacing marks + })) + ascii, _, _ := transform.String(t, s) + + // Keep only alphanumeric and space + ascii = albumSlugRegex.ReplaceAllString(ascii, " ") + tokens := strings.Fields(ascii) + s = strings.Join(tokens, "_") + s = strings.Trim(s, "_") + + if s != "" { + return s + } + + h := sha1.Sum([]byte(raw)) + return fmt.Sprintf("u_%x", h[:8]) +} + +// GenerateAlbumID builds the canonical album identifier: album__ or album_. +func GenerateAlbumID(album string, year *int32) string { + b := Slugify(album) + if b == "" { + return "" + } + if year != nil && *year >= 1000 && *year <= 2100 { + return fmt.Sprintf("album_%s_%d", b, *year) + } + return fmt.Sprintf("album_%s", b) +} + +// SHA256PrefixFile hashes up to maxBytes of the file at filePath and returns "sha256:". +func SHA256PrefixFile(filePath string, maxBytes int64) (string, error) { + f, err := os.Open(filePath) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + _, err = io.CopyN(h, f, maxBytes) + if err != nil && err != io.EOF { + return "", err + } + + return fmt.Sprintf("sha256:%x", h.Sum(nil)), nil +} + +// BuildMetadataFingerprint creates the canonical fingerprint string matching Python StreamXBot. +func BuildMetadataFingerprint(title, artist, album string, durationSec int32) string { + t := normalizeText(title) + a := normalizeText(artist) + al := normalizeText(album) + + durKey := "" + if durationSec > 0 { + bucket := int32(2) + durKey = strconv.Itoa(int(int32(math.Round(float64(durationSec)/float64(bucket))) * bucket)) + } + + parts := []string{t, a, al, durKey} + res := strings.Join(parts, "|") + return strings.Trim(res, "|") +} + +func normalizeText(val string) string { + s := strings.ToLower(val) + re := regexp.MustCompile(`[^\p{L}\p{N}]+`) + s = re.ReplaceAllString(s, " ") + return strings.Join(strings.Fields(s), " ") +} + +// NormalizeMimeType ensures audio mime types are standardized matching Python StreamXBot. +func NormalizeMimeType(mime string, fileName string) string { + if fileName != "" { + fn := strings.ToLower(strings.TrimSpace(fileName)) + switch { + case strings.HasSuffix(fn, ".wav") || strings.HasSuffix(fn, ".wave"): + return "audio/wav" + case strings.HasSuffix(fn, ".flac"): + return "audio/flac" + case strings.HasSuffix(fn, ".mp3"): + return "audio/mpeg" + case strings.HasSuffix(fn, ".m4a"): + return "audio/mp4" + case strings.HasSuffix(fn, ".ogg") || strings.HasSuffix(fn, ".opus"): + return "audio/ogg" + case strings.HasSuffix(fn, ".aac"): + return "audio/aac" + } + } + + if mime == "" { + return "audio/mpeg" + } + + raw := strings.ToLower(strings.TrimSpace(strings.Split(mime, ";")[0])) + switch { + case raw == "audio/flac" || raw == "audio/x-flac" || strings.HasSuffix(raw, "/x-flac"): + return "audio/flac" + case raw == "audio/wav" || raw == "audio/x-wav" || raw == "audio/wave": + return "audio/wav" + case raw == "audio/mp3" || raw == "audio/mpeg": + return "audio/mpeg" + case raw == "audio/m4a" || raw == "audio/x-m4a" || raw == "audio/mp4": + return "audio/mp4" + case raw == "audio/ogg" || raw == "application/ogg": + return "audio/ogg" + case raw == "audio/aac": + return "audio/aac" + default: + return raw + } +} diff --git a/internal/services/metadata_cleaner.go b/internal/services/metadata_cleaner.go new file mode 100644 index 0000000..2f407d5 --- /dev/null +++ b/internal/services/metadata_cleaner.go @@ -0,0 +1,12 @@ +package services + +import "streamgo/internal/metadata" + +// Expose metadata cleaner functions in services package for convenience +var ( + NormalizePunctuation = metadata.NormalizePunctuation + CleanDuplicatedPhrase = metadata.CleanDuplicatedPhrase + CleanArtist = metadata.CleanArtist + CleanTitle = metadata.CleanTitle + CleanMetadata = metadata.CleanMetadata +) diff --git a/internal/services/r2_storage.go b/internal/services/r2_storage.go new file mode 100644 index 0000000..95b49fb --- /dev/null +++ b/internal/services/r2_storage.go @@ -0,0 +1,372 @@ +package services + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "streamgo/internal/config" + "streamgo/internal/logger" +) + +var logR2 = logger.New("r2_storage") + +// AlbumCovers holds cached public R2 URLs for preview and master covers, as well as external iTunes/Deezer cover URLs. +type AlbumCovers struct { + CoverURL string // lightweight thumbnail for list previews + BigCoverURL string // master 1000x1000 artwork + ExternalCoverURL string // external iTunes/Deezer cover URL +} + +// R2StorageService provides optional Cloudflare R2 / S3-compatible blob storage +// for persistent, CDN-delivered artwork thumbnails. +type R2StorageService struct { + accountID string + accessKeyID string + secretAccessKey string + bucketName string + publicURL string + httpClient *http.Client + + mu sync.RWMutex + albumCoverCache map[string]AlbumCovers // albumID -> AlbumCovers +} + +// NewR2StorageService creates a new R2StorageService from application config. +func NewR2StorageService(cfg *config.Config) *R2StorageService { + if cfg == nil || !cfg.HasR2() { + return &R2StorageService{ + albumCoverCache: make(map[string]AlbumCovers), + } + } + + publicURL := strings.TrimRight(cfg.R2PublicURL, "/") + if publicURL == "" { + // Fallback to standard R2 dev or custom endpoint format + publicURL = fmt.Sprintf("https://%s.r2.cloudflarestorage.com/%s", cfg.R2AccountID, cfg.R2BucketName) + } + + return &R2StorageService{ + accountID: cfg.R2AccountID, + accessKeyID: cfg.R2AccessKeyID, + secretAccessKey: cfg.R2SecretAccessKey, + bucketName: cfg.R2BucketName, + publicURL: publicURL, + httpClient: &http.Client{ + Timeout: 15 * time.Second, + }, + albumCoverCache: make(map[string]AlbumCovers), + } +} + +// IsConfigured returns true if all required R2 credentials are present. +func (s *R2StorageService) IsConfigured() bool { + return s != nil && s.accountID != "" && s.accessKeyID != "" && s.secretAccessKey != "" && s.bucketName != "" +} + +// GetAlbumCover returns the cached public R2 master/big cover URL for an album, if present. +func (s *R2StorageService) GetAlbumCover(albumID string) string { + if s == nil || albumID == "" { + return "" + } + s.mu.RLock() + defer s.mu.RUnlock() + entry := s.albumCoverCache[albumID] + if entry.BigCoverURL != "" { + return entry.BigCoverURL + } + return entry.CoverURL +} + +// GetAlbumCovers returns the cached preview thumbnail and master artwork R2 URLs for an album. +func (s *R2StorageService) GetAlbumCovers(albumID string) (coverURL, bigCoverURL string) { + if s == nil || albumID == "" { + return "", "" + } + s.mu.RLock() + defer s.mu.RUnlock() + entry := s.albumCoverCache[albumID] + return entry.CoverURL, entry.BigCoverURL +} + +// GetAlbumExternalCover returns the cached external (iTunes/Deezer) cover URL for an album, if present. +func (s *R2StorageService) GetAlbumExternalCover(albumID string) string { + if s == nil || albumID == "" { + return "" + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.albumCoverCache[albumID].ExternalCoverURL +} + +// SetAlbumExternalCover caches the external (iTunes/Deezer) cover URL for an album. +func (s *R2StorageService) SetAlbumExternalCover(albumID, externalCoverURL string) { + if s == nil || albumID == "" || externalCoverURL == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + entry := s.albumCoverCache[albumID] + entry.ExternalCoverURL = externalCoverURL + s.albumCoverCache[albumID] = entry +} + +// SetAlbumCover caches the public R2 cover URL for an album. +func (s *R2StorageService) SetAlbumCover(albumID, coverURL string) { + if s == nil || albumID == "" || coverURL == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + entry := s.albumCoverCache[albumID] + entry.BigCoverURL = coverURL + if entry.CoverURL == "" { + entry.CoverURL = coverURL + } + s.albumCoverCache[albumID] = entry +} + +// SetAlbumCovers caches both the preview and master R2 cover URLs for an album. +func (s *R2StorageService) SetAlbumCovers(albumID, coverURL, bigCoverURL string) { + if s == nil || albumID == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + entry := s.albumCoverCache[albumID] + if coverURL != "" { + entry.CoverURL = coverURL + } + if bigCoverURL != "" { + entry.BigCoverURL = bigCoverURL + } + s.albumCoverCache[albumID] = entry +} + +// MaxCoverPayloadBytes defines the maximum allowed file size for cover art uploads (5 MB). +const MaxCoverPayloadBytes = 5 * 1024 * 1024 + +// DetectImageFormat inspects magic header bytes to determine image MIME type and file extension. +// Returns ok=false if data does not match known image formats (JPEG, PNG, WebP). +func DetectImageFormat(data []byte) (mimeType string, ext string, ok bool) { + if len(data) < 4 { + return "", "", false + } + // JPEG: 0xFF 0xD8 0xFF + if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { + return "image/jpeg", "jpg", true + } + // PNG: 0x89 'P' 'N' 'G' + if data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G' { + return "image/png", "png", true + } + // WebP: RIFF....WEBP + if len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP" { + if _, _, _, err := SanitizeAndValidateWebP(data); err == nil { + return "image/webp", "webp", true + } + return "", "", false + } + return "", "", false +} + +// UploadCover uploads artwork bytes to Cloudflare R2 using standard S3 SigV4 authentication. +// Rejects non-image payloads (audio files, corrupt streams) and oversized payloads > 5 MB. +// Returns the public CDN URL for the uploaded object. +func (s *R2StorageService) UploadCover(ctx context.Context, hash string, data []byte, contentType string) (string, error) { + if !s.IsConfigured() { + return "", fmt.Errorf("r2 storage is not configured") + } + if len(data) == 0 { + return "", fmt.Errorf("cannot upload empty data") + } + if len(data) > MaxCoverPayloadBytes { + return "", fmt.Errorf("refusing to upload oversized cover (%d bytes, max %d bytes)", len(data), MaxCoverPayloadBytes) + } + + detectedMime, ext, isImg := DetectImageFormat(data) + if !isImg { + preview := data + if len(preview) > 8 { + preview = preview[:8] + } + return "", fmt.Errorf("refusing to upload non-image or invalid payload to R2 (detected header: %x)", preview) + } + + if detectedMime == "image/webp" { + sanitized, _, _, err := SanitizeAndValidateWebP(data) + if err != nil { + return "", fmt.Errorf("refusing to upload invalid/corrupted WebP to R2: %w", err) + } + data = sanitized + } + + if contentType == "" { + contentType = detectedMime + } + key := fmt.Sprintf("covers/%s.%s", hash, ext) + + endpointHost := fmt.Sprintf("%s.r2.cloudflarestorage.com", s.accountID) + reqURL := fmt.Sprintf("https://%s/%s/%s", endpointHost, s.bucketName, key) + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, reqURL, bytes.NewReader(data)) + if err != nil { + return "", fmt.Errorf("failed to create upload request: %w", err) + } + + cacheControl := "public, max-age=31536000, immutable" + + req.Header.Set("Content-Type", contentType) + req.Header.Set("Cache-Control", cacheControl) + req.Header.Set("Host", endpointHost) + + // Sign request with AWS SigV4 + now := time.Now().UTC() + signS3Request(req, data, s.accessKeyID, s.secretAccessKey, "auto", "s3", now) + + resp, err := s.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("r2 upload request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { + return "", fmt.Errorf("r2 upload returned status %d", resp.StatusCode) + } + + publicURL := fmt.Sprintf("%s/%s", s.publicURL, key) + logR2.Infof("Successfully uploaded artwork to R2: %s (%.1f KB)", key, float64(len(data))/1024.0) + return publicURL, nil +} + +// DeleteCover removes an artwork object from Cloudflare R2 bucket. +func (s *R2StorageService) DeleteCover(ctx context.Context, key string) error { + if !s.IsConfigured() { + return fmt.Errorf("r2 storage is not configured") + } + key = strings.TrimPrefix(key, "/") + endpointHost := fmt.Sprintf("%s.r2.cloudflarestorage.com", s.accountID) + reqURL := fmt.Sprintf("https://%s/%s/%s", endpointHost, s.bucketName, key) + + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil) + if err != nil { + return fmt.Errorf("failed to create delete request: %w", err) + } + req.Header.Set("Host", endpointHost) + + now := time.Now().UTC() + signS3Request(req, nil, s.accessKeyID, s.secretAccessKey, "auto", "s3", now) + + resp, err := s.httpClient.Do(req) + if err != nil { + return fmt.Errorf("r2 delete request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound { + return fmt.Errorf("r2 delete returned status %d", resp.StatusCode) + } + + logR2.Infof("Successfully deleted artwork from R2: %s", key) + return nil +} + +// DeleteCoverByURL parses the R2 key from a public CDN URL and removes the object. +// Returns nil if R2 is not configured or if URL is empty. +func (s *R2StorageService) DeleteCoverByURL(ctx context.Context, rawURL string) error { + if s == nil || !s.IsConfigured() || strings.TrimSpace(rawURL) == "" { + return nil + } + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid cover url: %w", err) + } + key := strings.TrimPrefix(parsed.Path, "/") + if key == "" { + return nil + } + return s.DeleteCover(ctx, key) +} + +// signS3Request signs an HTTP request using AWS Signature Version 4 (SigV4). +func signS3Request(req *http.Request, payload []byte, accessKey, secretKey, region, service string, t time.Time) { + dateStamp := t.Format("20060102") + amzDate := t.Format("20060102T150405Z") + + payloadHashBytes := sha256.Sum256(payload) + payloadHash := hex.EncodeToString(payloadHashBytes[:]) + + req.Header.Set("x-amz-date", amzDate) + req.Header.Set("x-amz-content-sha256", payloadHash) + + // Build Canonical Headers (must be lowercase and sorted alphabetically) + host := req.Header.Get("Host") + cacheControl := req.Header.Get("Cache-Control") + contentType := req.Header.Get("Content-Type") + + canonicalHeaders := fmt.Sprintf( + "cache-control:%s\ncontent-type:%s\nhost:%s\nx-amz-content-sha256:%s\nx-amz-date:%s\n", + cacheControl, contentType, host, payloadHash, amzDate, + ) + signedHeaders := "cache-control;content-type;host;x-amz-content-sha256;x-amz-date" + + canonicalURI := req.URL.EscapedPath() + if canonicalURI == "" { + canonicalURI = "/" + } + + canonicalRequest := strings.Join([]string{ + req.Method, + canonicalURI, + "", // query string (empty for simple PUT) + canonicalHeaders, + signedHeaders, + payloadHash, + }, "\n") + + canonicalRequestHash := sha256Hex([]byte(canonicalRequest)) + + credentialScope := fmt.Sprintf("%s/%s/%s/aws4_request", dateStamp, region, service) + stringToSign := strings.Join([]string{ + "AWS4-HMAC-SHA256", + amzDate, + credentialScope, + canonicalRequestHash, + }, "\n") + + signingKey := getSignatureKey(secretKey, dateStamp, region, service) + signature := hex.EncodeToString(hmacSHA256(signingKey, []byte(stringToSign))) + + authHeader := fmt.Sprintf( + "AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", + accessKey, credentialScope, signedHeaders, signature, + ) + req.Header.Set("Authorization", authHeader) +} + +func hmacSHA256(key []byte, data []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(data) + return h.Sum(nil) +} + +func sha256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func getSignatureKey(key, dateStamp, regionName, serviceName string) []byte { + kDate := hmacSHA256([]byte("AWS4"+key), []byte(dateStamp)) + kRegion := hmacSHA256(kDate, []byte(regionName)) + kService := hmacSHA256(kRegion, []byte(serviceName)) + kSigning := hmacSHA256(kService, []byte("aws4_request")) + return kSigning +} diff --git a/internal/services/recap_service.go b/internal/services/recap_service.go new file mode 100644 index 0000000..daf91ab --- /dev/null +++ b/internal/services/recap_service.go @@ -0,0 +1,1105 @@ +package services + +import ( + "context" + "fmt" + "math" + "sort" + "strconv" + "strings" + "time" + + "streamgo/internal/models" + "streamgo/internal/repository" +) + +const ( + minPlayMs = 30_000 // 30 seconds + sessionGapSec = 30 * 60 // 30 minutes + ongoingTTLSec = 15 * 60 // 15 minutes + finishedTTLSec = 24 * 3600 // 24 hours +) + +var ( + nightHours = map[int]bool{21: true, 22: true, 23: true, 0: true, 1: true, 2: true, 3: true} + earlyHours = map[int]bool{5: true, 6: true, 7: true, 8: true} +) + +// RecapService manages recap computation, caching, and sharing. +type RecapService struct { + recapRepo repository.RecapRepository + trackRepo repository.TrackRepository +} + +// NewRecapService creates a new RecapService. +func NewRecapService(recapRepo repository.RecapRepository, trackRepo repository.TrackRepository) *RecapService { + return &RecapService{ + recapRepo: recapRepo, + trackRepo: trackRepo, + } +} + +func localMidnightUTC(year, month, day int, tzOffsetMin int) float64 { + loc := time.FixedZone("user_tz", tzOffsetMin*60) + t := time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc) + return float64(t.Unix()) +} + +func userLocalTime(epoch float64, tzOffsetMin int) time.Time { + loc := time.FixedZone("user_tz", tzOffsetMin*60) + return time.Unix(int64(epoch), 0).In(loc) +} + +// PeriodBounds calculates the start epoch, end epoch, and display label for a period ID. +func PeriodBounds(ptype models.RecapPeriodType, period string, tzOffsetMin int) (float64, float64, string, error) { + switch ptype { + case models.RecapPeriodWeekly: + parts := strings.Split(strings.ToUpper(period), "-W") + if len(parts) != 2 { + return 0, 0, "", fmt.Errorf("invalid weekly period format '%s', expected YYYY-Www", period) + } + year, err1 := strconv.Atoi(parts[0]) + week, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil || week < 1 || week > 53 { + return 0, 0, "", fmt.Errorf("invalid weekly period format '%s'", period) + } + + // ISO week Monday: Jan 4 is always in week 1 + jan4 := time.Date(year, time.January, 4, 0, 0, 0, 0, time.UTC) + isoMondayWeek1 := jan4.AddDate(0, 0, -int((jan4.Weekday()+6)%7)) + monday := isoMondayWeek1.AddDate(0, 0, (week-1)*7) + + start := localMidnightUTC(monday.Year(), int(monday.Month()), monday.Day(), tzOffsetMin) + nxtMonday := monday.AddDate(0, 0, 7) + end := localMidnightUTC(nxtMonday.Year(), int(nxtMonday.Month()), nxtMonday.Day(), tzOffsetMin) + + sunday := monday.AddDate(0, 0, 6) + label := fmt.Sprintf("%s %d – %s %d, %d", monday.Format("Jan"), monday.Day(), sunday.Format("Jan"), sunday.Day(), sunday.Year()) + return start, end, label, nil + + case models.RecapPeriodMonthly: + parts := strings.Split(period, "-") + if len(parts) != 2 { + return 0, 0, "", fmt.Errorf("invalid monthly period format '%s', expected YYYY-MM", period) + } + year, err1 := strconv.Atoi(parts[0]) + month, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil || month < 1 || month > 12 { + return 0, 0, "", fmt.Errorf("invalid monthly period format '%s'", period) + } + + start := localMidnightUTC(year, month, 1, tzOffsetMin) + ny, nm := year, month+1 + if month == 12 { + ny, nm = year+1, 1 + } + end := localMidnightUTC(ny, nm, 1, tzOffsetMin) + label := fmt.Sprintf("%s %d", time.Month(month).String(), year) + return start, end, label, nil + + case models.RecapPeriodYearly: + year, err := strconv.Atoi(period) + if err != nil { + return 0, 0, "", fmt.Errorf("invalid yearly period format '%s', expected YYYY", period) + } + start := localMidnightUTC(year, 1, 1, tzOffsetMin) + end := localMidnightUTC(year+1, 1, 1, tzOffsetMin) + label := fmt.Sprintf("%d", year) + return start, end, label, nil + + default: + return 0, 0, "", fmt.Errorf("unknown period type '%s'", ptype) + } +} + +// PreviousPeriod returns the identifier of the period immediately preceding the given period. +func PreviousPeriod(ptype models.RecapPeriodType, period string) string { + switch ptype { + case models.RecapPeriodWeekly: + parts := strings.Split(strings.ToUpper(period), "-W") + if len(parts) == 2 { + year, _ := strconv.Atoi(parts[0]) + week, _ := strconv.Atoi(parts[1]) + jan4 := time.Date(year, time.January, 4, 0, 0, 0, 0, time.UTC) + isoMondayWeek1 := jan4.AddDate(0, 0, -int((jan4.Weekday()+6)%7)) + monday := isoMondayWeek1.AddDate(0, 0, (week-1)*7).AddDate(0, 0, -7) + prevYear, prevWeek := monday.ISOWeek() + return fmt.Sprintf("%d-W%02d", prevYear, prevWeek) + } + return period + + case models.RecapPeriodMonthly: + parts := strings.Split(period, "-") + if len(parts) == 2 { + year, _ := strconv.Atoi(parts[0]) + month, _ := strconv.Atoi(parts[1]) + if month == 1 { + return fmt.Sprintf("%d-12", year-1) + } + return fmt.Sprintf("%d-%02d", year, month-1) + } + return period + + case models.RecapPeriodYearly: + year, err := strconv.Atoi(period) + if err == nil { + return strconv.Itoa(year - 1) + } + return period + + default: + return period + } +} + +// PeriodIDFor formats a timestamp into the period identifier for the given type. +func PeriodIDFor(ptype models.RecapPeriodType, t time.Time) string { + switch ptype { + case models.RecapPeriodWeekly: + year, week := t.ISOWeek() + return fmt.Sprintf("%d-W%02d", year, week) + case models.RecapPeriodMonthly: + return t.Format("2006-01") + case models.RecapPeriodYearly: + return fmt.Sprintf("%d", t.Year()) + default: + return "" + } +} + +// IsPeriodAvailable determines whether a recap period is ready/unlocked to show to users. +func IsPeriodAvailable(ptype models.RecapPeriodType, period string, tzOffsetMin int) bool { + _, end, _, err := PeriodBounds(ptype, period, tzOffsetMin) + if err != nil { + return false + } + + now := float64(time.Now().Unix()) + if end <= now { + return true // past completed period is always available + } + + nowLocal := userLocalTime(now, tzOffsetMin) + switch ptype { + case models.RecapPeriodWeekly: + // Unlocked on weekends (Saturday or Sunday) + wd := nowLocal.Weekday() + return wd == time.Saturday || wd == time.Sunday + case models.RecapPeriodMonthly: + // Unlocked on the last day of the month + nextDay := nowLocal.AddDate(0, 0, 1) + return nextDay.Month() != nowLocal.Month() + case models.RecapPeriodYearly: + // Unlocked on Dec 31st + return nowLocal.Month() == time.December && nowLocal.Day() == 31 + default: + return false + } +} + +func classifyEvent(ev *models.ListeningEventDoc, durationMs int) (isPlay bool, completed bool, isSkip bool, playedMs int) { + if ev.PlayedMs == nil { + // Legacy history row: assume played through + if durationMs > 0 { + playedMs = durationMs + } else { + playedMs = 180_000 // 3 minutes default + } + } else { + playedMs = *ev.PlayedMs + } + + completed = ev.Completed || (durationMs > 0 && playedMs >= int(0.8*float64(durationMs))) + isPlay = playedMs >= minPlayMs || completed + isSkip = !isPlay + + effectiveMs := playedMs + if durationMs > 0 && playedMs > durationMs { + effectiveMs = durationMs + } + return isPlay, completed, isSkip, effectiveMs +} + +type startSession struct { + start float64 + ms int +} + +// ComputeStats calculates aggregated listening statistics for a list of events. +func (s *RecapService) ComputeStats( + events []*models.ListeningEventDoc, + tracks map[string]*models.Track, + tzOffsetMin int, + seenBefore map[string]bool, +) *models.RecapStats { + totalMs := 0 + plays := 0 + completed := 0 + skipped := 0 + + trackPlays := make(map[string]int) + trackMs := make(map[string]int) + + artistPlays := make(map[string]int) + artistMs := make(map[string]int) + artistCovers := make(map[string]string) + + type albumKey struct { + album string + artist string + } + albumPlays := make(map[albumKey]int) + albumMs := make(map[albumKey]int) + albumMeta := make(map[albumKey]*models.AlbumStat) + + topicPlays := make(map[string]int) + + byHour := make([]int, 24) + byWeekday := make([]int, 7) + byDate := make(map[string]int) + byMonth := make(map[string]int) + + losslessPlays := 0 + var starts []startSession + + for _, ev := range events { + t := tracks[ev.TrackID] + durationMs := ev.DurationMs + if durationMs <= 0 && t != nil && t.Audio.DurationSec > 0 { + durationMs = int(t.Audio.DurationSec) * 1000 + } + + _, isComp, isSkip, playedMs := classifyEvent(ev, durationMs) + if isSkip { + skipped++ + continue + } + + plays++ + if isComp { + completed++ + } + totalMs += playedMs + + tid := ev.TrackID + trackPlays[tid]++ + trackMs[tid] += playedMs + + when := userLocalTime(ev.PlayedAt, tzOffsetMin) + hour := when.Hour() + if hour >= 0 && hour < 24 { + byHour[hour] += playedMs + } + + // Convert Go Sunday=0 weekday to ISO Monday=0 ... Sunday=6 + isoWd := int((when.Weekday() + 6) % 7) + if isoWd >= 0 && isoWd < 7 { + byWeekday[isoWd] += playedMs + } + + dateKey := when.Format("2006-01-02") + byDate[dateKey] += playedMs + + monthKey := when.Format("2006-01") + byMonth[monthKey] += playedMs + + startTime := ev.StartedAt + if startTime <= 0 { + startTime = ev.PlayedAt + } + starts = append(starts, startSession{start: startTime, ms: playedMs}) + + if t != nil { + artist := strings.TrimSpace(t.EffectiveArtist()) + if artist == "" { + artist = "Unknown artist" + } + artistPlays[artist]++ + artistMs[artist] += playedMs + if _, exists := artistCovers[artist]; !exists { + artistCovers[artist] = t.EffectiveCoverURL() + } + + if album := strings.TrimSpace(t.Audio.Album); album != "" { + ak := albumKey{album: album, artist: artist} + albumPlays[ak]++ + albumMs[ak] += playedMs + if _, exists := albumMeta[ak]; !exists { + var albumIDPtr *string + if t.Audio.AlbumID != "" { + idCopy := t.Audio.AlbumID + albumIDPtr = &idCopy + } + var coverPtr *string + if cov := t.EffectiveCoverURL(); cov != "" { + covCopy := cov + coverPtr = &covCopy + } + albumMeta[ak] = &models.AlbumStat{ + Album: album, + Artist: artist, + AlbumID: albumIDPtr, + CoverURL: coverPtr, + } + } + } + + if t.TopicName != "" { + topicPlays[t.TopicName]++ + } + + typ := strings.ToUpper(strings.TrimSpace(t.Audio.Type)) + if typ == "FLAC" || typ == "ALAC" || typ == "WAV" { + losslessPlays++ + } + } + } + + // Longest session & session count calculation (split on gaps > 30 min) + longestSessionMs := 0 + sessionCount := 0 + if len(starts) > 0 { + sort.Slice(starts, func(i, j int) bool { + return starts[i].start < starts[j].start + }) + curStart := starts[0].start + curMs := starts[0].ms + curEnd := curStart + float64(starts[0].ms)/1000.0 + sessionCount = 1 + + for _, s := range starts[1:] { + if s.start-curEnd > sessionGapSec { + if curMs > longestSessionMs { + longestSessionMs = curMs + } + curStart = s.start + curMs = 0 + sessionCount++ + } + curMs += s.ms + end := s.start + float64(s.ms)/1000.0 + if end > curEnd { + curEnd = end + } + } + if curMs > longestSessionMs { + longestSessionMs = curMs + } + } + + // Build Top Tracks + type trackEntry struct { + tid string + plays int + ms int + } + var sortedTracks []trackEntry + for tid, count := range trackPlays { + sortedTracks = append(sortedTracks, trackEntry{tid: tid, plays: count, ms: trackMs[tid]}) + } + sort.Slice(sortedTracks, func(i, j int) bool { + if sortedTracks[i].plays == sortedTracks[j].plays { + return sortedTracks[i].ms > sortedTracks[j].ms + } + return sortedTracks[i].plays > sortedTracks[j].plays + }) + + var topTracks []*models.TrackStat + maxTopTracks := 10 + if len(sortedTracks) < maxTopTracks { + maxTopTracks = len(sortedTracks) + } + for i := 0; i < maxTopTracks; i++ { + te := sortedTracks[i] + t := tracks[te.tid] + var item *models.BrowseItem + if t != nil { + item = t.ToBrowseItem() + } else { + item = &models.BrowseItem{ + ID: te.tid, + Title: "Unknown track", + } + } + topTracks = append(topTracks, &models.TrackStat{ + Track: item, + Plays: te.plays, + Minutes: math.Round(float64(te.ms)/60000.0*10) / 10, + }) + } + + // Build Top Artists + type artistEntry struct { + name string + plays int + ms int + } + var sortedArtists []artistEntry + for name, count := range artistPlays { + sortedArtists = append(sortedArtists, artistEntry{name: name, plays: count, ms: artistMs[name]}) + } + sort.Slice(sortedArtists, func(i, j int) bool { + if sortedArtists[i].plays == sortedArtists[j].plays { + return sortedArtists[i].ms > sortedArtists[j].ms + } + return sortedArtists[i].plays > sortedArtists[j].plays + }) + + var topArtists []*models.ArtistStat + maxTopArtists := 10 + if len(sortedArtists) < maxTopArtists { + maxTopArtists = len(sortedArtists) + } + for i := 0; i < maxTopArtists; i++ { + ae := sortedArtists[i] + var covPtr *string + if c, ok := artistCovers[ae.name]; ok && c != "" { + cCopy := c + covPtr = &cCopy + } + topArtists = append(topArtists, &models.ArtistStat{ + Name: ae.name, + Plays: ae.plays, + Minutes: math.Round(float64(ae.ms)/60000.0*10) / 10, + CoverURL: covPtr, + }) + } + + // Build Top Albums + type albumEntry struct { + key albumKey + plays int + ms int + } + var sortedAlbums []albumEntry + for k, count := range albumPlays { + sortedAlbums = append(sortedAlbums, albumEntry{key: k, plays: count, ms: albumMs[k]}) + } + sort.Slice(sortedAlbums, func(i, j int) bool { + if sortedAlbums[i].plays == sortedAlbums[j].plays { + return sortedAlbums[i].ms > sortedAlbums[j].ms + } + return sortedAlbums[i].plays > sortedAlbums[j].plays + }) + + var topAlbums []*models.AlbumStat + maxTopAlbums := 6 + if len(sortedAlbums) < maxTopAlbums { + maxTopAlbums = len(sortedAlbums) + } + for i := 0; i < maxTopAlbums; i++ { + ale := sortedAlbums[i] + baseMeta := albumMeta[ale.key] + topAlbums = append(topAlbums, &models.AlbumStat{ + Album: baseMeta.Album, + Artist: baseMeta.Artist, + AlbumID: baseMeta.AlbumID, + CoverURL: baseMeta.CoverURL, + Plays: ale.plays, + Minutes: math.Round(float64(ale.ms)/60000.0*10) / 10, + }) + } + + // Top Genres + type genreEntry struct { + name string + plays int + } + var sortedGenres []genreEntry + for name, count := range topicPlays { + sortedGenres = append(sortedGenres, genreEntry{name: name, plays: count}) + } + sort.Slice(sortedGenres, func(i, j int) bool { + return sortedGenres[i].plays > sortedGenres[j].plays + }) + + var topGenres []*models.GenreStat + maxTopGenres := 5 + if len(sortedGenres) < maxTopGenres { + maxTopGenres = len(sortedGenres) + } + for i := 0; i < maxTopGenres; i++ { + topGenres = append(topGenres, &models.GenreStat{ + Name: sortedGenres[i].name, + Plays: sortedGenres[i].plays, + }) + } + + // Convert hour and weekday ms to minutes + listeningByHour := make([]int, 24) + for h := 0; h < 24; h++ { + listeningByHour[h] = int(math.Round(float64(byHour[h]) / 60000.0)) + } + listeningByWeekday := make([]int, 7) + for d := 0; d < 7; d++ { + listeningByWeekday[d] = int(math.Round(float64(byWeekday[d]) / 60000.0)) + } + + // Sorted days + var sortedDayKeys []string + for k := range byDate { + sortedDayKeys = append(sortedDayKeys, k) + } + sort.Strings(sortedDayKeys) + var listeningByDay []*models.DayStat + for _, k := range sortedDayKeys { + listeningByDay = append(listeningByDay, &models.DayStat{ + Date: k, + Minutes: int(math.Round(float64(byDate[k]) / 60000.0)), + }) + } + + // Sorted months + var sortedMonthKeys []string + for k := range byMonth { + sortedMonthKeys = append(sortedMonthKeys, k) + } + sort.Strings(sortedMonthKeys) + var listeningByMonth []*models.MonthStat + for _, k := range sortedMonthKeys { + listeningByMonth = append(listeningByMonth, &models.MonthStat{ + Month: k, + Minutes: int(math.Round(float64(byMonth[k]) / 60000.0)), + }) + } + + // Active periods & shares + nightMs := 0 + for h := range nightHours { + nightMs += byHour[h] + } + earlyMs := 0 + for h := range earlyHours { + earlyMs += byHour[h] + } + weekendMs := byWeekday[5] + byWeekday[6] + + repeatPlays := 0 + for _, c := range trackPlays { + if c > 1 { + repeatPlays += c - 1 + } + } + + var mostActiveHour *int + if totalMs > 0 { + bestH := 0 + bestHMs := byHour[0] + for h := 1; h < 24; h++ { + if byHour[h] > bestHMs { + bestH = h + bestHMs = byHour[h] + } + } + mostActiveHour = &bestH + } + + var mostActiveWeekday *int + if totalMs > 0 { + bestD := 0 + bestDMs := byWeekday[0] + for d := 1; d < 7; d++ { + if byWeekday[d] > bestDMs { + bestD = d + bestDMs = byWeekday[d] + } + } + mostActiveWeekday = &bestD + } + + var mostActiveDate *string + mostActiveDateMinutes := 0 + if len(byDate) > 0 { + bestDate := "" + bestDateMs := -1 + for d, ms := range byDate { + if ms > bestDateMs { + bestDate = d + bestDateMs = ms + } + } + if bestDate != "" { + mostActiveDate = &bestDate + mostActiveDateMinutes = int(math.Round(float64(bestDateMs) / 60000.0)) + } + } + + // Discovery tracks + var discoveryTracks []*models.BrowseItem + discoveryCount := 0 + for _, te := range sortedTracks { + if !seenBefore[te.tid] { + discoveryCount++ + if len(discoveryTracks) < 6 { + if t := tracks[te.tid]; t != nil { + discoveryTracks = append(discoveryTracks, t.ToBrowseItem()) + } + } + } + } + + round3 := func(val float64) float64 { + return math.Round(val*1000) / 1000 + } + + nightShare := 0.0 + earlyShare := 0.0 + weekendShare := 0.0 + if totalMs > 0 { + nightShare = round3(float64(nightMs) / float64(totalMs)) + earlyShare = round3(float64(earlyMs) / float64(totalMs)) + weekendShare = round3(float64(weekendMs) / float64(totalMs)) + } + + losslessShare := 0.0 + if plays > 0 { + losslessShare = round3(float64(losslessPlays) / float64(plays)) + } + + topArtistShare := 0.0 + if plays > 0 && len(sortedArtists) > 0 { + topArtistShare = round3(float64(sortedArtists[0].plays) / float64(plays)) + } + + return &models.RecapStats{ + TotalMinutes: int(math.Round(float64(totalMs) / 60000.0)), + TotalPlays: plays, + CompletedPlays: completed, + SkippedPlays: skipped, + UniqueTracks: len(trackPlays), + UniqueArtists: len(artistPlays), + UniqueAlbums: len(albumPlays), + TopTracks: topTracks, + TopArtists: topArtists, + TopAlbums: topAlbums, + TopGenres: topGenres, + ListeningByHour: listeningByHour, + ListeningByWeekday: listeningByWeekday, + ListeningByDay: listeningByDay, + ListeningByMonth: listeningByMonth, + DiscoveryCount: discoveryCount, + DiscoveryTracks: discoveryTracks, + RepeatCount: repeatPlays, + MostActiveHour: mostActiveHour, + MostActiveWeekday: mostActiveWeekday, + MostActiveDate: mostActiveDate, + MostActiveDateMinutes: mostActiveDateMinutes, + LongestSessionMinutes: int(math.Round(float64(longestSessionMs) / 60000.0)), + SessionCount: sessionCount, + NightShare: nightShare, + EarlyShare: earlyShare, + WeekendShare: weekendShare, + LosslessShare: losslessShare, + TopArtistShare: topArtistShare, + } +} + +// ComputePersonality derives up to 3 transparent listening personality traits. +func ComputePersonality(stats *models.RecapStats) []*models.RecapPersonality { + var out []*models.RecapPersonality + plays := stats.TotalPlays + if plays < 5 { + return out + } + + if stats.NightShare >= 0.45 { + out = append(out, &models.RecapPersonality{ + ID: "night_owl", + Name: "Night Owl", + Detail: fmt.Sprintf("%d%% of your listening happened after 9 PM", int(math.Round(stats.NightShare*100))), + }) + } + if stats.EarlyShare >= 0.35 { + out = append(out, &models.RecapPersonality{ + ID: "early_bird", + Name: "Early Bird", + Detail: fmt.Sprintf("%d%% of your listening was before 9 AM", int(math.Round(stats.EarlyShare*100))), + }) + } + if stats.UniqueTracks > 0 && float64(stats.DiscoveryCount)/float64(stats.UniqueTracks) >= 0.5 && stats.DiscoveryCount >= 10 { + out = append(out, &models.RecapPersonality{ + ID: "explorer", + Name: "Explorer", + Detail: fmt.Sprintf("%d of %d tracks were new to you", stats.DiscoveryCount, stats.UniqueTracks), + }) + } + if plays > 0 && float64(stats.RepeatCount)/float64(plays) >= 0.45 { + out = append(out, &models.RecapPersonality{ + ID: "replayer", + Name: "Replayer", + Detail: fmt.Sprintf("%d replays — you know what you like", stats.RepeatCount), + }) + } + if stats.TopArtistShare >= 0.4 && len(stats.TopArtists) > 0 { + out = append(out, &models.RecapPersonality{ + ID: "loyalist", + Name: "Loyalist", + Detail: fmt.Sprintf("%d%% of plays were %s", int(math.Round(stats.TopArtistShare*100)), stats.TopArtists[0].Name), + }) + } + if stats.UniqueArtists >= 25 && stats.TopArtistShare < 0.15 { + out = append(out, &models.RecapPersonality{ + ID: "genre_hopper", + Name: "Genre Hopper", + Detail: fmt.Sprintf("%d artists, no clear favourite", stats.UniqueArtists), + }) + } + if stats.UniqueAlbums > 0 && len(stats.TopAlbums) > 0 && stats.TopAlbums[0].Plays >= 8 && plays > 0 && float64(stats.CompletedPlays)/float64(plays) >= 0.7 { + out = append(out, &models.RecapPersonality{ + ID: "album_collector", + Name: "Album Listener", + Detail: "You play albums through, front to back", + }) + } + if stats.WeekendShare >= 0.5 { + out = append(out, &models.RecapPersonality{ + ID: "weekend", + Name: "Weekend Listener", + Detail: fmt.Sprintf("%d%% of listening on weekends", int(math.Round(stats.WeekendShare*100))), + }) + } + if stats.LongestSessionMinutes >= 120 { + out = append(out, &models.RecapPersonality{ + ID: "binge", + Name: "Binge Listener", + Detail: fmt.Sprintf("Longest session: %d minutes", stats.LongestSessionMinutes), + }) + } + if stats.LosslessShare >= 0.6 { + out = append(out, &models.RecapPersonality{ + ID: "audiophile", + Name: "Audiophile", + Detail: fmt.Sprintf("%d%% of plays were lossless", int(math.Round(stats.LosslessShare*100))), + }) + } + + if len(out) == 0 { + out = append(out, &models.RecapPersonality{ + ID: "steady", + Name: "Steady Listener", + Detail: fmt.Sprintf("%d plays across %d artists", plays, stats.UniqueArtists), + }) + } + + if len(out) > 3 { + return out[:3] + } + return out +} + +// BuildSnapshot generates a full RecapSnapshot for a user and period. +func (s *RecapService) BuildSnapshot(ctx context.Context, userID int64, ptype models.RecapPeriodType, period string, tzOffsetMin int) (*models.RecapSnapshot, error) { + start, end, label, err := PeriodBounds(ptype, period, tzOffsetMin) + if err != nil { + return nil, err + } + + events, err := s.recapRepo.LoadEvents(ctx, userID, start, end) + if err != nil { + return nil, err + } + + // Extract unique track IDs + var trackIDs []string + idSet := make(map[string]bool) + for _, e := range events { + if e.TrackID != "" && !idSet[e.TrackID] { + idSet[e.TrackID] = true + trackIDs = append(trackIDs, e.TrackID) + } + } + + // Load track metadata from MongoDB + tracksMap := make(map[string]*models.Track) + if len(trackIDs) > 0 { + loadedTracks, err := s.trackRepo.GetByIDs(ctx, trackIDs) + if err == nil { + for _, t := range loadedTracks { + tracksMap[t.ID] = t + } + } + } + + seenBefore, _ := s.recapRepo.FirstSeenBefore(ctx, userID, trackIDs, start) + stats := s.ComputeStats(events, tracksMap, tzOffsetMin, seenBefore) + + // Previous period comparison + prevID := PreviousPeriod(ptype, period) + var comparison *models.RecapComparison + if prevStart, prevEnd, _, err := PeriodBounds(ptype, prevID, tzOffsetMin); err == nil { + prevEvents, err := s.recapRepo.LoadEvents(ctx, userID, prevStart, prevEnd) + if err == nil && len(prevEvents) > 0 { + var prevTrackIDs []string + prevIDSet := make(map[string]bool) + for _, e := range prevEvents { + if e.TrackID != "" && !prevIDSet[e.TrackID] { + prevIDSet[e.TrackID] = true + prevTrackIDs = append(prevTrackIDs, e.TrackID) + } + } + prevTracksMap := make(map[string]*models.Track) + if loaded, err := s.trackRepo.GetByIDs(ctx, prevTrackIDs); err == nil { + for _, t := range loaded { + prevTracksMap[t.ID] = t + } + } + + prevStats := s.ComputeStats(prevEvents, prevTracksMap, tzOffsetMin, make(map[string]bool)) + comparison = ComputeComparison(prevID, stats, prevStats) + } + } + + now := float64(time.Now().Unix()) + legacyData := len(events) > 0 + for _, e := range events { + if !e.Legacy { + legacyData = false + break + } + } + + return &models.RecapSnapshot{ + SchemaVersion: repository.RecapSchemaVersion, + Type: ptype, + Period: period, + Label: label, + Start: start, + End: end, + Ongoing: end > now, + GeneratedAt: now, + TzOffsetMin: tzOffsetMin, + EventCount: len(events), + LegacyData: legacyData, + Stats: stats, + Personality: ComputePersonality(stats), + Comparison: comparison, + }, nil +} + +// GetSnapshot returns a cached snapshot if valid, or builds and saves a fresh one. +func (s *RecapService) GetSnapshot(ctx context.Context, userID int64, ptype models.RecapPeriodType, period string, tzOffsetMin int, force bool) (*models.RecapSnapshot, error) { + now := float64(time.Now().Unix()) + if !force { + cached, genAt, err := s.recapRepo.GetSnapshot(ctx, userID, ptype, period, tzOffsetMin) + if err == nil && cached != nil { + ttl := float64(finishedTTLSec) + if cached.Ongoing { + ttl = float64(ongoingTTLSec) + } + if now-genAt < ttl { + return cached, nil + } + } + } + + snap, err := s.BuildSnapshot(ctx, userID, ptype, period, tzOffsetMin) + if err != nil { + return nil, err + } + + _ = s.recapRepo.SaveSnapshot(ctx, userID, ptype, period, tzOffsetMin, snap) + return snap, nil +} + +// ListAvailable returns all unlocked periods that contain listening history for the user. +func (s *RecapService) ListAvailable(ctx context.Context, userID int64, tzOffsetMin int) ([]*models.RecapAvailableItem, error) { + firstTimestamp, found, err := s.recapRepo.FirstEventTimestamp(ctx, userID) + if err != nil || !found || firstTimestamp <= 0 { + return []*models.RecapAvailableItem{}, nil + } + + now := float64(time.Now().Unix()) + nowLocal := userLocalTime(now, tzOffsetMin) + firstLocal := userLocalTime(firstTimestamp, tzOffsetMin) + var out []*models.RecapAvailableItem + + // Check weeks (up to 8 weeks backwards) + curWeek := nowLocal + for i := 0; i < 8; i++ { + pid := PeriodIDFor(models.RecapPeriodWeekly, curWeek) + start, end, label, err := PeriodBounds(models.RecapPeriodWeekly, pid, tzOffsetMin) + if err == nil { + if end < firstTimestamp { + break + } + if IsPeriodAvailable(models.RecapPeriodWeekly, pid, tzOffsetMin) { + has, _ := s.recapRepo.HasPlays(ctx, userID, start, end) + if has { + out = append(out, &models.RecapAvailableItem{ + Type: models.RecapPeriodWeekly, + Period: pid, + Label: label, + Ongoing: end > now, + }) + } + } + } + curWeek = curWeek.AddDate(0, 0, -7) + } + + // Check months (up to 12 months backwards) + y, m := nowLocal.Year(), int(nowLocal.Month()) + for i := 0; i < 12; i++ { + pid := fmt.Sprintf("%d-%02d", y, m) + start, end, label, err := PeriodBounds(models.RecapPeriodMonthly, pid, tzOffsetMin) + if err == nil { + if end < firstTimestamp { + break + } + if IsPeriodAvailable(models.RecapPeriodMonthly, pid, tzOffsetMin) { + has, _ := s.recapRepo.HasPlays(ctx, userID, start, end) + if has { + out = append(out, &models.RecapAvailableItem{ + Type: models.RecapPeriodMonthly, + Period: pid, + Label: label, + Ongoing: end > now, + }) + } + } + } + if m == 1 { + y, m = y-1, 12 + } else { + m-- + } + } + + // Check years + for year := nowLocal.Year(); year >= firstLocal.Year(); year-- { + pid := strconv.Itoa(year) + start, end, label, err := PeriodBounds(models.RecapPeriodYearly, pid, tzOffsetMin) + if err == nil { + if IsPeriodAvailable(models.RecapPeriodYearly, pid, tzOffsetMin) { + has, _ := s.recapRepo.HasPlays(ctx, userID, start, end) + if has { + out = append(out, &models.RecapAvailableItem{ + Type: models.RecapPeriodYearly, + Period: pid, + Label: label, + Ongoing: end > now, + }) + } + } + } + } + + return out, nil +} + +// ComputeComparison calculates comparison statistics against the preceding period. +func ComputeComparison(prevID string, stats *models.RecapStats, prevStats *models.RecapStats) *models.RecapComparison { + if stats == nil || prevStats == nil { + return nil + } + + deltaPct := func(cur, old int) *int { + if old == 0 { + return nil + } + pct := int(math.Round(float64(cur-old) / float64(old) * 100)) + return &pct + } + + var topArtistPrev *string + if len(prevStats.TopArtists) > 0 { + aName := prevStats.TopArtists[0].Name + topArtistPrev = &aName + } + + return &models.RecapComparison{ + Period: prevID, + TotalMinutes: prevStats.TotalMinutes, + TotalPlays: prevStats.TotalPlays, + UniqueArtists: prevStats.UniqueArtists, + UniqueTracks: prevStats.UniqueTracks, + MinutesDeltaPct: deltaPct(stats.TotalMinutes, prevStats.TotalMinutes), + PlaysDeltaPct: deltaPct(stats.TotalPlays, prevStats.TotalPlays), + ArtistsDelta: stats.UniqueArtists - prevStats.UniqueArtists, + NightShareDelta: int(math.Round((stats.NightShare - prevStats.NightShare) * 100)), + TopArtistPrev: topArtistPrev, + } +} + +// PublicSummary converts a full RecapSnapshot into a sanitized summary for public sharing. +func PublicSummary(snap *models.RecapSnapshot) *models.RecapPublicSummary { + var topTrack *models.RecapPublicTrack + if len(snap.Stats.TopTracks) > 0 && snap.Stats.TopTracks[0].Track != nil { + t := snap.Stats.TopTracks[0].Track + var tTitle, tArtist, tCover *string + if t.Title != "" { + c := t.Title + tTitle = &c + } + if t.Artist != "" { + c := t.Artist + tArtist = &c + } + if t.CoverURL != "" { + c := t.CoverURL + tCover = &c + } + topTrack = &models.RecapPublicTrack{ + Title: tTitle, + Artist: tArtist, + CoverURL: tCover, + } + } + + var topArtist *models.ArtistStat + if len(snap.Stats.TopArtists) > 0 { + topArtist = snap.Stats.TopArtists[0] + } + + return &models.RecapPublicSummary{ + Type: snap.Type, + Period: snap.Period, + Label: snap.Label, + TotalMinutes: snap.Stats.TotalMinutes, + TotalPlays: snap.Stats.TotalPlays, + UniqueArtists: snap.Stats.UniqueArtists, + UniqueTracks: snap.Stats.UniqueTracks, + TopArtist: topArtist, + TopTrack: topTrack, + Personality: snap.Personality, + } +} + +// CreateShare creates or refreshes a public share link for a user's recap. +func (s *RecapService) CreateShare(ctx context.Context, userID int64, snap *models.RecapSnapshot) (*models.RecapShareItem, error) { + summary := PublicSummary(snap) + return s.recapRepo.CreateShare(ctx, userID, snap.Type, snap.Period, summary) +} + +// ListShares returns all active public share links for the user. +func (s *RecapService) ListShares(ctx context.Context, userID int64) ([]*models.RecapShareItem, error) { + return s.recapRepo.ListShares(ctx, userID) +} + +// RevokeShare revokes an active share token. +func (s *RecapService) RevokeShare(ctx context.Context, userID int64, token string) (bool, error) { + return s.recapRepo.RevokeShare(ctx, userID, token) +} + +// GetPublicShare returns the sanitized summary for a public share link. +func (s *RecapService) GetPublicShare(ctx context.Context, token string) (*models.RecapPublicSummary, error) { + return s.recapRepo.GetPublicShare(ctx, token) +} + +// DeleteUserData deletes all recap telemetry and share links for a user. +func (s *RecapService) DeleteUserData(ctx context.Context, userID int64) (map[string]int64, error) { + e, sn, sh, err := s.recapRepo.DeleteUserData(ctx, userID) + if err != nil { + return nil, err + } + return map[string]int64{ + "events": e, + "snapshots": sn, + "shares": sh, + }, nil +} + +// RecordEvents saves incoming listening telemetry events. +func (s *RecapService) RecordEvents(ctx context.Context, userID int64, events []models.ListeningEventItem) (int, error) { + return s.recapRepo.RecordEvents(ctx, userID, events) +} diff --git a/internal/services/stream_service.go b/internal/services/stream_service.go new file mode 100644 index 0000000..222cc07 --- /dev/null +++ b/internal/services/stream_service.go @@ -0,0 +1,671 @@ +package services + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/gotd/td/tg" + + "streamgo/internal/logger" + "streamgo/internal/models" + "streamgo/internal/repository" + "streamgo/internal/telegram" +) + +var logStream = logger.New("stream") + +var rangeRegex = regexp.MustCompile(`(?i)^bytes\s*=\s*(\d*)\s*-\s*(\d*)$`) + +// ErrRangeNotSatisfiable is returned when the requested byte range is outside the file bounds. +var ErrRangeNotSatisfiable = errors.New("range not satisfiable") + +// StreamService coordinates audio range parsing and streaming. +type StreamService struct { + repo repository.TrackRepository + tgService *telegram.Service + alacService *ALACService + mediaDir string +} + +// NewStreamService creates a new StreamService. +func NewStreamService(repo repository.TrackRepository, tg *telegram.Service) *StreamService { + mediaDir := "stream_media" + _ = os.MkdirAll(mediaDir, 0755) + _ = os.MkdirAll(filepath.Join(mediaDir, "alac_cache"), 0755) + + return &StreamService{ + repo: repo, + tgService: tg, + mediaDir: mediaDir, + } +} + +// SetALACService attaches the ALACService to StreamService. +func (s *StreamService) SetALACService(alac *ALACService) { + s.alacService = alac +} + +// ALACService returns the attached ALACService. +func (s *StreamService) ALACService() *ALACService { + return s.alacService +} + +// ByteRange defines start and end byte offsets. +type ByteRange struct { + Start int64 + End int64 + Length int64 +} + +// ParseRange parses an HTTP Range header string into byte boundaries. +func ParseRange(rangeHeader string, totalSize int64) (*ByteRange, error) { + rangeHeader = strings.TrimSpace(rangeHeader) + if rangeHeader == "" { + return nil, nil + } + + matches := rangeRegex.FindStringSubmatch(rangeHeader) + if len(matches) != 3 { + return nil, fmt.Errorf("invalid range format: %s", rangeHeader) + } + + startStr := strings.TrimSpace(matches[1]) + endStr := strings.TrimSpace(matches[2]) + + if startStr == "" && endStr == "" { + return nil, fmt.Errorf("empty range boundaries") + } + + var start, end int64 + + if startStr == "" { + n, err := strconv.ParseInt(endStr, 10, 64) + if err != nil || n <= 0 { + return nil, fmt.Errorf("invalid range offset: %s", endStr) + } + start = totalSize - n + if start < 0 { + start = 0 + } + end = totalSize - 1 + } else { + var err error + start, err = strconv.ParseInt(startStr, 10, 64) + if err != nil || start < 0 { + return nil, fmt.Errorf("invalid range start: %s", startStr) + } + + if endStr == "" { + end = totalSize - 1 + } else { + end, err = strconv.ParseInt(endStr, 10, 64) + if err != nil || end < start { + return nil, fmt.Errorf("invalid range end: %s", endStr) + } + if end >= totalSize { + end = totalSize - 1 + } + } + } + + if totalSize > 0 && start >= totalSize { + return nil, ErrRangeNotSatisfiable + } + + return &ByteRange{ + Start: start, + End: end, + Length: end - start + 1, + }, nil +} + +// StreamTrack handles streaming audio for a track ID with HTTP Range support. +func (s *StreamService) StreamTrack(w http.ResponseWriter, r *http.Request, trackID string) { + ctx := r.Context() + + track, err := s.repo.GetByID(ctx, trackID) + if err != nil { + http.Error(w, "Failed to retrieve track", http.StatusInternalServerError) + return + } + if track == nil { + http.Error(w, "Track not found", http.StatusNotFound) + return + } + + // 1. Determine total size + totalSize := track.Audio.FileSize + if totalSize <= 0 { + totalSize = track.Telegram.FileSize + } + if totalSize <= 0 { + totalSize = 10 * 1024 * 1024 + } + + // 2. Determine mime type and normalize vendor mime types + audioType := strings.ToLower(strings.TrimSpace(track.Audio.Type)) + mimeType := track.Audio.MimeType + if mimeType == "" { + mimeType = track.Telegram.MimeType + } + switch { + case audioType == "flac" || mimeType == "audio/x-flac" || mimeType == "audio/flac": + mimeType = "audio/flac" + case audioType == "wav" || mimeType == "audio/x-wav" || mimeType == "audio/wav": + mimeType = "audio/wav" + case audioType == "mp3" || audioType == "mpeg" || mimeType == "audio/mp3": + mimeType = "audio/mpeg" + case audioType == "alac" || audioType == "m4a" || audioType == "aac" || mimeType == "audio/x-m4a" || mimeType == "audio/mp4": + mimeType = "audio/mp4" + case audioType == "ogg" || audioType == "opus": + mimeType = "audio/ogg" + } + if mimeType == "" { + mimeType = "audio/mpeg" + } + + // 3. Clean filename for Content-Disposition with proper audio extension + cleanBase := "" + if track.Audio.Title != "" { + if track.Audio.Artist != "" { + cleanBase = fmt.Sprintf("%s - %s", track.Audio.Artist, track.Audio.Title) + } else { + cleanBase = track.Audio.Title + } + } else if track.Telegram.FileName != "" { + cleanBase = strings.TrimSuffix(track.Telegram.FileName, filepath.Ext(track.Telegram.FileName)) + } else { + cleanBase = track.ID + } + + ext := audioType + if ext == "alac" { + ext = "m4a" + } + if ext == "" { + switch mimeType { + case "audio/flac": + ext = "flac" + case "audio/wav": + ext = "wav" + case "audio/mp4": + ext = "m4a" + case "audio/ogg": + ext = "ogg" + default: + ext = "mp3" + } + } + filename := fmt.Sprintf("%s.%s", cleanBase, ext) + + serveCachedFLAC := func(filePath string) bool { + info, statErr := os.Stat(filePath) + if statErr != nil || info.Size() <= 10240 { + return false + } + f, openErr := os.Open(filePath) + if openErr != nil { + return false + } + defer f.Close() + + if s.alacService != nil { + s.alacService.TouchFile(filePath) + } + + cleanBase := track.Audio.Title + if track.Audio.Artist != "" { + cleanBase = fmt.Sprintf("%s - %s", track.Audio.Artist, track.Audio.Title) + } + if cleanBase == "" { + cleanBase = track.Telegram.FileName + } + if cleanBase == "" { + cleanBase = track.ID + } + cleanBase = strings.TrimSuffix(cleanBase, filepath.Ext(cleanBase)) + flacFilename := fmt.Sprintf("%s.flac", cleanBase) + + fallback := strings.ReplaceAll(flacFilename, `"`, `_`) + encoded := url.QueryEscape(flacFilename) + w.Header().Set("Content-Type", "audio/flac") + w.Header().Set("Accept-Ranges", "bytes") + if w.Header().Get("Content-Disposition") == "" { + w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"; filename*=UTF-8''%s`, fallback, encoded)) + } + + go func(tid string) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = s.repo.IncrementPlayCount(timeoutCtx, tid) + }(track.ID) + + http.ServeContent(w, r, flacFilename, info.ModTime(), f) + return true + } + + // 3. If track is ALAC (or FLAC requested) and needs decoding, serve consistent WAV stream + if s.alacService != nil && s.alacService.ShouldDecodeALAC(r, track) { + cacheFile := filepath.Join(s.mediaDir, "alac_cache", fmt.Sprintf("%s.flac", track.ID)) + + // Kick off background FLAC cache if not already running + go s.alacService.EnsureDecodedFLAC(context.Background(), track) + + // Serve on-the-fly WAV stream with full seek support (reads from local cacheFile if ready, else on-the-fly) + wavStreamer := NewALACWAVStreamer(track, s.alacService.cfg.Port, cacheFile) + defer wavStreamer.Close() + + // Increment play count asynchronously + go func(tid string) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = s.repo.IncrementPlayCount(timeoutCtx, tid) + }(track.ID) + + cleanBase := track.Audio.Title + if track.Audio.Artist != "" { + cleanBase = fmt.Sprintf("%s - %s", track.Audio.Artist, track.Audio.Title) + } + if cleanBase == "" { + cleanBase = track.Telegram.FileName + } + if cleanBase == "" { + cleanBase = track.ID + } + cleanBase = strings.TrimSuffix(cleanBase, filepath.Ext(cleanBase)) + wavFilename := fmt.Sprintf("%s.wav", cleanBase) + + fallback := strings.ReplaceAll(wavFilename, `"`, `_`) + encoded := url.QueryEscape(wavFilename) + + w.Header().Set("Content-Type", "audio/wav") + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"; filename*=UTF-8''%s`, fallback, encoded)) + + // http.ServeContent handles all Range parsing and chunking automatically! + http.ServeContent(w, r, wavFilename, time.Now(), wavStreamer) + return + } + + // For non-decoded tracks, do not hijack with cached FLAC if raw format was explicitly requested + rawParam := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format"))) + if rawParam != "raw" && rawParam != "alac" && rawParam != "original" && rawParam != "source" { + cacheFile := filepath.Join(s.mediaDir, "alac_cache", fmt.Sprintf("%s.flac", track.ID)) + if serveCachedFLAC(cacheFile) { + return + } + } + + // 4. Handle Range Header + rangeHeader := r.Header.Get("Range") + var byteRange *ByteRange + if rangeHeader != "" { + var err error + byteRange, err = ParseRange(rangeHeader, totalSize) + if errors.Is(err, ErrRangeNotSatisfiable) { + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", totalSize)) + http.Error(w, "Requested Range Not Satisfiable", http.StatusRequestedRangeNotSatisfiable) + return + } + // If err != nil (malformed syntax), RFC 7233 says ignore Range and serve full content (byteRange == nil) + } + + // 5. Check Telegram client connectivity BEFORE writing any response headers. + // If headers (200/206 + Content-Length) are committed and then no bytes follow, + // the browser's