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
+57
View File
@@ -0,0 +1,57 @@
/**
* Component: Audiobook Details API Route
* Documentation: documentation/integrations/audible.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getAudibleService } from '@/lib/integrations/audible.service';
/**
* GET /api/audiobooks/[asin]
* Get detailed information for a specific audiobook
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ asin: string }> }
) {
try {
const { asin } = await params;
if (!asin || asin.length !== 10) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Valid ASIN is required',
},
{ status: 400 }
);
}
const audibleService = getAudibleService();
const audiobook = await audibleService.getAudiobookDetails(asin);
if (!audiobook) {
return NextResponse.json(
{
error: 'NotFound',
message: 'Audiobook not found',
},
{ status: 404 }
);
}
return NextResponse.json({
success: true,
audiobook,
});
} catch (error) {
console.error('Failed to get audiobook details:', error);
return NextResponse.json(
{
error: 'FetchError',
message: 'Failed to fetch audiobook details',
},
{ status: 500 }
);
}
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Component: Audiobook Covers API Route
* Documentation: documentation/frontend/pages/login.md
*
* Serves random popular audiobook covers for login page floating animations
*/
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
/**
* GET /api/audiobooks/covers?count=100
* Get random popular audiobook covers for login page
*
* Returns lightweight cover data without matching overhead.
* Returns up to 200 covers for immersive login screen experience.
*/
export async function GET() {
try {
// Fetch all popular audiobooks with covers (up to 200)
const audiobooks = await prisma.audibleCache.findMany({
where: {
isPopular: true,
cachedCoverPath: {
not: null,
},
},
orderBy: {
popularRank: 'asc',
},
take: 200,
select: {
asin: true,
title: true,
author: true,
cachedCoverPath: true,
coverArtUrl: true,
},
});
// Transform to cover URLs
const covers = audiobooks.map((book) => {
// Prefer cached cover, fallback to original URL
let coverUrl = book.coverArtUrl || '';
if (book.cachedCoverPath) {
const filename = book.cachedCoverPath.split('/').pop();
coverUrl = `/api/cache/thumbnails/${filename}`;
}
return {
asin: book.asin,
title: book.title,
author: book.author,
coverUrl,
};
});
// Shuffle for random distribution
const shuffled = covers.sort(() => Math.random() - 0.5);
return NextResponse.json({
success: true,
covers: shuffled,
count: shuffled.length,
});
} catch (error) {
console.error('Failed to get audiobook covers:', error);
// Return empty array on error (login page will show placeholders)
return NextResponse.json({
success: false,
covers: [],
count: 0,
});
}
}
@@ -0,0 +1,140 @@
/**
* Component: New Releases API Route
* Documentation: documentation/integrations/audible.md
*
* Serves new release audiobooks from audible_cache with real-time Plex matching
*/
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth';
/**
* GET /api/audiobooks/new-releases?page=1&limit=20
* Get new release audiobooks from audible_cache with pagination
*
* Real-time matching against plex_library determines availability.
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get('page') || '1', 10);
const limit = parseInt(searchParams.get('limit') || '20', 10);
// Validate pagination parameters
if (page < 1 || limit < 1 || limit > 100) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Invalid pagination parameters. Page must be >= 1 and limit must be between 1 and 100.',
},
{ status: 400 }
);
}
const skip = (page - 1) * limit;
// Query audible_cache for new release audiobooks
const [audiobooks, totalCount] = await Promise.all([
prisma.audibleCache.findMany({
where: {
isNewRelease: true,
},
orderBy: {
newReleaseRank: 'asc',
},
skip,
take: limit,
select: {
id: true,
asin: true,
title: true,
author: true,
narrator: true,
description: true,
coverArtUrl: true,
cachedCoverPath: true,
durationMinutes: true,
releaseDate: true,
rating: true,
genres: true,
lastSyncedAt: true,
},
}),
prisma.audibleCache.count({
where: {
isNewRelease: true,
},
}),
]);
// If no data found, return helpful message
if (totalCount === 0) {
return NextResponse.json({
success: true,
audiobooks: [],
count: 0,
totalCount: 0,
page,
totalPages: 0,
hasMore: false,
message: 'No new releases found. The Audible data refresh job may need to be run. Please check the Admin Jobs page to enable or trigger the "Audible Data Refresh" job.',
});
}
// Transform to matcher input format (uses ASIN as required field)
// Use cached cover path when available, otherwise fall back to coverArtUrl
const audibleBooks = audiobooks.map((book) => {
// Convert cached path to API URL if it exists
let coverUrl = book.coverArtUrl || undefined;
if (book.cachedCoverPath) {
const filename = book.cachedCoverPath.split('/').pop();
coverUrl = `/api/cache/thumbnails/${filename}`;
}
return {
asin: book.asin,
title: book.title,
author: book.author,
narrator: book.narrator || undefined,
description: book.description || undefined,
coverArtUrl: coverUrl,
durationMinutes: book.durationMinutes || undefined,
releaseDate: book.releaseDate?.toISOString() || undefined,
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
genres: (book.genres as string[]) || [],
};
});
// Get current user (optional - for request status enrichment)
const currentUser = getCurrentUser(request);
const userId = currentUser?.sub || undefined;
// Enrich with real-time Plex library matching and request status
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
const totalPages = Math.ceil(totalCount / limit);
const hasMore = page < totalPages;
return NextResponse.json({
success: true,
audiobooks: enrichedAudiobooks,
count: enrichedAudiobooks.length,
totalCount,
page,
totalPages,
hasMore,
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null,
});
} catch (error) {
console.error('Failed to get new releases:', error);
return NextResponse.json(
{
error: 'FetchError',
message: 'Failed to fetch new releases from database',
},
{ status: 500 }
);
}
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Component: Popular Audiobooks API Route
* Documentation: documentation/integrations/audible.md
*
* Serves popular audiobooks from audible_cache with real-time Plex matching
*/
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth';
/**
* GET /api/audiobooks/popular?page=1&limit=20
* Get popular audiobooks from audible_cache with pagination
*
* Real-time matching against plex_library determines availability.
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get('page') || '1', 10);
const limit = parseInt(searchParams.get('limit') || '20', 10);
// Validate pagination parameters
if (page < 1 || limit < 1 || limit > 100) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Invalid pagination parameters. Page must be >= 1 and limit must be between 1 and 100.',
},
{ status: 400 }
);
}
const skip = (page - 1) * limit;
// Query audible_cache for popular audiobooks
const [audiobooks, totalCount] = await Promise.all([
prisma.audibleCache.findMany({
where: {
isPopular: true,
},
orderBy: {
popularRank: 'asc',
},
skip,
take: limit,
select: {
id: true,
asin: true,
title: true,
author: true,
narrator: true,
description: true,
coverArtUrl: true,
cachedCoverPath: true,
durationMinutes: true,
releaseDate: true,
rating: true,
genres: true,
lastSyncedAt: true,
},
}),
prisma.audibleCache.count({
where: {
isPopular: true,
},
}),
]);
// If no data found, return helpful message
if (totalCount === 0) {
return NextResponse.json({
success: true,
audiobooks: [],
count: 0,
totalCount: 0,
page,
totalPages: 0,
hasMore: false,
message: 'No popular audiobooks found. The Audible data refresh job may need to be run. Please check the Admin Jobs page to enable or trigger the "Audible Data Refresh" job.',
});
}
// Transform to matcher input format (uses ASIN as required field)
// Use cached cover path when available, otherwise fall back to coverArtUrl
const audibleBooks = audiobooks.map((book) => {
// Convert cached path to API URL if it exists
let coverUrl = book.coverArtUrl || undefined;
if (book.cachedCoverPath) {
const filename = book.cachedCoverPath.split('/').pop();
coverUrl = `/api/cache/thumbnails/${filename}`;
}
return {
asin: book.asin,
title: book.title,
author: book.author,
narrator: book.narrator || undefined,
description: book.description || undefined,
coverArtUrl: coverUrl,
durationMinutes: book.durationMinutes || undefined,
releaseDate: book.releaseDate?.toISOString() || undefined,
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
genres: (book.genres as string[]) || [],
};
});
// Get current user (optional - for request status enrichment)
const currentUser = getCurrentUser(request);
const userId = currentUser?.sub || undefined;
// Enrich with real-time Plex library matching and request status
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
const totalPages = Math.ceil(totalCount / limit);
const hasMore = page < totalPages;
return NextResponse.json({
success: true,
audiobooks: enrichedAudiobooks,
count: enrichedAudiobooks.length,
totalCount,
page,
totalPages,
hasMore,
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null,
});
} catch (error) {
console.error('Failed to get popular audiobooks:', error);
return NextResponse.json(
{
error: 'FetchError',
message: 'Failed to fetch popular audiobooks from database',
},
{ status: 500 }
);
}
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Component: Audiobook Search API Route
* Documentation: documentation/integrations/audible.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth';
/**
* GET /api/audiobooks/search?q=query&page=1
* Search for audiobooks on Audible
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q') || searchParams.get('query');
const page = parseInt(searchParams.get('page') || '1', 10);
if (!query) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Search query is required',
},
{ status: 400 }
);
}
const audibleService = getAudibleService();
const results = await audibleService.search(query, page);
// Get current user (optional - for request status enrichment)
const currentUser = getCurrentUser(request);
const userId = currentUser?.sub || undefined;
// Enrich search results with availability and request status information
const enrichedResults = await enrichAudiobooksWithMatches(results.results, userId);
return NextResponse.json({
success: true,
query: results.query,
results: enrichedResults,
totalResults: results.totalResults,
page: results.page,
hasMore: results.hasMore,
});
} catch (error) {
console.error('Failed to search audiobooks:', error);
return NextResponse.json(
{
error: 'SearchError',
message: 'Failed to search audiobooks',
},
{ status: 500 }
);
}
}