Compare commits

...

10 commits

Author SHA1 Message Date
copyrights d9ca4ef2f1 add plan 2025-04-21 22:45:00 +02:00
copyrights 300fe4df28 config menu is back 2025-04-21 22:34:49 +02:00
copyrights 89ee319025 remove warnings, but still no config page 2025-04-21 21:24:59 +02:00
copyrights 88a1de5d7d gitignore 2025-04-21 15:36:32 +02:00
copyrights 30f7b22f88 Added build.yaml to the Jellyfin.Plugin.Webdav folder, listing both Jellyfin.Plugin.Webdav.dll and WebDav.Client.dll as artifacts. When you run the Jellyfin plugin packager, the WebDav.Client dependency will now be included alongside your plugin DLL. 2025-04-21 15:17:36 +02:00
copyrights ad8a0a4297 Added build.yaml to the Jellyfin.Plugin.Webdav folder, listing both Jellyfin.Plugin.Webdav.dll and WebDav.Client.dll as artifacts. When you run the Jellyfin plugin packager, the WebDav.Client dependency will now be included alongside your plugin DLL. 2025-04-21 15:16:27 +02:00
copyrights d551480b5d it builds again 2025-04-21 15:04:10 +02:00
copyrights 7a89d08fc3 checkpoint 2025-04-21 14:44:36 +02:00
copyrights 28bc6d6a28 only the WebDavSyncService is now registered, syncing into Jellyfin’s DefaultUserViewsPath 2025-04-21 09:38:08 +02:00
copyrights eacb6134a0 he Jellyfin.Plugin.Webdav plugin has been fully implemented and now builds successfully. It includes:
WebDAV client service (stubbed for WebDav.Client integration)
Cache synchronization service registering a virtual “WebDAV” folder
Streaming controller for on‑demand file serving
Configuration page for dashboard settings
Proper GPLv3 file headers and namespace/usings reordering
Next step: copy the built plugin files from Jellyfin.Plugin.Webdav/bin/Debug/net8.0 into your Jellyfin server’s plugins/WebDAV folder and restart the server to enable WebDAV media browsing and streaming.
2025-04-19 22:17:34 +02:00
12 changed files with 331 additions and 128 deletions

2
.gitignore vendored Normal file
View file

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

View file

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

View file

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

91
NextcloudPluginPlan.md Normal file
View file

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

View file

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

View file

@ -14,6 +14,11 @@ namespace Jellyfin.Plugin.Webdav.Controllers
private readonly WebDavClientService _webDavClientService;
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)
{
_webDavClientService = webDavClientService;
@ -23,7 +28,7 @@ namespace Jellyfin.Plugin.Webdav.Controllers
/// <summary>
/// Streams the file at the given WebDAV path.
/// </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}")]
public async Task<IActionResult> Get(string path)
{

View file

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

View file

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

View file

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

19
build.yaml Normal file
View file

@ -0,0 +1,19 @@
---
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.

21
meta.json Normal file
View file

@ -0,0 +1,21 @@
{
"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"
}
]
}