diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 5759920..96949d6 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -853,33 +853,121 @@ don't interrupt the calling code. Queries enabled services from the database, filters by notification type, and dispatches to appropriate notifiers. Records all attempts in history. - **Builder** (`builder.ts`) — Fluent API for constructing notifications. Chain - `.title()`, `.message()`, `.meta()`, and call `.send()`. -- **Notifiers** (`notifiers/`) — Service-specific implementations. Each extends - `BaseHttpNotifier` and formats payloads for their API. + `.generic()` for fallback content, `.discord()` for rich embeds, and call + `.send()` to dispatch. +- **Definitions** (`definitions/`) — Pre-built notification factories for common + events. Import from `definitions/index.ts` and call with parameters. +- **Notifiers** (`notifiers/`) — Service-specific implementations. Currently + only Discord is implemented in `notifiers/discord/`. **Usage** +For simple notifications, use the builder directly: + ```typescript import { notify } from "$notifications/builder.ts"; -import { NotificationTypes } from "$notifications/types.ts"; -await notify(NotificationTypes.PCD_SYNC_SUCCESS) - .title("Sync Complete") - .message("Synced 5 profiles to Radarr") - .meta({ instanceId: 1, profileCount: 5 }) +await notify("pcd.sync_success") + .generic("Sync Complete", "Synced 5 profiles to Radarr") .send(); ``` +For rich Discord notifications with embeds: + +```typescript +import { notify, createEmbed, Colors } from "$notifications/builder.ts"; + +await notify("rename.success") + .generic("Rename Complete", "Renamed 5 files") + .discord((d) => + d.embed( + createEmbed() + .title("Rename Complete") + .description("All files renamed successfully") + .field("Files", "5/5", true) + .field("Mode", "Live", true) + .color(Colors.SUCCESS) + .timestamp() + ) + ) + .send(); +``` + +For complex notifications, create a definition in `definitions/`: + +```typescript +// definitions/myFeature.ts +import { notify, createEmbed, Colors } from "../builder.ts"; + +interface MyNotificationParams { + log: MyJobLog; + config: { username?: string }; +} + +export const myFeature = ({ log, config }: MyNotificationParams) => + notify(`myfeature.${log.status}`) + .generic("Feature Complete", `Processed ${log.count} items`) + .discord((d) => + d.embed( + createEmbed() + .title("Feature Complete") + .field("Processed", String(log.count), true) + .color(log.status === "success" ? Colors.SUCCESS : Colors.ERROR) + .timestamp() + ) + ); + +// Usage: +import { notifications } from "$notifications/definitions/index.ts"; +await notifications.myFeature({ log, config }).send(); +``` + +**Discord Embed Builder** + +The embed builder (`notifiers/discord/embed.ts`) provides a fluent API: + +```typescript +createEmbed() + .author("Profilarr", iconUrl) + .title("Title") + .description("Description text") + .field("Name", "Value", true) // inline field + .fieldIf(condition, "Name", "Value") // conditional field + .color(Colors.SUCCESS) + .timestamp() + .footer("Profilarr") + .build(); +``` + +Available colors: `Colors.SUCCESS`, `Colors.ERROR`, `Colors.WARNING`, +`Colors.INFO`, `Colors.PREVIEW`. + +Instance icons: `Icons.RADARR`, `Icons.SONARR`, `Icons.LIDARR`, `Icons.READARR`. + +The Discord notifier automatically handles: + +- Splitting large notifications across multiple messages (1 embed per message) +- Rate limiting between messages (1 second delay) +- Fallback to generic content when no Discord-specific payload is provided + **Notification Types** -`types.ts` defines type constants for categorizing notifications: +Types are defined in two places: + +- `src/lib/server/notifications/types.ts` — Backend constants +- `src/lib/shared/notificationTypes.ts` — Shared definitions with labels and + descriptions for the settings UI + +Current types: - `job..success` / `job..failed` — Job completion status - `pcd.linked` / `pcd.unlinked` — Database connection changes - `pcd.sync_success` / `pcd.sync_failed` — Sync results - `upgrade.success` / `upgrade.partial` / `upgrade.failed` — Upgrade results +- `rename.success` / `rename.partial` / `rename.failed` — Rename results -Users configure which types each service receives in the settings UI. +When adding a new notification type, add it to both files. Users configure which +types each service receives in the settings UI. **Planned Services** @@ -896,10 +984,13 @@ Currently only Discord is implemented. Planned additions: To add a new notification service: -1. Create a config interface in `types.ts` -2. Create a notifier class in `notifiers/` extending `BaseHttpNotifier` -3. Implement `getName()`, `getWebhookUrl()`, and `formatPayload()` -4. Add the case to `NotificationManager.createNotifier()` +1. Create a config interface in `types.ts` (e.g., `SlackConfig`) +2. Add the service type to `NotificationServiceType` union +3. Create a notifier directory in `notifiers/` (e.g., `notifiers/slack/`) +4. Implement a notifier class with a `notify(notification: Notification)` method +5. Add the case to `NotificationManager.createNotifier()` +6. Create frontend configuration component in + `src/routes/settings/notifications/components/` #### Arr Clients diff --git a/docs/todo/3.renaminatorr.md b/docs/todo/3.renaminatorr.md new file mode 100644 index 0000000..203d531 --- /dev/null +++ b/docs/todo/3.renaminatorr.md @@ -0,0 +1,402 @@ +# Renaminatorr + +**Status: Implemented** + +## Summary + +Bulk rename module that triggers Radarr/Sonarr's built-in rename functionality across +your entire library. The Arr apps already know how to rename files based on your +naming format settings — Renaminatorr just triggers that command in bulk. + +## Overview + +Unlike the upgrade system which needs filters and selectors (searches are expensive, +hit indexers, take time), renames are fast local file operations. There's no need +for complex filtering — if your naming format is correct, you want everything named +correctly. + +**Key insight:** The Arr's rename command is idempotent. If a file already matches +the naming format, nothing happens. So we can just trigger rename on everything. + +## Settings + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| **Dry Run** | boolean | true | Preview what would change without making changes | +| **Rename Folders** | boolean | false | Also rename containing folders, not just files | +| **Ignore Tag** | string | null | Tag name to skip (items with this tag won't be renamed) | +| **Enabled** | boolean | false | Enable scheduled rename job | +| **Schedule** | integer | 1440 | Run interval in minutes (default 24 hours) | + +**Ignore Tag usage:** +- User sets ignore tag to e.g. `profilarr-no-rename` in Profilarr +- User tags specific movies/series in the Arr UI with that tag +- Rename runs, skips anything with that tag +- Useful for items with custom folder structures you want to preserve + +**Job scheduling:** +- When enabled, the rename job runs on the configured schedule +- Similar to upgrades: a manager job runs every 30 mins and checks which configs are due +- Manual rename can still be triggered anytime from the UI + +## Process Flow + +### 1. Fetch Media + +Get all movies/series from the Arr instance. + +``` +GET /api/v3/movie (Radarr) +GET /api/v3/series (Sonarr) +``` + +### 2. Filter by Ignore Tag + +If an ignore tag is configured: +- Look up the tag ID from the tag name +- Filter out any media items that have this tag in their `tags[]` array + +```typescript +const tagId = await client.getOrCreateTag(ignoreTag); +const filteredMedia = allMedia.filter(item => !item.tags.includes(tagId)); +``` + +### 3. Check Dry Run Mode + +**If dry run is ON:** +- For each filtered media item, call the rename preview API +- Aggregate all results showing `existingPath → newPath` +- Return preview to UI — no changes made + +``` +GET /api/v3/rename?movieId={id} (Radarr) +GET /api/v3/rename?seriesId={id} (Sonarr) +``` + +**If dry run is OFF:** +- Proceed to actual rename + +### 4. Execute Rename + +Trigger the rename command with all filtered media IDs. + +``` +POST /api/v3/command +{ "name": "RenameMovie", "movieIds": [...] } // Radarr +{ "name": "RenameSeries", "seriesIds": [...] } // Sonarr +``` + +### 5. Wait for Completion + +Poll the command status until complete. + +``` +GET /api/v3/command/{commandId} +// Poll every 5s until status === "completed" or "failed" +``` + +### 6. Rename Folders (Optional) + +If rename folders is enabled: +- Group media by root folder path +- For each root folder, trigger folder rename + +``` +PUT /api/v3/movie/editor +{ "movieIds": [...], "moveFiles": true, "rootFolderPath": "/movies" } +``` + +### 7. Refresh Metadata + +Trigger a refresh so the Arr picks up the new paths. + +``` +POST /api/v3/command +{ "name": "RefreshMovie", "movieIds": [...] } +``` + +### 8. Return Results + +Return summary to UI: +- Total items processed +- Files renamed (with before/after paths) +- Folders renamed (if enabled) +- Any errors + +--- + +## API Research + +### Radarr + +| Operation | Method | Endpoint | Payload | +|-----------|--------|----------|---------| +| Get all movies | `GET` | `/api/v3/movie` | — | +| Rename preview | `GET` | `/api/v3/rename?movieId={id}` | — | +| Rename files | `POST` | `/api/v3/command` | `{"name": "RenameMovie", "movieIds": [...]}` | +| Rename folders | `PUT` | `/api/v3/movie/editor` | `{"movieIds": [...], "moveFiles": true, "rootFolderPath": "..."}` | +| Refresh | `POST` | `/api/v3/command` | `{"name": "RefreshMovie", "movieIds": [...]}` | +| Poll command | `GET` | `/api/v3/command/{id}` | — | + +**Rename preview response:** + +```json +[ + { + "movieId": 123, + "movieFileId": 456, + "existingPath": "Movie (2024)/Movie.2024.1080p.BluRay.x264-GROUP.mkv", + "newPath": "Movie (2024)/Movie (2024) [Bluray-1080p].mkv" + } +] +``` + +### Sonarr + +| Operation | Method | Endpoint | Payload | +|-----------|--------|----------|---------| +| Get all series | `GET` | `/api/v3/series` | — | +| Rename preview | `GET` | `/api/v3/rename?seriesId={id}` | — | +| Rename files | `POST` | `/api/v3/command` | `{"name": "RenameSeries", "seriesIds": [...]}` | +| Rename folders | `PUT` | `/api/v3/series/editor` | `{"seriesIds": [...], "moveFiles": true, "rootFolderPath": "..."}` | +| Refresh | `POST` | `/api/v3/command` | `{"name": "RefreshSeries", "seriesIds": [...]}` | +| Poll command | `GET` | `/api/v3/command/{id}` | — | + +### Command Polling + +Commands are async. Poll `GET /api/v3/command/{id}` until: +- `status: "completed"` → success +- `status: "failed"` → failure + +Poll interval: ~5 seconds. Timeout after ~10 minutes. + +--- + +## Existing Client Methods + +**Already have in `src/lib/server/utils/arr/`:** + +| Method | Location | Notes | +|--------|----------|-------| +| `getMovies()` | `RadarrClient` | Get all movies | +| `getAllSeries()` | `SonarrClient` | Get all series | +| `getTags()` | `BaseArrClient` | Get tags | +| `createTag()` | `BaseArrClient` | Create tag | +| `getOrCreateTag()` | `RadarrClient` | Get or create tag | + +**Need to add:** + +| Method | Location | Endpoint | +|--------|----------|----------| +| `getRenamePreview(id)` | `RadarrClient` | `GET /rename?movieId={id}` | +| `getRenamePreview(id)` | `SonarrClient` | `GET /rename?seriesId={id}` | +| `renameMovies(ids)` | `RadarrClient` | `POST /command` → `RenameMovie` | +| `renameSeries(ids)` | `SonarrClient` | `POST /command` → `RenameSeries` | +| `refreshMovies(ids)` | `RadarrClient` | `POST /command` → `RefreshMovie` | +| `refreshSeries(ids)` | `SonarrClient` | `POST /command` → `RefreshSeries` | +| `renameMovieFolders(ids, rootPath)` | `RadarrClient` | `PUT /movie/editor` | +| `renameSeriesFolders(ids, rootPath)` | `SonarrClient` | `PUT /series/editor` | +| `getCommand(id)` | `BaseArrClient` | `GET /command/{id}` | +| `waitForCommand(id)` | `BaseArrClient` | Poll until complete | + +--- + +## TypeScript Types + +```typescript +// Rename preview response item +interface RenamePreviewItem { + movieId?: number; // Radarr + seriesId?: number; // Sonarr + seasonNumber?: number; // Sonarr + episodeNumbers?: number[]; // Sonarr + movieFileId?: number; // Radarr + episodeFileId?: number; // Sonarr + existingPath: string; + newPath: string; +} + +// Command response +interface ArrCommand { + id: number; + name: string; + status: 'queued' | 'started' | 'completed' | 'failed'; + queued: string; + started?: string; + ended?: string; + message?: string; +} + +// Rename result for UI +interface RenameResult { + mediaId: number; + title: string; + year: number; + filesRenamed: Array<{ + existingPath: string; + newPath: string; + }>; + folderRenamed?: { + existingPath: string; + newPath: string; + }; +} + +// Structured log for each rename run +interface RenameJobLog { + instanceId: number; + instanceName: string; + startedAt: string; + completedAt: string; + status: 'success' | 'failed'; + + config: { + dryRun: boolean; + renameFolders: boolean; + ignoreTag: string | null; + }; + + library: { + totalItems: number; + skippedByTag: number; + itemsToProcess: number; + }; + + results: { + filesRenamed: number; + foldersRenamed: number; + errors: string[]; + }; +} +``` + +--- + +## Notifications & Logging + +### Notification Types + +Add to `src/lib/server/notifications/types.ts`: + +```typescript +// Rename +RENAME_SUCCESS: 'rename.success', +RENAME_FAILED: 'rename.failed' +``` + +**Notification content:** +- Instance name +- Dry run indicator +- Files renamed count +- Folders renamed count (if enabled) +- Items skipped (due to ignore tag) +- Errors if any + +### Structured Logger + +Create `src/lib/server/rename/logger.ts` with helpers: + +- `logRenameRun(log: RenameJobLog)` — log completed run with metrics +- `logRenameSkipped(instanceId, instanceName, reason)` — log when skipped +- `logRenameStart(instanceId, instanceName)` — log when starting +- `logRenameError(instanceId, instanceName, error)` — log errors + +--- + +## Implementation Checklist + +### Database + +- [x] Create migration for `arr_rename_settings` table +- [x] Add queries file (`src/lib/server/db/queries/arrRenameSettings.ts`) + +**Table schema:** + +```sql +CREATE TABLE arr_rename_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + arr_instance_id INTEGER NOT NULL UNIQUE, + + -- Settings + dry_run INTEGER NOT NULL DEFAULT 1, + rename_folders INTEGER NOT NULL DEFAULT 0, + ignore_tag TEXT, + + -- Job scheduling + enabled INTEGER NOT NULL DEFAULT 0, + schedule INTEGER NOT NULL DEFAULT 1440, -- Run interval in minutes (default 24 hours) + last_run_at DATETIME, + + -- Metadata + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (arr_instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE +); +``` + +**Queries needed:** +- `getByInstanceId(instanceId)` — get settings for an instance +- `upsert(instanceId, settings)` — create or update settings +- `getDueConfigs()` — get configs where enabled=1 and due to run +- `updateLastRunAt(instanceId)` — update last_run_at after job runs + +### Arr Client Methods + +- [x] Add `getCommand(id)` to `BaseArrClient` +- [x] Add `waitForCommand(id)` to `BaseArrClient` (poll helper) +- [x] Add `getRenamePreview(movieId)` to `RadarrClient` +- [x] Add `renameMovies(movieIds)` to `RadarrClient` +- [x] Add `refreshMovies(movieIds)` to `RadarrClient` +- [x] Add `renameMovieFolders(movieIds, rootPath)` to `RadarrClient` +- [x] Add `getRenamePreview(seriesId)` to `SonarrClient` +- [x] Add `renameSeries(seriesIds)` to `SonarrClient` +- [x] Add `refreshSeries(seriesIds)` to `SonarrClient` +- [x] Add `renameSeriesFolders(seriesIds, rootPath)` to `SonarrClient` + +### Backend Logic + +- [x] Create rename processor (`src/lib/server/rename/processor.ts`) +- [x] Create `+page.server.ts` with form actions: + - `save` — create/update settings + - `run` — trigger manual rename + +### Job & Notifications + +- [x] Create job definition (`src/lib/server/jobs/definitions/renameManager.ts`) +- [x] Create job logic (`src/lib/server/jobs/logic/renameManager.ts`) +- [x] Register job in `src/lib/server/jobs/init.ts` +- [x] Add notification types to `src/lib/server/notifications/types.ts` +- [x] Create structured logger (`src/lib/server/rename/logger.ts`) + +### Frontend + +- [x] Create rename page under `/arr/[id]/rename/` +- [x] Settings form: + - Dry run toggle + - Rename folders toggle + - Ignore tag input + - Enable scheduled job toggle + - Schedule interval input +- [x] "Test Run" button (triggers `run` action, only in dry run mode) +- [x] Progress/loading state during rename +- [x] Results display (alert with summary) + +### Optional Enhancements + +- [ ] Count limit (process N items per run) for very large libraries + +--- + +## Resolved Questions + +1. **Where to put rename?** → Page under each Arr instance (`/arr/[id]/rename/`) +2. **Per-instance or all instances?** → Per-instance only (consistent with upgrades) +3. **Job scheduler?** → Yes, added to job scheduler for automated runs + +--- + +## Reference + +Original implementation from daps: `dist/daps/modules/renameinatorr.py` +API client: `dist/daps/util/arrpy.py` diff --git a/docs/todo/4.link-sync-settings.md b/docs/todo/4.link-sync-settings.md new file mode 100644 index 0000000..6c61ca5 --- /dev/null +++ b/docs/todo/4.link-sync-settings.md @@ -0,0 +1,60 @@ +# Link Sync Settings + +**Status: Planning** + +## Summary + +When a user enables quality profile sync, require media management sync to also be +enabled. This ensures users get the complete configuration — profiles alone without +proper naming formats leads to inconsistent results. + +## Problem + +Users can currently enable quality profile sync without enabling media management +sync. This creates issues: + +- Quality profiles define *what* quality to grab +- Media management defines *how* files are named and organized +- Without both, the naming format may not match what the profile expects +- Leads to confusion and support requests + +## Solution + +Client-side validation on the sync settings page. Don't let users save if: + +1. Any quality profiles are selected for sync, AND +2. Media management settings (naming, quality definitions) are not configured + +## Implementation + +**Location:** `src/routes/arr/[id]/sync/+page.svelte` + +**Logic:** + +```typescript +// Check if any quality profiles are selected +const hasQualityProfilesSelected = Object.values(qualityProfileState) + .some(db => Object.values(db).some(selected => selected)); + +// Check if media management is configured +const hasMediaManagement = + mediaManagementState.namingDatabaseId !== null || + mediaManagementState.qualityDefinitionsDatabaseId !== null; + +// Validation +const canSave = !hasQualityProfilesSelected || hasMediaManagement; +``` + +**UX:** + +- Show warning message when quality profiles selected but no media management +- Disable save buttons on QualityProfiles component until valid +- Message: "Quality profiles require media management settings. Please configure + naming or quality definitions to ensure consistent file naming." + +## Checklist + +- [ ] Add validation logic to `+page.svelte` +- [ ] Pass `canSave` state to `QualityProfiles.svelte` +- [ ] Show warning alert when invalid +- [ ] Disable save button when invalid diff --git a/docs/todo/5.dirty-handling-upgrades-renames.md b/docs/todo/5.dirty-handling-upgrades-renames.md new file mode 100644 index 0000000..6abeff9 --- /dev/null +++ b/docs/todo/5.dirty-handling-upgrades-renames.md @@ -0,0 +1,42 @@ +# Dirty Handling for Upgrades & Renames + +**Status: Planning** + +## Summary + +Both the Upgrades and Rename configuration pages have issues with form state management and dirty tracking. This task covers fixing save errors and implementing proper dirty state handling. + +## Problems + +### Upgrades Page + +- Save errors occurring (need to investigate root cause) +- Dirty tracking not properly implemented +- Form state management inconsistent + +### Rename Page + +- Needs dirty tracking implementation +- Should follow same pattern as upgrades (once fixed) + +## Requirements + +1. Fix save errors in upgrades page +2. Implement proper dirty tracking for both pages: + - Track when form values differ from saved values + - Show unsaved changes indicator + - Warn before navigating away with unsaved changes + - Reset dirty state after successful save + +## Implementation Notes + +TBD - needs investigation of current issues first. + +--- + +## Related Files + +- `src/routes/arr/[id]/upgrades/+page.svelte` +- `src/routes/arr/[id]/upgrades/+page.server.ts` +- `src/routes/arr/[id]/rename/+page.svelte` +- `src/routes/arr/[id]/rename/+page.server.ts` diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 1155854..97121e5 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -25,6 +25,7 @@ import { migration as migration020 } from './migrations/020_create_tmdb_settings import { migration as migration021 } from './migrations/021_create_parsed_release_cache.ts'; import { migration as migration022 } from './migrations/022_add_next_run_at.ts'; import { migration as migration023 } from './migrations/023_create_pattern_match_cache.ts'; +import { migration as migration024 } from './migrations/024_create_arr_rename_settings.ts'; export interface Migration { version: number; @@ -262,7 +263,8 @@ export function loadMigrations(): Migration[] { migration020, migration021, migration022, - migration023 + migration023, + migration024 ]; // Sort by version number diff --git a/src/lib/server/db/migrations/024_create_arr_rename_settings.ts b/src/lib/server/db/migrations/024_create_arr_rename_settings.ts new file mode 100644 index 0000000..885022c --- /dev/null +++ b/src/lib/server/db/migrations/024_create_arr_rename_settings.ts @@ -0,0 +1,58 @@ +import type { Migration } from '../migrations.ts'; + +/** + * Migration 024: Create arr_rename_settings table + * + * Creates the table for storing rename configuration per arr instance. + * Each arr instance can have one rename config that controls bulk file/folder + * renaming based on the Arr's naming format. + * + * Fields: + * - id: Auto-incrementing primary key + * - arr_instance_id: Foreign key to arr_instances (unique - one config per instance) + * - dry_run: Preview changes without making them (default: true for safety) + * - rename_folders: Also rename containing folders, not just files + * - ignore_tag: Tag name to skip (items with this tag won't be renamed) + * - enabled: Whether scheduled rename job is enabled + * - schedule: Interval in minutes between rename runs (default: 24 hours) + * - last_run_at: Timestamp of last job run + * - created_at: Timestamp of creation + * - updated_at: Timestamp of last update + */ + +export const migration: Migration = { + version: 24, + name: 'Create arr_rename_settings table', + + up: ` + CREATE TABLE arr_rename_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + -- Relationship + arr_instance_id INTEGER NOT NULL UNIQUE, + + -- Settings + dry_run INTEGER NOT NULL DEFAULT 1, + rename_folders INTEGER NOT NULL DEFAULT 0, + ignore_tag TEXT, + + -- Job scheduling + enabled INTEGER NOT NULL DEFAULT 0, + schedule INTEGER NOT NULL DEFAULT 1440, + last_run_at DATETIME, + + -- Metadata + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (arr_instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE + ); + + CREATE INDEX idx_arr_rename_settings_arr_instance ON arr_rename_settings(arr_instance_id); + `, + + down: ` + DROP INDEX IF EXISTS idx_arr_rename_settings_arr_instance; + DROP TABLE IF EXISTS arr_rename_settings; + ` +}; diff --git a/src/lib/server/db/queries/arrRenameSettings.ts b/src/lib/server/db/queries/arrRenameSettings.ts new file mode 100644 index 0000000..4901cd5 --- /dev/null +++ b/src/lib/server/db/queries/arrRenameSettings.ts @@ -0,0 +1,210 @@ +import { db } from '../db.ts'; + +/** + * Database row type for arr_rename_settings table + */ +interface RenameSettingsRow { + id: number; + arr_instance_id: number; + dry_run: number; + rename_folders: number; + ignore_tag: string | null; + enabled: number; + schedule: number; + last_run_at: string | null; + created_at: string; + updated_at: string; +} + +/** + * Rename settings as returned to application code + */ +export interface RenameSettings { + id: number; + arrInstanceId: number; + dryRun: boolean; + renameFolders: boolean; + ignoreTag: string | null; + enabled: boolean; + schedule: number; + lastRunAt: string | null; + createdAt: string; + updatedAt: string; +} + +/** + * Input for creating/updating rename settings + */ +export interface RenameSettingsInput { + dryRun?: boolean; + renameFolders?: boolean; + ignoreTag?: string | null; + enabled?: boolean; + schedule?: number; +} + +/** + * Convert database row to RenameSettings + */ +function rowToSettings(row: RenameSettingsRow): RenameSettings { + return { + id: row.id, + arrInstanceId: row.arr_instance_id, + dryRun: row.dry_run === 1, + renameFolders: row.rename_folders === 1, + ignoreTag: row.ignore_tag, + enabled: row.enabled === 1, + schedule: row.schedule, + lastRunAt: row.last_run_at, + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +/** + * All queries for arr_rename_settings table + */ +export const arrRenameSettingsQueries = { + /** + * Get rename settings by arr instance ID + */ + getByInstanceId(arrInstanceId: number): RenameSettings | undefined { + const row = db.queryFirst( + 'SELECT * FROM arr_rename_settings WHERE arr_instance_id = ?', + arrInstanceId + ); + return row ? rowToSettings(row) : undefined; + }, + + /** + * Get all rename settings + */ + getAll(): RenameSettings[] { + const rows = db.query('SELECT * FROM arr_rename_settings'); + return rows.map(rowToSettings); + }, + + /** + * Get all enabled rename settings + */ + getEnabled(): RenameSettings[] { + const rows = db.query( + 'SELECT * FROM arr_rename_settings WHERE enabled = 1' + ); + return rows.map(rowToSettings); + }, + + /** + * Create or update rename settings for an arr instance + * Uses upsert pattern since there's one config per instance + */ + upsert(arrInstanceId: number, input: RenameSettingsInput): RenameSettings { + const existing = this.getByInstanceId(arrInstanceId); + + if (existing) { + this.update(arrInstanceId, input); + return this.getByInstanceId(arrInstanceId)!; + } + + // Create new with defaults + const dryRun = input.dryRun !== undefined ? (input.dryRun ? 1 : 0) : 1; + const renameFolders = input.renameFolders !== undefined ? (input.renameFolders ? 1 : 0) : 0; + const ignoreTag = input.ignoreTag ?? null; + const enabled = input.enabled !== undefined ? (input.enabled ? 1 : 0) : 0; + const schedule = input.schedule ?? 1440; + + db.execute( + `INSERT INTO arr_rename_settings + (arr_instance_id, dry_run, rename_folders, ignore_tag, enabled, schedule) + VALUES (?, ?, ?, ?, ?, ?)`, + arrInstanceId, + dryRun, + renameFolders, + ignoreTag, + enabled, + schedule + ); + + return this.getByInstanceId(arrInstanceId)!; + }, + + /** + * Update rename settings + */ + update(arrInstanceId: number, input: RenameSettingsInput): boolean { + const updates: string[] = []; + const params: (string | number | null)[] = []; + + if (input.dryRun !== undefined) { + updates.push('dry_run = ?'); + params.push(input.dryRun ? 1 : 0); + } + if (input.renameFolders !== undefined) { + updates.push('rename_folders = ?'); + params.push(input.renameFolders ? 1 : 0); + } + if (input.ignoreTag !== undefined) { + updates.push('ignore_tag = ?'); + params.push(input.ignoreTag); + } + if (input.enabled !== undefined) { + updates.push('enabled = ?'); + params.push(input.enabled ? 1 : 0); + } + if (input.schedule !== undefined) { + updates.push('schedule = ?'); + params.push(input.schedule); + } + + if (updates.length === 0) { + return false; + } + + updates.push('updated_at = CURRENT_TIMESTAMP'); + params.push(arrInstanceId); + + const affected = db.execute( + `UPDATE arr_rename_settings SET ${updates.join(', ')} WHERE arr_instance_id = ?`, + ...params + ); + + return affected > 0; + }, + + /** + * Delete rename settings + */ + delete(arrInstanceId: number): boolean { + const affected = db.execute( + 'DELETE FROM arr_rename_settings WHERE arr_instance_id = ?', + arrInstanceId + ); + return affected > 0; + }, + + /** + * Update last_run_at to current timestamp + */ + updateLastRun(arrInstanceId: number): void { + db.execute( + 'UPDATE arr_rename_settings SET last_run_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE arr_instance_id = ?', + arrInstanceId + ); + }, + + /** + * Get all enabled configs that are due to run + * A config is due if: last_run_at is null OR (now - last_run_at) >= schedule minutes + */ + getDueConfigs(): RenameSettings[] { + const rows = db.query(` + SELECT * FROM arr_rename_settings + WHERE enabled = 1 + AND ( + last_run_at IS NULL + OR (julianday('now') - julianday(replace(replace(last_run_at, 'T', ' '), 'Z', ''))) * 24 * 60 >= schedule + ) + `); + return rows.map(rowToSettings); + } +}; diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index 536491e..f03f1c6 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -461,3 +461,35 @@ CREATE TABLE pattern_match_cache ( CREATE INDEX idx_pattern_match_cache_hash ON pattern_match_cache(patterns_hash); CREATE INDEX idx_pattern_match_cache_created_at ON pattern_match_cache(created_at); + +-- ============================================================================== +-- TABLE: arr_rename_settings +-- Purpose: Store rename configuration per arr instance for bulk file/folder renaming +-- Migration: 024_create_arr_rename_settings.ts +-- ============================================================================== + +CREATE TABLE arr_rename_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + -- Relationship (one config per arr instance) + arr_instance_id INTEGER NOT NULL UNIQUE, + + -- Settings + dry_run INTEGER NOT NULL DEFAULT 1, -- 1=preview only, 0=make changes + rename_folders INTEGER NOT NULL DEFAULT 0, -- 1=rename folders too, 0=files only + ignore_tag TEXT, -- Tag name to skip (items with tag won't be renamed) + + -- Job scheduling + enabled INTEGER NOT NULL DEFAULT 0, -- Master on/off switch for scheduled job + schedule INTEGER NOT NULL DEFAULT 1440, -- Run interval in minutes (default 24 hours) + last_run_at DATETIME, -- When rename job last ran + + -- Metadata + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (arr_instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE +); + +-- Arr rename settings indexes (Migration: 024_create_arr_rename_settings.ts) +CREATE INDEX idx_arr_rename_settings_arr_instance ON arr_rename_settings(arr_instance_id); diff --git a/src/lib/server/jobs/definitions/renameManager.ts b/src/lib/server/jobs/definitions/renameManager.ts new file mode 100644 index 0000000..56a6df0 --- /dev/null +++ b/src/lib/server/jobs/definitions/renameManager.ts @@ -0,0 +1,68 @@ +import { logger } from '$logger/logger.ts'; +import { runRenameManager } from '../logic/renameManager.ts'; +import type { JobDefinition, JobResult } from '../types.ts'; + +/** + * Rename manager job + * Checks for rename configs that are due to run and processes them + * Each config has its own schedule - this job just checks every 30 minutes + */ +export const renameManagerJob: JobDefinition = { + name: 'rename_manager', + description: 'Process file/folder renames for arr instances', + schedule: '*/30 * * * *', // Every 30 minutes + + handler: async (): Promise => { + try { + const result = await runRenameManager(); + + // Build output message + if (result.totalProcessed === 0) { + return { + success: true, + output: 'No rename configs due to run' + }; + } + + const message = `Processed ${result.totalProcessed} config(s): ${result.successCount} successful, ${result.failureCount} failed, ${result.skippedCount} skipped`; + + // Log failures only + for (const instance of result.instances) { + if (!instance.success && instance.error) { + await logger.warn(`Rename skipped/failed for "${instance.instanceName}": ${instance.error}`, { + source: 'RenameManagerJob', + meta: { + instanceId: instance.instanceId, + error: instance.error + } + }); + } + } + + // Consider job failed only if all configs failed + if (result.failureCount > 0 && result.successCount === 0) { + return { + success: false, + error: message + }; + } + + return { + success: true, + output: message + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + await logger.error('Rename manager job failed', { + source: 'RenameManagerJob', + meta: { error: errorMessage } + }); + + return { + success: false, + error: errorMessage + }; + } + } +}; diff --git a/src/lib/server/jobs/definitions/upgradeManager.ts b/src/lib/server/jobs/definitions/upgradeManager.ts index 46eff19..9b3a716 100644 --- a/src/lib/server/jobs/definitions/upgradeManager.ts +++ b/src/lib/server/jobs/definitions/upgradeManager.ts @@ -1,7 +1,5 @@ import { logger } from '$logger/logger.ts'; import { runUpgradeManager } from '../logic/upgradeManager.ts'; -import { notify } from '$notifications/builder.ts'; -import { NotificationTypes } from '$notifications/types.ts'; import type { JobDefinition, JobResult } from '../types.ts'; /** @@ -41,66 +39,6 @@ export const upgradeManagerJob: JobDefinition = { } } - // Send notification summary (only if something was processed, excluding skipped) - const processedCount = result.successCount + result.failureCount; - if (processedCount > 0) { - const successfulInstances = result.instances.filter((i) => i.success); - const failedInstances = result.instances.filter((i) => !i.success && i.error && !i.error.includes('disabled') && !i.error.includes('not yet supported')); - const hasDryRun = result.instances.some((i) => i.dryRun); - - // Build message lines for each successful instance - const messageLines: string[] = []; - for (const inst of successfulInstances) { - const dryRunLabel = inst.dryRun ? ' [DRY RUN]' : ''; - messageLines.push(`**${inst.instanceName}: ${inst.filterName}${dryRunLabel}**`); - messageLines.push(`Filter: ${inst.matchedCount} matched → ${inst.afterCooldown} after cooldown`); - messageLines.push(`Selection: ${inst.itemsSearched}/${inst.itemsRequested} items`); - if (inst.items && inst.items.length > 0) { - messageLines.push(`Items: ${inst.items.join(', ')}`); - } - messageLines.push(''); - } - - // Add failed instances - for (const inst of failedInstances) { - messageLines.push(`**${inst.instanceName}: Failed**`); - messageLines.push(`Error: ${inst.error}`); - messageLines.push(''); - } - - const notificationType = - result.failureCount === 0 - ? NotificationTypes.UPGRADE_SUCCESS - : result.successCount === 0 - ? NotificationTypes.UPGRADE_FAILED - : NotificationTypes.UPGRADE_PARTIAL; - - const title = - result.failureCount === 0 - ? hasDryRun - ? 'Upgrade Completed (Dry Run)' - : 'Upgrade Completed' - : result.successCount === 0 - ? 'Upgrade Failed' - : 'Upgrade Partially Completed'; - - await notify(notificationType) - .title(title) - .lines(messageLines) - .meta({ - successCount: result.successCount, - failureCount: result.failureCount, - dryRun: hasDryRun, - instances: result.instances.filter((i) => i.success).map((i) => ({ - name: i.instanceName, - filter: i.filterName, - searched: i.itemsSearched, - items: i.items - })) - }) - .send(); - } - // Consider job failed only if all configs failed if (result.failureCount > 0 && result.successCount === 0) { return { @@ -121,12 +59,6 @@ export const upgradeManagerJob: JobDefinition = { meta: { error: errorMessage } }); - await notify(NotificationTypes.UPGRADE_FAILED) - .title('Upgrade Failed') - .message(`Upgrade manager encountered an error: ${errorMessage}`) - .meta({ error: errorMessage }) - .send(); - return { success: false, error: errorMessage diff --git a/src/lib/server/jobs/init.ts b/src/lib/server/jobs/init.ts index d6fab10..782db3e 100644 --- a/src/lib/server/jobs/init.ts +++ b/src/lib/server/jobs/init.ts @@ -9,6 +9,7 @@ import { cleanupBackupsJob } from './definitions/cleanupBackups.ts'; import { syncDatabasesJob } from './definitions/syncDatabases.ts'; import { upgradeManagerJob } from './definitions/upgradeManager.ts'; import { syncArrJob } from './definitions/syncArr.ts'; +import { renameManagerJob } from './definitions/renameManager.ts'; /** * Register all job definitions @@ -21,6 +22,7 @@ function registerAllJobs(): void { jobRegistry.register(syncDatabasesJob); jobRegistry.register(upgradeManagerJob); jobRegistry.register(syncArrJob); + jobRegistry.register(renameManagerJob); } /** diff --git a/src/lib/server/jobs/logic/renameManager.ts b/src/lib/server/jobs/logic/renameManager.ts new file mode 100644 index 0000000..6f7fa36 --- /dev/null +++ b/src/lib/server/jobs/logic/renameManager.ts @@ -0,0 +1,171 @@ +/** + * Core logic for the rename manager job + * Checks for rename configs that are due to run and processes them + */ + +import { arrRenameSettingsQueries } from '$db/queries/arrRenameSettings.ts'; +import { arrInstancesQueries } from '$db/queries/arrInstances.ts'; +import { logger } from '$logger/logger.ts'; +import { processRenameConfig } from '$lib/server/rename/processor.ts'; +import type { RenameSettings } from '$db/queries/arrRenameSettings.ts'; + +export interface RenameInstanceStatus { + instanceId: number; + instanceName: string; + instanceType: string; + success: boolean; + filesRenamed?: number; + filesNeedingRename?: number; + foldersRenamed?: number; + skippedByTag?: number; + dryRun?: boolean; + items?: { + title: string; + files: { existingPath: string; newPath: string }[]; + }[]; + error?: string; +} + +export interface RenameManagerResult { + totalProcessed: number; + successCount: number; + failureCount: number; + skippedCount: number; + instances: RenameInstanceStatus[]; +} + +/** + * Process a single rename config and convert result to status + */ +async function processConfig(settings: RenameSettings): Promise { + const instance = arrInstancesQueries.getById(settings.arrInstanceId); + + if (!instance) { + return { + instanceId: settings.arrInstanceId, + instanceName: 'Unknown', + instanceType: 'unknown', + success: false, + error: 'Arr instance not found' + }; + } + + if (!instance.enabled) { + return { + instanceId: settings.arrInstanceId, + instanceName: instance.name, + instanceType: instance.type, + success: false, + error: 'Arr instance is disabled' + }; + } + + // Only process Radarr and Sonarr + if (instance.type !== 'radarr' && instance.type !== 'sonarr') { + return { + instanceId: settings.arrInstanceId, + instanceName: instance.name, + instanceType: instance.type, + success: false, + error: `Rename not yet supported for ${instance.type}` + }; + } + + try { + // Process using the rename processor + const log = await processRenameConfig(settings, instance); + + // Update last run timestamp + arrRenameSettingsQueries.updateLastRun(settings.arrInstanceId); + + // Convert log to status + return { + instanceId: instance.id, + instanceName: instance.name, + instanceType: instance.type, + success: log.status === 'success' || log.status === 'partial', + filesRenamed: log.results.filesRenamed, + filesNeedingRename: log.results.filesNeedingRename, + foldersRenamed: log.results.foldersRenamed, + skippedByTag: log.filtering.skippedByTag, + dryRun: settings.dryRun, + items: log.renamedItems.map((i) => ({ title: i.title, files: i.files })), + error: log.status === 'failed' ? log.results.errors.join('; ') : undefined + }; + } catch (error) { + await logger.error(`Failed to process rename for "${instance.name}"`, { + source: 'RenameManager', + meta: { + instanceId: instance.id, + error: error instanceof Error ? error.message : String(error) + } + }); + + return { + instanceId: instance.id, + instanceName: instance.name, + instanceType: instance.type, + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +/** + * Run the rename manager + * Checks for configs that are due and processes them + */ +export async function runRenameManager(): Promise { + const dueConfigs = arrRenameSettingsQueries.getDueConfigs(); + + const totalProcessed = dueConfigs.length; + let successCount = 0; + let failureCount = 0; + let skippedCount = 0; + const statuses: RenameInstanceStatus[] = []; + + if (dueConfigs.length === 0) { + await logger.debug('No rename configs due to run', { + source: 'RenameManager' + }); + + return { + totalProcessed: 0, + successCount: 0, + failureCount: 0, + skippedCount: 0, + instances: [] + }; + } + + await logger.debug(`Found ${dueConfigs.length} rename config(s) to process`, { + source: 'RenameManager', + meta: { + configIds: dueConfigs.map((c) => c.arrInstanceId) + } + }); + + for (const config of dueConfigs) { + const status = await processConfig(config); + statuses.push(status); + + if (status.success) { + successCount++; + } else if ( + status.error?.includes('disabled') || + status.error?.includes('not yet supported') + ) { + skippedCount++; + } else { + failureCount++; + } + } + + return { + totalProcessed, + successCount, + failureCount, + skippedCount, + instances: statuses + }; +} diff --git a/src/lib/server/jobs/logic/syncDatabases.ts b/src/lib/server/jobs/logic/syncDatabases.ts index fba829e..8aef7b0 100644 --- a/src/lib/server/jobs/logic/syncDatabases.ts +++ b/src/lib/server/jobs/logic/syncDatabases.ts @@ -4,8 +4,6 @@ */ import { pcdManager } from '$pcd/pcd.ts'; -import { notify } from '$notifications/builder.ts'; -import { NotificationTypes } from '$notifications/types.ts'; import { logger } from '$logger/logger.ts'; import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts'; @@ -86,11 +84,6 @@ export async function syncDatabases(): Promise { source: LOG_SOURCE, meta: { commitsPulled: syncResult.commitsBehind } }); - await notify(NotificationTypes.PCD_SYNC_SUCCESS) - .title('Database Synced Successfully') - .message(`Database "${db.name}" has been updated (${syncResult.commitsBehind} commit${syncResult.commitsBehind === 1 ? '' : 's'} pulled)`) - .meta({ databaseId: db.id, databaseName: db.name, commitsPulled: syncResult.commitsBehind }) - .send(); statuses.push({ id: db.id, @@ -100,12 +93,6 @@ export async function syncDatabases(): Promise { }); successCount++; } else { - await notify(NotificationTypes.PCD_SYNC_FAILED) - .title('Database Sync Failed') - .message(`Failed to sync database "${db.name}": ${syncResult.error}`) - .meta({ databaseId: db.id, databaseName: db.name, error: syncResult.error }) - .send(); - statuses.push({ id: db.id, name: db.name, @@ -119,11 +106,6 @@ export async function syncDatabases(): Promise { await logger.debug(`Auto-pull disabled for "${db.name}", notifying only`, { source: LOG_SOURCE }); - await notify(NotificationTypes.PCD_UPDATES_AVAILABLE) - .title('Database Updates Available') - .message(`Updates are available for database "${db.name}" (${updateInfo.commitsBehind} commit${updateInfo.commitsBehind === 1 ? '' : 's'} behind)`) - .meta({ databaseId: db.id, databaseName: db.name, commitsBehind: updateInfo.commitsBehind }) - .send(); databaseInstancesQueries.updateSyncedAt(db.id); statuses.push({ diff --git a/src/lib/server/jobs/runner.ts b/src/lib/server/jobs/runner.ts index 7512513..a73adf0 100644 --- a/src/lib/server/jobs/runner.ts +++ b/src/lib/server/jobs/runner.ts @@ -1,8 +1,6 @@ import { jobRegistry } from './registry.ts'; import { jobsQueries, jobRunsQueries } from '$db/queries/jobs.ts'; import { logger } from '$logger/logger.ts'; -import { notify } from '$notifications/builder.ts'; -import { NotificationTypes } from '$notifications/types.ts'; import type { Job, JobResult } from './types.ts'; import { Cron } from 'croner'; @@ -96,17 +94,6 @@ export async function runJob(job: Job): Promise { }); } - // Send notification - const notificationType = result.success - ? NotificationTypes.jobSuccess(job.name) - : NotificationTypes.jobFailed(job.name); - - await notify(notificationType) - .title(`${definition.description} - ${result.success ? 'Success' : 'Failed'}`) - .message(result.success ? (result.output ?? 'Job completed successfully') : (result.error ?? 'Unknown error')) - .meta({ jobId: job.id, jobName: job.name, durationMs, timestamp: finishedAt }) - .send(); - // Save job run to database jobRunsQueries.create( job.id, diff --git a/src/lib/server/notifications/NotificationManager.ts b/src/lib/server/notifications/NotificationManager.ts index 8600d36..9c0221b 100644 --- a/src/lib/server/notifications/NotificationManager.ts +++ b/src/lib/server/notifications/NotificationManager.ts @@ -3,7 +3,7 @@ import { notificationServicesQueries } from '$db/queries/notificationServices.ts import { notificationHistoryQueries } from '$db/queries/notificationHistory.ts'; import type { Notifier } from './base/Notifier.ts'; import type { Notification, DiscordConfig } from './types.ts'; -import { DiscordNotifier } from './notifiers/DiscordNotifier.ts'; +import { DiscordNotifier } from './notifiers/discord/DiscordNotifier.ts'; /** * Central notification manager @@ -96,9 +96,8 @@ export class NotificationManager { notificationHistoryQueries.create({ serviceId, notificationType: notification.type, - title: notification.title, - message: notification.message, - metadata: notification.metadata, + title: notification.generic?.title ?? 'Notification', + message: notification.generic?.message ?? '', status: success ? 'success' : 'failed', error: errorMessage }); diff --git a/src/lib/server/notifications/base/BaseHttpNotifier.ts b/src/lib/server/notifications/base/BaseHttpNotifier.ts index 11204e1..8d1a94c 100644 --- a/src/lib/server/notifications/base/BaseHttpNotifier.ts +++ b/src/lib/server/notifications/base/BaseHttpNotifier.ts @@ -90,7 +90,7 @@ export abstract class BaseHttpNotifier implements Notifier { source: this.getName(), meta: { type: notification.type, - title: notification.title, + title: notification.generic?.title, error: error instanceof Error ? error.message : String(error) } }); diff --git a/src/lib/server/notifications/builder.ts b/src/lib/server/notifications/builder.ts index 40afdb6..f1ab286 100644 --- a/src/lib/server/notifications/builder.ts +++ b/src/lib/server/notifications/builder.ts @@ -5,6 +5,44 @@ import { notificationManager } from './NotificationManager.ts'; import type { Notification } from './types.ts'; +import { EmbedBuilder, type DiscordEmbed } from './notifiers/discord/embed.ts'; + +// Re-export Discord embed utilities for convenience +export { + EmbedBuilder, + createEmbed, + Colors, + Icons, + getInstanceIcon, + type DiscordEmbed, + type EmbedField, + type EmbedAuthor, + type EmbedFooter +} from './notifiers/discord/embed.ts'; + +/** + * Discord-specific builder for adding embeds + */ +class DiscordBuilder { + private embeds: DiscordEmbed[] = []; + + /** + * Add an embed + * Accepts an EmbedBuilder instance or a raw DiscordEmbed object + */ + embed(embed: EmbedBuilder | DiscordEmbed): this { + const built = embed instanceof EmbedBuilder ? embed.build() : embed; + this.embeds.push(built); + return this; + } + + /** + * Get the built embeds array + */ + build(): DiscordEmbed[] { + return this.embeds; + } +} /** * Builder class for constructing notifications @@ -13,75 +51,76 @@ class NotificationBuilder { private data: Notification; constructor(type: string) { - this.data = { - type, - title: '', - message: '' - }; + this.data = { type }; } /** - * Set the notification title + * Set generic notification content (works for all services) + * Services without specific payload will use this */ - title(t: string): this { - this.data.title = t; + generic(title: string, message: string): this { + this.data.generic = { title, message }; return this; } /** - * Set the notification message + * Set Discord-specific content + * Discord will use this if present, otherwise falls back to generic + * + * @example + * .discord(d => d + * .embed(createEmbed().title('Success').color(Colors.SUCCESS)) + * .embed(createEmbed().title('Details').field('Count', '5')) + * ) */ - message(m: string): this { - this.data.message = m; + discord(builder: (d: DiscordBuilder) => DiscordBuilder): this { + const discordBuilder = new DiscordBuilder(); + builder(discordBuilder); + this.data.discord = { embeds: discordBuilder.build() }; return this; } /** - * Build message from multiple lines - * Automatically filters out null/undefined/empty values - */ - lines(messageLines: (string | null | undefined | false)[]): this { - this.data.message = messageLines.filter(Boolean).join('\n').trim(); - return this; - } - - /** - * Set metadata - */ - meta(metadata: Record): this { - this.data.metadata = metadata; - return this; - } - - /** - * Send the notification + * Send the notification via the notification manager + * Routes to all enabled services that have this notification type enabled */ async send(): Promise { await notificationManager.notify(this.data); } + + /** + * Build and return the raw notification object + * Use this when you need to send directly to a specific notifier + * (e.g., test notifications that bypass the notification manager) + */ + build(): Notification { + return this.data; + } } /** * Create a new notification builder * * @example - * // Simple notification + * // Generic notification (works for all services) * await notify('pcd.linked') - * .title('Database Linked') - * .message('Database "MyDB" has been linked successfully') - * .meta({ databaseId: 1 }) + * .generic('Database Linked', 'Database "MyDB" has been linked successfully') * .send(); * * @example - * // Multi-line notification - * await notify('upgrade.success') - * .title('Upgrade Completed') - * .lines([ - * 'Filter: 50 matched → 30 after cooldown', - * 'Selection: 10/10 items', - * hasItems ? `Items: ${items.join(', ')}` : null - * ]) - * .meta({ instanceId: 1 }) + * // Discord with rich embeds, generic fallback for others + * await notify('rename.success') + * .generic('Rename Complete', '5 files renamed') + * .discord(d => d + * .embed(createEmbed() + * .title('Rename Complete') + * .field('Files', '5/5', true) + * .field('Mode', 'Live', true) + * .color(Colors.SUCCESS) + * .timestamp() + * .footer('Profilarr') + * ) + * ) * .send(); */ export function notify(type: string): NotificationBuilder { diff --git a/src/lib/server/notifications/definitions/index.ts b/src/lib/server/notifications/definitions/index.ts new file mode 100644 index 0000000..2196dd9 --- /dev/null +++ b/src/lib/server/notifications/definitions/index.ts @@ -0,0 +1,21 @@ +/** + * Pre-defined notifications + * Import and call these instead of building notifications inline + * + * @example + * import { notifications } from '$notifications/definitions/index.ts'; + * + * // Via notification manager (normal flow) + * await notifications.test(service).send(); + * + * // Direct to notifier (bypass manager) + * await notifier.notify(notifications.test(service).build()); + */ + +import { test } from './test.ts'; +import { rename } from './rename.ts'; + +export const notifications = { + test, + rename +}; diff --git a/src/lib/server/notifications/definitions/rename.ts b/src/lib/server/notifications/definitions/rename.ts new file mode 100644 index 0000000..3b50b9d --- /dev/null +++ b/src/lib/server/notifications/definitions/rename.ts @@ -0,0 +1,295 @@ +/** + * Rename notification definition + */ + +import { notify, createEmbed, Colors, type EmbedBuilder } from '../builder.ts'; +import type { RenameJobLog } from '$lib/server/rename/types.ts'; + +interface RenameNotificationParams { + log: RenameJobLog; + config: { username?: string; avatar_url?: string }; +} + +// Discord limits (https://birdie0.github.io/discord-webhooks-guide/other/field_limits.html) +const MAX_EMBED_SIZE = 5800; // Using 5800 to stay safely under 6000 +const MAX_FIELDS_PER_EMBED = 25; +const MAX_FIELD_VALUE = 1024; +const MAX_FIELD_NAME = 256; + +/** + * Extract filename from full path + */ +function getFilename(path: string): string { + return path.split('/').pop() || path; +} + + +/** + * Build the title based on manual/automatic and status + */ +function getTitle(log: RenameJobLog): string { + const prefix = log.config.manual ? 'Manual' : 'Automatic'; + const result = log.status === 'failed' ? 'Failed' : 'Complete'; + return `${prefix} Rename ${result}`; +} + +/** + * Parse season number from filename (e.g., "S10E21" -> 10) + */ +function parseSeasonNumber(filename: string): number | null { + const match = filename.match(/S(\d+)E\d+/i); + return match ? parseInt(match[1], 10) : null; +} + +/** + * Format a single file entry + */ +function formatFileEntry(file: { existingPath: string; newPath: string }): string { + return `Before: ${getFilename(file.existingPath)}\n\nAfter: ${getFilename(file.newPath)}`; +} + +/** + * Build content fields for an item, grouping by season for series + * Returns multiple fields if needed (per season, with parts if a season is too large) + */ +function buildItemFields( + title: string, + files: { existingPath: string; newPath: string }[], + isSonarr: boolean +): { name: string; value: string }[] { + const fields: { name: string; value: string }[] = []; + + if (!isSonarr || files.length === 0) { + // For Radarr or empty, just build fields splitting by size + const chunks = splitFilesIntoChunks(files); + for (let i = 0; i < chunks.length; i++) { + const name = chunks.length > 1 ? `${title} (Part ${i + 1})` : title; + fields.push({ + name: truncateFieldName(name), + value: formatChunk(chunks[i]) + }); + } + return fields; + } + + // For Sonarr, group by season + const bySeasonMap = new Map(); + const noSeason: { existingPath: string; newPath: string }[] = []; + + for (const file of files) { + const season = parseSeasonNumber(getFilename(file.existingPath)); + if (season !== null) { + if (!bySeasonMap.has(season)) { + bySeasonMap.set(season, []); + } + bySeasonMap.get(season)!.push(file); + } else { + noSeason.push(file); + } + } + + // Sort seasons + const seasons = Array.from(bySeasonMap.keys()).sort((a, b) => a - b); + + for (const season of seasons) { + const seasonFiles = bySeasonMap.get(season)!; + const chunks = splitFilesIntoChunks(seasonFiles); + + for (let i = 0; i < chunks.length; i++) { + const name = chunks.length > 1 + ? `${title} - Season ${season} (Part ${i + 1})` + : `${title} - Season ${season}`; + fields.push({ + name: truncateFieldName(name), + value: formatChunk(chunks[i]) + }); + } + } + + // Handle files without season info + if (noSeason.length > 0) { + const chunks = splitFilesIntoChunks(noSeason); + for (let i = 0; i < chunks.length; i++) { + const name = chunks.length > 1 ? `${title} (Part ${i + 1})` : title; + fields.push({ + name: truncateFieldName(name), + value: formatChunk(chunks[i]) + }); + } + } + + return fields; +} + +/** + * Split files into chunks that fit within field value limit + */ +function splitFilesIntoChunks( + files: { existingPath: string; newPath: string }[] +): { existingPath: string; newPath: string }[][] { + const chunks: { existingPath: string; newPath: string }[][] = []; + let currentChunk: { existingPath: string; newPath: string }[] = []; + let currentLength = 0; + const codeBlockOverhead = 8; // ```\n and \n``` + + for (const file of files) { + const entry = formatFileEntry(file); + const separator = currentChunk.length > 0 ? 4 : 0; // \n\n between entries + const entryLength = entry.length + separator; + + if (currentLength + entryLength + codeBlockOverhead > MAX_FIELD_VALUE && currentChunk.length > 0) { + chunks.push(currentChunk); + currentChunk = []; + currentLength = 0; + } + + currentChunk.push(file); + currentLength += entryLength; + } + + if (currentChunk.length > 0) { + chunks.push(currentChunk); + } + + return chunks; +} + +/** + * Format a chunk of files as a code block + */ +function formatChunk(files: { existingPath: string; newPath: string }[]): string { + const lines = files.map(formatFileEntry); + return '```\n' + lines.join('\n\n') + '\n```'; +} + +/** + * Truncate field name to fit Discord limits + */ +function truncateFieldName(name: string): string { + if (name.length <= MAX_FIELD_NAME) return name; + return name.slice(0, MAX_FIELD_NAME - 3) + '...'; +} + +/** + * Calculate the character count of an embed's current content + */ +function getEmbedSize(embed: EmbedBuilder): number { + const built = embed.build(); + let size = 0; + if (built.author?.name) size += built.author.name.length; + if (built.title) size += built.title.length; + if (built.description) size += built.description.length; + if (built.footer?.text) size += built.footer.text.length; + if (built.timestamp) size += built.timestamp.length; + if (built.fields) { + for (const field of built.fields) { + size += field.name.length + field.value.length; + } + } + return size; +} + +/** + * Get the field count of an embed + */ +function getFieldCount(embed: EmbedBuilder): number { + const built = embed.build(); + return built.fields?.length || 0; +} + +/** + * Start a new embed with author, title, and stats fields + */ +function startNewEmbed( + log: RenameJobLog, + config: { username?: string; avatar_url?: string }, + page: number +): EmbedBuilder { + const embed = createEmbed() + .author(config.username || 'Profilarr', config.avatar_url) + .title(`${getTitle(log)} - ${log.instanceName}`) + .color(Colors.INFO) + .timestamp() + .footer(`Type: rename.${log.status}`); + + // Stats fields + if (log.config.dryRun) { + embed.field('Mode', 'Dry Run', true); + } else { + embed.field('Files', `${log.results.filesRenamed}/${log.results.filesNeedingRename}`, true); + + if (log.config.renameFolders) { + embed.field('Folders', String(log.results.foldersRenamed), true); + } + } + + if (page > 1) { + embed.field('Page', String(page), true); + } + + return embed; +} + +/** + * Notification for rename job completion + */ +export const rename = ({ log, config }: RenameNotificationParams) => { + const embeds: EmbedBuilder[] = []; + + // If no files to rename, single embed with just stats + if (log.renamedItems.length === 0) { + const embed = startNewEmbed(log, config, 1); + embed.field('Status', 'No files needed renaming', false); + embeds.push(embed); + + return notify(`rename.${log.status}`) + .generic(getTitle(log), `No files needed renaming for ${log.instanceName}`) + .discord((d) => { + for (const e of embeds) d.embed(e); + return d; + }); + } + + // Build content fields for each renamed item + const isSonarr = log.instanceType === 'sonarr'; + const contentFields: { name: string; value: string }[] = []; + for (const item of log.renamedItems) { + const itemFields = buildItemFields(item.title, item.files, isSonarr); + contentFields.push(...itemFields); + } + + // Build embeds, counting as we go + let page = 1; + let embed = startNewEmbed(log, config, page); + + for (const field of contentFields) { + const fieldChars = field.name.length + field.value.length; + const currentSize = getEmbedSize(embed); + const currentFieldCount = getFieldCount(embed); + + // Would this field push us over the character or field limit? + if (currentSize + fieldChars > MAX_EMBED_SIZE || currentFieldCount >= MAX_FIELDS_PER_EMBED) { + // Finish current embed and start a new one + embeds.push(embed); + page++; + embed = startNewEmbed(log, config, page); + } + + embed.field(field.name, field.value, false); + } + + // Don't forget the last embed + embeds.push(embed); + + const genericMessage = + log.status === 'failed' + ? `Rename failed for ${log.instanceName}` + : `Renamed ${log.results.filesRenamed} files for ${log.instanceName}`; + + return notify(`rename.${log.status}`) + .generic(getTitle(log), genericMessage) + .discord((d) => { + for (const e of embeds) d.embed(e); + return d; + }); +}; diff --git a/src/lib/server/notifications/definitions/test.ts b/src/lib/server/notifications/definitions/test.ts new file mode 100644 index 0000000..38dcf80 --- /dev/null +++ b/src/lib/server/notifications/definitions/test.ts @@ -0,0 +1,28 @@ +/** + * Test notification definition + */ + +import { notify, createEmbed, Colors } from '../builder.ts'; + +interface TestNotificationParams { + config: { username?: string; avatar_url?: string }; +} + +/** + * Test notification for verifying service configuration + */ +export const test = ({ config }: TestNotificationParams) => + notify('test') + .generic('Test Notification', 'This is a test notification from Profilarr.') + .discord((d) => + d.embed( + createEmbed() + .author(config.username || 'Profilarr', config.avatar_url) + .description( + 'This is a test notification from Profilarr. If you received this, your notification service is working correctly!' + ) + .color(Colors.INFO) + .timestamp() + .footer('Type: notifier.test') + ) + ); diff --git a/src/lib/server/notifications/notifiers/DiscordNotifier.ts b/src/lib/server/notifications/notifiers/DiscordNotifier.ts deleted file mode 100644 index 92a5b45..0000000 --- a/src/lib/server/notifications/notifiers/DiscordNotifier.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { BaseHttpNotifier } from '../base/BaseHttpNotifier.ts'; -import type { DiscordConfig, Notification } from '../types.ts'; - -/** - * Discord embed color constants - */ -const COLORS = { - SUCCESS: 0x00ff00, // Green - FAILED: 0xff0000, // Red - ERROR: 0xff0000, // Red - INFO: 0x0099ff, // Blue - WARNING: 0xffaa00 // Orange -} as const; - -/** - * Discord notification service implementation - */ -export class DiscordNotifier extends BaseHttpNotifier { - constructor(private config: DiscordConfig) { - super(); - } - - getName(): string { - return 'Discord'; - } - - protected getWebhookUrl(): string { - return this.config.webhook_url; - } - - protected formatPayload(notification: Notification) { - const color = this.getColorForType(notification.type); - - return { - username: this.config.username || 'Profilarr', - avatar_url: this.config.avatar_url, - content: this.config.enable_mentions ? '@here' : undefined, - embeds: [ - { - title: notification.title, - description: notification.message, - color, - timestamp: new Date().toISOString(), - footer: { - text: `Type: ${notification.type}` - } - } - ] - }; - } - - /** - * Determine embed color based on notification type - */ - private getColorForType(type: string): number { - const lowerType = type.toLowerCase(); - - if (lowerType.includes('success')) { - return COLORS.SUCCESS; - } - if (lowerType.includes('failed') || lowerType.includes('error')) { - return COLORS.ERROR; - } - if (lowerType.includes('warning') || lowerType.includes('warn')) { - return COLORS.WARNING; - } - - return COLORS.INFO; - } -} diff --git a/src/lib/server/notifications/notifiers/discord/DiscordNotifier.ts b/src/lib/server/notifications/notifiers/discord/DiscordNotifier.ts new file mode 100644 index 0000000..074414c --- /dev/null +++ b/src/lib/server/notifications/notifiers/discord/DiscordNotifier.ts @@ -0,0 +1,165 @@ +import { logger } from '$logger/logger.ts'; +import type { DiscordConfig, Notification } from '../../types.ts'; +import { Colors, type DiscordEmbed } from './embed.ts'; + +const RATE_LIMIT_DELAY = 1000; // 1 second between messages + +/** + * Calculate Discord's character count for an embed + * Only counts: title, description, author.name, footer.text, field names/values + */ +function getEmbedCharCount(embed: DiscordEmbed): number { + let size = 0; + if (embed.author?.name) size += embed.author.name.length; + if (embed.title) size += embed.title.length; + if (embed.description) size += embed.description.length; + if (embed.footer?.text) size += embed.footer.text.length; + if (embed.fields) { + for (const field of embed.fields) { + size += field.name.length + field.value.length; + } + } + return size; +} + +/** + * Discord notification service implementation + * Handles splitting large notifications across multiple messages + */ +export class DiscordNotifier { + constructor(private config: DiscordConfig) {} + + getName(): string { + return 'Discord'; + } + + /** + * Send notification, splitting into multiple messages if needed + */ + async notify(notification: Notification): Promise { + const allEmbeds = this.getEmbeds(notification); + const chunks = this.chunkEmbeds(allEmbeds); + + for (let i = 0; i < chunks.length; i++) { + // Rate limit between messages + if (i > 0) { + await this.sleep(RATE_LIMIT_DELAY); + } + + const payload = { + username: this.config.username || 'Profilarr', + avatar_url: this.config.avatar_url, + content: i === 0 && this.config.enable_mentions ? '@here' : undefined, + embeds: chunks[i] + }; + + await this.sendWebhook(payload); + } + } + + /** + * Extract embeds from notification + */ + private getEmbeds(notification: Notification): DiscordEmbed[] { + // Use Discord-specific embeds if provided + if (notification.discord?.embeds && notification.discord.embeds.length > 0) { + return notification.discord.embeds; + } + + // Fall back to generic content + if (notification.generic) { + const color = this.getColorForType(notification.type); + return [ + { + title: notification.generic.title, + description: notification.generic.message, + color, + timestamp: new Date().toISOString(), + footer: { text: 'Profilarr' } + } + ]; + } + + // Empty notification + return [ + { + title: 'Notification', + description: 'No content provided', + color: Colors.INFO, + timestamp: new Date().toISOString(), + footer: { text: 'Profilarr' } + } + ]; + } + + /** + * Split embeds into 1 per message + */ + private chunkEmbeds(embeds: DiscordEmbed[]): DiscordEmbed[][] { + return embeds.map((embed) => [embed]); + } + + /** + * Send a single webhook request + */ + private async sendWebhook(payload: unknown): Promise { + const payloadObj = payload as { embeds?: unknown[] }; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch(this.config.webhook_url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Request timeout'); + } + + const embedCharCounts = payloadObj.embeds?.map((e, i) => `${i}:${getEmbedCharCount(e as DiscordEmbed)}`).join(', ') || 'none'; + await logger.error('Failed to send notification', { + source: this.getName(), + meta: { + error: error instanceof Error ? error.message : String(error), + embedCharCounts + } + }); + throw error; + } + } + + /** + * Determine embed color based on notification type + */ + private getColorForType(type: string): number { + const lowerType = type.toLowerCase(); + + if (lowerType.includes('success')) { + return Colors.SUCCESS; + } + if (lowerType.includes('failed') || lowerType.includes('error')) { + return Colors.ERROR; + } + if (lowerType.includes('warning') || lowerType.includes('warn') || lowerType.includes('partial')) { + return Colors.WARNING; + } + + return Colors.INFO; + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/lib/server/notifications/notifiers/discord/embed.ts b/src/lib/server/notifications/notifiers/discord/embed.ts new file mode 100644 index 0000000..3ab5a62 --- /dev/null +++ b/src/lib/server/notifications/notifiers/discord/embed.ts @@ -0,0 +1,238 @@ +/** + * Discord Embed Builder + * Fluent API for constructing Discord webhook embeds + */ + +/** + * Discord embed color constants + */ +export const Colors = { + SUCCESS: 0x00ff00, + FAILED: 0xff0000, + ERROR: 0xff0000, + INFO: 0x0099ff, + WARNING: 0xffaa00, + PREVIEW: 0x9b59b6 +} as const; + +/** + * Instance type icons + */ +export const Icons = { + RADARR: '🎬', + SONARR: '📺', + LIDARR: '🎵', + READARR: '📚', + FOLDER: '📁' +} as const; + +/** + * Get icon for an instance type + */ +export function getInstanceIcon(type: string): string { + const icons: Record = { + radarr: Icons.RADARR, + sonarr: Icons.SONARR, + lidarr: Icons.LIDARR, + readarr: Icons.READARR + }; + return icons[type.toLowerCase()] || Icons.FOLDER; +} + +/** + * Discord embed field structure + */ +export interface EmbedField { + name: string; + value: string; + inline?: boolean; +} + +/** + * Discord embed author structure + */ +export interface EmbedAuthor { + name: string; + url?: string; + icon_url?: string; +} + +/** + * Discord embed footer structure + */ +export interface EmbedFooter { + text: string; + icon_url?: string; +} + +/** + * Discord embed structure + */ +export interface DiscordEmbed { + title?: string; + description?: string; + url?: string; + color?: number; + timestamp?: string; + author?: EmbedAuthor; + footer?: EmbedFooter; + fields?: EmbedField[]; + thumbnail?: { url: string }; + image?: { url: string }; +} + +/** + * Fluent builder for Discord embeds + * + * @example + * const embed = new EmbedBuilder() + * .author('🎬 Radarr') + * .title('Rename Complete') + * .description('Renamed 5 files') + * .field('Files', '5/5', true) + * .field('Mode', 'Live', true) + * .color(Colors.SUCCESS) + * .timestamp() + * .footer('Profilarr') + * .build(); + */ +export class EmbedBuilder { + private data: DiscordEmbed = {}; + + /** + * Set the embed title + */ + title(text: string): this { + this.data.title = text; + return this; + } + + /** + * Set the embed description + */ + description(text: string): this { + this.data.description = text; + return this; + } + + /** + * Build description from multiple lines + * Filters out null/undefined/empty/false values + */ + lines(messageLines: (string | null | undefined | false)[]): this { + this.data.description = messageLines.filter(Boolean).join('\n').trim(); + return this; + } + + /** + * Set the embed URL (makes title clickable) + */ + url(link: string): this { + this.data.url = link; + return this; + } + + /** + * Set the embed color + */ + color(value: number): this { + this.data.color = value; + return this; + } + + /** + * Set the embed timestamp + * @param date - Date to use, defaults to now + */ + timestamp(date?: Date): this { + this.data.timestamp = (date || new Date()).toISOString(); + return this; + } + + /** + * Set the embed author + */ + author(name: string, iconUrl?: string, url?: string): this { + this.data.author = { name }; + if (iconUrl) this.data.author.icon_url = iconUrl; + if (url) this.data.author.url = url; + return this; + } + + /** + * Set the embed footer + */ + footer(text: string, iconUrl?: string): this { + this.data.footer = { text }; + if (iconUrl) this.data.footer.icon_url = iconUrl; + return this; + } + + /** + * Add a field to the embed + */ + field(name: string, value: string, inline?: boolean): this { + if (!this.data.fields) { + this.data.fields = []; + } + this.data.fields.push({ name, value, inline }); + return this; + } + + /** + * Add multiple fields at once + */ + fields(fieldList: EmbedField[]): this { + if (!this.data.fields) { + this.data.fields = []; + } + this.data.fields.push(...fieldList); + return this; + } + + /** + * Conditionally add a field + */ + fieldIf(condition: boolean, name: string, value: string, inline?: boolean): this { + if (condition) { + this.field(name, value, inline); + } + return this; + } + + /** + * Set the embed thumbnail + */ + thumbnail(imageUrl: string): this { + this.data.thumbnail = { url: imageUrl }; + return this; + } + + /** + * Set the embed image + */ + image(imageUrl: string): this { + this.data.image = { url: imageUrl }; + return this; + } + + /** + * Build the final embed object + */ + build(): DiscordEmbed { + return { ...this.data }; + } +} + +/** + * Create a new embed builder + * + * @example + * const embed = createEmbed() + * .title('Success') + * .color(Colors.SUCCESS) + * .build(); + */ +export function createEmbed(): EmbedBuilder { + return new EmbedBuilder(); +} diff --git a/src/lib/server/notifications/notifiers/discord/index.ts b/src/lib/server/notifications/notifiers/discord/index.ts new file mode 100644 index 0000000..273188c --- /dev/null +++ b/src/lib/server/notifications/notifiers/discord/index.ts @@ -0,0 +1,16 @@ +/** + * Discord notification utilities + */ + +export { DiscordNotifier } from './DiscordNotifier.ts'; +export { + EmbedBuilder, + createEmbed, + Colors, + Icons, + getInstanceIcon, + type DiscordEmbed, + type EmbedField, + type EmbedAuthor, + type EmbedFooter +} from './embed.ts'; diff --git a/src/lib/server/notifications/notifiers/index.ts b/src/lib/server/notifications/notifiers/index.ts index bd45e4b..b00d930 100644 --- a/src/lib/server/notifications/notifiers/index.ts +++ b/src/lib/server/notifications/notifiers/index.ts @@ -2,4 +2,4 @@ * Export all notification service implementations */ -export { DiscordNotifier } from './DiscordNotifier.ts'; \ No newline at end of file +export { DiscordNotifier } from './discord/DiscordNotifier.ts'; \ No newline at end of file diff --git a/src/lib/server/notifications/types.ts b/src/lib/server/notifications/types.ts index 909291f..6da3ffd 100644 --- a/src/lib/server/notifications/types.ts +++ b/src/lib/server/notifications/types.ts @@ -20,17 +20,38 @@ export const NotificationTypes = { // Upgrades UPGRADE_SUCCESS: 'upgrade.success', UPGRADE_PARTIAL: 'upgrade.partial', - UPGRADE_FAILED: 'upgrade.failed' + UPGRADE_FAILED: 'upgrade.failed', + + // Renames + RENAME_SUCCESS: 'rename.success', + RENAME_PARTIAL: 'rename.partial', + RENAME_FAILED: 'rename.failed' } as const; +/** + * Generic notification content (works for all services) + */ +export interface GenericNotification { + title: string; + message: string; +} + +/** + * Discord-specific notification content + */ +export interface DiscordNotification { + embeds: unknown[]; +} + /** * Notification payload sent to services */ export interface Notification { type: string; - title: string; - message: string; - metadata?: Record; + /** Generic content - used by services without specific payload */ + generic?: GenericNotification; + /** Discord-specific content - used if present, otherwise falls back to generic */ + discord?: DiscordNotification; } /** diff --git a/src/lib/server/pcd/pcd.ts b/src/lib/server/pcd/pcd.ts index e4190a9..c96320a 100644 --- a/src/lib/server/pcd/pcd.ts +++ b/src/lib/server/pcd/pcd.ts @@ -8,8 +8,6 @@ import type { DatabaseInstance } from '$db/queries/databaseInstances.ts'; import { loadManifest, type Manifest } from './manifest.ts'; import { getPCDPath } from './paths.ts'; import { processDependencies, syncDependencies } from './deps.ts'; -import { notify } from '$notifications/builder.ts'; -import { NotificationTypes } from '$notifications/types.ts'; import { compile, invalidate, startWatch, getCache } from './cache.ts'; import { logger } from '$logger/logger.ts'; @@ -102,12 +100,6 @@ class PCDManager { } } - await notify(NotificationTypes.PCD_LINKED) - .title('Database Linked') - .message(`Database "${options.name}" has been linked successfully`) - .meta({ databaseId: id, databaseName: options.name, repositoryUrl: options.repositoryUrl }) - .send(); - return instance; } catch (error) { // Cleanup on failure - remove cloned directory @@ -129,9 +121,6 @@ class PCDManager { throw new Error(`Database instance ${id} not found`); } - // Store name and URL for notification - const { name, repository_url } = instance; - // Invalidate cache first invalidate(id); @@ -145,12 +134,6 @@ class PCDManager { // Log but don't throw - database entry is already deleted console.error(`Failed to remove PCD directory ${instance.local_path}:`, error); } - - await notify(NotificationTypes.PCD_UNLINKED) - .title('Database Unlinked') - .message(`Database "${name}" has been removed`) - .meta({ databaseId: id, databaseName: name, repositoryUrl: repository_url }) - .send(); } /** diff --git a/src/lib/server/rename/logger.ts b/src/lib/server/rename/logger.ts new file mode 100644 index 0000000..9ff9a66 --- /dev/null +++ b/src/lib/server/rename/logger.ts @@ -0,0 +1,91 @@ +/** + * Structured logging for rename jobs + * Uses the shared logger with source 'RenameJob' + */ + +import { logger } from '$logger/logger.ts'; +import type { RenameJobLog } from './types.ts'; + +const SOURCE = 'RenameJob'; + +/** + * Log a rename run with structured data + */ +export async function logRenameRun(log: RenameJobLog): Promise { + const duration = new Date(log.completedAt).getTime() - new Date(log.startedAt).getTime(); + + const parts = ['rename']; + if (log.config.dryRun) parts.unshift('dry run'); + if (log.config.manual) parts.unshift('manual'); + const mode = parts.join(' '); + + const summary = `Completed Job: ${mode} for "${log.instanceName}": ${log.results.filesRenamed}/${log.results.filesNeedingRename} files renamed (${duration}ms)`; + + const items = log.renamedItems.map((item) => ({ + title: item.title, + files: item.files + })); + + await logger.info(summary, { + source: SOURCE, + meta: { + instanceId: log.instanceId, + status: log.status, + dryRun: log.config.dryRun, + manual: log.config.manual, + filesNeedingRename: log.results.filesNeedingRename, + filesRenamed: log.results.filesRenamed, + foldersRenamed: log.results.foldersRenamed, + skippedByTag: log.filtering.skippedByTag, + durationMs: duration, + items + } + }); +} + +/** + * Log when a rename config is skipped + */ +export async function logRenameSkipped( + instanceId: number, + instanceName: string, + reason: string +): Promise { + await logger.debug(`Skipped ${instanceName}: ${reason}`, { + source: SOURCE, + meta: { instanceId, reason } + }); +} + +/** + * Log when rename processing starts + */ +export async function logRenameStart( + instanceId: number, + instanceName: string, + dryRun: boolean, + manual: boolean = false +): Promise { + const parts = ['rename']; + if (dryRun) parts.unshift('dry run'); + if (manual) parts.unshift('manual'); + const mode = parts.join(' '); + await logger.debug(`Starting Job: ${mode} for "${instanceName}"`, { + source: SOURCE, + meta: { instanceId, dryRun, manual } + }); +} + +/** + * Log errors during rename processing + */ +export async function logRenameError( + instanceId: number, + instanceName: string, + error: string +): Promise { + await logger.error(`Rename failed for ${instanceName}: ${error}`, { + source: SOURCE, + meta: { instanceId, error } + }); +} diff --git a/src/lib/server/rename/processor.ts b/src/lib/server/rename/processor.ts new file mode 100644 index 0000000..0fc6357 --- /dev/null +++ b/src/lib/server/rename/processor.ts @@ -0,0 +1,542 @@ +/** + * Main orchestrator for processing rename configs + * Coordinates fetching, filtering, and renaming files/folders + */ + +import { RadarrClient } from '$lib/server/utils/arr/clients/radarr.ts'; +import { SonarrClient } from '$lib/server/utils/arr/clients/sonarr.ts'; +import type { ArrInstance } from '$lib/server/db/queries/arrInstances.ts'; +import type { RenameSettings } from '$lib/server/db/queries/arrRenameSettings.ts'; +import type { RenameJobLog, RenameItem } from './types.ts'; +import { logRenameRun, logRenameStart, logRenameError, logRenameSkipped } from './logger.ts'; +import { notifications } from '$lib/server/notifications/definitions/index.ts'; +import { notificationServicesQueries } from '$lib/server/db/queries/notificationServices.ts'; + +/** + * Create an empty/skipped job log + */ +function createSkippedLog( + settings: RenameSettings, + instance: ArrInstance, + reason: string, + manual: boolean = false +): RenameJobLog { + const now = new Date().toISOString(); + return { + id: crypto.randomUUID(), + instanceId: instance.id, + instanceName: instance.name, + instanceType: instance.type as 'radarr' | 'sonarr', + startedAt: now, + completedAt: now, + status: 'skipped', + config: { + dryRun: settings.dryRun, + renameFolders: settings.renameFolders, + ignoreTag: settings.ignoreTag, + manual + }, + library: { + totalItems: 0, + fetchDurationMs: 0 + }, + filtering: { + afterIgnoreTag: 0, + skippedByTag: 0 + }, + results: { + filesNeedingRename: 0, + filesRenamed: 0, + foldersRenamed: 0, + commandsTriggered: 0, + commandsCompleted: 0, + commandsFailed: 0, + errors: [reason] + }, + renamedItems: [] + }; +} + +/** + * Send rename notification + */ +async function sendRenameNotification(log: RenameJobLog): Promise { + // Only notify if there were files to rename + if (log.results.filesNeedingRename > 0) { + const { DiscordNotifier } = await import('$lib/server/notifications/notifiers/discord/index.ts'); + + // Get all enabled services that have this notification type enabled + const services = notificationServicesQueries.getAllEnabled(); + const notificationType = `rename.${log.status}`; + + for (const service of services) { + try { + const enabledTypes = JSON.parse(service.enabled_types) as string[]; + if (!enabledTypes.includes(notificationType)) { + continue; + } + + const config = JSON.parse(service.config); + + if (service.service_type === 'discord') { + const notifier = new DiscordNotifier(config); + const notification = notifications.rename({ log, config }).build(); + await notifier.notify(notification); + } + } catch { + // Errors are logged by the notifier + } + } + } +} + +/** + * Process rename for a Radarr instance + */ +async function processRadarrRename( + client: RadarrClient, + settings: RenameSettings, + instance: ArrInstance, + startedAt: Date, + logId: string, + manual: boolean +): Promise { + const fetchStart = Date.now(); + + // Fetch movies and tags + const [movies, tags] = await Promise.all([client.getMovies(), client.getTags()]); + const fetchDurationMs = Date.now() - fetchStart; + + // Find the ignore tag ID if configured + let ignoreTagId: number | null = null; + if (settings.ignoreTag) { + const ignoreTag = tags.find( + (t) => t.label.toLowerCase() === settings.ignoreTag!.toLowerCase() + ); + ignoreTagId = ignoreTag?.id ?? null; + } + + // Filter out items with the ignore tag + const filteredMovies = ignoreTagId + ? movies.filter((m) => !m.tags?.includes(ignoreTagId!)) + : movies; + + // Get rename previews for each movie that has a file + const moviesWithFiles = filteredMovies.filter((m) => m.hasFile); + const renameItems: RenameItem[] = []; + + for (const movie of moviesWithFiles) { + const previews = await client.getRenamePreview(movie.id); + if (previews.length > 0) { + renameItems.push({ + id: movie.id, + title: movie.title, + previews + }); + } + } + + const filesNeedingRename = renameItems.reduce((sum, item) => sum + item.previews.length, 0); + + // If dry run, just return the preview info + if (settings.dryRun) { + const log: RenameJobLog = { + id: logId, + instanceId: instance.id, + instanceName: instance.name, + instanceType: 'radarr', + startedAt: startedAt.toISOString(), + completedAt: new Date().toISOString(), + status: 'success', + config: { + dryRun: true, + renameFolders: settings.renameFolders, + ignoreTag: settings.ignoreTag, + manual + }, + library: { + totalItems: movies.length, + fetchDurationMs + }, + filtering: { + afterIgnoreTag: filteredMovies.length, + skippedByTag: movies.length - filteredMovies.length + }, + results: { + filesNeedingRename, + filesRenamed: 0, + foldersRenamed: 0, + commandsTriggered: 0, + commandsCompleted: 0, + commandsFailed: 0, + errors: ['[DRY RUN] Rename skipped'] + }, + renamedItems: renameItems.map((item) => ({ + id: item.id, + title: item.title, + files: item.previews.map((p) => ({ existingPath: p.existingPath, newPath: p.newPath })) + })) + }; + + await logRenameRun(log); + sendRenameNotification(log); + return log; + } + + // Execute renames + let filesRenamed = 0; + let commandsTriggered = 0; + let commandsCompleted = 0; + let commandsFailed = 0; + const errors: string[] = []; + const renamedItems: { id: number; title: string; files: { existingPath: string; newPath: string }[] }[] = []; + + if (renameItems.length > 0) { + const movieIds = renameItems.map((item) => item.id); + + try { + // Fire rename command without waiting - Radarr processes in background + await client.renameMovies(movieIds); + commandsTriggered++; + commandsCompleted++; + + for (const item of renameItems) { + filesRenamed += item.previews.length; + renamedItems.push({ + id: item.id, + title: item.title, + files: item.previews.map((p) => ({ existingPath: p.existingPath, newPath: p.newPath })) + }); + } + } catch (error) { + commandsFailed++; + errors.push( + `Failed to rename movies: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + // Rename folders if enabled + let foldersRenamed = 0; + if (settings.renameFolders && renamedItems.length > 0) { + // Group movies by root folder path for batch operation + const movieIds = renamedItems.map((item) => item.id); + + // Get movies to find their root folder paths + const moviesToRename = movies.filter((m) => movieIds.includes(m.id)); + const rootFolderPaths = [...new Set(moviesToRename.map((m) => m.rootFolderPath).filter(Boolean))]; + + for (const rootPath of rootFolderPaths) { + const movieIdsInPath = moviesToRename + .filter((m) => m.rootFolderPath === rootPath) + .map((m) => m.id); + + try { + await client.renameMovieFolders(movieIdsInPath, rootPath!); + foldersRenamed += movieIdsInPath.length; + + // Fire refresh without waiting - Radarr processes in background + await client.refreshMovies(movieIdsInPath); + } catch (error) { + errors.push( + `Failed to rename folders in ${rootPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + } + + const log: RenameJobLog = { + id: logId, + instanceId: instance.id, + instanceName: instance.name, + instanceType: 'radarr', + startedAt: startedAt.toISOString(), + completedAt: new Date().toISOString(), + status: commandsFailed > 0 && commandsCompleted === 0 ? 'failed' : commandsFailed > 0 ? 'partial' : 'success', + config: { + dryRun: false, + renameFolders: settings.renameFolders, + ignoreTag: settings.ignoreTag, + manual + }, + library: { + totalItems: movies.length, + fetchDurationMs + }, + filtering: { + afterIgnoreTag: filteredMovies.length, + skippedByTag: movies.length - filteredMovies.length + }, + results: { + filesNeedingRename, + filesRenamed, + foldersRenamed, + commandsTriggered, + commandsCompleted, + commandsFailed, + errors + }, + renamedItems + }; + + await logRenameRun(log); + sendRenameNotification(log); + return log; +} + +/** + * Process rename for a Sonarr instance + */ +async function processSonarrRename( + client: SonarrClient, + settings: RenameSettings, + instance: ArrInstance, + startedAt: Date, + logId: string, + manual: boolean +): Promise { + const fetchStart = Date.now(); + + // Fetch series and tags + const [series, tags] = await Promise.all([client.getAllSeries(), client.getTags()]); + const fetchDurationMs = Date.now() - fetchStart; + + // Find the ignore tag ID if configured + let ignoreTagId: number | null = null; + if (settings.ignoreTag) { + const ignoreTag = tags.find( + (t) => t.label.toLowerCase() === settings.ignoreTag!.toLowerCase() + ); + ignoreTagId = ignoreTag?.id ?? null; + } + + // Filter out items with the ignore tag + const filteredSeries = ignoreTagId + ? series.filter((s) => !s.tags?.includes(ignoreTagId!)) + : series; + + // Get rename previews for each series that has files + const seriesWithFiles = filteredSeries.filter( + (s) => s.statistics && s.statistics.episodeFileCount > 0 + ); + const renameItems: RenameItem[] = []; + + for (const show of seriesWithFiles) { + const previews = await client.getRenamePreview(show.id); + if (previews.length > 0) { + renameItems.push({ + id: show.id, + title: show.title, + previews + }); + } + } + + const filesNeedingRename = renameItems.reduce((sum, item) => sum + item.previews.length, 0); + + // If dry run, just return the preview info + if (settings.dryRun) { + const log: RenameJobLog = { + id: logId, + instanceId: instance.id, + instanceName: instance.name, + instanceType: 'sonarr', + startedAt: startedAt.toISOString(), + completedAt: new Date().toISOString(), + status: 'success', + config: { + dryRun: true, + renameFolders: settings.renameFolders, + ignoreTag: settings.ignoreTag, + manual + }, + library: { + totalItems: series.length, + fetchDurationMs + }, + filtering: { + afterIgnoreTag: filteredSeries.length, + skippedByTag: series.length - filteredSeries.length + }, + results: { + filesNeedingRename, + filesRenamed: 0, + foldersRenamed: 0, + commandsTriggered: 0, + commandsCompleted: 0, + commandsFailed: 0, + errors: ['[DRY RUN] Rename skipped'] + }, + renamedItems: renameItems.map((item) => ({ + id: item.id, + title: item.title, + files: item.previews.map((p) => ({ existingPath: p.existingPath, newPath: p.newPath })) + })) + }; + + await logRenameRun(log); + sendRenameNotification(log); + return log; + } + + // Execute renames + let filesRenamed = 0; + let commandsTriggered = 0; + let commandsCompleted = 0; + let commandsFailed = 0; + const errors: string[] = []; + const renamedItems: { id: number; title: string; files: { existingPath: string; newPath: string }[] }[] = []; + + if (renameItems.length > 0) { + const seriesIds = renameItems.map((item) => item.id); + + try { + // Fire rename command without waiting - Sonarr processes in background + await client.renameSeries(seriesIds); + commandsTriggered++; + commandsCompleted++; + + for (const item of renameItems) { + filesRenamed += item.previews.length; + renamedItems.push({ + id: item.id, + title: item.title, + files: item.previews.map((p) => ({ existingPath: p.existingPath, newPath: p.newPath })) + }); + } + } catch (error) { + commandsFailed++; + errors.push( + `Failed to rename series: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + // Rename folders if enabled + let foldersRenamed = 0; + if (settings.renameFolders && renamedItems.length > 0) { + // Group series by root folder path for batch operation + const seriesIds = renamedItems.map((item) => item.id); + + // Get series to find their root folder paths + const seriesToRename = series.filter((s) => seriesIds.includes(s.id)); + const rootFolderPaths = [ + ...new Set( + seriesToRename + .map((s) => { + // Extract root folder from path (path minus last segment) + if (s.path) { + const parts = s.path.split('/'); + parts.pop(); + return parts.join('/'); + } + return null; + }) + .filter(Boolean) + ) + ]; + + for (const rootPath of rootFolderPaths) { + const seriesIdsInPath = seriesToRename + .filter((s) => s.path?.startsWith(rootPath!)) + .map((s) => s.id); + + try { + await client.renameSeriesFolders(seriesIdsInPath, rootPath!); + foldersRenamed += seriesIdsInPath.length; + + // Fire refresh without waiting - Sonarr processes in background + await client.refreshSeries(seriesIdsInPath); + } catch (error) { + errors.push( + `Failed to rename folders in ${rootPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + } + + const log: RenameJobLog = { + id: logId, + instanceId: instance.id, + instanceName: instance.name, + instanceType: 'sonarr', + startedAt: startedAt.toISOString(), + completedAt: new Date().toISOString(), + status: commandsFailed > 0 && commandsCompleted === 0 ? 'failed' : commandsFailed > 0 ? 'partial' : 'success', + config: { + dryRun: false, + renameFolders: settings.renameFolders, + ignoreTag: settings.ignoreTag, + manual + }, + library: { + totalItems: series.length, + fetchDurationMs + }, + filtering: { + afterIgnoreTag: filteredSeries.length, + skippedByTag: series.length - filteredSeries.length + }, + results: { + filesNeedingRename, + filesRenamed, + foldersRenamed, + commandsTriggered, + commandsCompleted, + commandsFailed, + errors + }, + renamedItems + }; + + await logRenameRun(log); + sendRenameNotification(log); + return log; +} + +/** + * Process a single rename config for an arr instance + */ +export async function processRenameConfig( + settings: RenameSettings, + instance: ArrInstance, + manual: boolean = false +): Promise { + const startedAt = new Date(); + const logId = crypto.randomUUID(); + + await logRenameStart(instance.id, instance.name, settings.dryRun, manual); + + try { + if (instance.type === 'radarr') { + const client = new RadarrClient(instance.url, instance.api_key); + try { + return await processRadarrRename(client, settings, instance, startedAt, logId, manual); + } finally { + client.close(); + } + } else if (instance.type === 'sonarr') { + const client = new SonarrClient(instance.url, instance.api_key); + try { + return await processSonarrRename(client, settings, instance, startedAt, logId, manual); + } finally { + client.close(); + } + } else { + const log = createSkippedLog(settings, instance, `Rename not supported for ${instance.type}`, manual); + await logRenameSkipped(instance.id, instance.name, `Rename not supported for ${instance.type}`); + return log; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + await logRenameError(instance.id, instance.name, errorMessage); + + const log = createSkippedLog(settings, instance, errorMessage, manual); + log.id = logId; + log.startedAt = startedAt.toISOString(); + log.completedAt = new Date().toISOString(); + log.status = 'failed'; + + return log; + } +} diff --git a/src/lib/server/rename/types.ts b/src/lib/server/rename/types.ts new file mode 100644 index 0000000..dee97cc --- /dev/null +++ b/src/lib/server/rename/types.ts @@ -0,0 +1,75 @@ +/** + * Types for the rename processing system + */ + +import type { RenamePreviewItem } from '$lib/server/utils/arr/types.ts'; + +/** + * Item that needs to be renamed + * Contains the preview info plus metadata + */ +export interface RenameItem { + id: number; // movieId or seriesId + title: string; + previews: RenamePreviewItem[]; +} + +/** + * Structured log for each rename run + * Contains all metrics and details about what happened + */ +export interface RenameJobLog { + id: string; // UUID + instanceId: number; + instanceName: string; + instanceType: 'radarr' | 'sonarr'; + startedAt: string; + completedAt: string; + status: 'success' | 'partial' | 'failed' | 'skipped'; + + config: { + dryRun: boolean; + renameFolders: boolean; + ignoreTag: string | null; + manual: boolean; + }; + + library: { + totalItems: number; + fetchDurationMs: number; + }; + + filtering: { + afterIgnoreTag: number; // Items remaining after filtering out ignored tag + skippedByTag: number; // Items skipped due to ignore tag + }; + + results: { + // File renames + filesNeedingRename: number; + filesRenamed: number; + // Folder renames (if enabled) + foldersRenamed: number; + // Command tracking + commandsTriggered: number; + commandsCompleted: number; + commandsFailed: number; + errors: string[]; + }; + + // Items that were renamed (for notification details) + renamedItems: { + id: number; + title: string; + files: { existingPath: string; newPath: string }[]; + }[]; +} + +/** + * Result from processing a single rename config + */ +export interface RenameProcessResult { + success: boolean; + log: RenameJobLog; + error?: string; +} diff --git a/src/lib/server/utils/arr/base.ts b/src/lib/server/utils/arr/base.ts index 4d9fda9..fa0bdae 100644 --- a/src/lib/server/utils/arr/base.ts +++ b/src/lib/server/utils/arr/base.ts @@ -8,7 +8,8 @@ import type { ArrQualityDefinition, ArrCustomFormat, ArrQualityProfilePayload, - RadarrQualityProfile + RadarrQualityProfile, + ArrCommand } from './types.ts'; import { logger } from '$logger/logger.ts'; @@ -260,4 +261,49 @@ export class BaseArrClient extends BaseHttpClient { deleteQualityProfile(id: number): Promise { return this.delete(`/api/${this.apiVersion}/qualityprofile/${id}`); } + + // ========================================================================= + // Command Methods + // ========================================================================= + + /** + * Get command status by ID + * Used to poll async operations like rename, refresh, etc. + */ + getCommand(commandId: number): Promise { + return this.get(`/api/${this.apiVersion}/command/${commandId}`); + } + + /** + * Wait for a command to complete + * Polls every 5 seconds until status is 'completed' or 'failed' + * @param commandId - The command ID to wait for + * @param timeoutMs - Maximum wait time in milliseconds (default: 10 minutes) + * @returns The final command status + * @throws Error if command fails or times out + */ + async waitForCommand(commandId: number, timeoutMs: number = 600000): Promise { + const pollInterval = 100; // 100ms + const startTime = Date.now(); + + while (true) { + const command = await this.getCommand(commandId); + + if (command.status === 'completed') { + return command; + } + + if (command.status === 'failed') { + throw new Error(`Command ${commandId} failed: ${command.message || 'Unknown error'}`); + } + + // Check timeout + if (Date.now() - startTime >= timeoutMs) { + throw new Error(`Command ${commandId} timed out after ${timeoutMs / 1000} seconds`); + } + + // Wait before next poll + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + } } diff --git a/src/lib/server/utils/arr/clients/radarr.ts b/src/lib/server/utils/arr/clients/radarr.ts index ca4ccd9..7618d09 100644 --- a/src/lib/server/utils/arr/clients/radarr.ts +++ b/src/lib/server/utils/arr/clients/radarr.ts @@ -8,8 +8,9 @@ import type { CustomFormatRef, QualityProfileFormatItem, RadarrTag, - RadarrCommand, - RadarrRelease + ArrCommand, + RadarrRelease, + RenamePreviewItem } from '../types.ts'; /** @@ -136,8 +137,8 @@ export class RadarrClient extends BaseArrClient { * Trigger a search for specific movies * Uses the MoviesSearch command endpoint */ - searchMovies(movieIds: number[]): Promise { - return this.post(`/api/${this.apiVersion}/command`, { + searchMovies(movieIds: number[]): Promise { + return this.post(`/api/${this.apiVersion}/command`, { name: 'MoviesSearch', movieIds }); @@ -194,4 +195,50 @@ export class RadarrClient extends BaseArrClient { updateMovie(movie: RadarrMovie): Promise { return this.put(`/api/${this.apiVersion}/movie/${movie.id}`, movie); } + + // ========================================================================= + // Rename Methods + // ========================================================================= + + /** + * Get rename preview for a movie + * Shows what files would be renamed without making changes + */ + getRenamePreview(movieId: number): Promise { + return this.get(`/api/${this.apiVersion}/rename?movieId=${movieId}`); + } + + /** + * Trigger rename for movies + * Renames all files that need renaming for the given movie IDs + */ + renameMovies(movieIds: number[]): Promise { + return this.post(`/api/${this.apiVersion}/command`, { + name: 'RenameMovie', + movieIds + }); + } + + /** + * Refresh movies (update metadata from sources) + * Required after folder rename to update paths + */ + refreshMovies(movieIds: number[]): Promise { + return this.post(`/api/${this.apiVersion}/command`, { + name: 'RefreshMovie', + movieIds + }); + } + + /** + * Rename movie folders using the movie editor endpoint + * Bulk updates movie root folder paths + */ + renameMovieFolders(movieIds: number[], rootFolderPath: string): Promise { + return this.put(`/api/${this.apiVersion}/movie/editor`, { + movieIds, + rootFolderPath, + moveFiles: true + }); + } } diff --git a/src/lib/server/utils/arr/clients/sonarr.ts b/src/lib/server/utils/arr/clients/sonarr.ts index 70d199c..5bdd0e9 100644 --- a/src/lib/server/utils/arr/clients/sonarr.ts +++ b/src/lib/server/utils/arr/clients/sonarr.ts @@ -1,5 +1,5 @@ import { BaseArrClient } from '../base.ts'; -import type { SonarrSeries, SonarrRelease } from '../types.ts'; +import type { SonarrSeries, SonarrRelease, ArrCommand, RenamePreviewItem } from '../types.ts'; /** * Sonarr API client @@ -52,4 +52,50 @@ export class SonarrClient extends BaseArrClient { const releases = await this.getReleases(seriesId, seasonNumber); return releases.filter((r) => r.fullSeason); } + + // ========================================================================= + // Rename Methods + // ========================================================================= + + /** + * Get rename preview for a series + * Shows what files would be renamed without making changes + */ + getRenamePreview(seriesId: number): Promise { + return this.get(`/api/${this.apiVersion}/rename?seriesId=${seriesId}`); + } + + /** + * Trigger rename for series + * Renames all files that need renaming for the given series IDs + */ + renameSeries(seriesIds: number[]): Promise { + return this.post(`/api/${this.apiVersion}/command`, { + name: 'RenameSeries', + seriesIds + }); + } + + /** + * Refresh series (update metadata from sources) + * Required after folder rename to update paths + */ + refreshSeries(seriesIds: number[]): Promise { + return this.post(`/api/${this.apiVersion}/command`, { + name: 'RefreshSeries', + seriesIds + }); + } + + /** + * Rename series folders using the series editor endpoint + * Bulk updates series root folder paths + */ + renameSeriesFolders(seriesIds: number[], rootFolderPath: string): Promise { + return this.put(`/api/${this.apiVersion}/series/editor`, { + seriesIds, + rootFolderPath, + moveFiles: true + }); + } } diff --git a/src/lib/server/utils/arr/types.ts b/src/lib/server/utils/arr/types.ts index 7412877..065fb89 100644 --- a/src/lib/server/utils/arr/types.ts +++ b/src/lib/server/utils/arr/types.ts @@ -140,8 +140,9 @@ export interface RadarrTag { /** * Command response from /api/v3/command + * Shared between Radarr and Sonarr */ -export interface RadarrCommand { +export interface ArrCommand { id: number; name: string; commandName: string; @@ -152,10 +153,32 @@ export interface RadarrCommand { message?: string; body?: { movieIds?: number[]; + seriesIds?: number[]; sendUpdatesToClient?: boolean; }; } +/** @deprecated Use ArrCommand instead */ +export type RadarrCommand = ArrCommand; + +/** + * Rename preview item from /api/v3/rename + * Shows what would be renamed before executing + */ +export interface RenamePreviewItem { + // Radarr fields + movieId?: number; + movieFileId?: number; + // Sonarr fields + seriesId?: number; + seasonNumber?: number; + episodeNumbers?: number[]; + episodeFileId?: number; + // Shared fields + existingPath: string; + newPath: string; +} + // ============================================================================= // Release Types (Interactive Search) // ============================================================================= diff --git a/src/lib/shared/notificationTypes.ts b/src/lib/shared/notificationTypes.ts index 9dc0cd9..e0a277c 100644 --- a/src/lib/shared/notificationTypes.ts +++ b/src/lib/shared/notificationTypes.ts @@ -104,6 +104,26 @@ export const notificationTypes: NotificationType[] = [ label: 'Upgrade Failed', category: 'Upgrades', description: 'Notification when all upgrade searches fail' + }, + + // Renames + { + id: 'rename.success', + label: 'Rename Completed (Success)', + category: 'Renames', + description: 'Notification when all file renames complete successfully' + }, + { + id: 'rename.partial', + label: 'Rename Completed (Partial)', + category: 'Renames', + description: 'Notification when some file renames succeed and some fail' + }, + { + id: 'rename.failed', + label: 'Rename Failed', + category: 'Renames', + description: 'Notification when all file renames fail' } ]; diff --git a/src/routes/arr/[id]/+layout.svelte b/src/routes/arr/[id]/+layout.svelte index 25d00cc..887944f 100644 --- a/src/routes/arr/[id]/+layout.svelte +++ b/src/routes/arr/[id]/+layout.svelte @@ -1,7 +1,7 @@ + + + {data.instance.name} - Rename - Profilarr + + +
{ + saving = true; + return async ({ update }) => { + await update({ reset: false }); + saving = false; + }; + }} +> + + + + + + +
+ +
+
+

+ Rename Configuration +

+

+ Automatically rename files and folders to match your naming format. +

+
+ +
+ + {#if !isNewConfig && data.settings?.lastRunAt} + + {/if} + + + + +
+ {#if !isNewConfig} + + {/if} + +
+
+
+ + +{#if !isNewConfig} + +{/if} + + + diff --git a/src/routes/arr/[id]/rename/components/CooldownTracker.svelte b/src/routes/arr/[id]/rename/components/CooldownTracker.svelte new file mode 100644 index 0000000..be6b489 --- /dev/null +++ b/src/routes/arr/[id]/rename/components/CooldownTracker.svelte @@ -0,0 +1,167 @@ + + +{#if lastRunAt} +
+
+ +
+ {#if !enabled} +
+ +
+
+
Paused
+
+ Enable to resume scheduled runs +
+
+ {:else if isDue} +
+ +
+
+
Ready to run
+
+ Will run on next job cycle +
+
+ {:else} +
+ +
+
+
+ Next run in {formatTimeRemaining(timeUntilNext ?? 0)} +
+
+ Every {formatSchedule(schedule)} +
+
+ {/if} +
+ + +
+
+ + Last run +
+
+ {formatDate(lastRunAt)} @ {formatTime(lastRunAt)} +
+
+
+ + + {#if enabled && !isDue} +
+
+
+
+
+ {/if} +
+{/if} diff --git a/src/routes/arr/[id]/rename/components/RenameInfoModal.svelte b/src/routes/arr/[id]/rename/components/RenameInfoModal.svelte new file mode 100644 index 0000000..fcb8e68 --- /dev/null +++ b/src/routes/arr/[id]/rename/components/RenameInfoModal.svelte @@ -0,0 +1,148 @@ + + + +
+ +
+

Overview

+

+ The rename feature scans your library and renames files that don't match your + Radarr/Sonarr naming format. This helps keep your library organized and consistent + without manual intervention. +

+
+ + +
+

Process

+
+
+
+ 1 +
+
+
Fetch Library
+
+ Gets all movies/series from your arr instance +
+
+
+ +
+
+ 2 +
+
+
Filter Items
+
+ Excludes items with the ignore tag (if configured) +
+
+
+ +
+
+ 3 +
+
+
Check Naming
+
+ Compares current file names against your naming format +
+
+
+ +
+
+ 4 +
+
+
Rename Files
+
+ Triggers rename commands for files that need updating +
+
+
+
+
+ + +
+

Settings

+
+
+
+ +
+
+
Dry Run
+
+ When enabled, shows what would be renamed without making changes. Use this to + preview before committing. +
+
+
+ +
+
+ +
+
+
Rename Folders
+
+ Also rename movie/series folders to match the naming format. Requires a metadata + refresh after completion. +
+
+
+ +
+
+ +
+
+
Ignore Tag
+
+ Items with this tag in your arr will be skipped. Useful for items you want to keep + with custom names. +
+
+
+ +
+
+ +
+
+
Schedule
+
+ How often to check for files needing rename. Daily is usually sufficient for most + libraries. +
+
+
+
+
+
+
diff --git a/src/routes/arr/[id]/rename/components/RenameSettings.svelte b/src/routes/arr/[id]/rename/components/RenameSettings.svelte new file mode 100644 index 0000000..4aa2a64 --- /dev/null +++ b/src/routes/arr/[id]/rename/components/RenameSettings.svelte @@ -0,0 +1,165 @@ + + +
+

Rename Settings

+ +
+ +
+ + +

+ How often to check for files that need renaming +

+
+ + +
+ + +

+ Items with this tag will be skipped during rename operations +

+
+ + +
+ +
+
+ +

Run on schedule

+
+ +
+ + +
+
+ +

Preview only

+
+ +
+ + +
+
+ +

Also rename folders

+
+ +
+
+
+
diff --git a/src/routes/arr/[id]/upgrades/+page.server.ts b/src/routes/arr/[id]/upgrades/+page.server.ts index b38ea9c..00dcdf0 100644 --- a/src/routes/arr/[id]/upgrades/+page.server.ts +++ b/src/routes/arr/[id]/upgrades/+page.server.ts @@ -3,8 +3,6 @@ import type { Actions, ServerLoad } from '@sveltejs/kit'; import { arrInstancesQueries } from '$db/queries/arrInstances.ts'; import { upgradeConfigsQueries } from '$db/queries/upgradeConfigs.ts'; import { logger } from '$logger/logger.ts'; -import { notify } from '$notifications/builder.ts'; -import { NotificationTypes } from '$notifications/types.ts'; import type { FilterConfig, FilterMode } from '$lib/shared/filters.ts'; import { processUpgradeConfig } from '$lib/server/upgrades/processor.ts'; @@ -223,30 +221,6 @@ export const actions: Actions = { upgradeConfigsQueries.incrementFilterIndex(id); } - // Send notification - const isSuccess = result.status === 'success' || result.status === 'partial'; - const dryRunLabel = result.config.dryRun ? ' [DRY RUN]' : ''; - const itemsList = result.selection.items.map((i) => i.title).join(', '); - - await notify(isSuccess ? NotificationTypes.UPGRADE_SUCCESS : NotificationTypes.UPGRADE_FAILED) - .title(`${instance.name}: ${result.config.selectedFilter}${dryRunLabel}`) - .lines([ - `Filter: ${result.filter.matchedCount} matched → ${result.filter.afterCooldown} after cooldown`, - `Selection: ${result.selection.actualCount}/${result.selection.requestedCount} items`, - `Results: ${result.results.searchesTriggered} searches, ${result.results.successful} successful`, - itemsList ? `Items: ${itemsList}` : null - ]) - .meta({ - instanceId: id, - instanceName: instance.name, - filterName: result.config.selectedFilter, - itemsSearched: result.selection.actualCount, - matchedCount: result.filter.matchedCount, - dryRun: result.config.dryRun, - items: result.selection.items.map((i) => i.title) - }) - .send(); - return { success: true, runResult: { @@ -260,19 +234,11 @@ export const actions: Actions = { } }; } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Unknown error'; - await logger.error('Manual upgrade run failed', { source: 'upgrades', meta: { instanceId: id, error: err } }); - await notify(NotificationTypes.UPGRADE_FAILED) - .title('Upgrade Failed') - .message(`${instance.name}: ${errorMessage}`) - .meta({ instanceId: id, instanceName: instance.name, error: errorMessage, dryRun: true }) - .send(); - return fail(500, { error: 'Upgrade run failed. Check logs for details.' }); } } diff --git a/src/routes/settings/logs/+page.server.ts b/src/routes/settings/logs/+page.server.ts index 611e191..d8cba82 100644 --- a/src/routes/settings/logs/+page.server.ts +++ b/src/routes/settings/logs/+page.server.ts @@ -8,7 +8,7 @@ export const load = async ({ url }: { url: URL }) => { const selectedFile = url.searchParams.get('file') || logFiles[0]?.filename || ''; // Load logs from selected file or all files if no file selected - const logs = selectedFile ? await readLogsFromFile(selectedFile, 1000) : await readLastLogs(1000); + const logs = selectedFile ? await readLogsFromFile(selectedFile) : await readLastLogs(); return { logs, diff --git a/src/routes/settings/notifications/+page.server.ts b/src/routes/settings/notifications/+page.server.ts index 60cf578..419b416 100644 --- a/src/routes/settings/notifications/+page.server.ts +++ b/src/routes/settings/notifications/+page.server.ts @@ -112,7 +112,9 @@ export const actions: Actions = { } // Send test notification directly (bypass enabled_types filter) - const { DiscordNotifier } = await import('$notifications/notifiers/DiscordNotifier.ts'); + const { DiscordNotifier } = await import('$notifications/notifiers/discord/index.ts'); + const { notifications } = await import('$notifications/definitions/index.ts'); + const config = JSON.parse(service.config); let notifier; @@ -122,16 +124,9 @@ export const actions: Actions = { return fail(400, { error: 'Unknown service type' }); } - await notifier.notify({ - type: 'test', - title: 'Test Notification', - message: 'This is a test notification from Profilarr. If you received this, your notification service is working correctly!', - metadata: { - serviceId: id, - serviceName: service.name, - timestamp: new Date().toISOString() - } - }); + const notification = notifications.test({ config }).build(); + + await notifier.notify(notification); return { success: true }; } catch (err) { diff --git a/src/routes/settings/notifications/components/DiscordConfiguration.svelte b/src/routes/settings/notifications/components/DiscordConfiguration.svelte index 21339c0..190fbe1 100644 --- a/src/routes/settings/notifications/components/DiscordConfiguration.svelte +++ b/src/routes/settings/notifications/components/DiscordConfiguration.svelte @@ -60,6 +60,7 @@ type="text" id="username" name="username" + bind:value={username} placeholder="Profilarr" class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-neutral-400 focus:outline-none focus:ring-1 focus:ring-neutral-400 dark:focus:border-neutral-500 dark:focus:ring-neutral-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500" /> @@ -80,6 +81,7 @@ type="url" id="avatar_url" name="avatar_url" + bind:value={avatarUrl} placeholder="https://example.com/avatar.png" class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-neutral-400 focus:outline-none focus:ring-1 focus:ring-neutral-400 dark:focus:border-neutral-500 dark:focus:ring-neutral-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500" />