mirror of
https://github.com/wizarrrr/wizarr.git
synced 2026-08-02 08:17:23 -04:00
613 lines
23 KiB
Python
613 lines
23 KiB
Python
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import logging
|
||
import random
|
||
import string
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
import structlog
|
||
from sqlalchemy import or_
|
||
|
||
from app.extensions import db
|
||
from app.models import Invitation, User
|
||
from app.services.invites import is_invite_valid
|
||
from app.services.media.utils import StandardizedPermissions
|
||
|
||
from .client_base import RestApiMixin, register_media_client
|
||
|
||
if TYPE_CHECKING:
|
||
from app.services.media.user_details import MediaUserDetails
|
||
|
||
|
||
@register_media_client("navidrome")
|
||
class NavidromeClient(RestApiMixin):
|
||
"""Navidrome wrapper using the Subsonic API."""
|
||
|
||
#: API prefix for Subsonic/OpenSubsonic endpoints
|
||
API_PREFIX = "/rest"
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
# Provide defaults for legacy compatibility
|
||
kwargs.setdefault("url_key", "server_url")
|
||
kwargs.setdefault("token_key", "api_key")
|
||
|
||
super().__init__(*args, **kwargs)
|
||
|
||
# Normalize URL
|
||
if self.url:
|
||
self.url = self.url.rstrip("/")
|
||
|
||
def _generate_auth_params(self) -> dict[str, str]:
|
||
"""Generate authentication parameters for Subsonic API calls.
|
||
|
||
Uses the recommended salted hash method for security.
|
||
"""
|
||
if not self.token:
|
||
raise ValueError("API token (password) is required for Navidrome")
|
||
|
||
# Generate random salt
|
||
salt = "".join(random.choices(string.ascii_letters + string.digits, k=6))
|
||
|
||
# Create MD5 hash of password + salt
|
||
token_hash = hashlib.md5((self.token + salt).encode()).hexdigest() # noqa: S324 # Required by Subsonic API specification
|
||
|
||
return {
|
||
"u": "admin", # Default username for API access
|
||
"t": token_hash,
|
||
"s": salt,
|
||
"v": "1.16.1", # Supported API version
|
||
"c": "wizarr", # Client identifier
|
||
"f": "json", # Response format
|
||
}
|
||
|
||
def _subsonic_request(self, endpoint: str, params: dict | None = None) -> dict:
|
||
"""Make a request to the Subsonic API with proper authentication."""
|
||
auth_params = self._generate_auth_params()
|
||
if params:
|
||
auth_params.update(params)
|
||
|
||
response = self.get(f"{self.API_PREFIX}/{endpoint}", params=auth_params)
|
||
response.raise_for_status()
|
||
|
||
data = response.json()
|
||
|
||
# Check for Subsonic API errors
|
||
subsonic_response = data.get("subsonic-response", {})
|
||
if subsonic_response.get("status") != "ok":
|
||
error = subsonic_response.get("error", {})
|
||
raise Exception(
|
||
f"Subsonic API error: {error.get('message', 'Unknown error')}"
|
||
)
|
||
|
||
return subsonic_response
|
||
|
||
def validate_connection(self) -> tuple[bool, str]:
|
||
"""Validate connection to Navidrome server."""
|
||
try:
|
||
# Test connection with ping endpoint
|
||
result = self._subsonic_request("ping")
|
||
if result.get("status") == "ok":
|
||
return True, "Connection successful"
|
||
return False, "Server did not respond properly"
|
||
except Exception as exc:
|
||
logging.error("Navidrome: connection validation failed – %s", exc)
|
||
return False, f"Connection failed: {exc!s}"
|
||
|
||
def libraries(self) -> dict[str, str]:
|
||
"""Return mapping of library_id → display_name."""
|
||
try:
|
||
result = self._subsonic_request("getMusicFolders")
|
||
folders = result.get("musicFolders", {}).get("musicFolder", [])
|
||
|
||
# Handle single folder response (not in array)
|
||
if isinstance(folders, dict):
|
||
folders = [folders]
|
||
|
||
return {str(folder["id"]): folder["name"] for folder in folders}
|
||
except Exception as exc:
|
||
logging.warning("Navidrome: failed to fetch libraries – %s", exc)
|
||
return {}
|
||
|
||
def scan_libraries(
|
||
self, url: str | None = None, token: str | None = None
|
||
) -> dict[str, str]:
|
||
"""Scan available libraries on this Navidrome server."""
|
||
try:
|
||
if url and token:
|
||
# Create temporary client for scanning
|
||
temp_client = NavidromeClient()
|
||
temp_client.url = url.rstrip("/")
|
||
temp_client.token = token
|
||
return {
|
||
name: lib_id for lib_id, name in temp_client.libraries().items()
|
||
}
|
||
# Use saved credentials
|
||
return {name: lib_id for lib_id, name in self.libraries().items()}
|
||
except Exception as exc:
|
||
logging.warning("Navidrome: failed to scan libraries – %s", exc)
|
||
return {}
|
||
|
||
def list_users(self) -> list[User]:
|
||
"""Read users from Navidrome and reflect them locally."""
|
||
server_id = getattr(self, "server_id", None)
|
||
if server_id is None:
|
||
return []
|
||
|
||
try:
|
||
result = self._subsonic_request("getUsers")
|
||
users_data = result.get("users", {}).get("user", [])
|
||
|
||
# Handle single user response (not in array)
|
||
if isinstance(users_data, dict):
|
||
users_data = [users_data]
|
||
except Exception as exc:
|
||
logging.warning("Navidrome: failed to list users – %s", exc)
|
||
return []
|
||
|
||
users_by_name = {u["username"]: u for u in users_data}
|
||
|
||
try:
|
||
# Add new users or update existing ones
|
||
for username, remote in users_by_name.items():
|
||
db_row = User.query.filter_by(
|
||
username=username, server_id=server_id
|
||
).first()
|
||
if not db_row:
|
||
db_row = User(
|
||
token=username, # Use username as token for Navidrome
|
||
username=username,
|
||
email=remote.get("email", ""),
|
||
code="empty",
|
||
server_id=server_id,
|
||
)
|
||
db.session.add(db_row)
|
||
else:
|
||
db_row.email = remote.get("email", db_row.email)
|
||
|
||
# Remove users that no longer exist upstream
|
||
to_check = User.query.filter(User.server_id == server_id).all()
|
||
for local in to_check:
|
||
if local.username not in users_by_name:
|
||
db.session.delete(local)
|
||
|
||
db.session.commit()
|
||
|
||
except Exception as exc:
|
||
logging.error("Navidrome: failed to sync users – %s", exc)
|
||
db.session.rollback()
|
||
return []
|
||
|
||
# Get users with policy information
|
||
users = User.query.filter(User.server_id == server_id).all()
|
||
|
||
# Enhance users with policy data from Navidrome
|
||
for user in users:
|
||
if user.username in users_by_name:
|
||
navidrome_user = users_by_name[user.username]
|
||
|
||
# Use standardized permissions helper for consistency
|
||
permissions = StandardizedPermissions.for_navidrome(navidrome_user)
|
||
|
||
# Update standardized User model columns
|
||
user.allow_downloads = permissions.allow_downloads
|
||
user.allow_live_tv = permissions.allow_live_tv
|
||
user.is_admin = permissions.is_admin
|
||
else:
|
||
# Default values if user data not found - use standardized helper
|
||
default_permissions = StandardizedPermissions.for_basic_server(
|
||
"navidrome"
|
||
)
|
||
|
||
# Update standardized User model columns with defaults
|
||
user.allow_downloads = default_permissions.allow_downloads
|
||
user.allow_live_tv = default_permissions.allow_live_tv
|
||
user.is_admin = default_permissions.is_admin
|
||
|
||
# Single commit for all metadata updates
|
||
try:
|
||
db.session.commit()
|
||
except Exception as e:
|
||
logging.error("Navidrome: failed to update user metadata – %s", e)
|
||
db.session.rollback()
|
||
return []
|
||
return users
|
||
|
||
def create_user(
|
||
self,
|
||
username: str,
|
||
password: str,
|
||
email: str,
|
||
*,
|
||
is_admin: bool = False,
|
||
allow_downloads: bool = True,
|
||
) -> str:
|
||
"""Create a user and return the username (Navidrome uses username as identifier)."""
|
||
try:
|
||
params = {
|
||
"username": username,
|
||
"password": password,
|
||
"email": email,
|
||
"adminRole": "true" if is_admin else "false",
|
||
"downloadRole": "true" if allow_downloads else "false",
|
||
"uploadRole": "false", # Don't allow uploads by default
|
||
"playlistRole": "true", # Allow playlist creation
|
||
"coverArtRole": "false", # Don't allow cover art changes
|
||
"commentRole": "false", # Don't allow comments
|
||
"podcastRole": "false", # Don't allow podcast management
|
||
"streamRole": "true", # Allow streaming
|
||
"jukeboxRole": "false", # Don't allow jukebox mode
|
||
"shareRole": "false", # Don't allow sharing
|
||
}
|
||
|
||
self._subsonic_request("createUser", params)
|
||
return username
|
||
except Exception as exc:
|
||
logging.error("Navidrome: failed to create user – %s", exc)
|
||
raise
|
||
|
||
def update_user(self, username: str, payload: dict[str, Any]):
|
||
"""Update user with given parameters."""
|
||
try:
|
||
params = {"username": username}
|
||
|
||
# Map common payload fields to Subsonic parameters
|
||
if "password" in payload:
|
||
params["password"] = payload["password"]
|
||
if "email" in payload:
|
||
params["email"] = payload["email"]
|
||
if "is_admin" in payload:
|
||
params["adminRole"] = "true" if payload["is_admin"] else "false"
|
||
if "allow_downloads" in payload:
|
||
params["downloadRole"] = (
|
||
"true" if payload["allow_downloads"] else "false"
|
||
)
|
||
|
||
self._subsonic_request("updateUser", params)
|
||
except Exception as exc:
|
||
logging.error("Navidrome: failed to update user %s – %s", username, exc)
|
||
raise
|
||
|
||
def enable_user(self, user_id: str) -> bool: # noqa: ARG002
|
||
"""Enable a user account on Navidrome.
|
||
|
||
Args:
|
||
user_id: The user's Navidrome ID (unused - Navidrome doesn't support enable/disable)
|
||
|
||
Returns:
|
||
bool: True if the user was successfully enabled, False otherwise
|
||
"""
|
||
try:
|
||
# Navidrome doesn't have a direct enable feature
|
||
# Return False to indicate this operation is not supported
|
||
structlog.get_logger().warning(
|
||
"Navidrome does not support enabling users. They need to be given library access."
|
||
)
|
||
return False
|
||
except Exception as e:
|
||
structlog.get_logger().error(f"Failed to enable Navidrome user: {e}")
|
||
return False
|
||
|
||
def disable_user(self, user_id: str) -> bool: # noqa: ARG002
|
||
"""Disable a user account on Navidrome.
|
||
|
||
Args:
|
||
user_id: The user's Navidrome usernameFailed to disable
|
||
|
||
Returns:
|
||
bool: True if the user was successfully disabled, False otherwise
|
||
"""
|
||
try:
|
||
# Navidrome doesn't have a disable feature, we can only delete users
|
||
# Return False to indicate this operation is not supported
|
||
structlog.get_logger().warning(
|
||
"Navidrome does not support disabling users, only deletion"
|
||
)
|
||
return False
|
||
except Exception as e:
|
||
structlog.get_logger().error(f"Failed to disable Navidrome user: {e}")
|
||
return False
|
||
|
||
def delete_user(self, username: str):
|
||
"""Delete a user permanently from Navidrome."""
|
||
try:
|
||
self._subsonic_request("deleteUser", {"username": username})
|
||
except Exception as exc:
|
||
logging.error("Navidrome: failed to delete user %s – %s", username, exc)
|
||
raise
|
||
|
||
def get_user(self, username: str):
|
||
"""Get user info in legacy format for backward compatibility."""
|
||
details = self.get_user_details(username)
|
||
return {
|
||
"username": details.username,
|
||
"email": details.email,
|
||
"isActive": details.is_enabled,
|
||
"createdAt": int(details.created_at.timestamp() * 1000)
|
||
if details.created_at
|
||
else None,
|
||
"lastSeen": int(details.last_active.timestamp() * 1000)
|
||
if details.last_active
|
||
else None,
|
||
"permissions": {"admin": details.is_admin},
|
||
}
|
||
|
||
def get_user_details(self, user_identifier: str | int) -> MediaUserDetails:
|
||
"""Get detailed user information in standardized format."""
|
||
username = str(user_identifier)
|
||
from app.services.media.utils import (
|
||
LibraryAccessHelper,
|
||
StandardizedPermissions,
|
||
create_standardized_user_details,
|
||
)
|
||
|
||
# Get raw user data from Navidrome API
|
||
result = self._subsonic_request("getUser", {"username": username})
|
||
raw_user = result.get("user", {})
|
||
|
||
# Extract standardized permissions using shared utility
|
||
permissions = StandardizedPermissions.for_navidrome(raw_user)
|
||
|
||
# Navidrome gives full access to all libraries
|
||
library_access = LibraryAccessHelper.create_full_access()
|
||
|
||
return create_standardized_user_details(
|
||
user_id=username,
|
||
username=raw_user.get("username", username),
|
||
email=raw_user.get("email"),
|
||
permissions=permissions,
|
||
library_access=library_access,
|
||
created_at=None, # Navidrome doesn't expose creation date
|
||
last_active=None, # Navidrome doesn't expose last seen
|
||
is_enabled=True, # Navidrome doesn't have disabled users concept
|
||
)
|
||
|
||
def _do_join(
|
||
self,
|
||
username: str,
|
||
password: str,
|
||
confirm: str,
|
||
email: str,
|
||
code: str,
|
||
):
|
||
"""Public invite flow for Navidrome users."""
|
||
if not 1 <= len(username) <= 50:
|
||
return False, "Username must be 1-50 characters."
|
||
if not 8 <= len(password) <= 128:
|
||
return False, "Password must be 8-128 characters."
|
||
if password != confirm:
|
||
return False, "Passwords do not match."
|
||
|
||
ok, msg = is_invite_valid(code)
|
||
if not ok:
|
||
return False, msg
|
||
|
||
server_id = getattr(self, "server_id", None)
|
||
if server_id is None:
|
||
return False, "Server configuration error"
|
||
|
||
existing = User.query.filter(
|
||
or_(User.username == username, User.email == email),
|
||
User.server_id == server_id,
|
||
).first()
|
||
if existing:
|
||
return False, "User or e-mail already exists."
|
||
|
||
try:
|
||
inv = Invitation.query.filter_by(code=code).first()
|
||
|
||
# Get download permission from invitation
|
||
allow_downloads = getattr(inv, "allow_downloads", True)
|
||
if allow_downloads is None:
|
||
allow_downloads = True
|
||
|
||
# Create user on Navidrome
|
||
user_id = self.create_user(
|
||
username, password, email=email, allow_downloads=allow_downloads
|
||
)
|
||
if not user_id:
|
||
return False, "Failed to create user on server"
|
||
|
||
# Calculate expiry
|
||
from app.services.expiry import calculate_user_expiry
|
||
|
||
expires = (
|
||
calculate_user_expiry(inv, getattr(self, "server_id", None))
|
||
if inv
|
||
else None
|
||
)
|
||
|
||
# Store locally
|
||
local = User(
|
||
token=user_id,
|
||
username=username,
|
||
email=email,
|
||
code=code,
|
||
expires=expires,
|
||
server_id=server_id,
|
||
)
|
||
db.session.add(local)
|
||
db.session.commit()
|
||
|
||
return True, ""
|
||
|
||
except Exception as exc:
|
||
logging.error("Navidrome join failed: %s", exc)
|
||
db.session.rollback()
|
||
return False, "Failed to create user"
|
||
|
||
def now_playing(self) -> list[dict]:
|
||
"""Return active sessions from Navidrome."""
|
||
try:
|
||
result = self._subsonic_request("getNowPlaying")
|
||
entries = result.get("nowPlaying", {}).get("entry", [])
|
||
|
||
# Handle single entry response (not in array)
|
||
if isinstance(entries, dict):
|
||
entries = [entries]
|
||
|
||
active = []
|
||
for entry in entries:
|
||
# Extract session information
|
||
session_data = {
|
||
"user_name": entry.get("username", "Unknown"),
|
||
"media_title": entry.get("title", "Unknown Track"),
|
||
"media_type": "track",
|
||
"progress": 0.0, # Navidrome doesn't provide progress in now playing
|
||
"state": "playing",
|
||
"session_id": entry.get("id", ""),
|
||
"client": entry.get("playerName", "Unknown"),
|
||
"device_name": entry.get("playerName", "Unknown Device"),
|
||
"position_ms": 0,
|
||
"duration_ms": (entry.get("duration", 0) * 1000)
|
||
if entry.get("duration")
|
||
else 0,
|
||
"artwork_url": f"{self.url}{self.API_PREFIX}/getCoverArt?id={entry.get('coverArt', '')}&"
|
||
+ "&".join(
|
||
f"{k}={v}" for k, v in self._generate_auth_params().items()
|
||
)
|
||
if entry.get("coverArt")
|
||
else None,
|
||
"thumbnail_url": None,
|
||
"transcoding": {"is_transcoding": False, "direct_play": True},
|
||
}
|
||
|
||
# Add artist and album info if available
|
||
if entry.get("artist"):
|
||
session_data["artist"] = entry["artist"]
|
||
if entry.get("album"):
|
||
session_data["album"] = entry["album"]
|
||
|
||
active.append(session_data)
|
||
|
||
return active
|
||
except Exception as exc:
|
||
logging.error("Navidrome: failed to fetch now playing – %s", exc)
|
||
return []
|
||
|
||
def statistics(self):
|
||
"""Return server statistics for Navidrome."""
|
||
try:
|
||
stats = {
|
||
"library_stats": {},
|
||
"user_stats": {},
|
||
"server_stats": {},
|
||
"content_stats": {},
|
||
}
|
||
|
||
# Get user count
|
||
try:
|
||
result = self._subsonic_request("getUsers")
|
||
users_data = result.get("users", {}).get("user", [])
|
||
if isinstance(users_data, dict):
|
||
users_data = [users_data]
|
||
total_users = len(users_data)
|
||
except Exception as exc:
|
||
logging.error("Navidrome statistics: failed to fetch users – %s", exc)
|
||
total_users = 0
|
||
|
||
# Get active sessions
|
||
try:
|
||
active_sessions = self.now_playing()
|
||
except Exception as exc:
|
||
logging.error(
|
||
"Navidrome statistics: failed to calculate active sessions – %s",
|
||
exc,
|
||
)
|
||
active_sessions = []
|
||
|
||
stats["user_stats"] = {
|
||
"total_users": total_users,
|
||
"active_sessions": len(active_sessions),
|
||
}
|
||
|
||
stats["server_stats"] = {
|
||
"version": "Unknown", # Navidrome doesn't expose version in Subsonic API
|
||
"transcoding_sessions": 0, # Not applicable for music streaming
|
||
}
|
||
|
||
return stats
|
||
|
||
except Exception as e:
|
||
logging.error(f"Failed to get Navidrome statistics: {e}")
|
||
return {
|
||
"library_stats": {},
|
||
"user_stats": {},
|
||
"server_stats": {},
|
||
"content_stats": {},
|
||
"error": str(e),
|
||
}
|
||
|
||
def get_user_count(self) -> int:
|
||
"""Get lightweight user count from database without triggering sync."""
|
||
try:
|
||
from app.models import MediaServer, User
|
||
|
||
if hasattr(self, "server_id") and self.server_id:
|
||
count = User.query.filter_by(server_id=self.server_id).count()
|
||
else:
|
||
# Fallback for legacy settings: find MediaServer for this server type
|
||
servers = MediaServer.query.filter_by(server_type="navidrome").all()
|
||
if servers:
|
||
server_ids = [s.id for s in servers]
|
||
count = User.query.filter(User.server_id.in_(server_ids)).count()
|
||
else:
|
||
# Ultimate fallback: no easy API for user count in Navidrome
|
||
count = 0
|
||
return count
|
||
except Exception as e:
|
||
logging.error(f"Failed to get Navidrome user count from database: {e}")
|
||
return 0
|
||
|
||
def get_server_info(self) -> dict:
|
||
"""Get lightweight server information without triggering user sync."""
|
||
try:
|
||
# Navidrome doesn't have traditional sessions/transcoding
|
||
return {
|
||
"version": "Unknown", # Would need API call to get version
|
||
"transcoding_sessions": 0, # Navidrome doesn't transcode
|
||
"active_sessions": 0, # Would need to implement session tracking
|
||
}
|
||
except Exception as e:
|
||
logging.error(f"Failed to get Navidrome server info: {e}")
|
||
return {
|
||
"version": "Unknown",
|
||
"transcoding_sessions": 0,
|
||
"active_sessions": 0,
|
||
}
|
||
|
||
def get_readonly_statistics(self) -> dict:
|
||
"""Get lightweight statistics without triggering user synchronization."""
|
||
try:
|
||
user_count = self.get_user_count()
|
||
server_info = self.get_server_info()
|
||
|
||
return {
|
||
"user_stats": {
|
||
"total_users": user_count,
|
||
"active_sessions": server_info.get("active_sessions", 0),
|
||
},
|
||
"server_stats": {
|
||
"version": server_info.get("version", "Unknown"),
|
||
"transcoding_sessions": server_info.get("transcoding_sessions", 0),
|
||
},
|
||
"library_stats": {},
|
||
"content_stats": {},
|
||
}
|
||
except Exception as e:
|
||
logging.error(f"Failed to get Navidrome readonly statistics: {e}")
|
||
return {
|
||
"user_stats": {"total_users": 0, "active_sessions": 0},
|
||
"server_stats": {"version": "Unknown", "transcoding_sessions": 0},
|
||
"library_stats": {},
|
||
"content_stats": {},
|
||
"error": str(e),
|
||
}
|
||
|
||
def _headers(self) -> dict[str, str]:
|
||
"""Return default headers for requests."""
|
||
return {
|
||
"Accept": "application/json",
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
}
|