@@ -356,11 +356,14 @@ <h1 class="text-lg font-bold text-white leading-tight">Librarr</h1>
356356 < div class ="flex items-center justify-between mb-6 ">
357357 < h2 class ="text-lg font-semibold text-white " data-i18n ="downloads_title "> Downloads</ h2 >
358358 < div class ="flex gap-2 ">
359- < button onclick ="refreshDownloads() " class ="px-3 py-1.5 text-sm bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg transition-colors flex items-center gap-1.5 ">
360- < svg class ="w-4 h-4 " fill ="none " stroke ="currentColor " viewBox ="0 0 24 24 "> < path stroke-linecap ="round " stroke-linejoin ="round " stroke-width ="2 " d ="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15 "/> </ svg >
359+ < button id =" downloads-refresh-btn " onclick ="refreshDownloads(true ) " class ="px-3 py-1.5 text-sm bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg transition-colors flex items-center gap-1.5 ">
360+ < svg id =" downloads-refresh-icon " class ="w-4 h-4 " fill ="none " stroke ="currentColor " viewBox ="0 0 24 24 " aria-hidden =" true "> < path stroke-linecap ="round " stroke-linejoin ="round " stroke-width ="2 " d ="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15 "/> </ svg >
361361 < span data-i18n ="refresh "> Refresh</ span >
362362 </ button >
363- < button onclick ="clearCompleted() " class ="px-3 py-1.5 text-sm bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg transition-colors " data-i18n ="clear_completed "> Clear Completed</ button >
363+ < button id ="downloads-clear-btn " onclick ="clearCompleted() " class ="px-3 py-1.5 text-sm bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg transition-colors flex items-center gap-1.5 ">
364+ < svg id ="downloads-clear-icon " class ="w-4 h-4 hidden spin " viewBox ="0 0 24 24 " fill ="none " aria-hidden ="true "> < circle class ="opacity-25 " cx ="12 " cy ="12 " r ="9 " stroke ="currentColor " stroke-width ="2 "> </ circle > < path class ="opacity-90 " fill ="currentColor " d ="M12 3a9 9 0 0 1 9 9h-2.5A6.5 6.5 0 0 0 12 5.5V3z "> </ path > </ svg >
365+ < span data-i18n ="clear_completed "> Clear Completed</ span >
366+ </ button >
364367 </ div >
365368 </ div >
366369
@@ -832,6 +835,8 @@ <h3 class="text-sm font-semibold text-slate-300 uppercase tracking-wider mb-4" d
832835 no_results : 'No results found' ,
833836 no_results_hint : 'Try different keywords or check your spelling' ,
834837 download : 'Download' ,
838+ download_added : 'Added' ,
839+ download_failed_state : 'Failed' ,
835840 search_failed : 'Search failed: {msg}' ,
836841 n_seeds : '{n} seed' ,
837842 n_leech : '{n} leech' ,
@@ -1024,6 +1029,8 @@ <h3 class="text-sm font-semibold text-slate-300 uppercase tracking-wider mb-4" d
10241029 no_results : 'Ничего не найдено' ,
10251030 no_results_hint : 'Попробуйте другие ключевые слова или проверьте написание' ,
10261031 download : 'Скачать' ,
1032+ download_added : 'Добавлено' ,
1033+ download_failed_state : 'Ошибка' ,
10271034 search_failed : 'Ошибка поиска: {msg}' ,
10281035 n_seeds : '{n} сид.' ,
10291036 n_leech : '{n} лич.' ,
@@ -1222,6 +1229,11 @@ <h3 class="text-sm font-semibold text-slate-300 uppercase tracking-wider mb-4" d
12221229 searchTab : 'ebooks' ,
12231230 libraryTab : 'ebooks' ,
12241231 searchResults : [ ] ,
1232+ pendingDownloads : new Set ( ) ,
1233+ downloadOutcomes : new Map ( ) ,
1234+ downloadOutcomeTimers : new Map ( ) ,
1235+ downloadJobs : [ ] ,
1236+ pendingRetryDownloads : new Set ( ) ,
12251237 sortMode : 'relevance' ,
12261238 libraryPage : 1 ,
12271239 libraryPages : 1 ,
@@ -1772,6 +1784,9 @@ <h3 class="text-sm font-semibold text-slate-300 uppercase tracking-wider mb-4" d
17721784
17731785function renderBookCard ( result , index ) {
17741786 const src = SOURCE_COLORS [ result . source ] || { bg : '#475569' , text : 'white' , label : result . source || 'Unknown' } ;
1787+ const downloadKey = getDownloadKey ( result ) ;
1788+ const isDownloading = state . pendingDownloads . has ( downloadKey ) ;
1789+ const downloadOutcome = state . downloadOutcomes . get ( downloadKey ) ;
17751790 const coverHtml = result . cover_url
17761791 ? `<img src="${ escapeHtml ( result . cover_url ) } " alt="" class="w-full h-48 object-cover" loading="lazy" onerror="this.outerHTML=makePlaceholder('${ escapeHtml ( result . title || '' ) } ', ${ index } )">`
17771792 : makePlaceholderHtml ( result . title || '?' , index ) ;
@@ -1783,6 +1798,26 @@ <h3 class="text-sm font-semibold text-slate-300 uppercase tracking-wider mb-4" d
17831798 const format = result . format ? `<span class="text-slate-500 text-xs uppercase">${ escapeHtml ( result . format ) } </span>` : '' ;
17841799 const indexer = result . indexer ? `<span class="text-slate-600 text-xs">${ escapeHtml ( result . indexer ) } </span>` : '' ;
17851800
1801+ const buttonState = isDownloading ? 'loading' : ( downloadOutcome ? downloadOutcome . status : 'idle' ) ;
1802+ const buttonStyles = {
1803+ idle : 'bg-indigo-600 hover:bg-indigo-500 text-white' ,
1804+ loading : 'bg-indigo-500/70 text-white cursor-not-allowed' ,
1805+ success : 'bg-emerald-600 hover:bg-emerald-500 text-white cursor-default' ,
1806+ error : 'bg-rose-600 hover:bg-rose-500 text-white cursor-pointer' ,
1807+ } ;
1808+ const buttonText = {
1809+ idle : t ( 'download' ) ,
1810+ loading : t ( 'loading' ) ,
1811+ success : t ( 'download_added' ) ,
1812+ error : t ( 'download_failed_state' ) ,
1813+ } ;
1814+ const buttonIcon = {
1815+ idle : `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>` ,
1816+ loading : `<svg class="w-4 h-4 spin" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle class="opacity-25" cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"></circle><path class="opacity-90" fill="currentColor" d="M12 3a9 9 0 0 1 9 9h-2.5A6.5 6.5 0 0 0 12 5.5V3z"></path></svg>` ,
1817+ success : `<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/></svg>` ,
1818+ error : `<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 8v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/></svg>` ,
1819+ } ;
1820+
17861821 return `
17871822 <div class="book-card bg-slate-900 rounded-xl overflow-hidden border border-slate-800 hover:border-slate-600 flex flex-col">
17881823 <div class="relative">
@@ -1795,9 +1830,13 @@ <h3 class="text-sm font-semibold text-white line-clamp-2 mb-1" title="${escapeHt
17951830 <div class="flex items-center gap-2 flex-wrap mt-auto mb-2">
17961831 ${ seeders } ${ leechers } ${ size } ${ format } ${ indexer }
17971832 </div>
1798- <button onclick='startDownload(${ JSON . stringify ( result ) . replace ( / ' / g, "'" ) } )' class="w-full bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium py-1.5 rounded-lg transition-colors flex items-center justify-center gap-1.5">
1799- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
1800- ${ t ( 'download' ) }
1833+ <button
1834+ onclick='startDownload(${ JSON . stringify ( result ) . replace ( / ' / g, "'" ) } )'
1835+ ${ buttonState === 'idle' || buttonState === 'error' ? '' : 'disabled aria-busy="true"' }
1836+ class="w-full ${ buttonStyles [ buttonState ] || buttonStyles . idle } text-white text-sm font-medium py-1.5 rounded-lg transition-colors flex items-center justify-center gap-1.5 disabled:opacity-100"
1837+ >
1838+ ${ buttonIcon [ buttonState ] || buttonIcon . idle }
1839+ ${ buttonText [ buttonState ] || buttonText . idle }
18011840 </button>
18021841 </div>
18031842 </div>
@@ -1828,14 +1867,50 @@ <h3 class="text-sm font-semibold text-white line-clamp-2 mb-1" title="${escapeHt
18281867 return '' ;
18291868}
18301869
1870+ function getDownloadKey ( result ) {
1871+ return [
1872+ result . source || '' ,
1873+ result . download_url || '' ,
1874+ result . url || '' ,
1875+ result . abb_url || '' ,
1876+ result . info_hash || '' ,
1877+ result . magnet || '' ,
1878+ result . md5 || '' ,
1879+ result . title || '' ,
1880+ result . author || '' ,
1881+ ] . join ( '|' ) ;
1882+ }
1883+
1884+ function setDownloadOutcome ( downloadKey , status ) {
1885+ const prevTimer = state . downloadOutcomeTimers . get ( downloadKey ) ;
1886+ if ( prevTimer ) clearTimeout ( prevTimer ) ;
1887+
1888+ state . downloadOutcomes . set ( downloadKey , { status } ) ;
1889+ renderSearchResults ( ) ;
1890+
1891+ const timer = setTimeout ( ( ) => {
1892+ state . downloadOutcomes . delete ( downloadKey ) ;
1893+ state . downloadOutcomeTimers . delete ( downloadKey ) ;
1894+ renderSearchResults ( ) ;
1895+ } , 2500 ) ;
1896+ state . downloadOutcomeTimers . set ( downloadKey , timer ) ;
1897+ }
1898+
18311899// ============================================================
18321900// DOWNLOAD
18331901// ============================================================
18341902async function startDownload ( result ) {
1903+ const downloadKey = getDownloadKey ( result ) ;
1904+ if ( state . pendingDownloads . has ( downloadKey ) ) return ;
1905+
1906+ state . pendingDownloads . add ( downloadKey ) ;
1907+ renderSearchResults ( ) ;
1908+
18351909 try {
18361910 const body = {
18371911 title : result . title ,
18381912 download_url : result . download_url || result . url || '' ,
1913+ abb_url : result . abb_url || '' ,
18391914 source : result . source ,
18401915 md5 : result . md5 || '' ,
18411916 author : result . author || '' ,
@@ -1849,57 +1924,97 @@ <h3 class="text-sm font-semibold text-white line-clamp-2 mb-1" title="${escapeHt
18491924 } ) ;
18501925
18511926 if ( data . success || data . job_id ) {
1927+ setDownloadOutcome ( downloadKey , 'success' ) ;
18521928 showToast ( t ( 'download_started' , { title : result . title } ) , 'success' ) ;
18531929 } else {
1930+ setDownloadOutcome ( downloadKey , 'error' ) ;
18541931 showToast ( t ( 'download_failed' , { msg : data . error || t ( 'unknown_error' ) } ) , 'error' ) ;
18551932 }
18561933 } catch ( err ) {
18571934 if ( err . message !== 'Unauthorized' ) {
1935+ setDownloadOutcome ( downloadKey , 'error' ) ;
18581936 showToast ( t ( 'download_failed' , { msg : err . message } ) , 'error' ) ;
18591937 }
1938+ } finally {
1939+ state . pendingDownloads . delete ( downloadKey ) ;
1940+ renderSearchResults ( ) ;
18601941 }
18611942}
18621943
1863- async function refreshDownloads ( ) {
1864- try {
1865- const data = await apiJson ( '/api/downloads' ) ;
1866- const jobs = data . jobs || [ ] ;
1867- const container = document . getElementById ( 'downloads-list' ) ;
1868- const emptyEl = document . getElementById ( 'downloads-empty' ) ;
1869-
1870- // Update badge
1871- const activeCount = jobs . filter ( j => j . status === 'downloading' || j . status === 'queued' || j . status === 'searching' || j . status === 'organizing' || j . status === 'importing' ) . length ;
1872- const badge = document . getElementById ( 'dl-badge' ) ;
1873- if ( activeCount > 0 ) {
1874- badge . textContent = activeCount ;
1875- badge . classList . remove ( 'hidden' ) ;
1876- } else {
1877- badge . classList . add ( 'hidden' ) ;
1878- }
1944+ function setDownloadsRefreshLoading ( loading ) {
1945+ const button = document . getElementById ( 'downloads-refresh-btn' ) ;
1946+ const icon = document . getElementById ( 'downloads-refresh-icon' ) ;
1947+ if ( ! button || ! icon ) return ;
18791948
1880- if ( jobs . length === 0 ) {
1881- container . innerHTML = '' ;
1882- emptyEl . classList . remove ( 'hidden' ) ;
1883- return ;
1884- }
1949+ button . disabled = loading ;
1950+ if ( loading ) {
1951+ button . setAttribute ( 'aria-busy' , 'true' ) ;
1952+ icon . classList . add ( 'spin' ) ;
1953+ } else {
1954+ button . removeAttribute ( 'aria-busy' ) ;
1955+ icon . classList . remove ( 'spin' ) ;
1956+ }
1957+ }
18851958
1886- emptyEl . classList . add ( 'hidden' ) ;
1887- container . innerHTML = jobs . map ( renderDownloadJob ) . join ( '' ) ;
1959+ async function refreshDownloads ( manual = false ) {
1960+ if ( manual ) setDownloadsRefreshLoading ( true ) ;
1961+
1962+ try {
1963+ const data = await apiJson ( '/api/downloads' ) ;
1964+ state . downloadJobs = data . jobs || [ ] ;
1965+ renderDownloadList ( ) ;
18881966 } catch ( err ) {
18891967 if ( err . message !== 'Unauthorized' ) {
18901968 showToast ( t ( 'failed_load_downloads' ) , 'error' ) ;
18911969 }
1970+ } finally {
1971+ if ( manual ) setDownloadsRefreshLoading ( false ) ;
1972+ }
1973+ }
1974+
1975+ function renderDownloadList ( ) {
1976+ const jobs = state . downloadJobs || [ ] ;
1977+ const container = document . getElementById ( 'downloads-list' ) ;
1978+ const emptyEl = document . getElementById ( 'downloads-empty' ) ;
1979+
1980+ // Update badge
1981+ const activeCount = jobs . filter ( j => j . status === 'downloading' || j . status === 'queued' || j . status === 'searching' || j . status === 'organizing' || j . status === 'importing' ) . length ;
1982+ const badge = document . getElementById ( 'dl-badge' ) ;
1983+ if ( activeCount > 0 ) {
1984+ badge . textContent = activeCount ;
1985+ badge . classList . remove ( 'hidden' ) ;
1986+ } else {
1987+ badge . classList . add ( 'hidden' ) ;
18921988 }
1989+
1990+ if ( jobs . length === 0 ) {
1991+ container . innerHTML = '' ;
1992+ emptyEl . classList . remove ( 'hidden' ) ;
1993+ return ;
1994+ }
1995+
1996+ emptyEl . classList . add ( 'hidden' ) ;
1997+ container . innerHTML = jobs . map ( renderDownloadJob ) . join ( '' ) ;
18931998}
18941999
18952000function renderDownloadJob ( job ) {
18962001 const st = STATUS_STYLES [ job . status ] || STATUS_STYLES . queued ;
18972002 const progress = job . progress || 0 ;
18982003 const showProgress = job . status === 'downloading' && progress > 0 ;
2004+ const retryKey = String ( job . job_id ) ;
2005+ const retryPending = state . pendingRetryDownloads . has ( retryKey ) ;
18992006
19002007 let actions = '' ;
19012008 if ( job . status === 'error' || job . status === 'dead_letter' ) {
1902- actions = `<button onclick="retryDownload('${ escapeHtml ( job . job_id ) } ')" class="px-2.5 py-1 text-xs bg-slate-700 hover:bg-slate-600 text-slate-300 rounded transition-colors">${ t ( 'retry' ) } </button>` ;
2009+ actions = `
2010+ <button
2011+ onclick="retryDownload('${ escapeHtml ( job . job_id ) } ')"
2012+ ${ retryPending ? 'disabled aria-busy="true"' : '' }
2013+ class="px-2.5 py-1 text-xs bg-slate-700 hover:bg-slate-600 text-slate-300 rounded transition-colors flex items-center gap-1 disabled:opacity-100"
2014+ >
2015+ ${ retryPending ? '<svg class="w-3.5 h-3.5 spin" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle class="opacity-25" cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"></circle><path class="opacity-90" fill="currentColor" d="M12 3a9 9 0 0 1 9 9h-2.5A6.5 6.5 0 0 0 12 5.5V3z"></path></svg>' : '' }
2016+ <span>${ retryPending ? t ( 'loading' ) : t ( 'retry' ) } </span>
2017+ </button>` ;
19032018 }
19042019
19052020 return `
@@ -1927,22 +2042,45 @@ <h4 class="text-sm font-medium text-white truncate" title="${escapeHtml(job.titl
19272042}
19282043
19292044async function retryDownload ( jobId ) {
2045+ const key = String ( jobId ) ;
2046+ if ( state . pendingRetryDownloads . has ( key ) ) return ;
2047+
2048+ state . pendingRetryDownloads . add ( key ) ;
2049+ renderDownloadList ( ) ;
2050+
19302051 try {
19312052 await apiJson ( `/api/downloads/jobs/${ jobId } /retry` , { method : 'POST' } ) ;
19322053 showToast ( t ( 'retrying_download' ) , 'info' ) ;
1933- refreshDownloads ( ) ;
2054+ await refreshDownloads ( ) ;
19342055 } catch ( err ) {
19352056 if ( err . message !== 'Unauthorized' ) showToast ( t ( 'retry_failed' ) , 'error' ) ;
2057+ } finally {
2058+ state . pendingRetryDownloads . delete ( key ) ;
2059+ renderDownloadList ( ) ;
19362060 }
19372061}
19382062
19392063async function clearCompleted ( ) {
2064+ const button = document . getElementById ( 'downloads-clear-btn' ) ;
2065+ const icon = document . getElementById ( 'downloads-clear-icon' ) ;
2066+ if ( button && icon ) {
2067+ button . disabled = true ;
2068+ button . setAttribute ( 'aria-busy' , 'true' ) ;
2069+ icon . classList . remove ( 'hidden' ) ;
2070+ }
2071+
19402072 try {
19412073 await apiJson ( '/api/downloads/clear' , { method : 'POST' } ) ;
19422074 showToast ( t ( 'cleared_completed' ) , 'success' ) ;
1943- refreshDownloads ( ) ;
2075+ await refreshDownloads ( ) ;
19442076 } catch ( err ) {
19452077 if ( err . message !== 'Unauthorized' ) showToast ( t ( 'failed_clear' ) , 'error' ) ;
2078+ } finally {
2079+ if ( button && icon ) {
2080+ button . disabled = false ;
2081+ button . removeAttribute ( 'aria-busy' ) ;
2082+ icon . classList . add ( 'hidden' ) ;
2083+ }
19462084 }
19472085}
19482086
@@ -2956,4 +3094,3 @@ <h4 class="text-sm font-medium text-white truncate">${escapeHtml(item.title || '
29563094</ script >
29573095</ body >
29583096</ html >
2959-
0 commit comments