Fix viewing-history artwork and add transcode playback metrics
This commit is contained in:
@@ -8,6 +8,11 @@ import './stats.css'
|
||||
|
||||
type Breakdown = { name: string; minutes: number }
|
||||
type Day = { date: string; minutes: number }
|
||||
type Transcoding = {
|
||||
video_minutes: number; audio_minutes: number; hardware_video_minutes: number; software_video_minutes: number
|
||||
unknown_hardware_minutes: number; unknown_video_minutes: number; unknown_audio_minutes: number
|
||||
hardware: Breakdown[]; audio_codecs: Breakdown[]; gpu_busy_minutes: null
|
||||
}
|
||||
type Stats = {
|
||||
state: 'ready' | 'not_configured' | 'unlinked'
|
||||
is_admin: boolean
|
||||
@@ -16,9 +21,10 @@ type Stats = {
|
||||
summary: null | { minutes: number; movies: number; episodes: number; plays: number; current_streak: number; longest_streak: number; active_days: number }
|
||||
daily?: Day[]
|
||||
top_titles?: { title: string; type: string; minutes: number; plays: number }[]
|
||||
recent?: { id: string; title: string; series: string; episode?: string; type: string; minutes: number; played_at: string; client: string; method: string }[]
|
||||
recent?: { id: string; title: string; series: string; episode?: string; type: string; minutes: number; played_at: string; client: string; method: string; artwork_url?: string | null }[]
|
||||
clients?: Breakdown[]
|
||||
methods?: Breakdown[]
|
||||
transcoding?: Transcoding
|
||||
requests: { total: number; movies: number; tv: number; pending: number; approved: number; declined: number; recent: { request_id: number; title: string; media_type: string; status: number }[] }
|
||||
}
|
||||
|
||||
@@ -55,6 +61,33 @@ function BreakdownCard({ title, rows }: { title: string; rows: Breakdown[] }) {
|
||||
return <section className="stats-panel"><div className="stats-panel-heading"><h2>{title}</h2></div>{rows.length ? <div className="stats-breakdown">{rows.map((row) => <div key={row.name}><div className="stats-breakdown-label"><span>{row.name}</span><strong>{number(row.minutes)} min</strong></div><div className="stats-meter"><span style={{ width: `${total ? row.minutes / total * 100 : 0}%` }} /></div></div>)}</div> : <p className="stats-muted">Your next watch will start the story here.</p>}</section>
|
||||
}
|
||||
|
||||
const minutes = (value: number) => `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })} min`
|
||||
|
||||
function StreamingCard({ rows, transcoding }: { rows: Breakdown[]; transcoding?: Transcoding }) {
|
||||
const total = rows.reduce((sum, row) => sum + row.minutes, 0)
|
||||
return <section className="stats-panel stats-streaming">
|
||||
<div className="stats-panel-heading"><h2>How you streamed</h2></div>
|
||||
<div className="stats-breakdown">{rows.map((row) => <div key={row.name}><div className="stats-breakdown-label"><span>{row.name}</span><strong>{minutes(row.minutes)}</strong></div><div className="stats-meter"><span style={{ width: `${total ? row.minutes / total * 100 : 0}%` }} /></div></div>)}</div>
|
||||
{transcoding && <div className="stats-transcoding">
|
||||
<h3>Transcoding playback time</h3>
|
||||
<div className="stats-transcode-metrics">
|
||||
<div><span>GPU-assisted video</span><strong>{transcoding.hardware_video_minutes === 0 && (transcoding.unknown_hardware_minutes > 0 || transcoding.unknown_video_minutes > 0) ? 'Not recorded' : minutes(transcoding.hardware_video_minutes)}</strong><small>{transcoding.hardware.map((entry) => `${entry.name} · ${minutes(entry.minutes)}`).join(' / ') || 'Hardware-accelerated video'}</small></div>
|
||||
<div><span>Audio transcoding</span><strong>{transcoding.audio_minutes === 0 && transcoding.unknown_audio_minutes > 0 ? 'Not recorded' : minutes(transcoding.audio_minutes)}</strong><small>{transcoding.audio_codecs.slice(0, 3).map((entry) => `${entry.name} · ${minutes(entry.minutes)}`).join(' / ') || 'Audio converted for your player'}</small></div>
|
||||
</div>
|
||||
<dl className="stats-transcode-details"><div><dt>Total video transcoding</dt><dd>{transcoding.video_minutes === 0 && transcoding.unknown_video_minutes > 0 ? 'Not recorded' : minutes(transcoding.video_minutes)}</dd></div>{transcoding.software_video_minutes > 0 && <div><dt>Software video</dt><dd>{minutes(transcoding.software_video_minutes)}</dd></div>}{transcoding.unknown_hardware_minutes > 0 && <div><dt>Video hardware not recorded</dt><dd>{minutes(transcoding.unknown_hardware_minutes)}</dd></div>}</dl>
|
||||
{(transcoding.unknown_audio_minutes > 0 || transcoding.unknown_video_minutes > 0) && <p className="stats-muted">Some stream details are missing: video {minutes(transcoding.unknown_video_minutes)}, audio {minutes(transcoding.unknown_audio_minutes)}.</p>}
|
||||
<p className="stats-muted">Playback minutes, with audio and video counted separately. They can overlap. GPU busy time is not recorded by Jellystat.</p>
|
||||
</div>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function RecentArtwork({ url, type }: { url?: string | null; type: string }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
return <div className={`stats-media-icon stats-media-icon-${type}`} aria-hidden="true">
|
||||
{url && !failed ? <img src={`${getApiBase()}${url}`} alt="" width={44} height={66} loading="lazy" onError={() => setFailed(true)} /> : <span>{type === 'episode' ? 'TV' : type === 'movie' ? 'MV' : '▶'}</span>}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default function InsightsPage() {
|
||||
const router = useRouter()
|
||||
const [days, setDays] = useState(30)
|
||||
@@ -120,11 +153,11 @@ export default function InsightsPage() {
|
||||
<div className="stats-three-grid">
|
||||
<section className="stats-panel"><div className="stats-panel-heading"><h2>Most watched</h2><span className="stats-unit">By minutes</span></div>{data.top_titles?.length ? <ol className="stats-top-titles">{data.top_titles.map((title, index) => <li key={`${title.title}-${index}`}><span className="stats-rank">{String(index + 1).padStart(2, '0')}</span><div><strong>{title.title}</strong><small>{title.type === 'series' ? 'TV series' : title.type === 'movie' ? 'Movie' : 'Other media'} · {title.plays} plays</small></div><span>{number(title.minutes)}<small>min</small></span></li>)}</ol> : <p className="stats-muted">Your favourites will find their place here.</p>}</section>
|
||||
<BreakdownCard title="Your players" rows={data.clients ?? []} />
|
||||
<BreakdownCard title="How you streamed" rows={data.methods ?? []} />
|
||||
<StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} />
|
||||
</div>
|
||||
</>}
|
||||
{data && <div className="stats-main-grid">
|
||||
{summary && <section className="stats-panel stats-history"><div className="stats-panel-heading"><h2>Recently watched</h2><span className="stats-unit">Latest 20 plays</span></div>{data.recent?.length ? <div className="stats-history-list">{data.recent.map((play) => <article key={play.id}><div className={`stats-media-icon stats-media-icon-${play.type}`} aria-hidden="true">{play.type === 'episode' ? 'TV' : play.type === 'movie' ? 'MV' : '▶'}</div><div className="stats-history-title"><strong>{play.series || play.title}</strong><small>{play.series ? `${play.episode} · ${play.title}` : play.client}</small><span>{play.client} · {play.method}</span></div><div className="stats-history-time"><strong>{number(play.minutes)} min</strong><time dateTime={play.played_at}>{dateLabel(play.played_at)}</time></div></article>)}</div> : <p className="stats-muted">Plays recorded by Jellystat will appear here.</p>}</section>}
|
||||
{summary && <section className="stats-panel stats-history"><div className="stats-panel-heading"><h2>Recently watched</h2><span className="stats-unit">Latest 20 plays</span></div>{data.recent?.length ? <div className="stats-history-list">{data.recent.map((play) => <article key={play.id}><RecentArtwork key={play.artwork_url ?? play.id} url={play.artwork_url} type={play.type} /><div className="stats-history-title"><strong>{play.series || play.title}</strong><small>{play.series ? `${play.episode} · ${play.title}` : play.type === 'movie' ? 'Movie' : 'Media'}</small><span>{play.client} · {play.method}</span></div><div className="stats-history-time"><strong>{number(play.minutes)} min</strong><time dateTime={play.played_at}>{dateLabel(play.played_at)}</time></div></article>)}</div> : <p className="stats-muted">Plays recorded by Jellystat will appear here.</p>}</section>}
|
||||
<section className="stats-panel stats-requests"><div className="stats-panel-heading"><h2>Your requests</h2><a href="/">View all</a></div><div className="stats-request-total"><strong>{data.requests.total}</strong><span>submitted in the past {days} days</span></div><div className="stats-request-counts"><span><strong>{data.requests.pending}</strong> Pending</span><span><strong>{data.requests.approved}</strong> Approved</span><span><strong>{data.requests.declined}</strong> Declined</span></div>{data.requests.recent.length > 0 ? <ul className="stats-request-list">{data.requests.recent.map((request) => <li key={request.request_id}><a href={`/requests/${request.request_id}`}>{request.title || `Request ${request.request_id}`}<span aria-hidden="true">↗</span></a></li>)}</ul> : <p className="stats-muted">Something on your watchlist? <a href="/new-requests">Make a request.</a></p>}</section>
|
||||
</div>}
|
||||
{summary && <p className="stats-footnote">Private to your account · Stats come from Jellystat and may take a minute to refresh. Counts describe plays, including unfinished watches. Movies are identified from Jellystat’s movie libraries; other media still contributes to watch time. Charts and streaks use UTC and the activity dates recorded by Jellystat.</p>}
|
||||
|
||||
@@ -51,7 +51,19 @@
|
||||
.stats-history-list { display: grid; }
|
||||
.stats-history-list article { display: flex; align-items: center; gap: 14px; padding: 15px 0; border-top: 1px solid var(--ops-line-soft); }
|
||||
.stats-history-list article:first-child { padding-top: 0; border-top: 0; }
|
||||
.stats-media-icon { display: grid; place-items: center; flex: 0 0 40px; height: 48px; border-radius: 6px; background: #373043; color: #cfc1eb; font: 10px "JetBrains Mono", monospace; }
|
||||
.stats-media-icon { display: grid; place-items: center; flex: 0 0 44px; height: 66px; border-radius: 6px; overflow: hidden; background: #373043; color: #cfc1eb; font: 10px "JetBrains Mono", monospace; }
|
||||
.stats-media-icon img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.stats-transcoding { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--ops-line-soft); }
|
||||
.stats-transcoding h3 { margin: 0 0 16px; color: var(--ops-text); font-size: 13px; font-weight: 500; }
|
||||
.stats-transcode-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.stats-transcode-metrics > div { display: grid; align-content: start; gap: 8px; min-width: 0; }
|
||||
.stats-transcode-metrics span { color: var(--ops-muted); font-size: 11px; }
|
||||
.stats-transcode-metrics strong { color: #d1c6ff; font-size: 20px; font-weight: 500; overflow-wrap: anywhere; }
|
||||
.stats-transcode-metrics small { color: var(--ops-faint); font-size: 10px; line-height: 1.7; overflow-wrap: anywhere; }
|
||||
.stats-transcode-details { display: grid; gap: 10px; margin: 20px 0 12px; }
|
||||
.stats-transcode-details > div { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; font-size: 11px; color: var(--ops-muted); }
|
||||
.stats-transcode-details dd { margin: 0; white-space: nowrap; color: var(--ops-faint); }
|
||||
.stats-transcoding > p { margin: 12px 0 0; font-size: 10px; }
|
||||
.stats-media-icon-movie { background: #3c322c; color: #e5bfa8; }
|
||||
.stats-history-title { flex: 1; min-width: 0; }
|
||||
.stats-history-title strong { display: block; color: var(--ops-text); font-size: 13px; font-weight: 500; overflow-wrap: anywhere; }
|
||||
|
||||
Reference in New Issue
Block a user