Compare commits

...

10 Commits

Author SHA1 Message Date
root
af6579163b Add frontend pages for music and books
Some checks failed
Rebuild Issue Index / build-index (push) Has been cancelled
- Create MusicDetails component with artist info, albums, stats, request button
- Create BookDetails component with cover, overview, request button
- Create music/[artistId] and book/[bookId] pages
- Update Search component with tabbed interface (Movies & TV, Music, Books)
- Music/Book search results with card grid and cover art
- Link to detail pages from search results
2026-04-03 21:19:40 -05:00
root
466db07e37 Add music/book request flow support
- Add permission checks for music/book in MediaRequest.request()
- Add quota checks for music/book types
- Add externalServiceId and externalServiceTitle columns to Media entity
- Add foreignId/foreignTitle to MediaRequestBody interface
- Add requestMusicOrBook() method for simplified music/book requests
- Make TMDB lookup conditional (skip for music/book)
- Update request route filtering for music/book types
- Handle duplicate detection for foreign ID based media
2026-04-03 21:11:34 -05:00
root
1cf0d541d6 Add Lidarr/Readarr backend support
- Add MUSIC and BOOK to MediaType enum
- Add permission flags for music/book requests
- Create Lidarr API adapter (artist/album search, add, remove)
- Create Readarr API adapter (book/author search, add, remove)
- Add Lidarr/Readarr settings interfaces and routes
- Add music and book API routes for search/detail
- Register all new routes in main router and settings router
2026-04-03 21:05:21 -05:00
John Costa
dc40ca413c docs: add self-signed certificate guide [skip ci] (#2776) 2026-04-04 06:51:35 +08:00
fallenbagel
6f9b743ea9 docs(contributing-guide): fix a typo (#2807) 2026-04-02 14:03:28 +02:00
renovate[bot]
868430b7db build(docker): update node.js to v22.22.1 (#2707) 2026-04-02 11:35:32 +02:00
fallenbagel
58514ec5cf ci(pr-validation): make checklist box detection case-insensitive (#2802) 2026-04-02 10:39:37 +02:00
Defendi
5bbdc52728 docs: move network-related docs to a dedicated tab (#2791) 2026-04-02 15:47:29 +08:00
fallenbagel
986761f61f ci(pr-validation): update pull request permissions to write for validation jobs (#2800) 2026-04-02 14:23:15 +08:00
fallenbagel
67e27d5b79 ci(pr-validation): disable package manager cache in nodejs setup (#2799) 2026-04-02 14:12:04 +08:00
31 changed files with 1811 additions and 74 deletions

View File

@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
pull-requests: write
checks: write
issues: write
steps:
@@ -115,6 +115,7 @@ jobs:
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
@@ -125,6 +126,7 @@ jobs:
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version-file: 'package.json'
package-manager-cache: false
- name: Skip bot PRs
id: bot-check

View File

@@ -263,4 +263,4 @@ DB_TYPE="postgres" DB_USER=postgres DB_PASS=postgres pnpm migration:generate ser
## Attribution
This contribution guide was inspired by the [Next.js](https://github.com/vercel/next.js), [Radarr](https://github.com/Radarr/Radarr), and [Ghostty](https://github.com/ghostty-org/ghostty) contribution guides. In addition, our AI policy was draws from [Jellyfin's LLM policies](https://jellyfin.org/docs/general/contributing/llm-policies/).
This contribution guide was inspired by the [Next.js](https://github.com/vercel/next.js), [Radarr](https://github.com/Radarr/Radarr), and [Ghostty](https://github.com/ghostty-org/ghostty) contribution guides. In addition, our AI policy draws from [Jellyfin's LLM policies](https://jellyfin.org/docs/general/contributing/llm-policies/).

View File

@@ -1,4 +1,4 @@
FROM node:22.22.0-alpine3.22@sha256:7aa86fa052f6e4b101557ccb56717cb4311be1334381f526fe013418fe157384 AS base
FROM node:22.22.1-alpine3.22@sha256:9f96f09f127f06feaff1e7faa4a34a3020cf5c1138c988782e59959641facabe AS base
ARG SOURCE_DATE_EPOCH
ARG TARGETPLATFORM
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
@@ -33,7 +33,7 @@ RUN pnpm build
RUN rm -rf .next/cache
FROM node:22.22.0-alpine3.22@sha256:7aa86fa052f6e4b101557ccb56717cb4311be1334381f526fe013418fe157384
FROM node:22.22.1-alpine3.22@sha256:9f96f09f127f06feaff1e7faa4a34a3020cf5c1138c988782e59959641facabe
ARG SOURCE_DATE_EPOCH
ARG COMMIT_TAG
ENV NODE_ENV=production

View File

@@ -1,4 +1,4 @@
FROM node:22.22.0-alpine3.22@sha256:7aa86fa052f6e4b101557ccb56717cb4311be1334381f526fe013418fe157384
FROM node:22.22.1-alpine3.22@sha256:9f96f09f127f06feaff1e7faa4a34a3020cf5c1138c988782e59959641facabe
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"

View File

@@ -58,7 +58,7 @@ if (!testingContent) {
const checklistMatch = body.match(/## Checklist:\s*\n([\s\S]*?)$/);
const checklistContent = checklistMatch ? checklistMatch[1] : '';
const totalBoxes = (checklistContent.match(/- \[[ x]\]/g) || []).length;
const totalBoxes = (checklistContent.match(/- \[[ x]\]/gi) || []).length;
const checkedBoxes = (checklistContent.match(/- \[x\]/gi) || []).length;
if (totalBoxes === 0) {

View File

@@ -0,0 +1,61 @@
---
id: self-signed-certificates
title: Self-Signed Certificates
sidebar_label: Self-Signed Certificates
description: How to configure Seerr to work with services that use self-signed SSL certificates.
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# Self-Signed Certificates
If your media server or services (Radarr, Sonarr, etc.) use self-signed SSL certificates, Seerr will reject the connection because it does not trust them by default. The fix is to add your CA certificate to Node.js.
## Add Your CA Certificate
The `NODE_EXTRA_CA_CERTS` environment variable tells Node.js to trust additional Certificate Authority (CA) certificates. This approach keeps certificate validation active while trusting your specific certificate.
You will need to mount your certificate file (in PEM format) into the container and set the environment variable to point to it.
:::note
These examples show only the certificate-related configuration. For a complete setup, see the [Getting Started](/getting-started) guide.
:::
<Tabs>
<TabItem value="docker-cli" label="Docker CLI">
```bash
docker run -d \
--name seerr \
-e NODE_EXTRA_CA_CERTS=/certs/my-ca.pem \
-v /path/to/my-ca.pem:/certs/my-ca.pem:ro \
-p 5055:5055 \
ghcr.io/seerr-team/seerr:latest
```
</TabItem>
<TabItem value="docker-compose" label="Docker Compose">
```yaml
services:
seerr:
image: ghcr.io/seerr-team/seerr:latest
environment:
- NODE_EXTRA_CA_CERTS=/certs/my-ca.pem
volumes:
- /path/to/my-ca.pem:/certs/my-ca.pem:ro
ports:
- 5055:5055
```
</TabItem>
</Tabs>
Replace `/path/to/my-ca.pem` with the actual path to your CA certificate on the host. The path after the colon (`/certs/my-ca.pem`) is where it will be available inside the container.
:::tip
The certificate must be in PEM format. Open it in a text editor — if it starts with `-----BEGIN CERTIFICATE-----`, it is PEM. If it contains binary data, convert it with `openssl x509 -inform DER -in cert.cer -out cert.pem`.
:::
For more details, see the [Node.js documentation on adding CA certificates](https://nodejs.org/en/learn/http/enterprise-network-configuration#adding-additional-ca-certificates).

View File

@@ -1,16 +0,0 @@
---
title: DNS Caching
description: Configure DNS caching settings.
sidebar_position: 7
---
# DNS Caching
Seerr uses DNS caching to improve performance and reduce the number of DNS lookups required for external API calls. This can help speed up response times and reduce load on DNS servers, when something like a Pi-hole is used as a DNS resolver.
## Configuration
You can enable the DNS caching settings in the Network tab of the Seerr settings. The default values follow the standard DNS caching behavior.
- **Force Minimum TTL**: Set a minimum time-to-live (TTL) in seconds for DNS cache entries. This ensures that frequently accessed DNS records are cached for a longer period, reducing the need for repeated lookups. Default is 0.
- **Force Maximum TTL**: Set a maximum time-to-live (TTL) in seconds for DNS cache entries. This prevents infrequently accessed DNS records from being cached indefinitely, allowing for more up-to-date information to be retrieved. Default is -1 (unlimited).

View File

@@ -24,28 +24,6 @@ Set this to the externally-accessible URL of your Seerr instance.
You must configure this setting in order to enable password reset and generation emails.
## Enable Proxy Support
If you have Seerr behind a reverse proxy, enable this setting to allow Seerr to correctly register client IP addresses. For details, please see the [Express Documentation](https://expressjs.com/en/guide/behind-proxies.html).
This setting is **disabled** by default.
## Enable CSRF Protection
:::warning
**This is an advanced setting.** Please only enable this setting if you are familiar with CSRF protection and how it works.
:::
CSRF stands for [cross-site request forgery](https://en.wikipedia.org/wiki/Cross-site_request_forgery). When this setting is enabled, all external API access that alters Seerr application data is blocked.
If you do not use Seerr integrations with third-party applications to add/modify/delete requests or users, you can consider enabling this setting to protect against malicious attacks.
One caveat, however, is that HTTPS is required, meaning that once this setting is enabled, you will no longer be able to access your Seerr instance over _HTTP_ (including using an IP address and port number).
If you enable this setting and find yourself unable to access Seerr, you can disable the setting by modifying `settings.json` in `/app/config`.
This setting is **disabled** by default.
## Enable Image Caching
When enabled, Jellseerr will proxy and cache images from pre-configured sources (such as TMDB). This can use a significant amount of disk space.

View File

@@ -84,7 +84,7 @@ This value should be set to the port that your Jellyfin server listens on. The d
#### Use SSL
Enable this setting to connect to Jellyfin via HTTPS rather than HTTP. Note that self-signed certificates are **not** officially supported.
Enable this setting to connect to Jellyfin via HTTPS rather than HTTP. Self-signed certificates are not trusted by default, but you can configure Seerr to accept them. See [Self-Signed Certificates](/using-seerr/advanced/self-signed-certificates) for details.
#### External URL
@@ -178,7 +178,7 @@ This value should be set to the port that your Emby server listens on. The defau
#### Use SSL
Enable this setting to connect to Emby via HTTPS rather than HTTP. Note that self-signed certificates are **not** officially supported.
Enable this setting to connect to Emby via HTTPS rather than HTTP. Self-signed certificates are not trusted by default, but you can configure Seerr to accept them. See [Self-Signed Certificates](/using-seerr/advanced/self-signed-certificates) for details.
#### External URL
@@ -218,7 +218,7 @@ This value should be set to the port that your Plex server listens on. The defau
#### Use SSL
Enable this setting to connect to Plex via HTTPS rather than HTTP. Note that self-signed certificates are _not_ supported.
Enable this setting to connect to Plex via HTTPS rather than HTTP. Self-signed certificates are not trusted by default, but you can configure Seerr to accept them. See [Self-Signed Certificates](/using-seerr/advanced/self-signed-certificates) for details.
#### Web App URL (optional)

View File

@@ -0,0 +1,60 @@
---
title: Network
description: Configure Network settings.
sidebar_position: 7
---
# Network
Network-related settings are available in the **Network** tab under **Settings**. These options control how Seerr communicates with external services
## DNS Caching
Seerr allows you to enable DNS caching if you are experiencing DNS-related issues. When enabled, it improves performance and reduces the number of DNS lookups required for external API calls. This can help speed up response times and reduce the load on DNS servers, especially when a local resolver like Pi-hole is used.
### Configuration
You can enable the DNS caching settings in the Network tab of the Seerr settings. The default values follow the standard DNS caching behavior.
- **Force Minimum TTL**: Set a minimum time-to-live (TTL) in seconds for DNS cache entries. This ensures that frequently accessed DNS records are cached for a longer period, reducing the need for repeated lookups. Default is 0.
- **Force Maximum TTL**: Set a maximum time-to-live (TTL) in seconds for DNS cache entries. This prevents infrequently accessed DNS records from being cached indefinitely, allowing for more up-to-date information to be retrieved. Default is -1 (unlimited).
## Force IPv4 resolution first
Sometimes there are configuration issues with IPv6 that prevent the hostname resolution from working correctly.
You can force resolution to prefer IPv4 by going to `Settings > Network`, enabling `Force IPv4 Resolution First`, and then restarting Seerr.
## HTTP(S) Proxy
If you can't change your DNS servers or force IPV4 resolution, you can use Seerr through a proxy.
In some places (like China), the ISP blocks not only the DNS resolution but also the connection to the TMDB API.
## Enable Proxy Support
If you have Seerr behind a reverse proxy, enable this setting to allow Seerr to correctly register client IP addresses. For details, please see the [Express Documentation](https://expressjs.com/en/guide/behind-proxies.html).
This setting is **disabled** by default.
## Enable CSRF Protection
:::warning
**This is an advanced setting.** Please only enable this setting if you are familiar with CSRF protection and how it works.
:::
CSRF stands for [cross-site request forgery](https://en.wikipedia.org/wiki/Cross-site_request_forgery). When this setting is enabled, all external API access that alters Seerr application data is blocked.
If you do not use Seerr integrations with third-party applications to add/modify/delete requests or users, you can consider enabling this setting to protect against malicious attacks.
One caveat, however, is that HTTPS is required, meaning that once this setting is enabled, you will no longer be able to access your Seerr instance over _HTTP_ (including using an IP address and port number).
If you enable this setting and find yourself unable to access Seerr, you can disable the setting by modifying `settings.json` in `/app/config`.
This setting is **disabled** by default.
## API Request Timeout
The API Request Timeout setting defines the maximum time (in seconds) Seerr will wait for a response from external services, such as Radarr or Sonarr. The default value is 10 seconds, though it can be entirely disabled by setting it to 0. Please note that any changes to this value require restarting Seerr to take effect.
Enforcing a timeout ensures the Seerr interface remains responsive and prevents infinite loading states when a connected service unexpectedly goes offline. Conversely, you may want to increase this value if you frequently experience failed requests due to your external services being slow to respond, which often happens when they are under heavy load or querying network-mounted storage.

View File

@@ -44,7 +44,7 @@ This value should be set to the port that your Radarr/Sonarr server listens on.
#### Use SSL
Enable this setting to connect to Radarr/Sonarr via HTTPS rather than HTTP. Note that self-signed certificates are _not_ supported.
Enable this setting to connect to Radarr/Sonarr via HTTPS rather than HTTP. Self-signed certificates are not trusted by default, but you can configure Seerr to accept them. See [Self-Signed Certificates](/using-seerr/advanced/self-signed-certificates) for details.
#### API Key

View File

@@ -0,0 +1,271 @@
import logger from '@server/logger';
import ServarrBase from './base';
export interface LidarrArtistOptions {
artistName: string;
qualityProfileId: number;
metadataProfileId: number;
tags: number[];
rootFolderPath: string;
foreignArtistId: string; // MusicBrainz ID
monitored?: boolean;
searchNow?: boolean;
}
export interface LidarrAlbumOptions {
foreignAlbumId: string; // MusicBrainz Album ID
monitored?: boolean;
searchNow?: boolean;
}
export interface LidarrArtist {
id: number;
artistName: string;
foreignArtistId: string;
monitored: boolean;
path: string;
qualityProfileId: number;
metadataProfileId: number;
rootFolderPath: string;
tags: number[];
added: string;
status: string;
ended: boolean;
artistType: string;
disambiguation: string;
images: {
coverType: string;
url: string;
remoteUrl: string;
}[];
genres: string[];
statistics?: {
albumCount: number;
trackFileCount: number;
trackCount: number;
totalTrackCount: number;
sizeOnDisk: number;
percentOfTracks: number;
};
}
export interface LidarrAlbum {
id: number;
title: string;
foreignAlbumId: string;
artistId: number;
monitored: boolean;
albumType: string;
duration: number;
releaseDate: string;
genres: string[];
images: {
coverType: string;
url: string;
remoteUrl: string;
}[];
artist: LidarrArtist;
statistics?: {
trackFileCount: number;
trackCount: number;
totalTrackCount: number;
sizeOnDisk: number;
percentOfTracks: number;
};
}
export interface MetadataProfile {
id: number;
name: string;
}
class LidarrAPI extends ServarrBase<{ artistId: number }> {
constructor({ url, apiKey }: { url: string; apiKey: string }) {
super({ url, apiKey, cacheName: 'lidarr', apiName: 'Lidarr' });
}
public getArtists = async (): Promise<LidarrArtist[]> => {
try {
const response = await this.axios.get<LidarrArtist[]>('/artist');
return response.data;
} catch (e) {
throw new Error(`[Lidarr] Failed to retrieve artists: ${e.message}`, {
cause: e,
});
}
};
public getArtist = async ({ id }: { id: number }): Promise<LidarrArtist> => {
try {
const response = await this.axios.get<LidarrArtist>(`/artist/${id}`);
return response.data;
} catch (e) {
throw new Error(`[Lidarr] Failed to retrieve artist: ${e.message}`, {
cause: e,
});
}
};
public async getArtistByMbId(mbId: string): Promise<LidarrArtist | null> {
try {
const artists = await this.getArtists();
return artists.find((a) => a.foreignArtistId === mbId) || null;
} catch (e) {
logger.error('Error retrieving artist by MusicBrainz ID', {
label: 'Lidarr API',
errorMessage: e.message,
mbId,
});
return null;
}
}
public searchArtist = async (term: string): Promise<LidarrArtist[]> => {
try {
const response = await this.axios.get<LidarrArtist[]>(
'/artist/lookup',
{ params: { term } }
);
return response.data;
} catch (e) {
throw new Error(`[Lidarr] Failed to search artists: ${e.message}`, {
cause: e,
});
}
};
public searchAlbum = async (term: string): Promise<LidarrAlbum[]> => {
try {
const response = await this.axios.get<LidarrAlbum[]>('/album/lookup', {
params: { term },
});
return response.data;
} catch (e) {
throw new Error(`[Lidarr] Failed to search albums: ${e.message}`, {
cause: e,
});
}
};
public getAlbums = async (artistId: number): Promise<LidarrAlbum[]> => {
try {
const response = await this.axios.get<LidarrAlbum[]>('/album', {
params: { artistId },
});
return response.data;
} catch (e) {
throw new Error(`[Lidarr] Failed to retrieve albums: ${e.message}`, {
cause: e,
});
}
};
public getAlbum = async ({ id }: { id: number }): Promise<LidarrAlbum> => {
try {
const response = await this.axios.get<LidarrAlbum>(`/album/${id}`);
return response.data;
} catch (e) {
throw new Error(`[Lidarr] Failed to retrieve album: ${e.message}`, {
cause: e,
});
}
};
public addArtist = async (
options: LidarrArtistOptions
): Promise<LidarrArtist> => {
try {
// Check if artist already exists
const existing = await this.getArtistByMbId(options.foreignArtistId);
if (existing) {
logger.info('Artist already exists in Lidarr.', {
label: 'Lidarr',
artistId: existing.id,
artistName: existing.artistName,
});
return existing;
}
// Look up artist details
const lookupResults = await this.searchArtist(
`lidarr:${options.foreignArtistId}`
);
const lookupArtist = lookupResults[0];
const response = await this.axios.post<LidarrArtist>('/artist', {
...lookupArtist,
artistName: options.artistName,
qualityProfileId: options.qualityProfileId,
metadataProfileId: options.metadataProfileId,
rootFolderPath: options.rootFolderPath,
monitored: options.monitored ?? true,
tags: options.tags,
addOptions: {
monitor: 'all',
searchForMissingAlbums: options.searchNow ?? true,
},
});
if (response.data.id) {
logger.info('Lidarr accepted request', {
label: 'Lidarr',
artistId: response.data.id,
artistName: response.data.artistName,
});
}
return response.data;
} catch (e) {
logger.error('Failed to add artist to Lidarr', {
label: 'Lidarr',
errorMessage: e.message,
options,
});
throw new Error('Failed to add artist to Lidarr', { cause: e });
}
};
public async searchArtistCommand(artistId: number): Promise<void> {
logger.info('Executing artist search command', {
label: 'Lidarr API',
artistId,
});
try {
await this.runCommand('ArtistSearch', { artistId });
} catch (e) {
logger.error('Something went wrong executing Lidarr artist search.', {
label: 'Lidarr API',
errorMessage: e.message,
artistId,
});
}
}
public getMetadataProfiles = async (): Promise<MetadataProfile[]> => {
try {
const response =
await this.axios.get<MetadataProfile[]>('/metadataprofile');
return response.data;
} catch (e) {
throw new Error(
`[Lidarr] Failed to retrieve metadata profiles: ${e.message}`,
{ cause: e }
);
}
};
public removeArtist = async (artistId: number): Promise<void> => {
try {
await this.axios.delete(`/artist/${artistId}`, {
params: { deleteFiles: true, addImportListExclusion: false },
});
logger.info(`[Lidarr] Removed artist ${artistId}`);
} catch (e) {
throw new Error(`[Lidarr] Failed to remove artist: ${e.message}`, {
cause: e,
});
}
};
}
export default LidarrAPI;

View File

@@ -0,0 +1,225 @@
import logger from '@server/logger';
import ServarrBase from './base';
export interface ReadarrBookOptions {
title: string;
qualityProfileId: number;
metadataProfileId: number;
tags: number[];
rootFolderPath: string;
foreignBookId: string; // GoodReads/Edition ID
authorId?: number;
monitored?: boolean;
searchNow?: boolean;
}
export interface ReadarrAuthor {
id: number;
authorName: string;
foreignAuthorId: string;
monitored: boolean;
path: string;
qualityProfileId: number;
metadataProfileId: number;
rootFolderPath: string;
tags: number[];
added: string;
status: string;
ended: boolean;
images: {
coverType: string;
url: string;
remoteUrl: string;
}[];
genres: string[];
statistics?: {
bookFileCount: number;
bookCount: number;
totalBookCount: number;
sizeOnDisk: number;
percentOfBooks: number;
};
}
export interface ReadarrBook {
id: number;
title: string;
foreignBookId: string;
authorId: number;
monitored: boolean;
releaseDate: string;
genres: string[];
images: {
coverType: string;
url: string;
remoteUrl: string;
}[];
author: ReadarrAuthor;
overview: string;
pageCount: number;
statistics?: {
bookFileCount: number;
sizeOnDisk: number;
};
}
export interface ReadarrMetadataProfile {
id: number;
name: string;
}
class ReadarrAPI extends ServarrBase<{ authorId: number }> {
constructor({ url, apiKey }: { url: string; apiKey: string }) {
super({ url, apiKey, cacheName: 'readarr', apiName: 'Readarr' });
}
public getAuthors = async (): Promise<ReadarrAuthor[]> => {
try {
const response = await this.axios.get<ReadarrAuthor[]>('/author');
return response.data;
} catch (e) {
throw new Error(`[Readarr] Failed to retrieve authors: ${e.message}`, {
cause: e,
});
}
};
public getAuthor = async ({
id,
}: {
id: number;
}): Promise<ReadarrAuthor> => {
try {
const response = await this.axios.get<ReadarrAuthor>(`/author/${id}`);
return response.data;
} catch (e) {
throw new Error(`[Readarr] Failed to retrieve author: ${e.message}`, {
cause: e,
});
}
};
public searchBook = async (term: string): Promise<ReadarrBook[]> => {
try {
const response = await this.axios.get<ReadarrBook[]>('/book/lookup', {
params: { term },
});
return response.data;
} catch (e) {
throw new Error(`[Readarr] Failed to search books: ${e.message}`, {
cause: e,
});
}
};
public searchAuthor = async (term: string): Promise<ReadarrAuthor[]> => {
try {
const response = await this.axios.get<ReadarrAuthor[]>(
'/author/lookup',
{ params: { term } }
);
return response.data;
} catch (e) {
throw new Error(`[Readarr] Failed to search authors: ${e.message}`, {
cause: e,
});
}
};
public getBooks = async (authorId: number): Promise<ReadarrBook[]> => {
try {
const response = await this.axios.get<ReadarrBook[]>('/book', {
params: { authorId },
});
return response.data;
} catch (e) {
throw new Error(`[Readarr] Failed to retrieve books: ${e.message}`, {
cause: e,
});
}
};
public getBook = async ({ id }: { id: number }): Promise<ReadarrBook> => {
try {
const response = await this.axios.get<ReadarrBook>(`/book/${id}`);
return response.data;
} catch (e) {
throw new Error(`[Readarr] Failed to retrieve book: ${e.message}`, {
cause: e,
});
}
};
public addBook = async (
options: ReadarrBookOptions
): Promise<ReadarrBook> => {
try {
const lookupResults = await this.searchBook(
`readarr:${options.foreignBookId}`
);
const lookupBook = lookupResults[0];
if (!lookupBook) {
throw new Error('Book not found in lookup');
}
const response = await this.axios.post<ReadarrBook>('/book', {
...lookupBook,
qualityProfileId: options.qualityProfileId,
metadataProfileId: options.metadataProfileId,
rootFolderPath: options.rootFolderPath,
monitored: options.monitored ?? true,
tags: options.tags,
addOptions: {
searchForNewBook: options.searchNow ?? true,
},
});
if (response.data.id) {
logger.info('Readarr accepted request', {
label: 'Readarr',
bookId: response.data.id,
title: response.data.title,
});
}
return response.data;
} catch (e) {
logger.error('Failed to add book to Readarr', {
label: 'Readarr',
errorMessage: e.message,
options,
});
throw new Error('Failed to add book to Readarr', { cause: e });
}
};
public getMetadataProfiles = async (): Promise<
ReadarrMetadataProfile[]
> => {
try {
const response =
await this.axios.get<ReadarrMetadataProfile[]>('/metadataprofile');
return response.data;
} catch (e) {
throw new Error(
`[Readarr] Failed to retrieve metadata profiles: ${e.message}`,
{ cause: e }
);
}
};
public removeBook = async (bookId: number): Promise<void> => {
try {
await this.axios.delete(`/book/${bookId}`, {
params: { deleteFiles: true, addImportListExclusion: false },
});
logger.info(`[Readarr] Removed book ${bookId}`);
} catch (e) {
throw new Error(`[Readarr] Failed to remove book: ${e.message}`, {
cause: e,
});
}
};
}
export default ReadarrAPI;

View File

@@ -9,6 +9,8 @@ export enum MediaRequestStatus {
export enum MediaType {
MOVIE = 'movie',
TV = 'tv',
MUSIC = 'music',
BOOK = 'book',
}
export enum MediaStatus {

View File

@@ -98,6 +98,13 @@ class Media {
@Index()
public imdbId?: string;
@Column({ nullable: true })
@Index()
public externalServiceId?: string;
@Column({ nullable: true })
public externalServiceTitle?: string;
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
@Index()
public status: MediaStatus;

View File

@@ -108,6 +108,26 @@ export class MediaRequest {
requestBody.is4k ? '4K ' : ''
}series requests.`
);
} else if (
requestBody.mediaType === MediaType.MUSIC &&
!requestUser.hasPermission(
[Permission.REQUEST, Permission.REQUEST_MUSIC],
{ type: 'or' }
)
) {
throw new RequestPermissionError(
'You do not have permission to make music requests.'
);
} else if (
requestBody.mediaType === MediaType.BOOK &&
!requestUser.hasPermission(
[Permission.REQUEST, Permission.REQUEST_BOOK],
{ type: 'or' }
)
) {
throw new RequestPermissionError(
'You do not have permission to make book requests.'
);
}
const quotas = await requestUser.getQuota();
@@ -116,15 +136,30 @@ export class MediaRequest {
throw new QuotaRestrictedError('Movie Quota exceeded.');
} else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) {
throw new QuotaRestrictedError('Series Quota exceeded.');
} else if (requestBody.mediaType === MediaType.MUSIC && quotas.music?.restricted) {
throw new QuotaRestrictedError('Music Quota exceeded.');
} else if (requestBody.mediaType === MediaType.BOOK && quotas.book?.restricted) {
throw new QuotaRestrictedError('Book Quota exceeded.');
}
const tmdbMedia =
// Music and Book requests don't use TMDB - they use foreign IDs from Lidarr/Readarr
const isMusicOrBook = requestBody.mediaType === MediaType.MUSIC || requestBody.mediaType === MediaType.BOOK;
let tmdbMedia: any = null;
if (!isMusicOrBook) {
tmdbMedia =
requestBody.mediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: requestBody.mediaId })
: await tmdb.getTvShow({ tvId: requestBody.mediaId });
}
let media = await mediaRepository.findOne({
where: {
where: isMusicOrBook
? {
externalServiceId: requestBody.foreignId,
mediaType: requestBody.mediaType,
}
: {
tmdbId: requestBody.mediaId,
mediaType: requestBody.mediaType,
},
@@ -133,8 +168,10 @@ export class MediaRequest {
if (!media) {
media = new Media({
tmdbId: tmdbMedia.id,
tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
tmdbId: isMusicOrBook ? 0 : tmdbMedia.id,
tvdbId: isMusicOrBook ? 0 : (requestBody.tvdbId ?? tmdbMedia.external_ids?.tvdb_id),
externalServiceId: isMusicOrBook ? requestBody.foreignId : undefined,
externalServiceTitle: isMusicOrBook ? requestBody.foreignTitle : undefined,
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
mediaType: requestBody.mediaType,
@@ -159,16 +196,26 @@ export class MediaRequest {
}
}
const existing = await requestRepository
const existingQuery = requestRepository
.createQueryBuilder('request')
.leftJoin('request.media', 'media')
.leftJoinAndSelect('request.requestedBy', 'user')
.where('request.is4k = :is4k', { is4k: requestBody.is4k })
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id })
.andWhere('media.mediaType = :mediaType', {
mediaType: requestBody.mediaType,
})
.getMany();
});
if (isMusicOrBook) {
existingQuery.andWhere('media.externalServiceId = :foreignId', {
foreignId: requestBody.foreignId,
});
} else {
existingQuery.andWhere('media.tmdbId = :tmdbId', {
tmdbId: tmdbMedia.id,
});
}
const existing = await existingQuery.getMany();
if (existing && existing.length > 0) {
// If there is an existing movie request that isn't declined, don't allow a new one.
@@ -510,6 +557,72 @@ export class MediaRequest {
}
}
/**
* Handle music/book request creation (simpler flow - no TMDB, no seasons, no 4K)
*/
public static async requestMusicOrBook(
requestBody: MediaRequestBody,
user: User,
options: MediaRequestOptions = {}
): Promise<MediaRequest> {
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
let media = await mediaRepository.findOne({
where: {
externalServiceId: requestBody.foreignId,
mediaType: requestBody.mediaType,
},
relations: ['requests'],
});
if (!media) {
media = new Media({
tmdbId: 0,
tvdbId: 0,
externalServiceId: requestBody.foreignId,
externalServiceTitle: requestBody.foreignTitle,
status: MediaStatus.PENDING,
status4k: MediaStatus.UNKNOWN,
mediaType: requestBody.mediaType,
});
}
await mediaRepository.save(media);
const autoApprovePermission =
requestBody.mediaType === MediaType.MUSIC
? Permission.AUTO_APPROVE_MUSIC
: Permission.AUTO_APPROVE_BOOK;
const request = new MediaRequest({
type: requestBody.mediaType,
media,
requestedBy: user,
status: user.hasPermission(
[Permission.AUTO_APPROVE, autoApprovePermission, Permission.MANAGE_REQUESTS],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy: user.hasPermission(
[Permission.AUTO_APPROVE, autoApprovePermission, Permission.MANAGE_REQUESTS],
{ type: 'or' }
)
? user
: undefined,
is4k: false,
serverId: requestBody.serverId,
profileId: requestBody.profileId,
rootFolder: requestBody.rootFolder,
tags: requestBody.tags,
isAutoRequest: options.isAutoRequest ?? false,
});
await requestRepository.save(request);
return request;
}
@PrimaryGeneratedColumn()
public id: number;

View File

@@ -26,4 +26,7 @@ export type MediaRequestBody = {
languageProfileId?: number;
userId?: number;
tags?: number[];
// Music/Book specific fields
foreignId?: string;
foreignTitle?: string;
};

View File

@@ -29,6 +29,10 @@ export enum Permission {
WATCHLIST_VIEW = 134217728,
MANAGE_BLOCKLIST = 268435456,
VIEW_BLOCKLIST = 1073741824,
REQUEST_MUSIC = 536870912,
AUTO_APPROVE_MUSIC = 2147483648,
REQUEST_BOOK = 4294967296,
AUTO_APPROVE_BOOK = 8589934592,
}
export interface PermissionCheckOptions {

View File

@@ -102,6 +102,16 @@ export interface SonarrSettings extends DVRSettings {
monitorNewItems: 'all' | 'none';
}
export interface LidarrSettings extends DVRSettings {
activeMetadataProfileId: number;
activeMetadataProfileName: string;
}
export interface ReadarrSettings extends DVRSettings {
activeMetadataProfileId: number;
activeMetadataProfileName: string;
}
interface Quota {
quotaLimit?: number;
quotaDays?: number;
@@ -137,6 +147,8 @@ export interface MainSettings {
defaultQuotas: {
movie: Quota;
tv: Quota;
music: Quota;
book: Quota;
};
hideAvailable: boolean;
hideBlocklisted: boolean;
@@ -368,6 +380,8 @@ export interface AllSettings {
tautulli: TautulliSettings;
radarr: RadarrSettings[];
sonarr: SonarrSettings[];
lidarr: LidarrSettings[];
readarr: ReadarrSettings[];
public: PublicSettings;
notifications: NotificationSettings;
jobs: Record<JobId, JobSettings>;
@@ -398,6 +412,8 @@ class Settings {
defaultQuotas: {
movie: {},
tv: {},
music: {},
book: {},
},
hideAvailable: false,
hideBlocklisted: false,
@@ -441,6 +457,8 @@ class Settings {
},
radarr: [],
sonarr: [],
lidarr: [],
readarr: [],
public: {
initialized: false,
},
@@ -673,6 +691,22 @@ class Settings {
this.data.sonarr = data;
}
get lidarr(): LidarrSettings[] {
return this.data.lidarr;
}
set lidarr(data: LidarrSettings[]) {
this.data.lidarr = data;
}
get readarr(): ReadarrSettings[] {
return this.data.readarr;
}
set readarr(data: ReadarrSettings[]) {
this.data.readarr = data;
}
get public(): PublicSettings {
return this.data.public;
}

94
server/routes/book.ts Normal file
View File

@@ -0,0 +1,94 @@
import ReadarrAPI from '@server/api/servarr/readarr';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { Router } from 'express';
const bookRoutes = Router();
bookRoutes.get('/search', async (req, res, next) => {
const { query } = req.query;
if (!query || typeof query !== 'string') {
return res.status(400).json({ error: 'Query parameter is required' });
}
try {
const settings = getSettings();
const readarrSettings = settings.readarr.find((r) => r.isDefault);
if (!readarrSettings) {
return res.status(404).json({ error: 'No default Readarr server configured' });
}
const readarr = new ReadarrAPI({
apiKey: readarrSettings.apiKey,
url: ReadarrAPI.buildUrl(readarrSettings, '/api/v1'),
});
const books = await readarr.searchBook(query);
const results = books.slice(0, 20).map((book) => ({
id: book.foreignBookId,
mediaType: 'book',
title: book.title,
overview: book.overview,
releaseDate: book.releaseDate,
images: book.images,
genres: book.genres,
pageCount: book.pageCount,
author: book.author ? {
id: book.author.foreignAuthorId,
name: book.author.authorName,
} : null,
foreignBookId: book.foreignBookId,
}));
return res.status(200).json({
results,
totalResults: results.length,
});
} catch (e) {
logger.error('Failed to search books', {
label: 'Book API',
message: e.message,
});
next({ status: 500, message: 'Failed to search books' });
}
});
bookRoutes.get('/author/search', async (req, res, next) => {
const { query } = req.query;
if (!query || typeof query !== 'string') {
return res.status(400).json({ error: 'Query parameter is required' });
}
try {
const settings = getSettings();
const readarrSettings = settings.readarr.find((r) => r.isDefault);
if (!readarrSettings) {
return res.status(404).json({ error: 'No default Readarr server configured' });
}
const readarr = new ReadarrAPI({
apiKey: readarrSettings.apiKey,
url: ReadarrAPI.buildUrl(readarrSettings, '/api/v1'),
});
const authors = await readarr.searchAuthor(query);
return res.status(200).json({
results: authors.slice(0, 20),
totalResults: authors.length,
});
} catch (e) {
logger.error('Failed to search authors', {
label: 'Book API',
message: e.message,
});
next({ status: 500, message: 'Failed to search authors' });
}
});
export default bookRoutes;

View File

@@ -30,12 +30,14 @@ import { isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express';
import authRoutes from './auth';
import blocklistRoutes from './blocklist';
import bookRoutes from './book';
import collectionRoutes from './collection';
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
import issueRoutes from './issue';
import issueCommentRoutes from './issueComment';
import mediaRoutes from './media';
import movieRoutes from './movie';
import musicRoutes from './music';
import personRoutes from './person';
import requestRoutes from './request';
import searchRoutes from './search';
@@ -165,6 +167,8 @@ router.use(
);
router.use('/movie', isAuthenticated(), movieRoutes);
router.use('/tv', isAuthenticated(), tvRoutes);
router.use('/music', isAuthenticated(), musicRoutes);
router.use('/book', isAuthenticated(), bookRoutes);
router.use('/media', isAuthenticated(), mediaRoutes);
router.use('/person', isAuthenticated(), personRoutes);
router.use('/collection', isAuthenticated(), collectionRoutes);

135
server/routes/music.ts Normal file
View File

@@ -0,0 +1,135 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { Router } from 'express';
const musicRoutes = Router();
musicRoutes.get('/search', async (req, res, next) => {
const { query } = req.query;
if (!query || typeof query !== 'string') {
return res.status(400).json({ error: 'Query parameter is required' });
}
try {
const settings = getSettings();
const lidarrSettings = settings.lidarr.find((l) => l.isDefault);
if (!lidarrSettings) {
return res.status(404).json({ error: 'No default Lidarr server configured' });
}
const lidarr = new LidarrAPI({
apiKey: lidarrSettings.apiKey,
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'),
});
const artists = await lidarr.searchArtist(query);
const results = artists.slice(0, 20).map((artist) => ({
id: artist.foreignArtistId,
mediaType: 'music',
name: artist.artistName,
artistType: artist.artistType,
disambiguation: artist.disambiguation,
status: artist.status,
images: artist.images,
genres: artist.genres,
foreignArtistId: artist.foreignArtistId,
statistics: artist.statistics,
inLibrary: !!artist.id && artist.id > 0,
}));
return res.status(200).json({
results,
totalResults: results.length,
});
} catch (e) {
logger.error('Failed to search music', {
label: 'Music API',
message: e.message,
});
next({ status: 500, message: 'Failed to search music' });
}
});
musicRoutes.get('/artist/:mbId', async (req, res, next) => {
try {
const settings = getSettings();
const lidarrSettings = settings.lidarr.find((l) => l.isDefault);
if (!lidarrSettings) {
return res.status(404).json({ error: 'No default Lidarr server configured' });
}
const lidarr = new LidarrAPI({
apiKey: lidarrSettings.apiKey,
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'),
});
// Search by MusicBrainz ID
const artists = await lidarr.searchArtist(`lidarr:${req.params.mbId}`);
const artist = artists[0];
if (!artist) {
return res.status(404).json({ error: 'Artist not found' });
}
// Get albums if artist is in library
let albums: any[] = [];
const existingArtist = await lidarr.getArtistByMbId(req.params.mbId);
if (existingArtist) {
albums = await lidarr.getAlbums(existingArtist.id);
}
return res.status(200).json({
...artist,
albums,
inLibrary: !!existingArtist,
});
} catch (e) {
logger.error('Failed to get artist details', {
label: 'Music API',
message: e.message,
});
next({ status: 500, message: 'Failed to get artist details' });
}
});
musicRoutes.get('/album/search', async (req, res, next) => {
const { query } = req.query;
if (!query || typeof query !== 'string') {
return res.status(400).json({ error: 'Query parameter is required' });
}
try {
const settings = getSettings();
const lidarrSettings = settings.lidarr.find((l) => l.isDefault);
if (!lidarrSettings) {
return res.status(404).json({ error: 'No default Lidarr server configured' });
}
const lidarr = new LidarrAPI({
apiKey: lidarrSettings.apiKey,
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'),
});
const albums = await lidarr.searchAlbum(query);
return res.status(200).json({
results: albums.slice(0, 20),
totalResults: albums.length,
});
} catch (e) {
logger.error('Failed to search albums', {
label: 'Music API',
message: e.message,
});
next({ status: 500, message: 'Failed to search albums' });
}
});
export default musicRoutes;

View File

@@ -173,6 +173,16 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
type: MediaType.TV,
});
break;
case 'music':
query = query.andWhere('request.type = :type', {
type: MediaType.MUSIC,
});
break;
case 'book':
query = query.andWhere('request.type = :type', {
type: MediaType.BOOK,
});
break;
}
const [requests, requestCount] = await query

View File

@@ -41,7 +41,9 @@ import semver from 'semver';
import { URL } from 'url';
import metadataRoutes from './metadata';
import notificationRoutes from './notifications';
import lidarrRoutes from './lidarr';
import radarrRoutes from './radarr';
import readarrRoutes from './readarr';
import sonarrRoutes from './sonarr';
const settingsRoutes = Router();
@@ -49,6 +51,8 @@ const settingsRoutes = Router();
settingsRoutes.use('/notifications', notificationRoutes);
settingsRoutes.use('/radarr', radarrRoutes);
settingsRoutes.use('/sonarr', sonarrRoutes);
settingsRoutes.use('/lidarr', lidarrRoutes);
settingsRoutes.use('/readarr', readarrRoutes);
settingsRoutes.use('/discover', discoverSettingRoutes);
settingsRoutes.use('/metadatas', metadataRoutes);

View File

@@ -0,0 +1,106 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import type { LidarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { Router } from 'express';
const lidarrRoutes = Router();
lidarrRoutes.get('/', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.lidarr);
});
lidarrRoutes.post('/', async (req, res) => {
const settings = getSettings();
const newLidarr = req.body as LidarrSettings;
const lastItem = settings.lidarr[settings.lidarr.length - 1];
newLidarr.id = lastItem ? lastItem.id + 1 : 0;
if (req.body.isDefault) {
settings.lidarr.forEach((instance) => {
instance.isDefault = false;
});
}
settings.lidarr = [...settings.lidarr, newLidarr];
await settings.save();
return res.status(201).json(newLidarr);
});
lidarrRoutes.post<
undefined,
Record<string, unknown>,
LidarrSettings
>('/test', async (req, res, next) => {
try {
const lidarr = new LidarrAPI({
apiKey: req.body.apiKey,
url: LidarrAPI.buildUrl(req.body, '/api/v1'),
});
const profiles = await lidarr.getProfiles();
const folders = await lidarr.getRootFolders();
const tags = await lidarr.getTags();
const metadataProfiles = await lidarr.getMetadataProfiles();
return res.status(200).json({
profiles,
rootFolders: folders.map((folder) => ({
id: folder.id,
path: folder.path,
})),
tags,
metadataProfiles,
});
} catch (e) {
logger.error('Failed to test Lidarr', {
label: 'Lidarr',
message: e.message,
});
next({ status: 500, message: 'Failed to connect to Lidarr' });
}
});
lidarrRoutes.put<{ id: string }>('/:id', async (req, res, next) => {
const settings = getSettings();
const lidarrIndex = settings.lidarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (lidarrIndex === -1) {
return next({ status: 404, message: 'Lidarr server not found.' });
}
if (req.body.isDefault) {
settings.lidarr.forEach((instance) => {
instance.isDefault = false;
});
}
settings.lidarr[lidarrIndex] = {
...settings.lidarr[lidarrIndex],
...req.body,
id: Number(req.params.id),
} as LidarrSettings;
await settings.save();
return res.status(200).json(settings.lidarr[lidarrIndex]);
});
lidarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => {
const settings = getSettings();
const lidarrIndex = settings.lidarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (lidarrIndex === -1) {
return next({ status: 404, message: 'Lidarr server not found.' });
}
const removed = settings.lidarr.splice(lidarrIndex, 1);
await settings.save();
return res.status(200).json(removed[0]);
});
export default lidarrRoutes;

View File

@@ -0,0 +1,106 @@
import ReadarrAPI from '@server/api/servarr/readarr';
import type { ReadarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { Router } from 'express';
const readarrRoutes = Router();
readarrRoutes.get('/', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.readarr);
});
readarrRoutes.post('/', async (req, res) => {
const settings = getSettings();
const newReadarr = req.body as ReadarrSettings;
const lastItem = settings.readarr[settings.readarr.length - 1];
newReadarr.id = lastItem ? lastItem.id + 1 : 0;
if (req.body.isDefault) {
settings.readarr.forEach((instance) => {
instance.isDefault = false;
});
}
settings.readarr = [...settings.readarr, newReadarr];
await settings.save();
return res.status(201).json(newReadarr);
});
readarrRoutes.post<
undefined,
Record<string, unknown>,
ReadarrSettings
>('/test', async (req, res, next) => {
try {
const readarr = new ReadarrAPI({
apiKey: req.body.apiKey,
url: ReadarrAPI.buildUrl(req.body, '/api/v1'),
});
const profiles = await readarr.getProfiles();
const folders = await readarr.getRootFolders();
const tags = await readarr.getTags();
const metadataProfiles = await readarr.getMetadataProfiles();
return res.status(200).json({
profiles,
rootFolders: folders.map((folder) => ({
id: folder.id,
path: folder.path,
})),
tags,
metadataProfiles,
});
} catch (e) {
logger.error('Failed to test Readarr', {
label: 'Readarr',
message: e.message,
});
next({ status: 500, message: 'Failed to connect to Readarr' });
}
});
readarrRoutes.put<{ id: string }>('/:id', async (req, res, next) => {
const settings = getSettings();
const readarrIndex = settings.readarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (readarrIndex === -1) {
return next({ status: 404, message: 'Readarr server not found.' });
}
if (req.body.isDefault) {
settings.readarr.forEach((instance) => {
instance.isDefault = false;
});
}
settings.readarr[readarrIndex] = {
...settings.readarr[readarrIndex],
...req.body,
id: Number(req.params.id),
} as ReadarrSettings;
await settings.save();
return res.status(200).json(settings.readarr[readarrIndex]);
});
readarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => {
const settings = getSettings();
const readarrIndex = settings.readarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (readarrIndex === -1) {
return next({ status: 404, message: 'Readarr server not found.' });
}
const removed = settings.readarr.splice(readarrIndex, 1);
await settings.save();
return res.status(200).json(removed[0]);
});
export default readarrRoutes;

View File

@@ -0,0 +1,111 @@
import Button from '@app/components/Common/Button';
import PageTitle from '@app/components/Common/PageTitle';
import { useUser } from '@app/hooks/useUser';
import axios from 'axios';
import { useState } from 'react';
interface BookDetailsProps {
book?: any;
}
const BookDetails = ({ book }: BookDetailsProps) => {
const { user } = useUser();
const [requesting, setRequesting] = useState(false);
const [requested, setRequested] = useState(false);
if (!book) {
return (
<div className="mt-16 text-center text-gray-400">
<h2 className="text-2xl">Book not found</h2>
</div>
);
}
const coverUrl =
book.images?.find((i: any) => i.coverType === 'cover')?.remoteUrl ||
book.images?.[0]?.remoteUrl;
const handleRequest = async () => {
setRequesting(true);
try {
await axios.post('/api/v1/request', {
mediaType: 'book',
mediaId: 0,
foreignId: book.foreignBookId,
foreignTitle: book.title,
});
setRequested(true);
} catch (e) {
console.error('Request failed', e);
}
setRequesting(false);
};
return (
<div className="media-page">
<PageTitle title={book.title} />
<div className="media-header">
<div className="media-poster">
{coverUrl && (
<img
src={coverUrl}
alt={book.title}
className="rounded-lg shadow-lg"
/>
)}
</div>
<div className="media-title">
<h1 className="text-4xl font-bold text-white">{book.title}</h1>
{book.author && (
<p className="mt-1 text-lg text-gray-400">
by {book.author.name}
</p>
)}
<div className="mt-2 flex flex-wrap gap-2">
{book.genres?.slice(0, 5).map((genre: string) => (
<span
key={genre}
className="rounded-full bg-gray-700 px-3 py-1 text-sm text-gray-300"
>
{genre}
</span>
))}
</div>
{book.pageCount > 0 && (
<p className="mt-2 text-sm text-gray-400">
{book.pageCount} pages ·{' '}
{book.releaseDate?.substring(0, 4)}
</p>
)}
<div className="media-actions mt-4">
{requested ? (
<Button disabled buttonType="success">
<span> Requested</span>
</Button>
) : (
<Button
buttonType="primary"
onClick={handleRequest}
disabled={requesting}
>
<span>
{requesting ? 'Requesting...' : '📚 Request Book'}
</span>
</Button>
)}
</div>
</div>
</div>
{book.overview && (
<div className="mt-8">
<h2 className="text-xl font-bold text-white">Overview</h2>
<p className="mt-2 text-gray-300">{book.overview}</p>
</div>
)}
</div>
);
};
export default BookDetails;

View File

@@ -0,0 +1,183 @@
import Button from '@app/components/Common/Button';
import PageTitle from '@app/components/Common/PageTitle';
import { useUser } from '@app/hooks/useUser';
import axios from 'axios';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
interface MusicDetailsProps {
artist?: any;
}
const MusicDetails = ({ artist }: MusicDetailsProps) => {
const router = useRouter();
const { user } = useUser();
const [requesting, setRequesting] = useState(false);
const [requested, setRequested] = useState(artist?.inLibrary || false);
if (!artist) {
return (
<div className="mt-16 text-center text-gray-400">
<h2 className="text-2xl">Artist not found</h2>
</div>
);
}
const posterUrl =
artist.images?.find((i: any) => i.coverType === 'poster')?.remoteUrl ||
artist.images?.[0]?.remoteUrl;
const handleRequest = async () => {
setRequesting(true);
try {
await axios.post('/api/v1/request', {
mediaType: 'music',
mediaId: 0,
foreignId: artist.foreignArtistId,
foreignTitle: artist.artistName,
});
setRequested(true);
} catch (e) {
console.error('Request failed', e);
}
setRequesting(false);
};
return (
<div className="media-page">
<PageTitle title={artist.artistName} />
<div className="media-page-bg-image">
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: posterUrl ? `url(${posterUrl})` : undefined,
filter: 'blur(16px) brightness(0.3)',
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-gray-900" />
</div>
<div className="media-header">
<div className="media-poster">
{posterUrl && (
<img
src={posterUrl}
alt={artist.artistName}
className="rounded-lg shadow-lg"
/>
)}
</div>
<div className="media-title">
<h1 className="text-4xl font-bold text-white">
{artist.artistName}
</h1>
{artist.disambiguation && (
<p className="mt-1 text-lg text-gray-400">
{artist.disambiguation}
</p>
)}
<div className="mt-2 flex flex-wrap gap-2">
{artist.genres?.slice(0, 5).map((genre: string) => (
<span
key={genre}
className="rounded-full bg-gray-700 px-3 py-1 text-sm text-gray-300"
>
{genre}
</span>
))}
</div>
<div className="media-actions mt-4">
{requested ? (
<Button disabled buttonType="success">
<span>
{artist.inLibrary ? '✓ In Library' : '✓ Requested'}
</span>
</Button>
) : (
<Button
buttonType="primary"
onClick={handleRequest}
disabled={requesting}
>
<span>
{requesting ? 'Requesting...' : '🎵 Request Artist'}
</span>
</Button>
)}
</div>
</div>
</div>
{/* Albums */}
{artist.albums && artist.albums.length > 0 && (
<div className="slider-header mt-8">
<h2 className="text-xl font-bold text-white">
Albums ({artist.albums.length})
</h2>
<div className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{artist.albums.map((album: any) => {
const albumArt =
album.images?.find((i: any) => i.coverType === 'cover')
?.remoteUrl || album.images?.[0]?.remoteUrl;
return (
<div
key={album.id}
className="group relative overflow-hidden rounded-lg bg-gray-800 shadow-lg"
>
{albumArt && (
<img
src={albumArt}
alt={album.title}
className="aspect-square w-full object-cover"
/>
)}
<div className="p-2">
<h3 className="truncate text-sm font-semibold text-white">
{album.title}
</h3>
<p className="text-xs text-gray-400">
{album.releaseDate?.substring(0, 4)} · {album.albumType}
</p>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Stats */}
{artist.statistics && (
<div className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="rounded-lg bg-gray-800 p-4">
<p className="text-sm text-gray-400">Albums</p>
<p className="text-2xl font-bold text-white">
{artist.statistics.albumCount}
</p>
</div>
<div className="rounded-lg bg-gray-800 p-4">
<p className="text-sm text-gray-400">Tracks</p>
<p className="text-2xl font-bold text-white">
{artist.statistics.totalTrackCount}
</p>
</div>
<div className="rounded-lg bg-gray-800 p-4">
<p className="text-sm text-gray-400">On Disk</p>
<p className="text-2xl font-bold text-white">
{artist.statistics.trackFileCount}
</p>
</div>
<div className="rounded-lg bg-gray-800 p-4">
<p className="text-sm text-gray-400">Size</p>
<p className="text-2xl font-bold text-white">
{(artist.statistics.sizeOnDisk / 1073741824).toFixed(1)} GB
</p>
</div>
</div>
)}
</div>
);
};
export default MusicDetails;

View File

@@ -9,7 +9,10 @@ import type {
PersonResult,
TvResult,
} from '@server/models/Search';
import axios from 'axios';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
const messages = defineMessages('components.Search', {
@@ -17,9 +20,86 @@ const messages = defineMessages('components.Search', {
searchresults: 'Search Results',
});
type SearchTab = 'all' | 'music' | 'books';
const MusicResultCard = ({ artist }: { artist: any }) => {
const posterUrl =
artist.images?.find((i: any) => i.coverType === 'poster')?.remoteUrl ||
artist.images?.[0]?.remoteUrl;
return (
<Link href={`/music/${artist.foreignArtistId}`}>
<div className="group relative w-36 cursor-pointer overflow-hidden rounded-lg bg-gray-800 shadow-lg transition duration-200 hover:scale-105 sm:w-36 md:w-44">
<div className="aspect-[2/3] w-full">
{posterUrl ? (
<img
src={posterUrl}
alt={artist.name}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center bg-gray-700 text-4xl">
🎵
</div>
)}
</div>
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/80 via-transparent p-2">
<div>
<h3 className="text-sm font-bold text-white">{artist.name}</h3>
<p className="text-xs text-gray-300">
{artist.artistType || 'Artist'}
{artist.inLibrary && ' · ✓ In Library'}
</p>
</div>
</div>
</div>
</Link>
);
};
const BookResultCard = ({ book }: { book: any }) => {
const coverUrl =
book.images?.find((i: any) => i.coverType === 'cover')?.remoteUrl ||
book.images?.[0]?.remoteUrl;
return (
<Link href={`/book/${book.foreignBookId}`}>
<div className="group relative w-36 cursor-pointer overflow-hidden rounded-lg bg-gray-800 shadow-lg transition duration-200 hover:scale-105 sm:w-36 md:w-44">
<div className="aspect-[2/3] w-full">
{coverUrl ? (
<img
src={coverUrl}
alt={book.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center bg-gray-700 text-4xl">
📚
</div>
)}
</div>
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/80 via-transparent p-2">
<div>
<h3 className="text-sm font-bold text-white">{book.title}</h3>
<p className="text-xs text-gray-300">
{book.author?.name || ''}
{book.releaseDate && ` · ${book.releaseDate.substring(0, 4)}`}
</p>
</div>
</div>
</div>
</Link>
);
};
const Search = () => {
const intl = useIntl();
const router = useRouter();
const [activeTab, setActiveTab] = useState<SearchTab>('all');
const [musicResults, setMusicResults] = useState<any[]>([]);
const [bookResults, setBookResults] = useState<any[]>([]);
const [musicLoading, setMusicLoading] = useState(false);
const [bookLoading, setBookLoading] = useState(false);
const {
isLoadingInitialData,
@@ -31,31 +111,125 @@ const Search = () => {
error,
} = useDiscover<MovieResult | TvResult | PersonResult>(
`/api/v1/search`,
{
query: router.query.query,
},
{ query: router.query.query },
{ hideAvailable: false, hideBlocklisted: false }
);
// Search music and books when query changes
useEffect(() => {
const query = router.query.query as string;
if (!query) return;
setMusicLoading(true);
axios
.get(`/api/v1/music/search?query=${encodeURIComponent(query)}`)
.then((res) => setMusicResults(res.data?.results || []))
.catch(() => setMusicResults([]))
.finally(() => setMusicLoading(false));
setBookLoading(true);
axios
.get(`/api/v1/book/search?query=${encodeURIComponent(query)}`)
.then((res) => setBookResults(res.data?.results || []))
.catch(() => setBookResults([]))
.finally(() => setBookLoading(false));
}, [router.query.query]);
if (error) {
return <ErrorPage statusCode={500} />;
}
const tabs: { id: SearchTab; label: string; count: number }[] = [
{ id: 'all', label: 'Movies & TV', count: titles?.length || 0 },
{ id: 'music', label: '🎵 Music', count: musicResults.length },
{ id: 'books', label: '📚 Books', count: bookResults.length },
];
return (
<>
<PageTitle title={intl.formatMessage(messages.search)} />
<div className="mb-5 mt-1">
<Header>{intl.formatMessage(messages.searchresults)}</Header>
</div>
{/* Tabs */}
<div className="mb-6 flex gap-2 border-b border-gray-700">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium transition ${
activeTab === tab.id
? 'border-b-2 border-indigo-500 text-white'
: 'text-gray-400 hover:text-white'
}`}
>
{tab.label}
{tab.count > 0 && (
<span className="ml-1 text-xs text-gray-500">
({tab.count})
</span>
)}
</button>
))}
</div>
{/* Movies & TV Tab */}
{activeTab === 'all' && (
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
isLoadingInitialData ||
(isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
)}
{/* Music Tab */}
{activeTab === 'music' && (
<div>
{musicLoading ? (
<div className="text-center text-gray-400">Searching music...</div>
) : musicResults.length === 0 ? (
<div className="text-center text-gray-400">
No music results found.
{!router.query.query && ' Try searching for an artist or album.'}
</div>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7">
{musicResults.map((artist) => (
<MusicResultCard
key={artist.foreignArtistId}
artist={artist}
/>
))}
</div>
)}
</div>
)}
{/* Books Tab */}
{activeTab === 'books' && (
<div>
{bookLoading ? (
<div className="text-center text-gray-400">Searching books...</div>
) : bookResults.length === 0 ? (
<div className="text-center text-gray-400">
No book results found.
{!router.query.query && ' Try searching for a title or author.'}
</div>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7">
{bookResults.map((book) => (
<BookResultCard key={book.foreignBookId} book={book} />
))}
</div>
)}
</div>
)}
</>
);
};

View File

@@ -0,0 +1,33 @@
import BookDetails from '@app/components/BookDetails';
import axios from 'axios';
import type { GetServerSideProps, NextPage } from 'next';
interface BookPageProps {
book?: any;
}
const BookPage: NextPage<BookPageProps> = ({ book }) => {
return <BookDetails book={book} />;
};
export const getServerSideProps: GetServerSideProps<BookPageProps> = async (
ctx
) => {
try {
const response = await axios.get(
`http://${process.env.HOST || 'localhost'}:${
process.env.PORT || 5055
}/api/v1/book/search?query=${ctx.query.bookId}`,
{
headers: ctx.req?.headers?.cookie
? { cookie: ctx.req.headers.cookie }
: undefined,
}
);
return { props: { book: response.data?.results?.[0] } };
} catch {
return { props: {} };
}
};
export default BookPage;

View File

@@ -0,0 +1,33 @@
import MusicDetails from '@app/components/MusicDetails';
import axios from 'axios';
import type { GetServerSideProps, NextPage } from 'next';
interface MusicPageProps {
artist?: any;
}
const MusicPage: NextPage<MusicPageProps> = ({ artist }) => {
return <MusicDetails artist={artist} />;
};
export const getServerSideProps: GetServerSideProps<MusicPageProps> = async (
ctx
) => {
try {
const response = await axios.get(
`http://${process.env.HOST || 'localhost'}:${
process.env.PORT || 5055
}/api/v1/music/artist/${ctx.query.artistId}`,
{
headers: ctx.req?.headers?.cookie
? { cookie: ctx.req.headers.cookie }
: undefined,
}
);
return { props: { artist: response.data } };
} catch {
return { props: {} };
}
};
export default MusicPage;