server.js aktualisiert
This commit is contained in:
379
server.js
379
server.js
@ -1,14 +1,371 @@
|
|||||||
{
|
// server.js
|
||||||
"name": "podchaser-abs-provider",
|
// Einfacher Custom Metadata Provider für Audiobookshelf,
|
||||||
"version": "1.0.0",
|
// der Podcast-Metadaten von Podchaser als "Book"-Metadaten ausliefert.
|
||||||
"description": "Custom Metadata Provider for Audiobookshelf using Podchaser API",
|
|
||||||
"main": "server.js",
|
import express from 'express';
|
||||||
"type": "module",
|
import dotenv from 'dotenv';
|
||||||
"scripts": {
|
|
||||||
"start": "node server.js"
|
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'
|
||||||
},
|
},
|
||||||
"dependencies": {
|
body: JSON.stringify({ query })
|
||||||
"dotenv": "^16.4.5",
|
});
|
||||||
"express": "^4.21.0"
|
|
||||||
|
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