initial audiocove integration
This commit is contained in:
@@ -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
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
- 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
Reference in New Issue
Block a user