2026-04-15 09:27:29 +12:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > Current Airing Shows</ title >
< link rel = "preconnect" href = "https://fonts.googleapis.com" >
< link href = "https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel = "stylesheet" >
< style >
: root {
--bg : #0d0f12 ;
--surface : #13171f ;
--surface2 : #1a2030 ;
--surface3 : #222840 ;
--border : #252d3d ;
--border-active : #4a6fff ;
--text : #e8ecf4 ;
--text-2 : #8892a8 ;
--text-3 : #4e5a70 ;
--accent : #4a6fff ;
--accent-h : #6384ff ;
--accent-glow : rgba ( 74 , 111 , 255 , 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 ;
min-height : 100 vh ;
min-height : 100 svh ;
background : var ( -- bg );
color : var ( -- text );
font-family : 'DM Sans' , sans-serif ;
}
. header {
height : 56 px ;
padding : 0 24 px ;
display : flex ;
align-items : center ;
gap : 14 px ;
border-bottom : 1 px solid var ( -- border );
background : var ( -- surface );
}
. header-logo {
width : 30 px ;
height : 30 px ;
border-radius : 7 px ;
display : grid ;
place-items : center ;
background : var ( -- accent );
color : #fff ;
font-size : 12 px ;
font-weight : 700 ;
}
. header h1 {
margin : 0 ;
font-size : 15 px ;
font-weight : 600 ;
letter-spacing : -0.02 em ;
}
. header-nav {
display : flex ;
align-items : center ;
gap : 8 px ;
margin-left : 10 px ;
}
. nav-link {
padding : 6 px 10 px ;
border-radius : 999 px ;
border : 1 px solid var ( -- border );
color : var ( -- text -2 );
text-decoration : none ;
font-size : 12 px ;
font-weight : 600 ;
transition : background 0.12 s , border-color 0.12 s , color 0.12 s ;
}
. nav-link : hover {
background : var ( -- surface2 );
color : var ( -- text );
}
. nav-link . 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 ;
}
. 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 : 820 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 ;
}
. 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 : 190 px ;
}
. card-poster {
width : 108 px ;
aspect-ratio : 2 / 3 ;
object-fit : cover ;
border-radius : 10 px ;
background : var ( -- surface2 );
}
. 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-blue {
background : rgba ( 74 , 111 , 255 , 0.12 );
border-color : rgba ( 74 , 111 , 255 , 0.35 );
color : #a9bbff ;
}
. pill-muted {
background : rgba ( 136 , 146 , 168 , 0.08 );
border-color : rgba ( 136 , 146 , 168 , 0.2 );
color : var ( -- text -2 );
}
. meta-list {
display : grid ;
gap : 8 px ;
}
. meta-row {
display : grid ;
gap : 2 px ;
}
. meta-label {
color : var ( -- text -3 );
font-size : 11 px ;
text-transform : uppercase ;
letter-spacing : 0.08 em ;
}
. meta-value {
color : var ( -- text );
font-size : 13 px ;
line-height : 1.35 ;
}
. meta-value . muted {
color : var ( -- text -2 );
}
. card-actions {
display : flex ;
align-items : center ;
gap : 10 px ;
margin-top : auto ;
}
. card-note {
color : var ( -- text -3 );
font-size : 12 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 ;
font-family : 'JetBrains Mono' , monospace ;
}
. 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 ;
}
}
@ 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 >
< header class = "header" >
< div class = "header-logo" > TG</ div >
< h1 > Emby Thumbnail Generator</ h1 >
< nav class = "header-nav" >
< a class = "nav-link" href = "/" > Generator</ a >
2026-04-20 23:16:53 +12:00
< a class = "nav-link" href = "/collections" > Collections</ a >
2026-04-15 09:27:29 +12:00
< a class = "nav-link active" href = "/airing" > Airing</ a >
</ nav >
< div class = "header-status" >
< span class = "dot" id = "statusDot" ></ span >
< span id = "statusText" > Checking…</ span >
</ div >
</ header >
< main class = "page" >
< section class = "hero" >
< div class = "hero-copy" >
< h2 > Current Airing Shows</ h2 >
< p >
This page uses Emby metadata to find continuing shows, upcoming airtimes, and season premieres.
< strong > Apply New Season</ strong > is enabled when the latest season premiere is between 1 and 3 weeks old.
</ p >
</ div >
</ section >
< section class = "toolbar" >
< div class = "toolbar-meta" >
< div >< strong id = "countText" > 0</ strong > shows</ div >
< div > Snapshot < strong id = "generatedAt" > —</ strong ></ div >
< div > Week < strong id = "weekLabel" > —</ strong ></ div >
< div > Window < strong id = "windowText" > 7-21 days</ strong ></ div >
</ div >
< div class = "toolbar-actions" >
< button class = "btn" id = "btnWeekPrev" > Previous week</ button >
< button class = "btn" id = "btnWeekCurrent" > This week</ button >
< button class = "btn" id = "btnWeekNext" > Next week</ button >
< label class = "chip-toggle" for = "eligibleOnlyToggle" >
< input type = "checkbox" id = "eligibleOnlyToggle" >
< span > Eligible only</ span >
</ label >
< button class = "btn" id = "btnRefresh" > Refresh</ button >
</ div >
</ section >
< section id = "results" >
< div class = "empty" > Loading airing titles…</ div >
</ section >
< section class = "pager" >
< div class = "pager-meta" id = "pagerMeta" > Page 1</ 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 = {
page : 1 ,
limit : 12 ,
total : 0 ,
eligibleOnly : false ,
weekOffset : 0 ,
windowMinDays : 7 ,
windowMaxDays : 21 ,
items : [],
applyingId : null ,
};
const MIN_WEEK_OFFSET = - 8 ;
const MAX_WEEK_OFFSET = 4 ;
const $ = ( selector ) => document . querySelector ( selector );
const results = $ ( '#results' );
const countText = $ ( '#countText' );
const generatedAt = $ ( '#generatedAt' );
const weekLabel = $ ( '#weekLabel' );
const windowText = $ ( '#windowText' );
const pagerMeta = $ ( '#pagerMeta' );
const btnWeekPrev = $ ( '#btnWeekPrev' );
const btnWeekCurrent = $ ( '#btnWeekCurrent' );
const btnWeekNext = $ ( '#btnWeekNext' );
const eligibleOnlyToggle = $ ( '#eligibleOnlyToggle' );
const btnRefresh = $ ( '#btnRefresh' );
const btnPrev = $ ( '#btnPrev' );
const btnNext = $ ( '#btnNext' );
const toast = $ ( '#toast' );
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 formatDateTime ( value ) {
if ( ! value ) return '—' ;
const date = new Date ( value );
if ( Number . isNaN ( date . getTime ())) return '—' ;
return new Intl . DateTimeFormat ( undefined , {
weekday : 'short' ,
month : 'short' ,
day : 'numeric' ,
hour : 'numeric' ,
minute : '2-digit' ,
}). format ( date );
}
function formatDateOnly ( value ) {
if ( ! value ) return '—' ;
const date = new Date ( value );
if ( Number . isNaN ( date . getTime ())) return '—' ;
return new Intl . DateTimeFormat ( undefined , {
month : 'short' ,
day : 'numeric' ,
year : 'numeric' ,
}). format ( date );
}
function formatWeekRange ( startValue , endValue ) {
if ( ! startValue || ! endValue ) return '—' ;
const start = new Date ( startValue );
const end = new Date ( endValue );
if ( Number . isNaN ( start . getTime ()) || Number . isNaN ( end . getTime ())) return '—' ;
const endDisplay = new Date ( end . getTime () - 1000 );
const fmt = new Intl . DateTimeFormat ( undefined , { month : 'short' , day : 'numeric' });
return ` ${ fmt . format ( start ) } - ${ fmt . format ( endDisplay ) } ` ;
}
function canApplyNewSeason ( item ) {
return Boolean (
item . eligible_new_season ||
(( item . status || '' ). toLowerCase () === 'continuing' && ( item . selected_week_air_at || item . next_air_at ))
);
}
function renderCards ( items ) {
if ( ! items . length ) {
results . innerHTML = '<div class="empty">No airing shows matched this week. Try Previous week or turn off Eligible only.</div>' ;
return ;
}
results . innerHTML = `
<div class="grid">
${ items . map (( item ) => `
${ (() => {
const applyAllowed = canApplyNewSeason ( item );
return `
<article class="card">
<img class="card-poster" src=" ${ esc ( item . poster_url ) } " alt="" loading="lazy" decoding="async">
<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 ${ item . eligible_new_season ? 'pill-green' : ( item . status === 'Continuing' ? 'pill-blue' : 'pill-muted' ) } ">
${ item . eligible_new_season ? 'Eligible' : esc ( item . status || 'Airing' ) }
</span>
</div>
<div class="meta-list">
<div class="meta-row">
<div class="meta-label">Air Days</div>
<div class="meta-value ${ item . air_days ? . length ? '' : 'muted' } "> ${ item . air_days ? . length ? esc ( item . air_days . join ( ', ' )) : 'No recurring air day metadata' } </div>
</div>
<div class="meta-row">
<div class="meta-label">Selected Week Airtime</div>
<div class="meta-value ${ item . selected_week_air_at ? '' : 'muted' } "> ${ item . selected_week_air_at ? ` ${ esc ( formatDateTime ( item . selected_week_air_at )) }${ item . selected_week_episode_label ? ` · ${ esc ( item . selected_week_episode_label ) } ` : '' } ` : 'No episode found in the selected week' } </div>
</div>
<div class="meta-row">
<div class="meta-label">Next Known Airtime</div>
<div class="meta-value ${ item . next_air_at ? '' : 'muted' } "> ${ item . next_air_at ? ` ${ esc ( formatDateTime ( item . next_air_at )) }${ item . next_episode_label ? ` · ${ esc ( item . next_episode_label ) } ` : '' } ` : 'No upcoming episode found in Emby' } </div>
</div>
<div class="meta-row">
<div class="meta-label">Latest Season Premiere</div>
<div class="meta-value ${ item . season_premiere_at ? '' : 'muted' } "> ${ item . season_premiere_at ? `Season ${ esc ( String ( item . season_number )) } · ${ esc ( formatDateOnly ( item . season_premiere_at )) } · ${ esc ( String ( item . days_since_season_premiere )) } days ago ${ item . season_premiere_inferred ? ' · inferred from recent season activity' : '' } ` : `No recent season start in the ${ esc ( String ( state . windowMinDays )) } - ${ esc ( String ( state . windowMaxDays )) } day window` } </div>
</div>
</div>
<div class="card-actions">
<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 New Season' }
</button>
<span class="card-note"> ${ applyAllowed ? ( item . eligibility_reason === 'current_airing' || ( ! item . eligibility_reason && (( item . status || '' ). toLowerCase () === 'continuing' ) && ( item . selected_week_air_at || item . next_air_at )) ? 'Applies thumb + primary. Eligible because the show is continuing and has a current or upcoming airing.' : ( item . season_premiere_inferred ? 'Applies thumb + primary. Season start was inferred from current season activity.' : 'Applies thumb + primary with default artwork settings.' )) : `Available when Emby finds a current or upcoming airing, or a recent season start in the ${ esc ( String ( state . windowMinDays )) } - ${ esc ( String ( state . windowMaxDays )) } day window.` } </span>
</div>
</div>
</article>
` ;
} )()}
` ). join ( '' ) }
</div>
` ;
}
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 ;
btnWeekPrev . disabled = state . weekOffset <= MIN_WEEK_OFFSET ;
btnWeekCurrent . disabled = state . weekOffset === 0 ;
btnWeekNext . disabled = state . weekOffset >= MAX_WEEK_OFFSET ;
}
async function loadAiring ( refresh = false ) {
results . innerHTML = '<div class="empty">Loading airing titles…</div>' ;
btnRefresh . disabled = true ;
try {
const response = await fetch ( `/api/airing?page= ${ state . page } &limit= ${ state . limit } &eligible_only= ${ state . eligibleOnly } &refresh= ${ refresh } &week_offset= ${ state . weekOffset } ` );
const data = await response . json ();
if ( ! response . ok ) throw new Error ( data . detail || ` ${ response . status } ` );
state . items = data . items ;
state . total = data . total ;
state . windowMinDays = data . new_season_min_days ;
state . windowMaxDays = data . new_season_max_days ;
countText . textContent = data . total ;
generatedAt . textContent = data . generated_at ? formatDateTime ( data . generated_at ) : '—' ;
weekLabel . textContent = formatWeekRange ( data . week_start , data . week_end );
windowText . textContent = ` ${ data . new_season_min_days } - ${ data . new_season_max_days } days` ;
renderCards ( data . items );
updatePager ();
} catch ( error ) {
results . innerHTML = `<div class="empty">Failed to load airing data: ${ esc ( error . message ) } </div>` ;
state . total = 0 ;
updatePager ();
} finally {
btnRefresh . disabled = false ;
}
}
results . addEventListener ( 'click' , async ( event ) => {
const button = event . target . closest ( '[data-apply-id]' );
if ( ! button || button . disabled ) return ;
const itemId = button . dataset . applyId ;
const title = button . dataset . applyTitle ;
state . applyingId = itemId ;
renderCards ( state . items );
try {
const response = await fetch ( '/api/airing/apply-new-season' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({
item_id : itemId ,
title ,
generate_primary : true ,
week_offset : state . weekOffset ,
}),
});
const data = await response . json ();
if ( ! response . ok ) throw new Error ( data . detail || ` ${ response . status } ` );
if ( data . primary_attempted && data . primary_code ) {
showToast ( 'Applied New Season thumb + primary' , 'ok' );
} else if ( data . primary_attempted && data . primary_error ) {
showToast ( 'Applied New Season thumb only' , 'ok' );
} else {
showToast ( 'Applied New Season artwork' , 'ok' );
}
} catch ( error ) {
showToast ( error . message , 'err' );
} finally {
state . applyingId = null ;
renderCards ( state . items );
}
});
eligibleOnlyToggle . addEventListener ( 'change' , () => {
state . eligibleOnly = eligibleOnlyToggle . checked ;
state . page = 1 ;
loadAiring ( false );
});
btnRefresh . addEventListener ( 'click' , () => {
state . page = 1 ;
loadAiring ( true );
});
btnWeekPrev . addEventListener ( 'click' , () => {
if ( state . weekOffset <= MIN_WEEK_OFFSET ) return ;
state . weekOffset -= 1 ;
state . page = 1 ;
loadAiring ( true );
});
btnWeekCurrent . addEventListener ( 'click' , () => {
if ( state . weekOffset === 0 ) return ;
state . weekOffset = 0 ;
state . page = 1 ;
loadAiring ( true );
});
btnWeekNext . addEventListener ( 'click' , () => {
if ( state . weekOffset >= MAX_WEEK_OFFSET ) return ;
state . weekOffset += 1 ;
state . page = 1 ;
loadAiring ( true );
});
btnPrev . addEventListener ( 'click' , () => {
if ( state . page <= 1 ) return ;
state . page -= 1 ;
loadAiring ( false );
});
btnNext . addEventListener ( 'click' , () => {
if ( state . page * state . limit >= state . total ) return ;
state . page += 1 ;
loadAiring ( false );
});
( 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' ;
}
})();
loadAiring ( false );
</ script >
</ body >
</ html >