From 7f5f91b141405cea996845def39f1f5c444405c6 Mon Sep 17 00:00:00 2001 From: Ender <33337309+e3ndr@users.noreply.github.com> Date: Thu, 21 Sep 2023 22:23:03 -0500 Subject: [PATCH] Server: Bring in the Simple UI! --- server/pom.xml | 6 + .../athena/server/http/StreamRoutes.java | 11 +- .../e3ndr/athena/webui/AthenaUIServer.java | 1 + .../xyz/e3ndr/athena/webui/HTMLBuilder.java | 67 ++++++++ .../e3ndr/athena/webui/InternalRoutes.java | 25 +++ .../java/xyz/e3ndr/athena/webui/UIRoutes.java | 157 ++++++++++++++---- 6 files changed, 236 insertions(+), 31 deletions(-) create mode 100644 server/src/main/java/xyz/e3ndr/athena/webui/HTMLBuilder.java create mode 100644 server/src/main/java/xyz/e3ndr/athena/webui/InternalRoutes.java diff --git a/server/pom.xml b/server/pom.xml index 0fc69b8..f729729 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -101,6 +101,12 @@ compile + + org.unbescape + unbescape + 1.1.6.RELEASE + compile + com.github.e3ndr FastLoggingFramework diff --git a/server/src/main/java/xyz/e3ndr/athena/server/http/StreamRoutes.java b/server/src/main/java/xyz/e3ndr/athena/server/http/StreamRoutes.java index cd66ca6..e93723d 100644 --- a/server/src/main/java/xyz/e3ndr/athena/server/http/StreamRoutes.java +++ b/server/src/main/java/xyz/e3ndr/athena/server/http/StreamRoutes.java @@ -126,6 +126,12 @@ public void close() throws IOException {} // Already handled in MediaSession#sta @SneakyThrows @HttpEndpoint(uri = "/api/media/:mediaId/stream/hls") public HttpResponse onStreamHLS(SoraHttpSession session) { + String playlist = this.generateHLSPlaylist(session, "/api/media/%s/stream/raw"); + return HttpResponse.newFixedLengthResponse(StandardHttpStatus.OK, playlist) + .setMimeType("application/vnd.apple.mpegurl"); + } + + public String generateHLSPlaylist(SoraHttpSession session, String uriBase) { final double DURATION = 8; String mediaId = session.getUriParameters().get("mediaId"); @@ -136,12 +142,11 @@ public HttpResponse onStreamHLS(SoraHttpSession session) { + "#EXT-X-MEDIA-SEQUENCE:0\r\n"; // TODO - playlist += String.format("#EXTINF:%.1f,\r\n/api/media/%s/stream/raw%s&skipTo=%d\r\n", DURATION, mediaId, session.getQueryString(), -1); + playlist += String.format("#EXTINF:%.1f,\r\n%s%s&skipTo=%d\r\n", DURATION, uriBase, mediaId, session.getQueryString(), -1); playlist += "#EXT-X-ENDLIST"; - return HttpResponse.newFixedLengthResponse(StandardHttpStatus.OK, playlist) - .setMimeType("application/vnd.apple.mpegurl"); + return playlist; } } diff --git a/server/src/main/java/xyz/e3ndr/athena/webui/AthenaUIServer.java b/server/src/main/java/xyz/e3ndr/athena/webui/AthenaUIServer.java index 6e78df9..78148d2 100644 --- a/server/src/main/java/xyz/e3ndr/athena/webui/AthenaUIServer.java +++ b/server/src/main/java/xyz/e3ndr/athena/webui/AthenaUIServer.java @@ -47,6 +47,7 @@ public static class AthenaSoraAdapter extends SoraPlugin { @Override public void onInit(Sora sora) { sora.addProvider(this, new UIRoutes()); + sora.addProvider(this, new InternalRoutes()); } @Override diff --git a/server/src/main/java/xyz/e3ndr/athena/webui/HTMLBuilder.java b/server/src/main/java/xyz/e3ndr/athena/webui/HTMLBuilder.java new file mode 100644 index 0000000..2eb0f86 --- /dev/null +++ b/server/src/main/java/xyz/e3ndr/athena/webui/HTMLBuilder.java @@ -0,0 +1,67 @@ +package xyz.e3ndr.athena.webui; + +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; + +import org.unbescape.html.HtmlEscape; + +import co.casterlabs.rakurai.io.http.HttpStatus; +import co.casterlabs.rakurai.io.http.server.HttpResponse; + +/** + * This class is chainable. + */ +public class HTMLBuilder { + private List lines = new LinkedList<>(); + + public HTMLBuilder f(String format, Object... args) { + // Escape the args. + for (int idx = 0; idx < args.length; idx++) { + if (args[idx] instanceof String) { + args[idx] = HtmlEscape.escapeHtml5(String.valueOf(args[idx])); + } + } + this.lines.add(String.format(format, args)); // Format and add to body. + return this; // Chain + } + + @Override + public String toString() { + List lines = new LinkedList<>(); + lines.addAll( + Arrays.asList( + "", + "", + " ", + " ", + " ", + " ", + " Athena", + " ", + " " + ) + ); + lines.addAll(this.lines); + lines.addAll( + Arrays.asList( + " ", + "" + ) + ); + return String.join("\r\n", lines); + } + + public HttpResponse toResponse(HttpStatus status) { + return HttpResponse + .newFixedLengthResponse(status, this.toString()) + .setMimeType("text/html"); + } + + public static HTMLBuilder of(String... lines) { + HTMLBuilder instance = new HTMLBuilder(); + for (String line : lines) instance.lines.add(line); + return instance; + } + +} diff --git a/server/src/main/java/xyz/e3ndr/athena/webui/InternalRoutes.java b/server/src/main/java/xyz/e3ndr/athena/webui/InternalRoutes.java new file mode 100644 index 0000000..80e8aca --- /dev/null +++ b/server/src/main/java/xyz/e3ndr/athena/webui/InternalRoutes.java @@ -0,0 +1,25 @@ +package xyz.e3ndr.athena.webui; + +import co.casterlabs.rakurai.io.http.StandardHttpStatus; +import co.casterlabs.rakurai.io.http.server.HttpResponse; +import co.casterlabs.sora.api.http.HttpProvider; +import co.casterlabs.sora.api.http.SoraHttpSession; +import co.casterlabs.sora.api.http.annotations.HttpEndpoint; +import xyz.e3ndr.athena.server.http.StreamRoutes; + +class InternalRoutes implements HttpProvider { + private StreamRoutes streamRoutes = new StreamRoutes(); + + @HttpEndpoint(uri = "/_internal/media/:mediaId/stream/raw") + public HttpResponse onStreamRAW(SoraHttpSession session) { + return this.streamRoutes.onStreamRAW(session); + } + + @HttpEndpoint(uri = "/_internal/media/:mediaId/stream/hls") + public HttpResponse onStreamHLS(SoraHttpSession session) { + String playlist = this.streamRoutes.generateHLSPlaylist(session, "/_internal/media/%s/stream/raw"); + return HttpResponse.newFixedLengthResponse(StandardHttpStatus.OK, playlist) + .setMimeType("application/vnd.apple.mpegurl"); + } + +} 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 8af4f63..709c333 100644 --- a/server/src/main/java/xyz/e3ndr/athena/webui/UIRoutes.java +++ b/server/src/main/java/xyz/e3ndr/athena/webui/UIRoutes.java @@ -1,49 +1,150 @@ package xyz.e3ndr.athena.webui; -import java.util.Arrays; -import java.util.LinkedList; import java.util.List; +import java.util.stream.Collectors; import co.casterlabs.rakurai.io.http.StandardHttpStatus; import co.casterlabs.rakurai.io.http.server.HttpResponse; import co.casterlabs.sora.api.http.HttpProvider; import co.casterlabs.sora.api.http.SoraHttpSession; import co.casterlabs.sora.api.http.annotations.HttpEndpoint; +import xyz.e3ndr.athena.Athena; +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; class UIRoutes implements HttpProvider { @HttpEndpoint(uri = "/*") public HttpResponse onViewIndex(SoraHttpSession session) { - return html( + return HTMLBuilder.of( "

Welcome to Athena!

", - ); + "

Continue to your media library.

" + ).toResponse(StandardHttpStatus.OK); } - private static HttpResponse html(String... body) { - List lines = new LinkedList<>(); - lines.addAll( - Arrays.asList( - "", - "", - " ", - " ", - " ", - " ", - " Athena", - " ", - " " - ) - ); - lines.addAll(Arrays.asList(body)); - lines.addAll( - Arrays.asList( - " ", - "" + @HttpEndpoint(uri = "/media") + public HttpResponse onViewMedia(SoraHttpSession session) { + final int itemsPerPage = 20; + int page = Integer.parseInt(session.getQueryParameters().getOrDefault("page", "1")); + int total = Athena.totalMedia(); + + // Constrain. + if (page < 1) { + page = 1; + } else if ((page - 1) * itemsPerPage > total) { + page = 1; + } + + List listOfMedia = Athena.listMedia((page - 1) * itemsPerPage, itemsPerPage); + + HTMLBuilder html = new HTMLBuilder(); + + for (Media media : listOfMedia) { + html.f(""); + html.f(" "); + html.f("

%s

", media.getInfo().getTitle()); + html.f(""); + } + + html.f("
"); + html.f("
"); + + html.f("You are on page %d.", page); + if (page > 1) { + html.f("Previous", page - 1); + } + if ((page) * itemsPerPage < total) { + html.f("Next", page + 1); + } + + return html.toResponse(StandardHttpStatus.OK); + } + + @HttpEndpoint(uri = "/media/:mediaId") + public HttpResponse onViewSpecificMedia(SoraHttpSession session) { + Media media = Athena.getMedia(session.getUriParameters().get("mediaId")); + + if (media == null) { + return HttpResponse.newFixedLengthResponse(StandardHttpStatus.NOT_FOUND); + } + + String genres = String.join(", ", media.getInfo().getGenres()); + String studios = String.join(", ", media.getInfo().getStudios().stream().limit(2).map((s) -> s.getName()).collect(Collectors.toList())); + String directors = String.join(", ", media.getInfo().getDirectors().stream().map((p) -> p.getName()).collect(Collectors.toList())); + String actors = String.join(", ", media.getInfo().getActors().stream().limit(4).map((p) -> p.getName()).collect(Collectors.toList())); + + return new HTMLBuilder() + .f("") + .f(" ") + .f(" ") + .f(" ") + .f(" ") + .f("
") + .f(" ") + .f(" ") + .f("
") + .f("

%s (%d)

", media.getInfo().getTitle(), media.getInfo().getYear()) + .f("

%s

", genres) + .f("
") + .f("") + .f("

%s

", media.getInfo().getSummary()) + .f("") + .f(" ") + .f(" ") + .f(" ") + .f(" ", directors) + .f(" ") + .f(" ") + .f(" ") + .f(" ", actors) + .f(" ") + .f(" ") + .f(" ") + .f(" ", studios) + .f(" ") + .f("
Directors:%s
Starring:%s
Studios:%s
") + .f("") + .f("
") + .f(" Watch", media.getId()) + .f("
") + .toResponse(StandardHttpStatus.OK); + } + + @HttpEndpoint(uri = "/media/:mediaId/watch") + public HttpResponse onWatchSpecificMedia(SoraHttpSession session) { + Media media = Athena.getMedia(session.getUriParameters().get("mediaId")); + + if (media == null) { + return HttpResponse.newFixedLengthResponse(StandardHttpStatus.NOT_FOUND); + } + + ContainerFormat container = ContainerFormat.MKV; + VideoCodec vCodec = VideoCodec.SOURCE; + AudioCodec aCodec = AudioCodec.SOURCE; + VideoQuality quality = VideoQuality.UHD; + + return new HTMLBuilder() + .f( + "