diff --git a/server/src/main/java/xyz/e3ndr/athena/transcoding/Transcoder.java b/server/src/main/java/xyz/e3ndr/athena/transcoding/Transcoder.java index 63d43fa..42c4cb2 100644 --- a/server/src/main/java/xyz/e3ndr/athena/transcoding/Transcoder.java +++ b/server/src/main/java/xyz/e3ndr/athena/transcoding/Transcoder.java @@ -24,11 +24,12 @@ import xyz.e3ndr.fastloggingframework.logging.LogLevel; public class Transcoder { + public static final String FFMPEG_EXEC = System.getProperty("athena.ffmpeg", "ffmpeg"); @SneakyThrows public static TranscodeSession start(File targetFile, Media media, VideoQuality desiredQuality, VideoCodec desiredVCodec, AudioCodec desiredACodec, ContainerFormat desiredContainer, int... streamIds) { List command = new LinkedList<>(); - command.add("ffmpeg"); + command.add(FFMPEG_EXEC); command.add("-hide_banner"); // command.add("-v"); // command.add("error"); diff --git a/server/src/main/java/xyz/e3ndr/athena/types/media/MediaFiles.java b/server/src/main/java/xyz/e3ndr/athena/types/media/MediaFiles.java index 2976e17..76415c6 100644 --- a/server/src/main/java/xyz/e3ndr/athena/types/media/MediaFiles.java +++ b/server/src/main/java/xyz/e3ndr/athena/types/media/MediaFiles.java @@ -8,14 +8,15 @@ import co.casterlabs.rakurai.json.annotating.JsonClass; import lombok.Getter; import lombok.NonNull; +import lombok.Setter; @Getter @NonNull @JsonClass(exposeAll = true) public class MediaFiles { private MediaFiles.Images images; - private MediaFiles.Streams streams; - private List subtitles; + private @Setter MediaFiles.Streams streams; + private @Setter List subtitles; @Getter @JsonClass(exposeAll = true) @@ -26,6 +27,7 @@ public static class Images { } @Getter + @Setter @NonNull @JsonClass(exposeAll = true) public static class Streams { @@ -42,16 +44,18 @@ public List getAll() { } @Getter + @Setter @NonNull @JsonClass(exposeAll = true) public static class VideoStream extends Stream { - private int frameRate; + private double frameRate; private int width; private int height; } @Getter + @Setter @NonNull @JsonClass(exposeAll = true) public static class AudioStream extends Stream { @@ -61,6 +65,7 @@ public static class AudioStream extends Stream { } @Getter + @Setter @NonNull @JsonClass(exposeAll = true) public static abstract class Stream { @@ -73,6 +78,7 @@ public static abstract class Stream { } @Getter + @Setter @NonNull @JsonClass(exposeAll = true) public static class Subtitle { diff --git a/server/src/main/java/xyz/e3ndr/athena/webui/UIRoutes.java b/server/src/main/java/xyz/e3ndr/athena/webui/UIRoutes.java index eb1631b..44a53f6 100644 --- a/server/src/main/java/xyz/e3ndr/athena/webui/UIRoutes.java +++ b/server/src/main/java/xyz/e3ndr/athena/webui/UIRoutes.java @@ -1,9 +1,16 @@ package xyz.e3ndr.athena.webui; +import java.io.File; +import java.io.IOException; +import java.lang.ProcessBuilder.Redirect; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import org.unbescape.uri.UriEscape; @@ -13,6 +20,7 @@ import co.casterlabs.rakurai.io.http.server.HttpResponse; import co.casterlabs.rakurai.json.Rson; import co.casterlabs.rakurai.json.TypeToken; +import co.casterlabs.rakurai.json.element.JsonArray; import co.casterlabs.rakurai.json.element.JsonElement; import co.casterlabs.rakurai.json.element.JsonObject; import co.casterlabs.sora.api.http.HttpProvider; @@ -20,11 +28,18 @@ import co.casterlabs.sora.api.http.annotations.HttpEndpoint; import lombok.SneakyThrows; import xyz.e3ndr.athena.Athena; +import xyz.e3ndr.athena.transcoding.Transcoder; import xyz.e3ndr.athena.types.AudioCodec; import xyz.e3ndr.athena.types.ContainerFormat; import xyz.e3ndr.athena.types.VideoCodec; import xyz.e3ndr.athena.types.VideoQuality; import xyz.e3ndr.athena.types.media.Media; +import xyz.e3ndr.athena.types.media.MediaFiles.Streams; +import xyz.e3ndr.athena.types.media.MediaFiles.Streams.AudioStream; +import xyz.e3ndr.athena.types.media.MediaFiles.Streams.Stream; +import xyz.e3ndr.athena.types.media.MediaFiles.Streams.VideoStream; +import xyz.e3ndr.fastloggingframework.logging.FastLogger; +import xyz.e3ndr.fastloggingframework.logging.LogLevel; class UIRoutes implements HttpProvider { private static final String MEDIA_SEARCH_API = System.getProperty("athena.searchapi", "https://athenamediaserver-public-api.e3ndr.workers.dev/search?query="); @@ -171,31 +186,66 @@ public HttpResponse onViewIngestMapStreams(SoraHttpSession session) { html .f("

Streams

") - .f("
"); + .f(""); int codecIdx = 0; for (JsonElement e : Athena.getIngestableInfo(toIngest)) { JsonObject codec = e.getAsObject(); + FastLogger.logStatic(LogLevel.DEBUG, "Codec in %s: %s", toIngest, codec); + html .f("

Stream %d

", codecIdx); switch (codec.getString("codec_type")) { case "video": { String codecName = codec.getString("codec_name").toUpperCase(); + String frameRateCalc = codec.getString("avg_frame_rate"); + int width = codec.getNumber("width").intValue(); + int height = codec.getNumber("height").intValue(); + + double frameRate; + if (frameRateCalc.contains("/")) { + String[] parts = frameRateCalc.split("/"); + double num = Double.parseDouble(parts[0]); + double den = Double.parseDouble(parts[1]); + frameRate = num / den; + } else { + frameRate = Double.parseDouble(frameRateCalc); + } + html .f("Name: ", codecIdx, codecName) - .f("Default Video Stream?: ", codecIdx, codecIdx); + .f("Default Video Stream?: ", codecIdx, codecIdx) + .f("", codecIdx, codecName) + .f("", codecIdx, frameRate) + .f("", codecIdx, width) + .f("", codecIdx, height) + .f("", codecIdx); break; } case "audio": { - String codecName = codec.getString("codec_name").toUpperCase(); + String codecName = codec.getString("codec_name"); String channelLayout = codec.getString("channel_layout"); channelLayout = channelLayout.substring(0, 1).toUpperCase() + channelLayout.substring(1).toLowerCase(); + int channels = codec.getNumber("channels").intValue(); + + String language = "Unknown"; + if (codec.containsKey("tags")) { + JsonObject tags = codec.getObject("tags"); + if (tags.containsKey("language")) { + language = tags.getString("language"); + } + } + html - .f("Name: ", codecIdx, channelLayout, codecName) - .f("Default Audio Stream?: ", codecIdx, codecIdx); + .f("Name: ", codecIdx, channelLayout, codecName.toUpperCase()) + .f("Language: ", codecIdx, language) + .f("Default Audio Stream?: ", codecIdx, codecIdx) + .f("", codecIdx, channels) + .f("", codecIdx, codecName) + .f("", codecIdx); break; } @@ -215,6 +265,126 @@ public HttpResponse onViewIngestMapStreams(SoraHttpSession session) { return html.toResponse(StandardHttpStatus.OK); } + @SneakyThrows + @HttpEndpoint(uri = "/ingest/finalize") + public HttpResponse onViewIngestFinalize(SoraHttpSession session) { + String toIngest = session.getQueryParameters().get("file"); + Media media = Rson.DEFAULT.fromJson(UriEscape.unescapeUriQueryParam(session.getQueryParameters().get("media")), Media.class); + + // Init. + media.getFiles().setStreams(new Streams()); + media.getFiles().getStreams().setVideo(new LinkedList<>()); + media.getFiles().getStreams().setAudio(new LinkedList<>()); + + // defaultStream + JsonArray defaultStreams = new JsonArray(); + if (session.getQueryParameters().containsKey("video/default")) { + defaultStreams.add(Integer.parseInt(session.getQueryParameters().get("video/default"))); + } + if (session.getQueryParameters().containsKey("audio/default")) { + defaultStreams.add(Integer.parseInt(session.getQueryParameters().get("audio/default"))); + } + media.getFiles().getStreams().setDefaultStreams(Rson.DEFAULT.fromJson(defaultStreams, int[].class)); + + // streams + Map streams = new HashMap<>(); + for (String key : session.getQueryParameters().keySet()) { + if (!key.startsWith("stream")) continue; + + String[] parts = key.split("/"); // stream/0/height=800 + + int index = Integer.parseInt(parts[1]); + String jsonKey = parts[2]; + String value = session.getQueryParameters().get(key); + + JsonObject json = streams.get(index); + if (json == null) { + json = new JsonObject(); + json.put("id", index); + streams.put(index, json); + } + + switch (jsonKey) { + // Needs numbers. + case "channels": + case "frameRate": + case "width": + case "height": + json.put(jsonKey, Double.parseDouble(value)); + break; + + default: + json.put(jsonKey, value); + break; + } + } + + for (JsonObject json : streams.values()) { + switch (json.getString("type")) { + case "video": + media + .getFiles() + .getStreams() + .getVideo() + .add( + Rson.DEFAULT.fromJson(json, VideoStream.class) + ); + break; + + case "audio": + media + .getFiles() + .getStreams() + .getAudio() + .add( + Rson.DEFAULT.fromJson(json, AudioStream.class) + ); + break; + } + } + + File mediaDirectory = new File(Athena.mediaDirectory, media.getId()); + new File(mediaDirectory, "subtitles").mkdirs(); + new File(mediaDirectory, "streams").mkdirs(); + + File ingestFile = new File(Athena.ingestDirectory, toIngest); + + // Write the index file. + Files.write( + new File(mediaDirectory, "index.json").toPath(), + Rson.DEFAULT + .toJson(media) + .toString(true) + .getBytes(StandardCharsets.UTF_8) + ); + + // Rip the streams to their own files for later muxing. + for (Stream stream : media.getFiles().getStreams().getAll()) { + int exitCode = new ProcessBuilder() + .command( + Transcoder.FFMPEG_EXEC, + "-i", ingestFile.getAbsolutePath(), + "-map", String.format("0:%d", stream.getId()), + "-c", "copy", + new File(mediaDirectory, "streams/" + stream.getId() + ".mkv").getAbsolutePath() + ) + .inheritIO() + .redirectInput(Redirect.PIPE) + .start() + .waitFor(); + if (exitCode != 0) throw new IOException(); + } + + new File(Athena.ingestDirectory, "completed/").mkdir(); + Files.move( + ingestFile.toPath(), + new File(Athena.ingestDirectory, "completed/" + toIngest).toPath() + ); + + return HttpResponse.newFixedLengthResponse(StandardHttpStatus.TEMPORARY_REDIRECT) + .putHeader("Location", "/media/" + media.getId()); + } + @HttpEndpoint(uri = "/media") public HttpResponse onViewMedia(SoraHttpSession session) { final int itemsPerPage = 20;