WIP: Submit transcoding jobs via a HTTP API #6

Draft
ansv7779 wants to merge 22 commits from api-submission into master
12 changed files with 190 additions and 6 deletions
Showing only changes of commit a1321d10c7 - Show all commits

View File

@ -210,6 +210,33 @@ public class JDBCTranscriptionRepository implements TranscriptionRepository {
.update(); .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> { private enum TranscriptionRowMapper implements RowMapper<Transcription> {
INSTANCE; INSTANCE;

View File

@ -30,7 +30,15 @@ public class WhisperApiApplication {
.authorizeHttpRequests(authorize -> authorize .authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/transcriptions/job/callback/**").permitAll() .requestMatchers("/api/transcriptions/job/callback/**").permitAll()
.anyRequest().authenticated()) .anyRequest().authenticated())
.httpBasic(Customizer.withDefaults()) .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()) .csrf(csrf -> csrf.disable())
.build(); .build();
} }

View File

@ -1,7 +1,10 @@
package se.su.dsv.whisperapi.api; package se.su.dsv.whisperapi.api;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.ErrorResponseException;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -15,9 +18,14 @@ import se.su.dsv.whisperapi.core.CreateTranscription;
import se.su.dsv.whisperapi.core.JobAlreadyCompleted; import se.su.dsv.whisperapi.core.JobAlreadyCompleted;
import se.su.dsv.whisperapi.core.JobCompletion; import se.su.dsv.whisperapi.core.JobCompletion;
import se.su.dsv.whisperapi.core.JobNotFound; 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.OutputFormat;
import se.su.dsv.whisperapi.core.TranscribedResult;
import se.su.dsv.whisperapi.core.Transcription; import se.su.dsv.whisperapi.core.Transcription;
import se.su.dsv.whisperapi.core.SourceFileUpload; 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 se.su.dsv.whisperapi.core.TranscriptionService;
import java.io.IOException; import java.io.IOException;
@ -127,17 +135,47 @@ public class ApiController {
@GetMapping(value = "/api/transcriptions/{transcriptionId}/file/{fileId}/result", @GetMapping(value = "/api/transcriptions/{transcriptionId}/file/{fileId}/result",
consumes = MediaType.ALL_VALUE, consumes = MediaType.ALL_VALUE,
produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public InputStream getTranscribedResult( public ResponseEntity<InputStreamResource> getTranscribedResult(
Principal owner, Principal owner,
@PathVariable("transcriptionId") String transcriptionId, @PathVariable("transcriptionId") String transcriptionId,
@PathVariable("fileId") String fileId) @PathVariable("fileId") String fileId)
{ {
// access control // access control
transcriptionService.getTranscription(owner, UUID.fromString(transcriptionId)) Transcription transcription = transcriptionService.getTranscription(owner, UUID.fromString(transcriptionId))
.orElseThrow(() -> new TranscriptionNotFound(transcriptionId)); .orElseThrow(() -> new TranscriptionNotFound(transcriptionId));
// todo try {
throw new UnsupportedOperationException("NYI"); 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";
};
} }
@PostMapping(value = "/api/transcriptions/{id}/job", consumes = "*/*") @PostMapping(value = "/api/transcriptions/{id}/job", consumes = "*/*")

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,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,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,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

@ -37,4 +37,8 @@ public interface TranscriptionRepository {
void markAsCompleted(Transcription transcription); void markAsCompleted(Transcription transcription);
void increaseFailureCount(Transcription transcription, Instant now); void increaseFailureCount(Transcription transcription, Instant now);
Optional<SourceFile> getFile(Transcription transcription, UUID fileId);
Optional<Job> findJobBySourceFile(SourceFile sourceFile);
} }

View File

@ -5,7 +5,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.SerializationFeature;
import java.io.IOException; import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
@ -182,6 +181,7 @@ public class TranscriptionService {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
return response.statusCode() == 200; return response.statusCode() == 200;
} catch (IOException | InterruptedException e) { } catch (IOException | InterruptedException e) {
Thread.currentThread().interrupt();
LOGGER.log(System.Logger.Level.ERROR, "Failed to notify owner", e); LOGGER.log(System.Logger.Level.ERROR, "Failed to notify owner", e);
return false; return false;
} }
@ -194,4 +194,27 @@ public class TranscriptionService {
private void markTranscriptionAsCompleted(Transcription transcription) { private void markTranscriptionAsCompleted(Transcription transcription) {
transcriptionRepository.markAsCompleted(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();
};
}
} }