Compare commits

14 Commits
master ... dev

Author SHA1 Message Date
root
2dc143faf7 Fix cache IDs and discover route for music/book types
Some checks failed
Rebuild Issue Index / build-index (push) Has been cancelled
Close Stale Issues and PRs / Close stale issues and PRs (push) Has been cancelled
Trivy Container Vulnerability Scan / Scan latest container image (push) Has been cancelled
Check Docs Links / Verify external links in Markdown and MDX (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
2026-04-04 12:31:10 -05:00
root
206f586a11 Add MUSIC/BOOK cases to request route switch statements 2026-04-04 12:13:45 -05:00
root
e4874f5792 Widen remaining type unions: StatusBadge, IssueModal, PersonDetails, ExternalLink, discover 2026-04-04 11:59:44 -05:00
root
6005422cea Widen all movie|tv type unions to include music|book across frontend and server 2026-04-04 11:50:28 -05:00
root
d8404496b8 Fix remaining mediaType casts in PlexWatchlistSlider and RecentlyAddedSlider 2026-04-04 06:51:28 -05:00
root
41bb2c3a2c Cast all mediaType props flowing to TmdbTitleCard
Some checks failed
Close Stale Issues and PRs / Close stale issues and PRs (push) Has been cancelled
2026-04-03 23:10:22 -05:00
root
53d7b56265 Cast mediaType in ListView to fix type narrowing 2026-04-03 23:01:50 -05:00
root
b012ccb500 Fix WatchlistItem mediaType union 2026-04-03 22:49:26 -05:00
root
8eb5dddd7b Add explicit genre type annotation 2026-04-03 22:43:44 -05:00
root
e87b093ea5 Add null check for tmdbMedia in override block 2026-04-03 22:38:42 -05:00
root
3fb73e4e62 Fix TypeScript errors: skip overrides for music/book, route to requestMusicOrBook 2026-04-03 22:33:31 -05:00
root
246e40a508 Fix QuotaResponse to include music/book quota types 2026-04-03 22:21:08 -05:00
root
2acb4fc4a4 Fix duplicate identifier: rename externalServiceId to foreignId
- Rename to avoid collision with existing externalServiceId column
- Update all references in MediaRequest entity
2026-04-03 22:03:19 -05:00
root
9a63f4a4dd Fix TypeScript type errors for new media types
- Update BlocklistItem interface to accept music/book media types
- Update RequestList MediaType union to include music/book
2026-04-03 21:50:03 -05:00
32 changed files with 72 additions and 42 deletions

View File

@@ -5,7 +5,7 @@ import { getSettings, MetadataProviderType } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
export const getMetadataProvider = async ( export const getMetadataProvider = async (
mediaType: 'movie' | 'tv' | 'anime' mediaType: 'movie' | 'tv' | 'anime' | 'music' | 'book'
): Promise<TvShowProvider> => { ): Promise<TvShowProvider> => {
try { try {
const settings = await getSettings(); const settings = await getSettings();

View File

@@ -100,10 +100,10 @@ class Media {
@Column({ nullable: true }) @Column({ nullable: true })
@Index() @Index()
public externalServiceId?: string; public foreignId?: string;
@Column({ nullable: true }) @Column({ nullable: true })
public externalServiceTitle?: string; public foreignTitle?: string;
@Column({ type: 'int', default: MediaStatus.UNKNOWN }) @Column({ type: 'int', default: MediaStatus.UNKNOWN })
@Index() @Index()

View File

@@ -156,7 +156,7 @@ export class MediaRequest {
let media = await mediaRepository.findOne({ let media = await mediaRepository.findOne({
where: isMusicOrBook where: isMusicOrBook
? { ? {
externalServiceId: requestBody.foreignId, foreignId: requestBody.foreignId,
mediaType: requestBody.mediaType, mediaType: requestBody.mediaType,
} }
: { : {
@@ -170,8 +170,8 @@ export class MediaRequest {
media = new Media({ media = new Media({
tmdbId: isMusicOrBook ? 0 : tmdbMedia.id, tmdbId: isMusicOrBook ? 0 : tmdbMedia.id,
tvdbId: isMusicOrBook ? 0 : (requestBody.tvdbId ?? tmdbMedia.external_ids?.tvdb_id), tvdbId: isMusicOrBook ? 0 : (requestBody.tvdbId ?? tmdbMedia.external_ids?.tvdb_id),
externalServiceId: isMusicOrBook ? requestBody.foreignId : undefined, foreignId: isMusicOrBook ? requestBody.foreignId : undefined,
externalServiceTitle: isMusicOrBook ? requestBody.foreignTitle : undefined, foreignTitle: isMusicOrBook ? requestBody.foreignTitle : undefined,
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
mediaType: requestBody.mediaType, mediaType: requestBody.mediaType,
@@ -206,7 +206,7 @@ export class MediaRequest {
}); });
if (isMusicOrBook) { if (isMusicOrBook) {
existingQuery.andWhere('media.externalServiceId = :foreignId', { existingQuery.andWhere('media.foreignId = :foreignId', {
foreignId: requestBody.foreignId, foreignId: requestBody.foreignId,
}); });
} else { } else {
@@ -250,7 +250,8 @@ export class MediaRequest {
} }
// Apply overrides if the user is not an admin or has the "advanced request" permission // Apply overrides if the user is not an admin or has the "advanced request" permission
const useOverrides = !user.hasPermission([Permission.MANAGE_REQUESTS], { // Override rules only apply to movie/TV (they use TMDB keywords and Radarr/Sonarr)
const useOverrides = !isMusicOrBook && !user.hasPermission([Permission.MANAGE_REQUESTS], {
type: 'or', type: 'or',
}); });
@@ -258,7 +259,7 @@ export class MediaRequest {
let profileId = requestBody.profileId; let profileId = requestBody.profileId;
let tags = requestBody.tags; let tags = requestBody.tags;
if (useOverrides) { if (useOverrides && tmdbMedia) {
const defaultRadarrId = requestBody.is4k const defaultRadarrId = requestBody.is4k
? settings.radarr.findIndex((r) => r.is4k && r.isDefault) ? settings.radarr.findIndex((r) => r.is4k && r.isDefault)
: settings.radarr.findIndex((r) => !r.is4k && r.isDefault); : settings.radarr.findIndex((r) => !r.is4k && r.isDefault);
@@ -307,7 +308,7 @@ export class MediaRequest {
.split(',') .split(',')
.some((genreId) => .some((genreId) =>
tmdbMedia.genres tmdbMedia.genres
.map((genre) => genre.id) .map((genre: { id: number }) => genre.id)
.includes(Number(genreId)) .includes(Number(genreId))
) )
) { ) {
@@ -377,6 +378,11 @@ export class MediaRequest {
} }
} }
// Handle music/book requests via simplified flow
if (isMusicOrBook) {
return MediaRequest.requestMusicOrBook(requestBody, requestUser, options);
}
if (requestBody.mediaType === MediaType.MOVIE) { if (requestBody.mediaType === MediaType.MOVIE) {
await mediaRepository.save(media); await mediaRepository.save(media);
@@ -570,7 +576,7 @@ export class MediaRequest {
let media = await mediaRepository.findOne({ let media = await mediaRepository.findOne({
where: { where: {
externalServiceId: requestBody.foreignId, foreignId: requestBody.foreignId,
mediaType: requestBody.mediaType, mediaType: requestBody.mediaType,
}, },
relations: ['requests'], relations: ['requests'],
@@ -580,8 +586,8 @@ export class MediaRequest {
media = new Media({ media = new Media({
tmdbId: 0, tmdbId: 0,
tvdbId: 0, tvdbId: 0,
externalServiceId: requestBody.foreignId, foreignId: requestBody.foreignId,
externalServiceTitle: requestBody.foreignTitle, foreignTitle: requestBody.foreignTitle,
status: MediaStatus.PENDING, status: MediaStatus.PENDING,
status4k: MediaStatus.UNKNOWN, status4k: MediaStatus.UNKNOWN,
mediaType: requestBody.mediaType, mediaType: requestBody.mediaType,

View File

@@ -3,7 +3,7 @@ import type { PaginatedResponse } from '@server/interfaces/api/common';
export interface BlocklistItem { export interface BlocklistItem {
tmdbId: number; tmdbId: number;
mediaType: 'movie' | 'tv'; mediaType: 'movie' | 'tv' | 'music' | 'book';
title?: string; title?: string;
createdAt?: Date; createdAt?: Date;
user?: User; user?: User;

View File

@@ -8,7 +8,7 @@ export interface WatchlistItem {
id: number; id: number;
ratingKey: string; ratingKey: string;
tmdbId: number; tmdbId: number;
mediaType: 'movie' | 'tv'; mediaType: 'movie' | 'tv' | 'music' | 'book';
title: string; title: string;
} }

View File

@@ -22,6 +22,8 @@ export interface QuotaStatus {
export interface QuotaResponse { export interface QuotaResponse {
movie: QuotaStatus; movie: QuotaStatus;
tv: QuotaStatus; tv: QuotaStatus;
music?: QuotaStatus;
book?: QuotaStatus;
} }
export interface UserWatchDataResponse { export interface UserWatchDataResponse {

View File

@@ -4,6 +4,8 @@ export type AvailableCacheIds =
| 'tmdb' | 'tmdb'
| 'radarr' | 'radarr'
| 'sonarr' | 'sonarr'
| 'lidarr'
| 'readarr'
| 'rt' | 'rt'
| 'imdb' | 'imdb'
| 'github' | 'github'
@@ -50,6 +52,8 @@ class CacheManager {
}), }),
radarr: new Cache('radarr', 'Radarr API'), radarr: new Cache('radarr', 'Radarr API'),
sonarr: new Cache('sonarr', 'Sonarr API'), sonarr: new Cache('sonarr', 'Sonarr API'),
lidarr: new Cache('lidarr', 'Lidarr API'),
readarr: new Cache('readarr', 'Readarr API'),
rt: new Cache('rt', 'Rotten Tomatoes API', { rt: new Cache('rt', 'Rotten Tomatoes API', {
stdTtl: 43200, stdTtl: 43200,
checkPeriod: 60 * 30, checkPeriod: 60 * 30,

View File

@@ -703,7 +703,7 @@ discoverRoutes.get('/trending', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user); const tmdb = createTmdbWithRegionLanguage(req.user);
try { try {
const mediaType = (req.query.mediaType as 'all' | 'movie' | 'tv') ?? 'all'; const mediaType = (req.query.mediaType as 'all' | 'movie' | 'tv' | 'music' | 'book') ?? 'all';
const timeWindow = const timeWindow =
(req.query.timeWindow as 'day' | 'week') === 'week' ? 'week' : 'day'; (req.query.timeWindow as 'day' | 'week') === 'week' ? 'week' : 'day';
const language = (req.query.language as string) ?? req.locale; const language = (req.query.language as string) ?? req.locale;
@@ -737,7 +737,9 @@ discoverRoutes.get('/trending', async (req, res, next) => {
}), }),
} as const; } as const;
const { data, mapper, type } = await trendingFetchers[mediaType](); // Music/book don't have TMDB trending - fall back to 'all'
const fetcherKey = (mediaType === 'music' || mediaType === 'book') ? 'all' : mediaType;
const { data, mapper, type } = await trendingFetchers[fetcherKey]();
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(
req.user, req.user,

View File

@@ -244,6 +244,14 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
?.profiles?.find((profile) => profile.id === r.profileId)?.name, ?.profiles?.find((profile) => profile.id === r.profileId)?.name,
}; };
} }
case MediaType.MUSIC:
case MediaType.BOOK:
default: {
return {
...r,
profileName: undefined,
};
}
} }
}); });
@@ -273,6 +281,14 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
), ),
}; };
} }
case MediaType.MUSIC:
case MediaType.BOOK:
default: {
return {
...r,
canRemove: true,
};
}
} }
}); });
} }

View File

@@ -12,7 +12,7 @@ import { useIntl } from 'react-intl';
interface BlocklistModalProps { interface BlocklistModalProps {
tmdbId: number; tmdbId: number;
type: 'movie' | 'tv' | 'collection'; type: 'movie' | 'tv' | 'music' | 'book' | 'collection';
show: boolean; show: boolean;
onComplete?: () => void; onComplete?: () => void;
onCancel?: () => void; onCancel?: () => void;

View File

@@ -56,7 +56,7 @@ const ListView = ({
<TmdbTitleCard <TmdbTitleCard
id={title.tmdbId} id={title.tmdbId}
tmdbId={title.tmdbId} tmdbId={title.tmdbId}
type={title.mediaType} type={title.mediaType as 'movie' | 'tv'}
isAddedToWatchlist={true} isAddedToWatchlist={true}
canExpand canExpand
mutateParent={mutateParent} mutateParent={mutateParent}

View File

@@ -50,7 +50,7 @@ const messages = defineMessages('components.Discover.FilterSlideover', {
type FilterSlideoverProps = { type FilterSlideoverProps = {
show: boolean; show: boolean;
onClose: () => void; onClose: () => void;
type: 'movie' | 'tv'; type: 'movie' | 'tv' | 'music' | 'book';
currentFilters: FilterOptions; currentFilters: FilterOptions;
}; };

View File

@@ -66,7 +66,7 @@ const PlexWatchlistSlider = () => {
id={item.tmdbId} id={item.tmdbId}
key={`watchlist-slider-item-${item.ratingKey}`} key={`watchlist-slider-item-${item.ratingKey}`}
tmdbId={item.tmdbId} tmdbId={item.tmdbId}
type={item.mediaType} type={item.mediaType as 'movie' | 'tv'}
isAddedToWatchlist={true} isAddedToWatchlist={true}
/> />
))} ))}

View File

@@ -43,7 +43,7 @@ const RecentlyAddedSlider = () => {
id={item.id} id={item.id}
tmdbId={item.tmdbId} tmdbId={item.tmdbId}
tvdbId={item.tvdbId} tvdbId={item.tvdbId}
type={item.mediaType} type={item.mediaType as 'movie' | 'tv'}
/> />
))} ))}
/> />

View File

@@ -20,7 +20,7 @@ const messages = defineMessages('components.Discover', {
timeWindowWeek: 'Weekly', timeWindowWeek: 'Weekly',
}); });
type MediaType = 'all' | 'movie' | 'tv'; type MediaType = 'all' | 'movie' | 'tv' | 'music' | 'book';
type TimeWindow = 'day' | 'week'; type TimeWindow = 'day' | 'week';

View File

@@ -12,7 +12,7 @@ import useSettings from '@app/hooks/useSettings';
import { MediaType } from '@server/constants/media'; import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
type ExternalLinkType = 'movie' | 'tv' | 'person'; type ExternalLinkType = 'movie' | 'tv' | 'person' | 'music' | 'book';
interface ExternalLinkBlockProps { interface ExternalLinkBlockProps {
mediaType: ExternalLinkType; mediaType: ExternalLinkType;

View File

@@ -48,7 +48,7 @@ const classNames = (...classes: string[]) => {
}; };
interface CreateIssueModalProps { interface CreateIssueModalProps {
mediaType: 'movie' | 'tv'; mediaType: 'movie' | 'tv' | 'music' | 'book';
tmdbId?: number; tmdbId?: number;
onCancel?: () => void; onCancel?: () => void;
} }

View File

@@ -12,7 +12,7 @@ const messages = defineMessages('components.IssueModal', {
interface IssueOption { interface IssueOption {
name: MessageDescriptor; name: MessageDescriptor;
issueType: IssueType; issueType: IssueType;
mediaType?: 'movie' | 'tv'; mediaType?: 'movie' | 'tv' | 'music' | 'book';
} }
export const issueOptions: IssueOption[] = [ export const issueOptions: IssueOption[] = [

View File

@@ -4,7 +4,7 @@ import { Transition } from '@headlessui/react';
interface IssueModalProps { interface IssueModalProps {
show?: boolean; show?: boolean;
onCancel: () => void; onCancel: () => void;
mediaType: 'movie' | 'tv'; mediaType: 'movie' | 'tv' | 'music' | 'book';
tmdbId: number; tmdbId: number;
issueId?: never; issueId?: never;
} }

View File

@@ -82,7 +82,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
}; };
interface ManageSlideOverProps { interface ManageSlideOverProps {
// mediaType: 'movie' | 'tv'; // mediaType: 'movie' | 'tv' | 'music' | 'book';
show?: boolean; show?: boolean;
onClose: () => void; onClose: () => void;
revalidate: () => void; revalidate: () => void;

View File

@@ -27,7 +27,7 @@ const messages = defineMessages('components.PersonDetails', {
ascharacter: 'as {character}', ascharacter: 'as {character}',
}); });
type MediaType = 'all' | 'movie' | 'tv'; type MediaType = 'all' | 'movie' | 'tv' | 'music' | 'book';
const PersonDetails = () => { const PersonDetails = () => {
const intl = useIntl(); const intl = useIntl();

View File

@@ -14,7 +14,7 @@ const messages = defineMessages('components.QuotaSelector', {
}); });
interface QuotaSelectorProps { interface QuotaSelectorProps {
mediaType: 'movie' | 'tv'; mediaType: 'movie' | 'tv' | 'music' | 'book';
defaultDays?: number; defaultDays?: number;
defaultLimit?: number; defaultLimit?: number;
dayOverride?: number; dayOverride?: number;

View File

@@ -45,7 +45,7 @@ interface ButtonOption {
} }
interface RequestButtonProps { interface RequestButtonProps {
mediaType: 'movie' | 'tv'; mediaType: 'movie' | 'tv' | 'music' | 'book';
onUpdate: () => void; onUpdate: () => void;
tmdbId: number; tmdbId: number;
media?: Media; media?: Media;

View File

@@ -51,7 +51,7 @@ type Sort = 'added' | 'modified';
type SortDirection = 'asc' | 'desc'; type SortDirection = 'asc' | 'desc';
type MediaType = 'all' | 'movie' | 'tv'; type MediaType = 'all' | 'movie' | 'tv' | 'music' | 'book';
const RequestList = () => { const RequestList = () => {
const router = useRouter(); const router = useRouter();

View File

@@ -50,7 +50,7 @@ export type RequestOverrides = {
}; };
interface AdvancedRequesterProps { interface AdvancedRequesterProps {
type: 'movie' | 'tv'; type: 'movie' | 'tv' | 'music' | 'book';
is4k: boolean; is4k: boolean;
isAnime?: boolean; isAnime?: boolean;
defaultOverrides?: RequestOverrides; defaultOverrides?: RequestOverrides;

View File

@@ -30,7 +30,7 @@ const messages = defineMessages('components.RequestModal.QuotaDisplay', {
interface QuotaDisplayProps { interface QuotaDisplayProps {
quota?: QuotaStatus; quota?: QuotaStatus;
mediaType: 'movie' | 'tv'; mediaType: 'movie' | 'tv' | 'music' | 'book';
userOverride?: number | null; userOverride?: number | null;
remaining?: number; remaining?: number;
overLimit?: number; overLimit?: number;

View File

@@ -8,7 +8,7 @@ import type { NonFunctionProperties } from '@server/interfaces/api/common';
interface RequestModalProps { interface RequestModalProps {
show: boolean; show: boolean;
type: 'movie' | 'tv' | 'collection'; type: 'movie' | 'tv' | 'music' | 'book' | 'collection';
tmdbId: number; tmdbId: number;
is4k?: boolean; is4k?: boolean;
editRequest?: NonFunctionProperties<MediaRequest>; editRequest?: NonFunctionProperties<MediaRequest>;

View File

@@ -144,7 +144,7 @@ export const CompanySelector = ({
}; };
type GenreSelectorProps = (BaseSelectorMultiProps | BaseSelectorSingleProps) & { type GenreSelectorProps = (BaseSelectorMultiProps | BaseSelectorSingleProps) & {
type: 'movie' | 'tv'; type: 'movie' | 'tv' | 'music' | 'book';
}; };
export const GenreSelector = ({ export const GenreSelector = ({
@@ -369,7 +369,7 @@ export const KeywordSelector = ({
}; };
type WatchProviderSelectorProps = { type WatchProviderSelectorProps = {
type: 'movie' | 'tv'; type: 'movie' | 'tv' | 'music' | 'book';
region?: string; region?: string;
activeProviders?: number[]; activeProviders?: number[];
onChange: (region: string, value: number[]) => void; onChange: (region: string, value: number[]) => void;

View File

@@ -29,7 +29,7 @@ interface StatusBadgeProps {
plexUrl?: string; plexUrl?: string;
serviceUrl?: string; serviceUrl?: string;
tmdbId?: number; tmdbId?: number;
mediaType?: 'movie' | 'tv'; mediaType?: 'movie' | 'tv' | 'music' | 'book';
title?: string | string[]; title?: string | string[];
statusLabelOverride?: string; statusLabelOverride?: string;
} }

View File

@@ -10,7 +10,7 @@ interface ErrorCardProps {
id: number; id: number;
tmdbId: number; tmdbId: number;
tvdbId?: number; tvdbId?: number;
type: 'movie' | 'tv'; type: 'movie' | 'tv' | 'music' | 'book';
canExpand?: boolean; canExpand?: boolean;
} }

View File

@@ -9,7 +9,7 @@ export interface TmdbTitleCardProps {
id: number; id: number;
tmdbId: number; tmdbId: number;
tvdbId?: number; tvdbId?: number;
type: 'movie' | 'tv'; type: 'movie' | 'tv' | 'music' | 'book';
canExpand?: boolean; canExpand?: boolean;
isAddedToWatchlist?: boolean; isAddedToWatchlist?: boolean;
mutateParent?: () => void; mutateParent?: () => void;

View File

@@ -369,7 +369,7 @@ const UserProfile = () => {
id={item.tmdbId} id={item.tmdbId}
key={`watchlist-slider-item-${item.ratingKey}`} key={`watchlist-slider-item-${item.ratingKey}`}
tmdbId={item.tmdbId} tmdbId={item.tmdbId}
type={item.mediaType} type={item.mediaType as 'movie' | 'tv'}
/> />
))} ))}
/> />
@@ -395,7 +395,7 @@ const UserProfile = () => {
id={item.id} id={item.id}
tmdbId={item.tmdbId} tmdbId={item.tmdbId}
tvdbId={item.tvdbId} tvdbId={item.tvdbId}
type={item.mediaType} type={item.mediaType as 'movie' | 'tv'}
/> />
))} ))}
/> />