Skip to content

Commit abb2506

Browse files
feat: add CRUD operations to CLI (add, edit, refresh, manual-search) (#86)
* feat: add CRUD operations to CLI (add, edit, refresh, manual-search) Add interactive add/edit/refresh/manual-search commands for all Servarr services. The add command guides users through searching, selecting quality profiles and root folders. Edit supports modifying monitored status and quality profile. Refresh and manual-search trigger metadata updates and release searches via service commands. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * feat: add tags support to edit commands and add CRUD tests Add --tags flag (comma-separated tag IDs) to edit commands for all services. Add unit tests covering edit logic, refresh/manual-search commands, and type handling. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: restore generated clients and bazarr routing * fix: align CRUD branch with main types --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 846c9e3 commit abb2506

112 files changed

Lines changed: 62067 additions & 94 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,6 @@ dist
174174
# Finder (MacOS) folder config
175175
.DS_Store
176176

177-
# Generated API clients
178-
src/generated/
179-
180177
# TypeDoc documentation output
181178
dist/docs/
182179

biome.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
{
2-
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
2+
"$schema": "https://biomejs.dev/schemas/2.3.3/schema.json",
33
"vcs": {
44
"enabled": true,
55
"clientKind": "git",
66
"useIgnoreFile": true
77
},
88
"files": {
99
"ignoreUnknown": false,
10-
"includes": ["**/*"],
11-
"experimentalScannerIgnores": [
12-
"dist/**",
13-
"node_modules/**",
14-
"src/generated/**",
15-
"examples/**",
16-
"*.generated.ts"
10+
"includes": [
11+
"**/*",
12+
"!!**/dist",
13+
"!!**/node_modules",
14+
"!!**/src/generated",
15+
"!!**/examples",
16+
"!!**/*.generated.ts"
1717
]
1818
},
1919
"formatter": {

scripts/generate.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
#!/usr/bin/env bun
22

3+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4+
import { tmpdir } from 'node:os';
5+
import { join } from 'node:path';
36
import { createClient } from '@hey-api/openapi-ts';
47

58
interface ServarrApp {
@@ -62,22 +65,68 @@ const SERVARR_APPS: ServarrApp[] = [
6265

6366
async function generateClient(app: ServarrApp) {
6467
const url = process.env[app.envVar] || app.defaultUrl;
68+
const input = prepareSpecInput(app, url);
6569

66-
console.log(`📡 Generating ${app.name} client from: ${url}`);
70+
console.log(`📡 Generating ${app.name} client from: ${input.display}`);
6771

6872
try {
6973
await createClient({
70-
input: url,
74+
input: input.path,
7175
output: app.outputPath,
7276
});
7377

7478
console.log(`✅ ${app.name} client generated successfully!`);
7579
} catch (error) {
7680
console.error(`❌ Failed to generate ${app.name} client:`, error);
7781
throw error;
82+
} finally {
83+
input.cleanup?.();
7884
}
7985
}
8086

87+
function prepareSpecInput(
88+
app: ServarrApp,
89+
inputPath: string
90+
): {
91+
path: string;
92+
display: string;
93+
cleanup?: () => void;
94+
} {
95+
if (app.name !== 'Bazarr') {
96+
return { path: inputPath, display: inputPath };
97+
}
98+
99+
const spec = JSON.parse(readFileSync(inputPath, 'utf-8')) as {
100+
basePath?: string;
101+
paths?: Record<string, unknown>;
102+
};
103+
104+
if (!spec.basePath || !spec.paths) {
105+
return { path: inputPath, display: inputPath };
106+
}
107+
108+
const basePath = spec.basePath.endsWith('/') ? spec.basePath.slice(0, -1) : spec.basePath;
109+
const rewrittenPaths = Object.fromEntries(
110+
Object.entries(spec.paths).map(([path, value]) => [`${basePath}${path}`, value])
111+
);
112+
113+
const normalizedSpec = {
114+
...spec,
115+
paths: rewrittenPaths,
116+
};
117+
delete normalizedSpec.basePath;
118+
119+
const tempDir = mkdtempSync(join(tmpdir(), 'tsarr-bazarr-spec-'));
120+
const tempPath = join(tempDir, 'bazarr-openapi.normalized.json');
121+
writeFileSync(tempPath, `${JSON.stringify(normalizedSpec, null, 2)}\n`);
122+
123+
return {
124+
path: tempPath,
125+
display: `${inputPath} (normalized with ${basePath})`,
126+
cleanup: () => rmSync(tempDir, { force: true, recursive: true }),
127+
};
128+
}
129+
81130
async function generateAllClients() {
82131
console.log('🚀 Generating all Servarr API clients...');
83132

src/cli/commands/doctor.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,6 @@ interface DoctorResult {
2727
error?: string;
2828
}
2929

30-
function getDoctorVersion(status: any): string | undefined {
31-
return (
32-
status?.data?.data?.version ??
33-
status?.data?.data?.bazarr_version ??
34-
status?.data?.version ??
35-
status?.version ??
36-
status?.data?.bazarr_version ??
37-
status?.bazarr_version ??
38-
undefined
39-
);
40-
}
41-
4230
export const doctor = defineCommand({
4331
meta: {
4432
name: 'doctor',
@@ -84,7 +72,10 @@ export const doctor = defineCommand({
8472
}
8573
const client = factory(svcConfig);
8674
const status = await client.getSystemStatus();
87-
const version = getDoctorVersion(status) ?? '?';
75+
const version = extractVersion(service, status) ?? '?';
76+
if (version === '?') {
77+
throw new Error('Unexpected response payload');
78+
}
8879
results.push({
8980
service,
9081
configured: true,
@@ -115,3 +106,17 @@ export const doctor = defineCommand({
115106
});
116107
},
117108
});
109+
110+
function extractVersion(service: string, status: unknown): string | null {
111+
const data = (status as any)?.data ?? status;
112+
113+
if (typeof data === 'string') {
114+
return null;
115+
}
116+
117+
if (service === 'bazarr') {
118+
return data?.data?.bazarr_version ?? data?.bazarr_version ?? null;
119+
}
120+
121+
return data?.version ?? (status as any)?.version ?? null;
122+
}

src/cli/commands/lidarr.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { LidarrClient } from '../../clients/lidarr.js';
2+
import { promptConfirm, promptSelect } from '../prompt.js';
23
import type { ResourceDef } from './service.js';
34
import { buildServiceCommand } from './service.js';
45

@@ -26,6 +27,93 @@ const resources: ResourceDef[] = [
2627
columns: ['foreignArtistId', 'artistName', 'overview'],
2728
run: (c: LidarrClient, a) => c.searchArtists(a.term),
2829
},
30+
{
31+
name: 'add',
32+
description: 'Search and add an artist',
33+
args: [{ name: 'term', description: 'Search term', required: true }],
34+
run: async (c: LidarrClient, a) => {
35+
const searchResult = await c.searchArtists(a.term);
36+
const results = searchResult?.data ?? searchResult;
37+
if (!Array.isArray(results) || results.length === 0) {
38+
throw new Error('No artists found.');
39+
}
40+
const artistId = await promptSelect(
41+
'Select an artist:',
42+
results.map((ar: any) => ({
43+
label: ar.artistName,
44+
value: String(ar.foreignArtistId),
45+
}))
46+
);
47+
const artist = results.find((ar: any) => String(ar.foreignArtistId) === artistId);
48+
if (!artist) {
49+
throw new Error('Selected artist was not found in the search results.');
50+
}
51+
52+
const profilesResult = await c.getQualityProfiles();
53+
const profiles = profilesResult?.data ?? profilesResult;
54+
if (!Array.isArray(profiles) || profiles.length === 0) {
55+
throw new Error('No quality profiles found. Configure one in Lidarr first.');
56+
}
57+
const profileId = await promptSelect(
58+
'Select quality profile:',
59+
profiles.map((p: any) => ({ label: p.name, value: String(p.id) }))
60+
);
61+
62+
const foldersResult = await c.getRootFolders();
63+
const folders = foldersResult?.data ?? foldersResult;
64+
if (!Array.isArray(folders) || folders.length === 0) {
65+
throw new Error('No root folders found. Configure one in Lidarr first.');
66+
}
67+
const rootFolderPath = await promptSelect(
68+
'Select root folder:',
69+
folders.map((f: any) => ({ label: f.path, value: f.path }))
70+
);
71+
72+
const confirmed = await promptConfirm(`Add "${artist.artistName}"?`, !!a.yes);
73+
if (!confirmed) throw new Error('Cancelled.');
74+
75+
return c.addArtist({
76+
...artist,
77+
qualityProfileId: Number(profileId),
78+
rootFolderPath,
79+
monitored: true,
80+
addOptions: { searchForMissingAlbums: true },
81+
});
82+
},
83+
},
84+
{
85+
name: 'edit',
86+
description: 'Edit an artist',
87+
args: [
88+
{ name: 'id', description: 'Artist ID', required: true, type: 'number' },
89+
{ name: 'monitored', description: 'Set monitored (true/false)' },
90+
{ name: 'quality-profile-id', description: 'Quality profile ID', type: 'number' },
91+
{ name: 'tags', description: 'Comma-separated tag IDs' },
92+
],
93+
run: async (c: LidarrClient, a) => {
94+
const result = await c.getArtist(a.id);
95+
const artist = result?.data ?? result;
96+
const updates: any = { ...artist };
97+
if (a.monitored !== undefined) updates.monitored = a.monitored === 'true';
98+
if (a['quality-profile-id'] !== undefined)
99+
updates.qualityProfileId = Number(a['quality-profile-id']);
100+
if (a.tags !== undefined)
101+
updates.tags = a.tags.split(',').map((t: string) => Number(t.trim()));
102+
return c.updateArtist(a.id, updates);
103+
},
104+
},
105+
{
106+
name: 'refresh',
107+
description: 'Refresh artist metadata',
108+
args: [{ name: 'id', description: 'Artist ID', required: true, type: 'number' }],
109+
run: (c: LidarrClient, a) => c.runCommand({ name: 'RefreshArtist', artistId: a.id } as any),
110+
},
111+
{
112+
name: 'manual-search',
113+
description: 'Trigger a manual search for releases',
114+
args: [{ name: 'id', description: 'Artist ID', required: true, type: 'number' }],
115+
run: (c: LidarrClient, a) => c.runCommand({ name: 'ArtistSearch', artistId: a.id } as any),
116+
},
29117
{
30118
name: 'delete',
31119
description: 'Delete an artist',

src/cli/commands/radarr.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { RadarrClient } from '../../clients/radarr.js';
2+
import { promptConfirm, promptSelect } from '../prompt.js';
23
import type { ResourceDef } from './service.js';
34
import { buildServiceCommand } from './service.js';
45

@@ -27,6 +28,92 @@ const resources: ResourceDef[] = [
2728
idField: 'tmdbId',
2829
run: (c: RadarrClient, a) => c.searchMovies(a.term),
2930
},
31+
{
32+
name: 'add',
33+
description: 'Search and add a movie',
34+
args: [{ name: 'term', description: 'Search term', required: true }],
35+
run: async (c: RadarrClient, a) => {
36+
const searchResult = await c.searchMovies(a.term);
37+
const results = searchResult?.data ?? searchResult;
38+
if (!Array.isArray(results) || results.length === 0) {
39+
throw new Error('No movies found.');
40+
}
41+
const movieId = await promptSelect(
42+
'Select a movie:',
43+
results.map((m: any) => ({ label: `${m.title} (${m.year})`, value: String(m.tmdbId) }))
44+
);
45+
const movie = results.find((m: any) => String(m.tmdbId) === movieId);
46+
if (!movie) {
47+
throw new Error('Selected movie was not found in the search results.');
48+
}
49+
50+
const profilesResult = await c.getQualityProfiles();
51+
const profiles = profilesResult?.data ?? profilesResult;
52+
if (!Array.isArray(profiles) || profiles.length === 0) {
53+
throw new Error('No quality profiles found. Configure one in Radarr first.');
54+
}
55+
const profileId = await promptSelect(
56+
'Select quality profile:',
57+
profiles.map((p: any) => ({ label: p.name, value: String(p.id) }))
58+
);
59+
60+
const foldersResult = await c.getRootFolders();
61+
const folders = foldersResult?.data ?? foldersResult;
62+
if (!Array.isArray(folders) || folders.length === 0) {
63+
throw new Error('No root folders found. Configure one in Radarr first.');
64+
}
65+
const rootFolderPath = await promptSelect(
66+
'Select root folder:',
67+
folders.map((f: any) => ({ label: f.path, value: f.path }))
68+
);
69+
70+
const confirmed = await promptConfirm(`Add "${movie.title} (${movie.year})"?`, !!a.yes);
71+
if (!confirmed) throw new Error('Cancelled.');
72+
73+
return c.addMovie({
74+
...movie,
75+
qualityProfileId: Number(profileId),
76+
rootFolderPath,
77+
monitored: true,
78+
addOptions: { searchForMovie: true },
79+
});
80+
},
81+
},
82+
{
83+
name: 'edit',
84+
description: 'Edit a movie',
85+
args: [
86+
{ name: 'id', description: 'Movie ID', required: true, type: 'number' },
87+
{ name: 'monitored', description: 'Set monitored (true/false)' },
88+
{ name: 'quality-profile-id', description: 'Quality profile ID', type: 'number' },
89+
{ name: 'tags', description: 'Comma-separated tag IDs' },
90+
],
91+
run: async (c: RadarrClient, a) => {
92+
const result = await c.getMovie(a.id);
93+
const movie = result?.data ?? result;
94+
const updates: any = { ...movie };
95+
if (a.monitored !== undefined) updates.monitored = a.monitored === 'true';
96+
if (a['quality-profile-id'] !== undefined)
97+
updates.qualityProfileId = Number(a['quality-profile-id']);
98+
if (a.tags !== undefined)
99+
updates.tags = a.tags.split(',').map((t: string) => Number(t.trim()));
100+
return c.updateMovie(a.id, updates);
101+
},
102+
},
103+
{
104+
name: 'refresh',
105+
description: 'Refresh movie metadata',
106+
args: [{ name: 'id', description: 'Movie ID', required: true, type: 'number' }],
107+
run: (c: RadarrClient, a) =>
108+
c.runCommand({ name: 'RefreshMovie', movieIds: [a.id] } as any),
109+
},
110+
{
111+
name: 'manual-search',
112+
description: 'Trigger a manual search for releases',
113+
args: [{ name: 'id', description: 'Movie ID', required: true, type: 'number' }],
114+
run: (c: RadarrClient, a) =>
115+
c.runCommand({ name: 'MoviesSearch', movieIds: [a.id] } as any),
116+
},
30117
{
31118
name: 'delete',
32119
description: 'Delete a movie',

0 commit comments

Comments
 (0)