@tilewise/source-arr
v0.1.2
Published
**The \*arr apps as resources: Radarr's movies, Sonarr's series, and each one's download queue, through their API v3.**
Readme
🎬 @tilewise/source-arr
The *arr apps as resources: Radarr's movies, Sonarr's series, and each one's download queue, through their API v3.
Four resources, each a definition and a source for @tilewise/source's serve.
Every call is typed by the app's own OpenAPI description: its params, its
body and its answer.
| Resource | From | Served |
| ----------------- | -------------- | ------------------------------------ |
| 🎞️ movies | radarrMovies | whole, in memory: every operator |
| 📺 series | sonarrSeries | whole, in memory: every operator |
| 📥 radarr-queue | radarrQueue | paged, filtered and sorted by Radarr |
| 📥 sonarr-queue | sonarrQueue | paged, filtered and sorted by Sonarr |
🔄 Where each query goes
flowchart LR
lib["POST /movies/query<br/>POST /series/query"] --> whole[("GET /api/v3/movie<br/>GET /api/v3/series")]
whole -->|every row| memory["tilewise, in memory<br/>filter · sort · group · page"]
queue["POST /radarr-queue/query"] --> params["the app's params<br/>movieIds · status · protocol"]
params --> paged[("GET /api/v3/queue")]
classDef route fill:#6366F1,stroke:#4338CA,color:#fff
classDef tilewise fill:#0F766E,stroke:#115E59,color:#fff
classDef app fill:#F59E0B,stroke:#B45309,color:#111
class lib,queue route
class memory,params tilewise
class whole,paged app🚀 Quick start
import { router, serve } from '@tilewise/source';
import { radarrMovies, radarrQueue, sonarrQueue, sonarrSeries } from '@tilewise/source-arr';
const radarr = {
url: 'http://localhost:7878',
apiKey: process.env.RADARR_KEY,
};
const sonarr = {
url: 'http://localhost:8989',
apiKey: process.env.SONARR_KEY,
};
const resources = [
radarrMovies({ ...radarr, add: { rootFolderPath: '/movies' } }),
radarrQueue({ ...radarr, remove: { blocklist: true } }),
sonarrSeries({ ...sonarr, add: { rootFolderPath: '/tv' } }),
sonarrQueue(sonarr),
];
const route = router(resources.map(({ definition, source }) => serve(definition, source)));[!TIP] The API key is in each app's Settings → General → Security. Behind a proxy,
urltakes the app's path too:https://home.example/radarr.
🎞️ Movies and series
The apps answer their whole library at once, so tilewise serves its queries in memory: every operator, sort, group, search and page the kinds allow.
// Unwatched dramas, newest first
await movies.query({
filter: {
all: [
{ field: 'genres', op: 'in', value: ['Drama'] },
{ field: 'hasFile', op: 'equals', value: false },
],
},
sort: [{ field: 'year', dir: 'desc' }],
});
// Add a movie where `add` says, searching for it if told
await movies.create({
values: { tmdbId: 603, title: 'The Matrix', qualityProfileId: 4 },
});
// Stop monitoring it
await movies.update(42, { values: { monitored: false } });A change reads the row and sends it back whole, as the apps' PUT takes it,
its files left where they are. A create needs add; without it the resource
creates nothing. A delete removes the files only when remove says so.
| Option | Radarr | Sonarr |
| ---------- | --------------------------------------------- | --------------------------------------------------------------------------------- |
| add | rootFolderPath, searchForMovie, monitor | rootFolderPath, monitor, searchForMissingEpisodes, languageProfileId (v3) |
| remove | deleteFiles, addImportExclusion | deleteFiles, addImportListExclusion |
| resource | 'movies' | 'series' |
| Writes | Radarr | Sonarr |
| ------ | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| create | tmdbId, title, qualityProfileId; monitored, minimumAvailability, year, tags | tvdbId, title, qualityProfileId; seriesType, monitored, seasonFolder, tags |
| update | monitored, qualityProfileId, minimumAvailability, tags | monitored, qualityProfileId, seriesType, seasonFolder, tags |
📥 Queues
A queue is paged, filtered and sorted by the app, as its queue controller does it, so the resource offers only what the controller says exactly:
| Field | Filters | Sorts |
| ------------------ | ----------------------------------------- | ----- |
| movie / series | is, in | |
| status | is, is not, in, none of, empty, not empty | |
| protocol | is (one protocol) | |
| size | | ✅ |
All of a filter's conditions become the values each param may hold in common, so this query:
await queue.query({
filter: {
all: [
{ field: 'status', op: 'in', value: ['downloading', 'queued'] },
{ field: 'status', op: 'notIn', value: ['downloading'] },
{ field: 'movie', op: 'in', value: [1, 2] },
],
},
sort: [{ field: 'size', dir: 'desc' }],
page: { take: 20 },
});…asks Radarr for one status, queued:
GET /api/v3/queue?page=1&pageSize=20&sortKey=size&sortDirection=descending
&includeUnknownMovieItems=true&movieIds=1&movieIds=2&status=queued
X-Api-Key: …A filter no item can meet asks the app nothing. A queue joins by neither any
nor not, and sorts by size alone: the app orders text by its culture, and
breaks ties by progress. Sonarr's queue has each item's episode and
season too.
[!NOTE] Past the last page the apps answer the last page again; the resource answers none there, as the contract says.
Removing a download takes the app's own choices (remove):
| Choice | Default | What |
| ------------------ | ------- | -------------------------------------------------------- |
| removeFromClient | true | it's removed from the download client too |
| blocklist | false | its release is blocklisted, so it isn't grabbed again |
| skipRedownload | false | no other release is searched for after a blocklist |
| changeCategory | false | its category in the client changes, in place of removing |
🔧 Regenerating the types
src/generated is written by openapi-typescript from the OpenAPI
descriptions on Radarr's and Sonarr's develop branches. ESLint and Prettier
skip it, and it isn't edited by hand.
pnpm --filter @tilewise/source-arr generate🗂️ Layout
src/
├── index.ts the package's exports
├── types/ the queue's types, one concept to a file
│ ├── queue-item.ts a queue item and page
│ ├── queue-request.ts a query's part, and the queue call's params
│ ├── queue-removal.ts what a download is removed with
│ └── queue-app.ts what each app gives the shared queue
├── client/
│ └── client.ts an openapi-fetch client per app
├── generated/ pnpm generate; never by hand
│ ├── radarr.ts Radarr's API v3, as types
│ └── sonarr.ts Sonarr's API v3, as types
├── library/
│ └── library.ts what movies and series share
├── queue/
│ ├── queue.ts queueSource
│ └── definition.ts a queue item's fields
├── radarr/
│ ├── movies.ts radarrMovies
│ ├── queue.ts radarrQueue
│ └── radarr.test.ts
├── sonarr/
│ ├── series.ts sonarrSeries
│ ├── queue.ts sonarrQueue
│ └── sonarr.test.ts
└── test/
├── fake-arr.ts a fake app, as the apps' controllers answer
└── fixtures.ts movies and downloads, as the apps answer them🔑 Key files
client/client.ts: one openapi-fetch client per app, at its URL with its key, and the app's answers as ours: a missing row is none, a refused value400 invalidwith the app's own messages.library/library.ts: what Radarr's movies and Sonarr's series share: the whole library in one answer, a row read by its id, a change read first and sent back whole, a removal with the app's own choices.queue/queue.ts: the queue for both apps: conditions turned into the values each param may hold in common, the size sort, the pages, and the last page the apps repeat answered as none.radarr/movies.tsandsonarr/series.ts: each app's own fields, its create and update, and where its calls go. A series' counts come from its statistics.test/fake-arr.ts: a fake app that filters, orders and pages its queue as the real controllers do, the last page again past the end included, so the tests hold the resources to the contract.
🧱 Built on
| Library | Used for |
| --------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| openapi-fetch | each call, typed by the app's own paths |
| openapi-typescript (dev) | writing those types from each app's OpenAPI description |
| change-case | the queue's status labels: downloadClientUnavailable → "Download client unavailable" |
