Initial import: Music_Server, MusicFree, catalog-sync
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from contextlib import asynccontextmanager
|
||||
import threading
|
||||
|
||||
from fastapi import FastAPI
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from .routes.admin_cache import api_router as admin_cache_api_router
|
||||
from .routes.admin_cache import router as admin_cache_router
|
||||
from .routes.admin_session import router as admin_session_router
|
||||
from .routes.app_update import router as app_update_router
|
||||
from .routes.auth import router as auth_router
|
||||
from .routes.covers import router as covers_router
|
||||
from .routes.health import router as health_router
|
||||
from .routes.mf_catalog import router as mf_catalog_router
|
||||
from .routes.mf_media import router as mf_media_router
|
||||
from .routes.mf_media import stream_router as mf_media_stream_router
|
||||
from .routes.player import router as player_router
|
||||
from .routes.plugins import router as plugins_router
|
||||
from .services.cache_service import CacheService
|
||||
from .settings import get_settings
|
||||
|
||||
|
||||
def _cache_worker(stop_event: threading.Event) -> None:
|
||||
settings = get_settings()
|
||||
if not settings.cache_relay_enabled:
|
||||
return
|
||||
while not stop_event.wait(settings.cache_reconcile_interval_seconds):
|
||||
try:
|
||||
service = CacheService(
|
||||
player_db_path=settings.player_db_path,
|
||||
catalog_db_path=settings.catalog_db_path,
|
||||
secret_encryption_key=settings.secret_encryption_key,
|
||||
local_library_root=settings.local_library_root,
|
||||
cache_relay_enabled=settings.cache_relay_enabled,
|
||||
)
|
||||
service.reconcile_cache_assignments()
|
||||
service.process_transfer_tasks()
|
||||
except Exception:
|
||||
# Keep the service available even if background cache maintenance fails.
|
||||
continue
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
stop_event = threading.Event()
|
||||
worker = threading.Thread(target=_cache_worker, args=(stop_event,), daemon=True)
|
||||
if settings.cache_relay_enabled and settings.cache_reconcile_interval_seconds > 0:
|
||||
worker.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
stop_event.set()
|
||||
if worker.is_alive():
|
||||
worker.join(timeout=1)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
app = FastAPI(title="Public Music Service", lifespan=_lifespan)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.secret_encryption_key,
|
||||
same_site="lax",
|
||||
https_only=False,
|
||||
)
|
||||
app.include_router(admin_session_router)
|
||||
app.include_router(admin_cache_router)
|
||||
app.include_router(admin_cache_api_router)
|
||||
app.include_router(health_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(app_update_router)
|
||||
app.include_router(mf_catalog_router)
|
||||
app.include_router(mf_media_router)
|
||||
app.include_router(mf_media_stream_router)
|
||||
app.include_router(covers_router)
|
||||
app.include_router(player_router)
|
||||
app.include_router(plugins_router)
|
||||
return app
|
||||
@@ -0,0 +1,66 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Header, HTTPException
|
||||
|
||||
from .settings import get_settings
|
||||
from .services.token_service import TokenService
|
||||
|
||||
|
||||
def parse_bearer_token(authorization: str | None) -> str:
|
||||
if authorization is None:
|
||||
raise HTTPException(status_code=401, detail="authorization_missing")
|
||||
|
||||
parts = authorization.strip().split(None, 1)
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise HTTPException(status_code=401, detail="authorization_invalid")
|
||||
|
||||
token = parts[1].strip()
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="authorization_invalid")
|
||||
return token
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthenticatedClientContext:
|
||||
token_id: str
|
||||
client_id: str | None
|
||||
client_label: str | None
|
||||
|
||||
|
||||
def require_authenticated_client(
|
||||
authorization: str | None = Header(default=None),
|
||||
x_music_client_id: str | None = Header(default=None, alias="X-Music-Client-Id"),
|
||||
x_music_client_label: str | None = Header(default=None, alias="X-Music-Client-Label"),
|
||||
) -> AuthenticatedClientContext:
|
||||
if get_settings().disable_auth:
|
||||
return AuthenticatedClientContext(
|
||||
token_id="auth_disabled",
|
||||
client_id=x_music_client_id,
|
||||
client_label=x_music_client_label,
|
||||
)
|
||||
|
||||
token = parse_bearer_token(authorization)
|
||||
auth_result = TokenService(get_settings().player_db_path).authenticate(
|
||||
plaintext_token=token,
|
||||
client_id=x_music_client_id,
|
||||
client_label=x_music_client_label,
|
||||
)
|
||||
if not auth_result.valid:
|
||||
raise HTTPException(status_code=401, detail=auth_result.error_code or "unauthorized")
|
||||
return AuthenticatedClientContext(
|
||||
token_id=auth_result.token_id or "",
|
||||
client_id=x_music_client_id,
|
||||
client_label=x_music_client_label,
|
||||
)
|
||||
|
||||
|
||||
def require_bearer_token(
|
||||
authorization: str | None = Header(default=None),
|
||||
x_music_client_id: str | None = Header(default=None, alias="X-Music-Client-Id"),
|
||||
x_music_client_label: str | None = Header(default=None, alias="X-Music-Client-Label"),
|
||||
) -> None:
|
||||
require_authenticated_client(
|
||||
authorization=authorization,
|
||||
x_music_client_id=x_music_client_id,
|
||||
x_music_client_label=x_music_client_label,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
import sqlite3
|
||||
|
||||
|
||||
def connect_sqlite(db_path: str) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,749 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from html import escape
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from ..services.cache_service import CacheService
|
||||
from ..settings import get_settings
|
||||
|
||||
router = APIRouter(prefix="/admin")
|
||||
api_router = APIRouter(prefix="/admin/api/cache")
|
||||
|
||||
|
||||
def _cache_service() -> CacheService:
|
||||
settings = get_settings()
|
||||
return CacheService(
|
||||
player_db_path=settings.player_db_path,
|
||||
catalog_db_path=settings.catalog_db_path,
|
||||
secret_encryption_key=settings.secret_encryption_key,
|
||||
local_library_root=settings.local_library_root,
|
||||
cache_relay_enabled=settings.cache_relay_enabled,
|
||||
)
|
||||
|
||||
|
||||
def _mask_target(target: dict) -> dict:
|
||||
secrets = target.pop("secrets", {}) or {}
|
||||
target["enabled"] = bool(target.get("enabled"))
|
||||
target["has_secrets"] = bool(secrets)
|
||||
target["secret_fields"] = sorted(secrets.keys())
|
||||
return target
|
||||
|
||||
|
||||
def require_admin_session(request: Request) -> None:
|
||||
if not request.session.get("admin_authenticated"):
|
||||
raise HTTPException(status_code=401, detail="admin_auth_required")
|
||||
|
||||
|
||||
def _login_page(error_message: str | None = None) -> str:
|
||||
error_html = ""
|
||||
if error_message:
|
||||
error_html = f"<p style='color:#b42318'>{escape(error_message)}</p>"
|
||||
return f"""
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Music Server Cache Admin</title>
|
||||
<style>
|
||||
body {{ font-family: 'Segoe UI', sans-serif; margin: 40px; background: #f6f7fb; color: #182230; }}
|
||||
.card {{ max-width: 420px; background: white; padding: 24px; border-radius: 16px; box-shadow: 0 10px 30px rgba(16,24,40,0.08); }}
|
||||
label {{ display: block; margin-top: 12px; font-size: 14px; }}
|
||||
input {{ width: 100%; padding: 10px 12px; margin-top: 6px; box-sizing: border-box; }}
|
||||
button {{ margin-top: 16px; padding: 10px 16px; border: none; border-radius: 10px; background: #111827; color: white; cursor: pointer; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Cache Admin</h1>
|
||||
<p>登录后可管理缓存目标、查看热榜并手动重排。</p>
|
||||
{error_html}
|
||||
<form action="/admin/session/login" method="post">
|
||||
<label>Username<input type="text" name="username" autocomplete="username" /></label>
|
||||
<label>Password<input type="password" name="password" autocomplete="current-password" /></label>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def _dashboard_page() -> str:
|
||||
return """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Music Server Cache Admin</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', sans-serif; margin: 24px; background: #f5f7fb; color: #182230; }
|
||||
header, section { background: white; border-radius: 16px; padding: 20px; margin-bottom: 16px; box-shadow: 0 10px 30px rgba(16,24,40,0.06); }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||||
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #eaecf0; font-size: 14px; }
|
||||
.actions { display: flex; gap: 12px; margin-top: 12px; flex-wrap: wrap; }
|
||||
.panel-grid { display: grid; grid-template-columns: minmax(360px, 420px) minmax(0, 1fr); gap: 16px; align-items: start; }
|
||||
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.field-full { grid-column: 1 / -1; }
|
||||
label { display: block; font-size: 13px; font-weight: 600; color: #344054; }
|
||||
input, select, textarea { width: 100%; box-sizing: border-box; margin-top: 6px; padding: 10px 12px; border: 1px solid #d0d5dd; border-radius: 10px; background: #fff; color: #101828; }
|
||||
textarea { min-height: 120px; resize: vertical; font-family: Consolas, 'Courier New', monospace; font-size: 12px; }
|
||||
button, .small-button { padding: 10px 14px; border: none; border-radius: 10px; background: #0f172a; color: white; cursor: pointer; }
|
||||
button.secondary, .small-button.secondary { background: #475467; }
|
||||
button.danger, .small-button.danger { background: #b42318; }
|
||||
form.inline { display: inline; }
|
||||
pre { white-space: pre-wrap; word-break: break-word; }
|
||||
.table-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.status { margin-top: 12px; min-height: 20px; font-size: 13px; color: #475467; }
|
||||
.status.error { color: #b42318; }
|
||||
.status.success { color: #027a48; }
|
||||
.hint { margin-top: 6px; font-size: 12px; color: #667085; white-space: pre-wrap; word-break: break-word; }
|
||||
.break-all { word-break: break-all; }
|
||||
.kind-fields { display: none; grid-template-columns: 1fr 1fr; gap: 12px; padding: 14px; border: 1px solid #eaecf0; border-radius: 14px; background: #f8fafc; }
|
||||
.kind-fields.active { display: grid; }
|
||||
.kind-title { grid-column: 1 / -1; margin: 0; font-size: 14px; color: #101828; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Cache Targets</h1>
|
||||
<div class="actions">
|
||||
<button id="reconcile-button" type="button">Run Reconcile</button>
|
||||
<form class="inline" action="/admin/session/logout" method="post">
|
||||
<button type="submit">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
<pre id="overview">Loading...</pre>
|
||||
</header>
|
||||
<section>
|
||||
<div class="panel-grid">
|
||||
<div>
|
||||
<h2 id="target-form-title">Create Target</h2>
|
||||
<form id="target-form">
|
||||
<input id="target-id" type="hidden" />
|
||||
<input id="target-original-kind" type="hidden" />
|
||||
<input id="target-has-secrets" type="hidden" value="0" />
|
||||
<div class="field-grid">
|
||||
<label>
|
||||
Name
|
||||
<input id="target-name" type="text" placeholder="vps-a" />
|
||||
</label>
|
||||
<label>
|
||||
Type
|
||||
<select id="target-kind">
|
||||
<option value="sftp">sftp</option>
|
||||
<option value="s3">s3</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Queue Order
|
||||
<input id="target-order-index" type="number" min="1" step="1" value="1" />
|
||||
</label>
|
||||
<label>
|
||||
Song Capacity
|
||||
<input id="target-capacity-songs" type="number" min="1" step="1" value="500" />
|
||||
</label>
|
||||
<label class="field-full">
|
||||
Public Base URL
|
||||
<input id="target-public-base-url" type="text" placeholder="https://cache.example.com" />
|
||||
</label>
|
||||
<label class="field-full">
|
||||
Path Prefix
|
||||
<input id="target-path-prefix" type="text" placeholder="music-cache" />
|
||||
</label>
|
||||
<label class="field-full">
|
||||
<input id="target-enabled" type="checkbox" checked style="width:auto;margin-right:8px;" />
|
||||
Enabled
|
||||
</label>
|
||||
<div id="credentials-hint" class="hint field-full">
|
||||
New target: fill the full credential set. Existing target: leave credential fields empty to keep saved credentials, or fill the full credential set to replace them.
|
||||
</div>
|
||||
<div id="sftp-fields" class="kind-fields field-full">
|
||||
<h3 class="kind-title">SFTP Connection</h3>
|
||||
<label>
|
||||
Host
|
||||
<input id="sftp-host" type="text" placeholder="1.2.3.4" />
|
||||
</label>
|
||||
<label>
|
||||
Port
|
||||
<input id="sftp-port" type="number" min="1" step="1" placeholder="22" />
|
||||
</label>
|
||||
<label class="field-full">
|
||||
Remote Root Directory
|
||||
<input id="sftp-remote-root" type="text" placeholder="/srv/music_server_cache" />
|
||||
</label>
|
||||
<label>
|
||||
Username
|
||||
<input id="sftp-username" type="text" placeholder="root" />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input id="sftp-password" type="password" placeholder="Password" />
|
||||
</label>
|
||||
<label>
|
||||
Timeout Seconds
|
||||
<input id="sftp-timeout-seconds" type="number" min="1" step="1" placeholder="10" />
|
||||
</label>
|
||||
<label class="field-full">
|
||||
Private Key
|
||||
<textarea id="sftp-private-key" placeholder="Optional private key content"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div id="s3-fields" class="kind-fields field-full">
|
||||
<h3 class="kind-title">S3 Connection</h3>
|
||||
<label>
|
||||
Bucket
|
||||
<input id="s3-bucket" type="text" placeholder="music-cache" />
|
||||
</label>
|
||||
<label>
|
||||
Region
|
||||
<input id="s3-region" type="text" placeholder="ap-shanghai" />
|
||||
</label>
|
||||
<label class="field-full">
|
||||
Endpoint URL
|
||||
<input id="s3-endpoint-url" type="text" placeholder="https://s3.example.com" />
|
||||
</label>
|
||||
<label>
|
||||
Access Key ID
|
||||
<input id="s3-access-key-id" type="text" placeholder="AKIA..." />
|
||||
</label>
|
||||
<label>
|
||||
Secret Access Key
|
||||
<input id="s3-secret-access-key" type="password" placeholder="Secret Access Key" />
|
||||
</label>
|
||||
<label class="field-full">
|
||||
Session Token
|
||||
<input id="s3-session-token" type="password" placeholder="Optional session token" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="target-submit-button" type="submit">Save Target</button>
|
||||
<button id="target-test-button" class="secondary" type="button">Test Connection</button>
|
||||
<button id="target-reset-button" class="secondary" type="button">Reset</button>
|
||||
</div>
|
||||
<div id="target-form-status" class="status"></div>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Targets</h2>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Kind</th><th>Queue</th><th>Capacity</th><th>Enabled</th><th>Occupied</th><th>Secrets</th><th>Actions</th></tr></thead>
|
||||
<tbody id="targets-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Hot Songs</h2>
|
||||
<table>
|
||||
<thead><tr><th>Song ID</th><th>Song Name</th><th>External URL</th><th>30d</th><th>Total</th><th>Last Played</th></tr></thead>
|
||||
<tbody id="hot-songs-body"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
<script>
|
||||
let targetItems = [];
|
||||
|
||||
function setStatus(message, type) {
|
||||
const node = document.getElementById('target-form-status');
|
||||
node.textContent = message || '';
|
||||
node.className = `status ${type || ''}`.trim();
|
||||
}
|
||||
|
||||
async function readErrorDetail(response, fallbackMessage) {
|
||||
try {
|
||||
const payload = await response.json();
|
||||
return payload.detail || JSON.stringify(payload);
|
||||
} catch (_) {
|
||||
return fallbackMessage;
|
||||
}
|
||||
}
|
||||
|
||||
function readTextValue(id) {
|
||||
return document.getElementById(id).value.trim();
|
||||
}
|
||||
|
||||
function clearCredentialInputs() {
|
||||
[
|
||||
'sftp-host',
|
||||
'sftp-port',
|
||||
'sftp-remote-root',
|
||||
'sftp-username',
|
||||
'sftp-password',
|
||||
'sftp-timeout-seconds',
|
||||
'sftp-private-key',
|
||||
's3-bucket',
|
||||
's3-region',
|
||||
's3-endpoint-url',
|
||||
's3-access-key-id',
|
||||
's3-secret-access-key',
|
||||
's3-session-token'
|
||||
].forEach((id) => {
|
||||
document.getElementById(id).value = '';
|
||||
});
|
||||
}
|
||||
|
||||
function currentTargetState() {
|
||||
return {
|
||||
targetId: readTextValue('target-id'),
|
||||
originalKind: readTextValue('target-original-kind'),
|
||||
hasSavedSecrets: readTextValue('target-has-secrets') === '1'
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRequireReplacementSecrets(state, currentKind) {
|
||||
if (!state.targetId) {
|
||||
return true;
|
||||
}
|
||||
if (!state.hasSavedSecrets) {
|
||||
return true;
|
||||
}
|
||||
if (state.originalKind && state.originalKind !== currentKind) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(rawValue, label) {
|
||||
if (!rawValue) {
|
||||
throw new Error(`${label} is required.`);
|
||||
}
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new Error(`${label} must be a positive integer.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function buildSftpSecrets(requireSecrets) {
|
||||
const host = readTextValue('sftp-host');
|
||||
const portText = readTextValue('sftp-port');
|
||||
const remoteRoot = readTextValue('sftp-remote-root');
|
||||
const username = readTextValue('sftp-username');
|
||||
const password = readTextValue('sftp-password');
|
||||
const timeoutText = readTextValue('sftp-timeout-seconds');
|
||||
const privateKey = document.getElementById('sftp-private-key').value.trim();
|
||||
const anyProvided = Boolean(host || portText || remoteRoot || username || password || timeoutText || privateKey);
|
||||
|
||||
if (!requireSecrets && !anyProvided) {
|
||||
return { provided: false, secrets: null };
|
||||
}
|
||||
if (!host) {
|
||||
throw new Error('SFTP Host is required.');
|
||||
}
|
||||
if (!username) {
|
||||
throw new Error('SFTP Username is required.');
|
||||
}
|
||||
if (!password && !privateKey) {
|
||||
throw new Error('SFTP Password or Private Key is required.');
|
||||
}
|
||||
|
||||
const secrets = {
|
||||
host,
|
||||
port: portText ? parsePositiveInteger(portText, 'SFTP Port') : 22,
|
||||
username
|
||||
};
|
||||
if (remoteRoot) {
|
||||
secrets.remote_root = remoteRoot;
|
||||
}
|
||||
if (password) {
|
||||
secrets.password = password;
|
||||
}
|
||||
if (privateKey) {
|
||||
secrets.private_key = privateKey;
|
||||
}
|
||||
if (timeoutText) {
|
||||
secrets.timeout_seconds = parsePositiveInteger(timeoutText, 'SFTP Timeout Seconds');
|
||||
}
|
||||
return { provided: true, secrets };
|
||||
}
|
||||
|
||||
function buildS3Secrets(requireSecrets) {
|
||||
const bucket = readTextValue('s3-bucket');
|
||||
const region = readTextValue('s3-region');
|
||||
const endpointUrl = readTextValue('s3-endpoint-url');
|
||||
const accessKeyId = readTextValue('s3-access-key-id');
|
||||
const secretAccessKey = readTextValue('s3-secret-access-key');
|
||||
const sessionToken = readTextValue('s3-session-token');
|
||||
const anyProvided = Boolean(bucket || region || endpointUrl || accessKeyId || secretAccessKey || sessionToken);
|
||||
|
||||
if (!requireSecrets && !anyProvided) {
|
||||
return { provided: false, secrets: null };
|
||||
}
|
||||
if (!bucket) {
|
||||
throw new Error('S3 Bucket is required.');
|
||||
}
|
||||
if (!accessKeyId) {
|
||||
throw new Error('S3 Access Key ID is required.');
|
||||
}
|
||||
if (!secretAccessKey) {
|
||||
throw new Error('S3 Secret Access Key is required.');
|
||||
}
|
||||
|
||||
const secrets = {
|
||||
bucket,
|
||||
access_key_id: accessKeyId,
|
||||
secret_access_key: secretAccessKey
|
||||
};
|
||||
if (region) {
|
||||
secrets.region = region;
|
||||
}
|
||||
if (endpointUrl) {
|
||||
secrets.endpoint_url = endpointUrl;
|
||||
}
|
||||
if (sessionToken) {
|
||||
secrets.session_token = sessionToken;
|
||||
}
|
||||
return { provided: true, secrets };
|
||||
}
|
||||
|
||||
function buildConnectionSecrets(requireSecrets) {
|
||||
const kind = document.getElementById('target-kind').value;
|
||||
if (kind === 'sftp') {
|
||||
return buildSftpSecrets(requireSecrets);
|
||||
}
|
||||
return buildS3Secrets(requireSecrets);
|
||||
}
|
||||
|
||||
function updateCredentialSections() {
|
||||
const kind = document.getElementById('target-kind').value;
|
||||
document.getElementById('sftp-fields').classList.toggle('active', kind === 'sftp');
|
||||
document.getElementById('s3-fields').classList.toggle('active', kind === 's3');
|
||||
}
|
||||
|
||||
function resetTargetForm() {
|
||||
document.getElementById('target-form-title').textContent = 'Create Target';
|
||||
document.getElementById('target-id').value = '';
|
||||
document.getElementById('target-original-kind').value = '';
|
||||
document.getElementById('target-has-secrets').value = '0';
|
||||
document.getElementById('target-name').value = '';
|
||||
document.getElementById('target-kind').value = 'sftp';
|
||||
document.getElementById('target-order-index').value = '1';
|
||||
document.getElementById('target-capacity-songs').value = '500';
|
||||
document.getElementById('target-public-base-url').value = '';
|
||||
document.getElementById('target-path-prefix').value = '';
|
||||
document.getElementById('target-enabled').checked = true;
|
||||
document.getElementById('target-submit-button').textContent = 'Save Target';
|
||||
clearCredentialInputs();
|
||||
updateCredentialSections();
|
||||
setStatus('', '');
|
||||
}
|
||||
|
||||
function populateTargetForm(item) {
|
||||
document.getElementById('target-form-title').textContent = `Edit Target #${item.id}`;
|
||||
document.getElementById('target-id').value = String(item.id);
|
||||
document.getElementById('target-original-kind').value = item.kind || '';
|
||||
document.getElementById('target-has-secrets').value = item.has_secrets ? '1' : '0';
|
||||
document.getElementById('target-name').value = item.name || '';
|
||||
document.getElementById('target-kind').value = item.kind || 'sftp';
|
||||
document.getElementById('target-order-index').value = String(item.order_index || 1);
|
||||
document.getElementById('target-capacity-songs').value = String(item.capacity_songs || 500);
|
||||
document.getElementById('target-public-base-url').value = item.public_base_url || '';
|
||||
document.getElementById('target-path-prefix').value = item.path_prefix || '';
|
||||
document.getElementById('target-enabled').checked = Boolean(item.enabled);
|
||||
document.getElementById('target-submit-button').textContent = 'Update Target';
|
||||
clearCredentialInputs();
|
||||
updateCredentialSections();
|
||||
if (item.has_secrets) {
|
||||
setStatus('Leave credential fields empty to keep saved credentials, or fill the full credential set to replace them.', '');
|
||||
return;
|
||||
}
|
||||
setStatus('This target has no saved credentials yet. Fill the full credential set before testing or saving.', '');
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
const [overviewRes, targetsRes, hotSongsRes] = await Promise.all([
|
||||
fetch('/admin/api/cache/overview'),
|
||||
fetch('/admin/api/cache/targets'),
|
||||
fetch('/admin/api/cache/hot-songs')
|
||||
]);
|
||||
const overview = await overviewRes.json();
|
||||
const targets = await targetsRes.json();
|
||||
const hotSongs = await hotSongsRes.json();
|
||||
targetItems = targets.items || [];
|
||||
document.getElementById('overview').textContent = JSON.stringify(overview, null, 2);
|
||||
document.getElementById('targets-body').innerHTML = targetItems.map(item => `
|
||||
<tr>
|
||||
<td>${item.name}</td>
|
||||
<td>${item.kind}</td>
|
||||
<td>${item.order_index}</td>
|
||||
<td>${item.capacity_songs}</td>
|
||||
<td>${item.enabled}</td>
|
||||
<td>${item.occupied_song_count ?? 0}</td>
|
||||
<td>${item.secret_fields.join(', ')}</td>
|
||||
<td>
|
||||
<div class="table-actions">
|
||||
<button class="small-button secondary" type="button" data-action="edit" data-target-id="${item.id}">Edit</button>
|
||||
<button class="small-button secondary" type="button" data-action="test" data-target-id="${item.id}">Test</button>
|
||||
<button class="small-button danger" type="button" data-action="delete" data-target-id="${item.id}">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
document.getElementById('hot-songs-body').innerHTML = hotSongs.items.map(item => `
|
||||
<tr>
|
||||
<td>${item.song_id}</td>
|
||||
<td>${item.name || ''}</td>
|
||||
<td class="break-all">${item.external_url ? `<a href="${item.external_url}" target="_blank" rel="noreferrer">${item.external_url}</a>` : ''}</td>
|
||||
<td>${item.play_count_30d}</td>
|
||||
<td>${item.play_count_total}</td>
|
||||
<td>${item.last_played_at ?? ''}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function handleTargetSubmit(event) {
|
||||
event.preventDefault();
|
||||
const state = currentTargetState();
|
||||
const targetId = document.getElementById('target-id').value.trim();
|
||||
const payload = {
|
||||
name: document.getElementById('target-name').value.trim(),
|
||||
kind: document.getElementById('target-kind').value,
|
||||
order_index: Number(document.getElementById('target-order-index').value),
|
||||
capacity_songs: Number(document.getElementById('target-capacity-songs').value),
|
||||
public_base_url: document.getElementById('target-public-base-url').value.trim(),
|
||||
path_prefix: document.getElementById('target-path-prefix').value.trim(),
|
||||
enabled: document.getElementById('target-enabled').checked
|
||||
};
|
||||
|
||||
try {
|
||||
if (!payload.name) {
|
||||
throw new Error('Name is required.');
|
||||
}
|
||||
if (!payload.public_base_url) {
|
||||
throw new Error('Public Base URL is required.');
|
||||
}
|
||||
if (!Number.isFinite(payload.order_index) || payload.order_index < 1) {
|
||||
throw new Error('Queue Order must be a positive integer.');
|
||||
}
|
||||
if (!Number.isFinite(payload.capacity_songs) || payload.capacity_songs < 1) {
|
||||
throw new Error('Song Capacity must be a positive integer.');
|
||||
}
|
||||
const credentialState = buildConnectionSecrets(
|
||||
shouldRequireReplacementSecrets(state, payload.kind)
|
||||
);
|
||||
if (credentialState.provided) {
|
||||
payload.secrets = credentialState.secrets;
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus(error.message || 'Invalid target form.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
targetId ? `/admin/api/cache/targets/${targetId}` : '/admin/api/cache/targets',
|
||||
{
|
||||
method: targetId ? 'PATCH' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
setStatus(await readErrorDetail(response, 'Save failed.'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(targetId ? 'Target updated.' : 'Target created.', 'success');
|
||||
resetTargetForm();
|
||||
await loadOverview();
|
||||
}
|
||||
|
||||
async function handleTargetConnectionTest() {
|
||||
const state = currentTargetState();
|
||||
const kind = document.getElementById('target-kind').value;
|
||||
const requireReplacementSecrets = shouldRequireReplacementSecrets(state, kind);
|
||||
let response;
|
||||
|
||||
try {
|
||||
const credentialState = buildConnectionSecrets(requireReplacementSecrets);
|
||||
if (state.targetId && !credentialState.provided && !requireReplacementSecrets) {
|
||||
response = await fetch(`/admin/api/cache/targets/${state.targetId}/test`, { method: 'POST' });
|
||||
} else {
|
||||
response = await fetch('/admin/api/cache/targets/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
kind,
|
||||
secrets: credentialState.secrets
|
||||
})
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus(error.message || 'Invalid target form.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
setStatus(await readErrorDetail(response, 'Connection test failed.'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (payload.target_id) {
|
||||
setStatus(`Connection test ok for saved target #${payload.target_id}.`, 'success');
|
||||
return;
|
||||
}
|
||||
setStatus(`Connection test ok for current ${payload.kind} settings.`, 'success');
|
||||
}
|
||||
|
||||
async function handleTargetAction(event) {
|
||||
const button = event.target.closest('button[data-action]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
const action = button.dataset.action;
|
||||
const targetId = button.dataset.targetId;
|
||||
const item = targetItems.find(entry => String(entry.id) === String(targetId));
|
||||
if (!item) {
|
||||
setStatus('Target not found.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'edit') {
|
||||
populateTargetForm(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'test') {
|
||||
const response = await fetch(`/admin/api/cache/targets/${targetId}/test`, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
setStatus(await readErrorDetail(response, `Connection test failed for ${item.name}.`), 'error');
|
||||
return;
|
||||
}
|
||||
setStatus(`Connection test ok for ${item.name}.`, 'success');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
if (!window.confirm(`Delete target ${item.name}?`)) {
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`/admin/api/cache/targets/${targetId}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
setStatus(await readErrorDetail(response, `Delete failed for ${item.name}.`), 'error');
|
||||
return;
|
||||
}
|
||||
setStatus(`Target deleted: ${item.name}.`, 'success');
|
||||
resetTargetForm();
|
||||
await loadOverview();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('reconcile-button').addEventListener('click', async () => {
|
||||
await fetch('/admin/api/cache/reconcile', { method: 'POST' });
|
||||
setStatus('Reconcile requested.', 'success');
|
||||
await loadOverview();
|
||||
});
|
||||
document.getElementById('target-kind').addEventListener('change', updateCredentialSections);
|
||||
document.getElementById('target-form').addEventListener('submit', handleTargetSubmit);
|
||||
document.getElementById('target-test-button').addEventListener('click', handleTargetConnectionTest);
|
||||
document.getElementById('target-reset-button').addEventListener('click', resetTargetForm);
|
||||
document.getElementById('targets-body').addEventListener('click', handleTargetAction);
|
||||
resetTargetForm();
|
||||
loadOverview();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@router.get("/cache", response_class=HTMLResponse)
|
||||
def cache_dashboard(request: Request) -> HTMLResponse:
|
||||
if not request.session.get("admin_authenticated"):
|
||||
return HTMLResponse(_login_page())
|
||||
return HTMLResponse(_dashboard_page())
|
||||
|
||||
|
||||
@api_router.get("/overview", dependencies=[Depends(require_admin_session)])
|
||||
def overview() -> dict:
|
||||
service = _cache_service()
|
||||
payload = service.get_overview()
|
||||
payload["recent_runs"] = service.list_reconcile_runs(limit=5)
|
||||
return payload
|
||||
|
||||
|
||||
@api_router.get("/targets", dependencies=[Depends(require_admin_session)])
|
||||
def list_targets() -> dict:
|
||||
items = [_mask_target(item) for item in _cache_service().list_cache_targets(include_secrets=True)]
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@api_router.post("/targets/test", dependencies=[Depends(require_admin_session)])
|
||||
def test_target_payload(payload: dict) -> dict:
|
||||
kind = str(payload.get("kind") or "").strip()
|
||||
secrets = payload.get("secrets")
|
||||
if not kind:
|
||||
raise HTTPException(status_code=400, detail="kind is required")
|
||||
if not isinstance(secrets, dict) or not secrets:
|
||||
raise HTTPException(status_code=400, detail="secrets are required")
|
||||
return _cache_service().test_target_connection_payload(kind=kind, secrets=secrets)
|
||||
|
||||
|
||||
@api_router.post("/targets", dependencies=[Depends(require_admin_session)])
|
||||
def create_target(payload: dict) -> dict:
|
||||
required = ["name", "kind", "order_index", "capacity_songs", "public_base_url", "path_prefix"]
|
||||
for key in required:
|
||||
if key not in payload:
|
||||
raise HTTPException(status_code=400, detail=f"{key} is required")
|
||||
created = _cache_service().create_cache_target(
|
||||
name=str(payload["name"]),
|
||||
kind=str(payload["kind"]),
|
||||
order_index=int(payload["order_index"]),
|
||||
capacity_songs=int(payload["capacity_songs"]),
|
||||
public_base_url=str(payload["public_base_url"]),
|
||||
path_prefix=str(payload.get("path_prefix", "")),
|
||||
enabled=bool(payload.get("enabled", True)),
|
||||
secrets=payload.get("secrets") or {},
|
||||
)
|
||||
target = _cache_service().get_cache_target(target_id=int(created["id"]), include_secrets=True)
|
||||
return _mask_target(target)
|
||||
|
||||
|
||||
@api_router.patch("/targets/{target_id}", dependencies=[Depends(require_admin_session)])
|
||||
def update_target(target_id: int, payload: dict) -> dict:
|
||||
updated = _cache_service().update_cache_target(
|
||||
target_id=target_id,
|
||||
name=payload.get("name"),
|
||||
kind=payload.get("kind"),
|
||||
order_index=int(payload["order_index"]) if "order_index" in payload else None,
|
||||
capacity_songs=int(payload["capacity_songs"]) if "capacity_songs" in payload else None,
|
||||
public_base_url=payload.get("public_base_url"),
|
||||
path_prefix=payload.get("path_prefix"),
|
||||
enabled=bool(payload["enabled"]) if "enabled" in payload else None,
|
||||
secrets=payload.get("secrets"),
|
||||
)
|
||||
target = _cache_service().get_cache_target(target_id=int(updated["id"]), include_secrets=True)
|
||||
return _mask_target(target)
|
||||
|
||||
|
||||
@api_router.delete("/targets/{target_id}", dependencies=[Depends(require_admin_session)])
|
||||
def delete_target(target_id: int) -> Response:
|
||||
_cache_service().delete_cache_target(target_id=target_id)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@api_router.get("/hot-songs", dependencies=[Depends(require_admin_session)])
|
||||
def hot_songs() -> dict:
|
||||
return {"items": _cache_service().list_hot_songs(limit=100)}
|
||||
|
||||
|
||||
@api_router.get("/objects", dependencies=[Depends(require_admin_session)])
|
||||
def cache_objects() -> dict:
|
||||
return {"items": _cache_service().list_cache_objects()}
|
||||
|
||||
|
||||
@api_router.get("/tasks", dependencies=[Depends(require_admin_session)])
|
||||
def cache_tasks() -> dict:
|
||||
return {"items": _cache_service().list_transfer_tasks()}
|
||||
|
||||
|
||||
@api_router.post("/reconcile", dependencies=[Depends(require_admin_session)])
|
||||
def reconcile() -> dict:
|
||||
return _cache_service().reconcile_cache_assignments()
|
||||
|
||||
|
||||
@api_router.post("/targets/{target_id}/test", dependencies=[Depends(require_admin_session)])
|
||||
def test_target(target_id: int) -> dict:
|
||||
return _cache_service().test_target_connection(target_id=target_id)
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from ..services.admin_security import verify_password_hash
|
||||
from ..settings import get_settings
|
||||
|
||||
router = APIRouter(prefix="/admin/session")
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(request: Request):
|
||||
form = await request.form()
|
||||
username = str(form.get("username") or "").strip()
|
||||
password = str(form.get("password") or "")
|
||||
settings = get_settings()
|
||||
if username != settings.admin_username or not verify_password_hash(
|
||||
plaintext_password=password,
|
||||
stored_hash=settings.admin_password_hash,
|
||||
):
|
||||
raise HTTPException(status_code=401, detail="admin_login_failed")
|
||||
|
||||
request.session["admin_authenticated"] = True
|
||||
request.session["admin_username"] = username
|
||||
return RedirectResponse(url="/admin/cache", status_code=303)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(request: Request):
|
||||
request.session.clear()
|
||||
return RedirectResponse(url="/admin/cache", status_code=303)
|
||||
@@ -0,0 +1,62 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from ..settings import get_settings
|
||||
|
||||
router = APIRouter(prefix="/app")
|
||||
|
||||
_APK_MEDIA_TYPE = "application/vnd.android.package-archive"
|
||||
|
||||
|
||||
def _missing_file_error(path: Path, label: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=404,
|
||||
detail=f"{label} file not found: {path}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/version.json", name="musicfree_app_version_json")
|
||||
def musicfree_app_version_json(request: Request) -> JSONResponse:
|
||||
settings = get_settings()
|
||||
version_json_path = Path(settings.musicfree_version_json_path)
|
||||
apk_path = Path(settings.musicfree_apk_path)
|
||||
if not version_json_path.is_file():
|
||||
raise _missing_file_error(version_json_path, "MusicFree version")
|
||||
if not apk_path.is_file():
|
||||
raise _missing_file_error(apk_path, "MusicFree APK")
|
||||
|
||||
try:
|
||||
payload = json.loads(version_json_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Invalid MusicFree version JSON: {exc}",
|
||||
) from exc
|
||||
|
||||
payload["download"] = [str(request.url_for("musicfree_app_apk"))]
|
||||
return JSONResponse(
|
||||
content=payload,
|
||||
headers={
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/MusicFree_latest_release_universal.apk",
|
||||
name="musicfree_app_apk",
|
||||
)
|
||||
def musicfree_app_apk() -> FileResponse:
|
||||
apk_path = Path(get_settings().musicfree_apk_path)
|
||||
if not apk_path.is_file():
|
||||
raise _missing_file_error(apk_path, "MusicFree APK")
|
||||
return FileResponse(
|
||||
apk_path,
|
||||
media_type=_APK_MEDIA_TYPE,
|
||||
filename=apk_path.name,
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, Header
|
||||
|
||||
from ..auth import parse_bearer_token
|
||||
from ..services.catalog_reader import CatalogReader
|
||||
from ..services.token_service import TokenService
|
||||
from ..settings import get_settings
|
||||
|
||||
router = APIRouter(prefix="/auth/v1")
|
||||
|
||||
|
||||
@router.get("/token-status")
|
||||
def token_status(
|
||||
authorization: str | None = Header(default=None),
|
||||
x_music_client_id: str | None = Header(default=None, alias="X-Music-Client-Id"),
|
||||
x_music_client_label: str | None = Header(default=None, alias="X-Music-Client-Label"),
|
||||
) -> dict:
|
||||
settings = get_settings()
|
||||
if settings.disable_auth:
|
||||
payload = {
|
||||
"valid": True,
|
||||
"status": "active",
|
||||
"source": "auth_disabled",
|
||||
"expires_at": None,
|
||||
}
|
||||
else:
|
||||
token = parse_bearer_token(authorization)
|
||||
payload = TokenService(settings.player_db_path).status(
|
||||
plaintext_token=token,
|
||||
client_id=x_music_client_id,
|
||||
client_label=x_music_client_label,
|
||||
)
|
||||
if payload["status"] == "active":
|
||||
try:
|
||||
payload["playableSongCount"] = CatalogReader(settings.catalog_db_path).count_playable_tracks()
|
||||
except sqlite3.Error:
|
||||
payload["playableSongCount"] = None
|
||||
return payload
|
||||
@@ -0,0 +1,21 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from ..services.cover_service import CoverService
|
||||
from ..settings import get_settings
|
||||
|
||||
router = APIRouter(prefix="/mf/v1/covers")
|
||||
|
||||
|
||||
def _service() -> CoverService:
|
||||
return CoverService(db_path=get_settings().catalog_db_path)
|
||||
|
||||
|
||||
@router.get("/playlists/{playlist_id}")
|
||||
def playlist_cover(playlist_id: int) -> RedirectResponse:
|
||||
return RedirectResponse(url=_service().playlist_cover_url(playlist_id), status_code=307)
|
||||
|
||||
|
||||
@router.get("/songs/{song_id}")
|
||||
def song_cover(song_id: int) -> RedirectResponse:
|
||||
return RedirectResponse(url=_service().song_cover_url(song_id), status_code=307)
|
||||
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/healthz")
|
||||
def healthz() -> dict:
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,291 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from ..auth import require_bearer_token
|
||||
from ..services.catalog_reader import CatalogReader
|
||||
from ..settings import get_settings
|
||||
|
||||
router = APIRouter(prefix="/mf/v1", dependencies=[Depends(require_bearer_token)])
|
||||
|
||||
|
||||
def _reader() -> CatalogReader:
|
||||
return CatalogReader(db_path=get_settings().catalog_db_path)
|
||||
|
||||
|
||||
def _read_raw_lrc(local_locator: str | None) -> str | None:
|
||||
library_root = get_settings().local_library_root
|
||||
if not library_root or not local_locator:
|
||||
return None
|
||||
|
||||
root_path = Path(library_root).resolve()
|
||||
audio_path = (root_path / local_locator).resolve()
|
||||
try:
|
||||
audio_path.relative_to(root_path)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
lyrics_path = audio_path.with_suffix(".lrc")
|
||||
try:
|
||||
lyrics_path.relative_to(root_path)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if not lyrics_path.is_file():
|
||||
return None
|
||||
|
||||
for encoding in ("utf-8-sig", "utf-8", "gb18030"):
|
||||
try:
|
||||
return lyrics_path.read_text(encoding=encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
try:
|
||||
return lyrics_path.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _to_sheet_item(row: dict) -> dict:
|
||||
if "playlist_id" in row:
|
||||
item_type = "playlist"
|
||||
item_id = row["playlist_id"]
|
||||
elif row.get("item_type") == "playlist":
|
||||
item_type = "playlist"
|
||||
item_id = row["item_id"]
|
||||
else:
|
||||
item_type = "toplist"
|
||||
item_id = row.get("toplist_id") or row["item_id"]
|
||||
return {
|
||||
"id": f"catalogsync:{item_type}:{item_id}",
|
||||
"platform": "catalogsync",
|
||||
"title": row["name"],
|
||||
"coverImg": row["cover_url"] or "",
|
||||
"description": row["description"] or "",
|
||||
"worksNum": row["song_count"],
|
||||
"playableSongCount": row.get("playable_song_count", 0),
|
||||
"play_count": row["play_count"],
|
||||
}
|
||||
|
||||
|
||||
def _to_music_item(row: dict) -> dict:
|
||||
duration_ms = int(row.get("duration_ms") or 0)
|
||||
item = {
|
||||
"id": f"catalogsync:song:{row['song_id']}",
|
||||
"platform": "catalogsync",
|
||||
"title": row["name"],
|
||||
"artist": row.get("singers") or "",
|
||||
"album": row.get("album") or "",
|
||||
"artwork": row.get("cover_url") or "",
|
||||
"duration": duration_ms // 1000,
|
||||
}
|
||||
raw_lrc = _read_raw_lrc(row.get("local_locator"))
|
||||
if raw_lrc:
|
||||
item["rawLrc"] = raw_lrc
|
||||
return item
|
||||
|
||||
|
||||
def _to_artist_item(row: dict) -> dict:
|
||||
return {
|
||||
"id": f"catalogsync:artist:{row['artist_id']}",
|
||||
"platform": row["platform"],
|
||||
"name": row["name"],
|
||||
"avatar": row.get("avatar_url") or "",
|
||||
"description": row.get("description") or "",
|
||||
"worksNum": row.get("playable_song_count", 0),
|
||||
"supportedArtistTabs": ["music"],
|
||||
}
|
||||
|
||||
|
||||
def _playlist_platform_filter_from_tag(tag: str) -> str | None:
|
||||
normalized_tag = str(tag or "").strip().lower()
|
||||
if normalized_tag in {"", "all", "playlist_square"}:
|
||||
return None
|
||||
return normalized_tag
|
||||
|
||||
|
||||
@router.get("/recommend/tags")
|
||||
def recommend_tags() -> dict:
|
||||
return {
|
||||
"pinned": [
|
||||
{"id": "all", "title": "all"},
|
||||
{"id": "netease", "title": "netease"},
|
||||
{"id": "qq", "title": "qq"},
|
||||
{"id": "kuwo", "title": "kuwo"},
|
||||
],
|
||||
"data": [
|
||||
{
|
||||
"title": "source",
|
||||
"data": [
|
||||
{"id": "playlist_square", "title": "playlist square"},
|
||||
{"id": "toplist", "title": "toplist"},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/recommend/sheets")
|
||||
def recommend_sheets(
|
||||
tag: str = Query(default="all"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=60, ge=1, le=200),
|
||||
) -> dict:
|
||||
normalized_tag = str(tag or "").strip().lower()
|
||||
reader = _reader()
|
||||
if normalized_tag == "toplist":
|
||||
toplist_groups = reader.list_toplists()
|
||||
all_toplist_rows = [row for group in toplist_groups for row in group["data"]]
|
||||
offset = (page - 1) * page_size
|
||||
rows = all_toplist_rows[offset : offset + page_size]
|
||||
return {
|
||||
"isEnd": offset + len(rows) >= len(all_toplist_rows),
|
||||
"data": [_to_sheet_item(row) for row in rows],
|
||||
}
|
||||
|
||||
rows = reader.list_playlists(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
platform=_playlist_platform_filter_from_tag(tag),
|
||||
)
|
||||
is_end = len(rows) < page_size
|
||||
return {
|
||||
"isEnd": is_end,
|
||||
"data": [_to_sheet_item(row) for row in rows],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/search/songs")
|
||||
def search_songs(
|
||||
q: str = Query(default=""),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=200),
|
||||
) -> dict:
|
||||
rows = _reader().search_tracks(query=q, page=page, page_size=page_size)
|
||||
return {"isEnd": len(rows) < page_size, "data": [_to_music_item(row) for row in rows]}
|
||||
|
||||
|
||||
@router.get("/songs/{song_id}/lyric")
|
||||
def get_song_lyric(song_id: int) -> dict:
|
||||
row = _reader().get_song(song_id=song_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="song not found")
|
||||
|
||||
raw_lrc = _read_raw_lrc(row.get("local_locator"))
|
||||
if not raw_lrc:
|
||||
raise HTTPException(status_code=404, detail="lyric not found")
|
||||
|
||||
return {"rawLrc": raw_lrc, "lyric": raw_lrc}
|
||||
|
||||
|
||||
@router.get("/search/artists")
|
||||
def search_artists(
|
||||
q: str = Query(default=""),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=200),
|
||||
) -> dict:
|
||||
rows = _reader().search_artists(query=q, page=page, page_size=page_size)
|
||||
return {"isEnd": len(rows) < page_size, "data": [_to_artist_item(row) for row in rows]}
|
||||
|
||||
|
||||
@router.get("/artists/{artist_id}")
|
||||
def get_artist(artist_id: int) -> dict:
|
||||
row = _reader().get_artist(artist_id=artist_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="artist not found")
|
||||
return _to_artist_item(row)
|
||||
|
||||
|
||||
@router.get("/artists/{artist_id}/tracks")
|
||||
def list_artist_tracks(
|
||||
artist_id: int,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=60, ge=1, le=200),
|
||||
) -> dict:
|
||||
rows = _reader().list_artist_tracks(artist_id=artist_id, page=page, page_size=page_size)
|
||||
return {"isEnd": len(rows) < page_size, "musicList": [_to_music_item(row) for row in rows]}
|
||||
|
||||
|
||||
@router.get("/search/sheets")
|
||||
def search_sheets(
|
||||
q: str = Query(default=""),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=200),
|
||||
) -> dict:
|
||||
rows = _reader().search_sheets(query=q, page=page, page_size=page_size)
|
||||
return {"isEnd": len(rows) < page_size, "data": [_to_sheet_item(row) for row in rows]}
|
||||
|
||||
|
||||
@router.get("/playlists/{playlist_id}")
|
||||
def get_playlist(playlist_id: int) -> dict:
|
||||
row = _reader().get_playlist(playlist_id=playlist_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="playlist not found")
|
||||
return _to_sheet_item(row)
|
||||
|
||||
|
||||
@router.get("/playlists/{playlist_id}/tracks")
|
||||
def list_playlist_tracks(
|
||||
playlist_id: int,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=60, ge=1, le=200),
|
||||
) -> dict:
|
||||
rows = _reader().list_playlist_tracks(
|
||||
playlist_id=playlist_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return {
|
||||
"isEnd": len(rows) < page_size,
|
||||
"musicList": [_to_music_item(row) for row in rows],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/toplists")
|
||||
def list_toplists() -> list[dict]:
|
||||
groups = _reader().list_toplists()
|
||||
return [
|
||||
{"title": group["title"], "data": [_to_sheet_item(row) for row in group["data"]]}
|
||||
for group in groups
|
||||
]
|
||||
|
||||
|
||||
@router.get("/toplists/{toplist_id}")
|
||||
def get_toplist(toplist_id: str) -> dict:
|
||||
row = _reader().get_toplist(toplist_id=toplist_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="toplist not found")
|
||||
return _to_sheet_item(row)
|
||||
|
||||
|
||||
@router.get("/toplists/{toplist_id}/tracks")
|
||||
def list_toplist_tracks(
|
||||
toplist_id: str,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=60, ge=1, le=200),
|
||||
) -> dict:
|
||||
reader = _reader()
|
||||
toplist = reader.get_toplist(toplist_id=toplist_id)
|
||||
if toplist is None:
|
||||
raise HTTPException(status_code=404, detail="toplist not found")
|
||||
|
||||
rows = reader.list_toplist_tracks(
|
||||
toplist_id=toplist_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
if len(rows) < page_size:
|
||||
is_end = True
|
||||
else:
|
||||
next_rows = reader.list_toplist_tracks(
|
||||
toplist_id=toplist_id,
|
||||
page=page + 1,
|
||||
page_size=page_size,
|
||||
)
|
||||
is_end = len(next_rows) == 0
|
||||
return {
|
||||
"isEnd": is_end,
|
||||
"musicList": [_to_music_item(row) for row in rows],
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse, Response, StreamingResponse
|
||||
|
||||
from ..auth import require_bearer_token
|
||||
from ..services.cache_service import CacheService
|
||||
from ..services.media_resolver import MediaResolver
|
||||
from ..services.local_streaming import (
|
||||
RangeNotSatisfiable,
|
||||
guess_audio_media_type,
|
||||
iter_file_range,
|
||||
parse_single_range,
|
||||
)
|
||||
from ..services.stream_tokens import create_stream_token, parse_stream_token
|
||||
from ..settings import get_settings
|
||||
|
||||
router = APIRouter(prefix="/mf/v1", dependencies=[Depends(require_bearer_token)])
|
||||
stream_router = APIRouter(prefix="/mf/v1")
|
||||
_CACHE_URL_PROBE_TIMEOUT_SECONDS = 2.0
|
||||
|
||||
|
||||
def _song_id_from_public_id(public_song_id: str) -> int:
|
||||
parts = str(public_song_id).split(":")
|
||||
if len(parts) < 3 or parts[0] != "catalogsync" or parts[1] != "song":
|
||||
raise HTTPException(status_code=400, detail="invalid song id")
|
||||
try:
|
||||
return int(parts[2])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="invalid song id") from exc
|
||||
|
||||
|
||||
def _resolve_local_stream_path(locator: str) -> Path:
|
||||
library_root = os.getenv("LOCAL_LIBRARY_ROOT")
|
||||
if not library_root:
|
||||
raise HTTPException(status_code=404, detail="local stream root not configured")
|
||||
|
||||
root_path = Path(library_root).resolve()
|
||||
file_path = (root_path / locator).resolve()
|
||||
try:
|
||||
file_path.relative_to(root_path)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail="local stream file not found") from exc
|
||||
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="local stream file not found")
|
||||
return file_path
|
||||
|
||||
|
||||
def _cache_service(settings) -> CacheService:
|
||||
return CacheService(
|
||||
player_db_path=settings.player_db_path,
|
||||
catalog_db_path=settings.catalog_db_path,
|
||||
secret_encryption_key=settings.secret_encryption_key,
|
||||
local_library_root=settings.local_library_root,
|
||||
cache_relay_enabled=settings.cache_relay_enabled,
|
||||
)
|
||||
|
||||
|
||||
def _is_cache_url_reachable(public_url: str) -> bool:
|
||||
try:
|
||||
response = httpx.head(
|
||||
public_url,
|
||||
follow_redirects=True,
|
||||
timeout=_CACHE_URL_PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
if response.status_code in {200, 206}:
|
||||
return True
|
||||
if response.status_code not in {405, 501}:
|
||||
return False
|
||||
|
||||
try:
|
||||
with httpx.stream(
|
||||
"GET",
|
||||
public_url,
|
||||
headers={"Range": "bytes=0-0"},
|
||||
follow_redirects=True,
|
||||
timeout=_CACHE_URL_PROBE_TIMEOUT_SECONDS,
|
||||
) as fallback_response:
|
||||
return fallback_response.status_code in {200, 206}
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
|
||||
def _selected_source_payload(*, resolved: dict, quality: str, size_bytes: int | None = None) -> dict:
|
||||
ext = resolved.get("ext")
|
||||
if not ext:
|
||||
locator = str(resolved.get("source_locator") or resolved.get("remote_key") or resolved.get("locator") or "")
|
||||
ext = Path(locator).suffix.lstrip(".") or None
|
||||
return {
|
||||
"kind": resolved.get("backend_type") or resolved.get("kind"),
|
||||
"backend": resolved.get("backend_name") or resolved.get("target_name"),
|
||||
"quality": resolved.get("quality_label") or quality,
|
||||
"ext": ext,
|
||||
"size_bytes": size_bytes,
|
||||
}
|
||||
|
||||
|
||||
def _build_stream_url(*, token: str, resolved: dict) -> str:
|
||||
ext = resolved.get("ext")
|
||||
if not ext:
|
||||
locator = str(resolved.get("source_locator") or resolved.get("remote_key") or resolved.get("locator") or "")
|
||||
ext = Path(locator).suffix.lstrip(".") or None
|
||||
if ext:
|
||||
return f"/mf/v1/media/stream/{token}.{ext}"
|
||||
return f"/mf/v1/media/stream/{token}"
|
||||
|
||||
|
||||
@router.post("/media/resolve")
|
||||
def resolve_media(payload: dict) -> dict:
|
||||
settings = get_settings()
|
||||
public_song_id = payload.get("song_id")
|
||||
if not public_song_id:
|
||||
raise HTTPException(status_code=400, detail="song_id is required")
|
||||
quality = str(payload.get("quality", "standard"))
|
||||
song_id = _song_id_from_public_id(str(public_song_id))
|
||||
resolver = MediaResolver(db_path=settings.catalog_db_path)
|
||||
|
||||
fallback_source = None
|
||||
try:
|
||||
fallback_source = resolver.resolve(
|
||||
song_id=song_id,
|
||||
quality=quality,
|
||||
)
|
||||
except LookupError as exc:
|
||||
fallback_error = exc
|
||||
else:
|
||||
fallback_error = None
|
||||
|
||||
cached_source = _cache_service(settings).resolve_cached_source(song_id=song_id)
|
||||
if fallback_source is None and cached_source is None:
|
||||
raise HTTPException(status_code=404, detail=str(fallback_error or "no playable source found"))
|
||||
|
||||
token_locator = ""
|
||||
if fallback_source is not None:
|
||||
token_locator = str(fallback_source["locator"])
|
||||
elif cached_source is not None:
|
||||
token_locator = str(cached_source.get("source_locator") or "")
|
||||
|
||||
token = create_stream_token(
|
||||
secret=settings.access_token,
|
||||
song_id=song_id,
|
||||
locator=token_locator,
|
||||
)
|
||||
selected_source = cached_source or fallback_source or {}
|
||||
selected_size = None
|
||||
if fallback_source is not None:
|
||||
selected_size = fallback_source.get("file_size_bytes")
|
||||
return {
|
||||
"song_id": public_song_id,
|
||||
"selected_source": _selected_source_payload(
|
||||
resolved=selected_source,
|
||||
quality=quality,
|
||||
size_bytes=selected_size,
|
||||
),
|
||||
"stream": {
|
||||
"url": _build_stream_url(token=token, resolved=selected_source),
|
||||
"headers": {},
|
||||
"range_supported": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@stream_router.get("/media/stream/{token}")
|
||||
@stream_router.get("/media/stream/{token}.{ext}")
|
||||
def stream_media(token: str, request: Request, ext: str | None = None):
|
||||
settings = get_settings()
|
||||
try:
|
||||
parsed = parse_stream_token(secret=settings.access_token, token=token)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
song_id = int(parsed["song_id"])
|
||||
cache_service = _cache_service(settings)
|
||||
|
||||
cached_source = cache_service.resolve_cached_source(song_id=song_id)
|
||||
if cached_source is not None and _is_cache_url_reachable(str(cached_source["public_url"])):
|
||||
cache_service.record_stream_play(song_id=song_id, stream_token=token)
|
||||
return RedirectResponse(url=str(cached_source["public_url"]), status_code=307)
|
||||
|
||||
try:
|
||||
resolved = MediaResolver(db_path=settings.catalog_db_path).resolve_by_locator(
|
||||
song_id=song_id,
|
||||
locator=str(parsed["locator"]),
|
||||
)
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
if resolved.get("backend_type") == "local_fs":
|
||||
file_path = _resolve_local_stream_path(str(resolved["locator"]))
|
||||
file_size = file_path.stat().st_size
|
||||
media_type = guess_audio_media_type(file_path)
|
||||
headers = {"Accept-Ranges": "bytes"}
|
||||
range_header = request.headers.get("range")
|
||||
|
||||
try:
|
||||
byte_range = parse_single_range(range_header, file_size)
|
||||
except RangeNotSatisfiable:
|
||||
return Response(
|
||||
status_code=416,
|
||||
headers={
|
||||
**headers,
|
||||
"Content-Range": f"bytes */{file_size}",
|
||||
},
|
||||
)
|
||||
|
||||
if byte_range is None:
|
||||
if file_size == 0:
|
||||
body_iter = iter(())
|
||||
else:
|
||||
body_iter = iter_file_range(file_path=file_path, start=0, end=file_size - 1)
|
||||
cache_service.record_stream_play(song_id=song_id, stream_token=token)
|
||||
return StreamingResponse(
|
||||
body_iter,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
**headers,
|
||||
"Content-Length": str(file_size),
|
||||
},
|
||||
)
|
||||
|
||||
start, end = byte_range
|
||||
cache_service.record_stream_play(song_id=song_id, stream_token=token)
|
||||
return StreamingResponse(
|
||||
iter_file_range(file_path=file_path, start=start, end=end),
|
||||
media_type=media_type,
|
||||
status_code=206,
|
||||
headers={
|
||||
**headers,
|
||||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||
"Content-Length": str(end - start + 1),
|
||||
},
|
||||
)
|
||||
|
||||
public_url = resolved.get("public_url")
|
||||
if not public_url:
|
||||
raise HTTPException(status_code=404, detail="public stream url not found")
|
||||
cache_service.record_stream_play(song_id=song_id, stream_token=token)
|
||||
return RedirectResponse(url=str(public_url), status_code=307)
|
||||
@@ -0,0 +1,73 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
|
||||
from ..auth import require_bearer_token
|
||||
from ..services.catalog_reader import CatalogReader
|
||||
from ..services.player_service import PlayerService
|
||||
from ..settings import get_settings
|
||||
|
||||
router = APIRouter(prefix="/player/v1", dependencies=[Depends(require_bearer_token)])
|
||||
|
||||
|
||||
def _player_service() -> PlayerService:
|
||||
return PlayerService(db_path=get_settings().player_db_path)
|
||||
|
||||
|
||||
def _catalog_reader() -> CatalogReader:
|
||||
return CatalogReader(db_path=get_settings().catalog_db_path)
|
||||
|
||||
|
||||
@router.get("/home")
|
||||
def home() -> dict:
|
||||
return {
|
||||
"recommend_playlists": _catalog_reader().list_playlists(page=1, page_size=12),
|
||||
"favorite_playlists": _player_service().list_favorite_playlists(),
|
||||
"recent_history": _player_service().list_history(),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/me/favorites/tracks/{track_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def add_favorite_track(track_id: int) -> Response:
|
||||
_player_service().add_favorite_track(track_id=track_id)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/me/favorites/tracks")
|
||||
def list_favorite_tracks() -> dict:
|
||||
return {"items": _player_service().list_favorite_tracks()}
|
||||
|
||||
|
||||
@router.put("/me/favorites/playlists/{playlist_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def add_favorite_playlist(playlist_id: int) -> Response:
|
||||
_player_service().add_favorite_playlist(playlist_id=playlist_id)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/me/favorites/playlists")
|
||||
def list_favorite_playlists() -> dict:
|
||||
return {"items": _player_service().list_favorite_playlists()}
|
||||
|
||||
|
||||
@router.post("/me/history", status_code=status.HTTP_201_CREATED)
|
||||
def record_history(payload: dict) -> dict:
|
||||
if "track_id" not in payload:
|
||||
raise HTTPException(status_code=400, detail="track_id is required")
|
||||
try:
|
||||
track_id = int(payload["track_id"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="track_id must be a number") from exc
|
||||
|
||||
try:
|
||||
progress_seconds = int(payload.get("progress_seconds", 0))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="progress_seconds must be a number") from exc
|
||||
|
||||
_player_service().record_history(
|
||||
track_id=track_id,
|
||||
progress_seconds=progress_seconds,
|
||||
)
|
||||
return {"status": "created"}
|
||||
|
||||
|
||||
@router.get("/me/history")
|
||||
def list_history() -> dict:
|
||||
return {"items": _player_service().list_history()}
|
||||
@@ -0,0 +1,75 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request, Response
|
||||
|
||||
router = APIRouter(prefix="/plugins")
|
||||
|
||||
_SRC_URL_PLACEHOLDER = "__MUSIC_SERVER_PLUGIN_SRC_URL__"
|
||||
_ASSET_ROOT = Path(__file__).resolve().parent.parent / "plugin_assets"
|
||||
_PLUGIN_ASSETS = {
|
||||
"music_server": {
|
||||
"name": "Music_Server",
|
||||
"asset_path": _ASSET_ROOT / "music_server.js",
|
||||
"route_name": "music_server_plugin_js",
|
||||
},
|
||||
"music_server_lan": {
|
||||
"name": "Music_Server LAN",
|
||||
"asset_path": _ASSET_ROOT / "music_server_lan.js",
|
||||
"route_name": "music_server_lan_plugin_js",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _plugin_src_url(request: Request, asset_key: str) -> str:
|
||||
plugin_asset = _PLUGIN_ASSETS[asset_key]
|
||||
return str(request.url_for(plugin_asset["route_name"]))
|
||||
|
||||
|
||||
def _plugin_js_text(request: Request, asset_key: str) -> str:
|
||||
plugin_asset = _PLUGIN_ASSETS[asset_key]
|
||||
raw = plugin_asset["asset_path"].read_text(encoding="utf-8")
|
||||
return raw.replace(_SRC_URL_PLACEHOLDER, _plugin_src_url(request, asset_key))
|
||||
|
||||
|
||||
@router.get("/music_server.js", name="music_server_plugin_js")
|
||||
def music_server_plugin_js(request: Request) -> Response:
|
||||
body = _plugin_js_text(request, "music_server")
|
||||
return Response(
|
||||
content=body,
|
||||
media_type="application/javascript",
|
||||
headers={
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/music_server_lan.js", name="music_server_lan_plugin_js")
|
||||
def music_server_lan_plugin_js(request: Request) -> Response:
|
||||
body = _plugin_js_text(request, "music_server_lan")
|
||||
return Response(
|
||||
content=body,
|
||||
media_type="application/javascript",
|
||||
headers={
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/music_server.json", name="music_server_plugin_manifest")
|
||||
def music_server_plugin_manifest(request: Request) -> dict:
|
||||
return {
|
||||
"plugins": [
|
||||
{
|
||||
"name": _PLUGIN_ASSETS["music_server"]["name"],
|
||||
"url": _plugin_src_url(request, "music_server"),
|
||||
},
|
||||
{
|
||||
"name": _PLUGIN_ASSETS["music_server_lan"]["name"],
|
||||
"url": _plugin_src_url(request, "music_server_lan"),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
|
||||
def verify_password_hash(*, plaintext_password: str, stored_hash: str) -> bool:
|
||||
normalized = (stored_hash or "").strip()
|
||||
if not normalized:
|
||||
return False
|
||||
if normalized.startswith("sha256$"):
|
||||
expected = normalized.split("$", 1)[1]
|
||||
actual = hashlib.sha256(plaintext_password.encode("utf-8")).hexdigest()
|
||||
return hmac.compare_digest(actual, expected)
|
||||
return hmac.compare_digest(plaintext_password, normalized)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import closing
|
||||
import mimetypes
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
import paramiko
|
||||
|
||||
|
||||
def _build_boto3_client(**kwargs):
|
||||
import boto3
|
||||
|
||||
return boto3.client("s3", **kwargs)
|
||||
|
||||
|
||||
def _guess_content_type(path: Path) -> str | None:
|
||||
guessed, _ = mimetypes.guess_type(str(path))
|
||||
return guessed
|
||||
|
||||
|
||||
class SFTPCacheTargetUploader:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
host: str,
|
||||
port: int = 22,
|
||||
username: str,
|
||||
password: str | None = None,
|
||||
private_key: str | None = None,
|
||||
timeout_seconds: int = 10,
|
||||
remote_root: str | None = None,
|
||||
) -> None:
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._username = username
|
||||
self._password = password
|
||||
self._private_key = private_key
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._remote_root = (remote_root or "").strip().rstrip("/")
|
||||
|
||||
def _connect(self):
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
kwargs: dict[str, Any] = {
|
||||
"hostname": self._host,
|
||||
"port": self._port,
|
||||
"username": self._username,
|
||||
"timeout": self._timeout_seconds,
|
||||
}
|
||||
if self._private_key:
|
||||
kwargs["key_filename"] = self._private_key
|
||||
elif self._password:
|
||||
kwargs["password"] = self._password
|
||||
client.connect(**kwargs)
|
||||
sftp = client.open_sftp()
|
||||
return client, sftp
|
||||
|
||||
def _full_remote_path(self, remote_key: str) -> str:
|
||||
normalized_key = remote_key.lstrip("/")
|
||||
if self._remote_root:
|
||||
return (PurePosixPath(self._remote_root) / normalized_key).as_posix()
|
||||
return normalized_key
|
||||
|
||||
def _ensure_remote_dir(self, sftp, remote_path: str) -> None:
|
||||
parent = PurePosixPath(remote_path).parent
|
||||
if str(parent) in {"", "."}:
|
||||
return
|
||||
current = PurePosixPath("/") if parent.is_absolute() else PurePosixPath(".")
|
||||
for part in parent.parts:
|
||||
if part in {"", ".", "/"}:
|
||||
continue
|
||||
current = current / part
|
||||
remote_path = current.as_posix()
|
||||
try:
|
||||
sftp.stat(remote_path)
|
||||
except IOError:
|
||||
sftp.mkdir(remote_path)
|
||||
|
||||
def upload_file(self, *, local_path: Path, remote_key: str) -> None:
|
||||
client, sftp = self._connect()
|
||||
try:
|
||||
remote_path = self._full_remote_path(remote_key)
|
||||
self._ensure_remote_dir(sftp, remote_path)
|
||||
sftp.put(str(local_path), remote_path)
|
||||
finally:
|
||||
sftp.close()
|
||||
client.close()
|
||||
|
||||
def delete_file(self, *, remote_key: str) -> None:
|
||||
client, sftp = self._connect()
|
||||
try:
|
||||
sftp.remove(self._full_remote_path(remote_key))
|
||||
finally:
|
||||
sftp.close()
|
||||
client.close()
|
||||
|
||||
def test_connection(self) -> None:
|
||||
client, sftp = self._connect()
|
||||
try:
|
||||
sftp.listdir(self._remote_root or ".")
|
||||
finally:
|
||||
sftp.close()
|
||||
client.close()
|
||||
|
||||
|
||||
class S3CacheTargetUploader:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bucket: str,
|
||||
region: str | None = None,
|
||||
endpoint_url: str | None = None,
|
||||
access_key_id: str | None = None,
|
||||
secret_access_key: str | None = None,
|
||||
session_token: str | None = None,
|
||||
) -> None:
|
||||
self._bucket = bucket
|
||||
self._region = region
|
||||
self._endpoint_url = endpoint_url
|
||||
self._access_key_id = access_key_id
|
||||
self._secret_access_key = secret_access_key
|
||||
self._session_token = session_token
|
||||
|
||||
def _client(self):
|
||||
return _build_boto3_client(
|
||||
region_name=self._region,
|
||||
endpoint_url=self._endpoint_url,
|
||||
aws_access_key_id=self._access_key_id,
|
||||
aws_secret_access_key=self._secret_access_key,
|
||||
aws_session_token=self._session_token,
|
||||
)
|
||||
|
||||
def upload_file(self, *, local_path: Path, remote_key: str) -> None:
|
||||
client = self._client()
|
||||
extra_args = {}
|
||||
content_type = _guess_content_type(local_path)
|
||||
if content_type:
|
||||
extra_args["ContentType"] = content_type
|
||||
client.upload_file(
|
||||
str(local_path),
|
||||
self._bucket,
|
||||
remote_key,
|
||||
ExtraArgs=extra_args or None,
|
||||
)
|
||||
|
||||
def delete_file(self, *, remote_key: str) -> None:
|
||||
client = self._client()
|
||||
client.delete_object(Bucket=self._bucket, Key=remote_key)
|
||||
|
||||
def test_connection(self) -> None:
|
||||
client = self._client()
|
||||
client.head_bucket(Bucket=self._bucket)
|
||||
@@ -0,0 +1,595 @@
|
||||
from contextlib import closing
|
||||
from typing import TypedDict, cast
|
||||
|
||||
from ..db import connect_sqlite
|
||||
|
||||
|
||||
class PlaylistRow(TypedDict):
|
||||
playlist_id: int
|
||||
platform: str
|
||||
remote_playlist_id: str
|
||||
name: str
|
||||
description: str | None
|
||||
cover_url: str | None
|
||||
play_count: int
|
||||
song_count: int
|
||||
playable_song_count: int
|
||||
|
||||
|
||||
class PlaylistTrackRow(TypedDict):
|
||||
song_id: int
|
||||
name: str
|
||||
singers: str | None
|
||||
album: str | None
|
||||
cover_url: str | None
|
||||
duration_ms: int
|
||||
local_locator: str | None
|
||||
|
||||
|
||||
class SearchTrackRow(TypedDict):
|
||||
song_id: int
|
||||
name: str
|
||||
singers: str | None
|
||||
album: str | None
|
||||
cover_url: str | None
|
||||
duration_ms: int
|
||||
local_locator: str | None
|
||||
|
||||
|
||||
class SongRow(TypedDict):
|
||||
song_id: int
|
||||
name: str
|
||||
singers: str | None
|
||||
album: str | None
|
||||
cover_url: str | None
|
||||
duration_ms: int
|
||||
local_locator: str | None
|
||||
|
||||
|
||||
class SheetSearchRow(TypedDict):
|
||||
item_type: str
|
||||
item_id: str
|
||||
platform: str
|
||||
name: str
|
||||
description: str | None
|
||||
cover_url: str | None
|
||||
play_count: int
|
||||
song_count: int
|
||||
playable_song_count: int
|
||||
|
||||
|
||||
class ArtistRow(TypedDict):
|
||||
artist_id: int
|
||||
artist_key: str
|
||||
platform: str
|
||||
remote_artist_id: str | None
|
||||
name: str
|
||||
normalized_name: str
|
||||
avatar_url: str | None
|
||||
description: str | None
|
||||
playable_song_count: int
|
||||
|
||||
|
||||
class ToplistRow(TypedDict):
|
||||
toplist_id: str
|
||||
platform: str
|
||||
name: str
|
||||
description: str | None
|
||||
cover_url: str | None
|
||||
play_count: int
|
||||
song_count: int
|
||||
playable_song_count: int
|
||||
group_name: str
|
||||
|
||||
|
||||
class CatalogReader:
|
||||
def __init__(self, db_path: str) -> None:
|
||||
self._db_path = db_path
|
||||
|
||||
def _normalize_pagination(self, page: int, page_size: int) -> tuple[int, int]:
|
||||
normalized_page = page if page > 0 else 1
|
||||
normalized_page_size = page_size if page_size > 0 else 1
|
||||
return normalized_page, normalized_page_size
|
||||
|
||||
def _escape_like_term(self, term: str) -> str:
|
||||
return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
def count_playable_tracks(self) -> int:
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
select count(distinct song_id) as playable_song_count
|
||||
from catalog_track_files
|
||||
where status = 'active'
|
||||
"""
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return 0
|
||||
value = row["playable_song_count"] if "playable_song_count" in row.keys() else row[0]
|
||||
return int(value or 0)
|
||||
|
||||
def list_playlists(
|
||||
self,
|
||||
page: int,
|
||||
page_size: int,
|
||||
platform: str | None = None,
|
||||
) -> list[PlaylistRow]:
|
||||
page, page_size = self._normalize_pagination(page, page_size)
|
||||
offset = (page - 1) * page_size
|
||||
normalized_platform = str(platform or "").strip().lower()
|
||||
supported_platforms = {"netease", "qq", "kuwo"}
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
if normalized_platform in supported_platforms:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
select
|
||||
playlist_id,
|
||||
platform,
|
||||
remote_playlist_id,
|
||||
name,
|
||||
description,
|
||||
cover_url,
|
||||
play_count,
|
||||
song_count,
|
||||
playable_song_count
|
||||
from catalog_playlists
|
||||
where song_count > 0
|
||||
and lower(platform) = ?
|
||||
order by play_count desc, playlist_id asc
|
||||
limit ? offset ?
|
||||
""",
|
||||
(normalized_platform, page_size, offset),
|
||||
).fetchall()
|
||||
elif normalized_platform:
|
||||
rows = []
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
select
|
||||
playlist_id,
|
||||
platform,
|
||||
remote_playlist_id,
|
||||
name,
|
||||
description,
|
||||
cover_url,
|
||||
play_count,
|
||||
song_count,
|
||||
playable_song_count
|
||||
from catalog_playlists
|
||||
where song_count > 0
|
||||
order by play_count desc, playlist_id asc
|
||||
limit ? offset ?
|
||||
""",
|
||||
(page_size, offset),
|
||||
).fetchall()
|
||||
return [cast(PlaylistRow, dict(row)) for row in rows]
|
||||
|
||||
def get_playlist(self, playlist_id: int) -> PlaylistRow | None:
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
select
|
||||
playlist_id,
|
||||
platform,
|
||||
remote_playlist_id,
|
||||
name,
|
||||
description,
|
||||
cover_url,
|
||||
play_count,
|
||||
song_count,
|
||||
playable_song_count
|
||||
from catalog_playlists
|
||||
where playlist_id = ?
|
||||
""",
|
||||
(playlist_id,),
|
||||
).fetchone()
|
||||
return cast(PlaylistRow, dict(row)) if row else None
|
||||
|
||||
def list_playlist_tracks(
|
||||
self, playlist_id: int, page: int, page_size: int
|
||||
) -> list[PlaylistTrackRow]:
|
||||
page, page_size = self._normalize_pagination(page, page_size)
|
||||
offset = (page - 1) * page_size
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
select
|
||||
t.song_id,
|
||||
t.name,
|
||||
t.singers,
|
||||
t.album,
|
||||
t.cover_url,
|
||||
t.duration_ms,
|
||||
(
|
||||
select f.locator
|
||||
from catalog_track_files f
|
||||
where f.song_id = t.song_id
|
||||
and f.status = 'active'
|
||||
and f.backend_type = 'local_fs'
|
||||
order by f.is_primary desc, f.locator asc
|
||||
limit 1
|
||||
) as local_locator
|
||||
from catalog_playlist_tracks pt
|
||||
join catalog_tracks t on t.song_id = pt.song_id
|
||||
where pt.playlist_id = ?
|
||||
and exists (
|
||||
select 1
|
||||
from catalog_track_files f
|
||||
where f.song_id = t.song_id
|
||||
and f.status = 'active'
|
||||
)
|
||||
order by pt.position asc, pt.song_id asc
|
||||
limit ? offset ?
|
||||
""",
|
||||
(playlist_id, page_size, offset),
|
||||
).fetchall()
|
||||
return [cast(PlaylistTrackRow, dict(row)) for row in rows]
|
||||
|
||||
def search_tracks(self, query: str, page: int, page_size: int) -> list[SearchTrackRow]:
|
||||
page, page_size = self._normalize_pagination(page, page_size)
|
||||
term = str(query or "").strip()
|
||||
if not term:
|
||||
return []
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
exact_query = term.lower()
|
||||
escaped_query = self._escape_like_term(exact_query)
|
||||
prefix_query = f"{escaped_query}%"
|
||||
like_query = f"%{escaped_query}%"
|
||||
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
select
|
||||
t.song_id,
|
||||
t.name,
|
||||
t.singers,
|
||||
t.album,
|
||||
t.cover_url,
|
||||
t.duration_ms,
|
||||
(
|
||||
select f.locator
|
||||
from catalog_track_files f
|
||||
where f.song_id = t.song_id
|
||||
and f.status = 'active'
|
||||
and f.backend_type = 'local_fs'
|
||||
order by f.is_primary desc, f.locator asc
|
||||
limit 1
|
||||
) as local_locator
|
||||
from catalog_tracks t
|
||||
where exists (
|
||||
select 1
|
||||
from catalog_track_files f
|
||||
where f.song_id = t.song_id
|
||||
and f.status = 'active'
|
||||
)
|
||||
and (
|
||||
lower(t.name) like ? escape '\\'
|
||||
or lower(coalesce(t.singers, '')) like ? escape '\\'
|
||||
)
|
||||
order by
|
||||
case
|
||||
when lower(t.name) = ? then 0
|
||||
when lower(t.name) like ? escape '\\' then 1
|
||||
when lower(t.name) like ? escape '\\' then 2
|
||||
when lower(coalesce(t.singers, '')) like ? escape '\\' then 3
|
||||
else 9
|
||||
end,
|
||||
lower(t.name) asc,
|
||||
t.song_id asc
|
||||
limit ? offset ?
|
||||
""",
|
||||
(
|
||||
like_query,
|
||||
like_query,
|
||||
exact_query,
|
||||
prefix_query,
|
||||
like_query,
|
||||
like_query,
|
||||
page_size,
|
||||
offset,
|
||||
),
|
||||
).fetchall()
|
||||
return [cast(SearchTrackRow, dict(row)) for row in rows]
|
||||
|
||||
def get_song(self, song_id: int) -> SongRow | None:
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
select
|
||||
t.song_id,
|
||||
t.name,
|
||||
t.singers,
|
||||
t.album,
|
||||
t.cover_url,
|
||||
t.duration_ms,
|
||||
(
|
||||
select f.locator
|
||||
from catalog_track_files f
|
||||
where f.song_id = t.song_id
|
||||
and f.status = 'active'
|
||||
and f.backend_type = 'local_fs'
|
||||
order by f.is_primary desc, f.locator asc
|
||||
limit 1
|
||||
) as local_locator
|
||||
from catalog_tracks t
|
||||
where t.song_id = ?
|
||||
and exists (
|
||||
select 1
|
||||
from catalog_track_files f
|
||||
where f.song_id = t.song_id
|
||||
and f.status = 'active'
|
||||
)
|
||||
""",
|
||||
(song_id,),
|
||||
).fetchone()
|
||||
return cast(SongRow, dict(row)) if row else None
|
||||
|
||||
def search_sheets(self, query: str, page: int, page_size: int) -> list[SheetSearchRow]:
|
||||
page, page_size = self._normalize_pagination(page, page_size)
|
||||
term = str(query or "").strip()
|
||||
if not term:
|
||||
return []
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
exact_query = term.lower()
|
||||
escaped_query = self._escape_like_term(exact_query)
|
||||
prefix_query = f"{escaped_query}%"
|
||||
like_query = f"%{escaped_query}%"
|
||||
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
select *
|
||||
from (
|
||||
select
|
||||
'playlist' as item_type,
|
||||
cast(playlist_id as text) as item_id,
|
||||
platform,
|
||||
name,
|
||||
description,
|
||||
cover_url,
|
||||
play_count,
|
||||
song_count,
|
||||
playable_song_count
|
||||
from catalog_playlists
|
||||
where playable_song_count > 0
|
||||
and lower(name) like ? escape '\\'
|
||||
|
||||
union all
|
||||
|
||||
select
|
||||
'toplist' as item_type,
|
||||
toplist_id as item_id,
|
||||
platform,
|
||||
name,
|
||||
description,
|
||||
cover_url,
|
||||
play_count,
|
||||
song_count,
|
||||
playable_song_count
|
||||
from catalog_toplists
|
||||
where playable_song_count > 0
|
||||
and lower(name) like ? escape '\\'
|
||||
) sheets
|
||||
order by
|
||||
case
|
||||
when lower(name) = ? then 0
|
||||
when lower(name) like ? escape '\\' then 1
|
||||
when lower(name) like ? escape '\\' then 2
|
||||
else 9
|
||||
end,
|
||||
play_count desc,
|
||||
case when item_type = 'playlist' then 0 else 1 end,
|
||||
item_id asc
|
||||
limit ? offset ?
|
||||
""",
|
||||
(
|
||||
like_query,
|
||||
like_query,
|
||||
exact_query,
|
||||
prefix_query,
|
||||
like_query,
|
||||
page_size,
|
||||
offset,
|
||||
),
|
||||
).fetchall()
|
||||
return [cast(SheetSearchRow, dict(row)) for row in rows]
|
||||
|
||||
def search_artists(self, query: str, page: int, page_size: int) -> list[ArtistRow]:
|
||||
page, page_size = self._normalize_pagination(page, page_size)
|
||||
term = str(query or "").strip()
|
||||
if not term:
|
||||
return []
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
exact_query = term.lower()
|
||||
escaped_query = self._escape_like_term(exact_query)
|
||||
prefix_query = f"{escaped_query}%"
|
||||
like_query = f"%{escaped_query}%"
|
||||
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
select
|
||||
artist_id,
|
||||
artist_key,
|
||||
platform,
|
||||
remote_artist_id,
|
||||
name,
|
||||
normalized_name,
|
||||
avatar_url,
|
||||
description,
|
||||
playable_song_count
|
||||
from catalog_artists
|
||||
where playable_song_count > 0
|
||||
and lower(name) like ? escape '\\'
|
||||
order by
|
||||
case
|
||||
when lower(name) = ? then 0
|
||||
when lower(name) like ? escape '\\' then 1
|
||||
when lower(name) like ? escape '\\' then 2
|
||||
else 9
|
||||
end,
|
||||
playable_song_count desc,
|
||||
lower(name) asc,
|
||||
artist_id asc
|
||||
limit ? offset ?
|
||||
""",
|
||||
(
|
||||
like_query,
|
||||
exact_query,
|
||||
prefix_query,
|
||||
like_query,
|
||||
page_size,
|
||||
offset,
|
||||
),
|
||||
).fetchall()
|
||||
return [cast(ArtistRow, dict(row)) for row in rows]
|
||||
|
||||
def get_artist(self, artist_id: int) -> ArtistRow | None:
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
select
|
||||
artist_id,
|
||||
artist_key,
|
||||
platform,
|
||||
remote_artist_id,
|
||||
name,
|
||||
normalized_name,
|
||||
avatar_url,
|
||||
description,
|
||||
playable_song_count
|
||||
from catalog_artists
|
||||
where artist_id = ?
|
||||
""",
|
||||
(artist_id,),
|
||||
).fetchone()
|
||||
return cast(ArtistRow, dict(row)) if row else None
|
||||
|
||||
def list_artist_tracks(
|
||||
self, artist_id: int, page: int, page_size: int
|
||||
) -> list[PlaylistTrackRow]:
|
||||
page, page_size = self._normalize_pagination(page, page_size)
|
||||
offset = (page - 1) * page_size
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
select
|
||||
t.song_id,
|
||||
t.name,
|
||||
t.singers,
|
||||
t.album,
|
||||
t.cover_url,
|
||||
t.duration_ms,
|
||||
(
|
||||
select f.locator
|
||||
from catalog_track_files f
|
||||
where f.song_id = t.song_id
|
||||
and f.status = 'active'
|
||||
and f.backend_type = 'local_fs'
|
||||
order by f.is_primary desc, f.locator asc
|
||||
limit 1
|
||||
) as local_locator
|
||||
from catalog_artist_tracks at
|
||||
join catalog_tracks t on t.song_id = at.song_id
|
||||
where at.artist_id = ?
|
||||
and exists (
|
||||
select 1
|
||||
from catalog_track_files f
|
||||
where f.song_id = t.song_id
|
||||
and f.status = 'active'
|
||||
)
|
||||
order by at.position asc, t.song_id asc
|
||||
limit ? offset ?
|
||||
""",
|
||||
(artist_id, page_size, offset),
|
||||
).fetchall()
|
||||
return [cast(PlaylistTrackRow, dict(row)) for row in rows]
|
||||
|
||||
def list_toplists(self) -> list[dict]:
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
select
|
||||
toplist_id,
|
||||
platform,
|
||||
name,
|
||||
description,
|
||||
cover_url,
|
||||
play_count,
|
||||
song_count,
|
||||
playable_song_count,
|
||||
group_name
|
||||
from catalog_toplists
|
||||
order by group_name asc, play_count desc, name asc
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
grouped: dict[str, list[ToplistRow]] = {}
|
||||
for row in rows:
|
||||
data = cast(ToplistRow, dict(row))
|
||||
grouped.setdefault(data["group_name"], []).append(data)
|
||||
return [{"title": group_name, "data": data} for group_name, data in grouped.items()]
|
||||
|
||||
def get_toplist(self, toplist_id: str) -> ToplistRow | None:
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
select
|
||||
toplist_id,
|
||||
platform,
|
||||
name,
|
||||
description,
|
||||
cover_url,
|
||||
play_count,
|
||||
song_count,
|
||||
playable_song_count,
|
||||
group_name
|
||||
from catalog_toplists
|
||||
where toplist_id = ?
|
||||
""",
|
||||
(toplist_id,),
|
||||
).fetchone()
|
||||
return cast(ToplistRow, dict(row)) if row else None
|
||||
|
||||
def list_toplist_tracks(
|
||||
self, toplist_id: str, page: int, page_size: int
|
||||
) -> list[PlaylistTrackRow]:
|
||||
page, page_size = self._normalize_pagination(page, page_size)
|
||||
offset = (page - 1) * page_size
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
select
|
||||
t.song_id,
|
||||
t.name,
|
||||
t.singers,
|
||||
t.album,
|
||||
t.cover_url,
|
||||
t.duration_ms,
|
||||
(
|
||||
select f.locator
|
||||
from catalog_track_files f
|
||||
where f.song_id = t.song_id
|
||||
and f.status = 'active'
|
||||
and f.backend_type = 'local_fs'
|
||||
order by f.is_primary desc, f.locator asc
|
||||
limit 1
|
||||
) as local_locator
|
||||
from catalog_toplist_tracks tt
|
||||
join catalog_tracks t on t.song_id = tt.song_id
|
||||
where tt.toplist_id = ?
|
||||
and exists (
|
||||
select 1
|
||||
from catalog_track_files f
|
||||
where f.song_id = t.song_id
|
||||
and f.status = 'active'
|
||||
)
|
||||
order by tt.position asc, tt.song_id asc
|
||||
limit ? offset ?
|
||||
""",
|
||||
(toplist_id, page_size, offset),
|
||||
).fetchall()
|
||||
return [cast(PlaylistTrackRow, dict(row)) for row in rows]
|
||||
@@ -0,0 +1,30 @@
|
||||
from contextlib import closing
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ..db import connect_sqlite
|
||||
|
||||
|
||||
class CoverService:
|
||||
def __init__(self, db_path: str) -> None:
|
||||
self._db_path = db_path
|
||||
|
||||
def playlist_cover_url(self, playlist_id: int) -> str:
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
row = conn.execute(
|
||||
"select cover_url from catalog_playlists where playlist_id = ?",
|
||||
(playlist_id,),
|
||||
).fetchone()
|
||||
if row is None or not row["cover_url"]:
|
||||
raise HTTPException(status_code=404, detail="playlist cover not found")
|
||||
return str(row["cover_url"])
|
||||
|
||||
def song_cover_url(self, song_id: int) -> str:
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
row = conn.execute(
|
||||
"select cover_url from catalog_tracks where song_id = ?",
|
||||
(song_id,),
|
||||
).fetchone()
|
||||
if row is None or not row["cover_url"]:
|
||||
raise HTTPException(status_code=404, detail="song cover not found")
|
||||
return str(row["cover_url"])
|
||||
@@ -0,0 +1,94 @@
|
||||
from os import PathLike
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
|
||||
class RangeNotSatisfiable(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
_AUDIO_MEDIA_TYPES = {
|
||||
"flac": "audio/flac",
|
||||
"mp3": "audio/mpeg",
|
||||
"m4a": "audio/mp4",
|
||||
"wav": "audio/wav",
|
||||
"ogg": "audio/ogg",
|
||||
"ape": "audio/ape",
|
||||
}
|
||||
|
||||
|
||||
def guess_audio_media_type(path_like: str | PathLike[str]) -> str:
|
||||
suffix = Path(path_like).suffix.lower().lstrip(".")
|
||||
return _AUDIO_MEDIA_TYPES.get(suffix, "application/octet-stream")
|
||||
|
||||
|
||||
def parse_single_range(range_header: str | None, file_size: int) -> tuple[int, int] | None:
|
||||
if range_header is None:
|
||||
return None
|
||||
|
||||
unit, sep, raw_range = range_header.strip().partition("=")
|
||||
if sep != "=" or unit.strip().lower() != "bytes":
|
||||
raise RangeNotSatisfiable("only bytes ranges are supported")
|
||||
|
||||
range_spec = raw_range.strip()
|
||||
if "," in range_spec:
|
||||
raise RangeNotSatisfiable("multiple ranges are not supported")
|
||||
|
||||
start_text, dash, end_text = range_spec.partition("-")
|
||||
if dash != "-":
|
||||
raise RangeNotSatisfiable("invalid range")
|
||||
|
||||
start_text = start_text.strip()
|
||||
end_text = end_text.strip()
|
||||
|
||||
if not start_text:
|
||||
if not end_text.isdigit():
|
||||
raise RangeNotSatisfiable("invalid suffix range")
|
||||
suffix_length = int(end_text)
|
||||
if suffix_length <= 0 or file_size <= 0:
|
||||
raise RangeNotSatisfiable("invalid suffix range")
|
||||
start = max(file_size - suffix_length, 0)
|
||||
return (start, file_size - 1)
|
||||
|
||||
if not start_text.isdigit():
|
||||
raise RangeNotSatisfiable("invalid range start")
|
||||
start = int(start_text)
|
||||
|
||||
if not end_text:
|
||||
if start >= file_size:
|
||||
raise RangeNotSatisfiable("range out of bounds")
|
||||
return (start, file_size - 1)
|
||||
|
||||
if not end_text.isdigit():
|
||||
raise RangeNotSatisfiable("invalid range end")
|
||||
end = int(end_text)
|
||||
|
||||
if end < start:
|
||||
raise RangeNotSatisfiable("range start exceeds end")
|
||||
if start >= file_size:
|
||||
raise RangeNotSatisfiable("range out of bounds")
|
||||
if end >= file_size:
|
||||
end = file_size - 1
|
||||
return (start, end)
|
||||
|
||||
|
||||
def iter_file_range(
|
||||
file_path: str | PathLike[str],
|
||||
start: int,
|
||||
end: int,
|
||||
chunk_size: int = 64 * 1024,
|
||||
) -> Iterator[bytes]:
|
||||
if chunk_size <= 0:
|
||||
raise ValueError("chunk_size must be positive")
|
||||
if start < 0 or end < start:
|
||||
raise ValueError("invalid byte range")
|
||||
|
||||
remaining = end - start + 1
|
||||
with Path(file_path).open("rb") as file_obj:
|
||||
file_obj.seek(start)
|
||||
while remaining > 0:
|
||||
chunk = file_obj.read(min(chunk_size, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
remaining -= len(chunk)
|
||||
yield chunk
|
||||
@@ -0,0 +1,40 @@
|
||||
from contextlib import closing
|
||||
|
||||
from ..db import connect_sqlite
|
||||
|
||||
|
||||
class MediaResolver:
|
||||
def __init__(self, db_path: str) -> None:
|
||||
self._db_path = db_path
|
||||
|
||||
def resolve(self, song_id: int, quality: str) -> dict:
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
select song_id, quality_label, ext, file_size_bytes, backend_type, backend_name, locator, public_url
|
||||
from catalog_track_files
|
||||
where song_id = ? and status = 'active'
|
||||
order by case when quality_label = ? then 0 else 1 end, is_primary desc
|
||||
limit 1
|
||||
""",
|
||||
(song_id, quality),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise LookupError("no playable source found")
|
||||
return dict(row)
|
||||
|
||||
def resolve_by_locator(self, song_id: int, locator: str) -> dict:
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
select song_id, quality_label, ext, file_size_bytes, backend_type, backend_name, locator, public_url
|
||||
from catalog_track_files
|
||||
where song_id = ? and locator = ? and status = 'active'
|
||||
order by is_primary desc
|
||||
limit 1
|
||||
""",
|
||||
(song_id, locator),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise LookupError("no playable source found")
|
||||
return dict(row)
|
||||
@@ -0,0 +1,127 @@
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timezone
|
||||
from typing import TypedDict, cast
|
||||
|
||||
from ..db import connect_sqlite
|
||||
|
||||
|
||||
class FavoriteTrackItem(TypedDict):
|
||||
track_id: int
|
||||
|
||||
|
||||
class FavoritePlaylistItem(TypedDict):
|
||||
playlist_id: int
|
||||
|
||||
|
||||
class PlayHistoryItem(TypedDict):
|
||||
track_id: int
|
||||
played_at: str
|
||||
progress_seconds: int
|
||||
|
||||
|
||||
class PlayerService:
|
||||
def __init__(self, db_path: str) -> None:
|
||||
self._db_path = db_path
|
||||
self._ensure_schema()
|
||||
|
||||
def _ensure_schema(self) -> None:
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
create table if not exists favorite_tracks (
|
||||
track_id integer primary key,
|
||||
added_at text not null
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
create table if not exists favorite_playlists (
|
||||
playlist_id integer primary key,
|
||||
added_at text not null
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
create table if not exists play_history (
|
||||
id integer primary key autoincrement,
|
||||
track_id integer not null,
|
||||
played_at text not null,
|
||||
progress_seconds integer not null default 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def add_favorite_track(self, track_id: int) -> None:
|
||||
added_at = datetime.now(timezone.utc).isoformat()
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
insert into favorite_tracks (track_id, added_at)
|
||||
values (?, ?)
|
||||
on conflict(track_id) do update set added_at = excluded.added_at
|
||||
""",
|
||||
(track_id, added_at),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def list_favorite_tracks(self) -> list[FavoriteTrackItem]:
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
select track_id
|
||||
from favorite_tracks
|
||||
order by added_at desc
|
||||
"""
|
||||
).fetchall()
|
||||
return [cast(FavoriteTrackItem, dict(row)) for row in rows]
|
||||
|
||||
def add_favorite_playlist(self, playlist_id: int) -> None:
|
||||
added_at = datetime.now(timezone.utc).isoformat()
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
insert into favorite_playlists (playlist_id, added_at)
|
||||
values (?, ?)
|
||||
on conflict(playlist_id) do update set added_at = excluded.added_at
|
||||
""",
|
||||
(playlist_id, added_at),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def list_favorite_playlists(self) -> list[FavoritePlaylistItem]:
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
select playlist_id
|
||||
from favorite_playlists
|
||||
order by added_at desc
|
||||
"""
|
||||
).fetchall()
|
||||
return [cast(FavoritePlaylistItem, dict(row)) for row in rows]
|
||||
|
||||
def record_history(self, track_id: int, progress_seconds: int) -> None:
|
||||
played_at = datetime.now(timezone.utc).isoformat()
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
insert into play_history (track_id, played_at, progress_seconds)
|
||||
values (?, ?, ?)
|
||||
""",
|
||||
(track_id, played_at, progress_seconds),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def list_history(self) -> list[PlayHistoryItem]:
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
select track_id, played_at, progress_seconds
|
||||
from play_history
|
||||
order by played_at desc, rowid desc
|
||||
limit 100
|
||||
"""
|
||||
).fetchall()
|
||||
return [cast(PlayHistoryItem, dict(row)) for row in rows]
|
||||
@@ -0,0 +1,57 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
|
||||
|
||||
def _sign_payload(secret: str, payload_json: str) -> str:
|
||||
return hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
payload_json.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def create_stream_token(secret: str, song_id: int, locator: str, ttl_seconds: int = 300) -> str:
|
||||
payload = {
|
||||
"song_id": int(song_id),
|
||||
"locator": str(locator),
|
||||
"expires_at": int(time.time()) + int(ttl_seconds),
|
||||
}
|
||||
payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
||||
signature = _sign_payload(secret=secret, payload_json=payload_json)
|
||||
envelope = {"payload": payload, "sig": signature}
|
||||
token = base64.urlsafe_b64encode(
|
||||
json.dumps(envelope, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
)
|
||||
return token.decode("ascii")
|
||||
|
||||
|
||||
def parse_stream_token(secret: str, token: str) -> dict:
|
||||
try:
|
||||
padding = "=" * (-len(token) % 4)
|
||||
raw = base64.urlsafe_b64decode((token + padding).encode("ascii"))
|
||||
envelope = json.loads(raw.decode("utf-8"))
|
||||
payload = envelope["payload"]
|
||||
sig = str(envelope["sig"])
|
||||
payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
||||
if not hmac.compare_digest(sig, _sign_payload(secret=secret, payload_json=payload_json)):
|
||||
raise ValueError("invalid stream token")
|
||||
|
||||
song_id = int(payload["song_id"])
|
||||
locator = str(payload["locator"])
|
||||
expires_at = int(payload["expires_at"])
|
||||
if not locator:
|
||||
raise ValueError("invalid stream token")
|
||||
if expires_at < int(time.time()):
|
||||
raise ValueError("stream token expired")
|
||||
return {
|
||||
"song_id": song_id,
|
||||
"locator": locator,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ValueError("invalid stream token") from exc
|
||||
@@ -0,0 +1,404 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import closing
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import secrets
|
||||
from typing import TypedDict
|
||||
|
||||
from ..db import connect_sqlite
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IssuedToken:
|
||||
token_id: str
|
||||
plaintext_token: str
|
||||
issued_at: str
|
||||
expires_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthResult:
|
||||
valid: bool
|
||||
error_code: str | None
|
||||
token_id: str | None
|
||||
bound_client_id: str | None
|
||||
expires_at: str | None
|
||||
|
||||
|
||||
class TokenStatus(TypedDict):
|
||||
valid: bool
|
||||
status: str
|
||||
tokenId: str | None
|
||||
label: str | None
|
||||
issuedAt: str | None
|
||||
expiresAt: str | None
|
||||
remainingSeconds: int | None
|
||||
remainingDays: int | None
|
||||
playableSongCount: int | None
|
||||
bound: bool
|
||||
isCurrentClientBound: bool
|
||||
boundClientLabel: str | None
|
||||
|
||||
|
||||
class TokenService:
|
||||
def __init__(self, db_path: str) -> None:
|
||||
self._db_path = db_path
|
||||
self._ensure_schema()
|
||||
|
||||
def _ensure_schema(self) -> None:
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
create table if not exists access_tokens (
|
||||
token_id text primary key,
|
||||
token_hash text not null unique,
|
||||
label text,
|
||||
issued_at text not null,
|
||||
expires_at text not null,
|
||||
bound_client_id text,
|
||||
bound_client_label text,
|
||||
bound_at text,
|
||||
last_seen_at text,
|
||||
revoked_at text,
|
||||
revoked_reason text
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"create index if not exists idx_access_tokens_expires_at on access_tokens (expires_at)"
|
||||
)
|
||||
conn.execute(
|
||||
"create index if not exists idx_access_tokens_bound_client_id on access_tokens (bound_client_id)"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def issue_token(self, days: int = 90, label: str | None = None) -> IssuedToken:
|
||||
issued_at = datetime.now(timezone.utc)
|
||||
expires_at = issued_at + timedelta(days=days)
|
||||
plaintext_token = f"msv1_{secrets.token_urlsafe(24)}"
|
||||
token_id = f"tok_{secrets.token_hex(6)}"
|
||||
token_hash = hashlib.sha256(plaintext_token.encode("utf-8")).hexdigest()
|
||||
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
insert into access_tokens (
|
||||
token_id, token_hash, label, issued_at, expires_at
|
||||
) values (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
token_id,
|
||||
token_hash,
|
||||
label,
|
||||
issued_at.isoformat(),
|
||||
expires_at.isoformat(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return IssuedToken(
|
||||
token_id=token_id,
|
||||
plaintext_token=plaintext_token,
|
||||
issued_at=issued_at.isoformat(),
|
||||
expires_at=expires_at.isoformat(),
|
||||
)
|
||||
|
||||
def _parse_datetime(self, value: str) -> datetime:
|
||||
normalized = value.strip()
|
||||
if normalized.endswith("Z"):
|
||||
normalized = f"{normalized[:-1]}+00:00"
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
def _is_expired(self, expires_at: str, now: datetime) -> bool:
|
||||
return self._parse_datetime(expires_at) <= now
|
||||
|
||||
def _remaining(self, expires_at: str, now: datetime) -> tuple[int, int]:
|
||||
remaining_seconds = max(int((self._parse_datetime(expires_at) - now).total_seconds()), 0)
|
||||
remaining_days = remaining_seconds // 86400
|
||||
return remaining_seconds, remaining_days
|
||||
|
||||
def _load_token_by_hash(self, conn, token_hash: str):
|
||||
return conn.execute(
|
||||
"""
|
||||
select *
|
||||
from access_tokens
|
||||
where token_hash = ?
|
||||
""",
|
||||
(token_hash,),
|
||||
).fetchone()
|
||||
|
||||
def _load_token_by_id(self, conn, token_id: str):
|
||||
return conn.execute(
|
||||
"""
|
||||
select *
|
||||
from access_tokens
|
||||
where token_id = ?
|
||||
""",
|
||||
(token_id,),
|
||||
).fetchone()
|
||||
|
||||
def _authenticate_row(
|
||||
self,
|
||||
conn,
|
||||
row,
|
||||
client_id: str,
|
||||
client_label: str | None,
|
||||
now: datetime,
|
||||
) -> tuple[AuthResult, object | None]:
|
||||
if row is None:
|
||||
return AuthResult(False, "token_not_found", None, None, None), None
|
||||
|
||||
token_id = row["token_id"]
|
||||
expires_at = row["expires_at"]
|
||||
now_iso = now.isoformat()
|
||||
|
||||
if row["revoked_at"]:
|
||||
return (
|
||||
AuthResult(False, "token_revoked", token_id, row["bound_client_id"], expires_at),
|
||||
row,
|
||||
)
|
||||
if self._is_expired(expires_at, now):
|
||||
return (
|
||||
AuthResult(False, "token_expired", token_id, row["bound_client_id"], expires_at),
|
||||
row,
|
||||
)
|
||||
|
||||
bound_client_id = row["bound_client_id"]
|
||||
if bound_client_id and bound_client_id != client_id:
|
||||
return (
|
||||
AuthResult(False, "token_bound_to_other_client", token_id, bound_client_id, expires_at),
|
||||
row,
|
||||
)
|
||||
|
||||
def failure_from_final_state(fresh_row):
|
||||
if fresh_row is None:
|
||||
return AuthResult(False, "token_not_found", None, None, None)
|
||||
if fresh_row["revoked_at"]:
|
||||
return AuthResult(
|
||||
False,
|
||||
"token_revoked",
|
||||
fresh_row["token_id"],
|
||||
fresh_row["bound_client_id"],
|
||||
fresh_row["expires_at"],
|
||||
)
|
||||
if self._is_expired(fresh_row["expires_at"], now):
|
||||
return AuthResult(
|
||||
False,
|
||||
"token_expired",
|
||||
fresh_row["token_id"],
|
||||
fresh_row["bound_client_id"],
|
||||
fresh_row["expires_at"],
|
||||
)
|
||||
final_bound_client_id = fresh_row["bound_client_id"]
|
||||
if final_bound_client_id and final_bound_client_id != client_id:
|
||||
return AuthResult(
|
||||
False,
|
||||
"token_bound_to_other_client",
|
||||
fresh_row["token_id"],
|
||||
final_bound_client_id,
|
||||
fresh_row["expires_at"],
|
||||
)
|
||||
return None
|
||||
|
||||
if bound_client_id == client_id:
|
||||
conn.execute(
|
||||
"update access_tokens set last_seen_at = ?, bound_client_label = ? where token_id = ?",
|
||||
(now_iso, client_label or row["bound_client_label"], token_id),
|
||||
)
|
||||
fresh = self._load_token_by_id(conn, token_id)
|
||||
failure = failure_from_final_state(fresh)
|
||||
if failure is not None:
|
||||
return failure, fresh
|
||||
return (
|
||||
AuthResult(True, None, fresh["token_id"], fresh["bound_client_id"], fresh["expires_at"]),
|
||||
fresh,
|
||||
)
|
||||
|
||||
bind_result = conn.execute(
|
||||
"""
|
||||
update access_tokens
|
||||
set bound_client_id = ?, bound_client_label = ?, bound_at = ?, last_seen_at = ?
|
||||
where token_id = ? and bound_client_id is null and revoked_at is null
|
||||
""",
|
||||
(client_id, client_label, now_iso, now_iso, token_id),
|
||||
)
|
||||
fresh = self._load_token_by_id(conn, token_id)
|
||||
failure = failure_from_final_state(fresh)
|
||||
if failure is not None:
|
||||
return failure, fresh
|
||||
|
||||
if bind_result.rowcount == 0 and fresh["bound_client_id"] is None:
|
||||
return (
|
||||
AuthResult(False, "token_not_found", fresh["token_id"], None, fresh["expires_at"]),
|
||||
fresh,
|
||||
)
|
||||
|
||||
if fresh["bound_client_id"] != client_id:
|
||||
return (
|
||||
AuthResult(
|
||||
False,
|
||||
"token_bound_to_other_client",
|
||||
fresh["token_id"],
|
||||
fresh["bound_client_id"],
|
||||
fresh["expires_at"],
|
||||
),
|
||||
fresh,
|
||||
)
|
||||
|
||||
return (
|
||||
AuthResult(True, None, fresh["token_id"], fresh["bound_client_id"], fresh["expires_at"]),
|
||||
fresh,
|
||||
)
|
||||
|
||||
def authenticate(
|
||||
self,
|
||||
plaintext_token: str,
|
||||
client_id: str | None,
|
||||
client_label: str | None,
|
||||
) -> AuthResult:
|
||||
if not client_id:
|
||||
return AuthResult(False, "client_id_missing", None, None, None)
|
||||
|
||||
token_hash = hashlib.sha256(plaintext_token.encode("utf-8")).hexdigest()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
row = self._load_token_by_hash(conn, token_hash)
|
||||
auth_result, _ = self._authenticate_row(
|
||||
conn=conn,
|
||||
row=row,
|
||||
client_id=client_id,
|
||||
client_label=client_label,
|
||||
now=now,
|
||||
)
|
||||
conn.commit()
|
||||
return auth_result
|
||||
|
||||
def status(
|
||||
self,
|
||||
plaintext_token: str,
|
||||
client_id: str | None,
|
||||
client_label: str | None,
|
||||
) -> TokenStatus:
|
||||
token_hash = hashlib.sha256(plaintext_token.encode("utf-8")).hexdigest()
|
||||
now = datetime.now(timezone.utc)
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
row = self._load_token_by_hash(conn, token_hash)
|
||||
|
||||
if row is None:
|
||||
return {
|
||||
"valid": False,
|
||||
"status": "token_not_found",
|
||||
"tokenId": None,
|
||||
"label": None,
|
||||
"issuedAt": None,
|
||||
"expiresAt": None,
|
||||
"remainingSeconds": None,
|
||||
"remainingDays": None,
|
||||
"playableSongCount": None,
|
||||
"bound": False,
|
||||
"isCurrentClientBound": False,
|
||||
"boundClientLabel": None,
|
||||
}
|
||||
|
||||
if not client_id:
|
||||
return {
|
||||
"valid": False,
|
||||
"status": "client_id_missing",
|
||||
"tokenId": row["token_id"],
|
||||
"label": row["label"],
|
||||
"issuedAt": row["issued_at"],
|
||||
"expiresAt": row["expires_at"],
|
||||
"remainingSeconds": None,
|
||||
"remainingDays": None,
|
||||
"playableSongCount": None,
|
||||
"bound": bool(row["bound_client_id"]),
|
||||
"isCurrentClientBound": False,
|
||||
"boundClientLabel": row["bound_client_label"],
|
||||
}
|
||||
|
||||
auth_result, fresh = self._authenticate_row(
|
||||
conn=conn,
|
||||
row=row,
|
||||
client_id=client_id,
|
||||
client_label=client_label,
|
||||
now=now,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
if fresh is None:
|
||||
return {
|
||||
"valid": False,
|
||||
"status": auth_result.error_code or "token_not_found",
|
||||
"tokenId": None,
|
||||
"label": None,
|
||||
"issuedAt": None,
|
||||
"expiresAt": None,
|
||||
"remainingSeconds": None,
|
||||
"remainingDays": None,
|
||||
"playableSongCount": None,
|
||||
"bound": False,
|
||||
"isCurrentClientBound": False,
|
||||
"boundClientLabel": None,
|
||||
}
|
||||
|
||||
remaining_seconds, remaining_days = self._remaining(fresh["expires_at"], now)
|
||||
status = "active" if auth_result.valid else (auth_result.error_code or "token_not_found")
|
||||
|
||||
return {
|
||||
"valid": auth_result.valid,
|
||||
"status": status,
|
||||
"tokenId": fresh["token_id"],
|
||||
"label": fresh["label"],
|
||||
"issuedAt": fresh["issued_at"],
|
||||
"expiresAt": fresh["expires_at"],
|
||||
"remainingSeconds": remaining_seconds,
|
||||
"remainingDays": remaining_days,
|
||||
"playableSongCount": None,
|
||||
"bound": bool(fresh["bound_client_id"]),
|
||||
"isCurrentClientBound": fresh["bound_client_id"] == client_id,
|
||||
"boundClientLabel": fresh["bound_client_label"],
|
||||
}
|
||||
|
||||
def list_tokens(self, include_revoked: bool = False) -> list[dict]:
|
||||
sql = """
|
||||
select token_id, label, issued_at, expires_at, bound_client_id, bound_client_label, bound_at, last_seen_at, revoked_at, revoked_reason
|
||||
from access_tokens
|
||||
"""
|
||||
if not include_revoked:
|
||||
sql += " where revoked_at is null"
|
||||
sql += " order by issued_at desc"
|
||||
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
rows = conn.execute(sql).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def unbind_token(self, token_id: str) -> None:
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
update access_tokens
|
||||
set bound_client_id = null, bound_client_label = null, bound_at = null
|
||||
where token_id = ?
|
||||
""",
|
||||
(token_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def revoke_token(self, token_id: str, reason: str | None = None) -> None:
|
||||
revoked_at = datetime.now(timezone.utc).isoformat()
|
||||
with closing(connect_sqlite(self._db_path)) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
update access_tokens
|
||||
set revoked_at = ?, revoked_reason = ?
|
||||
where token_id = ?
|
||||
""",
|
||||
(revoked_at, reason, token_id),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -0,0 +1,89 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
normalized = raw.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
access_token: str
|
||||
catalog_db_path: str
|
||||
player_db_path: str
|
||||
local_library_root: str | None
|
||||
disable_auth: bool
|
||||
cache_relay_enabled: bool
|
||||
admin_username: str
|
||||
admin_password_hash: str
|
||||
secret_encryption_key: str
|
||||
cache_reconcile_interval_seconds: int
|
||||
musicfree_version_json_path: str
|
||||
musicfree_apk_path: str
|
||||
|
||||
|
||||
def _default_admin_password_hash() -> str:
|
||||
return f"sha256${hashlib.sha256('admin'.encode('utf-8')).hexdigest()}"
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
try:
|
||||
return int(raw.strip())
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _default_musicfree_release_dir() -> Path:
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
sibling_release_dir = project_root.parent / "MusicFree" / "release"
|
||||
if sibling_release_dir.is_dir():
|
||||
return sibling_release_dir
|
||||
return project_root / "release"
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
musicfree_release_dir = Path(
|
||||
os.getenv("MUSICFREE_RELEASE_DIR", str(_default_musicfree_release_dir()))
|
||||
)
|
||||
return Settings(
|
||||
access_token=os.getenv("PUBLIC_MUSIC_ACCESS_TOKEN", "dev-token"),
|
||||
catalog_db_path=os.getenv("CATALOG_DB_PATH", "./data/catalog_read.db"),
|
||||
player_db_path=os.getenv("PLAYER_DB_PATH", "./data/player.db"),
|
||||
local_library_root=os.getenv("LOCAL_LIBRARY_ROOT"),
|
||||
disable_auth=_env_bool("MUSIC_SERVER_DISABLE_AUTH", default=False),
|
||||
cache_relay_enabled=_env_bool("MUSIC_SERVER_CACHE_RELAY_ENABLED", default=True),
|
||||
admin_username=os.getenv("MUSIC_SERVER_ADMIN_USERNAME", "admin"),
|
||||
admin_password_hash=os.getenv(
|
||||
"MUSIC_SERVER_ADMIN_PASSWORD_HASH",
|
||||
_default_admin_password_hash(),
|
||||
),
|
||||
secret_encryption_key=os.getenv(
|
||||
"MUSIC_SERVER_SECRET_ENCRYPTION_KEY",
|
||||
"dev-secret-encryption-key",
|
||||
),
|
||||
cache_reconcile_interval_seconds=_env_int(
|
||||
"MUSIC_SERVER_CACHE_RECONCILE_INTERVAL_SECONDS",
|
||||
600,
|
||||
),
|
||||
musicfree_version_json_path=os.getenv(
|
||||
"MUSICFREE_VERSION_JSON",
|
||||
str(musicfree_release_dir / "version.json"),
|
||||
),
|
||||
musicfree_apk_path=os.getenv(
|
||||
"MUSICFREE_APK_PATH",
|
||||
str(musicfree_release_dir / "MusicFree_latest_release_universal.apk"),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Command-line tools for Music_Server."""
|
||||
@@ -0,0 +1,12 @@
|
||||
import argparse
|
||||
|
||||
from ..services.token_service import TokenService
|
||||
from ..settings import get_settings
|
||||
|
||||
|
||||
def token_service_from_settings() -> TokenService:
|
||||
return TokenService(db_path=get_settings().player_db_path)
|
||||
|
||||
|
||||
def build_parser(prog: str, description: str) -> argparse.ArgumentParser:
|
||||
return argparse.ArgumentParser(prog=prog, description=description)
|
||||
@@ -0,0 +1,17 @@
|
||||
from ._common import build_parser, token_service_from_settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = build_parser("issue_token", "Issue a new Music_Server access token")
|
||||
parser.add_argument("--days", type=int, default=90)
|
||||
parser.add_argument("--label", default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
issued = token_service_from_settings().issue_token(days=args.days, label=args.label)
|
||||
print(f"token_id={issued.token_id}")
|
||||
print(f"token={issued.plaintext_token}")
|
||||
print(f"expires_at={issued.expires_at}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,26 @@
|
||||
from ._common import build_parser, token_service_from_settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = build_parser("list_tokens", "List Music_Server access tokens")
|
||||
parser.add_argument("--include-revoked", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
for row in token_service_from_settings().list_tokens(include_revoked=args.include_revoked):
|
||||
print(
|
||||
"|".join(
|
||||
[
|
||||
row["token_id"],
|
||||
row.get("label") or "",
|
||||
row["expires_at"],
|
||||
row.get("bound_client_id") or "",
|
||||
row.get("bound_client_label") or "",
|
||||
row.get("last_seen_at") or "",
|
||||
row.get("revoked_at") or "",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,15 @@
|
||||
from ._common import build_parser, token_service_from_settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = build_parser("revoke_token", "Revoke a Music_Server token")
|
||||
parser.add_argument("--token-id", required=True)
|
||||
parser.add_argument("--reason", default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
token_service_from_settings().revoke_token(args.token_id, reason=args.reason)
|
||||
print(f"revoked={args.token_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,14 @@
|
||||
from ._common import build_parser, token_service_from_settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = build_parser("unbind_token", "Unbind a Music_Server token")
|
||||
parser.add_argument("--token-id", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
token_service_from_settings().unbind_token(args.token_id)
|
||||
print(f"unbound={args.token_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user