2026-05-27 07:21:24 +12:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > EmbyToolkit</ title >
2026-06-08 00:01:55 +12:00
< link rel = "stylesheet" href = "/static/app-theme.css" >
2026-05-27 07:21:24 +12:00
< style >
: root {
--bg : #0d0f12 ;
--surface : #13171f ;
--surface2 : #1a2030 ;
--surface3 : #222840 ;
--border : #252d3d ;
--text : #e8ecf4 ;
--text-2 : #8892a8 ;
--text-3 : #4e5a70 ;
--accent : #14b8a6 ;
--accent-h : #2dd4bf ;
--accent-glow : rgba ( 20 , 184 , 166 , 0.12 );
--green : #34d399 ;
--green-bg : rgba ( 52 , 211 , 153 , 0.08 );
--red : #f87171 ;
--r : 10 px ;
--r-lg : 14 px ;
}
* { box-sizing : border-box ; }
body {
margin : 0 ;
background : var ( -- bg );
color : var ( -- text );
2026-06-08 00:01:55 +12:00
font-family : 'Inter' , system-ui , sans-serif ;
2026-05-27 07:21:24 +12:00
display : grid ;
grid-template-columns : 220 px 1 fr ;
height : 100 vh ;
height : 100 svh ;
overflow : hidden ;
}
/* ── App Nav Sidebar ── */
. app-nav {
display : flex ;
flex-direction : column ;
background : var ( -- surface );
border-right : 1 px solid var ( -- border );
overflow : hidden ;
}
. app-nav-brand {
padding : 18 px 16 px ;
display : flex ;
align-items : center ;
gap : 10 px ;
border-bottom : 1 px solid var ( -- border );
flex-shrink : 0 ;
}
. app-nav-logo {
width : 32 px ; height : 32 px ;
background : var ( -- accent );
border-radius : 8 px ;
display : grid ; place-items : center ;
color : #fff ;
flex-shrink : 0 ;
}
. app-nav-name {
font-size : 15 px ;
font-weight : 700 ;
letter-spacing : -0.03 em ;
}
. app-nav-items {
list-style : none ;
padding : 8 px ;
flex : 1 ;
overflow-y : auto ;
margin : 0 ;
}
. app-nav-item {
display : flex ;
align-items : center ;
gap : 10 px ;
padding : 9 px 12 px ;
border-radius : 8 px ;
color : var ( -- text -2 );
text-decoration : none ;
font-size : 13 px ;
font-weight : 500 ;
transition : background 0.12 s , color 0.12 s ;
margin-bottom : 1 px ;
}
. app-nav-item : hover { background : var ( -- surface2 ); color : var ( -- text ); }
. app-nav-item . active { background : var ( -- accent ); color : #fff ; }
. app-nav-footer {
padding : 14 px 16 px ;
border-top : 1 px solid var ( -- border );
display : flex ;
align-items : center ;
gap : 7 px ;
font-size : 12 px ;
color : var ( -- text -2 );
flex-shrink : 0 ;
}
. dot {
width : 7 px ; height : 7 px ; border-radius : 50 % ;
background : var ( -- green ); box-shadow : 0 0 6 px var ( -- green );
}
. dot . off { background : var ( -- red ); box-shadow : 0 0 6 px var ( -- red ); }
. tab-bar {
display : flex ;
gap : 6 px ;
margin-bottom : 18 px ;
}
. tab-btn {
min-height : 34 px ;
padding : 0 16 px ;
border-radius : 999 px ;
border : 1 px solid var ( -- border );
background : var ( -- surface2 );
color : var ( -- text -2 );
font-family : inherit ;
font-size : 13 px ;
font-weight : 600 ;
cursor : pointer ;
transition : background 0.12 s , border-color 0.12 s , color 0.12 s ;
}
. tab-btn : hover { background : var ( -- surface3 ); color : var ( -- text ); }
. tab-btn . active {
background : var ( -- accent - glow );
border-color : var ( -- accent );
color : var ( -- text );
}
. header-status {
margin-left : auto ;
display : flex ;
align-items : center ;
gap : 7 px ;
font-size : 12 px ;
color : var ( -- text -2 );
}
. dot {
width : 7 px ;
height : 7 px ;
border-radius : 50 % ;
background : var ( -- green );
box-shadow : 0 0 6 px var ( -- green );
}
. dot . off {
background : var ( -- red );
box-shadow : 0 0 6 px var ( -- red );
}
. page {
padding : 20 px 22 px 26 px ;
overflow-y : auto ;
}
. hero {
display : flex ;
align-items : flex-start ;
justify-content : space-between ;
gap : 18 px ;
margin-bottom : 16 px ;
}
. hero-copy h2 {
margin : 0 0 8 px ;
font-size : clamp ( 24 px , 3 vw , 34 px );
line-height : 1 ;
letter-spacing : -0.04 em ;
}
. hero-copy p {
margin : 0 ;
max-width : 860 px ;
color : var ( -- text -2 );
font-size : 14 px ;
line-height : 1.5 ;
}
. toolbar {
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 14 px ;
margin-bottom : 18 px ;
padding : 14 px 16 px ;
border : 1 px solid var ( -- border );
border-radius : var ( -- r - lg );
background : linear-gradient ( 180 deg , rgba ( 26 , 32 , 48 , 0.95 ), rgba ( 19 , 23 , 31 , 0.95 ));
}
. toolbar-meta {
display : flex ;
flex-wrap : wrap ;
gap : 16 px ;
color : var ( -- text -2 );
font-size : 12 px ;
}
. toolbar-meta strong {
color : var ( -- text );
font-weight : 700 ;
}
. toolbar-actions {
display : flex ;
align-items : center ;
gap : 10 px ;
flex-wrap : wrap ;
}
. search-input {
min-width : 260 px ;
min-height : 36 px ;
padding : 0 12 px ;
border-radius : 10 px ;
border : 1 px solid var ( -- border );
background : var ( -- surface2 );
color : var ( -- text );
font : inherit ;
}
. selection-meta {
color : var ( -- text -2 );
font-size : 12 px ;
2026-06-08 00:01:55 +12:00
font-family : inherit ;
font-variant-numeric : tabular-nums ;
2026-05-27 07:21:24 +12:00
}
. chip-toggle {
min-height : 34 px ;
padding : 0 12 px ;
display : inline-flex ;
align-items : center ;
gap : 8 px ;
border-radius : 999 px ;
border : 1 px solid var ( -- border );
background : var ( -- surface2 );
color : var ( -- text );
cursor : pointer ;
user-select : none ;
font-size : 12 px ;
font-weight : 600 ;
}
. chip-toggle input {
margin : 0 ;
accent-color : var ( -- accent );
}
. btn {
min-height : 36 px ;
padding : 0 14 px ;
border-radius : 10 px ;
border : 1 px solid var ( -- border );
background : var ( -- surface2 );
color : var ( -- text );
font-family : inherit ;
font-size : 13 px ;
font-weight : 600 ;
cursor : pointer ;
transition : background 0.12 s , border-color 0.12 s , opacity 0.12 s ;
}
. btn : hover : not ( : disabled ) { background : var ( -- surface3 ); }
. btn : disabled { opacity : 0.4 ; cursor : not-allowed ; }
. btn-primary {
background : var ( -- accent );
border-color : var ( -- accent );
color : #fff ;
}
. btn-primary : hover : not ( : disabled ) { background : var ( -- accent - h ); }
. btn-green {
background : var ( -- green );
border-color : var ( -- green );
color : #08120e ;
}
. grid {
display : grid ;
grid-template-columns : repeat ( auto - fill , minmax ( 300 px , 1 fr ));
gap : 14 px ;
}
. card {
display : grid ;
grid-template-columns : 108 px minmax ( 0 , 1 fr );
gap : 14 px ;
padding : 14 px ;
border : 1 px solid var ( -- border );
border-radius : var ( -- r - lg );
background : linear-gradient ( 180 deg , rgba ( 19 , 23 , 31 , 0.98 ), rgba ( 13 , 15 , 18 , 0.98 ));
min-height : 186 px ;
}
. card-poster {
width : 108 px ;
aspect-ratio : 2 / 3 ;
object-fit : cover ;
border-radius : 10 px ;
background : var ( -- surface2 );
}
. card-poster . placeholder {
display : grid ;
place-items : center ;
color : var ( -- text -3 );
font-size : 12 px ;
}
. card-body {
min-width : 0 ;
display : flex ;
flex-direction : column ;
gap : 10 px ;
}
. card-top {
display : flex ;
align-items : flex-start ;
justify-content : space-between ;
gap : 10 px ;
}
. card-title {
margin : 0 ;
font-size : 18 px ;
font-weight : 700 ;
line-height : 1.1 ;
letter-spacing : -0.03 em ;
}
. card-year {
margin-top : 4 px ;
color : var ( -- text -2 );
font-size : 12 px ;
}
. pill {
padding : 4 px 8 px ;
border-radius : 999 px ;
font-size : 10 px ;
font-weight : 700 ;
letter-spacing : 0.06 em ;
text-transform : uppercase ;
border : 1 px solid transparent ;
white-space : nowrap ;
}
. pill-green {
background : var ( -- green - bg );
border-color : rgba ( 52 , 211 , 153 , 0.4 );
color : var ( -- green );
}
. pill-muted {
background : rgba ( 136 , 146 , 168 , 0.08 );
border-color : rgba ( 136 , 146 , 168 , 0.2 );
color : var ( -- text -2 );
}
. meta-list {
display : flex ;
flex-wrap : wrap ;
gap : 8 px ;
}
. meta-chip {
padding : 6 px 8 px ;
border-radius : 999 px ;
border : 1 px solid var ( -- border );
background : var ( -- surface2 );
color : var ( -- text -2 );
font-size : 11 px ;
font-weight : 600 ;
letter-spacing : 0.03 em ;
}
. meta-chip . ok {
border-color : rgba ( 52 , 211 , 153 , 0.4 );
background : var ( -- green - bg );
color : var ( -- green );
}
. card-actions {
display : flex ;
align-items : center ;
gap : 10 px ;
margin-top : auto ;
flex-wrap : wrap ;
}
. card-check {
min-height : 32 px ;
padding : 0 10 px ;
}
. card-note {
color : var ( -- text -3 );
font-size : 12 px ;
flex : 1 1 220 px ;
}
. empty {
padding : 54 px 20 px ;
border : 1 px dashed var ( -- border );
border-radius : var ( -- r - lg );
text-align : center ;
color : var ( -- text -3 );
font-size : 14 px ;
}
. pager {
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 12 px ;
margin-top : 16 px ;
padding-top : 4 px ;
}
. pager-meta {
color : var ( -- text -2 );
font-size : 12 px ;
2026-06-08 00:01:55 +12:00
font-family : inherit ;
font-variant-numeric : tabular-nums ;
2026-05-27 07:21:24 +12:00
}
. pager-actions {
display : flex ;
gap : 8 px ;
}
. toast {
position : fixed ;
right : 20 px ;
bottom : 20 px ;
padding : 11 px 18 px ;
border-radius : 10 px ;
font-size : 13 px ;
font-weight : 500 ;
transform : translateY ( 80 px );
opacity : 0 ;
transition : all 0.25 s cubic-bezier ( 0.4 , 0 , 0.2 , 1 );
pointer-events : none ;
z-index : 50 ;
}
. toast . show { transform : translateY ( 0 ); opacity : 1 ; }
. toast . ok { background : var ( -- green - bg ); border : 1 px solid var ( -- green ); color : var ( -- green ); }
. toast . err { background : rgba ( 248 , 113 , 113 , .1 ); border : 1 px solid var ( -- red ); color : var ( -- red ); }
@ media ( max-width : 980px ) {
. hero , . toolbar , . pager {
flex-direction : column ;
align-items : stretch ;
}
. toolbar-actions , . pager-actions {
justify-content : space-between ;
}
. search-input {
min-width : 0 ;
width : 100 % ;
}
}
@ media ( max-width : 720px ) {
. page { padding : 16 px ; }
. header { padding : 0 16 px ; }
. card {
grid-template-columns : 84 px minmax ( 0 , 1 fr );
gap : 12 px ;
padding : 12 px ;
}
. card-poster { width : 84 px ; }
. card-title { font-size : 16 px ; }
}
</ style >
</ head >
< body >
< nav class = "app-nav" >
< div class = "app-nav-brand" >
< div class = "app-nav-logo" >
< svg width = "18" height = "18" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />< polyline points = "3.29 7 12 12 20.71 7" />< line x1 = "12" y1 = "22" x2 = "12" y2 = "12" /></ svg >
</ div >
< span class = "app-nav-name" > EmbyToolkit</ span >
</ div >
< ul class = "app-nav-items" >
< li >< a class = "app-nav-item" href = "/" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< rect x = "3" y = "3" width = "18" height = "18" rx = "2" />< circle cx = "8.5" cy = "8.5" r = "1.5" />< path d = "m21 15-5-5L5 21" /></ svg >
< span > Generator</ span >
</ a ></ li >
< li >< a class = "app-nav-item" href = "/collections" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< rect x = "3" y = "3" width = "7" height = "7" />< rect x = "14" y = "3" width = "7" height = "7" />< rect x = "14" y = "14" width = "7" height = "7" />< rect x = "3" y = "14" width = "7" height = "7" /></ svg >
< span > Collections</ span >
</ a ></ li >
< li >< a class = "app-nav-item" href = "/airing" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< rect x = "2" y = "7" width = "20" height = "15" rx = "2" />< polyline points = "17 2 12 7 7 2" /></ svg >
< span > Airing</ span >
</ a ></ li >
< li >< a class = "app-nav-item active" href = "/bulk-assign" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" /></ svg >
< span > Bulk Assign</ span >
</ a ></ li >
2026-06-08 00:01:55 +12:00
< li >< a class = "app-nav-item" href = "/favorites" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" /></ svg >
< span > User Favourites</ span >
</ a ></ li >
2026-05-27 07:21:24 +12:00
</ ul >
2026-06-08 00:01:55 +12:00
< div class = "app-nav-foot" >
< div class = "app-nav-status" >
< span class = "dot" id = "statusDot" ></ span >
< span id = "statusText" > Checking…</ span >
</ div >
< div class = "app-nav-version" > EmbyToolkit · v1.0</ div >
2026-05-27 07:21:24 +12:00
</ div >
</ nav >
< main class = "page" >
< section class = "hero" >
< div class = "hero-copy" >
< h2 > Bulk Assign</ h2 >
< p id = "heroDesc" >
Bulk assign applies generated < strong > Thumb</ strong > artwork only,
and is enabled when Emby already has a < strong > Logo</ strong > and < strong > Backdrop</ strong > .
</ p >
</ div >
</ section >
< div class = "tab-bar" >
< button class = "tab-btn active" id = "tabSeries" data-mode = "series" > TV Series</ button >
< button class = "tab-btn" id = "tabMovies" data-mode = "movies" > Movies</ button >
</ div >
< section class = "toolbar" >
< div class = "toolbar-meta" >
< div >< strong id = "countText" > 0</ strong > < span id = "countLabel" > series</ span ></ div >
< div > Search < strong id = "queryText" > All</ strong ></ div >
</ div >
< div class = "toolbar-actions" >
< input class = "search-input" id = "searchInput" type = "text" placeholder = "Search TV series…" autocomplete = "off" >
< button class = "btn" id = "btnRefresh" > Refresh</ button >
< button class = "btn" id = "btnSelectEligible" > Select eligible</ button >
< button class = "btn" id = "btnClearSelected" > Clear selected</ button >
< button class = "btn btn-primary" id = "btnBulkApply" disabled > Apply Selected</ button >
< button class = "btn btn-green" id = "btnApplyAll" > Apply All Eligible</ button >
< div class = "selection-meta" id = "selectionMeta" > 0 selected</ div >
</ div >
</ section >
< section id = "results" >
< div class = "empty" > Loading series…</ div >
</ section >
< section class = "pager" >
< div class = "pager-meta" id = "pagerMeta" > No results</ div >
< div class = "pager-actions" >
< button class = "btn" id = "btnPrev" disabled > Previous</ button >
< button class = "btn" id = "btnNext" disabled > Next</ button >
</ div >
</ section >
</ main >
< div class = "toast" id = "toast" ></ div >
< script >
const state = {
mode : 'series' ,
page : 1 ,
limit : 24 ,
total : 0 ,
query : '' ,
items : [],
applyingId : null ,
bulkApplying : false ,
selectedIds : new Set (),
searchTimer : null ,
};
const $ = ( selector ) => document . querySelector ( selector );
const results = $ ( '#results' );
const countText = $ ( '#countText' );
const countLabel = $ ( '#countLabel' );
const queryText = $ ( '#queryText' );
const searchInput = $ ( '#searchInput' );
const selectionMeta = $ ( '#selectionMeta' );
const btnRefresh = $ ( '#btnRefresh' );
const btnSelectEligible = $ ( '#btnSelectEligible' );
const btnClearSelected = $ ( '#btnClearSelected' );
const btnBulkApply = $ ( '#btnBulkApply' );
const pagerMeta = $ ( '#pagerMeta' );
const btnPrev = $ ( '#btnPrev' );
const btnNext = $ ( '#btnNext' );
const toast = $ ( '#toast' );
const tabSeries = $ ( '#tabSeries' );
const tabMovies = $ ( '#tabMovies' );
const btnApplyAll = $ ( '#btnApplyAll' );
function esc ( value ) {
const div = document . createElement ( 'div' );
div . textContent = value ?? '' ;
return div . innerHTML ;
}
function showToast ( message , type ) {
toast . textContent = message ;
toast . className = `toast ${ type } show` ;
setTimeout (() => toast . classList . remove ( 'show' ), 2800 );
}
function canBulkAssign ( item ) {
return Boolean ( item . can_bulk_assign );
}
function updateBulkSelectionState () {
const count = state . selectedIds . size ;
selectionMeta . textContent = ` ${ count } selected` ;
btnBulkApply . disabled = count === 0 || state . bulkApplying ;
btnSelectEligible . disabled = state . bulkApplying || ! state . items . some ( canBulkAssign );
btnClearSelected . disabled = state . bulkApplying || count === 0 ;
btnApplyAll . disabled = state . bulkApplying ;
}
function updatePager () {
const start = state . total === 0 ? 0 : (( state . page - 1 ) * state . limit ) + 1 ;
const end = Math . min ( state . page * state . limit , state . total );
pagerMeta . textContent = state . total ? ` ${ start } - ${ end } of ${ state . total } · Page ${ state . page } ` : 'No results' ;
btnPrev . disabled = state . page <= 1 ;
btnNext . disabled = state . page * state . limit >= state . total ;
}
function selectEligibleOnPage () {
state . selectedIds = new Set ( state . items . filter ( canBulkAssign ). map (( item ) => item . id ));
renderCards ( state . items );
updateBulkSelectionState ();
}
function clearSelected () {
state . selectedIds . clear ();
renderCards ( state . items );
updateBulkSelectionState ();
}
function renderCards ( items ) {
if ( ! items . length ) {
const label = state . mode === 'movies' ? 'movies' : 'TV series' ;
results . innerHTML = `<div class="empty">No ${ label } matched this search.</div>` ;
updateBulkSelectionState ();
return ;
}
results . innerHTML = `
<div class="grid">
${ items . map (( item ) => `
${ (() => {
const applyAllowed = canBulkAssign ( item );
const note = applyAllowed
? 'Ready to apply generated thumb artwork from Emby assets.'
: 'Requires Emby Logo and Backdrop images before bulk assign can apply.' ;
const poster = item . poster_url
? `<img class="card-poster" src=" ${ esc ( item . poster_url ) } " alt="" loading="lazy" decoding="async">`
: '<div class="card-poster placeholder">No poster</div>' ;
return `
<article class="card">
${ poster }
<div class="card-body">
<div class="card-top">
<div>
<h3 class="card-title"> ${ esc ( item . name ) } </h3>
<div class="card-year"> ${ item . year ? esc ( String ( item . year )) : '—' } </div>
</div>
<span class="pill ${ applyAllowed ? 'pill-green' : 'pill-muted' } ">
${ applyAllowed ? 'Ready' : 'Missing assets' }
</span>
</div>
<div class="meta-list">
<span class="meta-chip ${ item . has_primary ? 'ok' : '' } ">Primary ${ item . has_primary ? 'yes' : 'no' } </span>
<span class="meta-chip ${ item . has_logo ? 'ok' : '' } ">Logo ${ item . has_logo ? 'yes' : 'no' } </span>
<span class="meta-chip ${ item . has_backdrop ? 'ok' : '' } ">Backdrop ${ item . has_backdrop ? item . backdrop_count : 'no' } </span>
</div>
<div class="card-actions">
<label class="chip-toggle card-check" for="select- ${ esc ( item . id ) } ">
<input
type="checkbox"
id="select- ${ esc ( item . id ) } "
data-select-id=" ${ esc ( item . id ) } "
${ applyAllowed ? '' : 'disabled' }
${ state . selectedIds . has ( item . id ) ? 'checked' : '' }
>
<span>Select</span>
</label>
<button
class="btn btn-green"
data-apply-id=" ${ esc ( item . id ) } "
data-apply-title=" ${ esc ( item . name ) } "
${ applyAllowed ? '' : 'disabled' }
>
${ state . applyingId === item . id ? 'Applying…' : 'Apply Thumb' }
</button>
<span class="card-note"> ${ note } </span>
</div>
</div>
</article>
` ;
} )()}
` ). join ( '' ) }
</div>
` ;
}
async function loadItems () {
const isMovies = state . mode === 'movies' ;
const label = isMovies ? 'movies' : 'series' ;
results . innerHTML = `<div class="empty">Loading ${ label } …</div>` ;
btnRefresh . disabled = true ;
searchInput . placeholder = isMovies ? 'Search movies…' : 'Search TV series…' ;
countLabel . textContent = label ;
try {
const start = ( state . page - 1 ) * state . limit ;
const endpoint = isMovies ? '/api/bulk-assign/movies' : '/api/bulk-assign/series' ;
const response = await fetch ( ` ${ endpoint } ?q= ${ encodeURIComponent ( state . query ) } &start= ${ start } &limit= ${ state . limit } ` );
const data = await response . json ();
if ( ! response . ok ) throw new Error ( data . detail || ` ${ response . status } ` );
state . items = data . items ;
state . total = data . total ;
state . selectedIds . clear ();
state . bulkApplying = false ;
countText . textContent = data . total ;
queryText . textContent = data . query || 'All' ;
renderCards ( data . items );
updatePager ();
} catch ( error ) {
results . innerHTML = `<div class="empty">Failed to load ${ label } : ${ esc ( error . message ) } </div>` ;
state . items = [];
state . total = 0 ;
state . selectedIds . clear ();
state . bulkApplying = false ;
updatePager ();
updateBulkSelectionState ();
} finally {
btnRefresh . disabled = false ;
updateBulkSelectionState ();
}
}
results . addEventListener ( 'click' , async ( event ) => {
const button = event . target . closest ( '[data-apply-id]' );
if ( ! button || button . disabled ) return ;
const itemId = button . dataset . applyId ;
state . applyingId = itemId ;
renderCards ( state . items );
updateBulkSelectionState ();
try {
const response = await fetch ( '/api/airing/apply-new-season/bulk' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({
item_ids : [ itemId ],
generate_primary : false ,
item_type : state . mode === 'movies' ? 'movie' : 'series' ,
}),
});
const data = await response . json ();
if ( ! response . ok ) throw new Error ( data . detail || ` ${ response . status } ` );
if ( data . applied_count !== 1 ) throw new Error ( 'Item was not applied.' );
showToast ( 'Applied thumb' , 'ok' );
} catch ( error ) {
showToast ( error . message , 'err' );
} finally {
state . applyingId = null ;
renderCards ( state . items );
updateBulkSelectionState ();
}
});
results . addEventListener ( 'change' , ( event ) => {
const checkbox = event . target . closest ( '[data-select-id]' );
if ( ! checkbox ) return ;
const itemId = checkbox . dataset . selectId ;
if ( checkbox . checked ) {
state . selectedIds . add ( itemId );
} else {
state . selectedIds . delete ( itemId );
}
updateBulkSelectionState ();
});
searchInput . addEventListener ( 'input' , () => {
clearTimeout ( state . searchTimer );
state . searchTimer = setTimeout (() => {
state . query = searchInput . value . trim ();
state . page = 1 ;
loadItems ();
}, 180 );
});
btnRefresh . addEventListener ( 'click' , () => loadItems ());
btnSelectEligible . addEventListener ( 'click' , selectEligibleOnPage );
btnClearSelected . addEventListener ( 'click' , clearSelected );
btnApplyAll . addEventListener ( 'click' , async () => {
if ( state . bulkApplying ) return ;
const typeLabel = state . mode === 'movies' ? 'movies' : 'TV series' ;
const confirmed = window . confirm (
`Apply generated thumbs to ALL eligible ${ typeLabel } ?\n\nThis will process every item in your library that has a Logo and Backdrop in Emby — across all pages. This may take a while.`
);
if ( ! confirmed ) return ;
state . bulkApplying = true ;
btnApplyAll . textContent = 'Applying…' ;
updateBulkSelectionState ();
try {
const response = await fetch ( '/api/bulk-assign/apply-all' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ item_type : state . mode === 'movies' ? 'movie' : 'series' }),
});
const data = await response . json ();
if ( ! response . ok ) throw new Error ( data . detail || ` ${ response . status } ` );
showToast (
`Apply All complete: ${ data . applied_count } / ${ data . eligible_count } applied, ${ data . failed_count } failed` ,
data . failed_count ? 'err' : 'ok' ,
);
} catch ( error ) {
showToast ( error . message , 'err' );
} finally {
state . bulkApplying = false ;
btnApplyAll . textContent = 'Apply All Eligible' ;
updateBulkSelectionState ();
}
});
[ tabSeries , tabMovies ]. forEach (( btn ) => {
btn . addEventListener ( 'click' , () => {
const mode = btn . dataset . mode ;
if ( mode === state . mode ) return ;
state . mode = mode ;
state . page = 1 ;
state . query = '' ;
searchInput . value = '' ;
tabSeries . classList . toggle ( 'active' , mode === 'series' );
tabMovies . classList . toggle ( 'active' , mode === 'movies' );
loadItems ();
});
});
btnBulkApply . addEventListener ( 'click' , async () => {
if ( ! state . selectedIds . size || state . bulkApplying ) return ;
const typeLabel = state . mode === 'movies' ? 'movies' : 'shows' ;
const confirmed = window . confirm ( `Apply generated thumbs to ${ state . selectedIds . size } selected ${ typeLabel } ?\n\nOnly items with Emby Logo and Backdrop assets will be updated.` );
if ( ! confirmed ) return ;
state . bulkApplying = true ;
updateBulkSelectionState ();
try {
const response = await fetch ( '/api/airing/apply-new-season/bulk' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({
item_ids : Array . from ( state . selectedIds ),
generate_primary : false ,
item_type : state . mode === 'movies' ? 'movie' : 'series' ,
}),
});
const data = await response . json ();
if ( ! response . ok ) throw new Error ( data . detail || ` ${ response . status } ` );
showToast (
`Bulk complete: ${ data . applied_count } / ${ data . requested_count } applied, ${ data . skipped_missing_assets_count } skipped for missing assets, ${ data . failed_count } failed` ,
data . failed_count ? 'err' : 'ok' ,
);
clearSelected ();
} catch ( error ) {
showToast ( error . message , 'err' );
} finally {
state . bulkApplying = false ;
updateBulkSelectionState ();
}
});
btnPrev . addEventListener ( 'click' , () => {
if ( state . page <= 1 ) return ;
state . page -= 1 ;
loadItems ();
});
btnNext . addEventListener ( 'click' , () => {
if ( state . page * state . limit >= state . total ) return ;
state . page += 1 ;
loadItems ();
});
( async () => {
try {
const config = await ( await fetch ( '/api/config' )). json ();
if ( config . connected ) {
$ ( '#statusText' ). textContent = 'Connected to Emby' ;
} else {
$ ( '#statusDot' ). classList . add ( 'off' );
$ ( '#statusText' ). textContent = 'No API key' ;
}
} catch {
$ ( '#statusDot' ). classList . add ( 'off' );
$ ( '#statusText' ). textContent = 'Connection error' ;
}
})();
updateBulkSelectionState ();
loadItems ();
</ script >
</ body >
</ html >