diff --git a/Jellyfin.Plugin.JellyLMS/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.JellyLMS/Configuration/PluginConfiguration.cs index 228d981..564fca6 100644 --- a/Jellyfin.Plugin.JellyLMS/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.JellyLMS/Configuration/PluginConfiguration.cs @@ -62,4 +62,24 @@ public class PluginConfiguration : BasePluginConfiguration /// Gets or sets the Jellyfin API key for authenticating stream requests from LMS. /// public string JellyfinApiKey { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether to use direct file paths instead of HTTP streaming. + /// When enabled, LMS accesses files directly from shared storage, enabling native seeking. + /// + public bool UseDirectFilePath { get; set; } + + /// + /// Gets or sets the media path prefix as seen by Jellyfin. + /// Used for path mapping when UseDirectFilePath is enabled. + /// Example: /media/music or C:\Music. + /// + public string JellyfinMediaPath { get; set; } = string.Empty; + + /// + /// Gets or sets the media path prefix as seen by LMS. + /// Used for path mapping when UseDirectFilePath is enabled. + /// Example: /mnt/music or //nas/music. + /// + public string LmsMediaPath { get; set; } = string.Empty; } diff --git a/Jellyfin.Plugin.JellyLMS/Configuration/configPage.html b/Jellyfin.Plugin.JellyLMS/Configuration/configPage.html index 5746031..6a8dcfe 100644 --- a/Jellyfin.Plugin.JellyLMS/Configuration/configPage.html +++ b/Jellyfin.Plugin.JellyLMS/Configuration/configPage.html @@ -141,6 +141,33 @@ +
+

Direct File Access (Optional)

+

If LMS and Jellyfin share the same storage (e.g., NAS), enable direct file access for native seeking support. This provides smooth seeking without audio restart.

+ +
+ +
When enabled, LMS will access files directly instead of streaming via HTTP
+
+ +
+
+ + +
The path prefix as Jellyfin sees your media library (e.g., /media/music)
+
+ +
+ + +
The same location as LMS sees it (e.g., /mnt/music or //nas/music)
+
+
+
+

Player Sync

Select players to sync together for multi-room audio. Synced players play in perfect sync.

@@ -418,6 +445,9 @@ document.querySelector('#ConnectionTimeoutSeconds').value = config.ConnectionTimeoutSeconds || 10; document.querySelector('#EnableAutoSync').checked = config.EnableAutoSync !== false; document.querySelector('#DefaultPlayerMac').value = config.DefaultPlayerMac || ''; + document.querySelector('#UseDirectFilePath').checked = config.UseDirectFilePath || false; + document.querySelector('#JellyfinMediaPath').value = config.JellyfinMediaPath || ''; + document.querySelector('#LmsMediaPath').value = config.LmsMediaPath || ''; Dashboard.hideLoadingMsg(); loadPlayers(); @@ -460,6 +490,9 @@ config.ConnectionTimeoutSeconds = parseInt(document.querySelector('#ConnectionTimeoutSeconds').value) || 10; config.EnableAutoSync = document.querySelector('#EnableAutoSync').checked; config.DefaultPlayerMac = document.querySelector('#DefaultPlayerMac').value; + config.UseDirectFilePath = document.querySelector('#UseDirectFilePath').checked; + config.JellyfinMediaPath = document.querySelector('#JellyfinMediaPath').value; + config.LmsMediaPath = document.querySelector('#LmsMediaPath').value; ApiClient.updatePluginConfiguration(JellyLmsConfig.pluginUniqueId, config).then(function (result) { Dashboard.processPluginConfigurationUpdateResult(result); }); diff --git a/Jellyfin.Plugin.JellyLMS/Services/LmsSessionController.cs b/Jellyfin.Plugin.JellyLMS/Services/LmsSessionController.cs index 2392328..708df61 100644 --- a/Jellyfin.Plugin.JellyLMS/Services/LmsSessionController.cs +++ b/Jellyfin.Plugin.JellyLMS/Services/LmsSessionController.cs @@ -172,14 +172,17 @@ public class LmsSessionController : ISessionController, IDisposable var itemId = _playlist[index]; _playlistIndex = index; - // Build stream URL with start position - LMS can't seek on HTTP streams, - // so we need to use Jellyfin's startTimeTicks parameter for transcoding - var streamUrl = BuildStreamUrlWithPosition(itemId, startPositionTicks); + // Look up the item first - we need it for file path if using direct mode + _currentItem = _libraryManager.GetItemById(itemId); + + // Build stream URL/path with start position + var (streamUrl, useDirectPath) = BuildPlaybackUrl(itemId, startPositionTicks); _logger.LogInformation( - "Playing item {Index}/{Total} from position {Position}s: {Url}", + "Playing item {Index}/{Total} from position {Position}s (direct={Direct}): {Url}", index + 1, _playlist.Length, startPositionTicks / TimeSpan.TicksPerSecond, + useDirectPath, streamUrl); await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false); @@ -190,12 +193,9 @@ public class LmsSessionController : ISessionController, IDisposable IsPaused = false; // Track the seek offset so we report the correct position - // When starting from a position, the transcoded stream starts at 0, - // but we need to report the actual track position - _seekOffsetTicks = startPositionTicks; - - // Look up the item for duration info - _currentItem = _libraryManager.GetItemById(itemId); + // When using direct file paths, LMS handles seeking natively so no offset needed + // When using HTTP streaming with startTimeTicks, the stream starts at 0 but we need to report actual position + _seekOffsetTicks = useDirectPath ? 0 : startPositionTicks; // Report playback start to Jellyfin await ReportPlaybackStartAsync(itemId, startPositionTicks).ConfigureAwait(false); @@ -419,21 +419,42 @@ public class LmsSessionController : ISessionController, IDisposable if (playstateRequest.SeekPositionTicks.HasValue && CurrentItemId.HasValue) { var positionTicks = playstateRequest.SeekPositionTicks.Value; + var positionSeconds = positionTicks / TimeSpan.TicksPerSecond; - // For HTTP streams, LMS can't seek directly - we need to restart with startTimeTicks - // Build a new URL with the seek position and restart playback - var streamUrl = BuildStreamUrlWithPosition(CurrentItemId.Value, positionTicks); - _logger.LogInformation( - "Seeking by restarting stream at position {Seconds}s: {Url}", - positionTicks / TimeSpan.TicksPerSecond, - streamUrl); + // Check if we're using direct file path mode - if so, LMS can seek natively + var config = Plugin.Instance?.Configuration; + var canSeekNatively = config?.UseDirectFilePath == true + && !string.IsNullOrEmpty(config.JellyfinMediaPath) + && !string.IsNullOrEmpty(config.LmsMediaPath) + && _currentItem?.Path != null + && _currentItem.Path.StartsWith(config.JellyfinMediaPath, StringComparison.OrdinalIgnoreCase); - await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false); + if (canSeekNatively) + { + // Use native LMS seeking - much smoother! + _logger.LogInformation("Seeking natively to {Seconds}s using LMS time command", positionSeconds); + await _lmsClient.SeekAsync(_player.MacAddress, positionSeconds).ConfigureAwait(false); + // No seek offset needed - LMS handles position tracking natively + _seekOffsetTicks = 0; + } + else + { + // For HTTP streams, LMS can't seek directly - we need to restart with startTimeTicks + // Build a new URL with the seek position and restart playback + var streamUrl = BuildStreamUrlWithPosition(CurrentItemId.Value, positionTicks); + _logger.LogInformation( + "Seeking by restarting stream at position {Seconds}s: {Url}", + positionSeconds, + streamUrl); - // Track the seek offset so we report the correct position - // The transcoded stream starts at 0, but we need to report the actual track position - _seekOffsetTicks = positionTicks; - _logger.LogInformation("Set seek offset to {Ticks} ticks ({Seconds}s)", positionTicks, positionTicks / TimeSpan.TicksPerSecond); + await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false); + + // Track the seek offset so we report the correct position + // The transcoded stream starts at 0, but we need to report the actual track position + _seekOffsetTicks = positionTicks; + } + + _logger.LogInformation("Seek offset is now {Ticks} ticks ({Seconds}s)", _seekOffsetTicks, _seekOffsetTicks / TimeSpan.TicksPerSecond); } else { @@ -536,6 +557,59 @@ public class LmsSessionController : ISessionController, IDisposable } } + /// + /// Builds the playback URL or file path for the given item. + /// Returns a tuple of (url/path, isDirectFilePath). + /// + private (string Url, bool IsDirectPath) BuildPlaybackUrl(Guid itemId, long startPositionTicks) + { + var config = Plugin.Instance?.Configuration; + + // Check if direct file path mode is enabled and configured + if (config?.UseDirectFilePath == true + && !string.IsNullOrEmpty(config.JellyfinMediaPath) + && !string.IsNullOrEmpty(config.LmsMediaPath) + && _currentItem?.Path != null) + { + var directPath = BuildDirectFilePath(_currentItem.Path, config.JellyfinMediaPath, config.LmsMediaPath); + if (directPath != null) + { + _logger.LogDebug( + "Using direct file path: {OriginalPath} -> {MappedPath}", + _currentItem.Path, + directPath); + return (directPath, true); + } + + _logger.LogWarning( + "Direct file path mode enabled but path mapping failed for: {Path}", + _currentItem.Path); + } + + // Fall back to HTTP streaming + return (BuildStreamUrlWithPosition(itemId, startPositionTicks), false); + } + + /// + /// Maps a Jellyfin file path to an LMS file path using the configured path prefixes. + /// + private static string? BuildDirectFilePath(string jellyfinPath, string jellyfinPrefix, string lmsPrefix) + { + // Normalize path separators for comparison + var normalizedPath = jellyfinPath.Replace('\\', '/'); + var normalizedJellyfinPrefix = jellyfinPrefix.TrimEnd('/', '\\').Replace('\\', '/'); + var normalizedLmsPrefix = lmsPrefix.TrimEnd('/', '\\').Replace('\\', '/'); + + if (!normalizedPath.StartsWith(normalizedJellyfinPrefix, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + // Replace the prefix + var relativePath = normalizedPath[normalizedJellyfinPrefix.Length..]; + return normalizedLmsPrefix + relativePath; + } + private string BuildStreamUrl(Guid itemId) { return BuildStreamUrlWithPosition(itemId, 0); @@ -553,7 +627,9 @@ public class LmsSessionController : ISessionController, IDisposable { // For seeking, we need to use transcoding (static=true doesn't support startTimeTicks) // Use MP3 transcoding with the start position - url = $"{jellyfinUrl}/Audio/{itemId}/stream.mp3?audioCodec=mp3&audioBitRate=320000&startTimeTicks={startPositionTicks}"; + // Add a cache-busting parameter to ensure we get a fresh stream on each seek + var cacheBuster = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + url = $"{jellyfinUrl}/Audio/{itemId}/stream.mp3?audioCodec=mp3&audioBitRate=320000&startTimeTicks={startPositionTicks}&_={cacheBuster}"; } else { diff --git a/README.md b/README.md index dd97060..134273f 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,30 @@ The plugin communicates with LMS using the `slim.request` JSON-RPC method. 2. Check that audio files are in a format supported by your LMS players 3. Ensure players are powered on (plugin can auto-power-on if configured) +## Known Limitations + +### Seeking with HTTP Streaming + +When using HTTP streaming (the default), LMS cannot seek within audio streams. To work around this, when you seek or cast from a specific position, JellyLMS restarts playback with a new transcoded stream that begins at the requested position. This means: + +- **Seeking triggers a brief audio restart** rather than a smooth jump +- **Starting playback mid-track** uses transcoding (MP3 320kbps) instead of direct streaming +- **Playback from the beginning** uses direct/static streaming for best quality + +This is a fundamental limitation of how LMS handles HTTP streams. + +### Solution: Direct File Access + +If your Jellyfin and LMS servers can both access the same storage (e.g., a NAS), you can enable **Direct File Access** mode in the plugin settings. This allows LMS to read files directly from disk, enabling: + +- **Native smooth seeking** - no audio restart when scrubbing +- **Full quality playback** - no transcoding needed +- **Better performance** - no HTTP overhead + +To configure, set the path mappings in the plugin settings: +- **Jellyfin Media Path**: The path prefix as Jellyfin sees your library (e.g., `/media/music`) +- **LMS Media Path**: The same location as LMS sees it (e.g., `/mnt/music` or `//nas/music`) + ## Development ### Project Structure