com.moviejukebox.writer.MovieJukeboxXMLWriter.java Source code

Java tutorial

Introduction

Here is the source code for com.moviejukebox.writer.MovieJukeboxXMLWriter.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 static com.moviejukebox.tools.PropertiesUtil.FALSE;
import static com.moviejukebox.tools.PropertiesUtil.TRUE;

import com.moviejukebox.model.*;
import com.moviejukebox.model.attachment.Attachment;
import com.moviejukebox.model.comparator.CertificationComparator;
import com.moviejukebox.model.comparator.IndexComparator;
import com.moviejukebox.model.comparator.SortIgnorePrefixesComparator;
import com.moviejukebox.model.enumerations.*;
import com.moviejukebox.plugin.ImdbPlugin;
import com.moviejukebox.tools.*;
import java.io.File;
import java.io.FileNotFoundException;
import java.util.*;
import java.util.Map.Entry;
import java.util.concurrent.Callable;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.stream.XMLStreamException;
import org.apache.commons.lang3.StringUtils;
import org.pojava.datetime.DateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.DOMException;
import org.w3c.dom.Document;
import org.w3c.dom.Element;

/**
 * Parse/Write XML files for movie details and library indexes
 *
 * @author Julien
 * @author Stuart.Boston
 */
public class MovieJukeboxXMLWriter {

    private static final Logger LOG = LoggerFactory.getLogger(MovieJukeboxXMLWriter.class);
    private static final GitRepositoryState GIT = new GitRepositoryState();
    // Literals
    public static final String EXT_XML = ".xml";
    public static final String EXT_HTML = ".html";
    // String to append to the eversion categories file if needed
    public static final String EV_FILE_SUFFIX = "_small";
    public static final String WON = "won";
    public static final String MOVIE = "movie";
    public static final String MOVIEDB = "moviedb";
    public static final String BASE_FILENAME = "baseFilename";
    public static final String TITLE = "title";
    public static final String ORIGINAL_TITLE = "originalTitle";
    public static final String SORT_TITLE = "titleSort";
    public static final String YEAR = "year";
    public static final String COUNTRY = "country";
    public static final String ORDER = "order";
    public static final String LANGUAGE = "language";
    public static final String YES = "YES";
    public static final String TRAILER_LAST_SCAN = "trailerLastScan";
    public static final String CHARACTER = "character";
    public static final String NAME = "name";
    public static final String DEPARTMENT = "department";
    public static final String JOB = "job";
    public static final String URL = "url";
    public static final String ID = "id_";
    public static final String SEASON = "season";
    public static final String PART = "part";
    public static final String RATING = "rating";
    public static final String COUNT = "count";
    public static final String INDEX = "index";
    public static final String ORIGINAL_NAME = "originalName";
    public static final String DETAILS = "details";
    public static final String SOURCE = "source";
    private static final boolean FORCE_XML_OVERWRITE = PropertiesUtil.getBooleanProperty("mjb.forceXMLOverwrite",
            Boolean.FALSE);
    private static final boolean FORCE_INDEX_OVERWRITE = PropertiesUtil
            .getBooleanProperty("mjb.forceIndexOverwrite", Boolean.FALSE);
    private final int nbMoviesPerPage;
    private final int nbMoviesPerLine;
    private int nbTvShowsPerPage;
    private int nbTvShowsPerLine;
    private int nbSetMoviesPerPage;
    private int nbSetMoviesPerLine;
    private int nbTVSetMoviesPerPage;
    private int nbTVSetMoviesPerLine;
    private final boolean fullMovieInfoInIndexes;
    private final boolean fullCategoriesInIndexes;
    private final boolean includeMoviesInCategories;
    private final boolean includeEpisodePlots;
    private final int episodePlotSize;
    private final boolean includeVideoImages;
    private final boolean includeEpisodeRating;
    private static final boolean IS_PLAYON_HD = PropertiesUtil.getBooleanProperty("mjb.PlayOnHD", Boolean.FALSE);
    private static final boolean IS_EXTENDED_URL = PropertiesUtil
            .getBooleanProperty("mjb.scanner.mediainfo.rar.extended.url", Boolean.FALSE);
    private static final String DEFAULT_SOURCE = PropertiesUtil.getProperty("filename.scanner.source.default",
            Movie.UNKNOWN);
    private final List<String> categoriesExplodeSet = Arrays
            .asList(PropertiesUtil.getProperty("mjb.categories.explodeSet", "").split(","));
    private final boolean removeExplodeSet = PropertiesUtil
            .getBooleanProperty("mjb.categories.explodeSet.removeSet", Boolean.FALSE);
    private final boolean keepTVExplodeSet = PropertiesUtil.getBooleanProperty("mjb.categories.explodeSet.keepTV",
            Boolean.TRUE);
    private final boolean beforeSortExplodeSet = PropertiesUtil
            .getBooleanProperty("mjb.categories.explodeSet.beforeSort", Boolean.FALSE);
    private static final List<String> CATEGORIES_DISPLAY_LIST = initDisplayList();
    private static final List<String> CATEGORIES_LIMIT_LIST = Arrays.asList(
            PropertiesUtil.getProperty("mjb.categories.limitList", "Cast,Director,Writer,Person").split(","));
    private static final boolean WRITE_NFO_FILES = PropertiesUtil.getBooleanProperty("filename.nfo.writeFiles",
            Boolean.FALSE);
    private static final List<String> INDEXES_FOR_CATEGORIES_XML = Arrays
            .asList("Other,Genres,Title,Year,Library,Set".split(","));
    private final boolean setsExcludeTV;
    private static final String PEOPLE_FOLDER = initPeopleFolder();
    private static final boolean ENABLE_WATCH_SCANNER = PropertiesUtil.getBooleanProperty("watched.scanner.enable",
            Boolean.TRUE);
    // Should we scrape people information
    private static final boolean ENABLE_PEOPLE = PropertiesUtil.getBooleanProperty("mjb.people", Boolean.FALSE);
    private static final boolean ADD_PEOPLE_INFO = PropertiesUtil.getBooleanProperty("mjb.people.addInfo",
            Boolean.FALSE);
    // Should we scrape the award information
    private static final boolean ENABLE_AWARDS = PropertiesUtil.getBooleanProperty("mjb.scrapeAwards",
            Boolean.FALSE) || PropertiesUtil.getProperty("mjb.scrapeAwards", "").equalsIgnoreCase(WON);
    // Should we scrape the business information
    private static final boolean ENABLE_BUSINESS = PropertiesUtil.getBooleanProperty("mjb.scrapeBusiness",
            Boolean.FALSE);
    // Should we scrape the trivia information
    private static final boolean ENABLE_TRIVIA = PropertiesUtil.getBooleanProperty("mjb.scrapeTrivia",
            Boolean.FALSE);
    // Retrieve the title sort type
    private static final TitleSortType TITLE_SORT_TYPE = TitleSortType
            .fromString(PropertiesUtil.getProperty("mjb.sortTitle", "title"));
    // Should we reindex the New / Watched / Unwatched categories?
    private boolean reindexNew = Boolean.FALSE;
    private boolean reindexWatched = Boolean.FALSE;
    private boolean reindexUnwatched = Boolean.FALSE;
    private final boolean XML_COMPATIBLE = PropertiesUtil.getBooleanProperty("mjb.XMLcompatible", Boolean.FALSE);
    private final boolean SORT_LIBRARY = PropertiesUtil.getBooleanProperty("indexing.sort.libraries", Boolean.TRUE);

    public MovieJukeboxXMLWriter() {
        nbMoviesPerPage = PropertiesUtil.getIntProperty("mjb.nbThumbnailsPerPage", 10);
        nbMoviesPerLine = PropertiesUtil.getIntProperty("mjb.nbThumbnailsPerLine", 5);
        nbTvShowsPerPage = PropertiesUtil.getIntProperty("mjb.nbTvThumbnailsPerPage", 0); // If 0 then use the Movies setting
        nbTvShowsPerLine = PropertiesUtil.getIntProperty("mjb.nbTvThumbnailsPerLine", 0); // If 0 then use the Movies setting
        nbSetMoviesPerPage = PropertiesUtil.getIntProperty("mjb.nbSetThumbnailsPerPage", 0); // If 0 then use the Movies setting
        nbSetMoviesPerLine = PropertiesUtil.getIntProperty("mjb.nbSetThumbnailsPerLine", 0); // If 0 then use the Movies setting
        nbTVSetMoviesPerPage = PropertiesUtil.getIntProperty("mjb.nbTVSetThumbnailsPerPage", 0); // If 0 then use the TV SHOW setting
        nbTVSetMoviesPerLine = PropertiesUtil.getIntProperty("mjb.nbTVSetThumbnailsPerLine", 0); // If 0 then use the TV SHOW setting
        fullMovieInfoInIndexes = PropertiesUtil.getBooleanProperty("mjb.fullMovieInfoInIndexes", Boolean.FALSE);
        fullCategoriesInIndexes = PropertiesUtil.getBooleanProperty("mjb.fullCategoriesInIndexes", Boolean.TRUE);
        includeMoviesInCategories = PropertiesUtil.getBooleanProperty("mjb.includeMoviesInCategories",
                Boolean.FALSE);
        includeEpisodePlots = PropertiesUtil.getBooleanProperty("mjb.includeEpisodePlots", Boolean.FALSE);
        episodePlotSize = PropertiesUtil.getIntProperty("mjb.episodePlotSize", 0);
        includeVideoImages = PropertiesUtil.getBooleanProperty("mjb.includeVideoImages", Boolean.FALSE);
        includeEpisodeRating = PropertiesUtil.getBooleanProperty("mjb.includeEpisodeRating", Boolean.FALSE);
        setsExcludeTV = PropertiesUtil.getBooleanProperty("mjb.sets.excludeTV", Boolean.FALSE);

        if (nbTvShowsPerPage == 0) {
            nbTvShowsPerPage = nbMoviesPerPage;
        }

        if (nbTvShowsPerLine == 0) {
            nbTvShowsPerLine = nbMoviesPerLine;
        }

        if (nbSetMoviesPerPage == 0) {
            nbSetMoviesPerPage = nbMoviesPerPage;
        }

        if (nbSetMoviesPerLine == 0) {
            nbSetMoviesPerLine = nbMoviesPerLine;
        }

        if (nbTVSetMoviesPerPage == 0) {
            nbTVSetMoviesPerPage = nbTvShowsPerPage;
        }

        if (nbTVSetMoviesPerLine == 0) {
            nbTVSetMoviesPerLine = nbTvShowsPerLine;
        }
    }

    /**
     * Set up the people folder
     *
     * @return
     */
    private static String initPeopleFolder() {
        // Issue 1947: Cast enhancement - option to save all related files to a specific folder
        String folder = PropertiesUtil.getProperty("mjb.people.folder", "");
        if (StringTools.isNotValidString(folder)) {
            folder = "";
        } else if (!folder.endsWith(File.separator)) {
            folder += File.separator;
        }
        return folder;
    }

    /**
     * Set up the display list
     *
     * @return
     */
    private static List<String> initDisplayList() {
        String strCategoriesDisplayList = PropertiesUtil.getProperty("mjb.categories.displayList", "");
        if (strCategoriesDisplayList.length() == 0) {
            strCategoriesDisplayList = PropertiesUtil.getProperty("mjb.categories.indexList",
                    "Other,Genres,Title,Certification,Year,Library,Set");
        }
        return Arrays.asList(strCategoriesDisplayList.split(","));
    }

    public void writeCategoryXML(Jukebox jukebox, Library library, String filename, boolean isDirty)
            throws ParserConfigurationException {
        // Issue 1886: HTML indexes recreated every time
        File oldFile = FileTools.fileCache
                .getFile(jukebox.getJukeboxRootLocationDetails() + File.separator + filename + EXT_XML);

        if (oldFile.exists() && !isDirty) {
            // Even if the library is not dirty, these files need to be added to the safe jukebox list
            FileTools.addJukeboxFile(filename + EXT_XML);
            FileTools.addJukeboxFile(filename + EXT_HTML);
            return;
        }

        FileTools.makeDirs(jukebox.getJukeboxTempLocationDetailsFile());

        File xmlFile = new File(jukebox.getJukeboxTempLocationDetailsFile(), filename + EXT_XML);
        FileTools.addJukeboxFile(filename + EXT_XML);

        Document xmlDoc = DOMHelper.createDocument();
        Element eLibrary = xmlDoc.createElement("library");
        int libraryCount = 0;

        // Issue 1148, generate category in the order specified in properties
        LOG.info("  Indexing {}...", filename);
        for (String categoryName : CATEGORIES_DISPLAY_LIST) {
            int categoryMinCount = Library.calcMinCategoryCount(categoryName);
            int categoryCount = 0;

            for (Entry<String, Index> category : library.getIndexes().entrySet()) {
                // Category not empty and match the current cat.
                if (!category.getValue().isEmpty() && categoryName.equalsIgnoreCase(category.getKey())
                        && (filename.equals(Library.INDEX_CATEGORIES) || filename.equals(category.getKey()))) {
                    Element eCategory = xmlDoc.createElement("category");

                    eCategory.setAttribute(NAME, category.getKey());

                    if ("Other".equalsIgnoreCase(categoryName)) {
                        // Process the other category using the order listed in the category.xml file
                        Map<String, String> cm = new LinkedHashMap<>(library.getCategoriesMap());

                        // Tidy up the new categories if needed
                        String newAll = cm.get(Library.INDEX_NEW);
                        String newTV = cm.get(Library.INDEX_NEW_TV);
                        String newMovie = cm.get(Library.INDEX_MOVIES);

                        // If the New-TV is named the same as the New, remove it
                        if (StringUtils.isNotBlank(newAll) && StringUtils.isNotBlank(newTV)
                                && newAll.equalsIgnoreCase(newTV)) {
                            cm.remove(Library.INDEX_NEW_TV);
                        }

                        // If the New-Movie is named the same as the New, remove it
                        if (StringUtils.isNotBlank(newAll) && StringUtils.isNotBlank(newMovie)
                                && newAll.equalsIgnoreCase(newMovie)) {
                            cm.remove(Library.INDEX_NEW_MOVIE);
                        }

                        // If the New-TV is named the same as the New-Movie, remove it
                        if (StringUtils.isNotBlank(newTV) && StringUtils.isNotBlank(newMovie)
                                && newTV.equalsIgnoreCase(newMovie)) {
                            cm.remove(Library.INDEX_NEW_TV);
                        }

                        for (Map.Entry<String, String> catOriginalName : cm.entrySet()) {
                            String catNewName = catOriginalName.getValue();
                            if (category.getValue().containsKey(catNewName)) {
                                Element eCatIndex = processCategoryIndex(xmlDoc, catNewName,
                                        catOriginalName.getKey(), category.getValue().get(catNewName), categoryName,
                                        categoryMinCount, library);
                                if (eCatIndex != null) {
                                    eCategory.appendChild(eCatIndex);
                                    categoryCount++;
                                }
                            }
                        }
                    } else {
                        Map<String, List<Movie>> sortedMap;

                        // Sort the certification according to certification.ordering
                        if (Library.INDEX_CERTIFICATION.equalsIgnoreCase(categoryName)) {
                            List<String> certificationOrdering = new ArrayList<>();
                            String certificationOrder = PropertiesUtil.getProperty("certification.ordering");
                            if (StringUtils.isNotBlank(certificationOrder)) {
                                for (String cert : certificationOrder.split(",")) {
                                    certificationOrdering.add(cert.trim());
                                }
                            }

                            // Process the certification in order of the certification.ordering property
                            sortedMap = new TreeMap<>(new CertificationComparator(certificationOrdering));
                        } else if (!SORT_LIBRARY && Library.INDEX_LIBRARY.equalsIgnoreCase(categoryName)) {
                            // Issue 2359, disable sorting the list of libraries so that the entries in categories.xml are written in the same order as the list of libraries in library.xml
                            sortedMap = new TreeMap<>(new CertificationComparator(Library.getLibraryOrdering()));
                        } else {
                            // Sort the remaining categories
                            sortedMap = new TreeMap<>(new SortIgnorePrefixesComparator());
                        }
                        sortedMap.putAll(category.getValue());

                        for (Map.Entry<String, List<Movie>> index : sortedMap.entrySet()) {
                            Element eCatIndex = processCategoryIndex(xmlDoc, index.getKey(), index.getKey(),
                                    index.getValue(), categoryName, categoryMinCount, library);
                            if (eCatIndex != null) {
                                eCategory.appendChild(eCatIndex);
                                categoryCount++;
                            }
                        }
                    }

                    // If there is nothing in the category, don't write it out
                    if (categoryCount > 0) {
                        if (!IS_PLAYON_HD) {
                            // Add the correct count to the index
                            eCategory.setAttribute(COUNT, String.valueOf(categoryCount));
                        }
                        eLibrary.appendChild(eCategory);
                        libraryCount++;
                    }
                }
            }
        }

        // Add the movie count to the library
        eLibrary.setAttribute(COUNT, String.valueOf(libraryCount));

        // Add the library node to the document
        xmlDoc.appendChild(eLibrary);

        // Add in the movies to the categories if needed
        if (includeMoviesInCategories) {
            // For the Categories file we want to split out a version without the movies for Eversion
            if (Library.INDEX_CATEGORIES.equals(filename)) {
                LOG.debug("Writing non-movie categories file...");
                // Create the eversion filename
                File xmlEvFile = new File(jukebox.getJukeboxTempLocationDetailsFile(),
                        filename + EV_FILE_SUFFIX + EXT_XML);
                // Add the eversion file to the cleanup list
                FileTools.addJukeboxFile(xmlEvFile.getName());

                // Write out the current index before adding the movies
                DOMHelper.writeDocumentToFile(xmlDoc, xmlEvFile);
            }

            Element eMovie;
            for (Movie movie : library.getMoviesList()) {
                if (fullMovieInfoInIndexes) {
                    eMovie = writeMovie(xmlDoc, movie, library);
                } else {
                    eMovie = writeMovieForIndex(xmlDoc, movie);
                }

                // Add the movie
                eLibrary.appendChild(eMovie);
            }
        }

        DOMHelper.writeDocumentToFile(xmlDoc, xmlFile);
    }

    private Element processCategoryIndex(Document doc, String indexName, String indexOriginalName,
            List<Movie> indexMovies, String categoryKey, int categoryMinCount, Library library) {
        List<Movie> allMovies = library.getMoviesList();
        int countMovieCat = library.getMovieCountForIndex(categoryKey, indexName);
        boolean skipSet = "Set".equalsIgnoreCase(categoryKey) && countMovieCat <= 1;

        LOG.debug("Index: {}, Category: {}, count: {}", categoryKey, indexName, indexMovies.size());

        JukeboxStatistic js = JukeboxStatistic.fromString("index_" + categoryKey);
        JukeboxStatistics.setStatistic(js, indexMovies.size());

        // Display a message about the category we're indexing
        if (countMovieCat < categoryMinCount && (skipSet || !INDEXES_FOR_CATEGORIES_XML.contains(categoryKey))) {
            LOG.debug(
                    "Category '{}' Index '{}' does not contain enough videos ({}/{}), not adding to categories.xml.",
                    categoryKey, indexName, countMovieCat, categoryMinCount);
            return null;
        }

        if (setsExcludeTV && categoryKey.equalsIgnoreCase(Library.INDEX_SET) && indexMovies.get(0).isTVShow()) {
            // Do not include the video in the set because it's a TV show
            return null;
        }

        String indexFilename = FileTools.makeSafeFilename(FileTools.createPrefix(categoryKey, indexName)) + "1";

        Element eCategory = doc.createElement(INDEX);
        eCategory.setAttribute(NAME, indexName);
        eCategory.setAttribute(ORIGINAL_NAME, indexOriginalName);

        if (includeMoviesInCategories) {
            eCategory.setAttribute("filename", indexFilename);

            for (Identifiable movie : indexMovies) {
                DOMHelper.appendChild(doc, eCategory, MOVIE, String.valueOf(allMovies.indexOf(movie)));
            }
        } else {
            eCategory.setTextContent(indexFilename);
        }

        return eCategory;
    }

    /**
     * Write the set of index XML files for the library
     *
     * @param jukebox
     * @param library
     * @param tasks
     * @throws Throwable
     */
    public void writeIndexXML(final Jukebox jukebox, final Library library, ThreadExecutor<Void> tasks)
            throws Throwable {
        int indexCount = 0;
        int indexSize = library.getIndexes().size();

        final boolean setReindex = PropertiesUtil.getBooleanProperty("mjb.sets.reindex", Boolean.TRUE);

        StringBuilder loggerString;

        tasks.restart();

        for (Map.Entry<String, Index> category : library.getIndexes().entrySet()) {
            final String categoryName = category.getKey();
            Map<String, List<Movie>> index = category.getValue();
            final int categoryMinCount = Library.calcMinCategoryCount(categoryName);
            int categoryMaxCount = Library.calcMaxCategoryCount(categoryName);
            final int movieMaxCount = Library.calcMaxMovieCount(categoryName);
            final boolean toLimitCategory = CATEGORIES_LIMIT_LIST.contains(categoryName);

            loggerString = new StringBuilder("  Indexing ");
            loggerString.append(categoryName).append(" (").append(++indexCount).append("/").append(indexSize);
            loggerString.append(") contains ").append(index.size())
                    .append(index.size() == 1 ? " index" : " indexes");
            loggerString.append(toLimitCategory && categoryMaxCount > 0 && index.size() > categoryMaxCount
                    ? (" (limit to " + categoryMaxCount + ")")
                    : "");
            LOG.info(loggerString.toString());

            List<Map.Entry<String, List<Movie>>> groupArray = new ArrayList<>(index.entrySet());
            Collections.sort(groupArray, new IndexComparator(library, categoryName));
            Iterator<Map.Entry<String, List<Movie>>> itr = groupArray.iterator();

            int currentCategoryCount = 0;
            while (itr.hasNext()) {
                final Map.Entry<String, List<Movie>> group = itr.next();
                tasks.submit(new Callable<Void>() {
                    @Override
                    public Void call() throws XMLStreamException, FileNotFoundException {
                        List<Movie> movies = group.getValue();
                        String key = FileTools.createCategoryKey(group.getKey());
                        String categoryPath = categoryName + " " + key;

                        // FIXME This is horrible! Issue 735 will get rid of it.
                        int categoryCount = library.getMovieCountForIndex(categoryName, key);
                        if (categoryCount < categoryMinCount
                                && !Arrays.asList("Other,Genres,Title,Year,Library,Set".split(","))
                                        .contains(categoryName)
                                && !Library.INDEX_SET.equalsIgnoreCase(categoryName)) {
                            StringBuilder loggerString = new StringBuilder();
                            loggerString.append("Category '").append(categoryPath)
                                    .append("' does not contain enough videos (");
                            loggerString.append(categoryCount).append("/").append(categoryMinCount)
                                    .append("), skipping XML generation.");
                            LOG.debug(loggerString.toString());
                            return null;
                        }
                        boolean skipIndex = !FORCE_INDEX_OVERWRITE;

                        // Try and determine if the set contains TV shows and therefore use the TV show settings
                        // TODO have a custom property so that you can set this on a per-set basis.
                        int nbVideosPerPage = nbMoviesPerPage, nbVideosPerLine = nbMoviesPerLine;

                        if (!movies.isEmpty()) {
                            if (key.equalsIgnoreCase(Library.getRenamedCategory(Library.INDEX_TVSHOWS))) {
                                nbVideosPerPage = nbTvShowsPerPage;
                                nbVideosPerLine = nbTvShowsPerLine;
                            }

                            if (categoryName.equalsIgnoreCase(Library.INDEX_SET)) {
                                if (movies.get(0).isTVShow()) {
                                    nbVideosPerPage = nbTVSetMoviesPerPage;
                                    nbVideosPerLine = nbTVSetMoviesPerLine;
                                } else {
                                    nbVideosPerPage = nbSetMoviesPerPage;
                                    nbVideosPerLine = nbSetMoviesPerLine;
                                    // Issue 1886: HTML indexes recreated every time
                                    for (Movie m : library.getMoviesList()) {
                                        if (m.isSetMaster() && m.getTitle().equals(key)) {
                                            skipIndex &= !m.isDirty(DirtyFlag.INFO);
                                            break;
                                        }
                                    }
                                }
                            }
                        }

                        List<Movie> tmpMovieList = movies;
                        int moviepos = 0;
                        for (Movie movie : movies) {
                            // Don't skip the index if the movie is dirty
                            if (movie.isDirty(DirtyFlag.INFO) || movie.isDirty(DirtyFlag.RECHECK)) {
                                skipIndex = false;
                            }

                            // Check for changes to the Watched, Unwatched and New categories whilst we are processing the All category
                            if (ENABLE_WATCH_SCANNER && key.equals(Library.getRenamedCategory(Library.INDEX_ALL))) {
                                if (movie.isWatched() && movie.isDirty(DirtyFlag.WATCHED)) {
                                    // Don't skip the index
                                    reindexWatched = true;
                                    reindexUnwatched = true;
                                    reindexNew = true;
                                }

                                if (!movie.isWatched() && movie.isDirty(DirtyFlag.WATCHED)) {
                                    // Don't skip the index
                                    reindexWatched = true;
                                    reindexUnwatched = true;
                                    reindexNew = true;
                                }
                            }

                            // Check to see if we are in one of the category indexes
                            if (reindexNew && (key.equals(Library.getRenamedCategory(Library.INDEX_NEW))
                                    || key.equals(Library.getRenamedCategory(Library.INDEX_NEW_MOVIE))
                                    || key.equals(Library.getRenamedCategory(Library.INDEX_NEW_TV)))) {
                                skipIndex = false;
                            }

                            if (reindexWatched && key.equals(Library.getRenamedCategory(Library.INDEX_WATCHED))) {
                                skipIndex = false;
                            }

                            if (reindexUnwatched
                                    && key.equals(Library.getRenamedCategory(Library.INDEX_UNWATCHED))) {
                                skipIndex = false;
                            }

                            if (!beforeSortExplodeSet) {
                                // Issue 1263 - Allow explode of Set in category .
                                if (movie.isSetMaster()
                                        && (categoriesExplodeSet.contains(categoryName)
                                                || categoriesExplodeSet.contains(group.getKey()))
                                        && (!keepTVExplodeSet || !movie.isTVShow())) {
                                    List<Movie> boxedSetMovies = library.getIndexes().get(Library.INDEX_SET)
                                            .get(movie.getTitle());
                                    boxedSetMovies = library.getMatchingMoviesList(categoryName, boxedSetMovies,
                                            key);
                                    LOG.debug("Exploding set for {}[{}] {}", categoryPath, movie.getTitle(),
                                            boxedSetMovies.size());
                                    // delay new instance
                                    if (tmpMovieList == movies) {
                                        tmpMovieList = new ArrayList<>(movies);
                                    }

                                    // do we want to keep the set?
                                    // Issue 2002: remove SET item after explode of Set in category
                                    if (removeExplodeSet) {
                                        tmpMovieList.remove(moviepos);
                                    }
                                    tmpMovieList.addAll(moviepos, boxedSetMovies);
                                    moviepos += boxedSetMovies.size() - 1;
                                }
                                moviepos++;
                            }
                        }

                        if (toLimitCategory && movieMaxCount > 0 && tmpMovieList.size() > movieMaxCount) {
                            LOG.debug("Limiting category {} {} {} -> {}", categoryName, key, tmpMovieList.size(),
                                    movieMaxCount);
                            while (tmpMovieList.size() > movieMaxCount) {
                                tmpMovieList.remove(tmpMovieList.size() - 1);
                            }
                        }

                        int last = 1 + (tmpMovieList.size() - 1) / nbVideosPerPage;
                        int previous = last;
                        moviepos = 0;
                        skipIndex = (skipIndex && Library.INDEX_LIBRARY.equalsIgnoreCase(categoryName))
                                ? !library.isDirtyLibrary(group.getKey())
                                : skipIndex;
                        IndexInfo idx = new IndexInfo(categoryName, key, last, nbVideosPerPage, nbVideosPerLine,
                                skipIndex);

                        // Don't skip the indexing for sets as this overwrites the set files
                        if (Library.INDEX_SET.equalsIgnoreCase(categoryName) && setReindex) {
                            LOG.trace("Forcing generation of set index.");
                            skipIndex = false;
                        }

                        for (int current = 1; current <= last; current++) {
                            if (setReindex && Library.INDEX_SET.equalsIgnoreCase(categoryName)) {
                                idx.canSkip = false;
                            } else {
                                idx.checkSkip(current, jukebox.getJukeboxRootLocationDetails());
                                skipIndex &= idx.canSkip;
                            }
                        }

                        if (skipIndex && Library.INDEX_PERSON.equalsIgnoreCase(idx.categoryName)) {
                            for (Person person : library.getPeople()) {
                                if (!person.getName().equalsIgnoreCase(idx.key)) {
                                    continue;
                                }
                                if (!person.isDirty()) {
                                    continue;
                                }
                                skipIndex = false;
                                break;
                            }
                        }

                        if (skipIndex) {
                            LOG.debug("Category '{}' - no change detected, skipping XML generation.", categoryPath);

                            // Add the existing file to the cache so they aren't deleted
                            for (int current = 1; current <= last; current++) {
                                String name = idx.baseName + current + EXT_XML;
                                FileTools.addJukeboxFile(name);
                            }
                        } else {
                            LOG.debug("Category '{}' - generating {} XML file{}", categoryPath, last,
                                    last == 1 ? "." : "s.");

                            int next;
                            for (int current = 1; current <= last; current++) {
                                // All pages are handled here
                                next = (current % last) + 1; // this gives 1 for last
                                writeIndexPage(library,
                                        tmpMovieList.subList(moviepos,
                                                Math.min(moviepos + nbVideosPerPage, tmpMovieList.size())),
                                        jukebox.getJukeboxTempLocationDetails(), idx, previous, current, next, last,
                                        tmpMovieList.size());

                                moviepos += nbVideosPerPage;
                                previous = current;
                            }
                        }

                        library.addGeneratedIndex(idx);
                        return null;
                    }
                });
                currentCategoryCount++;
                if (toLimitCategory && categoryMaxCount > 0 && currentCategoryCount >= categoryMaxCount) {
                    break;
                }
            }
        }
        tasks.waitFor();
    }

    /**
     * Write out the index pages
     *
     * @param library
     * @param movies
     * @param rootPath
     * @param idx
     * @param previous
     * @param current
     * @param next
     * @param last
     * @param indexCount
     */
    public void writeIndexPage(Library library, List<Movie> movies, String rootPath, IndexInfo idx, int previous,
            int current, int next, int last, int indexCount) {
        String prefix = idx.baseName;
        File xmlFile = new File(rootPath, prefix + current + EXT_XML);

        Document xmlDoc;
        try {
            xmlDoc = DOMHelper.createDocument();
        } catch (ParserConfigurationException error) {
            LOG.error("Failed writing index page: {}", xmlFile.getName());
            LOG.error(SystemTools.getStackTrace(error));
            return;
        }

        FileTools.addJukeboxFile(xmlFile.getName());
        boolean isCurrentKey;

        Element eLibrary = xmlDoc.createElement("library");
        int libraryCount = 0;

        for (Map.Entry<String, Index> category : library.getIndexes().entrySet()) {
            Element eCategory;
            int categoryCount = 0;

            String categoryKey = category.getKey();
            Map<String, List<Movie>> index = category.getValue();

            // Is this the current category?
            isCurrentKey = categoryKey.equalsIgnoreCase(idx.categoryName);
            if (!isCurrentKey && !fullCategoriesInIndexes) {
                // This isn't the current index, so we don't want it
                continue;
            }

            eCategory = xmlDoc.createElement("category");
            eCategory.setAttribute(NAME, categoryKey);
            if (isCurrentKey) {
                eCategory.setAttribute("current", TRUE);
            }

            int indexSize;
            if ("other".equalsIgnoreCase(categoryKey)) {
                // Process the other category using the order listed in the category.xml file
                Map<String, String> cm = new LinkedHashMap<>(library.getCategoriesMap());

                // Tidy up the new categories if needed
                String newAll = cm.get(Library.INDEX_NEW);
                String newTV = cm.get(Library.INDEX_NEW_TV);
                String newMovie = cm.get(Library.INDEX_NEW_MOVIE);

                // If the New-TV is named the same as the New, remove it
                if (StringUtils.isNotBlank(newAll) && StringUtils.isNotBlank(newTV)
                        && newAll.equalsIgnoreCase(newTV)) {
                    cm.remove(Library.INDEX_NEW_TV);
                }

                // If the New-Movie is named the same as the New, remove it
                if (StringUtils.isNotBlank(newAll) && StringUtils.isNotBlank(newMovie)
                        && newAll.equalsIgnoreCase(newMovie)) {
                    cm.remove(Library.INDEX_NEW_MOVIE);
                }

                // If the New-TV is named the same as the New-Movie, remove it
                if (StringUtils.isNotBlank(newTV) && StringUtils.isNotBlank(newMovie)
                        && newTV.equalsIgnoreCase(newMovie)) {
                    cm.remove(Library.INDEX_NEW_TV);
                }

                for (Map.Entry<String, String> catOriginalName : cm.entrySet()) {
                    String catNewName = catOriginalName.getValue();
                    if (category.getValue().containsKey(catNewName)) {
                        indexSize = index.get(catNewName).size();
                        Element eIndexCategory = processIndexCategory(xmlDoc, catNewName, categoryKey, isCurrentKey,
                                idx, indexSize, previous, current, next, last);
                        if (eIndexCategory != null) {
                            eCategory.appendChild(eIndexCategory);
                            categoryCount++;
                        }
                    }
                }

            } else {
                for (Map.Entry<String, List<Movie>> categoryName : index.entrySet()) {
                    indexSize = categoryName.getValue().size();

                    Element eIndexCategory = processIndexCategory(xmlDoc, categoryName.getKey(), categoryKey,
                            isCurrentKey, idx, indexSize, previous, current, next, last);
                    if (eIndexCategory != null) {
                        eCategory.appendChild(eIndexCategory);
                        categoryCount++;
                    }
                }
            }

            // Only output the category if there are entries
            if (categoryCount > 0) {
                // Write the actual count of the category
                eCategory.setAttribute(COUNT, String.valueOf(categoryCount));
                eLibrary.appendChild(eCategory);
                libraryCount++;
            }
        }

        if (ENABLE_PEOPLE && ADD_PEOPLE_INFO
                && (Library.INDEX_PERSON + Library.INDEX_CAST + Library.INDEX_DIRECTOR + Library.INDEX_WRITER)
                        .contains(idx.categoryName)) {
            for (Person person : library.getPeople()) {
                if (!person.getName().equalsIgnoreCase(idx.key) && !person.getTitle().equalsIgnoreCase(idx.key)) {
                    boolean found = false;
                    if (!Library.INDEX_PERSON.equals(idx.categoryName)) {
                        for (String name : person.getAka()) {
                            if (name.equalsIgnoreCase(idx.key)) {
                                found = true;
                                break;
                            }
                        }
                    }
                    if (!found) {
                        continue;
                    }
                }
                eLibrary.appendChild(writePerson(xmlDoc, person, false));
                break;
            }
        }

        // FIXME: The count here is off. It needs to be correct
        Element eMovies = xmlDoc.createElement("movies");
        eMovies.setAttribute("cols", String.valueOf(idx.videosPerLine));
        eMovies.setAttribute(COUNT, String.valueOf(idx.videosPerPage));

        //eMovies.setAttribute("indexCount", String.valueOf(library.getMovieCountForIndex(idx.categoryName, idx.key)));
        eMovies.setAttribute("indexCount", String.valueOf(indexCount));
        eMovies.setAttribute("totalCount",
                String.valueOf(library.getMovieCountForIndex(Library.INDEX_OTHER, Library.INDEX_ALL)));

        if (fullMovieInfoInIndexes) {
            for (Movie movie : movies) {
                eMovies.appendChild(writeMovie(xmlDoc, movie, library));
            }
        } else {
            for (Movie movie : movies) {
                eMovies.appendChild(writeMovieForIndex(xmlDoc, movie));
            }
        }

        // Add the movies node to the Library node
        eLibrary.appendChild(eMovies);

        // Add the correct count to the library node
        eLibrary.setAttribute(COUNT, String.valueOf(libraryCount));

        // Add the Library node to the document
        xmlDoc.appendChild(eLibrary);

        // Save the document to file
        DOMHelper.writeDocumentToFile(xmlDoc, xmlFile);
    }

    private Element processIndexCategory(Document doc, String categoryName, String categoryKey,
            boolean isCurrentKey, IndexInfo idx, int indexSize, int previous, int current, int next, int last) {
        String encakey = FileTools.createCategoryKey(categoryName);
        boolean isCurrentCat = isCurrentKey && encakey.equalsIgnoreCase(idx.key);

        // Check to see if we need the non-current index
        if (!isCurrentCat && !fullCategoriesInIndexes) {
            // We don't need this index, so skip it
            return null;
        }

        // FIXME This is horrible! Issue 735 will get rid of it.
        if (indexSize < Library.calcMinCategoryCount(categoryName)
                && !Arrays.asList("Other,Genres,Title,Year,Library,Set".split(",")).contains(categoryKey)) {
            return null;
        }

        String prefix = FileTools.makeSafeFilename(FileTools.createPrefix(categoryKey, encakey));

        Element eCategory = doc.createElement(INDEX);
        eCategory.setAttribute(NAME, categoryName);

        // The category changes only occur for "Other" category
        if (Library.INDEX_OTHER.equals(categoryKey)) {
            eCategory.setAttribute(ORIGINAL_NAME, Library.getOriginalCategory(encakey, Boolean.TRUE));
        }

        // if currently writing this page then add current attribute with value true
        if (isCurrentCat) {
            eCategory.setAttribute("current", TRUE);
            eCategory.setAttribute("first", prefix + '1');
            eCategory.setAttribute("previous", prefix + previous);
            eCategory.setAttribute("next", prefix + next);
            eCategory.setAttribute("last", prefix + last);
            eCategory.setAttribute("currentIndex", Integer.toString(current));
            eCategory.setAttribute("lastIndex", Integer.toString(last));
        }

        eCategory.setTextContent(prefix + '1');

        return eCategory;
    }

    /**
     * Return an Element with the movie details
     *
     * @param doc
     * @param movie
     * @return
     */
    private static Element writeMovieForIndex(Document doc, Movie movie) {
        Element eMovie = doc.createElement(MOVIE);

        eMovie.setAttribute("isExtra", Boolean.toString(movie.isExtra()));
        eMovie.setAttribute("isSet", Boolean.toString(movie.isSetMaster()));
        if (movie.isSetMaster()) {
            eMovie.setAttribute("setSize", Integer.toString(movie.getSetSize()));
        }
        eMovie.setAttribute("isTV", Boolean.toString(movie.isTVShow()));

        DOMHelper.appendChild(doc, eMovie, DETAILS, HTMLTools.encodeUrl(movie.getBaseName()) + EXT_HTML);
        DOMHelper.appendChild(doc, eMovie, "baseFilenameBase", movie.getBaseFilename());
        DOMHelper.appendChild(doc, eMovie, BASE_FILENAME, movie.getBaseName());
        if ((TITLE_SORT_TYPE == TitleSortType.ADOPT_ORIGINAL)
                && (StringTools.isValidString(movie.getOriginalTitle()))) {
            DOMHelper.appendChild(doc, eMovie, TITLE, movie.getOriginalTitle());
        } else {
            DOMHelper.appendChild(doc, eMovie, TITLE, movie.getTitle());
        }
        DOMHelper.appendChild(doc, eMovie, SORT_TITLE, movie.getTitleSort());
        DOMHelper.appendChild(doc, eMovie, ORIGINAL_TITLE, movie.getOriginalTitle());
        DOMHelper.appendChild(doc, eMovie, "detailPosterFile",
                HTMLTools.encodeUrl(movie.getDetailPosterFilename()));
        DOMHelper.appendChild(doc, eMovie, "thumbnail", HTMLTools.encodeUrl(movie.getThumbnailFilename()));
        DOMHelper.appendChild(doc, eMovie, "bannerFile", HTMLTools.encodeUrl(movie.getBannerFilename()));
        DOMHelper.appendChild(doc, eMovie, "wideBannerFile", HTMLTools.encodeUrl(movie.getWideBannerFilename()));
        DOMHelper.appendChild(doc, eMovie, "certification", movie.getCertification());
        DOMHelper.appendChild(doc, eMovie, SEASON, Integer.toString(movie.getSeason()));

        // Return the generated movie
        return eMovie;
    }

    /**
     * Create an element based on a collection of items
     *
     * @param doc
     * @param set
     * @param element
     * @param items
     * @param library
     * @param cat
     * @return
     */
    private static Element generateElementSet(Document doc, String set, String element, Collection<String> items,
            Library library, String cat, String source) {

        if (!items.isEmpty()) {
            Element eSet = doc.createElement(set);
            eSet.setAttribute(COUNT, String.valueOf(items.size()));
            eSet.setAttribute(SOURCE, source);
            for (String item : items) {
                writeIndexedElement(doc, eSet, element, item, createIndexAttribute(library, cat, item));
            }
            return eSet;
        }
        return null;
    }

    /**
     * Write the element with the indexed attribute.
     *
     * If there is a non-null value in the indexValue, this will be appended to the element.
     *
     * @param doc
     * @param parentElement
     * @param attributeName
     * @param attributeValue
     * @param indexValue
     */
    private static void writeIndexedElement(Document doc, Element parentElement, String attributeName,
            String attributeValue, String indexValue) {
        if (indexValue == null) {
            DOMHelper.appendChild(doc, parentElement, attributeName, attributeValue);
        } else {
            DOMHelper.appendChild(doc, parentElement, attributeName, attributeValue, INDEX, indexValue);
        }
    }

    /**
     * Create the index filename for a category & value.
     *
     * Will return "null" if no index found
     *
     * @param library
     * @param categoryName
     * @param value
     * @return
     */
    private static String createIndexAttribute(Library library, String categoryName, String value) {
        if (StringTools.isNotValidString(value)) {
            return null;
        }

        Index index = library.getIndexes().get(categoryName);
        if (null != index) {
            int categoryMinCount = Library.calcMinCategoryCount(categoryName);

            if (library.getMovieCountForIndex(categoryName, value) >= categoryMinCount) {
                return HTMLTools
                        .encodeUrl(FileTools.makeSafeFilename(FileTools.createPrefix(categoryName, value)) + 1);
            }
        }
        return null;
    }

    /**
     * Create an element with the movie details in it
     *
     * @param doc
     * @param movie
     * @param library
     * @return
     */
    @SuppressWarnings("deprecation")
    private Element writeMovie(Document doc, Movie movie, Library library) {
        Element eMovie = doc.createElement(MOVIE);

        // holds the child attributes for reuse
        Map<String, String> childAttributes = new LinkedHashMap<>();

        eMovie.setAttribute("isExtra", Boolean.toString(movie.isExtra()));
        eMovie.setAttribute("isSet", Boolean.toString(movie.isSetMaster()));
        if (movie.isSetMaster()) {
            eMovie.setAttribute("setSize", Integer.toString(movie.getSetSize()));
        }
        eMovie.setAttribute("isTV", Boolean.toString(movie.isTVShow()));

        for (Map.Entry<String, String> e : movie.getIdMap().entrySet()) {
            DOMHelper.appendChild(doc, eMovie, "id", e.getValue(), MOVIEDB, e.getKey());
        }

        DOMHelper.appendChild(doc, eMovie, "mjbVersion", GitRepositoryState.getVersion());
        DOMHelper.appendChild(doc, eMovie, "mjbGitSHA", GIT.getCommitId());
        DOMHelper.appendChild(doc, eMovie, "xmlGenerationDate",
                DateTimeTools.convertDateToString(new Date(), DateTimeTools.getDateFormatLongString()));
        DOMHelper.appendChild(doc, eMovie, "baseFilenameBase", movie.getBaseFilename());
        DOMHelper.appendChild(doc, eMovie, BASE_FILENAME, movie.getBaseName());
        if ((TITLE_SORT_TYPE == TitleSortType.ADOPT_ORIGINAL)
                && (StringTools.isValidString(movie.getOriginalTitle()))) {
            DOMHelper.appendChild(doc, eMovie, TITLE, movie.getOriginalTitle(), SOURCE,
                    movie.getOverrideSource(OverrideFlag.TITLE));
        } else {
            DOMHelper.appendChild(doc, eMovie, TITLE, movie.getTitle(), SOURCE,
                    movie.getOverrideSource(OverrideFlag.TITLE));
        }
        DOMHelper.appendChild(doc, eMovie, SORT_TITLE, movie.getTitleSort());
        DOMHelper.appendChild(doc, eMovie, ORIGINAL_TITLE, movie.getOriginalTitle(), SOURCE,
                movie.getOverrideSource(OverrideFlag.ORIGINALTITLE));

        childAttributes.clear();
        childAttributes.put("Year", Library.getYearCategory(movie.getYear()));
        childAttributes.put(SOURCE, movie.getOverrideSource(OverrideFlag.YEAR));
        DOMHelper.appendChild(doc, eMovie, YEAR, movie.getYear(), childAttributes);

        DOMHelper.appendChild(doc, eMovie, "releaseDate", movie.getReleaseDate(), SOURCE,
                movie.getOverrideSource(OverrideFlag.RELEASEDATE));
        DOMHelper.appendChild(doc, eMovie, "showStatus", movie.getShowStatus());

        // This is the main rating
        DOMHelper.appendChild(doc, eMovie, RATING, Integer.toString(movie.getRating()));

        // This is the list of ratings
        Element eRatings = doc.createElement("ratings");
        for (String site : movie.getRatings().keySet()) {
            DOMHelper.appendChild(doc, eRatings, RATING, Integer.toString(movie.getRating(site)), MOVIEDB, site);
        }
        eMovie.appendChild(eRatings);

        DOMHelper.appendChild(doc, eMovie, "watched", Boolean.toString(movie.isWatched()));
        DOMHelper.appendChild(doc, eMovie, "watchedNFO", Boolean.toString(movie.isWatchedNFO()));
        DOMHelper.appendChild(doc, eMovie, "watchedFile", Boolean.toString(movie.isWatchedFile()));
        if (movie.isWatched()) {
            DOMHelper.appendChild(doc, eMovie, "watchedDate", getWatchedDateString(movie.getWatchedDate()));
        }
        DOMHelper.appendChild(doc, eMovie, "top250", Integer.toString(movie.getTop250()), SOURCE,
                movie.getOverrideSource(OverrideFlag.TOP250));
        DOMHelper.appendChild(doc, eMovie, DETAILS, HTMLTools.encodeUrl(movie.getBaseName()) + EXT_HTML);
        DOMHelper.appendChild(doc, eMovie, "posterURL", HTMLTools.encodeUrl(movie.getPosterURL()));
        DOMHelper.appendChild(doc, eMovie, "posterFile", HTMLTools.encodeUrl(movie.getPosterFilename()));
        DOMHelper.appendChild(doc, eMovie, "fanartURL", HTMLTools.encodeUrl(movie.getFanartURL()));
        DOMHelper.appendChild(doc, eMovie, "fanartFile", HTMLTools.encodeUrl(movie.getFanartFilename()));
        DOMHelper.appendChild(doc, eMovie, "detailPosterFile",
                HTMLTools.encodeUrl(movie.getDetailPosterFilename()));
        DOMHelper.appendChild(doc, eMovie, "thumbnail", HTMLTools.encodeUrl(movie.getThumbnailFilename()));
        DOMHelper.appendChild(doc, eMovie, "bannerURL", HTMLTools.encodeUrl(movie.getBannerURL()));
        DOMHelper.appendChild(doc, eMovie, "bannerFile", HTMLTools.encodeUrl(movie.getBannerFilename()));
        DOMHelper.appendChild(doc, eMovie, "wideBannerFile", HTMLTools.encodeUrl(movie.getWideBannerFilename()));
        DOMHelper.appendChild(doc, eMovie, "clearLogoURL", HTMLTools.encodeUrl(movie.getClearLogoURL()));
        DOMHelper.appendChild(doc, eMovie, "clearLogoFile", HTMLTools.encodeUrl(movie.getClearLogoFilename()));
        DOMHelper.appendChild(doc, eMovie, "clearArtURL", HTMLTools.encodeUrl(movie.getClearArtURL()));
        DOMHelper.appendChild(doc, eMovie, "clearArtFile", HTMLTools.encodeUrl(movie.getClearArtFilename()));
        DOMHelper.appendChild(doc, eMovie, "tvThumbURL", HTMLTools.encodeUrl(movie.getTvThumbURL()));
        DOMHelper.appendChild(doc, eMovie, "tvThumbFile", HTMLTools.encodeUrl(movie.getTvThumbFilename()));
        DOMHelper.appendChild(doc, eMovie, "seasonThumbURL", HTMLTools.encodeUrl(movie.getSeasonThumbURL()));
        DOMHelper.appendChild(doc, eMovie, "seasonThumbFile", HTMLTools.encodeUrl(movie.getSeasonThumbFilename()));
        DOMHelper.appendChild(doc, eMovie, "movieDiscURL", HTMLTools.encodeUrl(movie.getMovieDiscURL()));
        DOMHelper.appendChild(doc, eMovie, "movieDiscFile", HTMLTools.encodeUrl(movie.getMovieDiscFilename()));
        DOMHelper.appendChild(doc, eMovie, "plot", movie.getPlot(), SOURCE,
                movie.getOverrideSource(OverrideFlag.PLOT));
        DOMHelper.appendChild(doc, eMovie, "outline", movie.getOutline(), SOURCE,
                movie.getOverrideSource(OverrideFlag.OUTLINE));
        DOMHelper.appendChild(doc, eMovie, "quote", movie.getQuote(), SOURCE,
                movie.getOverrideSource(OverrideFlag.QUOTE));
        DOMHelper.appendChild(doc, eMovie, "tagline", movie.getTagline(), SOURCE,
                movie.getOverrideSource(OverrideFlag.TAGLINE));

        childAttributes.clear();
        String countryIndex = createIndexAttribute(library, Library.INDEX_COUNTRY, movie.getCountriesAsString());
        if (countryIndex != null) {
            childAttributes.put(INDEX, countryIndex);
        }
        childAttributes.put(SOURCE, movie.getOverrideSource(OverrideFlag.COUNTRY));
        DOMHelper.appendChild(doc, eMovie, COUNTRY, movie.getCountriesAsString(), childAttributes);
        if (XML_COMPATIBLE) {
            Element eCountry = doc.createElement("countries");
            int cnt = 0;
            for (String country : movie.getCountries()) {
                writeIndexedElement(doc, eCountry, "land", country,
                        createIndexAttribute(library, Library.INDEX_COUNTRY, country));
                cnt++;
            }
            eCountry.setAttribute(COUNT, String.valueOf(cnt));
            eMovie.appendChild(eCountry);
        }

        DOMHelper.appendChild(doc, eMovie, "company", movie.getCompany(), SOURCE,
                movie.getOverrideSource(OverrideFlag.COMPANY));
        if (XML_COMPATIBLE) {
            Element eCompany = doc.createElement("companies");
            int cnt = 0;
            for (String company : movie.getCompany().split(Movie.SPACE_SLASH_SPACE)) {
                DOMHelper.appendChild(doc, eCompany, "credit", company);
                cnt++;
            }
            eCompany.setAttribute(COUNT, String.valueOf(cnt));
            eMovie.appendChild(eCompany);
        }

        DOMHelper.appendChild(doc, eMovie, "runtime", movie.getRuntime(), SOURCE,
                movie.getOverrideSource(OverrideFlag.RUNTIME));
        DOMHelper.appendChild(doc, eMovie, "certification",
                Library.getIndexingCertification(movie.getCertification()), SOURCE,
                movie.getOverrideSource(OverrideFlag.CERTIFICATION));
        DOMHelper.appendChild(doc, eMovie, SEASON, Integer.toString(movie.getSeason()));

        DOMHelper.appendChild(doc, eMovie, LANGUAGE, movie.getLanguage(), SOURCE,
                movie.getOverrideSource(OverrideFlag.LANGUAGE));
        if (XML_COMPATIBLE) {
            Element eLanguage = doc.createElement("languages");
            int cnt = 0;
            for (String language : movie.getLanguage().split(Movie.SPACE_SLASH_SPACE)) {
                DOMHelper.appendChild(doc, eLanguage, "lang", language);
                cnt++;
            }
            eLanguage.setAttribute(COUNT, String.valueOf(cnt));
            eMovie.appendChild(eLanguage);
        }

        DOMHelper.appendChild(doc, eMovie, "subtitles", movie.getSubtitles());
        if (XML_COMPATIBLE) {
            Element eSubtitle = doc.createElement("subs");
            int cnt = 0;
            for (String subtitle : SubtitleTools.getSubtitles(movie)) {
                DOMHelper.appendChild(doc, eSubtitle, "subtitle", subtitle);
                cnt++;
            }
            eSubtitle.setAttribute(COUNT, String.valueOf(cnt));
            eMovie.appendChild(eSubtitle);
        }

        DOMHelper.appendChild(doc, eMovie, "trailerExchange", movie.isTrailerExchange() ? YES : "NO");

        if (movie.getTrailerLastScan() == 0) {
            DOMHelper.appendChild(doc, eMovie, TRAILER_LAST_SCAN, Movie.UNKNOWN);
        } else {
            try {
                DateTime dt = new DateTime(movie.getTrailerLastScan());
                DOMHelper.appendChild(doc, eMovie, TRAILER_LAST_SCAN, DateTimeTools.convertDateToString(dt));
            } catch (Exception error) {
                DOMHelper.appendChild(doc, eMovie, TRAILER_LAST_SCAN, Movie.UNKNOWN);
            }
        }

        DOMHelper.appendChild(doc, eMovie, "container", movie.getContainer(), SOURCE,
                movie.getOverrideSource(OverrideFlag.CONTAINER));
        DOMHelper.appendChild(doc, eMovie, "videoCodec", movie.getVideoCodec());
        DOMHelper.appendChild(doc, eMovie, "audioCodec", movie.getAudioCodec());

        // Write codec information
        eMovie.appendChild(createCodecsElement(doc, movie.getCodecs()));
        DOMHelper.appendChild(doc, eMovie, "audioChannels", movie.getAudioChannels());
        DOMHelper.appendChild(doc, eMovie, "resolution", movie.getResolution(), SOURCE,
                movie.getOverrideSource(OverrideFlag.RESOLUTION));

        // If the source is unknown, use the default source
        if (StringTools.isNotValidString(movie.getVideoSource())) {
            DOMHelper.appendChild(doc, eMovie, "videoSource", DEFAULT_SOURCE, SOURCE, Movie.UNKNOWN);
        } else {
            DOMHelper.appendChild(doc, eMovie, "videoSource", movie.getVideoSource(), SOURCE,
                    movie.getOverrideSource(OverrideFlag.VIDEOSOURCE));
        }

        DOMHelper.appendChild(doc, eMovie, "videoOutput", movie.getVideoOutput(), SOURCE,
                movie.getOverrideSource(OverrideFlag.VIDEOOUTPUT));
        DOMHelper.appendChild(doc, eMovie, "aspect", movie.getAspectRatio(), SOURCE,
                movie.getOverrideSource(OverrideFlag.ASPECTRATIO));
        DOMHelper.appendChild(doc, eMovie, "fps", Float.toString(movie.getFps()), SOURCE,
                movie.getOverrideSource(OverrideFlag.FPS));

        if (movie.getFileDate() == null) {
            DOMHelper.appendChild(doc, eMovie, "fileDate", Movie.UNKNOWN);
        } else {
            // Try to catch any date re-formatting errors
            try {
                DOMHelper.appendChild(doc, eMovie, "fileDate",
                        DateTimeTools.convertDateToString(movie.getFileDate()));
            } catch (ArrayIndexOutOfBoundsException error) {
                DOMHelper.appendChild(doc, eMovie, "fileDate", Movie.UNKNOWN);
            }
        }
        DOMHelper.appendChild(doc, eMovie, "fileSize", movie.getFileSizeString());
        DOMHelper.appendChild(doc, eMovie, "first", HTMLTools.encodeUrl(movie.getFirst()));
        DOMHelper.appendChild(doc, eMovie, "previous", HTMLTools.encodeUrl(movie.getPrevious()));
        DOMHelper.appendChild(doc, eMovie, "next", HTMLTools.encodeUrl(movie.getNext()));
        DOMHelper.appendChild(doc, eMovie, "last", HTMLTools.encodeUrl(movie.getLast()));
        DOMHelper.appendChild(doc, eMovie, "libraryDescription", movie.getLibraryDescription());
        DOMHelper.appendChild(doc, eMovie, "prebuf", Long.toString(movie.getPrebuf()));

        if (!movie.getGenres().isEmpty()) {
            Element eGenres = doc.createElement("genres");
            eGenres.setAttribute(COUNT, String.valueOf(movie.getGenres().size()));
            eGenres.setAttribute(SOURCE, movie.getOverrideSource(OverrideFlag.GENRES));
            for (String genre : movie.getGenres()) {
                writeIndexedElement(doc, eGenres, "genre", genre,
                        createIndexAttribute(library, Library.INDEX_GENRES, Library.getIndexingGenre(genre)));
            }
            eMovie.appendChild(eGenres);
        }

        Collection<String> items = movie.getSetsKeys();
        if (!items.isEmpty()) {
            Element eSets = doc.createElement("sets");
            eSets.setAttribute(COUNT, String.valueOf(items.size()));
            for (String item : items) {
                Element eSetItem = doc.createElement("set");
                Integer order = movie.getSetOrder(item);
                if (null != order) {
                    eSetItem.setAttribute(ORDER, String.valueOf(order));
                }
                String index = createIndexAttribute(library, Library.INDEX_SET, item);
                if (null != index) {
                    eSetItem.setAttribute(INDEX, index);
                }

                eSetItem.setTextContent(item);
                eSets.appendChild(eSetItem);
            }
            eMovie.appendChild(eSets);
        }

        writeIndexedElement(doc, eMovie, "director", movie.getDirector(),
                createIndexAttribute(library, Library.INDEX_DIRECTOR, movie.getDirector()));

        Element eSet;
        eSet = generateElementSet(doc, "directors", "director", movie.getDirectors(), library,
                Library.INDEX_DIRECTOR, movie.getOverrideSource(OverrideFlag.DIRECTORS));
        if (eSet != null) {
            eMovie.appendChild(eSet);
        }

        eSet = generateElementSet(doc, "writers", "writer", movie.getWriters(), library, Library.INDEX_WRITER,
                movie.getOverrideSource(OverrideFlag.WRITERS));
        if (eSet != null) {
            eMovie.appendChild(eSet);
        }

        eSet = generateElementSet(doc, "cast", "actor", movie.getCast(), library, Library.INDEX_CAST,
                movie.getOverrideSource(OverrideFlag.ACTORS));
        if (eSet != null) {
            eMovie.appendChild(eSet);
        }

        // Issue 1901: Awards
        if (ENABLE_AWARDS) {
            Collection<AwardEvent> awards = movie.getAwards();
            if (awards != null && !awards.isEmpty()) {
                Element eAwards = doc.createElement("awards");
                eAwards.setAttribute(COUNT, String.valueOf(awards.size()));
                for (AwardEvent event : awards) {
                    Element eEvent = doc.createElement("event");
                    eEvent.setAttribute(NAME, event.getName());
                    eEvent.setAttribute(COUNT, String.valueOf(event.getAwards().size()));
                    for (Award award : event.getAwards()) {
                        Element eAwardItem = doc.createElement("award");
                        eAwardItem.setAttribute(NAME, award.getName());
                        eAwardItem.setAttribute(WON, Integer.toString(award.getWon()));
                        eAwardItem.setAttribute("nominated", Integer.toString(award.getNominated()));
                        eAwardItem.setAttribute(YEAR, Integer.toString(award.getYear()));
                        if (award.getWons() != null && !award.getWons().isEmpty()) {
                            eAwardItem.setAttribute("wons",
                                    StringUtils.join(award.getWons(), Movie.SPACE_SLASH_SPACE));
                            if (XML_COMPATIBLE) {
                                for (String won : award.getWons()) {
                                    DOMHelper.appendChild(doc, eAwardItem, WON, won);
                                }
                            }
                        }
                        if (award.getNominations() != null && !award.getNominations().isEmpty()) {
                            eAwardItem.setAttribute("nominations",
                                    StringUtils.join(award.getNominations(), Movie.SPACE_SLASH_SPACE));
                            if (XML_COMPATIBLE) {
                                for (String nomination : award.getNominations()) {
                                    DOMHelper.appendChild(doc, eAwardItem, "nomination", nomination);
                                }
                            }
                        }
                        if (!XML_COMPATIBLE) {
                            eAwardItem.setTextContent(award.getName());
                        }
                        eEvent.appendChild(eAwardItem);
                    }
                    eAwards.appendChild(eEvent);
                }
                eMovie.appendChild(eAwards);
            }
        }

        // Issue 1897: Cast enhancement
        if (ENABLE_PEOPLE) {
            Collection<Filmography> people = movie.getPeople();
            if (people != null && !people.isEmpty()) {
                Element ePeople = doc.createElement("people");
                ePeople.setAttribute(COUNT, String.valueOf(people.size()));
                for (Filmography person : people) {
                    Element ePerson = doc.createElement("person");

                    ePerson.setAttribute(NAME, person.getName());
                    ePerson.setAttribute("doublage", person.getDoublage());
                    ePerson.setAttribute(TITLE, person.getTitle());
                    ePerson.setAttribute(CHARACTER, person.getCharacter());
                    ePerson.setAttribute(JOB, person.getJob());
                    ePerson.setAttribute("id", person.getId());
                    for (Map.Entry<String, String> personID : person.getIdMap().entrySet()) {
                        if (!personID.getKey().equals(ImdbPlugin.IMDB_PLUGIN_ID)) {
                            ePerson.setAttribute(ID + personID.getKey(), personID.getValue());
                        }
                    }
                    ePerson.setAttribute(DEPARTMENT, person.getDepartment());
                    ePerson.setAttribute(URL, person.getUrl());
                    ePerson.setAttribute(ORDER, Integer.toString(person.getOrder()));
                    ePerson.setAttribute("cast_id", Integer.toString(person.getCastId()));
                    ePerson.setAttribute("photoFile", person.getPhotoFilename());
                    String inx = createIndexAttribute(library, Library.INDEX_PERSON, person.getName());
                    if (inx != null) {
                        ePerson.setAttribute(INDEX, inx);
                    }
                    ePerson.setAttribute(SOURCE, person.getSource());
                    ePerson.setTextContent(person.getFilename());
                    ePeople.appendChild(ePerson);
                }
                eMovie.appendChild(ePeople);
            }
        }

        // Issue 2012: Financial information about movie
        if (ENABLE_BUSINESS) {
            Element eBusiness = doc.createElement("business");
            eBusiness.setAttribute("budget", movie.getBudget());

            for (Map.Entry<String, String> gross : movie.getGross().entrySet()) {
                DOMHelper.appendChild(doc, eBusiness, "gross", gross.getValue(), COUNTRY, gross.getKey());
            }

            for (Map.Entry<String, String> openweek : movie.getOpenWeek().entrySet()) {
                DOMHelper.appendChild(doc, eBusiness, "openweek", openweek.getValue(), COUNTRY, openweek.getKey());
            }

            eMovie.appendChild(eBusiness);
        }

        // Issue 2013: Add trivia
        if (ENABLE_TRIVIA) {
            Element eTrivia = doc.createElement("didyouknow");
            eTrivia.setAttribute(COUNT, String.valueOf(movie.getDidYouKnow().size()));

            for (String trivia : movie.getDidYouKnow()) {
                DOMHelper.appendChild(doc, eTrivia, "trivia", trivia);
            }

            eMovie.appendChild(eTrivia);
        }

        // Write the indexes that the movie belongs to
        Element eIndexes = doc.createElement("indexes");
        String originalName;
        for (Entry<String, String> index : movie.getIndexes().entrySet()) {
            Element eIndexEntry = doc.createElement(INDEX);
            eIndexEntry.setAttribute("type", index.getKey());
            originalName = Library.getOriginalCategory(index.getKey(), Boolean.TRUE);
            eIndexEntry.setAttribute(ORIGINAL_NAME, originalName);
            eIndexEntry.setAttribute("encoded", FileTools.makeSafeFilename(index.getValue()));
            eIndexEntry.setTextContent(index.getValue());
            eIndexes.appendChild(eIndexEntry);
        }
        eMovie.appendChild(eIndexes);

        // Write details about the files
        Element eFiles = doc.createElement("files");
        for (MovieFile mf : movie.getFiles()) {
            Element eFileItem = doc.createElement("file");
            eFileItem.setAttribute(SEASON, Integer.toString(mf.getSeason()));
            eFileItem.setAttribute("firstPart", Integer.toString(mf.getFirstPart()));
            eFileItem.setAttribute("lastPart", Integer.toString(mf.getLastPart()));
            eFileItem.setAttribute(TITLE, mf.getTitle());
            eFileItem.setAttribute("subtitlesExchange", mf.isSubtitlesExchange() ? YES : "NO");

            // Fixes an issue with null file lengths
            try {
                if (mf.getFile() == null) {
                    eFileItem.setAttribute("size", "0");
                } else {
                    eFileItem.setAttribute("size", Long.toString(mf.getSize()));
                }
            } catch (DOMException ex) {
                LOG.debug("File length error for file {}", mf.getFilename(), ex);
                eFileItem.setAttribute("size", "0");
            }

            // Playlink values; can be empty, but not null
            for (Map.Entry<String, String> e : mf.getPlayLink().entrySet()) {
                eFileItem.setAttribute(e.getKey().toLowerCase(), e.getValue());
            }

            eFileItem.setAttribute("watched", mf.isWatched() ? TRUE : FALSE);

            if (mf.getFile() != null) {
                DOMHelper.appendChild(doc, eFileItem, "fileLocation", mf.getFile().getAbsolutePath());
            }

            // Write the fileURL
            String filename = mf.getFilename();
            // Issue 1237: Add "VIDEO_TS.IFO" for PlayOnHD VIDEO_TS path names
            if (IS_PLAYON_HD && filename.toUpperCase().endsWith("VIDEO_TS")) {
                filename = filename + "/VIDEO_TS.IFO";
            }

            // If attribute was set, save it back out.
            String archiveName = mf.getArchiveName();
            if (StringTools.isValidString(archiveName)) {
                LOG.debug("getArchivename is '{}' for {} length {}", archiveName, mf.getFilename(),
                        archiveName.length());
            }

            if (StringTools.isValidString(archiveName)) {
                DOMHelper.appendChild(doc, eFileItem, "fileArchiveName", archiveName);

                // If they want full URL, do so
                if (IS_EXTENDED_URL && !filename.endsWith(archiveName)) {
                    filename = filename + "/" + archiveName;
                }
            }

            DOMHelper.appendChild(doc, eFileItem, "fileURL", filename);

            for (int part = mf.getFirstPart(); part <= mf.getLastPart(); ++part) {

                childAttributes.clear();
                childAttributes.put(PART, Integer.toString(part));
                childAttributes.put(SOURCE, mf.getOverrideSource(OverrideFlag.EPISODE_TITLE));
                DOMHelper.appendChild(doc, eFileItem, "fileTitle", mf.getTitle(part), childAttributes);

                // Only write out these for TV Shows
                if (movie.isTVShow()) {
                    childAttributes.clear();
                    childAttributes.put(PART, Integer.toString(part));
                    childAttributes.put("afterSeason", mf.getAirsAfterSeason(part));
                    childAttributes.put("beforeSeason", mf.getAirsBeforeSeason(part));
                    childAttributes.put("beforeEpisode", mf.getAirsBeforeEpisode(part));
                    DOMHelper.appendChild(doc, eFileItem, "airsInfo", String.valueOf(part), childAttributes);

                    childAttributes.clear();
                    childAttributes.put(PART, Integer.toString(part));
                    childAttributes.put(SOURCE, mf.getOverrideSource(OverrideFlag.EPISODE_FIRST_AIRED));
                    DOMHelper.appendChild(doc, eFileItem, "firstAired", mf.getFirstAired(part), childAttributes);
                }

                if (mf.getWatchedDate() > 0) {
                    DOMHelper.appendChild(doc, eFileItem, "watchedDate", getWatchedDateString(mf.getWatchedDate()));
                }

                if (includeEpisodePlots) {
                    childAttributes.clear();
                    childAttributes.put(PART, Integer.toString(part));

                    // use file title if plot is invalid
                    String filePlot = mf.getPlot(part);
                    final String filePlotSource;
                    if (movie.isTVShow() && StringTools.isNotValidString(filePlot)) {
                        filePlot = mf.getTitle(part);
                        filePlotSource = Movie.UNKNOWN;
                    } else {
                        filePlotSource = mf.getOverrideSource(OverrideFlag.EPISODE_PLOT);
                    }

                    if (episodePlotSize > 0) {
                        filePlot = StringTools.trimToLength(filePlot, episodePlotSize);
                    }

                    childAttributes.put(SOURCE, filePlotSource);
                    DOMHelper.appendChild(doc, eFileItem, "filePlot", filePlot, childAttributes);
                }

                if (includeEpisodeRating) {
                    childAttributes.clear();
                    childAttributes.put(PART, Integer.toString(part));
                    childAttributes.put(SOURCE, mf.getOverrideSource(OverrideFlag.EPISODE_RATING));
                    DOMHelper.appendChild(doc, eFileItem, "fileRating", mf.getRating(part), childAttributes);
                }

                if (includeVideoImages) {
                    DOMHelper.appendChild(doc, eFileItem, "fileImageURL",
                            HTMLTools.encodeUrl(mf.getVideoImageURL(part)), PART, String.valueOf(part));
                    DOMHelper.appendChild(doc, eFileItem, "fileImageFile",
                            HTMLTools.encodeUrl(mf.getVideoImageFilename(part)), PART, String.valueOf(part));
                }

                // Episode IDs
                if (mf.getIds(part) != null && !mf.getIds(part).isEmpty()) {
                    for (Entry<String, String> entry : mf.getIds(part).entrySet()) {
                        childAttributes.clear();
                        childAttributes.put(PART, Integer.toString(part));
                        childAttributes.put(SOURCE, entry.getKey());
                        DOMHelper.appendChild(doc, eFileItem, "fileId", entry.getValue(), childAttributes);
                    }
                }
            }

            if (mf.getAttachments() != null && !mf.getAttachments().isEmpty()) {
                Element eAttachments = doc.createElement("attachments");
                for (Attachment att : mf.getAttachments()) {
                    Element eAttachment = doc.createElement("attachment");
                    eAttachment.setAttribute("type", att.getType().toString());
                    DOMHelper.appendChild(doc, eAttachment, "attachmentId", String.valueOf(att.getAttachmentId()));
                    DOMHelper.appendChild(doc, eAttachment, "contentType", att.getContentType().toString());
                    DOMHelper.appendChild(doc, eAttachment, "mimeType", att.getMimeType());
                    DOMHelper.appendChild(doc, eAttachment, "part", String.valueOf(att.getPart()));
                    eAttachments.appendChild(eAttachment);
                }
                eFileItem.appendChild(eAttachments);
            }

            eFiles.appendChild(eFileItem);
        }
        eMovie.appendChild(eFiles);

        Collection<ExtraFile> extraFiles = movie.getExtraFiles();
        if (extraFiles != null && !extraFiles.isEmpty()) {
            Element eExtras = doc.createElement("extras");
            for (ExtraFile ef : extraFiles) {
                Element eExtraItem = doc.createElement("extra");
                eExtraItem.setAttribute(TITLE, ef.getTitle());
                if (ef.getPlayLink() != null) {
                    // Playlink values
                    for (Map.Entry<String, String> e : ef.getPlayLink().entrySet()) {
                        eExtraItem.setAttribute(e.getKey().toLowerCase(), e.getValue());
                    }
                }
                eExtraItem.setTextContent(ef.getFilename()); // should already be URL-encoded
                eExtras.appendChild(eExtraItem);
            }
            eMovie.appendChild(eExtras);
        }

        return eMovie;
    }

    /**
     * Create an element with the codec information in it.
     *
     * @param doc
     * @param movieCodecs
     * @return
     */
    private static Element createCodecsElement(Document doc, Set<Codec> movieCodecs) {
        Element eCodecs = doc.createElement("codecs");
        Element eCodecAudio = doc.createElement("audio");
        Element eCodecVideo = doc.createElement("video");
        int countAudio = 0;
        int countVideo = 0;

        Map<String, String> codecAttribs = new HashMap<>();

        for (Codec codec : movieCodecs) {
            codecAttribs.clear();

            codecAttribs.put("format", codec.getCodecFormat());
            codecAttribs.put("formatProfile", codec.getCodecFormatProfile());
            codecAttribs.put("formatVersion", codec.getCodecFormatVersion());
            codecAttribs.put("codecId", codec.getCodecId());
            codecAttribs.put("codecIdHint", codec.getCodecIdHint());
            codecAttribs.put(SOURCE, codec.getCodecSource().toString());
            codecAttribs.put("bitrate", codec.getCodecBitRate());
            if (codec.getCodecType() == CodecType.AUDIO) {
                codecAttribs.put(LANGUAGE, codec.getCodecLanguage());
                codecAttribs.put("langugageFull", codec.getCodecFullLanguage());
                codecAttribs.put("channels", String.valueOf(codec.getCodecChannels()));
                DOMHelper.appendChild(doc, eCodecAudio, "codec", codec.getCodec(), codecAttribs);
                countAudio++;
            } else {
                DOMHelper.appendChild(doc, eCodecVideo, "codec", codec.getCodec(), codecAttribs);
                countVideo++;
            }
        }
        eCodecAudio.setAttribute(COUNT, String.valueOf(countAudio));
        eCodecVideo.setAttribute(COUNT, String.valueOf(countVideo));
        eCodecs.appendChild(eCodecAudio);
        eCodecs.appendChild(eCodecVideo);

        return eCodecs;
    }

    /**
     * Persist a movie into an XML file.
     *
     * Doesn't overwrite an already existing XML file for the specified movie unless, movie's data has changed (INFO, RECHECK,
     * WATCHED) or forceXMLOverwrite is true.
     *
     * @param jukebox
     * @param movie
     * @param library
     */
    public void writeMovieXML(Jukebox jukebox, Movie movie, Library library) {
        String baseName = movie.getBaseName();
        File finalXmlFile = FileTools.fileCache
                .getFile(jukebox.getJukeboxRootLocationDetails() + File.separator + baseName + EXT_XML);
        File tempXmlFile = new File(jukebox.getJukeboxTempLocationDetails() + File.separator + baseName + EXT_XML);

        FileTools.addJukeboxFile(finalXmlFile.getName());

        LOG.debug("DirtyFlags for {} are: {}", movie.getBaseName(), movie.showDirty());
        if (!finalXmlFile.exists() || FORCE_XML_OVERWRITE || movie.isDirty(DirtyFlag.INFO)
                || movie.isDirty(DirtyFlag.RECHECK) || movie.isDirty(DirtyFlag.WATCHED)) {
            Document xmlDoc;
            try {
                xmlDoc = DOMHelper.createDocument();
            } catch (ParserConfigurationException error) {
                LOG.error("Failed writing {}", tempXmlFile.getAbsolutePath());
                LOG.error(SystemTools.getStackTrace(error));
                return;
            }

            Element eDetails = xmlDoc.createElement(DETAILS);
            xmlDoc.appendChild(eDetails);

            Element eMovie = writeMovie(xmlDoc, movie, library);

            if (eMovie != null) {
                eDetails.appendChild(eMovie);
            }

            DOMHelper.writeDocumentToFile(xmlDoc, tempXmlFile);

            if (WRITE_NFO_FILES) {
                MovieNFOWriter.writeNfoFile(jukebox, movie);
            }
        }
    }

    /**
     * Generate the person
     *
     * @param doc
     * @param person
     * @param includeVersion
     * @return
     */
    private static Element writePerson(Document doc, Person person, boolean includeVersion) {
        Element ePerson = doc.createElement("person");

        for (Map.Entry<String, String> e : person.getIdMap().entrySet()) {
            DOMHelper.appendChild(doc, ePerson, "id", e.getValue(), "persondb", e.getKey());
        }

        // Add the version information to the output
        if (includeVersion) {
            DOMHelper.appendChild(doc, ePerson, "mjbVersion", GitRepositoryState.getVersion());
            DOMHelper.appendChild(doc, ePerson, "mjbGitSHA", GIT.getCommitId());
            DOMHelper.appendChild(doc, ePerson, "xmlGenerationDate",
                    DateTimeTools.convertDateToString(new Date(), DateTimeTools.getDateFormatLongString()));
        }
        DOMHelper.appendChild(doc, ePerson, NAME, person.getName());
        DOMHelper.appendChild(doc, ePerson, TITLE, person.getTitle());
        DOMHelper.appendChild(doc, ePerson, BASE_FILENAME, person.getFilename());

        if (!person.getAka().isEmpty()) {
            Element eAka = doc.createElement("aka");
            for (String aka : person.getAka()) {
                DOMHelper.appendChild(doc, eAka, NAME, aka);
            }
            ePerson.appendChild(eAka);
        }

        DOMHelper.appendChild(doc, ePerson, "biography", person.getBiography());
        DOMHelper.appendChild(doc, ePerson, "birthday", person.getYear());
        DOMHelper.appendChild(doc, ePerson, "birthplace", person.getBirthPlace());
        DOMHelper.appendChild(doc, ePerson, "birthname", person.getBirthName());
        DOMHelper.appendChild(doc, ePerson, URL, person.getUrl());
        DOMHelper.appendChild(doc, ePerson, "photoFile", person.getPhotoFilename());
        DOMHelper.appendChild(doc, ePerson, "photoURL", person.getPhotoURL());
        DOMHelper.appendChild(doc, ePerson, "backdropFile", person.getBackdropFilename());
        DOMHelper.appendChild(doc, ePerson, "backdropURL", person.getBackdropURL());
        DOMHelper.appendChild(doc, ePerson, "knownMovies", String.valueOf(person.getKnownMovies()));

        if (!person.getFilmography().isEmpty()) {
            Element eFilmography = doc.createElement("filmography");

            for (Filmography film : person.getFilmography()) {
                Element eMovie = doc.createElement(MOVIE);
                eMovie.setAttribute("id", film.getId());

                for (Map.Entry<String, String> e : film.getIdMap().entrySet()) {
                    if (!e.getKey().equals(ImdbPlugin.IMDB_PLUGIN_ID)) {
                        eMovie.setAttribute(ID + e.getKey(), e.getValue());
                    }
                }
                eMovie.setAttribute(NAME, film.getName());
                eMovie.setAttribute(TITLE, film.getTitle());
                eMovie.setAttribute(ORIGINAL_TITLE, film.getOriginalTitle());
                eMovie.setAttribute(YEAR, film.getYear());
                eMovie.setAttribute(RATING, film.getRating());
                eMovie.setAttribute(CHARACTER, film.getCharacter());
                eMovie.setAttribute(JOB, film.getJob());
                eMovie.setAttribute(DEPARTMENT, film.getDepartment());
                eMovie.setAttribute(URL, film.getUrl());
                eMovie.setTextContent(film.getFilename());

                eFilmography.appendChild(eMovie);
            }
            ePerson.appendChild(eFilmography);
        }

        // Write the indexes that the people belongs to
        Element eIndexes = doc.createElement("indexes");
        String originalName;
        for (Entry<String, String> index : person.getIndexes().entrySet()) {
            Element eIndexEntry = doc.createElement(INDEX);
            eIndexEntry.setAttribute("type", index.getKey());
            originalName = Library.getOriginalCategory(index.getKey(), Boolean.TRUE);
            eIndexEntry.setAttribute(ORIGINAL_NAME, originalName);
            eIndexEntry.setAttribute("encoded", FileTools.makeSafeFilename(index.getValue()));
            eIndexEntry.setTextContent(index.getValue());
            eIndexes.appendChild(eIndexEntry);
        }
        ePerson.appendChild(eIndexes);

        DOMHelper.appendChild(doc, ePerson, "version", String.valueOf(person.getVersion()));
        DOMHelper.appendChild(doc, ePerson, "lastModifiedAt", person.getLastModifiedAt());

        return ePerson;
    }

    public void writePersonXML(Jukebox jukebox, Person person) {
        String baseName = person.getFilename();
        File finalXmlFile = FileTools.fileCache.getFile(
                jukebox.getJukeboxRootLocationDetails() + File.separator + PEOPLE_FOLDER + baseName + EXT_XML);
        File tempXmlFile = new File(
                jukebox.getJukeboxTempLocationDetails() + File.separator + PEOPLE_FOLDER + baseName + EXT_XML);

        FileTools.makeDirsForFile(tempXmlFile);
        FileTools.makeDirsForFile(finalXmlFile);

        FileTools.addJukeboxFile(finalXmlFile.getName());

        if (!finalXmlFile.exists() || FORCE_XML_OVERWRITE || person.isDirty()) {
            try {
                Document personDoc = DOMHelper.createDocument();
                Element eDetails = personDoc.createElement(DETAILS);
                eDetails.appendChild(writePerson(personDoc, person, true));
                personDoc.appendChild(eDetails);
                DOMHelper.writeDocumentToFile(personDoc, tempXmlFile);
            } catch (ParserConfigurationException error) {
                LOG.error("Failed writing person XML for {}", tempXmlFile.getName());
                LOG.error(SystemTools.getStackTrace(error));
            }
        }
    }

    private static String getWatchedDateString(long watchedDate) {
        if (watchedDate == 0) {
            return Movie.UNKNOWN;
        }
        return new DateTime(watchedDate).toString(DateTimeTools.getDateFormatLongString());
    }
}