Files
ReadMeABook/documentation/features/audiobookshelf-implementation-guide.md
T
2026-01-28 11:41:24 -05:00

58 KiB

Audiobookshelf Integration - Implementation Guide

Purpose: Step-by-step implementation instructions for AI agents to build the Audiobookshelf integration feature.

Prerequisites:

  • Read the full PRD: documentation/features/audiobookshelf-integration.md
  • Understand current architecture via documentation/TABLEOFCONTENTS.md

Critical Rules:

  1. Complete each phase fully before moving to the next
  2. Run tests after each phase to verify no regressions
  3. Existing Plex functionality must remain unchanged
  4. Follow existing code patterns and file structure conventions
  5. Update documentation as you implement
  6. Keep files under 400 lines - split if needed

Phase 1: Foundation (Abstraction Layer)

1.1 Create Library Service Interface

Goal: Abstract library operations so both Plex and Audiobookshelf can be used interchangeably.

Create file: src/lib/services/library/ILibraryService.ts

/**
 * 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>;
}

1.2 Refactor Existing Plex Code into PlexLibraryService

Goal: Move existing Plex library logic into the new interface structure.

Create file: src/lib/services/library/PlexLibraryService.ts

Instructions:

  1. Read existing Plex integration code in src/lib/services/plex/ or similar
  2. Identify all library-related functions (getLibraries, scanLibrary, getItems, etc.)
  3. Implement ILibraryService interface using existing Plex logic
  4. Do NOT delete original code yet - keep for reference
  5. Map Plex data structures to the generic LibraryItem interface:
    • ratingKeyid
    • guidexternalId
    • parentTitleauthor
    • grandparentTitle or metadata → narrator

Key mapping:

function mapPlexItemToLibraryItem(plexItem: PlexAudiobook): LibraryItem {
  return {
    id: plexItem.ratingKey,
    externalId: plexItem.guid,
    title: plexItem.title,
    author: plexItem.author,  // from parentTitle
    narrator: plexItem.narrator,
    description: plexItem.summary,
    coverUrl: plexItem.thumb,
    duration: plexItem.duration ? Math.floor(plexItem.duration / 1000) : undefined,
    asin: extractAsinFromGuid(plexItem.guid),
    year: plexItem.year,
    addedAt: new Date(plexItem.addedAt * 1000),
    updatedAt: new Date(plexItem.updatedAt * 1000),
  };
}

1.3 Create Library Service Factory

Create file: src/lib/services/library/index.ts

/**
 * Library Service Factory
 * Documentation: documentation/features/audiobookshelf-integration.md
 */

import { ILibraryService } from './ILibraryService';
import { PlexLibraryService } from './PlexLibraryService';
// import { AudiobookshelfLibraryService } from './AudiobookshelfLibraryService'; // Phase 2

export async function getLibraryService(): Promise<ILibraryService> {
  // TODO: Read from config once backend mode is implemented
  // const mode = await getConfig('system.backend_mode');
  // if (mode === 'audiobookshelf') {
  //   return new AudiobookshelfLibraryService();
  // }
  return new PlexLibraryService();
}

export * from './ILibraryService';

1.4 Create Auth Provider Interface

Create file: src/lib/services/auth/IAuthProvider.ts

/**
 * Auth Provider Interface
 * Documentation: documentation/features/audiobookshelf-integration.md
 */

export interface UserInfo {
  id: string;              // External ID (plexId, oidc subject, or local username)
  username: string;
  email?: string;
  avatarUrl?: string;
  isAdmin?: boolean;       // From claims or first-user logic
}

export interface AuthTokens {
  accessToken: string;
  refreshToken: string;
}

export interface LoginInitiation {
  redirectUrl?: string;    // For OAuth/OIDC flows
  pinId?: string;          // For Plex PIN flow
  state?: string;          // CSRF state token
}

export interface CallbackParams {
  code?: string;           // Authorization code
  state?: string;          // CSRF state
  pinId?: string;          // Plex PIN
  error?: string;
}

export interface AuthResult {
  success: boolean;
  user?: UserInfo;
  tokens?: AuthTokens;
  error?: string;
  requiresApproval?: boolean;  // For pending approval flow
  requiresProfileSelection?: boolean;  // For Plex Home
  profiles?: any[];  // Plex Home profiles
}

export interface IAuthProvider {
  type: 'plex' | 'oidc' | 'local';

  // Auth initiation
  initiateLogin(): Promise<LoginInitiation>;

  // Auth completion
  handleCallback(params: CallbackParams): Promise<AuthResult>;

  // Token refresh
  refreshToken(refreshToken: string): Promise<AuthTokens | null>;

  // Validation
  validateAccess(userInfo: UserInfo): Promise<boolean>;
}

1.5 Refactor Plex OAuth into PlexAuthProvider

Create file: src/lib/services/auth/PlexAuthProvider.ts

Instructions:

  1. Read existing auth code in src/lib/services/auth.ts or src/app/api/auth/plex/
  2. Extract Plex OAuth logic into PlexAuthProvider implementing IAuthProvider
  3. Keep existing Plex Home profile support
  4. Map Plex user data to generic UserInfo interface

1.6 Create Auth Provider Factory

Create file: src/lib/services/auth/index.ts

/**
 * Auth Provider Factory
 * Documentation: documentation/features/audiobookshelf-integration.md
 */

import { IAuthProvider } from './IAuthProvider';
import { PlexAuthProvider } from './PlexAuthProvider';
// import { OIDCAuthProvider } from './OIDCAuthProvider'; // Phase 3
// import { LocalAuthProvider } from './LocalAuthProvider'; // Phase 4

export type AuthMethod = 'plex' | 'oidc' | 'local';

export async function getAuthProvider(method?: AuthMethod): Promise<IAuthProvider> {
  // TODO: Read from config once backend mode is implemented
  // const mode = await getConfig('system.backend_mode');
  // const authMethod = method || await getConfig('auth.method');

  // if (authMethod === 'oidc') return new OIDCAuthProvider();
  // if (authMethod === 'local') return new LocalAuthProvider();

  return new PlexAuthProvider();
}

export * from './IAuthProvider';

1.7 Update Database Schema

Modify: prisma/schema.prisma

Add new fields to User model:

model User {
  // ... existing fields ...

  // New fields for multi-auth support
  authProvider        String?    @map("auth_provider")  // 'plex' | 'oidc' | 'local'
  oidcSubject         String?    @map("oidc_subject")   // OIDC subject ID
  oidcProvider        String?    @map("oidc_provider")  // e.g., 'authentik'
  registrationStatus  String?    @map("registration_status")  // 'pending_approval' | 'approved' | 'rejected'
}

Add new Configuration keys (will be set during setup):

// These are stored in Configuration table, not schema changes
// system.backend_mode = 'plex' | 'audiobookshelf'

Run migration:

npx prisma db push

1.8 Add Backend Mode Config Helper

Modify: src/lib/services/config.service.ts (or create if doesn't exist)

Add function to get backend mode:

export async function getBackendMode(): Promise<'plex' | 'audiobookshelf'> {
  const config = await prisma.configuration.findUnique({
    where: { key: 'system.backend_mode' }
  });
  return (config?.value as 'plex' | 'audiobookshelf') || 'plex';
}

export async function isAudiobookshelfMode(): Promise<boolean> {
  return (await getBackendMode()) === 'audiobookshelf';
}

1.9 Phase 1 Verification

Tests to run:

  1. Existing Plex authentication still works
  2. Existing library scanning still works
  3. All existing tests pass
  4. New interfaces compile without errors

Checklist:

  • ILibraryService interface created
  • PlexLibraryService implements interface with existing logic
  • IAuthProvider interface created
  • PlexAuthProvider implements interface with existing logic
  • Factory functions created for both services
  • Database schema updated with new fields
  • All existing functionality unchanged

Phase 2: Audiobookshelf Library Integration

2.1 Create Audiobookshelf API Client

Create file: src/lib/services/audiobookshelf/api.ts

/**
 * Audiobookshelf API Client
 * Documentation: documentation/features/audiobookshelf-integration.md
 */

import { getConfig } from '../config.service';

interface ABSRequestOptions {
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
  body?: any;
}

export async function absRequest<T>(endpoint: string, options: ABSRequestOptions = {}): Promise<T> {
  const serverUrl = await getConfig('abs.server_url');
  const apiToken = await getConfig('abs.api_token', true);  // true = decrypt

  if (!serverUrl || !apiToken) {
    throw new Error('Audiobookshelf not configured');
  }

  const url = `${serverUrl.replace(/\/$/, '')}/api${endpoint}`;

  const response = await fetch(url, {
    method: options.method || 'GET',
    headers: {
      'Authorization': `Bearer ${apiToken}`,
      'Content-Type': 'application/json',
    },
    body: options.body ? JSON.stringify(options.body) : undefined,
  });

  if (!response.ok) {
    throw new Error(`ABS API error: ${response.status} ${response.statusText}`);
  }

  return response.json();
}

// API endpoint wrappers
export async function getABSServerInfo() {
  return absRequest<{ version: string; name: string }>('/status');
}

export async function getABSLibraries() {
  const result = await absRequest<{ libraries: any[] }>('/libraries');
  return result.libraries;
}

export async function getABSLibraryItems(libraryId: string) {
  const result = await absRequest<{ results: any[] }>(`/libraries/${libraryId}/items`);
  return result.results;
}

export async function getABSRecentItems(libraryId: string, limit: number) {
  const result = await absRequest<{ results: any[] }>(
    `/libraries/${libraryId}/items?sort=addedAt&desc=1&limit=${limit}`
  );
  return result.results;
}

export async function getABSItem(itemId: string) {
  return absRequest<any>(`/items/${itemId}`);
}

export async function searchABSItems(libraryId: string, query: string) {
  const result = await absRequest<{ book: any[] }>(
    `/libraries/${libraryId}/search?q=${encodeURIComponent(query)}`
  );
  return result.book || [];
}

export async function triggerABSScan(libraryId: string) {
  await absRequest(`/libraries/${libraryId}/scan`, { method: 'POST' });
}

2.2 Create Audiobookshelf Type Definitions

Create file: src/lib/services/audiobookshelf/types.ts

/**
 * Audiobookshelf Type Definitions
 * Documentation: documentation/features/audiobookshelf-integration.md
 */

export interface ABSLibrary {
  id: string;
  name: string;
  mediaType: 'book' | 'podcast';
  folders: { id: string; fullPath: string }[];
}

export interface ABSBookMetadata {
  title: string;
  subtitle?: string;
  authorName: string;
  authorNameLF?: string;
  narratorName?: string;
  seriesName?: string;
  genres: string[];
  publishedYear?: string;
  description?: string;
  isbn?: string;
  asin?: string;
  language?: string;
  explicit: boolean;
}

export interface ABSAudioFile {
  index: number;
  ino: string;
  metadata: {
    filename: string;
    ext: string;
    path: string;
    size: number;
    mtimeMs: number;
  };
  duration: number;
}

export interface ABSLibraryItem {
  id: string;
  ino: string;
  libraryId: string;
  folderId: string;
  path: string;
  relPath: string;
  isFile: boolean;
  mtimeMs: number;
  ctimeMs: number;
  birthtimeMs: number;
  addedAt: number;
  updatedAt: number;
  isMissing: boolean;
  isInvalid: boolean;
  mediaType: 'book';
  media: {
    metadata: ABSBookMetadata;
    coverPath?: string;
    audioFiles: ABSAudioFile[];
    duration: number;
    size: number;
    numTracks: number;
    numAudioFiles: number;
  };
  numFiles: number;
  size: number;
}

2.3 Create AudiobookshelfLibraryService

Create file: src/lib/services/library/AudiobookshelfLibraryService.ts

/**
 * Audiobookshelf Library Service
 * Documentation: documentation/features/audiobookshelf-integration.md
 */

import {
  ILibraryService,
  LibraryConnectionResult,
  ServerInfo,
  Library,
  LibraryItem,
} from './ILibraryService';
import {
  absRequest,
  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),
    };
  }
}

2.4 Update Library Service Factory

Modify: src/lib/services/library/index.ts

import { ILibraryService } from './ILibraryService';
import { PlexLibraryService } from './PlexLibraryService';
import { AudiobookshelfLibraryService } from './AudiobookshelfLibraryService';
import { getBackendMode } from '../config.service';

export async function getLibraryService(): Promise<ILibraryService> {
  const mode = await getBackendMode();
  if (mode === 'audiobookshelf') {
    return new AudiobookshelfLibraryService();
  }
  return new PlexLibraryService();
}

2.5 Create ABS Setup Test Endpoint

Create file: src/app/api/setup/test-abs/route.ts

/**
 * Test Audiobookshelf Connection
 * Documentation: documentation/features/audiobookshelf-integration.md
 */

import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  try {
    const { serverUrl, apiToken } = await request.json();

    if (!serverUrl || !apiToken) {
      return NextResponse.json(
        { error: 'Server URL and API token are required' },
        { status: 400 }
      );
    }

    // Test connection
    const response = await fetch(`${serverUrl.replace(/\/$/, '')}/api/status`, {
      headers: {
        'Authorization': `Bearer ${apiToken}`,
      },
    });

    if (!response.ok) {
      return NextResponse.json(
        { error: `Connection failed: ${response.status} ${response.statusText}` },
        { status: 400 }
      );
    }

    const serverInfo = await response.json();

    // Get libraries
    const libResponse = await fetch(`${serverUrl.replace(/\/$/, '')}/api/libraries`, {
      headers: {
        'Authorization': `Bearer ${apiToken}`,
      },
    });

    const libData = await libResponse.json();
    const libraries = libData.libraries
      .filter((lib: any) => lib.mediaType === 'book')
      .map((lib: any) => ({
        id: lib.id,
        name: lib.name,
        itemCount: lib.stats?.totalItems || 0,
      }));

    return NextResponse.json({
      success: true,
      serverInfo: {
        name: serverInfo.name || 'Audiobookshelf',
        version: serverInfo.version,
      },
      libraries,
    });
  } catch (error) {
    return NextResponse.json(
      { error: error instanceof Error ? error.message : 'Connection failed' },
      { status: 500 }
    );
  }
}

2.6 Update Library Scanning Jobs

Modify existing scan jobs to use the abstraction layer:

Find files like src/lib/jobs/processors/plex-scan.ts or similar.

Replace direct Plex calls with:

import { getLibraryService } from '@/lib/services/library';

async function scanLibrary() {
  const libraryService = await getLibraryService();
  const items = await libraryService.getLibraryItems(libraryId);
  // ... rest of scanning logic using generic LibraryItem interface
}

2.7 Update Audiobook Matcher for ABS

Modify: src/lib/utils/audiobook-matcher.ts (or create if doesn't exist)

Enhance matching to use ASIN/ISBN from ABS:

export function matchAudiobook(
  request: { title: string; author: string; asin?: string; isbn?: string },
  libraryItems: LibraryItem[]
): LibraryItem | null {
  // 1. Exact ASIN match (highest confidence)
  if (request.asin) {
    const asinMatch = libraryItems.find(item =>
      item.asin?.toLowerCase() === request.asin?.toLowerCase()
    );
    if (asinMatch) return asinMatch;
  }

  // 2. Exact ISBN match
  if (request.isbn) {
    const isbnMatch = libraryItems.find(item =>
      item.isbn?.replace(/-/g, '') === request.isbn?.replace(/-/g, '')
    );
    if (isbnMatch) return isbnMatch;
  }

  // 3. Fuzzy title/author match (existing logic)
  return fuzzyMatch(request, libraryItems);
}

2.8 Phase 2 Verification

Tests to run:

  1. ABS connection test endpoint works
  2. ABS library scanning retrieves items
  3. ABS recently added works
  4. Plex mode still works unchanged
  5. Matching works with ASIN/ISBN

Checklist:

  • ABS API client created
  • ABS types defined
  • AudiobookshelfLibraryService implements interface
  • Test endpoint for ABS connection
  • Scan jobs use abstraction layer
  • Matcher enhanced for ASIN/ISBN

Phase 3: OIDC Authentication

3.1 Install OIDC Dependencies

npm install openid-client

3.2 Create OIDC Provider Service

Create file: src/lib/services/auth/OIDCAuthProvider.ts

/**
 * OIDC Auth Provider
 * Documentation: documentation/features/audiobookshelf-integration.md
 */

import { Issuer, Client, generators } from 'openid-client';
import { getConfig, setConfig } from '../config.service';
import {
  IAuthProvider,
  LoginInitiation,
  CallbackParams,
  AuthResult,
  UserInfo,
  AuthTokens,
} from './IAuthProvider';

export class OIDCAuthProvider implements IAuthProvider {
  type: 'oidc' = 'oidc';
  private client: Client | null = null;

  private async getClient(): Promise<Client> {
    if (this.client) return this.client;

    const issuerUrl = await getConfig('oidc.issuer_url');
    const clientId = await getConfig('oidc.client_id');
    const clientSecret = await getConfig('oidc.client_secret', true);
    const redirectUri = await this.getRedirectUri();

    const issuer = await Issuer.discover(issuerUrl);

    this.client = new issuer.Client({
      client_id: clientId,
      client_secret: clientSecret,
      redirect_uris: [redirectUri],
      response_types: ['code'],
    });

    return this.client;
  }

  private async getRedirectUri(): Promise<string> {
    const baseUrl = process.env.NEXTAUTH_URL || process.env.BASE_URL || 'http://localhost:3000';
    return `${baseUrl}/api/auth/oidc/callback`;
  }

  async initiateLogin(): Promise<LoginInitiation> {
    const client = await this.getClient();
    const state = generators.state();
    const nonce = generators.nonce();
    const codeVerifier = generators.codeVerifier();
    const codeChallenge = generators.codeChallenge(codeVerifier);

    // Store state/nonce/verifier in session or encrypted cookie
    // This is a simplified example - use proper session storage
    await setConfig('oidc.pending_state', state);
    await setConfig('oidc.pending_nonce', nonce);
    await setConfig('oidc.pending_verifier', codeVerifier);

    const redirectUrl = client.authorizationUrl({
      scope: 'openid profile email groups',
      state,
      nonce,
      code_challenge: codeChallenge,
      code_challenge_method: 'S256',
    });

    return { redirectUrl, state };
  }

  async handleCallback(params: CallbackParams): Promise<AuthResult> {
    try {
      const client = await this.getClient();
      const redirectUri = await this.getRedirectUri();

      // Retrieve stored values
      const expectedState = await getConfig('oidc.pending_state');
      const nonce = await getConfig('oidc.pending_nonce');
      const codeVerifier = await getConfig('oidc.pending_verifier');

      if (params.state !== expectedState) {
        return { success: false, error: 'Invalid state parameter' };
      }

      const tokenSet = await client.callback(redirectUri, { code: params.code, state: params.state }, {
        code_verifier: codeVerifier,
        nonce,
      });

      const userinfo = await client.userinfo(tokenSet.access_token!);

      // Check access control
      const hasAccess = await this.checkAccessControl(userinfo);
      if (!hasAccess) {
        return {
          success: false,
          error: 'You do not have access to this application'
        };
      }

      // Map to UserInfo
      const user: UserInfo = {
        id: userinfo.sub,
        username: userinfo.preferred_username || userinfo.email || userinfo.sub,
        email: userinfo.email as string | undefined,
        avatarUrl: userinfo.picture as string | undefined,
        isAdmin: await this.checkAdminClaim(userinfo),
      };

      // Check if admin approval required
      const accessMethod = await getConfig('oidc.access_control_method');
      if (accessMethod === 'admin_approval') {
        const existingUser = await this.findExistingUser(user.id);
        if (!existingUser) {
          // Create pending user
          await this.createPendingUser(user);
          return { success: false, requiresApproval: true };
        }
        if (existingUser.registrationStatus === 'pending_approval') {
          return { success: false, requiresApproval: true };
        }
      }

      // Generate session tokens
      const tokens = await this.generateSessionTokens(user);

      return { success: true, user, tokens };
    } catch (error) {
      return {
        success: false,
        error: error instanceof Error ? error.message : 'Authentication failed'
      };
    }
  }

  private async checkAccessControl(userinfo: any): Promise<boolean> {
    const method = await getConfig('oidc.access_control_method');

    switch (method) {
      case 'open':
        return true;

      case 'group_claim': {
        const claimName = await getConfig('oidc.access_group_claim') || 'groups';
        const requiredGroup = await getConfig('oidc.access_group_value');
        const userGroups = userinfo[claimName] || [];
        return Array.isArray(userGroups) && userGroups.includes(requiredGroup);
      }

      case 'allowed_list': {
        const allowedEmails = JSON.parse(await getConfig('oidc.allowed_emails') || '[]');
        const allowedUsernames = JSON.parse(await getConfig('oidc.allowed_usernames') || '[]');
        return (
          allowedEmails.includes(userinfo.email) ||
          allowedUsernames.includes(userinfo.preferred_username)
        );
      }

      case 'admin_approval':
        return true;  // Handled separately

      default:
        return false;
    }
  }

  private async checkAdminClaim(userinfo: any): Promise<boolean> {
    const enabled = await getConfig('oidc.admin_claim_enabled');
    if (enabled !== 'true') {
      // First user becomes admin logic handled elsewhere
      return false;
    }

    const claimName = await getConfig('oidc.admin_claim_name') || 'groups';
    const claimValue = await getConfig('oidc.admin_claim_value');
    const userClaims = userinfo[claimName] || [];

    if (Array.isArray(userClaims)) {
      return userClaims.includes(claimValue);
    }
    return userClaims === claimValue;
  }

  async refreshToken(refreshToken: string): Promise<AuthTokens | null> {
    // Implement JWT refresh logic (reuse existing JWT refresh code)
    return null;
  }

  async validateAccess(userInfo: UserInfo): Promise<boolean> {
    return true;  // Already validated in handleCallback
  }

  private async findExistingUser(oidcSubject: string) {
    // Query database for user with this OIDC subject
    return null;  // Implement with Prisma
  }

  private async createPendingUser(user: UserInfo) {
    // Create user with registrationStatus: 'pending_approval'
    // Implement with Prisma
  }

  private async generateSessionTokens(user: UserInfo): Promise<AuthTokens> {
    // Reuse existing JWT generation logic
    return { accessToken: '', refreshToken: '' };
  }
}

3.3 Create OIDC Login Endpoint

Create file: src/app/api/auth/oidc/login/route.ts

/**
 * OIDC Login Initiation
 * Documentation: documentation/features/audiobookshelf-integration.md
 */

import { NextResponse } from 'next/server';
import { OIDCAuthProvider } from '@/lib/services/auth/OIDCAuthProvider';

export async function GET() {
  try {
    const provider = new OIDCAuthProvider();
    const { redirectUrl } = await provider.initiateLogin();

    return NextResponse.redirect(redirectUrl!);
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to initiate login' },
      { status: 500 }
    );
  }
}

3.4 Create OIDC Callback Endpoint

Create file: src/app/api/auth/oidc/callback/route.ts

/**
 * OIDC Callback Handler
 * Documentation: documentation/features/audiobookshelf-integration.md
 */

import { NextRequest, NextResponse } from 'next/server';
import { OIDCAuthProvider } from '@/lib/services/auth/OIDCAuthProvider';
import { createOrUpdateUser, generateJWT } from '@/lib/services/auth';

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const code = searchParams.get('code');
  const state = searchParams.get('state');
  const error = searchParams.get('error');

  if (error) {
    return NextResponse.redirect(`/login?error=${encodeURIComponent(error)}`);
  }

  try {
    const provider = new OIDCAuthProvider();
    const result = await provider.handleCallback({ code: code!, state: state! });

    if (!result.success) {
      if (result.requiresApproval) {
        return NextResponse.redirect('/login?pending=approval');
      }
      return NextResponse.redirect(`/login?error=${encodeURIComponent(result.error || 'Authentication failed')}`);
    }

    // Create or update user in database
    const dbUser = await createOrUpdateUser({
      authProvider: 'oidc',
      oidcSubject: result.user!.id,
      username: result.user!.username,
      email: result.user!.email,
      avatarUrl: result.user!.avatarUrl,
      isAdmin: result.user!.isAdmin,
    });

    // Generate JWT tokens
    const tokens = await generateJWT(dbUser);

    // Set cookies and redirect
    const response = NextResponse.redirect('/');
    response.cookies.set('accessToken', tokens.accessToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 60 * 60,  // 1 hour
    });
    response.cookies.set('refreshToken', tokens.refreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 60 * 60 * 24 * 7,  // 7 days
    });

    return response;
  } catch (error) {
    console.error('OIDC callback error:', error);
    return NextResponse.redirect('/login?error=auth_failed');
  }
}

3.5 Create OIDC Test Endpoint

Create file: src/app/api/setup/test-oidc/route.ts

/**
 * Test OIDC Configuration
 * Documentation: documentation/features/audiobookshelf-integration.md
 */

import { NextRequest, NextResponse } from 'next/server';
import { Issuer } from 'openid-client';

export async function POST(request: NextRequest) {
  try {
    const { issuerUrl, clientId, clientSecret } = await request.json();

    if (!issuerUrl || !clientId || !clientSecret) {
      return NextResponse.json(
        { error: 'Issuer URL, Client ID, and Client Secret are required' },
        { status: 400 }
      );
    }

    // Discover OIDC endpoints
    const issuer = await Issuer.discover(issuerUrl);

    return NextResponse.json({
      success: true,
      issuer: {
        issuer: issuer.issuer,
        authorizationEndpoint: issuer.metadata.authorization_endpoint,
        tokenEndpoint: issuer.metadata.token_endpoint,
        userinfoEndpoint: issuer.metadata.userinfo_endpoint,
      },
    });
  } catch (error) {
    return NextResponse.json(
      { error: error instanceof Error ? error.message : 'OIDC discovery failed' },
      { status: 500 }
    );
  }
}

3.6 Phase 3 Verification

Tests to run:

  1. OIDC discovery works with test provider
  2. OIDC login redirects correctly
  3. OIDC callback creates user
  4. Group claim access control works
  5. Admin claim mapping works
  6. Plex auth still works unchanged

Checklist:

  • openid-client installed
  • OIDCAuthProvider implements interface
  • Login endpoint initiates flow
  • Callback endpoint handles response
  • Access control (group claim) works
  • Test endpoint validates OIDC config

Phase 4: Manual Registration

4.1 Create Local Auth Provider

Create file: src/lib/services/auth/LocalAuthProvider.ts

/**
 * Local Auth Provider (Username/Password)
 * Documentation: documentation/features/audiobookshelf-integration.md
 */

import bcrypt from 'bcrypt';
import { prisma } from '@/lib/prisma';
import {
  IAuthProvider,
  LoginInitiation,
  CallbackParams,
  AuthResult,
  UserInfo,
  AuthTokens,
} from './IAuthProvider';
import { generateJWT } from './jwt';
import { getConfig } from '../config.service';

interface LocalLoginParams extends CallbackParams {
  username: string;
  password: string;
}

interface RegisterParams {
  username: string;
  password: string;
}

export class LocalAuthProvider implements IAuthProvider {
  type: 'local' = 'local';

  async initiateLogin(): Promise<LoginInitiation> {
    // Local auth doesn't need initiation - return empty
    return {};
  }

  async handleCallback(params: CallbackParams): Promise<AuthResult> {
    // This handles login with username/password
    const { username, password } = params as LocalLoginParams;

    if (!username || !password) {
      return { success: false, error: 'Username and password required' };
    }

    // Find user
    const user = await prisma.user.findFirst({
      where: {
        plexUsername: username,
        authProvider: 'local',
      },
    });

    if (!user) {
      return { success: false, error: 'Invalid username or password' };
    }

    // Check registration status
    if (user.registrationStatus === 'pending_approval') {
      return { success: false, requiresApproval: true };
    }

    if (user.registrationStatus === 'rejected') {
      return { success: false, error: 'Account has been rejected' };
    }

    // Verify password
    const passwordValid = await bcrypt.compare(password, user.authToken || '');
    if (!passwordValid) {
      return { success: false, error: 'Invalid username or password' };
    }

    // Generate tokens
    const tokens = await generateJWT({
      id: user.id,
      username: user.plexUsername,
      role: user.role,
    });

    return {
      success: true,
      user: {
        id: user.id,
        username: user.plexUsername,
        isAdmin: user.role === 'admin',
      },
      tokens,
    };
  }

  async register(params: RegisterParams): Promise<AuthResult> {
    const { username, password } = params;

    // Validate
    if (!username || username.length < 3) {
      return { success: false, error: 'Username must be at least 3 characters' };
    }

    if (!password || password.length < 8) {
      return { success: false, error: 'Password must be at least 8 characters' };
    }

    // Check if registration is enabled
    const registrationEnabled = await getConfig('auth.registration_enabled');
    if (registrationEnabled !== 'true') {
      return { success: false, error: 'Registration is disabled' };
    }

    // Check username uniqueness
    const existing = await prisma.user.findFirst({
      where: {
        plexUsername: username,
        authProvider: 'local',
      },
    });

    if (existing) {
      return { success: false, error: 'Username already taken' };
    }

    // Hash password
    const passwordHash = await bcrypt.hash(password, 10);

    // Determine registration status
    const requireApproval = (await getConfig('auth.require_admin_approval')) === 'true';
    const registrationStatus = requireApproval ? 'pending_approval' : 'approved';

    // Check if first user (make admin)
    const userCount = await prisma.user.count();
    const isFirstUser = userCount === 0;

    // Create user
    const user = await prisma.user.create({
      data: {
        plexId: `local-${username}`,
        plexUsername: username,
        authToken: passwordHash,
        authProvider: 'local',
        role: isFirstUser ? 'admin' : 'user',
        isSetupAdmin: isFirstUser,
        registrationStatus: isFirstUser ? 'approved' : registrationStatus,
      },
    });

    if (requireApproval && !isFirstUser) {
      return { success: false, requiresApproval: true };
    }

    // Generate tokens for immediate login
    const tokens = await generateJWT({
      id: user.id,
      username: user.plexUsername,
      role: user.role,
    });

    return {
      success: true,
      user: {
        id: user.id,
        username: user.plexUsername,
        isAdmin: user.role === 'admin',
      },
      tokens,
    };
  }

  async refreshToken(refreshToken: string): Promise<AuthTokens | null> {
    // Reuse existing JWT refresh logic
    return null;
  }

  async validateAccess(userInfo: UserInfo): Promise<boolean> {
    return true;
  }
}

4.2 Create Registration Endpoint

Create file: src/app/api/auth/register/route.ts

/**
 * User Registration Endpoint
 * Documentation: documentation/features/audiobookshelf-integration.md
 */

import { NextRequest, NextResponse } from 'next/server';
import { LocalAuthProvider } from '@/lib/services/auth/LocalAuthProvider';

// Rate limiting map (in production, use Redis)
const registrationAttempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 5;
const WINDOW_MS = 60 * 60 * 1000; // 1 hour

function checkRateLimit(ip: string): boolean {
  const now = Date.now();
  const attempts = registrationAttempts.get(ip);

  if (!attempts || now > attempts.resetAt) {
    registrationAttempts.set(ip, { count: 1, resetAt: now + WINDOW_MS });
    return true;
  }

  if (attempts.count >= MAX_ATTEMPTS) {
    return false;
  }

  attempts.count++;
  return true;
}

export async function POST(request: NextRequest) {
  // Rate limiting
  const ip = request.headers.get('x-forwarded-for') || 'unknown';
  if (!checkRateLimit(ip)) {
    return NextResponse.json(
      { error: 'Too many registration attempts. Please try again later.' },
      { status: 429 }
    );
  }

  try {
    const { username, password } = await request.json();

    const provider = new LocalAuthProvider();
    const result = await provider.register({ username, password });

    if (!result.success) {
      if (result.requiresApproval) {
        return NextResponse.json({
          success: false,
          pendingApproval: true,
          message: 'Account created. Waiting for admin approval.',
        });
      }
      return NextResponse.json(
        { error: result.error },
        { status: 400 }
      );
    }

    // Return tokens for auto-login
    return NextResponse.json({
      success: true,
      user: result.user,
      accessToken: result.tokens!.accessToken,
      refreshToken: result.tokens!.refreshToken,
    });
  } catch (error) {
    return NextResponse.json(
      { error: 'Registration failed' },
      { status: 500 }
    );
  }
}

4.3 Create Local Login Endpoint

Create file: src/app/api/auth/local/login/route.ts

/**
 * Local Login Endpoint
 * Documentation: documentation/features/audiobookshelf-integration.md
 */

import { NextRequest, NextResponse } from 'next/server';
import { LocalAuthProvider } from '@/lib/services/auth/LocalAuthProvider';

export async function POST(request: NextRequest) {
  try {
    const { username, password } = await request.json();

    const provider = new LocalAuthProvider();
    const result = await provider.handleCallback({ username, password });

    if (!result.success) {
      if (result.requiresApproval) {
        return NextResponse.json({
          success: false,
          pendingApproval: true,
          message: 'Account pending admin approval.',
        });
      }
      return NextResponse.json(
        { error: result.error },
        { status: 401 }
      );
    }

    return NextResponse.json({
      success: true,
      user: result.user,
      accessToken: result.tokens!.accessToken,
      refreshToken: result.tokens!.refreshToken,
    });
  } catch (error) {
    return NextResponse.json(
      { error: 'Login failed' },
      { status: 500 }
    );
  }
}

4.4 Create Auth Providers Endpoint

Create file: src/app/api/auth/providers/route.ts

/**
 * List Available Auth Providers
 * Documentation: documentation/features/audiobookshelf-integration.md
 */

import { NextResponse } from 'next/server';
import { getConfig, getBackendMode } from '@/lib/services/config.service';

export async function GET() {
  const mode = await getBackendMode();

  if (mode === 'plex') {
    return NextResponse.json({
      providers: ['plex'],
      registrationEnabled: false,
    });
  }

  // Audiobookshelf mode
  const oidcEnabled = (await getConfig('oidc.enabled')) === 'true';
  const registrationEnabled = (await getConfig('auth.registration_enabled')) === 'true';
  const oidcProviderName = await getConfig('oidc.provider_name') || 'SSO';

  const providers: string[] = [];
  if (oidcEnabled) providers.push('oidc');
  if (registrationEnabled) providers.push('local');

  return NextResponse.json({
    providers,
    registrationEnabled,
    oidcProviderName: oidcEnabled ? oidcProviderName : null,
  });
}

4.5 Create Admin User Approval Endpoints

Create file: src/app/api/admin/users/pending/route.ts

/**
 * Pending User Approvals
 * Documentation: documentation/features/audiobookshelf-integration.md
 */

import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { requireAdmin } from '@/lib/middleware/auth';

export async function GET() {
  const authResult = await requireAdmin();
  if (!authResult.success) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
  }

  const pendingUsers = await prisma.user.findMany({
    where: { registrationStatus: 'pending_approval' },
    select: {
      id: true,
      plexUsername: true,
      createdAt: true,
      authProvider: true,
    },
    orderBy: { createdAt: 'desc' },
  });

  return NextResponse.json({ users: pendingUsers });
}

Create file: src/app/api/admin/users/[id]/approve/route.ts

/**
 * Approve User Registration
 * Documentation: documentation/features/audiobookshelf-integration.md
 */

import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { requireAdmin } from '@/lib/middleware/auth';

export async function POST(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const authResult = await requireAdmin();
  if (!authResult.success) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
  }

  await prisma.user.update({
    where: { id: params.id },
    data: { registrationStatus: 'approved' },
  });

  return NextResponse.json({ success: true });
}

4.6 Phase 4 Verification

Tests to run:

  1. Registration creates user with correct status
  2. Login works for approved users
  3. Login blocked for pending users
  4. Admin can see pending users
  5. Admin can approve users
  6. Rate limiting works
  7. Existing auth methods still work

Checklist:

  • LocalAuthProvider implements interface
  • Registration endpoint with rate limiting
  • Local login endpoint
  • Auth providers listing endpoint
  • Admin approval endpoints
  • First user becomes admin

Phase 5: Setup Wizard Modifications

5.1 Create Backend Selection Step

Create file: src/app/setup/components/BackendSelectionStep.tsx

/**
 * Backend Selection Step
 * Documentation: documentation/features/audiobookshelf-integration.md
 */

'use client';

import { useState } from 'react';

interface Props {
  value: 'plex' | 'audiobookshelf';
  onChange: (value: 'plex' | 'audiobookshelf') => void;
  onNext: () => void;
}

export function BackendSelectionStep({ value, onChange, onNext }: Props) {
  return (
    <div className="space-y-6">
      <div>
        <h2 className="text-2xl font-bold">Choose Your Library Backend</h2>
        <p className="text-gray-600 mt-2">
          Select which media server you'll use to manage your audiobook library.
        </p>
      </div>

      <div className="space-y-4">
        <label className={`block p-4 border rounded-lg cursor-pointer transition ${
          value === 'plex' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'
        }`}>
          <input
            type="radio"
            name="backend"
            value="plex"
            checked={value === 'plex'}
            onChange={() => onChange('plex')}
            className="sr-only"
          />
          <div className="flex items-center gap-4">
            <div className="w-12 h-12 bg-orange-500 rounded-lg flex items-center justify-center">
              {/* Plex icon */}
              <span className="text-white text-2xl">P</span>
            </div>
            <div>
              <h3 className="font-semibold">Plex Media Server</h3>
              <p className="text-sm text-gray-600">
                Use Plex for library management. Authentication via Plex OAuth.
              </p>
            </div>
          </div>
        </label>

        <label className={`block p-4 border rounded-lg cursor-pointer transition ${
          value === 'audiobookshelf' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'
        }`}>
          <input
            type="radio"
            name="backend"
            value="audiobookshelf"
            checked={value === 'audiobookshelf'}
            onChange={() => onChange('audiobookshelf')}
            className="sr-only"
          />
          <div className="flex items-center gap-4">
            <div className="w-12 h-12 bg-green-500 rounded-lg flex items-center justify-center">
              {/* ABS icon */}
              <span className="text-white text-2xl">A</span>
            </div>
            <div>
              <h3 className="font-semibold">Audiobookshelf</h3>
              <p className="text-sm text-gray-600">
                Use Audiobookshelf for library management. Choose OIDC or password authentication.
              </p>
            </div>
          </div>
        </label>
      </div>

      <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
        <p className="text-sm text-yellow-800">
          <strong>Note:</strong> This choice cannot be changed after setup.
          To switch backends, you'll need to reset the application.
        </p>
      </div>

      <button
        onClick={onNext}
        className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
      >
        Continue
      </button>
    </div>
  );
}

5.2 Create Audiobookshelf Setup Step

Create file: src/app/setup/components/AudiobookshelfStep.tsx

/**
 * Audiobookshelf Configuration Step
 * Documentation: documentation/features/audiobookshelf-integration.md
 */

'use client';

import { useState } from 'react';

interface Props {
  serverUrl: string;
  apiToken: string;
  libraryId: string;
  onServerUrlChange: (value: string) => void;
  onApiTokenChange: (value: string) => void;
  onLibraryIdChange: (value: string) => void;
  onNext: () => void;
  onBack: () => void;
}

export function AudiobookshelfStep({
  serverUrl,
  apiToken,
  libraryId,
  onServerUrlChange,
  onApiTokenChange,
  onLibraryIdChange,
  onNext,
  onBack,
}: Props) {
  const [testing, setTesting] = useState(false);
  const [testResult, setTestResult] = useState<{
    success: boolean;
    libraries?: { id: string; name: string; itemCount: number }[];
    error?: string;
  } | null>(null);

  const handleTest = async () => {
    setTesting(true);
    setTestResult(null);

    try {
      const response = await fetch('/api/setup/test-abs', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ serverUrl, apiToken }),
      });

      const data = await response.json();
      setTestResult(data);
    } catch (error) {
      setTestResult({ success: false, error: 'Connection failed' });
    } finally {
      setTesting(false);
    }
  };

  const canProceed = testResult?.success && libraryId;

  return (
    <div className="space-y-6">
      <div>
        <h2 className="text-2xl font-bold">Configure Audiobookshelf</h2>
        <p className="text-gray-600 mt-2">
          Enter your Audiobookshelf server details and API token.
        </p>
      </div>

      <div className="space-y-4">
        <div>
          <label className="block text-sm font-medium mb-1">Server URL</label>
          <input
            type="url"
            value={serverUrl}
            onChange={(e) => onServerUrlChange(e.target.value)}
            placeholder="http://audiobookshelf:13378"
            className="w-full px-3 py-2 border rounded-lg"
          />
        </div>

        <div>
          <label className="block text-sm font-medium mb-1">API Token</label>
          <input
            type="password"
            value={apiToken}
            onChange={(e) => onApiTokenChange(e.target.value)}
            placeholder="Your API token"
            className="w-full px-3 py-2 border rounded-lg"
          />
          <p className="text-xs text-gray-500 mt-1">
            Find this in Audiobookshelf  Settings  Users  Your User  API Token
          </p>
        </div>

        <button
          onClick={handleTest}
          disabled={testing || !serverUrl || !apiToken}
          className="px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50"
        >
          {testing ? 'Testing...' : 'Test Connection'}
        </button>

        {testResult && (
          <div className={`p-4 rounded-lg ${testResult.success ? 'bg-green-50' : 'bg-red-50'}`}>
            {testResult.success ? (
              <>
                <p className="text-green-800 font-medium">Connection successful!</p>
                <div className="mt-2">
                  <label className="block text-sm font-medium mb-1">Select Library</label>
                  <select
                    value={libraryId}
                    onChange={(e) => onLibraryIdChange(e.target.value)}
                    className="w-full px-3 py-2 border rounded-lg"
                  >
                    <option value="">Select a library...</option>
                    {testResult.libraries?.map((lib) => (
                      <option key={lib.id} value={lib.id}>
                        {lib.name} ({lib.itemCount} items)
                      </option>
                    ))}
                  </select>
                </div>
              </>
            ) : (
              <p className="text-red-800">{testResult.error}</p>
            )}
          </div>
        )}
      </div>

      <div className="flex gap-4">
        <button
          onClick={onBack}
          className="flex-1 py-3 border rounded-lg hover:bg-gray-50 transition"
        >
          Back
        </button>
        <button
          onClick={onNext}
          disabled={!canProceed}
          className="flex-1 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition"
        >
          Continue
        </button>
      </div>
    </div>
  );
}

5.3 Create Auth Method Selection Step

Create file: src/app/setup/components/AuthMethodStep.tsx

Similar pattern to BackendSelectionStep - allow choosing between:

  • OIDC Provider
  • Manual Registration
  • Both

5.4 Create OIDC Configuration Step

Create file: src/app/setup/components/OIDCConfigStep.tsx

Include fields for:

  • Provider name (display name)
  • Issuer URL
  • Client ID
  • Client Secret
  • Access control method selection
  • Group claim configuration (if group_claim selected)

Include test connection button that validates OIDC discovery.

5.5 Create Registration Settings Step

Create file: src/app/setup/components/RegistrationSettingsStep.tsx

Include:

  • Enable/disable toggle
  • Require admin approval toggle

5.6 Update Main Setup Wizard

Modify: src/app/setup/page.tsx

Update step flow based on backend mode:

const steps = useMemo(() => {
  const baseSteps = ['welcome', 'backend'];

  if (state.backendMode === 'plex') {
    return [...baseSteps, 'plex', 'admin', 'prowlarr', 'download', 'paths', 'bookdate', 'review', 'finalize'];
  } else {
    return [...baseSteps, 'audiobookshelf', 'auth-method',
      ...(state.authMethod === 'oidc' || state.authMethod === 'both' ? ['oidc-config'] : []),
      ...(state.authMethod === 'manual' || state.authMethod === 'both' ? ['registration-settings'] : []),
      ...(state.authMethod === 'manual' ? ['admin-account'] : []),
      'prowlarr', 'download', 'paths', 'bookdate', 'review', 'finalize'
    ];
  }
}, [state.backendMode, state.authMethod]);

5.7 Update Setup Complete Endpoint

Modify: src/app/api/setup/complete/route.ts

Handle saving all new configuration:

// Save backend mode
await setConfig('system.backend_mode', state.backendMode);

if (state.backendMode === 'audiobookshelf') {
  // Save ABS config
  await setConfig('abs.server_url', state.absUrl);
  await setConfig('abs.api_token', state.absApiToken, true);  // encrypted
  await setConfig('abs.library_id', state.absLibraryId);

  // Save auth config
  if (state.authMethod === 'oidc' || state.authMethod === 'both') {
    await setConfig('oidc.enabled', 'true');
    await setConfig('oidc.provider_name', state.oidcProviderName);
    await setConfig('oidc.issuer_url', state.oidcIssuerUrl);
    await setConfig('oidc.client_id', state.oidcClientId);
    await setConfig('oidc.client_secret', state.oidcClientSecret, true);
    await setConfig('oidc.access_control_method', state.oidcAccessMethod);
    // ... other OIDC config
  }

  if (state.authMethod === 'manual' || state.authMethod === 'both') {
    await setConfig('auth.registration_enabled', 'true');
    await setConfig('auth.require_admin_approval', state.requireAdminApproval ? 'true' : 'false');
  }
}

5.8 Phase 5 Verification

Tests to run:

  1. Full setup flow with Plex mode
  2. Full setup flow with ABS + OIDC
  3. Full setup flow with ABS + Manual registration
  4. All config saved correctly
  5. Correct steps shown for each mode

Checklist:

  • Backend selection step
  • ABS configuration step
  • Auth method selection step
  • OIDC configuration step
  • Registration settings step
  • Dynamic step flow based on selections
  • Setup complete saves all config

Phase 6: Settings & Login UI

6.1 Update Login Page for Multi-Mode

Modify: src/app/login/page.tsx

'use client';

import { useEffect, useState } from 'react';
import { PlexLoginButton } from '@/components/auth/PlexLoginButton';
import { OIDCLoginButton } from '@/components/auth/OIDCLoginButton';
import { LocalLoginForm } from '@/components/auth/LocalLoginForm';
import { RegistrationForm } from '@/components/auth/RegistrationForm';

export default function LoginPage() {
  const [providers, setProviders] = useState<{
    providers: string[];
    registrationEnabled: boolean;
    oidcProviderName: string | null;
  } | null>(null);
  const [showRegister, setShowRegister] = useState(false);

  useEffect(() => {
    fetch('/api/auth/providers')
      .then(res => res.json())
      .then(setProviders);
  }, []);

  if (!providers) return <div>Loading...</div>;

  // Plex mode
  if (providers.providers.includes('plex')) {
    return <PlexLoginButton />;
  }

  // Audiobookshelf mode
  return (
    <div className="max-w-md mx-auto space-y-6">
      {showRegister ? (
        <>
          <RegistrationForm onSuccess={() => setShowRegister(false)} />
          <button onClick={() => setShowRegister(false)}>
            Already have an account? Login
          </button>
        </>
      ) : (
        <>
          {providers.providers.includes('oidc') && (
            <OIDCLoginButton providerName={providers.oidcProviderName!} />
          )}

          {providers.providers.includes('oidc') && providers.providers.includes('local') && (
            <div className="text-center text-gray-500">OR</div>
          )}

          {providers.providers.includes('local') && (
            <LocalLoginForm />
          )}

          {providers.registrationEnabled && (
            <button onClick={() => setShowRegister(true)}>
              Don't have an account? Register
            </button>
          )}
        </>
      )}
    </div>
  );
}

6.2 Create Auth Components

Create: src/components/auth/OIDCLoginButton.tsx Create: src/components/auth/LocalLoginForm.tsx Create: src/components/auth/RegistrationForm.tsx

6.3 Add Settings Tabs for ABS Mode

Modify: src/app/admin/settings/page.tsx

Add conditional tabs:

  • Audiobookshelf tab (if mode = audiobookshelf)
  • OIDC tab (if OIDC enabled)
  • Registration tab (if registration enabled)

6.4 Create Settings Tab Components

Create: src/app/admin/settings/components/AudiobookshelfTab.tsx Create: src/app/admin/settings/components/OIDCTab.tsx Create: src/app/admin/settings/components/RegistrationTab.tsx

6.5 Phase 6 Verification

Tests to run:

  1. Login page shows correct options per mode
  2. OIDC login button redirects correctly
  3. Local login form works
  4. Registration form works
  5. Settings tabs appear correctly
  6. Settings can be modified and saved

Phase 7: Integration Testing & Documentation

7.1 End-to-End Tests

Create comprehensive tests for:

  1. Plex Mode (Regression)

    • Full setup flow
    • Login/logout
    • Library scanning
    • Request flow
  2. ABS + OIDC Mode

    • Full setup flow
    • OIDC login with group claim access control
    • Library scanning
    • Request flow
  3. ABS + Manual Registration Mode

    • Full setup flow
    • User registration
    • Admin approval flow
    • Login after approval
    • Library scanning
    • Request flow

7.2 Update Documentation

Update: documentation/TABLEOFCONTENTS.md

  • Add entries for new integration docs

Update: documentation/backend/services/auth.md

  • Add OIDC and local auth sections

Create: documentation/integrations/audiobookshelf.md

  • API reference
  • Configuration
  • Troubleshooting

Update: documentation/setup-wizard.md

  • Document new steps
  • Mode-specific flows

7.3 Final Verification Checklist

  • All Phase 1-6 checklists complete
  • Plex mode unchanged (full regression)
  • ABS library integration works
  • OIDC authentication works
  • Group claim access control works
  • Manual registration works
  • Admin approval works
  • Setup wizard handles all modes
  • Settings pages handle all modes
  • Login page adapts to mode
  • Documentation updated
  • No console errors
  • No TypeScript errors
  • All tests pass

Important Notes for AI Agent

  1. Always read existing code first before making changes
  2. Run tests frequently - after each major change
  3. Keep existing functionality working - Plex mode must not break
  4. Follow existing patterns - match code style and structure
  5. Update imports when moving/creating files
  6. Handle errors gracefully - never crash on API errors
  7. Log appropriately - debug info but never tokens
  8. Ask if unclear - don't make assumptions about requirements