Merge pull request #130 from Xoconoch/dev

2.0.0
This commit is contained in:
Xoconoch
2025-05-29 22:18:38 -06:00
committed by GitHub
75 changed files with 9860 additions and 3301 deletions

View File

@@ -6,24 +6,10 @@
/Test.py
/prgs/
/flask_server.log
/routes/__pycache__/
routes/utils/__pycache__/
test.sh
__pycache__/
routes/__pycache__/__init__.cpython-312.pyc
routes/__pycache__/credentials.cpython-312.pyc
routes/__pycache__/search.cpython-312.pyc
routes/utils/__pycache__/__init__.cpython-312.pyc
routes/utils/__pycache__/credentials.cpython-312.pyc
routes/utils/__pycache__/search.cpython-312.pyc
routes/utils/__pycache__/__init__.cpython-312.pyc
routes/utils/__pycache__/credentials.cpython-312.pyc
routes/utils/__pycache__/search.cpython-312.pyc
routes/utils/__pycache__/credentials.cpython-312.pyc
routes/utils/__pycache__/search.cpython-312.pyc
routes/utils/__pycache__/__init__.cpython-312.pyc
routes/utils/__pycache__/credentials.cpython-312.pyc
routes/utils/__pycache__/search.cpython-312.pyc
routes/__pycache__/*
routes/utils/__pycache__/*
search_test.py
config/main.json
.cache
@@ -32,3 +18,9 @@ output.log
queue_state.json
search_demo.py
celery_worker.log
static/js/*
logs/
.env.example
.env
.venv
data

View File

@@ -1,4 +1,4 @@
# Docker Compose environment variables
# Docker Compose environment variables# Delete all comments of this when deploying (everything that is )
# Redis connection (external or internal)
REDIS_HOST=redis
@@ -6,12 +6,14 @@ REDIS_PORT=6379
REDIS_DB=0
REDIS_PASSWORD=CHANGE_ME
# Optional: Redis connection details
# Set to true to filter out explicit content
EXPLICIT_FILTER=false
# User ID for the container
PUID=1000
# Group ID for the container
PGID=1000
# Optional: Sets the default file permissions for newly created files within the container.
UMASK=0022

View File

@@ -12,11 +12,13 @@ A clear and concise description of what the bug is.
**To Reproduce**
Precise steps to reproduce the behavior (start from how you built your container):
1. Go to '...'
2. Click on '....'
1. Search for '...'
2. Download album/track/playlist 'https://open.spotify.com/...'
3. Scroll down to '....'
4. See error
*Note: Sometimes, an error is specific to an album, track or playlist, so preferrably share the specific url of the album you downloaded*
**Expected behavior**
A clear and concise description of what you expected to happen.
@@ -31,11 +33,20 @@ If applicable, add screenshots to help explain your problem.
```
Paste it here
```
**.env**
```
Paste it here
```
**Config**
- You can either share a screenshot of the config page or, preferably, the config file (should be under `./config/main.json`, depending on where you mapped it on your docker-compose.yaml)
**Logs**
```
Preferably, restart the app before reproducing so you can paste the logs from the bare beginning
```
**Image**
Run
```docker container ls --format "{{.Names}}: {{.Image}}"``` and share the relevant output (e.g. spotizerr: cooldockerizer93/spotizerr:latest)
**Version**
Go to config page and look for the version number

4
.gitignore vendored
View File

@@ -34,3 +34,7 @@ search_demo.py
celery_worker.log
logs/spotizerr.log
/.venv
static/js
data
logs/
.env

View File

@@ -10,6 +10,8 @@ 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/*
@@ -22,9 +24,17 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
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
# Create necessary directories with proper permissions
RUN mkdir -p downloads config creds logs && \
chmod 777 downloads config creds logs
RUN mkdir -p downloads data/config data/creds data/watch data/history logs/tasks && \
chmod -R 777 downloads data logs
# Make entrypoint script executable
RUN chmod +x entrypoint.sh

View File

@@ -53,7 +53,7 @@ mkdir spotizerr && cd spotizerr
3. Copy `docker-compose.yml` from this repo.
4. Create required directories:
```bash
mkdir -p creds config downloads logs cache
mkdir -p data/creds data/config data/watch data/history downloads logs/tasks .cache
```
5. Launch containers:
```bash
@@ -262,9 +262,14 @@ UMASK=0022 # Default file permission mask
- API errors: Ensure your Spotify client ID and client secret are correctly entered
**Log Locations**:
- Credentials: `./creds/` directory
- Downloads: `./downloads/` directory
- Application logs: `docker logs spotizerr`
- Application Logs: `docker logs spotizerr` (for main app and Celery workers)
- Individual Task Logs: `./logs/tasks/` (inside the container, maps to your volume)
- Credentials: `./data/creds/`
- Configuration Files: `./data/config/`
- Downloaded Music: `./downloads/`
- Watch Feature Database: `./data/watch/`
- Download History Database: `./data/history/`
- Spotify Token Cache: `./.cache/` (if `SPOTIPY_CACHE_PATH` is set to `/app/cache/.cache` and mapped)
## Notes

25
app.py
View File

@@ -8,6 +8,7 @@ from routes.playlist import playlist_bp
from routes.prgs import prgs_bp
from routes.config import config_bp
from routes.artist import artist_bp
from routes.history import history_bp
import logging
import logging.handlers
import time
@@ -38,6 +39,10 @@ def setup_logging():
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
# Clear any existing handlers from the root logger
if root_logger.hasHandlers():
root_logger.handlers.clear()
# Log formatting
log_format = logging.Formatter(
'%(asctime)s [%(processName)s:%(threadName)s] [%(name)s] [%(levelname)s] - %(message)s',
@@ -131,7 +136,7 @@ def check_redis_connection():
return False
def create_app():
app = Flask(__name__)
app = Flask(__name__, template_folder='static/html')
# Set up CORS
CORS(app)
@@ -145,6 +150,7 @@ def create_app():
app.register_blueprint(playlist_bp, url_prefix='/api/playlist')
app.register_blueprint(artist_bp, url_prefix='/api/artist')
app.register_blueprint(prgs_bp, url_prefix='/api/prgs')
app.register_blueprint(history_bp, url_prefix='/api/history')
# Serve frontend
@app.route('/')
@@ -156,12 +162,17 @@ def create_app():
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')
# New route: Serve playlist.html under /playlist/<id>
@app.route('/album/<id>')
def serve_album(id):
# The id parameter is captured, but you can use it as needed.
@@ -177,6 +188,10 @@ def create_app():
# 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)
@@ -184,7 +199,7 @@ def create_app():
# Serve favicon.ico from the same directory as index.html (templates)
@app.route('/favicon.ico')
def serve_favicon():
return send_from_directory('templates', 'favicon.ico')
return send_from_directory('static/html', 'favicon.ico')
# Add request logging middleware
@app.before_request
@@ -230,6 +245,10 @@ if __name__ == '__main__':
# Check Redis connection before starting workers
if check_redis_connection():
# Start Watch Manager
from routes.utils.watch.manager import start_watch_manager
start_watch_manager()
# Start Celery workers
start_celery_workers()

View File

@@ -3,13 +3,12 @@ name: spotizerr
services:
spotizerr:
volumes:
- ./creds:/app/creds
- ./config:/app/config
- ./data:/app/data
- ./downloads:/app/downloads # <-- Change this for your music library dir
- ./logs:/app/logs # <-- Volume for persistent logs
ports:
- 7171:7171
image: cooldockerizer93/spotizerr:latest
image: cooldockerizer93/spotizerr
container_name: spotizerr-app
restart: unless-stopped
environment:

View File

@@ -1,47 +1,61 @@
amqp==5.3.1
annotated-types==0.7.0
anyio==4.8.0
anyio==4.9.0
billiard==4.2.1
blinker==1.9.0
certifi==2024.12.14
charset-normalizer==3.4.1
click==8.1.8
celery==5.5.2
certifi==2025.4.26
charset-normalizer==3.4.2
click==8.2.1
click-didyoumean==0.3.1
click-plugins==1.1.1
click-repl==0.3.0
deezspot @ git+https://github.com/Xoconoch/deezspot-fork-again
defusedxml==0.7.1
fastapi==0.115.7
Flask==3.1.0
Flask-Cors==5.0.0
h11==0.14.0
fastapi==0.115.12
Flask==3.1.1
Flask-Celery-Helper==1.1.0
flask-cors==6.0.0
h11==0.16.0
httptools==0.6.4
idna==3.10
ifaddr==0.2.0
itsdangerous==2.2.0
Jinja2==3.1.5
Jinja2==3.1.6
kombu==5.5.3
librespot==0.0.9
MarkupSafe==3.0.2
mutagen==1.47.0
prompt_toolkit==3.0.51
protobuf==3.20.1
pycryptodome==3.21.0
pycryptodome==3.23.0
pycryptodomex==3.17
pydantic==2.10.6
pydantic_core==2.27.2
pydantic==2.11.5
pydantic_core==2.33.2
PyOgg==0.6.14a1
python-dotenv==1.0.1
python-dateutil==2.9.0.post0
python-dotenv==1.1.0
PyYAML==6.0.2
redis==5.2.1
redis==6.2.0
requests==2.30.0
six==1.17.0
sniffio==1.3.1
spotipy
spotipy_anon
starlette==0.45.3
spotipy==2.25.1
spotipy_anon==1.4
sse-starlette==2.3.5
starlette==0.46.2
tqdm==4.67.1
typing_extensions==4.12.2
urllib3==2.3.0
uvicorn==0.34.0
typing-inspection==0.4.1
typing_extensions==4.13.2
tzdata==2025.2
urllib3==2.4.0
uvicorn==0.34.2
uvloop==0.21.0
vine==5.1.0
waitress==3.0.2
watchfiles==1.0.4
watchfiles==1.0.5
wcwidth==0.2.13
websocket-client==1.5.1
websockets==14.2
websockets==15.0.1
Werkzeug==3.1.3
zeroconf==0.62.0
celery==5.3.6
flask-celery-helper==1.1.0

View File

@@ -0,0 +1,24 @@
import logging
import atexit
# Configure basic logging for the application if not already configured
# This is a good place for it if routes are a central part of your app structure.
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
try:
from routes.utils.watch.manager import start_watch_manager, stop_watch_manager
# Start the playlist watch manager when the application/blueprint is initialized
start_watch_manager()
# Register the stop function to be called on application exit
atexit.register(stop_watch_manager)
logger.info("Playlist Watch Manager initialized and registered for shutdown.")
except ImportError as e:
logger.error(f"Could not import or start Playlist Watch Manager: {e}. Playlist watching will be disabled.")
except Exception as e:
logger.error(f"An unexpected error occurred during Playlist Watch Manager setup: {e}", exc_info=True)
from .artist import artist_bp
from .prgs import prgs_bp

View File

@@ -2,16 +2,42 @@ from flask import Blueprint, Response, request
import json
import os
import traceback
import uuid
import time
from routes.utils.celery_queue_manager import download_queue_manager
from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState
from routes.utils.get_info import get_spotify_info
album_bp = Blueprint('album', __name__)
@album_bp.route('/download', methods=['GET'])
def handle_download():
@album_bp.route('/download/<album_id>', methods=['GET'])
def handle_download(album_id):
# Retrieve essential parameters from the request.
url = request.args.get('url')
name = request.args.get('name')
artist = request.args.get('artist')
# name = request.args.get('name')
# artist = request.args.get('artist')
# Construct the URL from album_id
url = f"https://open.spotify.com/album/{album_id}"
# Fetch metadata from Spotify
try:
album_info = get_spotify_info(album_id, "album")
if not album_info or not album_info.get('name') or not album_info.get('artists'):
return Response(
json.dumps({"error": f"Could not retrieve metadata for album ID: {album_id}"}),
status=404,
mimetype='application/json'
)
name_from_spotify = album_info.get('name')
artist_from_spotify = album_info['artists'][0].get('name') if album_info['artists'] else "Unknown Artist"
except Exception as e:
return Response(
json.dumps({"error": f"Failed to fetch metadata for album {album_id}: {str(e)}"}),
status=500,
mimetype='application/json'
)
# Validate required parameters
if not url:
@@ -26,13 +52,38 @@ def handle_download():
# Include full original request URL in metadata
orig_params = request.args.to_dict()
orig_params["original_url"] = request.url
task_id = download_queue_manager.add_task({
"download_type": "album",
"url": url,
"name": name,
"artist": artist,
"orig_request": orig_params
})
try:
task_id = download_queue_manager.add_task({
"download_type": "album",
"url": url,
"name": name_from_spotify,
"artist": artist_from_spotify,
"orig_request": orig_params
})
except Exception as e:
# Generic error handling for other issues during task submission
# Create an error task ID if add_task itself fails before returning an ID
error_task_id = str(uuid.uuid4())
store_task_info(error_task_id, {
"download_type": "album",
"url": url,
"name": name_from_spotify,
"artist": artist_from_spotify,
"original_request": orig_params,
"created_at": time.time(),
"is_submission_error_task": True
})
store_task_status(error_task_id, {
"status": ProgressState.ERROR,
"error": f"Failed to queue album download: {str(e)}",
"timestamp": time.time()
})
return Response(
json.dumps({"error": f"Failed to queue album download: {str(e)}", "task_id": error_task_id}),
status=500,
mimetype='application/json'
)
return Response(
json.dumps({"prg_file": task_id}),

View File

@@ -3,32 +3,53 @@
Artist endpoint blueprint.
"""
from flask import Blueprint, Response, request
from flask import Blueprint, Response, request, jsonify
import json
import os
import traceback
from routes.utils.celery_queue_manager import download_queue_manager
from routes.utils.artist import download_artist_albums
artist_bp = Blueprint('artist', __name__)
# Imports for merged watch functionality
import logging
import threading
from routes.utils.watch.db import (
add_artist_to_watch as add_artist_db,
remove_artist_from_watch as remove_artist_db,
get_watched_artist,
get_watched_artists,
add_specific_albums_to_artist_table,
remove_specific_albums_from_artist_table,
is_album_in_artist_db
)
from routes.utils.watch.manager import check_watched_artists
from routes.utils.get_info import get_spotify_info
artist_bp = Blueprint('artist', __name__, url_prefix='/api/artist')
# Existing log_json can be used, or a logger instance.
# Let's initialize a logger for consistency with merged code.
logger = logging.getLogger(__name__)
def log_json(message_dict):
print(json.dumps(message_dict))
@artist_bp.route('/download', methods=['GET'])
def handle_artist_download():
@artist_bp.route('/download/<artist_id>', methods=['GET'])
def handle_artist_download(artist_id):
"""
Enqueues album download tasks for the given artist.
Expected query parameters:
- url: string (a Spotify artist URL)
- album_type: string(s); comma-separated values such as "album,single,appears_on,compilation"
"""
# Construct the artist URL from artist_id
url = f"https://open.spotify.com/artist/{artist_id}"
# Retrieve essential parameters from the request.
url = request.args.get('url')
album_type = request.args.get('album_type', "album,single,compilation")
# Validate required parameters
if not url:
if not url: # This check is mostly for safety, as url is constructed
return Response(
json.dumps({"error": "Missing required parameter: url"}),
status=400,
@@ -37,23 +58,28 @@ def handle_artist_download():
try:
# Import and call the updated download_artist_albums() function.
from routes.utils.artist import download_artist_albums
# from routes.utils.artist import download_artist_albums # Already imported at top
# Delegate to the download_artist_albums function which will handle album filtering
task_ids = download_artist_albums(
successfully_queued_albums, duplicate_albums = download_artist_albums(
url=url,
album_type=album_type,
request_args=request.args.to_dict()
)
# Return the list of album task IDs.
response_data = {
"status": "complete",
"message": f"Artist discography processing initiated. {len(successfully_queued_albums)} albums queued.",
"queued_albums": successfully_queued_albums
}
if duplicate_albums:
response_data["duplicate_albums"] = duplicate_albums
response_data["message"] += f" {len(duplicate_albums)} albums were already in progress or queued."
return Response(
json.dumps({
"status": "complete",
"task_ids": task_ids,
"message": f"Artist discography queued {len(task_ids)} album tasks have been queued."
}),
status=202,
json.dumps(response_data),
status=202, # Still 202 Accepted as some operations may have succeeded
mimetype='application/json'
)
except Exception as e:
@@ -97,8 +123,22 @@ def get_artist_info():
)
try:
from routes.utils.get_info import get_spotify_info
artist_info = get_spotify_info(spotify_id, "artist")
artist_info = get_spotify_info(spotify_id, "artist_discography")
# If artist_info is successfully fetched (it contains album items),
# check if the artist is watched and augment album items with is_locally_known status
if artist_info and artist_info.get('items'):
watched_artist_details = get_watched_artist(spotify_id) # spotify_id is the artist ID
if watched_artist_details: # Artist is being watched
for album_item in artist_info['items']:
if album_item and album_item.get('id'):
album_id = album_item['id']
album_item['is_locally_known'] = is_album_in_artist_db(spotify_id, album_id)
elif album_item: # Album object exists but no ID
album_item['is_locally_known'] = False
# If not watched, or no albums, is_locally_known will not be added.
# Frontend should handle absence of this key as false.
return Response(
json.dumps(artist_info),
status=200,
@@ -113,3 +153,178 @@ def get_artist_info():
status=500,
mimetype='application/json'
)
# --- Merged Artist Watch Routes ---
@artist_bp.route('/watch/<string:artist_spotify_id>', methods=['PUT'])
def add_artist_to_watchlist(artist_spotify_id):
"""Adds an artist to the watchlist."""
logger.info(f"Attempting to add artist {artist_spotify_id} to watchlist.")
try:
if get_watched_artist(artist_spotify_id):
return jsonify({"message": f"Artist {artist_spotify_id} is already being watched."}), 200
# This call returns an album list-like structure based on logs
artist_album_list_data = get_spotify_info(artist_spotify_id, "artist_discography")
# Check if we got any data and if it has items
if not artist_album_list_data or not isinstance(artist_album_list_data.get('items'), list):
logger.error(f"Could not fetch album list details for artist {artist_spotify_id} from Spotify using get_spotify_info('artist_discography'). Data: {artist_album_list_data}")
return jsonify({"error": f"Could not fetch sufficient details for artist {artist_spotify_id} to initiate watch."}), 404
# Attempt to extract artist name and verify ID
# The actual artist name might be consistently found in the items, if they exist
artist_name_from_albums = "Unknown Artist" # Default
if artist_album_list_data['items']:
first_album = artist_album_list_data['items'][0]
if first_album and isinstance(first_album.get('artists'), list) and first_album['artists']:
# Find the artist in the list that matches the artist_spotify_id
found_artist = next((art for art in first_album['artists'] if art.get('id') == artist_spotify_id), None)
if found_artist and found_artist.get('name'):
artist_name_from_albums = found_artist['name']
elif first_album['artists'][0].get('name'): # Fallback to first artist if specific match not found or no ID
artist_name_from_albums = first_album['artists'][0]['name']
logger.warning(f"Could not find exact artist ID {artist_spotify_id} in first album's artists list. Using name '{artist_name_from_albums}'.")
else:
logger.warning(f"No album items found for artist {artist_spotify_id} to extract name. Using default.")
# Construct the artist_data object expected by add_artist_db
# We use the provided artist_spotify_id as the primary ID.
artist_data_for_db = {
"id": artist_spotify_id, # This is the crucial part
"name": artist_name_from_albums,
"albums": { # Mimic structure if add_artist_db expects it for total_albums
"total": artist_album_list_data.get('total', 0)
}
# Add any other fields add_artist_db might expect from a true artist object if necessary
}
add_artist_db(artist_data_for_db)
logger.info(f"Artist {artist_spotify_id} ('{artist_name_from_albums}') added to watchlist. Their albums will be processed by the watch manager.")
return jsonify({"message": f"Artist {artist_spotify_id} added to watchlist. Albums will be processed shortly."}), 201
except Exception as e:
logger.error(f"Error adding artist {artist_spotify_id} to watchlist: {e}", exc_info=True)
return jsonify({"error": f"Could not add artist to watchlist: {str(e)}"}), 500
@artist_bp.route('/watch/<string:artist_spotify_id>/status', methods=['GET'])
def get_artist_watch_status(artist_spotify_id):
"""Checks if a specific artist is being watched."""
logger.info(f"Checking watch status for artist {artist_spotify_id}.")
try:
artist = get_watched_artist(artist_spotify_id)
if artist:
return jsonify({"is_watched": True, "artist_data": dict(artist)}), 200
else:
return jsonify({"is_watched": False}), 200
except Exception as e:
logger.error(f"Error checking watch status for artist {artist_spotify_id}: {e}", exc_info=True)
return jsonify({"error": f"Could not check watch status: {str(e)}"}), 500
@artist_bp.route('/watch/<string:artist_spotify_id>', methods=['DELETE'])
def remove_artist_from_watchlist(artist_spotify_id):
"""Removes an artist from the watchlist."""
logger.info(f"Attempting to remove artist {artist_spotify_id} from watchlist.")
try:
if not get_watched_artist(artist_spotify_id):
return jsonify({"error": f"Artist {artist_spotify_id} not found in watchlist."}), 404
remove_artist_db(artist_spotify_id)
logger.info(f"Artist {artist_spotify_id} removed from watchlist successfully.")
return jsonify({"message": f"Artist {artist_spotify_id} removed from watchlist."}), 200
except Exception as e:
logger.error(f"Error removing artist {artist_spotify_id} from watchlist: {e}", exc_info=True)
return jsonify({"error": f"Could not remove artist from watchlist: {str(e)}"}), 500
@artist_bp.route('/watch/list', methods=['GET'])
def list_watched_artists_endpoint():
"""Lists all artists currently in the watchlist."""
try:
artists = get_watched_artists()
return jsonify([dict(artist) for artist in artists]), 200
except Exception as e:
logger.error(f"Error listing watched artists: {e}", exc_info=True)
return jsonify({"error": f"Could not list watched artists: {str(e)}"}), 500
@artist_bp.route('/watch/trigger_check', methods=['POST'])
def trigger_artist_check_endpoint():
"""Manually triggers the artist checking mechanism for all watched artists."""
logger.info("Manual trigger for artist check received for all artists.")
try:
thread = threading.Thread(target=check_watched_artists, args=(None,))
thread.start()
return jsonify({"message": "Artist check triggered successfully in the background for all artists."}), 202
except Exception as e:
logger.error(f"Error manually triggering artist check for all: {e}", exc_info=True)
return jsonify({"error": f"Could not trigger artist check for all: {str(e)}"}), 500
@artist_bp.route('/watch/trigger_check/<string:artist_spotify_id>', methods=['POST'])
def trigger_specific_artist_check_endpoint(artist_spotify_id: str):
"""Manually triggers the artist checking mechanism for a specific artist."""
logger.info(f"Manual trigger for specific artist check received for ID: {artist_spotify_id}")
try:
watched_artist = get_watched_artist(artist_spotify_id)
if not watched_artist:
logger.warning(f"Trigger specific check: Artist ID {artist_spotify_id} not found in watchlist.")
return jsonify({"error": f"Artist {artist_spotify_id} is not in the watchlist. Add it first."}), 404
thread = threading.Thread(target=check_watched_artists, args=(artist_spotify_id,))
thread.start()
logger.info(f"Artist check triggered in background for specific artist ID: {artist_spotify_id}")
return jsonify({"message": f"Artist check triggered successfully in the background for {artist_spotify_id}."}), 202
except Exception as e:
logger.error(f"Error manually triggering specific artist check for {artist_spotify_id}: {e}", exc_info=True)
return jsonify({"error": f"Could not trigger artist check for {artist_spotify_id}: {str(e)}"}), 500
@artist_bp.route('/watch/<string:artist_spotify_id>/albums', methods=['POST'])
def mark_albums_as_known_for_artist(artist_spotify_id):
"""Fetches details for given album IDs and adds/updates them in the artist's local DB table."""
logger.info(f"Attempting to mark albums as known for artist {artist_spotify_id}.")
try:
album_ids = request.json
if not isinstance(album_ids, list) or not all(isinstance(aid, str) for aid in album_ids):
return jsonify({"error": "Invalid request body. Expecting a JSON array of album Spotify IDs."}), 400
if not get_watched_artist(artist_spotify_id):
return jsonify({"error": f"Artist {artist_spotify_id} is not being watched."}), 404
fetched_albums_details = []
for album_id in album_ids:
try:
# We need full album details. get_spotify_info with type "album" should provide this.
album_detail = get_spotify_info(album_id, "album")
if album_detail and album_detail.get('id'):
fetched_albums_details.append(album_detail)
else:
logger.warning(f"Could not fetch details for album {album_id} when marking as known for artist {artist_spotify_id}.")
except Exception as e:
logger.error(f"Failed to fetch Spotify details for album {album_id}: {e}")
if not fetched_albums_details:
return jsonify({"message": "No valid album details could be fetched to mark as known.", "processed_count": 0}), 200
processed_count = add_specific_albums_to_artist_table(artist_spotify_id, fetched_albums_details)
logger.info(f"Successfully marked/updated {processed_count} albums as known for artist {artist_spotify_id}.")
return jsonify({"message": f"Successfully processed {processed_count} albums for artist {artist_spotify_id}."}), 200
except Exception as e:
logger.error(f"Error marking albums as known for artist {artist_spotify_id}: {e}", exc_info=True)
return jsonify({"error": f"Could not mark albums as known: {str(e)}"}), 500
@artist_bp.route('/watch/<string:artist_spotify_id>/albums', methods=['DELETE'])
def mark_albums_as_missing_locally_for_artist(artist_spotify_id):
"""Removes specified albums from the artist's local DB table."""
logger.info(f"Attempting to mark albums as missing (delete locally) for artist {artist_spotify_id}.")
try:
album_ids = request.json
if not isinstance(album_ids, list) or not all(isinstance(aid, str) for aid in album_ids):
return jsonify({"error": "Invalid request body. Expecting a JSON array of album Spotify IDs."}), 400
if not get_watched_artist(artist_spotify_id):
return jsonify({"error": f"Artist {artist_spotify_id} is not being watched."}), 404
deleted_count = remove_specific_albums_from_artist_table(artist_spotify_id, album_ids)
logger.info(f"Successfully removed {deleted_count} albums locally for artist {artist_spotify_id}.")
return jsonify({"message": f"Successfully removed {deleted_count} albums locally for artist {artist_spotify_id}."}), 200
except Exception as e:
logger.error(f"Error marking albums as missing (deleting locally) for artist {artist_spotify_id}: {e}", exc_info=True)
return jsonify({"error": f"Could not mark albums as missing: {str(e)}"}), 500

View File

@@ -7,7 +7,8 @@ import time
import os
config_bp = Blueprint('config_bp', __name__)
CONFIG_PATH = Path('./config/main.json')
CONFIG_PATH = Path('./data/config/main.json')
CONFIG_PATH_WATCH = Path('./data/config/watch.json')
# Flag for config change notifications
config_changed = False
@@ -63,6 +64,41 @@ def save_config(config_data):
logging.error(f"Error saving config: {str(e)}")
return False
def get_watch_config():
"""Reads watch.json and returns its content or defaults."""
try:
if not CONFIG_PATH_WATCH.exists():
CONFIG_PATH_WATCH.parent.mkdir(parents=True, exist_ok=True)
# Default watch config
defaults = {
'enabled': False,
'watchedArtistAlbumGroup': ["album", "single"],
'watchPollIntervalSeconds': 3600
}
CONFIG_PATH_WATCH.write_text(json.dumps(defaults, indent=2))
return defaults
with open(CONFIG_PATH_WATCH, 'r') as f:
return json.load(f)
except Exception as e:
logging.error(f"Error reading watch config: {str(e)}")
# Return defaults on error to prevent crashes
return {
'enabled': False,
'watchedArtistAlbumGroup': ["album", "single"],
'watchPollIntervalSeconds': 3600
}
def save_watch_config(watch_config_data):
"""Saves data to watch.json."""
try:
CONFIG_PATH_WATCH.parent.mkdir(parents=True, exist_ok=True)
with open(CONFIG_PATH_WATCH, 'w') as f:
json.dump(watch_config_data, f, indent=2)
return True
except Exception as e:
logging.error(f"Error saving watch config: {str(e)}")
return False
@config_bp.route('/config', methods=['GET'])
def handle_config():
config = get_config()
@@ -70,7 +106,7 @@ def handle_config():
return jsonify({"error": "Could not read config file"}), 500
# Create config/state directory
Path('./config/state').mkdir(parents=True, exist_ok=True)
Path('./data/config/state').mkdir(parents=True, exist_ok=True)
# Set default values for any missing config options
defaults = {
@@ -116,7 +152,14 @@ def update_config():
if not save_config(new_config):
return jsonify({"error": "Failed to save config"}), 500
return jsonify({"message": "Config updated successfully"})
# Return the updated config
updated_config_values = get_config()
if updated_config_values is None:
# This case should ideally not be reached if save_config succeeded
# and get_config handles errors by returning a default or None.
return jsonify({"error": "Failed to retrieve configuration after saving"}), 500
return jsonify(updated_config_values)
except json.JSONDecodeError:
return jsonify({"error": "Invalid JSON data"}), 400
except Exception as e:
@@ -142,3 +185,39 @@ def check_config_changes():
"changed": has_changed,
"last_config": last_config
})
@config_bp.route('/config/watch', methods=['GET'])
def handle_watch_config():
watch_config = get_watch_config()
# Ensure defaults are applied if file was corrupted or missing fields
defaults = {
'enabled': False,
'watchedArtistAlbumGroup': ["album", "single"],
'watchPollIntervalSeconds': 3600
}
for key, default_value in defaults.items():
if key not in watch_config:
watch_config[key] = default_value
return jsonify(watch_config)
@config_bp.route('/config/watch', methods=['POST', 'PUT'])
def update_watch_config():
try:
new_watch_config = request.get_json()
if not isinstance(new_watch_config, dict):
return jsonify({"error": "Invalid watch config format"}), 400
if not save_watch_config(new_watch_config):
return jsonify({"error": "Failed to save watch config"}), 500
updated_watch_config_values = get_watch_config()
if updated_watch_config_values is None:
return jsonify({"error": "Failed to retrieve watch configuration after saving"}), 500
return jsonify(updated_watch_config_values)
except json.JSONDecodeError:
return jsonify({"error": "Invalid JSON data for watch config"}), 400
except Exception as e:
logging.error(f"Error updating watch config: {str(e)}")
return jsonify({"error": "Failed to update watch config"}), 500

View File

@@ -69,7 +69,7 @@ def handle_search_credential(service, name):
return jsonify({"error": "Both client_id and client_secret are required"}), 400
# For POST, first check if the credentials directory exists
if request.method == 'POST' and not any(Path(f'./creds/{service}/{name}').glob('*.json')):
if request.method == 'POST' and not any(Path(f'./data/{service}/{name}').glob('*.json')):
return jsonify({"error": f"Account '{name}' doesn't exist. Create it first."}), 404
# Create or update search credentials

42
routes/history.py Normal file
View File

@@ -0,0 +1,42 @@
from flask import Blueprint, jsonify, request
from routes.utils.history_manager import get_history_entries
import logging
logger = logging.getLogger(__name__)
history_bp = Blueprint('history', __name__, url_prefix='/api/history')
@history_bp.route('', methods=['GET'])
def get_download_history():
"""API endpoint to retrieve download history with pagination, sorting, and filtering."""
try:
limit = request.args.get('limit', 25, type=int)
offset = request.args.get('offset', 0, type=int)
sort_by = request.args.get('sort_by', 'timestamp_completed')
sort_order = request.args.get('sort_order', 'DESC')
# Basic filtering example: filter by status_final or download_type
filters = {}
status_filter = request.args.get('status_final')
if status_filter:
filters['status_final'] = status_filter
type_filter = request.args.get('download_type')
if type_filter:
filters['download_type'] = type_filter
# Add more filters as needed, e.g., by item_name (would need LIKE for partial match)
# search_term = request.args.get('search')
# if search_term:
# filters['item_name'] = f'%{search_term}%' # This would require LIKE in get_history_entries
entries, total_count = get_history_entries(limit, offset, sort_by, sort_order, filters)
return jsonify({
'entries': entries,
'total_count': total_count,
'limit': limit,
'offset': offset
})
except Exception as e:
logger.error(f"Error in /api/history endpoint: {e}", exc_info=True)
return jsonify({"error": "Failed to retrieve download history"}), 500

View File

@@ -1,41 +1,105 @@
from flask import Blueprint, Response, request
from flask import Blueprint, Response, request, jsonify
import os
import json
import traceback
import logging # Added logging import
import uuid # For generating error task IDs
import time # For timestamps
from routes.utils.celery_queue_manager import download_queue_manager
from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState # For error task creation
import threading # For playlist watch trigger
playlist_bp = Blueprint('playlist', __name__)
# Imports from playlist_watch.py
from routes.utils.watch.db import (
add_playlist_to_watch as add_playlist_db,
remove_playlist_from_watch as remove_playlist_db,
get_watched_playlist,
get_watched_playlists,
add_specific_tracks_to_playlist_table,
remove_specific_tracks_from_playlist_table,
is_track_in_playlist_db # Added import
)
from routes.utils.get_info import get_spotify_info # Already used, but ensure it's here
from routes.utils.watch.manager import check_watched_playlists # For manual trigger
@playlist_bp.route('/download', methods=['GET'])
def handle_download():
logger = logging.getLogger(__name__) # Added logger initialization
playlist_bp = Blueprint('playlist', __name__, url_prefix='/api/playlist')
@playlist_bp.route('/download/<playlist_id>', methods=['GET'])
def handle_download(playlist_id):
# Retrieve essential parameters from the request.
url = request.args.get('url')
name = request.args.get('name')
artist = request.args.get('artist')
# name = request.args.get('name') # Removed
# artist = request.args.get('artist') # Removed
orig_params = request.args.to_dict()
# Construct the URL from playlist_id
url = f"https://open.spotify.com/playlist/{playlist_id}"
orig_params["original_url"] = request.url # Update original_url to the constructed one
# Fetch metadata from Spotify
try:
playlist_info = get_spotify_info(playlist_id, "playlist")
if not playlist_info or not playlist_info.get('name') or not playlist_info.get('owner'):
return Response(
json.dumps({"error": f"Could not retrieve metadata for playlist ID: {playlist_id}"}),
status=404,
mimetype='application/json'
)
name_from_spotify = playlist_info.get('name')
# Use owner's display_name as the 'artist' for playlists
owner_info = playlist_info.get('owner', {})
artist_from_spotify = owner_info.get('display_name', "Unknown Owner")
except Exception as e:
return Response(
json.dumps({"error": f"Failed to fetch metadata for playlist {playlist_id}: {str(e)}"}),
status=500,
mimetype='application/json'
)
# Validate required parameters
if not url:
if not url: # This check might be redundant now but kept for safety
return Response(
json.dumps({"error": "Missing required parameter: url"}),
status=400,
mimetype='application/json'
)
# Add the task to the queue with only essential parameters
# The queue manager will now handle all config parameters
# Include full original request URL in metadata
orig_params = request.args.to_dict()
orig_params["original_url"] = request.url
task_id = download_queue_manager.add_task({
"download_type": "playlist",
"url": url,
"name": name,
"artist": artist,
"orig_request": orig_params
})
try:
task_id = download_queue_manager.add_task({
"download_type": "playlist",
"url": url,
"name": name_from_spotify, # Use fetched name
"artist": artist_from_spotify, # Use fetched owner name as artist
"orig_request": orig_params
})
# Removed DuplicateDownloadError handling, add_task now manages this by creating an error task.
except Exception as e:
# Generic error handling for other issues during task submission
error_task_id = str(uuid.uuid4())
store_task_info(error_task_id, {
"download_type": "playlist",
"url": url,
"name": name_from_spotify, # Use fetched name
"artist": artist_from_spotify, # Use fetched owner name as artist
"original_request": orig_params,
"created_at": time.time(),
"is_submission_error_task": True
})
store_task_status(error_task_id, {
"status": ProgressState.ERROR,
"error": f"Failed to queue playlist download: {str(e)}",
"timestamp": time.time()
})
return Response(
json.dumps({"error": f"Failed to queue playlist download: {str(e)}", "task_id": error_task_id}),
status=500,
mimetype='application/json'
)
return Response(
json.dumps({"prg_file": task_id}),
json.dumps({"prg_file": task_id}), # prg_file is the old name for task_id
status=202,
mimetype='application/json'
)
@@ -80,8 +144,23 @@ def get_playlist_info():
try:
# Import and use the get_spotify_info function from the utility module.
from routes.utils.get_info import get_spotify_info
playlist_info = get_spotify_info(spotify_id, "playlist")
# If playlist_info is successfully fetched, check if it's watched
# and augment track items with is_locally_known status
if playlist_info and playlist_info.get('id'):
watched_playlist_details = get_watched_playlist(playlist_info['id'])
if watched_playlist_details: # Playlist is being watched
if playlist_info.get('tracks') and playlist_info['tracks'].get('items'):
for item in playlist_info['tracks']['items']:
if item and item.get('track') and item['track'].get('id'):
track_id = item['track']['id']
item['track']['is_locally_known'] = is_track_in_playlist_db(playlist_info['id'], track_id)
elif item and item.get('track'): # Track object exists but no ID
item['track']['is_locally_known'] = False
# If not watched, or no tracks, is_locally_known will not be added, or tracks won't exist to add it to.
# Frontend should handle absence of this key as false.
return Response(
json.dumps(playlist_info),
status=200,
@@ -97,3 +176,160 @@ def get_playlist_info():
status=500,
mimetype='application/json'
)
@playlist_bp.route('/watch/<string:playlist_spotify_id>', methods=['PUT'])
def add_to_watchlist(playlist_spotify_id):
"""Adds a playlist to the watchlist."""
logger.info(f"Attempting to add playlist {playlist_spotify_id} to watchlist.")
try:
# Check if already watched
if get_watched_playlist(playlist_spotify_id):
return jsonify({"message": f"Playlist {playlist_spotify_id} is already being watched."}), 200
# Fetch playlist details from Spotify to populate our DB
playlist_data = get_spotify_info(playlist_spotify_id, "playlist")
if not playlist_data or 'id' not in playlist_data:
logger.error(f"Could not fetch details for playlist {playlist_spotify_id} from Spotify.")
return jsonify({"error": f"Could not fetch details for playlist {playlist_spotify_id} from Spotify."}), 404
add_playlist_db(playlist_data) # This also creates the tracks table
# REMOVED: Do not add initial tracks directly to DB.
# The playlist watch manager will pick them up as new and queue downloads.
# Tracks will be added to DB only after successful download via Celery task callback.
# initial_track_items = playlist_data.get('tracks', {}).get('items', [])
# if initial_track_items:
# from routes.utils.watch.db import add_tracks_to_playlist_db # Keep local import for clarity
# add_tracks_to_playlist_db(playlist_spotify_id, initial_track_items)
logger.info(f"Playlist {playlist_spotify_id} added to watchlist. Its tracks will be processed by the watch manager.")
return jsonify({"message": f"Playlist {playlist_spotify_id} added to watchlist. Tracks will be processed shortly."}), 201
except Exception as e:
logger.error(f"Error adding playlist {playlist_spotify_id} to watchlist: {e}", exc_info=True)
return jsonify({"error": f"Could not add playlist to watchlist: {str(e)}"}), 500
@playlist_bp.route('/watch/<string:playlist_spotify_id>/status', methods=['GET'])
def get_playlist_watch_status(playlist_spotify_id):
"""Checks if a specific playlist is being watched."""
logger.info(f"Checking watch status for playlist {playlist_spotify_id}.")
try:
playlist = get_watched_playlist(playlist_spotify_id)
if playlist:
return jsonify({"is_watched": True, "playlist_data": playlist}), 200
else:
# Return 200 with is_watched: false, so frontend can clearly distinguish
# between "not watched" and an actual error fetching status.
return jsonify({"is_watched": False}), 200
except Exception as e:
logger.error(f"Error checking watch status for playlist {playlist_spotify_id}: {e}", exc_info=True)
return jsonify({"error": f"Could not check watch status: {str(e)}"}), 500
@playlist_bp.route('/watch/<string:playlist_spotify_id>', methods=['DELETE'])
def remove_from_watchlist(playlist_spotify_id):
"""Removes a playlist from the watchlist."""
logger.info(f"Attempting to remove playlist {playlist_spotify_id} from watchlist.")
try:
if not get_watched_playlist(playlist_spotify_id):
return jsonify({"error": f"Playlist {playlist_spotify_id} not found in watchlist."}), 404
remove_playlist_db(playlist_spotify_id)
logger.info(f"Playlist {playlist_spotify_id} removed from watchlist successfully.")
return jsonify({"message": f"Playlist {playlist_spotify_id} removed from watchlist."}), 200
except Exception as e:
logger.error(f"Error removing playlist {playlist_spotify_id} from watchlist: {e}", exc_info=True)
return jsonify({"error": f"Could not remove playlist from watchlist: {str(e)}"}), 500
@playlist_bp.route('/watch/<string:playlist_spotify_id>/tracks', methods=['POST'])
def mark_tracks_as_known(playlist_spotify_id):
"""Fetches details for given track IDs and adds/updates them in the playlist's local DB table."""
logger.info(f"Attempting to mark tracks as known for playlist {playlist_spotify_id}.")
try:
track_ids = request.json
if not isinstance(track_ids, list) or not all(isinstance(tid, str) for tid in track_ids):
return jsonify({"error": "Invalid request body. Expecting a JSON array of track Spotify IDs."}), 400
if not get_watched_playlist(playlist_spotify_id):
return jsonify({"error": f"Playlist {playlist_spotify_id} is not being watched."}), 404
fetched_tracks_details = []
for track_id in track_ids:
try:
track_detail = get_spotify_info(track_id, "track")
if track_detail and track_detail.get('id'):
fetched_tracks_details.append(track_detail)
else:
logger.warning(f"Could not fetch details for track {track_id} when marking as known for playlist {playlist_spotify_id}.")
except Exception as e:
logger.error(f"Failed to fetch Spotify details for track {track_id}: {e}")
if not fetched_tracks_details:
return jsonify({"message": "No valid track details could be fetched to mark as known.", "processed_count": 0}), 200
add_specific_tracks_to_playlist_table(playlist_spotify_id, fetched_tracks_details)
logger.info(f"Successfully marked/updated {len(fetched_tracks_details)} tracks as known for playlist {playlist_spotify_id}.")
return jsonify({"message": f"Successfully processed {len(fetched_tracks_details)} tracks for playlist {playlist_spotify_id}."}), 200
except Exception as e:
logger.error(f"Error marking tracks as known for playlist {playlist_spotify_id}: {e}", exc_info=True)
return jsonify({"error": f"Could not mark tracks as known: {str(e)}"}), 500
@playlist_bp.route('/watch/<string:playlist_spotify_id>/tracks', methods=['DELETE'])
def mark_tracks_as_missing_locally(playlist_spotify_id):
"""Removes specified tracks from the playlist's local DB table."""
logger.info(f"Attempting to mark tracks as missing (delete locally) for playlist {playlist_spotify_id}.")
try:
track_ids = request.json
if not isinstance(track_ids, list) or not all(isinstance(tid, str) for tid in track_ids):
return jsonify({"error": "Invalid request body. Expecting a JSON array of track Spotify IDs."}), 400
if not get_watched_playlist(playlist_spotify_id):
return jsonify({"error": f"Playlist {playlist_spotify_id} is not being watched."}), 404
deleted_count = remove_specific_tracks_from_playlist_table(playlist_spotify_id, track_ids)
logger.info(f"Successfully removed {deleted_count} tracks locally for playlist {playlist_spotify_id}.")
return jsonify({"message": f"Successfully removed {deleted_count} tracks locally for playlist {playlist_spotify_id}."}), 200
except Exception as e:
logger.error(f"Error marking tracks as missing (deleting locally) for playlist {playlist_spotify_id}: {e}", exc_info=True)
return jsonify({"error": f"Could not mark tracks as missing: {str(e)}"}), 500
@playlist_bp.route('/watch/list', methods=['GET'])
def list_watched_playlists_endpoint():
"""Lists all playlists currently in the watchlist."""
try:
playlists = get_watched_playlists()
return jsonify(playlists), 200
except Exception as e:
logger.error(f"Error listing watched playlists: {e}", exc_info=True)
return jsonify({"error": f"Could not list watched playlists: {str(e)}"}), 500
@playlist_bp.route('/watch/trigger_check', methods=['POST'])
def trigger_playlist_check_endpoint():
"""Manually triggers the playlist checking mechanism for all watched playlists."""
logger.info("Manual trigger for playlist check received for all playlists.")
try:
# Run check_watched_playlists without an ID to check all
thread = threading.Thread(target=check_watched_playlists, args=(None,))
thread.start()
return jsonify({"message": "Playlist check triggered successfully in the background for all playlists."}), 202
except Exception as e:
logger.error(f"Error manually triggering playlist check for all: {e}", exc_info=True)
return jsonify({"error": f"Could not trigger playlist check for all: {str(e)}"}), 500
@playlist_bp.route('/watch/trigger_check/<string:playlist_spotify_id>', methods=['POST'])
def trigger_specific_playlist_check_endpoint(playlist_spotify_id: str):
"""Manually triggers the playlist checking mechanism for a specific playlist."""
logger.info(f"Manual trigger for specific playlist check received for ID: {playlist_spotify_id}")
try:
# Check if the playlist is actually in the watchlist first
watched_playlist = get_watched_playlist(playlist_spotify_id)
if not watched_playlist:
logger.warning(f"Trigger specific check: Playlist ID {playlist_spotify_id} not found in watchlist.")
return jsonify({"error": f"Playlist {playlist_spotify_id} is not in the watchlist. Add it first."}), 404
# Run check_watched_playlists with the specific ID
thread = threading.Thread(target=check_watched_playlists, args=(playlist_spotify_id,))
thread.start()
logger.info(f"Playlist check triggered in background for specific playlist ID: {playlist_spotify_id}")
return jsonify({"message": f"Playlist check triggered successfully in the background for {playlist_spotify_id}."}), 202
except Exception as e:
logger.error(f"Error manually triggering specific playlist check for {playlist_spotify_id}: {e}", exc_info=True)
return jsonify({"error": f"Could not trigger playlist check for {playlist_spotify_id}: {str(e)}"}), 500

View File

@@ -1,4 +1,4 @@
from flask import Blueprint, abort, jsonify, Response, stream_with_context
from flask import Blueprint, abort, jsonify, Response, stream_with_context, request
import os
import json
import logging
@@ -38,11 +38,38 @@ def get_prg_file(task_id):
task_info = get_task_info(task_id)
if not task_info:
abort(404, "Task not found")
original_request = task_info.get("original_request", {})
# Dynamically construct original_url
dynamic_original_url = ""
download_type = task_info.get("download_type")
# The 'url' field in task_info stores the Spotify/Deezer URL of the item
# e.g., https://open.spotify.com/album/albumId or https://www.deezer.com/track/trackId
item_url = task_info.get("url")
if download_type and item_url:
try:
# Extract the ID from the item_url (last part of the path)
item_id = item_url.split('/')[-1]
if item_id: # Ensure item_id is not empty
base_url = request.host_url.rstrip('/')
dynamic_original_url = f"{base_url}/api/{download_type}/download/{item_id}"
else:
logger.warning(f"Could not extract item ID from URL: {item_url} for task {task_id}. Falling back for original_url.")
original_request_obj = task_info.get("original_request", {})
dynamic_original_url = original_request_obj.get("original_url", "")
except Exception as e:
logger.error(f"Error constructing dynamic original_url for task {task_id}: {e}", exc_info=True)
original_request_obj = task_info.get("original_request", {})
dynamic_original_url = original_request_obj.get("original_url", "") # Fallback on any error
else:
logger.warning(f"Missing download_type ('{download_type}') or item_url ('{item_url}') in task_info for task {task_id}. Falling back for original_url.")
original_request_obj = task_info.get("original_request", {})
dynamic_original_url = original_request_obj.get("original_url", "")
last_status = get_last_task_status(task_id)
status_count = len(get_task_status(task_id))
response = {
"original_url": original_request.get("original_url", ""),
"original_url": dynamic_original_url,
"last_line": last_status,
"timestamp": time.time(),
"task_id": task_id,
@@ -75,12 +102,53 @@ def delete_prg_file(task_id):
def list_prg_files():
"""
Retrieve a list of all tasks in the system.
Combines results from both the old PRG file system and the new task ID based system.
Returns a detailed list of task objects including status and metadata.
"""
# List only new system tasks
tasks = get_all_tasks()
task_ids = [task["task_id"] for task in tasks]
return jsonify(task_ids)
try:
tasks = get_all_tasks() # This already gets summary data
detailed_tasks = []
for task_summary in tasks:
task_id = task_summary.get("task_id")
if not task_id:
continue
task_info = get_task_info(task_id)
last_status = get_last_task_status(task_id)
if task_info and last_status:
detailed_tasks.append({
"task_id": task_id,
"type": task_info.get("type", task_summary.get("type", "unknown")),
"name": task_info.get("name", task_summary.get("name", "Unknown")),
"artist": task_info.get("artist", task_summary.get("artist", "")),
"download_type": task_info.get("download_type", task_summary.get("download_type", "unknown")),
"status": last_status.get("status", "unknown"), # Keep summary status for quick access
"last_status_obj": last_status, # Full last status object
"original_request": task_info.get("original_request", {}),
"created_at": task_info.get("created_at", 0),
"timestamp": last_status.get("timestamp", task_info.get("created_at", 0))
})
elif task_info: # If last_status is somehow missing, still provide some info
detailed_tasks.append({
"task_id": task_id,
"type": task_info.get("type", "unknown"),
"name": task_info.get("name", "Unknown"),
"artist": task_info.get("artist", ""),
"download_type": task_info.get("download_type", "unknown"),
"status": "unknown",
"last_status_obj": None,
"original_request": task_info.get("original_request", {}),
"created_at": task_info.get("created_at", 0),
"timestamp": task_info.get("created_at", 0)
})
# Sort tasks by creation time (newest first, or by timestamp if creation time is missing)
detailed_tasks.sort(key=lambda x: x.get('timestamp', x.get('created_at', 0)), reverse=True)
return jsonify(detailed_tasks)
except Exception as e:
logger.error(f"Error in /api/prgs/list: {e}", exc_info=True)
return jsonify({"error": "Failed to retrieve task list"}), 500
@prgs_bp.route('/retry/<task_id>', methods=['POST'])

View File

@@ -2,17 +2,45 @@ from flask import Blueprint, Response, request
import os
import json
import traceback
import uuid # For generating error task IDs
import time # For timestamps
from routes.utils.celery_queue_manager import download_queue_manager
from routes.utils.celery_tasks import store_task_info, store_task_status, ProgressState # For error task creation
from urllib.parse import urlparse # for URL validation
from routes.utils.get_info import get_spotify_info # Added import
track_bp = Blueprint('track', __name__)
@track_bp.route('/download', methods=['GET'])
def handle_download():
@track_bp.route('/download/<track_id>', methods=['GET'])
def handle_download(track_id):
# Retrieve essential parameters from the request.
url = request.args.get('url')
name = request.args.get('name')
artist = request.args.get('artist')
# name = request.args.get('name') # Removed
# artist = request.args.get('artist') # Removed
orig_params = request.args.to_dict()
# Construct the URL from track_id
url = f"https://open.spotify.com/track/{track_id}"
orig_params["original_url"] = url # Update original_url to the constructed one
# Fetch metadata from Spotify
try:
track_info = get_spotify_info(track_id, "track")
if not track_info or not track_info.get('name') or not track_info.get('artists'):
return Response(
json.dumps({"error": f"Could not retrieve metadata for track ID: {track_id}"}),
status=404,
mimetype='application/json'
)
name_from_spotify = track_info.get('name')
artist_from_spotify = track_info['artists'][0].get('name') if track_info['artists'] else "Unknown Artist"
except Exception as e:
return Response(
json.dumps({"error": f"Failed to fetch metadata for track {track_id}: {str(e)}"}),
status=500,
mimetype='application/json'
)
# Validate required parameters
if not url:
@@ -31,20 +59,40 @@ def handle_download():
mimetype='application/json'
)
# Add the task to the queue with only essential parameters
# The queue manager will now handle all config parameters
orig_params = request.args.to_dict()
orig_params["original_url"] = request.url
task_id = download_queue_manager.add_task({
"download_type": "track",
"url": url,
"name": name,
"artist": artist,
"orig_request": orig_params
})
try:
task_id = download_queue_manager.add_task({
"download_type": "track",
"url": url,
"name": name_from_spotify, # Use fetched name
"artist": artist_from_spotify, # Use fetched artist
"orig_request": orig_params
})
# Removed DuplicateDownloadError handling, add_task now manages this by creating an error task.
except Exception as e:
# Generic error handling for other issues during task submission
error_task_id = str(uuid.uuid4())
store_task_info(error_task_id, {
"download_type": "track",
"url": url,
"name": name_from_spotify, # Use fetched name
"artist": artist_from_spotify, # Use fetched artist
"original_request": orig_params,
"created_at": time.time(),
"is_submission_error_task": True
})
store_task_status(error_task_id, {
"status": ProgressState.ERROR,
"error": f"Failed to queue track download: {str(e)}",
"timestamp": time.time()
})
return Response(
json.dumps({"error": f"Failed to queue track download: {str(e)}", "task_id": error_task_id}),
status=500,
mimetype='application/json'
)
return Response(
json.dumps({"prg_file": task_id}),
json.dumps({"prg_file": task_id}), # prg_file is the old name for task_id
status=202,
mimetype='application/json'
)

View File

@@ -47,11 +47,11 @@ def download_album(
# Smartly determine where to look for Spotify search credentials
if service == 'spotify' and fallback:
# If fallback is enabled, use the fallback account for Spotify search credentials
search_creds_path = Path(f'./creds/spotify/{fallback}/search.json')
search_creds_path = Path(f'./data/creds/spotify/{fallback}/search.json')
print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}")
else:
# Otherwise use the main account for Spotify search credentials
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}")
if search_creds_path.exists():
@@ -77,7 +77,7 @@ def download_album(
deezer_error = None
try:
# Load Deezer credentials from 'main' under deezer directory
deezer_creds_dir = os.path.join('./creds/deezer', main)
deezer_creds_dir = os.path.join('./data/creds/deezer', main)
deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json'))
# DEBUG: Print Deezer credential paths being used
@@ -89,8 +89,8 @@ def download_album(
# List available directories to compare
print(f"DEBUG: Available Deezer credential directories:")
for dir_name in os.listdir('./creds/deezer'):
print(f"DEBUG: ./creds/deezer/{dir_name}")
for dir_name in os.listdir('./data/creds/deezer'):
print(f"DEBUG: ./data/creds/deezer/{dir_name}")
with open(deezer_creds_path, 'r') as f:
deezer_creds = json.load(f)
@@ -129,7 +129,7 @@ def download_album(
# Load fallback Spotify credentials and attempt download
try:
spo_creds_dir = os.path.join('./creds/spotify', fallback)
spo_creds_dir = os.path.join('./data/creds/spotify', fallback)
spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json'))
print(f"DEBUG: Using Spotify fallback credentials from: {spo_creds_path}")
@@ -173,7 +173,7 @@ def download_album(
# Original behavior: use Spotify main
if quality is None:
quality = 'HIGH'
creds_dir = os.path.join('./creds/spotify', main)
creds_dir = os.path.join('./data/creds/spotify', main)
credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json'))
print(f"DEBUG: Using Spotify main credentials from: {credentials_path}")
print(f"DEBUG: Credentials exist: {os.path.exists(credentials_path)}")
@@ -208,7 +208,7 @@ def download_album(
if quality is None:
quality = 'FLAC'
# Existing code remains the same, ignoring fallback
creds_dir = os.path.join('./creds/deezer', main)
creds_dir = os.path.join('./data/creds/deezer', main)
creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json'))
print(f"DEBUG: Using Deezer credentials from: {creds_path}")
print(f"DEBUG: Credentials exist: {os.path.exists(creds_path)}")

View File

@@ -6,6 +6,7 @@ import logging
from flask import Blueprint, Response, request, url_for
from routes.utils.celery_queue_manager import download_queue_manager, get_config_params
from routes.utils.get_info import get_spotify_info
from routes.utils.celery_tasks import get_last_task_status, ProgressState
from deezspot.easy_spoty import Spo
from deezspot.libutils.utils import get_ids, link_is_valid
@@ -32,7 +33,7 @@ def get_artist_discography(url, main, album_type='album,single,compilation,appea
# Initialize Spotify API with credentials
spotify_client_id = None
spotify_client_secret = None
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
if search_creds_path.exists():
try:
with open(search_creds_path, 'r') as f:
@@ -76,7 +77,7 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a
request_args (dict): Original request arguments for tracking
Returns:
list: List of task IDs for the queued album downloads
tuple: (list of successfully queued albums, list of duplicate albums)
"""
if not url:
raise ValueError("Missing required parameter: url")
@@ -133,10 +134,12 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a
if not filtered_albums:
logger.warning(f"No albums match the specified types: {album_type}")
return []
return [], []
# Queue each album as a separate download task
album_task_ids = []
successfully_queued_albums = []
duplicate_albums = [] # To store info about albums that were duplicates
for album in filtered_albums:
# Add detailed logging to inspect each album's structure and URLs
@@ -185,10 +188,38 @@ def download_artist_albums(url, album_type="album,single,compilation", request_a
# Debug log the task data being sent to the queue
logger.debug(f"Album task data: url={task_data['url']}, retry_url={task_data['retry_url']}")
# Add the task to the queue manager
task_id = download_queue_manager.add_task(task_data)
album_task_ids.append(task_id)
logger.info(f"Queued album download: {album_name} ({task_id})")
try:
task_id = download_queue_manager.add_task(task_data)
logger.info(f"Queued {len(album_task_ids)} album downloads for artist: {artist_name}")
return album_task_ids
# Check the status of the newly added task to see if it was marked as a duplicate error
last_status = get_last_task_status(task_id)
if last_status and last_status.get("status") == ProgressState.ERROR and last_status.get("existing_task_id"):
logger.warning(f"Album {album_name} (URL: {album_url}) is a duplicate. Error task ID: {task_id}. Existing task ID: {last_status.get('existing_task_id')}")
duplicate_albums.append({
"name": album_name,
"artist": album_artist,
"url": album_url,
"error_task_id": task_id, # This is the ID of the task marked as a duplicate error
"existing_task_id": last_status.get("existing_task_id"),
"message": last_status.get("message", "Duplicate download attempt.")
})
else:
# If not a duplicate error, it was successfully queued (or failed for other reasons handled by add_task)
# We only add to successfully_queued_albums if it wasn't a duplicate error from add_task
# Other errors from add_task (like submission failure) would also result in an error status for task_id
# but won't have 'existing_task_id'. The client can check the status of this task_id.
album_task_ids.append(task_id) # Keep track of all task_ids returned by add_task
successfully_queued_albums.append({
"name": album_name,
"artist": album_artist,
"url": album_url,
"task_id": task_id
})
logger.info(f"Queued album download: {album_name} ({task_id})")
except Exception as e: # Catch any other unexpected error from add_task itself (though it should be rare now)
logger.error(f"Failed to queue album {album_name} due to an unexpected error in add_task: {str(e)}")
# Optionally, collect these errors. For now, just logging and continuing.
logger.info(f"Artist album processing: {len(successfully_queued_albums)} queued, {len(duplicate_albums)} duplicates found.")
return successfully_queued_albums, duplicate_albums

View File

@@ -22,7 +22,7 @@ REDIS_BACKEND = os.getenv('REDIS_BACKEND', REDIS_URL)
logger.info(f"Redis configuration: REDIS_URL={REDIS_URL}, REDIS_BACKEND={REDIS_BACKEND}")
# Config path
CONFIG_PATH = './config/main.json'
CONFIG_PATH = './data/config/main.json'
def get_config_params():
"""

View File

@@ -11,11 +11,25 @@ import queue
import sys
import uuid
# Import Celery task utilities
from .celery_tasks import (
ProgressState,
get_task_info,
get_last_task_status,
store_task_status,
get_all_tasks as get_all_celery_tasks_info,
cleanup_stale_errors,
delayed_delete_task_data
)
from .celery_config import get_config_params
# Import history manager
from .history_manager import init_history_db
# Configure logging
logger = logging.getLogger(__name__)
# Configuration
CONFIG_PATH = './config/main.json'
CONFIG_PATH = './data/config/main.json'
CELERY_APP = 'routes.utils.celery_tasks.celery_app'
CELERY_PROCESS = None
CONFIG_CHECK_INTERVAL = 30 # seconds
@@ -29,10 +43,128 @@ class CeleryManager:
self.celery_process = None
self.current_worker_count = 0
self.monitoring_thread = None
self.error_cleanup_thread = None
self.running = False
self.log_queue = queue.Queue()
self.output_threads = []
def _cleanup_stale_tasks(self):
logger.info("Cleaning up potentially stale Celery tasks...")
try:
tasks = get_all_celery_tasks_info()
if not tasks:
logger.info("No tasks found in Redis to check for staleness.")
return
active_stale_states = [
ProgressState.PROCESSING,
ProgressState.INITIALIZING,
ProgressState.DOWNLOADING,
ProgressState.PROGRESS,
ProgressState.REAL_TIME,
ProgressState.RETRYING
]
stale_tasks_count = 0
for task_summary in tasks:
task_id = task_summary.get("task_id")
if not task_id:
continue
last_status_data = get_last_task_status(task_id)
if last_status_data:
current_status_str = last_status_data.get("status")
if current_status_str in active_stale_states:
logger.warning(f"Task {task_id} ('{task_summary.get('name', 'Unknown')}') found in stale state '{current_status_str}'. Marking as error.")
task_info_details = get_task_info(task_id)
config = get_config_params()
error_payload = {
"status": ProgressState.ERROR,
"message": "Task interrupted due to application restart.",
"error": "Task interrupted due to application restart.",
"timestamp": time.time(),
"type": task_info_details.get("type", task_summary.get("type", "unknown")),
"name": task_info_details.get("name", task_summary.get("name", "Unknown")),
"artist": task_info_details.get("artist", task_summary.get("artist", "")),
"can_retry": True,
"retry_count": last_status_data.get("retry_count", 0),
"max_retries": config.get('maxRetries', 3)
}
store_task_status(task_id, error_payload)
stale_tasks_count += 1
# Schedule deletion for this interrupted task
logger.info(f"Task {task_id} was interrupted. Data scheduled for deletion in 30s.")
delayed_delete_task_data.apply_async(
args=[task_id, "Task interrupted by application restart and auto-cleaned."],
countdown=30
)
if stale_tasks_count > 0:
logger.info(f"Marked {stale_tasks_count} stale tasks as 'error'.")
else:
logger.info("No stale tasks found that needed cleanup (active states).")
# NEW: Check for tasks that are already terminal but might have missed their cleanup
logger.info("Checking for terminal tasks (COMPLETE, CANCELLED, terminal ERROR) that might have missed cleanup...")
cleaned_during_this_pass = 0
# `tasks` variable is from `get_all_celery_tasks_info()` called at the beginning of the method
for task_summary in tasks:
task_id = task_summary.get("task_id")
if not task_id:
continue
last_status_data = get_last_task_status(task_id)
if last_status_data:
current_status_str = last_status_data.get("status")
task_info_details = get_task_info(task_id) # Get full info for download_type etc.
cleanup_reason = ""
schedule_cleanup = False
if current_status_str == ProgressState.COMPLETE:
# If a task is COMPLETE (any download_type) and still here, its original scheduled deletion was missed.
logger.warning(f"Task {task_id} ('{task_summary.get('name', 'Unknown')}', type: {task_info_details.get('download_type')}) is COMPLETE and still in Redis. Re-scheduling cleanup.")
cleanup_reason = f"Task ({task_info_details.get('download_type')}) was COMPLETE; re-scheduling auto-cleanup."
schedule_cleanup = True
elif current_status_str == ProgressState.CANCELLED:
logger.warning(f"Task {task_id} ('{task_summary.get('name', 'Unknown')}') is CANCELLED and still in Redis. Re-scheduling cleanup.")
cleanup_reason = "Task was CANCELLED; re-scheduling auto-cleanup."
schedule_cleanup = True
elif current_status_str == ProgressState.ERROR:
can_retry_flag = last_status_data.get("can_retry", False)
# is_submission_error_task and is_duplicate_error_task are flags on task_info, not typically on last_status
is_submission_error = task_info_details.get("is_submission_error_task", False)
is_duplicate_error = task_info_details.get("is_duplicate_error_task", False)
# Check if it's an error state that should have been cleaned up
if not can_retry_flag or is_submission_error or is_duplicate_error or last_status_data.get("status") == ProgressState.ERROR_RETRIED:
# ERROR_RETRIED means the original task is done and should be cleaned.
logger.warning(f"Task {task_id} ('{task_summary.get('name', 'Unknown')}') is in a terminal ERROR state ('{last_status_data.get('error')}') and still in Redis. Re-scheduling cleanup.")
cleanup_reason = f"Task was in terminal ERROR state ('{last_status_data.get('error', 'Unknown error')}'); re-scheduling auto-cleanup."
schedule_cleanup = True
elif current_status_str == ProgressState.ERROR_RETRIED:
# This state itself implies the task is terminal and its data can be cleaned.
logger.warning(f"Task {task_id} ('{task_summary.get('name', 'Unknown')}') is ERROR_RETRIED and still in Redis. Re-scheduling cleanup.")
cleanup_reason = "Task was ERROR_RETRIED; re-scheduling auto-cleanup."
schedule_cleanup = True
if schedule_cleanup:
delayed_delete_task_data.apply_async(
args=[task_id, cleanup_reason],
countdown=30 # Schedule with 30s delay
)
cleaned_during_this_pass +=1
if cleaned_during_this_pass > 0:
logger.info(f"Re-scheduled cleanup for {cleaned_during_this_pass} terminal tasks that were still in Redis.")
else:
logger.info("No additional terminal tasks found in Redis needing cleanup re-scheduling.")
except Exception as e:
logger.error(f"Error during stale task cleanup: {e}", exc_info=True)
def start(self):
"""Start the Celery manager and initial workers"""
if self.running:
@@ -40,6 +172,12 @@ class CeleryManager:
self.running = True
# Initialize history database
init_history_db()
# Clean up stale tasks BEFORE starting/restarting workers
self._cleanup_stale_tasks()
# Start initial workers
self._update_workers()
@@ -47,6 +185,10 @@ class CeleryManager:
self.monitoring_thread = threading.Thread(target=self._monitor_config, daemon=True)
self.monitoring_thread.start()
# Start periodic error cleanup thread
self.error_cleanup_thread = threading.Thread(target=self._run_periodic_error_cleanup, daemon=True)
self.error_cleanup_thread.start()
# Register shutdown handler
atexit.register(self.stop)
@@ -147,7 +289,7 @@ class CeleryManager:
'worker',
'--loglevel=info',
f'--concurrency={new_worker_count}',
'-Q', 'downloads',
'-Q', 'downloads,default',
'--logfile=-', # Output logs to stdout
'--without-heartbeat', # Reduce log noise
'--without-gossip', # Reduce log noise
@@ -257,5 +399,24 @@ class CeleryManager:
logger.error(f"Error in config monitoring thread: {e}")
time.sleep(5) # Wait before retrying
def _run_periodic_error_cleanup(self):
"""Periodically triggers the cleanup_stale_errors Celery task."""
cleanup_interval = 60 # Run cleanup task every 60 seconds
logger.info(f"Starting periodic error cleanup scheduler (runs every {cleanup_interval}s).")
while self.running:
try:
logger.info("Scheduling cleanup_stale_errors task...")
cleanup_stale_errors.delay() # Call the Celery task
except Exception as e:
logger.error(f"Error scheduling cleanup_stale_errors task: {e}", exc_info=True)
# Wait for the next interval
# Use a loop to check self.running more frequently to allow faster shutdown
for _ in range(cleanup_interval):
if not self.running:
break
time.sleep(1)
logger.info("Periodic error cleanup scheduler stopped.")
# Create single instance
celery_manager = CeleryManager()

View File

@@ -25,7 +25,7 @@ from routes.utils.celery_tasks import (
logger = logging.getLogger(__name__)
# Load configuration
CONFIG_PATH = './config/main.json'
CONFIG_PATH = './data/config/main.json'
try:
with open(CONFIG_PATH, 'r') as f:
config_data = json.load(f)
@@ -94,77 +94,120 @@ class CeleryDownloadQueueManager:
self.paused = False
print(f"Celery Download Queue Manager initialized with max_concurrent={self.max_concurrent}")
def add_task(self, task):
def add_task(self, task: dict, from_watch_job: bool = False):
"""
Add a new download task to the Celery queue
Add a new download task to the Celery queue.
- If from_watch_job is True and an active duplicate is found, the task is not queued and None is returned.
- If from_watch_job is False and an active duplicate is found, a new task ID is created,
set to an ERROR state indicating the duplicate, and this new error task's ID is returned.
Args:
task (dict): Task parameters including download_type, url, etc.
from_watch_job (bool): If True, duplicate active tasks are skipped. Defaults to False.
Returns:
str: Task ID
str | None: Task ID if successfully queued or an error task ID for non-watch duplicates.
None if from_watch_job is True and an active duplicate was found.
"""
try:
# Extract essential parameters
download_type = task.get("download_type", "unknown")
# Extract essential parameters for duplicate check
incoming_url = task.get("url")
incoming_type = task.get("download_type", "unknown")
# Debug existing task data
logger.debug(f"Adding {download_type} task with data: {json.dumps({k: v for k, v in task.items() if k != 'orig_request'})}")
if not incoming_url:
logger.warning("Task being added with no URL. Duplicate check might be unreliable.")
NON_BLOCKING_STATES = [
ProgressState.COMPLETE,
ProgressState.CANCELLED,
ProgressState.ERROR,
ProgressState.ERROR_RETRIED,
ProgressState.ERROR_AUTO_CLEANED
]
all_existing_tasks_summary = get_all_tasks()
if incoming_url:
for task_summary in all_existing_tasks_summary:
existing_task_id = task_summary.get("task_id")
if not existing_task_id:
continue
existing_task_info = get_task_info(existing_task_id)
existing_last_status_obj = get_last_task_status(existing_task_id)
if not existing_task_info or not existing_last_status_obj:
continue
existing_url = existing_task_info.get("url")
existing_type = existing_task_info.get("download_type")
existing_status = existing_last_status_obj.get("status")
if (existing_url == incoming_url and
existing_type == incoming_type and
existing_status not in NON_BLOCKING_STATES):
message = f"Duplicate download: URL '{incoming_url}' (type: {incoming_type}) is already being processed by task {existing_task_id} (status: {existing_status})."
logger.warning(message)
if from_watch_job:
logger.info(f"Task from watch job for {incoming_url} not queued due to active duplicate {existing_task_id}.")
return None # Skip execution for watch jobs
else:
# Create a new task_id for this duplicate request and mark it as an error
error_task_id = str(uuid.uuid4())
error_task_info_payload = {
"download_type": incoming_type,
"type": task.get("type", incoming_type),
"name": task.get("name", "Duplicate Task"),
"artist": task.get("artist", ""),
"url": incoming_url,
"original_request": task.get("orig_request", task.get("original_request", {})),
"created_at": time.time(),
"is_duplicate_error_task": True
}
store_task_info(error_task_id, error_task_info_payload)
error_status_payload = {
"status": ProgressState.ERROR,
"error": message,
"existing_task_id": existing_task_id,
"timestamp": time.time(),
"type": error_task_info_payload["type"],
"name": error_task_info_payload["name"],
"artist": error_task_info_payload["artist"]
}
store_task_status(error_task_id, error_status_payload)
return error_task_id # Return the ID of this new error-state task
# Create a unique task ID
task_id = str(uuid.uuid4())
# Get config parameters and process original request
config_params = get_config_params()
# Extract original request or use empty dict
original_request = task.get("orig_request", task.get("original_request", {}))
# Debug retry_url if present
if "retry_url" in task:
logger.debug(f"Task has retry_url: {task['retry_url']}")
# Build the complete task with config parameters
complete_task = {
"download_type": download_type,
"type": task.get("type", download_type),
"download_type": incoming_type,
"type": task.get("type", incoming_type),
"name": task.get("name", ""),
"artist": task.get("artist", ""),
"url": task.get("url", ""),
# Preserve retry_url if present
"retry_url": task.get("retry_url", ""),
# Use main account from config
"main": original_request.get("main", config_params['deezer']),
# Set fallback if enabled in config
"fallback": original_request.get("fallback",
config_params['spotify'] if config_params['fallback'] else None),
# Use default quality settings
"quality": original_request.get("quality", config_params['deezerQuality']),
"fall_quality": original_request.get("fall_quality", config_params['spotifyQuality']),
# Parse boolean parameters from string values
"real_time": self._parse_bool_param(original_request.get("real_time"), config_params['realTime']),
"custom_dir_format": original_request.get("custom_dir_format", config_params['customDirFormat']),
"custom_track_format": original_request.get("custom_track_format", config_params['customTrackFormat']),
# Parse boolean parameters from string values
"pad_tracks": self._parse_bool_param(original_request.get("tracknum_padding"), config_params['tracknum_padding']),
"retry_count": 0,
"original_request": original_request,
"created_at": time.time()
}
# Store the task info in Redis for later retrieval
store_task_info(task_id, complete_task)
# If from_watch_job is True, ensure track_details_for_db is passed through
if from_watch_job and "track_details_for_db" in task:
complete_task["track_details_for_db"] = task["track_details_for_db"]
# Store initial queued status
store_task_info(task_id, complete_task)
store_task_status(task_id, {
"status": ProgressState.QUEUED,
"timestamp": time.time(),
@@ -172,46 +215,35 @@ class CeleryDownloadQueueManager:
"name": complete_task["name"],
"artist": complete_task["artist"],
"retry_count": 0,
"queue_position": len(get_all_tasks()) + 1 # Approximate queue position
"queue_position": len(get_all_tasks()) + 1
})
# Launch the appropriate Celery task based on download_type
celery_task = None
celery_task_map = {
"track": download_track,
"album": download_album,
"playlist": download_playlist
}
if download_type == "track":
celery_task = download_track.apply_async(
kwargs=complete_task,
task_id=task_id,
countdown=0 if not self.paused else 3600 # Delay task if paused
)
elif download_type == "album":
celery_task = download_album.apply_async(
kwargs=complete_task,
task_id=task_id,
countdown=0 if not self.paused else 3600
)
elif download_type == "playlist":
celery_task = download_playlist.apply_async(
task_func = celery_task_map.get(incoming_type)
if task_func:
task_func.apply_async(
kwargs=complete_task,
task_id=task_id,
countdown=0 if not self.paused else 3600
)
logger.info(f"Added {incoming_type} download task {task_id} to Celery queue.")
return task_id
else:
# Store error status for unknown download type
store_task_status(task_id, {
"status": ProgressState.ERROR,
"message": f"Unsupported download type: {download_type}",
"message": f"Unsupported download type: {incoming_type}",
"timestamp": time.time()
})
logger.error(f"Unsupported download type: {download_type}")
return task_id # Still return the task_id so the error can be tracked
logger.info(f"Added {download_type} download task {task_id} to Celery queue")
return task_id
logger.error(f"Unsupported download type: {incoming_type}")
return task_id
except Exception as e:
logger.error(f"Error adding task to Celery queue: {e}", exc_info=True)
# Generate a task ID even for failed tasks so we can track the error
error_task_id = str(uuid.uuid4())
store_task_status(error_task_id, {
"status": ProgressState.ERROR,

View File

@@ -7,12 +7,19 @@ from datetime import datetime
from celery import Celery, Task, states
from celery.signals import task_prerun, task_postrun, task_failure, worker_ready, worker_init, setup_logging
from celery.exceptions import Retry
import os # Added for path operations
from pathlib import Path # Added for path operations
# Configure logging
logger = logging.getLogger(__name__)
# Setup Redis and Celery
from routes.utils.celery_config import REDIS_URL, REDIS_BACKEND, REDIS_PASSWORD, get_config_params
# Import for playlist watch DB update
from routes.utils.watch.db import add_single_track_to_playlist_db, add_or_update_album_for_artist
# Import history manager function
from .history_manager import add_entry_to_history
# Initialize Celery app
celery_app = Celery('download_tasks',
@@ -44,6 +51,8 @@ class ProgressState:
REAL_TIME = "real_time"
SKIPPED = "skipped"
DONE = "done"
ERROR_RETRIED = "ERROR_RETRIED" # Status for an error task that has been retried
ERROR_AUTO_CLEANED = "ERROR_AUTO_CLEANED" # Status for an error task that was auto-cleaned
# Reuse the application's logging configuration for Celery workers
@setup_logging.connect
@@ -140,20 +149,73 @@ def get_task_info(task_id):
logger.error(f"Error getting task info: {e}")
return {}
# --- History Logging Helper ---
def _log_task_to_history(task_id, final_status_str, error_msg=None):
"""Helper function to gather task data and log it to the history database."""
try:
task_info = get_task_info(task_id)
last_status_obj = get_last_task_status(task_id)
if not task_info:
logger.warning(f"History: No task_info found for task_id {task_id}. Cannot log to history.")
return
# Extract Spotify ID from item URL if possible
spotify_id = None
item_url = task_info.get('url', '')
if item_url:
try:
spotify_id = item_url.split('/')[-1]
# Further validation if it looks like a Spotify ID (e.g., 22 chars, alphanumeric)
if not (spotify_id and len(spotify_id) == 22 and spotify_id.isalnum()):
spotify_id = None # Reset if not a valid-looking ID
except Exception:
spotify_id = None # Ignore errors in parsing
history_entry = {
'task_id': task_id,
'download_type': task_info.get('download_type'),
'item_name': task_info.get('name'),
'item_artist': task_info.get('artist'),
'item_album': task_info.get('album', task_info.get('name') if task_info.get('download_type') == 'album' else None),
'item_url': item_url,
'spotify_id': spotify_id,
'status_final': final_status_str,
'error_message': error_msg if error_msg else (last_status_obj.get('error') if last_status_obj else None),
'timestamp_added': task_info.get('created_at', time.time()),
'timestamp_completed': last_status_obj.get('timestamp', time.time()) if last_status_obj else time.time(),
'original_request_json': json.dumps(task_info.get('original_request', {})),
'last_status_obj_json': json.dumps(last_status_obj if last_status_obj else {})
}
add_entry_to_history(history_entry)
except Exception as e:
logger.error(f"History: Error preparing or logging history for task {task_id}: {e}", exc_info=True)
# --- End History Logging Helper ---
def cancel_task(task_id):
"""Cancel a task by its ID"""
try:
# Mark the task as cancelled in Redis
store_task_status(task_id, {
"status": ProgressState.CANCELLED,
"message": "Task cancelled by user",
"error": "Task cancelled by user",
"timestamp": time.time()
})
# Try to revoke the Celery task if it hasn't started yet
celery_app.control.revoke(task_id, terminate=True, signal='SIGTERM')
logger.info(f"Task {task_id} cancelled by user")
# Log cancellation to history
_log_task_to_history(task_id, 'CANCELLED', "Task cancelled by user")
# Schedule deletion of task data after 30 seconds
delayed_delete_task_data.apply_async(
args=[task_id, "Task cancelled by user and auto-cleaned."],
countdown=30
)
logger.info(f"Task {task_id} cancelled by user. Data scheduled for deletion in 30s.")
return {"status": "cancelled", "task_id": task_id}
except Exception as e:
logger.error(f"Error cancelling task {task_id}: {e}")
@@ -165,12 +227,12 @@ def retry_task(task_id):
# Get task info
task_info = get_task_info(task_id)
if not task_info:
return {"status": "error", "message": f"Task {task_id} not found"}
return {"status": "error", "error": f"Task {task_id} not found"}
# Check if task has error status
last_status = get_last_task_status(task_id)
if not last_status or last_status.get("status") != ProgressState.ERROR:
return {"status": "error", "message": "Task is not in a failed state"}
return {"status": "error", "error": "Task is not in a failed state"}
# Get current retry count
retry_count = last_status.get("retry_count", 0)
@@ -185,7 +247,7 @@ def retry_task(task_id):
if retry_count >= max_retries:
return {
"status": "error",
"message": f"Maximum retry attempts ({max_retries}) exceeded"
"error": f"Maximum retry attempts ({max_retries}) exceeded"
}
# Calculate retry delay
@@ -255,35 +317,52 @@ def retry_task(task_id):
# Launch the appropriate task based on download_type
download_type = task_info.get("download_type", "unknown")
task = None
new_celery_task_obj = None
logger.info(f"Retrying task {task_id} as {new_task_id} (retry {retry_count + 1}/{max_retries})")
if download_type == "track":
task = download_track.apply_async(
new_celery_task_obj = download_track.apply_async(
kwargs=task_info,
task_id=new_task_id,
queue='downloads'
)
elif download_type == "album":
task = download_album.apply_async(
new_celery_task_obj = download_album.apply_async(
kwargs=task_info,
task_id=new_task_id,
queue='downloads'
)
elif download_type == "playlist":
task = download_playlist.apply_async(
new_celery_task_obj = download_playlist.apply_async(
kwargs=task_info,
task_id=new_task_id,
queue='downloads'
)
else:
logger.error(f"Unknown download type for retry: {download_type}")
store_task_status(new_task_id, {
"status": ProgressState.ERROR,
"error": f"Cannot retry: Unknown download type '{download_type}' for original task {task_id}",
"timestamp": time.time()
})
return {
"status": "error",
"message": f"Unknown download type: {download_type}"
"error": f"Unknown download type: {download_type}"
}
# If retry was successfully submitted, update the original task's status
if new_celery_task_obj:
store_task_status(task_id, {
"status": "ERROR_RETRIED",
"error": f"Task superseded by retry: {new_task_id}",
"retried_as_task_id": new_task_id,
"timestamp": time.time()
})
logger.info(f"Original task {task_id} status updated to ERROR_RETRIED, superseded by {new_task_id}")
else:
logger.error(f"Retry submission for task {task_id} (as {new_task_id}) did not return a Celery AsyncResult. Original task not marked as ERROR_RETRIED.")
return {
"status": "requeued",
"task_id": new_task_id,
@@ -292,9 +371,8 @@ def retry_task(task_id):
"retry_delay": retry_delay
}
except Exception as e:
logger.error(f"Error retrying task {task_id}: {e}")
traceback.print_exc()
return {"status": "error", "message": str(e)}
logger.error(f"Error retrying task {task_id}: {e}", exc_info=True)
return {"status": "error", "error": str(e)}
def get_all_tasks():
"""Get all active task IDs"""
@@ -340,6 +418,27 @@ class ProgressTrackingTask(Task):
"""
task_id = self.request.id
# Ensure ./logs/tasks directory exists
logs_tasks_dir = Path('./logs/tasks') # Using relative path as per your update
try:
logs_tasks_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
logger.error(f"Task {task_id}: Could not create log directory {logs_tasks_dir}: {e}")
# Define log file path
log_file_path = logs_tasks_dir / f"{task_id}.log"
# Log progress_data to the task-specific file
try:
with open(log_file_path, 'a') as log_file:
# Add a timestamp to the log entry if not present, for consistency in the file
log_entry = progress_data.copy()
if 'timestamp' not in log_entry:
log_entry['timestamp'] = time.time()
print(json.dumps(log_entry), file=log_file) # Use print to file
except Exception as e:
logger.error(f"Task {task_id}: Could not write to task log file {log_file_path}: {e}")
# Add timestamp if not present
if 'timestamp' not in progress_data:
progress_data['timestamp'] = time.time()
@@ -657,8 +756,9 @@ class ProgressTrackingTask(Task):
task_info['error_count'] = error_count
store_task_info(task_id, task_info)
# Set status
# Set status and error message
data['status'] = ProgressState.ERROR
data['error'] = message
def _handle_done(self, task_id, data, task_info):
"""Handle done status from deezspot"""
@@ -734,6 +834,46 @@ class ProgressTrackingTask(Task):
# Log summary
logger.info(f"Task {task_id} summary: {completed_tracks} completed, {skipped_tracks} skipped, {error_count} errors")
# Schedule deletion for completed multi-track downloads
delayed_delete_task_data.apply_async(
args=[task_id, "Task completed successfully and auto-cleaned."],
countdown=30 # Delay in seconds
)
# If from playlist_watch and successful, add track to DB
original_request = task_info.get("original_request", {})
if original_request.get("source") == "playlist_watch" and task_info.get("download_type") == "track": # ensure it's a track for playlist
playlist_id = original_request.get("playlist_id")
track_item_for_db = original_request.get("track_item_for_db")
if playlist_id and track_item_for_db and track_item_for_db.get('track'):
logger.info(f"Task {task_id} was from playlist watch for playlist {playlist_id}. Adding track to DB.")
try:
add_single_track_to_playlist_db(playlist_id, track_item_for_db)
except Exception as db_add_err:
logger.error(f"Failed to add track to DB for playlist {playlist_id} after successful download task {task_id}: {db_add_err}", exc_info=True)
else:
logger.warning(f"Task {task_id} was from playlist_watch but missing playlist_id or track_item_for_db for DB update. Original Request: {original_request}")
# If from artist_watch and successful, update album in DB
if original_request.get("source") == "artist_watch" and task_info.get("download_type") == "album":
artist_spotify_id = original_request.get("artist_spotify_id")
album_data_for_db = original_request.get("album_data_for_db")
if artist_spotify_id and album_data_for_db and album_data_for_db.get("id"):
album_spotify_id = album_data_for_db.get("id")
logger.info(f"Task {task_id} was from artist watch for artist {artist_spotify_id}, album {album_spotify_id}. Updating album in DB as complete.")
try:
add_or_update_album_for_artist(
artist_spotify_id=artist_spotify_id,
album_data=album_data_for_db,
task_id=task_id,
is_download_complete=True
)
except Exception as db_update_err:
logger.error(f"Failed to update album {album_spotify_id} in DB for artist {artist_spotify_id} after successful download task {task_id}: {db_update_err}", exc_info=True)
else:
logger.warning(f"Task {task_id} was from artist_watch (album) but missing key data (artist_spotify_id or album_data_for_db) for DB update. Original Request: {original_request}")
else:
# Generic done for other types
@@ -765,27 +905,72 @@ def task_prerun_handler(task_id=None, task=None, *args, **kwargs):
def task_postrun_handler(task_id=None, task=None, retval=None, state=None, *args, **kwargs):
"""Signal handler when a task finishes"""
try:
# Skip if task is already marked as complete or error in Redis
last_status = get_last_task_status(task_id)
if last_status and last_status.get("status") in [ProgressState.COMPLETE, ProgressState.ERROR]:
return
last_status_for_history = get_last_task_status(task_id)
if last_status_for_history and last_status_for_history.get("status") in [ProgressState.COMPLETE, ProgressState.ERROR, ProgressState.CANCELLED, "ERROR_RETRIED", "ERROR_AUTO_CLEANED"]:
if state == states.REVOKED and last_status_for_history.get("status") != ProgressState.CANCELLED:
logger.info(f"Task {task_id} was REVOKED (likely cancelled), logging to history.")
_log_task_to_history(task_id, 'CANCELLED', "Task was revoked/cancelled.")
# return # Let status update proceed if necessary
# Get task info
task_info = get_task_info(task_id)
current_redis_status = last_status_for_history.get("status") if last_status_for_history else None
# Update task status based on Celery task state
if state == states.SUCCESS:
store_task_status(task_id, {
"status": ProgressState.COMPLETE,
"timestamp": time.time(),
"type": task_info.get("type", "unknown"),
"name": task_info.get("name", "Unknown"),
"artist": task_info.get("artist", ""),
"message": "Download completed successfully."
})
if current_redis_status != ProgressState.COMPLETE:
store_task_status(task_id, {
"status": ProgressState.COMPLETE,
"timestamp": time.time(),
"type": task_info.get("type", "unknown"),
"name": task_info.get("name", "Unknown"),
"artist": task_info.get("artist", ""),
"message": "Download completed successfully."
})
logger.info(f"Task {task_id} completed successfully: {task_info.get('name', 'Unknown')}")
_log_task_to_history(task_id, 'COMPLETED')
if task_info.get("download_type") == "track": # Applies to single track downloads and tracks from playlists/albums
delayed_delete_task_data.apply_async(
args=[task_id, "Task completed successfully and auto-cleaned."],
countdown=30
)
original_request = task_info.get("original_request", {})
# Handle successful track from playlist watch
if original_request.get("source") == "playlist_watch" and task_info.get("download_type") == "track":
playlist_id = original_request.get("playlist_id")
track_item_for_db = original_request.get("track_item_for_db")
if playlist_id and track_item_for_db and track_item_for_db.get('track'):
logger.info(f"Task {task_id} was from playlist watch for playlist {playlist_id}. Adding track to DB.")
try:
add_single_track_to_playlist_db(playlist_id, track_item_for_db)
except Exception as db_add_err:
logger.error(f"Failed to add track to DB for playlist {playlist_id} after successful download task {task_id}: {db_add_err}", exc_info=True)
else:
logger.warning(f"Task {task_id} was from playlist_watch but missing playlist_id or track_item_for_db for DB update. Original Request: {original_request}")
# Handle successful album from artist watch
if original_request.get("source") == "artist_watch" and task_info.get("download_type") == "album":
artist_spotify_id = original_request.get("artist_spotify_id")
album_data_for_db = original_request.get("album_data_for_db")
if artist_spotify_id and album_data_for_db and album_data_for_db.get("id"):
album_spotify_id = album_data_for_db.get("id")
logger.info(f"Task {task_id} was from artist watch for artist {artist_spotify_id}, album {album_spotify_id}. Updating album in DB as complete.")
try:
add_or_update_album_for_artist(
artist_spotify_id=artist_spotify_id,
album_data=album_data_for_db,
task_id=task_id,
is_download_complete=True
)
except Exception as db_update_err:
logger.error(f"Failed to update album {album_spotify_id} in DB for artist {artist_spotify_id} after successful download task {task_id}: {db_update_err}", exc_info=True)
else:
logger.warning(f"Task {task_id} was from artist_watch (album) but missing key data (artist_spotify_id or album_data_for_db) for DB update. Original Request: {original_request}")
except Exception as e:
logger.error(f"Error in task_postrun_handler: {e}")
logger.error(f"Error in task_postrun_handler: {e}", exc_info=True)
@task_failure.connect
def task_failure_handler(task_id=None, exception=None, traceback=None, *args, **kwargs):
@@ -811,25 +996,34 @@ def task_failure_handler(task_id=None, exception=None, traceback=None, *args, **
# Check if we can retry
can_retry = retry_count < max_retries
# Update task status to error
error_message = str(exception)
store_task_status(task_id, {
"status": ProgressState.ERROR,
"timestamp": time.time(),
"type": task_info.get("type", "unknown"),
"name": task_info.get("name", "Unknown"),
"artist": task_info.get("artist", ""),
"error": error_message,
"traceback": str(traceback),
"can_retry": can_retry,
"retry_count": retry_count,
"max_retries": max_retries,
"message": f"Error: {error_message}"
})
# Update task status to error in Redis if not already an error
if last_status and last_status.get("status") != ProgressState.ERROR:
store_task_status(task_id, {
"status": ProgressState.ERROR,
"timestamp": time.time(),
"type": task_info.get("type", "unknown"),
"name": task_info.get("name", "Unknown"),
"artist": task_info.get("artist", ""),
"error": str(exception),
"traceback": str(traceback),
"can_retry": can_retry,
"retry_count": retry_count,
"max_retries": max_retries
})
logger.error(f"Task {task_id} failed: {str(exception)}")
_log_task_to_history(task_id, 'ERROR', str(exception))
logger.error(f"Task {task_id} failed: {error_message}")
if can_retry:
logger.info(f"Task {task_id} can be retried ({retry_count}/{max_retries})")
else:
# If task cannot be retried, schedule its data for deletion
logger.info(f"Task {task_id} failed and cannot be retried. Data scheduled for deletion in 30s.")
delayed_delete_task_data.apply_async(
args=[task_id, f"Task failed ({str(exception)}) and max retries reached. Auto-cleaned."],
countdown=30
)
except Exception as e:
logger.error(f"Error in task_failure_handler: {e}")
@@ -1054,3 +1248,103 @@ def download_playlist(self, **task_data):
logger.error(f"Error in download_playlist task: {e}")
traceback.print_exc()
raise
# Helper function to fully delete task data from Redis
def delete_task_data_and_log(task_id, reason="Task data deleted"):
"""
Marks a task as cancelled (if not already) and deletes all its data from Redis.
"""
try:
task_info = get_task_info(task_id) # Get info before deleting
last_status = get_last_task_status(task_id)
current_status_val = last_status.get("status") if last_status else None
# Determine the final status for Redis before deletion
# The reason passed to this function indicates why it's being deleted.
final_redis_status = ProgressState.ERROR_AUTO_CLEANED # Default for most cleanup scenarios
error_message_for_status = reason
if reason == "Task completed successfully and auto-cleaned.":
final_redis_status = ProgressState.COMPLETE # It was already complete
error_message_for_status = "Task completed and auto-cleaned."
elif reason == "Task cancelled by user and auto-cleaned.":
final_redis_status = ProgressState.CANCELLED # It was already cancelled
error_message_for_status = "Task cancelled and auto-cleaned."
elif "Task failed" in reason and "max retries reached" in reason:
final_redis_status = ProgressState.ERROR # It was already an error (non-retryable)
error_message_for_status = reason
elif reason == "Task interrupted by application restart and auto-cleaned.":
final_redis_status = ProgressState.ERROR # It was marked as ERROR (interrupted)
error_message_for_status = reason
# Add more specific conditions if needed based on other reasons `delayed_delete_task_data` might be called with.
# Update Redis status one last time if it's not already reflecting the final intended state for this cleanup.
# This is mainly for cases where cleanup is initiated for tasks not yet in a fully terminal state by other handlers.
if current_status_val not in [ProgressState.COMPLETE, ProgressState.CANCELLED, ProgressState.ERROR_RETRIED, ProgressState.ERROR_AUTO_CLEANED, final_redis_status]:
store_task_status(task_id, {
"status": final_redis_status,
"error": error_message_for_status, # Use the reason as the error/message for this status
"timestamp": time.time()
})
# History logging for COMPLETION, CANCELLATION, or definitive ERROR should have occurred when those states were first reached.
# If this cleanup is for a task that *wasn't* in such a state (e.g. stale, still processing), log it now.
if final_redis_status == ProgressState.ERROR_AUTO_CLEANED:
_log_task_to_history(task_id, 'ERROR', error_message_for_status) # Or a more specific status if desired
# Delete Redis keys associated with the task
redis_client.delete(f"task:{task_id}:info")
redis_client.delete(f"task:{task_id}:status")
redis_client.delete(f"task:{task_id}:status:next_id")
logger.info(f"Data for task {task_id} ('{task_info.get('name', 'Unknown')}') deleted from Redis. Reason: {reason}")
return True
except Exception as e:
logger.error(f"Error deleting task data for {task_id}: {e}", exc_info=True)
return False
@celery_app.task(name="cleanup_stale_errors", queue="default") # Put on default queue, not downloads
def cleanup_stale_errors():
"""
Periodically checks for tasks in ERROR state for more than 1 minute and cleans them up.
"""
logger.info("Running cleanup_stale_errors task...")
cleaned_count = 0
try:
task_keys = redis_client.keys("task:*:info")
if not task_keys:
logger.info("No task keys found for cleanup.")
return {"status": "complete", "message": "No tasks to check."}
current_time = time.time()
stale_threshold = 60 # 1 minute
for key_bytes in task_keys:
task_id = key_bytes.decode('utf-8').split(':')[1]
last_status = get_last_task_status(task_id)
if last_status and last_status.get("status") == ProgressState.ERROR:
error_timestamp = last_status.get("timestamp", 0)
if (current_time - error_timestamp) > stale_threshold:
# Check again to ensure it wasn't retried just before cleanup
current_last_status_before_delete = get_last_task_status(task_id)
if current_last_status_before_delete and current_last_status_before_delete.get("status") == ProgressState.ERROR_RETRIED:
logger.info(f"Task {task_id} was retried just before cleanup. Skipping delete.")
continue
logger.info(f"Task {task_id} is in ERROR state for more than {stale_threshold}s. Cleaning up.")
if delete_task_data_and_log(task_id, reason=f"Auto-cleaned: Task was in ERROR state for over {stale_threshold} seconds without manual retry."):
cleaned_count += 1
logger.info(f"cleanup_stale_errors task finished. Cleaned up {cleaned_count} stale errored tasks.")
return {"status": "complete", "cleaned_count": cleaned_count}
except Exception as e:
logger.error(f"Error during cleanup_stale_errors: {e}", exc_info=True)
return {"status": "error", "error": str(e)}
@celery_app.task(name="delayed_delete_task_data", queue="default") # Use default queue for utility tasks
def delayed_delete_task_data(task_id, reason):
"""
Celery task to delete task data after a delay.
"""
logger.info(f"Executing delayed deletion for task {task_id}. Reason: {reason}")
delete_task_data_and_log(task_id, reason)

View File

@@ -1,6 +1,85 @@
import json
from pathlib import Path
import shutil
from deezspot.spotloader import SpoLogin
from deezspot.deezloader import DeeLogin
import traceback # For logging detailed error messages
import time # For retry delays
def _get_spotify_search_creds(creds_dir: Path):
"""Helper to load client_id and client_secret from search.json for a Spotify account."""
search_file = creds_dir / 'search.json'
if search_file.exists():
try:
with open(search_file, 'r') as f:
search_data = json.load(f)
return search_data.get('client_id'), search_data.get('client_secret')
except Exception:
# Log error if search.json is malformed or unreadable
print(f"Warning: Could not read Spotify search credentials from {search_file}")
traceback.print_exc()
return None, None
def _validate_with_retry(service_name, account_name, creds_dir_path, cred_file_path, data_for_validation, is_spotify):
"""
Attempts to validate credentials with retries for connection errors.
- For Spotify, cred_file_path is used.
- For Deezer, data_for_validation (which contains the 'arl' key) is used.
Returns True if validated, raises ValueError if not.
"""
max_retries = 5
last_exception = None
for attempt in range(max_retries):
try:
if is_spotify:
client_id, client_secret = _get_spotify_search_creds(creds_dir_path)
SpoLogin(credentials_path=str(cred_file_path), spotify_client_id=client_id, spotify_client_secret=client_secret)
else: # Deezer
arl = data_for_validation.get('arl')
if not arl:
# This should be caught by prior checks, but as a safeguard:
raise ValueError("Missing 'arl' for Deezer validation.")
DeeLogin(arl=arl)
print(f"{service_name.capitalize()} credentials for {account_name} validated successfully (attempt {attempt + 1}).")
return True # Validation successful
except Exception as e:
last_exception = e
error_str = str(e).lower()
# More comprehensive check for connection-related errors
is_connection_error = (
"connection refused" in error_str or
"connection error" in error_str or
"timeout" in error_str or
"temporary failure in name resolution" in error_str or
"dns lookup failed" in error_str or
"network is unreachable" in error_str or
"ssl handshake failed" in error_str or # Can be network-related
"connection reset by peer" in error_str
)
if is_connection_error and attempt < max_retries - 1:
retry_delay = 2 + attempt # Increasing delay (2s, 3s, 4s, 5s)
print(f"Validation for {account_name} ({service_name}) failed on attempt {attempt + 1}/{max_retries} due to connection issue: {e}. Retrying in {retry_delay}s...")
time.sleep(retry_delay)
continue # Go to next retry attempt
else:
# Not a connection error, or it's the last retry for a connection error
print(f"Validation for {account_name} ({service_name}) failed on attempt {attempt + 1} with non-retryable error or max retries reached for connection error.")
break # Exit retry loop
# If loop finished without returning True, validation failed
print(f"ERROR: Credential validation definitively failed for {service_name} account {account_name} after {attempt + 1} attempt(s).")
if last_exception:
base_error_message = str(last_exception).splitlines()[-1]
detailed_error_message = f"Invalid {service_name} credentials. Verification failed: {base_error_message}"
if is_spotify and "incorrect padding" in base_error_message.lower():
detailed_error_message += ". Hint: Do not throw your password here, read the docs"
# traceback.print_exc() # Already printed in create/edit, avoid duplicate full trace
raise ValueError(detailed_error_message)
else: # Should not happen if loop runs at least once
raise ValueError(f"Invalid {service_name} credentials. Verification failed (unknown reason after retries).")
def get_credential(service, name, cred_type='credentials'):
"""
@@ -28,7 +107,7 @@ def get_credential(service, name, cred_type='credentials'):
if service == 'deezer' and cred_type == 'search':
raise ValueError("Search credentials are only supported for Spotify")
creds_dir = Path('./creds') / service / name
creds_dir = Path('./data/creds') / service / name
file_path = creds_dir / f'{cred_type}.json'
if not file_path.exists():
@@ -56,7 +135,7 @@ def list_credentials(service):
if service not in ['spotify', 'deezer']:
raise ValueError("Service must be 'spotify' or 'deezer'")
service_dir = Path('./creds') / service
service_dir = Path('./data/creds') / service
if not service_dir.exists():
return []
@@ -116,21 +195,80 @@ def create_credential(service, name, data, cred_type='credentials'):
raise ValueError(f"Missing required field for {cred_type}: {field}")
# Create directory
creds_dir = Path('./creds') / service / name
creds_dir = Path('./data/creds') / service / name
file_created_now = False
dir_created_now = False
if cred_type == 'credentials':
try:
creds_dir.mkdir(parents=True, exist_ok=False)
dir_created_now = True
except FileExistsError:
raise FileExistsError(f"Credential '{name}' already exists for {service}")
else:
# Directory already exists, which is fine for creating credentials.json
# if it doesn't exist yet, or if we are overwriting (though POST usually means new)
pass
except Exception as e:
raise ValueError(f"Could not create directory {creds_dir}: {e}")
file_path = creds_dir / 'credentials.json'
if file_path.exists() and request.method == 'POST': # type: ignore
# Safety check for POST to not overwrite if file exists unless it's an edit (PUT)
raise FileExistsError(f"Credential file {file_path} already exists. Use PUT to modify.")
# Write the credential file first
try:
with open(file_path, 'w') as f:
json.dump(data, f, indent=4)
file_created_now = True # Mark as created for potential cleanup
except Exception as e:
if dir_created_now: # Cleanup directory if file write failed
try:
creds_dir.rmdir()
except OSError: # rmdir fails if not empty, though it should be
pass
raise ValueError(f"Could not write credential file {file_path}: {e}")
# --- Validation Step ---
try:
_validate_with_retry(
service_name=service,
account_name=name,
creds_dir_path=creds_dir,
cred_file_path=file_path,
data_for_validation=data, # 'data' contains the arl for Deezer
is_spotify=(service == 'spotify')
)
except ValueError as val_err: # Catch the specific error from our helper
print(f"ERROR: Credential validation failed during creation for {service} account {name}: {val_err}")
traceback.print_exc() # Print full traceback here for creation failure context
# Clean up the created file and directory if validation fails
if file_created_now:
try:
file_path.unlink(missing_ok=True)
except OSError:
pass # Ignore if somehow already gone
if dir_created_now and not any(creds_dir.iterdir()): # Only remove if empty
try:
creds_dir.rmdir()
except OSError:
pass
raise # Re-raise the ValueError from validation
elif cred_type == 'search': # Spotify only
# For search.json, ensure the directory exists (it should if credentials.json exists)
if not creds_dir.exists():
raise FileNotFoundError(f"Credential '{name}' not found for {service}")
# This implies credentials.json was not created first, which is an issue.
# However, the form logic might allow adding API creds to an existing empty dir.
# For now, let's create it if it's missing, assuming API creds can be standalone.
try:
creds_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
raise ValueError(f"Could not create directory for search credentials {creds_dir}: {e}")
# Write credentials file
file_path = creds_dir / f'{cred_type}.json'
with open(file_path, 'w') as f:
json.dump(data, f, indent=4)
file_path = creds_dir / 'search.json'
# No specific validation for client_id/secret themselves, they are validated in use.
with open(file_path, 'w') as f:
json.dump(data, f, indent=4)
def delete_credential(service, name, cred_type=None):
"""
@@ -145,7 +283,7 @@ def delete_credential(service, name, cred_type=None):
Raises:
FileNotFoundError: If the credential directory or specified file does not exist
"""
creds_dir = Path('./creds') / service / name
creds_dir = Path('./data/creds') / service / name
if cred_type:
if cred_type not in ['credentials', 'search']:
@@ -193,23 +331,29 @@ def edit_credential(service, name, new_data, cred_type='credentials'):
raise ValueError("Search credentials are only supported for Spotify")
# Get file path
creds_dir = Path('./creds') / service / name
creds_dir = Path('./data/creds') / service / name
file_path = creds_dir / f'{cred_type}.json'
# For search.json, create if it doesn't exist
if cred_type == 'search' and not file_path.exists():
if not creds_dir.exists():
raise FileNotFoundError(f"Credential '{name}' not found for {service}")
data = {}
else:
# Load existing data
if not file_path.exists():
raise FileNotFoundError(f"{cred_type.capitalize()} credential '{name}' not found for {service}")
original_data_str = None # Store original data as string to revert
file_existed_before_edit = file_path.exists()
if file_existed_before_edit:
with open(file_path, 'r') as f:
data = json.load(f)
original_data_str = f.read()
try:
data = json.loads(original_data_str)
except json.JSONDecodeError:
# If existing file is corrupt, treat as if we are creating it anew for edit
data = {}
original_data_str = None # Can't revert to corrupt data
else:
# If file doesn't exist, and we're editing (PUT), it's usually an error
# unless it's for search.json which can be created during an edit flow.
if cred_type == 'credentials':
raise FileNotFoundError(f"Cannot edit non-existent credentials file: {file_path}")
data = {} # Start with empty data for search.json creation
# Validate new_data fields
# Validate new_data fields (data to be merged)
allowed_fields = []
if cred_type == 'credentials':
if service == 'spotify':
@@ -223,13 +367,64 @@ def edit_credential(service, name, new_data, cred_type='credentials'):
if key not in allowed_fields:
raise ValueError(f"Invalid field '{key}' for {cred_type} credentials")
# Update data
# Update data (merging new_data into existing or empty data)
data.update(new_data)
# For Deezer: Strip all fields except 'arl'
# --- Write and Validate Step for 'credentials' type ---
if cred_type == 'credentials':
try:
# Temporarily write new data for validation
with open(file_path, 'w') as f:
json.dump(data, f, indent=4)
_validate_with_retry(
service_name=service,
account_name=name,
creds_dir_path=creds_dir,
cred_file_path=file_path,
data_for_validation=data, # 'data' is the merged data with 'arl' for Deezer
is_spotify=(service == 'spotify')
)
except ValueError as val_err: # Catch the specific error from our helper
print(f"ERROR: Edited credential validation failed for {service} account {name}: {val_err}")
traceback.print_exc() # Print full traceback here for edit failure context
# Revert or delete the file
if original_data_str is not None:
with open(file_path, 'w') as f:
f.write(original_data_str) # Restore original content
elif file_existed_before_edit: # file existed but original_data_str is None (corrupt)
pass
else: # File didn't exist before this edit attempt, so remove it
try:
file_path.unlink(missing_ok=True)
except OSError:
pass # Ignore if somehow already gone
raise # Re-raise the ValueError from validation
except Exception as e: # Catch other potential errors like file IO during temp write
print(f"ERROR: Unexpected error during edit/validation for {service} account {name}: {e}")
traceback.print_exc()
# Attempt revert/delete
if original_data_str is not None:
with open(file_path, 'w') as f: f.write(original_data_str)
elif file_existed_before_edit:
pass
else:
try:
file_path.unlink(missing_ok=True)
except OSError: pass
raise ValueError(f"Failed to save edited {service} credentials due to: {str(e).splitlines()[-1]}")
# For 'search' type, just write, no specific validation here for client_id/secret
elif cred_type == 'search':
if not creds_dir.exists(): # Should not happen if we're editing
raise FileNotFoundError(f"Credential directory {creds_dir} not found for editing search credentials.")
with open(file_path, 'w') as f:
json.dump(data, f, indent=4) # `data` here is the merged data for search
# For Deezer: Strip all fields except 'arl' - This should use `data` which is `updated_data`
if service == 'deezer' and cred_type == 'credentials':
if 'arl' not in data:
raise ValueError("Missing 'arl' field for Deezer credential")
raise ValueError("Missing 'arl' field for Deezer credential after edit.")
data = {'arl': data['arl']}
# Ensure required fields are present

View File

@@ -7,13 +7,15 @@ from routes.utils.celery_queue_manager import get_config_params
# We'll rely on get_config_params() instead of directly loading the config file
def get_spotify_info(spotify_id, spotify_type):
def get_spotify_info(spotify_id, spotify_type, limit=None, offset=None):
"""
Get info from Spotify API using the default Spotify account configured in main.json
Args:
spotify_id: The Spotify ID of the entity
spotify_type: The type of entity (track, album, playlist, artist)
limit (int, optional): The maximum number of items to return. Only used if spotify_type is "artist".
offset (int, optional): The index of the first item to return. Only used if spotify_type is "artist".
Returns:
Dictionary with the entity information
@@ -29,7 +31,7 @@ def get_spotify_info(spotify_id, spotify_type):
raise ValueError("No Spotify account configured in settings")
if spotify_id:
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
if search_creds_path.exists():
try:
with open(search_creds_path, 'r') as f:
@@ -50,6 +52,15 @@ def get_spotify_info(spotify_id, spotify_type):
return Spo.get_album(spotify_id)
elif spotify_type == "playlist":
return Spo.get_playlist(spotify_id)
elif spotify_type == "artist_discography":
if limit is not None and offset is not None:
return Spo.get_artist_discography(spotify_id, limit=limit, offset=offset)
elif limit is not None:
return Spo.get_artist_discography(spotify_id, limit=limit)
elif offset is not None:
return Spo.get_artist_discography(spotify_id, offset=offset)
else:
return Spo.get_artist_discography(spotify_id)
elif spotify_type == "artist":
return Spo.get_artist(spotify_id)
elif spotify_type == "episode":

View File

@@ -0,0 +1,235 @@
import sqlite3
import json
import time
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
HISTORY_DIR = Path('./data/history')
HISTORY_DB_FILE = HISTORY_DIR / 'download_history.db'
def init_history_db():
"""Initializes the download history database and creates the table if it doesn't exist."""
try:
HISTORY_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(HISTORY_DB_FILE)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS download_history (
task_id TEXT PRIMARY KEY,
download_type TEXT,
item_name TEXT,
item_artist TEXT,
item_album TEXT,
item_url TEXT,
spotify_id TEXT,
status_final TEXT, -- 'COMPLETED', 'ERROR', 'CANCELLED'
error_message TEXT,
timestamp_added REAL,
timestamp_completed REAL,
original_request_json TEXT,
last_status_obj_json TEXT
)
""")
conn.commit()
logger.info(f"Download history database initialized at {HISTORY_DB_FILE}")
except sqlite3.Error as e:
logger.error(f"Error initializing download history database: {e}", exc_info=True)
finally:
if conn:
conn.close()
def add_entry_to_history(history_data: dict):
"""Adds or replaces an entry in the download_history table.
Args:
history_data (dict): A dictionary containing the data for the history entry.
Expected keys match the table columns.
"""
required_keys = [
'task_id', 'download_type', 'item_name', 'item_artist', 'item_album',
'item_url', 'spotify_id', 'status_final', 'error_message',
'timestamp_added', 'timestamp_completed', 'original_request_json',
'last_status_obj_json'
]
# Ensure all keys are present, filling with None if not
for key in required_keys:
history_data.setdefault(key, None)
conn = None
try:
conn = sqlite3.connect(HISTORY_DB_FILE)
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO download_history (
task_id, download_type, item_name, item_artist, item_album,
item_url, spotify_id, status_final, error_message,
timestamp_added, timestamp_completed, original_request_json,
last_status_obj_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
history_data['task_id'], history_data['download_type'], history_data['item_name'],
history_data['item_artist'], history_data['item_album'], history_data['item_url'],
history_data['spotify_id'], history_data['status_final'], history_data['error_message'],
history_data['timestamp_added'], history_data['timestamp_completed'],
history_data['original_request_json'], history_data['last_status_obj_json']
))
conn.commit()
logger.info(f"Added/Updated history for task_id: {history_data['task_id']}, status: {history_data['status_final']}")
except sqlite3.Error as e:
logger.error(f"Error adding entry to download history for task_id {history_data.get('task_id')}: {e}", exc_info=True)
except Exception as e:
logger.error(f"Unexpected error adding to history for task_id {history_data.get('task_id')}: {e}", exc_info=True)
finally:
if conn:
conn.close()
def get_history_entries(limit=25, offset=0, sort_by='timestamp_completed', sort_order='DESC', filters=None):
"""Retrieves entries from the download_history table with pagination, sorting, and filtering.
Args:
limit (int): Maximum number of entries to return.
offset (int): Number of entries to skip (for pagination).
sort_by (str): Column name to sort by.
sort_order (str): 'ASC' or 'DESC'.
filters (dict, optional): A dictionary of column_name: value to filter by.
Currently supports exact matches.
Returns:
tuple: (list of history entries as dicts, total_count of matching entries)
"""
conn = None
try:
conn = sqlite3.connect(HISTORY_DB_FILE)
conn.row_factory = sqlite3.Row # Access columns by name
cursor = conn.cursor()
base_query = "FROM download_history"
count_query = "SELECT COUNT(*) " + base_query
select_query = "SELECT * " + base_query
where_clauses = []
params = []
if filters:
for column, value in filters.items():
# Basic security: ensure column is a valid one (alphanumeric + underscore)
if column.replace('_', '').isalnum():
where_clauses.append(f"{column} = ?")
params.append(value)
if where_clauses:
where_sql = " WHERE " + " AND ".join(where_clauses)
count_query += where_sql
select_query += where_sql
# Get total count for pagination
cursor.execute(count_query, params)
total_count = cursor.fetchone()[0]
# Validate sort_by and sort_order to prevent SQL injection
valid_sort_columns = [
'task_id', 'download_type', 'item_name', 'item_artist', 'item_album',
'item_url', 'status_final', 'timestamp_added', 'timestamp_completed'
]
if sort_by not in valid_sort_columns:
sort_by = 'timestamp_completed' # Default sort
sort_order_upper = sort_order.upper()
if sort_order_upper not in ['ASC', 'DESC']:
sort_order_upper = 'DESC'
select_query += f" ORDER BY {sort_by} {sort_order_upper} LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor.execute(select_query, params)
rows = cursor.fetchall()
# Convert rows to list of dicts
entries = [dict(row) for row in rows]
return entries, total_count
except sqlite3.Error as e:
logger.error(f"Error retrieving history entries: {e}", exc_info=True)
return [], 0
finally:
if conn:
conn.close()
if __name__ == '__main__':
# For testing purposes
logging.basicConfig(level=logging.INFO)
init_history_db()
sample_data_complete = {
'task_id': 'test_task_123',
'download_type': 'track',
'item_name': 'Test Song',
'item_artist': 'Test Artist',
'item_album': 'Test Album',
'item_url': 'http://spotify.com/track/123',
'spotify_id': '123',
'status_final': 'COMPLETED',
'error_message': None,
'timestamp_added': time.time() - 3600,
'timestamp_completed': time.time(),
'original_request_json': json.dumps({'param1': 'value1'}),
'last_status_obj_json': json.dumps({'status': 'complete', 'message': 'Finished!'})
}
add_entry_to_history(sample_data_complete)
sample_data_error = {
'task_id': 'test_task_456',
'download_type': 'album',
'item_name': 'Another Album',
'item_artist': 'Another Artist',
'item_album': 'Another Album', # For albums, item_name and item_album are often the same
'item_url': 'http://spotify.com/album/456',
'spotify_id': '456',
'status_final': 'ERROR',
'error_message': 'Download failed due to network issue.',
'timestamp_added': time.time() - 7200,
'timestamp_completed': time.time() - 60,
'original_request_json': json.dumps({'param2': 'value2'}),
'last_status_obj_json': json.dumps({'status': 'error', 'error': 'Network issue'})
}
add_entry_to_history(sample_data_error)
# Test updating an entry
updated_data_complete = {
'task_id': 'test_task_123',
'download_type': 'track',
'item_name': 'Test Song (Updated)',
'item_artist': 'Test Artist',
'item_album': 'Test Album II',
'item_url': 'http://spotify.com/track/123',
'spotify_id': '123',
'status_final': 'COMPLETED',
'error_message': None,
'timestamp_added': time.time() - 3600,
'timestamp_completed': time.time() + 100, # Updated completion time
'original_request_json': json.dumps({'param1': 'value1', 'new_param': 'added'}),
'last_status_obj_json': json.dumps({'status': 'complete', 'message': 'Finished! With update.'})
}
add_entry_to_history(updated_data_complete)
print(f"Test entries added/updated in {HISTORY_DB_FILE}")
print("\nFetching all history entries (default sort):")
entries, total = get_history_entries(limit=5)
print(f"Total entries: {total}")
for entry in entries:
print(entry)
print("\nFetching history entries (sorted by item_name ASC, limit 2, offset 1):")
entries_sorted, total_sorted = get_history_entries(limit=2, offset=1, sort_by='item_name', sort_order='ASC')
print(f"Total entries (should be same as above): {total_sorted}")
for entry in entries_sorted:
print(entry)
print("\nFetching history entries with filter (status_final = COMPLETED):")
entries_filtered, total_filtered = get_history_entries(filters={'status_final': 'COMPLETED'})
print(f"Total COMPLETED entries: {total_filtered}")
for entry in entries_filtered:
print(entry)

View File

@@ -47,11 +47,11 @@ def download_playlist(
# Smartly determine where to look for Spotify search credentials
if service == 'spotify' and fallback:
# If fallback is enabled, use the fallback account for Spotify search credentials
search_creds_path = Path(f'./creds/spotify/{fallback}/search.json')
search_creds_path = Path(f'./data/creds/spotify/{fallback}/search.json')
print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}")
else:
# Otherwise use the main account for Spotify search credentials
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}")
if search_creds_path.exists():
@@ -77,7 +77,7 @@ def download_playlist(
deezer_error = None
try:
# Load Deezer credentials from 'main' under deezer directory
deezer_creds_dir = os.path.join('./creds/deezer', main)
deezer_creds_dir = os.path.join('./data/creds/deezer', main)
deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json'))
# DEBUG: Print Deezer credential paths being used
@@ -124,7 +124,7 @@ def download_playlist(
# Load fallback Spotify credentials and attempt download
try:
spo_creds_dir = os.path.join('./creds/spotify', fallback)
spo_creds_dir = os.path.join('./data/creds/spotify', fallback)
spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json'))
print(f"DEBUG: Using Spotify fallback credentials from: {spo_creds_path}")
@@ -168,7 +168,7 @@ def download_playlist(
# Original behavior: use Spotify main
if quality is None:
quality = 'HIGH'
creds_dir = os.path.join('./creds/spotify', main)
creds_dir = os.path.join('./data/creds/spotify', main)
credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json'))
print(f"DEBUG: Using Spotify main credentials from: {credentials_path}")
print(f"DEBUG: Credentials exist: {os.path.exists(credentials_path)}")
@@ -203,7 +203,7 @@ def download_playlist(
if quality is None:
quality = 'FLAC'
# Existing code for Deezer, using main as Deezer account.
creds_dir = os.path.join('./creds/deezer', main)
creds_dir = os.path.join('./data/creds/deezer', main)
creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json'))
print(f"DEBUG: Using Deezer credentials from: {creds_path}")
print(f"DEBUG: Credentials exist: {os.path.exists(creds_path)}")

View File

@@ -19,7 +19,7 @@ def search(
client_secret = None
if main:
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
logger.debug(f"Looking for credentials at: {search_creds_path}")
if search_creds_path.exists():

View File

@@ -47,11 +47,11 @@ def download_track(
# Smartly determine where to look for Spotify search credentials
if service == 'spotify' and fallback:
# If fallback is enabled, use the fallback account for Spotify search credentials
search_creds_path = Path(f'./creds/spotify/{fallback}/search.json')
search_creds_path = Path(f'./data/creds/spotify/{fallback}/search.json')
print(f"DEBUG: Using Spotify search credentials from fallback: {search_creds_path}")
else:
# Otherwise use the main account for Spotify search credentials
search_creds_path = Path(f'./creds/spotify/{main}/search.json')
search_creds_path = Path(f'./data/creds/spotify/{main}/search.json')
print(f"DEBUG: Using Spotify search credentials from main: {search_creds_path}")
if search_creds_path.exists():
@@ -76,7 +76,7 @@ def download_track(
# First attempt: use Deezer's download_trackspo with 'main' (Deezer credentials)
deezer_error = None
try:
deezer_creds_dir = os.path.join('./creds/deezer', main)
deezer_creds_dir = os.path.join('./data/creds/deezer', main)
deezer_creds_path = os.path.abspath(os.path.join(deezer_creds_dir, 'credentials.json'))
# DEBUG: Print Deezer credential paths being used
@@ -88,8 +88,8 @@ def download_track(
# List available directories to compare
print(f"DEBUG: Available Deezer credential directories:")
for dir_name in os.listdir('./creds/deezer'):
print(f"DEBUG: ./creds/deezer/{dir_name}")
for dir_name in os.listdir('./data/creds/deezer'):
print(f"DEBUG: ./data/creds/deezer/{dir_name}")
with open(deezer_creds_path, 'r') as f:
deezer_creds = json.load(f)
@@ -122,7 +122,7 @@ def download_track(
# If the first attempt fails, use the fallback Spotify credentials
try:
spo_creds_dir = os.path.join('./creds/spotify', fallback)
spo_creds_dir = os.path.join('./data/creds/spotify', fallback)
spo_creds_path = os.path.abspath(os.path.join(spo_creds_dir, 'credentials.json'))
# We've already loaded the Spotify client credentials above based on fallback
@@ -159,7 +159,7 @@ def download_track(
# Directly use Spotify main account
if quality is None:
quality = 'HIGH'
creds_dir = os.path.join('./creds/spotify', main)
creds_dir = os.path.join('./data/creds/spotify', main)
credentials_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json'))
spo = SpoLogin(
credentials_path=credentials_path,
@@ -188,7 +188,7 @@ def download_track(
if quality is None:
quality = 'FLAC'
# Deezer download logic remains unchanged, with the custom formatting parameters passed along.
creds_dir = os.path.join('./creds/deezer', main)
creds_dir = os.path.join('./data/creds/deezer', main)
creds_path = os.path.abspath(os.path.join(creds_dir, 'credentials.json'))
with open(creds_path, 'r') as f:
creds = json.load(f)

723
routes/utils/watch/db.py Normal file
View File

@@ -0,0 +1,723 @@
import sqlite3
import json
from pathlib import Path
import logging
import time
logger = logging.getLogger(__name__)
DB_DIR = Path('./data/watch')
# Define separate DB paths
PLAYLISTS_DB_PATH = DB_DIR / 'playlists.db'
ARTISTS_DB_PATH = DB_DIR / 'artists.db'
# Config path remains the same
CONFIG_PATH = Path('./data/config/watch.json')
def _get_playlists_db_connection():
DB_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(PLAYLISTS_DB_PATH, timeout=10)
conn.row_factory = sqlite3.Row
return conn
def _get_artists_db_connection():
DB_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(ARTISTS_DB_PATH, timeout=10)
conn.row_factory = sqlite3.Row
return conn
def init_playlists_db():
"""Initializes the playlists database and creates the main watched_playlists table if it doesn't exist."""
try:
with _get_playlists_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS watched_playlists (
spotify_id TEXT PRIMARY KEY,
name TEXT,
owner_id TEXT,
owner_name TEXT,
total_tracks INTEGER,
link TEXT,
snapshot_id TEXT,
last_checked INTEGER,
added_at INTEGER,
is_active INTEGER DEFAULT 1
)
""")
conn.commit()
logger.info(f"Playlists database initialized successfully at {PLAYLISTS_DB_PATH}")
except sqlite3.Error as e:
logger.error(f"Error initializing watched_playlists table: {e}", exc_info=True)
raise
def _create_playlist_tracks_table(playlist_spotify_id: str):
"""Creates a table for a specific playlist to store its tracks if it doesn't exist in playlists.db."""
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" # Sanitize table name
try:
with _get_playlists_db_connection() as conn: # Use playlists connection
cursor = conn.cursor()
cursor.execute(f"""
CREATE TABLE IF NOT EXISTS {table_name} (
spotify_track_id TEXT PRIMARY KEY,
title TEXT,
artist_names TEXT, -- Comma-separated artist names
album_name TEXT,
album_artist_names TEXT, -- Comma-separated album artist names
track_number INTEGER,
album_spotify_id TEXT,
duration_ms INTEGER,
added_at_playlist TEXT, -- When track was added to Spotify playlist
added_to_db INTEGER, -- Timestamp when track was added to this DB table
is_present_in_spotify INTEGER DEFAULT 1, -- Flag to mark if still in Spotify playlist
last_seen_in_spotify INTEGER -- Timestamp when last confirmed in Spotify playlist
)
""")
conn.commit()
logger.info(f"Tracks table '{table_name}' created or already exists in {PLAYLISTS_DB_PATH}.")
except sqlite3.Error as e:
logger.error(f"Error creating playlist tracks table {table_name} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True)
raise
def add_playlist_to_watch(playlist_data: dict):
"""Adds a playlist to the watched_playlists table and creates its tracks table in playlists.db."""
try:
_create_playlist_tracks_table(playlist_data['id'])
with _get_playlists_db_connection() as conn: # Use playlists connection
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO watched_playlists
(spotify_id, name, owner_id, owner_name, total_tracks, link, snapshot_id, last_checked, added_at, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
""", (
playlist_data['id'],
playlist_data['name'],
playlist_data['owner']['id'],
playlist_data['owner'].get('display_name', playlist_data['owner']['id']),
playlist_data['tracks']['total'],
playlist_data['external_urls']['spotify'],
playlist_data.get('snapshot_id'),
int(time.time()),
int(time.time())
))
conn.commit()
logger.info(f"Playlist '{playlist_data['name']}' ({playlist_data['id']}) added to watchlist in {PLAYLISTS_DB_PATH}.")
except sqlite3.Error as e:
logger.error(f"Error adding playlist {playlist_data.get('id')} to watchlist in {PLAYLISTS_DB_PATH}: {e}", exc_info=True)
raise
def remove_playlist_from_watch(playlist_spotify_id: str):
"""Removes a playlist from watched_playlists and drops its tracks table in playlists.db."""
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
try:
with _get_playlists_db_connection() as conn: # Use playlists connection
cursor = conn.cursor()
cursor.execute("DELETE FROM watched_playlists WHERE spotify_id = ?", (playlist_spotify_id,))
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
conn.commit()
logger.info(f"Playlist {playlist_spotify_id} removed from watchlist and its table '{table_name}' dropped in {PLAYLISTS_DB_PATH}.")
except sqlite3.Error as e:
logger.error(f"Error removing playlist {playlist_spotify_id} from watchlist in {PLAYLISTS_DB_PATH}: {e}", exc_info=True)
raise
def get_watched_playlists():
"""Retrieves all active playlists from the watched_playlists table in playlists.db."""
try:
with _get_playlists_db_connection() as conn: # Use playlists connection
cursor = conn.cursor()
cursor.execute("SELECT * FROM watched_playlists WHERE is_active = 1")
playlists = [dict(row) for row in cursor.fetchall()]
return playlists
except sqlite3.Error as e:
logger.error(f"Error retrieving watched playlists from {PLAYLISTS_DB_PATH}: {e}", exc_info=True)
return []
def get_watched_playlist(playlist_spotify_id: str):
"""Retrieves a specific playlist from the watched_playlists table in playlists.db."""
try:
with _get_playlists_db_connection() as conn: # Use playlists connection
cursor = conn.cursor()
cursor.execute("SELECT * FROM watched_playlists WHERE spotify_id = ?", (playlist_spotify_id,))
row = cursor.fetchone()
return dict(row) if row else None
except sqlite3.Error as e:
logger.error(f"Error retrieving playlist {playlist_spotify_id} from {PLAYLISTS_DB_PATH}: {e}", exc_info=True)
return None
def update_playlist_snapshot(playlist_spotify_id: str, snapshot_id: str, total_tracks: int):
"""Updates the snapshot_id and total_tracks for a watched playlist in playlists.db."""
try:
with _get_playlists_db_connection() as conn: # Use playlists connection
cursor = conn.cursor()
cursor.execute("""
UPDATE watched_playlists
SET snapshot_id = ?, total_tracks = ?, last_checked = ?
WHERE spotify_id = ?
""", (snapshot_id, total_tracks, int(time.time()), playlist_spotify_id))
conn.commit()
except sqlite3.Error as e:
logger.error(f"Error updating snapshot for playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True)
def get_playlist_track_ids_from_db(playlist_spotify_id: str):
"""Retrieves all track Spotify IDs from a specific playlist's tracks table in playlists.db."""
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
track_ids = set()
try:
with _get_playlists_db_connection() as conn: # Use playlists connection
cursor = conn.cursor()
cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';")
if cursor.fetchone() is None:
logger.warning(f"Track table {table_name} does not exist in {PLAYLISTS_DB_PATH}. Cannot fetch track IDs.")
return track_ids
cursor.execute(f"SELECT spotify_track_id FROM {table_name} WHERE is_present_in_spotify = 1")
rows = cursor.fetchall()
for row in rows:
track_ids.add(row['spotify_track_id'])
return track_ids
except sqlite3.Error as e:
logger.error(f"Error retrieving track IDs for playlist {playlist_spotify_id} from table {table_name} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True)
return track_ids
def add_tracks_to_playlist_db(playlist_spotify_id: str, tracks_data: list):
"""
Updates existing tracks in the playlist's DB table to mark them as currently present
in Spotify and updates their last_seen timestamp. Also refreshes metadata.
Does NOT insert new tracks. New tracks are only added upon successful download.
"""
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
if not tracks_data:
return
current_time = int(time.time())
tracks_to_update = []
for track_item in tracks_data:
track = track_item.get('track')
if not track or not track.get('id'):
logger.warning(f"Skipping track update due to missing data or ID in playlist {playlist_spotify_id}: {track_item}")
continue
artist_names = ", ".join([artist['name'] for artist in track.get('artists', []) if artist.get('name')])
album_artist_names = ", ".join([artist['name'] for artist in track.get('album', {}).get('artists', []) if artist.get('name')])
# Prepare tuple for UPDATE statement.
# Order: title, artist_names, album_name, album_artist_names, track_number,
# album_spotify_id, duration_ms, added_at_playlist,
# is_present_in_spotify, last_seen_in_spotify, spotify_track_id (for WHERE)
tracks_to_update.append((
track.get('name', 'N/A'),
artist_names,
track.get('album', {}).get('name', 'N/A'),
album_artist_names,
track.get('track_number'),
track.get('album', {}).get('id'),
track.get('duration_ms'),
track_item.get('added_at'), # From playlist item, update if changed
1, # is_present_in_spotify flag
current_time, # last_seen_in_spotify timestamp
# added_to_db is NOT updated here as this function only updates existing records.
track['id'] # spotify_track_id for the WHERE clause
))
if not tracks_to_update:
logger.info(f"No valid tracks to prepare for update for playlist {playlist_spotify_id}.")
return
try:
with _get_playlists_db_connection() as conn: # Use playlists connection
cursor = conn.cursor()
# The table should have been created when the playlist was added to watch
# or when the first track was successfully downloaded.
# _create_playlist_tracks_table(playlist_spotify_id) # Not strictly needed here if table creation is robust elsewhere.
# The fields in SET must match the order of ?s, excluding the last one for WHERE.
# This will only update rows where spotify_track_id matches.
cursor.executemany(f"""
UPDATE {table_name} SET
title = ?,
artist_names = ?,
album_name = ?,
album_artist_names = ?,
track_number = ?,
album_spotify_id = ?,
duration_ms = ?,
added_at_playlist = ?,
is_present_in_spotify = ?,
last_seen_in_spotify = ?
WHERE spotify_track_id = ?
""", tracks_to_update)
conn.commit()
logger.info(f"Attempted to update metadata for {len(tracks_to_update)} tracks from API in DB for playlist {playlist_spotify_id}. Actual rows updated: {cursor.rowcount if cursor.rowcount != -1 else 'unknown'}.")
except sqlite3.Error as e:
logger.error(f"Error updating tracks in playlist {playlist_spotify_id} in table {table_name} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True)
# Not raising here to allow other operations to continue if one batch fails.
def mark_tracks_as_not_present_in_spotify(playlist_spotify_id: str, track_ids_to_mark: list):
"""Marks specified tracks as not present in the Spotify playlist anymore in playlists.db."""
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
if not track_ids_to_mark:
return
try:
with _get_playlists_db_connection() as conn: # Use playlists connection
cursor = conn.cursor()
placeholders = ','.join('?' for _ in track_ids_to_mark)
sql = f"UPDATE {table_name} SET is_present_in_spotify = 0 WHERE spotify_track_id IN ({placeholders})"
cursor.execute(sql, track_ids_to_mark)
conn.commit()
logger.info(f"Marked {cursor.rowcount} tracks as not present in Spotify for playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}.")
except sqlite3.Error as e:
logger.error(f"Error marking tracks as not present for playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True)
def add_specific_tracks_to_playlist_table(playlist_spotify_id: str, track_details_list: list):
"""
Adds specific tracks (with full details fetched separately) to the playlist's table.
This is used when a user manually marks tracks as "downloaded" or "known".
"""
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
if not track_details_list:
return
current_time = int(time.time())
tracks_to_insert = []
for track in track_details_list: # track here is assumed to be a full Spotify TrackObject
if not track or not track.get('id'):
logger.warning(f"Skipping track due to missing data or ID (manual add) in playlist {playlist_spotify_id}: {track}")
continue
artist_names = ", ".join([artist['name'] for artist in track.get('artists', []) if artist.get('name')])
album_artist_names = ", ".join([artist['name'] for artist in track.get('album', {}).get('artists', []) if artist.get('name')])
tracks_to_insert.append((
track['id'],
track.get('name', 'N/A'),
artist_names,
track.get('album', {}).get('name', 'N/A'),
album_artist_names,
track.get('track_number'),
track.get('album', {}).get('id'),
track.get('duration_ms'),
None, # added_at_playlist - not known for manually added tracks this way
current_time, # added_to_db
1, # is_present_in_spotify (assume user wants it considered present)
current_time # last_seen_in_spotify
))
if not tracks_to_insert:
logger.info(f"No valid tracks to insert (manual add) for playlist {playlist_spotify_id}.")
return
try:
with _get_playlists_db_connection() as conn: # Use playlists connection
cursor = conn.cursor()
_create_playlist_tracks_table(playlist_spotify_id) # Ensure table exists
cursor.executemany(f"""
INSERT OR REPLACE INTO {table_name}
(spotify_track_id, title, artist_names, album_name, album_artist_names, track_number, album_spotify_id, duration_ms, added_at_playlist, added_to_db, is_present_in_spotify, last_seen_in_spotify)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", tracks_to_insert)
conn.commit()
logger.info(f"Manually added/updated {len(tracks_to_insert)} tracks in DB for playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}.")
except sqlite3.Error as e:
logger.error(f"Error manually adding tracks to playlist {playlist_spotify_id} in table {table_name} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True)
def remove_specific_tracks_from_playlist_table(playlist_spotify_id: str, track_spotify_ids: list):
"""Removes specific tracks from the playlist's local track table."""
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
if not track_spotify_ids:
return 0
try:
with _get_playlists_db_connection() as conn:
cursor = conn.cursor()
placeholders = ','.join('?' for _ in track_spotify_ids)
# Check if table exists first
cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';")
if cursor.fetchone() is None:
logger.warning(f"Track table {table_name} does not exist. Cannot remove tracks.")
return 0
cursor.execute(f"DELETE FROM {table_name} WHERE spotify_track_id IN ({placeholders})", track_spotify_ids)
conn.commit()
deleted_count = cursor.rowcount
logger.info(f"Manually removed {deleted_count} tracks from DB for playlist {playlist_spotify_id}.")
return deleted_count
except sqlite3.Error as e:
logger.error(f"Error manually removing tracks for playlist {playlist_spotify_id} from table {table_name}: {e}", exc_info=True)
return 0
def add_single_track_to_playlist_db(playlist_spotify_id: str, track_item_for_db: dict):
"""Adds or updates a single track in the specified playlist's tracks table in playlists.db."""
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
track_detail = track_item_for_db.get('track')
if not track_detail or not track_detail.get('id'):
logger.warning(f"Skipping single track due to missing data for playlist {playlist_spotify_id}: {track_item_for_db}")
return
current_time = int(time.time())
artist_names = ", ".join([a['name'] for a in track_detail.get('artists', []) if a.get('name')])
album_artist_names = ", ".join([a['name'] for a in track_detail.get('album', {}).get('artists', []) if a.get('name')])
track_data_tuple = (
track_detail['id'],
track_detail.get('name', 'N/A'),
artist_names,
track_detail.get('album', {}).get('name', 'N/A'),
album_artist_names,
track_detail.get('track_number'),
track_detail.get('album', {}).get('id'),
track_detail.get('duration_ms'),
track_item_for_db.get('added_at'),
current_time,
1,
current_time
)
try:
with _get_playlists_db_connection() as conn: # Use playlists connection
cursor = conn.cursor()
_create_playlist_tracks_table(playlist_spotify_id)
cursor.execute(f"""
INSERT OR REPLACE INTO {table_name}
(spotify_track_id, title, artist_names, album_name, album_artist_names, track_number, album_spotify_id, duration_ms, added_at_playlist, added_to_db, is_present_in_spotify, last_seen_in_spotify)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", track_data_tuple)
conn.commit()
logger.info(f"Track '{track_detail.get('name')}' added/updated in DB for playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}.")
except sqlite3.Error as e:
logger.error(f"Error adding single track to playlist {playlist_spotify_id} in {PLAYLISTS_DB_PATH}: {e}", exc_info=True)
# --- Artist Watch Database Functions ---
def init_artists_db():
"""Initializes the artists database and creates the watched_artists table if it doesn't exist."""
try:
with _get_artists_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS watched_artists (
spotify_id TEXT PRIMARY KEY,
name TEXT,
total_albums_on_spotify INTEGER,
last_checked INTEGER,
added_at INTEGER,
is_active INTEGER DEFAULT 1,
last_known_status TEXT,
last_task_id TEXT
)
""")
conn.commit()
logger.info(f"Artists database initialized successfully at {ARTISTS_DB_PATH}")
except sqlite3.Error as e:
logger.error(f"Error initializing watched_artists table in {ARTISTS_DB_PATH}: {e}", exc_info=True)
raise
def _create_artist_albums_table(artist_spotify_id: str):
"""Creates a table for a specific artist to store its albums if it doesn't exist in artists.db."""
table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums"
try:
with _get_artists_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(f"""
CREATE TABLE IF NOT EXISTS {table_name} (
album_spotify_id TEXT PRIMARY KEY,
name TEXT,
album_group TEXT,
album_type TEXT,
release_date TEXT,
total_tracks INTEGER,
added_to_db_at INTEGER,
is_download_initiated INTEGER DEFAULT 0,
task_id TEXT,
last_checked_for_tracks INTEGER
)
""")
conn.commit()
logger.info(f"Albums table '{table_name}' for artist {artist_spotify_id} created or exists in {ARTISTS_DB_PATH}.")
except sqlite3.Error as e:
logger.error(f"Error creating artist albums table {table_name} in {ARTISTS_DB_PATH}: {e}", exc_info=True)
raise
def add_artist_to_watch(artist_data: dict):
"""Adds an artist to the watched_artists table and creates its albums table in artists.db."""
artist_id = artist_data.get('id')
if not artist_id:
logger.error("Cannot add artist to watch: Missing 'id' in artist_data.")
return
try:
_create_artist_albums_table(artist_id)
with _get_artists_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO watched_artists
(spotify_id, name, total_albums_on_spotify, last_checked, added_at, is_active)
VALUES (?, ?, ?, ?, ?, 1)
""", (
artist_id,
artist_data.get('name', 'N/A'),
artist_data.get('albums', {}).get('total', 0),
int(time.time()),
int(time.time())
))
conn.commit()
logger.info(f"Artist '{artist_data.get('name')}' ({artist_id}) added to watchlist in {ARTISTS_DB_PATH}.")
except sqlite3.Error as e:
logger.error(f"Error adding artist {artist_id} to watchlist in {ARTISTS_DB_PATH}: {e}", exc_info=True)
raise
except KeyError as e:
logger.error(f"Missing key in artist_data for artist {artist_id}: {e}. Data: {artist_data}", exc_info=True)
raise
def remove_artist_from_watch(artist_spotify_id: str):
"""Removes an artist from watched_artists and drops its albums table in artists.db."""
table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums"
try:
with _get_artists_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM watched_artists WHERE spotify_id = ?", (artist_spotify_id,))
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
conn.commit()
logger.info(f"Artist {artist_spotify_id} removed from watchlist and its table '{table_name}' dropped from {ARTISTS_DB_PATH}.")
except sqlite3.Error as e:
logger.error(f"Error removing artist {artist_spotify_id} from watchlist in {ARTISTS_DB_PATH}: {e}", exc_info=True)
raise
def get_watched_artists():
"""Retrieves all active artists from the watched_artists table in artists.db."""
try:
with _get_artists_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM watched_artists WHERE is_active = 1")
artists = [dict(row) for row in cursor.fetchall()]
return artists
except sqlite3.Error as e:
logger.error(f"Error retrieving watched artists from {ARTISTS_DB_PATH}: {e}", exc_info=True)
return []
def get_watched_artist(artist_spotify_id: str):
"""Retrieves a specific artist from the watched_artists table in artists.db."""
try:
with _get_artists_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM watched_artists WHERE spotify_id = ?", (artist_spotify_id,))
row = cursor.fetchone()
return dict(row) if row else None
except sqlite3.Error as e:
logger.error(f"Error retrieving artist {artist_spotify_id} from {ARTISTS_DB_PATH}: {e}", exc_info=True)
return None
def update_artist_metadata_after_check(artist_spotify_id: str, total_albums_from_api: int):
"""Updates the total_albums_on_spotify and last_checked for an artist in artists.db."""
try:
with _get_artists_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE watched_artists
SET total_albums_on_spotify = ?, last_checked = ?
WHERE spotify_id = ?
""", (total_albums_from_api, int(time.time()), artist_spotify_id))
conn.commit()
except sqlite3.Error as e:
logger.error(f"Error updating metadata for artist {artist_spotify_id} in {ARTISTS_DB_PATH}: {e}", exc_info=True)
def get_artist_album_ids_from_db(artist_spotify_id: str):
"""Retrieves all album Spotify IDs from a specific artist's albums table in artists.db."""
table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums"
album_ids = set()
try:
with _get_artists_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';")
if cursor.fetchone() is None:
logger.warning(f"Album table {table_name} for artist {artist_spotify_id} does not exist in {ARTISTS_DB_PATH}. Cannot fetch album IDs.")
return album_ids
cursor.execute(f"SELECT album_spotify_id FROM {table_name}")
rows = cursor.fetchall()
for row in rows:
album_ids.add(row['album_spotify_id'])
return album_ids
except sqlite3.Error as e:
logger.error(f"Error retrieving album IDs for artist {artist_spotify_id} from {ARTISTS_DB_PATH}: {e}", exc_info=True)
return album_ids
def add_or_update_album_for_artist(artist_spotify_id: str, album_data: dict, task_id: str = None, is_download_complete: bool = False):
"""Adds or updates an album in the specified artist's albums table in artists.db."""
table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums"
album_id = album_data.get('id')
if not album_id:
logger.warning(f"Skipping album for artist {artist_spotify_id} due to missing album ID: {album_data}")
return
download_status = 0
if task_id and not is_download_complete:
download_status = 1
elif is_download_complete:
download_status = 2
current_time = int(time.time())
album_tuple = (
album_id,
album_data.get('name', 'N/A'),
album_data.get('album_group', 'N/A'),
album_data.get('album_type', 'N/A'),
album_data.get('release_date'),
album_data.get('total_tracks'),
current_time,
download_status,
task_id
)
try:
with _get_artists_db_connection() as conn:
cursor = conn.cursor()
_create_artist_albums_table(artist_spotify_id)
cursor.execute(f"SELECT added_to_db_at FROM {table_name} WHERE album_spotify_id = ?", (album_id,))
existing_row = cursor.fetchone()
if existing_row:
update_tuple = (
album_data.get('name', 'N/A'),
album_data.get('album_group', 'N/A'),
album_data.get('album_type', 'N/A'),
album_data.get('release_date'),
album_data.get('total_tracks'),
download_status,
task_id,
album_id
)
cursor.execute(f"""
UPDATE {table_name} SET
name = ?, album_group = ?, album_type = ?, release_date = ?, total_tracks = ?,
is_download_initiated = ?, task_id = ?
WHERE album_spotify_id = ?
""", update_tuple)
logger.info(f"Updated album '{album_data.get('name')}' in DB for artist {artist_spotify_id} in {ARTISTS_DB_PATH}.")
else:
cursor.execute(f"""
INSERT INTO {table_name}
(album_spotify_id, name, album_group, album_type, release_date, total_tracks, added_to_db_at, is_download_initiated, task_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", album_tuple)
logger.info(f"Added album '{album_data.get('name')}' to DB for artist {artist_spotify_id} in {ARTISTS_DB_PATH}.")
conn.commit()
except sqlite3.Error as e:
logger.error(f"Error adding/updating album {album_id} for artist {artist_spotify_id} in {ARTISTS_DB_PATH}: {e}", exc_info=True)
def update_album_download_status_for_artist(artist_spotify_id: str, album_spotify_id: str, task_id: str, status: int):
"""Updates the download status (is_download_initiated) and task_id for a specific album of an artist in artists.db."""
table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums"
try:
with _get_artists_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(f"""
UPDATE {table_name}
SET is_download_initiated = ?, task_id = ?
WHERE album_spotify_id = ?
""", (status, task_id, album_spotify_id))
if cursor.rowcount == 0:
logger.warning(f"Attempted to update download status for non-existent album {album_spotify_id} for artist {artist_spotify_id} in {ARTISTS_DB_PATH}.")
else:
logger.info(f"Updated download status to {status} for album {album_spotify_id} (task: {task_id}) for artist {artist_spotify_id} in {ARTISTS_DB_PATH}.")
conn.commit()
except sqlite3.Error as e:
logger.error(f"Error updating album download status for album {album_spotify_id}, artist {artist_spotify_id} in {ARTISTS_DB_PATH}: {e}", exc_info=True)
def add_specific_albums_to_artist_table(artist_spotify_id: str, album_details_list: list):
"""
Adds specific albums (with full details fetched separately) to the artist's album table.
This can be used when a user manually marks albums as "known" or "processed".
Albums added this way are marked with is_download_initiated = 3 (Manually Added/Known).
"""
if not album_details_list:
logger.info(f"No album details provided to add specifically for artist {artist_spotify_id}.")
return 0
processed_count = 0
for album_data in album_details_list:
if not album_data or not album_data.get('id'):
logger.warning(f"Skipping album due to missing data or ID (manual add) for artist {artist_spotify_id}: {album_data}")
continue
# Use existing function to add/update, ensuring it handles manual state
# Set task_id to None and is_download_initiated to a specific state for manually added known albums
# The add_or_update_album_for_artist expects `is_download_complete` not `is_download_initiated` directly.
# We can adapt `add_or_update_album_for_artist` or pass status directly if it's modified to handle it.
# For now, let's pass task_id=None and a flag that implies manual addition (e.g. is_download_complete=True, and then modify add_or_update_album_for_artist status logic)
# Or, more directly, update the `is_download_initiated` field as part of the album_tuple for INSERT and in UPDATE.
# Let's stick to calling `add_or_update_album_for_artist` and adjust its status handling if needed.
# Setting `is_download_complete=True` and `task_id=None` should set `is_download_initiated = 2` (completed)
# We might need a new status like 3 for "Manually Marked as Known"
# For simplicity, we'll use `add_or_update_album_for_artist` and the status will be 'download_complete'.
# If a more distinct status is needed, `add_or_update_album_for_artist` would need adjustment.
# Simplification: we'll call add_or_update_album_for_artist which will mark it based on task_id presence or completion.
# For a truly "manual" state distinct from "downloaded", `add_or_update_album_for_artist` would need a new status value.
# Let's assume for now that adding it via this function means it's "known" and doesn't need downloading.
# The `add_or_update_album_for_artist` function sets is_download_initiated based on task_id and is_download_complete.
# If task_id is None and is_download_complete is True, it implies it's processed.
try:
add_or_update_album_for_artist(artist_spotify_id, album_data, task_id=None, is_download_complete=True)
processed_count += 1
except Exception as e:
logger.error(f"Error manually adding album {album_data.get('id')} for artist {artist_spotify_id}: {e}", exc_info=True)
logger.info(f"Manually added/updated {processed_count} albums in DB for artist {artist_spotify_id} in {ARTISTS_DB_PATH}.")
return processed_count
def remove_specific_albums_from_artist_table(artist_spotify_id: str, album_spotify_ids: list):
"""Removes specific albums from the artist's local album table."""
table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums"
if not album_spotify_ids:
return 0
try:
with _get_artists_db_connection() as conn:
cursor = conn.cursor()
placeholders = ','.join('?' for _ in album_spotify_ids)
# Check if table exists first
cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';")
if cursor.fetchone() is None:
logger.warning(f"Album table {table_name} for artist {artist_spotify_id} does not exist. Cannot remove albums.")
return 0
cursor.execute(f"DELETE FROM {table_name} WHERE album_spotify_id IN ({placeholders})", album_spotify_ids)
conn.commit()
deleted_count = cursor.rowcount
logger.info(f"Manually removed {deleted_count} albums from DB for artist {artist_spotify_id}.")
return deleted_count
except sqlite3.Error as e:
logger.error(f"Error manually removing albums for artist {artist_spotify_id} from table {table_name}: {e}", exc_info=True)
return 0
def is_track_in_playlist_db(playlist_spotify_id: str, track_spotify_id: str) -> bool:
"""Checks if a specific track Spotify ID exists in the given playlist's tracks table."""
table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}"
try:
with _get_playlists_db_connection() as conn:
cursor = conn.cursor()
# First, check if the table exists to prevent errors on non-watched or new playlists
cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';")
if cursor.fetchone() is None:
return False # Table doesn't exist, so track cannot be in it
cursor.execute(f"SELECT 1 FROM {table_name} WHERE spotify_track_id = ?", (track_spotify_id,))
return cursor.fetchone() is not None
except sqlite3.Error as e:
logger.error(f"Error checking if track {track_spotify_id} is in playlist {playlist_spotify_id} DB: {e}", exc_info=True)
return False # Assume not present on error
def is_album_in_artist_db(artist_spotify_id: str, album_spotify_id: str) -> bool:
"""Checks if a specific album Spotify ID exists in the given artist's albums table."""
table_name = f"artist_{artist_spotify_id.replace('-', '_')}_albums"
try:
with _get_artists_db_connection() as conn:
cursor = conn.cursor()
# First, check if the table exists
cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';")
if cursor.fetchone() is None:
return False # Table doesn't exist
cursor.execute(f"SELECT 1 FROM {table_name} WHERE album_spotify_id = ?", (album_spotify_id,))
return cursor.fetchone() is not None
except sqlite3.Error as e:
logger.error(f"Error checking if album {album_spotify_id} is in artist {artist_spotify_id} DB: {e}", exc_info=True)
return False # Assume not present on error

View File

@@ -0,0 +1,420 @@
import time
import threading
import logging
import json
from pathlib import Path
from routes.utils.watch.db import (
get_watched_playlists,
get_watched_playlist,
get_playlist_track_ids_from_db,
add_tracks_to_playlist_db,
update_playlist_snapshot,
mark_tracks_as_not_present_in_spotify,
# Artist watch DB functions
init_artists_db,
get_watched_artists,
get_watched_artist,
get_artist_album_ids_from_db,
add_or_update_album_for_artist, # Renamed from add_album_to_artist_db
update_artist_metadata_after_check # Renamed from update_artist_metadata
)
from routes.utils.get_info import get_spotify_info # To fetch playlist, track, artist, and album details
from routes.utils.celery_queue_manager import download_queue_manager, get_config_params
logger = logging.getLogger(__name__)
CONFIG_PATH = Path('./data/config/watch.json')
STOP_EVENT = threading.Event()
DEFAULT_WATCH_CONFIG = {
"enabled": False,
"watchPollIntervalSeconds": 3600,
"max_tracks_per_run": 50, # For playlists
"watchedArtistAlbumGroup": ["album", "single"], # Default for artists
"delay_between_playlists_seconds": 2,
"delay_between_artists_seconds": 5 # Added for artists
}
def get_watch_config():
"""Loads the watch configuration from watch.json."""
try:
if CONFIG_PATH.exists():
with open(CONFIG_PATH, 'r') as f:
config = json.load(f)
# Ensure all default keys are present
for key, value in DEFAULT_WATCH_CONFIG.items():
config.setdefault(key, value)
return config
else:
# Create a default config if it doesn't exist
with open(CONFIG_PATH, 'w') as f:
json.dump(DEFAULT_WATCH_CONFIG, f, indent=2)
logger.info(f"Created default watch config at {CONFIG_PATH}")
return DEFAULT_WATCH_CONFIG
except Exception as e:
logger.error(f"Error loading watch config: {e}", exc_info=True)
return DEFAULT_WATCH_CONFIG # Fallback
def construct_spotify_url(item_id, item_type="track"):
return f"https://open.spotify.com/{item_type}/{item_id}"
def check_watched_playlists(specific_playlist_id: str = None):
"""Checks watched playlists for new tracks and queues downloads.
If specific_playlist_id is provided, only that playlist is checked.
"""
logger.info(f"Playlist Watch Manager: Starting check. Specific playlist: {specific_playlist_id or 'All'}")
config = get_watch_config()
if specific_playlist_id:
playlist_obj = get_watched_playlist(specific_playlist_id)
if not playlist_obj:
logger.error(f"Playlist Watch Manager: Playlist {specific_playlist_id} not found in watch database.")
return
watched_playlists_to_check = [playlist_obj]
else:
watched_playlists_to_check = get_watched_playlists()
if not watched_playlists_to_check:
logger.info("Playlist Watch Manager: No playlists to check.")
return
for playlist_in_db in watched_playlists_to_check:
playlist_spotify_id = playlist_in_db['spotify_id']
playlist_name = playlist_in_db['name']
logger.info(f"Playlist Watch Manager: Checking playlist '{playlist_name}' ({playlist_spotify_id})...")
try:
# For playlists, we fetch all tracks in one go usually (Spotify API limit permitting)
current_playlist_data_from_api = get_spotify_info(playlist_spotify_id, "playlist")
if not current_playlist_data_from_api or 'tracks' not in current_playlist_data_from_api:
logger.error(f"Playlist Watch Manager: Failed to fetch data or tracks from Spotify for playlist {playlist_spotify_id}.")
continue
api_snapshot_id = current_playlist_data_from_api.get('snapshot_id')
api_total_tracks = current_playlist_data_from_api.get('tracks', {}).get('total', 0)
# Paginate through playlist tracks if necessary
all_api_track_items = []
offset = 0
limit = 50 # Spotify API limit for playlist items
while True:
# Re-fetch with pagination if tracks.next is present, or on first call.
# get_spotify_info for playlist should ideally handle pagination internally if asked for all tracks.
# Assuming get_spotify_info for playlist returns all items or needs to be called iteratively.
# For simplicity, let's assume current_playlist_data_from_api has 'tracks' -> 'items' for the first page.
# And that get_spotify_info with 'playlist' type can take offset.
# Modifying get_spotify_info is outside current scope, so we'll assume it returns ALL items for a playlist.
# If it doesn't, this part would need adjustment for robust pagination.
# For now, we use the items from the initial fetch.
paginated_playlist_data = get_spotify_info(playlist_spotify_id, "playlist", offset=offset, limit=limit)
if not paginated_playlist_data or 'tracks' not in paginated_playlist_data:
break
page_items = paginated_playlist_data.get('tracks', {}).get('items', [])
if not page_items:
break
all_api_track_items.extend(page_items)
if paginated_playlist_data.get('tracks', {}).get('next'):
offset += limit
else:
break
current_api_track_ids = set()
api_track_id_to_item_map = {}
for item in all_api_track_items: # Use all_api_track_items
track = item.get('track')
if track and track.get('id') and not track.get('is_local'):
track_id = track['id']
current_api_track_ids.add(track_id)
api_track_id_to_item_map[track_id] = item
db_track_ids = get_playlist_track_ids_from_db(playlist_spotify_id)
new_track_ids_for_download = current_api_track_ids - db_track_ids
queued_for_download_count = 0
if new_track_ids_for_download:
logger.info(f"Playlist Watch Manager: Found {len(new_track_ids_for_download)} new tracks for playlist '{playlist_name}' to download.")
for track_id in new_track_ids_for_download:
api_item = api_track_id_to_item_map.get(track_id)
if not api_item or not api_item.get("track"):
logger.warning(f"Playlist Watch Manager: Missing track details in API map for new track_id {track_id} in playlist {playlist_spotify_id}. Cannot queue.")
continue
track_to_queue = api_item["track"]
task_payload = {
"download_type": "track",
"url": construct_spotify_url(track_id, "track"),
"name": track_to_queue.get('name', 'Unknown Track'),
"artist": ", ".join([a['name'] for a in track_to_queue.get('artists', []) if a.get('name')]),
"orig_request": {
"source": "playlist_watch",
"playlist_id": playlist_spotify_id,
"playlist_name": playlist_name,
"track_spotify_id": track_id,
"track_item_for_db": api_item # Pass full API item for DB update on completion
}
# "track_details_for_db" was old name, using track_item_for_db consistent with celery_tasks
}
try:
task_id_or_none = download_queue_manager.add_task(task_payload, from_watch_job=True)
if task_id_or_none: # Task was newly queued
logger.info(f"Playlist Watch Manager: Queued download task {task_id_or_none} for new track {track_id} ('{track_to_queue.get('name')}') from playlist '{playlist_name}'.")
queued_for_download_count += 1
# If task_id_or_none is None, it was a duplicate and not re-queued, Celery manager handles logging.
except Exception as e:
logger.error(f"Playlist Watch Manager: Failed to queue download for new track {track_id} from playlist '{playlist_name}': {e}", exc_info=True)
logger.info(f"Playlist Watch Manager: Attempted to queue {queued_for_download_count} new tracks for playlist '{playlist_name}'.")
else:
logger.info(f"Playlist Watch Manager: No new tracks to download for playlist '{playlist_name}'.")
# Update DB for tracks that are still present in API (e.g. update 'last_seen_in_spotify')
# add_tracks_to_playlist_db handles INSERT OR REPLACE, updating existing entries.
# We should pass all current API tracks to ensure their `last_seen_in_spotify` and `is_present_in_spotify` are updated.
if all_api_track_items: # If there are any tracks in the API for this playlist
logger.info(f"Playlist Watch Manager: Refreshing {len(all_api_track_items)} tracks from API in local DB for playlist '{playlist_name}'.")
add_tracks_to_playlist_db(playlist_spotify_id, all_api_track_items)
removed_db_ids = db_track_ids - current_api_track_ids
if removed_db_ids:
logger.info(f"Playlist Watch Manager: {len(removed_db_ids)} tracks removed from Spotify playlist '{playlist_name}'. Marking in DB.")
mark_tracks_as_not_present_in_spotify(playlist_spotify_id, list(removed_db_ids))
update_playlist_snapshot(playlist_spotify_id, api_snapshot_id, api_total_tracks) # api_total_tracks from initial fetch
logger.info(f"Playlist Watch Manager: Finished checking playlist '{playlist_name}'. Snapshot ID updated. API Total Tracks: {api_total_tracks}.")
except Exception as e:
logger.error(f"Playlist Watch Manager: Error processing playlist {playlist_spotify_id}: {e}", exc_info=True)
time.sleep(max(1, config.get("delay_between_playlists_seconds", 2)))
logger.info("Playlist Watch Manager: Finished checking all watched playlists.")
def check_watched_artists(specific_artist_id: str = None):
"""Checks watched artists for new albums and queues downloads."""
logger.info(f"Artist Watch Manager: Starting check. Specific artist: {specific_artist_id or 'All'}")
config = get_watch_config()
watched_album_groups = [g.lower() for g in config.get("watchedArtistAlbumGroup", ["album", "single"])]
logger.info(f"Artist Watch Manager: Watching for album groups: {watched_album_groups}")
if specific_artist_id:
artist_obj_in_db = get_watched_artist(specific_artist_id)
if not artist_obj_in_db:
logger.error(f"Artist Watch Manager: Artist {specific_artist_id} not found in watch database.")
return
artists_to_check = [artist_obj_in_db]
else:
artists_to_check = get_watched_artists()
if not artists_to_check:
logger.info("Artist Watch Manager: No artists to check.")
return
for artist_in_db in artists_to_check:
artist_spotify_id = artist_in_db['spotify_id']
artist_name = artist_in_db['name']
logger.info(f"Artist Watch Manager: Checking artist '{artist_name}' ({artist_spotify_id})...")
try:
# Spotify API for artist albums is paginated.
# We need to fetch all albums. get_spotify_info with type 'artist-albums' should handle this.
# Let's assume get_spotify_info(artist_id, 'artist-albums') returns a list of all album objects.
# Or we implement pagination here.
all_artist_albums_from_api = []
offset = 0
limit = 50 # Spotify API limit for artist albums
while True:
# The 'artist-albums' type for get_spotify_info needs to support pagination params.
# And return a list of album objects.
logger.debug(f"Artist Watch Manager: Fetching albums for {artist_spotify_id}. Limit: {limit}, Offset: {offset}")
artist_albums_page = get_spotify_info(artist_spotify_id, "artist_discography", limit=limit, offset=offset)
if not artist_albums_page or not isinstance(artist_albums_page.get('items'), list):
logger.warning(f"Artist Watch Manager: No album items found or invalid format for artist {artist_spotify_id} (name: '{artist_name}') at offset {offset}. Response: {artist_albums_page}")
break
current_page_albums = artist_albums_page.get('items', [])
if not current_page_albums:
logger.info(f"Artist Watch Manager: No more albums on page for artist {artist_spotify_id} (name: '{artist_name}') at offset {offset}. Total fetched so far: {len(all_artist_albums_from_api)}.")
break
logger.debug(f"Artist Watch Manager: Fetched {len(current_page_albums)} albums on current page for artist '{artist_name}'.")
all_artist_albums_from_api.extend(current_page_albums)
# Correct pagination: Check if Spotify indicates a next page URL
# The `next` field in Spotify API responses is a URL to the next page or null.
if artist_albums_page.get('next'):
offset += limit # CORRECT: Increment offset by the limit used for the request
else:
logger.info(f"Artist Watch Manager: No 'next' page URL for artist '{artist_name}'. Pagination complete. Total albums fetched: {len(all_artist_albums_from_api)}.")
break
# total_albums_from_api = len(all_artist_albums_from_api)
# Use the 'total' field from the API response for a more accurate count of all available albums (matching current API filter if any)
api_reported_total_albums = artist_albums_page.get('total', 0) if 'artist_albums_page' in locals() and artist_albums_page else len(all_artist_albums_from_api)
logger.info(f"Artist Watch Manager: Fetched {len(all_artist_albums_from_api)} albums in total from API for artist '{artist_name}'. API reports total: {api_reported_total_albums}.")
db_album_ids = get_artist_album_ids_from_db(artist_spotify_id)
logger.info(f"Artist Watch Manager: Found {len(db_album_ids)} albums in DB for artist '{artist_name}'. These will be skipped if re-encountered unless logic changes.")
queued_for_download_count = 0
processed_album_ids_in_run = set() # To avoid processing duplicate album_ids if API returns them across pages (should not happen with correct pagination)
for album_data in all_artist_albums_from_api:
album_id = album_data.get('id')
album_name = album_data.get('name', 'Unknown Album')
album_group = album_data.get('album_group', 'N/A').lower()
album_type = album_data.get('album_type', 'N/A').lower()
if not album_id:
logger.warning(f"Artist Watch Manager: Skipping album without ID for artist '{artist_name}'. Album data: {album_data}")
continue
if album_id in processed_album_ids_in_run:
logger.debug(f"Artist Watch Manager: Album '{album_name}' ({album_id}) already processed in this run. Skipping.")
continue
processed_album_ids_in_run.add(album_id)
# Filter based on watchedArtistAlbumGroup
# The album_group field is generally preferred for this type of categorization as per Spotify docs.
is_matching_group = album_group in watched_album_groups
logger.debug(f"Artist '{artist_name}', Album '{album_name}' ({album_id}): album_group='{album_group}', album_type='{album_type}'. Watched groups: {watched_album_groups}. Match: {is_matching_group}.")
if not is_matching_group:
logger.debug(f"Artist Watch Manager: Skipping album '{album_name}' ({album_id}) by '{artist_name}' - group '{album_group}' not in watched list: {watched_album_groups}.")
continue
logger.info(f"Artist Watch Manager: Album '{album_name}' ({album_id}) by '{artist_name}' (group: {album_group}) IS a matching group.")
if album_id not in db_album_ids:
logger.info(f"Artist Watch Manager: Found NEW matching album '{album_name}' ({album_id}) by '{artist_name}'. Queuing for download.")
album_artists_list = album_data.get('artists', [])
album_main_artist_name = album_artists_list[0].get('name', 'Unknown Artist') if album_artists_list else 'Unknown Artist'
task_payload = {
"download_type": "album", # Or "track" if downloading individual tracks of album later
"url": construct_spotify_url(album_id, "album"),
"name": album_name,
"artist": album_main_artist_name, # Primary artist of the album
"orig_request": {
"source": "artist_watch",
"artist_spotify_id": artist_spotify_id, # Watched artist
"artist_name": artist_name,
"album_spotify_id": album_id,
"album_data_for_db": album_data # Pass full API album object for DB update on completion/queuing
}
}
try:
# Add to DB first with task_id, then queue. Or queue and add task_id to DB.
# Let's use add_or_update_album_for_artist to record it with a task_id before queuing.
# The celery_queue_manager.add_task might return None if it's a duplicate.
# Record the album in DB as being processed for download
# Task_id will be added if successfully queued
# We should call add_task first, and if it returns a task_id (not a duplicate), then update our DB.
task_id_or_none = download_queue_manager.add_task(task_payload, from_watch_job=True)
if task_id_or_none: # Task was newly queued
# REMOVED: add_or_update_album_for_artist(artist_spotify_id, album_data, task_id=task_id_or_none, is_download_complete=False)
# The album will be added/updated in the DB by celery_tasks.py upon successful download completion.
logger.info(f"Artist Watch Manager: Queued download task {task_id_or_none} for new album '{album_name}' from artist '{artist_name}'. DB entry will be created/updated on success.")
queued_for_download_count += 1
# If task_id_or_none is None, it was a duplicate. Celery manager handles logging.
except Exception as e:
logger.error(f"Artist Watch Manager: Failed to queue download for new album {album_id} ('{album_name}') from artist '{artist_name}': {e}", exc_info=True)
else:
logger.info(f"Artist Watch Manager: Album '{album_name}' ({album_id}) by '{artist_name}' already known in DB (ID found in db_album_ids). Skipping queue.")
# Optionally, update its entry (e.g. last_seen, or if details changed), but for now, we only queue new ones.
# add_or_update_album_for_artist(artist_spotify_id, album_data, task_id=None, is_download_complete=False) # would update added_to_db_at
logger.info(f"Artist Watch Manager: For artist '{artist_name}', processed {len(all_artist_albums_from_api)} API albums, attempted to queue {queued_for_download_count} new albums.")
update_artist_metadata_after_check(artist_spotify_id, api_reported_total_albums)
logger.info(f"Artist Watch Manager: Finished checking artist '{artist_name}'. DB metadata updated. API reported total albums (for API filter): {api_reported_total_albums}.")
except Exception as e:
logger.error(f"Artist Watch Manager: Error processing artist {artist_spotify_id} ('{artist_name}'): {e}", exc_info=True)
time.sleep(max(1, config.get("delay_between_artists_seconds", 5)))
logger.info("Artist Watch Manager: Finished checking all watched artists.")
def playlist_watch_scheduler():
"""Periodically calls check_watched_playlists and check_watched_artists."""
logger.info("Watch Scheduler: Thread started.")
config = get_watch_config() # Load config once at start, or reload each loop? Reload each loop for dynamic changes.
while not STOP_EVENT.is_set():
current_config = get_watch_config() # Get latest config for this run
interval = current_config.get("watchPollIntervalSeconds", 3600)
watch_enabled = current_config.get("enabled", False) # Get enabled status
if not watch_enabled:
logger.info("Watch Scheduler: Watch feature is disabled in config. Skipping checks.")
STOP_EVENT.wait(interval) # Still respect poll interval for checking config again
continue # Skip to next iteration
try:
logger.info("Watch Scheduler: Starting playlist check run.")
check_watched_playlists()
logger.info("Watch Scheduler: Playlist check run completed.")
except Exception as e:
logger.error(f"Watch Scheduler: Unhandled exception during check_watched_playlists: {e}", exc_info=True)
# Add a small delay between playlist and artist checks if desired
# time.sleep(current_config.get("delay_between_check_types_seconds", 10))
if STOP_EVENT.is_set(): break # Check stop event again before starting artist check
try:
logger.info("Watch Scheduler: Starting artist check run.")
check_watched_artists()
logger.info("Watch Scheduler: Artist check run completed.")
except Exception as e:
logger.error(f"Watch Scheduler: Unhandled exception during check_watched_artists: {e}", exc_info=True)
logger.info(f"Watch Scheduler: All checks complete. Next run in {interval} seconds.")
STOP_EVENT.wait(interval)
logger.info("Watch Scheduler: Thread stopped.")
# --- Global thread for the scheduler ---
_watch_scheduler_thread = None # Renamed from _playlist_watch_thread
def start_watch_manager(): # Renamed from start_playlist_watch_manager
global _watch_scheduler_thread
if _watch_scheduler_thread is None or not _watch_scheduler_thread.is_alive():
STOP_EVENT.clear()
# Initialize DBs on start
from routes.utils.watch.db import init_playlists_db, init_artists_db # Updated import
init_playlists_db() # For playlists
init_artists_db() # For artists
_watch_scheduler_thread = threading.Thread(target=playlist_watch_scheduler, daemon=True)
_watch_scheduler_thread.start()
logger.info("Watch Manager: Background scheduler started (includes playlists and artists).")
else:
logger.info("Watch Manager: Background scheduler already running.")
def stop_watch_manager(): # Renamed from stop_playlist_watch_manager
global _watch_scheduler_thread
if _watch_scheduler_thread and _watch_scheduler_thread.is_alive():
logger.info("Watch Manager: Stopping background scheduler...")
STOP_EVENT.set()
_watch_scheduler_thread.join(timeout=10)
if _watch_scheduler_thread.is_alive():
logger.warning("Watch Manager: Scheduler thread did not stop in time.")
else:
logger.info("Watch Manager: Background scheduler stopped.")
_watch_scheduler_thread = None
else:
logger.info("Watch Manager: Background scheduler not running.")
# If this module is imported, and you want to auto-start the manager, you could call start_watch_manager() here.
# However, it's usually better to explicitly start it from the main application/__init__.py.

373
src/js/album.ts Normal file
View File

@@ -0,0 +1,373 @@
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();
});
}
});
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;
}
}

765
src/js/artist.ts Normal file
View File

@@ -0,0 +1,765 @@
// 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
}
document.addEventListener('DOMContentLoaded', () => {
const pathSegments = window.location.pathname.split('/');
const artistId = pathSegments[pathSegments.indexOf('artist') + 1];
if (!artistId) {
showError('No artist ID provided.');
return;
}
// 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))
.catch(error => {
console.error('Error:', error);
showError('Failed to load artist info.');
});
const queueIcon = document.getElementById('queueIcon');
if (queueIcon) {
queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility());
}
// 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) {
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
const 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;
}
// Initialize Watch Button after other elements are rendered
const watchArtistBtn = document.getElementById('watchArtistBtn') as HTMLButtonElement | null;
if (watchArtistBtn) {
initializeWatchButton(artistId, isArtistActuallyWatched);
} else {
console.warn("Watch artist button not found in HTML.");
}
// 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; // New way
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 (useThisWatchStatusForAlbums && 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 (useThisWatchStatusForAlbums && 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);
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) {
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) {
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
const newArtistData = await (await fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`)).json() as ArtistData;
renderArtist(newArtistData, artistId);
} 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;
renderArtist(newArtistData, artistId);
}
} 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);
}

1046
src/js/config.ts Normal file

File diff suppressed because it is too large Load Diff

160
src/js/history.ts Normal file
View File

@@ -0,0 +1,160 @@
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;
let currentPage = 1;
let limit = 25;
let totalEntries = 0;
let currentSortBy = 'timestamp_completed';
let currentSortOrder = 'DESC';
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}`;
}
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();
} catch (error) {
console.error('Error fetching history:', error);
if (historyTableBody) {
historyTableBody.innerHTML = '<tr><td colspan="7">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="7">No history entries found.</td></tr>';
return;
}
entries.forEach(entry => {
const row = historyTableBody.insertRow();
row.insertCell().textContent = entry.item_name || 'N/A';
row.insertCell().textContent = entry.item_artist || 'N/A';
row.insertCell().textContent = entry.download_type ? entry.download_type.charAt(0).toUpperCase() + entry.download_type.slice(1) : 'N/A';
const statusCell = row.insertCell();
statusCell.textContent = entry.status_final || 'N/A';
statusCell.className = `status-${entry.status_final}`;
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 detailsCell = row.insertCell();
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);
detailsCell.appendChild(detailsButton);
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 = 7; // Span across all columns
newCell.appendChild(errorDetailsDiv);
// Visually, this new cell will be after the 'Details' button cell.
// To make it appear as part of the status cell or below the row, more complex DOM manipulation or CSS would be needed.
}
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 showDetailsModal(entry: any) {
const 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` +
`Spotify ID: ${entry.spotify_id || '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\n` +
`Original 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)}`;
alert(details);
}
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);
});
});
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));
// Initial fetch
fetchHistory();
});

590
src/js/main.ts Normal file
View File

@@ -0,0 +1,590 @@
// 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');
// 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);
});
}
// 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);
}
}
});

749
src/js/playlist.ts Normal file
View File

@@ -0,0 +1,749 @@
// 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
}
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
}
document.addEventListener('DOMContentLoaded', () => {
// 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;
}
// 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))
.catch(error => {
console.error('Error:', error);
showError('Failed to load playlist.');
});
// Fetch initial watch status
fetchWatchStatus(playlistId);
const queueIcon = document.getElementById('queueIcon');
if (queueIcon) {
queueIcon.addEventListener('click', () => {
downloadQueue.toggleVisibility();
});
}
});
/**
* Renders playlist header and tracks.
*/
function renderPlaylist(playlist: Playlist) {
// 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;
const isPlaylistWatched = watchPlaylistButton && watchPlaylistButton.classList.contains('watching');
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 (isPlaylistWatched) {
// 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();
}
/**
* 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() {
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;
}
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;
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);
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;
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);
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
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

@@ -35,15 +35,18 @@ document.addEventListener('DOMContentLoaded', () => {
/**
* Renders the track header information.
*/
function renderTrack(track) {
function renderTrack(track: any) {
// Hide the loading and error messages.
document.getElementById('loading').classList.add('hidden');
document.getElementById('error').classList.add('hidden');
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
document.getElementById('loading').classList.add('hidden');
const loadingElExplicit = document.getElementById('loading');
if (loadingElExplicit) loadingElExplicit.classList.add('hidden');
const placeholderContent = `
<div class="explicit-filter-placeholder">
@@ -63,30 +66,46 @@ function renderTrack(track) {
}
// Update track information fields.
document.getElementById('track-name').innerHTML =
`<a href="/track/${track.id || ''}" title="View track details">${track.name || 'Unknown Track'}</a>`;
const trackNameEl = document.getElementById('track-name');
if (trackNameEl) {
trackNameEl.innerHTML =
`<a href="/track/${track.id || ''}" title="View track details">${track.name || 'Unknown Track'}</a>`;
}
document.getElementById('track-artist').innerHTML =
`By ${track.artists?.map(a =>
`<a href="/artist/${a?.id || ''}" title="View artist details">${a?.name || 'Unknown Artist'}</a>`
).join(', ') || 'Unknown Artist'}`;
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'}`;
}
document.getElementById('track-album').innerHTML =
`Album: <a href="/album/${track.album?.id || ''}" title="View album details">${track.album?.name || 'Unknown Album'}</a> (${track.album?.album_type || 'album'})`;
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'})`;
}
document.getElementById('track-duration').textContent =
`Duration: ${msToTime(track.duration_ms || 0)}`;
const trackDurationEl = document.getElementById('track-duration');
if (trackDurationEl) {
trackDurationEl.textContent =
`Duration: ${msToTime(track.duration_ms || 0)}`;
}
document.getElementById('track-explicit').textContent =
track.explicit ? 'Explicit' : 'Clean';
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';
document.getElementById('track-album-image').src = imageUrl;
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');
let homeButton = document.getElementById('homeButton') as HTMLButtonElement;
if (!homeButton) {
homeButton = document.createElement('button');
homeButton.id = 'homeButton';
@@ -103,7 +122,7 @@ function renderTrack(track) {
});
// --- Move the Download Button from #actions into #track-header ---
let downloadBtn = document.getElementById('downloadTrackBtn');
let downloadBtn = document.getElementById('downloadTrackBtn') as HTMLButtonElement;
if (downloadBtn) {
// Remove the parent container (#actions) if needed.
const actionsContainer = document.getElementById('actions');
@@ -131,15 +150,22 @@ function renderTrack(track) {
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(trackUrl, 'track', { name: track.name || 'Unknown Track', artist: track.artists?.[0]?.name })
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 => {
.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">`;
@@ -148,13 +174,14 @@ function renderTrack(track) {
}
// Reveal the header now that track info is loaded.
document.getElementById('track-header').classList.remove('hidden');
const trackHeaderEl = document.getElementById('track-header');
if (trackHeaderEl) trackHeaderEl.classList.remove('hidden');
}
/**
* Converts milliseconds to minutes:seconds.
*/
function msToTime(duration) {
function msToTime(duration: number) {
if (!duration || isNaN(duration)) return '0:00';
const minutes = Math.floor(duration / 60000);
@@ -165,7 +192,7 @@ function msToTime(duration) {
/**
* Displays an error message in the UI.
*/
function showError(message) {
function showError(message: string) {
const errorEl = document.getElementById('error');
if (errorEl) {
errorEl.textContent = message || 'An error occurred';
@@ -176,19 +203,19 @@ function showError(message) {
/**
* Starts the download process by calling the centralized downloadQueue method
*/
async function startDownload(url, type, item) {
if (!url || !type) {
showError('Missing URL or type for download');
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(url, type, item);
await downloadQueue.download(itemId, type, item);
// Make the queue visible after queueing
downloadQueue.toggleVisibility(true);
} catch (error) {
} catch (error: any) {
showError('Download failed: ' + (error?.message || 'Unknown error'));
throw error;
}

644
src/js/watch.ts Normal file
View File

@@ -0,0 +1,644 @@
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' });
document.addEventListener('DOMContentLoaded', 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;
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
loadWatchedItems();
});
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

@@ -325,6 +325,53 @@ body {
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) */
@@ -434,3 +481,157 @@ 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;
}

View File

@@ -65,85 +65,6 @@ body {
font-weight: bold;
}
/* Back button as floating icon - keep this for our floating button */
.back-button.floating-icon {
position: fixed;
width: 56px;
height: 56px;
bottom: 20px;
left: 20px;
background-color: var(--color-primary);
border-radius: 50%;
box-shadow: var(--shadow-lg);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, background-color 0.2s ease;
text-decoration: none !important;
}
.back-button.floating-icon:hover {
background-color: var(--color-primary-hover);
transform: scale(1.05);
}
.back-button.floating-icon img {
width: 24px;
height: 24px;
filter: brightness(0) invert(1);
margin: 0;
}
/* Queue button as floating icon */
.queue-icon.floating-icon {
position: fixed;
width: 56px;
height: 56px;
bottom: 20px;
right: 20px;
background-color: var(--color-primary);
border-radius: 50%;
box-shadow: var(--shadow-lg);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, background-color 0.2s ease;
text-decoration: none !important;
}
.queue-icon.floating-icon:hover {
background-color: var(--color-primary-hover);
transform: scale(1.05);
}
.queue-icon.floating-icon img {
width: 24px;
height: 24px;
filter: brightness(0) invert(1);
margin: 0;
}
/* Queue Icon Active State */
.queue-icon.queue-icon-active {
background-color: #d13838 !important;
transition: background-color 0.3s ease;
}
.queue-icon.queue-icon-active:hover {
background-color: #e04c4c !important;
}
.queue-icon .queue-x {
font-size: 28px;
font-weight: bold;
color: white;
line-height: 24px;
display: inline-block;
transform: translateY(-2px);
}
/* Queue Sidebar for Config Page */
#downloadQueue {
position: fixed;
@@ -849,24 +770,6 @@ input:checked + .slider:before {
width: 28px;
height: 28px;
}
/* Queue icon mobile styles */
.queue-icon.floating-icon {
width: 50px;
height: 50px;
right: 16px;
bottom: 16px;
}
.queue-icon.floating-icon img {
width: 22px;
height: 22px;
}
.queue-icon .queue-x {
font-size: 24px;
line-height: 20px;
}
}
/* Format help styles */
@@ -928,3 +831,166 @@ input:checked + .slider:before {
opacity: 1;
transform: translateY(0);
}
/* Credentials List Wrapper */
.credentials-list-wrapper {
background: #181818; /* Same as original .credentials-list.card */
border-radius: 12px;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
padding: 1.5rem; /* Add padding here if you want it around the whole block */
margin-bottom: 2rem;
}
/* Where individual credential items will be rendered */
.credentials-list-items {
/* No specific styles needed here unless items need separation from the add button */
}
/* Styling for the Add New Account button to make it look like a list item */
.add-account-item {
margin-top: 0.75rem; /* Space above the add button if there are items */
}
.btn-add-account-styled {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 1.25rem;
background-color: #1db954; /* Green background */
color: #ffffff;
border: none;
border-radius: 8px; /* Same as credential-item */
font-size: 1.1rem; /* Similar to credential-name */
font-weight: 600;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.2s ease;
text-align: center;
opacity: 1; /* Ensure it's not transparent by default */
}
.btn-add-account-styled img {
width: 20px; /* Adjust as needed */
height: 20px; /* Adjust as needed */
margin-right: 10px;
filter: brightness(0) invert(1); /* Make icon white if it's not already */
}
.btn-add-account-styled:hover {
background-color: #1aa34a; /* Darker green on hover */
transform: translateY(-1px);
}
/* New styles for the icon-based cancel button */
.btn-cancel-icon {
background-color: #c0392b !important; /* Red background */
padding: 0.6rem !important; /* Adjust padding for icon */
width: auto; /* Allow button to size to icon */
min-width: 40px; /* Ensure a minimum touch target size */
height: 40px; /* Ensure a minimum touch target size */
align-items: center;
justify-content: center;
border-radius: 50% !important; /* Make it circular */
opacity: 1 !important; /* Ensure it's always visible when its container is */
visibility: visible !important; /* Ensure it's not hidden by visibility property */
}
.btn-cancel-icon img {
width: 16px; /* Adjust icon size as needed */
height: 16px;
filter: brightness(0) invert(1); /* Make icon white */
}
.btn-cancel-icon:hover {
background-color: #e74c3c !important; /* Lighter red on hover */
transform: translateY(-2px) scale(1.05);
}
/* Watch Options Config Section */
.watch-options-config {
background: #181818;
padding: 1.5rem;
border-radius: 12px;
margin-bottom: 2rem;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
transition: transform 0.3s ease;
}
.watch-options-config:hover {
transform: translateY(-2px);
}
/* New Checklist Styles */
.checklist-container {
background: #2a2a2a;
border: 1px solid #404040;
border-radius: 8px;
padding: 0.8rem;
margin-top: 0.5rem;
}
.checklist-item {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
padding: 0.3rem 0;
}
.checklist-item:last-child {
margin-bottom: 0;
}
.checklist-item input[type="checkbox"] {
margin-right: 0.8rem;
width: 18px; /* Custom size */
height: 18px; /* Custom size */
cursor: pointer;
accent-color: #1db954; /* Modern way to color checkboxes */
}
.checklist-item label {
color: #ffffff; /* Ensure label text is white */
font-size: 0.95rem;
cursor: pointer;
/* Reset some global label styles if they interfere */
display: inline;
margin-bottom: 0;
}
/* Urgent Warning Message Style */
.urgent-warning-message {
background-color: rgba(255, 165, 0, 0.1); /* Orange/Amber background */
border: 1px solid #FFA500; /* Orange/Amber border */
color: #FFA500; /* Orange/Amber text */
padding: 1rem;
border-radius: 8px;
display: flex; /* Use flex to align icon and text */
align-items: center; /* Vertically align icon and text */
margin-top: 1rem;
margin-bottom: 1rem;
}
.urgent-warning-message .warning-icon {
margin-right: 0.75rem; /* Space between icon and text */
min-width: 24px; /* Ensure icon doesn't shrink too much */
color: #FFA500; /* Match icon color to text/border */
}
/* Existing info-message style - ensure it doesn't conflict or adjust if needed */
.info-message {
background-color: rgba(0, 123, 255, 0.1);
border: 1px solid #007bff;
color: #007bff;
padding: 1rem;
border-radius: 8px;
margin-top: 1rem;
margin-bottom: 1rem;
}
/* Version text styling */
.version-text {
font-size: 0.9rem;
color: #888; /* Light grey color */
margin-left: auto; /* Push it to the right */
padding-top: 0.5rem; /* Align with title better */
}

View File

@@ -0,0 +1,121 @@
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;
}
.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;
}
.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;
}
.status-COMPLETED { color: #1DB954; font-weight: bold; }
.status-ERROR { color: #FF4136; font-weight: bold; }
.status-CANCELLED { color: #AAAAAA; }
.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 Details icon button in the table */
.details-btn {
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;
}
.details-btn img {
width: 16px; /* Icon size */
height: 16px;
filter: invert(1); /* Make icon white if it's dark, adjust if needed */
}
.details-btn:hover {
background-color: #333; /* Darker on hover */
}

View File

@@ -27,6 +27,9 @@
--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;
@@ -155,44 +158,36 @@ a:hover, a:focus {
background-color: var(--color-surface-hover);
}
/* Floating icons (queue and settings) */
/* General styles for floating action buttons (FABs) */
.floating-icon {
position: fixed;
width: 56px;
height: 56px;
bottom: 20px;
background-color: var(--color-primary);
border-radius: var(--radius-round);
box-shadow: var(--shadow-lg);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, background-color 0.2s ease;
text-decoration: none !important;
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:hover,
.floating-icon:active {
background-color: var(--color-primary-hover);
transform: scale(1.05);
transform: scale(0.98);
}
.floating-icon img {
width: 24px;
height: 24px;
filter: brightness(0) invert(1);
margin: 0;
}
/* Settings icon - bottom left */
.settings-icon {
left: 20px;
}
/* Queue icon - bottom right */
.queue-icon {
right: 20px;
width: 24px;
height: 24px;
filter: invert(1); /* White icon */
margin: 0; /* Reset any margin if inherited */
}
/* Home button */
@@ -218,20 +213,17 @@ a:hover, a:focus {
transform: scale(0.98);
}
/* When home button is used as a floating button */
.floating-icon.home-btn {
background-color: var(--color-primary);
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.floating-icon.home-btn img {
width: 24px;
height: 24px;
filter: brightness(0) invert(1);
margin: 0;
/* 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 */
@@ -500,3 +492,39 @@ a:hover, a:focus {
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

@@ -162,3 +162,44 @@
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

@@ -185,6 +185,14 @@ body {
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;
}
@@ -381,6 +389,7 @@ a:focus {
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 */
@@ -459,3 +468,95 @@ a:focus {
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 */
}

359
static/css/watch/watch.css Normal file
View File

@@ -0,0 +1,359 @@
/* 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

@@ -46,10 +46,17 @@
</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 href="/watchlist" class="btn-icon watchlist-icon floating-icon" 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"

View File

@@ -28,6 +28,11 @@
<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>
@@ -46,10 +51,17 @@
</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 href="/watchlist" class="btn-icon watchlist-icon floating-icon" 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"

View File

@@ -7,6 +7,7 @@
<!-- 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>
@@ -14,6 +15,7 @@
<div class="config-container">
<header class="config-header">
<h1 class="header-title">Configuration</h1>
<span class="version-text">2.0.0</span>
</header>
<div class="account-config card">
@@ -32,6 +34,7 @@
<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>
@@ -44,6 +47,7 @@
<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>
@@ -223,13 +227,73 @@
</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="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>
<div class="credentials-list card"></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>
@@ -241,6 +305,9 @@
<div id="serviceFields"></div>
<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>
@@ -250,10 +317,17 @@
</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 href="/watchlist" class="btn-icon watchlist-icon floating-icon" 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"

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

83
static/html/history.html Normal file
View File

@@ -0,0 +1,83 @@
<!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>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>
</div>
<table>
<thead>
<tr>
<th data-sort="item_name">Name</th>
<th data-sort="item_artist">Artist</th>
<th data-sort="download_type">Type</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>Details</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

@@ -59,10 +59,17 @@
</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 href="/watchlist" class="btn-icon watchlist-icon floating-icon" 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"

View File

@@ -33,6 +33,14 @@
<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>
@@ -50,10 +58,17 @@
</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 href="/watchlist" class="btn-icon watchlist-icon floating-icon" 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"

View File

@@ -45,10 +45,17 @@
</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 href="/watchlist" class="btn-icon watchlist-icon floating-icon" 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"

71
static/html/watch.html Normal file
View File

@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Watched Items - Spotizerr</title>
<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/watch/watch.css') }}">
<script>
// Helper function to handle image loading errors
function handleImageError(img) {
img.src = '/static/images/placeholder.jpg'; // Ensure this placeholder exists
}
</script>
</head>
<body>
<div class="app-container">
<div class="watch-header">
<h1>Watched Artists & Playlists</h1>
<button id="checkAllWatchedBtn" class="btn btn-secondary check-all-btn">
<img src="{{ url_for('static', filename='images/refresh-cw.svg') }}" alt="Refresh"> Check All
</button>
</div>
<div id="watchedItemsContainer" class="results-grid">
<!-- Watched items will be dynamically inserted here -->
</div>
<div id="loadingWatchedItems" class="loading hidden">
<div class="loading-indicator">Loading watched items...</div>
</div>
<div id="emptyWatchedItems" class="empty-state hidden">
<div class="empty-state-content">
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Binoculars" class="empty-state-icon" />
<h2>Nothing to see here yet!</h2>
<p>Start watching artists or playlists, and they'll appear here.</p>
</div>
</div>
</div>
<!-- Fixed floating buttons for settings 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>
<a href="/" class="btn-icon home-btn floating-icon settings-icon" aria-label="Return to Home" title="Return to Home">
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home" onerror="handleImageError(this)"/>
</a>
<a href="/watchlist" class="btn-icon watchlist-icon floating-icon" 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" onerror="handleImageError(this)"/>
</button>
<script type="module" src="{{ url_for('static', filename='js/watch.js') }}"></script>
<!-- Include queue.js if queueIcon functionality is desired on this page too -->
<script type="module" src="{{ url_for('static', filename='js/queue.js') }}"></script>
</body>
</html>

View File

@@ -0,0 +1,12 @@
<?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 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>binoculars-filled</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="icon" fill="#000000" transform="translate(64.000000, 64.000000)">
<path d="M277.333333,-4.26325641e-14 C300.897483,-4.69612282e-14 320,19.1025173 320,42.6666667 L320.001038,66.6886402 C356.805359,76.1619142 384,109.571799 384,149.333333 L384,298.666667 C384,345.794965 345.794965,384 298.666667,384 C251.538368,384 213.333333,345.794965 213.333333,298.666667 L213.334343,290.517566 C207.67282,295.585196 200.196268,298.666667 192,298.666667 C183.803732,298.666667 176.32718,295.585196 170.665657,290.517566 L170.666667,298.666667 C170.666667,345.794965 132.461632,384 85.3333333,384 C38.2050347,384 -4.26325641e-14,345.794965 -4.26325641e-14,298.666667 L-4.26325641e-14,149.333333 C-4.75019917e-14,109.57144 27.1951335,76.1613096 63.9999609,66.6883831 L64,42.6666667 C64,19.1025173 83.1025173,-3.83039001e-14 106.666667,-4.26325641e-14 C130.230816,-4.69612282e-14 149.333333,19.1025173 149.333333,42.6666667 L149.333764,69.7082895 C158.827303,75.200153 166.008403,84.2448998 169.058923,95.0243894 C174.872894,89.046446 183.002825,85.3333333 192,85.3333333 C200.997175,85.3333333 209.127106,89.046446 214.940994,95.0238729 C217.991694,84.2447833 225.172752,75.2001286 234.666215,69.7083018 L234.666667,42.6666667 C234.666667,19.1025173 253.769184,-3.83039001e-14 277.333333,-4.26325641e-14 Z M85.3333333,256 C61.769184,256 42.6666667,275.102517 42.6666667,298.666667 C42.6666667,322.230816 61.769184,341.333333 85.3333333,341.333333 C108.897483,341.333333 128,322.230816 128,298.666667 C128,275.102517 108.897483,256 85.3333333,256 Z M298.666667,256 C275.102517,256 256,275.102517 256,298.666667 C256,322.230816 275.102517,341.333333 298.666667,341.333333 C322.230816,341.333333 341.333333,322.230816 341.333333,298.666667 C341.333333,275.102517 322.230816,256 298.666667,256 Z" id="Combined-Shape">
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

4
static/images/check.svg Normal file
View File

@@ -0,0 +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 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8C16 12.4183 12.4183 16 8 16ZM11.7069 6.70739C12.0975 6.31703 12.0978 5.68386 11.7074 5.29318C11.3171 4.9025 10.6839 4.90224 10.2932 5.29261L6.99765 8.58551L5.70767 7.29346C5.31746 6.90262 4.6843 6.90212 4.29346 7.29233C3.90262 7.68254 3.90212 8.3157 4.29233 8.70654L6.28912 10.7065C6.47655 10.8943 6.7309 10.9998 6.99619 11C7.26147 11.0002 7.51595 10.8949 7.70361 10.7074L11.7069 6.70739Z" fill="#212121"/>
</svg>

After

Width:  |  Height:  |  Size: 723 B

4
static/images/cross.svg Normal file
View File

@@ -0,0 +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="M19 5L4.99998 19M5.00001 5L19 19" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 358 B

View File

@@ -0,0 +1,5 @@
<?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 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 16H13L10.8368 13.3376C9.96488 13.7682 8.99592 14 8 14C6.09909 14 4.29638 13.1557 3.07945 11.6953L0 8L3.07945 4.30466C3.14989 4.22013 3.22229 4.13767 3.29656 4.05731L0 0H3L16 16ZM5.35254 6.58774C5.12755 7.00862 5 7.48941 5 8C5 9.65685 6.34315 11 8 11C8.29178 11 8.57383 10.9583 8.84053 10.8807L5.35254 6.58774Z" fill="#000000"/>
<path d="M16 8L14.2278 10.1266L7.63351 2.01048C7.75518 2.00351 7.87739 2 8 2C9.90091 2 11.7036 2.84434 12.9206 4.30466L16 8Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 755 B

4
static/images/eye.svg Normal file
View File

@@ -0,0 +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 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 8L3.07945 4.30466C4.29638 2.84434 6.09909 2 8 2C9.90091 2 11.7036 2.84434 12.9206 4.30466L16 8L12.9206 11.6953C11.7036 13.1557 9.90091 14 8 14C6.09909 14 4.29638 13.1557 3.07945 11.6953L0 8ZM8 11C9.65685 11 11 9.65685 11 8C11 6.34315 9.65685 5 8 5C6.34315 5 5 6.34315 5 8C5 9.65685 6.34315 11 8 11Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 599 B

View File

@@ -0,0 +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 fill-rule="evenodd" clip-rule="evenodd" d="M5.07868 5.06891C8.87402 1.27893 15.0437 1.31923 18.8622 5.13778C22.6824 8.95797 22.7211 15.1313 18.9262 18.9262C15.1312 22.7211 8.95793 22.6824 5.13774 18.8622C2.87389 16.5984 1.93904 13.5099 2.34047 10.5812C2.39672 10.1708 2.775 9.88377 3.18537 9.94002C3.59575 9.99627 3.88282 10.3745 3.82658 10.7849C3.4866 13.2652 4.27782 15.881 6.1984 17.8016C9.44288 21.0461 14.6664 21.0646 17.8655 17.8655C21.0646 14.6664 21.046 9.44292 17.8015 6.19844C14.5587 2.95561 9.33889 2.93539 6.13935 6.12957L6.88705 6.13333C7.30126 6.13541 7.63535 6.47288 7.63327 6.88709C7.63119 7.3013 7.29372 7.63539 6.87951 7.63331L4.33396 7.62052C3.92269 7.61845 3.58981 7.28556 3.58774 6.8743L3.57495 4.32874C3.57286 3.91454 3.90696 3.57707 4.32117 3.57498C4.73538 3.5729 5.07285 3.907 5.07493 4.32121L5.07868 5.06891ZM11.9999 7.24992C12.4141 7.24992 12.7499 7.58571 12.7499 7.99992V11.6893L15.0302 13.9696C15.3231 14.2625 15.3231 14.7374 15.0302 15.0302C14.7373 15.3231 14.2624 15.3231 13.9696 15.0302L11.2499 12.3106V7.99992C11.2499 7.58571 11.5857 7.24992 11.9999 7.24992Z" fill="#1C274C"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

7
static/images/info.svg Normal file
View File

@@ -0,0 +1,7 @@
<?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">
<g id="Warning / Info">
<path id="Vector" d="M12 11V16M12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21ZM12.0498 8V8.1L11.9502 8.1002V8H12.0498Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>

After

Width:  |  Height:  |  Size: 531 B

12
static/images/missing.svg Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="800px" height="800px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<title>ic_fluent_missing_metadata_24_filled</title>
<desc>Created with Sketch.</desc>
<g id="🔍-System-Icons" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="ic_fluent_missing_metadata_24_filled" fill="#212121" fill-rule="nonzero">
<path d="M17.5,12 C20.5376,12 23,14.4624 23,17.5 C23,20.5376 20.5376,23 17.5,23 C14.4624,23 12,20.5376 12,17.5 C12,14.4624 14.4624,12 17.5,12 Z M19.7501,2 C20.9927,2 22.0001,3.00736 22.0001,4.25 L22.0001,9.71196 C22.0001,10.50198 21.7124729,11.2623046 21.1951419,11.8530093 L21.0222,12.0361 C20.0073,11.3805 18.7981,11 17.5,11 C13.9101,11 11,13.9101 11,17.5 C11,18.7703 11.3644,19.9554 11.9943,20.9567 C10.7373,21.7569 9.05064,21.6098 7.95104,20.5143 L3.48934,16.0592 C2.21887,14.7913 2.21724,12.7334 3.48556,11.4632 L11.9852,2.95334 C12.5948,2.34297 13.4221,2 14.2847,2 L19.7501,2 Z M17.5,19.88 C17.1551,19.88 16.8755,20.1596 16.8755,20.5045 C16.8755,20.8494 17.1551,21.129 17.5,21.129 C17.8449,21.129 18.1245,20.8494 18.1245,20.5045 C18.1245,20.1596 17.8449,19.88 17.5,19.88 Z M17.5,14.0031 C16.4521,14.0031 15.6357,14.8205 15.6467,15.9574 C15.6493,16.2335 15.8753,16.4552 16.1514,16.4526 C16.4276,16.4499 16.6493,16.2239 16.6465901,15.9478 C16.6411,15.3688 17.0063,15.0031 17.5,15.0031 C17.9724,15.0031 18.3534,15.395 18.3534,15.9526 C18.3534,16.1448571 18.298151,16.2948694 18.1295283,16.5141003 L18.0355,16.63 L17.9365,16.7432 L17.6711,17.0333 C17.1868,17.5749 17,17.9255 17,18.5006 C17,18.7767 17.2239,19.0006 17.5,19.0006 C17.7762,19.0006 18,18.7767 18,18.5006 C18,18.297425 18.0585703,18.1416422 18.2388846,17.9103879 L18.3238,17.8063 L18.4247,17.6908 L18.6905,17.4003 C19.1682,16.866 19.3534,16.5186 19.3534,15.9526 C19.3534,14.8489 18.5311,14.0031 17.5,14.0031 Z M17,5.50218 C16.1716,5.50218 15.5001,6.17374 15.5001,7.00216 C15.5001,7.83057 16.1716,8.50213 17,8.50213 C17.8284,8.50213 18.5,7.83057 18.5,7.00216 C18.5,6.17374 17.8284,5.50218 17,5.50218 Z" id="🎨-Color">
</path>
</g>
</g>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +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 fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM12 7C12.5523 7 13 7.44772 13 8V11H16C16.5523 11 17 11.4477 17 12C17 12.5523 16.5523 13 16 13H13V16C13 16.5523 12.5523 17 12 17C11.4477 17 11 16.5523 11 16V13H8C7.44772 13 7 12.5523 7 12C7 11.4477 7.44772 11 8 11H11V8C11 7.44772 11.4477 7 12 7Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 653 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M17.8069373,7 C16.4464601,5.07869636 14.3936238,4 12,4 C7.581722,4 4,7.581722 4,12 L2,12 C2,6.4771525 6.4771525,2 12,2 C14.8042336,2 17.274893,3.18251178 19,5.27034886 L19,4 L21,4 L21,9 L16,9 L16,7 L17.8069373,7 Z M6.19306266,17 C7.55353989,18.9213036 9.60637619,20 12,20 C16.418278,20 20,16.418278 20,12 L22,12 C22,17.5228475 17.5228475,22 12,22 C9.19576641,22 6.72510698,20.8174882 5,18.7296511 L5,20 L3,20 L3,15 L8,15 L8,17 L6.19306266,17 Z M12.0003283,15.9983464 C11.4478622,15.9983464 11,15.5506311 11,14.9983464 C11,14.4460616 11.4478622,13.9983464 12.0003283,13.9983464 C12.5527943,13.9983464 13.0006565,14.4460616 13.0006565,14.9983464 C13.0006565,15.5506311 12.5527943,15.9983464 12.0003283,15.9983464 Z M11.0029544,6.99834639 L13.0036109,6.99834639 L13.0036109,12.9983464 L11.0029544,12.9983464 L11.0029544,6.99834639 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +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="M12 21C16.9706 21 21 16.9706 21 12C21 9.69494 20.1334 7.59227 18.7083 6L16 3M12 3C7.02944 3 3 7.02944 3 12C3 14.3051 3.86656 16.4077 5.29168 18L8 21M21 3H16M16 3V8M3 21H8M8 21V16" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 502 B

View File

@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12,2a8.945,8.945,0,0,0-9,8.889,8.826,8.826,0,0,0,3.375,6.933v1.956A2.236,2.236,0,0,0,8.625,22h6.75a2.236,2.236,0,0,0,2.25-2.222V17.822A8.826,8.826,0,0,0,21,10.889,8.945,8.945,0,0,0,12,2ZM11,20H9V18a1,1,0,0,1,2,0ZM9,15a2,2,0,1,1,2-2A2,2,0,0,1,9,15Zm6,5H13V18a1,1,0,0,1,2,0Zm0-5a2,2,0,1,1,2-2A2,2,0,0,1,15,15Z"/></svg>

After

Width:  |  Height:  |  Size: 550 B

View File

@@ -1,282 +0,0 @@
import { downloadQueue } from './queue.js';
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();
})
.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();
});
}
});
function renderAlbum(album) {
// Hide loading and error messages.
document.getElementById('loading').classList.add('hidden');
document.getElementById('error').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.
document.getElementById('album-name').innerHTML =
`<a href="${baseUrl}/album/${album.id || ''}">${album.name || 'Unknown Album'}</a>`;
document.getElementById('album-artist').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';
document.getElementById('album-stats').textContent =
`${releaseYear}${album.total_tracks || '0'} songs • ${album.label || 'Unknown Label'}`;
document.getElementById('album-copyright').textContent =
album.copyrights?.map(c => c?.text || '').filter(text => text).join(' • ') || '';
const image = album.images?.[0]?.url || '/static/images/placeholder.jpg';
document.getElementById('album-image').src = image;
// Create (if needed) the Home Button.
let homeButton = document.getElementById('homeButton');
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');
headerContainer.insertBefore(homeButton, headerContainer.firstChild);
}
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');
if (!downloadAlbumBtn) {
downloadAlbumBtn = document.createElement('button');
downloadAlbumBtn.id = 'downloadAlbumBtn';
downloadAlbumBtn.textContent = 'Download Full Album';
downloadAlbumBtn.className = 'download-btn download-btn--main';
document.getElementById('album-header').appendChild(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();
});
downloadAlbumBtn.disabled = true;
downloadAlbumBtn.textContent = 'Queueing...';
downloadWholeAlbum(album)
.then(() => {
downloadAlbumBtn.textContent = 'Queued!';
})
.catch(err => {
showError('Failed to queue album download: ' + (err?.message || 'Unknown error'));
downloadAlbumBtn.disabled = false;
});
});
}
// Render each track.
const tracksList = document.getElementById('tracks-list');
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-url="${track.external_urls?.spotify || ''}"
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.
document.getElementById('album-header').classList.remove('hidden');
document.getElementById('tracks-container').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';
document.getElementById('album-header').appendChild(actionsContainer);
}
// Append in the desired order: Home, Download, then Queue Toggle (if exists).
actionsContainer.innerHTML = ''; // Clear any previous content
actionsContainer.appendChild(document.getElementById('homeButton'));
actionsContainer.appendChild(document.getElementById('downloadAlbumBtn'));
const queueToggle = document.querySelector('.queue-toggle');
if (queueToggle) {
actionsContainer.appendChild(queueToggle);
}
}
}
async function downloadWholeAlbum(album) {
const url = album.external_urls?.spotify || '';
if (!url) {
throw new Error('Missing album URL');
}
try {
// Use the centralized downloadQueue.download method
await downloadQueue.download(url, 'album', { name: album.name || 'Unknown Album' });
// Make the queue visible after queueing
downloadQueue.toggleVisibility(true);
} catch (error) {
showError('Album download failed: ' + (error?.message || 'Unknown error'));
throw error;
}
}
function msToTime(duration) {
const minutes = Math.floor(duration / 60000);
const seconds = ((duration % 60000) / 1000).toFixed(0);
return `${minutes}:${seconds.padStart(2, '0')}`;
}
function showError(message) {
const errorEl = document.getElementById('error');
errorEl.textContent = message || 'An error occurred';
errorEl.classList.remove('hidden');
}
function attachDownloadListeners() {
document.querySelectorAll('.download-btn').forEach((btn) => {
if (btn.id === 'downloadAlbumBtn') return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
const url = e.currentTarget.dataset.url || '';
const type = e.currentTarget.dataset.type || '';
const name = e.currentTarget.dataset.name || extractName(url) || 'Unknown';
// Remove the button immediately after click.
e.currentTarget.remove();
startDownload(url, type, { name });
});
});
}
async function startDownload(url, type, item, albumType) {
if (!url) {
showError('Missing URL for download');
return;
}
try {
// Use the centralized downloadQueue.download method
await downloadQueue.download(url, type, item, albumType);
// Make the queue visible after queueing
downloadQueue.toggleVisibility(true);
} catch (error) {
showError('Download failed: ' + (error?.message || 'Unknown error'));
throw error;
}
}
function extractName(url) {
return url || 'Unknown';
}

View File

@@ -1,381 +0,0 @@
// Import the downloadQueue singleton
import { downloadQueue } from './queue.js';
document.addEventListener('DOMContentLoaded', () => {
const pathSegments = window.location.pathname.split('/');
const artistId = pathSegments[pathSegments.indexOf('artist') + 1];
if (!artistId) {
showError('No artist ID provided.');
return;
}
// 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();
})
.then(data => renderArtist(data, artistId))
.catch(error => {
console.error('Error:', error);
showError('Failed to load artist info.');
});
const queueIcon = document.getElementById('queueIcon');
if (queueIcon) {
queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility());
}
});
function renderArtist(artistData, artistId) {
document.getElementById('loading').classList.add('hidden');
document.getElementById('error').classList.add('hidden');
// Check if explicit filter is enabled
const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled();
const firstAlbum = artistData.items?.[0] || {};
const artistName = firstAlbum?.artists?.[0]?.name || 'Unknown Artist';
const artistImage = firstAlbum?.images?.[0]?.url || '/static/images/placeholder.jpg';
document.getElementById('artist-name').innerHTML =
`<a href="/artist/${artistId}" class="artist-link">${artistName}</a>`;
document.getElementById('artist-stats').textContent = `${artistData.total || '0'} albums`;
document.getElementById('artist-image').src = artistImage;
// Define the artist URL (used by both full-discography and group downloads)
const artistUrl = `https://open.spotify.com/artist/${artistId}`;
// Home Button
let homeButton = document.getElementById('homeButton');
if (!homeButton) {
homeButton = document.createElement('button');
homeButton.id = 'homeButton';
homeButton.className = 'home-btn';
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="Home">`;
document.getElementById('artist-header').prepend(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');
if (!downloadArtistBtn) {
downloadArtistBtn = document.createElement('button');
downloadArtistBtn.id = 'downloadArtistBtn';
downloadArtistBtn.className = 'download-btn download-btn--main';
downloadArtistBtn.textContent = 'Download All Discography';
document.getElementById('artist-header').appendChild(downloadArtistBtn);
}
// When explicit filter is enabled, disable all download buttons
if (isExplicitFilterEnabled) {
// Disable the artist download button and display a message explaining why
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 {
// Normal behavior when explicit filter is not enabled
downloadArtistBtn.addEventListener('click', () => {
// Optionally remove other download buttons from individual albums.
document.querySelectorAll('.download-btn:not(#downloadArtistBtn)').forEach(btn => btn.remove());
downloadArtistBtn.disabled = true;
downloadArtistBtn.textContent = 'Queueing...';
// Queue the entire discography (albums, singles, compilations, and appears_on)
// Use our local startDownload function instead of downloadQueue.startArtistDownload
startDownload(
artistUrl,
'artist',
{ name: artistName, artist: artistName },
'album,single,compilation,appears_on'
)
.then((taskIds) => {
downloadArtistBtn.textContent = 'Artist queued';
// Make the queue visible after queueing
downloadQueue.toggleVisibility(true);
// Optionally show number of albums queued
if (Array.isArray(taskIds)) {
downloadArtistBtn.title = `${taskIds.length} albums queued for download`;
}
})
.catch(err => {
downloadArtistBtn.textContent = 'Download All Discography';
downloadArtistBtn.disabled = false;
showError('Failed to queue artist download: ' + (err?.message || 'Unknown error'));
});
});
}
// Group albums by type (album, single, compilation, etc.) and separate "appears_on" albums
const albumGroups = {};
const appearingAlbums = [];
(artistData.items || []).forEach(album => {
if (!album) return;
// Skip explicit albums if filter is enabled
if (isExplicitFilterEnabled && album.explicit) {
return;
}
// Check if this is an "appears_on" album
if (album.album_group === 'appears_on') {
appearingAlbums.push(album);
} else {
// Group by album_type for the artist's own releases
const type = (album.album_type || 'unknown').toLowerCase();
if (!albumGroups[type]) albumGroups[type] = [];
albumGroups[type].push(album);
}
});
// Render album groups
const groupsContainer = document.getElementById('album-groups');
groupsContainer.innerHTML = '';
// Render regular album groups first
for (const [groupType, albums] of Object.entries(albumGroups)) {
const groupSection = document.createElement('section');
groupSection.className = 'album-group';
// If explicit filter is enabled, don't show the group download button
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}
<div class="albums-list"></div>
`;
const albumsContainer = groupSection.querySelector('.albums-list');
albums.forEach(album => {
if (!album) return;
const albumElement = document.createElement('div');
albumElement.className = 'album-card';
// Create album card with or without download button based on explicit filter setting
if (isExplicitFilterEnabled) {
albumElement.innerHTML = `
<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">
</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>
`;
} else {
albumElement.innerHTML = `
<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">
</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>
<button class="download-btn download-btn--circle"
data-url="${album.external_urls?.spotify || ''}"
data-type="${album.album_type || 'album'}"
data-name="${album.name || 'Unknown Album'}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
`;
}
albumsContainer.appendChild(albumElement);
});
groupsContainer.appendChild(groupSection);
}
// Render "Featuring" section if there are any appearing albums
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}
<div class="albums-list"></div>
`;
const albumsContainer = featuringSection.querySelector('.albums-list');
appearingAlbums.forEach(album => {
if (!album) return;
const albumElement = document.createElement('div');
albumElement.className = 'album-card';
// Create album card with or without download button based on explicit filter setting
if (isExplicitFilterEnabled) {
albumElement.innerHTML = `
<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">
</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>
`;
} else {
albumElement.innerHTML = `
<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">
</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>
<button class="download-btn download-btn--circle"
data-url="${album.external_urls?.spotify || ''}"
data-type="${album.album_type || 'album'}"
data-name="${album.name || 'Unknown Album'}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
`;
}
albumsContainer.appendChild(albumElement);
});
// Add to the end so it appears at the bottom
groupsContainer.appendChild(featuringSection);
}
document.getElementById('artist-header').classList.remove('hidden');
document.getElementById('albums-container').classList.remove('hidden');
// Only attach download listeners if explicit filter is not enabled
if (!isExplicitFilterEnabled) {
attachDownloadListeners();
// Pass the artist URL and name so the group buttons can use the artist download function
attachGroupDownloadListeners(artistUrl, artistName);
}
}
// Event listeners for group downloads using the artist download function
function attachGroupDownloadListeners(artistUrl, artistName) {
document.querySelectorAll('.group-download-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const groupType = e.target.dataset.groupType || 'album'; // e.g. "album", "single", "compilation", "appears_on"
e.target.disabled = true;
// Custom text for the 'appears_on' group
const displayType = groupType === 'appears_on' ? 'Featuring Albums' : `${capitalize(groupType)}s`;
e.target.textContent = `Queueing all ${displayType}...`;
try {
// Use our local startDownload function with the group type filter
const taskIds = await startDownload(
artistUrl,
'artist',
{ name: artistName || 'Unknown Artist', artist: artistName || 'Unknown Artist' },
groupType // Only queue releases of this specific type.
);
// Optionally show number of albums queued
const totalQueued = Array.isArray(taskIds) ? taskIds.length : 0;
e.target.textContent = `Queued all ${displayType}`;
e.target.title = `${totalQueued} albums queued for download`;
// Make the queue visible after queueing
downloadQueue.toggleVisibility(true);
} catch (error) {
e.target.textContent = `Download All ${displayType}`;
e.target.disabled = false;
showError(`Failed to queue download for all ${groupType}: ${error?.message || 'Unknown error'}`);
}
});
});
}
// Individual download handlers remain unchanged.
function attachDownloadListeners() {
document.querySelectorAll('.download-btn:not(.group-download-btn)').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const url = e.currentTarget.dataset.url || '';
const name = e.currentTarget.dataset.name || 'Unknown';
// Always use 'album' type for individual album downloads regardless of category
const type = 'album';
e.currentTarget.remove();
// Use the centralized downloadQueue.download method
downloadQueue.download(url, type, { name, type })
.catch(err => showError('Download failed: ' + (err?.message || 'Unknown error')));
});
});
}
// Add startDownload function (similar to track.js and main.js)
/**
* Starts the download process via centralized download queue
*/
async function startDownload(url, type, item, albumType) {
if (!url || !type) {
showError('Missing URL or type for download');
return;
}
try {
// Use the centralized downloadQueue.download method for all downloads including artist downloads
const result = await downloadQueue.download(url, type, item, albumType);
// Make the queue visible after queueing
downloadQueue.toggleVisibility(true);
// Return the result for tracking
return result;
} catch (error) {
showError('Download failed: ' + (error?.message || 'Unknown error'));
throw error;
}
}
// UI Helpers
function showError(message) {
const errorEl = document.getElementById('error');
if (errorEl) {
errorEl.textContent = message || 'An error occurred';
errorEl.classList.remove('hidden');
}
}
function capitalize(str) {
return str ? str.charAt(0).toUpperCase() + str.slice(1) : '';
}

View File

@@ -1,763 +0,0 @@
import { downloadQueue } from './queue.js';
const serviceConfig = {
spotify: {
fields: [
{ id: 'username', label: 'Username', type: 'text' },
{ id: 'credentials', label: 'Credentials', type: 'text' }
],
validator: (data) => ({
username: data.username,
credentials: data.credentials
}),
// Adding search credentials fields
searchFields: [
{ id: 'client_id', label: 'Client ID', type: 'text' },
{ id: 'client_secret', label: 'Client Secret', type: 'password' }
],
searchValidator: (data) => ({
client_id: data.client_id,
client_secret: data.client_secret
})
},
deezer: {
fields: [
{ id: 'arl', label: 'ARL', type: 'text' }
],
validator: (data) => ({
arl: data.arl
})
}
};
let currentService = 'spotify';
let currentCredential = null;
let isEditingSearch = false;
// Global variables to hold the active accounts from the config response.
let activeSpotifyAccount = '';
let activeDeezerAccount = '';
document.addEventListener('DOMContentLoaded', async () => {
try {
await initConfig();
setupServiceTabs();
setupEventListeners();
const queueIcon = document.getElementById('queueIcon');
if (queueIcon) {
queueIcon.addEventListener('click', () => {
downloadQueue.toggleVisibility();
});
}
} catch (error) {
showConfigError(error.message);
}
});
async function initConfig() {
await loadConfig();
await updateAccountSelectors();
loadCredentials(currentService);
updateFormFields();
}
function setupServiceTabs() {
const serviceTabs = document.querySelectorAll('.tab-button');
serviceTabs.forEach(tab => {
tab.addEventListener('click', () => {
serviceTabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentService = tab.dataset.service;
loadCredentials(currentService);
updateFormFields();
});
});
}
function setupEventListeners() {
document.getElementById('credentialForm').addEventListener('submit', handleCredentialSubmit);
// Config change listeners
document.getElementById('defaultServiceSelect').addEventListener('change', function() {
updateServiceSpecificOptions();
saveConfig();
});
document.getElementById('fallbackToggle').addEventListener('change', saveConfig);
document.getElementById('realTimeToggle').addEventListener('change', saveConfig);
document.getElementById('spotifyQualitySelect').addEventListener('change', saveConfig);
document.getElementById('deezerQualitySelect').addEventListener('change', saveConfig);
document.getElementById('tracknumPaddingToggle').addEventListener('change', saveConfig);
document.getElementById('maxRetries').addEventListener('change', saveConfig);
document.getElementById('retryDelaySeconds').addEventListener('change', saveConfig);
// Update active account globals when the account selector is changed.
document.getElementById('spotifyAccountSelect').addEventListener('change', (e) => {
activeSpotifyAccount = e.target.value;
saveConfig();
});
document.getElementById('deezerAccountSelect').addEventListener('change', (e) => {
activeDeezerAccount = e.target.value;
saveConfig();
});
// Formatting settings
document.getElementById('customDirFormat').addEventListener('change', saveConfig);
document.getElementById('customTrackFormat').addEventListener('change', saveConfig);
// Copy to clipboard when selecting placeholders
document.getElementById('dirFormatHelp').addEventListener('change', function() {
copyPlaceholderToClipboard(this);
});
document.getElementById('trackFormatHelp').addEventListener('change', function() {
copyPlaceholderToClipboard(this);
});
// Max concurrent downloads change listener
document.getElementById('maxConcurrentDownloads').addEventListener('change', saveConfig);
}
function updateServiceSpecificOptions() {
// Get the selected service
const selectedService = document.getElementById('defaultServiceSelect').value;
// Get all service-specific sections
const spotifyOptions = document.querySelectorAll('.config-item.spotify-specific');
const deezerOptions = document.querySelectorAll('.config-item.deezer-specific');
// Handle Spotify specific options
if (selectedService === 'spotify') {
// Highlight Spotify section
document.getElementById('spotifyQualitySelect').closest('.config-item').classList.add('highlighted-option');
document.getElementById('spotifyAccountSelect').closest('.config-item').classList.add('highlighted-option');
// Remove highlight from Deezer
document.getElementById('deezerQualitySelect').closest('.config-item').classList.remove('highlighted-option');
document.getElementById('deezerAccountSelect').closest('.config-item').classList.remove('highlighted-option');
}
// Handle Deezer specific options (for future use)
else if (selectedService === 'deezer') {
// Highlight Deezer section
document.getElementById('deezerQualitySelect').closest('.config-item').classList.add('highlighted-option');
document.getElementById('deezerAccountSelect').closest('.config-item').classList.add('highlighted-option');
// Remove highlight from Spotify
document.getElementById('spotifyQualitySelect').closest('.config-item').classList.remove('highlighted-option');
document.getElementById('spotifyAccountSelect').closest('.config-item').classList.remove('highlighted-option');
}
}
async function updateAccountSelectors() {
try {
const [spotifyResponse, deezerResponse] = await Promise.all([
fetch('/api/credentials/spotify'),
fetch('/api/credentials/deezer')
]);
const spotifyAccounts = await spotifyResponse.json();
const deezerAccounts = await deezerResponse.json();
// Get the select elements
const spotifySelect = document.getElementById('spotifyAccountSelect');
const deezerSelect = document.getElementById('deezerAccountSelect');
// Rebuild the Spotify selector options
spotifySelect.innerHTML = spotifyAccounts
.map(a => `<option value="${a}">${a}</option>`)
.join('');
// Use the active account loaded from the config (activeSpotifyAccount)
if (spotifyAccounts.includes(activeSpotifyAccount)) {
spotifySelect.value = activeSpotifyAccount;
} else if (spotifyAccounts.length > 0) {
spotifySelect.value = spotifyAccounts[0];
activeSpotifyAccount = spotifyAccounts[0];
await saveConfig();
}
// Rebuild the Deezer selector options
deezerSelect.innerHTML = deezerAccounts
.map(a => `<option value="${a}">${a}</option>`)
.join('');
if (deezerAccounts.includes(activeDeezerAccount)) {
deezerSelect.value = activeDeezerAccount;
} else if (deezerAccounts.length > 0) {
deezerSelect.value = deezerAccounts[0];
activeDeezerAccount = deezerAccounts[0];
await saveConfig();
}
// Handle empty account lists
[spotifySelect, deezerSelect].forEach((select, index) => {
const accounts = index === 0 ? spotifyAccounts : deezerAccounts;
if (accounts.length === 0) {
select.innerHTML = '<option value="">No accounts available</option>';
select.value = '';
}
});
} catch (error) {
showConfigError('Error updating accounts: ' + error.message);
}
}
async function loadCredentials(service) {
try {
const response = await fetch(`/api/credentials/all/${service}`);
if (!response.ok) {
throw new Error(`Failed to load credentials: ${response.statusText}`);
}
const credentials = await response.json();
renderCredentialsList(service, credentials);
} catch (error) {
showConfigError(error.message);
}
}
function renderCredentialsList(service, credentials) {
const list = document.querySelector('.credentials-list');
list.innerHTML = '';
if (!credentials.length) {
list.innerHTML = '<div class="no-credentials">No accounts found. Add a new account below.</div>';
return;
}
credentials.forEach(credData => {
const credItem = document.createElement('div');
credItem.className = 'credential-item';
const hasSearchCreds = credData.search && Object.keys(credData.search).length > 0;
credItem.innerHTML = `
<div class="credential-info">
<span class="credential-name">${credData.name}</span>
${service === 'spotify' ?
`<div class="search-credentials-status ${hasSearchCreds ? 'has-api' : 'no-api'}">
${hasSearchCreds ? 'API Configured' : 'No API Credentials'}
</div>` : ''}
</div>
<div class="credential-actions">
<button class="edit-btn" data-name="${credData.name}" data-service="${service}">Edit Account</button>
${service === 'spotify' ?
`<button class="edit-search-btn" data-name="${credData.name}" data-service="${service}">
${hasSearchCreds ? 'Edit API' : 'Add API'}
</button>` : ''}
<button class="delete-btn" data-name="${credData.name}" data-service="${service}">Delete</button>
</div>
`;
list.appendChild(credItem);
});
// Set up event handlers
list.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', handleDeleteCredential);
});
list.querySelectorAll('.edit-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
isEditingSearch = false;
handleEditCredential(e);
});
});
if (service === 'spotify') {
list.querySelectorAll('.edit-search-btn').forEach(btn => {
btn.addEventListener('click', handleEditSearchCredential);
});
}
}
async function handleDeleteCredential(e) {
try {
const service = e.target.dataset.service;
const name = e.target.dataset.name;
if (!service || !name) {
throw new Error('Missing credential information');
}
if (!confirm(`Are you sure you want to delete the ${name} account?`)) {
return;
}
const response = await fetch(`/api/credentials/${service}/${name}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete credential');
}
// If the deleted credential is the active account, clear the selection.
const accountSelect = document.getElementById(`${service}AccountSelect`);
if (accountSelect.value === name) {
accountSelect.value = '';
if (service === 'spotify') {
activeSpotifyAccount = '';
} else if (service === 'deezer') {
activeDeezerAccount = '';
}
await saveConfig();
}
loadCredentials(service);
await updateAccountSelectors();
} catch (error) {
showConfigError(error.message);
}
}
async function handleEditCredential(e) {
const service = e.target.dataset.service;
const name = e.target.dataset.name;
try {
document.querySelector(`[data-service="${service}"]`).click();
await new Promise(resolve => setTimeout(resolve, 50));
const response = await fetch(`/api/credentials/${service}/${name}`);
if (!response.ok) {
throw new Error(`Failed to load credential: ${response.statusText}`);
}
const data = await response.json();
currentCredential = name;
document.getElementById('credentialName').value = name;
document.getElementById('credentialName').disabled = true;
document.getElementById('formTitle').textContent = `Edit ${service.charAt(0).toUpperCase() + service.slice(1)} Account`;
document.getElementById('submitCredentialBtn').textContent = 'Update Account';
// Show regular fields
populateFormFields(service, data);
toggleSearchFieldsVisibility(false);
} catch (error) {
showConfigError(error.message);
}
}
async function handleEditSearchCredential(e) {
const service = e.target.dataset.service;
const name = e.target.dataset.name;
try {
if (service !== 'spotify') {
throw new Error('Search credentials are only available for Spotify');
}
document.querySelector(`[data-service="${service}"]`).click();
await new Promise(resolve => setTimeout(resolve, 50));
isEditingSearch = true;
currentCredential = name;
document.getElementById('credentialName').value = name;
document.getElementById('credentialName').disabled = true;
document.getElementById('formTitle').textContent = `Spotify API Credentials for ${name}`;
document.getElementById('submitCredentialBtn').textContent = 'Save API Credentials';
// Try to load existing search credentials
try {
const searchResponse = await fetch(`/api/credentials/${service}/${name}?type=search`);
if (searchResponse.ok) {
const searchData = await searchResponse.json();
// Populate search fields
serviceConfig[service].searchFields.forEach(field => {
const element = document.getElementById(field.id);
if (element) element.value = searchData[field.id] || '';
});
} else {
// Clear search fields if no existing search credentials
serviceConfig[service].searchFields.forEach(field => {
const element = document.getElementById(field.id);
if (element) element.value = '';
});
}
} catch (error) {
// Clear search fields if there was an error
serviceConfig[service].searchFields.forEach(field => {
const element = document.getElementById(field.id);
if (element) element.value = '';
});
}
// Hide regular account fields, show search fields
toggleSearchFieldsVisibility(true);
} catch (error) {
showConfigError(error.message);
}
}
function toggleSearchFieldsVisibility(showSearchFields) {
const serviceFieldsDiv = document.getElementById('serviceFields');
const searchFieldsDiv = document.getElementById('searchFields');
if (showSearchFields) {
// Hide regular fields and remove 'required' attribute
serviceFieldsDiv.style.display = 'none';
// Remove required attribute from service fields
serviceConfig[currentService].fields.forEach(field => {
const input = document.getElementById(field.id);
if (input) input.removeAttribute('required');
});
// Show search fields and add 'required' attribute
searchFieldsDiv.style.display = 'block';
// Make search fields required
if (currentService === 'spotify' && serviceConfig[currentService].searchFields) {
serviceConfig[currentService].searchFields.forEach(field => {
const input = document.getElementById(field.id);
if (input) input.setAttribute('required', '');
});
}
} else {
// Show regular fields and add 'required' attribute
serviceFieldsDiv.style.display = 'block';
// Make service fields required
serviceConfig[currentService].fields.forEach(field => {
const input = document.getElementById(field.id);
if (input) input.setAttribute('required', '');
});
// Hide search fields and remove 'required' attribute
searchFieldsDiv.style.display = 'none';
// Remove required from search fields
if (currentService === 'spotify' && serviceConfig[currentService].searchFields) {
serviceConfig[currentService].searchFields.forEach(field => {
const input = document.getElementById(field.id);
if (input) input.removeAttribute('required');
});
}
}
}
function updateFormFields() {
const serviceFieldsDiv = document.getElementById('serviceFields');
const searchFieldsDiv = document.getElementById('searchFields');
// Clear any existing fields
serviceFieldsDiv.innerHTML = '';
searchFieldsDiv.innerHTML = '';
// Add regular account fields
serviceConfig[currentService].fields.forEach(field => {
const fieldDiv = document.createElement('div');
fieldDiv.className = 'form-group';
fieldDiv.innerHTML = `
<label>${field.label}:</label>
<input type="${field.type}"
id="${field.id}"
name="${field.id}"
required
${field.type === 'password' ? 'autocomplete="new-password"' : ''}>
`;
serviceFieldsDiv.appendChild(fieldDiv);
});
// Add search fields for Spotify
if (currentService === 'spotify' && serviceConfig[currentService].searchFields) {
serviceConfig[currentService].searchFields.forEach(field => {
const fieldDiv = document.createElement('div');
fieldDiv.className = 'form-group';
fieldDiv.innerHTML = `
<label>${field.label}:</label>
<input type="${field.type}"
id="${field.id}"
name="${field.id}"
required
${field.type === 'password' ? 'autocomplete="new-password"' : ''}>
`;
searchFieldsDiv.appendChild(fieldDiv);
});
}
// Reset form title and button text
document.getElementById('formTitle').textContent = `Add New ${currentService.charAt(0).toUpperCase() + currentService.slice(1)} Account`;
document.getElementById('submitCredentialBtn').textContent = 'Save Account';
// Initially show regular fields, hide search fields
toggleSearchFieldsVisibility(false);
isEditingSearch = false;
}
function populateFormFields(service, data) {
serviceConfig[service].fields.forEach(field => {
const element = document.getElementById(field.id);
if (element) element.value = data[field.id] || '';
});
}
async function handleCredentialSubmit(e) {
e.preventDefault();
const service = document.querySelector('.tab-button.active').dataset.service;
const nameInput = document.getElementById('credentialName');
const name = nameInput.value.trim();
try {
if (!currentCredential && !name) {
throw new Error('Credential name is required');
}
const endpointName = currentCredential || name;
let method, data, endpoint;
if (isEditingSearch && service === 'spotify') {
// Handle search credentials
const formData = {};
let isValid = true;
let firstInvalidField = null;
// Manually validate search fields
serviceConfig[service].searchFields.forEach(field => {
const input = document.getElementById(field.id);
const value = input ? input.value.trim() : '';
formData[field.id] = value;
if (!value) {
isValid = false;
if (!firstInvalidField) firstInvalidField = input;
}
});
if (!isValid) {
if (firstInvalidField) firstInvalidField.focus();
throw new Error('All fields are required');
}
data = serviceConfig[service].searchValidator(formData);
endpoint = `/api/credentials/${service}/${endpointName}?type=search`;
// Check if search credentials already exist for this account
const checkResponse = await fetch(endpoint);
method = checkResponse.ok ? 'PUT' : 'POST';
} else {
// Handle regular account credentials
const formData = {};
let isValid = true;
let firstInvalidField = null;
// Manually validate account fields
serviceConfig[service].fields.forEach(field => {
const input = document.getElementById(field.id);
const value = input ? input.value.trim() : '';
formData[field.id] = value;
if (!value) {
isValid = false;
if (!firstInvalidField) firstInvalidField = input;
}
});
if (!isValid) {
if (firstInvalidField) firstInvalidField.focus();
throw new Error('All fields are required');
}
data = serviceConfig[service].validator(formData);
endpoint = `/api/credentials/${service}/${endpointName}`;
method = currentCredential ? 'PUT' : 'POST';
}
const response = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to save credentials');
}
await updateAccountSelectors();
await saveConfig();
loadCredentials(service);
resetForm();
// Show success message
showConfigSuccess(isEditingSearch ? 'API credentials saved successfully' : 'Account saved successfully');
} catch (error) {
showConfigError(error.message);
}
}
function resetForm() {
currentCredential = null;
isEditingSearch = false;
const nameInput = document.getElementById('credentialName');
nameInput.value = '';
nameInput.disabled = false;
document.getElementById('credentialForm').reset();
// Reset form title and button text
const service = currentService.charAt(0).toUpperCase() + currentService.slice(1);
document.getElementById('formTitle').textContent = `Add New ${service} Account`;
document.getElementById('submitCredentialBtn').textContent = 'Save Account';
// Show regular account fields, hide search fields
toggleSearchFieldsVisibility(false);
}
async function saveConfig() {
// Read active account values directly from the DOM (or from the globals which are kept in sync)
const config = {
service: document.getElementById('defaultServiceSelect').value,
spotify: document.getElementById('spotifyAccountSelect').value,
deezer: document.getElementById('deezerAccountSelect').value,
fallback: document.getElementById('fallbackToggle').checked,
spotifyQuality: document.getElementById('spotifyQualitySelect').value,
deezerQuality: document.getElementById('deezerQualitySelect').value,
realTime: document.getElementById('realTimeToggle').checked,
customDirFormat: document.getElementById('customDirFormat').value,
customTrackFormat: document.getElementById('customTrackFormat').value,
maxConcurrentDownloads: parseInt(document.getElementById('maxConcurrentDownloads').value, 10) || 3,
maxRetries: parseInt(document.getElementById('maxRetries').value, 10) || 3,
retryDelaySeconds: parseInt(document.getElementById('retryDelaySeconds').value, 10) || 5,
retry_delay_increase: parseInt(document.getElementById('retryDelayIncrease').value, 10) || 5,
tracknum_padding: document.getElementById('tracknumPaddingToggle').checked
};
try {
const response = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to save config');
}
} catch (error) {
showConfigError(error.message);
}
}
async function loadConfig() {
try {
const response = await fetch('/api/config');
if (!response.ok) throw new Error('Failed to load config');
const savedConfig = await response.json();
// Set default service selection
document.getElementById('defaultServiceSelect').value = savedConfig.service || 'spotify';
// Update the service-specific options based on selected service
updateServiceSpecificOptions();
// Use the "spotify" and "deezer" properties from the API response to set the active accounts.
activeSpotifyAccount = savedConfig.spotify || '';
activeDeezerAccount = savedConfig.deezer || '';
// (Optionally, if the account selects already exist you can set their values here,
// but updateAccountSelectors() will rebuild the options and set the proper values.)
const spotifySelect = document.getElementById('spotifyAccountSelect');
const deezerSelect = document.getElementById('deezerAccountSelect');
if (spotifySelect) spotifySelect.value = activeSpotifyAccount;
if (deezerSelect) deezerSelect.value = activeDeezerAccount;
// Update other configuration fields.
document.getElementById('fallbackToggle').checked = !!savedConfig.fallback;
document.getElementById('spotifyQualitySelect').value = savedConfig.spotifyQuality || 'NORMAL';
document.getElementById('deezerQualitySelect').value = savedConfig.deezerQuality || 'MP3_128';
document.getElementById('realTimeToggle').checked = !!savedConfig.realTime;
document.getElementById('customDirFormat').value = savedConfig.customDirFormat || '%ar_album%/%album%';
document.getElementById('customTrackFormat').value = savedConfig.customTrackFormat || '%tracknum%. %music%';
document.getElementById('maxConcurrentDownloads').value = savedConfig.maxConcurrentDownloads || '3';
document.getElementById('maxRetries').value = savedConfig.maxRetries || '3';
document.getElementById('retryDelaySeconds').value = savedConfig.retryDelaySeconds || '5';
document.getElementById('retryDelayIncrease').value = savedConfig.retry_delay_increase || '5';
document.getElementById('tracknumPaddingToggle').checked = savedConfig.tracknum_padding === undefined ? true : !!savedConfig.tracknum_padding;
// Update explicit filter status
updateExplicitFilterStatus(savedConfig.explicitFilter);
} catch (error) {
showConfigError('Error loading config: ' + error.message);
}
}
function updateExplicitFilterStatus(isEnabled) {
const statusElement = document.getElementById('explicitFilterStatus');
if (statusElement) {
// Remove existing classes
statusElement.classList.remove('enabled', 'disabled');
// Add appropriate class and text based on whether filter is enabled
if (isEnabled) {
statusElement.textContent = 'Enabled';
statusElement.classList.add('enabled');
} else {
statusElement.textContent = 'Disabled';
statusElement.classList.add('disabled');
}
}
}
function showConfigError(message) {
const errorDiv = document.getElementById('configError');
errorDiv.textContent = message;
setTimeout(() => (errorDiv.textContent = ''), 5000);
}
function showConfigSuccess(message) {
const successDiv = document.getElementById('configSuccess');
successDiv.textContent = message;
setTimeout(() => (successDiv.textContent = ''), 5000);
}
// Function to copy the selected placeholder to clipboard
function copyPlaceholderToClipboard(select) {
const placeholder = select.value;
if (!placeholder) return; // If nothing selected
// Copy to clipboard
navigator.clipboard.writeText(placeholder)
.then(() => {
// Show success notification
showCopyNotification(`Copied ${placeholder} to clipboard`);
// Reset select to default after a short delay
setTimeout(() => {
select.selectedIndex = 0;
}, 500);
})
.catch(err => {
console.error('Failed to copy: ', err);
});
}
// Function to show a notification when copying
function showCopyNotification(message) {
// Check if notification container exists, create if not
let notificationContainer = document.getElementById('copyNotificationContainer');
if (!notificationContainer) {
notificationContainer = document.createElement('div');
notificationContainer.id = 'copyNotificationContainer';
document.body.appendChild(notificationContainer);
}
// Create notification element
const notification = document.createElement('div');
notification.className = 'copy-notification';
notification.textContent = message;
// Add to container
notificationContainer.appendChild(notification);
// Trigger animation
setTimeout(() => {
notification.classList.add('show');
}, 10);
// Remove after animation completes
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => {
notificationContainer.removeChild(notification);
}, 300);
}, 2000);
}

View File

@@ -1,468 +0,0 @@
// main.js
import { downloadQueue } from './queue.js';
document.addEventListener('DOMContentLoaded', function() {
// DOM elements
const searchInput = document.getElementById('searchInput');
const searchButton = document.getElementById('searchButton');
const searchType = document.getElementById('searchType');
const resultsContainer = document.getElementById('resultsContainer');
const queueIcon = document.getElementById('queueIcon');
const emptyState = document.getElementById('emptyState');
const loadingResults = document.getElementById('loadingResults');
// 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) {
if (e.key === 'Enter') {
performSearch();
}
});
// Auto-detect and handle pasted Spotify URLs
searchInput.addEventListener('input', function(e) {
const inputVal = e.target.value.trim();
if (isSpotifyUrl(inputVal)) {
const details = getSpotifyResourceDetails(inputVal);
if (details) {
searchType.value = details.type;
}
}
});
}
// Restore last search type if no URL override
const savedType = localStorage.getItem('lastSearchType');
if (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);
});
}
// Check for URL parameters
const urlParams = new URLSearchParams(window.location.search);
const query = urlParams.get('q');
const type = urlParams.get('type');
if (query) {
searchInput.value = query;
if (type && ['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 query = searchInput.value.trim();
if (!query) return;
// Handle direct Spotify URLs
if (isSpotifyUrl(query)) {
const details = getSpotifyResourceDetails(query);
if (details && details.id) {
// Redirect to the appropriate page
window.location.href = `/${details.type}/${details.id}`;
return;
}
}
// Update URL without reloading page
const newUrl = `${window.location.pathname}?q=${encodeURIComponent(query)}&type=${searchType.value}`;
window.history.pushState({ path: newUrl }, '', newUrl);
// Show loading state
showEmptyState(false);
showLoading(true);
resultsContainer.innerHTML = '';
try {
const url = `/api/search?q=${encodeURIComponent(query)}&search_type=${searchType.value}&limit=40`;
const response = await fetch(url);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
// Hide loading indicator
showLoading(false);
// Render results
if (data && data.items && data.items.length > 0) {
resultsContainer.innerHTML = '';
// Filter out items with null/undefined essential display parameters
const validItems = filterValidItems(data.items, searchType.value);
if (validItems.length === 0) {
// No valid items found after filtering
resultsContainer.innerHTML = `
<div class="empty-search-results">
<p>No valid results found for "${query}"</p>
</div>
`;
return;
}
validItems.forEach((item, index) => {
const cardElement = createResultCard(item, searchType.value, index);
// Store the item data directly on the button element
const downloadBtn = cardElement.querySelector('.download-btn');
if (downloadBtn) {
downloadBtn.dataset.itemIndex = index;
}
resultsContainer.appendChild(cardElement);
});
// Attach download handlers to the newly created cards
attachDownloadListeners(validItems);
} else {
// No results found
resultsContainer.innerHTML = `
<div class="empty-search-results">
<p>No results found for "${query}"</p>
</div>
`;
}
} catch (error) {
console.error('Error:', error);
showLoading(false);
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, type) {
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() && item.explicit === true) {
return false;
}
// Check essential parameters based on search type
switch (type) {
case 'track':
// For tracks, we need name, artists, and album
return (
item.name &&
item.artists &&
item.artists.length > 0 &&
item.artists[0] &&
item.artists[0].name &&
item.album &&
item.album.name &&
item.external_urls &&
item.external_urls.spotify
);
case 'album':
// For albums, we need name, artists, and cover image
return (
item.name &&
item.artists &&
item.artists.length > 0 &&
item.artists[0] &&
item.artists[0].name &&
item.external_urls &&
item.external_urls.spotify
);
case 'playlist':
// For playlists, we need name, owner, and tracks
return (
item.name &&
item.owner &&
item.owner.display_name &&
item.tracks &&
item.external_urls &&
item.external_urls.spotify
);
case 'artist':
// For artists, we need name
return (
item.name &&
item.external_urls &&
item.external_urls.spotify
);
default:
// Default case - just check if the item exists
return true;
}
});
}
/**
* Attaches download handlers to result cards
*/
function attachDownloadListeners(items) {
document.querySelectorAll('.download-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
// Get the item index from the button's dataset
const itemIndex = parseInt(btn.dataset.itemIndex, 10);
// Get the corresponding item
const item = items[itemIndex];
if (!item) return;
const type = searchType.value;
let url;
// Determine the URL based on item type
if (item.external_urls && item.external_urls.spotify) {
url = item.external_urls.spotify;
} else if (item.href) {
url = item.href;
} else {
showError('Could not determine download URL');
return;
}
// Prepare metadata for the download
const metadata = {
name: item.name || 'Unknown',
artist: item.artists ? item.artists[0]?.name : undefined
};
// Disable the button and update text
btn.disabled = true;
// For artist downloads, show a different message since it will queue multiple albums
if (type === 'artist') {
btn.innerHTML = 'Queueing albums...';
} else {
btn.innerHTML = 'Queueing...';
}
// Start the download
startDownload(url, type, metadata, item.album ? item.album.album_type : null)
.then(() => {
// For artists, show how many albums were queued
if (type === 'artist') {
btn.innerHTML = 'Albums queued!';
// Open the queue automatically for artist downloads
downloadQueue.toggleVisibility(true);
} else {
btn.innerHTML = 'Queued!';
}
})
.catch((error) => {
btn.disabled = false;
btn.innerHTML = 'Download';
showError('Failed to queue download: ' + error.message);
});
});
});
}
/**
* Starts the download process via API
*/
async function startDownload(url, type, item, albumType) {
if (!url || !type) {
showError('Missing URL or type for download');
return;
}
try {
// Use the centralized downloadQueue.download method
await downloadQueue.download(url, type, item, albumType);
// Make the queue visible after queueing
downloadQueue.toggleVisibility(true);
} catch (error) {
showError('Download failed: ' + (error.message || 'Unknown error'));
throw error;
}
}
/**
* Shows an error message
*/
function showError(message) {
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) {
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) {
return url.includes('open.spotify.com') ||
url.includes('spotify:') ||
url.includes('link.tospotify.com');
}
/**
* Extracts details from a Spotify URL
*/
function getSpotifyResourceDetails(url) {
// 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) {
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, type, index) {
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';
if (item.album && item.album.images && item.album.images.length > 0) {
imageUrl = item.album.images[0].url;
} else if (item.images && item.images.length > 0) {
imageUrl = item.images[0].url;
}
// Get the appropriate details based on type
let subtitle = '';
let details = '';
switch (type) {
case 'track':
subtitle = item.artists ? item.artists.map(a => a.name).join(', ') : 'Unknown Artist';
details = item.album ? `<span>${item.album.name}</span><span class="duration">${msToMinutesSeconds(item.duration_ms)}</span>` : '';
break;
case 'album':
subtitle = item.artists ? item.artists.map(a => a.name).join(', ') : 'Unknown Artist';
details = `<span>${item.total_tracks || 0} tracks</span><span>${item.release_date ? new Date(item.release_date).getFullYear() : ''}</span>`;
break;
case 'playlist':
subtitle = `By ${item.owner ? item.owner.display_name : 'Unknown'}`;
details = `<span>${item.tracks && item.tracks.total ? item.tracks.total : 0} tracks</span>`;
break;
case 'artist':
subtitle = 'Artist';
details = item.genres ? `<span>${item.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) => {
// Don't trigger if the download button was clicked
if (e.target.classList.contains('download-btn') ||
e.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) {
if (emptyState) {
emptyState.style.display = show ? 'flex' : 'none';
}
}
/**
* Show/hide the loading indicator
*/
function showLoading(show) {
if (loadingResults) {
loadingResults.classList.toggle('hidden', !show);
}
}
});

View File

@@ -1,395 +0,0 @@
// Import the downloadQueue singleton from your working queue.js implementation.
import { downloadQueue } from './queue.js';
document.addEventListener('DOMContentLoaded', () => {
// 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;
}
// 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();
})
.then(data => renderPlaylist(data))
.catch(error => {
console.error('Error:', error);
showError('Failed to load playlist.');
});
const queueIcon = document.getElementById('queueIcon');
if (queueIcon) {
queueIcon.addEventListener('click', () => {
downloadQueue.toggleVisibility();
});
}
});
/**
* Renders playlist header and tracks.
*/
function renderPlaylist(playlist) {
// Hide loading and error messages
document.getElementById('loading').classList.add('hidden');
document.getElementById('error').classList.add('hidden');
// Check if explicit filter is enabled
const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled();
// Update header info
document.getElementById('playlist-name').textContent = playlist.name || 'Unknown Playlist';
document.getElementById('playlist-owner').textContent = `By ${playlist.owner?.display_name || 'Unknown User'}`;
document.getElementById('playlist-stats').textContent =
`${playlist.followers?.total || '0'} followers • ${playlist.tracks?.total || '0'} songs`;
document.getElementById('playlist-description').textContent = playlist.description || '';
const image = playlist.images?.[0]?.url || '/static/images/placeholder.jpg';
document.getElementById('playlist-image').src = image;
// --- Add Home Button ---
let homeButton = document.getElementById('homeButton');
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 => item?.track && item.track.explicit);
}
// --- Add "Download Whole Playlist" Button ---
let downloadPlaylistBtn = document.getElementById('downloadPlaylistBtn');
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');
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
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>`;
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
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 => {
showError('Failed to queue playlist download: ' + (err?.message || 'Unknown error'));
downloadPlaylistBtn.disabled = false;
});
});
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(() => {
downloadAlbumsBtn.textContent = 'Queued!';
})
.catch(err => {
showError('Failed to queue album downloads: ' + (err?.message || 'Unknown error'));
downloadAlbumsBtn.disabled = false;
});
});
}
// Render tracks list
const tracksList = document.getElementById('tracks-list');
if (!tracksList) return;
tracksList.innerHTML = ''; // Clear any existing content
if (playlist.tracks?.items) {
playlist.tracks.items.forEach((item, index) => {
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;
}
// Create links for track, artist, and album using their IDs.
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';
trackElement.innerHTML = `
<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>
<button class="download-btn download-btn--circle"
data-url="${track.external_urls?.spotify || ''}"
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 tracks container
document.getElementById('playlist-header').classList.remove('hidden');
document.getElementById('tracks-container').classList.remove('hidden');
// Attach download listeners to newly rendered download buttons
attachDownloadListeners();
}
/**
* Converts milliseconds to minutes:seconds.
*/
function msToTime(duration) {
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) {
const errorEl = document.getElementById('error');
if (errorEl) {
errorEl.textContent = message || 'An error occurred';
errorEl.classList.remove('hidden');
}
}
/**
* Attaches event listeners to all individual download buttons.
*/
function attachDownloadListeners() {
document.querySelectorAll('.download-btn').forEach((btn) => {
// Skip the whole playlist and album download buttons.
if (btn.id === 'downloadPlaylistBtn' || btn.id === 'downloadAlbumsBtn') return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
const url = e.currentTarget.dataset.url || '';
const type = e.currentTarget.dataset.type || '';
const name = e.currentTarget.dataset.name || extractName(url) || 'Unknown';
// Remove the button immediately after click.
e.currentTarget.remove();
startDownload(url, type, { name });
});
});
}
/**
* Initiates the whole playlist download by calling the playlist endpoint.
*/
async function downloadWholePlaylist(playlist) {
if (!playlist) {
throw new Error('Invalid playlist data');
}
const url = playlist.external_urls?.spotify || '';
if (!url) {
throw new Error('Missing playlist URL');
}
try {
// Use the centralized downloadQueue.download method
await downloadQueue.download(url, 'playlist', { name: playlist.name || 'Unknown Playlist' });
// Make the queue visible after queueing
downloadQueue.toggleVisibility(true);
} catch (error) {
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) {
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();
playlist.tracks.items.forEach(item => {
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');
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(
albumUrl,
'album',
{ name: album.name || 'Unknown Album' }
);
// 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) {
// Propagate any errors encountered.
throw error;
}
}
/**
* Starts the download process using the centralized download method from the queue.
*/
async function startDownload(url, type, item, albumType) {
if (!url || !type) {
showError('Missing URL or type for download');
return;
}
try {
// Use the centralized downloadQueue.download method
await downloadQueue.download(url, type, item, albumType);
// Make the queue visible after queueing
downloadQueue.toggleVisibility(true);
} catch (error) {
showError('Download failed: ' + (error?.message || 'Unknown error'));
throw error;
}
}
/**
* A helper function to extract a display name from the URL.
*/
function extractName(url) {
return url || 'Unknown';
}

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2017", // Specify ECMAScript target version
"module": "ES2020", // Specify module code generation
"strict": true, // Enable all strict type-checking options
"esModuleInterop": true, // Enables emit interoperability between CommonJS and ES Modules
"skipLibCheck": true, // Skip type checking of declaration files
"forceConsistentCasingInFileNames": true, // Disallow inconsistently-cased references to the same file.
"outDir": "./static/js",
"rootDir": "./src/js"
},
"include": [
"src/js/**/*.ts",
"src/js/album.ts",
"src/js/artist.ts",
"src/js/config.ts",
"src/js/main.ts",
"src/js/playlist.ts",
"src/js/queue.ts",
"src/js/track.ts"
],
"exclude": [
"node_modules" // Specifies an array of filenames or patterns that should be skipped when resolving include.
]
}