diff --git a/app/Filament/GuestPanel/Resources/DvrRecordings/GuestDvrRecordingResource.php b/app/Filament/GuestPanel/Resources/DvrRecordings/GuestDvrRecordingResource.php index f77dd32cd..6ad5eafef 100644 --- a/app/Filament/GuestPanel/Resources/DvrRecordings/GuestDvrRecordingResource.php +++ b/app/Filament/GuestPanel/Resources/DvrRecordings/GuestDvrRecordingResource.php @@ -168,6 +168,14 @@ public static function getEloquentQuery(): Builder $currentAuth = static::getCurrentPlaylistAuth(); + // A null $currentAuth is only safe to treat as "the playlist owner" + // when isOwnerAuth() confirms it — otherwise (a guest session that + // failed to resolve) ->where('playlist_auth_id', null) would become + // whereNull() and leak the owner's recordings to that guest. + if (! $currentAuth && ! static::isOwnerAuth()) { + return parent::getEloquentQuery()->whereRaw('1 = 0'); + } + return parent::getEloquentQuery() ->with(['channel', 'playlistAuth', 'dvrSetting.playlist', 'dvrSetting.customPlaylist', 'dvrSetting.mergedPlaylist']) ->where('dvr_setting_id', $dvrSetting->id) diff --git a/app/Filament/GuestPanel/Resources/DvrRules/GuestDvrRuleResource.php b/app/Filament/GuestPanel/Resources/DvrRules/GuestDvrRuleResource.php index 590750208..458985da4 100644 --- a/app/Filament/GuestPanel/Resources/DvrRules/GuestDvrRuleResource.php +++ b/app/Filament/GuestPanel/Resources/DvrRules/GuestDvrRuleResource.php @@ -119,6 +119,14 @@ public static function getEloquentQuery(): Builder $currentAuth = static::getCurrentPlaylistAuth(); + // A null $currentAuth is only safe to treat as "the playlist owner" + // when isOwnerAuth() confirms it — otherwise (a guest session that + // failed to resolve) ->where('playlist_auth_id', null) would become + // whereNull() and leak the owner's rules to that guest. + if (! $currentAuth && ! static::isOwnerAuth()) { + return parent::getEloquentQuery()->whereRaw('1 = 0'); + } + return parent::getEloquentQuery() ->with(['channel', 'playlistAuth']) ->where('dvr_setting_id', $dvrSetting->id) diff --git a/app/Filament/GuestPanel/Widgets/GuestScheduledSeriesWidget.php b/app/Filament/GuestPanel/Widgets/GuestScheduledSeriesWidget.php index 980c3f932..966168ba5 100644 --- a/app/Filament/GuestPanel/Widgets/GuestScheduledSeriesWidget.php +++ b/app/Filament/GuestPanel/Widgets/GuestScheduledSeriesWidget.php @@ -28,6 +28,14 @@ public function getSeriesRules(): Collection $currentAuth = static::getCurrentPlaylistAuth(); + // A null $currentAuth is only safe to treat as "the playlist owner" + // when isOwnerAuth() confirms it — otherwise (a guest session that + // failed to resolve) ->where('playlist_auth_id', null) would become + // whereNull() and leak the owner's series rules to that guest. + if (! $currentAuth && ! static::isOwnerAuth()) { + return new Collection; + } + return DvrRecordingRule::with(['channel']) ->where('dvr_setting_id', $dvrSetting->id) ->where('type', DvrRuleType::Series) diff --git a/app/Filament/Resources/MediaServerIntegrations/MediaServerIntegrationResource.php b/app/Filament/Resources/MediaServerIntegrations/MediaServerIntegrationResource.php index 36e60b0cc..e18c91553 100644 --- a/app/Filament/Resources/MediaServerIntegrations/MediaServerIntegrationResource.php +++ b/app/Filament/Resources/MediaServerIntegrations/MediaServerIntegrationResource.php @@ -1695,6 +1695,7 @@ public static function getRelations(): array RelationManagers\SeriesRelationManager::class, RelationManagers\AioStreamsMoviesRelationManager::class, RelationManagers\AioStreamsSeriesRelationManager::class, + RelationManagers\EmbyLibraryMappingsRelationManager::class, ]; } diff --git a/app/Filament/Resources/MediaServerIntegrations/RelationManagers/EmbyLibraryMappingsRelationManager.php b/app/Filament/Resources/MediaServerIntegrations/RelationManagers/EmbyLibraryMappingsRelationManager.php new file mode 100644 index 000000000..4c62d5cea --- /dev/null +++ b/app/Filament/Resources/MediaServerIntegrations/RelationManagers/EmbyLibraryMappingsRelationManager.php @@ -0,0 +1,635 @@ + block — the revision hash and every + * other field are still computed from the complete, untruncated catalog. + */ + private const int PREVIEW_ITEM_LIMIT = 50; + + public function isReadOnly(): bool + { + return false; + } + + public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool + { + $user = auth()->user(); + + return $ownerRecord->isEmby() + && $user?->canUseIntegrations() + && ($user->isAdmin() || $ownerRecord->user_id === $user->id); + } + + public static function getTabComponent(Model $ownerRecord, string $pageClass): Tab + { + return Tab::make(__('Managed Libraries')) + ->badge($ownerRecord->embyLibraryMappings()->count()) + ->icon('heroicon-m-rectangle-stack'); + } + + public function form(Schema $schema): Schema + { + return $schema + ->components([ + Fieldset::make(__('Source')) + ->schema([ + Grid::make(2)->schema([ + Select::make('source_kind') + ->label(__('Source type')) + ->options([ + 'vod_group' => __('VOD group'), + 'series_category' => __('Series category'), + 'custom_playlist_group' => __('Custom playlist group'), + 'all' => __('All eligible items'), + ]) + ->required() + ->live() + ->afterStateUpdated(function (Set $set, ?string $state): void { + $set('source_identifier', $state === 'all' ? '*' : null); + $set('source_label', $state === 'all' ? __('All eligible items') : null); + $set('collection_type', match ($state) { + 'vod_group' => 'movies', + 'series_category' => 'tvshows', + default => null, + }); + }), + Select::make('source_identifier') + ->label(__('Source')) + ->required() + ->searchable() + ->live() + // Async search rather than a static options() list: vod_group and + // series_category can each span thousands of rows across a user's + // playlists, so loading them all upfront doesn't scale. The search + // results (and the option label shown once a value is selected) also + // append the owning playlist's name for vod_group/series_category — + // group/category names collide across playlists constantly, and + // without it there's no way to tell which playlist's "Action" you're + // actually picking. This is presentation-only: the raw, unsuffixed + // name is still what gets written to source_label below, since + // that's matched verbatim against channels.group/categories.name by + // EmbyPublicationCatalogService. + ->getSearchResultsUsing(fn (Get $get, string $search): array => $this->sourceSearchOptions($get('source_kind'), $search)) + ->getOptionLabelUsing(fn (Get $get, ?string $state): ?string => $state === null + ? null + : $this->sourceSearchOptions($get('source_kind'), '', $state)[$state] ?? null) + ->afterStateUpdated(function (Set $set, Get $get, ?string $state): void { + // For custom_playlist_group, sourceOptions() labels are the + // CustomPlaylist's own name — never a valid source_label value + // (that only ever comes from sourceLabelOptions(), which also + // needs collection_type to know which groups are eligible). + // Setting it here would populate "Mapped group" with a value + // that's guaranteed invalid until collection_type is chosen too. + if ($get('source_kind') === 'custom_playlist_group') { + $set('source_label', null); + + return; + } + + $set('source_label', $this->sourceOptions($get('source_kind'))[$state] ?? null); + }), + Select::make('collection_type') + ->label(__('Library type')) + ->options([ + 'movies' => __('Movies'), + 'tvshows' => __('TV shows'), + ]) + ->required() + ->live() + ->afterStateUpdated(function (Set $set, Get $get): void { + // A group/category chosen for one collection type is not + // necessarily valid for the other (sourceLabelOptions() is + // scoped by collection_type — see below), so it can't just + // carry over silently. + if ($get('source_kind') === 'custom_playlist_group') { + $set('source_label', null); + } + }), + Select::make('source_label') + ->label(__('Mapped group')) + ->options(fn (Get $get): array => $this->sourceLabelOptions( + $get('source_kind'), + $get('source_identifier'), + $get('collection_type'), + )) + // Only custom_playlist_group needs a second-level pick here: the + // "Source" select above chose the CustomPlaylist itself, and this + // field is where the specific group/category inside it is chosen — + // it's also the actual value the catalog matches items against for + // that source kind (see EmbyPublicationCatalogService). For every + // other source kind, source_identifier already uniquely identifies + // the group/category, and source_label is auto-populated from it + // (afterStateUpdated above) with the single matching option — so + // it's disabled rather than hidden: still visible for transparency + // and still validated/submitted, just not something the user needs + // to (or can) redundantly re-pick. + ->disabled(fn (Get $get): bool => $get('source_kind') !== 'custom_playlist_group' || ! $get('collection_type')) + ->dehydrated() + ->required() + ->helperText(function (Get $get): string { + if ($get('source_kind') !== 'custom_playlist_group') { + return __('Automatically set from the source selected above.'); + } + + if (! $get('collection_type')) { + return __('Choose a library type first.'); + } + + if ($this->sourceLabelOptions($get('source_kind'), $get('source_identifier'), $get('collection_type')) === []) { + return $get('collection_type') === 'movies' + ? __('This custom playlist has no VOD groups available to publish as movies.') + : __('This custom playlist has no series categories available to publish as TV shows.'); + } + + return __('Choose the specific group or category within the custom playlist to publish.'); + }) + ->searchable(), + ]), + ]) + ->columnSpanFull(), + Fieldset::make(__('Emby library')) + ->schema([ + Grid::make(2)->schema([ + Select::make('target_library_id') + ->label(__('Existing library')) + ->placeholder(__('Create a managed library')) + ->options(fn (): array => $this->libraryOptions()) + ->searchable() + ->live() + ->afterStateUpdated(function (Set $set, ?string $state): void { + if ($state === null) { + return; + } + + $library = collect($this->ownerRecord->available_libraries ?? []) + ->firstWhere('id', $state); + if ($library) { + $set('target_library_name', $library['name'] ?? null); + $set('collection_type', $library['type'] ?? null); + $set('is_managed', false); + } + }), + TextInput::make('target_library_name') + ->label(__('Library name')) + ->required() + ->maxLength(255), + Select::make('output_path') + ->label(__('Companion output path')) + ->options(fn (): array => $this->writablePathOptions()) + ->required() + ->searchable() + ->columnSpanFull() + ->helperText(__('Only paths validated and advertised by m3u-editor for Emby are available.')), + Toggle::make('is_managed') + ->label(__('Create and manage this Emby library')) + ->default(true), + Toggle::make('enabled') + ->label(__('Enabled')) + ->default(true), + ]), + ]) + ->columnSpanFull(), + Fieldset::make(__('Publishing options')) + ->schema([ + Grid::make(2)->schema([ + Select::make('options.naming') + ->label(__('Naming')) + ->options([ + 'media-year' => __('Title and year'), + 'title' => __('Title only'), + ]) + ->default('media-year') + ->required(), + Select::make('options.cleanup') + ->label(__('Cleanup')) + ->options([ + 'replace' => __('Replace stale managed files'), + 'keep' => __('Keep stale managed files'), + 'disabled' => __('Do not clean up files'), + ]) + ->default('replace') + ->required(), + Toggle::make('options.nfo') + ->label(__('Publish local NFO')) + ->default(true), + Toggle::make('options.versions') + ->label(__('Publish visible versions')) + ->default(true), + Toggle::make('options.refresh') + ->label(__('Refresh Emby after successful sync')) + ->columnSpanFull() + ->default(true), + ]), + ]) + ->columnSpanFull(), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('source_label') + ->columns([ + TextColumn::make('source_label') + ->label(__('Source')) + ->searchable() + ->sortable(), + TextColumn::make('target_library_name') + ->label(__('Emby library')) + ->searchable(), + TextColumn::make('collection_type') + ->label(__('Type')) + ->badge(), + TextColumn::make('output_path') + ->label(__('Output path')) + ->limit(40) + ->tooltip(fn (EmbyLibraryMapping $record): string => $record->output_path), + ToggleColumn::make('enabled') + ->label(__('Enabled')), + TextColumn::make('status') + ->label(__('Status')) + ->badge() + ->color(fn (string $state): string => match ($state) { + 'synced' => 'success', + 'failed', 'drifted' => 'danger', + 'pending', 'planned' => 'warning', + default => 'gray', + }), + TextColumn::make('last_applied_revision') + ->label(__('Applied revision')) + ->limit(12) + ->copyable() + ->placeholder(__('Not applied')), + TextColumn::make('last_success_at') + ->label(__('Last success')) + ->since() + ->placeholder(__('Never')), + IconColumn::make('is_managed') + ->label(__('Managed')) + ->boolean(), + TextColumn::make('error_summary') + ->label(__('Last error')) + ->limit(50) + ->placeholder(__('None')), + ]) + ->filters([]) + ->headerActions([ + Action::make('download_m3u_emby_plugin') + ->label(__('Get m3u-editor for Emby')) + ->icon('heroicon-o-arrow-top-right-on-square') + ->color('gray') + ->url('https://github.com/Serph91P/m3u-editor-for-emby') + ->openUrlInNewTab(), + CreateAction::make() + ->label(__('Create mapping')) + ->mutateDataUsing(fn (array $data): array => [ + ...$data, + 'media_server_integration_id' => $this->ownerRecord->id, + 'user_id' => $this->ownerRecord->user_id, + 'status' => 'idle', + ]) + ->slideOver(), + ]) + ->recordActions([ + DeleteAction::make() + ->button() + ->size('sm') + ->hiddenLabel(), + EditAction::make() + ->button() + ->size('sm') + ->hiddenLabel() + ->slideOver(), + Action::make('reconcile') + ->label(__('Reconcile')) + ->icon('heroicon-o-arrow-path') + ->requiresConfirmation() + ->button() + ->size('sm') + ->hiddenLabel() + ->action(fn (EmbyLibraryMapping $record) => $this->reconcile($record)), + Action::make('preview') + ->label(__('Preview')) + ->icon('heroicon-o-eye') + ->modalHeading(__('Catalog plan preview')) + ->modalWidth('4xl') + ->modalContent(function (EmbyLibraryMapping $record) { + $catalog = app(EmbyPublicationCatalogService::class)->buildMapping($record); + $itemsTotal = count($catalog['items']); + + // The full catalog (all $itemsTotal items) is still what gets hashed + // into 'revision' above and what actually gets synced — only the + // rendered JSON is capped, since a large library's full item list can + // be large enough to crash the browser rendering it into the DOM. + if ($itemsTotal > self::PREVIEW_ITEM_LIMIT) { + $catalog['items'] = array_slice($catalog['items'], 0, self::PREVIEW_ITEM_LIMIT); + } + + return view( + 'filament.resources.media-server-integrations.relation-managers.emby-library-mapping-preview', + [ + 'catalog' => $catalog, + 'itemsTotal' => $itemsTotal, + 'itemsShown' => count($catalog['items']), + ], + ); + }) + ->button() + ->size('sm') + ->hiddenLabel() + ->slideOver() + ->modalSubmitAction(false), + ], position: RecordActionsPosition::BeforeCells) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + /** @return array */ + private function sourceOptions(?string $sourceKind): array + { + if ($sourceKind === 'all') { + return ['*' => __('All eligible items')]; + } + + $query = match ($sourceKind) { + 'vod_group' => Group::query() + ->where('user_id', $this->ownerRecord->user_id) + ->where('type', 'vod') + ->select(['id', 'name']), + 'series_category' => Category::query() + ->where('user_id', $this->ownerRecord->user_id) + ->select(['id', 'name']), + 'custom_playlist_group' => CustomPlaylist::query() + ->where('user_id', $this->ownerRecord->user_id) + ->select(['id', 'name']), + default => null, + }; + + if ($query === null) { + return []; + } + + $options = []; + foreach ($query->orderBy('name')->cursor() as $record) { + $options[(string) $record->id] = $record->name; + } + + return $options; + } + + /** + * Options for the "Source" select's async search (and, via + * $onlyIdentifier, for resolving the label of an already-selected + * value). Unlike sourceOptions(), labels for vod_group/series_category + * are suffixed with the owning playlist's name — group/category names + * routinely collide across a user's playlists, and this is the only + * field where that ambiguity matters, so the suffix lives here rather + * than in sourceOptions() (whose plain names still back source_label, + * which EmbyPublicationCatalogService matches verbatim against + * channels.group / categories.name). + * + * @return array + */ + private function sourceSearchOptions(?string $sourceKind, string $search, ?string $onlyIdentifier = null): array + { + if ($sourceKind === 'all') { + return ['*' => __('All eligible items')]; + } + + if ($sourceKind === 'custom_playlist_group') { + $query = CustomPlaylist::query()->where('user_id', $this->ownerRecord->user_id); + + if ($onlyIdentifier !== null) { + $query->whereKey($onlyIdentifier); + } elseif ($search !== '') { + $query->whereRaw('LOWER(name) LIKE ?', ['%'.strtolower($search).'%']); + } + + $options = []; + foreach ($query->orderBy('name')->limit(50)->cursor() as $record) { + $options[(string) $record->id] = $record->name; + } + + return $options; + } + + $model = match ($sourceKind) { + 'vod_group' => Group::class, + 'series_category' => Category::class, + default => null, + }; + + if ($model === null) { + return []; + } + + $query = $model::query() + ->where('user_id', $this->ownerRecord->user_id) + ->with('playlist:id,name') + ->when($sourceKind === 'vod_group', fn ($q) => $q->where('type', 'vod')); + + if ($onlyIdentifier !== null) { + $query->whereKey($onlyIdentifier); + } elseif ($search !== '') { + $searchLower = strtolower($search); + $query->where(function ($inner) use ($searchLower): void { + $inner->whereRaw('LOWER(name) LIKE ?', ["%{$searchLower}%"]) + ->orWhereHas('playlist', fn ($p) => $p->whereRaw('LOWER(name) LIKE ?', ["%{$searchLower}%"])); + }); + } + + $options = []; + foreach ($query->orderBy('name')->limit(50)->get() as $record) { + $options[(string) $record->id] = $record->playlist?->name + ? "{$record->name} ({$record->playlist->name})" + : $record->name; + } + + return $options; + } + + /** @return array */ + private function sourceLabelOptions(?string $sourceKind, ?string $sourceIdentifier, ?string $collectionType): array + { + if ($sourceKind !== 'custom_playlist_group') { + $label = $this->sourceOptions($sourceKind)[$sourceIdentifier] ?? null; + + return $label === null ? [] : [$label => $label]; + } + + if (! in_array($collectionType, EmbyLibraryMapping::COLLECTION_TYPES, true)) { + return []; + } + + $customPlaylist = CustomPlaylist::query() + ->where('user_id', $this->ownerRecord->user_id) + ->find($sourceIdentifier); + if (! $customPlaylist) { + return []; + } + + // Scoped by collection_type rather than unioning both: movies are + // matched against VOD-channel groups and tvshows against series + // categories (see EmbyPublicationCatalogService::buildMovies()/ + // buildSeries()), so a name valid for one is never a valid match for + // the other — offering both together let you pick an option that + // silently matched nothing at publish time. + $groups = $collectionType === 'movies' + ? $customPlaylist->filterableGroupsQuery(isVod: true) + : $customPlaylist->filterableCategoriesQuery(); + + $options = []; + foreach ($groups->orderBy('name')->cursor() as $group) { + $options[$group->name] = $group->name; + } + + return $options; + } + + /** @return array */ + private function libraryOptions(): array + { + return collect($this->ownerRecord->available_libraries ?? []) + ->filter(fn (array $library): bool => in_array($library['type'] ?? null, ['movies', 'tvshows'], true)) + ->mapWithKeys(fn (array $library): array => [ + (string) $library['id'] => ($library['name'] ?? __('Unnamed library')).' ('.($library['type'] ?? '').')', + ]) + ->all(); + } + + /** @return array */ + private function writablePathOptions(): array + { + return array_combine( + $this->ownerRecord->getEmbyPublisherWritablePaths(), + $this->ownerRecord->getEmbyPublisherWritablePaths(), + ) ?: []; + } + + private function reconcile(EmbyLibraryMapping $mapping): void + { + $result = MediaServerService::make($this->ownerRecord)->createLibrary( + $mapping->target_library_name, + $mapping->collection_type, + [$mapping->output_path], + false, + $mapping->target_library_id, + ); + + if (! $result['success']) { + $error = EmbyLibraryMapping::redactSummary($result['message']); + $mapping->updateQuietly([ + 'status' => 'failed', + 'status_summary' => __('Reconcile failed.'), + 'error_summary' => $error, + ]); + Notification::make() + ->danger() + ->title(__('Managed library reconcile failed')) + ->body($error) + ->send(); + + return; + } + + $targetLibraryId = $result['library']['id'] ?? $mapping->target_library_id; + if ($targetLibraryId !== $mapping->target_library_id) { + $mapping->updateQuietly(['target_library_id' => $targetLibraryId]); + $mapping->refresh(); + } + + if ($targetLibraryId === null) { + $mapping->updateQuietly([ + 'last_planned_revision' => null, + 'status' => 'pending', + 'status_summary' => __('Pending'), + 'error_summary' => null, + ]); + Notification::make() + ->warning() + ->title(__('Pending')) + ->send(); + + return; + } + + // Emby's VirtualFolders API found the library by ID, but its name, + // type, or paths no longer match this mapping — most likely someone + // edited the mapping (or the library itself) after they were last in + // sync. We don't auto-correct Emby's config here (renaming/moving a + // library's paths can be destructive), so surface it clearly instead + // of reporting a silent "planned" success. + if ($result['drift'] ?? false) { + $mapping->updateQuietly([ + 'status' => 'drifted', + 'status_summary' => __('Emby library configuration differs from this mapping.'), + 'error_summary' => null, + ]); + Notification::make() + ->warning() + ->title(__('Managed library configuration drifted')) + ->body(__('The existing Emby library\'s name, type, or paths no longer match this mapping. Update it manually in Emby, or delete it there to let reconcile recreate it.')) + ->send(); + + return; + } + + $catalog = app(EmbyPublicationCatalogService::class)->buildMapping($mapping); + $mapping->updateQuietly([ + 'last_planned_revision' => $catalog['revision'], + 'status' => 'planned', + 'status_summary' => __('Revision planned for companion sync.'), + 'error_summary' => null, + ]); + Notification::make() + ->success() + ->title(__('Managed library plan updated')) + ->body(Str::limit($catalog['revision'], 12, '')) + ->send(); + } +} diff --git a/app/Filament/Resources/PlaylistAuths/PlaylistAuthResource.php b/app/Filament/Resources/PlaylistAuths/PlaylistAuthResource.php index 1ed49b92c..999e5e5e1 100644 --- a/app/Filament/Resources/PlaylistAuths/PlaylistAuthResource.php +++ b/app/Filament/Resources/PlaylistAuths/PlaylistAuthResource.php @@ -301,6 +301,20 @@ public static function getForm(): array ->collapsible() ->collapsed(fn ($record) => ! ($record?->aiostreams_enabled)); + $libraryPublishingSection = Section::make(__('Library Publishing Access')) + ->description(__('Allow this credential to read managed Emby publishing catalogs and report sync results.')) + ->compact() + ->hidden(fn () => ! (auth()->user()?->canUseIntegrations() ?? false)) + ->schema([ + Toggle::make('library_publishing_enabled') + ->label(__('Enable Library Publishing')) + ->default(false) + ->columnSpan(2), + ]) + ->columns(2) + ->collapsible() + ->collapsed(fn ($record) => ! ($record?->library_publishing_enabled)); + return [ Grid::make() ->schema([ @@ -475,6 +489,7 @@ public static function getForm(): array $requestsSection, $dvrSection, $aiostreamsSection, + $libraryPublishingSection, ]; } } diff --git a/app/Http/Controllers/XtreamApiController.php b/app/Http/Controllers/XtreamApiController.php index 592c05b8a..b7473075a 100644 --- a/app/Http/Controllers/XtreamApiController.php +++ b/app/Http/Controllers/XtreamApiController.php @@ -11,15 +11,18 @@ use App\Events\ViewerFavoriteEvent; use App\Facades\PlaylistFacade; use App\Facades\ProxyFacade; +use App\Jobs\RefreshMediaServerLibraryJob; use App\Models\ArrIntegration; use App\Models\Category; use App\Models\Channel; use App\Models\CustomPlaylist; use App\Models\DvrRecording; use App\Models\DvrRecordingRule; +use App\Models\EmbyLibraryMapping; use App\Models\Epg; use App\Models\EpgProgramme; use App\Models\Group; +use App\Models\MediaServerIntegration; use App\Models\MergedPlaylist; use App\Models\Network; use App\Models\NetworkProgramme; @@ -34,6 +37,7 @@ use App\Providers\VersionServiceProvider; use App\Services\ContentRequestService; use App\Services\DvrRecorderService; +use App\Services\EmbyPublicationCatalogService; use App\Services\EpgCacheService; use App\Services\LogoCacheService; use App\Services\M3uProxyService; @@ -45,7 +49,9 @@ use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Redirect; use Illuminate\Support\Facades\Validator; @@ -712,6 +718,25 @@ public function handle(Request $request) $m3uEditorPayload['proxy'] = $proxyData; } + if ($this->canAdvertiseLibraryPublishing($playlist, $authMethod, $playlistAuth)) { + $m3uEditorPayload['library_publishing'] = [ + 'api_version' => 1, + 'actions' => [ + 'register_publisher' => 'm3u_editor_register_publisher', + 'catalog' => 'm3u_editor_catalog', + 'sync_result' => 'm3u_editor_sync_result', + ], + 'snapshot_mode' => 'full', + 'features' => [ + 'library_mappings', + 'variants', + 'provider_failover', + 'local_nfo', + 'revision_metadata', + ], + ]; + } + $payload['m3u_editor'] = $m3uEditorPayload; } @@ -2078,6 +2103,12 @@ public function handle(Request $request) return $this->requestStatus($request, $playlist, $authMethod, $playlistAuth); } elseif ($action === 'request_dismiss') { return $this->dismissRequest($request, $playlist, $authMethod, $playlistAuth); + } elseif ($action === 'm3u_editor_register_publisher') { + return $this->registerManagedLibraryPublisher($request, $playlist, $authMethod, $playlistAuth); + } elseif ($action === 'm3u_editor_catalog') { + return $this->managedLibraryCatalog($request, $playlist, $authMethod, $playlistAuth); + } elseif ($action === 'm3u_editor_sync_result') { + return $this->managedLibrarySyncResult($request, $playlist, $authMethod, $playlistAuth); } elseif (in_array($action, self::DVR_ACTIONS, true)) { $dvrPlaylist = $this->resolveDvrPlaylist($playlist); if (! $dvrPlaylist) { @@ -3091,6 +3122,260 @@ private function resolveM3uEditorFeatures($playlist, string $authMethod, ?Playli return $features; } + private function canAdvertiseLibraryPublishing( + mixed $playlist, + string $authMethod, + ?PlaylistAuth $playlistAuth, + ): bool { + $effectivePlaylist = $this->resolveEffectivePlaylist($playlist); + + return $effectivePlaylist !== null + && $this->libraryPublishingAuthorized($effectivePlaylist, $authMethod, $playlistAuth); + } + + /** + * Core authorization check for the managed-library-publishing protocol, + * shared by canAdvertiseLibraryPublishing() (raw $playlist, used for the + * info-response feature flag) and the three action handlers below (which + * already hold an $effectivePlaylist and must not re-resolve it). + */ + private function libraryPublishingAuthorized( + Playlist|CustomPlaylist|MergedPlaylist $effectivePlaylist, + string $authMethod, + ?PlaylistAuth $playlistAuth, + ): bool { + if (! $effectivePlaylist->user?->canUseIntegrations()) { + return false; + } + + if ($authMethod === 'playlist_auth' && ! $playlistAuth?->library_publishing_enabled) { + return false; + } + + if (! in_array($authMethod, ['owner_auth', 'playlist_auth'], true)) { + return false; + } + + return MediaServerIntegration::query() + ->where('user_id', $effectivePlaylist->user_id) + ->where('type', 'emby') + ->where('enabled', true) + ->exists(); + } + + /** + * Build the standard 400/422 error response for a failed api_version- + * gated validator, shared by the three managed-library-publishing action + * handlers below. + */ + private function apiVersionValidationError(Request $request): JsonResponse + { + $code = $request->integer('api_version') !== 1 + ? 'unsupported_api_version' + : 'invalid_request'; + + return $this->requestError( + $code, + $code === 'unsupported_api_version' + ? 'The requested API version is not supported.' + : 'The request parameters are invalid.', + $code === 'unsupported_api_version' ? 400 : 422, + ); + } + + private function registerManagedLibraryPublisher( + Request $request, + mixed $playlist, + string $authMethod, + ?PlaylistAuth $playlistAuth, + ): JsonResponse { + $effectivePlaylist = $this->resolveEffectivePlaylist($playlist); + if (! $effectivePlaylist || ! $this->libraryPublishingAuthorized($effectivePlaylist, $authMethod, $playlistAuth)) { + return $this->requestError( + 'library_publishing_unavailable', + 'Managed library publishing is not available for these credentials.', + 403, + ); + } + + $input = $request->all(); + if (is_array($input['writable_paths'] ?? null)) { + $input['writable_paths'] = array_map( + fn (mixed $path): mixed => is_string($path) ? trim($path) : $path, + $input['writable_paths'], + ); + } + + $validator = Validator::make($input, [ + 'api_version' => ['required', 'integer', 'in:1'], + 'integration_id' => ['required', 'integer', 'min:1'], + 'writable_paths' => ['required', 'array', 'list', 'min:1', 'max:50'], + 'writable_paths.*' => [ + 'required', + 'string', + 'distinct:strict', + function (string $attribute, mixed $value, \Closure $fail): void { + if (! is_string($value) || ! MediaServerIntegration::isSafeWritablePath($value)) { + $fail('The :attribute must be a valid absolute path.'); + } + }, + ], + ]); + if ($validator->fails()) { + return $this->apiVersionValidationError($request); + } + + $validated = $validator->validated(); + $integration = MediaServerIntegration::query() + ->whereKey($validated['integration_id']) + ->where('user_id', $effectivePlaylist->user_id) + ->where('type', 'emby') + ->where('enabled', true) + ->first(); + if (! $integration) { + return $this->requestError('integration_not_found', 'The Emby integration was not found.', 404); + } + + $integration->updateQuietly([ + 'emby_publisher_writable_paths' => $validated['writable_paths'], + 'emby_publisher_capabilities_updated_at' => now(), + ]); + + return $this->requestSuccess([ + 'integration_id' => $integration->id, + 'writable_paths' => $integration->getEmbyPublisherWritablePaths(), + ]); + } + + private function managedLibraryCatalog( + Request $request, + mixed $playlist, + string $authMethod, + ?PlaylistAuth $playlistAuth, + ): JsonResponse { + $effectivePlaylist = $this->resolveEffectivePlaylist($playlist); + if (! $effectivePlaylist || ! $this->libraryPublishingAuthorized($effectivePlaylist, $authMethod, $playlistAuth)) { + return $this->requestError( + 'library_publishing_unavailable', + 'Managed library publishing is not available for these credentials.', + 403, + ); + } + + $validator = Validator::make($request->all(), [ + 'api_version' => ['required', 'integer', 'in:1'], + ]); + if ($validator->fails()) { + return $this->apiVersionValidationError($request); + } + + return response()->json(app(EmbyPublicationCatalogService::class)->buildForUser($effectivePlaylist->user)); + } + + private function managedLibrarySyncResult( + Request $request, + mixed $playlist, + string $authMethod, + ?PlaylistAuth $playlistAuth, + ): JsonResponse { + $effectivePlaylist = $this->resolveEffectivePlaylist($playlist); + if (! $effectivePlaylist || ! $this->libraryPublishingAuthorized($effectivePlaylist, $authMethod, $playlistAuth)) { + return $this->requestError( + 'library_publishing_unavailable', + 'Managed library publishing is not available for these credentials.', + 403, + ); + } + + $validator = Validator::make($request->all(), [ + 'api_version' => ['required', 'integer', 'in:1'], + 'integration_id' => ['required', 'integer', 'min:1'], + 'mapping_uuid' => ['required', 'uuid'], + 'revision' => ['required', 'string', 'regex:/^[a-f0-9]{64}$/'], + 'status' => ['required', 'string', 'in:success,failed'], + 'summary' => ['nullable', 'string', 'max:2000'], + 'error' => ['nullable', 'string', 'max:2000'], + ]); + if ($validator->fails()) { + return $this->apiVersionValidationError($request); + } + + $validated = $validator->validated(); + $result = DB::transaction(function () use ($effectivePlaylist, $validated): array { + $mapping = EmbyLibraryMapping::query() + ->with('integration') + ->where('user_id', $effectivePlaylist->user_id) + ->where('media_server_integration_id', $validated['integration_id']) + ->where('uuid', $validated['mapping_uuid']) + ->where('enabled', true) + ->lockForUpdate() + ->first(); + + if (! $mapping) { + return ['error' => 'mapping_not_found']; + } + + if (! hash_equals((string) $mapping->last_planned_revision, $validated['revision'])) { + return ['error' => 'stale_revision']; + } + + if ($validated['status'] !== 'success') { + $mapping->updateQuietly([ + 'status' => 'failed', + 'status_summary' => EmbyLibraryMapping::redactSummary($validated['summary'] ?? null), + 'error_summary' => EmbyLibraryMapping::redactSummary($validated['error'] ?? null), + ]); + + return ['error' => 'sync_failed']; + } + + if ($mapping->last_applied_revision === $validated['revision']) { + return [ + 'mapping' => $mapping, + 'duplicate' => true, + 'refresh' => false, + ]; + } + + $mapping->updateQuietly([ + 'last_applied_revision' => $validated['revision'], + 'last_success_at' => now(), + 'status' => 'synced', + 'status_summary' => EmbyLibraryMapping::redactSummary($validated['summary'] ?? null) + ?? 'Revision applied.', + 'error_summary' => null, + ]); + + return [ + 'mapping' => $mapping, + 'duplicate' => false, + 'refresh' => (bool) ($mapping->options['refresh'] ?? true), + ]; + }); + + if (isset($result['error'])) { + return match ($result['error']) { + 'mapping_not_found' => $this->requestError('mapping_not_found', 'The mapping was not found.', 404), + 'stale_revision' => $this->requestError('stale_revision', 'The reported revision is not current.', 409), + default => $this->requestError('sync_failed', 'The companion reported a failed sync.', 422), + }; + } + + if ($result['refresh']) { + Bus::dispatch(new RefreshMediaServerLibraryJob( + $result['mapping']->integration, + notify: false, + )); + } + + return $this->requestSuccess([ + 'applied' => true, + 'duplicate' => $result['duplicate'], + 'mapping_uuid' => $result['mapping']->uuid, + 'revision' => $result['mapping']->last_applied_revision, + ]); + } + private function hasAIOStreams($playlist, string $authMethod, ?PlaylistAuth $playlistAuth): bool { $effectivePlaylist = $this->resolveEffectivePlaylist($playlist); diff --git a/app/Interfaces/MediaServer.php b/app/Interfaces/MediaServer.php index 435117909..505dc9f46 100644 --- a/app/Interfaces/MediaServer.php +++ b/app/Interfaces/MediaServer.php @@ -20,6 +20,18 @@ public function testConnection(): array; */ public function fetchLibraries(): Collection; + /** + * @param list $paths + * @return array{success: bool, created: bool, message: string, library: array|null, drift: bool} + */ + public function createLibrary( + string $name, + string $collectionType, + array $paths, + bool $refreshLibrary = true, + ?string $libraryId = null, + ): array; + public function fetchMovies(): Collection; public function fetchSeries(): Collection; diff --git a/app/Models/EmbyLibraryMapping.php b/app/Models/EmbyLibraryMapping.php new file mode 100644 index 000000000..4e1ea1d9c --- /dev/null +++ b/app/Models/EmbyLibraryMapping.php @@ -0,0 +1,129 @@ + */ + use HasFactory; + + public const COLLECTION_TYPES = ['movies', 'tvshows']; + + public const SOURCE_KINDS = ['vod_group', 'series_category', 'custom_playlist_group', 'all']; + + protected $fillable = [ + 'media_server_integration_id', + 'user_id', + 'enabled', + 'source_kind', + 'source_identifier', + 'source_label', + 'target_library_id', + 'target_library_name', + 'collection_type', + 'output_path', + 'is_managed', + 'options', + 'last_planned_revision', + 'last_applied_revision', + 'last_success_at', + 'status', + 'status_summary', + 'error_summary', + ]; + + protected $attributes = [ + 'enabled' => true, + 'is_managed' => false, + 'options' => '[]', + 'status' => 'idle', + ]; + + protected static function booted(): void + { + static::saving(function (EmbyLibraryMapping $mapping): void { + Validator::make(['options' => $mapping->options], [ + 'options' => ['array:naming,nfo,versions,cleanup,refresh', 'max:5'], + 'options.naming' => ['sometimes', 'string', 'max:64'], + 'options.nfo' => ['sometimes', 'boolean'], + 'options.versions' => ['sometimes', 'boolean'], + 'options.cleanup' => ['sometimes', 'in:replace,keep,disabled'], + 'options.refresh' => ['sometimes', 'boolean'], + ])->validate(); + + if (! in_array($mapping->source_kind, self::SOURCE_KINDS, true)) { + throw ValidationException::withMessages([ + 'source_kind' => trans('validation.in', ['attribute' => 'source kind']), + ]); + } + + if (! in_array($mapping->collection_type, self::COLLECTION_TYPES, true)) { + throw ValidationException::withMessages([ + 'collection_type' => trans('validation.in', ['attribute' => 'collection type']), + ]); + } + + $isOwnedIntegration = MediaServerIntegration::query() + ->whereKey($mapping->media_server_integration_id) + ->where('user_id', $mapping->user_id) + ->exists(); + + if (! $isOwnedIntegration) { + throw ValidationException::withMessages([ + 'media_server_integration_id' => trans('validation.exists', [ + 'attribute' => 'media server integration', + ]), + ]); + } + }); + + static::creating(function (EmbyLibraryMapping $mapping): void { + $mapping->uuid ??= Str::orderedUuid()->toString(); + }); + } + + protected function casts(): array + { + return [ + 'enabled' => 'boolean', + 'is_managed' => 'boolean', + 'options' => 'array', + 'last_success_at' => 'datetime', + ]; + } + + public function integration(): BelongsTo + { + return $this->belongsTo(MediaServerIntegration::class, 'media_server_integration_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public static function redactSummary(?string $summary): ?string + { + if ($summary === null || trim($summary) === '') { + return null; + } + + $summary = preg_replace('#https?://[^\s]+#i', '[redacted-url]', $summary) ?? ''; + $summary = preg_replace( + '/\b(api[_-]?key|token|password|secret)\b\s*[:=]\s*[^\s,;]+/i', + '$1=[redacted]', + $summary, + ) ?? ''; + $summary = preg_replace('/[\x00-\x1F\x7F]+/', ' ', $summary) ?? ''; + + return Str::limit(trim($summary), 500, ''); + } +} diff --git a/app/Models/MediaServerIntegration.php b/app/Models/MediaServerIntegration.php index 12f0ae389..e850aeadb 100644 --- a/app/Models/MediaServerIntegration.php +++ b/app/Models/MediaServerIntegration.php @@ -78,6 +78,8 @@ class MediaServerIntegration extends Model 'aiostreams_catalogs' => 'array', 'aiostreams_enable_all_catalogs' => 'boolean', 'aiostreams_selected_catalog_ids' => 'array', + 'emby_publisher_writable_paths' => 'array', + 'emby_publisher_capabilities_updated_at' => 'datetime', ]; /** @@ -99,6 +101,11 @@ public function user(): BelongsTo return $this->belongsTo(User::class); } + public function embyLibraryMappings(): HasMany + { + return $this->hasMany(EmbyLibraryMapping::class); + } + /** * Get the playlist associated with this integration. * Content synced from the media server is stored in this playlist. @@ -198,6 +205,57 @@ public function isEmby(): bool return $this->type === 'emby'; } + /** + * Whether a string is safe to store/advertise as an Emby publisher + * writable path: an absolute path (Unix, Windows drive, or UNC), within + * length/byte bounds, and free of ".." traversal segments. + * + * These paths live on the companion app's (Emby) host, never on + * m3u-editor's own filesystem, so there is no local root to resolve + * against via realpath() — this is a shape/sanity check, not a + * filesystem-boundary check. Shared by this model's own getter (paths + * already stored) and XtreamApiController::registerManagedLibraryPublisher() + * (paths a companion app is registering), so both enforce identical rules. + */ + public static function isSafeWritablePath(string $path): bool + { + if ($path === '' || strlen($path) > 1024 || str_contains($path, "\0")) { + return false; + } + + if (preg_match('/^(?:\/|[A-Za-z]:[\\\\\/]|\\\\\\\\)/', $path) !== 1) { + return false; + } + + $segments = preg_split('/[\/\\\\]+/', $path); + + return ! in_array('..', $segments, true); + } + + /** + * @return list + */ + public function getEmbyPublisherWritablePaths(): array + { + $paths = []; + + foreach (array_slice($this->emby_publisher_writable_paths ?? [], 0, 50) as $path) { + if (! is_string($path)) { + continue; + } + + $path = trim($path); + + if (! static::isSafeWritablePath($path)) { + continue; + } + + $paths[$path] = $path; + } + + return array_values($paths); + } + /** * Check if this is a Jellyfin server. */ @@ -361,6 +419,50 @@ public function getSelectedLibraryIdsForType(string $type): array ->toArray(); } + /** + * Return null when imports are intentionally unfiltered, or a safe list that excludes + * libraries generated by managed publishing. + * + * @return array|null + */ + public function getImportLibraryIdsForType(string $type): ?array + { + $managedMappings = $this->embyLibraryMappings() + ->where('is_managed', true) + ->where('collection_type', $type) + ->where(fn (Builder $query): Builder => $query + ->whereNotNull('target_library_id') + ->orWhere('enabled', true)) + ->select(['id', 'target_library_id']) + ->cursor(); + + $managedLibraryIds = []; + $requiresFilteredImport = false; + foreach ($managedMappings as $mapping) { + $requiresFilteredImport = true; + if ($mapping->target_library_id !== null) { + $managedLibraryIds[] = $mapping->target_library_id; + } + } + + $selectedLibraryIds = $this->getSelectedLibraryIdsForType($type); + if ($selectedLibraryIds !== []) { + return array_values(array_diff($selectedLibraryIds, $managedLibraryIds)); + } + + if (! $requiresFilteredImport) { + return null; + } + + return collect($this->available_libraries ?? []) + ->filter(fn (array $library): bool => ($library['type'] ?? null) === $type + && ! in_array($library['id'] ?? null, $managedLibraryIds, true)) + ->pluck('id') + ->filter() + ->values() + ->all(); + } + /** * Check if any libraries of a specific type are selected. * diff --git a/app/Models/PlaylistAuth.php b/app/Models/PlaylistAuth.php index 855286d34..909827ca3 100644 --- a/app/Models/PlaylistAuth.php +++ b/app/Models/PlaylistAuth.php @@ -43,6 +43,7 @@ protected static function booted(): void 'stop_oldest_on_limit' => 'boolean', 'request_enabled' => 'boolean', 'auto_approve_requests' => 'boolean', + 'library_publishing_enabled' => 'boolean', 'aiostreams_enabled' => 'boolean', 'proxy_enabled' => 'boolean', 'proxy_stream_profile_ids' => 'array', diff --git a/app/Models/User.php b/app/Models/User.php index d569ce7ed..5d9f38dda 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,6 +10,7 @@ use Filament\Models\Contracts\HasAvatar; use Filament\Panel; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; @@ -118,6 +119,11 @@ public function playlists() return $this->hasMany(Playlist::class); } + public function embyLibraryMappings(): HasMany + { + return $this->hasMany(EmbyLibraryMapping::class); + } + /** * Users custom playlists. */ diff --git a/app/Policies/EmbyLibraryMappingPolicy.php b/app/Policies/EmbyLibraryMappingPolicy.php new file mode 100644 index 000000000..b9e08bbf4 --- /dev/null +++ b/app/Policies/EmbyLibraryMappingPolicy.php @@ -0,0 +1,70 @@ +canUseIntegrations(); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, EmbyLibraryMapping $embyLibraryMapping): bool + { + return $user->canUseIntegrations() + && ($user->isAdmin() || $user->id === $embyLibraryMapping->user_id); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->canUseIntegrations(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, EmbyLibraryMapping $embyLibraryMapping): bool + { + return $user->canUseIntegrations() + && ($user->isAdmin() || $user->id === $embyLibraryMapping->user_id); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, EmbyLibraryMapping $embyLibraryMapping): bool + { + return $user->canUseIntegrations() + && ($user->isAdmin() || $user->id === $embyLibraryMapping->user_id); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, EmbyLibraryMapping $embyLibraryMapping): bool + { + return $user->canUseIntegrations() + && ($user->isAdmin() || $user->id === $embyLibraryMapping->user_id); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, EmbyLibraryMapping $embyLibraryMapping): bool + { + return $user->canUseIntegrations() + && ($user->isAdmin() || $user->id === $embyLibraryMapping->user_id); + } +} diff --git a/app/Services/AIOStreamsService.php b/app/Services/AIOStreamsService.php index 325bd91ff..2f366c57f 100644 --- a/app/Services/AIOStreamsService.php +++ b/app/Services/AIOStreamsService.php @@ -21,6 +21,22 @@ */ class AIOStreamsService implements MediaServer { + public function createLibrary( + string $name, + string $collectionType, + array $paths, + bool $refreshLibrary = true, + ?string $libraryId = null, + ): array { + return [ + 'success' => false, + 'created' => false, + 'message' => 'Library creation is not supported by this media server.', + 'library' => null, + 'drift' => false, + ]; + } + protected MediaServerIntegration $integration; protected string $baseUrl; diff --git a/app/Services/EmbyJellyfinService.php b/app/Services/EmbyJellyfinService.php index da3404b63..d1c706fd4 100644 --- a/app/Services/EmbyJellyfinService.php +++ b/app/Services/EmbyJellyfinService.php @@ -135,6 +135,9 @@ public function fetchLibraries(): Collection 'name' => $library['Name'] ?? 'Unknown Library', 'type' => $collectionType, 'item_count' => $library['ChildCount'] ?? 0, + 'paths' => is_array($library['Locations'] ?? null) + ? array_values($library['Locations']) + : array_values(array_filter([$library['Path'] ?? null])), 'path' => is_array($library['Locations'] ?? null) ? implode(', ', $library['Locations']) : ($library['Path'] ?? ''), @@ -159,6 +162,112 @@ public function fetchLibraries(): Collection } } + /** + * @param list $paths + * @return array{success: bool, created: bool, message: string, library: array|null} + */ + /** + * Build the standard createLibrary() result shape, so every return path + * carries the same keys (including 'drift') instead of each branch + * assembling its own array and risking an omitted key. + */ + private function libraryResult( + bool $success, + bool $created, + string $message, + ?array $library = null, + bool $drift = false, + ): array { + return [ + 'success' => $success, + 'created' => $created, + 'message' => $message, + 'library' => $library, + 'drift' => $drift, + ]; + } + + public function createLibrary( + string $name, + string $collectionType, + array $paths, + bool $refreshLibrary = true, + ?string $libraryId = null, + ): array { + if (! $this->integration->isEmby()) { + return $this->libraryResult(false, false, 'Managed library creation is supported only for Emby.'); + } + + if (! in_array($collectionType, ['movies', 'tvshows'], true)) { + return $this->libraryResult(false, false, 'Invalid Emby library collection type.'); + } + + $paths = array_values(array_unique(array_map( + fn (mixed $path): string => is_string($path) ? trim($path) : '', + $paths, + ))); + $hasInvalidPath = $paths === [] || collect($paths)->contains( + fn (string $path): bool => ! MediaServerIntegration::isSafeWritablePath($path), + ); + + if ($hasInvalidPath) { + return $this->libraryResult(false, false, 'Invalid Emby library path.'); + } + + try { + $existingLibraries = $this->fetchLibraries(); + $existingLibrary = $libraryId === null + ? null + : $existingLibraries->firstWhere('id', $libraryId); + + if ($existingLibrary !== null) { + $drift = $existingLibrary['name'] !== $name + || $existingLibrary['type'] !== $collectionType + || $existingLibrary['paths'] !== $paths; + + return $this->libraryResult(true, false, 'Existing Emby library found by ID.', $existingLibrary, $drift); + } + + $existingLibrary = $existingLibraries->first(fn (array $library): bool => $library['name'] === $name + && $library['type'] === $collectionType + && $library['paths'] === $paths); + + if ($existingLibrary !== null) { + return $this->libraryResult(true, false, 'Existing managed Emby library found.', $existingLibrary); + } + + $conflictingLibrary = $existingLibraries->firstWhere('name', $name); + + if ($conflictingLibrary !== null) { + return $this->libraryResult(false, false, 'An Emby library with this name has different settings.', $conflictingLibrary, true); + } + + $response = $this->client()->post('/Library/VirtualFolders', [ + 'Name' => $name, + 'CollectionType' => $collectionType, + 'Paths' => $paths, + 'RefreshLibrary' => $refreshLibrary, + ]); + + if (! $response->successful()) { + return $this->libraryResult(false, false, 'Emby rejected the library request.'); + } + + $library = $this->fetchLibraries()->first(fn (array $library): bool => $library['name'] === $name + && $library['type'] === $collectionType + && $library['paths'] === $paths); + + return $this->libraryResult(true, true, 'Emby library created.', $library); + } catch (Exception $exception) { + Log::warning('EmbyJellyfinService: Library creation failed', [ + 'integration_id' => $this->integration->id, + 'exception' => $exception::class, + ]); + + return $this->libraryResult(false, false, 'Emby library request failed.'); + } + } + /** * Fetch all movies from the media server. * If specific libraries are selected, only fetches from those libraries. @@ -177,11 +286,11 @@ public function fetchMovies(): Collection ]; // Filter by selected libraries if specified - $selectedLibraryIds = $this->integration->getSelectedLibraryIdsForType('movies'); - if (! empty($selectedLibraryIds)) { + $importLibraryIds = $this->integration->getImportLibraryIdsForType('movies'); + if ($importLibraryIds !== null) { // For multiple libraries, we need to fetch from each and merge $allMovies = collect(); - foreach ($selectedLibraryIds as $libraryId) { + foreach ($importLibraryIds as $libraryId) { $params['ParentId'] = $libraryId; $response = $this->client()->get('/Items', $params); @@ -237,11 +346,11 @@ public function fetchSeries(): Collection ]; // Filter by selected libraries if specified - $selectedLibraryIds = $this->integration->getSelectedLibraryIdsForType('tvshows'); - if (! empty($selectedLibraryIds)) { + $importLibraryIds = $this->integration->getImportLibraryIdsForType('tvshows'); + if ($importLibraryIds !== null) { // For multiple libraries, we need to fetch from each and merge $allSeries = collect(); - foreach ($selectedLibraryIds as $libraryId) { + foreach ($importLibraryIds as $libraryId) { $params['ParentId'] = $libraryId; $response = $this->client()->get('/Items', $params); diff --git a/app/Services/EmbyPublicationCatalogService.php b/app/Services/EmbyPublicationCatalogService.php new file mode 100644 index 000000000..0cc5ec4ca --- /dev/null +++ b/app/Services/EmbyPublicationCatalogService.php @@ -0,0 +1,749 @@ + + */ + public function buildForUser( + User $user, + ?string $username = null, + ?string $password = null, + ): array { + $mappings = []; + $query = EmbyLibraryMapping::query() + ->whereBelongsTo($user) + ->where('enabled', true) + ->whereHas('integration', fn ($integrationQuery) => $integrationQuery + ->where('type', 'emby') + ->where('enabled', true)); + + foreach ($query->lazyById(100) as $mapping) { + if ($mapping->is_managed && $mapping->target_library_id === null) { + $mapping->updateQuietly([ + 'last_planned_revision' => null, + 'status' => 'pending', + 'status_summary' => __('Pending'), + 'error_summary' => null, + ]); + + continue; + } + + $catalog = $this->buildMapping($mapping, $username, $password); + $mapping->updateQuietly([ + 'last_planned_revision' => $catalog['revision'], + 'status' => 'planned', + 'status_summary' => count($catalog['items']).' top-level items planned.', + 'error_summary' => null, + ]); + $mappings[] = $catalog; + } + + usort($mappings, fn (array $left, array $right): int => $left['mapping_uuid'] <=> $right['mapping_uuid']); + $catalog = [ + 'api_version' => 1, + 'full_snapshot' => true, + 'mappings' => $mappings, + ]; + $catalog['revision'] = hash( + 'sha256', + json_encode($catalog, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + ); + + return $catalog; + } + + /** + * @return array + */ + public function buildMapping( + EmbyLibraryMapping $mapping, + ?string $username = null, + ?string $password = null, + ): array { + $items = $mapping->collection_type === 'movies' + ? $this->buildMovies($mapping, $username, $password) + : $this->buildSeries($mapping, $username, $password); + + $catalog = [ + 'mapping_uuid' => $mapping->uuid, + 'integration_id' => $mapping->media_server_integration_id, + 'target_library' => [ + 'id' => $mapping->target_library_id, + 'name' => $mapping->target_library_name, + 'collection_type' => $mapping->collection_type, + 'output_path' => $mapping->output_path, + 'managed' => $mapping->is_managed, + ], + 'options' => $mapping->options, + 'full_snapshot' => true, + 'items' => $items, + ]; + $catalog['revision'] = hash( + 'sha256', + json_encode($catalog, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + ); + + return $catalog; + } + + /** + * @return list> + */ + private function buildMovies( + EmbyLibraryMapping $mapping, + ?string $username, + ?string $password, + ): array { + $query = Channel::query() + ->where('user_id', $mapping->user_id) + ->where('enabled', true) + ->where('is_vod', true) + ->with(['playlist', 'user', 'failoverChannels.playlist', 'failoverChannels.user']) + ->orderBy('id'); + + if ($mapping->source_kind === 'vod_group') { + $query->where('group_id', $mapping->source_identifier); + } elseif ($mapping->source_kind === 'custom_playlist_group') { + $customPlaylist = CustomPlaylist::query() + ->where('user_id', $mapping->user_id) + ->find($mapping->source_identifier); + + if ($customPlaylist === null) { + return []; + } + + $query + ->whereIn('channels.id', $customPlaylist->channels()->select('channels.id')) + ->where(function ($groupQuery) use ($customPlaylist, $mapping): void { + $groupQuery + ->whereHas('tags', fn ($tagQuery) => $tagQuery + ->where('type', $customPlaylist->uuid) + ->where('name->en', $mapping->source_label)) + ->orWhere(function ($fallbackQuery) use ($customPlaylist, $mapping): void { + $fallbackQuery + ->where('channels.group', $mapping->source_label) + ->whereDoesntHave('tags', fn ($tagQuery) => $tagQuery + ->where('type', $customPlaylist->uuid)); + }); + }); + } elseif ($mapping->source_kind !== 'all') { + return []; + } + + $items = []; + + foreach ($query->lazyById(500) as $channel) { + $canonicalId = $this->movieCanonicalId($channel); + $variantKey = $this->variantKey($channel); + + if (! isset($items[$canonicalId])) { + $items[$canonicalId] = $this->movieItem($channel, $mapping, $canonicalId); + } + + $items[$canonicalId]['variants'][$variantKey][] = [ + 'source_id' => $channel->id, + 'source_priority' => 0, + 'sort' => (float) ($channel->sort ?? 0), + 'playback_url' => $channel->getProxyUrl( + username: $username, + password: $password, + ), + 'playlist_id' => $channel->playlist_id, + 'technical_metadata' => $channel->stream_stats, + ]; + + foreach ($channel->failoverChannels as $index => $failoverChannel) { + if (! $failoverChannel->enabled || $failoverChannel->user_id !== $mapping->user_id) { + continue; + } + + $failoverVariantKey = $this->variantKey($failoverChannel); + $existingSourceIds = array_column( + $items[$canonicalId]['variants'][$failoverVariantKey] ?? [], + 'source_id', + ); + + if (in_array($failoverChannel->id, $existingSourceIds, true)) { + continue; + } + + $items[$canonicalId]['variants'][$failoverVariantKey][] = [ + 'source_id' => $failoverChannel->id, + 'source_priority' => 1, + 'sort' => $index, + 'playback_url' => $failoverChannel->getProxyUrl( + username: $username, + password: $password, + ), + 'playlist_id' => $failoverChannel->playlist_id, + 'technical_metadata' => $failoverChannel->stream_stats, + ]; + } + } + + ksort($items, SORT_STRING); + + return array_values(array_map(function (array $item): array { + $item['variants'] = $this->formatVariants($item['variants']); + + return $item; + }, $items)); + } + + /** + * @return list> + */ + private function buildSeries( + EmbyLibraryMapping $mapping, + ?string $username, + ?string $password, + ): array { + $query = Series::query() + ->where('user_id', $mapping->user_id) + ->where('enabled', true) + ->with([ + 'category', + 'episodes' => fn ($query) => $query + ->where('enabled', true) + ->with(['playlist', 'user', 'failoverEpisodes.playlist', 'failoverEpisodes.user']) + ->orderBy('season') + ->orderBy('episode_num') + ->orderBy('id'), + ]) + ->orderBy('id'); + + if ($mapping->source_kind === 'series_category') { + $query->where('category_id', $mapping->source_identifier); + } elseif ($mapping->source_kind === 'custom_playlist_group') { + $customPlaylist = CustomPlaylist::query() + ->where('user_id', $mapping->user_id) + ->find($mapping->source_identifier); + + if ($customPlaylist === null) { + return []; + } + + $categoryTagType = $customPlaylist->uuid.'-category'; + $query + ->whereIn('series.id', $customPlaylist->series()->select('series.id')) + ->where(function ($groupQuery) use ($categoryTagType, $mapping): void { + $groupQuery + ->whereHas('tags', fn ($tagQuery) => $tagQuery + ->where('type', $categoryTagType) + ->where('name->en', $mapping->source_label)) + ->orWhere(function ($fallbackQuery) use ($categoryTagType, $mapping): void { + $fallbackQuery + ->whereHas('category', fn ($categoryQuery) => $categoryQuery + ->where('name', $mapping->source_label)) + ->whereDoesntHave('tags', fn ($tagQuery) => $tagQuery + ->where('type', $categoryTagType)); + }); + }); + } elseif ($mapping->source_kind !== 'all') { + return []; + } + + $items = []; + + foreach ($query->lazyById(100) as $series) { + $canonicalId = $this->seriesCanonicalId($series); + + if (! isset($items[$canonicalId])) { + $items[$canonicalId] = $this->seriesItem($series, $mapping, $canonicalId); + } + + foreach ($series->episodes as $episode) { + $episodeItem = $this->episodeItem( + $episode, + $series, + $canonicalId, + $username, + $password, + ); + $episodeCanonicalId = $episodeItem['canonical_id']; + + if (! isset($items[$canonicalId]['episodes'][$episodeCanonicalId])) { + $items[$canonicalId]['episodes'][$episodeCanonicalId] = $episodeItem; + + continue; + } + + $items[$canonicalId]['episodes'][$episodeCanonicalId]['variants'] = $this->mergeEpisodeVariants( + $items[$canonicalId]['episodes'][$episodeCanonicalId]['variants'], + $episodeItem['variants'], + ); + } + } + + ksort($items, SORT_STRING); + + foreach ($items as &$item) { + $item['episodes'] = array_values($item['episodes']); + usort($item['episodes'], fn (array $left, array $right): int => [ + $left['season_number'], + $left['episode_number'], + $left['canonical_id'], + ] <=> [ + $right['season_number'], + $right['episode_number'], + $right['canonical_id'], + ]); + } + unset($item); + + return array_values($items); + } + + /** + * @param list> $existingVariants + * @param list> $additionalVariants + * @return list> + */ + private function mergeEpisodeVariants(array $existingVariants, array $additionalVariants): array + { + $variants = collect($existingVariants)->keyBy('key')->all(); + + foreach ($additionalVariants as $additionalVariant) { + $key = $additionalVariant['key']; + if (! isset($variants[$key])) { + $variants[$key] = $additionalVariant; + + continue; + } + + $sourceIds = array_column([ + $variants[$key]['preferred'], + ...$variants[$key]['failover'], + ], 'source_id'); + + foreach ([$additionalVariant['preferred'], ...$additionalVariant['failover']] as $source) { + if (in_array($source['source_id'], $sourceIds, true)) { + continue; + } + + $variants[$key]['failover'][] = $source; + $sourceIds[] = $source['source_id']; + } + } + + ksort($variants, SORT_STRING); + + return array_values($variants); + } + + private function seriesCanonicalId(Series $series): string + { + $year = $this->seriesYear($series) ?? 'unknown'; + $fallback = 'series:title:'.$this->safeComponent($series->name).':'.$year.':'.hash('sha256', (string) $series->id); + + return $this->canonicalIdFromIds('series', $this->seriesIds($series), $fallback); + } + + /** + * @return array{tmdb: int|null, tvdb: int|null, imdb: string|null} + */ + private function seriesIds(Series $series): array + { + $metadata = $series->metadata ?? []; + + return $this->normalizeIds( + $series->tmdb_id ?? $metadata['tmdb_id'] ?? $metadata['tmdb'] ?? null, + $series->tvdb_id ?? $metadata['tvdb_id'] ?? $metadata['tvdb'] ?? null, + $series->imdb_id ?? $metadata['imdb_id'] ?? $metadata['imdb'] ?? null, + ); + } + + /** + * @return array + */ + private function seriesItem(Series $series, EmbyLibraryMapping $mapping, string $canonicalId): array + { + $metadata = $series->metadata ?? []; + $year = $this->seriesYear($series); + $originalTitle = (string) ($metadata['original_name'] ?? $metadata['original_title'] ?? $series->name); + $originalTitleSource = isset($metadata['original_name']) + ? 'metadata.original_name' + : (isset($metadata['original_title']) ? 'metadata.original_title' : 'series.name'); + $relativeFolder = $this->safeComponent(trim($series->name.' '.($year ?? ''))); + + return [ + 'canonical_id' => $canonicalId, + 'media_type' => 'series', + 'display_title' => $series->name, + 'display_title_source' => 'series.name', + 'original_title' => $originalTitle, + 'original_title_source' => $originalTitleSource, + 'year' => $year, + 'ids' => $this->seriesIds($series), + 'groups' => [$mapping->source_label], + 'relative_folder' => $relativeFolder, + 'base_filename' => $relativeFolder, + 'nfo' => [ + 'title' => $series->name, + 'original_title' => $originalTitle, + 'year' => $year, + 'plot' => $series->plot, + 'genres' => array_values(array_filter(array_map('trim', explode(',', (string) $series->genre)))), + 'ids' => $this->seriesIds($series), + ], + 'episodes' => [], + ]; + } + + /** + * @return array + */ + private function episodeItem( + Episode $episode, + Series $series, + string $seriesCanonicalId, + ?string $username, + ?string $password, + ): array { + $ids = $this->episodeIds($episode); + $seasonNumber = (int) ($episode->season ?? 0); + $episodeNumber = (int) ($episode->episode_num ?? 0); + $fallback = $seasonNumber > 0 && $episodeNumber > 0 + ? sprintf('episode:%s:s%02de%02d', $seriesCanonicalId, $seasonNumber, $episodeNumber) + : 'episode:source:'.hash('sha256', (string) $episode->id); + $canonicalId = $this->canonicalIdFromIds('episode', $ids, $fallback); + $info = $episode->info ?? []; + $originalTitle = (string) ($info['original_title'] ?? $episode->title); + $variants = [ + $this->variantKey($episode) => [[ + 'source_id' => $episode->id, + 'source_priority' => 0, + 'sort' => 0, + 'playback_url' => $episode->getProxyUrl(username: $username, password: $password), + 'playlist_id' => $episode->playlist_id, + 'technical_metadata' => $episode->stream_stats ?? [], + ]], + ]; + + foreach ($episode->failoverEpisodes as $index => $failoverEpisode) { + if (! $failoverEpisode->enabled || $failoverEpisode->user_id !== $episode->user_id) { + continue; + } + + $variants[$this->variantKey($failoverEpisode)][] = [ + 'source_id' => $failoverEpisode->id, + 'source_priority' => 1, + 'sort' => $index, + 'playback_url' => $failoverEpisode->getProxyUrl(username: $username, password: $password), + 'playlist_id' => $failoverEpisode->playlist_id, + 'technical_metadata' => $failoverEpisode->stream_stats ?? [], + ]; + } + + return [ + 'canonical_id' => $canonicalId, + 'series_canonical_id' => $seriesCanonicalId, + 'media_type' => 'episode', + 'display_title' => $episode->title, + 'display_title_source' => 'episode.title', + 'original_title' => $originalTitle, + 'original_title_source' => isset($info['original_title']) ? 'info.original_title' : 'episode.title', + 'season_number' => $seasonNumber, + 'episode_number' => $episodeNumber, + 'ids' => $ids, + 'groups' => [$series->category?->name ?? ''], + 'relative_folder' => sprintf('season-%02d', $seasonNumber), + 'base_filename' => $this->safeComponent(sprintf( + '%s-s%02de%02d-%s', + $series->name, + $seasonNumber, + $episodeNumber, + $episode->title, + )), + 'nfo' => [ + 'title' => $episode->title, + 'original_title' => $originalTitle, + 'plot' => $info['plot'] ?? null, + 'season_number' => $seasonNumber, + 'episode_number' => $episodeNumber, + 'ids' => $ids, + ], + 'variants' => $this->formatVariants($variants), + ]; + } + + /** + * @return array{tmdb: int|null, tvdb: int|null, imdb: string|null} + */ + private function episodeIds(Episode $episode): array + { + $info = $episode->info ?? []; + + return $this->normalizeIds( + $episode->tmdb_id ?? $info['tmdb_id'] ?? $info['tmdb'] ?? null, + $info['tvdb_id'] ?? $info['tvdb'] ?? null, + $info['imdb_id'] ?? $info['imdb'] ?? null, + ); + } + + private function seriesYear(Series $series): ?int + { + $value = $series->release_date ?? $series->metadata['year'] ?? null; + + if (is_string($value) && preg_match('/^(\d{4})/', $value, $matches) === 1) { + return (int) $matches[1]; + } + + return is_numeric($value) ? (int) $value : null; + } + + private function movieCanonicalId(Channel $channel): string + { + $title = $this->displayTitle($channel); + $year = $this->movieYear($channel) ?? 'unknown'; + $sourceIdentity = $channel->uuid ?: (string) $channel->id; + $fallback = 'movie:title:'.$this->safeComponent($title).':'.$year.':'.hash('sha256', $sourceIdentity); + + // Priority is tmdb -> tvdb -> imdb, matching seriesCanonicalId() and + // episodeItem()'s cascade (canonicalIdFromIds()) — kept identical + // across media types so the same provider always wins when an item + // carries more than one external ID. + return $this->canonicalIdFromIds('movie', $this->movieIds($channel), $fallback); + } + + /** + * @return array{tmdb: int|null, tvdb: int|null, imdb: string|null} + */ + private function movieIds(Channel $channel): array + { + $info = $channel->info; + $movieData = $channel->movie_data; + + return $this->normalizeIds( + $channel->tmdb_id ?? $info['tmdb_id'] ?? $info['tmdb'] ?? $movieData['tmdb_id'] ?? null, + $channel->tvdb_id ?? $info['tvdb_id'] ?? $info['tvdb'] ?? $movieData['tvdb_id'] ?? null, + $channel->imdb_id ?? $info['imdb_id'] ?? $info['imdb'] ?? $movieData['imdb_id'] ?? null, + ); + } + + /** + * @return array + */ + private function movieItem(Channel $channel, EmbyLibraryMapping $mapping, string $canonicalId): array + { + $info = $channel->info; + $movieData = $channel->movie_data; + $displayTitle = $this->displayTitle($channel); + [$originalTitle, $originalTitleSource] = $this->originalTitle($channel, $displayTitle); + $year = $this->movieYear($channel); + $component = $this->safeComponent(trim($displayTitle.' '.($year ?? ''))); + + return [ + 'canonical_id' => $canonicalId, + 'media_type' => 'movie', + 'display_title' => $displayTitle, + 'display_title_source' => $channel->title_custom !== null ? 'channel.title_custom' : 'channel.title', + 'original_title' => $originalTitle, + 'original_title_source' => $originalTitleSource, + 'year' => $year, + 'ids' => $this->movieIds($channel), + 'groups' => [$mapping->source_label], + 'relative_folder' => $component, + 'base_filename' => $component, + 'nfo' => [ + 'title' => $displayTitle, + 'original_title' => $originalTitle, + 'year' => $year, + 'plot' => $info['plot'] ?? $movieData['plot'] ?? $movieData['description'] ?? null, + 'genres' => $info['genres'] ?? $movieData['genre'] ?? [], + 'ids' => $this->movieIds($channel), + ], + 'variants' => [], + ]; + } + + private function displayTitle(Channel $channel): string + { + return trim((string) ($channel->title_custom ?? $channel->title ?? $channel->name_custom ?? $channel->name)); + } + + /** + * @return array{string, string} + */ + private function originalTitle(Channel $channel, string $displayTitle): array + { + if (! empty($channel->info['original_title'])) { + return [(string) $channel->info['original_title'], 'info.original_title']; + } + + if (! empty($channel->movie_data['original_title'])) { + return [(string) $channel->movie_data['original_title'], 'movie_data.original_title']; + } + + return [$displayTitle, 'display_title']; + } + + private function movieYear(Channel $channel): ?int + { + $value = $channel->year ?? $channel->info['year'] ?? $channel->movie_data['year'] ?? null; + + if (is_numeric($value)) { + return (int) substr((string) $value, 0, 4); + } + + if (is_string($value) && preg_match('/^(\d{4})/', $value, $matches) === 1) { + return (int) $matches[1]; + } + + return null; + } + + private function variantKey(Channel|Episode $source): string + { + $video = null; + $audio = null; + + foreach ($source->stream_stats ?? [] as $entry) { + $stream = $entry['stream'] ?? null; + + if (($stream['codec_type'] ?? null) === 'video' && $video === null) { + $video = $stream; + } + + if (($stream['codec_type'] ?? null) === 'audio' && $audio === null) { + $audio = $stream; + } + } + + $height = isset($video['height']) ? (int) $video['height'] : null; + $resolution = match (true) { + $height >= 2160 => '2160p', + $height >= 1440 => '1440p', + $height >= 1080 => '1080p', + $height >= 720 => '720p', + $height > 0 => $height.'p', + default => 'unknown', + }; + $transfer = Str::lower((string) ($video['color_transfer'] ?? '')); + $hdr = match (true) { + $transfer === '' => 'unknown', + in_array($transfer, ['smpte2084', 'arib-std-b67'], true) => 'hdr', + default => 'sdr', + }; + + return implode('-', [ + $resolution, + $hdr, + $this->safeComponent((string) ($video['codec_name'] ?? 'unknown')), + $this->safeComponent((string) ($audio['codec_name'] ?? 'unknown')), + $this->safeComponent((string) ($audio['tags']['language'] ?? 'unknown')), + $this->safeComponent((string) ($source instanceof Channel ? ($source->edition ?? 'unknown') : 'unknown')), + ]); + } + + /** + * @param array>> $groupedSources + * @return list> + */ + private function formatVariants(array $groupedSources): array + { + ksort($groupedSources, SORT_STRING); + $variants = []; + + foreach ($groupedSources as $key => $sources) { + usort($sources, fn (array $left, array $right): int => [ + $left['source_priority'], + $left['sort'], + $left['source_id'], + ] <=> [ + $right['source_priority'], + $right['sort'], + $right['source_id'], + ]); + $preferred = array_shift($sources); + $technicalMetadata = $preferred['technical_metadata']; + unset($preferred['source_priority'], $preferred['sort'], $preferred['technical_metadata']); + $failover = array_map(function (array $source): array { + unset($source['source_priority'], $source['sort'], $source['technical_metadata']); + + return $source; + }, $sources); + + $variants[] = [ + 'key' => $key, + 'preferred' => $preferred, + 'failover' => $failover, + 'technical_metadata' => $technicalMetadata, + ]; + } + + return $variants; + } + + private function safeComponent(string $value): string + { + $component = Str::slug(Str::limit($value, 150, '')); + + return $component !== '' ? $component : 'unknown'; + } + + private function integerId(mixed $value): ?int + { + return is_numeric($value) ? (int) $value : null; + } + + private function stringId(mixed $value): ?string + { + return is_scalar($value) && trim((string) $value) !== '' ? trim((string) $value) : null; + } + + /** + * Normalize a movie/series/episode's raw tmdb/tvdb/imdb field values into + * the shared {tmdb, tvdb, imdb} shape. Callers pull the raw values from + * whichever model-specific fields apply (Channel/Series/Episode each + * carry them under different property/metadata-array names), but the + * type coercion is identical everywhere, so it lives here once. + * + * @return array{tmdb: int|null, tvdb: int|null, imdb: string|null} + */ + private function normalizeIds(mixed $tmdb, mixed $tvdb, mixed $imdb): array + { + return [ + 'tmdb' => $this->integerId($tmdb), + 'tvdb' => $this->integerId($tvdb), + 'imdb' => $this->stringId($imdb), + ]; + } + + /** + * Build a canonical ID from the shared tmdb -> tvdb -> imdb priority + * cascade, falling back to $fallback when none of the three are present. + * Shared by movie/series/episode canonical-ID resolution so the priority + * order can't silently drift between media types. + * + * @param array{tmdb: int|null, tvdb: int|null, imdb: string|null} $ids + */ + private function canonicalIdFromIds(string $mediaType, array $ids, string $fallback): string + { + if ($ids['tmdb'] !== null) { + return "{$mediaType}:tmdb:{$ids['tmdb']}"; + } + + if ($ids['tvdb'] !== null) { + return "{$mediaType}:tvdb:{$ids['tvdb']}"; + } + + if ($ids['imdb'] !== null) { + return "{$mediaType}:imdb:".Str::lower($ids['imdb']); + } + + return $fallback; + } +} diff --git a/app/Services/LocalMediaService.php b/app/Services/LocalMediaService.php index df93d4cf4..7c5f50fe5 100644 --- a/app/Services/LocalMediaService.php +++ b/app/Services/LocalMediaService.php @@ -19,6 +19,22 @@ */ class LocalMediaService implements MediaServer { + public function createLibrary( + string $name, + string $collectionType, + array $paths, + bool $refreshLibrary = true, + ?string $libraryId = null, + ): array { + return [ + 'success' => false, + 'created' => false, + 'message' => 'Library creation is not supported by this media server.', + 'library' => null, + 'drift' => false, + ]; + } + protected MediaServerIntegration $integration; /** diff --git a/app/Services/PlexService.php b/app/Services/PlexService.php index 7cff203fb..c853e708e 100644 --- a/app/Services/PlexService.php +++ b/app/Services/PlexService.php @@ -14,6 +14,22 @@ class PlexService implements MediaServer { + public function createLibrary( + string $name, + string $collectionType, + array $paths, + bool $refreshLibrary = true, + ?string $libraryId = null, + ): array { + return [ + 'success' => false, + 'created' => false, + 'message' => 'Library creation is not supported by this media server.', + 'library' => null, + 'drift' => false, + ]; + } + protected MediaServerIntegration $integration; protected string $baseUrl; diff --git a/app/Services/WebDavMediaService.php b/app/Services/WebDavMediaService.php index 9f6da8afe..77357bf22 100644 --- a/app/Services/WebDavMediaService.php +++ b/app/Services/WebDavMediaService.php @@ -20,6 +20,22 @@ */ class WebDavMediaService implements MediaServer { + public function createLibrary( + string $name, + string $collectionType, + array $paths, + bool $refreshLibrary = true, + ?string $libraryId = null, + ): array { + return [ + 'success' => false, + 'created' => false, + 'message' => 'Library creation is not supported by this media server.', + 'library' => null, + 'drift' => false, + ]; + } + protected MediaServerIntegration $integration; /** diff --git a/database/factories/EmbyLibraryMappingFactory.php b/database/factories/EmbyLibraryMappingFactory.php new file mode 100644 index 000000000..d3e991595 --- /dev/null +++ b/database/factories/EmbyLibraryMappingFactory.php @@ -0,0 +1,46 @@ + + */ +class EmbyLibraryMappingFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'media_server_integration_id' => fn (array $attributes): int => MediaServerIntegration::factory() + ->for(User::query()->findOrFail($attributes['user_id'])) + ->createQuietly(['type' => 'emby']) + ->id, + 'enabled' => true, + 'source_kind' => 'vod_group', + 'source_identifier' => (string) fake()->unique()->numberBetween(1, 100000), + 'source_label' => fake()->words(2, true), + 'target_library_id' => fake()->uuid(), + 'target_library_name' => fake()->words(2, true), + 'collection_type' => 'movies', + 'output_path' => '/media/m3u-editor/'.fake()->slug(), + 'is_managed' => true, + 'options' => [ + 'naming' => 'media-year', + 'nfo' => true, + 'versions' => true, + 'cleanup' => 'replace', + 'refresh' => true, + ], + ]; + } +} diff --git a/database/migrations/2026_08_02_124332_create_emby_library_mappings_table.php b/database/migrations/2026_08_02_124332_create_emby_library_mappings_table.php new file mode 100644 index 000000000..816dd2a45 --- /dev/null +++ b/database/migrations/2026_08_02_124332_create_emby_library_mappings_table.php @@ -0,0 +1,52 @@ +id(); + $table->uuid('uuid')->unique(); + $table->foreignId('media_server_integration_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->boolean('enabled')->default(true); + $table->string('source_kind', 32); + $table->string('source_identifier', 255); + $table->string('source_label'); + $table->string('target_library_id')->nullable(); + $table->string('target_library_name'); + $table->string('collection_type', 16); + $table->string('output_path', 1024); + $table->boolean('is_managed')->default(false); + $table->json('options'); + $table->string('last_planned_revision', 64)->nullable(); + $table->string('last_applied_revision', 64)->nullable(); + $table->timestamp('last_success_at')->nullable(); + $table->string('status', 32)->default('idle'); + $table->string('status_summary', 500)->nullable(); + $table->string('error_summary', 500)->nullable(); + $table->timestamps(); + + $table->index(['media_server_integration_id', 'enabled']); + $table->unique( + ['media_server_integration_id', 'source_kind', 'source_identifier', 'source_label', 'collection_type'], + 'emby_library_mapping_source_unique' + ); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('emby_library_mappings'); + } +}; diff --git a/database/migrations/2026_08_02_221647_add_emby_publisher_writable_paths_to_media_server_integrations_table.php b/database/migrations/2026_08_02_221647_add_emby_publisher_writable_paths_to_media_server_integrations_table.php new file mode 100644 index 000000000..32b439637 --- /dev/null +++ b/database/migrations/2026_08_02_221647_add_emby_publisher_writable_paths_to_media_server_integrations_table.php @@ -0,0 +1,32 @@ +json('emby_publisher_writable_paths')->nullable(); + $table->timestamp('emby_publisher_capabilities_updated_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('media_server_integrations', function (Blueprint $table) { + $table->dropColumn([ + 'emby_publisher_writable_paths', + 'emby_publisher_capabilities_updated_at', + ]); + }); + } +}; diff --git a/database/migrations/2026_08_02_225754_add_library_publishing_enabled_to_playlist_auths_table.php b/database/migrations/2026_08_02_225754_add_library_publishing_enabled_to_playlist_auths_table.php new file mode 100644 index 000000000..901d25a6a --- /dev/null +++ b/database/migrations/2026_08_02_225754_add_library_publishing_enabled_to_playlist_auths_table.php @@ -0,0 +1,28 @@ +boolean('library_publishing_enabled')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('playlist_auths', function (Blueprint $table) { + $table->dropColumn('library_publishing_enabled'); + }); + } +}; diff --git a/lang/de.json b/lang/de.json index 4f03d5a3f..333fe6471 100644 --- a/lang/de.json +++ b/lang/de.json @@ -4958,5 +4958,56 @@ "your-api-key-here": "Ihr-API-Schlüssel-hier", "yt-dlp format selector followed by optional flags. Example: bestvideo+bestaudio/best --no-playlist": "yt-dlp-Formatauswahl, gefolgt von optionalen Flags. Beispiel: bestvideo+bestaudio/best --no-playlist", "—": "—", - "— Any —": "- Beliebig -" + "— Any —": "- Beliebig -", + "All eligible items": "Alle geeigneten Elemente", + "Applied revision": "Angewendete Revision", + "Cleanup": "Bereinigung", + "Companion output path": "Ausgabepfad der Companion-App", + "Create a managed library": "Eine verwaltete Bibliothek erstellen", + "Create and manage this Emby library": "Diese Emby-Bibliothek erstellen und verwalten", + "Create mapping": "Zuordnung erstellen", + "Custom playlist group": "Benutzerdefinierte Wiedergabelistengruppe", + "Do not clean up files": "Dateien nicht bereinigen", + "Emby library": "Emby-Bibliothek", + "Emby library configuration differs from this mapping.": "Die Emby-Bibliothekskonfiguration weicht von dieser Zuordnung ab.", + "Existing library": "Vorhandene Bibliothek", + "Keep stale managed files": "Veraltete verwaltete Dateien behalten", + "Last error": "Letzter Fehler", + "Last success": "Letzter Erfolg", + "Library name": "Bibliotheksname", + "Library type": "Bibliothekstyp", + "Managed": "Verwaltet", + "Managed Libraries": "Verwaltete Bibliotheken", + "Managed library configuration drifted": "Konfiguration der verwalteten Bibliothek ist abgewichen", + "Managed library plan updated": "Plan für verwaltete Bibliothek aktualisiert", + "Managed library reconcile failed": "Abgleich der verwalteten Bibliothek fehlgeschlagen", + "Mapped group": "Zugeordnete Gruppe", + "Naming": "Benennung", + "Not applied": "Nicht angewendet", + "Only paths validated and advertised by m3u-editor for Emby are available.": "Nur von m3u-editor für Emby validierte und bekannt gegebene Pfade sind verfügbar.", + "Output path": "Ausgabepfad", + "Publish local NFO": "Lokale NFO veröffentlichen", + "Publish visible versions": "Sichtbare Versionen veröffentlichen", + "Publishing options": "Veröffentlichungsoptionen", + "Reconcile": "Abgleichen", + "Reconcile failed.": "Abgleich fehlgeschlagen.", + "Refresh Emby after successful sync": "Emby nach erfolgreicher Synchronisierung aktualisieren", + "Replace stale managed files": "Veraltete verwaltete Dateien ersetzen", + "Revision planned for companion sync.": "Revision für Companion-Synchronisierung geplant.", + "Series category": "Serienkategorie", + "Source type": "Quelltyp", + "TV shows": "TV-Sendungen", + "Title and year": "Titel und Jahr", + "Title only": "Nur Titel", + "Unnamed library": "Unbenannte Bibliothek", + "VOD group": "VOD-Gruppe", + "The existing Emby library's name, type, or paths no longer match this mapping. Update it manually in Emby, or delete it there to let reconcile recreate it.": "Der Name, Typ oder die Pfade der vorhandenen Emby-Bibliothek stimmen nicht mehr mit dieser Zuordnung überein. Aktualisieren Sie sie manuell in Emby, oder löschen Sie sie dort, damit der Abgleich sie neu erstellen kann.", + "Choose the specific group or category within the custom playlist to publish.": "Wählen Sie die spezifische Gruppe oder Kategorie innerhalb der benutzerdefinierten Wiedergabeliste aus, die veröffentlicht werden soll.", + "Automatically set from the source selected above.": "Wird automatisch anhand der oben ausgewählten Quelle festgelegt.", + "Choose a library type first.": "Wählen Sie zuerst einen Bibliothekstyp aus.", + "This custom playlist has no VOD groups available to publish as movies.": "Diese benutzerdefinierte Wiedergabeliste enthält keine VOD-Gruppen, die als Filme veröffentlicht werden können.", + "This custom playlist has no series categories available to publish as TV shows.": "Diese benutzerdefinierte Wiedergabeliste enthält keine Seriekategorien, die als TV-Sendungen veröffentlicht werden können.", + "Catalog plan preview": "Vorschau des Katalogplans", + "Showing :shown of :total items": "Es werden :shown von :total Elementen angezeigt", + "The full catalog (all :total items) is what actually gets synced — this preview is capped so a large library does not crash the browser.": "Der vollständige Katalog (alle :total Elemente) wird tatsächlich synchronisiert — diese Vorschau ist begrenzt, damit eine große Bibliothek den Browser nicht zum Absturz bringt." } diff --git a/lang/en.json b/lang/en.json index b6582d2b8..7c357d4c2 100644 --- a/lang/en.json +++ b/lang/en.json @@ -2311,6 +2311,7 @@ "All Series": "All Series", "All VOD Channels": "All VOD Channels", "All categories": "All categories", + "All eligible items": "All eligible items", "All episodes have been unmerged successfully.": "All episodes have been unmerged successfully.", "All groups": "All groups", "All library content cleared. A fresh sync has been queued.": "All library content cleared. A fresh sync has been queued.", @@ -2332,6 +2333,7 @@ "Allow probing this channel when running playlist channel probe jobs.": "Allow probing this channel when running playlist channel probe jobs.", "Allow queue manager access": "Allow queue manager access", "Allow this channel to be merged during \"Merge Same ID\" jobs.": "Allow this channel to be merged during \"Merge Same ID\" jobs.", + "Allow this credential to read managed Emby publishing catalogs and report sync results.": "Allow this credential to read managed Emby publishing catalogs and report sync results.", "Allow this guest to view and schedule recordings via the public playlist viewer.": "Allow this guest to view and schedule recordings via the public playlist viewer.", "Allow this user to browse and stream AIOStreams catalogs when the assigned playlist also has AIOStreams enabled.": "Allow this user to browse and stream AIOStreams catalogs when the assigned playlist also has AIOStreams enabled.", "Allow this user to request content when the assigned playlist and ARR integration also allow user requests.": "Allow this user to request content when the assigned playlist and ARR integration also allow user requests.", @@ -2366,6 +2368,7 @@ "Appended to the standard filename. Title/Year/TMDB/Group come from \"Filename metadata\" above.": "Appended to the standard filename. Title/Year/TMDB/Group come from \"Filename metadata\" above.", "Application Timezone": "Application Timezone", "Applied": "Applied", + "Applied revision": "Applied revision", "Apply": "Apply", "Apply & Close": "Apply & Close", "Apply Suggestions": "Apply Suggestions", @@ -2681,6 +2684,7 @@ "Choose the position of primary navigation": "Choose the position of primary navigation", "Choose whether to retain or remove any database tables and storage files the plugin created during its lifetime.": "Choose whether to retain or remove any database tables and storage files the plugin created during its lifetime.", "Clean special characters": "Clean special characters", + "Cleanup": "Cleanup", "Cleanup Complete": "Cleanup Complete", "Cleanup Duplicate Series": "Cleanup Duplicate Series", "Cleanup Duplicates": "Cleanup Duplicates", @@ -2721,6 +2725,7 @@ "Combine multiple EPGs into one unified source for external players or EPG mapping.": "Combine multiple EPGs into one unified source for external players or EPG mapping.", "Commercial Detection (Comskip)": "Commercial Detection (Comskip)", "Common presets": "Common presets", + "Companion output path": "Companion output path", "Complete": "Complete", "Complete cast list": "Complete cast list", "Completed": "Completed", @@ -2817,10 +2822,13 @@ "Create Networks in the Networks section to build pseudo-live channels": "Create Networks in the Networks section to build pseudo-live channels", "Create Plugin": "Create Plugin", "Create Token": "Create Token", + "Create a managed library": "Create a managed library", "Create an Incoming Webhook in your Discord server settings and paste the URL here.": "Create an Incoming Webhook in your Discord server settings and paste the URL here.", "Create an alias of an existing playlist or custom playlist to use a different Xtream API credentials, while still using the same underlying Channel, VOD and Series configurations of the linked playlist.": "Create an alias of an existing playlist or custom playlist to use a different Xtream API credentials, while still using the same underlying Channel, VOD and Series configurations of the linked playlist.", + "Create and manage this Emby library": "Create and manage this Emby library", "Create credentials and assign them to your Playlist for simple authentication. They can also be used to access the Xtream API for the assigned Playlists.": "Create credentials and assign them to your Playlist for simple authentication. They can also be used to access the Xtream API for the assigned Playlists.", "Create live TV channels from your media server content": "Create live TV channels from your media server content", + "Create mapping": "Create mapping", "Create now": "Create now", "Create playlists composed of channels from your other playlists. Head to channels to bulk add channels to your custom playlist.": "Create playlists composed of channels from your other playlists. Head to channels to bulk add channels to your custom playlist.", "Created By": "Created By", @@ -2851,6 +2859,7 @@ "Custom Playlists": "Custom Playlists", "Custom channel selection": "Custom channel selection", "Custom headers to use when streaming via the proxy.": "Custom headers to use when streaming via the proxy.", + "Custom playlist group": "Custom playlist group", "Custom playlist is being duplicated": "Custom playlist is being duplicated", "Custom quality indicators": "Custom quality indicators", "DNS failover URLs": "DNS failover URLs", @@ -3006,6 +3015,7 @@ "Display Name": "Display Name", "Display aspect ratio": "Display aspect ratio", "Display label": "Display label", + "Do not clean up files": "Do not clean up files", "Docs": "Docs", "Documentation": "Documentation", "Does not have metadata": "Does not have metadata", @@ -3096,6 +3106,7 @@ "Email address": "Email address", "Email variables": "Email variables", "Emby": "Emby", + "Emby library": "Emby library", "Enable": "Enable", "Enable .strm file generation": "Enable .strm file generation", "Enable AI Copilot": "Enable AI Copilot", @@ -3115,6 +3126,7 @@ "Enable EPG mapping by default": "Enable EPG mapping by default", "Enable Group Channels": "Enable Group Channels", "Enable Groups": "Enable Groups", + "Enable Library Publishing": "Enable Library Publishing", "Enable Logo Proxy": "Enable Logo Proxy", "Enable Logo Repository endpoint": "Enable Logo Repository endpoint", "Enable Merge": "Enable Merge", @@ -3260,6 +3272,7 @@ "Exclude disabled groups from master selection": "Exclude disabled groups from master selection", "Execution is now disabled until an administrator reviews and trusts this plugin again.": "Execution is now disabled until an administrator reviews and trusts this plugin again.", "Execution requires both admin trust and verified file integrity.": "Execution requires both admin trust and verified file integrity.", + "Existing library": "Existing library", "Expiration (date & time)": "Expiration (date & time)", "Expiration Date": "Expiration Date", "Expired logo cache cleared": "Expired logo cache cleared", @@ -3600,6 +3613,7 @@ "Keep cache permanently (disable expiry cleanup)": "Keep cache permanently (disable expiry cleanup)", "Keep failover channels hidden": "Keep failover channels hidden", "Keep last N recordings": "Keep last N recordings", + "Keep stale managed files": "Keep stale managed files", "Keeps automatic Live stream probing incremental by skipping streams that already have stored stream metadata.": "Keeps automatic Live stream probing incremental by skipping streams that already have stored stream metadata.", "Keeps automatic VOD and series probing incremental by skipping streams that already have stored stream metadata.": "Keeps automatic VOD and series probing incremental by skipping streams that already have stored stream metadata.", "Kinopoisk Rating Count": "Kinopoisk Rating Count", @@ -3621,6 +3635,7 @@ "Last Synced": "Last Synced", "Last Tested": "Last Tested", "Last Watched": "Last Watched", + "Last error": "Last error", "Last heartbeat": "Last heartbeat", "Last probe returned no data — the stream may have been unreachable.": "Last probe returned no data — the stream may have been unreachable.", "Last probe returned no data. The stream may have been unreachable.": "Last probe returned no data. The stream may have been unreachable.", @@ -3628,6 +3643,7 @@ "Last ran": "Last ran", "Last scrubber result: dead": "Last scrubber result: dead", "Last scrubber result: live": "Last scrubber result: live", + "Last success": "Last success", "Last sync :date": "Last sync :date", "Last validation": "Last validation", "Latest Air Date": "Latest Air Date", @@ -3666,7 +3682,10 @@ "Libraries to Import": "Libraries to Import", "Library Flushed": "Library Flushed", "Library Name": "Library Name", + "Library Publishing Access": "Library Publishing Access", "Library Selection": "Library Selection", + "Library name": "Library name", + "Library type": "Library type", "Lifecycle": "Lifecycle", "Lineup": "Lineup", "Lineup added successfully!": "Lineup added successfully!", @@ -3755,6 +3774,10 @@ "Manage users that can access and use the application. Each user will have their own playlists, channels, series, and other resources. Some features may be restricted based on user roles (such as global settings).": "Manage users that can access and use the application. Each user will have their own playlists, channels, series, and other resources. Some features may be restricted based on user roles (such as global settings).", "Manage your API tokens. Tokens allow you to authenticate API requests for certain API actions.": "Manage your API tokens. Tokens allow you to authenticate API requests for certain API actions.", "Manage your Plex server directly from m3u-editor — register DVR tuners, monitor sessions, and control libraries.": "Manage your Plex server directly from m3u-editor — register DVR tuners, monitor sessions, and control libraries.", + "Managed": "Managed", + "Managed Libraries": "Managed Libraries", + "Managed library plan updated": "Managed library plan updated", + "Managed library reconcile failed": "Managed library reconcile failed", "Manages M3U playlists, including live streams, VOD, and series. Supports Xtream API.": "Manages M3U playlists, including live streams, VOD, and series. Supports Xtream API.", "Manifest URL": "Manifest URL", "Manual": "Manual", @@ -3773,6 +3796,7 @@ "Map now": "Map now", "Map the selected EPG to the selected Playlist channels.": "Map the selected EPG to the selected Playlist channels.", "Map the selected EPG to the selected channel(s).": "Map the selected EPG to the selected channel(s).", + "Mapped group": "Mapped group", "Mapping": "Mapping", "Mapping Enabled": "Mapping Enabled", "Mapping applied": "Mapping applied", @@ -3915,6 +3939,7 @@ "Name and describe your plugin": "Name and describe your plugin", "Name of the HTTP header.": "Name of the HTTP header.", "Name of the variable to send as GET/POST variable to your webhook URL.": "Name of the variable to send as GET/POST variable to your webhook URL.", + "Naming": "Naming", "Navigation position": "Navigation position", "Netherlands": "Netherlands", "Network": "Network", @@ -4038,6 +4063,7 @@ "Norway": "Norway", "Not Configured": "Not Configured", "Not Found": "Not Found", + "Not applied": "Not applied", "Not authorized to manage this stream.": "Not authorized to manage this stream.", "Not in Library": "Not in Library", "Not probed": "Not probed", @@ -4089,6 +4115,7 @@ "Only expired logo cache entries (those older than 30 days). If permanent cache is enabled, nothing will be removed.": "Only expired logo cache entries (those older than 30 days). If permanent cache is enabled, nothing will be removed.", "Only export enabled channels?": "Only export enabled channels?", "Only live channels in these groups will be accessible. Leave empty to allow all live groups.": "Only live channels in these groups will be accessible. Leave empty to allow all live groups.", + "Only paths validated and advertised by m3u-editor for Emby are available.": "Only paths validated and advertised by m3u-editor for Emby are available.", "Only pending rows with a top candidate are eligible.": "Only pending rows with a top candidate are eligible.", "Only probe Live streams that have not been probed before": "Only probe Live streams that have not been probed before", "Only probe VOD and series streams that have not been probed before": "Only probe VOD and series streams that have not been probed before", @@ -4134,6 +4161,7 @@ "Output TZ": "Output TZ", "Output Timezone": "Output Timezone", "Output WAN address in menu": "Output WAN address in menu", + "Output path": "Output path", "Output processing options": "Output processing options", "Output the provider URL directly in M3U instead of routing through the internal Xtream URL format.": "Output the provider URL directly in M3U instead of routing through the internal Xtream URL format.", "Override": "Override", @@ -4468,6 +4496,9 @@ "Proxy is enabled on the parent playlist. All channels in this playlist are already proxied. You can still select a stream profile override below.": "Proxy is enabled on the parent playlist. All channels in this playlist are already proxied. You can still select a stream profile override below.", "Proxy mode was automatically enabled because this playlist now contains channels from source playlists with Provider Profiles enabled.": "Proxy mode was automatically enabled because this playlist now contains channels from source playlists with Provider Profiles enabled.", "Public URL": "Public URL", + "Publish local NFO": "Publish local NFO", + "Publish visible versions": "Publish visible versions", + "Publishing options": "Publishing options", "Purge Series": "Purge Series", "Purge now": "Purge now", "Purged": "Purged", @@ -4516,6 +4547,8 @@ "Recent Runs": "Recent Runs", "Recent plugin uploads — pending approval, approved, or rejected.": "Recent plugin uploads — pending approval, approved, or rejected.", "Recommended Next Step": "Recommended Next Step", + "Reconcile": "Reconcile", + "Reconcile failed.": "Reconcile failed.", "Record": "Record", "Record Episodes": "Record Episodes", "Record Episodes (Default)": "Record Episodes (Default)", @@ -4551,6 +4584,7 @@ "Refine runs": "Refine runs", "Refresh": "Refresh", "Refresh EPG Guide": "Refresh EPG Guide", + "Refresh Emby after successful sync": "Refresh Emby after successful sync", "Refresh Failed": "Refresh Failed", "Refresh Libraries": "Refresh Libraries", "Refresh Logo Repository": "Refresh Logo Repository", @@ -4618,6 +4652,7 @@ "Removes the selected reviews from the system. This does not affect the installed plugins or their files on disk.": "Removes the selected reviews from the system. This does not affect the installed plugins or their files on disk.", "Removing unused lineups from your SchedulesDirect account frees up slots for new ones.": "Removing unused lineups from your SchedulesDirect account frees up slots for new ones.", "Replace now": "Replace now", + "Replace stale managed files": "Replace stale managed files", "Replace with": "Replace with", "Replace with (optional)": "Replace with (optional)", "Reprocess Comskip": "Reprocess Comskip", @@ -4707,6 +4742,7 @@ "Review and edit the AI suggestions before applying them to the form.": "Review and edit the AI suggestions before applying them to the form.", "Review explainable candidates for unresolved channels from this map.": "Review explainable candidates for unresolved channels from this map.", "Review the failed run, check the activity stream for the error context, and correct the target playlist, EPG, or thresholds before trying again.": "Review the failed run, check the activity stream for the error context, and correct the target playlist, EPG, or thresholds before trying again.", + "Revision planned for companion sync.": "Revision planned for companion sync.", "Revoke": "Revoke", "Revoke device": "Revoke device", "Revoke selected": "Revoke selected", @@ -5005,6 +5041,7 @@ "Series added — add individual episodes to resolve their streams": "Series added — add individual episodes to resolve their streams", "Series are being processed": "Series are being processed", "Series categories": "Series categories", + "Series category": "Series category", "Series episodes disabled": "Series episodes disabled", "Series episodes enabled": "Series episodes enabled", "Series have been added and are being processed.": "Series have been added and are being processed.", @@ -5103,6 +5140,7 @@ "Source Playlist": "Source Playlist", "Source Timezone": "Source Timezone", "Source Type": "Source Type", + "Source type": "Source type", "Sources Cached": "Sources Cached", "South Korea": "South Korea", "Space between consecutive programmes during cascade bump (0 = no gap)": "Space between consecutive programmes during cascade bump (0 = no gap)", @@ -5268,6 +5306,7 @@ "TV App settings": "TV App settings", "TV Notification Tester": "TV Notification Tester", "TV Series": "TV Series", + "TV shows": "TV shows", "TVDB ID": "TVDB ID", "TVG ID / Stream ID": "TVG ID / Stream ID", "TVG ID/Stream ID (default)": "TVG ID/Stream ID (default)", @@ -5510,7 +5549,9 @@ "Title Keyword": "Title Keyword", "Title Output Format": "Title Output Format", "Title Regex": "Title Regex", + "Title and year": "Title and year", "Title folder metadata": "Title folder metadata", + "Title only": "Title only", "To": "To", "To Email Address": "To Email Address", "To use as the master for the selected channel.": "To use as the master for the selected channel.", @@ -5606,6 +5647,7 @@ "Unmerging channels for this group in the background. You will be notified once the process is complete.": "Unmerging channels for this group in the background. You will be notified once the process is complete.", "Unmerging channels in the background. You will be notified once the process is complete.": "Unmerging channels in the background. You will be notified once the process is complete.", "Unmerging episodes in the background. You will be notified once the process is complete.": "Unmerging episodes in the background. You will be notified once the process is complete.", + "Unnamed library": "Unnamed library", "Unpin the selected item(s) first: ": "Unpin the selected item(s) first: ", "Unreachable": "Unreachable", "Unselected only": "Unselected only", @@ -5691,6 +5733,7 @@ "VOD and Series Streaming Profile": "VOD and Series Streaming Profile", "VOD and Series Transcoding Profile": "VOD and Series Transcoding Profile", "VOD channels have been sorted by release date across the playlist.": "VOD channels have been sorted by release date across the playlist.", + "VOD group": "VOD group", "VOD groups": "VOD groups", "VOD groups to import": "VOD groups to import", "VOD processing": "VOD processing", @@ -6013,5 +6056,16 @@ "your-api-key-here": "your-api-key-here", "yt-dlp format selector followed by optional flags. Example: bestvideo+bestaudio/best --no-playlist": "yt-dlp format selector followed by optional flags. Example: bestvideo+bestaudio/best --no-playlist", "—": "—", - "— Any —": "— Any —" + "— Any —": "— Any —", + "Managed library configuration drifted": "Managed library configuration drifted", + "Emby library configuration differs from this mapping.": "Emby library configuration differs from this mapping.", + "The existing Emby library's name, type, or paths no longer match this mapping. Update it manually in Emby, or delete it there to let reconcile recreate it.": "The existing Emby library's name, type, or paths no longer match this mapping. Update it manually in Emby, or delete it there to let reconcile recreate it.", + "Choose the specific group or category within the custom playlist to publish.": "Choose the specific group or category within the custom playlist to publish.", + "Automatically set from the source selected above.": "Automatically set from the source selected above.", + "Choose a library type first.": "Choose a library type first.", + "This custom playlist has no VOD groups available to publish as movies.": "This custom playlist has no VOD groups available to publish as movies.", + "This custom playlist has no series categories available to publish as TV shows.": "This custom playlist has no series categories available to publish as TV shows.", + "Catalog plan preview": "Catalog plan preview", + "Showing :shown of :total items": "Showing :shown of :total items", + "The full catalog (all :total items) is what actually gets synced — this preview is capped so a large library does not crash the browser.": "The full catalog (all :total items) is what actually gets synced — this preview is capped so a large library does not crash the browser." } diff --git a/lang/es.json b/lang/es.json index 6a0eb881e..bf5c30158 100644 --- a/lang/es.json +++ b/lang/es.json @@ -4958,5 +4958,56 @@ "your-api-key-here": "tu-clave-api-aquí", "yt-dlp format selector followed by optional flags. Example: bestvideo+bestaudio/best --no-playlist": "Selector de formato yt-dlp seguido de indicadores opcionales. Ejemplo: bestvideo+bestaudio/best --no-playlist", "—": "—", - "— Any —": "- Cualquier -" + "— Any —": "- Cualquier -", + "All eligible items": "Todos los elementos elegibles", + "Applied revision": "Revisión aplicada", + "Cleanup": "Limpieza", + "Companion output path": "Ruta de salida de la app complementaria", + "Create a managed library": "Crear una biblioteca gestionada", + "Create and manage this Emby library": "Crear y gestionar esta biblioteca de Emby", + "Create mapping": "Crear asignación", + "Custom playlist group": "Grupo de lista de reproducción personalizada", + "Do not clean up files": "No limpiar archivos", + "Emby library": "Biblioteca de Emby", + "Emby library configuration differs from this mapping.": "La configuración de la biblioteca de Emby difiere de esta asignación.", + "Existing library": "Biblioteca existente", + "Keep stale managed files": "Conservar archivos gestionados obsoletos", + "Last error": "Último error", + "Last success": "Último éxito", + "Library name": "Nombre de la biblioteca", + "Library type": "Tipo de biblioteca", + "Managed": "Gestionada", + "Managed Libraries": "Bibliotecas gestionadas", + "Managed library configuration drifted": "La configuración de la biblioteca gestionada ha divergido", + "Managed library plan updated": "Plan de biblioteca gestionada actualizado", + "Managed library reconcile failed": "Error al conciliar la biblioteca gestionada", + "Mapped group": "Grupo asignado", + "Naming": "Nomenclatura", + "Not applied": "No aplicada", + "Only paths validated and advertised by m3u-editor for Emby are available.": "Solo están disponibles las rutas validadas y anunciadas por m3u-editor para Emby.", + "Output path": "Ruta de salida", + "Publish local NFO": "Publicar NFO local", + "Publish visible versions": "Publicar versiones visibles", + "Publishing options": "Opciones de publicación", + "Reconcile": "Conciliar", + "Reconcile failed.": "Error al conciliar.", + "Refresh Emby after successful sync": "Actualizar Emby tras una sincronización exitosa", + "Replace stale managed files": "Reemplazar archivos gestionados obsoletos", + "Revision planned for companion sync.": "Revisión planificada para la sincronización de la app complementaria.", + "Series category": "Categoría de series", + "Source type": "Tipo de origen", + "TV shows": "Series de TV", + "Title and year": "Título y año", + "Title only": "Solo título", + "Unnamed library": "Biblioteca sin nombre", + "VOD group": "Grupo VOD", + "The existing Emby library's name, type, or paths no longer match this mapping. Update it manually in Emby, or delete it there to let reconcile recreate it.": "El nombre, tipo o rutas de la biblioteca de Emby existente ya no coinciden con esta asignación. Actualícela manualmente en Emby, o elimínela allí para que la conciliación la vuelva a crear.", + "Choose the specific group or category within the custom playlist to publish.": "Elija el grupo o categoría específico dentro de la lista de reproducción personalizada que se va a publicar.", + "Automatically set from the source selected above.": "Se establece automáticamente según la fuente seleccionada arriba.", + "Choose a library type first.": "Primero elija un tipo de biblioteca.", + "This custom playlist has no VOD groups available to publish as movies.": "Esta lista de reproducción personalizada no tiene grupos VOD disponibles para publicar como películas.", + "This custom playlist has no series categories available to publish as TV shows.": "Esta lista de reproducción personalizada no tiene categorías de series disponibles para publicar como series de TV.", + "Catalog plan preview": "Vista previa del plan de catálogo", + "Showing :shown of :total items": "Mostrando :shown de :total elementos", + "The full catalog (all :total items) is what actually gets synced — this preview is capped so a large library does not crash the browser.": "El catálogo completo (los :total elementos) es lo que realmente se sincroniza; esta vista previa está limitada para que una biblioteca grande no bloquee el navegador." } diff --git a/lang/fr.json b/lang/fr.json index 6d7829c07..788dc0d0e 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -4958,5 +4958,56 @@ "your-api-key-here": "votre-clé-api-ici", "yt-dlp format selector followed by optional flags. Example: bestvideo+bestaudio/best --no-playlist": "sélecteur de format yt-dlp suivi d'indicateurs facultatifs. Exemple : bestvideo+bestaudio/best --no-playlist", "—": "—", - "— Any —": "- N'importe lequel -" + "— Any —": "- N'importe lequel -", + "All eligible items": "Tous les éléments éligibles", + "Applied revision": "Révision appliquée", + "Cleanup": "Nettoyage", + "Companion output path": "Chemin de sortie de l'application compagnon", + "Create a managed library": "Créer une bibliothèque gérée", + "Create and manage this Emby library": "Créer et gérer cette bibliothèque Emby", + "Create mapping": "Créer une association", + "Custom playlist group": "Groupe de liste de lecture personnalisée", + "Do not clean up files": "Ne pas nettoyer les fichiers", + "Emby library": "Bibliothèque Emby", + "Emby library configuration differs from this mapping.": "La configuration de la bibliothèque Emby diffère de cette association.", + "Existing library": "Bibliothèque existante", + "Keep stale managed files": "Conserver les fichiers gérés obsolètes", + "Last error": "Dernière erreur", + "Last success": "Dernier succès", + "Library name": "Nom de la bibliothèque", + "Library type": "Type de bibliothèque", + "Managed": "Gérée", + "Managed Libraries": "Bibliothèques gérées", + "Managed library configuration drifted": "La configuration de la bibliothèque gérée a dérivé", + "Managed library plan updated": "Plan de bibliothèque gérée mis à jour", + "Managed library reconcile failed": "Échec de la réconciliation de la bibliothèque gérée", + "Mapped group": "Groupe associé", + "Naming": "Nommage", + "Not applied": "Non appliquée", + "Only paths validated and advertised by m3u-editor for Emby are available.": "Seuls les chemins validés et annoncés par m3u-editor pour Emby sont disponibles.", + "Output path": "Chemin de sortie", + "Publish local NFO": "Publier le NFO local", + "Publish visible versions": "Publier les versions visibles", + "Publishing options": "Options de publication", + "Reconcile": "Réconcilier", + "Reconcile failed.": "Échec de la réconciliation.", + "Refresh Emby after successful sync": "Actualiser Emby après une synchronisation réussie", + "Replace stale managed files": "Remplacer les fichiers gérés obsolètes", + "Revision planned for companion sync.": "Révision planifiée pour la synchronisation de l'application compagnon.", + "Series category": "Catégorie de séries", + "Source type": "Type de source", + "TV shows": "Séries télévisées", + "Title and year": "Titre et année", + "Title only": "Titre uniquement", + "Unnamed library": "Bibliothèque sans nom", + "VOD group": "Groupe VOD", + "The existing Emby library's name, type, or paths no longer match this mapping. Update it manually in Emby, or delete it there to let reconcile recreate it.": "Le nom, le type ou les chemins de la bibliothèque Emby existante ne correspondent plus à cette association. Mettez-la à jour manuellement dans Emby, ou supprimez-la là-bas pour que la réconciliation la recrée.", + "Choose the specific group or category within the custom playlist to publish.": "Choisissez le groupe ou la catégorie spécifique au sein de la liste de lecture personnalisée à publier.", + "Automatically set from the source selected above.": "Défini automatiquement à partir de la source sélectionnée ci-dessus.", + "Choose a library type first.": "Choisissez d'abord un type de bibliothèque.", + "This custom playlist has no VOD groups available to publish as movies.": "Cette liste de lecture personnalisée ne contient aucun groupe VOD pouvant être publié en tant que films.", + "This custom playlist has no series categories available to publish as TV shows.": "Cette liste de lecture personnalisée ne contient aucune catégorie de séries pouvant être publiée en tant que séries télévisées.", + "Catalog plan preview": "Aperçu du plan de catalogue", + "Showing :shown of :total items": "Affichage de :shown éléments sur :total", + "The full catalog (all :total items) is what actually gets synced — this preview is capped so a large library does not crash the browser.": "Le catalogue complet (les :total éléments) est ce qui est réellement synchronisé — cet aperçu est limité pour qu'une grande bibliothèque ne fasse pas planter le navigateur." } diff --git a/lang/zh_CN.json b/lang/zh_CN.json index 6318c86c5..62b1317d3 100644 --- a/lang/zh_CN.json +++ b/lang/zh_CN.json @@ -4958,5 +4958,56 @@ "your-api-key-here": "在此输入您的 API 密钥", "yt-dlp format selector followed by optional flags. Example: bestvideo+bestaudio/best --no-playlist": "yt-dlp 格式选择器后跟可选标志。示例:bestvideo+bestaudio/best --no-playlist", "—": "—", - "— Any —": "- 任何 -" + "— Any —": "- 任何 -", + "All eligible items": "所有符合条件的项目", + "Applied revision": "已应用的修订版本", + "Cleanup": "清理", + "Companion output path": "配套应用输出路径", + "Create a managed library": "创建受管理的媒体库", + "Create and manage this Emby library": "创建并管理此 Emby 媒体库", + "Create mapping": "创建映射", + "Custom playlist group": "自定义播放列表分组", + "Do not clean up files": "不清理文件", + "Emby library": "Emby 媒体库", + "Emby library configuration differs from this mapping.": "Emby 媒体库配置与此映射不一致。", + "Existing library": "现有媒体库", + "Keep stale managed files": "保留过期的受管理文件", + "Last error": "最近一次错误", + "Last success": "最近一次成功", + "Library name": "媒体库名称", + "Library type": "媒体库类型", + "Managed": "受管理", + "Managed Libraries": "受管理的媒体库", + "Managed library configuration drifted": "受管理媒体库配置已出现偏差", + "Managed library plan updated": "受管理媒体库计划已更新", + "Managed library reconcile failed": "受管理媒体库同步协调失败", + "Mapped group": "已映射分组", + "Naming": "命名", + "Not applied": "尚未应用", + "Only paths validated and advertised by m3u-editor for Emby are available.": "仅提供经过 m3u-editor 为 Emby 验证并发布的路径。", + "Output path": "输出路径", + "Publish local NFO": "发布本地 NFO", + "Publish visible versions": "发布可见版本", + "Publishing options": "发布选项", + "Reconcile": "协调同步", + "Reconcile failed.": "协调同步失败。", + "Refresh Emby after successful sync": "同步成功后刷新 Emby", + "Replace stale managed files": "替换过期的受管理文件", + "Revision planned for companion sync.": "已为配套应用同步计划修订版本。", + "Series category": "剧集分类", + "Source type": "来源类型", + "TV shows": "电视剧", + "Title and year": "标题和年份", + "Title only": "仅标题", + "Unnamed library": "未命名媒体库", + "VOD group": "点播 (VOD) 分组", + "The existing Emby library's name, type, or paths no longer match this mapping. Update it manually in Emby, or delete it there to let reconcile recreate it.": "现有 Emby 媒体库的名称、类型或路径与此映射不再匹配。请在 Emby 中手动更新,或在那里删除它,以便协调同步重新创建它。", + "Choose the specific group or category within the custom playlist to publish.": "选择要发布的自定义播放列表中的具体分组或分类。", + "Automatically set from the source selected above.": "根据上方选择的来源自动设置。", + "Choose a library type first.": "请先选择媒体库类型。", + "This custom playlist has no VOD groups available to publish as movies.": "此自定义播放列表没有可作为电影发布的点播 (VOD) 分组。", + "This custom playlist has no series categories available to publish as TV shows.": "此自定义播放列表没有可作为电视剧发布的剧集分类。", + "Catalog plan preview": "目录计划预览", + "Showing :shown of :total items": "显示 :total 个项目中的 :shown 个", + "The full catalog (all :total items) is what actually gets synced — this preview is capped so a large library does not crash the browser.": "实际同步的是完整目录(全部 :total 个项目)——为避免大型媒体库导致浏览器崩溃,此预览已做限制。" } diff --git a/resources/views/filament/resources/media-server-integrations/relation-managers/emby-library-mapping-preview.blade.php b/resources/views/filament/resources/media-server-integrations/relation-managers/emby-library-mapping-preview.blade.php new file mode 100644 index 000000000..97f537b8c --- /dev/null +++ b/resources/views/filament/resources/media-server-integrations/relation-managers/emby-library-mapping-preview.blade.php @@ -0,0 +1,19 @@ +@php + $json = json_encode($catalog, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}'; +@endphp + +
+ @if ($itemsTotal > $itemsShown) + + + {{ __('Showing :shown of :total items', ['shown' => $itemsShown, 'total' => $itemsTotal]) }} + + + + {{ __('The full catalog (all :total items) is what actually gets synced — this preview is capped so a large library does not crash the browser.', ['total' => $itemsTotal]) }} + + + @endif + +
{{ $json }}
+
diff --git a/tests/Feature/EmbyLibraryManagementTest.php b/tests/Feature/EmbyLibraryManagementTest.php new file mode 100644 index 000000000..c80918a09 --- /dev/null +++ b/tests/Feature/EmbyLibraryManagementTest.php @@ -0,0 +1,306 @@ +toBeTrue(); +}); + +beforeEach(function () { + $user = User::factory()->create(); + $this->integration = MediaServerIntegration::factory()->for($user)->createQuietly([ + 'type' => 'emby', + 'host' => 'emby.test', + 'port' => 8096, + 'ssl' => true, + 'api_key' => 'emby-secret', + ]); +}); + +it('creates an Emby library through the official virtual folders endpoint', function () { + Http::preventStrayRequests(); + Http::fakeSequence('https://emby.test:8096/Library/VirtualFolders') + ->push([], 200) + ->push([], 204) + ->push([[ + 'ItemId' => 'library-1', + 'Name' => 'Managed Movies', + 'CollectionType' => 'movies', + 'Locations' => ['/srv/emby/managed/movies'], + ]], 200); + + $result = MediaServerService::make($this->integration)->createLibrary( + name: 'Managed Movies', + collectionType: 'movies', + paths: ['/srv/emby/managed/movies'], + refreshLibrary: false, + ); + + expect($result['success'])->toBeTrue() + ->and($result['created'])->toBeTrue() + ->and($result['library']['id'])->toBe('library-1'); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' + && $request->url() === 'https://emby.test:8096/Library/VirtualFolders' + && $request->hasHeader('X-Emby-Token', 'emby-secret') + && $request->data() === [ + 'Name' => 'Managed Movies', + 'CollectionType' => 'movies', + 'Paths' => ['/srv/emby/managed/movies'], + 'RefreshLibrary' => false, + ]); + Http::assertNotSent(fn (Request $request): bool => $request->method() === 'DELETE'); +}); + +it('rejects unsupported Emby library collection types before making a request', function () { + Http::preventStrayRequests(); + Http::fake(); + + $result = MediaServerService::make($this->integration)->createLibrary( + name: 'Managed Music', + collectionType: 'music', + paths: ['/srv/emby/managed/music'], + ); + + expect($result)->toMatchArray([ + 'success' => false, + 'created' => false, + ]); + Http::assertNothingSent(); +}); + +it('rejects non-absolute Emby library paths before making a request', function () { + Http::preventStrayRequests(); + Http::fake(); + + $result = MediaServerService::make($this->integration)->createLibrary( + name: 'Managed Movies', + collectionType: 'movies', + paths: ['relative/movies'], + ); + + expect($result['success'])->toBeFalse(); + Http::assertNothingSent(); +}); + +it('does not create libraries for Jellyfin integrations', function () { + Http::preventStrayRequests(); + Http::fake(); + $this->integration->update(['type' => 'jellyfin']); + + $result = MediaServerService::make($this->integration)->createLibrary( + name: 'Managed Movies', + collectionType: 'movies', + paths: ['/srv/jellyfin/managed/movies'], + ); + + expect($result['success'])->toBeFalse(); + Http::assertNothingSent(); +}); + +it('reconciles an existing Emby library by stable ID without creating a duplicate', function () { + Http::preventStrayRequests(); + Http::fake([ + 'https://emby.test:8096/Library/VirtualFolders' => Http::response([[ + 'ItemId' => 'library-1', + 'Name' => 'Renamed Managed Movies', + 'CollectionType' => 'movies', + 'Locations' => ['/srv/emby/moved/movies'], + ]], 200), + ]); + + $result = MediaServerService::make($this->integration)->createLibrary( + name: 'Managed Movies', + collectionType: 'movies', + paths: ['/srv/emby/managed/movies'], + libraryId: 'library-1', + ); + + expect($result['success'])->toBeTrue() + ->and($result['created'])->toBeFalse() + ->and($result['library']['id'])->toBe('library-1') + ->and($result['drift'])->toBeTrue(); + Http::assertSentCount(1); + Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST' + || $request->method() === 'DELETE'); +}); + +it('reconciles an existing Emby library by exact managed name and path', function () { + Http::preventStrayRequests(); + Http::fake([ + 'https://emby.test:8096/Library/VirtualFolders' => Http::response([[ + 'ItemId' => 'library-2', + 'Name' => 'Managed TV', + 'CollectionType' => 'tvshows', + 'Locations' => ['/srv/emby/managed/tv'], + ]], 200), + ]); + + $result = MediaServerService::make($this->integration)->createLibrary( + name: 'Managed TV', + collectionType: 'tvshows', + paths: ['/srv/emby/managed/tv'], + libraryId: 'missing-library', + ); + + expect($result['success'])->toBeTrue() + ->and($result['created'])->toBeFalse() + ->and($result['library']['id'])->toBe('library-2'); + Http::assertSentCount(1); + Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST' + || $request->method() === 'DELETE'); +}); + +it('fails closed when an Emby library name exists at a different path', function () { + Http::preventStrayRequests(); + Http::fake([ + 'https://emby.test:8096/Library/VirtualFolders' => Http::response([[ + 'ItemId' => 'library-3', + 'Name' => 'Managed Movies', + 'CollectionType' => 'movies', + 'Locations' => ['/srv/emby/unmanaged/movies'], + ]], 200), + ]); + + $result = MediaServerService::make($this->integration)->createLibrary( + name: 'Managed Movies', + collectionType: 'movies', + paths: ['/srv/emby/managed/movies'], + ); + + expect($result['success'])->toBeFalse() + ->and($result['created'])->toBeFalse() + ->and($result['drift'])->toBeTrue(); + Http::assertSentCount(1); + Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST' + || $request->method() === 'DELETE'); +}); + +it('normalizes Emby errors without exposing response details', function () { + Http::preventStrayRequests(); + Http::fakeSequence('https://emby.test:8096/Library/VirtualFolders') + ->push([], 200) + ->push('upstream secret: emby-secret', 500) + ->push('upstream secret: emby-secret', 500); + + $result = MediaServerService::make($this->integration)->createLibrary( + name: 'Managed Movies', + collectionType: 'movies', + paths: ['/srv/emby/managed/movies'], + ); + + expect($result['success'])->toBeFalse() + ->and($result['message'])->not->toContain('emby-secret') + ->and($result['message'])->not->toContain('upstream secret'); + Http::assertNotSent(fn (Request $request): bool => $request->method() === 'DELETE'); +}); + +it('fails closed for movie imports while a created managed library is unresolved', function () { + EmbyLibraryMapping::factory() + ->for($this->integration->user) + ->for($this->integration, 'integration') + ->create([ + 'collection_type' => 'movies', + 'target_library_id' => null, + 'target_library_name' => 'Managed Movies', + 'output_path' => '/srv/emby/managed/movies', + 'is_managed' => true, + 'enabled' => true, + ]); + Http::preventStrayRequests(); + Http::fake([ + 'https://emby.test:8096/Library/VirtualFolders' => Http::sequence() + ->push([], 200) + ->push([], 204) + ->push([], 200), + 'https://emby.test:8096/Items*' => Http::response(['Items' => []], 200), + ]); + + $service = MediaServerService::make($this->integration); + $result = $service->createLibrary( + name: 'Managed Movies', + collectionType: 'movies', + paths: ['/srv/emby/managed/movies'], + refreshLibrary: false, + ); + + expect($result['success'])->toBeTrue() + ->and($result['created'])->toBeTrue() + ->and($result['library'])->toBeNull() + ->and($service->fetchMovies())->toBeEmpty(); + Http::assertNotSent(fn (Request $request): bool => str_starts_with($request->url(), 'https://emby.test:8096/Items?') + && ($request->data()['ParentId'] ?? null) === null); +}); + +it('fails closed for series imports while a managed library is unresolved', function () { + EmbyLibraryMapping::factory() + ->for($this->integration->user) + ->for($this->integration, 'integration') + ->create([ + 'collection_type' => 'tvshows', + 'target_library_id' => null, + 'target_library_name' => 'Managed TV', + 'output_path' => '/srv/emby/managed/tv', + 'is_managed' => true, + 'enabled' => true, + ]); + Http::preventStrayRequests(); + Http::fake([ + 'https://emby.test:8096/Items*' => Http::response(['Items' => []], 200), + ]); + + expect(MediaServerService::make($this->integration)->fetchSeries())->toBeEmpty(); + Http::assertNothingSent(); +}); + +it('excludes managed Emby libraries from default movie imports', function () { + $this->integration->update([ + 'available_libraries' => [ + ['id' => 'source-library', 'name' => 'Source Movies', 'type' => 'movies'], + ['id' => 'managed-library', 'name' => 'Managed Movies', 'type' => 'movies'], + ], + 'selected_library_ids' => [], + ]); + EmbyLibraryMapping::factory() + ->for($this->integration->user) + ->for($this->integration, 'integration') + ->create([ + 'target_library_id' => 'managed-library', + 'target_library_name' => 'Managed Movies', + 'is_managed' => true, + ]); + Http::preventStrayRequests(); + Http::fake([ + 'https://emby.test:8096/Items*' => Http::response(['Items' => []], 200), + ]); + + MediaServerService::make($this->integration->refresh())->fetchMovies(); + + Http::assertSentCount(1); + Http::assertSent(fn (Request $request): bool => $request->method() === 'GET' + && ($request->data()['ParentId'] ?? null) === 'source-library'); + Http::assertNotSent(fn (Request $request): bool => ($request->data()['ParentId'] ?? null) === 'managed-library' + || ($request->method() === 'GET' && ($request->data()['ParentId'] ?? null) === null)); +}); + +it('preserves unfiltered imports for media types without selected libraries', function () { + $this->integration->update([ + 'available_libraries' => [ + ['id' => 'movie-library', 'name' => 'Movies', 'type' => 'movies'], + ['id' => 'series-library', 'name' => 'Series', 'type' => 'tvshows'], + ], + 'selected_library_ids' => ['series-library'], + ]); + + expect($this->integration->getImportLibraryIdsForType('movies'))->toBeNull() + ->and($this->integration->getImportLibraryIdsForType('tvshows'))->toBe(['series-library']); +}); diff --git a/tests/Feature/EmbyLibraryMappingRelationManagerTest.php b/tests/Feature/EmbyLibraryMappingRelationManagerTest.php new file mode 100644 index 000000000..e87ada5e8 --- /dev/null +++ b/tests/Feature/EmbyLibraryMappingRelationManagerTest.php @@ -0,0 +1,620 @@ +instance(); + $method = new ReflectionMethod($instance, 'sourceLabelOptions'); + $method->setAccessible(true); + + return $method->invoke($instance, $sourceKind, $sourceIdentifier, $collectionType); +} + +/** Invokes the relation manager's private sourceSearchOptions() directly. */ +function embySourceSearchOptions($component, ?string $sourceKind, string $search, ?string $onlyIdentifier = null): array +{ + $instance = $component->instance(); + $method = new ReflectionMethod($instance, 'sourceSearchOptions'); + $method->setAccessible(true); + + return $method->invoke($instance, $sourceKind, $search, $onlyIdentifier); +} + +it('shows managed library mappings only on authorized Emby integrations', function () { + $user = User::factory()->create(['permissions' => ['use_integrations']]); + $this->actingAs($user); + $emby = MediaServerIntegration::factory()->for($user)->createQuietly(['type' => 'emby']); + $jellyfin = MediaServerIntegration::factory()->for($user)->createQuietly(['type' => 'jellyfin']); + $foreignEmby = MediaServerIntegration::factory()->createQuietly(['type' => 'emby']); + + expect(EmbyLibraryMappingsRelationManager::canViewForRecord($emby, EditMediaServerIntegration::class))->toBeTrue() + ->and(EmbyLibraryMappingsRelationManager::canViewForRecord($jellyfin, EditMediaServerIntegration::class))->toBeFalse() + ->and(EmbyLibraryMappingsRelationManager::canViewForRecord($foreignEmby, EditMediaServerIntegration::class))->toBeFalse(); + + $admin = User::factory()->admin()->create(); + $this->actingAs($admin); + + expect(EmbyLibraryMappingsRelationManager::canViewForRecord($foreignEmby, EditMediaServerIntegration::class))->toBeTrue(); +}); + +it('creates an owned mapping from eligible sources and companion writable paths', function () { + config(['app.key' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=']); + $user = User::factory()->create(['permissions' => ['use_integrations']]); + $this->actingAs($user); + $playlist = Playlist::factory()->for($user)->createQuietly(); + $group = Group::factory()->for($user)->for($playlist)->create(['name' => 'Action', 'type' => 'vod']); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly([ + 'type' => 'emby', + 'emby_publisher_writable_paths' => ['/srv/emby/managed/movies'], + ]); + + Livewire::test(EmbyLibraryMappingsRelationManager::class, [ + 'ownerRecord' => $integration, + 'pageClass' => EditMediaServerIntegration::class, + ])->callAction(TestAction::make('create')->table(), [ + 'enabled' => true, + 'source_kind' => 'vod_group', + 'source_identifier' => (string) $group->id, + 'source_label' => 'Action', + 'target_library_id' => null, + 'target_library_name' => 'Managed Movies', + 'collection_type' => 'movies', + 'output_path' => '/srv/emby/managed/movies', + 'is_managed' => true, + 'options' => [ + 'naming' => 'media-year', + 'nfo' => true, + 'versions' => true, + 'cleanup' => 'replace', + 'refresh' => true, + ], + ])->assertHasNoActionErrors(); + + $mapping = EmbyLibraryMapping::query()->sole(); + expect($mapping->user_id)->toBe($user->id) + ->and($mapping->media_server_integration_id)->toBe($integration->id) + ->and($mapping->source_identifier)->toBe((string) $group->id) + ->and($mapping->output_path)->toBe('/srv/emby/managed/movies'); +}); + +it('registers companion writable paths before creating the first owned mapping', function () { + config(['app.key' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=']); + $user = User::factory()->create(['permissions' => ['use_integrations']]); + $this->actingAs($user); + $playlist = Playlist::factory()->for($user)->createQuietly(); + $group = Group::factory()->for($user)->for($playlist)->create([ + 'name' => 'Action', + 'type' => 'vod', + ]); + $auth = PlaylistAuth::factory()->for($user)->create([ + 'enabled' => true, + 'username' => 'emby-companion', + 'password' => 'companion-secret', + 'library_publishing_enabled' => true, + ]); + $auth->assignTo($playlist); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly([ + 'type' => 'emby', + 'emby_publisher_writable_paths' => null, + ]); + + $this->postJson('/player_api.php', [ + 'username' => 'emby-companion', + 'password' => 'companion-secret', + 'action' => 'm3u_editor_register_publisher', + 'api_version' => 1, + 'integration_id' => $integration->id, + 'writable_paths' => ['/srv/emby/managed/movies'], + ])->assertOk() + ->assertJsonPath('data.integration_id', $integration->id) + ->assertJsonPath('data.writable_paths', ['/srv/emby/managed/movies']); + + expect($integration->refresh()->emby_publisher_writable_paths) + ->toBe(['/srv/emby/managed/movies']) + ->and($integration->emby_publisher_capabilities_updated_at)->not->toBeNull(); + + Livewire::test(EmbyLibraryMappingsRelationManager::class, [ + 'ownerRecord' => $integration, + 'pageClass' => EditMediaServerIntegration::class, + ])->callAction(TestAction::make('create')->table(), [ + 'enabled' => true, + 'source_kind' => 'vod_group', + 'source_identifier' => (string) $group->id, + 'source_label' => 'Action', + 'target_library_id' => null, + 'target_library_name' => 'Managed Movies', + 'collection_type' => 'movies', + 'output_path' => '/srv/emby/managed/movies', + 'is_managed' => true, + 'options' => [ + 'naming' => 'media-year', + 'nfo' => true, + 'versions' => true, + 'cleanup' => 'replace', + 'refresh' => true, + ], + ])->assertHasNoActionErrors(); + + $mapping = EmbyLibraryMapping::query()->sole(); + expect($mapping->user_id)->toBe($user->id) + ->and($mapping->media_server_integration_id)->toBe($integration->id) + ->and($mapping->output_path)->toBe('/srv/emby/managed/movies'); +}); + +it('rejects foreign sources and unadvertised output paths', function () { + config(['app.key' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=']); + $user = User::factory()->create(['permissions' => ['use_integrations']]); + $otherUser = User::factory()->create(); + $this->actingAs($user); + $playlist = Playlist::factory()->for($user)->createQuietly(); + $otherPlaylist = Playlist::factory()->for($otherUser)->createQuietly(); + $foreignGroup = Group::factory()->for($otherUser)->for($otherPlaylist)->create([ + 'name' => 'Foreign', + 'type' => 'vod', + ]); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly([ + 'type' => 'emby', + 'emby_publisher_writable_paths' => ['/srv/emby/managed/movies'], + ]); + + Livewire::test(EmbyLibraryMappingsRelationManager::class, [ + 'ownerRecord' => $integration, + 'pageClass' => EditMediaServerIntegration::class, + ])->callAction(TestAction::make('create')->table(), [ + 'enabled' => true, + 'source_kind' => 'vod_group', + 'source_identifier' => (string) $foreignGroup->id, + 'source_label' => 'Foreign', + 'target_library_name' => 'Managed Movies', + 'collection_type' => 'movies', + 'output_path' => '/unadvertised/path', + 'is_managed' => true, + 'options' => [ + 'naming' => 'media-year', + 'cleanup' => 'replace', + ], + ])->assertHasActionErrors([ + 'source_identifier', + 'source_label', + 'output_path', + ]); + + expect(EmbyLibraryMapping::query()->count())->toBe(0); +}); + +it('shows mapping state and toggles publishing without deleting state', function () { + config(['app.key' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=']); + $user = User::factory()->create(['permissions' => ['use_integrations']]); + $this->actingAs($user); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly([ + 'type' => 'emby', + 'emby_publisher_writable_paths' => ['/srv/emby/managed/movies'], + ]); + $mapping = EmbyLibraryMapping::factory()->for($user)->for($integration, 'integration')->create([ + 'source_kind' => 'all', + 'source_identifier' => '*', + 'source_label' => 'All eligible items', + 'target_library_id' => null, + 'output_path' => '/srv/emby/managed/movies', + 'status' => 'failed', + 'error_summary' => 'Redacted failure', + 'enabled' => true, + ]); + + Livewire::test(EmbyLibraryMappingsRelationManager::class, [ + 'ownerRecord' => $integration, + 'pageClass' => EditMediaServerIntegration::class, + ])->assertCanSeeTableRecords([$mapping]) + ->assertSee('Redacted failure') + ->assertTableActionExists('preview') + ->assertTableActionExists('reconcile') + ->assertTableActionExists('edit') + ->assertTableActionExists('delete') + ->call('updateTableColumnState', 'enabled', (string) $mapping->id, false); + + expect($mapping->refresh()->enabled)->toBeFalse() + ->and($mapping->exists)->toBeTrue(); +}); + +it('previews the exact canonical dry-run plan without mutating the mapping', function () { + config(['app.key' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=']); + $user = User::factory()->create(['permissions' => ['use_integrations']]); + $this->actingAs($user); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly(['type' => 'emby']); + $mapping = EmbyLibraryMapping::factory()->for($user)->for($integration, 'integration')->create([ + 'source_kind' => 'all', + 'source_identifier' => '*', + 'source_label' => 'All eligible items', + 'target_library_id' => null, + 'last_planned_revision' => null, + ]); + $plan = app(EmbyPublicationCatalogService::class)->buildMapping($mapping); + + Livewire::test(EmbyLibraryMappingsRelationManager::class, [ + 'ownerRecord' => $integration, + 'pageClass' => EditMediaServerIntegration::class, + ])->assertTableActionExists( + 'preview', + fn (FilamentAction $action): bool => str_contains( + (string) $action->getModalContent(), + $plan['revision'], + ), + $mapping, + ); + + expect($mapping->refresh()->last_planned_revision)->toBeNull(); +}); + +it('defers reconcile with a generic pending result while the managed library is unresolved', function () { + config(['app.key' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=']); + $user = User::factory()->create(['permissions' => ['use_integrations']]); + $this->actingAs($user); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly([ + 'type' => 'emby', + 'host' => 'emby.test', + 'port' => 8096, + 'ssl' => true, + 'api_key' => 'emby-secret', + 'emby_publisher_writable_paths' => ['/srv/emby/managed/movies'], + ]); + $mapping = EmbyLibraryMapping::factory()->for($user)->for($integration, 'integration')->create([ + 'source_kind' => 'all', + 'source_identifier' => '*', + 'source_label' => 'All eligible items', + 'target_library_id' => null, + 'target_library_name' => 'Managed Movies', + 'output_path' => '/srv/emby/managed/movies', + 'is_managed' => true, + 'last_planned_revision' => 'unsafe-revision', + ]); + Http::preventStrayRequests(); + Http::fakeSequence('https://emby.test:8096/Library/VirtualFolders') + ->push([], 200) + ->push([], 204) + ->push([], 200); + + Livewire::test(EmbyLibraryMappingsRelationManager::class, [ + 'ownerRecord' => $integration, + 'pageClass' => EditMediaServerIntegration::class, + ])->callAction(TestAction::make('reconcile')->table($mapping)) + ->assertNotified(); + + $mapping->refresh(); + expect($mapping->target_library_id)->toBeNull() + ->and($mapping->status)->toBe('pending') + ->and($mapping->status_summary)->toBe('Pending') + ->and($mapping->error_summary)->toBeNull() + ->and($mapping->last_planned_revision)->toBeNull() + ->and($mapping->status_summary.$mapping->error_summary) + ->not->toContain('emby-secret', '/srv/emby/managed/movies'); + Http::assertNotSent(fn (Request $request): bool => $request->method() === 'DELETE'); +}); + +it('resolves a pending managed library from a later exact listing without duplicate state', function () { + config(['app.key' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=']); + $user = User::factory()->create(['permissions' => ['use_integrations']]); + $this->actingAs($user); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly([ + 'type' => 'emby', + 'host' => 'emby.test', + 'port' => 8096, + 'ssl' => true, + 'api_key' => 'emby-secret', + 'emby_publisher_writable_paths' => ['/srv/emby/managed/movies'], + ]); + $mapping = EmbyLibraryMapping::factory()->for($user)->for($integration, 'integration')->create([ + 'source_kind' => 'all', + 'source_identifier' => '*', + 'source_label' => 'All eligible items', + 'target_library_id' => null, + 'target_library_name' => 'Managed Movies', + 'output_path' => '/srv/emby/managed/movies', + 'is_managed' => true, + 'last_planned_revision' => null, + ]); + Http::preventStrayRequests(); + Http::fakeSequence('https://emby.test:8096/Library/VirtualFolders') + ->push([], 200) + ->push([], 204) + ->push([], 200) + ->push([[ + 'ItemId' => 'managed-library-1', + 'Name' => 'Managed Movies', + 'CollectionType' => 'movies', + 'Locations' => ['/srv/emby/managed/movies'], + ]], 200); + + $component = Livewire::test(EmbyLibraryMappingsRelationManager::class, [ + 'ownerRecord' => $integration, + 'pageClass' => EditMediaServerIntegration::class, + ])->callAction(TestAction::make('reconcile')->table($mapping)) + ->assertNotified(); + + expect($mapping->refresh()->status)->toBe('pending') + ->and($mapping->target_library_id)->toBeNull(); + + $component->callAction(TestAction::make('reconcile')->table($mapping)) + ->assertNotified(); + + $mapping->refresh(); + $currentPlan = app(EmbyPublicationCatalogService::class)->buildMapping($mapping); + expect($mapping->target_library_id)->toBe('managed-library-1') + ->and($mapping->status)->toBe('planned') + ->and($mapping->last_planned_revision)->toBe($currentPlan['revision']) + ->and(EmbyLibraryMapping::query()->count())->toBe(1) + ->and(Http::recorded(fn (Request $request): bool => $request->method() === 'POST'))->toHaveCount(1); + Http::assertNotSent(fn (Request $request): bool => $request->method() === 'DELETE'); +}); + +it('creates a managed Emby library and plans a bounded manual reconcile', function () { + config(['app.key' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=']); + $user = User::factory()->create(['permissions' => ['use_integrations']]); + $this->actingAs($user); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly([ + 'type' => 'emby', + 'host' => 'emby.test', + 'port' => 8096, + 'ssl' => true, + 'api_key' => 'emby-secret', + 'emby_publisher_writable_paths' => ['/srv/emby/managed/movies'], + ]); + $mapping = EmbyLibraryMapping::factory()->for($user)->for($integration, 'integration')->create([ + 'source_kind' => 'all', + 'source_identifier' => '*', + 'source_label' => 'All eligible items', + 'target_library_id' => null, + 'target_library_name' => 'Managed Movies', + 'output_path' => '/srv/emby/managed/movies', + 'is_managed' => true, + 'last_planned_revision' => null, + ]); + Http::preventStrayRequests(); + Http::fakeSequence('https://emby.test:8096/Library/VirtualFolders') + ->push([], 200) + ->push([], 204) + ->push([[ + 'ItemId' => 'managed-library-1', + 'Name' => 'Managed Movies', + 'CollectionType' => 'movies', + 'Locations' => ['/srv/emby/managed/movies'], + ]], 200); + + Livewire::test(EmbyLibraryMappingsRelationManager::class, [ + 'ownerRecord' => $integration, + 'pageClass' => EditMediaServerIntegration::class, + ])->callAction(TestAction::make('reconcile')->table($mapping)) + ->assertNotified(); + + $mapping->refresh(); + $currentPlan = app(EmbyPublicationCatalogService::class)->buildMapping($mapping); + expect($mapping->target_library_id)->toBe('managed-library-1') + ->and($mapping->status)->toBe('planned') + ->and($mapping->last_planned_revision)->toBe($currentPlan['revision']) + ->and($mapping->last_applied_revision)->toBeNull(); + Http::assertNotSent(fn (Request $request): bool => $request->method() === 'DELETE'); +}); + +it('edits and deletes an owned mapping through Filament actions', function () { + config(['app.key' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=']); + $user = User::factory()->create(['permissions' => ['use_integrations']]); + $this->actingAs($user); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly([ + 'type' => 'emby', + 'emby_publisher_writable_paths' => ['/srv/emby/managed/movies'], + ]); + $mapping = EmbyLibraryMapping::factory()->for($user)->for($integration, 'integration')->create([ + 'source_kind' => 'all', + 'source_identifier' => '*', + 'source_label' => 'All eligible items', + 'target_library_id' => null, + 'target_library_name' => 'Managed Movies', + 'output_path' => '/srv/emby/managed/movies', + ]); + + $component = Livewire::test(EmbyLibraryMappingsRelationManager::class, [ + 'ownerRecord' => $integration, + 'pageClass' => EditMediaServerIntegration::class, + ])->callAction(TestAction::make('edit')->table($mapping), [ + 'enabled' => true, + 'source_kind' => 'all', + 'source_identifier' => '*', + 'source_label' => 'All eligible items', + 'target_library_id' => null, + 'target_library_name' => 'Renamed Managed Movies', + 'collection_type' => 'movies', + 'output_path' => '/srv/emby/managed/movies', + 'is_managed' => true, + 'options' => [ + 'naming' => 'media-year', + 'nfo' => true, + 'versions' => true, + 'cleanup' => 'replace', + 'refresh' => true, + ], + ])->assertHasNoActionErrors(); + + expect($mapping->refresh()->target_library_name)->toBe('Renamed Managed Movies'); + + $component->callAction(TestAction::make('delete')->table($mapping)); + expect(EmbyLibraryMapping::find($mapping->id))->toBeNull(); +}); + +it('returns no Mapped group options for a live-only custom playlist, for either library type', function () { + $user = User::factory()->create(['permissions' => ['use_integrations']]); + $this->actingAs($user); + $playlist = Playlist::factory()->for($user)->createQuietly(); + $customPlaylist = CustomPlaylist::factory()->for($user)->createQuietly(); + + // Live channels only (is_vod: false, no series attached) — nothing here + // is eligible content for Emby publishing (movies/tvshows only). + $liveChannel = Channel::factory()->for($user)->for($playlist)->createQuietly([ + 'group' => 'PPV', 'is_vod' => false, 'enabled' => true, + ]); + $customPlaylist->channels()->attach([$liveChannel->id]); + + $integration = MediaServerIntegration::factory()->for($user)->createQuietly(['type' => 'emby']); + $component = Livewire::test(EmbyLibraryMappingsRelationManager::class, [ + 'ownerRecord' => $integration, + 'pageClass' => EditMediaServerIntegration::class, + ]); + + expect(embyMappedGroupOptions($component, 'custom_playlist_group', (string) $customPlaylist->id, 'movies'))->toBe([]) + ->and(embyMappedGroupOptions($component, 'custom_playlist_group', (string) $customPlaylist->id, 'tvshows'))->toBe([]); +}); + +it('scopes Mapped group options to VOD groups for movies and series categories for tvshows independently', function () { + $user = User::factory()->create(['permissions' => ['use_integrations']]); + $this->actingAs($user); + $playlist = Playlist::factory()->for($user)->createQuietly(); + $customPlaylist = CustomPlaylist::factory()->for($user)->createQuietly(); + + $vodChannel = Channel::factory()->for($user)->for($playlist)->createQuietly([ + 'group' => 'Action', 'is_vod' => true, 'enabled' => true, + ]); + $customPlaylist->channels()->attach([$vodChannel->id]); + + $category = Category::factory()->for($user)->createQuietly(['name' => 'Drama']); + $series = Series::factory()->for($user)->for($category)->createQuietly(['enabled' => true]); + $customPlaylist->series()->attach([$series->id]); + + $integration = MediaServerIntegration::factory()->for($user)->createQuietly(['type' => 'emby']); + $component = Livewire::test(EmbyLibraryMappingsRelationManager::class, [ + 'ownerRecord' => $integration, + 'pageClass' => EditMediaServerIntegration::class, + ]); + + expect(embyMappedGroupOptions($component, 'custom_playlist_group', (string) $customPlaylist->id, 'movies')) + ->toBe(['Action' => 'Action']) + ->and(embyMappedGroupOptions($component, 'custom_playlist_group', (string) $customPlaylist->id, 'tvshows')) + ->toBe(['Drama' => 'Drama']); +}); + +it('does not prematurely populate Mapped group with the custom playlist\'s own name', function () { + $user = User::factory()->create(['permissions' => ['use_integrations']]); + $this->actingAs($user); + $playlist = Playlist::factory()->for($user)->createQuietly(); + $customPlaylist = CustomPlaylist::factory()->for($user)->createQuietly(['name' => 'Sports']); + $vodChannel = Channel::factory()->for($user)->for($playlist)->createQuietly([ + 'group' => 'Action', 'is_vod' => true, 'enabled' => true, + ]); + $customPlaylist->channels()->attach([$vodChannel->id]); + + $integration = MediaServerIntegration::factory()->for($user)->createQuietly(['type' => 'emby']); + + $component = Livewire::test(EmbyLibraryMappingsRelationManager::class, [ + 'ownerRecord' => $integration, + 'pageClass' => EditMediaServerIntegration::class, + ])->mountAction(TestAction::make('create')->table()); + + // sourceOptions('custom_playlist_group') labels are the CustomPlaylist's + // own name ("Sports") — selecting it as "Source" must not leak that into + // "Mapped group", which is only ever populated from sourceLabelOptions(). + $component->set('mountedActions.0.data.source_kind', 'custom_playlist_group') + ->set('mountedActions.0.data.source_identifier', (string) $customPlaylist->id) + ->assertSet('mountedActions.0.data.source_label', null); + + // Nor should picking a library type resurrect the stale value. + $component->set('mountedActions.0.data.collection_type', 'movies') + ->assertSet('mountedActions.0.data.source_label', null); +}); + +it('disambiguates same-named VOD groups across playlists in the Source search, without leaking the suffix into Mapped group', function () { + $user = User::factory()->create(['permissions' => ['use_integrations']]); + $this->actingAs($user); + $playlistA = Playlist::factory()->for($user)->createQuietly(['name' => 'Provider A']); + $playlistB = Playlist::factory()->for($user)->createQuietly(['name' => 'Provider B']); + $groupA = Group::factory()->for($user)->for($playlistA)->create(['name' => 'Action', 'type' => 'vod']); + $groupB = Group::factory()->for($user)->for($playlistB)->create(['name' => 'Action', 'type' => 'vod']); + + $integration = MediaServerIntegration::factory()->for($user)->createQuietly(['type' => 'emby']); + $component = Livewire::test(EmbyLibraryMappingsRelationManager::class, [ + 'ownerRecord' => $integration, + 'pageClass' => EditMediaServerIntegration::class, + ]); + + $results = embySourceSearchOptions($component, 'vod_group', 'action'); + expect($results)->toBe([ + (string) $groupA->id => 'Action (Provider A)', + (string) $groupB->id => 'Action (Provider B)', + ]); + + // The already-selected-value lookup (getOptionLabelUsing) resolves the same way. + expect(embySourceSearchOptions($component, 'vod_group', '', (string) $groupB->id)) + ->toBe([(string) $groupB->id => 'Action (Provider B)']); + + // Mapped group's auto-populated value must stay the raw group name — it's + // matched verbatim against channels.group by EmbyPublicationCatalogService, + // so the "(Provider B)" UI disambiguation must never leak into it. + $action = Livewire::test(EmbyLibraryMappingsRelationManager::class, [ + 'ownerRecord' => $integration, + 'pageClass' => EditMediaServerIntegration::class, + ])->mountAction(TestAction::make('create')->table()); + + $action->set('mountedActions.0.data.source_kind', 'vod_group') + ->set('mountedActions.0.data.source_identifier', (string) $groupB->id) + ->assertSet('mountedActions.0.data.source_label', 'Action'); +}); + +it('caps the number of items rendered in the Preview modal without affecting the actual revision hash', function () { + config(['app.key' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=']); + $user = User::factory()->create(['permissions' => ['use_integrations']]); + $this->actingAs($user); + $playlist = Playlist::factory()->for($user)->createQuietly(); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly(['type' => 'emby']); + $mapping = EmbyLibraryMapping::factory()->for($user)->for($integration, 'integration')->create([ + 'source_kind' => 'all', + 'source_identifier' => '*', + 'source_label' => 'All eligible items', + 'collection_type' => 'movies', + 'target_library_id' => null, + ]); + + // More than the 50-item preview cap. + Channel::factory()->for($user)->for($playlist)->count(60)->createQuietly([ + 'is_vod' => true, 'enabled' => true, + ]); + + $fullPlan = app(EmbyPublicationCatalogService::class)->buildMapping($mapping); + expect($fullPlan['items'])->toHaveCount(60); + + Livewire::test(EmbyLibraryMappingsRelationManager::class, [ + 'ownerRecord' => $integration, + 'pageClass' => EditMediaServerIntegration::class, + ])->assertTableActionExists('preview', function (FilamentAction $action) use ($fullPlan): bool { + $modalHtml = (string) $action->getModalContent(); + + // The rendered JSON only carries 50 items... + $renderedItemCount = substr_count($modalHtml, '"canonical_id"'); + + // ...but the revision shown is still the hash of the complete, untruncated catalog. + return $renderedItemCount === 50 + && str_contains($modalHtml, $fullPlan['revision']); + }, $mapping); +}); diff --git a/tests/Feature/EmbyLibraryPublishingTest.php b/tests/Feature/EmbyLibraryPublishingTest.php new file mode 100644 index 000000000..b61b44b91 --- /dev/null +++ b/tests/Feature/EmbyLibraryPublishingTest.php @@ -0,0 +1,143 @@ +create(); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly(['type' => 'emby']); + + $mapping = EmbyLibraryMapping::create([ + 'media_server_integration_id' => $integration->id, + 'user_id' => $user->id, + 'source_kind' => 'vod_group', + 'source_identifier' => '42', + 'source_label' => 'Action', + 'target_library_id' => 'emby-library-1', + 'target_library_name' => 'Managed Movies', + 'collection_type' => 'movies', + 'output_path' => '/media/m3u-editor/movies', + 'is_managed' => true, + 'options' => [ + 'naming' => 'media-year', + 'nfo' => true, + 'versions' => true, + 'cleanup' => 'replace', + 'refresh' => true, + ], + 'last_planned_revision' => 'revision-1', + 'status_summary' => 'Planned 3 items', + ]); + + expect($mapping->uuid)->toBeString()->not->toBeEmpty() + ->and($mapping->enabled)->toBeTrue() + ->and($mapping->is_managed)->toBeTrue() + ->and($mapping->options)->toBeArray() + ->and($mapping->last_success_at)->toBeNull() + ->and($mapping->integration->is($integration))->toBeTrue() + ->and($mapping->user->is($user))->toBeTrue(); +}); + +it('rejects a mapping owned by a different user than its integration', function () { + $owner = User::factory()->create(); + $otherUser = User::factory()->create(); + $integration = MediaServerIntegration::factory()->for($owner)->createQuietly(['type' => 'emby']); + + expect(fn () => EmbyLibraryMapping::create([ + 'media_server_integration_id' => $integration->id, + 'user_id' => $otherUser->id, + 'source_kind' => 'vod_group', + 'source_identifier' => '42', + 'source_label' => 'Action', + 'target_library_name' => 'Managed Movies', + 'collection_type' => 'movies', + 'output_path' => '/media/m3u-editor/movies', + 'options' => [], + ]))->toThrow(ValidationException::class); +}); + +it('creates a mapping factory with matching integration ownership', function () { + $mapping = EmbyLibraryMapping::factory()->create(); + + expect($mapping->user_id)->toBe($mapping->integration->user_id) + ->and($mapping->collection_type)->toBeIn(['movies', 'tvshows']) + ->and($mapping->options)->toBeArray(); +}); + +it('exposes mappings through their integration and owner', function () { + $mapping = EmbyLibraryMapping::factory()->create(); + + expect($mapping->integration->embyLibraryMappings->modelKeys())->toContain($mapping->id) + ->and($mapping->user->embyLibraryMappings->modelKeys())->toContain($mapping->id); +}); + +it('authorizes integration users for only their own mappings', function () { + $mapping = EmbyLibraryMapping::factory()->create(); + $owner = $mapping->user; + $owner->update(['permissions' => ['use_integrations']]); + $otherUser = User::factory()->create(['permissions' => ['use_integrations']]); + $admin = User::factory()->admin()->create(); + + expect($owner->can('view', $mapping))->toBeTrue() + ->and($owner->can('update', $mapping))->toBeTrue() + ->and($owner->can('delete', $mapping))->toBeTrue() + ->and($otherUser->can('view', $mapping))->toBeFalse() + ->and($otherUser->can('update', $mapping))->toBeFalse() + ->and($otherUser->can('delete', $mapping))->toBeFalse() + ->and($admin->can('view', $mapping))->toBeTrue(); +}); + +it('allows only one mapping for a source and collection in an integration', function () { + $mapping = EmbyLibraryMapping::factory()->create(); + $duplicate = $mapping->replicate(['uuid', 'target_library_id', 'target_library_name', 'output_path']); + $duplicate->target_library_id = fake()->uuid(); + $duplicate->target_library_name = 'Another Library'; + $duplicate->output_path = '/media/m3u-editor/another-library'; + + expect(fn () => $duplicate->save())->toThrow(QueryException::class); +}); + +it('rejects unsupported collection types', function () { + $mapping = EmbyLibraryMapping::factory()->make(['collection_type' => 'music']); + + expect(fn () => $mapping->save())->toThrow(ValidationException::class); +}); + +it('rejects unsupported source kinds', function () { + $mapping = EmbyLibraryMapping::factory()->make(['source_kind' => 'live_group']); + + expect(fn () => $mapping->save())->toThrow(ValidationException::class); +}); + +it('rejects unrecognized publishing options', function () { + $mapping = EmbyLibraryMapping::factory()->make([ + 'options' => ['provider_url' => 'https://user:secret@example.com/stream'], + ]); + + expect(fn () => $mapping->save())->toThrow(ValidationException::class); +}); + +it('exposes only validated companion writable paths', function () { + $user = User::factory()->create(); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly(['type' => 'emby']); + $integration->update([ + 'emby_publisher_writable_paths' => [ + '/srv/emby/managed', + '/srv/emby/managed', + 'relative/path', + 'https://user:secret@example.com/path', + 'C:\\Emby\\Managed', + ], + ]); + + expect($integration->getEmbyPublisherWritablePaths())->toBe([ + '/srv/emby/managed', + 'C:\\Emby\\Managed', + ]); +}); diff --git a/tests/Feature/EmbyPublicationCatalogTest.php b/tests/Feature/EmbyPublicationCatalogTest.php new file mode 100644 index 000000000..36d925899 --- /dev/null +++ b/tests/Feature/EmbyPublicationCatalogTest.php @@ -0,0 +1,519 @@ + 'https://m3u-editor.test', 'app.port' => null]); + $user = User::factory()->create(); + $playlist = Playlist::factory()->for($user)->createQuietly(); + $group = Group::factory()->for($user)->for($playlist)->create(['name' => 'Action', 'type' => 'vod']); + $channel = Channel::factory()->for($user)->for($playlist)->for($group)->createQuietly([ + 'enabled' => true, + 'is_vod' => true, + 'name' => 'Provider Name', + 'title' => 'John Wick', + 'title_custom' => 'John Wick: Chapter 4', + 'url' => 'https://provider-user:provider-secret@provider.invalid/movie.mkv', + 'container_extension' => 'mkv', + 'tmdb_id' => 603692, + 'imdb_id' => 'tt10366206', + 'year' => 2023, + 'edition' => 'Theatrical', + 'info' => [ + 'original_title' => 'John Wick: Chapter 4', + 'plot' => 'John Wick uncovers a path to defeating The High Table.', + 'genres' => ['Action', 'Thriller'], + ], + 'stream_stats' => [ + ['stream' => [ + 'codec_type' => 'video', + 'codec_name' => 'hevc', + 'width' => 3840, + 'height' => 2160, + 'color_transfer' => 'smpte2084', + 'tags' => [], + ]], + ['stream' => [ + 'codec_type' => 'audio', + 'codec_name' => 'eac3', + 'tags' => ['language' => 'eng'], + ]], + ], + ]); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly(['type' => 'emby']); + $mapping = EmbyLibraryMapping::create([ + 'media_server_integration_id' => $integration->id, + 'user_id' => $user->id, + 'source_kind' => 'vod_group', + 'source_identifier' => (string) $group->id, + 'source_label' => $group->name, + 'target_library_name' => 'Managed Movies', + 'collection_type' => 'movies', + 'output_path' => '/srv/emby/managed/movies', + 'is_managed' => true, + 'options' => ['nfo' => true, 'versions' => true], + ]); + + $first = app(EmbyPublicationCatalogService::class)->buildMapping($mapping, 'tuner', 'secret'); + $second = app(EmbyPublicationCatalogService::class)->buildMapping($mapping, 'tuner', 'secret'); + $item = $first['items'][0]; + $variant = $item['variants'][0]; + + expect($first)->toBe($second) + ->and($first['mapping_uuid'])->toBe($mapping->uuid) + ->and($first['full_snapshot'])->toBeTrue() + ->and($first['revision'])->toMatch('/^[a-f0-9]{64}$/') + ->and($item['canonical_id'])->toBe('movie:tmdb:603692') + ->and($item['media_type'])->toBe('movie') + ->and($item['display_title'])->toBe('John Wick: Chapter 4') + ->and($item['original_title'])->toBe('John Wick: Chapter 4') + ->and($item['original_title_source'])->toBe('info.original_title') + ->and($item['year'])->toBe(2023) + ->and($item['ids'])->toBe([ + 'tmdb' => 603692, + 'tvdb' => null, + 'imdb' => 'tt10366206', + ]) + ->and($item['groups'])->toBe(['Action']) + ->and($item['relative_folder'])->toBe('john-wick-chapter-4-2023') + ->and($item['base_filename'])->toBe('john-wick-chapter-4-2023') + ->and($item['nfo']['plot'])->toContain('High Table') + ->and($variant['key'])->toBe('2160p-hdr-hevc-eac3-eng-theatrical') + ->and($variant['preferred']['playback_url'])->toBe( + "https://m3u-editor.test/movie/tuner/secret/{$channel->id}.mkv?proxy=true" + ) + ->and($variant['failover'])->toBe([]) + ->and($variant['technical_metadata'])->toBe($channel->stream_stats) + ->and(json_encode($first))->not->toContain('provider.invalid') + ->and(json_encode($first))->not->toContain('provider-secret'); +}); + +it('keeps uncertain movie identities separate and excludes disabled or foreign sources', function () { + config(['app.url' => 'https://m3u-editor.test', 'app.port' => null]); + $user = User::factory()->create(); + $playlist = Playlist::factory()->for($user)->createQuietly(); + $group = Group::factory()->for($user)->for($playlist)->create(['name' => 'Unsorted', 'type' => 'vod']); + $channel = Channel::factory()->for($user)->for($playlist)->for($group)->createQuietly([ + 'uuid' => '3ca47b68-9e2e-4b99-980f-dcae73b2ba67', + 'enabled' => true, + 'is_vod' => true, + 'title' => '../../Untitled / Film', + 'title_custom' => null, + 'name_custom' => null, + 'tmdb_id' => null, + 'tvdb_id' => null, + 'imdb_id' => null, + 'year' => null, + 'edition' => null, + 'info' => null, + 'movie_data' => null, + 'stream_stats' => null, + 'container_extension' => null, + ]); + Channel::factory()->for($user)->for($playlist)->for($group)->createQuietly([ + 'enabled' => false, + 'is_vod' => true, + 'title' => 'Disabled Movie', + 'tmdb_id' => 1, + ]); + $otherUser = User::factory()->create(); + $otherPlaylist = Playlist::factory()->for($otherUser)->createQuietly(); + Channel::factory()->for($otherUser)->for($otherPlaylist)->for($group)->createQuietly([ + 'enabled' => true, + 'is_vod' => true, + 'title' => 'Foreign Movie', + 'tmdb_id' => 2, + ]); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly(['type' => 'emby']); + $mapping = EmbyLibraryMapping::factory()->for($user)->for($integration, 'integration')->create([ + 'source_kind' => 'vod_group', + 'source_identifier' => (string) $group->id, + 'source_label' => $group->name, + 'target_library_name' => 'Managed Movies', + 'output_path' => '/srv/emby/managed/movies', + ]); + + $catalog = app(EmbyPublicationCatalogService::class)->buildMapping($mapping, 'tuner', 'secret'); + $item = $catalog['items'][0]; + + expect($catalog['items'])->toHaveCount(1) + ->and($item['canonical_id'])->toBe( + 'movie:title:untitled-film:unknown:'.hash('sha256', $channel->uuid) + ) + ->and($item['relative_folder'])->toBe('untitled-film') + ->and($item['base_filename'])->toBe('untitled-film') + ->and($item['ids'])->toBe(['tmdb' => null, 'tvdb' => null, 'imdb' => null]) + ->and($item['variants'][0]['key'])->toBe('unknown-unknown-unknown-unknown-unknown-unknown') + ->and(json_encode($catalog))->not->toContain('Disabled Movie') + ->and(json_encode($catalog))->not->toContain('Foreign Movie') + ->and($item['variants'][0]['preferred']['source_id'])->toBe($channel->id); +}); + +it('separates visible variants while ordering same-class provider failover', function () { + config(['app.url' => 'https://m3u-editor.test', 'app.port' => null]); + $user = User::factory()->create(); + $playlist = Playlist::factory()->for($user)->createQuietly(); + $group = Group::factory()->for($user)->for($playlist)->create(['name' => 'Movies', 'type' => 'vod']); + $hdStats = [ + ['stream' => ['codec_type' => 'video', 'codec_name' => 'h264', 'height' => 1080, 'color_transfer' => 'bt709']], + ['stream' => ['codec_type' => 'audio', 'codec_name' => 'aac', 'tags' => ['language' => 'eng']]], + ]; + $firstProvider = Channel::factory()->for($user)->for($playlist)->for($group)->createQuietly([ + 'enabled' => true, + 'is_vod' => true, + 'title' => 'Dune', + 'tmdb_id' => 438631, + 'year' => 2021, + 'edition' => 'Theatrical', + 'sort' => 20, + 'stream_stats' => $hdStats, + 'container_extension' => 'mkv', + ]); + $preferredProvider = Channel::factory()->for($user)->for($playlist)->for($group)->createQuietly([ + 'enabled' => true, + 'is_vod' => true, + 'title' => 'Dune', + 'tmdb_id' => 438631, + 'year' => 2021, + 'edition' => 'Theatrical', + 'sort' => 10, + 'stream_stats' => $hdStats, + 'container_extension' => 'mkv', + ]); + $uhdProvider = Channel::factory()->for($user)->for($playlist)->for($group)->createQuietly([ + 'enabled' => true, + 'is_vod' => true, + 'title' => 'Dune', + 'tmdb_id' => 438631, + 'year' => 2021, + 'edition' => 'Theatrical', + 'sort' => 5, + 'stream_stats' => [ + ['stream' => ['codec_type' => 'video', 'codec_name' => 'hevc', 'height' => 2160, 'color_transfer' => 'smpte2084']], + ['stream' => ['codec_type' => 'audio', 'codec_name' => 'truehd', 'tags' => ['language' => 'eng']]], + ], + 'container_extension' => 'mkv', + ]); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly(['type' => 'emby']); + $mapping = EmbyLibraryMapping::factory()->for($user)->for($integration, 'integration')->create([ + 'source_kind' => 'vod_group', + 'source_identifier' => (string) $group->id, + 'source_label' => $group->name, + ]); + + $catalog = app(EmbyPublicationCatalogService::class)->buildMapping($mapping, 'tuner', 'secret'); + $variants = collect($catalog['items'][0]['variants'])->keyBy('key'); + + expect($variants)->toHaveCount(2) + ->and($variants->keys()->all())->toBe([ + '1080p-sdr-h264-aac-eng-theatrical', + '2160p-hdr-hevc-truehd-eng-theatrical', + ]) + ->and($variants->get('1080p-sdr-h264-aac-eng-theatrical')['preferred']['source_id']) + ->toBe($preferredProvider->id) + ->and($variants->get('1080p-sdr-h264-aac-eng-theatrical')['failover']) + ->toHaveCount(1) + ->and($variants->get('1080p-sdr-h264-aac-eng-theatrical')['failover'][0]['source_id']) + ->toBe($firstProvider->id) + ->and($variants->get('2160p-hdr-hevc-truehd-eng-theatrical')['preferred']['source_id']) + ->toBe($uhdProvider->id) + ->and($variants->get('2160p-hdr-hevc-truehd-eng-theatrical')['failover'])->toBe([]); +}); + +it('includes configured provider failover candidates without exposing provider URLs', function () { + config(['app.url' => 'https://m3u-editor.test', 'app.port' => null]); + $user = User::factory()->create(); + $playlist = Playlist::factory()->for($user)->createQuietly(); + $group = Group::factory()->for($user)->for($playlist)->create(['name' => 'Movies', 'type' => 'vod']); + $stats = [ + ['stream' => ['codec_type' => 'video', 'codec_name' => 'h264', 'height' => 1080, 'color_transfer' => 'bt709']], + ['stream' => ['codec_type' => 'audio', 'codec_name' => 'aac', 'tags' => ['language' => 'eng']]], + ]; + $primary = Channel::factory()->for($user)->for($playlist)->for($group)->createQuietly([ + 'enabled' => true, + 'is_vod' => true, + 'title' => 'Arrival', + 'tmdb_id' => 329865, + 'edition' => null, + 'url' => 'https://primary.invalid/arrival.mkv', + 'stream_stats' => $stats, + 'container_extension' => 'mkv', + ]); + $failover = Channel::factory()->for($user)->for($playlist)->for($group)->createQuietly([ + 'enabled' => true, + 'is_vod' => true, + 'title' => 'Arrival', + 'tmdb_id' => 329865, + 'edition' => null, + 'url' => 'https://backup-user:backup-secret@backup.invalid/arrival.mkv', + 'stream_stats' => $stats, + 'container_extension' => 'mkv', + 'is_aio_failover_clone' => true, + ]); + ChannelFailover::create([ + 'user_id' => $user->id, + 'channel_id' => $primary->id, + 'channel_failover_id' => $failover->id, + 'sort' => 1, + ]); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly(['type' => 'emby']); + $mapping = EmbyLibraryMapping::factory()->for($user)->for($integration, 'integration')->create([ + 'source_kind' => 'vod_group', + 'source_identifier' => (string) $group->id, + 'source_label' => $group->name, + ]); + + $catalog = app(EmbyPublicationCatalogService::class)->buildMapping($mapping, 'tuner', 'secret'); + $variant = $catalog['items'][0]['variants'][0]; + + expect($variant['preferred']['source_id'])->toBe($primary->id) + ->and($variant['failover'])->toHaveCount(1) + ->and($variant['failover'][0]['source_id'])->toBe($failover->id) + ->and($variant['failover'][0]['playback_url'])->toContain("/movie/tuner/secret/{$failover->id}.mkv") + ->and(json_encode($catalog))->not->toContain('backup.invalid') + ->and(json_encode($catalog))->not->toContain('backup-secret'); +}); + +it('builds canonical series and episode catalog shapes with local NFO data', function () { + config(['app.url' => 'https://m3u-editor.test', 'app.port' => null]); + $user = User::factory()->create(); + $playlist = Playlist::factory()->for($user)->createQuietly(); + $category = Category::factory()->for($user)->for($playlist)->create(['name' => 'Drama']); + $series = Series::factory()->for($user)->for($playlist)->for($category)->createQuietly([ + 'name' => 'Dark', + 'enabled' => true, + 'tmdb_id' => null, + 'tvdb_id' => 334824, + 'imdb_id' => 'tt5753856', + 'release_date' => '2017-12-01', + 'plot' => 'A missing child sets four families on a frantic hunt for answers.', + 'genre' => 'Drama, Mystery', + 'metadata' => ['original_name' => 'Dark'], + ]); + $season = Season::factory()->for($user)->for($playlist)->for($category)->for($series)->createQuietly([ + 'name' => 'Season 1', + 'season_number' => 1, + ]); + $episode = Episode::factory()->for($user)->for($playlist)->for($series)->for($season)->createQuietly([ + 'enabled' => true, + 'title' => 'Secrets', + 'season' => 1, + 'episode_num' => 1, + 'tmdb_id' => 123456, + 'url' => 'https://provider.invalid/dark-s01e01.mkv', + 'container_extension' => 'mkv', + 'info' => [ + 'original_title' => 'Geheimnisse', + 'plot' => 'The disappearance exposes old secrets.', + 'tvdb_id' => 654321, + 'imdb_id' => 'tt7315158', + ], + 'stream_stats' => [ + ['stream' => ['codec_type' => 'video', 'codec_name' => 'h264', 'height' => 1080, 'color_transfer' => 'bt709']], + ['stream' => ['codec_type' => 'audio', 'codec_name' => 'aac', 'tags' => ['language' => 'deu']]], + ], + ]); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly(['type' => 'emby']); + $mapping = EmbyLibraryMapping::factory()->for($user)->for($integration, 'integration')->create([ + 'source_kind' => 'series_category', + 'source_identifier' => (string) $category->id, + 'source_label' => $category->name, + 'target_library_name' => 'Managed TV', + 'collection_type' => 'tvshows', + 'output_path' => '/srv/emby/managed/tv', + ]); + + $catalog = app(EmbyPublicationCatalogService::class)->buildMapping($mapping, 'tuner', 'secret'); + $seriesItem = $catalog['items'][0]; + $episodeItem = $seriesItem['episodes'][0]; + + expect($seriesItem['canonical_id'])->toBe('series:tvdb:334824') + ->and($seriesItem['media_type'])->toBe('series') + ->and($seriesItem['display_title'])->toBe('Dark') + ->and($seriesItem['original_title'])->toBe('Dark') + ->and($seriesItem['original_title_source'])->toBe('metadata.original_name') + ->and($seriesItem['year'])->toBe(2017) + ->and($seriesItem['ids'])->toBe([ + 'tmdb' => null, + 'tvdb' => 334824, + 'imdb' => 'tt5753856', + ]) + ->and($seriesItem['relative_folder'])->toBe('dark-2017') + ->and($seriesItem['nfo']['plot'])->toContain('missing child') + ->and($episodeItem['canonical_id'])->toBe('episode:tmdb:123456') + ->and($episodeItem['series_canonical_id'])->toBe('series:tvdb:334824') + ->and($episodeItem['media_type'])->toBe('episode') + ->and($episodeItem['display_title'])->toBe('Secrets') + ->and($episodeItem['original_title'])->toBe('Geheimnisse') + ->and($episodeItem['season_number'])->toBe(1) + ->and($episodeItem['episode_number'])->toBe(1) + ->and($episodeItem['relative_folder'])->toBe('season-01') + ->and($episodeItem['base_filename'])->toBe('dark-s01e01-secrets') + ->and($episodeItem['ids'])->toBe([ + 'tmdb' => 123456, + 'tvdb' => 654321, + 'imdb' => 'tt7315158', + ]) + ->and($episodeItem['variants'][0]['preferred']['playback_url'])->toBe( + "https://m3u-editor.test/series/tuner/secret/{$episode->id}.mkv?proxy=true" + ) + ->and(json_encode($catalog))->not->toContain('provider.invalid'); +}); + +it('merges episodes from duplicate provider series identities', function () { + $user = User::factory()->create(); + $firstPlaylist = Playlist::factory()->for($user)->createQuietly(); + $secondPlaylist = Playlist::factory()->for($user)->createQuietly(); + $firstCategory = Category::factory()->for($user)->for($firstPlaylist)->create(['name' => 'Drama']); + $secondCategory = Category::factory()->for($user)->for($secondPlaylist)->create(['name' => 'Drama']); + $firstSeries = Series::factory()->for($user)->for($firstPlaylist)->for($firstCategory)->createQuietly([ + 'name' => 'Shared Series', + 'enabled' => true, + 'tvdb_id' => 12345, + ]); + $secondSeries = Series::factory()->for($user)->for($secondPlaylist)->for($secondCategory)->createQuietly([ + 'name' => 'Shared Series', + 'enabled' => true, + 'tvdb_id' => 12345, + ]); + $firstSeason = Season::factory()->for($user)->for($firstPlaylist)->for($firstCategory)->for($firstSeries)->createQuietly([ + 'season_number' => 1, + ]); + $secondSeason = Season::factory()->for($user)->for($secondPlaylist)->for($secondCategory)->for($secondSeries)->createQuietly([ + 'season_number' => 1, + ]); + Episode::factory()->for($user)->for($firstPlaylist)->for($firstSeries)->for($firstSeason)->createQuietly([ + 'enabled' => true, + 'title' => 'First Episode', + 'season' => 1, + 'episode_num' => 1, + 'tmdb_id' => null, + ]); + Episode::factory()->for($user)->for($secondPlaylist)->for($secondSeries)->for($secondSeason)->createQuietly([ + 'enabled' => true, + 'title' => 'Second Episode', + 'season' => 1, + 'episode_num' => 2, + 'tmdb_id' => null, + ]); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly(['type' => 'emby']); + $mapping = EmbyLibraryMapping::factory()->for($user)->for($integration, 'integration')->create([ + 'source_kind' => 'all', + 'source_identifier' => '*', + 'source_label' => 'All eligible items', + 'collection_type' => 'tvshows', + ]); + + $catalog = app(EmbyPublicationCatalogService::class)->buildMapping($mapping, 'tuner', 'secret'); + + expect($catalog['items'])->toHaveCount(1) + ->and($catalog['items'][0]['canonical_id'])->toBe('series:tvdb:12345') + ->and(array_column($catalog['items'][0]['episodes'], 'episode_number'))->toBe([1, 2]); +}); + +it('scopes custom playlist group mappings to the selected group', function () { + $user = User::factory()->create(); + $playlist = Playlist::factory()->for($user)->createQuietly(); + $group = Group::factory()->for($user)->for($playlist)->create(['name' => 'Source', 'type' => 'vod']); + $customPlaylist = CustomPlaylist::factory()->for($user)->createQuietly(); + $included = Channel::factory()->for($user)->for($playlist)->for($group)->createQuietly([ + 'enabled' => true, + 'is_vod' => true, + 'title' => 'Included Movie', + 'group' => 'Favorites', + 'tmdb_id' => 10, + ]); + $excluded = Channel::factory()->for($user)->for($playlist)->for($group)->createQuietly([ + 'enabled' => true, + 'is_vod' => true, + 'title' => 'Excluded Movie', + 'group' => 'Other', + 'tmdb_id' => 20, + ]); + $customPlaylist->channels()->attach([$included->id, $excluded->id]); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly(['type' => 'emby']); + $mapping = EmbyLibraryMapping::factory()->for($user)->for($integration, 'integration')->create([ + 'source_kind' => 'custom_playlist_group', + 'source_identifier' => (string) $customPlaylist->id, + 'source_label' => 'Favorites', + ]); + + $catalog = app(EmbyPublicationCatalogService::class)->buildMapping($mapping, 'tuner', 'secret'); + + expect($catalog['items'])->toHaveCount(1) + ->and($catalog['items'][0]['display_title'])->toBe('Included Movie') + ->and($catalog['items'][0]['groups'])->toBe(['Favorites']) + ->and(json_encode($catalog))->not->toContain('Excluded Movie'); +}); + +it('defers unresolved managed mappings from the user publication catalog', function () { + $user = User::factory()->create(); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly([ + 'type' => 'emby', + 'enabled' => true, + ]); + $mapping = EmbyLibraryMapping::factory()->for($user)->for($integration, 'integration')->create([ + 'target_library_id' => null, + 'is_managed' => true, + 'enabled' => true, + 'status' => 'planned', + 'status_summary' => 'Unsafe stale plan', + 'last_planned_revision' => 'unsafe-revision', + ]); + + $catalog = app(EmbyPublicationCatalogService::class)->buildForUser($user, 'tuner', 'secret'); + + expect($catalog['mappings'])->toBeEmpty() + ->and($mapping->refresh()->status)->toBe('pending') + ->and($mapping->status_summary)->toBe('Pending') + ->and($mapping->error_summary)->toBeNull() + ->and($mapping->last_planned_revision)->toBeNull(); +}); + +it('builds a deterministic full user snapshot and records only enabled planned revisions', function () { + $user = User::factory()->create(); + $integration = MediaServerIntegration::factory()->for($user)->createQuietly([ + 'type' => 'emby', + 'enabled' => true, + ]); + $enabled = EmbyLibraryMapping::factory()->for($user)->for($integration, 'integration')->create([ + 'source_kind' => 'all', + 'source_identifier' => '*', + 'source_label' => 'All Movies', + 'enabled' => true, + ]); + $disabled = EmbyLibraryMapping::factory()->for($user)->for($integration, 'integration')->create([ + 'source_kind' => 'all', + 'source_identifier' => 'disabled', + 'source_label' => 'Disabled Movies', + 'enabled' => false, + ]); + + $first = app(EmbyPublicationCatalogService::class)->buildForUser($user, 'tuner', 'secret'); + $second = app(EmbyPublicationCatalogService::class)->buildForUser($user, 'tuner', 'secret'); + + expect($first)->toBe($second) + ->and($first['api_version'])->toBe(1) + ->and($first['full_snapshot'])->toBeTrue() + ->and($first['mappings'])->toHaveCount(1) + ->and($first['mappings'][0]['mapping_uuid'])->toBe($enabled->uuid) + ->and($first['revision'])->toMatch('/^[a-f0-9]{64}$/') + ->and($enabled->refresh()->last_planned_revision)->toBe($first['mappings'][0]['revision']) + ->and($enabled->status)->toBe('planned') + ->and($disabled->refresh()->last_planned_revision)->toBeNull(); +}); diff --git a/tests/Feature/GuestDvrRecordingResourceTest.php b/tests/Feature/GuestDvrRecordingResourceTest.php index db0e2914a..470a0aa8f 100644 --- a/tests/Feature/GuestDvrRecordingResourceTest.php +++ b/tests/Feature/GuestDvrRecordingResourceTest.php @@ -160,6 +160,22 @@ function setOwnerAuthRecordingContext(Playlist $playlist, User $user): void expect($ids)->toBe([$ownRecording->id]); }); +it('returns no recordings when the guest session credentials do not resolve to a PlaylistAuth or the owner', function () { + DvrRecording::factory() + ->for($this->dvrSetting) + ->for($this->user) + ->create(['playlist_auth_id' => null]); + + request()->attributes->set('playlist_uuid', $this->playlist->uuid); + $prefix = base64_encode($this->playlist->uuid).'_'; + session()->put("{$prefix}guest_auth_username", 'stale-username'); + session()->put("{$prefix}guest_auth_password", 'stale-password'); + + $ids = GuestDvrRecordingResource::getEloquentQuery()->pluck('id')->all(); + + expect($ids)->toBe([]); +}); + // --- Navigation badge count --- it('navigation badge only counts the current guest\'s own active recordings', function () { diff --git a/tests/Feature/XtreamEmbyPublishingTest.php b/tests/Feature/XtreamEmbyPublishingTest.php new file mode 100644 index 000000000..1100690a2 --- /dev/null +++ b/tests/Feature/XtreamEmbyPublishingTest.php @@ -0,0 +1,343 @@ + 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + 'cache.default' => 'array', + ]); + $this->user = User::factory()->create(['permissions' => ['use_integrations']]); + $this->playlist = Playlist::factory()->for($this->user)->createQuietly(); + cache()->put("p:{$this->playlist->id}:xtream_status", []); + $this->auth = PlaylistAuth::factory()->for($this->user)->create([ + 'enabled' => true, + 'username' => 'emby-companion', + 'password' => 'companion-secret', + 'library_publishing_enabled' => true, + ]); + $this->auth->assignTo($this->playlist); + $this->integration = MediaServerIntegration::factory()->for($this->user)->createQuietly([ + 'type' => 'emby', + 'api_key' => 'must-not-leak', + ]); + $this->mapping = EmbyLibraryMapping::factory() + ->for($this->user) + ->for($this->integration, 'integration') + ->create([ + 'source_kind' => 'all', + 'source_identifier' => '*', + 'source_label' => 'All Movies', + 'target_library_name' => 'Managed Movies', + 'output_path' => '/srv/emby/managed/movies', + ]); + + $settings = Mockery::mock(GeneralSettings::class)->makePartial(); + $settings->app_output_enabled = true; + app()->instance(GeneralSettings::class, $settings); +}); + +function embyPublishingActionUrl(string $action, array $parameters = []): string +{ + return '/player_api.php?'.http_build_query(array_merge([ + 'username' => 'emby-companion', + 'password' => 'companion-secret', + 'action' => $action, + ], $parameters)); +} + +it('advertises the versioned managed library publishing contract to authenticated clients', function () { + $response = $this->getJson(embyPublishingActionUrl('get_server_info')); + + $response->assertOk() + ->assertJsonPath('m3u_editor.library_publishing.api_version', 1) + ->assertJsonPath('m3u_editor.library_publishing.actions.register_publisher', 'm3u_editor_register_publisher') + ->assertJsonPath('m3u_editor.library_publishing.actions.catalog', 'm3u_editor_catalog') + ->assertJsonPath('m3u_editor.library_publishing.actions.sync_result', 'm3u_editor_sync_result') + ->assertJsonPath('m3u_editor.library_publishing.snapshot_mode', 'full') + ->assertJsonPath('m3u_editor.library_publishing.features', [ + 'library_mappings', + 'variants', + 'provider_failover', + 'local_nfo', + 'revision_metadata', + ]); + + expect(json_encode($response->json('m3u_editor.library_publishing'))) + ->not->toContain('must-not-leak') + ->not->toContain('/srv/emby'); +}); + +it('advertises publisher registration before the first mapping exists', function () { + $this->mapping->delete(); + + $this->getJson(embyPublishingActionUrl('get_server_info')) + ->assertOk() + ->assertJsonPath( + 'm3u_editor.library_publishing.actions.register_publisher', + 'm3u_editor_register_publisher', + ); +}); + +it('rejects invalid companion writable path advertisements', function (mixed $writablePaths) { + $response = $this->postJson('/player_api.php', [ + 'username' => 'emby-companion', + 'password' => 'companion-secret', + 'action' => 'm3u_editor_register_publisher', + 'api_version' => 1, + 'integration_id' => $this->integration->id, + 'writable_paths' => $writablePaths, + ]); + + $response->assertUnprocessable() + ->assertJsonPath('error.code', 'invalid_request'); + expect($this->integration->refresh()->emby_publisher_writable_paths)->toBeNull() + ->and($this->integration->emby_publisher_capabilities_updated_at)->toBeNull(); +})->with([ + 'missing list' => null, + 'relative path' => [['relative/path']], + 'traversal path' => [['/srv/emby/managed/../../etc']], + 'traversal path (windows-style)' => [['C:\\emby\\managed\\..\\..\\Windows']], + 'NUL-bearing path' => [["/srv/emby/managed\0/movies"]], + 'overlong path' => [['/'.str_repeat('a', 1024)]], + 'duplicate path' => [['/srv/emby/managed', '/srv/emby/managed']], + 'duplicate normalized path' => [['/srv/emby/managed', ' /srv/emby/managed ']], + 'associative path list' => [['movies' => '/srv/emby/managed']], + 'over-limit list' => [array_map(fn (int $index): string => "/srv/emby/managed/{$index}", range(1, 51))], +]); + +it('rejects cross-owner publisher registration', function () { + $otherUser = User::factory()->create(['permissions' => ['use_integrations']]); + $foreignIntegration = MediaServerIntegration::factory()->for($otherUser)->createQuietly([ + 'type' => 'emby', + 'emby_publisher_writable_paths' => null, + ]); + + $this->postJson('/player_api.php', [ + 'username' => 'emby-companion', + 'password' => 'companion-secret', + 'action' => 'm3u_editor_register_publisher', + 'api_version' => 1, + 'integration_id' => $foreignIntegration->id, + 'writable_paths' => ['/srv/emby/managed/movies'], + ])->assertNotFound() + ->assertJsonPath('error.code', 'integration_not_found'); + + expect($foreignIntegration->refresh()->emby_publisher_writable_paths)->toBeNull() + ->and($foreignIntegration->emby_publisher_capabilities_updated_at)->toBeNull(); +}); + +it('denies managed library publishing to playlist credentials without explicit access', function () { + $this->auth->update(['library_publishing_enabled' => false]); + + $serverInfo = $this->getJson(embyPublishingActionUrl('get_server_info'))->assertOk(); + expect($serverInfo->json('m3u_editor.library_publishing'))->toBeNull(); + + $this->getJson(embyPublishingActionUrl('m3u_editor_catalog', [ + 'api_version' => 1, + ]))->assertForbidden() + ->assertJsonPath('error.code', 'library_publishing_unavailable'); + + $this->postJson('/player_api.php', [ + 'username' => 'emby-companion', + 'password' => 'companion-secret', + 'action' => 'm3u_editor_register_publisher', + 'api_version' => 1, + 'integration_id' => $this->integration->id, + 'writable_paths' => ['/srv/emby/managed/movies'], + ])->assertForbidden() + ->assertJsonPath('error.code', 'library_publishing_unavailable'); + + expect($this->integration->refresh()->emby_publisher_writable_paths)->toBeNull(); +}); + +it('publishes valid source-owner playback credentials to an opted-in companion', function () { + config(['app.url' => 'https://m3u-editor.test', 'app.port' => null]); + $sourcePlaylist = Playlist::factory()->for($this->user)->createQuietly(); + $group = Group::factory()->for($this->user)->for($sourcePlaylist)->create([ + 'name' => 'Movies', + 'type' => 'vod', + ]); + $channel = Channel::factory()->for($this->user)->for($sourcePlaylist)->for($group)->createQuietly([ + 'enabled' => true, + 'is_vod' => true, + 'title' => 'Source Movie', + 'container_extension' => 'mkv', + ]); + + $response = $this->getJson(embyPublishingActionUrl('m3u_editor_catalog', [ + 'api_version' => 1, + ]))->assertOk(); + $playbackUrl = $response->json('mappings.0.items.0.variants.0.preferred.playback_url'); + + expect($playbackUrl) + ->toContain('/movie/'.urlencode($this->user->name).'/'.urlencode($sourcePlaylist->uuid)."/{$channel->id}.mkv") + ->not->toContain('companion-secret'); +}); + +it('returns the canonical catalog through the authenticated versioned action', function () { + $response = $this->getJson(embyPublishingActionUrl('m3u_editor_catalog', [ + 'api_version' => 1, + ])); + + $response->assertOk() + ->assertJsonPath('api_version', 1) + ->assertJsonPath('full_snapshot', true) + ->assertJsonPath('mappings.0.mapping_uuid', $this->mapping->uuid) + ->assertJsonPath('mappings.0.integration_id', $this->integration->id); + + expect($this->mapping->refresh()->last_planned_revision) + ->toBe($response->json('mappings.0.revision')) + ->and(json_encode($response->json())) + ->not->toContain('must-not-leak'); +}); + +it('rejects unsupported catalog API versions without planning a revision', function () { + $response = $this->getJson(embyPublishingActionUrl('m3u_editor_catalog', [ + 'api_version' => 2, + ])); + + $response->assertBadRequest() + ->assertJsonPath('error.code', 'unsupported_api_version'); + expect($this->mapping->refresh()->last_planned_revision)->toBeNull(); +}); + +it('rejects unauthenticated catalog requests using existing Xtream conventions', function () { + $response = $this->getJson('/player_api.php?'.http_build_query([ + 'username' => 'emby-companion', + 'password' => 'wrong-password', + 'action' => 'm3u_editor_catalog', + 'api_version' => 1, + ])); + + $response->assertUnauthorized() + ->assertJsonPath('error', 'Unauthorized'); + expect($this->mapping->refresh()->last_planned_revision)->toBeNull(); +}); + +it('applies an exact successful revision once and requests one Emby refresh', function () { + Bus::fake(); + $catalog = $this->getJson(embyPublishingActionUrl('m3u_editor_catalog', [ + 'api_version' => 1, + ]))->assertOk()->json(); + $revision = $catalog['mappings'][0]['revision']; + $payload = [ + 'username' => 'emby-companion', + 'password' => 'companion-secret', + 'action' => 'm3u_editor_sync_result', + 'api_version' => 1, + 'integration_id' => $this->integration->id, + 'mapping_uuid' => $this->mapping->uuid, + 'revision' => $revision, + 'status' => 'success', + 'summary' => 'Applied 0 items', + ]; + + $this->postJson('/player_api.php', $payload) + ->assertOk() + ->assertJsonPath('data.applied', true) + ->assertJsonPath('data.duplicate', false); + + $mapping = $this->mapping->refresh(); + expect($mapping->last_applied_revision)->toBe($revision) + ->and($mapping->last_success_at)->not->toBeNull() + ->and($mapping->status)->toBe('synced') + ->and($mapping->status_summary)->toBe('Applied 0 items'); + Bus::assertDispatchedTimes(RefreshMediaServerLibraryJob::class, 1); + + $this->postJson('/player_api.php', $payload) + ->assertOk() + ->assertJsonPath('data.applied', true) + ->assertJsonPath('data.duplicate', true); + Bus::assertDispatchedTimes(RefreshMediaServerLibraryJob::class, 1); +}); + +it('rejects a stale successful revision without changing applied state', function () { + Bus::fake(); + $catalog = $this->getJson(embyPublishingActionUrl('m3u_editor_catalog', [ + 'api_version' => 1, + ]))->assertOk()->json(); + $reportedRevision = $catalog['mappings'][0]['revision']; + $this->mapping->updateQuietly(['last_planned_revision' => str_repeat('b', 64)]); + + $this->postJson('/player_api.php', [ + 'username' => 'emby-companion', + 'password' => 'companion-secret', + 'action' => 'm3u_editor_sync_result', + 'api_version' => 1, + 'integration_id' => $this->integration->id, + 'mapping_uuid' => $this->mapping->uuid, + 'revision' => $reportedRevision, + 'status' => 'success', + ])->assertConflict() + ->assertJsonPath('error.code', 'stale_revision'); + + expect($this->mapping->refresh()->last_applied_revision)->toBeNull(); + Bus::assertNotDispatched(RefreshMediaServerLibraryJob::class); +}); + +it('records redacted failed results without applying or refreshing', function () { + Bus::fake(); + $catalog = $this->getJson(embyPublishingActionUrl('m3u_editor_catalog', [ + 'api_version' => 1, + ]))->assertOk()->json(); + $revision = $catalog['mappings'][0]['revision']; + + $this->postJson('/player_api.php', [ + 'username' => 'emby-companion', + 'password' => 'companion-secret', + 'action' => 'm3u_editor_sync_result', + 'api_version' => 1, + 'integration_id' => $this->integration->id, + 'mapping_uuid' => $this->mapping->uuid, + 'revision' => $revision, + 'status' => 'failed', + 'summary' => 'Companion failed', + 'error' => 'POST https://user:secret@provider.invalid token=abc api_key=xyz', + ])->assertUnprocessable() + ->assertJsonPath('error.code', 'sync_failed'); + + $mapping = $this->mapping->refresh(); + expect($mapping->last_applied_revision)->toBeNull() + ->and($mapping->status)->toBe('failed') + ->and($mapping->error_summary)->not->toContain('provider.invalid') + ->and($mapping->error_summary)->not->toContain('secret') + ->and($mapping->error_summary)->not->toContain('abc') + ->and($mapping->error_summary)->not->toContain('xyz'); + Bus::assertNotDispatched(RefreshMediaServerLibraryJob::class); +}); + +it('rejects cross-integration sync results without changing mapping state', function () { + Bus::fake(); + $catalog = $this->getJson(embyPublishingActionUrl('m3u_editor_catalog', [ + 'api_version' => 1, + ]))->assertOk()->json(); + $otherIntegration = MediaServerIntegration::factory()->for($this->user)->createQuietly(['type' => 'emby']); + + $this->postJson('/player_api.php', [ + 'username' => 'emby-companion', + 'password' => 'companion-secret', + 'action' => 'm3u_editor_sync_result', + 'api_version' => 1, + 'integration_id' => $otherIntegration->id, + 'mapping_uuid' => $this->mapping->uuid, + 'revision' => $catalog['mappings'][0]['revision'], + 'status' => 'success', + ])->assertNotFound() + ->assertJsonPath('error.code', 'mapping_not_found'); + + expect($this->mapping->refresh()->last_applied_revision)->toBeNull(); + Bus::assertNotDispatched(RefreshMediaServerLibraryJob::class); +});