server.js aktualisiert
This commit is contained in:
381
server.js
381
server.js
@ -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) // 1–50
|
||||
: 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}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user