mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50: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:
@@ -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