feat(library and playback): Support for serverside channel plugins and hls streaming

This commit is contained in:
2026-06-27 17:25:57 +02:00
parent f1d25c4f4d
commit 7d7f27aa10
16 changed files with 495 additions and 61 deletions
+38 -2
View File
@@ -1134,6 +1134,24 @@ async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId:
async repositoryGetAudioStreamUrl(handle: string, itemId: string) : Promise<string> {
return await TAURI_INVOKE("repository_get_audio_stream_url", { handle, itemId });
},
/**
* Get Live TV channels (broadcast / IPTV) for browsing
*/
async repositoryGetLiveTvChannels(handle: string) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_live_tv_channels", { handle });
},
/**
* Get the root list of plugin "Channels"
*/
async repositoryGetChannels(handle: string) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_get_channels", { handle });
},
/**
* Open a live stream for a Live TV channel / live item
*/
async repositoryOpenLiveStream(handle: string, itemId: string) : Promise<LiveStreamInfo> {
return await TAURI_INVOKE("repository_open_live_stream", { handle, itemId });
},
/**
* Report playback start
*/
@@ -1539,6 +1557,14 @@ export type ImageType = "Primary" | "Backdrop" | "Banner" | "Thumb" | "Logo"
* Library (media collection)
*/
export type Library = { id: string; name: string; collectionType: string; imageTag?: string | null }
/**
* Live stream information returned from opening a Live TV / channel stream.
*
* Unlike on-demand video, a live channel must be "opened" before it can be
* streamed; the server returns a transcoding URL (already absolute) plus a
* `live_stream_id` that can later be used to close the stream.
*/
export type LiveStreamInfo = { streamUrl: string; playSessionId: string | null; liveStreamId: string | null; mediaSourceId: string | null }
/**
* An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`.
*
@@ -1549,7 +1575,12 @@ export type LmsSyncGroup = { masterMac: string; masterName?: string; slaveMacs?:
/**
* 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;
/**
* Whether this item is a folder/container (vs a playable leaf). Used to
* decide whether a channel item drills into a list or plays directly.
*/
isFolder?: boolean; 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
*/
@@ -1927,7 +1958,12 @@ export type PlaylistEntry =
/**
* 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;
/**
* Whether this item is a folder/container (vs a playable leaf). Used to
* decide whether a channel item drills into a list or plays directly.
*/
isFolder?: boolean; 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)
*/
+18
View File
@@ -11,6 +11,7 @@ import type {
GetItemsOptions,
SearchOptions,
PlaybackInfo,
LiveStreamInfo,
ImageType,
ImageOptions,
Genre,
@@ -161,6 +162,23 @@ export class RepositoryClient {
);
}
// ===== Live TV / Channels =====
/** Browse Live TV channels (broadcast / IPTV). */
async getLiveTvChannels(): Promise<MediaItem[]> {
return commands.repositoryGetLiveTvChannels(this.ensureHandle());
}
/** Browse the root list of plugin "Channels". Drill-down uses getItems(channelId). */
async getChannels(): Promise<SearchResult> {
return commands.repositoryGetChannels(this.ensureHandle());
}
/** Open a live stream for a Live TV channel / live item before HLS playback. */
async openLiveStream(itemId: string): Promise<LiveStreamInfo> {
return commands.repositoryOpenLiveStream(this.ensureHandle(), itemId);
}
// ===== URL Construction Methods (sync, no server call) =====
/**
+3
View File
@@ -12,6 +12,7 @@ export type {
ImageOptions,
ImageType,
Library,
LiveStreamInfo,
MediaItem,
MediaSource,
MediaStream,
@@ -51,6 +52,7 @@ export type ItemType =
| "CollectionFolder"
| "Channel"
| "ChannelFolderItem"
| "TvChannel"
| "Person";
export type LibraryType =
@@ -63,6 +65,7 @@ export type LibraryType =
| "boxsets"
| "playlists"
| "channels"
| "livetv"
| "unknown";
export type PersonType =
+46 -33
View File
@@ -30,9 +30,10 @@
onEnded?: () => void; // Called when video playback ends naturally
onNext?: () => void; // Called when user clicks next episode button
hasNext?: boolean; // Whether there is a next episode available
isLive?: boolean; // Live stream (Live TV) - no seek bar, no resume, no progress reporting
}
let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false }: Props = $props();
let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false, isLive = false }: Props = $props();
// The id this player instance reports progress against. Snapshotted from the
// media prop so a late reportStop (e.g. from onDestroy during autoplay
@@ -488,12 +489,15 @@
// Load series audio preference (for TV shows)
await loadSeriesAudioPreference();
// Report progress every 10 seconds while playing
progressInterval = setInterval(() => {
if (isPlaying && !isSeeking && onReportProgress) {
onReportProgress(currentTime, false, reportMediaId);
}
}, 10000);
// Report progress every 10 seconds while playing. Live streams have no
// meaningful position to report, so skip progress reporting entirely.
if (!isLive) {
progressInterval = setInterval(() => {
if (isPlaying && !isSeeking && onReportProgress) {
onReportProgress(currentTime, false, reportMediaId);
}
}, 10000);
}
// Debug logging every second
debugLogInterval = setInterval(() => {
@@ -555,8 +559,8 @@
}
}
// Report stop when component is destroyed
if (onReportStop && currentTime > 0) {
// Report stop when component is destroyed (skip for live - no resume tracking)
if (!isLive && onReportStop && currentTime > 0) {
onReportStop(currentTime, reportMediaId);
}
});
@@ -749,8 +753,8 @@
function handlePlay() {
isPlaying = true;
startTimeUpdates(); // Start RAF loop for smooth time updates
// Report playback start on first play
if (!hasReportedStart && onReportStart) {
// Report playback start on first play (skip for live - no resume tracking)
if (!isLive && !hasReportedStart && onReportStart) {
onReportStart(currentTime, reportMediaId);
hasReportedStart = true;
}
@@ -768,8 +772,8 @@
function handleEnded() {
isPlaying = false;
stopTimeUpdates(); // Stop RAF loop when ended
// Report stop when video ends
if (onReportStop) {
// Report stop when video ends (skip for live - no resume tracking)
if (!isLive && onReportStop) {
onReportStop(currentTime, reportMediaId);
}
// Notify parent that video has ended (for next episode popup)
@@ -1413,26 +1417,35 @@
<h2 class="text-white text-lg font-semibold">{media?.name || "Video"}</h2>
</div>
<!-- Progress bar -->
<div class="flex items-center gap-2 mb-2">
<span class="text-white text-sm w-12">{formatTime(currentTime)}</span>
<input
type="range"
min="0"
max={duration || 100}
value={currentTime}
oninput={handleSeekBarInput}
onchange={handleSeekBarChange}
onmousedown={() => isDraggingSeekBar = true}
onmouseup={() => isDraggingSeekBar = false}
ontouchstart={() => isDraggingSeekBar = true}
ontouchend={() => isDraggingSeekBar = false}
class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full"
/>
<span class="text-white text-sm w-12 text-right">{formatTime(duration)}</span>
</div>
<!-- Progress bar (hidden for live streams - no fixed timeline) -->
{#if isLive}
<div class="flex items-center gap-2 mb-2">
<span class="flex items-center gap-1.5 text-white text-sm font-semibold">
<span class="inline-block w-2 h-2 rounded-full bg-red-500"></span>
LIVE
</span>
</div>
{:else}
<div class="flex items-center gap-2 mb-2">
<span class="text-white text-sm w-12">{formatTime(currentTime)}</span>
<input
type="range"
min="0"
max={duration || 100}
value={currentTime}
oninput={handleSeekBarInput}
onchange={handleSeekBarChange}
onmousedown={() => isDraggingSeekBar = true}
onmouseup={() => isDraggingSeekBar = false}
ontouchstart={() => isDraggingSeekBar = true}
ontouchend={() => isDraggingSeekBar = false}
class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full"
/>
<span class="text-white text-sm w-12 text-right">{formatTime(duration)}</span>
</div>
{/if}
<!-- Control buttons -->
<div class="flex items-center justify-between">
+43
View File
@@ -152,6 +152,47 @@ function createLibraryStore() {
}
}
// Load Live TV channels (broadcast / IPTV) into the items list. Live TV uses a
// dedicated Jellyfin endpoint rather than the generic /Items browse.
async function loadLiveTvChannels() {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
try {
const repo = auth.getRepository();
const channels = await repo.getLiveTvChannels();
update((s) => ({
...s,
items: channels,
totalItems: channels.length,
loadingCount: Math.max(0, s.loadingCount - 1),
}));
return channels;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load Live TV channels";
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
throw error;
}
}
// Load the root list of plugin "Channels" into the items list.
async function loadChannels() {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
try {
const repo = auth.getRepository();
const result = await repo.getChannels();
update((s) => ({
...s,
items: result.items,
totalItems: result.totalRecordCount,
loadingCount: Math.max(0, s.loadingCount - 1),
}));
return result;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load channels";
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
throw error;
}
}
async function loadItem(itemId: string) {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
@@ -297,6 +338,8 @@ function createLibraryStore() {
subscribe,
loadLibraries,
loadItems,
loadLiveTvChannels,
loadChannels,
loadItem,
search,
setCurrentLibrary,
+23 -1
View File
@@ -76,6 +76,22 @@
return;
}
// Live TV channels use a dedicated endpoint
if (lib.collectionType === "livetv") {
library.setCurrentLibrary(lib);
library.clearGenres();
await library.loadLiveTvChannels();
return;
}
// Plugin "Channels" root list uses a dedicated endpoint
if (lib.collectionType === "channels") {
library.setCurrentLibrary(lib);
library.clearGenres();
await library.loadChannels();
return;
}
// For other library types, load items normally
library.setCurrentLibrary(lib);
library.clearGenres();
@@ -97,6 +113,11 @@
if ("type" in item) {
// It's a MediaItem
const mediaItem = item as MediaItem;
// A ChannelFolderItem can be a folder (drill in) or a playable leaf.
if (mediaItem.type === "ChannelFolderItem" && !mediaItem.isFolder) {
goto(`/player/${mediaItem.id}`);
return;
}
switch (mediaItem.type) {
case "Series":
case "Movie":
@@ -111,7 +132,8 @@
goto(`/library/${mediaItem.id}`);
break;
case "Episode":
// Episodes play directly
case "TvChannel":
// Episodes and live TV channels play directly
goto(`/player/${mediaItem.id}`);
break;
default:
+6
View File
@@ -187,6 +187,12 @@
goto(`/library/${clickedItem.id}`);
return;
}
// A ChannelFolderItem can be either a folder (drill in) or a playable leaf.
// Route non-folder channel items straight to the player.
if (clickedItem.type === "ChannelFolderItem" && !clickedItem.isFolder) {
goto(`/player/${clickedItem.id}`);
return;
}
switch (clickedItem.type) {
case "Series":
case "Season":
+38 -6
View File
@@ -57,6 +57,7 @@
let streamUrl = $state<string | null>(null);
let mediaSourceId = $state<string | null>(null);
let isVideo = $state(false);
let isLive = $state(false); // Whether this is a live stream (Live TV channel) - no seek/resume
let videoInitialPosition = $state(0); // Position in seconds to seek to after video loads
let videoNeedsTranscoding = $state(false); // Whether video needs transcoding (HEVC/10-bit)
let isOfflinePlayback = $state(false); // Whether playing from local file
@@ -104,6 +105,15 @@
}
});
// A playable channel leaf (ChannelFolderItem) has no dedicated item type, so
// treat it as video when it carries a video media stream.
function isVideoChannelItem(item: MediaItem): boolean {
return (
item.type === "ChannelFolderItem" &&
(item.mediaStreams?.some((s) => s.type === "Video") ?? false)
);
}
async function loadAndPlay(id: string, startPosition?: number, forceRestart = false) {
loading = true;
error = null;
@@ -118,8 +128,9 @@
currentMedia = item;
// Check if this is a non-playable collection type that should be viewed in library instead
const collectionTypes = ["MusicAlbum", "MusicArtist", "Series", "Season", "Folder", "CollectionFolder", "Playlist"];
if (collectionTypes.includes(item.type)) {
const collectionTypes = ["MusicAlbum", "MusicArtist", "Series", "Season", "Folder", "CollectionFolder", "Playlist", "Channel"];
// A ChannelFolderItem that is itself a folder is a container, not playable.
if (collectionTypes.includes(item.type) || (item.type === "ChannelFolderItem" && item.isFolder)) {
console.log("loadAndPlay: Redirecting collection type to library:", item.type);
goto(`/library/${id}`);
return;
@@ -132,7 +143,8 @@
const alreadyPlayingMedia = get(storeCurrentMedia);
if (alreadyPlayingMedia?.id === id && !startPosition && !forceRestart) {
console.log("loadAndPlay: Track already playing, showing UI without restarting");
isVideo = item.type === "Movie" || item.type === "Episode";
isLive = item.type === "TvChannel";
isVideo = item.type === "Movie" || item.type === "Episode" || isLive || isVideoChannelItem(item);
isPlaying = true;
loading = false;
// hasNext/hasPrevious come from the event-driven queue store.
@@ -143,8 +155,10 @@
return;
}
// Determine if this is video content (Movie and Episode are video types)
isVideo = item.type === "Movie" || item.type === "Episode";
// Determine if this is video content (Movie, Episode, live TV channels, and
// channel leaf items that carry a video stream).
isLive = item.type === "TvChannel";
isVideo = item.type === "Movie" || item.type === "Episode" || isLive || isVideoChannelItem(item);
// When switching to video, stop audio playback and clear the queue
// This prevents audio from continuing in the background and clears stale state
@@ -164,7 +178,8 @@
const userId = auth.getUserId();
console.log("Resume check - userId:", userId, "itemId:", id, "startPosition:", startPosition, "forceRestart:", forceRestart);
if (!startPosition && !forceRestart && userId) {
// Live streams have no fixed position - never resume.
if (!startPosition && !forceRestart && userId && !isLive) {
try {
const progress = await commands.storageGetPlaybackProgress(userId, id);
console.log("Resume check - retrieved progress:", progress);
@@ -251,6 +266,22 @@
// Online playback - get playback info from server
isOfflinePlayback = false;
const repo = auth.getRepository();
if (isLive) {
// Live TV channels must be "opened" before streaming; the server returns
// a ready-to-play HLS transcoding URL. No resume, no seek, no progress.
console.log("loadAndPlay: Opening live stream for channel:", id);
const liveInfo = await repo.openLiveStream(id);
console.log("loadAndPlay: Live stream URL:", liveInfo.streamUrl);
mediaSourceId = liveInfo.mediaSourceId;
streamUrl = liveInfo.streamUrl;
videoNeedsTranscoding = true;
videoInitialPosition = 0;
isPlaying = true;
loading = false;
return;
}
console.log("loadAndPlay: Getting playback info");
const playbackInfo = await repo.getPlaybackInfo(id);
console.log("loadAndPlay: Got playback info, mediaSourceId:", playbackInfo.mediaSourceId);
@@ -613,6 +644,7 @@
mediaSourceId={mediaSourceId ?? undefined}
initialPosition={videoInitialPosition}
needsTranscoding={videoNeedsTranscoding}
{isLive}
onClose={handleClose}
onSeek={handleVideoSeek}
onReportStart={handleReportStart}