initial audiocove integration

This commit is contained in:
Stefan Hardegger
2026-07-13 09:26:45 +02:00
parent e03031ea05
commit 9161ec6baa
10 changed files with 461 additions and 3 deletions

View File

@@ -32,5 +32,10 @@ OPENSEARCH_PASSWORD=secure_opensearch_password_here
# Image Storage
IMAGE_STORAGE_PATH=/app/images
# AudioCove Integration (optional)
# Set these to enable the "Send to Audiocove" button on story detail pages
AUDIOCOVE_URL=http://audiocove:8000
AUDIOCOVE_API_KEY=your-audiocove-api-key
# Frontend API URL (for development only - not used in Docker)
NEXT_PUBLIC_API_URL=http://localhost:8080/api

View File

@@ -0,0 +1,32 @@
package com.storycove.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties("storycove.audiocove")
public class AudioCoveProperties {
private String url = "";
private String apiKey = "";
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getApiKey() {
return apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
public boolean isConfigured() {
return url != null && !url.isBlank();
}
}

View File

@@ -48,7 +48,8 @@ public class StoryController {
private final ZIPImportService zipImportService;
private final AsyncImageProcessingService asyncImageProcessingService;
private final ImageProcessingProgressService progressService;
private final AudioCoveService audioCoveService;
public StoryController(StoryService storyService,
AuthorService authorService,
SeriesService seriesService,
@@ -62,7 +63,8 @@ public class StoryController {
PDFImportService pdfImportService,
ZIPImportService zipImportService,
AsyncImageProcessingService asyncImageProcessingService,
ImageProcessingProgressService progressService) {
ImageProcessingProgressService progressService,
AudioCoveService audioCoveService) {
this.storyService = storyService;
this.authorService = authorService;
this.seriesService = seriesService;
@@ -77,6 +79,7 @@ public class StoryController {
this.zipImportService = zipImportService;
this.asyncImageProcessingService = asyncImageProcessingService;
this.progressService = progressService;
this.audioCoveService = audioCoveService;
}
@GetMapping
@@ -1058,6 +1061,20 @@ public class StoryController {
}
}
@PostMapping("/{id}/send-to-audiocove")
public ResponseEntity<String> sendToAudioCove(@PathVariable UUID id) {
try {
String response = audioCoveService.ingestStory(id);
return ResponseEntity.ok(response);
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(e.getMessage());
} catch (Exception e) {
logger.error("Failed to send story {} to AudioCove: {}", id, e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body("AudioCove request failed: " + e.getMessage());
}
}
// Request DTOs
public static class CreateStoryRequest {
private String title;

View File

@@ -0,0 +1,81 @@
package com.storycove.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class AudioCoveIngestRequest {
@JsonProperty("schema_version")
private final int schemaVersion = 1;
@JsonProperty("story_id")
private String storyId;
@JsonProperty("content_hash")
private String contentHash;
@JsonProperty("title")
private String title;
@JsonProperty("author")
private String author;
@JsonProperty("content_html")
private String contentHtml;
@JsonProperty("language")
private String language = "en";
@JsonProperty("collection")
private String collection;
@JsonProperty("series")
private String series;
@JsonProperty("series_part")
private String seriesPart;
@JsonProperty("tags")
private List<String> tags;
@JsonProperty("cover_image")
private String coverImage;
public int getSchemaVersion() { return schemaVersion; }
public String getStoryId() { return storyId; }
public void setStoryId(String storyId) { this.storyId = storyId; }
public String getContentHash() { return contentHash; }
public void setContentHash(String contentHash) { this.contentHash = contentHash; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getAuthor() { return author; }
public void setAuthor(String author) { this.author = author; }
public String getContentHtml() { return contentHtml; }
public void setContentHtml(String contentHtml) { this.contentHtml = contentHtml; }
public String getLanguage() { return language; }
public void setLanguage(String language) { this.language = language; }
public String getCollection() { return collection; }
public void setCollection(String collection) { this.collection = collection; }
public String getSeries() { return series; }
public void setSeries(String series) { this.series = series; }
public String getSeriesPart() { return seriesPart; }
public void setSeriesPart(String seriesPart) { this.seriesPart = seriesPart; }
public List<String> getTags() { return tags; }
public void setTags(List<String> tags) { this.tags = tags; }
public String getCoverImage() { return coverImage; }
public void setCoverImage(String coverImage) { this.coverImage = coverImage; }
}

View File

@@ -0,0 +1,115 @@
package com.storycove.service;
import com.storycove.config.AudioCoveProperties;
import com.storycove.dto.AudioCoveIngestRequest;
import com.storycove.entity.Story;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.UUID;
@Service
public class AudioCoveService {
private static final Logger logger = LoggerFactory.getLogger(AudioCoveService.class);
private final StoryService storyService;
private final AudioCoveProperties audioCoveProperties;
private final String publicUrl;
private final RestClient restClient;
public AudioCoveService(StoryService storyService,
AudioCoveProperties audioCoveProperties,
@Value("${storycove.app.public-url}") String publicUrl) {
this.storyService = storyService;
this.audioCoveProperties = audioCoveProperties;
this.publicUrl = publicUrl;
this.restClient = RestClient.create();
}
@Transactional(readOnly = true)
public String ingestStory(UUID storyId) {
if (!audioCoveProperties.isConfigured()) {
throw new IllegalStateException("AudioCove URL is not configured. Set the AUDIOCOVE_URL environment variable.");
}
Story story = storyService.findById(storyId);
AudioCoveIngestRequest request = buildIngestRequest(story);
logger.info("Sending story '{}' ({}) to AudioCove", story.getTitle(), storyId);
try {
String response = restClient.post()
.uri(audioCoveProperties.getUrl() + "/api/ingest")
.header("X-API-Key", audioCoveProperties.getApiKey())
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(String.class);
logger.info("AudioCove ingest successful for story '{}'", story.getTitle());
return response;
} catch (RestClientException e) {
logger.error("AudioCove ingest failed for story '{}': {}", story.getTitle(), e.getMessage());
throw e;
}
}
private AudioCoveIngestRequest buildIngestRequest(Story story) {
AudioCoveIngestRequest request = new AudioCoveIngestRequest();
request.setStoryId(story.getId().toString());
request.setTitle(story.getTitle());
request.setContentHtml(story.getContentHtml());
request.setContentHash(computeContentHash(story.getContentHtml()));
if (story.getAuthor() != null) {
request.setAuthor(story.getAuthor().getName());
}
if (story.getSeries() != null) {
request.setSeries(story.getSeries().getName());
if (story.getVolume() != null) {
request.setSeriesPart(story.getVolume().toString());
}
}
List<String> tagNames = story.getTags().stream()
.map(tag -> tag.getName())
.toList();
request.setTags(tagNames);
if (story.getCoverPath() != null && !story.getCoverPath().isBlank()) {
request.setCoverImage(publicUrl + "/api/files/images/" + story.getCoverPath());
}
return request;
}
private String computeContentHash(String content) {
if (content == null) {
content = "";
}
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = digest.digest(content.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (byte b : hashBytes) {
hex.append(String.format("%02x", b));
}
return "sha256:" + hex;
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 algorithm not available", e);
}
}
}

View File

@@ -92,6 +92,9 @@ storycove:
check-interval: ${SOLR_HEALTH_CHECK_INTERVAL:30000} # 30 seconds
slow-query-threshold: ${SOLR_SLOW_QUERY_THRESHOLD:5000} # 5 seconds
enable-metrics: ${SOLR_ENABLE_METRICS:true}
audiocove:
url: ${AUDIOCOVE_URL:}
api-key: ${AUDIOCOVE_API_KEY:}
images:
storage-path: ${IMAGE_STORAGE_PATH:/app/images}
backup:

View File

@@ -0,0 +1,170 @@
package com.storycove.service;
import com.storycove.config.AudioCoveProperties;
import com.storycove.dto.AudioCoveIngestRequest;
import com.storycove.entity.Author;
import com.storycove.entity.Series;
import com.storycove.entity.Story;
import com.storycove.entity.Tag;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.lang.reflect.Method;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class AudioCoveServiceTest {
@Mock
private StoryService storyService;
private AudioCoveProperties audioCoveProperties;
private AudioCoveService audioCoveService;
@BeforeEach
void setUp() {
audioCoveProperties = new AudioCoveProperties();
audioCoveProperties.setUrl("http://audiocove:8000");
audioCoveProperties.setApiKey("test-api-key");
audioCoveService = new AudioCoveService(storyService, audioCoveProperties, "http://localhost:6925");
}
@Test
@DisplayName("Throws IllegalStateException when AudioCove URL is not configured")
void ingestStory_throwsWhenNotConfigured() {
audioCoveProperties.setUrl("");
UUID storyId = UUID.randomUUID();
assertThatThrownBy(() -> audioCoveService.ingestStory(storyId))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("AUDIOCOVE_URL");
}
@Test
@DisplayName("Content hash is formatted as sha256:<64-hex-chars>")
void computeContentHash_correctFormat() throws Exception {
Method method = AudioCoveService.class.getDeclaredMethod("computeContentHash", String.class);
method.setAccessible(true);
String hash = (String) method.invoke(audioCoveService, "hello world");
assertThat(hash).startsWith("sha256:");
assertThat(hash.substring(7)).hasSize(64).matches("[0-9a-f]+");
}
@Test
@DisplayName("Null content produces a valid hash (of empty string)")
void computeContentHash_nullContentProducesValidHash() throws Exception {
Method method = AudioCoveService.class.getDeclaredMethod("computeContentHash", String.class);
method.setAccessible(true);
String hash = (String) method.invoke(audioCoveService, (Object) null);
assertThat(hash).startsWith("sha256:");
assertThat(hash.substring(7)).hasSize(64);
}
@Test
@DisplayName("Payload maps all story fields correctly")
void buildIngestRequest_mapsAllFields() throws Exception {
UUID storyId = UUID.randomUUID();
Story story = buildFullStory(storyId);
when(storyService.findById(storyId)).thenReturn(story);
// We can't call ingestStory directly without mocking RestClient,
// so we test buildIngestRequest via reflection
Method method = AudioCoveService.class.getDeclaredMethod("buildIngestRequest", Story.class);
method.setAccessible(true);
AudioCoveIngestRequest request = (AudioCoveIngestRequest) method.invoke(audioCoveService, story);
assertThat(request.getStoryId()).isEqualTo(storyId.toString());
assertThat(request.getTitle()).isEqualTo("Test Story");
assertThat(request.getAuthor()).isEqualTo("Jane Doe");
assertThat(request.getContentHtml()).isEqualTo("<p>Content</p>");
assertThat(request.getContentHash()).startsWith("sha256:");
assertThat(request.getSeries()).isEqualTo("Nightfall Chronicles");
assertThat(request.getSeriesPart()).isEqualTo("2");
assertThat(request.getTags()).containsExactlyInAnyOrder("fantasy", "short-story");
assertThat(request.getCoverImage()).isEqualTo("http://localhost:6925/api/files/images/cover.jpg");
assertThat(request.getSchemaVersion()).isEqualTo(1);
}
@Test
@DisplayName("Null coverPath results in null coverImage")
void buildIngestRequest_nullCoverProducesNullCoverImage() throws Exception {
UUID storyId = UUID.randomUUID();
Story story = buildFullStory(storyId);
story.setCoverPath(null);
Method method = AudioCoveService.class.getDeclaredMethod("buildIngestRequest", Story.class);
method.setAccessible(true);
AudioCoveIngestRequest request = (AudioCoveIngestRequest) method.invoke(audioCoveService, story);
assertThat(request.getCoverImage()).isNull();
}
@Test
@DisplayName("Story without author or series produces null for those fields")
void buildIngestRequest_nullAuthorAndSeries() throws Exception {
UUID storyId = UUID.randomUUID();
Story story = new Story();
ReflectionTestUtils.setField(story, "id", storyId);
story.setTitle("Minimal Story");
story.setContentHtml("<p>Content</p>");
Method method = AudioCoveService.class.getDeclaredMethod("buildIngestRequest", Story.class);
method.setAccessible(true);
AudioCoveIngestRequest request = (AudioCoveIngestRequest) method.invoke(audioCoveService, story);
assertThat(request.getAuthor()).isNull();
assertThat(request.getSeries()).isNull();
assertThat(request.getSeriesPart()).isNull();
}
@Test
@DisplayName("Volume is serialized as string for series_part")
void buildIngestRequest_volumeAsString() throws Exception {
UUID storyId = UUID.randomUUID();
Story story = buildFullStory(storyId);
story.setVolume(3);
Method method = AudioCoveService.class.getDeclaredMethod("buildIngestRequest", Story.class);
method.setAccessible(true);
AudioCoveIngestRequest request = (AudioCoveIngestRequest) method.invoke(audioCoveService, story);
assertThat(request.getSeriesPart()).isEqualTo("3");
}
private Story buildFullStory(UUID storyId) {
Author author = new Author("Jane Doe");
Series series = new Series("Nightfall Chronicles");
Tag tag1 = new Tag("fantasy");
Tag tag2 = new Tag("short-story");
Story story = new Story();
ReflectionTestUtils.setField(story, "id", storyId);
story.setTitle("Test Story");
story.setContentHtml("<p>Content</p>");
story.setAuthor(author);
story.setSeries(series);
story.setVolume(2);
story.setCoverPath("cover.jpg");
story.getTags().add(tag1);
story.getTags().add(tag2);
return story;
}
}

View File

@@ -41,6 +41,8 @@ services:
- IMAGE_STORAGE_PATH=/app/images
- APP_PASSWORD=${APP_PASSWORD}
- STORYCOVE_CORS_ALLOWED_ORIGINS=${STORYCOVE_CORS_ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:6925}
- AUDIOCOVE_URL=${AUDIOCOVE_URL:-}
- AUDIOCOVE_API_KEY=${AUDIOCOVE_API_KEY:-}
volumes:
- ${DATA_DIR:-/volume1/docker/storycove}/images:/app/images
- ${DATA_DIR:-/volume1/docker/storycove}/config:/app/config

View File

@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
import { storyApi, seriesApi, getImageUrl } from '../../../../lib/api';
import { storyApi, seriesApi, audioCoveApi, getImageUrl } from '../../../../lib/api';
import { Story, Collection } from '../../../../types/api';
import AppLayout from '../../../../components/layout/AppLayout';
import Button from '../../../../components/ui/Button';
@@ -24,6 +24,8 @@ export default function StoryDetailPage() {
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [isSendingToAudioCove, setIsSendingToAudioCove] = useState(false);
const [audioCovedSuccess, setAudioCovedSuccess] = useState(false);
useEffect(() => {
const loadStoryData = async () => {
@@ -115,6 +117,22 @@ export default function StoryDetailPage() {
}
};
const handleSendToAudioCove = async () => {
if (!story || isSendingToAudioCove) return;
setIsSendingToAudioCove(true);
try {
await audioCoveApi.sendToAudioCove(story.id);
setAudioCovedSuccess(true);
setTimeout(() => setAudioCovedSuccess(false), 3000);
} catch (error: any) {
const message = error?.response?.data || 'Failed to send to AudioCove. Please try again.';
alert(message);
} finally {
setIsSendingToAudioCove(false);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
@@ -432,6 +450,14 @@ export default function StoryDetailPage() {
>
Edit Story
</Button>
<Button
onClick={handleSendToAudioCove}
variant="ghost"
size="lg"
disabled={isSendingToAudioCove || audioCovedSuccess}
>
{isSendingToAudioCove ? '🎧 Sending...' : audioCovedSuccess ? '✅ Sent!' : '🎧 Send to Audiocove'}
</Button>
</div>
</div>
</div>

View File

@@ -1164,6 +1164,13 @@ export const statisticsApi = {
},
};
export const audioCoveApi = {
sendToAudioCove: async (storyId: string): Promise<string> => {
const response = await api.post(`/stories/${storyId}/send-to-audiocove`);
return response.data;
},
};
// Image utility - now library-aware
export const getImageUrl = (path: string): string => {
if (!path) return '';