From 9161ec6baa88ac9ad572b9a0c99f4fbbb7e3fa8b Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Mon, 13 Jul 2026 09:26:45 +0200 Subject: [PATCH] initial audiocove integration --- .env.example | 5 + .../storycove/config/AudioCoveProperties.java | 32 ++++ .../storycove/controller/StoryController.java | 21 ++- .../storycove/dto/AudioCoveIngestRequest.java | 81 +++++++++ .../storycove/service/AudioCoveService.java | 115 ++++++++++++ backend/src/main/resources/application.yml | 3 + .../service/AudioCoveServiceTest.java | 170 ++++++++++++++++++ docker-compose.yml | 2 + frontend/src/app/stories/[id]/detail/page.tsx | 28 ++- frontend/src/lib/api.ts | 7 + 10 files changed, 461 insertions(+), 3 deletions(-) create mode 100644 backend/src/main/java/com/storycove/config/AudioCoveProperties.java create mode 100644 backend/src/main/java/com/storycove/dto/AudioCoveIngestRequest.java create mode 100644 backend/src/main/java/com/storycove/service/AudioCoveService.java create mode 100644 backend/src/test/java/com/storycove/service/AudioCoveServiceTest.java diff --git a/.env.example b/.env.example index e3b5969..b6c8ed6 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/config/AudioCoveProperties.java b/backend/src/main/java/com/storycove/config/AudioCoveProperties.java new file mode 100644 index 0000000..815e98d --- /dev/null +++ b/backend/src/main/java/com/storycove/config/AudioCoveProperties.java @@ -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(); + } +} diff --git a/backend/src/main/java/com/storycove/controller/StoryController.java b/backend/src/main/java/com/storycove/controller/StoryController.java index 318bb35..1dfc059 100644 --- a/backend/src/main/java/com/storycove/controller/StoryController.java +++ b/backend/src/main/java/com/storycove/controller/StoryController.java @@ -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 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; diff --git a/backend/src/main/java/com/storycove/dto/AudioCoveIngestRequest.java b/backend/src/main/java/com/storycove/dto/AudioCoveIngestRequest.java new file mode 100644 index 0000000..1c1926d --- /dev/null +++ b/backend/src/main/java/com/storycove/dto/AudioCoveIngestRequest.java @@ -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 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 getTags() { return tags; } + public void setTags(List tags) { this.tags = tags; } + + public String getCoverImage() { return coverImage; } + public void setCoverImage(String coverImage) { this.coverImage = coverImage; } +} diff --git a/backend/src/main/java/com/storycove/service/AudioCoveService.java b/backend/src/main/java/com/storycove/service/AudioCoveService.java new file mode 100644 index 0000000..eb985d1 --- /dev/null +++ b/backend/src/main/java/com/storycove/service/AudioCoveService.java @@ -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 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); + } + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 50db642..53254ed 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -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: diff --git a/backend/src/test/java/com/storycove/service/AudioCoveServiceTest.java b/backend/src/test/java/com/storycove/service/AudioCoveServiceTest.java new file mode 100644 index 0000000..bdf8fb9 --- /dev/null +++ b/backend/src/test/java/com/storycove/service/AudioCoveServiceTest.java @@ -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("

Content

"); + 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("

Content

"); + + 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("

Content

"); + story.setAuthor(author); + story.setSeries(series); + story.setVolume(2); + story.setCoverPath("cover.jpg"); + story.getTags().add(tag1); + story.getTags().add(tag2); + + return story; + } +} diff --git a/docker-compose.yml b/docker-compose.yml index de051ae..03bc46d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/src/app/stories/[id]/detail/page.tsx b/frontend/src/app/stories/[id]/detail/page.tsx index 56a6fab..ca31a5b 100644 --- a/frontend/src/app/stories/[id]/detail/page.tsx +++ b/frontend/src/app/stories/[id]/detail/page.tsx @@ -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 + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4e1c631..4eea035 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1164,6 +1164,13 @@ export const statisticsApi = { }, }; +export const audioCoveApi = { + sendToAudioCove: async (storyId: string): Promise => { + 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 '';