Merge branch 'dev' into main
@@ -1,13 +1,45 @@
|
||||
/credentials.json
|
||||
/test.py
|
||||
/venv
|
||||
/downloads/
|
||||
/creds/
|
||||
/Test.py
|
||||
/prgs/
|
||||
/flask_server.log
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
spotizerr-ui/node_modules
|
||||
npm-debug.log
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
.env
|
||||
.venv
|
||||
venv/
|
||||
env/
|
||||
.env.example
|
||||
|
||||
# Editor/OS
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
*.swp
|
||||
|
||||
# Application data
|
||||
credentials.json
|
||||
test.py
|
||||
downloads/
|
||||
creds/
|
||||
Test.py
|
||||
prgs/
|
||||
flask_server.log
|
||||
test.sh
|
||||
__pycache__/
|
||||
routes/__pycache__/*
|
||||
routes/utils/__pycache__/*
|
||||
search_test.py
|
||||
@@ -20,8 +52,5 @@ search_demo.py
|
||||
celery_worker.log
|
||||
static/js/*
|
||||
logs/
|
||||
.env.example
|
||||
.env
|
||||
.venv
|
||||
data
|
||||
tests/
|
||||
@@ -4,23 +4,34 @@ repos:
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: check-symlinks
|
||||
exclude: ^spotizerr-ui/
|
||||
- id: trailing-whitespace
|
||||
exclude: ^spotizerr-ui/
|
||||
- id: mixed-line-ending
|
||||
args: [--fix=lf]
|
||||
exclude: ^spotizerr-ui/
|
||||
- id: check-yaml
|
||||
exclude: 'mkdocs.yml'
|
||||
exclude: 'mkdocs.yml|^spotizerr-ui/'
|
||||
- id: check-toml
|
||||
exclude: ^spotizerr-ui/
|
||||
- id: check-json
|
||||
exclude: ^spotizerr-ui/
|
||||
- id: check-ast
|
||||
exclude: ^spotizerr-ui/
|
||||
- id: debug-statements
|
||||
exclude: ^spotizerr-ui/
|
||||
- id: check-merge-conflict
|
||||
exclude: ^spotizerr-ui/
|
||||
- id: check-shebang-scripts-are-executable
|
||||
exclude: ^spotizerr-ui/
|
||||
- id: check-added-large-files
|
||||
args: [--maxkb=10000]
|
||||
exclude: ^spotizerr-ui/
|
||||
- repo: https://github.com/python-jsonschema/check-jsonschema
|
||||
rev: '0.33.0'
|
||||
hooks:
|
||||
- id: check-github-workflows
|
||||
exclude: ^spotizerr-ui/
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.11.13
|
||||
@@ -29,13 +40,16 @@ repos:
|
||||
- id: ruff
|
||||
types_or: [python, pyi, jupyter]
|
||||
args: [--fix]
|
||||
exclude: ^spotizerr-ui/
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
types_or: [python, pyi, jupyter]
|
||||
exclude: ^spotizerr-ui/
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: 'v1.16.0'
|
||||
hooks:
|
||||
- id: mypy
|
||||
args: [--no-strict-optional, --ignore-missing-imports]
|
||||
exclude: ^spotizerr-ui/
|
||||
# NOTE: you might need to add some deps here:
|
||||
additional_dependencies: [waitress==3.0.2, types-waitress]
|
||||
additional_dependencies: [waitress==3.0.2, types-waitress, types-requests]
|
||||
|
||||
38
Dockerfile
@@ -1,24 +1,20 @@
|
||||
# Stage 1: TypeScript build
|
||||
FROM node:22.16.0-slim AS typescript-builder
|
||||
# Stage 1: Frontend build
|
||||
FROM node:22-slim AS frontend-builder
|
||||
WORKDIR /app/spotizerr-ui
|
||||
RUN npm install -g pnpm
|
||||
COPY spotizerr-ui/package.json spotizerr-ui/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY spotizerr-ui/. .
|
||||
RUN pnpm build
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
# Stage 2: Final application image
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Copy necessary files for TypeScript build
|
||||
COPY tsconfig.json ./tsconfig.json
|
||||
COPY src/js ./src/js
|
||||
# Set an environment variable for non-interactive frontend installation
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install TypeScript globally
|
||||
RUN npm install -g typescript
|
||||
|
||||
# Compile TypeScript
|
||||
RUN tsc
|
||||
|
||||
# Stage 2: Final image
|
||||
FROM python:3.12-slim AS python-builder
|
||||
LABEL org.opencontainers.image.source="https://github.com/Xoconoch/spotizerr"
|
||||
|
||||
# Set the working directory in the container
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
@@ -34,12 +30,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy static files generated by TypeScript
|
||||
COPY --from=typescript-builder /app/static/js ./static/js
|
||||
|
||||
# Copy application code
|
||||
# Copy application code (excluding UI source and TS source)
|
||||
COPY . .
|
||||
|
||||
# Copy compiled assets from previous stages
|
||||
COPY --from=frontend-builder /app/spotizerr-ui/dist ./spotizerr-ui/dist
|
||||
|
||||
# Create necessary directories with proper permissions
|
||||
RUN mkdir -p downloads data/config data/creds data/watch data/history logs/tasks && \
|
||||
chmod -R 777 downloads data logs
|
||||
@@ -49,5 +45,3 @@ RUN chmod +x entrypoint.sh
|
||||
|
||||
# Set entrypoint to our script
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
|
||||
# No CMD needed as entrypoint.sh handles application startup
|
||||
|
||||
116
app.py
@@ -1,4 +1,4 @@
|
||||
from flask import Flask, request, send_from_directory, render_template
|
||||
from flask import Flask, request, send_from_directory
|
||||
from flask_cors import CORS
|
||||
from routes.search import search_bp
|
||||
from routes.credentials import credentials_bp
|
||||
@@ -145,7 +145,7 @@ def check_redis_connection():
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__, template_folder="static/html")
|
||||
app = Flask(__name__, static_folder="spotizerr-ui/dist", static_url_path="/")
|
||||
|
||||
# Set up CORS
|
||||
CORS(app)
|
||||
@@ -164,54 +164,14 @@ def create_app():
|
||||
app.register_blueprint(prgs_bp, url_prefix="/api/prgs")
|
||||
app.register_blueprint(history_bp, url_prefix="/api/history")
|
||||
|
||||
# Serve frontend
|
||||
@app.route("/")
|
||||
def serve_index():
|
||||
return render_template("main.html")
|
||||
|
||||
# Config page route
|
||||
@app.route("/config")
|
||||
def serve_config():
|
||||
return render_template("config.html")
|
||||
|
||||
# New route: Serve watch.html under /watchlist
|
||||
@app.route("/watchlist")
|
||||
def serve_watchlist():
|
||||
return render_template("watch.html")
|
||||
|
||||
# New route: Serve playlist.html under /playlist/<id>
|
||||
@app.route("/playlist/<id>")
|
||||
def serve_playlist(id):
|
||||
# The id parameter is captured, but you can use it as needed.
|
||||
return render_template("playlist.html")
|
||||
|
||||
@app.route("/album/<id>")
|
||||
def serve_album(id):
|
||||
# The id parameter is captured, but you can use it as needed.
|
||||
return render_template("album.html")
|
||||
|
||||
@app.route("/track/<id>")
|
||||
def serve_track(id):
|
||||
# The id parameter is captured, but you can use it as needed.
|
||||
return render_template("track.html")
|
||||
|
||||
@app.route("/artist/<id>")
|
||||
def serve_artist(id):
|
||||
# The id parameter is captured, but you can use it as needed.
|
||||
return render_template("artist.html")
|
||||
|
||||
@app.route("/history")
|
||||
def serve_history_page():
|
||||
return render_template("history.html")
|
||||
|
||||
@app.route("/static/<path:path>")
|
||||
def serve_static(path):
|
||||
return send_from_directory("static", path)
|
||||
|
||||
# Serve favicon.ico from the same directory as index.html (templates)
|
||||
@app.route("/favicon.ico")
|
||||
def serve_favicon():
|
||||
return send_from_directory("static/html", "favicon.ico")
|
||||
# Serve React App
|
||||
@app.route("/", defaults={"path": ""})
|
||||
@app.route("/<path:path>")
|
||||
def serve_react_app(path):
|
||||
if path != "" and os.path.exists(os.path.join(app.static_folder, path)):
|
||||
return send_from_directory(app.static_folder, path)
|
||||
else:
|
||||
return send_from_directory(app.static_folder, "index.html")
|
||||
|
||||
# Add request logging middleware
|
||||
@app.before_request
|
||||
@@ -248,31 +208,37 @@ if __name__ == "__main__":
|
||||
# Configure application logging
|
||||
log_handler = setup_logging()
|
||||
|
||||
# Set file permissions for log files if needed
|
||||
# Set permissions for log file
|
||||
try:
|
||||
os.chmod(log_handler.baseFilename, 0o666)
|
||||
except (OSError, FileNotFoundError) as e:
|
||||
logging.warning(f"Could not set permissions on log file: {str(e)}")
|
||||
if os.name != "nt": # Not Windows
|
||||
os.chmod(log_handler.baseFilename, 0o666)
|
||||
except Exception as e:
|
||||
logging.warning(f"Could not set permissions on log file: {e}")
|
||||
|
||||
# Log application startup
|
||||
logging.info("=== Spotizerr Application Starting ===")
|
||||
|
||||
# Check Redis connection before starting workers
|
||||
if check_redis_connection():
|
||||
# Start Watch Manager
|
||||
from routes.utils.watch.manager import start_watch_manager
|
||||
|
||||
start_watch_manager()
|
||||
|
||||
# Start Celery workers
|
||||
start_celery_workers()
|
||||
|
||||
# Create and start Flask app
|
||||
app = create_app()
|
||||
logging.info("Starting Flask server on port 7171")
|
||||
from waitress import serve
|
||||
|
||||
serve(app, host="0.0.0.0", port=7171)
|
||||
else:
|
||||
logging.error("Cannot start application: Redis connection failed")
|
||||
# Check Redis connection before starting
|
||||
if not check_redis_connection():
|
||||
logging.error("Exiting: Could not establish Redis connection.")
|
||||
sys.exit(1)
|
||||
|
||||
# Start Celery workers in a separate thread
|
||||
start_celery_workers()
|
||||
|
||||
# Clean up Celery workers on exit
|
||||
atexit.register(celery_manager.stop)
|
||||
|
||||
# Create Flask app
|
||||
app = create_app()
|
||||
|
||||
# Get host and port from environment variables or use defaults
|
||||
host = os.environ.get("HOST", "0.0.0.0")
|
||||
port = int(os.environ.get("PORT", 7171))
|
||||
|
||||
# Use Flask's built-in server for development
|
||||
# logging.info(f"Starting Flask development server on http://{host}:{port}")
|
||||
# app.run(host=host, port=port, debug=True)
|
||||
|
||||
# The following uses Waitress, a production-ready server.
|
||||
# To use it, comment out the app.run() line above and uncomment the lines below.
|
||||
logging.info(f"Starting server with Waitress on http://{host}:{port}")
|
||||
from waitress import serve
|
||||
serve(app, host=host, port=port)
|
||||
|
||||
BIN
celerybeat-schedule
Normal file
@@ -8,7 +8,9 @@ services:
|
||||
- ./logs:/app/logs # <-- Volume for persistent logs
|
||||
ports:
|
||||
- 7171:7171
|
||||
image: cooldockerizer93/spotizerr
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: spotizerr-app
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -2,4 +2,4 @@ waitress==3.0.2
|
||||
celery==5.5.3
|
||||
Flask==3.1.1
|
||||
flask_cors==6.0.0
|
||||
deezspot-spotizerr==1.10.0
|
||||
deezspot-spotizerr==2.0.3
|
||||
@@ -6,6 +6,7 @@ import time
|
||||
from routes.utils.celery_queue_manager import download_queue_manager
|
||||
from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState
|
||||
from routes.utils.get_info import get_spotify_info
|
||||
from routes.utils.errors import DuplicateDownloadError
|
||||
|
||||
album_bp = Blueprint("album", __name__)
|
||||
|
||||
@@ -74,6 +75,17 @@ def handle_download(album_id):
|
||||
"orig_request": orig_params,
|
||||
}
|
||||
)
|
||||
except DuplicateDownloadError as e:
|
||||
return Response(
|
||||
json.dumps(
|
||||
{
|
||||
"error": "Duplicate download detected.",
|
||||
"existing_task": e.existing_task,
|
||||
}
|
||||
),
|
||||
status=409,
|
||||
mimetype="application/json",
|
||||
)
|
||||
except Exception as e:
|
||||
# Generic error handling for other issues during task submission
|
||||
# Create an error task ID if add_task itself fails before returning an ID
|
||||
|
||||
@@ -27,6 +27,7 @@ from routes.utils.watch.manager import (
|
||||
check_watched_playlists,
|
||||
get_watch_config,
|
||||
) # For manual trigger & config
|
||||
from routes.utils.errors import DuplicateDownloadError
|
||||
|
||||
logger = logging.getLogger(__name__) # Added logger initialization
|
||||
playlist_bp = Blueprint("playlist", __name__, url_prefix="/api/playlist")
|
||||
@@ -97,7 +98,17 @@ def handle_download(playlist_id):
|
||||
"orig_request": orig_params,
|
||||
}
|
||||
)
|
||||
# Removed DuplicateDownloadError handling, add_task now manages this by creating an error task.
|
||||
except DuplicateDownloadError as e:
|
||||
return Response(
|
||||
json.dumps(
|
||||
{
|
||||
"error": "Duplicate download detected.",
|
||||
"existing_task": e.existing_task,
|
||||
}
|
||||
),
|
||||
status=409,
|
||||
mimetype="application/json",
|
||||
)
|
||||
except Exception as e:
|
||||
# Generic error handling for other issues during task submission
|
||||
error_task_id = str(uuid.uuid4())
|
||||
|
||||
269
routes/prgs.py
@@ -10,6 +10,7 @@ from routes.utils.celery_tasks import (
|
||||
cancel_task,
|
||||
retry_task,
|
||||
redis_client,
|
||||
delete_task_data,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
@@ -20,6 +21,60 @@ prgs_bp = Blueprint("prgs", __name__, url_prefix="/api/prgs")
|
||||
# (Old .prg file system removed. Using new task system only.)
|
||||
|
||||
|
||||
def _build_error_callback_object(last_status):
|
||||
"""
|
||||
Constructs a structured error callback object based on the last status of a task.
|
||||
This conforms to the CallbackObject types in the frontend.
|
||||
"""
|
||||
# The 'type' from the status update corresponds to the download_type (album, playlist, track)
|
||||
download_type = last_status.get("type")
|
||||
name = last_status.get("name")
|
||||
# The 'artist' field from the status may contain artist names or a playlist owner's name
|
||||
artist_or_owner = last_status.get("artist")
|
||||
error_message = last_status.get("error", "An unknown error occurred.")
|
||||
|
||||
status_info = {"status": "error", "error": error_message}
|
||||
|
||||
callback_object = {"status_info": status_info}
|
||||
|
||||
if download_type == "album":
|
||||
callback_object["album"] = {
|
||||
"type": "album",
|
||||
"title": name,
|
||||
"artists": [{
|
||||
"type": "artistAlbum",
|
||||
"name": artist_or_owner
|
||||
}] if artist_or_owner else [],
|
||||
}
|
||||
elif download_type == "playlist":
|
||||
playlist_payload = {"type": "playlist", "title": name}
|
||||
if artist_or_owner:
|
||||
playlist_payload["owner"] = {"type": "user", "name": artist_or_owner}
|
||||
callback_object["playlist"] = playlist_payload
|
||||
elif download_type == "track":
|
||||
callback_object["track"] = {
|
||||
"type": "track",
|
||||
"title": name,
|
||||
"artists": [{
|
||||
"type": "artistTrack",
|
||||
"name": artist_or_owner
|
||||
}] if artist_or_owner else [],
|
||||
}
|
||||
else:
|
||||
# Fallback for unknown types to avoid breaking the client, returning a basic error structure.
|
||||
return {
|
||||
"status_info": status_info,
|
||||
"unstructured_error": True,
|
||||
"details": {
|
||||
"type": download_type,
|
||||
"name": name,
|
||||
"artist_or_owner": artist_or_owner,
|
||||
},
|
||||
}
|
||||
|
||||
return callback_object
|
||||
|
||||
|
||||
@prgs_bp.route("/<task_id>", methods=["GET"])
|
||||
def get_task_details(task_id):
|
||||
"""
|
||||
@@ -77,17 +132,26 @@ def get_task_details(task_id):
|
||||
last_status = get_last_task_status(task_id)
|
||||
status_count = len(get_task_status(task_id))
|
||||
|
||||
# Default to the full last_status object, then check for the raw callback
|
||||
last_line_content = last_status
|
||||
# Determine last_line content
|
||||
if last_status and "raw_callback" in last_status:
|
||||
last_line_content = last_status["raw_callback"]
|
||||
elif last_status and last_status.get("status") == "error":
|
||||
last_line_content = _build_error_callback_object(last_status)
|
||||
else:
|
||||
# Fallback for non-error, no raw_callback, or if last_status is None
|
||||
last_line_content = last_status
|
||||
|
||||
response = {
|
||||
"original_url": dynamic_original_url,
|
||||
"last_line": last_line_content,
|
||||
"timestamp": time.time(),
|
||||
"timestamp": last_status.get("timestamp") if last_status else time.time(),
|
||||
"task_id": task_id,
|
||||
"status_count": status_count,
|
||||
"created_at": task_info.get("created_at"),
|
||||
"name": task_info.get("name"),
|
||||
"artist": task_info.get("artist"),
|
||||
"type": task_info.get("type"),
|
||||
"download_type": task_info.get("download_type"),
|
||||
}
|
||||
if last_status and last_status.get("summary"):
|
||||
response["summary"] = last_status["summary"]
|
||||
@@ -106,9 +170,13 @@ def delete_task(task_id):
|
||||
task_info = get_task_info(task_id)
|
||||
if not task_info:
|
||||
abort(404, "Task not found")
|
||||
|
||||
# First, cancel the task if it's running
|
||||
cancel_task(task_id)
|
||||
redis_client.delete(f"task:{task_id}:info")
|
||||
redis_client.delete(f"task:{task_id}:status")
|
||||
|
||||
# Then, delete all associated data from Redis
|
||||
delete_task_data(task_id)
|
||||
|
||||
return {"message": f"Task {task_id} deleted successfully"}, 200
|
||||
|
||||
|
||||
@@ -117,9 +185,14 @@ def list_tasks():
|
||||
"""
|
||||
Retrieve a list of all tasks in the system.
|
||||
Returns a detailed list of task objects including status and metadata.
|
||||
By default, it returns active tasks. Use ?include_finished=true to include completed tasks.
|
||||
"""
|
||||
try:
|
||||
tasks = get_all_tasks() # This already gets summary data
|
||||
# Check for 'include_finished' query parameter
|
||||
include_finished_str = request.args.get("include_finished", "false")
|
||||
include_finished = include_finished_str.lower() in ["true", "1", "yes"]
|
||||
|
||||
tasks = get_all_tasks(include_finished=include_finished)
|
||||
detailed_tasks = []
|
||||
for task_summary in tasks:
|
||||
task_id = task_summary.get("task_id")
|
||||
@@ -127,95 +200,86 @@ def list_tasks():
|
||||
continue
|
||||
|
||||
task_info = get_task_info(task_id)
|
||||
last_status = get_last_task_status(task_id)
|
||||
if not task_info:
|
||||
continue
|
||||
|
||||
if task_info and last_status:
|
||||
task_details = {
|
||||
"task_id": task_id,
|
||||
"type": task_info.get(
|
||||
"type", task_summary.get("type", "unknown")
|
||||
),
|
||||
"name": task_info.get(
|
||||
"name", task_summary.get("name", "Unknown")
|
||||
),
|
||||
"artist": task_info.get(
|
||||
"artist", task_summary.get("artist", "")
|
||||
),
|
||||
"download_type": task_info.get(
|
||||
"download_type",
|
||||
task_summary.get("download_type", "unknown"),
|
||||
),
|
||||
"status": last_status.get(
|
||||
"status", "unknown"
|
||||
), # Keep summary status for quick access
|
||||
"last_status_obj": last_status, # Full last status object
|
||||
"original_request": task_info.get("original_request", {}),
|
||||
"created_at": task_info.get("created_at", 0),
|
||||
"timestamp": last_status.get(
|
||||
"timestamp", task_info.get("created_at", 0)
|
||||
),
|
||||
}
|
||||
if last_status.get("summary"):
|
||||
task_details["summary"] = last_status["summary"]
|
||||
detailed_tasks.append(task_details)
|
||||
elif (
|
||||
task_info
|
||||
): # If last_status is somehow missing, still provide some info
|
||||
detailed_tasks.append(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"type": task_info.get("type", "unknown"),
|
||||
"name": task_info.get("name", "Unknown"),
|
||||
"artist": task_info.get("artist", ""),
|
||||
"download_type": task_info.get("download_type", "unknown"),
|
||||
"status": "unknown",
|
||||
"last_status_obj": None,
|
||||
"original_request": task_info.get("original_request", {}),
|
||||
"created_at": task_info.get("created_at", 0),
|
||||
"timestamp": task_info.get("created_at", 0),
|
||||
}
|
||||
# Dynamically construct original_url
|
||||
dynamic_original_url = ""
|
||||
download_type = task_info.get("download_type")
|
||||
# The 'url' field in task_info stores the Spotify/Deezer URL of the item
|
||||
# e.g., https://open.spotify.com/album/albumId or https://www.deezer.com/track/trackId
|
||||
item_url = task_info.get("url")
|
||||
|
||||
if download_type and item_url:
|
||||
try:
|
||||
# Extract the ID from the item_url (last part of the path)
|
||||
item_id = item_url.split("/")[-1]
|
||||
if item_id: # Ensure item_id is not empty
|
||||
base_url = request.host_url.rstrip("/")
|
||||
dynamic_original_url = (
|
||||
f"{base_url}/api/{download_type}/download/{item_id}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Could not extract item ID from URL: {item_url} for task {task_id}. Falling back for original_url."
|
||||
)
|
||||
original_request_obj = task_info.get("original_request", {})
|
||||
dynamic_original_url = original_request_obj.get(
|
||||
"original_url", ""
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error constructing dynamic original_url for task {task_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
original_request_obj = task_info.get("original_request", {})
|
||||
dynamic_original_url = original_request_obj.get(
|
||||
"original_url", ""
|
||||
) # Fallback on any error
|
||||
else:
|
||||
logger.warning(
|
||||
f"Missing download_type ('{download_type}') or item_url ('{item_url}') in task_info for task {task_id}. Falling back for original_url."
|
||||
)
|
||||
original_request_obj = task_info.get("original_request", {})
|
||||
dynamic_original_url = original_request_obj.get("original_url", "")
|
||||
|
||||
# Sort tasks by creation time (newest first, or by timestamp if creation time is missing)
|
||||
detailed_tasks.sort(
|
||||
key=lambda x: x.get("timestamp", x.get("created_at", 0)), reverse=True
|
||||
)
|
||||
last_status = get_last_task_status(task_id)
|
||||
status_count = len(get_task_status(task_id))
|
||||
|
||||
# Determine last_line content
|
||||
if last_status and "raw_callback" in last_status:
|
||||
last_line_content = last_status["raw_callback"]
|
||||
elif last_status and last_status.get("status") == "error":
|
||||
last_line_content = _build_error_callback_object(last_status)
|
||||
else:
|
||||
# Fallback for non-error, no raw_callback, or if last_status is None
|
||||
last_line_content = last_status
|
||||
|
||||
response = {
|
||||
"original_url": dynamic_original_url,
|
||||
"last_line": last_line_content,
|
||||
"timestamp": last_status.get("timestamp") if last_status else time.time(),
|
||||
"task_id": task_id,
|
||||
"status_count": status_count,
|
||||
"created_at": task_info.get("created_at"),
|
||||
"name": task_info.get("name"),
|
||||
"artist": task_info.get("artist"),
|
||||
"type": task_info.get("type"),
|
||||
"download_type": task_info.get("download_type"),
|
||||
}
|
||||
if last_status and last_status.get("summary"):
|
||||
response["summary"] = last_status["summary"]
|
||||
|
||||
detailed_tasks.append(response)
|
||||
|
||||
# Sort tasks by creation time (newest first)
|
||||
detailed_tasks.sort(key=lambda x: x.get("created_at", 0), reverse=True)
|
||||
|
||||
return jsonify(detailed_tasks)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in /api/prgs/list: {e}", exc_info=True)
|
||||
return jsonify({"error": "Failed to retrieve task list"}), 500
|
||||
|
||||
|
||||
@prgs_bp.route("/retry/<task_id>", methods=["POST"])
|
||||
def retry_task_endpoint(task_id):
|
||||
"""
|
||||
Retry a failed task.
|
||||
|
||||
Args:
|
||||
task_id: The ID of the task to retry
|
||||
"""
|
||||
try:
|
||||
# First check if this is a task ID in the new system
|
||||
task_info = get_task_info(task_id)
|
||||
|
||||
if task_info:
|
||||
# This is a task ID in the new system
|
||||
result = retry_task(task_id)
|
||||
return jsonify(result)
|
||||
|
||||
# If not found in new system, we need to handle the old system retry
|
||||
# For now, return an error as we're transitioning to the new system
|
||||
return jsonify(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Retry for old system is not supported in the new API. Please use the new task ID format.",
|
||||
}
|
||||
), 400
|
||||
except Exception as e:
|
||||
abort(500, f"An error occurred: {e}")
|
||||
|
||||
|
||||
@prgs_bp.route("/cancel/<task_id>", methods=["POST"])
|
||||
def cancel_task_endpoint(task_id):
|
||||
"""
|
||||
@@ -243,3 +307,36 @@ def cancel_task_endpoint(task_id):
|
||||
), 400
|
||||
except Exception as e:
|
||||
abort(500, f"An error occurred: {e}")
|
||||
|
||||
|
||||
@prgs_bp.route("/cancel/all", methods=["POST"])
|
||||
def cancel_all_tasks():
|
||||
"""
|
||||
Cancel all active (running or queued) tasks.
|
||||
"""
|
||||
try:
|
||||
tasks_to_cancel = get_all_tasks(include_finished=False)
|
||||
cancelled_count = 0
|
||||
errors = []
|
||||
|
||||
for task_summary in tasks_to_cancel:
|
||||
task_id = task_summary.get("task_id")
|
||||
if not task_id:
|
||||
continue
|
||||
try:
|
||||
cancel_task(task_id)
|
||||
cancelled_count += 1
|
||||
except Exception as e:
|
||||
error_message = f"Failed to cancel task {task_id}: {e}"
|
||||
logger.error(error_message)
|
||||
errors.append(error_message)
|
||||
|
||||
response = {
|
||||
"message": f"Attempted to cancel all active tasks. {cancelled_count} tasks cancelled.",
|
||||
"cancelled_count": cancelled_count,
|
||||
"errors": errors,
|
||||
}
|
||||
return jsonify(response), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error in /api/prgs/cancel/all: {e}", exc_info=True)
|
||||
return jsonify({"error": "Failed to cancel all tasks"}), 500
|
||||
|
||||
@@ -47,6 +47,10 @@ def handle_search():
|
||||
elif raw_results and search_type in raw_results:
|
||||
items = raw_results[search_type].get("items", [])
|
||||
|
||||
# Filter out any null items from the results
|
||||
if items:
|
||||
items = [item for item in items if item is not None]
|
||||
|
||||
# Return both the items array and the full data for debugging
|
||||
return jsonify(
|
||||
{
|
||||
|
||||
@@ -3,7 +3,10 @@ import json
|
||||
import traceback
|
||||
import uuid # For generating error task IDs
|
||||
import time # For timestamps
|
||||
from routes.utils.celery_queue_manager import download_queue_manager
|
||||
from routes.utils.celery_queue_manager import (
|
||||
download_queue_manager,
|
||||
get_existing_task_id,
|
||||
)
|
||||
from routes.utils.celery_tasks import (
|
||||
store_task_info,
|
||||
store_task_status,
|
||||
@@ -81,6 +84,20 @@ def handle_download(track_id):
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
# Check for existing task before adding to the queue
|
||||
existing_task = get_existing_task_id(url)
|
||||
if existing_task:
|
||||
return Response(
|
||||
json.dumps(
|
||||
{
|
||||
"error": "Duplicate download detected.",
|
||||
"existing_task": existing_task,
|
||||
}
|
||||
),
|
||||
status=409,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
try:
|
||||
task_id = download_queue_manager.add_task(
|
||||
{
|
||||
|
||||
@@ -6,6 +6,8 @@ from routes.utils.credentials import (
|
||||
_get_global_spotify_api_creds,
|
||||
get_spotify_blob_path,
|
||||
)
|
||||
from routes.utils.celery_queue_manager import get_existing_task_id
|
||||
from routes.utils.errors import DuplicateDownloadError
|
||||
|
||||
|
||||
def download_album(
|
||||
@@ -25,7 +27,15 @@ def download_album(
|
||||
progress_callback=None,
|
||||
convert_to=None,
|
||||
bitrate=None,
|
||||
_is_celery_task_execution=False, # Added to skip duplicate check from Celery task
|
||||
):
|
||||
if not _is_celery_task_execution:
|
||||
existing_task = get_existing_task_id(url) # Check for duplicates only if not called by Celery task
|
||||
if existing_task:
|
||||
raise DuplicateDownloadError(
|
||||
f"Download for this URL is already in progress.",
|
||||
existing_task=existing_task,
|
||||
)
|
||||
try:
|
||||
# Detect URL source (Spotify or Deezer) from URL
|
||||
is_spotify_url = "open.spotify.com" in url.lower()
|
||||
|
||||
@@ -4,7 +4,7 @@ from flask import url_for
|
||||
from routes.utils.celery_queue_manager import download_queue_manager
|
||||
from routes.utils.get_info import get_spotify_info
|
||||
from routes.utils.credentials import get_credential, _get_global_spotify_api_creds
|
||||
from routes.utils.celery_tasks import get_last_task_status, ProgressState
|
||||
from routes.utils.errors import DuplicateDownloadError
|
||||
|
||||
from deezspot.easy_spoty import Spo
|
||||
from deezspot.libutils.utils import get_ids, link_is_valid
|
||||
@@ -112,48 +112,34 @@ def download_artist_albums(
|
||||
if not url:
|
||||
raise ValueError("Missing required parameter: url")
|
||||
|
||||
# Extract artist ID from URL
|
||||
artist_id = url.split("/")[-1]
|
||||
if "?" in artist_id:
|
||||
artist_id = artist_id.split("?")[0]
|
||||
|
||||
logger.info(f"Fetching artist info for ID: {artist_id}")
|
||||
|
||||
# Detect URL source (only Spotify is supported for artists)
|
||||
is_spotify_url = "open.spotify.com" in url.lower()
|
||||
|
||||
# Artist functionality only works with Spotify URLs currently
|
||||
if not is_spotify_url:
|
||||
if "open.spotify.com" not in url.lower():
|
||||
error_msg = (
|
||||
"Invalid URL: Artist functionality only supports open.spotify.com URLs"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# Get artist info with albums
|
||||
artist_data = get_spotify_info(artist_id, "artist_discography")
|
||||
|
||||
# Debug logging to inspect the structure of artist_data
|
||||
logger.debug(
|
||||
f"Artist data structure has keys: {list(artist_data.keys() if isinstance(artist_data, dict) else [])}"
|
||||
)
|
||||
|
||||
if not artist_data or "items" not in artist_data:
|
||||
raise ValueError(
|
||||
f"Failed to retrieve artist data or no albums found for artist ID {artist_id}"
|
||||
)
|
||||
|
||||
# Parse the album types to filter by
|
||||
allowed_types = [t.strip().lower() for t in album_type.split(",")]
|
||||
logger.info(f"Filtering albums by types: {allowed_types}")
|
||||
|
||||
# Filter albums by the specified types
|
||||
filtered_albums = []
|
||||
for album in artist_data.get("items", []):
|
||||
album_type_value = album.get("album_type", "").lower()
|
||||
album_group_value = album.get("album_group", "").lower()
|
||||
|
||||
# Apply filtering logic based on album_type and album_group
|
||||
if (
|
||||
(
|
||||
"album" in allowed_types
|
||||
@@ -174,116 +160,54 @@ def download_artist_albums(
|
||||
logger.warning(f"No albums match the specified types: {album_type}")
|
||||
return [], []
|
||||
|
||||
# Queue each album as a separate download task
|
||||
album_task_ids = []
|
||||
successfully_queued_albums = []
|
||||
duplicate_albums = [] # To store info about albums that were duplicates
|
||||
duplicate_albums = []
|
||||
|
||||
for album in filtered_albums:
|
||||
# Add detailed logging to inspect each album's structure and URLs
|
||||
logger.debug(f"Processing album: {album.get('name', 'Unknown')}")
|
||||
logger.debug(f"Album structure has keys: {list(album.keys())}")
|
||||
|
||||
external_urls = album.get("external_urls", {})
|
||||
logger.debug(f"Album external_urls: {external_urls}")
|
||||
|
||||
album_url = external_urls.get("spotify", "")
|
||||
album_url = album.get("external_urls", {}).get("spotify", "")
|
||||
album_name = album.get("name", "Unknown Album")
|
||||
album_artists = album.get("artists", [])
|
||||
album_artist = (
|
||||
album_artists[0].get("name", "Unknown Artist")
|
||||
if album_artists
|
||||
else "Unknown Artist"
|
||||
album_artists[0].get("name", "Unknown Artist") if album_artists else "Unknown Artist"
|
||||
)
|
||||
album_id = album.get("id")
|
||||
|
||||
logger.debug(f"Extracted album URL: {album_url}")
|
||||
logger.debug(f"Extracted album ID: {album_id}")
|
||||
|
||||
if not album_url or not album_id:
|
||||
logger.warning(f"Skipping album without URL or ID: {album_name}")
|
||||
if not album_url:
|
||||
logger.warning(f"Skipping album '{album_name}' because it has no Spotify URL.")
|
||||
continue
|
||||
|
||||
# Create album-specific request args instead of using original artist request
|
||||
album_request_args = {
|
||||
task_data = {
|
||||
"download_type": "album",
|
||||
"url": album_url,
|
||||
"name": album_name,
|
||||
"artist": album_artist,
|
||||
"type": "album",
|
||||
# URL source will be automatically detected in the download functions
|
||||
"parent_artist_url": url,
|
||||
"parent_request_type": "artist",
|
||||
"orig_request": request_args,
|
||||
}
|
||||
|
||||
# Include original download URL for this album task
|
||||
album_request_args["original_url"] = url_for(
|
||||
"album.handle_download", album_id=album_id, _external=True
|
||||
)
|
||||
|
||||
# Create task for this album
|
||||
task_data = {
|
||||
"download_type": "album",
|
||||
"type": "album", # Type for the download task
|
||||
"url": album_url, # Important: use the album URL, not artist URL
|
||||
"retry_url": album_url, # Use album URL for retry logic, not artist URL
|
||||
"name": album_name,
|
||||
"artist": album_artist,
|
||||
"orig_request": album_request_args, # Store album-specific request params
|
||||
}
|
||||
|
||||
# Debug log the task data being sent to the queue
|
||||
logger.debug(
|
||||
f"Album task data: url={task_data['url']}, retry_url={task_data['retry_url']}"
|
||||
)
|
||||
|
||||
try:
|
||||
task_id = download_queue_manager.add_task(task_data)
|
||||
|
||||
# Check the status of the newly added task to see if it was marked as a duplicate error
|
||||
last_status = get_last_task_status(task_id)
|
||||
|
||||
if (
|
||||
last_status
|
||||
and last_status.get("status") == ProgressState.ERROR
|
||||
and last_status.get("existing_task_id")
|
||||
):
|
||||
logger.warning(
|
||||
f"Album {album_name} (URL: {album_url}) is a duplicate. Error task ID: {task_id}. Existing task ID: {last_status.get('existing_task_id')}"
|
||||
)
|
||||
duplicate_albums.append(
|
||||
{
|
||||
"name": album_name,
|
||||
"artist": album_artist,
|
||||
"url": album_url,
|
||||
"error_task_id": task_id, # This is the ID of the task marked as a duplicate error
|
||||
"existing_task_id": last_status.get("existing_task_id"),
|
||||
"message": last_status.get(
|
||||
"message", "Duplicate download attempt."
|
||||
),
|
||||
}
|
||||
)
|
||||
else:
|
||||
# If not a duplicate error, it was successfully queued (or failed for other reasons handled by add_task)
|
||||
# We only add to successfully_queued_albums if it wasn't a duplicate error from add_task
|
||||
# Other errors from add_task (like submission failure) would also result in an error status for task_id
|
||||
# but won't have 'existing_task_id'. The client can check the status of this task_id.
|
||||
album_task_ids.append(
|
||||
task_id
|
||||
) # Keep track of all task_ids returned by add_task
|
||||
successfully_queued_albums.append(
|
||||
{
|
||||
"name": album_name,
|
||||
"artist": album_artist,
|
||||
"url": album_url,
|
||||
"task_id": task_id,
|
||||
}
|
||||
)
|
||||
logger.info(f"Queued album download: {album_name} ({task_id})")
|
||||
except Exception as e: # Catch any other unexpected error from add_task itself (though it should be rare now)
|
||||
logger.error(
|
||||
f"Failed to queue album {album_name} due to an unexpected error in add_task: {str(e)}"
|
||||
successfully_queued_albums.append(
|
||||
{
|
||||
"name": album_name,
|
||||
"artist": album_artist,
|
||||
"url": album_url,
|
||||
"task_id": task_id,
|
||||
}
|
||||
)
|
||||
# Optionally, collect these errors. For now, just logging and continuing.
|
||||
except DuplicateDownloadError as e:
|
||||
logger.warning(
|
||||
f"Skipping duplicate album {album_name} (URL: {album_url}). Existing task: {e.existing_task}"
|
||||
)
|
||||
duplicate_albums.append(
|
||||
{
|
||||
"name": album_name,
|
||||
"artist": album_artist,
|
||||
"url": album_url,
|
||||
"existing_task": e.existing_task,
|
||||
"message": str(e),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to queue album {album_name} for an unknown reason: {e}")
|
||||
|
||||
logger.info(
|
||||
f"Artist album processing: {len(successfully_queued_albums)} queued, {len(duplicate_albums)} duplicates found."
|
||||
|
||||
@@ -149,7 +149,7 @@ task_max_retries = MAX_RETRIES
|
||||
|
||||
# Task result settings
|
||||
task_track_started = True
|
||||
result_expires = 60 * 60 * 24 * 7 # 7 days
|
||||
result_expires = 3600 # 1 hour
|
||||
|
||||
# Configure visibility timeout for task messages
|
||||
broker_transport_options = {
|
||||
@@ -167,3 +167,11 @@ broker_pool_limit = 10
|
||||
worker_prefetch_multiplier = 1 # Process one task at a time per worker
|
||||
worker_max_tasks_per_child = 100 # Restart worker after 100 tasks
|
||||
worker_disable_rate_limits = False
|
||||
|
||||
# Celery Beat schedule
|
||||
beat_schedule = {
|
||||
'cleanup-old-tasks': {
|
||||
'task': 'routes.utils.celery_tasks.cleanup_old_tasks',
|
||||
'schedule': 3600.0, # Run every hour
|
||||
},
|
||||
}
|
||||
|
||||
@@ -83,6 +83,89 @@ def get_config_params():
|
||||
}
|
||||
|
||||
|
||||
def get_existing_task_id(url, download_type=None):
|
||||
"""
|
||||
Check if an active task with the same URL (and optionally, type) already exists.
|
||||
This function ignores tasks that are in a terminal state (e.g., completed, cancelled, or failed).
|
||||
|
||||
Args:
|
||||
url (str): The URL to check for duplicates.
|
||||
download_type (str, optional): The type of download to check. Defaults to None.
|
||||
|
||||
Returns:
|
||||
str | None: The task ID of the existing active task, or None if no active duplicate is found.
|
||||
"""
|
||||
logger.debug(f"GET_EXISTING_TASK_ID: Checking for URL='{url}', type='{download_type}'")
|
||||
if not url:
|
||||
logger.debug("GET_EXISTING_TASK_ID: No URL provided, returning None.")
|
||||
return None
|
||||
|
||||
# Define terminal states. Tasks in these states are considered inactive and will be ignored.
|
||||
TERMINAL_STATES = {
|
||||
ProgressState.COMPLETE,
|
||||
ProgressState.DONE,
|
||||
ProgressState.CANCELLED,
|
||||
ProgressState.ERROR,
|
||||
ProgressState.ERROR_RETRIED,
|
||||
ProgressState.ERROR_AUTO_CLEANED,
|
||||
}
|
||||
logger.debug(f"GET_EXISTING_TASK_ID: Terminal states defined as: {TERMINAL_STATES}")
|
||||
|
||||
all_existing_tasks_summary = get_all_tasks() # This function already filters by default based on its own TERMINAL_STATES
|
||||
logger.debug(f"GET_EXISTING_TASK_ID: Found {len(all_existing_tasks_summary)} tasks from get_all_tasks(). Iterating...")
|
||||
|
||||
for task_summary in all_existing_tasks_summary:
|
||||
existing_task_id = task_summary.get("task_id")
|
||||
if not existing_task_id:
|
||||
logger.debug("GET_EXISTING_TASK_ID: Skipping summary with no task_id.")
|
||||
continue
|
||||
|
||||
logger.debug(f"GET_EXISTING_TASK_ID: Processing existing task_id='{existing_task_id}' from summary.")
|
||||
|
||||
# First, check the status of the task directly from its latest status record.
|
||||
# get_all_tasks() might have its own view of terminal, but we re-check here for absolute certainty.
|
||||
existing_last_status_obj = get_last_task_status(existing_task_id)
|
||||
if not existing_last_status_obj:
|
||||
logger.debug(f"GET_EXISTING_TASK_ID: No last status object for task_id='{existing_task_id}'. Skipping.")
|
||||
continue
|
||||
|
||||
existing_status = existing_last_status_obj.get("status")
|
||||
logger.debug(f"GET_EXISTING_TASK_ID: Task_id='{existing_task_id}', last_status_obj='{existing_last_status_obj}', extracted status='{existing_status}'.")
|
||||
|
||||
# If the task is in a terminal state, ignore it and move to the next one.
|
||||
if existing_status in TERMINAL_STATES:
|
||||
logger.debug(f"GET_EXISTING_TASK_ID: Task_id='{existing_task_id}' has terminal status='{existing_status}'. Skipping.")
|
||||
continue
|
||||
|
||||
logger.debug(f"GET_EXISTING_TASK_ID: Task_id='{existing_task_id}' has ACTIVE status='{existing_status}'. Proceeding to check URL/type.")
|
||||
|
||||
# If the task is active, then check if its URL and type match.
|
||||
existing_task_info = get_task_info(existing_task_id)
|
||||
if not existing_task_info:
|
||||
logger.debug(f"GET_EXISTING_TASK_ID: No task info for active task_id='{existing_task_id}'. Skipping.")
|
||||
continue
|
||||
|
||||
existing_url = existing_task_info.get("url")
|
||||
logger.debug(f"GET_EXISTING_TASK_ID: Task_id='{existing_task_id}', info_url='{existing_url}'. Comparing with target_url='{url}'.")
|
||||
if existing_url != url:
|
||||
logger.debug(f"GET_EXISTING_TASK_ID: Task_id='{existing_task_id}' URL mismatch. Skipping.")
|
||||
continue
|
||||
|
||||
if download_type:
|
||||
existing_type = existing_task_info.get("download_type")
|
||||
logger.debug(f"GET_EXISTING_TASK_ID: Task_id='{existing_task_id}', info_type='{existing_type}'. Comparing with target_type='{download_type}'.")
|
||||
if existing_type != download_type:
|
||||
logger.debug(f"GET_EXISTING_TASK_ID: Task_id='{existing_task_id}' type mismatch. Skipping.")
|
||||
continue
|
||||
|
||||
# Found an active task that matches the criteria.
|
||||
logger.info(f"GET_EXISTING_TASK_ID: Found ACTIVE duplicate: task_id='{existing_task_id}' for URL='{url}', type='{download_type}'. Returning this ID.")
|
||||
return existing_task_id
|
||||
|
||||
logger.debug(f"GET_EXISTING_TASK_ID: No active duplicate found for URL='{url}', type='{download_type}'. Returning None.")
|
||||
return None
|
||||
|
||||
|
||||
class CeleryDownloadQueueManager:
|
||||
"""
|
||||
Manages a queue of download tasks using Celery.
|
||||
@@ -125,14 +208,14 @@ class CeleryDownloadQueueManager:
|
||||
"Task being added with no URL. Duplicate check might be unreliable."
|
||||
)
|
||||
|
||||
NON_BLOCKING_STATES = [
|
||||
TERMINAL_STATES = { # Renamed and converted to a set for consistency
|
||||
ProgressState.COMPLETE,
|
||||
ProgressState.DONE,
|
||||
ProgressState.CANCELLED,
|
||||
ProgressState.ERROR,
|
||||
ProgressState.ERROR_RETRIED,
|
||||
ProgressState.ERROR_AUTO_CLEANED,
|
||||
]
|
||||
}
|
||||
|
||||
all_existing_tasks_summary = get_all_tasks()
|
||||
if incoming_url:
|
||||
@@ -155,7 +238,7 @@ class CeleryDownloadQueueManager:
|
||||
if (
|
||||
existing_url == incoming_url
|
||||
and existing_type == incoming_type
|
||||
and existing_status not in NON_BLOCKING_STATES
|
||||
and existing_status not in TERMINAL_STATES
|
||||
):
|
||||
message = f"Duplicate download: URL '{incoming_url}' (type: {incoming_type}) is already being processed by task {existing_task_id} (status: {existing_status})."
|
||||
logger.warning(message)
|
||||
|
||||
@@ -217,6 +217,7 @@ def get_all_tasks():
|
||||
return []
|
||||
|
||||
|
||||
|
||||
# --- History Logging Helper ---
|
||||
def _log_task_to_history(task_id, final_status_str, error_msg=None):
|
||||
"""Helper function to gather task data and log it to the history database."""
|
||||
@@ -548,7 +549,6 @@ def retry_task(task_id):
|
||||
logger.error(f"Error retrying task {task_id}: {e}", exc_info=True)
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
class ProgressTrackingTask(Task):
|
||||
"""Base task class that tracks progress through callbacks"""
|
||||
|
||||
@@ -566,7 +566,7 @@ class ProgressTrackingTask(Task):
|
||||
task_id = self.request.id
|
||||
|
||||
# Ensure ./logs/tasks directory exists
|
||||
logs_tasks_dir = Path("./logs/tasks") # Using relative path as per your update
|
||||
logs_tasks_dir = Path("./logs/tasks")
|
||||
try:
|
||||
logs_tasks_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
@@ -580,235 +580,118 @@ class ProgressTrackingTask(Task):
|
||||
# Log progress_data to the task-specific file
|
||||
try:
|
||||
with open(log_file_path, "a") as log_file:
|
||||
# Add a timestamp to the log entry if not present, for consistency in the file
|
||||
log_entry = progress_data.copy()
|
||||
if "timestamp" not in log_entry:
|
||||
log_entry["timestamp"] = time.time()
|
||||
print(json.dumps(log_entry), file=log_file) # Use print to file
|
||||
print(json.dumps(log_entry), file=log_file)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Task {task_id}: Could not write to task log file {log_file_path}: {e}"
|
||||
)
|
||||
|
||||
# Add timestamp if not present
|
||||
if "timestamp" not in progress_data:
|
||||
progress_data["timestamp"] = time.time()
|
||||
|
||||
# Get status type
|
||||
status = progress_data.get("status", "unknown")
|
||||
|
||||
# Get task info for context
|
||||
task_info = get_task_info(task_id)
|
||||
|
||||
# Log raw progress data at debug level
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
f"Task {task_id}: Raw progress data: {json.dumps(progress_data)}"
|
||||
)
|
||||
|
||||
# Process based on status type using a more streamlined approach
|
||||
if status == "initializing":
|
||||
# --- INITIALIZING: Start of a download operation ---
|
||||
self._handle_initializing(task_id, progress_data, task_info)
|
||||
|
||||
elif status == "downloading":
|
||||
# --- DOWNLOADING: Track download started ---
|
||||
self._handle_downloading(task_id, progress_data, task_info)
|
||||
|
||||
elif status == "progress":
|
||||
# --- PROGRESS: Album/playlist track progress ---
|
||||
self._handle_progress(task_id, progress_data, task_info)
|
||||
|
||||
elif status == "real_time" or status == "track_progress":
|
||||
# --- REAL_TIME/TRACK_PROGRESS: Track download real-time progress ---
|
||||
elif status in ["real_time", "track_progress"]:
|
||||
self._handle_real_time(task_id, progress_data)
|
||||
|
||||
elif status == "skipped":
|
||||
# --- SKIPPED: Track was skipped ---
|
||||
self._handle_skipped(task_id, progress_data, task_info)
|
||||
|
||||
elif status == "retrying":
|
||||
# --- RETRYING: Download failed and being retried ---
|
||||
self._handle_retrying(task_id, progress_data, task_info)
|
||||
|
||||
elif status == "error":
|
||||
# --- ERROR: Error occurred during download ---
|
||||
self._handle_error(task_id, progress_data, task_info)
|
||||
|
||||
elif status == "done":
|
||||
# --- DONE: Download operation completed ---
|
||||
self._handle_done(task_id, progress_data, task_info)
|
||||
|
||||
else:
|
||||
# --- UNKNOWN: Unrecognized status ---
|
||||
logger.info(
|
||||
f"Task {task_id} {status}: {progress_data.get('message', 'No details')}"
|
||||
)
|
||||
|
||||
# Embed the raw callback data into the status object before storing
|
||||
progress_data["raw_callback"] = raw_callback_data
|
||||
|
||||
# Store the processed status update
|
||||
store_task_status(task_id, progress_data)
|
||||
|
||||
def _handle_initializing(self, task_id, data, task_info):
|
||||
"""Handle initializing status from deezspot"""
|
||||
# Extract relevant fields
|
||||
content_type = data.get("type", "").upper()
|
||||
name = data.get("name", "")
|
||||
album_name = data.get("album", "")
|
||||
artist = data.get("artist", "")
|
||||
total_tracks = data.get("total_tracks", 0)
|
||||
|
||||
# Use album name as name if name is empty
|
||||
if not name and album_name:
|
||||
data["name"] = album_name
|
||||
|
||||
# Log initialization with appropriate detail level
|
||||
if album_name and artist:
|
||||
logger.info(
|
||||
f"Task {task_id} initializing: {content_type} '{album_name}' by {artist} with {total_tracks} tracks"
|
||||
)
|
||||
elif album_name:
|
||||
logger.info(
|
||||
f"Task {task_id} initializing: {content_type} '{album_name}' with {total_tracks} tracks"
|
||||
)
|
||||
elif name:
|
||||
logger.info(
|
||||
f"Task {task_id} initializing: {content_type} '{name}' with {total_tracks} tracks"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Task {task_id} initializing: {content_type} with {total_tracks} tracks"
|
||||
)
|
||||
|
||||
# Update task info with total tracks count
|
||||
if total_tracks > 0:
|
||||
task_info["total_tracks"] = total_tracks
|
||||
task_info["completed_tracks"] = task_info.get("completed_tracks", 0)
|
||||
task_info["skipped_tracks"] = task_info.get("skipped_tracks", 0)
|
||||
store_task_info(task_id, task_info)
|
||||
|
||||
# Update status in data
|
||||
# data["status"] = ProgressState.INITIALIZING
|
||||
logger.info(f"Task {task_id} initializing...")
|
||||
# Initializing object is now very basic, mainly for acknowledging the start.
|
||||
# More detailed info comes with 'progress' or 'downloading' states.
|
||||
data["status"] = ProgressState.INITIALIZING
|
||||
|
||||
def _handle_downloading(self, task_id, data, task_info):
|
||||
"""Handle downloading status from deezspot"""
|
||||
# Extract relevant fields
|
||||
track_name = data.get("song", "Unknown")
|
||||
artist = data.get("artist", "")
|
||||
album = data.get("album", "")
|
||||
download_type = data.get("type", "")
|
||||
track_obj = data.get("track", {})
|
||||
track_name = track_obj.get("title", "Unknown")
|
||||
|
||||
# Get parent task context
|
||||
parent_type = task_info.get("type", "").lower()
|
||||
artists = track_obj.get("artists", [])
|
||||
artist_name = artists[0].get("name", "") if artists else ""
|
||||
|
||||
# If this is a track within an album/playlist, update progress
|
||||
if parent_type in ["album", "playlist"] and download_type == "track":
|
||||
total_tracks = task_info.get("total_tracks", 0)
|
||||
current_track = task_info.get("current_track_num", 0) + 1
|
||||
album_obj = track_obj.get("album", {})
|
||||
album_name = album_obj.get("title", "")
|
||||
|
||||
# Update task info
|
||||
task_info["current_track_num"] = current_track
|
||||
task_info["current_track"] = track_name
|
||||
task_info["current_artist"] = artist
|
||||
store_task_info(task_id, task_info)
|
||||
logger.info(f"Task {task_id}: Starting download for track '{track_name}' by {artist_name}")
|
||||
|
||||
# Only calculate progress if we have total tracks
|
||||
if total_tracks > 0:
|
||||
overall_progress = min(int((current_track / total_tracks) * 100), 100)
|
||||
data["overall_progress"] = overall_progress
|
||||
data["parsed_current_track"] = current_track
|
||||
data["parsed_total_tracks"] = total_tracks
|
||||
|
||||
# Create a progress update for the album/playlist
|
||||
progress_update = {
|
||||
"status": ProgressState.DOWNLOADING,
|
||||
"type": parent_type,
|
||||
"track": track_name,
|
||||
"current_track": f"{current_track}/{total_tracks}",
|
||||
"album": album,
|
||||
"artist": artist,
|
||||
"timestamp": data["timestamp"],
|
||||
"parent_task": True,
|
||||
}
|
||||
|
||||
# Store separate progress update
|
||||
store_task_status(task_id, progress_update)
|
||||
|
||||
# Log with appropriate detail level
|
||||
if artist and album:
|
||||
logger.info(
|
||||
f"Task {task_id} downloading: '{track_name}' by {artist} from {album}"
|
||||
)
|
||||
elif artist:
|
||||
logger.info(f"Task {task_id} downloading: '{track_name}' by {artist}")
|
||||
else:
|
||||
logger.info(f"Task {task_id} downloading: '{track_name}'")
|
||||
|
||||
# Update status
|
||||
# data["status"] = ProgressState.DOWNLOADING
|
||||
data["status"] = ProgressState.DOWNLOADING
|
||||
data["song"] = track_name
|
||||
data["artist"] = artist_name
|
||||
data["album"] = album_name
|
||||
|
||||
def _handle_progress(self, task_id, data, task_info):
|
||||
"""Handle progress status from deezspot"""
|
||||
# Extract track info
|
||||
track_name = data.get("track", data.get("song", "Unknown track"))
|
||||
current_track_raw = data.get("current_track", "0")
|
||||
album = data.get("album", "")
|
||||
artist = data.get("artist", "")
|
||||
"""Handle progress status for albums/playlists from deezspot"""
|
||||
item = data.get("playlist") or data.get("album", {})
|
||||
track = data.get("track", {})
|
||||
|
||||
# Process artist if it's a list
|
||||
if isinstance(artist, list) and len(artist) > 0:
|
||||
data["artist_name"] = artist[0]
|
||||
elif isinstance(artist, str):
|
||||
data["artist_name"] = artist
|
||||
item_name = item.get("title", "Unknown Item")
|
||||
total_tracks = item.get("total_tracks", 0)
|
||||
|
||||
# Parse track numbers from "current/total" format
|
||||
if isinstance(current_track_raw, str) and "/" in current_track_raw:
|
||||
try:
|
||||
parts = current_track_raw.split("/")
|
||||
current_track = int(parts[0])
|
||||
total_tracks = int(parts[1])
|
||||
track_name = track.get("title", "Unknown Track")
|
||||
artists = track.get("artists", [])
|
||||
artist_name = artists[0].get("name", "") if artists else ""
|
||||
|
||||
# Update with parsed values
|
||||
data["parsed_current_track"] = current_track
|
||||
data["parsed_total_tracks"] = total_tracks
|
||||
# The 'progress' field in the callback is the track number being processed
|
||||
current_track_num = data.get("progress", 0)
|
||||
|
||||
# Calculate percentage
|
||||
overall_progress = min(int((current_track / total_tracks) * 100), 100)
|
||||
data["overall_progress"] = overall_progress
|
||||
if total_tracks > 0:
|
||||
task_info["total_tracks"] = total_tracks
|
||||
task_info["completed_tracks"] = current_track_num - 1
|
||||
task_info["current_track_num"] = current_track_num
|
||||
store_task_info(task_id, task_info)
|
||||
|
||||
# Update task info
|
||||
task_info["current_track_num"] = current_track
|
||||
task_info["total_tracks"] = total_tracks
|
||||
task_info["current_track"] = track_name
|
||||
store_task_info(task_id, task_info)
|
||||
overall_progress = min(int(((current_track_num -1) / total_tracks) * 100), 100)
|
||||
data["overall_progress"] = overall_progress
|
||||
data["parsed_current_track"] = current_track_num
|
||||
data["parsed_total_tracks"] = total_tracks
|
||||
|
||||
# Log progress with appropriate detail
|
||||
artist_name = data.get("artist_name", artist)
|
||||
if album and artist_name:
|
||||
logger.info(
|
||||
f"Task {task_id} progress: [{current_track}/{total_tracks}] {overall_progress}% - {track_name} by {artist_name} from {album}"
|
||||
)
|
||||
elif album:
|
||||
logger.info(
|
||||
f"Task {task_id} progress: [{current_track}/{total_tracks}] {overall_progress}% - {track_name} from {album}"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Task {task_id} progress: [{current_track}/{total_tracks}] {overall_progress}% - {track_name}"
|
||||
)
|
||||
logger.info(f"Task {task_id}: Progress on '{item_name}': Processing track {current_track_num}/{total_tracks} - '{track_name}'")
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"Error parsing track numbers '{current_track_raw}': {e}")
|
||||
|
||||
# Ensure correct status
|
||||
# data["status"] = ProgressState.PROGRESS
|
||||
data["status"] = ProgressState.PROGRESS
|
||||
data["song"] = track_name
|
||||
data["artist"] = artist_name
|
||||
data["current_track"] = f"{current_track_num}/{total_tracks}"
|
||||
|
||||
def _handle_real_time(self, task_id, data):
|
||||
"""Handle real-time progress status from deezspot"""
|
||||
# Extract track info
|
||||
title = data.get("title", data.get("song", "Unknown"))
|
||||
track_obj = data.get("track", {})
|
||||
track_name = track_obj.get("title", "Unknown Track")
|
||||
percentage = data.get("percentage", 0)
|
||||
|
||||
logger.debug(f"Task {task_id}: Real-time progress for '{track_name}': {percentage}%")
|
||||
|
||||
data["status"] = ProgressState.TRACK_PROGRESS
|
||||
data["song"] = track_name
|
||||
artist = data.get("artist", "Unknown")
|
||||
|
||||
# Handle percent formatting
|
||||
@@ -1421,6 +1304,7 @@ def download_track(self, **task_data):
|
||||
progress_callback=self.progress_callback,
|
||||
convert_to=convert_to,
|
||||
bitrate=bitrate,
|
||||
_is_celery_task_execution=True, # Skip duplicate check inside Celery task (consistency)
|
||||
)
|
||||
|
||||
return {"status": "success", "message": "Track download completed"}
|
||||
@@ -1507,6 +1391,7 @@ def download_album(self, **task_data):
|
||||
progress_callback=self.progress_callback,
|
||||
convert_to=convert_to,
|
||||
bitrate=bitrate,
|
||||
_is_celery_task_execution=True, # Skip duplicate check inside Celery task
|
||||
)
|
||||
|
||||
return {"status": "success", "message": "Album download completed"}
|
||||
@@ -1605,6 +1490,7 @@ def download_playlist(self, **task_data):
|
||||
progress_callback=self.progress_callback,
|
||||
convert_to=convert_to,
|
||||
bitrate=bitrate,
|
||||
_is_celery_task_execution=True, # Skip duplicate check inside Celery task
|
||||
)
|
||||
|
||||
return {"status": "success", "message": "Playlist download completed"}
|
||||
|
||||
6
routes/utils/errors.py
Normal file
@@ -0,0 +1,6 @@
|
||||
class DuplicateDownloadError(Exception):
|
||||
def __init__(self, message, existing_task=None):
|
||||
if existing_task:
|
||||
message = f"{message} (Conflicting Task ID: {existing_task})"
|
||||
super().__init__(message)
|
||||
self.existing_task = existing_task
|
||||
@@ -3,6 +3,8 @@ from deezspot.spotloader import SpoLogin
|
||||
from deezspot.deezloader import DeeLogin
|
||||
from pathlib import Path
|
||||
from routes.utils.credentials import get_credential, _get_global_spotify_api_creds
|
||||
from routes.utils.celery_queue_manager import get_existing_task_id
|
||||
from routes.utils.errors import DuplicateDownloadError
|
||||
|
||||
|
||||
def download_playlist(
|
||||
@@ -22,7 +24,15 @@ def download_playlist(
|
||||
progress_callback=None,
|
||||
convert_to=None,
|
||||
bitrate=None,
|
||||
_is_celery_task_execution=False, # Added to skip duplicate check from Celery task
|
||||
):
|
||||
if not _is_celery_task_execution:
|
||||
existing_task = get_existing_task_id(url) # Check for duplicates only if not called by Celery task
|
||||
if existing_task:
|
||||
raise DuplicateDownloadError(
|
||||
f"Download for this URL is already in progress.",
|
||||
existing_task=existing_task,
|
||||
)
|
||||
try:
|
||||
# Detect URL source (Spotify or Deezer) from URL
|
||||
is_spotify_url = "open.spotify.com" in url.lower()
|
||||
|
||||
@@ -25,6 +25,7 @@ def download_track(
|
||||
progress_callback=None,
|
||||
convert_to=None,
|
||||
bitrate=None,
|
||||
_is_celery_task_execution=False, # Added for consistency, not currently used for duplicate check
|
||||
):
|
||||
try:
|
||||
# Detect URL source (Spotify or Deezer) from URL
|
||||
|
||||
24
spotizerr-ui/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
9
spotizerr-ui/.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
coverage
|
||||
.pnpm-store
|
||||
.vite
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
7
spotizerr-ui/.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2
|
||||
}
|
||||
54
spotizerr-ui/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
extends: [
|
||||
// Remove ...tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from "eslint-plugin-react-x";
|
||||
import reactDom from "eslint-plugin-react-dom";
|
||||
|
||||
export default tseslint.config({
|
||||
plugins: {
|
||||
// Add the react-x and react-dom plugins
|
||||
"react-x": reactX,
|
||||
"react-dom": reactDom,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended typescript rules
|
||||
...reactX.configs["recommended-typescript"].rules,
|
||||
...reactDom.configs.recommended.rules,
|
||||
},
|
||||
});
|
||||
```
|
||||
33
spotizerr-ui/eslint.config.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import prettier from "eslint-plugin-prettier";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
// Read Prettier configuration from .prettierrc.json
|
||||
const prettierOptions = JSON.parse(readFileSync("./.prettierrc.json", "utf8"));
|
||||
|
||||
export default [
|
||||
{ ignores: ["dist"] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
prettier: prettier,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"prettier/prettier": ["error", prettierOptions],
|
||||
},
|
||||
},
|
||||
];
|
||||
13
spotizerr-ui/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Spotizerr</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
50
spotizerr-ui/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "spotizerr-ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
"@tanstack/react-query": "^5.80.6",
|
||||
"@tanstack/react-router": "^1.120.18",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/router-devtools": "^1.120.18",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"axios": "^1.9.0",
|
||||
"lucide-react": "^0.515.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.57.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"sonner": "^2.0.5",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"use-debounce": "^10.0.5",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@types/node": "^22.15.30",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-prettier": "^5.4.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.3.5"
|
||||
},
|
||||
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
|
||||
}
|
||||
3066
spotizerr-ui/pnpm-lock.yaml
generated
Normal file
5
spotizerr-ui/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
import tailwindcss from "@tailwindcss/postcss";
|
||||
|
||||
export default {
|
||||
plugins: [tailwindcss],
|
||||
};
|
||||
|
Before Width: | Height: | Size: 337 B After Width: | Height: | Size: 337 B |
|
Before Width: | Height: | Size: 284 B After Width: | Height: | Size: 284 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 723 B After Width: | Height: | Size: 723 B |
|
Before Width: | Height: | Size: 356 B After Width: | Height: | Size: 356 B |
|
Before Width: | Height: | Size: 891 B After Width: | Height: | Size: 891 B |
|
Before Width: | Height: | Size: 752 B After Width: | Height: | Size: 752 B |
|
Before Width: | Height: | Size: 597 B After Width: | Height: | Size: 597 B |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 673 B After Width: | Height: | Size: 673 B |
|
Before Width: | Height: | Size: 527 B After Width: | Height: | Size: 527 B |
|
Before Width: | Height: | Size: 247 B After Width: | Height: | Size: 247 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 307 B After Width: | Height: | Size: 307 B |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 652 B After Width: | Height: | Size: 652 B |
|
Before Width: | Height: | Size: 873 B After Width: | Height: | Size: 873 B |
|
Before Width: | Height: | Size: 666 B After Width: | Height: | Size: 666 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 500 B After Width: | Height: | Size: 500 B |
|
Before Width: | Height: | Size: 284 B After Width: | Height: | Size: 284 B |
|
Before Width: | Height: | Size: 778 B After Width: | Height: | Size: 778 B |
|
Before Width: | Height: | Size: 513 B After Width: | Height: | Size: 510 B |
|
Before Width: | Height: | Size: 550 B After Width: | Height: | Size: 550 B |
|
Before Width: | Height: | Size: 597 B After Width: | Height: | Size: 597 B |
44
spotizerr-ui/src/components/AlbumCard.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import type { AlbumType } from "../types/spotify";
|
||||
|
||||
interface AlbumCardProps {
|
||||
album: AlbumType;
|
||||
onDownload?: () => void;
|
||||
}
|
||||
|
||||
export const AlbumCard = ({ album, onDownload }: AlbumCardProps) => {
|
||||
const imageUrl = album.images && album.images.length > 0 ? album.images[0].url : "/placeholder.jpg";
|
||||
const subtitle = album.artists.map((artist) => artist.name).join(", ");
|
||||
|
||||
return (
|
||||
<div className="group flex flex-col rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-xl hover:shadow-2xl transition-all duration-300 ease-in-out hover:-translate-y-1 hover:scale-105">
|
||||
<div className="relative">
|
||||
<Link to="/album/$albumId" params={{ albumId: album.id }}>
|
||||
<img src={imageUrl} alt={album.name} className="w-full aspect-square object-cover" />
|
||||
{onDownload && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onDownload();
|
||||
}}
|
||||
className="absolute bottom-2 right-2 p-2 bg-green-600 text-white rounded-full hover:bg-green-700 transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300"
|
||||
title="Download album"
|
||||
>
|
||||
<img src="/download.svg" alt="Download" className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="p-4 flex-grow flex flex-col">
|
||||
<Link
|
||||
to="/album/$albumId"
|
||||
params={{ albumId: album.id }}
|
||||
className="font-semibold text-gray-900 dark:text-white truncate block"
|
||||
>
|
||||
{album.name}
|
||||
</Link>
|
||||
{subtitle && <p className="text-sm text-gray-600 dark:text-gray-400 mt-1 truncate">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
286
spotizerr-ui/src/components/Queue.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import { useContext } from "react";
|
||||
import {
|
||||
FaTimes,
|
||||
FaSync,
|
||||
FaCheckCircle,
|
||||
FaExclamationCircle,
|
||||
FaHourglassHalf,
|
||||
FaMusic,
|
||||
FaCompactDisc,
|
||||
} from "react-icons/fa";
|
||||
import { QueueContext, type QueueItem, type QueueStatus } from "@/contexts/queue-context";
|
||||
|
||||
const isTerminalStatus = (status: QueueStatus) =>
|
||||
["completed", "error", "cancelled", "skipped", "done"].includes(status);
|
||||
|
||||
const statusStyles: Record<
|
||||
QueueStatus,
|
||||
{ icon: React.ReactNode; color: string; bgColor: string; name: string }
|
||||
> = {
|
||||
queued: {
|
||||
icon: <FaHourglassHalf />,
|
||||
color: "text-gray-500",
|
||||
bgColor: "bg-gray-100",
|
||||
name: "Queued",
|
||||
},
|
||||
initializing: {
|
||||
icon: <FaSync className="animate-spin" />,
|
||||
color: "text-blue-500",
|
||||
bgColor: "bg-blue-100",
|
||||
name: "Initializing",
|
||||
},
|
||||
downloading: {
|
||||
icon: <FaSync className="animate-spin" />,
|
||||
color: "text-blue-500",
|
||||
bgColor: "bg-blue-100",
|
||||
name: "Downloading",
|
||||
},
|
||||
processing: {
|
||||
icon: <FaSync className="animate-spin" />,
|
||||
color: "text-purple-500",
|
||||
bgColor: "bg-purple-100",
|
||||
name: "Processing",
|
||||
},
|
||||
retrying: {
|
||||
icon: <FaSync className="animate-spin" />,
|
||||
color: "text-orange-500",
|
||||
bgColor: "bg-orange-100",
|
||||
name: "Retrying",
|
||||
},
|
||||
completed: {
|
||||
icon: <FaCheckCircle />,
|
||||
color: "text-green-500",
|
||||
bgColor: "bg-green-100",
|
||||
name: "Completed",
|
||||
},
|
||||
done: {
|
||||
icon: <FaCheckCircle />,
|
||||
color: "text-green-500",
|
||||
bgColor: "bg-green-100",
|
||||
name: "Done",
|
||||
},
|
||||
error: {
|
||||
icon: <FaExclamationCircle />,
|
||||
color: "text-red-500",
|
||||
bgColor: "bg-red-100",
|
||||
name: "Error",
|
||||
},
|
||||
cancelled: {
|
||||
icon: <FaTimes />,
|
||||
color: "text-yellow-500",
|
||||
bgColor: "bg-yellow-100",
|
||||
name: "Cancelled",
|
||||
},
|
||||
skipped: {
|
||||
icon: <FaTimes />,
|
||||
color: "text-gray-500",
|
||||
bgColor: "bg-gray-100",
|
||||
name: "Skipped",
|
||||
},
|
||||
pending: {
|
||||
icon: <FaHourglassHalf />,
|
||||
color: "text-gray-500",
|
||||
bgColor: "bg-gray-100",
|
||||
name: "Pending",
|
||||
},
|
||||
};
|
||||
|
||||
const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
||||
const { removeItem, retryItem, cancelItem } = useContext(QueueContext) || {};
|
||||
const statusInfo = statusStyles[item.status] || statusStyles.queued;
|
||||
const isTerminal = isTerminalStatus(item.status);
|
||||
|
||||
const getProgressText = () => {
|
||||
const { status, type, progress, totalTracks, summary } = item;
|
||||
|
||||
if (status === "downloading" || status === "processing") {
|
||||
if (type === "track") {
|
||||
return progress !== undefined ? `${progress.toFixed(0)}%` : null;
|
||||
}
|
||||
// For albums/playlists, detailed progress is in the main body
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((status === "completed" || status === "done") && summary) {
|
||||
if (type === "track") {
|
||||
if (summary.total_successful > 0) return "Completed";
|
||||
if (summary.total_failed > 0) return "Failed";
|
||||
return "Finished";
|
||||
}
|
||||
return `${summary.total_successful}/${totalTracks} tracks`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const progressText = getProgressText();
|
||||
|
||||
return (
|
||||
<div className={`p-4 rounded-lg shadow-md mb-3 transition-all duration-300 ${statusInfo.bgColor}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className={`text-2xl ${statusInfo.color}`}>{statusInfo.icon}</div>
|
||||
<div className="flex-grow min-w-0">
|
||||
{item.type === "track" && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<FaMusic className="text-gray-500" />
|
||||
<p className="font-bold text-gray-800 truncate" title={item.name}>
|
||||
{item.name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 truncate" title={item.artist}>
|
||||
{item.artist}
|
||||
</p>
|
||||
{item.albumName && (
|
||||
<p className="text-xs text-gray-500 truncate" title={item.albumName}>
|
||||
{item.albumName}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{item.type === "album" && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<FaCompactDisc className="text-gray-500" />
|
||||
<p className="font-bold text-gray-800 truncate" title={item.name}>
|
||||
{item.name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 truncate" title={item.artist}>
|
||||
{item.artist}
|
||||
</p>
|
||||
{item.currentTrackTitle && (
|
||||
<p className="text-xs text-gray-500 truncate" title={item.currentTrackTitle}>
|
||||
{item.currentTrackNumber}/{item.totalTracks}: {item.currentTrackTitle}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{item.type === "playlist" && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<FaMusic className="text-gray-500" />
|
||||
<p className="font-bold text-gray-800 truncate" title={item.name}>
|
||||
{item.name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 truncate" title={item.playlistOwner}>
|
||||
{item.playlistOwner}
|
||||
</p>
|
||||
{item.currentTrackTitle && (
|
||||
<p className="text-xs text-gray-500 truncate" title={item.currentTrackTitle}>
|
||||
{item.currentTrackNumber}/{item.totalTracks}: {item.currentTrackTitle}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className={`text-sm font-semibold ${statusInfo.color}`}>{statusInfo.name}</p>
|
||||
{progressText && <p className="text-xs text-gray-500">{progressText}</p>}
|
||||
</div>
|
||||
{isTerminal ? (
|
||||
<button
|
||||
onClick={() => removeItem?.(item.id)}
|
||||
className="text-gray-400 hover:text-red-500 transition-colors"
|
||||
aria-label="Remove"
|
||||
>
|
||||
<FaTimes />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => cancelItem?.(item.id)}
|
||||
className="text-gray-400 hover:text-orange-500 transition-colors"
|
||||
aria-label="Cancel"
|
||||
>
|
||||
<FaTimes />
|
||||
</button>
|
||||
)}
|
||||
{item.canRetry && (
|
||||
<button
|
||||
onClick={() => retryItem?.(item.id)}
|
||||
className="text-gray-400 hover:text-blue-500 transition-colors"
|
||||
aria-label="Retry"
|
||||
>
|
||||
<FaSync />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(item.status === "error" || item.status === "retrying") && item.error && (
|
||||
<p className="text-xs text-red-600 mt-2">Error: {item.error}</p>
|
||||
)}
|
||||
{isTerminal && item.summary && (item.summary.total_failed > 0 || item.summary.total_skipped > 0) && (
|
||||
<div className="mt-2 text-xs">
|
||||
{item.summary.total_failed > 0 && (
|
||||
<p className="text-red-600">{item.summary.total_failed} track(s) failed.</p>
|
||||
)}
|
||||
{item.summary.total_skipped > 0 && (
|
||||
<p className="text-yellow-600">{item.summary.total_skipped} track(s) skipped.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(item.status === "downloading" || item.status === "processing") &&
|
||||
item.type === "track" &&
|
||||
item.progress !== undefined && (
|
||||
<div className="mt-2 h-1.5 w-full bg-gray-200 rounded-full">
|
||||
<div
|
||||
className={`h-1.5 rounded-full ${statusInfo.color.replace("text", "bg")}`}
|
||||
style={{ width: `${item.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Queue = () => {
|
||||
const context = useContext(QueueContext);
|
||||
|
||||
if (!context) return null;
|
||||
const { items, isVisible, toggleVisibility, cancelAll, clearCompleted } = context;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const hasActive = items.some((item) => !isTerminalStatus(item.status));
|
||||
const hasFinished = items.some((item) => isTerminalStatus(item.status));
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 w-full max-w-md bg-white rounded-lg shadow-xl border border-gray-200 z-50">
|
||||
<header className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-bold">Download Queue ({items.length})</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={cancelAll}
|
||||
className="text-sm text-gray-500 hover:text-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!hasActive}
|
||||
aria-label="Cancel all active downloads"
|
||||
>
|
||||
Cancel All
|
||||
</button>
|
||||
<button
|
||||
onClick={clearCompleted}
|
||||
className="text-sm text-gray-500 hover:text-green-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!hasFinished}
|
||||
aria-label="Clear all finished downloads"
|
||||
>
|
||||
Clear Finished
|
||||
</button>
|
||||
<button onClick={toggleVisibility} className="text-gray-500 hover:text-gray-800" aria-label="Close queue">
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="p-4 overflow-y-auto max-h-96">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-4">The queue is empty.</p>
|
||||
) : (
|
||||
items.map((item) => <QueueItemCard key={item.id} item={item} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
48
spotizerr-ui/src/components/SearchResultCard.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
interface SearchResultCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
imageUrl?: string;
|
||||
type: "track" | "album" | "artist" | "playlist";
|
||||
onDownload?: () => void;
|
||||
}
|
||||
|
||||
export const SearchResultCard = ({ id, name, subtitle, imageUrl, type, onDownload }: SearchResultCardProps) => {
|
||||
const getLinkPath = () => {
|
||||
switch (type) {
|
||||
case "track":
|
||||
return `/track/${id}`;
|
||||
case "album":
|
||||
return `/album/${id}`;
|
||||
case "artist":
|
||||
return `/artist/${id}`;
|
||||
case "playlist":
|
||||
return `/playlist/${id}`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group flex flex-col rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-xl hover:shadow-2xl transition-shadow duration-300 ease-in-out">
|
||||
<div className="relative">
|
||||
<img src={imageUrl || "/placeholder.jpg"} alt={name} className="w-full aspect-square object-cover" />
|
||||
{onDownload && (
|
||||
<button
|
||||
onClick={onDownload}
|
||||
className="absolute bottom-2 right-2 p-2 bg-green-600 text-white rounded-full hover:bg-green-700 transition-opacity shadow-lg opacity-0 group-hover:opacity-100 duration-300"
|
||||
title={`Download ${type}`}
|
||||
>
|
||||
<img src="/download.svg" alt="Download" className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 flex-grow flex flex-col">
|
||||
<Link to={getLinkPath()} className="font-semibold text-gray-900 dark:text-white truncate block">
|
||||
{name}
|
||||
</Link>
|
||||
{subtitle && <p className="text-sm text-gray-600 dark:text-gray-400 mt-1 truncate">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
202
spotizerr-ui/src/components/config/AccountsTab.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useState } from "react";
|
||||
import { useForm, type SubmitHandler } from "react-hook-form";
|
||||
import apiClient from "../../lib/api-client";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
// --- Type Definitions ---
|
||||
type Service = "spotify" | "deezer";
|
||||
|
||||
interface Credential {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// A single form shape with optional fields
|
||||
interface AccountFormData {
|
||||
accountName: string;
|
||||
accountRegion?: string;
|
||||
authBlob?: string; // Spotify specific
|
||||
arl?: string; // Deezer specific
|
||||
}
|
||||
|
||||
// --- API Functions ---
|
||||
const fetchCredentials = async (service: Service): Promise<Credential[]> => {
|
||||
const { data } = await apiClient.get<string[]>(`/credentials/${service}`);
|
||||
return data.map((name) => ({ name }));
|
||||
};
|
||||
|
||||
const addCredential = async ({ service, data }: { service: Service; data: AccountFormData }) => {
|
||||
const payload =
|
||||
service === "spotify"
|
||||
? { blob_content: data.authBlob, region: data.accountRegion }
|
||||
: { arl: data.arl, region: data.accountRegion };
|
||||
|
||||
const { data: response } = await apiClient.post(`/credentials/${service}/${data.accountName}`, payload);
|
||||
return response;
|
||||
};
|
||||
|
||||
const deleteCredential = async ({ service, name }: { service: Service; name: string }) => {
|
||||
const { data: response } = await apiClient.delete(`/credentials/${service}/${name}`);
|
||||
return response;
|
||||
};
|
||||
|
||||
// --- Component ---
|
||||
export function AccountsTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeService, setActiveService] = useState<Service>("spotify");
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const { data: credentials, isLoading } = useQuery({
|
||||
queryKey: ["credentials", activeService],
|
||||
queryFn: () => fetchCredentials(activeService),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<AccountFormData>();
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: addCredential,
|
||||
onSuccess: () => {
|
||||
toast.success("Account added successfully!");
|
||||
queryClient.invalidateQueries({ queryKey: ["credentials", activeService] });
|
||||
setIsAdding(false);
|
||||
reset();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to add account: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteCredential,
|
||||
onSuccess: (_, variables) => {
|
||||
toast.success(`Account "${variables.name}" deleted.`);
|
||||
queryClient.invalidateQueries({ queryKey: ["credentials", activeService] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to delete account: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<AccountFormData> = (data) => {
|
||||
addMutation.mutate({ service: activeService, data });
|
||||
};
|
||||
|
||||
const renderAddForm = () => (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="p-4 border rounded-lg mt-4 space-y-4">
|
||||
<h4 className="font-semibold">Add New {activeService === "spotify" ? "Spotify" : "Deezer"} Account</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="accountName">Account Name</label>
|
||||
<input
|
||||
id="accountName"
|
||||
{...register("accountName", { required: "This field is required" })}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
{errors.accountName && <p className="text-red-500 text-sm">{errors.accountName.message}</p>}
|
||||
</div>
|
||||
{activeService === "spotify" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="authBlob">Auth Blob (JSON)</label>
|
||||
<textarea
|
||||
id="authBlob"
|
||||
{...register("authBlob", { required: activeService === "spotify" ? "Auth Blob is required" : false })}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={4}
|
||||
></textarea>
|
||||
{errors.authBlob && <p className="text-red-500 text-sm">{errors.authBlob.message}</p>}
|
||||
</div>
|
||||
)}
|
||||
{activeService === "deezer" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="arl">ARL Token</label>
|
||||
<input
|
||||
id="arl"
|
||||
{...register("arl", { required: activeService === "deezer" ? "ARL is required" : false })}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
{errors.arl && <p className="text-red-500 text-sm">{errors.arl.message}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="accountRegion">Region (Optional)</label>
|
||||
<input
|
||||
id="accountRegion"
|
||||
{...register("accountRegion")}
|
||||
placeholder="e.g. US, GB"
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addMutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{addMutation.isPending ? "Saving..." : "Save Account"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAdding(false)}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-2 border-b">
|
||||
<button
|
||||
onClick={() => setActiveService("spotify")}
|
||||
className={`p-2 ${activeService === "spotify" ? "border-b-2 border-blue-500 font-semibold" : ""}`}
|
||||
>
|
||||
Spotify
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveService("deezer")}
|
||||
className={`p-2 ${activeService === "deezer" ? "border-b-2 border-blue-500 font-semibold" : ""}`}
|
||||
>
|
||||
Deezer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p>Loading accounts...</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{credentials?.map((cred) => (
|
||||
<div
|
||||
key={cred.name}
|
||||
className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white rounded-md"
|
||||
>
|
||||
<span>{cred.name}</span>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate({ service: activeService, name: cred.name })}
|
||||
disabled={deleteMutation.isPending && deleteMutation.variables?.name === cred.name}
|
||||
className="text-red-500 hover:text-red-400"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAdding && (
|
||||
<button
|
||||
onClick={() => setIsAdding(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Add Account
|
||||
</button>
|
||||
)}
|
||||
{isAdding && renderAddForm()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
225
spotizerr-ui/src/components/config/DownloadsTab.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useForm, type SubmitHandler } from "react-hook-form";
|
||||
import apiClient from "../../lib/api-client";
|
||||
import { toast } from "sonner";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// --- Type Definitions ---
|
||||
interface DownloadSettings {
|
||||
maxConcurrentDownloads: number;
|
||||
realTime: boolean;
|
||||
fallback: boolean;
|
||||
convertTo: "MP3" | "AAC" | "OGG" | "OPUS" | "FLAC" | "WAV" | "ALAC" | "";
|
||||
bitrate: string;
|
||||
maxRetries: number;
|
||||
retryDelaySeconds: number;
|
||||
retryDelayIncrease: number;
|
||||
threads: number;
|
||||
path: string;
|
||||
skipExisting: boolean;
|
||||
m3u: boolean;
|
||||
hlsThreads: number;
|
||||
deezerQuality: "MP3_128" | "MP3_320" | "FLAC";
|
||||
spotifyQuality: "NORMAL" | "HIGH" | "VERY_HIGH";
|
||||
}
|
||||
|
||||
interface DownloadsTabProps {
|
||||
config: DownloadSettings;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const CONVERSION_FORMATS: Record<string, string[]> = {
|
||||
MP3: ["32k", "64k", "96k", "128k", "192k", "256k", "320k"],
|
||||
AAC: ["32k", "64k", "96k", "128k", "192k", "256k"],
|
||||
OGG: ["64k", "96k", "128k", "192k", "256k", "320k"],
|
||||
OPUS: ["32k", "64k", "96k", "128k", "192k", "256k"],
|
||||
FLAC: [],
|
||||
WAV: [],
|
||||
ALAC: [],
|
||||
};
|
||||
|
||||
// --- API Functions ---
|
||||
const saveDownloadConfig = async (data: Partial<DownloadSettings>) => {
|
||||
const { data: response } = await apiClient.post("/config", data);
|
||||
return response;
|
||||
};
|
||||
|
||||
// --- Component ---
|
||||
export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: saveDownloadConfig,
|
||||
onSuccess: () => {
|
||||
toast.success("Download settings saved successfully!");
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to save settings: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const { register, handleSubmit, watch, reset } = useForm<DownloadSettings>({
|
||||
defaultValues: config,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
reset(config);
|
||||
}
|
||||
}, [config, reset]);
|
||||
|
||||
const selectedFormat = watch("convertTo");
|
||||
|
||||
const onSubmit: SubmitHandler<DownloadSettings> = (data) => {
|
||||
mutation.mutate({
|
||||
...data,
|
||||
maxConcurrentDownloads: Number(data.maxConcurrentDownloads),
|
||||
maxRetries: Number(data.maxRetries),
|
||||
retryDelaySeconds: Number(data.retryDelaySeconds),
|
||||
retryDelayIncrease: Number(data.retryDelayIncrease),
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading download settings...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||
{/* Download Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Download Behavior</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="maxConcurrentDownloads">Max Concurrent Downloads</label>
|
||||
<input
|
||||
id="maxConcurrentDownloads"
|
||||
type="number"
|
||||
min="1"
|
||||
{...register("maxConcurrentDownloads")}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="realTimeToggle">Real-time downloading</label>
|
||||
<input id="realTimeToggle" type="checkbox" {...register("realTime")} className="h-6 w-6 rounded" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="fallbackToggle">Download Fallback</label>
|
||||
<input id="fallbackToggle" type="checkbox" {...register("fallback")} className="h-6 w-6 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Quality Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Source Quality</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="spotifyQuality">Spotify Quality</label>
|
||||
<select
|
||||
id="spotifyQuality"
|
||||
{...register("spotifyQuality")}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="NORMAL">OGG 96kbps</option>
|
||||
<option value="HIGH">OGG 160kbps</option>
|
||||
<option value="VERY_HIGH">OGG 320kbps (Premium)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="deezerQuality">Deezer Quality</label>
|
||||
<select
|
||||
id="deezerQuality"
|
||||
{...register("deezerQuality")}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="MP3_128">MP3 128kbps</option>
|
||||
<option value="MP3_320">MP3 320kbps</option>
|
||||
<option value="FLAC">FLAC (HiFi)</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
This sets the quality of the original download. Conversion settings below are applied after download.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Conversion Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Conversion</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="convertToSelect">Convert To Format</label>
|
||||
<select
|
||||
id="convertToSelect"
|
||||
{...register("convertTo")}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">No Conversion</option>
|
||||
{Object.keys(CONVERSION_FORMATS).map((format) => (
|
||||
<option key={format} value={format}>
|
||||
{format}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="bitrateSelect">Bitrate</label>
|
||||
<select
|
||||
id="bitrateSelect"
|
||||
{...register("bitrate")}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={!selectedFormat || CONVERSION_FORMATS[selectedFormat]?.length === 0}
|
||||
>
|
||||
<option value="">Auto</option>
|
||||
{(CONVERSION_FORMATS[selectedFormat] || []).map((rate) => (
|
||||
<option key={rate} value={rate}>
|
||||
{rate}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Retry Options */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Retries</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="maxRetries">Max Retry Attempts</label>
|
||||
<input
|
||||
id="maxRetries"
|
||||
type="number"
|
||||
min="0"
|
||||
{...register("maxRetries")}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="retryDelaySeconds">Initial Retry Delay (s)</label>
|
||||
<input
|
||||
id="retryDelaySeconds"
|
||||
type="number"
|
||||
min="1"
|
||||
{...register("retryDelaySeconds")}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="retryDelayIncrease">Retry Delay Increase (s)</label>
|
||||
<input
|
||||
id="retryDelayIncrease"
|
||||
type="number"
|
||||
min="0"
|
||||
{...register("retryDelayIncrease")}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? "Saving..." : "Save Download Settings"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
166
spotizerr-ui/src/components/config/FormattingTab.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useRef } from "react";
|
||||
import { useForm, type SubmitHandler } from "react-hook-form";
|
||||
import apiClient from "../../lib/api-client";
|
||||
import { toast } from "sonner";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
// --- Type Definitions ---
|
||||
interface FormattingSettings {
|
||||
customDirFormat: string;
|
||||
customTrackFormat: string;
|
||||
tracknumPadding: boolean;
|
||||
saveCover: boolean;
|
||||
track: string;
|
||||
album: string;
|
||||
playlist: string;
|
||||
compilation: string;
|
||||
}
|
||||
|
||||
interface FormattingTabProps {
|
||||
config: FormattingSettings;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// --- API Functions ---
|
||||
const saveFormattingConfig = async (data: Partial<FormattingSettings>) => {
|
||||
const { data: response } = await apiClient.post("/config", data);
|
||||
return response;
|
||||
};
|
||||
|
||||
// --- Placeholders ---
|
||||
const placeholders = {
|
||||
Common: {
|
||||
"%music%": "Track title",
|
||||
"%artist%": "Track artist",
|
||||
"%album%": "Album name",
|
||||
"%ar_album%": "Album artist",
|
||||
"%tracknum%": "Track number",
|
||||
"%year%": "Year of release",
|
||||
},
|
||||
Additional: {
|
||||
"%discnum%": "Disc number",
|
||||
"%date%": "Release date",
|
||||
"%genre%": "Music genre",
|
||||
"%isrc%": "ISRC",
|
||||
"%explicit%": "Explicit flag",
|
||||
"%duration%": "Track duration (s)",
|
||||
},
|
||||
};
|
||||
|
||||
const PlaceholderSelector = ({ onSelect }: { onSelect: (value: string) => void }) => (
|
||||
<select
|
||||
onChange={(e) => onSelect(e.target.value)}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm mt-1"
|
||||
>
|
||||
<option value="">-- Insert Placeholder --</option>
|
||||
{Object.entries(placeholders).map(([group, options]) => (
|
||||
<optgroup label={group} key={group}>
|
||||
{Object.entries(options).map(([value, label]) => (
|
||||
<option key={value} value={value}>{`${value} - ${label}`}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
// --- Component ---
|
||||
export function FormattingTab({ config, isLoading }: FormattingTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const dirInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const trackInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: saveFormattingConfig,
|
||||
onSuccess: () => {
|
||||
toast.success("Formatting settings saved!");
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to save settings: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const { register, handleSubmit, setValue } = useForm<FormattingSettings>({
|
||||
values: config,
|
||||
});
|
||||
|
||||
// Correctly register the refs for react-hook-form while also holding a local ref.
|
||||
const { ref: dirFormatRef, ...dirFormatRest } = register("customDirFormat");
|
||||
const { ref: trackFormatRef, ...trackFormatRest } = register("customTrackFormat");
|
||||
|
||||
const handlePlaceholderSelect =
|
||||
(field: "customDirFormat" | "customTrackFormat", inputRef: React.RefObject<HTMLInputElement | null>) =>
|
||||
(value: string) => {
|
||||
if (!value || !inputRef.current) return;
|
||||
const { selectionStart, selectionEnd } = inputRef.current;
|
||||
const currentValue = inputRef.current.value;
|
||||
const newValue =
|
||||
currentValue.substring(0, selectionStart ?? 0) + value + currentValue.substring(selectionEnd ?? 0);
|
||||
setValue(field, newValue);
|
||||
};
|
||||
|
||||
const onSubmit: SubmitHandler<FormattingSettings> = (data) => {
|
||||
mutation.mutate(data);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading formatting settings...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">File Naming</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="customDirFormat">Custom Directory Format</label>
|
||||
<input
|
||||
id="customDirFormat"
|
||||
type="text"
|
||||
{...dirFormatRest}
|
||||
ref={(e) => {
|
||||
dirFormatRef(e);
|
||||
dirInputRef.current = e;
|
||||
}}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<PlaceholderSelector onSelect={handlePlaceholderSelect("customDirFormat", dirInputRef)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="customTrackFormat">Custom Track Format</label>
|
||||
<input
|
||||
id="customTrackFormat"
|
||||
type="text"
|
||||
{...trackFormatRest}
|
||||
ref={(e) => {
|
||||
trackFormatRef(e);
|
||||
trackInputRef.current = e;
|
||||
}}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<PlaceholderSelector onSelect={handlePlaceholderSelect("customTrackFormat", trackInputRef)} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="tracknumPaddingToggle">Track Number Padding</label>
|
||||
<input
|
||||
id="tracknumPaddingToggle"
|
||||
type="checkbox"
|
||||
{...register("tracknumPadding")}
|
||||
className="h-6 w-6 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="saveCoverToggle">Save Album Cover</label>
|
||||
<input id="saveCoverToggle" type="checkbox" {...register("saveCover")} className="h-6 w-6 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? "Saving..." : "Save Formatting Settings"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
153
spotizerr-ui/src/components/config/GeneralTab.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useForm, type SubmitHandler } from "react-hook-form";
|
||||
import apiClient from "../../lib/api-client";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSettings } from "../../contexts/settings-context";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// --- Type Definitions ---
|
||||
interface Credential {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface GeneralSettings {
|
||||
service: "spotify" | "deezer";
|
||||
spotify: string;
|
||||
deezer: string;
|
||||
}
|
||||
|
||||
interface GeneralTabProps {
|
||||
config: GeneralSettings;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// --- API Functions ---
|
||||
const fetchCredentials = async (service: "spotify" | "deezer"): Promise<Credential[]> => {
|
||||
const { data } = await apiClient.get<string[]>(`/credentials/${service}`);
|
||||
return data.map((name) => ({ name }));
|
||||
};
|
||||
|
||||
const saveGeneralConfig = async (data: Partial<GeneralSettings>) => {
|
||||
const { data: response } = await apiClient.post("/config", data);
|
||||
return response;
|
||||
};
|
||||
|
||||
// --- Component ---
|
||||
export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { settings: globalSettings, isLoading: settingsLoading } = useSettings();
|
||||
|
||||
const { data: spotifyAccounts, isLoading: spotifyLoading } = useQuery({
|
||||
queryKey: ["credentials", "spotify"],
|
||||
queryFn: () => fetchCredentials("spotify"),
|
||||
});
|
||||
const { data: deezerAccounts, isLoading: deezerLoading } = useQuery({
|
||||
queryKey: ["credentials", "deezer"],
|
||||
queryFn: () => fetchCredentials("deezer"),
|
||||
});
|
||||
|
||||
const { register, handleSubmit, reset } = useForm<GeneralSettings>({
|
||||
defaultValues: config,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
reset(config);
|
||||
}
|
||||
}, [config, reset]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: saveGeneralConfig,
|
||||
onSuccess: () => {
|
||||
toast.success("General settings saved!");
|
||||
queryClient.invalidateQueries({ queryKey: ["config"] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(`Failed to save: ${e.message}`),
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<GeneralSettings> = (data) => {
|
||||
mutation.mutate(data);
|
||||
};
|
||||
|
||||
const isLoading = isConfigLoading || spotifyLoading || deezerLoading || settingsLoading;
|
||||
if (isLoading) return <p>Loading general settings...</p>;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Service Defaults</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="service">Default Service</label>
|
||||
<select
|
||||
id="service"
|
||||
{...register("service")}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="spotify">Spotify</option>
|
||||
<option value="deezer">Deezer</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Spotify Settings</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="spotifyAccount">Active Spotify Account</label>
|
||||
<select
|
||||
id="spotifyAccount"
|
||||
{...register("spotify")}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{spotifyAccounts?.map((acc) => (
|
||||
<option key={acc.name} value={acc.name}>
|
||||
{acc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Deezer Settings</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="deezerAccount">Active Deezer Account</label>
|
||||
<select
|
||||
id="deezerAccount"
|
||||
{...register("deezer")}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{deezerAccounts?.map((acc) => (
|
||||
<option key={acc.name} value={acc.name}>
|
||||
{acc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Content Filters</h3>
|
||||
<div className="form-item--row">
|
||||
<label>Filter Explicit Content</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-semibold ${globalSettings?.explicitFilter ? "text-green-400" : "text-red-400"}`}>
|
||||
{globalSettings?.explicitFilter ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<span className="text-xs bg-gray-600 text-white px-2 py-1 rounded-full">ENV</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
The explicit content filter is controlled by an environment variable and cannot be changed here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? "Saving..." : "Save General Settings"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
209
spotizerr-ui/src/components/config/ServerTab.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useEffect } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import apiClient from "../../lib/api-client";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
// --- Type Definitions ---
|
||||
interface SpotifyApiSettings {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
}
|
||||
|
||||
interface WebhookSettings {
|
||||
url: string;
|
||||
events: string[];
|
||||
available_events: string[]; // Provided by API, not saved
|
||||
}
|
||||
|
||||
// --- API Functions ---
|
||||
const fetchSpotifyApiConfig = async (): Promise<SpotifyApiSettings> => {
|
||||
const { data } = await apiClient.get("/credentials/spotify_api_config");
|
||||
return data;
|
||||
};
|
||||
const saveSpotifyApiConfig = (data: SpotifyApiSettings) => apiClient.put("/credentials/spotify_api_config", data);
|
||||
|
||||
const fetchWebhookConfig = async (): Promise<WebhookSettings> => {
|
||||
// Mock a response since backend endpoint doesn't exist
|
||||
// This will prevent the UI from crashing.
|
||||
return Promise.resolve({
|
||||
url: "",
|
||||
events: [],
|
||||
available_events: ["download_start", "download_complete", "download_failed", "watch_added"],
|
||||
});
|
||||
};
|
||||
const saveWebhookConfig = (data: Partial<WebhookSettings>) => {
|
||||
toast.info("Webhook configuration is not available.");
|
||||
return Promise.resolve(data);
|
||||
};
|
||||
const testWebhook = (url: string) => {
|
||||
toast.info("Webhook testing is not available.");
|
||||
return Promise.resolve(url);
|
||||
};
|
||||
|
||||
// --- Components ---
|
||||
function SpotifyApiForm() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading } = useQuery({ queryKey: ["spotifyApiConfig"], queryFn: fetchSpotifyApiConfig });
|
||||
const { register, handleSubmit, reset } = useForm<SpotifyApiSettings>();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: saveSpotifyApiConfig,
|
||||
onSuccess: () => {
|
||||
toast.success("Spotify API settings saved!");
|
||||
queryClient.invalidateQueries({ queryKey: ["spotifyApiConfig"] });
|
||||
},
|
||||
onError: (e) => toast.error(`Failed to save: ${e.message}`),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) reset(data);
|
||||
}, [data, reset]);
|
||||
|
||||
const onSubmit = (formData: SpotifyApiSettings) => mutation.mutate(formData);
|
||||
|
||||
if (isLoading) return <p>Loading Spotify API settings...</p>;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="client_id">Client ID</label>
|
||||
<input
|
||||
id="client_id"
|
||||
type="password"
|
||||
{...register("client_id")}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="client_secret">Client Secret</label>
|
||||
<input
|
||||
id="client_secret"
|
||||
type="password"
|
||||
{...register("client_secret")}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? "Saving..." : "Save Spotify API"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function WebhookForm() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading } = useQuery({ queryKey: ["webhookConfig"], queryFn: fetchWebhookConfig });
|
||||
const { register, handleSubmit, control, reset, watch } = useForm<WebhookSettings>();
|
||||
const currentUrl = watch("url");
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: saveWebhookConfig,
|
||||
onSuccess: () => {
|
||||
// No toast needed since the function shows one
|
||||
queryClient.invalidateQueries({ queryKey: ["webhookConfig"] });
|
||||
},
|
||||
onError: (e) => toast.error(`Failed to save: ${e.message}`),
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: testWebhook,
|
||||
onSuccess: () => {
|
||||
// No toast needed
|
||||
},
|
||||
onError: (e) => toast.error(`Webhook test failed: ${e.message}`),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) reset(data);
|
||||
}, [data, reset]);
|
||||
|
||||
const onSubmit = (formData: WebhookSettings) => mutation.mutate(formData);
|
||||
|
||||
if (isLoading) return <p>Loading Webhook settings...</p>;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="webhookUrl">Webhook URL</label>
|
||||
<input
|
||||
id="webhookUrl"
|
||||
type="url"
|
||||
{...register("url")}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="https://example.com/webhook"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label>Webhook Events</label>
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
{data?.available_events.map((event) => (
|
||||
<Controller
|
||||
key={event}
|
||||
name="events"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-5 w-5 rounded"
|
||||
checked={field.value?.includes(event) ?? false}
|
||||
onChange={(e) => {
|
||||
const value = field.value || [];
|
||||
const newValues = e.target.checked ? [...value, event] : value.filter((v) => v !== event);
|
||||
field.onChange(newValues);
|
||||
}}
|
||||
/>
|
||||
<span className="capitalize">{event.replace(/_/g, " ")}</span>
|
||||
</label>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? "Saving..." : "Save Webhook"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => testMutation.mutate(currentUrl)}
|
||||
disabled={!currentUrl || testMutation.isPending}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServerTab() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">Spotify API</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Provide your own API credentials to avoid rate-limiting issues.</p>
|
||||
<SpotifyApiForm />
|
||||
</div>
|
||||
<hr className="border-gray-600" />
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">Webhooks</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Get notifications for events like download completion. (Currently disabled)
|
||||
</p>
|
||||
<WebhookForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
spotizerr-ui/src/components/config/WatchTab.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useEffect } from "react";
|
||||
import { useForm, type SubmitHandler, Controller } from "react-hook-form";
|
||||
import apiClient from "../../lib/api-client";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
// --- Type Definitions ---
|
||||
const ALBUM_GROUPS = ["album", "single", "compilation", "appears_on"] as const;
|
||||
|
||||
type AlbumGroup = (typeof ALBUM_GROUPS)[number];
|
||||
|
||||
interface WatchSettings {
|
||||
enabled: boolean;
|
||||
watchPollIntervalSeconds: number;
|
||||
watchedArtistAlbumGroup: AlbumGroup[];
|
||||
}
|
||||
|
||||
// --- API Functions ---
|
||||
const fetchWatchConfig = async (): Promise<WatchSettings> => {
|
||||
const { data } = await apiClient.get("/config/watch");
|
||||
return data;
|
||||
};
|
||||
|
||||
const saveWatchConfig = async (data: Partial<WatchSettings>) => {
|
||||
const { data: response } = await apiClient.post("/config/watch", data);
|
||||
return response;
|
||||
};
|
||||
|
||||
// --- Component ---
|
||||
export function WatchTab() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: config, isLoading } = useQuery({
|
||||
queryKey: ["watchConfig"],
|
||||
queryFn: fetchWatchConfig,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: saveWatchConfig,
|
||||
onSuccess: () => {
|
||||
toast.success("Watch settings saved successfully!");
|
||||
queryClient.invalidateQueries({ queryKey: ["watchConfig"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to save settings: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const { register, handleSubmit, control, reset } = useForm<WatchSettings>();
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
reset(config);
|
||||
}
|
||||
}, [config, reset]);
|
||||
|
||||
const onSubmit: SubmitHandler<WatchSettings> = (data) => {
|
||||
mutation.mutate({
|
||||
...data,
|
||||
watchPollIntervalSeconds: Number(data.watchPollIntervalSeconds),
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading watch settings...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Watchlist Behavior</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="watchEnabledToggle">Enable Watchlist</label>
|
||||
<input id="watchEnabledToggle" type="checkbox" {...register("enabled")} className="h-6 w-6 rounded" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="watchPollIntervalSeconds">Watch Poll Interval (seconds)</label>
|
||||
<input
|
||||
id="watchPollIntervalSeconds"
|
||||
type="number"
|
||||
min="60"
|
||||
{...register("watchPollIntervalSeconds")}
|
||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">How often to check watched items for updates.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold">Artist Album Groups</h3>
|
||||
<p className="text-sm text-gray-500">Select which album groups to monitor for watched artists.</p>
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
{ALBUM_GROUPS.map((group) => (
|
||||
<Controller
|
||||
key={group}
|
||||
name="watchedArtistAlbumGroup"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-5 w-5 rounded"
|
||||
checked={field.value?.includes(group) ?? false}
|
||||
onChange={(e) => {
|
||||
const value = field.value || [];
|
||||
const newValues = e.target.checked ? [...value, group] : value.filter((v) => v !== group);
|
||||
field.onChange(newValues);
|
||||
}}
|
||||
/>
|
||||
<span className="capitalize">{group.replace("_", " ")}</span>
|
||||
</label>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? "Saving..." : "Save Watch Settings"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
411
spotizerr-ui/src/contexts/QueueProvider.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
import { useState, useCallback, type ReactNode, useEffect, useRef } from "react";
|
||||
import apiClient from "../lib/api-client";
|
||||
import {
|
||||
QueueContext,
|
||||
type QueueItem,
|
||||
type DownloadType,
|
||||
type QueueStatus,
|
||||
} from "./queue-context";
|
||||
import { toast } from "sonner";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type {
|
||||
CallbackObject,
|
||||
SummaryObject,
|
||||
ProcessingCallbackObject,
|
||||
TrackCallbackObject,
|
||||
AlbumCallbackObject,
|
||||
PlaylistCallbackObject,
|
||||
} from "@/types/callbacks";
|
||||
|
||||
const isTerminalStatus = (status: QueueStatus) =>
|
||||
["completed", "error", "cancelled", "skipped", "done"].includes(status);
|
||||
|
||||
function isProcessingCallback(obj: CallbackObject): obj is ProcessingCallbackObject {
|
||||
return obj && "status" in obj && obj.status === "processing";
|
||||
}
|
||||
|
||||
function isTrackCallback(obj: any): obj is TrackCallbackObject {
|
||||
return obj && "track" in obj && "status_info" in obj;
|
||||
}
|
||||
|
||||
function isAlbumCallback(obj: any): obj is AlbumCallbackObject {
|
||||
return obj && "album" in obj && "status_info" in obj;
|
||||
}
|
||||
|
||||
function isPlaylistCallback(obj: any): obj is PlaylistCallbackObject {
|
||||
return obj && "playlist" in obj && "status_info" in obj;
|
||||
}
|
||||
|
||||
export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<QueueItem[]>([]);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const pollingIntervals = useRef<Record<string, number>>({});
|
||||
|
||||
const stopPolling = useCallback((internalId: string) => {
|
||||
if (pollingIntervals.current[internalId]) {
|
||||
clearInterval(pollingIntervals.current[internalId]);
|
||||
delete pollingIntervals.current[internalId];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateItemFromPrgs = useCallback((item: QueueItem, prgsData: any): QueueItem => {
|
||||
const updatedItem: QueueItem = { ...item };
|
||||
const { last_line, summary, status, name, artist, download_type } = prgsData;
|
||||
|
||||
if (status) updatedItem.status = status as QueueStatus;
|
||||
if (summary) updatedItem.summary = summary;
|
||||
if (name) updatedItem.name = name;
|
||||
if (artist) updatedItem.artist = artist;
|
||||
if (download_type) updatedItem.type = download_type;
|
||||
|
||||
if (last_line) {
|
||||
if (isProcessingCallback(last_line)) {
|
||||
updatedItem.status = "processing";
|
||||
} else if (isTrackCallback(last_line)) {
|
||||
const { status_info, track, current_track, total_tracks, parent } = last_line;
|
||||
updatedItem.currentTrackTitle = track.title;
|
||||
if (current_track) updatedItem.currentTrackNumber = current_track;
|
||||
if (total_tracks) updatedItem.totalTracks = total_tracks;
|
||||
updatedItem.status = (parent && ["done", "skipped"].includes(status_info.status)) ? "downloading" : status_info.status as QueueStatus;
|
||||
if (status_info.status === "skipped") {
|
||||
updatedItem.error = status_info.reason;
|
||||
} else if (status_info.status === "error" || status_info.status === "retrying") {
|
||||
updatedItem.error = status_info.error;
|
||||
}
|
||||
if (!parent && status_info.status === "done" && status_info.summary) updatedItem.summary = status_info.summary;
|
||||
} else if (isAlbumCallback(last_line)) {
|
||||
const { status_info, album } = last_line;
|
||||
updatedItem.status = status_info.status as QueueStatus;
|
||||
updatedItem.name = album.title;
|
||||
updatedItem.artist = album.artists.map(a => a.name).join(", ");
|
||||
if (status_info.status === "done") {
|
||||
if (status_info.summary) updatedItem.summary = status_info.summary;
|
||||
updatedItem.currentTrackTitle = undefined;
|
||||
} else if (status_info.status === "error") {
|
||||
updatedItem.error = status_info.error;
|
||||
}
|
||||
} else if (isPlaylistCallback(last_line)) {
|
||||
const { status_info, playlist } = last_line;
|
||||
updatedItem.status = status_info.status as QueueStatus;
|
||||
updatedItem.name = playlist.title;
|
||||
updatedItem.playlistOwner = playlist.owner.name;
|
||||
if (status_info.status === "done") {
|
||||
if (status_info.summary) updatedItem.summary = status_info.summary;
|
||||
updatedItem.currentTrackTitle = undefined;
|
||||
} else if (status_info.status === "error") {
|
||||
updatedItem.error = status_info.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedItem;
|
||||
}, []);
|
||||
|
||||
const startPolling = useCallback(
|
||||
(taskId: string) => {
|
||||
if (pollingIntervals.current[taskId]) return;
|
||||
|
||||
const intervalId = window.setInterval(async () => {
|
||||
try {
|
||||
const response = await apiClient.get<any>(`/prgs/${taskId}`);
|
||||
setItems(prev =>
|
||||
prev.map(item => {
|
||||
if (item.taskId !== taskId) return item;
|
||||
const updatedItem = updateItemFromPrgs(item, response.data);
|
||||
if (isTerminalStatus(updatedItem.status as QueueStatus)) {
|
||||
stopPolling(taskId);
|
||||
}
|
||||
return updatedItem;
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Polling failed for task ${taskId}:`, error);
|
||||
stopPolling(taskId);
|
||||
setItems(prev =>
|
||||
prev.map(i =>
|
||||
i.taskId === taskId
|
||||
? { ...i, status: "error", error: "Connection lost" }
|
||||
: i,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
pollingIntervals.current[taskId] = intervalId;
|
||||
},
|
||||
[stopPolling, updateItemFromPrgs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchQueue = async () => {
|
||||
try {
|
||||
const response = await apiClient.get<any[]>("/prgs/list");
|
||||
const backendItems = response.data.map((task: any) => {
|
||||
const spotifyId = task.original_url?.split("/").pop() || "";
|
||||
const baseItem: QueueItem = {
|
||||
id: task.task_id,
|
||||
taskId: task.task_id,
|
||||
name: task.name || "Unknown",
|
||||
type: task.download_type || "track",
|
||||
spotifyId: spotifyId,
|
||||
status: "initializing",
|
||||
artist: task.artist,
|
||||
};
|
||||
return updateItemFromPrgs(baseItem, task);
|
||||
});
|
||||
|
||||
setItems(backendItems);
|
||||
|
||||
backendItems.forEach((item: QueueItem) => {
|
||||
if (item.taskId && !isTerminalStatus(item.status)) {
|
||||
startPolling(item.taskId);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch queue from backend:", error);
|
||||
toast.error("Could not load queue. Please refresh the page.");
|
||||
}
|
||||
};
|
||||
|
||||
fetchQueue();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const addItem = useCallback(
|
||||
async (item: { name: string; type: DownloadType; spotifyId: string; artist?: string }) => {
|
||||
const internalId = uuidv4();
|
||||
const newItem: QueueItem = {
|
||||
...item,
|
||||
id: internalId,
|
||||
status: "initializing",
|
||||
};
|
||||
setItems(prev => [newItem, ...prev]);
|
||||
setIsVisible(true);
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<{ task_id: string }>(
|
||||
`/${item.type}/download/${item.spotifyId}`,
|
||||
);
|
||||
const { task_id: taskId } = response.data;
|
||||
|
||||
setItems(prev =>
|
||||
prev.map(i =>
|
||||
i.id === internalId
|
||||
? { ...i, id: taskId, taskId, status: "queued" }
|
||||
: i,
|
||||
),
|
||||
);
|
||||
|
||||
startPolling(taskId);
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to start download for ${item.name}:`, error);
|
||||
toast.error(`Failed to start download for ${item.name}`);
|
||||
setItems(prev =>
|
||||
prev.map(i =>
|
||||
i.id === internalId
|
||||
? {
|
||||
...i,
|
||||
status: "error",
|
||||
error: "Failed to start download task.",
|
||||
}
|
||||
: i,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[isVisible, startPolling],
|
||||
);
|
||||
|
||||
const removeItem = useCallback((id: string) => {
|
||||
const item = items.find(i => i.id === id);
|
||||
if (item && item.taskId) {
|
||||
stopPolling(item.taskId);
|
||||
apiClient.delete(`/prgs/delete/${item.taskId}`).catch(err => {
|
||||
console.error(`Failed to delete task ${item.taskId} from backend`, err);
|
||||
// Proceed with frontend removal anyway
|
||||
});
|
||||
}
|
||||
setItems(prev => prev.filter(i => i.id !== id));
|
||||
}, [items, stopPolling]);
|
||||
|
||||
const cancelItem = useCallback(
|
||||
async (id: string) => {
|
||||
const item = items.find(i => i.id === id);
|
||||
if (!item || !item.taskId) return;
|
||||
|
||||
try {
|
||||
await apiClient.post(`/prgs/cancel/${item.taskId}`);
|
||||
stopPolling(item.taskId);
|
||||
setItems(prev =>
|
||||
prev.map(i =>
|
||||
i.id === id
|
||||
? {
|
||||
...i,
|
||||
status: "cancelled",
|
||||
}
|
||||
: i,
|
||||
),
|
||||
);
|
||||
toast.info(`Cancelled download: ${item.name}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to cancel task ${item.taskId}:`, error);
|
||||
toast.error(`Failed to cancel download: ${item.name}`);
|
||||
}
|
||||
},
|
||||
[items, stopPolling],
|
||||
);
|
||||
|
||||
const retryItem = useCallback(
|
||||
(id: string) => {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (item && item.taskId) {
|
||||
setItems((prev) =>
|
||||
prev.map((i) =>
|
||||
i.id === id
|
||||
? {
|
||||
...i,
|
||||
status: "pending",
|
||||
error: undefined,
|
||||
}
|
||||
: i,
|
||||
),
|
||||
);
|
||||
startPolling(item.taskId);
|
||||
toast.info(`Retrying download: ${item.name}`);
|
||||
}
|
||||
},
|
||||
[items, startPolling],
|
||||
);
|
||||
|
||||
const toggleVisibility = useCallback(() => {
|
||||
setIsVisible((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const clearCompleted = useCallback(() => {
|
||||
setItems((prev) => prev.filter((item) => !isTerminalStatus(item.status) || item.status === "error"));
|
||||
}, []);
|
||||
|
||||
const cancelAll = useCallback(async () => {
|
||||
const activeItems = items.filter((item) => item.taskId && !isTerminalStatus(item.status));
|
||||
if (activeItems.length === 0) {
|
||||
toast.info("No active downloads to cancel.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const taskIds = activeItems.map((item) => item.taskId!);
|
||||
await apiClient.post("/prgs/cancel/all", { task_ids: taskIds });
|
||||
|
||||
activeItems.forEach((item) => stopPolling(item.id));
|
||||
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
taskIds.includes(item.taskId!)
|
||||
? {
|
||||
...item,
|
||||
status: "cancelled",
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
toast.info("Cancelled all active downloads.");
|
||||
} catch (error) {
|
||||
console.error("Failed to cancel all tasks:", error);
|
||||
toast.error("Failed to cancel all downloads.");
|
||||
}
|
||||
}, [items, stopPolling]);
|
||||
|
||||
const clearAllPolls = useCallback(() => {
|
||||
Object.values(pollingIntervals.current).forEach(clearInterval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
interface PrgsListEntry {
|
||||
task_id: string;
|
||||
name?: string;
|
||||
download_type?: string;
|
||||
status?: string;
|
||||
original_request?: { url?: string };
|
||||
last_status_obj?: {
|
||||
progress?: number;
|
||||
current_track?: number;
|
||||
total_tracks?: number;
|
||||
error?: string;
|
||||
can_retry?: boolean;
|
||||
};
|
||||
summary?: SummaryObject;
|
||||
}
|
||||
|
||||
const syncActiveTasks = async () => {
|
||||
try {
|
||||
const response = await apiClient.get<PrgsListEntry[]>("/prgs/list");
|
||||
const activeTasks: QueueItem[] = response.data
|
||||
.filter((task) => {
|
||||
const status = task.status?.toLowerCase();
|
||||
return status && !isTerminalStatus(status as QueueStatus);
|
||||
})
|
||||
.map((task) => {
|
||||
const url = task.original_request?.url || "";
|
||||
const spotifyId = url.includes("spotify.com") ? url.split("/").pop() || "" : "";
|
||||
let type: DownloadType = "track";
|
||||
if (task.download_type === "album") type = "album";
|
||||
if (task.download_type === "playlist") type = "playlist";
|
||||
if (task.download_type === "artist") type = "artist";
|
||||
|
||||
const queueItem: QueueItem = {
|
||||
id: task.task_id,
|
||||
taskId: task.task_id,
|
||||
name: task.name || "Unknown",
|
||||
type,
|
||||
spotifyId,
|
||||
status: (task.status?.toLowerCase() || "pending") as QueueStatus,
|
||||
progress: task.last_status_obj?.progress,
|
||||
currentTrackNumber: task.last_status_obj?.current_track,
|
||||
totalTracks: task.last_status_obj?.total_tracks,
|
||||
error: task.last_status_obj?.error,
|
||||
canRetry: task.last_status_obj?.can_retry,
|
||||
summary: task.summary,
|
||||
};
|
||||
return queueItem;
|
||||
});
|
||||
|
||||
setItems((prevItems) => {
|
||||
const newItems = [...prevItems];
|
||||
activeTasks.forEach((task) => {
|
||||
const existingIndex = newItems.findIndex((item) => item.id === task.id);
|
||||
if (existingIndex === -1) {
|
||||
newItems.push(task);
|
||||
} else {
|
||||
newItems[existingIndex] = { ...newItems[existingIndex], ...task };
|
||||
}
|
||||
if (task.taskId && !isTerminalStatus(task.status)) {
|
||||
if (task.taskId && !isTerminalStatus(task.status)) {
|
||||
startPolling(task.taskId);
|
||||
}
|
||||
}
|
||||
});
|
||||
return newItems;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to sync active tasks:", error);
|
||||
}
|
||||
};
|
||||
|
||||
syncActiveTasks();
|
||||
return () => clearAllPolls();
|
||||
}, [startPolling, clearAllPolls]);
|
||||
|
||||
const value = {
|
||||
items,
|
||||
isVisible,
|
||||
addItem,
|
||||
removeItem,
|
||||
retryItem,
|
||||
toggleVisibility,
|
||||
clearCompleted,
|
||||
cancelAll,
|
||||
cancelItem,
|
||||
};
|
||||
|
||||
return <QueueContext.Provider value={value}>{children}</QueueContext.Provider>;
|
||||
}
|
||||
135
spotizerr-ui/src/contexts/SettingsProvider.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { type ReactNode } from "react";
|
||||
import apiClient from "../lib/api-client";
|
||||
import { SettingsContext, type AppSettings } from "./settings-context";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
// --- Case Conversion Utility ---
|
||||
// This is added here to simplify the fix and avoid module resolution issues.
|
||||
function snakeToCamel(str: string): string {
|
||||
return str.replace(/(_\w)/g, (m) => m[1].toUpperCase());
|
||||
}
|
||||
|
||||
function convertKeysToCamelCase(obj: unknown): unknown {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((v) => convertKeysToCamelCase(v));
|
||||
}
|
||||
if (typeof obj === "object" && obj !== null) {
|
||||
return Object.keys(obj).reduce((acc: Record<string, unknown>, key: string) => {
|
||||
const camelKey = snakeToCamel(key);
|
||||
acc[camelKey] = convertKeysToCamelCase((obj as Record<string, unknown>)[key]);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Redefine AppSettings to match the flat structure of the API response
|
||||
export type FlatAppSettings = {
|
||||
service: "spotify" | "deezer";
|
||||
spotify: string;
|
||||
spotifyQuality: "NORMAL" | "HIGH" | "VERY_HIGH";
|
||||
deezer: string;
|
||||
deezerQuality: "MP3_128" | "MP3_320" | "FLAC";
|
||||
maxConcurrentDownloads: number;
|
||||
realTime: boolean;
|
||||
fallback: boolean;
|
||||
convertTo: "MP3" | "AAC" | "OGG" | "OPUS" | "FLAC" | "WAV" | "ALAC" | "";
|
||||
bitrate: string;
|
||||
maxRetries: number;
|
||||
retryDelaySeconds: number;
|
||||
retryDelayIncrease: number;
|
||||
customDirFormat: string;
|
||||
customTrackFormat: string;
|
||||
tracknumPadding: boolean;
|
||||
saveCover: boolean;
|
||||
explicitFilter: boolean;
|
||||
// Add other fields from the old AppSettings as needed by other parts of the app
|
||||
watch: AppSettings["watch"];
|
||||
// Add defaults for the new download properties
|
||||
threads: number;
|
||||
path: string;
|
||||
skipExisting: boolean;
|
||||
m3u: boolean;
|
||||
hlsThreads: number;
|
||||
// Add defaults for the new formatting properties
|
||||
track: string;
|
||||
album: string;
|
||||
playlist: string;
|
||||
compilation: string;
|
||||
};
|
||||
|
||||
const defaultSettings: FlatAppSettings = {
|
||||
service: "spotify",
|
||||
spotify: "",
|
||||
spotifyQuality: "NORMAL",
|
||||
deezer: "",
|
||||
deezerQuality: "MP3_128",
|
||||
maxConcurrentDownloads: 3,
|
||||
realTime: false,
|
||||
fallback: false,
|
||||
convertTo: "",
|
||||
bitrate: "",
|
||||
maxRetries: 3,
|
||||
retryDelaySeconds: 5,
|
||||
retryDelayIncrease: 5,
|
||||
customDirFormat: "%ar_album%/%album%",
|
||||
customTrackFormat: "%tracknum%. %music%",
|
||||
tracknumPadding: true,
|
||||
saveCover: true,
|
||||
explicitFilter: false,
|
||||
// Add defaults for the new download properties
|
||||
threads: 4,
|
||||
path: "/downloads",
|
||||
skipExisting: true,
|
||||
m3u: false,
|
||||
hlsThreads: 8,
|
||||
// Add defaults for the new formatting properties
|
||||
track: "{artist_name}/{album_name}/{track_number} - {track_name}",
|
||||
album: "{artist_name}/{album_name}",
|
||||
playlist: "Playlists/{playlist_name}",
|
||||
compilation: "Compilations/{album_name}",
|
||||
watch: {
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
interface FetchedCamelCaseSettings {
|
||||
watchEnabled?: boolean;
|
||||
watch?: { enabled: boolean };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const fetchSettings = async (): Promise<FlatAppSettings> => {
|
||||
const [{ data: generalConfig }, { data: watchConfig }] = await Promise.all([
|
||||
apiClient.get("/config"),
|
||||
apiClient.get("/config/watch"),
|
||||
]);
|
||||
|
||||
const combinedConfig = {
|
||||
...generalConfig,
|
||||
watch: watchConfig,
|
||||
};
|
||||
|
||||
// Transform the keys before returning the data
|
||||
const camelData = convertKeysToCamelCase(combinedConfig) as FetchedCamelCaseSettings;
|
||||
|
||||
return camelData as unknown as FlatAppSettings;
|
||||
};
|
||||
|
||||
export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
const {
|
||||
data: settings,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["config"],
|
||||
queryFn: fetchSettings,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Use default settings on error to prevent app crash
|
||||
const value = { settings: isError ? defaultSettings : settings || null, isLoading };
|
||||
|
||||
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
|
||||
}
|
||||
64
spotizerr-ui/src/contexts/queue-context.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { SummaryObject } from "@/types/callbacks";
|
||||
|
||||
export type DownloadType = "track" | "album" | "artist" | "playlist";
|
||||
export type QueueStatus =
|
||||
| "initializing"
|
||||
| "pending"
|
||||
| "downloading"
|
||||
| "processing"
|
||||
| "completed"
|
||||
| "error"
|
||||
| "skipped"
|
||||
| "cancelled"
|
||||
| "done"
|
||||
| "queued"
|
||||
| "retrying";
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: DownloadType;
|
||||
spotifyId: string;
|
||||
|
||||
// Display Info
|
||||
artist?: string;
|
||||
albumName?: string;
|
||||
playlistOwner?: string;
|
||||
currentTrackTitle?: string;
|
||||
|
||||
// Status and Progress
|
||||
status: QueueStatus;
|
||||
taskId?: string;
|
||||
error?: string;
|
||||
canRetry?: boolean;
|
||||
progress?: number;
|
||||
speed?: string;
|
||||
size?: string;
|
||||
eta?: string;
|
||||
currentTrackNumber?: number;
|
||||
totalTracks?: number;
|
||||
summary?: SummaryObject;
|
||||
}
|
||||
|
||||
export interface QueueContextType {
|
||||
items: QueueItem[];
|
||||
isVisible: boolean;
|
||||
addItem: (item: { name: string; type: DownloadType; spotifyId: string; artist?: string }) => void;
|
||||
removeItem: (id: string) => void;
|
||||
retryItem: (id: string) => void;
|
||||
toggleVisibility: () => void;
|
||||
clearCompleted: () => void;
|
||||
cancelAll: () => void;
|
||||
cancelItem: (id: string) => void;
|
||||
}
|
||||
|
||||
export const QueueContext = createContext<QueueContextType | undefined>(undefined);
|
||||
|
||||
export function useQueue() {
|
||||
const context = useContext(QueueContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useQueue must be used within a QueueProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
54
spotizerr-ui/src/contexts/settings-context.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
// This new type reflects the flat structure of the /api/config response
|
||||
export interface AppSettings {
|
||||
service: "spotify" | "deezer";
|
||||
spotify: string;
|
||||
spotifyQuality: "NORMAL" | "HIGH" | "VERY_HIGH";
|
||||
deezer: string;
|
||||
deezerQuality: "MP3_128" | "MP3_320" | "FLAC";
|
||||
maxConcurrentDownloads: number;
|
||||
realTime: boolean;
|
||||
fallback: boolean;
|
||||
convertTo: "MP3" | "AAC" | "OGG" | "OPUS" | "FLAC" | "WAV" | "ALAC" | "";
|
||||
bitrate: string;
|
||||
maxRetries: number;
|
||||
retryDelaySeconds: number;
|
||||
retryDelayIncrease: number;
|
||||
customDirFormat: string;
|
||||
customTrackFormat: string;
|
||||
tracknumPadding: boolean;
|
||||
saveCover: boolean;
|
||||
explicitFilter: boolean;
|
||||
// Properties from the old 'downloads' object
|
||||
threads: number;
|
||||
path: string;
|
||||
skipExisting: boolean;
|
||||
m3u: boolean;
|
||||
hlsThreads: number;
|
||||
// Properties from the old 'formatting' object
|
||||
track: string;
|
||||
album: string;
|
||||
playlist: string;
|
||||
compilation: string;
|
||||
watch: {
|
||||
enabled: boolean;
|
||||
// Add other watch properties from the old type if they still exist in the API response
|
||||
};
|
||||
// Add other root-level properties from the API if they exist
|
||||
}
|
||||
|
||||
export interface SettingsContextType {
|
||||
settings: AppSettings | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const SettingsContext = createContext<SettingsContextType | undefined>(undefined);
|
||||
|
||||
export function useSettings() {
|
||||
const context = useContext(SettingsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useSettings must be used within a SettingsProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
7
spotizerr-ui/src/index.css
Normal file
@@ -0,0 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
a {
|
||||
@apply no-underline hover:underline cursor-pointer;
|
||||
}
|
||||
}
|
||||
41
spotizerr-ui/src/lib/api-client.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: "/api",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 10000, // 10 seconds timeout
|
||||
});
|
||||
|
||||
// Response interceptor for error handling
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
const contentType = response.headers["content-type"];
|
||||
if (contentType && contentType.includes("application/json")) {
|
||||
return response;
|
||||
}
|
||||
// If the response is not JSON, reject it to trigger the error handling
|
||||
const error = new Error("Invalid response type. Expected JSON.");
|
||||
toast.error("API Error", {
|
||||
description: "Received an invalid response from the server. Expected JSON data.",
|
||||
});
|
||||
return Promise.reject(error);
|
||||
},
|
||||
(error) => {
|
||||
if (error.code === "ECONNABORTED") {
|
||||
toast.error("Request Timed Out", {
|
||||
description: "The server did not respond in time. Please try again later.",
|
||||
});
|
||||
} else {
|
||||
const errorMessage = error.response?.data?.error || error.message || "An unknown error occurred.";
|
||||
toast.error("API Error", {
|
||||
description: errorMessage,
|
||||
});
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
11
spotizerr-ui/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import { router } from "./router";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
113
spotizerr-ui/src/router.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { createRouter, createRootRoute, createRoute } from "@tanstack/react-router";
|
||||
import { Root } from "./routes/root";
|
||||
import { Album } from "./routes/album";
|
||||
import { Artist } from "./routes/artist";
|
||||
import { Track } from "./routes/track";
|
||||
import { Home } from "./routes/home";
|
||||
import { Config } from "./routes/config";
|
||||
import { Playlist } from "./routes/playlist";
|
||||
import { History } from "./routes/history";
|
||||
import { Watchlist } from "./routes/watchlist";
|
||||
import apiClient from "./lib/api-client";
|
||||
import type { SearchResult } from "./types/spotify";
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: Root,
|
||||
});
|
||||
|
||||
export const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
component: Home,
|
||||
validateSearch: (
|
||||
search: Record<string, unknown>,
|
||||
): { q?: string; type?: "track" | "album" | "artist" | "playlist" } => {
|
||||
return {
|
||||
q: search.q as string | undefined,
|
||||
type: search.type as "track" | "album" | "artist" | "playlist" | undefined,
|
||||
};
|
||||
},
|
||||
loaderDeps: ({ search: { q, type } }) => ({ q, type: type || "track" }),
|
||||
loader: async ({ deps: { q, type } }) => {
|
||||
if (!q || q.length < 3) return { items: [] };
|
||||
|
||||
const spotifyUrlRegex = /https:\/\/open\.spotify\.com\/(playlist|album|artist|track)\/([a-zA-Z0-9]+)/;
|
||||
const match = q.match(spotifyUrlRegex);
|
||||
|
||||
if (match) {
|
||||
const [, urlType, id] = match;
|
||||
const response = await apiClient.get<SearchResult>(`/${urlType}/info?id=${id}`);
|
||||
return { items: [{ ...response.data, model: urlType as "track" | "album" | "artist" | "playlist" }] };
|
||||
}
|
||||
|
||||
const response = await apiClient.get<{ items: SearchResult[] }>(`/search?q=${q}&search_type=${type}&limit=50`);
|
||||
const augmentedResults = response.data.items.map((item) => ({
|
||||
...item,
|
||||
model: type,
|
||||
}));
|
||||
return { items: augmentedResults };
|
||||
},
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
const albumRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/album/$albumId",
|
||||
component: Album,
|
||||
});
|
||||
|
||||
const artistRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/artist/$artistId",
|
||||
component: Artist,
|
||||
});
|
||||
|
||||
const trackRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/track/$trackId",
|
||||
component: Track,
|
||||
});
|
||||
|
||||
const configRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/config",
|
||||
component: Config,
|
||||
});
|
||||
|
||||
const playlistRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/playlist/$playlistId",
|
||||
component: Playlist,
|
||||
});
|
||||
|
||||
const historyRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/history",
|
||||
component: History,
|
||||
});
|
||||
|
||||
const watchlistRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/watchlist",
|
||||
component: Watchlist,
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
albumRoute,
|
||||
artistRoute,
|
||||
trackRoute,
|
||||
configRoute,
|
||||
playlistRoute,
|
||||
historyRoute,
|
||||
watchlistRoute,
|
||||
]);
|
||||
|
||||
export const router = createRouter({ routeTree });
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
186
spotizerr-ui/src/routes/album.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { useEffect, useState, useContext } from "react";
|
||||
import apiClient from "../lib/api-client";
|
||||
import { QueueContext } from "../contexts/queue-context";
|
||||
import { useSettings } from "../contexts/settings-context";
|
||||
import type { AlbumType, TrackType } from "../types/spotify";
|
||||
import { toast } from "sonner";
|
||||
import { FaArrowLeft } from "react-icons/fa";
|
||||
|
||||
export const Album = () => {
|
||||
const { albumId } = useParams({ from: "/album/$albumId" });
|
||||
const [album, setAlbum] = useState<AlbumType | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const context = useContext(QueueContext);
|
||||
const { settings } = useSettings();
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useQueue must be used within a QueueProvider");
|
||||
}
|
||||
const { addItem } = context;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAlbum = async () => {
|
||||
try {
|
||||
const response = await apiClient.get(`/album/info?id=${albumId}`);
|
||||
setAlbum(response.data);
|
||||
} catch (err) {
|
||||
setError("Failed to load album");
|
||||
console.error("Error fetching album:", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (albumId) {
|
||||
fetchAlbum();
|
||||
}
|
||||
}, [albumId]);
|
||||
|
||||
const handleDownloadTrack = (track: TrackType) => {
|
||||
if (!track.id) return;
|
||||
toast.info(`Adding ${track.name} to queue...`);
|
||||
addItem({ spotifyId: track.id, type: "track", name: track.name });
|
||||
};
|
||||
|
||||
const handleDownloadAlbum = () => {
|
||||
if (!album) return;
|
||||
toast.info(`Adding ${album.name} to queue...`);
|
||||
addItem({ spotifyId: album.id, type: "album", name: album.name });
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500">{error}</div>;
|
||||
}
|
||||
|
||||
if (!album) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
const isExplicitFilterEnabled = settings?.explicitFilter ?? false;
|
||||
|
||||
// Show placeholder for an entirely explicit album
|
||||
if (isExplicitFilterEnabled && album.explicit) {
|
||||
return (
|
||||
<div className="p-8 text-center border rounded-lg">
|
||||
<h2 className="text-2xl font-bold">Explicit Content Filtered</h2>
|
||||
<p className="mt-2 text-gray-500">This album has been filtered based on your settings.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasExplicitTrack = album.tracks.items.some((track) => track.explicit);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<FaArrowLeft />
|
||||
<span>Back to results</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row items-start gap-6">
|
||||
<img
|
||||
src={album.images[0]?.url || "/placeholder.jpg"}
|
||||
alt={album.name}
|
||||
className="w-48 h-48 object-cover rounded-lg shadow-lg"
|
||||
/>
|
||||
<div className="flex-grow space-y-2">
|
||||
<h1 className="text-3xl font-bold">{album.name}</h1>
|
||||
<p className="text-lg text-gray-500 dark:text-gray-400">
|
||||
By{" "}
|
||||
{album.artists.map((artist, index) => (
|
||||
<span key={artist.id}>
|
||||
<Link to="/artist/$artistId" params={{ artistId: artist.id }} className="hover:underline">
|
||||
{artist.name}
|
||||
</Link>
|
||||
{index < album.artists.length - 1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
{new Date(album.release_date).getFullYear()} • {album.total_tracks} songs
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-600">{album.label}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<button
|
||||
onClick={handleDownloadAlbum}
|
||||
disabled={isExplicitFilterEnabled && hasExplicitTrack}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
title={
|
||||
isExplicitFilterEnabled && hasExplicitTrack ? "Album contains explicit tracks" : "Download Full Album"
|
||||
}
|
||||
>
|
||||
Download Album
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Tracks</h2>
|
||||
<div className="space-y-2">
|
||||
{album.tracks.items.map((track, index) => {
|
||||
if (isExplicitFilterEnabled && track.explicit) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 bg-gray-100 dark:bg-gray-800 rounded-lg opacity-50"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-500 dark:text-gray-400 w-8 text-right">{index + 1}</span>
|
||||
<p className="font-medium text-gray-500">Explicit track filtered</p>
|
||||
</div>
|
||||
<span className="text-gray-500">--:--</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={track.id}
|
||||
className="flex items-center justify-between p-3 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-500 dark:text-gray-400 w-8 text-right">{index + 1}</span>
|
||||
<div>
|
||||
<p className="font-medium">{track.name}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{track.artists.map((artist, index) => (
|
||||
<span key={artist.id}>
|
||||
<Link
|
||||
to="/artist/$artistId"
|
||||
params={{
|
||||
artistId: artist.id,
|
||||
}}
|
||||
className="hover:underline"
|
||||
>
|
||||
{artist.name}
|
||||
</Link>
|
||||
{index < track.artists.length - 1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{Math.floor(track.duration_ms / 60000)}:
|
||||
{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, "0")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDownloadTrack(track)}
|
||||
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full"
|
||||
title="Download"
|
||||
>
|
||||
<img src="/download.svg" alt="Download" className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
224
spotizerr-ui/src/routes/artist.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { useEffect, useState, useContext } from "react";
|
||||
import { toast } from "sonner";
|
||||
import apiClient from "../lib/api-client";
|
||||
import type { AlbumType, ArtistType, TrackType } from "../types/spotify";
|
||||
import { QueueContext } from "../contexts/queue-context";
|
||||
import { useSettings } from "../contexts/settings-context";
|
||||
import { FaArrowLeft, FaBookmark, FaRegBookmark, FaDownload } from "react-icons/fa";
|
||||
import { AlbumCard } from "../components/AlbumCard";
|
||||
|
||||
export const Artist = () => {
|
||||
const { artistId } = useParams({ from: "/artist/$artistId" });
|
||||
const [artist, setArtist] = useState<ArtistType | null>(null);
|
||||
const [albums, setAlbums] = useState<AlbumType[]>([]);
|
||||
const [topTracks, setTopTracks] = useState<TrackType[]>([]);
|
||||
const [isWatched, setIsWatched] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const context = useContext(QueueContext);
|
||||
const { settings } = useSettings();
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useQueue must be used within a QueueProvider");
|
||||
}
|
||||
const { addItem } = context;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchArtistData = async () => {
|
||||
if (!artistId) return;
|
||||
try {
|
||||
const response = await apiClient.get<{ items: AlbumType[] }>(`/artist/info?id=${artistId}`);
|
||||
const albumData = response.data;
|
||||
|
||||
if (albumData?.items && albumData.items.length > 0) {
|
||||
const firstAlbum = albumData.items[0];
|
||||
if (firstAlbum.artists && firstAlbum.artists.length > 0) {
|
||||
setArtist(firstAlbum.artists[0]);
|
||||
} else {
|
||||
setError("Could not determine artist from album data.");
|
||||
return;
|
||||
}
|
||||
setAlbums(albumData.items);
|
||||
} else {
|
||||
setError("No albums found for this artist.");
|
||||
return;
|
||||
}
|
||||
|
||||
setTopTracks([]);
|
||||
|
||||
const watchStatusResponse = await apiClient.get<{ is_watched: boolean }>(`/artist/watch/${artistId}/status`);
|
||||
setIsWatched(watchStatusResponse.data.is_watched);
|
||||
} catch (err) {
|
||||
setError("Failed to load artist page");
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchArtistData();
|
||||
}, [artistId]);
|
||||
|
||||
const handleDownloadTrack = (track: TrackType) => {
|
||||
if (!track.id) return;
|
||||
toast.info(`Adding ${track.name} to queue...`);
|
||||
addItem({ spotifyId: track.id, type: "track", name: track.name });
|
||||
};
|
||||
|
||||
const handleDownloadAlbum = (album: AlbumType) => {
|
||||
toast.info(`Adding ${album.name} to queue...`);
|
||||
addItem({ spotifyId: album.id, type: "album", name: album.name });
|
||||
};
|
||||
|
||||
const handleDownloadArtist = () => {
|
||||
if (!artistId || !artist) return;
|
||||
toast.info(`Adding ${artist.name} to queue...`);
|
||||
addItem({
|
||||
spotifyId: artistId,
|
||||
type: "artist",
|
||||
name: artist.name,
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleWatch = async () => {
|
||||
if (!artistId || !artist) return;
|
||||
try {
|
||||
if (isWatched) {
|
||||
await apiClient.delete(`/artist/watch/${artistId}`);
|
||||
toast.success(`Removed ${artist.name} from watchlist.`);
|
||||
} else {
|
||||
await apiClient.put(`/artist/watch/${artistId}`);
|
||||
toast.success(`Added ${artist.name} to watchlist.`);
|
||||
}
|
||||
setIsWatched(!isWatched);
|
||||
} catch (err) {
|
||||
toast.error("Failed to update watchlist.");
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500">{error}</div>;
|
||||
}
|
||||
|
||||
if (!artist) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!artist.name) {
|
||||
return <div>Artist data could not be fully loaded. Please try again later.</div>;
|
||||
}
|
||||
|
||||
const applyFilters = (items: AlbumType[]) => {
|
||||
return items.filter((item) => (settings?.explicitFilter ? !item.explicit : true));
|
||||
};
|
||||
|
||||
const artistAlbums = applyFilters(albums.filter((album) => album.album_type === "album"));
|
||||
const artistSingles = applyFilters(albums.filter((album) => album.album_type === "single"));
|
||||
const artistCompilations = applyFilters(albums.filter((album) => album.album_type === "compilation"));
|
||||
|
||||
return (
|
||||
<div className="artist-page">
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<FaArrowLeft />
|
||||
<span>Back to results</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="artist-header mb-8 text-center">
|
||||
{artist.images && artist.images.length > 0 && (
|
||||
<img
|
||||
src={artist.images[0]?.url}
|
||||
alt={artist.name}
|
||||
className="artist-image w-48 h-48 rounded-full mx-auto mb-4 shadow-lg"
|
||||
/>
|
||||
)}
|
||||
<h1 className="text-5xl font-bold">{artist.name}</h1>
|
||||
<div className="flex gap-4 justify-center mt-4">
|
||||
<button
|
||||
onClick={handleDownloadArtist}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
|
||||
>
|
||||
<FaDownload />
|
||||
<span>Download All</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleWatch}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors border ${
|
||||
isWatched
|
||||
? "bg-blue-500 text-white border-blue-500"
|
||||
: "bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
{isWatched ? (
|
||||
<>
|
||||
<FaBookmark />
|
||||
<span>Watching</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaRegBookmark />
|
||||
<span>Watch</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{topTracks.length > 0 && (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-3xl font-bold mb-6">Top Tracks</h2>
|
||||
<div className="track-list space-y-2">
|
||||
{topTracks.map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className="track-item flex items-center justify-between p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<Link to="/track/$trackId" params={{ trackId: track.id }} className="font-semibold">
|
||||
{track.name}
|
||||
</Link>
|
||||
<button onClick={() => handleDownloadTrack(track)} className="download-btn">
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{artistAlbums.length > 0 && (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-3xl font-bold mb-6">Albums</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
||||
{artistAlbums.map((album) => (
|
||||
<AlbumCard key={album.id} album={album} onDownload={() => handleDownloadAlbum(album)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{artistSingles.length > 0 && (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-3xl font-bold mb-6">Singles</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
||||
{artistSingles.map((album) => (
|
||||
<AlbumCard key={album.id} album={album} onDownload={() => handleDownloadAlbum(album)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{artistCompilations.length > 0 && (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-3xl font-bold mb-6">Compilations</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
||||
{artistCompilations.map((album) => (
|
||||
<AlbumCard key={album.id} album={album} onDownload={() => handleDownloadAlbum(album)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
95
spotizerr-ui/src/routes/config.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useState } from "react";
|
||||
import { GeneralTab } from "../components/config/GeneralTab";
|
||||
import { DownloadsTab } from "../components/config/DownloadsTab";
|
||||
import { FormattingTab } from "../components/config/FormattingTab";
|
||||
import { AccountsTab } from "../components/config/AccountsTab";
|
||||
import { WatchTab } from "../components/config/WatchTab";
|
||||
import { ServerTab } from "../components/config/ServerTab";
|
||||
import { useSettings } from "../contexts/settings-context";
|
||||
|
||||
const ConfigComponent = () => {
|
||||
const [activeTab, setActiveTab] = useState("general");
|
||||
|
||||
// Get settings from the context instead of fetching here
|
||||
const { settings: config, isLoading } = useSettings();
|
||||
|
||||
const renderTabContent = () => {
|
||||
if (isLoading) return <p className="text-center">Loading configuration...</p>;
|
||||
if (!config) return <p className="text-center text-red-500">Error loading configuration.</p>;
|
||||
|
||||
switch (activeTab) {
|
||||
case "general":
|
||||
return <GeneralTab config={config} isLoading={isLoading} />;
|
||||
case "downloads":
|
||||
return <DownloadsTab config={config} isLoading={isLoading} />;
|
||||
case "formatting":
|
||||
return <FormattingTab config={config} isLoading={isLoading} />;
|
||||
case "accounts":
|
||||
return <AccountsTab />;
|
||||
case "watch":
|
||||
return <WatchTab />;
|
||||
case "server":
|
||||
return <ServerTab />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Configuration</h1>
|
||||
<p className="text-gray-500">Manage application settings and services.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8">
|
||||
<aside className="w-1/4">
|
||||
<nav className="flex flex-col space-y-1">
|
||||
<button
|
||||
onClick={() => setActiveTab("general")}
|
||||
className={`p-2 rounded-md text-left ${activeTab === "general" ? "bg-gray-100 dark:bg-gray-800 font-semibold" : ""}`}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("downloads")}
|
||||
className={`p-2 rounded-md text-left ${activeTab === "downloads" ? "bg-gray-100 dark:bg-gray-800 font-semibold" : ""}`}
|
||||
>
|
||||
Downloads
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("formatting")}
|
||||
className={`p-2 rounded-md text-left ${activeTab === "formatting" ? "bg-gray-100 dark:bg-gray-800 font-semibold" : ""}`}
|
||||
>
|
||||
Formatting
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("accounts")}
|
||||
className={`p-2 rounded-md text-left ${activeTab === "accounts" ? "bg-gray-100 dark:bg-gray-800 font-semibold" : ""}`}
|
||||
>
|
||||
Accounts
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("watch")}
|
||||
className={`p-2 rounded-md text-left ${activeTab === "watch" ? "bg-gray-100 dark:bg-gray-800 font-semibold" : ""}`}
|
||||
>
|
||||
Watch
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("server")}
|
||||
className={`p-2 rounded-md text-left ${activeTab === "server" ? "bg-gray-100 dark:bg-gray-800 font-semibold" : ""}`}
|
||||
>
|
||||
Server
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main className="w-3/4">{renderTabContent()}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Config = () => {
|
||||
return <ConfigComponent />;
|
||||
};
|
||||
494
spotizerr-ui/src/routes/history.tsx
Normal file
@@ -0,0 +1,494 @@
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import apiClient from "../lib/api-client";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getSortedRowModel,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
// --- Type Definitions ---
|
||||
type HistoryEntry = {
|
||||
task_id: string;
|
||||
item_name: string;
|
||||
item_artist: string;
|
||||
item_url?: string;
|
||||
download_type: "track" | "album" | "playlist" | "artist";
|
||||
service_used: string;
|
||||
quality_profile: string;
|
||||
convert_to?: string;
|
||||
bitrate?: string;
|
||||
status_final: "COMPLETED" | "ERROR" | "CANCELLED" | "SKIPPED";
|
||||
timestamp_completed: number;
|
||||
error_message?: string;
|
||||
parent_task_id?: string;
|
||||
track_status?: "SUCCESSFUL" | "SKIPPED" | "FAILED";
|
||||
total_successful?: number;
|
||||
total_skipped?: number;
|
||||
total_failed?: number;
|
||||
};
|
||||
|
||||
const STATUS_CLASS: Record<string, string> = {
|
||||
COMPLETED: "text-green-500",
|
||||
ERROR: "text-red-500",
|
||||
CANCELLED: "text-gray-500",
|
||||
SKIPPED: "text-yellow-500",
|
||||
};
|
||||
|
||||
const QUALITY_MAP: Record<string, Record<string, string>> = {
|
||||
spotify: {
|
||||
NORMAL: "OGG 96k",
|
||||
HIGH: "OGG 160k",
|
||||
VERY_HIGH: "OGG 320k",
|
||||
},
|
||||
deezer: {
|
||||
MP3_128: "MP3 128k",
|
||||
MP3_320: "MP3 320k",
|
||||
FLAC: "FLAC (Hi-Res)",
|
||||
},
|
||||
};
|
||||
|
||||
const getDownloadSource = (entry: HistoryEntry): "Spotify" | "Deezer" | "Unknown" => {
|
||||
const url = entry.item_url?.toLowerCase() || "";
|
||||
const service = entry.service_used?.toLowerCase() || "";
|
||||
if (url.includes("spotify.com")) return "Spotify";
|
||||
if (url.includes("deezer.com")) return "Deezer";
|
||||
if (service.includes("spotify")) return "Spotify";
|
||||
if (service.includes("deezer")) return "Deezer";
|
||||
return "Unknown";
|
||||
};
|
||||
|
||||
const formatQuality = (entry: HistoryEntry): string => {
|
||||
const sourceName = getDownloadSource(entry).toLowerCase();
|
||||
const profile = entry.quality_profile || "N/A";
|
||||
const sourceQuality = sourceName !== "unknown" ? QUALITY_MAP[sourceName]?.[profile] || profile : profile;
|
||||
let qualityDisplay = sourceQuality;
|
||||
if (entry.convert_to && entry.convert_to !== "None") {
|
||||
qualityDisplay += ` → ${entry.convert_to.toUpperCase()}`;
|
||||
if (entry.bitrate && entry.bitrate !== "None") {
|
||||
qualityDisplay += ` ${entry.bitrate}`;
|
||||
}
|
||||
}
|
||||
return qualityDisplay;
|
||||
};
|
||||
|
||||
// --- Column Definitions ---
|
||||
const columnHelper = createColumnHelper<HistoryEntry>();
|
||||
|
||||
export const History = () => {
|
||||
const [data, setData] = useState<HistoryEntry[]>([]);
|
||||
const [totalEntries, setTotalEntries] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// State for TanStack Table
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: "timestamp_completed", desc: true }]);
|
||||
const [{ pageIndex, pageSize }, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
});
|
||||
|
||||
// State for filters
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("");
|
||||
const [trackStatusFilter, setTrackStatusFilter] = useState("");
|
||||
const [showChildTracks, setShowChildTracks] = useState(false);
|
||||
const [parentTaskId, setParentTaskId] = useState<string | null>(null);
|
||||
const [parentTask, setParentTask] = useState<HistoryEntry | null>(null);
|
||||
|
||||
const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]);
|
||||
|
||||
const viewTracksForParent = useCallback(
|
||||
(parentEntry: HistoryEntry) => {
|
||||
setPagination({ pageIndex: 0, pageSize });
|
||||
setParentTaskId(parentEntry.task_id);
|
||||
setParentTask(parentEntry);
|
||||
setStatusFilter("");
|
||||
setTypeFilter("");
|
||||
setTrackStatusFilter("");
|
||||
},
|
||||
[pageSize],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("item_name", {
|
||||
header: "Name",
|
||||
cell: (info) =>
|
||||
info.row.original.parent_task_id ? (
|
||||
<span className="pl-8 text-muted-foreground">└─ {info.getValue()}</span>
|
||||
) : (
|
||||
<span className="font-semibold">{info.getValue()}</span>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("item_artist", { header: "Artist" }),
|
||||
columnHelper.accessor("download_type", {
|
||||
header: "Type",
|
||||
cell: (info) => <span className="capitalize">{info.getValue()}</span>,
|
||||
}),
|
||||
columnHelper.accessor("quality_profile", {
|
||||
header: "Quality",
|
||||
cell: (info) => formatQuality(info.row.original),
|
||||
}),
|
||||
columnHelper.accessor("status_final", {
|
||||
header: "Status",
|
||||
cell: (info) => {
|
||||
const entry = info.row.original;
|
||||
const status = entry.parent_task_id ? entry.track_status : entry.status_final;
|
||||
const statusKey = (status || "").toUpperCase();
|
||||
const statusClass =
|
||||
{
|
||||
COMPLETED: "text-green-500",
|
||||
SUCCESSFUL: "text-green-500",
|
||||
ERROR: "text-red-500",
|
||||
FAILED: "text-red-500",
|
||||
CANCELLED: "text-gray-500",
|
||||
SKIPPED: "text-yellow-500",
|
||||
}[statusKey] || "text-gray-500";
|
||||
|
||||
return <span className={`font-semibold ${statusClass}`}>{status}</span>;
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("item_url", {
|
||||
id: "source",
|
||||
header: parentTaskId ? "Download Source" : "Search Source",
|
||||
cell: (info) => getDownloadSource(info.row.original),
|
||||
}),
|
||||
columnHelper.accessor("timestamp_completed", {
|
||||
header: "Date Completed",
|
||||
cell: (info) => new Date(info.getValue() * 1000).toLocaleString(),
|
||||
}),
|
||||
...(!parentTaskId
|
||||
? [
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => {
|
||||
const entry = row.original;
|
||||
if (!entry.parent_task_id && (entry.download_type === "album" || entry.download_type === "playlist")) {
|
||||
const hasChildren =
|
||||
(entry.total_successful ?? 0) > 0 ||
|
||||
(entry.total_skipped ?? 0) > 0 ||
|
||||
(entry.total_failed ?? 0) > 0;
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => viewTracksForParent(row.original)}
|
||||
className="px-2 py-1 text-xs rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
View Tracks
|
||||
</button>
|
||||
<span className="text-xs">
|
||||
<span className="text-green-500">{entry.total_successful ?? 0}</span> /{" "}
|
||||
<span className="text-yellow-500">{entry.total_skipped ?? 0}</span> /{" "}
|
||||
<span className="text-red-500">{entry.total_failed ?? 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[viewTracksForParent, parentTaskId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchHistory = async () => {
|
||||
setIsLoading(true);
|
||||
setData([]);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: `${pageSize}`,
|
||||
offset: `${pageIndex * pageSize}`,
|
||||
sort_by: sorting[0]?.id ?? "timestamp_completed",
|
||||
sort_order: sorting[0]?.desc ? "DESC" : "ASC",
|
||||
});
|
||||
if (statusFilter) params.append("status_final", statusFilter);
|
||||
if (typeFilter) params.append("download_type", typeFilter);
|
||||
if (trackStatusFilter) params.append("track_status", trackStatusFilter);
|
||||
if (!parentTaskId && !showChildTracks) {
|
||||
params.append("hide_child_tracks", "true");
|
||||
}
|
||||
if (parentTaskId) params.append("parent_task_id", parentTaskId);
|
||||
|
||||
const response = await apiClient.get<{
|
||||
entries: HistoryEntry[];
|
||||
total_count: number;
|
||||
}>(`/history?${params.toString()}`);
|
||||
|
||||
const originalEntries = response.data.entries;
|
||||
let processedEntries = originalEntries;
|
||||
|
||||
// If including child tracks in the main history, group them with their parents
|
||||
if (showChildTracks && !parentTaskId) {
|
||||
const parents = originalEntries.filter((e) => !e.parent_task_id);
|
||||
const childrenByParentId = originalEntries
|
||||
.filter((e) => e.parent_task_id)
|
||||
.reduce(
|
||||
(acc, child) => {
|
||||
const parentId = child.parent_task_id!;
|
||||
if (!acc[parentId]) {
|
||||
acc[parentId] = [];
|
||||
}
|
||||
acc[parentId].push(child);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, HistoryEntry[]>,
|
||||
);
|
||||
|
||||
const groupedEntries: HistoryEntry[] = [];
|
||||
parents.forEach((parent) => {
|
||||
groupedEntries.push(parent);
|
||||
const children = childrenByParentId[parent.task_id];
|
||||
if (children) {
|
||||
groupedEntries.push(...children);
|
||||
}
|
||||
});
|
||||
processedEntries = groupedEntries;
|
||||
}
|
||||
|
||||
// If viewing child tracks for a specific parent, filter out the parent entry from the list
|
||||
const finalEntries = parentTaskId
|
||||
? processedEntries.filter((entry) => entry.task_id !== parentTaskId)
|
||||
: processedEntries;
|
||||
|
||||
setData(finalEntries);
|
||||
|
||||
// Adjust total count to reflect filtered entries for accurate pagination
|
||||
const numFiltered = originalEntries.length - finalEntries.length;
|
||||
setTotalEntries(response.data.total_count - numFiltered);
|
||||
} catch {
|
||||
toast.error("Failed to load history.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchHistory();
|
||||
}, [pageIndex, pageSize, sorting, statusFilter, typeFilter, trackStatusFilter, showChildTracks, parentTaskId]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
pageCount: Math.ceil(totalEntries / pageSize),
|
||||
state: { sorting, pagination },
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
});
|
||||
|
||||
const clearFilters = () => {
|
||||
setStatusFilter("");
|
||||
setTypeFilter("");
|
||||
setTrackStatusFilter("");
|
||||
setShowChildTracks(false);
|
||||
};
|
||||
|
||||
const viewParentTask = () => {
|
||||
setPagination({ pageIndex: 0, pageSize });
|
||||
setParentTaskId(null);
|
||||
setParentTask(null);
|
||||
clearFilters();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{parentTaskId && parentTask ? (
|
||||
<div className="space-y-4">
|
||||
<button onClick={viewParentTask} className="flex items-center gap-2 text-sm hover:underline">
|
||||
← Back to All History
|
||||
</button>
|
||||
<div className="rounded-lg border bg-gradient-to-br from-card to-muted/30 p-6 shadow-lg">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="md:col-span-2 space-y-1.5">
|
||||
<h2 className="text-3xl font-bold tracking-tight">{parentTask.item_name}</h2>
|
||||
<p className="text-xl text-muted-foreground">{parentTask.item_artist}</p>
|
||||
<div className="pt-2">
|
||||
<span className="capitalize inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold bg-secondary text-secondary-foreground">
|
||||
{parentTask.download_type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm md:text-right">
|
||||
<div
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1 text-base font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${
|
||||
STATUS_CLASS[parentTask.status_final]
|
||||
}`}
|
||||
>
|
||||
{parentTask.status_final}
|
||||
</div>
|
||||
<p className="text-muted-foreground pt-2">
|
||||
<span className="font-semibold text-foreground">Quality: </span>
|
||||
{formatQuality(parentTask)}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">Completed: </span>
|
||||
{new Date(parentTask.timestamp_completed * 1000).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold tracking-tight pt-4">Tracks</h3>
|
||||
</div>
|
||||
) : (
|
||||
<h1 className="text-3xl font-bold">Download History</h1>
|
||||
)}
|
||||
|
||||
{/* Filter Controls */}
|
||||
{!parentTaskId && (
|
||||
<div className="flex gap-4 items-center">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="COMPLETED">Completed</option>
|
||||
<option value="ERROR">Error</option>
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
<option value="SKIPPED">Skipped</option>
|
||||
</select>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="track">Track</option>
|
||||
<option value="album">Album</option>
|
||||
<option value="playlist">Playlist</option>
|
||||
<option value="artist">Artist</option>
|
||||
</select>
|
||||
<select
|
||||
value={trackStatusFilter}
|
||||
onChange={(e) => setTrackStatusFilter(e.target.value)}
|
||||
className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700"
|
||||
>
|
||||
<option value="">All Track Statuses</option>
|
||||
<option value="SUCCESSFUL">Successful</option>
|
||||
<option value="SKIPPED">Skipped</option>
|
||||
<option value="FAILED">Failed</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showChildTracks}
|
||||
onChange={(e) => setShowChildTracks(e.target.checked)}
|
||||
disabled={!!parentTaskId}
|
||||
/>
|
||||
Include child tracks
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th key={header.id} className="p-2 text-left">
|
||||
{header.isPlaceholder ? null : (
|
||||
<div
|
||||
{...{
|
||||
className: header.column.getCanSort() ? "cursor-pointer select-none" : "",
|
||||
onClick: header.column.getToggleSortingHandler(),
|
||||
}}
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{{ asc: " ▲", desc: " ▼" }[header.column.getIsSorted() as string] ?? null}
|
||||
</div>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="text-center p-4">
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
) : table.getRowModel().rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="text-center p-4">
|
||||
No history entries found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const isParent =
|
||||
!row.original.parent_task_id &&
|
||||
(row.original.download_type === "album" || row.original.download_type === "playlist");
|
||||
const isChild = !!row.original.parent_task_id;
|
||||
let rowClass = "hover:bg-muted/50";
|
||||
if (isParent) {
|
||||
rowClass += " bg-muted/50 font-semibold hover:bg-muted";
|
||||
} else if (isChild) {
|
||||
rowClass += " border-t border-dashed border-muted-foreground/20";
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={row.id} className={`border-b border-border ${rowClass}`}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="p-3">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
className="p-2 border rounded-md disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span>
|
||||
Page{" "}
|
||||
<strong>
|
||||
{table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||
</strong>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
className="p-2 border rounded-md disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<select
|
||||
value={table.getState().pagination.pageSize}
|
||||
onChange={(e) => table.setPageSize(Number(e.target.value))}
|
||||
className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700"
|
||||
>
|
||||
{[10, 25, 50, 100].map((size) => (
|
||||
<option key={size} value={size}>
|
||||
Show {size}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
164
spotizerr-ui/src/routes/home.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useState, useEffect, useMemo, useContext, useCallback, useRef } from "react";
|
||||
import { useNavigate, useSearch, useRouterState } from "@tanstack/react-router";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { toast } from "sonner";
|
||||
import type { TrackType, AlbumType, ArtistType, PlaylistType, SearchResult } from "@/types/spotify";
|
||||
import { QueueContext } from "@/contexts/queue-context";
|
||||
import { SearchResultCard } from "@/components/SearchResultCard";
|
||||
import { indexRoute } from "@/router";
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
export const Home = () => {
|
||||
const navigate = useNavigate({ from: "/" });
|
||||
const { q, type } = useSearch({ from: "/" });
|
||||
const { items: allResults } = indexRoute.useLoaderData();
|
||||
const isLoading = useRouterState({ select: (s) => s.status === "pending" });
|
||||
|
||||
const [query, setQuery] = useState(q || "");
|
||||
const [searchType, setSearchType] = useState<"track" | "album" | "artist" | "playlist">(type || "track");
|
||||
const [debouncedQuery] = useDebounce(query, 500);
|
||||
|
||||
const [displayedResults, setDisplayedResults] = useState<SearchResult[]>([]);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const context = useContext(QueueContext);
|
||||
const loaderRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
navigate({ search: (prev) => ({ ...prev, q: debouncedQuery, type: searchType }) });
|
||||
}, [debouncedQuery, searchType, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayedResults(allResults.slice(0, PAGE_SIZE));
|
||||
}, [allResults]);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useQueue must be used within a QueueProvider");
|
||||
}
|
||||
const { addItem } = context;
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
setIsLoadingMore(true);
|
||||
setTimeout(() => {
|
||||
const currentLength = displayedResults.length;
|
||||
const nextBatch = allResults.slice(currentLength, currentLength + PAGE_SIZE);
|
||||
setDisplayedResults((prev) => [...prev, ...nextBatch]);
|
||||
setIsLoadingMore(false);
|
||||
}, 500); // Simulate network delay
|
||||
}, [allResults, displayedResults]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const firstEntry = entries[0];
|
||||
if (firstEntry.isIntersecting && allResults.length > displayedResults.length) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 1.0 },
|
||||
);
|
||||
|
||||
const currentLoader = loaderRef.current;
|
||||
if (currentLoader) {
|
||||
observer.observe(currentLoader);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentLoader) {
|
||||
observer.unobserve(currentLoader);
|
||||
}
|
||||
};
|
||||
}, [allResults, displayedResults, loadMore]);
|
||||
|
||||
const handleDownloadTrack = useCallback(
|
||||
(track: TrackType) => {
|
||||
const artistName = track.artists?.map((a) => a.name).join(", ");
|
||||
addItem({ spotifyId: track.id, type: "track", name: track.name, artist: artistName });
|
||||
toast.info(`Adding ${track.name} to queue...`);
|
||||
},
|
||||
[addItem],
|
||||
);
|
||||
|
||||
const handleDownloadAlbum = useCallback(
|
||||
(album: AlbumType) => {
|
||||
const artistName = album.artists?.map((a) => a.name).join(", ");
|
||||
addItem({ spotifyId: album.id, type: "album", name: album.name, artist: artistName });
|
||||
toast.info(`Adding ${album.name} to queue...`);
|
||||
},
|
||||
[addItem],
|
||||
);
|
||||
|
||||
const resultComponent = useMemo(() => {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{displayedResults.map((item) => {
|
||||
let imageUrl;
|
||||
let onDownload;
|
||||
let subtitle;
|
||||
|
||||
if (item.model === "track") {
|
||||
imageUrl = (item as TrackType).album?.images?.[0]?.url;
|
||||
onDownload = () => handleDownloadTrack(item as TrackType);
|
||||
subtitle = (item as TrackType).artists?.map((a) => a.name).join(", ");
|
||||
} else if (item.model === "album") {
|
||||
imageUrl = (item as AlbumType).images?.[0]?.url;
|
||||
onDownload = () => handleDownloadAlbum(item as AlbumType);
|
||||
subtitle = (item as AlbumType).artists?.map((a) => a.name).join(", ");
|
||||
} else if (item.model === "artist") {
|
||||
imageUrl = (item as ArtistType).images?.[0]?.url;
|
||||
subtitle = "Artist";
|
||||
} else if (item.model === "playlist") {
|
||||
imageUrl = (item as PlaylistType).images?.[0]?.url;
|
||||
subtitle = `By ${(item as PlaylistType).owner?.display_name || "Unknown"}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchResultCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
type={item.model}
|
||||
imageUrl={imageUrl}
|
||||
subtitle={subtitle}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}, [displayedResults, handleDownloadTrack, handleDownloadAlbum]);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-4">
|
||||
<h1 className="text-2xl font-bold mb-6">Search Spotify</h1>
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search for a track, album, or artist"
|
||||
className="flex-1 p-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<select
|
||||
value={searchType}
|
||||
onChange={(e) => setSearchType(e.target.value as "track" | "album" | "artist" | "playlist")}
|
||||
className="p-2 border rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="track">Track</option>
|
||||
<option value="album">Album</option>
|
||||
<option value="artist">Artist</option>
|
||||
<option value="playlist">Playlist</option>
|
||||
</select>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<p className="text-center my-4">Loading results...</p>
|
||||
) : (
|
||||
<>
|
||||
{resultComponent}
|
||||
<div ref={loaderRef} />
|
||||
{isLoadingMore && <p className="text-center my-4">Loading more results...</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
205
spotizerr-ui/src/routes/playlist.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { useEffect, useState, useContext } from "react";
|
||||
import apiClient from "../lib/api-client";
|
||||
import { useSettings } from "../contexts/settings-context";
|
||||
import { toast } from "sonner";
|
||||
import type { PlaylistType, TrackType } from "../types/spotify";
|
||||
import { QueueContext } from "../contexts/queue-context";
|
||||
import { FaArrowLeft } from "react-icons/fa";
|
||||
import { FaDownload } from "react-icons/fa6";
|
||||
|
||||
export const Playlist = () => {
|
||||
const { playlistId } = useParams({ from: "/playlist/$playlistId" });
|
||||
const [playlist, setPlaylist] = useState<PlaylistType | null>(null);
|
||||
const [isWatched, setIsWatched] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const context = useContext(QueueContext);
|
||||
const { settings } = useSettings();
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useQueue must be used within a QueueProvider");
|
||||
}
|
||||
const { addItem } = context;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlaylist = async () => {
|
||||
if (!playlistId) return;
|
||||
try {
|
||||
const response = await apiClient.get<PlaylistType>(`/playlist/info?id=${playlistId}`);
|
||||
setPlaylist(response.data);
|
||||
} catch (err) {
|
||||
setError("Failed to load playlist");
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const checkWatchStatus = async () => {
|
||||
if (!playlistId) return;
|
||||
try {
|
||||
const response = await apiClient.get(`/playlist/watch/${playlistId}/status`);
|
||||
if (response.data.is_watched) {
|
||||
setIsWatched(true);
|
||||
}
|
||||
} catch {
|
||||
console.log("Could not get watch status");
|
||||
}
|
||||
};
|
||||
|
||||
fetchPlaylist();
|
||||
checkWatchStatus();
|
||||
}, [playlistId]);
|
||||
|
||||
const handleDownloadTrack = (track: TrackType) => {
|
||||
if (!track?.id) return;
|
||||
addItem({ spotifyId: track.id, type: "track", name: track.name });
|
||||
toast.info(`Adding ${track.name} to queue...`);
|
||||
};
|
||||
|
||||
const handleDownloadPlaylist = () => {
|
||||
if (!playlist) return;
|
||||
addItem({
|
||||
spotifyId: playlist.id,
|
||||
type: "playlist",
|
||||
name: playlist.name,
|
||||
});
|
||||
toast.info(`Adding ${playlist.name} to queue...`);
|
||||
};
|
||||
|
||||
const handleToggleWatch = async () => {
|
||||
if (!playlistId) return;
|
||||
try {
|
||||
if (isWatched) {
|
||||
await apiClient.delete(`/playlist/watch/${playlistId}`);
|
||||
toast.success(`Removed ${playlist?.name} from watchlist.`);
|
||||
} else {
|
||||
await apiClient.put(`/playlist/watch/${playlistId}`);
|
||||
toast.success(`Added ${playlist?.name} to watchlist.`);
|
||||
}
|
||||
setIsWatched(!isWatched);
|
||||
} catch (err) {
|
||||
toast.error("Failed to update watchlist.");
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500 p-8 text-center">{error}</div>;
|
||||
}
|
||||
|
||||
if (!playlist) {
|
||||
return <div className="p-8 text-center">Loading...</div>;
|
||||
}
|
||||
|
||||
const filteredTracks = playlist.tracks.items.filter(({ track }) => {
|
||||
if (!track) return false;
|
||||
if (settings?.explicitFilter && track.explicit) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<FaArrowLeft />
|
||||
<span>Back to results</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row items-start gap-6">
|
||||
<img
|
||||
src={playlist.images[0]?.url || "/placeholder.jpg"}
|
||||
alt={playlist.name}
|
||||
className="w-48 h-48 object-cover rounded-lg shadow-lg"
|
||||
/>
|
||||
<div className="flex-grow space-y-2">
|
||||
<h1 className="text-3xl font-bold">{playlist.name}</h1>
|
||||
{playlist.description && <p className="text-gray-500 dark:text-gray-400">{playlist.description}</p>}
|
||||
<div className="text-sm text-gray-400 dark:text-gray-500">
|
||||
<p>
|
||||
By {playlist.owner.display_name} • {playlist.followers.total.toLocaleString()} followers •{" "}
|
||||
{playlist.tracks.total} songs
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={handleDownloadPlaylist}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Download All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleWatch}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
|
||||
isWatched
|
||||
? "bg-red-600 text-white hover:bg-red-700"
|
||||
: "bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={isWatched ? "/eye-crossed.svg" : "/eye.svg"}
|
||||
alt="Watch status"
|
||||
className="w-5 h-5"
|
||||
style={{ filter: !isWatched ? "invert(1)" : undefined }}
|
||||
/>
|
||||
{isWatched ? "Unwatch" : "Watch"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Tracks</h2>
|
||||
<div className="space-y-2">
|
||||
{filteredTracks.map(({ track }, index) => {
|
||||
if (!track) return null;
|
||||
return (
|
||||
<div
|
||||
key={track.id}
|
||||
className="flex items-center justify-between p-3 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-500 dark:text-gray-400 w-8 text-right">{index + 1}</span>
|
||||
<img
|
||||
src={track.album.images.at(-1)?.url}
|
||||
alt={track.album.name}
|
||||
className="w-10 h-10 object-cover rounded"
|
||||
/>
|
||||
<div>
|
||||
<Link to="/track/$trackId" params={{ trackId: track.id }} className="font-medium hover:underline">
|
||||
{track.name}
|
||||
</Link>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{track.artists.map((artist, index) => (
|
||||
<span key={artist.id}>
|
||||
<Link to="/artist/$artistId" params={{ artistId: artist.id }} className="hover:underline">
|
||||
{artist.name}
|
||||
</Link>
|
||||
{index < track.artists.length - 1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{Math.floor(track.duration_ms / 60000)}:
|
||||
{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, "0")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDownloadTrack(track)}
|
||||
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full"
|
||||
title="Download"
|
||||
>
|
||||
<FaDownload />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
61
spotizerr-ui/src/routes/root.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Outlet } from "@tanstack/react-router";
|
||||
import { QueueProvider } from "../contexts/QueueProvider";
|
||||
import { useQueue } from "../contexts/queue-context";
|
||||
import { Queue } from "../components/Queue";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { SettingsProvider } from "../contexts/SettingsProvider";
|
||||
import { Toaster } from "sonner";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
// Create a client
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function AppLayout() {
|
||||
const { toggleVisibility } = useQueue();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<header className="sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur-sm">
|
||||
<div className="container mx-auto h-14 flex items-center justify-between">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<img src="/music.svg" alt="Logo" className="w-6 h-6" />
|
||||
<h1 className="text-xl font-bold">Spotizerr</h1>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/watchlist" className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
<img src="/binoculars.svg" alt="Watchlist" className="w-6 h-6" />
|
||||
</Link>
|
||||
<Link to="/history" className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
<img src="/history.svg" alt="History" className="w-6 h-6" />
|
||||
</Link>
|
||||
<Link to="/config" className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
<img src="/settings.svg" alt="Settings" className="w-6 h-6" />
|
||||
</Link>
|
||||
<button onClick={toggleVisibility} className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
<img src="/queue.svg" alt="Queue" className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<Queue />
|
||||
<Toaster richColors duration={1500} position="bottom-left" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Root() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SettingsProvider>
|
||||
<QueueProvider>
|
||||
<AppLayout />
|
||||
</QueueProvider>
|
||||
</SettingsProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
139
spotizerr-ui/src/routes/track.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { useEffect, useState, useContext } from "react";
|
||||
import apiClient from "../lib/api-client";
|
||||
import type { TrackType } from "../types/spotify";
|
||||
import { toast } from "sonner";
|
||||
import { QueueContext } from "../contexts/queue-context";
|
||||
import { FaSpotify, FaArrowLeft } from "react-icons/fa";
|
||||
|
||||
// Helper to format milliseconds to mm:ss
|
||||
const formatDuration = (ms: number) => {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = ((ms % 60000) / 1000).toFixed(0);
|
||||
return `${minutes}:${seconds.padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
export const Track = () => {
|
||||
const { trackId } = useParams({ from: "/track/$trackId" });
|
||||
const [track, setTrack] = useState<TrackType | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const context = useContext(QueueContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useQueue must be used within a QueueProvider");
|
||||
}
|
||||
const { addItem } = context;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTrack = async () => {
|
||||
if (!trackId) return;
|
||||
try {
|
||||
const response = await apiClient.get<TrackType>(`/track/info?id=${trackId}`);
|
||||
setTrack(response.data);
|
||||
} catch (err) {
|
||||
setError("Failed to load track");
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
fetchTrack();
|
||||
}, [trackId]);
|
||||
|
||||
const handleDownloadTrack = () => {
|
||||
if (!track) return;
|
||||
addItem({ spotifyId: track.id, type: "track", name: track.name });
|
||||
toast.info(`Adding ${track.name} to queue...`);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<p className="text-red-500 text-lg">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!track) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<p className="text-lg">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const imageUrl = track.album.images?.[0]?.url;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-4 md:p-8">
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<FaArrowLeft />
|
||||
<span>Back to results</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-white shadow-lg rounded-lg overflow-hidden md:flex">
|
||||
{imageUrl && (
|
||||
<div className="md:w-1/3">
|
||||
<img src={imageUrl} alt={track.album.name} className="w-full h-auto object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6 md:w-2/3 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="text-3xl font-bold text-gray-900">{track.name}</h1>
|
||||
{track.explicit && (
|
||||
<span className="text-xs bg-gray-700 text-white px-2 py-1 rounded-full">EXPLICIT</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-lg text-gray-600 mt-1">
|
||||
{track.artists.map((artist, index) => (
|
||||
<span key={artist.id}>
|
||||
<Link to="/artist/$artistId" params={{ artistId: artist.id }}>
|
||||
{artist.name}
|
||||
</Link>
|
||||
{index < track.artists.length - 1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-md text-gray-500 mt-4">
|
||||
From the album{" "}
|
||||
<Link to="/album/$albumId" params={{ albumId: track.album.id }} className="font-semibold">
|
||||
{track.album.name}
|
||||
</Link>
|
||||
</p>
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
<p>Release Date: {track.album.release_date}</p>
|
||||
<p>Duration: {formatDuration(track.duration_ms)}</p>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-gray-600">Popularity:</p>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div className="bg-green-500 h-2.5 rounded-full" style={{ width: `${track.popularity}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-6">
|
||||
<button
|
||||
onClick={handleDownloadTrack}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-full transition duration-300"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<a
|
||||
href={track.external_urls.spotify}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-gray-700 hover:text-black transition duration-300"
|
||||
aria-label="Listen on Spotify"
|
||||
>
|
||||
<FaSpotify size={24} />
|
||||
<span className="font-semibold">Listen on Spotify</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
164
spotizerr-ui/src/routes/watchlist.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import apiClient from "../lib/api-client";
|
||||
import { toast } from "sonner";
|
||||
import { useSettings } from "../contexts/settings-context";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import type { ArtistType, PlaylistType } from "../types/spotify";
|
||||
import { FaRegTrashAlt, FaSearch } from "react-icons/fa";
|
||||
|
||||
// --- Type Definitions ---
|
||||
interface BaseWatched {
|
||||
itemType: "artist" | "playlist";
|
||||
spotify_id: string;
|
||||
}
|
||||
type WatchedArtist = ArtistType & { itemType: "artist" };
|
||||
type WatchedPlaylist = PlaylistType & { itemType: "playlist" };
|
||||
|
||||
type WatchedItem = WatchedArtist | WatchedPlaylist;
|
||||
|
||||
export const Watchlist = () => {
|
||||
const { settings, isLoading: settingsLoading } = useSettings();
|
||||
const [items, setItems] = useState<WatchedItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const fetchWatchlist = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [artistsRes, playlistsRes] = await Promise.all([
|
||||
apiClient.get<BaseWatched[]>("/artist/watch/list"),
|
||||
apiClient.get<BaseWatched[]>("/playlist/watch/list"),
|
||||
]);
|
||||
|
||||
const artistDetailsPromises = artistsRes.data.map((artist) =>
|
||||
apiClient.get<ArtistType>(`/artist/info?id=${artist.spotify_id}`),
|
||||
);
|
||||
const playlistDetailsPromises = playlistsRes.data.map((playlist) =>
|
||||
apiClient.get<PlaylistType>(`/playlist/info?id=${playlist.spotify_id}`),
|
||||
);
|
||||
|
||||
const [artistDetailsRes, playlistDetailsRes] = await Promise.all([
|
||||
Promise.all(artistDetailsPromises),
|
||||
Promise.all(playlistDetailsPromises),
|
||||
]);
|
||||
|
||||
const artists: WatchedItem[] = artistDetailsRes.map((res) => ({ ...res.data, itemType: "artist" }));
|
||||
const playlists: WatchedItem[] = playlistDetailsRes.map((res) => ({
|
||||
...res.data,
|
||||
itemType: "playlist",
|
||||
spotify_id: res.data.id,
|
||||
}));
|
||||
|
||||
setItems([...artists, ...playlists]);
|
||||
} catch {
|
||||
toast.error("Failed to load watchlist.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsLoading && settings?.watch?.enabled) {
|
||||
fetchWatchlist();
|
||||
} else if (!settingsLoading) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [settings, settingsLoading, fetchWatchlist]);
|
||||
|
||||
const handleUnwatch = async (item: WatchedItem) => {
|
||||
toast.promise(apiClient.delete(`/${item.itemType}/watch/${item.id}`), {
|
||||
loading: `Unwatching ${item.name}...`,
|
||||
success: () => {
|
||||
setItems((prev) => prev.filter((i) => i.id !== item.id));
|
||||
return `${item.name} has been unwatched.`;
|
||||
},
|
||||
error: `Failed to unwatch ${item.name}.`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCheck = async (item: WatchedItem) => {
|
||||
toast.promise(apiClient.post(`/${item.itemType}/watch/trigger_check/${item.id}`), {
|
||||
loading: `Checking ${item.name} for updates...`,
|
||||
success: (res: { data: { message?: string } }) => res.data.message || `Check triggered for ${item.name}.`,
|
||||
error: `Failed to trigger check for ${item.name}.`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCheckAll = () => {
|
||||
toast.promise(
|
||||
Promise.all([apiClient.post("/artist/watch/trigger_check"), apiClient.post("/playlist/watch/trigger_check")]),
|
||||
{
|
||||
loading: "Triggering checks for all watched items...",
|
||||
success: "Successfully triggered checks for all items.",
|
||||
error: "Failed to trigger one or more checks.",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading || settingsLoading) {
|
||||
return <div className="text-center">Loading Watchlist...</div>;
|
||||
}
|
||||
|
||||
if (!settings?.watch?.enabled) {
|
||||
return (
|
||||
<div className="text-center p-8">
|
||||
<h2 className="text-2xl font-bold mb-2">Watchlist Disabled</h2>
|
||||
<p>The watchlist feature is currently disabled. You can enable it in the settings.</p>
|
||||
<Link to="/config" className="text-blue-500 hover:underline mt-4 inline-block">
|
||||
Go to Settings
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center p-8">
|
||||
<h2 className="text-2xl font-bold mb-2">Watchlist is Empty</h2>
|
||||
<p>Start watching artists or playlists to see them here.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold">Watched Artists & Playlists</h1>
|
||||
<button
|
||||
onClick={handleCheckAll}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<FaSearch /> Check All
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="bg-card p-4 rounded-lg shadow space-y-2 flex flex-col">
|
||||
<a href={`/${item.itemType}/${item.id}`} className="flex-grow">
|
||||
<img
|
||||
src={item.images?.[0]?.url || "/images/placeholder.jpg"}
|
||||
alt={item.name}
|
||||
className="w-full h-auto object-cover rounded-md aspect-square"
|
||||
/>
|
||||
<h3 className="font-bold pt-2 truncate">{item.name}</h3>
|
||||
<p className="text-sm text-muted-foreground capitalize">{item.itemType}</p>
|
||||
</a>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => handleUnwatch(item)}
|
||||
className="w-full px-3 py-1.5 text-sm bg-red-600 text-white rounded-md hover:bg-red-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<FaRegTrashAlt /> Unwatch
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCheck(item)}
|
||||
className="w-full px-3 py-1.5 text-sm bg-gray-600 text-white rounded-md hover:bg-gray-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<FaSearch /> Check
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
270
spotizerr-ui/src/types/callbacks.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
// Common Interfaces
|
||||
export interface IDs {
|
||||
spotify?: string;
|
||||
deezer?: string;
|
||||
isrc?: string;
|
||||
upc?: string;
|
||||
}
|
||||
|
||||
export interface ReleaseDate {
|
||||
year: number;
|
||||
month?: number;
|
||||
day?: number;
|
||||
}
|
||||
|
||||
// User Model
|
||||
export interface UserObject {
|
||||
name: string;
|
||||
type: "user";
|
||||
ids: IDs;
|
||||
}
|
||||
|
||||
// Track Module Models
|
||||
|
||||
export interface ArtistAlbumTrackObject {
|
||||
type: "artistAlbumTrack";
|
||||
name: string;
|
||||
ids: IDs;
|
||||
}
|
||||
|
||||
export interface ArtistTrackObject {
|
||||
type: "artistTrack";
|
||||
name: string;
|
||||
ids: IDs;
|
||||
}
|
||||
|
||||
export interface AlbumTrackObject {
|
||||
type: "albumTrack";
|
||||
album_type: "album" | "single" | "compilation";
|
||||
title: string;
|
||||
release_date: { [key: string]: any };
|
||||
total_tracks: number;
|
||||
genres: string[];
|
||||
images: { [key: string]: any }[];
|
||||
ids: IDs;
|
||||
artists: ArtistAlbumTrackObject[];
|
||||
}
|
||||
|
||||
export interface PlaylistTrackObject {
|
||||
type: "playlistTrack";
|
||||
title: string;
|
||||
description?: string;
|
||||
owner: UserObject;
|
||||
ids: IDs;
|
||||
}
|
||||
|
||||
export interface TrackObject {
|
||||
type: "track";
|
||||
title: string;
|
||||
disc_number: number;
|
||||
track_number: number;
|
||||
duration_ms: number;
|
||||
explicit: boolean;
|
||||
genres: string[];
|
||||
album: AlbumTrackObject;
|
||||
artists: ArtistTrackObject[];
|
||||
ids: IDs;
|
||||
}
|
||||
|
||||
// Playlist Module Models
|
||||
|
||||
export interface ArtistAlbumTrackPlaylistObject {
|
||||
type: "artistAlbumTrackPlaylist";
|
||||
name: string;
|
||||
ids: IDs;
|
||||
}
|
||||
|
||||
export interface AlbumTrackPlaylistObject {
|
||||
type: "albumTrackPlaylist";
|
||||
album_type: string;
|
||||
title: string;
|
||||
release_date: { [key: string]: any };
|
||||
total_tracks: number;
|
||||
images: { [key: string]: any }[];
|
||||
ids: IDs;
|
||||
artists: ArtistAlbumTrackPlaylistObject[];
|
||||
}
|
||||
|
||||
export interface ArtistTrackPlaylistObject {
|
||||
type: "artistTrackPlaylist";
|
||||
name: string;
|
||||
ids: IDs;
|
||||
}
|
||||
|
||||
export interface TrackPlaylistObject {
|
||||
type: "trackPlaylist";
|
||||
title: string;
|
||||
position: number;
|
||||
duration_ms: number;
|
||||
artists: ArtistTrackPlaylistObject[];
|
||||
album: AlbumTrackPlaylistObject;
|
||||
ids: IDs;
|
||||
disc_number: number;
|
||||
track_number: number;
|
||||
explicit: boolean;
|
||||
}
|
||||
|
||||
export interface PlaylistObject {
|
||||
type: "playlist";
|
||||
title: string;
|
||||
description?: string;
|
||||
owner: UserObject;
|
||||
tracks: TrackPlaylistObject[];
|
||||
images: { [key: string]: any }[];
|
||||
ids: IDs;
|
||||
}
|
||||
|
||||
// Artist Module Models
|
||||
|
||||
export interface AlbumArtistObject {
|
||||
type: "albumArtist";
|
||||
album_type: string;
|
||||
title: string;
|
||||
release_date: { [key: string]: any };
|
||||
total_tracks: number;
|
||||
ids: IDs;
|
||||
}
|
||||
|
||||
export interface ArtistObject {
|
||||
type: "artist";
|
||||
name: string;
|
||||
genres: string[];
|
||||
images: { [key: string]: any }[];
|
||||
ids: IDs;
|
||||
albums: AlbumArtistObject[];
|
||||
}
|
||||
|
||||
// Album Module Models
|
||||
|
||||
export interface ArtistTrackAlbumObject {
|
||||
type: "artistTrackAlbum";
|
||||
name: string;
|
||||
ids: IDs;
|
||||
}
|
||||
|
||||
export interface ArtistAlbumObject {
|
||||
type: "artistAlbum";
|
||||
name: string;
|
||||
genres: string[];
|
||||
ids: IDs;
|
||||
}
|
||||
|
||||
export interface TrackAlbumObject {
|
||||
type: "trackAlbum";
|
||||
title: string;
|
||||
disc_number: number;
|
||||
track_number: number;
|
||||
duration_ms: number;
|
||||
explicit: boolean;
|
||||
genres: string[];
|
||||
ids: IDs;
|
||||
artists: ArtistTrackAlbumObject[];
|
||||
}
|
||||
|
||||
export interface AlbumObject {
|
||||
type: "album";
|
||||
album_type: string;
|
||||
title: string;
|
||||
release_date: { [key: string]: any };
|
||||
total_tracks: number;
|
||||
genres: string[];
|
||||
images: { [key: string]: any }[];
|
||||
copyrights: { [key: string]: string }[];
|
||||
ids: IDs;
|
||||
tracks: TrackAlbumObject[];
|
||||
artists: ArtistAlbumObject[];
|
||||
}
|
||||
|
||||
// Callback Module Models
|
||||
|
||||
export interface BaseStatusObject {
|
||||
ids?: IDs;
|
||||
convert_to?: string;
|
||||
bitrate?: string;
|
||||
}
|
||||
|
||||
export interface InitializingObject extends BaseStatusObject {
|
||||
status: "initializing";
|
||||
}
|
||||
|
||||
export interface SkippedObject extends BaseStatusObject {
|
||||
status: "skipped";
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface RetryingObject extends BaseStatusObject {
|
||||
status: "retrying";
|
||||
retry_count: number;
|
||||
seconds_left: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface RealTimeObject extends BaseStatusObject {
|
||||
status: "real-time";
|
||||
time_elapsed: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface ErrorObject extends BaseStatusObject {
|
||||
status: "error";
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface FailedTrackObject {
|
||||
track: TrackObject;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface SummaryObject {
|
||||
successful_tracks: TrackObject[];
|
||||
skipped_tracks: TrackObject[];
|
||||
failed_tracks: FailedTrackObject[];
|
||||
total_successful: number;
|
||||
total_skipped: number;
|
||||
total_failed: number;
|
||||
}
|
||||
|
||||
export interface DoneObject extends BaseStatusObject {
|
||||
status: "done";
|
||||
summary?: SummaryObject;
|
||||
}
|
||||
|
||||
export type StatusInfo =
|
||||
| InitializingObject
|
||||
| SkippedObject
|
||||
| RetryingObject
|
||||
| RealTimeObject
|
||||
| ErrorObject
|
||||
| DoneObject;
|
||||
|
||||
export interface TrackCallbackObject {
|
||||
track: TrackObject;
|
||||
status_info: StatusInfo;
|
||||
current_track?: number;
|
||||
total_tracks?: number;
|
||||
parent?: AlbumTrackObject | PlaylistTrackObject;
|
||||
}
|
||||
|
||||
export interface AlbumCallbackObject {
|
||||
album: AlbumObject;
|
||||
status_info: StatusInfo;
|
||||
}
|
||||
|
||||
export interface PlaylistCallbackObject {
|
||||
playlist: PlaylistObject;
|
||||
status_info: StatusInfo;
|
||||
}
|
||||
|
||||
export interface ProcessingCallbackObject {
|
||||
status: "processing";
|
||||
timestamp: number;
|
||||
type: "track" | "album" | "playlist";
|
||||
name: string;
|
||||
artist: string;
|
||||
}
|
||||
|
||||
export type CallbackObject =
|
||||
| TrackCallbackObject
|
||||
| AlbumCallbackObject
|
||||
| PlaylistCallbackObject
|
||||
| ProcessingCallbackObject;
|
||||
37
spotizerr-ui/src/types/settings.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// This new type reflects the flat structure of the /api/config response
|
||||
export interface AppSettings {
|
||||
service: "spotify" | "deezer";
|
||||
spotify: string;
|
||||
spotifyQuality: "NORMAL" | "HIGH" | "VERY_HIGH";
|
||||
deezer: string;
|
||||
deezerQuality: "MP3_128" | "MP3_320" | "FLAC";
|
||||
maxConcurrentDownloads: number;
|
||||
realTime: boolean;
|
||||
fallback: boolean;
|
||||
convertTo: "MP3" | "AAC" | "OGG" | "OPUS" | "FLAC" | "WAV" | "ALAC" | "";
|
||||
bitrate: string;
|
||||
maxRetries: number;
|
||||
retryDelaySeconds: number;
|
||||
retryDelayIncrease: number;
|
||||
customDirFormat: string;
|
||||
customTrackFormat: string;
|
||||
tracknumPadding: boolean;
|
||||
saveCover: boolean;
|
||||
explicitFilter: boolean;
|
||||
// Properties from the old 'downloads' object
|
||||
threads: number;
|
||||
path: string;
|
||||
skipExisting: boolean;
|
||||
m3u: boolean;
|
||||
hlsThreads: number;
|
||||
// Properties from the old 'formatting' object
|
||||
track: string;
|
||||
album: string;
|
||||
playlist: string;
|
||||
compilation: string;
|
||||
watch: {
|
||||
enabled: boolean;
|
||||
// Add other watch properties from the old type if they still exist in the API response
|
||||
};
|
||||
// Add other root-level properties from the API if they exist
|
||||
}
|
||||
77
spotizerr-ui/src/types/spotify.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export interface ImageType {
|
||||
url: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export interface ArtistType {
|
||||
id: string;
|
||||
name: string;
|
||||
images?: ImageType[];
|
||||
}
|
||||
|
||||
export interface TrackAlbumInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
images: ImageType[];
|
||||
release_date: string;
|
||||
}
|
||||
|
||||
export interface TrackType {
|
||||
id: string;
|
||||
name: string;
|
||||
artists: ArtistType[];
|
||||
duration_ms: number;
|
||||
explicit: boolean;
|
||||
album: TrackAlbumInfo;
|
||||
popularity: number;
|
||||
external_urls: {
|
||||
spotify: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AlbumType {
|
||||
id: string;
|
||||
name: string;
|
||||
album_type: "album" | "single" | "compilation";
|
||||
artists: ArtistType[];
|
||||
images: ImageType[];
|
||||
release_date: string;
|
||||
total_tracks: number;
|
||||
label: string;
|
||||
copyrights: Array<{ text: string; type: string }>;
|
||||
explicit: boolean;
|
||||
tracks: {
|
||||
items: TrackType[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlaylistItemType {
|
||||
added_at: string;
|
||||
is_local: boolean;
|
||||
track: TrackType | null;
|
||||
}
|
||||
|
||||
export interface PlaylistOwnerType {
|
||||
id: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface PlaylistType {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
images: ImageType[];
|
||||
tracks: {
|
||||
items: PlaylistItemType[];
|
||||
total: number;
|
||||
};
|
||||
owner: PlaylistOwnerType;
|
||||
followers: {
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type SearchResult = (TrackType | AlbumType | ArtistType | PlaylistType) & {
|
||||
model: "track" | "album" | "artist" | "playlist";
|
||||
};
|
||||
1
spotizerr-ui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
33
spotizerr-ui/tsconfig.app.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitAny": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
4
spotizerr-ui/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
25
spotizerr-ui/tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitAny": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
26
spotizerr-ui/vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, resolve } from "path";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:7171",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
409
src/js/album.ts
@@ -1,409 +0,0 @@
|
||||
import { downloadQueue } from './queue.js';
|
||||
|
||||
// Define interfaces for API data
|
||||
interface Image {
|
||||
url: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
interface Artist {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: {
|
||||
spotify: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Track {
|
||||
id: string;
|
||||
name: string;
|
||||
artists: Artist[];
|
||||
duration_ms: number;
|
||||
explicit: boolean;
|
||||
external_urls: {
|
||||
spotify: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
name: string;
|
||||
artists: Artist[];
|
||||
images: Image[];
|
||||
release_date: string;
|
||||
total_tracks: number;
|
||||
label: string;
|
||||
copyrights: { text: string; type: string }[];
|
||||
explicit: boolean;
|
||||
tracks: {
|
||||
items: Track[];
|
||||
// Add other properties from Spotify API if needed (e.g., total, limit, offset)
|
||||
};
|
||||
external_urls: {
|
||||
spotify: string;
|
||||
};
|
||||
// Add other album properties if available
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const pathSegments = window.location.pathname.split('/');
|
||||
const albumId = pathSegments[pathSegments.indexOf('album') + 1];
|
||||
|
||||
if (!albumId) {
|
||||
showError('No album ID provided.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch album info directly
|
||||
fetch(`/api/album/info?id=${encodeURIComponent(albumId)}`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
return response.json() as Promise<Album>; // Add Album type
|
||||
})
|
||||
.then(data => renderAlbum(data))
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showError('Failed to load album.');
|
||||
});
|
||||
|
||||
const queueIcon = document.getElementById('queueIcon');
|
||||
if (queueIcon) {
|
||||
queueIcon.addEventListener('click', () => {
|
||||
downloadQueue.toggleVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
// Attempt to set initial watchlist button visibility from cache
|
||||
const watchlistButton = document.getElementById('watchlistButton') as HTMLAnchorElement | null;
|
||||
if (watchlistButton) {
|
||||
const cachedWatchEnabled = localStorage.getItem('spotizerr_watch_enabled_cached');
|
||||
if (cachedWatchEnabled === 'true') {
|
||||
watchlistButton.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch watch config to determine if watchlist button should be visible
|
||||
async function updateWatchlistButtonVisibility() {
|
||||
if (watchlistButton) {
|
||||
try {
|
||||
const response = await fetch('/api/config/watch');
|
||||
if (response.ok) {
|
||||
const watchConfig = await response.json();
|
||||
localStorage.setItem('spotizerr_watch_enabled_cached', watchConfig.enabled ? 'true' : 'false');
|
||||
if (watchConfig && watchConfig.enabled === false) {
|
||||
watchlistButton.classList.add('hidden');
|
||||
} else {
|
||||
watchlistButton.classList.remove('hidden'); // Ensure it's shown if enabled
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch watch config, defaulting to hidden');
|
||||
// Don't update cache on error
|
||||
watchlistButton.classList.add('hidden'); // Hide if config fetch fails
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching watch config:', error);
|
||||
// Don't update cache on error
|
||||
watchlistButton.classList.add('hidden'); // Hide on error
|
||||
}
|
||||
}
|
||||
}
|
||||
updateWatchlistButtonVisibility();
|
||||
});
|
||||
|
||||
function renderAlbum(album: Album) {
|
||||
// Hide loading and error messages.
|
||||
const loadingEl = document.getElementById('loading');
|
||||
if (loadingEl) loadingEl.classList.add('hidden');
|
||||
|
||||
const errorSectionEl = document.getElementById('error'); // Renamed to avoid conflict with error var in catch
|
||||
if (errorSectionEl) errorSectionEl.classList.add('hidden');
|
||||
|
||||
// Check if album itself is marked explicit and filter is enabled
|
||||
const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled();
|
||||
if (isExplicitFilterEnabled && album.explicit) {
|
||||
// Show placeholder for explicit album
|
||||
const placeholderContent = `
|
||||
<div class="explicit-filter-placeholder">
|
||||
<h2>Explicit Content Filtered</h2>
|
||||
<p>This album contains explicit content and has been filtered based on your settings.</p>
|
||||
<p>The explicit content filter is controlled by environment variables.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const contentContainer = document.getElementById('album-header');
|
||||
if (contentContainer) {
|
||||
contentContainer.innerHTML = placeholderContent;
|
||||
contentContainer.classList.remove('hidden');
|
||||
}
|
||||
|
||||
return; // Stop rendering the actual album content
|
||||
}
|
||||
|
||||
const baseUrl = window.location.origin;
|
||||
|
||||
// Set album header info.
|
||||
const albumNameEl = document.getElementById('album-name');
|
||||
if (albumNameEl) {
|
||||
albumNameEl.innerHTML =
|
||||
`<a href="${baseUrl}/album/${album.id || ''}">${album.name || 'Unknown Album'}</a>`;
|
||||
}
|
||||
|
||||
const albumArtistEl = document.getElementById('album-artist');
|
||||
if (albumArtistEl) {
|
||||
albumArtistEl.innerHTML =
|
||||
`By ${album.artists?.map(artist =>
|
||||
`<a href="${baseUrl}/artist/${artist?.id || ''}">${artist?.name || 'Unknown Artist'}</a>`
|
||||
).join(', ') || 'Unknown Artist'}`;
|
||||
}
|
||||
|
||||
const releaseYear = album.release_date ? new Date(album.release_date).getFullYear() : 'N/A';
|
||||
const albumStatsEl = document.getElementById('album-stats');
|
||||
if (albumStatsEl) {
|
||||
albumStatsEl.textContent =
|
||||
`${releaseYear} • ${album.total_tracks || '0'} songs • ${album.label || 'Unknown Label'}`;
|
||||
}
|
||||
|
||||
const albumCopyrightEl = document.getElementById('album-copyright');
|
||||
if (albumCopyrightEl) {
|
||||
albumCopyrightEl.textContent =
|
||||
album.copyrights?.map(c => c?.text || '').filter(text => text).join(' • ') || '';
|
||||
}
|
||||
|
||||
const imageSrc = album.images?.[0]?.url || '/static/images/placeholder.jpg';
|
||||
const albumImageEl = document.getElementById('album-image') as HTMLImageElement | null;
|
||||
if (albumImageEl) {
|
||||
albumImageEl.src = imageSrc;
|
||||
}
|
||||
|
||||
// Create (if needed) the Home Button.
|
||||
let homeButton = document.getElementById('homeButton') as HTMLButtonElement | null;
|
||||
if (!homeButton) {
|
||||
homeButton = document.createElement('button');
|
||||
homeButton.id = 'homeButton';
|
||||
homeButton.className = 'home-btn';
|
||||
|
||||
const homeIcon = document.createElement('img');
|
||||
homeIcon.src = '/static/images/home.svg';
|
||||
homeIcon.alt = 'Home';
|
||||
homeButton.appendChild(homeIcon);
|
||||
|
||||
// Insert as first child of album-header.
|
||||
const headerContainer = document.getElementById('album-header');
|
||||
if (headerContainer) { // Null check
|
||||
headerContainer.insertBefore(homeButton, headerContainer.firstChild);
|
||||
}
|
||||
}
|
||||
if (homeButton) { // Null check
|
||||
homeButton.addEventListener('click', () => {
|
||||
window.location.href = window.location.origin;
|
||||
});
|
||||
}
|
||||
|
||||
// Check if any track in the album is explicit when filter is enabled
|
||||
let hasExplicitTrack = false;
|
||||
if (isExplicitFilterEnabled && album.tracks?.items) {
|
||||
hasExplicitTrack = album.tracks.items.some(track => track && track.explicit);
|
||||
}
|
||||
|
||||
// Create (if needed) the Download Album Button.
|
||||
let downloadAlbumBtn = document.getElementById('downloadAlbumBtn') as HTMLButtonElement | null;
|
||||
if (!downloadAlbumBtn) {
|
||||
downloadAlbumBtn = document.createElement('button');
|
||||
downloadAlbumBtn.id = 'downloadAlbumBtn';
|
||||
downloadAlbumBtn.textContent = 'Download Full Album';
|
||||
downloadAlbumBtn.className = 'download-btn download-btn--main';
|
||||
const albumHeader = document.getElementById('album-header');
|
||||
if (albumHeader) albumHeader.appendChild(downloadAlbumBtn); // Null check
|
||||
}
|
||||
|
||||
if (downloadAlbumBtn) { // Null check for downloadAlbumBtn
|
||||
if (isExplicitFilterEnabled && hasExplicitTrack) {
|
||||
// Disable the album download button and display a message explaining why
|
||||
downloadAlbumBtn.disabled = true;
|
||||
downloadAlbumBtn.classList.add('download-btn--disabled');
|
||||
downloadAlbumBtn.innerHTML = `<span title="Cannot download entire album because it contains explicit tracks">Album Contains Explicit Tracks</span>`;
|
||||
} else {
|
||||
// Normal behavior when no explicit tracks are present
|
||||
downloadAlbumBtn.addEventListener('click', () => {
|
||||
// Remove any other download buttons (keeping the full-album button in place).
|
||||
document.querySelectorAll('.download-btn').forEach(btn => {
|
||||
if (btn.id !== 'downloadAlbumBtn') btn.remove();
|
||||
});
|
||||
|
||||
if (downloadAlbumBtn) { // Inner null check
|
||||
downloadAlbumBtn.disabled = true;
|
||||
downloadAlbumBtn.textContent = 'Queueing...';
|
||||
}
|
||||
|
||||
downloadWholeAlbum(album)
|
||||
.then(() => {
|
||||
if (downloadAlbumBtn) downloadAlbumBtn.textContent = 'Queued!'; // Inner null check
|
||||
})
|
||||
.catch(err => {
|
||||
showError('Failed to queue album download: ' + (err?.message || 'Unknown error'));
|
||||
if (downloadAlbumBtn) downloadAlbumBtn.disabled = false; // Inner null check
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Render each track.
|
||||
const tracksList = document.getElementById('tracks-list');
|
||||
if (tracksList) { // Null check
|
||||
tracksList.innerHTML = '';
|
||||
|
||||
if (album.tracks?.items) {
|
||||
album.tracks.items.forEach((track, index) => {
|
||||
if (!track) return; // Skip null or undefined tracks
|
||||
|
||||
// Skip explicit tracks if filter is enabled
|
||||
if (isExplicitFilterEnabled && track.explicit) {
|
||||
// Add a placeholder for filtered explicit tracks
|
||||
const trackElement = document.createElement('div');
|
||||
trackElement.className = 'track track-filtered';
|
||||
trackElement.innerHTML = `
|
||||
<div class="track-number">${index + 1}</div>
|
||||
<div class="track-info">
|
||||
<div class="track-name explicit-filtered">Explicit Content Filtered</div>
|
||||
<div class="track-artist">This track is not shown due to explicit content filter settings</div>
|
||||
</div>
|
||||
<div class="track-duration">--:--</div>
|
||||
`;
|
||||
tracksList.appendChild(trackElement);
|
||||
return;
|
||||
}
|
||||
|
||||
const trackElement = document.createElement('div');
|
||||
trackElement.className = 'track';
|
||||
trackElement.innerHTML = `
|
||||
<div class="track-number">${index + 1}</div>
|
||||
<div class="track-info">
|
||||
<div class="track-name">
|
||||
<a href="${baseUrl}/track/${track.id || ''}">${track.name || 'Unknown Track'}</a>
|
||||
</div>
|
||||
<div class="track-artist">
|
||||
${track.artists?.map(a =>
|
||||
`<a href="${baseUrl}/artist/${a?.id || ''}">${a?.name || 'Unknown Artist'}</a>`
|
||||
).join(', ') || 'Unknown Artist'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-duration">${msToTime(track.duration_ms || 0)}</div>
|
||||
<button class="download-btn download-btn--circle"
|
||||
data-id="${track.id || ''}"
|
||||
data-type="track"
|
||||
data-name="${track.name || 'Unknown Track'}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
</button>
|
||||
`;
|
||||
tracksList.appendChild(trackElement);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Reveal header and track list.
|
||||
const albumHeaderEl = document.getElementById('album-header');
|
||||
if (albumHeaderEl) albumHeaderEl.classList.remove('hidden');
|
||||
|
||||
const tracksContainerEl = document.getElementById('tracks-container');
|
||||
if (tracksContainerEl) tracksContainerEl.classList.remove('hidden');
|
||||
attachDownloadListeners();
|
||||
|
||||
// If on a small screen, re-arrange the action buttons.
|
||||
if (window.innerWidth <= 480) {
|
||||
let actionsContainer = document.getElementById('album-actions');
|
||||
if (!actionsContainer) {
|
||||
actionsContainer = document.createElement('div');
|
||||
actionsContainer.id = 'album-actions';
|
||||
const albumHeader = document.getElementById('album-header');
|
||||
if (albumHeader) albumHeader.appendChild(actionsContainer); // Null check
|
||||
}
|
||||
if (actionsContainer) { // Null check for actionsContainer
|
||||
actionsContainer.innerHTML = ''; // Clear any previous content
|
||||
const homeBtn = document.getElementById('homeButton');
|
||||
if (homeBtn) actionsContainer.appendChild(homeBtn); // Null check
|
||||
|
||||
const dlAlbumBtn = document.getElementById('downloadAlbumBtn');
|
||||
if (dlAlbumBtn) actionsContainer.appendChild(dlAlbumBtn); // Null check
|
||||
|
||||
const queueToggle = document.querySelector('.queue-toggle');
|
||||
if (queueToggle) {
|
||||
actionsContainer.appendChild(queueToggle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadWholeAlbum(album: Album) {
|
||||
const albumIdToDownload = album.id || '';
|
||||
if (!albumIdToDownload) {
|
||||
throw new Error('Missing album ID');
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the centralized downloadQueue.download method
|
||||
await downloadQueue.download(albumIdToDownload, 'album', { name: album.name || 'Unknown Album' });
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
} catch (error: any) { // Add type for error
|
||||
showError('Album download failed: ' + (error?.message || 'Unknown error'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function msToTime(duration: number): string {
|
||||
const minutes = Math.floor(duration / 60000);
|
||||
const seconds = ((duration % 60000) / 1000).toFixed(0);
|
||||
return `${minutes}:${seconds.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function showError(message: string) {
|
||||
const errorEl = document.getElementById('error');
|
||||
if (errorEl) { // Null check
|
||||
errorEl.textContent = message || 'An error occurred';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function attachDownloadListeners() {
|
||||
document.querySelectorAll('.download-btn').forEach((btn) => {
|
||||
const button = btn as HTMLButtonElement; // Cast to HTMLButtonElement
|
||||
if (button.id === 'downloadAlbumBtn') return;
|
||||
button.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const currentTarget = e.currentTarget as HTMLButtonElement | null; // Cast currentTarget
|
||||
if (!currentTarget) return;
|
||||
|
||||
const itemId = currentTarget.dataset.id || '';
|
||||
const type = currentTarget.dataset.type || '';
|
||||
const name = currentTarget.dataset.name || 'Unknown';
|
||||
|
||||
if (!itemId) {
|
||||
showError('Missing item ID for download in album page');
|
||||
return;
|
||||
}
|
||||
// Remove the button immediately after click.
|
||||
currentTarget.remove();
|
||||
startDownload(itemId, type, { name }); // albumType will be undefined
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startDownload(itemId: string, type: string, item: { name: string }, albumType?: string) { // Add types and make albumType optional
|
||||
if (!itemId || !type) {
|
||||
showError('Missing ID or type for download');
|
||||
return Promise.reject(new Error('Missing ID or type for download')); // Return a rejected promise
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the centralized downloadQueue.download method
|
||||
await downloadQueue.download(itemId, type, item, albumType);
|
||||
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
} catch (error: any) { // Add type for error
|
||||
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
854
src/js/artist.ts
@@ -1,854 +0,0 @@
|
||||
// Import the downloadQueue singleton
|
||||
import { downloadQueue } from './queue.js';
|
||||
|
||||
// Define interfaces for API data
|
||||
interface Image {
|
||||
url: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
interface Artist {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: {
|
||||
spotify: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
name: string;
|
||||
artists: Artist[];
|
||||
images: Image[];
|
||||
album_type: string; // "album", "single", "compilation"
|
||||
album_group?: string; // "album", "single", "compilation", "appears_on"
|
||||
external_urls: {
|
||||
spotify: string;
|
||||
};
|
||||
explicit?: boolean; // Added to handle explicit filter
|
||||
total_tracks?: number;
|
||||
release_date?: string;
|
||||
is_locally_known?: boolean; // Added for local DB status
|
||||
}
|
||||
|
||||
interface ArtistData {
|
||||
items: Album[];
|
||||
total: number;
|
||||
// Add other properties if available from the API
|
||||
// For watch status, the artist object itself might have `is_watched` if we extend API
|
||||
// For now, we fetch status separately.
|
||||
}
|
||||
|
||||
// Interface for watch status response
|
||||
interface WatchStatusResponse {
|
||||
is_watched: boolean;
|
||||
artist_data?: any; // The artist data from DB if watched
|
||||
}
|
||||
|
||||
// Added: Interface for global watch config
|
||||
interface GlobalWatchConfig {
|
||||
enabled: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Added: Helper function to fetch global watch config
|
||||
async function getGlobalWatchConfig(): Promise<GlobalWatchConfig> {
|
||||
try {
|
||||
const response = await fetch('/api/config/watch');
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch global watch config, assuming disabled.');
|
||||
return { enabled: false }; // Default to disabled on error
|
||||
}
|
||||
return await response.json() as GlobalWatchConfig;
|
||||
} catch (error) {
|
||||
console.error('Error fetching global watch config:', error);
|
||||
return { enabled: false }; // Default to disabled on error
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const pathSegments = window.location.pathname.split('/');
|
||||
const artistId = pathSegments[pathSegments.indexOf('artist') + 1];
|
||||
|
||||
if (!artistId) {
|
||||
showError('No artist ID provided.');
|
||||
return;
|
||||
}
|
||||
|
||||
const globalWatchConfig = await getGlobalWatchConfig(); // Fetch global config
|
||||
const isGlobalWatchActuallyEnabled = globalWatchConfig.enabled;
|
||||
|
||||
// Fetch artist info directly
|
||||
fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
return response.json() as Promise<ArtistData>;
|
||||
})
|
||||
.then(data => renderArtist(data, artistId, isGlobalWatchActuallyEnabled))
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showError('Failed to load artist info.');
|
||||
});
|
||||
|
||||
const queueIcon = document.getElementById('queueIcon');
|
||||
if (queueIcon) {
|
||||
queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility());
|
||||
}
|
||||
|
||||
// Attempt to set initial watchlist button visibility from cache
|
||||
const watchlistButton = document.getElementById('watchlistButton') as HTMLAnchorElement | null;
|
||||
if (watchlistButton) {
|
||||
const cachedWatchEnabled = localStorage.getItem('spotizerr_watch_enabled_cached');
|
||||
if (cachedWatchEnabled === 'true') {
|
||||
watchlistButton.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch watch config to determine if watchlist button should be visible
|
||||
async function updateWatchlistButtonVisibility() {
|
||||
if (watchlistButton) {
|
||||
try {
|
||||
const response = await fetch('/api/config/watch');
|
||||
if (response.ok) {
|
||||
const watchConfig = await response.json();
|
||||
localStorage.setItem('spotizerr_watch_enabled_cached', watchConfig.enabled ? 'true' : 'false');
|
||||
if (watchConfig && watchConfig.enabled === false) {
|
||||
watchlistButton.classList.add('hidden');
|
||||
} else {
|
||||
watchlistButton.classList.remove('hidden'); // Ensure it's shown if enabled
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch watch config for artist page, defaulting to hidden');
|
||||
// Don't update cache on error
|
||||
watchlistButton.classList.add('hidden'); // Hide if config fetch fails
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching watch config for artist page:', error);
|
||||
// Don't update cache on error
|
||||
watchlistButton.classList.add('hidden'); // Hide on error
|
||||
}
|
||||
}
|
||||
}
|
||||
updateWatchlistButtonVisibility();
|
||||
|
||||
// Initialize the watch button after main artist rendering
|
||||
// This is done inside renderArtist after button element is potentially created.
|
||||
});
|
||||
|
||||
async function renderArtist(artistData: ArtistData, artistId: string, isGlobalWatchEnabled: boolean) {
|
||||
const loadingEl = document.getElementById('loading');
|
||||
if (loadingEl) loadingEl.classList.add('hidden');
|
||||
|
||||
const errorEl = document.getElementById('error');
|
||||
if (errorEl) errorEl.classList.add('hidden');
|
||||
|
||||
// Fetch watch status upfront to avoid race conditions for album button rendering
|
||||
let isArtistActuallyWatched = false; // Default
|
||||
if (isGlobalWatchEnabled) { // Only fetch if globally enabled
|
||||
isArtistActuallyWatched = await getArtistWatchStatus(artistId);
|
||||
}
|
||||
|
||||
// Check if explicit filter is enabled
|
||||
const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled();
|
||||
|
||||
const firstAlbum = artistData.items?.[0];
|
||||
const artistName = firstAlbum?.artists?.[0]?.name || 'Unknown Artist';
|
||||
const artistImageSrc = firstAlbum?.images?.[0]?.url || '/static/images/placeholder.jpg';
|
||||
|
||||
const artistNameEl = document.getElementById('artist-name');
|
||||
if (artistNameEl) {
|
||||
artistNameEl.innerHTML =
|
||||
`<a href="/artist/${artistId}" class="artist-link">${artistName}</a>`;
|
||||
}
|
||||
const artistStatsEl = document.getElementById('artist-stats');
|
||||
if (artistStatsEl) {
|
||||
artistStatsEl.textContent = `${artistData.total || '0'} albums`;
|
||||
}
|
||||
const artistImageEl = document.getElementById('artist-image') as HTMLImageElement | null;
|
||||
if (artistImageEl) {
|
||||
artistImageEl.src = artistImageSrc;
|
||||
}
|
||||
|
||||
const watchArtistBtn = document.getElementById('watchArtistBtn') as HTMLButtonElement | null;
|
||||
const syncArtistBtn = document.getElementById('syncArtistBtn') as HTMLButtonElement | null;
|
||||
|
||||
if (!isGlobalWatchEnabled) {
|
||||
if (watchArtistBtn) {
|
||||
watchArtistBtn.classList.add('hidden');
|
||||
watchArtistBtn.disabled = true;
|
||||
}
|
||||
if (syncArtistBtn) {
|
||||
syncArtistBtn.classList.add('hidden');
|
||||
syncArtistBtn.disabled = true;
|
||||
}
|
||||
} else {
|
||||
if (watchArtistBtn) {
|
||||
initializeWatchButton(artistId, isArtistActuallyWatched);
|
||||
} else {
|
||||
console.warn("Watch artist button not found in HTML.");
|
||||
}
|
||||
// Sync button visibility is managed by initializeWatchButton
|
||||
}
|
||||
|
||||
// Define the artist URL (used by both full-discography and group downloads)
|
||||
// const artistUrl = `https://open.spotify.com/artist/${artistId}`; // Not directly used here anymore
|
||||
|
||||
// Home Button
|
||||
let homeButton = document.getElementById('homeButton') as HTMLButtonElement | null;
|
||||
if (!homeButton) {
|
||||
homeButton = document.createElement('button');
|
||||
homeButton.id = 'homeButton';
|
||||
homeButton.className = 'home-btn';
|
||||
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="Home">`;
|
||||
const artistHeader = document.getElementById('artist-header');
|
||||
if (artistHeader) artistHeader.prepend(homeButton);
|
||||
}
|
||||
if (homeButton) {
|
||||
homeButton.addEventListener('click', () => window.location.href = window.location.origin);
|
||||
}
|
||||
|
||||
// Download Whole Artist Button using the new artist API endpoint
|
||||
let downloadArtistBtn = document.getElementById('downloadArtistBtn') as HTMLButtonElement | null;
|
||||
if (!downloadArtistBtn) {
|
||||
downloadArtistBtn = document.createElement('button');
|
||||
downloadArtistBtn.id = 'downloadArtistBtn';
|
||||
downloadArtistBtn.className = 'download-btn download-btn--main';
|
||||
downloadArtistBtn.textContent = 'Download All Discography';
|
||||
const artistHeader = document.getElementById('artist-header');
|
||||
if (artistHeader) artistHeader.appendChild(downloadArtistBtn);
|
||||
}
|
||||
|
||||
// When explicit filter is enabled, disable all download buttons
|
||||
if (isExplicitFilterEnabled) {
|
||||
if (downloadArtistBtn) {
|
||||
downloadArtistBtn.disabled = true;
|
||||
downloadArtistBtn.classList.add('download-btn--disabled');
|
||||
downloadArtistBtn.innerHTML = `<span title="Direct artist downloads are restricted when explicit filter is enabled. Please visit individual album pages.">Downloads Restricted</span>`;
|
||||
}
|
||||
} else {
|
||||
if (downloadArtistBtn) {
|
||||
downloadArtistBtn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.download-btn:not(#downloadArtistBtn)').forEach(btn => btn.remove());
|
||||
if (downloadArtistBtn) {
|
||||
downloadArtistBtn.disabled = true;
|
||||
downloadArtistBtn.textContent = 'Queueing...';
|
||||
}
|
||||
startDownload(
|
||||
artistId,
|
||||
'artist',
|
||||
{ name: artistName, artist: artistName },
|
||||
'album,single,compilation,appears_on'
|
||||
)
|
||||
.then((taskIds) => {
|
||||
if (downloadArtistBtn) {
|
||||
downloadArtistBtn.textContent = 'Artist queued';
|
||||
downloadQueue.toggleVisibility(true);
|
||||
if (Array.isArray(taskIds)) {
|
||||
downloadArtistBtn.title = `${taskIds.length} albums queued for download`;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if (downloadArtistBtn) {
|
||||
downloadArtistBtn.textContent = 'Download All Discography';
|
||||
downloadArtistBtn.disabled = false;
|
||||
}
|
||||
showError('Failed to queue artist download: ' + (err?.message || 'Unknown error'));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const albumGroups: Record<string, Album[]> = {};
|
||||
const appearingAlbums: Album[] = [];
|
||||
|
||||
(artistData.items || []).forEach(album => {
|
||||
if (!album) return;
|
||||
if (isExplicitFilterEnabled && album.explicit) {
|
||||
return;
|
||||
}
|
||||
if (album.album_group === 'appears_on') {
|
||||
appearingAlbums.push(album);
|
||||
} else {
|
||||
const type = (album.album_type || 'unknown').toLowerCase();
|
||||
if (!albumGroups[type]) albumGroups[type] = [];
|
||||
albumGroups[type].push(album);
|
||||
}
|
||||
});
|
||||
|
||||
const groupsContainer = document.getElementById('album-groups');
|
||||
if (groupsContainer) {
|
||||
groupsContainer.innerHTML = '';
|
||||
|
||||
// Use the definitively fetched watch status for rendering album buttons
|
||||
// const isArtistWatched = watchArtistBtn && watchArtistBtn.dataset.watching === 'true'; // Old way
|
||||
// const useThisWatchStatusForAlbums = isArtistActuallyWatched; // Old way, now combination of global and individual
|
||||
|
||||
for (const [groupType, albums] of Object.entries(albumGroups)) {
|
||||
const groupSection = document.createElement('section');
|
||||
groupSection.className = 'album-group';
|
||||
|
||||
const groupHeaderHTML = isExplicitFilterEnabled ?
|
||||
`<div class="album-group-header">
|
||||
<h3>${capitalize(groupType)}s</h3>
|
||||
<div class="download-note">Visit album pages to download content</div>
|
||||
</div>` :
|
||||
`<div class="album-group-header">
|
||||
<h3>${capitalize(groupType)}s</h3>
|
||||
<button class="download-btn download-btn--main group-download-btn"
|
||||
data-group-type="${groupType}">
|
||||
Download All ${capitalize(groupType)}s
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
groupSection.innerHTML = groupHeaderHTML;
|
||||
const albumsListContainer = document.createElement('div');
|
||||
albumsListContainer.className = 'albums-list';
|
||||
|
||||
albums.forEach(album => {
|
||||
if (!album) return;
|
||||
const albumElement = document.createElement('div');
|
||||
albumElement.className = 'album-card';
|
||||
albumElement.dataset.albumId = album.id;
|
||||
|
||||
let albumCardHTML = `
|
||||
<a href="/album/${album.id || ''}" class="album-link">
|
||||
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
|
||||
alt="Album cover"
|
||||
class="album-cover ${album.is_locally_known === false ? 'album-missing-in-db' : ''}">
|
||||
</a>
|
||||
<div class="album-info">
|
||||
<div class="album-title">${album.name || 'Unknown Album'}</div>
|
||||
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
|
||||
</div>
|
||||
`;
|
||||
albumElement.innerHTML = albumCardHTML;
|
||||
|
||||
const albumCardActions = document.createElement('div');
|
||||
albumCardActions.className = 'album-card-actions';
|
||||
|
||||
// Persistent Mark as Known/Missing button (if artist is watched) - Appears first (left)
|
||||
if (isGlobalWatchEnabled && isArtistActuallyWatched && album.id) {
|
||||
const toggleKnownBtn = document.createElement('button');
|
||||
toggleKnownBtn.className = 'toggle-known-status-btn persistent-album-action-btn';
|
||||
toggleKnownBtn.dataset.albumId = album.id;
|
||||
|
||||
if (album.is_locally_known) {
|
||||
toggleKnownBtn.dataset.status = 'known';
|
||||
toggleKnownBtn.innerHTML = '<img src="/static/images/check.svg" alt="Mark as missing">';
|
||||
toggleKnownBtn.title = 'Mark album as not in local library (Missing)';
|
||||
toggleKnownBtn.classList.add('status-known'); // Green
|
||||
} else {
|
||||
toggleKnownBtn.dataset.status = 'missing';
|
||||
toggleKnownBtn.innerHTML = '<img src="/static/images/missing.svg" alt="Mark as known">';
|
||||
toggleKnownBtn.title = 'Mark album as in local library (Known)';
|
||||
toggleKnownBtn.classList.add('status-missing'); // Red
|
||||
}
|
||||
albumCardActions.appendChild(toggleKnownBtn); // Add to actions container
|
||||
}
|
||||
|
||||
// Persistent Download Button (if not explicit filter) - Appears second (right)
|
||||
if (!isExplicitFilterEnabled) {
|
||||
const downloadBtn = document.createElement('button');
|
||||
downloadBtn.className = 'download-btn download-btn--circle persistent-download-btn';
|
||||
downloadBtn.innerHTML = '<img src="/static/images/download.svg" alt="Download album">';
|
||||
downloadBtn.title = 'Download this album';
|
||||
downloadBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
downloadBtn.disabled = true;
|
||||
downloadBtn.innerHTML = '<img src="/static/images/refresh.svg" alt="Queueing..." class="icon-spin">';
|
||||
startDownload(album.id, 'album', { name: album.name, artist: album.artists?.[0]?.name || 'Unknown Artist', type: 'album' })
|
||||
.then(() => {
|
||||
downloadBtn.innerHTML = '<img src="/static/images/check.svg" alt="Queued">';
|
||||
showNotification(`Album '${album.name}' queued for download.`);
|
||||
downloadQueue.toggleVisibility(true);
|
||||
})
|
||||
.catch(err => {
|
||||
downloadBtn.disabled = false;
|
||||
downloadBtn.innerHTML = '<img src="/static/images/download.svg" alt="Download album">';
|
||||
showError(`Failed to queue album: ${err?.message || 'Unknown error'}`);
|
||||
});
|
||||
});
|
||||
albumCardActions.appendChild(downloadBtn); // Add to actions container
|
||||
}
|
||||
|
||||
// Only append albumCardActions if it has any buttons
|
||||
if (albumCardActions.hasChildNodes()) {
|
||||
albumElement.appendChild(albumCardActions);
|
||||
}
|
||||
|
||||
albumsListContainer.appendChild(albumElement);
|
||||
});
|
||||
groupSection.appendChild(albumsListContainer);
|
||||
groupsContainer.appendChild(groupSection);
|
||||
}
|
||||
|
||||
if (appearingAlbums.length > 0) {
|
||||
const featuringSection = document.createElement('section');
|
||||
featuringSection.className = 'album-group';
|
||||
const featuringHeaderHTML = isExplicitFilterEnabled ?
|
||||
`<div class="album-group-header">
|
||||
<h3>Featuring</h3>
|
||||
<div class="download-note">Visit album pages to download content</div>
|
||||
</div>` :
|
||||
`<div class="album-group-header">
|
||||
<h3>Featuring</h3>
|
||||
<button class="download-btn download-btn--main group-download-btn"
|
||||
data-group-type="appears_on">
|
||||
Download All Featuring Albums
|
||||
</button>
|
||||
</div>`;
|
||||
featuringSection.innerHTML = featuringHeaderHTML;
|
||||
const appearingAlbumsListContainer = document.createElement('div');
|
||||
appearingAlbumsListContainer.className = 'albums-list';
|
||||
|
||||
appearingAlbums.forEach(album => {
|
||||
if (!album) return;
|
||||
const albumElement = document.createElement('div');
|
||||
albumElement.className = 'album-card';
|
||||
albumElement.dataset.albumId = album.id; // Set dataset for appears_on albums too
|
||||
|
||||
let albumCardHTML = `
|
||||
<a href="/album/${album.id || ''}" class="album-link">
|
||||
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
|
||||
alt="Album cover"
|
||||
class="album-cover ${album.is_locally_known === false ? 'album-missing-in-db' : ''}">
|
||||
</a>
|
||||
<div class="album-info">
|
||||
<div class="album-title">${album.name || 'Unknown Album'}</div>
|
||||
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
|
||||
</div>
|
||||
`;
|
||||
albumElement.innerHTML = albumCardHTML;
|
||||
|
||||
const albumCardActions_AppearsOn = document.createElement('div');
|
||||
albumCardActions_AppearsOn.className = 'album-card-actions';
|
||||
|
||||
// Persistent Mark as Known/Missing button for appearing_on albums (if artist is watched) - Appears first (left)
|
||||
if (isGlobalWatchEnabled && isArtistActuallyWatched && album.id) {
|
||||
const toggleKnownBtn = document.createElement('button');
|
||||
toggleKnownBtn.className = 'toggle-known-status-btn persistent-album-action-btn';
|
||||
toggleKnownBtn.dataset.albumId = album.id;
|
||||
if (album.is_locally_known) {
|
||||
toggleKnownBtn.dataset.status = 'known';
|
||||
toggleKnownBtn.innerHTML = '<img src="/static/images/check.svg" alt="Mark as missing">';
|
||||
toggleKnownBtn.title = 'Mark album as not in local library (Missing)';
|
||||
toggleKnownBtn.classList.add('status-known'); // Green
|
||||
} else {
|
||||
toggleKnownBtn.dataset.status = 'missing';
|
||||
toggleKnownBtn.innerHTML = '<img src="/static/images/missing.svg" alt="Mark as known">';
|
||||
toggleKnownBtn.title = 'Mark album as in local library (Known)';
|
||||
toggleKnownBtn.classList.add('status-missing'); // Red
|
||||
}
|
||||
albumCardActions_AppearsOn.appendChild(toggleKnownBtn); // Add to actions container
|
||||
}
|
||||
|
||||
// Persistent Download Button for appearing_on albums (if not explicit filter) - Appears second (right)
|
||||
if (!isExplicitFilterEnabled) {
|
||||
const downloadBtn = document.createElement('button');
|
||||
downloadBtn.className = 'download-btn download-btn--circle persistent-download-btn';
|
||||
downloadBtn.innerHTML = '<img src="/static/images/download.svg" alt="Download album">';
|
||||
downloadBtn.title = 'Download this album';
|
||||
downloadBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
downloadBtn.disabled = true;
|
||||
downloadBtn.innerHTML = '<img src="/static/images/refresh.svg" alt="Queueing..." class="icon-spin">';
|
||||
startDownload(album.id, 'album', { name: album.name, artist: album.artists?.[0]?.name || 'Unknown Artist', type: 'album' })
|
||||
.then(() => {
|
||||
downloadBtn.innerHTML = '<img src="/static/images/check.svg" alt="Queued">';
|
||||
showNotification(`Album '${album.name}' queued for download.`);
|
||||
downloadQueue.toggleVisibility(true);
|
||||
})
|
||||
.catch(err => {
|
||||
downloadBtn.disabled = false;
|
||||
downloadBtn.innerHTML = '<img src="/static/images/download.svg" alt="Download album">';
|
||||
showError(`Failed to queue album: ${err?.message || 'Unknown error'}`);
|
||||
});
|
||||
});
|
||||
albumCardActions_AppearsOn.appendChild(downloadBtn); // Add to actions container
|
||||
}
|
||||
|
||||
// Only append albumCardActions_AppearsOn if it has any buttons
|
||||
if (albumCardActions_AppearsOn.hasChildNodes()) {
|
||||
albumElement.appendChild(albumCardActions_AppearsOn);
|
||||
}
|
||||
|
||||
appearingAlbumsListContainer.appendChild(albumElement);
|
||||
});
|
||||
featuringSection.appendChild(appearingAlbumsListContainer);
|
||||
groupsContainer.appendChild(featuringSection);
|
||||
}
|
||||
}
|
||||
|
||||
const artistHeaderEl = document.getElementById('artist-header');
|
||||
if (artistHeaderEl) artistHeaderEl.classList.remove('hidden');
|
||||
const albumsContainerEl = document.getElementById('albums-container');
|
||||
if (albumsContainerEl) albumsContainerEl.classList.remove('hidden');
|
||||
|
||||
if (!isExplicitFilterEnabled) {
|
||||
attachAlbumActionListeners(artistId, isGlobalWatchEnabled);
|
||||
attachGroupDownloadListeners(artistId, artistName);
|
||||
}
|
||||
}
|
||||
|
||||
function attachGroupDownloadListeners(artistId: string, artistName: string) {
|
||||
document.querySelectorAll('.group-download-btn').forEach(btn => {
|
||||
const button = btn as HTMLButtonElement;
|
||||
button.addEventListener('click', async (e) => {
|
||||
const target = e.target as HTMLButtonElement | null;
|
||||
if (!target) return;
|
||||
const groupType = target.dataset.groupType || 'album';
|
||||
target.disabled = true;
|
||||
const displayType = groupType === 'appears_on' ? 'Featuring Albums' : `${capitalize(groupType)}s`;
|
||||
target.textContent = `Queueing all ${displayType}...`;
|
||||
try {
|
||||
const taskIds = await startDownload(
|
||||
artistId,
|
||||
'artist',
|
||||
{ name: artistName || 'Unknown Artist', artist: artistName || 'Unknown Artist' },
|
||||
groupType
|
||||
);
|
||||
const totalQueued = Array.isArray(taskIds) ? taskIds.length : 0;
|
||||
target.textContent = `Queued all ${displayType}`;
|
||||
target.title = `${totalQueued} albums queued for download`;
|
||||
downloadQueue.toggleVisibility(true);
|
||||
} catch (error: any) {
|
||||
target.textContent = `Download All ${displayType}`;
|
||||
target.disabled = false;
|
||||
showError(`Failed to queue download for all ${groupType}: ${error?.message || 'Unknown error'}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function attachAlbumActionListeners(artistIdForContext: string, isGlobalWatchEnabled: boolean) {
|
||||
const groupsContainer = document.getElementById('album-groups');
|
||||
if (!groupsContainer) return;
|
||||
|
||||
groupsContainer.addEventListener('click', async (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const button = target.closest('.toggle-known-status-btn') as HTMLButtonElement | null;
|
||||
|
||||
if (button && button.dataset.albumId) {
|
||||
if (!isGlobalWatchEnabled) {
|
||||
showNotification("Watch feature is currently disabled globally.");
|
||||
return;
|
||||
}
|
||||
const albumId = button.dataset.albumId;
|
||||
const currentStatus = button.dataset.status;
|
||||
|
||||
// Optimistic UI update
|
||||
button.disabled = true;
|
||||
const originalIcon = button.innerHTML; // Save original icon
|
||||
button.innerHTML = '<img src="/static/images/refresh.svg" alt="Updating..." class="icon-spin">';
|
||||
|
||||
try {
|
||||
if (currentStatus === 'known') {
|
||||
await handleMarkAlbumAsMissing(artistIdForContext, albumId);
|
||||
button.dataset.status = 'missing';
|
||||
button.innerHTML = '<img src="/static/images/missing.svg" alt="Mark as known">'; // Update to missing.svg
|
||||
button.title = 'Mark album as in local library (Known)';
|
||||
button.classList.remove('status-known');
|
||||
button.classList.add('status-missing');
|
||||
const albumCard = button.closest('.album-card') as HTMLElement | null;
|
||||
if (albumCard) {
|
||||
const coverImg = albumCard.querySelector('.album-cover') as HTMLImageElement | null;
|
||||
if (coverImg) coverImg.classList.add('album-missing-in-db');
|
||||
}
|
||||
showNotification(`Album marked as missing from local library.`);
|
||||
} else {
|
||||
await handleMarkAlbumAsKnown(artistIdForContext, albumId);
|
||||
button.dataset.status = 'known';
|
||||
button.innerHTML = '<img src="/static/images/check.svg" alt="Mark as missing">'; // Update to check.svg
|
||||
button.title = 'Mark album as not in local library (Missing)';
|
||||
button.classList.remove('status-missing');
|
||||
button.classList.add('status-known');
|
||||
const albumCard = button.closest('.album-card') as HTMLElement | null;
|
||||
if (albumCard) {
|
||||
const coverImg = albumCard.querySelector('.album-cover') as HTMLImageElement | null;
|
||||
if (coverImg) coverImg.classList.remove('album-missing-in-db');
|
||||
}
|
||||
showNotification(`Album marked as present in local library.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update album status:', error);
|
||||
showError('Failed to update album status. Please try again.');
|
||||
// Revert UI on error
|
||||
button.dataset.status = currentStatus; // Revert status
|
||||
button.innerHTML = originalIcon; // Revert icon
|
||||
// Revert card style if needed (though if API failed, actual state is unchanged)
|
||||
} finally {
|
||||
button.disabled = false; // Re-enable button
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMarkAlbumAsKnown(artistId: string, albumId: string) {
|
||||
// Ensure albumId is a string and not undefined.
|
||||
if (!albumId || typeof albumId !== 'string') {
|
||||
console.error('Invalid albumId provided to handleMarkAlbumAsKnown:', albumId);
|
||||
throw new Error('Invalid album ID.');
|
||||
}
|
||||
const response = await fetch(`/api/artist/watch/${artistId}/albums`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify([albumId]) // API expects an array of album IDs
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to mark album as known.' }));
|
||||
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function handleMarkAlbumAsMissing(artistId: string, albumId: string) {
|
||||
// Ensure albumId is a string and not undefined.
|
||||
if (!albumId || typeof albumId !== 'string') {
|
||||
console.error('Invalid albumId provided to handleMarkAlbumAsMissing:', albumId);
|
||||
throw new Error('Invalid album ID.');
|
||||
}
|
||||
const response = await fetch(`/api/artist/watch/${artistId}/albums`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify([albumId]) // API expects an array of album IDs
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to mark album as missing.' }));
|
||||
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
// For DELETE, Spotify often returns 204 No Content, or we might return custom JSON.
|
||||
// If expecting JSON:
|
||||
// return response.json();
|
||||
// If handling 204 or simple success message:
|
||||
const result = await response.json(); // Assuming the backend sends a JSON response
|
||||
console.log('Mark as missing result:', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Add startDownload function (similar to track.js and main.js)
|
||||
/**
|
||||
* Starts the download process via centralized download queue
|
||||
*/
|
||||
async function startDownload(itemId: string, type: string, item: { name: string, artist?: string, type?: string }, albumType?: string) {
|
||||
if (!itemId || !type) {
|
||||
showError('Missing ID or type for download');
|
||||
return Promise.reject(new Error('Missing ID or type for download')); // Return a rejected promise
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the centralized downloadQueue.download method for all downloads including artist downloads
|
||||
const result = await downloadQueue.download(itemId, type, item, albumType);
|
||||
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
|
||||
// Return the result for tracking
|
||||
return result;
|
||||
} catch (error: any) { // Add type for error
|
||||
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// UI Helpers
|
||||
function showError(message: string) {
|
||||
const errorEl = document.getElementById('error');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message || 'An error occurred';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function capitalize(str: string) {
|
||||
return str ? str.charAt(0).toUpperCase() + str.slice(1) : '';
|
||||
}
|
||||
|
||||
async function getArtistWatchStatus(artistId: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`/api/artist/watch/${artistId}/status`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({})); // Catch if res not json
|
||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: WatchStatusResponse = await response.json();
|
||||
return data.is_watched;
|
||||
} catch (error) {
|
||||
console.error('Error fetching artist watch status:', error);
|
||||
showError('Could not fetch watch status.');
|
||||
return false; // Assume not watching on error
|
||||
}
|
||||
}
|
||||
|
||||
async function watchArtist(artistId: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`/api/artist/watch/${artistId}`, {
|
||||
method: 'PUT',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
// Optionally handle success message from response.json()
|
||||
await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error watching artist:', error);
|
||||
showError('Failed to watch artist.');
|
||||
throw error; // Re-throw to allow caller to handle UI update failure
|
||||
}
|
||||
}
|
||||
|
||||
async function unwatchArtist(artistId: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`/api/artist/watch/${artistId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
// Optionally handle success message
|
||||
await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error unwatching artist:', error);
|
||||
showError('Failed to unwatch artist.');
|
||||
throw error; // Re-throw
|
||||
}
|
||||
}
|
||||
|
||||
function updateWatchButton(artistId: string, isWatching: boolean) {
|
||||
const watchArtistBtn = document.getElementById('watchArtistBtn') as HTMLButtonElement | null;
|
||||
const syncArtistBtn = document.getElementById('syncArtistBtn') as HTMLButtonElement | null;
|
||||
|
||||
if (watchArtistBtn) {
|
||||
const img = watchArtistBtn.querySelector('img');
|
||||
if (isWatching) {
|
||||
if (img) img.src = '/static/images/eye-crossed.svg';
|
||||
watchArtistBtn.innerHTML = `<img src="/static/images/eye-crossed.svg" alt="Unwatch"> Unwatch Artist`;
|
||||
watchArtistBtn.classList.add('watching');
|
||||
watchArtistBtn.title = "Stop watching this artist";
|
||||
if (syncArtistBtn) syncArtistBtn.classList.remove('hidden');
|
||||
} else {
|
||||
if (img) img.src = '/static/images/eye.svg';
|
||||
watchArtistBtn.innerHTML = `<img src="/static/images/eye.svg" alt="Watch"> Watch Artist`;
|
||||
watchArtistBtn.classList.remove('watching');
|
||||
watchArtistBtn.title = "Watch this artist for new releases";
|
||||
if (syncArtistBtn) syncArtistBtn.classList.add('hidden');
|
||||
}
|
||||
watchArtistBtn.dataset.watching = isWatching ? 'true' : 'false';
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeWatchButton(artistId: string, initialIsWatching: boolean) {
|
||||
const watchArtistBtn = document.getElementById('watchArtistBtn') as HTMLButtonElement | null;
|
||||
const syncArtistBtn = document.getElementById('syncArtistBtn') as HTMLButtonElement | null;
|
||||
|
||||
if (!watchArtistBtn) return;
|
||||
|
||||
try {
|
||||
watchArtistBtn.disabled = true;
|
||||
if (syncArtistBtn) syncArtistBtn.disabled = true;
|
||||
|
||||
// const isWatching = await getArtistWatchStatus(artistId); // No longer fetch here, use parameter
|
||||
updateWatchButton(artistId, initialIsWatching); // Use passed status
|
||||
watchArtistBtn.disabled = false;
|
||||
if (syncArtistBtn) syncArtistBtn.disabled = !(watchArtistBtn.dataset.watching === 'true');
|
||||
|
||||
watchArtistBtn.addEventListener('click', async () => {
|
||||
const currentlyWatching = watchArtistBtn.dataset.watching === 'true';
|
||||
watchArtistBtn.disabled = true;
|
||||
if (syncArtistBtn) syncArtistBtn.disabled = true;
|
||||
try {
|
||||
if (currentlyWatching) {
|
||||
await unwatchArtist(artistId);
|
||||
updateWatchButton(artistId, false);
|
||||
// Re-fetch and re-render artist data, passing the global watch status again
|
||||
const newArtistData = await (await fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`)).json() as ArtistData;
|
||||
// Assuming renderArtist needs the global status, which it does. We need to get it or have it available.
|
||||
// Since initializeWatchButton is called from renderArtist, we can assume isGlobalWatchEnabled is in that scope.
|
||||
// This part is tricky as initializeWatchButton doesn't have isGlobalWatchEnabled.
|
||||
// Let's re-fetch global config or rely on the fact that if this button is clickable, global is on.
|
||||
// For simplicity, the re-render will pick up the global status from its own scope if called from top level.
|
||||
// The click handler itself does not need to pass isGlobalWatchEnabled to renderArtist, renderArtist's caller does.
|
||||
// Let's ensure renderArtist is called correctly after watch/unwatch.
|
||||
const globalWatchConfig = await getGlobalWatchConfig(); // Re-fetch for re-render
|
||||
renderArtist(newArtistData, artistId, globalWatchConfig.enabled);
|
||||
} else {
|
||||
await watchArtist(artistId);
|
||||
updateWatchButton(artistId, true);
|
||||
// Re-fetch and re-render artist data
|
||||
const newArtistData = await (await fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`)).json() as ArtistData;
|
||||
const globalWatchConfig = await getGlobalWatchConfig(); // Re-fetch for re-render
|
||||
renderArtist(newArtistData, artistId, globalWatchConfig.enabled);
|
||||
}
|
||||
} catch (error) {
|
||||
// On error, revert button to its state before the click attempt
|
||||
updateWatchButton(artistId, currentlyWatching);
|
||||
}
|
||||
watchArtistBtn.disabled = false;
|
||||
if (syncArtistBtn) syncArtistBtn.disabled = !(watchArtistBtn.dataset.watching === 'true');
|
||||
});
|
||||
|
||||
// Add event listener for the sync button
|
||||
if (syncArtistBtn) {
|
||||
syncArtistBtn.addEventListener('click', async () => {
|
||||
syncArtistBtn.disabled = true;
|
||||
const originalButtonContent = syncArtistBtn.innerHTML; // Store full HTML
|
||||
const textNode = Array.from(syncArtistBtn.childNodes).find(node => node.nodeType === Node.TEXT_NODE);
|
||||
const originalText = textNode ? textNode.nodeValue : 'Sync Watched Artist'; // Fallback text
|
||||
|
||||
syncArtistBtn.innerHTML = `<img src="/static/images/refresh.svg" alt="Sync"> Syncing...`; // Keep icon
|
||||
try {
|
||||
await triggerArtistSync(artistId);
|
||||
showNotification('Artist sync triggered successfully.');
|
||||
} catch (error) {
|
||||
// Error is shown by triggerArtistSync
|
||||
}
|
||||
syncArtistBtn.innerHTML = originalButtonContent; // Restore full original HTML
|
||||
syncArtistBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (watchArtistBtn) watchArtistBtn.disabled = false;
|
||||
if (syncArtistBtn) syncArtistBtn.disabled = true;
|
||||
updateWatchButton(artistId, false); // On error fetching initial status (though now it's passed)
|
||||
// This line might be less relevant if initialIsWatching is guaranteed by caller
|
||||
// but as a fallback it sets to a non-watching state.
|
||||
}
|
||||
}
|
||||
|
||||
// New function to trigger artist sync
|
||||
async function triggerArtistSync(artistId: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`/api/artist/watch/trigger_check/${artistId}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
await response.json(); // Contains success message
|
||||
} catch (error) {
|
||||
console.error('Error triggering artist sync:', error);
|
||||
showError('Failed to trigger artist sync.');
|
||||
throw error; // Re-throw
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a temporary notification message.
|
||||
*/
|
||||
function showNotification(message: string) {
|
||||
// Basic notification - consider a more robust solution for production
|
||||
const notificationEl = document.createElement('div');
|
||||
notificationEl.className = 'notification'; // Ensure this class is styled
|
||||
notificationEl.textContent = message;
|
||||
document.body.appendChild(notificationEl);
|
||||
setTimeout(() => {
|
||||
notificationEl.remove();
|
||||
}, 3000);
|
||||
}
|
||||
1240
src/js/config.ts
@@ -1,330 +0,0 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const historyTableBody = document.getElementById('history-table-body') as HTMLTableSectionElement | null;
|
||||
const prevButton = document.getElementById('prev-page') as HTMLButtonElement | null;
|
||||
const nextButton = document.getElementById('next-page') as HTMLButtonElement | null;
|
||||
const pageInfo = document.getElementById('page-info') as HTMLSpanElement | null;
|
||||
const limitSelect = document.getElementById('limit-select') as HTMLSelectElement | null;
|
||||
const statusFilter = document.getElementById('status-filter') as HTMLSelectElement | null;
|
||||
const typeFilter = document.getElementById('type-filter') as HTMLSelectElement | null;
|
||||
const trackFilter = document.getElementById('track-filter') as HTMLSelectElement | null;
|
||||
const hideChildTracksCheckbox = document.getElementById('hide-child-tracks') as HTMLInputElement | null;
|
||||
|
||||
let currentPage = 1;
|
||||
let limit = 25;
|
||||
let totalEntries = 0;
|
||||
let currentSortBy = 'timestamp_completed';
|
||||
let currentSortOrder = 'DESC';
|
||||
let currentParentTaskId: string | null = null;
|
||||
|
||||
async function fetchHistory(page = 1) {
|
||||
if (!historyTableBody || !prevButton || !nextButton || !pageInfo || !limitSelect || !statusFilter || !typeFilter) {
|
||||
console.error('One or more critical UI elements are missing for history page.');
|
||||
return;
|
||||
}
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
let apiUrl = `/api/history?limit=${limit}&offset=${offset}&sort_by=${currentSortBy}&sort_order=${currentSortOrder}`;
|
||||
|
||||
const statusVal = statusFilter.value;
|
||||
if (statusVal) {
|
||||
apiUrl += `&status_final=${statusVal}`;
|
||||
}
|
||||
const typeVal = typeFilter.value;
|
||||
if (typeVal) {
|
||||
apiUrl += `&download_type=${typeVal}`;
|
||||
}
|
||||
|
||||
// Add track status filter if present
|
||||
if (trackFilter && trackFilter.value) {
|
||||
apiUrl += `&track_status=${trackFilter.value}`;
|
||||
}
|
||||
|
||||
// Add parent task filter if viewing a specific parent's tracks
|
||||
if (currentParentTaskId) {
|
||||
apiUrl += `&parent_task_id=${currentParentTaskId}`;
|
||||
}
|
||||
|
||||
// Add hide child tracks filter if checkbox is checked
|
||||
if (hideChildTracksCheckbox && hideChildTracksCheckbox.checked) {
|
||||
apiUrl += `&hide_child_tracks=true`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
renderHistory(data.entries);
|
||||
totalEntries = data.total_count;
|
||||
currentPage = Math.floor(offset / limit) + 1;
|
||||
updatePagination();
|
||||
updateSortIndicators();
|
||||
|
||||
// Update page title if viewing tracks for a parent
|
||||
updatePageTitle();
|
||||
} catch (error) {
|
||||
console.error('Error fetching history:', error);
|
||||
if (historyTableBody) {
|
||||
historyTableBody.innerHTML = '<tr><td colspan="10">Error loading history.</td></tr>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderHistory(entries: any[]) {
|
||||
if (!historyTableBody) return;
|
||||
|
||||
historyTableBody.innerHTML = ''; // Clear existing rows
|
||||
if (!entries || entries.length === 0) {
|
||||
historyTableBody.innerHTML = '<tr><td colspan="10">No history entries found.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
entries.forEach(entry => {
|
||||
const row = historyTableBody.insertRow();
|
||||
|
||||
// Add class for parent/child styling
|
||||
if (entry.parent_task_id) {
|
||||
row.classList.add('child-track-row');
|
||||
} else if (entry.download_type === 'album' || entry.download_type === 'playlist') {
|
||||
row.classList.add('parent-task-row');
|
||||
}
|
||||
|
||||
// Item name with indentation for child tracks
|
||||
const nameCell = row.insertCell();
|
||||
if (entry.parent_task_id) {
|
||||
nameCell.innerHTML = `<span class="child-track-indent">└─ </span>${entry.item_name || 'N/A'}`;
|
||||
} else {
|
||||
nameCell.textContent = entry.item_name || 'N/A';
|
||||
}
|
||||
|
||||
row.insertCell().textContent = entry.item_artist || 'N/A';
|
||||
|
||||
// Type cell - show track status for child tracks
|
||||
const typeCell = row.insertCell();
|
||||
if (entry.parent_task_id && entry.track_status) {
|
||||
typeCell.textContent = entry.track_status;
|
||||
typeCell.classList.add(`track-status-${entry.track_status.toLowerCase()}`);
|
||||
} else {
|
||||
typeCell.textContent = entry.download_type ? entry.download_type.charAt(0).toUpperCase() + entry.download_type.slice(1) : 'N/A';
|
||||
}
|
||||
|
||||
row.insertCell().textContent = entry.service_used || 'N/A';
|
||||
|
||||
// Construct Quality display string
|
||||
const qualityCell = row.insertCell();
|
||||
let qualityDisplay = entry.quality_profile || 'N/A';
|
||||
|
||||
// Check if convert_to exists and is not "None"
|
||||
if (entry.convert_to && entry.convert_to !== "None") {
|
||||
qualityDisplay = `${entry.convert_to.toUpperCase()}`;
|
||||
// Check if bitrate exists and is not "None"
|
||||
if (entry.bitrate && entry.bitrate !== "None") {
|
||||
qualityDisplay += ` ${entry.bitrate}k`;
|
||||
}
|
||||
qualityDisplay += ` (${entry.quality_profile || 'Original'})`;
|
||||
} else if (entry.bitrate && entry.bitrate !== "None") { // Case where convert_to might not be set, but bitrate is (e.g. for OGG Vorbis quality settings)
|
||||
qualityDisplay = `${entry.bitrate}k (${entry.quality_profile || 'Profile'})`;
|
||||
}
|
||||
// If both are "None" or null, it will just use the quality_profile value set above
|
||||
qualityCell.textContent = qualityDisplay;
|
||||
|
||||
const statusCell = row.insertCell();
|
||||
statusCell.textContent = entry.status_final || 'N/A';
|
||||
statusCell.className = `status-${entry.status_final?.toLowerCase() || 'unknown'}`;
|
||||
|
||||
row.insertCell().textContent = entry.timestamp_added ? new Date(entry.timestamp_added * 1000).toLocaleString() : 'N/A';
|
||||
row.insertCell().textContent = entry.timestamp_completed ? new Date(entry.timestamp_completed * 1000).toLocaleString() : 'N/A';
|
||||
|
||||
const actionsCell = row.insertCell();
|
||||
|
||||
// Add details button
|
||||
const detailsButton = document.createElement('button');
|
||||
detailsButton.innerHTML = `<img src="/static/images/info.svg" alt="Details">`;
|
||||
detailsButton.className = 'details-btn btn-icon';
|
||||
detailsButton.title = 'Show Details';
|
||||
detailsButton.onclick = () => showDetailsModal(entry);
|
||||
actionsCell.appendChild(detailsButton);
|
||||
|
||||
// Add view tracks button for album/playlist entries with child tracks
|
||||
if (!entry.parent_task_id && (entry.download_type === 'album' || entry.download_type === 'playlist') &&
|
||||
(entry.total_successful > 0 || entry.total_skipped > 0 || entry.total_failed > 0)) {
|
||||
const viewTracksButton = document.createElement('button');
|
||||
viewTracksButton.innerHTML = `<img src="/static/images/list.svg" alt="Tracks">`;
|
||||
viewTracksButton.className = 'tracks-btn btn-icon';
|
||||
viewTracksButton.title = 'View Tracks';
|
||||
viewTracksButton.setAttribute('data-task-id', entry.task_id);
|
||||
viewTracksButton.onclick = () => viewTracksForParent(entry.task_id);
|
||||
actionsCell.appendChild(viewTracksButton);
|
||||
|
||||
// Add track counts display
|
||||
const trackCountsSpan = document.createElement('span');
|
||||
trackCountsSpan.className = 'track-counts';
|
||||
trackCountsSpan.title = `Successful: ${entry.total_successful || 0}, Skipped: ${entry.total_skipped || 0}, Failed: ${entry.total_failed || 0}`;
|
||||
trackCountsSpan.innerHTML = `
|
||||
<span class="track-count success">${entry.total_successful || 0}</span> /
|
||||
<span class="track-count skipped">${entry.total_skipped || 0}</span> /
|
||||
<span class="track-count failed">${entry.total_failed || 0}</span>
|
||||
`;
|
||||
actionsCell.appendChild(trackCountsSpan);
|
||||
}
|
||||
|
||||
if (entry.status_final === 'ERROR' && entry.error_message) {
|
||||
const errorSpan = document.createElement('span');
|
||||
errorSpan.textContent = ' (Show Error)';
|
||||
errorSpan.className = 'error-message-toggle';
|
||||
errorSpan.style.marginLeft = '5px';
|
||||
errorSpan.onclick = (e) => {
|
||||
e.stopPropagation(); // Prevent click on row if any
|
||||
let errorDetailsDiv = row.querySelector('.error-details') as HTMLElement | null;
|
||||
if (!errorDetailsDiv) {
|
||||
errorDetailsDiv = document.createElement('div');
|
||||
errorDetailsDiv.className = 'error-details';
|
||||
const newCell = row.insertCell(); // This will append to the end of the row
|
||||
newCell.colSpan = 10; // Span across all columns
|
||||
newCell.appendChild(errorDetailsDiv);
|
||||
}
|
||||
errorDetailsDiv.textContent = entry.error_message;
|
||||
// Toggle display by directly manipulating the style of the details div
|
||||
errorDetailsDiv.style.display = errorDetailsDiv.style.display === 'none' ? 'block' : 'none';
|
||||
};
|
||||
statusCell.appendChild(errorSpan);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updatePagination() {
|
||||
if (!pageInfo || !prevButton || !nextButton) return;
|
||||
|
||||
const totalPages = Math.ceil(totalEntries / limit) || 1;
|
||||
pageInfo.textContent = `Page ${currentPage} of ${totalPages}`;
|
||||
prevButton.disabled = currentPage === 1;
|
||||
nextButton.disabled = currentPage === totalPages;
|
||||
}
|
||||
|
||||
function updatePageTitle() {
|
||||
const titleElement = document.getElementById('history-title');
|
||||
if (!titleElement) return;
|
||||
|
||||
if (currentParentTaskId) {
|
||||
titleElement.textContent = 'Download History - Viewing Tracks';
|
||||
|
||||
// Add back button
|
||||
if (!document.getElementById('back-to-history')) {
|
||||
const backButton = document.createElement('button');
|
||||
backButton.id = 'back-to-history';
|
||||
backButton.className = 'btn btn-secondary';
|
||||
backButton.innerHTML = '← Back to All History';
|
||||
backButton.onclick = () => {
|
||||
currentParentTaskId = null;
|
||||
updatePageTitle();
|
||||
fetchHistory(1);
|
||||
};
|
||||
titleElement.parentNode?.insertBefore(backButton, titleElement);
|
||||
}
|
||||
} else {
|
||||
titleElement.textContent = 'Download History';
|
||||
|
||||
// Remove back button if it exists
|
||||
const backButton = document.getElementById('back-to-history');
|
||||
if (backButton) {
|
||||
backButton.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showDetailsModal(entry: any) {
|
||||
// Create more detailed modal content with new fields
|
||||
let details = `Task ID: ${entry.task_id}\n` +
|
||||
`Type: ${entry.download_type}\n` +
|
||||
`Name: ${entry.item_name}\n` +
|
||||
`Artist: ${entry.item_artist}\n` +
|
||||
`Album: ${entry.item_album || 'N/A'}\n` +
|
||||
`URL: ${entry.item_url || 'N/A'}\n` +
|
||||
`Spotify ID: ${entry.spotify_id || 'N/A'}\n` +
|
||||
`Service Used: ${entry.service_used || 'N/A'}\n` +
|
||||
`Quality Profile (Original): ${entry.quality_profile || 'N/A'}\n` +
|
||||
`ConvertTo: ${entry.convert_to || 'N/A'}\n` +
|
||||
`Bitrate: ${entry.bitrate ? entry.bitrate + 'k' : 'N/A'}\n` +
|
||||
`Status: ${entry.status_final}\n` +
|
||||
`Error: ${entry.error_message || 'None'}\n` +
|
||||
`Added: ${new Date(entry.timestamp_added * 1000).toLocaleString()}\n` +
|
||||
`Completed/Ended: ${new Date(entry.timestamp_completed * 1000).toLocaleString()}\n`;
|
||||
|
||||
// Add track-specific details if this is a track
|
||||
if (entry.parent_task_id) {
|
||||
details += `Parent Task ID: ${entry.parent_task_id}\n` +
|
||||
`Track Status: ${entry.track_status || 'N/A'}\n`;
|
||||
}
|
||||
|
||||
// Add summary details if this is a parent task
|
||||
if (entry.total_successful !== null || entry.total_skipped !== null || entry.total_failed !== null) {
|
||||
details += `\nTrack Summary:\n` +
|
||||
`Successful: ${entry.total_successful || 0}\n` +
|
||||
`Skipped: ${entry.total_skipped || 0}\n` +
|
||||
`Failed: ${entry.total_failed || 0}\n`;
|
||||
}
|
||||
|
||||
details += `\nOriginal Request: ${JSON.stringify(JSON.parse(entry.original_request_json || '{}'), null, 2)}\n\n` +
|
||||
`Last Status Object: ${JSON.stringify(JSON.parse(entry.last_status_obj_json || '{}'), null, 2)}`;
|
||||
|
||||
// Try to parse and display summary if available
|
||||
if (entry.summary_json) {
|
||||
try {
|
||||
const summary = JSON.parse(entry.summary_json);
|
||||
details += `\nSummary: ${JSON.stringify(summary, null, 2)}`;
|
||||
} catch (e) {
|
||||
console.error('Error parsing summary JSON:', e);
|
||||
}
|
||||
}
|
||||
|
||||
alert(details);
|
||||
}
|
||||
|
||||
// Function to view tracks for a parent task
|
||||
async function viewTracksForParent(taskId: string) {
|
||||
currentParentTaskId = taskId;
|
||||
currentPage = 1;
|
||||
fetchHistory(1);
|
||||
}
|
||||
|
||||
document.querySelectorAll('th[data-sort]').forEach(headerCell => {
|
||||
headerCell.addEventListener('click', () => {
|
||||
const sortField = (headerCell as HTMLElement).dataset.sort;
|
||||
if (!sortField) return;
|
||||
|
||||
if (currentSortBy === sortField) {
|
||||
currentSortOrder = currentSortOrder === 'ASC' ? 'DESC' : 'ASC';
|
||||
} else {
|
||||
currentSortBy = sortField;
|
||||
currentSortOrder = 'DESC';
|
||||
}
|
||||
fetchHistory(1);
|
||||
});
|
||||
});
|
||||
|
||||
function updateSortIndicators() {
|
||||
document.querySelectorAll('th[data-sort]').forEach(headerCell => {
|
||||
const th = headerCell as HTMLElement;
|
||||
th.classList.remove('sort-asc', 'sort-desc');
|
||||
if (th.dataset.sort === currentSortBy) {
|
||||
th.classList.add(currentSortOrder === 'ASC' ? 'sort-asc' : 'sort-desc');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Event listeners for pagination and filters
|
||||
prevButton?.addEventListener('click', () => fetchHistory(currentPage - 1));
|
||||
nextButton?.addEventListener('click', () => fetchHistory(currentPage + 1));
|
||||
limitSelect?.addEventListener('change', (e) => {
|
||||
limit = parseInt((e.target as HTMLSelectElement).value, 10);
|
||||
fetchHistory(1);
|
||||
});
|
||||
statusFilter?.addEventListener('change', () => fetchHistory(1));
|
||||
typeFilter?.addEventListener('change', () => fetchHistory(1));
|
||||
trackFilter?.addEventListener('change', () => fetchHistory(1));
|
||||
hideChildTracksCheckbox?.addEventListener('change', () => fetchHistory(1));
|
||||
|
||||
// Initial fetch
|
||||
fetchHistory();
|
||||
});
|
||||
626
src/js/main.ts
@@ -1,626 +0,0 @@
|
||||
// main.ts
|
||||
import { downloadQueue } from './queue.js';
|
||||
|
||||
// Define interfaces for API data and search results
|
||||
interface Image {
|
||||
url: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
interface Artist {
|
||||
id?: string; // Artist ID might not always be present in search results for track artists
|
||||
name: string;
|
||||
external_urls?: { spotify?: string };
|
||||
genres?: string[]; // For artist type results
|
||||
}
|
||||
|
||||
interface Album {
|
||||
id?: string; // Album ID might not always be present
|
||||
name: string;
|
||||
images?: Image[];
|
||||
album_type?: string; // Used in startDownload
|
||||
artists?: Artist[]; // Album can have artists too
|
||||
total_tracks?: number;
|
||||
release_date?: string;
|
||||
external_urls?: { spotify?: string };
|
||||
}
|
||||
|
||||
interface Track {
|
||||
id: string;
|
||||
name: string;
|
||||
artists: Artist[];
|
||||
album: Album;
|
||||
duration_ms?: number;
|
||||
explicit?: boolean;
|
||||
external_urls: { spotify: string };
|
||||
href?: string; // Some spotify responses use href
|
||||
}
|
||||
|
||||
interface Playlist {
|
||||
id: string;
|
||||
name: string;
|
||||
owner: { display_name?: string; id?: string };
|
||||
images?: Image[];
|
||||
tracks: { total: number }; // Simplified for search results
|
||||
external_urls: { spotify: string };
|
||||
href?: string; // Some spotify responses use href
|
||||
explicit?: boolean; // Playlists themselves aren't explicit, but items can be
|
||||
}
|
||||
|
||||
// Specific item types for search results
|
||||
interface TrackResultItem extends Track {}
|
||||
interface AlbumResultItem extends Album { id: string; images?: Image[]; explicit?: boolean; external_urls: { spotify: string }; href?: string; }
|
||||
interface PlaylistResultItem extends Playlist {}
|
||||
interface ArtistResultItem extends Artist { id: string; images?: Image[]; explicit?: boolean; external_urls: { spotify: string }; href?: string; followers?: { total: number }; }
|
||||
|
||||
// Union type for any search result item
|
||||
type SearchResultItem = TrackResultItem | AlbumResultItem | PlaylistResultItem | ArtistResultItem;
|
||||
|
||||
// Interface for the API response structure
|
||||
interface SearchResponse {
|
||||
items: SearchResultItem[];
|
||||
// Add other top-level properties from the search API if needed (e.g., total, limit, offset)
|
||||
}
|
||||
|
||||
// Interface for the item passed to downloadQueue.download
|
||||
interface DownloadQueueItem {
|
||||
name: string;
|
||||
artist?: string;
|
||||
album?: { name: string; album_type?: string };
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// DOM elements
|
||||
const searchInput = document.getElementById('searchInput') as HTMLInputElement | null;
|
||||
const searchButton = document.getElementById('searchButton') as HTMLButtonElement | null;
|
||||
const searchType = document.getElementById('searchType') as HTMLSelectElement | null;
|
||||
const resultsContainer = document.getElementById('resultsContainer');
|
||||
const queueIcon = document.getElementById('queueIcon');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const loadingResults = document.getElementById('loadingResults');
|
||||
const watchlistButton = document.getElementById('watchlistButton') as HTMLAnchorElement | null;
|
||||
|
||||
// Initialize the queue
|
||||
if (queueIcon) {
|
||||
queueIcon.addEventListener('click', () => {
|
||||
downloadQueue.toggleVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
if (searchButton) {
|
||||
searchButton.addEventListener('click', performSearch);
|
||||
}
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keypress', function(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
performSearch();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-detect and handle pasted Spotify URLs
|
||||
searchInput.addEventListener('input', function(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const inputVal = target.value.trim();
|
||||
if (isSpotifyUrl(inputVal)) {
|
||||
const details = getSpotifyResourceDetails(inputVal);
|
||||
if (details && searchType) {
|
||||
searchType.value = details.type;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Restore last search type if no URL override
|
||||
const savedType = localStorage.getItem('lastSearchType');
|
||||
if (searchType && savedType && ['track','album','playlist','artist'].includes(savedType)) {
|
||||
searchType.value = savedType;
|
||||
}
|
||||
// Save last selection on change
|
||||
if (searchType) {
|
||||
searchType.addEventListener('change', () => {
|
||||
localStorage.setItem('lastSearchType', searchType.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Attempt to set initial watchlist button visibility from cache
|
||||
if (watchlistButton) {
|
||||
const cachedWatchEnabled = localStorage.getItem('spotizerr_watch_enabled_cached');
|
||||
if (cachedWatchEnabled === 'true') {
|
||||
watchlistButton.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch watch config to determine if watchlist button should be visible
|
||||
async function updateWatchlistButtonVisibility() {
|
||||
if (watchlistButton) {
|
||||
try {
|
||||
const response = await fetch('/api/config/watch');
|
||||
if (response.ok) {
|
||||
const watchConfig = await response.json();
|
||||
localStorage.setItem('spotizerr_watch_enabled_cached', watchConfig.enabled ? 'true' : 'false');
|
||||
if (watchConfig && watchConfig.enabled === false) {
|
||||
watchlistButton.classList.add('hidden');
|
||||
} else {
|
||||
watchlistButton.classList.remove('hidden'); // Ensure it's shown if enabled
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch watch config, defaulting to hidden');
|
||||
// Don't update cache on error, rely on default hidden or previous cache state until success
|
||||
watchlistButton.classList.add('hidden'); // Hide if config fetch fails
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching watch config:', error);
|
||||
// Don't update cache on error
|
||||
watchlistButton.classList.add('hidden'); // Hide on error
|
||||
}
|
||||
}
|
||||
}
|
||||
updateWatchlistButtonVisibility();
|
||||
|
||||
// Check for URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const query = urlParams.get('q');
|
||||
const type = urlParams.get('type');
|
||||
|
||||
if (query && searchInput) {
|
||||
searchInput.value = query;
|
||||
if (type && searchType && ['track', 'album', 'playlist', 'artist'].includes(type)) {
|
||||
searchType.value = type;
|
||||
}
|
||||
performSearch();
|
||||
} else {
|
||||
// Show empty state if no query
|
||||
showEmptyState(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the search based on input values
|
||||
*/
|
||||
async function performSearch() {
|
||||
const currentQuery = searchInput?.value.trim();
|
||||
if (!currentQuery) return;
|
||||
|
||||
// Handle direct Spotify URLs
|
||||
if (isSpotifyUrl(currentQuery)) {
|
||||
const details = getSpotifyResourceDetails(currentQuery);
|
||||
if (details && details.id) {
|
||||
// Redirect to the appropriate page
|
||||
window.location.href = `/${details.type}/${details.id}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Update URL without reloading page
|
||||
const currentSearchType = searchType?.value || 'track';
|
||||
const newUrl = `${window.location.pathname}?q=${encodeURIComponent(currentQuery)}&type=${currentSearchType}`;
|
||||
window.history.pushState({ path: newUrl }, '', newUrl);
|
||||
|
||||
// Show loading state
|
||||
showEmptyState(false);
|
||||
showLoading(true);
|
||||
if(resultsContainer) resultsContainer.innerHTML = '';
|
||||
|
||||
try {
|
||||
const url = `/api/search?q=${encodeURIComponent(currentQuery)}&search_type=${currentSearchType}&limit=40`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
|
||||
const data = await response.json() as SearchResponse; // Assert type for API response
|
||||
|
||||
// Hide loading indicator
|
||||
showLoading(false);
|
||||
|
||||
// Render results
|
||||
if (data && data.items && data.items.length > 0) {
|
||||
if(resultsContainer) resultsContainer.innerHTML = '';
|
||||
|
||||
// Filter out items with null/undefined essential display parameters
|
||||
const validItems = filterValidItems(data.items, currentSearchType);
|
||||
|
||||
if (validItems.length === 0) {
|
||||
// No valid items found after filtering
|
||||
if(resultsContainer) resultsContainer.innerHTML = `
|
||||
<div class="empty-search-results">
|
||||
<p>No valid results found for "${currentQuery}"</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
validItems.forEach((item, index) => {
|
||||
const cardElement = createResultCard(item, currentSearchType, index);
|
||||
|
||||
// Store the item data directly on the button element
|
||||
const downloadBtn = cardElement.querySelector('.download-btn') as HTMLButtonElement | null;
|
||||
if (downloadBtn) {
|
||||
downloadBtn.dataset.itemIndex = index.toString();
|
||||
}
|
||||
|
||||
if(resultsContainer) resultsContainer.appendChild(cardElement);
|
||||
});
|
||||
|
||||
// Attach download handlers to the newly created cards
|
||||
attachDownloadListeners(validItems);
|
||||
} else {
|
||||
// No results found
|
||||
if(resultsContainer) resultsContainer.innerHTML = `
|
||||
<div class="empty-search-results">
|
||||
<p>No results found for "${currentQuery}"</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error:', error);
|
||||
showLoading(false);
|
||||
if(resultsContainer) resultsContainer.innerHTML = `
|
||||
<div class="error">
|
||||
<p>Error searching: ${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out items with null/undefined essential display parameters based on search type
|
||||
*/
|
||||
function filterValidItems(items: SearchResultItem[], type: string): SearchResultItem[] {
|
||||
if (!items) return [];
|
||||
|
||||
return items.filter(item => {
|
||||
// Skip null/undefined items
|
||||
if (!item) return false;
|
||||
|
||||
// Skip explicit content if filter is enabled
|
||||
if (downloadQueue.isExplicitFilterEnabled() && ('explicit' in item && item.explicit === true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check essential parameters based on search type
|
||||
switch (type) {
|
||||
case 'track':
|
||||
const trackItem = item as TrackResultItem;
|
||||
return (
|
||||
trackItem.name &&
|
||||
trackItem.artists &&
|
||||
trackItem.artists.length > 0 &&
|
||||
trackItem.artists[0] &&
|
||||
trackItem.artists[0].name &&
|
||||
trackItem.album &&
|
||||
trackItem.album.name &&
|
||||
trackItem.external_urls &&
|
||||
trackItem.external_urls.spotify
|
||||
);
|
||||
|
||||
case 'album':
|
||||
const albumItem = item as AlbumResultItem;
|
||||
return (
|
||||
albumItem.name &&
|
||||
albumItem.artists &&
|
||||
albumItem.artists.length > 0 &&
|
||||
albumItem.artists[0] &&
|
||||
albumItem.artists[0].name &&
|
||||
albumItem.external_urls &&
|
||||
albumItem.external_urls.spotify
|
||||
);
|
||||
|
||||
case 'playlist':
|
||||
const playlistItem = item as PlaylistResultItem;
|
||||
return (
|
||||
playlistItem.name &&
|
||||
playlistItem.owner &&
|
||||
playlistItem.owner.display_name &&
|
||||
playlistItem.tracks &&
|
||||
playlistItem.external_urls &&
|
||||
playlistItem.external_urls.spotify
|
||||
);
|
||||
|
||||
case 'artist':
|
||||
const artistItem = item as ArtistResultItem;
|
||||
return (
|
||||
artistItem.name &&
|
||||
artistItem.external_urls &&
|
||||
artistItem.external_urls.spotify
|
||||
);
|
||||
|
||||
default:
|
||||
// Default case - just check if the item exists (already handled by `if (!item) return false;`)
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches download handlers to result cards
|
||||
*/
|
||||
function attachDownloadListeners(items: SearchResultItem[]) {
|
||||
document.querySelectorAll('.download-btn').forEach((btnElm) => {
|
||||
const btn = btnElm as HTMLButtonElement;
|
||||
btn.addEventListener('click', (e: Event) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Get the item index from the button's dataset
|
||||
const itemIndexStr = btn.dataset.itemIndex;
|
||||
if (!itemIndexStr) return;
|
||||
const itemIndex = parseInt(itemIndexStr, 10);
|
||||
|
||||
// Get the corresponding item
|
||||
const item = items[itemIndex];
|
||||
if (!item) return;
|
||||
|
||||
const currentSearchType = searchType?.value || 'track';
|
||||
let itemId = item.id || ''; // Use item.id directly
|
||||
|
||||
if (!itemId) { // Check if ID was found
|
||||
showError('Could not determine download ID');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare metadata for the download
|
||||
let metadata: DownloadQueueItem;
|
||||
if (currentSearchType === 'track') {
|
||||
const trackItem = item as TrackResultItem;
|
||||
metadata = {
|
||||
name: trackItem.name || 'Unknown',
|
||||
artist: trackItem.artists ? trackItem.artists[0]?.name : undefined,
|
||||
album: trackItem.album ? { name: trackItem.album.name, album_type: trackItem.album.album_type } : undefined
|
||||
};
|
||||
} else if (currentSearchType === 'album') {
|
||||
const albumItem = item as AlbumResultItem;
|
||||
metadata = {
|
||||
name: albumItem.name || 'Unknown',
|
||||
artist: albumItem.artists ? albumItem.artists[0]?.name : undefined,
|
||||
album: { name: albumItem.name, album_type: albumItem.album_type}
|
||||
};
|
||||
} else if (currentSearchType === 'playlist') {
|
||||
const playlistItem = item as PlaylistResultItem;
|
||||
metadata = {
|
||||
name: playlistItem.name || 'Unknown',
|
||||
// artist for playlist is owner
|
||||
artist: playlistItem.owner?.display_name
|
||||
};
|
||||
} else if (currentSearchType === 'artist') {
|
||||
const artistItem = item as ArtistResultItem;
|
||||
metadata = {
|
||||
name: artistItem.name || 'Unknown',
|
||||
artist: artistItem.name // For artist type, artist is the item name itself
|
||||
};
|
||||
} else {
|
||||
metadata = { name: item.name || 'Unknown' }; // Fallback
|
||||
}
|
||||
|
||||
// Disable the button and update text
|
||||
btn.disabled = true;
|
||||
|
||||
// For artist downloads, show a different message since it will queue multiple albums
|
||||
if (currentSearchType === 'artist') {
|
||||
btn.innerHTML = 'Queueing albums...';
|
||||
} else {
|
||||
btn.innerHTML = 'Queueing...';
|
||||
}
|
||||
|
||||
// Start the download
|
||||
startDownload(itemId, currentSearchType, metadata,
|
||||
(item as AlbumResultItem).album_type || ((item as TrackResultItem).album ? (item as TrackResultItem).album.album_type : null))
|
||||
.then(() => {
|
||||
// For artists, show how many albums were queued
|
||||
if (currentSearchType === 'artist') {
|
||||
btn.innerHTML = 'Albums queued!';
|
||||
// Open the queue automatically for artist downloads
|
||||
downloadQueue.toggleVisibility(true);
|
||||
} else {
|
||||
btn.innerHTML = 'Queued!';
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = 'Download';
|
||||
showError('Failed to queue download: ' + error.message);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the download process via API
|
||||
*/
|
||||
async function startDownload(itemId: string, type: string, item: DownloadQueueItem, albumType: string | null | undefined) {
|
||||
if (!itemId || !type) {
|
||||
showError('Missing ID or type for download');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the centralized downloadQueue.download method
|
||||
await downloadQueue.download(itemId, type, item, albumType);
|
||||
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
} catch (error: any) {
|
||||
showError('Download failed: ' + (error.message || 'Unknown error'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an error message
|
||||
*/
|
||||
function showError(message: string) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'error';
|
||||
errorDiv.textContent = message;
|
||||
document.body.appendChild(errorDiv);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => errorDiv.remove(), 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a success message
|
||||
*/
|
||||
function showSuccess(message: string) {
|
||||
const successDiv = document.createElement('div');
|
||||
successDiv.className = 'success';
|
||||
successDiv.textContent = message;
|
||||
document.body.appendChild(successDiv);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => successDiv.remove(), 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid Spotify URL
|
||||
*/
|
||||
function isSpotifyUrl(url: string): boolean {
|
||||
return url.includes('open.spotify.com') ||
|
||||
url.includes('spotify:') ||
|
||||
url.includes('link.tospotify.com');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts details from a Spotify URL
|
||||
*/
|
||||
function getSpotifyResourceDetails(url: string): { type: string; id: string } | null {
|
||||
// Allow optional path segments (e.g. intl-fr) before resource type
|
||||
const regex = /spotify\.com\/(?:[^\/]+\/)??(track|album|playlist|artist)\/([a-zA-Z0-9]+)/i;
|
||||
const match = url.match(regex);
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
type: match[1],
|
||||
id: match[2]
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats milliseconds to MM:SS
|
||||
*/
|
||||
function msToMinutesSeconds(ms: number | undefined): string {
|
||||
if (!ms) return '0:00';
|
||||
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = ((ms % 60000) / 1000).toFixed(0);
|
||||
return `${minutes}:${seconds.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a result card element
|
||||
*/
|
||||
function createResultCard(item: SearchResultItem, type: string, index: number): HTMLDivElement {
|
||||
const cardElement = document.createElement('div');
|
||||
cardElement.className = 'result-card';
|
||||
|
||||
// Set cursor to pointer for clickable cards
|
||||
cardElement.style.cursor = 'pointer';
|
||||
|
||||
// Get the appropriate image URL
|
||||
let imageUrl = '/static/images/placeholder.jpg';
|
||||
// Type guards to safely access images
|
||||
if (type === 'album' || type === 'artist') {
|
||||
const albumOrArtistItem = item as AlbumResultItem | ArtistResultItem;
|
||||
if (albumOrArtistItem.images && albumOrArtistItem.images.length > 0) {
|
||||
imageUrl = albumOrArtistItem.images[0].url;
|
||||
}
|
||||
} else if (type === 'track') {
|
||||
const trackItem = item as TrackResultItem;
|
||||
if (trackItem.album && trackItem.album.images && trackItem.album.images.length > 0) {
|
||||
imageUrl = trackItem.album.images[0].url;
|
||||
}
|
||||
} else if (type === 'playlist') {
|
||||
const playlistItem = item as PlaylistResultItem;
|
||||
if (playlistItem.images && playlistItem.images.length > 0) {
|
||||
imageUrl = playlistItem.images[0].url;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the appropriate details based on type
|
||||
let subtitle = '';
|
||||
let details = '';
|
||||
|
||||
switch (type) {
|
||||
case 'track':
|
||||
{
|
||||
const trackItem = item as TrackResultItem;
|
||||
subtitle = trackItem.artists ? trackItem.artists.map((a: Artist) => a.name).join(', ') : 'Unknown Artist';
|
||||
details = trackItem.album ? `<span>${trackItem.album.name}</span><span class="duration">${msToMinutesSeconds(trackItem.duration_ms)}</span>` : '';
|
||||
}
|
||||
break;
|
||||
case 'album':
|
||||
{
|
||||
const albumItem = item as AlbumResultItem;
|
||||
subtitle = albumItem.artists ? albumItem.artists.map((a: Artist) => a.name).join(', ') : 'Unknown Artist';
|
||||
details = `<span>${albumItem.total_tracks || 0} tracks</span><span>${albumItem.release_date ? new Date(albumItem.release_date).getFullYear() : ''}</span>`;
|
||||
}
|
||||
break;
|
||||
case 'playlist':
|
||||
{
|
||||
const playlistItem = item as PlaylistResultItem;
|
||||
subtitle = `By ${playlistItem.owner ? playlistItem.owner.display_name : 'Unknown'}`;
|
||||
details = `<span>${playlistItem.tracks && playlistItem.tracks.total ? playlistItem.tracks.total : 0} tracks</span>`;
|
||||
}
|
||||
break;
|
||||
case 'artist':
|
||||
{
|
||||
const artistItem = item as ArtistResultItem;
|
||||
subtitle = 'Artist';
|
||||
details = artistItem.genres ? `<span>${artistItem.genres.slice(0, 2).join(', ')}</span>` : '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Build the HTML
|
||||
cardElement.innerHTML = `
|
||||
<div class="album-art-wrapper">
|
||||
<img class="album-art" src="${imageUrl}" alt="${item.name || 'Item'}" onerror="this.src='/static/images/placeholder.jpg'">
|
||||
</div>
|
||||
<div class="track-title">${item.name || 'Unknown'}</div>
|
||||
<div class="track-artist">${subtitle}</div>
|
||||
<div class="track-details">${details}</div>
|
||||
<button class="download-btn btn-primary" data-item-index="${index}">
|
||||
<img src="/static/images/download.svg" alt="Download" />
|
||||
Download
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add click event to navigate to the item's detail page
|
||||
cardElement.addEventListener('click', (e: MouseEvent) => {
|
||||
// Don't trigger if the download button was clicked
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains('download-btn') ||
|
||||
target.parentElement?.classList.contains('download-btn')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.id) {
|
||||
window.location.href = `/${type}/${item.id}`;
|
||||
}
|
||||
});
|
||||
|
||||
return cardElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide the empty state
|
||||
*/
|
||||
function showEmptyState(show: boolean) {
|
||||
if (emptyState) {
|
||||
emptyState.style.display = show ? 'flex' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide the loading indicator
|
||||
*/
|
||||
function showLoading(show: boolean) {
|
||||
if (loadingResults) {
|
||||
loadingResults.classList.toggle('hidden', !show);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,864 +0,0 @@
|
||||
// Import the downloadQueue singleton from your working queue.js implementation.
|
||||
import { downloadQueue } from './queue.js';
|
||||
|
||||
// Define interfaces for API data
|
||||
interface Image {
|
||||
url: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
interface Artist {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls?: { spotify?: string };
|
||||
}
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
name: string;
|
||||
images?: Image[];
|
||||
external_urls?: { spotify?: string };
|
||||
}
|
||||
|
||||
interface Track {
|
||||
id: string;
|
||||
name: string;
|
||||
artists: Artist[];
|
||||
album: Album;
|
||||
duration_ms: number;
|
||||
explicit: boolean;
|
||||
external_urls?: { spotify?: string };
|
||||
is_locally_known?: boolean; // Added for local DB status
|
||||
}
|
||||
|
||||
interface PlaylistItem {
|
||||
track: Track | null;
|
||||
// Add other playlist item properties like added_at, added_by if needed
|
||||
}
|
||||
|
||||
interface Playlist {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
owner: {
|
||||
display_name?: string;
|
||||
id?: string;
|
||||
};
|
||||
images: Image[];
|
||||
tracks: {
|
||||
items: PlaylistItem[];
|
||||
total: number;
|
||||
};
|
||||
followers?: {
|
||||
total: number;
|
||||
};
|
||||
external_urls?: { spotify?: string };
|
||||
}
|
||||
|
||||
interface WatchedPlaylistStatus {
|
||||
is_watched: boolean;
|
||||
playlist_data?: Playlist; // Optional, present if watched
|
||||
}
|
||||
|
||||
// Added: Interface for global watch config
|
||||
interface GlobalWatchConfig {
|
||||
enabled: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface DownloadQueueItem {
|
||||
name: string;
|
||||
artist?: string; // Can be a simple string for the queue
|
||||
album?: { name: string }; // Match QueueItem's album structure
|
||||
owner?: string; // For playlists, owner can be a string
|
||||
// Add any other properties your item might have, compatible with QueueItem
|
||||
}
|
||||
|
||||
// Added: Helper function to fetch global watch config
|
||||
async function getGlobalWatchConfig(): Promise<GlobalWatchConfig> {
|
||||
try {
|
||||
const response = await fetch('/api/config/watch');
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch global watch config, assuming disabled.');
|
||||
return { enabled: false }; // Default to disabled on error
|
||||
}
|
||||
return await response.json() as GlobalWatchConfig;
|
||||
} catch (error) {
|
||||
console.error('Error fetching global watch config:', error);
|
||||
return { enabled: false }; // Default to disabled on error
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Parse playlist ID from URL
|
||||
const pathSegments = window.location.pathname.split('/');
|
||||
const playlistId = pathSegments[pathSegments.indexOf('playlist') + 1];
|
||||
|
||||
if (!playlistId) {
|
||||
showError('No playlist ID provided.');
|
||||
return;
|
||||
}
|
||||
|
||||
const globalWatchConfig = await getGlobalWatchConfig(); // Fetch global config
|
||||
const isGlobalWatchActuallyEnabled = globalWatchConfig.enabled;
|
||||
|
||||
// Fetch playlist info directly
|
||||
fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
return response.json() as Promise<Playlist>;
|
||||
})
|
||||
.then(data => renderPlaylist(data, isGlobalWatchActuallyEnabled))
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showError('Failed to load playlist.');
|
||||
});
|
||||
|
||||
// Fetch initial watch status for the specific playlist
|
||||
if (isGlobalWatchActuallyEnabled) {
|
||||
fetchWatchStatus(playlistId); // This function then calls updateWatchButtons
|
||||
} else {
|
||||
// If global watch is disabled, ensure watch-related buttons are hidden/disabled
|
||||
const watchBtn = document.getElementById('watchPlaylistBtn') as HTMLButtonElement;
|
||||
const syncBtn = document.getElementById('syncPlaylistBtn') as HTMLButtonElement;
|
||||
if (watchBtn) {
|
||||
watchBtn.classList.add('hidden');
|
||||
watchBtn.disabled = true;
|
||||
// Remove any existing event listener to prevent actions
|
||||
watchBtn.onclick = null;
|
||||
}
|
||||
if (syncBtn) {
|
||||
syncBtn.classList.add('hidden');
|
||||
syncBtn.disabled = true;
|
||||
syncBtn.onclick = null;
|
||||
}
|
||||
}
|
||||
|
||||
const queueIcon = document.getElementById('queueIcon');
|
||||
if (queueIcon) {
|
||||
queueIcon.addEventListener('click', () => {
|
||||
downloadQueue.toggleVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
// Attempt to set initial watchlist button visibility from cache
|
||||
const watchlistButton = document.getElementById('watchlistButton') as HTMLAnchorElement | null;
|
||||
if (watchlistButton) {
|
||||
const cachedWatchEnabled = localStorage.getItem('spotizerr_watch_enabled_cached');
|
||||
if (cachedWatchEnabled === 'true') {
|
||||
watchlistButton.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch watch config to determine if watchlist button should be visible
|
||||
async function updateWatchlistButtonVisibility() {
|
||||
if (watchlistButton) {
|
||||
try {
|
||||
const response = await fetch('/api/config/watch');
|
||||
if (response.ok) {
|
||||
const watchConfig = await response.json();
|
||||
localStorage.setItem('spotizerr_watch_enabled_cached', watchConfig.enabled ? 'true' : 'false');
|
||||
if (watchConfig && watchConfig.enabled === false) {
|
||||
watchlistButton.classList.add('hidden');
|
||||
} else {
|
||||
watchlistButton.classList.remove('hidden'); // Ensure it's shown if enabled
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch watch config for playlist page, defaulting to hidden');
|
||||
// Don't update cache on error
|
||||
watchlistButton.classList.add('hidden'); // Hide if config fetch fails
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching watch config for playlist page:', error);
|
||||
// Don't update cache on error
|
||||
watchlistButton.classList.add('hidden'); // Hide on error
|
||||
}
|
||||
}
|
||||
}
|
||||
updateWatchlistButtonVisibility();
|
||||
});
|
||||
|
||||
/**
|
||||
* Renders playlist header and tracks.
|
||||
*/
|
||||
function renderPlaylist(playlist: Playlist, isGlobalWatchEnabled: boolean) {
|
||||
// Hide loading and error messages
|
||||
const loadingEl = document.getElementById('loading');
|
||||
if (loadingEl) loadingEl.classList.add('hidden');
|
||||
const errorEl = document.getElementById('error');
|
||||
if (errorEl) errorEl.classList.add('hidden');
|
||||
|
||||
// Check if explicit filter is enabled
|
||||
const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled();
|
||||
|
||||
// Update header info
|
||||
const playlistNameEl = document.getElementById('playlist-name');
|
||||
if (playlistNameEl) playlistNameEl.textContent = playlist.name || 'Unknown Playlist';
|
||||
const playlistOwnerEl = document.getElementById('playlist-owner');
|
||||
if (playlistOwnerEl) playlistOwnerEl.textContent = `By ${playlist.owner?.display_name || 'Unknown User'}`;
|
||||
const playlistStatsEl = document.getElementById('playlist-stats');
|
||||
if (playlistStatsEl) playlistStatsEl.textContent =
|
||||
`${playlist.followers?.total || '0'} followers • ${playlist.tracks?.total || '0'} songs`;
|
||||
const playlistDescriptionEl = document.getElementById('playlist-description');
|
||||
if (playlistDescriptionEl) playlistDescriptionEl.textContent = playlist.description || '';
|
||||
const image = playlist.images?.[0]?.url || '/static/images/placeholder.jpg';
|
||||
const playlistImageEl = document.getElementById('playlist-image') as HTMLImageElement;
|
||||
if (playlistImageEl) playlistImageEl.src = image;
|
||||
|
||||
// --- Add Home Button ---
|
||||
let homeButton = document.getElementById('homeButton') as HTMLButtonElement;
|
||||
if (!homeButton) {
|
||||
homeButton = document.createElement('button');
|
||||
homeButton.id = 'homeButton';
|
||||
homeButton.className = 'home-btn';
|
||||
// Use an <img> tag to display the SVG icon.
|
||||
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="Home">`;
|
||||
// Insert the home button at the beginning of the header container.
|
||||
const headerContainer = document.getElementById('playlist-header');
|
||||
if (headerContainer) {
|
||||
headerContainer.insertBefore(homeButton, headerContainer.firstChild);
|
||||
}
|
||||
}
|
||||
homeButton.addEventListener('click', () => {
|
||||
// Navigate to the site's base URL.
|
||||
window.location.href = window.location.origin;
|
||||
});
|
||||
|
||||
// Check if any track in the playlist is explicit when filter is enabled
|
||||
let hasExplicitTrack = false;
|
||||
if (isExplicitFilterEnabled && playlist.tracks?.items) {
|
||||
hasExplicitTrack = playlist.tracks.items.some((item: PlaylistItem) => item?.track && item.track.explicit);
|
||||
}
|
||||
|
||||
// --- Add "Download Whole Playlist" Button ---
|
||||
let downloadPlaylistBtn = document.getElementById('downloadPlaylistBtn') as HTMLButtonElement;
|
||||
if (!downloadPlaylistBtn) {
|
||||
downloadPlaylistBtn = document.createElement('button');
|
||||
downloadPlaylistBtn.id = 'downloadPlaylistBtn';
|
||||
downloadPlaylistBtn.textContent = 'Download Whole Playlist';
|
||||
downloadPlaylistBtn.className = 'download-btn download-btn--main';
|
||||
// Insert the button into the header container.
|
||||
const headerContainer = document.getElementById('playlist-header');
|
||||
if (headerContainer) {
|
||||
headerContainer.appendChild(downloadPlaylistBtn);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Add "Download Playlist's Albums" Button ---
|
||||
let downloadAlbumsBtn = document.getElementById('downloadAlbumsBtn') as HTMLButtonElement;
|
||||
if (!downloadAlbumsBtn) {
|
||||
downloadAlbumsBtn = document.createElement('button');
|
||||
downloadAlbumsBtn.id = 'downloadAlbumsBtn';
|
||||
downloadAlbumsBtn.textContent = "Download Playlist's Albums";
|
||||
downloadAlbumsBtn.className = 'download-btn download-btn--main';
|
||||
// Insert the new button into the header container.
|
||||
const headerContainer = document.getElementById('playlist-header');
|
||||
if (headerContainer) {
|
||||
headerContainer.appendChild(downloadAlbumsBtn);
|
||||
}
|
||||
}
|
||||
|
||||
if (isExplicitFilterEnabled && hasExplicitTrack) {
|
||||
// Disable both playlist buttons and display messages explaining why
|
||||
if (downloadPlaylistBtn) {
|
||||
downloadPlaylistBtn.disabled = true;
|
||||
downloadPlaylistBtn.classList.add('download-btn--disabled');
|
||||
downloadPlaylistBtn.innerHTML = `<span title="Cannot download entire playlist because it contains explicit tracks">Playlist Contains Explicit Tracks</span>`;
|
||||
}
|
||||
|
||||
if (downloadAlbumsBtn) {
|
||||
downloadAlbumsBtn.disabled = true;
|
||||
downloadAlbumsBtn.classList.add('download-btn--disabled');
|
||||
downloadAlbumsBtn.innerHTML = `<span title="Cannot download albums from this playlist because it contains explicit tracks">Albums Access Restricted</span>`;
|
||||
}
|
||||
} else {
|
||||
// Normal behavior when no explicit tracks are present
|
||||
if (downloadPlaylistBtn) {
|
||||
downloadPlaylistBtn.addEventListener('click', () => {
|
||||
// Remove individual track download buttons (but leave the whole playlist button).
|
||||
document.querySelectorAll('.download-btn').forEach(btn => {
|
||||
if (btn.id !== 'downloadPlaylistBtn') {
|
||||
btn.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Disable the whole playlist button to prevent repeated clicks.
|
||||
downloadPlaylistBtn.disabled = true;
|
||||
downloadPlaylistBtn.textContent = 'Queueing...';
|
||||
|
||||
// Initiate the playlist download.
|
||||
downloadWholePlaylist(playlist).then(() => {
|
||||
downloadPlaylistBtn.textContent = 'Queued!';
|
||||
}).catch((err: any) => {
|
||||
showError('Failed to queue playlist download: ' + (err?.message || 'Unknown error'));
|
||||
if (downloadPlaylistBtn) downloadPlaylistBtn.disabled = false; // Re-enable on error
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (downloadAlbumsBtn) {
|
||||
downloadAlbumsBtn.addEventListener('click', () => {
|
||||
// Remove individual track download buttons (but leave this album button).
|
||||
document.querySelectorAll('.download-btn').forEach(btn => {
|
||||
if (btn.id !== 'downloadAlbumsBtn') btn.remove();
|
||||
});
|
||||
|
||||
downloadAlbumsBtn.disabled = true;
|
||||
downloadAlbumsBtn.textContent = 'Queueing...';
|
||||
|
||||
downloadPlaylistAlbums(playlist)
|
||||
.then(() => {
|
||||
if (downloadAlbumsBtn) downloadAlbumsBtn.textContent = 'Queued!';
|
||||
})
|
||||
.catch((err: any) => {
|
||||
showError('Failed to queue album downloads: ' + (err?.message || 'Unknown error'));
|
||||
if (downloadAlbumsBtn) downloadAlbumsBtn.disabled = false; // Re-enable on error
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Render tracks list
|
||||
const tracksList = document.getElementById('tracks-list');
|
||||
if (!tracksList) return;
|
||||
|
||||
tracksList.innerHTML = ''; // Clear any existing content
|
||||
|
||||
// Determine if the playlist is being watched to show/hide management buttons
|
||||
const watchPlaylistButton = document.getElementById('watchPlaylistBtn') as HTMLButtonElement;
|
||||
// isIndividuallyWatched checks if the button is visible and has the 'watching' class.
|
||||
// This implies global watch is enabled if the button is even interactable for individual status.
|
||||
const isIndividuallyWatched = watchPlaylistButton &&
|
||||
watchPlaylistButton.classList.contains('watching') &&
|
||||
!watchPlaylistButton.classList.contains('hidden');
|
||||
|
||||
if (playlist.tracks?.items) {
|
||||
playlist.tracks.items.forEach((item: PlaylistItem, index: number) => {
|
||||
if (!item || !item.track) return; // Skip null/undefined tracks
|
||||
|
||||
const track = item.track;
|
||||
|
||||
// Skip explicit tracks if filter is enabled
|
||||
if (isExplicitFilterEnabled && track.explicit) {
|
||||
// Add a placeholder for filtered explicit tracks
|
||||
const trackElement = document.createElement('div');
|
||||
trackElement.className = 'track track-filtered';
|
||||
trackElement.innerHTML = `
|
||||
<div class="track-number">${index + 1}</div>
|
||||
<div class="track-info">
|
||||
<div class="track-name explicit-filtered">Explicit Content Filtered</div>
|
||||
<div class="track-artist">This track is not shown due to explicit content filter settings</div>
|
||||
</div>
|
||||
<div class="track-album">Not available</div>
|
||||
<div class="track-duration">--:--</div>
|
||||
`;
|
||||
tracksList.appendChild(trackElement);
|
||||
return;
|
||||
}
|
||||
|
||||
const trackLink = `/track/${track.id || ''}`;
|
||||
const artistLink = `/artist/${track.artists?.[0]?.id || ''}`;
|
||||
const albumLink = `/album/${track.album?.id || ''}`;
|
||||
|
||||
const trackElement = document.createElement('div');
|
||||
trackElement.className = 'track';
|
||||
let trackHTML = `
|
||||
<div class="track-number">${index + 1}</div>
|
||||
<div class="track-info">
|
||||
<div class="track-name">
|
||||
<a href="${trackLink}" title="View track details">${track.name || 'Unknown Track'}</a>
|
||||
</div>
|
||||
<div class="track-artist">
|
||||
<a href="${artistLink}" title="View artist details">${track.artists?.[0]?.name || 'Unknown Artist'}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-album">
|
||||
<a href="${albumLink}" title="View album details">${track.album?.name || 'Unknown Album'}</a>
|
||||
</div>
|
||||
<div class="track-duration">${msToTime(track.duration_ms || 0)}</div>
|
||||
`;
|
||||
|
||||
const actionsContainer = document.createElement('div');
|
||||
actionsContainer.className = 'track-actions-container';
|
||||
|
||||
if (!(isExplicitFilterEnabled && hasExplicitTrack)) {
|
||||
const downloadBtnHTML = `
|
||||
<button class="download-btn download-btn--circle track-download-btn"
|
||||
data-id="${track.id || ''}"
|
||||
data-type="track"
|
||||
data-name="${track.name || 'Unknown Track'}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
</button>
|
||||
`;
|
||||
actionsContainer.innerHTML += downloadBtnHTML;
|
||||
}
|
||||
|
||||
if (isGlobalWatchEnabled && isIndividuallyWatched) { // Check global and individual watch status
|
||||
// Initial state is set based on track.is_locally_known
|
||||
const isKnown = track.is_locally_known === true; // Ensure boolean check, default to false if undefined
|
||||
const initialStatus = isKnown ? "known" : "missing";
|
||||
const initialIcon = isKnown ? "/static/images/check.svg" : "/static/images/missing.svg";
|
||||
const initialTitle = isKnown ? "Click to mark as missing from DB" : "Click to mark as known in DB";
|
||||
|
||||
const toggleKnownBtnHTML = `
|
||||
<button class="action-btn toggle-known-status-btn"
|
||||
data-id="${track.id || ''}"
|
||||
data-playlist-id="${playlist.id || ''}"
|
||||
data-status="${initialStatus}"
|
||||
title="${initialTitle}">
|
||||
<img src="${initialIcon}" alt="Mark as Missing/Known">
|
||||
</button>
|
||||
`;
|
||||
actionsContainer.innerHTML += toggleKnownBtnHTML;
|
||||
}
|
||||
|
||||
trackElement.innerHTML = trackHTML;
|
||||
trackElement.appendChild(actionsContainer);
|
||||
tracksList.appendChild(trackElement);
|
||||
});
|
||||
}
|
||||
|
||||
// Reveal header and tracks container
|
||||
const playlistHeaderEl = document.getElementById('playlist-header');
|
||||
if (playlistHeaderEl) playlistHeaderEl.classList.remove('hidden');
|
||||
const tracksContainerEl = document.getElementById('tracks-container');
|
||||
if (tracksContainerEl) tracksContainerEl.classList.remove('hidden');
|
||||
|
||||
// Attach download listeners to newly rendered download buttons
|
||||
attachTrackActionListeners(isGlobalWatchEnabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts milliseconds to minutes:seconds.
|
||||
*/
|
||||
function msToTime(duration: number) {
|
||||
if (!duration || isNaN(duration)) return '0:00';
|
||||
|
||||
const minutes = Math.floor(duration / 60000);
|
||||
const seconds = ((duration % 60000) / 1000).toFixed(0);
|
||||
return `${minutes}:${seconds.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays an error message in the UI.
|
||||
*/
|
||||
function showError(message: string) {
|
||||
const errorEl = document.getElementById('error');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message || 'An error occurred';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches event listeners to all individual track action buttons (download, mark known, mark missing).
|
||||
*/
|
||||
function attachTrackActionListeners(isGlobalWatchEnabled: boolean) {
|
||||
document.querySelectorAll('.track-download-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', (e: Event) => {
|
||||
e.stopPropagation();
|
||||
const currentTarget = e.currentTarget as HTMLButtonElement;
|
||||
const itemId = currentTarget.dataset.id || '';
|
||||
const type = currentTarget.dataset.type || 'track';
|
||||
const name = currentTarget.dataset.name || 'Unknown';
|
||||
if (!itemId) {
|
||||
showError('Missing item ID for download on playlist page');
|
||||
return;
|
||||
}
|
||||
currentTarget.remove();
|
||||
startDownload(itemId, type, { name }, '');
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.toggle-known-status-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', async (e: Event) => {
|
||||
e.stopPropagation();
|
||||
const button = e.currentTarget as HTMLButtonElement;
|
||||
const trackId = button.dataset.id || '';
|
||||
const playlistId = button.dataset.playlistId || '';
|
||||
const currentStatus = button.dataset.status;
|
||||
const img = button.querySelector('img');
|
||||
|
||||
if (!trackId || !playlistId || !img) {
|
||||
showError('Missing data for toggling track status');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isGlobalWatchEnabled) { // Added check
|
||||
showNotification("Watch feature is currently disabled globally. Cannot change track status.");
|
||||
return;
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
try {
|
||||
if (currentStatus === 'missing') {
|
||||
await handleMarkTrackAsKnown(playlistId, trackId);
|
||||
button.dataset.status = 'known';
|
||||
img.src = '/static/images/check.svg';
|
||||
button.title = 'Click to mark as missing from DB';
|
||||
} else {
|
||||
await handleMarkTrackAsMissing(playlistId, trackId);
|
||||
button.dataset.status = 'missing';
|
||||
img.src = '/static/images/missing.svg';
|
||||
button.title = 'Click to mark as known in DB';
|
||||
}
|
||||
} catch (error) {
|
||||
// Revert UI on error if needed, error is shown by handlers
|
||||
showError('Failed to update track status. Please try again.');
|
||||
}
|
||||
button.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMarkTrackAsKnown(playlistId: string, trackId: string) {
|
||||
try {
|
||||
const response = await fetch(`/api/playlist/watch/${playlistId}/tracks`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify([trackId]),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
showNotification(result.message || 'Track marked as known.');
|
||||
} catch (error: any) {
|
||||
showError(`Failed to mark track as known: ${error.message}`);
|
||||
throw error; // Re-throw for the caller to handle button state if needed
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkTrackAsMissing(playlistId: string, trackId: string) {
|
||||
try {
|
||||
const response = await fetch(`/api/playlist/watch/${playlistId}/tracks`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify([trackId]),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
showNotification(result.message || 'Track marked as missing.');
|
||||
} catch (error: any) {
|
||||
showError(`Failed to mark track as missing: ${error.message}`);
|
||||
throw error; // Re-throw
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates the whole playlist download by calling the playlist endpoint.
|
||||
*/
|
||||
async function downloadWholePlaylist(playlist: Playlist) {
|
||||
if (!playlist) {
|
||||
throw new Error('Invalid playlist data');
|
||||
}
|
||||
|
||||
const playlistId = playlist.id || '';
|
||||
if (!playlistId) {
|
||||
throw new Error('Missing playlist ID');
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the centralized downloadQueue.download method
|
||||
await downloadQueue.download(playlistId, 'playlist', {
|
||||
name: playlist.name || 'Unknown Playlist',
|
||||
owner: playlist.owner?.display_name // Pass owner as a string
|
||||
// total_tracks can also be passed if QueueItem supports it directly
|
||||
});
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
} catch (error: any) {
|
||||
showError('Playlist download failed: ' + (error?.message || 'Unknown error'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates album downloads for each unique album in the playlist,
|
||||
* adding a 20ms delay between each album download and updating the button
|
||||
* with the progress (queued_albums/total_albums).
|
||||
*/
|
||||
async function downloadPlaylistAlbums(playlist: Playlist) {
|
||||
if (!playlist?.tracks?.items) {
|
||||
showError('No tracks found in this playlist.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a map of unique albums (using album ID as the key).
|
||||
const albumMap = new Map<string, Album>();
|
||||
playlist.tracks.items.forEach((item: PlaylistItem) => {
|
||||
if (!item?.track?.album) return;
|
||||
|
||||
const album = item.track.album;
|
||||
if (album && album.id) {
|
||||
albumMap.set(album.id, album);
|
||||
}
|
||||
});
|
||||
|
||||
const uniqueAlbums = Array.from(albumMap.values());
|
||||
const totalAlbums = uniqueAlbums.length;
|
||||
if (totalAlbums === 0) {
|
||||
showError('No albums found in this playlist.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get a reference to the "Download Playlist's Albums" button.
|
||||
const downloadAlbumsBtn = document.getElementById('downloadAlbumsBtn') as HTMLButtonElement | null;
|
||||
if (downloadAlbumsBtn) {
|
||||
// Initialize the progress display.
|
||||
downloadAlbumsBtn.textContent = `0/${totalAlbums}`;
|
||||
}
|
||||
|
||||
try {
|
||||
// Process each album sequentially.
|
||||
for (let i = 0; i < totalAlbums; i++) {
|
||||
const album = uniqueAlbums[i];
|
||||
if (!album) continue;
|
||||
|
||||
const albumUrl = album.external_urls?.spotify || '';
|
||||
if (!albumUrl) continue;
|
||||
|
||||
// Use the centralized downloadQueue.download method
|
||||
await downloadQueue.download(
|
||||
album.id, // Pass album ID directly
|
||||
'album',
|
||||
{
|
||||
name: album.name || 'Unknown Album',
|
||||
// If artist information is available on album objects from playlist, pass it
|
||||
// artist: album.artists?.[0]?.name
|
||||
}
|
||||
);
|
||||
|
||||
// Update button text with current progress.
|
||||
if (downloadAlbumsBtn) {
|
||||
downloadAlbumsBtn.textContent = `${i + 1}/${totalAlbums}`;
|
||||
}
|
||||
|
||||
// Wait 20 milliseconds before processing the next album.
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
|
||||
// Once all albums have been queued, update the button text.
|
||||
if (downloadAlbumsBtn) {
|
||||
downloadAlbumsBtn.textContent = 'Queued!';
|
||||
}
|
||||
|
||||
// Make the queue visible after queueing all albums
|
||||
downloadQueue.toggleVisibility(true);
|
||||
} catch (error: any) {
|
||||
// Propagate any errors encountered.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the download process using the centralized download method from the queue.
|
||||
*/
|
||||
async function startDownload(itemId: string, type: string, item: DownloadQueueItem, albumType?: string) {
|
||||
if (!itemId || !type) {
|
||||
showError('Missing ID or type for download');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the centralized downloadQueue.download method
|
||||
await downloadQueue.download(itemId, type, item, albumType);
|
||||
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
} catch (error: any) {
|
||||
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper function to extract a display name from the URL.
|
||||
*/
|
||||
function extractName(url: string | null): string {
|
||||
return url || 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the watch status of the current playlist and updates the UI.
|
||||
*/
|
||||
async function fetchWatchStatus(playlistId: string) {
|
||||
if (!playlistId) return;
|
||||
try {
|
||||
const response = await fetch(`/api/playlist/watch/${playlistId}/status`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to fetch watch status');
|
||||
}
|
||||
const data: WatchedPlaylistStatus = await response.json();
|
||||
updateWatchButtons(data.is_watched, playlistId);
|
||||
} catch (error) {
|
||||
console.error('Error fetching watch status:', error);
|
||||
// Don't show a blocking error, but maybe a small notification or log
|
||||
// For now, assume not watched if status fetch fails, or keep buttons in default state
|
||||
updateWatchButtons(false, playlistId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the Watch/Unwatch and Sync buttons based on the playlist's watch status.
|
||||
*/
|
||||
function updateWatchButtons(isWatched: boolean, playlistId: string) {
|
||||
const watchBtn = document.getElementById('watchPlaylistBtn') as HTMLButtonElement;
|
||||
const syncBtn = document.getElementById('syncPlaylistBtn') as HTMLButtonElement;
|
||||
|
||||
if (!watchBtn || !syncBtn) return;
|
||||
|
||||
const watchBtnImg = watchBtn.querySelector('img');
|
||||
|
||||
if (isWatched) {
|
||||
watchBtn.innerHTML = `<img src="/static/images/eye-crossed.svg" alt="Unwatch"> Unwatch Playlist`;
|
||||
watchBtn.classList.add('watching');
|
||||
watchBtn.onclick = () => unwatchPlaylist(playlistId);
|
||||
syncBtn.classList.remove('hidden');
|
||||
syncBtn.onclick = () => syncPlaylist(playlistId);
|
||||
} else {
|
||||
watchBtn.innerHTML = `<img src="/static/images/eye.svg" alt="Watch"> Watch Playlist`;
|
||||
watchBtn.classList.remove('watching');
|
||||
watchBtn.onclick = () => watchPlaylist(playlistId);
|
||||
syncBtn.classList.add('hidden');
|
||||
}
|
||||
watchBtn.disabled = false; // Enable after status is known
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the current playlist to the watchlist.
|
||||
*/
|
||||
async function watchPlaylist(playlistId: string) {
|
||||
const watchBtn = document.getElementById('watchPlaylistBtn') as HTMLButtonElement;
|
||||
if (watchBtn) watchBtn.disabled = true;
|
||||
// This function should only be callable if global watch is enabled.
|
||||
// We can add a check here or rely on the UI not presenting the button.
|
||||
// For safety, let's check global config again before proceeding.
|
||||
const globalConfig = await getGlobalWatchConfig();
|
||||
if (!globalConfig.enabled) {
|
||||
showError("Cannot watch playlist, feature is disabled globally.");
|
||||
if (watchBtn) {
|
||||
watchBtn.disabled = false; // Re-enable if it was somehow clicked
|
||||
updateWatchButtons(false, playlistId); // Reset button to non-watching state
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/playlist/watch/${playlistId}`, { method: 'PUT' });
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to watch playlist');
|
||||
}
|
||||
updateWatchButtons(true, playlistId);
|
||||
// Re-fetch and re-render playlist data
|
||||
const newPlaylistInfoResponse = await fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`);
|
||||
if (!newPlaylistInfoResponse.ok) throw new Error('Failed to re-fetch playlist info after watch.');
|
||||
const newPlaylistData = await newPlaylistInfoResponse.json() as Playlist;
|
||||
renderPlaylist(newPlaylistData, globalConfig.enabled); // Pass current global enabled state
|
||||
|
||||
showNotification(`Playlist added to watchlist. Tracks are being updated.`);
|
||||
} catch (error: any) {
|
||||
showError(`Error watching playlist: ${error.message}`);
|
||||
if (watchBtn) watchBtn.disabled = false; // Re-enable on error before potential UI revert
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the current playlist from the watchlist.
|
||||
*/
|
||||
async function unwatchPlaylist(playlistId: string) {
|
||||
const watchBtn = document.getElementById('watchPlaylistBtn') as HTMLButtonElement;
|
||||
if (watchBtn) watchBtn.disabled = true;
|
||||
// Similarly, check global config
|
||||
const globalConfig = await getGlobalWatchConfig();
|
||||
if (!globalConfig.enabled) {
|
||||
// This case should be rare if UI behaves, but good for robustness
|
||||
showError("Cannot unwatch playlist, feature is disabled globally.");
|
||||
if (watchBtn) {
|
||||
watchBtn.disabled = false;
|
||||
// updateWatchButtons(true, playlistId); // Or keep as is if it was 'watching'
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/playlist/watch/${playlistId}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to unwatch playlist');
|
||||
}
|
||||
updateWatchButtons(false, playlistId);
|
||||
// Re-fetch and re-render playlist data
|
||||
const newPlaylistInfoResponse = await fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`);
|
||||
if (!newPlaylistInfoResponse.ok) throw new Error('Failed to re-fetch playlist info after unwatch.');
|
||||
const newPlaylistData = await newPlaylistInfoResponse.json() as Playlist;
|
||||
renderPlaylist(newPlaylistData, globalConfig.enabled); // Pass current global enabled state
|
||||
|
||||
showNotification('Playlist removed from watchlist. Track statuses updated.');
|
||||
} catch (error: any) {
|
||||
showError(`Error unwatching playlist: ${error.message}`);
|
||||
if (watchBtn) watchBtn.disabled = false; // Re-enable on error before potential UI revert
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a manual sync for the watched playlist.
|
||||
*/
|
||||
async function syncPlaylist(playlistId: string) {
|
||||
const syncBtn = document.getElementById('syncPlaylistBtn') as HTMLButtonElement;
|
||||
let originalButtonContent = ''; // Define outside
|
||||
// Check global config
|
||||
const globalConfig = await getGlobalWatchConfig();
|
||||
if (!globalConfig.enabled) {
|
||||
showError("Cannot sync playlist, feature is disabled globally.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncBtn) {
|
||||
syncBtn.disabled = true;
|
||||
originalButtonContent = syncBtn.innerHTML; // Store full HTML
|
||||
syncBtn.innerHTML = `<img src="/static/images/refresh.svg" alt="Sync"> Syncing...`; // Keep icon
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/playlist/watch/trigger_check/${playlistId}`, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to trigger sync');
|
||||
}
|
||||
showNotification('Playlist sync triggered successfully.');
|
||||
} catch (error: any) {
|
||||
showError(`Error triggering sync: ${error.message}`);
|
||||
} finally {
|
||||
if (syncBtn) {
|
||||
syncBtn.disabled = false;
|
||||
syncBtn.innerHTML = originalButtonContent; // Restore full original HTML
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a temporary notification message.
|
||||
*/
|
||||
function showNotification(message: string) {
|
||||
// Basic notification - consider a more robust solution for production
|
||||
const notificationEl = document.createElement('div');
|
||||
notificationEl.className = 'notification';
|
||||
notificationEl.textContent = message;
|
||||
document.body.appendChild(notificationEl);
|
||||
setTimeout(() => {
|
||||
notificationEl.remove();
|
||||
}, 3000);
|
||||
}
|
||||
2895
src/js/queue.ts
258
src/js/track.ts
@@ -1,258 +0,0 @@
|
||||
// Import the downloadQueue singleton from your working queue.js implementation.
|
||||
import { downloadQueue } from './queue.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Parse track ID from URL. Expecting URL in the form /track/{id}
|
||||
const pathSegments = window.location.pathname.split('/');
|
||||
const trackId = pathSegments[pathSegments.indexOf('track') + 1];
|
||||
|
||||
if (!trackId) {
|
||||
showError('No track ID provided.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch track info directly
|
||||
fetch(`/api/track/info?id=${encodeURIComponent(trackId)}`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => renderTrack(data))
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showError('Error loading track');
|
||||
});
|
||||
|
||||
// Attach event listener to the queue icon to toggle the download queue
|
||||
const queueIcon = document.getElementById('queueIcon');
|
||||
if (queueIcon) {
|
||||
queueIcon.addEventListener('click', () => {
|
||||
downloadQueue.toggleVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
// Attempt to set initial watchlist button visibility from cache
|
||||
const watchlistButton = document.getElementById('watchlistButton') as HTMLAnchorElement | null;
|
||||
if (watchlistButton) {
|
||||
const cachedWatchEnabled = localStorage.getItem('spotizerr_watch_enabled_cached');
|
||||
if (cachedWatchEnabled === 'true') {
|
||||
watchlistButton.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch watch config to determine if watchlist button should be visible
|
||||
async function updateWatchlistButtonVisibility() {
|
||||
if (watchlistButton) {
|
||||
try {
|
||||
const response = await fetch('/api/config/watch');
|
||||
if (response.ok) {
|
||||
const watchConfig = await response.json();
|
||||
localStorage.setItem('spotizerr_watch_enabled_cached', watchConfig.enabled ? 'true' : 'false');
|
||||
if (watchConfig && watchConfig.enabled === false) {
|
||||
watchlistButton.classList.add('hidden');
|
||||
} else {
|
||||
watchlistButton.classList.remove('hidden'); // Ensure it's shown if enabled
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch watch config for track page, defaulting to hidden');
|
||||
// Don't update cache on error
|
||||
watchlistButton.classList.add('hidden'); // Hide if config fetch fails
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching watch config for track page:', error);
|
||||
// Don't update cache on error
|
||||
watchlistButton.classList.add('hidden'); // Hide on error
|
||||
}
|
||||
}
|
||||
}
|
||||
updateWatchlistButtonVisibility();
|
||||
});
|
||||
|
||||
/**
|
||||
* Renders the track header information.
|
||||
*/
|
||||
function renderTrack(track: any) {
|
||||
// Hide the loading and error messages.
|
||||
const loadingEl = document.getElementById('loading');
|
||||
if (loadingEl) loadingEl.classList.add('hidden');
|
||||
const errorEl = document.getElementById('error');
|
||||
if (errorEl) errorEl.classList.add('hidden');
|
||||
|
||||
// Check if track is explicit and if explicit filter is enabled
|
||||
if (track.explicit && downloadQueue.isExplicitFilterEnabled()) {
|
||||
// Show placeholder for explicit content
|
||||
const loadingElExplicit = document.getElementById('loading');
|
||||
if (loadingElExplicit) loadingElExplicit.classList.add('hidden');
|
||||
|
||||
const placeholderContent = `
|
||||
<div class="explicit-filter-placeholder">
|
||||
<h2>Explicit Content Filtered</h2>
|
||||
<p>This track contains explicit content and has been filtered based on your settings.</p>
|
||||
<p>The explicit content filter is controlled by environment variables.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const contentContainer = document.getElementById('track-header');
|
||||
if (contentContainer) {
|
||||
contentContainer.innerHTML = placeholderContent;
|
||||
contentContainer.classList.remove('hidden');
|
||||
}
|
||||
|
||||
return; // Stop rendering the actual track content
|
||||
}
|
||||
|
||||
// Update track information fields.
|
||||
const trackNameEl = document.getElementById('track-name');
|
||||
if (trackNameEl) {
|
||||
trackNameEl.innerHTML =
|
||||
`<a href="/track/${track.id || ''}" title="View track details">${track.name || 'Unknown Track'}</a>`;
|
||||
}
|
||||
|
||||
const trackArtistEl = document.getElementById('track-artist');
|
||||
if (trackArtistEl) {
|
||||
trackArtistEl.innerHTML =
|
||||
`By ${track.artists?.map((a: any) =>
|
||||
`<a href="/artist/${a?.id || ''}" title="View artist details">${a?.name || 'Unknown Artist'}</a>`
|
||||
).join(', ') || 'Unknown Artist'}`;
|
||||
}
|
||||
|
||||
const trackAlbumEl = document.getElementById('track-album');
|
||||
if (trackAlbumEl) {
|
||||
trackAlbumEl.innerHTML =
|
||||
`Album: <a href="/album/${track.album?.id || ''}" title="View album details">${track.album?.name || 'Unknown Album'}</a> (${track.album?.album_type || 'album'})`;
|
||||
}
|
||||
|
||||
const trackDurationEl = document.getElementById('track-duration');
|
||||
if (trackDurationEl) {
|
||||
trackDurationEl.textContent =
|
||||
`Duration: ${msToTime(track.duration_ms || 0)}`;
|
||||
}
|
||||
|
||||
const trackExplicitEl = document.getElementById('track-explicit');
|
||||
if (trackExplicitEl) {
|
||||
trackExplicitEl.textContent =
|
||||
track.explicit ? 'Explicit' : 'Clean';
|
||||
}
|
||||
|
||||
const imageUrl = (track.album?.images && track.album.images[0])
|
||||
? track.album.images[0].url
|
||||
: '/static/images/placeholder.jpg';
|
||||
const trackAlbumImageEl = document.getElementById('track-album-image') as HTMLImageElement;
|
||||
if (trackAlbumImageEl) trackAlbumImageEl.src = imageUrl;
|
||||
|
||||
// --- Insert Home Button (if not already present) ---
|
||||
let homeButton = document.getElementById('homeButton') as HTMLButtonElement;
|
||||
if (!homeButton) {
|
||||
homeButton = document.createElement('button');
|
||||
homeButton.id = 'homeButton';
|
||||
homeButton.className = 'home-btn';
|
||||
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="Home" />`;
|
||||
// Prepend the home button into the header.
|
||||
const trackHeader = document.getElementById('track-header');
|
||||
if (trackHeader) {
|
||||
trackHeader.insertBefore(homeButton, trackHeader.firstChild);
|
||||
}
|
||||
}
|
||||
homeButton.addEventListener('click', () => {
|
||||
window.location.href = window.location.origin;
|
||||
});
|
||||
|
||||
// --- Move the Download Button from #actions into #track-header ---
|
||||
let downloadBtn = document.getElementById('downloadTrackBtn') as HTMLButtonElement;
|
||||
if (downloadBtn) {
|
||||
// Remove the parent container (#actions) if needed.
|
||||
const actionsContainer = document.getElementById('actions');
|
||||
if (actionsContainer) {
|
||||
actionsContainer.parentNode?.removeChild(actionsContainer);
|
||||
}
|
||||
// Set the inner HTML to use the download.svg icon.
|
||||
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
|
||||
// Append the download button to the track header so it appears at the right.
|
||||
const trackHeader = document.getElementById('track-header');
|
||||
if (trackHeader) {
|
||||
trackHeader.appendChild(downloadBtn);
|
||||
}
|
||||
}
|
||||
|
||||
if (downloadBtn) {
|
||||
downloadBtn.addEventListener('click', () => {
|
||||
downloadBtn.disabled = true;
|
||||
downloadBtn.innerHTML = `<span>Queueing...</span>`;
|
||||
|
||||
const trackUrl = track.external_urls?.spotify || '';
|
||||
if (!trackUrl) {
|
||||
showError('Missing track URL');
|
||||
downloadBtn.disabled = false;
|
||||
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
|
||||
return;
|
||||
}
|
||||
const trackIdToDownload = track.id || '';
|
||||
if (!trackIdToDownload) {
|
||||
showError('Missing track ID for download');
|
||||
downloadBtn.disabled = false;
|
||||
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the centralized downloadQueue.download method
|
||||
downloadQueue.download(trackIdToDownload, 'track', { name: track.name || 'Unknown Track', artist: track.artists?.[0]?.name })
|
||||
.then(() => {
|
||||
downloadBtn.innerHTML = `<span>Queued!</span>`;
|
||||
// Make the queue visible to show the download
|
||||
downloadQueue.toggleVisibility(true);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
showError('Failed to queue track download: ' + (err?.message || 'Unknown error'));
|
||||
downloadBtn.disabled = false;
|
||||
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Reveal the header now that track info is loaded.
|
||||
const trackHeaderEl = document.getElementById('track-header');
|
||||
if (trackHeaderEl) trackHeaderEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts milliseconds to minutes:seconds.
|
||||
*/
|
||||
function msToTime(duration: number) {
|
||||
if (!duration || isNaN(duration)) return '0:00';
|
||||
|
||||
const minutes = Math.floor(duration / 60000);
|
||||
const seconds = Math.floor((duration % 60000) / 1000);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays an error message in the UI.
|
||||
*/
|
||||
function showError(message: string) {
|
||||
const errorEl = document.getElementById('error');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message || 'An error occurred';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the download process by calling the centralized downloadQueue method
|
||||
*/
|
||||
async function startDownload(itemId: string, type: string, item: any) {
|
||||
if (!itemId || !type) {
|
||||
showError('Missing ID or type for download');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the centralized downloadQueue.download method
|
||||
await downloadQueue.download(itemId, type, item);
|
||||
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
} catch (error: any) {
|
||||
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
688
src/js/watch.ts
@@ -1,688 +0,0 @@
|
||||
import { downloadQueue } from './queue.js'; // Assuming queue.js is in the same directory
|
||||
|
||||
// Interfaces for API data
|
||||
interface Image {
|
||||
url: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
// --- Items from the initial /watch/list API calls ---
|
||||
interface ArtistFromWatchList {
|
||||
spotify_id: string; // Changed from id to spotify_id
|
||||
name: string;
|
||||
images?: Image[];
|
||||
total_albums?: number; // Already provided by /api/artist/watch/list
|
||||
}
|
||||
|
||||
// New interface for artists after initial processing (spotify_id mapped to id)
|
||||
interface ProcessedArtistFromWatchList extends ArtistFromWatchList {
|
||||
id: string; // This is the mapped spotify_id
|
||||
}
|
||||
|
||||
interface WatchedPlaylistOwner { // Kept as is, used by PlaylistFromWatchList
|
||||
display_name?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
interface PlaylistFromWatchList {
|
||||
spotify_id: string; // Changed from id to spotify_id
|
||||
name: string;
|
||||
owner?: WatchedPlaylistOwner;
|
||||
images?: Image[]; // Ensure images can be part of this initial fetch
|
||||
total_tracks?: number;
|
||||
}
|
||||
|
||||
// New interface for playlists after initial processing (spotify_id mapped to id)
|
||||
interface ProcessedPlaylistFromWatchList extends PlaylistFromWatchList {
|
||||
id: string; // This is the mapped spotify_id
|
||||
}
|
||||
// --- End of /watch/list items ---
|
||||
|
||||
|
||||
// --- Responses from /api/{artist|playlist}/info endpoints ---
|
||||
interface AlbumWithImages { // For items in ArtistInfoResponse.items
|
||||
images?: Image[];
|
||||
// Other album properties like name, id etc., are not strictly needed for this specific change
|
||||
}
|
||||
|
||||
interface ArtistInfoResponse {
|
||||
artist_id: string; // Matches key from artist.py
|
||||
artist_name: string; // Matches key from artist.py
|
||||
artist_image_url?: string; // Matches key from artist.py
|
||||
total: number; // This is total_albums, matches key from artist.py
|
||||
artist_external_url?: string; // Matches key from artist.py
|
||||
items?: AlbumWithImages[]; // Add album items to get the first album's image
|
||||
}
|
||||
|
||||
// PlaylistInfoResponse is effectively the Playlist interface from playlist.ts
|
||||
// For clarity, defining it here based on what's needed for the card.
|
||||
interface PlaylistInfoResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
owner: { display_name?: string; id?: string; }; // Matches Playlist.owner
|
||||
images: Image[]; // Matches Playlist.images
|
||||
tracks: { total: number; /* items: PlaylistItem[] - not needed for card */ }; // Matches Playlist.tracks
|
||||
followers?: { total: number; }; // Matches Playlist.followers
|
||||
external_urls?: { spotify?: string }; // Matches Playlist.external_urls
|
||||
}
|
||||
// --- End of /info endpoint responses ---
|
||||
|
||||
|
||||
// --- Final combined data structure for rendering cards ---
|
||||
interface FinalArtistCardItem {
|
||||
itemType: 'artist';
|
||||
id: string; // Spotify ID
|
||||
name: string; // Best available name (from /info or fallback)
|
||||
imageUrl?: string; // Best available image URL (from /info or fallback)
|
||||
total_albums: number;// From /info or fallback
|
||||
external_urls?: { spotify?: string }; // From /info
|
||||
}
|
||||
|
||||
interface FinalPlaylistCardItem {
|
||||
itemType: 'playlist';
|
||||
id: string; // Spotify ID
|
||||
name: string; // Best available name (from /info or fallback)
|
||||
imageUrl?: string; // Best available image URL (from /info or fallback)
|
||||
owner_name?: string; // From /info or fallback
|
||||
total_tracks: number;// From /info or fallback
|
||||
followers_count?: number; // From /info
|
||||
description?: string | null; // From /info, for potential use (e.g., tooltip)
|
||||
external_urls?: { spotify?: string }; // From /info
|
||||
}
|
||||
|
||||
type FinalCardItem = FinalArtistCardItem | FinalPlaylistCardItem;
|
||||
// --- End of final card data structure ---
|
||||
|
||||
// The type for items initially fetched from /watch/list, before detailed processing
|
||||
// Updated to use ProcessedArtistFromWatchList for artists and ProcessedPlaylistFromWatchList for playlists
|
||||
type InitialWatchedItem =
|
||||
(ProcessedArtistFromWatchList & { itemType: 'artist' }) |
|
||||
(ProcessedPlaylistFromWatchList & { itemType: 'playlist' });
|
||||
|
||||
// Interface for a settled promise (fulfilled)
|
||||
interface CustomPromiseFulfilledResult<T> {
|
||||
status: 'fulfilled';
|
||||
value: T;
|
||||
}
|
||||
|
||||
// Interface for a settled promise (rejected)
|
||||
interface CustomPromiseRejectedResult {
|
||||
status: 'rejected';
|
||||
reason: any;
|
||||
}
|
||||
|
||||
type CustomSettledPromiseResult<T> = CustomPromiseFulfilledResult<T> | CustomPromiseRejectedResult;
|
||||
|
||||
// Original WatchedItem type, which will be replaced by FinalCardItem for rendering
|
||||
interface WatchedArtistOriginal {
|
||||
id: string;
|
||||
name: string;
|
||||
images?: Image[];
|
||||
total_albums?: number;
|
||||
}
|
||||
|
||||
interface WatchedPlaylistOriginal {
|
||||
id: string;
|
||||
name: string;
|
||||
owner?: WatchedPlaylistOwner;
|
||||
images?: Image[];
|
||||
total_tracks?: number;
|
||||
}
|
||||
|
||||
type WatchedItem = (WatchedArtistOriginal & { itemType: 'artist' }) | (WatchedPlaylistOriginal & { itemType: 'playlist' });
|
||||
|
||||
// Added: Interface for global watch config
|
||||
interface GlobalWatchConfig {
|
||||
enabled: boolean;
|
||||
[key: string]: any; // Allow other properties
|
||||
}
|
||||
|
||||
// Added: Helper function to fetch global watch config
|
||||
async function getGlobalWatchConfig(): Promise<GlobalWatchConfig> {
|
||||
try {
|
||||
const response = await fetch('/api/config/watch');
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch global watch config, assuming disabled.');
|
||||
return { enabled: false }; // Default to disabled on error
|
||||
}
|
||||
return await response.json() as GlobalWatchConfig;
|
||||
} catch (error) {
|
||||
console.error('Error fetching global watch config:', error);
|
||||
return { enabled: false }; // Default to disabled on error
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
const watchedItemsContainer = document.getElementById('watchedItemsContainer');
|
||||
const loadingIndicator = document.getElementById('loadingWatchedItems');
|
||||
const emptyStateIndicator = document.getElementById('emptyWatchedItems');
|
||||
const queueIcon = document.getElementById('queueIcon');
|
||||
const checkAllWatchedBtn = document.getElementById('checkAllWatchedBtn') as HTMLButtonElement | null;
|
||||
|
||||
// Fetch global watch config first
|
||||
const globalWatchConfig = await getGlobalWatchConfig();
|
||||
|
||||
if (queueIcon) {
|
||||
queueIcon.addEventListener('click', () => {
|
||||
downloadQueue.toggleVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
if (checkAllWatchedBtn) {
|
||||
checkAllWatchedBtn.addEventListener('click', async () => {
|
||||
checkAllWatchedBtn.disabled = true;
|
||||
const originalText = checkAllWatchedBtn.innerHTML;
|
||||
checkAllWatchedBtn.innerHTML = '<img src="/static/images/refresh-cw.svg" alt="Refreshing..."> Checking...';
|
||||
|
||||
try {
|
||||
const artistCheckPromise = fetch('/api/artist/watch/trigger_check', { method: 'POST' });
|
||||
const playlistCheckPromise = fetch('/api/playlist/watch/trigger_check', { method: 'POST' });
|
||||
|
||||
// Use Promise.allSettled-like behavior to handle both responses
|
||||
const results = await Promise.all([
|
||||
artistCheckPromise.then(async res => ({
|
||||
ok: res.ok,
|
||||
data: await res.json().catch(() => ({ error: 'Invalid JSON response' })),
|
||||
type: 'artist'
|
||||
})).catch(e => ({ ok: false, data: { error: e.message || 'Request failed' }, type: 'artist' })),
|
||||
playlistCheckPromise.then(async res => ({
|
||||
ok: res.ok,
|
||||
data: await res.json().catch(() => ({ error: 'Invalid JSON response' })),
|
||||
type: 'playlist'
|
||||
})).catch(e => ({ ok: false, data: { error: e.message || 'Request failed' }, type: 'playlist' }))
|
||||
]);
|
||||
|
||||
const artistResult = results.find(r => r.type === 'artist');
|
||||
const playlistResult = results.find(r => r.type === 'playlist');
|
||||
|
||||
let successMessages: string[] = [];
|
||||
let errorMessages: string[] = [];
|
||||
|
||||
if (artistResult) {
|
||||
if (artistResult.ok) {
|
||||
successMessages.push(artistResult.data.message || 'Artist check triggered.');
|
||||
} else {
|
||||
errorMessages.push(`Artist check failed: ${artistResult.data.error || 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (playlistResult) {
|
||||
if (playlistResult.ok) {
|
||||
successMessages.push(playlistResult.data.message || 'Playlist check triggered.');
|
||||
} else {
|
||||
errorMessages.push(`Playlist check failed: ${playlistResult.data.error || 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMessages.length > 0) {
|
||||
showNotification(errorMessages.join(' '), true);
|
||||
if (successMessages.length > 0) { // If some succeeded and some failed
|
||||
// Delay the success message slightly so it doesn't overlap or get missed
|
||||
setTimeout(() => showNotification(successMessages.join(' ')), 1000);
|
||||
}
|
||||
} else if (successMessages.length > 0) {
|
||||
showNotification(successMessages.join(' '));
|
||||
} else {
|
||||
showNotification('Could not determine check status for artists or playlists.', true);
|
||||
}
|
||||
|
||||
} catch (error: any) { // Catch for unexpected issues with Promise.all or setup
|
||||
console.error('Error in checkAllWatchedBtn handler:', error);
|
||||
showNotification(`An unexpected error occurred: ${error.message}`, true);
|
||||
} finally {
|
||||
checkAllWatchedBtn.disabled = false;
|
||||
checkAllWatchedBtn.innerHTML = originalText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initial load is now conditional
|
||||
if (globalWatchConfig.enabled) {
|
||||
if (checkAllWatchedBtn) checkAllWatchedBtn.classList.remove('hidden');
|
||||
loadWatchedItems();
|
||||
} else {
|
||||
// Watch feature is disabled globally
|
||||
showLoading(false);
|
||||
showEmptyState(false);
|
||||
if (checkAllWatchedBtn) checkAllWatchedBtn.classList.add('hidden'); // Hide the button
|
||||
|
||||
if (watchedItemsContainer) {
|
||||
watchedItemsContainer.innerHTML = `
|
||||
<div class="empty-state-container">
|
||||
<img src="/static/images/eye-crossed.svg" alt="Watch Disabled" class="empty-state-icon">
|
||||
<p class="empty-state-message">The Watchlist feature is currently disabled in the application settings.</p>
|
||||
<p class="empty-state-submessage">Please enable it in <a href="/settings" class="settings-link">Settings</a> to use this page.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
// Ensure the main loading indicator is also hidden if it was shown by default
|
||||
if (loadingIndicator) loadingIndicator.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
const MAX_NOTIFICATIONS = 3;
|
||||
|
||||
async function loadWatchedItems() {
|
||||
const watchedItemsContainer = document.getElementById('watchedItemsContainer');
|
||||
const loadingIndicator = document.getElementById('loadingWatchedItems');
|
||||
const emptyStateIndicator = document.getElementById('emptyWatchedItems');
|
||||
|
||||
showLoading(true);
|
||||
showEmptyState(false);
|
||||
if (watchedItemsContainer) watchedItemsContainer.innerHTML = '';
|
||||
|
||||
try {
|
||||
const [artistsResponse, playlistsResponse] = await Promise.all([
|
||||
fetch('/api/artist/watch/list'),
|
||||
fetch('/api/playlist/watch/list')
|
||||
]);
|
||||
|
||||
if (!artistsResponse.ok || !playlistsResponse.ok) {
|
||||
throw new Error('Failed to load initial watched items list');
|
||||
}
|
||||
|
||||
const artists: ArtistFromWatchList[] = await artistsResponse.json();
|
||||
const playlists: PlaylistFromWatchList[] = await playlistsResponse.json();
|
||||
|
||||
const initialItems: InitialWatchedItem[] = [
|
||||
...artists.map(artist => ({
|
||||
...artist,
|
||||
id: artist.spotify_id, // Map spotify_id to id for artists
|
||||
itemType: 'artist' as const
|
||||
})),
|
||||
...playlists.map(playlist => ({
|
||||
...playlist,
|
||||
id: playlist.spotify_id, // Map spotify_id to id for playlists
|
||||
itemType: 'playlist' as const
|
||||
}))
|
||||
];
|
||||
|
||||
if (initialItems.length === 0) {
|
||||
showLoading(false);
|
||||
showEmptyState(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch detailed info for each item
|
||||
const detailedItemPromises = initialItems.map(async (initialItem) => {
|
||||
try {
|
||||
if (initialItem.itemType === 'artist') {
|
||||
const infoResponse = await fetch(`/api/artist/info?id=${initialItem.id}`);
|
||||
if (!infoResponse.ok) {
|
||||
console.warn(`Failed to fetch artist info for ${initialItem.name} (ID: ${initialItem.id}): ${infoResponse.status}`);
|
||||
// Fallback to initial data if info fetch fails
|
||||
return {
|
||||
itemType: 'artist',
|
||||
id: initialItem.id,
|
||||
name: initialItem.name,
|
||||
imageUrl: (initialItem as ArtistFromWatchList).images?.[0]?.url, // Cast to access images
|
||||
total_albums: (initialItem as ArtistFromWatchList).total_albums || 0, // Cast to access total_albums
|
||||
} as FinalArtistCardItem;
|
||||
}
|
||||
const info: ArtistInfoResponse = await infoResponse.json();
|
||||
return {
|
||||
itemType: 'artist',
|
||||
id: initialItem.id, // Use the ID from the watch list, as /info might have 'artist_id'
|
||||
name: info.artist_name || initialItem.name, // Prefer info, fallback to initial
|
||||
imageUrl: info.items?.[0]?.images?.[0]?.url || info.artist_image_url || (initialItem as ProcessedArtistFromWatchList).images?.[0]?.url, // Prioritize first album image from items
|
||||
total_albums: info.total, // 'total' from ArtistInfoResponse is total_albums
|
||||
external_urls: { spotify: info.artist_external_url }
|
||||
} as FinalArtistCardItem;
|
||||
} else { // Playlist
|
||||
const infoResponse = await fetch(`/api/playlist/info?id=${initialItem.id}`);
|
||||
if (!infoResponse.ok) {
|
||||
console.warn(`Failed to fetch playlist info for ${initialItem.name} (ID: ${initialItem.id}): ${infoResponse.status}`);
|
||||
// Fallback to initial data if info fetch fails
|
||||
return {
|
||||
itemType: 'playlist',
|
||||
id: initialItem.id,
|
||||
name: initialItem.name,
|
||||
imageUrl: (initialItem as ProcessedPlaylistFromWatchList).images?.[0]?.url, // Cast to access images
|
||||
owner_name: (initialItem as ProcessedPlaylistFromWatchList).owner?.display_name, // Cast to access owner
|
||||
total_tracks: (initialItem as ProcessedPlaylistFromWatchList).total_tracks || 0, // Cast to access total_tracks
|
||||
} as FinalPlaylistCardItem;
|
||||
}
|
||||
const info: PlaylistInfoResponse = await infoResponse.json();
|
||||
return {
|
||||
itemType: 'playlist',
|
||||
id: initialItem.id, // Use ID from watch list
|
||||
name: info.name || initialItem.name, // Prefer info, fallback to initial
|
||||
imageUrl: info.images?.[0]?.url || (initialItem as ProcessedPlaylistFromWatchList).images?.[0]?.url, // Prefer info, fallback to initial (ProcessedPlaylistFromWatchList)
|
||||
owner_name: info.owner?.display_name || (initialItem as ProcessedPlaylistFromWatchList).owner?.display_name, // Prefer info, fallback to initial (ProcessedPlaylistFromWatchList)
|
||||
total_tracks: info.tracks.total, // 'total' from PlaylistInfoResponse.tracks
|
||||
followers_count: info.followers?.total,
|
||||
description: info.description,
|
||||
external_urls: info.external_urls
|
||||
} as FinalPlaylistCardItem;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(`Error processing item ${initialItem.name} (ID: ${initialItem.id}):`, e);
|
||||
// Return a fallback structure if processing fails catastrophically
|
||||
return {
|
||||
itemType: initialItem.itemType,
|
||||
id: initialItem.id,
|
||||
name: initialItem.name + " (Error loading details)",
|
||||
imageUrl: initialItem.images?.[0]?.url,
|
||||
// Add minimal common fields for artists and playlists for fallback
|
||||
...(initialItem.itemType === 'artist' ? { total_albums: (initialItem as ProcessedArtistFromWatchList).total_albums || 0 } : {}),
|
||||
...(initialItem.itemType === 'playlist' ? { total_tracks: (initialItem as ProcessedPlaylistFromWatchList).total_tracks || 0 } : {}),
|
||||
} as FinalCardItem; // Cast to avoid TS errors, knowing one of the spreads will match
|
||||
}
|
||||
});
|
||||
|
||||
// Simulating Promise.allSettled behavior for compatibility
|
||||
const settledResults: CustomSettledPromiseResult<FinalCardItem>[] = await Promise.all(
|
||||
detailedItemPromises.map(p =>
|
||||
p.then(value => ({ status: 'fulfilled', value } as CustomPromiseFulfilledResult<FinalCardItem>))
|
||||
.catch(reason => ({ status: 'rejected', reason } as CustomPromiseRejectedResult))
|
||||
)
|
||||
);
|
||||
|
||||
const finalItems: FinalCardItem[] = settledResults
|
||||
.filter((result): result is CustomPromiseFulfilledResult<FinalCardItem> => result.status === 'fulfilled')
|
||||
.map(result => result.value)
|
||||
.filter(item => item !== null) as FinalCardItem[]; // Ensure no nulls from catastrophic failures
|
||||
|
||||
showLoading(false);
|
||||
|
||||
if (finalItems.length === 0) {
|
||||
showEmptyState(true);
|
||||
// Potentially show a different message if initialItems existed but all failed to load details
|
||||
if (initialItems.length > 0 && watchedItemsContainer) {
|
||||
watchedItemsContainer.innerHTML = `<div class="error"><p>Could not load details for any watched items. Please check the console for errors.</p></div>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (watchedItemsContainer) {
|
||||
// Clear previous content
|
||||
watchedItemsContainer.innerHTML = '';
|
||||
|
||||
if (finalItems.length > 8) {
|
||||
const playlistItems = finalItems.filter(item => item.itemType === 'playlist') as FinalPlaylistCardItem[];
|
||||
const artistItems = finalItems.filter(item => item.itemType === 'artist') as FinalArtistCardItem[];
|
||||
|
||||
// Create and append Playlist section
|
||||
if (playlistItems.length > 0) {
|
||||
const playlistSection = document.createElement('div');
|
||||
playlistSection.className = 'watched-items-group';
|
||||
const playlistHeader = document.createElement('h2');
|
||||
playlistHeader.className = 'watched-group-header';
|
||||
playlistHeader.textContent = 'Watched Playlists';
|
||||
playlistSection.appendChild(playlistHeader);
|
||||
const playlistGrid = document.createElement('div');
|
||||
playlistGrid.className = 'results-grid'; // Use existing grid style
|
||||
playlistItems.forEach(item => {
|
||||
const cardElement = createWatchedItemCard(item);
|
||||
playlistGrid.appendChild(cardElement);
|
||||
});
|
||||
playlistSection.appendChild(playlistGrid);
|
||||
watchedItemsContainer.appendChild(playlistSection);
|
||||
} else {
|
||||
const noPlaylistsMessage = document.createElement('p');
|
||||
noPlaylistsMessage.textContent = 'No watched playlists.';
|
||||
noPlaylistsMessage.className = 'empty-group-message';
|
||||
// Optionally add a header for consistency even if empty
|
||||
const playlistHeader = document.createElement('h2');
|
||||
playlistHeader.className = 'watched-group-header';
|
||||
playlistHeader.textContent = 'Watched Playlists';
|
||||
watchedItemsContainer.appendChild(playlistHeader);
|
||||
watchedItemsContainer.appendChild(noPlaylistsMessage);
|
||||
}
|
||||
|
||||
// Create and append Artist section
|
||||
if (artistItems.length > 0) {
|
||||
const artistSection = document.createElement('div');
|
||||
artistSection.className = 'watched-items-group';
|
||||
const artistHeader = document.createElement('h2');
|
||||
artistHeader.className = 'watched-group-header';
|
||||
artistHeader.textContent = 'Watched Artists';
|
||||
artistSection.appendChild(artistHeader);
|
||||
const artistGrid = document.createElement('div');
|
||||
artistGrid.className = 'results-grid'; // Use existing grid style
|
||||
artistItems.forEach(item => {
|
||||
const cardElement = createWatchedItemCard(item);
|
||||
artistGrid.appendChild(cardElement);
|
||||
});
|
||||
artistSection.appendChild(artistGrid);
|
||||
watchedItemsContainer.appendChild(artistSection);
|
||||
} else {
|
||||
const noArtistsMessage = document.createElement('p');
|
||||
noArtistsMessage.textContent = 'No watched artists.';
|
||||
noArtistsMessage.className = 'empty-group-message';
|
||||
// Optionally add a header for consistency even if empty
|
||||
const artistHeader = document.createElement('h2');
|
||||
artistHeader.className = 'watched-group-header';
|
||||
artistHeader.textContent = 'Watched Artists';
|
||||
watchedItemsContainer.appendChild(artistHeader);
|
||||
watchedItemsContainer.appendChild(noArtistsMessage);
|
||||
}
|
||||
|
||||
} else { // 8 or fewer items, render them directly
|
||||
finalItems.forEach(item => {
|
||||
const cardElement = createWatchedItemCard(item);
|
||||
watchedItemsContainer.appendChild(cardElement);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Error loading watched items:', error);
|
||||
showLoading(false);
|
||||
if (watchedItemsContainer) {
|
||||
watchedItemsContainer.innerHTML = `<div class="error"><p>Error loading watched items: ${error.message}</p></div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createWatchedItemCard(item: FinalCardItem): HTMLDivElement {
|
||||
const cardElement = document.createElement('div');
|
||||
cardElement.className = 'watched-item-card';
|
||||
cardElement.dataset.itemId = item.id;
|
||||
cardElement.dataset.itemType = item.itemType;
|
||||
|
||||
// Check Now button HTML is no longer generated separately here for absolute positioning
|
||||
|
||||
let imageUrl = '/static/images/placeholder.jpg';
|
||||
if (item.imageUrl) {
|
||||
imageUrl = item.imageUrl;
|
||||
}
|
||||
|
||||
let detailsHtml = '';
|
||||
let typeBadgeClass = '';
|
||||
let typeName = '';
|
||||
|
||||
if (item.itemType === 'artist') {
|
||||
typeName = 'Artist';
|
||||
typeBadgeClass = 'artist';
|
||||
const artist = item as FinalArtistCardItem;
|
||||
detailsHtml = artist.total_albums !== undefined ? `<span>${artist.total_albums} albums</span>` : '';
|
||||
} else if (item.itemType === 'playlist') {
|
||||
typeName = 'Playlist';
|
||||
typeBadgeClass = 'playlist';
|
||||
const playlist = item as FinalPlaylistCardItem;
|
||||
detailsHtml = playlist.owner_name ? `<span>By: ${playlist.owner_name}</span>` : '';
|
||||
detailsHtml += playlist.total_tracks !== undefined ? `<span> • ${playlist.total_tracks} tracks</span>` : '';
|
||||
if (playlist.followers_count !== undefined) {
|
||||
detailsHtml += `<span> • ${playlist.followers_count} followers</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
cardElement.innerHTML = `
|
||||
<div class="item-art-wrapper">
|
||||
<img class="item-art" src="${imageUrl}" alt="${item.name}" onerror="handleImageError(this)">
|
||||
</div>
|
||||
<div class="item-name">${item.name}</div>
|
||||
<div class="item-details">${detailsHtml}</div>
|
||||
<span class="item-type-badge ${typeBadgeClass}">${typeName}</span>
|
||||
<div class="item-actions">
|
||||
<button class="btn-icon unwatch-item-btn" data-id="${item.id}" data-type="${item.itemType}" title="Unwatch">
|
||||
<img src="/static/images/eye-crossed.svg" alt="Unwatch">
|
||||
</button>
|
||||
<button class="btn-icon check-item-now-btn" data-id="${item.id}" data-type="${item.itemType}" title="Check Now">
|
||||
<img src="/static/images/refresh.svg" alt="Check">
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add click event to navigate to the item's detail page
|
||||
cardElement.addEventListener('click', (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
// Don't navigate if any button within the card was clicked
|
||||
if (target.closest('button')) {
|
||||
return;
|
||||
}
|
||||
window.location.href = `/${item.itemType}/${item.id}`;
|
||||
});
|
||||
|
||||
// Add event listener for the "Check Now" button
|
||||
const checkNowBtn = cardElement.querySelector('.check-item-now-btn') as HTMLButtonElement | null;
|
||||
if (checkNowBtn) {
|
||||
checkNowBtn.addEventListener('click', (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const itemId = checkNowBtn.dataset.id;
|
||||
const itemType = checkNowBtn.dataset.type as 'artist' | 'playlist';
|
||||
if (itemId && itemType) {
|
||||
triggerItemCheck(itemId, itemType, checkNowBtn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add event listener for the "Unwatch" button
|
||||
const unwatchBtn = cardElement.querySelector('.unwatch-item-btn') as HTMLButtonElement | null;
|
||||
if (unwatchBtn) {
|
||||
unwatchBtn.addEventListener('click', (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const itemId = unwatchBtn.dataset.id;
|
||||
const itemType = unwatchBtn.dataset.type as 'artist' | 'playlist';
|
||||
if (itemId && itemType) {
|
||||
unwatchItem(itemId, itemType, unwatchBtn, cardElement);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return cardElement;
|
||||
}
|
||||
|
||||
function showLoading(show: boolean) {
|
||||
const loadingIndicator = document.getElementById('loadingWatchedItems');
|
||||
if (loadingIndicator) loadingIndicator.classList.toggle('hidden', !show);
|
||||
}
|
||||
|
||||
function showEmptyState(show: boolean) {
|
||||
const emptyStateIndicator = document.getElementById('emptyWatchedItems');
|
||||
if (emptyStateIndicator) emptyStateIndicator.classList.toggle('hidden', !show);
|
||||
}
|
||||
|
||||
async function unwatchItem(itemId: string, itemType: 'artist' | 'playlist', buttonElement: HTMLButtonElement, cardElement: HTMLElement) {
|
||||
const originalButtonContent = buttonElement.innerHTML;
|
||||
buttonElement.disabled = true;
|
||||
buttonElement.innerHTML = '<img src="/static/images/refresh.svg" class="spin-counter-clockwise" alt="Unwatching...">'; // Assuming a small loader icon
|
||||
|
||||
const endpoint = `/api/${itemType}/watch/${itemId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `Server error: ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
showNotification(result.message || `${itemType.charAt(0).toUpperCase() + itemType.slice(1)} unwatched successfully.`);
|
||||
|
||||
cardElement.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
|
||||
cardElement.style.opacity = '0';
|
||||
cardElement.style.transform = 'scale(0.9)';
|
||||
setTimeout(() => {
|
||||
cardElement.remove();
|
||||
const watchedItemsContainer = document.getElementById('watchedItemsContainer');
|
||||
const playlistGroups = document.querySelectorAll('.watched-items-group .results-grid');
|
||||
let totalItemsLeft = 0;
|
||||
|
||||
if (playlistGroups.length > 0) { // Grouped view
|
||||
playlistGroups.forEach(group => {
|
||||
totalItemsLeft += group.childElementCount;
|
||||
});
|
||||
// If a group becomes empty, we might want to remove the group header or show an empty message for that group.
|
||||
// This can be added here if desired.
|
||||
} else if (watchedItemsContainer) { // Non-grouped view
|
||||
totalItemsLeft = watchedItemsContainer.childElementCount;
|
||||
}
|
||||
|
||||
if (totalItemsLeft === 0) {
|
||||
// If all items are gone (either from groups or directly), reload to show empty state.
|
||||
// This also correctly handles the case where the initial list had <= 8 items.
|
||||
loadWatchedItems();
|
||||
}
|
||||
|
||||
}, 500);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`Error unwatching ${itemType}:`, error);
|
||||
showNotification(`Failed to unwatch: ${error.message}`, true);
|
||||
buttonElement.disabled = false;
|
||||
buttonElement.innerHTML = originalButtonContent;
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerItemCheck(itemId: string, itemType: 'artist' | 'playlist', buttonElement: HTMLButtonElement) {
|
||||
const originalButtonContent = buttonElement.innerHTML; // Will just be the img
|
||||
buttonElement.disabled = true;
|
||||
// Keep the icon, but we can add a class for spinning or use the same icon.
|
||||
// For simplicity, just using the same icon. Text "Checking..." is removed.
|
||||
buttonElement.innerHTML = '<img src="/static/images/refresh.svg" alt="Checking...">';
|
||||
|
||||
const endpoint = `/api/${itemType}/watch/trigger_check/${itemId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({})); // Handle non-JSON error responses
|
||||
throw new Error(errorData.error || `Server error: ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
showNotification(result.message || `Successfully triggered check for ${itemType}.`);
|
||||
} catch (error: any) {
|
||||
console.error(`Error triggering ${itemType} check:`, error);
|
||||
showNotification(`Failed to trigger check: ${error.message}`, true);
|
||||
} finally {
|
||||
buttonElement.disabled = false;
|
||||
buttonElement.innerHTML = originalButtonContent;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to show notifications (can be moved to a shared utility file if used elsewhere)
|
||||
function showNotification(message: string, isError: boolean = false) {
|
||||
const notificationArea = document.getElementById('notificationArea') || createNotificationArea();
|
||||
|
||||
// Limit the number of visible notifications
|
||||
while (notificationArea.childElementCount >= MAX_NOTIFICATIONS) {
|
||||
const oldestNotification = notificationArea.firstChild; // In column-reverse, firstChild is visually the bottom one
|
||||
if (oldestNotification) {
|
||||
oldestNotification.remove();
|
||||
} else {
|
||||
break; // Should not happen if childElementCount > 0
|
||||
}
|
||||
}
|
||||
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification-toast ${isError ? 'error' : 'success'}`;
|
||||
notification.textContent = message;
|
||||
|
||||
notificationArea.appendChild(notification);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
notification.classList.add('hide');
|
||||
setTimeout(() => notification.remove(), 500); // Remove from DOM after fade out
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function createNotificationArea(): HTMLElement {
|
||||
const area = document.createElement('div');
|
||||
area.id = 'notificationArea';
|
||||
document.body.appendChild(area);
|
||||
return area;
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
/* Base Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #121212, #1e1e1e);
|
||||
color: #ffffff;
|
||||
min-height: 100vh;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Main App Container */
|
||||
#app {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Album Header */
|
||||
#album-header {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 2rem;
|
||||
align-items: center;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
flex-wrap: wrap;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
#album-image {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
#album-image:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
#album-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#album-name {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(90deg, #1db954, #17a44b);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
#album-artist,
|
||||
#album-stats {
|
||||
font-size: 1.1rem;
|
||||
color: #b3b3b3;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#album-copyright {
|
||||
font-size: 0.9rem;
|
||||
color: #b3b3b3;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Tracks Container */
|
||||
#tracks-container {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
#tracks-container h2 {
|
||||
font-size: 1.75rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Tracks List */
|
||||
#tracks-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Individual Track Styling */
|
||||
.track {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto auto;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: var(--color-surface);
|
||||
margin-bottom: 0.5rem;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.track:hover {
|
||||
background-color: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.track-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
padding: 0 1rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-name {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.track-duration {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
/* Loading and Error States */
|
||||
.loading,
|
||||
.error {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Unified Download Button Base Style */
|
||||
.download-btn {
|
||||
background-color: #1db954;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.6rem 1.2rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
background-color: #17a44b;
|
||||
}
|
||||
|
||||
.download-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Circular Variant for Compact Areas */
|
||||
.download-btn--circle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
font-size: 0; /* Hide any text */
|
||||
background-color: #1db954;
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.download-btn--circle img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
filter: brightness(0) invert(1);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.download-btn--circle:hover {
|
||||
background-color: #17a44b;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.download-btn--circle:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Home Button Styling */
|
||||
.home-btn {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
margin-right: 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.home-btn img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
filter: invert(1);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.home-btn:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.home-btn:active img {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Queue Toggle Button */
|
||||
.queue-toggle {
|
||||
background: #1db954;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
transition: background-color 0.3s ease, transform 0.2s ease;
|
||||
z-index: 1002;
|
||||
/* Remove any fixed positioning by default for mobile; fixed positioning remains for larger screens */
|
||||
}
|
||||
|
||||
/* Actions Container for Small Screens */
|
||||
#album-actions {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Responsive Styles */
|
||||
|
||||
/* Medium Devices (Tablets) */
|
||||
@media (max-width: 768px) {
|
||||
#album-header {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#album-image {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
#album-name {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
#album-artist,
|
||||
#album-stats {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.track {
|
||||
grid-template-columns: 30px 1fr auto auto;
|
||||
padding: 0.6rem 0.8rem;
|
||||
}
|
||||
|
||||
.track-duration {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small Devices (Mobile Phones) */
|
||||
@media (max-width: 480px) {
|
||||
#app {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#album-header {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Center the album cover */
|
||||
#album-image {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#album-name {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
#album-artist,
|
||||
#album-stats,
|
||||
#album-copyright {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.track {
|
||||
grid-template-columns: 30px 1fr auto;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.track-name, .track-artist {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Ensure the actions container lays out buttons properly */
|
||||
#album-actions {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Remove extra margins from the queue toggle */
|
||||
.queue-toggle {
|
||||
position: static;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent anchor links from appearing all blue */
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:focus {
|
||||
color: #1db954;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* (Optional) Override for circular download button pseudo-element */
|
||||
.download-btn--circle::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
/* Album page specific styles */
|
||||
|
||||
/* Add some context styles for the album copyright */
|
||||
.album-copyright {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-tertiary);
|
||||
margin-top: 0.5rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Section title styling */
|
||||
.section-title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section-title:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 50px;
|
||||
height: 2px;
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
@@ -1,637 +0,0 @@
|
||||
/* Base Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #121212;
|
||||
color: #ffffff;
|
||||
min-height: 100vh;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Main App Container */
|
||||
#app {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Artist Header */
|
||||
#artist-header {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 2rem;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
flex-wrap: wrap;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, rgba(0,0,0,0.5), transparent);
|
||||
}
|
||||
|
||||
#artist-image {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
#artist-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#artist-name {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#artist-stats {
|
||||
font-size: 1rem;
|
||||
color: #b3b3b3;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Albums Container */
|
||||
#albums-container {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
#albums-container h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Album groups layout */
|
||||
.album-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
/* Album group section */
|
||||
.album-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.album-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.album-group-header h3 {
|
||||
font-size: 1.3rem;
|
||||
position: relative;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.album-group-header h3::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.group-download-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Albums grid layout */
|
||||
.albums-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Album card styling */
|
||||
.album-card {
|
||||
background-color: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
position: relative;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.album-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.album-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 1/1;
|
||||
object-fit: cover;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.album-info {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.album-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.album-artist {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Track Card (for Albums or Songs) */
|
||||
.track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: #181818;
|
||||
border-radius: 8px;
|
||||
transition: background 0.3s ease;
|
||||
flex-wrap: wrap;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.track:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.track-number {
|
||||
width: 30px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
margin-right: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.track-name {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
font-size: 0.9rem;
|
||||
color: #b3b3b3;
|
||||
}
|
||||
|
||||
.track-album {
|
||||
max-width: 200px;
|
||||
font-size: 0.9rem;
|
||||
color: #b3b3b3;
|
||||
margin-left: 1rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.track-duration {
|
||||
width: 60px;
|
||||
text-align: right;
|
||||
font-size: 0.9rem;
|
||||
color: #b3b3b3;
|
||||
margin-left: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Loading and Error States */
|
||||
.loading,
|
||||
.error {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Unified Download Button Base Style */
|
||||
.download-btn {
|
||||
background-color: #1db954;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
background-color: #17a44b;
|
||||
}
|
||||
|
||||
.download-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Circular Variant for Compact Areas (e.g. album download buttons) */
|
||||
.download-btn--circle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
font-size: 0; /* Hide any text */
|
||||
background-color: #1db954;
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.download-btn--circle img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
filter: brightness(0) invert(1);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.download-btn--circle:hover {
|
||||
background-color: #17a44b;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.download-btn--circle:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Home Button Styling */
|
||||
.home-btn {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
margin-right: 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.home-btn img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
filter: invert(1);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.home-btn:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.home-btn:active img {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Watch Artist Button Styling */
|
||||
.watch-btn {
|
||||
background-color: transparent;
|
||||
color: #ffffff;
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.watch-btn:hover {
|
||||
background-color: #ffffff;
|
||||
color: #121212;
|
||||
border-color: #ffffff;
|
||||
}
|
||||
|
||||
.watch-btn.watching {
|
||||
background-color: #1db954; /* Spotify green for "watching" state */
|
||||
color: #ffffff;
|
||||
border-color: #1db954;
|
||||
}
|
||||
|
||||
.watch-btn.watching:hover {
|
||||
background-color: #17a44b; /* Darker green on hover */
|
||||
border-color: #17a44b;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.watch-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Styling for icons within watch and sync buttons */
|
||||
.watch-btn img,
|
||||
.sync-btn img {
|
||||
width: 16px; /* Adjust size as needed */
|
||||
height: 16px; /* Adjust size as needed */
|
||||
margin-right: 8px; /* Space between icon and text */
|
||||
filter: brightness(0) invert(1); /* Make icons white */
|
||||
}
|
||||
|
||||
/* Responsive Styles */
|
||||
|
||||
/* Medium Devices (Tablets) */
|
||||
@media (max-width: 768px) {
|
||||
#artist-header {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#artist-image {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.track {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.track-album,
|
||||
.track-duration {
|
||||
margin-left: 0;
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.albums-list {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.album-group-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.group-download-btn {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small Devices (Mobile Phones) */
|
||||
@media (max-width: 480px) {
|
||||
#app {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#artist-name {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.track {
|
||||
padding: 0.8rem;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.track-number {
|
||||
font-size: 0.9rem;
|
||||
margin-right: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.track-album,
|
||||
.track-duration {
|
||||
margin-left: 0;
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.albums-list {
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.album-info {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.album-title {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.album-artist {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent anchor links from appearing blue */
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:focus {
|
||||
color: #1db954;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Toggle Known Status Button for Tracks/Albums */
|
||||
.toggle-known-status-btn {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--color-text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
padding: 5px;
|
||||
border-radius: 50%; /* Make it circular */
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px; /* Fixed size */
|
||||
height: 30px; /* Fixed size */
|
||||
transition: background-color 0.2s, border-color 0.2s, color 0.2s, opacity 0.2s; /* Added opacity */
|
||||
/* opacity: 0; Initially hidden, JS will make it visible if artist is watched via persistent-album-action-btn */
|
||||
}
|
||||
|
||||
.toggle-known-status-btn img {
|
||||
width: 16px; /* Adjust icon size */
|
||||
height: 16px;
|
||||
filter: brightness(0) invert(1); /* Make icon white consistently */
|
||||
margin: 0; /* Ensure no accidental margin for centering */
|
||||
}
|
||||
|
||||
.toggle-known-status-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
background-color: rgba(var(--color-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.toggle-known-status-btn[data-status="known"] {
|
||||
/* Optional: specific styles if it's already known, e.g., a slightly different border */
|
||||
border-color: var(--color-success); /* Green border for known items */
|
||||
}
|
||||
|
||||
.toggle-known-status-btn[data-status="missing"] {
|
||||
/* Optional: specific styles if it's missing, e.g., a warning color */
|
||||
border-color: var(--color-warning); /* Orange border for missing items */
|
||||
}
|
||||
|
||||
.toggle-known-status-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Ensure album download button also fits well within actions container */
|
||||
.album-actions-container .album-download-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 5px; /* Ensure padding doesn't make it too big */
|
||||
}
|
||||
|
||||
.album-actions-container .album-download-btn img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Album actions container */
|
||||
.album-actions-container {
|
||||
/* position: absolute; */ /* No longer needed if buttons are positioned individually */
|
||||
/* bottom: 8px; */
|
||||
/* right: 8px; */
|
||||
/* display: flex; */
|
||||
/* gap: 8px; */
|
||||
/* background-color: rgba(0, 0, 0, 0.6); */
|
||||
/* padding: 5px; */
|
||||
/* border-radius: var(--radius-sm); */
|
||||
/* opacity: 0; */ /* Ensure it doesn't hide buttons if it still wraps them elsewhere */
|
||||
/* transition: opacity 0.2s ease-in-out; */
|
||||
display: none; /* Hide this container if it solely relied on hover and now buttons are persistent */
|
||||
}
|
||||
|
||||
/* .album-card:hover .album-actions-container { */
|
||||
/* opacity: 1; */ /* Remove this hover effect */
|
||||
/* } */
|
||||
|
||||
/* Album card actions container - for persistent buttons at the bottom */
|
||||
.album-card-actions {
|
||||
display: flex;
|
||||
justify-content: space-between; /* Pushes children to ends */
|
||||
align-items: center;
|
||||
padding: 8px; /* Spacing around the buttons */
|
||||
border-top: 1px solid var(--color-surface-darker, #2a2a2a); /* Separator line */
|
||||
/* Ensure it takes up full width of the card if not already */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Persistent action button (e.g., toggle known/missing) on album card - BOTTOM-LEFT */
|
||||
.persistent-album-action-btn {
|
||||
/* position: absolute; */ /* No longer absolute */
|
||||
/* bottom: 8px; */
|
||||
/* left: 8px; */
|
||||
/* z-index: 2; */
|
||||
opacity: 1; /* Ensure it is visible */
|
||||
/* Specific margin if needed, but flexbox space-between should handle it */
|
||||
margin: 0; /* Reset any previous margins */
|
||||
}
|
||||
|
||||
/* Persistent download button on album card - BOTTOM-RIGHT */
|
||||
.persistent-download-btn {
|
||||
/* position: absolute; */ /* No longer absolute */
|
||||
/* bottom: 8px; */
|
||||
/* right: 8px; */
|
||||
/* z-index: 2; */
|
||||
opacity: 1; /* Ensure it is visible */
|
||||
/* Specific margin if needed, but flexbox space-between should handle it */
|
||||
margin: 0; /* Reset any previous margins */
|
||||
}
|
||||
|
||||
.album-cover.album-missing-in-db {
|
||||
border: 3px dashed var(--color-warning); /* Example: orange dashed border */
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* NEW STYLES FOR BUTTON STATES */
|
||||
.persistent-album-action-btn.status-missing {
|
||||
background-color: #d9534f; /* Bootstrap's btn-danger red */
|
||||
border-color: #d43f3a;
|
||||
}
|
||||
|
||||
.persistent-album-action-btn.status-missing:hover {
|
||||
background-color: #c9302c;
|
||||
border-color: #ac2925;
|
||||
}
|
||||
|
||||
/* Ensure icon is white on colored background */
|
||||
.persistent-album-action-btn.status-missing img {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.persistent-album-action-btn.status-known {
|
||||
background-color: #5cb85c; /* Bootstrap's btn-success green */
|
||||
border-color: #4cae4c;
|
||||
}
|
||||
|
||||
.persistent-album-action-btn.status-known:hover {
|
||||
background-color: #449d44;
|
||||
border-color: #398439;
|
||||
}
|
||||
|
||||
/* Ensure icon is white on colored background */
|
||||
.persistent-album-action-btn.status-known img {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
/* END OF NEW STYLES */
|
||||
|
||||
/* Spinning Icon Animation */
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(-360deg); }
|
||||
}
|
||||
|
||||
.icon-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||