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_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()
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
|
|
@ -3629,14 +3647,38 @@ class MusicDatabase:
|
|||
if file_path is None and hasattr(track_obj, 'suffix') and 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
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO tracks
|
||||
INSERT OR REPLACE INTO tracks
|
||||
(id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
""", (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source))
|
||||
|
||||
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -7806,6 +7848,70 @@ class MusicDatabase:
|
|||
logger.error(f"API: Error getting genres from {table}: {e}")
|
||||
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]]:
|
||||
"""Get recently added entities, ordered by created_at DESC."""
|
||||
table = {"artists": "artists", "albums": "albums", "tracks": "tracks"}.get(entity_type)
|
||||
|
|
|
|||
|
|
@ -1371,6 +1371,53 @@ def _emit_track_downloaded(context):
|
|||
except Exception:
|
||||
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) ---
|
||||
try:
|
||||
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}")
|
||||
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')
|
||||
def get_library_artists():
|
||||
"""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['_final_path'] = str(destination)
|
||||
_emit_track_downloaded(context)
|
||||
_record_library_history_download(context)
|
||||
return
|
||||
# --- 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}")
|
||||
|
||||
_emit_track_downloaded(context)
|
||||
_record_library_history_download(context)
|
||||
|
||||
# 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)
|
||||
|
|
@ -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)}")
|
||||
|
||||
_emit_track_downloaded(context)
|
||||
_record_library_history_download(context)
|
||||
|
||||
# RETAG DATA CAPTURE: Record completed album/single downloads for retag tool
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -957,7 +957,10 @@
|
|||
</div>
|
||||
|
||||
<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-item">
|
||||
<span class="activity-icon">📊</span>
|
||||
|
|
@ -5599,6 +5602,26 @@
|
|||
</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 -->
|
||||
<div class="mcache-modal-overlay" id="mcache-browse-modal" style="display:none;">
|
||||
<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 _mcachePage = 0;
|
||||
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 */
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
|
|
|
|||
Loading…
Reference in a new issue