Initial commit

This commit is contained in:
kikootwo
2026-01-28 11:41:24 -05:00
commit a3ba192fbd
257 changed files with 89482 additions and 0 deletions
@@ -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;
}
}
+50
View File
@@ -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';