formatters, device profile, media matching, subtitle utils, syncplay, trakt
This commit is contained in:
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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],
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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%)`
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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 ?? ''
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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' },
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user