WIP: Submit transcoding jobs via a HTTP API #6

Draft
ansv7779 wants to merge 22 commits from api-submission into master
47 changed files with 1417 additions and 8 deletions

27
pom.xml
View File

@ -42,6 +42,16 @@
<groupId>org.flywaydb</groupId> <groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId> <artifactId>flyway-mysql</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>
<dependency>
<groupId>com.github.therapi</groupId>
<artifactId>therapi-runtime-javadoc</artifactId>
<version>0.15.0</version>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
@ -82,6 +92,23 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>spring-boot-maven-plugin</artifactId>
</plugin> </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>com.github.therapi</groupId>
<artifactId>therapi-runtime-javadoc-scribe</artifactId>
<version>0.15.0</version>
</path>
<path>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins> </plugins>
</build> </build>

View File

@ -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<Transcription> 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<SourceFile> 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<Job> 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<Transcription> 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<Job> 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<SourceFile> 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<Job> 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<Transcription> {
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<Job> {
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<SourceFile> {
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);
}
}
}

View File

@ -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();
}
}

View File

@ -1,34 +1,104 @@
package se.su.dsv.whisperapi; 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.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.Order;
import org.springframework.core.Ordered; 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.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.filter.ForwardedHeaderFilter; 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 @SpringBootApplication
@EnableConfigurationProperties(WhisperFrontendConfiguration.class)
@EnableScheduling
public class WhisperApiApplication { public class WhisperApiApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(WhisperApiApplication.class, 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 @Bean
public FilterRegistrationBean<ForwardedHeaderFilter> forwardedHeaderFilter() { public FilterRegistrationBean<ForwardedHeaderFilter> forwardedHeaderFilter() {
var filterRegistrationBean = new FilterRegistrationBean<>(new ForwardedHeaderFilter()); var filterRegistrationBean = new FilterRegistrationBean<>(new ForwardedHeaderFilter());
filterRegistrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); filterRegistrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE);
return filterRegistrationBean; 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<String, Schema> 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"));
};
}
} }

View File

@ -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)
{
}

View File

@ -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}.
* <p>
* Transcribing files is a three-step process;
* <ol>
* <li>Create a transcription using {@code POST /api/transcriptions}</li>
* <li>Attach source files to be transcribed by by {@code POST}ing to the {@code attach-source-file} relation link</li>
* <li>Submit the transcription for processing using {@code PUT} on the {@code submit-job} relation link</li>
* </ol>
* <p>
* 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<InputStreamResource> 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<Void> 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<Void> 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();
}
}

View File

@ -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)
{
}

View File

@ -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");
}
}

View File

@ -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");
}
}

View File

@ -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.");
}
}

View File

@ -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.");
}
}

View File

@ -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 {
}
}

View File

@ -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");
}
}

View File

@ -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) {}

View File

@ -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");
}
}

View File

@ -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);
}
}

View File

@ -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() + "'");
}
}

View File

@ -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");
}
}

View File

@ -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<String, Link> links)
{
}

View File

@ -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<File> 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 {}
}
}

View File

@ -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);
}

View File

@ -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) {
}

View File

@ -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;
};
}
}

View File

@ -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;
}
}

View File

@ -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 {}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,6 @@
package se.su.dsv.whisperapi.core;
public class NotSubmittedForTranscription extends Exception {
public NotSubmittedForTranscription() {
}
}

View File

@ -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 {}
}

View File

@ -0,0 +1,9 @@
package se.su.dsv.whisperapi.core;
public enum OutputFormat {
PLAIN_TEXT,
VTT,
SRT,
TSV,
JSON
}

View File

@ -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) {
}

View File

@ -0,0 +1,6 @@
package se.su.dsv.whisperapi.core;
import java.io.InputStream;
public record SourceFileUpload(String filename, InputStream data) {
}

View File

@ -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);
}

View File

@ -0,0 +1,6 @@
package se.su.dsv.whisperapi.core;
import java.io.InputStream;
public record TranscribedResult(SourceFile sourceFile, long size, InputStream data) {
}

View File

@ -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) {
}

View File

@ -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;
}
}

View File

@ -0,0 +1,6 @@
package se.su.dsv.whisperapi.core;
public class TranscriptionJobStillPending extends Exception {
public TranscriptionJobStillPending() {
}
}

View File

@ -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<Transcription> 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<SourceFile> getFiles(Transcription transcription);
/**
* @param transcription the transcription this specific job is a part of
*/
void createNewJob(Transcription transcription, Job job);
Optional<Job> findJobById(UUID jobId);
void setJobCompleted(Job job, JobCompletion jobCompletion);
List<Transcription> getProcessingTranscriptions();
List<Job> getJobs(Transcription transcription);
NotificationStatus getNotificationStatus(Transcription transcription);
void markAsCompleted(Transcription transcription);
void increaseFailureCount(Transcription transcription, Instant now);
Optional<SourceFile> getFile(Transcription transcription, UUID fileId);
Optional<Job> findJobBySourceFile(SourceFile sourceFile);
}

View File

@ -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;
}
ansv7779 marked this conversation as resolved
Review

Avoid using the filename provided by the user as a security measure.

Avoid using the filename provided by the user as a security measure.
Review

Fixed in 515b2aa642

Fixed in https://gitea.dsv.su.se/DMC/whisper-frontend/commit/515b2aa642d3bdce23a27f96ae532f2a1ea29543
public Optional<Transcription> 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<SourceFile> 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<Transcription> processing = transcriptionRepository.getProcessingTranscriptions();
for (Transcription transcription : processing) {
List<Job> 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<Job> jobs) {
URI callbackUri = transcription.callbackUri();
List<Callback.File> files = jobs.stream()
.<Callback.File>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<String> 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();
};
}
}

View File

@ -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.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-info-uri=${OAUTH2_USER_INFO_URI}
spring.security.oauth2.client.provider.su.user-name-attribute=sub 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

View File

@ -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)
);

View File

@ -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
);

View File

@ -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)
);

View File

@ -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;

View File

@ -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);

View File

@ -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`);

View File

@ -0,0 +1,2 @@
ALTER TABLE `transcriptions`
ADD COLUMN `language` CHAR(2);