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
+91 -12
View File
@@ -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',
+1
View File
@@ -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',
};
}
+9
View File
@@ -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
*/
+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';