diff --git a/apps/server/package.json b/apps/server/package.json index 464a4392..95682e0d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -42,6 +42,7 @@ "axios-retry": "^4.5.0", "better-sqlite3": "^12.8.0", "chalk": "^4.1.2", + "cron": "^4.4.0", "cron-validator": "^1.4.0", "email-templates": "13.0.1", "http-terminator": "^3.2.0", diff --git a/apps/server/src/app/app.module.ts b/apps/server/src/app/app.module.ts index e7729311..0f140280 100644 --- a/apps/server/src/app/app.module.ts +++ b/apps/server/src/app/app.module.ts @@ -25,6 +25,7 @@ import { NotificationService } from '../modules/notifications/notifications.serv import { RulesModule } from '../modules/rules/rules.module'; import { SettingsModule } from '../modules/settings/settings.module'; import { SettingsService } from '../modules/settings/settings.service'; +import { StatsModule } from '../modules/stats/stats.module'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import ormConfig from './config/typeOrmConfig'; @@ -48,6 +49,7 @@ import ormConfig from './config/typeOrmConfig'; TautulliApiModule, RulesModule, CollectionsModule, + StatsModule, NotificationsModule, EventsModule, ServeStaticModule.forRootAsync({ diff --git a/apps/server/src/modules/api/media-server/plex/plex-adapter.service.ts b/apps/server/src/modules/api/media-server/plex/plex-adapter.service.ts index ca21d140..631c2013 100644 --- a/apps/server/src/modules/api/media-server/plex/plex-adapter.service.ts +++ b/apps/server/src/modules/api/media-server/plex/plex-adapter.service.ts @@ -169,13 +169,20 @@ export class PlexAdapterService implements IMediaServerService { libraryId: string, options?: RecentlyAddedOptions, ): Promise { - // PlexApiService.getRecentlyAdded uses addedAt timestamp, not limit/type - // We'll use the default (items added in last hour) - const results = await this.plexApi.getRecentlyAdded(libraryId); + const response = await this.plexApi.getLibraryContents( + libraryId, + { + offset: 0, + size: options?.limit ?? 50, + sort: 'addedAt:desc', + }, + undefined, + false, + ); + const results = response?.items; if (!results) return []; - // Apply limit if provided const limited = options?.limit ? results.slice(0, options.limit) : results; return limited.map(PlexMapper.toMediaItem); } diff --git a/apps/server/src/modules/collections/collections.controller.ts b/apps/server/src/modules/collections/collections.controller.ts index 7c4d7562..6939e8d1 100644 --- a/apps/server/src/modules/collections/collections.controller.ts +++ b/apps/server/src/modules/collections/collections.controller.ts @@ -184,6 +184,11 @@ export class CollectionsController { return this.collectionService.getCollectionMediaCount(collectionId); } + @Get('/storage-summary') + getStorageSummary() { + return this.collectionService.getCollectionStorageSummary(); + } + @Get('/media/:id/content/:page') getLibraryContent( @Param('id', ParseIntPipe) id: number, diff --git a/apps/server/src/modules/collections/collections.service.ts b/apps/server/src/modules/collections/collections.service.ts index e6d31a34..eb5aa40c 100644 --- a/apps/server/src/modules/collections/collections.service.ts +++ b/apps/server/src/modules/collections/collections.service.ts @@ -1,6 +1,7 @@ import { BasicResponseDto, CollectionLogMeta, + CollectionLogMediaSnapshot, ECollectionLogType, isMediaType, MaintainerrEvent, @@ -43,6 +44,23 @@ import { ServarrAction, } from './interfaces/collection.interface'; +export interface CollectionStorageSummary { + collectionCount: number; + sizedCollectionCount: number; + totalSizeBytes: number; + reclaimableCollectionCount: number; + reclaimableSizeBytes: number; + byLibrary: CollectionStorageLibrarySummary[]; +} + +export interface CollectionStorageLibrarySummary { + libraryId: string; + collectionCount: number; + collectedCount: number; + totalSizeBytes: number; + reclaimableSizeBytes: number; +} + interface addCollectionDbResponse { id: number; mediaServerId?: string; @@ -129,6 +147,90 @@ export class CollectionsService { return await this.CollectionMediaRepo.count(); } + public async getCollectionStorageSummary(): Promise { + const collections = await this.collectionRepo.find({ + select: { + id: true, + libraryId: true, + isActive: true, + deleteAfterDays: true, + totalSizeBytes: true, + }, + }); + const mediaCountRows = await this.CollectionMediaRepo.createQueryBuilder( + 'media', + ) + .select('media.collectionId', 'collectionId') + .addSelect('COUNT(media.id)', 'mediaCount') + .groupBy('media.collectionId') + .getRawMany<{ collectionId: number; mediaCount: string }>(); + const mediaCountByCollection = new Map( + mediaCountRows.map((row) => [ + Number(row.collectionId), + Number(row.mediaCount), + ]), + ); + const librarySummaryById = new Map< + string, + CollectionStorageLibrarySummary + >(); + + const summary = collections.reduce( + (summary, collection) => { + const sizeBytes = Number(collection.totalSizeBytes ?? 0); + const hasSize = Number.isFinite(sizeBytes) && sizeBytes > 0; + const isReclaimable = + collection.isActive && (collection.deleteAfterDays ?? 0) > 0; + const librarySummary = + librarySummaryById.get(collection.libraryId) ?? + ({ + libraryId: collection.libraryId, + collectionCount: 0, + collectedCount: 0, + totalSizeBytes: 0, + reclaimableSizeBytes: 0, + } satisfies CollectionStorageLibrarySummary); + + summary.collectionCount += 1; + librarySummary.collectionCount += 1; + librarySummary.collectedCount += + mediaCountByCollection.get(collection.id) ?? 0; + + if (hasSize) { + summary.sizedCollectionCount += 1; + summary.totalSizeBytes += sizeBytes; + librarySummary.totalSizeBytes += sizeBytes; + } + + if (isReclaimable) { + summary.reclaimableCollectionCount += 1; + + if (hasSize) { + summary.reclaimableSizeBytes += sizeBytes; + librarySummary.reclaimableSizeBytes += sizeBytes; + } + } + + librarySummaryById.set(collection.libraryId, librarySummary); + return summary; + }, + { + collectionCount: 0, + sizedCollectionCount: 0, + totalSizeBytes: 0, + reclaimableCollectionCount: 0, + reclaimableSizeBytes: 0, + byLibrary: [], + }, + ); + + summary.byLibrary = [...librarySummaryById.values()].sort((a, b) => + a.libraryId.localeCompare(b.libraryId), + ); + + return summary; + } + public async getCollectionMediaWithServerDataAndPaging( id: number, { offset = 0, size = 25 }: { offset?: number; size?: number } = {}, @@ -1204,6 +1306,12 @@ export class CollectionsService { // if there's no data.. skip logging if (mediaData) { + const logMetaWithMedia = await this.addMediaSnapshotToLogMeta( + logMeta, + mediaData, + mediaServer, + type, + ); const subject = isMediaType(mediaData.type, 'episode') ? `${mediaData.grandparentTitle} - season ${mediaData.parentIndex} - episode ${mediaData.index}` : isMediaType(mediaData.type, 'season') @@ -1213,11 +1321,61 @@ export class CollectionsService { { id: collectionId } as Collection, `${type === 'add' ? 'Added' : type === 'handle' ? 'Successfully handled' : type === 'exclude' ? 'Added a specific exclusion for' : type === 'include' ? 'Removed specific exclusion of' : 'Removed'} "${subject}"`, ECollectionLogType.MEDIA, - logMeta, + logMetaWithMedia, ); } } + private async addMediaSnapshotToLogMeta( + logMeta: CollectionLogMeta | undefined, + mediaData: MediaItem, + mediaServer: IMediaServerService, + actionType: 'add' | 'remove' | 'handle' | 'exclude' | 'include', + ): Promise { + const media = await this.getLogMediaSnapshot(mediaData, mediaServer); + + if (logMeta) { + return { ...logMeta, media } as CollectionLogMeta; + } + + return { + type: + actionType === 'remove' + ? 'media_removed_manually' + : 'media_added_manually', + media, + }; + } + + private async getLogMediaSnapshot( + mediaData: MediaItem, + mediaServer: IMediaServerService, + ): Promise { + const parentId = + mediaData.type === 'episode' + ? mediaData.grandparentId + : mediaData.type === 'season' + ? mediaData.parentId + : undefined; + const parentItem = parentId + ? await mediaServer.getMetadata(parentId) + : undefined; + const posterSource = mediaData.type === 'movie' ? mediaData : parentItem; + + return { + mediaServerId: mediaData.id, + mediaType: mediaData.type, + title: mediaData.title, + parentTitle: mediaData.parentTitle, + grandparentTitle: mediaData.grandparentTitle, + seasonNumber: + mediaData.type === 'season' ? mediaData.index : mediaData.parentIndex, + episodeNumber: mediaData.type === 'episode' ? mediaData.index : undefined, + tmdbId: posterSource?.providerIds?.tmdb?.[0], + posterType: mediaData.type === 'movie' ? 'movie' : 'show', + }; + } + private async removeChildFromCollection( collectionIds: { mediaServerId: string; dbId: number }, childId: string, diff --git a/apps/server/src/modules/settings/settings.controller.spec.ts b/apps/server/src/modules/settings/settings.controller.spec.ts index 0259dedf..8851a372 100644 --- a/apps/server/src/modules/settings/settings.controller.spec.ts +++ b/apps/server/src/modules/settings/settings.controller.spec.ts @@ -122,7 +122,7 @@ describe('SettingsController', () => { }); it('sets database download headers and returns streamable file', async () => { - const fileStream = createReadStream('/etc/hosts'); + const fileStream = createReadStream(__filename); databaseDownloadService.getDatabaseDownload.mockResolvedValue({ fileStream, fileName: 'maintainerr.db', diff --git a/apps/server/src/modules/stats/stats.controller.ts b/apps/server/src/modules/stats/stats.controller.ts new file mode 100644 index 00000000..e6d3fdce --- /dev/null +++ b/apps/server/src/modules/stats/stats.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get } from '@nestjs/common'; +import { AppStatsResponse, StatsService } from './stats.service'; + +@Controller('api/stats') +export class StatsController { + constructor(private readonly statsService: StatsService) {} + + @Get() + getStats(): Promise { + return this.statsService.getStats(); + } +} diff --git a/apps/server/src/modules/stats/stats.module.ts b/apps/server/src/modules/stats/stats.module.ts new file mode 100644 index 00000000..b26d95f4 --- /dev/null +++ b/apps/server/src/modules/stats/stats.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { MediaServerModule } from '../api/media-server/media-server.module'; +import { ServarrApiModule } from '../api/servarr-api/servarr-api.module'; +import { CollectionsModule } from '../collections/collections.module'; +import { CollectionLog } from '../collections/entities/collection_log.entities'; +import { RuleGroup } from '../rules/entities/rule-group.entities'; +import { SonarrSettings } from '../settings/entities/sonarr_settings.entities'; +import { StatsController } from './stats.controller'; +import { StatsService } from './stats.service'; + +@Module({ + imports: [ + CollectionsModule, + MediaServerModule, + ServarrApiModule, + TypeOrmModule.forFeature([CollectionLog, RuleGroup, SonarrSettings]), + ], + controllers: [StatsController], + providers: [StatsService], +}) +export class StatsModule {} diff --git a/apps/server/src/modules/stats/stats.service.ts b/apps/server/src/modules/stats/stats.service.ts new file mode 100644 index 00000000..fcb2b36f --- /dev/null +++ b/apps/server/src/modules/stats/stats.service.ts @@ -0,0 +1,727 @@ +import { + CollectionLogMeta, + ECollectionLogType, + MediaItem, + MediaItemType, + MediaItemWithParent, +} from '@maintainerr/contracts'; +import { Injectable } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { CronTime } from 'cron'; +import { Repository } from 'typeorm'; +import { MediaServerFactory } from '../api/media-server/media-server.factory'; +import { + DiskSpaceResource, + RootFolder, +} from '../api/servarr-api/interfaces/servarr.interface'; +import { ServarrService } from '../api/servarr-api/servarr.service'; +import { CollectionsService } from '../collections/collections.service'; +import { CollectionLog } from '../collections/entities/collection_log.entities'; +import { RuleGroup } from '../rules/entities/rule-group.entities'; +import { SonarrSettings } from '../settings/entities/sonarr_settings.entities'; +import { SettingsService } from '../settings/settings.service'; + +export interface AppStatsResponse { + rules: number; + storage: AppStorageStats; + choppingBlock: AppChoppingBlockStats; + libraries: AppLibraryStats[]; + recentlyAdded: MediaItem[]; + collections: AppCollectionPreview[]; + leavingSoon: AppLeavingSoonItem[]; + tasks: AppTaskStats[]; + configuredServices: AppConfiguredService[]; + recentActivity: AppRecentActivityItem[]; +} + +interface AppStorageStats { + totalSpace: number; + usedSpace: number; + freeSpace: number; + sourceCount: number; +} + +interface AppLibraryStats { + id: string; + title: string; + type: 'movie' | 'show'; + itemCount: number; + seasonCount?: number; + episodeCount?: number; +} + +interface AppChoppingBlockStats { + totalSizeBytes: number; + collections: AppChoppingBlockCollectionStats[]; +} + +interface AppChoppingBlockCollectionStats { + id: number; + title: string; + totalSizeBytes: number; + mediaCount: number; +} + +interface AppCollectionPreview { + id: number; + title: string; + description?: string; + type: MediaItemType; + libraryId: string; + mediaCount: number; + totalSizeBytes?: number | null; + deleteAfterDays?: number | null; + isActive: boolean; + media: AppCollectionPreviewMedia[]; +} + +interface AppCollectionPreviewMedia { + image_path?: string; +} + +interface AppLeavingSoonItem { + media: MediaItem | MediaItemWithParent; + collectionId: number; + collectionTitle: string; + deleteDate: string; + daysLeft: number; +} + +interface AppTaskStats { + name: string; + nextRun?: string; + lastRun?: string; +} + +interface AppConfiguredService { + name: string; + status: 'Connected' | 'Disconnected'; +} + +interface AppRecentActivityItem { + id: number; + collectionId: number; + collectionTitle: string; + posterTmdbId?: string; + posterType?: 'movie' | 'show'; + timestamp: string; + message: string; + type: ECollectionLogType; + meta?: CollectionLogMeta; +} + +function normalizeDiskPath(path: string): string { + return path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase() || '/'; +} + +function isPathPrefix(parent: string, child: string): boolean { + if (parent === child) return true; + if (parent === '/') return child.startsWith('/'); + return child.startsWith(`${parent}/`); +} + +@Injectable() +export class StatsService { + private readonly serviceStatusCacheMs = 5 * 60 * 1000; + private serviceStatusCache: + | { timestamp: number; services: AppConfiguredService[] } + | undefined; + private serviceStatusRefresh: Promise | undefined; + + constructor( + private readonly mediaServerFactory: MediaServerFactory, + private readonly servarrService: ServarrService, + private readonly collectionsService: CollectionsService, + private readonly settingsService: SettingsService, + private readonly schedulerRegistry: SchedulerRegistry, + @InjectRepository(RuleGroup) + private readonly ruleGroupRepository: Repository, + @InjectRepository(SonarrSettings) + private readonly sonarrSettingsRepository: Repository, + @InjectRepository(CollectionLog) + private readonly collectionLogRepository: Repository, + ) {} + + async getStats(): Promise { + const [rules, storage, choppingBlock, libraries, collections] = + await Promise.all([ + this.ruleGroupRepository.count(), + this.getSonarrStorageStats(), + this.getChoppingBlockStats(), + this.getLibraryStats(), + this.getCollectionPreviews(), + ]); + const [recentlyAdded, leavingSoon, recentActivity, tasks] = + await Promise.all([ + this.getRecentlyAdded(libraries), + this.getLeavingSoon(), + this.getRecentActivity(), + this.getTaskStats(), + ]); + + return { + rules, + storage, + choppingBlock, + libraries, + recentlyAdded, + collections, + leavingSoon, + tasks, + configuredServices: await this.getConfiguredServices(), + recentActivity, + }; + } + + private async getTaskStats(): Promise { + const settings = await this.settingsService.getSettings(); + const collectionSchedule = + settings && 'collection_handler_job_cron' in settings + ? settings.collection_handler_job_cron + : this.settingsService.collection_handler_job_cron; + const rulesSchedule = + settings && 'rules_handler_job_cron' in settings + ? settings.rules_handler_job_cron + : this.settingsService.rules_handler_job_cron; + + return [ + this.getTaskStat('Collection Handler', collectionSchedule), + this.getTaskStat( + 'Rule Handler', + rulesSchedule, + 'execute-global-schedule-rules', + ), + ]; + } + + private getTaskStat( + name: string, + schedule: string, + jobName: string = name, + ): AppTaskStats { + const job = this.schedulerRegistry.getCronJobs().get(jobName); + const lastRun = job?.lastDate()?.toISOString(); + const now = Date.now(); + + if (!schedule) { + return { name, lastRun }; + } + + const nextRun = this.getNextRunFromSchedule(schedule, now); + + return nextRun + ? { name, nextRun: nextRun.toISOString(), lastRun } + : { name, lastRun }; + } + + private getNextRunFromSchedule( + schedule: string, + now: number, + ): Date | undefined { + try { + const cronTime = new CronTime(schedule); + const candidates = cronTime.sendAt(10); + + return candidates + .map((candidate) => candidate.toJSDate()) + .find((date) => date.getTime() > now); + } catch { + return undefined; + } + } + + private async getConfiguredServices(): Promise { + const now = Date.now(); + + if ( + this.serviceStatusCache && + now - this.serviceStatusCache.timestamp < this.serviceStatusCacheMs + ) { + return this.serviceStatusCache.services; + } + + const serviceNames = await this.getConfiguredServiceNames(); + const cachedServicesByName = new Map( + this.serviceStatusCache?.services.map((service) => [ + service.name, + service, + ]) ?? [], + ); + + this.refreshConfiguredServiceStatus(); + + return serviceNames.map((name) => ({ + name, + status: cachedServicesByName.get(name)?.status ?? 'Connected', + })); + } + + private async getConfiguredServiceNames(): Promise { + const services: string[] = []; + const mediaServerType = this.settingsService.getMediaServerType(); + + if (mediaServerType) { + services.push(mediaServerType === 'jellyfin' ? 'Jellyfin' : 'Plex'); + } + + const sonarrSettings = await this.settingsService.getSonarrSettings(); + if (Array.isArray(sonarrSettings) && sonarrSettings.length > 0) { + services.push('Sonarr'); + } + + const radarrSettings = await this.settingsService.getRadarrSettings(); + if (Array.isArray(radarrSettings) && radarrSettings.length > 0) { + services.push('Radarr'); + } + + if (this.settingsService.tautulliConfigured()) { + services.push('Tautulli'); + } + + if (this.settingsService.seerrConfigured()) { + services.push('Seerr'); + } + + return services; + } + + private refreshConfiguredServiceStatus(): void { + if (this.serviceStatusRefresh) { + return; + } + + this.serviceStatusRefresh = this.buildConfiguredServiceStatuses() + .then((services) => { + this.serviceStatusCache = { + timestamp: Date.now(), + services, + }; + }) + .catch(() => undefined) + .finally(() => { + this.serviceStatusRefresh = undefined; + }); + } + + private async buildConfiguredServiceStatuses(): Promise< + AppConfiguredService[] + > { + const services: AppConfiguredService[] = []; + const mediaServerType = this.settingsService.getMediaServerType(); + const getStatus = async (isConnected: Promise | boolean) => + (await isConnected) ? 'Connected' : 'Disconnected'; + + if (mediaServerType) { + services.push({ + name: mediaServerType === 'jellyfin' ? 'Jellyfin' : 'Plex', + status: await getStatus( + this.settingsService.testMediaServerConnection(), + ), + }); + } + + const sonarrSettings = await this.settingsService.getSonarrSettings(); + if (Array.isArray(sonarrSettings) && sonarrSettings.length > 0) { + const statuses = await Promise.all( + sonarrSettings.map((setting) => + this.settingsService.testSonarr(setting.id), + ), + ); + services.push({ + name: 'Sonarr', + status: statuses.every((status) => status.status === 'OK') + ? 'Connected' + : 'Disconnected', + }); + } + + const radarrSettings = await this.settingsService.getRadarrSettings(); + if (Array.isArray(radarrSettings) && radarrSettings.length > 0) { + const statuses = await Promise.all( + radarrSettings.map((setting) => + this.settingsService.testRadarr(setting.id), + ), + ); + services.push({ + name: 'Radarr', + status: statuses.every((status) => status.status === 'OK') + ? 'Connected' + : 'Disconnected', + }); + } + + if (this.settingsService.tautulliConfigured()) { + services.push({ + name: 'Tautulli', + status: await getStatus( + this.settingsService + .testTautulli() + .then((result) => result.status === 'OK'), + ), + }); + } + + if (this.settingsService.seerrConfigured()) { + services.push({ + name: 'Seerr', + status: await getStatus( + this.settingsService + .testSeerr() + .then((result) => result.status === 'OK'), + ), + }); + } + + return services; + } + + private async getRecentActivity(): Promise { + const logs = await this.collectionLogRepository.find({ + relations: ['collection'], + order: { id: 'DESC' }, + take: 20, + }); + return logs + .filter((log) => log.collection) + .map((log) => { + const thumbnail = this.getRecentActivityThumbnail(log.meta); + + return { + id: log.id, + collectionId: log.collection.id, + collectionTitle: log.collection.title, + ...thumbnail, + timestamp: log.timestamp.toISOString(), + message: log.message, + type: log.type, + meta: log.meta, + }; + }); + } + + private getRecentActivityThumbnail( + meta: CollectionLogMeta | undefined, + ): Pick { + try { + if ( + meta && + 'media' in meta && + meta.media?.tmdbId && + meta.media.posterType + ) { + return { + posterTmdbId: meta.media.tmdbId, + posterType: meta.media.posterType, + }; + } + } catch { + return {}; + } + + return {}; + } + + private async getChoppingBlockStats(): Promise { + const collections = await this.collectionsService.getAllCollections(); + const sizedCollections = ( + await Promise.all( + collections.map(async (collection) => ({ + id: collection.id, + title: collection.title, + totalSizeBytes: Number(collection.totalSizeBytes ?? 0), + mediaCount: await this.collectionsService.getCollectionMediaCount( + collection.id, + ), + })), + ) + ) + .filter( + (collection) => + Number.isFinite(collection.totalSizeBytes) && + collection.totalSizeBytes > 0, + ) + .sort((a, b) => b.totalSizeBytes - a.totalSizeBytes); + + return { + totalSizeBytes: sizedCollections.reduce( + (total, collection) => total + collection.totalSizeBytes, + 0, + ), + collections: sizedCollections, + }; + } + + private async getLibraryStats(): Promise { + const mediaServer = await this.mediaServerFactory.getService(); + const libraries = await mediaServer.getLibraries(); + + return Promise.all( + libraries.map(async (library) => { + const itemCount = await mediaServer.getLibraryContentCount( + library.id, + library.type, + ); + + if (library.type !== 'show') { + return { + id: library.id, + title: library.title, + type: library.type, + itemCount, + }; + } + + const [seasonCount, episodeCount] = await Promise.all([ + mediaServer.getLibraryContentCount(library.id, 'season'), + mediaServer.getLibraryContentCount(library.id, 'episode'), + ]); + + return { + id: library.id, + title: library.title, + type: library.type, + itemCount, + seasonCount, + episodeCount, + }; + }), + ); + } + + private async getRecentlyAdded( + libraries: AppLibraryStats[], + ): Promise { + const mediaServer = await this.mediaServerFactory.getService(); + const recentItems = ( + await Promise.all( + libraries.map(async (library) => { + try { + return await mediaServer.getRecentlyAdded(library.id, { + limit: 10, + type: library.type, + }); + } catch { + return []; + } + }), + ) + ).flat(); + + return recentItems + .sort( + (a, b) => new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime(), + ) + .slice(0, 12); + } + + private async getCollectionPreviews(): Promise { + const collections = await this.collectionsService.getCollections(); + + return (collections ?? []) + .map((collection) => ({ + id: collection.id, + title: collection.manualCollection + ? `${collection.manualCollectionName} (manual)` + : collection.title, + description: collection.manualCollection + ? `Handled by rule: '${collection.title}'` + : collection.description, + type: collection.type, + libraryId: collection.libraryId, + mediaCount: collection.media?.length ?? 0, + totalSizeBytes: collection.totalSizeBytes, + deleteAfterDays: collection.deleteAfterDays, + isActive: collection.isActive, + media: (collection.media ?? []) + .slice(0, 2) + .map((media) => ({ image_path: media.image_path })), + })) + .sort((a, b) => b.mediaCount - a.mediaCount) + .slice(0, 12); + } + + private async getLeavingSoon(): Promise { + const collections = (await this.collectionsService.getCalendarData()) ?? []; + const mediaServer = await this.mediaServerFactory.getService(); + const candidates = collections + .flatMap((collection) => + collection.media.map((media) => { + const deleteDate = new Date(media.addDate); + deleteDate.setDate(deleteDate.getDate() + collection.deleteAfterDays); + + return { + mediaServerId: media.mediaServerId, + collectionId: collection.id, + collectionTitle: collection.title, + deleteDate, + }; + }), + ) + .filter((item) => Number.isFinite(item.deleteDate.getTime())) + .sort((a, b) => a.deleteDate.getTime() - b.deleteDate.getTime()); + const selectedCandidates = this.pickLeavingSoonCandidates(candidates, 24); + + return ( + await Promise.all( + selectedCandidates.map(async (item) => { + const media = await mediaServer.getMetadata(item.mediaServerId); + + if (!media) { + return undefined; + } + + const parentId = + media.type === 'episode' + ? media.grandparentId + : media.type === 'season' + ? media.parentId + : undefined; + const parentItem = parentId + ? await mediaServer.getMetadata(parentId) + : undefined; + const mediaWithParent = parentItem + ? ({ ...media, parentItem } as MediaItemWithParent) + : media; + + const daysLeft = Math.ceil( + (item.deleteDate.getTime() - Date.now()) / 86400000, + ); + + return { + media: mediaWithParent, + collectionId: item.collectionId, + collectionTitle: item.collectionTitle, + deleteDate: item.deleteDate.toISOString(), + daysLeft, + }; + }), + ) + ).filter((item): item is AppLeavingSoonItem => item !== undefined); + } + + private pickLeavingSoonCandidates( + items: T[], + limit: number, + ): T[] { + if (items.length <= limit) { + return items; + } + + const cutoff = items[limit - 1].deleteDate.getTime(); + const beforeCutoff = items.filter( + (item) => item.deleteDate.getTime() < cutoff, + ); + const tiedAtCutoff = items.filter( + (item) => item.deleteDate.getTime() === cutoff, + ); + const remaining = limit - beforeCutoff.length; + + return [...beforeCutoff, ...this.shuffle(tiedAtCutoff).slice(0, remaining)]; + } + + private shuffle(items: T[]): T[] { + return [...items].sort(() => Math.random() - 0.5); + } + + private async getSonarrStorageStats(): Promise { + const settings = await this.sonarrSettingsRepository.find(); + const diskspaceByRootFolder = ( + await Promise.all( + settings.map(async (setting) => { + try { + const client = await this.servarrService.getSonarrApiClient( + setting.id, + ); + const [diskspace, rootFolders] = await Promise.all([ + client.getDiskspace(), + client.getRootFolders(), + ]); + + return this.getRootFolderDiskspace( + diskspace ?? [], + rootFolders ?? [], + ); + } catch { + return []; + } + }), + ) + ).flat(); + const diskspace = + diskspaceByRootFolder.length > 0 + ? diskspaceByRootFolder + : ( + await Promise.all( + settings.map(async (setting) => { + try { + const client = await this.servarrService.getSonarrApiClient( + setting.id, + ); + return (await client.getDiskspace()) ?? []; + } catch { + return []; + } + }), + ) + ).flat(); + const uniqueDiskspace = new Map(); + + for (const entry of diskspace) { + const key = entry.path ?? `${entry.totalSpace}-${entry.freeSpace}`; + if (!uniqueDiskspace.has(key)) { + uniqueDiskspace.set(key, entry); + } + } + + const totals = [...uniqueDiskspace.values()].reduce( + (acc, entry) => { + const totalSpace = entry.totalSpace ?? 0; + const freeSpace = entry.freeSpace ?? 0; + + acc.totalSpace += totalSpace; + acc.freeSpace += freeSpace; + return acc; + }, + { totalSpace: 0, freeSpace: 0 }, + ); + + return { + totalSpace: totals.totalSpace, + freeSpace: totals.freeSpace, + usedSpace: Math.max(totals.totalSpace - totals.freeSpace, 0), + sourceCount: uniqueDiskspace.size, + }; + } + + private getRootFolderDiskspace( + diskspace: DiskSpaceResource[], + rootFolders: RootFolder[], + ): DiskSpaceResource[] { + const result = new Map(); + + for (const rootFolder of rootFolders) { + const rootPath = normalizeDiskPath(rootFolder.path); + let bestMatch: DiskSpaceResource | undefined; + let bestMatchLength = -1; + + for (const entry of diskspace) { + if (!entry.path) continue; + + const diskPath = normalizeDiskPath(entry.path); + if (!isPathPrefix(diskPath, rootPath)) continue; + + if (diskPath.length > bestMatchLength) { + bestMatch = entry; + bestMatchLength = diskPath.length; + } + } + + if (bestMatch?.path) { + result.set(normalizeDiskPath(bestMatch.path), bestMatch); + } + } + + return [...result.values()]; + } +} diff --git a/apps/ui/src/components/Calendar/index.tsx b/apps/ui/src/components/Calendar/index.tsx index 3cb73398..78e26050 100644 --- a/apps/ui/src/components/Calendar/index.tsx +++ b/apps/ui/src/components/Calendar/index.tsx @@ -1,12 +1,6 @@ import { type MediaItemType } from '@maintainerr/contracts' import { useQuery } from '@tanstack/react-query' -import { - type RefObject, - useEffect, - useMemo, - useRef, - useState, -} from 'react' +import { type RefObject, useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' import GetApiHandler from '../../utils/ApiHandler' import type { ICollectionMedia } from '../Collection' @@ -367,7 +361,9 @@ const getReferencesByCollection = (references: CalendarReference[]) => }, new Map()) const getReferenceDateLookups = (references: CalendarReference[]) => ({ - addDateByMediaId: new Map(references.map((ref) => [ref.mediaId, ref.addDate])), + addDateByMediaId: new Map( + references.map((ref) => [ref.mediaId, ref.addDate]), + ), addDateByMediaServerId: new Map( references.map((ref) => [ref.mediaServerId, ref.addDate]), ), @@ -387,7 +383,9 @@ const fetchCalendarModalItems = async ( const mediaResponse = await GetApiHandler<{ totalSize: number items: ICollectionMedia[] - }>(`/collections/media/${collectionId}/content/1?size=${collection?.media.length ?? 25}`) + }>( + `/collections/media/${collectionId}/content/1?size=${collection?.media.length ?? 25}`, + ) const mediaIds = new Set(refs.map((ref) => ref.mediaId)) const mediaServerIds = new Set(refs.map((ref) => ref.mediaServerId)) @@ -397,8 +395,7 @@ const fetchCalendarModalItems = async ( return mediaResponse.items .filter( (media) => - mediaIds.has(media.id) || - mediaServerIds.has(media.mediaServerId), + mediaIds.has(media.id) || mediaServerIds.has(media.mediaServerId), ) .map((media) => ({ mediaTitle: getMediaTitle(media), @@ -487,7 +484,10 @@ const CalendarPage = () => { useState(null) const [expandedDayKey, setExpandedDayKey] = useState(null) const collectionLookup = useMemo( - () => new Map((collections ?? []).map((collection) => [collection.id, collection])), + () => + new Map( + (collections ?? []).map((collection) => [collection.id, collection]), + ), [collections], ) const [modalTableBodyRef, modalTableScrollbarWidth] = @@ -611,7 +611,7 @@ const CalendarPage = () => {
= memo( document.body.style.overflow = '' } }, []) - return ( + return createPortal(
e.stopPropagation()} // Prevent modal close on content click > {/* Top Half with Background Image */} @@ -117,7 +118,7 @@ const MediaModalContent: React.FC = memo( >
{loading && (
-
+
)} @@ -129,10 +130,10 @@ const MediaModalContent: React.FC = memo( mediaType === 'movie' ? 'bg-black' : mediaType === 'show' - ? 'bg-sky-900' + ? 'bg-zinc-800' : mediaType === 'season' - ? 'bg-cyan-800' - : 'bg-indigo-900' + ? 'bg-zinc-800' + : 'bg-zinc-800' }`} > {mediaType} @@ -312,7 +313,7 @@ const MediaModalContent: React.FC = memo(
@@ -320,7 +321,8 @@ const MediaModalContent: React.FC = memo(
- + , + document.body, ) }, ) diff --git a/apps/ui/src/components/Common/MediaCard/index.tsx b/apps/ui/src/components/Common/MediaCard/index.tsx index 90227b57..76ad1166 100644 --- a/apps/ui/src/components/Common/MediaCard/index.tsx +++ b/apps/ui/src/components/Common/MediaCard/index.tsx @@ -142,9 +142,9 @@ const MediaCard: React.FC = ({ mediaType === 'movie' ? 'bg-slate-950/90 ring-1 ring-slate-500/30' : mediaType === 'show' - ? 'bg-sky-900/90 ring-1 ring-sky-400/30' + ? 'bg-maintainerrdark/90 ring-1 ring-maintainerr-600/30' : mediaType === 'season' - ? 'bg-cyan-800/90 ring-1 ring-cyan-400/30' + ? 'bg-maintainerr-800/90 ring-1 ring-maintainerr-500/30' : 'bg-indigo-900/90 ring-1 ring-indigo-400/30' }`} > @@ -160,9 +160,9 @@ const MediaCard: React.FC = ({ mediaType === 'movie' ? 'bg-slate-950/90 ring-1 ring-slate-500/30' : mediaType === 'show' - ? 'bg-sky-900/90 ring-1 ring-sky-400/30' + ? 'bg-maintainerrdark/90 ring-1 ring-maintainerr-600/30' : mediaType === 'season' - ? 'bg-cyan-800/90 ring-1 ring-cyan-400/30' + ? 'bg-maintainerr-800/90 ring-1 ring-maintainerr-500/30' : 'bg-indigo-900/90 ring-1 ring-indigo-400/30' }`} > @@ -181,9 +181,9 @@ const MediaCard: React.FC = ({ mediaType === 'movie' ? 'bg-slate-950/90 ring-1 ring-slate-500/30' : mediaType === 'show' - ? 'bg-sky-900/90 ring-1 ring-sky-400/30' + ? 'bg-maintainerrdark/90 ring-1 ring-maintainerr-600/30' : mediaType === 'season' - ? 'bg-cyan-800/90 ring-1 ring-cyan-400/30' + ? 'bg-maintainerr-800/90 ring-1 ring-maintainerr-500/30' : 'bg-indigo-900/90 ring-1 ring-indigo-400/30' }`} > @@ -204,9 +204,9 @@ const MediaCard: React.FC = ({ : mediaType === 'movie' ? 'bg-slate-950/90 ring-1 ring-slate-500/30' : mediaType === 'show' - ? 'bg-sky-900/90 ring-1 ring-sky-400/30' + ? 'bg-maintainerrdark/90 ring-1 ring-maintainerr-600/30' : mediaType === 'season' - ? 'bg-cyan-800/90 ring-1 ring-cyan-400/30' + ? 'bg-maintainerr-800/90 ring-1 ring-maintainerr-500/30' : 'bg-indigo-900/90 ring-1 ring-indigo-400/30' } `} > @@ -225,9 +225,9 @@ const MediaCard: React.FC = ({ mediaType === 'movie' ? 'bg-slate-950/90 ring-1 ring-slate-500/30' : mediaType === 'show' - ? 'bg-sky-900/90 ring-1 ring-sky-400/30' + ? 'bg-maintainerrdark/90 ring-1 ring-maintainerr-600/30' : mediaType === 'season' - ? 'bg-cyan-800/90 ring-1 ring-cyan-400/30' + ? 'bg-maintainerr-800/90 ring-1 ring-maintainerr-500/30' : 'bg-indigo-900/90 ring-1 ring-indigo-400/30' }`} > diff --git a/apps/ui/src/components/Common/PaginatedList/index.tsx b/apps/ui/src/components/Common/PaginatedList/index.tsx deleted file mode 100644 index 1192823d..00000000 --- a/apps/ui/src/components/Common/PaginatedList/index.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React, { useState } from 'react' - -interface ListItem { - id: number - title: string -} - -interface PaginatedListProps { - items: ListItem[] - onEdit: (id: number) => void - onAdd: () => void - addName?: string -} - -const PaginatedList: React.FC = ({ - items, - onEdit, - onAdd, - addName, -}) => { - const [currentPage, setCurrentPage] = useState(1) - const itemsPerPage = 5 - - const totalPages = Math.ceil(items.length / itemsPerPage) - - const currentItems = items.slice( - (currentPage - 1) * itemsPerPage, - currentPage * itemsPerPage, - ) - - const handlePrevPage = () => { - if (currentPage > 1) setCurrentPage(currentPage - 1) - } - - const handleNextPage = () => { - if (currentPage < totalPages) setCurrentPage(currentPage + 1) - } - - return ( -
-
    - {currentItems.map((item) => ( -
  • -
    - {item.title} - -
    -
  • - ))} -
- -
- - - - Page {currentPage} of {totalPages} - - - -
- -
- -
-
- ) -} - -export default PaginatedList diff --git a/apps/ui/src/components/Common/SearchBar/index.tsx b/apps/ui/src/components/Common/SearchBar/index.tsx index 0c1e88bf..297208e8 100644 --- a/apps/ui/src/components/Common/SearchBar/index.tsx +++ b/apps/ui/src/components/Common/SearchBar/index.tsx @@ -25,13 +25,13 @@ const SearchBar = (props: ISearchBar) => { } return ( -
+
{ onChange={(e) => inputHandler(e)} placeholder={props.placeholder ? props.placeholder : 'Search'} value={text} - className="block w-full rounded-full border border-sky-500/25 bg-slate-950/70 py-2 pl-10 text-white placeholder-slate-400 shadow-inner shadow-slate-950/40 backdrop-blur hover:border-sky-400/60 focus:border-sky-300 focus:bg-slate-950/90 focus:placeholder-slate-300 focus:outline-none focus:ring-1 focus:ring-sky-400/30 sm:text-base" + className="block w-full rounded-full border border-zinc-500 bg-zinc-700 py-2 pl-10 text-white placeholder-zinc-400 shadow-sm hover:border-zinc-400 focus:border-maintainerr-600 focus:placeholder-zinc-300 focus:outline-none focus:ring-0 sm:text-base" />
) diff --git a/apps/ui/src/components/Common/TabbedLinks/index.tsx b/apps/ui/src/components/Common/TabbedLinks/index.tsx index 074605b5..65a566a9 100644 --- a/apps/ui/src/components/Common/TabbedLinks/index.tsx +++ b/apps/ui/src/components/Common/TabbedLinks/index.tsx @@ -26,7 +26,7 @@ const TabbedLink = (props: ITabbedLink) => { const linkClasses = (props.disabled ? 'pointer-events-none touch-none ' : 'cursor-pointer ') + 'px-1 py-4 ml-8 text-md font-semibold leading-5 transition duration-300 leading-5 whitespace-nowrap first:ml-0' - const activeLinkColor = ' border-b text-amber-500 border-amber-600' + const activeLinkColor = ' border-b text-maintainerr border-maintainerr-600' const inactiveLinkColor = 'border-transparent text-zinc-500 hover:border-b focus:border-b hover:text-zinc-300 hover:border-zinc-400 focus:text-zinc-300 focus:border-zinc-400' diff --git a/apps/ui/src/components/Common/YamlImporterModal/index.tsx b/apps/ui/src/components/Common/YamlImporterModal/index.tsx index 55a5a6e6..b48d185f 100644 --- a/apps/ui/src/components/Common/YamlImporterModal/index.tsx +++ b/apps/ui/src/components/Common/YamlImporterModal/index.tsx @@ -129,7 +129,7 @@ const YamlImporterModal = (props: IYamlImporterModal) => { title="Copy YAML" aria-label="Copy YAML" > - + ) : ( diff --git a/apps/ui/src/components/Forms/Input.tsx b/apps/ui/src/components/Forms/Input.tsx index 3122a617..26c8cfbf 100644 --- a/apps/ui/src/components/Forms/Input.tsx +++ b/apps/ui/src/components/Forms/Input.tsx @@ -15,12 +15,11 @@ export const Input = forwardRef( ref={ref} id={props.id || props.name} className={clsx( - 'block w-full min-w-0 flex-1 rounded-md border border-sky-500/25 bg-slate-900/80 text-white shadow-sm shadow-slate-950/20 transition duration-150 ease-in-out placeholder:text-slate-500 focus:border-sky-400 focus:ring-1 focus:ring-sky-400/40 disabled:opacity-50 sm:text-sm sm:leading-5', - { - '!border-red-500 outline-red-500 focus:border-red-500 focus:outline-none focus:ring-0': - !props.disabled && error, - className, - }, + 'block w-full min-w-0 flex-1 rounded-md border border-zinc-500 bg-zinc-700 text-white shadow-sm transition duration-150 ease-in-out placeholder:text-zinc-400 focus:border-maintainerr-600 focus:outline-none focus:ring-0 disabled:opacity-50 sm:text-sm sm:leading-5', + !props.disabled && + error && + '!border-red-500 outline-red-500 focus:border-red-500 focus:outline-none focus:ring-0', + className, )} aria-required={required} aria-invalid={error} diff --git a/apps/ui/src/components/Forms/Select.tsx b/apps/ui/src/components/Forms/Select.tsx index 8baa5992..49eab1ce 100644 --- a/apps/ui/src/components/Forms/Select.tsx +++ b/apps/ui/src/components/Forms/Select.tsx @@ -14,12 +14,11 @@ export const Select = forwardRef( {...props} ref={ref} className={clsx( - 'block w-full min-w-0 flex-1 rounded-md border border-sky-500/25 bg-slate-900/80 text-white shadow-sm shadow-slate-950/20 transition duration-150 ease-in-out focus:border-sky-400 focus:ring-1 focus:ring-sky-400/40 disabled:opacity-50 sm:text-sm sm:leading-5', - { - '!border-red-500 outline-red-500 focus:border-red-500 focus:outline-none focus:ring-0': - !props.disabled && error, - className, - }, + 'block w-full min-w-0 flex-1 rounded-md border border-zinc-500 bg-zinc-700 text-white shadow-sm transition duration-150 ease-in-out focus:border-maintainerr-600 focus:outline-none focus:ring-0 disabled:opacity-50 sm:text-sm sm:leading-5', + !props.disabled && + error && + '!border-red-500 outline-red-500 focus:border-red-500 focus:outline-none focus:ring-0', + className, )} aria-required={required} aria-invalid={error} diff --git a/apps/ui/src/components/Layout/NavBar/index.tsx b/apps/ui/src/components/Layout/NavBar/index.tsx index 889aef30..2da5c316 100644 --- a/apps/ui/src/components/Layout/NavBar/index.tsx +++ b/apps/ui/src/components/Layout/NavBar/index.tsx @@ -1,13 +1,15 @@ -import { Transition, TransitionChild } from '@headlessui/react' import { CalendarIcon, + ChartBarIcon, ClipboardCheckIcon, CollectionIcon, CogIcon, EyeIcon, + MenuIcon, + SearchIcon, XIcon, } from '@heroicons/react/outline' -import { ReactNode, useContext, useMemo, useRef } from 'react' +import { ReactNode, useContext, useMemo, useState } from 'react' import { Link, useLocation } from 'react-router-dom' import SearchContext from '../../../contexts/search-context' import Messages from '../../Messages/Messages' @@ -22,15 +24,15 @@ interface NavBarLink { } interface NavBarProps { - open?: boolean - setClosed: () => void + onSearchOpen: () => void } -const NavBar: React.FC = ({ open, setClosed }) => { - const navRef = useRef(null) +const NavBar: React.FC = ({ onSearchOpen }) => { + const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const SearchCtx = useContext(SearchContext) const basePath = import.meta.env.VITE_BASE_PATH ?? '' const location = useLocation() + const isMediaRoute = /^\/media(?:\/.*)?$/.test(location.pathname) // Keep variable for potential future customization const collectionsLabel = 'Collections' @@ -39,35 +41,42 @@ const NavBar: React.FC = ({ open, setClosed }) => { { key: '0', href: '/overview', - svgIcon: , + svgIcon: , name: 'Overview', matchPattern: /^\/(?:overview(?:\/.*)?|)$/, }, { key: '1', + href: '/media', + svgIcon: , + name: 'Media', + matchPattern: /^\/media(?:\/.*)?$/, + }, + { + key: '2', href: '/rules', - svgIcon: , + svgIcon: , name: 'Rules', matchPattern: /^\/rules(?:\/.*)?$/, }, { - key: '2', + key: '3', href: '/collections', - svgIcon: , + svgIcon: , name: collectionsLabel, matchPattern: /^\/collections(?:\/.*)?$/, }, { - key: '3', + key: '4', href: '/calendar', - svgIcon: , + svgIcon: , name: 'Calendar', matchPattern: /^\/calendar(?:\/.*)?$/, }, { - key: '4', + key: '5', href: '/settings', - svgIcon: , + svgIcon: , name: 'Settings', matchPattern: /^\/settings(?:\/.*)?$/, }, @@ -84,126 +93,99 @@ const NavBar: React.FC = ({ open, setClosed }) => { } return ( -
-
- - -
-
- -
-
-
- -
-
-
- - - Logo - - -
- -
- - - - -
-
- {/* */} -
-
-
-
-
- -
-
-
-
-
- - - Logo - - -
- -
- - -
-
-
+
+
+ + Logo + + + + +
+ +
-
+ {mobileMenuOpen ? ( +
+ +
+ ) : null} + ) } diff --git a/apps/ui/src/components/Layout/index.tsx b/apps/ui/src/components/Layout/index.tsx index 8cc1972a..2cd260f9 100644 --- a/apps/ui/src/components/Layout/index.tsx +++ b/apps/ui/src/components/Layout/index.tsx @@ -1,17 +1,22 @@ -import { ArrowLeftIcon, MenuAlt2Icon } from '@heroicons/react/solid' -import { debounce } from 'lodash-es' -import { ReactNode, useContext, useEffect, useState } from 'react' +import { ChartBarIcon } from '@heroicons/react/outline' +import { SearchIcon, XIcon } from '@heroicons/react/solid' +import { + type MediaItem, + type MediaItemWithParent, +} from '@maintainerr/contracts' +import { ChangeEvent, ReactNode, useEffect, useRef, useState } from 'react' import { isRouteErrorResponse, + Link, Outlet, useLocation, useNavigate, useRouteError, } from 'react-router-dom' import { ToastContainer } from 'react-toastify' -import SearchContext from '../../contexts/search-context' import GetApiHandler from '../../utils/ApiHandler' -import SearchBar from '../Common/SearchBar' +import { SmallLoadingSpinner } from '../Common/LoadingSpinner' +import MediaModalContent from '../Common/MediaCard/MediaModal' import NavBar from './NavBar' type LayoutShellProps = { @@ -19,15 +24,14 @@ type LayoutShellProps = { } const LayoutShell: React.FC = ({ children }) => { - const [navBarOpen, setNavBarOpen] = useState(false) - const SearchCtx = useContext(SearchContext) + const [searchOpen, setSearchOpen] = useState(false) const navigate = useNavigate() const basePath = import.meta.env.VITE_BASE_PATH ?? '' const location = useLocation() - - const handleNavbar = () => { - setNavBarOpen(!navBarOpen) - } + const isMediaRoute = /^\/media(?:\/.*)?$/.test(location.pathname) + const shouldShowStatsDrawer = + !/^\/(?:overview)?$/.test(location.pathname) && + !/^\/settings(?:\/.*)?$/.test(location.pathname) useEffect(() => { // Check if setup is complete, if not redirect to appropriate settings page @@ -57,41 +61,22 @@ const LayoutShell: React.FC = ({ children }) => { sizes="180x180" href={`${basePath}/apple-touch-icon.png`} /> -
-
- -
-
-
- - - { - SearchCtx.addText(text) - - if (text !== '') { - navigate('/overview') - } - }, 1000)} - /> -
+
+
+
+
+ setSearchOpen(true)} /> + setSearchOpen(false)} + /> + {shouldShowStatsDrawer ? : undefined}
@@ -113,6 +98,541 @@ const LayoutShell: React.FC = ({ children }) => { ) } +interface GlobalStats { + rules?: number + storage?: AppStorageStats + choppingBlock?: AppChoppingBlockStats + libraries?: AppLibraryStats[] +} + +interface AppStorageStats { + totalSpace: number + usedSpace: number + freeSpace: number + sourceCount: number +} + +interface AppLibraryStats { + id: string + title: string + type: 'movie' | 'show' + itemCount: number +} + +interface AppChoppingBlockStats { + totalSizeBytes: number + collections: AppChoppingBlockCollectionStats[] +} + +interface AppChoppingBlockCollectionStats { + id: number + title: string + totalSizeBytes: number +} + +const formatStatValue = (value?: number): string => + value == null ? '--' : value.toLocaleString() + +const formatBytes = (value?: number): string => { + if (value == null || !Number.isFinite(value) || value <= 0) { + return '--' + } + + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] + let size = value + let unitIndex = 0 + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024 + unitIndex += 1 + } + + const decimals = size >= 10 || unitIndex === 0 ? 0 : 1 + return `${size.toFixed(decimals)} ${units[unitIndex]}` +} + +const getStoragePercent = (storage?: AppStorageStats): number => { + if (!storage?.totalSpace) { + return 0 + } + + return Math.min( + Math.max((storage.usedSpace / storage.totalSpace) * 100, 0), + 100, + ) +} + +const getStorageRemainingPercent = (storage?: AppStorageStats): number => { + if (!storage?.totalSpace) { + return 0 + } + + return Math.min( + Math.max((storage.freeSpace / storage.totalSpace) * 100, 0), + 100, + ) +} + +const getChoppingBlockStoragePercent = ( + storage?: AppStorageStats, + choppingBlock?: AppChoppingBlockStats, +): number => { + if (!storage?.totalSpace || !choppingBlock?.totalSizeBytes) { + return 0 + } + + return Math.min( + Math.max((choppingBlock.totalSizeBytes / storage.totalSpace) * 100, 0), + 100, + ) +} + +const GlobalStatsDrawer: React.FC = () => { + const [open, setOpen] = useState(false) + const [stats, setStats] = useState({}) + const [loading, setLoading] = useState(false) + const drawerRef = useRef(null) + const storagePercent = getStoragePercent(stats.storage) + const storageRemainingPercent = getStorageRemainingPercent(stats.storage) + const choppingBlockStoragePercent = getChoppingBlockStoragePercent( + stats.storage, + stats.choppingBlock, + ) + + useEffect(() => { + if (!open) { + return + } + + let active = true + setLoading(true) + GetApiHandler('/stats') + .then((statsResponse) => { + if (active) { + setStats(statsResponse) + } + }) + .finally(() => { + if (active) { + setLoading(false) + } + }) + + return () => { + active = false + } + }, [open]) + + return ( + <> + {open ? ( + + +
+ + ) +} + +interface SpotlightSearchProps { + open: boolean + onClose: () => void +} + +const padMediaIndex = (value: number): string => + value.toString().padStart(2, '0') + +const getSpotlightTitle = (item: MediaItem): string => { + return item.grandparentTitle + ? item.grandparentTitle + : item.parentTitle + ? item.parentTitle + : item.title +} + +const getSpotlightMeta = (item: MediaItem): string => { + if (item.type === 'episode' && item.index != null) { + if (item.parentIndex != null) { + return `S${padMediaIndex(item.parentIndex)}E${padMediaIndex(item.index)}` + } + return `E${padMediaIndex(item.index)}` + } + + if (item.type === 'season') { + return item.index != null ? `Season ${item.index}` : item.title + } + + return item.year ? item.year.toString().slice(0, 4) : item.type +} + +const getSpotlightSecondary = (item: MediaItem): string => { + if (item.type === 'episode') { + return item.title + } + + if (item.type === 'season' && item.parentTitle) { + return item.parentTitle + } + + return item.library?.title ?? item.type +} + +const getSpotlightTmdbId = (item: MediaItem): string | undefined => { + const parentItem = (item as MediaItemWithParent).parentItem + + if ( + (item.type === 'season' || item.type === 'episode') && + parentItem?.providerIds?.tmdb?.[0] + ) { + return parentItem.providerIds.tmdb[0] + } + + return item.providerIds?.tmdb?.[0] ?? parentItem?.providerIds?.tmdb?.[0] +} + +const getSpotlightAudienceRating = (item: MediaItem): number => { + return item.ratings?.find((rating) => rating.type === 'audience')?.value ?? 0 +} + +const formatSpotlightRating = (item: MediaItem): string => { + const rating = getSpotlightAudienceRating(item) + return rating > 0 ? rating.toFixed(1) : '-' +} + +const formatSpotlightViews = (item: MediaItem): string => { + const views = item.viewCount ?? item.watchedChildCount + return views != null ? views.toString() : '-' +} + +const formatSpotlightDate = (value?: Date): string => { + if (!value) { + return '-' + } + + const date = new Date(value) + return Number.isNaN(date.getTime()) ? '-' : date.toLocaleDateString() +} + +const getSpotlightTypeLabel = (item: MediaItem): string => { + if (item.type === 'show') { + return 'TV' + } + + return item.type.toUpperCase() +} + +const SpotlightSearch: React.FC = ({ open, onClose }) => { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [selectedMedia, setSelectedMedia] = useState() + const inputRef = useRef(null) + + useEffect(() => { + if (!open) { + return + } + + const focusTimer = setTimeout(() => inputRef.current?.focus(), 50) + return () => clearTimeout(focusTimer) + }, [open]) + + useEffect(() => { + if (!open) { + return + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose() + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [open, onClose]) + + useEffect(() => { + if (!open) { + return + } + + const trimmedQuery = query.trim().toLowerCase() + if (trimmedQuery.length < 2) { + setResults([]) + setLoading(false) + return + } + + let active = true + setLoading(true) + const searchTimer = setTimeout(() => { + GetApiHandler(`/media-server/search/${encodeURIComponent(trimmedQuery)}`) + .then((resp: MediaItem[]) => { + if (active) { + setResults(resp ?? []) + } + }) + .finally(() => { + if (active) { + setLoading(false) + } + }) + }, 250) + + return () => { + active = false + clearTimeout(searchTimer) + } + }, [query, open]) + + const handleQueryChange = (event: ChangeEvent) => { + setQuery(event.target.value) + } + + const handleClose = () => { + onClose() + setSelectedMedia(undefined) + } + + const displayResults = results.slice(0, 40) + + if (!open) { + return undefined + } + + return ( + <> +
+
+
+
+ + + +
+
+ {loading ? ( +
+ +
+ ) : query.trim().length < 2 ? ( +
+ Type at least two characters. +
+ ) : displayResults.length > 0 ? ( +
+
+ Title + Rating + Views + Last Viewed +
+
+ {displayResults.map((item) => ( + + ))} +
+ {results.length > displayResults.length ? ( +
+ Showing first {displayResults.length} of {results.length}{' '} + results. +
+ ) : undefined} +
+ ) : ( +
+ No results found. +
+ )} +
+
+
+ {selectedMedia ? ( + setSelectedMedia(undefined)} + title={getSpotlightTitle(selectedMedia)} + summary={selectedMedia.summary || 'No description available.'} + mediaType={selectedMedia.type} + tmdbid={getSpotlightTmdbId(selectedMedia)} + year={getSpotlightMeta(selectedMedia)} + userScore={getSpotlightAudienceRating(selectedMedia)} + /> + ) : undefined} + + ) +} + const Layout: React.FC = () => { return ( @@ -175,16 +695,16 @@ export const LayoutErrorBoundary: React.FC = () => {

diff --git a/apps/ui/src/components/Overview/Content/index.tsx b/apps/ui/src/components/Media/Content/index.tsx similarity index 95% rename from apps/ui/src/components/Overview/Content/index.tsx rename to apps/ui/src/components/Media/Content/index.tsx index 9dd10ee8..cc0c505a 100644 --- a/apps/ui/src/components/Overview/Content/index.tsx +++ b/apps/ui/src/components/Media/Content/index.tsx @@ -9,8 +9,9 @@ import LoadingSpinner, { SmallLoadingSpinner, } from '../../Common/LoadingSpinner' import MediaCard from '../../Common/MediaCard' +import { getMediaItemIdentity } from '../../../utils/mediaIdentity' -interface IOverviewContent { +interface IMediaContent { data: MediaItem[] dataFinished: boolean loading: boolean @@ -51,7 +52,7 @@ function extractTmdbId( return undefined } -const OverviewContent = (props: IOverviewContent) => { +const MediaContent = (props: IMediaContent) => { const latestPropsRef = useRef(props) const isNearBottom = () => @@ -161,8 +162,8 @@ const OverviewContent = (props: IOverviewContent) => { if (props.data && props.data.length > 0) { return (
    - {props.data.map((el) => ( -
  • + {props.data.map((el, index) => ( +
  • { } return <> } -export default OverviewContent +export default MediaContent diff --git a/apps/ui/src/components/Media/index.tsx b/apps/ui/src/components/Media/index.tsx new file mode 100644 index 00000000..b46cca0f --- /dev/null +++ b/apps/ui/src/components/Media/index.tsx @@ -0,0 +1,287 @@ +import { + type MediaItem, + type MediaLibrary, + type MediaLibrarySortParams, +} from '@maintainerr/contracts' +import { useContext, useEffect, useMemo, useRef, useState } from 'react' +import { useMediaServerLibraries } from '../../api/media-server' +import SearchContext from '../../contexts/search-context' +import GetApiHandler from '../../utils/ApiHandler' +import { dedupeMediaItems } from '../../utils/mediaIdentity' +import LibrarySwitcher from '../Common/LibrarySwitcher' +import { + getMediaLibrarySortConfig, + MediaLibrarySortControl, + sortMediaItems, + useMediaLibrarySort, +} from '../Common/MediaLibrarySortControl' +import { Select } from '../Forms/Select' +import MediaContent from './Content' + +const mediaLibraryStorageKey = 'maintainerr.media.selectedLibraryId' +const defaultFilterValue = 'all' +const filterOptions = [{ value: defaultFilterValue, label: 'All items' }] + +const getStoredMediaLibraryId = (): string | undefined => { + if (typeof window === 'undefined') { + return undefined + } + + return window.localStorage.getItem(mediaLibraryStorageKey) ?? undefined +} + +const Media = () => { + // const [isLoading, setIsLoading] = useState(false) + const loadingRef = useRef(false) + + const [loadingExtra, setLoadingExtra] = useState(false) + + const [data, setData] = useState([]) + const dataRef = useRef([]) + + const [totalSize, setTotalSize] = useState(999) + const totalSizeRef = useRef(999) + + const [selectedLibrary, setSelectedLibrary] = useState( + getStoredMediaLibraryId, + ) + const selectedLibraryRef = useRef(undefined) + const [searchUsed, setSearchUsed] = useState(false) + const [filterValue, setFilterValue] = useState(defaultFilterValue) + + const pageData = useRef(0) + const SearchCtx = useContext(SearchContext) + + const { data: libraries } = useMediaServerLibraries() + const currentLibrary = libraries?.find( + (library: MediaLibrary) => library.id === selectedLibrary, + ) + const currentLibraryType = currentLibrary?.type + const sortConfig = useMemo( + () => getMediaLibrarySortConfig(currentLibraryType), + [currentLibraryType], + ) + const { sortValue, sortParams, onSortChange } = + useMediaLibrarySort(sortConfig) + const hasCustomSortSelected = sortValue !== sortConfig.defaultValue + const hasCustomFilterSelected = filterValue !== defaultFilterValue + const hasResolvedTotalSize = totalSize !== 999 || data.length > 0 + const libraryCountLabel = hasResolvedTotalSize + ? `${totalSize.toLocaleString()} items` + : 'Loading count' + const loadedCount = data.length + + const fetchAmount = 30 + + const setIsLoading = (val: boolean) => { + loadingRef.current = val + } + + useEffect(() => { + if (!libraries || libraries.length === 0) { + return + } + + setTimeout(() => { + if ( + loadingRef.current && + data.length === 0 && + SearchCtx.search.text === '' + ) { + switchLib(selectedLibrary ? selectedLibrary : libraries[0].id) + } + }, 300) + + // Cleanup on unmount + return () => { + setData([]) + dataRef.current = [] + totalSizeRef.current = 999 + pageData.current = 0 + } + }, [libraries]) + + useEffect(() => { + if (!libraries || libraries.length === 0) return + + if (SearchCtx.search.text !== '') { + GetApiHandler(`/media-server/search/${SearchCtx.search.text}`).then( + (resp: MediaItem[]) => { + setSearchUsed(true) + setTotalSize(resp.length) + pageData.current = resp.length * 50 + setData( + resp ? dedupeMediaItems(sortMediaItems(resp, sortParams)) : [], + ) + setIsLoading(false) + }, + ) + } else { + setSearchUsed(false) + setData([]) + setTotalSize(999) + pageData.current = 0 + setIsLoading(true) + fetchData() + } + }, [SearchCtx.search.text]) + + useEffect(() => { + selectedLibraryRef.current = selectedLibrary + fetchData() + }, [selectedLibrary]) + + useEffect(() => { + dataRef.current = data + }, [data]) + + useEffect(() => { + totalSizeRef.current = totalSize + }, [totalSize]) + + const switchLib = (libraryId: string) => { + setIsLoading(true) + pageData.current = 0 + setTotalSize(999) + setData([]) + dataRef.current = [] + setSearchUsed(false) + setSelectedLibrary(libraryId) + window.localStorage.setItem(mediaLibraryStorageKey, libraryId) + } + + const fetchData = async ( + requestedSortParams: MediaLibrarySortParams = sortParams, + ) => { + if ( + selectedLibraryRef.current && + SearchCtx.search.text === '' && + totalSizeRef.current >= pageData.current * fetchAmount + ) { + const askedLib = selectedLibraryRef.current + + const resp: { totalSize: number; items: MediaItem[] } = + await GetApiHandler( + `/media-server/library/${selectedLibraryRef.current}/content?page=${ + pageData.current + 1 + }&limit=${fetchAmount}&${new URLSearchParams({ + sort: requestedSortParams.sort, + sortOrder: requestedSortParams.sortOrder, + }).toString()}`, + ) + + if (askedLib === selectedLibraryRef.current) { + setTotalSize(resp.totalSize) + pageData.current = pageData.current + 1 + setData( + dedupeMediaItems([ + ...dataRef.current, + ...(resp && resp.items ? resp.items : []), + ]), + ) + setIsLoading(false) + } + setLoadingExtra(false) + setIsLoading(false) + } + } + + const handleSortChange = (nextSortValue: string) => { + const nextSortState = onSortChange(nextSortValue) + if (!nextSortState) { + return + } + + if (SearchCtx.search.text !== '') { + setData((currentData) => + sortMediaItems(currentData, nextSortState.sortParams), + ) + return + } + + pageData.current = 0 + setTotalSize(999) + setData([]) + dataRef.current = [] + setIsLoading(true) + fetchData(nextSortState.sortParams) + } + + return ( + <> + Media - Maintainerr +
    + {!searchUsed ? ( +
    +
    +
    + +
    +
    + {libraryCountLabel} +
    +
    + +
    +
    + {hasCustomSortSelected ? ( + + ) : null} + +
    +
    + {hasCustomFilterSelected ? ( + + ) : null} + +
    +
    +
    + ) : undefined} + {selectedLibrary ? ( + = pageData.current * fetchAmount) + } + fetchData={() => { + setLoadingExtra(true) + fetchData() + }} + loading={loadingRef.current} + extrasLoading={ + loadingExtra && + !loadingRef.current && + totalSizeRef.current >= pageData.current * fetchAmount + } + data={data} + libraryId={selectedLibrary!} + /> + ) : undefined} +
    + + ) +} +export default Media diff --git a/apps/ui/src/components/Messages/Messages.tsx b/apps/ui/src/components/Messages/Messages.tsx index aa5ce183..75628e87 100644 --- a/apps/ui/src/components/Messages/Messages.tsx +++ b/apps/ui/src/components/Messages/Messages.tsx @@ -113,7 +113,7 @@ const RuleHandlerMessages = () => { {event && isRuleHandlerProgressedEvent(event) && (
    {
    {event.totalCollections > 1 && (
    )}
    { - if (typeof window === 'undefined') { - return undefined +import { ReactNode, useEffect, useMemo, useRef, useState } from 'react' +import { Link } from 'react-router-dom' +import ReconnectingEventSource from 'reconnecting-eventsource' +import GetApiHandler, { + API_BASE_PATH, + DeleteApiHandler, + PostApiHandler, +} from '../../utils/ApiHandler' +import LoadingSpinner from '../Common/LoadingSpinner' +import Modal from '../Common/Modal' +import type { ICollectionMedia } from '../Collection' + +interface AppStats { + rules?: number + storage?: AppStorageStats + choppingBlock?: AppChoppingBlockStats + libraries?: AppLibraryStats[] + recentlyAdded?: MediaItem[] + collections?: AppCollectionPreview[] + leavingSoon?: AppLeavingSoonItem[] + tasks?: AppTaskStats[] + configuredServices?: AppConfiguredService[] + recentActivity?: AppRecentActivityItem[] +} + +type CalendarCollectionMedia = { + id: number + mediaServerId: string + addDate: Date | string +} + +type CalendarCollection = { + id: number + title: string + type: MediaItemType + arrAction: number + deleteAfterDays: number + radarrSettingsId?: number + sonarrSettingsId?: number + media: CalendarCollectionMedia[] +} + +type WeekDaySummary = { + date: Date + items: CalendarItem[] +} + +type CalendarItem = { + id: string + title: string + count: number + references: CalendarReference[] +} + +type CalendarReference = { + collectionId: number + mediaId: number + mediaServerId: string + addDate: Date | string +} + +type CalendarModalItem = { + mediaTitle: string + addedAt: string + collectionId: number + collectionTitle: string + mediaType: MediaItemType +} + +type SelectedCalendarEntry = { + item: CalendarItem + date: Date +} + +interface AppStorageStats { + totalSpace: number + usedSpace: number + freeSpace: number + sourceCount: number +} + +interface AppLibraryStats { + id: string + title: string + type: 'movie' | 'show' + itemCount: number + seasonCount?: number + episodeCount?: number +} + +interface AppChoppingBlockStats { + totalSizeBytes: number + collections: AppChoppingBlockCollectionStats[] +} + +interface AppChoppingBlockCollectionStats { + id: number + title: string + totalSizeBytes: number + mediaCount: number +} + +interface AppCollectionPreview { + id: number + title: string + description?: string + type: 'movie' | 'show' | 'season' | 'episode' + libraryId: string + mediaCount: number + totalSizeBytes?: number | null + deleteAfterDays?: number | null + isActive: boolean + media: { image_path?: string }[] +} + +interface AppLeavingSoonItem { + media: MediaItem | MediaItemWithParent + collectionId: number + collectionTitle: string + deleteDate: string + daysLeft: number +} + +interface AppTaskStats { + name: string + nextRun?: string + lastRun?: string +} + +interface AppConfiguredService { + name: string + status: 'Connected' | 'Disconnected' +} + +interface AppRecentActivityItem { + id: number + collectionId: number + collectionTitle: string + posterTmdbId?: string + posterType?: 'movie' | 'show' + timestamp: string + message: string + type: number +} + +const formatBytes = (value?: number): string => { + if (value == null || !Number.isFinite(value) || value <= 0) { + return '--' } - return window.localStorage.getItem(overviewLibraryStorageKey) ?? undefined + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] + let size = value + let unitIndex = 0 + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024 + unitIndex += 1 + } + + const decimals = size >= 10 || unitIndex === 0 ? 0 : 1 + return `${size.toFixed(decimals)} ${units[unitIndex]}` } -const Overview = () => { - // const [isLoading, setIsLoading] = useState(false) - const loadingRef = useRef(false) +const formatNumber = (value?: number): string => + value == null ? '--' : value.toLocaleString() - const [loadingExtra, setLoadingExtra] = useState(false) +const formatRelativeTime = ( + value?: string, + fallback = 'Not scheduled', +): string => { + if (!value) { + return fallback + } - const [data, setData] = useState([]) - const dataRef = useRef([]) + const diffMs = new Date(value).getTime() - Date.now() + const absMs = Math.abs(diffMs) + const units: [Intl.RelativeTimeFormatUnit, number][] = [ + ['day', 86400000], + ['hour', 3600000], + ['minute', 60000], + ] + const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - const [totalSize, setTotalSize] = useState(999) - const totalSizeRef = useRef(999) + for (const [unit, size] of units) { + if (absMs >= size || unit === 'minute') { + return formatter.format(Math.round(diffMs / size), unit) + } + } - const [selectedLibrary, setSelectedLibrary] = useState( - getStoredOverviewLibraryId, - ) - const selectedLibraryRef = useRef(undefined) - const [searchUsed, setSearchUsed] = useState(false) - const [filterValue, setFilterValue] = useState(defaultFilterValue) - - const pageData = useRef(0) - const SearchCtx = useContext(SearchContext) - - const { data: libraries } = useMediaServerLibraries() - const currentLibraryType = libraries?.find( - (library: MediaLibrary) => library.id === selectedLibrary, - )?.type - const sortConfig = useMemo( - () => getMediaLibrarySortConfig(currentLibraryType), - [currentLibraryType], - ) - const { sortValue, sortParams, onSortChange } = - useMediaLibrarySort(sortConfig) - const hasCustomSortSelected = sortValue !== sortConfig.defaultValue - const hasCustomFilterSelected = filterValue !== defaultFilterValue - const hasResolvedTotalSize = totalSize !== 999 || data.length > 0 - const libraryCountLabel = hasResolvedTotalSize - ? `${totalSize.toLocaleString()} items` - : 'Loading count' - - const fetchAmount = 30 - - const setIsLoading = (val: boolean) => { - loadingRef.current = val + return formatter.format(0, 'minute') +} + +const formatLocalTime = (value?: string): string => { + if (!value) { + return '' } - useEffect(() => { - if (!libraries || libraries.length === 0) { + return new Date(value).toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + }) +} + +const isFutureDate = (value?: string): boolean => + value ? new Date(value).getTime() > Date.now() : false + +const getPercent = (part?: number, total?: number): number => { + if (!part || !total) { + return 0 + } + + return Math.min(Math.max((part / total) * 100, 0), 100) +} + +const getActivityColor = (item: AppRecentActivityItem) => { + const message = item.message.toLowerCase() + + if (message.includes('successfully handled')) { + return 'border-l-red-500' + } + + if (message.includes('added')) { + return 'border-l-green-500' + } + + if (message.includes('removed')) { + return 'border-l-maintainerr' + } + + return 'border-l-zinc-500' +} + +const getActivityDetail = (message: string) => + message + .replace(/^Successfully handled\s+/i, '') + .replace(/^Added a specific exclusion for\s+/i, 'Specific exclusion: ') + .replace(/^Removed specific exclusion of\s+/i, 'Specific exclusion: ') + .replace(/^Added\s+/i, '') + .replace(/^Removed\s+/i, '') + .replace( + /S(?:eason)?\s*(\d+)\s*E(?:pisode)?\s*(\d+)/gi, + (_match, season, episode) => + `S${String(season).padStart(2, '0')}E${String(episode).padStart( + 2, + '0', + )}`, + ) + .replace( + /Season\s+(\d+)\s*[-: ]+\s*Episode\s+(\d+)/gi, + (_match, season, episode) => + `S${String(season).padStart(2, '0')}E${String(episode).padStart( + 2, + '0', + )}`, + ) + .replace( + /Season\s+(\d+)/gi, + (_match, season) => `S${String(season).padStart(2, '0')}`, + ) + +const startOfDay = (date: Date) => + new Date(date.getFullYear(), date.getMonth(), date.getDate()) + +const startOfWeekSunday = (date: Date) => { + const start = startOfDay(date) + start.setDate(start.getDate() - start.getDay()) + return start +} + +const addDays = (date: Date, days: number) => { + const next = new Date(date) + next.setDate(next.getDate() + days) + return next +} + +const getDayKey = (date: Date) => + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String( + date.getDate(), + ).padStart(2, '0')}` + +enum CalendarServarrAction { + DELETE = 0, + UNMONITOR_DELETE_ALL = 1, + UNMONITOR_DELETE_EXISTING = 2, + UNMONITOR = 3, + DO_NOTHING = 4, + DELETE_SHOW_IF_EMPTY = 5, + UNMONITOR_SHOW_IF_EMPTY = 6, + CHANGE_QUALITY_PROFILE = 7, +} + +const DEFAULT_ACTION_LABEL = 'Scheduled Action' + +const MOVIE_ACTION_LABELS: Partial> = { + [CalendarServarrAction.DELETE]: 'Delete', + [CalendarServarrAction.UNMONITOR_DELETE_ALL]: 'Unmonitor/Delete', + [CalendarServarrAction.UNMONITOR]: 'Unmonitor/Keep', + [CalendarServarrAction.CHANGE_QUALITY_PROFILE]: 'Change Quality', + [CalendarServarrAction.DO_NOTHING]: 'Do nothing', +} + +const SHOW_ACTION_LABELS: Partial> = { + [CalendarServarrAction.DELETE]: 'Delete', + [CalendarServarrAction.UNMONITOR_DELETE_ALL]: 'Unmonitor/Delete', + [CalendarServarrAction.UNMONITOR_DELETE_EXISTING]: + 'Unmonitor/Delete Existing', + [CalendarServarrAction.UNMONITOR]: 'Unmonitor/Keep', + [CalendarServarrAction.CHANGE_QUALITY_PROFILE]: 'Change Quality', + [CalendarServarrAction.DO_NOTHING]: 'Do nothing', +} + +const SEASON_ACTION_LABELS: Partial> = { + [CalendarServarrAction.DELETE]: 'Unmonitor/Delete', + [CalendarServarrAction.DELETE_SHOW_IF_EMPTY]: + 'Unmonitor/Delete + Delete Empty Show', + [CalendarServarrAction.UNMONITOR_DELETE_EXISTING]: + 'Unmonitor/Delete Existing', + [CalendarServarrAction.UNMONITOR]: 'Unmonitor/Keep', + [CalendarServarrAction.UNMONITOR_SHOW_IF_EMPTY]: + 'Unmonitor/Keep + Unmonitor Empty Show', + [CalendarServarrAction.DO_NOTHING]: 'Do nothing', +} + +const EPISODE_ACTION_LABELS: Partial> = { + [CalendarServarrAction.DELETE]: 'Unmonitor/Delete', + [CalendarServarrAction.UNMONITOR]: 'Unmonitor/Keep', + [CalendarServarrAction.DO_NOTHING]: 'Do nothing', +} + +const GENERIC_ACTION_LABELS: Partial> = { + [CalendarServarrAction.DELETE]: 'Delete', + [CalendarServarrAction.UNMONITOR_DELETE_ALL]: 'Unmonitor/Delete', + [CalendarServarrAction.UNMONITOR_DELETE_EXISTING]: + 'Unmonitor/Delete Existing', + [CalendarServarrAction.UNMONITOR]: 'Unmonitor/Keep', + [CalendarServarrAction.DELETE_SHOW_IF_EMPTY]: 'Delete Empty Show', + [CalendarServarrAction.UNMONITOR_SHOW_IF_EMPTY]: 'Unmonitor Empty Show', + [CalendarServarrAction.CHANGE_QUALITY_PROFILE]: 'Change Quality', +} + +const formatCalendarItemTitle = (actionLabel: string, count: number) => + `${actionLabel}: ${count} items` + +const getActionLabelFromMap = ( + action: CalendarServarrAction, + labels: Partial>, +) => labels[action] ?? DEFAULT_ACTION_LABEL + +const getActionLabel = (collection: CalendarCollection) => { + const action = collection.arrAction as CalendarServarrAction + const hasRadarr = collection.radarrSettingsId != null + + if (hasRadarr || collection.type === 'movie') { + return getActionLabelFromMap(action, MOVIE_ACTION_LABELS) + } + + if (collection.type === 'show') { + return getActionLabelFromMap(action, SHOW_ACTION_LABELS) + } + + if (collection.type === 'season') { + return getActionLabelFromMap(action, SEASON_ACTION_LABELS) + } + + if (collection.type === 'episode') { + return getActionLabelFromMap(action, EPISODE_ACTION_LABELS) + } + + return getActionLabelFromMap(action, GENERIC_ACTION_LABELS) +} + +const buildCalendarItemsByDayKey = ( + collections: CalendarCollection[] | undefined, +) => { + const itemsByKey = new Map() + + collections?.forEach((collection) => { + if ( + collection.arrAction === CalendarServarrAction.DO_NOTHING || + collection.deleteAfterDays == null + ) { return } - setTimeout(() => { - if ( - loadingRef.current && - data.length === 0 && - SearchCtx.search.text === '' - ) { - switchLib(selectedLibrary ? selectedLibrary : libraries[0].id) + collection.media.forEach((media) => { + if (!media.addDate) { + return } - }, 300) - // Cleanup on unmount - return () => { - setData([]) - dataRef.current = [] - totalSizeRef.current = 999 - pageData.current = 0 + const deleteDate = startOfDay(new Date(media.addDate)) + deleteDate.setDate(deleteDate.getDate() + collection.deleteAfterDays) + + const key = getDayKey(deleteDate) + const actionLabel = getActionLabel(collection) + const items = itemsByKey.get(key) ?? [] + const existingItem = items.find((item) => item.id === actionLabel) + const reference = { + collectionId: collection.id, + mediaId: media.id, + mediaServerId: media.mediaServerId, + addDate: media.addDate, + } + + if (existingItem) { + existingItem.count += 1 + existingItem.title = formatCalendarItemTitle( + actionLabel, + existingItem.count, + ) + existingItem.references.push(reference) + } else { + items.push({ + id: actionLabel, + title: formatCalendarItemTitle(actionLabel, 1), + count: 1, + references: [reference], + }) + } + + itemsByKey.set(key, items) + }) + }) + + return itemsByKey +} + +const getWeekDays = ( + itemsByKey: Map, + weekStart: Date, +): WeekDaySummary[] => + Array.from({ length: 7 }, (_, index) => { + const date = addDays(weekStart, index) + return { + date, + items: itemsByKey.get(getDayKey(date)) ?? [], } - }, [libraries]) + }) - useEffect(() => { - if (!libraries || libraries.length === 0) return - - if (SearchCtx.search.text !== '') { - GetApiHandler(`/media-server/search/${SearchCtx.search.text}`).then( - (resp: MediaItem[]) => { - setSearchUsed(true) - setTotalSize(resp.length) - pageData.current = resp.length * 50 - setData(resp ? sortMediaItems(resp, sortParams) : []) - setIsLoading(false) - }, +const getCalendarMediaTitle = (media: ICollectionMedia) => { + const mediaData = media.mediaData + + if (!mediaData) { + return media.mediaServerId + } + + if (mediaData.type === 'episode') { + const showTitle = mediaData.grandparentTitle || mediaData.parentTitle || '' + const seasonEpisode = + mediaData.parentIndex != null && mediaData.index != null + ? `S${String(mediaData.parentIndex).padStart(2, '0')}E${String( + mediaData.index, + ).padStart(2, '0')}` + : mediaData.index != null + ? `E${String(mediaData.index).padStart(2, '0')}` + : '' + + return [showTitle, seasonEpisode].filter(Boolean).join(' - ') + } + + return ( + mediaData.grandparentTitle || + mediaData.parentTitle || + mediaData.title || + media.mediaServerId + ) +} + +const formatAddedAt = (value: Date | string) => { + const date = new Date(value) + + if (Number.isNaN(date.getTime())) { + return 'Unknown' + } + + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} + +const formatScheduledDate = (value: Date) => + value.toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + +const getMediaTypeLabel = (mediaType: MediaItemType) => { + switch (mediaType) { + case 'movie': + return 'Movie' + case 'show': + return 'Show' + case 'season': + return 'Season' + case 'episode': + return 'Episode' + default: + return 'Media' + } +} + +const getReferencesByCollection = (references: CalendarReference[]) => + references.reduce((map, reference) => { + const refs = map.get(reference.collectionId) ?? [] + refs.push(reference) + map.set(reference.collectionId, refs) + return map + }, new Map()) + +const getReferenceDateLookups = (references: CalendarReference[]) => ({ + addDateByMediaId: new Map( + references.map((ref) => [ref.mediaId, ref.addDate]), + ), + addDateByMediaServerId: new Map( + references.map((ref) => [ref.mediaServerId, ref.addDate]), + ), +}) + +const fetchCalendarModalItems = async ( + selectedEntry: SelectedCalendarEntry, + collectionsById: Map, +) => { + const referencesByCollection = getReferencesByCollection( + selectedEntry.item.references, + ) + + const collectionResults = await Promise.all( + [...referencesByCollection.entries()].map(async ([collectionId, refs]) => { + const collection = collectionsById.get(collectionId) + const mediaResponse = await GetApiHandler<{ + totalSize: number + items: ICollectionMedia[] + }>( + `/collections/media/${collectionId}/content/1?size=${collection?.media.length ?? 25}`, ) - } else { - setSearchUsed(false) - setData([]) - setTotalSize(999) - pageData.current = 0 - setIsLoading(true) - fetchData() + + const mediaIds = new Set(refs.map((ref) => ref.mediaId)) + const mediaServerIds = new Set(refs.map((ref) => ref.mediaServerId)) + const { addDateByMediaId, addDateByMediaServerId } = + getReferenceDateLookups(refs) + + return mediaResponse.items + .filter( + (media) => + mediaIds.has(media.id) || mediaServerIds.has(media.mediaServerId), + ) + .map((media) => ({ + mediaTitle: getCalendarMediaTitle(media), + addedAt: formatAddedAt( + addDateByMediaId.get(media.id) ?? + addDateByMediaServerId.get(media.mediaServerId) ?? + media.addDate, + ), + collectionId, + collectionTitle: + collection?.title ?? + media.collection?.title ?? + `Collection ${collectionId}`, + mediaType: media.mediaData?.type ?? collection?.type ?? 'movie', + })) + }), + ) + + return collectionResults + .flat() + .sort((left, right) => left.mediaTitle.localeCompare(right.mediaTitle)) +} + +const getMediaTitle = (item: MediaItem): string => + item.grandparentTitle || item.parentTitle || item.title + +const getMediaYear = (item: MediaItem): string | undefined => { + const parentItem = (item as MediaItemWithParent).parentItem + return (parentItem?.year ?? item.year)?.toString() +} + +const getMediaContext = (item: MediaItem): string | undefined => { + if (item.type === 'episode') { + const seasonNumber = + item.parentIndex != null ? String(item.parentIndex).padStart(2, '0') : '' + const episodeNumber = + item.index != null ? String(item.index).padStart(2, '0') : '' + + if (seasonNumber && episodeNumber) { + return `S${seasonNumber}E${episodeNumber}` } - }, [SearchCtx.search.text]) - useEffect(() => { - selectedLibraryRef.current = selectedLibrary - fetchData() - }, [selectedLibrary]) + if (episodeNumber) { + return `Episode ${item.index}` + } + } + + if (item.type === 'season' && item.index != null) { + return `Season ${item.index}` + } + + return getMediaYear(item) +} + +const getTmdbId = (item: MediaItem): string | undefined => { + const parentItem = (item as MediaItemWithParent).parentItem + + if ( + (item.type === 'season' || item.type === 'episode') && + parentItem?.providerIds?.tmdb?.[0] + ) { + return parentItem.providerIds.tmdb[0] + } + + return item.providerIds?.tmdb?.[0] ?? parentItem?.providerIds?.tmdb?.[0] +} + +const getPosterType = (item: MediaItem): 'movie' | 'show' => + item.type === 'movie' ? 'movie' : 'show' + +const Overview = () => { + const [stats, setStats] = useState() + const [calendarCollections, setCalendarCollections] = + useState() + const [weekStart, setWeekStart] = useState(() => + startOfWeekSunday(new Date()), + ) + const [loading, setLoading] = useState(true) + const [selectedLeavingSoon, setSelectedLeavingSoon] = + useState() + const [selectedCalendarEntry, setSelectedCalendarEntry] = + useState() + const [calendarModalItems, setCalendarModalItems] = + useState() + const [calendarModalLoading, setCalendarModalLoading] = useState(false) + const [excluding, setExcluding] = useState(false) useEffect(() => { - dataRef.current = data - }, [data]) + let active = true + + GetApiHandler('/stats') + .then((response) => { + if (active) { + setStats(response) + } + }) + .finally(() => { + if (active) { + setLoading(false) + } + }) + + return () => { + active = false + } + }, []) useEffect(() => { - totalSizeRef.current = totalSize - }, [totalSize]) - - const switchLib = (libraryId: string) => { - setIsLoading(true) - pageData.current = 0 - setTotalSize(999) - setData([]) - dataRef.current = [] - setSearchUsed(false) - setSelectedLibrary(libraryId) - window.localStorage.setItem(overviewLibraryStorageKey, libraryId) - } + let active = true - const fetchData = async ( - requestedSortParams: MediaLibrarySortParams = sortParams, - ) => { - if ( - selectedLibraryRef.current && - SearchCtx.search.text === '' && - totalSizeRef.current >= pageData.current * fetchAmount - ) { - const askedLib = clone(selectedLibraryRef.current) - - const resp: { totalSize: number; items: MediaItem[] } = - await GetApiHandler( - `/media-server/library/${selectedLibraryRef.current}/content?page=${ - pageData.current + 1 - }&limit=${fetchAmount}&${new URLSearchParams({ - sort: requestedSortParams.sort, - sortOrder: requestedSortParams.sortOrder, - }).toString()}`, - ) + GetApiHandler('/collections/calendar-data').then( + (response) => { + if (active) { + setCalendarCollections(response) + } + }, + ) - if (askedLib === selectedLibraryRef.current) { - setTotalSize(resp.totalSize) - pageData.current = pageData.current + 1 - setData([...dataRef.current, ...(resp && resp.items ? resp.items : [])]) - setIsLoading(false) - } - setLoadingExtra(false) - setIsLoading(false) + return () => { + active = false } - } + }, []) - const handleSortChange = (nextSortValue: string) => { - const nextSortState = onSortChange(nextSortValue) - if (!nextSortState) { + const storageUsedPercent = getPercent( + stats?.storage?.usedSpace, + stats?.storage?.totalSpace, + ) + const choppingBlockPercent = getPercent( + stats?.choppingBlock?.totalSizeBytes, + stats?.storage?.totalSpace, + ) + const calendarItemsByKey = useMemo( + () => buildCalendarItemsByDayKey(calendarCollections), + [calendarCollections], + ) + const calendarCollectionsById = useMemo( + () => + new Map( + (calendarCollections ?? []).map((collection) => [ + collection.id, + collection, + ]), + ), + [calendarCollections], + ) + const weekDays = useMemo( + () => getWeekDays(calendarItemsByKey, weekStart), + [calendarItemsByKey, weekStart], + ) + + useEffect(() => { + if (!selectedCalendarEntry) { + setCalendarModalItems(undefined) return } - if (SearchCtx.search.text !== '') { - setData((currentData) => - sortMediaItems(currentData, nextSortState.sortParams), - ) + let active = true + setCalendarModalLoading(true) + + fetchCalendarModalItems(selectedCalendarEntry, calendarCollectionsById) + .then((items) => { + if (active) { + setCalendarModalItems(items) + } + }) + .finally(() => { + if (active) { + setCalendarModalLoading(false) + } + }) + + return () => { + active = false + } + }, [calendarCollectionsById, selectedCalendarEntry]) + const excludeSelectedLeavingSoon = async () => { + if (!selectedLeavingSoon) { return } - pageData.current = 0 - setTotalSize(999) - setData([]) - dataRef.current = [] - setIsLoading(true) - fetchData(nextSortState.sortParams) + setExcluding(true) + + try { + await DeleteApiHandler( + `/collections/media?mediaId=${selectedLeavingSoon.media.id}&collectionId=${selectedLeavingSoon.collectionId}`, + ) + await PostApiHandler('/rules/exclusion', { + collectionId: selectedLeavingSoon.collectionId, + mediaId: selectedLeavingSoon.media.id, + action: 0, + }) + setStats((current) => + current + ? { + ...current, + leavingSoon: current.leavingSoon?.filter( + (item) => + !( + item.collectionId === selectedLeavingSoon.collectionId && + item.media.id === selectedLeavingSoon.media.id + ), + ), + } + : current, + ) + setSelectedLeavingSoon(undefined) + } finally { + setExcluding(false) + } + } + + if (loading) { + return ( + <> + Overview - Maintainerr + + + ) } return ( <> Overview - Maintainerr -
    - {!searchUsed ? ( -
    -
    -
    - -
    -
    - {libraryCountLabel} -
    +
    +
    + + +
    + +
    + } + label="Chopping Block" + value={formatBytes(stats?.choppingBlock?.totalSizeBytes)} + detail={`${choppingBlockPercent.toFixed(2)}% of total storage`} + details={stats?.choppingBlock?.collections.map((item) => ({ + href: `/collections/${item.id}`, + label: item.title, + size: formatBytes(item.totalSizeBytes), + count: formatNumber(item.mediaCount), + }))} + /> + + + +
    + + + {(stats?.leavingSoon ?? []).map((item) => ( + setSelectedLeavingSoon(item)} + /> + ))} + + + + + setWeekStart((current) => addDays(current, -7))} + onCurrent={() => setWeekStart(startOfWeekSunday(new Date()))} + onNext={() => setWeekStart((current) => addDays(current, 7))} + onOpenEntry={(item, date) => setSelectedCalendarEntry({ item, date })} + /> + + + + + {(stats?.recentlyAdded ?? []).map((item) => ( + + ))} + +
    + {selectedLeavingSoon ? ( + setSelectedLeavingSoon(undefined)} + onExclude={excludeSelectedLeavingSoon} + /> + ) : undefined} + {selectedCalendarEntry ? ( + setSelectedCalendarEntry(undefined)} + /> + ) : undefined} + + ) +} + +const MetricCard = ({ + icon, + label, + value, + detail, + details, +}: { + icon: ReactNode + label: string + value: string + detail: string + details?: { href?: string; label: string; size: string; count: string }[] +}) => ( +
    +
    + + {icon} + + + {label} + +
    +

    {value}

    +

    {detail}

    + {details?.length ? ( +
    + {details.map((item) => { + const content = ( + <> +

    + {item.label} +

    +

    + {item.size} +

    +

    {item.count} items

    + + ) + + return item.href ? ( + + {content} + + ) : ( +
    + {content}
    + ) + })} +
    + ) : undefined} +
    +) + +const MediaMetricCard = ({ libraries }: { libraries: AppLibraryStats[] }) => { + const seasonTotal = libraries.reduce( + (count, library) => count + (library.seasonCount ?? 0), + 0, + ) + const episodeTotal = libraries.reduce( + (count, library) => count + (library.episodeCount ?? 0), + 0, + ) + + return ( +
    +
    + + + + + Media + +
    +
    + {libraries.map((library) => ( +
    +

    + {library.title} +

    +

    + {formatNumber(library.itemCount)} +

    +
    + ))} +
    +

    + Seasons +

    +

    + {formatNumber(seasonTotal)} +

    +
    +
    +

    + Episodes +

    +

    + {formatNumber(episodeTotal)} +

    +
    +
    +
    + ) +} + +const NextRunCard = ({ tasks }: { tasks: AppTaskStats[] }) => ( +
    +
    + + + + + Next Run + +
    +
    + {tasks.map((task) => ( +
    +
    +

    + {task.name} +

    +
    +

    + {isFutureDate(task.nextRun) + ? formatRelativeTime(task.nextRun) + : 'Not scheduled'} +

    + {isFutureDate(task.nextRun) ? ( +

    + {formatLocalTime(task.nextRun)} +

    + ) : undefined} +
    +
    +
    + ))} +
    +
    +) + +const LogsCard = () => { + const [logLines, setLogLines] = useState([]) + + useEffect(() => { + const es = new ReconnectingEventSource(`${API_BASE_PATH}/api/logs/stream`) + + const handleLog = (event: MessageEvent) => { + const message: LogEvent = JSON.parse(event.data) + setLogLines((current) => [...current, message].slice(-10)) + } + + es.addEventListener('log', handleLog) -
    -
    - {hasCustomSortSelected ? ( - - ) : null} - + return () => { + es.removeEventListener('log', handleLog) + es.close() + } + }, []) + + return ( +
    +
    +

    Logs

    + + + +
    +
    + {logLines.length > 0 ? ( + logLines.map((row, index) => { + const levelColor = + row.level === 'ERROR' + ? 'text-red-400' + : row.level === 'WARN' + ? 'text-yellow-400' + : row.level === 'INFO' + ? 'text-green-400' + : 'text-indigo-400' + + return ( +
    + + {new Date(row.date).toLocaleTimeString()} + + + {row.level} + +

    {row.message}

    -
    - {hasCustomFilterSelected ? ( - - ) : null} - + {item.title} + + )) + ) : ( +

    No scheduled actions

    + )} + {day.items.length > 2 ? ( +

    + +{day.items.length - 2} more +

    + ) : undefined} +
    +
    + ))} +
    + +) + +const RecentActivityRow = ({ + activity, +}: { + activity: AppRecentActivityItem[] +}) => ( +
    +
    +

    Recent Activity

    +
    + + + Added + + + + Removed + + + + Handled + +
    +
    +
    + {activity.length > 0 ? ( +
    + {activity.map((item) => ( + +
    +

    + {item.collectionTitle} +

    + + {formatRelativeTime(item.timestamp)} + +

    + {getActivityDetail(item.message)} +

    +
    + + + ))} +
    + ) : ( + + )} +
    +
    +) + +const RecentActivityThumbnail = ({ item }: { item: AppRecentActivityItem }) => { + const [posterPath, setPosterPath] = useState() + + useEffect(() => { + if (!item.posterTmdbId || !item.posterType) { + setPosterPath(undefined) + return + } + + let active = true + GetApiHandler( + `/moviedb/image/${item.posterType}/${item.posterTmdbId}`, + ).then((path) => { + if (active) { + setPosterPath(path) + } + }) + + return () => { + active = false + } + }, [item.posterTmdbId, item.posterType]) + + return posterPath ? ( + + ) : ( +
    + ) +} + +const useHorizontalWheelScroll = () => { + const scrollRef = useRef(null) + + useEffect(() => { + const scroller = scrollRef.current + + if (!scroller) { + return + } + + const handleWheel = (event: WheelEvent) => { + if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) { + return + } + + const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth + const nextScrollLeft = scroller.scrollLeft + event.deltaY + const canScrollLeft = event.deltaY < 0 && scroller.scrollLeft > 0 + const canScrollRight = + event.deltaY > 0 && scroller.scrollLeft < maxScrollLeft + + if (!canScrollLeft && !canScrollRight) { + return + } + + event.preventDefault() + scroller.scrollLeft = Math.min(Math.max(nextScrollLeft, 0), maxScrollLeft) + } + + scroller.addEventListener('wheel', handleWheel, { passive: false }) + + return () => { + scroller.removeEventListener('wheel', handleWheel) + } + }, []) + + return scrollRef +} + +const PosterRow = ({ + title, + emptyText, + children, +}: { + title: string + emptyText: string + children: ReactNode +}) => { + const items = Array.isArray(children) ? children.filter(Boolean) : children + const scrollRef = useHorizontalWheelScroll() + + return ( +
    +

    {title}

    +
    +
    + {Array.isArray(items) && items.length === 0 ? ( + + ) : ( + items + )} +
    +
    +
    + ) +} + +const DashboardPoster = ({ + title, + subtitle, + mediaType, + posterType, + tmdbId, + tone = 'default', + daysLeft, + onSelect, +}: { + title: string + subtitle?: string + mediaType: string + posterType: 'movie' | 'show' + tmdbId?: string + tone?: 'default' | 'danger' + daysLeft?: number + onSelect?: () => void +}) => { + const [posterPath, setPosterPath] = useState() + + useEffect(() => { + if (!tmdbId) { + return + } + + let active = true + GetApiHandler(`/moviedb/image/${posterType}/${tmdbId}`).then( + (path) => { + if (active && path) { + setPosterPath(path) + } + }, + ) + + return () => { + active = false + } + }, [posterType, tmdbId]) + + const poster = ( +
    + {posterPath ? ( + + ) : ( +
    + {mediaType} +
    + )} + + {mediaType} + + {daysLeft !== undefined ? ( + + {daysLeft} + + ) : undefined} +
    +

    + {title} +

    + {subtitle ? ( +

    + {subtitle} +

    + ) : undefined} +
    +
    + ) + + return ( +
    + {onSelect ? ( + + ) : ( +
    {poster}
    + )} +
    + ) +} + +const LeavingSoonModal = ({ + item, + excluding, + onClose, + onExclude, +}: { + item: AppLeavingSoonItem + excluding: boolean + onClose: () => void + onExclude: () => void +}) => ( + +
    + {getMediaContext(item.media) ? ( + <> +

    + Media +

    +

    + {getMediaContext(item.media)} +

    + + ) : undefined} +

    + Collection +

    + + {item.collectionTitle} + +

    + {item.daysLeft <= 0 + ? 'Scheduled for removal now.' + : `${item.daysLeft} days left before removal.`} +

    +
    +
    +) + +const CalendarItemsModal = ({ + entry, + items, + loading, + onClose, +}: { + entry: SelectedCalendarEntry + items: CalendarModalItem[] + loading: boolean + onClose: () => void +}) => ( + + {loading ? ( +
    + Loading scheduled items... +
    + ) : items.length > 0 ? ( +
    +
    + {formatScheduledDate(entry.date)} +
    +
    + {items.map((item, index) => ( +
    +
    + {item.mediaTitle} +
    +
    +
    +
    Added On
    +
    {item.addedAt}
    +
    +
    +
    Type
    +
    + {getMediaTypeLabel(item.mediaType)} +
    +
    +
    +
    Collection
    + + {item.collectionTitle} + +
    + ))} +
    +
    + + + + + + + + + + + + + + + +
    + Media + + Added On + + Collection + + Type +
    +
    + + + + + + + + + {items.map((item, index) => ( + + + + + + + ))} + +
    +
    {item.mediaTitle}
    +
    + {item.addedAt} + + + {item.collectionTitle} + + + {getMediaTypeLabel(item.mediaType)} +
    - ) : undefined} - {selectedLibrary ? ( - = pageData.current * fetchAmount) - } - fetchData={() => { - setLoadingExtra(true) - fetchData() - }} - loading={loadingRef.current} - extrasLoading={ - loadingExtra && - !loadingRef.current && - totalSizeRef.current >= pageData.current * fetchAmount - } - data={data} - libraryId={selectedLibrary!} - /> - ) : undefined} +
    - + ) : ( +
    + No media items found for this scheduled action. +
    + )} +
    +) + +const CollectionRow = ({ + collections, +}: { + collections: AppCollectionPreview[] +}) => { + const scrollRef = useHorizontalWheelScroll() + + return ( +
    +
    +

    Collections

    + + + +
    +
    + {collections.length > 0 ? ( + collections.map((collection) => ( + + {collection.media.length > 1 ? ( +
    + {collection.media + .slice(0, 2) + .map((media, index) => + media.image_path ? ( + + ) : undefined, + )} +
    +
    + ) : undefined} +
    +
    +

    + {collection.title} +

    +

    + {collection.description} +

    +
    +
    + + + +
    +
    + + )) + ) : ( + + )} +
    +
    ) } + +const CollectionStat = ({ label, value }: { label: string; value: string }) => ( +
    +

    + {label} +

    +

    {value}

    +
    +) + +const EmptyState = ({ text }: { text: string }) => ( +
    + {text} +
    +) + export default Overview diff --git a/apps/ui/src/components/Rules/Rule/RuleCreator/RuleInput/index.tsx b/apps/ui/src/components/Rules/Rule/RuleCreator/RuleInput/index.tsx index ffbae26c..6fc69a7b 100644 --- a/apps/ui/src/components/Rules/Rule/RuleCreator/RuleInput/index.tsx +++ b/apps/ui/src/components/Rules/Rule/RuleCreator/RuleInput/index.tsx @@ -370,7 +370,7 @@ const RuleInput = (props: IRuleInput) => { > {/* Header Section */}
    -

    +

    {props.tagId ? `Rule #${props.tagId}` : props.id @@ -436,9 +436,9 @@ const RuleInput = (props: IRuleInput) => { id="first_val" onChange={updateFirstValue} value={firstval} - className="w-full rounded-lg p-2 text-zinc-100 focus:border-amber-500 focus:ring-amber-500" + className="w-full rounded-lg p-2 text-zinc-100 focus:border-maintainerr focus:ring-maintainerr" > - {constants.applications @@ -486,9 +486,9 @@ const RuleInput = (props: IRuleInput) => { id="action" onChange={updateAction} value={action} - className="w-full rounded-lg p-2 text-zinc-100 focus:border-amber-500 focus:ring-amber-500" + className="w-full rounded-lg p-2 text-zinc-100 focus:border-maintainerr focus:ring-maintainerr" > - {possibilities.map((action) => ( @@ -512,9 +512,9 @@ const RuleInput = (props: IRuleInput) => { id="second_val" onChange={updateSecondValue} value={secondVal} - className="w-full rounded-lg p-2 text-zinc-100 focus:border-amber-500 focus:ring-amber-500" + className="w-full rounded-lg p-2 text-zinc-100 focus:border-maintainerr focus:ring-maintainerr" > - diff --git a/apps/ui/src/components/Rules/Rule/RuleCreator/index.tsx b/apps/ui/src/components/Rules/Rule/RuleCreator/index.tsx index 2d0b3cb2..3a22c123 100644 --- a/apps/ui/src/components/Rules/Rule/RuleCreator/index.tsx +++ b/apps/ui/src/components/Rules/Rule/RuleCreator/index.tsx @@ -271,7 +271,7 @@ const RuleCreator = (props: iRuleCreator) => {

    diff --git a/apps/ui/src/components/Settings/About/index.tsx b/apps/ui/src/components/Settings/About/index.tsx index 8acd8817..a7cbb9ca 100644 --- a/apps/ui/src/components/Settings/About/index.tsx +++ b/apps/ui/src/components/Settings/About/index.tsx @@ -58,7 +58,7 @@ const AboutSettings = () => { <> About - Maintainerr
    -
    +
    @@ -178,9 +178,9 @@ const AboutSettings = () => {
    -
    +
    {
    -
    +
    {
    -
    +
    {
    -
    +
    {
    -
    +
    { className="flex items-center gap-x-2" > {row.name} - + {Math.ceil(row.size / 1024)} KB diff --git a/apps/ui/src/components/Settings/MediaServerSelector/index.tsx b/apps/ui/src/components/Settings/MediaServerSelector/index.tsx index 2564bfd3..ea6bddd0 100644 --- a/apps/ui/src/components/Settings/MediaServerSelector/index.tsx +++ b/apps/ui/src/components/Settings/MediaServerSelector/index.tsx @@ -197,9 +197,9 @@ const MediaServerSelector = ({ type="button" onClick={() => handleServerClick(option.value)} disabled={isPreviewPending || isSwitchPending} - className={`relative flex cursor-pointer rounded-lg border p-4 shadow-sm transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-amber-500 ${ + className={`relative flex cursor-pointer rounded-lg border p-4 shadow-sm transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-maintainerr ${ isSelected - ? 'border-amber-500 bg-amber-500/10' + ? 'border-maintainerr bg-maintainerr/10' : 'border-zinc-700 bg-zinc-800 hover:border-zinc-600' } ${(isPreviewPending || isSwitchPending) && !isPending ? 'opacity-50' : ''}`} > @@ -218,13 +218,13 @@ const MediaServerSelector = ({
    {isSelected && ( -
    +
    )} {isPending && (
    -
    +
    )}
    @@ -403,7 +403,7 @@ const MediaServerSelector = ({ checked={migrateRules} onChange={(e) => setMigrateRules(e.target.checked)} disabled={isSwitchPending || isSwitchComplete} - className="mt-1 h-4 w-4 rounded border-zinc-600 bg-zinc-700 text-amber-500 focus:ring-amber-500" + className="mt-1 h-4 w-4 rounded border-zinc-600 bg-zinc-700 text-maintainerr focus:ring-maintainerr" />
    )} -

    +

    Important:{' '} After migration, you must manually assign a library to each rule diff --git a/apps/ui/src/components/Settings/Notifications/index.tsx b/apps/ui/src/components/Settings/Notifications/index.tsx index 1f4c12fc..18a13aad 100644 --- a/apps/ui/src/components/Settings/Notifications/index.tsx +++ b/apps/ui/src/components/Settings/Notifications/index.tsx @@ -75,7 +75,7 @@ const NotificationSettings = () => { {config.name}

    {!config.enabled && ( -
    +
    Disabled
    )} @@ -108,7 +108,7 @@ const NotificationSettings = () => {