mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
Initial commit
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Component: Audiobookshelf Library Service
|
||||
* Documentation: documentation/features/audiobookshelf-integration.md
|
||||
*/
|
||||
|
||||
import {
|
||||
ILibraryService,
|
||||
LibraryConnectionResult,
|
||||
ServerInfo,
|
||||
Library,
|
||||
LibraryItem,
|
||||
} from './ILibraryService';
|
||||
import {
|
||||
getABSServerInfo,
|
||||
getABSLibraries,
|
||||
getABSLibraryItems,
|
||||
getABSRecentItems,
|
||||
getABSItem,
|
||||
searchABSItems,
|
||||
triggerABSScan,
|
||||
} from '../audiobookshelf/api';
|
||||
import { ABSLibraryItem } from '../audiobookshelf/types';
|
||||
|
||||
export class AudiobookshelfLibraryService implements ILibraryService {
|
||||
|
||||
async testConnection(): Promise<LibraryConnectionResult> {
|
||||
try {
|
||||
const serverInfo = await this.getServerInfo();
|
||||
return {
|
||||
success: true,
|
||||
serverInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getServerInfo(): Promise<ServerInfo> {
|
||||
const info = await getABSServerInfo();
|
||||
return {
|
||||
name: info.name || 'Audiobookshelf',
|
||||
version: info.version,
|
||||
identifier: info.name, // ABS doesn't have unique identifier like Plex
|
||||
};
|
||||
}
|
||||
|
||||
async getLibraries(): Promise<Library[]> {
|
||||
const libraries = await getABSLibraries();
|
||||
return libraries
|
||||
.filter((lib: any) => lib.mediaType === 'book') // Only audiobook libraries
|
||||
.map((lib: any) => ({
|
||||
id: lib.id,
|
||||
name: lib.name,
|
||||
type: lib.mediaType,
|
||||
itemCount: lib.stats?.totalItems,
|
||||
}));
|
||||
}
|
||||
|
||||
async getLibraryItems(libraryId: string): Promise<LibraryItem[]> {
|
||||
const items = await getABSLibraryItems(libraryId);
|
||||
return items.map(this.mapABSItemToLibraryItem);
|
||||
}
|
||||
|
||||
async getRecentlyAdded(libraryId: string, limit: number): Promise<LibraryItem[]> {
|
||||
const items = await getABSRecentItems(libraryId, limit);
|
||||
return items.map(this.mapABSItemToLibraryItem);
|
||||
}
|
||||
|
||||
async getItem(itemId: string): Promise<LibraryItem | null> {
|
||||
try {
|
||||
const item = await getABSItem(itemId);
|
||||
return this.mapABSItemToLibraryItem(item);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async searchItems(libraryId: string, query: string): Promise<LibraryItem[]> {
|
||||
const items = await searchABSItems(libraryId, query);
|
||||
return items.map((result: any) => this.mapABSItemToLibraryItem(result.libraryItem));
|
||||
}
|
||||
|
||||
async triggerLibraryScan(libraryId: string): Promise<void> {
|
||||
await triggerABSScan(libraryId);
|
||||
}
|
||||
|
||||
private mapABSItemToLibraryItem(item: ABSLibraryItem): LibraryItem {
|
||||
const metadata = item.media.metadata;
|
||||
return {
|
||||
id: item.id,
|
||||
externalId: item.id, // ABS item ID is the external ID
|
||||
title: metadata.title,
|
||||
author: metadata.authorName,
|
||||
narrator: metadata.narratorName,
|
||||
description: metadata.description,
|
||||
coverUrl: item.media.coverPath ? `/api/items/${item.id}/cover` : undefined,
|
||||
duration: item.media.duration,
|
||||
asin: metadata.asin,
|
||||
isbn: metadata.isbn,
|
||||
year: metadata.publishedYear ? parseInt(metadata.publishedYear) : undefined,
|
||||
addedAt: new Date(item.addedAt),
|
||||
updatedAt: new Date(item.updatedAt),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Library Service Interface
|
||||
* Documentation: documentation/features/audiobookshelf-integration.md
|
||||
*/
|
||||
|
||||
export interface ServerInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
platform?: string;
|
||||
identifier: string; // machineIdentifier (Plex) or serverId (ABS)
|
||||
}
|
||||
|
||||
export interface Library {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
itemCount?: number;
|
||||
}
|
||||
|
||||
export interface LibraryItem {
|
||||
id: string; // ratingKey (Plex) or item id (ABS)
|
||||
externalId: string; // plexGuid or abs_item_id
|
||||
title: string;
|
||||
author: string;
|
||||
narrator?: string;
|
||||
description?: string;
|
||||
coverUrl?: string;
|
||||
duration?: number; // seconds
|
||||
asin?: string;
|
||||
isbn?: string;
|
||||
year?: number;
|
||||
addedAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface LibraryConnectionResult {
|
||||
success: boolean;
|
||||
serverInfo?: ServerInfo;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ILibraryService {
|
||||
// Connection
|
||||
testConnection(): Promise<LibraryConnectionResult>;
|
||||
getServerInfo(): Promise<ServerInfo>;
|
||||
|
||||
// Libraries
|
||||
getLibraries(): Promise<Library[]>;
|
||||
getLibraryItems(libraryId: string): Promise<LibraryItem[]>;
|
||||
getRecentlyAdded(libraryId: string, limit: number): Promise<LibraryItem[]>;
|
||||
|
||||
// Items
|
||||
getItem(itemId: string): Promise<LibraryItem | null>;
|
||||
searchItems(libraryId: string, query: string): Promise<LibraryItem[]>;
|
||||
|
||||
// Scanning
|
||||
triggerLibraryScan(libraryId: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Plex Library Service Implementation
|
||||
* Documentation: documentation/features/audiobookshelf-integration.md
|
||||
*/
|
||||
|
||||
import {
|
||||
ILibraryService,
|
||||
ServerInfo,
|
||||
Library,
|
||||
LibraryItem,
|
||||
LibraryConnectionResult,
|
||||
} from './ILibraryService';
|
||||
import { getPlexService } from '@/lib/integrations/plex.service';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
|
||||
export class PlexLibraryService implements ILibraryService {
|
||||
private plexService = getPlexService();
|
||||
private configService = getConfigService();
|
||||
|
||||
/**
|
||||
* Test connection to Plex server
|
||||
*/
|
||||
async testConnection(): Promise<LibraryConnectionResult> {
|
||||
try {
|
||||
const config = await this.configService.getPlexConfig();
|
||||
|
||||
if (!config.serverUrl || !config.authToken) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Plex server configuration is incomplete',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.plexService.testConnection(
|
||||
config.serverUrl,
|
||||
config.authToken
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
serverInfo: result.info ? {
|
||||
name: result.info.platform || 'Plex Media Server',
|
||||
version: result.info.version,
|
||||
platform: result.info.platform,
|
||||
identifier: result.info.machineIdentifier,
|
||||
} : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Connection failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Plex server information
|
||||
*/
|
||||
async getServerInfo(): Promise<ServerInfo> {
|
||||
const config = await this.configService.getPlexConfig();
|
||||
|
||||
if (!config.serverUrl || !config.authToken) {
|
||||
throw new Error('Plex server configuration is incomplete');
|
||||
}
|
||||
|
||||
const result = await this.plexService.testConnection(
|
||||
config.serverUrl,
|
||||
config.authToken
|
||||
);
|
||||
|
||||
if (!result.success || !result.info) {
|
||||
throw new Error('Failed to get server information');
|
||||
}
|
||||
|
||||
return {
|
||||
name: result.info.platform || 'Plex Media Server',
|
||||
version: result.info.version,
|
||||
platform: result.info.platform,
|
||||
identifier: result.info.machineIdentifier,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all libraries from Plex server
|
||||
*/
|
||||
async getLibraries(): Promise<Library[]> {
|
||||
const config = await this.configService.getPlexConfig();
|
||||
|
||||
if (!config.serverUrl || !config.authToken) {
|
||||
throw new Error('Plex server configuration is incomplete');
|
||||
}
|
||||
|
||||
const libraries = await this.plexService.getLibraries(
|
||||
config.serverUrl,
|
||||
config.authToken
|
||||
);
|
||||
|
||||
return libraries.map(lib => ({
|
||||
id: lib.id,
|
||||
name: lib.title,
|
||||
type: lib.type,
|
||||
itemCount: lib.itemCount,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all items from a library
|
||||
*/
|
||||
async getLibraryItems(libraryId: string): Promise<LibraryItem[]> {
|
||||
const config = await this.configService.getPlexConfig();
|
||||
|
||||
if (!config.serverUrl || !config.authToken) {
|
||||
throw new Error('Plex server configuration is incomplete');
|
||||
}
|
||||
|
||||
const items = await this.plexService.getLibraryContent(
|
||||
config.serverUrl,
|
||||
config.authToken,
|
||||
libraryId
|
||||
);
|
||||
|
||||
return items.map(item => this.mapPlexItemToLibraryItem(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recently added items from a library
|
||||
*/
|
||||
async getRecentlyAdded(libraryId: string, limit: number): Promise<LibraryItem[]> {
|
||||
const config = await this.configService.getPlexConfig();
|
||||
|
||||
if (!config.serverUrl || !config.authToken) {
|
||||
throw new Error('Plex server configuration is incomplete');
|
||||
}
|
||||
|
||||
const items = await this.plexService.getRecentlyAdded(
|
||||
config.serverUrl,
|
||||
config.authToken,
|
||||
libraryId,
|
||||
limit
|
||||
);
|
||||
|
||||
return items.map(item => this.mapPlexItemToLibraryItem(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single item by its rating key
|
||||
*/
|
||||
async getItem(itemId: string): Promise<LibraryItem | null> {
|
||||
const config = await this.configService.getPlexConfig();
|
||||
|
||||
if (!config.serverUrl || !config.authToken) {
|
||||
throw new Error('Plex server configuration is incomplete');
|
||||
}
|
||||
|
||||
try {
|
||||
const metadata = await this.plexService.getItemMetadata(
|
||||
config.serverUrl,
|
||||
config.authToken,
|
||||
itemId
|
||||
);
|
||||
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Note: getItemMetadata only returns partial data (userRating)
|
||||
// For full item data, we would need to fetch from library content
|
||||
// This is a simplified implementation
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[PlexLibraryService] Failed to get item:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search library for items matching query
|
||||
*/
|
||||
async searchItems(libraryId: string, query: string): Promise<LibraryItem[]> {
|
||||
const config = await this.configService.getPlexConfig();
|
||||
|
||||
if (!config.serverUrl || !config.authToken) {
|
||||
throw new Error('Plex server configuration is incomplete');
|
||||
}
|
||||
|
||||
const items = await this.plexService.searchLibrary(
|
||||
config.serverUrl,
|
||||
config.authToken,
|
||||
libraryId,
|
||||
query
|
||||
);
|
||||
|
||||
return items.map(item => this.mapPlexItemToLibraryItem(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger library scan
|
||||
*/
|
||||
async triggerLibraryScan(libraryId: string): Promise<void> {
|
||||
const config = await this.configService.getPlexConfig();
|
||||
|
||||
if (!config.serverUrl || !config.authToken) {
|
||||
throw new Error('Plex server configuration is incomplete');
|
||||
}
|
||||
|
||||
await this.plexService.scanLibrary(
|
||||
config.serverUrl,
|
||||
config.authToken,
|
||||
libraryId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Plex audiobook to generic LibraryItem interface
|
||||
*/
|
||||
private mapPlexItemToLibraryItem(plexItem: any): LibraryItem {
|
||||
// Extract ASIN from plexGuid if present
|
||||
const asin = this.extractAsinFromGuid(plexItem.guid);
|
||||
|
||||
return {
|
||||
id: plexItem.ratingKey,
|
||||
externalId: plexItem.guid,
|
||||
title: plexItem.title,
|
||||
author: plexItem.author || '',
|
||||
narrator: plexItem.narrator,
|
||||
description: plexItem.summary,
|
||||
coverUrl: plexItem.thumb,
|
||||
duration: plexItem.duration ? Math.floor(plexItem.duration / 1000) : undefined, // Convert ms to seconds
|
||||
asin,
|
||||
isbn: undefined, // Plex doesn't typically store ISBN
|
||||
year: plexItem.year,
|
||||
addedAt: new Date(plexItem.addedAt * 1000), // Convert Unix timestamp to Date
|
||||
updatedAt: new Date(plexItem.updatedAt * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ASIN from Plex GUID
|
||||
* Plex GUIDs can contain ASIN in formats like:
|
||||
* - com.plexapp.agents.audible://B00ABC123?lang=en
|
||||
* - plex://album/5d07bcfe403c64002036d1af
|
||||
*/
|
||||
private extractAsinFromGuid(guid: string): string | undefined {
|
||||
if (!guid) return undefined;
|
||||
|
||||
// Match ASIN pattern in Audible agent GUIDs
|
||||
const asinMatch = guid.match(/audible:\/\/([A-Z0-9]{10})/i);
|
||||
if (asinMatch && asinMatch[1]) {
|
||||
return asinMatch[1];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Library Service Factory
|
||||
* Documentation: documentation/features/audiobookshelf-integration.md
|
||||
*/
|
||||
|
||||
import { ILibraryService } from './ILibraryService';
|
||||
import { PlexLibraryService } from './PlexLibraryService';
|
||||
import { AudiobookshelfLibraryService } from './AudiobookshelfLibraryService';
|
||||
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
|
||||
let cachedService: ILibraryService | null = null;
|
||||
let cachedMode: 'plex' | 'audiobookshelf' | null = null;
|
||||
|
||||
/**
|
||||
* Get the appropriate library service based on backend mode
|
||||
* Returns cached instance if mode hasn't changed
|
||||
*/
|
||||
export async function getLibraryService(): Promise<ILibraryService> {
|
||||
const configService = getConfigService();
|
||||
const mode = await configService.getBackendMode();
|
||||
|
||||
// Return cached instance if mode hasn't changed
|
||||
if (cachedService && cachedMode === mode) {
|
||||
return cachedService;
|
||||
}
|
||||
|
||||
// Create new instance based on mode
|
||||
if (mode === 'audiobookshelf') {
|
||||
cachedService = new AudiobookshelfLibraryService();
|
||||
} else {
|
||||
cachedService = new PlexLibraryService();
|
||||
}
|
||||
|
||||
cachedMode = mode;
|
||||
return cachedService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached service instance (useful for testing or mode changes)
|
||||
*/
|
||||
export function clearLibraryServiceCache(): void {
|
||||
cachedService = null;
|
||||
cachedMode = null;
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export * from './ILibraryService';
|
||||
export { PlexLibraryService } from './PlexLibraryService';
|
||||
export { AudiobookshelfLibraryService } from './AudiobookshelfLibraryService';
|
||||
Reference in New Issue
Block a user