Compare commits

..

No commits in common. "d9ca4ef2f196356527b128ed4e2176bbc83ee372" and "2811ed4aadb9bbdc722637eb7acc904750b89d90" have entirely different histories.

12 changed files with 128 additions and 331 deletions

2
.gitignore vendored
View file

@ -1,2 +0,0 @@
bin/
obj/

View file

@ -7,9 +7,6 @@ namespace Jellyfin.Plugin.Webdav.Configuration
/// </summary> /// </summary>
public class PluginConfiguration : BasePluginConfiguration public class PluginConfiguration : BasePluginConfiguration
{ {
/// <summary>
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
/// </summary>
public PluginConfiguration() public PluginConfiguration()
{ {
BaseUrl = ""; BaseUrl = "";

View file

@ -4,7 +4,10 @@
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.Webdav</RootNamespace> <RootNamespace>Jellyfin.Plugin.Webdav</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -14,10 +17,9 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<!-- Manually include the WebDav.Client.dll in the output directory --> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<None Include="$(NuGetPackageRoot)webdav.client/2.9.0/lib/netstandard2.0/WebDav.Client.dll"> <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="All" />
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
</None>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@ -25,9 +27,4 @@
<EmbeddedResource Include="Configuration\configPage.html" /> <EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Include="meta.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project> </Project>

View file

@ -1,91 +0,0 @@
# Nextcloud/WebDAV Jellyfin Plugin Plan
Below is a high-level plan for a Jellyfin plugin that integrates Nextcloud/WebDAV sources as library content.
```mermaid
flowchart TD
subgraph Plugin Core
A[PluginConfiguration<br/> holds URL, creds, paths]
B[Plugin<br/> BasePlugin<Config>]
C[IPluginServiceRegistrator<br/> registers services]
D[IHostedService<br/> hook on startup]
end
subgraph Services
E[WebDavClientService<br/> wraps WebDav.Client]
F[RemoteLibraryProvider<br/> implements virtual folder scanning]
G[CachingLayer<br/> buffer / temporary file cache]
H[StreamingController<br/> ControllerBase for proxying streams]
end
subgraph Jellyfin Core
I[ILibraryManager<br/> library registration]
J[Scan Engine<br/> calls provider to enumerate items]
K[HTTP Pipeline<br/> routes to controllers]
end
A --> B
B --> C
C --> E
C --> F
E --> F
F --> J
F --> G
G --> H
H --> K
D --> I
I --> J
```
## 1. Skeleton & Setup
- Copy the `jellyfin-plugin-template` and rename to `Jellyfin.Plugin.Nextcloud`.
- Update project metadata: plugin ID, name, version in `.csproj`.
- Add NuGet reference to `WebDav.Client`.
## 2. Configuration & Dependency Injection
- Define `PluginConfiguration` with URL, credentials, root path, and cache settings.
- Implement `Plugin : BasePlugin<PluginConfiguration>` to override `Name`, `Id`, and inject DI.
- Create `IPluginServiceRegistrator` to register services:
- `WebDavClientService` (singleton)
- `RemoteLibraryProvider` (scanning)
- `StreamingController` (ASP.NET Controller)
- Register virtual folder with `ILibraryManager.RegisterVirtualFolder` on startup.
## 3. Scanning Integration
- Research Jellyfin scan engine provider interfaces.
- Implement `RemoteLibraryProvider` to list directories/files via `WebDavClientService`.
- Map each remote item to a `BaseItem` (e.g., `Video`, `Folder`) with metadata.
- Track ETags or timestamps to support incremental scans.
## 4. Streaming & Caching
- Create `StreamingController : ControllerBase` with endpoint `/WebDav/Stream/{itemId}`.
- Map `itemId` to remote path and stream via `WebDavClientService.GetStream`.
- Implement `CachingLayer` to buffer chunks and write to temp files to prevent stalls.
## 5. Admin UI
- Define `configPage.html` as an embedded resource and implement `IHasWebPages` in `Plugin.cs` to return a `PluginPageInfo` for the settings page.
- Update `build.yaml` to include a `pages` section:
```yaml
pages:
- name: WebDAV
embeddedResourcePath: Jellyfin.Plugin.Webdav.Configuration.configPage.html
```
- Rebuild the plugin so that MSBuild generates a `plugin.json` manifest containing the pages definition.
- Deploy the `plugin.json`, DLL, and dependencies to Jellyfins `plugins` folder.
- Restart Jellyfin to verify the “WebDAV” settings page appears under Admin → Plugins.
## 6. Testing & Validation
- Unit-test `WebDavClientService` using a test Nextcloud instance or mock.
- Deploy plugin locally; verify library shows Nextcloud virtual folders.
- Test playback to ensure smooth streaming.
## 7. Documentation & Release
- Write README with installation, configuration, and troubleshooting guidance.
- Bump version, package plugin (`.nupkg`), and publish to GitHub Releases.
### Milestones
- **M1**: Plugin skeleton & DI wiring
- **M2**: Scanning proof-of-concept
- **M3**: Streaming proxy & caching
- **M4**: Admin UI & settings page
- **M5**: Testing, documentation, release

View file

@ -1,58 +1,51 @@
/* using System;
* Jellyfin.Plugin.Webdav using System.Collections.Generic;
* Copyright (C) 2025 Jellyfin contributors using System.Globalization;
* Licensed under GPLv3 using Jellyfin.Plugin.Webdav.Configuration;
*/ using MediaBrowser.Common.Configuration;
namespace Jellyfin.Plugin.Webdav using MediaBrowser.Common.Plugins;
{ using MediaBrowser.Model.Plugins;
using System; using MediaBrowser.Model.Serialization;
using System.Collections.Generic;
using System.Globalization;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
namespace Jellyfin.Plugin.Webdav;
/// <summary>
/// The main plugin.
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary> /// <summary>
/// The main plugin. /// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary> /// </summary>
public sealed class WebdavPlugin : BasePlugin<Configuration.PluginConfiguration>, IHasWebPages /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
{ /// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
/// <summary> public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
/// Initializes a new instance of the <see cref="WebdavPlugin"/> class.
/// </summary>
/// <param name="applicationPaths">The application paths.</param>
/// <param name="xmlSerializer">The xml serializer.</param>
public WebdavPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer) : base(applicationPaths, xmlSerializer)
{ {
Instance = this; Instance = this;
} }
/// <inheritdoc/> /// <inheritdoc />
public override string Name => "WebDAV"; public override string Name => "WebDAV";
/// <inheritdoc/> /// <inheritdoc />
public override Guid Id => Guid.Parse("5db89aeb-14ad-450b-bcf2-ae235ebbe299"); public override Guid Id => Guid.Parse("5db89aeb-14ad-450b-bcf2-ae235ebbe299");
/// <summary> /// <summary>
/// Gets the plugin instance. /// Gets the current plugin instance.
/// </summary> /// </summary>
public static WebdavPlugin Instance { get; private set; } public static Plugin? Instance { get; private set; }
/// <inheritdoc/> /// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages() public IEnumerable<PluginPageInfo> GetPages()
{ {
return new[] return
{ [
new PluginPageInfo new PluginPageInfo
{ {
Name = Name, Name = Name,
EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configPage.html" EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
}
};
} }
];
} }
} }

View file

@ -1,27 +1,20 @@
/* using Microsoft.Extensions.DependencyInjection;
* Jellyfin.Plugin.Webdav using MediaBrowser.Controller.Plugins;
* Copyright (C) 2025 Jellyfin contributors using MediaBrowser.Controller.Library;
* Licensed under GPLv3 using Microsoft.Extensions.Hosting;
*/
namespace Jellyfin.Plugin.Webdav namespace Jellyfin.Plugin.Webdav
{ {
using MediaBrowser.Controller;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
/// <summary> /// <summary>
/// Registers plugin services with the DI container. /// Registers plugin services with the DI container.
/// </summary> /// </summary>
public class ServiceRegistrator : IPluginServiceRegistrator public class ServiceRegistrator : IPluginServiceRegistrator
{ {
/// <inheritdoc/> /// <inheritdoc/>
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) public void RegisterServices(IServiceCollection services, IServerApplicationHost applicationHost)
{ {
serviceCollection.AddSingleton(sp => WebdavPlugin.Instance.Configuration); services.AddSingleton<WebDavClientService>();
serviceCollection.AddSingleton<WebDavClientService>(); services.AddHostedService<WebDavSyncService>();
serviceCollection.AddHostedService<WebDavSyncService>();
} }
} }
} }

View file

@ -14,11 +14,6 @@ namespace Jellyfin.Plugin.Webdav.Controllers
private readonly WebDavClientService _webDavClientService; private readonly WebDavClientService _webDavClientService;
private readonly PluginConfiguration _config; private readonly PluginConfiguration _config;
/// <summary>
/// Initializes a new instance of the <see cref="StreamController"/> class.
/// </summary>
/// <param name="webDavClientService">The WebDAV client service.</param>
/// <param name="config">The plugin configuration.</param>
public StreamController(WebDavClientService webDavClientService, PluginConfiguration config) public StreamController(WebDavClientService webDavClientService, PluginConfiguration config)
{ {
_webDavClientService = webDavClientService; _webDavClientService = webDavClientService;
@ -28,7 +23,7 @@ namespace Jellyfin.Plugin.Webdav.Controllers
/// <summary> /// <summary>
/// Streams the file at the given WebDAV path. /// Streams the file at the given WebDAV path.
/// </summary> /// </summary>
/// <param name="path">The relative path under the configured root.</param> /// <param name="*path">The relative path under the configured root.</param>
[HttpGet("{*path}")] [HttpGet("{*path}")]
public async Task<IActionResult> Get(string path) public async Task<IActionResult> Get(string path)
{ {

View file

@ -1,58 +1,43 @@
/* using System;
* Jellyfin.Plugin.Webdav using System.Collections.Generic;
* Copyright (C) 2025 Jellyfin contributors using System.IO;
* Licensed under GPLv3 using System.Net;
*/ using System.Threading.Tasks;
using WebDav.Client;
namespace Jellyfin.Plugin.Webdav namespace Jellyfin.Plugin.Webdav
{ {
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using Jellyfin.Plugin.Webdav.Configuration;
using WebDav;
/// <summary> /// <summary>
/// Service for interacting with WebDAV endpoints. /// Service for interacting with WebDAV endpoints.
/// </summary> /// </summary>
public sealed class WebDavClientService public class WebDavClientService
{ {
private readonly PluginConfiguration _config; private readonly PluginConfiguration _config;
private readonly WebDavClient _client; private readonly WebDavClient _client;
/// <summary>
/// Initializes a new instance of the <see cref="WebDavClientService"/> class.
/// </summary>
/// <param name="config">Plugin configuration.</param>
public WebDavClientService(PluginConfiguration config) public WebDavClientService(PluginConfiguration config)
{ {
_config = config; _config = config;
var parameters = new WebDavClientParams var parameters = new WebDavClientParams
{ {
BaseAddress = new Uri(_config.BaseUrl.TrimEnd('/')), BaseAddress = new Uri(_config.BaseUrl),
Credentials = new NetworkCredential(_config.Username, _config.Password), Credentials = new NetworkCredential(_config.Username, _config.Password)
PreAuthenticate = true
}; };
_client = new WebDavClient(parameters); _client = new WebDavClient(parameters);
} }
/// <summary> /// <summary>
/// Lists resources at the specified WebDAV path. /// List resources at the specified WebDAV path.
/// </summary> /// </summary>
/// <param name="path">Remote path.</param> public async Task<IEnumerable<WebDavResource>> ListAsync(string path)
/// <returns>A collection of WebDAV resources.</returns>
public async Task<IEnumerable<dynamic>> ListAsync(string path)
{ {
var response = await _client.Propfind(path).ConfigureAwait(false); var response = await _client.Propfind(path).ConfigureAwait(false);
return response.Resources; return response.Resources;
} }
/// <summary> /// <summary>
/// Gets a raw file stream from the WebDAV endpoint. /// Get a raw file stream from the WebDAV endpoint.
/// </summary> /// </summary>
/// <param name="path">Remote file path.</param>
/// <returns>A stream for the remote file.</returns>
public async Task<Stream> GetStreamAsync(string path) public async Task<Stream> GetStreamAsync(string path)
{ {
var response = await _client.GetRawFile(path).ConfigureAwait(false); var response = await _client.GetRawFile(path).ConfigureAwait(false);

View file

@ -1,41 +1,37 @@
/* using System.Threading;
* Jellyfin.Plugin.Webdav using System.Threading.Tasks;
* Copyright (C) 2025 Jellyfin contributors using Microsoft.Extensions.Hosting;
* Licensed under GPLv3 using MediaBrowser.Controller.Library;
*/ using MediaBrowser.Model.Configuration;
namespace Jellyfin.Plugin.Webdav namespace Jellyfin.Plugin.Webdav
{ {
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Configuration;
using Microsoft.Extensions.Hosting;
/// <summary> /// <summary>
/// Hosted service that registers the WebDAV cache as a virtual folder on startup. /// Hosted service that registers the WebDAV source as a virtual folder on server startup.
/// </summary> /// </summary>
public class WebDavHostedService : IHostedService public class WebDavHostedService : IHostedService
{ {
private readonly ILibraryManager _libraryManager; private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="WebDavHostedService"/> class.
/// </summary>
/// <param name="libraryManager">The library manager.</param>
public WebDavHostedService(ILibraryManager libraryManager) public WebDavHostedService(ILibraryManager libraryManager)
{ {
_libraryManager = libraryManager; _libraryManager = libraryManager;
} }
/// <inheritdoc/>
public async Task StartAsync(CancellationToken cancellationToken) public async Task StartAsync(CancellationToken cancellationToken)
{ {
var options = new LibraryOptions(); // Register a virtual folder for WebDAV remote library
var options = new LibraryOptions
{
EnableInvertFolderHierarchy = false
};
await _libraryManager.AddVirtualFolder("WebDAV", null, options, true).ConfigureAwait(false); // Name must match plugin display name
await _libraryManager
.AddVirtualFolder("WebDAV", null, options, true)
.ConfigureAwait(false);
} }
/// <inheritdoc/>
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
} }
} }

View file

@ -1,101 +1,75 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Plugins;
namespace Jellyfin.Plugin.Webdav namespace Jellyfin.Plugin.Webdav
{ {
using System.IO;
using MediaBrowser.Controller;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Webdav.Configuration;
using MediaBrowser.Controller.Library;
using System.Collections.Generic;
using Microsoft.Extensions.Hosting;
using MediaBrowser.Model.Configuration;
/// <summary> /// <summary>
/// Hosted service that synchronizes WebDAV content into the local cache directory /// Hosted service that synchronizes WebDAV content into a local cache directory
/// and registers it as a Jellyfin virtual folder. /// and registers it as a Jellyfin virtual folder.
/// </summary> /// </summary>
public sealed class WebDavSyncService : IHostedService public class WebDavSyncService : IHostedService
{ {
private readonly WebDavClientService client; private readonly WebDavClientService _client;
private readonly PluginConfiguration config; private readonly PluginConfiguration _config;
private readonly IServerApplicationPaths appPaths; private readonly ILibraryManager _libraryManager;
private readonly ILibraryManager libraryManager; private readonly IServerApplicationHost _appHost;
/// <summary> public WebDavSyncService(WebDavClientService client, PluginConfiguration config, ILibraryManager libraryManager, IServerApplicationHost appHost)
/// Initializes a new instance of the <see cref="WebDavSyncService"/> class.
/// </summary>
/// <param name="client">The WebDAV client service.</param>
/// <param name="config">The plugin configuration.</param>
/// <param name="appPaths">Server application paths.</param>
/// <param name="libraryManager">Library manager.</param>
public WebDavSyncService(
WebDavClientService client,
PluginConfiguration config,
IServerApplicationPaths appPaths,
ILibraryManager libraryManager)
{ {
this.client = client; _client = client;
this.config = config; _config = config;
this.appPaths = appPaths; _libraryManager = libraryManager;
this.libraryManager = libraryManager; _appHost = appHost;
} }
/// <inheritdoc/>
public async Task StartAsync(CancellationToken cancellationToken) public async Task StartAsync(CancellationToken cancellationToken)
{ {
// Determine cache root: either configured directory or default user views path // Compute local root under DefaultUserViewsPath
var baseCache = string.IsNullOrWhiteSpace(config.CacheDirectory) var localRoot = Path.Combine(_appHost.ApplicationPaths.DefaultUserViewsPath, "WebDAV");
? appPaths.DefaultUserViewsPath
: config.CacheDirectory;
var localRoot = Path.Combine(baseCache, "WebDAV");
if (!Directory.Exists(localRoot))
{
Directory.CreateDirectory(localRoot); Directory.CreateDirectory(localRoot);
}
await SyncDirectoryAsync(config.RootPath.Trim('/'), localRoot, cancellationToken) // Sync directories recursively
.ConfigureAwait(false); await SyncDirectory(_config.RootPath, localRoot, cancellationToken).ConfigureAwait(false);
// Register the virtual folder pointing to the synced cache
var options = new LibraryOptions(); var options = new LibraryOptions();
await libraryManager await _libraryManager.AddVirtualFolder("WebDAV", null, options, true).ConfigureAwait(false);
.AddVirtualFolder("WebDAV", null, options, true)
.ConfigureAwait(false);
libraryManager.AddMediaPath("WebDAV", new MediaPathInfo { Path = localRoot });
} }
private async Task SyncDirectoryAsync(string remotePath, string localPath, CancellationToken token) private async Task SyncDirectory(string remotePath, string localPath, CancellationToken cancellationToken)
{ {
var resources = await client.ListAsync(remotePath).ConfigureAwait(false); var resources = await _client.ListAsync(remotePath).ConfigureAwait(false);
foreach (var item in resources.Where(r => !string.IsNullOrEmpty(r.DisplayName))) foreach (var res in resources.Where(r => r.DisplayName != null))
{ {
if (token.IsCancellationRequested) if (cancellationToken.IsCancellationRequested)
{ {
break; break;
} }
var name = item.DisplayName!; var name = res.DisplayName;
var remoteItem = $"{remotePath}/{name}"; var remoteSub = $"{remotePath.TrimEnd('/')}/{name}";
var localItem = Path.Combine(localPath, name); var localSub = Path.Combine(localPath, name);
if (item.IsCollection) if (res.IsCollection)
{ {
if (!Directory.Exists(localItem)) Directory.CreateDirectory(localSub);
{ await SyncDirectory(remoteSub, localSub, cancellationToken).ConfigureAwait(false);
Directory.CreateDirectory(localItem);
}
await SyncDirectoryAsync(remoteItem, localItem, token).ConfigureAwait(false);
} }
else else
{ {
using var stream = await client.GetStreamAsync(remoteItem).ConfigureAwait(false); using var stream = await _client.GetStreamAsync(remoteSub).ConfigureAwait(false);
using var fs = File.Create(localItem); using var fs = File.Create(localSub);
await stream.CopyToAsync(fs, token).ConfigureAwait(false); await stream.CopyToAsync(fs, cancellationToken).ConfigureAwait(false);
} }
} }
} }
/// <inheritdoc/>
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
} }
} }

View file

@ -1,19 +0,0 @@
---
name: "WebDAV"
guid: "5db89aeb-14ad-450b-bcf2-ae235ebbe299"
version: "1.0.0.0"
targetAbi: "10.9.0.0"
framework: "net8.0"
overview: "WebDAV Nextcloud integration plugin"
description: >
Integrates Nextcloud/WebDAV sources as virtual library content in Jellyfin.
category: "General"
owner: "jellyfin"
pages:
- name: WebDAV
embeddedResourcePath: Jellyfin.Plugin.Webdav.Configuration.configPage.html
artifacts:
- "Jellyfin.Plugin.Webdav.dll"
- "WebDav.Client.dll"
changelog: >
Initial release.

View file

@ -1,21 +0,0 @@
{
"name": "WebDAV",
"guid": "5db89aeb-14ad-450b-bcf2-ae235ebbe299",
"version": "1.0.0.0",
"targetAbi": "10.9.0.0",
"framework": "net8.0",
"overview": "WebDAV Nextcloud integration plugin",
"description": "Integrates Nextcloud/WebDAV sources as virtual library content in Jellyfin.",
"category": "General",
"owner": "jellyfin",
"assemblies": [
"Jellyfin.Plugin.Webdav.dll",
"WebDav.Client.dll"
],
"pages": [
{
"name": "WebDAV",
"embeddedResourcePath": "Jellyfin.Plugin.Webdav.Configuration.configPage.html"
}
]
}