diff --git a/e2e/Makefile b/e2e/Makefile index 9f2e021e..a8666fac 100644 --- a/e2e/Makefile +++ b/e2e/Makefile @@ -13,7 +13,7 @@ APP_SERVICES = app nginx CORE_STACK = keycloak $(WIREMOCKS) CLIENTS_STACK = $(CORE_STACK) tracker qbittorrent transmission deluge utorrent rutorrent sabnews sabnzbd -ARR_STACK = $(CORE_STACK) wiremock-indexer tracker qbittorrent sonarr radarr whisparr +ARR_STACK = $(CORE_STACK) wiremock-indexer tracker qbittorrent sonarr radarr whisparr lidarr LAZYLIBRARIAN_STACK = $(CORE_STACK) wiremock-indexer tracker qbittorrent sabnews sabnzbd lazylibrarian setup: diff --git a/e2e/arr-seed/lidarr/config.xml b/e2e/arr-seed/lidarr/config.xml new file mode 100644 index 00000000..5a7d2b65 --- /dev/null +++ b/e2e/arr-seed/lidarr/config.xml @@ -0,0 +1,20 @@ + + * + 8686 + False + False + 0000000000000000000000000000e2e7 + External + DisabledForLocalAddresses + master + info + + Lidarr + Docker + False + + + 6868 + + + \ No newline at end of file diff --git a/e2e/arr-seed/lidarr/lidarr.db b/e2e/arr-seed/lidarr/lidarr.db new file mode 100644 index 00000000..d8d51aed Binary files /dev/null and b/e2e/arr-seed/lidarr/lidarr.db differ diff --git a/e2e/docker-compose.e2e.yml b/e2e/docker-compose.e2e.yml index 8d2aec54..b0505471 100644 --- a/e2e/docker-compose.e2e.yml +++ b/e2e/docker-compose.e2e.yml @@ -213,7 +213,7 @@ services: retries: 12 start_period: 30s - # Real Sonarr, Radarr and Whisparr, restored from e2e/arr-seed by setup-test-data.sh. + # Real Sonarr, Radarr, Whisparr and Lidarr, restored from e2e/arr-seed by setup-test-data.sh. # Every run starts with the same library, API key, indexer and download client. # Regenerate the seed with `make seed-arr`. # The tags are pinned: a seed database is only valid for the version that wrote it. @@ -272,6 +272,24 @@ services: retries: 12 start_period: 30s + lidarr: + image: lscr.io/linuxserver/lidarr:3.1.6 + network_mode: host + environment: + PUID: "${E2E_PUID:-1000}" + PGID: "${E2E_PGID:-1000}" + TZ: "UTC" + volumes: + - ./test-data/lidarr-config:/config + - ./test-data/lidarr-music:/music + - ./test-data/downloads/qbittorrent:/downloads + healthcheck: + test: ["CMD-SHELL", "curl -fs -o /dev/null http://localhost:8686/ping || exit 1"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 30s + # Real LazyLibrarian, restored from e2e/lazylibrarian-seed by setup-test-data.sh. # LazyLibrarian publishes no versions, so the tag names the upstream commit. # The seed database is only valid for the commit that wrote it. diff --git a/e2e/scripts/seed-arr.sh b/e2e/scripts/seed-arr.sh index a6454ad1..1e8382f3 100644 --- a/e2e/scripts/seed-arr.sh +++ b/e2e/scripts/seed-arr.sh @@ -7,7 +7,7 @@ # CI therefore needs no network and every run sees the same library. # # Needs internet: each arr reads its metadata provider. -# Stops the sonarr, radarr and whisparr containers. +# Stops the sonarr, radarr, whisparr and lidarr containers. # # Re-run it when the pinned image tags change, or when the library has to change. # @@ -21,9 +21,11 @@ COMPOSE="docker compose -f $HERE/docker-compose.e2e.yml" SONARR_URL="http://localhost:8989" RADARR_URL="http://localhost:7878" WHISPARR_URL="http://localhost:6970" +LIDARR_URL="http://localhost:8686" SONARR_API_KEY="0000000000000000000000000000e2e1" RADARR_API_KEY="0000000000000000000000000000e2e2" WHISPARR_API_KEY="0000000000000000000000000000e2e6" +LIDARR_API_KEY="0000000000000000000000000000e2e7" # The mock Torznab indexer, and qBittorrent, both on the host network. INDEXER_URL="http://127.0.0.1:9500" @@ -43,6 +45,8 @@ RADARR_TMDB_ID=911430 RADARR_SECOND_TMDB_ID=1184918 # Package Holiday (2004): a plain title, since the seed database is public. WHISPARR_TMDB_ID=1253309 +# Jeff Buckley: one studio album and a plain name. +LIDARR_MBID=e6e879c0-3d56-4f12-b3c5-3ce459661a8e log() { echo "[seed-arr] $*" @@ -134,7 +138,7 @@ wait_for_items() { log "waiting for $name" for _ in $(seq 1 90); do local count - count=$(arr_get "$url" "$api_key" "$path" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0) + count=$(arr_get "$url" "$api_key" "$path" | python3 -c 'import sys,json; d = json.load(sys.stdin); print(len(d["records"]) if isinstance(d, dict) else len(d))' 2>/dev/null || echo 0) if [[ "$count" -gt 0 ]]; then log "$name ready ($count)" return 0 @@ -149,9 +153,9 @@ wait_for_items() { # forceSave skips the field validation, but not the connection test. # The mock indexer has to be up and answering caps. add_indexer() { - local url="$1" api_key="$2" categories="$3" + local url="$1" api_key="$2" categories="$3" api_version="${4:-v3}" - arr_post "$url" "$api_key" '/api/v3/indexer?forceSave=true' "$(cat < /dev/null + add_indexer "$LIDARR_URL" "$LIDARR_API_KEY" '[3000]' 'v1' + add_download_client "$LIDARR_URL" "$LIDARR_API_KEY" 'musicCategory' 'lidarr' 'v1' + + log "adding the artist" + local artist + artist=$(arr_get "$LIDARR_URL" "$LIDARR_API_KEY" "/api/v1/artist/lookup?term=lidarr%3A$LIDARR_MBID" \ + | python3 -c ' +import json +import sys + +item = json.load(sys.stdin)[0] +item.update({ + "qualityProfileId": 1, + "metadataProfileId": 1, + "rootFolderPath": "/music", + "monitored": True, + "addOptions": {"monitor": "all", "searchForMissingAlbums": False}, +}) +print(json.dumps(item)) +') + arr_post "$LIDARR_URL" "$LIDARR_API_KEY" '/api/v1/artist' "$artist" > /dev/null + + wait_for_items "$LIDARR_URL" "$LIDARR_API_KEY" '/api/v1/wanted/missing' 'lidarr missing albums' +} + # Keeps the database and config.xml, drops everything the arr rebuilds on start. # Whisparr v3 names its database whisparr3.db. snapshot() { @@ -302,30 +335,36 @@ main() { write_config_xml "$TEST_DATA/sonarr-config" 8989 "$SONARR_API_KEY" 'Sonarr' write_config_xml "$TEST_DATA/radarr-config" 7878 "$RADARR_API_KEY" 'Radarr' write_config_xml "$TEST_DATA/whisparr-config" 6970 "$WHISPARR_API_KEY" 'Whisparr' + write_config_xml "$TEST_DATA/lidarr-config" 8686 "$LIDARR_API_KEY" 'Lidarr' # An arr tests the indexer and the download client before it saves them. # Both have to answer during seeding. log "starting the arrs" - $COMPOSE up -d --force-recreate wiremock-indexer qbittorrent sonarr radarr whisparr + $COMPOSE up -d --force-recreate wiremock-indexer qbittorrent sonarr radarr whisparr lidarr wait_for_arr "$SONARR_URL" 'sonarr' wait_for_arr "$RADARR_URL" 'radarr' wait_for_arr "$WHISPARR_URL" 'whisparr' + wait_for_arr "$LIDARR_URL" 'lidarr' seed_sonarr seed_radarr seed_whisparr + seed_lidarr log "stopping the arrs" - $COMPOSE stop wiremock-indexer qbittorrent sonarr radarr whisparr + $COMPOSE stop wiremock-indexer qbittorrent sonarr radarr whisparr lidarr snapshot sonarr snapshot radarr snapshot whisparr whisparr3 + snapshot lidarr # The seed is public, so it keeps the title and drops the synopsis, posters and cast. sqlite3 "$ARR_SEED/whisparr/whisparr3.db" "UPDATE MovieMetadata SET Overview = '', Images = '[]', Credits = '[]'; VACUUM;" + sqlite3 "$ARR_SEED/lidarr/lidarr.db" "UPDATE ArtistMetadata SET Overview = '', Images = '[]', Links = '[]'; UPDATE Albums SET Overview = '', Images = '[]', Links = '[]'; VACUUM;" + log "seed written to $ARR_SEED" } diff --git a/e2e/scripts/setup-test-data.sh b/e2e/scripts/setup-test-data.sh index afa29929..93c91422 100644 --- a/e2e/scripts/setup-test-data.sh +++ b/e2e/scripts/setup-test-data.sh @@ -16,13 +16,14 @@ ARR_SEED="$HERE/arr-seed" rm -rf "$TEST_DATA/torznab-src" # Media an arr imports during a run, wiped so its library always starts empty. -rm -rf "$TEST_DATA/sonarr-tv" "$TEST_DATA/radarr-movies" "$TEST_DATA/whisparr-movies" "$TEST_DATA/lazylibrarian-books" +rm -rf "$TEST_DATA/sonarr-tv" "$TEST_DATA/radarr-movies" "$TEST_DATA/whisparr-movies" "$TEST_DATA/lidarr-music" "$TEST_DATA/lazylibrarian-books" mkdir -p \ "$TEST_DATA/torznab-src" \ "$TEST_DATA/sonarr-tv" \ "$TEST_DATA/radarr-movies" \ "$TEST_DATA/whisparr-movies" \ + "$TEST_DATA/lidarr-music" \ "$TEST_DATA/lazylibrarian-books" \ "$TEST_DATA/downloads/qbittorrent" \ "$TEST_DATA/downloads/transmission" \ @@ -101,9 +102,9 @@ script = Default priority = 0 EOF -# Restore Sonarr, Radarr and Whisparr from the committed seed. +# Restore Sonarr, Radarr, Whisparr and Lidarr from the committed seed. # The arrs write to /config on every start, so the run gets a throwaway copy. -for arr in sonarr radarr whisparr; do +for arr in sonarr radarr whisparr lidarr; do if [[ -d "$ARR_SEED/$arr" ]]; then rm -rf "$TEST_DATA/$arr-config" cp -R "$ARR_SEED/$arr" "$TEST_DATA/$arr-config" diff --git a/e2e/tests/helpers/live-arr.ts b/e2e/tests/helpers/live-arr.ts index 81763023..63d0a85b 100644 --- a/e2e/tests/helpers/live-arr.ts +++ b/e2e/tests/helpers/live-arr.ts @@ -2,7 +2,7 @@ import { TEST_CONFIG } from './test-config'; import { WireMockClient } from './mocks/wiremock-client'; /** - * Direct access to the real Sonarr, Radarr and Whisparr v3 containers. + * Direct access to the real Sonarr, Radarr, Whisparr v3 and Lidarr containers. * * The specs drive Cleanuparr through its own API, like every other spec. * This is the other side of the contract: what the arr itself ended up with. @@ -18,6 +18,8 @@ export interface LiveArrQueueRecord { seriesId?: number; seasonNumber?: number; movieId?: number; + artistId?: number; + albumId?: number; } export interface LiveArrCommand { @@ -32,6 +34,7 @@ export class LiveArr { readonly url: string, private readonly apiKey: string, private readonly queueQuery: string, + readonly apiVersion: string = 'v3', ) {} get(path: string): Promise { @@ -52,14 +55,14 @@ export class LiveArr { /** Creates the tag if the arr does not have it yet, and returns its id. */ async ensureTag(label: string): Promise { - const tags = await this.get>('/api/v3/tag'); + const tags = await this.get>(`/api/${this.apiVersion}/tag`); const existing = tags.find((tag) => tag.label === label); if (existing) { return existing.id; } - return (await this.post<{ id: number }>('/api/v3/tag', { label })).id; + return (await this.post<{ id: number }>(`/api/${this.apiVersion}/tag`, { label })).id; } private async request(path: string, init?: RequestInit): Promise { @@ -83,19 +86,19 @@ export class LiveArr { } async queuePage(page = 1): Promise<{ records?: LiveArrQueueRecord[]; totalRecords: number }> { - return (await this.request(`/api/v3/queue?page=${page}&pageSize=200&${this.queueQuery}`)) as { + return (await this.request(`/api/${this.apiVersion}/queue?page=${page}&pageSize=200&${this.queueQuery}`)) as { records?: LiveArrQueueRecord[]; totalRecords: number; }; } async commands(): Promise { - return (await this.request('/api/v3/command')) as LiveArrCommand[]; + return (await this.request(`/api/${this.apiVersion}/command`)) as LiveArrCommand[]; } /** The arr only queues a grab on its own refresh, which runs once a minute. */ async refreshMonitoredDownloads(): Promise { - await this.request('/api/v3/command', { + await this.request(`/api/${this.apiVersion}/command`, { method: 'POST', body: JSON.stringify({ name: 'RefreshMonitoredDownloads' }), }); @@ -105,7 +108,7 @@ export class LiveArr { async clearQueue(): Promise { for (const record of await this.queue()) { await this.request( - `/api/v3/queue/${record.id}?removeFromClient=true&blocklist=false&skipRedownload=true`, + `/api/${this.apiVersion}/queue/${record.id}?removeFromClient=true&blocklist=false&skipRedownload=true`, { method: 'DELETE' }, ); } @@ -147,5 +150,12 @@ export const liveWhisparr = new LiveArr( 'includeUnknownMovieItems=true&includeMovie=true', ); +export const liveLidarr = new LiveArr( + TEST_CONFIG.liveArr.lidarrUrl, + TEST_CONFIG.liveArr.lidarrApiKey, + 'includeUnknownArtistItems=true&includeArtist=true&includeAlbum=true', + 'v1', +); + /** The fake Torznab indexer, kept out of MockServers: other suites lack its container. */ export const indexerMock = new WireMockClient(TEST_CONFIG.mocks.indexerAdminUrl); diff --git a/e2e/tests/helpers/mocks/torznab-stubs.ts b/e2e/tests/helpers/mocks/torznab-stubs.ts index acbf3d78..e6de149e 100644 --- a/e2e/tests/helpers/mocks/torznab-stubs.ts +++ b/e2e/tests/helpers/mocks/torznab-stubs.ts @@ -10,13 +10,14 @@ import type { Mapping } from './wiremock-client'; * A test adds the release it wants grabbed, plus the torrent that release points at. */ -/** Torznab search mode. Sonarr sends `tvsearch`, Radarr sends `movie`. */ -export type TorznabSearchMode = 'tvsearch' | 'movie' | 'search'; +/** Torznab search mode. Sonarr sends `tvsearch`, Radarr sends `movie`, Lidarr sends `music`. */ +export type TorznabSearchMode = 'tvsearch' | 'movie' | 'music' | 'search'; /** Torznab category. The seeded arrs are configured for the parent categories. */ export const TORZNAB_TV_CATEGORY = 5040; export const TORZNAB_MOVIE_CATEGORY = 2040; export const TORZNAB_XXX_CATEGORY = 6040; +export const TORZNAB_AUDIO_CATEGORY = 3040; /** * The advertised release size. @@ -120,6 +121,11 @@ export interface GrabbableRelease { mappings: Mapping[]; } +/** Keeps the link path free of characters the arr would percent-encode, which WireMock matches raw. */ +function torrentFile(title: string): string { + return `${title.replace(/[^A-Za-z0-9.-]/g, '.')}.torrent`; +} + /** * Builds a release the arr can grab: a torrent, a search hit, a download link. * @@ -131,7 +137,7 @@ export function grabbableRelease( title: string, category: number, ): GrabbableRelease { - const file = `${title}.torrent`; + const file = torrentFile(title); const torrent = buildSingleFileTorrent(TORRENT_SOURCE_DIR, title, 32_768, 'http://127.0.0.1:6969/announce'); return { @@ -162,7 +168,7 @@ export function grabbableReleases( mappings: [ torznabSearchStub( mode, - built.map((entry) => ({ title: entry.title, category, file: `${entry.title}.torrent` })), + built.map((entry) => ({ title: entry.title, category, file: torrentFile(entry.title) })), ), // Index 1 of each release's mappings is its own torrent stub. ...built.map((entry) => entry.release.mappings[1]), diff --git a/e2e/tests/helpers/seeker-live.ts b/e2e/tests/helpers/seeker-live.ts index 4d2204b5..e45284da 100644 --- a/e2e/tests/helpers/seeker-live.ts +++ b/e2e/tests/helpers/seeker-live.ts @@ -6,9 +6,14 @@ import { buildSingleFileTorrent } from './torrent-fixtures'; import type { CleanuparrApi } from './api'; import { buildDownloadClientPayload } from './api/download-client'; import type { ArrType } from './api/arr'; -import { LiveArr, indexerMock, liveRadarr, liveSonarr, liveWhisparr } from './live-arr'; +import { LiveArr, indexerMock, liveLidarr, liveRadarr, liveSonarr, liveWhisparr } from './live-arr'; import { QBittorrentDriver } from './torrent-clients/qbittorrent'; -import { TORZNAB_MOVIE_CATEGORY, TORZNAB_TV_CATEGORY, TORZNAB_XXX_CATEGORY } from './mocks/torznab-stubs'; +import { + TORZNAB_AUDIO_CATEGORY, + TORZNAB_MOVIE_CATEGORY, + TORZNAB_TV_CATEGORY, + TORZNAB_XXX_CATEGORY, +} from './mocks/torznab-stubs'; import { mkdirShared, writeFileShared } from './shared-volume'; /** @@ -54,13 +59,13 @@ export interface SeededArr { url: string; apiKey: string; version: number; - searchMode: 'tvsearch' | 'movie'; + searchMode: 'tvsearch' | 'movie' | 'music'; category: number; release: string; /** Id of the seeded library item, which is always 1 in a freshly restored seed. */ itemId: number; /** The arr endpoint holding that item. */ - itemPath: 'series' | 'movie'; + itemPath: 'series' | 'movie' | 'artist'; /** Title the seed gave that item. */ seededTitle: string; /** Download client category the arr grabs into, set by the seed. */ @@ -112,6 +117,21 @@ export const WHISPARR: SeededArr = { downloadCategory: 'whisparr', }; +export const LIDARR: SeededArr = { + type: 'lidarr', + arr: liveLidarr, + url: TEST_CONFIG.liveArr.lidarrUrl, + apiKey: TEST_CONFIG.liveArr.lidarrApiKey, + version: 3, + searchMode: 'music', + category: TORZNAB_AUDIO_CATEGORY, + release: `${TEST_CONFIG.liveArr.seededArtistName} - ${TEST_CONFIG.liveArr.seededAlbumTitle} (1994) [FLAC]-E2E${RUN_TAG}`, + itemId: 1, + itemPath: 'artist', + seededTitle: TEST_CONFIG.liveArr.seededArtistName, + downloadCategory: 'lidarr', +}; + export const qbittorrent = new QBittorrentDriver(); /** Outside the qBittorrent save path, so a decoy download never finds its data. */ @@ -259,7 +279,7 @@ export async function teardownInstances(api: CleanuparrApi): Promise { * A spec takes this in its setup and restores it in its teardown. */ export async function snapshotLibrary(target: SeededArr): Promise>> { - return target.arr.get>>(`/api/v3/${target.itemPath}`); + return target.arr.get>>(`/api/${target.arr.apiVersion}/${target.itemPath}`); } /** @@ -271,7 +291,7 @@ export async function snapshotLibrary(target: SeededArr): Promise>): Promise { for (const item of snapshot) { - await target.arr.put(`/api/v3/${target.itemPath}/${item.id}`, item); + await target.arr.put(`/api/${target.arr.apiVersion}/${target.itemPath}/${item.id}`, item); } const restored = await snapshotLibrary(target); @@ -311,8 +331,18 @@ export async function waitForArrQueue(arr: LiveArr, downloadId: string, timeoutM */ export async function resetDownloads(): Promise { await qbittorrent.clearAllTorrents(); - await Promise.all([liveSonarr.clearQueue(), liveRadarr.clearQueue(), liveWhisparr.clearQueue()]); - await Promise.all([waitForEmptyQueue(liveSonarr), waitForEmptyQueue(liveRadarr), waitForEmptyQueue(liveWhisparr)]); + await Promise.all([ + liveSonarr.clearQueue(), + liveRadarr.clearQueue(), + liveWhisparr.clearQueue(), + liveLidarr.clearQueue(), + ]); + await Promise.all([ + waitForEmptyQueue(liveSonarr), + waitForEmptyQueue(liveRadarr), + waitForEmptyQueue(liveWhisparr), + waitForEmptyQueue(liveLidarr), + ]); } async function waitForEmptyQueue(arr: LiveArr, timeoutMs = 120_000): Promise { diff --git a/e2e/tests/helpers/test-config.ts b/e2e/tests/helpers/test-config.ts index 63c1539d..3d7668f9 100644 --- a/e2e/tests/helpers/test-config.ts +++ b/e2e/tests/helpers/test-config.ts @@ -26,7 +26,7 @@ export const TEST_CONFIG = { indexerAdminUrl: 'http://localhost:9500/__admin', }, - // The real Sonarr, Radarr and Whisparr v3 containers. + // The real Sonarr, Radarr, Whisparr v3 and Lidarr containers. // Their api keys come from the committed seed in e2e/arr-seed. liveArr: { sonarrUrl: 'http://localhost:8989', @@ -38,6 +38,10 @@ export const TEST_CONFIG = { whisparrUrl: 'http://localhost:6970', whisparrApiKey: '0000000000000000000000000000e2e6', seededWhisparrTitle: 'Package Holiday', + lidarrUrl: 'http://localhost:8686', + lidarrApiKey: '0000000000000000000000000000e2e7', + seededArtistName: 'Jeff Buckley', + seededAlbumTitle: 'Grace', }, // Real qBittorrent container: the API key seeded into diff --git a/e2e/wiremock-indexer/__files/torznab-caps.xml b/e2e/wiremock-indexer/__files/torznab-caps.xml index 33ad55cb..c8dc4fb0 100644 --- a/e2e/wiremock-indexer/__files/torznab-caps.xml +++ b/e2e/wiremock-indexer/__files/torznab-caps.xml @@ -6,14 +6,18 @@ - - + + + + + +