From 34009f77c443dbbb63a4e30d7a9ae4f52a00948e Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Tue, 10 Mar 2026 18:22:48 +0100 Subject: [PATCH 01/30] feat: Cleanups, refactors --- .../command-providers/app-settings.tsx | 2 +- .../command-providers/entities.tsx | 2 +- .../command-providers/search-movie.tsx | 2 +- .../command-providers/search-series.tsx | 2 +- .../dashboard/device-availability.tsx | 2 +- frontend/src/components/dashboard/index.tsx | 2 +- .../src/components/dashboard/movie-widget.tsx | 2 +- frontend/src/components/header.tsx | 2 +- frontend/src/components/user-avatar-menu.tsx | 2 +- frontend/src/index.tsx | 4 +- frontend/src/pages/admin/app-settings.tsx | 2 +- frontend/src/pages/admin/user-list.tsx | 2 +- frontend/src/pages/ai/ai-chat-input.tsx | 5 +- .../src/pages/ai/create-ai-chat-button.tsx | 5 +- frontend/src/pages/chat/add-chat-button.tsx | 6 +- .../src/pages/chat/chat-invitation-list.tsx | 10 +-- .../src/pages/chat/delete-chat-button.tsx | 5 +- .../src/pages/chat/delete-chat-message.tsx | 5 +- frontend/src/pages/chat/invite-button.tsx | 6 +- frontend/src/pages/chat/message-input.tsx | 5 +- frontend/src/pages/file-browser/file-list.tsx | 4 +- .../src/pages/file-browser/folder-panel.tsx | 2 +- frontend/src/pages/files/image-viewer.tsx | 2 +- .../src/pages/files/monaco-file-editor.tsx | 2 +- frontend/src/pages/files/unknown-type.tsx | 2 +- .../pages/logging/log-entries-terminal.tsx | 2 +- frontend/src/pages/login.tsx | 3 +- frontend/src/pages/movies/movie-overview.tsx | 2 +- .../movie-player-v2/get-subtitle-tracks.tsx | 2 +- .../movie-player-v2/movie-player-service.ts | 2 +- .../src/pages/movies/plain-hls-player.tsx | 2 +- frontend/src/pages/offline.tsx | 2 +- frontend/src/pages/register.tsx | 2 +- frontend/src/routes/settings-routes.tsx | 2 +- .../src/services/api-clients/ai-api-client.ts | 2 +- .../services/api-clients/chat-api-client.ts | 2 +- .../services/api-clients/config-api-client.ts | 2 +- .../api-clients/dashboards-api-client.ts | 2 +- .../services/api-clients/drives-api-client.ts | 2 +- .../api-clients/identity-api-client.ts | 2 +- .../api-clients/install-api-client.ts | 2 +- .../services/api-clients/iot-api-client.ts | 2 +- .../api-clients/logging-api-client.ts | 2 +- .../services/api-clients/media-api-client.ts | 2 +- frontend/src/services/error-reporter.ts | 2 +- frontend/src/services/session.spec.ts | 62 ++++++++++++-- frontend/src/services/session.ts | 19 +++-- frontend/src/services/websocket-events.ts | 2 +- .../src/{ => utils}/environment-options.ts | 0 .../src/{ => utils}/navigate-to-route.spec.ts | 0 frontend/src/{ => utils}/navigate-to-route.ts | 2 +- frontend/src/utils/session-helpers.spec.ts | 81 +++++++++++++++++++ frontend/src/utils/session-helpers.ts | 31 +++++++ .../src/{ => utils}/theme-switch-cheat.tsx | 0 .../src/{ => utils}/trigger-download.spec.ts | 0 frontend/src/{ => utils}/trigger-download.ts | 0 56 files changed, 241 insertions(+), 82 deletions(-) rename frontend/src/{ => utils}/environment-options.ts (100%) rename frontend/src/{ => utils}/navigate-to-route.spec.ts (100%) rename frontend/src/{ => utils}/navigate-to-route.ts (94%) create mode 100644 frontend/src/utils/session-helpers.spec.ts create mode 100644 frontend/src/utils/session-helpers.ts rename frontend/src/{ => utils}/theme-switch-cheat.tsx (100%) rename frontend/src/{ => utils}/trigger-download.spec.ts (100%) rename frontend/src/{ => utils}/trigger-download.ts (100%) diff --git a/frontend/src/components/command-palette/command-providers/app-settings.tsx b/frontend/src/components/command-palette/command-providers/app-settings.tsx index dfc453f7..dc6fe14a 100644 --- a/frontend/src/components/command-palette/command-providers/app-settings.tsx +++ b/frontend/src/components/command-palette/command-providers/app-settings.tsx @@ -2,7 +2,7 @@ import { getCurrentUser } from '@furystack/core' import { createComponent } from '@furystack/shades' import type { CommandProvider } from '@furystack/shades-common-components' import { Icon, icons } from '@furystack/shades-common-components' -import { navigateToRoute } from '../../../navigate-to-route.js' +import { navigateToRoute } from '../../../utils/navigate-to-route.js' import type { SuggestionOptions } from './create-suggestion.js' import { createSuggestion, distinctByName } from './create-suggestion.js' diff --git a/frontend/src/components/command-palette/command-providers/entities.tsx b/frontend/src/components/command-palette/command-providers/entities.tsx index d35d2675..1b2f31e1 100644 --- a/frontend/src/components/command-palette/command-providers/entities.tsx +++ b/frontend/src/components/command-palette/command-providers/entities.tsx @@ -2,7 +2,7 @@ import { getCurrentUser } from '@furystack/core' import { createComponent } from '@furystack/shades' import type { CommandProvider } from '@furystack/shades-common-components' import { Icon, icons } from '@furystack/shades-common-components' -import { navigateToRoute } from '../../../navigate-to-route.js' +import { navigateToRoute } from '../../../utils/navigate-to-route.js' import type { SuggestionOptions } from './create-suggestion.js' import { createSuggestion, distinctByName } from './create-suggestion.js' diff --git a/frontend/src/components/command-palette/command-providers/search-movie.tsx b/frontend/src/components/command-palette/command-providers/search-movie.tsx index ec47e50b..a84c5988 100644 --- a/frontend/src/components/command-palette/command-providers/search-movie.tsx +++ b/frontend/src/components/command-palette/command-providers/search-movie.tsx @@ -1,6 +1,6 @@ import type { CommandProvider } from '@furystack/shades-common-components' import { createSuggestion } from './create-suggestion.js' -import { navigateToRoute } from '../../../navigate-to-route.js' +import { navigateToRoute } from '../../../utils/navigate-to-route.js' import { createComponent } from '@furystack/shades' import { MoviesService } from '../../../services/movies-service.js' diff --git a/frontend/src/components/command-palette/command-providers/search-series.tsx b/frontend/src/components/command-palette/command-providers/search-series.tsx index 7dfdf992..498f0bea 100644 --- a/frontend/src/components/command-palette/command-providers/search-series.tsx +++ b/frontend/src/components/command-palette/command-providers/search-series.tsx @@ -1,7 +1,7 @@ import type { CommandProvider } from '@furystack/shades-common-components' import { SeriesService } from '../../../services/series-service.js' import { createSuggestion } from './create-suggestion.js' -import { navigateToRoute } from '../../../navigate-to-route.js' +import { navigateToRoute } from '../../../utils/navigate-to-route.js' import { createComponent } from '@furystack/shades' export const searchSeriesCommandProvider: CommandProvider = async ({ term, injector }) => { diff --git a/frontend/src/components/dashboard/device-availability.tsx b/frontend/src/components/dashboard/device-availability.tsx index c8787379..de17a18c 100644 --- a/frontend/src/components/dashboard/device-availability.tsx +++ b/frontend/src/components/dashboard/device-availability.tsx @@ -4,7 +4,7 @@ import { Shade, createComponent } from '@furystack/shades' import { CacheView, Skeleton } from '@furystack/shades-common-components' import type { Device, DeviceAvailability as DeviceAvailabilityProps, Icon as IconType } from 'common' import { AppLink } from '../../routes/index.js' -import { navigateToRoute } from '../../navigate-to-route.js' +import { navigateToRoute } from '../../utils/navigate-to-route.js' import { IotDevicesService } from '../../services/iot-devices-service.js' import { SessionService } from '../../services/session.js' import { DynamicIcon } from '../dynamic-icon.js' diff --git a/frontend/src/components/dashboard/index.tsx b/frontend/src/components/dashboard/index.tsx index 78ef84c3..b5a15079 100644 --- a/frontend/src/components/dashboard/index.tsx +++ b/frontend/src/components/dashboard/index.tsx @@ -3,7 +3,7 @@ import { Shade, createComponent } from '@furystack/shades' import { ContextMenu, ContextMenuManager } from '@furystack/shades-common-components' import type { ContextMenuItem } from '@furystack/shades-common-components' import type { Dashboard as DashboardData } from 'common' -import { navigateToRoute } from '../../navigate-to-route.js' +import { navigateToRoute } from '../../utils/navigate-to-route.js' import { SessionService } from '../../services/session.js' import { Widget } from './widget.js' diff --git a/frontend/src/components/dashboard/movie-widget.tsx b/frontend/src/components/dashboard/movie-widget.tsx index 96696fae..0bb0ccc6 100644 --- a/frontend/src/components/dashboard/movie-widget.tsx +++ b/frontend/src/components/dashboard/movie-widget.tsx @@ -5,7 +5,7 @@ import { LazyLoad, Shade, createComponent } from '@furystack/shades' import { CacheView, cssVariableTheme, Skeleton } from '@furystack/shades-common-components' import type { Movie } from 'common' import { AppLink } from '../../routes/index.js' -import { navigateToRoute } from '../../navigate-to-route.js' +import { navigateToRoute } from '../../utils/navigate-to-route.js' import { MovieFilesService } from '../../services/movie-files-service.js' import { MoviesService } from '../../services/movies-service.js' import { SessionService } from '../../services/session.js' diff --git a/frontend/src/components/header.tsx b/frontend/src/components/header.tsx index 42aa04b7..3da0a7d6 100644 --- a/frontend/src/components/header.tsx +++ b/frontend/src/components/header.tsx @@ -1,7 +1,7 @@ import { createComponent, Shade } from '@furystack/shades' import { AppBar, Button, Icon, icons } from '@furystack/shades-common-components' import { AppBarAppLink } from '../routes/index.js' -import { environmentOptions } from '../environment-options.js' +import { environmentOptions } from '../utils/environment-options.js' import { SessionService } from '../services/session.js' import { AiIcon } from './ai/ai-icon.js' import { ChatIcon } from './chat/chat-icon.js' diff --git a/frontend/src/components/user-avatar-menu.tsx b/frontend/src/components/user-avatar-menu.tsx index 5f6fc5dc..a6e2d52a 100644 --- a/frontend/src/components/user-avatar-menu.tsx +++ b/frontend/src/components/user-avatar-menu.tsx @@ -1,7 +1,7 @@ import { createComponent, Shade } from '@furystack/shades' import type { MenuEntry } from '@furystack/shades-common-components' import { Avatar, cssVariableTheme, Dropdown, Icon, icons } from '@furystack/shades-common-components' -import { navigateToRoute } from '../navigate-to-route.js' +import { navigateToRoute } from '../utils/navigate-to-route.js' import { SessionService } from '../services/session.js' export const UserAvatarMenu = Shade({ diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index b2f1b57a..293dc081 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -8,9 +8,9 @@ import { createComponent, initializeShadeRoot } from '@furystack/shades' import { ThemeProviderService } from '@furystack/shades-common-components' import { AiChatMessage, Chat, ChatMessage, LogEntry } from 'common' import { Layout } from './components/layout.js' -import { environmentOptions } from './environment-options.js' +import { environmentOptions } from './utils/environment-options.js' import { SessionService } from './services/session.js' -import { registerThemeSwitchCheat } from './theme-switch-cheat.js' +import { registerThemeSwitchCheat } from './utils/theme-switch-cheat.js' import { darkTheme } from './themes/dark.js' const shadeInjector = new Injector() diff --git a/frontend/src/pages/admin/app-settings.tsx b/frontend/src/pages/admin/app-settings.tsx index ac737754..99e28c1f 100644 --- a/frontend/src/pages/admin/app-settings.tsx +++ b/frontend/src/pages/admin/app-settings.tsx @@ -2,7 +2,7 @@ import { createComponent, LocationService, Shade } from '@furystack/shades' import { Drawer, Icon, icons, Menu, type MenuEntry } from '@furystack/shades-common-components' import { match } from 'path-to-regexp' import type { AppPaths } from '../../routes/index.js' -import { navigateToRoute } from '../../navigate-to-route.js' +import { navigateToRoute } from '../../utils/navigate-to-route.js' type AppSettingsPageProps = { outlet?: JSX.Element diff --git a/frontend/src/pages/admin/user-list.tsx b/frontend/src/pages/admin/user-list.tsx index 5734a66a..7d34f806 100644 --- a/frontend/src/pages/admin/user-list.tsx +++ b/frontend/src/pages/admin/user-list.tsx @@ -13,7 +13,7 @@ import { Skeleton, } from '@furystack/shades-common-components' import type { User } from 'common' -import { navigateToRoute } from '../../navigate-to-route.js' +import { navigateToRoute } from '../../utils/navigate-to-route.js' import { RoleTag } from '../../components/role-tag/index.js' import { GenericErrorPage } from '../../components/generic-error.js' import { UsersService } from '../../services/users-service.js' diff --git a/frontend/src/pages/ai/ai-chat-input.tsx b/frontend/src/pages/ai/ai-chat-input.tsx index a0ed90f0..14b505a7 100644 --- a/frontend/src/pages/ai/ai-chat-input.tsx +++ b/frontend/src/pages/ai/ai-chat-input.tsx @@ -1,6 +1,6 @@ import { createComponent, Shade } from '@furystack/shades' import { Button, Form, Input } from '@furystack/shades-common-components' -import { SessionService } from '../../services/session.js' +import { getUser } from '../../utils/session-helpers.js' import { AiChatMessageService } from './ai-chat-message-service.js' import { AiChatService } from './ai-chat-service.js' @@ -24,7 +24,6 @@ export const AiChatInput = Shade<{ selectedChatId: string }>({ render: ({ props, injector, useObservable, useRef }) => { const aiChatMessageService = injector.getInstance(AiChatMessageService) const aiChatService = injector.getInstance(AiChatService) - const sessionService = injector.getInstance(SessionService) const formRef = useRef('form') const [selectedChat] = useObservable( @@ -47,7 +46,7 @@ export const AiChatInput = Shade<{ selectedChatId: string }>({ role: 'user', createdAt: new Date(), id: crypto.randomUUID(), - owner: sessionService.currentUser.getValue()!.username, + owner: getUser(injector).username, visibility: selectedChat?.value?.entries[0]?.visibility ?? 'private', }) .then(() => { diff --git a/frontend/src/pages/ai/create-ai-chat-button.tsx b/frontend/src/pages/ai/create-ai-chat-button.tsx index cdf10d8e..3bb2fb9e 100644 --- a/frontend/src/pages/ai/create-ai-chat-button.tsx +++ b/frontend/src/pages/ai/create-ai-chat-button.tsx @@ -11,7 +11,7 @@ import { } from '@furystack/shades-common-components' import type { AiChat } from 'common' import { ErrorDisplay } from '../../components/error-display.js' -import { SessionService } from '../../services/session.js' +import { getUser } from '../../utils/session-helpers.js' import { AiChatService } from './ai-chat-service.js' import { AiModelSelector } from './ai-model-selector.js' @@ -34,7 +34,6 @@ export const CreateAiChatButton = Shade({ render: ({ injector, useState }) => { const aiChatService = injector.getInstance(AiChatService) const [isModalOpen, setIsModalOpen] = useState('isModalOpen', false) - const session = injector.getInstance(SessionService) const noty = injector.getInstance(NotyService) @@ -74,7 +73,7 @@ export const CreateAiChatButton = Shade({ ...chat, id: crypto.randomUUID(), createdAt: new Date(), - owner: session.currentUser.getValue()!.username, + owner: getUser(injector).username, status: 'active', visibility: 'private', description: chat.description, diff --git a/frontend/src/pages/chat/add-chat-button.tsx b/frontend/src/pages/chat/add-chat-button.tsx index c9aabee5..d570931f 100644 --- a/frontend/src/pages/chat/add-chat-button.tsx +++ b/frontend/src/pages/chat/add-chat-button.tsx @@ -1,6 +1,6 @@ import { createComponent, Shade } from '@furystack/shades' import { Button, cssVariableTheme, Form, Input, Modal, Paper, Typography } from '@furystack/shades-common-components' -import { SessionService } from '../../services/session.js' +import { getUser } from '../../utils/session-helpers.js' import { ChatService } from './chat-service.js' export type AddChatPayload = { @@ -23,8 +23,6 @@ export const AddChatButton = Shade({ render: ({ useState, injector }) => { const [isModalOpen, setIsModalOpen] = useState('isModalOpen', false) - const session = injector.getInstance(SessionService) - const chats = injector.getInstance(ChatService) return ( @@ -58,7 +56,7 @@ export const AddChatButton = Shade({ id: crypto.randomUUID(), participants: [], createdAt: new Date(), - owner: session.currentUser.getValue()?.username || '', + owner: getUser(injector).username, }) .then(() => { setIsModalOpen(false) diff --git a/frontend/src/pages/chat/chat-invitation-list.tsx b/frontend/src/pages/chat/chat-invitation-list.tsx index 4e9e4468..abb4beb0 100644 --- a/frontend/src/pages/chat/chat-invitation-list.tsx +++ b/frontend/src/pages/chat/chat-invitation-list.tsx @@ -2,7 +2,7 @@ import { createComponent, Shade } from '@furystack/shades' import { Button, NotyService, Paper, Skeleton, Typography } from '@furystack/shades-common-components' import { ErrorDisplay } from '../../components/error-display.js' import { GenericErrorPage } from '../../components/generic-error.js' -import { SessionService } from '../../services/session.js' +import { getUser } from '../../utils/session-helpers.js' import { ChatInvitationService } from './chat-intivation-service.js' export const ChatInvitationList = Shade({ @@ -16,7 +16,7 @@ export const ChatInvitationList = Shade({ const filter = { filter: { status: { $eq: 'pending' } } } as const const chatInvitationService = injector.getInstance(ChatInvitationService) - const currentUser = injector.getInstance(SessionService).currentUser.getValue() + const currentUsername = getUser(injector).username const reloadChatInvitations = () => { void chatInvitationService.getChatInvitations(filter) @@ -57,11 +57,11 @@ export const ChatInvitationList = Shade({ const noty = injector.getInstance(NotyService) const received = invitations.value.entries.filter( - (invitation) => invitation.userId === currentUser?.username && invitation.status === 'pending', + (invitation) => invitation.userId === currentUsername && invitation.status === 'pending', ) const sent = invitations.value.entries.filter( - (invitation) => invitation.createdBy === currentUser?.username && invitation.status === 'pending', + (invitation) => invitation.createdBy === currentUsername && invitation.status === 'pending', ) return ( @@ -80,7 +80,7 @@ export const ChatInvitationList = Shade({ justifyContent: 'space-between', }} > - {invitation.userId === currentUser?.username ? ( + {invitation.userId === currentUsername ? (
{invitation.chatName} ) : ( - + )} diff --git a/frontend/src/components/movie-picker.tsx b/frontend/src/components/movie-picker.tsx index 62bbdcae..744a2089 100644 --- a/frontend/src/components/movie-picker.tsx +++ b/frontend/src/components/movie-picker.tsx @@ -13,14 +13,14 @@ export const MoviePicker = Shade({ const result = await moviesService.findMovie({ top: 10, filter: { - $or: [{ title: { $like: `%${term}%` } }], + $or: [{ imdbId: { $like: `%${term}%` } }], }, }) return result.entries }} defaultPrefix="" getSuggestionEntry={(entry) => ({ - element:
{entry.title}
, + element:
{entry.imdbId}
, score: 1, })} onSelectSuggestion={(entry: Movie) => { diff --git a/frontend/src/pages/admin/app-settings.tsx b/frontend/src/pages/admin/app-settings.tsx index 99e28c1f..8baabca7 100644 --- a/frontend/src/pages/admin/app-settings.tsx +++ b/frontend/src/pages/admin/app-settings.tsx @@ -15,6 +15,7 @@ const getMenuItems = (): Array => [ label: 'Media', children: [ { key: '/app-settings/omdb', label: 'OMDB Settings', icon: }, + { key: '/app-settings/tmdb', label: 'TMDB Settings', icon: }, { key: '/app-settings/streaming', label: 'Streaming Settings', icon: }, ], }, diff --git a/frontend/src/pages/admin/tmdb-settings.tsx b/frontend/src/pages/admin/tmdb-settings.tsx new file mode 100644 index 00000000..c1866c35 --- /dev/null +++ b/frontend/src/pages/admin/tmdb-settings.tsx @@ -0,0 +1,212 @@ +import type { CacheWithValue } from '@furystack/cache' +import { createComponent, Shade } from '@furystack/shades' +import { + Button, + CacheView, + cssVariableTheme, + Form, + Icon, + icons, + Input, + NotyService, + PageContainer, + PageHeader, + Paper, + Skeleton, + Typography, +} from '@furystack/shades-common-components' +import { ObservableValue } from '@furystack/utils' +import type { Config, TmdbConfig } from 'common' +import { GenericErrorPage } from '../../components/generic-error.js' +import { ConfigService } from '../../services/config-service.js' + +type TmdbFormData = TmdbConfig['value'] + +type TmdbRawFormData = { + apiKey: string + defaultLanguage: string + additionalLanguages: string +} + +const isTmdbRawFormData = (data: unknown): data is TmdbRawFormData => { + if (typeof data !== 'object' || data === null) return false + const d = data as Record + return typeof d.apiKey === 'string' +} + +const TmdbSettingsContent = Shade<{ data: CacheWithValue }>({ + customElementName: 'tmdb-settings-content', + css: { + '& .page-description': { + marginBottom: '24px', + color: cssVariableTheme.text.secondary, + }, + '& .form-field': { + marginBottom: '24px', + }, + '& .api-key-row': { + display: 'flex', + alignItems: 'flex-end', + gap: '8px', + }, + '& .field-hint': { + color: cssVariableTheme.text.secondary, + display: 'block', + marginTop: '4px', + }, + '& .field-hint a': { + color: cssVariableTheme.palette.primary.main, + }, + '& .form-footer': { + borderTop: `1px solid ${cssVariableTheme.background.default}`, + paddingTop: '16px', + }, + }, + render: ({ props, injector, useObservable, useDisposable, useState }) => { + const configService = injector.getInstance(ConfigService) + const notyService = injector.getInstance(NotyService) + + const isLoadingObservable = useDisposable('isLoading', () => new ObservableValue(false)) + const [isLoading] = useObservable('isLoadingValue', isLoadingObservable) + const [isApiKeyVisible, setApiKeyVisible] = useState('apiKeyVisible', false) + + const toggleApiKeyVisibility = () => { + setApiKeyVisible(!isApiKeyVisible) + } + + const handleSubmit = async (formData: TmdbRawFormData) => { + const additionalLanguages = formData.additionalLanguages + .split(',') + .map((lang) => lang.trim()) + .filter(Boolean) + + const data: TmdbFormData = { + apiKey: formData.apiKey, + defaultLanguage: formData.defaultLanguage || 'en-US', + additionalLanguages, + } + + isLoadingObservable.setValue(true) + try { + await configService.saveConfig('TMDB_CONFIG', data) + notyService.emit('onNotyAdded', { + title: 'Success', + body: 'TMDB settings saved successfully', + type: 'success', + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to save settings' + notyService.emit('onNotyAdded', { + title: 'Error', + body: errorMessage, + type: 'error', + }) + } finally { + isLoadingObservable.setValue(false) + } + } + + const currentValues: TmdbFormData = props.data.value.value + ? (props.data.value.value as TmdbFormData) + : { apiKey: '', defaultLanguage: 'en-US', additionalLanguages: [] } + + return ( + <> + + Configure the TMDB API integration for fetching movie and series metadata with multi-language support. + + + + validate={isTmdbRawFormData} onSubmit={(data) => void handleSubmit(data)}> +
+
+ + +
+ + Get your API key at{' '} + + themoviedb.org + + +
+ +
+ + + Primary language for metadata (TMDB locale format, e.g. en-US, fr-FR, de-DE) + +
+ +
+ + + Comma-separated list of additional languages to fetch alongside the default + +
+ +
+ +
+ +
+ + ) + }, +}) + +export const TmdbSettingsPage = Shade({ + customElementName: 'tmdb-settings-page', + render: ({ injector }) => { + const configService = injector.getInstance(ConfigService) + + return ( + + } title="TMDB Settings" /> + } + error={(err, retry) => retry()} />} + /> + + ) + }, +}) diff --git a/frontend/src/pages/entities/tmdb-movie-metadata.tsx b/frontend/src/pages/entities/tmdb-movie-metadata.tsx new file mode 100644 index 00000000..c2beff19 --- /dev/null +++ b/frontend/src/pages/entities/tmdb-movie-metadata.tsx @@ -0,0 +1,59 @@ +import { createComponent, Shade } from '@furystack/shades' +import { TmdbMovieMetadata } from 'common' +import mediaSchemas from 'common/schemas/media-entities.json' with { type: 'json' } +import { GenericEditorService } from '../../components/generic-editor/generic-editor-service.js' +import { GenericEditor } from '../../components/generic-editor/index.js' +import { MediaApiClient } from '../../services/api-clients/media-api-client.js' + +export const TmdbMovieMetadataPage = Shade({ + customElementName: 'shade-app-tmdb-movie-metadata-page', + render: ({ useDisposable, injector }) => { + const api = injector.getInstance(MediaApiClient) + + const service = useDisposable( + 'service', + () => + new GenericEditorService({ + model: TmdbMovieMetadata, + keyProperty: 'id', + readonlyProperties: [], + getEntities: async (findOptions) => { + const result = await api.call({ + method: 'GET', + action: '/tmdb-movie-metadata', + query: { findOptions }, + }) + return result.result + }, + deleteEntities: async () => { + alert('Not supported') + }, + getEntity: async (id) => { + const result = await api.call({ method: 'GET', action: `/tmdb-movie-metadata/:id`, url: { id }, query: {} }) + return result.result + }, + patchEntity: async () => { + alert('Not supported!') + }, + postEntity: async (entity) => { + alert('Not supported!') + return entity + }, + }), + ) + return ( + + ) + }, +}) diff --git a/frontend/src/pages/entities/tmdb-series-metadata.tsx b/frontend/src/pages/entities/tmdb-series-metadata.tsx new file mode 100644 index 00000000..0c45cac3 --- /dev/null +++ b/frontend/src/pages/entities/tmdb-series-metadata.tsx @@ -0,0 +1,64 @@ +import { createComponent, Shade } from '@furystack/shades' +import { TmdbSeriesMetadata } from 'common' +import mediaSchemas from 'common/schemas/media-entities.json' with { type: 'json' } +import { GenericEditorService } from '../../components/generic-editor/generic-editor-service.js' +import { GenericEditor } from '../../components/generic-editor/index.js' +import { MediaApiClient } from '../../services/api-clients/media-api-client.js' + +export const TmdbSeriesMetadataPage = Shade({ + customElementName: 'shade-app-tmdb-series-metadata-page', + render: ({ useDisposable, injector }) => { + const api = injector.getInstance(MediaApiClient) + + const service = useDisposable( + 'service', + () => + new GenericEditorService({ + model: TmdbSeriesMetadata, + keyProperty: 'id', + readonlyProperties: [], + getEntities: async (findOptions) => { + const result = await api.call({ + method: 'GET', + action: '/tmdb-series-metadata', + query: { findOptions }, + }) + return result.result + }, + deleteEntities: async () => { + alert('Not supported') + }, + getEntity: async (id) => { + const result = await api.call({ + method: 'GET', + action: `/tmdb-series-metadata/:id`, + url: { id }, + query: {}, + }) + return result.result + }, + patchEntity: async () => { + alert('Not supported!') + }, + postEntity: async (entity) => { + alert('Not supported!') + return entity + }, + }), + ) + return ( + + ) + }, +}) diff --git a/frontend/src/pages/movies/movie-overview.tsx b/frontend/src/pages/movies/movie-overview.tsx index 4dcdabbc..cc7f3fc9 100644 --- a/frontend/src/pages/movies/movie-overview.tsx +++ b/frontend/src/pages/movies/movie-overview.tsx @@ -3,9 +3,10 @@ import { isLoadedCacheResult } from '@furystack/cache' import { serializeToQueryString } from '@furystack/rest' import { createComponent, Shade } from '@furystack/shades' import { Button, CacheView, Skeleton, Typography } from '@furystack/shades-common-components' -import type { Movie } from 'common' +import type { Movie, MovieMetadataLocalized } from 'common' import { GenericErrorPage } from '../../components/generic-error.js' import { navigateToRoute } from '../../utils/navigate-to-route.js' +import { LocalizedMetadataService } from '../../services/localized-metadata-service.js' import { MovieFilesService } from '../../services/movie-files-service.js' import { MoviesService } from '../../services/movies-service.js' import { SessionService } from '../../services/session.js' @@ -99,14 +100,23 @@ const MovieOverviewContent = Shade<{ data: CacheWithValue }>({ const [currentUser] = useObservable('currentUser', injector.getInstance(SessionService).currentUser) const movie = props.data.value + const localizedService = injector.getInstance(LocalizedMetadataService) + const [localized] = useObservable('localized', localizedService.getMovieLocalizedAsObservable(movie.imdbId)) + + const localizedData = (localized as CacheWithValue | undefined)?.value + const title = localizedData?.title ?? movie.imdbId + const plot = localizedData?.plot + const posterUrl = localizedData?.posterUrl + const genre = localizedData?.genre + return ( - - {movie.title} + + {title} - {movie.year?.toString()}   {movie.genre} + {movie.year?.toString()}   {genre?.join(', ')} - {movie.plot} + {plot}
diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx index 59f37a54..a423487e 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx @@ -211,7 +211,7 @@ export const MoviePlayerV2 = Shade({ - + diff --git a/frontend/src/pages/movies/series-overview.tsx b/frontend/src/pages/movies/series-overview.tsx index c5bf3c80..34f8ba88 100644 --- a/frontend/src/pages/movies/series-overview.tsx +++ b/frontend/src/pages/movies/series-overview.tsx @@ -2,6 +2,7 @@ import { createComponent, ScreenService, Shade } from '@furystack/shades' import { Typography } from '@furystack/shades-common-components' import { WidgetGroup } from '../../components/dashboard/widget-group.js' import { PiRatLazyLoad } from '../../components/pirat-lazy-load.js' +import { LocalizedMetadataService } from '../../services/localized-metadata-service.js' import { MovieFilesService } from '../../services/movie-files-service.js' import { MoviesService } from '../../services/movies-service.js' import { SeriesService } from '../../services/series-service.js' @@ -20,14 +21,16 @@ export const SeriesOverview = Shade({ const moviesService = injector.getInstance(MoviesService) const movieFileService = injector.getInstance(MovieFilesService) const watchProgresses = injector.getInstance(WatchProgressService) + const localizedService = injector.getInstance(LocalizedMetadataService) return ( { - const [series, relatedMovies, relatedMovieFiles] = await Promise.all([ + const [series, relatedMovies, relatedMovieFiles, seriesLocalized] = await Promise.all([ seriesService.getSeries(props.imdbId), moviesService.findMovie({ filter: { seriesId: { $eq: props.imdbId } } }), movieFileService.findMovieFile({ filter: { imdbId: { $in: [props.imdbId] } } }), + localizedService.getSeriesLocalized(props.imdbId), ]) await Promise.all([ @@ -35,24 +38,28 @@ export const SeriesOverview = Shade({ watchProgresses.prefetchWatchProgressForFiles(relatedMovieFiles.entries), ]) + const title = seriesLocalized?.title ?? series.imdbId + const plot = seriesLocalized?.plot + const posterUrl = seriesLocalized?.posterUrl + const seasons = Array.from( new Set(relatedMovies.entries.map((m) => m.season).filter((s) => !isNaN(s as number))), ).sort() as number[] return ( - {series.title} + {title} {series.year?.toString()}   - {series.plot} + {plot}
{seasons.map((s) => ( diff --git a/frontend/src/routes/entity-routes.tsx b/frontend/src/routes/entity-routes.tsx index d72aa47b..9f3efa17 100644 --- a/frontend/src/routes/entity-routes.tsx +++ b/frontend/src/routes/entity-routes.tsx @@ -103,6 +103,30 @@ export const entityRoute = { ), children: entityEditorChildren, }, + '/tmdb-movie-metadata': { + meta: { title: 'TMDB Movie Metadata', icon: icons.globe }, + component: () => ( + { + const { TmdbMovieMetadataPage } = await import('../pages/entities/tmdb-movie-metadata.js') + return + }} + /> + ), + children: entityEditorChildren, + }, + '/tmdb-series-metadata': { + meta: { title: 'TMDB Series Metadata', icon: icons.globe }, + component: () => ( + { + const { TmdbSeriesMetadataPage } = await import('../pages/entities/tmdb-series-metadata.js') + return + }} + /> + ), + children: entityEditorChildren, + }, '/config': { meta: { title: 'Config', icon: icons.settings }, component: () => ( diff --git a/frontend/src/routes/settings-routes.tsx b/frontend/src/routes/settings-routes.tsx index ab508137..5074d59c 100644 --- a/frontend/src/routes/settings-routes.tsx +++ b/frontend/src/routes/settings-routes.tsx @@ -23,6 +23,17 @@ export const settingsChildren = { /> ), }, + '/tmdb': { + meta: { title: 'TMDB Settings', icon: icons.film }, + component: () => ( + { + const { TmdbSettingsPage } = await import('../pages/admin/tmdb-settings.js') + return + }} + /> + ), + }, '/streaming': { meta: { title: 'Streaming Settings', icon: icons.play }, component: () => ( diff --git a/frontend/src/services/localized-metadata-service.ts b/frontend/src/services/localized-metadata-service.ts new file mode 100644 index 00000000..dfab5ab1 --- /dev/null +++ b/frontend/src/services/localized-metadata-service.ts @@ -0,0 +1,65 @@ +import { Injectable, Injected } from '@furystack/inject' +import { Cache } from '@furystack/cache' +import type { MovieMetadataLocalized, SeriesMetadataLocalized } from 'common' +import { MediaApiClient } from './api-clients/media-api-client.js' + +/** + * Provides cached access to localized movie/series display data. + * Uses 'en' as the default language until user language preferences are integrated. + */ +@Injectable({ lifetime: 'singleton' }) +export class LocalizedMetadataService implements Disposable { + @Injected(MediaApiClient) + declare private readonly mediaApiClient: MediaApiClient + + public movieLocalizedCache = new Cache({ + capacity: 200, + load: async (movieImdbId: string, language = 'en') => { + const { result } = await this.mediaApiClient.call({ + method: 'GET', + action: '/movie-metadata-localized', + query: { + findOptions: { + filter: { + movieImdbId: { $eq: movieImdbId }, + language: { $eq: language }, + }, + top: 1, + }, + }, + }) + return result.entries[0] as MovieMetadataLocalized | undefined + }, + }) + + public seriesLocalizedCache = new Cache({ + capacity: 200, + load: async (seriesImdbId: string, language = 'en') => { + const { result } = await this.mediaApiClient.call({ + method: 'GET', + action: '/series-metadata-localized', + query: { + findOptions: { + filter: { + seriesImdbId: { $eq: seriesImdbId }, + language: { $eq: language }, + }, + top: 1, + }, + }, + }) + return result.entries[0] as SeriesMetadataLocalized | undefined + }, + }) + + public getMovieLocalized = this.movieLocalizedCache.get.bind(this.movieLocalizedCache) + public getMovieLocalizedAsObservable = this.movieLocalizedCache.getObservable.bind(this.movieLocalizedCache) + + public getSeriesLocalized = this.seriesLocalizedCache.get.bind(this.seriesLocalizedCache) + public getSeriesLocalizedAsObservable = this.seriesLocalizedCache.getObservable.bind(this.seriesLocalizedCache) + + public [Symbol.dispose](): void { + this.movieLocalizedCache[Symbol.dispose]() + this.seriesLocalizedCache[Symbol.dispose]() + } +} diff --git a/frontend/src/services/movies-service.spec.ts b/frontend/src/services/movies-service.spec.ts index 0c7761aa..55f4d50f 100644 --- a/frontend/src/services/movies-service.spec.ts +++ b/frontend/src/services/movies-service.spec.ts @@ -5,14 +5,10 @@ import { MoviesService } from './movies-service.js' import { MediaApiClient } from './api-clients/media-api-client.js' import type { Movie } from 'common' -const createMockMovie = (imdbId = 'tt1234567', title = 'Test Movie'): Movie => ({ +const createMockMovie = (imdbId = 'tt1234567', _title = 'Test Movie'): Movie => ({ imdbId, - title, year: 2024, type: 'movie', - genre: ['Action'], - plot: 'Test plot', - thumbnailImageUrl: 'https://example.com/poster.jpg', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }) @@ -180,12 +176,8 @@ describe('MoviesService', () => { const body = { imdbId: 'tt1234567', - title: 'Test Movie', year: 2024, type: 'movie' as const, - genre: ['Action'], - plot: 'Test plot', - thumbnailImageUrl: 'https://example.com/poster.jpg', } const result = await service.createMovie(body) @@ -216,12 +208,8 @@ describe('MoviesService', () => { await service.getMovie('tt1234567') const body = { - title: 'Updated Movie', year: 2024, type: 'movie' as const, - genre: ['Drama'], - plot: 'Updated plot', - thumbnailImageUrl: 'https://example.com/updated-poster.jpg', } const result = await service.updateMovie('tt1234567', body) diff --git a/frontend/src/services/series-service.spec.ts b/frontend/src/services/series-service.spec.ts index 901ec962..bbed1dd9 100644 --- a/frontend/src/services/series-service.spec.ts +++ b/frontend/src/services/series-service.spec.ts @@ -5,12 +5,9 @@ import { SeriesService } from './series-service.js' import { MediaApiClient } from './api-clients/media-api-client.js' import type { Series } from 'common' -const createMockSeries = (imdbId = 'tt9876543', title = 'Test Series'): Series => ({ +const createMockSeries = (imdbId = 'tt9876543', _title = 'Test Series'): Series => ({ imdbId, - title, year: '2024', - plot: 'Test series plot', - thumbnailImageUrl: 'https://example.com/series-poster.jpg', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }) diff --git a/service/src/app-models/install/actions/get-service-status.ts b/service/src/app-models/install/actions/get-service-status.ts index 7f7fb159..36f66d15 100644 --- a/service/src/app-models/install/actions/get-service-status.ts +++ b/service/src/app-models/install/actions/get-service-status.ts @@ -13,6 +13,7 @@ export const GetServiceStatus: RequestAction = async ({ state, services: { omdb: services.omdb ?? false, + tmdb: services.tmdb ?? false, github: services.github ?? false, }, }) diff --git a/service/src/app-models/media/metadata-services/tmdb-api-types.ts b/service/src/app-models/media/metadata-services/tmdb-api-types.ts new file mode 100644 index 00000000..184969b3 --- /dev/null +++ b/service/src/app-models/media/metadata-services/tmdb-api-types.ts @@ -0,0 +1,252 @@ +/** + * Hand-written types for the TMDB v3 API endpoints used by pi-rat. + * Based on https://developer.themoviedb.org/reference/getting-started + */ + +// ── Search Movie ── GET /3/search/movie ────────────────────────────── + +export type TmdbSearchMovieParams = { + query: string + include_adult?: boolean + language?: string + primary_release_year?: string + page?: number + region?: string + year?: string +} + +export type TmdbSearchMovieResult = { + adult: boolean + backdrop_path: string | null + genre_ids: number[] + id: number + original_language: string + original_title: string + overview: string + popularity: number + poster_path: string | null + release_date: string + title: string + video: boolean + vote_average: number + vote_count: number +} + +export type TmdbPaginatedResponse = { + page: number + results: T[] + total_pages: number + total_results: number +} + +// ── Search TV ── GET /3/search/tv ──────────────────────────────────── + +export type TmdbSearchTvParams = { + query: string + first_air_date_year?: number + include_adult?: boolean + language?: string + page?: number + year?: number +} + +export type TmdbSearchTvResult = { + adult: boolean + backdrop_path: string | null + genre_ids: number[] + id: number + origin_country: string[] + original_language: string + original_name: string + overview: string + popularity: number + poster_path: string | null + first_air_date: string + name: string + vote_average: number + vote_count: number +} + +// ── Movie Details ── GET /3/movie/{movie_id} ───────────────────────── + +export type TmdbMovieDetailsResponse = { + adult: boolean + backdrop_path: string | null + belongs_to_collection: { + id: number + name: string + poster_path: string | null + backdrop_path: string | null + } | null + budget: number + genres: Array<{ id: number; name: string }> + homepage: string + id: number + imdb_id: string | null + origin_country: string[] + original_language: string + original_title: string + overview: string + popularity: number + poster_path: string | null + production_companies: Array<{ + id: number + logo_path: string | null + name: string + origin_country: string + }> + production_countries: Array<{ iso_3166_1: string; name: string }> + release_date: string + revenue: number + runtime: number + spoken_languages: Array<{ + english_name: string + iso_639_1: string + name: string + }> + status: string + tagline: string + title: string + video: boolean + vote_average: number + vote_count: number + external_ids?: TmdbExternalIds +} + +// ── TV Series Details ── GET /3/tv/{series_id} ─────────────────────── + +export type TmdbTvDetailsResponse = { + adult: boolean + backdrop_path: string | null + created_by: Array<{ + id: number + credit_id: string + name: string + original_name: string + gender: number + profile_path: string | null + }> + episode_run_time: number[] + first_air_date: string + genres: Array<{ id: number; name: string }> + homepage: string + id: number + in_production: boolean + languages: string[] + last_air_date: string + last_episode_to_air: TmdbEpisodeSummary | null + name: string + networks: Array<{ + id: number + logo_path: string | null + name: string + origin_country: string + }> + next_episode_to_air: TmdbEpisodeSummary | null + number_of_episodes: number + number_of_seasons: number + origin_country: string[] + original_language: string + original_name: string + overview: string + popularity: number + poster_path: string | null + production_companies: Array<{ + id: number + logo_path: string | null + name: string + origin_country: string + }> + production_countries: Array<{ iso_3166_1: string; name: string }> + seasons: Array<{ + air_date: string + episode_count: number + id: number + name: string + overview: string + poster_path: string | null + season_number: number + vote_average: number + }> + spoken_languages: Array<{ + english_name: string + iso_639_1: string + name: string + }> + status: string + tagline: string + type: string + vote_average: number + vote_count: number + external_ids?: TmdbExternalIds +} + +export type TmdbEpisodeSummary = { + air_date: string + episode_number: number + episode_type?: string + id: number + name: string + overview: string + production_code?: string + runtime: number | null + season_number: number + show_id: number + still_path: string | null + vote_average: number + vote_count: number +} + +// ── TV Episode Details ── GET /3/tv/{id}/season/{n}/episode/{n} ────── + +export type TmdbEpisodeDetailsResponse = { + air_date: string + episode_number: number + id: number + name: string + overview: string + production_code: string + runtime: number | null + season_number: number + still_path: string | null + vote_average: number + vote_count: number + crew: Array<{ + id: number + name: string + job: string + department: string + profile_path: string | null + }> + guest_stars: Array<{ + id: number + name: string + character: string + order: number + profile_path: string | null + }> +} + +// ── Find by External ID ── GET /3/find/{external_id} ───────────────── + +export type TmdbFindByIdResponse = { + movie_results: TmdbSearchMovieResult[] + tv_results: TmdbSearchTvResult[] + person_results: unknown[] + tv_episode_results: unknown[] + tv_season_results: unknown[] +} + +// ── External IDs (append_to_response=external_ids) ─────────────────── + +export type TmdbExternalIds = { + imdb_id: string | null + facebook_id: string | null + instagram_id: string | null + twitter_id: string | null + wikidata_id: string | null + freebase_id?: string | null + freebase_mid?: string | null + tvdb_id?: number | null + tvrage_id?: number | null +} diff --git a/service/src/app-models/media/metadata-services/tmdb-client-service.ts b/service/src/app-models/media/metadata-services/tmdb-client-service.ts new file mode 100644 index 00000000..a161f230 --- /dev/null +++ b/service/src/app-models/media/metadata-services/tmdb-client-service.ts @@ -0,0 +1,423 @@ +import { useSystemIdentityContext } from '@furystack/core' +import { Injectable, Injected, type Injector } from '@furystack/inject' +import type { ScopedLogger } from '@furystack/logging' +import { getLogger } from '@furystack/logging' +import { getDataSetFor, type DataSet } from '@furystack/repository' +import { Semaphore, sleepAsync } from '@furystack/utils' +import type { TmdbConfig, PiRatFile } from 'common' +import { Config } from 'common' + +import type { + TmdbMovieDetailsResponse, + TmdbTvDetailsResponse, + TmdbEpisodeDetailsResponse, + TmdbFindByIdResponse, + TmdbPaginatedResponse, + TmdbSearchMovieResult, + TmdbSearchTvResult, +} from './tmdb-api-types.js' + +export type TmdbFetchResult = + | { status: 'success'; data: T } + | { status: 'not-found' } + | { status: 'rate-limited' } + | { status: 'not-configured' } + | { status: 'error'; error: unknown } + +const MAX_RETRIES = 3 +const INITIAL_BACKOFF_MS = 2_000 +const MAX_BACKOFF_MS = 30_000 +const TMDB_BASE_URL = 'https://api.themoviedb.org/3' +const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p' +const DEFAULT_POSTER_SIZE = 'w500' + +export const buildTmdbImageUrl = (path: string | null, size = DEFAULT_POSTER_SIZE): string | undefined => { + if (!path) return undefined + return `${TMDB_IMAGE_BASE_URL}/${size}${path}` +} + +@Injectable({ lifetime: 'singleton' }) +export class TmdbClientService { + public config?: TmdbConfig + + @Injected((injector) => getLogger(injector).withScope('TMDB Client Service')) + declare private logger: ScopedLogger + + @Injected((injector) => getDataSetFor(injector, Config, 'id')) + declare private configDataSet: DataSet + + @Injected((injector) => useSystemIdentityContext({ injector, username: 'tmdb-service' })) + declare private systemInjector: Injector + + private readonly semaphore = new Semaphore(1) + + public init() { + void this.initAsync().catch((error) => { + void this.logger.error({ message: 'Failed to initialize TMDB Client Service', data: { error } }) + }) + } + + private configSubscriptions: Disposable[] = [] + + private async initAsync() { + for (const sub of this.configSubscriptions) { + sub[Symbol.dispose]() + } + this.configSubscriptions = [] + + const config = await this.configDataSet.get(this.systemInjector, 'TMDB_CONFIG') + if (!config) { + this.config = undefined + await this.logger.information({ + message: '🚫 No config found, TMDB Service will not be initialized', + }) + } else { + this.config = config as TmdbConfig + await this.logger.verbose({ + message: '✅ TMDB Service initialized', + }) + } + + this.configSubscriptions.push( + this.configDataSet.subscribe('onEntityAdded', ({ entity }) => { + if (entity.id === 'TMDB_CONFIG') { + this.config = entity as TmdbConfig + void this.logger.information({ + message: `🎬 TMDB Service config added`, + }) + } + }), + this.configDataSet.subscribe('onEntityUpdated', ({ change }) => { + if (change.id === 'TMDB_CONFIG') { + this.config = { + ...this.config, + ...change, + } as TmdbConfig + void this.logger.information({ + message: `🎬 TMDB Service config updated`, + data: change, + }) + } + }), + this.configDataSet.subscribe('onEntityRemoved', ({ key }) => { + if (key === 'TMDB_CONFIG') { + this.config = undefined + void this.logger.information({ + message: '🚫 TMDB Service config removed, service will not be able to fetch metadata', + }) + } + }), + ) + } + + private async fetchJson( + path: string, + context: { file?: PiRatFile; description: string }, + ): Promise> { + if (!this.config) { + return { status: 'not-configured' } + } + + let backoffMs = INITIAL_BACKOFF_MS + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + const response = await fetch(`${TMDB_BASE_URL}${path}`, { + headers: { + Authorization: `Bearer ${this.config.value.apiKey}`, + Accept: 'application/json', + }, + }) + + if (response.status === 429) { + if (attempt < MAX_RETRIES) { + const retryAfter = response.headers.get('Retry-After') + const waitMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : backoffMs + await this.logger.warning({ + message: `⏳ TMDB rate limit reached for ${context.description}, retrying in ${waitMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})`, + data: { file: context.file }, + }) + await sleepAsync(waitMs) + backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS) + continue + } + return { status: 'rate-limited' } + } + + if (response.status === 404) { + return { status: 'not-found' } + } + + if (!response.ok) { + return { status: 'error', error: new Error(`HTTP ${response.status}: ${response.statusText}`) } + } + + const body = (await response.json()) as T + return { status: 'success', data: body } + } + + return { status: 'rate-limited' } + } + + // ── Low-level endpoint methods ───────────────────────────────────── + + public async searchMovie( + title: string, + options?: { year?: number; language?: string }, + ): Promise>> { + const params = new URLSearchParams({ + query: title, + language: options?.language ?? this.config?.value.defaultLanguage ?? 'en-US', + }) + if (options?.year) params.set('year', String(options.year)) + + return this.semaphore.execute(() => + this.fetchJson(`/search/movie?${params}`, { description: `search movie '${title}'` }), + ) + } + + public async searchTv( + title: string, + options?: { language?: string }, + ): Promise>> { + const params = new URLSearchParams({ + query: title, + language: options?.language ?? this.config?.value.defaultLanguage ?? 'en-US', + }) + + return this.semaphore.execute(() => this.fetchJson(`/search/tv?${params}`, { description: `search tv '${title}'` })) + } + + public async getMovieDetails( + tmdbId: number, + options?: { language?: string }, + ): Promise> { + const params = new URLSearchParams({ + language: options?.language ?? this.config?.value.defaultLanguage ?? 'en-US', + append_to_response: 'external_ids', + }) + + return this.semaphore.execute(() => + this.fetchJson(`/movie/${tmdbId}?${params}`, { description: `movie details #${tmdbId}` }), + ) + } + + public async getTvDetails( + tmdbId: number, + options?: { language?: string }, + ): Promise> { + const params = new URLSearchParams({ + language: options?.language ?? this.config?.value.defaultLanguage ?? 'en-US', + append_to_response: 'external_ids', + }) + + return this.semaphore.execute(() => + this.fetchJson(`/tv/${tmdbId}?${params}`, { description: `tv details #${tmdbId}` }), + ) + } + + public async getEpisodeDetails( + tvId: number, + seasonNumber: number, + episodeNumber: number, + options?: { language?: string }, + ): Promise> { + const params = new URLSearchParams({ + language: options?.language ?? this.config?.value.defaultLanguage ?? 'en-US', + }) + + return this.semaphore.execute(() => + this.fetchJson(`/tv/${tvId}/season/${seasonNumber}/episode/${episodeNumber}?${params}`, { + description: `episode S${seasonNumber}E${episodeNumber} of tv #${tvId}`, + }), + ) + } + + public async findByImdbId(imdbId: string): Promise> { + const params = new URLSearchParams({ + external_source: 'imdb_id', + language: this.config?.value.defaultLanguage ?? 'en-US', + }) + + return this.semaphore.execute(() => + this.fetchJson(`/find/${imdbId}?${params}`, { description: `find by IMDB ID '${imdbId}'` }), + ) + } + + // ── High-level orchestration methods ─────────────────────────────── + + /** + * Searches for a movie or episode on TMDB by title, year, season, and episode. + * For episodes (season+episode present): searches TV -> fetches series -> fetches episode. + * For movies: searches movie -> fetches details. + * Skips results without an imdb_id (D1). + */ + public async fetchTmdbMovieMetadata( + { + title, + year, + season, + episode, + }: { + title: string + year?: number + season?: number + episode?: number + }, + context?: { file?: PiRatFile }, + ): Promise< + TmdbFetchResult<{ + movie: TmdbMovieDetailsResponse + episode?: TmdbEpisodeDetailsResponse + series?: TmdbTvDetailsResponse + }> + > { + if (!this.config) { + return { status: 'not-configured' } + } + + try { + if (season != null && episode != null) { + return await this.fetchEpisodeMetadata({ title, season, episode }, context) + } + return await this.fetchMovieOnlyMetadata({ title, year }, context) + } catch (error) { + await this.logger.warning({ + message: `❗ Failed to fetch TMDB metadata for '${title}'`, + data: { error, title, year, season, episode, file: context?.file }, + }) + return { status: 'error', error } + } + } + + private async fetchEpisodeMetadata( + { title, season, episode }: { title: string; season: number; episode: number }, + context?: { file?: PiRatFile }, + ): Promise< + TmdbFetchResult<{ + movie: TmdbMovieDetailsResponse + episode?: TmdbEpisodeDetailsResponse + series?: TmdbTvDetailsResponse + }> + > { + const searchResult = await this.searchTv(title) + if (searchResult.status !== 'success') return searchResult + if (searchResult.data.results.length === 0) return { status: 'not-found' } + + const tvId = searchResult.data.results[0].id + const tvResult = await this.getTvDetails(tvId) + if (tvResult.status !== 'success') return tvResult + + const imdbId = tvResult.data.external_ids?.imdb_id + if (!imdbId) { + await this.logger.debug({ + message: `TMDB TV #${tvId} has no IMDB ID, skipping (D1)`, + data: { file: context?.file }, + }) + return { status: 'not-found' } + } + + const episodeResult = await this.getEpisodeDetails(tvId, season, episode) + if (episodeResult.status !== 'success') return episodeResult + + // Build a synthetic TmdbMovieDetailsResponse from series+episode data + // so the caller gets a consistent shape for ensureMovieExists + const syntheticMovie: TmdbMovieDetailsResponse = { + adult: tvResult.data.adult, + backdrop_path: episodeResult.data.still_path, + belongs_to_collection: null, + budget: 0, + genres: tvResult.data.genres, + homepage: '', + id: episodeResult.data.id, + imdb_id: imdbId, + origin_country: tvResult.data.origin_country, + original_language: tvResult.data.original_language, + original_title: episodeResult.data.name, + overview: episodeResult.data.overview, + popularity: 0, + poster_path: tvResult.data.poster_path, + production_companies: [], + production_countries: [], + release_date: episodeResult.data.air_date, + revenue: 0, + runtime: episodeResult.data.runtime ?? 0, + spoken_languages: [], + status: 'Released', + tagline: '', + title: episodeResult.data.name, + video: false, + vote_average: episodeResult.data.vote_average, + vote_count: episodeResult.data.vote_count, + } + + return { + status: 'success', + data: { + movie: syntheticMovie, + episode: episodeResult.data, + series: tvResult.data, + }, + } + } + + private async fetchMovieOnlyMetadata( + { title, year }: { title: string; year?: number }, + context?: { file?: PiRatFile }, + ): Promise< + TmdbFetchResult<{ + movie: TmdbMovieDetailsResponse + episode?: TmdbEpisodeDetailsResponse + series?: TmdbTvDetailsResponse + }> + > { + const searchResult = await this.searchMovie(title, { year }) + if (searchResult.status !== 'success') return searchResult + if (searchResult.data.results.length === 0) return { status: 'not-found' } + + const movieId = searchResult.data.results[0].id + const detailResult = await this.getMovieDetails(movieId) + if (detailResult.status !== 'success') return detailResult + + const imdbId = detailResult.data.imdb_id ?? detailResult.data.external_ids?.imdb_id + if (!imdbId) { + await this.logger.debug({ + message: `TMDB movie #${movieId} has no IMDB ID, skipping (D1)`, + data: { file: context?.file }, + }) + return { status: 'not-found' } + } + + return { + status: 'success', + data: { movie: { ...detailResult.data, imdb_id: imdbId } }, + } + } + + /** + * Fetches series metadata from TMDB using an IMDB ID. + * Uses /find to bridge IMDB -> TMDB, then fetches full TV details. + */ + public async fetchTmdbSeriesMetadata( + { imdbId }: { imdbId: string }, + context?: { file?: PiRatFile }, + ): Promise> { + if (!this.config) { + return { status: 'not-configured' } + } + + try { + const findResult = await this.findByImdbId(imdbId) + if (findResult.status !== 'success') return findResult + if (findResult.data.tv_results.length === 0) return { status: 'not-found' } + + const tmdbId = findResult.data.tv_results[0].id + return await this.getTvDetails(tmdbId) + } catch (error) { + await this.logger.warning({ + message: `❗ Failed to fetch TMDB Series metadata for '${imdbId}'`, + data: { error, imdbId, file: context?.file }, + }) + return { status: 'error', error } + } + } +} diff --git a/service/src/app-models/media/setup-media-api.ts b/service/src/app-models/media/setup-media-api.ts index 73ed35c7..c9506c07 100644 --- a/service/src/app-models/media/setup-media-api.ts +++ b/service/src/app-models/media/setup-media-api.ts @@ -11,7 +11,18 @@ import { useRestService, } from '@furystack/rest-service' import type { MediaApi } from 'common' -import { Movie, MovieFile, OmdbMovieMetadata, OmdbSeriesMetadata, Series, WatchHistoryEntry } from 'common' +import { + Movie, + MovieFile, + MovieMetadataLocalized, + OmdbMovieMetadata, + OmdbSeriesMetadata, + Series, + SeriesMetadataLocalized, + TmdbMovieMetadata, + TmdbSeriesMetadata, + WatchHistoryEntry, +} from 'common' import mediaApiSchema from 'common/schemas/media-api.json' with { type: 'json' } import { getCorsOptions } from '../../get-cors-options.js' import { getPort } from '../../get-port.js' @@ -80,6 +91,38 @@ export const setupMediaRestApi = async (injector: Injector) => { schema: mediaApiSchema, schemaName: 'GetEntityEndpoint', })(createGetEntityEndpoint({ model: OmdbSeriesMetadata, primaryKey: 'imdbID' })), + '/tmdb-movie-metadata': Validate({ + schema: mediaApiSchema, + schemaName: 'GetCollectionEndpoint', + })(createGetCollectionEndpoint({ model: TmdbMovieMetadata, primaryKey: 'id' })), + '/tmdb-movie-metadata/:id': Validate({ + schema: mediaApiSchema, + schemaName: 'GetEntityEndpoint', + })(createGetEntityEndpoint({ model: TmdbMovieMetadata, primaryKey: 'id' })), + '/tmdb-series-metadata': Validate({ + schema: mediaApiSchema, + schemaName: 'GetCollectionEndpoint', + })(createGetCollectionEndpoint({ model: TmdbSeriesMetadata, primaryKey: 'id' })), + '/tmdb-series-metadata/:id': Validate({ + schema: mediaApiSchema, + schemaName: 'GetEntityEndpoint', + })(createGetEntityEndpoint({ model: TmdbSeriesMetadata, primaryKey: 'id' })), + '/movie-metadata-localized': Validate({ + schema: mediaApiSchema, + schemaName: 'GetCollectionEndpoint', + })(createGetCollectionEndpoint({ model: MovieMetadataLocalized, primaryKey: 'id' })), + '/movie-metadata-localized/:id': Validate({ + schema: mediaApiSchema, + schemaName: 'GetEntityEndpoint', + })(createGetEntityEndpoint({ model: MovieMetadataLocalized, primaryKey: 'id' })), + '/series-metadata-localized': Validate({ + schema: mediaApiSchema, + schemaName: 'GetCollectionEndpoint', + })(createGetCollectionEndpoint({ model: SeriesMetadataLocalized, primaryKey: 'id' })), + '/series-metadata-localized/:id': Validate({ + schema: mediaApiSchema, + schemaName: 'GetEntityEndpoint', + })(createGetEntityEndpoint({ model: SeriesMetadataLocalized, primaryKey: 'id' })), '/movie-files': Validate({ schema: mediaApiSchema, schemaName: 'GetCollectionEndpoint' })( createGetCollectionEndpoint({ model: MovieFile, primaryKey: 'id' }), ), diff --git a/service/src/app-models/media/setup-media.spec.ts b/service/src/app-models/media/setup-media.spec.ts index 33b7905e..e1e19c04 100644 --- a/service/src/app-models/media/setup-media.spec.ts +++ b/service/src/app-models/media/setup-media.spec.ts @@ -36,7 +36,6 @@ const createMovieFile = (overrides: Partial = {}): MovieFile => const createMovie = (overrides: Partial = {}): Movie => ({ imdbId: 'tt1234567', - title: 'Test Movie', ...overrides, }) as Movie diff --git a/service/src/app-models/media/setup-media.ts b/service/src/app-models/media/setup-media.ts index 7948be99..9425dd13 100644 --- a/service/src/app-models/media/setup-media.ts +++ b/service/src/app-models/media/setup-media.ts @@ -2,7 +2,18 @@ import type { Injector } from '@furystack/inject' import type { ScopedLogger } from '@furystack/logging' import { getLogger } from '@furystack/logging' -import { Movie, MovieFile, OmdbMovieMetadata, OmdbSeriesMetadata, Series, WatchHistoryEntry } from 'common' +import { + Movie, + MovieFile, + MovieMetadataLocalized, + OmdbMovieMetadata, + OmdbSeriesMetadata, + Series, + SeriesMetadataLocalized, + TmdbMovieMetadata, + TmdbSeriesMetadata, + WatchHistoryEntry, +} from 'common' import { getCurrentUser, isAuthorized } from '@furystack/core' import type { AuthorizationResult, DataSet } from '@furystack/repository' @@ -17,16 +28,13 @@ import type { FfprobeResult } from '../../ffprobe-service.js' import { getDefaultDbSettings } from '../../get-default-db-options.js' import { WebsocketService } from '../../websocket-service.js' import { OmdbClientService } from './metadata-services/omdb-client-service.js' +import { TmdbClientService } from './metadata-services/tmdb-client-service.js' import { useMovieFileMaintainer } from './services/movie-file-maintainer.js' class MovieModel extends Model implements Movie { - declare title: string declare imdbId: string declare year?: number | undefined declare duration?: number | undefined - declare genre?: string[] | undefined - declare thumbnailImageUrl?: string | undefined - declare plot?: string | undefined declare type?: 'episode' | 'movie' | undefined declare seriesId?: string | undefined declare season?: number | undefined @@ -59,10 +67,8 @@ class WatchHistoryEntryModel extends Model class SeriesModel extends Model implements Series { declare imdbId: string - declare title: string declare year: string - declare thumbnailImageUrl?: string | undefined - declare plot: string + declare numberOfSeasons?: number | undefined declare createdAt: string declare updatedAt: string } @@ -127,6 +133,87 @@ class OmdbSeriesMetadataModel extends Model implements TmdbMovieMetadata { + declare id: number + declare imdbId?: string + declare title: string + declare originalTitle: string + declare overview: string + declare releaseDate?: string + declare runtime?: number + declare posterPath?: string + declare backdropPath?: string + declare genres: Array<{ id: number; name: string }> + declare voteAverage?: number + declare voteCount?: number + declare popularity?: number + declare originalLanguage: string + declare spokenLanguages?: Array<{ iso_639_1: string; name: string }> + declare productionCountries?: Array<{ iso_3166_1: string; name: string }> + declare status?: string + declare tagline?: string + declare budget?: number + declare revenue?: number + declare language: string + declare createdAt: string + declare updatedAt: string +} + +class TmdbSeriesMetadataModel extends Model implements TmdbSeriesMetadata { + declare id: number + declare imdbId?: string + declare name: string + declare originalName: string + declare overview: string + declare firstAirDate?: string + declare posterPath?: string + declare backdropPath?: string + declare genres: Array<{ id: number; name: string }> + declare voteAverage?: number + declare voteCount?: number + declare numberOfSeasons?: number + declare numberOfEpisodes?: number + declare status?: string + declare originalLanguage: string + declare languages?: string[] + declare language: string + declare createdAt: string + declare updatedAt: string +} + +class MovieMetadataLocalizedModel + extends Model + implements MovieMetadataLocalized +{ + declare id: string + declare movieImdbId: string + declare language: string + declare title: string + declare plot?: string + declare posterUrl?: string + declare genre?: string[] + declare source: 'omdb' | 'tmdb' + declare sourceId?: string + declare createdAt: string + declare updatedAt: string +} + +class SeriesMetadataLocalizedModel + extends Model + implements SeriesMetadataLocalized +{ + declare id: string + declare seriesImdbId: string + declare language: string + declare title: string + declare plot?: string + declare posterUrl?: string + declare source: 'omdb' | 'tmdb' + declare sourceId?: string + declare createdAt: string + declare updatedAt: string +} + export const announceMovieFileAdded = async ({ entity, injector, @@ -179,10 +266,6 @@ export const setupMedia = async (injector: Injector) => { allowNull: false, primaryKey: true, }, - title: { - type: DataTypes.STRING, - allowNull: false, - }, duration: { type: DataTypes.INTEGER, allowNull: true, @@ -191,10 +274,6 @@ export const setupMedia = async (injector: Injector) => { type: DataTypes.INTEGER, allowNull: true, }, - genre: { - type: DataTypes.JSON, //DataTypes.ARRAY(DataTypes.STRING), - allowNull: true, - }, episode: { type: DataTypes.INTEGER, allowNull: true, @@ -203,14 +282,6 @@ export const setupMedia = async (injector: Injector) => { type: DataTypes.INTEGER, allowNull: true, }, - plot: { - type: DataTypes.STRING, - allowNull: true, - }, - thumbnailImageUrl: { - type: DataTypes.STRING, - allowNull: true, - }, type: { type: DataTypes.ENUM('episode', 'movie'), allowNull: true, @@ -339,22 +410,14 @@ export const setupMedia = async (injector: Injector) => { type: DataTypes.STRING, primaryKey: true, }, - title: { - type: DataTypes.STRING, - allowNull: false, - }, year: { type: DataTypes.STRING, allowNull: false, }, - thumbnailImageUrl: { - type: DataTypes.STRING, + numberOfSeasons: { + type: DataTypes.INTEGER, allowNull: true, }, - plot: { - type: DataTypes.STRING, - allowNull: false, - }, createdAt: { type: DataTypes.DATE, allowNull: false, @@ -617,6 +680,330 @@ export const setupMedia = async (injector: Injector) => { }, }) + useSequelize({ + injector, + model: TmdbMovieMetadata, + sequelizeModel: TmdbMovieMetadataModel, + primaryKey: 'id', + options: dbOptions, + initModel: async (sequelize) => { + TmdbMovieMetadataModel.init( + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + allowNull: false, + }, + imdbId: { + type: DataTypes.STRING, + allowNull: true, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + originalTitle: { + type: DataTypes.STRING, + allowNull: false, + }, + overview: { + type: DataTypes.TEXT, + allowNull: false, + }, + releaseDate: { + type: DataTypes.STRING, + allowNull: true, + }, + runtime: { + type: DataTypes.INTEGER, + allowNull: true, + }, + posterPath: { + type: DataTypes.STRING, + allowNull: true, + }, + backdropPath: { + type: DataTypes.STRING, + allowNull: true, + }, + genres: { + type: DataTypes.JSON, + allowNull: true, + }, + voteAverage: { + type: DataTypes.FLOAT, + allowNull: true, + }, + voteCount: { + type: DataTypes.INTEGER, + allowNull: true, + }, + popularity: { + type: DataTypes.FLOAT, + allowNull: true, + }, + originalLanguage: { + type: DataTypes.STRING, + allowNull: false, + }, + spokenLanguages: { + type: DataTypes.JSON, + allowNull: true, + }, + productionCountries: { + type: DataTypes.JSON, + allowNull: true, + }, + status: { + type: DataTypes.STRING, + allowNull: true, + }, + tagline: { + type: DataTypes.STRING, + allowNull: true, + }, + budget: { + type: DataTypes.INTEGER, + allowNull: true, + }, + revenue: { + type: DataTypes.INTEGER, + allowNull: true, + }, + language: { + type: DataTypes.STRING, + allowNull: false, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { sequelize, indexes: [{ fields: ['imdbId'] }] }, + ) + }, + }) + + useSequelize({ + injector, + model: TmdbSeriesMetadata, + sequelizeModel: TmdbSeriesMetadataModel, + primaryKey: 'id', + options: dbOptions, + initModel: async (sequelize) => { + TmdbSeriesMetadataModel.init( + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + allowNull: false, + }, + imdbId: { + type: DataTypes.STRING, + allowNull: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + originalName: { + type: DataTypes.STRING, + allowNull: false, + }, + overview: { + type: DataTypes.TEXT, + allowNull: false, + }, + firstAirDate: { + type: DataTypes.STRING, + allowNull: true, + }, + posterPath: { + type: DataTypes.STRING, + allowNull: true, + }, + backdropPath: { + type: DataTypes.STRING, + allowNull: true, + }, + genres: { + type: DataTypes.JSON, + allowNull: true, + }, + voteAverage: { + type: DataTypes.FLOAT, + allowNull: true, + }, + voteCount: { + type: DataTypes.INTEGER, + allowNull: true, + }, + numberOfSeasons: { + type: DataTypes.INTEGER, + allowNull: true, + }, + numberOfEpisodes: { + type: DataTypes.INTEGER, + allowNull: true, + }, + status: { + type: DataTypes.STRING, + allowNull: true, + }, + originalLanguage: { + type: DataTypes.STRING, + allowNull: false, + }, + languages: { + type: DataTypes.JSON, + allowNull: true, + }, + language: { + type: DataTypes.STRING, + allowNull: false, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { sequelize, indexes: [{ fields: ['imdbId'] }] }, + ) + }, + }) + + useSequelize({ + injector, + model: MovieMetadataLocalized, + sequelizeModel: MovieMetadataLocalizedModel, + primaryKey: 'id', + options: dbOptions, + initModel: async (sequelize) => { + MovieMetadataLocalizedModel.init( + { + id: { + type: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false, + defaultValue: () => crypto.randomUUID(), + }, + movieImdbId: { + type: DataTypes.STRING, + allowNull: false, + }, + language: { + type: DataTypes.STRING, + allowNull: false, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + plot: { + type: DataTypes.TEXT, + allowNull: true, + }, + posterUrl: { + type: DataTypes.STRING, + allowNull: true, + }, + genre: { + type: DataTypes.JSON, + allowNull: true, + }, + source: { + type: DataTypes.STRING, + allowNull: false, + }, + sourceId: { + type: DataTypes.STRING, + allowNull: true, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { + sequelize, + indexes: [{ fields: ['movieImdbId'] }, { fields: ['movieImdbId', 'language', 'source'], unique: true }], + }, + ) + }, + }) + + useSequelize({ + injector, + model: SeriesMetadataLocalized, + sequelizeModel: SeriesMetadataLocalizedModel, + primaryKey: 'id', + options: dbOptions, + initModel: async (sequelize) => { + SeriesMetadataLocalizedModel.init( + { + id: { + type: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false, + defaultValue: () => crypto.randomUUID(), + }, + seriesImdbId: { + type: DataTypes.STRING, + allowNull: false, + }, + language: { + type: DataTypes.STRING, + allowNull: false, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + plot: { + type: DataTypes.TEXT, + allowNull: true, + }, + posterUrl: { + type: DataTypes.STRING, + allowNull: true, + }, + source: { + type: DataTypes.STRING, + allowNull: false, + }, + sourceId: { + type: DataTypes.STRING, + allowNull: true, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { + sequelize, + indexes: [{ fields: ['seriesImdbId'] }, { fields: ['seriesImdbId', 'language', 'source'], unique: true }], + }, + ) + }, + }) + const repo = getRepository(injector) repo.createDataSet(Movie, 'imdbId', { @@ -695,9 +1082,39 @@ export const setupMedia = async (injector: Injector) => { authorizeRemove: withRole('admin'), }) + repo.createDataSet(TmdbMovieMetadata, 'id', { + authorizeGet: authorizedOnly, + authorizeAdd: withRole('admin'), + authorizeUpdate: withRole('admin'), + authorizeRemove: withRole('admin'), + }) + + repo.createDataSet(TmdbSeriesMetadata, 'id', { + authorizeGet: authorizedOnly, + authorizeAdd: withRole('admin'), + authorizeUpdate: withRole('admin'), + authorizeRemove: withRole('admin'), + }) + + repo.createDataSet(MovieMetadataLocalized, 'id', { + authorizeGet: authorizedOnly, + authorizeAdd: withRole('admin'), + authorizeUpdate: withRole('admin'), + authorizeRemove: withRole('admin'), + }) + + repo.createDataSet(SeriesMetadataLocalized, 'id', { + authorizeGet: authorizedOnly, + authorizeAdd: withRole('admin'), + authorizeUpdate: withRole('admin'), + authorizeRemove: withRole('admin'), + }) + const omdbClientService = injector.getInstance(OmdbClientService) + const tmdbClientService = injector.getInstance(TmdbClientService) injector.getInstance(ExternalServiceStatusRegistry).register('omdb', () => !!omdbClientService.config) + injector.getInstance(ExternalServiceStatusRegistry).register('tmdb', () => !!tmdbClientService.config) const movieFileDataSet = getDataSetFor(injector, MovieFile, 'id') const movieDataSet = getDataSetFor(injector, Movie, 'imdbId') diff --git a/service/src/app-models/media/utils/ensure-localized-metadata-exists.ts b/service/src/app-models/media/utils/ensure-localized-metadata-exists.ts new file mode 100644 index 00000000..e50a4ad1 --- /dev/null +++ b/service/src/app-models/media/utils/ensure-localized-metadata-exists.ts @@ -0,0 +1,65 @@ +import type { Injector } from '@furystack/inject' +import { getDataSetFor } from '@furystack/repository' +import type { MovieMetadataLocalized, SeriesMetadataLocalized } from 'common' +import { + MovieMetadataLocalized as MovieMetadataLocalizedClass, + SeriesMetadataLocalized as SeriesMetadataLocalizedClass, +} from 'common' + +export const ensureMovieLocalizedMetadataExists = async ( + data: Omit, + injector: Injector, +) => { + const dataSet = getDataSetFor(injector, MovieMetadataLocalizedClass, 'id') + const existing = await dataSet.find(injector, { + filter: { + movieImdbId: { $eq: data.movieImdbId }, + language: { $eq: data.language }, + source: { $eq: data.source }, + }, + top: 1, + }) + + if (existing.length > 0) { + return existing[0] + } + + const { + created: [added], + } = await dataSet.add(injector, { + ...data, + id: crypto.randomUUID(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + return added +} + +export const ensureSeriesLocalizedMetadataExists = async ( + data: Omit, + injector: Injector, +) => { + const dataSet = getDataSetFor(injector, SeriesMetadataLocalizedClass, 'id') + const existing = await dataSet.find(injector, { + filter: { + seriesImdbId: { $eq: data.seriesImdbId }, + language: { $eq: data.language }, + source: { $eq: data.source }, + }, + top: 1, + }) + + if (existing.length > 0) { + return existing[0] + } + + const { + created: [added], + } = await dataSet.add(injector, { + ...data, + id: crypto.randomUUID(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + return added +} diff --git a/service/src/app-models/media/utils/ensure-movie-exists.spec.ts b/service/src/app-models/media/utils/ensure-movie-exists.spec.ts index d732be1d..f3cfeaac 100644 --- a/service/src/app-models/media/utils/ensure-movie-exists.spec.ts +++ b/service/src/app-models/media/utils/ensure-movie-exists.spec.ts @@ -1,7 +1,6 @@ import { Injector } from '@furystack/inject' import { usingAsync } from '@furystack/utils' import { describe, expect, it, vi } from 'vitest' -import type { OmdbMovieMetadata } from 'common' import { ensureMovieExists } from './ensure-movie-exists.js' const mockMovieStoreGet = vi.fn() @@ -15,43 +14,15 @@ vi.mock('@furystack/repository', () => ({ })) describe('ensureMovieExists', () => { - const createOmdbMeta = (overrides: Partial = {}): OmdbMovieMetadata => ({ - imdbID: 'tt1234567', - Title: 'Test Movie', - Year: '2024', - Type: 'movie', - Poster: 'https://example.com/poster.jpg', - Plot: 'A test movie plot', - Runtime: '120 min', - Rated: 'PG-13', - Released: '01 Jan 2024', - Genre: 'Action', - Director: 'Test Director', - Writer: 'Test Writer', - Actors: 'Actor 1, Actor 2', - Language: 'English', - Country: 'USA', - Awards: 'None', - Ratings: [], - Metascore: '75', - imdbRating: '7.5', - imdbVotes: '10000', - Response: 'True', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - ...overrides, - }) - it('should return existing movie if found', async () => { const existingMovie = { imdbId: 'tt1234567', - title: 'Test Movie', year: 2024, } mockMovieStoreGet.mockResolvedValue(existingMovie) await usingAsync(new Injector(), async (injector) => { - const result = await ensureMovieExists(createOmdbMeta(), injector) + const result = await ensureMovieExists({ imdbId: 'tt1234567', year: 2024, type: 'movie' }, injector) expect(mockMovieStoreGet).toHaveBeenCalledWith(injector, 'tt1234567') expect(mockMovieStoreAdd).not.toHaveBeenCalled() @@ -62,21 +33,19 @@ describe('ensureMovieExists', () => { it('should create new movie if not found', async () => { const newMovie = { imdbId: 'tt1234567', - title: 'Test Movie', year: 2024, } mockMovieStoreGet.mockResolvedValue(null) mockMovieStoreAdd.mockResolvedValue({ created: [newMovie] }) await usingAsync(new Injector(), async (injector) => { - const result = await ensureMovieExists(createOmdbMeta(), injector) + const result = await ensureMovieExists({ imdbId: 'tt1234567', year: 2024, type: 'movie' }, injector) expect(mockMovieStoreGet).toHaveBeenCalledWith(injector, 'tt1234567') expect(mockMovieStoreAdd).toHaveBeenCalledWith( injector, expect.objectContaining({ imdbId: 'tt1234567', - title: 'Test Movie', year: 2024, type: 'movie', }), @@ -88,7 +57,6 @@ describe('ensureMovieExists', () => { it('should handle series episodes with season and episode', async () => { const newMovie = { imdbId: 'tt9999999', - title: 'Episode Title', year: 2024, season: 1, episode: 5, @@ -96,17 +64,18 @@ describe('ensureMovieExists', () => { mockMovieStoreGet.mockResolvedValue(null) mockMovieStoreAdd.mockResolvedValue({ created: [newMovie] }) - const omdbMeta = createOmdbMeta({ - imdbID: 'tt9999999', - Title: 'Episode Title', - Season: '1', - Episode: '5', - Type: 'episode', - seriesID: 'tt1111111', - }) - await usingAsync(new Injector(), async (injector) => { - await ensureMovieExists(omdbMeta, injector) + await ensureMovieExists( + { + imdbId: 'tt9999999', + year: 2024, + season: 1, + episode: 5, + type: 'episode', + seriesId: 'tt1111111', + }, + injector, + ) expect(mockMovieStoreAdd).toHaveBeenCalledWith( injector, @@ -121,14 +90,12 @@ describe('ensureMovieExists', () => { }) }) - it('should handle missing runtime', async () => { + it('should handle missing duration', async () => { mockMovieStoreGet.mockResolvedValue(null) mockMovieStoreAdd.mockResolvedValue({ created: [{ imdbId: 'tt1234567' }] }) - const omdbMeta = createOmdbMeta({ Runtime: undefined }) - await usingAsync(new Injector(), async (injector) => { - await ensureMovieExists(omdbMeta, injector) + await ensureMovieExists({ imdbId: 'tt1234567', year: 2024, type: 'movie' }, injector) expect(mockMovieStoreAdd).toHaveBeenCalledWith( injector, diff --git a/service/src/app-models/media/utils/ensure-movie-exists.ts b/service/src/app-models/media/utils/ensure-movie-exists.ts index 18c22ff0..3a671b07 100644 --- a/service/src/app-models/media/utils/ensure-movie-exists.ts +++ b/service/src/app-models/media/utils/ensure-movie-exists.ts @@ -1,25 +1,32 @@ import type { Injector } from '@furystack/inject' import { getDataSetFor } from '@furystack/repository' -import { Movie, type OmdbMovieMetadata } from 'common' +import { Movie } from 'common' -export const ensureMovieExists = async (omdbMeta: OmdbMovieMetadata, injector: Injector) => { +type MovieInput = { + imdbId: string + year?: number + duration?: number + type?: 'movie' | 'episode' + seriesId?: string + season?: number + episode?: number +} + +export const ensureMovieExists = async (input: MovieInput, injector: Injector) => { const movieDataSet = getDataSetFor(injector, Movie, 'imdbId') - const existingMovie = await movieDataSet.get(injector, omdbMeta.imdbID) + const existingMovie = await movieDataSet.get(injector, input.imdbId) if (!existingMovie) { const { created: [newMovie], } = await movieDataSet.add(injector, { - imdbId: omdbMeta.imdbID, - title: omdbMeta.Title, - year: parseInt(omdbMeta.Year, 10), - season: omdbMeta.Season ? parseInt(omdbMeta.Season, 10) : undefined, - episode: omdbMeta.Episode ? parseInt(omdbMeta.Episode, 10) : undefined, - type: omdbMeta.Type, - duration: omdbMeta.Runtime ? parseInt(omdbMeta.Runtime, 10) : undefined, - thumbnailImageUrl: omdbMeta.Poster, - plot: omdbMeta.Plot, - seriesId: omdbMeta.seriesID, + imdbId: input.imdbId, + year: input.year, + duration: input.duration, + type: input.type, + seriesId: input.seriesId, + season: input.season, + episode: input.episode, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }) diff --git a/service/src/app-models/media/utils/ensure-omdb-series-exists.spec.ts b/service/src/app-models/media/utils/ensure-omdb-series-exists.spec.ts index 88139212..88054e55 100644 --- a/service/src/app-models/media/utils/ensure-omdb-series-exists.spec.ts +++ b/service/src/app-models/media/utils/ensure-omdb-series-exists.spec.ts @@ -28,6 +28,14 @@ vi.mock('./ensure-series-exists.js', () => ({ ensureSeriesExists: (...args: unknown[]) => mockEnsureSeriesExists(...args) as unknown, })) +vi.mock('./ensure-localized-metadata-exists.js', () => ({ + ensureSeriesLocalizedMetadataExists: vi.fn().mockResolvedValue({}), +})) + +vi.mock('./map-omdb-to-localized.js', () => ({ + mapOmdbSeriesToLocalized: vi.fn().mockReturnValue({}), +})) + const { ensureOmdbSeriesExists } = await import('./ensure-omdb-series-exists.js') const createMeta = (overrides?: Partial): OmdbMovieMetadata => @@ -63,7 +71,12 @@ describe('ensureOmdbSeriesExists', () => { }) it('should call ensureSeriesExists with stored result when series is already in database', async () => { - const storedSeries = { imdbID: 'tt9999999', Title: 'Stored Series' } as OmdbSeriesMetadata + const storedSeries = { + imdbID: 'tt9999999', + Title: 'Stored Series', + Year: '2024', + totalSeasons: '3', + } as OmdbSeriesMetadata mockOmdbSeriesGet.mockResolvedValue(storedSeries) await usingAsync(createTestInjector(), async (injector) => { @@ -71,12 +84,20 @@ describe('ensureOmdbSeriesExists', () => { expect(mockOmdbSeriesGet).toHaveBeenCalled() expect(mockFetchOmdbSeriesMetadata).not.toHaveBeenCalled() - expect(mockEnsureSeriesExists).toHaveBeenCalledWith(storedSeries, injector) + expect(mockEnsureSeriesExists).toHaveBeenCalledWith( + { imdbId: 'tt9999999', year: '2024', numberOfSeasons: 3 }, + injector, + ) }) }) it('should fetch from OMDB, add to dataset, and call ensureSeriesExists when not stored', async () => { - const fetchedSeries = { imdbID: 'tt9999999', Title: 'Fetched Series' } as OmdbSeriesMetadata + const fetchedSeries = { + imdbID: 'tt9999999', + Title: 'Fetched Series', + Year: '2023', + totalSeasons: '5', + } as OmdbSeriesMetadata mockOmdbSeriesGet.mockResolvedValue(undefined) mockFetchOmdbSeriesMetadata.mockResolvedValue({ status: 'success', data: fetchedSeries }) mockOmdbSeriesAdd.mockResolvedValue({ created: [fetchedSeries] }) @@ -86,7 +107,10 @@ describe('ensureOmdbSeriesExists', () => { expect(mockFetchOmdbSeriesMetadata).toHaveBeenCalledWith({ imdbId: 'tt9999999' }, { file: undefined }) expect(mockOmdbSeriesAdd).toHaveBeenCalledWith(injector, fetchedSeries) - expect(mockEnsureSeriesExists).toHaveBeenCalledWith(fetchedSeries, injector) + expect(mockEnsureSeriesExists).toHaveBeenCalledWith( + { imdbId: 'tt9999999', year: '2023', numberOfSeasons: 5 }, + injector, + ) }) }) diff --git a/service/src/app-models/media/utils/ensure-omdb-series-exists.ts b/service/src/app-models/media/utils/ensure-omdb-series-exists.ts index c1ec4ba6..0dd0bc0e 100644 --- a/service/src/app-models/media/utils/ensure-omdb-series-exists.ts +++ b/service/src/app-models/media/utils/ensure-omdb-series-exists.ts @@ -4,6 +4,8 @@ import { getDataSetFor } from '@furystack/repository' import { OmdbSeriesMetadata, type OmdbMovieMetadata, type PiRatFile } from 'common' import { OmdbClientService } from '../metadata-services/omdb-client-service.js' import { ensureSeriesExists } from './ensure-series-exists.js' +import { ensureSeriesLocalizedMetadataExists } from './ensure-localized-metadata-exists.js' +import { mapOmdbSeriesToLocalized } from './map-omdb-to-localized.js' export const ensureOmdbSeriesExists = async ( omdbMeta: OmdbMovieMetadata, @@ -33,8 +35,24 @@ export const ensureOmdbSeriesExists = async ( const { created: [newAdded], } = await omdbSeriesDataSet.add(injector, result.data) - await ensureSeriesExists(newAdded, injector) + await ensureSeriesExists( + { + imdbId: newAdded.imdbID, + year: newAdded.Year, + numberOfSeasons: parseInt(newAdded.totalSeasons, 10) || undefined, + }, + injector, + ) + await ensureSeriesLocalizedMetadataExists(mapOmdbSeriesToLocalized(newAdded), injector) } else { - await ensureSeriesExists(storedResult, injector) + await ensureSeriesExists( + { + imdbId: storedResult.imdbID, + year: storedResult.Year, + numberOfSeasons: parseInt(storedResult.totalSeasons, 10) || undefined, + }, + injector, + ) + await ensureSeriesLocalizedMetadataExists(mapOmdbSeriesToLocalized(storedResult), injector) } } diff --git a/service/src/app-models/media/utils/ensure-series-exists.ts b/service/src/app-models/media/utils/ensure-series-exists.ts index d7898c9d..22cf7b22 100644 --- a/service/src/app-models/media/utils/ensure-series-exists.ts +++ b/service/src/app-models/media/utils/ensure-series-exists.ts @@ -1,18 +1,22 @@ import type { Injector } from '@furystack/inject' import { getDataSetFor } from '@furystack/repository' -import { Series, type OmdbSeriesMetadata } from 'common' +import { Series } from 'common' -export const ensureSeriesExists = async (omdbMeta: OmdbSeriesMetadata, injector: Injector) => { +type SeriesInput = { + imdbId: string + year: string + numberOfSeasons?: number +} + +export const ensureSeriesExists = async (input: SeriesInput, injector: Injector) => { const seriesDataSet = getDataSetFor(injector, Series, 'imdbId') - const existingSeries = await seriesDataSet.get(injector, omdbMeta.imdbID) + const existingSeries = await seriesDataSet.get(injector, input.imdbId) if (!existingSeries) { await seriesDataSet.add(injector, { - imdbId: omdbMeta.imdbID, - title: omdbMeta.Title, - year: omdbMeta.Year, - thumbnailImageUrl: omdbMeta.Poster, - plot: omdbMeta.Plot, + imdbId: input.imdbId, + year: input.year, + numberOfSeasons: input.numberOfSeasons, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }) diff --git a/service/src/app-models/media/utils/ensure-tmdb-movie-exists.ts b/service/src/app-models/media/utils/ensure-tmdb-movie-exists.ts new file mode 100644 index 00000000..d35423e6 --- /dev/null +++ b/service/src/app-models/media/utils/ensure-tmdb-movie-exists.ts @@ -0,0 +1,45 @@ +import type { Injector } from '@furystack/inject' +import { getDataSetFor } from '@furystack/repository' +import { TmdbMovieMetadata } from 'common' + +import type { TmdbMovieDetailsResponse } from '../metadata-services/tmdb-api-types.js' + +export const ensureTmdbMovieExists = async ( + tmdbMovie: TmdbMovieDetailsResponse, + language: string, + injector: Injector, +) => { + const dataSet = getDataSetFor(injector, TmdbMovieMetadata, 'id') + const existing = await dataSet.get(injector, tmdbMovie.id) + if (existing) { + return existing + } + const { + created: [added], + } = await dataSet.add(injector, { + id: tmdbMovie.id, + imdbId: tmdbMovie.imdb_id ?? undefined, + title: tmdbMovie.title, + originalTitle: tmdbMovie.original_title, + overview: tmdbMovie.overview, + releaseDate: tmdbMovie.release_date || undefined, + runtime: tmdbMovie.runtime || undefined, + posterPath: tmdbMovie.poster_path ?? undefined, + backdropPath: tmdbMovie.backdrop_path ?? undefined, + genres: tmdbMovie.genres, + voteAverage: tmdbMovie.vote_average, + voteCount: tmdbMovie.vote_count, + popularity: tmdbMovie.popularity, + originalLanguage: tmdbMovie.original_language, + spokenLanguages: tmdbMovie.spoken_languages, + productionCountries: tmdbMovie.production_countries, + status: tmdbMovie.status || undefined, + tagline: tmdbMovie.tagline || undefined, + budget: tmdbMovie.budget || undefined, + revenue: tmdbMovie.revenue || undefined, + language, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + return added +} diff --git a/service/src/app-models/media/utils/ensure-tmdb-series-exists.ts b/service/src/app-models/media/utils/ensure-tmdb-series-exists.ts new file mode 100644 index 00000000..3a180869 --- /dev/null +++ b/service/src/app-models/media/utils/ensure-tmdb-series-exists.ts @@ -0,0 +1,97 @@ +import type { Injector } from '@furystack/inject' +import { getLogger } from '@furystack/logging' +import { getDataSetFor } from '@furystack/repository' +import { TmdbSeriesMetadata, type PiRatFile } from 'common' + +import type { TmdbTvDetailsResponse } from '../metadata-services/tmdb-api-types.js' +import { TmdbClientService } from '../metadata-services/tmdb-client-service.js' +import { ensureSeriesExists } from './ensure-series-exists.js' +import { ensureSeriesLocalizedMetadataExists } from './ensure-localized-metadata-exists.js' +import { mapTmdbSeriesToLocalized } from './map-tmdb-to-localized.js' + +const storeTmdbSeriesMetadata = async ( + tmdbSeries: TmdbTvDetailsResponse, + imdbId: string, + language: string, + injector: Injector, +) => { + const dataSet = getDataSetFor(injector, TmdbSeriesMetadata, 'id') + const existing = await dataSet.get(injector, tmdbSeries.id) + if (existing) return existing + + const { + created: [added], + } = await dataSet.add(injector, { + id: tmdbSeries.id, + imdbId, + name: tmdbSeries.name, + originalName: tmdbSeries.original_name, + overview: tmdbSeries.overview, + firstAirDate: tmdbSeries.first_air_date || undefined, + posterPath: tmdbSeries.poster_path ?? undefined, + backdropPath: tmdbSeries.backdrop_path ?? undefined, + genres: tmdbSeries.genres, + voteAverage: tmdbSeries.vote_average, + voteCount: tmdbSeries.vote_count, + numberOfSeasons: tmdbSeries.number_of_seasons, + numberOfEpisodes: tmdbSeries.number_of_episodes, + status: tmdbSeries.status || undefined, + originalLanguage: tmdbSeries.original_language, + languages: tmdbSeries.languages, + language, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + return added +} + +/** + * Given an IMDB series ID from a TMDB episode result, fetches and stores the series data via TMDB. + */ +export const ensureTmdbSeriesExists = async ( + seriesImdbId: string, + tmdbSeriesData: TmdbTvDetailsResponse | undefined, + language: string, + injector: Injector, + context?: { file?: PiRatFile }, +) => { + if (tmdbSeriesData) { + await storeTmdbSeriesMetadata(tmdbSeriesData, seriesImdbId, language, injector) + await ensureSeriesExists( + { + imdbId: seriesImdbId, + year: tmdbSeriesData.first_air_date?.slice(0, 4) ?? '', + numberOfSeasons: tmdbSeriesData.number_of_seasons, + }, + injector, + ) + await ensureSeriesLocalizedMetadataExists( + mapTmdbSeriesToLocalized(tmdbSeriesData, seriesImdbId, language), + injector, + ) + return + } + + const tmdbClientService = injector.getInstance(TmdbClientService) + const result = await tmdbClientService.fetchTmdbSeriesMetadata({ imdbId: seriesImdbId }, { file: context?.file }) + if (result.status !== 'success') { + const logger = getLogger(injector).withScope('ensureTmdbSeriesExists') + await logger.warning({ + message: `Could not fetch TMDB series metadata for '${seriesImdbId}' (${result.status})`, + data: { seriesImdbId, status: result.status, file: context?.file }, + }) + return + } + + const imdbId = result.data.external_ids?.imdb_id ?? seriesImdbId + await storeTmdbSeriesMetadata(result.data, imdbId, language, injector) + await ensureSeriesExists( + { + imdbId, + year: result.data.first_air_date?.slice(0, 4) ?? '', + numberOfSeasons: result.data.number_of_seasons, + }, + injector, + ) + await ensureSeriesLocalizedMetadataExists(mapTmdbSeriesToLocalized(result.data, imdbId, language), injector) +} diff --git a/service/src/app-models/media/utils/link-movie.spec.ts b/service/src/app-models/media/utils/link-movie.spec.ts index 347a9e97..797b6200 100644 --- a/service/src/app-models/media/utils/link-movie.spec.ts +++ b/service/src/app-models/media/utils/link-movie.spec.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { PiRatFile } from 'common' import { FfprobeService } from '../../../ffprobe-service.js' import { OmdbClientService } from '../metadata-services/omdb-client-service.js' +import { TmdbClientService } from '../metadata-services/tmdb-client-service.js' import { linkMovie } from './link-movie.js' const mockMovieFileStoreFind = vi.fn() @@ -24,6 +25,11 @@ vi.mock('@furystack/repository', () => ({ find: (...args: unknown[]) => mockOmdbStoreFind(...args) as unknown, } } + if (name === 'Config') { + return { + get: vi.fn().mockResolvedValue(null), + } + } return {} }, })) @@ -39,7 +45,7 @@ vi.mock('@furystack/logging', () => ({ })) vi.mock('./ensure-movie-exists.js', () => ({ - ensureMovieExists: vi.fn().mockResolvedValue({ imdbId: 'tt1234567', title: 'Test Movie' }), + ensureMovieExists: vi.fn().mockResolvedValue({ imdbId: 'tt1234567' }), })) vi.mock('./ensure-omdb-movie-exists.js', () => ({ @@ -50,8 +56,30 @@ vi.mock('./ensure-omdb-series-exists.js', () => ({ ensureOmdbSeriesExists: vi.fn().mockResolvedValue(undefined), })) +vi.mock('./ensure-tmdb-movie-exists.js', () => ({ + ensureTmdbMovieExists: vi.fn().mockResolvedValue({}), +})) + +vi.mock('./ensure-tmdb-series-exists.js', () => ({ + ensureTmdbSeriesExists: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('./ensure-localized-metadata-exists.js', () => ({ + ensureMovieLocalizedMetadataExists: vi.fn().mockResolvedValue({}), + ensureSeriesLocalizedMetadataExists: vi.fn().mockResolvedValue({}), +})) + +vi.mock('./map-omdb-to-localized.js', () => ({ + mapOmdbMovieToLocalized: vi.fn().mockReturnValue({}), +})) + +vi.mock('./map-tmdb-to-localized.js', () => ({ + mapTmdbMovieToLocalized: vi.fn().mockReturnValue({}), +})) + const mockGetFfprobeForPiratFile = vi.fn().mockResolvedValue({ duration: 7200 }) const mockFetchOmdbMovieMetadata = vi.fn() +const mockFetchTmdbMovieMetadata = vi.fn() describe('linkMovie', () => { beforeEach(() => { @@ -76,6 +104,11 @@ describe('linkMovie', () => { OmdbClientService, ) + injector.setExplicitInstance( + { fetchTmdbMovieMetadata: mockFetchTmdbMovieMetadata, config: undefined } as unknown as TmdbClientService, + TmdbClientService, + ) + return injector } @@ -183,6 +216,7 @@ describe('linkMovie', () => { mockMovieFileStoreFind.mockResolvedValue([]) mockOmdbStoreFind.mockResolvedValue([]) mockFetchOmdbMovieMetadata.mockResolvedValue({ status: 'not-found' }) + mockFetchTmdbMovieMetadata.mockResolvedValue({ status: 'not-found' }) await usingAsync(createTestInjector(), async (injector) => { const result = await linkMovie({ @@ -209,10 +243,11 @@ describe('linkMovie', () => { }) }) - it('should return omdb-not-configured when OMDB is not configured', async () => { + it('should return metadata-not-found when all providers are not configured', async () => { mockMovieFileStoreFind.mockResolvedValue([]) mockOmdbStoreFind.mockResolvedValue([]) mockFetchOmdbMovieMetadata.mockResolvedValue({ status: 'not-configured' }) + mockFetchTmdbMovieMetadata.mockResolvedValue({ status: 'not-configured' }) await usingAsync(createTestInjector(), async (injector) => { const result = await linkMovie({ @@ -220,14 +255,15 @@ describe('linkMovie', () => { file: createFile('movies/Another.Movie.2024.mkv'), }) - expect(result.status).toBe('omdb-not-configured') + expect(result.status).toBe('metadata-not-found') }) }) - it('should return omdb-error when OMDB returns an error', async () => { + it('should return metadata-not-found when all providers return errors', async () => { mockMovieFileStoreFind.mockResolvedValue([]) mockOmdbStoreFind.mockResolvedValue([]) mockFetchOmdbMovieMetadata.mockResolvedValue({ status: 'error', error: new Error('Network failure') }) + mockFetchTmdbMovieMetadata.mockResolvedValue({ status: 'error', error: new Error('Network failure') }) await usingAsync(createTestInjector(), async (injector) => { const result = await linkMovie({ @@ -235,7 +271,7 @@ describe('linkMovie', () => { file: createFile('movies/Error.Movie.2024.mkv'), }) - expect(result.status).toBe('omdb-error') + expect(result.status).toBe('metadata-not-found') }) }) }) diff --git a/service/src/app-models/media/utils/link-movie.ts b/service/src/app-models/media/utils/link-movie.ts index a173fe74..fe952202 100644 --- a/service/src/app-models/media/utils/link-movie.ts +++ b/service/src/app-models/media/utils/link-movie.ts @@ -2,19 +2,125 @@ import type { Injector } from '@furystack/inject' import { getLogger } from '@furystack/logging' import { getDataSetFor } from '@furystack/repository' import { + Config, getFallbackMetadata, getFileName, isMovieFile, isSampleFile, MovieFile, OmdbMovieMetadata, + type MetadataProviderConfig, type PiRatFile, } from 'common' import { FfprobeService } from '../../../ffprobe-service.js' import { OmdbClientService } from '../metadata-services/omdb-client-service.js' +import { TmdbClientService } from '../metadata-services/tmdb-client-service.js' import { ensureMovieExists } from './ensure-movie-exists.js' import { ensureOmdbMovieExists } from './ensure-omdb-movie-exists.js' import { ensureOmdbSeriesExists } from './ensure-omdb-series-exists.js' +import { ensureTmdbMovieExists } from './ensure-tmdb-movie-exists.js' +import { ensureTmdbSeriesExists } from './ensure-tmdb-series-exists.js' +import { ensureMovieLocalizedMetadataExists } from './ensure-localized-metadata-exists.js' +import { mapOmdbMovieToLocalized } from './map-omdb-to-localized.js' +import { mapTmdbMovieToLocalized } from './map-tmdb-to-localized.js' + +type LinkResult = + | { status: 'already-linked' } + | { status: 'linked'; movieFile: MovieFile; movie: unknown } + | { status: 'failed' } + | { status: 'not-movie-file' } + | { status: 'rate-limited' } + | { status: 'metadata-not-found' } + | { status: 'provider-not-configured' } + | { status: 'provider-error'; error?: unknown } + +const getProviderPriority = async (injector: Injector): Promise> => { + try { + const configDataSet = getDataSetFor(injector, Config, 'id') + const config = await configDataSet.get(injector, 'METADATA_PROVIDER_CONFIG') + if (config) { + return (config as MetadataProviderConfig).value.priority + } + } catch { + // Config not found, use default + } + return ['omdb', 'tmdb'] +} + +const tryOmdbProvider = async ( + injector: Injector, + { title, year, season, episode }: { title: string; year?: number; season?: number; episode?: number }, + context?: { file?: PiRatFile }, +): Promise => { + const omdbClientService = injector.getInstance(OmdbClientService) + const result = await omdbClientService.fetchOmdbMovieMetadata({ title, year, season, episode }, context) + + if (result.status === 'not-configured') return { status: 'skip' } + if (result.status === 'rate-limited') return { status: 'rate-limited' } + if (result.status === 'not-found') return { status: 'skip' } + if (result.status === 'error') return { status: 'skip' } + + const added = await ensureOmdbMovieExists(result.data, injector) + const movie = await ensureMovieExists( + { + imdbId: added.imdbID, + year: parseInt(added.Year, 10), + season: added.Season ? parseInt(added.Season, 10) : undefined, + episode: added.Episode ? parseInt(added.Episode, 10) : undefined, + type: added.Type, + duration: added.Runtime ? parseInt(added.Runtime, 10) : undefined, + seriesId: added.seriesID, + }, + injector, + ) + await ensureMovieLocalizedMetadataExists(mapOmdbMovieToLocalized(added), injector) + await ensureOmdbSeriesExists(added, injector, context) + + return { status: 'linked', movieFile: undefined as unknown as MovieFile, movie } +} + +const tryTmdbProvider = async ( + injector: Injector, + { title, year, season, episode }: { title: string; year?: number; season?: number; episode?: number }, + context?: { file?: PiRatFile }, +): Promise => { + const tmdbClientService = injector.getInstance(TmdbClientService) + const result = await tmdbClientService.fetchTmdbMovieMetadata({ title, year, season, episode }, context) + + if (result.status === 'not-configured') return { status: 'skip' } + if (result.status === 'rate-limited') return { status: 'rate-limited' } + if (result.status === 'not-found') return { status: 'skip' } + if (result.status === 'error') return { status: 'skip' } + + const { movie: tmdbMovie, series: tmdbSeries } = result.data + const imdbId = tmdbMovie.imdb_id! + const language = tmdbClientService.config?.value.defaultLanguage?.slice(0, 2) ?? 'en' + + await ensureTmdbMovieExists(tmdbMovie, language, injector) + + const movie = await ensureMovieExists( + { + imdbId, + year: tmdbMovie.release_date ? parseInt(tmdbMovie.release_date.slice(0, 4), 10) : undefined, + duration: tmdbMovie.runtime || undefined, + type: tmdbSeries ? 'episode' : 'movie', + seriesId: tmdbSeries?.external_ids?.imdb_id ?? undefined, + season: result.data.episode?.season_number, + episode: result.data.episode?.episode_number, + }, + injector, + ) + await ensureMovieLocalizedMetadataExists(mapTmdbMovieToLocalized(tmdbMovie, language), injector) + + if (tmdbSeries) { + const seriesImdbId = tmdbSeries.external_ids?.imdb_id + if (seriesImdbId) { + await ensureTmdbSeriesExists(seriesImdbId, tmdbSeries, language, injector, context) + } + } + + return { status: 'linked', movieFile: undefined as unknown as MovieFile, movie } +} export const linkMovie = async (options: { injector: Injector; file: PiRatFile }) => { const logger = getLogger(options.injector).withScope('linkMovie') @@ -60,6 +166,7 @@ export const linkMovie = async (options: { injector: Injector; file: PiRatFile } const ffprobeResult = await injector.getInstance(FfprobeService).getFfprobeForPiratFile(file) + // Check existing OMDB metadata first (backward compatibility) const omdbDataSet = getDataSetFor(injector, OmdbMovieMetadata, 'imdbID') const storedResult = await omdbDataSet.find(injector, { filter: { @@ -80,7 +187,19 @@ export const linkMovie = async (options: { injector: Injector; file: PiRatFile } } if (storedResult.length === 1) { - const movie = await ensureMovieExists(storedResult[0], injector) + const movie = await ensureMovieExists( + { + imdbId: storedResult[0].imdbID, + year: parseInt(storedResult[0].Year, 10), + season: storedResult[0].Season ? parseInt(storedResult[0].Season, 10) : undefined, + episode: storedResult[0].Episode ? parseInt(storedResult[0].Episode, 10) : undefined, + type: storedResult[0].Type, + duration: storedResult[0].Runtime ? parseInt(storedResult[0].Runtime, 10) : undefined, + seriesId: storedResult[0].seriesID, + }, + injector, + ) + await ensureMovieLocalizedMetadataExists(mapOmdbMovieToLocalized(storedResult[0]), injector) await ensureOmdbSeriesExists(storedResult[0], injector, { file }) const { @@ -93,66 +212,62 @@ export const linkMovie = async (options: { injector: Injector; file: PiRatFile } }) await logger.debug({ - message: `File ${fileName} linked successfully.`, + message: `File ${fileName} linked successfully (from stored OMDB).`, data: { file, movieFile: newMovieFile, movie }, }) return { status: 'linked', movieFile: newMovieFile, movie } as const } - const omdbClientService = injector.getInstance(OmdbClientService) - const result = await omdbClientService.fetchOmdbMovieMetadata({ title, year, season, episode }, { file }) + // Try providers in priority order + const priority = await getProviderPriority(injector) + const providers: Record = { + omdb: tryOmdbProvider, + tmdb: tryTmdbProvider, + } - if (result.status === 'rate-limited') { - await logger.warning({ - message: `OMDB rate limit reached while linking '${fileName}', skipping.`, - data: { file, title, year }, - }) - return { status: 'rate-limited' } as const + let imdbId: string | undefined + for (const provider of priority) { + const tryProvider = providers[provider] + if (!tryProvider) continue + + const result = await tryProvider(injector, { title, year, season, episode }, { file }) + + if (result.status === 'skip') continue + if (result.status === 'rate-limited') { + await logger.warning({ + message: `${provider.toUpperCase()} rate limit reached while linking '${fileName}', skipping.`, + data: { file, title, year }, + }) + return { status: 'rate-limited' } as const + } + if (result.status === 'linked') { + imdbId = (result.movie as { imdbId?: string })?.imdbId + break + } } - if (result.status === 'not-found') { + if (!imdbId) { await logger.debug({ - message: `No OMDB metadata found for '${fileName}'.`, + message: `No metadata found for '${fileName}' from any provider.`, data: { file, title, year }, }) return { status: 'metadata-not-found' } as const } - if (result.status === 'not-configured') { - await logger.warning({ - message: `OMDB service not configured, cannot link '${fileName}'.`, - data: { file }, - }) - return { status: 'omdb-not-configured' } as const - } - - if (result.status === 'error') { - await logger.error({ - message: `OMDB error while linking '${fileName}'.`, - data: { file, error: result.error }, - }) - return { status: 'omdb-error', error: result.error } as const - } - - const added = await ensureOmdbMovieExists(result.data, injector) - - const movie = await ensureMovieExists(added, injector) - await ensureOmdbSeriesExists(added, injector, { file }) - const { created: [newMovieFile], } = await movieFileDataSet.add(injector, { driveLetter, path, - imdbId: added.imdbID, + imdbId, ffprobe: ffprobeResult, }) await logger.debug({ message: `File ${fileName} linked successfully.`, - data: { file, movieFile: newMovieFile, movie }, + data: { file, movieFile: newMovieFile }, }) - return { status: 'linked', movieFile: newMovieFile, movie } as const + return { status: 'linked', movieFile: newMovieFile, movie: { imdbId } } as const } diff --git a/service/src/app-models/media/utils/map-omdb-to-localized.ts b/service/src/app-models/media/utils/map-omdb-to-localized.ts new file mode 100644 index 00000000..3d490a65 --- /dev/null +++ b/service/src/app-models/media/utils/map-omdb-to-localized.ts @@ -0,0 +1,26 @@ +import type { MovieMetadataLocalized, OmdbMovieMetadata, OmdbSeriesMetadata, SeriesMetadataLocalized } from 'common' + +export const mapOmdbMovieToLocalized = ( + omdbMeta: OmdbMovieMetadata, +): Omit => ({ + movieImdbId: omdbMeta.imdbID, + language: 'en', + title: omdbMeta.Title, + plot: omdbMeta.Plot, + posterUrl: omdbMeta.Poster !== 'N/A' ? omdbMeta.Poster : undefined, + genre: omdbMeta.Genre ? omdbMeta.Genre.split(', ') : undefined, + source: 'omdb', + sourceId: omdbMeta.imdbID, +}) + +export const mapOmdbSeriesToLocalized = ( + omdbMeta: OmdbSeriesMetadata, +): Omit => ({ + seriesImdbId: omdbMeta.imdbID, + language: 'en', + title: omdbMeta.Title, + plot: omdbMeta.Plot, + posterUrl: omdbMeta.Poster !== 'N/A' ? omdbMeta.Poster : undefined, + source: 'omdb', + sourceId: omdbMeta.imdbID, +}) diff --git a/service/src/app-models/media/utils/map-tmdb-to-localized.ts b/service/src/app-models/media/utils/map-tmdb-to-localized.ts new file mode 100644 index 00000000..fe596de5 --- /dev/null +++ b/service/src/app-models/media/utils/map-tmdb-to-localized.ts @@ -0,0 +1,32 @@ +import type { MovieMetadataLocalized, SeriesMetadataLocalized } from 'common' + +import type { TmdbMovieDetailsResponse, TmdbTvDetailsResponse } from '../metadata-services/tmdb-api-types.js' +import { buildTmdbImageUrl } from '../metadata-services/tmdb-client-service.js' + +export const mapTmdbMovieToLocalized = ( + tmdbMovie: TmdbMovieDetailsResponse, + language: string, +): Omit => ({ + movieImdbId: tmdbMovie.imdb_id!, + language, + title: tmdbMovie.title, + plot: tmdbMovie.overview || undefined, + posterUrl: buildTmdbImageUrl(tmdbMovie.poster_path), + genre: tmdbMovie.genres.map((g) => g.name), + source: 'tmdb', + sourceId: String(tmdbMovie.id), +}) + +export const mapTmdbSeriesToLocalized = ( + tmdbSeries: TmdbTvDetailsResponse, + imdbId: string, + language: string, +): Omit => ({ + seriesImdbId: imdbId, + language, + title: tmdbSeries.name, + plot: tmdbSeries.overview || undefined, + posterUrl: buildTmdbImageUrl(tmdbSeries.poster_path), + source: 'tmdb', + sourceId: String(tmdbSeries.id), +}) From 40d6fde1264737760274bad6db2fb096d5991a19 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Wed, 11 Mar 2026 10:00:15 +0100 Subject: [PATCH 04/30] eslint ignore glob updates --- eslint.config.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index ac435801..8036c797 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,10 +15,10 @@ export default tseslint.config( { ignores: [ 'coverage', - '*/node_modules/*', - '*/esm/*', - '*/types/*', - '*/dist/*', + '**/node_modules/**', + '**/esm/**', + '**/types/**', + '**/dist/**', '.yarn/*', 'eslint.config.js', 'prettier.config.js', From 31ad5f1c0901a6962b87c8ef638cb00fd702a327 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Wed, 11 Mar 2026 11:04:45 +0100 Subject: [PATCH 05/30] fs update, eslint config fix, file list fixes --- common/src/utils/media/is-movie-file.ts | 4 +- eslint.config.js | 7 +- .../pages/file-browser/file-context-menu.tsx | 160 --------- frontend/src/pages/file-browser/file-list.tsx | 324 +++++++++++++----- package.json | 2 +- yarn.lock | 10 +- 6 files changed, 253 insertions(+), 254 deletions(-) delete mode 100644 frontend/src/pages/file-browser/file-context-menu.tsx diff --git a/common/src/utils/media/is-movie-file.ts b/common/src/utils/media/is-movie-file.ts index d88da75d..4e0adb03 100644 --- a/common/src/utils/media/is-movie-file.ts +++ b/common/src/utils/media/is-movie-file.ts @@ -1,6 +1,8 @@ +const movieExtensions = ['.mkv', '.webm', '.avi', '.mp4', '.mov'] + export const isMovieFile = (path: string) => { const pathToLower = path.toLowerCase() - if (pathToLower.endsWith('.mkv') || pathToLower.endsWith('.webm') || pathToLower.endsWith('.avi')) { + if (movieExtensions.some((extension) => pathToLower.endsWith(extension))) { return true } return false diff --git a/eslint.config.js b/eslint.config.js index 8036c797..37352f95 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,9 +5,10 @@ import furystack from '@furystack/eslint-plugin' import prettierConfig from 'eslint-config-prettier' import jsdoc from 'eslint-plugin-jsdoc' import playwright from 'eslint-plugin-playwright' +import { defineConfig } from 'eslint/config' import tseslint from 'typescript-eslint' -export default tseslint.config( +export default defineConfig( { ...playwright.configs['flat/recommended'], files: ['e2e'], @@ -29,7 +30,9 @@ export default tseslint.config( eslint.configs.recommended, ...tseslint.configs.recommendedTypeChecked, { - plugins: { furystack }, + plugins: { + furystack, + }, ...furystack.configs.recommendedStrict, }, prettierConfig, diff --git a/frontend/src/pages/file-browser/file-context-menu.tsx b/frontend/src/pages/file-browser/file-context-menu.tsx deleted file mode 100644 index 22cce682..00000000 --- a/frontend/src/pages/file-browser/file-context-menu.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { Shade, createComponent } from '@furystack/shades' -import { ContextMenu, ContextMenuManager, Icon, icons, NotyService } from '@furystack/shades-common-components' -import type { ContextMenuItem } from '@furystack/shades-common-components' -import type { DirectoryEntry } from 'common' -import { getFallbackMetadata, getFullPath, isMovieFile, isSampleFile } from 'common' -import { RelatedMoviesModal } from '../../components/movie-file-management/related-movies-modal.js' -import { MediaApiClient } from '../../services/api-clients/media-api-client.js' -import { FileInfoModal } from './file-info-modal.js' - -export const FileContextMenu = Shade<{ - entry: DirectoryEntry - currentDriveLetter: string - currentPath: string - open: () => void -}>({ - customElementName: 'file-context-menu', - render: ({ children, props, useState, injector, useDisposable }) => { - const { entry, currentDriveLetter, currentPath, open } = props - const [isInfoVisible, setInfoVisible] = useState('isInfoVisible', false) - const [isRelatedMoviesVisible, setRelatedMoviesVisible] = useState('isRelatedMoviesVisible', false) - - const manager = useDisposable('contextMenuManager', () => new ContextMenuManager<() => void>()) - - const path = `${currentDriveLetter}:${currentPath}/${entry.name}` - const movieMetadata = props.entry.isFile && !isSampleFile(path) && isMovieFile(path) && getFallbackMetadata(path) - - const allowScanForMovies = props.entry.isDirectory - - const getItems = (): Array void>> => [ - { - type: 'item', - icon: , - label: 'Open', - data: () => open(), - }, - ...(movieMetadata - ? [ - { - type: 'item' as const, - icon: , - label: `Related movie: ${movieMetadata.title} ${ - movieMetadata.type === 'episode' ? `S${movieMetadata.season}E${movieMetadata.episode}` : '' - }`, - data: () => setRelatedMoviesVisible(true), - }, - { - type: 'item' as const, - icon: , - label: 'Extract Subtitles', - data: () => { - const notyService = injector.getInstance(NotyService) - injector - .getInstance(MediaApiClient) - .call({ - method: 'POST', - action: '/extract-subtitles', - body: { - driveLetter: currentDriveLetter, - path: getFullPath(currentPath, entry.name), - }, - }) - .then(() => { - notyService.emit('onNotyAdded', { - type: 'success', - title: 'Subtitles extracted', - body: <>Subtitles extracted successfully for file {entry.name}, - }) - }) - .catch(() => { - notyService.emit('onNotyAdded', { - type: 'error', - title: 'Subtitles extraction failed', - body: <>Subtitles extraction failed for file {entry.name}, - }) - }) - }, - }, - ] - : []), - ...(allowScanForMovies - ? [ - { - type: 'item' as const, - icon: , - label: 'Scan for movies', - data: () => { - const notyService = injector.getInstance(NotyService) - injector - .getInstance(MediaApiClient) - .call({ - method: 'POST', - action: '/scan-for-movies', - body: { - root: { - driveLetter: currentDriveLetter, - path: getFullPath(currentPath, entry.name), - }, - autoExtractSubtitles: false, - }, - }) - .then(() => { - notyService.emit('onNotyAdded', { - type: 'success', - title: 'Movies scanned', - body: <>Movies scanned successfully for folder {entry.name}, - }) - }) - .catch(() => { - notyService.emit('onNotyAdded', { - type: 'error', - title: 'Movies scanning failed', - body: <>Movies scanning failed for folder {entry.name}, - }) - }) - }, - }, - ] - : []), - { - type: 'item', - icon: , - label: 'Show file info', - data: () => setInfoVisible(true), - }, - ] - - return ( - <> -
{ - ev.preventDefault() - manager.open({ - position: { x: ev.clientX, y: ev.clientY }, - items: getItems(), - }) - }} - > - {children} -
- action()} /> - setInfoVisible(false)} - currentDriveLetter={currentDriveLetter} - currentPath={currentPath} - /> - {movieMetadata && ( - setRelatedMoviesVisible(false)} - /> - )} - - ) - }, -}) diff --git a/frontend/src/pages/file-browser/file-list.tsx b/frontend/src/pages/file-browser/file-list.tsx index b4d3bd3b..052ef1bf 100644 --- a/frontend/src/pages/file-browser/file-list.tsx +++ b/frontend/src/pages/file-browser/file-list.tsx @@ -1,17 +1,28 @@ import type { FindOptions } from '@furystack/core' import { createComponent, Shade } from '@furystack/shades' -import type { CollectionService } from '@furystack/shades-common-components' -import { DataGrid, NotyService, SelectionCell } from '@furystack/shades-common-components' +import type { CollectionService, ContextMenuItem } from '@furystack/shades-common-components' +import { + ContextMenu, + ContextMenuManager, + DataGrid, + Icon, + icons, + NotyService, + SelectionCell, +} from '@furystack/shades-common-components' import { PathHelper } from '@furystack/utils' -import { getFullPath, type DirectoryEntry } from 'common' -import { environmentOptions } from '../../utils/environment-options.js' +import { getFallbackMetadata, getFullPath, isMovieFile, isSampleFile, type DirectoryEntry } from 'common' + +import { RelatedMoviesModal } from '../../components/movie-file-management/related-movies-modal.js' +import { MediaApiClient } from '../../services/api-clients/media-api-client.js' import { DrivesService } from '../../services/drives-service.js' import { getErrorMessage } from '../../services/get-error-message.js' import { SessionService } from '../../services/session.js' +import { environmentOptions } from '../../utils/environment-options.js' import { triggerDownload } from '../../utils/trigger-download.js' import { BreadCrumbs } from './breadcrumbs.js' import { DirectoryEntryIcon } from './directory-entry-icon.js' -import { FileContextMenu } from './file-context-menu.js' +import { FileInfoModal } from './file-info-modal.js' export const FileList = Shade<{ currentDriveLetter: string @@ -47,6 +58,12 @@ export const FileList = Shade<{ {}, ) + const [activeEntry, setActiveEntry] = useState('activeEntry', null) + const [isInfoVisible, setInfoVisible] = useState('isInfoVisible', false) + const [isRelatedMoviesVisible, setRelatedMoviesVisible] = useState('isRelatedMoviesVisible', false) + + const contextMenuManager = useDisposable('contextMenuManager', () => new ContextMenuManager<() => void>()) + const activate = () => { const focused = service.focusedEntry.getValue() const isComponentFocused = service.hasFocus.getValue() @@ -55,6 +72,117 @@ export const FileList = Shade<{ } } + const getContextMenuItems = (entry: DirectoryEntry): Array void>> => { + const path = `${currentDriveLetter}:${currentPath}/${entry.name}` + const movieMetadata = entry.isFile && !isSampleFile(path) && isMovieFile(path) && getFallbackMetadata(path) + const allowScanForMovies = entry.isDirectory + + return [ + { + type: 'item', + icon: , + label: 'Open', + data: () => props.onActivate?.(entry), + }, + ...(movieMetadata + ? [ + { + type: 'item' as const, + icon: , + label: `Related movie: ${movieMetadata.title} ${ + movieMetadata.type === 'episode' ? `S${movieMetadata.season}E${movieMetadata.episode}` : '' + }`, + data: () => setRelatedMoviesVisible(true), + }, + { + type: 'item' as const, + icon: , + label: 'Extract Subtitles', + data: () => { + injector + .getInstance(MediaApiClient) + .call({ + method: 'POST', + action: '/extract-subtitles', + body: { + driveLetter: currentDriveLetter, + path: getFullPath(currentPath, entry.name), + }, + }) + .then(() => { + notyService.emit('onNotyAdded', { + type: 'success', + title: 'Subtitles extracted', + body: <>Subtitles extracted successfully for file {entry.name}, + }) + }) + .catch(() => { + notyService.emit('onNotyAdded', { + type: 'error', + title: 'Subtitles extraction failed', + body: <>Subtitles extraction failed for file {entry.name}, + }) + }) + }, + }, + ] + : []), + ...(allowScanForMovies + ? [ + { + type: 'item' as const, + icon: , + label: 'Scan for movies', + data: () => { + injector + .getInstance(MediaApiClient) + .call({ + method: 'POST', + action: '/scan-for-movies', + body: { + root: { + driveLetter: currentDriveLetter, + path: getFullPath(currentPath, entry.name), + }, + autoExtractSubtitles: false, + }, + }) + .then(() => { + notyService.emit('onNotyAdded', { + type: 'success', + title: 'Movies scanned', + body: <>Movies scanned successfully for folder {entry.name}, + }) + }) + .catch(() => { + notyService.emit('onNotyAdded', { + type: 'error', + title: 'Movies scanning failed', + body: <>Movies scanning failed for folder {entry.name}, + }) + }) + }, + }, + ] + : []), + { + type: 'item', + icon: , + label: 'Show file info', + data: () => setInfoVisible(true), + }, + ] + } + + const handleContextMenu = (entry: DirectoryEntry, ev: MouseEvent) => { + ev.preventDefault() + setActiveEntry(entry) + contextMenuManager.open({ + position: { x: ev.clientX, y: ev.clientY }, + items: getContextMenuItems(entry), + }) + } + useDisposable('keypressListener', () => { const listener = (ev: KeyboardEvent) => { if (ev.key === 'Enter') { @@ -102,85 +230,92 @@ export const FileList = Shade<{ } }) - return ( -
{ - ev.preventDefault() - }} - ondrop={async (ev) => { - ev.preventDefault() - if (ev.dataTransfer?.files) { - const session = injector.getInstance(SessionService) - if (!(await session.isAuthorized('admin'))) { - return notyService.emit('onNotyAdded', { - type: 'warning', - title: 'Not authorized', - body: <>You are not authorized to upload files, - }) - } + const activeEntryPath = activeEntry ? `${currentDriveLetter}:${currentPath}/${activeEntry.name}` : '' + const activeMovieMetadata = + activeEntry?.isFile && + activeEntryPath && + !isSampleFile(activeEntryPath) && + isMovieFile(activeEntryPath) && + getFallbackMetadata(activeEntryPath) - const formData = new FormData() - for (const file of ev.dataTransfer.files) { - formData.append('uploads', file) - } - await fetch( - `${environmentOptions.serviceUrl}/drives/volumes/${encodeURIComponent( - currentDriveLetter, - )}/${encodeURIComponent(currentPath)}/upload`, - { - method: 'POST', - credentials: 'include', - body: formData, - }, - ) - .then(() => { - notyService.emit('onNotyAdded', { - type: 'success', - title: 'Upload completed', - body: <>The files are upploaded succesfully, + return ( + <> +
{ + ev.preventDefault() + }} + ondrop={async (ev) => { + ev.preventDefault() + if (ev.dataTransfer?.files) { + const session = injector.getInstance(SessionService) + if (!(await session.isAuthorized('admin'))) { + return notyService.emit('onNotyAdded', { + type: 'warning', + title: 'Not authorized', + body: <>You are not authorized to upload files, }) - }) - .catch((err) => - notyService.emit('onNotyAdded', { - title: 'Upload failed', - body: <>{getErrorMessage(err)}, - type: 'error', - }), + } + + const formData = new FormData() + for (const file of ev.dataTransfer.files) { + formData.append('uploads', file) + } + await fetch( + `${environmentOptions.serviceUrl}/drives/volumes/${encodeURIComponent( + currentDriveLetter, + )}/${encodeURIComponent(currentPath)}/upload`, + { + method: 'POST', + credentials: 'include', + body: formData, + }, ) - } - }} - ondblclick={activate} - onkeydown={(ev) => { - if (ev.key === 'Enter') { - activate() - } - }} - > - ( - - ), + .then(() => { + notyService.emit('onNotyAdded', { + type: 'success', + title: 'Upload completed', + body: <>The files are upploaded succesfully, + }) + }) + .catch((err) => + notyService.emit('onNotyAdded', { + title: 'Upload failed', + body: <>{getErrorMessage(err)}, + type: 'error', + }), + ) + } }} - styles={{}} - rowComponents={{ - name: (entry) => ( - -
+ ondblclick={activate} + onkeydown={(ev) => { + if (ev.key === 'Enter') { + activate() + } + }} + > + ( + + ), + }} + styles={{}} + rowComponents={{ + name: (entry) => ( +
handleContextMenu(entry, ev)} + >
@@ -189,11 +324,30 @@ export const FileList = Shade<{
{entry.name}
-
- ), - }} - /> -
+ ), + }} + /> +
+ action()} /> + {activeEntry && ( + setInfoVisible(false)} + currentDriveLetter={currentDriveLetter} + currentPath={currentPath} + /> + )} + {activeEntry && activeMovieMetadata && ( + setRelatedMoviesVisible(false)} + /> + )} + ) }, }) diff --git a/package.json b/package.json index 9777d87b..f04ecad7 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@furystack/eslint-plugin": "^2.1.0", + "@furystack/eslint-plugin": "^2.1.1", "@furystack/yarn-plugin-changelog": "^1.0.6", "@playwright/test": "^1.58.2", "@types/jsdom": "^28.0.0", diff --git a/yarn.lock b/yarn.lock index fbebbc13..5fc0983f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -755,14 +755,14 @@ __metadata: languageName: node linkType: hard -"@furystack/eslint-plugin@npm:^2.1.0": - version: 2.1.0 - resolution: "@furystack/eslint-plugin@npm:2.1.0" +"@furystack/eslint-plugin@npm:^2.1.1": + version: 2.1.1 + resolution: "@furystack/eslint-plugin@npm:2.1.1" dependencies: "@typescript-eslint/utils": "npm:^8.56.1" peerDependencies: eslint: ">=9.0.0" - checksum: 10c0/2e3ed31bb989fa74a28544e3ed7742fb395999f10bfb914369368068e4c278ab527efa3d8fcd4cd2afc535f354aeb363e3bc9e3aaa22cae64405fa583638605e + checksum: 10c0/070c3dbf64c243930a81f7764b2d18a4d413c42fea634e0cd85d7e8a98ae0a2367e1249075afc75c3fc48b02c34b2a3660f988b1da6ef1f2e7482a943198eab0 languageName: node linkType: hard @@ -6983,7 +6983,7 @@ __metadata: resolution: "pi-rat@workspace:." dependencies: "@eslint/js": "npm:^10.0.1" - "@furystack/eslint-plugin": "npm:^2.1.0" + "@furystack/eslint-plugin": "npm:^2.1.1" "@furystack/yarn-plugin-changelog": "npm:^1.0.6" "@playwright/test": "npm:^1.58.2" "@types/jsdom": "npm:^28.0.0" From 814fe7faa78d0751cf76a44fcd01fcf5bdb36ed7 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Wed, 11 Mar 2026 11:06:08 +0100 Subject: [PATCH 06/30] legacy navigation fixes --- .../components/dashboard/device-availability.tsx | 14 ++------------ frontend/src/components/dashboard/index.tsx | 14 +++----------- frontend/src/components/dashboard/movie-widget.tsx | 14 +++----------- 3 files changed, 8 insertions(+), 34 deletions(-) diff --git a/frontend/src/components/dashboard/device-availability.tsx b/frontend/src/components/dashboard/device-availability.tsx index de17a18c..f3979f53 100644 --- a/frontend/src/components/dashboard/device-availability.tsx +++ b/frontend/src/components/dashboard/device-availability.tsx @@ -1,12 +1,11 @@ import type { CacheWithValue } from '@furystack/cache' -import { serializeToQueryString } from '@furystack/rest' import { Shade, createComponent } from '@furystack/shades' import { CacheView, Skeleton } from '@furystack/shades-common-components' import type { Device, DeviceAvailability as DeviceAvailabilityProps, Icon as IconType } from 'common' import { AppLink } from '../../routes/index.js' -import { navigateToRoute } from '../../utils/navigate-to-route.js' import { IotDevicesService } from '../../services/iot-devices-service.js' import { SessionService } from '../../services/session.js' +import { navigateToRoute } from '../../utils/navigate-to-route.js' import { DynamicIcon } from '../dynamic-icon.js' import { DeviceAvailabilityPanel } from '../iot-devices/device-availability-panel.js' import { WidgetCard } from './widget-card.js' @@ -42,16 +41,7 @@ const DeviceAvailabilityContent = Shade<{ onclick={(ev) => { ev.preventDefault() ev.stopImmediatePropagation() - navigateToRoute( - injector, - '/entities/iot-devices', - {}, - { - queryString: serializeToQueryString({ - gedst: { mode: 'edit', currentId: device.name }, - }), - }, - ) + navigateToRoute(injector, '/entities/iot-devices/edit/:id', { id: device.name }) }} title="Edit device details" > diff --git a/frontend/src/components/dashboard/index.tsx b/frontend/src/components/dashboard/index.tsx index b5a15079..9fea60b3 100644 --- a/frontend/src/components/dashboard/index.tsx +++ b/frontend/src/components/dashboard/index.tsx @@ -1,10 +1,9 @@ -import { serializeToQueryString } from '@furystack/rest' import { Shade, createComponent } from '@furystack/shades' -import { ContextMenu, ContextMenuManager } from '@furystack/shades-common-components' import type { ContextMenuItem } from '@furystack/shades-common-components' +import { ContextMenu, ContextMenuManager } from '@furystack/shades-common-components' import type { Dashboard as DashboardData } from 'common' -import { navigateToRoute } from '../../utils/navigate-to-route.js' import { SessionService } from '../../services/session.js' +import { navigateToRoute } from '../../utils/navigate-to-route.js' import { Widget } from './widget.js' export const Dashboard = Shade({ @@ -22,14 +21,7 @@ export const Dashboard = Shade({ icon: 📝, label: 'Edit this dashboard', data: () => { - navigateToRoute( - injector, - '/entities/dashboards', - {}, - { - queryString: serializeToQueryString({ gedst: { currentId: props.id, mode: 'edit' } }), - }, - ) + navigateToRoute(injector, '/entities/dashboards/edit/:id', { id: props.id }) }, }, ] diff --git a/frontend/src/components/dashboard/movie-widget.tsx b/frontend/src/components/dashboard/movie-widget.tsx index 02923ba9..58ecf176 100644 --- a/frontend/src/components/dashboard/movie-widget.tsx +++ b/frontend/src/components/dashboard/movie-widget.tsx @@ -1,16 +1,15 @@ import type { CacheWithValue } from '@furystack/cache' import { isLoadedCacheResult } from '@furystack/cache' -import { serializeToQueryString } from '@furystack/rest' -import { LazyLoad, Shade, createComponent } from '@furystack/shades' +import { createComponent, LazyLoad, Shade } from '@furystack/shades' import { CacheView, cssVariableTheme, Skeleton } from '@furystack/shades-common-components' import type { Movie, MovieMetadataLocalized } from 'common' import { AppLink } from '../../routes/index.js' -import { navigateToRoute } from '../../utils/navigate-to-route.js' import { LocalizedMetadataService } from '../../services/localized-metadata-service.js' import { MovieFilesService } from '../../services/movie-files-service.js' import { MoviesService } from '../../services/movies-service.js' import { SessionService } from '../../services/session.js' import { WatchProgressService } from '../../services/watch-progress-service.js' +import { navigateToRoute } from '../../utils/navigate-to-route.js' import { WidgetCard } from './widget-card.js' const MovieWidgetContent = Shade<{ @@ -72,14 +71,7 @@ const MovieWidgetContent = Shade<{ onclick={(ev) => { ev.preventDefault() ev.stopImmediatePropagation() - navigateToRoute( - injector, - '/entities/movies', - {}, - { - queryString: serializeToQueryString({ gedst: { mode: 'edit', currentId: imdbId } }), - }, - ) + navigateToRoute(injector, '/entities/movies/edit/:id', { id: imdbId }) }} title="Edit movie details" > From 3a07b93017fa09907f849429780562821ed668f7 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Wed, 11 Mar 2026 11:10:35 +0100 Subject: [PATCH 07/30] encoding fix --- .../movie-player-v2/movie-player-service.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts b/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts index 1fd14d3f..2fbd3a9f 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts @@ -1,16 +1,17 @@ import type { ScopedLogger } from '@furystack/logging' import { ObservableValue } from '@furystack/utils' -import type { - AudioTrackInfo, - FfprobeData, - PiRatFile, - PlaybackInfoResponse, - PlaybackMode, - SubtitleTrackInfo, +import { + encode, + type AudioTrackInfo, + type FfprobeData, + type PiRatFile, + type PlaybackInfoResponse, + type PlaybackMode, + type SubtitleTrackInfo, } from 'common' import type Hls from 'hls.js' -import { environmentOptions } from '../../../utils/environment-options.js' import type { MediaApiClient } from '../../../services/api-clients/media-api-client.js' +import { environmentOptions } from '../../../utils/environment-options.js' export const videoCodecs = { h264: 'avc1.42E01E', @@ -197,7 +198,7 @@ export class MoviePlayerService implements AsyncDisposable { private async startHlsPlayback(videoElement: HTMLVideoElement) { const mode = this.playbackMode.getValue() const hlsUrl = this.toServiceUrl( - `/api/media/files/${encodeURIComponent(this.file.driveLetter)}/${encodeURIComponent(this.file.path)}/master.m3u8?mode=${encodeURIComponent(mode)}`, + `/api/media/files/${encodeURIComponent(this.file.driveLetter)}/${encodeURIComponent(this.file.path)}/master.m3u8?mode=${encode(mode)}`, ) const HlsModule = await loadHls() From a7f246b24ed2953d094e28e357ea7b4a65634a3e Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Wed, 11 Mar 2026 11:47:26 +0100 Subject: [PATCH 08/30] fixes and improvements --- frontend/src/pages/file-browser/file-list.tsx | 111 +++++++++++--- .../movie-player-v2-component.tsx | 47 ++++-- .../services/transcoding-session.spec.ts | 89 +++++++++++ .../media/services/transcoding-session.ts | 24 ++- .../utils/extract-imdb-id-from-nfo.spec.ts | 119 +++++++++++++++ .../media/utils/extract-imdb-id-from-nfo.ts | 37 +++++ .../utils/extract-imdb-id-from-tags.spec.ts | 77 ++++++++++ .../media/utils/extract-imdb-id-from-tags.ts | 13 ++ .../app-models/media/utils/link-movie.spec.ts | 139 +++++++++++++++++- .../src/app-models/media/utils/link-movie.ts | 57 ++++++- 10 files changed, 673 insertions(+), 40 deletions(-) create mode 100644 service/src/app-models/media/utils/extract-imdb-id-from-nfo.spec.ts create mode 100644 service/src/app-models/media/utils/extract-imdb-id-from-nfo.ts create mode 100644 service/src/app-models/media/utils/extract-imdb-id-from-tags.spec.ts create mode 100644 service/src/app-models/media/utils/extract-imdb-id-from-tags.ts diff --git a/frontend/src/pages/file-browser/file-list.tsx b/frontend/src/pages/file-browser/file-list.tsx index 052ef1bf..fec531c2 100644 --- a/frontend/src/pages/file-browser/file-list.tsx +++ b/frontend/src/pages/file-browser/file-list.tsx @@ -2,9 +2,11 @@ import type { FindOptions } from '@furystack/core' import { createComponent, Shade } from '@furystack/shades' import type { CollectionService, ContextMenuItem } from '@furystack/shades-common-components' import { + Button, ContextMenu, ContextMenuManager, DataGrid, + Dialog, Icon, icons, NotyService, @@ -61,9 +63,24 @@ export const FileList = Shade<{ const [activeEntry, setActiveEntry] = useState('activeEntry', null) const [isInfoVisible, setInfoVisible] = useState('isInfoVisible', false) const [isRelatedMoviesVisible, setRelatedMoviesVisible] = useState('isRelatedMoviesVisible', false) + const [isDeleteDialogVisible, setDeleteDialogVisible] = useState('isDeleteDialogVisible', false) + const [entriesToDelete, setEntriesToDelete] = useState('entriesToDelete', []) + const [isDeleting, setDeleting] = useState('isDeleting', false) const contextMenuManager = useDisposable('contextMenuManager', () => new ContextMenuManager<() => void>()) + const collectDeleteTargets = (): DirectoryEntry[] => { + const selection = service.selection.getValue() + if (selection.length > 0) { + return selection.filter((e) => e.name !== '..') + } + const focused = service.focusedEntry.getValue() + if (focused && focused.name !== '..') { + return [focused] + } + return [] + } + const activate = () => { const focused = service.focusedEntry.getValue() const isComponentFocused = service.hasFocus.getValue() @@ -171,6 +188,25 @@ export const FileList = Shade<{ label: 'Show file info', data: () => setInfoVisible(true), }, + ...(entry.name !== '..' + ? [ + { + type: 'separator' as const, + }, + { + type: 'item' as const, + icon: , + label: 'Delete', + data: () => { + const targets = collectDeleteTargets() + if (targets.length > 0) { + setEntriesToDelete(targets) + setDeleteDialogVisible(true) + } + }, + }, + ] + : []), ] } @@ -183,6 +219,33 @@ export const FileList = Shade<{ }) } + const handleDeleteConfirm = async () => { + setDeleting(true) + try { + for (const entry of entriesToDelete) { + await drivesService.removeFile({ + letter: currentDriveLetter, + path: getFullPath(currentPath, entry.name), + }) + } + notyService.emit('onNotyAdded', { + type: 'success', + title: 'Delete completed', + body: <>{entriesToDelete.length} item(s) deleted successfully, + }) + } catch (err) { + notyService.emit('onNotyAdded', { + type: 'error', + title: 'Delete failed', + body: <>{getErrorMessage(err)}, + }) + } finally { + setDeleting(false) + setDeleteDialogVisible(false) + setEntriesToDelete([]) + } + } + useDisposable('keypressListener', () => { const listener = (ev: KeyboardEvent) => { if (ev.key === 'Enter') { @@ -203,24 +266,10 @@ export const FileList = Shade<{ } if (ev.key === 'Delete') { - const focused = service.focusedEntry.getValue() - if (focused) { - drivesService - .removeFile({ letter: currentDriveLetter, path: getFullPath(currentPath, focused.name) }) - .then(() => { - notyService.emit('onNotyAdded', { - type: 'success', - title: 'Delete completed', - body: <>The file is deleted succesfully, - }) - }) - .catch((err) => - notyService.emit('onNotyAdded', { - title: 'Delete failed', - body: <>{getErrorMessage(err)}, - type: 'error', - }), - ) + const targets = collectDeleteTargets() + if (targets.length > 0) { + setEntriesToDelete(targets) + setDeleteDialogVisible(true) } } } @@ -347,6 +396,32 @@ export const FileList = Shade<{ onClose={() => setRelatedMoviesVisible(false)} /> )} + setDeleteDialogVisible(false)} + actions={ + <> + + + + } + > +

+ Are you sure you want to delete the following {entriesToDelete.length} item(s)? +

+
    + {entriesToDelete.map((e) => ( +
  • + {e.name} +
  • + ))} +
+
) }, diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx index a423487e..2e247512 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx @@ -26,23 +26,41 @@ export const MoviePlayerV2 = Shade({ const { driveLetter, path } = props.file const watchProgressService = injector.getInstance(WatchProgressService) useDisposable('watchProgressUpdater', () => { + const createUpdater = (video: HTMLVideoElement) => + new WatchProgressUpdater({ + intervalMs: 10 * 1000, + onSave: async (progress) => { + void watchProgressService.updateWatchEntry({ + completed: (mediaService.playbackInfo.getValue()?.duration ?? video.duration) - progress < 10, + driveLetter, + path, + watchedSeconds: progress, + }) + }, + saveTresholdSeconds: 10, + videoElement: video, + }) + const video = videoRef.current - if (!video) { - return { [Symbol.asyncDispose]: async () => {} } + if (video) { + return createUpdater(video) } - return new WatchProgressUpdater({ - intervalMs: 10 * 1000, - onSave: async (progress) => { - void watchProgressService.updateWatchEntry({ - completed: video.duration - progress < 10, - driveLetter, - path, - watchedSeconds: progress, - }) - }, - saveTresholdSeconds: 10, - videoElement: video, + + let updater: WatchProgressUpdater | null = null + const frameId = requestAnimationFrame(() => { + const deferredVideo = videoRef.current + if (deferredVideo) { + updater = createUpdater(deferredVideo) + } }) + return { + [Symbol.asyncDispose]: async () => { + cancelAnimationFrame(frameId) + if (updater) { + await updater[Symbol.asyncDispose]() + } + }, + } }) const { watchProgress, file } = props @@ -86,6 +104,7 @@ export const MoviePlayerV2 = Shade({ }} > { }) }) + it('should remove all resolution variants when resolution is undefined', async () => { + const mockProcess = createMockProcess() + mockSpawn.mockReturnValue(mockProcess) + + await usingAsync(new Injector(), async (injector) => { + injector.setExplicitInstance( + { getFfprobeForPiratFile: vi.fn().mockResolvedValue(mockFfprobe) } as unknown as FfprobeService, + FfprobeService, + ) + injector.setExplicitInstance( + { getEncoder: vi.fn().mockResolvedValue('libx264') } as unknown as HwAccelDetector, + HwAccelDetector, + ) + + const service = injector.getInstance(TranscodingSessionService) + try { + await service.getOrCreateSession({ + driveLetter: 'A', + path: 'test.mkv', + mode: 'transcode', + resolution: '1080p', + }) + await service.getOrCreateSession({ + driveLetter: 'A', + path: 'test.mkv', + mode: 'transcode', + resolution: '720p', + }) + await service.getOrCreateSession({ + driveLetter: 'A', + path: 'other.mkv', + mode: 'transcode', + resolution: '1080p', + }) + + expect(service.getActiveSessionCount()).toBe(3) + + service.removeSession('A', 'test.mkv', 'transcode', 0) + expect(service.getActiveSessionCount()).toBe(1) + expect(service.getSession('A', 'test.mkv', 'transcode', 0, '1080p')).toBeUndefined() + expect(service.getSession('A', 'test.mkv', 'transcode', 0, '720p')).toBeUndefined() + expect(service.getSession('A', 'other.mkv', 'transcode', 0, '1080p')).toBeDefined() + } finally { + service.dispose() + } + }) + }) + + it('should remove only exact match when resolution is specified', async () => { + const mockProcess = createMockProcess() + mockSpawn.mockReturnValue(mockProcess) + + await usingAsync(new Injector(), async (injector) => { + injector.setExplicitInstance( + { getFfprobeForPiratFile: vi.fn().mockResolvedValue(mockFfprobe) } as unknown as FfprobeService, + FfprobeService, + ) + injector.setExplicitInstance( + { getEncoder: vi.fn().mockResolvedValue('libx264') } as unknown as HwAccelDetector, + HwAccelDetector, + ) + + const service = injector.getInstance(TranscodingSessionService) + try { + await service.getOrCreateSession({ + driveLetter: 'A', + path: 'test.mkv', + mode: 'transcode', + resolution: '1080p', + }) + await service.getOrCreateSession({ + driveLetter: 'A', + path: 'test.mkv', + mode: 'transcode', + resolution: '720p', + }) + + expect(service.getActiveSessionCount()).toBe(2) + + service.removeSession('A', 'test.mkv', 'transcode', 0, '1080p') + expect(service.getActiveSessionCount()).toBe(1) + expect(service.getSession('A', 'test.mkv', 'transcode', 0, '1080p')).toBeUndefined() + expect(service.getSession('A', 'test.mkv', 'transcode', 0, '720p')).toBeDefined() + } finally { + service.dispose() + } + }) + }) + it('should handle removeSession on non-existent session gracefully', async () => { await usingAsync(new Injector(), async (injector) => { const service = injector.getInstance(TranscodingSessionService) diff --git a/service/src/app-models/media/services/transcoding-session.ts b/service/src/app-models/media/services/transcoding-session.ts index f18b665e..86121bd6 100644 --- a/service/src/app-models/media/services/transcoding-session.ts +++ b/service/src/app-models/media/services/transcoding-session.ts @@ -415,11 +415,25 @@ export class TranscodingSessionService { audioTrackId: number = 0, resolution?: string, ) { - const key = this.buildSessionKey(driveLetter, path, mode, audioTrackId, resolution) - const session = this.sessions.get(key) - if (session) { - this.destroySession(session) - this.sessions.delete(key) + if (resolution !== undefined) { + const key = this.buildSessionKey(driveLetter, path, mode, audioTrackId, resolution) + const session = this.sessions.get(key) + if (session) { + this.destroySession(session) + this.sessions.delete(key) + } + return + } + + const prefix = `${driveLetter}:${path}:${mode}:${audioTrackId}:` + const keysToRemove = [...this.sessions.keys()].filter((k) => k.startsWith(prefix)) + for (const key of keysToRemove) { + const session = this.sessions.get(key) + if (session) { + void this.logger.verbose({ message: `Removing session: ${key}` }) + this.destroySession(session) + this.sessions.delete(key) + } } } diff --git a/service/src/app-models/media/utils/extract-imdb-id-from-nfo.spec.ts b/service/src/app-models/media/utils/extract-imdb-id-from-nfo.spec.ts new file mode 100644 index 00000000..8c1688cf --- /dev/null +++ b/service/src/app-models/media/utils/extract-imdb-id-from-nfo.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest' +import { extractImdbIdFromNfoFiles } from './extract-imdb-id-from-nfo.js' + +const mockReaddir = vi.fn() +const mockReadFile = vi.fn() + +vi.mock('fs/promises', () => ({ + readdir: (...args: unknown[]) => mockReaddir(...args) as unknown, + readFile: (...args: unknown[]) => mockReadFile(...args) as unknown, +})) + +describe('extractImdbIdFromNfoFiles', () => { + it('should return empty when directory cannot be read', async () => { + mockReaddir.mockRejectedValue(new Error('ENOENT')) + + const result = await extractImdbIdFromNfoFiles('/mnt/media/movies', 'movies') + + expect(result).toEqual({ nfoFiles: [] }) + }) + + it('should return empty when no .nfo files exist', async () => { + mockReaddir.mockResolvedValue(['movie.mkv', 'movie.srt', 'readme.txt']) + + const result = await extractImdbIdFromNfoFiles('/mnt/media/movies', 'movies') + + expect(result).toEqual({ nfoFiles: [] }) + }) + + it('should extract IMDB ID from .nfo file containing a plain ID', async () => { + mockReaddir.mockResolvedValue(['movie.nfo', 'movie.mkv']) + mockReadFile.mockResolvedValue('tt1234567') + + const result = await extractImdbIdFromNfoFiles('/mnt/media/movies', 'movies') + + expect(result.imdbId).toBe('tt1234567') + expect(result.nfoFiles).toEqual(['movies/movie.nfo']) + }) + + it('should extract IMDB ID from .nfo file containing an IMDB URL', async () => { + mockReaddir.mockResolvedValue(['movie.nfo']) + mockReadFile.mockResolvedValue('https://www.imdb.com/title/tt9876543/') + + const result = await extractImdbIdFromNfoFiles('/mnt/media/movies', 'movies') + + expect(result.imdbId).toBe('tt9876543') + }) + + it('should extract IMDB ID from Kodi-style XML .nfo file', async () => { + mockReaddir.mockResolvedValue(['movie.nfo']) + mockReadFile.mockResolvedValue(` + + Some Movie + tt7654321 + 2024 +`) + + const result = await extractImdbIdFromNfoFiles('/mnt/media/movies', 'movies') + + expect(result.imdbId).toBe('tt7654321') + }) + + it('should handle .nfo files case-insensitively', async () => { + mockReaddir.mockResolvedValue(['Movie.NFO']) + mockReadFile.mockResolvedValue('tt1111111') + + const result = await extractImdbIdFromNfoFiles('/mnt/media/movies', 'movies') + + expect(result.imdbId).toBe('tt1111111') + expect(result.nfoFiles).toEqual(['movies/Movie.NFO']) + }) + + it('should return nfoFiles without imdbId when .nfo has no IMDB ID', async () => { + mockReaddir.mockResolvedValue(['movie.nfo']) + mockReadFile.mockResolvedValue('Just some random text without any ID') + + const result = await extractImdbIdFromNfoFiles('/mnt/media/movies', 'movies') + + expect(result.imdbId).toBeUndefined() + expect(result.nfoFiles).toEqual(['movies/movie.nfo']) + }) + + it('should skip unreadable .nfo files and try the next one', async () => { + mockReaddir.mockResolvedValue(['broken.nfo', 'good.nfo']) + mockReadFile.mockRejectedValueOnce(new Error('EACCES')).mockResolvedValueOnce('tt2222222') + + const result = await extractImdbIdFromNfoFiles('/mnt/media/movies', 'movies') + + expect(result.imdbId).toBe('tt2222222') + expect(result.nfoFiles).toEqual(['movies/broken.nfo', 'movies/good.nfo']) + }) + + it('should return all nfo files even when all are unreadable', async () => { + mockReaddir.mockResolvedValue(['a.nfo', 'b.nfo']) + mockReadFile.mockRejectedValue(new Error('EACCES')) + + const result = await extractImdbIdFromNfoFiles('/mnt/media/movies', 'movies') + + expect(result.imdbId).toBeUndefined() + expect(result.nfoFiles).toEqual(['movies/a.nfo', 'movies/b.nfo']) + }) + + it('should normalize backslashes in relatedFiles paths', async () => { + mockReaddir.mockResolvedValue(['movie.nfo']) + mockReadFile.mockResolvedValue('tt3333333') + + const result = await extractImdbIdFromNfoFiles('/mnt/media/movies/subdir', 'movies/subdir') + + expect(result.nfoFiles).toEqual(['movies/subdir/movie.nfo']) + }) + + it('should handle IDs with more than 7 digits', async () => { + mockReaddir.mockResolvedValue(['movie.nfo']) + mockReadFile.mockResolvedValue('tt12345678') + + const result = await extractImdbIdFromNfoFiles('/mnt/media/movies', 'movies') + + expect(result.imdbId).toBe('tt12345678') + }) +}) diff --git a/service/src/app-models/media/utils/extract-imdb-id-from-nfo.ts b/service/src/app-models/media/utils/extract-imdb-id-from-nfo.ts new file mode 100644 index 00000000..43d247bf --- /dev/null +++ b/service/src/app-models/media/utils/extract-imdb-id-from-nfo.ts @@ -0,0 +1,37 @@ +import { readdir, readFile } from 'fs/promises' +import { join } from 'path' + +const IMDB_ID_IN_CONTENT = /tt\d{7,}/ + +export const extractImdbIdFromNfoFiles = async ( + physicalParentPath: string, + relativeParentPath: string, +): Promise<{ imdbId?: string; nfoFiles: string[] }> => { + let entries: string[] + try { + const dirEntries = await readdir(physicalParentPath) + entries = dirEntries.filter((name) => name.toLowerCase().endsWith('.nfo')) + } catch { + return { nfoFiles: [] } + } + + if (entries.length === 0) { + return { nfoFiles: [] } + } + + const nfoFiles = entries.map((name) => join(relativeParentPath, name).split('\\').join('/')) + + for (const name of entries) { + try { + const content = await readFile(join(physicalParentPath, name), 'utf-8') + const match = IMDB_ID_IN_CONTENT.exec(content) + if (match) { + return { imdbId: match[0], nfoFiles } + } + } catch { + // Skip unreadable files + } + } + + return { nfoFiles } +} diff --git a/service/src/app-models/media/utils/extract-imdb-id-from-tags.spec.ts b/service/src/app-models/media/utils/extract-imdb-id-from-tags.spec.ts new file mode 100644 index 00000000..78983745 --- /dev/null +++ b/service/src/app-models/media/utils/extract-imdb-id-from-tags.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { extractImdbIdFromFfprobeTags } from './extract-imdb-id-from-tags.js' + +describe('extractImdbIdFromFfprobeTags', () => { + it('should return undefined for null tags', () => { + expect(extractImdbIdFromFfprobeTags(null)).toBeUndefined() + }) + + it('should return undefined for undefined tags', () => { + expect(extractImdbIdFromFfprobeTags(undefined)).toBeUndefined() + }) + + it('should return undefined for non-object tags', () => { + expect(extractImdbIdFromFfprobeTags('string')).toBeUndefined() + expect(extractImdbIdFromFfprobeTags(42)).toBeUndefined() + expect(extractImdbIdFromFfprobeTags(true)).toBeUndefined() + }) + + it('should return undefined for empty object', () => { + expect(extractImdbIdFromFfprobeTags({})).toBeUndefined() + }) + + it('should return undefined when no IMDB-related keys exist', () => { + expect(extractImdbIdFromFfprobeTags({ title: 'Some Movie', year: '2024' })).toBeUndefined() + }) + + it('should extract from "IMDB" key', () => { + expect(extractImdbIdFromFfprobeTags({ IMDB: 'tt1234567' })).toBe('tt1234567') + }) + + it('should extract from "imdb" key (lowercase)', () => { + expect(extractImdbIdFromFfprobeTags({ imdb: 'tt1234567' })).toBe('tt1234567') + }) + + it('should extract from "IMDb" key (mixed case)', () => { + expect(extractImdbIdFromFfprobeTags({ IMDb: 'tt1234567' })).toBe('tt1234567') + }) + + it('should extract from "IMDB_ID" key', () => { + expect(extractImdbIdFromFfprobeTags({ IMDB_ID: 'tt1234567' })).toBe('tt1234567') + }) + + it('should extract from "imdb_id" key (lowercase)', () => { + expect(extractImdbIdFromFfprobeTags({ imdb_id: 'tt1234567' })).toBe('tt1234567') + }) + + it('should extract from "imdb-id" key (hyphenated)', () => { + expect(extractImdbIdFromFfprobeTags({ 'imdb-id': 'tt1234567' })).toBe('tt1234567') + }) + + it('should extract from "IMDBID" key (no separator)', () => { + expect(extractImdbIdFromFfprobeTags({ IMDBID: 'tt1234567' })).toBe('tt1234567') + }) + + it('should handle IDs with more than 7 digits', () => { + expect(extractImdbIdFromFfprobeTags({ IMDB: 'tt12345678' })).toBe('tt12345678') + }) + + it('should trim whitespace from values', () => { + expect(extractImdbIdFromFfprobeTags({ IMDB: ' tt1234567 ' })).toBe('tt1234567') + }) + + it('should return undefined for invalid IMDB ID format', () => { + expect(extractImdbIdFromFfprobeTags({ IMDB: 'not-an-id' })).toBeUndefined() + expect(extractImdbIdFromFfprobeTags({ IMDB: '1234567' })).toBeUndefined() + expect(extractImdbIdFromFfprobeTags({ IMDB: 'tt123' })).toBeUndefined() + expect(extractImdbIdFromFfprobeTags({ IMDB: '' })).toBeUndefined() + }) + + it('should return undefined for IMDB ID embedded in a URL', () => { + expect(extractImdbIdFromFfprobeTags({ IMDB: 'https://www.imdb.com/title/tt1234567/' })).toBeUndefined() + }) + + it('should ignore unrelated keys even when they contain numbers', () => { + expect(extractImdbIdFromFfprobeTags({ encoder: 'Lavf58.76.100', duration: '7200' })).toBeUndefined() + }) +}) diff --git a/service/src/app-models/media/utils/extract-imdb-id-from-tags.ts b/service/src/app-models/media/utils/extract-imdb-id-from-tags.ts new file mode 100644 index 00000000..33a1e77a --- /dev/null +++ b/service/src/app-models/media/utils/extract-imdb-id-from-tags.ts @@ -0,0 +1,13 @@ +const IMDB_ID_PATTERN = /^tt\d{7,}$/ + +export const extractImdbIdFromFfprobeTags = (tags: unknown): string | undefined => { + if (!tags || typeof tags !== 'object') return undefined + const record = tags as Record + for (const key of Object.keys(record)) { + if (/^imdb[_-]?id$/i.test(key) || /^imdb$/i.test(key)) { + const value = String(record[key]).trim() + if (IMDB_ID_PATTERN.test(value)) return value + } + } + return undefined +} diff --git a/service/src/app-models/media/utils/link-movie.spec.ts b/service/src/app-models/media/utils/link-movie.spec.ts index 797b6200..8da2e703 100644 --- a/service/src/app-models/media/utils/link-movie.spec.ts +++ b/service/src/app-models/media/utils/link-movie.spec.ts @@ -1,7 +1,7 @@ import { Injector } from '@furystack/inject' import { usingAsync } from '@furystack/utils' +import type { MovieFile, PiRatFile } from 'common' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { PiRatFile } from 'common' import { FfprobeService } from '../../../ffprobe-service.js' import { OmdbClientService } from '../metadata-services/omdb-client-service.js' import { TmdbClientService } from '../metadata-services/tmdb-client-service.js' @@ -10,6 +10,7 @@ import { linkMovie } from './link-movie.js' const mockMovieFileStoreFind = vi.fn() const mockMovieFileStoreAdd = vi.fn() const mockOmdbStoreFind = vi.fn() +const mockDriveGet = vi.fn() vi.mock('@furystack/repository', () => ({ getDataSetFor: (_injector: unknown, model: { name?: string } | ((...args: unknown[]) => unknown)) => { @@ -30,6 +31,11 @@ vi.mock('@furystack/repository', () => ({ get: vi.fn().mockResolvedValue(null), } } + if (name === 'Drive') { + return { + get: (...args: unknown[]) => mockDriveGet(...args) as unknown, + } + } return {} }, })) @@ -77,13 +83,31 @@ vi.mock('./map-tmdb-to-localized.js', () => ({ mapTmdbMovieToLocalized: vi.fn().mockReturnValue({}), })) -const mockGetFfprobeForPiratFile = vi.fn().mockResolvedValue({ duration: 7200 }) +const mockExtractImdbIdFromFfprobeTags = vi.fn() +vi.mock('./extract-imdb-id-from-tags.js', () => ({ + extractImdbIdFromFfprobeTags: (...args: unknown[]) => mockExtractImdbIdFromFfprobeTags(...args) as unknown, +})) + +const mockExtractImdbIdFromNfoFiles = vi.fn() +vi.mock('./extract-imdb-id-from-nfo.js', () => ({ + extractImdbIdFromNfoFiles: (...args: unknown[]) => mockExtractImdbIdFromNfoFiles(...args) as unknown, +})) + +vi.mock('../../../utils/physical-path-utils.js', () => ({ + getPhysicalParentPath: (_drive: unknown, file: { path: string }) => + `/mnt/media/${file.path.split('/').slice(0, -1).join('/')}`, +})) + +const mockGetFfprobeForPiratFile = vi.fn().mockResolvedValue({ format: { tags: {} }, duration: 7200 }) const mockFetchOmdbMovieMetadata = vi.fn() const mockFetchTmdbMovieMetadata = vi.fn() describe('linkMovie', () => { beforeEach(() => { vi.clearAllMocks() + mockExtractImdbIdFromFfprobeTags.mockReturnValue(undefined) + mockExtractImdbIdFromNfoFiles.mockResolvedValue({ nfoFiles: [] }) + mockDriveGet.mockResolvedValue({ letter: 'A', physicalPath: '/mnt/media' }) }) const createFile = (path: string): PiRatFile => ({ @@ -275,4 +299,115 @@ describe('linkMovie', () => { }) }) }) + + describe('linking via ffprobe tags', () => { + it('should link directly when ffprobe tags contain IMDB ID', async () => { + mockMovieFileStoreFind.mockResolvedValue([]) + mockExtractImdbIdFromFfprobeTags.mockReturnValue('tt9999999') + mockMovieFileStoreAdd.mockResolvedValue({ + created: [{ id: 'new-file-id', path: 'movies/Tagged.Movie.2024.mkv', imdbId: 'tt9999999' }], + }) + + await usingAsync(createTestInjector(), async (injector) => { + const result = await linkMovie({ + injector, + file: createFile('movies/Tagged.Movie.2024.mkv'), + }) + + expect(result.status).toBe('linked') + expect(mockMovieFileStoreAdd).toHaveBeenCalled() + expect(mockOmdbStoreFind).not.toHaveBeenCalled() + expect(mockFetchOmdbMovieMetadata).not.toHaveBeenCalled() + expect(mockFetchTmdbMovieMetadata).not.toHaveBeenCalled() + }) + }) + + it('should skip .nfo scanning when ffprobe tags already have IMDB ID', async () => { + mockMovieFileStoreFind.mockResolvedValue([]) + mockExtractImdbIdFromFfprobeTags.mockReturnValue('tt8888888') + mockMovieFileStoreAdd.mockResolvedValue({ + created: [{ id: 'new-file-id' }], + }) + + await usingAsync(createTestInjector(), async (injector) => { + await linkMovie({ + injector, + file: createFile('movies/Tagged.Movie.2024.mkv'), + }) + + expect(mockExtractImdbIdFromNfoFiles).not.toHaveBeenCalled() + }) + }) + }) + + describe('linking via .nfo files', () => { + it('should link when .nfo file contains IMDB ID', async () => { + mockMovieFileStoreFind.mockResolvedValue([]) + mockExtractImdbIdFromNfoFiles.mockResolvedValue({ + imdbId: 'tt5555555', + nfoFiles: ['movies/movie.nfo'], + }) + mockMovieFileStoreAdd.mockResolvedValue({ + created: [{ id: 'new-file-id', path: 'movies/Nfo.Movie.2024.mkv', imdbId: 'tt5555555' }], + }) + + await usingAsync(createTestInjector(), async (injector) => { + const result = await linkMovie({ + injector, + file: createFile('movies/Nfo.Movie.2024.mkv'), + }) + + expect(result.status).toBe('linked') + expect(mockMovieFileStoreAdd).toHaveBeenCalled() + expect(mockOmdbStoreFind).not.toHaveBeenCalled() + expect(mockFetchOmdbMovieMetadata).not.toHaveBeenCalled() + }) + }) + + it('should store .nfo files in relatedFiles when linking via .nfo', async () => { + mockMovieFileStoreFind.mockResolvedValue([]) + mockExtractImdbIdFromNfoFiles.mockResolvedValue({ + imdbId: 'tt4444444', + nfoFiles: ['movies/movie.nfo', 'movies/extra.nfo'], + }) + mockMovieFileStoreAdd.mockResolvedValue({ + created: [{ id: 'new-file-id' }], + }) + + await usingAsync(createTestInjector(), async (injector) => { + await linkMovie({ + injector, + file: createFile('movies/Nfo.Movie.2024.mkv'), + }) + + const addCall = mockMovieFileStoreAdd.mock.calls[0] + const addedEntity = addCall[1] as unknown as MovieFile + expect(addedEntity.relatedFiles).toEqual([ + { type: 'info', path: 'movies/movie.nfo' }, + { type: 'info', path: 'movies/extra.nfo' }, + ]) + }) + }) + + it('should fall back to OMDB/TMDB when .nfo has no IMDB ID', async () => { + mockMovieFileStoreFind.mockResolvedValue([]) + mockExtractImdbIdFromNfoFiles.mockResolvedValue({ + nfoFiles: ['movies/movie.nfo'], + }) + mockOmdbStoreFind.mockResolvedValue([{ imdbID: 'tt1234567', Title: 'Test Movie', Year: '2024' }]) + mockMovieFileStoreAdd.mockResolvedValue({ + created: [{ id: 'new-file-id' }], + }) + + await usingAsync(createTestInjector(), async (injector) => { + const result = await linkMovie({ + injector, + file: createFile('movies/Test.Movie.2024.mkv'), + }) + + expect(result.status).toBe('linked') + expect(mockOmdbStoreFind).toHaveBeenCalled() + }) + }) + }) }) diff --git a/service/src/app-models/media/utils/link-movie.ts b/service/src/app-models/media/utils/link-movie.ts index fe952202..c064afdb 100644 --- a/service/src/app-models/media/utils/link-movie.ts +++ b/service/src/app-models/media/utils/link-movie.ts @@ -3,8 +3,10 @@ import { getLogger } from '@furystack/logging' import { getDataSetFor } from '@furystack/repository' import { Config, + Drive, getFallbackMetadata, getFileName, + getParentPath, isMovieFile, isSampleFile, MovieFile, @@ -13,9 +15,12 @@ import { type PiRatFile, } from 'common' import { FfprobeService } from '../../../ffprobe-service.js' +import { getPhysicalParentPath } from '../../../utils/physical-path-utils.js' import { OmdbClientService } from '../metadata-services/omdb-client-service.js' import { TmdbClientService } from '../metadata-services/tmdb-client-service.js' import { ensureMovieExists } from './ensure-movie-exists.js' +import { extractImdbIdFromFfprobeTags } from './extract-imdb-id-from-tags.js' +import { extractImdbIdFromNfoFiles } from './extract-imdb-id-from-nfo.js' import { ensureOmdbMovieExists } from './ensure-omdb-movie-exists.js' import { ensureOmdbSeriesExists } from './ensure-omdb-series-exists.js' import { ensureTmdbMovieExists } from './ensure-tmdb-movie-exists.js' @@ -166,7 +171,57 @@ export const linkMovie = async (options: { injector: Injector; file: PiRatFile } const ffprobeResult = await injector.getInstance(FfprobeService).getFfprobeForPiratFile(file) - // Check existing OMDB metadata first (backward compatibility) + // Try extracting IMDB ID directly from ffprobe tags or sibling .nfo files + const tagImdbId = extractImdbIdFromFfprobeTags(ffprobeResult.format?.tags) + + const driveDataSet = getDataSetFor(injector, Drive, 'letter') + const drive = await driveDataSet.get(injector, driveLetter) + + let nfoFiles: string[] = [] + let nfoImdbId: string | undefined + + if (!tagImdbId && drive) { + const physicalParent = getPhysicalParentPath(drive, file) + const relativeParent = getParentPath(file) + ;({ nfoFiles, imdbId: nfoImdbId } = await extractImdbIdFromNfoFiles(physicalParent, relativeParent)) + } + + const directImdbId = tagImdbId ?? nfoImdbId + const relatedFiles: Array<{ type: 'subtitle' | 'audio' | 'trailer' | 'info' | 'other'; path: string }> = nfoFiles.map( + (nfoPath) => ({ type: 'info' as const, path: nfoPath }), + ) + + if (directImdbId) { + const movie = await ensureMovieExists( + { + imdbId: directImdbId, + year, + season, + episode, + type: season != null && episode != null ? 'episode' : 'movie', + }, + injector, + ) + + const { + created: [newMovieFile], + } = await movieFileDataSet.add(injector, { + driveLetter, + path, + imdbId: directImdbId, + ffprobe: ffprobeResult, + ...(relatedFiles.length > 0 ? { relatedFiles } : {}), + }) + + await logger.debug({ + message: `File ${fileName} linked successfully (from ${tagImdbId ? 'ffprobe tags' : '.nfo file'}).`, + data: { file, movieFile: newMovieFile, movie, source: tagImdbId ? 'ffprobe-tags' : 'nfo-file' }, + }) + + return { status: 'linked', movieFile: newMovieFile, movie } as const + } + + // Check existing OMDB metadata (backward compatibility) const omdbDataSet = getDataSetFor(injector, OmdbMovieMetadata, 'imdbID') const storedResult = await omdbDataSet.find(injector, { filter: { From d7e6764099c3c6ebe08d43e1dd3cfcd96427b355 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Wed, 11 Mar 2026 12:04:57 +0100 Subject: [PATCH 09/30] ffmpeg session teardown fix --- .../actions/hls-session-teardown-action.ts | 16 +- .../services/transcoding-session.spec.ts | 137 ++++++++---------- .../media/services/transcoding-session.ts | 24 +-- 3 files changed, 74 insertions(+), 103 deletions(-) diff --git a/service/src/app-models/media/actions/hls-session-teardown-action.ts b/service/src/app-models/media/actions/hls-session-teardown-action.ts index e97b8ec2..d9627629 100644 --- a/service/src/app-models/media/actions/hls-session-teardown-action.ts +++ b/service/src/app-models/media/actions/hls-session-teardown-action.ts @@ -2,35 +2,25 @@ import { getLogger } from '@furystack/logging' import { RequestError } from '@furystack/rest' import { JsonResult } from '@furystack/rest-service' import type { RequestAction } from '@furystack/rest-service' -import type { HlsSessionTeardownEndpoint, PlaybackMode } from 'common' +import type { HlsSessionTeardownEndpoint } from 'common' import { TranscodingSessionService } from '../services/transcoding-session.js' -const VALID_MODES: PlaybackMode[] = ['direct-play', 'remux', 'direct-stream', 'transcode'] - export const HlsSessionTeardownAction: RequestAction = async ({ injector, getUrlParams, - getQuery, }) => { const logger = getLogger(injector).withScope('HlsSessionTeardownAction') const { letter, path } = getUrlParams() - const query = getQuery() if (path.includes('..') || path.includes('\0')) { throw new RequestError('Invalid path', 400) } - const mode: PlaybackMode = query.mode || 'transcode' - if (!VALID_MODES.includes(mode)) { - throw new RequestError('Invalid playback mode', 400) - } - const sessionService = injector.getInstance(TranscodingSessionService) - sessionService.removeSession(letter, path, mode, query.audioTrack ?? 0, query.resolution) + sessionService.removeAllSessionsForFile(letter, path) await logger.verbose({ - message: `Tore down HLS session for ${letter}:${path}`, - data: { mode, audioTrack: query.audioTrack }, + message: `Tore down HLS sessions for ${letter}:${path}`, }) return JsonResult({ success: true }) diff --git a/service/src/app-models/media/services/transcoding-session.spec.ts b/service/src/app-models/media/services/transcoding-session.spec.ts index 202a0811..8b658a86 100644 --- a/service/src/app-models/media/services/transcoding-session.spec.ts +++ b/service/src/app-models/media/services/transcoding-session.spec.ts @@ -691,92 +691,69 @@ describe('TranscodingSessionService', () => { }) }) - it('should remove all resolution variants when resolution is undefined', async () => { - const mockProcess = createMockProcess() - mockSpawn.mockReturnValue(mockProcess) + describe('removeAllSessionsForFile', () => { + it('should remove all sessions for a file regardless of mode, audioTrack, and resolution', async () => { + const mockProcess = createMockProcess() + mockSpawn.mockReturnValue(mockProcess) - await usingAsync(new Injector(), async (injector) => { - injector.setExplicitInstance( - { getFfprobeForPiratFile: vi.fn().mockResolvedValue(mockFfprobe) } as unknown as FfprobeService, - FfprobeService, - ) - injector.setExplicitInstance( - { getEncoder: vi.fn().mockResolvedValue('libx264') } as unknown as HwAccelDetector, - HwAccelDetector, - ) + await usingAsync(new Injector(), async (injector) => { + injector.setExplicitInstance( + { getFfprobeForPiratFile: vi.fn().mockResolvedValue(mockFfprobe) } as unknown as FfprobeService, + FfprobeService, + ) + injector.setExplicitInstance( + { getEncoder: vi.fn().mockResolvedValue('libx264') } as unknown as HwAccelDetector, + HwAccelDetector, + ) - const service = injector.getInstance(TranscodingSessionService) - try { - await service.getOrCreateSession({ - driveLetter: 'A', - path: 'test.mkv', - mode: 'transcode', - resolution: '1080p', - }) - await service.getOrCreateSession({ - driveLetter: 'A', - path: 'test.mkv', - mode: 'transcode', - resolution: '720p', - }) - await service.getOrCreateSession({ - driveLetter: 'A', - path: 'other.mkv', - mode: 'transcode', - resolution: '1080p', - }) + const service = injector.getInstance(TranscodingSessionService) + try { + await service.getOrCreateSession({ + driveLetter: 'A', + path: 'test.mkv', + mode: 'transcode', + resolution: '1080p', + }) + await service.getOrCreateSession({ + driveLetter: 'A', + path: 'test.mkv', + mode: 'transcode', + resolution: '720p', + }) + await service.getOrCreateSession({ + driveLetter: 'A', + path: 'test.mkv', + mode: 'direct-stream', + audioTrackId: 1, + }) + await service.getOrCreateSession({ + driveLetter: 'A', + path: 'other.mkv', + mode: 'transcode', + resolution: '1080p', + }) - expect(service.getActiveSessionCount()).toBe(3) + expect(service.getActiveSessionCount()).toBe(4) - service.removeSession('A', 'test.mkv', 'transcode', 0) - expect(service.getActiveSessionCount()).toBe(1) - expect(service.getSession('A', 'test.mkv', 'transcode', 0, '1080p')).toBeUndefined() - expect(service.getSession('A', 'test.mkv', 'transcode', 0, '720p')).toBeUndefined() - expect(service.getSession('A', 'other.mkv', 'transcode', 0, '1080p')).toBeDefined() - } finally { - service.dispose() - } + service.removeAllSessionsForFile('A', 'test.mkv') + expect(service.getActiveSessionCount()).toBe(1) + expect(service.getSession('A', 'other.mkv', 'transcode', 0, '1080p')).toBeDefined() + } finally { + service.dispose() + } + }) }) - }) - - it('should remove only exact match when resolution is specified', async () => { - const mockProcess = createMockProcess() - mockSpawn.mockReturnValue(mockProcess) - - await usingAsync(new Injector(), async (injector) => { - injector.setExplicitInstance( - { getFfprobeForPiratFile: vi.fn().mockResolvedValue(mockFfprobe) } as unknown as FfprobeService, - FfprobeService, - ) - injector.setExplicitInstance( - { getEncoder: vi.fn().mockResolvedValue('libx264') } as unknown as HwAccelDetector, - HwAccelDetector, - ) - const service = injector.getInstance(TranscodingSessionService) - try { - await service.getOrCreateSession({ - driveLetter: 'A', - path: 'test.mkv', - mode: 'transcode', - resolution: '1080p', - }) - await service.getOrCreateSession({ - driveLetter: 'A', - path: 'test.mkv', - mode: 'transcode', - resolution: '720p', - }) - - expect(service.getActiveSessionCount()).toBe(2) - - service.removeSession('A', 'test.mkv', 'transcode', 0, '1080p') - expect(service.getActiveSessionCount()).toBe(1) - expect(service.getSession('A', 'test.mkv', 'transcode', 0, '1080p')).toBeUndefined() - expect(service.getSession('A', 'test.mkv', 'transcode', 0, '720p')).toBeDefined() - } finally { - service.dispose() - } + it('should handle no matching sessions gracefully', async () => { + await usingAsync(new Injector(), async (injector) => { + const service = injector.getInstance(TranscodingSessionService) + try { + service.removeAllSessionsForFile('Z', 'nonexistent.mkv') + expect(service.getActiveSessionCount()).toBe(0) + } finally { + service.dispose() + } + }) }) }) diff --git a/service/src/app-models/media/services/transcoding-session.ts b/service/src/app-models/media/services/transcoding-session.ts index 86121bd6..e8988ed5 100644 --- a/service/src/app-models/media/services/transcoding-session.ts +++ b/service/src/app-models/media/services/transcoding-session.ts @@ -415,22 +415,26 @@ export class TranscodingSessionService { audioTrackId: number = 0, resolution?: string, ) { - if (resolution !== undefined) { - const key = this.buildSessionKey(driveLetter, path, mode, audioTrackId, resolution) - const session = this.sessions.get(key) - if (session) { - this.destroySession(session) - this.sessions.delete(key) - } - return + const key = this.buildSessionKey(driveLetter, path, mode, audioTrackId, resolution) + const session = this.sessions.get(key) + if (session) { + this.destroySession(session) + this.sessions.delete(key) } + } - const prefix = `${driveLetter}:${path}:${mode}:${audioTrackId}:` + /** + * Removes all transcoding sessions for a given file, regardless of mode, + * audio track, or resolution. Used when a client disconnects or navigates + * away — there is no reason to keep any session alive for that file. + */ + public removeAllSessionsForFile(driveLetter: string, path: string) { + const prefix = `${driveLetter}:${path}:` const keysToRemove = [...this.sessions.keys()].filter((k) => k.startsWith(prefix)) for (const key of keysToRemove) { const session = this.sessions.get(key) if (session) { - void this.logger.verbose({ message: `Removing session: ${key}` }) + void this.logger.verbose({ message: `Removing session for file teardown: ${key}` }) this.destroySession(session) this.sessions.delete(key) } From 971d04389f10c3a6a4b747432c117011d4c2daee Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Wed, 11 Mar 2026 12:32:26 +0100 Subject: [PATCH 10/30] movie duration fix --- .../media/actions/hls-stream-action.spec.ts | 1 + .../services/transcoding-session.spec.ts | 69 ++++++++++++++++++- .../media/services/transcoding-session.ts | 65 +++++++++++++++-- 3 files changed, 129 insertions(+), 6 deletions(-) diff --git a/service/src/app-models/media/actions/hls-stream-action.spec.ts b/service/src/app-models/media/actions/hls-stream-action.spec.ts index 63680b5c..e5edd744 100644 --- a/service/src/app-models/media/actions/hls-stream-action.spec.ts +++ b/service/src/app-models/media/actions/hls-stream-action.spec.ts @@ -38,6 +38,7 @@ const mockSession = { driveLetter: 'A', path: 'test.mkv', audioTrackId: 0, + totalDuration: 16, createdAt: Date.now(), lastAccessedAt: Date.now(), ffmpegProcess: { killed: false, kill: vi.fn() }, diff --git a/service/src/app-models/media/services/transcoding-session.spec.ts b/service/src/app-models/media/services/transcoding-session.spec.ts index 8b658a86..615c0893 100644 --- a/service/src/app-models/media/services/transcoding-session.spec.ts +++ b/service/src/app-models/media/services/transcoding-session.spec.ts @@ -655,9 +655,9 @@ describe('TranscodingSessionService', () => { if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }) }) - it('should return playlist content when file exists', async () => { + it('should return playlist content when file exists and has ENDLIST', async () => { vi.useRealTimers() - const playlistContent = '#EXTM3U\n#EXT-X-VERSION:7\nsegment0.m4s\n' + const playlistContent = '#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-ENDLIST\n' writeFileSync(join(testDir, 'playlist.m3u8'), playlistContent) const service = new TranscodingSessionService() @@ -665,6 +665,7 @@ describe('TranscodingSessionService', () => { const mockSession = { sessionDir: testDir, state: 'running' as const, + totalDuration: 60, } as Parameters[0] const result = await service.readPlaylist(mockSession) @@ -674,6 +675,69 @@ describe('TranscodingSessionService', () => { } }) + it('should pad playlist with remaining segments when ENDLIST is missing', async () => { + vi.useRealTimers() + const playlistContent = [ + '#EXTM3U', + '#EXT-X-VERSION:7', + '#EXT-X-TARGETDURATION:6', + '#EXT-X-MAP:URI="init.mp4"', + '#EXTINF:6.000000,', + 'segment0.m4s', + '#EXTINF:6.000000,', + 'segment1.m4s', + '', + ].join('\n') + writeFileSync(join(testDir, 'playlist.m3u8'), playlistContent) + + const service = new TranscodingSessionService() + try { + const mockSession = { + sessionDir: testDir, + state: 'running' as const, + totalDuration: 30, + } as Parameters[0] + + const result = await service.readPlaylist(mockSession) + expect(result).toContain('#EXT-X-ENDLIST') + expect(result).toContain('segment2.m4s') + expect(result).toContain('segment3.m4s') + expect(result).toContain('segment4.m4s') + } finally { + service.dispose() + } + }) + + it('should add ENDLIST when all segments are already encoded', async () => { + vi.useRealTimers() + const playlistContent = [ + '#EXTM3U', + '#EXT-X-VERSION:7', + '#EXT-X-TARGETDURATION:6', + '#EXTINF:6.000000,', + 'segment0.m4s', + '#EXTINF:6.000000,', + 'segment1.m4s', + '', + ].join('\n') + writeFileSync(join(testDir, 'playlist.m3u8'), playlistContent) + + const service = new TranscodingSessionService() + try { + const mockSession = { + sessionDir: testDir, + state: 'running' as const, + totalDuration: 12, + } as Parameters[0] + + const result = await service.readPlaylist(mockSession) + expect(result).toContain('#EXT-X-ENDLIST') + expect(result).not.toContain('segment2.m4s') + } finally { + service.dispose() + } + }) + it('should return null when session errors before playlist is created', async () => { vi.useRealTimers() const service = new TranscodingSessionService() @@ -681,6 +745,7 @@ describe('TranscodingSessionService', () => { const mockSession = { sessionDir: testDir, state: 'error' as const, + totalDuration: 60, } as Parameters[0] const result = await service.readPlaylist(mockSession) diff --git a/service/src/app-models/media/services/transcoding-session.ts b/service/src/app-models/media/services/transcoding-session.ts index e8988ed5..bc889c02 100644 --- a/service/src/app-models/media/services/transcoding-session.ts +++ b/service/src/app-models/media/services/transcoding-session.ts @@ -27,6 +27,7 @@ type TranscodingSessionEntry = { path: string audioTrackId: number resolution?: string + totalDuration: number createdAt: number lastAccessedAt: number } @@ -169,7 +170,7 @@ export class TranscodingSessionService { mkdirSync(sessionDir, { recursive: true }) } - const ffmpegArgs = await this.buildHlsFfmpegArgs({ + const { args: ffmpegArgs, totalDuration } = await this.buildHlsFfmpegArgs({ driveLetter, path, mode, @@ -197,6 +198,7 @@ export class TranscodingSessionService { path, audioTrackId, resolution, + totalDuration, createdAt: Date.now(), lastAccessedAt: Date.now(), } @@ -280,7 +282,61 @@ export class TranscodingSessionService { const playlistPath = join(session.sessionDir, 'playlist.m3u8') const ready = await this.waitForFile(playlistPath, session) if (!ready) return null - return readFile(playlistPath, 'utf-8') + const content = await readFile(playlistPath, 'utf-8') + return this.padPlaylistToFullDuration(content, session.totalDuration) + } + + /** + * If the playlist is still being written by FFmpeg (no #EXT-X-ENDLIST), + * pad it with the remaining expected segments so that clients see the + * full VOD duration from the first request. The segment-serving endpoint + * already waits for segments that haven't been transcoded yet. + */ + private padPlaylistToFullDuration(playlist: string, totalDuration: number): string { + if (totalDuration <= 0 || playlist.includes('#EXT-X-ENDLIST')) { + return playlist + } + + const lines = playlist.split('\n') + + let encodedDuration = 0 + let maxSegmentIndex = -1 + for (const line of lines) { + const extinfMatch = line.match(/^#EXTINF:([\d.]+)/) + if (extinfMatch) { + encodedDuration += parseFloat(extinfMatch[1]) + } + const segmentMatch = line.match(/^segment(\d+)\.m4s/) + if (segmentMatch) { + maxSegmentIndex = Math.max(maxSegmentIndex, parseInt(segmentMatch[1], 10)) + } + } + + const remainingDuration = totalDuration - encodedDuration + if (remainingDuration <= 0) { + return `${playlist.trimEnd()}\n#EXT-X-ENDLIST\n` + } + + const fullSegmentCount = Math.floor(remainingDuration / SEGMENT_DURATION) + const lastSegmentDuration = remainingDuration - fullSegmentCount * SEGMENT_DURATION + + const padLines: string[] = [] + let nextIndex = maxSegmentIndex + 1 + + for (let i = 0; i < fullSegmentCount; i++) { + padLines.push(`#EXTINF:${SEGMENT_DURATION.toFixed(6)},`) + padLines.push(`segment${nextIndex}.m4s`) + nextIndex++ + } + + if (lastSegmentDuration > 0.01) { + padLines.push(`#EXTINF:${lastSegmentDuration.toFixed(6)},`) + padLines.push(`segment${nextIndex}.m4s`) + } + + padLines.push('#EXT-X-ENDLIST') + + return `${playlist.trimEnd()}\n${padLines.join('\n')}\n` } private async buildHlsFfmpegArgs({ @@ -297,7 +353,7 @@ export class TranscodingSessionService { audioTrackId: number resolution?: string sessionDir: string - }): Promise { + }): Promise<{ args: string[]; totalDuration: number }> { const [drive, config, ffprobe] = await Promise.all([ (async () => { const driveDataSet = getDataSetFor(this.injector, Drive, 'letter') @@ -313,6 +369,7 @@ export class TranscodingSessionService { if (!drive) throw new Error(`Drive ${driveLetter} not found`) + const totalDuration = parseFloat(ffprobe.format.duration ?? '0') || 0 const fullPath = join(drive.physicalPath, path) const audioStreams = ffprobe.streams.filter((s) => s.codec_type === 'audio') const audioStream = audioStreams.find((t) => t.index === audioTrackId) || audioStreams[0] @@ -401,7 +458,7 @@ export class TranscodingSessionService { args.push('-hls_list_size', '0') args.push('-y', join(sessionDir, 'playlist.m3u8')) - return args + return { args, totalDuration } } public getActiveSessionCount(): number { From e9fbffc13b4359eaa3eb9aab8fc912d45aa9dcf7 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Wed, 11 Mar 2026 14:13:54 +0100 Subject: [PATCH 11/30] improved playback quality controls --- .../movie-player-service.spec.ts | 110 ++++++++++++++++++ .../movie-player-v2/movie-player-service.ts | 40 ++++++- .../movie-player-v2-component.spec.ts | 53 ++++++++- .../movie-player-v2-component.tsx | 20 +++- .../services/hls-manifest-generator.spec.ts | 37 ++++++ .../media/services/hls-manifest-generator.ts | 1 + 6 files changed, 253 insertions(+), 8 deletions(-) diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-service.spec.ts b/frontend/src/pages/movies/movie-player-v2/movie-player-service.spec.ts index e235806d..2eaa0532 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-service.spec.ts +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-service.spec.ts @@ -252,3 +252,113 @@ describe('audioCodecs', () => { expect(audioCodecs.opus).toBe('opus') }) }) + +describe('switchResolution', () => { + let api: ReturnType + + beforeEach(() => { + api = createMockApi() + vi.clearAllMocks() + }) + + it('should force transcode mode when a specific resolution is selected', async () => { + await usingAsync( + new MoviePlayerService(mockFile, mockFfprobe, api as never, 0, mockLogger as never), + async (service) => { + await vi.waitFor(() => { + expect(service.playbackInfo.getValue()).not.toBeNull() + }) + + expect(service.playbackMode.getValue()).toBe('remux') + + await service.switchResolution('720p') + expect(service.resolution.getValue()).toBe('720p') + expect(service.playbackMode.getValue()).toBe('transcode') + }, + ) + }) + + it('should restore original playback mode when switching to Auto', async () => { + await usingAsync( + new MoviePlayerService(mockFile, mockFfprobe, api as never, 0, mockLogger as never), + async (service) => { + await vi.waitFor(() => { + expect(service.playbackInfo.getValue()).not.toBeNull() + }) + + expect(service.playbackMode.getValue()).toBe('remux') + + await service.switchResolution('720p') + expect(service.playbackMode.getValue()).toBe('transcode') + + await service.switchResolution(undefined) + expect(service.resolution.getValue()).toBeUndefined() + expect(service.playbackMode.getValue()).toBe('remux') + }, + ) + }) + + it('should preserve playback progress across resolution switches', async () => { + await usingAsync( + new MoviePlayerService(mockFile, mockFfprobe, api as never, 50, mockLogger as never), + async (service) => { + await vi.waitFor(() => { + expect(service.playbackInfo.getValue()).not.toBeNull() + }) + + const mockVideo = { currentTime: 75 } as HTMLVideoElement + service.videoElement = mockVideo + + await service.switchResolution('480p') + expect(service.progress.getValue()).toBe(50) + }, + ) + }) + + it('should stay in transcode mode when already transcoding', async () => { + const transcodeResponse: PlaybackInfoResponse = { + ...mockPlaybackInfoResponse, + mode: 'transcode', + } + api.call.mockResolvedValue({ result: transcodeResponse }) + + await usingAsync( + new MoviePlayerService(mockFile, mockFfprobe, api as never, 0, mockLogger as never), + async (service) => { + await vi.waitFor(() => { + expect(service.playbackInfo.getValue()).not.toBeNull() + }) + + expect(service.playbackMode.getValue()).toBe('transcode') + + await service.switchResolution('720p') + expect(service.playbackMode.getValue()).toBe('transcode') + + await service.switchResolution(undefined) + expect(service.playbackMode.getValue()).toBe('transcode') + }, + ) + }) + + it('should call teardown before switching', async () => { + await usingAsync( + new MoviePlayerService(mockFile, mockFfprobe, api as never, 0, mockLogger as never), + async (service) => { + await vi.waitFor(() => { + expect(service.playbackInfo.getValue()).not.toBeNull() + }) + + api.call.mockClear() + + await service.switchResolution('720p') + + expect(api.call).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'DELETE', + action: '/files/:letter/:path/hls-session', + }), + ) + }, + ) + }) +}) diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts b/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts index 2fbd3a9f..ad15af37 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts @@ -58,6 +58,8 @@ const buildCodecSupportMap = () => { } } +export type ResolutionValue = '4k' | '1080p' | '720p' | '480p' | '360p' + export class MoviePlayerService implements AsyncDisposable { constructor( private readonly file: PiRatFile, @@ -72,11 +74,12 @@ export class MoviePlayerService implements AsyncDisposable { } private hls: Hls | null = null + private originalPlaybackMode: PlaybackMode = 'transcode' public videoElement: HTMLVideoElement | null = null public audioTrackId = new ObservableValue(0) public playbackInfo = new ObservableValue(null) public playbackMode = new ObservableValue('transcode') - public resolution = new ObservableValue<'4k' | '1080p' | '720p' | '480p' | '360p' | undefined>(undefined) + public resolution = new ObservableValue(undefined) public progress: ObservableValue public async [Symbol.asyncDispose]() { @@ -117,7 +120,10 @@ export class MoviePlayerService implements AsyncDisposable { } private async initialize() { - await this.fetchPlaybackInfo() + const info = await this.fetchPlaybackInfo() + if (info) { + this.originalPlaybackMode = info.mode + } } public async fetchPlaybackInfo(selectedSubtitleTrackIndex?: number): Promise { @@ -175,7 +181,8 @@ export class MoviePlayerService implements AsyncDisposable { this.hls = null } - if (info.mode === 'direct-play') { + const mode = this.playbackMode.getValue() + if (mode === 'direct-play') { this.startDirectPlayback(videoElement, info) } else { void this.startHlsPlayback(videoElement) @@ -293,6 +300,33 @@ export class MoviePlayerService implements AsyncDisposable { } } + /** + * Switches resolution and restarts playback. Forces transcode mode when a + * specific resolution is requested; restores the original mode on "Auto". + */ + public async switchResolution(value: ResolutionValue | undefined) { + const previousProgress = this.videoElement?.currentTime ?? this.progress.getValue() + await this.teardownHlsSession() + + this.resolution.setValue(value) + this.currentProgress = previousProgress + + if (value) { + if (this.playbackMode.getValue() !== 'transcode') { + this.playbackMode.setValue('transcode') + } + } else { + this.playbackMode.setValue(this.originalPlaybackMode) + } + + if (this.videoElement) { + const info = this.playbackInfo.getValue() + if (info) { + this.startPlayback(this.videoElement, info) + } + } + } + public getAudioTrackInfoFromPlaybackInfo(): AudioTrackInfo[] { return this.playbackInfo.getValue()?.audioTracks ?? [] } diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.spec.ts b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.spec.ts index 4d9fb164..c73e463c 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.spec.ts +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.spec.ts @@ -11,8 +11,9 @@ import { getSubtitleTracks, getSubtitleTracksFromPlaybackInfo } from './get-subt * and get-subtitle-tracks.spec.tsx. */ +type Rendition = { id: string; width: number; height: number; src: string; selected: boolean } + const buildRenditionList = (height: number, currentValue?: string) => { - type Rendition = { id: string; width: number; height: number; src: string; selected: boolean } const renditions: Rendition[] = [ ...(height >= 2160 ? [{ id: '4k', width: 3840, height: 2160, src: '', selected: currentValue === '4k' }] : []), ...(height >= 1080 @@ -25,6 +26,16 @@ const buildRenditionList = (height: number, currentValue?: string) => { return renditions } +const createRenditionList = (items: Rendition[], selectedIndex: number) => { + const target = new EventTarget() + return Object.assign([...items], { + addEventListener: target.addEventListener.bind(target), + removeEventListener: target.removeEventListener.bind(target), + dispatchEvent: target.dispatchEvent.bind(target), + selectedIndex, + }) +} + const mapAudioTracksForMediaChrome = (audioTracks: AudioTrackInfo[]) => audioTracks.map((track, index) => ({ id: track.index.toFixed(0), @@ -66,6 +77,46 @@ describe('MoviePlayerV2 component logic', () => { }) }) + describe('createRenditionList', () => { + it('should implement EventTarget methods', () => { + const items = buildRenditionList(1080) + const list = createRenditionList(items, -1) + + expect(typeof list.addEventListener).toBe('function') + expect(typeof list.removeEventListener).toBe('function') + expect(typeof list.dispatchEvent).toBe('function') + }) + + it('should expose selectedIndex', () => { + const items = buildRenditionList(1080, '720p') + const selectedIdx = items.findIndex((r) => r.selected) + const list = createRenditionList(items, selectedIdx) + + expect(list.selectedIndex).toBe(1) + }) + + it('should be array-like with rendition items', () => { + const items = buildRenditionList(1080) + const list = createRenditionList(items, -1) + + expect(list).toHaveLength(4) + expect(list[0].id).toBe('1080p') + expect(list[3].id).toBe('360p') + }) + + it('should allow adding and dispatching events', () => { + const items = buildRenditionList(720) + const list = createRenditionList(items, 0) + + let eventFired = false + list.addEventListener('change', () => { + eventFired = true + }) + list.dispatchEvent(new Event('change')) + expect(eventFired).toBe(true) + }) + }) + describe('mapAudioTracksForMediaChrome', () => { it('should map audio tracks with correct ids and labels', () => { const tracks: AudioTrackInfo[] = [ diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx index 2e247512..90e8386d 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx @@ -10,6 +10,16 @@ import { getSubtitleTracks, getSubtitleTracksFromPlaybackInfo } from './get-subt import './media-chrome.js' import { MoviePlayerService } from './movie-player-service.js' +const createRenditionList = (items: Rendition[], selectedIndex: number) => { + const target = new EventTarget() + return Object.assign([...items], { + addEventListener: target.addEventListener.bind(target), + removeEventListener: target.removeEventListener.bind(target), + dispatchEvent: target.dispatchEvent.bind(target), + selectedIndex, + }) +} + type MoviePlayerProps = { file: PiRatFile ffprobe: FfprobeData @@ -129,9 +139,9 @@ export const MoviePlayerV2 = Shade({ const { value } = ev.currentTarget as HTMLInputElement if (validValues.includes(value as (typeof validValues)[number])) { - mediaService.resolution.setValue(value as (typeof validValues)[number]) + void mediaService.switchResolution(value as (typeof validValues)[number]) } else { - mediaService.resolution.setValue(undefined) + void mediaService.switchResolution(undefined) } }} > @@ -184,7 +194,7 @@ export const MoviePlayerV2 = Shade({ const video = ev.currentTarget as HTMLVideoElement & { audioTracks: AudioTrack[] - videoRenditions: Rendition[] + videoRenditions: ReturnType } video.audioTracks = audioTracks.map((track, index) => ({ @@ -199,7 +209,7 @@ export const MoviePlayerV2 = Shade({ const videoStream = props.ffprobe.streams.find((stream) => stream.codec_type === 'video') const height = videoStream?.height || 1080 - video.videoRenditions = [ + const renditionItems: Rendition[] = [ ...(height >= 2160 ? [{ id: '4k', width: 3840, height: 2160, src: '', selected: currentValue === '4k' }] : []), @@ -214,6 +224,8 @@ export const MoviePlayerV2 = Shade({ : []), { id: '360p', width: 640, height: 360, src: '', selected: currentValue === '360p' }, ] + const selectedIdx = renditionItems.findIndex((r) => r.selected) + video.videoRenditions = createRenditionList(renditionItems, selectedIdx) if (video.audioTracks[0]) { mediaService.audioTrackId.setValue(parseInt(video.audioTracks[0].id as string, 10)) diff --git a/service/src/app-models/media/services/hls-manifest-generator.spec.ts b/service/src/app-models/media/services/hls-manifest-generator.spec.ts index b75aaca4..e0fad3fb 100644 --- a/service/src/app-models/media/services/hls-manifest-generator.spec.ts +++ b/service/src/app-models/media/services/hls-manifest-generator.spec.ts @@ -115,9 +115,46 @@ describe('generateMasterPlaylist', () => { subtitleTracks: [], }) + expect(playlist).not.toContain('RESOLUTION=3840x2160') expect(playlist).not.toContain('RESOLUTION=1920x1080') expect(playlist).toContain('RESOLUTION=1280x720') expect(playlist).toContain('RESOLUTION=854x480') expect(playlist).toContain('RESOLUTION=640x360') }) + + it('should include 4K variant for 4K sources in transcode mode', () => { + const ffprobe = createFfprobe({ + streams: [ + { index: 0, codec_type: 'video', codec_name: 'hevc', width: 3840, height: 2160, tags: {} }, + { index: 1, codec_type: 'audio', codec_name: 'aac', channels: 2, tags: {} }, + ], + }) + + const playlist = generateMasterPlaylist({ + ffprobe, + file: { driveLetter: 'A', path: 'movies/test-4k.mkv' }, + mode: 'transcode', + baseUrl: '/api/media', + subtitleTracks: [], + }) + + expect(playlist).toContain('RESOLUTION=3840x2160') + expect(playlist).toContain('RESOLUTION=1920x1080') + expect(playlist).toContain('RESOLUTION=1280x720') + expect(playlist).toContain('RESOLUTION=854x480') + expect(playlist).toContain('RESOLUTION=640x360') + }) + + it('should not include 4K variant for 1080p sources in transcode mode', () => { + const playlist = generateMasterPlaylist({ + ffprobe: createFfprobe(), + file: { driveLetter: 'A', path: 'movies/test.mkv' }, + mode: 'transcode', + baseUrl: '/api/media', + subtitleTracks: [], + }) + + expect(playlist).not.toContain('RESOLUTION=3840x2160') + expect(playlist).toContain('RESOLUTION=1920x1080') + }) }) diff --git a/service/src/app-models/media/services/hls-manifest-generator.ts b/service/src/app-models/media/services/hls-manifest-generator.ts index 5bfabddd..a9ea952c 100644 --- a/service/src/app-models/media/services/hls-manifest-generator.ts +++ b/service/src/app-models/media/services/hls-manifest-generator.ts @@ -9,6 +9,7 @@ export type HlsVariant = { } const DEFAULT_VARIANTS: HlsVariant[] = [ + { resolution: '3840x2160', width: 3840, height: 2160, bandwidth: 15000000 }, { resolution: '1920x1080', width: 1920, height: 1080, bandwidth: 5000000 }, { resolution: '1280x720', width: 1280, height: 720, bandwidth: 2800000 }, { resolution: '854x480', width: 854, height: 480, bandwidth: 1400000 }, From 09e2037ea31603e31a032ed5e805f910824ed56b Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Wed, 11 Mar 2026 14:26:54 +0100 Subject: [PATCH 12/30] improvements on quality switching --- common/schemas/media-api.json | 3 ++ common/src/apis/media.ts | 2 +- .../movie-player-service.spec.ts | 2 +- .../movie-player-v2/movie-player-service.ts | 5 ++- .../media/actions/hls-master-action.ts | 1 + .../services/hls-manifest-generator.spec.ts | 43 +++++++++++++++++++ .../media/services/hls-manifest-generator.ts | 8 +++- 7 files changed, 59 insertions(+), 5 deletions(-) diff --git a/common/schemas/media-api.json b/common/schemas/media-api.json index 98f1b3cd..f4a3e5a7 100644 --- a/common/schemas/media-api.json +++ b/common/schemas/media-api.json @@ -10489,6 +10489,9 @@ }, "containers": { "type": "string" + }, + "audioTrack": { + "type": "number" } }, "additionalProperties": false diff --git a/common/src/apis/media.ts b/common/src/apis/media.ts index e501c734..cd2b1204 100644 --- a/common/src/apis/media.ts +++ b/common/src/apis/media.ts @@ -177,7 +177,7 @@ export type PlaybackInfoResponse = { export type HlsMasterEndpoint = { url: { letter: string; path: string } - query: { mode?: PlaybackMode; videoCodecs?: string; audioCodecs?: string; containers?: string } + query: { mode?: PlaybackMode; videoCodecs?: string; audioCodecs?: string; containers?: string; audioTrack?: number } result: unknown } diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-service.spec.ts b/frontend/src/pages/movies/movie-player-v2/movie-player-service.spec.ts index 2eaa0532..3c48cffc 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-service.spec.ts +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-service.spec.ts @@ -310,7 +310,7 @@ describe('switchResolution', () => { service.videoElement = mockVideo await service.switchResolution('480p') - expect(service.progress.getValue()).toBe(50) + expect(service.progress.getValue()).toBe(75) }, ) }) diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts b/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts index ad15af37..208b8373 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts @@ -204,8 +204,10 @@ export class MoviePlayerService implements AsyncDisposable { private async startHlsPlayback(videoElement: HTMLVideoElement) { const mode = this.playbackMode.getValue() + const audioTrack = this.audioTrackId.getValue() + const audioParam = audioTrack ? `&audioTrack=${encode(String(audioTrack))}` : '' const hlsUrl = this.toServiceUrl( - `/api/media/files/${encodeURIComponent(this.file.driveLetter)}/${encodeURIComponent(this.file.path)}/master.m3u8?mode=${encode(mode)}`, + `/api/media/files/${encodeURIComponent(this.file.driveLetter)}/${encodeURIComponent(this.file.path)}/master.m3u8?mode=${encode(mode)}${audioParam}`, ) const HlsModule = await loadHls() @@ -310,6 +312,7 @@ export class MoviePlayerService implements AsyncDisposable { this.resolution.setValue(value) this.currentProgress = previousProgress + this.progress.setValue(previousProgress) if (value) { if (this.playbackMode.getValue() !== 'transcode') { diff --git a/service/src/app-models/media/actions/hls-master-action.ts b/service/src/app-models/media/actions/hls-master-action.ts index a092de67..2f380c3a 100644 --- a/service/src/app-models/media/actions/hls-master-action.ts +++ b/service/src/app-models/media/actions/hls-master-action.ts @@ -59,6 +59,7 @@ export const HlsMasterAction: RequestAction = async ({ mode, baseUrl: '/api/media', subtitleTracks, + audioTrack: query.audioTrack, }) await logger.verbose({ message: `Generated master playlist for ${letter}:${path}`, data: { mode } }) diff --git a/service/src/app-models/media/services/hls-manifest-generator.spec.ts b/service/src/app-models/media/services/hls-manifest-generator.spec.ts index e0fad3fb..8355708e 100644 --- a/service/src/app-models/media/services/hls-manifest-generator.spec.ts +++ b/service/src/app-models/media/services/hls-manifest-generator.spec.ts @@ -157,4 +157,47 @@ describe('generateMasterPlaylist', () => { expect(playlist).not.toContain('RESOLUTION=3840x2160') expect(playlist).toContain('RESOLUTION=1920x1080') }) + + it('should propagate audioTrack into variant URLs for transcode mode', () => { + const playlist = generateMasterPlaylist({ + ffprobe: createFfprobe(), + file: { driveLetter: 'A', path: 'movies/test.mkv' }, + mode: 'transcode', + baseUrl: '/api/media', + subtitleTracks: [], + audioTrack: 2, + }) + + expect(playlist).toContain('audioTrack') + const variantLines = playlist.split('\n').filter((l) => l.includes('stream.m3u8')) + for (const line of variantLines) { + expect(line).toContain('audioTrack') + } + }) + + it('should propagate audioTrack into variant URL for remux mode', () => { + const playlist = generateMasterPlaylist({ + ffprobe: createFfprobe(), + file: { driveLetter: 'A', path: 'movies/test.mkv' }, + mode: 'remux', + baseUrl: '/api/media', + subtitleTracks: [], + audioTrack: 3, + }) + + const variantLine = playlist.split('\n').find((l) => l.includes('stream.m3u8')) + expect(variantLine).toContain('audioTrack') + }) + + it('should not include audioTrack when not specified', () => { + const playlist = generateMasterPlaylist({ + ffprobe: createFfprobe(), + file: { driveLetter: 'A', path: 'movies/test.mkv' }, + mode: 'transcode', + baseUrl: '/api/media', + subtitleTracks: [], + }) + + expect(playlist).not.toContain('audioTrack') + }) }) diff --git a/service/src/app-models/media/services/hls-manifest-generator.ts b/service/src/app-models/media/services/hls-manifest-generator.ts index a9ea952c..a14e16da 100644 --- a/service/src/app-models/media/services/hls-manifest-generator.ts +++ b/service/src/app-models/media/services/hls-manifest-generator.ts @@ -22,12 +22,14 @@ export const generateMasterPlaylist = ({ mode, baseUrl, subtitleTracks, + audioTrack, }: { ffprobe: FfprobeData file: { driveLetter: string; path: string } mode: PlaybackMode baseUrl: string subtitleTracks: SubtitleTrackInfo[] + audioTrack?: number }): string => { const lines: string[] = ['#EXTM3U', '#EXT-X-VERSION:7'] @@ -47,11 +49,13 @@ export const generateMasterPlaylist = ({ const sourceWidth = videoStream?.width || 1920 const sourceBitrate = ffprobe.format.bit_rate || 5000000 + const audioTrackQuery = audioTrack !== undefined ? { audioTrack } : {} + if (mode === 'remux' || mode === 'direct-play' || mode === 'direct-stream') { const subtitleGroup = subtitleTracks.filter((t) => !t.requiresBurnIn).length > 0 ? ',SUBTITLES="subs"' : '' lines.push( `#EXT-X-STREAM-INF:BANDWIDTH=${sourceBitrate},RESOLUTION=${sourceWidth}x${sourceHeight},CODECS="${getCodecString(ffprobe)}"${subtitleGroup}`, - `${streamBase}/stream.m3u8?${serializeToQueryString({ mode })}`, + `${streamBase}/stream.m3u8?${serializeToQueryString({ mode, ...audioTrackQuery })}`, ) } else { const applicableVariants = DEFAULT_VARIANTS.filter((v) => v.height <= sourceHeight) @@ -64,7 +68,7 @@ export const generateMasterPlaylist = ({ for (const variant of applicableVariants) { lines.push( `#EXT-X-STREAM-INF:BANDWIDTH=${variant.bandwidth},RESOLUTION=${variant.resolution},CODECS="avc1.42E01E,mp4a.40.2"${subtitleGroup}`, - `${streamBase}/stream.m3u8?${serializeToQueryString({ mode: 'transcode' as PlaybackMode, resolution: `${variant.height}p` })}`, + `${streamBase}/stream.m3u8?${serializeToQueryString({ mode: 'transcode' as PlaybackMode, resolution: `${variant.height}p`, ...audioTrackQuery })}`, ) } } From 98d69004e74c0aeb3da0ce1515a9dfd45b697d03 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Wed, 11 Mar 2026 15:04:01 +0100 Subject: [PATCH 13/30] seeking improvements --- common/schemas/media-api.json | 15 ++ common/src/apis/media.ts | 17 +- .../movie-player-service.spec.ts | 163 +++++++++++++++- .../movie-player-v2/movie-player-service.ts | 115 ++++++++++- .../movie-player-v2-component.tsx | 5 + .../media/actions/hls-init-action.spec.ts | 2 + .../media/actions/hls-init-action.ts | 5 +- .../media/actions/hls-master-action.ts | 1 + .../media/actions/hls-segment-action.spec.ts | 2 +- .../media/actions/hls-segment-action.ts | 9 +- .../hls-session-teardown-action.spec.ts | 39 ++-- .../media/actions/hls-stream-action.spec.ts | 59 ++++++ .../media/actions/hls-stream-action.ts | 7 + .../services/hls-manifest-generator.spec.ts | 55 ++++++ .../media/services/hls-manifest-generator.ts | 7 +- .../services/transcoding-session.spec.ts | 183 ++++++++++++++++++ .../media/services/transcoding-session.ts | 37 +++- 17 files changed, 661 insertions(+), 60 deletions(-) diff --git a/common/schemas/media-api.json b/common/schemas/media-api.json index f4a3e5a7..44316181 100644 --- a/common/schemas/media-api.json +++ b/common/schemas/media-api.json @@ -10450,6 +10450,9 @@ }, "resolution": { "type": "string" + }, + "startTime": { + "type": "number" } }, "additionalProperties": false @@ -10492,6 +10495,9 @@ }, "audioTrack": { "type": "number" + }, + "startTime": { + "type": "number" } }, "additionalProperties": false @@ -10531,6 +10537,9 @@ }, "audioTrack": { "type": "number" + }, + "startTime": { + "type": "number" } }, "additionalProperties": false @@ -10567,6 +10576,9 @@ }, "audioTrack": { "type": "number" + }, + "startTime": { + "type": "number" } }, "additionalProperties": false @@ -10612,6 +10624,9 @@ }, "audioTrack": { "type": "number" + }, + "startTime": { + "type": "number" } }, "additionalProperties": false diff --git a/common/src/apis/media.ts b/common/src/apis/media.ts index cd2b1204..1911a045 100644 --- a/common/src/apis/media.ts +++ b/common/src/apis/media.ts @@ -177,31 +177,38 @@ export type PlaybackInfoResponse = { export type HlsMasterEndpoint = { url: { letter: string; path: string } - query: { mode?: PlaybackMode; videoCodecs?: string; audioCodecs?: string; containers?: string; audioTrack?: number } + query: { + mode?: PlaybackMode + videoCodecs?: string + audioCodecs?: string + containers?: string + audioTrack?: number + startTime?: number + } result: unknown } export type HlsStreamEndpoint = { url: { letter: string; path: string } - query: { mode?: PlaybackMode; resolution?: string; audioTrack?: number } + query: { mode?: PlaybackMode; resolution?: string; audioTrack?: number; startTime?: number } result: unknown } export type HlsSegmentEndpoint = { url: { letter: string; path: string; index: string } - query: { mode?: PlaybackMode; resolution?: string; audioTrack?: number } + query: { mode?: PlaybackMode; resolution?: string; audioTrack?: number; startTime?: number } result: unknown } export type HlsInitEndpoint = { url: { letter: string; path: string } - query: { mode?: PlaybackMode; audioTrack?: number; resolution?: string } + query: { mode?: PlaybackMode; audioTrack?: number; resolution?: string; startTime?: number } result: unknown } export type HlsSessionTeardownEndpoint = { url: { letter: string; path: string } - query: { mode?: PlaybackMode; resolution?: string; audioTrack?: number } + query: { mode?: PlaybackMode; resolution?: string; audioTrack?: number; startTime?: number } result: { success: boolean } } diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-service.spec.ts b/frontend/src/pages/movies/movie-player-v2/movie-player-service.spec.ts index 3c48cffc..339b781c 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-service.spec.ts +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-service.spec.ts @@ -3,13 +3,22 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { usingAsync } from '@furystack/utils' import { MoviePlayerService, videoCodecs, audioCodecs } from './movie-player-service.js' -vi.mock('hls.js', () => ({ - default: { +vi.mock('hls.js', () => { + class MockHls { + on = vi.fn() + loadSource = vi.fn() + attachMedia = vi.fn() + destroy = vi.fn() + levels: unknown[] = [] + currentLevel = -1 + } + Object.assign(MockHls, { isSupported: () => true, - Events: { ERROR: 'hlsError', MANIFEST_PARSED: 'hlsManifestParsed' }, + Events: { ERROR: 'hlsError', MANIFEST_PARSED: 'hlsManifestParsed', DESTROYING: 'hlsDestroying' }, ErrorTypes: { NETWORK_ERROR: 'networkError', MEDIA_ERROR: 'mediaError' }, - }, -})) + }) + return { default: MockHls } +}) const mockLogger = { verbose: vi.fn().mockResolvedValue(undefined), @@ -306,7 +315,11 @@ describe('switchResolution', () => { expect(service.playbackInfo.getValue()).not.toBeNull() }) - const mockVideo = { currentTime: 75 } as HTMLVideoElement + const mockVideo = { + currentTime: 75, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as HTMLVideoElement service.videoElement = mockVideo await service.switchResolution('480p') @@ -362,3 +375,141 @@ describe('switchResolution', () => { ) }) }) + +describe('seekToTime', () => { + let api: ReturnType + + beforeEach(() => { + api = createMockApi() + vi.clearAllMocks() + }) + + it('should not seek in direct-play mode', async () => { + const directPlayResponse: PlaybackInfoResponse = { + ...mockPlaybackInfoResponse, + mode: 'direct-play', + } + api.call.mockResolvedValue({ result: directPlayResponse }) + + await usingAsync( + new MoviePlayerService(mockFile, mockFfprobe, api as never, 0, mockLogger as never), + async (service) => { + await vi.waitFor(() => { + expect(service.playbackInfo.getValue()).not.toBeNull() + }) + + api.call.mockClear() + service.seekToTime(3600) + expect(api.call).not.toHaveBeenCalled() + }, + ) + }) + + it('should not seek when already switching', async () => { + await usingAsync( + new MoviePlayerService(mockFile, mockFfprobe, api as never, 0, mockLogger as never), + async (service) => { + await vi.waitFor(() => { + expect(service.playbackInfo.getValue()).not.toBeNull() + }) + + await service.switchAudioTrack(2) + api.call.mockClear() + service.seekToTime(3600) + expect(api.call).not.toHaveBeenCalled() + }, + ) + }) + + it('should not restart if target is within buffered range', async () => { + await usingAsync( + new MoviePlayerService(mockFile, mockFfprobe, api as never, 0, mockLogger as never), + async (service) => { + await vi.waitFor(() => { + expect(service.playbackInfo.getValue()).not.toBeNull() + }) + + const mockVideo = { + currentTime: 10, + buffered: { + length: 1, + start: () => 0, + end: () => 30, + }, + } as unknown as HTMLVideoElement + service.videoElement = mockVideo + + api.call.mockClear() + service.seekToTime(15) + expect(api.call).not.toHaveBeenCalled() + }, + ) + }) + + it('should not restart if quantized startTime is the same', async () => { + await usingAsync( + new MoviePlayerService(mockFile, mockFfprobe, api as never, 0, mockLogger as never), + async (service) => { + await vi.waitFor(() => { + expect(service.playbackInfo.getValue()).not.toBeNull() + }) + + const mockVideo = { + currentTime: 0, + buffered: { length: 0, start: () => 0, end: () => 0 }, + } as unknown as HTMLVideoElement + service.videoElement = mockVideo + + api.call.mockClear() + service.seekToTime(3) + expect(api.call).not.toHaveBeenCalled() + }, + ) + }) + + it('should restart HLS session when seeking beyond buffered range', async () => { + await usingAsync( + new MoviePlayerService(mockFile, mockFfprobe, api as never, 0, mockLogger as never), + async (service) => { + await vi.waitFor(() => { + expect(service.playbackInfo.getValue()).not.toBeNull() + }) + + const mockVideo = { + currentTime: 10, + buffered: { + length: 1, + start: () => 0, + end: () => 30, + }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + canPlayType: vi.fn().mockReturnValue(''), + } as unknown as HTMLVideoElement + service.videoElement = mockVideo + + api.call.mockClear() + service.seekToTime(3600) + + await vi.waitFor(() => { + expect(api.call).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'DELETE', + action: '/files/:letter/:path/hls-session', + }), + ) + expect(service.progress.getValue()).toBe(3600) + }) + }, + ) + }) + + it('should initialize hlsStartTime from watch progress', async () => { + await usingAsync( + new MoviePlayerService(mockFile, mockFfprobe, api as never, 3605, mockLogger as never), + async (service) => { + expect(service.progress.getValue()).toBe(3605) + }, + ) + }) +}) diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts b/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts index 208b8373..47526e7d 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts @@ -60,6 +60,8 @@ const buildCodecSupportMap = () => { export type ResolutionValue = '4k' | '1080p' | '720p' | '480p' | '360p' +const SEGMENT_DURATION = 6 + export class MoviePlayerService implements AsyncDisposable { constructor( private readonly file: PiRatFile, @@ -69,12 +71,15 @@ export class MoviePlayerService implements AsyncDisposable { private readonly logger: ScopedLogger, ) { this.progress = new ObservableValue(this.currentProgress) + this.hlsStartTime = Math.floor(this.currentProgress / SEGMENT_DURATION) * SEGMENT_DURATION void this.initialize() } private hls: Hls | null = null private originalPlaybackMode: PlaybackMode = 'transcode' + private isSwitching = false + private hlsStartTime = 0 public videoElement: HTMLVideoElement | null = null public audioTrackId = new ObservableValue(0) public playbackInfo = new ObservableValue(null) @@ -112,6 +117,7 @@ export class MoviePlayerService implements AsyncDisposable { mode, audioTrack: this.audioTrackId.getValue(), resolution: this.resolution.getValue(), + startTime: this.hlsStartTime || undefined, }, }) } catch (error) { @@ -139,8 +145,8 @@ export class MoviePlayerService implements AsyncDisposable { selectedSubtitleTrackIndex, }, }) - this.playbackInfo.setValue(result) this.playbackMode.setValue(result.mode) + this.playbackInfo.setValue(result) void this.logger.verbose({ message: `Playback info received: mode=${result.mode}`, @@ -206,8 +212,9 @@ export class MoviePlayerService implements AsyncDisposable { const mode = this.playbackMode.getValue() const audioTrack = this.audioTrackId.getValue() const audioParam = audioTrack ? `&audioTrack=${encode(String(audioTrack))}` : '' + const startTimeParam = this.hlsStartTime > 0 ? `&startTime=${encode(String(this.hlsStartTime))}` : '' const hlsUrl = this.toServiceUrl( - `/api/media/files/${encodeURIComponent(this.file.driveLetter)}/${encodeURIComponent(this.file.path)}/master.m3u8?mode=${encode(mode)}${audioParam}`, + `/api/media/files/${encodeURIComponent(this.file.driveLetter)}/${encodeURIComponent(this.file.path)}/master.m3u8?mode=${encode(mode)}${audioParam}${startTimeParam}`, ) const HlsModule = await loadHls() @@ -289,47 +296,137 @@ export class MoviePlayerService implements AsyncDisposable { public async switchAudioTrack(trackIndex: number) { const previousProgress = this.videoElement?.currentTime ?? this.progress.getValue() + this.isSwitching = true await this.teardownHlsSession() this.audioTrackId.setValue(trackIndex) this.currentProgress = previousProgress + this.progress.setValue(previousProgress) + this.hlsStartTime = Math.floor(previousProgress / SEGMENT_DURATION) * SEGMENT_DURATION await this.fetchPlaybackInfo() const info = this.playbackInfo.getValue() if (this.videoElement && info) { this.startPlayback(this.videoElement, info) + const video = this.videoElement + const onCanPlay = () => { + video.removeEventListener('canplay', onCanPlay) + this.isSwitching = false + void video.play().catch(() => {}) + } + video.addEventListener('canplay', onCanPlay) + } else { + this.isSwitching = false } } /** * Switches resolution and restarts playback. Forces transcode mode when a * specific resolution is requested; restores the original mode on "Auto". + * + * If already in transcode mode, just changes hls.js level (no reload). + * A full reload only happens when the playback mode changes. */ public async switchResolution(value: ResolutionValue | undefined) { + const currentMode = this.playbackMode.getValue() + const targetMode = value ? 'transcode' : this.originalPlaybackMode + + if (currentMode === 'transcode' && targetMode === 'transcode') { + this.resolution.setValue(value) + return + } + const previousProgress = this.videoElement?.currentTime ?? this.progress.getValue() await this.teardownHlsSession() + this.isSwitching = true this.resolution.setValue(value) this.currentProgress = previousProgress this.progress.setValue(previousProgress) + this.hlsStartTime = Math.floor(previousProgress / SEGMENT_DURATION) * SEGMENT_DURATION + this.playbackMode.setValue(targetMode) - if (value) { - if (this.playbackMode.getValue() !== 'transcode') { - this.playbackMode.setValue('transcode') + if (this.videoElement) { + const info = this.playbackInfo.getValue() + if (info) { + this.startPlayback(this.videoElement, info) + const video = this.videoElement + const onCanPlay = () => { + video.removeEventListener('canplay', onCanPlay) + this.isSwitching = false + void video.play().catch(() => {}) + } + video.addEventListener('canplay', onCanPlay) + } else { + this.isSwitching = false } } else { - this.playbackMode.setValue(this.originalPlaybackMode) + this.isSwitching = false } + } + + /** + * Handles a seek to a new time position. If the target is not within the + * video's buffered ranges and we're in HLS mode, tears down the current + * session and starts a new one with server-side seeking via `-ss`. + */ + public seekToTime(targetSeconds: number) { + const mode = this.playbackMode.getValue() + if (mode === 'direct-play' || this.isSwitching) return + + const video = this.videoElement + if (!video) return + + if (this.isTimeBuffered(video, targetSeconds)) return + + const quantizedStart = Math.floor(targetSeconds / SEGMENT_DURATION) * SEGMENT_DURATION + if (quantizedStart === this.hlsStartTime) return + + void this.restartHlsAtTime(targetSeconds, quantizedStart) + } + + private isTimeBuffered(video: HTMLVideoElement, time: number): boolean { + const { buffered } = video + for (let i = 0; i < buffered.length; i++) { + if (time >= buffered.start(i) && time <= buffered.end(i)) { + return true + } + } + return false + } + + private async restartHlsAtTime(targetSeconds: number, quantizedStart: number) { + this.isSwitching = true + + await this.teardownHlsSession() + if (this.hls) { + this.hls.destroy() + this.hls = null + } + + this.hlsStartTime = quantizedStart + this.currentProgress = targetSeconds + this.progress.setValue(targetSeconds) if (this.videoElement) { - const info = this.playbackInfo.getValue() - if (info) { - this.startPlayback(this.videoElement, info) + void this.startHlsPlayback(this.videoElement) + const video = this.videoElement + const onCanPlay = () => { + video.removeEventListener('canplay', onCanPlay) + this.isSwitching = false + void video.play().catch(() => {}) } + video.addEventListener('canplay', onCanPlay) + } else { + this.isSwitching = false } } + public getIsSwitching(): boolean { + return this.isSwitching + } + public getAudioTrackInfoFromPlaybackInfo(): AudioTrackInfo[] { return this.playbackInfo.getValue()?.audioTracks ?? [] } diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx index 90e8386d..104fbb82 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx @@ -231,7 +231,12 @@ export const MoviePlayerV2 = Shade({ mediaService.audioTrackId.setValue(parseInt(video.audioTracks[0].id as string, 10)) } }} + onseeking={(ev) => { + const { currentTime } = ev.currentTarget as HTMLVideoElement + mediaService.seekToTime(currentTime) + }} ontimeupdate={(ev) => { + if (mediaService.getIsSwitching()) return const { currentTime } = ev.currentTarget as HTMLVideoElement mediaService.progress.setValue(currentTime || 0) }} diff --git a/service/src/app-models/media/actions/hls-init-action.spec.ts b/service/src/app-models/media/actions/hls-init-action.spec.ts index 01397052..d0efd29b 100644 --- a/service/src/app-models/media/actions/hls-init-action.spec.ts +++ b/service/src/app-models/media/actions/hls-init-action.spec.ts @@ -145,6 +145,8 @@ describe('HlsInitAction', () => { path: 'test.mkv', mode: 'transcode', audioTrackId: 1, + resolution: undefined, + startTime: 0, }) }) }) diff --git a/service/src/app-models/media/actions/hls-init-action.ts b/service/src/app-models/media/actions/hls-init-action.ts index b077f48e..f760673c 100644 --- a/service/src/app-models/media/actions/hls-init-action.ts +++ b/service/src/app-models/media/actions/hls-init-action.ts @@ -27,8 +27,10 @@ export const HlsInitAction: RequestAction = async ({ injector, const sessionService = injector.getInstance(TranscodingSessionService) + const startTime = query.startTime ?? 0 + // The session should already exist (created when stream.m3u8 was requested) - let session = sessionService.getSession(letter, path, mode, query.audioTrack ?? 0, query.resolution) + let session = sessionService.getSession(letter, path, mode, query.audioTrack ?? 0, query.resolution, startTime) if (!session) { // Create one if it doesn't exist (init might be requested before stream.m3u8) session = await sessionService.getOrCreateSession({ @@ -37,6 +39,7 @@ export const HlsInitAction: RequestAction = async ({ injector, mode, audioTrackId: query.audioTrack ?? 0, resolution: query.resolution, + startTime, }) } diff --git a/service/src/app-models/media/actions/hls-master-action.ts b/service/src/app-models/media/actions/hls-master-action.ts index 2f380c3a..b9fa316e 100644 --- a/service/src/app-models/media/actions/hls-master-action.ts +++ b/service/src/app-models/media/actions/hls-master-action.ts @@ -60,6 +60,7 @@ export const HlsMasterAction: RequestAction = async ({ baseUrl: '/api/media', subtitleTracks, audioTrack: query.audioTrack, + startTime: query.startTime, }) await logger.verbose({ message: `Generated master playlist for ${letter}:${path}`, data: { mode } }) diff --git a/service/src/app-models/media/actions/hls-segment-action.spec.ts b/service/src/app-models/media/actions/hls-segment-action.spec.ts index bfff7d48..4efb2cc3 100644 --- a/service/src/app-models/media/actions/hls-segment-action.spec.ts +++ b/service/src/app-models/media/actions/hls-segment-action.spec.ts @@ -244,7 +244,7 @@ describe('HlsSegmentAction', () => { request: {} as IncomingMessage, }) - expect(getSession).toHaveBeenCalledWith('A', 'test.mkv', 'transcode', 2, '720p') + expect(getSession).toHaveBeenCalledWith('A', 'test.mkv', 'transcode', 2, '720p', 0) }) }) }) diff --git a/service/src/app-models/media/actions/hls-segment-action.ts b/service/src/app-models/media/actions/hls-segment-action.ts index 63fc7438..2f9e2673 100644 --- a/service/src/app-models/media/actions/hls-segment-action.ts +++ b/service/src/app-models/media/actions/hls-segment-action.ts @@ -37,7 +37,14 @@ export const HlsSegmentAction: RequestAction = async ({ const sessionService = injector.getInstance(TranscodingSessionService) - const session = sessionService.getSession(letter, path, mode, query.audioTrack ?? 0, query.resolution) + const session = sessionService.getSession( + letter, + path, + mode, + query.audioTrack ?? 0, + query.resolution, + query.startTime ?? 0, + ) if (!session) { throw new RequestError('No active transcoding session', 404) } diff --git a/service/src/app-models/media/actions/hls-session-teardown-action.spec.ts b/service/src/app-models/media/actions/hls-session-teardown-action.spec.ts index b4ab10ae..795d8aef 100644 --- a/service/src/app-models/media/actions/hls-session-teardown-action.spec.ts +++ b/service/src/app-models/media/actions/hls-session-teardown-action.spec.ts @@ -32,28 +32,14 @@ describe('HlsSessionTeardownAction', () => { }) }) - it('should reject invalid playback mode', async () => { - await usingAsync(new Injector(), async (injector) => { - try { - await HlsSessionTeardownAction({ - injector, - getUrlParams: () => ({ letter: 'A', path: 'test.mkv' }), - getQuery: () => ({ mode: 'invalid' as never }), - response: {} as ServerResponse, - request: {} as IncomingMessage, - }) - expect.fail('Should have thrown') - } catch (error) { - expect((error as Error).message).toContain('Invalid playback mode') - } - }) - }) - - it('should call removeSession and return success', async () => { - const removeSession = vi.fn() + it('should call removeAllSessionsForFile and return success', async () => { + const removeAllSessionsForFile = vi.fn() await usingAsync(new Injector(), async (injector) => { - injector.setExplicitInstance({ removeSession } as unknown as TranscodingSessionService, TranscodingSessionService) + injector.setExplicitInstance( + { removeAllSessionsForFile } as unknown as TranscodingSessionService, + TranscodingSessionService, + ) const result = await HlsSessionTeardownAction({ injector, @@ -63,7 +49,7 @@ describe('HlsSessionTeardownAction', () => { request: {} as IncomingMessage, }) - expect(removeSession).toHaveBeenCalledWith('A', 'test.mkv', 'transcode', 1, '720p') + expect(removeAllSessionsForFile).toHaveBeenCalledWith('A', 'test.mkv') expect(result).toEqual({ chunk: { success: true }, headers: { 'Content-Type': 'application/json' }, @@ -72,11 +58,14 @@ describe('HlsSessionTeardownAction', () => { }) }) - it('should default mode to transcode and audioTrack to 0', async () => { - const removeSession = vi.fn() + it('should remove all sessions regardless of query params', async () => { + const removeAllSessionsForFile = vi.fn() await usingAsync(new Injector(), async (injector) => { - injector.setExplicitInstance({ removeSession } as unknown as TranscodingSessionService, TranscodingSessionService) + injector.setExplicitInstance( + { removeAllSessionsForFile } as unknown as TranscodingSessionService, + TranscodingSessionService, + ) await HlsSessionTeardownAction({ injector, @@ -86,7 +75,7 @@ describe('HlsSessionTeardownAction', () => { request: {} as IncomingMessage, }) - expect(removeSession).toHaveBeenCalledWith('B', 'movie.mp4', 'transcode', 0, undefined) + expect(removeAllSessionsForFile).toHaveBeenCalledWith('B', 'movie.mp4') }) }) }) diff --git a/service/src/app-models/media/actions/hls-stream-action.spec.ts b/service/src/app-models/media/actions/hls-stream-action.spec.ts index e5edd744..015fda45 100644 --- a/service/src/app-models/media/actions/hls-stream-action.spec.ts +++ b/service/src/app-models/media/actions/hls-stream-action.spec.ts @@ -240,7 +240,66 @@ describe('HlsStreamAction', () => { mode: 'transcode', audioTrackId: 2, resolution: '1080p', + startTime: 0, }) }) }) + + it('should pass startTime to session creation and include in segment URLs', async () => { + const getOrCreateSession = vi.fn().mockResolvedValue(mockSession) + let writtenBody = '' + const response = { + writeHead: vi.fn(), + end: vi.fn((body: string) => { + writtenBody = body + }), + } + + await usingAsync(new Injector(), async (injector) => { + injector.setExplicitInstance( + { + getOrCreateSession, + readPlaylist: vi.fn().mockResolvedValue(MOCK_PLAYLIST), + } as unknown as TranscodingSessionService, + TranscodingSessionService, + ) + + await HlsStreamAction({ + injector, + getUrlParams: () => ({ letter: 'A', path: 'test.mkv' }), + getQuery: () => ({ mode: 'transcode', startTime: 3600 }), + response: response as unknown as ServerResponse, + request: {} as IncomingMessage, + }) + + expect(getOrCreateSession).toHaveBeenCalledWith( + expect.objectContaining({ + startTime: 3600, + }), + ) + expect(writtenBody).toContain('startTime') + }) + }) + + it('should reject negative startTime', async () => { + await usingAsync(new Injector(), async (injector) => { + injector.setExplicitInstance( + { getOrCreateSession: vi.fn(), readPlaylist: vi.fn() } as unknown as TranscodingSessionService, + TranscodingSessionService, + ) + + try { + await HlsStreamAction({ + injector, + getUrlParams: () => ({ letter: 'A', path: 'test.mkv' }), + getQuery: () => ({ mode: 'transcode', startTime: -10 }), + response: { writeHead: vi.fn(), end: vi.fn() } as unknown as ServerResponse, + request: {} as IncomingMessage, + }) + expect.fail('Should have thrown') + } catch (error) { + expect((error as Error).message).toContain('Invalid startTime') + } + }) + }) }) diff --git a/service/src/app-models/media/actions/hls-stream-action.ts b/service/src/app-models/media/actions/hls-stream-action.ts index 378d0b29..56dadfe4 100644 --- a/service/src/app-models/media/actions/hls-stream-action.ts +++ b/service/src/app-models/media/actions/hls-stream-action.ts @@ -29,12 +29,18 @@ export const HlsStreamAction: RequestAction = async ({ const sessionService = injector.getInstance(TranscodingSessionService) + const startTime = query.startTime ?? 0 + if (startTime < 0) { + throw new RequestError('Invalid startTime', 400) + } + const session = await sessionService.getOrCreateSession({ driveLetter: letter, path, mode, audioTrackId: query.audioTrack ?? 0, resolution: query.resolution, + startTime, }) const playlistContent = await sessionService.readPlaylist(session) @@ -51,6 +57,7 @@ export const HlsStreamAction: RequestAction = async ({ mode, audioTrack: query.audioTrack ?? 0, ...(query.resolution ? { resolution: query.resolution } : {}), + ...(startTime > 0 ? { startTime } : {}), } const serializedQuery = serializeToQueryString(queryParams) diff --git a/service/src/app-models/media/services/hls-manifest-generator.spec.ts b/service/src/app-models/media/services/hls-manifest-generator.spec.ts index 8355708e..8bc76554 100644 --- a/service/src/app-models/media/services/hls-manifest-generator.spec.ts +++ b/service/src/app-models/media/services/hls-manifest-generator.spec.ts @@ -200,4 +200,59 @@ describe('generateMasterPlaylist', () => { expect(playlist).not.toContain('audioTrack') }) + + it('should propagate startTime into variant URLs for transcode mode', () => { + const playlist = generateMasterPlaylist({ + ffprobe: createFfprobe(), + file: { driveLetter: 'A', path: 'movies/test.mkv' }, + mode: 'transcode', + baseUrl: '/api/media', + subtitleTracks: [], + startTime: 3600, + }) + + const variantLines = playlist.split('\n').filter((l) => l.includes('stream.m3u8')) + for (const line of variantLines) { + expect(line).toContain('startTime') + } + }) + + it('should propagate startTime into variant URL for remux mode', () => { + const playlist = generateMasterPlaylist({ + ffprobe: createFfprobe(), + file: { driveLetter: 'A', path: 'movies/test.mkv' }, + mode: 'remux', + baseUrl: '/api/media', + subtitleTracks: [], + startTime: 1800, + }) + + const variantLine = playlist.split('\n').find((l) => l.includes('stream.m3u8')) + expect(variantLine).toContain('startTime') + }) + + it('should not include startTime when not specified', () => { + const playlist = generateMasterPlaylist({ + ffprobe: createFfprobe(), + file: { driveLetter: 'A', path: 'movies/test.mkv' }, + mode: 'transcode', + baseUrl: '/api/media', + subtitleTracks: [], + }) + + expect(playlist).not.toContain('startTime') + }) + + it('should not include startTime when set to 0', () => { + const playlist = generateMasterPlaylist({ + ffprobe: createFfprobe(), + file: { driveLetter: 'A', path: 'movies/test.mkv' }, + mode: 'transcode', + baseUrl: '/api/media', + subtitleTracks: [], + startTime: 0, + }) + + expect(playlist).not.toContain('startTime') + }) }) diff --git a/service/src/app-models/media/services/hls-manifest-generator.ts b/service/src/app-models/media/services/hls-manifest-generator.ts index a14e16da..11283692 100644 --- a/service/src/app-models/media/services/hls-manifest-generator.ts +++ b/service/src/app-models/media/services/hls-manifest-generator.ts @@ -23,6 +23,7 @@ export const generateMasterPlaylist = ({ baseUrl, subtitleTracks, audioTrack, + startTime, }: { ffprobe: FfprobeData file: { driveLetter: string; path: string } @@ -30,6 +31,7 @@ export const generateMasterPlaylist = ({ baseUrl: string subtitleTracks: SubtitleTrackInfo[] audioTrack?: number + startTime?: number }): string => { const lines: string[] = ['#EXTM3U', '#EXT-X-VERSION:7'] @@ -50,12 +52,13 @@ export const generateMasterPlaylist = ({ const sourceBitrate = ffprobe.format.bit_rate || 5000000 const audioTrackQuery = audioTrack !== undefined ? { audioTrack } : {} + const startTimeQuery = startTime && startTime > 0 ? { startTime } : {} if (mode === 'remux' || mode === 'direct-play' || mode === 'direct-stream') { const subtitleGroup = subtitleTracks.filter((t) => !t.requiresBurnIn).length > 0 ? ',SUBTITLES="subs"' : '' lines.push( `#EXT-X-STREAM-INF:BANDWIDTH=${sourceBitrate},RESOLUTION=${sourceWidth}x${sourceHeight},CODECS="${getCodecString(ffprobe)}"${subtitleGroup}`, - `${streamBase}/stream.m3u8?${serializeToQueryString({ mode, ...audioTrackQuery })}`, + `${streamBase}/stream.m3u8?${serializeToQueryString({ mode, ...audioTrackQuery, ...startTimeQuery })}`, ) } else { const applicableVariants = DEFAULT_VARIANTS.filter((v) => v.height <= sourceHeight) @@ -68,7 +71,7 @@ export const generateMasterPlaylist = ({ for (const variant of applicableVariants) { lines.push( `#EXT-X-STREAM-INF:BANDWIDTH=${variant.bandwidth},RESOLUTION=${variant.resolution},CODECS="avc1.42E01E,mp4a.40.2"${subtitleGroup}`, - `${streamBase}/stream.m3u8?${serializeToQueryString({ mode: 'transcode' as PlaybackMode, resolution: `${variant.height}p`, ...audioTrackQuery })}`, + `${streamBase}/stream.m3u8?${serializeToQueryString({ mode: 'transcode' as PlaybackMode, resolution: `${variant.height}p`, ...audioTrackQuery, ...startTimeQuery })}`, ) } } diff --git a/service/src/app-models/media/services/transcoding-session.spec.ts b/service/src/app-models/media/services/transcoding-session.spec.ts index 615c0893..b8288fe4 100644 --- a/service/src/app-models/media/services/transcoding-session.spec.ts +++ b/service/src/app-models/media/services/transcoding-session.spec.ts @@ -1034,5 +1034,188 @@ describe('TranscodingSessionService', () => { } }) }) + + it('should include -ss before -i when startTime is set', async () => { + const mockProcess = createMockProcess() + mockSpawn.mockReturnValue(mockProcess) + + await usingAsync(new Injector(), async (injector) => { + injector.setExplicitInstance( + { getFfprobeForPiratFile: vi.fn().mockResolvedValue(mockFfprobe) } as unknown as FfprobeService, + FfprobeService, + ) + injector.setExplicitInstance( + { getEncoder: vi.fn().mockResolvedValue('libx264') } as unknown as HwAccelDetector, + HwAccelDetector, + ) + + const service = injector.getInstance(TranscodingSessionService) + try { + await service.getOrCreateSession({ + driveLetter: 'A', + path: 'test.mkv', + mode: 'transcode', + startTime: 3600, + }) + + const lastCall = mockSpawn.mock.lastCall as [string, string[]] + const args = lastCall[1] + const ssIndex = args.indexOf('-ss') + const iIndex = args.indexOf('-i') + expect(ssIndex).toBeGreaterThanOrEqual(0) + expect(args[ssIndex + 1]).toBe('3600') + expect(ssIndex).toBeLessThan(iIndex) + } finally { + service.dispose() + } + }) + }) + + it('should not include -ss when startTime is 0', async () => { + const mockProcess = createMockProcess() + mockSpawn.mockReturnValue(mockProcess) + + await usingAsync(new Injector(), async (injector) => { + injector.setExplicitInstance( + { getFfprobeForPiratFile: vi.fn().mockResolvedValue(mockFfprobe) } as unknown as FfprobeService, + FfprobeService, + ) + injector.setExplicitInstance( + { getEncoder: vi.fn().mockResolvedValue('libx264') } as unknown as HwAccelDetector, + HwAccelDetector, + ) + + const service = injector.getInstance(TranscodingSessionService) + try { + await service.getOrCreateSession({ + driveLetter: 'A', + path: 'test.mkv', + mode: 'transcode', + startTime: 0, + }) + + const lastCall = mockSpawn.mock.lastCall as [string, string[]] + const args = lastCall[1] + expect(args).not.toContain('-ss') + } finally { + service.dispose() + } + }) + }) + + it('should offset -force_key_frames by startTime for transcode mode', async () => { + const mockProcess = createMockProcess() + mockSpawn.mockReturnValue(mockProcess) + + await usingAsync(new Injector(), async (injector) => { + injector.setExplicitInstance( + { getFfprobeForPiratFile: vi.fn().mockResolvedValue(mockFfprobe) } as unknown as FfprobeService, + FfprobeService, + ) + injector.setExplicitInstance( + { getEncoder: vi.fn().mockResolvedValue('libx264') } as unknown as HwAccelDetector, + HwAccelDetector, + ) + + const service = injector.getInstance(TranscodingSessionService) + try { + await service.getOrCreateSession({ + driveLetter: 'A', + path: 'test.mkv', + mode: 'transcode', + startTime: 3600, + }) + + const lastCall = mockSpawn.mock.lastCall as [string, string[]] + const args = lastCall[1] + const fkfIndex = args.indexOf('-force_key_frames') + expect(fkfIndex).toBeGreaterThanOrEqual(0) + expect(args[fkfIndex + 1]).toBe('expr:gte(t,n_forced*6+3600)') + } finally { + service.dispose() + } + }) + }) + + it('should create separate sessions for different startTime values', async () => { + const mockProcess = createMockProcess() + mockSpawn.mockReturnValue(mockProcess) + + await usingAsync(new Injector(), async (injector) => { + injector.setExplicitInstance( + { getFfprobeForPiratFile: vi.fn().mockResolvedValue(mockFfprobe) } as unknown as FfprobeService, + FfprobeService, + ) + injector.setExplicitInstance( + { getEncoder: vi.fn().mockResolvedValue('libx264') } as unknown as HwAccelDetector, + HwAccelDetector, + ) + + const service = injector.getInstance(TranscodingSessionService) + try { + const session1 = await service.getOrCreateSession({ + driveLetter: 'A', + path: 'test.mkv', + mode: 'transcode', + startTime: 0, + }) + const session2 = await service.getOrCreateSession({ + driveLetter: 'A', + path: 'test.mkv', + mode: 'transcode', + startTime: 3600, + }) + + expect(session1).not.toBe(session2) + expect(mockSpawn).toHaveBeenCalledTimes(2) + expect(service.getActiveSessionCount()).toBe(2) + } finally { + service.dispose() + } + }) + }) + }) + + describe('padPlaylistToFullDuration with startTime', () => { + const testDir = join(tmpdir(), 'pirat-test-pad-starttime') + + beforeEach(() => { + if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true }) + }) + + afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }) + }) + + it('should pad based on remaining duration from startTime', async () => { + vi.useRealTimers() + const playlistContent = [ + '#EXTM3U', + '#EXT-X-VERSION:7', + '#EXT-X-TARGETDURATION:6', + '#EXT-X-MAP:URI="init.mp4"', + '#EXTINF:6.000000,', + 'segment0.m4s', + '', + ].join('\n') + writeFileSync(join(testDir, 'playlist.m3u8'), playlistContent) + + const service = new TranscodingSessionService() + try { + const mockSession = { + sessionDir: testDir, + state: 'running' as const, + totalDuration: 120, + startTime: 108, + } as Parameters[0] + + const result = await service.readPlaylist(mockSession) + expect(result).toContain('#EXT-X-ENDLIST') + expect(result).toContain('segment1.m4s') + expect(result).not.toContain('segment3.m4s') + } finally { + service.dispose() + } + }) }) }) diff --git a/service/src/app-models/media/services/transcoding-session.ts b/service/src/app-models/media/services/transcoding-session.ts index bc889c02..741cba27 100644 --- a/service/src/app-models/media/services/transcoding-session.ts +++ b/service/src/app-models/media/services/transcoding-session.ts @@ -27,6 +27,7 @@ type TranscodingSessionEntry = { path: string audioTrackId: number resolution?: string + startTime: number totalDuration: number createdAt: number lastAccessedAt: number @@ -72,8 +73,9 @@ export class TranscodingSessionService { mode: PlaybackMode, audioTrackId: number, resolution?: string, + startTime: number = 0, ): SessionKey { - return `${driveLetter}:${path}:${mode}:${audioTrackId}:${resolution || ''}` + return `${driveLetter}:${path}:${mode}:${audioTrackId}:${resolution || ''}:${startTime}` } private getSessionDir(key: SessionKey): string { @@ -114,8 +116,9 @@ export class TranscodingSessionService { mode: PlaybackMode, audioTrackId: number = 0, resolution?: string, + startTime: number = 0, ): TranscodingSessionEntry | undefined { - const key = this.buildSessionKey(driveLetter, path, mode, audioTrackId, resolution) + const key = this.buildSessionKey(driveLetter, path, mode, audioTrackId, resolution, startTime) const session = this.sessions.get(key) if (session) { session.lastAccessedAt = Date.now() @@ -129,14 +132,16 @@ export class TranscodingSessionService { mode, audioTrackId = 0, resolution, + startTime = 0, }: { driveLetter: string path: string mode: PlaybackMode audioTrackId?: number resolution?: string + startTime?: number }): Promise { - const key = this.buildSessionKey(driveLetter, path, mode, audioTrackId, resolution) + const key = this.buildSessionKey(driveLetter, path, mode, audioTrackId, resolution, startTime) const existing = this.sessions.get(key) if (existing) { existing.lastAccessedAt = Date.now() @@ -146,7 +151,7 @@ export class TranscodingSessionService { const pending = this.pendingSessions.get(key) if (pending) return pending - const createPromise = this.createSession(key, driveLetter, path, mode, audioTrackId, resolution) + const createPromise = this.createSession(key, driveLetter, path, mode, audioTrackId, resolution, startTime) this.pendingSessions.set(key, createPromise) try { @@ -163,6 +168,7 @@ export class TranscodingSessionService { mode: PlaybackMode, audioTrackId: number, resolution?: string, + startTime: number = 0, ): Promise { await this.getBaseDirFromConfig() const sessionDir = this.getSessionDir(key) @@ -177,6 +183,7 @@ export class TranscodingSessionService { audioTrackId, resolution, sessionDir, + startTime, }) void this.logger.verbose({ @@ -198,6 +205,7 @@ export class TranscodingSessionService { path, audioTrackId, resolution, + startTime, totalDuration, createdAt: Date.now(), lastAccessedAt: Date.now(), @@ -283,7 +291,7 @@ export class TranscodingSessionService { const ready = await this.waitForFile(playlistPath, session) if (!ready) return null const content = await readFile(playlistPath, 'utf-8') - return this.padPlaylistToFullDuration(content, session.totalDuration) + return this.padPlaylistToFullDuration(content, session.totalDuration, session.startTime) } /** @@ -292,7 +300,7 @@ export class TranscodingSessionService { * full VOD duration from the first request. The segment-serving endpoint * already waits for segments that haven't been transcoded yet. */ - private padPlaylistToFullDuration(playlist: string, totalDuration: number): string { + private padPlaylistToFullDuration(playlist: string, totalDuration: number, startTime: number = 0): string { if (totalDuration <= 0 || playlist.includes('#EXT-X-ENDLIST')) { return playlist } @@ -312,7 +320,8 @@ export class TranscodingSessionService { } } - const remainingDuration = totalDuration - encodedDuration + const effectiveDuration = totalDuration - startTime + const remainingDuration = effectiveDuration - encodedDuration if (remainingDuration <= 0) { return `${playlist.trimEnd()}\n#EXT-X-ENDLIST\n` } @@ -346,6 +355,7 @@ export class TranscodingSessionService { audioTrackId, resolution, sessionDir, + startTime = 0, }: { driveLetter: string path: string @@ -353,6 +363,7 @@ export class TranscodingSessionService { audioTrackId: number resolution?: string sessionDir: string + startTime?: number }): Promise<{ args: string[]; totalDuration: number }> { const [drive, config, ffprobe] = await Promise.all([ (async () => { @@ -381,6 +392,11 @@ export class TranscodingSessionService { const args: string[] = [] + // Input seeking (before -i for fast keyframe-based seeking) + if (startTime > 0) { + args.push('-ss', String(startTime)) + } + // Input with timestamp preservation (like Jellyfin) args.push('-copyts', '-avoid_negative_ts', 'disabled') args.push('-i', fullPath) @@ -425,8 +441,8 @@ export class TranscodingSessionService { args.push('-c:v', videoCodec) - // Force keyframes at segment boundaries - args.push('-force_key_frames', `expr:gte(t,n_forced*${SEGMENT_DURATION})`) + // Force keyframes at segment boundaries (offset by startTime when -copyts preserves original PTS) + args.push('-force_key_frames', `expr:gte(t,n_forced*${SEGMENT_DURATION}+${startTime})`) args.push('-sc_threshold:v', '0') const isSoftwareEncoder = videoCodec === 'libx264' || videoCodec === 'libx265' @@ -471,8 +487,9 @@ export class TranscodingSessionService { mode: PlaybackMode, audioTrackId: number = 0, resolution?: string, + startTime: number = 0, ) { - const key = this.buildSessionKey(driveLetter, path, mode, audioTrackId, resolution) + const key = this.buildSessionKey(driveLetter, path, mode, audioTrackId, resolution, startTime) const session = this.sessions.get(key) if (session) { this.destroySession(session) From 479c7727e55f5e975ecda9340e36d2696c699edc Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Wed, 11 Mar 2026 15:22:30 +0100 Subject: [PATCH 14/30] cleanup, refactorings --- .../src/pages/admin/tmdb-settings.spec.ts | 64 + frontend/src/pages/admin/tmdb-settings.tsx | 2 +- .../file-browser/file-context-menu-items.tsx | 135 ++ frontend/src/pages/file-browser/file-list.tsx | 197 +-- .../file-browser/file-upload-handler.tsx | 61 + .../localized-metadata-service.spec.ts | 191 +++ .../media/announce-movie-file-added.ts | 40 + .../src/app-models/media/media-data-sets.ts | 127 ++ .../app-models/media/media-schema-setup.ts | 786 ++++++++++++ .../media/media-sequelize-models.ts | 205 +++ .../build-synthetic-movie.ts | 38 + .../tmdb-client-service.spec.ts | 735 +++++++++++ .../metadata-services/tmdb-client-service.ts | 50 +- .../src/app-models/media/setup-media.spec.ts | 2 +- service/src/app-models/media/setup-media.ts | 1107 +---------------- .../ensure-localized-metadata-exists.spec.ts | 132 ++ .../utils/ensure-tmdb-movie-exists.spec.ts | 110 ++ .../utils/ensure-tmdb-series-exists.spec.ts | 118 ++ .../app-models/media/utils/link-movie.spec.ts | 178 +++ .../media/utils/map-omdb-to-localized.spec.ts | 75 ++ .../media/utils/map-tmdb-to-localized.spec.ts | 90 ++ 21 files changed, 3133 insertions(+), 1310 deletions(-) create mode 100644 frontend/src/pages/admin/tmdb-settings.spec.ts create mode 100644 frontend/src/pages/file-browser/file-context-menu-items.tsx create mode 100644 frontend/src/pages/file-browser/file-upload-handler.tsx create mode 100644 frontend/src/services/localized-metadata-service.spec.ts create mode 100644 service/src/app-models/media/announce-movie-file-added.ts create mode 100644 service/src/app-models/media/media-data-sets.ts create mode 100644 service/src/app-models/media/media-schema-setup.ts create mode 100644 service/src/app-models/media/media-sequelize-models.ts create mode 100644 service/src/app-models/media/metadata-services/build-synthetic-movie.ts create mode 100644 service/src/app-models/media/metadata-services/tmdb-client-service.spec.ts create mode 100644 service/src/app-models/media/utils/ensure-localized-metadata-exists.spec.ts create mode 100644 service/src/app-models/media/utils/ensure-tmdb-movie-exists.spec.ts create mode 100644 service/src/app-models/media/utils/ensure-tmdb-series-exists.spec.ts create mode 100644 service/src/app-models/media/utils/map-omdb-to-localized.spec.ts create mode 100644 service/src/app-models/media/utils/map-tmdb-to-localized.spec.ts diff --git a/frontend/src/pages/admin/tmdb-settings.spec.ts b/frontend/src/pages/admin/tmdb-settings.spec.ts new file mode 100644 index 00000000..6255b23d --- /dev/null +++ b/frontend/src/pages/admin/tmdb-settings.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { isTmdbRawFormData } from './tmdb-settings.js' + +describe('isTmdbRawFormData', () => { + describe('valid data', () => { + it('returns true for valid payload with all fields', () => { + expect( + isTmdbRawFormData({ + apiKey: 'test-key', + defaultLanguage: 'en-US', + additionalLanguages: 'fr-FR, de-DE', + }), + ).toBe(true) + }) + + it('returns true for minimal valid payload', () => { + expect(isTmdbRawFormData({ apiKey: 'key' })).toBe(true) + }) + + it('returns true for empty apiKey string', () => { + expect(isTmdbRawFormData({ apiKey: '' })).toBe(true) + }) + + it('returns true for payload with extra fields', () => { + expect(isTmdbRawFormData({ apiKey: 'key', extra: 'field' })).toBe(true) + }) + }) + + describe('primitives and nullish', () => { + it('returns false for null', () => { + expect(isTmdbRawFormData(null)).toBe(false) + }) + + it('returns false for undefined', () => { + expect(isTmdbRawFormData(undefined)).toBe(false) + }) + + it('returns false for string', () => { + expect(isTmdbRawFormData('hello')).toBe(false) + }) + + it('returns false for number', () => { + expect(isTmdbRawFormData(42)).toBe(false) + }) + }) + + describe('missing or wrong types', () => { + it('returns false when apiKey is missing', () => { + expect(isTmdbRawFormData({ defaultLanguage: 'en-US' })).toBe(false) + }) + + it('returns false when apiKey is not a string', () => { + expect(isTmdbRawFormData({ apiKey: 123 })).toBe(false) + }) + + it('returns false when apiKey is null', () => { + expect(isTmdbRawFormData({ apiKey: null })).toBe(false) + }) + + it('returns false for empty object', () => { + expect(isTmdbRawFormData({})).toBe(false) + }) + }) +}) diff --git a/frontend/src/pages/admin/tmdb-settings.tsx b/frontend/src/pages/admin/tmdb-settings.tsx index c1866c35..f8c39ee6 100644 --- a/frontend/src/pages/admin/tmdb-settings.tsx +++ b/frontend/src/pages/admin/tmdb-settings.tsx @@ -28,7 +28,7 @@ type TmdbRawFormData = { additionalLanguages: string } -const isTmdbRawFormData = (data: unknown): data is TmdbRawFormData => { +export const isTmdbRawFormData = (data: unknown): data is TmdbRawFormData => { if (typeof data !== 'object' || data === null) return false const d = data as Record return typeof d.apiKey === 'string' diff --git a/frontend/src/pages/file-browser/file-context-menu-items.tsx b/frontend/src/pages/file-browser/file-context-menu-items.tsx new file mode 100644 index 00000000..0f5ee490 --- /dev/null +++ b/frontend/src/pages/file-browser/file-context-menu-items.tsx @@ -0,0 +1,135 @@ +import { createComponent } from '@furystack/shades' +import type { ContextMenuItem } from '@furystack/shades-common-components' +import { Icon, icons, type NotyService } from '@furystack/shades-common-components' +import { getFallbackMetadata, getFullPath, isMovieFile, isSampleFile, type DirectoryEntry } from 'common' + +import type { MediaApiClient } from '../../services/api-clients/media-api-client.js' + +type ContextMenuDeps = { + currentDriveLetter: string + currentPath: string + mediaApiClient: MediaApiClient + notyService: NotyService + onActivate?: (entry: DirectoryEntry) => void + onShowRelatedMovies: () => void + onShowFileInfo: () => void + onDeleteRequest: () => void +} + +export const getContextMenuItems = ( + entry: DirectoryEntry, + deps: ContextMenuDeps, +): Array void>> => { + const { currentDriveLetter, currentPath, mediaApiClient, notyService } = deps + + const path = `${currentDriveLetter}:${currentPath}/${entry.name}` + const movieMetadata = entry.isFile && !isSampleFile(path) && isMovieFile(path) && getFallbackMetadata(path) + const allowScanForMovies = entry.isDirectory + + return [ + { + type: 'item', + icon: , + label: 'Open', + data: () => deps.onActivate?.(entry), + }, + ...(movieMetadata + ? [ + { + type: 'item' as const, + icon: , + label: `Related movie: ${movieMetadata.title} ${ + movieMetadata.type === 'episode' ? `S${movieMetadata.season}E${movieMetadata.episode}` : '' + }`, + data: () => deps.onShowRelatedMovies(), + }, + { + type: 'item' as const, + icon: , + label: 'Extract Subtitles', + data: () => { + mediaApiClient + .call({ + method: 'POST', + action: '/extract-subtitles', + body: { + driveLetter: currentDriveLetter, + path: getFullPath(currentPath, entry.name), + }, + }) + .then(() => { + notyService.emit('onNotyAdded', { + type: 'success', + title: 'Subtitles extracted', + body: <>Subtitles extracted successfully for file {entry.name}, + }) + }) + .catch(() => { + notyService.emit('onNotyAdded', { + type: 'error', + title: 'Subtitles extraction failed', + body: <>Subtitles extraction failed for file {entry.name}, + }) + }) + }, + }, + ] + : []), + ...(allowScanForMovies + ? [ + { + type: 'item' as const, + icon: , + label: 'Scan for movies', + data: () => { + mediaApiClient + .call({ + method: 'POST', + action: '/scan-for-movies', + body: { + root: { + driveLetter: currentDriveLetter, + path: getFullPath(currentPath, entry.name), + }, + autoExtractSubtitles: false, + }, + }) + .then(() => { + notyService.emit('onNotyAdded', { + type: 'success', + title: 'Movies scanned', + body: <>Movies scanned successfully for folder {entry.name}, + }) + }) + .catch(() => { + notyService.emit('onNotyAdded', { + type: 'error', + title: 'Movies scanning failed', + body: <>Movies scanning failed for folder {entry.name}, + }) + }) + }, + }, + ] + : []), + { + type: 'item', + icon: , + label: 'Show file info', + data: () => deps.onShowFileInfo(), + }, + ...(entry.name !== '..' + ? [ + { + type: 'separator' as const, + }, + { + type: 'item' as const, + icon: , + label: 'Delete', + data: () => deps.onDeleteRequest(), + }, + ] + : []), + ] +} diff --git a/frontend/src/pages/file-browser/file-list.tsx b/frontend/src/pages/file-browser/file-list.tsx index fec531c2..42a34028 100644 --- a/frontend/src/pages/file-browser/file-list.tsx +++ b/frontend/src/pages/file-browser/file-list.tsx @@ -1,19 +1,17 @@ import type { FindOptions } from '@furystack/core' import { createComponent, Shade } from '@furystack/shades' -import type { CollectionService, ContextMenuItem } from '@furystack/shades-common-components' +import type { CollectionService } from '@furystack/shades-common-components' import { Button, ContextMenu, ContextMenuManager, DataGrid, Dialog, - Icon, - icons, NotyService, SelectionCell, } from '@furystack/shades-common-components' import { PathHelper } from '@furystack/utils' -import { getFallbackMetadata, getFullPath, isMovieFile, isSampleFile, type DirectoryEntry } from 'common' +import { getFallbackMetadata, isMovieFile, isSampleFile, type DirectoryEntry } from 'common' import { RelatedMoviesModal } from '../../components/movie-file-management/related-movies-modal.js' import { MediaApiClient } from '../../services/api-clients/media-api-client.js' @@ -24,7 +22,9 @@ import { environmentOptions } from '../../utils/environment-options.js' import { triggerDownload } from '../../utils/trigger-download.js' import { BreadCrumbs } from './breadcrumbs.js' import { DirectoryEntryIcon } from './directory-entry-icon.js' +import { getContextMenuItems } from './file-context-menu-items.js' import { FileInfoModal } from './file-info-modal.js' +import { handleFileDrop } from './file-upload-handler.js' export const FileList = Shade<{ currentDriveLetter: string @@ -54,6 +54,8 @@ export const FileList = Shade<{ const drivesService = injector.getInstance(DrivesService) const notyService = injector.getInstance(NotyService) + const mediaApiClient = injector.getInstance(MediaApiClient) + const sessionService = injector.getInstance(SessionService) const [findOptions, setFindOptions] = useState>>( 'findOptions', @@ -89,125 +91,12 @@ export const FileList = Shade<{ } } - const getContextMenuItems = (entry: DirectoryEntry): Array void>> => { - const path = `${currentDriveLetter}:${currentPath}/${entry.name}` - const movieMetadata = entry.isFile && !isSampleFile(path) && isMovieFile(path) && getFallbackMetadata(path) - const allowScanForMovies = entry.isDirectory - - return [ - { - type: 'item', - icon: , - label: 'Open', - data: () => props.onActivate?.(entry), - }, - ...(movieMetadata - ? [ - { - type: 'item' as const, - icon: , - label: `Related movie: ${movieMetadata.title} ${ - movieMetadata.type === 'episode' ? `S${movieMetadata.season}E${movieMetadata.episode}` : '' - }`, - data: () => setRelatedMoviesVisible(true), - }, - { - type: 'item' as const, - icon: , - label: 'Extract Subtitles', - data: () => { - injector - .getInstance(MediaApiClient) - .call({ - method: 'POST', - action: '/extract-subtitles', - body: { - driveLetter: currentDriveLetter, - path: getFullPath(currentPath, entry.name), - }, - }) - .then(() => { - notyService.emit('onNotyAdded', { - type: 'success', - title: 'Subtitles extracted', - body: <>Subtitles extracted successfully for file {entry.name}, - }) - }) - .catch(() => { - notyService.emit('onNotyAdded', { - type: 'error', - title: 'Subtitles extraction failed', - body: <>Subtitles extraction failed for file {entry.name}, - }) - }) - }, - }, - ] - : []), - ...(allowScanForMovies - ? [ - { - type: 'item' as const, - icon: , - label: 'Scan for movies', - data: () => { - injector - .getInstance(MediaApiClient) - .call({ - method: 'POST', - action: '/scan-for-movies', - body: { - root: { - driveLetter: currentDriveLetter, - path: getFullPath(currentPath, entry.name), - }, - autoExtractSubtitles: false, - }, - }) - .then(() => { - notyService.emit('onNotyAdded', { - type: 'success', - title: 'Movies scanned', - body: <>Movies scanned successfully for folder {entry.name}, - }) - }) - .catch(() => { - notyService.emit('onNotyAdded', { - type: 'error', - title: 'Movies scanning failed', - body: <>Movies scanning failed for folder {entry.name}, - }) - }) - }, - }, - ] - : []), - { - type: 'item', - icon: , - label: 'Show file info', - data: () => setInfoVisible(true), - }, - ...(entry.name !== '..' - ? [ - { - type: 'separator' as const, - }, - { - type: 'item' as const, - icon: , - label: 'Delete', - data: () => { - const targets = collectDeleteTargets() - if (targets.length > 0) { - setEntriesToDelete(targets) - setDeleteDialogVisible(true) - } - }, - }, - ] - : []), - ] + const requestDelete = () => { + const targets = collectDeleteTargets() + if (targets.length > 0) { + setEntriesToDelete(targets) + setDeleteDialogVisible(true) + } } const handleContextMenu = (entry: DirectoryEntry, ev: MouseEvent) => { @@ -215,7 +104,16 @@ export const FileList = Shade<{ setActiveEntry(entry) contextMenuManager.open({ position: { x: ev.clientX, y: ev.clientY }, - items: getContextMenuItems(entry), + items: getContextMenuItems(entry, { + currentDriveLetter, + currentPath, + mediaApiClient, + notyService, + onActivate: props.onActivate, + onShowRelatedMovies: () => setRelatedMoviesVisible(true), + onShowFileInfo: () => setInfoVisible(true), + onDeleteRequest: requestDelete, + }), }) } @@ -225,7 +123,7 @@ export const FileList = Shade<{ for (const entry of entriesToDelete) { await drivesService.removeFile({ letter: currentDriveLetter, - path: getFullPath(currentPath, entry.name), + path: `${currentPath}/${entry.name}`, }) } notyService.emit('onNotyAdded', { @@ -266,11 +164,7 @@ export const FileList = Shade<{ } if (ev.key === 'Delete') { - const targets = collectDeleteTargets() - if (targets.length > 0) { - setEntriesToDelete(targets) - setDeleteDialogVisible(true) - } + requestDelete() } } window.addEventListener('keydown', listener) @@ -294,47 +188,8 @@ export const FileList = Shade<{ ondragover={(ev) => { ev.preventDefault() }} - ondrop={async (ev) => { - ev.preventDefault() - if (ev.dataTransfer?.files) { - const session = injector.getInstance(SessionService) - if (!(await session.isAuthorized('admin'))) { - return notyService.emit('onNotyAdded', { - type: 'warning', - title: 'Not authorized', - body: <>You are not authorized to upload files, - }) - } - - const formData = new FormData() - for (const file of ev.dataTransfer.files) { - formData.append('uploads', file) - } - await fetch( - `${environmentOptions.serviceUrl}/drives/volumes/${encodeURIComponent( - currentDriveLetter, - )}/${encodeURIComponent(currentPath)}/upload`, - { - method: 'POST', - credentials: 'include', - body: formData, - }, - ) - .then(() => { - notyService.emit('onNotyAdded', { - type: 'success', - title: 'Upload completed', - body: <>The files are upploaded succesfully, - }) - }) - .catch((err) => - notyService.emit('onNotyAdded', { - title: 'Upload failed', - body: <>{getErrorMessage(err)}, - type: 'error', - }), - ) - } + ondrop={(ev) => { + void handleFileDrop({ ev, sessionService, notyService, currentDriveLetter, currentPath }) }} ondblclick={activate} onkeydown={(ev) => { diff --git a/frontend/src/pages/file-browser/file-upload-handler.tsx b/frontend/src/pages/file-browser/file-upload-handler.tsx new file mode 100644 index 00000000..16e5ce6c --- /dev/null +++ b/frontend/src/pages/file-browser/file-upload-handler.tsx @@ -0,0 +1,61 @@ +import { createComponent } from '@furystack/shades' +import type { NotyService } from '@furystack/shades-common-components' + +import type { SessionService } from '../../services/session.js' +import { getErrorMessage } from '../../services/get-error-message.js' +import { environmentOptions } from '../../utils/environment-options.js' + +export const handleFileDrop = async ({ + ev, + sessionService, + notyService, + currentDriveLetter, + currentPath, +}: { + ev: DragEvent + sessionService: SessionService + notyService: NotyService + currentDriveLetter: string + currentPath: string +}) => { + ev.preventDefault() + if (!ev.dataTransfer?.files) return + + if (!(await sessionService.isAuthorized('admin'))) { + return notyService.emit('onNotyAdded', { + type: 'warning', + title: 'Not authorized', + body: <>You are not authorized to upload files, + }) + } + + const formData = new FormData() + for (const file of ev.dataTransfer.files) { + formData.append('uploads', file) + } + + await fetch( + `${environmentOptions.serviceUrl}/drives/volumes/${encodeURIComponent( + currentDriveLetter, + )}/${encodeURIComponent(currentPath)}/upload`, + { + method: 'POST', + credentials: 'include', + body: formData, + }, + ) + .then(() => { + notyService.emit('onNotyAdded', { + type: 'success', + title: 'Upload completed', + body: <>The files are upploaded succesfully, + }) + }) + .catch((err) => + notyService.emit('onNotyAdded', { + title: 'Upload failed', + body: <>{getErrorMessage(err)}, + type: 'error', + }), + ) +} diff --git a/frontend/src/services/localized-metadata-service.spec.ts b/frontend/src/services/localized-metadata-service.spec.ts new file mode 100644 index 00000000..d9e6e902 --- /dev/null +++ b/frontend/src/services/localized-metadata-service.spec.ts @@ -0,0 +1,191 @@ +import { Injector } from '@furystack/inject' +import { usingAsync } from '@furystack/utils' +import type { MovieMetadataLocalized, SeriesMetadataLocalized } from 'common' +import { describe, expect, it, vi } from 'vitest' +import { MediaApiClient } from './api-clients/media-api-client.js' +import { LocalizedMetadataService } from './localized-metadata-service.js' + +const createMockMovieLocalized = (imdbId = 'tt1234567'): MovieMetadataLocalized => ({ + id: 'localized-1', + movieImdbId: imdbId, + language: 'en', + title: 'Test Movie', + plot: 'A test movie plot.', + posterUrl: 'https://example.com/poster.jpg', + genre: ['Action'], + source: 'omdb', + sourceId: imdbId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}) + +const createMockSeriesLocalized = (imdbId = 'tt9876543'): SeriesMetadataLocalized => ({ + id: 'localized-2', + seriesImdbId: imdbId, + language: 'en', + title: 'Test Series', + plot: 'A test series plot.', + posterUrl: 'https://example.com/series-poster.jpg', + source: 'tmdb', + sourceId: '67890', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}) + +describe('LocalizedMetadataService', () => { + const createTestInjector = (mockCall: ReturnType) => { + const injector = new Injector() + injector.setExplicitInstance( + { + call: mockCall, + } as unknown as MediaApiClient, + MediaApiClient, + ) + return injector + } + + describe('getMovieLocalized', () => { + it('should fetch movie localized metadata by imdb id', async () => { + const mockLocalized = createMockMovieLocalized() + const mockCall = vi.fn().mockResolvedValue({ + result: { count: 1, entries: [mockLocalized] }, + }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(LocalizedMetadataService) + const result = await service.getMovieLocalized('tt1234567') + + expect(mockCall).toHaveBeenCalledWith({ + method: 'GET', + action: '/movie-metadata-localized', + query: { + findOptions: { + filter: { + movieImdbId: { $eq: 'tt1234567' }, + language: { $eq: 'en' }, + }, + top: 1, + }, + }, + }) + expect(result).toEqual(mockLocalized) + }) + }) + + it('should cache movie localized results', async () => { + const mockLocalized = createMockMovieLocalized() + const mockCall = vi.fn().mockResolvedValue({ + result: { count: 1, entries: [mockLocalized] }, + }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(LocalizedMetadataService) + + await service.getMovieLocalized('tt1234567') + await service.getMovieLocalized('tt1234567') + + expect(mockCall).toHaveBeenCalledTimes(1) + }) + }) + + it('should return undefined when no results', async () => { + const mockCall = vi.fn().mockResolvedValue({ + result: { count: 0, entries: [] }, + }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(LocalizedMetadataService) + const result = await service.getMovieLocalized('tt0000000') + + expect(result).toBeUndefined() + }) + }) + }) + + describe('getSeriesLocalized', () => { + it('should fetch series localized metadata by imdb id', async () => { + const mockLocalized = createMockSeriesLocalized() + const mockCall = vi.fn().mockResolvedValue({ + result: { count: 1, entries: [mockLocalized] }, + }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(LocalizedMetadataService) + const result = await service.getSeriesLocalized('tt9876543') + + expect(mockCall).toHaveBeenCalledWith({ + method: 'GET', + action: '/series-metadata-localized', + query: { + findOptions: { + filter: { + seriesImdbId: { $eq: 'tt9876543' }, + language: { $eq: 'en' }, + }, + top: 1, + }, + }, + }) + expect(result).toEqual(mockLocalized) + }) + }) + + it('should cache series localized results', async () => { + const mockLocalized = createMockSeriesLocalized() + const mockCall = vi.fn().mockResolvedValue({ + result: { count: 1, entries: [mockLocalized] }, + }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(LocalizedMetadataService) + + await service.getSeriesLocalized('tt9876543') + await service.getSeriesLocalized('tt9876543') + + expect(mockCall).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe('getMovieLocalizedAsObservable', () => { + it('should return an observable', async () => { + const mockCall = vi.fn().mockResolvedValue({ + result: { count: 1, entries: [createMockMovieLocalized()] }, + }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(LocalizedMetadataService) + const observable = service.getMovieLocalizedAsObservable('tt1234567') + + expect(observable).toBeDefined() + expect(observable.getValue().status).toBe('loading') + }) + }) + }) + + describe('disposal', () => { + it('should dispose both caches on dispose', async () => { + const mockCall = vi.fn().mockResolvedValue({ + result: { count: 0, entries: [] }, + }) + const injector = createTestInjector(mockCall) + + await usingAsync(injector, async (i) => { + const service = i.getInstance(LocalizedMetadataService) + const movieDisposeSpy = vi.spyOn(service.movieLocalizedCache, Symbol.dispose as never) + const seriesDisposeSpy = vi.spyOn(service.seriesLocalizedCache, Symbol.dispose as never) + + service[Symbol.dispose]() + + expect(movieDisposeSpy).toHaveBeenCalled() + expect(seriesDisposeSpy).toHaveBeenCalled() + }) + }) + }) +}) diff --git a/service/src/app-models/media/announce-movie-file-added.ts b/service/src/app-models/media/announce-movie-file-added.ts new file mode 100644 index 00000000..69b2293d --- /dev/null +++ b/service/src/app-models/media/announce-movie-file-added.ts @@ -0,0 +1,40 @@ +import { isAuthorized } from '@furystack/core' +import type { Injector } from '@furystack/inject' +import type { ScopedLogger } from '@furystack/logging' +import type { DataSet } from '@furystack/repository' +import type { Movie, MovieFile } from 'common' + +import { WebsocketService } from '../../websocket-service.js' + +export const announceMovieFileAdded = async ({ + entity, + injector, + movieDataSet, + logger, +}: { + entity: MovieFile + injector: Injector + movieDataSet: DataSet + logger: ScopedLogger +}) => { + if (!entity.imdbId) return + try { + const movie = await movieDataSet.get(injector, entity.imdbId) + if (movie) { + await injector.getInstance(WebsocketService).announce( + { + type: 'add-movie', + file: { driveLetter: entity.driveLetter, path: entity.path }, + movie, + movieFile: entity, + }, + async ({ injector: i }) => isAuthorized(i, 'admin'), + ) + } + } catch (error) { + await logger.error({ + message: `Failed to announce new movie file '${entity.path}'`, + data: { error }, + }) + } +} diff --git a/service/src/app-models/media/media-data-sets.ts b/service/src/app-models/media/media-data-sets.ts new file mode 100644 index 00000000..df74513e --- /dev/null +++ b/service/src/app-models/media/media-data-sets.ts @@ -0,0 +1,127 @@ +import { getCurrentUser, isAuthorized } from '@furystack/core' +import type { Injector } from '@furystack/inject' +import type { AuthorizationResult } from '@furystack/repository' +import { getRepository } from '@furystack/repository' +import { + Movie, + MovieFile, + MovieMetadataLocalized, + OmdbMovieMetadata, + OmdbSeriesMetadata, + Series, + SeriesMetadataLocalized, + TmdbMovieMetadata, + TmdbSeriesMetadata, + WatchHistoryEntry, +} from 'common' + +import { authorizedOnly } from '../../authorization/authorized-only.js' +import { withRole } from '../../authorization/with-role.js' + +export const setupMediaDataSets = (injector: Injector) => { + const repo = getRepository(injector) + + repo.createDataSet(Movie, 'imdbId', { + authorizeGet: authorizedOnly, + authorizeAdd: withRole('admin'), + authorizeUpdate: withRole('admin'), + authorizeRemove: withRole('admin'), + }) + + repo.createDataSet(MovieFile, 'id', { + authorizeGet: authorizedOnly, + authorizeAdd: withRole('admin'), + authorizeUpdate: withRole('admin'), + authorizeRemove: withRole('admin'), + }) + + const onlyOwned = async ({ + entity, + injector: i, + }: { + entity: WatchHistoryEntry + injector: Injector + }): Promise => { + const user = await getCurrentUser(i) + if (user.username === entity.userName) { + return { isAllowed: true } + } + + if (await isAuthorized(i, 'admin')) { + return { isAllowed: true } + } + return { + isAllowed: false, + message: 'You are not authorized to access this resource', + } + } + + repo.createDataSet(WatchHistoryEntry, 'id', { + authorizeGet: authorizedOnly, + authorizeAdd: authorizedOnly, + authorizeUpdate: authorizedOnly, + authorizeRemove: authorizedOnly, + authorizeGetEntity: onlyOwned, + addFilter: async ({ filter, injector: i }) => { + const user = await getCurrentUser(i) + return { + ...filter, + filter: { + ...filter.filter, + userName: { $eq: user.username }, + }, + } as typeof filter + }, + authorizeUpdateEntity: onlyOwned, + authorizeRemoveEntity: onlyOwned, + }) + + repo.createDataSet(Series, 'imdbId', { + authorizeGet: authorizedOnly, + authorizeAdd: withRole('admin'), + authorizeUpdate: withRole('admin'), + authorizeRemove: withRole('admin'), + }) + + repo.createDataSet(OmdbMovieMetadata, 'imdbID', { + authorizeGet: authorizedOnly, + authorizeAdd: withRole('admin'), + authorizeUpdate: withRole('admin'), + authorizeRemove: withRole('admin'), + }) + + repo.createDataSet(OmdbSeriesMetadata, 'imdbID', { + authorizeGet: authorizedOnly, + authorizeAdd: withRole('admin'), + authorizeUpdate: withRole('admin'), + authorizeRemove: withRole('admin'), + }) + + repo.createDataSet(TmdbMovieMetadata, 'id', { + authorizeGet: authorizedOnly, + authorizeAdd: withRole('admin'), + authorizeUpdate: withRole('admin'), + authorizeRemove: withRole('admin'), + }) + + repo.createDataSet(TmdbSeriesMetadata, 'id', { + authorizeGet: authorizedOnly, + authorizeAdd: withRole('admin'), + authorizeUpdate: withRole('admin'), + authorizeRemove: withRole('admin'), + }) + + repo.createDataSet(MovieMetadataLocalized, 'id', { + authorizeGet: authorizedOnly, + authorizeAdd: withRole('admin'), + authorizeUpdate: withRole('admin'), + authorizeRemove: withRole('admin'), + }) + + repo.createDataSet(SeriesMetadataLocalized, 'id', { + authorizeGet: authorizedOnly, + authorizeAdd: withRole('admin'), + authorizeUpdate: withRole('admin'), + authorizeRemove: withRole('admin'), + }) +} diff --git a/service/src/app-models/media/media-schema-setup.ts b/service/src/app-models/media/media-schema-setup.ts new file mode 100644 index 00000000..5d541522 --- /dev/null +++ b/service/src/app-models/media/media-schema-setup.ts @@ -0,0 +1,786 @@ +import type { Injector } from '@furystack/inject' +import type { ScopedLogger } from '@furystack/logging' +import { useSequelize } from '@furystack/sequelize-store' +import { DataTypes } from 'sequelize' +import { + Movie, + MovieFile, + MovieMetadataLocalized, + OmdbMovieMetadata, + OmdbSeriesMetadata, + Series, + SeriesMetadataLocalized, + TmdbMovieMetadata, + TmdbSeriesMetadata, + WatchHistoryEntry, +} from 'common' + +import { getDefaultDbSettings } from '../../get-default-db-options.js' +import { + MovieModel, + MovieFileModel, + WatchHistoryEntryModel, + SeriesModel, + OmdbMovieMetadataModel, + OmdbSeriesMetadataModel, + TmdbMovieMetadataModel, + TmdbSeriesMetadataModel, + MovieMetadataLocalizedModel, + SeriesMetadataLocalizedModel, +} from './media-sequelize-models.js' + +export const setupMediaSchema = (injector: Injector, logger: ScopedLogger) => { + const dbOptions = getDefaultDbSettings('movies.sqlite', logger) + + useSequelize({ + injector, + model: Movie, + sequelizeModel: MovieModel, + primaryKey: 'imdbId', + options: dbOptions, + initModel: async (sequelize) => { + MovieModel.init( + { + imdbId: { + type: DataTypes.STRING, + allowNull: false, + primaryKey: true, + }, + duration: { + type: DataTypes.INTEGER, + allowNull: true, + }, + year: { + type: DataTypes.INTEGER, + allowNull: true, + }, + episode: { + type: DataTypes.INTEGER, + allowNull: true, + }, + season: { + type: DataTypes.INTEGER, + allowNull: true, + }, + type: { + type: DataTypes.ENUM('episode', 'movie'), + allowNull: true, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + }, + seriesId: { + type: DataTypes.STRING, + allowNull: true, + }, + }, + { sequelize }, + ) + }, + }) + + useSequelize({ + injector, + model: MovieFile, + sequelizeModel: MovieFileModel, + primaryKey: 'id', + options: dbOptions, + initModel: async (sequelize) => { + MovieFileModel.init( + { + id: { + type: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false, + defaultValue: () => crypto.randomUUID(), + }, + imdbId: { + type: DataTypes.STRING, + allowNull: true, + }, + driveLetter: { + type: DataTypes.STRING, + allowNull: false, + }, + path: { + type: DataTypes.STRING, + allowNull: false, + }, + ffprobe: { + type: DataTypes.JSON, + allowNull: true, + }, + relatedFiles: { + type: DataTypes.JSON, + allowNull: true, + }, + }, + { sequelize, indexes: [{ fields: ['imdbId'] }, { fields: ['driveLetter', 'path'], unique: true }] }, + ) + }, + }) + + useSequelize({ + injector, + model: WatchHistoryEntry, + sequelizeModel: WatchHistoryEntryModel, + primaryKey: 'id', + options: dbOptions, + initModel: async (sequelize) => { + WatchHistoryEntryModel.init( + { + id: { + type: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false, + defaultValue: () => crypto.randomUUID(), + }, + userName: { + type: DataTypes.STRING, + allowNull: false, + }, + driveLetter: { + type: DataTypes.STRING, + allowNull: false, + }, + path: { + type: DataTypes.STRING, + allowNull: false, + }, + watchedSeconds: { + type: DataTypes.INTEGER, + allowNull: false, + }, + completed: { + type: DataTypes.BOOLEAN, + allowNull: false, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { + sequelize, + indexes: [{ fields: ['userName', 'driveLetter', 'path'], unique: true }], + }, + ) + }, + }) + + useSequelize({ + injector, + model: Series, + sequelizeModel: SeriesModel, + primaryKey: 'imdbId', + options: dbOptions, + initModel: async (sequelize) => { + SeriesModel.init( + { + imdbId: { + type: DataTypes.STRING, + primaryKey: true, + }, + year: { + type: DataTypes.STRING, + allowNull: false, + }, + numberOfSeasons: { + type: DataTypes.INTEGER, + allowNull: true, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { sequelize }, + ) + }, + }) + + useSequelize({ + injector, + model: OmdbMovieMetadata, + sequelizeModel: OmdbMovieMetadataModel, + primaryKey: 'imdbID', + options: dbOptions, + initModel: async (sequelize) => { + OmdbMovieMetadataModel.init( + { + imdbID: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false, + }, + Title: { + type: DataTypes.STRING, + allowNull: false, + }, + Year: { + type: DataTypes.STRING, + allowNull: false, + }, + Rated: { + type: DataTypes.STRING, + allowNull: true, + }, + Released: { + type: DataTypes.STRING, + allowNull: true, + }, + Runtime: { + type: DataTypes.STRING, + allowNull: true, + }, + Genre: { + type: DataTypes.STRING, + allowNull: true, + }, + Director: { + type: DataTypes.STRING, + allowNull: true, + }, + Writer: { + type: DataTypes.STRING, + allowNull: true, + }, + Actors: { + type: DataTypes.STRING, + allowNull: true, + }, + Plot: { + type: DataTypes.STRING, + allowNull: false, + }, + Language: { + type: DataTypes.STRING, + allowNull: true, + }, + Country: { + type: DataTypes.STRING, + allowNull: true, + }, + Awards: { + type: DataTypes.STRING, + allowNull: true, + }, + Poster: { + type: DataTypes.STRING, + allowNull: false, + }, + Ratings: { + type: DataTypes.JSON, + allowNull: true, + }, + Metascore: { + type: DataTypes.STRING, + allowNull: true, + }, + imdbRating: { + type: DataTypes.STRING, + allowNull: true, + }, + imdbVotes: { + type: DataTypes.STRING, + allowNull: true, + }, + Response: { + type: DataTypes.STRING, + allowNull: false, + }, + Type: { + type: DataTypes.STRING, + allowNull: false, + }, + DVD: { + type: DataTypes.STRING, + allowNull: true, + }, + BoxOffice: { + type: DataTypes.STRING, + allowNull: true, + }, + Production: { + type: DataTypes.STRING, + allowNull: true, + }, + Episode: { + type: DataTypes.STRING, + allowNull: true, + }, + Season: { + type: DataTypes.STRING, + allowNull: true, + }, + Website: { + type: DataTypes.STRING, + allowNull: true, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + }, + seriesID: { + type: DataTypes.STRING, + allowNull: true, + }, + }, + { sequelize }, + ) + }, + }) + + useSequelize({ + injector, + model: OmdbSeriesMetadata, + sequelizeModel: OmdbSeriesMetadataModel, + primaryKey: 'imdbID', + options: dbOptions, + initModel: async (sequelize) => { + OmdbSeriesMetadataModel.init( + { + imdbID: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false, + }, + Title: { + type: DataTypes.STRING, + allowNull: false, + }, + Actors: { + type: DataTypes.STRING, + allowNull: false, + }, + Awards: { + type: DataTypes.STRING, + allowNull: false, + }, + Country: { + type: DataTypes.STRING, + allowNull: false, + }, + Director: { + type: DataTypes.STRING, + allowNull: false, + }, + Genre: { + type: DataTypes.STRING, + allowNull: false, + }, + imdbRating: { + type: DataTypes.STRING, + allowNull: false, + }, + imdbVotes: { + type: DataTypes.STRING, + allowNull: false, + }, + Language: { + type: DataTypes.STRING, + allowNull: false, + }, + Plot: { + type: DataTypes.STRING, + allowNull: false, + }, + Poster: { + type: DataTypes.STRING, + allowNull: false, + }, + Metascore: { + type: DataTypes.STRING, + allowNull: false, + }, + Rated: { + type: DataTypes.STRING, + allowNull: false, + }, + Released: { + type: DataTypes.STRING, + allowNull: false, + }, + Response: { + type: DataTypes.STRING, + allowNull: false, + }, + Runtime: { + type: DataTypes.STRING, + allowNull: false, + }, + Type: { + type: DataTypes.STRING, + allowNull: false, + }, + Ratings: { + type: DataTypes.JSON, + allowNull: false, + }, + totalSeasons: { + type: DataTypes.STRING, + allowNull: false, + }, + Writer: { + type: DataTypes.STRING, + allowNull: false, + }, + Year: { + type: DataTypes.STRING, + allowNull: false, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { sequelize }, + ) + }, + }) + + useSequelize({ + injector, + model: TmdbMovieMetadata, + sequelizeModel: TmdbMovieMetadataModel, + primaryKey: 'id', + options: dbOptions, + initModel: async (sequelize) => { + TmdbMovieMetadataModel.init( + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + allowNull: false, + }, + imdbId: { + type: DataTypes.STRING, + allowNull: true, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + originalTitle: { + type: DataTypes.STRING, + allowNull: false, + }, + overview: { + type: DataTypes.TEXT, + allowNull: false, + }, + releaseDate: { + type: DataTypes.STRING, + allowNull: true, + }, + runtime: { + type: DataTypes.INTEGER, + allowNull: true, + }, + posterPath: { + type: DataTypes.STRING, + allowNull: true, + }, + backdropPath: { + type: DataTypes.STRING, + allowNull: true, + }, + genres: { + type: DataTypes.JSON, + allowNull: true, + }, + voteAverage: { + type: DataTypes.FLOAT, + allowNull: true, + }, + voteCount: { + type: DataTypes.INTEGER, + allowNull: true, + }, + popularity: { + type: DataTypes.FLOAT, + allowNull: true, + }, + originalLanguage: { + type: DataTypes.STRING, + allowNull: false, + }, + spokenLanguages: { + type: DataTypes.JSON, + allowNull: true, + }, + productionCountries: { + type: DataTypes.JSON, + allowNull: true, + }, + status: { + type: DataTypes.STRING, + allowNull: true, + }, + tagline: { + type: DataTypes.STRING, + allowNull: true, + }, + budget: { + type: DataTypes.INTEGER, + allowNull: true, + }, + revenue: { + type: DataTypes.INTEGER, + allowNull: true, + }, + language: { + type: DataTypes.STRING, + allowNull: false, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { sequelize, indexes: [{ fields: ['imdbId'] }] }, + ) + }, + }) + + useSequelize({ + injector, + model: TmdbSeriesMetadata, + sequelizeModel: TmdbSeriesMetadataModel, + primaryKey: 'id', + options: dbOptions, + initModel: async (sequelize) => { + TmdbSeriesMetadataModel.init( + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + allowNull: false, + }, + imdbId: { + type: DataTypes.STRING, + allowNull: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + originalName: { + type: DataTypes.STRING, + allowNull: false, + }, + overview: { + type: DataTypes.TEXT, + allowNull: false, + }, + firstAirDate: { + type: DataTypes.STRING, + allowNull: true, + }, + posterPath: { + type: DataTypes.STRING, + allowNull: true, + }, + backdropPath: { + type: DataTypes.STRING, + allowNull: true, + }, + genres: { + type: DataTypes.JSON, + allowNull: true, + }, + voteAverage: { + type: DataTypes.FLOAT, + allowNull: true, + }, + voteCount: { + type: DataTypes.INTEGER, + allowNull: true, + }, + numberOfSeasons: { + type: DataTypes.INTEGER, + allowNull: true, + }, + numberOfEpisodes: { + type: DataTypes.INTEGER, + allowNull: true, + }, + status: { + type: DataTypes.STRING, + allowNull: true, + }, + originalLanguage: { + type: DataTypes.STRING, + allowNull: false, + }, + languages: { + type: DataTypes.JSON, + allowNull: true, + }, + language: { + type: DataTypes.STRING, + allowNull: false, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { sequelize, indexes: [{ fields: ['imdbId'] }] }, + ) + }, + }) + + useSequelize({ + injector, + model: MovieMetadataLocalized, + sequelizeModel: MovieMetadataLocalizedModel, + primaryKey: 'id', + options: dbOptions, + initModel: async (sequelize) => { + MovieMetadataLocalizedModel.init( + { + id: { + type: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false, + defaultValue: () => crypto.randomUUID(), + }, + movieImdbId: { + type: DataTypes.STRING, + allowNull: false, + }, + language: { + type: DataTypes.STRING, + allowNull: false, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + plot: { + type: DataTypes.TEXT, + allowNull: true, + }, + posterUrl: { + type: DataTypes.STRING, + allowNull: true, + }, + genre: { + type: DataTypes.JSON, + allowNull: true, + }, + source: { + type: DataTypes.STRING, + allowNull: false, + }, + sourceId: { + type: DataTypes.STRING, + allowNull: true, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { + sequelize, + indexes: [{ fields: ['movieImdbId'] }, { fields: ['movieImdbId', 'language', 'source'], unique: true }], + }, + ) + }, + }) + + useSequelize({ + injector, + model: SeriesMetadataLocalized, + sequelizeModel: SeriesMetadataLocalizedModel, + primaryKey: 'id', + options: dbOptions, + initModel: async (sequelize) => { + SeriesMetadataLocalizedModel.init( + { + id: { + type: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false, + defaultValue: () => crypto.randomUUID(), + }, + seriesImdbId: { + type: DataTypes.STRING, + allowNull: false, + }, + language: { + type: DataTypes.STRING, + allowNull: false, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + plot: { + type: DataTypes.TEXT, + allowNull: true, + }, + posterUrl: { + type: DataTypes.STRING, + allowNull: true, + }, + source: { + type: DataTypes.STRING, + allowNull: false, + }, + sourceId: { + type: DataTypes.STRING, + allowNull: true, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { + sequelize, + indexes: [{ fields: ['seriesImdbId'] }, { fields: ['seriesImdbId', 'language', 'source'], unique: true }], + }, + ) + }, + }) +} diff --git a/service/src/app-models/media/media-sequelize-models.ts b/service/src/app-models/media/media-sequelize-models.ts new file mode 100644 index 00000000..6518af7c --- /dev/null +++ b/service/src/app-models/media/media-sequelize-models.ts @@ -0,0 +1,205 @@ +import type { + Movie, + MovieFile, + MovieMetadataLocalized, + OmdbMovieMetadata, + OmdbSeriesMetadata, + Series, + SeriesMetadataLocalized, + TmdbMovieMetadata, + TmdbSeriesMetadata, + WatchHistoryEntry, +} from 'common' + +import { Model } from 'sequelize' + +import type { FfprobeResult } from '../../ffprobe-service.js' + +export class MovieModel extends Model implements Movie { + declare imdbId: string + declare year?: number | undefined + declare duration?: number | undefined + declare type?: 'episode' | 'movie' | undefined + declare seriesId?: string | undefined + declare season?: number | undefined + declare episode?: number | undefined + declare createdAt: string + declare updatedAt: string +} + +export class MovieFileModel extends Model implements MovieFile { + declare id: string + declare imdbId?: string + declare driveLetter: string + declare path: string + declare ffprobe: FfprobeResult + declare relatedFiles?: Array<{ type: 'subtitle' | 'audio' | 'trailer' | 'info' | 'other'; path: string }> | undefined +} + +export class WatchHistoryEntryModel extends Model implements WatchHistoryEntry { + declare movieFileId: string + declare driveLetter: string + declare path: string + declare id: string + declare userName: string + declare movie: Movie + declare watchedSeconds: number + declare completed: boolean + declare createdAt: string + declare updatedAt: string +} + +export class SeriesModel extends Model implements Series { + declare imdbId: string + declare year: string + declare numberOfSeasons?: number | undefined + declare createdAt: string + declare updatedAt: string +} + +export class OmdbMovieMetadataModel extends Model implements OmdbMovieMetadata { + declare Title: string + declare Year: string + declare Rated: string + declare Released: string + declare Runtime: string + declare Genre: string + declare Director: string + declare Writer: string + declare Actors: string + declare Plot: string + declare Language: string + declare Country: string + declare Awards: string + declare Poster: string + declare Ratings: Array<{ Source: string; Value: string }> + declare Metascore: string + declare imdbRating: string + declare imdbVotes: string + declare imdbID: string + declare Type: 'episode' | 'movie' + declare DVD?: string | undefined + declare BoxOffice?: string | undefined + declare Production?: string | undefined + declare Website?: string | undefined + declare Response: 'True' + declare seriesID?: string | undefined + declare Season?: string | undefined + declare Episode?: string | undefined + declare createdAt: string + declare updatedAt: string +} + +export class OmdbSeriesMetadataModel + extends Model + implements OmdbSeriesMetadata +{ + declare Title: string + declare Year: string + declare Rated: string + declare Released: string + declare Runtime: string + declare Genre: string + declare Director: string + declare Writer: string + declare Actors: string + declare Plot: string + declare Language: string + declare Country: string + declare Awards: string + declare Poster: string + declare Ratings: Array<{ Source: string; Value: string }> + declare Metascore: string + declare imdbRating: string + declare imdbVotes: string + declare imdbID: string + declare Type: string + declare totalSeasons: string + declare Response: string + declare createdAt: string + declare updatedAt: string +} + +export class TmdbMovieMetadataModel extends Model implements TmdbMovieMetadata { + declare id: number + declare imdbId?: string + declare title: string + declare originalTitle: string + declare overview: string + declare releaseDate?: string + declare runtime?: number + declare posterPath?: string + declare backdropPath?: string + declare genres: Array<{ id: number; name: string }> + declare voteAverage?: number + declare voteCount?: number + declare popularity?: number + declare originalLanguage: string + declare spokenLanguages?: Array<{ iso_639_1: string; name: string }> + declare productionCountries?: Array<{ iso_3166_1: string; name: string }> + declare status?: string + declare tagline?: string + declare budget?: number + declare revenue?: number + declare language: string + declare createdAt: string + declare updatedAt: string +} + +export class TmdbSeriesMetadataModel + extends Model + implements TmdbSeriesMetadata +{ + declare id: number + declare imdbId?: string + declare name: string + declare originalName: string + declare overview: string + declare firstAirDate?: string + declare posterPath?: string + declare backdropPath?: string + declare genres: Array<{ id: number; name: string }> + declare voteAverage?: number + declare voteCount?: number + declare numberOfSeasons?: number + declare numberOfEpisodes?: number + declare status?: string + declare originalLanguage: string + declare languages?: string[] + declare language: string + declare createdAt: string + declare updatedAt: string +} + +export class MovieMetadataLocalizedModel + extends Model + implements MovieMetadataLocalized +{ + declare id: string + declare movieImdbId: string + declare language: string + declare title: string + declare plot?: string + declare posterUrl?: string + declare genre?: string[] + declare source: 'omdb' | 'tmdb' + declare sourceId?: string + declare createdAt: string + declare updatedAt: string +} + +export class SeriesMetadataLocalizedModel + extends Model + implements SeriesMetadataLocalized +{ + declare id: string + declare seriesImdbId: string + declare language: string + declare title: string + declare plot?: string + declare posterUrl?: string + declare source: 'omdb' | 'tmdb' + declare sourceId?: string + declare createdAt: string + declare updatedAt: string +} diff --git a/service/src/app-models/media/metadata-services/build-synthetic-movie.ts b/service/src/app-models/media/metadata-services/build-synthetic-movie.ts new file mode 100644 index 00000000..0da591ee --- /dev/null +++ b/service/src/app-models/media/metadata-services/build-synthetic-movie.ts @@ -0,0 +1,38 @@ +import type { TmdbMovieDetailsResponse, TmdbTvDetailsResponse, TmdbEpisodeDetailsResponse } from './tmdb-api-types.js' + +/** + * Builds a synthetic TmdbMovieDetailsResponse from series + episode data + * so callers get a consistent shape for ensureMovieExists. + */ +export const buildSyntheticMovieFromEpisode = ( + tvDetails: TmdbTvDetailsResponse, + episodeDetails: TmdbEpisodeDetailsResponse, + imdbId: string, +): TmdbMovieDetailsResponse => ({ + adult: tvDetails.adult, + backdrop_path: episodeDetails.still_path, + belongs_to_collection: null, + budget: 0, + genres: tvDetails.genres, + homepage: '', + id: episodeDetails.id, + imdb_id: imdbId, + origin_country: tvDetails.origin_country, + original_language: tvDetails.original_language, + original_title: episodeDetails.name, + overview: episodeDetails.overview, + popularity: 0, + poster_path: tvDetails.poster_path, + production_companies: [], + production_countries: [], + release_date: episodeDetails.air_date, + revenue: 0, + runtime: episodeDetails.runtime ?? 0, + spoken_languages: [], + status: 'Released', + tagline: '', + title: episodeDetails.name, + video: false, + vote_average: episodeDetails.vote_average, + vote_count: episodeDetails.vote_count, +}) diff --git a/service/src/app-models/media/metadata-services/tmdb-client-service.spec.ts b/service/src/app-models/media/metadata-services/tmdb-client-service.spec.ts new file mode 100644 index 00000000..d3018fa5 --- /dev/null +++ b/service/src/app-models/media/metadata-services/tmdb-client-service.spec.ts @@ -0,0 +1,735 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { TmdbClientService, buildTmdbImageUrl } from './tmdb-client-service.js' +import type { + TmdbMovieDetailsResponse, + TmdbTvDetailsResponse, + TmdbEpisodeDetailsResponse, + TmdbFindByIdResponse, + TmdbPaginatedResponse, + TmdbSearchMovieResult, + TmdbSearchTvResult, +} from './tmdb-api-types.js' + +vi.mock('@furystack/core', () => ({ + useSystemIdentityContext: () => ({}), +})) + +vi.mock('@furystack/logging', () => ({ + getLogger: () => ({ + withScope: () => ({ + verbose: vi.fn().mockResolvedValue(undefined), + information: vi.fn().mockResolvedValue(undefined), + warning: vi.fn().mockResolvedValue(undefined), + error: vi.fn().mockResolvedValue(undefined), + debug: vi.fn().mockResolvedValue(undefined), + }), + }), +})) + +vi.mock('@furystack/inject', () => ({ + Injectable: () => (target: unknown) => target, + Injected: () => () => undefined, +})) + +vi.mock('@furystack/repository', () => ({ + getDataSetFor: () => ({ + get: vi.fn(), + subscribe: vi.fn(), + }), +})) + +const createMockResponse = (body: unknown, status = 200) => + ({ + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : `HTTP ${status}`, + headers: { + get: () => null, + }, + json: () => Promise.resolve(body), + }) as unknown as Response + +const createSearchMovieResponse = ( + results: Array> = [], +): TmdbPaginatedResponse => ({ + page: 1, + results: results.map((r) => ({ + adult: false, + backdrop_path: null, + genre_ids: [], + id: 12345, + original_language: 'en', + original_title: 'Test Movie', + overview: 'A test movie', + popularity: 10, + poster_path: null, + release_date: '2024-01-01', + title: 'Test Movie', + video: false, + vote_average: 7.5, + vote_count: 100, + ...r, + })), + total_pages: 1, + total_results: results.length, +}) + +const createSearchTvResponse = ( + results: Array> = [], +): TmdbPaginatedResponse => ({ + page: 1, + results: results.map((r) => ({ + adult: false, + backdrop_path: null, + genre_ids: [], + id: 67890, + origin_country: ['US'], + original_language: 'en', + original_name: 'Test Series', + overview: 'A test series', + popularity: 10, + poster_path: null, + first_air_date: '2024-01-01', + name: 'Test Series', + vote_average: 8.0, + vote_count: 200, + ...r, + })), + total_pages: 1, + total_results: results.length, +}) + +const createMovieDetailsResponse = (overrides: Partial = {}): TmdbMovieDetailsResponse => ({ + adult: false, + backdrop_path: null, + belongs_to_collection: null, + budget: 1000000, + genres: [{ id: 28, name: 'Action' }], + homepage: '', + id: 12345, + imdb_id: 'tt1234567', + origin_country: ['US'], + original_language: 'en', + original_title: 'Test Movie', + overview: 'A test movie', + popularity: 10, + poster_path: '/poster.jpg', + production_companies: [], + production_countries: [], + release_date: '2024-01-01', + revenue: 5000000, + runtime: 120, + spoken_languages: [], + status: 'Released', + tagline: 'A tagline', + title: 'Test Movie', + video: false, + vote_average: 7.5, + vote_count: 100, + ...overrides, +}) + +const createTvDetailsResponse = (overrides: Partial = {}): TmdbTvDetailsResponse => ({ + adult: false, + backdrop_path: null, + created_by: [], + episode_run_time: [45], + first_air_date: '2024-01-01', + genres: [{ id: 18, name: 'Drama' }], + homepage: '', + id: 67890, + in_production: true, + languages: ['en'], + last_air_date: '2024-12-01', + last_episode_to_air: null, + name: 'Test Series', + networks: [], + next_episode_to_air: null, + number_of_episodes: 10, + number_of_seasons: 1, + origin_country: ['US'], + original_language: 'en', + original_name: 'Test Series', + overview: 'A test series', + popularity: 10, + poster_path: '/poster.jpg', + production_companies: [], + production_countries: [], + seasons: [], + spoken_languages: [], + status: 'Returning Series', + tagline: '', + type: 'Scripted', + vote_average: 8.0, + vote_count: 200, + external_ids: { imdb_id: 'tt9876543', facebook_id: null, instagram_id: null, twitter_id: null, wikidata_id: null }, + ...overrides, +}) + +const createEpisodeDetailsResponse = ( + overrides: Partial = {}, +): TmdbEpisodeDetailsResponse => ({ + air_date: '2024-03-01', + episode_number: 5, + id: 111222, + name: 'Test Episode', + overview: 'A test episode', + production_code: '', + runtime: 45, + season_number: 1, + still_path: '/still.jpg', + vote_average: 8.5, + vote_count: 50, + crew: [], + guest_stars: [], + ...overrides, +}) + +const createFindByIdResponse = (overrides: Partial = {}): TmdbFindByIdResponse => ({ + movie_results: [], + tv_results: [], + person_results: [], + tv_episode_results: [], + tv_season_results: [], + ...overrides, +}) + +describe('TmdbClientService', () => { + let service: TmdbClientService + const originalFetch = globalThis.fetch + + beforeEach(() => { + service = new TmdbClientService() + + Object.defineProperty(service, 'logger', { + value: { + verbose: vi.fn().mockResolvedValue(undefined), + information: vi.fn().mockResolvedValue(undefined), + warning: vi.fn().mockResolvedValue(undefined), + error: vi.fn().mockResolvedValue(undefined), + debug: vi.fn().mockResolvedValue(undefined), + }, + writable: true, + }) + + Object.defineProperty(service, 'semaphore', { + value: { execute: (fn: () => Promise) => fn() }, + writable: true, + }) + + service.config = { + id: 'TMDB_CONFIG', + value: { apiKey: 'test-api-key', defaultLanguage: 'en-US' }, + } as never + }) + + afterEach(() => { + globalThis.fetch = originalFetch + vi.restoreAllMocks() + }) + + describe('buildTmdbImageUrl', () => { + it('should return undefined for null path', () => { + expect(buildTmdbImageUrl(null)).toBeUndefined() + }) + + it('should build URL with default size', () => { + expect(buildTmdbImageUrl('/poster.jpg')).toBe('https://image.tmdb.org/t/p/w500/poster.jpg') + }) + + it('should build URL with custom size', () => { + expect(buildTmdbImageUrl('/poster.jpg', 'original')).toBe('https://image.tmdb.org/t/p/original/poster.jpg') + }) + }) + + describe('searchMovie', () => { + it('should return not-configured when config is missing', async () => { + service.config = undefined + const result = await service.searchMovie('Test') + expect(result.status).toBe('not-configured') + }) + + it('should return success with search results', async () => { + expect.assertions(2) + + const searchResponse = createSearchMovieResponse([{ id: 12345, title: 'Test Movie' }]) + globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse(searchResponse)) + + const result = await service.searchMovie('Test Movie') + expect(result.status).toBe('success') + if (result.status === 'success') { + expect(result.data.results[0].title).toBe('Test Movie') + } + }) + + it('should include year in query when provided', async () => { + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(createSearchMovieResponse([]))) + globalThis.fetch = mockFetch + + await service.searchMovie('Test', { year: 2024 }) + + const calledUrl = mockFetch.mock.calls[0][0] as string + expect(calledUrl).toContain('year=2024') + }) + + it('should return not-found for 404 response', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse({}, 404)) + + const result = await service.searchMovie('Nonexistent') + expect(result.status).toBe('not-found') + }) + + it('should return error for non-ok response', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse({}, 500)) + + const result = await service.searchMovie('Test') + expect(result.status).toBe('error') + }) + }) + + describe('searchTv', () => { + it('should return not-configured when config is missing', async () => { + service.config = undefined + const result = await service.searchTv('Test') + expect(result.status).toBe('not-configured') + }) + + it('should return success with search results', async () => { + expect.assertions(2) + + const searchResponse = createSearchTvResponse([{ id: 67890, name: 'Test Series' }]) + globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse(searchResponse)) + + const result = await service.searchTv('Test Series') + expect(result.status).toBe('success') + if (result.status === 'success') { + expect(result.data.results[0].name).toBe('Test Series') + } + }) + }) + + describe('getMovieDetails', () => { + it('should return not-configured when config is missing', async () => { + service.config = undefined + const result = await service.getMovieDetails(12345) + expect(result.status).toBe('not-configured') + }) + + it('should return success with movie details', async () => { + expect.assertions(2) + + const details = createMovieDetailsResponse() + globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse(details)) + + const result = await service.getMovieDetails(12345) + expect(result.status).toBe('success') + if (result.status === 'success') { + expect(result.data.imdb_id).toBe('tt1234567') + } + }) + + it('should include append_to_response for external_ids', async () => { + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(createMovieDetailsResponse())) + globalThis.fetch = mockFetch + + await service.getMovieDetails(12345) + + const calledUrl = mockFetch.mock.calls[0][0] as string + expect(calledUrl).toContain('append_to_response=external_ids') + }) + }) + + describe('getTvDetails', () => { + it('should return success with TV details', async () => { + expect.assertions(2) + + const details = createTvDetailsResponse() + globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse(details)) + + const result = await service.getTvDetails(67890) + expect(result.status).toBe('success') + if (result.status === 'success') { + expect(result.data.name).toBe('Test Series') + } + }) + }) + + describe('getEpisodeDetails', () => { + it('should return success with episode details', async () => { + expect.assertions(2) + + const details = createEpisodeDetailsResponse() + globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse(details)) + + const result = await service.getEpisodeDetails(67890, 1, 5) + expect(result.status).toBe('success') + if (result.status === 'success') { + expect(result.data.name).toBe('Test Episode') + } + }) + + it('should construct correct URL for episode', async () => { + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(createEpisodeDetailsResponse())) + globalThis.fetch = mockFetch + + await service.getEpisodeDetails(67890, 2, 3) + + const calledUrl = mockFetch.mock.calls[0][0] as string + expect(calledUrl).toContain('/tv/67890/season/2/episode/3') + }) + }) + + describe('findByImdbId', () => { + it('should return not-configured when config is missing', async () => { + service.config = undefined + const result = await service.findByImdbId('tt1234567') + expect(result.status).toBe('not-configured') + }) + + it('should return success with find results', async () => { + expect.assertions(2) + + const findResponse = createFindByIdResponse({ + tv_results: [ + { + id: 67890, + name: 'Test Series', + } as TmdbSearchTvResult, + ], + }) + globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse(findResponse)) + + const result = await service.findByImdbId('tt1234567') + expect(result.status).toBe('success') + if (result.status === 'success') { + expect(result.data.tv_results[0].id).toBe(67890) + } + }) + + it('should include external_source=imdb_id in URL', async () => { + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(createFindByIdResponse())) + globalThis.fetch = mockFetch + + await service.findByImdbId('tt1234567') + + const calledUrl = mockFetch.mock.calls[0][0] as string + expect(calledUrl).toContain('/find/tt1234567') + expect(calledUrl).toContain('external_source=imdb_id') + }) + }) + + describe('rate limit handling', () => { + it('should return rate-limited after exhausting retries', async () => { + vi.useFakeTimers() + + globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse({}, 429)) + + const resultPromise = service.searchMovie('Test') + + for (let i = 0; i < 5; i++) { + await vi.advanceTimersByTimeAsync(60_000) + } + + const result = await resultPromise + expect(result.status).toBe('rate-limited') + // 1 initial + 3 retries = 4 total calls + expect(globalThis.fetch).toHaveBeenCalledTimes(4) + + vi.useRealTimers() + }) + + it('should succeed after transient rate limit', async () => { + vi.useFakeTimers() + + const mockFetch = vi + .fn() + .mockResolvedValueOnce(createMockResponse({}, 429)) + .mockResolvedValueOnce(createMockResponse(createSearchMovieResponse([{ id: 1 }]))) + + globalThis.fetch = mockFetch + + const resultPromise = service.searchMovie('Test') + await vi.advanceTimersByTimeAsync(10_000) + + const result = await resultPromise + expect(result.status).toBe('success') + expect(mockFetch).toHaveBeenCalledTimes(2) + + vi.useRealTimers() + }) + + it('should use Retry-After header when present', async () => { + vi.useFakeTimers() + + const rateLimitResponse = { + ok: false, + status: 429, + statusText: 'Too Many Requests', + headers: { get: (name: string) => (name === 'Retry-After' ? '5' : null) }, + json: () => Promise.resolve({}), + } as unknown as Response + + const mockFetch = vi + .fn() + .mockResolvedValueOnce(rateLimitResponse) + .mockResolvedValueOnce(createMockResponse(createSearchMovieResponse([{ id: 1 }]))) + + globalThis.fetch = mockFetch + + const resultPromise = service.searchMovie('Test') + + // 5 seconds from Retry-After header + await vi.advanceTimersByTimeAsync(5_000) + + const result = await resultPromise + expect(result.status).toBe('success') + + vi.useRealTimers() + }) + }) + + describe('fetchTmdbMovieMetadata', () => { + it('should return not-configured when config is missing', async () => { + service.config = undefined + const result = await service.fetchTmdbMovieMetadata({ title: 'Test' }) + expect(result.status).toBe('not-configured') + }) + + it('should return success for a movie search', async () => { + expect.assertions(2) + + const searchResponse = createSearchMovieResponse([{ id: 12345 }]) + const detailsResponse = createMovieDetailsResponse({ imdb_id: 'tt1234567' }) + + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce(createMockResponse(searchResponse)) + .mockResolvedValueOnce(createMockResponse(detailsResponse)) + + const result = await service.fetchTmdbMovieMetadata({ title: 'Test Movie', year: 2024 }) + expect(result.status).toBe('success') + if (result.status === 'success') { + expect(result.data.movie.imdb_id).toBe('tt1234567') + } + }) + + it('should return not-found when search returns empty results', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse(createSearchMovieResponse([]))) + + const result = await service.fetchTmdbMovieMetadata({ title: 'Nonexistent' }) + expect(result.status).toBe('not-found') + }) + + it('should return not-found when movie has no IMDB ID', async () => { + const searchResponse = createSearchMovieResponse([{ id: 12345 }]) + const detailsResponse = createMovieDetailsResponse({ imdb_id: null, external_ids: undefined }) + + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce(createMockResponse(searchResponse)) + .mockResolvedValueOnce(createMockResponse(detailsResponse)) + + const result = await service.fetchTmdbMovieMetadata({ title: 'Test' }) + expect(result.status).toBe('not-found') + }) + + it('should use external_ids.imdb_id as fallback', async () => { + expect.assertions(2) + + const searchResponse = createSearchMovieResponse([{ id: 12345 }]) + const detailsResponse = createMovieDetailsResponse({ + imdb_id: null, + external_ids: { + imdb_id: 'tt7777777', + facebook_id: null, + instagram_id: null, + twitter_id: null, + wikidata_id: null, + }, + }) + + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce(createMockResponse(searchResponse)) + .mockResolvedValueOnce(createMockResponse(detailsResponse)) + + const result = await service.fetchTmdbMovieMetadata({ title: 'Test' }) + expect(result.status).toBe('success') + if (result.status === 'success') { + expect(result.data.movie.imdb_id).toBe('tt7777777') + } + }) + + it('should return error when fetch throws', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error')) + + const result = await service.fetchTmdbMovieMetadata({ title: 'Test' }) + expect(result.status).toBe('error') + }) + }) + + describe('fetchTmdbMovieMetadata - episode flow', () => { + it('should fetch episode metadata when season and episode are provided', async () => { + expect.assertions(4) + + const searchTvResponse = createSearchTvResponse([{ id: 67890 }]) + const tvDetailsResponse = createTvDetailsResponse() + const episodeResponse = createEpisodeDetailsResponse() + + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce(createMockResponse(searchTvResponse)) + .mockResolvedValueOnce(createMockResponse(tvDetailsResponse)) + .mockResolvedValueOnce(createMockResponse(episodeResponse)) + + const result = await service.fetchTmdbMovieMetadata({ title: 'Test Series', season: 1, episode: 5 }) + expect(result.status).toBe('success') + if (result.status === 'success') { + expect(result.data.movie.imdb_id).toBe('tt9876543') + expect(result.data.episode?.name).toBe('Test Episode') + expect(result.data.series?.name).toBe('Test Series') + } + }) + + it('should return not-found when TV search returns no results', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse(createSearchTvResponse([]))) + + const result = await service.fetchTmdbMovieMetadata({ title: 'Nonexistent', season: 1, episode: 1 }) + expect(result.status).toBe('not-found') + }) + + it('should return not-found when TV series has no IMDB ID', async () => { + const searchTvResponse = createSearchTvResponse([{ id: 67890 }]) + const tvDetailsResponse = createTvDetailsResponse({ + external_ids: { + imdb_id: null, + facebook_id: null, + instagram_id: null, + twitter_id: null, + wikidata_id: null, + }, + }) + + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce(createMockResponse(searchTvResponse)) + .mockResolvedValueOnce(createMockResponse(tvDetailsResponse)) + + const result = await service.fetchTmdbMovieMetadata({ title: 'Test', season: 1, episode: 1 }) + expect(result.status).toBe('not-found') + }) + + it('should build synthetic movie from episode data', async () => { + expect.assertions(3) + + const searchTvResponse = createSearchTvResponse([{ id: 67890 }]) + const tvDetailsResponse = createTvDetailsResponse() + const episodeResponse = createEpisodeDetailsResponse({ name: 'Pilot', runtime: 60 }) + + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce(createMockResponse(searchTvResponse)) + .mockResolvedValueOnce(createMockResponse(tvDetailsResponse)) + .mockResolvedValueOnce(createMockResponse(episodeResponse)) + + const result = await service.fetchTmdbMovieMetadata({ title: 'Test', season: 1, episode: 1 }) + expect(result.status).toBe('success') + if (result.status === 'success') { + expect(result.data.movie.title).toBe('Pilot') + expect(result.data.movie.runtime).toBe(60) + } + }) + }) + + describe('fetchTmdbSeriesMetadata', () => { + it('should return not-configured when config is missing', async () => { + service.config = undefined + const result = await service.fetchTmdbSeriesMetadata({ imdbId: 'tt1234567' }) + expect(result.status).toBe('not-configured') + }) + + it('should return success with series details', async () => { + expect.assertions(2) + + const findResponse = createFindByIdResponse({ + tv_results: [{ id: 67890 } as TmdbSearchTvResult], + }) + const tvDetailsResponse = createTvDetailsResponse() + + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce(createMockResponse(findResponse)) + .mockResolvedValueOnce(createMockResponse(tvDetailsResponse)) + + const result = await service.fetchTmdbSeriesMetadata({ imdbId: 'tt1234567' }) + expect(result.status).toBe('success') + if (result.status === 'success') { + expect(result.data.name).toBe('Test Series') + } + }) + + it('should return not-found when IMDB ID has no TV results', async () => { + const findResponse = createFindByIdResponse({ tv_results: [] }) + globalThis.fetch = vi.fn().mockResolvedValue(createMockResponse(findResponse)) + + const result = await service.fetchTmdbSeriesMetadata({ imdbId: 'tt9999999' }) + expect(result.status).toBe('not-found') + }) + + it('should return error when fetch throws', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error')) + + const result = await service.fetchTmdbSeriesMetadata({ imdbId: 'tt1234567' }) + expect(result.status).toBe('error') + }) + }) + + describe('authorization header', () => { + it('should send Bearer token in Authorization header', async () => { + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(createSearchMovieResponse([]))) + globalThis.fetch = mockFetch + + await service.searchMovie('Test') + + const calledOptions = mockFetch.mock.calls[0][1] as RequestInit + expect((calledOptions.headers as Record).Authorization).toBe('Bearer test-api-key') + }) + }) + + describe('language defaults', () => { + it('should use config defaultLanguage', async () => { + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(createSearchMovieResponse([]))) + globalThis.fetch = mockFetch + + await service.searchMovie('Test') + + const calledUrl = mockFetch.mock.calls[0][0] as string + expect(calledUrl).toContain('language=en-US') + }) + + it('should use explicit language override', async () => { + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(createSearchMovieResponse([]))) + globalThis.fetch = mockFetch + + await service.searchMovie('Test', { language: 'fr-FR' }) + + const calledUrl = mockFetch.mock.calls[0][0] as string + expect(calledUrl).toContain('language=fr-FR') + }) + + it('should fall back to en-US when config has no defaultLanguage', async () => { + service.config = { + id: 'TMDB_CONFIG', + value: { apiKey: 'test-api-key' }, + } as never + + const mockFetch = vi.fn().mockResolvedValue(createMockResponse(createSearchMovieResponse([]))) + globalThis.fetch = mockFetch + + await service.searchMovie('Test') + + const calledUrl = mockFetch.mock.calls[0][0] as string + expect(calledUrl).toContain('language=en-US') + }) + }) +}) diff --git a/service/src/app-models/media/metadata-services/tmdb-client-service.ts b/service/src/app-models/media/metadata-services/tmdb-client-service.ts index a161f230..2dc7a317 100644 --- a/service/src/app-models/media/metadata-services/tmdb-client-service.ts +++ b/service/src/app-models/media/metadata-services/tmdb-client-service.ts @@ -16,6 +16,7 @@ import type { TmdbSearchMovieResult, TmdbSearchTvResult, } from './tmdb-api-types.js' +import { buildSyntheticMovieFromEpisode } from './build-synthetic-movie.js' export type TmdbFetchResult = | { status: 'success'; data: T } @@ -51,6 +52,10 @@ export class TmdbClientService { private readonly semaphore = new Semaphore(1) + private getLanguage(override?: string): string { + return override ?? this.config?.value.defaultLanguage ?? 'en-US' + } + public init() { void this.initAsync().catch((error) => { void this.logger.error({ message: 'Failed to initialize TMDB Client Service', data: { error } }) @@ -166,7 +171,7 @@ export class TmdbClientService { ): Promise>> { const params = new URLSearchParams({ query: title, - language: options?.language ?? this.config?.value.defaultLanguage ?? 'en-US', + language: this.getLanguage(options?.language), }) if (options?.year) params.set('year', String(options.year)) @@ -181,7 +186,7 @@ export class TmdbClientService { ): Promise>> { const params = new URLSearchParams({ query: title, - language: options?.language ?? this.config?.value.defaultLanguage ?? 'en-US', + language: this.getLanguage(options?.language), }) return this.semaphore.execute(() => this.fetchJson(`/search/tv?${params}`, { description: `search tv '${title}'` })) @@ -192,7 +197,7 @@ export class TmdbClientService { options?: { language?: string }, ): Promise> { const params = new URLSearchParams({ - language: options?.language ?? this.config?.value.defaultLanguage ?? 'en-US', + language: this.getLanguage(options?.language), append_to_response: 'external_ids', }) @@ -206,7 +211,7 @@ export class TmdbClientService { options?: { language?: string }, ): Promise> { const params = new URLSearchParams({ - language: options?.language ?? this.config?.value.defaultLanguage ?? 'en-US', + language: this.getLanguage(options?.language), append_to_response: 'external_ids', }) @@ -222,7 +227,7 @@ export class TmdbClientService { options?: { language?: string }, ): Promise> { const params = new URLSearchParams({ - language: options?.language ?? this.config?.value.defaultLanguage ?? 'en-US', + language: this.getLanguage(options?.language), }) return this.semaphore.execute(() => @@ -235,7 +240,7 @@ export class TmdbClientService { public async findByImdbId(imdbId: string): Promise> { const params = new URLSearchParams({ external_source: 'imdb_id', - language: this.config?.value.defaultLanguage ?? 'en-US', + language: this.getLanguage(), }) return this.semaphore.execute(() => @@ -319,41 +324,10 @@ export class TmdbClientService { const episodeResult = await this.getEpisodeDetails(tvId, season, episode) if (episodeResult.status !== 'success') return episodeResult - // Build a synthetic TmdbMovieDetailsResponse from series+episode data - // so the caller gets a consistent shape for ensureMovieExists - const syntheticMovie: TmdbMovieDetailsResponse = { - adult: tvResult.data.adult, - backdrop_path: episodeResult.data.still_path, - belongs_to_collection: null, - budget: 0, - genres: tvResult.data.genres, - homepage: '', - id: episodeResult.data.id, - imdb_id: imdbId, - origin_country: tvResult.data.origin_country, - original_language: tvResult.data.original_language, - original_title: episodeResult.data.name, - overview: episodeResult.data.overview, - popularity: 0, - poster_path: tvResult.data.poster_path, - production_companies: [], - production_countries: [], - release_date: episodeResult.data.air_date, - revenue: 0, - runtime: episodeResult.data.runtime ?? 0, - spoken_languages: [], - status: 'Released', - tagline: '', - title: episodeResult.data.name, - video: false, - vote_average: episodeResult.data.vote_average, - vote_count: episodeResult.data.vote_count, - } - return { status: 'success', data: { - movie: syntheticMovie, + movie: buildSyntheticMovieFromEpisode(tvResult.data, episodeResult.data, imdbId), episode: episodeResult.data, series: tvResult.data, }, diff --git a/service/src/app-models/media/setup-media.spec.ts b/service/src/app-models/media/setup-media.spec.ts index e1e19c04..478e609d 100644 --- a/service/src/app-models/media/setup-media.spec.ts +++ b/service/src/app-models/media/setup-media.spec.ts @@ -3,7 +3,7 @@ import { usingAsync } from '@furystack/utils' import type { Movie, MovieFile } from 'common' import { beforeEach, describe, expect, it, vi } from 'vitest' import { WebsocketService } from '../../websocket-service.js' -import { announceMovieFileAdded } from './setup-media.js' +import { announceMovieFileAdded } from './announce-movie-file-added.js' const mockDataSetGet = vi.fn() const mockAnnounce = vi.fn().mockResolvedValue(undefined) diff --git a/service/src/app-models/media/setup-media.ts b/service/src/app-models/media/setup-media.ts index 9425dd13..b30e6466 100644 --- a/service/src/app-models/media/setup-media.ts +++ b/service/src/app-models/media/setup-media.ts @@ -1,1114 +1,23 @@ import type { Injector } from '@furystack/inject' -import type { ScopedLogger } from '@furystack/logging' import { getLogger } from '@furystack/logging' +import { getDataSetFor } from '@furystack/repository' +import { Movie, MovieFile } from 'common' -import { - Movie, - MovieFile, - MovieMetadataLocalized, - OmdbMovieMetadata, - OmdbSeriesMetadata, - Series, - SeriesMetadataLocalized, - TmdbMovieMetadata, - TmdbSeriesMetadata, - WatchHistoryEntry, -} from 'common' - -import { getCurrentUser, isAuthorized } from '@furystack/core' -import type { AuthorizationResult, DataSet } from '@furystack/repository' -import { getDataSetFor, getRepository } from '@furystack/repository' -import { useSequelize } from '@furystack/sequelize-store' -import { DataTypes, Model } from 'sequelize' - -import { authorizedOnly } from '../../authorization/authorized-only.js' import { ExternalServiceStatusRegistry } from '../../external-service-status-registry.js' -import { withRole } from '../../authorization/with-role.js' -import type { FfprobeResult } from '../../ffprobe-service.js' -import { getDefaultDbSettings } from '../../get-default-db-options.js' -import { WebsocketService } from '../../websocket-service.js' import { OmdbClientService } from './metadata-services/omdb-client-service.js' import { TmdbClientService } from './metadata-services/tmdb-client-service.js' import { useMovieFileMaintainer } from './services/movie-file-maintainer.js' +import { announceMovieFileAdded } from './announce-movie-file-added.js' +import { setupMediaSchema } from './media-schema-setup.js' +import { setupMediaDataSets } from './media-data-sets.js' -class MovieModel extends Model implements Movie { - declare imdbId: string - declare year?: number | undefined - declare duration?: number | undefined - declare type?: 'episode' | 'movie' | undefined - declare seriesId?: string | undefined - declare season?: number | undefined - declare episode?: number | undefined - declare createdAt: string - declare updatedAt: string -} - -class MovieFileModel extends Model implements MovieFile { - declare id: string - declare imdbId?: string - declare driveLetter: string - declare path: string - declare ffprobe: FfprobeResult - declare relatedFiles?: Array<{ type: 'subtitle' | 'audio' | 'trailer' | 'info' | 'other'; path: string }> | undefined -} - -class WatchHistoryEntryModel extends Model implements WatchHistoryEntry { - declare movieFileId: string - declare driveLetter: string - declare path: string - declare id: string - declare userName: string - declare movie: Movie - declare watchedSeconds: number - declare completed: boolean - declare createdAt: string - declare updatedAt: string -} - -class SeriesModel extends Model implements Series { - declare imdbId: string - declare year: string - declare numberOfSeasons?: number | undefined - declare createdAt: string - declare updatedAt: string -} - -class OmdbMovieMetadataModel extends Model implements OmdbMovieMetadata { - declare Title: string - declare Year: string - declare Rated: string - declare Released: string - declare Runtime: string - declare Genre: string - declare Director: string - declare Writer: string - declare Actors: string - declare Plot: string - declare Language: string - declare Country: string - declare Awards: string - declare Poster: string - declare Ratings: Array<{ Source: string; Value: string }> - declare Metascore: string - declare imdbRating: string - declare imdbVotes: string - declare imdbID: string - declare Type: 'episode' | 'movie' - declare DVD?: string | undefined - declare BoxOffice?: string | undefined - declare Production?: string | undefined - declare Website?: string | undefined - declare Response: 'True' - declare seriesID?: string | undefined - declare Season?: string | undefined - declare Episode?: string | undefined - declare createdAt: string - declare updatedAt: string -} - -class OmdbSeriesMetadataModel extends Model implements OmdbSeriesMetadata { - declare Title: string - declare Year: string - declare Rated: string - declare Released: string - declare Runtime: string - declare Genre: string - declare Director: string - declare Writer: string - declare Actors: string - declare Plot: string - declare Language: string - declare Country: string - declare Awards: string - declare Poster: string - declare Ratings: Array<{ Source: string; Value: string }> - declare Metascore: string - declare imdbRating: string - declare imdbVotes: string - declare imdbID: string - declare Type: string - declare totalSeasons: string - declare Response: string - declare createdAt: string - declare updatedAt: string -} - -class TmdbMovieMetadataModel extends Model implements TmdbMovieMetadata { - declare id: number - declare imdbId?: string - declare title: string - declare originalTitle: string - declare overview: string - declare releaseDate?: string - declare runtime?: number - declare posterPath?: string - declare backdropPath?: string - declare genres: Array<{ id: number; name: string }> - declare voteAverage?: number - declare voteCount?: number - declare popularity?: number - declare originalLanguage: string - declare spokenLanguages?: Array<{ iso_639_1: string; name: string }> - declare productionCountries?: Array<{ iso_3166_1: string; name: string }> - declare status?: string - declare tagline?: string - declare budget?: number - declare revenue?: number - declare language: string - declare createdAt: string - declare updatedAt: string -} - -class TmdbSeriesMetadataModel extends Model implements TmdbSeriesMetadata { - declare id: number - declare imdbId?: string - declare name: string - declare originalName: string - declare overview: string - declare firstAirDate?: string - declare posterPath?: string - declare backdropPath?: string - declare genres: Array<{ id: number; name: string }> - declare voteAverage?: number - declare voteCount?: number - declare numberOfSeasons?: number - declare numberOfEpisodes?: number - declare status?: string - declare originalLanguage: string - declare languages?: string[] - declare language: string - declare createdAt: string - declare updatedAt: string -} - -class MovieMetadataLocalizedModel - extends Model - implements MovieMetadataLocalized -{ - declare id: string - declare movieImdbId: string - declare language: string - declare title: string - declare plot?: string - declare posterUrl?: string - declare genre?: string[] - declare source: 'omdb' | 'tmdb' - declare sourceId?: string - declare createdAt: string - declare updatedAt: string -} - -class SeriesMetadataLocalizedModel - extends Model - implements SeriesMetadataLocalized -{ - declare id: string - declare seriesImdbId: string - declare language: string - declare title: string - declare plot?: string - declare posterUrl?: string - declare source: 'omdb' | 'tmdb' - declare sourceId?: string - declare createdAt: string - declare updatedAt: string -} - -export const announceMovieFileAdded = async ({ - entity, - injector, - movieDataSet, - logger, -}: { - entity: MovieFile - injector: Injector - movieDataSet: DataSet - logger: ScopedLogger -}) => { - if (!entity.imdbId) return - try { - const movie = await movieDataSet.get(injector, entity.imdbId) - if (movie) { - await injector.getInstance(WebsocketService).announce( - { - type: 'add-movie', - file: { driveLetter: entity.driveLetter, path: entity.path }, - movie, - movieFile: entity, - }, - async ({ injector: i }) => isAuthorized(i, 'admin'), - ) - } - } catch (error) { - await logger.error({ - message: `Failed to announce new movie file '${entity.path}'`, - data: { error }, - }) - } -} +export { announceMovieFileAdded } from './announce-movie-file-added.js' export const setupMedia = async (injector: Injector) => { const logger = getLogger(injector).withScope('Movies') - const dbOptions = getDefaultDbSettings('movies.sqlite', logger) - - useSequelize({ - injector, - model: Movie, - sequelizeModel: MovieModel, - primaryKey: 'imdbId', - options: dbOptions, - initModel: async (sequelize) => { - MovieModel.init( - { - imdbId: { - type: DataTypes.STRING, - allowNull: false, - primaryKey: true, - }, - duration: { - type: DataTypes.INTEGER, - allowNull: true, - }, - year: { - type: DataTypes.INTEGER, - allowNull: true, - }, - episode: { - type: DataTypes.INTEGER, - allowNull: true, - }, - season: { - type: DataTypes.INTEGER, - allowNull: true, - }, - type: { - type: DataTypes.ENUM('episode', 'movie'), - allowNull: true, - }, - createdAt: { - type: DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: DataTypes.DATE, - allowNull: false, - }, - seriesId: { - type: DataTypes.STRING, - allowNull: true, - }, - }, - { sequelize }, - ) - }, - }) - - useSequelize({ - injector, - model: MovieFile, - sequelizeModel: MovieFileModel, - primaryKey: 'id', - options: dbOptions, - initModel: async (sequelize) => { - MovieFileModel.init( - { - id: { - type: DataTypes.UUIDV4, - primaryKey: true, - allowNull: false, - defaultValue: () => crypto.randomUUID(), - }, - imdbId: { - type: DataTypes.STRING, - allowNull: true, - }, - driveLetter: { - type: DataTypes.STRING, - allowNull: false, - }, - path: { - type: DataTypes.STRING, - allowNull: false, - }, - ffprobe: { - type: DataTypes.JSON, - allowNull: true, - }, - relatedFiles: { - type: DataTypes.JSON, - allowNull: true, - }, - }, - { sequelize, indexes: [{ fields: ['imdbId'] }, { fields: ['driveLetter', 'path'], unique: true }] }, - ) - }, - }) - - useSequelize({ - injector, - model: WatchHistoryEntry, - sequelizeModel: WatchHistoryEntryModel, - primaryKey: 'id', - options: dbOptions, - initModel: async (sequelize) => { - WatchHistoryEntryModel.init( - { - id: { - type: DataTypes.UUIDV4, - primaryKey: true, - allowNull: false, - defaultValue: () => crypto.randomUUID(), - }, - userName: { - type: DataTypes.STRING, - allowNull: false, - }, - driveLetter: { - type: DataTypes.STRING, - allowNull: false, - }, - path: { - type: DataTypes.STRING, - allowNull: false, - }, - watchedSeconds: { - type: DataTypes.INTEGER, - allowNull: false, - }, - completed: { - type: DataTypes.BOOLEAN, - allowNull: false, - }, - createdAt: { - type: DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: DataTypes.DATE, - allowNull: false, - }, - }, - { - sequelize, - indexes: [{ fields: ['userName', 'driveLetter', 'path'], unique: true }], - }, - ) - }, - }) - - useSequelize({ - injector, - model: Series, - sequelizeModel: SeriesModel, - primaryKey: 'imdbId', - options: dbOptions, - initModel: async (sequelize) => { - SeriesModel.init( - { - imdbId: { - type: DataTypes.STRING, - primaryKey: true, - }, - year: { - type: DataTypes.STRING, - allowNull: false, - }, - numberOfSeasons: { - type: DataTypes.INTEGER, - allowNull: true, - }, - createdAt: { - type: DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: DataTypes.DATE, - allowNull: false, - }, - }, - { sequelize }, - ) - }, - }) - - useSequelize({ - injector, - model: OmdbMovieMetadata, - sequelizeModel: OmdbMovieMetadataModel, - primaryKey: 'imdbID', - options: dbOptions, - initModel: async (sequelize) => { - OmdbMovieMetadataModel.init( - { - imdbID: { - type: DataTypes.STRING, - primaryKey: true, - allowNull: false, - }, - Title: { - type: DataTypes.STRING, - allowNull: false, - }, - Year: { - type: DataTypes.STRING, - allowNull: false, - }, - Rated: { - type: DataTypes.STRING, - allowNull: true, - }, - Released: { - type: DataTypes.STRING, - allowNull: true, - }, - Runtime: { - type: DataTypes.STRING, - allowNull: true, - }, - Genre: { - type: DataTypes.STRING, - allowNull: true, - }, - Director: { - type: DataTypes.STRING, - allowNull: true, - }, - Writer: { - type: DataTypes.STRING, - allowNull: true, - }, - Actors: { - type: DataTypes.STRING, - allowNull: true, - }, - Plot: { - type: DataTypes.STRING, - allowNull: false, - }, - Language: { - type: DataTypes.STRING, - allowNull: true, - }, - Country: { - type: DataTypes.STRING, - allowNull: true, - }, - Awards: { - type: DataTypes.STRING, - allowNull: true, - }, - Poster: { - type: DataTypes.STRING, - allowNull: false, - }, - Ratings: { - type: DataTypes.JSON, - allowNull: true, - }, - Metascore: { - type: DataTypes.STRING, - allowNull: true, - }, - imdbRating: { - type: DataTypes.STRING, - allowNull: true, - }, - imdbVotes: { - type: DataTypes.STRING, - allowNull: true, - }, - Response: { - type: DataTypes.STRING, - allowNull: false, - }, - Type: { - type: DataTypes.STRING, - allowNull: false, - }, - DVD: { - type: DataTypes.STRING, - allowNull: true, - }, - BoxOffice: { - type: DataTypes.STRING, - allowNull: true, - }, - Production: { - type: DataTypes.STRING, - allowNull: true, - }, - Episode: { - type: DataTypes.STRING, - allowNull: true, - }, - Season: { - type: DataTypes.STRING, - allowNull: true, - }, - Website: { - type: DataTypes.STRING, - allowNull: true, - }, - createdAt: { - type: DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: DataTypes.DATE, - allowNull: false, - }, - seriesID: { - type: DataTypes.STRING, - allowNull: true, - }, - }, - { sequelize }, - ) - }, - }) - - useSequelize({ - injector, - model: OmdbSeriesMetadata, - sequelizeModel: OmdbSeriesMetadataModel, - primaryKey: 'imdbID', - options: dbOptions, - initModel: async (sequelize) => { - OmdbSeriesMetadataModel.init( - { - imdbID: { - type: DataTypes.STRING, - primaryKey: true, - allowNull: false, - }, - Title: { - type: DataTypes.STRING, - allowNull: false, - }, - Actors: { - type: DataTypes.STRING, - allowNull: false, - }, - Awards: { - type: DataTypes.STRING, - allowNull: false, - }, - Country: { - type: DataTypes.STRING, - allowNull: false, - }, - Director: { - type: DataTypes.STRING, - allowNull: false, - }, - Genre: { - type: DataTypes.STRING, - allowNull: false, - }, - imdbRating: { - type: DataTypes.STRING, - allowNull: false, - }, - imdbVotes: { - type: DataTypes.STRING, - allowNull: false, - }, - Language: { - type: DataTypes.STRING, - allowNull: false, - }, - Plot: { - type: DataTypes.STRING, - allowNull: false, - }, - Poster: { - type: DataTypes.STRING, - allowNull: false, - }, - Metascore: { - type: DataTypes.STRING, - allowNull: false, - }, - Rated: { - type: DataTypes.STRING, - allowNull: false, - }, - Released: { - type: DataTypes.STRING, - allowNull: false, - }, - Response: { - type: DataTypes.STRING, - allowNull: false, - }, - Runtime: { - type: DataTypes.STRING, - allowNull: false, - }, - Type: { - type: DataTypes.STRING, - allowNull: false, - }, - Ratings: { - type: DataTypes.JSON, - allowNull: false, - }, - totalSeasons: { - type: DataTypes.STRING, - allowNull: false, - }, - Writer: { - type: DataTypes.STRING, - allowNull: false, - }, - Year: { - type: DataTypes.STRING, - allowNull: false, - }, - createdAt: { - type: DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: DataTypes.DATE, - allowNull: false, - }, - }, - { sequelize }, - ) - }, - }) - - useSequelize({ - injector, - model: TmdbMovieMetadata, - sequelizeModel: TmdbMovieMetadataModel, - primaryKey: 'id', - options: dbOptions, - initModel: async (sequelize) => { - TmdbMovieMetadataModel.init( - { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - allowNull: false, - }, - imdbId: { - type: DataTypes.STRING, - allowNull: true, - }, - title: { - type: DataTypes.STRING, - allowNull: false, - }, - originalTitle: { - type: DataTypes.STRING, - allowNull: false, - }, - overview: { - type: DataTypes.TEXT, - allowNull: false, - }, - releaseDate: { - type: DataTypes.STRING, - allowNull: true, - }, - runtime: { - type: DataTypes.INTEGER, - allowNull: true, - }, - posterPath: { - type: DataTypes.STRING, - allowNull: true, - }, - backdropPath: { - type: DataTypes.STRING, - allowNull: true, - }, - genres: { - type: DataTypes.JSON, - allowNull: true, - }, - voteAverage: { - type: DataTypes.FLOAT, - allowNull: true, - }, - voteCount: { - type: DataTypes.INTEGER, - allowNull: true, - }, - popularity: { - type: DataTypes.FLOAT, - allowNull: true, - }, - originalLanguage: { - type: DataTypes.STRING, - allowNull: false, - }, - spokenLanguages: { - type: DataTypes.JSON, - allowNull: true, - }, - productionCountries: { - type: DataTypes.JSON, - allowNull: true, - }, - status: { - type: DataTypes.STRING, - allowNull: true, - }, - tagline: { - type: DataTypes.STRING, - allowNull: true, - }, - budget: { - type: DataTypes.INTEGER, - allowNull: true, - }, - revenue: { - type: DataTypes.INTEGER, - allowNull: true, - }, - language: { - type: DataTypes.STRING, - allowNull: false, - }, - createdAt: { - type: DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: DataTypes.DATE, - allowNull: false, - }, - }, - { sequelize, indexes: [{ fields: ['imdbId'] }] }, - ) - }, - }) - - useSequelize({ - injector, - model: TmdbSeriesMetadata, - sequelizeModel: TmdbSeriesMetadataModel, - primaryKey: 'id', - options: dbOptions, - initModel: async (sequelize) => { - TmdbSeriesMetadataModel.init( - { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - allowNull: false, - }, - imdbId: { - type: DataTypes.STRING, - allowNull: true, - }, - name: { - type: DataTypes.STRING, - allowNull: false, - }, - originalName: { - type: DataTypes.STRING, - allowNull: false, - }, - overview: { - type: DataTypes.TEXT, - allowNull: false, - }, - firstAirDate: { - type: DataTypes.STRING, - allowNull: true, - }, - posterPath: { - type: DataTypes.STRING, - allowNull: true, - }, - backdropPath: { - type: DataTypes.STRING, - allowNull: true, - }, - genres: { - type: DataTypes.JSON, - allowNull: true, - }, - voteAverage: { - type: DataTypes.FLOAT, - allowNull: true, - }, - voteCount: { - type: DataTypes.INTEGER, - allowNull: true, - }, - numberOfSeasons: { - type: DataTypes.INTEGER, - allowNull: true, - }, - numberOfEpisodes: { - type: DataTypes.INTEGER, - allowNull: true, - }, - status: { - type: DataTypes.STRING, - allowNull: true, - }, - originalLanguage: { - type: DataTypes.STRING, - allowNull: false, - }, - languages: { - type: DataTypes.JSON, - allowNull: true, - }, - language: { - type: DataTypes.STRING, - allowNull: false, - }, - createdAt: { - type: DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: DataTypes.DATE, - allowNull: false, - }, - }, - { sequelize, indexes: [{ fields: ['imdbId'] }] }, - ) - }, - }) - - useSequelize({ - injector, - model: MovieMetadataLocalized, - sequelizeModel: MovieMetadataLocalizedModel, - primaryKey: 'id', - options: dbOptions, - initModel: async (sequelize) => { - MovieMetadataLocalizedModel.init( - { - id: { - type: DataTypes.UUIDV4, - primaryKey: true, - allowNull: false, - defaultValue: () => crypto.randomUUID(), - }, - movieImdbId: { - type: DataTypes.STRING, - allowNull: false, - }, - language: { - type: DataTypes.STRING, - allowNull: false, - }, - title: { - type: DataTypes.STRING, - allowNull: false, - }, - plot: { - type: DataTypes.TEXT, - allowNull: true, - }, - posterUrl: { - type: DataTypes.STRING, - allowNull: true, - }, - genre: { - type: DataTypes.JSON, - allowNull: true, - }, - source: { - type: DataTypes.STRING, - allowNull: false, - }, - sourceId: { - type: DataTypes.STRING, - allowNull: true, - }, - createdAt: { - type: DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: DataTypes.DATE, - allowNull: false, - }, - }, - { - sequelize, - indexes: [{ fields: ['movieImdbId'] }, { fields: ['movieImdbId', 'language', 'source'], unique: true }], - }, - ) - }, - }) - - useSequelize({ - injector, - model: SeriesMetadataLocalized, - sequelizeModel: SeriesMetadataLocalizedModel, - primaryKey: 'id', - options: dbOptions, - initModel: async (sequelize) => { - SeriesMetadataLocalizedModel.init( - { - id: { - type: DataTypes.UUIDV4, - primaryKey: true, - allowNull: false, - defaultValue: () => crypto.randomUUID(), - }, - seriesImdbId: { - type: DataTypes.STRING, - allowNull: false, - }, - language: { - type: DataTypes.STRING, - allowNull: false, - }, - title: { - type: DataTypes.STRING, - allowNull: false, - }, - plot: { - type: DataTypes.TEXT, - allowNull: true, - }, - posterUrl: { - type: DataTypes.STRING, - allowNull: true, - }, - source: { - type: DataTypes.STRING, - allowNull: false, - }, - sourceId: { - type: DataTypes.STRING, - allowNull: true, - }, - createdAt: { - type: DataTypes.DATE, - allowNull: false, - }, - updatedAt: { - type: DataTypes.DATE, - allowNull: false, - }, - }, - { - sequelize, - indexes: [{ fields: ['seriesImdbId'] }, { fields: ['seriesImdbId', 'language', 'source'], unique: true }], - }, - ) - }, - }) - - const repo = getRepository(injector) - - repo.createDataSet(Movie, 'imdbId', { - authorizeGet: authorizedOnly, - authorizeAdd: withRole('admin'), - authorizeUpdate: withRole('admin'), - authorizeRemove: withRole('admin'), - }) - - repo.createDataSet(MovieFile, 'id', { - authorizeGet: authorizedOnly, - authorizeAdd: withRole('admin'), - authorizeUpdate: withRole('admin'), - authorizeRemove: withRole('admin'), - }) - - const onlyOwned = async ({ - entity, - injector: i, - }: { - entity: WatchHistoryEntry - injector: Injector - }): Promise => { - const user = await getCurrentUser(i) - if (user.username === entity.userName) { - return { isAllowed: true } - } - - if (await isAuthorized(i, 'admin')) { - return { isAllowed: true } - } - return { - isAllowed: false, - message: 'You are not authorized to access this resource', - } - } - - repo.createDataSet(WatchHistoryEntry, 'id', { - authorizeGet: authorizedOnly, - authorizeAdd: authorizedOnly, - authorizeUpdate: authorizedOnly, - authorizeRemove: authorizedOnly, - authorizeGetEntity: onlyOwned, - addFilter: async ({ filter, injector: i }) => { - const user = await getCurrentUser(i) - return { - ...filter, - filter: { - ...filter.filter, - userName: { $eq: user.username }, - }, - } as typeof filter - }, - authorizeUpdateEntity: onlyOwned, - authorizeRemoveEntity: onlyOwned, - }) - - repo.createDataSet(Series, 'imdbId', { - authorizeGet: authorizedOnly, - authorizeAdd: withRole('admin'), - authorizeUpdate: withRole('admin'), - authorizeRemove: withRole('admin'), - }) - - repo.createDataSet(OmdbMovieMetadata, 'imdbID', { - authorizeGet: authorizedOnly, - authorizeAdd: withRole('admin'), - authorizeUpdate: withRole('admin'), - authorizeRemove: withRole('admin'), - }) - - repo.createDataSet(OmdbSeriesMetadata, 'imdbID', { - authorizeGet: authorizedOnly, - authorizeAdd: withRole('admin'), - authorizeUpdate: withRole('admin'), - authorizeRemove: withRole('admin'), - }) - - repo.createDataSet(TmdbMovieMetadata, 'id', { - authorizeGet: authorizedOnly, - authorizeAdd: withRole('admin'), - authorizeUpdate: withRole('admin'), - authorizeRemove: withRole('admin'), - }) - - repo.createDataSet(TmdbSeriesMetadata, 'id', { - authorizeGet: authorizedOnly, - authorizeAdd: withRole('admin'), - authorizeUpdate: withRole('admin'), - authorizeRemove: withRole('admin'), - }) - - repo.createDataSet(MovieMetadataLocalized, 'id', { - authorizeGet: authorizedOnly, - authorizeAdd: withRole('admin'), - authorizeUpdate: withRole('admin'), - authorizeRemove: withRole('admin'), - }) - - repo.createDataSet(SeriesMetadataLocalized, 'id', { - authorizeGet: authorizedOnly, - authorizeAdd: withRole('admin'), - authorizeUpdate: withRole('admin'), - authorizeRemove: withRole('admin'), - }) + setupMediaSchema(injector, logger) + setupMediaDataSets(injector) const omdbClientService = injector.getInstance(OmdbClientService) const tmdbClientService = injector.getInstance(TmdbClientService) diff --git a/service/src/app-models/media/utils/ensure-localized-metadata-exists.spec.ts b/service/src/app-models/media/utils/ensure-localized-metadata-exists.spec.ts new file mode 100644 index 00000000..6f12f6e4 --- /dev/null +++ b/service/src/app-models/media/utils/ensure-localized-metadata-exists.spec.ts @@ -0,0 +1,132 @@ +import { Injector } from '@furystack/inject' +import { usingAsync } from '@furystack/utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + ensureMovieLocalizedMetadataExists, + ensureSeriesLocalizedMetadataExists, +} from './ensure-localized-metadata-exists.js' + +const mockFind = vi.fn() +const mockAdd = vi.fn() + +vi.mock('@furystack/repository', () => ({ + getDataSetFor: () => ({ + find: (...args: unknown[]) => mockFind(...args) as unknown, + add: (...args: unknown[]) => mockAdd(...args) as unknown, + }), +})) + +describe('ensureMovieLocalizedMetadataExists', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const movieData = { + movieImdbId: 'tt1234567', + language: 'en', + title: 'Test Movie', + plot: 'A test plot.', + posterUrl: 'https://example.com/poster.jpg', + genre: ['Action'], + source: 'omdb' as const, + sourceId: 'tt1234567', + } + + it('should return existing record when found', async () => { + const existing = { ...movieData, id: 'existing-id' } + mockFind.mockResolvedValue([existing]) + + await usingAsync(new Injector(), async (injector) => { + const result = await ensureMovieLocalizedMetadataExists(movieData, injector) + + expect(mockFind).toHaveBeenCalledWith(injector, { + filter: { + movieImdbId: { $eq: 'tt1234567' }, + language: { $eq: 'en' }, + source: { $eq: 'omdb' }, + }, + top: 1, + }) + expect(mockAdd).not.toHaveBeenCalled() + expect(result).toBe(existing) + }) + }) + + it('should create new record when not found', async () => { + const newRecord = { ...movieData, id: 'new-id' } + mockFind.mockResolvedValue([]) + mockAdd.mockResolvedValue({ created: [newRecord] }) + + await usingAsync(new Injector(), async (injector) => { + const result = await ensureMovieLocalizedMetadataExists(movieData, injector) + + expect(mockAdd).toHaveBeenCalledWith( + injector, + expect.objectContaining({ + movieImdbId: 'tt1234567', + language: 'en', + source: 'omdb', + title: 'Test Movie', + }), + ) + expect(result).toBe(newRecord) + }) + }) +}) + +describe('ensureSeriesLocalizedMetadataExists', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const seriesData = { + seriesImdbId: 'tt9876543', + language: 'en', + title: 'Test Series', + plot: 'A test series.', + posterUrl: 'https://example.com/series-poster.jpg', + source: 'tmdb' as const, + sourceId: '67890', + } + + it('should return existing record when found', async () => { + const existing = { ...seriesData, id: 'existing-id' } + mockFind.mockResolvedValue([existing]) + + await usingAsync(new Injector(), async (injector) => { + const result = await ensureSeriesLocalizedMetadataExists(seriesData, injector) + + expect(mockFind).toHaveBeenCalledWith(injector, { + filter: { + seriesImdbId: { $eq: 'tt9876543' }, + language: { $eq: 'en' }, + source: { $eq: 'tmdb' }, + }, + top: 1, + }) + expect(mockAdd).not.toHaveBeenCalled() + expect(result).toBe(existing) + }) + }) + + it('should create new record when not found', async () => { + const newRecord = { ...seriesData, id: 'new-id' } + mockFind.mockResolvedValue([]) + mockAdd.mockResolvedValue({ created: [newRecord] }) + + await usingAsync(new Injector(), async (injector) => { + const result = await ensureSeriesLocalizedMetadataExists(seriesData, injector) + + expect(mockAdd).toHaveBeenCalledWith( + injector, + expect.objectContaining({ + seriesImdbId: 'tt9876543', + language: 'en', + source: 'tmdb', + title: 'Test Series', + }), + ) + expect(result).toBe(newRecord) + }) + }) +}) diff --git a/service/src/app-models/media/utils/ensure-tmdb-movie-exists.spec.ts b/service/src/app-models/media/utils/ensure-tmdb-movie-exists.spec.ts new file mode 100644 index 00000000..bde2660e --- /dev/null +++ b/service/src/app-models/media/utils/ensure-tmdb-movie-exists.spec.ts @@ -0,0 +1,110 @@ +import { Injector } from '@furystack/inject' +import { usingAsync } from '@furystack/utils' +import { describe, expect, it, vi } from 'vitest' +import type { TmdbMovieDetailsResponse } from '../metadata-services/tmdb-api-types.js' +import { ensureTmdbMovieExists } from './ensure-tmdb-movie-exists.js' + +const mockGet = vi.fn() +const mockAdd = vi.fn() + +vi.mock('@furystack/repository', () => ({ + getDataSetFor: () => ({ + get: (...args: unknown[]) => mockGet(...args) as unknown, + add: (...args: unknown[]) => mockAdd(...args) as unknown, + }), +})) + +const createTmdbMovie = (overrides: Partial = {}): TmdbMovieDetailsResponse => + ({ + id: 12345, + imdb_id: 'tt1234567', + title: 'Test Movie', + original_title: 'Test Movie Original', + overview: 'A test movie.', + release_date: '2024-06-15', + runtime: 120, + poster_path: '/poster.jpg', + backdrop_path: '/backdrop.jpg', + genres: [{ id: 28, name: 'Action' }], + vote_average: 7.5, + vote_count: 100, + popularity: 50.0, + original_language: 'en', + spoken_languages: [{ iso_639_1: 'en', name: 'English' }], + production_countries: [{ iso_3166_1: 'US', name: 'United States' }], + status: 'Released', + tagline: 'A tagline', + budget: 1000000, + revenue: 5000000, + ...overrides, + }) as TmdbMovieDetailsResponse + +describe('ensureTmdbMovieExists', () => { + it('should return existing record when found', async () => { + const existing = { id: 12345, title: 'Test Movie' } + mockGet.mockResolvedValue(existing) + + await usingAsync(new Injector(), async (injector) => { + const result = await ensureTmdbMovieExists(createTmdbMovie(), 'en', injector) + + expect(mockGet).toHaveBeenCalledWith(injector, 12345) + expect(mockAdd).not.toHaveBeenCalled() + expect(result).toBe(existing) + }) + }) + + it('should create new record when not found', async () => { + const newRecord = { id: 12345, title: 'Test Movie' } + mockGet.mockResolvedValue(null) + mockAdd.mockResolvedValue({ created: [newRecord] }) + + await usingAsync(new Injector(), async (injector) => { + const result = await ensureTmdbMovieExists(createTmdbMovie(), 'en', injector) + + expect(mockGet).toHaveBeenCalledWith(injector, 12345) + expect(mockAdd).toHaveBeenCalledWith( + injector, + expect.objectContaining({ + id: 12345, + imdbId: 'tt1234567', + title: 'Test Movie', + originalTitle: 'Test Movie Original', + language: 'en', + }), + ) + expect(result).toBe(newRecord) + }) + }) + + it('should handle null imdb_id', async () => { + mockGet.mockResolvedValue(null) + mockAdd.mockResolvedValue({ created: [{ id: 99999 }] }) + + await usingAsync(new Injector(), async (injector) => { + await ensureTmdbMovieExists(createTmdbMovie({ imdb_id: null }), 'en', injector) + + expect(mockAdd).toHaveBeenCalledWith( + injector, + expect.objectContaining({ + imdbId: undefined, + }), + ) + }) + }) + + it('should use provided language', async () => { + mockGet.mockResolvedValue(null) + mockAdd.mockResolvedValue({ created: [{ id: 12345 }] }) + + await usingAsync(new Injector(), async (injector) => { + await ensureTmdbMovieExists(createTmdbMovie(), 'fr', injector) + + expect(mockAdd).toHaveBeenCalledWith( + injector, + expect.objectContaining({ + language: 'fr', + }), + ) + }) + }) +}) diff --git a/service/src/app-models/media/utils/ensure-tmdb-series-exists.spec.ts b/service/src/app-models/media/utils/ensure-tmdb-series-exists.spec.ts new file mode 100644 index 00000000..b3816208 --- /dev/null +++ b/service/src/app-models/media/utils/ensure-tmdb-series-exists.spec.ts @@ -0,0 +1,118 @@ +import { Injector } from '@furystack/inject' +import { usingAsync } from '@furystack/utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TmdbTvDetailsResponse } from '../metadata-services/tmdb-api-types.js' +import { TmdbClientService } from '../metadata-services/tmdb-client-service.js' +import { ensureTmdbSeriesExists } from './ensure-tmdb-series-exists.js' + +const mockTmdbSeriesGet = vi.fn() +const mockTmdbSeriesAdd = vi.fn() +const mockFetchTmdbSeriesMetadata = vi.fn() + +vi.mock('@furystack/repository', () => ({ + getDataSetFor: () => ({ + get: (...args: unknown[]) => mockTmdbSeriesGet(...args) as unknown, + add: (...args: unknown[]) => mockTmdbSeriesAdd(...args) as unknown, + }), +})) + +vi.mock('@furystack/logging', () => ({ + getLogger: () => ({ + withScope: () => ({ + warning: vi.fn().mockResolvedValue(undefined), + }), + }), +})) + +vi.mock('./ensure-series-exists.js', () => ({ + ensureSeriesExists: vi.fn().mockResolvedValue({ imdbId: 'tt9876543' }), +})) + +vi.mock('./ensure-localized-metadata-exists.js', () => ({ + ensureSeriesLocalizedMetadataExists: vi.fn().mockResolvedValue({}), +})) + +vi.mock('./map-tmdb-to-localized.js', () => ({ + mapTmdbSeriesToLocalized: vi.fn().mockReturnValue({}), +})) + +const createTmdbSeries = (): TmdbTvDetailsResponse => + ({ + id: 67890, + name: 'Test Series', + original_name: 'Test Series', + overview: 'A test series.', + first_air_date: '2024-01-15', + poster_path: '/poster.jpg', + backdrop_path: null, + genres: [{ id: 18, name: 'Drama' }], + vote_average: 8.0, + vote_count: 200, + number_of_seasons: 3, + number_of_episodes: 30, + status: 'Returning Series', + original_language: 'en', + languages: ['en'], + external_ids: { imdb_id: 'tt9876543', facebook_id: null, instagram_id: null, twitter_id: null, wikidata_id: null }, + }) as TmdbTvDetailsResponse + +describe('ensureTmdbSeriesExists', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTmdbSeriesGet.mockResolvedValue(null) + mockTmdbSeriesAdd.mockResolvedValue({ created: [{ id: 67890 }] }) + }) + + it('should store provided series data without fetching', async () => { + await usingAsync(new Injector(), async (injector) => { + await ensureTmdbSeriesExists('tt9876543', createTmdbSeries(), 'en', injector) + + expect(mockTmdbSeriesAdd).toHaveBeenCalled() + expect(mockFetchTmdbSeriesMetadata).not.toHaveBeenCalled() + }) + }) + + it('should return early when TMDB series record already exists', async () => { + mockTmdbSeriesGet.mockResolvedValue({ id: 67890, name: 'Test Series' }) + + await usingAsync(new Injector(), async (injector) => { + await ensureTmdbSeriesExists('tt9876543', createTmdbSeries(), 'en', injector) + + expect(mockTmdbSeriesAdd).not.toHaveBeenCalled() + }) + }) + + it('should fetch from TMDB when no series data is provided', async () => { + mockFetchTmdbSeriesMetadata.mockResolvedValue({ + status: 'success', + data: createTmdbSeries(), + }) + + await usingAsync(new Injector(), async (injector) => { + injector.setExplicitInstance( + { fetchTmdbSeriesMetadata: mockFetchTmdbSeriesMetadata } as unknown as TmdbClientService, + TmdbClientService, + ) + + await ensureTmdbSeriesExists('tt9876543', undefined, 'en', injector) + + expect(mockFetchTmdbSeriesMetadata).toHaveBeenCalledWith({ imdbId: 'tt9876543' }, { file: undefined }) + expect(mockTmdbSeriesAdd).toHaveBeenCalled() + }) + }) + + it('should handle fetch failure gracefully', async () => { + mockFetchTmdbSeriesMetadata.mockResolvedValue({ status: 'not-found' }) + + await usingAsync(new Injector(), async (injector) => { + injector.setExplicitInstance( + { fetchTmdbSeriesMetadata: mockFetchTmdbSeriesMetadata } as unknown as TmdbClientService, + TmdbClientService, + ) + + await ensureTmdbSeriesExists('tt9876543', undefined, 'en', injector) + + expect(mockTmdbSeriesAdd).not.toHaveBeenCalled() + }) + }) +}) diff --git a/service/src/app-models/media/utils/link-movie.spec.ts b/service/src/app-models/media/utils/link-movie.spec.ts index 8da2e703..8561e0ac 100644 --- a/service/src/app-models/media/utils/link-movie.spec.ts +++ b/service/src/app-models/media/utils/link-movie.spec.ts @@ -410,4 +410,182 @@ describe('linkMovie', () => { }) }) }) + + describe('fetching new TMDB data', () => { + it('should link via TMDB when OMDB is not configured', async () => { + mockMovieFileStoreFind.mockResolvedValue([]) + mockOmdbStoreFind.mockResolvedValue([]) + mockFetchOmdbMovieMetadata.mockResolvedValue({ status: 'not-configured' }) + mockFetchTmdbMovieMetadata.mockResolvedValue({ + status: 'success', + data: { + movie: { + imdb_id: 'tt5551234', + title: 'TMDB Movie', + release_date: '2024-06-15', + runtime: 120, + }, + }, + }) + mockMovieFileStoreAdd.mockResolvedValue({ + created: [{ id: 'new-file-id' }], + }) + + await usingAsync(createTestInjector(), async (injector) => { + injector.setExplicitInstance( + { + fetchTmdbMovieMetadata: mockFetchTmdbMovieMetadata, + config: { id: 'TMDB_CONFIG', value: { apiKey: 'key', defaultLanguage: 'en-US' } }, + } as unknown as TmdbClientService, + TmdbClientService, + ) + + const result = await linkMovie({ + injector, + file: createFile('movies/TMDB.Movie.2024.mkv'), + }) + + expect(result.status).toBe('linked') + expect(mockFetchTmdbMovieMetadata).toHaveBeenCalled() + }) + }) + + it('should link via TMDB with episode/series data', async () => { + mockMovieFileStoreFind.mockResolvedValue([]) + mockOmdbStoreFind.mockResolvedValue([]) + mockFetchOmdbMovieMetadata.mockResolvedValue({ status: 'not-configured' }) + mockFetchTmdbMovieMetadata.mockResolvedValue({ + status: 'success', + data: { + movie: { + imdb_id: 'tt5551234', + title: 'Episode Title', + release_date: '2024-03-01', + runtime: 45, + }, + episode: { + season_number: 2, + episode_number: 5, + }, + series: { + name: 'Test Series', + external_ids: { imdb_id: 'tt9876543' }, + }, + }, + }) + mockMovieFileStoreAdd.mockResolvedValue({ + created: [{ id: 'new-file-id' }], + }) + + await usingAsync(createTestInjector(), async (injector) => { + injector.setExplicitInstance( + { + fetchTmdbMovieMetadata: mockFetchTmdbMovieMetadata, + config: { id: 'TMDB_CONFIG', value: { apiKey: 'key', defaultLanguage: 'en-US' } }, + } as unknown as TmdbClientService, + TmdbClientService, + ) + + const result = await linkMovie({ + injector, + file: createFile('movies/Test.Series.S02E05.mkv'), + }) + + expect(result.status).toBe('linked') + }) + }) + + it('should return rate-limited when TMDB is rate-limited', async () => { + mockMovieFileStoreFind.mockResolvedValue([]) + mockOmdbStoreFind.mockResolvedValue([]) + mockFetchOmdbMovieMetadata.mockResolvedValue({ status: 'not-found' }) + mockFetchTmdbMovieMetadata.mockResolvedValue({ status: 'rate-limited' }) + + await usingAsync(createTestInjector(), async (injector) => { + const result = await linkMovie({ + injector, + file: createFile('movies/Rate.Limited.Movie.2024.mkv'), + }) + + expect(result.status).toBe('rate-limited') + }) + }) + + it('should fall back to TMDB when OMDB returns not-found', async () => { + mockMovieFileStoreFind.mockResolvedValue([]) + mockOmdbStoreFind.mockResolvedValue([]) + mockFetchOmdbMovieMetadata.mockResolvedValue({ status: 'not-found' }) + mockFetchTmdbMovieMetadata.mockResolvedValue({ + status: 'success', + data: { + movie: { + imdb_id: 'tt7771234', + title: 'Fallback Movie', + release_date: '2024-01-01', + runtime: 90, + }, + }, + }) + mockMovieFileStoreAdd.mockResolvedValue({ + created: [{ id: 'new-file-id' }], + }) + + await usingAsync(createTestInjector(), async (injector) => { + injector.setExplicitInstance( + { + fetchTmdbMovieMetadata: mockFetchTmdbMovieMetadata, + config: { id: 'TMDB_CONFIG', value: { apiKey: 'key', defaultLanguage: 'en-US' } }, + } as unknown as TmdbClientService, + TmdbClientService, + ) + + const result = await linkMovie({ + injector, + file: createFile('movies/Fallback.Movie.2024.mkv'), + }) + + expect(result.status).toBe('linked') + expect(mockFetchOmdbMovieMetadata).toHaveBeenCalled() + expect(mockFetchTmdbMovieMetadata).toHaveBeenCalled() + }) + }) + + it('should fall back to TMDB when OMDB returns error', async () => { + mockMovieFileStoreFind.mockResolvedValue([]) + mockOmdbStoreFind.mockResolvedValue([]) + mockFetchOmdbMovieMetadata.mockResolvedValue({ status: 'error', error: new Error('OMDB failure') }) + mockFetchTmdbMovieMetadata.mockResolvedValue({ + status: 'success', + data: { + movie: { + imdb_id: 'tt1112222', + title: 'Error Fallback Movie', + release_date: '2024-05-01', + runtime: 100, + }, + }, + }) + mockMovieFileStoreAdd.mockResolvedValue({ + created: [{ id: 'new-file-id' }], + }) + + await usingAsync(createTestInjector(), async (injector) => { + injector.setExplicitInstance( + { + fetchTmdbMovieMetadata: mockFetchTmdbMovieMetadata, + config: { id: 'TMDB_CONFIG', value: { apiKey: 'key', defaultLanguage: 'en-US' } }, + } as unknown as TmdbClientService, + TmdbClientService, + ) + + const result = await linkMovie({ + injector, + file: createFile('movies/Error.Fallback.Movie.2024.mkv'), + }) + + expect(result.status).toBe('linked') + expect(mockFetchTmdbMovieMetadata).toHaveBeenCalled() + }) + }) + }) }) diff --git a/service/src/app-models/media/utils/map-omdb-to-localized.spec.ts b/service/src/app-models/media/utils/map-omdb-to-localized.spec.ts new file mode 100644 index 00000000..e83e2831 --- /dev/null +++ b/service/src/app-models/media/utils/map-omdb-to-localized.spec.ts @@ -0,0 +1,75 @@ +import type { OmdbMovieMetadata, OmdbSeriesMetadata } from 'common' +import { describe, expect, it } from 'vitest' +import { mapOmdbMovieToLocalized, mapOmdbSeriesToLocalized } from './map-omdb-to-localized.js' + +describe('mapOmdbMovieToLocalized', () => { + const createOmdbMovie = (overrides: Partial = {}): OmdbMovieMetadata => + ({ + imdbID: 'tt1234567', + Title: 'Test Movie', + Plot: 'A great movie about testing.', + Poster: 'https://example.com/poster.jpg', + Genre: 'Action, Drama', + ...overrides, + }) as OmdbMovieMetadata + + it('should map all fields correctly', () => { + const result = mapOmdbMovieToLocalized(createOmdbMovie()) + + expect(result).toEqual({ + movieImdbId: 'tt1234567', + language: 'en', + title: 'Test Movie', + plot: 'A great movie about testing.', + posterUrl: 'https://example.com/poster.jpg', + genre: ['Action', 'Drama'], + source: 'omdb', + sourceId: 'tt1234567', + }) + }) + + it('should set posterUrl to undefined when Poster is N/A', () => { + const result = mapOmdbMovieToLocalized(createOmdbMovie({ Poster: 'N/A' })) + expect(result.posterUrl).toBeUndefined() + }) + + it('should set genre to undefined when Genre is empty', () => { + const result = mapOmdbMovieToLocalized(createOmdbMovie({ Genre: '' })) + expect(result.genre).toBeUndefined() + }) + + it('should handle single genre', () => { + const result = mapOmdbMovieToLocalized(createOmdbMovie({ Genre: 'Comedy' })) + expect(result.genre).toEqual(['Comedy']) + }) +}) + +describe('mapOmdbSeriesToLocalized', () => { + const createOmdbSeries = (overrides: Partial = {}): OmdbSeriesMetadata => + ({ + imdbID: 'tt9876543', + Title: 'Test Series', + Plot: 'A great series about testing.', + Poster: 'https://example.com/series-poster.jpg', + ...overrides, + }) as OmdbSeriesMetadata + + it('should map all fields correctly', () => { + const result = mapOmdbSeriesToLocalized(createOmdbSeries()) + + expect(result).toEqual({ + seriesImdbId: 'tt9876543', + language: 'en', + title: 'Test Series', + plot: 'A great series about testing.', + posterUrl: 'https://example.com/series-poster.jpg', + source: 'omdb', + sourceId: 'tt9876543', + }) + }) + + it('should set posterUrl to undefined when Poster is N/A', () => { + const result = mapOmdbSeriesToLocalized(createOmdbSeries({ Poster: 'N/A' })) + expect(result.posterUrl).toBeUndefined() + }) +}) diff --git a/service/src/app-models/media/utils/map-tmdb-to-localized.spec.ts b/service/src/app-models/media/utils/map-tmdb-to-localized.spec.ts new file mode 100644 index 00000000..cb18f43d --- /dev/null +++ b/service/src/app-models/media/utils/map-tmdb-to-localized.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import type { TmdbMovieDetailsResponse, TmdbTvDetailsResponse } from '../metadata-services/tmdb-api-types.js' +import { mapTmdbMovieToLocalized, mapTmdbSeriesToLocalized } from './map-tmdb-to-localized.js' + +describe('mapTmdbMovieToLocalized', () => { + const createTmdbMovie = (): TmdbMovieDetailsResponse => + ({ + id: 12345, + imdb_id: 'tt1234567', + title: 'Test Movie', + overview: 'A test movie overview.', + poster_path: '/poster.jpg', + genres: [ + { id: 28, name: 'Action' }, + { id: 18, name: 'Drama' }, + ], + }) as TmdbMovieDetailsResponse + + it('should map all fields correctly', () => { + const result = mapTmdbMovieToLocalized(createTmdbMovie(), 'en') + + expect(result).toEqual({ + movieImdbId: 'tt1234567', + language: 'en', + title: 'Test Movie', + plot: 'A test movie overview.', + posterUrl: 'https://image.tmdb.org/t/p/w500/poster.jpg', + genre: ['Action', 'Drama'], + source: 'tmdb', + sourceId: '12345', + }) + }) + + it('should set posterUrl to undefined when poster_path is null', () => { + const movie = createTmdbMovie() + movie.poster_path = null + const result = mapTmdbMovieToLocalized(movie, 'en') + expect(result.posterUrl).toBeUndefined() + }) + + it('should set plot to undefined when overview is empty', () => { + const movie = createTmdbMovie() + movie.overview = '' + const result = mapTmdbMovieToLocalized(movie, 'en') + expect(result.plot).toBeUndefined() + }) + + it('should use provided language', () => { + const result = mapTmdbMovieToLocalized(createTmdbMovie(), 'fr') + expect(result.language).toBe('fr') + }) +}) + +describe('mapTmdbSeriesToLocalized', () => { + const createTmdbSeries = (): TmdbTvDetailsResponse => + ({ + id: 67890, + name: 'Test Series', + overview: 'A test series overview.', + poster_path: '/series-poster.jpg', + }) as TmdbTvDetailsResponse + + it('should map all fields correctly', () => { + const result = mapTmdbSeriesToLocalized(createTmdbSeries(), 'tt9876543', 'en') + + expect(result).toEqual({ + seriesImdbId: 'tt9876543', + language: 'en', + title: 'Test Series', + plot: 'A test series overview.', + posterUrl: 'https://image.tmdb.org/t/p/w500/series-poster.jpg', + source: 'tmdb', + sourceId: '67890', + }) + }) + + it('should set posterUrl to undefined when poster_path is null', () => { + const series = createTmdbSeries() + series.poster_path = null + const result = mapTmdbSeriesToLocalized(series, 'tt9876543', 'en') + expect(result.posterUrl).toBeUndefined() + }) + + it('should set plot to undefined when overview is empty', () => { + const series = createTmdbSeries() + series.overview = '' + const result = mapTmdbSeriesToLocalized(series, 'tt9876543', 'en') + expect(result.plot).toBeUndefined() + }) +}) From 31720ed15ff4109dd584293225976956e60156fd Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Wed, 11 Mar 2026 15:39:26 +0100 Subject: [PATCH 15/30] code review fixes --- .cursor/rules/BACKEND_PATTERNS.mdc | 33 +++++++++ .cursor/rules/TYPESCRIPT_GUIDELINES.mdc | 24 +++++++ .cursor/rules/rules-index.mdc | 2 + common/src/utils/media/hls-constants.ts | 1 + common/src/utils/media/index.ts | 1 + .../movie-player-v2/movie-player-service.ts | 49 ++++++------- frontend/src/services/session.spec.ts | 4 -- frontend/src/services/session.ts | 4 +- .../media/services/transcoding-session.ts | 13 ++-- .../src/app-models/media/utils/link-movie.ts | 69 +++++++++++++------ 10 files changed, 139 insertions(+), 61 deletions(-) create mode 100644 common/src/utils/media/hls-constants.ts diff --git a/.cursor/rules/BACKEND_PATTERNS.mdc b/.cursor/rules/BACKEND_PATTERNS.mdc index 775b2829..1fd0c7f2 100644 --- a/.cursor/rules/BACKEND_PATTERNS.mdc +++ b/.cursor/rules/BACKEND_PATTERNS.mdc @@ -91,6 +91,39 @@ service/src/app-models/[module]/ - **Clean up**: Remove partial state on failures - **Log appropriately**: Use scoped loggers with meaningful context +### Provider / Adapter Fallback Chains + +When building fallback chains where multiple providers are tried in priority order: + +- **Use a narrow internal result type** for provider helpers, not the top-level result type +- Providers should only return what they can produce (e.g., an `imdbId`), not dummy values for fields they don't have +- The orchestrator function creates the full result from the provider's narrow output + +```typescript +// ✅ Good - narrow type for internal helpers +type ProviderResult = + | { status: 'skip' } + | { status: 'rate-limited' } + | { status: 'linked'; imdbId: string } + +const tryProvider = async (...): Promise => { + // ... + return { status: 'linked', imdbId: result.imdbID } +} + +// The orchestrator builds the full result +for (const provider of priority) { + const result = await providers[provider](injector, params) + if (result.status === 'linked') { + linkedImdbId = result.imdbId + break + } +} + +// ❌ Bad - reusing the top-level type forces dummy values +return { status: 'linked', movieFile: undefined as unknown as MovieFile, movie } +``` + ### Service Layer ```typescript diff --git a/.cursor/rules/TYPESCRIPT_GUIDELINES.mdc b/.cursor/rules/TYPESCRIPT_GUIDELINES.mdc index 86801dd6..f3e97c09 100644 --- a/.cursor/rules/TYPESCRIPT_GUIDELINES.mdc +++ b/.cursor/rules/TYPESCRIPT_GUIDELINES.mdc @@ -551,6 +551,30 @@ const value = 'hello' as string; const value = unknownValue as User; // Prefer type guard ``` +### Double-Cast Anti-Pattern (`as unknown as T`) + +**NEVER** use `undefined as unknown as T` or `null as unknown as T` to satisfy a type contract. +This hides a design flaw — if a function doesn't have the value, the return type should reflect that. + +```typescript +// ❌ FORBIDDEN - double-cast to satisfy type +return { status: 'linked', movieFile: undefined as unknown as MovieFile, movie }; + +// ✅ Good - use a narrower return type that doesn't require the value +type ProviderResult = + | { status: 'skip' } + | { status: 'rate-limited' } + | { status: 'linked'; imdbId: string }; + +return { status: 'linked', imdbId: added.imdbID }; + +// ✅ Good - if the caller needs different shapes, use discriminated unions +type InternalResult = { status: 'linked'; imdbId: string }; +type FullResult = { status: 'linked'; movieFile: MovieFile; movie: Movie }; +``` + +If you encounter `as unknown as T`, refactor the types so the cast is unnecessary. + ### Non-Null Assertion Operator - Avoid using `!` operator when possible diff --git a/.cursor/rules/rules-index.mdc b/.cursor/rules/rules-index.mdc index 6874da3d..23e8681d 100644 --- a/.cursor/rules/rules-index.mdc +++ b/.cursor/rules/rules-index.mdc @@ -12,3 +12,5 @@ This file contains a list of helpful information and context that the agent can - [REST action Validate() wrappers and explicit param checking before system calls](./REST_ACTION_VALIDATION.mdc) - [Singleton concurrency guards, getOrCreate race prevention, fire-and-forget init, dispose-before-reinit, and safe Map iteration](./SINGLETON_CONCURRENCY.md) - [MFE runtime type boundaries, duplicate type contracts, and cross-package type sync](./MFE_TYPE_CONTRACTS.md) +- [Double-cast anti-pattern (`as unknown as T`) and non-null assertion avoidance](./TYPESCRIPT_GUIDELINES.mdc) +- [Provider/adapter fallback chain patterns with narrow internal result types](./BACKEND_PATTERNS.mdc) diff --git a/common/src/utils/media/hls-constants.ts b/common/src/utils/media/hls-constants.ts new file mode 100644 index 00000000..4e0d47e7 --- /dev/null +++ b/common/src/utils/media/hls-constants.ts @@ -0,0 +1 @@ +export const HLS_SEGMENT_DURATION = 6 diff --git a/common/src/utils/media/index.ts b/common/src/utils/media/index.ts index cb4ad0f8..fbe6f97e 100644 --- a/common/src/utils/media/index.ts +++ b/common/src/utils/media/index.ts @@ -1,3 +1,4 @@ +export * from './hls-constants.js' export * from './is-movie-file.js' export * from './is-sample-file.js' export * from './get-fallback-metadata.js' diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts b/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts index 47526e7d..23675d79 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts @@ -2,6 +2,7 @@ import type { ScopedLogger } from '@furystack/logging' import { ObservableValue } from '@furystack/utils' import { encode, + HLS_SEGMENT_DURATION, type AudioTrackInfo, type FfprobeData, type PiRatFile, @@ -60,8 +61,6 @@ const buildCodecSupportMap = () => { export type ResolutionValue = '4k' | '1080p' | '720p' | '480p' | '360p' -const SEGMENT_DURATION = 6 - export class MoviePlayerService implements AsyncDisposable { constructor( private readonly file: PiRatFile, @@ -71,7 +70,7 @@ export class MoviePlayerService implements AsyncDisposable { private readonly logger: ScopedLogger, ) { this.progress = new ObservableValue(this.currentProgress) - this.hlsStartTime = Math.floor(this.currentProgress / SEGMENT_DURATION) * SEGMENT_DURATION + this.hlsStartTime = Math.floor(this.currentProgress / HLS_SEGMENT_DURATION) * HLS_SEGMENT_DURATION void this.initialize() } @@ -79,6 +78,7 @@ export class MoviePlayerService implements AsyncDisposable { private hls: Hls | null = null private originalPlaybackMode: PlaybackMode = 'transcode' private isSwitching = false + private seekGeneration = 0 private hlsStartTime = 0 public videoElement: HTMLVideoElement | null = null public audioTrackId = new ObservableValue(0) @@ -302,20 +302,14 @@ export class MoviePlayerService implements AsyncDisposable { this.audioTrackId.setValue(trackIndex) this.currentProgress = previousProgress this.progress.setValue(previousProgress) - this.hlsStartTime = Math.floor(previousProgress / SEGMENT_DURATION) * SEGMENT_DURATION + this.hlsStartTime = Math.floor(previousProgress / HLS_SEGMENT_DURATION) * HLS_SEGMENT_DURATION await this.fetchPlaybackInfo() const info = this.playbackInfo.getValue() if (this.videoElement && info) { this.startPlayback(this.videoElement, info) - const video = this.videoElement - const onCanPlay = () => { - video.removeEventListener('canplay', onCanPlay) - this.isSwitching = false - void video.play().catch(() => {}) - } - video.addEventListener('canplay', onCanPlay) + this.waitForCanPlay(this.videoElement) } else { this.isSwitching = false } @@ -344,20 +338,14 @@ export class MoviePlayerService implements AsyncDisposable { this.resolution.setValue(value) this.currentProgress = previousProgress this.progress.setValue(previousProgress) - this.hlsStartTime = Math.floor(previousProgress / SEGMENT_DURATION) * SEGMENT_DURATION + this.hlsStartTime = Math.floor(previousProgress / HLS_SEGMENT_DURATION) * HLS_SEGMENT_DURATION this.playbackMode.setValue(targetMode) if (this.videoElement) { const info = this.playbackInfo.getValue() if (info) { this.startPlayback(this.videoElement, info) - const video = this.videoElement - const onCanPlay = () => { - video.removeEventListener('canplay', onCanPlay) - this.isSwitching = false - void video.play().catch(() => {}) - } - video.addEventListener('canplay', onCanPlay) + this.waitForCanPlay(this.videoElement) } else { this.isSwitching = false } @@ -380,7 +368,7 @@ export class MoviePlayerService implements AsyncDisposable { if (this.isTimeBuffered(video, targetSeconds)) return - const quantizedStart = Math.floor(targetSeconds / SEGMENT_DURATION) * SEGMENT_DURATION + const quantizedStart = Math.floor(targetSeconds / HLS_SEGMENT_DURATION) * HLS_SEGMENT_DURATION if (quantizedStart === this.hlsStartTime) return void this.restartHlsAtTime(targetSeconds, quantizedStart) @@ -398,8 +386,12 @@ export class MoviePlayerService implements AsyncDisposable { private async restartHlsAtTime(targetSeconds: number, quantizedStart: number) { this.isSwitching = true + const generation = ++this.seekGeneration await this.teardownHlsSession() + + if (generation !== this.seekGeneration) return + if (this.hls) { this.hls.destroy() this.hls = null @@ -411,18 +403,21 @@ export class MoviePlayerService implements AsyncDisposable { if (this.videoElement) { void this.startHlsPlayback(this.videoElement) - const video = this.videoElement - const onCanPlay = () => { - video.removeEventListener('canplay', onCanPlay) - this.isSwitching = false - void video.play().catch(() => {}) - } - video.addEventListener('canplay', onCanPlay) + this.waitForCanPlay(this.videoElement) } else { this.isSwitching = false } } + private waitForCanPlay(video: HTMLVideoElement) { + const onCanPlay = () => { + video.removeEventListener('canplay', onCanPlay) + this.isSwitching = false + void video.play().catch(() => {}) + } + video.addEventListener('canplay', onCanPlay) + } + public getIsSwitching(): boolean { return this.isSwitching } diff --git a/frontend/src/services/session.spec.ts b/frontend/src/services/session.spec.ts index f15f4c07..7829e51f 100644 --- a/frontend/src/services/session.spec.ts +++ b/frontend/src/services/session.spec.ts @@ -215,7 +215,6 @@ describe('SessionService', () => { it('should still clear state when logout API call fails', async () => { const mockCall = vi.fn().mockRejectedValue(new Error('Network error')) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { injector, mockNotyService } = createTestInjector(mockCall) @@ -230,10 +229,7 @@ describe('SessionService', () => { expect(service.currentUser.getValue()).toBeNull() expect(service.state.getValue()).toBe('unauthenticated') expect(mockNotyService.emit).toHaveBeenCalledWith('onNotyAdded', expect.objectContaining({ type: 'info' })) - expect(warnSpy).toHaveBeenCalledWith('Logout API call failed:', expect.any(Error)) }) - - warnSpy.mockRestore() }) }) diff --git a/frontend/src/services/session.ts b/frontend/src/services/session.ts index 818cf06b..ed30b4e7 100644 --- a/frontend/src/services/session.ts +++ b/frontend/src/services/session.ts @@ -114,8 +114,8 @@ export class SessionService implements IdentityContext, Disposable { return await usingAsync(this.operation(), async () => { try { await this.api.call({ method: 'POST', action: '/logout' }) - } catch (error) { - console.warn('Logout API call failed:', error) + } catch { + // Logout failure is non-critical — session is cleared client-side regardless } if (this.isDisposed) return this.currentUser.setValue(null) diff --git a/service/src/app-models/media/services/transcoding-session.ts b/service/src/app-models/media/services/transcoding-session.ts index 741cba27..16462edd 100644 --- a/service/src/app-models/media/services/transcoding-session.ts +++ b/service/src/app-models/media/services/transcoding-session.ts @@ -3,7 +3,7 @@ import { Injectable, Injected, type Injector } from '@furystack/inject' import { getLogger, type ScopedLogger } from '@furystack/logging' import { getDataSetFor } from '@furystack/repository' import type { PlaybackMode } from 'common' -import { Config, Drive, type MoviesConfig } from 'common' +import { Config, Drive, HLS_SEGMENT_DURATION, type MoviesConfig } from 'common' import { spawn, type ChildProcess } from 'child_process' import { createHash } from 'crypto' import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'fs' @@ -34,7 +34,6 @@ type TranscodingSessionEntry = { } const SESSION_IDLE_TIMEOUT_MS = 5 * 60 * 1000 -const SEGMENT_DURATION = 6 const WAIT_POLL_INTERVAL_MS = 100 const WAIT_TIMEOUT_MS = 60_000 const DEFAULT_MAX_CACHE_SIZE_MB = 5000 @@ -326,14 +325,14 @@ export class TranscodingSessionService { return `${playlist.trimEnd()}\n#EXT-X-ENDLIST\n` } - const fullSegmentCount = Math.floor(remainingDuration / SEGMENT_DURATION) - const lastSegmentDuration = remainingDuration - fullSegmentCount * SEGMENT_DURATION + const fullSegmentCount = Math.floor(remainingDuration / HLS_SEGMENT_DURATION) + const lastSegmentDuration = remainingDuration - fullSegmentCount * HLS_SEGMENT_DURATION const padLines: string[] = [] let nextIndex = maxSegmentIndex + 1 for (let i = 0; i < fullSegmentCount; i++) { - padLines.push(`#EXTINF:${SEGMENT_DURATION.toFixed(6)},`) + padLines.push(`#EXTINF:${HLS_SEGMENT_DURATION.toFixed(6)},`) padLines.push(`segment${nextIndex}.m4s`) nextIndex++ } @@ -442,7 +441,7 @@ export class TranscodingSessionService { args.push('-c:v', videoCodec) // Force keyframes at segment boundaries (offset by startTime when -copyts preserves original PTS) - args.push('-force_key_frames', `expr:gte(t,n_forced*${SEGMENT_DURATION}+${startTime})`) + args.push('-force_key_frames', `expr:gte(t,n_forced*${HLS_SEGMENT_DURATION}+${startTime})`) args.push('-sc_threshold:v', '0') const isSoftwareEncoder = videoCodec === 'libx264' || videoCodec === 'libx265' @@ -466,7 +465,7 @@ export class TranscodingSessionService { // HLS muxer output (the core change from per-segment to continuous) args.push('-f', 'hls') - args.push('-hls_time', String(SEGMENT_DURATION)) + args.push('-hls_time', String(HLS_SEGMENT_DURATION)) args.push('-hls_segment_type', 'fmp4') args.push('-hls_fmp4_init_filename', 'init.mp4') args.push('-hls_segment_filename', join(sessionDir, 'segment%d.m4s')) diff --git a/service/src/app-models/media/utils/link-movie.ts b/service/src/app-models/media/utils/link-movie.ts index c064afdb..1bda6bd4 100644 --- a/service/src/app-models/media/utils/link-movie.ts +++ b/service/src/app-models/media/utils/link-movie.ts @@ -29,15 +29,7 @@ import { ensureMovieLocalizedMetadataExists } from './ensure-localized-metadata- import { mapOmdbMovieToLocalized } from './map-omdb-to-localized.js' import { mapTmdbMovieToLocalized } from './map-tmdb-to-localized.js' -type LinkResult = - | { status: 'already-linked' } - | { status: 'linked'; movieFile: MovieFile; movie: unknown } - | { status: 'failed' } - | { status: 'not-movie-file' } - | { status: 'rate-limited' } - | { status: 'metadata-not-found' } - | { status: 'provider-not-configured' } - | { status: 'provider-error'; error?: unknown } +type ProviderResult = { status: 'skip' } | { status: 'rate-limited' } | { status: 'linked'; imdbId: string } const getProviderPriority = async (injector: Injector): Promise> => { try { @@ -56,7 +48,7 @@ const tryOmdbProvider = async ( injector: Injector, { title, year, season, episode }: { title: string; year?: number; season?: number; episode?: number }, context?: { file?: PiRatFile }, -): Promise => { +): Promise => { const omdbClientService = injector.getInstance(OmdbClientService) const result = await omdbClientService.fetchOmdbMovieMetadata({ title, year, season, episode }, context) @@ -66,7 +58,7 @@ const tryOmdbProvider = async ( if (result.status === 'error') return { status: 'skip' } const added = await ensureOmdbMovieExists(result.data, injector) - const movie = await ensureMovieExists( + await ensureMovieExists( { imdbId: added.imdbID, year: parseInt(added.Year, 10), @@ -81,14 +73,14 @@ const tryOmdbProvider = async ( await ensureMovieLocalizedMetadataExists(mapOmdbMovieToLocalized(added), injector) await ensureOmdbSeriesExists(added, injector, context) - return { status: 'linked', movieFile: undefined as unknown as MovieFile, movie } + return { status: 'linked', imdbId: added.imdbID } } const tryTmdbProvider = async ( injector: Injector, { title, year, season, episode }: { title: string; year?: number; season?: number; episode?: number }, context?: { file?: PiRatFile }, -): Promise => { +): Promise => { const tmdbClientService = injector.getInstance(TmdbClientService) const result = await tmdbClientService.fetchTmdbMovieMetadata({ title, year, season, episode }, context) @@ -98,12 +90,14 @@ const tryTmdbProvider = async ( if (result.status === 'error') return { status: 'skip' } const { movie: tmdbMovie, series: tmdbSeries } = result.data - const imdbId = tmdbMovie.imdb_id! + const imdbId = tmdbMovie.imdb_id + if (!imdbId) return { status: 'skip' } + const language = tmdbClientService.config?.value.defaultLanguage?.slice(0, 2) ?? 'en' await ensureTmdbMovieExists(tmdbMovie, language, injector) - const movie = await ensureMovieExists( + await ensureMovieExists( { imdbId, year: tmdbMovie.release_date ? parseInt(tmdbMovie.release_date.slice(0, 4), 10) : undefined, @@ -124,7 +118,32 @@ const tryTmdbProvider = async ( } } - return { status: 'linked', movieFile: undefined as unknown as MovieFile, movie } + return { status: 'linked', imdbId } +} + +/** + * Tries each configured provider to populate localized metadata for a movie + * already linked via a direct IMDB ID (ffprobe tags or .nfo file). + */ +const enrichMetadataFromProviders = async ( + injector: Injector, + params: { title: string; year?: number; season?: number; episode?: number }, + context?: { file?: PiRatFile }, +) => { + const priority = await getProviderPriority(injector) + const providers: Record = { + omdb: tryOmdbProvider, + tmdb: tryTmdbProvider, + } + + for (const provider of priority) { + const tryProvider = providers[provider] + if (!tryProvider) continue + + const result = await tryProvider(injector, params, context) + if (result.status === 'linked') return + if (result.status === 'rate-limited') return + } } export const linkMovie = async (options: { injector: Injector; file: PiRatFile }) => { @@ -218,6 +237,14 @@ export const linkMovie = async (options: { injector: Injector; file: PiRatFile } data: { file, movieFile: newMovieFile, movie, source: tagImdbId ? 'ffprobe-tags' : 'nfo-file' }, }) + // Fire-and-forget: enrich localized metadata via providers + void enrichMetadataFromProviders(injector, { title, year, season, episode }, { file }).catch((error) => { + void logger.warning({ + message: `Failed to enrich metadata for '${fileName}' after direct-ID link`, + data: { error }, + }) + }) + return { status: 'linked', movieFile: newMovieFile, movie } as const } @@ -281,7 +308,7 @@ export const linkMovie = async (options: { injector: Injector; file: PiRatFile } tmdb: tryTmdbProvider, } - let imdbId: string | undefined + let linkedImdbId: string | undefined for (const provider of priority) { const tryProvider = providers[provider] if (!tryProvider) continue @@ -297,12 +324,12 @@ export const linkMovie = async (options: { injector: Injector; file: PiRatFile } return { status: 'rate-limited' } as const } if (result.status === 'linked') { - imdbId = (result.movie as { imdbId?: string })?.imdbId + linkedImdbId = result.imdbId break } } - if (!imdbId) { + if (!linkedImdbId) { await logger.debug({ message: `No metadata found for '${fileName}' from any provider.`, data: { file, title, year }, @@ -315,7 +342,7 @@ export const linkMovie = async (options: { injector: Injector; file: PiRatFile } } = await movieFileDataSet.add(injector, { driveLetter, path, - imdbId, + imdbId: linkedImdbId, ffprobe: ffprobeResult, }) @@ -324,5 +351,5 @@ export const linkMovie = async (options: { injector: Injector; file: PiRatFile } data: { file, movieFile: newMovieFile }, }) - return { status: 'linked', movieFile: newMovieFile, movie: { imdbId } } as const + return { status: 'linked', movieFile: newMovieFile, movie: { imdbId: linkedImdbId } } as const } From d4e0e3471d47fdc36f61f667b4a736e7f97c81ec Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Wed, 11 Mar 2026 16:07:57 +0100 Subject: [PATCH 16/30] code review fixes, version bumps, changelogs --- .yarn/changelogs/common.90cc3afc.md | 34 ++++++ .yarn/changelogs/frontend.90cc3afc.md | 44 +++++++ .yarn/changelogs/pi-rat.90cc3afc.md | 11 ++ .yarn/changelogs/service.90cc3afc.md | 68 +++++++++++ .yarn/versions/90cc3afc.yml | 5 + .../movie-player-v2/movie-player-service.ts | 32 ++--- .../movie-player-v2-component.tsx | 2 +- .../hls-session-teardown-action.spec.ts | 4 +- .../media/announce-movie-file-added.spec.ts | 112 ++++++++++++++++++ .../media/utils/ensure-series-exists.spec.ts | 91 ++++++++++++++ .../src/app-models/media/utils/link-movie.ts | 57 +++++---- .../media/utils/map-tmdb-to-localized.spec.ts | 13 +- .../media/utils/map-tmdb-to-localized.ts | 3 +- 13 files changed, 425 insertions(+), 51 deletions(-) create mode 100644 .yarn/changelogs/common.90cc3afc.md create mode 100644 .yarn/changelogs/frontend.90cc3afc.md create mode 100644 .yarn/changelogs/pi-rat.90cc3afc.md create mode 100644 .yarn/changelogs/service.90cc3afc.md create mode 100644 .yarn/versions/90cc3afc.yml create mode 100644 service/src/app-models/media/announce-movie-file-added.spec.ts create mode 100644 service/src/app-models/media/utils/ensure-series-exists.spec.ts diff --git a/.yarn/changelogs/common.90cc3afc.md b/.yarn/changelogs/common.90cc3afc.md new file mode 100644 index 00000000..a82ab9cb --- /dev/null +++ b/.yarn/changelogs/common.90cc3afc.md @@ -0,0 +1,34 @@ + + +# common + +## ✨ Features + +### TMDB Integration Models + +- Added `TmdbMovieMetadata` model with fields for TMDB movie details including genres, vote data, production info, and multi-language support +- Added `TmdbSeriesMetadata` model with fields for TMDB series details including season/episode counts and language info +- Added `TmdbConfig` configuration type for TMDB API key, default language, and additional language preferences +- Added `MetadataProviderConfig` type to define an ordered priority list of metadata providers (`omdb` | `tmdb`) + +### Localized Metadata Models + +- Added `MovieMetadataLocalized` model for per-language movie metadata (title, plot, poster, genre) with source tracking (`omdb` | `tmdb`) +- Added `SeriesMetadataLocalized` model for per-language series metadata with source tracking + +### Media API Endpoints + +- Added REST endpoints for `TmdbMovieMetadata`, `TmdbSeriesMetadata`, `MovieMetadataLocalized`, and `SeriesMetadataLocalized` entity browsing +- Added `audioTrack` and `startTime` query parameters to HLS master, stream, segment, init, and teardown endpoints to support mid-stream seeking and audio track selection + +### Other + +- Added `HLS_SEGMENT_DURATION` constant (6 seconds) shared between frontend and service +- Extended `isMovieFile()` to recognize `.mp4` and `.mov` extensions +- Added `tmdb` field to `ServiceStatusResponse` for TMDB API availability checks + +## ♻️ Refactoring + +- Moved language-dependent fields (`title`, `plot`, `genre`, `thumbnailImageUrl`) from `Movie` and `Series` models into the new localized metadata models +- Renamed `omdb-not-configured` / `omdb-error` link statuses to `provider-not-configured` / `provider-error` to reflect multi-provider support +- Renamed `omdbNotConfigured` / `omdbError` scan progress fields to `providerNotConfigured` / `providerError` diff --git a/.yarn/changelogs/frontend.90cc3afc.md b/.yarn/changelogs/frontend.90cc3afc.md new file mode 100644 index 00000000..18645534 --- /dev/null +++ b/.yarn/changelogs/frontend.90cc3afc.md @@ -0,0 +1,44 @@ + + +# frontend + +## ✨ Features + +### TMDB Settings Page + +Added admin settings page at `/settings/tmdb` for configuring TMDB API credentials, default language, and additional languages for metadata fetching. + +### Localized Metadata Service + +Added `LocalizedMetadataService` with caches for fetching `MovieMetadataLocalized` and `SeriesMetadataLocalized` by IMDB ID and language. Movie and series overview pages now display localized titles, plots, posters, and genres from this service. + +### Movie Player Seeking and Quality Switching + +- Added server-side seek support via `startTime` parameter — the player requests a new HLS session starting at the desired position when seeking outside the buffered range +- Added `switchResolution()` for changing playback quality mid-stream with optional full session reload +- Added `isSwitching` guard to avoid progress updates during resolution/audio/seek transitions + +### Entity Browser Pages + +- Added entity browser pages for `TmdbMovieMetadata` and `TmdbSeriesMetadata` + +## 🐛 Bug Fixes + +- Fixed movie duration not displaying correctly in the player controls by passing `defaultDuration` to `media-controller` +- Fixed legacy navigation issues by relocating route utilities to `utils/` directory + +## ♻️ Refactoring + +- Extracted file context menu items into a pure `getContextMenuItems()` function, replacing the Shade-based `FileContextMenu` component +- Extracted file drag-and-drop upload logic into `handleFileDrop()` utility +- Extracted `SessionUserUnavailableError`, `getUser()`, and `hasRole()` into `utils/session-helpers.ts` for reusable session access +- Relocated `environment-options.ts`, `navigate-to-route.ts`, `trigger-download.ts`, and `theme-switch-cheat.tsx` into `utils/` directory +- Simplified `SessionService.currentUser` to store the full `User` object instead of a partial pick + +## 🧪 Tests + +- Added tests for `LocalizedMetadataService` cache behavior +- Added tests for `TmdbSettings` page form validation and config persistence +- Added tests for `session-helpers` utilities +- Updated `MoviePlayerService` tests for seeking, resolution switching, and start-time support +- Updated `MoviePlayerV2Component` tests for the new seeking and resolution switching behavior diff --git a/.yarn/changelogs/pi-rat.90cc3afc.md b/.yarn/changelogs/pi-rat.90cc3afc.md new file mode 100644 index 00000000..f7cac6d8 --- /dev/null +++ b/.yarn/changelogs/pi-rat.90cc3afc.md @@ -0,0 +1,11 @@ + + +# pi-rat + +## 📦 Build + +- Updated ESLint configuration with refined ignore globs for generated schemas and build artifacts + +## ⬆️ Dependencies + +- Updated FuryStack framework dependencies diff --git a/.yarn/changelogs/service.90cc3afc.md b/.yarn/changelogs/service.90cc3afc.md new file mode 100644 index 00000000..208fda2a --- /dev/null +++ b/.yarn/changelogs/service.90cc3afc.md @@ -0,0 +1,68 @@ + + +# service + +## ✨ Features + +### TMDB Client Service + +Added `TmdbClientService` with support for searching and fetching movie/series details from the TMDB API, including multi-language metadata retrieval based on the configured `TmdbConfig` languages. + +### Configurable Metadata Provider Chain + +The movie-linking pipeline now supports a configurable provider priority (`omdb`, `tmdb`) via `MetadataProviderConfig`. Providers are tried in order; the first one to return a result wins. + +### IMDB ID Extraction from Files + +- Added `extractImdbIdFromNfoFiles()` — scans `.nfo` files in the parent directory for IMDB IDs (e.g. `tt1234567`), enabling direct metadata lookup without a search API call +- Added `extractImdbIdFromFfprobeTags()` — extracts IMDB IDs from ffprobe format tags (`imdb_id`, `imdb-id`, `imdb`, etc.) + +### Localized Metadata Storage + +- Added `ensureMovieLocalizedMetadataExists()` and `ensureSeriesLocalizedMetadataExists()` for upserting per-language metadata records +- Added `mapOmdbMovieToLocalized()` / `mapOmdbSeriesToLocalized()` to convert OMDB metadata into localized format (English only) +- Added `mapTmdbMovieToLocalized()` / `mapTmdbSeriesToLocalized()` to convert TMDB responses into localized format for any language + +### HLS Start-Time Seeking + +- Transcoding sessions now accept a `startTime` parameter, using FFmpeg input seeking (`-ss` before `-i`) to start transcoding from an arbitrary position +- Added `padPlaylistToFullDuration()` to pad HLS playlists for correct total duration when segments don't cover the full file + +### Other + +- Added `removeAllSessionsForFile()` to tear down every active transcoding session for a given file path +- Added 4K (3840x2160) resolution variant to the HLS manifest generator +- Added `TmdbMovieMetadata`, `TmdbSeriesMetadata`, `MovieMetadataLocalized`, and `SeriesMetadataLocalized` data sets and API endpoints +- Added TMDB status check to the service status endpoint + +## 🐛 Bug Fixes + +- Fixed HLS session teardown to remove all sessions for a file instead of requiring exact mode/resolution/audioTrack match + +## ♻️ Refactoring + +### `setup-media.ts` Split + +Extracted the monolithic `setup-media.ts` into focused modules: + +- `media-sequelize-models.ts` — Sequelize model definitions for all media entities +- `media-data-sets.ts` — Repository data set registration +- `media-schema-setup.ts` — Database schema setup and sync +- `announce-movie-file-added.ts` — WebSocket notification when movie files are added + +### Link-Movie Provider Architecture + +Refactored `link-movie.ts` from a single OMDB-only flow into a provider-based architecture with `tryOmdbProvider()` and `tryTmdbProvider()` functions, plus an `enrichMetadataFromProviders()` step that fetches additional localized metadata after initial linking. + +## 🧪 Tests + +- Added tests for `TmdbClientService` covering search, detail fetching, multi-language support, and error/rate-limit handling +- Added tests for `extractImdbIdFromNfoFiles()` and `extractImdbIdFromFfprobeTags()` +- Added tests for `ensureMovieLocalizedMetadataExists()` and `ensureSeriesLocalizedMetadataExists()` +- Added tests for `mapOmdbMovieToLocalized()` and `mapTmdbMovieToLocalized()` / `mapTmdbSeriesToLocalized()` +- Added tests for `ensureTmdbMovieExists()` and `ensureTmdbSeriesExists()` +- Added tests for `HlsManifestGenerator` including 4K variant and `startTime` / `audioTrack` pass-through +- Added tests for `HlsStreamAction` start-time parameter handling +- Updated `TranscodingSession` tests for start-time seeking and playlist padding +- Updated `link-movie` tests for provider chain, NFO extraction, and TMDB fallback +- Updated `HlsSessionTeardownAction` tests for `removeAllSessionsForFile()` behavior diff --git a/.yarn/versions/90cc3afc.yml b/.yarn/versions/90cc3afc.yml new file mode 100644 index 00000000..ac090136 --- /dev/null +++ b/.yarn/versions/90cc3afc.yml @@ -0,0 +1,5 @@ +releases: + common: patch + frontend: patch + pi-rat: patch + service: patch diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts b/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts index 23675d79..c4bf802f 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts @@ -77,7 +77,7 @@ export class MoviePlayerService implements AsyncDisposable { private hls: Hls | null = null private originalPlaybackMode: PlaybackMode = 'transcode' - private isSwitching = false + public isSwitching = new ObservableValue(false) private seekGeneration = 0 private hlsStartTime = 0 public videoElement: HTMLVideoElement | null = null @@ -95,6 +95,7 @@ export class MoviePlayerService implements AsyncDisposable { this.playbackInfo[Symbol.dispose]() this.playbackMode[Symbol.dispose]() this.audioTrackId[Symbol.dispose]() + this.isSwitching[Symbol.dispose]() if (this.hls) { this.hls.destroy() this.hls = null @@ -113,12 +114,7 @@ export class MoviePlayerService implements AsyncDisposable { letter: this.file.driveLetter, path: this.file.path, }, - query: { - mode, - audioTrack: this.audioTrackId.getValue(), - resolution: this.resolution.getValue(), - startTime: this.hlsStartTime || undefined, - }, + query: {}, }) } catch (error) { void this.logger.warning({ message: 'Failed to tear down HLS session', data: { error } }) @@ -296,7 +292,7 @@ export class MoviePlayerService implements AsyncDisposable { public async switchAudioTrack(trackIndex: number) { const previousProgress = this.videoElement?.currentTime ?? this.progress.getValue() - this.isSwitching = true + this.isSwitching.setValue(true) await this.teardownHlsSession() this.audioTrackId.setValue(trackIndex) @@ -311,7 +307,7 @@ export class MoviePlayerService implements AsyncDisposable { this.startPlayback(this.videoElement, info) this.waitForCanPlay(this.videoElement) } else { - this.isSwitching = false + this.isSwitching.setValue(false) } } @@ -334,7 +330,7 @@ export class MoviePlayerService implements AsyncDisposable { const previousProgress = this.videoElement?.currentTime ?? this.progress.getValue() await this.teardownHlsSession() - this.isSwitching = true + this.isSwitching.setValue(true) this.resolution.setValue(value) this.currentProgress = previousProgress this.progress.setValue(previousProgress) @@ -347,10 +343,10 @@ export class MoviePlayerService implements AsyncDisposable { this.startPlayback(this.videoElement, info) this.waitForCanPlay(this.videoElement) } else { - this.isSwitching = false + this.isSwitching.setValue(false) } } else { - this.isSwitching = false + this.isSwitching.setValue(false) } } @@ -361,7 +357,7 @@ export class MoviePlayerService implements AsyncDisposable { */ public seekToTime(targetSeconds: number) { const mode = this.playbackMode.getValue() - if (mode === 'direct-play' || this.isSwitching) return + if (mode === 'direct-play' || this.isSwitching.getValue()) return const video = this.videoElement if (!video) return @@ -385,7 +381,7 @@ export class MoviePlayerService implements AsyncDisposable { } private async restartHlsAtTime(targetSeconds: number, quantizedStart: number) { - this.isSwitching = true + this.isSwitching.setValue(true) const generation = ++this.seekGeneration await this.teardownHlsSession() @@ -405,23 +401,19 @@ export class MoviePlayerService implements AsyncDisposable { void this.startHlsPlayback(this.videoElement) this.waitForCanPlay(this.videoElement) } else { - this.isSwitching = false + this.isSwitching.setValue(false) } } private waitForCanPlay(video: HTMLVideoElement) { const onCanPlay = () => { video.removeEventListener('canplay', onCanPlay) - this.isSwitching = false + this.isSwitching.setValue(false) void video.play().catch(() => {}) } video.addEventListener('canplay', onCanPlay) } - public getIsSwitching(): boolean { - return this.isSwitching - } - public getAudioTrackInfoFromPlaybackInfo(): AudioTrackInfo[] { return this.playbackInfo.getValue()?.audioTracks ?? [] } diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx index 104fbb82..84dc3abf 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx @@ -236,7 +236,7 @@ export const MoviePlayerV2 = Shade({ mediaService.seekToTime(currentTime) }} ontimeupdate={(ev) => { - if (mediaService.getIsSwitching()) return + if (mediaService.isSwitching.getValue()) return const { currentTime } = ev.currentTarget as HTMLVideoElement mediaService.progress.setValue(currentTime || 0) }} diff --git a/service/src/app-models/media/actions/hls-session-teardown-action.spec.ts b/service/src/app-models/media/actions/hls-session-teardown-action.spec.ts index 795d8aef..c197ba62 100644 --- a/service/src/app-models/media/actions/hls-session-teardown-action.spec.ts +++ b/service/src/app-models/media/actions/hls-session-teardown-action.spec.ts @@ -21,7 +21,7 @@ describe('HlsSessionTeardownAction', () => { await HlsSessionTeardownAction({ injector, getUrlParams: () => ({ letter: 'A', path: '../etc/passwd' }), - getQuery: () => ({ mode: 'transcode' }), + getQuery: () => ({}), response: {} as ServerResponse, request: {} as IncomingMessage, }) @@ -44,7 +44,7 @@ describe('HlsSessionTeardownAction', () => { const result = await HlsSessionTeardownAction({ injector, getUrlParams: () => ({ letter: 'A', path: 'test.mkv' }), - getQuery: () => ({ mode: 'transcode', audioTrack: 1, resolution: '720p' }), + getQuery: () => ({}), response: {} as ServerResponse, request: {} as IncomingMessage, }) diff --git a/service/src/app-models/media/announce-movie-file-added.spec.ts b/service/src/app-models/media/announce-movie-file-added.spec.ts new file mode 100644 index 00000000..9ebd45bc --- /dev/null +++ b/service/src/app-models/media/announce-movie-file-added.spec.ts @@ -0,0 +1,112 @@ +import { Injector } from '@furystack/inject' +import { usingAsync } from '@furystack/utils' +import type { Movie, MovieFile } from 'common' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { WebsocketService } from '../../websocket-service.js' +import { announceMovieFileAdded } from './announce-movie-file-added.js' + +vi.mock('@furystack/core', () => ({ + isAuthorized: vi.fn().mockResolvedValue(true), +})) + +describe('announceMovieFileAdded', () => { + const mockAnnounce = vi.fn().mockResolvedValue(undefined) + const mockGet = vi.fn() + const mockLogger = { + error: vi.fn().mockResolvedValue(undefined), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + const createEntity = (overrides?: Partial): MovieFile => + ({ + id: 'file-1', + driveLetter: 'A', + path: 'movies/test.mkv', + imdbId: 'tt1234567', + ffprobe: {}, + ...overrides, + }) as MovieFile + + const createMovieDataSet = () => ({ + get: (...args: unknown[]) => mockGet(...args) as unknown, + }) + + it('should return early when entity has no imdbId', async () => { + await usingAsync(new Injector(), async (injector) => { + await announceMovieFileAdded({ + entity: createEntity({ imdbId: undefined }), + injector, + movieDataSet: createMovieDataSet() as never, + logger: mockLogger as never, + }) + + expect(mockGet).not.toHaveBeenCalled() + expect(mockAnnounce).not.toHaveBeenCalled() + }) + }) + + it('should announce via websocket when movie exists', async () => { + const movie: Movie = { imdbId: 'tt1234567', createdAt: '', updatedAt: '' } + mockGet.mockResolvedValue(movie) + + await usingAsync(new Injector(), async (injector) => { + injector.setExplicitInstance({ announce: mockAnnounce } as unknown as WebsocketService, WebsocketService) + + await announceMovieFileAdded({ + entity: createEntity(), + injector, + movieDataSet: createMovieDataSet() as never, + logger: mockLogger as never, + }) + + expect(mockGet).toHaveBeenCalledWith(injector, 'tt1234567') + expect(mockAnnounce).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'add-movie', + file: { driveLetter: 'A', path: 'movies/test.mkv' }, + movie, + }), + expect.any(Function), + ) + }) + }) + + it('should not announce when movie is not found', async () => { + mockGet.mockResolvedValue(null) + + await usingAsync(new Injector(), async (injector) => { + injector.setExplicitInstance({ announce: mockAnnounce } as unknown as WebsocketService, WebsocketService) + + await announceMovieFileAdded({ + entity: createEntity(), + injector, + movieDataSet: createMovieDataSet() as never, + logger: mockLogger as never, + }) + + expect(mockAnnounce).not.toHaveBeenCalled() + }) + }) + + it('should log error when movie lookup fails', async () => { + mockGet.mockRejectedValue(new Error('DB error')) + + await usingAsync(new Injector(), async (injector) => { + await announceMovieFileAdded({ + entity: createEntity(), + injector, + movieDataSet: createMovieDataSet() as never, + logger: mockLogger as never, + }) + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Failed to announce'), + }), + ) + }) + }) +}) diff --git a/service/src/app-models/media/utils/ensure-series-exists.spec.ts b/service/src/app-models/media/utils/ensure-series-exists.spec.ts new file mode 100644 index 00000000..99e6108a --- /dev/null +++ b/service/src/app-models/media/utils/ensure-series-exists.spec.ts @@ -0,0 +1,91 @@ +import { Injector } from '@furystack/inject' +import { usingAsync } from '@furystack/utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ensureSeriesExists } from './ensure-series-exists.js' + +const mockGet = vi.fn() +const mockAdd = vi.fn() + +vi.mock('@furystack/repository', () => ({ + getDataSetFor: () => ({ + get: (...args: unknown[]) => mockGet(...args) as unknown, + add: (...args: unknown[]) => mockAdd(...args) as unknown, + }), +})) + +describe('ensureSeriesExists', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const seriesInput = { + imdbId: 'tt1234567', + year: '2020', + numberOfSeasons: 5, + } + + it('should not create a new series when one already exists', async () => { + const existing = { imdbId: 'tt1234567', year: '2020' } + mockGet.mockResolvedValue(existing) + + await usingAsync(new Injector(), async (injector) => { + await ensureSeriesExists(seriesInput, injector) + + expect(mockGet).toHaveBeenCalledWith(injector, 'tt1234567') + expect(mockAdd).not.toHaveBeenCalled() + }) + }) + + it('should create a new series when not found', async () => { + mockGet.mockResolvedValue(null) + mockAdd.mockResolvedValue({ created: [{ ...seriesInput }] }) + + await usingAsync(new Injector(), async (injector) => { + await ensureSeriesExists(seriesInput, injector) + + expect(mockAdd).toHaveBeenCalledWith( + injector, + expect.objectContaining({ + imdbId: 'tt1234567', + year: '2020', + numberOfSeasons: 5, + }), + ) + }) + }) + + it('should set timestamps on creation', async () => { + mockGet.mockResolvedValue(null) + mockAdd.mockResolvedValue({ created: [{}] }) + + await usingAsync(new Injector(), async (injector) => { + await ensureSeriesExists(seriesInput, injector) + + const addCall = mockAdd.mock.calls[0][1] as Record + expect(addCall.createdAt).toBeDefined() + expect(addCall.updatedAt).toBeDefined() + expect(typeof addCall.createdAt).toBe('string') + expect(typeof addCall.updatedAt).toBe('string') + }) + }) + + it('should create a series without numberOfSeasons', async () => { + mockGet.mockResolvedValue(null) + mockAdd.mockResolvedValue({ created: [{}] }) + + const input = { imdbId: 'tt9999999', year: '2015' } + + await usingAsync(new Injector(), async (injector) => { + await ensureSeriesExists(input, injector) + + expect(mockAdd).toHaveBeenCalledWith( + injector, + expect.objectContaining({ + imdbId: 'tt9999999', + year: '2015', + numberOfSeasons: undefined, + }), + ) + }) + }) +}) diff --git a/service/src/app-models/media/utils/link-movie.ts b/service/src/app-models/media/utils/link-movie.ts index 1bda6bd4..a37409e7 100644 --- a/service/src/app-models/media/utils/link-movie.ts +++ b/service/src/app-models/media/utils/link-movie.ts @@ -12,6 +12,7 @@ import { MovieFile, OmdbMovieMetadata, type MetadataProviderConfig, + type Movie, type PiRatFile, } from 'common' import { FfprobeService } from '../../../ffprobe-service.js' @@ -29,7 +30,20 @@ import { ensureMovieLocalizedMetadataExists } from './ensure-localized-metadata- import { mapOmdbMovieToLocalized } from './map-omdb-to-localized.js' import { mapTmdbMovieToLocalized } from './map-tmdb-to-localized.js' -type ProviderResult = { status: 'skip' } | { status: 'rate-limited' } | { status: 'linked'; imdbId: string } +type ProviderResult = + | { status: 'skip' } + | { status: 'rate-limited' } + | { status: 'linked'; imdbId: string; movie: Movie } + +/** + * Normalizes an IETF locale (e.g. 'en-US') to a storage-friendly language code. + * Preserves region for Chinese (zh-CN vs zh-TW) where it distinguishes scripts. + */ +const normalizeLanguage = (locale: string): string => { + const parts = locale.split('-') + if (parts[0] === 'zh' && parts[1]) return `${parts[0]}-${parts[1]}` + return parts[0] +} const getProviderPriority = async (injector: Injector): Promise> => { try { @@ -58,7 +72,7 @@ const tryOmdbProvider = async ( if (result.status === 'error') return { status: 'skip' } const added = await ensureOmdbMovieExists(result.data, injector) - await ensureMovieExists( + const movie = await ensureMovieExists( { imdbId: added.imdbID, year: parseInt(added.Year, 10), @@ -73,7 +87,7 @@ const tryOmdbProvider = async ( await ensureMovieLocalizedMetadataExists(mapOmdbMovieToLocalized(added), injector) await ensureOmdbSeriesExists(added, injector, context) - return { status: 'linked', imdbId: added.imdbID } + return { status: 'linked', imdbId: added.imdbID, movie } } const tryTmdbProvider = async ( @@ -93,11 +107,11 @@ const tryTmdbProvider = async ( const imdbId = tmdbMovie.imdb_id if (!imdbId) return { status: 'skip' } - const language = tmdbClientService.config?.value.defaultLanguage?.slice(0, 2) ?? 'en' + const language = normalizeLanguage(tmdbClientService.config?.value.defaultLanguage ?? 'en-US') await ensureTmdbMovieExists(tmdbMovie, language, injector) - await ensureMovieExists( + const movie = await ensureMovieExists( { imdbId, year: tmdbMovie.release_date ? parseInt(tmdbMovie.release_date.slice(0, 4), 10) : undefined, @@ -109,7 +123,7 @@ const tryTmdbProvider = async ( }, injector, ) - await ensureMovieLocalizedMetadataExists(mapTmdbMovieToLocalized(tmdbMovie, language), injector) + await ensureMovieLocalizedMetadataExists(mapTmdbMovieToLocalized(tmdbMovie, imdbId, language), injector) if (tmdbSeries) { const seriesImdbId = tmdbSeries.external_ids?.imdb_id @@ -118,7 +132,12 @@ const tryTmdbProvider = async ( } } - return { status: 'linked', imdbId } + return { status: 'linked', imdbId, movie } +} + +const metadataProviders: Record = { + omdb: tryOmdbProvider, + tmdb: tryTmdbProvider, } /** @@ -131,13 +150,9 @@ const enrichMetadataFromProviders = async ( context?: { file?: PiRatFile }, ) => { const priority = await getProviderPriority(injector) - const providers: Record = { - omdb: tryOmdbProvider, - tmdb: tryTmdbProvider, - } for (const provider of priority) { - const tryProvider = providers[provider] + const tryProvider = metadataProviders[provider] if (!tryProvider) continue const result = await tryProvider(injector, params, context) @@ -303,14 +318,10 @@ export const linkMovie = async (options: { injector: Injector; file: PiRatFile } // Try providers in priority order const priority = await getProviderPriority(injector) - const providers: Record = { - omdb: tryOmdbProvider, - tmdb: tryTmdbProvider, - } - let linkedImdbId: string | undefined + let linkedResult: { imdbId: string; movie: Movie } | undefined for (const provider of priority) { - const tryProvider = providers[provider] + const tryProvider = metadataProviders[provider] if (!tryProvider) continue const result = await tryProvider(injector, { title, year, season, episode }, { file }) @@ -324,12 +335,12 @@ export const linkMovie = async (options: { injector: Injector; file: PiRatFile } return { status: 'rate-limited' } as const } if (result.status === 'linked') { - linkedImdbId = result.imdbId + linkedResult = result break } } - if (!linkedImdbId) { + if (!linkedResult) { await logger.debug({ message: `No metadata found for '${fileName}' from any provider.`, data: { file, title, year }, @@ -342,14 +353,14 @@ export const linkMovie = async (options: { injector: Injector; file: PiRatFile } } = await movieFileDataSet.add(injector, { driveLetter, path, - imdbId: linkedImdbId, + imdbId: linkedResult.imdbId, ffprobe: ffprobeResult, }) await logger.debug({ message: `File ${fileName} linked successfully.`, - data: { file, movieFile: newMovieFile }, + data: { file, movieFile: newMovieFile, movie: linkedResult.movie }, }) - return { status: 'linked', movieFile: newMovieFile, movie: { imdbId: linkedImdbId } } as const + return { status: 'linked', movieFile: newMovieFile, movie: linkedResult.movie } as const } diff --git a/service/src/app-models/media/utils/map-tmdb-to-localized.spec.ts b/service/src/app-models/media/utils/map-tmdb-to-localized.spec.ts index cb18f43d..0a1e5756 100644 --- a/service/src/app-models/media/utils/map-tmdb-to-localized.spec.ts +++ b/service/src/app-models/media/utils/map-tmdb-to-localized.spec.ts @@ -17,7 +17,7 @@ describe('mapTmdbMovieToLocalized', () => { }) as TmdbMovieDetailsResponse it('should map all fields correctly', () => { - const result = mapTmdbMovieToLocalized(createTmdbMovie(), 'en') + const result = mapTmdbMovieToLocalized(createTmdbMovie(), 'tt1234567', 'en') expect(result).toEqual({ movieImdbId: 'tt1234567', @@ -34,21 +34,26 @@ describe('mapTmdbMovieToLocalized', () => { it('should set posterUrl to undefined when poster_path is null', () => { const movie = createTmdbMovie() movie.poster_path = null - const result = mapTmdbMovieToLocalized(movie, 'en') + const result = mapTmdbMovieToLocalized(movie, 'tt1234567', 'en') expect(result.posterUrl).toBeUndefined() }) it('should set plot to undefined when overview is empty', () => { const movie = createTmdbMovie() movie.overview = '' - const result = mapTmdbMovieToLocalized(movie, 'en') + const result = mapTmdbMovieToLocalized(movie, 'tt1234567', 'en') expect(result.plot).toBeUndefined() }) it('should use provided language', () => { - const result = mapTmdbMovieToLocalized(createTmdbMovie(), 'fr') + const result = mapTmdbMovieToLocalized(createTmdbMovie(), 'tt1234567', 'fr') expect(result.language).toBe('fr') }) + + it('should use the explicitly provided imdbId', () => { + const result = mapTmdbMovieToLocalized(createTmdbMovie(), 'tt0000001', 'en') + expect(result.movieImdbId).toBe('tt0000001') + }) }) describe('mapTmdbSeriesToLocalized', () => { diff --git a/service/src/app-models/media/utils/map-tmdb-to-localized.ts b/service/src/app-models/media/utils/map-tmdb-to-localized.ts index fe596de5..af25959a 100644 --- a/service/src/app-models/media/utils/map-tmdb-to-localized.ts +++ b/service/src/app-models/media/utils/map-tmdb-to-localized.ts @@ -5,9 +5,10 @@ import { buildTmdbImageUrl } from '../metadata-services/tmdb-client-service.js' export const mapTmdbMovieToLocalized = ( tmdbMovie: TmdbMovieDetailsResponse, + imdbId: string, language: string, ): Omit => ({ - movieImdbId: tmdbMovie.imdb_id!, + movieImdbId: imdbId, language, title: tmdbMovie.title, plot: tmdbMovie.overview || undefined, From 78bff720d045c8389552b6542310d1342e197dd1 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Wed, 11 Mar 2026 17:08:11 +0100 Subject: [PATCH 17/30] playback fixes --- .../movie-player-v2/movie-player-service.ts | 96 ++++++++++++------- .../movie-player-v2-component.tsx | 4 +- .../services/transcoding-session.spec.ts | 4 +- .../media/services/transcoding-session.ts | 15 ++- 4 files changed, 75 insertions(+), 44 deletions(-) diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts b/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts index c4bf802f..9315afd1 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts @@ -10,7 +10,7 @@ import { type PlaybackMode, type SubtitleTrackInfo, } from 'common' -import type Hls from 'hls.js' +import Hls from 'hls.js' import type { MediaApiClient } from '../../../services/api-clients/media-api-client.js' import { environmentOptions } from '../../../utils/environment-options.js' @@ -29,11 +29,6 @@ export const audioCodecs = { dts: 'dts+', } -const loadHls = async () => { - const mod = await import('hls.js') - return mod.default -} - const buildCodecSupportMap = () => { const supportedVideo: string[] = [] const supportedAudio: string[] = [] @@ -87,7 +82,29 @@ export class MoviePlayerService implements AsyncDisposable { public resolution = new ObservableValue(undefined) public progress: ObservableValue + /** + * Converts a 0-based stream time (from video.currentTime during HLS) + * to the absolute file position. For direct-play, the value is returned + * unchanged because the video element already uses absolute timestamps. + */ + public streamTimeToAbsolute(streamTime: number): number { + const mode = this.playbackMode.getValue() + if (mode === 'direct-play') return streamTime + return streamTime + this.hlsStartTime + } + + private getAbsoluteProgress(): number { + const video = this.videoElement + if (!video) return this.progress.getValue() + return this.streamTimeToAbsolute(video.currentTime) + } + public async [Symbol.asyncDispose]() { + if (this.seekDebounceTimer) { + clearTimeout(this.seekDebounceTimer) + this.seekDebounceTimer = null + } + await this.teardownHlsSession() this.progress[Symbol.dispose]() @@ -187,7 +204,7 @@ export class MoviePlayerService implements AsyncDisposable { if (mode === 'direct-play') { this.startDirectPlayback(videoElement, info) } else { - void this.startHlsPlayback(videoElement) + this.startHlsPlayback(videoElement) } } @@ -204,7 +221,7 @@ export class MoviePlayerService implements AsyncDisposable { } } - private async startHlsPlayback(videoElement: HTMLVideoElement) { + private startHlsPlayback(videoElement: HTMLVideoElement) { const mode = this.playbackMode.getValue() const audioTrack = this.audioTrackId.getValue() const audioParam = audioTrack ? `&audioTrack=${encode(String(audioTrack))}` : '' @@ -213,12 +230,10 @@ export class MoviePlayerService implements AsyncDisposable { `/api/media/files/${encodeURIComponent(this.file.driveLetter)}/${encodeURIComponent(this.file.path)}/master.m3u8?mode=${encode(mode)}${audioParam}${startTimeParam}`, ) - const HlsModule = await loadHls() - // Prefer hls.js over native HLS — many browsers (including Chromium) report // canPlayType('application/vnd.apple.mpegurl') as 'maybe' without full support. // hls.js also handles missing alternative renditions more gracefully. - if (!HlsModule.isSupported()) { + if (!Hls.isSupported()) { if (videoElement.canPlayType('application/vnd.apple.mpegurl')) { void this.logger.verbose({ message: 'Using native HLS playback' }) videoElement.src = hlsUrl @@ -233,23 +248,24 @@ export class MoviePlayerService implements AsyncDisposable { void this.logger.verbose({ message: 'Starting HLS playback via hls.js' }) - this.hls = new HlsModule({ + this.hls = new Hls({ xhrSetup: (xhr) => { xhr.withCredentials = true }, - startPosition: this.currentProgress > 0 ? this.currentProgress : -1, + + startPosition: this.currentProgress > this.hlsStartTime ? this.currentProgress - this.hlsStartTime : -1, }) - this.hls.on(HlsModule.Events.ERROR, (_event, data) => { + this.hls.on(Hls.Events.ERROR, (_event, data) => { if (data.fatal) { void this.logger.error({ message: `HLS fatal error: ${data.type}`, data: { details: data.details }, }) - if (data.type === HlsModule.ErrorTypes.NETWORK_ERROR) { + if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { this.hls?.startLoad() - } else if (data.type === HlsModule.ErrorTypes.MEDIA_ERROR) { + } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { this.hls?.recoverMediaError() } } @@ -257,7 +273,7 @@ export class MoviePlayerService implements AsyncDisposable { const { hls } = this - hls.on(HlsModule.Events.MANIFEST_PARSED, () => { + hls.on(Hls.Events.MANIFEST_PARSED, () => { void this.logger.verbose({ message: 'HLS manifest parsed' }) const resolutionHeightMap: Record = { @@ -279,7 +295,7 @@ export class MoviePlayerService implements AsyncDisposable { hls.currentLevel = levelIndex >= 0 ? levelIndex : -1 }) - hls.on(HlsModule.Events.DESTROYING, () => sub[Symbol.dispose]()) + hls.on(Hls.Events.DESTROYING, () => sub[Symbol.dispose]()) }) this.hls.loadSource(hlsUrl) @@ -290,7 +306,7 @@ export class MoviePlayerService implements AsyncDisposable { * Switches audio track and reloads from current position */ public async switchAudioTrack(trackIndex: number) { - const previousProgress = this.videoElement?.currentTime ?? this.progress.getValue() + const previousProgress = this.getAbsoluteProgress() this.isSwitching.setValue(true) await this.teardownHlsSession() @@ -315,22 +331,17 @@ export class MoviePlayerService implements AsyncDisposable { * Switches resolution and restarts playback. Forces transcode mode when a * specific resolution is requested; restores the original mode on "Auto". * - * If already in transcode mode, just changes hls.js level (no reload). - * A full reload only happens when the playback mode changes. + * Always tears down the existing server-side HLS session to ensure the + * old ffmpeg process is cleaned up before the new one starts. */ public async switchResolution(value: ResolutionValue | undefined) { - const currentMode = this.playbackMode.getValue() const targetMode = value ? 'transcode' : this.originalPlaybackMode - if (currentMode === 'transcode' && targetMode === 'transcode') { - this.resolution.setValue(value) - return - } + const previousProgress = this.getAbsoluteProgress() - const previousProgress = this.videoElement?.currentTime ?? this.progress.getValue() + this.isSwitching.setValue(true) await this.teardownHlsSession() - this.isSwitching.setValue(true) this.resolution.setValue(value) this.currentProgress = previousProgress this.progress.setValue(previousProgress) @@ -350,10 +361,14 @@ export class MoviePlayerService implements AsyncDisposable { } } + private seekDebounceTimer: ReturnType | null = null + /** - * Handles a seek to a new time position. If the target is not within the - * video's buffered ranges and we're in HLS mode, tears down the current - * session and starts a new one with server-side seeking via `-ss`. + * Handles a seek to a new absolute file time position. If the target is + * before the current playlist start, tears down the current session and + * starts a new one with server-side seeking via `-ss`. Forward seeks + * within the current playlist are left to hls.js which can load the + * required segments on its own. */ public seekToTime(targetSeconds: number) { const mode = this.playbackMode.getValue() @@ -362,12 +377,23 @@ export class MoviePlayerService implements AsyncDisposable { const video = this.videoElement if (!video) return - if (this.isTimeBuffered(video, targetSeconds)) return + // Convert absolute file time to 0-based stream time for the buffer check + const streamTime = targetSeconds - this.hlsStartTime + if (streamTime >= 0 && this.isTimeBuffered(video, streamTime)) return + + // hls.js can handle forward seeks within the current VOD playlist + if (targetSeconds >= this.hlsStartTime) return const quantizedStart = Math.floor(targetSeconds / HLS_SEGMENT_DURATION) * HLS_SEGMENT_DURATION - if (quantizedStart === this.hlsStartTime) return - void this.restartHlsAtTime(targetSeconds, quantizedStart) + if (this.seekDebounceTimer) { + clearTimeout(this.seekDebounceTimer) + } + + this.seekDebounceTimer = setTimeout(() => { + this.seekDebounceTimer = null + void this.restartHlsAtTime(targetSeconds, quantizedStart) + }, 300) } private isTimeBuffered(video: HTMLVideoElement, time: number): boolean { @@ -398,7 +424,7 @@ export class MoviePlayerService implements AsyncDisposable { this.progress.setValue(targetSeconds) if (this.videoElement) { - void this.startHlsPlayback(this.videoElement) + this.startHlsPlayback(this.videoElement) this.waitForCanPlay(this.videoElement) } else { this.isSwitching.setValue(false) diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx index 84dc3abf..7ecba37b 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx @@ -233,12 +233,12 @@ export const MoviePlayerV2 = Shade({ }} onseeking={(ev) => { const { currentTime } = ev.currentTarget as HTMLVideoElement - mediaService.seekToTime(currentTime) + mediaService.seekToTime(mediaService.streamTimeToAbsolute(currentTime)) }} ontimeupdate={(ev) => { if (mediaService.isSwitching.getValue()) return const { currentTime } = ev.currentTarget as HTMLVideoElement - mediaService.progress.setValue(currentTime || 0) + mediaService.progress.setValue(mediaService.streamTimeToAbsolute(currentTime) || 0) }} > {...subtitleElements} diff --git a/service/src/app-models/media/services/transcoding-session.spec.ts b/service/src/app-models/media/services/transcoding-session.spec.ts index b8288fe4..31e1e94c 100644 --- a/service/src/app-models/media/services/transcoding-session.spec.ts +++ b/service/src/app-models/media/services/transcoding-session.spec.ts @@ -1103,7 +1103,7 @@ describe('TranscodingSessionService', () => { }) }) - it('should offset -force_key_frames by startTime for transcode mode', async () => { + it('should use 0-based -force_key_frames for transcode mode (no startTime offset since -copyts is not used)', async () => { const mockProcess = createMockProcess() mockSpawn.mockReturnValue(mockProcess) @@ -1130,7 +1130,7 @@ describe('TranscodingSessionService', () => { const args = lastCall[1] const fkfIndex = args.indexOf('-force_key_frames') expect(fkfIndex).toBeGreaterThanOrEqual(0) - expect(args[fkfIndex + 1]).toBe('expr:gte(t,n_forced*6+3600)') + expect(args[fkfIndex + 1]).toBe('expr:gte(t,n_forced*6)') } finally { service.dispose() } diff --git a/service/src/app-models/media/services/transcoding-session.ts b/service/src/app-models/media/services/transcoding-session.ts index 16462edd..881911da 100644 --- a/service/src/app-models/media/services/transcoding-session.ts +++ b/service/src/app-models/media/services/transcoding-session.ts @@ -391,13 +391,18 @@ export class TranscodingSessionService { const args: string[] = [] - // Input seeking (before -i for fast keyframe-based seeking) + // Input seeking (before -i for fast keyframe-based seeking). + // Do NOT add -copyts here: it preserves the original PTS from the + // source file which causes a mismatch between the HLS playlist + // timeline (starts at 0) and the media PTS (starts at ~startTime). + // This breaks hls.js startPosition, causes the progress bar to + // show 00:00, and introduces audio/video desync because -ss seeks + // to the nearest video keyframe while audio seeking is sample-precise. + // The client adds hlsStartTime to video.currentTime instead. if (startTime > 0) { args.push('-ss', String(startTime)) } - // Input with timestamp preservation (like Jellyfin) - args.push('-copyts', '-avoid_negative_ts', 'disabled') args.push('-i', fullPath) // Thread config @@ -440,8 +445,8 @@ export class TranscodingSessionService { args.push('-c:v', videoCodec) - // Force keyframes at segment boundaries (offset by startTime when -copyts preserves original PTS) - args.push('-force_key_frames', `expr:gte(t,n_forced*${HLS_SEGMENT_DURATION}+${startTime})`) + // Force keyframes at segment boundaries (t starts from 0 since we don't use -copyts) + args.push('-force_key_frames', `expr:gte(t,n_forced*${HLS_SEGMENT_DURATION})`) args.push('-sc_threshold:v', '0') const isSoftwareEncoder = videoCodec === 'libx264' || videoCodec === 'libx265' From 1bfcc5fe6e71aaa1adf9f7849cfcf260f5ead85d Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Thu, 12 Mar 2026 11:31:14 +0100 Subject: [PATCH 18/30] added fast build option without monaco --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index f04ecad7..6623c36a 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "scripts": { "prepare": "husky", "build:monaco-mfe": "yarn workspace monaco-mfe build", + "build:fast": "tsc -b common service frontend && yarn workspace frontend build", "build": "yarn build:monaco-mfe && tsc -b common service frontend && yarn workspace frontend build", "create-schemas": "yarn workspace common create-schemas", "test:e2e:install": "yarn playwright test --grep @install --project chromium", From 44888fc81bd18783f72ef2ff21820b0a6b7c3491 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Thu, 12 Mar 2026 11:40:44 +0100 Subject: [PATCH 19/30] fire-and-forget fixes --- .cursor/rules/ASYNC_PATTERNS.mdc | 88 +++++++++++++++++++ .cursor/rules/SINGLETON_CONCURRENCY.md | 2 + .cursor/rules/rules-index.mdc | 1 + .../movie-player-v2/movie-player-service.ts | 1 + .../services/transcoding-session.spec.ts | 10 +-- .../media/services/transcoding-session.ts | 60 +++++++------ 6 files changed, 130 insertions(+), 32 deletions(-) create mode 100644 .cursor/rules/ASYNC_PATTERNS.mdc diff --git a/.cursor/rules/ASYNC_PATTERNS.mdc b/.cursor/rules/ASYNC_PATTERNS.mdc new file mode 100644 index 00000000..5cd336de --- /dev/null +++ b/.cursor/rules/ASYNC_PATTERNS.mdc @@ -0,0 +1,88 @@ +--- +alwaysApply: false +--- +# Async Patterns + +## Fire-and-Forget Promises Must Handle Errors + +Every fire-and-forget `void` promise call must include a `.catch()` that logs the error. An unhandled async rejection is a silent bug. + +```typescript +// ✅ Good — error is logged +void this.evictByDiskUsage().catch((error) => { + void this.logger.error({ message: 'Failed to evict by disk usage', data: { error } }) +}) + +// ✅ Good — singleton init pattern (see SINGLETON_CONCURRENCY.md) +void this.initAsync().catch((error) => { + void this.logger.error({ message: 'Failed to initialize service', data: { error } }) +}) + +// ❌ Bad — error silently lost +void this.evictByDiskUsage() + +// ❌ Bad — empty catch swallows errors +void this.evictByDiskUsage().catch(() => {}) +``` + +### Exception: Browser `video.play()` + +Browser `HTMLMediaElement.play()` rejects with `AbortError` when navigation interrupts playback. This is benign and expected. An empty `.catch(() => {})` is acceptable **only** with an explanatory comment. + +```typescript +// ✅ Acceptable — benign browser rejection documented +// play() can reject with AbortError when navigation interrupts playback; this is benign +void video.play().catch(() => {}) +``` + +### When `void` logger calls are fine + +Logger methods return promises but are fire-and-forget by design. `void this.logger.verbose(...)` is the correct pattern — do not `await` logger calls in hot paths. + +### When `void` in frontend event handlers is fine + +Sync DOM callbacks (`onclick`, `onsubmit`, `ondrop`) cannot be `async`. Using `void asyncFn()` is correct when the called function handles its own errors internally (e.g., shows user-facing error notifications). + +## Prefer `fs/promises` in Async Contexts + +When inside an `async` function, use `fs/promises` instead of sync `fs` methods to avoid blocking the Node.js event loop. + +```typescript +// ✅ Good — non-blocking in async context +import { mkdir, stat, readdir, rm } from 'fs/promises' +import { existsAsync } from '../../../utils/exists-async.js' + +async function ensureDir(dir: string) { + if (!(await existsAsync(dir))) { + await mkdir(dir, { recursive: true }) + } +} + +// ❌ Bad — blocks event loop in async context +import { existsSync, mkdirSync } from 'fs' + +async function ensureDir(dir: string) { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } +} +``` + +### Mapping sync → async + +| Sync | Async equivalent | +| ------------------------ | -------------------------------------------------- | +| `existsSync(p)` | `existsAsync(p)` (from `service/src/utils/exists-async.ts`) | +| `statSync(p)` | `stat(p)` from `fs/promises` | +| `mkdirSync(p, opts)` | `mkdir(p, opts)` from `fs/promises` | +| `readdirSync(p, opts)` | `readdir(p, opts)` from `fs/promises` | +| `rmSync(p, opts)` | `rm(p, opts)` from `fs/promises` | +| `readFileSync(p, enc)` | `readFile(p, enc)` from `fs/promises` | +| `writeFileSync(p, data)` | `writeFile(p, data)` from `fs/promises` | + +### When sync FS is acceptable + +- **Sync-only API contracts:** Vite middleware, Vite build hooks, Express middleware +- **Test setup/teardown:** `beforeEach` / `afterEach` with small temp directories +- **One-time cached helpers:** Sync helper that runs once and caches the result (e.g., `getBaseDir()`) +- **Synchronous process cleanup:** `destroySession()` where the caller needs the directory removed before the reference is dropped diff --git a/.cursor/rules/SINGLETON_CONCURRENCY.md b/.cursor/rules/SINGLETON_CONCURRENCY.md index 9260b205..464d2a90 100644 --- a/.cursor/rules/SINGLETON_CONCURRENCY.md +++ b/.cursor/rules/SINGLETON_CONCURRENCY.md @@ -56,6 +56,8 @@ Apply this pattern when **all** of the following are true: ## Fire-and-Forget Async Initialization +> See also [ASYNC_PATTERNS.mdc](./ASYNC_PATTERNS.mdc) for the broader rule on fire-and-forget promises and `fs/promises` usage. + Singleton services that need async initialization (e.g., loading config from a database, connecting to external APIs) should **not** block startup. Use a synchronous `init()` that kicks off the async work and logs errors. ### The Problem diff --git a/.cursor/rules/rules-index.mdc b/.cursor/rules/rules-index.mdc index 23e8681d..5facd45a 100644 --- a/.cursor/rules/rules-index.mdc +++ b/.cursor/rules/rules-index.mdc @@ -14,3 +14,4 @@ This file contains a list of helpful information and context that the agent can - [MFE runtime type boundaries, duplicate type contracts, and cross-package type sync](./MFE_TYPE_CONTRACTS.md) - [Double-cast anti-pattern (`as unknown as T`) and non-null assertion avoidance](./TYPESCRIPT_GUIDELINES.mdc) - [Provider/adapter fallback chain patterns with narrow internal result types](./BACKEND_PATTERNS.mdc) +- [Fire-and-forget error handling, `fs/promises` in async contexts, and `void` promise patterns](./ASYNC_PATTERNS.mdc) diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts b/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts index 9315afd1..d76e57ee 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts @@ -435,6 +435,7 @@ export class MoviePlayerService implements AsyncDisposable { const onCanPlay = () => { video.removeEventListener('canplay', onCanPlay) this.isSwitching.setValue(false) + // play() can reject with AbortError when navigation interrupts playback; this is benign void video.play().catch(() => {}) } video.addEventListener('canplay', onCanPlay) diff --git a/service/src/app-models/media/services/transcoding-session.spec.ts b/service/src/app-models/media/services/transcoding-session.spec.ts index 31e1e94c..227da088 100644 --- a/service/src/app-models/media/services/transcoding-session.spec.ts +++ b/service/src/app-models/media/services/transcoding-session.spec.ts @@ -409,7 +409,7 @@ describe('TranscodingSessionService', () => { if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }) }) - it('should calculate session disk usage', () => { + it('should calculate session disk usage', async () => { const sessionDir = join(testDir, 'session1') mkdirSync(sessionDir, { recursive: true }) writeFileSync(join(sessionDir, 'segment0.m4s'), Buffer.alloc(1000)) @@ -419,20 +419,20 @@ describe('TranscodingSessionService', () => { const service = new TranscodingSessionService() try { const mockSession = { sessionDir } as Parameters[0] - const usage = service.getSessionDiskUsage(mockSession) + const usage = await service.getSessionDiskUsage(mockSession) expect(usage).toBe(3500) } finally { service.dispose() } }) - it('should return 0 for non-existent session directory', () => { + it('should return 0 for non-existent session directory', async () => { const service = new TranscodingSessionService() try { const mockSession = { sessionDir: join(testDir, 'nonexistent') } as Parameters< typeof service.getSessionDiskUsage >[0] - const usage = service.getSessionDiskUsage(mockSession) + const usage = await service.getSessionDiskUsage(mockSession) expect(usage).toBe(0) } finally { service.dispose() @@ -469,7 +469,7 @@ describe('TranscodingSessionService', () => { writeFileSync(join(s1.sessionDir, 'segment0.m4s'), Buffer.alloc(100)) writeFileSync(join(s2.sessionDir, 'segment0.m4s'), Buffer.alloc(200)) - const total = service.getTotalDiskUsage() + const total = await service.getTotalDiskUsage() expect(total).toBe(300) } finally { service.dispose() diff --git a/service/src/app-models/media/services/transcoding-session.ts b/service/src/app-models/media/services/transcoding-session.ts index 881911da..93d61751 100644 --- a/service/src/app-models/media/services/transcoding-session.ts +++ b/service/src/app-models/media/services/transcoding-session.ts @@ -6,8 +6,9 @@ import type { PlaybackMode } from 'common' import { Config, Drive, HLS_SEGMENT_DURATION, type MoviesConfig } from 'common' import { spawn, type ChildProcess } from 'child_process' import { createHash } from 'crypto' -import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'fs' -import { readFile } from 'fs/promises' +import { existsSync, mkdirSync, rmSync } from 'fs' +import { mkdir, readdir, readFile, stat } from 'fs/promises' +import { existsAsync } from '../../../utils/exists-async.js' import { join } from 'path' import { tmpdir } from 'os' import { FfprobeService } from '../../../ffprobe-service.js' @@ -97,7 +98,7 @@ export class TranscodingSessionService { const configDataSet = getDataSetFor(this.injector, Config, 'id') const config = (await configDataSet.get(this.systemInjector, 'MOVIES_CONFIG')) as MoviesConfig | undefined const dir = config?.value?.hlsSegmentPath || join(tmpdir(), 'pirat-hls-sessions') - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + if (!(await existsAsync(dir))) await mkdir(dir, { recursive: true }) this.baseDirCache = dir const mb = config?.value?.hlsMaxCacheSizeMb ?? DEFAULT_MAX_CACHE_SIZE_MB @@ -171,8 +172,8 @@ export class TranscodingSessionService { ): Promise { await this.getBaseDirFromConfig() const sessionDir = this.getSessionDir(key) - if (!existsSync(sessionDir)) { - mkdirSync(sessionDir, { recursive: true }) + if (!(await existsAsync(sessionDir))) { + await mkdir(sessionDir, { recursive: true }) } const { args: ffmpegArgs, totalDuration } = await this.buildHlsFfmpegArgs({ @@ -229,7 +230,9 @@ export class TranscodingSessionService { }) this.sessions.set(key, session) - void this.evictByDiskUsage() + void this.evictByDiskUsage().catch((error) => { + void this.logger.error({ message: 'Failed to evict sessions by disk usage', data: { error } }) + }) return session } @@ -241,23 +244,22 @@ export class TranscodingSessionService { const deadline = Date.now() + WAIT_TIMEOUT_MS while (Date.now() < deadline) { - if (existsSync(filePath)) { - // For segments, check that the file has non-zero size + if (await existsAsync(filePath)) { try { - const stat = statSync(filePath) - if (stat.size > 0) return true + const fileStat = await stat(filePath) + if (fileStat.size > 0) return true } catch { // File might have been deleted between check and stat } } if (session.state === 'error') return false - if (session.state === 'completed' && !existsSync(filePath)) return false + if (session.state === 'completed' && !(await existsAsync(filePath))) return false await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_INTERVAL_MS)) } - return existsSync(filePath) + return existsAsync(filePath) } /** @@ -270,9 +272,8 @@ export class TranscodingSessionService { const deadline = Date.now() + WAIT_TIMEOUT_MS while (Date.now() < deadline) { - if (existsSync(segmentPath)) { - // Segment is fully written if the next one exists or ffmpeg is done - if (existsSync(nextSegmentPath) || session.state === 'completed' || session.state === 'error') { + if (await existsAsync(segmentPath)) { + if ((await existsAsync(nextSegmentPath)) || session.state === 'completed' || session.state === 'error') { return true } } @@ -282,7 +283,7 @@ export class TranscodingSessionService { await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_INTERVAL_MS)) } - return existsSync(segmentPath) + return existsAsync(segmentPath) } public async readPlaylist(session: TranscodingSessionEntry): Promise { @@ -522,16 +523,19 @@ export class TranscodingSessionService { /** * Returns the total bytes used by a session's directory on disk. */ - public getSessionDiskUsage(session: TranscodingSessionEntry): number { + public async getSessionDiskUsage(session: TranscodingSessionEntry): Promise { try { - if (!existsSync(session.sessionDir)) return 0 - return readdirSync(session.sessionDir).reduce((total, file) => { + if (!(await existsAsync(session.sessionDir))) return 0 + const files = await readdir(session.sessionDir) + let total = 0 + for (const file of files) { try { - return total + statSync(join(session.sessionDir, file)).size + total += (await stat(join(session.sessionDir, file))).size } catch { - return total + // File may have been deleted } - }, 0) + } + return total } catch { return 0 } @@ -540,10 +544,10 @@ export class TranscodingSessionService { /** * Returns the total bytes used across all active session directories. */ - public getTotalDiskUsage(): number { + public async getTotalDiskUsage(): Promise { let total = 0 for (const session of this.sessions.values()) { - total += this.getSessionDiskUsage(session) + total += await this.getSessionDiskUsage(session) } return total } @@ -567,7 +571,7 @@ export class TranscodingSessionService { */ private async evictByDiskUsage(): Promise { const maxBytes = await this.loadMaxCacheSize() - let totalUsage = this.getTotalDiskUsage() + let totalUsage = await this.getTotalDiskUsage() if (totalUsage <= maxBytes) return // Sort sessions by lastAccessedAt ascending (oldest first), prefer completed/error over active @@ -580,7 +584,7 @@ export class TranscodingSessionService { for (const [key, session] of candidates) { if (totalUsage <= maxBytes) break - const usage = this.getSessionDiskUsage(session) + const usage = await this.getSessionDiskUsage(session) void this.logger.verbose({ message: `Evicting session for cache limit: ${key} (${Math.round(usage / BYTES_PER_MB)}MB)`, }) @@ -607,7 +611,9 @@ export class TranscodingSessionService { } } - void this.evictByDiskUsage() + void this.evictByDiskUsage().catch((error) => { + void this.logger.error({ message: 'Failed to evict sessions by disk usage', data: { error } }) + }) } private destroySession(session: TranscodingSessionEntry) { From ccac2b13634e38370135d5904df8260c5c141fe8 Mon Sep 17 00:00:00 2001 From: Gallay Lajos Date: Thu, 12 Mar 2026 13:54:33 +0100 Subject: [PATCH 20/30] Native Shades video controls, code review fixes --- .cursor/rules/SCRIPT_EXECUTION.md | 7 + .yarn/changelogs/frontend.90cc3afc.md | 4 +- e2e/video-playback.spec.ts | 26 +- frontend/package.json | 2 - .../movies/movie-player-v2/control-area.tsx | 124 ------- .../controls/captions-button.tsx | 53 +++ .../movie-player-v2/controls/control-bar.tsx | 46 +++ .../controls/error-overlay.tsx | 69 ++++ .../controls/fullscreen-button.tsx | 42 +++ .../movies/movie-player-v2/controls/index.ts | 13 + .../controls/loading-overlay.tsx | 44 +++ .../movie-player-v2/controls/pip-button.tsx | 34 ++ .../movie-player-v2/controls/play-button.tsx | 36 ++ .../movie-player-v2/controls/player-icons.ts | 44 +++ .../controls/poster-overlay.tsx | 34 ++ .../movie-player-v2/controls/seek-bar.tsx | 84 +++++ .../controls/settings-menu.tsx | 243 +++++++++++++ .../movie-player-v2/controls/time-display.tsx | 39 +++ .../controls/video-container.tsx | 155 +++++++++ .../controls/volume-control.tsx | 72 ++++ .../movies/movie-player-v2/media-chrome.ts | 93 ----- .../movie-player-service.spec.ts | 181 +++------- .../movie-player-v2/movie-player-service.ts | 322 ++++++++++-------- .../movie-player-v2-component.spec.ts | 149 +------- .../movie-player-v2-component.tsx | 171 +--------- .../src/pages/movies/plain-hls-player.tsx | 87 ++--- yarn.lock | 27 -- 27 files changed, 1276 insertions(+), 925 deletions(-) delete mode 100644 frontend/src/pages/movies/movie-player-v2/control-area.tsx create mode 100644 frontend/src/pages/movies/movie-player-v2/controls/captions-button.tsx create mode 100644 frontend/src/pages/movies/movie-player-v2/controls/control-bar.tsx create mode 100644 frontend/src/pages/movies/movie-player-v2/controls/error-overlay.tsx create mode 100644 frontend/src/pages/movies/movie-player-v2/controls/fullscreen-button.tsx create mode 100644 frontend/src/pages/movies/movie-player-v2/controls/index.ts create mode 100644 frontend/src/pages/movies/movie-player-v2/controls/loading-overlay.tsx create mode 100644 frontend/src/pages/movies/movie-player-v2/controls/pip-button.tsx create mode 100644 frontend/src/pages/movies/movie-player-v2/controls/play-button.tsx create mode 100644 frontend/src/pages/movies/movie-player-v2/controls/player-icons.ts create mode 100644 frontend/src/pages/movies/movie-player-v2/controls/poster-overlay.tsx create mode 100644 frontend/src/pages/movies/movie-player-v2/controls/seek-bar.tsx create mode 100644 frontend/src/pages/movies/movie-player-v2/controls/settings-menu.tsx create mode 100644 frontend/src/pages/movies/movie-player-v2/controls/time-display.tsx create mode 100644 frontend/src/pages/movies/movie-player-v2/controls/video-container.tsx create mode 100644 frontend/src/pages/movies/movie-player-v2/controls/volume-control.tsx delete mode 100644 frontend/src/pages/movies/movie-player-v2/media-chrome.ts diff --git a/.cursor/rules/SCRIPT_EXECUTION.md b/.cursor/rules/SCRIPT_EXECUTION.md index a0511d4a..1b898cce 100644 --- a/.cursor/rules/SCRIPT_EXECUTION.md +++ b/.cursor/rules/SCRIPT_EXECUTION.md @@ -79,6 +79,13 @@ yarn create-schemas # Generate schemas from API definitions yarn clean # Clean build artifacts ``` +> **WARNING -- never run `tsc`, `tsc -b`, or `tsc --build` directly.** +> The project uses `"composite": true` with project references, so bare +> `tsc -b` emits `.js`, `.d.ts`, and `.js.map` files **into the source +> tree**. Always use `yarn build` instead. If you only need a type check +> without emitting, use the built-in linter tools (ReadLints) or run a +> targeted `vitest` suite -- do NOT run tsc in any form. + **Testing:** ```bash diff --git a/.yarn/changelogs/frontend.90cc3afc.md b/.yarn/changelogs/frontend.90cc3afc.md index 18645534..7ad2f18d 100644 --- a/.yarn/changelogs/frontend.90cc3afc.md +++ b/.yarn/changelogs/frontend.90cc3afc.md @@ -24,11 +24,13 @@ Added `LocalizedMetadataService` with caches for fetching `MovieMetadataLocalize ## 🐛 Bug Fixes -- Fixed movie duration not displaying correctly in the player controls by passing `defaultDuration` to `media-controller` +- Fixed movie duration not displaying correctly by preferring `playbackInfo.duration` over stream duration in seek bar - Fixed legacy navigation issues by relocating route utilities to `utils/` directory ## ♻️ Refactoring +- Replaced `media-chrome` and `hls.js` dependencies with modular native player controls (`PlayButton`, `SeekBar`, `VolumeControl`, `SettingsMenu`, etc.) under `controls/` directory +- Moved video event binding and playback state (play/pause, volume, duration, buffered) into `MoviePlayerService` observables - Extracted file context menu items into a pure `getContextMenuItems()` function, replacing the Shade-based `FileContextMenu` component - Extracted file drag-and-drop upload logic into `handleFileDrop()` utility - Extracted `SessionUserUnavailableError`, `getUser()`, and `hasRole()` into `utils/session-helpers.ts` for reusable session access diff --git a/e2e/video-playback.spec.ts b/e2e/video-playback.spec.ts index 01ce7d99..e69f2c5f 100644 --- a/e2e/video-playback.spec.ts +++ b/e2e/video-playback.spec.ts @@ -91,11 +91,11 @@ const navigateToMovieAndPlay = async (page: Page, browserName: string, wIndex: n } const openSettingsSubmenu = async (page: Page, menuItemText: string) => { - const settingsButton = page.locator('media-settings-menu-button').first() + const settingsButton = page.locator('[data-testid="settings-menu-button"]').first() await expect(settingsButton).toBeVisible({ timeout: 5_000 }) await settingsButton.click() - const menuItem = page.locator('media-settings-menu-item').filter({ hasText: menuItemText }) + const menuItem = page.locator('[data-testid="settings-menu-item"]').filter({ hasText: menuItemText }) await expect(menuItem).toBeVisible({ timeout: 5_000 }) await menuItem.click() } @@ -219,7 +219,7 @@ test.describe('Video Playback @media', () => { await navigateToMovieAndPlay(page, browserName, workerIndex) await openSettingsSubmenu(page, 'Captions') - const captionOptions = page.locator('media-captions-menu media-chrome-menu-item') + const captionOptions = page.locator('[data-testid="caption-track-item"]') await expect(captionOptions.first()).toBeVisible({ timeout: 5_000 }) const optionCount = await captionOptions.count() expect(optionCount).toBeGreaterThan(0) @@ -231,7 +231,7 @@ test.describe('Video Playback @media', () => { const video = await navigateToMovieAndPlay(page, browserName, workerIndex) await openSettingsSubmenu(page, 'Audio') - const audioOptions = page.locator('media-audio-track-menu media-chrome-menu-item') + const audioOptions = page.locator('[data-testid="audio-track-item"]') await expect(audioOptions.first()).toBeVisible({ timeout: 5_000 }) const audioCount = await audioOptions.count() expect(audioCount).toBeGreaterThanOrEqual(2) @@ -244,22 +244,4 @@ test.describe('Video Playback @media', () => { expect(currentTime).toBeGreaterThan(0) }).toPass({ timeout: 30_000, intervals: [2_000, 3_000, 5_000] }) }) - - test('Quality switching (HLS): quality options are listed and selectable', async ({ page, browserName }) => { - const video = await navigateToMovieAndPlay(page, browserName, workerIndex) - await openSettingsSubmenu(page, 'Quality') - - const qualityOptions = page.locator('media-rendition-menu media-chrome-menu-item') - await expect(qualityOptions.first()).toBeVisible({ timeout: 5_000 }) - const qualityCount = await qualityOptions.count() - expect(qualityCount).toBeGreaterThanOrEqual(2) - - await qualityOptions.last().click() - - // Verify playback continues after quality switch - await expect(async () => { - const currentTime = await video.evaluate((el: HTMLVideoElement) => el.currentTime) - expect(currentTime).toBeGreaterThan(0) - }).toPass({ timeout: 30_000, intervals: [2_000, 3_000, 5_000] }) - }) }) diff --git a/frontend/package.json b/frontend/package.json index 2d85dd03..c84b0d6c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,8 +35,6 @@ "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "common": "workspace:^", - "hls.js": "^1.6.15", - "media-chrome": "^4.18.0", "ollama": "^0.6.3", "path-to-regexp": "^8.3.0", "video.js": "8.23.8" diff --git a/frontend/src/pages/movies/movie-player-v2/control-area.tsx b/frontend/src/pages/movies/movie-player-v2/control-area.tsx deleted file mode 100644 index 09127e76..00000000 --- a/frontend/src/pages/movies/movie-player-v2/control-area.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { Shade, createComponent, styledShade } from '@furystack/shades' -import type { IconDefinition } from '@furystack/shades-common-components' -import { Button, Icon, icons, Input } from '@furystack/shades-common-components' -import type { ObservableValue } from '@furystack/utils' - -const maximizeIcon: IconDefinition = { - name: 'Maximize', - description: 'Expand to full screen', - keywords: ['fullscreen', 'maximize', 'expand'], - category: 'Actions', - paths: [{ d: 'M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3' }], -} - -const minimizeIcon: IconDefinition = { - name: 'Minimize', - description: 'Exit full screen', - keywords: ['fullscreen', 'minimize', 'shrink'], - category: 'Actions', - paths: [{ d: 'M4 14h6v6m10-10h-6V4m0 6l7-7M3 21l7-7' }], -} - -type ControlAreaProps = { - isPlaying: ObservableValue - isFullScreen: ObservableValue - isMuted: ObservableValue - volume: ObservableValue - watchedSeconds: ObservableValue - lengthSeconds: number - seekTo: (seconds: number) => void -} - -const ControlButton = styledShade(Button, { - fontSize: '2em', - padding: '.3em 1em', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - lineHeight: '100%', -}) - -export const SoundControl = Shade<{ - isMuted: ObservableValue - volume: ObservableValue -}>({ - customElementName: 'pirat-movie-player-v2-sound-control', - css: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }, - render: ({ props, useObservable }) => { - const [isMuted, setIsMuted] = useObservable('isMuted', props.isMuted) - const [volume] = useObservable('volume', props.volume) - - return ( - <> - setIsMuted(!isMuted)}> - {isMuted ? '🔇' : '🔊'} - - props.volume.setValue((e.target as HTMLInputElement).value as unknown as number)} - /> - - ) - }, -}) - -export const ControlArea = Shade({ - customElementName: 'pirat-movie-player-v2-control-area', - css: { - '& .control-bar': { - position: 'absolute', - bottom: '0', - background: 'linear-gradient(0deg, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.2) 80%, rgba(0,0,0,0) 100%)', - width: '100%', - height: '4em', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - zIndex: '2147483647', - }, - '& .progress-bar': { - position: 'absolute', - top: '-15px', - left: '10px', - width: 'calc(100% - 20px)', - }, - }, - render: ({ props, useObservable }) => { - const [isPlaying, setIsPlaying] = useObservable('isPlaying', props.isPlaying) - const [progress] = useObservable('progress', props.watchedSeconds) - const [isFullScreen, setFullScreen] = useObservable('isFullScreen', props.isFullScreen) - - return ( -
- props.seekTo(parseInt(e, 10))} - /> - {isPlaying ? ( - setIsPlaying(false)}> - - - ) : ( - setIsPlaying(true)}> - - - )} - setFullScreen(!isFullScreen)}> - {isFullScreen ? : } - - -
- ) - }, -}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/captions-button.tsx b/frontend/src/pages/movies/movie-player-v2/controls/captions-button.tsx new file mode 100644 index 00000000..c89bba83 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/captions-button.tsx @@ -0,0 +1,53 @@ +import { Shade, createComponent } from '@furystack/shades' +import { Icon } from '@furystack/shades-common-components' + +import type { MoviePlayerService } from '../movie-player-service.js' +import { captionsIcon } from './player-icons.js' + +type CaptionsButtonProps = { + mediaService: MoviePlayerService +} + +export const CaptionsButton = Shade({ + customElementName: 'pirat-player-captions-button', + render: ({ props, useObservable }) => { + const [activeTrack] = useObservable('activeTrack', props.mediaService.activeSubtitleTrack) + + return ( + + ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/control-bar.tsx b/frontend/src/pages/movies/movie-player-v2/controls/control-bar.tsx new file mode 100644 index 00000000..828e4172 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/control-bar.tsx @@ -0,0 +1,46 @@ +import type { RefObject } from '@furystack/shades' +import { Shade, createComponent } from '@furystack/shades' +import { cssVariableTheme } from '@furystack/shades-common-components' + +import type { MoviePlayerService } from '../movie-player-service.js' +import { CaptionsButton } from './captions-button.js' +import { FullscreenButton } from './fullscreen-button.js' +import { PipButton } from './pip-button.js' +import { PlayButton } from './play-button.js' +import { SeekBar } from './seek-bar.js' +import { SettingsMenu } from './settings-menu.js' +import { TimeDisplay } from './time-display.js' +import { VolumeControl } from './volume-control.js' + +type ControlBarProps = { + mediaService: MoviePlayerService + playerContainerRef: RefObject +} + +export const ControlBar = Shade({ + customElementName: 'pirat-player-control-bar', + css: { + display: 'flex', + alignItems: 'center', + gap: '4px', + padding: '8px 12px', + background: `linear-gradient(transparent, ${cssVariableTheme.background.paper})`, + color: cssVariableTheme.text.primary, + width: '100%', + boxSizing: 'border-box', + }, + render: ({ props }) => { + return ( + <> + + + + + + + + + + ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/error-overlay.tsx b/frontend/src/pages/movies/movie-player-v2/controls/error-overlay.tsx new file mode 100644 index 00000000..4a44d54b --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/error-overlay.tsx @@ -0,0 +1,69 @@ +import { Shade, createComponent } from '@furystack/shades' + +import type { MoviePlayerService } from '../movie-player-service.js' + +type ErrorOverlayProps = { + mediaService: MoviePlayerService +} + +export const ErrorOverlay = Shade({ + customElementName: 'pirat-player-error-overlay', + render: ({ props, useState, useDisposable }) => { + const [error, setError] = useState('error', null) + + useDisposable('errorListener', () => { + const onError = (video: HTMLVideoElement) => { + const mediaError = video.error + if (mediaError) { + setError(`Playback error: ${mediaError.message || `code ${mediaError.code}`}`) + } + } + + const video = props.mediaService.videoElement + if (video) { + const handler = () => onError(video) + video.addEventListener('error', handler) + return { [Symbol.dispose]: () => video.removeEventListener('error', handler) } + } + + let cleanup: Disposable | null = null + const frameId = requestAnimationFrame(() => { + const deferredVideo = props.mediaService.videoElement + if (deferredVideo) { + const handler = () => onError(deferredVideo) + deferredVideo.addEventListener('error', handler) + cleanup = { [Symbol.dispose]: () => deferredVideo.removeEventListener('error', handler) } + } + }) + return { + [Symbol.dispose]: () => { + cancelAnimationFrame(frameId) + cleanup?.[Symbol.dispose]() + }, + } + }) + + if (!error) return
+ + return ( +
+ {error} +
+ ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/fullscreen-button.tsx b/frontend/src/pages/movies/movie-player-v2/controls/fullscreen-button.tsx new file mode 100644 index 00000000..f24e3d4a --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/fullscreen-button.tsx @@ -0,0 +1,42 @@ +import type { RefObject } from '@furystack/shades' +import { Shade, createComponent } from '@furystack/shades' +import { Icon } from '@furystack/shades-common-components' + +import type { MoviePlayerService } from '../movie-player-service.js' +import { fullscreenEnterIcon, fullscreenExitIcon } from './player-icons.js' + +type FullscreenButtonProps = { + mediaService: MoviePlayerService + playerContainerRef: RefObject +} + +export const FullscreenButton = Shade({ + customElementName: 'pirat-player-fullscreen-button', + render: ({ props, useObservable }) => { + const [isFullscreen] = useObservable('isFullscreen', props.mediaService.isFullscreen) + + return ( + + ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/index.ts b/frontend/src/pages/movies/movie-player-v2/controls/index.ts new file mode 100644 index 00000000..41b6992a --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/index.ts @@ -0,0 +1,13 @@ +export { VideoContainer } from './video-container.js' +export { ControlBar } from './control-bar.js' +export { PlayButton } from './play-button.js' +export { TimeDisplay } from './time-display.js' +export { SeekBar } from './seek-bar.js' +export { VolumeControl } from './volume-control.js' +export { FullscreenButton } from './fullscreen-button.js' +export { PipButton } from './pip-button.js' +export { CaptionsButton } from './captions-button.js' +export { SettingsMenu } from './settings-menu.js' +export { LoadingOverlay } from './loading-overlay.js' +export { ErrorOverlay } from './error-overlay.js' +export { PosterOverlay } from './poster-overlay.js' diff --git a/frontend/src/pages/movies/movie-player-v2/controls/loading-overlay.tsx b/frontend/src/pages/movies/movie-player-v2/controls/loading-overlay.tsx new file mode 100644 index 00000000..21c1ed44 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/loading-overlay.tsx @@ -0,0 +1,44 @@ +import { Shade, createComponent } from '@furystack/shades' + +import type { MoviePlayerService } from '../movie-player-service.js' + +type LoadingOverlayProps = { + mediaService: MoviePlayerService +} + +export const LoadingOverlay = Shade({ + customElementName: 'pirat-player-loading-overlay', + render: ({ props, useObservable }) => { + const [isSwitching] = useObservable('isSwitching', props.mediaService.isSwitching) + + if (!isSwitching) return
+ + return ( +
+
+ +
+ ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/pip-button.tsx b/frontend/src/pages/movies/movie-player-v2/controls/pip-button.tsx new file mode 100644 index 00000000..8f308ebc --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/pip-button.tsx @@ -0,0 +1,34 @@ +import { Shade, createComponent } from '@furystack/shades' +import { Icon } from '@furystack/shades-common-components' + +import type { MoviePlayerService } from '../movie-player-service.js' +import { pipIcon } from './player-icons.js' + +type PipButtonProps = { + mediaService: MoviePlayerService +} + +export const PipButton = Shade({ + customElementName: 'pirat-player-pip-button', + render: ({ props }) => { + return ( + + ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/play-button.tsx b/frontend/src/pages/movies/movie-player-v2/controls/play-button.tsx new file mode 100644 index 00000000..bcd81f24 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/play-button.tsx @@ -0,0 +1,36 @@ +import { Shade, createComponent } from '@furystack/shades' +import { Icon, icons } from '@furystack/shades-common-components' + +import type { MoviePlayerService } from '../movie-player-service.js' + +type PlayButtonProps = { + mediaService: MoviePlayerService +} + +export const PlayButton = Shade({ + customElementName: 'pirat-player-play-button', + render: ({ props, useObservable }) => { + const [isPlaying] = useObservable('isPlaying', props.mediaService.isPlaying) + + return ( + + ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/player-icons.ts b/frontend/src/pages/movies/movie-player-v2/controls/player-icons.ts new file mode 100644 index 00000000..3d7d8f06 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/player-icons.ts @@ -0,0 +1,44 @@ +import type { IconDefinition } from '@furystack/shades-common-components' + +export const volumeHighIcon: IconDefinition = { + paths: [{ d: 'M11 5L6 9H2v6h4l5 4V5z' }, { d: 'M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07' }], +} + +export const volumeLowIcon: IconDefinition = { + paths: [{ d: 'M11 5L6 9H2v6h4l5 4V5z' }, { d: 'M15.54 8.46a5 5 0 010 7.07' }], +} + +export const volumeMuteIcon: IconDefinition = { + paths: [{ d: 'M11 5L6 9H2v6h4l5 4V5z' }, { d: 'M23 9l-6 6M17 9l6 6' }], +} + +export const fullscreenEnterIcon: IconDefinition = { + paths: [{ d: 'M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3' }], +} + +export const fullscreenExitIcon: IconDefinition = { + paths: [{ d: 'M4 14h6v6m10-10h-6V4m0 6l7-7M3 21l7-7' }], +} + +export const pipIcon: IconDefinition = { + paths: [ + { d: 'M2 4.5A2.5 2.5 0 014.5 2h15A2.5 2.5 0 0122 4.5v15a2.5 2.5 0 01-2.5 2.5h-15A2.5 2.5 0 012 19.5v-15z' }, + { d: 'M12 13.5a1 1 0 011-1h6a1 1 0 011 1V18a1 1 0 01-1 1h-6a1 1 0 01-1-1v-4.5z' }, + ], +} + +export const captionsIcon: IconDefinition = { + paths: [ + { d: 'M2 6a2 2 0 012-2h16a2 2 0 012 2v12a2 2 0 01-2 2H4a2 2 0 01-2-2V6z' }, + { d: 'M10 9.5a2.5 2.5 0 00-4.5 0v5a2.5 2.5 0 004.5 0M18.5 9.5a2.5 2.5 0 00-4.5 0v5a2.5 2.5 0 004.5 0' }, + ], +} + +export const gearIcon: IconDefinition = { + paths: [ + { + d: 'M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z', + }, + { d: 'M12 15a3 3 0 100-6 3 3 0 000 6z' }, + ], +} diff --git a/frontend/src/pages/movies/movie-player-v2/controls/poster-overlay.tsx b/frontend/src/pages/movies/movie-player-v2/controls/poster-overlay.tsx new file mode 100644 index 00000000..6946fe7d --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/poster-overlay.tsx @@ -0,0 +1,34 @@ +import { Shade, createComponent } from '@furystack/shades' + +import type { MoviePlayerService } from '../movie-player-service.js' + +type PosterOverlayProps = { + mediaService: MoviePlayerService + posterUrl?: string +} + +export const PosterOverlay = Shade({ + customElementName: 'pirat-player-poster-overlay', + render: ({ props, useObservable }) => { + const [isPlaying] = useObservable('isPlaying', props.mediaService.isPlaying) + const [progress] = useObservable('progress', props.mediaService.progress) + + if (isPlaying || progress > 0 || !props.posterUrl) return
+ + return ( +
props.mediaService.togglePlay()} + /> + ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/seek-bar.tsx b/frontend/src/pages/movies/movie-player-v2/controls/seek-bar.tsx new file mode 100644 index 00000000..fde57607 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/seek-bar.tsx @@ -0,0 +1,84 @@ +import { Shade, createComponent } from '@furystack/shades' +import { cssVariableTheme } from '@furystack/shades-common-components' + +import type { MoviePlayerService } from '../movie-player-service.js' + +type SeekBarProps = { + mediaService: MoviePlayerService +} + +export const SeekBar = Shade({ + customElementName: 'pirat-player-seek-bar', + css: { + display: 'flex', + alignItems: 'center', + flex: '1', + position: 'relative', + height: '20px', + cursor: 'pointer', + '& .seek-track': { + position: 'absolute', + width: '100%', + height: '4px', + borderRadius: '2px', + background: 'rgba(255, 255, 255, 0.2)', + overflow: 'hidden', + }, + '& .seek-buffered': { + position: 'absolute', + height: '100%', + background: 'rgba(255, 255, 255, 0.35)', + }, + '& .seek-played': { + position: 'absolute', + height: '100%', + background: cssVariableTheme.palette.primary.main, + }, + '& input[type="range"]': { + position: 'absolute', + width: '100%', + height: '100%', + margin: '0', + opacity: '0', + cursor: 'pointer', + zIndex: '1', + }, + }, + render: ({ props, useObservable }) => { + const [progress] = useObservable('progress', props.mediaService.progress) + const [duration] = useObservable('duration', props.mediaService.duration) + const [buffered] = useObservable('buffered', props.mediaService.buffered) + + const playedPercent = duration > 0 ? (progress / duration) * 100 : 0 + + let bufferedPercent = 0 + if (buffered && buffered.length > 0 && duration > 0) { + const end = buffered.end(buffered.length - 1) + const absoluteBuffered = props.mediaService.streamTimeToAbsolute(end) + bufferedPercent = (absoluteBuffered / duration) * 100 + } + + return ( +
+
+
+
+
+ { + const target = ev.currentTarget as HTMLInputElement + const targetTime = parseFloat(target.value) + if (!isNaN(targetTime)) { + props.mediaService.seekToTime(targetTime) + } + }} + /> +
+ ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/settings-menu.tsx b/frontend/src/pages/movies/movie-player-v2/controls/settings-menu.tsx new file mode 100644 index 00000000..653abc37 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/settings-menu.tsx @@ -0,0 +1,243 @@ +import { Shade, createComponent } from '@furystack/shades' +import { Icon, cssVariableTheme } from '@furystack/shades-common-components' + +import type { MoviePlayerService } from '../movie-player-service.js' +import { gearIcon } from './player-icons.js' + +type SettingsMenuProps = { + mediaService: MoviePlayerService +} + +type SubmenuId = 'speed' | 'audio' | 'captions' | null + +const SPEED_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const + +export const SettingsMenu = Shade({ + customElementName: 'pirat-player-settings-menu', + css: { + position: 'relative', + '& .settings-panel': { + position: 'absolute', + bottom: '100%', + right: '0', + marginBottom: '8px', + background: cssVariableTheme.background.paper, + borderRadius: '8px', + border: `1px solid ${cssVariableTheme.action.subtleBorder}`, + boxShadow: cssVariableTheme.shadows.lg, + minWidth: '200px', + maxHeight: '300px', + overflowY: 'auto', + zIndex: '10', + }, + '& .menu-item': { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '10px 16px', + cursor: 'pointer', + fontSize: '14px', + color: cssVariableTheme.text.primary, + border: 'none', + background: 'none', + width: '100%', + textAlign: 'left', + }, + '& .menu-item:hover': { + background: cssVariableTheme.action.hoverBackground, + }, + '& .menu-item.active': { + color: cssVariableTheme.palette.primary.main, + }, + '& .menu-header': { + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '10px 16px', + borderBottom: `1px solid ${cssVariableTheme.action.subtleBorder}`, + fontSize: '14px', + fontWeight: 'bold', + color: cssVariableTheme.text.primary, + cursor: 'pointer', + background: 'none', + border: 'none', + width: '100%', + textAlign: 'left', + }, + }, + render: ({ props, useObservable, useState }) => { + const [isOpen, setIsOpen] = useState('isOpen', false) + const [activeSubmenu, setActiveSubmenu] = useState('activeSubmenu', null) + const [playbackRate] = useObservable('playbackRate', props.mediaService.playbackRate) + const [activeTrack] = useObservable('activeTrack', props.mediaService.activeSubtitleTrack) + + const audioTracks = props.mediaService.getAudioTrackInfoFromPlaybackInfo() + const ffprobeAudioTracks = + audioTracks.length === 0 + ? props.mediaService.getAudioTracks().map((t) => ({ + index: t.id, + label: + (t.stream.tags as Record)?.title || + (t.stream.tags as Record)?.language || + 'Audio Track', + language: (t.stream.tags as Record)?.language || 'unknown', + codecName: t.codecName ?? 'unknown', + channels: t.stream.channels ?? 2, + isDefault: t.stream.disposition?.default === 1, + })) + : [] + const allAudioTracks = audioTracks.length > 0 ? audioTracks : ffprobeAudioTracks + + const subtitleTracks = props.mediaService.getSubtitleTrackInfoFromPlaybackInfo() + + const toggleMenu = () => { + if (isOpen) { + setIsOpen(false) + setActiveSubmenu(null) + } else { + setIsOpen(true) + } + } + + const renderMainMenu = () => ( +
+ + {allAudioTracks.length > 1 && ( + + )} + {subtitleTracks.length > 0 && ( + + )} +
+ ) + + const renderSpeedSubmenu = () => ( +
+ + {SPEED_OPTIONS.map((rate) => ( + + ))} +
+ ) + + const renderAudioSubmenu = () => ( +
+ + {allAudioTracks.map((track, index) => ( + + ))} +
+ ) + + const renderCaptionsSubmenu = () => ( +
+ + + {subtitleTracks.map((track, index) => ( + + ))} +
+ ) + + return ( + <> + + {isOpen && ( + <> + {activeSubmenu === null && renderMainMenu()} + {activeSubmenu === 'speed' && renderSpeedSubmenu()} + {activeSubmenu === 'audio' && renderAudioSubmenu()} + {activeSubmenu === 'captions' && renderCaptionsSubmenu()} + + )} + + ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/time-display.tsx b/frontend/src/pages/movies/movie-player-v2/controls/time-display.tsx new file mode 100644 index 00000000..2ade9a18 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/time-display.tsx @@ -0,0 +1,39 @@ +import { Shade, createComponent } from '@furystack/shades' + +import type { MoviePlayerService } from '../movie-player-service.js' + +type TimeDisplayProps = { + mediaService: MoviePlayerService +} + +const formatTime = (seconds: number): string => { + if (!isFinite(seconds) || seconds < 0) return '0:00' + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + const s = Math.floor(seconds % 60) + const pad = (n: number) => n.toString().padStart(2, '0') + return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}` +} + +export const TimeDisplay = Shade({ + customElementName: 'pirat-player-time-display', + render: ({ props, useObservable }) => { + const [progress] = useObservable('progress', props.mediaService.progress) + const [duration] = useObservable('duration', props.mediaService.duration) + + return ( + + {formatTime(progress)} / {formatTime(duration)} + + ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/video-container.tsx b/frontend/src/pages/movies/movie-player-v2/controls/video-container.tsx new file mode 100644 index 00000000..5c0199a4 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/video-container.tsx @@ -0,0 +1,155 @@ +import type { RefObject } from '@furystack/shades' +import { Shade, createComponent } from '@furystack/shades' + +import type { MoviePlayerService } from '../movie-player-service.js' +import { ControlBar } from './control-bar.js' +import { ErrorOverlay } from './error-overlay.js' +import { LoadingOverlay } from './loading-overlay.js' + +type VideoContainerProps = { + mediaService: MoviePlayerService + playerContainerRef: RefObject +} + +const IDLE_TIMEOUT_MS = 3000 +const SEEK_STEP_SECONDS = 10 + +export const VideoContainer = Shade({ + customElementName: 'pirat-player-video-container', + css: { + display: 'block', + position: 'absolute', + inset: '0', + '& .controls-wrapper': { + position: 'absolute', + bottom: '0', + left: '0', + right: '0', + transition: 'opacity 0.3s ease', + zIndex: '10', + }, + '& .controls-wrapper.hidden': { + opacity: '0', + pointerEvents: 'none', + }, + }, + render: ({ props, useDisposable, useState, useRef }) => { + const containerRef = useRef('container') + const [controlsHidden, setControlsHidden] = useState('controlsHidden', false) + + useDisposable('idleTimer', () => { + let timerId: ReturnType | null = null + + const resetTimer = () => { + setControlsHidden(false) + if (timerId) clearTimeout(timerId) + timerId = setTimeout(() => setControlsHidden(true), IDLE_TIMEOUT_MS) + } + + const onMouseLeave = () => { + setControlsHidden(true) + if (timerId) { + clearTimeout(timerId) + timerId = null + } + } + + const attach = (container: HTMLElement) => { + container.addEventListener('mousemove', resetTimer) + container.addEventListener('mouseleave', onMouseLeave) + container.addEventListener('mouseenter', resetTimer) + resetTimer() + } + + const container = containerRef.current + if (container) { + attach(container) + } else { + const frameId = requestAnimationFrame(() => { + const deferred = containerRef.current + if (deferred) attach(deferred) + }) + return { + [Symbol.dispose]: () => { + cancelAnimationFrame(frameId) + if (timerId) clearTimeout(timerId) + }, + } + } + + return { + [Symbol.dispose]: () => { + if (timerId) clearTimeout(timerId) + if (container) { + container.removeEventListener('mousemove', resetTimer) + container.removeEventListener('mouseleave', onMouseLeave) + container.removeEventListener('mouseenter', resetTimer) + } + }, + } + }) + + useDisposable('keyboardShortcuts', () => { + const attach = (container: HTMLElement) => { + const onKeyDown = (ev: KeyboardEvent) => { + switch (ev.key) { + case ' ': + case 'k': + ev.preventDefault() + props.mediaService.togglePlay() + break + case 'ArrowLeft': + ev.preventDefault() + props.mediaService.seekToTime(Math.max(0, props.mediaService.progress.getValue() - SEEK_STEP_SECONDS)) + break + case 'ArrowRight': + ev.preventDefault() + props.mediaService.seekToTime(props.mediaService.progress.getValue() + SEEK_STEP_SECONDS) + break + case 'm': + case 'M': + ev.preventDefault() + props.mediaService.setMuted(!props.mediaService.isMuted.getValue()) + break + case 'f': + case 'F': + ev.preventDefault() + if (props.playerContainerRef.current) { + props.mediaService.toggleFullscreen(props.playerContainerRef.current) + } + break + default: + break + } + } + container.addEventListener('keydown', onKeyDown) + return { [Symbol.dispose]: () => container.removeEventListener('keydown', onKeyDown) } + } + + const container = containerRef.current + if (container) return attach(container) + + let cleanup: Disposable | null = null + const frameId = requestAnimationFrame(() => { + const deferred = containerRef.current + if (deferred) cleanup = attach(deferred) + }) + return { + [Symbol.dispose]: () => { + cancelAnimationFrame(frameId) + cleanup?.[Symbol.dispose]() + }, + } + }) + + return ( +
+ + +
+ +
+
+ ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/controls/volume-control.tsx b/frontend/src/pages/movies/movie-player-v2/controls/volume-control.tsx new file mode 100644 index 00000000..8d2eae39 --- /dev/null +++ b/frontend/src/pages/movies/movie-player-v2/controls/volume-control.tsx @@ -0,0 +1,72 @@ +import { Shade, createComponent } from '@furystack/shades' +import { Icon } from '@furystack/shades-common-components' + +import type { MoviePlayerService } from '../movie-player-service.js' +import { volumeHighIcon, volumeLowIcon, volumeMuteIcon } from './player-icons.js' + +type VolumeControlProps = { + mediaService: MoviePlayerService +} + +export const VolumeControl = Shade({ + customElementName: 'pirat-player-volume-control', + css: { + display: 'flex', + alignItems: 'center', + gap: '4px', + '& input[type="range"]': { + width: '80px', + cursor: 'pointer', + accentColor: 'white', + }, + }, + render: ({ props, useObservable }) => { + const [isMuted] = useObservable('isMuted', props.mediaService.isMuted) + const [volume] = useObservable('volume', props.mediaService.volume) + + const getVolumeIcon = () => { + if (isMuted || volume === 0) return volumeMuteIcon + if (volume < 0.5) return volumeLowIcon + return volumeHighIcon + } + + return ( + <> + + { + const target = ev.currentTarget as HTMLInputElement + const val = parseFloat(target.value) + if (!isNaN(val)) { + if (val > 0 && isMuted) { + props.mediaService.setMuted(false) + } + props.mediaService.setVolume(val) + } + }} + /> + + ) + }, +}) diff --git a/frontend/src/pages/movies/movie-player-v2/media-chrome.ts b/frontend/src/pages/movies/movie-player-v2/media-chrome.ts deleted file mode 100644 index 16922ef6..00000000 --- a/frontend/src/pages/movies/movie-player-v2/media-chrome.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { PartialElement } from '@furystack/shades' -import 'media-chrome/all' -import type { - MediaAirplayButton, - MediaCaptionsButton, - MediaCaptionsMenu, - MediaCastButton, - MediaChromeButton, - MediaChromeDialog, - MediaChromeMenu, - MediaChromeMenuButton, - MediaChromeMenuItem, - MediaChromeRange, - MediaContainer, - MediaControlBar, - MediaController, - MediaDurationDisplay, - MediaErrorDialog, - MediaFullscreenButton, - MediaGestureReceiver, - MediaLiveButton, - MediaLoadingIndicator, - MediaMuteButton, - MediaPipButton, - MediaPlaybackRateButton, - MediaPlayButton, - MediaPosterImage, - MediaPreviewChapterDisplay, - MediaPreviewThumbnail, - MediaPreviewTimeDisplay, - MediaRenditionMenu, - MediaRenditionMenuButton, - MediaSeekBackwardButton, - MediaSeekForwardButton, - MediaSettingsMenu, - MediaSettingsMenuButton, - MediaSettingsMenuItem, - MediaTextDisplay, - MediaTimeDisplay, - MediaTimeRange, - MediaTooltip, - MediaVolumeRange, -} from 'media-chrome/all' - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace JSX { - interface IntrinsicElements { - 'media-controller': PartialElement - 'media-play-button': PartialElement - 'media-airplay-button': PartialElement - 'media-captions-button': PartialElement - 'media-cast-button': PartialElement - 'media-chrome-button': PartialElement - 'media-chrome-dialog': PartialElement - 'media-chrome-range': PartialElement - 'media-container': PartialElement - 'media-control-bar': PartialElement - 'media-chrome': PartialElement - 'media-duration-display': PartialElement - 'media-error-dialog': PartialElement - 'media-fullscreen-button': PartialElement - 'media-gesture-receiver': PartialElement - 'media-live-button': PartialElement - 'media-loading-indicator': PartialElement - 'media-mute-button': PartialElement - 'media-pip-button': PartialElement - 'media-poster-image': PartialElement - 'media-playback-rate-button': PartialElement - 'media-preview-chapter-display': PartialElement - 'media-preview-thumbnail': PartialElement - 'media-preview-time-display': PartialElement - 'media-seek-backward-button': PartialElement - 'media-seek-forward-button': PartialElement - 'media-text-display': PartialElement - 'media-time-display': PartialElement - 'media-time-range': PartialElement - 'media-tooltip': PartialElement - 'media-volume-range': PartialElement - 'media-chrome-menu': PartialElement - 'media-chrome-menu-button': PartialElement - 'media-chrome-menu-item': PartialElement - 'media-captions-menu': PartialElement - 'media-settings-menu': PartialElement - 'media-settings-menu-button': PartialElement - 'media-settings-menu-item': PartialElement - 'media-rendition-menu': PartialElement - 'media-rendition-menu-button': PartialElement - 'media-audio-track-menu': PartialElement - 'media-playback-rate-menu': PartialElement - } - } -} diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-service.spec.ts b/frontend/src/pages/movies/movie-player-v2/movie-player-service.spec.ts index 339b781c..3ce4df05 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-service.spec.ts +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-service.spec.ts @@ -3,23 +3,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { usingAsync } from '@furystack/utils' import { MoviePlayerService, videoCodecs, audioCodecs } from './movie-player-service.js' -vi.mock('hls.js', () => { - class MockHls { - on = vi.fn() - loadSource = vi.fn() - attachMedia = vi.fn() - destroy = vi.fn() - levels: unknown[] = [] - currentLevel = -1 - } - Object.assign(MockHls, { - isSupported: () => true, - Events: { ERROR: 'hlsError', MANIFEST_PARSED: 'hlsManifestParsed', DESTROYING: 'hlsDestroying' }, - ErrorTypes: { NETWORK_ERROR: 'networkError', MEDIA_ERROR: 'mediaError' }, - }) - return { default: MockHls } -}) - const mockLogger = { verbose: vi.fn().mockResolvedValue(undefined), error: vi.fn().mockResolvedValue(undefined), @@ -84,7 +67,6 @@ describe('MoviePlayerService', () => { expect(service.audioTrackId.getValue()).toBe(0) expect(service.playbackMode.getValue()).toBe('transcode') expect(service.progress.getValue()).toBe(0) - expect(service.resolution.getValue()).toBeUndefined() }, ) }) @@ -236,6 +218,15 @@ describe('MoviePlayerService', () => { src: '', currentTime: 0, canPlayType: vi.fn().mockReturnValue(''), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + volume: 1, + muted: false, + paused: true, + playbackRate: 1, + duration: 0, + buffered: { length: 0, start: vi.fn(), end: vi.fn() }, + textTracks: [], } as unknown as HTMLVideoElement service.attachToVideo(mockVideo) @@ -262,120 +253,6 @@ describe('audioCodecs', () => { }) }) -describe('switchResolution', () => { - let api: ReturnType - - beforeEach(() => { - api = createMockApi() - vi.clearAllMocks() - }) - - it('should force transcode mode when a specific resolution is selected', async () => { - await usingAsync( - new MoviePlayerService(mockFile, mockFfprobe, api as never, 0, mockLogger as never), - async (service) => { - await vi.waitFor(() => { - expect(service.playbackInfo.getValue()).not.toBeNull() - }) - - expect(service.playbackMode.getValue()).toBe('remux') - - await service.switchResolution('720p') - expect(service.resolution.getValue()).toBe('720p') - expect(service.playbackMode.getValue()).toBe('transcode') - }, - ) - }) - - it('should restore original playback mode when switching to Auto', async () => { - await usingAsync( - new MoviePlayerService(mockFile, mockFfprobe, api as never, 0, mockLogger as never), - async (service) => { - await vi.waitFor(() => { - expect(service.playbackInfo.getValue()).not.toBeNull() - }) - - expect(service.playbackMode.getValue()).toBe('remux') - - await service.switchResolution('720p') - expect(service.playbackMode.getValue()).toBe('transcode') - - await service.switchResolution(undefined) - expect(service.resolution.getValue()).toBeUndefined() - expect(service.playbackMode.getValue()).toBe('remux') - }, - ) - }) - - it('should preserve playback progress across resolution switches', async () => { - await usingAsync( - new MoviePlayerService(mockFile, mockFfprobe, api as never, 50, mockLogger as never), - async (service) => { - await vi.waitFor(() => { - expect(service.playbackInfo.getValue()).not.toBeNull() - }) - - const mockVideo = { - currentTime: 75, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - } as unknown as HTMLVideoElement - service.videoElement = mockVideo - - await service.switchResolution('480p') - expect(service.progress.getValue()).toBe(75) - }, - ) - }) - - it('should stay in transcode mode when already transcoding', async () => { - const transcodeResponse: PlaybackInfoResponse = { - ...mockPlaybackInfoResponse, - mode: 'transcode', - } - api.call.mockResolvedValue({ result: transcodeResponse }) - - await usingAsync( - new MoviePlayerService(mockFile, mockFfprobe, api as never, 0, mockLogger as never), - async (service) => { - await vi.waitFor(() => { - expect(service.playbackInfo.getValue()).not.toBeNull() - }) - - expect(service.playbackMode.getValue()).toBe('transcode') - - await service.switchResolution('720p') - expect(service.playbackMode.getValue()).toBe('transcode') - - await service.switchResolution(undefined) - expect(service.playbackMode.getValue()).toBe('transcode') - }, - ) - }) - - it('should call teardown before switching', async () => { - await usingAsync( - new MoviePlayerService(mockFile, mockFfprobe, api as never, 0, mockLogger as never), - async (service) => { - await vi.waitFor(() => { - expect(service.playbackInfo.getValue()).not.toBeNull() - }) - - api.call.mockClear() - - await service.switchResolution('720p') - - expect(api.call).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'DELETE', - action: '/files/:letter/:path/hls-session', - }), - ) - }, - ) - }) -}) - describe('seekToTime', () => { let api: ReturnType @@ -467,29 +344,25 @@ describe('seekToTime', () => { ) }) - it('should restart HLS session when seeking beyond buffered range', async () => { + it('should restart HLS session when seeking backward past hlsStartTime', async () => { await usingAsync( - new MoviePlayerService(mockFile, mockFfprobe, api as never, 0, mockLogger as never), + new MoviePlayerService(mockFile, mockFfprobe, api as never, 3605, mockLogger as never), async (service) => { await vi.waitFor(() => { expect(service.playbackInfo.getValue()).not.toBeNull() }) const mockVideo = { - currentTime: 10, - buffered: { - length: 1, - start: () => 0, - end: () => 30, - }, + currentTime: 5, + src: '', + buffered: { length: 0, start: vi.fn(), end: vi.fn() }, addEventListener: vi.fn(), removeEventListener: vi.fn(), - canPlayType: vi.fn().mockReturnValue(''), } as unknown as HTMLVideoElement service.videoElement = mockVideo api.call.mockClear() - service.seekToTime(3600) + service.seekToTime(100) await vi.waitFor(() => { expect(api.call).toHaveBeenCalledWith( @@ -498,8 +371,30 @@ describe('seekToTime', () => { action: '/files/:letter/:path/hls-session', }), ) - expect(service.progress.getValue()).toBe(3600) + expect(service.progress.getValue()).toBe(100) + }) + }, + ) + }) + + it('should set video.currentTime for forward seeks', async () => { + await usingAsync( + new MoviePlayerService(mockFile, mockFfprobe, api as never, 0, mockLogger as never), + async (service) => { + await vi.waitFor(() => { + expect(service.playbackInfo.getValue()).not.toBeNull() }) + + const mockVideo = { + currentTime: 10, + buffered: { length: 0, start: vi.fn(), end: vi.fn() }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as HTMLVideoElement + service.videoElement = mockVideo + + service.seekToTime(3600) + expect(mockVideo.currentTime).toBe(3600) }, ) }) diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts b/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts index d76e57ee..ff6536a3 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-service.ts @@ -10,7 +10,6 @@ import { type PlaybackMode, type SubtitleTrackInfo, } from 'common' -import Hls from 'hls.js' import type { MediaApiClient } from '../../../services/api-clients/media-api-client.js' import { environmentOptions } from '../../../utils/environment-options.js' @@ -54,8 +53,6 @@ const buildCodecSupportMap = () => { } } -export type ResolutionValue = '4k' | '1080p' | '720p' | '480p' | '360p' - export class MoviePlayerService implements AsyncDisposable { constructor( private readonly file: PiRatFile, @@ -70,18 +67,27 @@ export class MoviePlayerService implements AsyncDisposable { void this.initialize() } - private hls: Hls | null = null - private originalPlaybackMode: PlaybackMode = 'transcode' public isSwitching = new ObservableValue(false) private seekGeneration = 0 private hlsStartTime = 0 + private pendingCanPlayCleanup: (() => void) | null = null public videoElement: HTMLVideoElement | null = null public audioTrackId = new ObservableValue(0) public playbackInfo = new ObservableValue(null) public playbackMode = new ObservableValue('transcode') - public resolution = new ObservableValue(undefined) public progress: ObservableValue + public isPlaying = new ObservableValue(false) + public duration = new ObservableValue(0) + public volume = new ObservableValue(1) + public isMuted = new ObservableValue(false) + public isFullscreen = new ObservableValue(false) + public playbackRate = new ObservableValue(1) + public buffered = new ObservableValue(null) + public activeSubtitleTrack = new ObservableValue(null) + + private videoEventCleanup: Disposable | null = null + /** * Converts a 0-based stream time (from video.currentTime during HLS) * to the absolute file position. For direct-play, the value is returned @@ -105,18 +111,28 @@ export class MoviePlayerService implements AsyncDisposable { this.seekDebounceTimer = null } + if (this.pendingCanPlayCleanup) { + this.pendingCanPlayCleanup() + } + + this.videoEventCleanup?.[Symbol.dispose]() + this.videoEventCleanup = null + await this.teardownHlsSession() this.progress[Symbol.dispose]() - this.resolution[Symbol.dispose]() this.playbackInfo[Symbol.dispose]() this.playbackMode[Symbol.dispose]() this.audioTrackId[Symbol.dispose]() this.isSwitching[Symbol.dispose]() - if (this.hls) { - this.hls.destroy() - this.hls = null - } + this.isPlaying[Symbol.dispose]() + this.duration[Symbol.dispose]() + this.volume[Symbol.dispose]() + this.isMuted[Symbol.dispose]() + this.isFullscreen[Symbol.dispose]() + this.playbackRate[Symbol.dispose]() + this.buffered[Symbol.dispose]() + this.activeSubtitleTrack[Symbol.dispose]() } private async teardownHlsSession() { @@ -139,10 +155,7 @@ export class MoviePlayerService implements AsyncDisposable { } private async initialize() { - const info = await this.fetchPlaybackInfo() - if (info) { - this.originalPlaybackMode = info.mode - } + await this.fetchPlaybackInfo() } public async fetchPlaybackInfo(selectedSubtitleTrackIndex?: number): Promise { @@ -174,10 +187,13 @@ export class MoviePlayerService implements AsyncDisposable { } /** - * Attaches to a video element and starts playback using HLS or direct source + * Attaches to a video element, binds event listeners for observable state, + * and starts playback using native HLS or direct source. */ public attachToVideo(videoElement: HTMLVideoElement) { this.videoElement = videoElement + this.bindVideoEvents(videoElement) + const info = this.playbackInfo.getValue() if (!info) { @@ -194,12 +210,115 @@ export class MoviePlayerService implements AsyncDisposable { this.startPlayback(videoElement, info) } - private startPlayback(videoElement: HTMLVideoElement, info: PlaybackInfoResponse) { - if (this.hls) { - this.hls.destroy() - this.hls = null + private bindVideoEvents(video: HTMLVideoElement) { + const onPlay = () => this.isPlaying.setValue(true) + const onPause = () => this.isPlaying.setValue(false) + const onVolumeChange = () => { + this.volume.setValue(video.volume) + this.isMuted.setValue(video.muted) + } + const updateDuration = () => { + const playbackDuration = this.playbackInfo.getValue()?.duration + if (playbackDuration && playbackDuration > 0) { + this.duration.setValue(playbackDuration) + } else if (video.duration && isFinite(video.duration)) { + this.duration.setValue(video.duration) + } + } + const onDurationChange = updateDuration + const onLoadedMetadata = updateDuration + const onRateChange = () => this.playbackRate.setValue(video.playbackRate) + const onProgress = () => this.buffered.setValue(video.buffered) + const onTimeUpdate = () => { + if (this.isSwitching.getValue()) return + this.progress.setValue(this.streamTimeToAbsolute(video.currentTime) || 0) + } + const onSeeking = () => { + this.seekToTime(this.streamTimeToAbsolute(video.currentTime)) } + const onFullscreenChange = () => { + this.isFullscreen.setValue(!!document.fullscreenElement) + } + + video.addEventListener('play', onPlay) + video.addEventListener('pause', onPause) + video.addEventListener('volumechange', onVolumeChange) + video.addEventListener('durationchange', onDurationChange) + video.addEventListener('loadedmetadata', onLoadedMetadata) + video.addEventListener('ratechange', onRateChange) + video.addEventListener('progress', onProgress) + video.addEventListener('timeupdate', onTimeUpdate) + video.addEventListener('seeking', onSeeking) + document.addEventListener('fullscreenchange', onFullscreenChange) + + const playbackInfoDuration = this.playbackInfo.getValue()?.duration + if (playbackInfoDuration && playbackInfoDuration > 0) { + this.duration.setValue(playbackInfoDuration) + } + + this.videoEventCleanup = { + [Symbol.dispose]: () => { + video.removeEventListener('play', onPlay) + video.removeEventListener('pause', onPause) + video.removeEventListener('volumechange', onVolumeChange) + video.removeEventListener('durationchange', onDurationChange) + video.removeEventListener('loadedmetadata', onLoadedMetadata) + video.removeEventListener('ratechange', onRateChange) + video.removeEventListener('progress', onProgress) + video.removeEventListener('timeupdate', onTimeUpdate) + video.removeEventListener('seeking', onSeeking) + document.removeEventListener('fullscreenchange', onFullscreenChange) + }, + } + } + + public togglePlay() { + const video = this.videoElement + if (!video) return + if (video.paused) { + void video.play().catch(() => {}) + } else { + video.pause() + } + } + public setVolume(value: number) { + if (this.videoElement) { + this.videoElement.volume = Math.max(0, Math.min(1, value)) + } + } + + public setMuted(muted: boolean) { + if (this.videoElement) { + this.videoElement.muted = muted + } + } + + public setPlaybackRate(rate: number) { + if (this.videoElement) { + this.videoElement.playbackRate = rate + } + } + + public toggleFullscreen(container: HTMLElement) { + if (document.fullscreenElement) { + void document.exitFullscreen() + } else { + void container.requestFullscreen() + } + } + + public togglePip() { + const video = this.videoElement + if (!video) return + if (document.pictureInPictureElement) { + void document.exitPictureInPicture() + } else { + void video.requestPictureInPicture() + } + } + + private startPlayback(videoElement: HTMLVideoElement, info: PlaybackInfoResponse) { const mode = this.playbackMode.getValue() if (mode === 'direct-play') { this.startDirectPlayback(videoElement, info) @@ -230,76 +349,11 @@ export class MoviePlayerService implements AsyncDisposable { `/api/media/files/${encodeURIComponent(this.file.driveLetter)}/${encodeURIComponent(this.file.path)}/master.m3u8?mode=${encode(mode)}${audioParam}${startTimeParam}`, ) - // Prefer hls.js over native HLS — many browsers (including Chromium) report - // canPlayType('application/vnd.apple.mpegurl') as 'maybe' without full support. - // hls.js also handles missing alternative renditions more gracefully. - if (!Hls.isSupported()) { - if (videoElement.canPlayType('application/vnd.apple.mpegurl')) { - void this.logger.verbose({ message: 'Using native HLS playback' }) - videoElement.src = hlsUrl - if (this.currentProgress > 0) { - videoElement.currentTime = this.currentProgress - } - return - } - void this.logger.error({ message: 'HLS is not supported in this browser' }) - return + void this.logger.verbose({ message: 'Starting native HLS playback' }) + videoElement.src = hlsUrl + if (this.currentProgress > this.hlsStartTime) { + videoElement.currentTime = this.currentProgress - this.hlsStartTime } - - void this.logger.verbose({ message: 'Starting HLS playback via hls.js' }) - - this.hls = new Hls({ - xhrSetup: (xhr) => { - xhr.withCredentials = true - }, - - startPosition: this.currentProgress > this.hlsStartTime ? this.currentProgress - this.hlsStartTime : -1, - }) - - this.hls.on(Hls.Events.ERROR, (_event, data) => { - if (data.fatal) { - void this.logger.error({ - message: `HLS fatal error: ${data.type}`, - data: { details: data.details }, - }) - - if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { - this.hls?.startLoad() - } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { - this.hls?.recoverMediaError() - } - } - }) - - const { hls } = this - - hls.on(Hls.Events.MANIFEST_PARSED, () => { - void this.logger.verbose({ message: 'HLS manifest parsed' }) - - const resolutionHeightMap: Record = { - '4k': 2160, - '1080p': 1080, - '720p': 720, - '480p': 480, - '360p': 360, - } - - const sub = this.resolution.subscribe((value) => { - if (!value) { - hls.currentLevel = -1 - return - } - const targetHeight = resolutionHeightMap[value] - if (!targetHeight) return - const levelIndex = hls.levels.findIndex((l) => l.height === targetHeight) - hls.currentLevel = levelIndex >= 0 ? levelIndex : -1 - }) - - hls.on(Hls.Events.DESTROYING, () => sub[Symbol.dispose]()) - }) - - this.hls.loadSource(hlsUrl) - this.hls.attachMedia(videoElement) } /** @@ -327,48 +381,13 @@ export class MoviePlayerService implements AsyncDisposable { } } - /** - * Switches resolution and restarts playback. Forces transcode mode when a - * specific resolution is requested; restores the original mode on "Auto". - * - * Always tears down the existing server-side HLS session to ensure the - * old ffmpeg process is cleaned up before the new one starts. - */ - public async switchResolution(value: ResolutionValue | undefined) { - const targetMode = value ? 'transcode' : this.originalPlaybackMode - - const previousProgress = this.getAbsoluteProgress() - - this.isSwitching.setValue(true) - await this.teardownHlsSession() - - this.resolution.setValue(value) - this.currentProgress = previousProgress - this.progress.setValue(previousProgress) - this.hlsStartTime = Math.floor(previousProgress / HLS_SEGMENT_DURATION) * HLS_SEGMENT_DURATION - this.playbackMode.setValue(targetMode) - - if (this.videoElement) { - const info = this.playbackInfo.getValue() - if (info) { - this.startPlayback(this.videoElement, info) - this.waitForCanPlay(this.videoElement) - } else { - this.isSwitching.setValue(false) - } - } else { - this.isSwitching.setValue(false) - } - } - private seekDebounceTimer: ReturnType | null = null /** - * Handles a seek to a new absolute file time position. If the target is - * before the current playlist start, tears down the current session and - * starts a new one with server-side seeking via `-ss`. Forward seeks - * within the current playlist are left to hls.js which can load the - * required segments on its own. + * Handles a seek to a new absolute file time position. For forward seeks + * within the current playlist, sets video.currentTime directly. If the + * target is before the current playlist start, tears down the current + * session and starts a new one with server-side seeking via `-ss`. */ public seekToTime(targetSeconds: number) { const mode = this.playbackMode.getValue() @@ -377,12 +396,13 @@ export class MoviePlayerService implements AsyncDisposable { const video = this.videoElement if (!video) return - // Convert absolute file time to 0-based stream time for the buffer check const streamTime = targetSeconds - this.hlsStartTime if (streamTime >= 0 && this.isTimeBuffered(video, streamTime)) return - // hls.js can handle forward seeks within the current VOD playlist - if (targetSeconds >= this.hlsStartTime) return + if (targetSeconds >= this.hlsStartTime) { + video.currentTime = targetSeconds - this.hlsStartTime + return + } const quantizedStart = Math.floor(targetSeconds / HLS_SEGMENT_DURATION) * HLS_SEGMENT_DURATION @@ -410,35 +430,45 @@ export class MoviePlayerService implements AsyncDisposable { this.isSwitching.setValue(true) const generation = ++this.seekGeneration - await this.teardownHlsSession() - - if (generation !== this.seekGeneration) return + try { + await this.teardownHlsSession() - if (this.hls) { - this.hls.destroy() - this.hls = null - } + if (generation !== this.seekGeneration) return - this.hlsStartTime = quantizedStart - this.currentProgress = targetSeconds - this.progress.setValue(targetSeconds) + this.hlsStartTime = quantizedStart + this.currentProgress = targetSeconds + this.progress.setValue(targetSeconds) - if (this.videoElement) { - this.startHlsPlayback(this.videoElement) - this.waitForCanPlay(this.videoElement) - } else { - this.isSwitching.setValue(false) + if (this.videoElement) { + this.startHlsPlayback(this.videoElement) + this.waitForCanPlay(this.videoElement) + } else { + this.isSwitching.setValue(false) + } + } catch { + if (generation === this.seekGeneration) { + this.isSwitching.setValue(false) + } } } private waitForCanPlay(video: HTMLVideoElement) { + if (this.pendingCanPlayCleanup) { + this.pendingCanPlayCleanup() + } + const onCanPlay = () => { video.removeEventListener('canplay', onCanPlay) + this.pendingCanPlayCleanup = null this.isSwitching.setValue(false) // play() can reject with AbortError when navigation interrupts playback; this is benign void video.play().catch(() => {}) } video.addEventListener('canplay', onCanPlay) + this.pendingCanPlayCleanup = () => { + video.removeEventListener('canplay', onCanPlay) + this.pendingCanPlayCleanup = null + } } public getAudioTrackInfoFromPlaybackInfo(): AudioTrackInfo[] { diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.spec.ts b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.spec.ts index c73e463c..27eb063a 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.spec.ts +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.spec.ts @@ -1,158 +1,17 @@ -import type { AudioTrackInfo, FfprobeData, PlaybackInfoResponse } from 'common' +import type { FfprobeData, PlaybackInfoResponse } from 'common' import { describe, expect, it } from 'vitest' import { getSubtitleTracks, getSubtitleTracksFromPlaybackInfo } from './get-subtitle-tracks.js' /** - * The MoviePlayerV2 component is a Shades component tightly coupled to - * media-chrome custom elements and HTMLVideoElement. Its testable logic - * is exercised here via the data-mapping helpers it uses inline. + * The MoviePlayerV2 component is a Shades component that uses custom + * Shades controls bound to MoviePlayerService observables. Its testable + * logic is exercised here via the data-mapping helpers it uses inline. * * The core playback logic is covered in movie-player-service.spec.ts * and get-subtitle-tracks.spec.tsx. */ -type Rendition = { id: string; width: number; height: number; src: string; selected: boolean } - -const buildRenditionList = (height: number, currentValue?: string) => { - const renditions: Rendition[] = [ - ...(height >= 2160 ? [{ id: '4k', width: 3840, height: 2160, src: '', selected: currentValue === '4k' }] : []), - ...(height >= 1080 - ? [{ id: '1080p', width: 1920, height: 1080, src: '', selected: currentValue === '1080p' }] - : []), - ...(height >= 720 ? [{ id: '720p', width: 1280, height: 720, src: '', selected: currentValue === '720p' }] : []), - ...(height >= 480 ? [{ id: '480p', width: 854, height: 480, src: '', selected: currentValue === '480p' }] : []), - { id: '360p', width: 640, height: 360, src: '', selected: currentValue === '360p' }, - ] - return renditions -} - -const createRenditionList = (items: Rendition[], selectedIndex: number) => { - const target = new EventTarget() - return Object.assign([...items], { - addEventListener: target.addEventListener.bind(target), - removeEventListener: target.removeEventListener.bind(target), - dispatchEvent: target.dispatchEvent.bind(target), - selectedIndex, - }) -} - -const mapAudioTracksForMediaChrome = (audioTracks: AudioTrackInfo[]) => - audioTracks.map((track, index) => ({ - id: track.index.toFixed(0), - label: track.label || track.language || `Audio Track ${index + 1}`, - language: track.language, - enabled: index === 0, - kind: track.label, - })) - describe('MoviePlayerV2 component logic', () => { - describe('buildRenditionList', () => { - it('should include all resolutions for 4K source', () => { - const renditions = buildRenditionList(2160) - expect(renditions).toHaveLength(5) - expect(renditions.map((r) => r.id)).toEqual(['4k', '1080p', '720p', '480p', '360p']) - }) - - it('should exclude resolutions above source height', () => { - const renditions = buildRenditionList(720) - expect(renditions).toHaveLength(3) - expect(renditions.map((r) => r.id)).toEqual(['720p', '480p', '360p']) - }) - - it('should always include 360p', () => { - const renditions = buildRenditionList(240) - expect(renditions).toHaveLength(1) - expect(renditions[0].id).toBe('360p') - }) - - it('should mark the selected rendition', () => { - const renditions = buildRenditionList(1080, '720p') - const selected = renditions.find((r) => r.selected) - expect(selected?.id).toBe('720p') - }) - - it('should not mark any rendition when no value is selected', () => { - const renditions = buildRenditionList(1080) - expect(renditions.every((r) => !r.selected)).toBe(true) - }) - }) - - describe('createRenditionList', () => { - it('should implement EventTarget methods', () => { - const items = buildRenditionList(1080) - const list = createRenditionList(items, -1) - - expect(typeof list.addEventListener).toBe('function') - expect(typeof list.removeEventListener).toBe('function') - expect(typeof list.dispatchEvent).toBe('function') - }) - - it('should expose selectedIndex', () => { - const items = buildRenditionList(1080, '720p') - const selectedIdx = items.findIndex((r) => r.selected) - const list = createRenditionList(items, selectedIdx) - - expect(list.selectedIndex).toBe(1) - }) - - it('should be array-like with rendition items', () => { - const items = buildRenditionList(1080) - const list = createRenditionList(items, -1) - - expect(list).toHaveLength(4) - expect(list[0].id).toBe('1080p') - expect(list[3].id).toBe('360p') - }) - - it('should allow adding and dispatching events', () => { - const items = buildRenditionList(720) - const list = createRenditionList(items, 0) - - let eventFired = false - list.addEventListener('change', () => { - eventFired = true - }) - list.dispatchEvent(new Event('change')) - expect(eventFired).toBe(true) - }) - }) - - describe('mapAudioTracksForMediaChrome', () => { - it('should map audio tracks with correct ids and labels', () => { - const tracks: AudioTrackInfo[] = [ - { index: 1, label: 'English', language: 'eng', codecName: 'aac', channels: 2, isDefault: true }, - { index: 2, label: 'French', language: 'fra', codecName: 'ac3', channels: 6, isDefault: false }, - ] - - const mapped = mapAudioTracksForMediaChrome(tracks) - expect(mapped).toHaveLength(2) - expect(mapped[0].id).toBe('1') - expect(mapped[0].label).toBe('English') - expect(mapped[0].enabled).toBe(true) - expect(mapped[1].id).toBe('2') - expect(mapped[1].label).toBe('French') - expect(mapped[1].enabled).toBe(false) - }) - - it('should fall back to language when label is empty', () => { - const tracks: AudioTrackInfo[] = [ - { index: 1, label: '', language: 'jpn', codecName: 'aac', channels: 2, isDefault: false }, - ] - - const mapped = mapAudioTracksForMediaChrome(tracks) - expect(mapped[0].label).toBe('jpn') - }) - - it('should fall back to generic label when both label and language are empty', () => { - const tracks: AudioTrackInfo[] = [ - { index: 1, label: '', language: '', codecName: 'aac', channels: 2, isDefault: false }, - ] - - const mapped = mapAudioTracksForMediaChrome(tracks) - expect(mapped[0].label).toBe('Audio Track 1') - }) - }) - describe('subtitle source selection', () => { const selectSubtitleElements = ( playbackInfo: PlaybackInfoResponse | null, diff --git a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx index 7ecba37b..a7bdad69 100644 --- a/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx +++ b/frontend/src/pages/movies/movie-player-v2/movie-player-v2-component.tsx @@ -1,25 +1,15 @@ import { getLogger } from '@furystack/logging' import { Shade, createComponent } from '@furystack/shades' import { type FfprobeData, type Movie, type PiRatFile, type WatchHistoryEntry } from 'common' -import type { AudioTrack, Rendition } from 'media-chrome/dist/media-store/state-mediator.js' + import { MediaApiClient } from '../../../services/api-clients/media-api-client.js' import { WatchProgressService } from '../../../services/watch-progress-service.js' import { WatchProgressUpdater } from '../../../services/watch-progress-updater.js' +import { VideoContainer } from './controls/index.js' import { getChaptersTrack } from './get-chapters-track.js' import { getSubtitleTracks, getSubtitleTracksFromPlaybackInfo } from './get-subtitle-tracks.js' -import './media-chrome.js' import { MoviePlayerService } from './movie-player-service.js' -const createRenditionList = (items: Rendition[], selectedIndex: number) => { - const target = new EventTarget() - return Object.assign([...items], { - addEventListener: target.addEventListener.bind(target), - removeEventListener: target.removeEventListener.bind(target), - dispatchEvent: target.dispatchEvent.bind(target), - selectedIndex, - }) -} - type MoviePlayerProps = { file: PiRatFile ffprobe: FfprobeData @@ -31,7 +21,7 @@ export const MoviePlayerV2 = Shade({ customElementName: 'pirat-movie-player-v2', render: ({ props, useDisposable, useObservable, useRef, injector }) => { const videoRef = useRef('video') - const containerRef = useRef('container') + const playerContainerRef = useRef('playerContainer') const { driveLetter, path } = props.file const watchProgressService = injector.getInstance(WatchProgressService) @@ -105,7 +95,7 @@ export const MoviePlayerV2 = Shade({ return (
({ overflow: 'hidden', }} > - - - - - - - - - - - - - - - - - - - - - + {...subtitleElements} + {getChaptersTrack(props.ffprobe)} + +
) }, diff --git a/frontend/src/pages/movies/plain-hls-player.tsx b/frontend/src/pages/movies/plain-hls-player.tsx index 9e5372a5..db7bc6d1 100644 --- a/frontend/src/pages/movies/plain-hls-player.tsx +++ b/frontend/src/pages/movies/plain-hls-player.tsx @@ -1,10 +1,10 @@ import { Shade, createComponent } from '@furystack/shades' -import type Hls from 'hls.js' + import { environmentOptions } from '../../utils/environment-options.js' /** - * Minimal HLS player for debugging — no media-chrome, no MoviePlayerService, - * no watch progress, no audio/subtitle track management. Just hls.js + a