Compare commits
10 Commits
772e83d104
...
af6579163b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af6579163b | ||
|
|
466db07e37 | ||
|
|
1cf0d541d6 | ||
|
|
dc40ca413c | ||
|
|
6f9b743ea9 | ||
|
|
868430b7db | ||
|
|
58514ec5cf | ||
|
|
5bbdc52728 | ||
|
|
986761f61f | ||
|
|
67e27d5b79 |
4
.github/workflows/pr-validation.yml
vendored
4
.github/workflows/pr-validation.yml
vendored
@@ -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
|
||||
|
||||
@@ -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/).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
61
docs/using-seerr/advanced/self-signed-certificates.mdx
Normal file
61
docs/using-seerr/advanced/self-signed-certificates.mdx
Normal 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).
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
60
docs/using-seerr/settings/network.md
Normal file
60
docs/using-seerr/settings/network.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
271
server/api/servarr/lidarr.ts
Normal file
271
server/api/servarr/lidarr.ts
Normal 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;
|
||||
225
server/api/servarr/readarr.ts
Normal file
225
server/api/servarr/readarr.ts
Normal 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;
|
||||
@@ -9,6 +9,8 @@ export enum MediaRequestStatus {
|
||||
export enum MediaType {
|
||||
MOVIE = 'movie',
|
||||
TV = 'tv',
|
||||
MUSIC = 'music',
|
||||
BOOK = 'book',
|
||||
}
|
||||
|
||||
export enum MediaStatus {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,25 +136,42 @@ 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 =
|
||||
requestBody.mediaType === MediaType.MOVIE
|
||||
? await tmdb.getMovie({ movieId: requestBody.mediaId })
|
||||
: await tmdb.getTvShow({ tvId: requestBody.mediaId });
|
||||
// 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: {
|
||||
tmdbId: requestBody.mediaId,
|
||||
mediaType: requestBody.mediaType,
|
||||
},
|
||||
where: isMusicOrBook
|
||||
? {
|
||||
externalServiceId: requestBody.foreignId,
|
||||
mediaType: requestBody.mediaType,
|
||||
}
|
||||
: {
|
||||
tmdbId: requestBody.mediaId,
|
||||
mediaType: requestBody.mediaType,
|
||||
},
|
||||
relations: ['requests'],
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -26,4 +26,7 @@ export type MediaRequestBody = {
|
||||
languageProfileId?: number;
|
||||
userId?: number;
|
||||
tags?: number[];
|
||||
// Music/Book specific fields
|
||||
foreignId?: string;
|
||||
foreignTitle?: string;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
94
server/routes/book.ts
Normal 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;
|
||||
@@ -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
135
server/routes/music.ts
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
106
server/routes/settings/lidarr.ts
Normal file
106
server/routes/settings/lidarr.ts
Normal 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;
|
||||
106
server/routes/settings/readarr.ts
Normal file
106
server/routes/settings/readarr.ts
Normal 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;
|
||||
111
src/components/BookDetails/index.tsx
Normal file
111
src/components/BookDetails/index.tsx
Normal 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;
|
||||
183
src/components/MusicDetails/index.tsx
Normal file
183
src/components/MusicDetails/index.tsx
Normal 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;
|
||||
@@ -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>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
|
||||
{/* 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)
|
||||
}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
33
src/pages/book/[bookId]/index.tsx
Normal file
33
src/pages/book/[bookId]/index.tsx
Normal 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;
|
||||
33
src/pages/music/[artistId]/index.tsx
Normal file
33
src/pages/music/[artistId]/index.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user