com.moviejukebox.writer.MovieJukeboxHTMLWriter.java Source code

Java tutorial

Introduction

Here is the source code for com.moviejukebox.writer.MovieJukeboxHTMLWriter.java

Source

/*
 *      Copyright (c) 2004-2016 YAMJ Members
 *      https://github.com/orgs/YAMJ/people
 *
 *      This file is part of the Yet Another Movie Jukebox (YAMJ) project.
 *
 *      YAMJ is free software: you can redistribute it and/or modify
 *      it under the terms of the GNU General Public License as published by
 *      the Free Software Foundation, either version 3 of the License, or
 *      any later version.
 *
 *      YAMJ is distributed in the hope that it will be useful,
 *      but WITHOUT ANY WARRANTY; without even the implied warranty of
 *      MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *      GNU General Public License for more details.
 *
 *      You should have received a copy of the GNU General Public License
 *      along with YAMJ.  If not, see <http://www.gnu.org/licenses/>.
 *
 *      Web: https://github.com/YAMJ/yamj-v2
 *
 */
package com.moviejukebox.writer;

import com.moviejukebox.model.*;
import com.moviejukebox.model.enumerations.DirtyFlag;
import com.moviejukebox.tools.*;
import java.io.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Map.Entry;
import java.util.concurrent.Callable;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamWriter;
import javax.xml.transform.*;
import javax.xml.transform.stream.StreamResult;
import javax.xml.transform.stream.StreamSource;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;

/**
 * Generate HTML pages from XML movies and indexes
 *
 * @author Julien
 * @author artem.gratchev
 */
public class MovieJukeboxHTMLWriter {

    private static final Logger LOG = LoggerFactory.getLogger(MovieJukeboxHTMLWriter.class);
    private static final String EXT_XML = ".xml";
    private static final String EXT_HTML = ".html";
    private static final String EXT_XSL = ".xsl";
    private final boolean forceHTMLOverwrite = PropertiesUtil.getBooleanProperty("mjb.forceHTMLOverwrite",
            Boolean.FALSE);
    private String peopleFolder;
    private static final String SKIN_HOME = SkinProperties.getSkinHome();
    private static final TransformerFactory TRANSFORMER = TransformerFactory.newInstance();
    private static final String PLAYLIST_IGNORE_EXT = PropertiesUtil.getProperty("mjb.playlist.IgnoreExtensions",
            "iso,img");
    private static final File PLAYLIST_FILE = new File("playlist.xsl");
    private static final String INDEX_HTML_FILE = "../" + PropertiesUtil.getProperty("mjb.indexFile", "index.htm");
    private static final String MYIHOME_IP = PropertiesUtil.getProperty("mjb.myiHome.IP", "");
    private static final boolean GENERATE_MULTIPART_PLAYLIST = PropertiesUtil
            .getBooleanProperty("mjb.playlist.generateMultiPart", Boolean.TRUE);
    private static final int MAX_RETRY_COUNT = 3; // The number of times to retry writing a HTML page

    public MovieJukeboxHTMLWriter() {

        // Issue 1947: Cast enhancement - option to save all related files to a specific folder
        peopleFolder = PropertiesUtil.getProperty("mjb.people.folder", "");
        if (StringTools.isNotValidString(peopleFolder)) {
            peopleFolder = "";
        } else if (!peopleFolder.endsWith(File.separator)) {
            peopleFolder += File.separator;
        }

        // Issue 310
        String transformerFactoryName = PropertiesUtil.getProperty("javax.xml.transform.TransformerFactory", null);
        if (transformerFactoryName != null) {
            System.setProperty("javax.xml.transform.TransformerFactory", transformerFactoryName);
        }
    }

    /**
     * Generate the HTML for the movie details
     *
     * @param jukebox
     * @param movie
     */
    public void generateMovieDetailsHTML(Jukebox jukebox, Movie movie) {
        try {
            String baseName = movie.getBaseName();
            String tempFilename = StringTools.appendToPath(jukebox.getJukeboxTempLocationDetails(), baseName);
            File tempXmlFile = new File(tempFilename + EXT_XML);
            File oldXmlFile = FileTools.fileCache
                    .getFile(jukebox.getJukeboxRootLocationDetails() + File.separator + baseName + EXT_XML);

            FileTools.addJukeboxFile(baseName + EXT_XML);
            String indexList = PropertiesUtil.getProperty("mjb.view.detailList", "detail.xsl");
            for (final String indexStr : indexList.split(",")) {
                String suffix = "";
                if (!"detail.xsl".equals(indexStr)) {
                    suffix = indexStr.replace("detail", "").replace(EXT_XSL, "");
                }

                File finalHtmlFile = FileTools.fileCache.getFile(
                        jukebox.getJukeboxRootLocationDetails() + File.separator + baseName + suffix + EXT_HTML);
                File tempHtmlFile = new File(tempFilename + suffix + EXT_HTML);
                Source xmlSource;

                FileTools.addJukeboxFile(baseName + suffix + EXT_HTML);

                if (!finalHtmlFile.exists() || forceHTMLOverwrite || movie.isDirty(DirtyFlag.INFO)
                        || movie.isDirty(DirtyFlag.WATCHED)) {

                    // Issue 216: If the HTML is deleted the generation fails because it looks in the temp directory and not
                    // the original source directory
                    if (tempXmlFile.exists()) {
                        // Use the temp file
                        xmlSource = new StreamSource(tempXmlFile);
                    } else {
                        // Use the file in the original directory
                        xmlSource = new StreamSource(oldXmlFile);
                    }
                    Result xmlResult = new StreamResult(tempHtmlFile);

                    if (xmlSource != null && xmlResult != null) {
                        File skinFile = new File(SKIN_HOME, indexStr);
                        Transformer transformer = getTransformer(skinFile, jukebox.getJukeboxRootLocationDetails());

                        doTransform(transformer, xmlSource, xmlResult, "Movie: " + movie.getBaseFilename());
                    } else {
                        LOG.error("Unable to transform XML for video {} source: {} result: {}",
                                movie.getBaseFilename(), xmlSource == null ? true : false,
                                xmlResult == null ? true : false);
                    }
                }
            }
        } catch (Exception error) {
            LOG.error("Failed generating HTML for movie {}", movie.getBaseFilename());
            LOG.error(SystemTools.getStackTrace(error));
        }
    }

    /**
     * Generate HTML person details
     *
     * @param jukebox
     * @param person
     */
    public void generatePersonDetailsHTML(Jukebox jukebox, Person person) {
        try {
            String baseName = person.getFilename();
            String tempFilename = jukebox.getJukeboxTempLocationDetails() + File.separator + peopleFolder
                    + baseName;
            File tempXmlFile = new File(tempFilename + EXT_XML);
            File oldXmlFile = FileTools.fileCache.getFile(
                    jukebox.getJukeboxRootLocationDetails() + File.separator + peopleFolder + baseName + EXT_XML);
            FileTools.makeDirsForFile(tempXmlFile);

            FileTools.addJukeboxFile(baseName + EXT_XML);
            String indexList = PropertiesUtil.getProperty("mjb.view.personList", "people.xsl");
            for (final String indexStr : indexList.split(",")) {
                String suffix = "";
                if (!"people.xsl".equals(indexStr)) {
                    suffix = indexStr.replace("people", "").replace(EXT_XSL, "");
                }

                File finalHtmlFile = FileTools.fileCache.getFile(jukebox.getJukeboxRootLocationDetails()
                        + File.separator + peopleFolder + baseName + suffix + EXT_HTML);
                File tempHtmlFile = new File(tempFilename + suffix + EXT_HTML);
                Source xmlSource;

                FileTools.addJukeboxFile(baseName + suffix + EXT_HTML);

                if (!finalHtmlFile.exists() || forceHTMLOverwrite || person.isDirty()) {
                    if (tempXmlFile.exists()) {
                        // Use the temp file
                        xmlSource = new StreamSource(tempXmlFile);
                    } else {
                        // Use the file in the original directory
                        xmlSource = new StreamSource(oldXmlFile);
                    }
                    Result xmlResult = new StreamResult(tempHtmlFile);

                    if (xmlSource != null && xmlResult != null) {
                        File skinFile = new File(SKIN_HOME, indexStr);
                        if (skinFile.exists()) {
                            Transformer transformer = getTransformer(skinFile,
                                    jukebox.getJukeboxRootLocationDetails());
                            doTransform(transformer, xmlSource, xmlResult, "Person: " + person.getName());
                        } else {
                            LOG.warn("XSL file '{}' does not exist!", skinFile.getAbsolutePath());
                        }
                    } else {
                        LOG.error("Unable to transform XML for person {} source: {} result: {}", person.getName(),
                                xmlSource == null ? true : false, xmlResult == null ? true : false);
                    }
                }
            }
        } catch (Exception error) {
            LOG.error("Failed generating HTML for person {}", person.getName());
            LOG.error(SystemTools.getStackTrace(error));
        }
    }

    /**
     * Generates a playlist per part of the video. Used primarily with TV Series
     *
     * @param jukebox
     * @param movie
     * @return List of generated file names
     */
    public Collection<String> generatePlaylist(Jukebox jukebox, Movie movie) {
        Collection<String> fileNames = new ArrayList<>();

        if (PLAYLIST_FILE == null) {
            return fileNames;
        }

        MovieFile[] movieFileArray = movie.getFiles().toArray(new MovieFile[movie.getFiles().size()]);

        try {
            String baseName = movie.getBaseName();
            String tempFilename = jukebox.getJukeboxTempLocationDetails() + File.separator + baseName;
            File tempXmlFile = new File(tempFilename + EXT_XML);
            File oldXmlFile = new File(
                    jukebox.getJukeboxRootLocationDetails() + File.separator + baseName + EXT_XML);
            final String filenameSuffix = ".playlist.jsp";
            File finalPlaylistFile = new File(
                    jukebox.getJukeboxRootLocationDetails() + File.separator + baseName + filenameSuffix);
            File tempPlaylistFile = new File(tempFilename + filenameSuffix);
            Source xmlSource;

            fileNames.add(baseName + filenameSuffix);

            // Issue 884: Remove ISO and IMG files from playlists
            int partCount = 0;
            for (MovieFile moviePart : movieFileArray) {
                String partExt = moviePart.getFilename().substring(moviePart.getFilename().lastIndexOf(".") + 1);
                if (PLAYLIST_IGNORE_EXT.contains(partExt)) {
                    partCount++;
                }
            }
            if (partCount > 0) {
                // Note this will skip playlist generation for any movie that has an "mjb.playlist.ignoreextensions" entry.
                LOG.debug("Playlist for {} skipped - All parts are in mjb.playlist.IgnoreExtensions",
                        movie.getTitle());
                return fileNames;
            } // Issue 884

            if (!finalPlaylistFile.exists() || forceHTMLOverwrite || movie.isDirty(DirtyFlag.INFO)) {
                FileTools.makeDirsForFile(tempPlaylistFile);

                Transformer transformer = getTransformer(PLAYLIST_FILE, jukebox.getJukeboxRootLocationDetails());

                if (tempXmlFile.exists()) {
                    // Use the temp file
                    xmlSource = new StreamSource(tempXmlFile);
                } else {
                    // Use the file in the original directory
                    xmlSource = new StreamSource(oldXmlFile);
                }
                Result xmlResult = new StreamResult(tempPlaylistFile);

                doTransform(transformer, xmlSource, xmlResult, "Movie: " + movie.getBaseName());

                removeBlankLines(tempPlaylistFile.getAbsolutePath());

                fileNames.add(baseName + filenameSuffix);
            }
        } catch (Exception error) {
            LOG.error("Failed generating playlist for video {}", movie.getBaseFilename());
            LOG.error(SystemTools.getStackTrace(error));
        }

        // if the multi part playlists are not required
        if (GENERATE_MULTIPART_PLAYLIST) {
            try {
                if (movie.getFiles().size() > 1) {
                    for (int i = 0; i < movieFileArray.length; i++) {
                        fileNames.add(generateSimplePlaylist(jukebox, movie, movieFileArray, i));
                    }
                }
            } catch (FileNotFoundException | UnsupportedEncodingException error) {
                LOG.error("Failed generating playlist for video {}", movie.getBaseFilename());
                LOG.error(SystemTools.getStackTrace(error));
            }
        }

        return fileNames;
    }

    /**
     * Remove blank lines from the file
     *
     * The PCH does not like blank lines in the playlist.jsp files, so this routine will remove them <br>
     * This routine is only called for the base playlist as this is transformed with the playlist.xsl file and therefore could end
     * up with blank lines in it
     *
     * @param filename
     */
    private static void removeBlankLines(String filename) {
        StringBuilder sb = new StringBuilder();

        try (FileReader inFile = new FileReader(filename); BufferedReader reader = new BufferedReader(inFile)) {
            String br;

            while ((br = reader.readLine()) != null) {
                if (br.trim().length() > 0) {
                    sb.append(br).append("\n");
                }
            }

            try (FileWriter outFile = new FileWriter(filename)) {
                outFile.write(sb.toString());
                outFile.flush();
            }

        } catch (IOException error) {
            LOG.debug("Failed deleting blank lines from {}", filename);
            LOG.error(SystemTools.getStackTrace(error));
        }
    }

    /**
     * Generate playlist with old simple method. The playlist is to be used for playing episodes starting from each episode
     * separately.
     *
     * @param rootPath
     * @param tempRootPath
     * @param movie
     * @param movieFiles
     * @param offset
     * @throws FileNotFoundException
     * @throws UnsupportedEncodingException
     * @return generated file name
     */
    private String generateSimplePlaylist(Jukebox jukebox, Movie movie, MovieFile[] movieFiles, int offset)
            throws FileNotFoundException, UnsupportedEncodingException {
        String fileSuffix = ".playlist" + movieFiles[offset % movieFiles.length].getFirstPart() + ".jsp";
        String baseName = movie.getBaseName();
        String tempFilename = jukebox.getJukeboxTempLocationDetails() + File.separator + baseName;
        File finalPlaylistFile = new File(
                jukebox.getJukeboxRootLocationDetails() + File.separator + baseName + fileSuffix);
        File tempPlaylistFile = new File(tempFilename + fileSuffix);

        if (!finalPlaylistFile.exists() || forceHTMLOverwrite || movie.isDirty(DirtyFlag.INFO)) {
            FileTools.makeDirsForFile(tempPlaylistFile);

            // Issue 237 - Add in the IP address of the MyiHome server so the playlist will work.
            // Issue 237 - It is perfectly valid for "mjb.myiHome.IP" to be blank, in fact this is
            // the normal method for stand alone YAMJ
            try (PrintWriter writer = new PrintWriter(tempPlaylistFile, "UTF-8")) {
                for (int i = 0; i < movieFiles.length; i++) {
                    MovieFile part = movieFiles[(i + offset) % movieFiles.length];
                    // write one line each in the format "name|0|0|IP/path" replacing an | that may exist in the title
                    writer.println(movie.getTitle().replace('|', ' ') + " " + part.getFirstPart() + "|0|0|"
                            + MYIHOME_IP + part.getFilename() + "|");
                }
                writer.flush();
            }
        }
        return baseName + fileSuffix;
    }

    /**
     * Generate the mjb.indexFile page from a template.
     *
     * If the template is not found, then create a default index page
     *
     * @param jukebox
     * @param library
     */
    public void generateMainIndexHTML(Jukebox jukebox, Library library) {
        File jukeboxIndexFile = new File(SKIN_HOME, "jukebox-index.xsl");

        if (jukeboxIndexFile.exists()) {
            generateTransformedIndexHTML(jukebox, library);
        } else {
            generateDefaultIndexHTML(jukebox, library);
        }
    }

    /**
     * Generate a HTML file from a XSL file
     *
     * @param jukebox
     * @param sourceFilename
     * @return
     */
    public boolean transformXmlFile(Jukebox jukebox, String sourceFilename) {
        // check xsl file exists
        File sourceXSL = new File(SKIN_HOME, sourceFilename + ".xsl");
        if (!sourceXSL.exists()) {
            LOG.warn("Source XSL file '{}.xsl' not found", sourceFilename);
            return false;
        }

        // check xml file exists
        File sourceXML = new File(SKIN_HOME, sourceFilename + ".xml");
        if (!sourceXML.exists()) {
            LOG.warn("Source XML file '{}.xsl' not found", sourceFilename);
            return false;
        }

        File targetHMTL = new File(jukebox.getJukeboxTempLocationDetails(), sourceFilename + ".html");

        // Transform the file
        Transformer transformer = getTransformer(sourceXSL, jukebox.getJukeboxTempLocation());

        Source xmlSource = new StreamSource(sourceXML);
        Result xmlResult = new StreamResult(targetHMTL);

        doTransform(transformer, xmlSource, xmlResult, "Skin custom file");

        FileTools.addJukeboxFile(targetHMTL.getName());

        return true;
    }

    /**
     * Use an XSL file to generate the jukebox index file
     *
     * @param jukebox
     * @param library
     */
    private static void generateTransformedIndexHTML(Jukebox jukebox, Library library) {
        LOG.debug("Generating Index file from jukebox-index.xsl");

        String homePage = PropertiesUtil.getProperty("mjb.homePage", "");
        if (StringUtils.isBlank(homePage)) {
            String defCat = library.getDefaultCategory();
            if (defCat != null) {
                homePage = FileTools.createPrefix("Other", HTMLTools.encodeUrl(FileTools.makeSafeFilename(defCat)))
                        + "1";
            } else {
                // figure out something better to do here
                LOG.info("No categories were found, so you should specify mjb.homePage in the config file.");
            }
        }

        try {
            // Create the index.xml file with some properties in it.
            File indexXmlFile = new File(jukebox.getJukeboxTempLocation(), "index.xml");
            FileTools.makeDirsForFile(indexXmlFile);

            Document docIndex = DOMHelper.createDocument();
            Element eRoot = docIndex.createElement("index");
            docIndex.appendChild(eRoot);

            DOMHelper.appendChild(docIndex, eRoot, "detailsDirName", jukebox.getDetailsDirName());
            DOMHelper.appendChild(docIndex, eRoot, "homePage", homePage);

            DOMHelper.writeDocumentToFile(docIndex, indexXmlFile);

            // Now generate the HTML from the XLST
            File htmlFile = new File(jukebox.getJukeboxTempLocation(),
                    PropertiesUtil.getProperty("mjb.indexFile", "index.htm"));
            FileTools.addJukeboxFile(indexXmlFile.getName());
            FileTools.addJukeboxFile(htmlFile.getName());

            Transformer transformer = getTransformer(new File(SKIN_HOME, "jukebox-index.xsl"),
                    jukebox.getJukeboxTempLocation());

            Source xmlSource = new StreamSource(indexXmlFile);
            Result xmlResult = new StreamResult(htmlFile);

            doTransform(transformer, xmlSource, xmlResult, "Jukebox index");
        } catch (ParserConfigurationException | RuntimeException ex) {
            LOG.error("Failed generating jukebox index: {}", ex.getMessage());
            LOG.error(SystemTools.getStackTrace(ex));
        }
    }

    /**
     * Generate a simple jukebox index file from scratch
     *
     * @param jukebox
     * @param library
     */
    private static void generateDefaultIndexHTML(Jukebox jukebox, Library library) {
        @SuppressWarnings("resource")
        OutputStream fos = null;
        XMLStreamWriter writer = null;

        try {
            File htmlFile = new File(jukebox.getJukeboxTempLocation(),
                    PropertiesUtil.getProperty("mjb.indexFile", "index.htm"));
            FileTools.makeDirsForFile(htmlFile);

            fos = FileTools.createFileOutputStream(htmlFile);
            XMLOutputFactory outputFactory = XMLOutputFactory.newInstance();
            writer = outputFactory.createXMLStreamWriter(fos, "UTF-8");

            String homePage = PropertiesUtil.getProperty("mjb.homePage", "");
            if (homePage.length() == 0) {
                String defCat = library.getDefaultCategory();
                if (defCat != null) {
                    homePage = FileTools.createPrefix("Other",
                            HTMLTools.encodeUrl(FileTools.makeSafeFilename(defCat))) + "1";
                } else {
                    // figure out something better to do here
                    LOG.info("No categories were found, so you should specify mjb.homePage in the config file.");
                }
            }

            writer.writeStartDocument();
            writer.writeStartElement("html");
            writer.writeStartElement("head");

            writer.writeStartElement("meta");
            writer.writeAttribute("name", "YAMJ");
            writer.writeAttribute("content", "MovieJukebox");
            writer.writeEndElement();

            writer.writeStartElement("meta");
            writer.writeAttribute("HTTP-EQUIV", "Content-Type");
            writer.writeAttribute("content", "text/html; charset=UTF-8");
            writer.writeEndElement();

            writer.writeStartElement("meta");
            writer.writeAttribute("HTTP-EQUIV", "REFRESH");
            writer.writeAttribute("content", "0; url=" + jukebox.getDetailsDirName() + '/' + homePage + EXT_HTML);
            writer.writeEndElement();

            writer.writeEndElement();
            writer.writeEndElement();
        } catch (XMLStreamException | IOException ex) {
            LOG.error("Failed generating HTML library index: {}", ex.getMessage());
            LOG.error(SystemTools.getStackTrace(ex));
        } finally {
            if (writer != null) {
                try {
                    writer.close();
                } catch (Exception ex) {
                    LOG.trace("Failed to close XMLStreamWriter");
                }
            }

            if (fos != null) {
                try {
                    fos.close();
                } catch (Exception ex) {
                    LOG.trace("Failed to close FileOutputStream");
                }
            }
        }
    }

    public void generateMoviesCategoryHTML(Jukebox jukebox, String filename, String template, boolean isDirty) {
        try {
            // Issue 1886: Html indexes recreated every time
            String destFolder = jukebox.getJukeboxRootLocationDetails();
            File oldFile = FileTools.fileCache.getFile(destFolder + File.separator + filename + EXT_HTML);
            if (oldFile.exists() && !isDirty) {
                return;
            }

            LOG.info("  {}...", filename);

            Source xmlSource;
            File detailsFolder = jukebox.getJukeboxTempLocationDetailsFile();
            File xmlFile = new File(detailsFolder, filename + EXT_XML);
            File oldXmlFile = FileTools.fileCache
                    .getFile(jukebox.getJukeboxRootLocationDetails() + File.separator + filename + EXT_XML);
            File htmlFile = new File(detailsFolder, filename + EXT_HTML);

            FileTools.makeDirsForFile(htmlFile);

            if (xmlFile.exists()) {
                FileTools.addJukeboxFile(xmlFile.getName());
                xmlSource = new StreamSource(xmlFile);
            } else {
                xmlSource = new StreamSource(oldXmlFile);
            }

            FileTools.addJukeboxFile(htmlFile.getName());
            Result xmlResult = new StreamResult(htmlFile);

            Transformer transformer = getTransformer(new File(SKIN_HOME, template),
                    jukebox.getJukeboxTempLocation());
            doTransform(transformer, xmlSource, xmlResult, "Playlist generation");

        } catch (Exception error) {
            LOG.error("Failed generating HTML library category index.");
            LOG.error(SystemTools.getStackTrace(error));
        }
    }

    public void generateMoviesIndexHTML(final Jukebox jukebox, final Library library, ThreadExecutor<Void> tasks)
            throws Throwable {
        tasks.restart();
        for (final IndexInfo idx : library.getGeneratedIndexes()) {
            if (idx.canSkip) { // this is evaluated during XML indexing
                LOG.debug("Category '{}' & '{}' no change detected, skipping HTML generation.", idx.categoryName,
                        idx.key);

                // Add the index files to the cache so they aren't deleted
                for (int page = 1; page <= idx.pages; page++) {
                    FileTools.addJukeboxFile(idx.baseName + page + EXT_XML);
                    FileTools.addJukeboxFile(idx.baseName + page + EXT_HTML);
                }
            } else {
                LOG.debug("Category '{}' & '{}'", idx.categoryName, idx.key);
                tasks.submit(new Callable<Void>() {
                    @Override
                    public Void call() {
                        for (int page = 1; page <= idx.pages; page++) {
                            writeSingleIndexPage(jukebox, idx, page);
                        }
                        return null;
                    }
                });
            }
        }

        tasks.waitFor();
    }

    private static void writeSingleIndexPage(Jukebox jukebox, IndexInfo idx, int page) {
        try {
            File detailsDir = jukebox.getJukeboxTempLocationDetailsFile();
            FileTools.makeDirs(detailsDir);

            String filename = idx.baseName + page;

            File xmlFile = new File(detailsDir, filename + EXT_XML);

            FileTools.addJukeboxFile(xmlFile.getName());
            String indexList = PropertiesUtil.getProperty("mjb.view.indexList", "index.xsl");

            for (final String indexStr : indexList.split(",")) {
                String suffix = "";
                if (!"index.xsl".equals(indexStr)) {
                    suffix = indexStr.replace("index", "").replace(EXT_XSL, "");
                }

                File htmlFile = new File(detailsDir, filename + suffix + EXT_HTML);
                FileTools.addJukeboxFile(htmlFile.getName());

                File transformCatKey = new File(SKIN_HOME,
                        FileTools.makeSafeFilename(idx.categoryName + "_" + idx.key) + EXT_XSL);
                File transformCategory = new File(SKIN_HOME,
                        FileTools.makeSafeFilename(idx.categoryName) + EXT_XSL);
                File transformBase = new File(SKIN_HOME, indexStr);

                Transformer transformer;

                if (transformCatKey.exists()) {
                    LOG.debug("Using CategoryKey transformation {} for {}", transformCatKey.getName(),
                            xmlFile.getName());
                    transformer = getTransformer(transformCatKey, jukebox.getJukeboxTempLocationDetails());
                } else if (transformCategory.exists()) {
                    LOG.debug("Using Category transformation {} for {}", transformCategory.getName(),
                            xmlFile.getName());
                    transformer = getTransformer(transformCategory, jukebox.getJukeboxTempLocationDetails());
                } else {
                    transformer = getTransformer(transformBase, jukebox.getJukeboxTempLocationDetails());
                }

                // Transformer transformer = getTransformer(new File(skinHome, "index.xsl"), rootPath);
                Source xmlSource = new StreamSource(xmlFile);
                Result xmlResult = new StreamResult(htmlFile);

                doTransform(transformer, xmlSource, xmlResult, "Category page");
            }
        } catch (Exception error) {
            LOG.error("Failed generating HTML library index for Category: {}, Key: {}, Page: {}", idx.categoryName,
                    idx.key, page);
            LOG.error(SystemTools.getStackTrace(error));
        }
    }

    /**
     * Creates and caches Transformer, one for every thread/xsl file.
     *
     * @param xslFile
     * @param styleSheetTargetRootPath
     * @return
     */
    public static Transformer getTransformer(File xslFile, String styleSheetTargetRootPath) {
        /*
         * Removed caching of transformer, as saxon keeps all parsed documents in memory, causing memory leaks.
         * Creating a new transformer every time doesn't consume too much time and has no impact on performance.
         * It lets YAMJ save lot of memory.
         * @author Vincent
         */
        Source xslSource = new StreamSource(xslFile);

        // Sometimes the StreamSource doesn't return an object and we get a null pointer exception, so check it and try loading it again
        for (int looper = 1; looper < 5; looper++) {
            xslSource = new StreamSource(xslFile);
            if (xslSource != null) {
                // looks ok, so quit the loop
                break;
            }
        }

        Transformer transformer = null;
        try {
            transformer = TRANSFORMER.newTransformer(xslSource);
            transformer.setParameter("homePage", INDEX_HTML_FILE);
            transformer.setParameter("rootPath",
                    new File(styleSheetTargetRootPath).getAbsolutePath().replace('\\', '/'));
            for (Entry<Object, Object> e : PropertiesUtil.getEntrySet()) {
                if (e.getKey() != null && e.getValue() != null) {
                    transformer.setParameter(e.getKey().toString(), e.getValue().toString());
                }
            }
        } catch (TransformerConfigurationException ex) {
            LOG.error("Failed to get transformer for XSL: " + xslFile.getAbsolutePath());
            LOG.warn(SystemTools.getStackTrace(ex));
        }
        return transformer;
    }

    /**
     * Try to safely perform the transformation. Will retry up to maxRetryCount times before throwing the error
     *
     * @param transformer
     * @param xmlSource
     * @param xmlResult
     * @param message Message to print if there is an error
     * @throws Exception
     */
    private static void doTransform(Transformer transformer, Source xmlSource, Result xmlResult, String message) {
        int retryCount = 0;

        do {
            try {
                transformer.transform(xmlSource, xmlResult);
                return; // If the transform didn't throw an error, return
            } catch (TransformerException ex) {
                int retryTimes = MAX_RETRY_COUNT - ++retryCount;

                if (retryTimes == 0) {
                    // We've exceeded the maximum number of retries, so throw the exception and quit
                    throw new RuntimeException("Failed generating HTML, retries exceeded: " + ex.getMessage(), ex);
                }

                LOG.debug("Failed generating HTML, will retry {} more time{}. {}", retryTimes,
                        (retryTimes == 1 ? "" : "s"), message);
                try {
                    Thread.sleep(500); // Sleep for 1/2 second to hopefully let the issue go away
                } catch (InterruptedException ex1) {
                    // We don't care if we're interrupted
                }
            }
        } while (retryCount <= MAX_RETRY_COUNT);
    }
}