Add Audible region config and user password change modal

Implements configurable Audible region selection in setup and admin settings, affecting all Audible API calls and triggering data refresh on change. Adds a user-facing 'Change Password' modal in the header for local users, moving password change from admin-only to all local users via a new /api/auth/change-password endpoint. Updates documentation, API routes, and context to support these features, and removes the old admin-only password change flow.
This commit is contained in:
kikootwo
2026-01-13 01:51:22 -05:00
parent 50fb5a68af
commit e346f88f42
24 changed files with 932 additions and 317 deletions
+1 -1
View File
@@ -92,7 +92,7 @@
**"How do I configure external services?"** → [setup-wizard.md](setup-wizard.md), [settings-pages.md](settings-pages.md) **"How do I configure external services?"** → [setup-wizard.md](setup-wizard.md), [settings-pages.md](settings-pages.md)
**"What's the database schema?"** → [backend/database.md](backend/database.md) **"What's the database schema?"** → [backend/database.md](backend/database.md)
**"How does authentication work?"** → [backend/services/auth.md](backend/services/auth.md) **"How does authentication work?"** → [backend/services/auth.md](backend/services/auth.md)
**"How do I change the admin password?"** → [settings-pages.md](settings-pages.md), [backend/services/auth.md](backend/services/auth.md) **"How do I change my password?"** → [backend/services/auth.md](backend/services/auth.md) (local users only - accessed via user menu in header)
**"How do I delete requests?"** → [admin-features/request-deletion.md](admin-features/request-deletion.md) **"How do I delete requests?"** → [admin-features/request-deletion.md](admin-features/request-deletion.md)
**"How do I deploy?"** → [deployment/docker.md](deployment/docker.md) (multi-container), [deployment/unified.md](deployment/unified.md) (all-in-one) **"How do I deploy?"** → [deployment/docker.md](deployment/docker.md) (multi-container), [deployment/unified.md](deployment/unified.md) (all-in-one)
**"How do I use the unified container?"** → [deployment/unified.md](deployment/unified.md) **"How do I use the unified container?"** → [deployment/unified.md](deployment/unified.md)
+5 -3
View File
@@ -54,7 +54,7 @@ Handles authentication and authorization: Multiple auth providers (Plex OAuth, O
**POST /api/auth/plex/switch-profile** - Switch to selected profile and complete authentication **POST /api/auth/plex/switch-profile** - Switch to selected profile and complete authentication
**POST /api/auth/refresh** - Get new access token (refresh token in header) **POST /api/auth/refresh** - Get new access token (refresh token in header)
**POST /api/auth/logout** - Clear client-side token **POST /api/auth/logout** - Clear client-side token
**GET /api/auth/me** - Get current user (JWT in header) **GET /api/auth/me** - Get current user (JWT in header), includes `authProvider` field ('plex' | 'oidc' | 'local')
## JWT Structure ## JWT Structure
@@ -110,10 +110,12 @@ Handles authentication and authorization: Multiple auth providers (Plex OAuth, O
- Identified by: `isSetupAdmin=true` AND `plexId` starts with `local-` - Identified by: `isSetupAdmin=true` AND `plexId` starts with `local-`
**Password Management:** **Password Management:**
- POST `/api/admin/settings/change-password` - Change local admin password - POST `/api/auth/change-password` - Change password for any local user
- Requires: current password, new password (min 8 chars), confirmation - Requires: current password, new password (min 8 chars), confirmation
- Security: Only accessible to local admin (verified via `requireLocalAdmin` middleware) - Security: Only accessible to users with `authProvider='local'` (Plex/OIDC users cannot change passwords)
- Validates current password before allowing change - Validates current password before allowing change
- Properly decrypts stored password hash before bcrypt comparison
- Available to all local users (setup admin, locally registered users)
## Plex Home Profile Support ## Plex Home Profile Support
+2 -1
View File
@@ -23,7 +23,7 @@ src/components/
## Key Components ## Key Components
**Layout** **Layout**
- **Header** - Top nav, search input, user menu - **Header** - Top nav, search input, user menu with "Change Password" option (local users only), logout
- **Sidebar** - Admin side nav - **Sidebar** - Admin side nav
- **Footer** - Version, links - **Footer** - Version, links
@@ -46,6 +46,7 @@ src/components/
- **Select** - Custom styling, search/filter - **Select** - Custom styling, search/filter
- **Modal** ✅ - Dialog overlay with backdrop, sizes (sm/md/lg/xl/full), ESC to close, body scroll lock - **Modal** ✅ - Dialog overlay with backdrop, sizes (sm/md/lg/xl/full), ESC to close, body scroll lock
- **ConfirmModal** ✅ - Confirmation dialog with customizable title, message, buttons, loading state, and variant (primary/danger) - **ConfirmModal** ✅ - Confirmation dialog with customizable title, message, buttons, loading state, and variant (primary/danger)
- **ChangePasswordModal** ✅ - Password change form for local users. Three fields (current, new, confirm), real-time validation, success/error states, auto-closes on success. Only accessible to users with `authProvider='local'`
- **Pagination** ✅ - Traditional page navigation with prev/next buttons, smart ellipsis (shows 1...4 5 6...10) - **Pagination** ✅ - Traditional page navigation with prev/next buttons, smart ellipsis (shows 1...4 5 6...10)
- **StickyPagination** ✅ - Minimal floating pill at bottom center with prev/next arrows, quick jump input, section label. Shows/hides based on section visibility (IntersectionObserver). Rounded-full design, backdrop blur, subtle shadow, auto-scroll on page change - **StickyPagination** ✅ - Minimal floating pill at bottom center with prev/next arrows, quick jump input, section label. Shows/hides based on section visibility (IntersectionObserver). Rounded-full design, backdrop blur, subtle shadow, auto-scroll on page change
+49 -4
View File
@@ -19,6 +19,46 @@ Audiobook metadata from Audnexus API (primary) and Audible.com scraping (fallbac
- Multiple selector strategies with promotional text filtering - Multiple selector strategies with promotional text filtering
- Extract JSON-LD structured data when available - Extract JSON-LD structured data when available
## Region Configuration
**Status:** ✅ Implemented
Configurable Audible region for accurate metadata matching across different international Audible stores.
**Supported Regions:**
- United States (`us`) - `audible.com` (default)
- Canada (`ca`) - `audible.ca`
- United Kingdom (`uk`) - `audible.co.uk`
- Australia (`au`) - `audible.com.au`
- India (`in`) - `audible.in`
**Why Regions Matter:**
- Each Audible region uses different ASINs for the same audiobook
- Metadata engines (Audnexus/Audible Agent) in Plex/Audiobookshelf must match RMAB's region
- Mismatched regions cause poor search results and failed metadata matching
**Configuration:**
- Key: `audible.region` (stored in database)
- Default: `us`
- Set during: Setup wizard (Backend Selection step) or Admin Settings (Library tab)
- Help text instructs users to match their metadata engine region
**Implementation:**
- `AudibleService` loads region from config on initialization
- Dynamically builds base URL: `AUDIBLE_REGIONS[region].baseUrl`
- Audnexus API calls include region parameter: `?region={code}`
- IP redirect prevention: `?ipRedirectOverride=true` on all Audible requests
- Configuration service helper: `getAudibleRegion()` returns configured region
- **Auto-detection of region changes**: Service checks config before each request and re-initializes if region changed
- **Cache clearing**: When region changes, ConfigService cache and AudibleService initialization are cleared
- **Automatic refresh**: Changing region automatically triggers `audible_refresh` job to fetch new data
**Files:**
- Types: `src/lib/types/audible.ts`
- Service: `src/lib/integrations/audible.service.ts`
- Config: `src/lib/services/config.service.ts`
- API: `src/app/api/admin/settings/audible/route.ts`
## Discovery Strategy (Popular/New/Search) ## Discovery Strategy (Popular/New/Search)
- Parse Audible HTML with Cheerio - Parse Audible HTML with Cheerio
@@ -28,10 +68,15 @@ Audiobook metadata from Audnexus API (primary) and Audible.com scraping (fallbac
## Data Sources ## Data Sources
1. **Best Sellers:** `https://www.audible.com/adblbestsellers` URLs dynamically built based on configured region:
2. **New Releases:** `https://www.audible.com/newreleases`
3. **Search:** `https://www.audible.com/search?keywords={query}` 1. **Best Sellers:** `{baseUrl}/adblbestsellers`
4. **Detail Page:** `https://www.audible.com/pd/{asin}` 2. **New Releases:** `{baseUrl}/newreleases`
3. **Search:** `{baseUrl}/search?keywords={query}&ipRedirectOverride=true`
4. **Detail Page:** `{baseUrl}/pd/{asin}?ipRedirectOverride=true`
5. **Audnexus API:** `https://api.audnex.us/books/{asin}?region={code}`
Where `{baseUrl}` is determined by configured region (e.g., `https://www.audible.co.uk` for UK).
## Metadata Extracted ## Metadata Extracted
+34 -15
View File
@@ -6,13 +6,39 @@ Single tabbed interface for admins to view/modify system configuration post-setu
## Sections ## Sections
1. **Plex** - URL, token (masked), library ID, filesystem scan trigger toggle 1. **Plex** - URL, token (masked), library ID, Audible region, filesystem scan trigger toggle
2. **Audiobookshelf** - URL, API token (masked), library ID, filesystem scan trigger toggle 2. **Audiobookshelf** - URL, API token (masked), library ID, Audible region, filesystem scan trigger toggle
3. **Prowlarr** - URL, API key (masked), indexer selection with priority, seeding time, RSS monitoring toggle 3. **Prowlarr** - URL, API key (masked), indexer selection with priority, seeding time, RSS monitoring toggle
4. **Download Client** - Type, URL, credentials (masked) 4. **Download Client** - Type, URL, credentials (masked)
5. **Paths** - Download + media directories 5. **Paths** - Download + media directories
6. **BookDate** - AI provider, API key (encrypted), model selection, library scope, custom prompt, swipe history 6. **BookDate** - AI provider, API key (encrypted), model selection, library scope, custom prompt, swipe history
7. **Account** - Local admin password change (only visible to setup admin)
## Audible Region
**Purpose:** Configure which Audible region to use for metadata and search to ensure accurate ASIN matching with your metadata engine.
**Configuration:**
- Key: `audible.region` (string, default: 'us')
- Supported regions: US, Canada, UK, Australia, India
- UI: Dropdown selector in Library tab (both Plex and Audiobookshelf settings)
- No validation required (immediate save)
**Why It Matters:**
- Each Audible region uses different ASINs for the same audiobook
- Users must match their RMAB region to their Plex/Audiobookshelf metadata engine region
- Mismatched regions cause poor search results and failed metadata matching
**Help Text:**
"Select the Audible region that matches your metadata engine (Audnexus/Audible Agent) configuration in [Plex/Audiobookshelf]. This ensures accurate book matching and metadata."
**Implementation:**
- Affects all Audible API calls (base URL changes per region)
- Affects all Audnexus API calls (region parameter added)
- Changes apply immediately on next API call (no restart required)
- **Automatic refresh**: Changing region automatically triggers `audible_refresh` job to fetch popular/new releases for the new region
- **Cache management**: ConfigService cache and AudibleService initialization are cleared when region changes
- **Smart re-initialization**: Service automatically detects region changes and re-initializes before each request
- See: `documentation/integrations/audible.md` for technical details
## Filesystem Scan Trigger ## Filesystem Scan Trigger
@@ -72,19 +98,11 @@ Single tabbed interface for admins to view/modify system configuration post-setu
4. **Save:** Updates user preferences immediately 4. **Save:** Updates user preferences immediately
5. Accessible to all authenticated users 5. Accessible to all authenticated users
**Account (local admin only):**
1. Local admin can change password
2. Requires: current password, new password (min 8 chars), confirmation
3. No "Save Changes" button - uses dedicated "Change Password" button
4. Form clears after successful change
5. Only visible to users with `isSetupAdmin=true` AND `plexId` starts with `local-`
**Validation state resets when:** **Validation state resets when:**
- Plex: URL or token modified - Plex: URL or token modified
- Prowlarr: URL or API key modified (NOT indexer config) - Prowlarr: URL or API key modified (NOT indexer config)
- Download Client: URL, username, or password modified - Download Client: URL, username, or password modified
- Paths: Directory paths modified - Paths: Directory paths modified
- Account: No validation required (password change is immediate)
## API Endpoints ## API Endpoints
@@ -106,6 +124,11 @@ Single tabbed interface for admins to view/modify system configuration post-setu
- Updates Prowlarr URL and API key - Updates Prowlarr URL and API key
- Requires prior successful test if values changed - Requires prior successful test if values changed
**PUT /api/admin/settings/audible**
- Updates Audible region
- Body: `{ region: string }` (one of: us, ca, uk, au, in)
- No validation required
**PUT /api/admin/settings/prowlarr/indexers** **PUT /api/admin/settings/prowlarr/indexers**
- Updates indexer configuration (enabled, priority, seeding time, RSS) - Updates indexer configuration (enabled, priority, seeding time, RSS)
- No test required if URL/API key unchanged - No test required if URL/API key unchanged
@@ -133,10 +156,6 @@ Single tabbed interface for admins to view/modify system configuration post-setu
- GET /api/bookdate/preferences - Get user's preferences (libraryScope, customPrompt) - GET /api/bookdate/preferences - Get user's preferences (libraryScope, customPrompt)
- PUT /api/bookdate/preferences - Update user's preferences (all authenticated users) - PUT /api/bookdate/preferences - Update user's preferences (all authenticated users)
**Account Endpoints:**
- POST /api/admin/settings/change-password - Change local admin password (local admin only)
- GET /api/auth/is-local-admin - Check if current user is local admin (returns `{isLocalAdmin: boolean}`)
## Features ## Features
- Password visibility toggle - Password visibility toggle
+78 -143
View File
@@ -34,6 +34,7 @@ interface IndexerConfig {
interface Settings { interface Settings {
backendMode: 'plex' | 'audiobookshelf'; backendMode: 'plex' | 'audiobookshelf';
hasLocalUsers: boolean; hasLocalUsers: boolean;
audibleRegion: string;
plex: { plex: {
url: string; url: string;
token: string; token: string;
@@ -136,15 +137,7 @@ export default function AdminSettings() {
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>( const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(
null null
); );
const [activeTab, setActiveTab] = useState<'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'account' | 'bookdate'>('library'); const [activeTab, setActiveTab] = useState<'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate'>('library');
// Password change form state
const [passwordForm, setPasswordForm] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
const [changingPassword, setChangingPassword] = useState(false);
// BookDate configuration state // BookDate configuration state
const [bookdateProvider, setBookdateProvider] = useState<string>('openai'); const [bookdateProvider, setBookdateProvider] = useState<string>('openai');
@@ -834,41 +827,6 @@ export default function AdminSettings() {
} }
}; };
const changePassword = async () => {
setChangingPassword(true);
setMessage(null);
try {
const response = await fetchWithAuth('/api/admin/settings/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(passwordForm),
});
const data = await response.json();
if (data.success) {
setMessage({ type: 'success', text: 'Password changed successfully!' });
// Clear form
setPasswordForm({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
setTimeout(() => setMessage(null), 5000);
} else {
setMessage({ type: 'error', text: data.error || 'Failed to change password' });
}
} catch (error) {
setMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Failed to change password',
});
} finally {
setChangingPassword(false);
}
};
const saveSettings = async () => { const saveSettings = async () => {
if (!settings) return; if (!settings) return;
@@ -879,6 +837,18 @@ export default function AdminSettings() {
// Save settings based on active tab // Save settings based on active tab
switch (activeTab) { switch (activeTab) {
case 'library': case 'library':
// Save Audible region (common to both backends)
const audibleResponse = await fetchWithAuth('/api/admin/settings/audible', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ region: settings.audibleRegion }),
});
if (!audibleResponse.ok) {
throw new Error('Failed to save Audible region settings');
}
// Save backend-specific settings
if (settings.backendMode === 'plex') { if (settings.backendMode === 'plex') {
const plexResponse = await fetchWithAuth('/api/admin/settings/plex', { const plexResponse = await fetchWithAuth('/api/admin/settings/plex', {
method: 'PUT', method: 'PUT',
@@ -1039,7 +1009,6 @@ export default function AdminSettings() {
{ id: 'paths', label: 'Paths', icon: '📁' }, { id: 'paths', label: 'Paths', icon: '📁' },
{ id: 'ebook', label: 'E-book Sidecar', icon: '📖' }, { id: 'ebook', label: 'E-book Sidecar', icon: '📖' },
{ id: 'bookdate', label: 'BookDate', icon: '📚' }, { id: 'bookdate', label: 'BookDate', icon: '📚' },
...(isLocalAdmin ? [{ id: 'account', label: 'Account', icon: '🔒' }] : []),
]; ];
return ( return (
@@ -1250,6 +1219,37 @@ export default function AdminSettings() {
</label> </label>
</div> </div>
{/* Audible Region Selection */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-2">
<label
htmlFor="audible-region"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Audible Region
</label>
<select
id="audible-region"
value={settings.audibleRegion || 'us'}
onChange={(e) => {
setSettings({
...settings,
audibleRegion: e.target.value,
});
}}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="us">United States</option>
<option value="ca">Canada</option>
<option value="uk">United Kingdom</option>
<option value="au">Australia</option>
<option value="in">India</option>
</select>
<p className="text-sm text-gray-500 dark:text-gray-400">
Select the Audible region that matches your metadata engine (Audnexus/Audible Agent)
configuration in Plex. This ensures accurate book matching and metadata.
</p>
</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">
<Button <Button
onClick={testPlexConnection} onClick={testPlexConnection}
@@ -1385,6 +1385,37 @@ export default function AdminSettings() {
</label> </label>
</div> </div>
{/* Audible Region Selection */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-2">
<label
htmlFor="audible-region-abs"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Audible Region
</label>
<select
id="audible-region-abs"
value={settings.audibleRegion || 'us'}
onChange={(e) => {
setSettings({
...settings,
audibleRegion: e.target.value,
});
}}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="us">United States</option>
<option value="ca">Canada</option>
<option value="uk">United Kingdom</option>
<option value="au">Australia</option>
<option value="in">India</option>
</select>
<p className="text-sm text-gray-500 dark:text-gray-400">
Select the Audible region that matches your metadata engine (Audnexus/Audible Agent)
configuration in Audiobookshelf. This ensures accurate book matching and metadata.
</p>
</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">
<Button <Button
onClick={testABSConnection} onClick={testABSConnection}
@@ -3067,106 +3098,10 @@ export default function AdminSettings() {
</div> </div>
)} )}
{/* Account Tab - Only visible to local admin */}
{activeTab === 'account' && isLocalAdmin && (
<div className="space-y-6 max-w-2xl">
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Account Security
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Change your local admin account password.
</p>
</div>
{/* Info Box */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" 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>
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">Local Admin Account</p>
<p>
This password is for your local admin account created during setup.
This is separate from media server authentication and is used to log in to the admin portal.
</p>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Current Password
</label>
<Input
type="password"
value={passwordForm.currentPassword}
onChange={(e) =>
setPasswordForm({ ...passwordForm, currentPassword: e.target.value })
}
placeholder="Enter current password"
autoComplete="current-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
New Password
</label>
<Input
type="password"
value={passwordForm.newPassword}
onChange={(e) =>
setPasswordForm({ ...passwordForm, newPassword: e.target.value })
}
placeholder="Enter new password"
autoComplete="new-password"
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Must be at least 8 characters
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Confirm New Password
</label>
<Input
type="password"
value={passwordForm.confirmPassword}
onChange={(e) =>
setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })
}
placeholder="Confirm new password"
autoComplete="new-password"
/>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<Button
onClick={changePassword}
loading={changingPassword}
disabled={
!passwordForm.currentPassword ||
!passwordForm.newPassword ||
!passwordForm.confirmPassword ||
passwordForm.newPassword.length < 8 ||
passwordForm.newPassword !== passwordForm.confirmPassword
}
className="w-full bg-blue-600 hover:bg-blue-700"
>
Change Password
</Button>
</div>
</div>
)}
</div> </div>
{/* Footer - Hide for Account, BookDate, and E-book tabs (they have their own save buttons) */} {/* Footer - Hide for BookDate and E-book tabs (they have their own save buttons) */}
{activeTab !== 'account' && activeTab !== 'bookdate' && activeTab !== 'ebook' && ( {activeTab !== 'bookdate' && activeTab !== 'ebook' && (
<div className="bg-gray-50 dark:bg-gray-900 px-8 py-4 border-t border-gray-200 dark:border-gray-700"> <div className="bg-gray-50 dark:bg-gray-900 px-8 py-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-end gap-4"> <div className="flex justify-end gap-4">
<Button variant="outline" onClick={() => window.location.reload()}> <Button variant="outline" onClick={() => window.location.reload()}>
@@ -0,0 +1,79 @@
/**
* Component: Audible Settings API
* Documentation: documentation/integrations/audible.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getConfigService } from '@/lib/services/config.service';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.Audible');
const VALID_REGIONS = ['us', 'ca', 'uk', 'au', 'in'];
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { region } = await request.json();
// Validate region
if (!region || !VALID_REGIONS.includes(region)) {
logger.warn('Invalid region provided', { region });
return NextResponse.json(
{ success: false, error: 'Invalid Audible region. Must be one of: us, ca, uk, au, in' },
{ status: 400 }
);
}
// Save region to configuration
const configService = getConfigService();
await configService.setMany([
{
key: 'audible.region',
value: region,
category: 'system',
description: 'Audible region for metadata and search',
},
]);
// Clear config cache to ensure new region is loaded immediately
configService.clearCache('audible.region');
// Force AudibleService to re-initialize with new region
const audibleService = getAudibleService();
audibleService.forceReinitialize();
logger.info('Audible region updated, triggering data refresh', { region });
// Trigger audible_refresh job to fetch data for new region
try {
const jobQueueService = getJobQueueService();
await jobQueueService.addAudibleRefreshJob();
logger.info('Audible refresh job triggered');
} catch (jobError) {
logger.warn('Failed to trigger audible refresh job', {
error: jobError instanceof Error ? jobError.message : String(jobError),
});
// Don't fail the request if job trigger fails
}
return NextResponse.json({
success: true,
message: `Audible region set to ${region.toUpperCase()}. Data refresh job triggered.`,
});
} catch (error) {
logger.error('Failed to update Audible region', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ success: false, error: 'Failed to update Audible region settings' },
{ status: 500 }
);
}
});
});
}
@@ -1,138 +0,0 @@
/**
* Component: Local Admin Password Change API
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireLocalAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import bcrypt from 'bcrypt';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.ChangePassword');
/**
* POST /api/admin/settings/change-password
* Change password for local admin user
*
* Security:
* - Only available to local admin (isSetupAdmin=true AND plexId starts with 'local-')
* - Requires current password verification
* - New password must be at least 8 characters
* - New password must be different from current password
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireLocalAdmin(req, async (authenticatedReq: AuthenticatedRequest) => {
try {
const { currentPassword, newPassword, confirmPassword } = await request.json();
// Validate input
if (!currentPassword || !newPassword || !confirmPassword) {
return NextResponse.json(
{
success: false,
error: 'All fields are required',
},
{ status: 400 }
);
}
// Validate new password length
if (newPassword.length < 8) {
return NextResponse.json(
{
success: false,
error: 'New password must be at least 8 characters',
},
{ status: 400 }
);
}
// Validate passwords match
if (newPassword !== confirmPassword) {
return NextResponse.json(
{
success: false,
error: 'New passwords do not match',
},
{ status: 400 }
);
}
// Validate new password is different from current
if (currentPassword === newPassword) {
return NextResponse.json(
{
success: false,
error: 'New password must be different from current password',
},
{ status: 400 }
);
}
// Get user from database
const user = await prisma.user.findUnique({
where: { id: authenticatedReq.user!.id },
select: {
id: true,
authToken: true,
plexId: true,
isSetupAdmin: true,
},
});
if (!user || !user.authToken) {
return NextResponse.json(
{
success: false,
error: 'User not found or invalid account type',
},
{ status: 404 }
);
}
// Verify current password
const currentPasswordValid = await bcrypt.compare(currentPassword, user.authToken);
if (!currentPasswordValid) {
return NextResponse.json(
{
success: false,
error: 'Current password is incorrect',
},
{ status: 400 }
);
}
// Hash new password
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Update password in database
await prisma.user.update({
where: { id: user.id },
data: {
authToken: hashedPassword,
updatedAt: new Date(),
},
});
logger.info(`Local admin password changed successfully`, { userId: user.id });
return NextResponse.json({
success: true,
message: 'Password changed successfully',
});
} catch (error) {
logger.error('Failed to change password', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to change password',
},
{ status: 500 }
);
}
});
});
}
+1
View File
@@ -36,6 +36,7 @@ export async function GET(request: NextRequest) {
const settings = { const settings = {
backendMode: configMap.get('system.backend_mode') || 'plex', backendMode: configMap.get('system.backend_mode') || 'plex',
hasLocalUsers, hasLocalUsers,
audibleRegion: configMap.get('audible.region') || 'us',
plex: { plex: {
url: configMap.get('plex_url') || '', url: configMap.get('plex_url') || '',
token: maskValue('token', configMap.get('plex_token')), token: maskValue('token', configMap.get('plex_token')),
+186
View File
@@ -0,0 +1,186 @@
/**
* Component: User Password Change API
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import bcrypt from 'bcrypt';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Auth.ChangePassword');
/**
* POST /api/auth/change-password
* Change password for any authenticated local user
*
* Security:
* - Only available to local users (authProvider='local')
* - Requires current password verification
* - New password must be at least 8 characters
* - New password must be different from current password
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
const { currentPassword, newPassword, confirmPassword } = await request.json();
// Validate input
if (!currentPassword || !newPassword || !confirmPassword) {
return NextResponse.json(
{
success: false,
error: 'All fields are required',
},
{ status: 400 }
);
}
// Validate new password length
if (newPassword.length < 8) {
return NextResponse.json(
{
success: false,
error: 'New password must be at least 8 characters',
},
{ status: 400 }
);
}
// Validate passwords match
if (newPassword !== confirmPassword) {
return NextResponse.json(
{
success: false,
error: 'New passwords do not match',
},
{ status: 400 }
);
}
// Validate new password is different from current
if (currentPassword === newPassword) {
return NextResponse.json(
{
success: false,
error: 'New password must be different from current password',
},
{ status: 400 }
);
}
// Get user from database
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: {
id: true,
authToken: true,
authProvider: true,
plexId: true,
plexUsername: true,
},
});
if (!user) {
return NextResponse.json(
{
success: false,
error: 'User not found',
},
{ status: 404 }
);
}
// Check if user is a local user (can change password)
if (user.authProvider !== 'local') {
return NextResponse.json(
{
success: false,
error: 'Password change is only available for local users. Your account is managed by an external provider.',
},
{ status: 403 }
);
}
if (!user.authToken) {
return NextResponse.json(
{
success: false,
error: 'Invalid account configuration',
},
{ status: 400 }
);
}
// Decrypt the stored hash before comparing
const encryptionService = getEncryptionService();
let decryptedHash: string;
try {
decryptedHash = encryptionService.decrypt(user.authToken);
} catch (error) {
logger.error('Failed to decrypt password hash', {
userId: user.id,
error: error instanceof Error ? error.message : String(error)
});
return NextResponse.json(
{
success: false,
error: 'Failed to verify current password',
},
{ status: 500 }
);
}
// Verify current password
const currentPasswordValid = await bcrypt.compare(currentPassword, decryptedHash);
if (!currentPasswordValid) {
return NextResponse.json(
{
success: false,
error: 'Current password is incorrect',
},
{ status: 400 }
);
}
// Hash new password
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Encrypt the new hash before storing
const encryptedHash = encryptionService.encrypt(hashedPassword);
// Update password in database
await prisma.user.update({
where: { id: user.id },
data: {
authToken: encryptedHash,
updatedAt: new Date(),
},
});
logger.info('Password changed successfully', {
userId: user.id,
username: user.plexUsername
});
return NextResponse.json({
success: true,
message: 'Password changed successfully',
});
} catch (error) {
logger.error('Failed to change password', {
error: error instanceof Error ? error.message : String(error)
});
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to change password',
},
{ status: 500 }
);
}
});
}
+2
View File
@@ -34,6 +34,7 @@ export async function GET(request: NextRequest) {
role: true, role: true,
isSetupAdmin: true, isSetupAdmin: true,
avatarUrl: true, avatarUrl: true,
authProvider: true,
createdAt: true, createdAt: true,
lastLoginAt: true, lastLoginAt: true,
}, },
@@ -61,6 +62,7 @@ export async function GET(request: NextRequest) {
role: user.role, role: user.role,
isLocalAdmin: isLocalAdmin, isLocalAdmin: isLocalAdmin,
avatarUrl: user.avatarUrl, avatarUrl: user.avatarUrl,
authProvider: user.authProvider,
createdAt: user.createdAt, createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt, lastLoginAt: user.lastLoginAt,
}, },
+8
View File
@@ -17,6 +17,7 @@ export async function POST(request: NextRequest) {
try { try {
const { const {
backendMode, backendMode,
audibleRegion,
admin, admin,
plex, plex,
audiobookshelf, audiobookshelf,
@@ -146,6 +147,13 @@ export async function POST(request: NextRequest) {
create: { key: 'system.backend_mode', value: backendMode }, create: { key: 'system.backend_mode', value: backendMode },
}); });
// Save Audible region (default to 'us' if not provided)
await prisma.configuration.upsert({
where: { key: 'audible.region' },
update: { value: audibleRegion || 'us', category: 'system' },
create: { key: 'audible.region', value: audibleRegion || 'us', category: 'system' },
});
if (backendMode === 'plex') { if (backendMode === 'plex') {
// Plex configuration // Plex configuration
await prisma.configuration.upsert({ await prisma.configuration.upsert({
+6
View File
@@ -22,6 +22,7 @@ import { PathsStep } from './steps/PathsStep';
import { BookDateStep } from './steps/BookDateStep'; import { BookDateStep } from './steps/BookDateStep';
import { ReviewStep } from './steps/ReviewStep'; import { ReviewStep } from './steps/ReviewStep';
import { FinalizeStep } from './steps/FinalizeStep'; import { FinalizeStep } from './steps/FinalizeStep';
import { AudibleRegion } from '@/lib/types/audible';
interface SelectedIndexer { interface SelectedIndexer {
id: number; id: number;
@@ -34,6 +35,7 @@ interface SetupState {
// Backend selection // Backend selection
backendMode: 'plex' | 'audiobookshelf'; backendMode: 'plex' | 'audiobookshelf';
audibleRegion: AudibleRegion;
// Admin account (for Plex mode and ABS + Manual mode) // Admin account (for Plex mode and ABS + Manual mode)
adminUsername: string; adminUsername: string;
@@ -106,6 +108,7 @@ export default function SetupWizard() {
// Backend selection // Backend selection
backendMode: 'plex', backendMode: 'plex',
audibleRegion: 'us',
// Admin account // Admin account
adminUsername: 'admin', adminUsername: 'admin',
@@ -215,6 +218,7 @@ export default function SetupWizard() {
try { try {
const payload: any = { const payload: any = {
backendMode: state.backendMode, backendMode: state.backendMode,
audibleRegion: state.audibleRegion,
prowlarr: { prowlarr: {
url: state.prowlarrUrl, url: state.prowlarrUrl,
api_key: state.prowlarrApiKey, api_key: state.prowlarrApiKey,
@@ -363,6 +367,8 @@ export default function SetupWizard() {
<BackendSelectionStep <BackendSelectionStep
value={state.backendMode} value={state.backendMode}
onChange={(value) => updateField('backendMode', value)} onChange={(value) => updateField('backendMode', value)}
audibleRegion={state.audibleRegion}
onAudibleRegionChange={(region) => updateField('audibleRegion', region)}
onNext={() => goToStep(currentStepNumber + 1)} onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)} onBack={() => goToStep(currentStepNumber - 1)}
/> />
@@ -6,10 +6,13 @@
'use client'; 'use client';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { AudibleRegion } from '@/lib/types/audible';
interface BackendSelectionStepProps { interface BackendSelectionStepProps {
value: 'plex' | 'audiobookshelf'; value: 'plex' | 'audiobookshelf';
onChange: (value: 'plex' | 'audiobookshelf') => void; onChange: (value: 'plex' | 'audiobookshelf') => void;
audibleRegion: AudibleRegion;
onAudibleRegionChange: (region: AudibleRegion) => void;
onNext: () => void; onNext: () => void;
onBack: () => void; onBack: () => void;
} }
@@ -17,6 +20,8 @@ interface BackendSelectionStepProps {
export function BackendSelectionStep({ export function BackendSelectionStep({
value, value,
onChange, onChange,
audibleRegion,
onAudibleRegionChange,
onNext, onNext,
onBack, onBack,
}: BackendSelectionStepProps) { }: BackendSelectionStepProps) {
@@ -94,6 +99,32 @@ export function BackendSelectionStep({
</label> </label>
</div> </div>
{/* Audible Region Selection */}
<div className="space-y-2">
<label
htmlFor="audible-region"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
Audible Region
</label>
<select
id="audible-region"
value={audibleRegion}
onChange={(e) => onAudibleRegionChange(e.target.value as AudibleRegion)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="us">United States</option>
<option value="ca">Canada</option>
<option value="uk">United Kingdom</option>
<option value="au">Australia</option>
<option value="in">India</option>
</select>
<p className="text-sm text-gray-600 dark:text-gray-400">
Select the Audible region that matches your metadata engine (Audnexus/Audible Agent)
configuration in {value === 'plex' ? 'Plex' : 'Audiobookshelf'}. This ensures accurate book matching and metadata.
</p>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4"> <div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<div className="flex gap-3"> <div className="flex gap-3">
<svg <svg
+22
View File
@@ -11,6 +11,7 @@ import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { VersionBadge } from '@/components/ui/VersionBadge'; import { VersionBadge } from '@/components/ui/VersionBadge';
import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal';
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition'; import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
export function Header() { export function Header() {
@@ -18,8 +19,12 @@ export function Header() {
const [showUserMenu, setShowUserMenu] = useState(false); const [showUserMenu, setShowUserMenu] = useState(false);
const [showMobileMenu, setShowMobileMenu] = useState(false); const [showMobileMenu, setShowMobileMenu] = useState(false);
const [showBookDate, setShowBookDate] = useState(false); const [showBookDate, setShowBookDate] = useState(false);
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(showUserMenu); const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(showUserMenu);
// Check if user can change password (local users only)
const canChangePassword = user?.authProvider === 'local';
// Check if BookDate is configured // Check if BookDate is configured
useEffect(() => { useEffect(() => {
async function checkBookDate() { async function checkBookDate() {
@@ -85,6 +90,17 @@ export function Header() {
> >
Profile Profile
</Link> </Link>
{canChangePassword && (
<button
onClick={() => {
setShowUserMenu(false);
setShowChangePasswordModal(true);
}}
className="w-full text-left px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Change Password
</button>
)}
<button <button
onClick={() => { onClick={() => {
logout(); logout();
@@ -268,6 +284,12 @@ export function Header() {
{/* User menu dropdown (rendered via portal) */} {/* User menu dropdown (rendered via portal) */}
{typeof window !== 'undefined' && userMenuDropdown && createPortal(userMenuDropdown, document.body)} {typeof window !== 'undefined' && userMenuDropdown && createPortal(userMenuDropdown, document.body)}
{/* Change Password Modal */}
<ChangePasswordModal
isOpen={showChangePasswordModal}
onClose={() => setShowChangePasswordModal(false)}
/>
</header> </header>
); );
} }
+256
View File
@@ -0,0 +1,256 @@
/**
* Component: Change Password Modal
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { useState } from 'react';
import { Modal } from './Modal';
import { Input } from './Input';
import { Button } from './Button';
interface ChangePasswordModalProps {
isOpen: boolean;
onClose: () => void;
}
export function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProps) {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
// Validation errors for individual fields
const [errors, setErrors] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
const validateForm = (): boolean => {
const newErrors = {
currentPassword: '',
newPassword: '',
confirmPassword: '',
};
let isValid = true;
if (!currentPassword) {
newErrors.currentPassword = 'Current password is required';
isValid = false;
}
if (!newPassword) {
newErrors.newPassword = 'New password is required';
isValid = false;
} else if (newPassword.length < 8) {
newErrors.newPassword = 'Password must be at least 8 characters';
isValid = false;
} else if (newPassword === currentPassword) {
newErrors.newPassword = 'New password must be different from current password';
isValid = false;
}
if (!confirmPassword) {
newErrors.confirmPassword = 'Please confirm your new password';
isValid = false;
} else if (newPassword !== confirmPassword) {
newErrors.confirmPassword = 'Passwords do not match';
isValid = false;
}
setErrors(newErrors);
return isValid;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Clear previous error and success states
setError(null);
setSuccess(false);
// Validate form
if (!validateForm()) {
return;
}
setLoading(true);
try {
const accessToken = localStorage.getItem('accessToken');
if (!accessToken) {
throw new Error('Not authenticated');
}
const response = await fetch('/api/auth/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({
currentPassword,
newPassword,
confirmPassword,
}),
});
const data = await response.json();
if (response.ok && data.success) {
setSuccess(true);
// Clear form
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setErrors({ currentPassword: '', newPassword: '', confirmPassword: '' });
// Auto-close after 2 seconds
setTimeout(() => {
setSuccess(false);
onClose();
}, 2000);
} else {
setError(data.error || 'Failed to change password');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to change password');
} finally {
setLoading(false);
}
};
const handleClose = () => {
// Reset form state when closing
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setError(null);
setSuccess(false);
setErrors({ currentPassword: '', newPassword: '', confirmPassword: '' });
onClose();
};
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Change Password" size="sm">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Success message */}
{success && (
<div className="p-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
<div className="flex items-center">
<svg
className="w-5 h-5 text-green-600 dark:text-green-400 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<p className="text-sm text-green-800 dark:text-green-200">
Password changed successfully!
</p>
</div>
</div>
)}
{/* Error message */}
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-center">
<svg
className="w-5 h-5 text-red-600 dark:text-red-400 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
</div>
)}
{/* Current Password */}
<Input
type="password"
label="Current Password"
value={currentPassword}
onChange={(e) => {
setCurrentPassword(e.target.value);
setErrors({ ...errors, currentPassword: '' });
}}
placeholder="Enter your current password"
autoComplete="current-password"
error={errors.currentPassword}
disabled={loading || success}
/>
{/* New Password */}
<Input
type="password"
label="New Password"
value={newPassword}
onChange={(e) => {
setNewPassword(e.target.value);
setErrors({ ...errors, newPassword: '' });
}}
placeholder="Enter your new password"
autoComplete="new-password"
helperText="Must be at least 8 characters"
error={errors.newPassword}
disabled={loading || success}
/>
{/* Confirm New Password */}
<Input
type="password"
label="Confirm New Password"
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value);
setErrors({ ...errors, confirmPassword: '' });
}}
placeholder="Confirm your new password"
autoComplete="new-password"
error={errors.confirmPassword}
disabled={loading || success}
/>
{/* Action Buttons */}
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={loading || success}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
loading={loading}
disabled={loading || success}
>
Change Password
</Button>
</div>
</form>
</Modal>
);
}
+19
View File
@@ -15,6 +15,7 @@ interface User {
email?: string; email?: string;
role: string; role: string;
avatarUrl?: string; avatarUrl?: string;
authProvider?: string | null; // 'plex' | 'oidc' | 'local' | null
} }
interface AuthContextType { interface AuthContextType {
@@ -90,6 +91,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setAccessToken(storedToken); setAccessToken(storedToken);
setUser(JSON.parse(storedUser)); setUser(JSON.parse(storedUser));
scheduleTokenRefresh(storedToken); scheduleTokenRefresh(storedToken);
// Fetch fresh user data from server to get latest fields (e.g., authProvider)
fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${storedToken}`,
},
})
.then((res) => res.json())
.then((data) => {
if (data.user) {
// Update user with fresh data from server
setUser(data.user);
localStorage.setItem('user', JSON.stringify(data.user));
}
})
.catch((error) => {
console.error('Failed to fetch fresh user data:', error);
});
} }
setIsLoading(false); setIsLoading(false);
+91 -12
View File
@@ -6,6 +6,8 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
import { getConfigService } from '../services/config.service';
import { AudibleRegion, AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION } from '../types/audible';
// Module-level logger // Module-level logger
const logger = RMABLogger.create('Audible'); const logger = RMABLogger.create('Audible');
@@ -32,25 +34,92 @@ export interface AudibleSearchResult {
} }
export class AudibleService { export class AudibleService {
private client: AxiosInstance; private client!: AxiosInstance;
private readonly baseUrl = 'https://www.audible.com'; private baseUrl: string = 'https://www.audible.com';
private region: AudibleRegion = 'us';
private initialized: boolean = false;
constructor() { constructor() {
this.client = axios.create({ // Client will be created lazily on first use
baseURL: this.baseUrl, }
timeout: 15000,
headers: { /**
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', * Force re-initialization (used when region config changes)
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', */
'Accept-Language': 'en-US,en;q=0.9', public forceReinitialize(): void {
}, logger.info('Force re-initializing AudibleService');
}); this.initialized = false;
}
/**
* Initialize service with configured region
* Lazy initialization allows async config loading
* Automatically re-initializes if region has changed
*/
private async initialize(): Promise<void> {
// If already initialized, check if region has changed
if (this.initialized) {
const configService = getConfigService();
const currentRegion = await configService.getAudibleRegion();
// If region changed, force re-initialization
if (currentRegion !== this.region) {
logger.info(`Region changed from ${this.region} to ${currentRegion}, re-initializing`);
this.initialized = false;
} else {
return; // Region unchanged, use existing initialization
}
}
try {
const configService = getConfigService();
this.region = await configService.getAudibleRegion();
this.baseUrl = AUDIBLE_REGIONS[this.region].baseUrl;
logger.info(`Initializing Audible service with region: ${this.region} (${this.baseUrl})`);
// Create axios client with region-specific base URL
this.client = axios.create({
baseURL: this.baseUrl,
timeout: 15000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
},
params: {
ipRedirectOverride: 'true', // Prevent IP-based region redirects
},
});
this.initialized = true;
} catch (error) {
logger.error('Failed to initialize AudibleService', { error: error instanceof Error ? error.message : String(error) });
// Fallback to default region
this.region = DEFAULT_AUDIBLE_REGION;
this.baseUrl = AUDIBLE_REGIONS[this.region].baseUrl;
this.client = axios.create({
baseURL: this.baseUrl,
timeout: 15000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
},
params: {
ipRedirectOverride: 'true',
},
});
this.initialized = true;
}
} }
/** /**
* Get popular audiobooks from best sellers (with pagination support) * Get popular audiobooks from best sellers (with pagination support)
*/ */
async getPopularAudiobooks(limit: number = 20): Promise<AudibleAudiobook[]> { async getPopularAudiobooks(limit: number = 20): Promise<AudibleAudiobook[]> {
await this.initialize();
try { try {
logger.info(` Fetching popular audiobooks (limit: ${limit})...`); logger.info(` Fetching popular audiobooks (limit: ${limit})...`);
@@ -137,6 +206,8 @@ export class AudibleService {
* Get new release audiobooks (with pagination support) * Get new release audiobooks (with pagination support)
*/ */
async getNewReleases(limit: number = 20): Promise<AudibleAudiobook[]> { async getNewReleases(limit: number = 20): Promise<AudibleAudiobook[]> {
await this.initialize();
try { try {
logger.info(` Fetching new releases (limit: ${limit})...`); logger.info(` Fetching new releases (limit: ${limit})...`);
@@ -222,6 +293,8 @@ export class AudibleService {
* Search for audiobooks * Search for audiobooks
*/ */
async search(query: string, page: number = 1): Promise<AudibleSearchResult> { async search(query: string, page: number = 1): Promise<AudibleSearchResult> {
await this.initialize();
try { try {
logger.info(` Searching for "${query}"...`); logger.info(` Searching for "${query}"...`);
@@ -316,6 +389,8 @@ export class AudibleService {
* Fallback: Audible scraping * Fallback: Audible scraping
*/ */
async getAudiobookDetails(asin: string): Promise<AudibleAudiobook | null> { async getAudiobookDetails(asin: string): Promise<AudibleAudiobook | null> {
await this.initialize();
try { try {
logger.info(` Fetching details for ASIN ${asin}...`); logger.info(` Fetching details for ASIN ${asin}...`);
@@ -341,9 +416,13 @@ export class AudibleService {
*/ */
private async fetchFromAudnexus(asin: string): Promise<AudibleAudiobook | null> { private async fetchFromAudnexus(asin: string): Promise<AudibleAudiobook | null> {
try { try {
logger.debug(`Fetching ASIN from Audnexus: ${asin}`); const audnexusRegion = AUDIBLE_REGIONS[this.region].audnexusParam;
logger.debug(`Fetching ASIN from Audnexus: ${asin} (region: ${audnexusRegion})`);
const response = await axios.get(`https://api.audnex.us/books/${asin}`, { const response = await axios.get(`https://api.audnex.us/books/${asin}`, {
params: {
region: audnexusRegion, // Pass region parameter to Audnexus
},
timeout: 10000, timeout: 10000,
headers: { headers: {
'User-Agent': 'ReadMeABook/1.0', 'User-Agent': 'ReadMeABook/1.0',
+1
View File
@@ -11,6 +11,7 @@ export interface UserInfo {
avatarUrl?: string; avatarUrl?: string;
role?: string; // 'admin' | 'user' role?: string; // 'admin' | 'user'
isAdmin?: boolean; // Deprecated: use role instead isAdmin?: boolean; // Deprecated: use role instead
authProvider?: string; // 'plex' | 'oidc' | 'local'
} }
export interface AuthTokens { export interface AuthTokens {
@@ -125,6 +125,7 @@ export class LocalAuthProvider implements IAuthProvider {
plexId: user.plexId, plexId: user.plexId,
username: user.plexUsername, username: user.plexUsername,
role: user.role, role: user.role,
authProvider: 'local',
}, },
tokens, tokens,
}; };
@@ -223,6 +224,7 @@ export class LocalAuthProvider implements IAuthProvider {
plexId: user.plexId, plexId: user.plexId,
username: user.plexUsername, username: user.plexUsername,
role: user.role, role: user.role,
authProvider: 'local',
}, },
tokens, tokens,
}; };
@@ -455,6 +455,7 @@ export class OIDCAuthProvider implements IAuthProvider {
email: user.plexEmail || undefined, email: user.plexEmail || undefined,
avatarUrl: user.avatarUrl || undefined, avatarUrl: user.avatarUrl || undefined,
isAdmin: user.role === 'admin', isAdmin: user.role === 'admin',
authProvider: 'oidc',
}, },
isFirstLogin: isFirstUser && shouldTriggerJobs, isFirstLogin: isFirstUser && shouldTriggerJobs,
}; };
@@ -240,6 +240,7 @@ export class PlexAuthProvider implements IAuthProvider {
email: user.plexEmail || undefined, email: user.plexEmail || undefined,
avatarUrl: user.avatarUrl || undefined, avatarUrl: user.avatarUrl || undefined,
isAdmin: user.role === 'admin', isAdmin: user.role === 'admin',
authProvider: 'plex',
}; };
} }
+9
View File
@@ -6,6 +6,7 @@
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { getEncryptionService } from './encryption.service'; import { getEncryptionService } from './encryption.service';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { AudibleRegion, DEFAULT_AUDIBLE_REGION } from '@/lib/types/audible';
const logger = RMABLogger.create('Config'); const logger = RMABLogger.create('Config');
@@ -228,6 +229,14 @@ export class ConfigurationService {
return (await this.getBackendMode()) === 'audiobookshelf'; return (await this.getBackendMode()) === 'audiobookshelf';
} }
/**
* Get configured Audible region
*/
async getAudibleRegion(): Promise<AudibleRegion> {
const region = await this.get('audible.region');
return (region as AudibleRegion) || DEFAULT_AUDIBLE_REGION;
}
/** /**
* Clear the cache for a specific key or all keys * Clear the cache for a specific key or all keys
*/ */
+48
View File
@@ -0,0 +1,48 @@
/**
* Component: Audible Region Types
* Documentation: documentation/integrations/audible.md
*/
export type AudibleRegion = 'us' | 'ca' | 'uk' | 'au' | 'in';
export interface AudibleRegionConfig {
code: AudibleRegion;
name: string;
baseUrl: string;
audnexusParam: string;
}
export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
us: {
code: 'us',
name: 'United States',
baseUrl: 'https://www.audible.com',
audnexusParam: 'us',
},
ca: {
code: 'ca',
name: 'Canada',
baseUrl: 'https://www.audible.ca',
audnexusParam: 'ca',
},
uk: {
code: 'uk',
name: 'United Kingdom',
baseUrl: 'https://www.audible.co.uk',
audnexusParam: 'uk',
},
au: {
code: 'au',
name: 'Australia',
baseUrl: 'https://www.audible.com.au',
audnexusParam: 'au',
},
in: {
code: 'in',
name: 'India',
baseUrl: 'https://www.audible.in',
audnexusParam: 'in',
},
};
export const DEFAULT_AUDIBLE_REGION: AudibleRegion = 'us';