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
-
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.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+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 |
-| --- | --- | --- |
-|  |  |  |
-
-| 4 | 5 | 6 |
-| --- | --- | --- |
-|  |  |  |
-
-| 7 | 8 | 9 |
-| --- | --- | --- |
-|  |  |  |
-
-| 10 | 11 | 12 |
-| --- | --- | --- |
-|  |  |  |
-
-| 13 | 14 | |
-| --- | --- | --- |
-|  |  | |
-
-## 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
+
+
+
+
+
+
+`,
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`,
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+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