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:
Broque Thomas 2026-03-16 09:36:05 -07:00
parent f4af4ea7db
commit 837c5ff680
5 changed files with 650 additions and 3 deletions

View file

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

View file

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

View file

@ -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()">&times;</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">

View file

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

View file

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