package com.xenoage.zong.webserver.actions; import static com.xenoage.utils.collections.CollectionUtils.alist; import static com.xenoage.utils.collections.CollectionUtils.llist; import static com.xenoage.utils.kernel.Range.range; import static com.xenoage.utils.kernel.Tuple2.t; import static com.xenoage.utils.log.Log.log; import static com.xenoage.utils.log.Report.remark; import static com.xenoage.zong.webserver.util.Database.delete; import static com.xenoage.zong.webserver.util.Database.exists; import static com.xenoage.zong.webserver.util.Database.insert; import static com.xenoage.zong.webserver.util.Database.stmt; import static com.xenoage.zong.webserver.util.Database.unixTime; import static com.xenoage.zong.webserver.util.Response.writeSuccess; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.net.MalformedURLException; import java.net.URL; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.LinkedList; import java.util.List; import java.util.UUID; import javax.imageio.ImageIO; import javax.servlet.http.HttpServletResponse; import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.xenoage.utils.annotations.MaybeNull; import com.xenoage.utils.document.io.FileOutput; import com.xenoage.utils.jse.io.JseInputStream; import com.xenoage.utils.jse.io.JseStreamUtils; import com.xenoage.utils.jse.io.URLUtils; import com.xenoage.utils.kernel.Tuple2; import com.xenoage.utils.math.Units; import com.xenoage.utils.math.geom.Size2f; import com.xenoage.zong.core.Score; import com.xenoage.zong.desktop.io.DocumentIO; import com.xenoage.zong.desktop.io.mp3.out.Mp3ScoreFileOutput; import com.xenoage.zong.desktop.io.musicxml.in.MusicXmlScoreDocFileInput; import com.xenoage.zong.desktop.io.ogg.out.OggScoreFileOutput; import com.xenoage.zong.documents.ScoreDoc; import com.xenoage.zong.layout.Layout; import com.xenoage.zong.renderer.awt.AwtLayoutRenderer; import com.xenoage.zong.webserver.Webserver; import com.xenoage.zong.webserver.io.CursorOutput; import com.xenoage.zong.webserver.model.Doc; import com.xenoage.zong.webserver.model.Page; import com.xenoage.zong.webserver.model.ScaledPage; import com.xenoage.zong.webserver.model.Scaling; import com.xenoage.zong.webserver.model.requests.OpenRequest; import com.xenoage.zong.webserver.model.requests.Request; import com.xenoage.zong.webserver.util.Database; import com.xenoage.zong.webserver.util.WorkerThread; /** * Responds to an {@link OpenRequest}, that means, a file * it is downloaded, opened, layouted and rendered and information * about it is send to the client. * * If the document is still in the cache, it is not not loaded again. * The cache is cleaned after the time defined in the settings. * * @author Andreas Wenger */ public class OpenAction extends Action { @Override public void perform(Request request, Webserver server, HttpServletResponse response) throws SQLException, IOException { OpenRequest openRequest = getAs(OpenRequest.class, request); log(remark("OpenAction started for URL " + openRequest.url)); final Connection db = server.getDBConnection(); //cleanup: delete documents which were not used during a defined period PreparedStatement stmtDelete = stmt(db, "SELECT id FROM docs WHERE last_access < ?", unixTime() - Integer.parseInt(server.getSetting("cachetime"))); ResultSet resDelete = stmtDelete.executeQuery(); while (resDelete.next()) { //TODO: automatically cascade int deleteID = resDelete.getInt(1); delete(db, "audio", "doc_id = ?", deleteID); delete(db, "cursors", "doc_id = ?", deleteID); delete(db, "pages", "doc_id = ?", deleteID); delete(db, "pageinfos", "doc_id = ?", deleteID); delete(db, "scaledpageinfos", "doc_id = ?", deleteID); delete(db, "docs", "id = ?", deleteID); } stmtDelete.close(); //test //long startTime = System.currentTimeMillis(); //see if document is already in database. if not, load it from the URL. ScoreDoc scoreDoc = null; Doc doc = null; PreparedStatement stmtDoc = stmt(db, "SELECT id FROM docs WHERE url = ?", openRequest.url); ResultSet resDoc = stmtDoc.executeQuery(); if (resDoc.next()) { //the document already exists in the database log(remark("Requested document is still in cache. Using it.")); doc = Doc.fromDB(db, openRequest.url); } else { //the document is unknown. load it. log(remark("Requested document is not in cache. Loading it.")); Tuple2<ScoreDoc, Doc> t = loadDocument(openRequest.url, openRequest.requestedID); scoreDoc = t.get1(); doc = t.get2(); } stmtDoc.close(); //load size of first page PreparedStatement stmtFirstPageSize = stmt(db, "SELECT width, height FROM pageinfos WHERE doc_id = ? AND page = 0", doc.id); ResultSet resFirstPageSize = stmtFirstPageSize.executeQuery(); resFirstPageSize.next(); Size2f firstPageSize = new Size2f(resFirstPageSize.getFloat(1), resFirstPageSize.getFloat(2)); stmtFirstPageSize.close(); //scalings are saved as value*72dpi/10000 in the database. //convert all requested scalings to this format final LinkedList<Integer> requestedScalings = llist(); for (Scaling scaling : openRequest.scalings) { requestedScalings.add(scaling.convertTo10000(firstPageSize)); } //find all requested scalings, that are not already available in the database final LinkedList<Integer> scalingsToRender = llist(); for (int scaling : requestedScalings) { boolean scalingExists = (ScaledPage.fromDB(db, doc.id, 0, scaling) != null); if (!scalingExists) { scalingsToRender.add(scaling); //if ScoreDoc was not loaded yet, load it now if (scoreDoc == null) scoreDoc = loadDocument(openRequest.url, openRequest.requestedID).get1(); //save information about scaled pages List<com.xenoage.zong.layout.Page> pages = scoreDoc.getLayout().getPages(); for (int iPage : range(pages)) { com.xenoage.zong.layout.Page page = pages.get(iPage); Size2f pageSize = page.getFormat().getSize(); ScaledPage scaledPage = new ScaledPage(doc.id, iPage, scaling, Units.mmToPxInt(pageSize.width, scaling / 10000f), Units.mmToPxInt(pageSize.height, scaling / 10000f)); scaledPage.insertIntoDB(db); } } } //decide, if we have something to do final boolean renderPages = (scalingsToRender.size() > 0); final boolean renderAudio = !exists(db, "audio", "doc_id = ?", doc.id); final boolean renderCursor = !exists(db, "cursors", "doc_id = ?", doc.id); if (renderPages || renderAudio || renderCursor) { //if ScoreDoc was not loaded yet, load it now if (scoreDoc == null) scoreDoc = loadDocument(openRequest.url, openRequest.requestedID).get1(); } //from here on, try to do things in parallel //first thread: render pages and save them in the database final ScoreDoc scoreDocFinal = scoreDoc; final Doc docFinal = doc; final ArrayList<Page> pages = alist(); final ArrayList<ArrayList<ScaledPage>> scaledPages = alist(); Thread threadPages = new WorkerThread() { @Override public void runTry() throws Exception { if (renderPages) { //render the pages for (int scaling : scalingsToRender) { List<BufferedImage> pages = renderTiles(scoreDocFinal.getLayout(), scaling / 10000f); for (int iPage : range(pages)) { BufferedImage page = pages.get(iPage); //write page ByteArrayOutputStream imageData = new ByteArrayOutputStream(); ImageIO.write(page, "png", imageData); insert(db, "pages", "doc_id, page, scaling, image", docFinal.id, iPage, scaling, imageData.toByteArray()); } } log(remark("Rendered " + scoreDocFinal.getLayout().getPages().size() + " pages at " + scalingsToRender + " scalings")); } //collect pages for response for (int iPage : range(docFinal.pages)) { pages.add(Page.fromDB(db, docFinal.id, iPage)); } //collect scaled pages for response for (int iPage : range(docFinal.pages)) { ArrayList<ScaledPage> sp = alist(); for (int scaling : requestedScalings) { sp.add(ScaledPage.fromDB(db, docFinal.id, iPage, scaling)); } scaledPages.add(sp); } System.out.println("pages finished"); //TEST } }; threadPages.start(); //second and third thread: render audio files, if not already in the cache Thread threadOgg = new WorkerThread() { @Override public void runTry() throws Exception { //render OGG file if (renderAudio) renderAndSaveAudioFile(db, docFinal, scoreDocFinal, "OGG", new OggScoreFileOutput()); System.out.println("ogg finished"); //TEST } }; threadOgg.start(); Thread threadMp3 = new WorkerThread() { @Override public void runTry() throws Exception { //render MP3 file if (renderAudio) renderAndSaveAudioFile(db, docFinal, scoreDocFinal, "MP3", new Mp3ScoreFileOutput()); System.out.println("mp3 finished"); //TEST } }; threadMp3.start(); //fourth thread: render cursor file, if not already in the cache Thread threadCursor = new WorkerThread() { @Override public void runTry() throws Exception { //create cursor data if (renderCursor) { log(remark("Creating cursor data")); JsonObject jsonCursor = new CursorOutput().write(scoreDocFinal); insert(db, "cursors", "doc_id, cursors", docFinal.id, jsonCursor.toString()); System.out.println("cursors finished"); //TEST } } }; threadCursor.start(); //wait until all threads are finished try { threadPages.join(); threadOgg.join(); threadMp3.join(); threadCursor.join(); } catch (InterruptedException e) { throw new RuntimeException("interrupted"); } //create response message JsonObject jsonResponse = new JsonObject(); jsonResponse.addProperty("id", "" + doc.publicID); JsonArray jsonPages = new JsonArray(); for (int iPage : range(pages)) { Page page = pages.get(iPage); JsonObject jsonPage = new JsonObject(); jsonPage.addProperty("width", page.width); jsonPage.addProperty("height", page.height); JsonArray jsonScalesPages = new JsonArray(); for (ScaledPage sp : scaledPages.get(iPage)) jsonScalesPages.add(Webserver.instance.getGson().toJsonTree(sp)); jsonPage.add("scaledPages", jsonScalesPages); jsonPages.add(jsonPage); } jsonResponse.add("pages", jsonPages); //test //long endTime = System.currentTimeMillis(); //System.out.println("total time: " + (endTime - startTime)); //send success response writeSuccess(response, jsonResponse); } private void renderAndSaveAudioFile(Connection db, Doc doc, ScoreDoc scoreDoc, String audioFormatID, FileOutput<Score> scoreFileOutput) throws IOException, SQLException { log(remark("Rendering " + audioFormatID + " audio file")); File tempFile = File.createTempFile(getClass().getName(), "." + audioFormatID.toLowerCase()); DocumentIO.write(scoreDoc.getScore(), tempFile, scoreFileOutput); try (InputStream in = new FileInputStream(tempFile)) { byte[] bytes = JseStreamUtils.readToByteArray(in); if (bytes == null) throw new IOException("Could not read " + audioFormatID + " file"); insert(db, "audio", "doc_id, format, audio", doc.id, audioFormatID, bytes); } tempFile.delete(); } /** * Loads the {@link Doc} at the given URL and stores information about * the score in the database, if it is not already present. */ public Tuple2<ScoreDoc, Doc> loadDocument(String url, @MaybeNull UUID publicID) throws SQLException { Connection db = Webserver.instance.getDBConnection(); ScoreDoc scoreDoc; //public ID of the document if (publicID == null) publicID = UUID.randomUUID(); //may not exist yet PreparedStatement stmt = stmt(db, "SELECT public_id FROM docs WHERE public_id = ?", publicID); ResultSet res = stmt.executeQuery(); boolean error = res.next(); stmt.close(); if (error) throw new SQLException("A document with this public ID already exists"); //load MusicXML document try { //open local or remote file InputStream inputStream; if (URLUtils.isAbsoluteURL(url)) { inputStream = new URL(url).openStream(); } else { inputStream = new FileInputStream(Webserver.webPath + url); } MusicXmlScoreDocFileInput in = new MusicXmlScoreDocFileInput(); scoreDoc = in.read(new JseInputStream(inputStream), null); } catch (FileNotFoundException ex) { throw new RuntimeException("file not found"); } catch (MalformedURLException ex) { throw new RuntimeException("invalid URL: " + url); } catch (IOException ex) { throw new RuntimeException("can not read from URL: " + url); } //register file in database, if not already known Layout layout = scoreDoc.getLayout(); boolean isDocKnown = Database.exists(db, "docs", "url = ?", "" + url); if (!isDocKnown) { Database.insert(db, "docs", "url, public_id, pages, last_access", "" + url, "" + publicID, layout.getPages().size(), unixTime()); } //read information about the document Doc doc = Doc.fromDB(db, "" + url); //for new documents: save information if (!isDocKnown) { //page information for (int iPage : range(layout.getPages())) { Size2f pageSize = layout.getPages().get(iPage).getFormat().getSize(); new Page(doc.id, iPage, pageSize.width, pageSize.height).insertIntoDB(db); } } return t(scoreDoc, doc); } /** * Renders the tiles. This function is single-threaded. * (Otherwise we get strange artefacts. The AWT renderer seems not to be thread-safe) */ private static synchronized List<BufferedImage> renderTiles(Layout layout, float scaling) { List<BufferedImage> ret = alist(); for (int iPage : range(layout.getPages())) { ret.add(AwtLayoutRenderer.paintToImage(layout, iPage, scaling)); } return ret; } }