feat(api): media detail + info + image-folder endpoints (#141/#161)

Add REST endpoints backing the SPA media detail pages and image browser:

- GET /api/movies/{id}, /api/shows/{id}, /api/seasons/{id}, /api/artists/{id}
  wrapping the existing detail queries; 404 on None.
- GET /api/media-items/{id}/info wrapping GetMediaItemInfo; UnableToLocateMediaItem
  -> 404, other errors -> 422.
- GET /api/images/folders?parentId= and PUT /api/images/folders/{id}/duration
  (validates null-or-positive -> 400; existence guard via new ImageFolderExists
  query -> 404).
- Extend GetLibraryBrowseItems parentId drill-in to Episode (episodes of a season,
  episode-number order) and MusicVideo (an artist's music videos, album/track order),
  alongside the existing TelevisionSeason branch.

Response DTOs live in ErsatzTV.Core/Api/* and never expose Application VMs. Artwork
values are rooted for the SPA via a shared ErsatzTV.Core/Api/ApiArtwork helper
(mirrors the #180/#181 browse-handler logic; handles jellyfin/emby proxy prefixes,
http passthrough, empty). Regenerated OpenAPI v1.json + web v1.d.ts. New controllers
registered in ApiControllerSecurityTests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 22:43:47 +02:00
co-authored by Claude Fable 5
parent 3ec3ab5fe0
commit cd31c755bb
26 changed files with 2546 additions and 7 deletions
+125
View File
@@ -3,6 +3,12 @@
export interface components {
schemas: {
"ActorResponseModel": {
"id": number;
"name": string;
"role": null | string;
"thumb": string;
};
"AddItemsToCollectionRequest": {
"movieIds": null | Array<number>;
"showIds": null | Array<number>;
@@ -17,6 +23,18 @@ export interface components {
};
"AddTraktListRequest": {
"url": null | string;
};
"ArtistDetailResponseModel": {
"id": number;
"name": string;
"disambiguation": null | string;
"biography": null | string;
"thumbnail": string;
"fanArt": string;
"genres": Array<string>;
"styles": Array<string>;
"moods": Array<string>;
"languages": Array<string>;
};
"ArtworkContentTypeModel": {
"path": null | string;
@@ -687,6 +705,14 @@ export interface components {
"lastAccess": string;
};
"IFormFile": string;
"ImageFolderResponseModel": {
"libraryFolderId": number;
"name": string;
"fullPath": string;
"subfolderCount": number;
"imageCount": number;
"durationSeconds": null | number;
};
"LibraryBrowseItemResponseModel": {
"id": number;
"mediaType": components["schemas"]["LibraryBrowseMediaType"];
@@ -739,8 +765,54 @@ export interface components {
"poster"?: null | string;
"state": components["schemas"]["MediaItemState"];
"hasMediaInfo"?: boolean;
};
"MediaItemInfoChapterResponseModel": {
"title": null | string;
"startTime": string;
"endTime": string;
};
"MediaItemInfoResponseModel": {
"id": number;
"title": string;
"kind": string;
"libraryKind": string;
"serverName": null | string;
"libraryName": string;
"state": components["schemas"]["MediaItemState"];
"duration": string;
"sampleAspectRatio": null | string;
"displayAspectRatio": null | string;
"rFrameRate": null | string;
"videoScanKind": components["schemas"]["VideoScanKind"];
"interlacedRatio": null | number;
"width": number;
"height": number;
"streams": Array<components["schemas"]["MediaItemInfoStreamResponseModel"]>;
"chapters": Array<components["schemas"]["MediaItemInfoChapterResponseModel"]>;
};
"MediaItemInfoStreamResponseModel": {
"index": null | number;
"kind": components["schemas"]["MediaStreamKind"];
"title": null | string;
"codec": null | string;
"profile": null | string;
"language": null | string;
"channels": null | number;
"default": null | boolean;
"forced": null | boolean;
"attachedPic": null | boolean;
"pixelFormat": null | string;
"colorRange": null | string;
"colorSpace": null | string;
"colorTransfer": null | string;
"colorPrimaries": null | string;
"bitsPerRawSample": null | number;
"mimeType": null | string;
"fileName": null | string;
"isExtracted": null | boolean;
};
"MediaItemState": "Normal" | "FileNotFound" | "Unavailable" | "RemoteOnly";
"MediaSourceKind": "Local" | "Plex" | "Jellyfin" | "Emby";
"MediaSourceLibraryResponseModel": {
"id": number;
"name": string;
@@ -754,6 +826,26 @@ export interface components {
"name": string;
"connectionAddress": null | string;
"libraries": Array<components["schemas"]["MediaSourceLibraryResponseModel"]>;
};
"MediaStreamKind": "Video" | "Audio" | "Subtitle" | "Attachment" | "ExternalSubtitle";
"MovieDetailResponseModel": {
"id": number;
"title": string;
"year": null | string;
"plot": null | string;
"genres": Array<string>;
"tags": Array<string>;
"studios": Array<string>;
"contentRatings": Array<string>;
"languages": Array<string>;
"actors": Array<components["schemas"]["ActorResponseModel"]>;
"directors": Array<string>;
"writers": Array<string>;
"path": null | string;
"localPath": null | string;
"state": components["schemas"]["MediaItemState"];
"poster": string;
"fanArt": string;
};
"MultiCollectionItemViewModel": {
"multiCollectionId": number;
@@ -1121,6 +1213,32 @@ export interface components {
"otherVideos": components["schemas"]["SearchResultGroupResponseModel"];
"images": components["schemas"]["SearchResultGroupResponseModel"];
"remoteStreams": components["schemas"]["SearchResultGroupResponseModel"];
};
"SeasonDetailResponseModel": {
"id": number;
"showId": number;
"title": string;
"year": null | string;
"name": string;
"poster": string;
"fanArt": string;
};
"ShowDetailResponseModel": {
"id": number;
"libraryId": number;
"mediaSourceKind": components["schemas"]["MediaSourceKind"];
"title": string;
"year": null | string;
"plot": null | string;
"poster": string;
"fanArt": string;
"genres": Array<string>;
"tags": Array<string>;
"studios": Array<string>;
"networks": Array<string>;
"contentRatings": Array<string>;
"languages": Array<string>;
"actors": Array<components["schemas"]["ActorResponseModel"]>;
};
"SmartCollectionResponseModel": {
"id": number;
@@ -1318,6 +1436,12 @@ export interface components {
};
"UpdateHdhrSettingsRequest": {
"tunerCount": number;
};
"UpdateImageFolderDurationRequest": {
"durationSeconds": null | number;
};
"UpdateImageFolderDurationResponseModel": {
"durationSeconds": null | number;
};
"UpdateLoggingSettingsRequest": {
"defaultMinimumLogLevel": components["schemas"]["LogEventLevel"];
@@ -1383,6 +1507,7 @@ export interface components {
"blockBehavior": components["schemas"]["XmltvBlockBehavior"];
};
"VaapiDriver": "Default" | "iHD" | "i965" | "RadeonSI" | "Nouveau";
"VideoScanKind": "Unknown" | "Progressive" | "Interlaced";
"WatermarkFullResponseModel": {
"id": number;
"name": string;