@muhammedaksam/easiarr 0.1.10 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,198 @@
1
+ /**
2
+ * qBittorrent WebAPI Client
3
+ * Configures qBittorrent settings via API
4
+ * API docs: https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)
5
+ */
6
+
7
+ export interface QBittorrentPreferences {
8
+ save_path?: string
9
+ temp_path_enabled?: boolean
10
+ temp_path?: string
11
+ auto_tmm_enabled?: boolean
12
+ category_changed_tmm_enabled?: boolean
13
+ save_path_changed_tmm_enabled?: boolean
14
+ }
15
+
16
+ export interface QBittorrentCategory {
17
+ name: string
18
+ savePath: string
19
+ }
20
+
21
+ export class QBittorrentClient {
22
+ private baseUrl: string
23
+ private username: string
24
+ private password: string
25
+ private cookie: string | null = null
26
+
27
+ constructor(host: string, port: number, username: string, password: string) {
28
+ this.baseUrl = `http://${host}:${port}`
29
+ this.username = username
30
+ this.password = password
31
+ }
32
+
33
+ /**
34
+ * Authenticate with qBittorrent WebUI
35
+ * POST /api/v2/auth/login
36
+ */
37
+ async login(): Promise<boolean> {
38
+ try {
39
+ const response = await fetch(`${this.baseUrl}/api/v2/auth/login`, {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
42
+ body: `username=${encodeURIComponent(this.username)}&password=${encodeURIComponent(this.password)}`,
43
+ })
44
+
45
+ if (!response.ok) return false
46
+
47
+ // Extract SID cookie from response
48
+ const setCookie = response.headers.get("set-cookie")
49
+ if (setCookie) {
50
+ const match = setCookie.match(/SID=([^;]+)/)
51
+ if (match) {
52
+ this.cookie = `SID=${match[1]}`
53
+ return true
54
+ }
55
+ }
56
+
57
+ // Check response text for "Ok."
58
+ const text = await response.text()
59
+ return text === "Ok."
60
+ } catch {
61
+ return false
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Check if connected to qBittorrent
67
+ */
68
+ async isConnected(): Promise<boolean> {
69
+ try {
70
+ const response = await fetch(`${this.baseUrl}/api/v2/app/version`, {
71
+ headers: this.cookie ? { Cookie: this.cookie } : {},
72
+ })
73
+ return response.ok
74
+ } catch {
75
+ return false
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Get current preferences
81
+ * GET /api/v2/app/preferences
82
+ */
83
+ async getPreferences(): Promise<QBittorrentPreferences> {
84
+ const response = await fetch(`${this.baseUrl}/api/v2/app/preferences`, {
85
+ headers: this.cookie ? { Cookie: this.cookie } : {},
86
+ })
87
+
88
+ if (!response.ok) {
89
+ throw new Error(`Failed to get preferences: ${response.status}`)
90
+ }
91
+
92
+ return response.json()
93
+ }
94
+
95
+ /**
96
+ * Set preferences
97
+ * POST /api/v2/app/setPreferences
98
+ */
99
+ async setPreferences(prefs: QBittorrentPreferences): Promise<void> {
100
+ const response = await fetch(`${this.baseUrl}/api/v2/app/setPreferences`, {
101
+ method: "POST",
102
+ headers: {
103
+ "Content-Type": "application/x-www-form-urlencoded",
104
+ ...(this.cookie ? { Cookie: this.cookie } : {}),
105
+ },
106
+ body: `json=${encodeURIComponent(JSON.stringify(prefs))}`,
107
+ })
108
+
109
+ if (!response.ok) {
110
+ throw new Error(`Failed to set preferences: ${response.status}`)
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Get all categories
116
+ * GET /api/v2/torrents/categories
117
+ */
118
+ async getCategories(): Promise<Record<string, { name: string; savePath: string }>> {
119
+ const response = await fetch(`${this.baseUrl}/api/v2/torrents/categories`, {
120
+ headers: this.cookie ? { Cookie: this.cookie } : {},
121
+ })
122
+
123
+ if (!response.ok) {
124
+ throw new Error(`Failed to get categories: ${response.status}`)
125
+ }
126
+
127
+ return response.json()
128
+ }
129
+
130
+ /**
131
+ * Create a category
132
+ * POST /api/v2/torrents/createCategory
133
+ */
134
+ async createCategory(name: string, savePath: string): Promise<void> {
135
+ const response = await fetch(`${this.baseUrl}/api/v2/torrents/createCategory`, {
136
+ method: "POST",
137
+ headers: {
138
+ "Content-Type": "application/x-www-form-urlencoded",
139
+ ...(this.cookie ? { Cookie: this.cookie } : {}),
140
+ },
141
+ body: `category=${encodeURIComponent(name)}&savePath=${encodeURIComponent(savePath)}`,
142
+ })
143
+
144
+ // 409 means category already exists - that's OK
145
+ if (!response.ok && response.status !== 409) {
146
+ throw new Error(`Failed to create category: ${response.status}`)
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Edit a category's save path
152
+ * POST /api/v2/torrents/editCategory
153
+ */
154
+ async editCategory(name: string, savePath: string): Promise<void> {
155
+ const response = await fetch(`${this.baseUrl}/api/v2/torrents/editCategory`, {
156
+ method: "POST",
157
+ headers: {
158
+ "Content-Type": "application/x-www-form-urlencoded",
159
+ ...(this.cookie ? { Cookie: this.cookie } : {}),
160
+ },
161
+ body: `category=${encodeURIComponent(name)}&savePath=${encodeURIComponent(savePath)}`,
162
+ })
163
+
164
+ if (!response.ok) {
165
+ throw new Error(`Failed to edit category: ${response.status}`)
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Configure qBittorrent for TRaSH Guide compliance
171
+ * Sets proper save paths and creates categories based on enabled apps
172
+ * @param categories - Array of {name, savePath} for each enabled *arr app
173
+ */
174
+ async configureTRaSHCompliant(categories: QBittorrentCategory[] = []): Promise<void> {
175
+ // 1. Set global preferences
176
+ await this.setPreferences({
177
+ save_path: "/data/torrents",
178
+ temp_path_enabled: false,
179
+ auto_tmm_enabled: true,
180
+ category_changed_tmm_enabled: true,
181
+ save_path_changed_tmm_enabled: true,
182
+ })
183
+
184
+ // 2. Create categories for each enabled media type
185
+ for (const cat of categories) {
186
+ try {
187
+ await this.createCategory(cat.name, cat.savePath)
188
+ } catch {
189
+ // Try to update existing category
190
+ try {
191
+ await this.editCategory(cat.name, cat.savePath)
192
+ } catch {
193
+ // Ignore - category might not exist or be locked
194
+ }
195
+ }
196
+ }
197
+ }
198
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Quality Profile API Client
3
+ * Manages Quality Profiles and Custom Format scoring for Radarr/Sonarr
4
+ */
5
+
6
+ export interface QualityItem {
7
+ id?: number
8
+ name?: string
9
+ quality?: {
10
+ id: number
11
+ name: string
12
+ source: string
13
+ resolution: number
14
+ }
15
+ items?: QualityItem[]
16
+ allowed: boolean
17
+ }
18
+
19
+ export interface FormatItem {
20
+ format: number
21
+ name?: string
22
+ score: number
23
+ }
24
+
25
+ export interface QualityProfile {
26
+ id?: number
27
+ name: string
28
+ upgradeAllowed: boolean
29
+ cutoff: number
30
+ minFormatScore: number
31
+ cutoffFormatScore: number
32
+ formatItems: FormatItem[]
33
+ language?: { id: number; name: string }
34
+ items: QualityItem[]
35
+ }
36
+
37
+ export interface QualityDefinition {
38
+ id: number
39
+ quality: {
40
+ id: number
41
+ name: string
42
+ source: string
43
+ resolution: number
44
+ }
45
+ title: string
46
+ weight: number
47
+ minSize: number
48
+ maxSize: number
49
+ preferredSize: number
50
+ }
51
+
52
+ export class QualityProfileClient {
53
+ private baseUrl: string
54
+ private apiKey: string
55
+ private apiVersion: string
56
+
57
+ constructor(host: string, port: number, apiKey: string, apiVersion = "v3") {
58
+ this.baseUrl = `http://${host}:${port}/api/${apiVersion}`
59
+ this.apiKey = apiKey
60
+ this.apiVersion = apiVersion
61
+ }
62
+
63
+ private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
64
+ const url = `${this.baseUrl}${endpoint}`
65
+ const headers: Record<string, string> = {
66
+ "X-Api-Key": this.apiKey,
67
+ "Content-Type": "application/json",
68
+ ...((options.headers as Record<string, string>) || {}),
69
+ }
70
+
71
+ const response = await fetch(url, { ...options, headers })
72
+
73
+ if (!response.ok) {
74
+ throw new Error(`API request failed: ${response.status} ${response.statusText}`)
75
+ }
76
+
77
+ const text = await response.text()
78
+ if (!text) return {} as T
79
+ return JSON.parse(text) as T
80
+ }
81
+
82
+ // Quality Profile methods
83
+ async getQualityProfiles(): Promise<QualityProfile[]> {
84
+ return this.request<QualityProfile[]>("/qualityprofile")
85
+ }
86
+
87
+ async getQualityProfile(id: number): Promise<QualityProfile> {
88
+ return this.request<QualityProfile>(`/qualityprofile/${id}`)
89
+ }
90
+
91
+ async createQualityProfile(profile: Omit<QualityProfile, "id">): Promise<QualityProfile> {
92
+ return this.request<QualityProfile>("/qualityprofile", {
93
+ method: "POST",
94
+ body: JSON.stringify(profile),
95
+ })
96
+ }
97
+
98
+ async updateQualityProfile(id: number, profile: QualityProfile): Promise<QualityProfile> {
99
+ return this.request<QualityProfile>(`/qualityprofile/${id}`, {
100
+ method: "PUT",
101
+ body: JSON.stringify({ ...profile, id }),
102
+ })
103
+ }
104
+
105
+ async deleteQualityProfile(id: number): Promise<void> {
106
+ await this.request(`/qualityprofile/${id}`, { method: "DELETE" })
107
+ }
108
+
109
+ // Quality Definition methods (for size limits)
110
+ async getQualityDefinitions(): Promise<QualityDefinition[]> {
111
+ return this.request<QualityDefinition[]>("/qualitydefinition")
112
+ }
113
+
114
+ async updateQualityDefinitions(definitions: QualityDefinition[]): Promise<QualityDefinition[]> {
115
+ return this.request<QualityDefinition[]>("/qualitydefinition/update", {
116
+ method: "PUT",
117
+ body: JSON.stringify(definitions),
118
+ })
119
+ }
120
+
121
+ // Helper: Get quality by name from existing profiles
122
+ async getQualityIdByName(name: string): Promise<number | null> {
123
+ const profiles = await this.getQualityProfiles()
124
+ if (profiles.length === 0) return null
125
+
126
+ const findQuality = (items: QualityItem[]): number | null => {
127
+ for (const item of items) {
128
+ if (item.quality?.name === name) return item.quality.id
129
+ if (item.items) {
130
+ const found = findQuality(item.items)
131
+ if (found !== null) return found
132
+ }
133
+ }
134
+ return null
135
+ }
136
+
137
+ return findQuality(profiles[0].items)
138
+ }
139
+
140
+ // Create TRaSH-recommended profile
141
+ async createTRaSHProfile(
142
+ name: string,
143
+ cutoffQualityName: string,
144
+ allowedQualities: string[],
145
+ cfScores: Record<string, number> = {}
146
+ ): Promise<QualityProfile> {
147
+ // Get existing profile to clone quality structure
148
+ const existingProfiles = await this.getQualityProfiles()
149
+ if (existingProfiles.length === 0) {
150
+ throw new Error("No existing profiles to clone quality structure from")
151
+ }
152
+
153
+ const baseProfile = existingProfiles[0]
154
+ const cutoffId = await this.getQualityIdByName(cutoffQualityName)
155
+
156
+ if (cutoffId === null) {
157
+ throw new Error(`Quality "${cutoffQualityName}" not found`)
158
+ }
159
+
160
+ // Build quality items with allowed flags
161
+ const setAllowed = (items: QualityItem[]): QualityItem[] => {
162
+ return items.map((item) => {
163
+ if (item.items) {
164
+ return { ...item, items: setAllowed(item.items) }
165
+ }
166
+ return {
167
+ ...item,
168
+ allowed: item.quality ? allowedQualities.includes(item.quality.name) : false,
169
+ }
170
+ })
171
+ }
172
+
173
+ // Get custom formats and map scores
174
+ const formatItems: FormatItem[] = baseProfile.formatItems.map((fi) => ({
175
+ format: fi.format,
176
+ name: fi.name,
177
+ score: cfScores[fi.name || ""] ?? 0,
178
+ }))
179
+
180
+ const newProfile: Omit<QualityProfile, "id"> = {
181
+ name,
182
+ upgradeAllowed: true,
183
+ cutoff: cutoffId,
184
+ minFormatScore: 0,
185
+ cutoffFormatScore: 10000,
186
+ formatItems,
187
+ language: baseProfile.language,
188
+ items: setAllowed(baseProfile.items),
189
+ }
190
+
191
+ return this.createQualityProfile(newProfile)
192
+ }
193
+
194
+ // Update CF scores on existing profile
195
+ async updateProfileCFScores(profileId: number, cfScores: Record<string, number>): Promise<QualityProfile> {
196
+ const profile = await this.getQualityProfile(profileId)
197
+
198
+ profile.formatItems = profile.formatItems.map((fi) => ({
199
+ ...fi,
200
+ score: cfScores[fi.name || ""] ?? fi.score,
201
+ }))
202
+
203
+ return this.updateQualityProfile(profileId, profile)
204
+ }
205
+ }
@@ -138,10 +138,8 @@ export const APPS: Record<AppId, AppDefinition> = {
138
138
  enabledKey: "api_enabled",
139
139
  generateIfMissing: true,
140
140
  },
141
- rootFolder: {
142
- path: "/data/media/comics",
143
- apiVersion: "v1",
144
- },
141
+ // Note: Mylar3 is NOT an *arr app - has different API format (?cmd=<endpoint>)
142
+ // Root folder is configured via Web UI settings, not API
145
143
  },
146
144
 
147
145
  whisparr: {
@@ -246,7 +244,8 @@ export const APPS: Record<AppId, AppDefinition> = {
246
244
  image: "lscr.io/linuxserver/qbittorrent:latest",
247
245
  puid: 13007,
248
246
  pgid: 13000,
249
- volumes: (root) => [`${root}/config/qbittorrent:/config`, `${root}/data/torrents:/data/torrents`],
247
+ // TRaSH: Mount full /data for consistent paths with *arr apps (enables hardlinks)
248
+ volumes: (root) => [`${root}/config/qbittorrent:/config`, `${root}/data:/data`],
250
249
  environment: { WEBUI_PORT: "8080" },
251
250
  trashGuide: "docs/Downloaders/qBittorrent/",
252
251
  },
@@ -260,7 +259,8 @@ export const APPS: Record<AppId, AppDefinition> = {
260
259
  image: "lscr.io/linuxserver/sabnzbd:latest",
261
260
  puid: 13011,
262
261
  pgid: 13000,
263
- volumes: (root) => [`${root}/config/sabnzbd:/config`, `${root}/data/usenet:/data/usenet`],
262
+ // TRaSH: Mount full /data for consistent paths with *arr apps (enables hardlinks)
263
+ volumes: (root) => [`${root}/config/sabnzbd:/config`, `${root}/data:/data`],
264
264
 
265
265
  trashGuide: "docs/Downloaders/SABnzbd/",
266
266
  apiKeyMeta: {
@@ -3,12 +3,12 @@
3
3
  * Generates docker-compose.yml from Easiarr configuration
4
4
  */
5
5
 
6
- import { writeFile, readFile } from "node:fs/promises"
7
- import { existsSync } from "node:fs"
6
+ import { writeFile } from "node:fs/promises"
8
7
  import type { EasiarrConfig, AppConfig, TraefikConfig, AppId } from "../config/schema"
9
8
  import { getComposePath } from "../config/manager"
10
9
  import { getApp } from "../apps/registry"
11
10
  import { generateServiceYaml } from "./templates"
11
+ import { updateEnv } from "../utils/env"
12
12
 
13
13
  export interface ComposeService {
14
14
  image: string
@@ -205,30 +205,12 @@ export async function saveCompose(config: EasiarrConfig): Promise<string> {
205
205
  }
206
206
 
207
207
  async function updateEnvFile(config: EasiarrConfig) {
208
- const envPath = getComposePath().replace("docker-compose.yml", ".env")
209
- let envContent = ""
210
-
211
- // Read existing .env if present to preserve secrets
212
- const currentEnv: Record<string, string> = {}
213
- if (existsSync(envPath)) {
214
- const content = await readFile(envPath, "utf-8")
215
- content.split("\n").forEach((line) => {
216
- const [key, ...val] = line.split("=")
217
- if (key && val) currentEnv[key.trim()] = val.join("=").trim()
218
- })
219
- }
220
-
221
- // Update/Set globals
222
- currentEnv["ROOT_DIR"] = config.rootDir
223
- currentEnv["TIMEZONE"] = config.timezone
224
- currentEnv["PUID"] = config.uid.toString()
225
- currentEnv["PGID"] = config.gid.toString()
226
- currentEnv["UMASK"] = config.umask
227
-
228
- // Reconstruct .env content
229
- envContent = Object.entries(currentEnv)
230
- .map(([k, v]) => `${k}=${v}`)
231
- .join("\n")
232
-
233
- await writeFile(envPath, envContent, "utf-8")
208
+ // Update .env with global configuration values
209
+ await updateEnv({
210
+ ROOT_DIR: config.rootDir,
211
+ TIMEZONE: config.timezone,
212
+ PUID: config.uid.toString(),
213
+ PGID: config.gid.toString(),
214
+ UMASK: config.umask,
215
+ })
234
216
  }