mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Merge branch 'main' into feature/bulk-import-folder-fallback
Resolves conflicts in src/lib/integrations/audible.service.ts. main switched the ASIN-detail fallback from HTML scraping to the JSON catalog API (fetchAudibleDetailsFromApi), removing scrapeAudibleDetails. The PR's lookupAsinFast was a fail-fast variant of the same pattern that getAudiobookDetails now performs (Audnexus -> catalog API), so it's redundant. - Drop the lookupAsinFast method (delete entire HEAD-side conflict block) - Take main's fetchAudibleDetailsFromApi verbatim (the scrapeAudibleDetails maxRetries parameterization is moot) - In bulk-import scan route, swap lookupAsinFast for getAudiobookDetails Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+118
-44
@@ -14,8 +14,10 @@ import { RecentRequestsTable } from './components/RecentRequestsTable';
|
||||
import { ToastProvider, useToast } from '@/components/ui/Toast';
|
||||
import { ReportedIssuesSection } from './components/ReportedIssuesSection';
|
||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||
import { BulkImportWizard } from '@/components/admin/BulkImportWizard';
|
||||
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
|
||||
import { InformationCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -56,15 +58,78 @@ function formatTorrentSize(bytes: number): string {
|
||||
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface ApprovalActionButtonsProps {
|
||||
isLoading: boolean;
|
||||
onApprove: () => void;
|
||||
onSearch: () => void;
|
||||
onDeny: () => void;
|
||||
}
|
||||
|
||||
function ApprovalActionButtons({ isLoading, onApprove, onSearch, onDeny }: ApprovalActionButtonsProps) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={onApprove}
|
||||
disabled={isLoading}
|
||||
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{isLoading ? <LoadingSpinner /> : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
<span>Approve</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onSearch}
|
||||
disabled={isLoading}
|
||||
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<span>Search</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onDeny}
|
||||
disabled={isLoading}
|
||||
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{isLoading ? <LoadingSpinner /> : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
<span>Deny</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest[] }) {
|
||||
const toast = useToast();
|
||||
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
|
||||
const [searchModalRequestId, setSearchModalRequestId] = useState<string | null>(null);
|
||||
const [detailsAsin, setDetailsAsin] = useState<string | null>(null);
|
||||
const [detailsRequestId, setDetailsRequestId] = useState<string | null>(null);
|
||||
|
||||
const searchModalRequest = searchModalRequestId
|
||||
? requests.find((r) => r.id === searchModalRequestId)
|
||||
: null;
|
||||
|
||||
const detailsRequest = detailsRequestId
|
||||
? requests.find((r) => r.id === detailsRequestId)
|
||||
: null;
|
||||
|
||||
const handleApproveRequest = async (requestId: string) => {
|
||||
setLoadingStates((prev) => ({ ...prev, [requestId]: true }));
|
||||
|
||||
@@ -125,13 +190,6 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
await mutate('/api/admin/metrics');
|
||||
};
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
{/* Section Header */}
|
||||
@@ -170,8 +228,23 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
return (
|
||||
<div
|
||||
key={request.id}
|
||||
className="bg-white dark:bg-gray-800 border-2 border-amber-200 dark:border-amber-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||
className="relative bg-white dark:bg-gray-800 border-2 border-amber-200 dark:border-amber-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||
>
|
||||
{/* Info Button — opens AudiobookDetailsModal */}
|
||||
{request.audiobook.audibleAsin && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setDetailsAsin(request.audiobook.audibleAsin);
|
||||
setDetailsRequestId(request.id);
|
||||
}}
|
||||
className="absolute top-2 right-2 z-10 p-1 text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-colors rounded-full hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="View book details"
|
||||
aria-label="View book details"
|
||||
>
|
||||
<InformationCircleIcon className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Card Content */}
|
||||
<div className="p-4">
|
||||
<div className="flex gap-3">
|
||||
@@ -314,42 +387,12 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="border-t border-amber-200 dark:border-amber-800 bg-gray-50 dark:bg-gray-900/50 px-4 py-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => handleApproveRequest(request.id)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{isLoading ? <LoadingSpinner /> : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
<span>Approve</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSearchModalRequestId(request.id)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<span>Search</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDenyRequest(request.id)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{isLoading ? <LoadingSpinner /> : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
<span>Deny</span>
|
||||
</button>
|
||||
<ApprovalActionButtons
|
||||
isLoading={isLoading}
|
||||
onApprove={() => handleApproveRequest(request.id)}
|
||||
onSearch={() => setSearchModalRequestId(request.id)}
|
||||
onDeny={() => handleDenyRequest(request.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -375,6 +418,37 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Book Details Modal — opened via info button on each approval card */}
|
||||
{detailsAsin && detailsRequestId && (
|
||||
<AudiobookDetailsModal
|
||||
asin={detailsAsin}
|
||||
isOpen={true}
|
||||
onClose={() => { setDetailsAsin(null); setDetailsRequestId(null); }}
|
||||
requestStatus="awaiting_approval"
|
||||
requestedByUsername={detailsRequest?.user.plexUsername ?? null}
|
||||
adminActions={
|
||||
<ApprovalActionButtons
|
||||
isLoading={loadingStates[detailsRequestId] || false}
|
||||
onApprove={async () => {
|
||||
await handleApproveRequest(detailsRequestId);
|
||||
setDetailsAsin(null);
|
||||
setDetailsRequestId(null);
|
||||
}}
|
||||
onSearch={() => {
|
||||
setSearchModalRequestId(detailsRequestId);
|
||||
setDetailsAsin(null);
|
||||
setDetailsRequestId(null);
|
||||
}}
|
||||
onDeny={async () => {
|
||||
await handleDenyRequest(detailsRequestId);
|
||||
setDetailsAsin(null);
|
||||
setDetailsRequestId(null);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ export async function POST(request: NextRequest) {
|
||||
// keyword text search. Fall back to text search if the lookup fails.
|
||||
if (book.extractedAsin) {
|
||||
try {
|
||||
const asinResult = await audibleService.lookupAsinFast(book.extractedAsin);
|
||||
const asinResult = await audibleService.getAudiobookDetails(book.extractedAsin);
|
||||
if (asinResult) {
|
||||
match = asinResult;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||
import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||
@@ -41,16 +41,19 @@ export async function GET(request: NextRequest) {
|
||||
const currentUser = getCurrentUser(request);
|
||||
const userId = currentUser?.sub || undefined;
|
||||
|
||||
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
|
||||
// Two-pass dedup: local title/narrator/duration matching first, then collapse
|
||||
// any remaining duplicates that the works table already knows are the same book
|
||||
// (handles cases where source metadata diverges across paths or pages).
|
||||
const { books: dedupedResults, groups } = deduplicateAndCollectGroups(results.results);
|
||||
|
||||
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
|
||||
if (groups.length > 0) {
|
||||
persistDedupGroups(groups).catch(() => {});
|
||||
}
|
||||
|
||||
const collapsedResults = await collapseByExistingWorks(dedupedResults);
|
||||
|
||||
// Enrich search results with availability and request status information
|
||||
const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId);
|
||||
const enrichedResults = await enrichAudiobooksWithMatches(collapsedResults, userId);
|
||||
|
||||
// Annotate with per-user ignore status
|
||||
const annotatedResults = await annotateWithIgnoreStatus(enrichedResults, userId);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||
import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||
@@ -56,17 +56,20 @@ export async function GET(
|
||||
const audibleService = getAudibleService();
|
||||
const result = await audibleService.searchByAuthorAsin(authorName.trim(), asin, page);
|
||||
|
||||
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
|
||||
// Two-pass dedup: local title/narrator/duration matching first, then collapse
|
||||
// any remaining duplicates that the works table already knows are the same book
|
||||
// (handles cases where source metadata diverges across paths or pages).
|
||||
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(result.books);
|
||||
|
||||
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
|
||||
if (groups.length > 0) {
|
||||
persistDedupGroups(groups).catch(() => {});
|
||||
}
|
||||
|
||||
const collapsedBooks = await collapseByExistingWorks(dedupedBooks);
|
||||
|
||||
// Enrich with library availability and request status
|
||||
const userId = currentUser.sub || undefined;
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(collapsedBooks, userId);
|
||||
|
||||
// Annotate with per-user ignore status
|
||||
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||
import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service';
|
||||
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||
|
||||
const logger = RMABLogger.create('API.Series.Detail');
|
||||
@@ -52,17 +52,20 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
|
||||
// Two-pass dedup: local title/narrator/duration matching first, then collapse
|
||||
// any remaining duplicates that the works table already knows are the same book
|
||||
// (handles cases where source metadata diverges across paths or pages).
|
||||
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(detail.books);
|
||||
|
||||
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
|
||||
if (groups.length > 0) {
|
||||
persistDedupGroups(groups).catch(() => {});
|
||||
}
|
||||
|
||||
const collapsedBooks = await collapseByExistingWorks(dedupedBooks);
|
||||
|
||||
// Enrich books with library availability and request status
|
||||
const userId = currentUser.sub || undefined;
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(collapsedBooks, userId);
|
||||
|
||||
// Annotate with per-user ignore status
|
||||
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
|
||||
|
||||
@@ -0,0 +1,932 @@
|
||||
/**
|
||||
* Component: Path Mapping Helper
|
||||
* Documentation: documentation/deployment/volume-mapping.md
|
||||
*
|
||||
* Public, unprotected page that guides users through configuring
|
||||
* Docker volume mappings for their download clients and RMAB.
|
||||
* Purely client-side — no API calls, no real data access.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import {
|
||||
CLIENT_DISPLAY_NAMES,
|
||||
CLIENT_PROTOCOL_MAP,
|
||||
type DownloadClientType,
|
||||
} from '@/lib/interfaces/download-client.interface';
|
||||
|
||||
// =========================================================================
|
||||
// TYPES
|
||||
// =========================================================================
|
||||
|
||||
interface ClientConfig {
|
||||
type: DownloadClientType;
|
||||
/** The path inside the download client container where completed downloads land */
|
||||
savePath: string;
|
||||
/** The volume mapping from the client's docker-compose (host:container) — host side */
|
||||
hostPath: string;
|
||||
/** The volume mapping from the client's docker-compose (host:container) — container side */
|
||||
containerMountPath: string;
|
||||
/** Whether this client needs remote path mapping */
|
||||
remotePathMapping: boolean;
|
||||
/** The path as seen by the remote download client (for remote path mapping) */
|
||||
remotePath: string;
|
||||
}
|
||||
|
||||
type Step = 'clients' | 'save-paths' | 'host-paths' | 'results';
|
||||
|
||||
const STEPS: { key: Step; title: string }[] = [
|
||||
{ key: 'clients', title: 'Clients' },
|
||||
{ key: 'save-paths', title: 'Save Paths' },
|
||||
{ key: 'host-paths', title: 'Volume Mapping' },
|
||||
{ key: 'results', title: 'Results' },
|
||||
];
|
||||
|
||||
const ALL_CLIENTS: DownloadClientType[] = ['qbittorrent', 'transmission', 'deluge', 'sabnzbd', 'nzbget'];
|
||||
|
||||
const DEFAULT_SAVE_PATHS: Record<DownloadClientType, string> = {
|
||||
qbittorrent: '/downloads',
|
||||
transmission: '/downloads/complete',
|
||||
deluge: '/downloads',
|
||||
sabnzbd: '/downloads/complete',
|
||||
nzbget: '/downloads/completed',
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// UTILITY FUNCTIONS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Find the longest common path prefix across multiple paths.
|
||||
* Only meaningful when there are multiple DIFFERENT paths.
|
||||
*/
|
||||
function findCommonRoot(paths: string[]): string {
|
||||
if (paths.length === 0) return '';
|
||||
if (paths.length === 1) return paths[0];
|
||||
|
||||
const unique = [...new Set(paths)];
|
||||
if (unique.length === 1) return unique[0];
|
||||
|
||||
// Split each path into segments
|
||||
const segmentArrays = unique.map((p) => p.replace(/\/+$/, '').split('/').filter(Boolean));
|
||||
const minLength = Math.min(...segmentArrays.map((s) => s.length));
|
||||
|
||||
const commonSegments: string[] = [];
|
||||
for (let i = 0; i < minLength; i++) {
|
||||
const segment = segmentArrays[0][i];
|
||||
if (segmentArrays.every((s) => s[i] === segment)) {
|
||||
commonSegments.push(segment);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (commonSegments.length === 0) return '/';
|
||||
return '/' + commonSegments.join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relative path from a root to a full path.
|
||||
* Returns empty string if they're the same.
|
||||
*/
|
||||
function getRelativePath(root: string, fullPath: string): string {
|
||||
const normalizedRoot = root.replace(/\/+$/, '');
|
||||
const normalizedFull = fullPath.replace(/\/+$/, '');
|
||||
|
||||
if (normalizedRoot === normalizedFull) return '';
|
||||
|
||||
if (normalizedFull.startsWith(normalizedRoot + '/')) {
|
||||
return normalizedFull.slice(normalizedRoot.length + 1);
|
||||
}
|
||||
|
||||
// Shouldn't happen if common root is correct, but fallback
|
||||
return normalizedFull;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the common root of the host paths to build the RMAB volume mapping.
|
||||
* Maps from the host path hierarchy to the container path hierarchy.
|
||||
*/
|
||||
function findHostCommonRoot(configs: ClientConfig[]): string {
|
||||
const hostPaths = configs.map((c) => c.hostPath);
|
||||
if (hostPaths.length === 0) return '';
|
||||
if (hostPaths.length === 1) return hostPaths[0];
|
||||
|
||||
const unique = [...new Set(hostPaths)];
|
||||
if (unique.length === 1) return unique[0];
|
||||
|
||||
return findCommonRoot(hostPaths);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// COMPONENTS
|
||||
// =========================================================================
|
||||
|
||||
function StepIndicator({ currentStep }: { currentStep: Step }) {
|
||||
const currentIndex = STEPS.findIndex((s) => s.key === currentStep);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-4">
|
||||
{STEPS.map((step, index) => (
|
||||
<div key={step.key} className="flex items-center flex-1">
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<div
|
||||
className={`
|
||||
w-10 h-10 rounded-full flex items-center justify-center font-semibold text-sm
|
||||
${
|
||||
index < currentIndex
|
||||
? 'bg-green-500 text-white'
|
||||
: index === currentIndex
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{index < currentIndex ? (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`
|
||||
text-xs mt-2 text-center whitespace-nowrap
|
||||
${
|
||||
index === currentIndex
|
||||
? 'text-blue-600 dark:text-blue-400 font-medium'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{step.title}
|
||||
</span>
|
||||
</div>
|
||||
{index < STEPS.length - 1 && (
|
||||
<div
|
||||
className={`
|
||||
h-1 flex-1 mx-1 rounded
|
||||
${index < currentIndex ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'}
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoBox({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex gap-3">
|
||||
<svg
|
||||
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WarningBox({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800">
|
||||
<div className="flex gap-3">
|
||||
<svg
|
||||
className="w-6 h-6 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div className="text-sm text-amber-800 dark:text-amber-200">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ children, label, onCopy }: { children: string; label?: string; onCopy?: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(children);
|
||||
setCopied(true);
|
||||
onCopy?.();
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{label && (
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{label}</div>
|
||||
)}
|
||||
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 font-mono text-sm text-gray-100 overflow-x-auto">
|
||||
<pre className="whitespace-pre">{children}</pre>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 px-2 py-1 text-xs rounded bg-gray-700 hover:bg-gray-600 text-gray-300 transition-colors"
|
||||
style={label ? { top: '1.75rem' } : undefined}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// STEP COMPONENTS
|
||||
// =========================================================================
|
||||
|
||||
function ClientSelectionStep({
|
||||
selectedClients,
|
||||
onToggle,
|
||||
onNext,
|
||||
}: {
|
||||
selectedClients: Set<DownloadClientType>;
|
||||
onToggle: (client: DownloadClientType) => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Which download clients do you use?
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Select all the download clients you have configured or plan to use with ReadMeABook.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{ALL_CLIENTS.map((client) => {
|
||||
const protocol = CLIENT_PROTOCOL_MAP[client];
|
||||
const isSelected = selectedClients.has(client);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={client}
|
||||
onClick={() => onToggle(client)}
|
||||
className={`
|
||||
w-full flex items-center gap-4 p-4 rounded-lg border-2 transition-all text-left
|
||||
${
|
||||
isSelected
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
w-6 h-6 rounded border-2 flex items-center justify-center flex-shrink-0
|
||||
${
|
||||
isSelected
|
||||
? 'border-blue-500 bg-blue-500'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{CLIENT_DISPLAY_NAMES[client]}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 capitalize">
|
||||
{protocol} client
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button onClick={onNext} disabled={selectedClients.size === 0}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SavePathsStep({
|
||||
configs,
|
||||
onUpdateConfig,
|
||||
onNext,
|
||||
onBack,
|
||||
}: {
|
||||
configs: ClientConfig[];
|
||||
onUpdateConfig: (type: DownloadClientType, field: keyof ClientConfig, value: string) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const allFilled = configs.every((c) => c.savePath.trim() !== '');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Download client save paths
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
For each client, enter the path <strong>inside that client's container</strong> where
|
||||
completed downloads are saved. This is the path you see in the client's own settings
|
||||
(e.g., qBittorrent Web UI → Options → Downloads → Default Save Path).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InfoBox>
|
||||
<p>
|
||||
<strong>This is the container path, not the host path.</strong> For example, if your
|
||||
qBittorrent docker-compose has <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">-
|
||||
/mnt/data/torrents:/downloads</code>, and qBittorrent is configured to save
|
||||
to <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">/downloads</code>, then
|
||||
enter <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">/downloads</code> here.
|
||||
</p>
|
||||
</InfoBox>
|
||||
|
||||
<div className="space-y-4">
|
||||
{configs.map((config) => (
|
||||
<div key={config.type} className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{CLIENT_DISPLAY_NAMES[config.type]}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 capitalize">
|
||||
{CLIENT_PROTOCOL_MAP[config.type]}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={DEFAULT_SAVE_PATHS[config.type]}
|
||||
value={config.savePath}
|
||||
onChange={(e) => onUpdateConfig(config.type, 'savePath', e.target.value)}
|
||||
className="font-mono"
|
||||
helperText={`Default: ${DEFAULT_SAVE_PATHS[config.type]}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button onClick={onBack} variant="outline">
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={onNext} disabled={!allFilled}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HostPathsStep({
|
||||
configs,
|
||||
onUpdateConfig,
|
||||
onNext,
|
||||
onBack,
|
||||
}: {
|
||||
configs: ClientConfig[];
|
||||
onUpdateConfig: (type: DownloadClientType, field: keyof ClientConfig, value: string | boolean) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const allFilled = configs.every(
|
||||
(c) => c.hostPath.trim() !== '' && c.containerMountPath.trim() !== '' && (!c.remotePathMapping || c.remotePath.trim() !== '')
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Docker volume mappings
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
For each client, enter the volume mapping from <strong>that client's</strong> docker-compose
|
||||
file. This tells us where on your host machine the downloads actually end up.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InfoBox>
|
||||
<p>
|
||||
A Docker volume mapping looks like <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">/host/path:/container/path</code> in
|
||||
your docker-compose.yml. We need both sides so we know how to map RMAB to the same files.
|
||||
</p>
|
||||
</InfoBox>
|
||||
|
||||
<div className="space-y-6">
|
||||
{configs.map((config) => (
|
||||
<div key={config.type} className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-5 border border-gray-200 dark:border-gray-700 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{CLIENT_DISPLAY_NAMES[config.type]}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 capitalize">
|
||||
{CLIENT_PROTOCOL_MAP[config.type]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Host path (left side of :)"
|
||||
placeholder="/mnt/data/downloads"
|
||||
value={config.hostPath}
|
||||
onChange={(e) => onUpdateConfig(config.type, 'hostPath', e.target.value)}
|
||||
className="font-mono"
|
||||
helperText="The real path on your server"
|
||||
/>
|
||||
<Input
|
||||
label="Container path (right side of :)"
|
||||
placeholder="/downloads"
|
||||
value={config.containerMountPath}
|
||||
onChange={(e) => onUpdateConfig(config.type, 'containerMountPath', e.target.value)}
|
||||
className="font-mono"
|
||||
helperText="The path inside the container"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.containerMountPath && config.hostPath && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 font-mono bg-gray-100 dark:bg-gray-900 rounded px-3 py-2">
|
||||
{config.hostPath}:{config.containerMountPath}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remote path mapping toggle */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`remote-${config.type}`}
|
||||
checked={config.remotePathMapping}
|
||||
onChange={(e) => onUpdateConfig(config.type, 'remotePathMapping', 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={`remote-${config.type}`}
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
This client runs on a different machine than RMAB
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Enable this if the download client is on a seedbox, separate server, or otherwise has a
|
||||
different filesystem than where RMAB runs. Also enable this if the client runs on the
|
||||
host (not in Docker) while RMAB runs in Docker.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.remotePathMapping && (
|
||||
<div className="mt-3 ml-8">
|
||||
<Input
|
||||
label="Remote path (as seen by the download client)"
|
||||
placeholder="/remote/mnt/downloads/complete"
|
||||
value={config.remotePath}
|
||||
onChange={(e) => onUpdateConfig(config.type, 'remotePath', e.target.value)}
|
||||
className="font-mono"
|
||||
helperText="The path the download client reports when a download completes. This is often the same as the client's save path."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button onClick={onBack} variant="outline">
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={onNext} disabled={!allFilled}>
|
||||
Generate Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultsStep({
|
||||
configs,
|
||||
onBack,
|
||||
onRestart,
|
||||
}: {
|
||||
configs: ClientConfig[];
|
||||
onBack: () => void;
|
||||
onRestart: () => void;
|
||||
}) {
|
||||
// Determine if we need custom paths (multiple clients with different save paths)
|
||||
const savePaths = configs.map((c) => c.savePath.replace(/\/+$/, ''));
|
||||
const uniqueSavePaths = [...new Set(savePaths)];
|
||||
const needsCustomPaths = configs.length > 1 && uniqueSavePaths.length > 1;
|
||||
|
||||
// Calculate RMAB download directory
|
||||
const rmabDownloadDir = needsCustomPaths ? findCommonRoot(savePaths) : savePaths[0];
|
||||
|
||||
// Calculate custom paths per client (only if needed)
|
||||
const clientCustomPaths = needsCustomPaths
|
||||
? configs.map((c) => ({
|
||||
type: c.type,
|
||||
customPath: getRelativePath(rmabDownloadDir, c.savePath.replace(/\/+$/, '')),
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Calculate RMAB volume mapping
|
||||
// We need the host path that corresponds to the rmabDownloadDir
|
||||
// If all clients share the same save path, we use that client's host path directly.
|
||||
// If multiple different paths, we find the common host root.
|
||||
let rmabHostPath: string;
|
||||
let rmabContainerPath: string;
|
||||
|
||||
if (!needsCustomPaths) {
|
||||
// Single path scenario — use the first client's host path
|
||||
// But we need to consider if the container mount path differs from the save path
|
||||
const config = configs[0];
|
||||
const saveRelativeToMount = getRelativePath(
|
||||
config.containerMountPath.replace(/\/+$/, ''),
|
||||
config.savePath.replace(/\/+$/, '')
|
||||
);
|
||||
|
||||
if (saveRelativeToMount) {
|
||||
// Save path is deeper than the mount: host must include that extra depth
|
||||
rmabHostPath = config.hostPath.replace(/\/+$/, '') + '/' + saveRelativeToMount;
|
||||
} else {
|
||||
rmabHostPath = config.hostPath;
|
||||
}
|
||||
rmabContainerPath = rmabDownloadDir;
|
||||
} else {
|
||||
// Multiple different paths — we need to find the host root that covers all
|
||||
// For each client, compute the host path that corresponds to the common container root
|
||||
const hostRoots = configs.map((c) => {
|
||||
const mountRelativeToCommon = getRelativePath(
|
||||
rmabDownloadDir,
|
||||
c.containerMountPath.replace(/\/+$/, '')
|
||||
);
|
||||
const saveRelativeToMount = getRelativePath(
|
||||
c.containerMountPath.replace(/\/+$/, ''),
|
||||
c.savePath.replace(/\/+$/, '')
|
||||
);
|
||||
// The host path maps to containerMountPath. We need to go up if rmabDownloadDir
|
||||
// is a parent of the container mount path.
|
||||
const containerMountNorm = c.containerMountPath.replace(/\/+$/, '');
|
||||
const rmabDirNorm = rmabDownloadDir.replace(/\/+$/, '');
|
||||
|
||||
if (containerMountNorm === rmabDirNorm) {
|
||||
return c.hostPath.replace(/\/+$/, '');
|
||||
} else if (containerMountNorm.startsWith(rmabDirNorm + '/')) {
|
||||
// Container mount is deeper than RMAB dir — we need to go up on the host side
|
||||
const depth = containerMountNorm.slice(rmabDirNorm.length + 1).split('/').length;
|
||||
const hostSegments = c.hostPath.replace(/\/+$/, '').split('/');
|
||||
return hostSegments.slice(0, -depth).join('/') || '/';
|
||||
} else if (rmabDirNorm.startsWith(containerMountNorm + '/')) {
|
||||
// RMAB dir is deeper than container mount — append the extra to host
|
||||
const extra = rmabDirNorm.slice(containerMountNorm.length + 1);
|
||||
return c.hostPath.replace(/\/+$/, '') + '/' + extra;
|
||||
}
|
||||
return c.hostPath.replace(/\/+$/, '');
|
||||
});
|
||||
|
||||
rmabHostPath = findHostCommonRoot(
|
||||
configs.map((c, i) => ({ ...c, hostPath: hostRoots[i] }))
|
||||
);
|
||||
rmabContainerPath = rmabDownloadDir;
|
||||
}
|
||||
|
||||
// Build the RMAB compose snippet
|
||||
const composeSnippet = `services:
|
||||
readmeabook:
|
||||
volumes:
|
||||
- ${rmabHostPath}:${rmabContainerPath}
|
||||
# ... your other RMAB volumes (config, media, etc.)`;
|
||||
|
||||
// Build remote path mapping info
|
||||
const remoteClients = configs.filter((c) => c.remotePathMapping);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Your recommended configuration
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Based on your inputs, here's how to configure ReadMeABook and your download clients.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* RMAB Download Directory */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
1. RMAB Download Directory Setting
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Set this in RMAB's settings under <strong>Admin → Settings → Paths → Download Directory</strong>.
|
||||
</p>
|
||||
<CodeBlock label="Download Directory">{rmabDownloadDir}</CodeBlock>
|
||||
</div>
|
||||
|
||||
{/* Custom paths per client */}
|
||||
{needsCustomPaths && clientCustomPaths.some((c) => c.customPath) && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
2. Client Custom Paths
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Since your clients save to different locations, set these custom paths on each download client
|
||||
in RMAB (<strong>Admin → Settings → Download Clients → Edit → Custom Path</strong>).
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{clientCustomPaths.map((c) => (
|
||||
<div key={c.type} className="flex items-center gap-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100 min-w-[120px]">
|
||||
{CLIENT_DISPLAY_NAMES[c.type as DownloadClientType]}:
|
||||
</span>
|
||||
<code className="font-mono text-sm bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded text-gray-800 dark:text-gray-200">
|
||||
{c.customPath || '(none — same as download directory)'}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RMAB Docker Compose Volume */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{needsCustomPaths ? '3' : '2'}. RMAB Docker Compose Volume Mapping
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Add this volume mapping to your RMAB docker-compose.yml. This ensures RMAB can see the
|
||||
same files your download clients produce.
|
||||
</p>
|
||||
<CodeBlock label="docker-compose.yml">{composeSnippet}</CodeBlock>
|
||||
</div>
|
||||
|
||||
{/* Golden Rule explanation */}
|
||||
<WarningBox>
|
||||
<p className="font-semibold mb-1">The Golden Rule</p>
|
||||
<p>
|
||||
Both your download client and RMAB must see files at the <strong>same container path</strong>.
|
||||
The volume mapping above ensures that when your download client saves a file
|
||||
to <code className="bg-amber-100 dark:bg-amber-800 px-1 rounded">{configs[0]?.savePath}</code>,
|
||||
RMAB can also find it at that same path.
|
||||
</p>
|
||||
</WarningBox>
|
||||
|
||||
{/* Verification */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{needsCustomPaths ? '4' : '3'}. Verify your setup
|
||||
</h3>
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<ul className="space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{configs.map((c) => (
|
||||
<li key={c.type} className="flex items-start gap-2">
|
||||
<span className="text-gray-400 mt-0.5">•</span>
|
||||
<span>
|
||||
<strong>{CLIENT_DISPLAY_NAMES[c.type]}</strong> saves
|
||||
to <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{c.savePath}</code>
|
||||
{' '}→ host path <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{c.hostPath}</code>
|
||||
{needsCustomPaths && (
|
||||
<>
|
||||
{' '}→ RMAB custom
|
||||
path: <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">
|
||||
{getRelativePath(rmabDownloadDir, c.savePath.replace(/\/+$/, '')) || '(none)'}
|
||||
</code>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-gray-400 mt-0.5">•</span>
|
||||
<span>
|
||||
<strong>RMAB</strong> mounts <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{rmabHostPath}:{rmabContainerPath}</code>
|
||||
{' '}→ download directory set
|
||||
to <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{rmabDownloadDir}</code>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remote Path Mapping */}
|
||||
{remoteClients.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Remote Path Mapping
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
These clients run on a different machine. Configure remote path mapping for each in
|
||||
RMAB (<strong>Admin → Settings → Download Clients → Edit</strong>).
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{remoteClients.map((c) => {
|
||||
const localPath = needsCustomPaths
|
||||
? rmabDownloadDir + '/' + getRelativePath(rmabDownloadDir, c.savePath.replace(/\/+$/, ''))
|
||||
: rmabDownloadDir;
|
||||
|
||||
return (
|
||||
<div key={c.type} className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700 space-y-2">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{CLIENT_DISPLAY_NAMES[c.type]}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400 block mb-1">Enable Remote Path Mapping:</span>
|
||||
<code className="bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded font-mono text-gray-800 dark:text-gray-200">Yes</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400 block mb-1">Remote Path:</span>
|
||||
<code className="bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded font-mono text-gray-800 dark:text-gray-200">{c.remotePath}</code>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<span className="text-gray-500 dark:text-gray-400 block mb-1">Local Path:</span>
|
||||
<code className="bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded font-mono text-gray-800 dark:text-gray-200">{localPath}</code>
|
||||
</div>
|
||||
</div>
|
||||
<InfoBox>
|
||||
<p>
|
||||
When this client reports a file at <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{c.remotePath}/audiobook.m4b</code>,
|
||||
RMAB will translate it to <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{localPath}/audiobook.m4b</code>.
|
||||
</p>
|
||||
</InfoBox>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button onClick={onBack} variant="outline">
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={onRestart} variant="secondary">
|
||||
Start Over
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MAIN PAGE
|
||||
// =========================================================================
|
||||
|
||||
export default function PathHelperPage() {
|
||||
const [step, setStep] = useState<Step>('clients');
|
||||
const [selectedClients, setSelectedClients] = useState<Set<DownloadClientType>>(new Set());
|
||||
const [clientConfigs, setClientConfigs] = useState<Map<DownloadClientType, ClientConfig>>(new Map());
|
||||
|
||||
// Build ordered configs array from selected clients
|
||||
const configs = useMemo(() => {
|
||||
return ALL_CLIENTS
|
||||
.filter((c) => selectedClients.has(c))
|
||||
.map((type) => {
|
||||
const existing = clientConfigs.get(type);
|
||||
return (
|
||||
existing || {
|
||||
type,
|
||||
savePath: DEFAULT_SAVE_PATHS[type],
|
||||
hostPath: '',
|
||||
containerMountPath: '',
|
||||
remotePathMapping: false,
|
||||
remotePath: '',
|
||||
}
|
||||
);
|
||||
});
|
||||
}, [selectedClients, clientConfigs]);
|
||||
|
||||
const toggleClient = (client: DownloadClientType) => {
|
||||
setSelectedClients((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(client)) {
|
||||
next.delete(client);
|
||||
} else {
|
||||
next.add(client);
|
||||
// Initialize config if not exists
|
||||
if (!clientConfigs.has(client)) {
|
||||
setClientConfigs((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(client, {
|
||||
type: client,
|
||||
savePath: DEFAULT_SAVE_PATHS[client],
|
||||
hostPath: '',
|
||||
containerMountPath: '',
|
||||
remotePathMapping: false,
|
||||
remotePath: '',
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateConfig = (type: DownloadClientType, field: keyof ClientConfig, value: string | boolean) => {
|
||||
setClientConfigs((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(type);
|
||||
if (existing) {
|
||||
next.set(type, { ...existing, [field]: value });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const goToStep = (target: Step) => setStep(target);
|
||||
|
||||
const restart = () => {
|
||||
setStep('clients');
|
||||
setSelectedClients(new Set());
|
||||
setClientConfigs(new Map());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="container mx-auto px-4 py-6 max-w-4xl">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Path Mapping Helper
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Get your download client volume mappings configured correctly for ReadMeABook
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="container mx-auto px-2 sm:px-4 max-w-4xl">
|
||||
<StepIndicator currentStep={step} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-8">
|
||||
{step === 'clients' && (
|
||||
<ClientSelectionStep
|
||||
selectedClients={selectedClients}
|
||||
onToggle={toggleClient}
|
||||
onNext={() => goToStep('save-paths')}
|
||||
/>
|
||||
)}
|
||||
{step === 'save-paths' && (
|
||||
<SavePathsStep
|
||||
configs={configs}
|
||||
onUpdateConfig={updateConfig}
|
||||
onNext={() => goToStep('host-paths')}
|
||||
onBack={() => goToStep('clients')}
|
||||
/>
|
||||
)}
|
||||
{step === 'host-paths' && (
|
||||
<HostPathsStep
|
||||
configs={configs}
|
||||
onUpdateConfig={updateConfig}
|
||||
onNext={() => goToStep('results')}
|
||||
onBack={() => goToStep('save-paths')}
|
||||
/>
|
||||
)}
|
||||
{step === 'results' && (
|
||||
<ResultsStep
|
||||
configs={configs}
|
||||
onBack={() => goToStep('host-paths')}
|
||||
onRestart={restart}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,8 @@ interface AudiobookDetailsModalProps {
|
||||
hideRequestActions?: boolean;
|
||||
hasReportedIssue?: boolean;
|
||||
aiReason?: string | null;
|
||||
/** Optional admin action buttons (Approve / Search / Deny) rendered as a second row in the action bar */
|
||||
adminActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Status helper
|
||||
@@ -80,6 +82,7 @@ export function AudiobookDetailsModal({
|
||||
hideRequestActions = false,
|
||||
hasReportedIssue = false,
|
||||
aiReason = null,
|
||||
adminActions,
|
||||
}: AudiobookDetailsModalProps) {
|
||||
const { user } = useAuth();
|
||||
const { squareCovers } = usePreferences();
|
||||
@@ -548,6 +551,30 @@ export function AudiobookDetailsModal({
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Language */}
|
||||
{audiobook.language && (
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Language</p>
|
||||
<p className="text-gray-900 dark:text-gray-100 capitalize">{audiobook.language}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Format */}
|
||||
{audiobook.formatType && (
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Format</p>
|
||||
<p className="text-gray-900 dark:text-gray-100 capitalize">{audiobook.formatType}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Publisher */}
|
||||
{audiobook.publisherName && (
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Publisher</p>
|
||||
<p className="text-gray-900 dark:text-gray-100">{audiobook.publisherName}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Link - subtle utility, visible from any context */}
|
||||
{isAvailable && downloadAvailable && requestId && user?.permissions?.download !== false && (
|
||||
<div>
|
||||
@@ -739,6 +766,13 @@ export function AudiobookDetailsModal({
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Admin Actions Row (Approve / Search / Deny) — injected by admin pages */}
|
||||
{adminActions && (
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-amber-200 dark:border-amber-700/50">
|
||||
{adminActions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* Component: Notification Event Constants
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*
|
||||
@@ -10,16 +10,28 @@ export type NotificationSeverity = 'info' | 'success' | 'error' | 'warning';
|
||||
export type NotificationPriority = 'normal' | 'high';
|
||||
|
||||
/**
|
||||
* Central registry of notification events.
|
||||
* Normalized interface for event metadata.
|
||||
* Each entry in NOTIFICATION_EVENTS is structurally validated against this via `satisfies`.
|
||||
*
|
||||
* Each entry defines:
|
||||
* - `label`: Human-readable name shown in the UI
|
||||
* - `title`: Default title used in notification messages
|
||||
* - `titleByRequestType`: Optional map of request-type-specific titles (e.g. audiobook → "Audiobook Available")
|
||||
* - `emoji`: Emoji prefix for notification titles
|
||||
* - `severity`: Drives provider formatting (colors, Apprise types, ntfy tags)
|
||||
* - `priority`: Drives notification urgency (Pushover/ntfy priority levels)
|
||||
* - `messageLabel`: Optional label for the `message` payload field (defaults to "Error" if omitted)
|
||||
*/
|
||||
export interface NotificationEventConfig {
|
||||
label: string;
|
||||
title: string;
|
||||
titleByRequestType?: Record<string, string>;
|
||||
emoji: string;
|
||||
severity: NotificationSeverity;
|
||||
priority: NotificationPriority;
|
||||
messageLabel?: string;
|
||||
}
|
||||
|
||||
/** Central registry of notification events. */
|
||||
export const NOTIFICATION_EVENTS = {
|
||||
request_pending_approval: {
|
||||
label: 'Request Pending Approval',
|
||||
@@ -31,17 +43,29 @@ export const NOTIFICATION_EVENTS = {
|
||||
request_approved: {
|
||||
label: 'Request Approved',
|
||||
title: 'Request Approved',
|
||||
emoji: '\u2705',
|
||||
emoji: '✅',
|
||||
severity: 'success' as const,
|
||||
priority: 'normal' as const,
|
||||
},
|
||||
request_grabbed: {
|
||||
label: 'Request Grabbed',
|
||||
title: 'Download Grabbed',
|
||||
titleByRequestType: {
|
||||
audiobook: 'Audiobook Grabbed',
|
||||
ebook: 'Ebook Grabbed',
|
||||
},
|
||||
emoji: '\u{1F4E5}',
|
||||
severity: 'info' as const,
|
||||
priority: 'normal' as const,
|
||||
messageLabel: 'Details',
|
||||
},
|
||||
request_available: {
|
||||
label: 'Request Available',
|
||||
title: 'Request Available',
|
||||
titleByRequestType: {
|
||||
audiobook: 'Audiobook Available',
|
||||
ebook: 'Ebook Available',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
emoji: '\u{1F389}',
|
||||
severity: 'success' as const,
|
||||
priority: 'high' as const,
|
||||
@@ -49,7 +73,7 @@ export const NOTIFICATION_EVENTS = {
|
||||
request_error: {
|
||||
label: 'Request Error',
|
||||
title: 'Request Error',
|
||||
emoji: '\u274C',
|
||||
emoji: '❌',
|
||||
severity: 'error' as const,
|
||||
priority: 'high' as const,
|
||||
},
|
||||
@@ -59,8 +83,9 @@ export const NOTIFICATION_EVENTS = {
|
||||
emoji: '\u{1F6A9}',
|
||||
severity: 'warning' as const,
|
||||
priority: 'high' as const,
|
||||
messageLabel: 'Reason',
|
||||
},
|
||||
} as const;
|
||||
} satisfies Record<string, NotificationEventConfig>;
|
||||
|
||||
/** Union type of all valid notification event keys */
|
||||
export type NotificationEvent = keyof typeof NOTIFICATION_EVENTS;
|
||||
@@ -72,7 +97,7 @@ export const NOTIFICATION_EVENT_KEYS = Object.keys(NOTIFICATION_EVENTS) as [Noti
|
||||
export type NotificationEventMeta = (typeof NOTIFICATION_EVENTS)[NotificationEvent];
|
||||
|
||||
/** Helper: get event metadata by key */
|
||||
export function getEventMeta(event: NotificationEvent) {
|
||||
export function getEventMeta(event: NotificationEvent): NotificationEventConfig {
|
||||
return NOTIFICATION_EVENTS[event];
|
||||
}
|
||||
|
||||
@@ -82,9 +107,9 @@ export function getEventMeta(event: NotificationEvent) {
|
||||
* returns the type-specific title. Otherwise falls back to the default `title`.
|
||||
*/
|
||||
export function getEventTitle(event: NotificationEvent, requestType?: string): string {
|
||||
const meta = NOTIFICATION_EVENTS[event];
|
||||
if (requestType && 'titleByRequestType' in meta) {
|
||||
const typeTitle = (meta as typeof meta & { titleByRequestType: Record<string, string> }).titleByRequestType[requestType];
|
||||
const meta = getEventMeta(event);
|
||||
if (requestType && meta.titleByRequestType) {
|
||||
const typeTitle = meta.titleByRequestType[requestType];
|
||||
if (typeTitle) return typeTitle;
|
||||
}
|
||||
return meta.title;
|
||||
|
||||
@@ -34,6 +34,9 @@ export interface Audiobook {
|
||||
requestedByUsername?: string | null; // Username who requested (only if not current user)
|
||||
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
|
||||
isIgnored?: boolean; // True if this user has ignored this audiobook from auto-requests
|
||||
language?: string;
|
||||
formatType?: string;
|
||||
publisherName?: string;
|
||||
}
|
||||
|
||||
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { parseRuntime } from '../utils/parse-runtime';
|
||||
import { randomDelay } from '../utils/scrape-resilience';
|
||||
import { extractAllNarrators } from '../utils/extract-narrator';
|
||||
|
||||
const logger = RMABLogger.create('Audible.Series');
|
||||
|
||||
@@ -442,10 +443,8 @@ function parseSeriesBooks(
|
||||
const authorHref = authorLink.attr('href') || '';
|
||||
const authorAsinMatch = authorHref.match(/\/author\/[^/]+\/([A-Z0-9]{10})/);
|
||||
|
||||
// Narrator
|
||||
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
|
||||
$el.find('.narratorLabel').text().trim() ||
|
||||
'';
|
||||
// Narrator — capture all narrator links (multi-narrator productions are common)
|
||||
const narratorText = extractAllNarrators($, $el);
|
||||
|
||||
// Cover art
|
||||
const coverArtUrl = $el.find('img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') || '';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -138,16 +138,37 @@ async function persistSectionBooks(
|
||||
logger: ReturnType<typeof RMABLogger.forJob>,
|
||||
labelForErrors: string,
|
||||
): Promise<number> {
|
||||
// Defensive dedup: the (asin, categoryId) unique constraint means a duplicate ASIN
|
||||
// in `books` crashes the second .create() with P2002. The HTML parser already dedupes
|
||||
// per page and across pages against the cumulative accumulator, but a warn-on-fire
|
||||
// signal here lets us detect upstream surprises (e.g. Audible serving the same item
|
||||
// in both a carousel and the main grid) without the noisy duplicate-key Postgres
|
||||
// errors. Keep the first occurrence so Audible's editorial ordering is preserved.
|
||||
const seenAsins = new Set<string>();
|
||||
const dedupedBooks = books.filter((b) => {
|
||||
if (!b?.asin || seenAsins.has(b.asin)) return false;
|
||||
seenAsins.add(b.asin);
|
||||
return true;
|
||||
});
|
||||
const droppedCount = books.length - dedupedBooks.length;
|
||||
if (droppedCount > 0) {
|
||||
logger.warn(
|
||||
`Dropped ${droppedCount} duplicate ASIN(s) from ${categoryId} input list before persist`,
|
||||
);
|
||||
}
|
||||
|
||||
// Wipe previous entries for this section
|
||||
logger.info(`Clearing previous data for ${categoryId}...`);
|
||||
await prisma.audibleCacheCategory.deleteMany({
|
||||
where: { categoryId },
|
||||
});
|
||||
logger.info(`Cleared previous entries for ${categoryId}, saving ${books.length} books...`);
|
||||
logger.info(
|
||||
`Cleared previous entries for ${categoryId}, saving ${dedupedBooks.length} books...`,
|
||||
);
|
||||
|
||||
let saved = 0;
|
||||
for (let i = 0; i < books.length; i++) {
|
||||
const book = books[i];
|
||||
for (let i = 0; i < dedupedBooks.length; i++) {
|
||||
const book = dedupedBooks[i];
|
||||
try {
|
||||
// Cache thumbnail if coverArtUrl exists
|
||||
let cachedCoverPath: string | null = null;
|
||||
|
||||
@@ -31,13 +31,16 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
|
||||
try {
|
||||
// Update request status to downloading
|
||||
await prisma.request.update({
|
||||
const request = await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'downloading',
|
||||
progress: 0,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Detect protocol from result and get appropriate client
|
||||
@@ -103,8 +106,22 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
|
||||
logger.info(`Created download history record: ${downloadHistory.id}`);
|
||||
|
||||
// Trigger monitor download job with initial delay
|
||||
// Send grab notification (non-blocking — failures here don't fail the download)
|
||||
const jobQueue = getJobQueueService();
|
||||
const grabMessage = `${torrent.title} via ${torrent.indexer} (${client.clientType})`;
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_grabbed',
|
||||
requestId,
|
||||
audiobook.title,
|
||||
audiobook.author,
|
||||
request.user.plexUsername || 'Unknown User',
|
||||
grabMessage,
|
||||
request.type
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue grab notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
// Trigger monitor download job with initial delay
|
||||
await jobQueue.addMonitorJob(
|
||||
requestId,
|
||||
downloadHistory.id,
|
||||
|
||||
@@ -127,6 +127,7 @@ export class AppriseProvider implements INotificationProvider {
|
||||
|
||||
private formatMessage(payload: NotificationPayload): { title: string; body: string } {
|
||||
const { event, title, author, userName, message, requestType } = payload;
|
||||
const meta = getEventMeta(event);
|
||||
|
||||
const isIssue = event === 'issue_reported';
|
||||
const messageLines = [
|
||||
@@ -136,7 +137,9 @@ export class AppriseProvider implements INotificationProvider {
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push(isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
|
||||
const messageLabel = meta.messageLabel ?? 'Error';
|
||||
const msgEmoji = meta.severity === 'error' ? '\u26A0\uFE0F' : '\u{1F4DD}';
|
||||
messageLines.push(`${msgEmoji} ${messageLabel}: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -71,7 +71,7 @@ export class DiscordProvider implements INotificationProvider {
|
||||
];
|
||||
|
||||
if (message) {
|
||||
fields.push({ name: isIssue ? 'Reason' : 'Error', value: message, inline: false });
|
||||
fields.push({ name: meta.messageLabel ?? 'Error', value: message, inline: false });
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -84,6 +84,7 @@ export class NtfyProvider implements INotificationProvider {
|
||||
|
||||
private formatMessage(payload: NotificationPayload): { title: string; message: string } {
|
||||
const { event, title, author, userName, message, requestType } = payload;
|
||||
const meta = getEventMeta(event);
|
||||
|
||||
const isIssue = event === 'issue_reported';
|
||||
const messageLines = [
|
||||
@@ -93,7 +94,9 @@ export class NtfyProvider implements INotificationProvider {
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push(isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
|
||||
const messageLabel = meta.messageLabel ?? 'Error';
|
||||
const msgEmoji = meta.severity === 'error' ? '\u26A0\uFE0F' : '\u{1F4DD}';
|
||||
messageLines.push(`${msgEmoji} ${messageLabel}: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -91,7 +91,9 @@ export class PushoverProvider implements INotificationProvider {
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push('', isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
|
||||
const messageLabel = meta.messageLabel ?? 'Error';
|
||||
const msgEmoji = meta.severity === 'error' ? '\u26A0\uFE0F' : '\u{1F4DD}';
|
||||
messageLines.push('', `${msgEmoji} ${messageLabel}: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import type { DedupGroup } from '@/lib/utils/deduplicate-audiobooks';
|
||||
import { metadataScore, type DedupGroup } from '@/lib/utils/deduplicate-audiobooks';
|
||||
import type { AudibleAudiobook } from '@/lib/integrations/audible.service';
|
||||
|
||||
const logger = RMABLogger.create('WorksService');
|
||||
|
||||
@@ -182,6 +183,96 @@ export async function seedAsin(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View-level collapse (consult the works table after local dedup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Collapse books that already share a Work record according to the works table.
|
||||
*
|
||||
* The local `deduplicateAndCollectGroups()` pass is title/narrator/duration-based
|
||||
* and stateless — it can fail to merge ASINs whose source metadata diverges (e.g.
|
||||
* a series-page scrape captures different "first narrators" for two ASINs of the
|
||||
* same recording, or two paginated pages each contain one ASIN and never compare
|
||||
* them). The works table is the durable source of truth for "same book" identity,
|
||||
* populated by every prior dedup pass and by request-time seeding. This pass
|
||||
* applies that knowledge to the current view.
|
||||
*
|
||||
* Behavior:
|
||||
* - Books whose ASINs map to a shared workId collapse to a single representative
|
||||
* chosen by `metadataScore()` (same ranking as local dedup).
|
||||
* - Books not present in any work, or in single-ASIN works, pass through untouched.
|
||||
* - Original ordering is preserved (the kept representative sits at the position
|
||||
* of the first occurrence of its work in the input list).
|
||||
* - DB failure is non-fatal: the input list is returned unchanged so the view
|
||||
* still renders (degrades to local-dedup-only behavior).
|
||||
*/
|
||||
export async function collapseByExistingWorks(
|
||||
books: AudibleAudiobook[],
|
||||
): Promise<AudibleAudiobook[]> {
|
||||
if (books.length <= 1) return books;
|
||||
|
||||
try {
|
||||
const asins = books.map(b => b.asin);
|
||||
const entries = await prisma.workAsin.findMany({
|
||||
where: { asin: { in: asins } },
|
||||
select: { asin: true, workId: true },
|
||||
});
|
||||
|
||||
if (entries.length === 0) return books;
|
||||
|
||||
// Map ASIN → workId for fast lookup in the loop below
|
||||
const asinToWorkId = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
asinToWorkId.set(entry.asin, entry.workId);
|
||||
}
|
||||
|
||||
// Walk the input once, preserving position. For each work seen, keep a
|
||||
// running "best" book; for books not in any work, emit immediately.
|
||||
const result: AudibleAudiobook[] = [];
|
||||
const workIdToResultIndex = new Map<string, number>();
|
||||
|
||||
for (const book of books) {
|
||||
const workId = asinToWorkId.get(book.asin);
|
||||
if (!workId) {
|
||||
result.push(book);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingIndex = workIdToResultIndex.get(workId);
|
||||
if (existingIndex === undefined) {
|
||||
workIdToResultIndex.set(workId, result.length);
|
||||
result.push(book);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A sibling from this work is already in the result. Keep whichever
|
||||
// has the richer metadata; on tie, keep the earlier entry (already there).
|
||||
const existing = result[existingIndex];
|
||||
if (metadataScore(book) > metadataScore(existing)) {
|
||||
result[existingIndex] = book;
|
||||
}
|
||||
}
|
||||
|
||||
const collapsed = books.length - result.length;
|
||||
if (collapsed > 0) {
|
||||
logger.debug('Collapsed books via works table', {
|
||||
inputCount: books.length,
|
||||
outputCount: result.length,
|
||||
collapsed,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('collapseByExistingWorks failed; returning input unchanged', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
bookCount: books.length,
|
||||
});
|
||||
return books;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sibling ASIN lookup (for library matching expansion)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface AudibleRegionConfig {
|
||||
code: AudibleRegion;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiBaseUrl: string;
|
||||
audnexusParam: string;
|
||||
language: SupportedLanguage;
|
||||
}
|
||||
@@ -20,6 +21,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'us',
|
||||
name: 'United States',
|
||||
baseUrl: 'https://www.audible.com',
|
||||
apiBaseUrl: 'https://api.audible.com',
|
||||
audnexusParam: 'us',
|
||||
language: 'en',
|
||||
},
|
||||
@@ -27,6 +29,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'ca',
|
||||
name: 'Canada',
|
||||
baseUrl: 'https://www.audible.ca',
|
||||
apiBaseUrl: 'https://api.audible.ca',
|
||||
audnexusParam: 'ca',
|
||||
language: 'en',
|
||||
},
|
||||
@@ -34,6 +37,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'uk',
|
||||
name: 'United Kingdom',
|
||||
baseUrl: 'https://www.audible.co.uk',
|
||||
apiBaseUrl: 'https://api.audible.co.uk',
|
||||
audnexusParam: 'uk',
|
||||
language: 'en',
|
||||
},
|
||||
@@ -41,6 +45,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'au',
|
||||
name: 'Australia',
|
||||
baseUrl: 'https://www.audible.com.au',
|
||||
apiBaseUrl: 'https://api.audible.com.au',
|
||||
audnexusParam: 'au',
|
||||
language: 'en',
|
||||
},
|
||||
@@ -48,6 +53,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'in',
|
||||
name: 'India',
|
||||
baseUrl: 'https://www.audible.in',
|
||||
apiBaseUrl: 'https://api.audible.in',
|
||||
audnexusParam: 'in',
|
||||
language: 'en',
|
||||
},
|
||||
@@ -55,6 +61,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'de',
|
||||
name: 'Germany',
|
||||
baseUrl: 'https://www.audible.de',
|
||||
apiBaseUrl: 'https://api.audible.de',
|
||||
audnexusParam: 'de',
|
||||
language: 'de',
|
||||
},
|
||||
@@ -62,6 +69,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'es',
|
||||
name: 'Spain',
|
||||
baseUrl: 'https://www.audible.es',
|
||||
apiBaseUrl: 'https://api.audible.es',
|
||||
audnexusParam: 'es',
|
||||
language: 'es',
|
||||
},
|
||||
@@ -69,9 +77,10 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'fr',
|
||||
name: 'France',
|
||||
baseUrl: 'https://www.audible.fr',
|
||||
apiBaseUrl: 'https://api.audible.fr',
|
||||
audnexusParam: 'fr',
|
||||
language: 'fr',
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_AUDIBLE_REGION: AudibleRegion = 'us';
|
||||
|
||||
@@ -109,7 +109,12 @@ export function areDurationsCompatible(a?: number, b?: number): boolean {
|
||||
// Metadata scoring (for picking best representative)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function metadataScore(book: AudibleAudiobook): number {
|
||||
/**
|
||||
* Score a book by how much metadata it carries. Used as the tie-breaker when
|
||||
* collapsing duplicates — the entry with the richest metadata wins. Exported
|
||||
* so the works-table collapse pass can apply the same ranking.
|
||||
*/
|
||||
export function metadataScore(book: AudibleAudiobook): number {
|
||||
let score = 0;
|
||||
if (book.coverArtUrl) score++;
|
||||
if (book.rating != null) score++;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Component: Narrator Extraction Utility
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Shared helper for Audible HTML scrapers. Audible product listings render
|
||||
* each narrator as a separate `<a href="?searchNarrator=...">` link; using
|
||||
* `.first()` on that selector silently drops co-narrators and breaks dedup
|
||||
* for multi-narrator productions (e.g. full-cast audiobooks). This helper
|
||||
* captures every narrator link and joins them, falling back to the
|
||||
* `.narratorLabel` span when no anchor links are present.
|
||||
*/
|
||||
|
||||
import type * as cheerio from 'cheerio';
|
||||
import type { AnyNode } from 'domhandler';
|
||||
|
||||
/**
|
||||
* Extract a comma-joined narrator string from an Audible product list item.
|
||||
*
|
||||
* Order is not semantically significant — downstream `normalizeNarrator()`
|
||||
* sorts before comparison — but document-order preserves a stable, legible
|
||||
* value for caching and logging.
|
||||
*/
|
||||
export function extractAllNarrators(
|
||||
$: cheerio.CheerioAPI,
|
||||
$el: cheerio.Cheerio<AnyNode>,
|
||||
): string {
|
||||
const links = $el.find('a[href*="searchNarrator="]');
|
||||
if (links.length > 0) {
|
||||
const names: string[] = [];
|
||||
links.each((_, link) => {
|
||||
const name = $(link).text().trim();
|
||||
if (name) names.push(name);
|
||||
});
|
||||
if (names.length > 0) return names.join(', ');
|
||||
}
|
||||
return $el.find('.narratorLabel').text().trim();
|
||||
}
|
||||
@@ -38,12 +38,18 @@ export function getBrowserHeaders(userAgent: string): Record<string, string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Jittered exponential backoff: 2^attempt * baseMs * random(0.5, 1.5)
|
||||
* Jittered exponential backoff: 2^attempt * baseMs * random(0.5, 1.5),
|
||||
* optionally capped so high attempt counts don't produce absurd waits.
|
||||
* Avoids predictable retry timing that is trivially fingerprinted.
|
||||
*/
|
||||
export function jitteredBackoff(attempt: number, baseMs: number = 1000): number {
|
||||
export function jitteredBackoff(
|
||||
attempt: number,
|
||||
baseMs: number = 1000,
|
||||
maxBackoffMs: number = Number.POSITIVE_INFINITY,
|
||||
): number {
|
||||
const jitter = 0.5 + Math.random(); // 0.5 – 1.5
|
||||
return Math.round(Math.pow(2, attempt) * baseMs * jitter);
|
||||
const raw = Math.pow(2, attempt) * baseMs * jitter;
|
||||
return Math.round(Math.min(raw, maxBackoffMs));
|
||||
}
|
||||
|
||||
/** Random integer in [minMs, maxMs] */
|
||||
|
||||
Reference in New Issue
Block a user