diff --git a/.gitignore b/.gitignore index f5af43dd..0bd3be77 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ tinker/* static/bootstrap/* static/bootstrap-icons/* docker-compose-custom.yaml +docker/docker-compose.dev-local.yaml local **/package-lock.json diff --git a/backend/beets_flask/bandcamp/__init__.py b/backend/beets_flask/bandcamp/__init__.py new file mode 100644 index 00000000..32d35edf --- /dev/null +++ b/backend/beets_flask/bandcamp/__init__.py @@ -0,0 +1,5 @@ +"""Bandcamp sync integration using bandcampsync package.""" + +from .sync import BandcampSyncManager, get_bandcamp_config + +__all__ = ["BandcampSyncManager", "get_bandcamp_config"] diff --git a/backend/beets_flask/bandcamp/sync.py b/backend/beets_flask/bandcamp/sync.py new file mode 100644 index 00000000..c1d2ab39 --- /dev/null +++ b/backend/beets_flask/bandcamp/sync.py @@ -0,0 +1,310 @@ +"""Bandcamp sync worker and manager. + +This module wraps the bandcampsync package. +Uses Redis pub/sub to stream logs to websocket clients. + +There is only ever one bandcamp sync job at a time (singleton pattern). +""" + +import logging +import os +import tempfile +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Any + +from beets_flask.config import get_config +from beets_flask.logger import log +from beets_flask.redis import redis_conn + + +class SyncStatus(str, Enum): + """Status of a bandcamp sync operation.""" + + PENDING = "pending" + RUNNING = "running" + COMPLETE = "complete" + ERROR = "error" + ABORTED = "aborted" + + +class PubSubLogHandler(logging.Handler): + """Logging handler that publishes log messages via Redis pub/sub.""" + + def __init__(self): + super().__init__() + + def emit(self, record: logging.LogRecord): + try: + from beets_flask.server.websocket.pubsub import publish_bandcamp_log + + msg = self.format(record) + # Always include "running" status so frontend knows sync is still active + publish_bandcamp_log(msg, status="running") + except Exception: + self.handleError(record) + + +@dataclass +class BandcampConfig: + """Configuration for bandcampsync integration.""" + + enabled: bool + path: str + + +def get_bandcamp_config() -> BandcampConfig: + """Get bandcampsync configuration from beets-flask config.""" + config = get_config() + try: + bandcamp_cfg = config["gui"]["bandcampsync"] + return BandcampConfig( + enabled=bandcamp_cfg["enabled"].get(bool), + path=bandcamp_cfg["path"].get(str), + ) + except Exception: + return BandcampConfig(enabled=False, path="/music/bandcamp_inbox") + + +# Redis keys for the singleton sync job +SYNC_STATUS_KEY = "bandcamp:sync:status" +SYNC_ABORT_KEY = "bandcamp:sync:abort" + + +class BandcampSyncManager: + """Manages the singleton bandcamp sync operation.""" + + def __init__(self): + self.redis = redis_conn + + def get_status(self) -> dict[str, Any]: + """Get the current status of the sync operation. + + Returns dict with 'status' key, plus 'error' if status is error. + Logs are streamed via WebSocket, not included here. + """ + from beets_flask.redis import bandcamp_queue + + # Check if there's a job in the queue or currently running + queued_jobs = bandcamp_queue.job_ids + current_job = bandcamp_queue.started_job_registry.get_job_ids() + + if current_job: + status_val = SyncStatus.RUNNING.value + elif queued_jobs: + status_val = SyncStatus.PENDING.value + else: + # Check Redis for last status (for complete/error/aborted) + status = self.redis.get(SYNC_STATUS_KEY) + status_val = ( + status.decode() if isinstance(status, bytes) else (status or "idle") + ) + + result: dict[str, Any] = {"status": status_val} + + # If error, try to get error message + if status_val == SyncStatus.ERROR.value: + error_msg = self.redis.get(f"{SYNC_STATUS_KEY}:error") + if error_msg: + result["error"] = ( + error_msg.decode() if isinstance(error_msg, bytes) else error_msg + ) + + return result + + def is_running(self) -> bool: + """Check if a sync is currently running or pending.""" + status = self.get_status() + return status["status"] in [SyncStatus.PENDING.value, SyncStatus.RUNNING.value] + + def start_sync(self, cookies: str) -> bool: + """Start a new bandcamp sync operation. + + Args: + cookies: Raw cookie string from Bandcamp + + Returns + ------- + True if sync was started, False if one is already running + """ + from beets_flask.redis import bandcamp_queue + + config = get_bandcamp_config() + if not config.enabled: + raise ValueError("Bandcampsync is not enabled in configuration") + + # Check if already running + if self.is_running(): + return False + + # Clear abort flag + self.redis.delete(SYNC_ABORT_KEY) + + # Set status to pending + self.redis.set(SYNC_STATUS_KEY, SyncStatus.PENDING.value) + self.redis.delete(f"{SYNC_STATUS_KEY}:error") + + # Enqueue the sync job + bandcamp_queue.enqueue( + run_bandcamp_sync, + cookies, + config.path, + job_timeout=3600, # 1 hour timeout + ) + + return True + + def abort_sync(self) -> bool: + """Request abort of the current sync operation. + + Returns True if abort was requested, False if no sync is running. + """ + if not self.is_running(): + return False + + self.redis.set(SYNC_ABORT_KEY, "1") + return True + + def is_aborted(self) -> bool: + """Check if abort has been requested.""" + abort_flag = self.redis.get(SYNC_ABORT_KEY) + return abort_flag == b"1" if abort_flag else False + + +def run_bandcamp_sync(cookies: str, download_path: str): + """Worker function to run bandcamp sync. + + This function is executed in an RQ worker process. + Uses Redis pub/sub to stream logs to websocket clients. + """ + from beets_flask.server.websocket.pubsub import publish_bandcamp_log + + # Monkey-patch requests to use curl_cffi for browser-like TLS fingerprint. + # This is needed because Alpine Linux's musl-based OpenSSL produces a TLS + # fingerprint that Bandcamp's bot detection blocks with 403. + try: + from curl_cffi import requests as cffi_requests + + class CffiSessionAdapter: + """Adapter that wraps curl_cffi to match requests.Session interface.""" + + def __init__(self): + self._cookies = {} + + @property + def cookies(self): + return self + + def set(self, name, value): + self._cookies[name] = value + + def request( + self, method, url, headers=None, cookies=None, data=None, json=None + ): + # Merge cookies + all_cookies = {**self._cookies} + if cookies: + all_cookies.update(cookies) + + return cffi_requests.request( + method, + url, + headers=headers, + cookies=all_cookies, + data=data, + json=json, + impersonate="chrome", + ) + + def get(self, url, **kwargs): + return self.request("GET", url, **kwargs) + + def post(self, url, **kwargs): + return self.request("POST", url, **kwargs) + + # Patch the requests module - bandcampsync already imported it, + # but this will affect future Session() instantiations + import requests + + requests.Session = CffiSessionAdapter + log.info("Patched requests.Session with curl_cffi for browser TLS fingerprint") + except ImportError: + log.warning("curl_cffi not available, using standard requests (may get 403)") + + redis_client = redis_conn + manager = BandcampSyncManager() + + log.info("Importing bandcampsync...") + from bandcampsync import do_sync + + log.info("bandcampsync imported") + + # Update status to running and notify clients + redis_client.set(SYNC_STATUS_KEY, SyncStatus.RUNNING.value) + publish_bandcamp_log("Starting sync...", status=SyncStatus.RUNNING.value) + + # Set up log handler to stream bandcampsync logs via pub/sub + handler = PubSubLogHandler() + handler.setFormatter(logging.Formatter("%(name)s [%(levelname)s] %(message)s")) + + # Attach handler to bandcampsync loggers + bandcamp_loggers = ["sync", "bandcamp", "download", "media", "ignores", "notify"] + for logger_name in bandcamp_loggers: + logger = logging.getLogger(logger_name) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + logger.propagate = False + + try: + # Ensure download path exists + os.makedirs(download_path, exist_ok=True) + + # Create a temporary file for cookies + with tempfile.NamedTemporaryFile( + mode="w", suffix=".txt", delete=False + ) as cookie_file: + cookie_file.write(cookies) + cookie_path = cookie_file.name + + try: + # Run the sync + # TODO: subprocess so that abort can actually work. + do_sync( + None, # cookies_path + cookies, # cookies + Path(download_path), # dir_path (must be Path object) + "flac", # media_format + None, # temp_dir_root + "", # ign_patterns (empty string, not None) + None, # notify_url + ) + + # Check if aborted during execution + if manager.is_aborted(): + redis_client.set(SYNC_STATUS_KEY, SyncStatus.ABORTED.value) + publish_bandcamp_log( + "Sync aborted by user", status=SyncStatus.ABORTED.value + ) + else: + redis_client.set(SYNC_STATUS_KEY, SyncStatus.COMPLETE.value) + publish_bandcamp_log( + "Sync completed successfully", status=SyncStatus.COMPLETE.value + ) + finally: + # Clean up temp cookie file + if os.path.exists(cookie_path): + os.unlink(cookie_path) + + except Exception as e: + log.exception(f"Bandcamp sync failed: {e}") + redis_client.set(SYNC_STATUS_KEY, SyncStatus.ERROR.value) + redis_client.set(f"{SYNC_STATUS_KEY}:error", str(e)) + publish_bandcamp_log(f"ERROR: {e}", status=SyncStatus.ERROR.value) + + finally: + # Remove handlers to avoid issues on subsequent runs + for logger_name in bandcamp_loggers: + logger = logging.getLogger(logger_name) + logger.removeHandler(handler) + logger.propagate = True diff --git a/backend/beets_flask/config/config_bf_default.yaml b/backend/beets_flask/config/config_bf_default.yaml index aae84399..381da75c 100644 --- a/backend/beets_flask/config/config_bf_default.yaml +++ b/backend/beets_flask/config/config_bf_default.yaml @@ -20,3 +20,8 @@ gui: debounce_before_autotag: 30 concat_nested_folders: yes expand_files: no + + # Optional bandcampsync integration for downloading Bandcamp purchases + bandcampsync: + enabled: false + path: "/music/bandcamp_inbox" diff --git a/backend/beets_flask/config/config_bf_example.yaml b/backend/beets_flask/config/config_bf_example.yaml index be92d1ac..f5014922 100644 --- a/backend/beets_flask/config/config_bf_example.yaml +++ b/backend/beets_flask/config/config_bf_example.yaml @@ -43,3 +43,9 @@ gui: path: "/music/inbox_preview" autotag: "preview" # trigger tag but do not import, recommended for most control + + # Optional bandcampsync integration for downloading Bandcamp purchases + # Set enabled to true and provide your Bandcamp cookies to use this feature + # bandcampsync: + # enabled: true + # path: "/music/bandcamp_inbox" # directory for Bandcamp downloads diff --git a/backend/beets_flask/redis.py b/backend/beets_flask/redis.py index 1f368ca8..b4c96eea 100644 --- a/backend/beets_flask/redis.py +++ b/backend/beets_flask/redis.py @@ -12,9 +12,10 @@ # Init our different queues preview_queue = Queue("preview", connection=redis_conn, default_timeout=600) import_queue = Queue("import", connection=redis_conn, default_timeout=600) +bandcamp_queue = Queue("bandcamp", connection=redis_conn, default_timeout=3600) -queues = [preview_queue, import_queue] +queues = [preview_queue, import_queue, bandcamp_queue] async def wait_for_job_results( @@ -65,6 +66,7 @@ async def wait_for_job_results( "queues", "import_queue", "preview_queue", + "bandcamp_queue", "redis_conn", "wait_for_job_results", ] diff --git a/backend/beets_flask/server/routes/__init__.py b/backend/beets_flask/server/routes/__init__.py index f0843e2c..30585f95 100644 --- a/backend/beets_flask/server/routes/__init__.py +++ b/backend/beets_flask/server/routes/__init__.py @@ -1,6 +1,7 @@ from quart import Blueprint, Quart from .art_preview import art_blueprint +from .bandcamp import bandcamp_bp from .config import config_bp from .db_models import register_state_models from .exception import error_bp @@ -13,6 +14,7 @@ # Register all backend blueprints backend_bp.register_blueprint(art_blueprint) +backend_bp.register_blueprint(bandcamp_bp) backend_bp.register_blueprint(config_bp) backend_bp.register_blueprint(error_bp) backend_bp.register_blueprint(frontend_bp) diff --git a/backend/beets_flask/server/routes/bandcamp.py b/backend/beets_flask/server/routes/bandcamp.py new file mode 100644 index 00000000..1ca6a75d --- /dev/null +++ b/backend/beets_flask/server/routes/bandcamp.py @@ -0,0 +1,133 @@ +"""Bandcamp sync API routes. + +Provides endpoints for: +- GET /bandcamp/status - Get current sync status and logs +- POST /bandcamp/sync - Start a new sync operation +- DELETE /bandcamp/sync - Abort current sync operation +- GET /bandcamp/config - Get bandcampsync configuration + +Progress logs are stored in Redis and can be polled via the status endpoint. +""" + +from http.cookies import SimpleCookie + +from quart import Blueprint, jsonify, request + +from beets_flask.bandcamp import BandcampSyncManager, get_bandcamp_config +from beets_flask.logger import log +from beets_flask.server.exceptions import InvalidUsageException + +bandcamp_bp = Blueprint("bandcamp", __name__, url_prefix="/bandcamp") + + +@bandcamp_bp.route("/config", methods=["GET"]) +async def get_config_endpoint(): + """Get bandcampsync configuration.""" + config = get_bandcamp_config() + return jsonify( + { + "enabled": config.enabled, + "path": config.path, + } + ) + + +@bandcamp_bp.route("/status", methods=["GET"]) +async def get_status(): + """Get current sync status. + + Returns + ------- + { + "status": "idle" | "pending" | "running" | "complete" | "error" | "aborted", + "error": "string" // Only present if status is "error" + } + + Note: Logs are streamed via WebSocket (bandcamp_sync_update event). + """ + manager = BandcampSyncManager() + status = manager.get_status() + return jsonify(status) + + +@bandcamp_bp.route("/sync", methods=["POST"]) +async def start_sync(): + """Start a new bandcamp sync operation. + + Request body: + { + "cookies": "string" // Raw cookie string from Bandcamp + } + + Returns + ------- + { + "started": true // If sync was started + } + + Returns 409 Conflict if a sync is already running. + """ + config = get_bandcamp_config() + if not config.enabled: + raise InvalidUsageException( + "Bandcampsync is not enabled. Enable it in your beets-flask config." + ) + + data = await request.get_json() + if not data or "cookies" not in data: + raise InvalidUsageException("Missing 'cookies' in request body") + + cookies = data["cookies"] + if not isinstance(cookies, str) or not cookies.strip(): + raise InvalidUsageException("'cookies' must be a non-empty string") + + # Clean up cookie string - strip "Cookie:" prefix if present + cookies = cookies.strip() + if cookies.lower().startswith("cookie:"): + cookies = cookies[7:].lstrip() + + # Validate that the cookie string can be parsed and contains required cookies + test_cookie = SimpleCookie() + test_cookie.load(cookies) + + if not test_cookie: + raise InvalidUsageException( + "Could not parse cookie string. Make sure you copied just the cookie value " + "(e.g., 'identity=XXX; client_id=YYY'), not the header name." + ) + + if "identity" not in test_cookie: + raise InvalidUsageException( + "Cookie string is missing the required 'identity' cookie. " + "Make sure you are logged in to Bandcamp and copied all cookies." + ) + + log.debug(f"Bandcamp sync: cookie length={len(cookies)}, parsed {len(test_cookie)} cookies") + + manager = BandcampSyncManager() + started = manager.start_sync(cookies) + + if not started: + return jsonify({"started": False, "message": "A sync is already running"}), 409 + + log.info("Started bandcamp sync") + + return jsonify({"started": True}), 202 + + +@bandcamp_bp.route("/sync", methods=["DELETE"]) +async def abort_sync(): + """Abort the current sync operation. + + Note: The abort is best-effort. The sync may continue for a short + time after the abort is requested, but no more progress will be + published and the final status will be 'aborted'. + """ + manager = BandcampSyncManager() + + if not manager.abort_sync(): + return jsonify({"aborted": False, "message": "No sync is currently running"}), 404 + + log.info("Abort requested for bandcamp sync") + + return jsonify({"aborted": True}) diff --git a/backend/beets_flask/server/websocket/pubsub.py b/backend/beets_flask/server/websocket/pubsub.py new file mode 100644 index 00000000..35792c90 --- /dev/null +++ b/backend/beets_flask/server/websocket/pubsub.py @@ -0,0 +1,79 @@ +"""Simple Redis pub/sub for streaming messages from RQ workers to websocket clients. + +Workers call `publish()` to send messages. +The server runs `subscriber_task()` to forward messages to connected clients. +""" + +import json +from typing import Any + +from beets_flask.logger import log +from beets_flask.redis import redis_conn + +# Channel name for bandcamp sync logs +BANDCAMP_CHANNEL = "bandcamp:logs" + + +def publish(channel: str, data: dict[str, Any]): + """Publish a message to a Redis channel. Call this from RQ workers. + + This is synchronous and safe to call from worker processes. + """ + message = json.dumps(data) + log.debug(f"Publishing to {channel}: {message}") + redis_conn.publish(channel, message) + + +def publish_bandcamp_log(message: str, status: str | None = None): + """Convenience function to publish a bandcamp sync log message.""" + data: dict[str, Any] = {"logs": [message]} + if status: + data["status"] = status + log.debug(f"publish_bandcamp_log: {data}") + publish(BANDCAMP_CHANNEL, data) + + +async def subscriber_task(sio, namespace: str = "/status"): + """Background task that subscribes to Redis and forwards to websocket clients. + + Args: + sio: The socket.io server instance + namespace: The namespace to emit to + """ + import asyncio + + log.info("Starting pub/sub subscriber task...") + + # Create a separate Redis connection for pub/sub (required by Redis) + from redis import Redis + pubsub_conn = Redis() + pubsub = pubsub_conn.pubsub() + pubsub.subscribe(BANDCAMP_CHANNEL) + + log.info(f"Subscribed to Redis channel: {BANDCAMP_CHANNEL}") + + try: + while True: + # Non-blocking get with timeout + message = pubsub.get_message(ignore_subscribe_messages=True, timeout=0.1) + + if message and message["type"] == "message": + log.debug(f"Received pub/sub message: {message}") + try: + data = json.loads(message["data"]) + await sio.emit("bandcamp_sync_update", data, namespace=namespace) + except json.JSONDecodeError: + log.warning(f"Invalid JSON in pub/sub message: {message['data']}") + except Exception as e: + log.error(f"Error emitting pub/sub message: {e}") + + # Yield control to other async tasks + await asyncio.sleep(0.01) + except asyncio.CancelledError: + log.info("Subscriber task cancelled") + except Exception as e: + log.error(f"Subscriber task error: {e}", exc_info=True) + finally: + pubsub.unsubscribe(BANDCAMP_CHANNEL) + pubsub.close() + pubsub_conn.close() diff --git a/backend/beets_flask/server/websocket/status.py b/backend/beets_flask/server/websocket/status.py index bdeed082..3a17dcdf 100644 --- a/backend/beets_flask/server/websocket/status.py +++ b/backend/beets_flask/server/websocket/status.py @@ -54,6 +54,18 @@ class FileSystemUpdate: event: Literal["file_system_update"] = "file_system_update" +@dataclass +class BandcampSyncUpdate: + """Status update for bandcamp sync operations.""" + + status: Literal["pending", "running", "complete", "error", "aborted", "idle"] + message: str | None = None + logs: list[str] | None = None + error: str | None = None + exc: SerializedException | None = None + event: Literal["bandcamp_sync_update"] = "bandcamp_sync_update" + + namespace = "/status" @@ -89,6 +101,13 @@ async def fs_update(sid, data): await sio.emit("file_system_update", data, namespace=namespace) +@sio.on("bandcamp_sync_update", namespace=namespace) +@sio_catch_exception +async def bandcamp_update(sid, data): + log.debug(f"bandcamp_sync_update: {data}") + await sio.emit("bandcamp_sync_update", data, namespace=namespace) + + # ------------------------------------- * ------------------------------------ # @@ -100,7 +119,7 @@ async def any_event(event, sid, data): async def send_status_update( - status: FolderStatusUpdate | JobStatusUpdate | FileSystemUpdate, + status: FolderStatusUpdate | JobStatusUpdate | FileSystemUpdate | BandcampSyncUpdate, ): """Send a status update to propagate to all clients. @@ -210,6 +229,7 @@ async def wrapper(hash: str, path: str | None, *args, **kwargs) -> R: ) ) + return ret return wrapper @@ -218,5 +238,9 @@ async def wrapper(hash: str, path: str | None, *args, **kwargs) -> R: def register_status(): - # we need this to at least allow loading the module at the right time - pass + """Initialize status socket and start pub/sub subscriber.""" + from .pubsub import subscriber_task + + log.info("Registering status socket and starting pub/sub subscriber...") + # Start background task to subscribe to Redis pub/sub and forward to clients + sio.start_background_task(subscriber_task, sio, namespace) diff --git a/backend/launch_redis_workers.py b/backend/launch_redis_workers.py index ef0ae168..d91eeaf9 100644 --- a/backend/launch_redis_workers.py +++ b/backend/launch_redis_workers.py @@ -21,3 +21,9 @@ log.info(f"Starting {num_import_workers} redis workers for import") for i in range(num_import_workers): os.system(f'rq worker import --log-format "Import worker $i: %(message)s" &') + +# bandcamp sync worker - one worker for bandcamp downloads +num_bandcamp_workers = 1 +log.info(f"Starting {num_bandcamp_workers} redis workers for bandcamp sync") +for i in range(num_bandcamp_workers): + os.system(f'rq worker bandcamp --log-format "Bandcamp worker $i: %(message)s" &') diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0cd43b18..81087bf9 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -41,6 +41,8 @@ dependencies = [ "numpy", "pandas", "typing_extensions", + "bandcampsync", + "curl_cffi", # For browser-like TLS fingerprint (avoids 403 on Alpine/musl) ] [project.optional-dependencies] diff --git a/docs/develop/contribution.md b/docs/develop/contribution.md index d69b7e12..d0ef9878 100644 --- a/docs/develop/contribution.md +++ b/docs/develop/contribution.md @@ -27,7 +27,7 @@ We recommend using a virtual environment to manage the dependencies. ```bash cd backend -pip install -e .[dev] +pip install -e ".[dev]" ``` 2.2 **Install the dependencies (frontend):** diff --git a/frontend/src/api/bandcamp.ts b/frontend/src/api/bandcamp.ts new file mode 100644 index 00000000..35a39b97 --- /dev/null +++ b/frontend/src/api/bandcamp.ts @@ -0,0 +1,68 @@ +/** + * Bandcamp sync API client + * + * Provides functions for interacting with the bandcamp sync endpoints: + * - Get sync status + * - Start a sync operation + * - Abort a sync operation + * - Get configuration + * + * Progress updates are received via WebSocket (bandcamp_sync_update event). + */ + +import { queryOptions } from "@tanstack/react-query"; + +export interface BandcampConfig { + enabled: boolean; + path: string; +} + +export interface SyncStatus { + status: "idle" | "pending" | "running" | "complete" | "error" | "aborted"; + error?: string; +} + +/** + * Query options for fetching current sync status + */ +export const bandcampStatusQueryOptions = () => + queryOptions({ + queryKey: ["bandcamp", "status"], + queryFn: async () => { + const response = await fetch(`/bandcamp/status`); + return (await response.json()) as SyncStatus; + }, + refetchInterval: false, // Don't auto-refetch, we use WebSocket for updates + }); + +/** + * Start a bandcamp sync operation + */ +export async function startBandcampSync(cookies: string): Promise<{ started: boolean; message?: string }> { + const response = await fetch(`/bandcamp/sync`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ cookies }), + }); + + const data = (await response.json()) as { started: boolean; message?: string }; + + if (!response.ok && response.status !== 409) { + throw new Error(data.message ?? "Failed to start sync"); + } + + return data; +} + +/** + * Abort the current bandcamp sync operation + */ +export async function abortBandcampSync(): Promise<{ aborted: boolean }> { + const response = await fetch(`/bandcamp/sync`, { + method: "DELETE", + }); + + return (await response.json()) as { aborted: boolean }; +} diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index 8a7642ff..2e394aef 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -31,6 +31,10 @@ export interface MinimalConfig { order_by: string; show_unchanged_tracks: boolean; }; + bandcampsync: { + enabled: boolean; + path: string; + } }; import: { duplicate_action: string; diff --git a/frontend/src/api/websocket.ts b/frontend/src/api/websocket.ts index f9fd0aba..53cb041a 100644 --- a/frontend/src/api/websocket.ts +++ b/frontend/src/api/websocket.ts @@ -2,12 +2,18 @@ import { FileSystemUpdate, FolderStatusUpdate, JobStatusUpdate } from "@/pythonT import type { Socket } from "socket.io-client"; +export interface BandcampSyncUpdate { + status: "pending" | "running" | "complete" | "error" | "aborted" | "idle"; + logs?: string[]; +} + interface Status_ServerToClientEvents { // This types our socket events, i.e. we can only use // the events defined here should help with type safety :) folder_status_update: (data: FolderStatusUpdate) => void; job_status_update: (data: JobStatusUpdate) => void; file_system_update: (data: FileSystemUpdate) => void; + bandcamp_sync_update: (data: BandcampSyncUpdate) => void; } interface Status_ClientToServerEvents { diff --git a/frontend/src/components/bandcamp/BandcampSyncModal.tsx b/frontend/src/components/bandcamp/BandcampSyncModal.tsx new file mode 100644 index 00000000..f47874b1 --- /dev/null +++ b/frontend/src/components/bandcamp/BandcampSyncModal.tsx @@ -0,0 +1,244 @@ +/** + * Bandcamp Sync Modal + * + * A modal dialog for syncing Bandcamp purchases. Allows users to: + * - Enter their Bandcamp cookies (persisted in localStorage) + * - Start a sync operation + * - View real-time progress via WebSocket + * - Abort a running sync + */ + +import { CloudDownloadIcon, XCircleIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Alert, + Box, + Button, + CircularProgress, + DialogActions, + DialogContent, + Link, + TextField, + Typography, +} from "@mui/material"; +import { useQueryClient } from "@tanstack/react-query"; + +import { + abortBandcampSync, + startBandcampSync, +} from "@/api/bandcamp"; +import { BandcampSyncUpdate } from "@/api/websocket"; +import { Dialog } from "@/components/common/dialogs"; +import { useLocalStorage } from "@/components/common/hooks/useLocalStorage"; +import { useStatusSocket } from "@/components/common/websocket/status"; + +interface BandcampSyncModalProps { + open: boolean; + onClose: () => void; +} + +type SyncState = "idle" | "pending" | "running" | "complete" | "error" | "aborted"; +const runningStates: SyncState[] = ["pending", "running"]; +const terminalStates: SyncState[] = ["complete", "error", "aborted"]; + +export function BandcampSyncModal({ open, onClose }: BandcampSyncModalProps) { + const queryClient = useQueryClient(); + const { socket } = useStatusSocket(); + + // Persist cookies in localStorage + const [cookies, setCookies] = useLocalStorage("bandcamp-cookies", ""); + + // Sync state + const [syncState, setSyncState] = useState("idle"); + const [logs, setLogs] = useState([]); + + const logsEndRef = useRef(null); + + // Auto-scroll logs to bottom + useEffect(() => { + logsEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [logs]); + + // Handle WebSocket updates + const handleSyncUpdate = useCallback( + (data: BandcampSyncUpdate) => { + // Append logs if present + if (data.logs && data.logs.length > 0) { + const newLogs = data.logs; + setLogs((prev) => [...prev, ...newLogs]); + } + + // Update state based on status + switch (data.status) { + case "complete": + case "error": + case "aborted": + void queryClient.invalidateQueries({ queryKey: ["bandcamp", "status"] }); + break; + } + + if (data.status !== syncState) { + setSyncState(data.status); + } + }, + [queryClient, syncState] + ); + + // Subscribe to WebSocket updates + useEffect(() => { + if (!socket) return; + + socket.on("bandcamp_sync_update", handleSyncUpdate); + + return () => { + socket.off("bandcamp_sync_update", handleSyncUpdate); + }; + }, [socket, handleSyncUpdate]); + + const startSync = async () => { + // Reset state + setSyncState("pending"); + setLogs([]); + + try { + const response = await startBandcampSync(cookies); + + if (!response.started) { + // Already running + setLogs(["Sync already in progress..."]); + } + + void queryClient.invalidateQueries({ queryKey: ["bandcamp", "status"] }); + } catch { + setSyncState("error"); + setLogs(["Failed to start sync"]); + } + }; + + const isRunning = runningStates.includes(syncState); + const isTerminalState = terminalStates.includes(syncState); + + return ( + + + {/* Cookie input */} + + + This feature uses bandcampsync and requires a valid session cookie from + bandcamp.com. Paste your cookie in the field below. It will only be stored + in your browser's local storage.{" "} + + Read these docs for help getting set up. + + + setCookies(e.target.value)} + disabled={isRunning} + sx={{ + fontFamily: "monospace", + "& .MuiInputBase-input": { + fontFamily: "monospace", + fontSize: "0.875rem", + }, + }} + /> + + + {/* Progress/Logs area */} + {(syncState !== "idle" || logs.length > 0) && ( + + {isRunning && logs.length === 0 && ( + + + Starting sync... + + )} + {logs.map((log, i) => ( + + {log} + + ))} +
+ + )} + + {/* Status message */} + {isTerminalState && ( + + {syncState === "complete" && "Sync completed successfully!"} + {syncState === "error" && "Sync failed. Check the logs above."} + {syncState === "aborted" && "Sync was aborted."} + + )} + + + + {isRunning ? ( + + ) : ( + + )} + + +
+ ); +} diff --git a/frontend/src/components/bandcamp/index.ts b/frontend/src/components/bandcamp/index.ts new file mode 100644 index 00000000..8ca156f6 --- /dev/null +++ b/frontend/src/components/bandcamp/index.ts @@ -0,0 +1 @@ +export { BandcampSyncModal } from "./BandcampSyncModal"; diff --git a/frontend/src/components/frontpage/statsCard.tsx b/frontend/src/components/frontpage/statsCard.tsx index ddead836..021c5396 100644 --- a/frontend/src/components/frontpage/statsCard.tsx +++ b/frontend/src/components/frontpage/statsCard.tsx @@ -63,7 +63,7 @@ export function LibraryStatsCard({ libraryStats }: { libraryStats: LibraryStats } - value={humanizeDuration(libraryStats.runtime)} + value={humanizeDuration(libraryStats.runtime ?? 0)} /> { return await Promise.all([ diff --git a/frontend/src/routes/inbox/index.tsx b/frontend/src/routes/inbox/index.tsx index 2b8a6b0c..89f1b578 100644 --- a/frontend/src/routes/inbox/index.tsx +++ b/frontend/src/routes/inbox/index.tsx @@ -1,18 +1,21 @@ -import { FolderClockIcon, InfoIcon, SettingsIcon, TagIcon } from "lucide-react"; +import { CloudDownloadIcon, FolderClockIcon, InfoIcon, Loader2Icon, SettingsIcon, TagIcon } from "lucide-react"; import { useState } from "react"; import { Box, BoxProps, DialogContent, IconButton, + Tooltip, Typography, useTheme, } from "@mui/material"; -import { useSuspenseQuery } from "@tanstack/react-query"; +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; -import { Action } from "@/api/config"; +import { bandcampStatusQueryOptions } from "@/api/bandcamp"; +import { Action, useConfig } from "@/api/config"; import { inboxQueryOptions } from "@/api/inbox"; +import { BandcampSyncModal } from "@/components/bandcamp"; import { MatchChip, StyledChip } from "@/components/common/chips"; import { Dialog } from "@/components/common/dialogs"; import { @@ -129,6 +132,7 @@ function PageHeader({ inboxes, ...props }: { inboxes: Folder[] } & BoxProps) { justifySelf: "flex-end", }} > + + + setOpen(true)} + > + {isRunning ? ( + + ) : ( + + )} + + + setOpen(false)} /> + + ); +} + /** Description of the inbox page, shown as modal on click */ function InfoDescription() { const theme = useTheme();