Series to Collection Function
This commit is contained in:
@@ -5,6 +5,7 @@ import com.storycove.entity.Collection;
|
||||
import com.storycove.entity.CollectionStory;
|
||||
import com.storycove.entity.Story;
|
||||
import com.storycove.entity.Tag;
|
||||
import com.storycove.service.AudioCoveService;
|
||||
import com.storycove.service.CollectionService;
|
||||
import com.storycove.service.EPUBExportService;
|
||||
import com.storycove.service.ImageService;
|
||||
@@ -29,16 +30,19 @@ public class CollectionController {
|
||||
private static final Logger logger = LoggerFactory.getLogger(CollectionController.class);
|
||||
|
||||
private final CollectionService collectionService;
|
||||
private final AudioCoveService audioCoveService;
|
||||
private final ImageService imageService;
|
||||
private final ReadingTimeService readingTimeService;
|
||||
private final EPUBExportService epubExportService;
|
||||
|
||||
|
||||
@Autowired
|
||||
public CollectionController(CollectionService collectionService,
|
||||
AudioCoveService audioCoveService,
|
||||
ImageService imageService,
|
||||
ReadingTimeService readingTimeService,
|
||||
EPUBExportService epubExportService) {
|
||||
this.collectionService = collectionService;
|
||||
this.audioCoveService = audioCoveService;
|
||||
this.imageService = imageService;
|
||||
this.readingTimeService = readingTimeService;
|
||||
this.epubExportService = epubExportService;
|
||||
@@ -281,6 +285,25 @@ public class CollectionController {
|
||||
return ResponseEntity.ok(Map.of("message", "Cover removed successfully"));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/collections/{id}/send-to-audiocove - Send combined collection content to AudioCove
|
||||
*/
|
||||
@PostMapping("/{id}/send-to-audiocove")
|
||||
public ResponseEntity<String> sendCollectionToAudioCove(@PathVariable UUID id) {
|
||||
try {
|
||||
String response = audioCoveService.ingestCollection(id);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (IllegalStateException e) {
|
||||
if (e.getMessage().contains("not configured")) {
|
||||
return ResponseEntity.status(503).body(e.getMessage());
|
||||
}
|
||||
return ResponseEntity.badRequest().body(e.getMessage());
|
||||
} catch (org.springframework.web.client.RestClientException e) {
|
||||
logger.error("AudioCove gateway error for collection {}: {}", id, e.getMessage());
|
||||
return ResponseEntity.status(502).body("AudioCove gateway error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/collections/reindex-typesense - Reindex all collections in Typesense
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.storycove.controller;
|
||||
|
||||
import com.storycove.dto.SeriesDto;
|
||||
import com.storycove.entity.Collection;
|
||||
import com.storycove.entity.Series;
|
||||
import com.storycove.service.CollectionService;
|
||||
import com.storycove.service.SeriesService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.data.domain.Page;
|
||||
@@ -22,9 +24,11 @@ import java.util.stream.Collectors;
|
||||
public class SeriesController {
|
||||
|
||||
private final SeriesService seriesService;
|
||||
|
||||
public SeriesController(SeriesService seriesService) {
|
||||
private final CollectionService collectionService;
|
||||
|
||||
public SeriesController(SeriesService seriesService, CollectionService collectionService) {
|
||||
this.seriesService = seriesService;
|
||||
this.collectionService = collectionService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -128,6 +132,15 @@ public class SeriesController {
|
||||
return ResponseEntity.ok(stats);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/create-collection")
|
||||
public ResponseEntity<Map<String, String>> createCollectionFromSeries(@PathVariable UUID id) {
|
||||
Collection collection = collectionService.createCollectionFromSeries(id);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(Map.of(
|
||||
"collectionId", collection.getId().toString(),
|
||||
"name", collection.getName()
|
||||
));
|
||||
}
|
||||
|
||||
private void updateSeriesFromRequest(Series series, Object request) {
|
||||
if (request instanceof CreateSeriesRequest createReq) {
|
||||
series.setName(createReq.getName());
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.storycove.service;
|
||||
|
||||
import com.storycove.config.AudioCoveProperties;
|
||||
import com.storycove.dto.AudioCoveIngestRequest;
|
||||
import com.storycove.entity.Collection;
|
||||
import com.storycove.entity.CollectionStory;
|
||||
import com.storycove.entity.Story;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -15,6 +17,7 @@ import org.springframework.web.client.RestClientException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -24,14 +27,17 @@ public class AudioCoveService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(AudioCoveService.class);
|
||||
|
||||
private final StoryService storyService;
|
||||
private final CollectionService collectionService;
|
||||
private final AudioCoveProperties audioCoveProperties;
|
||||
private final String publicUrl;
|
||||
private final RestClient restClient;
|
||||
|
||||
public AudioCoveService(StoryService storyService,
|
||||
CollectionService collectionService,
|
||||
AudioCoveProperties audioCoveProperties,
|
||||
@Value("${storycove.app.public-url}") String publicUrl) {
|
||||
this.storyService = storyService;
|
||||
this.collectionService = collectionService;
|
||||
this.audioCoveProperties = audioCoveProperties;
|
||||
this.publicUrl = publicUrl;
|
||||
this.restClient = RestClient.create();
|
||||
@@ -65,6 +71,70 @@ public class AudioCoveService {
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public String ingestCollection(UUID collectionId) {
|
||||
if (!audioCoveProperties.isConfigured()) {
|
||||
throw new IllegalStateException("AudioCove URL is not configured. Set the AUDIOCOVE_URL environment variable.");
|
||||
}
|
||||
|
||||
Collection collection = collectionService.findById(collectionId);
|
||||
List<CollectionStory> collectionStories = collection.getCollectionStories().stream()
|
||||
.sorted(Comparator.comparingInt(CollectionStory::getPosition))
|
||||
.toList();
|
||||
|
||||
if (collectionStories.isEmpty()) {
|
||||
throw new IllegalStateException("Collection has no stories to send to AudioCove.");
|
||||
}
|
||||
|
||||
StringBuilder combinedContent = new StringBuilder();
|
||||
for (int i = 0; i < collectionStories.size(); i++) {
|
||||
Story story = collectionStories.get(i).getStory();
|
||||
String chapterTitle = story.getTitle() + " - Part " + (i + 1) + " of Collection " + collection.getName();
|
||||
combinedContent.append("<h1>").append(chapterTitle).append("</h1>\n");
|
||||
if (story.getContentHtml() != null) {
|
||||
combinedContent.append(story.getContentHtml());
|
||||
}
|
||||
combinedContent.append("\n");
|
||||
}
|
||||
|
||||
AudioCoveIngestRequest request = new AudioCoveIngestRequest();
|
||||
request.setStoryId("collection:" + collectionId);
|
||||
request.setTitle(collection.getName());
|
||||
request.setContentHtml(combinedContent.toString());
|
||||
request.setContentHash(computeContentHash(combinedContent.toString()));
|
||||
request.setCollection(collection.getName());
|
||||
|
||||
List<String> allTags = collectionStories.stream()
|
||||
.flatMap(cs -> cs.getStory().getTags().stream())
|
||||
.map(tag -> tag.getName())
|
||||
.distinct()
|
||||
.toList();
|
||||
request.setTags(allTags);
|
||||
|
||||
if (collection.getCoverImagePath() != null && !collection.getCoverImagePath().isBlank()) {
|
||||
request.setCoverImage(publicUrl + "/api/files/images/" + collection.getCoverImagePath());
|
||||
}
|
||||
|
||||
logger.info("Sending collection '{}' ({}) to AudioCove with {} stories",
|
||||
collection.getName(), collectionId, collectionStories.size());
|
||||
|
||||
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 collection '{}'", collection.getName());
|
||||
return response;
|
||||
} catch (RestClientException e) {
|
||||
logger.error("AudioCove ingest failed for collection '{}': {}", collection.getName(), e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private AudioCoveIngestRequest buildIngestRequest(Story story) {
|
||||
AudioCoveIngestRequest request = new AudioCoveIngestRequest();
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.storycove.entity.Story;
|
||||
import com.storycove.entity.Tag;
|
||||
import com.storycove.repository.CollectionRepository;
|
||||
import com.storycove.repository.CollectionStoryRepository;
|
||||
import com.storycove.repository.SeriesRepository;
|
||||
import com.storycove.repository.StoryRepository;
|
||||
import com.storycove.repository.TagRepository;
|
||||
import com.storycove.service.exception.ResourceNotFoundException;
|
||||
@@ -32,20 +33,23 @@ public class CollectionService {
|
||||
private final CollectionStoryRepository collectionStoryRepository;
|
||||
private final StoryRepository storyRepository;
|
||||
private final TagRepository tagRepository;
|
||||
private final SeriesRepository seriesRepository;
|
||||
private final SearchServiceAdapter searchServiceAdapter;
|
||||
private final ReadingTimeService readingTimeService;
|
||||
|
||||
|
||||
@Autowired
|
||||
public CollectionService(CollectionRepository collectionRepository,
|
||||
CollectionStoryRepository collectionStoryRepository,
|
||||
StoryRepository storyRepository,
|
||||
TagRepository tagRepository,
|
||||
SeriesRepository seriesRepository,
|
||||
SearchServiceAdapter searchServiceAdapter,
|
||||
ReadingTimeService readingTimeService) {
|
||||
this.collectionRepository = collectionRepository;
|
||||
this.collectionStoryRepository = collectionStoryRepository;
|
||||
this.storyRepository = storyRepository;
|
||||
this.tagRepository = tagRepository;
|
||||
this.seriesRepository = seriesRepository;
|
||||
this.searchServiceAdapter = searchServiceAdapter;
|
||||
this.readingTimeService = readingTimeService;
|
||||
}
|
||||
@@ -416,6 +420,26 @@ public class CollectionService {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a collection from all stories in a series, ordered by volume
|
||||
*/
|
||||
public Collection createCollectionFromSeries(UUID seriesId) {
|
||||
com.storycove.entity.Series series = seriesRepository.findById(seriesId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Series not found with id: " + seriesId));
|
||||
|
||||
List<UUID> storyIds = series.getStories().stream()
|
||||
.sorted(java.util.Comparator.comparingInt(s -> s.getVolume() != null ? s.getVolume() : Integer.MAX_VALUE))
|
||||
.map(Story::getId)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (storyIds.isEmpty()) {
|
||||
throw new IllegalStateException("Cannot create a collection from a series with no stories.");
|
||||
}
|
||||
|
||||
logger.info("Creating collection from series '{}' with {} stories", series.getName(), storyIds.size());
|
||||
return createCollection(series.getName(), series.getDescription(), null, storyIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all collections for indexing (used by SearchServiceAdapter)
|
||||
*/
|
||||
|
||||
@@ -279,7 +279,11 @@ public class DatabaseManagementService {
|
||||
Path tempBackupFile = Files.createTempFile("storycove_backup_", ".sql");
|
||||
|
||||
try {
|
||||
// Build pg_dump command
|
||||
// Build pg_dump command.
|
||||
// --no-owner / --no-acl: omit ownership and privilege SQL that would fail
|
||||
// when restoring to a different environment or under a different role.
|
||||
// --create is intentionally omitted: it generates ALTER DATABASE / \connect
|
||||
// commands for the source database name which break cross-environment restores.
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
"pg_dump",
|
||||
"--host=" + dbHost,
|
||||
@@ -290,7 +294,8 @@ public class DatabaseManagementService {
|
||||
"--verbose",
|
||||
"--clean",
|
||||
"--if-exists",
|
||||
"--create",
|
||||
"--no-owner",
|
||||
"--no-acl",
|
||||
"--file=" + tempBackupFile.toString()
|
||||
);
|
||||
|
||||
@@ -368,12 +373,18 @@ public class DatabaseManagementService {
|
||||
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
// Skip DROP DATABASE and CREATE DATABASE commands - we're already connected to the DB
|
||||
// Also skip database connection commands as we're already connected
|
||||
if (line.trim().startsWith("DROP DATABASE") ||
|
||||
line.trim().startsWith("CREATE DATABASE") ||
|
||||
line.trim().startsWith("\\connect")) {
|
||||
System.err.println("Skipping incompatible command: " + line.substring(0, Math.min(50, line.length())));
|
||||
// Skip database-level commands that were generated by older backups using
|
||||
// pg_dump --create and that would fail when restoring to a different environment.
|
||||
// New backups no longer use --create so these lines won't appear, but we keep
|
||||
// the filters for backward compatibility with previously created backups.
|
||||
String trimmed = line.trim();
|
||||
if (trimmed.startsWith("DROP DATABASE") ||
|
||||
trimmed.startsWith("CREATE DATABASE") ||
|
||||
trimmed.startsWith("\\connect") ||
|
||||
trimmed.startsWith("\\c ") || // short-form \connect alias
|
||||
trimmed.startsWith("ALTER DATABASE") ||
|
||||
trimmed.startsWith("COMMENT ON DATABASE")) {
|
||||
System.err.println("Skipping database-level command: " + line.substring(0, Math.min(80, line.length())));
|
||||
continue;
|
||||
}
|
||||
writer.write(line);
|
||||
@@ -383,7 +394,10 @@ public class DatabaseManagementService {
|
||||
|
||||
System.err.println("Starting PostgreSQL restore using psql...");
|
||||
|
||||
// Build psql command to restore the backup
|
||||
// Build psql command to restore the backup.
|
||||
// --single-transaction wraps the entire restore in one atomic transaction:
|
||||
// if any statement fails (e.g. a COPY for story_tags), the whole restore rolls
|
||||
// back and an error is returned rather than leaving the DB in a partial state.
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
"psql",
|
||||
"--host=" + dbHost,
|
||||
@@ -392,6 +406,7 @@ public class DatabaseManagementService {
|
||||
"--dbname=" + dbName,
|
||||
"--no-password",
|
||||
"--echo-errors",
|
||||
"--single-transaction",
|
||||
"--file=" + tempBackupFile.toString()
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user