<repo_name>GeorgeEBeresford/ContentExplorer<file_sep>/ContentExplorer/Core/Scripts/Common/Repositories/TagRepository.js
﻿/**
 * An object which sends and retrieves tag-related information to and from the server
 * @class
 */
function TagRepository() {

    /**
     * An object which deals with sending HTTP requests to the server
     * @type {HttpRequester}
     */
    var httpRequester = new HttpRequester();

    var controller = "Tag";

    /**
     * Adds the specified tags to the provided directory paths
     * @param {Array<string>} directoryPaths
     * @param {Array<string>} tags
     * @param {string} mediaType
     * @returns {JQuery.Promise<Array<any>>}
     */
    this.addTagsToDirectories = function (directoryPaths, tags, mediaType) {

        var deferred = $.Deferred();
        var payload = {
            directoryPaths: directoryPaths,
            tags: tags,
            mediaType: mediaType
        };

        httpRequester.postAsync("AddTagsToDirectories", controller, payload)
            .then(function (isSuccess) {

                if (isSuccess) {

                    deferred.resolve();
                }
                else {

                    alert("Failed to add tags to directories");
                    deferred.reject();
                }
            })
            .fail(function (xhr) {

                alert("[" + xhr.status + "] " + xhr.statusText);
                deferred.reject();
            });

        return deferred.promise();
    }

    /**
     * Adds the specified tags to the provided directory paths
     * @param {Array<string>} filePaths
     * @param {Array<string>} tags
     * @param {string} mediaType
     * @returns {JQuery.Promise<Array<any>>}
     */
    this.addTagsToFiles = function (filePaths, tags, mediaType) {

        var deferred = $.Deferred();
        var payload = {
            filePaths: filePaths,
            tags: tags,
            mediaType: mediaType
        };

        httpRequester.postAsync("AddTagsToFiles", controller, payload)
            .then(function (isSuccess) {

                if (isSuccess) {

                    deferred.resolve();
                }
                else {

                    alert("Failed to add tags to files");
                    deferred.reject();
                }
            })
            .fail(function (xhr) {

                alert("[" + xhr.status + "] " + xhr.statusText);
                deferred.reject();
            });

        return deferred.promise();
    }

    /**
     * Retrieves the tags for the given directory and mediaType
     * @param {string} directoryPath -
     * @param {string} mediaType - 
     * @param {string} filter - 
     * @returns {JQuery.Promise<any>}
     */
    this.getTagsAsync = function (directoryPath, mediaType, filter) {

        var deferred = $.Deferred();
        var payload = {
            directoryName: directoryPath,
            mediaType: mediaType,
            filter: filter
        };

        httpRequester.getAsync("GetDirectoryTags", controller, payload)
            .then(function (tags) {

                deferred.resolve(tags);
            })
            .fail(function (xhr) {

                alert("[" + xhr.status + "] " + xhr.statusText);
                deferred.reject();
            });

        return deferred.promise();
    }

    /**
     * Retrieves the tags for the given file and mediaType
     * @param {string} filePath -
     * @param {string} mediaType -
     * @returns {JQuery.Promise<any>}
     */
    this.getFileTagsAsync = function(filePath, mediaType) {

        var deferred = $.Deferred();
        var payload = {
            fileName: filePath,
            mediaType: mediaType
        };

        httpRequester.getAsync("GetFileTags", controller, payload)
            .then(function (tags) {

                deferred.resolve(tags);
            })
            .fail(function (xhr) {

                alert("[" + xhr.status + "] " + xhr.statusText);
                deferred.reject();
            });

        return deferred.promise();
    }
}<file_sep>/ContentExplorer.Tests/API/Test.cs
﻿using System.Configuration;
using System.IO;
using ContentExplorer.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace ContentExplorer.Tests.API
{
    public abstract class Test
    {
        protected Test()
        {
            FakeHttpContext = new FakeHttpContext.FakeHttpContext();
        }

        private FakeHttpContext.FakeHttpContext FakeHttpContext { get; }

        protected const string DefaultTestTagName = "TestTag";
        protected const string DefaultTestImageName = "test.png";

        protected static readonly string TestImagesDirectory = ConfigurationManager.AppSettings["ImagesPath"];
        protected static readonly string DefaultTestImagePath = $"{TestImagesDirectory}\\{DefaultTestImageName}";


        [TestInitialize]
        public void Initialise()
        {
            bool tagsAreInitialised = Tag.InitialiseTable();
            Assert.IsTrue(tagsAreInitialised);

            string sql = "SELECT * FROM Tags LIMIT 1";
            using (SqliteWrapper sqliteWrapper = new SqliteWrapper("AppDb"))
            {
                sqliteWrapper.GetDataRow(sql);
            }

            bool tagLinksAreInitialised = TagLink.InitialiseTable();
            Assert.IsTrue(tagLinksAreInitialised);

            sql = "SELECT * FROM TagLinks LIMIT 1";
            using (SqliteWrapper sqliteWrapper = new SqliteWrapper("AppDb"))
            {
                sqliteWrapper.GetDataRow(sql);
            }
        }

        [TestCleanup]
        public void Cleanup()
        {
            FileInfo fileInfo = new FileInfo("database.db");
            if (fileInfo.Exists)
            {
                fileInfo.Delete();
            }

            fileInfo = new FileInfo(DefaultTestImagePath);
            if (fileInfo.Exists)
            {
                try
                {
                    fileInfo.Delete();
                }
                // File may be in-use. Try again.
                catch (IOException)
                {
                    fileInfo.Delete();
                }
            }

            FakeHttpContext.Dispose();
        }

        protected FileInfo CreateAndReturnImage(string relativeDirectory = null)
        {
            string cdnDiskPath = ConfigurationManager.AppSettings["BaseDirectory"];
            string directoryPath = relativeDirectory == null
                ? $"{cdnDiskPath}\\{TestImagesDirectory}"
                : $"{cdnDiskPath}\\{TestImagesDirectory}\\{relativeDirectory}";

            DirectoryInfo directoryInfo = new DirectoryInfo(directoryPath);
            if (directoryInfo.Exists != true)
            {
                directoryInfo.Create();
            }

            // We don't really need multiple files in a directory. Just attach multiple tag links to the same file.

            string filePath = $"{cdnDiskPath}\\{DefaultTestImagePath}";
            FileInfo createdFile = new FileInfo(filePath);
            if (createdFile.Exists)
            {
                createdFile.Delete();
            }

            // The image doesn't have to be readable. We just have to be able to guess it's an image from the extension
            using (StreamWriter streamWriter = new StreamWriter(new FileStream(filePath, FileMode.CreateNew)))
            {
                streamWriter.WriteLine("Test file");
            }

            FileInfo testFileInfo = new FileInfo(filePath);
            return testFileInfo;
        }

        protected TagLink CreateAndReturnTagLink(string relativeDirectory = null, string tagName = null)
        {
            Tag tag = CreateAndReturnTag(tagName);

            CreateAndReturnImage(relativeDirectory);

            string filePath = relativeDirectory == null
                ? DefaultTestImagePath
                : $"{TestImagesDirectory}\\{relativeDirectory}\\{DefaultTestImageName}";

            TagLink tagLink = new TagLink
            {
                TagId = tag.TagId,
                FilePath = filePath
            };

            bool isSuccess = tagLink.Create();
            Assert.IsTrue(isSuccess, "TagLink was not successfully created");
            Assert.AreNotEqual(0, tag.TagId, "TagLink ID was not set");

            return tagLink;
        }

        protected Tag CreateAndReturnTag(string tagName = null)
        {
            Tag tag = new Tag
            {
                TagName = tagName ?? DefaultTestTagName
            };

            bool isSuccess = tag.Create();
            Assert.IsTrue(isSuccess, "Tag was not successfully created");
            Assert.AreNotEqual(0, tag.TagId, "Tag ID was not set");

            return tag;
        }
    }
}<file_sep>/ContentExplorer/Services/IThumbnailService.cs
﻿using System.IO;

namespace ContentExplorer.Services
{
    public interface IThumbnailService
    {
        void CreateThumbnail(FileInfo fileInfo);
        void DeleteThumbnailIfExists(FileInfo fileInfo);
        FileInfo GetDirectoryThumbnail(DirectoryInfo directory);
        FileInfo GetFileThumbnail(FileInfo fileInfo);
        bool IsThumbnail(FileInfo fileInfo);
    }
}<file_sep>/ContentExplorer/Models/ViewModels/DirectoryViewModel.cs
﻿using System.Collections.Generic;
using System.IO;

namespace ContentExplorer.Models.ViewModels
{
    public class DirectoryViewModel
    {
        public ICollection<DirectoryInfo> DirectoryInfos { get; set; }
        public ICollection<FileInfo> FileInfos { get; set; }
        public int FileCount { get; set; }
    }
}<file_sep>/ContentExplorer.Tests/API/Models/TagLinkTest.cs
﻿using System.Collections.Generic;
using ContentExplorer.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace ContentExplorer.Tests.API.Models
{
    [TestClass]
    public class TagLinkTest : Test
    {
        [TestMethod]
        public void GetTag()
        {
            Tag tag = CreateAndReturnTag();
            TagLink createdTagLink = new TagLink
            {
                TagId = tag.TagId
            };

            Tag retrievedTag = createdTagLink.GetTag();
            Assert.IsNotNull(retrievedTag, "Tag was not retrieved");
            Assert.AreEqual(tag.TagId, retrievedTag.TagId);

            TagLink tagLink = CreateAndReturnTagLink();
            retrievedTag = tagLink.GetTag();
            Assert.IsNotNull(tag, "Tag was not retrirved");
            Assert.AreEqual(tagLink.TagId, retrievedTag.TagId);
        }

        [TestMethod]
        public void GetTagLinkByDirectory()
        {
            CreateAndReturnTagLink();
            ICollection<TagLink> directoryTagLinks = TagLink.GetByDirectory(TestImagesDirectory);
            Assert.IsNotNull(directoryTagLinks, "Null was returned for directory tag links");
            Assert.AreNotEqual(0, directoryTagLinks.Count, "No tag links were returned for directory");
        }

        [TestMethod]
        public void GetAllTagLinks()
        {
            CreateAndReturnTagLink();
            CreateAndReturnTagLink();

            ICollection<TagLink> allTagLinks = TagLink.GetAll();
            Assert.IsNotNull(allTagLinks, "Null was returned for directory tag links");
            Assert.AreEqual(2, allTagLinks.Count);
        }

        [TestMethod]
        public void GetTagLinkByFilteredDirectory()
        {
            // Default tag
            CreateAndReturnTagLink();
            CreateAndReturnTagLink("Nested");

            // Second tag
            CreateAndReturnTagLink(null, "Filtered");
            CreateAndReturnTagLink("Nested", "Filtered");

            // Non matching tags (to make sure it's actually filtered)
            Tag nonMatchingTag = CreateAndReturnTag("NonMatchingFilter");
            TagLink nonMatchingTagLink = new TagLink
            {
                TagId = nonMatchingTag.TagId,
                FilePath = $"{TestImagesDirectory}\\test2.png"
            };
            nonMatchingTagLink.Create();

            nonMatchingTagLink.FilePath = $"{TestImagesDirectory}\\Nested\\test2.png";
            nonMatchingTagLink.Create();

            // Add a new file with just the default tag so we can make sure multiple filters return the correct files
            Tag singleMatchTag = CreateAndReturnTag();
            TagLink singleMatchTagLink = new TagLink
            {
                TagId = singleMatchTag.TagId,
                FilePath = $"{TestImagesDirectory}\\test3.png"
            };
            singleMatchTagLink.Create();

            // Check for default tag
            ICollection<TagLink> nonRecursiveTagLinks =
                TagLink.GetByDirectory(TestImagesDirectory, new[] {DefaultTestTagName}, 0, -1);

            Assert.IsNotNull(nonRecursiveTagLinks, "Returned tag links were null");
            Assert.AreEqual(2, nonRecursiveTagLinks.Count);

            ICollection<TagLink> recursiveTagLinks =
                TagLink.GetByDirectory(TestImagesDirectory, new[] {DefaultTestTagName}, 0, -1, true);

            Assert.IsNotNull(recursiveTagLinks, "Returned tag links were null");
            Assert.AreEqual(3, recursiveTagLinks.Count);

            // Check for two tags
            nonRecursiveTagLinks =
                TagLink.GetByDirectory(TestImagesDirectory, new[] {DefaultTestTagName, "Filtered"}, 0, -1);

            Assert.IsNotNull(nonRecursiveTagLinks, "Returned tag links were null");
            Assert.AreEqual(1, nonRecursiveTagLinks.Count);

            recursiveTagLinks =
                TagLink.GetByDirectory(TestImagesDirectory, new[] {DefaultTestTagName, "Filtered"}, 0, -1, true);

            Assert.IsNotNull(recursiveTagLinks, "Returned tag links were null");
            Assert.AreEqual(2, recursiveTagLinks.Count);
        }

        [TestMethod]
        public void GetTagLinkByTagName()
        {
            CreateAndReturnTagLink();
            CreateAndReturnTagLink();

            ICollection<TagLink> tagLinks = TagLink.GetByTagName(DefaultTestTagName);
            Assert.IsNotNull(tagLinks, "Returned tag links are null");
            Assert.AreEqual(2, tagLinks.Count);
        }

        [TestMethod]
        public void CreateTagLink()
        {
            TagLink tagLink = CreateAndReturnTagLink();

            using (SqliteWrapper sqliteWrapper = new SqliteWrapper("AppDb"))
            {
                IDictionary<string, object> dataRow = sqliteWrapper.GetDataRow(
                    "SELECT * FROM TagLinks WHERE TagLinkId = @TagLinkId",
                    SqliteWrapper.GenerateParameter("@TagLinkId", tagLink.TagLinkId)
                );

                Assert.IsNotNull(dataRow);
            }
        }

        [TestMethod]
        public void DeleteTagLink()
        {
            TagLink tagLink = CreateAndReturnTagLink();
            bool isSuccess = tagLink.Delete();
            Assert.IsTrue(isSuccess, "TagLink was not successfully deleted");

            using (SqliteWrapper sqliteWrapper = new SqliteWrapper("AppDb"))
            {
                IDictionary<string, object> dataRow = sqliteWrapper.GetDataRow(
                    "SELECT * FROM TagLinks WHERE TagLinkId = @TagLinkId",
                    SqliteWrapper.GenerateParameter("@TagLinkId", tagLink.TagLinkId)
                );

                Assert.IsNull(dataRow);
            }
        }
    }
}<file_sep>/ContentExplorer/Core/Scripts/Index/VideoIndex.js
/**
 * An object that displays a list of videos in a directory
 * @class
 */
function VideoIndex() {

    MediaIndex.call(this, "video", "Video");

    this.$actions = $("[data-actions='video']");
}

VideoIndex.prototype = Object.create(MediaIndex.prototype);

VideoIndex.prototype.addMediaDependentActions = function () {

    MediaIndex.prototype.addMediaDependentActions.call(this);

    var $rebuildThumbnailsButton = $("<a>")
        .attr("href", "../" + this.controller + "/" + "RebuildThumbnails?path=" + this.directoryPath)
        .attr("target", "_blank")
        .append(
            $("<div>").addClass("btn btn-default").text("Rebuild Thumbnails")
        );

    var $reformatNames = $("<a>")
        .attr("href", "../" + this.controller + "/" + "ReformatNames?path=" + this.directoryPath)
        .attr("target", "_blank")
        .append(
            $("<div>").addClass("btn btn-default").text("Reformat Names")
        );

    var $convertUnplayableVideos = $("<a>")
        .attr("href", "../" + this.controller + "/" + "ConvertUnplayableVideos?path=" + this.directoryPath)
        .attr("target", "_blank")
        .append(
            $("<div>").addClass("btn btn-default").text("Convert Unplayable Videos")
        );

    this.$actions
        .append($rebuildThumbnailsButton)
        .append($reformatNames)
        .append($convertUnplayableVideos);
}

$(function () {

    var mediaIndex = new VideoIndex();
    mediaIndex.initialiseAsync();
})<file_sep>/ContentExplorer/Core/Scripts/Index/MediaIndex.js
﻿/**
 * An object which displays a directory of media items in a way that allows
 * the user to navigate between directories and media items
 * @param {string} mediaType
 * @param {string} controller
 * @class
 */
function MediaIndex(mediaType, controller) {

    this.$customFilter = $("[data-custom-filter]");

    this.$applyFilter = $("[data-apply-filter]");

    this.$selectableForTagging = $("[data-tag-selector]").not("[data-template] [data-tag-selector]");

    this.$taggingContainer = $("[data-tagging]");

    this.$tagList = this.$taggingContainer.find("[data-tags-for-folder]");

    this.$tagName = this.$taggingContainer.find("[data-tag-name]");

    this.$addTag = this.$taggingContainer.find("[data-add-tag]");

    this.$clearFilter = $("[data-clear-filter]");

    this.$steppingStones = $("[data-stepping-stones]");

    this.$directoryTemplate = $("[data-template='directory']");

    this.$fileTemplate = $("[data-template='file']");

    this.$directoryList = $("[data-directory-list]");

    this.$fileList = $("[data-file-list]");

    this.$pages = $("[data-pages]");

    this.directoryPath = $("[name='Path']").val();

    this.cdnPath = $("[name='CdnPath']").val();

    this.page = +$("[name='Page']").val();

    this.filesPerPage = +$("[name='FilesPerPage']").val();

    this.filter = $("[name='Filter']").val();

    this.mediaType = mediaType;

    this.controller = controller;

    this.totalFiles = 0;

    this.mediaRepository = new MediaRepository();

    this.mediaUiFactory = new MediaUiFactory();

    this.tagRepository = new TagRepository();
}

MediaIndex.prototype.renderPages = function () {

    var totalPages = Math.ceil(this.totalFiles / this.filesPerPage);

    if (totalPages > 1) {

        var $pageList = this.$pages.find("[data-page-list]");
        var $totalPages = this.$pages.find("[data-total-pages]");

        $pageList.html("");
        $totalPages.text(totalPages + " Pages Total");

        for (var pageIndex = 1; pageIndex <= totalPages; pageIndex++) {

            var $currentPage = $("<a>").text(pageIndex);

            if (pageIndex != this.page) {

                var url = "../" + this.controller + "/Index?path=" + this.directoryPath + "&page=" + pageIndex + "&filter=" + this.filter;
                $currentPage.addClass("button_item").attr("href", url);
            }
            else {

                $currentPage.addClass("button_item-current");
            }

            $pageList.append($currentPage);
        }

        this.$pages.show();
    }
    else {

        this.$pages.hide();
    }
}

MediaIndex.prototype.renderSubFilesAsync = function () {

    var deferred = $.Deferred();
    var self = this;

    this.mediaRepository.getSubFilesAsync(this.directoryPath, this.mediaType, this.filter, (this.page - 1) * 50, 50)
        .then(function (paginatedSubFiles) {

            self.totalFiles = paginatedSubFiles.Total;

            var $files = $("[data-files]");

            if (paginatedSubFiles.CurrentPage.length !== 0) {

                paginatedSubFiles.CurrentPage.forEach(function (subFileInfo, subMediaIndex) {

                    self.renderSubFile(subFileInfo, ((self.page - 1) * self.filesPerPage) + subMediaIndex + 1);
                });
            }
            else {

                $files.find("h1").remove();
            }

            $files.show();

            self.$selectableForTagging = $("[data-tag-selector]").not("[data-template] [data-tag-selector]");

            self.renderPages();

            deferred.resolve();
        })
        .fail(function () {

            deferred.reject();
        });

    return deferred.promise();
};

MediaIndex.prototype.renderSubFile = function (subFileInfo, subMediaIndex) {

    var $filePreview = this.$fileTemplate
        .clone()
        .show()
        .removeAttr("data-template");

    var fileViewingPage = window.location.origin +
        "/" +
        this.controller +
        "/" +
        "View?path=" +
        subFileInfo.Path +
        "&page=" +
        subMediaIndex +
        "&filter=" +
        this.filter;

    $filePreview.find("a").attr("href", fileViewingPage);
    $filePreview.find("[data-file-name]").text(subFileInfo.Name);
    $filePreview.find("[data-tag-selector]").attr("data-path", subFileInfo.TaggingUrl);
    $filePreview.css("background-image", "url(\"" + this.cdnPath + "/" + subFileInfo.ThumbnailUrl + "\")");

    this.$fileList.append($filePreview);
}

MediaIndex.prototype.renderSubDirectoriesAsync = function () {

    var deferred = $.Deferred();
    var self = this;

    this.mediaRepository.getSubDirectoriesAsync(this.directoryPath, this.mediaType, this.filter)
        .then(function (subDirectoryInfos) {

            var $directories = $("[data-directories]");

            if (subDirectoryInfos.length !== 0) {

                subDirectoryInfos.forEach(function (subDirectoryInfo) {

                    self.renderSubDirectory(subDirectoryInfo);
                });
            }
            else {

                $directories.find("h1").remove();
            }

            $directories.show();

            self.$selectableForTagging = $("[data-tag-selector]").not("[data-template] [data-tag-selector]");

            deferred.resolve();
        })
        .fail(function () {

            deferred.reject();
        });

    return deferred.promise();
};

MediaIndex.prototype.renderSubDirectory = function (subDirectoryInfo) {

    var $directoryPreview = this.$directoryTemplate
        .clone()
        .show()
        .removeAttr("data-template");

    $directoryPreview.find("a").attr("href", window.location.origin + window.location.pathname + "?path=" + subDirectoryInfo.Path + "&filter=" + this.filter);
    $directoryPreview.find("[data-directory-name]").text(subDirectoryInfo.Name);
    $directoryPreview.find("[data-tag-selector]").attr("data-path", subDirectoryInfo.TaggingUrl);
    $directoryPreview.css("background-image", "url(\"" + this.cdnPath + "/" + subDirectoryInfo.ThumbnailUrl + "\")");

    this.$directoryList.append($directoryPreview);
}

MediaIndex.prototype.renderSteppingStonesAsync = function () {

    var self = this;
    var deferred = $.Deferred();

    this.mediaRepository.getDirectoryHierarchyAsync(this.directoryPath, this.mediaType)
        .then(function (directoryHierarchy) {

            var $steppingStones = self.mediaUiFactory.generateSteppingStones(self.controller, directoryHierarchy, self.filter);
            self.$steppingStones.html($steppingStones);
            deferred.resolve();
        })
        .fail(function () {

            deferred.reject();
        });

    return deferred.promise();
}

/**
 * Initialises the file index
 * @param {string} mediaType
 */
MediaIndex.prototype.initialiseAsync = function () {

    var deferred = $.Deferred();
    var self = this;

    this.addEventHandlers();

    var renderAsynchronously = $.when(
        this.renderSteppingStonesAsync(),
        this.renderSubDirectoriesAsync(),
        this.renderSubFilesAsync(),
        this.renderTagListAsync()
    );

    renderAsynchronously
        .then(function () {

            var numberOfMediaItems = $("[data-files],[data-directories]").find("li").length;

            if (numberOfMediaItems === 0) {

                var $files = $("[data-files]");
                var $noFilesMessage = $("<h1>").text(this.filter !== "" ? "No files found with filter " + self.filter : "No files found");

                $files.html($noFilesMessage);
            }

            self.addMediaDependentActions();

            deferred.resolve();
        })
        .fail(function () {

            deferred.reject();
        });

    return deferred.promise();
}

MediaIndex.prototype.addEventHandlers = function () {

    var self = this;

    this.$applyFilter.on("click",
        function () {

            self.applyFilter();
        });

    this.$customFilter.on("keypress",
        function (event) {

            var pressedKey = event.keyCode || event.which;
            var enterKey = 13;

            if (pressedKey === enterKey) {

                self.applyFilter();
            }
        }
    );

    this.$addTag.on("click", function () {

        self.addTagsAsync()
            .then(function () {

                alert("Tags added");
            });
    });

    this.$tagName.on("keypress",
        function (event) {

            var pressedKey = event.keyCode || event.which;
            var enterKey = 13;

            if (pressedKey === enterKey) {

                self.addTagsAsync()
                    .then(function () {

                        alert("Tags added")
                    });
            }
        }
    );

    this.$clearFilter.on("click", function () {

        self.$customFilter.val("");
        self.applyFilter();
    });
}

/**
 * Adds any actions to the page which require the files and directories to already be loaded
 */
MediaIndex.prototype.addMediaDependentActions = function () {

    var self = this;
    var numberOfFiles = $("[data-files]").find("li").length;
    var numberOfDirectories = $("[data-directories]").find("li").length;

    if (numberOfFiles === 0 && numberOfDirectories === 0) {

        this.$taggingContainer.hide();

        return;
    }

    this.$taggingContainer.show();

    var $mediaSelection = $("[data-media-selection]");

    var $selectAll = $("<button>")
        .addClass("btn btn-primary")
        .text("Select All")
        .on("click", function () {

            self.$selectableForTagging.prop("checked", true);
        });

    var $selectNone = $("<button>")
        .addClass("btn btn-default")
        .text("Select None")
        .on("click", function () {

            self.$selectableForTagging.prop("checked", false);
        });

    var $moveFiles = $("<button>")
        .addClass("btn btn-default")
        .text("Move selected")
        .on("click", function () {

            self.moveSelectedFilesAsync();
            self.moveSelectedDirectoriesAsync();
        });

    $mediaSelection
        .html("")
        .append($selectAll)
        .append($selectNone)
        .append($moveFiles);

}

MediaIndex.prototype.addTagsAsync = function () {

    var deferred = $.Deferred();
    var self = this;

    var addTagsToAll = $.when(
        this.addTagsToFilesAsync(),
        this.addTagsToDirectoriesAsync()
    );

    addTagsToAll
        .then(function () {

            self.$tagName.val("");
            deferred.resolve();
        })
        .fail(function () {

            deferred.reject();
        });

    return deferred.promise();
}

MediaIndex.prototype.moveSelectedDirectoriesAsync = function () {

    var self = this;
    var deferred = $.Deferred();
    var $selectedCheckboxes = this.$selectableForTagging.filter("[data-tag-type='directory']:checked");

    if ($selectedCheckboxes.length === 0) {

        deferred.reject();
        return deferred.promise();
    }

    var newDirectoryPath = prompt("New Directory Path: ", this.directoryPath);
    if (newDirectoryPath === "" || newDirectoryPath === null || typeof (newDirectoryPath) === "undefined") {

        deferred.reject();
        return deferred.promise();
    }

    var directoryPaths = [];
    $selectedCheckboxes.each(function (_, selectedCheckbox) {

        var $selectedCheckbox = $(selectedCheckbox);
        var directoryPath = $selectedCheckbox.attr("data-path");
        directoryPaths.push(directoryPath);
    });

    this.mediaRepository.moveSubDirectories(directoryPaths, newDirectoryPath, this.mediaType)
        .then(function () {

            self.$directoryList.html("");
            self.$fileList.html("");

            // We've just moved some files around. Recalculate which files we need to show
            self.initialiseAsync()
                .then(function () {

                    deferred.resolve();
                })
                .fail(function () {

                    deferred.reject();
                });
        })
        .fail(function () {

            deferred.reject();
        });

    return deferred.promise();
}

MediaIndex.prototype.moveSelectedFilesAsync = function () {

    var self = this;
    var deferred = $.Deferred();
    var $selectedCheckboxes = this.$selectableForTagging.filter("[data-tag-type='file']:checked");

    if ($selectedCheckboxes.length === 0) {

        deferred.reject();
        return deferred.promise();
    }

    var newDirectoryPath = prompt("New Directory Path: ", this.directoryPath);
    if (newDirectoryPath === "") {

        deferred.reject();
        return deferred.promise();
    }

    var filePaths = [];
    $selectedCheckboxes.each(function (_, selectedCheckbox) {

        var $selectedCheckbox = $(selectedCheckbox);
        var filePath = $selectedCheckbox.attr("data-path");
        filePaths.push(filePath);
    });

    this.mediaRepository.moveSubFiles(filePaths, newDirectoryPath, this.mediaType)
        .then(function () {

            self.$directoryList.html("");
            self.$fileList.html("");

            // We've just moved some files around. Recalculate which files we need to show
            self.initialiseAsync()
                .then(function () {

                    deferred.resolve();
                })
                .fail(function () {

                    deferred.reject();
                });
        })
        .fail(function () {

            deferred.reject();
        });

    return deferred.promise();
}

MediaIndex.prototype.addTagsToDirectoriesAsync = function () {

    var self = this;
    var tagNames = this.$tagName.val();
    var $selectedCheckboxes = this.$selectableForTagging.filter("[data-tag-type='directory']:checked");
    var deferred = $.Deferred();

    if (tagNames === "" || $selectedCheckboxes.length === 0) {

        deferred.resolve();
        return deferred.promise();
    }

    var directoryPaths = [];
    $selectedCheckboxes.each(function (_, selectedCheckbox) {

        var $selectedCheckbox = $(selectedCheckbox);
        var filePath = $selectedCheckbox.attr("data-path");
        directoryPaths.push(filePath);
    });

    var tags = tagNames.split(",");
    this.tagRepository.addTagsToDirectories(directoryPaths, tags, this.mediaType)
        .then(function () {

            tags.forEach(function (tagName) {

                self.addTagToList(tagName);
            });

            self.$tagName.val("");

            deferred.resolve();
        })
        .fail(function () {

            deferred.reject();
        });

    return deferred.promise();
}

MediaIndex.prototype.addTagsToFilesAsync = function () {

    var self = this;
    var tagNames = this.$tagName.val();
    var $selectedCheckboxes = this.$selectableForTagging.filter("[data-tag-type='file']:checked");

    if (tagNames === "" || $selectedCheckboxes.length === 0) {

        var deferred = $.Deferred();
        deferred.resolve();
        return deferred.promise();
    }

    var filePaths = [];
    $selectedCheckboxes.each(function (_, selectedCheckbox) {

        var $selectedCheckbox = $(selectedCheckbox);
        var filePath = $selectedCheckbox.attr("data-path");
        filePaths.push(filePath);
    });

    var deferred = $.Deferred();
    var tags = tagNames.split(",");
    this.tagRepository.addTagsToFiles(filePaths, tags, this.mediaType)
        .then(function () {

            tags.forEach(function (tagName) {

                self.addTagToList(tagName);
            });

            deferred.resolve();
        })
        .fail(function () {

            deferred.reject();
        });

    return deferred.promise();
}

MediaIndex.prototype.renderTagListAsync = function () {

    var deferred = $.Deferred();
    var self = this;

    this.tagRepository.getTagsAsync(this.directoryPath, this.mediaType, this.filter)
        .then(function (tag) {

            tag.forEach(function (tag) {

                self.addTagToList(tag.TagName);
            });

            deferred.resolve();
        })
        .fail(function () {

            deferred.reject();
        });

    return deferred.promise();
}

MediaIndex.prototype.addTagToList = function (tagName) {

    var self = this;
    var regex = new RegExp("'");
    var encodedTagName = tagName.replace(regex, "%23");
    var $tagLink = $("[data-tag='" + encodedTagName + "']");

    if ($tagLink.length !== 0) {
        return;
    }

    $tagLink = $("<li>").attr("data-tag", encodedTagName).addClass("tagList_item").text(tagName);

    $tagLink.on("click", function () {

        var filterValue = self.$customFilter.val();

        if (filterValue !== "") {

            filterValue = (filterValue + "," + $tagLink.text());
        }
        else {

            filterValue = $tagLink.text();
        }

        self.$customFilter.val(filterValue);
        self.applyFilter();
    });

    this.$tagList.append($tagLink);
}

MediaIndex.prototype.applyFilter = function () {

    var filterString = this.$customFilter.val();
    window.location.search = "?filter=" + filterString + "&page=1&path=" + this.directoryPath;
}<file_sep>/ContentExplorer/Models/TagLink.cs
﻿using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using Microsoft.Data.Sqlite;
using NReco.VideoConverter;

namespace ContentExplorer.Models
{
    public class TagLink
    {
        public TagLink()
        {
        }

        public TagLink(IDictionary<string, object> rowValues) : this()
        {
            TagLinkId = Convert.ToInt32(rowValues["TagLinkId"]);
            TagId = Convert.ToInt32(rowValues["TagId"]);
            FilePath = Convert.ToString(rowValues["FilePath"]);
        }

        public int TagLinkId { get; set; }
        public int TagId { get; set; }
        public string FilePath { get; set; }
        private Tag Tag { get; set; }

        public Tag GetTag()
        {
            if (Tag != null)
            {
                return Tag;
            }

            Tag = Tag.GetById(TagId);
            return Tag;
        }

        public static bool UpdateDirectoryPath(string oldDirectoryPath, string newDirectoryPath)
        {
            // The caller shouldn't have to care about which slash or case to use
            oldDirectoryPath = oldDirectoryPath.Replace("/", "\\").ToLowerInvariant();
            newDirectoryPath = newDirectoryPath.Replace("/", "\\").ToLowerInvariant();

            // We need to distinguish between files and directories with the same name but the caller shouldn't have to worry about it
            oldDirectoryPath = oldDirectoryPath.TrimEnd("\\".ToCharArray());
            newDirectoryPath = newDirectoryPath.TrimEnd("\\".ToCharArray());

            // Replaces any instances of the old directory path it finds with the new one. This may change rows unexpectedly. E.g. pictures/test/pictures/test/2.jpg
            // Todo - Find a better way to do this
            string query = @"UPDATE TagLinks
                            SET FilePath = REPLACE(FilePath, @OldDirectoryPath, @NewDirectoryPath)
                            WHERE FilePath LIKE @OldDirectoryPathLike
                            AND FilePath NOT LIKE @OldDirectoryPathRecursionLike";

            ICollection<SqliteParameter> dbParameters = new List<SqliteParameter>
            {
                SqliteWrapper.GenerateParameter("@NewDirectoryPath", $"{newDirectoryPath}\\"),
                SqliteWrapper.GenerateParameter("@OldDirectoryPath", $"{oldDirectoryPath}\\"),
                SqliteWrapper.GenerateParameter("@OldDirectoryPathLike", $"{oldDirectoryPath}\\%"),
                SqliteWrapper.GenerateParameter("@OldDirectoryPathRecursionLike", $"{oldDirectoryPath}\\%\\%")
            };

            bool isSuccess;
            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                isSuccess = dbContext.ExecuteNonQuery(query, dbParameters);
            }

            return isSuccess;
        }

        public static bool UpdateFilePath(string oldFilePath, string newFilePath)
        {
            // The caller shouldn't have to care about which slash or case to use
            oldFilePath = oldFilePath.Replace("/", "\\").ToLowerInvariant();
            newFilePath = newFilePath.Replace("/", "\\").ToLowerInvariant();

            // We need to distinguish between files and directories with the same name but the caller shouldn't have to worry about it
            oldFilePath = oldFilePath.TrimEnd("\\".ToCharArray());
            newFilePath = newFilePath.TrimEnd("\\".ToCharArray());

            string query = @"UPDATE TagLinks SET FilePath = @NewFilePath WHERE FilePath = @OldFilePath";
            ICollection<SqliteParameter> dbParameters = new List<SqliteParameter>
            {
                SqliteWrapper.GenerateParameter("@NewFilePath", newFilePath),
                SqliteWrapper.GenerateParameter("@OldFilePath", oldFilePath)
            };

            bool isSuccess;
            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                isSuccess = dbContext.ExecuteNonQuery(query, dbParameters);
            }

            return isSuccess;
        }

        public static ICollection<TagLink> GetByFileName(string filePath)
        {
            string cacheKey = $"[TagLinks][ByFile]{filePath}";
            if (HttpContext.Current.Cache[cacheKey] is ICollection<TagLink> cachedTags)
            {
                return cachedTags;
            }

            // The caller shouldn't have to care about which slash or case to use
            filePath = filePath.Replace("/", "\\").ToLowerInvariant();

            // We need to distinguish between files and directories with the same name but the caller shouldn't have to worry about it
            filePath = filePath.TrimEnd("\\".ToCharArray());

            string query = @"SELECT Tags.TagId, Tags.TagName, TagLinks.FilePath AS [FilePath], TagLinks.TagLinkId
                                FROM Tags
                                INNER JOIN TagLinks ON Tags.TagId = TagLinks.TagId
                                WHERE TagLinks.FilePath = @FullName";


            ICollection<IDictionary<string, object>> dataRows;
            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                dataRows = dbContext.GetDataRows(query, SqliteWrapper.GenerateParameter("@FullName", filePath));
            }

            ICollection<TagLink> tagLinks = dataRows
                .Select(dataRow =>
                    new TagLink(dataRow)
                )
                .ToList();

            HttpContext.Current.Cache.Insert(cacheKey, tagLinks, null, DateTime.Today.AddDays(1), TimeSpan.Zero);

            return tagLinks;
        }

        public static ICollection<TagLink> GetAll()
        {
            string cacheKey = "[TagLinks][All]";
            if (HttpContext.Current.Cache[cacheKey] is ICollection<TagLink> cachedTags)
            {
                return cachedTags;
            }

            string query = @"SELECT TagLinks.FilePath, TagLinks.TagLinkId, TagLinks.TagId
                           FROM TagLinks";

            ICollection<IDictionary<string, object>> dataRows;
            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                dataRows = dbContext.GetDataRows(query);
            }

            ICollection<TagLink> tagLinks = dataRows
                .Select(dataRow =>
                    new TagLink(dataRow)
                )
                .ToList();

            HttpContext.Current.Cache.Insert(cacheKey, tagLinks, null, DateTime.Today.AddDays(1), TimeSpan.Zero);

            return tagLinks;
        }

        public static ICollection<TagLink> GetByDirectory(string directoryPath)
        {
            string cacheKey = $"[TagLinks][ByDirectory]{directoryPath}";
            if (HttpContext.Current.Cache[cacheKey] is ICollection<TagLink> cachedTags)
            {
                return cachedTags;
            }

            // The caller shouldn't have to care about which slash or case to use
            directoryPath = directoryPath.Replace("/", "\\").ToLowerInvariant();

            // We need to distinguish between files and directories with the same name but the caller shouldn't have to worry about it
            directoryPath = directoryPath.TrimEnd("\\".ToCharArray());

            string query =
                @"SELECT Tags.TagId, Tags.TagName, TagLinks.FilePath AS [FilePath], TagLinks.TagLinkId
                                FROM Tags
                                INNER JOIN TagLinks ON Tags.TagId = TagLinks.TagId
                                WHERE TagLinks.FilePath LIKE @FilePath";

            ICollection<IDictionary<string, object>> dataRows;
            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                dataRows = dbContext.GetDataRows(query,
                    SqliteWrapper.GenerateParameter("@FilePath", $"{directoryPath}\\%"));
            }

            ICollection<TagLink> tagLinks = dataRows.Select(dataRow =>
                    new TagLink(dataRow)
                    {
                        Tag = new Tag(dataRow)
                    }
                )
                .ToList();

            HttpContext.Current.Cache.Insert(cacheKey, tagLinks, null, DateTime.Today.AddDays(1), TimeSpan.Zero);

            return tagLinks;
        }

        public static ICollection<TagLink> GetByDirectory(string directoryPath, string[] filters, int skip, int take,
            bool isRecursive = false)
        {
            filters = filters.Select(filter => filter.ToLowerInvariant()).ToArray();

            // The caller shouldn't have to care about which slash or case to use
            directoryPath = directoryPath.Replace("/", "\\").ToLowerInvariant();

            // We need to distinguish between files and directories with the same name but the caller shouldn't have to worry about it
            directoryPath = directoryPath.TrimEnd("\\".ToCharArray());

            string cacheKey =
                $"[TagLinks][ByDirectoryAndFilters]{directoryPath}|{string.Join(",", filters)}|{skip}|{take}|{isRecursive}|";
            if (HttpContext.Current.Cache[cacheKey] is ICollection<TagLink> cachedTags)
            {
                return cachedTags;
            }

            string query = @"WITH FilePathTags AS (
	                            SELECT
		                            (',' || GROUP_CONCAT(Tags.TagName) || ',') AS [CombinedTags],
		                            TagLinks.FilePath
	                            FROM TagLinks
	                            INNER JOIN Tags ON Tags.TagId = TagLinks.TagId
	                            GROUP BY TagLinks.FilePath
                            )
                            SELECT
	                            TagLinks.FilePath,
	                            TagLinks.TagLinkId,
	                            Tags.TagId,
	                            Tags.TagName
                            FROM TagLinks
                            INNER JOIN Tags ON Tags.TagId = TagLinks.TagId
                            INNER JOIN FilePathTags ON FilePathTags.FilePath = TagLinks.FilePath
                            WHERE TagLinks.FilePath LIKE @FilePath";

            List<SqliteParameter> dbParameters = new List<SqliteParameter>();

            for (int filterIndex = 0; filterIndex < filters.Length; filterIndex++)
            {
                string stringParameter = $"@{filterIndex}";
                query += $" AND FilePathTags.CombinedTags LIKE {stringParameter}";
                dbParameters.Add(SqliteWrapper.GenerateParameter(stringParameter, $"%,{filters[filterIndex]},%"));
            }

            // Add the file path parameter to the end of the array
            dbParameters.Add(SqliteWrapper.GenerateParameter("@FilePath", $"{directoryPath}\\%"));

            if (isRecursive != true)
            {
                query += " AND TagLinks.FilePath NOT LIKE @ExcludedFilePath";
                dbParameters.Add(SqliteWrapper.GenerateParameter("@ExcludedFilePath", $"{directoryPath}\\%\\%"));
            }

            query += " GROUP BY TagLinks.FilePath LIMIT @Take OFFSET @Skip";
            dbParameters.Add(SqliteWrapper.GenerateParameter("@Skip", skip));
            dbParameters.Add(SqliteWrapper.GenerateParameter("@Take", take));

            ICollection<IDictionary<string, object>> dataRows;
            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                dataRows = dbContext.GetDataRows(query, dbParameters);
            }

            ICollection<TagLink> tagLinks = dataRows
                .Select(dataRow =>
                    new TagLink(dataRow)
                    {
                        Tag = new Tag(dataRow)
                    }
                )
                .ToList();

            HttpContext.Current.Cache.Insert(cacheKey, tagLinks, null, DateTime.Today.AddDays(1), TimeSpan.Zero);

            return tagLinks;
        }

        public static int GetFileCount(string directoryPath, string[] filters, int skip, int take,
            bool isRecursive = false)
        {
            filters = filters.Select(filter => filter.ToLowerInvariant()).ToArray();

            // The caller shouldn't have to care about which slash or case to use
            directoryPath = directoryPath.Replace("/", "\\").ToLowerInvariant();

            // We need to distinguish between files and directories with the same name but the caller shouldn't have to worry about it
            directoryPath = directoryPath.TrimEnd("\\".ToCharArray());

            string cacheKey =
                $"[TagLinks][CountByDirectoryAndFilters]{directoryPath}|{string.Join(",", filters)}|{skip}|{take}|{isRecursive}|";
            if (HttpContext.Current.Cache[cacheKey] is int cachedCount)
            {
                return cachedCount;
            }

            string query = @"WITH FilePathTags AS (
	                            SELECT
		                            (',' || GROUP_CONCAT(Tags.TagName) || ',') AS [CombinedTags],
		                            TagLinks.FilePath
	                            FROM TagLinks
	                            INNER JOIN Tags ON Tags.TagId = TagLinks.TagId
	                            GROUP BY TagLinks.FilePath
                            )
                            SELECT COUNT(DISTINCT TagLinks.FilePath)
                            FROM TagLinks
                            INNER JOIN Tags ON Tags.TagId = TagLinks.TagId
                            INNER JOIN FilePathTags ON FilePathTags.FilePath = TagLinks.FilePath
                            WHERE TagLinks.FilePath LIKE @FilePath";

            List<SqliteParameter> dbParameters = new List<SqliteParameter>();

            for (int filterIndex = 0; filterIndex < filters.Length; filterIndex++)
            {
                string stringParameter = $"@{filterIndex}";
                query += $" AND FilePathTags.CombinedTags LIKE {stringParameter}";
                dbParameters.Add(SqliteWrapper.GenerateParameter(stringParameter, $"%,{filters[filterIndex]},%"));
            }

            // Add the file path parameter to the end of the array
            dbParameters.Add(SqliteWrapper.GenerateParameter("@FilePath", $"{directoryPath}\\%"));

            if (isRecursive != true)
            {
                query += " AND TagLinks.FilePath NOT LIKE @ExcludedFilePath";
                dbParameters.Add(SqliteWrapper.GenerateParameter("@ExcludedFilePath", $"{directoryPath}\\%\\%"));
            }

            int numberOfRecords;
            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                numberOfRecords = Convert.ToInt32(dbContext.GetScalar(query, dbParameters));
            }

            HttpContext.Current.Cache.Insert(cacheKey, numberOfRecords, null, DateTime.Today.AddDays(1), TimeSpan.Zero);

            return numberOfRecords;
        }

        public static ICollection<TagLink> GetByTagName(string tagName)
        {
            tagName = tagName.ToLowerInvariant();

            string cacheKey = $"[TagLinks][ByTagName]{tagName}";
            if (HttpContext.Current.Cache[cacheKey] is ICollection<TagLink> cachedTags)
            {
                return cachedTags;
            }

            string query =
                @"SELECT TagLinks.TagLinkId, TagLinks.FilePath AS [FilePath], TagLinks.TagId
                                FROM TagLinks
                                INNER JOIN Tags ON TagLinks.TagId = Tags.TagId
                                WHERE Tags.TagName = @TagName";

            ICollection<IDictionary<string, object>> dataRows;
            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                dataRows = dbContext.GetDataRows(query, SqliteWrapper.GenerateParameter("@TagName", tagName));
            }

            ICollection<TagLink> tagLinks = dataRows.Select(dataRow =>
                    new TagLink(dataRow)
                )
                .ToList();

            HttpContext.Current.Cache.Insert(cacheKey, tagLinks, null, DateTime.Today.AddDays(1), TimeSpan.Zero);

            return tagLinks;
        }

        public static bool InitialiseTable()
        {
            string query = @"CREATE TABLE IF NOT EXISTS TagLinks (
                TagLinkId INTEGER PRIMARY KEY,
                TagId INT,
                FilePath TEXT NOT NULL,
                FOREIGN KEY(TagId) REFERENCES Tags(TagId)
            )";

            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                try
                {
                    dbContext.ExecuteNonQuery(query);
                    return true;
                }
                catch (SqliteException)
                {
                    return false;
                }
            }
        }

        /// <summary>
        ///     Creates multiple tag links at once. For performance reasons, the ID is not set.
        /// </summary>
        /// <param name="tagLinks"></param>
        /// <returns></returns>
        public static bool CreateRange(IEnumerable<TagLink> tagLinks)
        {
            ICollection<SqliteParameter> parameters = new List<SqliteParameter>();

            string query = "INSERT INTO TagLinks (TagId, FilePath) VALUES";

            int tagLinkIndex = 1;
            bool isSuccess = true;
            foreach (TagLink tagLink in tagLinks)
            {
                // We may need to search for paths later and we don't want to be dealing with differences in slashes
                tagLink.FilePath = tagLink.FilePath.ToLower().Replace("/", "\\");

                parameters.Add(SqliteWrapper.GenerateParameter($@"TagId{tagLinkIndex}", tagLink.TagId));
                parameters.Add(SqliteWrapper.GenerateParameter($"FilePath{tagLinkIndex}", tagLink.FilePath));

                query += $" (@TagId{tagLinkIndex}, @FilePath{tagLinkIndex}),";

                // Quick fix for "too many SQL variables" as I'm tired. // Todo - Refactor this
                if (tagLinkIndex % 499 == 0)
                {
                    query = query.TrimEnd(',');

                    using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
                    {
                        isSuccess &= dbContext.ExecuteNonQuery(query, parameters);
                    }

                    parameters = new List<SqliteParameter>();
                    query = "INSERT INTO TagLinks (TagId, FilePath) VALUES";
                }

                tagLinkIndex++;
            }

            if (query == "INSERT INTO TagLinks (TagId, FilePath) VALUES")
            {
                return isSuccess;
            }

            query = query.TrimEnd(',');

            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                isSuccess &= dbContext.ExecuteNonQuery(query, parameters);
            }

            ClearCaches();

            return isSuccess;
        }

        private static void ClearCaches()
        {
            foreach (DictionaryEntry cacheEntry in HttpContext.Current.Cache)
            {
                string cacheKey = cacheEntry.Key.ToString();
                if (cacheKey.StartsWith("[TagLinks]"))
                {
                    HttpContext.Current.Cache.Remove(cacheKey);
                }
            }
        }

        public bool Create()
        {
            // We may need to search for paths later and we don't want to be dealing with differences in slashes
            FilePath = FilePath.ToLowerInvariant().Replace("/", "\\");

            const string query =
                @"INSERT INTO TagLinks (TagId, FilePath) VALUES (@TagId, @FilePath); SELECT last_insert_rowid()";

            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                TagLinkId = Convert.ToInt32(dbContext.GetScalar(query, GenerateParameters()));
            }

            bool isSuccess = TagLinkId != 0;

            ClearCaches();

            return isSuccess;
        }

        public bool Delete()
        {
            const string query = @"DELETE FROM TagLinks WHERE TagLinkId = @TagLinkId";

            bool isSuccess;
            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                isSuccess = dbContext.ExecuteNonQuery(query, GenerateParameters());
            }

            ClearCaches();

            return isSuccess;
        }

        public bool Update()
        {
            // We may need to search for paths later and we don't want to be dealing with differences in slashes
            FilePath = FilePath.ToLowerInvariant().Replace("/", "\\");

            const string query =
                @"UPDATE TagLinks SET TagId = @TagId, FilePath = @FilePath WHERE TagLinkId = @TagLinkId";

            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                dbContext.GetScalar(query, GenerateParameters());
            }

            bool isSuccess = TagLinkId != 0;

            ClearCaches();

            return isSuccess;
        }

        private ICollection<SqliteParameter> GenerateParameters()
        {
            return new List<SqliteParameter>
            {
                SqliteWrapper.GenerateParameter("@TagLinkId", TagLinkId),
                SqliteWrapper.GenerateParameter("@TagId", TagId),
                SqliteWrapper.GenerateParameter("@FilePath", FilePath)
            };
        }
    }
}<file_sep>/ContentExplorer/README.md
**Before Running on IIS** 
Rename Example.Web.Config to Web.Config and change the AppSettings accordingly<file_sep>/ContentExplorer/Services/VideoThumbnailService.cs
﻿using System.Collections.Generic;
using System.IO;
using System.Linq;
using NReco.VideoConverter;

namespace ContentExplorer.Services
{
    public class VideoThumbnailService : IThumbnailService
    {
        public void CreateThumbnail(FileInfo fileInfo)
        {
            FileInfo fileThumbnail = GetFileThumbnail(fileInfo);
            if (fileThumbnail.Exists)
            {
                return;
            }

            CreateThumbnail(fileInfo, 240);

            FileInfo thumbnailInfo = GetFileThumbnail(fileInfo);
            if (thumbnailInfo.Exists == false || thumbnailInfo.Length == 0)
            {
                CreateThumbnail(fileInfo, 120);
            }

            thumbnailInfo = GetFileThumbnail(fileInfo);
            if (thumbnailInfo.Exists == false || thumbnailInfo.Length == 0)
            {
                CreateThumbnail(fileInfo, 60);
            }

            thumbnailInfo = GetFileThumbnail(fileInfo);
            if (thumbnailInfo.Exists == false || thumbnailInfo.Length == 0)
            {
                CreateThumbnail(fileInfo, 1);
            }

            // If the video is less than 1 second long, it should really be a gif
            thumbnailInfo = GetFileThumbnail(fileInfo);
            if (thumbnailInfo.Exists == false || thumbnailInfo.Length == 0)
            {
                fileInfo.MoveTo(fileInfo.FullName + ".shouldbegif");
            }
        }

        public void DeleteThumbnailIfExists(FileInfo fileInfo)
        {
            FileInfo thumbnail = GetFileThumbnail(fileInfo);
            if (thumbnail.Exists)
            {
                thumbnail.Delete();
            }
        }

        public FileInfo GetDirectoryThumbnail(DirectoryInfo directory)
        {
            FileTypeService fileTypeService = new FileTypeService();

            FileInfo previewImage = directory
                .EnumerateFiles()
                .FirstOrDefault(file => fileTypeService.IsFileVideo(file.Name));

            if (previewImage != null)
            {
                return previewImage;
            }

            IEnumerable<DirectoryInfo> subDirectories =
                directory.EnumerateDirectories("*", SearchOption.AllDirectories);

            FileInfo firstPreview = subDirectories
                .Select(GetDirectoryThumbnail)
                .FirstOrDefault(thumbnail => thumbnail != null);

            return firstPreview;
        }

        public FileInfo GetFileThumbnail(FileInfo fileInfo)
        {
            string thumbnailPath = $"{fileInfo.FullName}.jpg";
            FileInfo directoryThumbnail = new FileInfo(thumbnailPath);

            return directoryThumbnail;
        }

        public bool IsThumbnail(FileInfo fileInfo)
        {
            bool isThumbnail = fileInfo.Name.EndsWith(".jpg");
            return isThumbnail;
        }

        private void CreateThumbnail(FileInfo videoFileInfo, int frametime)
        {
            FileInfo thumbnailLocation = GetFileThumbnail(videoFileInfo);
            using (Stream jpgStream = new FileStream(thumbnailLocation.FullName, FileMode.Create))
            {
                FFMpegConverter ffMpeg = new FFMpegConverter();

                try
                {
                    ffMpeg.GetVideoThumbnail(videoFileInfo.FullName, jpgStream, frametime);
                }
                catch (FFMpegException exception)
                {
                    // File can not be properly read so no thumbnail for us
                    if (exception.ErrorCode == 69)
                    {
                        videoFileInfo.MoveTo(videoFileInfo.FullName + ".broken");
                    }
                }
            }
        }
    }
}<file_sep>/ContentExplorer/Services/IFileSystemFilteringService.cs
﻿using ContentExplorer.Models;
using System.Collections.Generic;
using System.IO;

namespace ContentExplorer.Services
{
    public interface IFileSystemFilteringService
    {
        bool TagLinkMatchesFilter(IEnumerable<TagLink> tagLinksforFile, string[] filters);
        bool TagsMatchFilter(IEnumerable<Tag> tags, IEnumerable<string> filters);
        bool FileMatchesFilter(FileInfo fileInfo, string[] filters);
        bool DirectoryMatchesFilter(DirectoryInfo directoryInfo, string[] filters, string mediaType);
    }
}
<file_sep>/ContentExplorer/Models/ViewModels/ImageViewModel.cs
﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

namespace ContentExplorer.Models.ViewModels
{
    public class ImageViewModel
    {
        public string ImagePath { get; set; }
        public string ImageName { get; set; }
    }
}<file_sep>/ContentExplorer/App_Start/BundleConfig.cs
﻿using System.Web.Optimization;

namespace ContentExplorer
{
    public class BundleConfig
    {
        // For more information on bundling, visit https://go.microsoft.com/fwlink/?LinkId=301862
        public static void RegisterBundles(BundleCollection bundles)
        {
            RegisterStyles(bundles);
            RegisterScripts(bundles);
        }

        private static void RegisterStyles(BundleCollection bundles)
        {
            bundles.Add(new StyleBundle("~/Content/css").Include(
                "~/Core/Styles/site.min.css"));
        }

        private static void RegisterScripts(BundleCollection bundles)
        {
            bundles.Add(new ScriptBundle("~/bundles/scripts/common").Include(
                "~/Core/Scripts/Common/jquery-{version}.js",
                "~/Core/Scripts/Common/modernizr-*",
                "~/Core/Scripts/Common/bootstrap.js",
                "~/Core/Scripts/Common/HttpRequester.js",
                "~/Core/Scripts/Common/Repositories/MediaRepository.js",
                "~/Core/Scripts/Common/Repositories/TagRepository.js",
                "~/Core/Scripts/Common/Factories/MediaUiFactory.js"
            ));

            bundles.Add(new ScriptBundle("~/bundles/scripts/image/view")
                .Include(
                    "~/Core/Scripts/View/MediaView.js",
                    "~/Core/Scripts/View/ImageView.js"
                )
            );

            bundles.Add(new ScriptBundle("~/bundles/scripts/video/view")
                .Include(
                    "~/Core/Scripts/View/MediaView.js",
                    "~/Core/Scripts/View/VideoView.js"
                )
            );

            bundles.Add(new ScriptBundle("~/bundles/scripts/image/index")
                .Include(
                    "~/Core/Scripts/Index/MediaIndex.js",
                    "~/Core/Scripts/Index/ImageIndex.js"
                )
            );

            bundles.Add(new ScriptBundle("~/bundles/scripts/video/index")
                .Include(
                    "~/Core/Scripts/Index/MediaIndex.js",
                    "~/Core/Scripts/Index/VideoIndex.js"
                )
            );
        }
    }
}
<file_sep>/ContentExplorer/Services/VideoConversionService.cs
﻿using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using NReco.VideoConverter;

namespace ContentExplorer.Services
{
    public class VideoConversionService
    {
        public void ConvertUnplayableVideos(DirectoryInfo directory)
        {
            IEnumerable<DirectoryInfo> subDirectories = directory.GetDirectories();

            foreach (DirectoryInfo subDirectory in subDirectories)
            {
                ConvertUnplayableVideos(subDirectory);
            }

            IEnumerable<FileInfo> unplayableVideos = directory
                .EnumerateFiles()
                .Where(potentialVideo =>
                    UnplayableVideoTypes.Any(unplayableVideoType =>

                        potentialVideo.Extension.Equals(unplayableVideoType, StringComparison.OrdinalIgnoreCase)
                    )
                );


            FFMpegConverter ffMpegConverter = new FFMpegConverter();

            foreach (FileInfo unplayableVideo in unplayableVideos)
            {
                int indexOfExtension = unplayableVideo.Name.LastIndexOf(".");
                string videoType = unplayableVideo.Name.Substring(indexOfExtension + 1);
                ffMpegConverter.ConvertMedia(unplayableVideo.FullName, $"{unplayableVideo.FullName}.mp4", "mp4");
                unplayableVideo.MoveTo(Path.Combine(unplayableVideo.Directory.FullName, unplayableVideo.Name + ".old"));
            }

        }

        private static readonly string[] UnplayableVideoTypes = new[]
        {
            ".avi",
            ".mkv"
        };
    }
}<file_sep>/ContentExplorer/Models/Tag.cs
﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using Microsoft.Data.Sqlite;

namespace ContentExplorer.Models
{
    public class Tag
    {
        private static readonly List<string> CacheKeys = new List<string>();

        public Tag()
        {
        }

        public Tag(IDictionary<string, object> rowValues) : this()
        {
            TagId = Convert.ToInt32(rowValues["TagId"]);
            TagName = Convert.ToString(rowValues["TagName"]);
        }

        public string TagName { get; set; }
        public int TagId { get; set; }

        private static void ClearCaches()
        {
            foreach (string cacheKey in CacheKeys)
            {
                HttpContext.Current.Cache.Remove(cacheKey);
            }
        }

        public static ICollection<Tag> GetAll()
        {
            string cacheKey = "[Tags]";

            if (HttpContext.Current.Cache[cacheKey] is ICollection<Tag> cachedTags)
            {
                return cachedTags;
            }

            string query = @"SELECT Tags.TagId, Tags.TagName
                                FROM Tags";

            ICollection<IDictionary<string, object>> dataRows;
            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                dbContext.GetDataRows(query);

                dataRows = dbContext.GetDataRows(query);
            }

            ICollection<Tag> tags = dataRows.Select(dataRow =>
                    new Tag(dataRow)
                )
                .ToList();

            HttpContext.Current.Cache.Insert(cacheKey, tags, null, DateTime.Today.AddDays(1), TimeSpan.Zero);
            CacheKeys.Add(cacheKey);

            return tags;
        }

        public static ICollection<Tag> GetByFile(string filePath)
        {
            string cacheKey = $"[TagsByFile]{filePath}";

            if (HttpContext.Current.Cache[cacheKey] is ICollection<Tag> cachedTags)
            {
                return cachedTags;
            }

            // The caller shouldn't have to care about which slash to use
            filePath = filePath.Replace("/", "\\");

            // We need to distinguish between files and directories with the same name but the caller shouldn't have to worry about it
            filePath = filePath.ToLowerInvariant().TrimEnd("/\\".ToCharArray());

            string query = @"SELECT Tags.TagId, Tags.TagName
                                FROM Tags
                                INNER JOIN TagLinks ON Tags.TagId = TagLinks.TagId
                                WHERE TagLinks.FilePath = @FilePath
                                GROUP BY Tags.TagId, Tags.TagName";

            ICollection<IDictionary<string, object>> dataRows;
            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                dataRows = dbContext.GetDataRows(query, SqliteWrapper.GenerateParameter("@FilePath", filePath));
            }

            ICollection<Tag> tags = dataRows.Select(dataRow =>
                    new Tag(dataRow)
                )
                .ToList();

            HttpContext.Current.Cache.Insert(cacheKey, tags, null, DateTime.Today.AddDays(1), TimeSpan.Zero);
            CacheKeys.Add(cacheKey);

            return tags;
        }

        public static ICollection<Tag> GetByDirectory(string directoryPath, string[] filters, bool isRecursive = false)
        {
            string cacheKey = $"[TagsByDirectoryAndFilters]{directoryPath}{string.Join(",", filters)}";

            if (HttpContext.Current.Cache[cacheKey] is ICollection<Tag> cachedTags)
            {
                return cachedTags;
            }

            string[] stringParameters = new string[filters.Length];
            SqliteParameter[] dbParameters = new SqliteParameter[filters.Length + 2];

            for (int filterIndex = 0; filterIndex < filters.Length; filterIndex++)
            {
                string stringParameter = $"@{filterIndex}";
                stringParameters[filterIndex] = stringParameter;
                dbParameters[filterIndex] = SqliteWrapper.GenerateParameter(stringParameter, filters[filterIndex]);
            }

            // Add the file path parameter to the end of the array
            dbParameters[filters.Length] = SqliteWrapper.GenerateParameter("@FilePath", $"{directoryPath}\\%");

            string query = $@"SELECT Tags.TagId, Tags.TagName, TagLinks.FilePath, TagLinks.TagLinkId
                                FROM Tags
                                INNER JOIN TagLinks ON Tags.TagId = TagLinks.TagId
                                WHERE TagLinks.FilePath LIKE @FilePath
                                AND Tags.TagName IN ({string.Join(",", stringParameters)})
                                GROUP BY Tags.TagId, Tags.TagName";

            if (isRecursive != true)
            {
                query += " AND FilePath NOT LIKE @ExcludedFilePath";
                dbParameters[filters.Length + 1] = SqliteWrapper.GenerateParameter("@ExcludedFilePath", $"{directoryPath}\\%\\%");
            }

            ICollection<IDictionary<string, object>> dataRows;
            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                dataRows = dbContext.GetDataRows(query, dbParameters);
            }

            ICollection<Tag> tags = dataRows
                .Select(dataRow =>
                    new Tag(dataRow)
                )
                .ToList();

            HttpContext.Current.Cache.Insert(cacheKey, tags, null, DateTime.Today.AddDays(1), TimeSpan.Zero);
            CacheKeys.Add(cacheKey);

            return tags;
        }

        public static Tag GetById(int tagId)
        {
            string cacheKey = $"[TagsById]{tagId}";

            if (HttpContext.Current.Cache[cacheKey] is Tag cachedTags)
            {
                return cachedTags;
            }

            string query = @"SELECT TagId, TagName
                                FROM Tags
                                WHERE TagId = @TagId";

            IDictionary<string, object> dataRow;
            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                dataRow = dbContext.GetDataRow(query, SqliteWrapper.GenerateParameter("@TagId", tagId));
            }

            Tag tag = dataRow != null ? new Tag(dataRow) : null;

            if (tag != null)
            {
                HttpContext.Current.Cache.Insert(cacheKey, tag, null, DateTime.Today.AddDays(1), TimeSpan.Zero);
                CacheKeys.Add(cacheKey);
            }

            return tag;
        }

        public static ICollection<Tag> GetByFile(string filePath, string[] filters)
        {
            string cacheKey = $"[TagsByFile]{filePath}";

            if (HttpContext.Current.Cache[cacheKey] is ICollection<Tag> cachedTags)
            {
                return cachedTags;
            }

            // The caller shouldn't have to care about which slash to use
            filePath = filePath.Replace("/", "\\");

            // We need to distinguish between files and directories with the same name but the caller shouldn't have to worry about it
            filePath = filePath.ToLowerInvariant().TrimEnd("/\\".ToCharArray());

            string query = @"SELECT Tags.TagId, Tags.TagName
                                FROM Tags
                                INNER JOIN TagLinks ON Tags.TagId = TagLinks.TagId
                                WHERE TagLinks.FilePath = @FilePath
                                GROUP BY Tags.TagId, Tags.TagName";

            ICollection<IDictionary<string, object>> dataRows;
            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                dataRows = dbContext.GetDataRows(query, SqliteWrapper.GenerateParameter("@FilePath", filePath));
            }

            ICollection<Tag> tags = dataRows.Select(dataRow =>
                    new Tag(dataRow)
                )
                .ToList();

            HttpContext.Current.Cache.Insert(cacheKey, tags, null, DateTime.Today.AddDays(1), TimeSpan.Zero);
            CacheKeys.Add(cacheKey);

            return tags;
        }

        public static Tag GetByTagName(string tagName)
        {
            string cacheKey = $"[TagByTagName]{tagName}";

            if (HttpContext.Current.Cache[cacheKey] is Tag cachedTags)
            {
                return cachedTags;
            }

            tagName = tagName.ToLowerInvariant();

            string query = @"SELECT TagId, TagName
                                FROM Tags
                                WHERE TagName = @TagName";


            IDictionary<string, object> dataRow;
            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                dataRow = dbContext.GetDataRow(query, SqliteWrapper.GenerateParameter("@TagName", tagName));
            }

            Tag tag = dataRow != null ? new Tag(dataRow) : null;

            if (tag != null)
            {
                HttpContext.Current.Cache.Insert(cacheKey, tag, null, DateTime.Today.AddDays(1), TimeSpan.Zero);
                CacheKeys.Add(cacheKey);
            }

            return tag;
        }

        public static bool InitialiseTable()
        {
            string query = @"CREATE TABLE IF NOT EXISTS Tags (
                TagId INTEGER PRIMARY KEY,
                TagName TEXT NOT NULL
            )";

            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                try
                {
                    dbContext.ExecuteNonQuery(query);
                    return true;
                }
                catch (SqliteException)
                {
                    return false;
                }
            }
        }

        public bool Create()
        {
            const string query = @"INSERT INTO Tags (TagName) VALUES (@TagName); SELECT last_insert_rowid()";

            TagName = TagName.ToLowerInvariant();

            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                TagId = Convert.ToInt32(dbContext.GetScalar(query, GenerateParameters()));
            }

            ClearCaches();

            bool isSuccess = TagId != 0;

            return isSuccess;
        }

        public bool Delete()
        {
            const string query = @"DELETE FROM Tags WHERE TagId = @TagId";

            bool isSuccess;
            using (SqliteWrapper dbContext = new SqliteWrapper("AppDb"))
            {
                isSuccess = dbContext.ExecuteNonQuery(query, GenerateParameters());
            }

            ClearCaches();

            return isSuccess;
        }

        private ICollection<SqliteParameter> GenerateParameters()
        {
            return new List<SqliteParameter>
            {
                SqliteWrapper.GenerateParameter("@TagId", TagId),
                SqliteWrapper.GenerateParameter("@TagName", TagName)
            };
        }
    }
}<file_sep>/ContentExplorer/Controllers/TagController.cs
﻿using System;
using System.Collections.Generic;
using System.Configuration;
using System.IO;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using ContentExplorer.Models;
using ContentExplorer.Services;

namespace ContentExplorer.Controllers
{
    public class TagController : Controller
    {
        [HttpPost]
        public JsonResult AddTagsToFiles(string[] filePaths, string[] tags, string mediaType)
        {
            // Filter inputs that would make no sense
            filePaths = filePaths
                .Where(filePath => filePath != "")
                .Select(HttpUtility.UrlDecode)
                .ToArray();
            tags = tags.Where(tag => tag != "").Select(tag => tag.Trim()).ToArray();

            string rootDirectory = GetRootDirectory(mediaType);
            IEnumerable<string> diskLocations = filePaths
                .Select(filePath => Path.Combine(rootDirectory, filePath))
                .ToArray();

            bool isSuccess = AddTags(diskLocations, tags);

            return Json(isSuccess);
        }

        [HttpPost]
        public JsonResult AddTagsToDirectories(string[] directoryPaths, string[] tags, string mediaType)
        {
            // Filter inputs that would make no sense
            directoryPaths = directoryPaths
                .Where(filePath => filePath != "")
                .Select(HttpUtility.UrlDecode)
                .ToArray();

            tags = tags.Where(tag => tag != "").Select(tag => tag.Trim()).ToArray();

            string cdnDiskLocation = ConfigurationManager.AppSettings["BaseDirectory"];
            string rootDirectory = Path.Combine(cdnDiskLocation, GetRootDirectory(mediaType));
            directoryPaths = directoryPaths.Select(directoryPath => Path.Combine(rootDirectory, directoryPath)).ToArray();

            bool isSuccess = true;
            foreach (string directoryPath in directoryPaths)
            {
                DirectoryInfo directory = new DirectoryInfo(directoryPath);
                if (directory.Exists)
                {
                    IEnumerable<string> fileNames = directory.EnumerateFiles("*.*", SearchOption.AllDirectories)
                        .Select(subFile => subFile.FullName.Substring(cdnDiskLocation.Length + 1));

                    isSuccess &= AddTags(fileNames, tags);
                }
            }

            return Json(isSuccess);
        }

        [HttpGet]
        public JsonResult GetFileTags(string fileName, string mediaType)
        {
            fileName = GetRootDirectory(mediaType) + "\\" + fileName;

            IFileSystemFilteringService fileSystemFilteringService = new FileSystemFilteringService();
            ICollection<Tag> tagLinksForFile = Tag.GetByFile(fileName)
                .OrderBy(tag => tag.TagName)
                .ToArray();

            return Json(tagLinksForFile, JsonRequestBehavior.AllowGet);
        }

        [HttpGet]
        public JsonResult GetDirectoryTags(string directoryName, string mediaType, string filter)
        {
            directoryName = GetRootDirectory(mediaType) + "\\" + directoryName;

            string[] filters = filter.Split(",".ToCharArray(), StringSplitOptions.RemoveEmptyEntries);
            IFileSystemFilteringService fileSystemFilteringService = new FileSystemFilteringService();
            ICollection<TagLink> tagLinks = TagLink.GetByDirectory(directoryName);

            var tagLinksGroupedByFile = tagLinks.GroupBy(tagLink => tagLink.FilePath);

            var filteredTagLinks = tagLinksGroupedByFile
                .Where(tagsLinksForFile =>
                    fileSystemFilteringService.TagLinkMatchesFilter(tagsLinksForFile, filters)
                ).ToList();

            IEnumerable<Tag> tagsForDirectory = filteredTagLinks
                .SelectMany(tagLinksForFile =>
                    tagLinksForFile.Select(tagLinkForFile =>
                        tagLinkForFile.GetTag()
                    )
                )
                .GroupBy(tag => tag.TagName)
                .Select(grouping => grouping.First())
                .OrderBy(tag => tag.TagName)
                .ToArray();

            return Json(tagsForDirectory, JsonRequestBehavior.AllowGet);
        }

        private string GetRootDirectory(string mediaType)
        {
            if (mediaType.Equals("video", StringComparison.OrdinalIgnoreCase))
            {
                return ConfigurationManager.AppSettings["VideosPath"];
            }
            else if (mediaType.Equals("image", StringComparison.OrdinalIgnoreCase))
            {
                return ConfigurationManager.AppSettings["ImagesPath"];
            }
            else
            {
                throw new InvalidOperationException("Media type is unsupported.");
            }
        }

        public JsonResult DeleteAllTags()
        {
            IEnumerable<Tag> tags = Tag.GetAll();

            foreach (Tag tag in tags)
            {
                IEnumerable<TagLink> tagLinks = TagLink.GetByTagName(tag.TagName);
                foreach (TagLink tagLink in tagLinks)
                {
                    tagLink.Delete();
                }

                tag.Delete();
            }

            return Json(true, JsonRequestBehavior.AllowGet);
        }

        [HttpGet]
        public JsonResult DeleteUnusedTags()
        {
            IEnumerable<TagLink> allTagLinks = TagLink.GetAll();
            string cdnDiskPath = ConfigurationManager.AppSettings["BaseDirectory"];

            IEnumerable<TagLink> unusedTagLinks = allTagLinks
                .Where(tagLink => new FileInfo($"{cdnDiskPath}\\{tagLink.FilePath}").Exists != true);

            foreach (TagLink tagLink in unusedTagLinks)
            {
                tagLink.Delete();
            }

            return Json(true, JsonRequestBehavior.AllowGet);
        }

        private bool AddTags(IEnumerable<string> filePaths, ICollection<string> tagNames)
        {
            IEnumerable<Tag> requestedTags = tagNames
                .Select(GetOrCreateTag);

            var filesMappedToTags = filePaths
                .Select(filePath =>
                    new
                    {
                        FilePath = filePath,
                        TagsByFileName = Tag.GetByFile(filePath)
                    }
                )
                .ToList();

            IEnumerable<TagLink> missingTagLinks = filesMappedToTags
                .SelectMany(fileMappedToTags =>

                    requestedTags.Where(requestedTag =>

                            fileMappedToTags.TagsByFileName.All(existingTag =>
                                existingTag.TagId != requestedTag.TagId
                            )
                        )
                        .Select(missingTag => new TagLink
                        {
                            TagId = missingTag.TagId,
                            FilePath = fileMappedToTags.FilePath
                        })
                )
                .ToList();

            bool isSuccess = TagLink.CreateRange(missingTagLinks);

            return isSuccess;
        }

        private Tag GetOrCreateTag(string tagName)
        {
            Tag savedTag = Tag.GetByTagName(tagName);

            if (savedTag != null)
            {
                return savedTag;
            }

            // If the tag hasn't been saved yet, save it
            savedTag = new Tag
            {
                TagName = tagName
            };

            bool isSuccess = savedTag.Create();
            return isSuccess ? savedTag : null;
        }
    }
}<file_sep>/ContentExplorer/Core/Scripts/Index/ImageIndex.js
function ImageIndex() {

    MediaIndex.call(this, "image", "Image");

    this.$actions = $("[data-actions='image']");
}

ImageIndex.prototype = Object.create(MediaIndex.prototype);

ImageIndex.prototype.addMediaDependentActions = function () {

    MediaIndex.prototype.addMediaDependentActions.call(this);

    var $rebuildThumbnailsButton = $("<a>")
        .attr("href", "../" + this.controller + "/" + "RebuildThumbnails?path=" + this.directoryPath)
        .attr("target", "_blank")
        .append(
            $("<div>").addClass("btn btn-default").text("Rebuild Thumbnails")
        );

    var $reformatNames = $("<a>")
        .attr("href", "../" + this.controller + "/" + "ReformatNames?path=" + this.directoryPath)
        .attr("target", "_blank")
        .append(
            $("<div>").addClass("btn btn-default").text("Reformat Names")
        );

    this.$actions
        .append($rebuildThumbnailsButton)
        .append($reformatNames);
}

$(function () {

    var mediaIndex = new ImageIndex();
    mediaIndex.initialiseAsync();
})<file_sep>/ContentExplorer/Models/ViewModels/MediaPreviewViewModel.cs
﻿namespace ContentExplorer.Models.ViewModels
{
    public class MediaPreviewViewModel
    {
        public string Name { get; set; }
        public string Path { get; set; }
        public string ContentUrl { get; set; }
        public string ThumbnailUrl { get; set; }
        public string TaggingUrl { get; set; }
    }
}<file_sep>/ContentExplorer/Controllers/ImageController.cs
﻿using System;
using System.Collections.Generic;
using System.Configuration;
using System.IO;
using System.Linq;
using System.Web.Mvc;
using ContentExplorer.Models;
using ContentExplorer.Services;

namespace ContentExplorer.Controllers
{
    public class ImageController : Controller
    {
        [HttpGet]
        public ActionResult Index(string path = "", int? page = 1, string filter = null)
        {
            if (page == null || page < 1)
            {
                page = 1;
            }

            if (path == null)
            {
                path = "";
            }
            else
            {
                // Remove any dangerous characters to prevent escaping the current context
                path = path.Trim("/\\.".ToCharArray());
            }

            if (filter == null)
            {
                filter = "";
            }

            DirectoryInfo directoryInfo = GetCurrentDirectory(path);

            if (directoryInfo.Exists == false)
            {
                return RedirectToAction("Index", new { page, filter });
            }

            ViewBag.FilesPerPage = 50;
            ViewBag.Directory = directoryInfo;
            ViewBag.Page = page;
            ViewBag.Filter = filter;

            return View();
        }

        [HttpGet]
        public ActionResult ReformatNames(string path = "")
        {
            DirectoryInfo baseDirectory = GetCurrentDirectory(path);
            IEnumerable<FileInfo> subFiles = baseDirectory.EnumerateFiles("*", SearchOption.AllDirectories);
            NameFormatService nameFormatService = new NameFormatService();
            string cdnDiskLocation = ConfigurationManager.AppSettings["BaseDirectory"];

            foreach (FileInfo subFile in subFiles)
            {
                FileInfo newFileName = nameFormatService.FormatFileName(subFile);

                if (subFile.Name.Equals(newFileName.Name, StringComparison.OrdinalIgnoreCase) != true)
                {
                    ICollection<TagLink> tagLinksForFile = TagLink.GetByFileName(subFile.FullName);

                    foreach (TagLink tagLink in tagLinksForFile)
                    {
                        tagLink.FilePath = newFileName.FullName.Substring(cdnDiskLocation.Length + 1);
                        tagLink.Update();
                    }
                }
            }

            return Json(true, JsonRequestBehavior.AllowGet);
        }

        [HttpGet]
        public ActionResult RebuildThumbnails(string path = "")
        {
            IThumbnailService imageThumbnailService = new ImageThumbnailService();

            DirectoryInfo baseDirectory = GetCurrentDirectory(path);
            IEnumerable<FileInfo> images = baseDirectory.EnumerateFiles("*", SearchOption.AllDirectories)
                .Where(image => imageThumbnailService.IsThumbnail(image) != true);
            foreach (FileInfo image in images)
            {
                imageThumbnailService.DeleteThumbnailIfExists(image);
                imageThumbnailService.CreateThumbnail(image);
            }

            return Json(true, JsonRequestBehavior.AllowGet);
        }

        [HttpGet]
        public ViewResult View(string path, int page, string filter = "")
        {
            if (page < 1)
            {
                page = 1;
            }

            if (filter == null)
            {
                filter = "";
            }

            ViewBag.Path = path;
            ViewBag.Id = page;
            ViewBag.Filter = filter;

            return View();
        }

        private DirectoryInfo GetCurrentDirectory(string relativePath)
        {
            DirectoryInfo directoryInfo = new DirectoryInfo(Path.Combine(ConfigurationManager.AppSettings["BaseDirectory"], ConfigurationManager.AppSettings["ImagesPath"], relativePath));

            return directoryInfo;
        }
    }
}<file_sep>/ContentExplorer/Services/NameFormatService.cs
﻿using System;
using System.Collections.Generic;
using System.Configuration;
using System.IO;
using System.Linq;
using System.Web;
using System.Web.Mvc;

namespace ContentExplorer.Services
{
    public class NameFormatService
    {
        public FileInfo FormatFileName(FileInfo fileInfo)
        {
            string formattedFileName = HttpUtility.UrlDecode(fileInfo.Name);

            formattedFileName = formattedFileName
                .Replace("\\", " ")
                .Replace("/", " ")
                .Replace(":", " - ");

            string[] wordsToRemoveFromNames = ConfigurationManager.AppSettings["WordsToRemoveFromNames"]?.Split('|');

            if (wordsToRemoveFromNames != null)
            {
                foreach (string wordToRemoveFromNames in wordsToRemoveFromNames)
                {
                    formattedFileName = formattedFileName.Replace($"{wordToRemoveFromNames} ", "");
                }
            }

            // Max length
            bool isTooLong = fileInfo.FullName.Length >= 250;
            if (isTooLong)
            {
                formattedFileName = formattedFileName.Remove(50);
                formattedFileName += fileInfo.Extension;
            }

            string formattedFullName = Path.Combine(fileInfo.Directory.FullName, formattedFileName);

            if (fileInfo.Name != formattedFileName)
            {
                try
                {
                    if (new FileInfo(formattedFullName).Exists)
                    {
                        formattedFileName = $"{formattedFileName}~{Guid.NewGuid()}";
                        formattedFullName = Path.Combine(fileInfo.Directory.FullName, formattedFileName + fileInfo.Extension);
                    }
                }
                catch (NotSupportedException)
                {
                    throw new NotSupportedException($"The file path {formattedFullName} is not supported by FileInfo.Exists");
                }

                string oldPath = isTooLong != true ? fileInfo.FullName : $@"\\?\{fileInfo.FullName}";

                try
                {
                    File.Move(oldPath, formattedFullName);
                }
                catch (DirectoryNotFoundException)
                {
                    throw new DirectoryNotFoundException($"Could not find either directory {oldPath} or directory {formattedFullName}");
                }
            }

            FileInfo renamedFile = new FileInfo(formattedFullName);
            return renamedFile;
        }
    }
}<file_sep>/ContentExplorer/Controllers/MediaController.cs
﻿using System;
using System.Collections.Generic;
using System.Configuration;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Web;
using System.Web.Mvc;
using ContentExplorer.Models;
using ContentExplorer.Models.ViewModels;
using ContentExplorer.Services;
using Microsoft.Ajax.Utilities;

namespace ContentExplorer.Controllers
{
    public class MediaController : Controller
    {
        [HttpGet]
        public JsonResult GetDirectoryHierarchy(string currentDirectory, string mediaType)
        {
            DirectoryInfo currentDirectoryInfo = GetCurrentDirectory(currentDirectory, mediaType);

            if (!currentDirectoryInfo.Exists)
            {
                return Json(null, JsonRequestBehavior.AllowGet);
            }

            DirectoryInfo hierarchyRootInfo = GetHierarchicalRootInfo(mediaType);
            DirectoryHierarchyViewModel directoryHierarchy =
                GetDirectoryAsHierarchy(currentDirectoryInfo, hierarchyRootInfo);

            return Json(directoryHierarchy, JsonRequestBehavior.AllowGet);
        }

        [HttpGet]
        public JsonResult GetSubDirectories(string currentDirectory, string mediaType, string filter)
        {
            string hierarchyRoot = GetHierarchyRoot(mediaType);
            string websiteDiskLocation = ConfigurationManager.AppSettings["BaseDirectory"];
            string hierarchyRootDiskLocation = Path.Combine(websiteDiskLocation, hierarchyRoot);

            string currentDirectoryPath = Path.Combine(hierarchyRootDiskLocation, currentDirectory);
            DirectoryInfo currentDirectoryInfo = new DirectoryInfo(currentDirectoryPath);

            if (!currentDirectoryInfo.Exists)
            {
                return Json(new List<MediaPreviewViewModel>(), JsonRequestBehavior.AllowGet);
            }

            string[] filters = filter.Split(",".ToCharArray(), StringSplitOptions.RemoveEmptyEntries);
            DirectoryInfo hierarchyRootInfo = new DirectoryInfo(hierarchyRootDiskLocation);
            IEnumerable<DirectoryInfo> matchingDirectories = GetMatchingSubDirectories(currentDirectoryInfo, filters, mediaType, 0, -1);

            ICollection<MediaPreviewViewModel> directoryPreviews = matchingDirectories
                .Select(subDirectoryInfo =>
                    GetMediaPreviewFromSubDirectory(subDirectoryInfo, hierarchyRootInfo, mediaType)
                )
                .Where(mediaPreview => mediaPreview != null)
                .ToList();

            return Json(directoryPreviews, JsonRequestBehavior.AllowGet);
        }

        [HttpPost]
        public JsonResult MoveSubDirectories(string[] directoryPaths, string newDirectoryPath, string mediaType)
        {
            directoryPaths = directoryPaths
                .Where(filePath => string.IsNullOrEmpty(filePath) != true)
                .Select(HttpUtility.UrlDecode)
                .ToArray();

            if (directoryPaths.Any() != true || string.IsNullOrEmpty(newDirectoryPath))
            {
                return Json(true, JsonRequestBehavior.AllowGet);
            }

            string websiteDiskLocation = ConfigurationManager.AppSettings["BaseDirectory"];
            string hierarchyRoot = GetHierarchyRoot(mediaType);
            string hierarchyRootDiskLocation = Path.Combine(websiteDiskLocation, hierarchyRoot);

            IThumbnailService thumbnailService = GetThumbnailService(mediaType);

            foreach (string directoryPath in directoryPaths)
            {
                DirectoryInfo directoryInfo = new DirectoryInfo($"{hierarchyRootDiskLocation}\\{directoryPath}");
                DirectoryInfo newDirectoryInfo = new DirectoryInfo($"{hierarchyRootDiskLocation}\\{newDirectoryPath}\\{directoryInfo.Name}");
                if (newDirectoryInfo.Exists != true)
                {
                    newDirectoryInfo.Create();
                }

                IEnumerable<FileInfo> directoryFiles = directoryInfo
                    .EnumerateFiles("*", SearchOption.TopDirectoryOnly)
                    .Where(subFile => thumbnailService.IsThumbnail(subFile) != true);

                foreach (FileInfo fileInfo in directoryFiles)
                {
                    FileInfo thumbnail = thumbnailService.GetFileThumbnail(fileInfo);
                    if (thumbnail.Exists)
                    {
                        string newThumbnailPath = $"{hierarchyRootDiskLocation}\\{newDirectoryPath}\\{directoryInfo.Name}\\{thumbnail.Name}";
                        thumbnail.MoveTo(newThumbnailPath);
                    }

                    if (fileInfo.Exists)
                    {
                        string newFilePath =
                            $"{hierarchyRootDiskLocation}\\{newDirectoryPath}\\{directoryInfo.Name}\\{fileInfo.Name}";
                        fileInfo.MoveTo(newFilePath);
                    }


                }

                TagLink.UpdateDirectoryPath(
                    $"{hierarchyRoot}\\{directoryPath}",
                    $"{hierarchyRoot}\\{newDirectoryPath}\\{directoryInfo.Name}\\"
                );

                bool areRemainingFiles = directoryInfo.EnumerateFiles("*", SearchOption.AllDirectories).Any();
                if (areRemainingFiles != true)
                {
                    directoryInfo.Delete(true);
                }
            }

            return Json(true, JsonRequestBehavior.AllowGet);
        }

        [HttpPost]
        public JsonResult MoveSubFiles(string[] filePaths, string newDirectoryPath, string mediaType)
        {
            filePaths = filePaths
                .Where(filePath => string.IsNullOrEmpty(filePath) != true)
                .Select(HttpUtility.UrlDecode)
                .ToArray();

            if (filePaths.Any() != true || string.IsNullOrEmpty(newDirectoryPath))
            {
                return Json(true, JsonRequestBehavior.AllowGet);
            }

            string websiteDiskLocation = ConfigurationManager.AppSettings["BaseDirectory"];
            string hierarchyRoot = GetHierarchyRoot(mediaType);
            string hierarchyRootDiskLocation = Path.Combine(websiteDiskLocation, hierarchyRoot);
            DirectoryInfo newDirectoryInfo = new DirectoryInfo($"{hierarchyRootDiskLocation}\\{newDirectoryPath}");
            if (newDirectoryInfo.Exists != true)
            {
                newDirectoryInfo.Create();
            }

            IThumbnailService thumbnailService = GetThumbnailService(mediaType);
            FileInfo firstFileInfo = new FileInfo($"{hierarchyRootDiskLocation}\\{filePaths[0]}");
            string directoryParentPath = firstFileInfo.DirectoryName;

            foreach (string filePath in filePaths)
            {
                FileInfo fileInfo = new FileInfo($"{hierarchyRootDiskLocation}\\{filePath}");
                string newFilePath = $"{hierarchyRootDiskLocation}\\{newDirectoryPath}\\{fileInfo.Name}";

                FileInfo thumbnail = thumbnailService.GetFileThumbnail(fileInfo);
                if (thumbnail.Exists)
                {
                    string newThumbnailPath = $"{hierarchyRootDiskLocation}\\{newDirectoryPath}\\{thumbnail.Name}";
                    thumbnail.MoveTo(newThumbnailPath);
                }

                if (fileInfo.Exists)
                {
                    fileInfo.MoveTo(newFilePath);
                }

                TagLink.UpdateFilePath(
                    $"{hierarchyRoot}\\{filePath}",
                    $"{hierarchyRoot}\\{newDirectoryPath}\\{fileInfo.Name}"
                );
            }

            DirectoryInfo parentDirectory = new DirectoryInfo(directoryParentPath);
            bool areRemainingFiles = parentDirectory.EnumerateFiles("*", SearchOption.AllDirectories).Any();
            if (areRemainingFiles != true)
            {
                parentDirectory.Delete(true);
            }

            return Json(true, JsonRequestBehavior.AllowGet);
        }

        [HttpGet]
        public JsonResult GetSubFiles(string currentDirectory, string mediaType, string filter, int skip, int take)
        {
            string hierarchyRoot = GetHierarchyRoot(mediaType);
            string websiteDiskLocation = ConfigurationManager.AppSettings["BaseDirectory"];
            string hierarchyRootDiskLocation = Path.Combine(websiteDiskLocation, hierarchyRoot);
            string[] filters = filter.Split(",".ToCharArray(), StringSplitOptions.RemoveEmptyEntries);

            string currentDirectoryPath = Path.Combine(hierarchyRootDiskLocation, currentDirectory);
            DirectoryInfo currentDirectoryInfo = new DirectoryInfo(currentDirectoryPath);

            if (!currentDirectoryInfo.Exists)
            {
                return Json(new PaginatedViewModel<MediaPreviewViewModel>()
                {
                    CurrentPage = new List<MediaPreviewViewModel>(),
                    Total = 0
                }, JsonRequestBehavior.AllowGet);
            }

            DirectoryInfo hierarchyRootInfo = new DirectoryInfo(hierarchyRootDiskLocation);
            IEnumerable<FileInfo> subFiles =
                GetMatchingSubFiles(currentDirectoryInfo, filters, mediaType, skip, take, false);

            ICollection<MediaPreviewViewModel> filePreviews = subFiles
                .Select(subFile => GetMediaPreviewFromSubFile(subFile, hierarchyRootInfo, mediaType))
                .ToArray();

            int totalNumberOfFiles = filters.Any() != true
                ? GetDirectoryFilesByMediaType(currentDirectoryInfo, mediaType, false).Count()
                : TagLink.GetFileCount($"{hierarchyRoot}\\{currentDirectory}", filters, skip, take);

            PaginatedViewModel<MediaPreviewViewModel> paginatedViewModel = new PaginatedViewModel<MediaPreviewViewModel>
            {
                CurrentPage = filePreviews,
                Total = totalNumberOfFiles
            };

            return Json(paginatedViewModel, JsonRequestBehavior.AllowGet);
        }

        private IEnumerable<FileInfo> GetDirectoryFilesByMediaType(DirectoryInfo directoryInfo, string mediaType,
            bool includeSubDirectories)
        {
            IEnumerable<FileInfo> subFiles = includeSubDirectories
                ? directoryInfo.EnumerateFiles("*.*", SearchOption.AllDirectories)
                : directoryInfo.EnumerateFiles("*.*", SearchOption.TopDirectoryOnly);

            FileTypeService fileTypeService = new FileTypeService();
            if (mediaType == "image")
            {
                IThumbnailService thumbnailService = new ImageThumbnailService();

                subFiles = subFiles
                    .Where(fileInfo =>
                        fileTypeService.IsFileImage(fileInfo.Name) &&
                        thumbnailService.IsThumbnail(fileInfo) != true
                    );
            }
            else
            {
                subFiles = subFiles
                    .Where(fileInfo =>
                        fileTypeService.IsFileVideo(fileInfo.Name)
                    );
            }

            return subFiles;
        }

        [HttpGet]
        public JsonResult GetSubFile(string currentDirectory, int page, string mediaType, string filter)
        {
            DirectoryInfo currentDirectoryInfo = GetCurrentDirectory(currentDirectory, mediaType);
            string[] filters = filter.Split(",".ToCharArray(), StringSplitOptions.RemoveEmptyEntries);
            FileInfo mediaItem = GetMatchingSubFiles(currentDirectoryInfo, filters, mediaType, page - 1, 1, false)
                .FirstOrDefault();

            if (mediaItem == null)
            {
                return Json(null, JsonRequestBehavior.AllowGet);
            }

            DirectoryInfo hierarchicalRootInfo = GetHierarchicalRootInfo(mediaType);
            MediaPreviewViewModel imageViewModel = GetMediaPreviewFromSubFile(mediaItem, hierarchicalRootInfo, mediaType);

            return Json(imageViewModel, JsonRequestBehavior.AllowGet);
        }

        private MediaPreviewViewModel GetMediaPreviewFromSubDirectory(DirectoryInfo subDirectory,
            DirectoryInfo hierarchicalDirectoryInfo, string mediaType)
        {
            IEnumerable<FileInfo> matchingSubFiles = subDirectory.EnumerateFiles("*.*", SearchOption.AllDirectories);
            IEnumerable<FileInfo> orderedSubFiles = OrderAlphabetically(matchingSubFiles);
            FileInfo firstMatchingImage = orderedSubFiles.FirstOrDefault();

            if (firstMatchingImage == null)
            {
                return null;
            }

            IThumbnailService thumbnailService = GetThumbnailService(mediaType);
            thumbnailService.CreateThumbnail(firstMatchingImage);

            MediaPreviewViewModel mediaPreview = new MediaPreviewViewModel
            {
                Name = subDirectory.Name,
                Path = GetUrl(subDirectory).Substring(hierarchicalDirectoryInfo.Name.Length).TrimStart('/'),
                ContentUrl = GetUrl(subDirectory),
                ThumbnailUrl = GetUrl(thumbnailService.GetFileThumbnail(firstMatchingImage)),
                TaggingUrl = GetUrl(subDirectory).Substring(hierarchicalDirectoryInfo.Name.Length).TrimStart('/')
            };

            return mediaPreview;
        }

        private IThumbnailService GetThumbnailService(string mediaType)
        {
            IThumbnailService thumbnailService;

            switch (mediaType)
            {
                case "image":
                    thumbnailService = new ImageThumbnailService();
                    break;
                case "video":
                    thumbnailService = new VideoThumbnailService();
                    break;
                default:
                    throw new NotImplementedException($"Media type {mediaType} is not supported");
            }

            return thumbnailService;
        }

        private MediaPreviewViewModel GetMediaPreviewFromSubFile(FileInfo subFile,
            DirectoryInfo hierarchicalDirectoryInfo, string mediaType)
        {
            IThumbnailService thumbnailService = GetThumbnailService(mediaType);

            if (thumbnailService.GetFileThumbnail(subFile).Exists != true)
            {
                thumbnailService.CreateThumbnail(subFile);
            }

            MediaPreviewViewModel mediaPreview = new MediaPreviewViewModel
            {
                Name = subFile.Name,
                Path = GetUrl(subFile.Directory).Substring(hierarchicalDirectoryInfo.Name.Length).TrimStart('/'),
                ContentUrl = $"{ConfigurationManager.AppSettings["CDNPath"]}/{GetUrl(subFile)}",
                ThumbnailUrl = GetUrl(thumbnailService.GetFileThumbnail(subFile)),
                TaggingUrl = GetUrl(subFile).Substring(hierarchicalDirectoryInfo.Name.Length).TrimStart('/')
            };

            return mediaPreview;
        }

        private IEnumerable<TFilesystemInfo> OrderAlphabetically<TFilesystemInfo>(
            IEnumerable<TFilesystemInfo> unorderedEnumerable) where TFilesystemInfo : FileSystemInfo
        {
            IEnumerable<TFilesystemInfo> orderedEnumerable = unorderedEnumerable
                .OrderBy(subFile => subFile.FullName)
                .ThenBy(file =>
                {
                    Match numbersInName = Regex.Match(file.Name, "[0-9]+");

                    bool isNumber = int.TryParse(numbersInName.Value, out int numericalMatch);

                    return isNumber ? numericalMatch : 0;
                });

            return orderedEnumerable;
        }

        private IEnumerable<DirectoryInfo> GetMatchingSubDirectories(DirectoryInfo currentDirectoryInfo, string[] filters, string mediaType, int skip, int take)
        {
            IEnumerable<DirectoryInfo> subDirectories =
                currentDirectoryInfo.EnumerateDirectories("*", SearchOption.TopDirectoryOnly);

            if (filters.Any() != true)
            {
                subDirectories = subDirectories.Skip(skip);

                // Keep the filtering consistent with SQLite
                if (take != -1)
                {
                    subDirectories = subDirectories.Take(take);
                }

                return subDirectories;
            }

            string cdnDiskLocation = ConfigurationManager.AppSettings["BaseDirectory"];

            IEnumerable<TagLink> directoryTagLinks = subDirectories.SelectMany(subDirectory =>
                TagLink.GetByDirectory(subDirectory.FullName.Substring(cdnDiskLocation.Length + 1), filters, skip, take, true)
            );

            IEnumerable<FileInfo> matchingSubFiles = directoryTagLinks
                .Select(directoryTagLink => new FileInfo($"{cdnDiskLocation}\\{directoryTagLink.FilePath}"));

            IEnumerable<DirectoryInfo> matchingSubDirectories = matchingSubFiles
                .Select(subFile => subFile.Directory)
                .DistinctBy(subFile => subFile.Parent.FullName);

            return matchingSubDirectories;
        }

        private IEnumerable<FileInfo> GetMatchingSubFiles(DirectoryInfo currentDirectoryInfo, string[] filters, string mediaType, int skip, int take, bool includeSubDirectories)
        {
            if (filters.Any() != true)
            {
                IEnumerable<FileInfo> subFiles =
                    GetDirectoryFilesByMediaType(currentDirectoryInfo, mediaType, includeSubDirectories);

                subFiles = subFiles.Skip(skip);

                if (take != -1)
                {
                    subFiles = subFiles.Take(take);
                }

                return subFiles;
            }

            // If we're filtering the files, any files without a tag will be skipped anyway. We may as well go solely off the tag links
            string cdnDiskLocation = ConfigurationManager.AppSettings["BaseDirectory"];
            string directoryPath = currentDirectoryInfo.FullName.Substring(cdnDiskLocation.Length + 1);
            IEnumerable<TagLink> directoryTagLinks = TagLink.GetByDirectory(directoryPath, filters, skip, take);
            IEnumerable<FileInfo> filesFromTagLinks =
                directoryTagLinks.Select(tagLink => new FileInfo($"{cdnDiskLocation}\\{tagLink.FilePath}"));

            return filesFromTagLinks;
        }


        private DirectoryInfo GetCurrentDirectory(string relativePath, string mediaType)
        {
            DirectoryInfo hierarchicalRootInfo = GetHierarchicalRootInfo(mediaType);
            string currentDirectoryPath = Path.Combine(hierarchicalRootInfo.FullName, relativePath);
            DirectoryInfo currentDirectoryInfo = new DirectoryInfo(currentDirectoryPath);

            return currentDirectoryInfo;
        }

        private DirectoryInfo GetHierarchicalRootInfo(string mediaType)
        {
            string hierarchyRoot = GetHierarchyRoot(mediaType);
            string websiteDiskLocation = ConfigurationManager.AppSettings["BaseDirectory"];
            string hierarchyRootDiskLocation = Path.Combine(websiteDiskLocation, hierarchyRoot);
            DirectoryInfo hierarchicalRootInfo = new DirectoryInfo(hierarchyRootDiskLocation);

            return hierarchicalRootInfo;
        }

        private DirectoryHierarchyViewModel GetDirectoryAsHierarchy(DirectoryInfo directoryInfo,
            DirectoryInfo hierarchyRootInfo)
        {
            DirectoryHierarchyViewModel directoryWithParents = new DirectoryHierarchyViewModel
            {
                Name = directoryInfo.Name,
                ContentUrl = GetUrl(directoryInfo),
                Path = GetUrl(directoryInfo).Substring(hierarchyRootInfo.Name.Length).TrimStart('/')
            };

            if (directoryInfo.Parent != null && directoryInfo.Parent.FullName != hierarchyRootInfo.Parent?.FullName)
            {
                directoryWithParents.Parent = GetDirectoryAsHierarchy(directoryInfo.Parent, hierarchyRootInfo);
            }

            return directoryWithParents;
        }

        private string GetUrl(FileSystemInfo fileSystemInfo)
        {
            string rawUrl =
                fileSystemInfo.FullName.Substring(ConfigurationManager.AppSettings["BaseDirectory"].Length + 1);
            string encodedUrl = rawUrl
                .Replace("'", "%27")
                .Replace("\\", "/")
                .Replace("#", "%23");

            return encodedUrl;
        }

        private string GetHierarchyRoot(string mediaType)
        {
            mediaType = mediaType.ToLowerInvariant();

            switch (mediaType)
            {
                case "image":
                    return ConfigurationManager.AppSettings["ImagesPath"];
                case "video":
                    return ConfigurationManager.AppSettings["VideosPath"];
                default:
                    Response.StatusCode = 422;
                    throw new InvalidOperationException("Media type is not supported");
            }
        }
    }
}<file_sep>/ContentExplorer/Models/ViewModels/PaginatedViewModel.cs
﻿using System.Collections.Generic;

namespace ContentExplorer.Models.ViewModels
{
    public class PaginatedViewModel<TPaginatedItems>
    {
        public int Total { get; set; }
        public ICollection<TPaginatedItems> CurrentPage { get; set; }
    }
}<file_sep>/ContentExplorer/Services/FileTypeService.cs
﻿using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Web;
using System.Web.Mvc;

namespace ContentExplorer.Services
{
    public class FileTypeService
    {
        public bool IsFileVideo(string fileName)
        {
            string fileExtension = fileName.Split('.').LastOrDefault();
            bool isVideo = VideoExtensions.Any(validExtension => validExtension == fileExtension);

            return isVideo;
        }

        public bool IsFileImage(string fileName)
        {
            string fileExtension = fileName.Split('.').LastOrDefault();

            bool isImage = ImageExtensions.Any(validExtension =>
                validExtension == fileExtension ||
                fileName.EndsWith($".thumbnail.{validExtension}")
            );

            return isImage;
        }

        private static readonly string[] VideoExtensions =
        {
            "mp4",
            "mpeg",
            "webm",
            "mkv",
            "avi",
            "mov",
            "m4v"
        };

        private static readonly string[] ImageExtensions =
        {
            "jpg",
            "jpeg",
            "gif",
            "png",
            "tga",
            "bmp"
        };
    }
}<file_sep>/ContentExplorer/Services/ImageThumbnailService.cs
﻿using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.IO;
using System.Linq;

namespace ContentExplorer.Services
{
    public class ImageThumbnailService : IThumbnailService
    {
        private const int MaximumThumbnailSize = 400;

        public void CreateThumbnail(FileInfo fileInfo)
        {
            FileInfo fileThumbnail = GetFileThumbnail(fileInfo);
            if (fileThumbnail.Exists)
            {
                return;
            }

            using (Image imageOnDisk = Image.FromFile(fileInfo.FullName))
            {
                Size thumbnailSize = GetThumbnailDimensions(imageOnDisk.Width, imageOnDisk.Height);
                using (Image thumbnailImage = new Bitmap(thumbnailSize.Width, thumbnailSize.Height))
                {
                    using (Graphics thumbnailGraphics = Graphics.FromImage(thumbnailImage))
                    {
                        thumbnailGraphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
                        thumbnailGraphics.SmoothingMode = SmoothingMode.HighQuality;
                        thumbnailGraphics.CompositingQuality = CompositingQuality.HighQuality;
                        thumbnailGraphics.PixelOffsetMode = PixelOffsetMode.HighQuality;

                        using (ImageAttributes imageAttributes = new ImageAttributes())
                        {
                            imageAttributes.SetWrapMode(WrapMode.TileFlipXY);
                            Rectangle newBounds = new Rectangle(new Point(0, 0), thumbnailSize);
                            thumbnailGraphics.DrawImage(
                                imageOnDisk,
                                newBounds,
                                0,
                                0,
                                imageOnDisk.Width,
                                imageOnDisk.Height,
                                GraphicsUnit.Pixel,
                                imageAttributes
                            );

                            EncoderParameters encoderParameters = new EncoderParameters(1);
                            encoderParameters.Param[0] = new EncoderParameter(Encoder.Quality, 90L);
                            ImageCodecInfo imageCodecInfo = GetImageCodeInfo("image/jpeg");

                            thumbnailImage.Save(fileThumbnail.FullName, imageCodecInfo, encoderParameters);
                        }
                    }
                }
            }
        }

        public void DeleteThumbnailIfExists(FileInfo fileInfo)
        {
            FileInfo thumbnail = GetFileThumbnail(fileInfo);

            if (thumbnail.Exists)
            {
                thumbnail.Delete();
            }
        }

        public FileInfo GetDirectoryThumbnail(DirectoryInfo directory)
        {
            FileTypeService fileTypeService = new FileTypeService();

            FileInfo previewImage = directory
                .EnumerateFiles()
                .FirstOrDefault(file => fileTypeService.IsFileImage(file.Name));

            if (previewImage != null)
            {
                return previewImage;
            }

            IEnumerable<DirectoryInfo> subDirectories =
                directory.EnumerateDirectories("*", SearchOption.AllDirectories);

            FileInfo firstPreview = subDirectories
                .Select(GetDirectoryThumbnail)
                .FirstOrDefault(thumbnail => thumbnail != null);

            return firstPreview;
        }

        public FileInfo GetFileThumbnail(FileInfo fileInfo)
        {
            string thumbnailPath = $"{fileInfo.FullName}.thumb.jpg";
            FileInfo fileThumbnail = new FileInfo(thumbnailPath);

            return fileThumbnail;
        }

        public bool IsThumbnail(FileInfo fileInfo)
        {
            bool isThumbnail = fileInfo.Name.EndsWith(".thumb.jpg");
            return isThumbnail;
        }

        private Size GetThumbnailDimensions(int originalWidth, int originalHeight)
        {
            int newWidth;
            int newHeight;

            if (originalWidth > originalHeight)
            {
                newWidth = MaximumThumbnailSize;
                newHeight = Convert.ToInt32((double)originalHeight / originalWidth * MaximumThumbnailSize);
            }
            else
            {
                newHeight = MaximumThumbnailSize;
                newWidth = Convert.ToInt32((double)originalWidth / originalHeight * MaximumThumbnailSize);
            }

            return new Size(newWidth, newHeight);
        }

        private ImageCodecInfo GetImageCodeInfo(string mimeType)
        {
            ImageCodecInfo[] imageEncoders = ImageCodecInfo.GetImageEncoders();
            ImageCodecInfo matchingCodecInfo = imageEncoders.FirstOrDefault(potentialCodec =>

                potentialCodec.MimeType.Equals(mimeType, StringComparison.OrdinalIgnoreCase)
            );

            return matchingCodecInfo;
        }
    }
}<file_sep>/ContentExplorer/Models/SqliteWrapper.cs
﻿using System;
using System.Collections.Generic;
using System.Configuration;
using System.Data;
using System.Data.Common;
using System.Linq;
using Microsoft.Data.Sqlite;

namespace ContentExplorer.Models
{
    public class SqliteWrapper : IDisposable
    {
        public SqliteWrapper(string connectionString)
        {
            try
            {
                SqlConnection = GenerateConnection(connectionString);
            }
            catch (ArgumentException)
            {
                // If we've passed the name of a connection string instead, try to get the value instead
                connectionString = GetConnectionStringFromName(connectionString);
                SqlConnection = GenerateConnection(connectionString);
            }
        }

        private string GetConnectionStringFromName(string connectionStringName)
        {
            ConnectionStringSettings connectionStringSettings = ConfigurationManager.ConnectionStrings[connectionStringName];
            string connectionString = connectionStringSettings.ToString();

            return connectionString;
        }


        private SqliteConnection SqlConnection { get; }

        public static SqliteParameter GenerateParameter(string name, object value)
        {
            SqliteParameter parameter = new SqliteParameter(name, value);

            return parameter;
        }

        public void Dispose()
        {
            SqlConnection?.Dispose();
        }

        public bool ExecuteNonQuery(string query)
        {
            return ExecuteNonQuery(query, parameters: null);
        }

        public bool ExecuteNonQuery(string query, SqliteParameter parameter)
        {
            return ExecuteNonQuery(query, new SqliteParameter[] { parameter });
        }

        public bool ExecuteNonQuery(string query, IEnumerable<SqliteParameter> parameters)
        {
            SqliteCommand command = GenerateCommand(query, parameters);
            int updatedRecordCount = command.ExecuteNonQuery();

            bool isSuccess = updatedRecordCount != 0;

            return isSuccess;
        }

        public ICollection<IDictionary<string, object>> GetDataRows(string query)
        {
            return GetDataRows(query, new SqliteParameter[0]);
        }

        public ICollection<IDictionary<string, object>> GetDataRows(string query, SqliteParameter parameter)
        {
            return GetDataRows(query, new SqliteParameter[] { parameter });
        }

        public ICollection<IDictionary<string, object>> GetDataRows(string query, IEnumerable<SqliteParameter> parameters)
        {
            SqliteCommand command = GenerateCommand(query, parameters);
            SqliteDataReader dataReader = command.ExecuteReader();

            ICollection<IDictionary<string, object>> dataRows = GetDataRows(dataReader);
            return dataRows;
        }

        public IDictionary<string, object> GetDataRow(string query)
        {
            return GetDataRow(query, new SqliteParameter[0]);
        }

        public IDictionary<string, object> GetDataRow(string query, SqliteParameter parameter)
        {
            return GetDataRow(query, new SqliteParameter[] { parameter });
        }

        public IDictionary<string, object> GetDataRow(string query, IEnumerable<SqliteParameter> parameters)
        {
            SqliteCommand command = GenerateCommand(query, parameters);
            SqliteDataReader dataReader = command.ExecuteReader(CommandBehavior.SingleRow);

            IDictionary<string, object> dataRow = GetDataRows(dataReader).FirstOrDefault();
            return dataRow;
        }

        public object GetScalar(string query)
        {
            return GetScalar(query, new SqliteParameter[0]);
        }

        public object GetScalar(string query, SqliteParameter parameter)
        {
            return GetScalar(query, new SqliteParameter[] { parameter });
        }

        public object GetScalar(string query, IEnumerable<SqliteParameter> parameters)
        {
            SqliteCommand command = GenerateCommand(query, parameters);

            object scalar = command.ExecuteScalar();

            return scalar;
        }

        /// <summary>
        /// Creates a new SQLite connection
        /// </summary>
        /// <param name="connectionString">A string which maps to a configured connection string</param>
        /// <exception cref="SqliteException">An exception occured while opening the database</exception>
        /// <returns></returns>
        private static SqliteConnection GenerateConnection(string connectionString)
        {
            SqliteConnection connection = new SqliteConnection(connectionString);

            connection.Open();

            return connection;
        }

        private ICollection<IDictionary<string, object>> GetDataRows(SqliteDataReader dataReader)
        {
            DataTable table = dataReader.GetSchemaTable();
            ICollection<string> columnNames = Enumerable.Range(0, dataReader.FieldCount)
                .Select(dataReader.GetName)
                .ToArray();

            ICollection<IDictionary<string, object>> dataRows = new List<IDictionary<string, object>>();
            while (dataReader.Read())
            {
                Dictionary<string, object> dataRow = columnNames
                    .Select(columnName =>
                        new KeyValuePair<string, object>(columnName, dataReader[columnName])
                    )
                    .ToDictionary(keyPair => keyPair.Key, keyPair => keyPair.Value);

                dataRows.Add(dataRow);
            }

            return dataRows;
        }

        private SqliteCommand GenerateCommand(string query, IEnumerable<SqliteParameter> parameters)
        {
            SqliteCommand command = new SqliteCommand(query, SqlConnection)
            {
                CommandTimeout = 120
            };

            if (parameters != null)
            {
                parameters = parameters.Where(parameter => parameter?.Value != null);

                command.Parameters.AddRange(parameters);
            }

            return command;
        }
    }
}<file_sep>/ContentExplorer/Models/ViewModels/DirectoryHeirarchyViewModel.cs
﻿namespace ContentExplorer.Models.ViewModels
{
    public class DirectoryHierarchyViewModel
    {
        public string Name { get; set; }
        public string ContentUrl { get; set; }
        public string Path { get; set; }
        public DirectoryHierarchyViewModel Parent { get; set; }
    }
}<file_sep>/ContentExplorer/Core/Scripts/View/VideoView.js
/// <reference path="./MediaView.js"/>

function VideoView() {

    MediaView.call(this, "video", "Video");

    this.$videoContainer = $("[data-video-container]");

    this.$video = null;

    this.$mediaSource = null;

    this.extensionsToMimeTypes = {
        "mp4": "video/mp4",
        "ogg": "video/ogg"
    };
}

VideoView.prototype = Object.create(MediaView.prototype);

VideoView.prototype.refreshMediaDisplay = function (subFilePreview, previousMediaInformation) {

    // Make current page navigatable
    this.$pageButtons.find(".button_medialink-disabled")
        .removeClass("button_medialink-disabled")
        .addClass("button_medialink")
        .attr("href", "/Video/View?path=" + this.relativeDirectory + "&page=" + previousMediaInformation.pageId + "&filter=" + this.filter);

    MediaView.prototype.refreshMediaDisplay.call(this, subFilePreview, previousMediaInformation);

    if (this.$video === null) {

        this.$video = $("<video>")
            .attr({
                "controls": "controls",
                "loop": "loop",
                "data-video": "data-video"
            })
            .addClass("mediaView_media");
    }

    if (this.$mediaSource == null) {

        this.$mediaSource = $("<source>");
    }

    this.$mediaSource.attr("src", subFilePreview.ContentUrl);

    var contentUrlParts = subFilePreview.ContentUrl.split(".");
    var extension = contentUrlParts[contentUrlParts.length - 1];
    var detectedMimeType = this.extensionsToMimeTypes[extension];
    if (typeof (detectedMimeType) !== "undefined") {

        this.$mediaSource.attr("type", detectedMimeType);
    }

    if (this.$videoContainer.find("[data-video]").length === 0) {

        this.$videoContainer.append(this.$video);
    }

    if (this.$video.find("[data-media-source]").length === 0) {

        this.$video.append(this.$mediaSource);
    }
}

/**
 * Ensures any thumbnail urls passed to page buttons have the correct formatting
 * @param {string} thumbnailUrl
 */
VideoView.prototype.formatThumbnailUrl = function (thumbnailUrl) {

    return thumbnailUrl;
}

$(function () {

    var mediaView = new VideoView();
    mediaView.initialiseAsync();
})<file_sep>/ContentExplorer/Core/Scripts/View/ImageView.js
/// <reference path="./MediaView.js"/>

function ImageView() {

    MediaView.call(this, "image", "Image");
}

ImageView.prototype = Object.create(MediaView.prototype);

ImageView.prototype.refreshMediaDisplay = function (subFilePreview, previousMediaInformation) {

    // Make current page navigatable
    this.$pageButtons.find(".button_medialink-disabled")
        .removeClass("button_medialink-disabled")
        .addClass("button_medialink")
        .attr("href", "/Image/View?path=" + this.relativeDirectory + "&page=" + previousMediaInformation.pageId + "&filter=" + this.filter);

    MediaView.prototype.refreshMediaDisplay.call(this, subFilePreview, previousMediaInformation);

    this.$media.attr("src", subFilePreview.ContentUrl).attr("alt", subFilePreview.Name);
    this.$mediaLink.attr("href", subFilePreview.ContentUrl);

    if (!this.isSlideshowEnabled) {

        this.unsetFocusedMediaDimensions();
    }
    else {

        this.setFocusedMediaDimensions();
    }
}

$(function () {

    var mediaView = new ImageView();
    mediaView.initialiseAsync();
})<file_sep>/ContentExplorer/Services/FileSystemFilteringService.cs
﻿using ContentExplorer.Models;
using System;
using System.Collections.Generic;
using System.Configuration;
using System.IO;
using System.Linq;
using System.Web;

namespace ContentExplorer.Services
{
    public class FileSystemFilteringService : IFileSystemFilteringService
    {
        public bool FileMatchesFilter(FileInfo fileInfo, string[] filters)
        {
            if (filters.Any() != true)
            {
                return true;
            }

            string websiteDiskLocation = ConfigurationManager.AppSettings["BaseDirectory"];
            string filePath = fileInfo.FullName.Substring(websiteDiskLocation.Length + 1);

            bool isMatch = true;

            // Make sure the file matches all of our filters
            for (int filterIndex = 0; filterIndex < filters.Length && isMatch; filterIndex++)
            {
                string filter = filters[filterIndex];

                IEnumerable<Tag> tagsForFile = Tag.GetByFile(filePath, filters);
                isMatch = tagsForFile.Any(tag =>
                    tag.TagName.Equals(filter, StringComparison.OrdinalIgnoreCase)
                );
            }

            return isMatch;
        }

        public bool TagsMatchFilter(IEnumerable<Tag> tags, IEnumerable<string> filters)
        {
            bool tagsMatchFilter = filters.All(filter =>
                tags.Any(tag =>
                    filter.Equals(tag.TagName, StringComparison.OrdinalIgnoreCase)
                )
            );

            return tagsMatchFilter;
        }

        public bool TagLinkMatchesFilter(IEnumerable<TagLink> tagLinks, string[] filters)
        {
            if (filters.Any() != true)
            {
                return true;
            }

            string[] tagsForFile = tagLinks
                .GroupBy(tagLink => tagLink.GetTag().TagName)
                .Select(tagGrouping => tagGrouping.Key)
                .ToArray();

            if (tagsForFile.Any() != true)
            {
                return false;
            }

            // Make sure the file matches all of our filters
            bool isMatch = true;
            for (int filterIndex = 0; filterIndex < filters.Length && isMatch; filterIndex++)
            {
                string filter = filters[filterIndex];

                isMatch = tagsForFile.Any(tagName =>
                    tagName.Equals(filter, StringComparison.OrdinalIgnoreCase)
                );
            }

            return isMatch;
        }

        public bool DirectoryMatchesFilter(DirectoryInfo directoryInfo, string[] filters, string mediaType)
        {
            string cdnDiskLocation = ConfigurationManager.AppSettings["BaseDirectory"];
            string directoryPath = directoryInfo.FullName.Substring(cdnDiskLocation.Length + 1);
            ICollection<Tag> directoryTags = Tag.GetByDirectory(directoryPath, filters, true);
            bool directoryContainsMatchingFile = directoryTags.Any();

            return directoryContainsMatchingFile;
        }
    }
}<file_sep>/ContentExplorer.Tests/API/Models/TagTest.cs
﻿using System.Collections.Generic;
using ContentExplorer.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace ContentExplorer.Tests.API.Models
{
    [TestClass]
    public class TagTest : Test
    {
        [TestMethod]
        public void CreateTag()
        {
            Tag tag = CreateAndReturnTag();

            using (SqliteWrapper sqliteWrapper = new SqliteWrapper("AppDb"))
            {
                IDictionary<string, object> dataRow = sqliteWrapper.GetDataRow(
                    "SELECT * FROM Tags WHERE TagId = @TagId",
                    SqliteWrapper.GenerateParameter("@TagId", tag.TagId)
                );

                Assert.IsNotNull(dataRow);
            }
        }

        [TestMethod]
        public void DeleteTag()
        {
            Tag tag = CreateAndReturnTag();
            bool isSuccess = tag.Delete();
            Assert.IsTrue(isSuccess, "Tag was not successfully deleted");

            using (SqliteWrapper sqliteWrapper = new SqliteWrapper("AppDb"))
            {
                IDictionary<string, object> dataRow = sqliteWrapper.GetDataRow(
                    "SELECT * FROM Tags WHERE TagId = @TagId",
                    SqliteWrapper.GenerateParameter("@TagId", tag.TagId)
                );

                Assert.IsNull(dataRow);
            }
        }

        [TestMethod]
        public void GetTagByName()
        {
            Tag tag = CreateAndReturnTag();
            Tag foundTag = Tag.GetByTagName(tag.TagName);

            Assert.IsNotNull(foundTag);
            Assert.AreEqual(tag.TagId, foundTag.TagId);
            Assert.AreEqual(tag.TagName, foundTag.TagName);
        }

        [TestMethod]
        public void GetByFile()
        {
            TagLink tagLink = CreateAndReturnTagLink();
            ICollection<Tag> tags = Tag.GetByFile(tagLink.FilePath);

            Assert.IsNotNull(tags, "Returned tag collection was null");
            Assert.AreNotEqual(0, tags.Count, "No matching tags were found");
        }

        [TestMethod]
        public void GetByFileAndFilters()
        {
            TagLink tagLink = CreateAndReturnTagLink();
            ICollection<Tag> tags = Tag.GetByFile(tagLink.FilePath, new []{ DefaultTestTagName });

            Assert.IsNotNull(tags, "Returned tag collection was null");
            Assert.AreNotEqual(0, tags.Count, "No matching tags were found");
        }

        [TestMethod]
        public void GetAll()
        {
            Tag tag = CreateAndReturnTag();
            ICollection<Tag> tags = Tag.GetAll();

            Assert.IsNotNull(tags, "Returned tag collection was null");
            Assert.AreNotEqual(0, tags.Count, "No matching tags were found");
        }
    }
}<file_sep>/ContentExplorer/Controllers/VideoController.cs
﻿using System;
using System.Collections.Generic;
using System.Configuration;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Web.Mvc;
using ContentExplorer.Models;
using ContentExplorer.Services;

namespace ContentExplorer.Controllers
{
    public class VideoController : Controller
    {
        [HttpGet]
        public ActionResult Index(string path = "", int? page = 1, string filter = null)
        {
            if (page == null || page < 1)
            {
                page = 1;
            }

            if (path == null)
            {
                path = "";
            }
            else
            {
                // Remove any dangerous characters to prevent escaping the current context
                path = path.Trim("/\\.".ToCharArray());
            }

            if (filter == null)
            {
                filter = "";
            }

            DirectoryInfo directoryInfo = GetCurrentDirectory(path);

            if (directoryInfo.Exists == false)
            {
                return RedirectToAction("Index", new { page, filter });
            }

            ViewBag.FilesPerPage = 50;
            ViewBag.Directory = directoryInfo;
            ViewBag.Page = page;
            ViewBag.Filter = filter;

            return View();
        }

        [HttpGet]
        public ViewResult View(string path, int page, string filter = "")
        {
            if (page < 1)
            {
                page = 1;
            }

            if (filter == null)
            {
                filter = "";
            }
            ViewBag.Path = path;
            ViewBag.Id = page;
            ViewBag.Filter = filter;

            // Zero-based index
            int pageIndex = page - 1;

            int startingPreview = pageIndex - 7 < 1 ? 1 : page - 7;
            ViewBag.StartingPreview = startingPreview;

            return View();
        }

        [HttpGet]
        public ActionResult RebuildThumbnails(string path = "")
        {
            IThumbnailService videoThumbnailService = new VideoThumbnailService();

            DirectoryInfo baseDirectory = GetCurrentDirectory(path);
            IEnumerable<FileInfo> videos = baseDirectory.EnumerateFiles("*", SearchOption.AllDirectories)
                .Where(image => videoThumbnailService.IsThumbnail(image) != true);

            foreach (FileInfo video in videos)
            {
                videoThumbnailService.DeleteThumbnailIfExists(video);
                videoThumbnailService.CreateThumbnail(video);
            }

            return Json(true, JsonRequestBehavior.AllowGet);
        }

        [HttpGet]
        public ActionResult ReformatNames(string path = "")
        {
            DirectoryInfo baseDirectory = GetCurrentDirectory(path);

            ReformatNames(baseDirectory);

            return Json(true, JsonRequestBehavior.AllowGet);
        }

        [HttpGet]
        public ActionResult ConvertUnplayableVideos(string path = "")
        {
            DirectoryInfo baseDirectory = GetCurrentDirectory(path);
            VideoConversionService videoConversionService = new VideoConversionService();

            videoConversionService.ConvertUnplayableVideos(baseDirectory);

            return Json(true, JsonRequestBehavior.AllowGet);
        }

        private ActionResult ReformatNames(DirectoryInfo directory)
        {
            IEnumerable<DirectoryInfo> subDirectories = directory.GetDirectories();

            foreach (DirectoryInfo subDirectory in subDirectories)
            {
                ReformatNames(subDirectory);
            }

            NameFormatService nameFormatService = new NameFormatService();
            IEnumerable<FileInfo> videos = directory.EnumerateFiles("*", SearchOption.AllDirectories);
            foreach (FileInfo video in videos)
            {
                nameFormatService.FormatFileName(video);
            }

            return Json(true, JsonRequestBehavior.AllowGet);
        }

        private DirectoryInfo GetCurrentDirectory(string relativePath)
        {
            DirectoryInfo directoryInfo = new DirectoryInfo(Path.Combine(ConfigurationManager.AppSettings["BaseDirectory"], ConfigurationManager.AppSettings["VideosPath"], relativePath));

            return directoryInfo;
        }
    }
}