diff --git a/pom.xml b/pom.xml index 6f429a9..c6898b3 100644 --- a/pom.xml +++ b/pom.xml @@ -42,6 +42,16 @@ org.flywaydb flyway-mysql + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.6.0 + + + com.github.therapi + therapi-runtime-javadoc + 0.15.0 + org.springframework.boot @@ -82,6 +92,23 @@ org.springframework.boot spring-boot-maven-plugin + + org.apache.maven.plugins + maven-compiler-plugin + + + + com.github.therapi + therapi-runtime-javadoc-scribe + 0.15.0 + + + org.springframework.boot + spring-boot-configuration-processor + + + + diff --git a/src/main/java/se/su/dsv/whisperapi/JDBCTranscriptionRepository.java b/src/main/java/se/su/dsv/whisperapi/JDBCTranscriptionRepository.java new file mode 100644 index 0000000..749b53f --- /dev/null +++ b/src/main/java/se/su/dsv/whisperapi/JDBCTranscriptionRepository.java @@ -0,0 +1,296 @@ +package se.su.dsv.whisperapi; + +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.simple.JdbcClient; +import se.su.dsv.whisperapi.core.Job; +import se.su.dsv.whisperapi.core.JobCompletion; +import se.su.dsv.whisperapi.core.NotificationStatus; +import se.su.dsv.whisperapi.core.OutputFormat; +import se.su.dsv.whisperapi.core.SourceFile; +import se.su.dsv.whisperapi.core.TranscriptionRepository; +import se.su.dsv.whisperapi.core.Transcription; + +import java.net.URI; +import java.nio.file.Path; +import java.security.Principal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Time; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public class JDBCTranscriptionRepository implements TranscriptionRepository { + private final JdbcClient jdbcClient; + + public JDBCTranscriptionRepository(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @Override + public void save(Transcription transcription) { + jdbcClient.sql(""" + INSERT INTO transcriptions (id, owner, callback_uri, language, output_format) + VALUES (:id, :owner, :callback_uri, :language, :output_format) + """) + .param("id", transcription.id()) + .param("owner", transcription.owner().getName()) + .param("callback_uri", transcription.callbackUri().toString()) + .param("language", transcription.language()) + .param("output_format", transcription.outputFormat().name()) + .update(); + } + + @Override + public Optional findByOwnerAndId(Principal owner, UUID uuid) { + return jdbcClient.sql(""" + SELECT id, owner, callback_uri, language, output_format + FROM transcriptions + WHERE id = :id AND owner = :owner + """) + .param("id", uuid) + .param("owner", owner.getName()) + .query(TranscriptionRowMapper.INSTANCE) + .optional(); + } + + @Override + public void addFileToTranscription(Transcription transcription, SourceFile sourceFile) { + jdbcClient.sql(""" + INSERT INTO transcriptions_files (id, transcription_id, filename, transcribed_result_uri) + VALUES (:id, :transcription_id, :filename, :result_file_download_uri) + ON DUPLICATE KEY UPDATE filename = :filename + """) + .param("transcription_id", transcription.id()) + .param("id", sourceFile.id()) + .param("filename", sourceFile.filename()) + .param("result_file_download_uri", sourceFile.downloadUri().toString()) + .update(); + } + + @Override + public List getFiles(Transcription transcription) { + return jdbcClient.sql(""" + SELECT id, filename, transcribed_result_uri + FROM transcriptions_files + WHERE transcription_id = :transcription_id + """) + .param("transcription_id", transcription.id()) + .query(SourceFileRowMapper.INSTANCE) + .list(); + } + + @Override + public void createNewJob(Transcription transcription, Job job) { + jdbcClient.sql(""" + INSERT INTO jobs (id, transcription_id, result_file_absolute_path, error_message, source_file_id) + VALUES (:id, :transcription_id, :result_file, :error_message, :source_file_id) + """) + .param("id", job.id()) + .param("transcription_id", transcription.id()) + .param("result_file", switch (job.status()) { + case Job.Status.Completed(JobCompletion.Success(Path resultFile)) -> + resultFile.toAbsolutePath().toString(); + default -> null; + }) + .param("error_message", switch (job.status()) { + case Job.Status.Completed(JobCompletion.Failure(String errorMessage)) -> errorMessage; + default -> null; + }) + .param("source_file_id", job.sourceFile().id()) + .update(); + } + + @Override + public Optional findJobById(UUID jobId) { + return jdbcClient.sql(""" + SELECT jobs.id as jobId, result_file_absolute_path, error_message, tf.id, tf.filename, tf.transcribed_result_uri + FROM jobs + INNER JOIN transcriptions_files tf on jobs.source_file_id = tf.id + WHERE jobs.id = :id + """) + .param("id", jobId) + .query(JobRowMapper.INSTANCE) + .optional(); + } + + @Override + public void setJobCompleted(Job job, JobCompletion jobCompletion) { + switch (jobCompletion) { + case JobCompletion.Success(Path resultFile) -> jdbcClient.sql(""" + UPDATE jobs + SET result_file_absolute_path = :result_file + WHERE id = :id + """) + .param("id", job.id()) + .param("result_file", resultFile.toAbsolutePath().toString()) + .update(); + case JobCompletion.Failure(String errorMessage) -> jdbcClient.sql(""" + UPDATE jobs + SET error_message = :error_message + WHERE id = :id + """) + .param("id", job.id()) + .param("error_message", errorMessage) + .update(); + } + } + + @Override + public List getProcessingTranscriptions() { + return jdbcClient.sql(""" + SELECT id, owner, callback_uri, language, output_format + FROM transcriptions + WHERE notification_success = FALSE + AND id IN ( + SELECT transcription_id + FROM jobs + ) + """) + .query(TranscriptionRowMapper.INSTANCE) + .list(); + } + + @Override + public List getJobs(Transcription transcription) { + return jdbcClient.sql(""" + SELECT jobs.id as jobId, result_file_absolute_path, error_message, tf.id, tf.filename, tf.transcribed_result_uri + FROM jobs + INNER JOIN transcriptions_files tf on jobs.source_file_id = tf.id + WHERE jobs.transcription_id = :transcription_id + """) + .param("transcription_id", transcription.id()) + .query(JobRowMapper.INSTANCE) + .list(); + } + + @Override + public NotificationStatus getNotificationStatus(Transcription transcription) { + return jdbcClient.sql(""" + SELECT last_notification_time, notification_attempts + FROM transcriptions + WHERE id = :id + """) + .param("id", transcription.id()) + .query((rs, rowNum) -> { + Timestamp lastNotificationTime = rs.getTimestamp("last_notification_time"); + int notificationAttempts = rs.getInt("notification_attempts"); + + if (notificationAttempts == 0) { + return new NotificationStatus.Never(); + } else { + return new NotificationStatus.Failed(lastNotificationTime.toInstant(), notificationAttempts); + } + }) + .single(); + } + + @Override + public void markAsCompleted(Transcription transcription) { + jdbcClient.sql(""" + UPDATE transcriptions + SET notification_success = true + WHERE id = :id + """) + .param("id", transcription.id()) + .update(); + } + + @Override + public void increaseFailureCount(Transcription transcription, Instant now) { + jdbcClient.sql(""" + UPDATE transcriptions + SET last_notification_time = :now, + notification_attempts = notification_attempts + 1 + WHERE id = :id + """) + .param("now", java.util.Date.from(now)) + .param("id", transcription.id()) + .update(); + } + + @Override + public Optional getFile(Transcription transcription, UUID fileId) { + return jdbcClient.sql(""" + SELECT id, filename, transcribed_result_uri + FROM transcriptions_files + WHERE transcription_id = :transcription_id + AND id = :file_id + """) + .param("transcription_id", transcription.id()) + .param("file_id", fileId) + .query(SourceFileRowMapper.INSTANCE) + .optional(); + } + + @Override + public Optional findJobBySourceFile(SourceFile sourceFile) { + return jdbcClient.sql(""" + SELECT jobs.id as jobId, result_file_absolute_path, error_message, tf.id, tf.filename, tf.transcribed_result_uri + FROM jobs + INNER JOIN transcriptions_files tf on jobs.source_file_id = tf.id + WHERE jobs.source_file_id = :source_file_id + """) + .param("source_file_id", sourceFile.id()) + .query(JobRowMapper.INSTANCE) + .optional(); + } + + private enum TranscriptionRowMapper implements RowMapper { + INSTANCE; + + @Override + public Transcription mapRow(ResultSet rs, int rowNum) throws SQLException { + UUID id = UUID.fromString(rs.getString("id")); + Principal owner = new SimplePrincipal(rs.getString("owner")); + URI callbackUri = URI.create(rs.getString("callback_uri")); + String language = rs.getString("language"); + OutputFormat outputFormat = OutputFormat.valueOf(rs.getString("output_format")); + return new Transcription(id, owner, callbackUri, language, outputFormat); + } + } + + private enum JobRowMapper implements RowMapper { + INSTANCE; + + @Override + public Job mapRow(ResultSet rs, int rowNum) throws SQLException { + SourceFile sourceFile = SourceFileRowMapper.INSTANCE.mapRow(rs, rowNum); + UUID id = UUID.fromString(rs.getString("jobId")); + return new Job(id, getStatus(rs), sourceFile); + } + + private Job.Status getStatus(ResultSet rs) throws SQLException { + String resultFileAbsolutePath = rs.getString("result_file_absolute_path"); + if (!rs.wasNull()) { + return new Job.Status.Completed(new JobCompletion.Success(Path.of(resultFileAbsolutePath))); + } + + String errorMessage = rs.getString("error_message"); + if (!rs.wasNull()) { + return new Job.Status.Completed(new JobCompletion.Failure(errorMessage)); + } + + return new Job.Status.Pending(); + } + } + + record SimplePrincipal(String getName) implements Principal { + } + + private enum SourceFileRowMapper implements RowMapper { + INSTANCE; + + @Override + public SourceFile mapRow(ResultSet rs, int rowNum) + throws SQLException + { + UUID id = UUID.fromString(rs.getString("id")); + String filename = rs.getString("filename"); + URI downloadUri = URI.create(rs.getString("transcribed_result_uri")); + return new SourceFile(id, filename, downloadUri); + } + } +} diff --git a/src/main/java/se/su/dsv/whisperapi/SendOutCallbacksJob.java b/src/main/java/se/su/dsv/whisperapi/SendOutCallbacksJob.java new file mode 100644 index 0000000..30dce48 --- /dev/null +++ b/src/main/java/se/su/dsv/whisperapi/SendOutCallbacksJob.java @@ -0,0 +1,19 @@ +package se.su.dsv.whisperapi; + +import org.springframework.scheduling.annotation.Scheduled; +import se.su.dsv.whisperapi.core.TranscriptionService; + +import java.util.concurrent.TimeUnit; + +public class SendOutCallbacksJob { + private final TranscriptionService transcriptionService; + + public SendOutCallbacksJob(TranscriptionService transcriptionService) { + this.transcriptionService = transcriptionService; + } + + @Scheduled(fixedRate = 5, timeUnit = TimeUnit.SECONDS) + public void sendOutCallbacks() { + transcriptionService.checkForCompletedTranscriptions(); + } +} diff --git a/src/main/java/se/su/dsv/whisperapi/WhisperApiApplication.java b/src/main/java/se/su/dsv/whisperapi/WhisperApiApplication.java index 078b689..06ec749 100644 --- a/src/main/java/se/su/dsv/whisperapi/WhisperApiApplication.java +++ b/src/main/java/se/su/dsv/whisperapi/WhisperApiApplication.java @@ -1,34 +1,104 @@ package se.su.dsv.whisperapi; +import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springdoc.core.customizers.OpenApiCustomizer; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; +import org.springframework.core.annotation.Order; import org.springframework.core.Ordered; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.SecurityFilterChain; import org.springframework.web.filter.ForwardedHeaderFilter; +import se.su.dsv.whisperapi.core.Callback; +import se.su.dsv.whisperapi.core.TranscriptionRepository; +import se.su.dsv.whisperapi.core.TranscriptionService; + +import java.util.Map; @SpringBootApplication +@EnableConfigurationProperties(WhisperFrontendConfiguration.class) +@EnableScheduling public class WhisperApiApplication { public static void main(String[] args) { SpringApplication.run(WhisperApiApplication.class, args); } - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http.authorizeHttpRequests( - authorize -> authorize.anyRequest().authenticated()) - .oauth2Login(Customizer.withDefaults()); - return http.build(); - } - @Bean public FilterRegistrationBean forwardedHeaderFilter() { var filterRegistrationBean = new FilterRegistrationBean<>(new ForwardedHeaderFilter()); filterRegistrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); return filterRegistrationBean; } + + @Bean + @Order(1) + public SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception { + return http + .securityMatcher("/api/**") + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/api/transcriptions/job/callback/**").permitAll() + .anyRequest().authenticated()) + .httpBasic(basic -> basic.authenticationEntryPoint((request, response, authException) -> { + // Override the entry point because the default one uses + // HttpServletResponse#sendError method which causes a redirect to /error + // which will in turn be caught by the OAuth2 filter that will further redirect + // to the login page. + // We want the client to stay at the right URL and only be prompted for credentials. + response.setStatus(401); + response.setHeader("WWW-Authenticate", "Basic realm=\"whisper\""); + })) + .csrf(csrf -> csrf.disable()) + .build(); + } + + @Bean + @Order(2) + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.authorizeHttpRequests( + authorize -> authorize.anyRequest().authenticated()) + .oauth2Login(Customizer.withDefaults()); + return http.build(); + } + + @Bean + public TranscriptionService transcriptionService( + TranscriptionRepository transcriptionRepository, + WhisperFrontendConfiguration config) + { + return new TranscriptionService(transcriptionRepository, config.transcriptionFilesDirectory(), config.jobsDirectory()); + } + + @Bean + public JDBCTranscriptionRepository jdbcTransactionRepository(JdbcClient jdbcClient) { + return new JDBCTranscriptionRepository(jdbcClient); + } + + @Bean + public SendOutCallbacksJob sendOutCallbacksJob(TranscriptionService transcriptionService) { + return new SendOutCallbacksJob(transcriptionService); + } + + @Bean + public OpenApiCustomizer customOpenAPI() { + return openApi -> { + ModelConverters modelConverters = ModelConverters.getInstance(); + Map 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; + *

    + *
  1. Create a transcription using {@code POST /api/transcriptions}
  2. + *
  3. Attach source files to be transcribed by by {@code POST}ing to the {@code attach-source-file} relation link
  4. + *
  5. Submit the transcription for processing using {@code PUT} on the {@code submit-job} relation link
  6. + *
+ *

+ * 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);