initial audiocove integration
This commit is contained in:
@@ -32,5 +32,10 @@ OPENSEARCH_PASSWORD=secure_opensearch_password_here
|
|||||||
# Image Storage
|
# Image Storage
|
||||||
IMAGE_STORAGE_PATH=/app/images
|
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)
|
# Frontend API URL (for development only - not used in Docker)
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:8080/api
|
NEXT_PUBLIC_API_URL=http://localhost:8080/api
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ public class StoryController {
|
|||||||
private final ZIPImportService zipImportService;
|
private final ZIPImportService zipImportService;
|
||||||
private final AsyncImageProcessingService asyncImageProcessingService;
|
private final AsyncImageProcessingService asyncImageProcessingService;
|
||||||
private final ImageProcessingProgressService progressService;
|
private final ImageProcessingProgressService progressService;
|
||||||
|
private final AudioCoveService audioCoveService;
|
||||||
|
|
||||||
public StoryController(StoryService storyService,
|
public StoryController(StoryService storyService,
|
||||||
AuthorService authorService,
|
AuthorService authorService,
|
||||||
@@ -62,7 +63,8 @@ public class StoryController {
|
|||||||
PDFImportService pdfImportService,
|
PDFImportService pdfImportService,
|
||||||
ZIPImportService zipImportService,
|
ZIPImportService zipImportService,
|
||||||
AsyncImageProcessingService asyncImageProcessingService,
|
AsyncImageProcessingService asyncImageProcessingService,
|
||||||
ImageProcessingProgressService progressService) {
|
ImageProcessingProgressService progressService,
|
||||||
|
AudioCoveService audioCoveService) {
|
||||||
this.storyService = storyService;
|
this.storyService = storyService;
|
||||||
this.authorService = authorService;
|
this.authorService = authorService;
|
||||||
this.seriesService = seriesService;
|
this.seriesService = seriesService;
|
||||||
@@ -77,6 +79,7 @@ public class StoryController {
|
|||||||
this.zipImportService = zipImportService;
|
this.zipImportService = zipImportService;
|
||||||
this.asyncImageProcessingService = asyncImageProcessingService;
|
this.asyncImageProcessingService = asyncImageProcessingService;
|
||||||
this.progressService = progressService;
|
this.progressService = progressService;
|
||||||
|
this.audioCoveService = audioCoveService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@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
|
// Request DTOs
|
||||||
public static class CreateStoryRequest {
|
public static class CreateStoryRequest {
|
||||||
private String title;
|
private String title;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,6 +92,9 @@ storycove:
|
|||||||
check-interval: ${SOLR_HEALTH_CHECK_INTERVAL:30000} # 30 seconds
|
check-interval: ${SOLR_HEALTH_CHECK_INTERVAL:30000} # 30 seconds
|
||||||
slow-query-threshold: ${SOLR_SLOW_QUERY_THRESHOLD:5000} # 5 seconds
|
slow-query-threshold: ${SOLR_SLOW_QUERY_THRESHOLD:5000} # 5 seconds
|
||||||
enable-metrics: ${SOLR_ENABLE_METRICS:true}
|
enable-metrics: ${SOLR_ENABLE_METRICS:true}
|
||||||
|
audiocove:
|
||||||
|
url: ${AUDIOCOVE_URL:}
|
||||||
|
api-key: ${AUDIOCOVE_API_KEY:}
|
||||||
images:
|
images:
|
||||||
storage-path: ${IMAGE_STORAGE_PATH:/app/images}
|
storage-path: ${IMAGE_STORAGE_PATH:/app/images}
|
||||||
backup:
|
backup:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,8 @@ services:
|
|||||||
- IMAGE_STORAGE_PATH=/app/images
|
- IMAGE_STORAGE_PATH=/app/images
|
||||||
- APP_PASSWORD=${APP_PASSWORD}
|
- APP_PASSWORD=${APP_PASSWORD}
|
||||||
- STORYCOVE_CORS_ALLOWED_ORIGINS=${STORYCOVE_CORS_ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:6925}
|
- 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:
|
volumes:
|
||||||
- ${DATA_DIR:-/volume1/docker/storycove}/images:/app/images
|
- ${DATA_DIR:-/volume1/docker/storycove}/images:/app/images
|
||||||
- ${DATA_DIR:-/volume1/docker/storycove}/config:/app/config
|
- ${DATA_DIR:-/volume1/docker/storycove}/config:/app/config
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
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 { Story, Collection } from '../../../../types/api';
|
||||||
import AppLayout from '../../../../components/layout/AppLayout';
|
import AppLayout from '../../../../components/layout/AppLayout';
|
||||||
import Button from '../../../../components/ui/Button';
|
import Button from '../../../../components/ui/Button';
|
||||||
@@ -24,6 +24,8 @@ export default function StoryDetailPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [updating, setUpdating] = useState(false);
|
const [updating, setUpdating] = useState(false);
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
const [isSendingToAudioCove, setIsSendingToAudioCove] = useState(false);
|
||||||
|
const [audioCovedSuccess, setAudioCovedSuccess] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadStoryData = async () => {
|
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) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -432,6 +450,14 @@ export default function StoryDetailPage() {
|
|||||||
>
|
>
|
||||||
✏️ Edit Story
|
✏️ Edit Story
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSendToAudioCove}
|
||||||
|
variant="ghost"
|
||||||
|
size="lg"
|
||||||
|
disabled={isSendingToAudioCove || audioCovedSuccess}
|
||||||
|
>
|
||||||
|
{isSendingToAudioCove ? '🎧 Sending...' : audioCovedSuccess ? '✅ Sent!' : '🎧 Send to Audiocove'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
// Image utility - now library-aware
|
||||||
export const getImageUrl = (path: string): string => {
|
export const getImageUrl = (path: string): string => {
|
||||||
if (!path) return '';
|
if (!path) return '';
|
||||||
|
|||||||
Reference in New Issue
Block a user