extraSchemas = modelConverters.readAll(Callback.class);
+ extraSchemas.forEach(openApi.getComponents()::addSchemas);
+ openApi.getComponents().addSecuritySchemes("basicAuth", new SecurityScheme()
+ .type(SecurityScheme.Type.HTTP)
+ .in(SecurityScheme.In.HEADER)
+ .scheme("basic"));
+ };
+ }
}
diff --git a/src/main/java/se/su/dsv/whisperapi/WhisperFrontendConfiguration.java b/src/main/java/se/su/dsv/whisperapi/WhisperFrontendConfiguration.java
new file mode 100644
index 0000000..f3caca1
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/WhisperFrontendConfiguration.java
@@ -0,0 +1,12 @@
+package se.su.dsv.whisperapi;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import java.nio.file.Path;
+
+@ConfigurationProperties(prefix = "whisper.frontend")
+public record WhisperFrontendConfiguration(
+ Path transcriptionFilesDirectory,
+ Path jobsDirectory)
+{
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/api/ApiController.java b/src/main/java/se/su/dsv/whisperapi/api/ApiController.java
new file mode 100644
index 0000000..bb52b75
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/api/ApiController.java
@@ -0,0 +1,279 @@
+package se.su.dsv.whisperapi.api;
+
+import io.swagger.v3.oas.annotations.ExternalDocumentation;
+import io.swagger.v3.oas.annotations.Hidden;
+import io.swagger.v3.oas.annotations.OpenAPIDefinition;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import org.springframework.core.io.InputStreamResource;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.ErrorResponseException;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.util.UriComponentsBuilder;
+import se.su.dsv.whisperapi.core.CreateTranscription;
+import se.su.dsv.whisperapi.core.JobAlreadyCompleted;
+import se.su.dsv.whisperapi.core.JobCompletion;
+import se.su.dsv.whisperapi.core.JobNotFound;
+import se.su.dsv.whisperapi.core.NoSuchSourceFile;
+import se.su.dsv.whisperapi.core.NotSubmittedForTranscription;
+import se.su.dsv.whisperapi.core.OutputFormat;
+import se.su.dsv.whisperapi.core.TranscribedResult;
+import se.su.dsv.whisperapi.core.Transcription;
+import se.su.dsv.whisperapi.core.SourceFileUpload;
+import se.su.dsv.whisperapi.core.TranscriptionJobFailed;
+import se.su.dsv.whisperapi.core.TranscriptionJobStillPending;
+import se.su.dsv.whisperapi.core.TranscriptionService;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.Paths;
+import java.security.Principal;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * For submitting jobs programmatically via a JSON HTTP API.
+ * The entry point for the API is {@code POST /api/transcriptions}.
+ *
+ * Transcribing files is a three-step process;
+ *
+ * - Create a transcription using {@code POST /api/transcriptions}
+ * - Attach source files to be transcribed by by {@code POST}ing to the {@code attach-source-file} relation link
+ * - Submit the transcription for processing using {@code PUT} on the {@code submit-job} relation link
+ *
+ *
+ * Once processing is completed, successful or not, your callback will be called with a {@code Callback}
+ */
+@OpenAPIDefinition(
+ security = @SecurityRequirement(name = "basicAuth"),
+ externalDocs = @ExternalDocumentation(description = "Wiki", url = "https://gitea.dsv.su.se/DMC/whisper-frontend/wiki")
+)
+@RestController
+@RequestMapping(consumes = "application/json", produces = "application/json")
+public class ApiController {
+ private static final System.Logger LOG = System.getLogger(ApiController.class.getName());
+
+ private final TranscriptionService transcriptionService;
+
+ public ApiController(TranscriptionService transcriptionService) {
+ this.transcriptionService = transcriptionService;
+ }
+
+ /**
+ * Create a new transcription job. The job will be created in a pending state and will not start processing until
+ * the source files have been attached and the job has been submitted.
+ * @param createTranscriptionRequest parameters of the new transcription
+ * @return the transcription job id and links to attach source files
+ * @see #submitTranscriptionJob(Principal, UriComponentsBuilder, String) Submitting the job for processing
+ * @see #uploadFileToBeTranscribed(Principal, String, String, UriComponentsBuilder, InputStream) Attach a source file to be transcribed
+ */
+ @PostMapping("/api/transcriptions")
+ public TranscriptionResponse submitTranscriptionJob(
+ Principal owner,
+ UriComponentsBuilder uriComponentsBuilder,
+ @RequestBody CreateTranscriptionRequest createTranscriptionRequest)
+ {
+ try {
+ if (createTranscriptionRequest.language() != null && createTranscriptionRequest.language().length() != 2) {
+ throw new InvalidLanguage(createTranscriptionRequest.language());
+ }
+ URI callbackUri = new URI(createTranscriptionRequest.callback());
+ OutputFormat outputFormat = parseOutputFormat(createTranscriptionRequest.outputFormat());
+ CreateTranscription createTranscription = new CreateTranscription(owner, callbackUri,
+ createTranscriptionRequest.language(), outputFormat);
+ Transcription transcription = transcriptionService.createTranscription(createTranscription);
+ URI attachSourceFile = createAttachSourceFileUri(uriComponentsBuilder, transcription);
+ return new TranscriptionResponse(transcription.id(), Map.of("attach-source-file", new Link(attachSourceFile)));
+ } catch (URISyntaxException e) {
+ throw new InvalidCallbackUri(createTranscriptionRequest.callback());
+ }
+ }
+
+ private static URI createAttachSourceFileUri(UriComponentsBuilder uriComponentsBuilder, Transcription transcription) {
+ return uriComponentsBuilder.cloneBuilder()
+ .pathSegment("api")
+ .pathSegment("transcriptions")
+ .pathSegment(transcription.id().toString())
+ .pathSegment("file")
+ .build()
+ .toUri();
+ }
+
+ private OutputFormat parseOutputFormat(String outputFormat) {
+ return switch (outputFormat) {
+ case "txt" -> OutputFormat.PLAIN_TEXT;
+ case "vtt" -> OutputFormat.VTT;
+ case "srt" -> OutputFormat.SRT;
+ case "tsv" -> OutputFormat.TSV;
+ case "json" -> OutputFormat.JSON;
+ default -> throw new InvalidOutputFormat(outputFormat);
+ };
+ }
+
+ /**
+ * Attach a file to be transcribed in the specified job. Multiple files can be attached to a single job.
+ * They must be unique based on filename, same filename will overwrite the existing file.
+ */
+ @PutMapping(value = "/api/transcriptions/{id}/file", consumes = "*/*")
+ public TranscriptionResponse uploadFileToBeTranscribed(
+ Principal owner,
+ @PathVariable("id") String id,
+ @RequestHeader("X-Filename") String filename,
+ UriComponentsBuilder uriComponentsBuilder,
+ InputStream fileData)
+ {
+ try {
+ if (filename == null || filename.isBlank()) {
+ throw new MissingFilename();
+ }
+ UUID uuid = UUID.fromString(id);
+ Transcription transcription = transcriptionService.getTranscription(owner, uuid)
+ .orElseThrow(() -> new TranscriptionNotFound(id));
+ transcriptionService.addFileToBeTranscribed(
+ transcription,
+ new SourceFileUpload(filename, fileData),
+ // The URI is generated here at upload since we need to be in a web context to generate it.
+ // It is likely that the source who uploaded is the same as who will download the result
+ // so using this as the web context seem like the best spot, the other option would be at
+ // submission time.
+ transcribedFileId -> uriComponentsBuilder.cloneBuilder()
+ .pathSegment("api")
+ .pathSegment("transcriptions")
+ .pathSegment(id)
+ .pathSegment("file")
+ .pathSegment(transcribedFileId.toString())
+ .pathSegment("result")
+ .build()
+ .toUri());
+ URI submitJobUri = uriComponentsBuilder.cloneBuilder()
+ .pathSegment("api")
+ .pathSegment("transcriptions")
+ .pathSegment(id)
+ .pathSegment("job")
+ .build()
+ .toUri();
+ return new TranscriptionResponse(uuid, Map.of(
+ "attach-source-file", new Link(createAttachSourceFileUri(uriComponentsBuilder, transcription)),
+ "submit-job", new Link(submitJobUri)));
+ } catch (IllegalArgumentException ignored) {
+ // Invalid UUID
+ throw new TranscriptionNotFound(id);
+ } catch (IOException e) {
+ LOG.log(System.Logger.Level.ERROR, "Failed to save file", e);
+ throw new FileUploadFailed();
+ }
+ }
+
+ /**
+ * Get the transcribed result for a given source file if it was transcribed successfully.
+ *
+ * @param owner owner of the transcription job
+ * @param transcriptionId id of the transcription job
+ * @param fileId id of the source file
+ * @return the transcribed result
+ */
+ @GetMapping(value = "/api/transcriptions/{transcriptionId}/file/{fileId}/result",
+ consumes = MediaType.ALL_VALUE,
+ produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
+ public ResponseEntity getTranscribedResult(
+ Principal owner,
+ @PathVariable("transcriptionId") String transcriptionId,
+ @PathVariable("fileId") String fileId)
+ {
+ // access control
+ Transcription transcription = transcriptionService.getTranscription(owner, UUID.fromString(transcriptionId))
+ .orElseThrow(() -> new TranscriptionNotFound(transcriptionId));
+
+ try {
+ TranscribedResult transcribedResult = transcriptionService.getTranscribedResult(
+ transcription,
+ UUID.fromString(fileId));
+ String extension = getExtensionForOutputFormat(transcription.outputFormat());
+ String filename = transcribedResult.sourceFile().filename() + "." + extension;
+ return ResponseEntity.ok()
+ .header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
+ .header("Content-Length", Long.toString(transcribedResult.size()))
+ .body(new InputStreamResource(transcribedResult.data()));
+ } catch (NotSubmittedForTranscription e) {
+ throw new NoTranscribedResultAvailable("The file has not been submitted for transcription");
+ } catch (TranscriptionJobStillPending e) {
+ throw new NoTranscribedResultAvailable("The file is still processing, try again later");
+ } catch (TranscriptionJobFailed e) {
+ throw new NoTranscribedResultAvailable("The transcription failed: " + e.errorMessage());
+ } catch (NoSuchSourceFile e) {
+ throw new SourceFileNotFound(e.transcription(), e.fileId());
+ } catch (IOException e) {
+ LOG.log(System.Logger.Level.ERROR, "Failed to get transcribed result", e);
+ throw new ErrorResponseException(HttpStatus.INTERNAL_SERVER_ERROR, e);
+ }
+ }
+
+ private String getExtensionForOutputFormat(OutputFormat outputFormat) {
+ return switch (outputFormat) {
+ case PLAIN_TEXT -> "txt";
+ case VTT -> "vtt";
+ case SRT -> "srt";
+ case TSV -> "tsv";
+ case JSON -> "json";
+ };
+ }
+
+ /**
+ * Submit the job for processing.
+ */
+ @PostMapping(value = "/api/transcriptions/{id}/job", consumes = "*/*")
+ public ResponseEntity submitTranscriptionJob(
+ Principal owner,
+ UriComponentsBuilder uriComponentsBuilder,
+ @PathVariable("id") String id)
+ {
+ UUID uuid = UUID.fromString(id);
+ Transcription transcription = transcriptionService.getTranscription(owner, uuid)
+ .orElseThrow(() -> new TranscriptionNotFound(id));
+ try {
+ transcriptionService.submitTranscriptionJob(transcription, jobId -> uriComponentsBuilder.cloneBuilder()
+ .path("api")
+ .pathSegment("transcriptions")
+ .pathSegment("job")
+ .pathSegment("callback")
+ .pathSegment(jobId.toString())
+ .build()
+ .toUri());
+ return ResponseEntity.accepted().build();
+ } catch (IOException e) {
+ LOG.log(System.Logger.Level.ERROR, "Failed to submit job", e);
+ throw new JobSubmissionFailed(e);
+ }
+ }
+
+ @Hidden
+ @PostMapping("/api/transcriptions/job/callback/{jobId}")
+ public ResponseEntity jobCallback(
+ @PathVariable("jobId") String jobId,
+ @RequestBody JobCallbackResponse jobCallbackResponse)
+ {
+ try {
+ JobCompletion jobCompletion = switch (jobCallbackResponse) {
+
+ case JobCallbackResponse.Failure(String errorMessage) -> new JobCompletion.Failure(errorMessage);
+ case JobCallbackResponse.Success(String resultFile) -> new JobCompletion.Success(Paths.get(resultFile));
+ };
+ transcriptionService.markJobAsCompleted(UUID.fromString(jobId), jobCompletion);
+ } catch (JobAlreadyCompleted | JobNotFound ignored) {
+ // do nothing
+ // since the callback is public we give no details to the caller
+ // every known error is treated as 200 OK
+ }
+ return ResponseEntity.ok().build();
+ }
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/api/CreateTranscriptionRequest.java b/src/main/java/se/su/dsv/whisperapi/api/CreateTranscriptionRequest.java
new file mode 100644
index 0000000..8a69717
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/api/CreateTranscriptionRequest.java
@@ -0,0 +1,10 @@
+package se.su.dsv.whisperapi.api;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public record CreateTranscriptionRequest(
+ @JsonProperty(value = "callback", required = true) String callback,
+ @JsonProperty(value = "language") String language,
+ @JsonProperty(value = "outputformat", required = true) String outputFormat)
+{
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/api/FileUploadFailed.java b/src/main/java/se/su/dsv/whisperapi/api/FileUploadFailed.java
new file mode 100644
index 0000000..b0eaea8
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/api/FileUploadFailed.java
@@ -0,0 +1,14 @@
+package se.su.dsv.whisperapi.api;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.ErrorResponseException;
+
+import java.net.URI;
+
+public class FileUploadFailed extends ErrorResponseException {
+ public FileUploadFailed() {
+ super(HttpStatus.INTERNAL_SERVER_ERROR);
+ setType(URI.create("https://gitea.dsv.su.se/DMC/whisper-frontend/wiki/Errors#file-upload-failed"));
+ setTitle("File upload failed");
+ }
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/api/InvalidCallbackUri.java b/src/main/java/se/su/dsv/whisperapi/api/InvalidCallbackUri.java
new file mode 100644
index 0000000..d91ce45
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/api/InvalidCallbackUri.java
@@ -0,0 +1,15 @@
+package se.su.dsv.whisperapi.api;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.ErrorResponseException;
+
+import java.net.URI;
+
+public class InvalidCallbackUri extends ErrorResponseException {
+ public InvalidCallbackUri(String callbackUri) {
+ super(HttpStatus.BAD_REQUEST);
+ setTitle("Invalid callback URI");
+ setType(URI.create("https://gitea.dsv.su.se/DMC/whisper-frontend/wiki/Errors#invalid-callback-uri"));
+ setDetail("The callback '" + callbackUri + "' is not a valid URI");
+ }
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/api/InvalidLanguage.java b/src/main/java/se/su/dsv/whisperapi/api/InvalidLanguage.java
new file mode 100644
index 0000000..c2ad5d4
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/api/InvalidLanguage.java
@@ -0,0 +1,15 @@
+package se.su.dsv.whisperapi.api;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.ErrorResponseException;
+
+import java.net.URI;
+
+public class InvalidLanguage extends ErrorResponseException {
+ public InvalidLanguage(String language) {
+ super(HttpStatus.BAD_REQUEST);
+ setType(URI.create("https://gitea.dsv.su.se/DMC/whisper-frontend/wiki/Errors#invalid-language"));
+ setTitle("Invalid language");
+ setDetail("The language '" + language + "' is not supported, it must be a valid two-letter ISO-639-1 code.");
+ }
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/api/InvalidOutputFormat.java b/src/main/java/se/su/dsv/whisperapi/api/InvalidOutputFormat.java
new file mode 100644
index 0000000..53ef59a
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/api/InvalidOutputFormat.java
@@ -0,0 +1,15 @@
+package se.su.dsv.whisperapi.api;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.ErrorResponseException;
+
+import java.net.URI;
+
+public class InvalidOutputFormat extends ErrorResponseException {
+ public InvalidOutputFormat(String outputFormat) {
+ super(HttpStatus.BAD_REQUEST);
+ setType(URI.create("https://gitea.dsv.su.se/DMC/whisper-frontend/wiki/Errors#invalid-output-format"));
+ setTitle("Invalid output format");
+ setDetail("The output format '" + outputFormat + "' is not supported.");
+ }
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/api/JobCallbackResponse.java b/src/main/java/se/su/dsv/whisperapi/api/JobCallbackResponse.java
new file mode 100644
index 0000000..e97f98a
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/api/JobCallbackResponse.java
@@ -0,0 +1,22 @@
+package se.su.dsv.whisperapi.api;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+
+@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "result")
+@JsonSubTypes({
+ @JsonSubTypes.Type(value = JobCallbackResponse.Success.class, name = "Success"),
+ @JsonSubTypes.Type(value = JobCallbackResponse.Failure.class, name = "Failure")
+})
+public sealed interface JobCallbackResponse {
+ record Success(
+ @JsonProperty(value = "resultfile", required = true) String resultFileAbsolutePath)
+ implements JobCallbackResponse {
+ }
+
+ record Failure(
+ @JsonProperty(value = "errormessage", required = true) String errorMessage)
+ implements JobCallbackResponse {
+ }
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/api/JobSubmissionFailed.java b/src/main/java/se/su/dsv/whisperapi/api/JobSubmissionFailed.java
new file mode 100644
index 0000000..7b24ee8
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/api/JobSubmissionFailed.java
@@ -0,0 +1,14 @@
+package se.su.dsv.whisperapi.api;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.ErrorResponseException;
+
+import java.net.URI;
+
+public class JobSubmissionFailed extends ErrorResponseException {
+ public JobSubmissionFailed(Throwable cause) {
+ super(HttpStatus.INTERNAL_SERVER_ERROR, cause);
+ setType(URI.create("https://gitea.dsv.su.se/DMC/whisper-frontend/wiki/Errors#job-submission-failed"));
+ setTitle("Job submission failed");
+ }
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/api/Link.java b/src/main/java/se/su/dsv/whisperapi/api/Link.java
new file mode 100644
index 0000000..b7ceb08
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/api/Link.java
@@ -0,0 +1,7 @@
+package se.su.dsv.whisperapi.api;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.net.URI;
+
+public record Link(@JsonProperty(value = "href", required = true) URI href) {}
diff --git a/src/main/java/se/su/dsv/whisperapi/api/MissingFilename.java b/src/main/java/se/su/dsv/whisperapi/api/MissingFilename.java
new file mode 100644
index 0000000..524869b
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/api/MissingFilename.java
@@ -0,0 +1,15 @@
+package se.su.dsv.whisperapi.api;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.ErrorResponseException;
+
+import java.net.URI;
+
+public class MissingFilename extends ErrorResponseException {
+ public MissingFilename() {
+ super(HttpStatus.BAD_REQUEST);
+ setType(URI.create("https://gitea.dsv.su.se/DMC/whisper-frontend/wiki/Errors#missing-filename"));
+ setTitle("Missing filename");
+ setDetail("A filename must be specified in the X-Filename header");
+ }
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/api/NoTranscribedResultAvailable.java b/src/main/java/se/su/dsv/whisperapi/api/NoTranscribedResultAvailable.java
new file mode 100644
index 0000000..6a1648e
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/api/NoTranscribedResultAvailable.java
@@ -0,0 +1,15 @@
+package se.su.dsv.whisperapi.api;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.ErrorResponseException;
+
+import java.net.URI;
+
+public class NoTranscribedResultAvailable extends ErrorResponseException {
+ public NoTranscribedResultAvailable(String detail) {
+ super(HttpStatus.CONFLICT);
+ setType(URI.create("https://gitea.dsv.su.se/DMC/whisper-frontend/wiki/Errors#no-transcribed-result-available"));
+ setTitle("No transcribed result available");
+ setDetail(detail);
+ }
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/api/SourceFileNotFound.java b/src/main/java/se/su/dsv/whisperapi/api/SourceFileNotFound.java
new file mode 100644
index 0000000..683288d
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/api/SourceFileNotFound.java
@@ -0,0 +1,17 @@
+package se.su.dsv.whisperapi.api;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.ErrorResponseException;
+import se.su.dsv.whisperapi.core.Transcription;
+
+import java.net.URI;
+import java.util.UUID;
+
+public class SourceFileNotFound extends ErrorResponseException {
+ public SourceFileNotFound(Transcription transcription, UUID uuid) {
+ super(HttpStatus.BAD_REQUEST);
+ setType(URI.create("https://gitea.dsv.su.se/DMC/whisper-frontend/wiki/Errors#transcription-file-not-found"));
+ setTitle("Transcription source file not found");
+ setDetail("No file with id '" + uuid + "' exists in transcription with id '" + transcription.id() + "'");
+ }
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/api/TranscriptionNotFound.java b/src/main/java/se/su/dsv/whisperapi/api/TranscriptionNotFound.java
new file mode 100644
index 0000000..a725281
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/api/TranscriptionNotFound.java
@@ -0,0 +1,15 @@
+package se.su.dsv.whisperapi.api;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.ErrorResponseException;
+
+import java.net.URI;
+
+public class TranscriptionNotFound extends ErrorResponseException {
+ public TranscriptionNotFound(String id) {
+ super(HttpStatus.BAD_REQUEST);
+ setType(URI.create("https://gitea.dsv.su.se/DMC/whisper-frontend/wiki/Errors#transcription-not-found"));
+ setTitle("Transcription not found");
+ setDetail("Transcription with id '" + id + "' not found");
+ }
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/api/TranscriptionResponse.java b/src/main/java/se/su/dsv/whisperapi/api/TranscriptionResponse.java
new file mode 100644
index 0000000..08d19cc
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/api/TranscriptionResponse.java
@@ -0,0 +1,12 @@
+package se.su.dsv.whisperapi.api;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.Map;
+import java.util.UUID;
+
+public record TranscriptionResponse(
+ @JsonProperty(value = "id", required = true) UUID id,
+ @JsonProperty(value = "links", required = true) Map links)
+{
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/core/Callback.java b/src/main/java/se/su/dsv/whisperapi/core/Callback.java
new file mode 100644
index 0000000..ff91d91
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/core/Callback.java
@@ -0,0 +1,29 @@
+package se.su.dsv.whisperapi.core;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+
+import java.util.List;
+import java.util.UUID;
+
+public record Callback(
+ @JsonProperty("transcription_id") UUID transcriptionId,
+ @JsonProperty("files") List files)
+{
+ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "result")
+ @JsonSubTypes({
+ @JsonSubTypes.Type(value = File.Transcribed.class, name = "success"),
+ @JsonSubTypes.Type(value = File.Failed.class, name = "failure")
+ })
+ sealed interface File {
+ record Transcribed(
+ @JsonProperty("original_file_name") String originalFilename,
+ @JsonProperty("transcription_download_link") String transcriptionDownloadLink)
+ implements File {}
+ record Failed(
+ @JsonProperty("original_file_name") String originalFilename,
+ @JsonProperty("error_message") String errorMessage)
+ implements File {}
+ }
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/core/CallbackUriGenerator.java b/src/main/java/se/su/dsv/whisperapi/core/CallbackUriGenerator.java
new file mode 100644
index 0000000..68c22f5
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/core/CallbackUriGenerator.java
@@ -0,0 +1,8 @@
+package se.su.dsv.whisperapi.core;
+
+import java.net.URI;
+import java.util.UUID;
+
+public interface CallbackUriGenerator {
+ URI generateCallbackUri(UUID jobId);
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/core/CreateTranscription.java b/src/main/java/se/su/dsv/whisperapi/core/CreateTranscription.java
new file mode 100644
index 0000000..8074f38
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/core/CreateTranscription.java
@@ -0,0 +1,7 @@
+package se.su.dsv.whisperapi.core;
+
+import java.net.URI;
+import java.security.Principal;
+
+public record CreateTranscription(Principal owner, URI callbackUri, String language, OutputFormat outputFormat) {
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/core/Job.java b/src/main/java/se/su/dsv/whisperapi/core/Job.java
new file mode 100644
index 0000000..08f4b01
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/core/Job.java
@@ -0,0 +1,17 @@
+package se.su.dsv.whisperapi.core;
+
+import java.util.UUID;
+
+public record Job(UUID id, Status status, SourceFile sourceFile) {
+ public sealed interface Status {
+ record Pending() implements Status {}
+ record Completed(JobCompletion completion) implements Status {}
+ }
+
+ public boolean isCompleted() {
+ return switch (status) {
+ case Status.Completed(var ignored) -> true;
+ case Status.Pending() -> false;
+ };
+ }
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/core/JobAlreadyCompleted.java b/src/main/java/se/su/dsv/whisperapi/core/JobAlreadyCompleted.java
new file mode 100644
index 0000000..e42d6c2
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/core/JobAlreadyCompleted.java
@@ -0,0 +1,13 @@
+package se.su.dsv.whisperapi.core;
+
+public class JobAlreadyCompleted extends Throwable {
+ private final Job job;
+
+ JobAlreadyCompleted(Job job) {
+ this.job = job;
+ }
+
+ public Job job() {
+ return job;
+ }
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/core/JobCompletion.java b/src/main/java/se/su/dsv/whisperapi/core/JobCompletion.java
new file mode 100644
index 0000000..cd809a9
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/core/JobCompletion.java
@@ -0,0 +1,12 @@
+package se.su.dsv.whisperapi.core;
+
+import java.nio.file.Path;
+
+public sealed interface JobCompletion {
+ record Success(Path resultFileAbsolutePath) implements JobCompletion {}
+
+ /**
+ * @param errorMessage intended for end users but will probably be highly technical in nature
+ */
+ record Failure(String errorMessage) implements JobCompletion {}
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/core/JobNotFound.java b/src/main/java/se/su/dsv/whisperapi/core/JobNotFound.java
new file mode 100644
index 0000000..79f5fb8
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/core/JobNotFound.java
@@ -0,0 +1,15 @@
+package se.su.dsv.whisperapi.core;
+
+import java.util.UUID;
+
+public class JobNotFound extends Exception {
+ private final UUID jobId;
+
+ JobNotFound(UUID jobId) {
+ this.jobId = jobId;
+ }
+
+ public UUID jobId() {
+ return jobId;
+ }
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/core/NoSuchSourceFile.java b/src/main/java/se/su/dsv/whisperapi/core/NoSuchSourceFile.java
new file mode 100644
index 0000000..3bd7a76
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/core/NoSuchSourceFile.java
@@ -0,0 +1,21 @@
+package se.su.dsv.whisperapi.core;
+
+import java.util.UUID;
+
+public class NoSuchSourceFile extends Exception {
+ private final Transcription transcription;
+ private final UUID fileId;
+
+ public NoSuchSourceFile(Transcription transcription, UUID fileId) {
+ this.transcription = transcription;
+ this.fileId = fileId;
+ }
+
+ public Transcription transcription() {
+ return transcription;
+ }
+
+ public UUID fileId() {
+ return fileId;
+ }
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/core/NotSubmittedForTranscription.java b/src/main/java/se/su/dsv/whisperapi/core/NotSubmittedForTranscription.java
new file mode 100644
index 0000000..63aa2d9
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/core/NotSubmittedForTranscription.java
@@ -0,0 +1,6 @@
+package se.su.dsv.whisperapi.core;
+
+public class NotSubmittedForTranscription extends Exception {
+ public NotSubmittedForTranscription() {
+ }
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/core/NotificationStatus.java b/src/main/java/se/su/dsv/whisperapi/core/NotificationStatus.java
new file mode 100644
index 0000000..e7906ab
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/core/NotificationStatus.java
@@ -0,0 +1,8 @@
+package se.su.dsv.whisperapi.core;
+
+import java.time.Instant;
+
+public sealed interface NotificationStatus {
+ record Never() implements NotificationStatus {}
+ record Failed(Instant lastAttempt, int numberOfAttempts) implements NotificationStatus {}
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/core/OutputFormat.java b/src/main/java/se/su/dsv/whisperapi/core/OutputFormat.java
new file mode 100644
index 0000000..f732a8e
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/core/OutputFormat.java
@@ -0,0 +1,9 @@
+package se.su.dsv.whisperapi.core;
+
+public enum OutputFormat {
+ PLAIN_TEXT,
+ VTT,
+ SRT,
+ TSV,
+ JSON
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/core/SourceFile.java b/src/main/java/se/su/dsv/whisperapi/core/SourceFile.java
new file mode 100644
index 0000000..a0af2c0
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/core/SourceFile.java
@@ -0,0 +1,11 @@
+package se.su.dsv.whisperapi.core;
+
+import java.net.URI;
+import java.util.UUID;
+
+/**
+ * @param filename the user provided file name
+ * @param downloadUri a pre-generated download link for any potential future transcription result
+ */
+public record SourceFile(UUID id, String filename, URI downloadUri) {
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/core/SourceFileUpload.java b/src/main/java/se/su/dsv/whisperapi/core/SourceFileUpload.java
new file mode 100644
index 0000000..c5772f5
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/core/SourceFileUpload.java
@@ -0,0 +1,6 @@
+package se.su.dsv.whisperapi.core;
+
+import java.io.InputStream;
+
+public record SourceFileUpload(String filename, InputStream data) {
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/core/TranscribedFileDownloadUriGenerator.java b/src/main/java/se/su/dsv/whisperapi/core/TranscribedFileDownloadUriGenerator.java
new file mode 100644
index 0000000..93177f0
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/core/TranscribedFileDownloadUriGenerator.java
@@ -0,0 +1,9 @@
+package se.su.dsv.whisperapi.core;
+
+import java.net.URI;
+import java.util.UUID;
+
+@FunctionalInterface
+public interface TranscribedFileDownloadUriGenerator {
+ URI generateDownloadUri(UUID transcribedFileId);
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/core/TranscribedResult.java b/src/main/java/se/su/dsv/whisperapi/core/TranscribedResult.java
new file mode 100644
index 0000000..fab7264
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/core/TranscribedResult.java
@@ -0,0 +1,6 @@
+package se.su.dsv.whisperapi.core;
+
+import java.io.InputStream;
+
+public record TranscribedResult(SourceFile sourceFile, long size, InputStream data) {
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/core/Transcription.java b/src/main/java/se/su/dsv/whisperapi/core/Transcription.java
new file mode 100644
index 0000000..3e08ee3
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/core/Transcription.java
@@ -0,0 +1,8 @@
+package se.su.dsv.whisperapi.core;
+
+import java.net.URI;
+import java.security.Principal;
+import java.util.UUID;
+
+public record Transcription(UUID id, Principal owner, URI callbackUri, String language, OutputFormat outputFormat) {
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/core/TranscriptionJobFailed.java b/src/main/java/se/su/dsv/whisperapi/core/TranscriptionJobFailed.java
new file mode 100644
index 0000000..15b492f
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/core/TranscriptionJobFailed.java
@@ -0,0 +1,13 @@
+package se.su.dsv.whisperapi.core;
+
+public class TranscriptionJobFailed extends Exception {
+ private final String errorMessage;
+
+ public TranscriptionJobFailed(String errorMessage) {
+ this.errorMessage = errorMessage;
+ }
+
+ public String errorMessage() {
+ return errorMessage;
+ }
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/core/TranscriptionJobStillPending.java b/src/main/java/se/su/dsv/whisperapi/core/TranscriptionJobStillPending.java
new file mode 100644
index 0000000..8d4aa8a
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/core/TranscriptionJobStillPending.java
@@ -0,0 +1,6 @@
+package se.su.dsv.whisperapi.core;
+
+public class TranscriptionJobStillPending extends Exception {
+ public TranscriptionJobStillPending() {
+ }
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/core/TranscriptionRepository.java b/src/main/java/se/su/dsv/whisperapi/core/TranscriptionRepository.java
new file mode 100644
index 0000000..d7e47d7
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/core/TranscriptionRepository.java
@@ -0,0 +1,44 @@
+package se.su.dsv.whisperapi.core;
+
+import java.security.Principal;
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+public interface TranscriptionRepository {
+ void save(Transcription transcription);
+
+ Optional findByOwnerAndId(Principal owner, UUID uuid);
+
+ void addFileToTranscription(Transcription transcription, SourceFile sourceFile);
+
+ /**
+ * @return all the files that have been {@link #addFileToTranscription added}
+ * to the transcription.
+ */
+ List getFiles(Transcription transcription);
+
+ /**
+ * @param transcription the transcription this specific job is a part of
+ */
+ void createNewJob(Transcription transcription, Job job);
+
+ Optional findJobById(UUID jobId);
+
+ void setJobCompleted(Job job, JobCompletion jobCompletion);
+
+ List getProcessingTranscriptions();
+
+ List getJobs(Transcription transcription);
+
+ NotificationStatus getNotificationStatus(Transcription transcription);
+
+ void markAsCompleted(Transcription transcription);
+
+ void increaseFailureCount(Transcription transcription, Instant now);
+
+ Optional getFile(Transcription transcription, UUID fileId);
+
+ Optional findJobBySourceFile(SourceFile sourceFile);
+}
diff --git a/src/main/java/se/su/dsv/whisperapi/core/TranscriptionService.java b/src/main/java/se/su/dsv/whisperapi/core/TranscriptionService.java
new file mode 100644
index 0000000..627bd2f
--- /dev/null
+++ b/src/main/java/se/su/dsv/whisperapi/core/TranscriptionService.java
@@ -0,0 +1,223 @@
+package se.su.dsv.whisperapi.core;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.security.Principal;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+public class TranscriptionService {
+ private static final System.Logger LOGGER = System.getLogger(TranscriptionService.class.getName());
+ private static final Duration INITIAL_NOTIFICATION_DELAY = Duration.ofMinutes(15);
+
+ private final TranscriptionRepository transcriptionRepository;
+ private final Path fileDirectory;
+ private final Path jobsDirectory;
+
+ public TranscriptionService(TranscriptionRepository transcriptionRepository, Path fileDirectory, Path jobsDirectory) {
+ this.transcriptionRepository = transcriptionRepository;
+ this.fileDirectory = fileDirectory;
+ this.jobsDirectory = jobsDirectory;
+ }
+
+
+ public Transcription createTranscription(CreateTranscription createTranscription) {
+ UUID id = UUID.randomUUID();
+ Transcription transcription = new Transcription(
+ id,
+ createTranscription.owner(),
+ createTranscription.callbackUri(),
+ createTranscription.language(),
+ createTranscription.outputFormat());
+ transcriptionRepository.save(transcription);
+ return transcription;
+ }
+
+ public Optional getTranscription(Principal owner, UUID uuid) {
+ return transcriptionRepository.findByOwnerAndId(owner, uuid);
+ }
+
+ public void addFileToBeTranscribed(
+ Transcription transcription,
+ SourceFileUpload file,
+ TranscribedFileDownloadUriGenerator downloadUriGenerator)
+ throws IOException
+ {
+ UUID uuid = UUID.randomUUID();
+ URI downloadUri = downloadUriGenerator.generateDownloadUri(uuid);
+ Path fileToBeTranscribed = fileDirectory.resolve(transcription.id().toString()).resolve(uuid.toString());
+ Files.createDirectories(fileToBeTranscribed.getParent());
+ try (var out = Files.newOutputStream(fileToBeTranscribed, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
+ var in = file.data())
+ {
+ in.transferTo(out);
+ SourceFile sourceFile = new SourceFile(uuid, file.filename(), downloadUri);
+ transcriptionRepository.addFileToTranscription(transcription, sourceFile);
+ }
+ }
+
+ public void submitTranscriptionJob(Transcription transcription, CallbackUriGenerator callbackUriGenerator)
+ throws IOException
+ {
+ Files.createDirectories(jobsDirectory);
+ ObjectMapper objectMapper = new ObjectMapper();
+ objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
+ List files = transcriptionRepository.getFiles(transcription);
+ for (SourceFile file : files) {
+ UUID jobId = UUID.randomUUID();
+ Path fileToBeTranscribed = fileDirectory.resolve(transcription.id().toString()).resolve(file.id().toString());
+ Path jobFile = jobsDirectory.resolve(jobId + ".json");
+ URI callbackUri = callbackUriGenerator.generateCallbackUri(jobId);
+
+ record WhisperJob(
+ @JsonProperty("jobfile") String absolutePathToFileToBeTranscribed,
+ @JsonProperty("language") String language,
+ @JsonProperty("outputformat") String outputFormat,
+ @JsonProperty("origin") String origin,
+ @JsonProperty("callback") String callbackUri)
+ {
+ }
+ WhisperJob whisperJob = new WhisperJob(
+ fileToBeTranscribed.toAbsolutePath().toString(),
+ transcription.language(),
+ toWhisperFormat(transcription.outputFormat()),
+ transcription.owner().getName(),
+ callbackUri.toString());
+
+ try (var out = Files.newOutputStream(jobFile, StandardOpenOption.CREATE_NEW)) {
+ objectMapper.writeValue(out, whisperJob);
+ Job job = new Job(jobId, new Job.Status.Pending(), file);
+ transcriptionRepository.createNewJob(transcription, job);
+ }
+ }
+ }
+
+ private static String toWhisperFormat(OutputFormat outputFormat) {
+ return switch (outputFormat) {
+ case PLAIN_TEXT -> "text";
+ case VTT -> "vtt";
+ case SRT -> "srt";
+ case TSV -> "tsv";
+ case JSON -> "json";
+ };
+ }
+
+ public void markJobAsCompleted(UUID jobId, JobCompletion jobCompletion)
+ throws JobNotFound, JobAlreadyCompleted
+ {
+ Job job = transcriptionRepository.findJobById(jobId)
+ .orElseThrow(() -> new JobNotFound(jobId));
+ if (job.isCompleted()) {
+ throw new JobAlreadyCompleted(job);
+ }
+ transcriptionRepository.setJobCompleted(job, jobCompletion);
+ }
+
+ public void checkForCompletedTranscriptions() {
+ Instant now = Instant.now();
+ List processing = transcriptionRepository.getProcessingTranscriptions();
+ for (Transcription transcription : processing) {
+ List jobs = transcriptionRepository.getJobs(transcription);
+ boolean allJobsCompleted = jobs.stream()
+ .allMatch(Job::isCompleted);
+ if (allJobsCompleted && shouldNotifyOwner(transcription, now)) {
+ boolean notificationSuccessful = notifyOwner(transcription, jobs);
+ if (notificationSuccessful) {
+ markTranscriptionAsCompleted(transcription);
+ }
+ else {
+ increaseFailureCount(transcription, now);
+ }
+ }
+ }
+ }
+
+ private boolean shouldNotifyOwner(Transcription transcription, Instant now) {
+ NotificationStatus notificationStatus = transcriptionRepository.getNotificationStatus(transcription);
+ return switch (notificationStatus) {
+ case NotificationStatus.Never() -> true;
+ case NotificationStatus.Failed(Instant lastAttempt, int numberOfAttempts) -> {
+ int delayMultiplier = (int) Math.pow(2, numberOfAttempts - 1); // double the delay each time
+ Duration delay = INITIAL_NOTIFICATION_DELAY.multipliedBy(delayMultiplier);
+ yield now.isAfter(lastAttempt.plus(delay));
+ }
+ };
+ }
+
+ private boolean notifyOwner(final Transcription transcription, List jobs) {
+ URI callbackUri = transcription.callbackUri();
+ List files = jobs.stream()
+ .map(job -> {
+ SourceFile sourceFile = job.sourceFile();
+ switch (job.status()) {
+ case Job.Status.Completed(JobCompletion.Success ignored) -> {
+ return new Callback.File.Transcribed(sourceFile.filename(), sourceFile.downloadUri().toString());
+ }
+ case Job.Status.Completed(JobCompletion.Failure(String errorMessage)) -> {
+ return new Callback.File.Failed(sourceFile.filename(), errorMessage);
+ }
+ case Job.Status.Pending() -> throw new IllegalStateException("Job should be completed");
+ }
+ })
+ .toList();
+ Callback callback = new Callback(transcription.id(), files);
+ ObjectMapper objectMapper = new ObjectMapper();
+ try (HttpClient client = HttpClient.newHttpClient()) {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(callbackUri)
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(callback)))
+ .build();
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
+ return response.statusCode() == 200;
+ } catch (IOException | InterruptedException e) {
+ Thread.currentThread().interrupt();
+ LOGGER.log(System.Logger.Level.ERROR, "Failed to notify owner", e);
+ return false;
+ }
+ }
+
+ private void increaseFailureCount(Transcription transcription, Instant now) {
+ transcriptionRepository.increaseFailureCount(transcription, now);
+ }
+
+ private void markTranscriptionAsCompleted(Transcription transcription) {
+ transcriptionRepository.markAsCompleted(transcription);
+ }
+
+ public TranscribedResult getTranscribedResult(Transcription transcription, UUID fileId)
+ throws
+ NotSubmittedForTranscription,
+ TranscriptionJobStillPending,
+ TranscriptionJobFailed,
+ IOException,
+ NoSuchSourceFile
+ {
+ SourceFile sourceFile = transcriptionRepository.getFile(transcription, fileId)
+ .orElseThrow(() -> new NoSuchSourceFile(transcription, fileId));
+ Job job = transcriptionRepository.findJobBySourceFile(sourceFile)
+ .orElseThrow(() -> new NotSubmittedForTranscription());
+
+ return switch (job.status()) {
+ case Job.Status.Completed(JobCompletion.Success(Path resultFile)) ->
+ new TranscribedResult(sourceFile, Files.size(resultFile), Files.newInputStream(resultFile));
+ case Job.Status.Completed(JobCompletion.Failure(String errorMessage)) ->
+ throw new TranscriptionJobFailed(errorMessage);
+ case Job.Status.Pending ignored ->
+ throw new TranscriptionJobStillPending();
+ };
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 8f550ea..efae7df 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -12,3 +12,9 @@ spring.security.oauth2.client.provider.su.authorization-uri=${OAUTH2_AUTH_URI}
spring.security.oauth2.client.provider.su.token-uri=${OAUTH2_TOKEN_URI}
spring.security.oauth2.client.provider.su.user-info-uri=${OAUTH2_USER_INFO_URI}
spring.security.oauth2.client.provider.su.user-name-attribute=sub
+spring.mvc.problemdetails.enabled=true
+# Disable logging of resolved exceptions or every ErrorResponseException (ProblemDetails)
+# that is correctly handled will be logged as a warning
+spring.mvc.log-resolved-exception=false
+whisper.frontend.transcription-files-directory=${java.io.tmpdir}/whisper/transcriptions
+whisper.frontend.jobs-directory=${java.io.tmpdir}/whisper/jobs
diff --git a/src/main/resources/db/migration/V1__transcriptions.sql b/src/main/resources/db/migration/V1__transcriptions.sql
new file mode 100644
index 0000000..22543c4
--- /dev/null
+++ b/src/main/resources/db/migration/V1__transcriptions.sql
@@ -0,0 +1,8 @@
+CREATE TABLE transcriptions (
+ id UUID NOT NULL,
+ owner VARCHAR(255) NOT NULL,
+ callback_uri VARCHAR(255),
+ output_format VARCHAR(32) NOT NULL,
+ PRIMARY KEY (id),
+ INDEX I_transcriptions_owner (owner)
+);
diff --git a/src/main/resources/db/migration/V2__transcriptions_files.sql b/src/main/resources/db/migration/V2__transcriptions_files.sql
new file mode 100644
index 0000000..d89636d
--- /dev/null
+++ b/src/main/resources/db/migration/V2__transcriptions_files.sql
@@ -0,0 +1,7 @@
+CREATE TABLE transcriptions_files (
+ transcription_id UUID NOT NULL,
+ filename VARCHAR(255) NOT NULL,
+ PRIMARY KEY (transcription_id, filename),
+ CONSTRAINT FK_transcriptions_files_transcription
+ FOREIGN KEY (transcription_id) REFERENCES transcriptions (id) ON DELETE CASCADE
+);
diff --git a/src/main/resources/db/migration/V3__jobs.sql b/src/main/resources/db/migration/V3__jobs.sql
new file mode 100644
index 0000000..86c2273
--- /dev/null
+++ b/src/main/resources/db/migration/V3__jobs.sql
@@ -0,0 +1,9 @@
+CREATE TABLE jobs (
+ id UUID NOT NULL,
+ transcription_id UUID NOT NULL,
+ result_file_absolute_path VARCHAR(255),
+ error_message TEXT,
+ PRIMARY KEY (id),
+ CONSTRAINT FK_jobs_transcription
+ FOREIGN KEY (transcription_id) REFERENCES transcriptions(id)
+);
diff --git a/src/main/resources/db/migration/V4__notification.sql b/src/main/resources/db/migration/V4__notification.sql
new file mode 100644
index 0000000..9ed8ecc
--- /dev/null
+++ b/src/main/resources/db/migration/V4__notification.sql
@@ -0,0 +1,4 @@
+ALTER TABLE transcriptions
+ ADD COLUMN notification_success BOOLEAN NOT NULL DEFAULT FALSE,
+ ADD COLUMN last_notification_time DATETIME DEFAULT NULL,
+ ADD COLUMN notification_attempts INT NOT NULL DEFAULT 0;
diff --git a/src/main/resources/db/migration/V5__download_uri_for_transcribed_result.sql b/src/main/resources/db/migration/V5__download_uri_for_transcribed_result.sql
new file mode 100644
index 0000000..1046388
--- /dev/null
+++ b/src/main/resources/db/migration/V5__download_uri_for_transcribed_result.sql
@@ -0,0 +1,7 @@
+ALTER TABLE transcriptions_files
+ ADD COLUMN id UUID NOT NULL FIRST,
+ ADD COLUMN transcribed_result_uri VARCHAR(255) NOT NULL AFTER filename;
+
+ALTER TABLE transcriptions_files ADD INDEX FK_transcriptions_files_transcription (transcription_id);
+ALTER TABLE transcriptions_files DROP PRIMARY KEY;
+ALTER TABLE transcriptions_files ADD PRIMARY KEY (id);
diff --git a/src/main/resources/db/migration/V6__job_source_file.sql b/src/main/resources/db/migration/V6__job_source_file.sql
new file mode 100644
index 0000000..6a90133
--- /dev/null
+++ b/src/main/resources/db/migration/V6__job_source_file.sql
@@ -0,0 +1,6 @@
+ALTER TABLE `jobs`
+ ADD COLUMN `source_file_id` UUID NOT NULL;
+
+ALTER TABLE `jobs`
+ ADD CONSTRAINT `FK_jobs_transcription_files_source_file`
+ FOREIGN KEY (`source_file_id`) REFERENCES `transcriptions_files` (`id`);
diff --git a/src/main/resources/db/migration/V7__language.sql b/src/main/resources/db/migration/V7__language.sql
new file mode 100644
index 0000000..feefa06
--- /dev/null
+++ b/src/main/resources/db/migration/V7__language.sql
@@ -0,0 +1,2 @@
+ALTER TABLE `transcriptions`
+ ADD COLUMN `language` CHAR(2);