server.js aktualisiert

This commit is contained in:
2025-11-26 13:42:07 +01:00
parent 6f6671f161
commit 87637c210e

381
server.js
View File

@ -1,14 +1,371 @@
{
"name": "podchaser-abs-provider",
"version": "1.0.0",
"description": "Custom Metadata Provider for Audiobookshelf using Podchaser API",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.21.0"
// server.js
// Einfacher Custom Metadata Provider für Audiobookshelf,
// der Podcast-Metadaten von Podchaser als "Book"-Metadaten ausliefert.
import express from 'express';
import dotenv from 'dotenv';
dotenv.config();
const app = express();
app.use(express.json());
// ========= Konfiguration über Umgebungsvariablen =========
//
// PODCHASER_CLIENT_ID -> Dein Podchaser API Key
// PODCHASER_CLIENT_SECRET -> Dein Podchaser API Secret
// ABS_AUTH_TOKEN -> Shared Secret, das du in Audiobookshelf
// als "Authorization Bearer" einträgst
// PORT -> Port des Webservers (Standard: 3000)
// PODCHASER_GRAPHQL_URL -> optional, Standard: https://api.podchaser.com/graphql
//
const {
PODCHASER_CLIENT_ID,
PODCHASER_CLIENT_SECRET,
ABS_AUTH_TOKEN,
PORT = 3000,
PODCHASER_GRAPHQL_URL = 'https://api.podchaser.com/graphql'
} = process.env;
// Einfache in-Memory Token-Cache-Struktur
let podchaserToken = {
accessToken: null,
// Unix-Timestamp in Millisekunden
expiresAt: 0
};
// ======== Hilfsfunktion: ABS-Authorization prüfen =========
function checkAbsAuth(req, res) {
if (!ABS_AUTH_TOKEN) {
res.status(500).json({ error: 'ABS_AUTH_TOKEN not configured on server' });
return false;
}
const header = req.header('authorization') || req.header('Authorization');
if (!header || !header.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid Authorization header' });
return false;
}
const token = header.substring('Bearer '.length).trim();
if (token !== ABS_AUTH_TOKEN) {
res.status(401).json({ error: 'Invalid bearer token' });
return false;
}
return true;
}
// ======== Hilfsfunktion: Podchaser Access Token holen/cachen =========
//
// Podchaser verwendet eine GraphQL-Mutation `requestAccessToken` mit
// grant_type CLIENT_CREDENTIALS, client_id und client_secret. :contentReference[oaicite:3]{index=3}
async function getPodchaserAccessToken() {
if (!PODCHASER_CLIENT_ID || !PODCHASER_CLIENT_SECRET) {
throw new Error('Podchaser client_id/client_secret not configured');
}
const now = Date.now();
// Wenn Token noch gültig ist, einfach wiederverwenden
if (podchaserToken.accessToken && podchaserToken.expiresAt > now + 60_000) {
return podchaserToken.accessToken;
}
const query = `
mutation {
requestAccessToken(
input: {
grant_type: CLIENT_CREDENTIALS
client_id: "${PODCHASER_CLIENT_ID}"
client_secret: "${PODCHASER_CLIENT_SECRET}"
}
) {
access_token
token_type
expires_in
}
}
`;
const response = await fetch(PODCHASER_GRAPHQL_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ query })
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(
`Failed to get Podchaser access token: HTTP ${response.status} ${text}`
);
}
const json = await response.json();
if (json.errors) {
throw new Error(
`Podchaser token error: ${JSON.stringify(json.errors)}`
);
}
const tokenData = json?.data?.requestAccessToken;
if (!tokenData?.access_token) {
throw new Error('Podchaser token response missing access_token');
}
const expiresInSeconds = tokenData.expires_in || 31536000; // laut Doku ~1 Jahr :contentReference[oaicite:4]{index=4}
podchaserToken.accessToken = tokenData.access_token;
podchaserToken.expiresAt = Date.now() + expiresInSeconds * 1000;
return podchaserToken.accessToken;
}
// ======== Hilfsfunktion: GraphQL-Query gegen Podchaser =========
async function podchaserGraphQL(query, variables = {}) {
const accessToken = await getPodchaserAccessToken();
const response = await fetch(PODCHASER_GRAPHQL_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({ query, variables })
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`Podchaser GraphQL HTTP ${response.status}: ${text}`);
}
const json = await response.json();
if (json.errors) {
throw new Error(`Podchaser GraphQL errors: ${JSON.stringify(json.errors)}`);
}
return json.data;
}
// ======== Mapping: Podchaser Podcast -> Audiobookshelf BookMetadata ========
//
// ABS erwartet das BookMetadata-Schema (siehe Custom Metadata Provider Spec). :contentReference[oaicite:5]{index=5}
function mapPodcastToBookMetadata(podcast, episodesList) {
if (!podcast) return null;
const {
title,
description,
imageUrl,
language,
numberOfEpisodes,
avgEpisodeLength,
startDate,
categories,
author
} = podcast;
// Basisbeschreibung
let desc = description || '';
// Optionale Episodenliste in die Beschreibung einbetten,
// damit sie in ABS sichtbar ist (da BookMetadata selbst kein Episodenfeld hat).
if (episodesList && episodesList.data && episodesList.data.length) {
const lines = episodesList.data.map((ep, idx) => {
const date = ep.airDate ? ep.airDate.slice(0, 10) : '';
const lengthMin =
typeof ep.length === 'number'
? `${Math.round(ep.length / 60)} min`
: 'n/a';
return `${idx + 1}. ${ep.title} (${lengthMin}${
date ? `, ${date}` : ''
})`;
});
desc += `\n\nEpisodes (first ${episodesList.data.length} on Podchaser):\n`;
desc += lines.join('\n');
}
// Genres aus Podchaser-Categories
const genres =
categories?.map((c) => c?.name).filter((g) => !!g) ?? [];
// Tags frei wählbar hier ein paar sinnvolle Defaults
const tags = ['podcast', 'podchaser'];
if (numberOfEpisodes != null) {
tags.push(`episodes:${numberOfEpisodes}`);
}
// Duration: Podchaser gibt avgEpisodeLength in Sekunden an.
// Für einen Podcast als "Book" ist das nur eine grobe Info;
// die tatsächliche Laufzeit kommt in ABS aus den Mediendateien.
const duration = typeof avgEpisodeLength === 'number'
? avgEpisodeLength
: undefined;
// Published Year grob aus Startdatum
let publishedYear;
if (startDate) {
publishedYear = String(new Date(startDate).getUTCFullYear());
}
const authorName = author?.name || author?.email || undefined;
return {
// Pflichtfeld
title: title || 'Unknown Podcast',
// Optionale Felder im ABS-BookMetadata
subtitle: undefined,
author: authorName,
narrator: undefined,
publisher: undefined,
publishedYear,
description: desc,
cover: imageUrl || undefined,
isbn: undefined,
asin: undefined,
genres,
tags,
series: [],
language: language || undefined,
duration
};
}
// ========= Endpoint: /search (für Audiobookshelf Custom Metadata Provider) =========
//
// ABS ruft nach Spec GET /search?query=... auf und erwartet:
// { "matches": [ BookMetadata, ... ] } :contentReference[oaicite:6]{index=6}
//
// Hier interpretieren wir "query" als freien Suchstring auf Podchaser
// (podcasts(searchTerm: "...")) :contentReference[oaicite:7]{index=7}
app.get('/search', async (req, res) => {
try {
if (!checkAbsAuth(req, res)) return;
const queryParam = (req.query.query || '').toString().trim();
if (!queryParam) {
return res.status(400).json({ error: 'Missing required query parameter ?query=' });
}
const gql = `
query SearchPodcasts($term: String!) {
podcasts(searchTerm: $term, first: 10) {
data {
id
title
description
imageUrl
language
startDate
numberOfEpisodes
avgEpisodeLength
categories { name }
author { name email }
}
}
}
`;
const data = await podchaserGraphQL(gql, { term: queryParam });
const podcasts = data?.podcasts?.data || [];
const matches = podcasts
.map((p) => mapPodcastToBookMetadata(p, null))
.filter((m) => m !== null);
res.json({ matches });
} catch (err) {
console.error('Error in /search:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// ========= Endpoint: /podcast/:id (komfortabler Direktzugriff) =========
//
// Diesen Endpoint kannst du z.B. manuell testen oder von anderen Tools aus nutzen.
// Er liefert EIN BookMetadata-Objekt für einen Podcast inkl. Episodenliste
// in der Beschreibung.
//
// Standardmäßig interpretiert :id als PODCHASER-ID.
// Du kannst den Identifiertyp per ?type=PODCHASER|APPLE_PODCASTS|SPOTIFY|RSS
// überschreiben. :contentReference[oaicite:8]{index=8}
app.get('/podcast/:id', async (req, res) => {
try {
if (!checkAbsAuth(req, res)) return;
const id = req.params.id;
if (!id) {
return res.status(400).json({ error: 'Missing podcast id in route' });
}
const typeParam = (req.query.type || 'PODCHASER').toString().toUpperCase();
const episodesParam = parseInt(req.query.episodes || '20', 10);
const episodesFirst = Number.isFinite(episodesParam)
? Math.min(Math.max(episodesParam, 1), 50) // 150
: 20;
const gql = `
query PodcastWithEpisodes(
$id: String!,
$type: PodcastIdentifierType!,
$episodesFirst: Int!
) {
podcast(identifier: { id: $id, type: $type }) {
id
title
description
imageUrl
language
startDate
numberOfEpisodes
avgEpisodeLength
categories { name }
author { name email }
episodes(first: $episodesFirst) {
data {
id
title
length
airDate
}
}
}
}
`;
const data = await podchaserGraphQL(gql, {
id,
type: typeParam,
episodesFirst
});
const podcast = data?.podcast;
if (!podcast) {
return res.status(404).json({ error: 'Podcast not found on Podchaser' });
}
const episodesList = podcast.episodes || null;
const meta = mapPodcastToBookMetadata(podcast, episodesList);
res.json(meta);
} catch (err) {
console.error('Error in /podcast/:id:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// ========= Healthcheck =========
app.get('/', (req, res) => {
res.json({
status: 'ok',
message: 'Podchaser -> Audiobookshelf metadata bridge',
endpoints: ['/search', '/podcast/:id']
});
});
// ========= Serverstart =========
app.listen(PORT, () => {
console.log(`Podchaser metadata provider listening on port ${PORT}`);
});