Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ RUN pip install --no-cache-dir --prefix=/install -r /tmp/requirements.txt

FROM python:3.13.5-slim

ARG COMMIT_TAG
ARG COMMIT_TAG=dev
ARG BUILD_DATE
ARG DROPPEDNEEDLE_SOURCE_REVISION=unknown

Expand Down
28 changes: 28 additions & 0 deletions backend/api/v1/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
AuthProvidersResponse,
AuthResponse,
CreateUserRequest,
DeviceSessionRequest,
DeviceSessionResponse,
ImportCandidateListResponse,
ImportUsersRequest,
ImportUsersResponse,
Expand Down Expand Up @@ -210,6 +212,32 @@ async def list_sessions(
return SessionListResponse(sessions = [session_to_response(token) for token in tokens])


@router.post("/device-sessions", response_model=DeviceSessionResponse)
async def create_device_session(
current_user: CurrentUserDep,
body: DeviceSessionRequest = MsgSpecBody(DeviceSessionRequest),
auth: AuthService = Depends(get_auth_service),
) -> DeviceSessionResponse:
"""Mint a separate session for a trusted companion such as Apple Watch.

The caller's bearer is never copied. The returned bearer is shown once,
belongs to the same user, and appears independently in Sessions.
"""
try:
auth.validate_device_session_name(body.device_name)
except AuthenticationError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
providers = await auth.get_provider_names_for_users([current_user.id])
try:
token = await auth.issue_device_session(current_user.id, body.device_name)
except AuthenticationError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
return DeviceSessionResponse(
token=token,
user=user_to_response(current_user, providers.get(current_user.id)),
)


@router.delete("/sessions/{session_id}", status_code = status.HTTP_204_NO_CONTENT)
async def revoke_session(
session_id: str,
Expand Down
1 change: 1 addition & 0 deletions backend/api/v1/routes/downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ def _to_response( # noqa: ANN001 - DownloadTask
user_id=task.user_id,
download_type=task.download_type,
source=task.source,
content_variant=task.content_variant,
release_group_mbid=task.release_group_mbid,
release_mbid=task.release_mbid,
release_track_mbid=task.release_track_mbid,
Expand Down
20 changes: 7 additions & 13 deletions backend/api/v1/routes/tracks.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,9 @@
from fastapi import APIRouter, Depends

from api.v1.schemas.download import TrackRequestBody, TrackRequestResponse
from core.dependencies import get_acquisition_dispatcher, get_quota_service
from core.dependencies import get_request_service
from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute
from middleware import CurrentUserDep
from services.native.download_service import ALREADY_IN_LIBRARY

logger = logging.getLogger(__name__)

Expand All @@ -25,24 +24,19 @@ async def request_track(
recording_mbid: str,
current_user: CurrentUserDep,
body: TrackRequestBody = MsgSpecBody(TrackRequestBody),
service=Depends(get_acquisition_dispatcher),
quota=Depends(get_quota_service),
service=Depends(get_request_service),
):
# Track asks bypass the approval queue (existing behaviour) but still count
# toward the rolling request quota (Feature C layer 1, D20) - their download
# task IS the ask, so the gate runs at this submit point.
await quota.check_request_quota(current_user.id, current_user.role)
task_id = await service.request_track(
return await service.request_track(
recording_mbid,
user_id=current_user.id,
recording_mbid=recording_mbid,
user_role=current_user.role,
requested_by_name=current_user.display_name,
artist_name=body.artist_name,
track_title=body.track_title,
album_title=body.album_title,
duration_seconds=body.duration_seconds,
release_group_mbid=body.release_group_mbid,
artist_mbid=body.artist_mbid,
release_mbid=body.release_id,
content_variant=body.content_variant,
)
if task_id == ALREADY_IN_LIBRARY:
return TrackRequestResponse(status="already_in_library")
return TrackRequestResponse(status="queued", task_id=task_id)
11 changes: 11 additions & 0 deletions backend/api/v1/schemas/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ class LoginRequest(AppStruct):
password: str


class DeviceSessionRequest(AppStruct):
"""A caller-authorized, separately revocable session for one companion."""

device_name: str


class PasswordRecoveryResetRequest(AppStruct):
username: str
recovery_code: str
Expand All @@ -58,6 +64,11 @@ class UserResponse(AppStruct):
providers: list[str] = []


class DeviceSessionResponse(AppStruct):
token: str
user: UserResponse


class AuthResponse(AppStruct):
token: str
user: UserResponse
Expand Down
6 changes: 5 additions & 1 deletion backend/api/v1/schemas/download.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Request/response DTOs for the download-client + search + quarantine routes (Phase 6)."""

import msgspec
from typing import Literal

from infrastructure.msgspec_fastapi import AppStruct
from models.common import ServiceStatus
Expand Down Expand Up @@ -131,6 +132,7 @@ class DownloadTaskResponse(AppStruct):
# "soulseek" | "usenet" - drives the source badge + the "via album NZB" label
# (derived as source=="usenet" && download_type=="track").
source: str
content_variant: str
release_group_mbid: str
release_mbid: str | None
release_track_mbid: str | None
Expand Down Expand Up @@ -317,10 +319,12 @@ class TrackRequestBody(AppStruct):
# MB RELEASE mbid (an edition): a SOFT acquisition target (D14) threaded into
# DownloadTask.release_mbid - same value, two names (release_id on the wire).
release_id: str | None = None
# ``clean`` opts into the fail-closed exact-recording verification contract.
content_variant: Literal["original", "clean"] = "original"


class TrackRequestResponse(AppStruct):
status: str # "queued" | "already_in_library"
status: str # "awaiting_approval" | "queued" | "already_in_library"
task_id: str | None = None


Expand Down
3 changes: 3 additions & 0 deletions backend/api/v1/schemas/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ class BatchRequestResponse(AppStruct):
requested: int = 0
skipped: int = 0
overflow: int = 0
# Native clients must render the decision made at this mutation boundary,
# not infer it from a role value that may have changed moments earlier.
status: str = "pending"


class BatchCancelRequest(AppStruct):
Expand Down
8 changes: 8 additions & 0 deletions backend/api/v1/schemas/requests_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ class ActiveRequestItem(AppStruct):
download_client: str | None = None
user_id: str | None = None
requested_by_name: str | None = None
request_kind: str = "album"
track_title: str | None = None
duration_seconds: int | None = None
track_release_group_mbid: str | None = None


class RequestHistoryItem(AppStruct):
Expand All @@ -49,6 +53,10 @@ class RequestHistoryItem(AppStruct):
reviewed_at: datetime | None = None
download_task_id: str | None = None
can_reimport: bool = False
request_kind: str = "album"
track_title: str | None = None
duration_seconds: int | None = None
track_release_group_mbid: str | None = None


class ActiveRequestsResponse(AppStruct):
Expand Down
7 changes: 7 additions & 0 deletions backend/api/v1/schemas/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,10 @@ class ConnectAppsSettings(AppStruct):

subsonic_enabled: bool = False
jellyfin_enabled: bool = False
# Capability negotiation for clients that must distinguish the historical
# exact-track endpoint (which bypassed approval) from the approval-safe
# implementation. Older servers omit this field, so clients fail closed.
exact_track_approval_supported: bool = True
transcoding_enabled: bool = True
transcode_default_format: Literal["mp3", "opus"] = "mp3"
transcode_max_bitrate_kbps: int = 320
Expand All @@ -714,6 +718,9 @@ class ConnectAppsSettings(AppStruct):
discover_mode: Literal["local-only", "lazy-mb", "use-scrobble-targets"] = (
"local-only"
)
# Protocol capability advertised to clients. Older servers omit the field,
# allowing clients to fail closed instead of silently requesting an explicit copy.
clean_content_requests_supported: bool = True

def __post_init__(self) -> None:
if (
Expand Down
13 changes: 12 additions & 1 deletion backend/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Self
import logging
import os
import msgspec
from core.exceptions import ConfigurationError
from infrastructure.file_utils import atomic_write_json, read_json
Expand Down Expand Up @@ -64,6 +65,12 @@ class Settings(BaseSettings):
default="contact@droppedneedle.com",
description="Contact email for MusicBrainz API User-Agent. Override with your own if desired."
)
http_user_agent: str | None = Field(
default=None,
max_length=512,
pattern=r"^[\x20-\x7E]*$",
description="Optional truthful application/version and contact identification for maintained integrations. Does not change provider rate limits.",
)
discover_warmer_enabled: bool = Field(
default=True,
description="Proactively warm per-user Discover/Home in the background through the day (kill switch)."
Expand Down Expand Up @@ -157,8 +164,12 @@ def validate_config(self) -> Self:
return self

def get_user_agent(self) -> str:
if self.http_user_agent and self.http_user_agent.strip():
return self.http_user_agent.strip()
version = os.environ.get("COMMIT_TAG", "").strip() or "dev"
id_part = self.instance_id[:8] if self.instance_id else "unknown"
return f"DroppedNeedle/1.0 ({id_part}; {self.contact_email}; https://www.droppedneedle.com)"
email = (self.contact_email or "").strip() or "contact@droppedneedle.com"
return f"DroppedNeedleApp/{version} ({id_part}; {email}; https://www.droppedneedle.com)"

def load_from_file(self) -> None:
if not self.config_file_path.exists():
Expand Down
52 changes: 51 additions & 1 deletion backend/infrastructure/persistence/auth_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ def _ensure_tables(self) -> None:
expires_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0,
user_agent TEXT
user_agent TEXT,
session_kind TEXT NOT NULL DEFAULT 'standard'
);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user
ON auth_tokens(user_id);
Expand Down Expand Up @@ -161,6 +162,15 @@ def _ensure_tables(self) -> None:
conn.execute("ALTER TABLE auth_oidc_states ADD COLUMN code_verifier TEXT")
except sqlite3.OperationalError:
pass # duplicate column - already present
# Companion sessions must not be inferred from an untrusted HTTP User-Agent.
# Existing rows remain standard because their provenance is ambiguous.
try:
conn.execute(
"ALTER TABLE auth_tokens ADD COLUMN session_kind "
"TEXT NOT NULL DEFAULT 'standard'"
)
except sqlite3.OperationalError:
pass # duplicate column - already present
# Username login (D3): additive, idempotent. `username` is the lowercased
# login identifier; `username_display` preserves preferred casing. The
# partial unique index lets pre-backfill NULL rows coexist.
Expand Down Expand Up @@ -640,6 +650,46 @@ def operation(conn: sqlite3.Connection) -> None:
user_agent = user_agent,
)

async def replace_companion_token(
self,
*,
id: str,
user_id: str,
token_hash: str,
user_agent: str,
) -> TokenRecord:
"""Issue one companion and revoke active same-label companions atomically."""
now = _now_iso()
expiry = _expiry_iso()

def operation(conn: sqlite3.Connection) -> None:
conn.execute(
"""INSERT INTO auth_tokens
(id, user_id, token_hash, issued_at, expires_at, last_seen_at,
revoked, user_agent, session_kind)
VALUES (?, ?, ?, ?, ?, ?, 0, ?, 'companion')""",
(id, user_id, token_hash, now, expiry, now, user_agent),
)
conn.execute(
"""UPDATE auth_tokens SET revoked = 1
WHERE user_id = ? AND user_agent = ? AND id != ?
AND session_kind = 'companion'
AND revoked = 0 AND expires_at > ?""",
(user_id, user_agent, id, now),
)

await self._write(operation)
return TokenRecord(
id=id,
user_id=user_id,
token_hash=token_hash,
issued_at=now,
expires_at=expiry,
last_seen_at=now,
revoked=False,
user_agent=user_agent,
)

async def verify_token(self, raw_token: str) -> TokenRecord | None:
candidate_hash = _hash_token(raw_token)
now = _now_iso()
Expand Down
5 changes: 5 additions & 0 deletions backend/infrastructure/persistence/download_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@
"download_client",
"source",
"origin",
"content_variant",
"source_username",
"source_directory",
"search_query",
Expand Down Expand Up @@ -471,6 +472,7 @@ def _ensure_tables(self) -> None:
-- source. Drives the origin-aware album gate, replace-on-import and
-- cap/quota exemptions (CollectionManagement D18/D19).
origin TEXT NOT NULL DEFAULT 'user',
content_variant TEXT NOT NULL DEFAULT 'original',
source_username TEXT,
source_directory TEXT,
search_query TEXT,
Expand Down Expand Up @@ -550,6 +552,7 @@ def _ensure_tables(self) -> None:
("release_track_mbid", "TEXT"),
("source", "TEXT NOT NULL DEFAULT 'soulseek'"),
("origin", "TEXT NOT NULL DEFAULT 'user'"),
("content_variant", "TEXT NOT NULL DEFAULT 'original'"),
("advertised_queue_depth", "INTEGER"),
("queue_position_start", "INTEGER"),
("queue_position_end", "INTEGER"),
Expand Down Expand Up @@ -665,6 +668,7 @@ async def create_task(
download_client: str = "slskd",
source: str = "soulseek",
origin: str = "user",
content_variant: str = "original",
search_query: str | None = None,
search_job_id: str | None = None,
candidate_index: int | None = None,
Expand Down Expand Up @@ -695,6 +699,7 @@ async def create_task(
download_client=download_client,
source=source,
origin=origin,
content_variant=content_variant,
search_query=search_query,
search_job_id=search_job_id,
candidate_index=candidate_index,
Expand Down
Loading