From a27455ce164fde5b0ffe740fe3f0524fa91b3000 Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Sat, 14 Mar 2026 22:49:02 -0400 Subject: [PATCH 1/9] feat: adding thumbnail set --- .../Media/MediaController.Tags.cs | 4 +- .../src/app/meta/meta.css | 26 ++++++++ .../src/app/meta/meta.html | 41 +++++++++---- .../src/app/meta/meta.spec.ts | 59 ++++++++++++++++++- .../src/app/meta/meta.ts | 49 +++++++++++++-- .../src/app/services/media.service.ts | 6 ++ 6 files changed, 167 insertions(+), 18 deletions(-) diff --git a/src/MediaBrowser.Common/Media/MediaController.Tags.cs b/src/MediaBrowser.Common/Media/MediaController.Tags.cs index 225504e..662d255 100644 --- a/src/MediaBrowser.Common/Media/MediaController.Tags.cs +++ b/src/MediaBrowser.Common/Media/MediaController.Tags.cs @@ -21,7 +21,7 @@ partial class MediaController _ => mediaConfig.WritersDirectory }; - [HttpGet("{tagType}/{name}/thumbnail")] + [HttpGet("{tagType}/{name}/thumbnail"), HttpGet("{tagType}s/{name}/thumbnail")] public ActionResult GetThumbnail(TagType tagType, string name) { var filePath = Path.Combine(GetTagDirectory(tagType), $"{name}.jpg"); @@ -40,7 +40,7 @@ public ActionResult GetThumbnail(TagType tagType, string name) return File(new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read), "image/jpeg"); } - [HttpPost("{tagType}/{name}/thumbnail")] + [HttpPost("{tagType}/{name}/thumbnail"), HttpPost("{tagType}s/{name}/thumbnail")] public async Task SetThumbnail(TagType tagType, string name, [FromForm] SetTagThumbnailRequest request) { if (!IsNameValid(name)) diff --git a/src/MediaBrowser.Frontend/src/app/meta/meta.css b/src/MediaBrowser.Frontend/src/app/meta/meta.css index 800d155..fa42356 100644 --- a/src/MediaBrowser.Frontend/src/app/meta/meta.css +++ b/src/MediaBrowser.Frontend/src/app/meta/meta.css @@ -40,11 +40,37 @@ height: auto; } +.meta-grid .result-link { + position: absolute; + inset: 0; + display: block; + text-decoration: none; +} + .meta-info { text-align: center; width: 100%; } +.upload-icon { + position: absolute; + background: none; + bottom: 8px; + border: none; + left: 8px; + color: var(--fg1); + padding: 4px; + border-radius: 4px; + font-size: 1.25rem; + z-index: 10; + opacity: 0.7; + text-shadow: 0.25rem 0.25rem 1rem black; +} + +.thumbnail-input { + display: none; +} + .meta-name { color: var(--fg1); font-size: 2.5rem; diff --git a/src/MediaBrowser.Frontend/src/app/meta/meta.html b/src/MediaBrowser.Frontend/src/app/meta/meta.html index 15817d1..d099a7a 100644 --- a/src/MediaBrowser.Frontend/src/app/meta/meta.html +++ b/src/MediaBrowser.Frontend/src/app/meta/meta.html @@ -7,18 +7,37 @@

{{ type }}

@if (!isLoading && metaMembers.length > 0) {
@for (metaMember of metaMembers; track $index) { - - } diff --git a/src/MediaBrowser.Frontend/src/app/meta/meta.spec.ts b/src/MediaBrowser.Frontend/src/app/meta/meta.spec.ts index b61c9bf..2f319e3 100644 --- a/src/MediaBrowser.Frontend/src/app/meta/meta.spec.ts +++ b/src/MediaBrowser.Frontend/src/app/meta/meta.spec.ts @@ -12,6 +12,7 @@ type MetaTagType = 'cast' | 'directors' | 'genres' | 'producers' | 'writers'; interface MetaMocks { mediaService: { getAllTags: ReturnType; + setThumbnailForTag: ReturnType; }; routeParamMap$: BehaviorSubject; } @@ -33,7 +34,8 @@ async function createComponent(overrides?: { }; const mocks: MetaMocks = { mediaService: { - getAllTags: vi.fn((tagType: MetaTagType) => of(valuesByType[tagType])) + getAllTags: vi.fn((tagType: MetaTagType) => of(valuesByType[tagType])), + setThumbnailForTag: vi.fn(() => of(void 0)) }, routeParamMap$ }; @@ -255,4 +257,59 @@ describe('MetaComponent', () => { expect(clearSpy).toHaveBeenCalledTimes(1); }); + + it('opens the thumbnail file picker without triggering card navigation', async () => { + const { component } = await createComponent(); + const input = document.createElement('input'); + const clickSpy = vi.spyOn(input, 'click'); + const preventDefault = vi.fn(); + const stopPropagation = vi.fn(); + + component.openThumbnailUpload({ preventDefault, stopPropagation } as unknown as MouseEvent, input); + + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(stopPropagation).toHaveBeenCalledTimes(1); + expect(clickSpy).toHaveBeenCalledTimes(1); + }); + + it('uploads selected thumbnail and refreshes image URL for matching member', async () => { + const { component, mocks } = await createComponent(); + const detectChangesSpy = vi.spyOn((component as any).cdr, 'detectChanges'); + const input = document.createElement('input'); + const file = new File(['thumb'], 'thumb.png', { type: 'image/png' }); + const metaMember = { + name: 'Keanu Reeves', + imageUrl: '/api/media/cast/Keanu%20Reeves/thumbnail', + queryParams: { cast: ['Keanu Reeves'], sort: SearchComponent.DEFAULT_SORT } + }; + + Object.defineProperty(input, 'files', { value: [file], configurable: true }); + component.type = 'cast'; + + await component.onThumbnailSelected({ target: input } as unknown as Event, metaMember); + + expect(mocks.mediaService.setThumbnailForTag).toHaveBeenCalledWith('cast', 'Keanu Reeves', file); + expect(metaMember.imageUrl).toContain('/api/media/cast/Keanu%20Reeves/thumbnail?t='); + expect(component.isUploading('Keanu Reeves')).toBe(false); + expect(detectChangesSpy).toHaveBeenCalledTimes(1); + }); + + it('skips thumbnail upload when the current type is unsupported', async () => { + const { component, mocks } = await createComponent(); + const input = document.createElement('input'); + const file = new File(['thumb'], 'thumb.png', { type: 'image/png' }); + const metaMember = { + name: 'Keanu Reeves', + imageUrl: '/api/media/cast/Keanu%20Reeves/thumbnail', + queryParams: { cast: ['Keanu Reeves'], sort: SearchComponent.DEFAULT_SORT } + }; + + Object.defineProperty(input, 'files', { value: [file], configurable: true }); + component.type = 'unknown'; + + await component.onThumbnailSelected({ target: input } as unknown as Event, metaMember); + + expect(mocks.mediaService.setThumbnailForTag).not.toHaveBeenCalled(); + expect(metaMember.imageUrl).toBe('/api/media/cast/Keanu%20Reeves/thumbnail'); + }); }); diff --git a/src/MediaBrowser.Frontend/src/app/meta/meta.ts b/src/MediaBrowser.Frontend/src/app/meta/meta.ts index 7b18ffc..05220f3 100644 --- a/src/MediaBrowser.Frontend/src/app/meta/meta.ts +++ b/src/MediaBrowser.Frontend/src/app/meta/meta.ts @@ -29,6 +29,7 @@ export class MetaComponent implements OnInit, AfterViewInit, OnDestroy { metaMembers: MetaMember[] = []; isLoading: boolean = false; type: string = ''; + uploadingMembers = new Set(); private scrollPosition: number = 0; private readonly SCROLL_KEY = '-scroll-position'; private scrollListener?: (event: Event) => void; @@ -41,6 +42,15 @@ export class MetaComponent implements OnInit, AfterViewInit, OnDestroy { writers: 'writer' }; + private isSupportedTagType(tagType: string): tagType is MediaTagType { + return tagType in this.routePrefixMap; + } + + private getImageUrl(tagType: MediaTagType, name: string, cacheBust?: number): string { + const baseUrl = `/api/media/${this.routePrefixMap[tagType]}/${encodeURIComponent(name)}/thumbnail`; + return cacheBust ? `${baseUrl}?t=${cacheBust}` : baseUrl; + } + async ngOnInit(): Promise { this.routeSubscription = this.route.paramMap.subscribe(async (params) => { const newType = params.get('type')?.toLowerCase() ?? ''; @@ -108,18 +118,16 @@ export class MetaComponent implements OnInit, AfterViewInit, OnDestroy { try { - let routePreFix = ''; let results: string[] = []; - if (this.type in this.routePrefixMap) { + if (this.isSupportedTagType(this.type)) { const tagType = this.type as MediaTagType; results = await firstValueFrom(this.mediaService.getAllTags(tagType)); - routePreFix = this.routePrefixMap[tagType]; } this.metaMembers = results.map(name => ({ name, - imageUrl: `/api/media/${encodeURIComponent(routePreFix)}/${encodeURIComponent(name)}/thumbnail`, + imageUrl: this.getImageUrl(this.type as MediaTagType, name), queryParams: { [this.type]: [name], sort: SearchComponent.DEFAULT_SORT } })); @@ -141,4 +149,37 @@ export class MetaComponent implements OnInit, AfterViewInit, OnDestroy { clearPagePositionState(): void { SearchComponent.clearPagePositionState(); } + + isUploading(name: string): boolean { + return this.uploadingMembers.has(name); + } + + openThumbnailUpload(event: MouseEvent, input: HTMLInputElement): void { + event.preventDefault(); + event.stopPropagation(); + input.click(); + } + + async onThumbnailSelected(event: Event, metaMember: MetaMember): Promise { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + + if (!file || !this.isSupportedTagType(this.type)) { + return; + } + + const tagType = this.type; + this.uploadingMembers.add(metaMember.name); + + try { + await firstValueFrom(this.mediaService.setThumbnailForTag(tagType, metaMember.name, file)); + metaMember.imageUrl = this.getImageUrl(tagType, metaMember.name, Date.now()); + } catch (error) { + console.error('Thumbnail upload error:', error); + } finally { + this.uploadingMembers.delete(metaMember.name); + input.value = ''; + this.cdr.detectChanges(); + } + } } \ No newline at end of file diff --git a/src/MediaBrowser.Frontend/src/app/services/media.service.ts b/src/MediaBrowser.Frontend/src/app/services/media.service.ts index 9086e28..12eec98 100644 --- a/src/MediaBrowser.Frontend/src/app/services/media.service.ts +++ b/src/MediaBrowser.Frontend/src/app/services/media.service.ts @@ -93,6 +93,12 @@ export class MediaService { return this.apiService.get(`/media/${tagType}`); } + setThumbnailForTag(tagType: MediaTagType, name: string, file: File): Observable { + const formData = new FormData(); + formData.append('thumbnail', file, file.name); + return this.apiService.post(`/media/${tagType}/${name}/thumbnail`, formData); + } + updateFanartThumbnail(id: string, request: UpdateThumbnailRequest): Observable { return this.apiService.post(`/media/${id}/file/thumbnail-fanart`, request); } From 45e1d9704019610d4212601502686be6eb7c7c52 Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Sat, 14 Mar 2026 23:15:52 -0400 Subject: [PATCH 2/9] feat: add navigation history check for media editor --- .../src/app/media-editor/media-editor.html | 8 +++++--- .../src/app/media-editor/media-editor.ts | 4 ++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.html b/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.html index e2f71c5..033de88 100644 --- a/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.html +++ b/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.html @@ -1,9 +1,11 @@ @if (!isLoading) {
- + @if (hasNavigationHistory) { + + }

Edit Media

} -

Edit Media

+

{{ mediaId === null ? 'Import Media' : 'Edit Media' }}