formatters, device profile, media matching, subtitle utils, syncplay, trakt

This commit is contained in:
2026-03-24 10:48:59 +02:00
parent 292b3f42cf
commit 996a85de76
41 changed files with 4306 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react'
/**
* Bundled per-show anime filler classification. Lives at
* `/data/anime-filler.json`, fetched on demand. Keyed by TMDB id (string).
*
* Shape:
* {
* "<tmdbId>": {
* "filler": [12, 13, 28, 29, 30],
* "mostlyFiller": [60, 84]
* }
* }
*
* This is a v1 curated bundled list - covers Naruto, Bleach, One Piece,
* Dragon Ball Z, Hunter x Hunter (1999), etc. Easy to extend by editing
* the JSON. We deliberately don't go online for it because no public
* filler API has reliable CORS / uptime, and the underlying lists rarely
* change.
*/
export type FillerFlag = 'filler' | 'mostly-filler' | 'canon' | null
interface ShowFillerData {
filler?: number[]
mostlyFiller?: number[]
}
type FillerDb = Record<string, ShowFillerData>
let cache: FillerDb | null = null
let cachePromise: Promise<FillerDb> | null = null
async function loadFillerDb(): Promise<FillerDb> {
if (cache) return cache
if (cachePromise) return cachePromise
cachePromise = (async () => {
try {
const res = await fetch('/data/anime-filler.json')
if (!res.ok) return {}
cache = (await res.json()) as FillerDb
return cache
} catch {
cache = {}
return cache
}
})()
return cachePromise
}
/**
* Hook: load the filler database once and look up the entry for a given
* TMDB id. Returns null until the data is loaded, or if the show isn't in
* the list (the common case for non-anime).
*/
export function useAnimeFiller(tmdbId?: string | number | null) {
const id = tmdbId != null ? String(tmdbId) : null
const [data, setData] = useState<ShowFillerData | null>(null)
useEffect(() => {
if (!id) {
setData(null)
return
}
let cancelled = false
loadFillerDb().then(db => {
if (cancelled) return
setData(db[id] || null)
})
return () => {
cancelled = true
}
}, [id])
return data
}
/**
* Classify an absolute episode number against the loaded filler data.
* Anime filler lists almost always count by absolute episode number, not
* by season + episode, so the caller should pass the running episode
* count rather than the per-season number.
*/
export function classifyEpisode(
absoluteEpisode: number,
data: ShowFillerData | null,
): FillerFlag {
if (!data) return null
if (data.filler?.includes(absoluteEpisode)) return 'filler'
if (data.mostlyFiller?.includes(absoluteEpisode)) return 'mostly-filler'
return 'canon'
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Lazy Web Audio graph wrapped around the player's <video> element.
*
* The graph is only created the first time we need to alter audio
* (delay, boost above 100%, compressor on). Until then audio plays
* through the native path and the existing volume slider works
* normally. Once attached, audio routes through:
*
* <video> → MediaElementSource → DelayNode → DynamicsCompressorNode
* → GainNode → destination
*
* Important caveats:
* - createMediaElementAudioSource is one-shot per element. If vidstack
* swaps the underlying <video>, we have to detach and rebuild against
* the new element.
* - When the graph exists, the element's own `.volume` is multiplied
* by the graph's gain. We keep gain >= 1 (boost) and let the slider
* drive .volume; otherwise the slider would feel "weaker" than 100%.
* - Compressor is in the chain at all times; when "off", we set its
* ratio to 1 to make it a no-op rather than re-wiring the graph.
*/
export interface AudioGraphState {
delayMs: number // DelayNode delayTime (ms)
boost: number // GainNode gain (1.0 = unity)
compressor: boolean // DynamicsCompressorNode active
}
interface AudioGraph {
ctx: AudioContext
source: MediaElementAudioSourceNode
delay: DelayNode
compressor: DynamicsCompressorNode
gain: GainNode
videoEl: HTMLVideoElement
}
let graph: AudioGraph | null = null
/** True when an audio graph is currently attached. */
export function hasAudioGraph(): boolean {
return graph !== null
}
/**
* Ensure an audio graph is attached to the given video element. If a graph
* already exists for a different element, tear it down and rebuild. Calling
* this with the same element is idempotent.
*/
export function ensureAudioGraph(video: HTMLVideoElement): AudioGraph | null {
if (graph && graph.videoEl === video) return graph
if (graph) detachAudioGraph()
try {
const Ctor = window.AudioContext || (window as any).webkitAudioContext
if (!Ctor) return null
const ctx: AudioContext = new Ctor()
const source = ctx.createMediaElementSource(video)
const delay = ctx.createDelay(2.0)
delay.delayTime.value = 0
const compressor = ctx.createDynamicsCompressor()
compressor.threshold.value = -50
compressor.knee.value = 30
compressor.ratio.value = 1 // off by default; flipping nightMode raises this
compressor.attack.value = 0.003
compressor.release.value = 0.25
const gain = ctx.createGain()
gain.gain.value = 1
source.connect(delay)
delay.connect(compressor)
compressor.connect(gain)
gain.connect(ctx.destination)
graph = { ctx, source, delay, compressor, gain, videoEl: video }
return graph
} catch {
return null
}
}
/**
* Apply state to the active graph. If state is "default" (no delay, no
* boost, no compressor) and there's no graph yet, this is a no-op so we
* keep the native audio path. If a graph already exists, we keep it
* attached - one-shot MediaElementSource means we can't undo it cleanly.
*/
export function applyAudioGraphState(video: HTMLVideoElement, state: AudioGraphState) {
const isDefault = state.delayMs === 0 && state.boost === 1 && !state.compressor
if (isDefault && !graph) return
const g = ensureAudioGraph(video)
if (!g) return
// Resume the context if it was suspended (autoplay policy)
if (g.ctx.state === 'suspended') g.ctx.resume().catch(() => {})
g.delay.delayTime.value = Math.max(0, Math.min(2, state.delayMs / 1000))
g.gain.gain.value = Math.max(0, Math.min(3, state.boost))
// Compressor on: fairly aggressive ratio for night-mode use
g.compressor.ratio.value = state.compressor ? 12 : 1
g.compressor.threshold.value = state.compressor ? -24 : -50
}
/**
* Detach and dispose the current graph. Call when the underlying video
* element changes (vidstack reload) or when the player unmounts.
*/
export function detachAudioGraph() {
if (!graph) return
try {
graph.source.disconnect()
graph.delay.disconnect()
graph.compressor.disconnect()
graph.gain.disconnect()
graph.ctx.close()
} catch {
/* ignored */
}
graph = null
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Per-item playback bookmarks. Persisted in localStorage keyed by Jellyfin
* itemId. Each entry has a position (in seconds) and an optional note.
*
* Storage shape: `jf_bookmarks_<itemId>` -> `Bookmark[]` JSON.
*/
export interface Bookmark {
/** Unique id within this item. Random string. */
id: string
/** Position in seconds. */
positionSec: number
/** Optional user note. */
note?: string
/** Created at, ms since epoch. */
createdAt: number
}
const KEY_PREFIX = 'jf_bookmarks_'
function key(itemId: string): string {
return `${KEY_PREFIX}${itemId}`
}
export function loadBookmarks(itemId: string): Bookmark[] {
if (!itemId) return []
try {
const raw = localStorage.getItem(key(itemId))
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as Bookmark[]) : []
} catch {
return []
}
}
export function saveBookmarks(itemId: string, bookmarks: Bookmark[]): void {
if (!itemId) return
try {
localStorage.setItem(key(itemId), JSON.stringify(bookmarks))
} catch {
/* quota - drop silently */
}
}
function genId(): string {
return Math.random().toString(36).slice(2, 10)
}
export function addBookmark(itemId: string, positionSec: number, note?: string): Bookmark {
const list = loadBookmarks(itemId)
const bm: Bookmark = {
id: genId(),
positionSec,
note,
createdAt: Date.now(),
}
list.push(bm)
list.sort((a, b) => a.positionSec - b.positionSec)
saveBookmarks(itemId, list)
return bm
}
export function removeBookmark(itemId: string, bookmarkId: string): void {
const list = loadBookmarks(itemId).filter(b => b.id !== bookmarkId)
saveBookmarks(itemId, list)
}
export function updateBookmark(
itemId: string,
bookmarkId: string,
patch: Partial<Pick<Bookmark, 'note' | 'positionSec'>>,
): void {
const list = loadBookmarks(itemId).map(b =>
b.id === bookmarkId ? { ...b, ...patch } : b,
)
list.sort((a, b) => a.positionSec - b.positionSec)
saveBookmarks(itemId, list)
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Bundled canon lists - curated arrays of TMDB ids for well-known
* "best films of all time" lists. Used by the home page's "Discover
* canon" section to surface highly regarded works against the user's
* library so missing entries are obvious.
*
* Each list keeps a small descriptive blurb plus the source (so the
* row can credit it). Lists are intentionally short to avoid a wall of
* unfamiliar titles; users who care can check the full list at the
* source. Where a TMDB id couldn't be confirmed at curate time, the
* entry was dropped rather than risk surfacing the wrong film.
*/
export interface CanonList {
id: string
title: string
subtitle: string
source: string
/** Ordered TMDB movie ids, top of list first. */
tmdbMovieIds: number[]
}
export const CANON_LISTS: CanonList[] = [
{
id: 'afi-classics',
title: 'AFI canon',
subtitle: 'American Film Institute "100 Years...100 Movies"',
source: 'AFI',
tmdbMovieIds: [
15, // Citizen Kane
238, // The Godfather
289, // Casablanca
1578, // Raging Bull
872, // Singin' in the Rain
11, // Star Wars
85, // Raiders of the Lost Ark
426, // Vertigo
539, // Psycho
599, // Sunset Boulevard
278, // The Shawshank Redemption
5894, // The Searchers
424, // Schindler's List
389, // 12 Angry Men
769, // GoodFellas
408, // Snow White and the Seven Dwarfs
857, // Saving Private Ryan
105, // Back to the Future
],
},
{
id: 'sight-and-sound',
title: 'Sight & Sound Greatest Films',
subtitle: 'BFI critics poll',
source: 'Sight & Sound / BFI',
tmdbMovieIds: [
426, // Vertigo
15, // Citizen Kane
843, // Tokyo Story
62, // 2001: A Space Odyssey
805, // Persona
672, // Mulholland Drive
11216, // Cinema Paradiso
28, // Apocalypse Now
346, // Seven Samurai
872, // Singin' in the Rain
],
},
{
id: 'imdb-top',
title: 'IMDb Top 25',
subtitle: 'Highest-rated by IMDb users',
source: 'IMDb',
tmdbMovieIds: [
278, // The Shawshank Redemption
238, // The Godfather
155, // The Dark Knight
240, // The Godfather Part II
389, // 12 Angry Men
424, // Schindler's List
120, // LOTR: The Fellowship of the Ring
121, // LOTR: The Two Towers
122, // LOTR: The Return of the King
680, // Pulp Fiction
550, // Fight Club
27205, // Inception
603, // The Matrix
769, // GoodFellas
11, // Star Wars
1891, // The Empire Strikes Back
157336, // Interstellar
346, // Seven Samurai
129, // Spirited Away
637, // Life Is Beautiful
],
},
]
+60
View File
@@ -0,0 +1,60 @@
import { jellyfinClient, getSessionApi } from '../api/jellyfin'
/**
* Remote-control / cast helpers built on top of Jellyfin's Sessions API.
*
* The server tracks every connected client (web, mobile, DLNA-bridged TV,
* Chromecast via plugin, etc.) as a session. Any session that's flagged
* `SupportsRemoteControl` can receive Play commands from another client -
* so "casting" here is really "tell session X to play item Y".
*
* No native DLNA discovery from this app. We just enumerate what the
* server already knows about.
*/
export interface CastTarget {
sessionId: string
deviceName: string
client: string
userName: string | null
mediaTypes: string[]
}
function getDeviceId(): string | null {
return jellyfinClient.getApi()?.deviceInfo?.id ?? null
}
export async function listCastTargets(mediaType?: string): Promise<CastTarget[]> {
const api = jellyfinClient.getApi()
if (!api) return []
const self = getDeviceId()
const res = await getSessionApi(api).getSessions()
const sessions = res.data || []
return sessions
.filter(s => {
if (!s.SupportsRemoteControl) return false
if (!s.Id) return false
if (self && s.DeviceId === self) return false
if (mediaType && s.PlayableMediaTypes && s.PlayableMediaTypes.length > 0) {
return s.PlayableMediaTypes.some(t => String(t).toLowerCase() === mediaType.toLowerCase())
}
return true
})
.map(s => ({
sessionId: s.Id!,
deviceName: s.DeviceName || 'Unknown device',
client: s.Client || '',
userName: s.UserName ?? null,
mediaTypes: (s.PlayableMediaTypes || []).map(String),
}))
}
export async function sendToSession(sessionId: string, itemId: string): Promise<void> {
const api = jellyfinClient.getApi()
if (!api) throw new Error('Not authenticated')
await getSessionApi(api).play({
sessionId,
playCommand: 'PlayNow' as any,
itemIds: [itemId],
})
}
+26
View File
@@ -0,0 +1,26 @@
import { usePreferencesStore } from '../stores/preferences-store'
/**
* Density-aware grid + spacing helpers. Reads from the user's pref so any
* caller automatically tightens up when "Compact" is selected in Settings.
*
* Comfortable: 3-7 columns from sm to 2xl, 16/28px gaps
* Compact: 4-8 columns from sm to 2xl, 12/20px gaps
*/
export function usePosterGridClasses(): string {
const density = usePreferencesStore(s => s.density)
return density === 'compact'
? 'grid grid-cols-4 sm:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8 gap-3 gap-y-5'
: 'grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4 gap-y-7'
}
export function useSquareGridClasses(): string {
const density = usePreferencesStore(s => s.density)
return density === 'compact'
? 'grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8 gap-3 gap-y-5'
: 'grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4 gap-y-7'
}
export function useDensity() {
return usePreferencesStore(s => s.density)
}
+218
View File
@@ -0,0 +1,218 @@
/**
* A Jellyfin DeviceProfile describing what this browser client can play.
*
* The server uses this to decide direct-play vs direct-stream vs transcode
* and to choose codecs/containers that browsers can decode.
*
* Strategy mirrors jellyfin-web:
* - DirectPlay any mp4/m4v with codecs the browser advertises support for.
* - Transcode (or stream-copy via remux) into fMP4 over HLS, served through
* hls.js + MSE. Listing multiple VideoCodec values on the TranscodingProfile
* lets the server pick the source codec when it's already compatible -
* that's the difference between "remux a file in 2 seconds" and "re-encode
* every frame in real time". A Raspberry Pi can do the former; nothing in
* this codebase makes it do the latter unless the source is genuinely
* incompatible.
*
* MaxStreamingBitrate is intentionally high - we'd rather direct-stream the
* source bitrate as-is than throttle it. The server only honours the cap when
* a transcode is actually required.
*/
/**
* Best-effort runtime probes for codec support in MSE. Browsers report this
* accurately for VP9/AV1; HEVC support is hardware-gated and can vary even
* on the same browser version, so we trust whatever isTypeSupported says.
*/
function canPlayInMse(mime: string): boolean {
try {
return typeof MediaSource !== 'undefined' && MediaSource.isTypeSupported(mime)
} catch {
return false
}
}
function supportedVideoCodecs(): string[] {
const codecs: string[] = ['h264'] // Universally supported in MSE
if (canPlayInMse('video/mp4; codecs="hev1.1.6.L93.B0"') ||
canPlayInMse('video/mp4; codecs="hvc1.1.6.L93.B0"')) {
codecs.push('hevc')
}
if (canPlayInMse('video/mp4; codecs="vp09.00.10.08"')) {
codecs.push('vp9')
}
if (canPlayInMse('video/mp4; codecs="av01.0.04M.08"')) {
codecs.push('av1')
}
return codecs
}
/**
* HDR support detection. Probes for the codec strings the major HDR
* formats use:
* - HDR10 / HLG via HEVC main10 level 5.1 (`hvc1.2.4.L153.B0`) or VP9.2
* (`vp09.02.10.10.01.09.16.09.00`)
* - Dolby Vision via the `dvh1.05.06` codec string
* When a format is detected we add it to the `VideoRangeType` matrix on
* the corresponding CodecProfile so the server direct-plays HDR content
* instead of falling back to a tone-mapped SDR transcode.
*/
function supportedVideoRanges(): string {
const ranges = ['SDR']
if (
canPlayInMse('video/mp4; codecs="hvc1.2.4.L153.B0"') ||
canPlayInMse('video/mp4; codecs="hev1.2.4.L153.B0"') ||
canPlayInMse('video/webm; codecs="vp09.02.10.10.01.09.16.09.00"')
) {
ranges.push('HDR10', 'HLG')
}
if (
canPlayInMse('video/mp4; codecs="dvh1.05.06"') ||
canPlayInMse('video/mp4; codecs="dvhe.05.06"')
) {
ranges.push('DOVI', 'DOVIWithHDR10', 'DOVIWithHLG', 'DOVIWithSDR')
}
return ranges.join('|')
}
export function browserDeviceProfile() {
const videoCodecs = supportedVideoCodecs()
const videoCodecsCsv = videoCodecs.join(',')
const videoRanges = supportedVideoRanges()
return {
Name: 'Jellyfin Browser Client',
MaxStreamingBitrate: 120_000_000,
MaxStaticBitrate: 100_000_000,
MusicStreamingTranscodingBitrate: 384_000,
DirectPlayProfiles: [
{
Container: 'mp4,m4v',
Type: 'Video',
VideoCodec: videoCodecsCsv,
AudioCodec: 'aac,mp3,ac3,eac3,flac,opus',
},
{
Container: 'webm',
Type: 'Video',
VideoCodec: 'vp8,vp9,av1',
AudioCodec: 'opus,vorbis',
},
],
TranscodingProfiles: [
{
// fMP4 over HLS. Listing the full supported codec set lets the server
// stream-copy (remux only) when the source already matches one of
// them - that's what makes h264-in-mkv "play instantly" instead of
// burning CPU. The browser plays this through MSE via hls.js.
Container: 'mp4',
Type: 'Video',
VideoCodec: videoCodecsCsv,
AudioCodec: 'aac,mp3',
Protocol: 'hls',
Context: 'Streaming',
MaxAudioChannels: '6',
MinSegments: 1,
BreakOnNonKeyFrames: true,
},
{
Container: 'aac',
Type: 'Audio',
AudioCodec: 'aac',
Context: 'Streaming',
Protocol: 'http',
MaxAudioChannels: '2',
},
],
ContainerProfiles: [],
CodecProfiles: [
// Bound h264 to profiles/levels MSE actually decodes - avoids the
// server stream-copying high-10 or level 5.2 footage that the browser
// would silently refuse.
{
Type: 'Video',
Codec: 'h264',
Conditions: [
{
Condition: 'EqualsAny',
Property: 'VideoProfile',
Value: 'high|main|baseline|constrained baseline|high 10',
IsRequired: false,
},
{
Condition: 'LessThanEqual',
Property: 'VideoLevel',
Value: '52',
IsRequired: false,
},
],
},
// HEVC: cap at main / main10 + level 5.1 - matches what hardware
// decoders on most consumer GPUs can chew through. VideoRangeType
// advertises whichever HDR formats the browser actually supports so
// the server direct-plays HDR sources instead of tone-mapping to SDR.
{
Type: 'Video',
Codec: 'hevc',
Conditions: [
{
Condition: 'EqualsAny',
Property: 'VideoProfile',
Value: 'main|main 10',
IsRequired: false,
},
{
Condition: 'LessThanEqual',
Property: 'VideoLevel',
Value: '153',
IsRequired: false,
},
{
Condition: 'EqualsAny',
Property: 'VideoRangeType',
Value: videoRanges,
IsRequired: false,
},
],
},
// VP9 + AV1 also support HDR transport; advertise the same range
// matrix so 4K HDR YouTube-style sources direct-play.
{
Type: 'Video',
Codec: 'vp9',
Conditions: [
{
Condition: 'EqualsAny',
Property: 'VideoRangeType',
Value: videoRanges,
IsRequired: false,
},
],
},
{
Type: 'Video',
Codec: 'av1',
Conditions: [
{
Condition: 'EqualsAny',
Property: 'VideoRangeType',
Value: videoRanges,
IsRequired: false,
},
],
},
],
SubtitleProfiles: [
{ Format: 'vtt', Method: 'External' },
{ Format: 'subrip', Method: 'External' },
{ Format: 'ass', Method: 'External' },
{ Format: 'ssa', Method: 'External' },
],
ResponseProfiles: [],
}
}
+143
View File
@@ -0,0 +1,143 @@
import type { DiaryEntry } from '../stores/diary-store'
/**
* Diary export helpers. Three flavours:
* - Markdown: human-readable log grouped by month
* - JSON: faithful dump for backups or re-import
* - CSV: Letterboxd-import-compatible (Title, Watched Date, Rating, Rewatch, Review, Tags)
*
* All produce a string ready to hand to triggerDownload(). No file IO here -
* keeps these pure and testable.
*/
export type ExportFormat = 'markdown' | 'json' | 'csv'
function pad(n: number) {
return n < 10 ? '0' + n : String(n)
}
function ymd(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
function monthLabel(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return 'Undated'
return d.toLocaleString('en-US', { month: 'long', year: 'numeric' })
}
function escapeCsv(value: string): string {
if (value === '') return ''
if (/[",\n\r]/.test(value)) {
return '"' + value.replace(/"/g, '""') + '"'
}
return value
}
export function toMarkdown(entries: DiaryEntry[]): string {
const sorted = [...entries].sort((a, b) => b.watchedAt.localeCompare(a.watchedAt))
const groups = new Map<string, DiaryEntry[]>()
for (const e of sorted) {
const key = monthLabel(e.watchedAt)
const arr = groups.get(key)
if (arr) arr.push(e)
else groups.set(key, [e])
}
const lines: string[] = []
lines.push('# Watch diary')
lines.push('')
lines.push(`_${sorted.length} ${sorted.length === 1 ? 'entry' : 'entries'} - exported ${ymd(new Date().toISOString())}_`)
lines.push('')
for (const [label, items] of groups) {
lines.push(`## ${label}`)
lines.push('')
for (const e of items) {
const date = ymd(e.watchedAt)
const rating = e.rating ? ` - ${e.rating}/10` : ''
const rewatch = e.rewatch ? ' (rewatch)' : ''
const emoji = e.emoji ? ` ${e.emoji}` : ''
lines.push(`- **${date}** - ${e.itemName}${rating}${rewatch}${emoji}`)
if (e.note && e.note.trim()) {
for (const line of e.note.trim().split(/\r?\n/)) {
lines.push(` > ${line}`)
}
}
}
lines.push('')
}
return lines.join('\n')
}
export function toJson(entries: DiaryEntry[]): string {
const sorted = [...entries].sort((a, b) => b.watchedAt.localeCompare(a.watchedAt))
return JSON.stringify(
{
exportedAt: new Date().toISOString(),
count: sorted.length,
entries: sorted,
},
null,
2,
)
}
/**
* Letterboxd import format. Columns chosen from their documented set
* (https://letterboxd.com/about/importing-data/). Title + Watched Date
* are the only required fields; everything else is optional and they'll
* tolerate missing values cleanly. Rating is converted from our 1-10
* scale to their 0.5-5 scale by halving.
*/
export function toLetterboxdCsv(entries: DiaryEntry[]): string {
const header = ['Title', 'Watched Date', 'Rating', 'Rewatch', 'Review', 'Tags']
const sorted = [...entries].sort((a, b) => b.watchedAt.localeCompare(a.watchedAt))
const rows = sorted.map(e => {
const rating = e.rating ? (e.rating / 2).toFixed(1) : ''
const rewatch = e.rewatch ? 'Yes' : ''
const review = e.note || ''
const tags = e.emoji || ''
return [
escapeCsv(e.itemName),
ymd(e.watchedAt),
rating,
rewatch,
escapeCsv(review),
escapeCsv(tags),
].join(',')
})
return [header.join(','), ...rows].join('\r\n')
}
const MIME: Record<ExportFormat, string> = {
markdown: 'text/markdown;charset=utf-8',
json: 'application/json;charset=utf-8',
csv: 'text/csv;charset=utf-8',
}
const EXT: Record<ExportFormat, string> = {
markdown: 'md',
json: 'json',
csv: 'csv',
}
export function triggerDownload(content: string, format: ExportFormat): void {
const blob = new Blob([content], { type: MIME[format] })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `diary-${ymd(new Date().toISOString())}.${EXT[format]}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 0)
}
export function exportDiary(entries: DiaryEntry[], format: ExportFormat): void {
const content =
format === 'markdown' ? toMarkdown(entries)
: format === 'json' ? toJson(entries)
: toLetterboxdCsv(entries)
triggerDownload(content, format)
}
+95
View File
@@ -0,0 +1,95 @@
import type { ComponentType } from 'react'
import {
Sword,
MasksTheater,
MoodHappy,
Rocket,
Spy,
Ghost,
Palette,
HomeHeart,
Wand,
Binoculars,
Backpack,
Leaf,
News,
Trophy,
Microphone,
Gavel,
Globe,
Tv,
Film as FilmIcon,
} from './icons'
type IconCmp = ComponentType<{ size?: number; stroke?: number; className?: string }>
/**
* Visual mapping for the Browse-by tiles. Each label gets a glyph + a
* solid tonal hue so the grid reads as a mosaic of distinct sections
* instead of a uniform wall of cards.
*
* Hue values are HSL tuples we feed to the tile background; the same
* hue at higher saturation drives the icon tint. Keeping everything on
* a single chromatic axis (warm-to-cool sweep) preserves the app's
* tonal-dark palette - no random rainbow.
*/
export interface TileVisual {
icon: IconCmp
/** HSL hue value 0-360. Tile uses this at low saturation + high luminance. */
hue: number
}
export const GENRE_VISUALS: Record<string, TileVisual> = {
Action: { icon: Sword, hue: 14 },
Drama: { icon: MasksTheater, hue: 280 },
Comedy: { icon: MoodHappy, hue: 48 },
'Science Fiction': { icon: Rocket, hue: 200 },
Thriller: { icon: Spy, hue: 0 },
Horror: { icon: Ghost, hue: 320 },
Animation: { icon: Palette, hue: 170 },
Romance: { icon: HomeHeart, hue: 340 },
Fantasy: { icon: Wand, hue: 260 },
Mystery: { icon: Binoculars, hue: 220 },
Adventure: { icon: Backpack, hue: 35 },
Documentary: { icon: Leaf, hue: 130 },
Crime: { icon: Gavel, hue: 10 },
Family: { icon: HomeHeart, hue: 40 },
News: { icon: News, hue: 210 },
Music: { icon: Microphone, hue: 290 },
History: { icon: Trophy, hue: 30 },
}
const GENRE_FALLBACK: TileVisual = { icon: FilmIcon, hue: 200 }
export function genreVisual(label: string): TileVisual {
return GENRE_VISUALS[label] || GENRE_FALLBACK
}
const LANG_HUE: Record<string, number> = {
ja: 0,
ko: 220,
fr: 245,
es: 25,
de: 50,
it: 130,
zh: 12,
hi: 30,
}
export function languageVisual(code: string): TileVisual {
return { icon: Globe, hue: LANG_HUE[code] ?? 200 }
}
export function studioVisual(): TileVisual {
return { icon: FilmIcon, hue: 40 }
}
export function networkVisual(): TileVisual {
return { icon: Tv, hue: 220 }
}
/** Build the gradient background CSS for a tile from a hue value. */
export function tileBackground(hue: number): string {
return `linear-gradient(135deg, hsla(${hue}, 50%, 38%, 0.32) 0%, hsla(${hue}, 30%, 18%, 0.55) 100%)`
}
+202
View File
@@ -0,0 +1,202 @@
import type { ComponentType } from 'react'
import {
Confetti,
Award,
Globe,
Spy,
MoonStars,
Heart,
Ghost,
HomeHeart,
Clock,
Trophy,
} from './icons'
/**
* Mood-first discovery entry points. Each mood maps to a TMDB filter
* recipe - the user picks a vibe, not a row from a wall of rows. The
* `extra` filter narrows the result client-side when a TMDB param
* alone can't express the mood (e.g. "non-English original").
*/
export interface DiscoverMood {
id: string
label: string
blurb: string
icon: ComponentType<{ size?: number; stroke?: number; className?: string }>
/** TMDB /discover/movie params */
movieParams: Record<string, string>
/** TMDB /discover/tv params - omit for movie-only moods. */
tvParams?: Record<string, string>
/** Optional client-side filter applied on top of the TMDB results. */
extra?: (m: { original_language?: string; vote_average?: number; runtime?: number }) => boolean
}
export const DISCOVER_MOODS: DiscoverMood[] = [
{
id: 'quick-laugh',
label: 'Quick laugh',
blurb: 'Comedy that doesn\'t overstay',
icon: Confetti,
movieParams: {
with_genres: '35',
'with_runtime.lte': '100',
'vote_count.gte': '500',
sort_by: 'popularity.desc',
},
tvParams: {
with_genres: '35',
'vote_count.gte': '200',
sort_by: 'vote_average.desc',
},
},
{
id: 'cinematic',
label: 'Cinematic epic',
blurb: 'Prestige, ambition, scale',
icon: Award,
movieParams: {
'with_runtime.gte': '140',
'vote_average.gte': '7.5',
'vote_count.gte': '1000',
sort_by: 'vote_average.desc',
},
tvParams: {
with_genres: '18',
'vote_average.gte': '8',
'vote_count.gte': '500',
sort_by: 'vote_average.desc',
},
},
{
id: 'foreign',
label: 'Foreign cinema',
blurb: 'Outside the English-speaking world',
icon: Globe,
movieParams: {
'vote_count.gte': '500',
'vote_average.gte': '7',
sort_by: 'vote_average.desc',
},
tvParams: {
'vote_count.gte': '200',
'vote_average.gte': '7.5',
sort_by: 'vote_average.desc',
},
extra: m => !!m.original_language && m.original_language !== 'en',
},
{
id: 'edge-of-seat',
label: 'Edge of seat',
blurb: 'Thrillers and tense rides',
icon: Spy,
movieParams: {
with_genres: '53',
'vote_count.gte': '500',
sort_by: 'vote_average.desc',
},
tvParams: {
with_genres: '9648,80',
'vote_count.gte': '200',
sort_by: 'vote_average.desc',
},
},
{
id: 'mind-bending',
label: 'Mind-bending',
blurb: 'Sci-fi that makes you think',
icon: MoonStars,
movieParams: {
with_genres: '878',
'vote_count.gte': '500',
'vote_average.gte': '7',
sort_by: 'vote_average.desc',
},
tvParams: {
with_genres: '10765',
'vote_count.gte': '200',
'vote_average.gte': '7.5',
sort_by: 'vote_average.desc',
},
},
{
id: 'feel-good',
label: 'Feel good',
blurb: 'Light, warm, easy watching',
icon: Heart,
movieParams: {
with_genres: '10749,35',
'vote_count.gte': '500',
sort_by: 'popularity.desc',
},
tvParams: {
with_genres: '35,10751',
'vote_count.gte': '200',
sort_by: 'popularity.desc',
},
},
{
id: 'lights-off',
label: 'Lights off',
blurb: 'Horror with a high bar',
icon: Ghost,
movieParams: {
with_genres: '27',
'vote_count.gte': '500',
'vote_average.gte': '6.5',
sort_by: 'vote_average.desc',
},
tvParams: {
with_genres: '9648',
'vote_count.gte': '200',
'vote_average.gte': '7',
sort_by: 'vote_average.desc',
},
},
{
id: 'family',
label: 'Family night',
blurb: 'Everyone in the room',
icon: HomeHeart,
movieParams: {
with_genres: '10751',
'vote_count.gte': '500',
sort_by: 'vote_average.desc',
},
tvParams: {
with_genres: '10751,16',
'vote_count.gte': '200',
sort_by: 'vote_average.desc',
},
},
{
id: 'short',
label: 'One sitting',
blurb: 'Under 95 minutes, well-rated',
icon: Clock,
movieParams: {
'with_runtime.lte': '95',
'with_runtime.gte': '70',
'vote_count.gte': '500',
'vote_average.gte': '7',
sort_by: 'vote_average.desc',
},
// Intentionally no tvParams - the "one sitting" mood is movie-only.
},
{
id: 'all-time',
label: 'All-time greats',
blurb: 'The canon, things you missed',
icon: Trophy,
movieParams: {
'vote_count.gte': '5000',
'vote_average.gte': '8',
sort_by: 'vote_average.desc',
},
tvParams: {
'vote_count.gte': '2000',
'vote_average.gte': '8.3',
sort_by: 'vote_average.desc',
},
},
]
+94
View File
@@ -0,0 +1,94 @@
import { useDownloads } from '../stores/downloads-store'
import { isTauri } from './tauri'
/**
* Download a Jellyfin stream to local storage.
*
* Browser: caches the response as a Blob URL (session-only, lost on reload).
* Tauri: writes the bytes to the app's AppLocalData directory and stores the path.
*/
export async function startDownload(args: {
itemId: string
name: string
posterUrl?: string | null
streamUrl: string
subtitleUrl?: string | null
}) {
const { itemId, name, posterUrl, streamUrl, subtitleUrl } = args
const store = useDownloads.getState()
// Don't re-queue if already active
if (store.getByItemId(itemId)) {
return
}
const dl = store.add({ itemId, name, posterUrl, streamUrl, subtitleUrl })
try {
store.update(dl.id, { status: 'downloading', progress: 0 })
if (isTauri) {
// Tauri path: fetch via the Rust-backed HTTP client so we avoid
// CORS + we can stream large files without the browser's memory
// pressure.
const { fetch } = await import('@tauri-apps/api/http')
const { appLocalDataDir } = await import('@tauri-apps/api/path')
const { writeBinaryFile, BaseDirectory } = await import('@tauri-apps/api/fs')
const res = await fetch<Uint8Array>(streamUrl, {
method: 'GET',
responseType: 3, // ResponseType.Binary
})
const bytes = res.data
const dir = await appLocalDataDir()
const fileName = `download_${itemId}_${Date.now()}.mp4`
await writeBinaryFile(fileName, bytes, { dir: BaseDirectory.AppLocalData })
const localPath = `${dir}/${fileName}`
store.update(dl.id, {
status: 'done',
progress: 100,
sizeBytes: bytes.length,
localPath,
})
return
}
// Browser path: fetch with progress tracking via a ReadableStream reader
const response = await fetch(streamUrl)
if (!response.ok || !response.body) {
throw new Error(`Download failed: ${response.status}`)
}
const contentLength = Number(response.headers.get('content-length')) || 0
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let received = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
received += value.length
if (contentLength > 0) {
store.update(dl.id, {
progress: Math.min(100, Math.round((received / contentLength) * 100)),
})
}
}
// Assemble the full blob
const blob = new Blob(chunks)
const objectUrl = URL.createObjectURL(blob)
store.update(dl.id, {
status: 'done',
progress: 100,
sizeBytes: received,
localPath: objectUrl,
})
} catch (err: any) {
store.update(dl.id, {
status: 'error',
error: err?.message || 'Download failed',
})
}
}
+70
View File
@@ -0,0 +1,70 @@
import { createContext, useContext, type ReactNode } from 'react'
import type { CinemetaEpisode } from '../api/cinemeta'
import type { FillerFlag } from './anime-filler'
/**
* Per-page-scoped enrichment for an episode list. DetailPage builds this
* once for the current series and provides it; EpisodeRow + EpisodesPanel
* read via the hooks so we don't have to drill three things through three
* layers of components.
*
* Carries:
* - `cinemetaMap`: Cinemeta video lookup keyed by `${season}:${episode}`
* - `fillerOf`: pure function (season, episode) -> FillerFlag, computed
* against the bundled anime-filler list using absolute episode number
* derivation from the season-length map the provider builds.
*/
type CinemetaMap = Map<string, CinemetaEpisode>
type FillerOf = (season: number | null | undefined, episode: number | null | undefined) => FillerFlag
interface EpisodeMetaValue {
cinemetaMap: CinemetaMap
fillerOf: FillerOf
}
const DEFAULT: EpisodeMetaValue = {
cinemetaMap: new Map(),
fillerOf: () => null,
}
const EpisodeMetaContext = createContext<EpisodeMetaValue>(DEFAULT)
export function EpisodeMetaProvider({
cinemetaMap,
fillerOf,
children,
}: {
cinemetaMap: CinemetaMap
fillerOf: FillerOf
children: ReactNode
}) {
return (
<EpisodeMetaContext.Provider value={{ cinemetaMap, fillerOf }}>
{children}
</EpisodeMetaContext.Provider>
)
}
export function useEpisodeMeta(): EpisodeMetaValue {
return useContext(EpisodeMetaContext)
}
/** Convenience: pull a single episode's Cinemeta entry from context. */
export function useCinemetaEpisode(
season: number | null | undefined,
episode: number | null | undefined,
): CinemetaEpisode | null {
const { cinemetaMap } = useEpisodeMeta()
if (season == null || episode == null) return null
return cinemetaMap.get(`${season}:${episode}`) || null
}
/** Convenience: classify a single episode against the filler list. */
export function useFillerFlag(
season: number | null | undefined,
episode: number | null | undefined,
): FillerFlag {
const { fillerOf } = useEpisodeMeta()
return fillerOf(season, episode)
}
+60
View File
@@ -0,0 +1,60 @@
import type { BaseItemDto } from '../api/types'
import type { CinemetaEpisode } from '../api/cinemeta'
import type { TmdbCastMember, TmdbEpisode } from '../api/tmdb'
import type { FillerFlag } from './anime-filler'
/**
* Combined view of a Jellyfin episode plus enrichments from external
* sources (Cinemeta IMDB rating, TMDB per-episode credits, anime-filler
* classification). Components render from this normalised shape so they
* don't have to juggle three sources.
*/
export interface EnrichedEpisode {
jf: BaseItemDto
imdbRating: number | null
imdbId: string | null
guestStars: TmdbCastMember[]
director: TmdbCastMember | null
writer: TmdbCastMember | null
fillerFlag: FillerFlag
}
/** Parse Cinemeta's stringy rating, return null on garbage. */
export function parseImdbRating(raw: string | undefined | null): number | null {
if (!raw) return null
const n = Number(raw)
return Number.isFinite(n) && n > 0 ? n : null
}
/**
* Build the EnrichedEpisode for a single Jellyfin episode. All inputs are
* optional - missing pieces just produce nulls / empty arrays.
*/
export function deriveEpisodeMeta(
jf: BaseItemDto,
cinemetaEpisode: CinemetaEpisode | null | undefined,
tmdbEpisode: TmdbEpisode | null | undefined,
fillerFlag: FillerFlag,
): EnrichedEpisode {
const credits = tmdbEpisode?.credits
// Episode-level credits come back with `cast` (regulars) + `guest_stars`.
// We surface a unified "featured cast" from guest_stars first, then any
// cast members not present in the show's main ensemble. The filtering
// against the main ensemble happens at the consuming component.
const guests = (credits?.cast || []).slice(0, 8)
const crew = credits?.crew || []
const director = crew.find(c => (c as any).job === 'Director') || null
const writer = crew.find(
c => (c as any).job === 'Writer' || (c as any).job === 'Screenplay',
) || null
return {
jf,
imdbRating: parseImdbRating(cinemetaEpisode?.imdbRating),
imdbId: cinemetaEpisode?.imdb_id || tmdbEpisode?.external_ids?.imdb_id || null,
guestStars: guests,
director: director as TmdbCastMember | null,
writer: writer as TmdbCastMember | null,
fillerFlag,
}
}
+63
View File
@@ -0,0 +1,63 @@
import type { BaseItemDto } from '../api/types'
import type { TmdbEpisodeGroupFull } from '../api/tmdb'
/**
* TMDB episode group types per their docs. We surface the ones with the
* most user value as alternative orderings; aired (1) is the natural
* order and rendered without a group reorder.
*/
export const EPISODE_GROUP_TYPES: Record<number, string> = {
1: 'Original air',
2: 'Absolute',
3: 'DVD',
4: 'Digital',
5: 'Story arc',
6: 'Production',
7: 'TV',
}
export interface OrderingOption {
/** TMDB group id, or 'aired' for the unmodified Jellyfin sequence. */
id: string
label: string
description?: string
}
/**
* Reorder a Jellyfin episode list to match a TMDB episode-group ordering.
*
* Strategy: build a Map from `${season}:${episode}` -> position in the
* group. Episodes the group references go first in the group's order;
* episodes not in the group fall to the end in their original order so
* we never silently drop entries.
*/
export function applyEpisodeGroupOrder(
episodes: BaseItemDto[],
group: TmdbEpisodeGroupFull | null | undefined,
): BaseItemDto[] {
if (!group || !episodes.length) return episodes
const positionByKey = new Map<string, number>()
let pos = 0
for (const g of group.groups || []) {
for (const ep of g.episodes) {
const key = `${ep.season_number}:${ep.episode_number}`
if (!positionByKey.has(key)) positionByKey.set(key, pos++)
}
}
if (positionByKey.size === 0) return episodes
const tagged = episodes.map((ep, originalIndex) => {
const key = `${ep.ParentIndexNumber ?? -1}:${ep.IndexNumber ?? -1}`
const groupPos = positionByKey.get(key)
return { ep, groupPos, originalIndex }
})
return tagged
.sort((a, b) => {
if (a.groupPos == null && b.groupPos == null) {
return a.originalIndex - b.originalIndex
}
if (a.groupPos == null) return 1
if (b.groupPos == null) return -1
return a.groupPos - b.groupPos
})
.map(t => t.ep)
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import {
formatBitrate,
formatBytes,
formatRuntime,
formatSeconds,
formatTimecode,
ticksToSeconds,
} from './format'
describe('format helpers', () => {
it('converts Jellyfin ticks to seconds', () => {
expect(ticksToSeconds(25_000_000)).toBe(2.5)
expect(ticksToSeconds(null)).toBe(0)
})
it('formats runtimes and timecodes', () => {
expect(formatRuntime(90 * 60 * 10_000_000)).toBe('1h 30m')
expect(formatRuntime(45 * 60 * 10_000_000)).toBe('45m')
expect(formatTimecode(3725 * 10_000_000)).toBe('1:02:05')
expect(formatSeconds(125)).toBe('2:05')
})
it('formats sizes and bitrates', () => {
expect(formatBytes(1024)).toBe('1 KB')
expect(formatBytes(5 * 1024 * 1024)).toBe('5 MB')
expect(formatBitrate(320_000)).toBe('320 kbps')
expect(formatBitrate(6_400_000)).toBe('6.4 Mbps')
})
})
+203
View File
@@ -0,0 +1,203 @@
/* ──────────────────────────────────────────────────────────────
* Shared formatters
* Centralizes time/size/runtime/locale formatting so display code
* stays declarative.
* ──────────────────────────────────────────────────────────── */
export const TICKS_PER_SECOND = 10_000_000
/** Convert Jellyfin ticks to seconds. */
export function ticksToSeconds(ticks?: number | bigint | null): number {
if (!ticks) return 0
return Number(ticks) / TICKS_PER_SECOND
}
/** "1h 34m" / "47m" / "12s" - humanized runtime from ticks. */
export function formatRuntime(ticks?: number | bigint | null): string {
const seconds = ticksToSeconds(ticks)
if (!seconds) return ''
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`
if (m > 0) return `${m}m`
return `${Math.round(seconds)}s`
}
/** "1:24:05" / "47:12" / "0:30" - timecode from ticks. */
export function formatTimecode(ticks?: number | bigint | null): string {
const total = Math.floor(ticksToSeconds(ticks))
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
return `${m}:${s.toString().padStart(2, '0')}`
}
/** Same but accepts seconds directly (used by player which has currentTime). */
export function formatSeconds(seconds: number): string {
if (!seconds || !isFinite(seconds)) return '0:00'
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
return `${m}:${s.toString().padStart(2, '0')}`
}
/** "6.4 Mbps" / "320 kbps" */
export function formatBitrate(bps?: number | null): string {
if (!bps) return ''
if (bps >= 1_000_000) return `${(bps / 1_000_000).toFixed(1)} Mbps`
if (bps >= 1_000) return `${Math.round(bps / 1_000)} kbps`
return `${bps} bps`
}
/** "24.7 GB" / "812 MB" */
export function formatBytes(bytes?: number | null): string {
if (!bytes) return ''
const KB = 1024
const MB = KB * 1024
const GB = MB * 1024
const TB = GB * 1024
if (bytes >= TB) return `${(bytes / TB).toFixed(2)} TB`
if (bytes >= GB) return `${(bytes / GB).toFixed(2)} GB`
if (bytes >= MB) return `${(bytes / MB).toFixed(0)} MB`
if (bytes >= KB) return `${(bytes / KB).toFixed(0)} KB`
return `${bytes} B`
}
/** "Mar 12, 2024" */
export function formatDate(iso?: string | null): string {
if (!iso) return ''
try {
const d = new Date(iso)
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
} catch {
return ''
}
}
/** "Mar 2024" */
export function formatMonthYear(iso?: string | null): string {
if (!iso) return ''
try {
const d = new Date(iso)
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short' })
} catch {
return ''
}
}
/** "in 4 days" / "5 hours ago" */
export function formatRelative(iso?: string | null): string {
if (!iso) return ''
try {
const d = new Date(iso).getTime()
if (isNaN(d)) return ''
const diff = d - Date.now()
const abs = Math.abs(diff)
const seconds = Math.round(diff / 1000)
const minutes = Math.round(seconds / 60)
const hours = Math.round(minutes / 60)
const days = Math.round(hours / 24)
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
if (abs < 60_000) return rtf.format(seconds, 'second')
if (abs < 3_600_000) return rtf.format(minutes, 'minute')
if (abs < 86_400_000) return rtf.format(hours, 'hour')
if (abs < 30 * 86_400_000) return rtf.format(days, 'day')
if (abs < 365 * 86_400_000) return rtf.format(Math.round(days / 30), 'month')
return rtf.format(Math.round(days / 365), 'year')
} catch {
return ''
}
}
/** ISO 639 → display name. "eng" → "English" */
const langNamer = (() => {
try {
return new Intl.DisplayNames(undefined, { type: 'language' })
} catch {
return null
}
})()
export function languageLabel(code?: string | null): string {
if (!code) return ''
// Jellyfin returns 3-letter codes; Intl.DisplayNames accepts both
try {
return langNamer?.of(code) || code.toUpperCase()
} catch {
return code.toUpperCase()
}
}
/** ISO 3166 → country name. "US" → "United States" */
const countryNamer = (() => {
try {
return new Intl.DisplayNames(undefined, { type: 'region' })
} catch {
return null
}
})()
export function countryLabel(code?: string | null): string {
if (!code) return ''
try {
return countryNamer?.of(code.toUpperCase()) || code.toUpperCase()
} catch {
return code.toUpperCase()
}
}
/** Render a 2-letter ISO 3166 code as a flag emoji. "US" → 🇺🇸 */
export function flagEmoji(code?: string | null): string {
if (!code || code.length !== 2) return ''
const upper = code.toUpperCase()
if (!/^[A-Z]{2}$/.test(upper)) return ''
const base = 0x1f1e6
return String.fromCodePoint(
base + upper.charCodeAt(0) - 65,
base + upper.charCodeAt(1) - 65,
)
}
/** Compact USD currency format. 145603415 → "$145,603,415" */
export function formatUsd(n?: number | null): string {
if (!n || !Number.isFinite(n)) return ''
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(n)
} catch {
return `$${n.toLocaleString()}`
}
}
/** Long-form date "January 31, 2025" from an ISO date string. */
export function formatLongDate(iso?: string | null): string {
if (!iso) return ''
try {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
return d.toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
})
} catch {
return ''
}
}
/** Default region for the current user, browser-derived. */
export function regionForUser(): string {
try {
const lang = navigator.language || 'en-US'
const parts = lang.split('-')
if (parts.length > 1 && parts[1].length === 2) return parts[1].toUpperCase()
return 'US'
} catch {
return 'US'
}
}
+69
View File
@@ -0,0 +1,69 @@
import type { ComponentType } from 'react'
import {
Sword, Swords, Palette, MoodHappy, MasksTheater, Mask, Wand, Ghost, Camera,
QuestionMark, Rocket, Bolt, Shield, HorseToy, News, Microphone, Ball,
Spy, Trophy, MoodKid, Heart, Music, Tv2 as Tv, Film, Backpack,
UserCheck, HomeHeart, Hourglass, Drone, Monument, Confetti, Flame,
Lock, Binoculars, ChefHat, Gavel, Stethoscope, Leaf, MoonStars, Plane,
} from './icons'
type IconType = ComponentType<{ size?: number; stroke?: number; className?: string }>
/**
* Maps a TMDB / Jellyfin genre name to a unique Tabler icon.
* Matches are case-insensitive. Order matters - more specific patterns
* (e.g. "tv movie", "action and adventure", "science fiction", "children")
* come before broader ones so the right icon wins.
*/
const map: Array<[RegExp, IconType]> = [
// Multi-word / compound matches first
[/^action.{0,4}adventure|action.+adventure/i, Swords],
[/^tv movie|television movie/i, Tv],
[/^game.?show/i, Trophy],
[/^talk.?show/i, Microphone],
[/^sci.?fi|science fiction/i, Rocket],
[/^children|kids/i, MoodKid],
// Single-word, alphabetised
[/^action/i, Sword],
[/^adult|erotic/i, Lock],
[/^adventure/i, Backpack],
[/^animation|animated/i, Palette],
[/^biography|biographic/i, UserCheck],
[/^comedy|sitcom/i, MoodHappy],
[/^crime/i, Spy],
[/^document/i, Camera],
[/^drama/i, MasksTheater],
[/^espionage/i, Binoculars],
[/^family/i, HomeHeart],
[/^fantasy/i, Wand],
[/^food|culinary|cooking/i, ChefHat],
[/^histor/i, Monument],
[/^holiday/i, Confetti],
[/^horror/i, Ghost],
[/^legal|law/i, Gavel],
[/^medical|hospital/i, Stethoscope],
[/^music/i, Music],
[/^myster/i, QuestionMark],
[/^natur|wildlife|environment/i, Leaf],
[/^news/i, News],
[/^realit/i, Drone],
[/^romance|romantic/i, Heart],
[/^short/i, Hourglass],
[/^soap/i, Mask],
[/^sport/i, Ball],
[/^supernatural|paranormal|occult/i, MoonStars],
[/^talk/i, Microphone],
[/^thriller|suspense/i, Bolt],
[/^travel/i, Plane],
[/^war/i, Shield],
[/^western/i, HorseToy],
]
export function iconForGenre(name: string | null | undefined): IconType {
if (!name) return Film
for (const [re, Icon] of map) {
if (re.test(name)) return Icon
}
return Flame
}
+165
View File
@@ -0,0 +1,165 @@
/**
* Icon shim - re-exports Tabler Icons under the names previously used from
* lucide-react. This way the rest of the app keeps working unchanged after
* swapping the underlying icon set.
*
* Tabler accepts both `stroke` and `strokeWidth` (the latter is forwarded to
* the underlying SVG), so existing prop usage stays compatible.
*/
export {
// Layout / nav
IconHome as Home,
IconMovie as Film,
IconDeviceTv as Tv,
IconDeviceTv as Tv2,
IconMusic as Music,
IconSearch as Search,
IconSettings as Settings,
IconLogout as LogOut,
IconLibrary as Library,
IconCompass as Compass,
IconFlame as Flame,
IconFilter as Filter,
// Arrows + chevrons
IconArrowLeft as ArrowLeft,
IconArrowRight as ArrowRight,
IconChevronLeft as ChevronLeft,
IconChevronRight as ChevronRight,
IconChevronUp as ChevronUp,
IconChevronDown as ChevronDown,
IconSortAscending as ArrowDownAZ,
// Player controls
IconPlayerPlay as Play,
IconPlayerPause as Pause,
IconPlayerTrackPrev as SkipBack,
IconPlayerTrackNext as SkipForward,
IconRepeat as Repeat,
IconRepeatOnce as Repeat1,
IconArrowsShuffle as Shuffle,
IconPlaylist as ListMusic,
IconLayoutList as Playlists,
IconLayoutSidebarLeftCollapse as SidebarCollapse,
IconLayoutSidebarLeftExpand as SidebarExpand,
IconPin as Pin,
IconPinFilled as PinFilled,
IconUserCircle as UserCircle,
IconRotate as RotateCcw,
IconRotateClockwise as RotateCw,
IconMaximize as Maximize,
IconMinimize as Minimize,
IconSubtitles as Subtitles,
IconTypography as Type,
IconListDetails as ListDetails,
IconBookmark as Bookmark,
IconList as List,
IconAdjustments as Adjustments,
IconWaveSine as AudioLines,
IconPictureInPicture as PictureInPicture2,
IconVolume as Volume2,
IconVolume2 as Volume1,
IconVolume3 as VolumeX,
// State / status
IconMinus as Minus,
IconSquare as Square,
IconCopy as RestoreDown,
IconHeart as Heart,
IconCheck as Check,
IconStar as Star,
IconPlus as Plus,
IconX as X,
IconShare as Share2,
IconTag as Tag,
IconRefresh as RefreshCw,
IconCloudOff as CloudOff,
IconCloud as Cloud,
IconBuilding as Building2,
IconTicket as Ticket,
IconAlertCircle as AlertCircle,
IconLoader2 as Loader2,
IconWifiOff as WifiOff,
IconInfoCircle as Info,
// Media + tech
IconDisc as Disc3,
IconVideo as FileVideo,
IconClock as Clock,
IconBroadcast as Radio,
IconDeviceDesktop as MonitorPlay,
IconDeviceFloppy as HardDrive,
IconLicense as FileBadge,
IconDatabase as Database,
IconAward as Award,
// Stats / metadata
IconStack3 as Layers,
IconChecklist as Checklist,
IconCalendarStar as CalendarStar,
IconCalendarOff as CalendarOff,
IconCalendarPlus as CalendarPlus,
IconCalendarEvent as CalendarEvent,
IconActivity as Activity,
IconHash as Hash,
// Genre icons (used by Library filter)
IconSword as Sword,
IconPalette as Palette,
IconMoodHappy as MoodHappy,
IconTheater as Theater,
IconMasksTheater as MasksTheater,
IconMask as Mask,
IconWand as Wand,
IconBook as Book,
IconBuildingMonument as Monument,
IconGhost as Ghost,
IconQuestionMark as QuestionMark,
IconRocket as Rocket,
IconBolt as Bolt,
IconShield as Shield,
IconHorseToy as HorseToy,
IconNews as News,
IconMicrophone as Microphone,
IconBallFootball as Ball,
IconSpy as Spy,
IconTrophy as Trophy,
IconMoodKid as MoodKid,
IconCamera as Camera,
IconBackpack as Backpack,
IconUserCheck as UserCheck,
IconHomeHeart as HomeHeart,
IconHourglass as Hourglass,
IconDrone as Drone,
IconConfetti as Confetti,
IconSwords as Swords,
IconBinoculars as Binoculars,
IconChefHat as ChefHat,
IconGavel as Gavel,
IconStethoscope as Stethoscope,
IconLeaf as Leaf,
IconMoonStars as MoonStars,
IconPlane as Plane,
// People / places
IconUser as User,
IconUsers as Users,
IconMapPin as MapPin,
IconCake as Cake,
IconSkull as Skull,
IconCalendar as Calendar,
// External / misc
IconExternalLink as ExternalLink,
IconWorld as Globe,
IconServer as Server,
IconLanguage as Languages,
IconKey as Key,
IconLock as Lock,
IconEye as Eye,
IconEyeOff as EyeOff,
IconBoxMultiple as Boxes,
IconTrash as Trash2,
IconDownload as Download,
} from '@tabler/icons-react'
+149
View File
@@ -0,0 +1,149 @@
/**
* Typed accessors for fields the Jellyfin SDK doesn't fully model and for
* synthetic fields the app stamps on `BaseItemDto` to bridge TMDB results
* into the `PosterCard` pipeline. Keeps the `as any` casts in one place
* so call sites can read fields directly with confidence.
*/
import type { BaseItemDto } from '../api/types'
import type { TmdbMovie, TmdbTvShow } from '../api/tmdb'
/* ─── TMDB union narrowing ──────────────────────────────────────── */
/**
* Merged shape for TMDB movie + TV detail data. The two endpoints return
* different shapes (`title`/`release_date` vs `name`/`first_air_date`)
* but the app frequently reads the same logical fields from either.
* `Partial<A> & Partial<B>` exposes the union surface without forcing
* call sites to rediscriminate at every read.
*/
export type TmdbDetailUnion = Partial<TmdbMovie> & Partial<TmdbTvShow>
/** Cast a movie | tv | null to the merged union type. */
export function asTmdbDetail(data: TmdbMovie | TmdbTvShow | null | undefined): TmdbDetailUnion | undefined {
return data as TmdbDetailUnion | undefined
}
/* ─── Synthetic TMDB fields ──────────────────────────────────────── */
/**
* Extra fields the app stamps on a `BaseItemDto` when it's actually a
* TMDB result mapped through `mapTmdbToJf`. Library hits route via the
* Jellyfin id; non-library hits route via the synthetic `tmdb-<kind>-<id>`
* route id.
*/
export interface TmdbSyntheticFields {
_tmdbPoster?: string
_tmdbBackdrop?: string
_inLibrary?: boolean
_tmdbId?: string
}
export type TmdbDecoratedItem = BaseItemDto & TmdbSyntheticFields
/** Read the TMDB-decoration fields from an item without per-call casts. */
export function tmdbDecoration(item: BaseItemDto | null | undefined): TmdbSyntheticFields {
if (!item) return {}
return item as TmdbDecoratedItem
}
/** TMDB id from the ProviderIds map, falling back to a synthetic _tmdbId
* set by mapTmdbToJf for non-library items. */
export function getTmdbId(item: BaseItemDto | null | undefined): string | undefined {
if (!item) return undefined
return item.ProviderIds?.Tmdb || tmdbDecoration(item)._tmdbId
}
/** True if the item is either a real Jellyfin item OR a TMDB-synthetic
* item the app has flagged as already in the library. */
export function isInLibrary(item: BaseItemDto | null | undefined): boolean {
if (!item) return false
return tmdbDecoration(item)._inLibrary === true
}
/* ─── Series-specific extensions ─────────────────────────────────── */
interface SeriesAirInfoFields {
AirDays?: string[] | null
AirTime?: string | null
}
/** Returns the AirDays + AirTime that the Jellyfin SDK doesn't expose on
* the base BaseItemDto. Both are null when the item isn't a Series or
* the server didn't populate the fields. */
export function getSeriesAirInfo(item: BaseItemDto | null | undefined): { airDays: string[] | null; airTime: string | null } {
if (!item) return { airDays: null, airTime: null }
const augmented = item as BaseItemDto & SeriesAirInfoFields
return {
airDays: augmented.AirDays ?? null,
airTime: augmented.AirTime ?? null,
}
}
/* ─── Episode-specific extensions ────────────────────────────────── */
interface EpisodeExtraFields {
LocationType?: string
Trickplay?: unknown
}
/** Jellyfin's canonical signal for "file not on disk": LocationType
* === 'Virtual'. Returns true for missing items so callers can render
* a missing-state without their own cast. */
export function isMissingEpisode(item: BaseItemDto | null | undefined): boolean {
if (!item) return false
return (item as BaseItemDto & EpisodeExtraFields).LocationType === 'Virtual'
}
/** True when the server has trickplay sprite sheets generated for this
* item. Just a typed accessor for the otherwise-unmodeled field. */
export function hasTrickplay(item: BaseItemDto | null | undefined): boolean {
if (!item) return false
return !!(item as BaseItemDto & EpisodeExtraFields).Trickplay
}
/* ─── Playlist-specific extensions ───────────────────────────────── */
interface PlaylistEntryFields {
PlaylistItemId?: string
}
/** PlaylistItemId is the per-row identifier the Jellyfin Playlists
* endpoint returns. The SDK's BaseItemDto doesn't include it, but the
* playlist-items response always has it. */
export function getPlaylistItemId(item: BaseItemDto | null | undefined): string | undefined {
if (!item) return undefined
return (item as BaseItemDto & PlaylistEntryFields).PlaylistItemId
}
/* ─── Music-specific extensions ──────────────────────────────────── */
interface MusicTrackFields {
AlbumArtist?: string
Album?: string
Artists?: string[]
}
/** Best-effort artist label for an audio item. Falls back through
* AlbumArtist -> first Artist -> empty string. */
export function getTrackArtist(item: BaseItemDto | null | undefined): string {
if (!item) return ''
const m = item as BaseItemDto & MusicTrackFields
if (m.AlbumArtist) return m.AlbumArtist
if (Array.isArray(m.Artists) && m.Artists.length > 0) return m.Artists[0]
return ''
}
/** Joined artist list for an audio item (first N, default 2). */
export function getTrackArtists(item: BaseItemDto | null | undefined, limit = 2): string {
if (!item) return ''
const m = item as BaseItemDto & MusicTrackFields
if (m.AlbumArtist) return m.AlbumArtist
if (Array.isArray(m.Artists)) return m.Artists.slice(0, limit).join(', ')
return ''
}
/** Album field accessor. */
export function getAlbum(item: BaseItemDto | null | undefined): string {
if (!item) return ''
return (item as BaseItemDto & MusicTrackFields).Album ?? ''
}
+225
View File
@@ -0,0 +1,225 @@
/* ──────────────────────────────────────────────────────────────
* Jellyfin metadata derivation
* Pure helpers that read MediaSources / MediaStreams off a
* BaseItemDto and project them into display-ready shapes.
* ──────────────────────────────────────────────────────────── */
import type { BaseItemDto } from '../api/types'
import { languageLabel } from './format'
interface MediaStreamLike {
Type?: string
Codec?: string | null
Profile?: string | null
Language?: string | null
Title?: string | null
Channels?: number | null
ChannelLayout?: string | null
SampleRate?: number | null
BitRate?: number | null
BitDepth?: number | null
Width?: number | null
Height?: number | null
AverageFrameRate?: number | null
RealFrameRate?: number | null
AspectRatio?: string | null
ColorSpace?: string | null
ColorTransfer?: string | null
PixelFormat?: string | null
IsDefault?: boolean | null
IsForced?: boolean | null
IsHearingImpaired?: boolean | null
IsExternal?: boolean | null
VideoRange?: string | null
VideoRangeType?: string | null
AudioSpatialFormat?: string | null
Hdr10PlusPresentFlag?: boolean | null
DvProfile?: number | null
DvVersionMajor?: number | null
Index?: number
DisplayTitle?: string | null
}
interface MediaSourceLike {
Id?: string | null
Name?: string | null
Container?: string | null
Size?: number | null
Bitrate?: number | null
RunTimeTicks?: number | null
MediaStreams?: MediaStreamLike[] | null
}
/** Returns the best (default) media source from an item, falling back to first. */
export function pickPrimarySource(item: Pick<BaseItemDto, 'MediaSources'>): MediaSourceLike | null {
const sources = (item.MediaSources || []) as MediaSourceLike[]
if (!sources.length) return null
return sources[0]
}
/** All media streams across primary source. */
export function getStreams(item: Pick<BaseItemDto, 'MediaSources'>): MediaStreamLike[] {
const src = pickPrimarySource(item)
return src?.MediaStreams || []
}
export function getVideoStream(item: Pick<BaseItemDto, 'MediaSources'>): MediaStreamLike | null {
const streams = getStreams(item)
return streams.find(s => s.Type === 'Video') || null
}
export function getAudioStreams(item: Pick<BaseItemDto, 'MediaSources'>): MediaStreamLike[] {
return getStreams(item).filter(s => s.Type === 'Audio')
}
export function getSubtitleStreams(item: Pick<BaseItemDto, 'MediaSources'>): MediaStreamLike[] {
return getStreams(item).filter(s => s.Type === 'Subtitle')
}
/** "4K" / "1080p" / "720p" / "480p" / null */
export function resolutionLabel(item: Pick<BaseItemDto, 'MediaSources' | 'Width' | 'Height'>): string | null {
const v = getVideoStream(item)
const w = v?.Width ?? item.Width ?? null
const h = v?.Height ?? item.Height ?? null
if (!w || !h) return null
if (w >= 3800 || h >= 2100) return '4K'
if (w >= 2500 || h >= 1400) return '2K'
if (h >= 1000) return '1080p'
if (h >= 700) return '720p'
if (h >= 400) return '480p'
return 'SD'
}
/** "Dolby Vision" / "HDR10+" / "HDR10" / "HDR" / null (omit "SDR") */
export function videoRangeLabel(item: Pick<BaseItemDto, 'MediaSources'>): string | null {
const v = getVideoStream(item)
if (!v) return null
if (v.VideoRangeType === 'DOVI' || (v.DvProfile != null && v.DvProfile > 0)) return 'Dolby Vision'
if (v.Hdr10PlusPresentFlag) return 'HDR10+'
if (v.VideoRangeType === 'HDR10') return 'HDR10'
if ((v.VideoRange || '').toUpperCase() === 'HDR') return 'HDR'
return null
}
/** Compact spatial/channel label: "Atmos" | "DTS:X" | "7.1" | "5.1" | "Stereo" | null */
export function audioBadgeLabel(stream: MediaStreamLike | null): string | null {
if (!stream) return null
const spatial = (stream.AudioSpatialFormat || '').toLowerCase()
if (spatial === 'dolbyatmos') return 'Atmos'
if (spatial === 'dtsx') return 'DTS:X'
const layout = (stream.ChannelLayout || '').toLowerCase()
const ch = stream.Channels || 0
if (layout.includes('7.1') || ch >= 8) return '7.1'
if (layout.includes('5.1') || ch === 6) return '5.1'
if (ch === 2) return 'Stereo'
if (ch === 1) return 'Mono'
return null
}
/** Verbose audio descriptor: "Dolby Atmos" / "DTS-HD MA 7.1" / "AC3 5.1" / "AAC 2.0" */
export function audioFormatLabel(stream: MediaStreamLike | null): string | null {
if (!stream) return null
const codec = (stream.Codec || '').toLowerCase()
const profile = (stream.Profile || '').toLowerCase()
const ch = stream.Channels || 0
const layoutMap: Record<number, string> = { 1: 'Mono', 2: 'Stereo', 6: '5.1', 8: '7.1' }
const channels = layoutMap[ch] || (ch ? `${ch}ch` : '')
let codecName = codec.toUpperCase()
if (codec === 'eac3') codecName = profile.includes('atmos') ? 'Dolby Atmos' : 'EAC3'
else if (codec === 'truehd') codecName = profile.includes('atmos') ? 'Dolby TrueHD Atmos' : 'TrueHD'
else if (codec === 'dts') codecName = profile.includes('hd ma') ? 'DTS-HD MA' : profile.includes('hd-hra') ? 'DTS-HD HRA' : profile.includes(':x') ? 'DTS:X' : 'DTS'
else if (codec === 'ac3') codecName = 'Dolby Digital'
else if (codec === 'opus') codecName = 'Opus'
else if (codec === 'flac') codecName = 'FLAC'
else if (codec === 'aac') codecName = 'AAC'
return [codecName, channels].filter(Boolean).join(' ')
}
/** Subtitle description: "English (Forced)" / "Spanish (SDH)" / "Japanese" */
export function subtitleLabel(stream: MediaStreamLike): string {
const lang = languageLabel(stream.Language)
const flags: string[] = []
if (stream.IsForced) flags.push('Forced')
if (stream.IsHearingImpaired) flags.push('SDH')
if (stream.IsExternal) flags.push('External')
if (flags.length) return `${lang || 'Unknown'} (${flags.join(', ')})`
return lang || stream.Title || 'Unknown'
}
/** "H.264 High" or "HEVC Main 10" or "AV1" */
export function videoCodecLabel(stream: MediaStreamLike | null): string {
if (!stream) return ''
const codec = (stream.Codec || '').toLowerCase()
const profile = stream.Profile || ''
const codecName =
codec === 'h264' ? 'H.264'
: codec === 'hevc' ? 'HEVC'
: codec === 'av1' ? 'AV1'
: codec === 'vp9' ? 'VP9'
: codec === 'mpeg2video' ? 'MPEG-2'
: codec.toUpperCase()
return [codecName, profile].filter(Boolean).join(' ')
}
/** "23.976 fps" */
export function framerateLabel(stream: MediaStreamLike | null): string {
if (!stream) return ''
const fps = stream.AverageFrameRate || stream.RealFrameRate
if (!fps) return ''
return `${(Math.round(fps * 1000) / 1000).toString().replace(/\.?0+$/, '')} fps`
}
/** Top-line badges, capped to keep UI uncluttered. Returns 0-3 entries. */
export function getTopBadges(item: Pick<BaseItemDto, 'MediaSources' | 'Width' | 'Height'>): string[] {
const out: string[] = []
const res = resolutionLabel(item)
const range = videoRangeLabel(item)
const audio = audioBadgeLabel(getAudioStreams(item)[0] || null)
if (res === '4K') out.push('4K')
if (range) out.push(range)
if (audio === 'Atmos' || audio === 'DTS:X') out.push(audio)
return out.slice(0, 3)
}
/** Full ordered badge list for hero/detail use. */
export function getAllBadges(item: Pick<BaseItemDto, 'MediaSources' | 'Width' | 'Height'>): string[] {
const out: string[] = []
const res = resolutionLabel(item)
const range = videoRangeLabel(item)
const audio = audioBadgeLabel(getAudioStreams(item)[0] || null)
if (res) out.push(res)
if (range) out.push(range)
if (audio) out.push(audio)
return out
}
/** A MediaSource summarized for the version selector. */
export function summarizeSource(source: MediaSourceLike): {
label: string
detail: string
} {
const v = (source.MediaStreams || []).find(s => s.Type === 'Video')
const w = v?.Width || 0
const h = v?.Height || 0
const range =
v?.VideoRangeType === 'DOVI' || (v?.DvProfile && v.DvProfile > 0) ? 'DV'
: v?.Hdr10PlusPresentFlag ? 'HDR10+'
: v?.VideoRangeType === 'HDR10' ? 'HDR'
: (v?.VideoRange || '').toUpperCase() === 'HDR' ? 'HDR'
: ''
const resolution =
w >= 3800 || h >= 2100 ? '4K'
: h >= 1000 ? '1080p'
: h >= 700 ? '720p'
: h >= 400 ? '480p'
: 'SD'
const label = [resolution, range].filter(Boolean).join(' ')
const detail = [
source.Container?.toUpperCase(),
source.Size ? `${Math.round((source.Size / 1024 / 1024 / 1024) * 10) / 10} GB` : '',
].filter(Boolean).join(' · ')
return { label, detail }
}
+15
View File
@@ -0,0 +1,15 @@
import { usePreferencesStore } from '../stores/preferences-store'
/**
* Gated console.log - only emits when diagnostic logging is enabled in
* Settings. Use everywhere we used to write `console.log('[JF] ...')`.
*/
export function debugLog(...args: unknown[]): void {
try {
if (usePreferencesStore.getState().diagnosticLogging) {
console.log(...args)
}
} catch {
/* noop */
}
}
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useState } from 'react'
/**
* Detect whether a remote logo image is light-content or dark-content by
* sampling the opaque pixels' luminance. Cached per URL so each logo is
* sampled at most once. Used to pick a contrasting pad color so neither
* white-on-white nor black-on-black happens.
*/
export type LogoTone = 'light' | 'dark' | 'unknown'
const cache = new Map<string, LogoTone>()
const inflight = new Map<string, Promise<LogoTone>>()
export function useLogoTone(url: string | null | undefined): LogoTone {
const initial = url ? cache.get(url) : undefined
const [tone, setTone] = useState<LogoTone>(initial ?? 'unknown')
useEffect(() => {
if (!url) return
const cached = cache.get(url)
if (cached) {
setTone(cached)
return
}
let cancelled = false
sampleLogoTone(url).then(t => {
cache.set(url, t)
if (!cancelled) setTone(t)
})
return () => { cancelled = true }
}, [url])
return tone
}
function sampleLogoTone(url: string): Promise<LogoTone> {
const existing = inflight.get(url)
if (existing) return existing
const promise = new Promise<LogoTone>(resolve => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
try {
const maxSide = 64
const ratio = Math.min(maxSide / img.naturalWidth, maxSide / img.naturalHeight, 1)
const w = Math.max(1, Math.round(img.naturalWidth * ratio))
const h = Math.max(1, Math.round(img.naturalHeight * ratio))
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return resolve('unknown')
ctx.drawImage(img, 0, 0, w, h)
const { data } = ctx.getImageData(0, 0, w, h)
let lumWeighted = 0
let alphaSum = 0
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3]
if (a < 16) continue
const l = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]
lumWeighted += l * a
alphaSum += a
}
if (alphaSum === 0) return resolve('unknown')
const avg = lumWeighted / alphaSum
resolve(avg > 128 ? 'light' : 'dark')
} catch {
// CORS or other read failure
resolve('unknown')
} finally {
inflight.delete(url)
}
}
img.onerror = () => {
inflight.delete(url)
resolve('unknown')
}
img.src = url
})
inflight.set(url, promise)
return promise
}
+358
View File
@@ -0,0 +1,358 @@
import type { MediaPlayerInstance } from '@vidstack/react'
/**
* Player keyboard shortcut registry. Each shortcut has:
* - id: stable, used as the override map key in user prefs
* - keys: array of binding strings; the first is the canonical default
* shown in the keyboard hints overlay
* - description: shown in the hints overlay and Settings → Shortcuts
* - category: for grouping in the hints overlay
* - handler: receives the player instance + a context carrying the
* wider state setters and refs the player UI exposes
*
* Binding string format: `Ctrl+Shift+Alt+<key>` or just `<key>`. Modifier
* order is fixed (Ctrl, Shift, Alt) for canonical comparison. The
* key name matches `KeyboardEvent.key` (case-insensitive matching).
*/
export type ShortcutCategory =
| 'playback'
| 'navigation'
| 'audio'
| 'subtitles'
| 'tools'
| 'view'
export interface ShortcutContext {
// Loose signature so a react-router NavigateFunction satisfies it
navigate: (to: any, options?: any) => void
showSeekIndicator: (direction: 'forward' | 'backward') => void
showControls: () => void
setStreamInfoOpen: (fn: (o: boolean) => boolean) => void
setIsMuted: (m: boolean) => void
toggleFullscreen: () => void
togglePictureInPicture: () => void
toggleTheaterMode: () => void
takeScreenshot: () => void
toggleHints: () => void
bumpVolume: (delta: number) => void
cycleSpeed: (direction: 1 | -1) => void
resetSpeed: () => void
bumpSubtitleDelay: (deltaMs: number) => void
resetSubtitleDelay: () => void
bumpAudioDelay: (deltaMs: number) => void
resetAudioDelay: () => void
setLoopA: () => void
setLoopB: () => void
clearLoop: () => void
addBookmark: () => void
toggleBookmarksPanel: () => void
toggleSubSearch: () => void
stepFrame: (direction: 1 | -1) => void
prevChapter: () => void
nextChapter: () => void
}
export type ShortcutHandler = (player: MediaPlayerInstance, ctx: ShortcutContext) => void
export interface Shortcut {
id: string
keys: string[]
description: string
category: ShortcutCategory
handler: ShortcutHandler
}
/** Canonical shortcuts. Order matches what the hints overlay should show. */
export const SHORTCUTS: Shortcut[] = [
// Playback
{
id: 'playPause',
keys: ['Space', 'k'],
description: 'Play / Pause',
category: 'playback',
handler: (p) => {
if (p.paused) p.play()
else p.pause()
},
},
{
id: 'seekBack',
keys: ['ArrowLeft', 'j'],
description: 'Back 10 seconds',
category: 'playback',
handler: (p, ctx) => {
p.currentTime = Math.max(0, (p.currentTime ?? 0) - 10)
ctx.showSeekIndicator('backward')
},
},
{
id: 'seekForward',
keys: ['ArrowRight', 'l'],
description: 'Forward 10 seconds',
category: 'playback',
handler: (p, ctx) => {
p.currentTime = (p.currentTime ?? 0) + 10
ctx.showSeekIndicator('forward')
},
},
{
id: 'speedUp',
keys: ['>', 'Shift+.'],
description: 'Speed up',
category: 'playback',
handler: (_p, ctx) => ctx.cycleSpeed(1),
},
{
id: 'speedDown',
keys: ['<', 'Shift+,'],
description: 'Slow down',
category: 'playback',
handler: (_p, ctx) => ctx.cycleSpeed(-1),
},
{
id: 'speedReset',
keys: ['='],
description: 'Reset speed to 1×',
category: 'playback',
handler: (_p, ctx) => ctx.resetSpeed(),
},
{
id: 'frameBack',
keys: [','],
description: 'Previous frame (when paused)',
category: 'playback',
handler: (_p, ctx) => ctx.stepFrame(-1),
},
{
id: 'frameForward',
keys: ['.'],
description: 'Next frame (when paused)',
category: 'playback',
handler: (_p, ctx) => ctx.stepFrame(1),
},
// Audio
{
id: 'mute',
keys: ['m'],
description: 'Mute / unmute',
category: 'audio',
handler: (p, ctx) => {
p.muted = !p.muted
ctx.setIsMuted(p.muted)
},
},
{
id: 'volumeUp',
keys: ['ArrowUp'],
description: 'Volume up',
category: 'audio',
handler: (_p, ctx) => ctx.bumpVolume(0.05),
},
{
id: 'volumeDown',
keys: ['ArrowDown'],
description: 'Volume down',
category: 'audio',
handler: (_p, ctx) => ctx.bumpVolume(-0.05),
},
{
id: 'audioDelayEarlier',
keys: ['['],
description: 'Audio earlier 100ms',
category: 'audio',
handler: (_p, ctx) => ctx.bumpAudioDelay(-100),
},
{
id: 'audioDelayLater',
keys: [']'],
description: 'Audio later 100ms',
category: 'audio',
handler: (_p, ctx) => ctx.bumpAudioDelay(100),
},
{
id: 'audioDelayReset',
keys: ['\\'],
description: 'Reset audio delay',
category: 'audio',
handler: (_p, ctx) => ctx.resetAudioDelay(),
},
// Subtitles
{
id: 'subDelayEarlier',
keys: ['z'],
description: 'Subtitles earlier 100ms',
category: 'subtitles',
handler: (_p, ctx) => ctx.bumpSubtitleDelay(-100),
},
{
id: 'subDelayLater',
keys: ['x'],
description: 'Subtitles later 100ms',
category: 'subtitles',
handler: (_p, ctx) => ctx.bumpSubtitleDelay(100),
},
{
id: 'subDelayReset',
keys: ['Shift+z'],
description: 'Reset subtitle delay',
category: 'subtitles',
handler: (_p, ctx) => ctx.resetSubtitleDelay(),
},
{
id: 'subSearch',
keys: ['/'],
description: 'Search subtitles',
category: 'subtitles',
handler: (_p, ctx) => ctx.toggleSubSearch(),
},
// Tools
{
id: 'loopA',
keys: ['Shift+['],
description: 'Set loop point A',
category: 'tools',
handler: (_p, ctx) => ctx.setLoopA(),
},
{
id: 'loopB',
keys: ['Shift+]'],
description: 'Set loop point B',
category: 'tools',
handler: (_p, ctx) => ctx.setLoopB(),
},
{
id: 'loopClear',
keys: ['Shift+\\'],
description: 'Clear A-B loop',
category: 'tools',
handler: (_p, ctx) => ctx.clearLoop(),
},
{
id: 'bookmark',
keys: ['b'],
description: 'Add bookmark',
category: 'tools',
handler: (_p, ctx) => ctx.addBookmark(),
},
{
id: 'bookmarksPanel',
keys: ['Shift+b'],
description: 'Open bookmarks',
category: 'tools',
handler: (_p, ctx) => ctx.toggleBookmarksPanel(),
},
{
id: 'screenshot',
keys: ['Ctrl+Shift+s'],
description: 'Copy frame to clipboard',
category: 'tools',
handler: (_p, ctx) => ctx.takeScreenshot(),
},
{
id: 'streamInfo',
keys: ['i'],
description: 'Toggle stream info',
category: 'tools',
handler: (_p, ctx) => ctx.setStreamInfoOpen(o => !o),
},
{
id: 'hints',
keys: ['?', 'Shift+/'],
description: 'Show keyboard shortcuts',
category: 'tools',
handler: (_p, ctx) => ctx.toggleHints(),
},
{
id: 'prevChapter',
keys: ['Shift+ArrowLeft'],
description: 'Previous chapter',
category: 'playback',
handler: (_p, ctx) => ctx.prevChapter(),
},
{
id: 'nextChapter',
keys: ['Shift+ArrowRight'],
description: 'Next chapter',
category: 'playback',
handler: (_p, ctx) => ctx.nextChapter(),
},
// View
{
id: 'fullscreen',
keys: ['f'],
description: 'Toggle fullscreen',
category: 'view',
handler: (_p, ctx) => ctx.toggleFullscreen(),
},
{
id: 'pip',
keys: ['p'],
description: 'Picture-in-picture',
category: 'view',
handler: (_p, ctx) => ctx.togglePictureInPicture(),
},
{
id: 'theater',
keys: ['t'],
description: 'Theater mode',
category: 'view',
handler: (_p, ctx) => ctx.toggleTheaterMode(),
},
// Navigation
{
id: 'exit',
keys: ['Escape'],
description: 'Exit player',
category: 'navigation',
handler: (_p, ctx) => ctx.navigate(-1),
},
]
/**
* Normalise a binding string for comparison. Lowercases the key but keeps
* the modifier order canonical (Ctrl+Shift+Alt+key).
*/
export function normalizeBinding(binding: string): string {
const parts = binding.split('+').map(p => p.trim())
const key = parts.pop() || ''
const mods = new Set(parts.map(p => p.toLowerCase()))
const out: string[] = []
if (mods.has('ctrl')) out.push('Ctrl')
if (mods.has('shift')) out.push('Shift')
if (mods.has('alt')) out.push('Alt')
out.push(key.length === 1 ? key.toLowerCase() : key)
return out.join('+')
}
/** Build the canonical binding string for a KeyboardEvent. */
export function eventToBinding(e: KeyboardEvent): string {
const out: string[] = []
if (e.ctrlKey || e.metaKey) out.push('Ctrl')
if (e.shiftKey) out.push('Shift')
if (e.altKey) out.push('Alt')
// KeyboardEvent.key already accounts for shift on letters - we want the
// raw key, so for letters use lowercase.
let key = e.key
if (key === ' ') key = 'Space'
else if (key.length === 1) key = key.toLowerCase()
out.push(key)
return out.join('+')
}
/**
* Resolve user's bindings (with overrides applied) into a flat map of
* normalised binding -> shortcut id.
*/
export function buildBindingMap(overrides: Record<string, string[]> = {}): Map<string, string> {
const map = new Map<string, string>()
for (const sc of SHORTCUTS) {
const keys = overrides[sc.id]?.length ? overrides[sc.id] : sc.keys
for (const k of keys) map.set(normalizeBinding(k), sc.id)
}
return map
}
export function findShortcut(id: string): Shortcut | undefined {
return SHORTCUTS.find(s => s.id === id)
}
+16
View File
@@ -0,0 +1,16 @@
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
})
queryClient.getQueryCache().config.onError = (err, query) => {
console.error(`[Query Error] ${query.queryHash}:`, err)
}
+60
View File
@@ -0,0 +1,60 @@
import type { BaseItemDto } from '../api/types'
export interface RecapTrigger {
shouldShow: boolean
previousEpisodes: BaseItemDto[]
daysSinceLastWatch: number | null
}
/**
* Decide whether to show a "Previously on..." recap card before playback.
*
* Trigger conditions (all must hold):
* - The current item is an Episode and not the very first episode (S1E1)
* - We have at least one preceding episode in the list
* - The user's most recent watch in the series happened more than
* `gapDays` ago (so a binge through the same season won't trigger it)
*
* `seasonEpisodes` should be the ordered episode list including the
* current one. For first-of-season cases the previous-episode list ends
* up empty, so the card stays hidden even if the gap fires.
*/
export function computeRecapTrigger(
item: BaseItemDto | null | undefined,
seasonEpisodes: BaseItemDto[],
gapDays: number,
): RecapTrigger {
const empty: RecapTrigger = { shouldShow: false, previousEpisodes: [], daysSinceLastWatch: null }
if (!item || item.Type !== 'Episode') return empty
// Treat S1E1 as no recap regardless of gap.
const season = item.ParentIndexNumber ?? 0
const episode = item.IndexNumber ?? 0
if (season <= 1 && episode <= 1) return empty
const idx = seasonEpisodes.findIndex(e => e.Id === item.Id)
if (idx <= 0) return empty // first in list (or unknown) - no prior episodes
const previousEpisodes = seasonEpisodes
.slice(Math.max(0, idx - 2), idx)
.filter(e => (e.UserData?.PlayedPercentage ?? 0) > 5 || e.UserData?.Played)
if (previousEpisodes.length === 0) return empty
// Find the most recent watch across the visible episode list (excluding
// the current one). LastPlayedDate is an ISO string when present.
let mostRecentMs: number | null = null
for (const e of seasonEpisodes) {
if (e.Id === item.Id) continue
const raw = e.UserData?.LastPlayedDate as string | undefined
if (!raw) continue
const t = Date.parse(raw)
if (!Number.isFinite(t)) continue
if (mostRecentMs == null || t > mostRecentMs) mostRecentMs = t
}
if (mostRecentMs == null) return empty
const daysSinceLastWatch = (Date.now() - mostRecentMs) / 86_400_000
if (daysSinceLastWatch < gapDays) {
return { shouldShow: false, previousEpisodes, daysSinceLastWatch }
}
return { shouldShow: true, previousEpisodes, daysSinceLastWatch }
}
+148
View File
@@ -0,0 +1,148 @@
/**
* Light-weight obfuscation layer over `localStorage` for secrets the app
* persists (Jellyfin auth tokens, TMDB / Fanart API keys). The values are
* XOR'd with a per-install salt and base64-encoded before they hit
* storage, so the secret isn't readable as plaintext if someone opens
* the WebView2 storage file or inspects `localStorage` via dev-tools.
*
* This is obscurity, not real cryptography - a determined attacker with
* access to the storage folder can extract the salt and reverse the XOR.
* It exists for the casual-snooping threat model: the user shares their
* PC with someone curious, or a screenshot accidentally exposes the
* storage panel.
*
* Why not a real keychain? The app is built as a portable Tauri binary
* with `WEBVIEW2_USER_DATA_FOLDER` redirected to `./data/` next to the
* EXE. An OS-level keychain (Windows Credential Manager, macOS Keychain)
* would break that portability - moving the EXE folder to another
* machine would leave the secrets behind. localStorage in the
* redirected folder travels with the data; obfuscation makes it
* not-immediately-readable.
*
* Values written through the older plaintext API are detected on read
* and silently re-written with obfuscation applied, so existing users
* upgrade in place.
*/
const SALT_KEY = '__ss_salt'
const ENVELOPE_PREFIX = 'ss1:'
type StoredValue = string | null
export interface SensitiveStorage {
getItem: (key: string) => StoredValue
setItem: (key: string, value: string) => void
removeItem: (key: string) => void
}
function getLocalStorage(): Storage | null {
try {
return typeof window !== 'undefined' ? window.localStorage : null
} catch {
return null
}
}
/**
* One-shot per install. Stored in plaintext under SALT_KEY - the salt
* alone isn't a secret; pairing it with the XOR'd payload is what makes
* extraction non-trivial.
*/
function getOrCreateSalt(): string {
const ls = getLocalStorage()
if (!ls) return ''
try {
const existing = ls.getItem(SALT_KEY)
if (existing && existing.length >= 16) return existing
// Generate a 32-char hex salt. crypto.randomUUID is available in
// every browser Tauri 2 targets and gives us 128 bits of entropy.
const fresh = (typeof crypto !== 'undefined' && crypto.randomUUID)
? crypto.randomUUID().replace(/-/g, '')
: Math.random().toString(36).slice(2).padEnd(32, '0')
ls.setItem(SALT_KEY, fresh)
return fresh
} catch {
return ''
}
}
function xorWithSalt(input: string, salt: string): string {
if (!salt) return input
const out = new Array<string>(input.length)
for (let i = 0; i < input.length; i++) {
const c = input.charCodeAt(i) ^ salt.charCodeAt(i % salt.length)
out[i] = String.fromCharCode(c)
}
return out.join('')
}
function toBase64(input: string): string {
// btoa only accepts Latin-1; encode UTF-8 first so non-ASCII tokens
// (rare but possible in JWT-like payloads) survive the round trip.
try {
return btoa(unescape(encodeURIComponent(input)))
} catch {
return input
}
}
function fromBase64(input: string): string {
try {
return decodeURIComponent(escape(atob(input)))
} catch {
return input
}
}
function obfuscate(value: string): string {
const salt = getOrCreateSalt()
const scrambled = xorWithSalt(value, salt)
return ENVELOPE_PREFIX + toBase64(scrambled)
}
function deobfuscate(stored: string): string {
if (!stored.startsWith(ENVELOPE_PREFIX)) {
// Legacy plaintext value - return as-is. The setItem path will
// re-write it on the next save.
return stored
}
const salt = getOrCreateSalt()
const scrambled = fromBase64(stored.slice(ENVELOPE_PREFIX.length))
return xorWithSalt(scrambled, salt)
}
export const sensitiveStorage: SensitiveStorage = {
getItem(key) {
const ls = getLocalStorage()
if (!ls) return null
const raw = ls.getItem(key)
if (raw == null) return null
const value = deobfuscate(raw)
// Migrate plaintext values to the obfuscated envelope on read so
// existing installs upgrade transparently.
if (!raw.startsWith(ENVELOPE_PREFIX)) {
try { ls.setItem(key, obfuscate(value)) } catch { /* noop */ }
}
return value
},
setItem(key, value) {
const ls = getLocalStorage()
if (!ls) return
try { ls.setItem(key, obfuscate(value)) } catch { /* noop */ }
},
removeItem(key) {
getLocalStorage()?.removeItem(key)
},
}
export function readSecret(key: string): StoredValue {
return sensitiveStorage.getItem(key)
}
export function writeSecret(key: string, value: string): void {
sensitiveStorage.setItem(key, value)
}
export function removeSecret(key: string): void {
sensitiveStorage.removeItem(key)
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Copy a shareable URL for an item to the clipboard. Prefers TMDB (the
* most universally-recognised media DB), falls back to IMDB. Returns
* `null` when neither id is available; caller is expected to fall back
* to a toast like "no shareable link" in that case.
*/
export function buildShareUrl(opts: {
tmdbId?: string | number | null
imdbId?: string | null
kind?: 'movie' | 'tv' | null
}): string | null {
const { tmdbId, imdbId, kind } = opts
if (tmdbId) {
const numeric = String(tmdbId).replace(/^tmdb-(movie|tv)-/, '')
return `https://www.themoviedb.org/${kind === 'tv' ? 'tv' : 'movie'}/${numeric}`
}
if (imdbId) {
return `https://www.imdb.com/title/${imdbId}/`
}
return null
}
export async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
return true
}
} catch {
/* fall through */
}
// Fallback for ancient browsers / contexts without clipboard API
try {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
const ok = document.execCommand('copy')
document.body.removeChild(ta)
return ok
} catch {
return false
}
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Counts manual skip-intro presses per series. After a threshold we
* surface a "want to auto-skip these for the rest of this show?" prompt.
*
* Storage: `jf_skip_count_<seriesId>` -> number, plus `jf_skip_dismissed_
* <seriesId>` -> '1' for series the user has explicitly waved off.
*/
const COUNT_PREFIX = 'jf_skip_count_'
const DISMISSED_PREFIX = 'jf_skip_dismissed_'
export const SKIP_THRESHOLD = 3
export function recordManualSkip(seriesId: string): { shouldPrompt: boolean; count: number } {
if (!seriesId) return { shouldPrompt: false, count: 0 }
const dismissed = localStorage.getItem(DISMISSED_PREFIX + seriesId) === '1'
if (dismissed) return { shouldPrompt: false, count: 0 }
const current = Number(localStorage.getItem(COUNT_PREFIX + seriesId) || '0')
const next = current + 1
try { localStorage.setItem(COUNT_PREFIX + seriesId, String(next)) } catch { /* noop */ }
return { shouldPrompt: next >= SKIP_THRESHOLD, count: next }
}
export function dismissSkipPrompt(seriesId: string) {
try {
localStorage.setItem(DISMISSED_PREFIX + seriesId, '1')
localStorage.removeItem(COUNT_PREFIX + seriesId)
} catch { /* noop */ }
}
export function clearSkipCount(seriesId: string) {
try {
localStorage.removeItem(COUNT_PREFIX + seriesId)
} catch { /* noop */ }
}
+147
View File
@@ -0,0 +1,147 @@
import type { BaseItemDto } from '../api/types'
/**
* Heavier stats derivations used by /stats. Profile-page helpers live in
* `watch-stats.ts` (streaks, basic genre share, longest binge); these
* add hours-weighted breakdowns, completion ratios, studios, and the
* cross-series "time saved by skipping" rollup.
*/
const TICKS_PER_HOUR = 36_000_000_000
const KEY_PREFIX = 'jf_time_saved_'
export interface HoursShare {
label: string
hours: number
share: number
}
export interface CompletionStats {
total: number
completed: number
inProgress: number
unstarted: number
completionRate: number
}
export interface PersonShare {
name: string
/** Number of items this person appears in. */
count: number
}
export function hoursPerGenre(items: BaseItemDto[]): HoursShare[] {
const buckets = new Map<string, number>()
let total = 0
for (const it of items) {
const ticks = Number(it.RunTimeTicks ?? 0)
if (!ticks) continue
const hours = ticks / TICKS_PER_HOUR
const genres = it.Genres || []
if (genres.length === 0) continue
for (const g of genres) {
buckets.set(g, (buckets.get(g) || 0) + hours)
total += hours
}
}
if (total === 0) return []
return [...buckets.entries()]
.map(([label, hours]) => ({ label, hours, share: hours / total }))
.sort((a, b) => b.hours - a.hours)
}
export function hoursPerStudio(items: BaseItemDto[]): HoursShare[] {
const buckets = new Map<string, number>()
let total = 0
for (const it of items) {
const ticks = Number(it.RunTimeTicks ?? 0)
if (!ticks) continue
const hours = ticks / TICKS_PER_HOUR
const studios = (it.Studios || []).map(s => s.Name).filter((n): n is string => !!n)
if (studios.length === 0) continue
for (const s of studios) {
buckets.set(s, (buckets.get(s) || 0) + hours)
total += hours
}
}
if (total === 0) return []
return [...buckets.entries()]
.map(([label, hours]) => ({ label, hours, share: hours / total }))
.sort((a, b) => b.hours - a.hours)
}
export function completion(items: BaseItemDto[]): CompletionStats {
let completed = 0
let inProgress = 0
let unstarted = 0
for (const it of items) {
const ud = it.UserData
const pct = Number(ud?.PlayedPercentage ?? 0)
if (ud?.Played) completed++
else if (pct > 0) inProgress++
else unstarted++
}
const total = items.length
return {
total,
completed,
inProgress,
unstarted,
completionRate: total > 0 ? completed / total : 0,
}
}
/**
* Top names from item.People filtered by role. Items must have been
* fetched with `People` in the fields list.
*/
export function topByPersonRole(
items: BaseItemDto[],
predicate: (role: string | null | undefined, type: string | null | undefined) => boolean,
limit = 10,
): PersonShare[] {
const buckets = new Map<string, number>()
for (const it of items) {
const people = ((it as any).People || []) as Array<{ Name?: string | null; Type?: string | null; Role?: string | null }>
const seen = new Set<string>()
for (const p of people) {
if (!p.Name) continue
if (!predicate(p.Role ?? null, p.Type ?? null)) continue
if (seen.has(p.Name)) continue
seen.add(p.Name)
buckets.set(p.Name, (buckets.get(p.Name) || 0) + 1)
}
}
return [...buckets.entries()]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
.slice(0, limit)
}
/**
* Sum every per-series time-saved bucket in localStorage. Returns total
* seconds saved across intros + credits skips.
*/
export function totalTimeSavedSeconds(): { total: number; intros: number; credits: number; series: number } {
let total = 0
let intros = 0
let credits = 0
let series = 0
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (!key || !key.startsWith(KEY_PREFIX)) continue
try {
const parsed = JSON.parse(localStorage.getItem(key) || '{}')
const ix = Number(parsed.intro) || 0
const cx = Number(parsed.credits) || 0
if (ix === 0 && cx === 0) continue
intros += ix
credits += cx
total += ix + cx
series++
} catch { /* skip malformed */ }
}
} catch { /* localStorage unavailable */ }
return { total, intros, credits, series }
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Curated TMDB company / network ids used by the home page's
* "From the studios" and "On the networks" rows. The lists are
* intentionally tight - 8 each - to avoid a wall of brand rows.
*
* Reference: TMDB /search/company and /search/network. Ids are stable.
*/
export interface BrandRef {
id: number
label: string
/** Optional one-line subtitle. */
blurb?: string
}
export const STUDIOS: BrandRef[] = [
{ id: 420, label: 'Marvel Studios', blurb: 'Superhero blockbusters' },
{ id: 2, label: 'Walt Disney Pictures', blurb: 'Animation + family' },
{ id: 1, label: 'Lucasfilm', blurb: 'Star Wars + Indiana Jones' },
{ id: 174, label: 'Warner Bros.', blurb: 'Hollywood mainstays' },
{ id: 33, label: 'Universal Pictures', blurb: 'Franchise + tentpole' },
{ id: 25, label: '20th Century Studios', blurb: 'Studio classics' },
{ id: 7505, label: 'Marvel Entertainment', blurb: 'Marvel co-production' },
{ id: 41077, label: 'A24', blurb: 'Indie + arthouse' },
{ id: 521, label: 'DreamWorks Animation', blurb: 'Family animation' },
{ id: 4, label: 'Paramount', blurb: 'Action + thriller' },
]
export const NETWORKS: BrandRef[] = [
{ id: 213, label: 'Netflix', blurb: 'Originals + acquisitions' },
{ id: 49, label: 'HBO', blurb: 'Prestige drama' },
{ id: 2739, label: 'Disney+', blurb: 'Disney + Marvel + Star Wars' },
{ id: 1024, label: 'Amazon Prime Video', blurb: 'Originals + licensed' },
{ id: 2552, label: 'Apple TV+', blurb: 'Apple originals' },
{ id: 453, label: 'Hulu', blurb: 'US streaming originals' },
{ id: 67, label: 'Showtime', blurb: 'Premium cable drama' },
{ id: 19, label: 'FOX', blurb: 'Network television' },
{ id: 4, label: 'BBC One', blurb: 'British drama' },
{ id: 174, label: 'AMC', blurb: 'Cable prestige' },
]
+123
View File
@@ -0,0 +1,123 @@
/**
* Subtitle auto-selection. Jellyfin returns subtitle streams with a
* mix of language tags - some have a 3-letter ISO code ('eng'), some
* a 2-letter code ('en'), some include a region ('en-US'), and some
* encode the language only in the track name ('English (SRT)'). This
* module normalises across all of those so a user pref of "eng" still
* picks up tracks tagged 'English' or 'en-US'.
*/
interface SubStream {
Index?: number
Language?: string | null
Title?: string | null
DisplayTitle?: string | null
Codec?: string | null
IsDefault?: boolean | null
IsForced?: boolean | null
IsExternal?: boolean | null
IsHearingImpaired?: boolean | null
}
const TWO_TO_THREE: Record<string, string> = {
en: 'eng', es: 'spa', fr: 'fra', de: 'ger', ja: 'jpn', ko: 'kor',
zh: 'chi', it: 'ita', pt: 'por', ru: 'rus', nl: 'nld', sv: 'swe',
no: 'nor', da: 'dan', fi: 'fin', pl: 'pol', tr: 'tur', ar: 'ara',
he: 'heb', hi: 'hin', cs: 'cze', hu: 'hun', ro: 'rum', bg: 'bul',
uk: 'ukr', el: 'gre', th: 'tha', vi: 'vie', id: 'ind', ms: 'may',
ca: 'cat', hr: 'hrv', sk: 'slo', sl: 'slv', sr: 'srp', et: 'est',
lv: 'lav', lt: 'lit', fa: 'per', ta: 'tam', te: 'tel', bn: 'ben',
}
const NAME_TO_CODE: Record<string, string> = {
english: 'eng', spanish: 'spa', french: 'fra', german: 'ger', japanese: 'jpn',
korean: 'kor', chinese: 'chi', mandarin: 'chi', cantonese: 'chi', italian: 'ita',
portuguese: 'por', russian: 'rus', dutch: 'nld', swedish: 'swe', norwegian: 'nor',
danish: 'dan', finnish: 'fin', polish: 'pol', turkish: 'tur', arabic: 'ara',
hebrew: 'heb', hindi: 'hin', czech: 'cze', hungarian: 'hun', romanian: 'rum',
bulgarian: 'bul', ukrainian: 'ukr', greek: 'gre', thai: 'tha', vietnamese: 'vie',
indonesian: 'ind', malay: 'may', catalan: 'cat', croatian: 'hrv', slovak: 'slo',
slovenian: 'slv', serbian: 'srp', estonian: 'est', latvian: 'lav', lithuanian: 'lit',
persian: 'per', farsi: 'per', tamil: 'tam', telugu: 'tel', bengali: 'ben',
}
/**
* Normalise an arbitrary language string to a 3-letter ISO 639-2 code.
* Handles 2-letter codes ('en'), region-tagged codes ('en-US', 'pt_BR'),
* full language names ('English'), and the alternate 3-letter codes
* Jellyfin sometimes returns ('en' tagged as eng).
*/
export function normalizeLang(s: string | null | undefined): string {
if (!s) return ''
const lower = s.toLowerCase().trim()
if (NAME_TO_CODE[lower]) return NAME_TO_CODE[lower]
const base = lower.split(/[-_]/)[0]
if (TWO_TO_THREE[base]) return TWO_TO_THREE[base]
// Already a 3-letter code or unknown - return as-is so an exact match
// still works for less common languages.
return base
}
function isTextCodec(codec: string | null | undefined): boolean {
const c = (codec || '').toLowerCase()
return c === 'srt' || c === 'subrip' || c === 'ass' || c === 'ssa' || c === 'vtt' || c === 'webvtt' || c === 'mov_text'
}
function trackMatchesLanguage(track: SubStream, wantNorm: string, wantRaw: string): boolean {
if (track.Language) {
if (normalizeLang(track.Language) === wantNorm) return true
}
// Sometimes the language only lives in the track title:
// "English (SRT)"
// "Eng"
// "Forced English"
const haystack = `${track.Title || ''} ${track.DisplayTitle || ''}`.toLowerCase()
if (!haystack.trim()) return false
if (haystack.includes(wantRaw.toLowerCase())) return true
// Look for any name that normalises to the same code.
for (const [name, code] of Object.entries(NAME_TO_CODE)) {
if (code === wantNorm && haystack.includes(name)) return true
}
return false
}
/**
* Pick the best subtitle stream for the user's preferred language.
*
* Ranking, in order:
* 1. Full-language non-forced text-based track (the typical pick)
* 2. Full-language non-forced image-based track (DVDSUB / PGS)
* 3. Full-language forced text track
* 4. Full-language forced image track
* 5. Default-flagged track of any language (only if 1-4 missed)
*
* Forced + hearing-impaired tracks are ranked lower because they
* usually translate only foreign dialogue or include audio cues, which
* isn't what someone setting "always English" actually wants.
*/
export function pickSubtitle(streams: SubStream[], preferredLanguage: string): SubStream | null {
if (streams.length === 0) return null
const wantNorm = normalizeLang(preferredLanguage)
const wantRaw = preferredLanguage.trim()
const matching = streams.filter(s => trackMatchesLanguage(s, wantNorm, wantRaw))
function score(s: SubStream): number {
let n = 0
if (!s.IsForced) n += 8
if (isTextCodec(s.Codec)) n += 4
if (!s.IsHearingImpaired) n += 2
if (s.IsDefault) n += 1
return n
}
if (matching.length > 0) {
return [...matching].sort((a, b) => score(b) - score(a))[0]
}
// No language match - fall back to the server-marked default rather
// than a random first track. Useful for users whose preferred
// language doesn't exist on a given file (so we don't force-select
// some unrelated track).
return streams.find(s => s.IsDefault) || null
}
+92
View File
@@ -0,0 +1,92 @@
import { usePreferencesStore } from '../stores/preferences-store'
import type { AppSettings } from '../api/types'
/**
* Builds styling for vidstack's <Captions> renderer based on the user's
* subtitle preferences. Font size is returned as an inline CSS custom
* property because Tailwind can't generate classes for arbitrary pixel
* values that only exist at runtime. The other properties use Tailwind
* arbitrary-variant selectors (`[&_[data-cue]]:...`) to target the cue
* elements that vidstack injects.
*
* The same builder powers the live preview in Settings so what users see
* there matches what they'll see in the player.
*/
export type SubtitleStyle = { className: string; style: React.CSSProperties }
type S = Pick<
AppSettings,
'subtitleFontSize' | 'subtitleFontFamily' | 'subtitleBackground' | 'subtitleEdge' | 'subtitlePosition' | 'subtitleColor'
>
const FAMILY_CLASS: Record<AppSettings['subtitleFontFamily'], string> = {
sans: '[&_[data-cue]]:font-sans',
serif: '[&_[data-cue]]:[font-family:var(--font-display)]',
mono: '[&_[data-cue]]:font-mono',
}
const BG_CLASS: Record<AppSettings['subtitleBackground'], string> = {
none: '',
subtle:
'[&_[data-cue]]:bg-black/55 [&_[data-cue]]:rounded-md [&_[data-cue]]:px-3 [&_[data-cue]]:py-1',
solid: '[&_[data-cue]]:bg-black [&_[data-cue]]:px-3 [&_[data-cue]]:py-1',
}
const EDGE_CLASS: Record<AppSettings['subtitleEdge'], string> = {
none: '',
shadow: '[&_[data-cue]]:[text-shadow:0_2px_6px_rgba(0,0,0,0.85)]',
outline:
'[&_[data-cue]]:[text-shadow:-1px_-1px_0_#000,1px_-1px_0_#000,-1px_1px_0_#000,1px_1px_0_#000,0_2px_4px_rgba(0,0,0,0.7)]',
}
const COLOR_CLASS: Record<AppSettings['subtitleColor'], string> = {
white: '[&_[data-cue]]:text-white',
yellow: '[&_[data-cue]]:text-yellow-300',
cyan: '[&_[data-cue]]:text-cyan-200',
}
const POSITION_CLASS: Record<AppSettings['subtitlePosition'], string> = {
bottom: 'bottom-32',
top: 'top-24',
}
/** Compose the className + inline style for the Captions container. */
export function subtitleClasses(s: S): SubtitleStyle {
const className = [
'absolute inset-x-0 px-7 pointer-events-none text-center font-semibold tracking-tight',
'[&_[data-cue]]:inline-block [&_[data-cue]]:leading-tight',
POSITION_CLASS[s.subtitlePosition],
FAMILY_CLASS[s.subtitleFontFamily],
BG_CLASS[s.subtitleBackground],
EDGE_CLASS[s.subtitleEdge],
COLOR_CLASS[s.subtitleColor],
]
.filter(Boolean)
.join(' ')
const style: React.CSSProperties = {
'--cue-font-size': `${s.subtitleFontSize}px`,
'--cue-font-size-md': `${Math.round(s.subtitleFontSize * 1.36)}px`,
} as React.CSSProperties
return { className, style }
}
/** Hook: composes subtitle styling for live use, subscribed to preference changes. */
export function useSubtitleStyles(): SubtitleStyle {
const subtitleFontSize = usePreferencesStore(s => s.subtitleFontSize)
const subtitleFontFamily = usePreferencesStore(s => s.subtitleFontFamily)
const subtitleBackground = usePreferencesStore(s => s.subtitleBackground)
const subtitleEdge = usePreferencesStore(s => s.subtitleEdge)
const subtitlePosition = usePreferencesStore(s => s.subtitlePosition)
const subtitleColor = usePreferencesStore(s => s.subtitleColor)
return subtitleClasses({
subtitleFontSize,
subtitleFontFamily,
subtitleBackground,
subtitleEdge,
subtitlePosition,
subtitleColor,
})
}
+141
View File
@@ -0,0 +1,141 @@
import { jellyfinClient } from '../api/jellyfin'
/**
* Lightweight Jellyfin WebSocket wrapper scoped to SyncPlay messages.
* The socket is opened on demand (when a SyncPlay group is joined),
* pushes JSON-decoded messages to subscribers, and reconnects with
* exponential backoff if the server drops us.
*
* We intentionally don't ship a full WS client - other features in the
* app (push notifications, library updates) would need different
* subscription scopes, so a generic event bus would do more harm than
* good. When those land they can graduate this into a shared singleton.
*/
export type SyncPlayMessage =
| { MessageType: 'SyncPlayGroupUpdate'; Data: SyncPlayGroupUpdate }
| { MessageType: 'SyncPlayCommand'; Data: SyncPlayCommand }
| { MessageType: string; Data?: unknown }
export interface SyncPlayGroupUpdate {
GroupId?: string
Type?: string
Data?: unknown
}
export interface SyncPlayCommand {
GroupId?: string
Command?: 'Pause' | 'Unpause' | 'Stop' | 'Seek' | string
PositionTicks?: number
When?: string
EmittedAt?: string
}
type Listener = (msg: SyncPlayMessage) => void
let socket: WebSocket | null = null
const listeners = new Set<Listener>()
let retry = 0
let retryTimer: number | null = null
let pingTimer: number | null = null
let stopped = false
function wsUrl(): string | null {
const auth = jellyfinClient.getAuthState()
const api = jellyfinClient.getApi()
if (!auth || !api) return null
const base = auth.serverUrl.replace(/^http/, 'ws')
const deviceId = (api.deviceInfo?.id) || 'jf-desktop'
return `${base}/socket?api_key=${encodeURIComponent(auth.token)}&deviceId=${encodeURIComponent(deviceId)}`
}
function clearRetryTimer() {
if (retryTimer != null) {
clearTimeout(retryTimer)
retryTimer = null
}
}
function clearPingTimer() {
if (pingTimer != null) {
clearInterval(pingTimer)
pingTimer = null
}
}
function connect() {
if (stopped) return
const url = wsUrl()
if (!url) return
try {
socket = new WebSocket(url)
} catch {
scheduleReconnect()
return
}
socket.onopen = () => {
retry = 0
// Server keeps the socket alive while it sees periodic KeepAlive
// pings. We send the start-keepalive sentinel, then heartbeat every
// 30s ourselves so we look active to the gateway in case the server
// skips its own pings.
try {
socket?.send(JSON.stringify({ MessageType: 'KeepAlive' }))
} catch { /* noop */ }
clearPingTimer()
pingTimer = window.setInterval(() => {
try {
socket?.send(JSON.stringify({ MessageType: 'KeepAlive' }))
} catch { /* noop */ }
}, 30_000)
}
socket.onmessage = ev => {
try {
const data = JSON.parse(ev.data) as SyncPlayMessage
if (!data.MessageType) return
if (data.MessageType.startsWith('SyncPlay')) {
for (const l of listeners) l(data)
}
} catch { /* skip malformed frames */ }
}
socket.onclose = () => {
clearPingTimer()
socket = null
scheduleReconnect()
}
socket.onerror = () => {
try { socket?.close() } catch { /* noop */ }
}
}
function scheduleReconnect() {
if (stopped) return
clearRetryTimer()
const delay = Math.min(30_000, 1000 * 2 ** Math.min(retry, 5))
retry++
retryTimer = window.setTimeout(connect, delay)
}
export function startSyncPlaySocket() {
stopped = false
if (socket) return
connect()
}
export function stopSyncPlaySocket() {
stopped = true
listeners.clear()
clearRetryTimer()
clearPingTimer()
if (socket) {
try { socket.close() } catch { /* noop */ }
socket = null
}
}
export function subscribeSyncPlay(listener: Listener): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
+87
View File
@@ -0,0 +1,87 @@
import { getSyncPlayApi } from '@jellyfin/sdk/lib/utils/api/sync-play-api'
import { jellyfinClient } from '../api/jellyfin'
/**
* REST wrappers around Jellyfin SyncPlay. The server keeps the group
* state - we just send commands and read the membership list. Real-time
* pushes come through the WebSocket subscription in `syncplay-socket.ts`.
*/
export interface SyncPlayGroup {
groupId: string
groupName: string
participantCount: number
}
function requireApi() {
const api = jellyfinClient.getApi()
if (!api) throw new Error('Not authenticated')
return api
}
export async function listGroups(): Promise<SyncPlayGroup[]> {
const api = jellyfinClient.getApi()
if (!api) return []
try {
const res = await getSyncPlayApi(api).syncPlayGetGroups()
return (res.data || []).map(g => ({
groupId: g.GroupId || '',
groupName: g.GroupName || 'Untitled group',
participantCount: g.Participants?.length || 0,
}))
} catch {
return []
}
}
export async function createGroup(name: string): Promise<SyncPlayGroup | null> {
const res = await getSyncPlayApi(requireApi()).syncPlayCreateGroup({
newGroupRequestDto: { GroupName: name },
})
const g = res.data
if (!g?.GroupId) return null
return {
groupId: g.GroupId,
groupName: g.GroupName || name,
participantCount: g.Participants?.length || 1,
}
}
export async function joinGroup(groupId: string): Promise<void> {
await getSyncPlayApi(requireApi()).syncPlayJoinGroup({
joinGroupRequestDto: { GroupId: groupId },
})
}
export async function leaveGroup(): Promise<void> {
await getSyncPlayApi(requireApi()).syncPlayLeaveGroup()
}
export async function sendPause(): Promise<void> {
await getSyncPlayApi(requireApi()).syncPlayPause()
}
export async function sendUnpause(): Promise<void> {
await getSyncPlayApi(requireApi()).syncPlayUnpause()
}
export async function sendSeek(positionTicks: number): Promise<void> {
await getSyncPlayApi(requireApi()).syncPlaySeek({
seekRequestDto: { PositionTicks: positionTicks },
})
}
/**
* Tell the server the current item + position. Used by the host when
* they create a party so that joiners get pushed the right item via
* the PlayQueue WS update.
*/
export async function setNewQueue(itemId: string, positionTicks: number): Promise<void> {
await getSyncPlayApi(requireApi()).syncPlaySetNewQueue({
playRequestDto: {
PlayingQueue: [itemId],
PlayingItemPosition: 0,
StartPositionTicks: positionTicks,
},
})
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Detects whether the page is running inside the Tauri shell. The shell
* injects `__TAURI_INTERNALS__` on the global before any user code runs, so
* the check is synchronous and safe at module init time.
*
* Used to gate features that only make sense in the desktop shell (custom
* titlebar, native window controls, autoplay flags, etc.).
*/
export const isTauri =
typeof window !== 'undefined' &&
('__TAURI_INTERNALS__' in window || '__TAURI_IPC__' in window)
+61
View File
@@ -0,0 +1,61 @@
/**
* Per-series accumulator for seconds saved by auto-skipping intros and
* credits markers. Persists in localStorage so the badge survives
* sessions. The figure is rough - we count `marker.endSec - currentTime`
* at the moment of skip, rounded down to whole seconds.
*/
const KEY_PREFIX = 'jf_time_saved_'
export interface TimeSavedEntry {
intro: number
credits: number
/** ISO timestamp of the most recent recorded skip. */
lastAt?: string
}
export function readTimeSaved(seriesId: string): TimeSavedEntry {
if (!seriesId) return { intro: 0, credits: 0 }
try {
const raw = localStorage.getItem(KEY_PREFIX + seriesId)
if (!raw) return { intro: 0, credits: 0 }
const parsed = JSON.parse(raw)
return {
intro: Number(parsed.intro) || 0,
credits: Number(parsed.credits) || 0,
lastAt: parsed.lastAt,
}
} catch {
return { intro: 0, credits: 0 }
}
}
export function recordSkippedSeconds(
seriesId: string,
type: 'intro' | 'credits',
seconds: number,
) {
if (!seriesId || !Number.isFinite(seconds) || seconds <= 0) return
// Cap a single skip at 20 minutes. Marvel credits with mid- and
// post-credit scenes can run over 10 minutes; anything past 20 is
// almost certainly a marker glitch.
const capped = Math.min(seconds, 1200)
const cur = readTimeSaved(seriesId)
const next: TimeSavedEntry = {
...cur,
[type]: cur[type] + Math.round(capped),
lastAt: new Date().toISOString(),
}
try {
localStorage.setItem(KEY_PREFIX + seriesId, JSON.stringify(next))
} catch { /* noop */ }
}
export function formatTimeSaved(totalSec: number): string {
if (totalSec <= 0) return '0m'
const hours = Math.floor(totalSec / 3600)
const mins = Math.floor((totalSec % 3600) / 60)
if (hours === 0) return `${mins}m`
if (mins === 0) return `${hours}h`
return `${hours}h ${mins}m`
}
+61
View File
@@ -0,0 +1,61 @@
/**
* TMDB genre id mappings. Movie and TV use different ids and names for
* a handful of genres; we keep both lookups so a Jellyfin string genre
* can be resolved against either type.
*
* Reference: TMDB /genre/movie/list and /genre/tv/list (stable values).
*/
export const TMDB_MOVIE_GENRES: Record<string, number> = {
Action: 28,
Adventure: 12,
Animation: 16,
Comedy: 35,
Crime: 80,
Documentary: 99,
Drama: 18,
Family: 10751,
Fantasy: 14,
History: 36,
Horror: 27,
Music: 10402,
Mystery: 9648,
Romance: 10749,
'Science Fiction': 878,
'TV Movie': 10770,
Thriller: 53,
War: 10752,
Western: 37,
}
export const TMDB_TV_GENRES: Record<string, number> = {
'Action & Adventure': 10759,
Action: 10759,
Adventure: 10759,
Animation: 16,
Comedy: 35,
Crime: 80,
Documentary: 99,
Drama: 18,
Family: 10751,
Kids: 10762,
Mystery: 9648,
News: 10763,
Reality: 10764,
'Sci-Fi & Fantasy': 10765,
'Science Fiction': 10765,
Fantasy: 10765,
Soap: 10766,
Talk: 10767,
'War & Politics': 10768,
War: 10768,
Western: 37,
}
export function tmdbMovieGenreId(name: string): number | null {
return TMDB_MOVIE_GENRES[name] ?? null
}
export function tmdbTvGenreId(name: string): number | null {
return TMDB_TV_GENRES[name] ?? null
}
+61
View File
@@ -0,0 +1,61 @@
import { getTmdbImageUrl } from '../api/tmdb'
import type { BaseItemDto } from '../api/types'
/**
* Convert a TMDB result row (movie / tv / multi) into a synthetic
* BaseItemDto so it can render through the same PosterCard pipeline as
* Jellyfin items. When a `libraryMap` (TMDB id -> Jellyfin item) is
* provided, items already in the user's library are flagged via
* `_inLibrary` and their `Id` is rewritten to the local Jellyfin id so
* clicking opens the existing detail page instead of the synthetic one.
*/
export function mapTmdbToJf(
items: any[],
libraryMap?: Map<string, { id: string; name: string; type: string }>,
): BaseItemDto[] {
return items.map(m => {
const local = libraryMap?.get(String(m.id))
const isSeries = m.media_type === 'tv' || !!m.first_air_date
// Encode kind in the synthetic id so DetailPage can route directly
// to the right TMDB endpoint without guessing. Library matches keep
// the Jellyfin id as before.
const syntheticId = `tmdb-${isSeries ? 'tv' : 'movie'}-${m.id}`
return {
Id: local?.id || syntheticId,
Name: m.title || m.name,
Type: isSeries ? 'Series' : 'Movie',
ProductionYear: m.release_date
? parseInt(m.release_date)
: m.first_air_date
? parseInt(m.first_air_date)
: undefined,
Overview: m.overview,
ImageTags: {},
ProviderIds: { Tmdb: String(m.id) },
CommunityRating: typeof m.vote_average === 'number' ? m.vote_average : undefined,
_tmdbPoster: m.poster_path ? getTmdbImageUrl(m.poster_path, 'w342') : undefined,
_tmdbBackdrop: m.backdrop_path ? getTmdbImageUrl(m.backdrop_path, 'w780') : undefined,
_inLibrary: !!local,
_tmdbId: String(m.id),
} as any as BaseItemDto
})
}
/**
* Detect whether a route id is a synthetic TMDB id (from the discovery
* rows) and parse out kind + numeric id when so. Returns null for
* regular Jellyfin ids.
*/
export function parseTmdbRouteId(id: string | undefined | null): { kind: 'movie' | 'tv'; tmdbId: number } | null {
if (!id) return null
// New format: tmdb-movie-123 / tmdb-tv-123
const match = id.match(/^tmdb-(movie|tv)-(\d+)$/)
if (match) {
return { kind: match[1] as 'movie' | 'tv', tmdbId: Number(match[2]) }
}
// Legacy format: tmdb-123 (kind unknown - default to movie). Persists
// until any saved data with the old id refreshes.
const legacy = id.match(/^tmdb-(\d+)$/)
if (legacy) return { kind: 'movie', tmdbId: Number(legacy[1]) }
return null
}
+27
View File
@@ -0,0 +1,27 @@
import type { BaseItemDto } from '../api/types'
/**
* Tally the user's most-watched genre from a given list of played
* library items, returning the leader plus a runner-up. We weight
* each item equally - shows watched once still count once - so the
* result is "what flavour of media you tend to pick" rather than
* "what filled the most hours".
*/
export function topGenre(items: BaseItemDto[] | null | undefined): {
primary: string | null
runnerUp: string | null
counts: Record<string, number>
} {
const counts: Record<string, number> = {}
for (const it of items || []) {
for (const g of it.Genres || []) {
counts[g] = (counts[g] || 0) + 1
}
}
const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1])
return {
primary: sorted[0]?.[0] || null,
runnerUp: sorted[1]?.[0] || null,
counts,
}
}
+283
View File
@@ -0,0 +1,283 @@
import { useTrakt, getTraktClientId, getTraktClientSecret } from '../stores/trakt-store'
import { jellyfinClient, getItemsApi } from '../api/jellyfin'
import type { BaseItemDto } from '../api/types'
/**
* Trakt.tv REST helpers: device-code OAuth + scrobble. The device flow
* lets us authorise without redirecting through a browser - we show the
* user a short code, they enter it on trakt.tv/activate, and we poll
* for the resulting token.
*
* Scrobble endpoints expect tmdb / imdb / tvdb ids on the media object
* plus a progress percentage. We pull those off the Jellyfin item's
* ProviderIds so no extra lookups are required.
*/
const BASE = 'https://api.trakt.tv'
export interface DeviceCode {
device_code: string
user_code: string
verification_url: string
expires_in: number
interval: number
}
function headers(): Record<string, string> {
const clientId = getTraktClientId()
return {
'Content-Type': 'application/json',
'trakt-api-version': '2',
'trakt-api-key': clientId,
}
}
function authHeaders(token: string): Record<string, string> {
return {
...headers(),
Authorization: `Bearer ${token}`,
}
}
export async function requestDeviceCode(): Promise<DeviceCode> {
const clientId = getTraktClientId()
if (!clientId) throw new Error('Trakt client id not configured')
const res = await fetch(`${BASE}/oauth/device/code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: clientId }),
})
if (!res.ok) throw new Error(`device/code failed: ${res.status}`)
return res.json()
}
export async function pollDeviceToken(deviceCode: string): Promise<{ access_token: string; refresh_token: string; expires_in: number } | null> {
const clientId = getTraktClientId()
const clientSecret = getTraktClientSecret()
if (!clientId || !clientSecret) throw new Error('Trakt credentials not configured')
const res = await fetch(`${BASE}/oauth/device/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: deviceCode,
client_id: clientId,
client_secret: clientSecret,
}),
})
if (res.status === 200) return res.json()
// 400 = pending, 404 = expired, 409 = already used, 410 = denied, 418 = polling too fast, 429 = rate limit
if (res.status === 400 || res.status === 429) return null
throw new Error(`token poll failed: ${res.status}`)
}
export async function refreshToken(): Promise<boolean> {
const tokens = useTrakt.getState().tokens
if (!tokens?.refreshToken) return false
const clientId = getTraktClientId()
const clientSecret = getTraktClientSecret()
if (!clientId || !clientSecret) return false
try {
const res = await fetch(`${BASE}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
refresh_token: tokens.refreshToken,
client_id: clientId,
client_secret: clientSecret,
grant_type: 'refresh_token',
}),
})
if (!res.ok) return false
const data = await res.json()
useTrakt.getState().setTokens({
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: new Date(Date.now() + data.expires_in * 1000).toISOString(),
})
return true
} catch {
return false
}
}
async function ensureFreshToken(): Promise<string | null> {
const tokens = useTrakt.getState().tokens
if (!tokens) return null
if (Date.parse(tokens.expiresAt) - Date.now() < 60_000) {
const ok = await refreshToken()
if (!ok) return null
return useTrakt.getState().tokens?.accessToken ?? null
}
return tokens.accessToken
}
// Cache series ProviderIds keyed by seriesId so episode scrobbles don't
// re-fetch the series item on every start/pause/stop. Cleared when the
// app reloads - matches the typical Jellyfin Items API freshness window.
const seriesIdsCache = new Map<string, Record<string, string>>()
async function fetchSeriesProviderIds(seriesId: string): Promise<Record<string, string>> {
const cached = seriesIdsCache.get(seriesId)
if (cached) return cached
const api = jellyfinClient.getApi()
if (!api) return {}
try {
const res = await getItemsApi(api).getItems({
userId: jellyfinClient.getAuthState()!.userId,
ids: [seriesId],
fields: ['ProviderIds'],
} as any)
const series = res.data.Items?.[0]
const ids = series?.ProviderIds || {}
const result: Record<string, string> = {}
if (ids.Tmdb) result.tmdb = String(ids.Tmdb)
if (ids.Imdb) result.imdb = String(ids.Imdb)
if (ids.Tvdb) result.tvdb = String(ids.Tvdb)
seriesIdsCache.set(seriesId, result)
return result
} catch {
return {}
}
}
/**
* Build the media payload for a scrobble call. For episodes we resolve
* the parent series' ProviderIds via the Items API (cached) so Trakt
* matches on tmdb / imdb / tvdb id rather than the much fuzzier title.
* Returns null when there's no usable identifier at all.
*/
async function buildScrobbleBody(item: BaseItemDto, progressPct: number) {
const progress = Math.max(0, Math.min(100, progressPct))
if (item.Type === 'Movie') {
const ids: Record<string, string | number> = {}
const tmdb = item.ProviderIds?.Tmdb
const imdb = item.ProviderIds?.Imdb
const tvdb = item.ProviderIds?.Tvdb
if (tmdb) ids.tmdb = Number(tmdb) || tmdb
if (imdb) ids.imdb = imdb
if (tvdb) ids.tvdb = Number(tvdb) || tvdb
if (Object.keys(ids).length === 0) return null
return { movie: { ids }, progress }
}
if (item.Type === 'Episode') {
const showIds: Record<string, string | number> = {}
if (item.SeriesId) {
const fetched = await fetchSeriesProviderIds(item.SeriesId)
if (fetched.tmdb) showIds.tmdb = Number(fetched.tmdb) || fetched.tmdb
if (fetched.imdb) showIds.imdb = fetched.imdb
if (fetched.tvdb) showIds.tvdb = Number(fetched.tvdb) || fetched.tvdb
}
const show = Object.keys(showIds).length
? { ids: showIds }
: { title: item.SeriesName || '' }
return {
show,
episode: {
season: item.ParentIndexNumber ?? 1,
number: item.IndexNumber ?? 1,
},
progress,
}
}
return null
}
async function postScrobble(action: 'start' | 'pause' | 'stop', body: object) {
const token = await ensureFreshToken()
if (!token) return false
try {
const res = await fetch(`${BASE}/scrobble/${action}`, {
method: 'POST',
headers: authHeaders(token),
body: JSON.stringify(body),
})
return res.ok
} catch {
return false
}
}
export async function scrobbleStart(item: BaseItemDto, progressPct: number): Promise<boolean> {
if (!useTrakt.getState().enabled) return false
const body = await buildScrobbleBody(item, progressPct)
if (!body) return false
return postScrobble('start', body)
}
export async function scrobblePause(item: BaseItemDto, progressPct: number): Promise<boolean> {
if (!useTrakt.getState().enabled) return false
const body = await buildScrobbleBody(item, progressPct)
if (!body) return false
return postScrobble('pause', body)
}
export async function scrobbleStop(item: BaseItemDto, progressPct: number): Promise<boolean> {
if (!useTrakt.getState().enabled) return false
const body = await buildScrobbleBody(item, progressPct)
if (!body) return false
return postScrobble('stop', body)
}
/** Fetch the user's Trakt watchlist (movies + shows). */
export async function fetchTraktWatchlist(): Promise<Array<{ type: 'movie' | 'show'; ids: Record<string, string | number> }>> {
const token = await ensureFreshToken()
if (!token) return []
try {
const res = await fetch(`${BASE}/sync/watchlist`, {
headers: authHeaders(token),
})
if (!res.ok) return []
const data = await res.json()
const out: Array<{ type: 'movie' | 'show'; ids: Record<string, string | number> }> = []
for (const entry of data || []) {
if (entry.type === 'movie' && entry.movie?.ids) {
out.push({ type: 'movie', ids: entry.movie.ids })
} else if (entry.type === 'show' && entry.show?.ids) {
out.push({ type: 'show', ids: entry.show.ids })
}
}
return out
} catch {
return []
}
}
/** Add a movie or show to the Trakt watchlist. */
export async function addToTraktWatchlist(item: BaseItemDto): Promise<boolean> {
const token = await ensureFreshToken()
if (!token) return false
const body = await buildScrobbleBody(item, 0)
if (!body) return false
const payload = item.Type === 'Movie' ? { movies: [{ ids: (body as any).movie.ids }] } : { shows: [{ ids: (body as any).show.ids }] }
try {
const res = await fetch(`${BASE}/sync/watchlist`, {
method: 'POST',
headers: authHeaders(token),
body: JSON.stringify(payload),
})
return res.ok
} catch {
return false
}
}
/** Remove a movie or show from the Trakt watchlist. */
export async function removeFromTraktWatchlist(item: BaseItemDto): Promise<boolean> {
const token = await ensureFreshToken()
if (!token) return false
const body = await buildScrobbleBody(item, 0)
if (!body) return false
const payload = item.Type === 'Movie' ? { movies: [{ ids: (body as any).movie.ids }] } : { shows: [{ ids: (body as any).show.ids }] }
try {
const res = await fetch(`${BASE}/sync/watchlist/remove`, {
method: 'POST',
headers: authHeaders(token),
body: JSON.stringify(payload),
})
return res.ok
} catch {
return false
}
}
+153
View File
@@ -0,0 +1,153 @@
import type { BaseItemDto } from '../api/types'
/**
* Helpers that crunch the user's played-items list into the
* personalisation stats surfaced on the /profile page (streaks, genre
* breakdown, year-in-review).
*
* All inputs assume Jellyfin items with `UserData.LastPlayedDate` and
* `RunTimeTicks` fields populated.
*/
const TICKS_PER_HOUR = 36_000_000_000
export interface GenreShare {
genre: string
count: number
/** Fractional share of the total (0..1). */
share: number
}
/**
* Tally each genre's share of the played list. Items with multiple
* genres count once per genre; the resulting shares can sum past 1.
*/
export function genreBreakdown(items: BaseItemDto[]): GenreShare[] {
const counts = new Map<string, number>()
let total = 0
for (const it of items) {
for (const g of it.Genres || []) {
counts.set(g, (counts.get(g) || 0) + 1)
total++
}
}
if (total === 0) return []
return [...counts.entries()]
.map(([genre, count]) => ({ genre, count, share: count / total }))
.sort((a, b) => b.count - a.count)
}
/**
* Detect the user's current consecutive-days watch streak. A streak
* counts every calendar day on which at least one item carries a
* LastPlayedDate inside it. Today and yesterday both keep the streak
* alive (so a user who hasn't yet watched today still sees a streak).
*/
export function watchStreak(items: BaseItemDto[]): {
current: number
longest: number
lastWatchDate: string | null
} {
const dayKeys = new Set<string>()
let lastWatchDate: string | null = null
for (const it of items) {
const raw = it.UserData?.LastPlayedDate
if (!raw) continue
const d = new Date(raw)
if (isNaN(d.getTime())) continue
if (!lastWatchDate || raw > lastWatchDate) lastWatchDate = raw
dayKeys.add(dayKey(d))
}
if (dayKeys.size === 0) {
return { current: 0, longest: 0, lastWatchDate: null }
}
// Compute longest run by walking sorted unique days.
const sorted = [...dayKeys].sort()
let longest = 1
let run = 1
for (let i = 1; i < sorted.length; i++) {
if (consecutiveDay(sorted[i - 1], sorted[i])) {
run++
if (run > longest) longest = run
} else {
run = 1
}
}
// Compute current run starting from the most recent watched day,
// counting back. If the last watch was more than 1 day ago we treat
// the streak as broken.
const now = new Date()
const todayKey = dayKey(now)
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
const yesterdayKey = dayKey(yesterday)
let current = 0
if (dayKeys.has(todayKey) || dayKeys.has(yesterdayKey)) {
let cursor = dayKeys.has(todayKey) ? todayKey : yesterdayKey
while (dayKeys.has(cursor)) {
current++
const prev = stepBack(cursor)
cursor = prev
}
}
return { current, longest, lastWatchDate }
}
/**
* Sum total hours watched across the input list. Uses RunTimeTicks
* (full duration) per played item; this overstates a little when the
* user only made it 60% through but is the only signal we have without
* per-session playback history.
*/
export function totalHoursWatched(items: BaseItemDto[]): number {
let ticks = 0
for (const it of items) {
ticks += Number(it.RunTimeTicks ?? 0)
}
return ticks / TICKS_PER_HOUR
}
/**
* Longest binge - the maximum number of items with LastPlayedDate
* timestamps falling within a single calendar day.
*/
export function longestBinge(items: BaseItemDto[]): { count: number; day: string | null } {
const counts = new Map<string, number>()
for (const it of items) {
const raw = it.UserData?.LastPlayedDate
if (!raw) continue
const d = new Date(raw)
if (isNaN(d.getTime())) continue
const k = dayKey(d)
counts.set(k, (counts.get(k) || 0) + 1)
}
let max = 0
let day: string | null = null
for (const [k, n] of counts) {
if (n > max) {
max = n
day = k
}
}
return { count: max, day }
}
export function dayKey(d: Date): string {
const yyyy = d.getFullYear()
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
}
function consecutiveDay(prev: string, next: string): boolean {
const pd = new Date(prev + 'T00:00:00')
const nd = new Date(next + 'T00:00:00')
const diff = nd.getTime() - pd.getTime()
return diff === 86_400_000
}
function stepBack(key: string): string {
const d = new Date(key + 'T00:00:00')
d.setDate(d.getDate() - 1)
return dayKey(d)
}