Quellcode durchsuchen

Merge pull request #2601 from mark-monteiro/support-running-without-web-content

Support Running Server Without Web Content
Vasily vor 5 Jahren
Ursprung
Commit
07ea120ba9

+ 2 - 5
Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs

@@ -5,7 +5,7 @@ using MediaBrowser.Common.Configuration;
 namespace Emby.Server.Implementations.AppBase
 {
     /// <summary>
-    /// Provides a base class to hold common application paths used by both the Ui and Server.
+    /// Provides a base class to hold common application paths used by both the UI and Server.
     /// This can be subclassed to add application-specific paths.
     /// </summary>
     public abstract class BaseApplicationPaths : IApplicationPaths
@@ -37,10 +37,7 @@ namespace Emby.Server.Implementations.AppBase
         /// <value>The program data path.</value>
         public string ProgramDataPath { get; }
 
-        /// <summary>
-        /// Gets the path to the web UI resources folder.
-        /// </summary>
-        /// <value>The web UI resources path.</value>
+        /// <inheritdoc/>
         public string WebPath { get; }
 
         /// <summary>

+ 13 - 30
Emby.Server.Implementations/ApplicationHost.cs

@@ -235,11 +235,6 @@ namespace Emby.Server.Implementations
         /// </summary>
         public int HttpsPort { get; private set; }
 
-        /// <summary>
-        /// Gets the content root for the webhost.
-        /// </summary>
-        public string ContentRoot { get; private set; }
-
         /// <summary>
         /// Gets the server configuration manager.
         /// </summary>
@@ -612,13 +607,7 @@ namespace Emby.Server.Implementations
 
             DiscoverTypes();
 
-            await RegisterResources(serviceCollection, startupConfig).ConfigureAwait(false);
-
-            ContentRoot = ServerConfigurationManager.Configuration.DashboardSourcePath;
-            if (string.IsNullOrEmpty(ContentRoot))
-            {
-                ContentRoot = ServerConfigurationManager.ApplicationPaths.WebPath;
-            }
+            await RegisterServices(serviceCollection, startupConfig).ConfigureAwait(false);
         }
 
         public async Task ExecuteWebsocketHandlerAsync(HttpContext context, Func<Task> next)
@@ -649,9 +638,9 @@ namespace Emby.Server.Implementations
         }
 
         /// <summary>
-        /// Registers resources that classes will depend on
+        /// Registers services/resources with the service collection that will be available via DI.
         /// </summary>
-        protected async Task RegisterResources(IServiceCollection serviceCollection, IConfiguration startupConfig)
+        protected async Task RegisterServices(IServiceCollection serviceCollection, IConfiguration startupConfig)
         {
             serviceCollection.AddMemoryCache();
 
@@ -769,20 +758,8 @@ namespace Emby.Server.Implementations
             CertificateInfo = GetCertificateInfo(true);
             Certificate = GetCertificate(CertificateInfo);
 
-            HttpServer = new HttpListenerHost(
-                this,
-                LoggerFactory.CreateLogger<HttpListenerHost>(),
-                ServerConfigurationManager,
-                startupConfig,
-                NetworkManager,
-                JsonSerializer,
-                XmlSerializer,
-                CreateHttpListener())
-            {
-                GlobalResponse = LocalizationManager.GetLocalizedString("StartupEmbyServerIsLoading")
-            };
-
-            serviceCollection.AddSingleton(HttpServer);
+            serviceCollection.AddSingleton<IHttpListener, WebSocketSharpListener>();
+            serviceCollection.AddSingleton<IHttpServer, HttpListenerHost>();
 
             ImageProcessor = new ImageProcessor(LoggerFactory.CreateLogger<ImageProcessor>(), ServerConfigurationManager.ApplicationPaths, FileSystemManager, ImageEncoder, () => LibraryManager, () => MediaEncoder);
             serviceCollection.AddSingleton(ImageProcessor);
@@ -895,6 +872,14 @@ namespace Emby.Server.Implementations
             ((LibraryManager)LibraryManager).ItemRepository = ItemRepository;
         }
 
+        /// <summary>
+        /// Create services registered with the service container that need to be initialized at application startup.
+        /// </summary>
+        public void InitializeServices()
+        {
+            HttpServer = Resolve<IHttpServer>();
+        }
+
         public static void LogEnvironmentInfo(ILogger logger, IApplicationPaths appPaths)
         {
             // Distinct these to prevent users from reporting problems that aren't actually problems
@@ -1212,8 +1197,6 @@ namespace Emby.Server.Implementations
             });
         }
 
-        protected IHttpListener CreateHttpListener() => new WebSocketSharpListener(LoggerFactory.CreateLogger<WebSocketSharpListener>());
-
         private CertificateInfo GetCertificateInfo(bool generateCertificate)
         {
             // Custom cert

+ 16 - 19
Emby.Server.Implementations/Browser/BrowserLauncher.cs

@@ -1,51 +1,48 @@
 using System;
 using MediaBrowser.Controller;
+using Microsoft.Extensions.Logging;
 
 namespace Emby.Server.Implementations.Browser
 {
     /// <summary>
-    /// Class BrowserLauncher.
+    /// Assists in opening application URLs in an external browser.
     /// </summary>
     public static class BrowserLauncher
     {
         /// <summary>
-        /// Opens the dashboard page.
+        /// Opens the home page of the web client.
         /// </summary>
-        /// <param name="page">The page.</param>
         /// <param name="appHost">The app host.</param>
-        private static void OpenDashboardPage(string page, IServerApplicationHost appHost)
+        public static void OpenWebApp(IServerApplicationHost appHost)
         {
-            var url = appHost.GetLocalApiUrl("localhost") + "/web/" + page;
-
-            OpenUrl(appHost, url);
+            TryOpenUrl(appHost, "/web/index.html");
         }
 
         /// <summary>
-        /// Opens the web client.
+        /// Opens the swagger API page.
         /// </summary>
         /// <param name="appHost">The app host.</param>
-        public static void OpenWebApp(IServerApplicationHost appHost)
+        public static void OpenSwaggerPage(IServerApplicationHost appHost)
         {
-            OpenDashboardPage("index.html", appHost);
+            TryOpenUrl(appHost, "/swagger/index.html");
         }
 
         /// <summary>
-        /// Opens the URL.
+        /// Opens the specified URL in an external browser window. Any exceptions will be logged, but ignored.
         /// </summary>
-        /// <param name="appHost">The application host instance.</param>
+        /// <param name="appHost">The application host.</param>
         /// <param name="url">The URL.</param>
-        private static void OpenUrl(IServerApplicationHost appHost, string url)
+        private static void TryOpenUrl(IServerApplicationHost appHost, string url)
         {
             try
             {
-                appHost.LaunchUrl(url);
-            }
-            catch (NotSupportedException)
-            {
-
+                string baseUrl = appHost.GetLocalApiUrl("localhost");
+                appHost.LaunchUrl(baseUrl + url);
             }
-            catch (Exception)
+            catch (Exception ex)
             {
+                var logger = appHost.Resolve<ILogger>();
+                logger?.LogError(ex, "Failed to open browser window with URL {URL}", url);
             }
         }
     }

+ 11 - 2
Emby.Server.Implementations/ConfigurationOptions.cs

@@ -1,13 +1,22 @@
 using System.Collections.Generic;
+using Emby.Server.Implementations.HttpServer;
+using MediaBrowser.Providers.Music;
 using static MediaBrowser.Controller.Extensions.ConfigurationExtensions;
 
 namespace Emby.Server.Implementations
 {
+    /// <summary>
+    /// Static class containing the default configuration options for the web server.
+    /// </summary>
     public static class ConfigurationOptions
     {
-        public static Dictionary<string, string> Configuration => new Dictionary<string, string>
+        /// <summary>
+        /// Gets a new copy of the default configuration options.
+        /// </summary>
+        public static Dictionary<string, string> DefaultConfiguration => new Dictionary<string, string>
         {
-            { "HttpListenerHost:DefaultRedirectPath", "web/index.html" },
+            { HostWebClientKey, bool.TrueString },
+            { HttpListenerHost.DefaultRedirectKey, "web/index.html" },
             { FfmpegProbeSizeKey, "1G" },
             { FfmpegAnalyzeDurationKey, "200M" },
             { PlaylistsAllowDuplicatesKey, bool.TrueString }

+ 10 - 5
Emby.Server.Implementations/EntryPoints/StartupWizard.cs

@@ -2,7 +2,9 @@ using System.Threading.Tasks;
 using Emby.Server.Implementations.Browser;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Extensions;
 using MediaBrowser.Controller.Plugins;
+using Microsoft.Extensions.Configuration;
 
 namespace Emby.Server.Implementations.EntryPoints
 {
@@ -11,10 +13,8 @@ namespace Emby.Server.Implementations.EntryPoints
     /// </summary>
     public sealed class StartupWizard : IServerEntryPoint
     {
-        /// <summary>
-        /// The app host.
-        /// </summary>
         private readonly IServerApplicationHost _appHost;
+        private readonly IConfiguration _appConfig;
         private readonly IServerConfigurationManager _config;
 
         /// <summary>
@@ -22,9 +22,10 @@ namespace Emby.Server.Implementations.EntryPoints
         /// </summary>
         /// <param name="appHost">The application host.</param>
         /// <param name="config">The configuration manager.</param>
-        public StartupWizard(IServerApplicationHost appHost, IServerConfigurationManager config)
+        public StartupWizard(IServerApplicationHost appHost, IConfiguration appConfig, IServerConfigurationManager config)
         {
             _appHost = appHost;
+            _appConfig = appConfig;
             _config = config;
         }
 
@@ -36,7 +37,11 @@ namespace Emby.Server.Implementations.EntryPoints
                 return Task.CompletedTask;
             }
 
-            if (!_config.Configuration.IsStartupWizardCompleted)
+            if (!_appConfig.HostWebClient())
+            {
+                BrowserLauncher.OpenSwaggerPage(_appHost);
+            }
+            else if (!_config.Configuration.IsStartupWizardCompleted)
             {
                 BrowserLauncher.OpenWebApp(_appHost);
             }

+ 11 - 2
Emby.Server.Implementations/HttpServer/HttpListenerHost.cs

@@ -17,6 +17,7 @@ using MediaBrowser.Controller;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Model.Events;
+using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.Serialization;
 using MediaBrowser.Model.Services;
 using Microsoft.AspNetCore.Http;
@@ -29,6 +30,12 @@ namespace Emby.Server.Implementations.HttpServer
 {
     public class HttpListenerHost : IHttpServer, IDisposable
     {
+        /// <summary>
+        /// The key for a setting that specifies the default redirect path
+        /// to use for requests where the URL base prefix is invalid or missing.
+        /// </summary>
+        public const string DefaultRedirectKey = "HttpListenerHost:DefaultRedirectPath";
+
         private readonly ILogger _logger;
         private readonly IServerConfigurationManager _config;
         private readonly INetworkManager _networkManager;
@@ -52,12 +59,13 @@ namespace Emby.Server.Implementations.HttpServer
             INetworkManager networkManager,
             IJsonSerializer jsonSerializer,
             IXmlSerializer xmlSerializer,
-            IHttpListener socketListener)
+            IHttpListener socketListener,
+            ILocalizationManager localizationManager)
         {
             _appHost = applicationHost;
             _logger = logger;
             _config = config;
-            _defaultRedirectPath = configuration["HttpListenerHost:DefaultRedirectPath"];
+            _defaultRedirectPath = configuration[DefaultRedirectKey];
             _baseUrlPrefix = _config.Configuration.BaseUrl;
             _networkManager = networkManager;
             _jsonSerializer = jsonSerializer;
@@ -69,6 +77,7 @@ namespace Emby.Server.Implementations.HttpServer
 
             Instance = this;
             ResponseFilters = Array.Empty<Action<IRequest, HttpResponse, object>>();
+            GlobalResponse = localizationManager.GetLocalizedString("StartupEmbyServerIsLoading");
         }
 
         public event EventHandler<GenericEventArgs<IWebSocketConnection>> WebSocketConnected;

+ 49 - 15
Jellyfin.Server/Program.cs

@@ -13,12 +13,14 @@ using System.Threading.Tasks;
 using CommandLine;
 using Emby.Drawing;
 using Emby.Server.Implementations;
+using Emby.Server.Implementations.HttpServer;
 using Emby.Server.Implementations.IO;
 using Emby.Server.Implementations.Networking;
 using Jellyfin.Drawing.Skia;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Drawing;
-using MediaBrowser.Model.Globalization;
+using MediaBrowser.Controller.Extensions;
+using MediaBrowser.WebDashboard.Api;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
@@ -112,9 +114,10 @@ namespace Jellyfin.Server
             // $JELLYFIN_LOG_DIR needs to be set for the logger configuration manager
             Environment.SetEnvironmentVariable("JELLYFIN_LOG_DIR", appPaths.LogDirectoryPath);
 
-            // Create an instance of the application configuration to use for application startup
             await InitLoggingConfigFile(appPaths).ConfigureAwait(false);
-            IConfiguration startupConfig = CreateAppConfiguration(appPaths);
+
+            // Create an instance of the application configuration to use for application startup
+            IConfiguration startupConfig = CreateAppConfiguration(options, appPaths);
 
             // Initialize logging framework
             InitializeLoggingFramework(startupConfig, appPaths);
@@ -183,15 +186,31 @@ namespace Jellyfin.Server
                 new ManagedFileSystem(_loggerFactory.CreateLogger<ManagedFileSystem>(), appPaths),
                 GetImageEncoder(appPaths),
                 new NetworkManager(_loggerFactory.CreateLogger<NetworkManager>()));
+
             try
             {
+                // If hosting the web client, validate the client content path
+                if (startupConfig.HostWebClient())
+                {
+                    string webContentPath = DashboardService.GetDashboardUIPath(startupConfig, appHost.ServerConfigurationManager);
+                    if (!Directory.Exists(webContentPath) || Directory.GetFiles(webContentPath).Length == 0)
+                    {
+                        throw new InvalidOperationException(
+                            "The server is expected to host the web client, but the provided content directory is either " +
+                            $"invalid or empty: {webContentPath}. If you do not want to host the web client with the " +
+                            "server, you may set the '--nowebclient' command line flag, or set" +
+                            $"'{MediaBrowser.Controller.Extensions.ConfigurationExtensions.HostWebClientKey}=false' in your config settings.");
+                    }
+                }
+
                 ServiceCollection serviceCollection = new ServiceCollection();
                 await appHost.InitAsync(serviceCollection, startupConfig).ConfigureAwait(false);
 
-                var webHost = CreateWebHostBuilder(appHost, serviceCollection, appPaths).Build();
+                var webHost = CreateWebHostBuilder(appHost, serviceCollection, options, startupConfig, appPaths).Build();
 
-                // A bit hacky to re-use service provider since ASP.NET doesn't allow a custom service collection.
+                // Re-use the web host service provider in the app host since ASP.NET doesn't allow a custom service collection.
                 appHost.ServiceProvider = webHost.Services;
+                appHost.InitializeServices();
                 appHost.FindParts();
                 Migrations.MigrationRunner.Run(appHost, _loggerFactory);
 
@@ -233,7 +252,12 @@ namespace Jellyfin.Server
             }
         }
 
-        private static IWebHostBuilder CreateWebHostBuilder(ApplicationHost appHost, IServiceCollection serviceCollection, IApplicationPaths appPaths)
+        private static IWebHostBuilder CreateWebHostBuilder(
+            ApplicationHost appHost,
+            IServiceCollection serviceCollection,
+            StartupOptions commandLineOpts,
+            IConfiguration startupConfig,
+            IApplicationPaths appPaths)
         {
             return new WebHostBuilder()
                 .UseKestrel(options =>
@@ -273,9 +297,8 @@ namespace Jellyfin.Server
                         }
                     }
                 })
-                .ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(appPaths))
+                .ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(commandLineOpts, appPaths, startupConfig))
                 .UseSerilog()
-                .UseContentRoot(appHost.ContentRoot)
                 .ConfigureServices(services =>
                 {
                     // Merge the external ServiceCollection into ASP.NET DI
@@ -398,9 +421,8 @@ namespace Jellyfin.Server
             // webDir
             // IF      --webdir
             // ELSE IF $JELLYFIN_WEB_DIR
-            // ELSE    use <bindir>/jellyfin-web
+            // ELSE    <bindir>/jellyfin-web
             var webDir = options.WebDir;
-
             if (string.IsNullOrEmpty(webDir))
             {
                 webDir = Environment.GetEnvironmentVariable("JELLYFIN_WEB_DIR");
@@ -471,21 +493,33 @@ namespace Jellyfin.Server
             await resource.CopyToAsync(dst).ConfigureAwait(false);
         }
 
-        private static IConfiguration CreateAppConfiguration(IApplicationPaths appPaths)
+        private static IConfiguration CreateAppConfiguration(StartupOptions commandLineOpts, IApplicationPaths appPaths)
         {
             return new ConfigurationBuilder()
-                .ConfigureAppConfiguration(appPaths)
+                .ConfigureAppConfiguration(commandLineOpts, appPaths)
                 .Build();
         }
 
-        private static IConfigurationBuilder ConfigureAppConfiguration(this IConfigurationBuilder config, IApplicationPaths appPaths)
+        private static IConfigurationBuilder ConfigureAppConfiguration(
+            this IConfigurationBuilder config,
+            StartupOptions commandLineOpts,
+            IApplicationPaths appPaths,
+            IConfiguration? startupConfig = null)
         {
+            // Use the swagger API page as the default redirect path if not hosting the web client
+            var inMemoryDefaultConfig = ConfigurationOptions.DefaultConfiguration;
+            if (startupConfig != null && !startupConfig.HostWebClient())
+            {
+                inMemoryDefaultConfig[HttpListenerHost.DefaultRedirectKey] = "swagger/index.html";
+            }
+
             return config
                 .SetBasePath(appPaths.ConfigurationDirectoryPath)
-                .AddInMemoryCollection(ConfigurationOptions.Configuration)
+                .AddInMemoryCollection(inMemoryDefaultConfig)
                 .AddJsonFile(LoggingConfigFileDefault, optional: false, reloadOnChange: true)
                 .AddJsonFile(LoggingConfigFileSystem, optional: true, reloadOnChange: true)
-                .AddEnvironmentVariables("JELLYFIN_");
+                .AddEnvironmentVariables("JELLYFIN_")
+                .AddInMemoryCollection(commandLineOpts.ConvertToConfig());
         }
 
         /// <summary>

+ 11 - 0
Jellyfin.Server/Properties/launchSettings.json

@@ -0,0 +1,11 @@
+{
+  "profiles": {
+    "Jellyfin.Server": {
+      "commandName": "Project"
+    },
+    "Jellyfin.Server (nowebclient)": {
+      "commandName": "Project",
+      "commandLineArgs": "--nowebclient"
+    }
+  }
+}

+ 25 - 0
Jellyfin.Server/StartupOptions.cs

@@ -1,5 +1,8 @@
+using System.Collections.Generic;
+using System.Globalization;
 using CommandLine;
 using Emby.Server.Implementations;
+using MediaBrowser.Controller.Extensions;
 
 namespace Jellyfin.Server
 {
@@ -15,6 +18,12 @@ namespace Jellyfin.Server
         [Option('d', "datadir", Required = false, HelpText = "Path to use for the data folder (database files, etc.).")]
         public string? DataDir { get; set; }
 
+        /// <summary>
+        /// Gets or sets a value indicating whether the server should not host the web client.
+        /// </summary>
+        [Option("nowebclient", Required = false, HelpText = "Indicates that the web server should not host the web client.")]
+        public bool NoWebClient { get; set; }
+
         /// <summary>
         /// Gets or sets the path to the web directory.
         /// </summary>
@@ -66,5 +75,21 @@ namespace Jellyfin.Server
         /// <inheritdoc />
         [Option("restartargs", Required = false, HelpText = "Arguments for restart script.")]
         public string? RestartArgs { get; set; }
+
+        /// <summary>
+        /// Gets the command line options as a dictionary that can be used in the .NET configuration system.
+        /// </summary>
+        /// <returns>The configuration dictionary.</returns>
+        public Dictionary<string, string> ConvertToConfig()
+        {
+            var config = new Dictionary<string, string>();
+
+            if (NoWebClient)
+            {
+                config.Add(ConfigurationExtensions.HostWebClientKey, bool.FalseString);
+            }
+
+            return config;
+        }
     }
 }

+ 7 - 2
MediaBrowser.Common/Configuration/IApplicationPaths.cs

@@ -1,3 +1,5 @@
+using MediaBrowser.Model.Configuration;
+
 namespace MediaBrowser.Common.Configuration
 {
     /// <summary>
@@ -12,9 +14,12 @@ namespace MediaBrowser.Common.Configuration
         string ProgramDataPath { get; }
 
         /// <summary>
-        /// Gets the path to the web UI resources folder
+        /// Gets the path to the web UI resources folder.
         /// </summary>
-        /// <value>The web UI resources path.</value>
+        /// <remarks>
+        /// This value is not relevant if the server is configured to not host any static web content. Additionally,
+        /// the value for <see cref="ServerConfiguration.DashboardSourcePath"/> takes precedence over this one.
+        /// </remarks>
         string WebPath { get; }
 
         /// <summary>

+ 15 - 0
MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs

@@ -1,3 +1,4 @@
+using System;
 using Microsoft.Extensions.Configuration;
 
 namespace MediaBrowser.Controller.Extensions
@@ -7,6 +8,11 @@ namespace MediaBrowser.Controller.Extensions
     /// </summary>
     public static class ConfigurationExtensions
     {
+        /// <summary>
+        /// The key for a setting that indicates whether the application should host web client content.
+        /// </summary>
+        public const string HostWebClientKey = "hostwebclient";
+
         /// <summary>
         /// The key for the FFmpeg probe size option.
         /// </summary>
@@ -22,6 +28,15 @@ namespace MediaBrowser.Controller.Extensions
         /// </summary>
         public const string PlaylistsAllowDuplicatesKey = "playlists:allowDuplicates";
 
+        /// <summary>
+        /// Gets a value indicating whether the application should host static web content from the <see cref="IConfiguration"/>.
+        /// </summary>
+        /// <param name="configuration">The configuration to retrieve the value from.</param>
+        /// <returns>The parsed config value.</returns>
+        /// <exception cref="FormatException">The config value is not a valid bool string. See <see cref="bool.Parse(string)"/>.</exception>
+        public static bool HostWebClient(this IConfiguration configuration)
+            => configuration.GetValue<bool>(HostWebClientKey);
+
         /// <summary>
         /// Gets the FFmpeg probe size from the <see cref="IConfiguration" />.
         /// </summary>

+ 5 - 0
MediaBrowser.Controller/IServerApplicationHost.cs

@@ -82,6 +82,11 @@ namespace MediaBrowser.Controller
         /// <returns>The local API URL.</returns>
         string GetLocalApiUrl(IPAddress address);
 
+        /// <summary>
+        /// Open a URL in an external browser window.
+        /// </summary>
+        /// <param name="url">The URL to open.</param>
+        /// <exception cref="NotSupportedException"><see cref="CanLaunchWebBrowser"/> is false.</exception>
         void LaunchUrl(string url);
 
         void EnableLoopback(string appName);

+ 2 - 2
MediaBrowser.Model/Configuration/ServerConfiguration.cs

@@ -148,9 +148,9 @@ namespace MediaBrowser.Model.Configuration
         public bool EnableDashboardResponseCaching { get; set; }
 
         /// <summary>
-        /// Allows the dashboard to be served from a custom path.
+        /// Gets or sets a custom path to serve the dashboard from.
         /// </summary>
-        /// <value>The dashboard source path.</value>
+        /// <value>The dashboard source path, or null if the default path should be used.</value>
         public string DashboardSourcePath { get; set; }
 
         /// <summary>

+ 36 - 10
MediaBrowser.WebDashboard/Api/DashboardService.cs

@@ -12,12 +12,14 @@ using MediaBrowser.Common.Extensions;
 using MediaBrowser.Common.Plugins;
 using MediaBrowser.Controller;
 using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Extensions;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Plugins;
 using MediaBrowser.Model.IO;
 using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Plugins;
 using MediaBrowser.Model.Services;
+using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.Logging;
 
 namespace MediaBrowser.WebDashboard.Api
@@ -102,6 +104,7 @@ namespace MediaBrowser.WebDashboard.Api
         /// <value>The HTTP result factory.</value>
         private readonly IHttpResultFactory _resultFactory;
         private readonly IServerApplicationHost _appHost;
+        private readonly IConfiguration _appConfig;
         private readonly IServerConfigurationManager _serverConfigurationManager;
         private readonly IFileSystem _fileSystem;
         private readonly IResourceFileManager _resourceFileManager;
@@ -111,6 +114,7 @@ namespace MediaBrowser.WebDashboard.Api
         /// </summary>
         /// <param name="logger">The logger.</param>
         /// <param name="appHost">The application host.</param>
+        /// <param name="appConfig">The application configuration.</param>
         /// <param name="resourceFileManager">The resource file manager.</param>
         /// <param name="serverConfigurationManager">The server configuration manager.</param>
         /// <param name="fileSystem">The file system.</param>
@@ -118,6 +122,7 @@ namespace MediaBrowser.WebDashboard.Api
         public DashboardService(
             ILogger<DashboardService> logger,
             IServerApplicationHost appHost,
+            IConfiguration appConfig,
             IResourceFileManager resourceFileManager,
             IServerConfigurationManager serverConfigurationManager,
             IFileSystem fileSystem,
@@ -125,6 +130,7 @@ namespace MediaBrowser.WebDashboard.Api
         {
             _logger = logger;
             _appHost = appHost;
+            _appConfig = appConfig;
             _resourceFileManager = resourceFileManager;
             _serverConfigurationManager = serverConfigurationManager;
             _fileSystem = fileSystem;
@@ -138,20 +144,30 @@ namespace MediaBrowser.WebDashboard.Api
         public IRequest Request { get; set; }
 
         /// <summary>
-        /// Gets the path for the web interface.
+        /// Gets the path of the directory containing the static web interface content, or null if the server is not
+        /// hosting the web client.
         /// </summary>
-        /// <value>The path for the web interface.</value>
-        public string DashboardUIPath
+        public string DashboardUIPath => GetDashboardUIPath(_appConfig, _serverConfigurationManager);
+
+        /// <summary>
+        /// Gets the path of the directory containing the static web interface content.
+        /// </summary>
+        /// <param name="appConfig">The app configuration.</param>
+        /// <param name="serverConfigManager">The server configuration manager.</param>
+        /// <returns>The directory path, or null if the server is not hosting the web client.</returns>
+        public static string GetDashboardUIPath(IConfiguration appConfig, IServerConfigurationManager serverConfigManager)
         {
-            get
+            if (!appConfig.HostWebClient())
             {
-                if (!string.IsNullOrEmpty(_serverConfigurationManager.Configuration.DashboardSourcePath))
-                {
-                    return _serverConfigurationManager.Configuration.DashboardSourcePath;
-                }
+                return null;
+            }
 
-                return _serverConfigurationManager.ApplicationPaths.WebPath;
+            if (!string.IsNullOrEmpty(serverConfigManager.Configuration.DashboardSourcePath))
+            {
+                return serverConfigManager.Configuration.DashboardSourcePath;
             }
+
+            return serverConfigManager.ApplicationPaths.WebPath;
         }
 
         [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
@@ -209,7 +225,7 @@ namespace MediaBrowser.WebDashboard.Api
                     return _resultFactory.GetStaticResult(Request, plugin.Version.ToString().GetMD5(), null, null, MimeTypes.GetMimeType("page.html"), () => Task.FromResult(stream));
                 }
 
-                return _resultFactory.GetStaticResult(Request, plugin.Version.ToString().GetMD5(), null, null, MimeTypes.GetMimeType("page.html"), () => GetPackageCreator(DashboardUIPath).ModifyHtml("dummy.html", stream, null, _appHost.ApplicationVersionString, null));
+                return _resultFactory.GetStaticResult(Request, plugin.Version.ToString().GetMD5(), null, null, MimeTypes.GetMimeType("page.html"), () => PackageCreator.ModifyHtml(false, stream, null, _appHost.ApplicationVersionString, null));
             }
 
             throw new ResourceNotFoundException();
@@ -307,6 +323,11 @@ namespace MediaBrowser.WebDashboard.Api
         /// <returns>System.Object.</returns>
         public async Task<object> Get(GetDashboardResource request)
         {
+            if (!_appConfig.HostWebClient() || DashboardUIPath == null)
+            {
+                throw new ResourceNotFoundException();
+            }
+
             var path = request.ResourceName;
 
             var contentType = MimeTypes.GetMimeType(path);
@@ -378,6 +399,11 @@ namespace MediaBrowser.WebDashboard.Api
 
         public async Task<object> Get(GetDashboardPackage request)
         {
+            if (!_appConfig.HostWebClient() || DashboardUIPath == null)
+            {
+                throw new ResourceNotFoundException();
+            }
+
             var mode = request.Mode;
 
             var inputPath = string.IsNullOrWhiteSpace(mode) ?

+ 16 - 6
MediaBrowser.WebDashboard/Api/PackageCreator.cs

@@ -31,7 +31,8 @@ namespace MediaBrowser.WebDashboard.Api
 
             if (resourceStream != null && IsCoreHtml(virtualPath))
             {
-                resourceStream = await ModifyHtml(virtualPath, resourceStream, mode, appVersion, localizationCulture).ConfigureAwait(false);
+                bool isMainIndexPage = string.Equals(virtualPath, "index.html", StringComparison.OrdinalIgnoreCase);
+                resourceStream = await ModifyHtml(isMainIndexPage, resourceStream, mode, appVersion, localizationCulture).ConfigureAwait(false);
             }
 
             return resourceStream;
@@ -47,16 +48,25 @@ namespace MediaBrowser.WebDashboard.Api
             return string.Equals(Path.GetExtension(path), ".html", StringComparison.OrdinalIgnoreCase);
         }
 
-        // Modifies the HTML by adding common meta tags, css and js.
-        public async Task<Stream> ModifyHtml(
-            string path,
+        /// <summary>
+        /// Modifies the source HTML stream by adding common meta tags, css and js.
+        /// </summary>
+        /// <param name="isMainIndexPage">True if the stream contains content for the main index page.</param>
+        /// <param name="sourceStream">The stream whose content should be modified.</param>
+        /// <param name="mode">The client mode ('cordova', 'android', etc).</param>
+        /// <param name="appVersion">The application version.</param>
+        /// <param name="localizationCulture">The localization culture.</param>
+        /// <returns>
+        /// A task that represents the async operation to read and modify the input stream.
+        /// The task result contains a stream containing the modified HTML content.
+        /// </returns>
+        public static async Task<Stream> ModifyHtml(
+            bool isMainIndexPage,
             Stream sourceStream,
             string mode,
             string appVersion,
             string localizationCulture)
         {
-            var isMainIndexPage = string.Equals(path, "index.html", StringComparison.OrdinalIgnoreCase);
-
             string html;
             using (var reader = new StreamReader(sourceStream, Encoding.UTF8))
             {