@muhammedaksam/easiarr 0.8.4 → 0.8.5

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muhammedaksam/easiarr",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "description": "TUI tool for generating docker-compose files for the *arr media ecosystem with 41 apps, TRaSH Guides best practices, VPN routing, and Traefik reverse proxy support",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -136,7 +136,7 @@ export class JellyfinClient {
136
136
  "Content-Type": "application/json",
137
137
  // Jellyfin requires client identification
138
138
  "X-Emby-Authorization":
139
- 'MediaBrowser Client="Easiarr", Device="Server", DeviceId="easiarr-setup", Version="1.0.0"' +
139
+ 'MediaBrowser Client="easiarr", Device="Server", DeviceId="easiarr-setup", Version="1.0.0"' +
140
140
  (this.accessToken ? `, Token="${this.accessToken}"` : ""),
141
141
  ...((options.headers as Record<string, string>) || {}),
142
142
  }
@@ -583,17 +583,20 @@ export const APPS: Record<AppId, AppDefinition> = {
583
583
  volumes: (root) => [`${root}/config/ddns-updater:/data`],
584
584
  },
585
585
 
586
- "easiarr-status": {
587
- id: "easiarr-status",
588
- name: "Easiarr Status",
589
- description: "Exposes Easiarr version for Homepage dashboard",
586
+ easiarr: {
587
+ id: "easiarr",
588
+ name: "easiarr",
589
+ description: "Exposes easiarr config and bookmarks for Homepage dashboard",
590
590
  category: "utility",
591
591
  defaultPort: 3010,
592
592
  internalPort: 8080,
593
593
  image: "halverneus/static-file-server:latest",
594
594
  puid: 0,
595
595
  pgid: 0,
596
- volumes: () => ["${HOME}/.easiarr/config.json:/web/config.json:ro"],
596
+ volumes: () => [
597
+ "${HOME}/.easiarr/config.json:/web/config.json:ro",
598
+ "${HOME}/.easiarr/bookmarks.html:/web/bookmarks.html:ro",
599
+ ],
597
600
  environment: {
598
601
  FOLDER: "/web",
599
602
  CORS: "true",
@@ -602,7 +605,7 @@ export const APPS: Record<AppId, AppDefinition> = {
602
605
  icon: "mdi-docker",
603
606
  widget: "customapi",
604
607
  widgetFields: {
605
- url: "http://easiarr-status:8080/config.json",
608
+ url: "http://easiarr:8080/config.json",
606
609
  mappings: JSON.stringify([{ field: "version", label: "Installed" }]),
607
610
  },
608
611
  },
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Docker Compose Generator
3
- * Generates docker-compose.yml from Easiarr configuration
3
+ * Generates docker-compose.yml from easiarr configuration
4
4
  */
5
5
 
6
6
  import { writeFile } from "node:fs/promises"
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Bookmarks Generator
3
+ * Generates Netscape-format HTML bookmarks for browser import
4
+ */
5
+
6
+ import { writeFile } from "node:fs/promises"
7
+ import { join } from "node:path"
8
+ import { homedir } from "node:os"
9
+ import type { EasiarrConfig, AppCategory } from "./schema"
10
+ import { APP_CATEGORIES } from "./schema"
11
+ import { CATEGORY_ORDER } from "../apps/categories"
12
+ import { getApp } from "../apps/registry"
13
+ import { readEnvSync } from "../utils/env"
14
+
15
+ interface BookmarkEntry {
16
+ name: string
17
+ url: string
18
+ description: string
19
+ }
20
+
21
+ type CategoryBookmarks = Map<AppCategory, BookmarkEntry[]>
22
+
23
+ /**
24
+ * Get the URL for an app based on Traefik configuration
25
+ */
26
+ function getAppUrl(appId: string, port: number, config: EasiarrConfig, useLocalUrls: boolean): string {
27
+ if (!useLocalUrls && config.traefik?.enabled && config.traefik.domain) {
28
+ return `https://${appId}.${config.traefik.domain}/`
29
+ }
30
+ // Read LOCAL_DOCKER_IP from .env file, fallback to localhost
31
+ const env = readEnvSync()
32
+ const host = env.LOCAL_DOCKER_IP || "localhost"
33
+ return `http://${host}:${port}/`
34
+ }
35
+
36
+ /**
37
+ * Generate bookmark entries grouped by category
38
+ */
39
+ function generateBookmarksByCategory(config: EasiarrConfig, useLocalUrls: boolean): CategoryBookmarks {
40
+ const categoryBookmarks: CategoryBookmarks = new Map()
41
+
42
+ for (const appConfig of config.apps) {
43
+ if (!appConfig.enabled) continue
44
+
45
+ const appDef = getApp(appConfig.id)
46
+ if (!appDef) continue
47
+
48
+ const port = appConfig.port ?? appDef.defaultPort
49
+ const url = getAppUrl(appConfig.id, port, config, useLocalUrls)
50
+
51
+ const entry: BookmarkEntry = {
52
+ name: appDef.name,
53
+ url,
54
+ description: appDef.description,
55
+ }
56
+
57
+ const category = appDef.category
58
+ if (!categoryBookmarks.has(category)) {
59
+ categoryBookmarks.set(category, [])
60
+ }
61
+ categoryBookmarks.get(category)!.push(entry)
62
+ }
63
+
64
+ return categoryBookmarks
65
+ }
66
+
67
+ /**
68
+ * Generate Netscape-format HTML bookmarks
69
+ */
70
+ export function generateBookmarksHtml(config: EasiarrConfig, useLocalUrls = false): string {
71
+ const categoryBookmarks = generateBookmarksByCategory(config, useLocalUrls)
72
+
73
+ let html = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
74
+ <META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
75
+ <TITLE>Bookmarks</TITLE>
76
+ <H1>Bookmarks</H1>
77
+ <DL><p>
78
+ <DT><H3 PERSONAL_TOOLBAR_FOLDER="true">easiarr</H3>
79
+ <DL><p>
80
+ `
81
+
82
+ // Add external resources first
83
+ html += ` <DT><A HREF="https://github.com/muhammedaksam/easiarr/">GitHub | easiarr Project Repo</A>\n`
84
+ html += ` <DT><A HREF="https://trash-guides.info/">TRaSH Guides</A>\n`
85
+
86
+ // Add apps grouped by category in defined order
87
+ for (const { id: categoryId } of CATEGORY_ORDER) {
88
+ const bookmarks = categoryBookmarks.get(categoryId)
89
+ if (!bookmarks || bookmarks.length === 0) continue
90
+
91
+ const categoryName = APP_CATEGORIES[categoryId]
92
+
93
+ // Add category header as a folder
94
+ html += ` <DT><H3>${categoryName}</H3>\n`
95
+ html += ` <DL><p>\n`
96
+
97
+ for (const bookmark of bookmarks) {
98
+ html += ` <DT><A HREF="${bookmark.url}">${bookmark.name} | ${bookmark.description}</A>\n`
99
+ }
100
+
101
+ html += ` </DL><p>\n`
102
+ }
103
+
104
+ // Close the structure
105
+ html += ` </DL><p>
106
+ </DL><p>
107
+ `
108
+
109
+ return html
110
+ }
111
+
112
+ /**
113
+ * Get the path to the bookmarks file
114
+ */
115
+ export function getBookmarksPath(): string {
116
+ return join(homedir(), ".easiarr", "bookmarks.html")
117
+ }
118
+
119
+ /**
120
+ * Save bookmarks HTML file
121
+ */
122
+ export async function saveBookmarks(config: EasiarrConfig, useLocalUrls = false): Promise<string> {
123
+ const html = generateBookmarksHtml(config, useLocalUrls)
124
+ const path = getBookmarksPath()
125
+ await writeFile(path, html, "utf-8")
126
+ return path
127
+ }
@@ -134,18 +134,18 @@ export async function generateServicesYaml(config: EasiarrConfig): Promise<strin
134
134
  }
135
135
 
136
136
  // Build YAML output
137
- let yaml = "---\n# Auto-generated by Easiarr\n# https://github.com/muhammedaksam/easiarr\n\n"
137
+ let yaml = "---\n# Auto-generated by easiarr\n# https://github.com/muhammedaksam/easiarr\n\n"
138
138
 
139
- // Add Easiarr info section with two widgets - one for installed, one for latest
140
- yaml += `- Easiarr:\n`
141
- // Installed version from local easiarr-status container
139
+ // Add easiarr info section with two widgets - one for installed, one for latest
140
+ yaml += `- easiarr:\n`
141
+ // Installed version from local easiarr container
142
142
  yaml += ` - Installed:\n`
143
143
  yaml += ` href: https://github.com/muhammedaksam/easiarr\n`
144
144
  yaml += ` icon: mdi-docker\n`
145
145
  yaml += ` description: Your current version\n`
146
146
  yaml += ` widget:\n`
147
147
  yaml += ` type: customapi\n`
148
- yaml += ` url: http://easiarr-status:8080/config.json\n`
148
+ yaml += ` url: http://easiarr:8080/config.json\n`
149
149
  yaml += ` refreshInterval: 3600000\n` // 1 hour
150
150
  yaml += ` mappings:\n`
151
151
  yaml += ` - field: version\n`
@@ -237,10 +237,10 @@ export async function generateServicesYaml(config: EasiarrConfig): Promise<strin
237
237
  */
238
238
  export function generateSettingsYaml(): string {
239
239
  return `---
240
- # Auto-generated by Easiarr
240
+ # Auto-generated by easiarr
241
241
  # For configuration options: https://gethomepage.dev/configs/settings/
242
242
 
243
- title: Easiarr Dashboard
243
+ title: easiarr Dashboard
244
244
 
245
245
  # Background: "Close-up Photography of Leaves With Droplets"
246
246
  # Photo by Sohail Nachiti: https://www.pexels.com/photo/close-up-photography-of-leaves-with-droplets-807598/
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Easiarr Configuration Schema
2
+ * easiarr Configuration Schema
3
3
  * TypeScript interfaces for configuration management
4
4
  */
5
5
 
@@ -120,7 +120,7 @@ export type AppId =
120
120
  | "guacamole"
121
121
  | "guacd"
122
122
  | "ddns-updater"
123
- | "easiarr-status"
123
+ | "easiarr"
124
124
  // VPN
125
125
  | "gluetun"
126
126
  // Monitoring & Infra
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Easiarr Entry Point
3
+ * easiarr Entry Point
4
4
  * TUI tool for generating docker-compose files for the *arr ecosystem
5
5
  *
6
6
  * Usage:
@@ -301,7 +301,7 @@ export class JellyfinSetup extends BoxRenderable {
301
301
  // Generate API key
302
302
  this.results[1].status = "configuring"
303
303
  this.refreshContent()
304
- const apiKey = await this.jellyfinClient.createApiKey("Easiarr")
304
+ const apiKey = await this.jellyfinClient.createApiKey("easiarr")
305
305
  if (!apiKey) {
306
306
  throw new Error("Failed to create API key")
307
307
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Main Menu Screen
3
- * Central navigation hub for Easiarr
3
+ * Central navigation hub for easiarr
4
4
  */
5
5
 
6
6
  import type { RenderContext, CliRenderer } from "@opentui/core"
@@ -9,6 +9,8 @@ import type { App } from "../App"
9
9
  import type { EasiarrConfig } from "../../config/schema"
10
10
  import { createPageLayout } from "../components/PageLayout"
11
11
  import { saveCompose } from "../../compose"
12
+ import { saveBookmarks } from "../../config/bookmarks-generator"
13
+ import { openUrl } from "../../utils/browser"
12
14
  import { ApiKeyViewer } from "./ApiKeyViewer"
13
15
  import { AppConfigurator } from "./AppConfigurator"
14
16
  import { TRaSHProfileSetup } from "./TRaSHProfileSetup"
@@ -138,6 +140,34 @@ export class MainMenu {
138
140
  action: () => this.showScreen(JellyseerrSetup),
139
141
  })
140
142
  }
143
+ // Bookmark generation options
144
+ const generateAndOpenBookmarks = async (useLocalUrls: boolean) => {
145
+ await saveBookmarks(this.config, useLocalUrls)
146
+ // Open in browser if easiarr service is enabled
147
+ if (this.isAppEnabled("easiarr")) {
148
+ const port = this.config.apps.find((a) => a.id === "easiarr")?.port ?? 3010
149
+ await openUrl(`http://localhost:${port}/bookmarks.html`)
150
+ }
151
+ }
152
+
153
+ if (this.config.traefik?.enabled) {
154
+ items.push({
155
+ name: "📑 Bookmarks (Local URLs)",
156
+ description: "Generate bookmarks using localhost addresses",
157
+ action: async () => generateAndOpenBookmarks(true),
158
+ })
159
+ items.push({
160
+ name: "📑 Bookmarks (Traefik URLs)",
161
+ description: `Generate bookmarks using ${this.config.traefik.domain} addresses`,
162
+ action: async () => generateAndOpenBookmarks(false),
163
+ })
164
+ } else {
165
+ items.push({
166
+ name: "📑 Generate Bookmarks",
167
+ description: "Create browser-importable bookmarks file",
168
+ action: async () => generateAndOpenBookmarks(true),
169
+ })
170
+ }
141
171
 
142
172
  items.push({
143
173
  name: "❌ Exit",
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Quick Setup Wizard
3
- * First-time setup flow for Easiarr
3
+ * First-time setup flow for easiarr
4
4
  */
5
5
  import { homedir } from "node:os"
6
6
 
@@ -41,7 +41,7 @@ export class QuickSetup {
41
41
  "jellyseerr",
42
42
  "flaresolverr",
43
43
  "homepage",
44
- "easiarr-status",
44
+ "easiarr",
45
45
  ])
46
46
 
47
47
  private rootDir: string = `${homedir()}/media`
@@ -208,7 +208,7 @@ export class QuickSetup {
208
208
  selectedBackgroundColor: "#3a4a6e",
209
209
  options: [
210
210
  { name: "▶ Start Setup", description: "Begin the configuration wizard" },
211
- { name: "📖 About", description: "Learn more about Easiarr" },
211
+ { name: "📖 About", description: "Learn more about easiarr" },
212
212
  { name: "✕ Exit", description: "Quit the application" },
213
213
  ],
214
214
  })
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Browser Utilities
3
+ * Open URLs in the default browser
4
+ */
5
+
6
+ import { $ } from "bun"
7
+ import { platform } from "node:os"
8
+
9
+ /**
10
+ * Open a URL in the default browser
11
+ */
12
+ export async function openUrl(url: string): Promise<void> {
13
+ const os = platform()
14
+
15
+ try {
16
+ if (os === "linux") {
17
+ await $`xdg-open ${url}`.quiet()
18
+ } else if (os === "darwin") {
19
+ await $`open ${url}`.quiet()
20
+ } else if (os === "win32") {
21
+ await $`cmd /c start ${url}`.quiet()
22
+ }
23
+ } catch {
24
+ // Silently fail if browser can't be opened
25
+ }
26
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Debug logging utility for Easiarr
2
+ * Debug logging utility for easiarr
3
3
  *
4
4
  * Enable debug logging via:
5
5
  * - CLI flag: easiarr --debug
@@ -25,7 +25,7 @@ const logFile = join(easiarrDir, "debug.log")
25
25
  export function initDebug(): void {
26
26
  if (!DEBUG_ENABLED) return
27
27
  try {
28
- writeFileSync(logFile, `=== Easiarr Debug Log - ${new Date().toISOString()} ===\n`)
28
+ writeFileSync(logFile, `=== easiarr Debug Log - ${new Date().toISOString()} ===\n`)
29
29
  } catch {
30
30
  // Ignore
31
31
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Migration: rename easiarr-status to easiarr
3
+ *
4
+ * OLD: { "id": "easiarr-status", "enabled": true }
5
+ * NEW: { "id": "easiarr", "enabled": true }
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
9
+ import { join } from "node:path"
10
+ import { homedir } from "node:os"
11
+ import { debugLog } from "../debug"
12
+
13
+ const CONFIG_FILE = join(homedir(), ".easiarr", "config.json")
14
+
15
+ export const name = "rename_easiarr_status"
16
+
17
+ export function up(): boolean {
18
+ if (!existsSync(CONFIG_FILE)) {
19
+ debugLog("Migrations", "No config.json file found, skipping migration")
20
+ return false
21
+ }
22
+
23
+ try {
24
+ const content = readFileSync(CONFIG_FILE, "utf-8")
25
+ const config = JSON.parse(content)
26
+
27
+ if (!config.apps || !Array.isArray(config.apps)) {
28
+ debugLog("Migrations", "No apps array in config, skipping migration")
29
+ return false
30
+ }
31
+
32
+ let changed = false
33
+
34
+ for (const app of config.apps) {
35
+ if (app.id === "easiarr-status") {
36
+ app.id = "easiarr"
37
+ changed = true
38
+ debugLog("Migrations", "Renamed easiarr-status → easiarr")
39
+ }
40
+ }
41
+
42
+ if (changed) {
43
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8")
44
+ debugLog("Migrations", "Migration completed: app id renamed")
45
+ return true
46
+ }
47
+
48
+ debugLog("Migrations", "No changes needed")
49
+ return false
50
+ } catch (e) {
51
+ debugLog("Migrations", `Migration error: ${e}`)
52
+ return false
53
+ }
54
+ }
55
+
56
+ export function down(): boolean {
57
+ if (!existsSync(CONFIG_FILE)) {
58
+ return false
59
+ }
60
+
61
+ try {
62
+ const content = readFileSync(CONFIG_FILE, "utf-8")
63
+ const config = JSON.parse(content)
64
+
65
+ if (!config.apps || !Array.isArray(config.apps)) {
66
+ return false
67
+ }
68
+
69
+ let changed = false
70
+
71
+ for (const app of config.apps) {
72
+ if (app.id === "easiarr") {
73
+ app.id = "easiarr-status"
74
+ changed = true
75
+ debugLog("Migrations", "Rolled back easiarr → easiarr-status")
76
+ }
77
+ }
78
+
79
+ if (changed) {
80
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8")
81
+ debugLog("Migrations", "Rollback completed: app id restored")
82
+ return true
83
+ }
84
+
85
+ return false
86
+ } catch (e) {
87
+ debugLog("Migrations", `Rollback error: ${e}`)
88
+ return false
89
+ }
90
+ }
@@ -69,6 +69,18 @@ async function loadMigrations(): Promise<Migration[]> {
69
69
  debugLog("Migrations", `Failed to load migration: ${e}`)
70
70
  }
71
71
 
72
+ try {
73
+ const m1765707135 = await import("./migrations/1765707135_rename_easiarr_status")
74
+ migrations.push({
75
+ timestamp: "1765707135",
76
+ name: m1765707135.name,
77
+ up: m1765707135.up,
78
+ down: m1765707135.down,
79
+ })
80
+ } catch (e) {
81
+ debugLog("Migrations", `Failed to load migration: ${e}`)
82
+ }
83
+
72
84
  // Add future migrations here:
73
85
  // const m2 = await import("./migrations/1734xxxxxx_xxx")
74
86
  // migrations.push({ timestamp: "...", name: m2.name, up: m2.up })