mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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:
+78
-143
@@ -34,6 +34,7 @@ interface IndexerConfig {
|
||||
interface Settings {
|
||||
backendMode: 'plex' | 'audiobookshelf';
|
||||
hasLocalUsers: boolean;
|
||||
audibleRegion: string;
|
||||
plex: {
|
||||
url: string;
|
||||
token: string;
|
||||
@@ -136,15 +137,7 @@ export default function AdminSettings() {
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(
|
||||
null
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'account' | 'bookdate'>('library');
|
||||
|
||||
// Password change form state
|
||||
const [passwordForm, setPasswordForm] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const [changingPassword, setChangingPassword] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate'>('library');
|
||||
|
||||
// BookDate configuration state
|
||||
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 () => {
|
||||
if (!settings) return;
|
||||
|
||||
@@ -879,6 +837,18 @@ export default function AdminSettings() {
|
||||
// Save settings based on active tab
|
||||
switch (activeTab) {
|
||||
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') {
|
||||
const plexResponse = await fetchWithAuth('/api/admin/settings/plex', {
|
||||
method: 'PUT',
|
||||
@@ -1039,7 +1009,6 @@ export default function AdminSettings() {
|
||||
{ id: 'paths', label: 'Paths', icon: '📁' },
|
||||
{ id: 'ebook', label: 'E-book Sidecar', icon: '📖' },
|
||||
{ id: 'bookdate', label: 'BookDate', icon: '📚' },
|
||||
...(isLocalAdmin ? [{ id: 'account', label: 'Account', icon: '🔒' }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -1250,6 +1219,37 @@ export default function AdminSettings() {
|
||||
</label>
|
||||
</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">
|
||||
<Button
|
||||
onClick={testPlexConnection}
|
||||
@@ -1385,6 +1385,37 @@ export default function AdminSettings() {
|
||||
</label>
|
||||
</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">
|
||||
<Button
|
||||
onClick={testABSConnection}
|
||||
@@ -3067,106 +3098,10 @@ export default function AdminSettings() {
|
||||
</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>
|
||||
|
||||
{/* Footer - Hide for Account, BookDate, and E-book tabs (they have their own save buttons) */}
|
||||
{activeTab !== 'account' && activeTab !== 'bookdate' && activeTab !== 'ebook' && (
|
||||
{/* Footer - Hide for BookDate and E-book tabs (they have their own save buttons) */}
|
||||
{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="flex justify-end gap-4">
|
||||
<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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export async function GET(request: NextRequest) {
|
||||
const settings = {
|
||||
backendMode: configMap.get('system.backend_mode') || 'plex',
|
||||
hasLocalUsers,
|
||||
audibleRegion: configMap.get('audible.region') || 'us',
|
||||
plex: {
|
||||
url: configMap.get('plex_url') || '',
|
||||
token: maskValue('token', configMap.get('plex_token')),
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export async function GET(request: NextRequest) {
|
||||
role: true,
|
||||
isSetupAdmin: true,
|
||||
avatarUrl: true,
|
||||
authProvider: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
},
|
||||
@@ -61,6 +62,7 @@ export async function GET(request: NextRequest) {
|
||||
role: user.role,
|
||||
isLocalAdmin: isLocalAdmin,
|
||||
avatarUrl: user.avatarUrl,
|
||||
authProvider: user.authProvider,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const {
|
||||
backendMode,
|
||||
audibleRegion,
|
||||
admin,
|
||||
plex,
|
||||
audiobookshelf,
|
||||
@@ -146,6 +147,13 @@ export async function POST(request: NextRequest) {
|
||||
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') {
|
||||
// Plex configuration
|
||||
await prisma.configuration.upsert({
|
||||
|
||||
@@ -22,6 +22,7 @@ import { PathsStep } from './steps/PathsStep';
|
||||
import { BookDateStep } from './steps/BookDateStep';
|
||||
import { ReviewStep } from './steps/ReviewStep';
|
||||
import { FinalizeStep } from './steps/FinalizeStep';
|
||||
import { AudibleRegion } from '@/lib/types/audible';
|
||||
|
||||
interface SelectedIndexer {
|
||||
id: number;
|
||||
@@ -34,6 +35,7 @@ interface SetupState {
|
||||
|
||||
// Backend selection
|
||||
backendMode: 'plex' | 'audiobookshelf';
|
||||
audibleRegion: AudibleRegion;
|
||||
|
||||
// Admin account (for Plex mode and ABS + Manual mode)
|
||||
adminUsername: string;
|
||||
@@ -106,6 +108,7 @@ export default function SetupWizard() {
|
||||
|
||||
// Backend selection
|
||||
backendMode: 'plex',
|
||||
audibleRegion: 'us',
|
||||
|
||||
// Admin account
|
||||
adminUsername: 'admin',
|
||||
@@ -215,6 +218,7 @@ export default function SetupWizard() {
|
||||
try {
|
||||
const payload: any = {
|
||||
backendMode: state.backendMode,
|
||||
audibleRegion: state.audibleRegion,
|
||||
prowlarr: {
|
||||
url: state.prowlarrUrl,
|
||||
api_key: state.prowlarrApiKey,
|
||||
@@ -363,6 +367,8 @@ export default function SetupWizard() {
|
||||
<BackendSelectionStep
|
||||
value={state.backendMode}
|
||||
onChange={(value) => updateField('backendMode', value)}
|
||||
audibleRegion={state.audibleRegion}
|
||||
onAudibleRegionChange={(region) => updateField('audibleRegion', region)}
|
||||
onNext={() => goToStep(currentStepNumber + 1)}
|
||||
onBack={() => goToStep(currentStepNumber - 1)}
|
||||
/>
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { AudibleRegion } from '@/lib/types/audible';
|
||||
|
||||
interface BackendSelectionStepProps {
|
||||
value: 'plex' | 'audiobookshelf';
|
||||
onChange: (value: 'plex' | 'audiobookshelf') => void;
|
||||
audibleRegion: AudibleRegion;
|
||||
onAudibleRegionChange: (region: AudibleRegion) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
@@ -17,6 +20,8 @@ interface BackendSelectionStepProps {
|
||||
export function BackendSelectionStep({
|
||||
value,
|
||||
onChange,
|
||||
audibleRegion,
|
||||
onAudibleRegionChange,
|
||||
onNext,
|
||||
onBack,
|
||||
}: BackendSelectionStepProps) {
|
||||
@@ -94,6 +99,32 @@ export function BackendSelectionStep({
|
||||
</label>
|
||||
</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="flex gap-3">
|
||||
<svg
|
||||
|
||||
@@ -11,6 +11,7 @@ import Link from 'next/link';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { VersionBadge } from '@/components/ui/VersionBadge';
|
||||
import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal';
|
||||
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
|
||||
|
||||
export function Header() {
|
||||
@@ -18,8 +19,12 @@ export function Header() {
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [showMobileMenu, setShowMobileMenu] = useState(false);
|
||||
const [showBookDate, setShowBookDate] = useState(false);
|
||||
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
|
||||
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
|
||||
useEffect(() => {
|
||||
async function checkBookDate() {
|
||||
@@ -85,6 +90,17 @@ export function Header() {
|
||||
>
|
||||
Profile
|
||||
</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
|
||||
onClick={() => {
|
||||
logout();
|
||||
@@ -268,6 +284,12 @@ export function Header() {
|
||||
|
||||
{/* User menu dropdown (rendered via portal) */}
|
||||
{typeof window !== 'undefined' && userMenuDropdown && createPortal(userMenuDropdown, document.body)}
|
||||
|
||||
{/* Change Password Modal */}
|
||||
<ChangePasswordModal
|
||||
isOpen={showChangePasswordModal}
|
||||
onClose={() => setShowChangePasswordModal(false)}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ interface User {
|
||||
email?: string;
|
||||
role: string;
|
||||
avatarUrl?: string;
|
||||
authProvider?: string | null; // 'plex' | 'oidc' | 'local' | null
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
@@ -90,6 +91,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setAccessToken(storedToken);
|
||||
setUser(JSON.parse(storedUser));
|
||||
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);
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { AudibleRegion, AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION } from '../types/audible';
|
||||
|
||||
// Module-level logger
|
||||
const logger = RMABLogger.create('Audible');
|
||||
@@ -32,25 +34,92 @@ export interface AudibleSearchResult {
|
||||
}
|
||||
|
||||
export class AudibleService {
|
||||
private client: AxiosInstance;
|
||||
private readonly baseUrl = 'https://www.audible.com';
|
||||
private client!: AxiosInstance;
|
||||
private baseUrl: string = 'https://www.audible.com';
|
||||
private region: AudibleRegion = 'us';
|
||||
private initialized: boolean = false;
|
||||
|
||||
constructor() {
|
||||
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',
|
||||
},
|
||||
});
|
||||
// Client will be created lazily on first use
|
||||
}
|
||||
|
||||
/**
|
||||
* Force re-initialization (used when region config changes)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
async getPopularAudiobooks(limit: number = 20): Promise<AudibleAudiobook[]> {
|
||||
await this.initialize();
|
||||
|
||||
try {
|
||||
logger.info(` Fetching popular audiobooks (limit: ${limit})...`);
|
||||
|
||||
@@ -137,6 +206,8 @@ export class AudibleService {
|
||||
* Get new release audiobooks (with pagination support)
|
||||
*/
|
||||
async getNewReleases(limit: number = 20): Promise<AudibleAudiobook[]> {
|
||||
await this.initialize();
|
||||
|
||||
try {
|
||||
logger.info(` Fetching new releases (limit: ${limit})...`);
|
||||
|
||||
@@ -222,6 +293,8 @@ export class AudibleService {
|
||||
* Search for audiobooks
|
||||
*/
|
||||
async search(query: string, page: number = 1): Promise<AudibleSearchResult> {
|
||||
await this.initialize();
|
||||
|
||||
try {
|
||||
logger.info(` Searching for "${query}"...`);
|
||||
|
||||
@@ -316,6 +389,8 @@ export class AudibleService {
|
||||
* Fallback: Audible scraping
|
||||
*/
|
||||
async getAudiobookDetails(asin: string): Promise<AudibleAudiobook | null> {
|
||||
await this.initialize();
|
||||
|
||||
try {
|
||||
logger.info(` Fetching details for ASIN ${asin}...`);
|
||||
|
||||
@@ -341,9 +416,13 @@ export class AudibleService {
|
||||
*/
|
||||
private async fetchFromAudnexus(asin: string): Promise<AudibleAudiobook | null> {
|
||||
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}`, {
|
||||
params: {
|
||||
region: audnexusRegion, // Pass region parameter to Audnexus
|
||||
},
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'ReadMeABook/1.0',
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface UserInfo {
|
||||
avatarUrl?: string;
|
||||
role?: string; // 'admin' | 'user'
|
||||
isAdmin?: boolean; // Deprecated: use role instead
|
||||
authProvider?: string; // 'plex' | 'oidc' | 'local'
|
||||
}
|
||||
|
||||
export interface AuthTokens {
|
||||
|
||||
@@ -125,6 +125,7 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
plexId: user.plexId,
|
||||
username: user.plexUsername,
|
||||
role: user.role,
|
||||
authProvider: 'local',
|
||||
},
|
||||
tokens,
|
||||
};
|
||||
@@ -223,6 +224,7 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
plexId: user.plexId,
|
||||
username: user.plexUsername,
|
||||
role: user.role,
|
||||
authProvider: 'local',
|
||||
},
|
||||
tokens,
|
||||
};
|
||||
|
||||
@@ -455,6 +455,7 @@ export class OIDCAuthProvider implements IAuthProvider {
|
||||
email: user.plexEmail || undefined,
|
||||
avatarUrl: user.avatarUrl || undefined,
|
||||
isAdmin: user.role === 'admin',
|
||||
authProvider: 'oidc',
|
||||
},
|
||||
isFirstLogin: isFirstUser && shouldTriggerJobs,
|
||||
};
|
||||
|
||||
@@ -240,6 +240,7 @@ export class PlexAuthProvider implements IAuthProvider {
|
||||
email: user.plexEmail || undefined,
|
||||
avatarUrl: user.avatarUrl || undefined,
|
||||
isAdmin: user.role === 'admin',
|
||||
authProvider: 'plex',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getEncryptionService } from './encryption.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { AudibleRegion, DEFAULT_AUDIBLE_REGION } from '@/lib/types/audible';
|
||||
|
||||
const logger = RMABLogger.create('Config');
|
||||
|
||||
@@ -228,6 +229,14 @@ export class ConfigurationService {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user