Add library issue reporting system with actionable detail modal
This commit is contained in:
parent
57a8bdd107
commit
e1a5bf678a
5 changed files with 3158 additions and 188 deletions
|
|
@ -429,6 +429,32 @@ class MusicDatabase:
|
|||
self._add_automation_system_column(cursor)
|
||||
self._add_automation_then_actions_column(cursor)
|
||||
|
||||
# Library issues — user-reported problems with tracks/albums/artists
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS library_issues (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
profile_id INTEGER NOT NULL DEFAULT 1,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
snapshot_data TEXT DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
priority TEXT NOT NULL DEFAULT 'normal',
|
||||
admin_response TEXT,
|
||||
resolved_by INTEGER,
|
||||
resolved_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (profile_id) REFERENCES profiles (id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_issues_profile ON library_issues (profile_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_issues_status ON library_issues (status)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_issues_entity ON library_issues (entity_type, entity_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_issues_created ON library_issues (created_at)")
|
||||
|
||||
conn.commit()
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
|
|
@ -7832,6 +7858,174 @@ class MusicDatabase:
|
|||
logger.error(f"Error getting radio tracks for track {track_id}: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
# ── Library Issues CRUD ──
|
||||
|
||||
def create_issue(self, profile_id: int, entity_type: str, entity_id: str,
|
||||
category: str, title: str, description: str = '',
|
||||
snapshot_data: Dict = None, priority: str = 'normal') -> Dict[str, Any]:
|
||||
"""Create a new library issue report."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO library_issues
|
||||
(profile_id, entity_type, entity_id, category, title, description,
|
||||
snapshot_data, priority)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (profile_id, entity_type, entity_id, category, title, description,
|
||||
json.dumps(snapshot_data or {}), priority))
|
||||
conn.commit()
|
||||
return {'success': True, 'id': cursor.lastrowid}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating issue: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def get_issues(self, profile_id: int = None, status: str = None,
|
||||
category: str = None, entity_type: str = None,
|
||||
limit: int = 100, offset: int = 0,
|
||||
is_admin: bool = False) -> Dict[str, Any]:
|
||||
"""Get issues with optional filters. Non-admin only sees own issues."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if not is_admin and profile_id:
|
||||
conditions.append("i.profile_id = ?")
|
||||
params.append(profile_id)
|
||||
if status:
|
||||
conditions.append("i.status = ?")
|
||||
params.append(status)
|
||||
if category:
|
||||
conditions.append("i.category = ?")
|
||||
params.append(category)
|
||||
if entity_type:
|
||||
conditions.append("i.entity_type = ?")
|
||||
params.append(entity_type)
|
||||
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
|
||||
# Count total
|
||||
cursor.execute(f"SELECT COUNT(*) FROM library_issues i {where}", params)
|
||||
total = cursor.fetchone()[0]
|
||||
|
||||
# Fetch issues with reporter profile info
|
||||
cursor.execute(f"""
|
||||
SELECT i.*, p.name as reporter_name, p.avatar_color as reporter_color,
|
||||
p.avatar_url as reporter_avatar
|
||||
FROM library_issues i
|
||||
LEFT JOIN profiles p ON i.profile_id = p.id
|
||||
{where}
|
||||
ORDER BY
|
||||
CASE i.status WHEN 'open' THEN 0 WHEN 'in_progress' THEN 1 ELSE 2 END,
|
||||
CASE i.priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END,
|
||||
i.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""", params + [limit, offset])
|
||||
|
||||
issues = []
|
||||
for row in cursor.fetchall():
|
||||
issue = dict(row)
|
||||
try:
|
||||
issue['snapshot_data'] = json.loads(issue.get('snapshot_data', '{}'))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
issue['snapshot_data'] = {}
|
||||
issues.append(issue)
|
||||
|
||||
return {'success': True, 'issues': issues, 'total': total}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting issues: {e}")
|
||||
return {'success': False, 'error': str(e), 'issues': [], 'total': 0}
|
||||
|
||||
def get_issue(self, issue_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get a single issue by ID with reporter info."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT i.*, p.name as reporter_name, p.avatar_color as reporter_color,
|
||||
p.avatar_url as reporter_avatar
|
||||
FROM library_issues i
|
||||
LEFT JOIN profiles p ON i.profile_id = p.id
|
||||
WHERE i.id = ?
|
||||
""", (issue_id,))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
issue = dict(row)
|
||||
try:
|
||||
issue['snapshot_data'] = json.loads(issue.get('snapshot_data', '{}'))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
issue['snapshot_data'] = {}
|
||||
return issue
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting issue {issue_id}: {e}")
|
||||
return None
|
||||
|
||||
def update_issue(self, issue_id: int, updates: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Update an issue (admin response, status change, etc.)."""
|
||||
allowed_fields = {'status', 'priority', 'admin_response', 'resolved_by', 'resolved_at',
|
||||
'title', 'description', 'category'}
|
||||
valid = {k: v for k, v in updates.items() if k in allowed_fields}
|
||||
if not valid:
|
||||
return {'success': False, 'error': 'No valid fields to update'}
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
set_clause = ', '.join(f'{k} = ?' for k in valid)
|
||||
values = list(valid.values()) + [issue_id]
|
||||
cursor.execute(
|
||||
f"UPDATE library_issues SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
values
|
||||
)
|
||||
conn.commit()
|
||||
if cursor.rowcount == 0:
|
||||
return {'success': False, 'error': 'Issue not found'}
|
||||
return {'success': True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating issue {issue_id}: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def delete_issue(self, issue_id: int) -> Dict[str, Any]:
|
||||
"""Delete an issue (admin only)."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM library_issues WHERE id = ?", (issue_id,))
|
||||
conn.commit()
|
||||
if cursor.rowcount == 0:
|
||||
return {'success': False, 'error': 'Issue not found'}
|
||||
return {'success': True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting issue {issue_id}: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def get_issue_counts(self, is_admin: bool = False, profile_id: int = None) -> Dict[str, int]:
|
||||
"""Get issue counts by status for badge display."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
profile_filter = ""
|
||||
params = []
|
||||
if not is_admin and profile_id:
|
||||
profile_filter = "WHERE profile_id = ?"
|
||||
params = [profile_id]
|
||||
cursor.execute(f"""
|
||||
SELECT status, COUNT(*) as count
|
||||
FROM library_issues
|
||||
{profile_filter}
|
||||
GROUP BY status
|
||||
""", params)
|
||||
counts = {'open': 0, 'in_progress': 0, 'resolved': 0, 'dismissed': 0, 'total': 0}
|
||||
for row in cursor.fetchall():
|
||||
counts[row['status']] = row['count']
|
||||
counts['total'] += row['count']
|
||||
return counts
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting issue counts: {e}")
|
||||
return {'open': 0, 'in_progress': 0, 'resolved': 0, 'dismissed': 0, 'total': 0}
|
||||
|
||||
# Thread-safe singleton pattern for database access
|
||||
_database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance
|
||||
_database_lock = threading.Lock()
|
||||
|
|
|
|||
335
web_server.py
335
web_server.py
|
|
@ -10194,6 +10194,334 @@ def get_reorganize_status():
|
|||
return jsonify(state)
|
||||
|
||||
|
||||
# ── Library Issues endpoints ──
|
||||
|
||||
@app.route('/api/issues', methods=['GET'])
|
||||
def list_issues():
|
||||
"""List issues. Admin sees all; non-admin sees own only."""
|
||||
try:
|
||||
database = get_database()
|
||||
profile_id = request.headers.get('X-Profile-Id', '1')
|
||||
try:
|
||||
profile_id = int(profile_id)
|
||||
except (ValueError, TypeError):
|
||||
profile_id = 1
|
||||
|
||||
# Determine admin status
|
||||
profile = database.get_profile(profile_id)
|
||||
is_admin = profile.get('is_admin', False) if profile else False
|
||||
|
||||
status = request.args.get('status')
|
||||
category = request.args.get('category')
|
||||
entity_type = request.args.get('entity_type')
|
||||
try:
|
||||
limit = min(200, max(1, int(request.args.get('limit', 100))))
|
||||
except (ValueError, TypeError):
|
||||
limit = 100
|
||||
try:
|
||||
offset = max(0, int(request.args.get('offset', 0)))
|
||||
except (ValueError, TypeError):
|
||||
offset = 0
|
||||
|
||||
result = database.get_issues(
|
||||
profile_id=profile_id,
|
||||
status=status,
|
||||
category=category,
|
||||
entity_type=entity_type,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
# Fix Plex/Jellyfin relative thumb URLs in stored snapshots
|
||||
for issue in result.get('issues', []):
|
||||
snap = issue.get('snapshot_data')
|
||||
if isinstance(snap, dict):
|
||||
for key in ('thumb_url', 'artist_thumb', 'album_thumb'):
|
||||
if snap.get(key):
|
||||
snap[key] = fix_artist_image_url(snap[key]) or snap[key]
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/issues', methods=['POST'])
|
||||
def create_issue():
|
||||
"""Create a new library issue."""
|
||||
try:
|
||||
database = get_database()
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"success": False, "error": "No data provided"}), 400
|
||||
|
||||
# Use header for profile_id (not body) to prevent spoofing
|
||||
profile_id = request.headers.get('X-Profile-Id', '1')
|
||||
try:
|
||||
profile_id = int(profile_id)
|
||||
except (ValueError, TypeError):
|
||||
profile_id = 1
|
||||
entity_type = data.get('entity_type')
|
||||
entity_id = data.get('entity_id')
|
||||
category = data.get('category')
|
||||
title = data.get('title', '').strip()
|
||||
description = data.get('description', '').strip()
|
||||
priority = data.get('priority', 'normal')
|
||||
|
||||
if not entity_type or not entity_id or not category or not title:
|
||||
return jsonify({"success": False, "error": "entity_type, entity_id, category, and title are required"}), 400
|
||||
|
||||
valid_types = ('artist', 'album', 'track')
|
||||
if entity_type not in valid_types:
|
||||
return jsonify({"success": False, "error": f"entity_type must be one of: {', '.join(valid_types)}"}), 400
|
||||
|
||||
valid_categories = ('wrong_track', 'wrong_metadata', 'wrong_cover', 'duplicate_tracks',
|
||||
'missing_tracks', 'audio_quality', 'wrong_artist', 'wrong_album',
|
||||
'incomplete_album', 'other')
|
||||
if category not in valid_categories:
|
||||
return jsonify({"success": False, "error": f"Invalid category: {category}"}), 400
|
||||
|
||||
# Build snapshot of the entity's current state
|
||||
snapshot = _build_issue_snapshot(database, entity_type, str(entity_id))
|
||||
|
||||
result = database.create_issue(
|
||||
profile_id=profile_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=str(entity_id),
|
||||
category=category,
|
||||
title=title,
|
||||
description=description,
|
||||
snapshot_data=snapshot,
|
||||
priority=priority,
|
||||
)
|
||||
return jsonify(result), 201 if result.get('success') else 400
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/issues/<int:issue_id>', methods=['GET'])
|
||||
def get_issue(issue_id):
|
||||
"""Get a single issue."""
|
||||
try:
|
||||
database = get_database()
|
||||
issue = database.get_issue(issue_id)
|
||||
if not issue:
|
||||
return jsonify({"success": False, "error": "Issue not found"}), 404
|
||||
# Fix Plex/Jellyfin relative thumb URLs in stored snapshot
|
||||
snap = issue.get('snapshot_data')
|
||||
if isinstance(snap, dict):
|
||||
for key in ('thumb_url', 'artist_thumb', 'album_thumb'):
|
||||
if snap.get(key):
|
||||
snap[key] = fix_artist_image_url(snap[key]) or snap[key]
|
||||
return jsonify({"success": True, "issue": issue})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/issues/<int:issue_id>', methods=['PUT'])
|
||||
def update_issue(issue_id):
|
||||
"""Update an issue (admin: respond/resolve; user: edit own description)."""
|
||||
try:
|
||||
database = get_database()
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"success": False, "error": "No data provided"}), 400
|
||||
|
||||
profile_id = request.headers.get('X-Profile-Id', '1')
|
||||
try:
|
||||
profile_id = int(profile_id)
|
||||
except (ValueError, TypeError):
|
||||
profile_id = 1
|
||||
|
||||
profile = database.get_profile(profile_id)
|
||||
is_admin = profile.get('is_admin', False) if profile else False
|
||||
|
||||
# Non-admin can only edit their own issue's title/description
|
||||
if not is_admin:
|
||||
issue = database.get_issue(issue_id)
|
||||
if not issue:
|
||||
return jsonify({"success": False, "error": "Issue not found"}), 404
|
||||
if issue['profile_id'] != profile_id:
|
||||
return jsonify({"success": False, "error": "Not authorized"}), 403
|
||||
data = {k: v for k, v in data.items() if k in ('title', 'description')}
|
||||
|
||||
# If resolving, stamp resolved_by and resolved_at
|
||||
if data.get('status') in ('resolved', 'dismissed') and is_admin:
|
||||
data['resolved_by'] = profile_id
|
||||
from datetime import datetime
|
||||
data['resolved_at'] = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')
|
||||
# If reopening, clear resolution metadata
|
||||
elif data.get('status') in ('open', 'in_progress') and is_admin:
|
||||
data['resolved_by'] = None
|
||||
data['resolved_at'] = None
|
||||
|
||||
result = database.update_issue(issue_id, data)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/issues/<int:issue_id>', methods=['DELETE'])
|
||||
def delete_issue(issue_id):
|
||||
"""Delete an issue (admin or issue owner)."""
|
||||
try:
|
||||
database = get_database()
|
||||
profile_id = request.headers.get('X-Profile-Id', '1')
|
||||
try:
|
||||
profile_id = int(profile_id)
|
||||
except (ValueError, TypeError):
|
||||
profile_id = 1
|
||||
|
||||
profile = database.get_profile(profile_id)
|
||||
is_admin = profile.get('is_admin', False) if profile else False
|
||||
|
||||
if not is_admin:
|
||||
issue = database.get_issue(issue_id)
|
||||
if not issue:
|
||||
return jsonify({"success": False, "error": "Issue not found"}), 404
|
||||
if issue['profile_id'] != profile_id:
|
||||
return jsonify({"success": False, "error": "Not authorized"}), 403
|
||||
|
||||
result = database.delete_issue(issue_id)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/issues/counts', methods=['GET'])
|
||||
def get_issue_counts():
|
||||
"""Get issue counts by status for badge display."""
|
||||
try:
|
||||
database = get_database()
|
||||
profile_id = request.headers.get('X-Profile-Id', '1')
|
||||
try:
|
||||
profile_id = int(profile_id)
|
||||
except (ValueError, TypeError):
|
||||
profile_id = 1
|
||||
profile = database.get_profile(profile_id)
|
||||
is_admin = profile.get('is_admin', False) if profile else False
|
||||
counts = database.get_issue_counts(is_admin=is_admin, profile_id=profile_id)
|
||||
return jsonify({"success": True, "counts": counts})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
def _build_issue_snapshot(database, entity_type, entity_id):
|
||||
"""Capture current state of the entity for the issue report."""
|
||||
snapshot = {}
|
||||
try:
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
if entity_type == 'track':
|
||||
cursor.execute("""
|
||||
SELECT t.id, t.title, t.track_number, t.duration,
|
||||
t.file_path, t.bitrate, t.bpm,
|
||||
t.spotify_track_id, t.musicbrainz_recording_id, t.deezer_id as track_deezer_id,
|
||||
a.name as artist_name, a.id as artist_id,
|
||||
a.spotify_artist_id, a.musicbrainz_id as artist_musicbrainz_id,
|
||||
a.deezer_id as artist_deezer_id, a.tidal_id as artist_tidal_id,
|
||||
a.qobuz_id as artist_qobuz_id, a.thumb_url as artist_thumb,
|
||||
al.title as album_title, al.year, al.thumb_url as album_thumb,
|
||||
al.id as album_id, al.spotify_album_id, al.musicbrainz_release_id,
|
||||
al.deezer_id as album_deezer_id, al.tidal_id as album_tidal_id,
|
||||
al.qobuz_id as album_qobuz_id, al.label, al.record_type,
|
||||
al.track_count as album_track_count
|
||||
FROM tracks t
|
||||
JOIN artists a ON t.artist_id = a.id
|
||||
JOIN albums al ON t.album_id = al.id
|
||||
WHERE t.id = ?
|
||||
""", (entity_id,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
d = dict(row)
|
||||
# Add format info if file exists
|
||||
resolved = _resolve_library_file_path(d.get('file_path'))
|
||||
if resolved:
|
||||
ext = os.path.splitext(resolved)[1].lower().lstrip('.')
|
||||
d['format'] = ext.upper()
|
||||
d['quality'] = _get_audio_quality_string(resolved)
|
||||
# Fix Plex/Jellyfin relative thumb URLs
|
||||
if d.get('artist_thumb'):
|
||||
d['artist_thumb'] = fix_artist_image_url(d['artist_thumb']) or d['artist_thumb']
|
||||
if d.get('album_thumb'):
|
||||
d['album_thumb'] = fix_artist_image_url(d['album_thumb']) or d['album_thumb']
|
||||
snapshot = d
|
||||
|
||||
elif entity_type == 'album':
|
||||
cursor.execute("""
|
||||
SELECT al.id, al.title, al.year, al.track_count, al.thumb_url,
|
||||
al.genres, al.label, al.record_type, al.duration,
|
||||
al.spotify_album_id, al.musicbrainz_release_id,
|
||||
al.deezer_id as album_deezer_id, al.tidal_id as album_tidal_id,
|
||||
al.qobuz_id as album_qobuz_id, al.upc,
|
||||
a.name as artist_name, a.id as artist_id,
|
||||
a.spotify_artist_id, a.musicbrainz_id as artist_musicbrainz_id,
|
||||
a.deezer_id as artist_deezer_id, a.tidal_id as artist_tidal_id,
|
||||
a.qobuz_id as artist_qobuz_id, a.thumb_url as artist_thumb
|
||||
FROM albums al
|
||||
JOIN artists a ON al.artist_id = a.id
|
||||
WHERE al.id = ?
|
||||
""", (entity_id,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
d = dict(row)
|
||||
# Fix Plex/Jellyfin relative thumb URLs
|
||||
if d.get('thumb_url'):
|
||||
d['thumb_url'] = fix_artist_image_url(d['thumb_url']) or d['thumb_url']
|
||||
if d.get('artist_thumb'):
|
||||
d['artist_thumb'] = fix_artist_image_url(d['artist_thumb']) or d['artist_thumb']
|
||||
# Parse genres
|
||||
if d.get('genres'):
|
||||
try:
|
||||
d['genres'] = json.loads(d['genres'])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
# Get track listing with enriched data
|
||||
cursor.execute("""
|
||||
SELECT id, title, track_number, duration, file_path, bitrate,
|
||||
spotify_track_id, bpm
|
||||
FROM tracks WHERE album_id = ? ORDER BY track_number
|
||||
""", (entity_id,))
|
||||
tracks_list = []
|
||||
for r in cursor.fetchall():
|
||||
td = dict(r)
|
||||
# Add format from file extension
|
||||
if td.get('file_path'):
|
||||
resolved = _resolve_library_file_path(td['file_path'])
|
||||
if resolved:
|
||||
ext = os.path.splitext(resolved)[1].lower().lstrip('.')
|
||||
td['format'] = ext.upper()
|
||||
tracks_list.append(td)
|
||||
d['tracks'] = tracks_list
|
||||
snapshot = d
|
||||
|
||||
elif entity_type == 'artist':
|
||||
cursor.execute("""
|
||||
SELECT id, name, thumb_url, genres, summary,
|
||||
spotify_artist_id, musicbrainz_id as artist_musicbrainz_id,
|
||||
deezer_id as artist_deezer_id, tidal_id as artist_tidal_id,
|
||||
qobuz_id as artist_qobuz_id
|
||||
FROM artists WHERE id = ?
|
||||
""", (entity_id,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
d = dict(row)
|
||||
# Fix Plex/Jellyfin relative thumb URL
|
||||
if d.get('thumb_url'):
|
||||
d['thumb_url'] = fix_artist_image_url(d['thumb_url']) or d['thumb_url']
|
||||
if d.get('genres'):
|
||||
try:
|
||||
d['genres'] = json.loads(d['genres'])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
snapshot = d
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error building issue snapshot: {e}")
|
||||
snapshot['_snapshot_error'] = str(e)
|
||||
|
||||
return snapshot
|
||||
|
||||
|
||||
def _sync_tracks_to_server(track_rows, server_type):
|
||||
"""Sync metadata for tracks to the active media server after writing file tags.
|
||||
|
||||
|
|
@ -19762,9 +20090,14 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json):
|
|||
batch_playlist_name = batch.get('playlist_name', 'Unknown Playlist')
|
||||
|
||||
# === ALBUM PRE-FLIGHT: Search for complete album folder before track-by-track ===
|
||||
# Only run pre-flight when Soulseek is the download source (or hybrid with soulseek)
|
||||
preflight_source = None
|
||||
preflight_tracks = None
|
||||
if batch_is_album and batch_album_context and batch_artist_context:
|
||||
dl_source_mode = config_manager.get('download_source.mode', 'soulseek')
|
||||
soulseek_is_source = dl_source_mode == 'soulseek' or (
|
||||
dl_source_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary', 'soulseek') == 'soulseek'
|
||||
)
|
||||
if batch_is_album and batch_album_context and batch_artist_context and soulseek_is_source:
|
||||
artist_name = batch_artist_context.get('name', '')
|
||||
album_name = batch_album_context.get('name', '')
|
||||
if artist_name and album_name:
|
||||
|
|
|
|||
|
|
@ -156,6 +156,11 @@
|
|||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||
<span class="nav-text">Settings</span>
|
||||
</button>
|
||||
<button class="nav-button" data-page="issues">
|
||||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></span>
|
||||
<span class="nav-text">Issues</span>
|
||||
<span class="issues-nav-badge hidden" id="issues-nav-badge">0</span>
|
||||
</button>
|
||||
<button class="nav-button" data-page="help">
|
||||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
|
||||
<span class="nav-text">Help & Docs</span>
|
||||
|
|
@ -4562,6 +4567,85 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Issues Page -->
|
||||
<div class="page" id="issues-page">
|
||||
<div class="issues-container">
|
||||
<div class="issues-header">
|
||||
<div class="issues-header-left">
|
||||
<h2 class="issues-title">Issues</h2>
|
||||
<p class="issues-subtitle" id="issues-subtitle">Track and resolve library problems</p>
|
||||
</div>
|
||||
<div class="issues-header-right">
|
||||
<div class="issues-filters" id="issues-filters">
|
||||
<select id="issues-filter-status" class="issues-filter-select" onchange="loadIssuesPage()">
|
||||
<option value="">All Status</option>
|
||||
<option value="open" selected>Open</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="dismissed">Dismissed</option>
|
||||
</select>
|
||||
<select id="issues-filter-category" class="issues-filter-select" onchange="loadIssuesPage()">
|
||||
<option value="">All Categories</option>
|
||||
<optgroup label="Track Issues">
|
||||
<option value="wrong_track">Wrong Track</option>
|
||||
<option value="wrong_artist">Wrong Artist</option>
|
||||
<option value="wrong_album">Wrong Album</option>
|
||||
<option value="audio_quality">Audio Quality</option>
|
||||
</optgroup>
|
||||
<optgroup label="Album Issues">
|
||||
<option value="wrong_cover">Wrong Cover Art</option>
|
||||
<option value="duplicate_tracks">Duplicate Tracks</option>
|
||||
<option value="missing_tracks">Missing Tracks</option>
|
||||
<option value="incomplete_album">Incomplete Album</option>
|
||||
</optgroup>
|
||||
<optgroup label="Both">
|
||||
<option value="wrong_metadata">Wrong Metadata</option>
|
||||
<option value="other">Other</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="issues-stats" id="issues-stats"></div>
|
||||
<div class="issues-list" id="issues-list">
|
||||
<div class="issues-empty">Loading issues...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Report Issue Modal -->
|
||||
<div class="modal-overlay hidden" id="report-issue-overlay">
|
||||
<div class="enhanced-bulk-modal report-issue-modal">
|
||||
<div class="enhanced-bulk-modal-header">
|
||||
<h3 id="report-issue-title">Report an Issue</h3>
|
||||
<button class="enhanced-bulk-modal-close" onclick="closeReportIssueModal()">×</button>
|
||||
</div>
|
||||
<div class="enhanced-bulk-modal-body" id="report-issue-body">
|
||||
<!-- Populated dynamically -->
|
||||
</div>
|
||||
<div class="enhanced-bulk-modal-footer">
|
||||
<button class="enhanced-bulk-btn secondary" onclick="closeReportIssueModal()">Cancel</button>
|
||||
<button class="enhanced-bulk-btn primary" id="report-issue-submit-btn" onclick="submitIssue()">Submit Issue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Issue Detail Modal (Admin) -->
|
||||
<div class="modal-overlay hidden" id="issue-detail-overlay">
|
||||
<div class="enhanced-bulk-modal issue-detail-modal">
|
||||
<div class="enhanced-bulk-modal-header">
|
||||
<h3 id="issue-detail-title">Issue Details</h3>
|
||||
<button class="enhanced-bulk-modal-close" onclick="closeIssueDetailModal()">×</button>
|
||||
</div>
|
||||
<div class="enhanced-bulk-modal-body" id="issue-detail-body">
|
||||
<!-- Populated dynamically -->
|
||||
</div>
|
||||
<div class="enhanced-bulk-modal-footer" id="issue-detail-footer">
|
||||
<button class="enhanced-bulk-btn secondary" onclick="closeIssueDetailModal()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help & Docs Page -->
|
||||
<div class="page" id="help-page">
|
||||
<div class="docs-layout">
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue