Add persistent library history tracking downloads and server imports
New library_history table logs every completed download and every new track imported from Plex/Jellyfin/Navidrome. A "History" button next to "Recent Activity" on the dashboard opens a modal with Downloads and Server Imports tabs, album art thumbnails, quality/source badges, and pagination.
This commit is contained in:
parent
f4af4ea7db
commit
837c5ff680
5 changed files with 650 additions and 3 deletions
|
|
@ -466,6 +466,24 @@ class MusicDatabase:
|
||||||
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_entity ON library_issues (entity_type, entity_id)")
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_issues_created ON library_issues (created_at)")
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_issues_created ON library_issues (created_at)")
|
||||||
|
|
||||||
|
# Library history — persistent log of downloads and server imports
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS library_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
artist_name TEXT,
|
||||||
|
album_name TEXT,
|
||||||
|
quality TEXT,
|
||||||
|
server_source TEXT,
|
||||||
|
file_path TEXT,
|
||||||
|
thumb_url TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lh_event_type ON library_history (event_type)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lh_created_at ON library_history (created_at DESC)")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info("Database initialized successfully")
|
logger.info("Database initialized successfully")
|
||||||
|
|
||||||
|
|
@ -3629,6 +3647,10 @@ class MusicDatabase:
|
||||||
if file_path is None and hasattr(track_obj, 'suffix') and track_obj.suffix:
|
if file_path is None and hasattr(track_obj, 'suffix') and track_obj.suffix:
|
||||||
file_path = f"{track_obj.title}.{track_obj.suffix}"
|
file_path = f"{track_obj.title}.{track_obj.suffix}"
|
||||||
|
|
||||||
|
# Check if this is a genuinely new track (for history logging)
|
||||||
|
cursor.execute("SELECT 1 FROM tracks WHERE id = ? LIMIT 1", (track_id,))
|
||||||
|
is_new_track = cursor.fetchone() is None
|
||||||
|
|
||||||
# Use INSERT OR REPLACE to handle duplicate IDs gracefully
|
# Use INSERT OR REPLACE to handle duplicate IDs gracefully
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT OR REPLACE INTO tracks
|
INSERT OR REPLACE INTO tracks
|
||||||
|
|
@ -3637,6 +3659,26 @@ class MusicDatabase:
|
||||||
""", (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source))
|
""", (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
# Log new imports to library history
|
||||||
|
if is_new_track:
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT name FROM artists WHERE id = ?", (artist_id,))
|
||||||
|
artist_row = cursor.fetchone()
|
||||||
|
cursor.execute("SELECT title, thumb_url FROM albums WHERE id = ?", (album_id,))
|
||||||
|
album_row = cursor.fetchone()
|
||||||
|
self.add_library_history_entry(
|
||||||
|
event_type='import',
|
||||||
|
title=title,
|
||||||
|
artist_name=artist_row[0] if artist_row else None,
|
||||||
|
album_name=album_row[0] if album_row else None,
|
||||||
|
server_source=server_source,
|
||||||
|
file_path=file_path,
|
||||||
|
thumb_url=album_row[1] if album_row and len(album_row) > 1 else None
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # Non-critical history logging
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -7806,6 +7848,70 @@ class MusicDatabase:
|
||||||
logger.error(f"API: Error getting genres from {table}: {e}")
|
logger.error(f"API: Error getting genres from {table}: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# ── Library History ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def add_library_history_entry(self, event_type, title, artist_name=None, album_name=None,
|
||||||
|
quality=None, server_source=None, file_path=None, thumb_url=None):
|
||||||
|
"""Record a download or import event to the library history table."""
|
||||||
|
try:
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO library_history (event_type, title, artist_name, album_name,
|
||||||
|
quality, server_source, file_path, thumb_url)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (event_type, title, artist_name, album_name, quality, server_source, file_path, thumb_url))
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error adding library history entry: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_library_history(self, event_type=None, page=1, limit=50):
|
||||||
|
"""Query library history with optional type filter and pagination.
|
||||||
|
|
||||||
|
Returns (entries_list, total_count).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
where = "WHERE event_type = ?" if event_type else ""
|
||||||
|
params = [event_type] if event_type else []
|
||||||
|
|
||||||
|
cursor.execute(f"SELECT COUNT(*) as cnt FROM library_history {where}", params)
|
||||||
|
total = cursor.fetchone()['cnt']
|
||||||
|
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
cursor.execute(f"""
|
||||||
|
SELECT * FROM library_history {where}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""", params + [limit, offset])
|
||||||
|
entries = [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
return entries, total
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error querying library history: {e}")
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
def get_library_history_stats(self):
|
||||||
|
"""Return counts per event_type: {downloads: int, imports: int}."""
|
||||||
|
try:
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT event_type, COUNT(*) as cnt FROM library_history GROUP BY event_type")
|
||||||
|
stats = {'downloads': 0, 'imports': 0}
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
if row['event_type'] == 'download':
|
||||||
|
stats['downloads'] = row['cnt']
|
||||||
|
elif row['event_type'] == 'import':
|
||||||
|
stats['imports'] = row['cnt']
|
||||||
|
return stats
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error getting library history stats: {e}")
|
||||||
|
return {'downloads': 0, 'imports': 0}
|
||||||
|
|
||||||
def api_get_recently_added(self, entity_type: str = "albums", limit: int = 50) -> List[Dict[str, Any]]:
|
def api_get_recently_added(self, entity_type: str = "albums", limit: int = 50) -> List[Dict[str, Any]]:
|
||||||
"""Get recently added entities, ordered by created_at DESC."""
|
"""Get recently added entities, ordered by created_at DESC."""
|
||||||
table = {"artists": "artists", "albums": "albums", "tracks": "tracks"}.get(entity_type)
|
table = {"artists": "artists", "albums": "albums", "tracks": "tracks"}.get(entity_type)
|
||||||
|
|
|
||||||
|
|
@ -1371,6 +1371,53 @@ def _emit_track_downloaded(context):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _record_library_history_download(context):
|
||||||
|
"""Record a completed download to the library_history table. Non-blocking."""
|
||||||
|
try:
|
||||||
|
ti = context.get('track_info') or context.get('search_result') or {}
|
||||||
|
artist_name = ''
|
||||||
|
artists = ti.get('artists', [])
|
||||||
|
if artists:
|
||||||
|
a = artists[0]
|
||||||
|
artist_name = a.get('name', str(a)) if isinstance(a, dict) else str(a)
|
||||||
|
if not artist_name:
|
||||||
|
artist_name = ti.get('artist', '')
|
||||||
|
|
||||||
|
album_raw = ti.get('album', '')
|
||||||
|
album_name = album_raw.get('name', '') if isinstance(album_raw, dict) else str(album_raw or '')
|
||||||
|
|
||||||
|
title = ti.get('name', ti.get('title', ''))
|
||||||
|
quality = context.get('_audio_quality', '')
|
||||||
|
file_path = context.get('_final_processed_path', context.get('_final_path', ''))
|
||||||
|
|
||||||
|
# Try to get album art URL
|
||||||
|
thumb_url = ''
|
||||||
|
spotify_album = context.get('spotify_album')
|
||||||
|
if spotify_album and isinstance(spotify_album, dict):
|
||||||
|
thumb_url = spotify_album.get('image_url', '')
|
||||||
|
if not thumb_url:
|
||||||
|
images = spotify_album.get('images', [])
|
||||||
|
if images:
|
||||||
|
thumb_url = images[0].get('url', '')
|
||||||
|
if not thumb_url:
|
||||||
|
album_info = context.get('album_info', {})
|
||||||
|
if isinstance(album_info, dict):
|
||||||
|
thumb_url = album_info.get('album_image_url', '')
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
db.add_library_history_entry(
|
||||||
|
event_type='download',
|
||||||
|
title=title,
|
||||||
|
artist_name=artist_name,
|
||||||
|
album_name=album_name,
|
||||||
|
quality=quality,
|
||||||
|
file_path=file_path,
|
||||||
|
thumb_url=thumb_url
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # Non-critical, never block download flow
|
||||||
|
|
||||||
# --- Register Public REST API Blueprint (v1) ---
|
# --- Register Public REST API Blueprint (v1) ---
|
||||||
try:
|
try:
|
||||||
from api import create_api_blueprint, limiter
|
from api import create_api_blueprint, limiter
|
||||||
|
|
@ -7872,6 +7919,33 @@ def fix_artist_image_url(thumb_url):
|
||||||
print(f"Error fixing image URL '{thumb_url}': {e}")
|
print(f"Error fixing image URL '{thumb_url}': {e}")
|
||||||
return thumb_url
|
return thumb_url
|
||||||
|
|
||||||
|
@app.route('/api/library/history')
|
||||||
|
def get_library_history():
|
||||||
|
"""Get persistent library history (downloads and server imports)."""
|
||||||
|
try:
|
||||||
|
event_type = request.args.get('type', None)
|
||||||
|
if event_type and event_type not in ('download', 'import'):
|
||||||
|
event_type = None
|
||||||
|
page = max(1, int(request.args.get('page', 1)))
|
||||||
|
limit = min(200, max(1, int(request.args.get('limit', 50))))
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
entries, total = db.get_library_history(event_type=event_type, page=page, limit=limit)
|
||||||
|
stats = db.get_library_history_stats()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'entries': entries,
|
||||||
|
'total': total,
|
||||||
|
'page': page,
|
||||||
|
'limit': limit,
|
||||||
|
'stats': stats
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching library history: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/library/artists')
|
@app.route('/api/library/artists')
|
||||||
def get_library_artists():
|
def get_library_artists():
|
||||||
"""Get artists for the library page with search, filtering, and pagination"""
|
"""Get artists for the library page with search, filtering, and pagination"""
|
||||||
|
|
@ -15706,6 +15780,7 @@ def _post_process_matched_download(context_key, context, file_path):
|
||||||
context['_simple_download_completed'] = True
|
context['_simple_download_completed'] = True
|
||||||
context['_final_path'] = str(destination)
|
context['_final_path'] = str(destination)
|
||||||
_emit_track_downloaded(context)
|
_emit_track_downloaded(context)
|
||||||
|
_record_library_history_download(context)
|
||||||
return
|
return
|
||||||
# --- END SIMPLE DOWNLOAD HANDLING ---
|
# --- END SIMPLE DOWNLOAD HANDLING ---
|
||||||
|
|
||||||
|
|
@ -15789,6 +15864,7 @@ def _post_process_matched_download(context_key, context, file_path):
|
||||||
print(f"⚠️ [Playlist Folder] Error checking wishlist removal: {wishlist_error}")
|
print(f"⚠️ [Playlist Folder] Error checking wishlist removal: {wishlist_error}")
|
||||||
|
|
||||||
_emit_track_downloaded(context)
|
_emit_track_downloaded(context)
|
||||||
|
_record_library_history_download(context)
|
||||||
|
|
||||||
# Mark as stream processed so the verification worker doesn't search
|
# Mark as stream processed so the verification worker doesn't search
|
||||||
# for the file by its original Soulseek name (which no longer exists after rename)
|
# for the file by its original Soulseek name (which no longer exists after rename)
|
||||||
|
|
@ -16130,6 +16206,7 @@ def _post_process_matched_download(context_key, context, file_path):
|
||||||
print(f"✅ Post-processing complete for: {context.get('_final_processed_path', final_path)}")
|
print(f"✅ Post-processing complete for: {context.get('_final_processed_path', final_path)}")
|
||||||
|
|
||||||
_emit_track_downloaded(context)
|
_emit_track_downloaded(context)
|
||||||
|
_record_library_history_download(context)
|
||||||
|
|
||||||
# RETAG DATA CAPTURE: Record completed album/single downloads for retag tool
|
# RETAG DATA CAPTURE: Record completed album/single downloads for retag tool
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -957,7 +957,10 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dashboard-section">
|
<div class="dashboard-section">
|
||||||
<h3 class="section-title">Recent Activity</h3>
|
<div class="section-title-row">
|
||||||
|
<h3 class="section-title">Recent Activity</h3>
|
||||||
|
<button class="library-history-btn" onclick="openLibraryHistoryModal()" title="View full library history">History</button>
|
||||||
|
</div>
|
||||||
<div class="activity-feed-container" id="dashboard-activity-feed">
|
<div class="activity-feed-container" id="dashboard-activity-feed">
|
||||||
<div class="activity-item">
|
<div class="activity-item">
|
||||||
<span class="activity-icon">📊</span>
|
<span class="activity-icon">📊</span>
|
||||||
|
|
@ -5599,6 +5602,26 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Library History Modal -->
|
||||||
|
<div class="modal-overlay hidden" id="library-history-overlay" onclick="if(event.target===this)closeLibraryHistoryModal()">
|
||||||
|
<div class="library-history-modal">
|
||||||
|
<div class="library-history-modal-header">
|
||||||
|
<h3>Library History</h3>
|
||||||
|
<button class="library-history-modal-close" onclick="closeLibraryHistoryModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="library-history-tabs">
|
||||||
|
<button class="library-history-tab active" data-tab="download" onclick="switchHistoryTab('download')">
|
||||||
|
Downloads <span class="library-history-tab-count" id="history-download-count">0</span>
|
||||||
|
</button>
|
||||||
|
<button class="library-history-tab" data-tab="import" onclick="switchHistoryTab('import')">
|
||||||
|
Server Imports <span class="library-history-tab-count" id="history-import-count">0</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="library-history-list" id="library-history-list"></div>
|
||||||
|
<div class="library-history-pagination" id="library-history-pagination"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Metadata Cache Browse Modal -->
|
<!-- Metadata Cache Browse Modal -->
|
||||||
<div class="mcache-modal-overlay" id="mcache-browse-modal" style="display:none;">
|
<div class="mcache-modal-overlay" id="mcache-browse-modal" style="display:none;">
|
||||||
<div class="mcache-modal">
|
<div class="mcache-modal">
|
||||||
|
|
|
||||||
|
|
@ -18032,6 +18032,131 @@ async function loadMetadataCacheStats() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Library History Modal ────────────────────────────────────────────
|
||||||
|
let _libraryHistoryState = { tab: 'download', page: 1, limit: 50 };
|
||||||
|
|
||||||
|
function openLibraryHistoryModal() {
|
||||||
|
const overlay = document.getElementById('library-history-overlay');
|
||||||
|
if (overlay) {
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
_libraryHistoryState.page = 1;
|
||||||
|
loadLibraryHistory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLibraryHistoryModal() {
|
||||||
|
const overlay = document.getElementById('library-history-overlay');
|
||||||
|
if (overlay) overlay.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchHistoryTab(tab) {
|
||||||
|
_libraryHistoryState.tab = tab;
|
||||||
|
_libraryHistoryState.page = 1;
|
||||||
|
document.querySelectorAll('.library-history-tab').forEach(t => {
|
||||||
|
t.classList.toggle('active', t.dataset.tab === tab);
|
||||||
|
});
|
||||||
|
loadLibraryHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLibraryHistory() {
|
||||||
|
const { tab, page, limit } = _libraryHistoryState;
|
||||||
|
const list = document.getElementById('library-history-list');
|
||||||
|
const pagination = document.getElementById('library-history-pagination');
|
||||||
|
if (!list) return;
|
||||||
|
list.innerHTML = '<div class="library-history-loading">Loading...</div>';
|
||||||
|
if (pagination) pagination.innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/library/history?type=${tab}&page=${page}&limit=${limit}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
// Update tab counts
|
||||||
|
const dlCount = document.getElementById('history-download-count');
|
||||||
|
const imCount = document.getElementById('history-import-count');
|
||||||
|
if (dlCount) dlCount.textContent = data.stats?.downloads || 0;
|
||||||
|
if (imCount) imCount.textContent = data.stats?.imports || 0;
|
||||||
|
|
||||||
|
if (!data.entries || data.entries.length === 0) {
|
||||||
|
const emptyIcon = tab === 'download' ? '📥' : '📚';
|
||||||
|
const emptyText = tab === 'download'
|
||||||
|
? 'No downloads recorded yet. Completed downloads will appear here.'
|
||||||
|
: 'No server imports recorded yet. New tracks from library scans will appear here.';
|
||||||
|
list.innerHTML = `<div class="library-history-empty">${emptyIcon}<br><br>${emptyText}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = data.entries.map(renderHistoryEntry).join('');
|
||||||
|
renderHistoryPagination(data.total, page, limit);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading library history:', err);
|
||||||
|
list.innerHTML = '<div class="library-history-empty">Error loading history</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistoryEntry(entry) {
|
||||||
|
const thumb = entry.thumb_url
|
||||||
|
? `<img src="${escapeHtml(entry.thumb_url)}" class="library-history-thumb" loading="lazy">`
|
||||||
|
: `<div class="library-history-thumb-placeholder">${entry.event_type === 'download' ? '📥' : '📚'}</div>`;
|
||||||
|
|
||||||
|
let badge = '';
|
||||||
|
if (entry.event_type === 'download' && entry.quality) {
|
||||||
|
badge = `<span class="library-history-badge download">${escapeHtml(entry.quality)}</span>`;
|
||||||
|
} else if (entry.event_type === 'import' && entry.server_source) {
|
||||||
|
const sourceName = { plex: 'Plex', jellyfin: 'Jellyfin', navidrome: 'Navidrome' }[entry.server_source] || entry.server_source;
|
||||||
|
badge = `<span class="library-history-badge import">${escapeHtml(sourceName)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = [entry.artist_name, entry.album_name].filter(Boolean).join(' — ');
|
||||||
|
|
||||||
|
return `<div class="library-history-entry">
|
||||||
|
${thumb}
|
||||||
|
<div class="library-history-entry-text">
|
||||||
|
<div class="library-history-entry-title">${escapeHtml(entry.title || 'Unknown')}</div>
|
||||||
|
<div class="library-history-entry-meta">${escapeHtml(meta)}</div>
|
||||||
|
</div>
|
||||||
|
${badge}
|
||||||
|
<div class="library-history-entry-time">${formatHistoryTime(entry.created_at)}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHistoryTime(isoStr) {
|
||||||
|
if (!isoStr) return '';
|
||||||
|
try {
|
||||||
|
const date = new Date(isoStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now - date;
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
if (diffMins < 1) return 'Just now';
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
|
} catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistoryPagination(total, page, limit) {
|
||||||
|
const pagination = document.getElementById('library-history-pagination');
|
||||||
|
if (!pagination) return;
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
if (totalPages <= 1) { pagination.innerHTML = ''; return; }
|
||||||
|
|
||||||
|
pagination.innerHTML = `
|
||||||
|
<button class="library-history-page-btn" onclick="changeHistoryPage(${page - 1})" ${page <= 1 ? 'disabled' : ''}>Prev</button>
|
||||||
|
<span class="library-history-page-info">Page ${page} of ${totalPages}</span>
|
||||||
|
<button class="library-history-page-btn" onclick="changeHistoryPage(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>Next</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeHistoryPage(newPage) {
|
||||||
|
if (newPage < 1) return;
|
||||||
|
_libraryHistoryState.page = newPage;
|
||||||
|
loadLibraryHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Metadata Cache Modal ────────────────────────────────────────────
|
||||||
let _mcacheCurrentTab = 'artist';
|
let _mcacheCurrentTab = 'artist';
|
||||||
let _mcachePage = 0;
|
let _mcachePage = 0;
|
||||||
let _mcacheSearchTimeout = null;
|
let _mcacheSearchTimeout = null;
|
||||||
|
|
|
||||||
|
|
@ -6491,6 +6491,322 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Section Title Row (header + button) ─────────────────────────── */
|
||||||
|
.section-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-row .section-title {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-row .section-title::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-btn {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-btn:hover {
|
||||||
|
color: rgb(var(--accent-rgb));
|
||||||
|
background: rgba(var(--accent-rgb), 0.1);
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Library History Modal ───────────────────────────────────────── */
|
||||||
|
#library-history-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#library-history-overlay.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-modal {
|
||||||
|
width: 700px;
|
||||||
|
max-width: 95vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
background: linear-gradient(135deg, rgba(26, 26, 26, 0.98) 0%, rgba(16, 16, 16, 0.99) 100%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 18px 24px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-modal-close:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-tab {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-tab:hover {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-tab.active {
|
||||||
|
color: rgb(var(--accent-rgb));
|
||||||
|
background: rgba(var(--accent-rgb), 0.12);
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-tab-count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-tab.active .library-history-tab-count {
|
||||||
|
background: rgba(var(--accent-rgb), 0.2);
|
||||||
|
color: rgb(var(--accent-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 16px;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-list::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(var(--accent-rgb), 0.25);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-left: 3px solid rgba(var(--accent-rgb), 0.15);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-entry:hover {
|
||||||
|
background: rgba(var(--accent-rgb), 0.04);
|
||||||
|
border-left-color: rgba(var(--accent-rgb), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-thumb {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-thumb-placeholder {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-entry-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-entry-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-entry-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-badge.download {
|
||||||
|
color: rgba(100, 200, 130, 0.9);
|
||||||
|
background: rgba(100, 200, 130, 0.1);
|
||||||
|
border: 1px solid rgba(100, 200, 130, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-badge.import {
|
||||||
|
color: rgba(120, 160, 230, 0.9);
|
||||||
|
background: rgba(120, 160, 230, 0.1);
|
||||||
|
border: 1px solid rgba(120, 160, 230, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-entry-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.25);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-pagination:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-page-btn {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-page-btn:hover:not(:disabled) {
|
||||||
|
color: rgb(var(--accent-rgb));
|
||||||
|
background: rgba(var(--accent-rgb), 0.1);
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-page-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-history-page-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Enhanced Progress Bar Animation */
|
/* Enhanced Progress Bar Animation */
|
||||||
.progress-bar-fill {
|
.progress-bar-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue