@ -21,614 +21,6 @@ const importPageState = {
_albumLookup : { } , // { albumId: { id, name, artist, source } }
} ;
// ===============================
// STATS PAGE
// ===============================
let _statsRange = '7d' ;
let _statsTimelineChart = null ;
let _statsGenreChart = null ;
let _statsDbStorageChart = null ;
let _statsInitialized = false ;
function initializeStatsPage ( ) {
if ( _statsInitialized ) {
loadStatsData ( ) ;
return ;
}
_statsInitialized = true ;
// Time range buttons
const rangeContainer = document . getElementById ( 'stats-time-range' ) ;
if ( rangeContainer ) {
rangeContainer . addEventListener ( 'click' , ( e ) => {
const btn = e . target . closest ( '.stats-range-btn' ) ;
if ( ! btn ) return ;
_statsRange = btn . dataset . range ;
rangeContainer . querySelectorAll ( '.stats-range-btn' ) . forEach ( b => b . classList . remove ( 'active' ) ) ;
btn . classList . add ( 'active' ) ;
loadStatsData ( ) ;
} ) ;
}
loadStatsData ( ) ;
_updateStatsLastSynced ( ) ;
}
async function triggerStatsSync ( ) {
const btn = document . getElementById ( 'stats-sync-btn' ) ;
if ( btn ) btn . classList . add ( 'syncing' ) ;
try {
const resp = await fetch ( '/api/listening-stats/sync' , { method : 'POST' } ) ;
const data = await resp . json ( ) ;
if ( data . success ) {
showToast ( 'Syncing listening data...' , 'info' ) ;
// Wait a few seconds for the sync to complete, then reload
setTimeout ( async ( ) => {
await loadStatsData ( ) ;
_updateStatsLastSynced ( ) ;
if ( btn ) btn . classList . remove ( 'syncing' ) ;
showToast ( 'Listening stats updated' , 'success' ) ;
} , 5000 ) ;
} else {
showToast ( data . error || 'Sync failed' , 'error' ) ;
if ( btn ) btn . classList . remove ( 'syncing' ) ;
}
} catch ( e ) {
showToast ( 'Sync failed' , 'error' ) ;
if ( btn ) btn . classList . remove ( 'syncing' ) ;
}
}
async function _updateStatsLastSynced ( ) {
const el = document . getElementById ( 'stats-last-synced' ) ;
if ( ! el ) return ;
try {
const resp = await fetch ( '/api/listening-stats/status' ) ;
const data = await resp . json ( ) ;
if ( data . stats && data . stats . last _poll ) {
el . textContent = ` Last synced: ${ data . stats . last _poll } ` ;
} else {
el . textContent = 'Not synced yet' ;
}
} catch {
el . textContent = '' ;
}
}
async function loadStatsData ( ) {
// Show loading state
document . querySelectorAll ( '.stats-card-value' ) . forEach ( el => el . style . opacity = '0.3' ) ;
// Single cached endpoint — instant response
let data ;
try {
const resp = await fetch ( ` /api/stats/cached?range= ${ _statsRange } ` ) ;
data = await resp . json ( ) ;
} catch {
data = { } ;
}
if ( ! data . success ) {
// Cache not available — show empty state, user should hit Sync
data = {
overview : { } , top _artists : [ ] , top _albums : [ ] , top _tracks : [ ] ,
timeline : [ ] , genres : [ ] , recent : [ ] , health : { }
} ;
}
const overview = data . overview || { } ;
const emptyEl = document . getElementById ( 'stats-empty' ) ;
const hasData = ( overview . total _plays || 0 ) > 0 ;
if ( emptyEl ) {
emptyEl . classList . toggle ( 'hidden' , hasData ) ;
}
// Hide main content sections when no data
const mainSections = document . querySelectorAll ( '.stats-overview, .stats-main-grid, .stats-full-width' ) ;
mainSections . forEach ( el => el . style . display = hasData ? '' : 'none' ) ;
// Overview cards
const _fmt = ( n ) => {
if ( ! n ) return '0' ;
if ( n >= 1000000 ) return ( n / 1000000 ) . toFixed ( 1 ) . replace ( /\.0$/ , '' ) + 'M' ;
if ( n >= 1000 ) return ( n / 1000 ) . toFixed ( 1 ) . replace ( /\.0$/ , '' ) + 'K' ;
return n . toLocaleString ( ) ;
} ;
const _fmtTime = ( ms ) => {
if ( ! ms ) return '0h' ;
const hours = Math . floor ( ms / 3600000 ) ;
const mins = Math . floor ( ( ms % 3600000 ) / 60000 ) ;
if ( hours > 0 ) return ` ${ hours } h ${ mins } m ` ;
return ` ${ mins } m ` ;
} ;
// Restore opacity
document . querySelectorAll ( '.stats-card-value' ) . forEach ( el => el . style . opacity = '1' ) ;
_setText ( 'stats-total-plays' , _fmt ( overview . total _plays ) ) ;
_setText ( 'stats-listening-time' , _fmtTime ( overview . total _time _ms ) ) ;
_setText ( 'stats-unique-artists' , _fmt ( overview . unique _artists ) ) ;
_setText ( 'stats-unique-albums' , _fmt ( overview . unique _albums ) ) ;
_setText ( 'stats-unique-tracks' , _fmt ( overview . unique _tracks ) ) ;
// Top Artists — visual bubbles
_renderTopArtistsVisual ( data . top _artists || [ ] ) ;
// Top Artists — ranked list
_renderRankedList ( 'stats-top-artists' , data . top _artists || [ ] , ( item , i ) => `
< div class = "stats-ranked-item" >
< span class = "stats-ranked-num" > $ { i + 1 } < / s p a n >
$ { item . image _url ? ` <img class="stats-ranked-img" src=" ${ item . image _url } " alt="" onerror="this.style.display='none'"> ` : '' }
< div class = "stats-ranked-info" >
< div class = "stats-ranked-name" > $ { item . id ? ` <a class="stats-artist-link" href=" ${ buildArtistDetailPath ( item . id ) } "> ${ _esc ( item . name ) } </a> ` : _esc ( item . name ) } $ { item . soul _id && ! String ( item . soul _id ) . startsWith ( 'soul_unnamed_' ) ? ' <img src="/static/trans2.png" style="width:12px;height:12px;vertical-align:middle;opacity:0.5;" title="SoulID">' : '' } < / d i v >
< div class = "stats-ranked-meta" > $ { item . global _listeners ? _fmt ( item . global _listeners ) + ' global listeners' : '' } < / d i v >
< / d i v >
< span class = "stats-ranked-count" > $ { _fmt ( item . play _count ) } plays < / s p a n >
< / d i v >
` );
// Top Albums
_renderRankedList ( 'stats-top-albums' , data . top _albums || [ ] , ( item , i ) => `
< div class = "stats-ranked-item" >
< span class = "stats-ranked-num" > $ { i + 1 } < / s p a n >
$ { item . image _url ? ` <img class="stats-ranked-img" src=" ${ item . image _url } " alt="" onerror="this.style.display='none'"> ` : '' }
< div class = "stats-ranked-info" >
< div class = "stats-ranked-name" > $ { _esc ( item . name ) } < / d i v >
< div class = "stats-ranked-meta" > $ { item . artist _id ? ` <a class="stats-artist-link" href=" ${ buildArtistDetailPath ( item . artist _id ) } "> ${ _esc ( item . artist || '' ) } </a> ` : _esc ( item . artist || '' ) } < / d i v >
< / d i v >
< span class = "stats-ranked-count" > $ { _fmt ( item . play _count ) } plays < / s p a n >
< / d i v >
` );
// Top Tracks
_renderRankedList ( 'stats-top-tracks' , data . top _tracks || [ ] , ( item , i ) => `
< div class = "stats-ranked-item" >
< span class = "stats-ranked-num" > $ { i + 1 } < / s p a n >
$ { item . image _url ? ` <img class="stats-ranked-img" src=" ${ item . image _url } " alt="" onerror="this.style.display='none'"> ` : '' }
< div class = "stats-ranked-info" >
< div class = "stats-ranked-name" > $ { _esc ( item . name ) } < / d i v >
< div class = "stats-ranked-meta" > $ { item . artist _id ? ` <a class="stats-artist-link" href=" ${ buildArtistDetailPath ( item . artist _id ) } "> ${ _esc ( item . artist || '' ) } </a> ` : _esc ( item . artist || '' ) } $ { item . album ? ' · ' + _esc ( item . album ) : '' } < / d i v >
< / d i v >
< button class = "stats-play-btn" onclick = "event.stopPropagation();playStatsTrack('${_esc(item.name).replace(/'/g, " \ \ '")}' , '${_esc(item.artist || ' ').replace(/' / g , "\\'" ) } ',' $ { _esc ( item . album || '' ) . replace ( /'/g , "\\'" ) } ' ) " title=" Play " > ▶ < / b u t t o n >
< span class = "stats-ranked-count" > $ { _fmt ( item . play _count ) } plays < / s p a n >
< / d i v >
` );
// Timeline chart
_renderTimelineChart ( data . timeline || [ ] ) ;
// Genre chart
_renderGenreChart ( data . genres || [ ] ) ;
// Library health
_renderLibraryHealth ( data . health || { } ) ;
// DB storage chart (separate fetch — not part of cached stats)
_loadDbStorageChart ( ) ;
// Library disk usage (separate fetch — populated by deep scan)
_loadLibraryDiskUsage ( ) ;
// Recent plays
_renderRecentPlays ( data . recent || [ ] ) ;
}
function _renderTopArtistsVisual ( artists ) {
const el = document . getElementById ( 'stats-top-artists-visual' ) ;
if ( ! el || ! artists . length ) { if ( el ) el . innerHTML = '' ; return ; }
const top5 = artists . slice ( 0 , 5 ) ;
const maxPlays = top5 [ 0 ] ? . play _count || 1 ;
const _fmt = ( n ) => {
if ( ! n ) return '0' ;
if ( n >= 1000000 ) return ( n / 1000000 ) . toFixed ( 1 ) . replace ( /\.0$/ , '' ) + 'M' ;
if ( n >= 1000 ) return ( n / 1000 ) . toFixed ( 1 ) . replace ( /\.0$/ , '' ) + 'K' ;
return n . toString ( ) ;
} ;
el . innerHTML = ` <div class="stats-artist-bubbles">
$ { top5 . map ( ( a , i ) => {
const pct = Math . round ( ( a . play _count / maxPlays ) * 100 ) ;
const size = 44 + ( 4 - i ) * 6 ; // Largest first: 68, 62, 56, 50, 44
return ` <a class="stats-artist-bubble" href=" ${ a . id ? buildArtistDetailPath ( a . id , a . source || null ) : '#' } " style="cursor: ${ a . id ? 'pointer' : 'default' } ;text-decoration:none;color:inherit;">
< div class = "stats-bubble-img" style = "width:${size}px;height:${size}px;${a.image_url ? `background-image:url('${a.image_url}')` : ''}" >
$ { ! a . image _url ? ` <span> ${ ( a . name || '?' ) [ 0 ] } </span> ` : '' }
< / d i v >
< div class = "stats-bubble-bar-container" >
< div class = "stats-bubble-bar" style = "width:${pct}%" > < / d i v >
< / d i v >
< div class = "stats-bubble-name" > $ { _esc ( a . name ) } < / d i v >
< div class = "stats-bubble-count" > $ { _fmt ( a . play _count ) } < / d i v >
< / a > ` ;
} ) . join ( '' ) }
< / d i v > ` ;
}
function _setText ( id , text ) {
const el = document . getElementById ( id ) ;
if ( el ) el . textContent = text ;
}
function _renderRankedList ( containerId , items , template ) {
const el = document . getElementById ( containerId ) ;
if ( ! el ) return ;
el . innerHTML = items . length
? items . map ( ( item , i ) => template ( item , i ) ) . join ( '' )
: '<div style="color:rgba(255,255,255,0.3);font-size:0.85em;padding:12px;">No data yet</div>' ;
}
function _renderTimelineChart ( data ) {
const canvas = document . getElementById ( 'stats-timeline-chart' ) ;
if ( ! canvas || typeof Chart === 'undefined' ) return ;
if ( _statsTimelineChart ) _statsTimelineChart . destroy ( ) ;
_statsTimelineChart = new Chart ( canvas , {
type : 'bar' ,
data : {
labels : data . map ( d => d . date ) ,
datasets : [ {
label : 'Plays' ,
data : data . map ( d => d . plays ) ,
backgroundColor : ` rgba( ${ getComputedStyle ( document . documentElement ) . getPropertyValue ( '--accent-rgb' ) . trim ( ) || '29,185,84' } , 0.5) ` ,
borderColor : ` rgba( ${ getComputedStyle ( document . documentElement ) . getPropertyValue ( '--accent-rgb' ) . trim ( ) || '29,185,84' } , 0.8) ` ,
borderWidth : 1 ,
borderRadius : 4 ,
} ]
} ,
options : {
responsive : true ,
maintainAspectRatio : false ,
plugins : { legend : { display : false } } ,
scales : {
x : { grid : { display : false } , ticks : { color : 'rgba(255,255,255,0.3)' , font : { size : 10 } , maxTicksLimit : 12 } } ,
y : { grid : { color : 'rgba(255,255,255,0.04)' } , ticks : { color : 'rgba(255,255,255,0.3)' , font : { size : 10 } } , beginAtZero : true } ,
}
}
} ) ;
}
function _renderGenreChart ( data ) {
const canvas = document . getElementById ( 'stats-genre-chart' ) ;
const legend = document . getElementById ( 'stats-genre-legend' ) ;
if ( ! canvas || typeof Chart === 'undefined' ) return ;
if ( _statsGenreChart ) _statsGenreChart . destroy ( ) ;
const colors = [
'#1db954' , '#1ed760' , '#4ade80' , '#7c3aed' , '#a855f7' ,
'#ec4899' , '#f43f5e' , '#f97316' , '#eab308' , '#06b6d4' ,
'#3b82f6' , '#6366f1' , '#14b8a6' , '#84cc16' , '#f59e0b' ,
] ;
const top = data . slice ( 0 , 10 ) ;
_statsGenreChart = new Chart ( canvas , {
type : 'doughnut' ,
data : {
labels : top . map ( g => g . genre ) ,
datasets : [ {
data : top . map ( g => g . play _count ) ,
backgroundColor : colors . slice ( 0 , top . length ) ,
borderWidth : 0 ,
hoverOffset : 6 ,
} ]
} ,
options : {
responsive : true ,
maintainAspectRatio : true ,
cutout : '65%' ,
plugins : { legend : { display : false } } ,
}
} ) ;
if ( legend ) {
legend . innerHTML = top . map ( ( g , i ) => `
< div class = "stats-genre-legend-item" >
< span class = "stats-genre-dot" style = "background:${colors[i]}" > < / s p a n >
< span > $ { g . genre } < / s p a n >
< span class = "stats-genre-pct" > $ { g . percentage } % < / s p a n >
< / d i v >
` ).join('');
}
}
function _renderLibraryHealth ( data ) {
if ( ! data || ! data . total _tracks ) return ;
const _fmt = ( n ) => {
if ( ! n ) return '0' ;
if ( n >= 1000000 ) return ( n / 1000000 ) . toFixed ( 1 ) + 'M' ;
if ( n >= 1000 ) return ( n / 1000 ) . toFixed ( 1 ) + 'K' ;
return n . toLocaleString ( ) ;
} ;
_setText ( 'stats-unplayed' , ` ${ _fmt ( data . unplayed _count ) } ( ${ data . unplayed _percentage || 0 } %) ` ) ;
_setText ( 'stats-total-duration' , data . total _duration _ms ? ` ${ Math . floor ( data . total _duration _ms / 3600000 ) } h ` : '0h' ) ;
_setText ( 'stats-total-tracks-count' , _fmt ( data . total _tracks ) ) ;
// Format bar
const bar = document . getElementById ( 'stats-format-bar' ) ;
if ( bar && data . format _breakdown ) {
const total = Object . values ( data . format _breakdown ) . reduce ( ( s , v ) => s + v , 0 ) || 1 ;
const fmtColors = { FLAC : '#3b82f6' , MP3 : '#f97316' , Opus : '#a855f7' , AAC : '#14b8a6' , OGG : '#eab308' , WAV : '#ec4899' , Other : '#555' } ;
bar . innerHTML = Object . entries ( data . format _breakdown ) . map ( ( [ fmt , count ] ) => {
const pct = ( count / total * 100 ) . toFixed ( 1 ) ;
return ` <div class="stats-format-segment" style="flex: ${ count } ;background: ${ fmtColors [ fmt ] || '#555' } " title=" ${ fmt } : ${ count } tracks ( ${ pct } %)"> ${ pct > 8 ? fmt : '' } </div> ` ;
} ) . join ( '' ) ;
}
// Enrichment coverage
const enrichEl = document . getElementById ( 'stats-enrichment-coverage' ) ;
if ( enrichEl && data . enrichment _coverage ) {
const ec = data . enrichment _coverage ;
const services = [
{ name : 'Spotify' , pct : ec . spotify || 0 , color : '#1db954' } ,
{ name : 'MusicBrainz' , pct : ec . musicbrainz || 0 , color : '#ba55d3' } ,
{ name : 'Deezer' , pct : ec . deezer || 0 , color : '#a238ff' } ,
{ name : 'Last.fm' , pct : ec . lastfm || 0 , color : '#d51007' } ,
{ name : 'iTunes' , pct : ec . itunes || 0 , color : '#fc3c44' } ,
{ name : 'AudioDB' , pct : ec . audiodb || 0 , color : '#1a9fff' } ,
{ name : 'Genius' , pct : ec . genius || 0 , color : '#ffff64' } ,
{ name : 'Tidal' , pct : ec . tidal || 0 , color : '#00ffff' } ,
{ name : 'Qobuz' , pct : ec . qobuz || 0 , color : '#4285f4' } ,
] ;
enrichEl . innerHTML = services . map ( s => `
< div class = "stats-enrich-item" >
< span class = "stats-enrich-name" > $ { s . name } < / s p a n >
< div class = "stats-enrich-bar" > < div class = "stats-enrich-fill" style = "width:${s.pct}%;background:${s.color}" > < / d i v > < / d i v >
< span class = "stats-enrich-pct" > $ { s . pct } % < / s p a n >
< / d i v >
` ).join('');
}
}
async function _loadDbStorageChart ( ) {
try {
const resp = await fetch ( '/api/stats/db-storage' ) ;
const data = await resp . json ( ) ;
if ( ! data . success || ! data . tables || ! data . tables . length ) return ;
_renderDbStorageChart ( data . tables , data . total _file _size , data . method ) ;
} catch ( e ) {
console . debug ( 'DB storage chart load failed:' , e ) ;
}
}
async function _loadLibraryDiskUsage ( ) {
try {
const resp = await fetch ( '/api/stats/library-disk-usage' ) ;
const data = await resp . json ( ) ;
if ( ! data . success ) return ;
_renderLibraryDiskUsage ( data ) ;
} catch ( e ) {
console . debug ( 'Library disk usage load failed:' , e ) ;
}
}
function _formatBytes ( n ) {
if ( ! n || n <= 0 ) return '0 B' ;
const units = [ 'B' , 'KB' , 'MB' , 'GB' , 'TB' ] ;
let i = 0 ;
let v = n ;
while ( v >= 1024 && i < units . length - 1 ) { v /= 1024 ; i ++ ; }
return ` ${ v . toFixed ( v < 10 ? 2 : 1 ) } ${ units [ i ] } ` ;
}
function _renderLibraryDiskUsage ( data ) {
const totalEl = document . getElementById ( 'stats-disk-total-value' ) ;
const metaEl = document . getElementById ( 'stats-disk-total-meta' ) ;
const formatsEl = document . getElementById ( 'stats-disk-formats' ) ;
if ( ! totalEl || ! metaEl || ! formatsEl ) return ;
if ( ! data . has _data || ! data . total _bytes ) {
totalEl . textContent = '—' ;
metaEl . textContent = data . tracks _without _size > 0
? ` Run a Deep Scan to populate ( ${ data . tracks _without _size . toLocaleString ( ) } tracks pending) `
: 'No tracks in library yet' ;
formatsEl . innerHTML = '' ;
return ;
}
totalEl . textContent = _formatBytes ( data . total _bytes ) ;
const withSize = data . tracks _with _size || 0 ;
const withoutSize = data . tracks _without _size || 0 ;
const trackBits = ` ${ withSize . toLocaleString ( ) } tracks measured ` ;
const pendingBits = withoutSize > 0
? ` (+ ${ withoutSize . toLocaleString ( ) } pending next Deep Scan) `
: '' ;
metaEl . textContent = trackBits + pendingBits ;
// Per-format bars sorted by size descending. Skip if no breakdown.
const formats = Object . entries ( data . by _format || { } ) . sort ( ( a , b ) => b [ 1 ] - a [ 1 ] ) ;
if ( ! formats . length ) { formatsEl . innerHTML = '' ; return ; }
const max = formats [ 0 ] [ 1 ] || 1 ;
formatsEl . innerHTML = formats . map ( ( [ ext , bytes ] ) => {
const pct = Math . max ( 2 , Math . round ( ( bytes / max ) * 100 ) ) ;
return `
< div class = "stats-disk-format-row" >
< span class = "stats-disk-format-name" > $ { ext . toUpperCase ( ) } < / s p a n >
< div class = "stats-disk-format-bar" >
< div class = "stats-disk-format-fill" style = "width:${pct}%" > < / d i v >
< / d i v >
< span class = "stats-disk-format-size" > $ { _formatBytes ( bytes ) } < / s p a n >
< / d i v >
` ;
} ) . join ( '' ) ;
}
function _renderDbStorageChart ( tables , totalFileSize , method ) {
const canvas = document . getElementById ( 'stats-db-storage-chart' ) ;
if ( ! canvas || typeof Chart === 'undefined' ) return ;
if ( _statsDbStorageChart ) _statsDbStorageChart . destroy ( ) ;
// Top 8 tables, group rest as "Other"
const top = tables . slice ( 0 , 8 ) ;
const rest = tables . slice ( 8 ) ;
const restSize = rest . reduce ( ( s , t ) => s + t . size , 0 ) ;
if ( restSize > 0 ) top . push ( { name : 'Other' , size : restSize } ) ;
const colors = [ '#3b82f6' , '#f97316' , '#a855f7' , '#14b8a6' , '#eab308' , '#ec4899' , '#6366f1' , '#22c55e' , '#555' ] ;
_statsDbStorageChart = new Chart ( canvas , {
type : 'doughnut' ,
data : {
labels : top . map ( t => t . name ) ,
datasets : [ {
data : top . map ( t => t . size ) ,
backgroundColor : colors . slice ( 0 , top . length ) ,
borderWidth : 0 ,
hoverOffset : 4 ,
} ] ,
} ,
options : {
responsive : false ,
cutout : '65%' ,
plugins : {
legend : { display : false } ,
tooltip : {
callbacks : {
label : ( ctx ) => {
const val = ctx . parsed ;
if ( method === 'dbstat' ) {
if ( val > 1048576 ) return ` ${ ( val / 1048576 ) . toFixed ( 1 ) } MB ` ;
return ` ${ ( val / 1024 ) . toFixed ( 0 ) } KB ` ;
}
return ` ${ val . toLocaleString ( ) } rows ` ;
}
}
}
} ,
} ,
} ) ;
// Center label — total file size
const totalEl = document . getElementById ( 'stats-db-total' ) ;
if ( totalEl ) {
let sizeStr ;
if ( totalFileSize > 1073741824 ) sizeStr = ( totalFileSize / 1073741824 ) . toFixed ( 2 ) + ' GB' ;
else if ( totalFileSize > 1048576 ) sizeStr = ( totalFileSize / 1048576 ) . toFixed ( 1 ) + ' MB' ;
else sizeStr = ( totalFileSize / 1024 ) . toFixed ( 0 ) + ' KB' ;
totalEl . innerHTML = ` <div class="stats-db-total-value"> ${ sizeStr } </div><div class="stats-db-total-label">Total Size</div> ` ;
}
// Legend
const legendEl = document . getElementById ( 'stats-db-legend' ) ;
if ( legendEl ) {
legendEl . innerHTML = top . map ( ( t , i ) => {
let sizeLabel ;
if ( method === 'dbstat' ) {
if ( t . size > 1048576 ) sizeLabel = ( t . size / 1048576 ) . toFixed ( 1 ) + ' MB' ;
else sizeLabel = ( t . size / 1024 ) . toFixed ( 0 ) + ' KB' ;
} else {
sizeLabel = t . size . toLocaleString ( ) + ' rows' ;
}
return ` <div class="stats-db-legend-item">
< span class = "stats-db-legend-dot" style = "background:${colors[i]}" > < / s p a n >
< span class = "stats-db-legend-name" > $ { t . name } < / s p a n >
< span class = "stats-db-legend-size" > $ { sizeLabel } < / s p a n >
< / d i v > ` ;
} ) . join ( '' ) ;
}
}
async function playStatsTrack ( title , artist , album ) {
// 1. Try the library first — fastest and best quality if owned.
try {
const resp = await fetch ( '/api/stats/resolve-track' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { title , artist } ) ,
} ) ;
const data = await resp . json ( ) ;
if ( data . success && data . track ) {
const t = data . track ;
playLibraryTrack ( {
id : t . id ,
title : t . title ,
file _path : t . file _path ,
bitrate : t . bitrate ,
artist _id : t . artist _id ,
album _id : t . album _id ,
_stats _image : t . image _url || null ,
} , t . album _title || album || '' , t . artist _name || artist || '' ) ;
return ;
}
} catch ( e ) {
console . debug ( 'Library resolve failed, will try streaming fallback:' , e ) ;
}
// 2. Library miss — fall back to streaming via the enhanced-search streamer
// (Soulseek → YouTube → other configured sources, same pipeline used by
// the search results' play button).
if ( typeof showLoadingOverlay === 'function' ) {
showLoadingOverlay ( ` Searching for ${ title } ... ` ) ;
}
try {
const streamResp = await fetch ( '/api/enhanced-search/stream-track' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( {
track _name : title ,
artist _name : artist ,
album _name : album || '' ,
duration _ms : 0 ,
} ) ,
} ) ;
const streamData = await streamResp . json ( ) ;
if ( typeof hideLoadingOverlay === 'function' ) hideLoadingOverlay ( ) ;
if ( streamData . success && streamData . result ) {
if ( typeof startStream === 'function' ) {
await startStream ( streamData . result ) ;
} else {
showToast ( 'Streaming not available' , 'error' ) ;
}
} else {
showToast ( streamData . error || 'Track not found in library or any source' , 'error' ) ;
}
} catch ( e ) {
if ( typeof hideLoadingOverlay === 'function' ) hideLoadingOverlay ( ) ;
showToast ( 'Failed to play track' , 'error' ) ;
console . error ( 'Stream fallback failed:' , e ) ;
}
}
function _renderRecentPlays ( tracks ) {
const el = document . getElementById ( 'stats-recent-plays' ) ;
if ( ! el ) return ;
if ( ! tracks . length ) {
el . innerHTML = '<div style="color:rgba(255,255,255,0.3);font-size:0.85em;padding:12px;">No recent plays</div>' ;
return ;
}
const _ago = ( dateStr ) => {
if ( ! dateStr ) return '' ;
const diff = Date . now ( ) - new Date ( dateStr ) . getTime ( ) ;
const mins = Math . floor ( diff / 60000 ) ;
if ( mins < 60 ) return ` ${ mins } m ago ` ;
const hours = Math . floor ( mins / 60 ) ;
if ( hours < 24 ) return ` ${ hours } h ago ` ;
const days = Math . floor ( hours / 24 ) ;
if ( days < 30 ) return ` ${ days } d ago ` ;
return ` ${ Math . floor ( days / 30 ) } mo ago ` ;
} ;
el . innerHTML = tracks . map ( t => `
< div class = "stats-recent-item" >
< button class = "stats-play-btn stats-play-btn-sm" onclick = "event.stopPropagation();playStatsTrack('${_esc(t.title).replace(/'/g, " \ \ '")}' , '${_esc(t.artist || ' ').replace(/' / g , "\\'" ) } ',' $ { _esc ( t . album || '' ) . replace ( /'/g , "\\'" ) } ' ) " title=" Play " > ▶ < / b u t t o n >
< span class = "stats-recent-title" > $ { _esc ( t . title ) } < / s p a n >
< span class = "stats-recent-artist" > $ { _esc ( t . artist || '' ) } < / s p a n >
< span class = "stats-recent-time" > $ { _ago ( t . played _at ) } < / s p a n >
< / d i v >
` ).join('');
}
// --- Initialization ---
function initializeImportPage ( ) {