Merge pull request #179 from mustafasoylu/ui-overhaul

UI overhaul
This commit is contained in:
Xoconoch
2025-06-14 11:36:21 -06:00
committed by GitHub
103 changed files with 7829 additions and 14682 deletions

View File

@@ -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/
tests/

View File

@@ -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]

View File

@@ -1,7 +1,20 @@
# Use an official Python runtime as a parent image
# 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
# Stage 2: Final application image
FROM python:3.12-slim
# Set the working directory in the container
# Set an environment variable for non-interactive frontend installation
ENV DEBIAN_FRONTEND=noninteractive
LABEL org.opencontainers.image.source="https://github.com/Xoconoch/spotizerr"
WORKDIR /app
# Install system dependencies
@@ -10,27 +23,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
gosu \
git \
ffmpeg \
nodejs \
npm \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements file
COPY requirements.txt .
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
# Copy application code (excluding UI source and TS source)
COPY . .
# Install TypeScript globally
RUN npm install -g typescript
# Compile TypeScript
# tsc will use tsconfig.json from the current directory (/app)
# It will read from /app/src/js and output to /app/static/js
RUN tsc
# 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 && \
@@ -41,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

60
app.py
View File

@@ -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

View 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:

24
spotizerr-ui/.gitignore vendored Normal file
View 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?

View File

@@ -0,0 +1,9 @@
node_modules
dist
.DS_Store
coverage
.pnpm-store
.vite
.env
.env.*
!.env.example

View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 120,
"tabWidth": 2
}

54
spotizerr-ui/README.md Normal file
View 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,
},
});
```

View 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
View 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>

48
spotizerr-ui/package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"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",
"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"
}
}

3383
spotizerr-ui/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
import tailwindcss from "@tailwindcss/postcss";
export default {
plugins: [tailwindcss],
};

View File

Before

Width:  |  Height:  |  Size: 337 B

After

Width:  |  Height:  |  Size: 337 B

View File

Before

Width:  |  Height:  |  Size: 284 B

After

Width:  |  Height:  |  Size: 284 B

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 723 B

After

Width:  |  Height:  |  Size: 723 B

View File

Before

Width:  |  Height:  |  Size: 356 B

After

Width:  |  Height:  |  Size: 356 B

View File

Before

Width:  |  Height:  |  Size: 891 B

After

Width:  |  Height:  |  Size: 891 B

View File

Before

Width:  |  Height:  |  Size: 752 B

After

Width:  |  Height:  |  Size: 752 B

View File

Before

Width:  |  Height:  |  Size: 597 B

After

Width:  |  Height:  |  Size: 597 B

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 673 B

After

Width:  |  Height:  |  Size: 673 B

View File

Before

Width:  |  Height:  |  Size: 527 B

After

Width:  |  Height:  |  Size: 527 B

View File

Before

Width:  |  Height:  |  Size: 247 B

After

Width:  |  Height:  |  Size: 247 B

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 307 B

After

Width:  |  Height:  |  Size: 307 B

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 652 B

After

Width:  |  Height:  |  Size: 652 B

View File

Before

Width:  |  Height:  |  Size: 873 B

After

Width:  |  Height:  |  Size: 873 B

View File

Before

Width:  |  Height:  |  Size: 666 B

After

Width:  |  Height:  |  Size: 666 B

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 500 B

After

Width:  |  Height:  |  Size: 500 B

View File

Before

Width:  |  Height:  |  Size: 284 B

After

Width:  |  Height:  |  Size: 284 B

View File

Before

Width:  |  Height:  |  Size: 778 B

After

Width:  |  Height:  |  Size: 778 B

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 6V18M13.5239 12.7809L8.6247 16.7002C7.96993 17.2241 7 16.7579 7 15.9194V8.08062C7 7.24212 7.96993 6.77595 8.6247 7.29976L13.5239 11.2191C14.0243 11.6195 14.0243 12.3805 13.5239 12.7809Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 6V18M13.5239 12.7809L8.6247 16.7002C7.96993 17.2241 7 16.7579 7 15.9194V8.08062C7 7.24212 7.96993 6.77595 8.6247 7.29976L13.5239 11.2191C14.0243 11.6195 14.0243 12.3805 13.5239 12.7809Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 513 B

After

Width:  |  Height:  |  Size: 510 B

View File

Before

Width:  |  Height:  |  Size: 550 B

After

Width:  |  Height:  |  Size: 550 B

View File

Before

Width:  |  Height:  |  Size: 597 B

After

Width:  |  Height:  |  Size: 597 B

View 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>
);
};

View File

@@ -0,0 +1,207 @@
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",
},
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 currentCount = isTerminal ? (item.summary?.successful?.length ?? item.totalTracks) : item.currentTrackNumber;
const progressText =
item.type === "album" || item.type === "playlist"
? `${currentCount || 0}/${item.totalTracks || "?"}`
: item.progress
? `${item.progress.toFixed(0)}%`
: "";
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">
<div className="flex items-center gap-2">
{item.type === "track" ? (
<FaMusic className="text-gray-500" />
) : (
<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>
</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.error && <p className="text-xs text-red-600 mt-2">Error: {item.error}</p>}
{(item.status === "downloading" || item.status === "processing") && 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>
);
};

View 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>
);
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,416 @@
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";
// --- Helper Types ---
// This represents the raw status object from the backend polling endpoint
interface TaskStatusDTO {
status: QueueStatus;
message?: string;
can_retry?: boolean;
// Progress indicators
progress?: number;
speed?: string;
size?: string;
eta?: string;
// Multi-track progress
current_track?: number;
total_tracks?: number;
summary?: {
successful_tracks: string[];
skipped_tracks: string[];
failed_tracks: number;
failed_track_details: { name: string; reason: string }[];
};
}
// Task from prgs/list endpoint
interface TaskDTO {
task_id: string;
name?: string;
type?: string;
download_type?: string;
status?: string;
last_status_obj?: {
status?: string;
progress?: number;
speed?: string;
size?: string;
eta?: string;
current_track?: number;
total_tracks?: number;
error?: string;
can_retry?: boolean;
};
original_request?: {
url?: string;
[key: string]: unknown;
};
summary?: {
successful_tracks: string[];
skipped_tracks: string[];
failed_tracks: number;
failed_track_details?: { name: string; reason: string }[];
};
}
const isTerminalStatus = (status: QueueStatus) =>
["completed", "error", "cancelled", "skipped", "done"].includes(status);
export function QueueProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<QueueItem[]>(() => {
try {
const storedItems = localStorage.getItem("queueItems");
return storedItems ? JSON.parse(storedItems) : [];
} catch {
return [];
}
});
const [isVisible, setIsVisible] = useState(false);
const pollingIntervals = useRef<Record<string, number>>({});
// --- Persistence ---
useEffect(() => {
localStorage.setItem("queueItems", JSON.stringify(items));
}, [items]);
const stopPolling = useCallback((internalId: string) => {
if (pollingIntervals.current[internalId]) {
clearInterval(pollingIntervals.current[internalId]);
delete pollingIntervals.current[internalId];
}
}, []);
// --- Polling Logic ---
const startPolling = useCallback(
(internalId: string, taskId: string) => {
if (pollingIntervals.current[internalId]) return;
const intervalId = window.setInterval(async () => {
try {
// Use the prgs endpoint instead of download/status
interface PrgsResponse {
status?: string;
summary?: TaskStatusDTO["summary"];
last_line?: {
status?: string;
message?: string;
error?: string;
can_retry?: boolean;
progress?: number;
speed?: string;
size?: string;
eta?: string;
current_track?: number;
total_tracks?: number;
};
}
const response = await apiClient.get<PrgsResponse>(`/prgs/${taskId}`);
const lastStatus = response.data.last_line || {};
const statusUpdate = {
status: response.data.status || lastStatus.status || "pending",
message: lastStatus.message || lastStatus.error,
can_retry: lastStatus.can_retry,
progress: lastStatus.progress,
speed: lastStatus.speed,
size: lastStatus.size,
eta: lastStatus.eta,
current_track: lastStatus.current_track,
total_tracks: lastStatus.total_tracks,
summary: response.data.summary,
};
setItems((prev) =>
prev.map((item) => {
if (item.id === internalId) {
const updatedItem: QueueItem = {
...item,
status: statusUpdate.status as QueueStatus,
progress: statusUpdate.progress,
speed: statusUpdate.speed,
size: statusUpdate.size,
eta: statusUpdate.eta,
error: statusUpdate.status === "error" ? statusUpdate.message : undefined,
canRetry: statusUpdate.can_retry,
currentTrackNumber: statusUpdate.current_track,
totalTracks: statusUpdate.total_tracks,
summary: statusUpdate.summary
? {
successful: statusUpdate.summary.successful_tracks,
skipped: statusUpdate.summary.skipped_tracks,
failed: statusUpdate.summary.failed_tracks,
failedTracks: statusUpdate.summary.failed_track_details || [],
}
: item.summary,
};
if (isTerminalStatus(statusUpdate.status as QueueStatus)) {
stopPolling(internalId);
}
return updatedItem;
}
return item;
}),
);
} catch (error) {
console.error(`Polling failed for task ${taskId}:`, error);
stopPolling(internalId);
setItems((prev) =>
prev.map((i) =>
i.id === internalId
? {
...i,
status: "error",
error: "Connection lost",
}
: i,
),
);
}
}, 2000); // Poll every 2 seconds
pollingIntervals.current[internalId] = intervalId;
},
[stopPolling],
);
// --- Core Action: Add Item ---
const addItem = useCallback(
async (item: { name: string; type: DownloadType; spotifyId: string; artist?: string }) => {
const internalId = uuidv4();
const newItem: QueueItem = {
...item,
id: internalId,
status: "queued",
};
setItems((prev) => [...prev, newItem]);
if (!isVisible) setIsVisible(true);
try {
let endpoint = "";
if (item.type === "track") {
endpoint = `/track/download/${item.spotifyId}`;
} else if (item.type === "album") {
endpoint = `/album/download/${item.spotifyId}`;
} else if (item.type === "playlist") {
endpoint = `/playlist/download/${item.spotifyId}`;
} else if (item.type === "artist") {
endpoint = `/artist/download/${item.spotifyId}`;
}
const response = await apiClient.get<{ task_id: string }>(endpoint);
const task_id = response.data.task_id;
setItems((prev) =>
prev.map((i) => (i.id === internalId ? { ...i, taskId: task_id, status: "initializing" } : i)),
);
startPolling(internalId, task_id);
} catch (error) {
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 clearAllPolls = useCallback(() => {
Object.values(pollingIntervals.current).forEach(clearInterval);
}, []);
// --- Load existing tasks on startup ---
useEffect(() => {
const syncActiveTasks = async () => {
try {
// Use the prgs/list endpoint instead of download/active
const response = await apiClient.get<TaskDTO[]>("/prgs/list");
// Map the prgs response to the expected QueueItem format
const activeTasks = response.data
.filter((task) => {
// Only include non-terminal tasks
const status = task.status?.toLowerCase();
return status && !isTerminalStatus(status as QueueStatus);
})
.map((task) => {
// Extract Spotify ID from URL if available
const url = task.original_request?.url || "";
const spotifyId = url.includes("spotify.com") ? url.split("/").pop() || "" : "";
// Map download_type to UI type
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";
return {
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,
speed: task.last_status_obj?.speed,
size: task.last_status_obj?.size,
eta: task.last_status_obj?.eta,
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
? {
successful: task.summary.successful_tracks,
skipped: task.summary.skipped_tracks,
failed: task.summary.failed_tracks,
failedTracks: task.summary.failed_track_details || [],
}
: undefined,
};
});
// Basic reconciliation
setItems((prevItems) => {
const newItems = [...prevItems];
activeTasks.forEach((task) => {
if (!newItems.some((item) => item.taskId === task.taskId)) {
newItems.push(task);
}
});
return newItems;
});
activeTasks.forEach((item) => {
if (item.id && item.taskId && !isTerminalStatus(item.status)) {
startPolling(item.id, item.taskId);
}
});
} catch (error) {
console.error("Failed to sync active tasks:", error);
}
};
syncActiveTasks();
// restart polling for any non-terminal items from localStorage
items.forEach((item) => {
if (item.id && item.taskId && !isTerminalStatus(item.status)) {
startPolling(item.id, item.taskId);
}
});
return clearAllPolls;
// This effect should only run once on mount to initialize the queue.
// We are intentionally omitting 'items' as a dependency to prevent re-runs.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clearAllPolls, startPolling]);
// --- Other Actions ---
const removeItem = useCallback((id: string) => {
setItems((prev) => prev.filter((item) => item.id !== id));
}, []);
const cancelItem = useCallback(
async (id: string) => {
const itemToCancel = items.find((i) => i.id === id);
if (itemToCancel && itemToCancel.taskId && !isTerminalStatus(itemToCancel.status)) {
stopPolling(id);
try {
await apiClient.post(`/prgs/cancel/${itemToCancel.taskId}`);
toast.success(`Cancelled download: ${itemToCancel.name}`);
setItems((prev) => prev.map((i) => (i.id === id ? { ...i, status: "cancelled" } : i)));
} catch (err) {
console.error(`Failed to cancel task ${itemToCancel.taskId}`, err);
toast.error(`Failed to cancel: ${itemToCancel.name}`);
}
}
},
[items, stopPolling],
);
const retryItem = useCallback(
async (id: string) => {
const itemToRetry = items.find((i) => i.id === id);
if (!itemToRetry || !itemToRetry.taskId) return;
try {
// Use the prgs/retry endpoint
await apiClient.post(`/prgs/retry/${itemToRetry.taskId}`);
toast.info(`Retrying download: ${itemToRetry.name}`);
// Update the item status in the UI
setItems((prev) =>
prev.map((item) =>
item.id === id
? {
...item,
status: "initializing",
error: undefined,
}
: item,
),
);
// Start polling again
startPolling(id, itemToRetry.taskId);
} catch (error) {
console.error(`Failed to retry download for ${itemToRetry.name}:`, error);
toast.error(`Failed to retry download: ${itemToRetry.name}`);
}
},
[items, startPolling],
);
const cancelAll = useCallback(async () => {
toast.info("Cancelling all active downloads...");
for (const item of items) {
if (item.taskId && !isTerminalStatus(item.status)) {
stopPolling(item.id);
try {
await apiClient.post(`/prgs/cancel/${item.taskId}`);
// Visually update the item to "cancelled" immediately
setItems((prev) => prev.map((i) => (i.id === item.id ? { ...i, status: "cancelled" } : i)));
} catch (err) {
console.error(`Failed to cancel task ${item.taskId}`, err);
toast.error(`Failed to cancel: ${item.name}`);
}
}
}
}, [items, stopPolling]);
const clearCompleted = useCallback(() => {
setItems((prev) => prev.filter((item) => !isTerminalStatus(item.status)));
toast.info("Cleared finished downloads.");
}, []);
const toggleVisibility = useCallback(() => setIsVisible((prev) => !prev), []);
const value = {
items,
isVisible,
addItem,
removeItem,
retryItem,
toggleVisibility,
clearCompleted,
cancelAll,
cancelItem,
};
return <QueueContext.Provider value={value}>{children}</QueueContext.Provider>;
}

View 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>;
}

View File

@@ -0,0 +1,66 @@
import { createContext, useContext } from "react";
export type DownloadType = "track" | "album" | "artist" | "playlist";
export type QueueStatus =
| "initializing"
| "pending"
| "downloading"
| "processing"
| "completed"
| "error"
| "skipped"
| "cancelled"
| "done"
| "queued";
export interface QueueItem {
id: string; // Unique ID for the queue item (can be task_id from backend)
name: string;
artist?: string;
type: DownloadType;
spotifyId: string; // Original Spotify ID
// --- Status and Progress ---
status: QueueStatus;
taskId?: string; // The backend task ID for polling
error?: string;
canRetry?: boolean;
// --- Single Track Progress ---
progress?: number; // 0-100
speed?: string;
size?: string;
eta?: string;
// --- Multi-Track (Album/Playlist) Progress ---
currentTrackNumber?: number;
totalTracks?: number;
summary?: {
successful: string[];
skipped: string[];
failed: number;
failedTracks: { name: string; reason: string }[];
};
}
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;
}

View 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;
}

View File

@@ -0,0 +1,7 @@
@import "tailwindcss";
@layer base {
a {
@apply no-underline hover:underline cursor-pointer;
}
}

View 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
View 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
View 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;
}
}

View 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>
);
};

View 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>
);
};

View 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 />;
};

View 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">
&larr; 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
}

View 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>
);
};

View 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>
);
};

View 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
}

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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"]
}

View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

View 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"]
}

View 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,
},
},
},
});

View File

@@ -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;
}
}

View File

@@ -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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 = '&larr; 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();
});

View File

@@ -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);
}
}
});

View File

@@ -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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,203 +0,0 @@
body {
font-family: sans-serif;
margin: 0;
background-color: #121212;
color: #e0e0e0;
}
.container {
padding: 20px;
max-width: 1200px;
margin: auto;
}
h1 {
color: #1DB954; /* Spotify Green */
text-align: center;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
background-color: #1e1e1e;
}
th, td {
border: 1px solid #333;
padding: 10px 12px;
text-align: left;
}
th {
background-color: #282828;
cursor: pointer;
}
tr:nth-child(even) {
background-color: #222;
}
/* Parent and child track styling */
.parent-task-row {
background-color: #282828 !important;
font-weight: bold;
}
.child-track-row {
background-color: #1a1a1a !important;
font-size: 0.9em;
}
.child-track-indent {
color: #1DB954;
margin-right: 5px;
}
/* Track status styling */
.track-status-successful {
color: #1DB954;
font-weight: bold;
}
.track-status-skipped {
color: #FFD700;
font-weight: bold;
}
.track-status-failed {
color: #FF4136;
font-weight: bold;
}
/* Track counts display */
.track-counts {
margin-left: 10px;
font-size: 0.85em;
}
.track-count.success {
color: #1DB954;
}
.track-count.skipped {
color: #FFD700;
}
.track-count.failed {
color: #FF4136;
}
/* Back button */
#back-to-history {
margin-right: 15px;
padding: 5px 10px;
background-color: #333;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
#back-to-history:hover {
background-color: #444;
}
.pagination {
margin-top: 20px;
text-align: center;
}
.pagination button, .pagination select {
padding: 8px 12px;
margin: 0 5px;
background-color: #1DB954;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.pagination button:disabled {
background-color: #555;
cursor: not-allowed;
}
.filters {
margin-bottom: 20px;
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.filters label, .filters select, .filters input {
margin-right: 5px;
}
.filters select, .filters input {
padding: 8px;
background-color: #282828;
color: #e0e0e0;
border: 1px solid #333;
border-radius: 4px;
}
.checkbox-filter {
display: flex;
align-items: center;
gap: 5px;
}
.status-COMPLETED { color: #1DB954; font-weight: bold; }
.status-ERROR { color: #FF4136; font-weight: bold; }
.status-CANCELLED { color: #AAAAAA; }
.status-skipped { color: #FFD700; font-weight: bold; }
.error-message-toggle {
cursor: pointer;
color: #FF4136; /* Red for error indicator */
text-decoration: underline;
}
.error-details {
display: none; /* Hidden by default */
white-space: pre-wrap; /* Preserve formatting */
background-color: #303030;
padding: 5px;
margin-top: 5px;
border-radius: 3px;
font-size: 0.9em;
}
/* Styling for the buttons in the table */
.btn-icon {
background-color: transparent; /* Or a subtle color like #282828 */
border: none;
border-radius: 50%; /* Make it circular */
padding: 5px; /* Adjust padding to control size */
cursor: pointer;
display: inline-flex; /* Important for aligning the image */
align-items: center;
justify-content: center;
transition: background-color 0.2s ease;
margin-right: 5px;
}
.btn-icon img {
width: 16px; /* Icon size */
height: 16px;
filter: invert(1); /* Make icon white if it's dark, adjust if needed */
}
.btn-icon:hover {
background-color: #333; /* Darker on hover */
}
.details-btn:hover img {
filter: invert(0.8) sepia(1) saturate(5) hue-rotate(175deg); /* Make icon blue on hover */
}
.tracks-btn:hover img {
filter: invert(0.8) sepia(1) saturate(5) hue-rotate(90deg); /* Make icon green on hover */
}

View File

@@ -1,530 +0,0 @@
/* Spotizerr Base Styles
Provides consistent styling across all pages */
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
:root {
/* Main colors */
--color-background: #121212;
--color-background-gradient: linear-gradient(135deg, #121212, #1e1e1e);
--color-surface: #1c1c1c;
--color-surface-hover: #2a2a2a;
--color-border: #2a2a2a;
/* Text colors */
--color-text-primary: #ffffff;
--color-text-secondary: #b3b3b3;
--color-text-tertiary: #757575;
/* Brand colors */
--color-primary: #1db954;
--color-primary-hover: #17a44b;
--color-error: #c0392b;
--color-success: #2ecc71;
/* Adding accent green if not present, or ensuring it is */
--color-accent-green: #22c55e; /* Example: A Tailwind-like green */
--color-accent-green-dark: #16a34a; /* Darker shade for hover */
/* Spacing */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
/* Shadow */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
--shadow-lg: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23);
/* Border radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-round: 50%;
}
body {
background: var(--color-background-gradient);
color: var(--color-text-primary);
min-height: 100vh;
line-height: 1.4;
}
a {
color: inherit;
text-decoration: none;
transition: color 0.2s ease;
}
a:hover, a:focus {
color: var(--color-primary);
text-decoration: underline;
}
/* Container for main content */
.app-container {
max-width: 1200px;
margin: 0 auto;
padding: var(--space-lg);
position: relative;
z-index: 1;
}
/* Card component */
.card {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: var(--space-md);
box-shadow: var(--shadow-sm);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
/* Button variants */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-sm) var(--space-md);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s ease, transform 0.2s ease;
background-color: var(--color-surface-hover);
color: var(--color-text-primary);
}
.btn:hover {
transform: translateY(-1px);
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
background-color: var(--color-primary);
color: white;
}
.btn-primary:hover {
background-color: var(--color-primary-hover);
}
/* Icon button */
.btn-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-round);
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
cursor: pointer;
transition: background-color 0.2s ease;
}
.btn-icon:hover {
background-color: var(--color-surface-hover);
}
.btn-icon img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
}
/* Queue icon styling */
.queue-icon {
background-color: transparent;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.queue-icon:hover {
background-color: var(--color-surface-hover);
}
/* General styles for floating action buttons (FABs) */
.floating-icon {
position: fixed;
z-index: 1000; /* Base z-index, can be overridden */
border-radius: 50%;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
display: flex;
align-items: center;
justify-content: center;
width: 48px; /* Standard size */
height: 48px; /* Standard size */
background-color: #282828; /* Dark background */
transition: background-color 0.3s ease, transform 0.2s ease;
text-decoration: none !important; /* Ensure no underline for <a> tags */
}
.floating-icon:hover {
background-color: #333; /* Slightly lighter on hover */
transform: scale(1.05);
}
.floating-icon:active {
transform: scale(0.98);
}
.floating-icon img {
width: 24px;
height: 24px;
filter: invert(1); /* White icon */
margin: 0; /* Reset any margin if inherited */
}
/* Home button */
.home-btn {
background-color: transparent;
border: none;
cursor: pointer;
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);
}
/* Styles for buttons that are specifically floating icons (like home button when it's a FAB) */
/* This ensures that if a .home-btn also has .floating-icon, it gets the correct FAB styling. */
.home-btn.floating-icon,
.settings-icon.floating-icon, /* If settings button is an <a> or <button> with this class */
.back-button.floating-icon, /* If back button is an <a> or <button> with this class */
.history-nav-btn.floating-icon, /* If history button is an <a> or <button> with this class */
.queue-icon.floating-icon, /* If queue button is an <a> or <button> with this class */
.watch-nav-btn.floating-icon { /* If watch button is an <a> or <button> with this class */
/* Specific overrides if needed, but mostly inherits from .floating-icon */
/* For example, if a specific button needs a different background */
/* background-color: var(--color-primary); */ /* Example if some should use primary color */
}
/* Download button */
.download-btn {
background-color: var(--color-primary);
border: none;
border-radius: var(--radius-sm);
padding: 0.7rem 1.2rem;
cursor: pointer;
font-weight: 600;
transition: background-color 0.2s ease, transform 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
color: white;
}
.download-btn img {
width: 18px;
height: 18px;
margin-right: 8px;
filter: brightness(0) invert(1);
}
.download-btn:hover {
background-color: var(--color-primary-hover);
transform: translateY(-2px);
}
.download-btn:active {
transform: scale(0.98);
}
.download-btn--circle {
width: 40px;
height: 40px;
border-radius: var(--radius-round);
padding: 0;
}
.download-btn--circle img {
margin-right: 0;
}
/* Header patterns */
.content-header {
display: flex;
align-items: center;
gap: var(--space-md);
margin-bottom: var(--space-xl);
padding-bottom: var(--space-md);
border-bottom: 1px solid var(--color-border);
}
.header-image {
width: 180px;
height: 180px;
object-fit: cover;
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
}
.header-info {
flex: 1;
}
.header-title {
font-size: 2rem;
margin-bottom: var(--space-sm);
font-weight: 700;
}
.header-subtitle {
font-size: 1.1rem;
color: var(--color-text-secondary);
margin-bottom: var(--space-xs);
}
.header-actions {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-md);
}
/* Track list styling */
.tracks-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin-top: var(--space-md);
}
.track-item {
display: grid;
grid-template-columns: 40px 1fr auto auto;
align-items: center;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
transition: background-color 0.2s ease;
}
.track-item:hover {
background-color: var(--color-surface-hover);
}
/* Utility classes */
.text-truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hidden {
display: none !important;
}
.flex {
display: flex;
}
.flex-column {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-sm {
gap: var(--space-sm);
}
.gap-md {
gap: var(--space-md);
}
/* Loading and error states */
.loading,
.error {
width: 100%;
text-align: center;
font-size: 1rem;
padding: var(--space-md);
}
.error {
color: var(--color-error);
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.content-header {
flex-direction: column;
text-align: center;
}
.header-image {
width: 150px;
height: 150px;
}
.header-title {
font-size: 1.75rem;
}
.track-item {
grid-template-columns: 30px 1fr auto;
}
}
@media (max-width: 480px) {
.app-container {
padding: var(--space-md);
}
.header-image {
width: 120px;
height: 120px;
}
.header-title {
font-size: 1.5rem;
}
.header-subtitle {
font-size: 0.9rem;
}
.header-actions {
flex-direction: column;
width: 100%;
}
.download-btn {
width: 100%;
}
/* Adjust floating icons size for very small screens */
.floating-icon {
width: 60px;
height: 60px;
}
.floating-icon img {
width: 28px;
height: 28px;
}
/* Position floating icons a bit closer to the edges on small screens */
.settings-icon {
left: 16px;
bottom: 16px;
}
.queue-icon {
right: 16px;
bottom: 16px;
}
}
/* Add styles for explicit content filter */
.explicit-filter-placeholder {
background-color: #2a2a2a;
border-radius: 8px;
padding: 2rem;
margin: 1rem 0;
text-align: center;
color: #f5f5f5;
border: 1px solid #444;
}
.explicit-filter-placeholder h2 {
color: #ff5555;
margin-bottom: 1rem;
}
.track-filtered {
opacity: 0.7;
}
.track-name.explicit-filtered {
color: #ff5555;
font-style: italic;
}
/* Add styles for disabled download buttons */
.download-btn--disabled {
background-color: #666;
cursor: not-allowed;
opacity: 0.7;
}
.download-btn--disabled:hover {
background-color: #666;
transform: none;
}
/* Add styles for download note in artist view */
.download-note {
color: var(--color-text-secondary);
font-style: italic;
font-size: 0.9rem;
margin-top: 0.5rem;
}
.watchlist-icon {
position: fixed;
right: 20px;
bottom: 90px; /* Positioned above the queue icon */
z-index: 1000;
}
/* Responsive adjustments for floating icons */
@media (max-width: 768px) {
.floating-icon {
width: 48px;
height: 48px;
right: 15px;
}
.settings-icon {
bottom: 15px; /* Adjust for smaller screens */
}
.queue-icon {
bottom: 15px; /* Adjust for smaller screens */
}
.watchlist-icon {
bottom: 75px; /* Adjust for smaller screens, above queue icon */
}
.home-btn.floating-icon { /* Specific for home button if it's also floating */
left: 15px;
bottom: 15px;
}
}
/* Ensure images inside btn-icon are sized correctly */
.btn-icon img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
}

View File

@@ -1,205 +0,0 @@
/* ICON STYLES */
.settings-icon img,
#queueIcon img {
width: 24px;
height: 24px;
vertical-align: middle;
filter: invert(1);
transition: opacity 0.3s;
}
.settings-icon:hover img,
#queueIcon:hover img {
opacity: 0.8;
}
#queueIcon {
background: none;
border: none;
cursor: pointer;
padding: 4px;
}
/* Style for the skull icon in the Cancel all button */
.skull-icon {
width: 16px;
height: 16px;
margin-right: 8px;
vertical-align: middle;
filter: brightness(0) invert(1); /* Makes icon white */
transition: transform 0.3s ease;
}
#cancelAllBtn:hover .skull-icon {
transform: rotate(-10deg) scale(1.2);
animation: skullShake 0.5s infinite alternate;
}
@keyframes skullShake {
0% { transform: rotate(-5deg); }
100% { transform: rotate(5deg); }
}
/* Style for the X that appears when the queue is visible */
.queue-x {
font-size: 28px;
font-weight: bold;
color: white;
line-height: 24px;
display: inline-block;
transform: translateY(-2px);
}
/* Queue icon with red tint when X is active */
.queue-icon-active {
background-color: #d13838 !important; /* Red background for active state */
transition: background-color 0.3s ease;
}
.queue-icon-active:hover {
background-color: #e04c4c !important; /* Lighter red on hover */
}
.download-icon,
.type-icon,
.toggle-chevron {
width: 16px;
height: 16px;
vertical-align: middle;
margin-right: 6px;
}
.toggle-chevron {
transition: transform 0.2s ease;
}
.option-btn .type-icon {
width: 18px;
height: 18px;
margin-right: 0.3rem;
}
/* Container for Title and Buttons */
.title-and-view {
display: flex;
align-items: center;
justify-content: space-between;
padding-right: 1rem; /* Extra right padding so buttons don't touch the edge */
}
/* Container for the buttons next to the title */
.title-buttons {
display: flex;
align-items: center;
}
/* Small Download Button Styles */
.download-btn-small {
background-color: #1db954; /* White background */
border: none;
border-radius: 50%; /* Circular shape */
padding: 6px; /* Adjust padding for desired size */
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 0.3s ease, transform 0.2s ease;
margin-left: 8px; /* Space between adjacent buttons */
}
.download-btn-small img {
width: 20px; /* Slightly bigger icon */
height: 20px;
filter: brightness(0) invert(1); /* Makes the icon white */
}
.download-btn-small:hover {
background-color: #1db954b4; /* Light gray on hover */
transform: translateY(-1px);
}
/* View Button Styles (unchanged) */
.view-btn {
background-color: #1db954;
border: none;
border-radius: 50%;
padding: 6px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 0.3s ease, transform 0.2s ease;
margin-left: 8px;
}
.view-btn img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
}
.view-btn:hover {
background-color: #1db954b0;
transform: translateY(-1px);
}
/* Mobile Compatibility Tweaks */
@media (max-width: 600px) {
.view-btn,
.download-btn-small {
padding: 6px 10px;
font-size: 13px;
margin: 4px;
}
}
/* Mobile compatibility tweaks */
@media (max-width: 600px) {
.view-btn {
padding: 6px 10px; /* Slightly larger padding on mobile for easier tap targets */
font-size: 13px; /* Ensure readability on smaller screens */
margin: 4px; /* Reduce margins to better fit mobile layouts */
}
}
/* Positioning for floating action buttons */
/* Base .floating-icon style is now in base.css */
/* Left-aligned buttons (Home, Settings, Back, History) */
.home-btn, .settings-icon, .back-button, .history-nav-btn {
left: 20px;
}
.settings-icon { /* Covers config, main */
bottom: 20px;
}
.home-btn { /* Covers album, artist, playlist, track, watch, history */
bottom: 20px;
}
.back-button { /* Specific to config page */
bottom: 20px;
}
/* New History button specific positioning - above other left buttons */
.history-nav-btn {
bottom: 80px; /* Positioned 60px above the buttons at 20px (48px button height + 12px margin) */
}
/* Right-aligned buttons (Queue, Watch) */
.queue-icon, .watch-nav-btn {
right: 20px;
z-index: 1002; /* Ensure these are above the sidebar (z-index: 1001) and other FABs (z-index: 1000) */
}
.queue-icon {
bottom: 20px;
}
/* Watch button specific positioning - above Queue */
.watch-nav-btn {
bottom: 80px; /* Positioned 60px above the queue button (48px button height + 12px margin) */
}

View File

@@ -1,338 +0,0 @@
/* GENERAL STYLING & UTILITIES */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
body {
/* Use a subtle dark gradient for a modern feel */
background: linear-gradient(135deg, #121212, #1e1e1e);
color: #ffffff;
min-height: 100vh;
}
/* Main container for page content */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
position: relative;
z-index: 1;
}
/* LOADING & ERROR STATES */
.loading,
.error,
.success {
width: 100%;
text-align: center;
font-size: 1rem;
padding: 1rem;
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
border-radius: 8px;
max-width: 80%;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.error {
color: #fff;
background-color: rgba(192, 57, 43, 0.9);
}
.success {
color: #fff;
background-color: rgba(46, 204, 113, 0.9);
}
/* Main search page specific styles */
/* Search header improvements */
.search-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 30px;
position: sticky;
top: 0;
background: rgba(18, 18, 18, 0.95);
backdrop-filter: blur(10px);
padding: 20px 0;
z-index: 100;
border-bottom: 1px solid var(--color-border);
}
.search-input-container {
display: flex;
flex: 1;
gap: 10px;
}
.search-input {
flex: 1;
padding: 12px 20px;
border: none;
border-radius: 25px;
background: var(--color-surface);
color: var(--color-text-primary);
font-size: 16px;
outline: none;
transition: background-color 0.3s ease, box-shadow 0.3s ease;
}
.search-input:focus {
background: var(--color-surface-hover);
box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.3);
}
.search-type {
padding: 12px 15px;
background: var(--color-surface);
border: none;
border-radius: 25px;
color: var(--color-text-primary);
cursor: pointer;
transition: background-color 0.3s ease;
min-width: 100px;
}
.search-type:hover,
.search-type:focus {
background: var(--color-surface-hover);
}
.search-button {
padding: 12px 25px;
background-color: var(--color-primary);
border: none;
border-radius: 25px;
color: white;
font-weight: 600;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.2s ease;
display: flex;
align-items: center;
gap: 8px;
}
.search-button img {
width: 18px;
height: 18px;
filter: brightness(0) invert(1);
}
.search-button:hover {
background-color: var(--color-primary-hover);
transform: translateY(-2px);
}
/* Empty state styles */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
text-align: center;
}
.empty-state-content {
max-width: 450px;
}
.empty-state-icon {
width: 80px;
height: 80px;
margin-bottom: 1.5rem;
opacity: 0.7;
}
.empty-state h2 {
font-size: 1.75rem;
margin-bottom: 1rem;
background: linear-gradient(90deg, var(--color-primary), #2ecc71);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.empty-state p {
color: var(--color-text-secondary);
font-size: 1rem;
line-height: 1.5;
}
/* Results grid improvement */
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 20px;
margin-top: 20px;
}
/* Result card style */
.result-card {
background: var(--color-surface);
border-radius: var(--radius-md);
overflow: hidden;
display: flex;
flex-direction: column;
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: var(--shadow-sm);
height: 100%;
}
.result-card:hover {
transform: translateY(-5px);
box-shadow: var(--shadow-md);
}
/* Album art styling */
.album-art-wrapper {
position: relative;
width: 100%;
overflow: hidden;
}
.album-art-wrapper::before {
content: "";
display: block;
padding-top: 100%;
}
.album-art {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.result-card:hover .album-art {
transform: scale(1.05);
}
/* Track title and details */
.track-title {
padding: 1rem 1rem 0.5rem;
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-artist {
padding: 0 1rem;
font-size: 0.9rem;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 0.75rem;
}
.track-details {
padding: 0.75rem 1rem;
font-size: 0.85rem;
color: var(--color-text-tertiary);
border-top: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
}
/* Download button within result cards */
.download-btn {
margin: 0 1rem 1rem;
max-width: calc(100% - 2rem); /* Ensure button doesn't overflow container */
width: auto; /* Allow button to shrink if needed */
font-size: 0.9rem; /* Slightly smaller font size */
padding: 0.6rem 1rem; /* Reduce padding slightly */
overflow: hidden; /* Hide overflow */
text-overflow: ellipsis; /* Add ellipsis for long text */
white-space: nowrap; /* Prevent wrapping */
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.search-header {
flex-wrap: wrap;
padding: 15px 0;
gap: 12px;
}
.search-input-container {
flex: 1 1 100%;
order: 1;
}
.search-button {
order: 2;
flex: 1;
}
.results-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 15px;
}
/* Smaller download button for mobile */
.download-btn {
padding: 0.5rem 0.8rem;
font-size: 0.85rem;
}
}
@media (max-width: 480px) {
.search-header {
padding: 10px 0;
}
.search-type {
min-width: 80px;
padding: 12px 10px;
}
.search-button {
padding: 12px 15px;
}
.results-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
}
.track-title, .track-artist {
font-size: 0.9rem;
}
.track-details {
font-size: 0.8rem;
}
/* Even smaller download button for very small screens */
.download-btn {
padding: 0.4rem 0.7rem;
font-size: 0.8rem;
margin: 0 0.8rem 0.8rem;
max-width: calc(100% - 1.6rem);
}
.empty-state h2 {
font-size: 1.5rem;
}
.empty-state p {
font-size: 0.9rem;
}
}

View File

@@ -1,562 +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;
}
/* Playlist Header */
#playlist-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;
}
#playlist-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;
}
#playlist-image:hover {
transform: scale(1.02);
}
#playlist-info {
flex: 1;
min-width: 0;
}
#playlist-name {
font-size: 2.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(90deg, #1db954, #17a44b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
#playlist-owner,
#playlist-stats,
#playlist-description {
font-size: 1.1rem;
color: #b3b3b3;
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: flex;
align-items: center;
padding: 1rem;
background: #181818;
border-radius: 8px;
transition: background 0.3s ease;
flex-wrap: wrap;
}
.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;
}
/* When displaying track album info on the side */
.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.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;
}
/* Style for icons within download buttons */
.download-btn img {
margin-right: 0.5rem; /* Space between icon and text */
width: 20px; /* Icon width */
height: 20px; /* Icon height */
vertical-align: middle; /* Align icon with text */
}
.download-btn:hover {
background-color: #17a44b;
}
.download-btn:active {
transform: scale(0.98);
}
/* Circular Variant for Compact Areas (e.g., in a queue list) */
.download-btn--circle {
width: 32px;
height: 32px;
padding: 0;
border-radius: 50%;
font-size: 0;
}
.download-btn--circle::before {
content: "↓";
font-size: 16px;
color: #fff;
display: inline-block;
}
/* Icon next to text */
.download-btn .btn-icon {
margin-right: 0.5rem;
display: inline-flex;
align-items: center;
}
/* 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); /* Makes the SVG icon appear white */
transition: transform 0.2s ease;
}
.home-btn:hover img {
transform: scale(1.05);
}
.home-btn:active img {
transform: scale(0.98);
}
/* Home Icon (SVG) */
.home-icon {
width: 24px;
height: 24px;
}
/* Download Queue Toggle Button */
.queue-toggle {
position: fixed;
bottom: 20px;
right: 20px;
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;
}
.queue-toggle:hover {
background: #1ed760;
transform: scale(1.05);
}
.queue-toggle:active {
transform: scale(1);
}
/* Responsive Styles */
/* Medium Devices (Tablets) */
@media (max-width: 768px) {
#playlist-header {
flex-direction: column;
align-items: center;
text-align: center;
}
#playlist-image {
width: 180px;
height: 180px;
margin-bottom: 1rem;
}
.track {
flex-direction: column;
align-items: flex-start;
}
.track-album,
.track-duration {
margin-left: 0;
margin-top: 0.5rem;
width: 100%;
text-align: left;
}
}
/* Small Devices (Mobile Phones) */
@media (max-width: 480px) {
#app {
padding: 10px;
}
#playlist-name {
font-size: 1.75rem;
}
/* Adjust track layout to vertical & centered */
.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;
}
}
/* Prevent anchor links from appearing all blue */
a {
color: inherit; /* Inherit color from the parent */
text-decoration: none; /* Remove default underline */
transition: color 0.2s ease;
}
a:hover,
a:focus {
color: #1db954; /* Change to a themed green on hover/focus */
text-decoration: underline;
}
/* Override for the circular download button variant */
.download-btn--circle {
width: 32px;
height: 32px;
padding: 0;
border-radius: 50%;
font-size: 0; /* Hide any text */
background-color: #1db954; /* Use the same green as the base button */
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;
}
/* Remove the default pseudo-element that inserts an arrow */
.download-btn--circle::before {
content: none;
}
/* Style the image inside the circular download button */
.download-btn--circle img {
width: 20px; /* Control icon size */
height: 20px;
filter: brightness(0) invert(1); /* Ensure the icon appears white */
display: block;
margin: 0; /* Explicitly remove any margin */
}
/* Hover and active states for the circular download button */
.download-btn--circle:hover {
background-color: #17a44b;
transform: scale(1.05);
}
.download-btn--circle:active {
transform: scale(0.98);
}
/* Playlist page specific styles */
/* Playlist description */
.playlist-description {
font-size: 0.9rem;
color: var(--color-text-secondary);
margin-top: 0.75rem;
max-width: 90%;
line-height: 1.5;
}
/* Additional column for album in playlist tracks */
.track-album {
font-size: 0.9rem;
color: var(--color-text-secondary);
margin-right: 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
/* Overriding the track layout for playlists to include the album column */
.track {
grid-template-columns: 40px 1fr 1fr auto auto;
}
/* Style for the download albums button */
#downloadAlbumsBtn {
background-color: rgba(255, 255, 255, 0.1);
}
#downloadAlbumsBtn:hover {
background-color: rgba(255, 255, 255, 0.2);
}
/* Mobile responsiveness adjustments */
@media (max-width: 1024px) {
.track {
grid-template-columns: 40px 1fr auto auto;
}
.track-album {
display: none;
}
}
@media (max-width: 768px) {
.playlist-description {
max-width: 100%;
}
#downloadAlbumsBtn {
margin-top: 0.5rem;
}
}
@media (max-width: 480px) {
.track {
grid-template-columns: 30px 1fr auto;
}
.playlist-description {
margin-bottom: 1rem;
}
}
/* Notification Styling */
.notification {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background-color: #333;
color: #fff;
padding: 10px 20px;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
z-index: 1005; /* Ensure it's above most other elements */
opacity: 0;
transition: opacity 0.5s ease-in-out;
animation: fadeInOut 3s ease-in-out;
}
@keyframes fadeInOut {
0% { opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { opacity: 0; }
}
/* Watch and Sync Button Specific Styles */
.watch-btn {
background-color: #535353; /* A neutral dark gray */
}
.watch-btn:hover {
background-color: #6f6f6f;
}
.sync-btn {
background-color: #28a745; /* A distinct green for sync */
}
.sync-btn:hover {
background-color: #218838;
}
.sync-btn.hidden {
display: none;
}
/* Toggle Known Status Button for Tracks/Albums */
.toggle-known-status-btn {
width: 32px;
height: 32px;
padding: 0;
border-radius: 50%;
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
margin-left: 0.5rem; /* Spacing from other buttons if any */
}
.toggle-known-status-btn img {
width: 18px; /* Adjust icon size as needed */
height: 18px;
filter: brightness(0) invert(1); /* White icon */
}
.toggle-known-status-btn[data-status="known"] {
background-color: #28a745; /* Green for known/available */
}
.toggle-known-status-btn[data-status="known"]:hover {
background-color: #218838; /* Darker green on hover */
}
.toggle-known-status-btn[data-status="missing"] {
background-color: #dc3545; /* Red for missing */
}
.toggle-known-status-btn[data-status="missing"]:hover {
background-color: #c82333; /* Darker red on hover */
}
.toggle-known-status-btn:active {
transform: scale(0.95);
}
.track-actions-container {
display: flex;
align-items: center;
margin-left: auto; /* Pushes action buttons to the right */
}

View File

@@ -1,825 +0,0 @@
/* ---------------------- */
/* DOWNLOAD QUEUE STYLES */
/* ---------------------- */
/* Container for the download queue sidebar */
#downloadQueue {
position: fixed;
top: 0;
right: -350px; /* Hidden offscreen by default */
width: 350px;
height: 100vh;
background: #181818;
padding: 20px;
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1001;
/* Remove overflow-y here to delegate scrolling to the queue items container */
box-shadow: -20px 0 30px rgba(0, 0, 0, 0.4);
/* Added for flex layout */
display: flex;
flex-direction: column;
}
/* When active, the sidebar slides into view */
#downloadQueue.active {
right: 0;
}
/* Header inside the queue sidebar */
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 15px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 20px;
}
.sidebar-header h2 {
font-size: 1.25rem;
font-weight: 600;
color: #fff;
margin: 0;
}
/* Queue subtitle with statistics */
.queue-subtitle {
display: flex;
gap: 10px;
margin-top: 5px;
font-size: 0.8rem;
color: #b3b3b3;
}
.queue-stat {
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
}
.queue-stat-active {
color: #4a90e2;
background-color: rgba(74, 144, 226, 0.1);
}
.queue-stat-completed {
color: #1DB954;
background-color: rgba(29, 185, 84, 0.1);
}
.queue-stat-error {
color: #ff5555;
background-color: rgba(255, 85, 85, 0.1);
}
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
/* Refresh queue button */
#refreshQueueBtn {
background: #2a2a2a;
border: none;
color: #fff;
padding: 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.3s ease, transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
#refreshQueueBtn:hover {
background: #333;
transform: translateY(-1px);
}
#refreshQueueBtn:active {
transform: scale(0.95);
}
#refreshQueueBtn.refreshing {
animation: spin 1s linear infinite;
}
/* Artist queue message */
.queue-artist-message {
background: #2a2a2a;
padding: 15px;
border-radius: 8px;
margin-bottom: 15px;
color: #fff;
text-align: center;
border-left: 4px solid #4a90e2;
animation: pulse 1.5s infinite;
font-weight: 500;
}
@keyframes pulse {
0% { opacity: 0.8; }
50% { opacity: 1; }
100% { opacity: 0.8; }
}
/* Cancel all button styling */
#cancelAllBtn {
background: #8b0000; /* Dark blood red */
border: none;
color: #fff;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
transition: background 0.3s ease, transform 0.2s ease;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
#cancelAllBtn:hover {
background: #a30000; /* Slightly lighter red on hover */
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
}
#cancelAllBtn:active {
transform: scale(0.98);
}
/* Close button for the queue sidebar */
.close-btn {
background: #2a2a2a;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 20px;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.2s ease;
}
.close-btn:hover {
background-color: #333;
transform: scale(1.05);
}
.close-btn:active {
transform: scale(0.95);
}
/* Container for all queue items */
#queueItems {
/* Allow the container to fill all available space in the sidebar */
flex: 1;
overflow-y: auto;
padding-right: 5px; /* Add slight padding for scrollbar */
scrollbar-width: thin;
scrollbar-color: #1DB954 rgba(255, 255, 255, 0.1);
}
/* Custom scrollbar styles */
#queueItems::-webkit-scrollbar {
width: 6px;
}
#queueItems::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
#queueItems::-webkit-scrollbar-thumb {
background-color: #1DB954;
border-radius: 10px;
}
/* Each download queue item */
.queue-item {
background: #2a2a2a;
padding: 15px;
border-radius: 8px;
margin-bottom: 15px;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
border-left: 4px solid transparent;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
/* Animation only for newly added items */
.queue-item-new {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
.queue-item:hover {
background-color: #333;
transform: translateY(-5px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
/* Title text in a queue item */
.queue-item .title {
font-weight: 600;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #fff;
font-size: 14px;
}
/* Type indicator (e.g. track, album) */
.queue-item .type {
font-size: 11px;
color: #1DB954;
text-transform: uppercase;
letter-spacing: 0.7px;
font-weight: 600;
background-color: rgba(29, 185, 84, 0.1);
padding: 3px 6px;
border-radius: 4px;
display: inline-block;
width: fit-content;
}
/* Album type - for better visual distinction */
.queue-item .type.album {
color: #4a90e2;
background-color: rgba(74, 144, 226, 0.1);
}
/* Track type */
.queue-item .type.track {
color: #1DB954;
background-color: rgba(29, 185, 84, 0.1);
}
/* Playlist type */
.queue-item .type.playlist {
color: #e67e22;
background-color: rgba(230, 126, 34, 0.1);
}
/* Log text for status messages */
.queue-item .log {
font-size: 13px;
color: #b3b3b3;
line-height: 1.4;
font-family: 'SF Mono', Menlo, monospace;
padding: 8px 0;
word-break: break-word;
}
/* Optional state indicators for each queue item */
.queue-item--complete,
.queue-item.download-success {
border-left-color: #1DB954;
}
.queue-item--error {
border-left-color: #ff5555;
}
.queue-item--processing {
border-left-color: #4a90e2;
}
/* Progress bar for downloads */
.status-bar {
height: 3px;
background: #1DB954;
width: 0;
transition: width 0.3s ease;
margin-top: 8px;
border-radius: 2px;
}
/* Overall progress container for albums and playlists */
.overall-progress-container {
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
position: relative; /* Positioning context for z-index */
z-index: 2; /* Ensure overall progress appears above track progress */
}
.overall-progress-header {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
font-size: 11px;
color: #b3b3b3;
}
.overall-progress-label {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.overall-progress-count {
font-weight: 600;
color: #1DB954;
}
.overall-progress-bar-container {
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
overflow: hidden;
}
.overall-progress-bar {
height: 100%;
background: linear-gradient(90deg, #4a90e2, #7a67ee); /* Changed to blue-purple gradient */
width: 0;
border-radius: 3px;
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.overall-progress-bar.complete {
background: #4a90e2; /* Changed to solid blue for completed overall progress */
}
/* Track progress bar container */
.track-progress-bar-container {
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
margin-top: 8px;
margin-bottom: 4px;
position: relative;
z-index: 1; /* Ensure it's below the overall progress */
}
/* Track progress bar */
.track-progress-bar {
height: 100%;
background: #1DB954; /* Keep green for track-level progress */
width: 0;
border-radius: 2px;
transition: width 0.3s ease;
box-shadow: 0 0 3px rgba(29, 185, 84, 0.5); /* Add subtle glow to differentiate */
}
/* Complete state for track progress */
/* Real-time progress style */
.track-progress-bar.real-time {
background: #1DB954; /* Vivid green for real-time progress */
background: #1DB954;
}
/* Pulsing animation for indeterminate progress */
.track-progress-bar.progress-pulse {
background: linear-gradient(90deg, #1DB954 0%, #2cd267 50%, #1DB954 100%); /* Keep in green family */
background-size: 200% 100%;
animation: progress-pulse-slide 1.5s ease infinite;
}
@keyframes progress-pulse-slide {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* Progress percentage text */
.progress-percent {
text-align: right;
font-weight: bold;
font-size: 12px;
color: #1DB954;
margin-top: 4px;
}
/* Optional status message colors (if using state classes) */
.log--success {
color: #1DB954 !important;
}
.log--error {
color: #ff5555 !important;
}
.log--warning {
color: #ffaa00 !important;
}
.log--info {
color: #4a90e2 !important;
}
/* Loader animations for real-time progress */
@keyframes progress-pulse {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}
.progress-indicator {
display: inline-block;
margin-left: 8px;
animation: progress-pulse 1.5s infinite;
}
/* Loading spinner style */
.loading-spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #1DB954;
animation: spin 1s ease-in-out infinite;
margin-right: 6px;
vertical-align: middle;
}
.loading-spinner.small {
width: 10px;
height: 10px;
border-width: 1px;
margin-right: 4px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Cancel button inside each queue item */
.cancel-btn {
background: none;
border: none;
cursor: pointer;
padding: 5px;
outline: none;
margin-top: 10px;
/* Optionally constrain the overall size */
max-width: 24px;
max-height: 24px;
position: absolute;
top: 10px;
right: 10px;
opacity: 0.7;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.cancel-btn:hover {
opacity: 1;
}
.cancel-btn img {
width: 16px;
height: 16px;
filter: invert(1);
transition: transform 0.3s ease;
}
.cancel-btn:hover img {
transform: scale(1.1);
}
.cancel-btn:active img {
transform: scale(0.9);
}
/* Group header for multiple albums from same artist */
.queue-group-header {
font-size: 14px;
color: #b3b3b3;
margin: 15px 0 10px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
}
.queue-group-header span {
display: flex;
align-items: center;
}
.queue-group-header span::before {
content: '';
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #1DB954;
margin-right: 8px;
}
/* ------------------------------- */
/* FOOTER & "SHOW MORE" BUTTON */
/* ------------------------------- */
#queueFooter {
text-align: center;
padding-top: 15px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin-top: 10px;
}
#queueFooter button {
background: #1DB954;
border: none;
padding: 10px 18px;
border-radius: 20px;
color: #fff;
cursor: pointer;
transition: all 0.3s ease;
font-size: 14px;
font-weight: 500;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
#queueFooter button:hover {
background: #17a448;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
#queueFooter button:active {
transform: scale(0.98);
}
/* -------------------------- */
/* ERROR BUTTONS STYLES */
/* -------------------------- */
/* Container for error action buttons */
.error-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}
/* ----------------------------- */
/* DOWNLOAD SUMMARY ICONS */
/* ----------------------------- */
/* Base styles for all summary icons */
.summary-icon {
width: 14px;
height: 14px;
vertical-align: middle;
margin-right: 4px;
margin-top: -2px;
}
/* Download summary formatting */
.download-summary {
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
padding: 12px;
margin-top: 5px;
}
.summary-line {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.summary-line span {
display: flex;
align-items: center;
padding: 3px 8px;
border-radius: 4px;
font-weight: 500;
}
/* Specific icon background colors */
.summary-line span:nth-child(2) {
background: rgba(29, 185, 84, 0.1); /* Success background */
}
.summary-line span:nth-child(3) {
background: rgba(230, 126, 34, 0.1); /* Skip background */
}
.summary-line span:nth-child(4) {
background: rgba(255, 85, 85, 0.1); /* Failed background */
}
/* Failed tracks list styling */
.failed-tracks-title {
color: #ff5555;
font-weight: 600;
margin: 10px 0 5px;
font-size: 13px;
}
.failed-tracks-list {
list-style-type: none;
padding-left: 10px;
margin: 0;
font-size: 12px;
color: #b3b3b3;
max-height: 100px;
overflow-y: auto;
}
.failed-tracks-list li {
padding: 3px 0;
position: relative;
}
.failed-tracks-list li::before {
content: "•";
color: #ff5555;
position: absolute;
left: -10px;
}
/* Base styles for error buttons */
.error-buttons button {
border: none;
border-radius: 4px;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
/* Hover state for all error buttons */
.error-buttons button:hover {
transform: translateY(-2px);
}
.error-buttons button:active {
transform: translateY(0);
}
/* Specific styles for the Close (X) error button */
.close-error-btn {
background-color: #333;
color: #fff;
}
.close-error-btn:hover {
background-color: #444;
}
/* Specific styles for the Retry button */
.retry-btn {
background-color: #ff5555;
color: #fff;
padding: 6px 15px !important;
}
.retry-btn:hover {
background-color: #ff6b6b;
}
/* Empty queue state */
.queue-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: #b3b3b3;
text-align: center;
padding: 20px;
}
.queue-empty img {
width: 60px;
height: 60px;
margin-bottom: 15px;
opacity: 0.6;
}
.queue-empty p {
font-size: 14px;
line-height: 1.5;
}
/* Error notification in queue */
.queue-error {
background-color: rgba(192, 57, 43, 0.1);
color: #ff5555;
padding: 10px 15px;
border-radius: 8px;
margin-bottom: 15px;
font-size: 14px;
border-left: 3px solid #ff5555;
animation: fadeIn 0.3s ease;
}
/* Error state styling */
.queue-item.error {
border-left: 4px solid #ff5555;
background-color: rgba(255, 85, 85, 0.05);
transition: none !important; /* Remove all transitions */
transform: none !important; /* Prevent any transform */
position: relative !important; /* Keep normal positioning */
left: 0 !important; /* Prevent any left movement */
right: 0 !important; /* Prevent any right movement */
top: 0 !important; /* Prevent any top movement */
}
.queue-item.error:hover {
background-color: rgba(255, 85, 85, 0.1);
transform: none !important; /* Force disable any transform */
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2) !important; /* Keep original shadow */
position: relative !important; /* Force normal positioning */
left: 0 !important; /* Prevent any left movement */
right: 0 !important; /* Prevent any right movement */
top: 0 !important; /* Prevent any top movement */
}
.error-message {
color: #ff5555;
margin-bottom: 10px;
font-size: 13px;
line-height: 1.4;
}
/* ------------------------------- */
/* MOBILE RESPONSIVE ADJUSTMENTS */
/* ------------------------------- */
@media (max-width: 600px) {
/* Make the sidebar full width on mobile */
#downloadQueue {
width: 100%;
right: -100%; /* Off-screen fully */
padding: 15px;
}
/* When active, the sidebar slides into view from full width */
#downloadQueue.active {
right: 0;
}
/* Adjust header and title for smaller screens */
.sidebar-header {
flex-direction: row;
align-items: center;
padding-bottom: 12px;
margin-bottom: 15px;
}
.sidebar-header h2 {
font-size: 1.1rem;
}
/* Reduce the size of the close buttons */
.close-btn {
width: 28px;
height: 28px;
font-size: 18px;
}
/* Adjust queue items padding */
.queue-item {
padding: 12px;
margin-bottom: 12px;
}
/* Ensure text remains legible on smaller screens */
.queue-item .log,
.queue-item .type {
font-size: 12px;
}
#cancelAllBtn {
padding: 6px 10px;
font-size: 12px;
}
.error-buttons {
flex-direction: row;
}
.close-error-btn {
width: 28px;
height: 28px;
}
.retry-btn {
padding: 6px 12px !important;
}
}

View File

@@ -1,360 +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;
}
/* App Container */
#app {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
position: relative;
z-index: 1;
}
/* Track Header:
We assume an HTML structure like:
<div id="track-header">
<img id="track-album-image" ... />
<div id="track-info">
... (track details: name, artist, album, duration, explicit)
</div>
<!-- Download button will be appended here -->
</div>
*/
#track-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #2a2a2a;
flex-wrap: wrap;
}
/* Album Image */
#track-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;
}
#track-album-image:hover {
transform: scale(1.02);
}
/* Track Info */
#track-info {
flex: 1;
min-width: 0;
/* For mobile, the text block can wrap if needed */
}
/* Track Text Elements */
#track-name {
font-size: 2.5rem;
margin-bottom: 0.5rem;
background: linear-gradient(90deg, #1db954, #17a44b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
#track-artist,
#track-album,
#track-duration,
#track-explicit {
font-size: 1.1rem;
color: #b3b3b3;
margin-bottom: 0.5rem;
}
/* Download Button */
.download-btn {
background-color: #1db954;
border: none;
border-radius: 4px;
padding: 0.6rem;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Download Button Icon */
.download-btn img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
display: block;
}
/* Hover and Active States */
.download-btn:hover {
background-color: #17a44b;
transform: scale(1.05);
}
.download-btn:active {
transform: scale(0.98);
}
/* Home Button Styling */
.home-btn {
background-color: transparent;
border: none;
cursor: pointer;
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);
}
/* Loading and Error Messages */
#loading,
#error {
width: 100%;
text-align: center;
font-size: 1rem;
padding: 1rem;
}
#error {
color: #c0392b;
}
/* Utility class to hide elements */
.hidden {
display: none !important;
}
/* Responsive Styles for Tablets and Smaller Devices */
@media (max-width: 768px) {
#app {
padding: 15px;
}
#track-header {
flex-direction: column;
align-items: center;
text-align: center;
}
#track-album-image {
width: 180px;
height: 180px;
}
#track-name {
font-size: 2rem;
}
#track-artist,
#track-album,
#track-duration,
#track-explicit {
font-size: 1rem;
}
.download-btn {
padding: 0.5rem;
margin-top: 0.8rem;
}
}
/* Responsive Styles for Mobile Phones */
@media (max-width: 480px) {
#track-album-image {
width: 150px;
height: 150px;
}
#track-name {
font-size: 1.75rem;
}
#track-artist,
#track-album,
#track-duration,
#track-explicit {
font-size: 0.9rem;
}
.download-btn {
padding: 0.5rem;
margin-top: 0.8rem;
}
}
/* 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;
}
/* Ensure the header lays out its children with space-between */
#track-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #2a2a2a;
flex-wrap: wrap;
}
/* (Optional) If you need to style the download button specifically: */
.download-btn {
background-color: #1db954;
border: none;
border-radius: 4px;
padding: 0.6rem;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Style the download button's icon */
.download-btn img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
display: block;
}
/* Hover and active states */
.download-btn:hover {
background-color: #17a44b;
transform: scale(1.05);
}
.download-btn:active {
transform: scale(0.98);
}
/* Responsive adjustments remain as before */
@media (max-width: 768px) {
#track-header {
flex-direction: column;
align-items: center;
text-align: center;
}
}
/* Track page specific styles */
/* Track details formatting */
.track-details {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.track-detail-item {
display: flex;
align-items: center;
}
/* Make explicit tag stand out if needed */
#track-explicit:not(:empty) {
background-color: rgba(255, 255, 255, 0.1);
padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Loading indicator animation */
.loading-indicator {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.loading-indicator:after {
content: " ";
display: block;
width: 40px;
height: 40px;
margin: 8px;
border-radius: 50%;
border: 4px solid var(--color-primary);
border-color: var(--color-primary) transparent var(--color-primary) transparent;
animation: loading-rotation 1.2s linear infinite;
}
@keyframes loading-rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Modern gradient for the track name */
#track-name a {
background: linear-gradient(90deg, var(--color-primary), #2ecc71);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
display: inline-block;
}
/* Ensure proper spacing for album and artist links */
#track-artist a,
#track-album a {
transition: color 0.2s ease, text-decoration 0.2s ease;
}
#track-artist a:hover,
#track-album a:hover {
color: var(--color-primary);
}
/* Mobile-specific adjustments */
@media (max-width: 768px) {
.track-details {
flex-direction: column;
gap: 0.25rem;
margin-bottom: 1rem;
}
#track-name a {
font-size: 1.75rem;
}
}
@media (max-width: 480px) {
#track-name a {
font-size: 1.5rem;
}
.track-details {
margin-bottom: 1.5rem;
}
}

View File

@@ -1,359 +0,0 @@
/* static/css/watch/watch.css */
/* General styles for the watch page, similar to main.css */
body {
font-family: var(--font-family-sans-serif);
background-color: var(--background-color);
color: white;
margin: 0;
padding: 0;
}
.app-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.watch-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 15px;
border-bottom: 1px solid var(--border-color-soft);
}
.watch-header h1 {
color: white;
font-size: 2em;
margin: 0;
}
.check-all-btn {
padding: 10px 15px;
font-size: 0.9em;
display: flex;
align-items: center;
gap: 8px; /* Space between icon and text */
background-color: var(--color-accent-green); /* Green background */
color: white; /* Ensure text is white for contrast */
border: none; /* Remove default border */
}
.check-all-btn:hover {
background-color: var(--color-accent-green-dark); /* Darker green on hover */
}
.check-all-btn img {
width: 18px; /* Slightly larger for header button */
height: 18px;
filter: brightness(0) invert(1); /* Ensure header icon is white */
}
.back-to-search-btn {
padding: 10px 20px;
font-size: 0.9em;
}
/* Styling for the grid of watched items, similar to results-grid */
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); /* Responsive grid */
gap: 20px;
padding: 0;
}
/* Individual watched item card styling, inspired by result-card from main.css */
.watched-item-card {
background-color: var(--color-surface);
border-radius: var(--border-radius-medium);
padding: 15px;
box-shadow: var(--shadow-soft);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
cursor: pointer;
position: relative;
}
.watched-item-card:hover {
transform: translateY(-5px);
box-shadow: var(--shadow-medium);
border-top: 1px solid var(--border-color-soft);
}
.item-art-wrapper {
width: 100%;
padding-bottom: 100%; /* 1:1 Aspect Ratio */
position: relative;
margin-bottom: 15px;
border-radius: var(--border-radius-soft);
overflow: hidden;
}
.item-art {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover; /* Cover the area, cropping if necessary */
}
.item-name {
font-size: 1.1em;
font-weight: bold;
color: white;
margin-bottom: 5px;
display: -webkit-box;
-webkit-line-clamp: 2; /* Limit to 2 lines */
-webkit-box-orient: vertical;
line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
min-height: 2.4em; /* Reserve space for two lines */
}
.item-details {
font-size: 0.9em;
color: white;
margin-bottom: 10px;
line-height: 1.4;
width: 100%; /* Ensure it takes full width for centering/alignment */
}
.item-details span {
display: block; /* Each detail on a new line */
margin-bottom: 3px;
}
.item-type-badge {
display: inline-block;
padding: 3px 8px;
font-size: 0.75em;
font-weight: bold;
border-radius: var(--border-radius-small);
margin-bottom: 10px;
text-transform: uppercase;
}
.item-type-badge.artist {
background-color: var(--color-accent-blue-bg);
color: var(--color-accent-blue-text);
}
.item-type-badge.playlist {
background-color: var(--color-accent-green-bg);
color: var(--color-accent-green-text);
}
/* Action buttons (e.g., Go to item, Unwatch) */
.item-actions {
margin-top: auto;
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 10px;
border-top: 1px solid var(--border-color-soft);
}
.item-actions .btn-icon {
padding: 0;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0;
border: none;
}
.item-actions .check-item-now-btn {
background-color: var(--color-accent-green);
}
.item-actions .check-item-now-btn:hover {
background-color: var(--color-accent-green-dark);
}
.item-actions .check-item-now-btn img,
.item-actions .unwatch-item-btn img {
width: 16px;
height: 16px;
filter: brightness(0) invert(1);
}
.item-actions .unwatch-item-btn {
background-color: var(--color-error);
color: white;
}
.item-actions .unwatch-item-btn:hover {
background-color: #a52a2a;
}
/* Loading and Empty State - reuse from main.css if possible or define here */
.loading,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 40px 20px;
color: var(--text-color-muted);
width: 100%;
}
.loading.hidden,
.empty-state.hidden {
display: none;
}
.loading-indicator {
font-size: 1.2em;
margin-bottom: 10px;
color: white;
}
.empty-state-content {
max-width: 400px;
}
.empty-state-icon {
width: 80px;
height: 80px;
margin-bottom: 20px;
opacity: 0.7;
filter: brightness(0) invert(1); /* Added to make icon white */
}
.empty-state h2 {
font-size: 1.5em;
color: white;
margin-bottom: 10px;
}
.empty-state p {
font-size: 1em;
line-height: 1.5;
color: white;
}
/* Ensure floating icons from base.css are not obscured or mispositioned */
/* No specific overrides needed if base.css handles them well */
/* Responsive adjustments if needed */
@media (max-width: 768px) {
.results-grid {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
.watch-header h1 {
font-size: 1.5em;
}
.watched-group-header {
font-size: 1.5rem;
}
}
@media (max-width: 480px) {
.results-grid {
grid-template-columns: 1fr; /* Single column on very small screens */
}
.watched-item-card {
padding: 10px;
}
.item-name {
font-size: 1em;
}
.item-details {
font-size: 0.8em;
}
}
.watched-items-group {
margin-bottom: 2rem; /* Space between groups */
}
.watched-group-header {
font-size: 1.8rem;
color: var(--color-text-primary);
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.empty-group-message {
color: var(--color-text-secondary);
padding: 1rem;
text-align: center;
font-style: italic;
}
/* Ensure the main watchedItemsContainer still behaves like a grid if there are few items */
#watchedItemsContainer:not(:has(.watched-items-group)) {
display: grid;
/* Assuming results-grid styles are already defined elsewhere,
or copy relevant grid styles here if needed */
}
/* Notification Toast Styles */
#notificationArea {
position: fixed;
bottom: 20px;
left: 50%; /* Center horizontally */
transform: translateX(-50%); /* Adjust for exact centering */
z-index: 2000;
display: flex;
flex-direction: column-reverse;
gap: 10px;
width: auto; /* Allow width to be determined by content */
max-width: 90%; /* Prevent it from being too wide on large screens */
}
.notification-toast {
padding: 12px 20px;
border-radius: var(--border-radius-medium);
color: white; /* Default text color to white */
font-size: 0.9em;
box-shadow: var(--shadow-strong);
opacity: 1;
transition: opacity 0.5s ease, transform 0.5s ease;
transform: translateX(0); /* Keep this for the hide animation */
text-align: center; /* Center text within the toast */
}
.notification-toast.success {
background-color: var(--color-success); /* Use existing success color */
/* color: var(--color-accent-green-text); REMOVE - use white */
/* border: 1px solid var(--color-accent-green-text); REMOVE */
}
.notification-toast.error {
background-color: var(--color-error); /* Use existing error color */
/* color: var(--color-accent-red-text); REMOVE - use white */
/* border: 1px solid var(--color-accent-red-text); REMOVE */
}
.notification-toast.hide {
opacity: 0;
transform: translateY(100%); /* Slide down for exit, or could keep translateX if preferred */
}
@keyframes spin-counter-clockwise {
from {
transform: rotate(0deg);
}
to {
transform: rotate(-360deg);
}
}
.spin-counter-clockwise {
animation: spin-counter-clockwise 1s linear infinite;
}

View File

@@ -1,72 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Album Viewer - Spotizerr</title>
<!-- Add the new base.css first -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/base.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/album/album.css') }}" />
</head>
<body>
<div class="app-container">
<div id="album-header" class="content-header hidden">
<!-- Album Image -->
<img id="album-image" class="header-image" alt="Album cover" onerror="this.src='/static/images/placeholder.jpg'">
<!-- Album Info -->
<div id="album-info" class="header-info">
<h1 id="album-name" class="header-title"></h1>
<p id="album-artist" class="header-subtitle"></p>
<p id="album-stats" class="header-subtitle"></p>
<p id="album-copyright" class="album-copyright"></p>
<!-- Download Button -->
<div class="header-actions">
<button id="downloadAlbumBtn" class="download-btn btn-primary">
<img src="{{ url_for('static', filename='images/download.svg') }}" alt="Download">
Download Full Album
</button>
</div>
</div>
</div>
<div id="tracks-container" class="hidden">
<h2 class="section-title">Tracks</h2>
<div id="tracks-list" class="tracks-list"></div>
</div>
<!-- Loading and Error states -->
<div id="loading" class="loading">
<div class="loading-indicator">Loading...</div>
</div>
<div id="error" class="error hidden">Error loading album</div>
</div>
<!-- Fixed floating buttons for home and queue -->
<a href="/history" class="btn-icon history-nav-btn floating-icon home-btn" aria-label="Download History" title="Go to Download History">
<img src="{{ url_for('static', filename='images/history.svg') }}" alt="History" onerror="handleImageError(this)"/>
</a>
<button id="homeButton" class="btn-icon home-btn floating-icon settings-icon" aria-label="Return to home">
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home">
</button>
<a id="watchlistButton" href="/watchlist" class="btn-icon watchlist-icon floating-icon hidden" aria-label="Watchlist" title="Go to Watchlist">
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Watchlist" onerror="handleImageError(this)"/>
</a>
<button
id="queueIcon"
class="btn-icon queue-icon floating-icon"
aria-label="Download queue"
aria-controls="downloadQueue"
aria-expanded="false"
>
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="Queue Icon">
</button>
<script type="module" src="{{ url_for('static', filename='js/album.js') }}"></script>
</body>
</html>

View File

@@ -1,77 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Artist Viewer - Spotizerr</title>
<!-- Add the new base.css first -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/base.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/artist/artist.css') }}" />
</head>
<body>
<div class="app-container">
<!-- Artist header container -->
<div id="artist-header" class="content-header hidden">
<!-- Artist Image -->
<img id="artist-image" class="header-image" alt="Artist image" onerror="this.src='/static/images/placeholder.jpg'">
<!-- Artist Info -->
<div id="artist-info" class="header-info">
<h1 id="artist-name" class="header-title"></h1>
<p id="artist-stats" class="header-subtitle"></p>
<!-- Download Button -->
<div class="header-actions">
<button id="downloadArtistBtn" class="download-btn btn-primary">
<img src="{{ url_for('static', filename='images/download.svg') }}" alt="Download">
Download All Discography
</button>
<button id="watchArtistBtn" class="watch-btn btn-secondary"> <img src="{{ url_for('static', filename='images/eye.svg') }}" alt="Watch"> Watch Artist </button>
<button id="syncArtistBtn" class="download-btn sync-btn hidden">
<img src="{{ url_for('static', filename='images/refresh.svg') }}" alt="Sync">
Sync Watched Artist
</button>
</div>
</div>
</div>
<!-- Albums container -->
<div id="albums-container" class="hidden">
<!-- This container will hold one section per album type -->
<div id="album-groups" class="album-groups"></div>
</div>
<!-- Loading and Error states -->
<div id="loading" class="loading">
<div class="loading-indicator">Loading...</div>
</div>
<div id="error" class="error hidden">Error loading artist info</div>
</div>
<!-- Fixed floating buttons for home and queue -->
<a href="/history" class="btn-icon history-nav-btn floating-icon home-btn" aria-label="Download History" title="Go to Download History">
<img src="{{ url_for('static', filename='images/history.svg') }}" alt="History" onerror="handleImageError(this)"/>
</a>
<button id="homeButton" class="btn-icon home-btn floating-icon settings-icon" aria-label="Return to home">
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home">
</button>
<a id="watchlistButton" href="/watchlist" class="btn-icon watchlist-icon floating-icon hidden" aria-label="Watchlist" title="Go to Watchlist">
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Watchlist" onerror="handleImageError(this)"/>
</a>
<button
id="queueIcon"
class="btn-icon queue-icon floating-icon"
aria-label="Download queue"
aria-controls="downloadQueue"
aria-expanded="false"
>
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="Queue Icon">
</button>
<script type="module" src="{{ url_for('static', filename='js/artist.js') }}"></script>
</body>
</html>

View File

@@ -1,412 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Configuration - Spotizerr</title>
<!-- Add the new base.css first -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/base.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/config/config.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
</head>
<body>
<div class="app-container">
<div class="config-container">
<header class="config-header">
<h1 class="header-title">Configuration</h1>
<span class="version-text">Set on build</span>
</header>
<div class="account-config card">
<h2 class="section-title">Download Settings</h2>
<!-- Default service selection - new element -->
<div class="config-item">
<label>Default Service:</label>
<select id="defaultServiceSelect" class="form-select">
<option value="spotify">Spotify</option>
</select>
<div class="setting-description">
The default service to use for downloads when not explicitly specified
</div>
</div>
<!-- Your account config section remains unchanged -->
<div class="config-item spotify-specific">
<label>Active Spotify Account:</label>
<select id="spotifyAccountSelect" class="form-select"></select>
<div id="spotifyAccountMessage" style="display: none; color: #888; margin-top: 5px;"></div>
</div>
<div class="config-item spotify-specific">
<label>Spotify Quality:</label>
<select id="spotifyQualitySelect" class="form-select">
<option value="NORMAL">OGG 96</option>
<option value="HIGH">OGG 160</option>
<option value="VERY_HIGH">OGG 320 (premium)</option>
</select>
</div>
<div class="config-item deezer-specific">
<label>Active Deezer Account:</label>
<select id="deezerAccountSelect" class="form-select"></select>
<div id="deezerAccountMessage" style="display: none; color: #888; margin-top: 5px;"></div>
</div>
<div class="config-item deezer-specific">
<label>Deezer Quality:</label>
<select id="deezerQualitySelect" class="form-select">
<option value="MP3_128">MP3 128</option>
<option value="MP3_320">MP3 320 (sometimes premium)</option>
<option value="FLAC">FLAC (premium)</option>
</select>
</div>
<!-- Explicit Filter Status -->
<div class="config-item">
<label>Explicit Content Filter:</label>
<div class="env-controlled-setting">
<span id="explicitFilterStatus" class="env-controlled-value">Loading...</span>
<div class="env-controlled-badge">ENV</div>
</div>
<div class="setting-description">
Filter explicit content. Controlled by environment variable EXPLICIT_FILTER.
</div>
</div>
<div class="config-item">
<label>Download Fallback:</label>
<label class="switch">
<input type="checkbox" id="fallbackToggle" />
<span class="slider"></span>
</label>
</div>
<div class="config-item">
<label>Real time downloading:</label>
<label class="switch">
<input type="checkbox" id="realTimeToggle" />
<span class="slider"></span>
</label>
</div>
<div class="config-item">
<label for="maxConcurrentDownloads">Max Concurrent Downloads:</label>
<input type="number" id="maxConcurrentDownloads" min="1" value="3" class="form-input">
</div>
<!-- New Conversion Options -->
<h2 class="section-title">Conversion Settings</h2>
<div class="config-item">
<label for="convertToSelect">Convert To Format:</label>
<select id="convertToSelect" class="form-select">
<option value="">No Conversion</option>
<option value="MP3">MP3</option>
<option value="AAC">AAC</option>
<option value="OGG">OGG</option>
<option value="OPUS">OPUS</option>
<option value="FLAC">FLAC</option>
<option value="WAV">WAV</option>
<option value="ALAC">ALAC</option>
</select>
<div class="setting-description">
Select a format to convert downloaded files to. "No Conversion" keeps the original format.
</div>
</div>
<div class="config-item">
<label for="bitrateSelect">Bitrate:</label>
<select id="bitrateSelect" class="form-select" disabled>
<!-- Options will be populated by JavaScript -->
<option value="">N/A</option>
</select>
<div class="setting-description">
Select the bitrate for the chosen format. Only applicable for lossy formats.
</div>
</div>
<!-- New Retry Options -->
<h2 class="section-title">Retry Options</h2>
<div class="config-item">
<label for="maxRetries">Max Retry Attempts:</label>
<input type="number" id="maxRetries" min="0" max="10" value="3" class="form-input">
</div>
<div class="config-item">
<label for="retryDelaySeconds">Initial Retry Delay (seconds):</label>
<input type="number" id="retryDelaySeconds" min="1" value="5" class="form-input">
</div>
<div class="config-item">
<label for="retryDelayIncrease">Retry Delay Increase (seconds):</label>
<input type="number" id="retryDelayIncrease" min="0" value="5" class="form-input">
<div class="setting-description">
The amount of additional delay to add for each retry attempt
</div>
</div>
<!-- New Formatting Options -->
<h2 class="section-title">Formatting Options</h2>
<div class="config-item">
<label>Custom Directory Format:</label>
<input
type="text"
id="customDirFormat"
placeholder="e.g. %artist%/%album%"
class="form-input"
/>
<div class="format-help">
<select id="dirFormatHelp" class="format-selector">
<option value="">-- Select placeholder --</option>
<optgroup label="Common">
<option value="%music%">%music% - Track title</option>
<option value="%artist%">%artist% - Track artist. If multiple artists, use %artist_1%, %artist_2%, etc. to select a specific one.</option>
<option value="%album%">%album% - Album name</option>
<option value="%ar_album%">%ar_album% - Album artist. If multiple album artists, use %ar_album_1%, %ar_album_2%, etc. to select a specific one.</option>
<option value="%tracknum%">%tracknum% - Track number</option>
<option value="%year%">%year% - Year of release</option>
</optgroup>
<optgroup label="Additional">
<option value="%discnum%">%discnum% - Disc number</option>
<option value="%date%">%date% - Release date</option>
<option value="%genre%">%genre% - Music genre</option>
<option value="%isrc%">%isrc% - International Standard Recording Code</option>
<option value="%explicit%">%explicit% - Explicit content flag</option>
<option value="%duration%">%duration% - Track duration (seconds)</option>
</optgroup>
<optgroup label="Metadata">
<option value="%publisher%">%publisher% - Publisher information</option>
<option value="%composer%">%composer% - Track composer</option>
<option value="%copyright%">%copyright% - Copyright information</option>
<option value="%author%">%author% - Author information</option>
<option value="%lyricist%">%lyricist% - Lyricist information</option>
<option value="%version%">%version% - Version information</option>
<option value="%comment%">%comment% - Comment field</option>
</optgroup>
<optgroup label="Other">
<option value="%encodedby%">%encodedby% - Encoded by information</option>
<option value="%language%">%language% - Language information</option>
<option value="%lyrics%">%lyrics% - Track lyrics</option>
<option value="%mood%">%mood% - Mood information</option>
<option value="%rating%">%rating% - Track rating</option>
<option value="%website%">%website% - Website information</option>
</optgroup>
<optgroup label="ReplayGain">
<option value="%replaygain_album_gain%">%replaygain_album_gain% - Album gain</option>
<option value="%replaygain_album_peak%">%replaygain_album_peak% - Album peak</option>
<option value="%replaygain_track_gain%">%replaygain_track_gain% - Track gain</option>
<option value="%replaygain_track_peak%">%replaygain_track_peak% - Track peak</option>
</optgroup>
</select>
</div>
</div>
<div class="config-item">
<label>Custom Track Format:</label>
<input
type="text"
id="customTrackFormat"
placeholder="e.g. %tracknum% - %music%"
class="form-input"
/>
<div class="format-help">
<select id="trackFormatHelp" class="format-selector">
<option value="">-- Select placeholder --</option>
<optgroup label="Common">
<option value="%music%">%music% - Track title</option>
<option value="%artist%">%artist% - Track artist. If multiple artists, use %artist_1%, %artist_2%, etc. to select a specific one.</option>
<option value="%album%">%album% - Album name</option>
<option value="%ar_album%">%ar_album% - Album artist. If multiple album artists, use %ar_album_1%, %ar_album_2%, etc. to select a specific one.</option>
<option value="%tracknum%">%tracknum% - Track number</option>
<option value="%year%">%year% - Year of release</option>
</optgroup>
<optgroup label="Additional">
<option value="%discnum%">%discnum% - Disc number</option>
<option value="%date%">%date% - Release date</option>
<option value="%genre%">%genre% - Music genre</option>
<option value="%isrc%">%isrc% - International Standard Recording Code</option>
<option value="%explicit%">%explicit% - Explicit content flag</option>
<option value="%duration%">%duration% - Track duration (seconds)</option>
</optgroup>
<optgroup label="Metadata">
<option value="%publisher%">%publisher% - Publisher information</option>
<option value="%composer%">%composer% - Track composer</option>
<option value="%copyright%">%copyright% - Copyright information</option>
<option value="%author%">%author% - Author information</option>
<option value="%lyricist%">%lyricist% - Lyricist information</option>
<option value="%version%">%version% - Version information</option>
<option value="%comment%">%comment% - Comment field</option>
</optgroup>
<optgroup label="Other">
<option value="%encodedby%">%encodedby% - Encoded by information</option>
<option value="%language%">%language% - Language information</option>
<option value="%lyrics%">%lyrics% - Track lyrics</option>
<option value="%mood%">%mood% - Mood information</option>
<option value="%rating%">%rating% - Track rating</option>
<option value="%website%">%website% - Website information</option>
<option value="%quality%">%quality% - Quality of the track</option>
</optgroup>
<optgroup label="ReplayGain">
<option value="%replaygain_album_gain%">%replaygain_album_gain% - Album gain</option>
<option value="%replaygain_album_peak%">%replaygain_album_peak% - Album peak</option>
<option value="%replaygain_track_gain%">%replaygain_track_gain% - Track gain</option>
<option value="%replaygain_track_peak%">%replaygain_track_peak% - Track peak</option>
</optgroup>
</select>
</div>
<div class="setting-description">
Note that these placeholder depend on the metadata of the track, if one entry is not available in a track, the placeholder will be replaced with an empty string.
</div>
</div>
<!-- New Track Number Padding Toggle -->
<div class="config-item">
<label>Track Number Padding:</label>
<label class="switch">
<input type="checkbox" id="tracknumPaddingToggle" />
<span class="slider"></span>
</label>
<div class="setting-description">
When enabled: "01. Track" - When disabled: "1. Track"
</div>
</div>
<!-- New Save Cover Art Toggle -->
<div class="config-item">
<label>Save Cover Art:</label>
<label class="switch">
<input type="checkbox" id="saveCoverToggle" />
<span class="slider"></span>
</label>
<div class="setting-description">
When enabled, cover art will saved as cover.jpg in the same directory as the track.
</div>
</div>
</div>
<div class="watch-options-config card">
<h2 class="section-title">Watch Options</h2>
<div class="config-item">
<label>Enable Watch Feature:</label>
<label class="switch">
<input type="checkbox" id="watchEnabledToggle" />
<span class="slider"></span>
</label>
<div class="setting-description">
Enable or disable the entire watch feature (monitoring playlists and artists for new content).
</div>
</div>
<div id="watchEnabledWarning" class="config-item urgent-warning-message" style="display: none;">
<svg class="warning-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24px" height="24px"><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></svg>
Warning: Enable "Real time downloading" in the Download Settings to avoid rate-limiting issues. If you don't, you WILL (pretty much immediately) encounter API rate limits, and the watch feature WILL break.
</div>
<div class="config-item">
<label for="watchedArtistAlbumGroup">Artist Page - Album Groups to Watch:</label>
<div id="watchedArtistAlbumGroupChecklist" class="checklist-container">
<div class="checklist-item">
<input type="checkbox" id="albumGroup-album" name="watchedArtistAlbumGroup" value="album">
<label for="albumGroup-album">Album</label>
</div>
<div class="checklist-item">
<input type="checkbox" id="albumGroup-single" name="watchedArtistAlbumGroup" value="single">
<label for="albumGroup-single">Single</label>
</div>
<div class="checklist-item">
<input type="checkbox" id="albumGroup-compilation" name="watchedArtistAlbumGroup" value="compilation">
<label for="albumGroup-compilation">Compilation</label>
</div>
<div class="checklist-item">
<input type="checkbox" id="albumGroup-appears_on" name="watchedArtistAlbumGroup" value="appears_on">
<label for="albumGroup-appears_on">Appears On</label>
</div>
</div>
<div class="setting-description">
Select which album groups to monitor on watched artist pages.
</div>
</div>
<div class="config-item">
<label for="watchPollIntervalSeconds">Watch Poll Interval (seconds):</label>
<input type="number" id="watchPollIntervalSeconds" min="60" value="3600" class="form-input">
<div class="setting-description">
How often to check watched items for updates (e.g., new playlist tracks, new artist albums).
</div>
</div>
</div>
<div class="master-accounts-config-section">
<h2 class="section-title">Accounts configuration</h2>
<!-- Global Spotify API Credentials Card: MOVED HERE -->
<div class="global-api-keys-config card"> <!-- Changed class to global-api-keys-config -->
<h2 class="section-title">Global Spotify API Credentials</h2>
<div class="config-item">
<label for="globalSpotifyClientId">Client ID:</label>
<input type="text" id="globalSpotifyClientId" class="form-input" placeholder="Enter your Spotify Client ID">
</div>
<div class="config-item">
<label for="globalSpotifyClientSecret">Client Secret:</label>
<input type="password" id="globalSpotifyClientSecret" class="form-input" placeholder="Enter your Spotify Client Secret">
</div>
<div class="config-item">
<button id="saveSpotifyApiConfigBtn" class="btn btn-primary">Save</button>
</div>
<div id="spotifyApiConfigStatus" class="status-message" style="margin-top: 10px;"></div>
</div>
<!-- End Global Spotify API Credentials Card -->
<div class="accounts-section">
<div class="service-tabs">
<button class="tab-button active" data-service="spotify">Spotify</button>
<button class="tab-button" data-service="deezer">Deezer</button>
</div>
<!-- Wrapper for the list and the add button -->
<div class="credentials-list-wrapper card">
<div class="credentials-list-items">
<!-- Dynamic credential items will be rendered here by JavaScript -->
<!-- "No credentials" message will also be rendered here -->
</div>
<div class="add-account-item">
<button id="showAddAccountFormBtn" class="btn-add-account-styled" type="button">
<img src="{{ url_for('static', filename='images/plus-circle.svg') }}" alt="Add" /> Add New Account
</button>
</div>
</div>
<div class="credentials-form card">
<h2 id="formTitle" class="section-title">Add New Spotify Account</h2>
<form id="credentialForm">
<div id="serviceFields"></div>
<!-- Region Hints START -->
<div id="spotifyRegionHint" class="setting-description" style="display:none; margin-left: 10px; margin-top: -5px; margin-bottom:15px; font-size: 0.9em;">
<small>Region not matching your account may lead to issues. Check it <a href="https://www.spotify.com/mx/account/profile/" target="_blank" rel="noopener noreferrer">here</a>.</small>
</div>
<div id="deezerRegionHint" class="setting-description" style="display:none; margin-left: 10px; margin-top: -5px; margin-bottom:15px; font-size: 0.9em;">
<small>Region not matching your account may lead to issues. Check it <a href="https://www.deezer.com/account/country_selector" target="_blank" rel="noopener noreferrer">here</a>.</small>
</div>
<!-- Region Hints END -->
<div id="searchFields" style="display: none;"></div>
<button type="submit" id="submitCredentialBtn" class="btn btn-primary save-btn">Save Account</button>
<button type="button" id="cancelAddAccountBtn" class="btn btn-secondary cancel-btn btn-cancel-icon" style="margin-left: 10px;" title="Cancel">
<img src="{{ url_for('static', filename='images/cross.svg') }}" alt="Cancel" />
</button>
</form>
<div id="configSuccess" class="success"></div>
<div id="configError" class="error"></div>
</div>
</div>
</div> <!-- End of accounts-section -->
</div>
</div>
<!-- Fixed floating buttons for back and queue -->
<a href="/history" class="btn-icon history-nav-btn floating-icon settings-icon" aria-label="Download History" title="Go to Download History">
<img src="{{ url_for('static', filename='images/history.svg') }}" alt="History" onerror="handleImageError(this)"/>
</a>
<a href="/" class="back-button floating-icon settings-icon" aria-label="Back to app">
<img src="{{ url_for('static', filename='images/arrow-left.svg') }}" alt="Back" />
</a>
<a id="watchlistButton" href="/watchlist" class="btn-icon watchlist-icon floating-icon hidden" aria-label="Watchlist" title="Go to Watchlist">
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Watchlist" onerror="handleImageError(this)"/>
</a>
<button
id="queueIcon"
class="btn-icon queue-icon floating-icon"
aria-label="Download queue"
aria-controls="downloadQueue"
aria-expanded="false"
>
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="Queue" />
</button>
<!-- Load config.js as a module so you can import queue.js -->
<script type="module" src="{{ url_for('static', filename='js/config.js') }}"></script>
</body>
</html>

View File

@@ -1,98 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Download History</title>
<!-- Link to global stylesheets first -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/base.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}">
<!-- Link to page-specific stylesheet -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/history/history.css') }}">
<!-- Helper function for image errors, if not already in base.css or loaded globally -->
<script>
function handleImageError(img) {
img.onerror = null; // Prevent infinite loop if placeholder also fails
img.src = "{{ url_for('static', filename='images/placeholder.jpg') }}";
}
</script>
</head>
<body>
<div class="container">
<h1 id="history-title">Download History</h1>
<div class="filters">
<label for="status-filter">Status:</label>
<select id="status-filter">
<option value="">All</option>
<option value="COMPLETED">Completed</option>
<option value="ERROR">Error</option>
<option value="CANCELLED">Cancelled</option>
</select>
<label for="type-filter">Type:</label>
<select id="type-filter">
<option value="">All</option>
<option value="track">Track</option>
<option value="album">Album</option>
<option value="playlist">Playlist</option>
<option value="artist">Artist</option>
</select>
<label for="track-filter">Track Status:</label>
<select id="track-filter">
<option value="">All</option>
<option value="SUCCESSFUL">Successful</option>
<option value="SKIPPED">Skipped</option>
<option value="FAILED">Failed</option>
</select>
<div class="checkbox-filter">
<input type="checkbox" id="hide-child-tracks" />
<label for="hide-child-tracks">Hide Individual Tracks</label>
</div>
</div>
<table>
<thead>
<tr>
<th data-sort="item_name">Name</th>
<th data-sort="item_artist">Artist</th>
<th data-sort="download_type">Type/Status</th>
<th data-sort="service_used">Service</th>
<th data-sort="quality_profile">Quality</th>
<th data-sort="status_final">Status</th>
<th data-sort="timestamp_added">Date Added</th>
<th data-sort="timestamp_completed">Date Completed/Ended</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="history-table-body">
<!-- Rows will be inserted here by JavaScript -->
</tbody>
</table>
<div class="pagination">
<button id="prev-page" disabled>Previous</button>
<span id="page-info">Page 1 of 1</span>
<button id="next-page" disabled>Next</button>
<select id="limit-select">
<option value="10">10 per page</option>
<option value="25" selected>25 per page</option>
<option value="50">50 per page</option>
<option value="100">100 per page</option>
</select>
</div>
</div>
<!-- Fixed floating buttons for home and queue -->
<a href="/" class="btn-icon home-btn floating-icon" aria-label="Return to home" title="Go to Home">
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home" onerror="handleImageError(this)"/>
</a>
<!-- Link to the new TypeScript file (compiled to JS) -->
<script type="module" src="{{ url_for('static', filename='js/history.js') }}"></script>
<!-- Queue icon, assuming queue.js handles its own initialization if included -->
<!-- You might want to include queue.js here if the queue icon is desired on this page -->
<!-- <script type="module" src="{{ url_for('static', filename='js/queue.js') }}"></script> -->
</body>
</html>

View File

@@ -1,85 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spotizerr</title>
<!-- Add the new base.css first -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/base.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/main.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
<script>
// Helper function to handle image loading errors
function handleImageError(img) {
img.src = '/static/images/placeholder.jpg';
}
</script>
</head>
<body>
<div class="app-container">
<div class="search-header">
<div class="search-input-container">
<input
type="text"
class="search-input"
placeholder="Search tracks, albums, playlists or artists... (Or paste in a spotify url)"
id="searchInput"
/>
<select class="search-type" id="searchType">
<option value="track">Tracks</option>
<option value="album">Albums</option>
<option value="playlist">Playlists</option>
<option value="artist">Artists</option>
</select>
</div>
<button class="search-button btn-primary" id="searchButton" aria-label="Search">
<img src="{{ url_for('static', filename='images/search.svg') }}" alt="" />
Search
</button>
</div>
<!-- Results container -->
<div id="resultsContainer" class="results-grid"></div>
<!-- Empty state when there are no results -->
<div id="emptyState" class="empty-state">
<div class="empty-state-content">
<img src="{{ url_for('static', filename='images/music.svg') }}" alt="Music" class="empty-state-icon" />
<h2>Search for music</h2>
<p>Find and download your favorite tracks, albums, playlists or artists</p>
</div>
</div>
<!-- Loading indicator -->
<div id="loadingResults" class="loading hidden">
<div class="loading-indicator">Searching...</div>
</div>
</div>
<!-- Fixed floating buttons for settings and queue -->
<a href="/history" class="btn-icon history-nav-btn floating-icon settings-icon" aria-label="Download History" title="Go to Download History">
<img src="{{ url_for('static', filename='images/history.svg') }}" alt="History" onerror="handleImageError(this)"/>
</a>
<a href="/config" class="btn-icon settings-icon floating-icon" aria-label="Settings">
<img src="{{ url_for('static', filename='images/settings.svg') }}" alt="Settings" onerror="handleImageError(this)"/>
</a>
<a id="watchlistButton" href="/watchlist" class="btn-icon watchlist-icon floating-icon hidden" aria-label="Watchlist" title="Go to Watchlist">
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Watchlist" onerror="handleImageError(this)"/>
</a>
<button
id="queueIcon"
class="btn-icon queue-icon floating-icon"
aria-label="Download queue"
aria-controls="downloadQueue"
aria-expanded="false"
>
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="" onerror="handleImageError(this)"/>
</button>
<script type="module" src="{{ url_for('static', filename='js/main.js') }}"></script>
</body>
</html>

View File

@@ -1,84 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Playlist Viewer - Spotizerr</title>
<!-- Add the new base.css first -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/base.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/playlist/playlist.css') }}" />
</head>
<body>
<div class="app-container">
<div id="playlist-header" class="content-header hidden">
<!-- Playlist Image -->
<img id="playlist-image" class="header-image" alt="Playlist cover" onerror="this.src='/static/images/placeholder.jpg'">
<!-- Playlist Info -->
<div id="playlist-info" class="header-info">
<h1 id="playlist-name" class="header-title"></h1>
<p id="playlist-owner" class="header-subtitle"></p>
<p id="playlist-stats" class="header-subtitle"></p>
<p id="playlist-description" class="playlist-description"></p>
<!-- Download Buttons -->
<div class="header-actions">
<button id="downloadPlaylistBtn" class="download-btn btn-primary">
<img src="{{ url_for('static', filename='images/download.svg') }}" alt="Download">
Download Whole Playlist
</button>
<button id="downloadAlbumsBtn" class="download-btn">
<img src="{{ url_for('static', filename='images/album.svg') }}" alt="Albums">
Download Playlist's Albums
</button>
<button id="watchPlaylistBtn" class="download-btn watch-btn">
<img src="{{ url_for('static', filename='images/eye.svg') }}" alt="Watch">
Watch Playlist
</button>
<button id="syncPlaylistBtn" class="download-btn sync-btn hidden">
<img src="{{ url_for('static', filename='images/refresh.svg') }}" alt="Sync">
Sync Watched Playlist
</button>
</div>
</div>
</div>
<div id="tracks-container" class="hidden">
<h2 class="section-title">Tracks</h2>
<div id="tracks-list" class="tracks-list"></div>
</div>
<!-- Loading and Error states -->
<div id="loading" class="loading">
<div class="loading-indicator">Loading...</div>
</div>
<div id="error" class="error hidden">Error loading playlist</div>
</div>
<!-- Fixed floating buttons for home and queue -->
<a href="/history" class="btn-icon history-nav-btn floating-icon home-btn" aria-label="Download History" title="Go to Download History">
<img src="{{ url_for('static', filename='images/history.svg') }}" alt="History" onerror="handleImageError(this)"/>
</a>
<button id="homeButton" class="btn-icon home-btn floating-icon settings-icon" aria-label="Return to home">
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home">
</button>
<a id="watchlistButton" href="/watchlist" class="btn-icon watchlist-icon floating-icon hidden" aria-label="Watchlist" title="Go to Watchlist">
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Watchlist" onerror="handleImageError(this)"/>
</a>
<button
id="queueIcon"
class="btn-icon queue-icon floating-icon"
aria-label="Download queue"
aria-controls="downloadQueue"
aria-expanded="false"
>
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="Queue Icon">
</button>
<script type="module" src="{{ url_for('static', filename='js/playlist.js') }}"></script>
</body>
</html>

View File

@@ -1,72 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Track Viewer - Spotizerr</title>
<!-- Add the new base.css first -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/base.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/track/track.css') }}">
</head>
<body>
<div class="app-container">
<div id="track-header" class="content-header hidden">
<!-- Album Image -->
<img id="track-album-image" class="header-image" alt="Album cover" onerror="this.src='/static/images/placeholder.jpg'">
<!-- Track Info -->
<div id="track-info" class="header-info">
<h1 id="track-name" class="header-title"></h1>
<p id="track-artist" class="header-subtitle"></p>
<p id="track-album" class="header-subtitle"></p>
<div class="track-details">
<span id="track-duration" class="track-detail-item"></span>
<span id="track-explicit" class="track-detail-item"></span>
</div>
<!-- Download Button moved here for better mobile layout -->
<div class="header-actions">
<button id="downloadTrackBtn" class="download-btn btn-primary">
<img src="{{ url_for('static', filename='images/download.svg') }}" alt="Download">
Download Track
</button>
</div>
</div>
</div>
<!-- Loading and Error states -->
<div id="loading" class="loading">
<div class="loading-indicator">Loading...</div>
</div>
<div id="error" class="error hidden">Error loading track</div>
</div>
<!-- Fixed floating buttons for home and queue -->
<a href="/history" class="btn-icon history-nav-btn floating-icon home-btn" aria-label="Download History" title="Go to Download History">
<img src="{{ url_for('static', filename='images/history.svg') }}" alt="History" onerror="handleImageError(this)"/>
</a>
<button id="homeButton" class="btn-icon home-btn floating-icon settings-icon" aria-label="Return to home">
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home">
</button>
<a id="watchlistButton" href="/watchlist" class="btn-icon watchlist-icon floating-icon hidden" aria-label="Watchlist" title="Go to Watchlist">
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Watchlist" onerror="handleImageError(this)"/>
</a>
<button
id="queueIcon"
class="btn-icon queue-icon floating-icon"
aria-label="Download queue"
aria-controls="downloadQueue"
aria-expanded="false"
>
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="Queue Icon">
</button>
<!-- The download queue container will be inserted by queue.js -->
<script type="module" src="{{ url_for('static', filename='js/track.js') }}"></script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More