Initial import: Music_Server, MusicFree, catalog-sync

This commit is contained in:
2026-05-23 16:51:14 +08:00
commit 069af30dba
847 changed files with 179878 additions and 0 deletions
@@ -0,0 +1 @@
+79
View File
@@ -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
+66
View File
@@ -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,
)
+7
View File
@@ -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()
+89
View File
@@ -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()