diff --git a/.github/workflows/checkPR.yml b/.github/workflows/checkPR.yml index 96fb0463..82fd5b70 100644 --- a/.github/workflows/checkPR.yml +++ b/.github/workflows/checkPR.yml @@ -22,4 +22,4 @@ jobs: - name: Run Checks run: | - bun run check \ No newline at end of file + bun run check diff --git a/packages/obsidian/src/api/apis/TMDBSeasonAPI.ts b/packages/obsidian/src/api/apis/TMDBSeasonAPI.ts index ae702957..7fd6bb8e 100644 --- a/packages/obsidian/src/api/apis/TMDBSeasonAPI.ts +++ b/packages/obsidian/src/api/apis/TMDBSeasonAPI.ts @@ -161,6 +161,7 @@ export class TMDBSeasonAPI extends APIModel { dataSource: this.apiName, id: result.id?.toString() ?? '', seasonCount: totalSeasons, + image: result.poster_path ? `https://image.tmdb.org/t/p/w780${result.poster_path}` : '', }); }), ); @@ -242,6 +243,7 @@ export class TMDBSeasonAPI extends APIModel { id: `${tvId}/season/${seasonNumber}`, seasonTitle: season.name ?? titleText, seasonNumber: seasonNumber, + image: season.poster_path ?? '', }), ); } diff --git a/packages/obsidian/src/modals/MediaDbSearchResultModal.ts b/packages/obsidian/src/modals/MediaDbSearchResultModal.ts index f6f372fd..ad7e4434 100644 --- a/packages/obsidian/src/modals/MediaDbSearchResultModal.ts +++ b/packages/obsidian/src/modals/MediaDbSearchResultModal.ts @@ -1,9 +1,15 @@ import type MediaDbPlugin from 'packages/obsidian/src/main'; +import { MediaItemComponent } from 'packages/obsidian/src/modals/MediaItemComponent'; import { SelectModal } from 'packages/obsidian/src/modals/SelectModal'; import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel'; import type { SelectModalData, SelectModalOptions } from 'packages/obsidian/src/utils/ModalHelper'; import { SELECTMODALOPTIONSDEFAULT } from 'packages/obsidian/src/utils/ModalHelper'; +interface RenderedMediaItem extends MediaTypeModel { + titleEl?: HTMLElement; + summaryEl?: HTMLElement; +} + export class MediaDbSearchResultModal extends SelectModal { plugin: MediaDbPlugin; @@ -15,6 +21,8 @@ export class MediaDbSearchResultModal extends SelectModal { skipCallback?: () => void; submitButtonText: string; + private autoFetchIndex: number; + constructor(plugin: MediaDbPlugin, selectModalOptions: SelectModalOptions) { selectModalOptions = Object.assign({}, SELECTMODALOPTIONSDEFAULT, selectModalOptions); super(plugin.app, selectModalOptions.elements ?? [], selectModalOptions.multiSelect); @@ -25,6 +33,7 @@ export class MediaDbSearchResultModal extends SelectModal { this.submitButtonText = selectModalOptions.submitButtonText ?? 'Ok'; this.busy = false; this.sendCallback = false; + this.autoFetchIndex = 0; } setSubmitCb(submitCallback: (res: SelectModalData) => void): void { @@ -39,14 +48,106 @@ export class MediaDbSearchResultModal extends SelectModal { this.skipCallback = skipCallback; } - // Renders each suggestion item. + /** + * Returns the rate limit delay based on API source. MAL APIs allow max 3 per second, but that still triggers rate limits, so using 750ms delay. + * @param dataSource The API source name (e.g., 'MALAPI', 'MALAPIManga') + * @returns The delay in milliseconds (750ms for MAL, 200ms for others) + */ + private getDelayForApi(dataSource: string): number { + const isMalApi = dataSource === 'MALAPI' || dataSource === 'MALAPIManga'; + return isMalApi ? 750 : 200; + } + + /** + * Renders a media suggestion item in the modal. + * Creates the MediaItemComponent and auto-fetches detailed info if the item lacks image or year. + * @param item The media type model to render + * @param el The HTMLElement to render into + */ renderElement(item: MediaTypeModel, el: HTMLElement): void { - el.createDiv({ text: this.plugin.mediaTypeManager.getFileName(item) }); - el.createEl('small', { text: `${item.getSummary()}\n` }); - el.createEl('small', { text: `${item.type.toUpperCase() + (item.subType ? ` (${item.subType})` : '')} from ${item.dataSource}` }); + const mediaComponent = new MediaItemComponent(el, { + imageUrl: this.getImageUrl(item), + imageAlt: item.title, + onImageError: (): void => { + console.debug('MDB | Image failed to load for', item.id); + }, + onImageLoad: (): void => { + console.debug('MDB | Image loaded for', item.id); + }, + renderContent: (contentEl: HTMLElement): void => { + const titleEl = contentEl.createDiv({ + text: this.plugin.mediaTypeManager.getFileName(item), + cls: 'media-db-plugin-select-title', + }); + const summaryEl = contentEl.createEl('small', { text: `${item.getSummary()}\n` }); + contentEl.createEl('small', { + text: `${item.type.toUpperCase() + (item.subType ? ` (${item.subType})` : '')} from ${item.dataSource}`, + }); + + const renderedItem: RenderedMediaItem = item; + renderedItem.titleEl = titleEl; + renderedItem.summaryEl = summaryEl; + }, + }); + + this.autoFetchDetails(item, mediaComponent); + } + + private getImageUrl(item: MediaTypeModel): string | undefined { + if (item.image && item.image !== 'NSFW') { + if (!String(item.image).includes('null')) { + return item.image; + } + } + return item.image; + } + + private autoFetchDetails(item: MediaTypeModel, mediaComponent: MediaItemComponent): void { + const needsFetch = !item.image || !item.year; + if (!needsFetch) return; + + const apiDelay = this.getDelayForApi(item.dataSource); + const index = this.autoFetchIndex++; + const delayMs = index * apiDelay; + + console.debug('MDB | will auto-fetch detail for', item.dataSource, item.id, 'in', delayMs, 'ms', `(${apiDelay}ms per request)`); + + window.setTimeout(async () => { + if (item.image && item.year) return; + console.debug('MDB | auto-fetching detail for', item.dataSource, item.id); + try { + console.debug('MDB | fetching detailed info for', item.dataSource, item.id); + const detailedResult = await this.plugin.apiManager.queryDetailedInfo(item); + if (!detailedResult.ok) { + console.warn('MDB | failed to fetch detailed info', detailedResult.error); + return; + } + + const detailed = detailedResult.value; + console.debug('MDB | detailed fetch result', detailed?.dataSource, detailed?.id, detailed?.image, detailed?.year); + + if (detailed?.image && !item.image) { + item.image = detailed.image; + mediaComponent.updateImage(item.image); + } + + if (!item.year && detailed?.year) { + item.year = detailed.year; + + const renderedItem: RenderedMediaItem = item; + if (renderedItem.titleEl) { + renderedItem.titleEl.textContent = this.plugin.mediaTypeManager.getFileName(item); + } + if (renderedItem.summaryEl) { + renderedItem.summaryEl.textContent = `${item.getSummary()}\n`; + } + } + } catch (e) { + console.warn('MDB | Failed to fetch detail', e); + } + }, delayMs); } - // Perform action on the selected suggestion. submit(): void { if (!this.busy) { this.busy = true; diff --git a/packages/obsidian/src/modals/MediaDbSeasonSelectModal.ts b/packages/obsidian/src/modals/MediaDbSeasonSelectModal.ts index 5bf902d7..09f6b4f8 100644 --- a/packages/obsidian/src/modals/MediaDbSeasonSelectModal.ts +++ b/packages/obsidian/src/modals/MediaDbSeasonSelectModal.ts @@ -1,4 +1,5 @@ import type MediaDbPlugin from 'packages/obsidian/src/main'; +import { MediaItemComponent } from 'packages/obsidian/src/modals/MediaItemComponent'; import { SelectModal } from 'packages/obsidian/src/modals/SelectModal'; export interface SeasonSelectModalElement { @@ -25,10 +26,16 @@ export class MediaDbSeasonSelectModal extends SelectModal { + contentEl.createDiv({ text: `${season.name}` }); + if (season.air_date) { + contentEl.createEl('small', { text: `Air date: ${season.air_date}` }); + } + }, + }); } submit(): void { diff --git a/packages/obsidian/src/modals/MediaItemComponent.ts b/packages/obsidian/src/modals/MediaItemComponent.ts new file mode 100644 index 00000000..3c887373 --- /dev/null +++ b/packages/obsidian/src/modals/MediaItemComponent.ts @@ -0,0 +1,94 @@ +import { setIcon } from 'obsidian'; + +export interface MediaItemComponentOptions { + imageUrl?: string; + imageAlt?: string; + onImageError?: () => void; + onImageLoad?: () => void; + renderContent: (contentEl: HTMLElement) => void; +} + +export class MediaItemComponent { + private container: HTMLElement; + private thumbEl!: HTMLElement; + private contentEl!: HTMLElement; + private imgEl: HTMLImageElement | undefined; + private options: MediaItemComponentOptions; + + constructor(container: HTMLElement, options: MediaItemComponentOptions) { + this.container = container; + this.options = options; + + this.setup(); + } + + private setup(): void { + // Set container layout + this.container.addClass('media-db-plugin-select-media-item-component'); + + // Create thumbnail + this.thumbEl = this.container.createDiv({ cls: 'media-db-plugin-select-media-item-thumb' }); + + // Create content area + this.contentEl = this.container.createDiv({ cls: 'media-db-plugin-select-media-item-content' }); + + // Render custom content + this.options.renderContent(this.contentEl); + + // Setup image if provided + if (this.options.imageUrl) { + this.loadImage(this.options.imageUrl); + } else { + this.showPlaceholder(); + } + } + + private loadImage(url: string): void { + if (!this.imgEl) { + this.imgEl = activeDocument.createElement('img'); + this.imgEl.loading = 'lazy'; + this.imgEl.alt = this.options.imageAlt ?? 'Media item'; + this.imgEl.className = 'media-db-plugin-select-media-item-image'; + + this.imgEl.onerror = (): void => { + this.showPlaceholder(); + this.options.onImageError?.(); + }; + + this.imgEl.onload = (): void => { + this.options.onImageLoad?.(); + }; + + this.thumbEl.empty(); + this.thumbEl.appendChild(this.imgEl); + } + + this.imgEl.src = url; + } + + private showPlaceholder(): void { + this.thumbEl.empty(); + const iconEl = this.thumbEl.createDiv('icon'); + setIcon(iconEl, 'image'); + iconEl.className = 'media-db-plugin-select-media-item-placeholder'; + } + + public updateImage(url: string | undefined): void { + if (url && url !== 'NSFW') { + if (!String(url).includes('null')) { + this.loadImage(url); + } else { + this.showPlaceholder(); + } + } else if (url === 'NSFW') { + this.thumbEl.empty(); + this.thumbEl.createEl('span', { text: 'NSFW', cls: 'media-db-plugin-select-media-item-placeholder' }); + } else { + this.showPlaceholder(); + } + } + + public getContentElement(): HTMLElement { + return this.contentEl; + } +} diff --git a/packages/obsidian/src/styles.css b/packages/obsidian/src/styles.css index ed3a79e0..60ed5e1a 100644 --- a/packages/obsidian/src/styles.css +++ b/packages/obsidian/src/styles.css @@ -41,6 +41,45 @@ small.media-db-plugin-list-text { font-size: 16px; } +.media-db-plugin-select-media-item-component { + display: flex; + gap: 8px; + align-items: flex-start; +} + +.media-db-plugin-select-media-item-thumb { + width: 48px; + height: 72px; + flex: 0 0 48px; + overflow: hidden; + background: var(--background-modifier-hover); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; +} + +.media-db-plugin-select-media-item-content { + flex: 1; + min-width: 0; +} + +.media-db-plugin-select-media-item-image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.media-db-plugin-select-media-item-placeholder { + font-size: 24px; + color: var(--text-muted); +} + +.media-db-plugin-select-title { + font-weight: 600; +} + .media-db-plugin-select-element-selected { border-left: 5px solid var(--interactive-accent) !important; background: var(--background-secondary-alt); diff --git a/packages/obsidian/src/utils/MediaDbFileHelper.ts b/packages/obsidian/src/utils/MediaDbFileHelper.ts index 4c7db6f2..d2a509da 100644 --- a/packages/obsidian/src/utils/MediaDbFileHelper.ts +++ b/packages/obsidian/src/utils/MediaDbFileHelper.ts @@ -307,7 +307,8 @@ export class MediaDbFileHelper { if (!imageResult.ok) { Logger.warn('MDB | Failed to download image:', imageResult.error); - return imageResult; + delete mediaTypeModel.image; + return ok(undefined); } }