WIP: Submit transcoding jobs via a HTTP API #6
27
pom.xml
27
pom.xml
@ -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>
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
19
src/main/java/se/su/dsv/whisperapi/SendOutCallbacksJob.java
Normal file
19
src/main/java/se/su/dsv/whisperapi/SendOutCallbacksJob.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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"));
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
{
|
||||||
|
}
|
279
src/main/java/se/su/dsv/whisperapi/api/ApiController.java
Normal file
279
src/main/java/se/su/dsv/whisperapi/api/ApiController.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
|
{
|
||||||
|
}
|
14
src/main/java/se/su/dsv/whisperapi/api/FileUploadFailed.java
Normal file
14
src/main/java/se/su/dsv/whisperapi/api/FileUploadFailed.java
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
15
src/main/java/se/su/dsv/whisperapi/api/InvalidLanguage.java
Normal file
15
src/main/java/se/su/dsv/whisperapi/api/InvalidLanguage.java
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
@ -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 {
|
||||||
|
}
|
||||||
|
}
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
7
src/main/java/se/su/dsv/whisperapi/api/Link.java
Normal file
7
src/main/java/se/su/dsv/whisperapi/api/Link.java
Normal 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) {}
|
15
src/main/java/se/su/dsv/whisperapi/api/MissingFilename.java
Normal file
15
src/main/java/se/su/dsv/whisperapi/api/MissingFilename.java
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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() + "'");
|
||||||
|
}
|
||||||
|
}
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
|
{
|
||||||
|
}
|
29
src/main/java/se/su/dsv/whisperapi/core/Callback.java
Normal file
29
src/main/java/se/su/dsv/whisperapi/core/Callback.java
Normal 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 {}
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
@ -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) {
|
||||||
|
}
|
17
src/main/java/se/su/dsv/whisperapi/core/Job.java
Normal file
17
src/main/java/se/su/dsv/whisperapi/core/Job.java
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
12
src/main/java/se/su/dsv/whisperapi/core/JobCompletion.java
Normal file
12
src/main/java/se/su/dsv/whisperapi/core/JobCompletion.java
Normal 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 {}
|
||||||
|
}
|
15
src/main/java/se/su/dsv/whisperapi/core/JobNotFound.java
Normal file
15
src/main/java/se/su/dsv/whisperapi/core/JobNotFound.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
package se.su.dsv.whisperapi.core;
|
||||||
|
|
||||||
|
public class NotSubmittedForTranscription extends Exception {
|
||||||
|
public NotSubmittedForTranscription() {
|
||||||
|
}
|
||||||
|
}
|
@ -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 {}
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
package se.su.dsv.whisperapi.core;
|
||||||
|
|
||||||
|
public enum OutputFormat {
|
||||||
|
PLAIN_TEXT,
|
||||||
|
VTT,
|
||||||
|
SRT,
|
||||||
|
TSV,
|
||||||
|
JSON
|
||||||
|
}
|
11
src/main/java/se/su/dsv/whisperapi/core/SourceFile.java
Normal file
11
src/main/java/se/su/dsv/whisperapi/core/SourceFile.java
Normal 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) {
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
package se.su.dsv.whisperapi.core;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
|
public record SourceFileUpload(String filename, InputStream data) {
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
package se.su.dsv.whisperapi.core;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
|
public record TranscribedResult(SourceFile sourceFile, long size, InputStream data) {
|
||||||
|
}
|
@ -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) {
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
package se.su.dsv.whisperapi.core;
|
||||||
|
|
||||||
|
public class TranscriptionJobStillPending extends Exception {
|
||||||
|
public TranscriptionJobStillPending() {
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
@ -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
|
|||||||
|
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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
8
src/main/resources/db/migration/V1__transcriptions.sql
Normal file
8
src/main/resources/db/migration/V1__transcriptions.sql
Normal 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)
|
||||||
|
);
|
@ -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
|
||||||
|
);
|
9
src/main/resources/db/migration/V3__jobs.sql
Normal file
9
src/main/resources/db/migration/V3__jobs.sql
Normal 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)
|
||||||
|
);
|
4
src/main/resources/db/migration/V4__notification.sql
Normal file
4
src/main/resources/db/migration/V4__notification.sql
Normal 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;
|
@ -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);
|
6
src/main/resources/db/migration/V6__job_source_file.sql
Normal file
6
src/main/resources/db/migration/V6__job_source_file.sql
Normal 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`);
|
2
src/main/resources/db/migration/V7__language.sql
Normal file
2
src/main/resources/db/migration/V7__language.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `transcriptions`
|
||||||
|
ADD COLUMN `language` CHAR(2);
|
Loading…
x
Reference in New Issue
Block a user
Avoid using the filename provided by the user as a security measure.
Fixed in
515b2aa642