Compare commits

..

2 Commits

Author SHA1 Message Date
14e9d7e03a E frontend reconciliation (2/2): align consumers to generated bindings; svelte-check clean
Some checks failed
Traceability Validation / Check Requirement Traces (push) Failing after 21s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 13m21s
🏗️ Build and Test JellyTau / Build Android APK (push) Failing after 2m14s
Reconcile the ~50 frontend files that consumed the old hand-written types against
the stricter generated bindings (141 -> 0 svelte-check errors):

- Nullable strictness: null-coalesce/guard at consumer sites; widen shared helpers
  (CachedImage.tag, ticksToSeconds, formatDuration, getSessionIcon, session store
  send*/transferToRemote) to accept null.
- Field-name fixes exposed by codegen: userData.played -> isPlayed; remote
  NowPlayingItem uses album (not albumName); ArtistItem id/name.
- player.ts: normalize remote NowPlayingItem -> MediaItem in mergedMedia.
- ArtistItem now serializes camelCase (PascalCase alias retained for Jellyfin
  deserialize); update its serialize test.
- Update downloads.test.ts to the bundled { request } shape; complete the
  sessions.test.ts NowPlayingItem mock.

Frontend: svelte-check 0 errors, vitest 448 passing. Backend: 387 passing.
2026-06-20 20:49:20 +02:00
76c78e2edc E frontend reconciliation (1/2): types.ts sources from bindings; backend field fixes
- types.ts now re-exports wire types from generated bindings (single source of
  truth); keeps frontend-only unions (ItemType/LibraryType/PersonType/
  SessionCommand) and aliases Session = SessionInfo.
- Backend: MediaItem.runtime_ticks serializes as runTimeTicks (matches frontend,
  fixes a latent undefined-read bug); ArtistItem serializes camelCase id/name
  (PascalCase aliases retained for Jellyfin deserialize).
- player.ts: normalize remote NowPlayingItem -> MediaItem in mergedMedia so
  display components treat local/remote items uniformly.
- Reduces svelte-check errors 141 -> 70 (remaining: nullable guards + played->isPlayed).
2026-06-20 20:30:08 +02:00
24 changed files with 166 additions and 312 deletions

View File

@ -60,9 +60,11 @@ pub struct UserData {
/// Artist item with ID and name (for clickable artist links) /// Artist item with ID and name (for clickable artist links)
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")] #[serde(rename_all = "camelCase")]
pub struct ArtistItem { pub struct ArtistItem {
#[serde(alias = "Id")]
pub id: String, pub id: String,
#[serde(alias = "Name")]
pub name: String, pub name: String,
} }
@ -118,6 +120,7 @@ pub struct MediaItem {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub official_rating: Option<String>, pub official_rating: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "runTimeTicks")]
pub runtime_ticks: Option<i64>, pub runtime_ticks: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub primary_image_tag: Option<String>, pub primary_image_tag: Option<String>,
@ -402,15 +405,21 @@ mod tests {
#[test] #[test]
fn test_artist_item_serialize() { fn test_artist_item_serialize() {
// Test that ArtistItem serializes to PascalCase for consistency // ArtistItem serializes to camelCase for the frontend; it still accepts
// Jellyfin's PascalCase on deserialize via #[serde(alias = ...)].
let artist = ArtistItem { let artist = ArtistItem {
id: "test-id".to_string(), id: "test-id".to_string(),
name: "Test Artist".to_string(), name: "Test Artist".to_string(),
}; };
let json = serde_json::to_string(&artist).expect("Failed to serialize"); let json = serde_json::to_string(&artist).expect("Failed to serialize");
assert!(json.contains(r#""Id":"test-id""#)); assert!(json.contains(r#""id":"test-id""#));
assert!(json.contains(r#""Name":"Test Artist""#)); assert!(json.contains(r#""name":"Test Artist""#));
let from_pascal: ArtistItem =
serde_json::from_str(r#"{"Id":"x","Name":"Y"}"#).expect("Failed to deserialize");
assert_eq!(from_pascal.id, "x");
assert_eq!(from_pascal.name, "Y");
} }
#[test] #[test]

View File

@ -1271,7 +1271,7 @@ export type AlbumStorageInfo = { album_id: string; album_name: string; artist_na
/** /**
* Artist item with ID and name (for clickable artist links) * Artist item with ID and name (for clickable artist links)
*/ */
export type ArtistItem = { Id: string; Name: string } export type ArtistItem = { id: string; name: string }
/** /**
* Audio playback settings * Audio playback settings
*/ */
@ -1452,7 +1452,7 @@ export type Library = { id: string; name: string; collectionType: string; imageT
/** /**
* Media item * Media item
*/ */
export type MediaItem = { id: string; name: string; type: string; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runtimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null } export type MediaItem = { id: string; name: string; type: string; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }
/** /**
* Media session type tracking the high-level playback context * Media session type tracking the high-level playback context
*/ */
@ -1764,7 +1764,7 @@ export type PlaylistEntry =
/** /**
* The underlying media item * The underlying media item
*/ */
({ id: string; name: string; type: string; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runtimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & { ({ id: string; name: string; type: string; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & {
/** /**
* The playlist-scoped entry ID (Jellyfin's PlaylistItemId) * The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
*/ */

View File

@ -1,76 +1,41 @@
// Jellyfin API Types // Frontend type layer.
//
// Wire types are the single source of truth, generated from Rust by tauri-specta
// in ./bindings.ts. This module re-exports them under the names the frontend uses
// and adds frontend-only unions/helpers that have no backend equivalent.
export interface ServerInfo { export type {
id: string; AuthResult,
name: string; Genre,
url: string; GetItemsOptions,
} ImageOptions,
ImageType,
Library,
MediaItem,
MediaSource,
MediaStream,
Person,
PlaybackInfo,
PlaylistCreatedResult,
PlaylistEntry,
PlayState,
SearchOptions,
SearchResult,
ServerInfo,
User,
UserData,
} from "./bindings";
export interface User { import type { SessionInfo } from "./bindings";
id: string;
name: string;
serverId: string;
primaryImageTag?: string;
}
export interface AuthResult { // The frontend's "Session" is the backend remote-control session (SessionInfo).
user: User; // (Note: bindings also has a `Session` type — that's the auth/login session, a
accessToken: string; // different concept — so we alias explicitly here rather than re-export it.)
serverId: string; export type Session = SessionInfo;
}
export interface Library { // ---------------------------------------------------------------------------
id: string; // Frontend-only types (not part of the Rust command surface)
name: string; // ---------------------------------------------------------------------------
collectionType: LibraryType;
imageTag?: string;
}
export type LibraryType = "movies" | "tvshows" | "music" | "books" | "photos" | "homevideos" | "boxsets" | "playlists" | "channels" | "unknown";
export interface MediaItem {
id: string;
name: string;
type: ItemType;
serverId: string;
// Common fields
overview?: string;
productionYear?: number;
communityRating?: number;
officialRating?: string;
runTimeTicks?: number;
// Images
primaryImageTag?: string;
backdropImageTags?: string[];
parentBackdropImageTags?: string[];
// For audio
albumId?: string;
albumName?: string;
artists?: string[];
artistItems?: { id: string; name: string }[];
indexNumber?: number; // Track number
parentIndexNumber?: number; // Disc number
// For video
seriesId?: string;
seriesName?: string;
seasonId?: string;
seasonName?: string;
// Playback
userData?: UserData;
mediaStreams?: MediaStream[];
mediaSources?: MediaSource[];
// Cast & Crew
people?: Person[];
// Genres
genres?: string[];
}
export type ItemType = export type ItemType =
| "Movie" | "Movie"
@ -87,7 +52,18 @@ export type ItemType =
| "ChannelFolderItem" | "ChannelFolderItem"
| "Person"; | "Person";
// Person/Cast types export type LibraryType =
| "movies"
| "tvshows"
| "music"
| "books"
| "photos"
| "homevideos"
| "boxsets"
| "playlists"
| "channels"
| "unknown";
export type PersonType = export type PersonType =
| "Actor" | "Actor"
| "Director" | "Director"
@ -99,81 +75,6 @@ export type PersonType =
| "Conductor" | "Conductor"
| "Lyricist"; | "Lyricist";
export interface Person {
id: string;
name: string;
role?: string; // Character name for actors
type: PersonType;
primaryImageTag?: string;
}
export interface UserData {
playbackPositionTicks: number;
playCount: number;
isFavorite: boolean;
played: boolean;
lastPlayedDate?: string;
}
export interface MediaStream {
type: "Video" | "Audio" | "Subtitle";
codec?: string;
language?: string;
displayTitle?: string;
index: number;
isDefault: boolean;
isForced: boolean;
}
export interface MediaSource {
id: string;
name: string;
container?: string;
size?: number;
bitrate?: number;
supportsDirectPlay: boolean;
supportsDirectStream: boolean;
supportsTranscoding: boolean;
directStreamUrl?: string;
}
export interface PlaybackInfo {
mediaSourceId: string;
playSessionId: string;
streamUrl: string;
directPlay: boolean;
/** True if content requires transcoding (HEVC, 10-bit) - seeking requires server-side StartTimeTicks */
needsTranscoding: boolean;
}
// Remote session control types
export interface Session {
id: string;
userId: string;
userName: string;
client: string;
deviceName: string;
deviceId: string;
applicationVersion: string;
isActive: boolean;
supportsMediaControl: boolean;
supportsRemoteControl: boolean;
playState: PlayState | null;
nowPlayingItem: MediaItem | null;
playableMediaTypes: string[];
supportedCommands: string[];
}
export interface PlayState {
positionTicks: number;
canSeek: boolean;
isPaused: boolean;
isMuted: boolean;
volumeLevel: number;
repeatMode: string;
shuffleMode: string;
}
export type SessionCommand = export type SessionCommand =
| "PlayPause" | "PlayPause"
| "Stop" | "Stop"
@ -183,94 +84,3 @@ export type SessionCommand =
| "PreviousTrack" | "PreviousTrack"
| "Mute" | "Mute"
| "Unmute"; | "Unmute";
export interface SearchResult {
items: MediaItem[];
totalRecordCount: number;
}
// Repository pattern for offline support
export interface MediaRepository {
// Libraries
getLibraries(): Promise<Library[]>;
// Items
getItems(parentId: string, options?: GetItemsOptions): Promise<SearchResult>;
getItem(itemId: string): Promise<MediaItem>;
getLatestItems(parentId: string, limit?: number): Promise<MediaItem[]>;
// Home screen
getResumeItems(parentId?: string, limit?: number): Promise<MediaItem[]>;
getNextUpEpisodes(seriesId?: string, limit?: number): Promise<MediaItem[]>;
getRecentlyPlayedAudio(limit?: number): Promise<MediaItem[]>;
getResumeMovies(limit?: number): Promise<MediaItem[]>;
// Genres
getGenres(parentId?: string): Promise<Genre[]>;
// Search
search(query: string, options?: SearchOptions): Promise<SearchResult>;
// Playback
getPlaybackInfo(itemId: string): Promise<PlaybackInfo>;
reportPlaybackStart(itemId: string, positionTicks: number): Promise<void>;
reportPlaybackProgress(itemId: string, positionTicks: number): Promise<void>;
reportPlaybackStopped(itemId: string, positionTicks: number): Promise<void>;
// Images
getImageUrl(itemId: string, imageType: ImageType, options?: ImageOptions): string;
// Subtitles
getSubtitleUrl(itemId: string, mediaSourceId: string, streamIndex: number, format?: string): string;
// Favorites
markFavorite(itemId: string): Promise<void>;
unmarkFavorite(itemId: string): Promise<void>;
// People/Cast
getPerson(personId: string): Promise<MediaItem>;
getItemsByPerson(personId: string, options?: GetItemsOptions): Promise<SearchResult>;
// Related Content
getSimilarItems(itemId: string, limit?: number): Promise<SearchResult>;
}
export interface Genre {
id: string;
name: string;
}
// Playlist types
export interface PlaylistEntry extends MediaItem {
playlistItemId: string;
}
export interface PlaylistCreatedResult {
id: string;
}
export interface GetItemsOptions {
startIndex?: number;
limit?: number;
sortBy?: string;
sortOrder?: "Ascending" | "Descending";
includeItemTypes?: ItemType[];
recursive?: boolean;
fields?: string[];
genres?: string[];
}
export interface SearchOptions {
limit?: number;
includeItemTypes?: ItemType[];
searchTerm?: string;
}
export type ImageType = "Primary" | "Backdrop" | "Banner" | "Thumb" | "Logo";
export interface ImageOptions {
maxWidth?: number;
maxHeight?: number;
quality?: number;
tag?: string;
}

View File

@ -6,7 +6,7 @@
interface Props { interface Props {
itemId: string; itemId: string;
imageType?: string; imageType?: string;
tag?: string; tag?: string | null;
maxWidth?: number; maxWidth?: number;
maxHeight?: number; maxHeight?: number;
class?: string; class?: string;

View File

@ -28,6 +28,7 @@
continue; continue;
} }
const type = person.type; const type = person.type;
if (!type) continue;
if (type in groups) { if (type in groups) {
groups[type].push(person); groups[type].push(person);
} else { } else {
@ -84,7 +85,7 @@
<!-- Person image --> <!-- Person image -->
<div class="w-24 h-24 rounded-full overflow-hidden bg-[var(--color-surface)] mb-2"> <div class="w-24 h-24 rounded-full overflow-hidden bg-[var(--color-surface)] mb-2">
<CachedImage <CachedImage
itemId={person.id} itemId={person.id ?? ""}
imageType="Primary" imageType="Primary"
tag={person.primaryImageTag} tag={person.primaryImageTag}
maxWidth={200} maxWidth={200}

View File

@ -55,7 +55,7 @@
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#each filteredPeople as person, index (person.id)} {#each filteredPeople as person, index (person.id)}
<button <button
onclick={(e) => handlePersonClick(person.id, e)} onclick={(e) => handlePersonClick(person.id ?? "", e)}
class="text-[var(--color-jellyfin)] hover:underline" class="text-[var(--color-jellyfin)] hover:underline"
> >
{person.name} {person.name}

View File

@ -84,7 +84,7 @@
return null; return null;
}); });
function formatDuration(ticks?: number): string { function formatDuration(ticks?: number | null): string {
if (!ticks) return ""; if (!ticks) return "";
const seconds = Math.floor(ticks / 10000000); const seconds = Math.floor(ticks / 10000000);
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
@ -100,7 +100,7 @@
if (!ep.userData || !ep.runTimeTicks) { if (!ep.userData || !ep.runTimeTicks) {
return 0; return 0;
} }
return (ep.userData.playbackPositionTicks / ep.runTimeTicks) * 100; return ((ep.userData.playbackPositionTicks ?? 0) / ep.runTimeTicks) * 100;
} }
function handlePlay() { function handlePlay() {
@ -178,7 +178,7 @@
{episode.communityRating.toFixed(1)} {episode.communityRating.toFixed(1)}
</span> </span>
{/if} {/if}
{#if episode.userData?.played} {#if episode.userData?.isPlayed}
<span class="flex items-center gap-1 text-[var(--color-jellyfin)]"> <span class="flex items-center gap-1 text-[var(--color-jellyfin)]">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
@ -281,7 +281,7 @@
{/if} {/if}
<!-- Played indicator --> <!-- Played indicator -->
{#if ep.userData?.played} {#if ep.userData?.isPlayed}
<div class="absolute top-2 right-2"> <div class="absolute top-2 right-2">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>

View File

@ -40,7 +40,7 @@
if (!episode.userData || !episode.runTimeTicks) { if (!episode.userData || !episode.runTimeTicks) {
return 0; return 0;
} }
return (episode.userData.playbackPositionTicks / episode.runTimeTicks) * 100; return ((episode.userData.playbackPositionTicks ?? 0) / episode.runTimeTicks) * 100;
}); });
const duration = $derived(formatDuration(episode.runTimeTicks)); const duration = $derived(formatDuration(episode.runTimeTicks));
@ -137,7 +137,7 @@
{episode.name} {episode.name}
</h3> </h3>
<!-- Played indicator --> <!-- Played indicator -->
{#if episode.userData?.played} {#if episode.userData?.isPlayed}
<svg class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg> </svg>
@ -164,10 +164,10 @@
<VideoDownloadButton <VideoDownloadButton
itemId={episode.id} itemId={episode.id}
itemName={episode.name} itemName={episode.name}
seriesName={episode.seriesName} seriesName={episode.seriesName ?? undefined}
seasonName={episode.seasonName} seasonName={episode.seasonName ?? undefined}
episodeNumber={episode.indexNumber} episodeNumber={episode.indexNumber ?? undefined}
seasonNumber={episode.parentIndexNumber} seasonNumber={episode.parentIndexNumber ?? undefined}
size="sm" size="sm"
/> />
</div> </div>

View File

@ -18,7 +18,7 @@
} }
function getImageTag(item: MediaItem | Library): string | undefined { function getImageTag(item: MediaItem | Library): string | undefined {
return "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined); return "primaryImageTag" in item ? (item.primaryImageTag ?? undefined) : ("imageTag" in item ? (item.imageTag ?? undefined) : undefined);
} }
function getSubtitle(item: MediaItem | Library): string { function getSubtitle(item: MediaItem | Library): string {
@ -44,7 +44,7 @@
if (!showProgress || !("userData" in item) || !item.userData || !("runTimeTicks" in item) || !item.runTimeTicks) { if (!showProgress || !("userData" in item) || !item.userData || !("runTimeTicks" in item) || !item.runTimeTicks) {
return 0; return 0;
} }
return (item.userData.playbackPositionTicks / item.runTimeTicks) * 100; return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100;
} }
function getTrackNumber(item: MediaItem | Library): string { function getTrackNumber(item: MediaItem | Library): string {
@ -61,7 +61,7 @@
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""} {@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
{@const progress = getProgress(item)} {@const progress = getProgress(item)}
{@const trackNum = getTrackNumber(item)} {@const trackNum = getTrackNumber(item)}
{@const isPlayed = "userData" in item && item.userData?.played} {@const isPlayed = "userData" in item && item.userData?.isPlayed}
{@const downloadInfo = getDownloadInfo(item.id)} {@const downloadInfo = getDownloadInfo(item.id)}
{@const isDownloaded = downloadInfo?.status === "completed"} {@const isDownloaded = downloadInfo?.status === "completed"}
{@const isDownloading = downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"} {@const isDownloading = downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"}

View File

@ -52,7 +52,7 @@
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) { if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) {
return 0; return 0;
} }
return (item.userData.playbackPositionTicks / item.runTimeTicks) * 100; return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100;
}); });
const subtitle = $derived(() => { const subtitle = $derived(() => {
@ -112,7 +112,7 @@
{/if} {/if}
<!-- Played indicator --> <!-- Played indicator -->
{#if "userData" in item && item.userData?.played} {#if "userData" in item && item.userData?.isPlayed}
<div class="absolute top-2 right-2"> <div class="absolute top-2 right-2">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>

View File

@ -145,7 +145,7 @@
goto(`/library/${artistId}`); goto(`/library/${artistId}`);
} }
function handleAlbumClick(albumId: string | undefined, e: Event) { function handleAlbumClick(albumId: string | null | undefined, e: Event) {
if (!albumId) return; if (!albumId) return;
e.stopPropagation(); e.stopPropagation();
goto(`/library/${albumId}`); goto(`/library/${albumId}`);

View File

@ -35,7 +35,7 @@
let dragDisabled = $state(true); let dragDisabled = $state(true);
const flipDurationMs = 200; const flipDurationMs = 200;
function formatDuration(ticks?: number): string { function formatDuration(ticks?: number | null): string {
if (!ticks) return ""; if (!ticks) return "";
const seconds = Math.floor(ticks / 10000000); const seconds = Math.floor(ticks / 10000000);
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);

View File

@ -15,8 +15,8 @@
const nowPlaying = $derived(session.nowPlayingItem); const nowPlaying = $derived(session.nowPlayingItem);
const supportsSeek = $derived(playState?.canSeek ?? false); const supportsSeek = $derived(playState?.canSeek ?? false);
const supportsNextPrevious = $derived( const supportsNextPrevious = $derived(
session.supportedCommands.includes("NextTrack") && (session.supportedCommands ?? []).includes("NextTrack") &&
session.supportedCommands.includes("PreviousTrack") (session.supportedCommands ?? []).includes("PreviousTrack")
); );
async function handlePlayPause() { async function handlePlayPause() {
@ -107,8 +107,8 @@
<h4 class="text-base font-medium text-white truncate">{nowPlaying.name}</h4> <h4 class="text-base font-medium text-white truncate">{nowPlaying.name}</h4>
{#if nowPlaying.artists && nowPlaying.artists.length > 0} {#if nowPlaying.artists && nowPlaying.artists.length > 0}
<p class="text-sm text-gray-400 truncate">{nowPlaying.artists.join(", ")}</p> <p class="text-sm text-gray-400 truncate">{nowPlaying.artists.join(", ")}</p>
{:else if nowPlaying.albumName} {:else if nowPlaying?.album}
<p class="text-sm text-gray-400 truncate">{nowPlaying.albumName}</p> <p class="text-sm text-gray-400 truncate">{nowPlaying?.album}</p>
{/if} {/if}
</div> </div>
@ -196,9 +196,9 @@
{#if playState.volumeLevel !== undefined} {#if playState.volumeLevel !== undefined}
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<svg class="w-5 h-5 text-gray-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 text-gray-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
{#if playState.isMuted || playState.volumeLevel === 0} {#if playState.isMuted || (playState.volumeLevel ?? 0) === 0}
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" /> <path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
{:else if playState.volumeLevel < 50} {:else if (playState.volumeLevel ?? 0) < 50}
<path d="M7 9v6h4l5 5V4l-5 5H7z" /> <path d="M7 9v6h4l5 5V4l-5 5H7z" />
{:else} {:else}
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z" /> <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z" />

View File

@ -51,11 +51,11 @@
<div class="flex items-center gap-3 mt-3 pt-3 border-t border-white/10"> <div class="flex items-center gap-3 mt-3 pt-3 border-t border-white/10">
<div class="w-12 h-12 rounded overflow-hidden flex-shrink-0"> <div class="w-12 h-12 rounded overflow-hidden flex-shrink-0">
<CachedImage <CachedImage
itemId={nowPlaying.id} itemId={nowPlaying.id ?? ""}
imageType="Primary" imageType="Primary"
tag={nowPlaying.primaryImageTag} tag={nowPlaying.primaryImageTag}
maxWidth={80} maxWidth={80}
alt={nowPlaying.name} alt={nowPlaying.name ?? undefined}
class="w-full h-full object-cover" class="w-full h-full object-cover"
/> />
</div> </div>
@ -64,8 +64,8 @@
<p class="text-sm font-medium text-white truncate">{nowPlaying.name}</p> <p class="text-sm font-medium text-white truncate">{nowPlaying.name}</p>
{#if nowPlaying.artists && nowPlaying.artists.length > 0} {#if nowPlaying.artists && nowPlaying.artists.length > 0}
<p class="text-xs text-gray-400 truncate">{nowPlaying.artists.join(", ")}</p> <p class="text-xs text-gray-400 truncate">{nowPlaying.artists.join(", ")}</p>
{:else if nowPlaying.albumName} {:else if nowPlaying.album}
<p class="text-xs text-gray-400 truncate">{nowPlaying.albumName}</p> <p class="text-xs text-gray-400 truncate">{nowPlaying.album}</p>
{/if} {/if}
<div class="flex items-center gap-2 mt-1"> <div class="flex items-center gap-2 mt-1">

View File

@ -63,8 +63,8 @@
} }
} }
function getSessionIcon(client: string): string { function getSessionIcon(client: string | null | undefined): string {
const clientLower = client.toLowerCase(); const clientLower = (client ?? "").toLowerCase();
if (clientLower.includes("tv") || clientLower.includes("roku") || clientLower.includes("android tv")) { if (clientLower.includes("tv") || clientLower.includes("roku") || clientLower.includes("android tv")) {
return "tv"; return "tv";
} else if (clientLower.includes("web") || clientLower.includes("chrome") || clientLower.includes("firefox")) { } else if (clientLower.includes("web") || clientLower.includes("chrome") || clientLower.includes("firefox")) {

View File

@ -68,7 +68,7 @@
<SessionCard <SessionCard
{session} {session}
selected={$sessions.selectedSessionId === session.id} selected={$sessions.selectedSessionId === session.id}
onclick={() => handleSessionClick(session.id)} onclick={() => handleSessionClick(session.id ?? "")}
/> />
{/each} {/each}
</div> </div>

View File

@ -80,14 +80,17 @@ describe("downloads store", () => {
); );
expect(mockInvoke).toHaveBeenCalledWith("download_item", { expect(mockInvoke).toHaveBeenCalledWith("download_item", {
request: {
itemId: "item-1", itemId: "item-1",
userId: "user-1", userId: "user-1",
filePath: "/path/to/file.mp3", filePath: "/path/to/file.mp3",
mimeType: "audio/mpeg", mimeType: "audio/mpeg",
priority: 10, priority: 10,
itemName: undefined, itemName: null,
artistName: undefined, artistName: null,
albumName: undefined, albumName: null,
expectedSize: null,
},
}); });
expect(downloadId).toBe(123); expect(downloadId).toBe(123);
}); });

View File

@ -73,7 +73,7 @@ function createPlaybackModeStore() {
* - Polls remote session until track loads * - Polls remote session until track loads
* - Stops local playback * - Stops local playback
*/ */
async function transferToRemote(sessionId: string): Promise<void> { async function transferToRemote(sessionId: string | null | undefined): Promise<void> {
console.log("[PlaybackMode] Transferring to remote session:", sessionId); console.log("[PlaybackMode] Transferring to remote session:", sessionId);
update((s) => ({ ...s, isTransferring: true, transferError: null })); update((s) => ({ ...s, isTransferring: true, transferError: null }));
@ -100,11 +100,11 @@ function createPlaybackModeStore() {
} }
// Update local state // Update local state
sessions.selectSession(sessionId); sessions.selectSession(sessionId ?? null);
update((s) => ({ update((s) => ({
...s, ...s,
mode: "remote", mode: "remote",
remoteSessionId: sessionId, remoteSessionId: sessionId ?? null,
isTransferring: false, isTransferring: false,
})); }));

View File

@ -9,7 +9,8 @@
*/ */
import { writable, derived } from "svelte/store"; import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem, ItemType } from "$lib/api/types";
import type { NowPlayingItem } from "$lib/api/bindings";
import { isRemoteMode } from "./playbackMode"; import { isRemoteMode } from "./playbackMode";
import { selectedSession } from "./sessions"; import { selectedSession } from "./sessions";
import { ticksToSeconds } from "$lib/utils/playbackUnits"; import { ticksToSeconds } from "$lib/utils/playbackUnits";
@ -161,13 +162,36 @@ export const isMuted = derived(player, ($p) => $p.muted);
/** /**
* Merged media item - prefers remote session when in remote mode * Merged media item - prefers remote session when in remote mode
*/ */
export const mergedMedia = derived( /**
* Normalize a remote session's NowPlayingItem into the MediaItem shape the UI
* renders, so display components can treat local and remote items uniformly.
* (NowPlayingItem has `album`/`artists` but no `albumName`/`artistItems`; the UI
* falls back to the `artists` string list when `artistItems` is absent.)
*/
function nowPlayingToMediaItem(npi: NowPlayingItem): MediaItem {
return {
id: npi.id ?? "",
name: npi.name ?? "",
type: (npi.Type ?? "Audio") as ItemType,
serverId: "",
albumName: npi.album,
albumId: npi.albumId,
artists: npi.artists,
primaryImageTag: npi.primaryImageTag ?? npi.albumPrimaryImageTag,
runTimeTicks: npi.runTimeTicks,
} as MediaItem;
}
export const mergedMedia = derived<
[typeof isRemoteMode, typeof selectedSession, typeof currentMedia],
MediaItem | null
>(
[isRemoteMode, selectedSession, currentMedia], [isRemoteMode, selectedSession, currentMedia],
([$isRemote, $session, $local]) => { ([$isRemote, $session, $local]) => {
if ($isRemote && $session?.nowPlayingItem) { if ($isRemote && $session?.nowPlayingItem) {
return $session.nowPlayingItem; return nowPlayingToMediaItem($session.nowPlayingItem);
} }
return $local; return $local ?? null;
} }
); );

View File

@ -40,8 +40,15 @@ describe("sessions store", () => {
nowPlayingItem: { nowPlayingItem: {
id: "item-1", id: "item-1",
name: "Test Song", name: "Test Song",
type: "Audio", Type: "Audio",
serverId: "server-1", runTimeTicks: null,
album: null,
albumId: null,
albumArtist: null,
artists: null,
imageTags: null,
primaryImageTag: null,
albumPrimaryImageTag: null,
}, },
playableMediaTypes: ["Audio", "Video"], playableMediaTypes: ["Audio", "Video"],
supportedCommands: ["PlayPause", "Stop", "Seek", "NextTrack", "PreviousTrack"], supportedCommands: ["PlayPause", "Stop", "Seek", "NextTrack", "PreviousTrack"],

View File

@ -89,7 +89,7 @@ function createSessionsStore() {
/** /**
* Send play/pause toggle command * Send play/pause toggle command
*/ */
async function sendPlayPause(sessionId: string): Promise<void> { async function sendPlayPause(sessionId: string | null | undefined): Promise<void> {
try { try {
await invoke("remote_send_command", { await invoke("remote_send_command", {
sessionId, sessionId,
@ -106,7 +106,7 @@ function createSessionsStore() {
/** /**
* Send stop command * Send stop command
*/ */
async function sendStop(sessionId: string): Promise<void> { async function sendStop(sessionId: string | null | undefined): Promise<void> {
try { try {
await invoke("remote_send_command", { await invoke("remote_send_command", {
sessionId, sessionId,
@ -122,7 +122,7 @@ function createSessionsStore() {
/** /**
* Send next track command * Send next track command
*/ */
async function sendNext(sessionId: string): Promise<void> { async function sendNext(sessionId: string | null | undefined): Promise<void> {
try { try {
await invoke("remote_send_command", { await invoke("remote_send_command", {
sessionId, sessionId,
@ -138,7 +138,7 @@ function createSessionsStore() {
/** /**
* Send previous track command * Send previous track command
*/ */
async function sendPrevious(sessionId: string): Promise<void> { async function sendPrevious(sessionId: string | null | undefined): Promise<void> {
try { try {
await invoke("remote_send_command", { await invoke("remote_send_command", {
sessionId, sessionId,
@ -154,7 +154,7 @@ function createSessionsStore() {
/** /**
* Seek to position (in ticks) * Seek to position (in ticks)
*/ */
async function sendSeek(sessionId: string, positionTicks: number): Promise<void> { async function sendSeek(sessionId: string | null | undefined, positionTicks: number): Promise<void> {
try { try {
await invoke("remote_session_seek", { await invoke("remote_session_seek", {
sessionId, sessionId,
@ -170,7 +170,7 @@ function createSessionsStore() {
/** /**
* Set volume (0-100) * Set volume (0-100)
*/ */
async function sendVolume(sessionId: string, volume: number): Promise<void> { async function sendVolume(sessionId: string | null | undefined, volume: number): Promise<void> {
try { try {
await invoke("remote_session_set_volume", { await invoke("remote_session_set_volume", {
sessionId, sessionId,
@ -186,7 +186,7 @@ function createSessionsStore() {
/** /**
* Toggle mute * Toggle mute
*/ */
async function sendToggleMute(sessionId: string): Promise<void> { async function sendToggleMute(sessionId: string | null | undefined): Promise<void> {
try { try {
await invoke("remote_send_command", { await invoke("remote_send_command", {
sessionId, sessionId,
@ -203,7 +203,7 @@ function createSessionsStore() {
* Play item(s) on remote session * Play item(s) on remote session
*/ */
async function playOnSession( async function playOnSession(
sessionId: string, sessionId: string | null | undefined,
itemIds: string[], itemIds: string[],
startIndex = 0 startIndex = 0
): Promise<void> { ): Promise<void> {

View File

@ -10,7 +10,7 @@
* @param format Format type: "mm:ss" (default) or "hh:mm:ss" * @param format Format type: "mm:ss" (default) or "hh:mm:ss"
* @returns Formatted duration string or empty string if no ticks * @returns Formatted duration string or empty string if no ticks
*/ */
export function formatDuration(ticks?: number, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string { export function formatDuration(ticks?: number | null, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string {
if (!ticks) return ""; if (!ticks) return "";
// Jellyfin uses 10,000,000 ticks per second // Jellyfin uses 10,000,000 ticks per second

View File

@ -25,8 +25,8 @@ export function secondsToTicks(seconds: number): number {
* @param ticks - Time in Jellyfin ticks * @param ticks - Time in Jellyfin ticks
* @returns Time in seconds * @returns Time in seconds
*/ */
export function ticksToSeconds(ticks: number): number { export function ticksToSeconds(ticks: number | null | undefined): number {
return ticks / TICKS_PER_SECOND; return (ticks ?? 0) / TICKS_PER_SECOND;
} }
/** /**

View File

@ -456,7 +456,7 @@
<div class="space-y-2"> <div class="space-y-2">
{#if item.people.some(p => p.type === "Director")} {#if item.people.some(p => p.type === "Director")}
<CrewLinks <CrewLinks
people={item.people} people={item.people ?? undefined}
roleFilter={["Director"]} roleFilter={["Director"]}
label="Directed by" label="Directed by"
maxShow={3} maxShow={3}
@ -465,7 +465,7 @@
{#if item.people.some(p => p.type === "Writer")} {#if item.people.some(p => p.type === "Writer")}
<CrewLinks <CrewLinks
people={item.people} people={item.people ?? undefined}
roleFilter={["Writer"]} roleFilter={["Writer"]}
label="Written by" label="Written by"
maxShow={3} maxShow={3}
@ -474,7 +474,7 @@
{#if item.people.some(p => p.type === "Composer")} {#if item.people.some(p => p.type === "Composer")}
<CrewLinks <CrewLinks
people={item.people} people={item.people ?? undefined}
roleFilter={["Composer"]} roleFilter={["Composer"]}
label="Music by" label="Music by"
maxShow={2} maxShow={2}
@ -486,13 +486,13 @@
<!-- Genre Tags --> <!-- Genre Tags -->
{#if item.genres?.length} {#if item.genres?.length}
<div> <div>
<GenreTags genres={item.genres} maxShow={6} /> <GenreTags genres={item.genres ?? undefined} maxShow={6} />
</div> </div>
{/if} {/if}
<!-- Cast Section - for Movies, Series, and Episodes --> <!-- Cast Section - for Movies, Series, and Episodes -->
{#if (item.type === "Movie" || item.type === "Series" || item.type === "Episode") && item.people?.length} {#if (item.type === "Movie" || item.type === "Series" || item.type === "Episode") && item.people?.length}
<CastSection people={item.people} /> <CastSection people={item.people ?? undefined} />
{/if} {/if}
<!-- Related Items Section - for Movies and Series --> <!-- Related Items Section - for Movies and Series -->
@ -500,8 +500,8 @@
<RelatedItemsSection <RelatedItemsSection
currentItemId={item.id} currentItemId={item.id}
itemType={item.type} itemType={item.type}
genres={item.genres} genres={item.genres ?? undefined}
people={item.people} people={item.people ?? undefined}
limit={12} limit={12}
/> />
{/if} {/if}
@ -528,7 +528,7 @@
<RelatedItemsSection <RelatedItemsSection
currentItemId={item.id} currentItemId={item.id}
itemType="MusicAlbum" itemType="MusicAlbum"
genres={item.genres} genres={item.genres ?? undefined}
artistIds={item.artistItems?.map(a => a.id)} artistIds={item.artistItems?.map(a => a.id)}
limit={12} limit={12}
/> />