mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Add skip-unreleased auto-search feature
Introduce an indexer-wide option to skip automatic searches for books with future release dates (config key: `indexer.skip_unreleased`, default ON). Adds a GET/PUT admin API for indexer options, a UI toggle on the Indexers settings tab (persisted on save), and persistence of a request-level releaseDate in the Prisma schema. Adds a new request status `awaiting_release` and wires it through constants, UI components (StatusBadge, RequestCard, RecentRequestsTable, Audiobook card/modal, RequestActions), API request flows (bookdate swipe, request creation, manual search, request PATCHs, request listing groups), and services. Implements a pure release-date utility (isUnreleased / shouldSkipAutoSearch) and updates background processors: monitor-rss-feeds (skip matches but do not mutate status), retry-missing-torrents (drives bidirectional transitions between awaiting_search and awaiting_release and queues searches when appropriate), and request-creator/bookdate swipe (gate initial auto-search). Adds tests for the swipe gate and other related test updates. Logs transitions and gate decisions for observability.
This commit is contained in:
@@ -57,3 +57,6 @@ next-env.d.ts
|
|||||||
/test-data
|
/test-data
|
||||||
/bookdrop
|
/bookdrop
|
||||||
dockerfile.patch
|
dockerfile.patch
|
||||||
|
|
||||||
|
# zach-flow scratch artifacts (locked briefs, orchestrator state)
|
||||||
|
.zach-flow/
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
- **Ebook delete behavior (files only, torrents seed)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#delete-behavior)
|
- **Ebook delete behavior (files only, torrents seed)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#delete-behavior)
|
||||||
- **Ebook settings (3-section UI)** → [settings-pages.md](settings-pages.md#e-book-sidecar)
|
- **Ebook settings (3-section UI)** → [settings-pages.md](settings-pages.md#e-book-sidecar)
|
||||||
- **Indexer categories (audiobook/ebook tabs)** → [settings-pages.md](settings-pages.md#indexer-categories-tabbed)
|
- **Indexer categories (audiobook/ebook tabs)** → [settings-pages.md](settings-pages.md#indexer-categories-tabbed)
|
||||||
|
- **Auto-search behavior toggle (skip unreleased books)** → [settings-pages.md](settings-pages.md#auto-search-behavior-indexers-tab)
|
||||||
|
|
||||||
## Automation Pipeline
|
## Automation Pipeline
|
||||||
- **Full pipeline overview** → [phase3/README.md](phase3/README.md)
|
- **Full pipeline overview** → [phase3/README.md](phase3/README.md)
|
||||||
|
|||||||
@@ -60,12 +60,14 @@ PostgreSQL database storing users, audiobooks, requests, downloads, configuratio
|
|||||||
|
|
||||||
### Requests
|
### Requests
|
||||||
- `id` (UUID PK), `user_id` (FK), `audiobook_id` (FK)
|
- `id` (UUID PK), `user_id` (FK), `audiobook_id` (FK)
|
||||||
- `status` ('pending'|'searching'|'downloading'|'processing'|'downloaded'|'available'|'failed'|'cancelled'|'awaiting_search'|'awaiting_import'|'warn'|'awaiting_approval'|'denied')
|
- `status` ('pending'|'searching'|'downloading'|'processing'|'downloaded'|'available'|'failed'|'cancelled'|'awaiting_search'|'awaiting_import'|'awaiting_release'|'warn'|'awaiting_approval'|'denied')
|
||||||
- **Approval flow:** awaiting_approval → (approve) → pending → searching → downloading → processing → downloaded → available
|
- **Approval flow:** awaiting_approval → (approve) → pending → searching → downloading → processing → downloaded → available
|
||||||
- **Denial flow:** awaiting_approval → (deny) → denied
|
- **Denial flow:** awaiting_approval → (deny) → denied
|
||||||
- **awaiting_approval** - Request pending admin approval (only if auto-approve disabled)
|
- **awaiting_approval** - Request pending admin approval (only if auto-approve disabled)
|
||||||
- **denied** - Request rejected by admin (terminal state)
|
- **denied** - Request rejected by admin (terminal state)
|
||||||
- **pending** - Request approved and queued for processing
|
- **pending** - Request approved and queued for processing
|
||||||
|
- **awaiting_release** - Book has a future release date; auto-search skipped until release (admin toggle controls behavior)
|
||||||
|
- `release_date` (Date, nullable) - Book release date snapshot from Audnexus at request creation; used by skip-unreleased-auto-search gate
|
||||||
- `progress` (0-100), `priority`, `error_message`
|
- `progress` (0-100), `priority`, `error_message`
|
||||||
- `search_attempts`, `download_attempts`, `import_attempts`, `max_import_retries` (default 5)
|
- `search_attempts`, `download_attempts`, `import_attempts`, `max_import_retries` (default 5)
|
||||||
- `last_search_at`, `last_import_at`, `created_at`, `updated_at`, `completed_at`
|
- `last_search_at`, `last_import_at`, `created_at`, `updated_at`, `completed_at`
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ Manages background job queue using Bull (Redis-backed) for async tasks: searchin
|
|||||||
**search_indexers:**
|
**search_indexers:**
|
||||||
- No torrents found → 'awaiting_search' status (not failed)
|
- No torrents found → 'awaiting_search' status (not failed)
|
||||||
- Allows automatic retry via scheduled job
|
- Allows automatic retry via scheduled job
|
||||||
|
- Upstream release-date gate: 4 enqueue sites (`request-creator.service`, `retry-missing-torrents.processor`, `monitor-rss-feeds.processor`, `bookdate/swipe/route`) check `shouldSkipAutoSearch` against `indexer.skip_unreleased`; gated requests are created/kept in `awaiting_release` and `addSearchJob` is not called. Manual search bypasses the gate.
|
||||||
|
|
||||||
**organize_files:**
|
**organize_files:**
|
||||||
- No audiobook files found → 'awaiting_import' status
|
- No audiobook files found → 'awaiting_import' status
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ Manages recurring/scheduled jobs providing automated tasks (Plex scans, Audible
|
|||||||
1. **plex_library_scan** - Default: every 6 hours, full library scan, disabled by default (enable after setup)
|
1. **plex_library_scan** - Default: every 6 hours, full library scan, disabled by default (enable after setup)
|
||||||
2. **plex_recently_added_check** - Default: every 5 minutes, lightweight polling of top 10 recently added items, enabled by default
|
2. **plex_recently_added_check** - Default: every 5 minutes, lightweight polling of top 10 recently added items, enabled by default
|
||||||
3. **audible_refresh** - Default: daily midnight, fetches 200 popular + 200 new releases, stores with rankings, disabled by default
|
3. **audible_refresh** - Default: daily midnight, fetches 200 popular + 200 new releases, stores with rankings, disabled by default
|
||||||
4. **retry_missing_torrents** - Default: daily midnight, re-searches 'awaiting_search' status (limit 50), handles both audiobook and ebook requests, enabled by default
|
4. **retry_missing_torrents** - Default: daily midnight, processes union of `awaiting_search` ∪ `awaiting_release` (limit 50), handles both audiobook and ebook requests. Bidirectional transitions: `awaiting_search` → `awaiting_release` when release date is future + `indexer.skip_unreleased` ON; `awaiting_release` → `awaiting_search` + run search when release date has passed or setting OFF. Sole owner of these transitions. Enabled by default.
|
||||||
5. **retry_failed_imports** - Default: every 6 hours, re-attempts 'awaiting_import' status (limit 50), enabled by default
|
5. **retry_failed_imports** - Default: every 6 hours, re-attempts 'awaiting_import' status (limit 50), enabled by default
|
||||||
6. **cleanup_seeded_torrents** - Default: every 30 mins, deletes torrents after seeding requirements met, respects `seeding_time_minutes` config (0 = never), enabled by default
|
6. **cleanup_seeded_torrents** - Default: every 30 mins, deletes torrents after seeding requirements met, respects `seeding_time_minutes` config (0 = never), enabled by default
|
||||||
7. **monitor_rss_feeds** - Default: every 15 mins, checks RSS feeds from enabled indexers, matches against 'awaiting_search' requests (audiobook and ebook, limit 100), triggers appropriate search jobs for matches, enabled by default
|
7. **monitor_rss_feeds** - Default: every 15 mins, checks RSS feeds from enabled indexers, matches against `awaiting_search` requests (audiobook and ebook, limit 100). Query is unchanged — release-date gate is applied AFTER a match is found: if matched book is unreleased + `indexer.skip_unreleased` ON, the match is skipped and request status is NOT mutated (retry job owns transitions). Enabled by default.
|
||||||
|
|
||||||
## Architecture: Bull + Cron
|
## Architecture: Bull + Cron
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ src/components/
|
|||||||
- **AudiobookDetailsModal** ✅ - Full-screen modal with comprehensive metadata (description, genres, rating, release date, narrator, language, format, publisher, request functionality). Shows requesting user's name when applicable
|
- **AudiobookDetailsModal** ✅ - Full-screen modal with comprehensive metadata (description, genres, rating, release date, narrator, language, format, publisher, request functionality). Shows requesting user's name when applicable
|
||||||
|
|
||||||
**Requests**
|
**Requests**
|
||||||
- **RequestCard** ✅ - Cover, title, author, status badge, progress bar, timestamps, action buttons (cancel, manual search, interactive search)
|
- **RequestCard** ✅ - Cover, title, author, status badge, progress bar, timestamps, action buttons (cancel, manual search, interactive search). When status=`awaiting_release` and `releaseDate` is set, shows "Releases <Mon DD, YYYY>" next to the status badge (UTC-formatted)
|
||||||
- **StatusBadge** - Color-coded status (pending=yellow, searching=blue, downloading=purple, downloaded=green, processing=orange, available=green, completed=green, failed=red, warn=orange, cancelled=gray). Shows "Initializing..." when downloading with 0% progress (fetching torrent info), "Downloading" when progress > 0%
|
- **StatusBadge** - Color-coded status (pending=yellow, awaiting_search=yellow, searching=blue, downloading=purple, downloaded=green, processing=orange, awaiting_import=orange, available=green, completed=green, failed=red, warn=orange, cancelled=gray, awaiting_approval=yellow, awaiting_release=teal "Awaiting Release", denied=red). Shows "Initializing..." when downloading with 0% progress (fetching torrent info), "Downloading" when progress > 0%
|
||||||
- **ProgressBar** - Animated fill with percentage
|
- **ProgressBar** - Animated fill with percentage
|
||||||
- **InteractiveTorrentSearchModal** ✅ - Responsive table of ranked torrent results, uses ConfirmModal for downloads, hides columns on smaller screens (size on mobile, seeds on tablet, indexer on desktop)
|
- **InteractiveTorrentSearchModal** ✅ - Responsive table of ranked torrent results, uses ConfirmModal for downloads, hides columns on smaller screens (size on mobile, seeds on tablet, indexer on desktop)
|
||||||
- Active indicator: "Setting up..." with spinner when progress = 0%, "Active" with pulsing dot when progress > 0%
|
- Active indicator: "Setting up..." with spinner when progress = 0%, "Active" with pulsing dot when progress > 0%
|
||||||
|
|||||||
@@ -130,6 +130,25 @@ src/app/admin/settings/
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Auto-Search Behavior (Indexers tab)
|
||||||
|
|
||||||
|
**Purpose:** Control how ReadMeABook performs automatic indexer searches. Lives on the Indexers tab between the Prowlarr connection block and the IndexerManagement list.
|
||||||
|
|
||||||
|
**Toggle:** Skip unreleased books in automatic searches
|
||||||
|
- When ON: auto-search skips books whose release date is in the future. Those requests automatically start searching once the book is released. Manual searches are unaffected.
|
||||||
|
- When OFF: auto-search proceeds regardless of release date.
|
||||||
|
|
||||||
|
**Configuration Key:**
|
||||||
|
| Key | Default | Category | Description |
|
||||||
|
|-----|---------|----------|-------------|
|
||||||
|
| `indexer.skip_unreleased` | `true` (ON) | `indexer` | Skip auto-searches for books with future release dates |
|
||||||
|
|
||||||
|
**Read contract (consumed by background workers):**
|
||||||
|
- `value !== 'false'` → ON (skip enabled). Missing key OR any non-`'false'` value → ON.
|
||||||
|
- Only the exact string `'false'` disables the toggle. Workers MUST match this.
|
||||||
|
|
||||||
|
**API:** Persisted via `PUT /api/admin/settings/indexer-options`. Saved alongside Prowlarr connection + indexer config when the Indexers tab Save button is clicked.
|
||||||
|
|
||||||
## Audible Region
|
## Audible Region
|
||||||
|
|
||||||
**Purpose:** Configure which Audible region to use for metadata and search to ensure accurate ASIN matching with your metadata engine.
|
**Purpose:** Configure which Audible region to use for metadata and search to ensure accurate ASIN matching with your metadata engine.
|
||||||
@@ -279,6 +298,17 @@ src/app/admin/settings/
|
|||||||
- No test required if URL/API key unchanged
|
- No test required if URL/API key unchanged
|
||||||
- Saves only enabled indexers to database
|
- Saves only enabled indexers to database
|
||||||
|
|
||||||
|
**GET /api/admin/settings/indexer-options**
|
||||||
|
- Returns `{ skipUnreleased: boolean }`
|
||||||
|
- Default ON: missing or non-`'false'` value resolves to `true`
|
||||||
|
- Admin auth required
|
||||||
|
|
||||||
|
**PUT /api/admin/settings/indexer-options**
|
||||||
|
- Updates indexer-wide auto-search options
|
||||||
|
- Body: `{ skipUnreleased: boolean }` (strict boolean validation)
|
||||||
|
- Persists `indexer.skip_unreleased` (category: `indexer`)
|
||||||
|
- No connection test required
|
||||||
|
|
||||||
**PUT /api/admin/settings/download-client**
|
**PUT /api/admin/settings/download-client**
|
||||||
- Updates download client config
|
- Updates download client config
|
||||||
- Requires prior successful test if credentials changed
|
- Requires prior successful test if credentials changed
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ model Request {
|
|||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
audiobookId String @map("audiobook_id")
|
audiobookId String @map("audiobook_id")
|
||||||
status String @default("pending")
|
status String @default("pending")
|
||||||
// Status values: pending, awaiting_approval, denied, searching, downloading, processing, downloaded, available, failed, cancelled, awaiting_search, awaiting_import, warn
|
// Status values: pending, awaiting_approval, denied, searching, downloading, processing, downloaded, available, failed, cancelled, awaiting_search, awaiting_import, awaiting_release, warn
|
||||||
// Flow (audiobook): pending → searching → downloading → processing → downloaded → available (when matched in Plex)
|
// Flow (audiobook): pending → searching → downloading → processing → downloaded → available (when matched in Plex)
|
||||||
// Flow (ebook): pending → searching → downloading → processing → downloaded (terminal - no available state)
|
// Flow (ebook): pending → searching → downloading → processing → downloaded (terminal - no available state)
|
||||||
progress Int @default(0) // 0-100
|
progress Int @default(0) // 0-100
|
||||||
@@ -240,6 +240,7 @@ model Request {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
completedAt DateTime? @map("completed_at")
|
completedAt DateTime? @map("completed_at")
|
||||||
|
releaseDate DateTime? @map("release_date") @db.Date // Book release date (copied from Audnexus on creation). Used by skip-unreleased-auto-search gate.
|
||||||
|
|
||||||
// Request type: 'audiobook' (default) or 'ebook'
|
// Request type: 'audiobook' (default) or 'ebook'
|
||||||
// Ebook requests are created automatically when an audiobook is organized (if ebook downloads enabled)
|
// Ebook requests are created automatically when an audiobook is organized (if ebook downloads enabled)
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const STATUS_OPTIONS = [
|
|||||||
{ value: 'pending', label: 'Pending' },
|
{ value: 'pending', label: 'Pending' },
|
||||||
{ value: 'awaiting_approval', label: 'Awaiting Approval' },
|
{ value: 'awaiting_approval', label: 'Awaiting Approval' },
|
||||||
{ value: 'awaiting_search', label: 'Awaiting Search' },
|
{ value: 'awaiting_search', label: 'Awaiting Search' },
|
||||||
|
{ value: 'awaiting_release', label: 'Awaiting Release' },
|
||||||
{ value: 'searching', label: 'Searching' },
|
{ value: 'searching', label: 'Searching' },
|
||||||
{ value: 'downloading', label: 'Downloading' },
|
{ value: 'downloading', label: 'Downloading' },
|
||||||
{ value: 'processing', label: 'Processing' },
|
{ value: 'processing', label: 'Processing' },
|
||||||
@@ -78,6 +79,7 @@ function getStatusBadge(status: string) {
|
|||||||
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||||
awaiting_approval: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
|
awaiting_approval: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
|
||||||
awaiting_search: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
awaiting_search: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||||
|
awaiting_release: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200',
|
||||||
searching: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
searching: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||||
downloading: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
|
downloading: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
|
||||||
downloaded: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
downloaded: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||||
@@ -95,6 +97,7 @@ function getStatusBadge(status: string) {
|
|||||||
|
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
awaiting_search: 'Awaiting Search',
|
awaiting_search: 'Awaiting Search',
|
||||||
|
awaiting_release: 'Awaiting Release',
|
||||||
awaiting_import: 'Awaiting Import',
|
awaiting_import: 'Awaiting Import',
|
||||||
awaiting_approval: 'Awaiting Approval',
|
awaiting_approval: 'Awaiting Approval',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ export function RequestActionsDropdown({
|
|||||||
const canViewDetails = !isEbook && !!request.asin && !!onViewDetails;
|
const canViewDetails = !isEbook && !!request.asin && !!onViewDetails;
|
||||||
|
|
||||||
// Determine available actions based on status
|
// Determine available actions based on status
|
||||||
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
const canSearch = ['pending', 'failed', 'awaiting_search', 'awaiting_release'].includes(request.status);
|
||||||
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'awaiting_release', 'searching'].includes(request.status);
|
||||||
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
|
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
|
||||||
const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(request.status);
|
const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(request.status);
|
||||||
const canDelete = true; // Admins can always delete
|
const canDelete = true; // Admins can always delete
|
||||||
|
|||||||
@@ -113,6 +113,17 @@ export const saveTabSettings = async (
|
|||||||
}).then(res => {
|
}).then(res => {
|
||||||
if (!res.ok) throw new Error('Failed to save indexer configuration');
|
if (!res.ok) throw new Error('Failed to save indexer configuration');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Save indexer-wide options (auto-search behavior, etc.)
|
||||||
|
await fetchWithAuth('/api/admin/settings/indexer-options', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
skipUnreleased: settings.indexerOptions.skipUnreleased,
|
||||||
|
}),
|
||||||
|
}).then(res => {
|
||||||
|
if (!res.ok) throw new Error('Failed to save indexer options');
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'download':
|
case 'download':
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface Settings {
|
|||||||
oidc: OIDCSettings;
|
oidc: OIDCSettings;
|
||||||
registration: RegistrationSettings;
|
registration: RegistrationSettings;
|
||||||
prowlarr: ProwlarrSettings;
|
prowlarr: ProwlarrSettings;
|
||||||
|
indexerOptions: IndexerOptionsSettings;
|
||||||
downloadClient: DownloadClientSettings;
|
downloadClient: DownloadClientSettings;
|
||||||
paths: PathsSettings;
|
paths: PathsSettings;
|
||||||
ebook: EbookSettings;
|
ebook: EbookSettings;
|
||||||
@@ -76,6 +77,19 @@ export interface ProwlarrSettings {
|
|||||||
apiKey: string;
|
apiKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indexer-wide behavioral options (not tied to a specific indexer connection).
|
||||||
|
* Persisted via `/api/admin/settings/indexer-options`.
|
||||||
|
*/
|
||||||
|
export interface IndexerOptionsSettings {
|
||||||
|
/**
|
||||||
|
* When true, automatic indexer searches skip books whose release date is
|
||||||
|
* in the future. Default ON. Manual searches are unaffected.
|
||||||
|
* Backing config key: `indexer.skip_unreleased`.
|
||||||
|
*/
|
||||||
|
skipUnreleased: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download client (qBittorrent) configuration
|
* Download client (qBittorrent) configuration
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -136,6 +136,48 @@ export function IndexersTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
Auto-Search Behavior
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Control how ReadMeABook performs automatic background searches across your indexers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="indexer-skip-unreleased"
|
||||||
|
checked={settings.indexerOptions.skipUnreleased}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
...settings,
|
||||||
|
indexerOptions: {
|
||||||
|
...settings.indexerOptions,
|
||||||
|
skipUnreleased: e.target.checked,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label
|
||||||
|
htmlFor="indexer-skip-unreleased"
|
||||||
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||||
|
>
|
||||||
|
Skip unreleased books in automatic searches
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
When ON, ReadMeABook will not search indexers for books whose release date is in the future. These requests will automatically begin searching once the book is released. Manual searches are not affected.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
<IndexerManagement
|
<IndexerManagement
|
||||||
prowlarrUrl={settings.prowlarr.url}
|
prowlarrUrl={settings.prowlarr.url}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Component: Admin Indexer Options Settings API
|
||||||
|
* Documentation: documentation/settings-pages.md
|
||||||
|
*
|
||||||
|
* Manages indexer-wide behavioral options that are not tied to a specific
|
||||||
|
* indexer connection (e.g., auto-search behavior toggles).
|
||||||
|
*
|
||||||
|
* Read contract (consumed by background auto-search workers):
|
||||||
|
* - Config key: `indexer.skip_unreleased`
|
||||||
|
* - Category: `indexer`
|
||||||
|
* - Value: string `'true'` | `'false'`
|
||||||
|
* - Default: ON when the key is missing OR its value is anything other
|
||||||
|
* than the exact string `'false'`. In other words, skipping
|
||||||
|
* unreleased books is enabled unless the admin explicitly
|
||||||
|
* opted out. Workers MUST match this contract:
|
||||||
|
*
|
||||||
|
* const skip = (await config.get('indexer.skip_unreleased')) !== 'false';
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { getConfigService } from '@/lib/services/config.service';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.Settings.IndexerOptions');
|
||||||
|
|
||||||
|
const CONFIG_KEY = 'indexer.skip_unreleased';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/settings/indexer-options
|
||||||
|
* Returns the current indexer-wide options.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const configService = getConfigService();
|
||||||
|
const value = await configService.get(CONFIG_KEY);
|
||||||
|
|
||||||
|
// Default ON: missing or any value other than 'false' is treated as enabled.
|
||||||
|
const skipUnreleased = value !== 'false';
|
||||||
|
|
||||||
|
return NextResponse.json({ skipUnreleased });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch indexer options', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch indexer options' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/admin/settings/indexer-options
|
||||||
|
* Persists indexer-wide options. Body: { skipUnreleased: boolean }
|
||||||
|
*/
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { skipUnreleased } = body ?? {};
|
||||||
|
|
||||||
|
if (typeof skipUnreleased !== 'boolean') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'skipUnreleased must be a boolean' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configService = getConfigService();
|
||||||
|
await configService.setMany([
|
||||||
|
{
|
||||||
|
key: CONFIG_KEY,
|
||||||
|
value: String(skipUnreleased),
|
||||||
|
category: 'indexer',
|
||||||
|
description:
|
||||||
|
'Skip auto-searches for books with future release dates',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Explicitly clear cache for the key after write. `setMany` already
|
||||||
|
// does this, but we make it visible here to guarantee fresh reads
|
||||||
|
// by any sibling service that has cached the value.
|
||||||
|
configService.clearCache(CONFIG_KEY);
|
||||||
|
|
||||||
|
logger.info('Indexer options updated', { skipUnreleased });
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Indexer options updated successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to update indexer options', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Failed to update indexer options',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -81,6 +81,12 @@ export async function GET(request: NextRequest) {
|
|||||||
url: configMap.get('prowlarr_url') || '',
|
url: configMap.get('prowlarr_url') || '',
|
||||||
apiKey: maskValue('api_key', configMap.get('prowlarr_api_key')),
|
apiKey: maskValue('api_key', configMap.get('prowlarr_api_key')),
|
||||||
},
|
},
|
||||||
|
indexerOptions: {
|
||||||
|
// Default ON: missing or any value other than 'false' is treated as enabled.
|
||||||
|
// Must stay in lock-step with /api/admin/settings/indexer-options read contract
|
||||||
|
// and any background worker that reads `indexer.skip_unreleased` directly.
|
||||||
|
skipUnreleased: configMap.get('indexer.skip_unreleased') !== 'false',
|
||||||
|
},
|
||||||
// downloadClient is populated from multi-client format for backward compatibility
|
// downloadClient is populated from multi-client format for backward compatibility
|
||||||
// The DownloadTab component now uses DownloadClientManagement which reads from /api/admin/settings/download-clients
|
// The DownloadTab component now uses DownloadClientManagement which reads from /api/admin/settings/download-clients
|
||||||
downloadClient: (() => {
|
downloadClient: (() => {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
|
import { getConfigService } from '@/lib/services/config.service';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { shouldSkipAutoSearch } from '@/lib/utils/release-date';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.BookDateSwipe');
|
const logger = RMABLogger.create('API.BookDateSwipe');
|
||||||
|
|
||||||
@@ -67,17 +69,22 @@ async function handler(req: AuthenticatedRequest) {
|
|||||||
let year: number | undefined;
|
let year: number | undefined;
|
||||||
let series: string | undefined;
|
let series: string | undefined;
|
||||||
let seriesPart: string | undefined;
|
let seriesPart: string | undefined;
|
||||||
|
let releaseDate: Date | null = null;
|
||||||
try {
|
try {
|
||||||
const audibleService = getAudibleService();
|
const audibleService = getAudibleService();
|
||||||
const audnexusData = await audibleService.getAudiobookDetails(recommendation.audnexusAsin);
|
const audnexusData = await audibleService.getAudiobookDetails(recommendation.audnexusAsin);
|
||||||
|
|
||||||
if (audnexusData?.releaseDate) {
|
if (audnexusData?.releaseDate) {
|
||||||
try {
|
try {
|
||||||
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
|
const parsed = new Date(audnexusData.releaseDate);
|
||||||
|
if (!isNaN(parsed.getTime())) {
|
||||||
|
releaseDate = parsed;
|
||||||
|
const releaseYear = parsed.getFullYear();
|
||||||
if (!isNaN(releaseYear)) {
|
if (!isNaN(releaseYear)) {
|
||||||
year = releaseYear;
|
year = releaseYear;
|
||||||
logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`);
|
logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
@@ -181,8 +188,28 @@ async function handler(req: AuthenticatedRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Evaluate release-date gate (only when not pending approval)
|
||||||
|
let releaseGateSkip = false;
|
||||||
|
if (!needsApproval) {
|
||||||
|
try {
|
||||||
|
const configService = getConfigService();
|
||||||
|
const skipUnreleasedSetting = (await configService.get('indexer.skip_unreleased')) !== 'false';
|
||||||
|
const gate = shouldSkipAutoSearch({ releaseDate }, skipUnreleasedSetting);
|
||||||
|
releaseGateSkip = gate.skip;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to evaluate release-date gate: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Determine initial status
|
// Determine initial status
|
||||||
const initialStatus = needsApproval ? 'awaiting_approval' : 'pending';
|
let initialStatus: string;
|
||||||
|
if (needsApproval) {
|
||||||
|
initialStatus = 'awaiting_approval';
|
||||||
|
} else if (releaseGateSkip) {
|
||||||
|
initialStatus = 'awaiting_release';
|
||||||
|
} else {
|
||||||
|
initialStatus = 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
const newRequest = await prisma.request.create({
|
const newRequest = await prisma.request.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -191,11 +218,21 @@ async function handler(req: AuthenticatedRequest) {
|
|||||||
status: initialStatus,
|
status: initialStatus,
|
||||||
type: 'audiobook', // Explicit type for user-created requests
|
type: 'audiobook', // Explicit type for user-created requests
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
releaseDate,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Created request for "${recommendation.title}" with status: ${initialStatus}`);
|
logger.info(`Created request for "${recommendation.title}" with status: ${initialStatus}`);
|
||||||
|
|
||||||
|
if (releaseGateSkip) {
|
||||||
|
logger.info(`Skipped auto-search for unreleased book`, {
|
||||||
|
gateSource: 'BookDateSwipe',
|
||||||
|
requestId: newRequest.id,
|
||||||
|
audiobookTitle: audiobook.title,
|
||||||
|
releaseDate: releaseDate?.toISOString() ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Import job queue service
|
// Import job queue service
|
||||||
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
|
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
|
||||||
const jobQueue = getJobQueueService();
|
const jobQueue = getJobQueueService();
|
||||||
@@ -224,7 +261,8 @@ async function handler(req: AuthenticatedRequest) {
|
|||||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trigger search job only if auto-approved
|
// Trigger search job only if auto-approved AND not gated by release date
|
||||||
|
if (!releaseGateSkip) {
|
||||||
await jobQueue.addSearchJob(newRequest.id, {
|
await jobQueue.addSearchJob(newRequest.id, {
|
||||||
id: audiobook.id,
|
id: audiobook.id,
|
||||||
title: audiobook.title,
|
title: audiobook.title,
|
||||||
@@ -235,6 +273,7 @@ async function handler(req: AuthenticatedRequest) {
|
|||||||
logger.info(`Triggered search job for request ${newRequest.id}`);
|
logger.info(`Triggered search job for request ${newRequest.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error creating request', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Error creating request', { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ export async function POST(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow manual search for pending, failed, awaiting_search statuses
|
// Only allow manual search for pending, failed, awaiting_search, awaiting_release statuses
|
||||||
const searchableStatuses = ['pending', 'failed', 'awaiting_search'];
|
const searchableStatuses = ['pending', 'failed', 'awaiting_search', 'awaiting_release'];
|
||||||
if (!searchableStatuses.includes(requestRecord.status)) {
|
if (!searchableStatuses.includes(requestRecord.status)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export async function PATCH(
|
|||||||
} else if (action === 'retry') {
|
} else if (action === 'retry') {
|
||||||
// Retry failed request - allow users to retry their own warn/failed requests
|
// Retry failed request - allow users to retry their own warn/failed requests
|
||||||
// Only allow retry for failed, warn, or awaiting_* statuses
|
// Only allow retry for failed, warn, or awaiting_* statuses
|
||||||
const retryableStatuses = ['failed', 'warn', 'awaiting_search', 'awaiting_import'];
|
const retryableStatuses = ['failed', 'warn', 'awaiting_search', 'awaiting_import', 'awaiting_release'];
|
||||||
|
|
||||||
if (!retryableStatuses.includes(requestRecord.status)) {
|
if (!retryableStatuses.includes(requestRecord.status)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Status groups for server-side filtering and count aggregation
|
// Status groups for server-side filtering and count aggregation
|
||||||
const STATUS_GROUPS: Record<string, string[]> = {
|
const STATUS_GROUPS: Record<string, string[]> = {
|
||||||
active: ['pending', 'searching', 'downloading', 'processing'],
|
active: ['pending', 'searching', 'downloading', 'processing'],
|
||||||
waiting: ['awaiting_search', 'awaiting_import', 'awaiting_approval'],
|
waiting: ['awaiting_search', 'awaiting_import', 'awaiting_approval', 'awaiting_release'],
|
||||||
completed: ['available', 'downloaded'],
|
completed: ['available', 'downloaded'],
|
||||||
failed: ['failed'],
|
failed: ['failed'],
|
||||||
cancelled: ['cancelled', 'denied'],
|
cancelled: ['cancelled', 'denied'],
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const getStatusConfig = (audiobook: Audiobook) => {
|
|||||||
return { type: 'processing', label: 'Processing', color: 'amber' };
|
return { type: 'processing', label: 'Processing', color: 'amber' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingStatuses = ['pending', 'awaiting_search', 'searching', 'awaiting_approval'];
|
const pendingStatuses = ['pending', 'awaiting_search', 'awaiting_release', 'searching', 'awaiting_approval'];
|
||||||
if (audiobook.requestStatus && pendingStatuses.includes(audiobook.requestStatus)) {
|
if (audiobook.requestStatus && pendingStatuses.includes(audiobook.requestStatus)) {
|
||||||
return { type: 'pending', label: 'Requested', color: 'blue' };
|
return { type: 'pending', label: 'Requested', color: 'blue' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const getStatusInfo = (isAvailable: boolean, requestStatus: string | null, reque
|
|||||||
return { type: 'processing', label: 'Processing', canRequest: false };
|
return { type: 'processing', label: 'Processing', canRequest: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingStatuses = ['pending', 'awaiting_search', 'searching', 'awaiting_approval'];
|
const pendingStatuses = ['pending', 'awaiting_search', 'awaiting_release', 'searching', 'awaiting_approval'];
|
||||||
if (requestStatus && pendingStatuses.includes(requestStatus)) {
|
if (requestStatus && pendingStatuses.includes(requestStatus)) {
|
||||||
const label = requestStatus === 'awaiting_approval'
|
const label = requestStatus === 'awaiting_approval'
|
||||||
? requestedByUsername ? `Pending Approval (${requestedByUsername})` : 'Pending Approval'
|
? requestedByUsername ? `Pending Approval (${requestedByUsername})` : 'Pending Approval'
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface RequestCardProps {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
downloadAvailable?: boolean;
|
downloadAvailable?: boolean;
|
||||||
|
releaseDate?: string | Date | null;
|
||||||
audiobook: {
|
audiobook: {
|
||||||
id: string;
|
id: string;
|
||||||
audibleAsin?: string;
|
audibleAsin?: string;
|
||||||
@@ -58,6 +59,18 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
|||||||
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
||||||
const isFailed = request.status === 'failed';
|
const isFailed = request.status === 'failed';
|
||||||
|
|
||||||
|
const releaseDateLabel = React.useMemo(() => {
|
||||||
|
if (request.status !== 'awaiting_release' || !request.releaseDate) return null;
|
||||||
|
const parsed = new Date(request.releaseDate);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return null;
|
||||||
|
return parsed.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
});
|
||||||
|
}, [request.status, request.releaseDate]);
|
||||||
|
|
||||||
const handleConfirmCancel = async () => {
|
const handleConfirmCancel = async () => {
|
||||||
try {
|
try {
|
||||||
await cancelRequest(request.id);
|
await cancelRequest(request.id);
|
||||||
@@ -150,6 +163,11 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
|||||||
{/* Status Badge and Type Badge */}
|
{/* Status Badge and Type Badge */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<StatusBadge status={request.status} progress={request.progress} />
|
<StatusBadge status={request.status} progress={request.progress} />
|
||||||
|
{releaseDateLabel && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Releases {releaseDateLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{isEbook && (
|
{isEbook && (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
|
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ export function StatusBadge({ status, progress, className }: StatusBadgeProps) {
|
|||||||
label: 'Pending Approval',
|
label: 'Pending Approval',
|
||||||
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||||
},
|
},
|
||||||
|
awaiting_release: {
|
||||||
|
label: 'Awaiting Release',
|
||||||
|
color: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200',
|
||||||
|
},
|
||||||
denied: {
|
denied: {
|
||||||
label: 'Request Denied',
|
label: 'Request Denied',
|
||||||
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ export const CANCELLABLE_STATUSES = [
|
|||||||
'downloading',
|
'downloading',
|
||||||
'awaiting_search',
|
'awaiting_search',
|
||||||
'awaiting_approval',
|
'awaiting_approval',
|
||||||
|
'awaiting_release',
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { prisma } from '../db';
|
import { prisma } from '../db';
|
||||||
import { RMABLogger } from '../utils/logger';
|
import { RMABLogger } from '../utils/logger';
|
||||||
import { getJobQueueService } from '../services/job-queue.service';
|
import { getJobQueueService } from '../services/job-queue.service';
|
||||||
|
import { shouldSkipAutoSearch } from '../utils/release-date';
|
||||||
|
|
||||||
export interface MonitorRssFeedsPayload {
|
export interface MonitorRssFeedsPayload {
|
||||||
jobId?: string;
|
jobId?: string;
|
||||||
@@ -25,6 +26,9 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
|
|||||||
const configService = getConfigService();
|
const configService = getConfigService();
|
||||||
const indexersConfigStr = await configService.get('prowlarr_indexers');
|
const indexersConfigStr = await configService.get('prowlarr_indexers');
|
||||||
|
|
||||||
|
// Read skip-unreleased setting once at start (default ON when absent)
|
||||||
|
const skipUnreleasedSetting = (await configService.get('indexer.skip_unreleased')) !== 'false';
|
||||||
|
|
||||||
if (!indexersConfigStr) {
|
if (!indexersConfigStr) {
|
||||||
logger.warn(`No indexers configured, skipping`);
|
logger.warn(`No indexers configured, skipping`);
|
||||||
return { success: false, message: 'No indexers configured', skipped: true };
|
return { success: false, message: 'No indexers configured', skipped: true };
|
||||||
@@ -95,6 +99,21 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
|
|||||||
if (hasAuthor && titleMatchCount >= 2) {
|
if (hasAuthor && titleMatchCount >= 2) {
|
||||||
logger.info(`Match found! "${audiobook.title}" by ${audiobook.author} matches torrent: ${torrent.title}`);
|
logger.info(`Match found! "${audiobook.title}" by ${audiobook.author} matches torrent: ${torrent.title}`);
|
||||||
|
|
||||||
|
// Release-date gate: skip RSS-driven auto-search for unreleased books.
|
||||||
|
// Does NOT mutate request.status — retry job is the sole owner of
|
||||||
|
// awaiting_search ↔ awaiting_release transitions.
|
||||||
|
const gate = shouldSkipAutoSearch({ releaseDate: request.releaseDate }, skipUnreleasedSetting);
|
||||||
|
if (gate.skip) {
|
||||||
|
logger.info(`Skipped RSS auto-search for unreleased book`, {
|
||||||
|
gateSource: 'MonitorRssFeeds',
|
||||||
|
requestId: request.id,
|
||||||
|
audiobookTitle: audiobook.title,
|
||||||
|
releaseDate: request.releaseDate?.toISOString() ?? null,
|
||||||
|
});
|
||||||
|
// Match exists but is gated — preserve "only trigger once per request" semantics.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger appropriate search job based on request type
|
// Trigger appropriate search job based on request type
|
||||||
try {
|
try {
|
||||||
if (request.type === 'ebook') {
|
if (request.type === 'ebook') {
|
||||||
|
|||||||
@@ -2,12 +2,17 @@
|
|||||||
* Component: Retry Missing Torrents Processor
|
* Component: Retry Missing Torrents Processor
|
||||||
* Documentation: documentation/backend/services/scheduler.md
|
* Documentation: documentation/backend/services/scheduler.md
|
||||||
*
|
*
|
||||||
* Retries search for requests that are awaiting torrent search
|
* Retries search for requests that are awaiting torrent search.
|
||||||
|
* Also drives bidirectional transitions between `awaiting_search` and
|
||||||
|
* `awaiting_release` based on the per-book release date and the
|
||||||
|
* `indexer.skip_unreleased` setting.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from '../db';
|
import { prisma } from '../db';
|
||||||
import { RMABLogger } from '../utils/logger';
|
import { RMABLogger } from '../utils/logger';
|
||||||
import { getJobQueueService } from '../services/job-queue.service';
|
import { getJobQueueService } from '../services/job-queue.service';
|
||||||
|
import { getConfigService } from '../services/config.service';
|
||||||
|
import { shouldSkipAutoSearch } from '../utils/release-date';
|
||||||
|
|
||||||
export interface RetryMissingTorrentsPayload {
|
export interface RetryMissingTorrentsPayload {
|
||||||
jobId?: string;
|
jobId?: string;
|
||||||
@@ -15,43 +20,110 @@ export interface RetryMissingTorrentsPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function processRetryMissingTorrents(payload: RetryMissingTorrentsPayload): Promise<any> {
|
export async function processRetryMissingTorrents(payload: RetryMissingTorrentsPayload): Promise<any> {
|
||||||
const { jobId, scheduledJobId } = payload;
|
const { jobId } = payload;
|
||||||
const logger = RMABLogger.forJob(jobId, 'RetryMissingTorrents');
|
const logger = RMABLogger.forJob(jobId, 'RetryMissingTorrents');
|
||||||
|
|
||||||
logger.info('Starting retry job for requests awaiting search...');
|
logger.info('Starting retry job for requests awaiting search/release...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find all active requests (audiobook or ebook) in awaiting_search status
|
// Read skip-unreleased setting once at start (default ON when absent)
|
||||||
|
const configService = getConfigService();
|
||||||
|
const skipUnreleasedSetting = (await configService.get('indexer.skip_unreleased')) !== 'false';
|
||||||
|
|
||||||
|
// Find all active requests in awaiting_search OR awaiting_release status
|
||||||
const requests = await prisma.request.findMany({
|
const requests = await prisma.request.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: 'awaiting_search',
|
status: { in: ['awaiting_search', 'awaiting_release'] },
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
audiobook: true,
|
audiobook: true,
|
||||||
},
|
},
|
||||||
take: 50, // Limit to 50 requests per run
|
take: 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Found ${requests.length} requests awaiting search`);
|
logger.info(`Found ${requests.length} requests awaiting search/release`);
|
||||||
|
|
||||||
if (requests.length === 0) {
|
if (requests.length === 0) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'No requests awaiting search',
|
message: 'No requests awaiting search/release',
|
||||||
triggered: 0,
|
triggered: 0,
|
||||||
|
transitioned: 0,
|
||||||
|
skipped: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger appropriate search job for each request based on type
|
|
||||||
// Throttle: 100ms delay between jobs to avoid connection pool burst
|
|
||||||
const jobQueue = getJobQueueService();
|
const jobQueue = getJobQueueService();
|
||||||
let triggered = 0;
|
let triggered = 0;
|
||||||
|
let transitioned = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
for (const request of requests) {
|
for (const request of requests) {
|
||||||
try {
|
try {
|
||||||
|
const gate = shouldSkipAutoSearch({ releaseDate: request.releaseDate }, skipUnreleasedSetting);
|
||||||
|
|
||||||
|
if (request.status === 'awaiting_search' && gate.skip) {
|
||||||
|
// Future release, setting ON → demote to awaiting_release
|
||||||
|
await prisma.request.update({
|
||||||
|
where: { id: request.id },
|
||||||
|
data: { status: 'awaiting_release' },
|
||||||
|
});
|
||||||
|
skipped++;
|
||||||
|
transitioned++;
|
||||||
|
logger.info(`Transitioned request to awaiting_release (unreleased)`, {
|
||||||
|
gateSource: 'RetryMissingTorrents',
|
||||||
|
requestId: request.id,
|
||||||
|
audiobookTitle: request.audiobook.title,
|
||||||
|
releaseDate: request.releaseDate?.toISOString() ?? null,
|
||||||
|
from: 'awaiting_search',
|
||||||
|
to: 'awaiting_release',
|
||||||
|
});
|
||||||
|
} else if (request.status === 'awaiting_release' && !gate.skip) {
|
||||||
|
// Released (or setting OFF) → promote to awaiting_search and run search.
|
||||||
|
// Order: update status → queue job → log (race safety).
|
||||||
|
await prisma.request.update({
|
||||||
|
where: { id: request.id },
|
||||||
|
data: { status: 'awaiting_search' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (request.type === 'ebook') {
|
||||||
|
await jobQueue.addSearchEbookJob(request.id, {
|
||||||
|
id: request.audiobook.id,
|
||||||
|
title: request.audiobook.title,
|
||||||
|
author: request.audiobook.author,
|
||||||
|
asin: request.audiobook.audibleAsin || undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await jobQueue.addSearchJob(request.id, {
|
||||||
|
id: request.audiobook.id,
|
||||||
|
title: request.audiobook.title,
|
||||||
|
author: request.audiobook.author,
|
||||||
|
asin: request.audiobook.audibleAsin || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
triggered++;
|
||||||
|
transitioned++;
|
||||||
|
logger.info(`Transitioned request to awaiting_search and queued search`, {
|
||||||
|
requestId: request.id,
|
||||||
|
audiobookTitle: request.audiobook.title,
|
||||||
|
releaseDate: request.releaseDate?.toISOString() ?? null,
|
||||||
|
from: 'awaiting_release',
|
||||||
|
to: 'awaiting_search',
|
||||||
|
triggeredBy: 'RetryMissingTorrents',
|
||||||
|
});
|
||||||
|
} else if (request.status === 'awaiting_release' && gate.skip) {
|
||||||
|
// Still unreleased — leave as-is.
|
||||||
|
skipped++;
|
||||||
|
logger.info(`Skipped awaiting_release request (still unreleased)`, {
|
||||||
|
gateSource: 'RetryMissingTorrents',
|
||||||
|
requestId: request.id,
|
||||||
|
audiobookTitle: request.audiobook.title,
|
||||||
|
releaseDate: request.releaseDate?.toISOString() ?? null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// awaiting_search + !gate.skip → existing search path
|
||||||
if (request.type === 'ebook') {
|
if (request.type === 'ebook') {
|
||||||
// Ebook requests use ebook search (Anna's Archive, etc.)
|
|
||||||
await jobQueue.addSearchEbookJob(request.id, {
|
await jobQueue.addSearchEbookJob(request.id, {
|
||||||
id: request.audiobook.id,
|
id: request.audiobook.id,
|
||||||
title: request.audiobook.title,
|
title: request.audiobook.title,
|
||||||
@@ -61,7 +133,6 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP
|
|||||||
triggered++;
|
triggered++;
|
||||||
logger.info(`Triggered ebook search for request ${request.id}: ${request.audiobook.title}`);
|
logger.info(`Triggered ebook search for request ${request.id}: ${request.audiobook.title}`);
|
||||||
} else {
|
} else {
|
||||||
// Audiobook requests use indexer search (Prowlarr)
|
|
||||||
await jobQueue.addSearchJob(request.id, {
|
await jobQueue.addSearchJob(request.id, {
|
||||||
id: request.audiobook.id,
|
id: request.audiobook.id,
|
||||||
title: request.audiobook.title,
|
title: request.audiobook.title,
|
||||||
@@ -71,21 +142,24 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP
|
|||||||
triggered++;
|
triggered++;
|
||||||
logger.info(`Triggered audiobook search for request ${request.id}: ${request.audiobook.title}`);
|
logger.info(`Triggered audiobook search for request ${request.id}: ${request.audiobook.title}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
logger.error(`Failed to process request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spread DB operations over time to avoid connection pool exhaustion
|
// Spread DB operations over time to avoid connection pool exhaustion
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Triggered ${triggered}/${requests.length} search jobs`);
|
logger.info(`Retry pass complete: triggered=${triggered}, transitioned=${transitioned}, skipped=${skipped} of ${requests.length}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Retry missing torrents completed',
|
message: 'Retry missing torrents completed',
|
||||||
totalRequests: requests.length,
|
totalRequests: requests.length,
|
||||||
triggered,
|
triggered,
|
||||||
|
transitioned,
|
||||||
|
skipped,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
|||||||
@@ -9,9 +9,11 @@
|
|||||||
|
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||||
|
import { getConfigService } from '@/lib/services/config.service';
|
||||||
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { shouldSkipAutoSearch } from '@/lib/utils/release-date';
|
||||||
import { seedAsin, getSiblingAsins } from '@/lib/services/works.service';
|
import { seedAsin, getSiblingAsins } from '@/lib/services/works.service';
|
||||||
|
|
||||||
const logger = RMABLogger.create('RequestCreator');
|
const logger = RMABLogger.create('RequestCreator');
|
||||||
@@ -95,21 +97,26 @@ export async function createRequestForUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch full details from Audnexus for year/series
|
// Fetch full details from Audnexus for year/series/releaseDate
|
||||||
let year: number | undefined;
|
let year: number | undefined;
|
||||||
let series: string | undefined;
|
let series: string | undefined;
|
||||||
let seriesPart: string | undefined;
|
let seriesPart: string | undefined;
|
||||||
let seriesAsin: string | undefined;
|
let seriesAsin: string | undefined;
|
||||||
|
let releaseDate: Date | null = null;
|
||||||
try {
|
try {
|
||||||
const audibleService = getAudibleService();
|
const audibleService = getAudibleService();
|
||||||
const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin);
|
const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin);
|
||||||
|
|
||||||
if (audnexusData?.releaseDate) {
|
if (audnexusData?.releaseDate) {
|
||||||
try {
|
try {
|
||||||
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
|
const parsed = new Date(audnexusData.releaseDate);
|
||||||
|
if (!isNaN(parsed.getTime())) {
|
||||||
|
releaseDate = parsed;
|
||||||
|
const releaseYear = parsed.getFullYear();
|
||||||
if (!isNaN(releaseYear)) {
|
if (!isNaN(releaseYear)) {
|
||||||
year = releaseYear;
|
year = releaseYear;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore parse errors
|
// Ignore parse errors
|
||||||
}
|
}
|
||||||
@@ -242,12 +249,28 @@ export async function createRequestForUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Evaluate release-date gate (skip-unreleased-auto-search)
|
||||||
|
let releaseGateSkip = false;
|
||||||
|
if (!needsApproval && !skipAutoSearch) {
|
||||||
|
try {
|
||||||
|
const configService = getConfigService();
|
||||||
|
const skipUnreleasedSetting = (await configService.get('indexer.skip_unreleased')) !== 'false';
|
||||||
|
const gate = shouldSkipAutoSearch({ releaseDate }, skipUnreleasedSetting);
|
||||||
|
releaseGateSkip = gate.skip;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to evaluate release-date gate: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let initialStatus: string;
|
let initialStatus: string;
|
||||||
if (needsApproval) {
|
if (needsApproval) {
|
||||||
initialStatus = 'awaiting_approval';
|
initialStatus = 'awaiting_approval';
|
||||||
shouldTriggerSearch = false;
|
shouldTriggerSearch = false;
|
||||||
} else if (skipAutoSearch) {
|
} else if (skipAutoSearch) {
|
||||||
initialStatus = 'awaiting_search';
|
initialStatus = 'awaiting_search';
|
||||||
|
} else if (releaseGateSkip) {
|
||||||
|
initialStatus = 'awaiting_release';
|
||||||
|
shouldTriggerSearch = false;
|
||||||
} else {
|
} else {
|
||||||
initialStatus = 'pending';
|
initialStatus = 'pending';
|
||||||
}
|
}
|
||||||
@@ -260,6 +283,7 @@ export async function createRequestForUser(
|
|||||||
status: initialStatus,
|
status: initialStatus,
|
||||||
type: 'audiobook',
|
type: 'audiobook',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
|
releaseDate,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
audiobook: true,
|
audiobook: true,
|
||||||
@@ -267,6 +291,15 @@ export async function createRequestForUser(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (releaseGateSkip) {
|
||||||
|
logger.info(`Skipped auto-search for unreleased book`, {
|
||||||
|
gateSource: 'InitialAutoSearch',
|
||||||
|
requestId: newRequest.id,
|
||||||
|
audiobookTitle: audiobookRecord.title,
|
||||||
|
releaseDate: releaseDate?.toISOString() ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const jobQueue = getJobQueueService();
|
const jobQueue = getJobQueueService();
|
||||||
|
|
||||||
// Send notification
|
// Send notification
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Component: Release Date Utilities
|
||||||
|
* Documentation: documentation/backend/database.md
|
||||||
|
*
|
||||||
|
* Pure helpers for reasoning about a book's release date relative to "today".
|
||||||
|
* Date-only comparison in UTC — no local-timezone arithmetic and no string slicing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the given release date is strictly after today (UTC date-only).
|
||||||
|
* Null, undefined, empty, or malformed input returns false (safe fallback).
|
||||||
|
*/
|
||||||
|
export function isUnreleased(
|
||||||
|
releaseDate: Date | string | null | undefined
|
||||||
|
): boolean {
|
||||||
|
if (releaseDate === null || releaseDate === undefined || releaseDate === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = releaseDate instanceof Date ? releaseDate : new Date(releaseDate);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const releaseY = date.getUTCFullYear();
|
||||||
|
const releaseM = date.getUTCMonth();
|
||||||
|
const releaseD = date.getUTCDate();
|
||||||
|
const nowY = now.getUTCFullYear();
|
||||||
|
const nowM = now.getUTCMonth();
|
||||||
|
const nowD = now.getUTCDate();
|
||||||
|
|
||||||
|
if (releaseY !== nowY) return releaseY > nowY;
|
||||||
|
if (releaseM !== nowM) return releaseM > nowM;
|
||||||
|
return releaseD > nowD;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides whether auto-search should be skipped because the book is unreleased.
|
||||||
|
* Short-circuits when the admin toggle is off.
|
||||||
|
*/
|
||||||
|
export function shouldSkipAutoSearch(
|
||||||
|
request: { releaseDate?: Date | string | null },
|
||||||
|
settingOn: boolean
|
||||||
|
): { skip: boolean; reason?: 'unreleased' } {
|
||||||
|
if (!settingOn) return { skip: false };
|
||||||
|
if (isUnreleased(request.releaseDate)) {
|
||||||
|
return { skip: true, reason: 'unreleased' };
|
||||||
|
}
|
||||||
|
return { skip: false };
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* Component: BookDate Swipe Release-Date Gate Tests
|
||||||
|
* Documentation: documentation/features/bookdate-prd.md
|
||||||
|
*
|
||||||
|
* Narrow coverage for the release-date gate on right-swipe request creation.
|
||||||
|
* Broader swipe behavior is covered in tests/api/bookdate.routes.test.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createPrismaMock } from '../helpers/prisma';
|
||||||
|
|
||||||
|
let authRequest: any;
|
||||||
|
const prismaMock = createPrismaMock();
|
||||||
|
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||||
|
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||||
|
const audibleServiceMock = vi.hoisted(() => ({
|
||||||
|
getAudiobookDetails: vi.fn(),
|
||||||
|
}));
|
||||||
|
const configServiceGet = vi.hoisted(() => vi.fn());
|
||||||
|
const jobQueueMock = vi.hoisted(() => ({
|
||||||
|
addSearchJob: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addNotificationJob: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/db', () => ({
|
||||||
|
prisma: prismaMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/middleware/auth', () => ({
|
||||||
|
requireAuth: requireAuthMock,
|
||||||
|
requireAdmin: requireAdminMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/integrations/audible.service', () => ({
|
||||||
|
getAudibleService: () => audibleServiceMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/services/config.service', () => ({
|
||||||
|
getConfigService: () => ({ get: configServiceGet }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||||
|
getJobQueueService: () => jobQueueMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function futureIso(days = 30): string {
|
||||||
|
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pastIso(days = 30): string {
|
||||||
|
return new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BookDate swipe — release-date gate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
jobQueueMock.addSearchJob.mockResolvedValue(undefined);
|
||||||
|
jobQueueMock.addNotificationJob.mockResolvedValue(undefined);
|
||||||
|
authRequest = { user: { id: 'user-1', role: 'admin' }, json: vi.fn() };
|
||||||
|
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||||
|
requireAdminMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates request in awaiting_release with no search when unreleased + setting ON', async () => {
|
||||||
|
authRequest.json.mockResolvedValue({ recommendationId: 'rec-future', action: 'right', markedAsKnown: false });
|
||||||
|
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 'rec-future',
|
||||||
|
userId: 'user-1',
|
||||||
|
title: 'Future Book',
|
||||||
|
author: 'Future Author',
|
||||||
|
audnexusAsin: 'ASIN-FUTURE',
|
||||||
|
} as any);
|
||||||
|
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any);
|
||||||
|
audibleServiceMock.getAudiobookDetails.mockResolvedValueOnce({
|
||||||
|
releaseDate: futureIso(45),
|
||||||
|
});
|
||||||
|
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
|
||||||
|
prismaMock.audiobook.create.mockResolvedValueOnce({
|
||||||
|
id: 'ab-future',
|
||||||
|
title: 'Future Book',
|
||||||
|
author: 'Future Author',
|
||||||
|
audibleAsin: 'ASIN-FUTURE',
|
||||||
|
} as any);
|
||||||
|
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||||
|
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 'user-1',
|
||||||
|
role: 'admin',
|
||||||
|
autoApproveRequests: null,
|
||||||
|
plexUsername: 'admin',
|
||||||
|
} as any);
|
||||||
|
configServiceGet.mockResolvedValueOnce(null); // default → ON
|
||||||
|
prismaMock.request.create.mockResolvedValueOnce({
|
||||||
|
id: 'req-future',
|
||||||
|
audiobook: { title: 'Future Book' },
|
||||||
|
user: { id: 'user-1', plexUsername: 'admin' },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/bookdate/swipe/route');
|
||||||
|
const response = await POST({} as any);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(payload.success).toBe(true);
|
||||||
|
expect(prismaMock.request.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
status: 'awaiting_release',
|
||||||
|
releaseDate: expect.any(Date),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates pending request and runs search when released + setting ON', async () => {
|
||||||
|
authRequest.json.mockResolvedValue({ recommendationId: 'rec-past', action: 'right', markedAsKnown: false });
|
||||||
|
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 'rec-past',
|
||||||
|
userId: 'user-1',
|
||||||
|
title: 'Old Book',
|
||||||
|
author: 'Old Author',
|
||||||
|
audnexusAsin: 'ASIN-PAST',
|
||||||
|
} as any);
|
||||||
|
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any);
|
||||||
|
audibleServiceMock.getAudiobookDetails.mockResolvedValueOnce({
|
||||||
|
releaseDate: pastIso(365),
|
||||||
|
});
|
||||||
|
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
|
||||||
|
prismaMock.audiobook.create.mockResolvedValueOnce({
|
||||||
|
id: 'ab-past',
|
||||||
|
title: 'Old Book',
|
||||||
|
author: 'Old Author',
|
||||||
|
audibleAsin: 'ASIN-PAST',
|
||||||
|
} as any);
|
||||||
|
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||||
|
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 'user-1',
|
||||||
|
role: 'admin',
|
||||||
|
autoApproveRequests: null,
|
||||||
|
plexUsername: 'admin',
|
||||||
|
} as any);
|
||||||
|
configServiceGet.mockResolvedValueOnce('true');
|
||||||
|
prismaMock.request.create.mockResolvedValueOnce({
|
||||||
|
id: 'req-past',
|
||||||
|
audiobook: { title: 'Old Book' },
|
||||||
|
user: { id: 'user-1', plexUsername: 'admin' },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/bookdate/swipe/route');
|
||||||
|
const response = await POST({} as any);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(payload.success).toBe(true);
|
||||||
|
expect(prismaMock.request.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
status: 'pending',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(jobQueueMock.addSearchJob).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates pending request and runs search when unreleased + setting OFF', async () => {
|
||||||
|
authRequest.json.mockResolvedValue({ recommendationId: 'rec-off', action: 'right', markedAsKnown: false });
|
||||||
|
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 'rec-off',
|
||||||
|
userId: 'user-1',
|
||||||
|
title: 'Off Book',
|
||||||
|
author: 'Off Author',
|
||||||
|
audnexusAsin: 'ASIN-OFF',
|
||||||
|
} as any);
|
||||||
|
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any);
|
||||||
|
audibleServiceMock.getAudiobookDetails.mockResolvedValueOnce({
|
||||||
|
releaseDate: futureIso(45),
|
||||||
|
});
|
||||||
|
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
|
||||||
|
prismaMock.audiobook.create.mockResolvedValueOnce({
|
||||||
|
id: 'ab-off',
|
||||||
|
title: 'Off Book',
|
||||||
|
author: 'Off Author',
|
||||||
|
audibleAsin: 'ASIN-OFF',
|
||||||
|
} as any);
|
||||||
|
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||||
|
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 'user-1',
|
||||||
|
role: 'admin',
|
||||||
|
autoApproveRequests: null,
|
||||||
|
plexUsername: 'admin',
|
||||||
|
} as any);
|
||||||
|
configServiceGet.mockResolvedValueOnce('false');
|
||||||
|
prismaMock.request.create.mockResolvedValueOnce({
|
||||||
|
id: 'req-off',
|
||||||
|
audiobook: { title: 'Off Book' },
|
||||||
|
user: { id: 'user-1', plexUsername: 'admin' },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { POST } = await import('@/app/api/bookdate/swipe/route');
|
||||||
|
const response = await POST({} as any);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(payload.success).toBe(true);
|
||||||
|
expect(prismaMock.request.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
status: 'pending',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(jobQueueMock.addSearchJob).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,6 +40,7 @@ const baseSettings = {
|
|||||||
},
|
},
|
||||||
registration: { enabled: true, requireAdminApproval: false },
|
registration: { enabled: true, requireAdminApproval: false },
|
||||||
prowlarr: { url: 'http://prowlarr', apiKey: 'key' },
|
prowlarr: { url: 'http://prowlarr', apiKey: 'key' },
|
||||||
|
indexerOptions: { skipUnreleased: true },
|
||||||
downloadClient: {
|
downloadClient: {
|
||||||
type: 'qbittorrent',
|
type: 'qbittorrent',
|
||||||
url: 'http://qb',
|
url: 'http://qb',
|
||||||
@@ -275,6 +276,7 @@ describe('admin settings helpers', () => {
|
|||||||
|
|
||||||
it('saves prowlarr settings with enabled indexers and flag configs', async () => {
|
it('saves prowlarr settings with enabled indexers and flag configs', async () => {
|
||||||
fetchWithAuthMock
|
fetchWithAuthMock
|
||||||
|
.mockResolvedValueOnce(makeOk())
|
||||||
.mockResolvedValueOnce(makeOk())
|
.mockResolvedValueOnce(makeOk())
|
||||||
.mockResolvedValueOnce(makeOk());
|
.mockResolvedValueOnce(makeOk());
|
||||||
|
|
||||||
@@ -289,6 +291,16 @@ describe('admin settings helpers', () => {
|
|||||||
const body = JSON.parse((fetchWithAuthMock.mock.calls[1][1] as RequestInit).body as string);
|
const body = JSON.parse((fetchWithAuthMock.mock.calls[1][1] as RequestInit).body as string);
|
||||||
expect(body.indexers[0].enabled).toBe(true);
|
expect(body.indexers[0].enabled).toBe(true);
|
||||||
expect(body.flagConfigs).toHaveLength(1);
|
expect(body.flagConfigs).toHaveLength(1);
|
||||||
|
|
||||||
|
// Indexer options PUT goes last in the prowlarr tab save flow.
|
||||||
|
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
||||||
|
'/api/admin/settings/indexer-options',
|
||||||
|
expect.objectContaining({ method: 'PUT' })
|
||||||
|
);
|
||||||
|
const optionsBody = JSON.parse(
|
||||||
|
(fetchWithAuthMock.mock.calls[2][1] as RequestInit).body as string
|
||||||
|
);
|
||||||
|
expect(optionsBody.skipUnreleased).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('saves download and paths settings', async () => {
|
it('saves download and paths settings', async () => {
|
||||||
|
|||||||
@@ -170,4 +170,52 @@ describe('RequestCard', () => {
|
|||||||
|
|
||||||
expect(screen.getByText(/Completed/)).toBeInTheDocument();
|
expect(screen.getByText(/Completed/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders release date when status is awaiting_release and releaseDate is provided', async () => {
|
||||||
|
const { RequestCard } = await import('@/components/requests/RequestCard');
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RequestCard
|
||||||
|
request={{
|
||||||
|
...baseRequest,
|
||||||
|
status: 'awaiting_release',
|
||||||
|
releaseDate: '2026-08-15T00:00:00Z',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Releases Aug 15, 2026')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render release text when status is awaiting_release but releaseDate is null', async () => {
|
||||||
|
const { RequestCard } = await import('@/components/requests/RequestCard');
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RequestCard
|
||||||
|
request={{
|
||||||
|
...baseRequest,
|
||||||
|
status: 'awaiting_release',
|
||||||
|
releaseDate: null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText(/^Releases /)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render release text when releaseDate is provided but status is not awaiting_release', async () => {
|
||||||
|
const { RequestCard } = await import('@/components/requests/RequestCard');
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RequestCard
|
||||||
|
request={{
|
||||||
|
...baseRequest,
|
||||||
|
status: 'pending',
|
||||||
|
releaseDate: '2026-08-15T00:00:00Z',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText(/^Releases /)).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,4 +20,14 @@ describe('StatusBadge', () => {
|
|||||||
render(<StatusBadge status="custom_status" />);
|
render(<StatusBadge status="custom_status" />);
|
||||||
expect(screen.getByText('custom_status')).toBeInTheDocument();
|
expect(screen.getByText('custom_status')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders the awaiting_release label with teal styling', () => {
|
||||||
|
render(<StatusBadge status="awaiting_release" />);
|
||||||
|
const badge = screen.getByText('Awaiting Release');
|
||||||
|
expect(badge).toBeInTheDocument();
|
||||||
|
expect(badge.className).toContain('bg-teal-100');
|
||||||
|
expect(badge.className).toContain('text-teal-800');
|
||||||
|
expect(badge.className).toContain('dark:bg-teal-900');
|
||||||
|
expect(badge.className).toContain('dark:text-teal-200');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { vi } from 'vitest';
|
|||||||
|
|
||||||
export const createJobQueueMock = () => ({
|
export const createJobQueueMock = () => ({
|
||||||
addSearchJob: vi.fn(),
|
addSearchJob: vi.fn(),
|
||||||
|
addSearchEbookJob: vi.fn(),
|
||||||
addDownloadJob: vi.fn(),
|
addDownloadJob: vi.fn(),
|
||||||
addMonitorJob: vi.fn(),
|
addMonitorJob: vi.fn(),
|
||||||
addOrganizeJob: vi.fn(),
|
addOrganizeJob: vi.fn(),
|
||||||
|
|||||||
@@ -28,15 +28,26 @@ vi.mock('@/lib/integrations/prowlarr.service', () => ({
|
|||||||
getProwlarrService: () => prowlarrMock,
|
getProwlarrService: () => prowlarrMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function futureDate(days = 30): Date {
|
||||||
|
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
describe('processMonitorRssFeeds', () => {
|
describe('processMonitorRssFeeds', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('matches RSS items and queues search jobs', async () => {
|
it('matches RSS items and queues search jobs', async () => {
|
||||||
configMock.get.mockResolvedValue(
|
// Indexer config + skip_unreleased setting both read via the same mock — return appropriate value per key.
|
||||||
JSON.stringify([{ id: 1, name: 'Indexer', rssEnabled: true }])
|
configMock.get.mockImplementation(async (key: string) => {
|
||||||
);
|
if (key === 'prowlarr_indexers') {
|
||||||
|
return JSON.stringify([{ id: 1, name: 'Indexer', rssEnabled: true }]);
|
||||||
|
}
|
||||||
|
if (key === 'indexer.skip_unreleased') {
|
||||||
|
return null; // default ON
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
prowlarrMock.getAllRssFeeds.mockResolvedValue([
|
prowlarrMock.getAllRssFeeds.mockResolvedValue([
|
||||||
{ title: 'Great Book - Author Name' },
|
{ title: 'Great Book - Author Name' },
|
||||||
@@ -45,6 +56,9 @@ describe('processMonitorRssFeeds', () => {
|
|||||||
prismaMock.request.findMany.mockResolvedValue([
|
prismaMock.request.findMany.mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: 'req-1',
|
id: 'req-1',
|
||||||
|
type: 'audiobook',
|
||||||
|
status: 'awaiting_search',
|
||||||
|
releaseDate: null,
|
||||||
audiobook: { id: 'a1', title: 'Great Book', author: 'Author Name', audibleAsin: 'ASIN1' },
|
audiobook: { id: 'a1', title: 'Great Book', author: 'Author Name', audibleAsin: 'ASIN1' },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -58,6 +72,75 @@ describe('processMonitorRssFeeds', () => {
|
|||||||
expect.objectContaining({ title: 'Great Book', author: 'Author Name' })
|
expect.objectContaining({ title: 'Great Book', author: 'Author Name' })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('skips RSS auto-search when matched book is unreleased and setting ON', async () => {
|
||||||
|
configMock.get.mockImplementation(async (key: string) => {
|
||||||
|
if (key === 'prowlarr_indexers') {
|
||||||
|
return JSON.stringify([{ id: 1, name: 'Indexer', rssEnabled: true }]);
|
||||||
|
}
|
||||||
|
if (key === 'indexer.skip_unreleased') {
|
||||||
|
return 'true';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
prowlarrMock.getAllRssFeeds.mockResolvedValue([
|
||||||
|
{ title: 'Future Book - Author Name' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
prismaMock.request.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'req-future',
|
||||||
|
type: 'audiobook',
|
||||||
|
status: 'awaiting_search',
|
||||||
|
releaseDate: futureDate(45),
|
||||||
|
audiobook: { id: 'a-future', title: 'Future Book', author: 'Author Name', audibleAsin: 'ASIN-F' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { processMonitorRssFeeds } = await import('@/lib/processors/monitor-rss-feeds.processor');
|
||||||
|
const result = await processMonitorRssFeeds({ jobId: 'job-2' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
|
||||||
|
expect(jobQueueMock.addSearchEbookJob).not.toHaveBeenCalled();
|
||||||
|
// Request status must not be mutated by RSS processor.
|
||||||
|
expect(prismaMock.request.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs RSS search when matched book is unreleased but setting is OFF', async () => {
|
||||||
|
configMock.get.mockImplementation(async (key: string) => {
|
||||||
|
if (key === 'prowlarr_indexers') {
|
||||||
|
return JSON.stringify([{ id: 1, name: 'Indexer', rssEnabled: true }]);
|
||||||
|
}
|
||||||
|
if (key === 'indexer.skip_unreleased') {
|
||||||
|
return 'false';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
prowlarrMock.getAllRssFeeds.mockResolvedValue([
|
||||||
|
{ title: 'Future Book - Author Name' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
prismaMock.request.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'req-future-off',
|
||||||
|
type: 'audiobook',
|
||||||
|
status: 'awaiting_search',
|
||||||
|
releaseDate: futureDate(45),
|
||||||
|
audiobook: { id: 'a-future', title: 'Future Book', author: 'Author Name', audibleAsin: 'ASIN-F' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { processMonitorRssFeeds } = await import('@/lib/processors/monitor-rss-feeds.processor');
|
||||||
|
const result = await processMonitorRssFeeds({ jobId: 'job-3' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith(
|
||||||
|
'req-future-off',
|
||||||
|
expect.objectContaining({ title: 'Future Book', author: 'Author Name' })
|
||||||
|
);
|
||||||
|
expect(prismaMock.request.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { createJobQueueMock } from '../helpers/job-queue';
|
|||||||
|
|
||||||
const prismaMock = createPrismaMock();
|
const prismaMock = createPrismaMock();
|
||||||
const jobQueueMock = createJobQueueMock();
|
const jobQueueMock = createJobQueueMock();
|
||||||
|
const configMock = vi.hoisted(() => ({ get: vi.fn() }));
|
||||||
|
|
||||||
vi.mock('@/lib/db', () => ({
|
vi.mock('@/lib/db', () => ({
|
||||||
prisma: prismaMock,
|
prisma: prismaMock,
|
||||||
@@ -18,15 +19,32 @@ vi.mock('@/lib/services/job-queue.service', () => ({
|
|||||||
getJobQueueService: () => jobQueueMock,
|
getJobQueueService: () => jobQueueMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/services/config.service', () => ({
|
||||||
|
getConfigService: () => configMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function futureDate(days = 30): Date {
|
||||||
|
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pastDate(days = 30): Date {
|
||||||
|
return new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
describe('processRetryMissingTorrents', () => {
|
describe('processRetryMissingTorrents', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Default: setting ON (default when absent)
|
||||||
|
configMock.get.mockResolvedValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('queues search jobs for awaiting_search requests', async () => {
|
it('queues search jobs for awaiting_search requests with no release date', async () => {
|
||||||
prismaMock.request.findMany.mockResolvedValue([
|
prismaMock.request.findMany.mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: 'req-1',
|
id: 'req-1',
|
||||||
|
type: 'audiobook',
|
||||||
|
status: 'awaiting_search',
|
||||||
|
releaseDate: null,
|
||||||
audiobook: { id: 'a1', title: 'Book', author: 'Author', audibleAsin: 'ASIN1' },
|
audiobook: { id: 'a1', title: 'Book', author: 'Author', audibleAsin: 'ASIN1' },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -39,7 +57,103 @@ describe('processRetryMissingTorrents', () => {
|
|||||||
'req-1',
|
'req-1',
|
||||||
expect.objectContaining({ id: 'a1', title: 'Book', author: 'Author' })
|
expect.objectContaining({ id: 'a1', title: 'Book', author: 'Author' })
|
||||||
);
|
);
|
||||||
|
expect(prismaMock.request.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transitions awaiting_search → awaiting_release when book is unreleased and setting ON', async () => {
|
||||||
|
configMock.get.mockResolvedValue('true');
|
||||||
|
prismaMock.request.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'req-2',
|
||||||
|
type: 'audiobook',
|
||||||
|
status: 'awaiting_search',
|
||||||
|
releaseDate: futureDate(30),
|
||||||
|
audiobook: { id: 'a2', title: 'Future Book', author: 'Future Author', audibleAsin: 'ASIN2' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { processRetryMissingTorrents } = await import('@/lib/processors/retry-missing-torrents.processor');
|
||||||
|
const result = await processRetryMissingTorrents({ jobId: 'job-2' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(prismaMock.request.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'req-2' },
|
||||||
|
data: { status: 'awaiting_release' },
|
||||||
|
});
|
||||||
|
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
|
||||||
|
expect(jobQueueMock.addSearchEbookJob).not.toHaveBeenCalled();
|
||||||
|
expect(result.transitioned).toBe(1);
|
||||||
|
expect(result.skipped).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transitions awaiting_release → awaiting_search and runs search when release date passed', async () => {
|
||||||
|
configMock.get.mockResolvedValue('true');
|
||||||
|
prismaMock.request.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'req-3',
|
||||||
|
type: 'audiobook',
|
||||||
|
status: 'awaiting_release',
|
||||||
|
releaseDate: pastDate(5),
|
||||||
|
audiobook: { id: 'a3', title: 'Released Book', author: 'Some Author', audibleAsin: 'ASIN3' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { processRetryMissingTorrents } = await import('@/lib/processors/retry-missing-torrents.processor');
|
||||||
|
const result = await processRetryMissingTorrents({ jobId: 'job-3' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(prismaMock.request.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'req-3' },
|
||||||
|
data: { status: 'awaiting_search' },
|
||||||
|
});
|
||||||
|
expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith(
|
||||||
|
'req-3',
|
||||||
|
expect.objectContaining({ id: 'a3', title: 'Released Book', author: 'Some Author' })
|
||||||
|
);
|
||||||
|
expect(result.transitioned).toBe(1);
|
||||||
|
expect(result.triggered).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves awaiting_release as-is when book is still unreleased', async () => {
|
||||||
|
configMock.get.mockResolvedValue('true');
|
||||||
|
prismaMock.request.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'req-4',
|
||||||
|
type: 'audiobook',
|
||||||
|
status: 'awaiting_release',
|
||||||
|
releaseDate: futureDate(60),
|
||||||
|
audiobook: { id: 'a4', title: 'Still Future', author: 'Author', audibleAsin: 'ASIN4' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { processRetryMissingTorrents } = await import('@/lib/processors/retry-missing-torrents.processor');
|
||||||
|
const result = await processRetryMissingTorrents({ jobId: 'job-4' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(prismaMock.request.update).not.toHaveBeenCalled();
|
||||||
|
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
|
||||||
|
expect(result.skipped).toBe(1);
|
||||||
|
expect(result.transitioned).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs search for awaiting_search with future date when setting is OFF', async () => {
|
||||||
|
configMock.get.mockResolvedValue('false');
|
||||||
|
prismaMock.request.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'req-5',
|
||||||
|
type: 'audiobook',
|
||||||
|
status: 'awaiting_search',
|
||||||
|
releaseDate: futureDate(10),
|
||||||
|
audiobook: { id: 'a5', title: 'Off Setting Book', author: 'Author', audibleAsin: 'ASIN5' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { processRetryMissingTorrents } = await import('@/lib/processors/retry-missing-torrents.processor');
|
||||||
|
const result = await processRetryMissingTorrents({ jobId: 'job-5' });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(prismaMock.request.update).not.toHaveBeenCalled();
|
||||||
|
expect(jobQueueMock.addSearchJob).toHaveBeenCalled();
|
||||||
|
expect(result.triggered).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -32,19 +32,28 @@ vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
|||||||
findPlexMatch: vi.fn().mockResolvedValue(null),
|
findPlexMatch: vi.fn().mockResolvedValue(null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock AudibleService
|
// Mock AudibleService (default = no Audnexus data)
|
||||||
vi.mock('@/lib/integrations/audible.service', () => ({
|
const audibleServiceMock = vi.hoisted(() => ({
|
||||||
getAudibleService: () => ({
|
|
||||||
getAudiobookDetails: vi.fn().mockResolvedValue(null),
|
getAudiobookDetails: vi.fn().mockResolvedValue(null),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/integrations/audible.service', () => ({
|
||||||
|
getAudibleService: () => audibleServiceMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock job queue (shared across tests so we can assert addSearchJob calls)
|
||||||
|
const jobQueueAddSearchJob = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||||
|
const jobQueueAddNotificationJob = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||||
|
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||||
|
getJobQueueService: () => ({
|
||||||
|
addSearchJob: jobQueueAddSearchJob,
|
||||||
|
addNotificationJob: jobQueueAddNotificationJob,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock job queue
|
// Mock config service for indexer.skip_unreleased setting
|
||||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
const configServiceGet = vi.hoisted(() => vi.fn());
|
||||||
getJobQueueService: () => ({
|
vi.mock('@/lib/services/config.service', () => ({
|
||||||
addSearchJob: vi.fn().mockResolvedValue(undefined),
|
getConfigService: () => ({ get: configServiceGet }),
|
||||||
addNotificationJob: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock getSiblingAsins from works.service
|
// Mock getSiblingAsins from works.service
|
||||||
@@ -68,6 +77,10 @@ describe('createRequestForUser — ignore list', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Restore mock return values cleared by clearAllMocks
|
||||||
|
jobQueueAddSearchJob.mockResolvedValue(undefined);
|
||||||
|
jobQueueAddNotificationJob.mockResolvedValue(undefined);
|
||||||
|
|
||||||
// Default: no existing requests, no library matches
|
// Default: no existing requests, no library matches
|
||||||
prismaMock.request.findFirst.mockResolvedValue(null);
|
prismaMock.request.findFirst.mockResolvedValue(null);
|
||||||
prismaMock.audiobook.findFirst.mockResolvedValue(null);
|
prismaMock.audiobook.findFirst.mockResolvedValue(null);
|
||||||
@@ -97,6 +110,10 @@ describe('createRequestForUser — ignore list', () => {
|
|||||||
prismaMock.ignoredAudiobook.findFirst.mockResolvedValue(null);
|
prismaMock.ignoredAudiobook.findFirst.mockResolvedValue(null);
|
||||||
mockGetSiblingAsins.mockResolvedValue(new Map());
|
mockGetSiblingAsins.mockResolvedValue(new Map());
|
||||||
mockSeedAsin.mockResolvedValue(undefined);
|
mockSeedAsin.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// Default Audnexus + config behaviour
|
||||||
|
audibleServiceMock.getAudiobookDetails.mockResolvedValue(null);
|
||||||
|
configServiceGet.mockResolvedValue(null); // default → setting ON
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blocks auto-request when ASIN is directly ignored', async () => {
|
it('blocks auto-request when ASIN is directly ignored', async () => {
|
||||||
@@ -198,3 +215,114 @@ describe('createRequestForUser — ignore list', () => {
|
|||||||
expect(prismaMock.ignoredAudiobook.findFirst).not.toHaveBeenCalled();
|
expect(prismaMock.ignoredAudiobook.findFirst).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('createRequestForUser — release-date gate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
jobQueueAddSearchJob.mockResolvedValue(undefined);
|
||||||
|
jobQueueAddNotificationJob.mockResolvedValue(undefined);
|
||||||
|
prismaMock.request.findFirst.mockResolvedValue(null);
|
||||||
|
prismaMock.audiobook.findFirst.mockResolvedValue(null);
|
||||||
|
prismaMock.audiobook.create.mockResolvedValue({
|
||||||
|
id: 'audiobook-1',
|
||||||
|
audibleAsin: TEST_AUDIOBOOK.asin,
|
||||||
|
title: TEST_AUDIOBOOK.title,
|
||||||
|
author: TEST_AUDIOBOOK.author,
|
||||||
|
narrator: null,
|
||||||
|
});
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue({
|
||||||
|
role: 'user',
|
||||||
|
autoApproveRequests: true,
|
||||||
|
plexUsername: 'testuser',
|
||||||
|
});
|
||||||
|
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
|
||||||
|
prismaMock.ignoredAudiobook.findFirst.mockResolvedValue(null);
|
||||||
|
mockGetSiblingAsins.mockResolvedValue(new Map());
|
||||||
|
mockSeedAsin.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates request in awaiting_release with no search when book is unreleased and setting ON', async () => {
|
||||||
|
const future = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
audibleServiceMock.getAudiobookDetails.mockResolvedValue({ releaseDate: future });
|
||||||
|
configServiceGet.mockResolvedValue(null); // default → ON
|
||||||
|
|
||||||
|
prismaMock.request.create.mockResolvedValue({
|
||||||
|
id: 'request-future-on',
|
||||||
|
userId: TEST_USER_ID,
|
||||||
|
audiobookId: 'audiobook-1',
|
||||||
|
status: 'awaiting_release',
|
||||||
|
audiobook: { id: 'audiobook-1', title: 'Test Book' },
|
||||||
|
user: { id: TEST_USER_ID, plexUsername: 'testuser' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
|
||||||
|
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(prismaMock.request.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
status: 'awaiting_release',
|
||||||
|
releaseDate: expect.any(Date),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(jobQueueAddSearchJob).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates pending request and runs search when book is already released and setting ON', async () => {
|
||||||
|
const past = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
audibleServiceMock.getAudiobookDetails.mockResolvedValue({ releaseDate: past });
|
||||||
|
configServiceGet.mockResolvedValue('true');
|
||||||
|
|
||||||
|
prismaMock.request.create.mockResolvedValue({
|
||||||
|
id: 'request-past-on',
|
||||||
|
userId: TEST_USER_ID,
|
||||||
|
audiobookId: 'audiobook-1',
|
||||||
|
status: 'pending',
|
||||||
|
audiobook: { id: 'audiobook-1', title: 'Test Book' },
|
||||||
|
user: { id: TEST_USER_ID, plexUsername: 'testuser' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
|
||||||
|
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(prismaMock.request.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
status: 'pending',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(jobQueueAddSearchJob).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates pending request and runs search when book is unreleased but setting OFF', async () => {
|
||||||
|
const future = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
audibleServiceMock.getAudiobookDetails.mockResolvedValue({ releaseDate: future });
|
||||||
|
configServiceGet.mockResolvedValue('false');
|
||||||
|
|
||||||
|
prismaMock.request.create.mockResolvedValue({
|
||||||
|
id: 'request-future-off',
|
||||||
|
userId: TEST_USER_ID,
|
||||||
|
audiobookId: 'audiobook-1',
|
||||||
|
status: 'pending',
|
||||||
|
audiobook: { id: 'audiobook-1', title: 'Test Book' },
|
||||||
|
user: { id: TEST_USER_ID, plexUsername: 'testuser' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
|
||||||
|
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(prismaMock.request.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
status: 'pending',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(jobQueueAddSearchJob).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Component: Release Date Utilities Tests
|
||||||
|
* Documentation: documentation/backend/database.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { isUnreleased, shouldSkipAutoSearch } from '@/lib/utils/release-date';
|
||||||
|
|
||||||
|
describe('isUnreleased', () => {
|
||||||
|
it('returns false for null', () => {
|
||||||
|
expect(isUnreleased(null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for undefined', () => {
|
||||||
|
expect(isUnreleased(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for empty string', () => {
|
||||||
|
expect(isUnreleased('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for malformed string', () => {
|
||||||
|
expect(isUnreleased('not-a-date')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when release date is today (UTC date-only)', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(Date.UTC(
|
||||||
|
now.getUTCFullYear(),
|
||||||
|
now.getUTCMonth(),
|
||||||
|
now.getUTCDate()
|
||||||
|
));
|
||||||
|
expect(isUnreleased(today)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when release date is yesterday', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const yesterday = new Date(Date.UTC(
|
||||||
|
now.getUTCFullYear(),
|
||||||
|
now.getUTCMonth(),
|
||||||
|
now.getUTCDate() - 1
|
||||||
|
));
|
||||||
|
expect(isUnreleased(yesterday)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when release date is tomorrow', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const tomorrow = new Date(Date.UTC(
|
||||||
|
now.getUTCFullYear(),
|
||||||
|
now.getUTCMonth(),
|
||||||
|
now.getUTCDate() + 1
|
||||||
|
));
|
||||||
|
expect(isUnreleased(tomorrow)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for far-future ISO date string', () => {
|
||||||
|
expect(isUnreleased('2099-01-01')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for far-future ISO datetime string', () => {
|
||||||
|
expect(isUnreleased('2099-01-01T00:00:00Z')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for far-past Date object', () => {
|
||||||
|
expect(isUnreleased(new Date('1990-01-01'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for far-future Date object', () => {
|
||||||
|
expect(isUnreleased(new Date('2099-01-01'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UTC boundary cases with fake timers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats same UTC day as released regardless of clock time', () => {
|
||||||
|
// Pin "now" to mid-day UTC on 2026-06-15
|
||||||
|
vi.setSystemTime(new Date('2026-06-15T12:00:00Z'));
|
||||||
|
|
||||||
|
// A release date at the very start of the same UTC day → released
|
||||||
|
expect(isUnreleased('2026-06-15T00:00:00Z')).toBe(false);
|
||||||
|
// A release date at the very end of the same UTC day → released
|
||||||
|
expect(isUnreleased('2026-06-15T23:59:59Z')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats next UTC day as unreleased', () => {
|
||||||
|
vi.setSystemTime(new Date('2026-06-15T23:59:59Z'));
|
||||||
|
expect(isUnreleased('2026-06-16T00:00:00Z')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats previous UTC day as released', () => {
|
||||||
|
vi.setSystemTime(new Date('2026-06-15T00:00:00Z'));
|
||||||
|
expect(isUnreleased('2026-06-14T23:59:59Z')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shouldSkipAutoSearch', () => {
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
|
||||||
|
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
|
||||||
|
|
||||||
|
it('does not skip when setting is OFF, even if unreleased', () => {
|
||||||
|
expect(shouldSkipAutoSearch({ releaseDate: tomorrow }, false)).toEqual({
|
||||||
|
skip: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips with reason "unreleased" when setting ON and release is in the future', () => {
|
||||||
|
expect(shouldSkipAutoSearch({ releaseDate: tomorrow }, true)).toEqual({
|
||||||
|
skip: true,
|
||||||
|
reason: 'unreleased',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not skip when setting ON and release is in the past', () => {
|
||||||
|
expect(shouldSkipAutoSearch({ releaseDate: yesterday }, true)).toEqual({
|
||||||
|
skip: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not skip when setting ON and releaseDate is null', () => {
|
||||||
|
expect(shouldSkipAutoSearch({ releaseDate: null }, true)).toEqual({
|
||||||
|
skip: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user