diff options
| -rw-r--r-- | .github/ISSUE_TEMPLATE/bug_report.md | 8 | ||||
| -rw-r--r-- | CONTRIBUTORS.md | 1 | ||||
| -rw-r--r-- | Emby.Dlna/PlayTo/Device.cs | 36 | ||||
| -rw-r--r-- | Emby.Dlna/PlayTo/PlayToController.cs | 41 | ||||
| -rw-r--r-- | Emby.Server.Implementations/Emby.Server.Implementations.csproj | 1 | ||||
| -rw-r--r-- | Emby.Server.Implementations/Library/UserDataManager.cs | 4 | ||||
| -rw-r--r-- | Emby.Server.Implementations/Localization/Core/en-US.json | 4 | ||||
| -rw-r--r-- | Emby.Server.Implementations/Localization/Core/es.json | 4 | ||||
| -rw-r--r-- | Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs | 101 | ||||
| -rw-r--r-- | Emby.Server.Implementations/Session/SessionManager.cs | 31 | ||||
| -rw-r--r-- | Emby.Server.Implementations/TV/TVSeriesManager.cs | 2 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/TvShowsController.cs | 5 | ||||
| -rw-r--r-- | Jellyfin.Server/Program.cs | 10 | ||||
| -rw-r--r-- | MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs | 20 | ||||
| -rw-r--r-- | MediaBrowser.Model/Entities/MediaStream.cs | 13 | ||||
| -rw-r--r-- | MediaBrowser.Model/Querying/NextUpQuery.cs | 6 |
16 files changed, 256 insertions, 31 deletions
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d67e1c98b..12f1f5ed5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -33,7 +33,13 @@ assignees: '' **Expected behavior** <!-- A clear and concise description of what you expected to happen. --> -**Logs** +**Server Logs** +<!-- Please paste any log errors. --> + +**FFmpeg Logs** +<!-- Please paste any log errors. --> + +**Browser Console Logs** <!-- Please paste any log errors. --> **Screenshots** diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 7a763a46c..10ea6e883 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -146,6 +146,7 @@ - [nielsvanvelzen](https://github.com/nielsvanvelzen) - [skyfrk](https://github.com/skyfrk) - [ianjazz246](https://github.com/ianjazz246) + - [peterspenler](https://github.com/peterspenler) # Emby Contributors diff --git a/Emby.Dlna/PlayTo/Device.cs b/Emby.Dlna/PlayTo/Device.cs index 5fa1fd589..6c580d15b 100644 --- a/Emby.Dlna/PlayTo/Device.cs +++ b/Emby.Dlna/PlayTo/Device.cs @@ -370,6 +370,42 @@ namespace Emby.Dlna.PlayTo RestartTimer(true); } + /* + * SetNextAvTransport is used to specify to the DLNA device what is the next track to play. + * Without that information, the next track command on the device does not work. + */ + public async Task SetNextAvTransport(string url, string header, string metaData, CancellationToken cancellationToken = default) + { + var avCommands = await GetAVProtocolAsync(cancellationToken).ConfigureAwait(false); + + url = url.Replace("&", "&", StringComparison.Ordinal); + + _logger.LogDebug("{PropertyName} - SetNextAvTransport Uri: {Url} DlnaHeaders: {Header}", Properties.Name, url, header); + + var command = avCommands.ServiceActions.FirstOrDefault(c => string.Equals(c.Name, "SetNextAVTransportURI", StringComparison.OrdinalIgnoreCase)); + if (command == null) + { + return; + } + + var dictionary = new Dictionary<string, string> + { + { "NextURI", url }, + { "NextURIMetaData", CreateDidlMeta(metaData) } + }; + + var service = GetAvTransportService(); + + if (service == null) + { + throw new InvalidOperationException("Unable to find service"); + } + + var post = avCommands.BuildPost(command, service.ServiceType, url, dictionary); + await new SsdpHttpClient(_httpClientFactory).SendCommandAsync(Properties.BaseUrl, service, command.Name, post, header: header, cancellationToken) + .ConfigureAwait(false); + } + private static string CreateDidlMeta(string value) { if (string.IsNullOrEmpty(value)) diff --git a/Emby.Dlna/PlayTo/PlayToController.cs b/Emby.Dlna/PlayTo/PlayToController.cs index 1e6a5fadb..0e49fd2c0 100644 --- a/Emby.Dlna/PlayTo/PlayToController.cs +++ b/Emby.Dlna/PlayTo/PlayToController.cs @@ -104,6 +104,22 @@ namespace Emby.Dlna.PlayTo _deviceDiscovery.DeviceLeft += OnDeviceDiscoveryDeviceLeft; } + /* + * Send a message to the DLNA device to notify what is the next track in the playlist. + */ + private async Task SendNextTrackMessage(int currentPlayListItemIndex, CancellationToken cancellationToken) + { + if (currentPlayListItemIndex >= 0 && currentPlayListItemIndex < _playlist.Count - 1) + { + // The current playing item is indeed in the play list and we are not yet at the end of the playlist. + var nextItemIndex = currentPlayListItemIndex + 1; + var nextItem = _playlist[nextItemIndex]; + + // Send the SetNextAvTransport message. + await _device.SetNextAvTransport(nextItem.StreamUrl, GetDlnaHeaders(nextItem), nextItem.Didl, cancellationToken).ConfigureAwait(false); + } + } + private void OnDeviceUnavailable() { try @@ -158,6 +174,15 @@ namespace Emby.Dlna.PlayTo var newItemProgress = GetProgressInfo(streamInfo); await _sessionManager.OnPlaybackStart(newItemProgress).ConfigureAwait(false); + + // Send a message to the DLNA device to notify what is the next track in the playlist. + var currentItemIndex = _playlist.FindIndex(item => item.StreamInfo.ItemId == streamInfo.ItemId); + if (currentItemIndex >= 0) + { + _currentPlaylistIndex = currentItemIndex; + } + + await SendNextTrackMessage(currentItemIndex, CancellationToken.None); } catch (Exception ex) { @@ -427,6 +452,11 @@ namespace Emby.Dlna.PlayTo var newItem = CreatePlaylistItem(info.Item, user, newPosition, info.MediaSourceId, info.AudioStreamIndex, info.SubtitleStreamIndex); await _device.SetAvTransport(newItem.StreamUrl, GetDlnaHeaders(newItem), newItem.Didl, CancellationToken.None).ConfigureAwait(false); + + // Send a message to the DLNA device to notify what is the next track in the play list. + var newItemIndex = _playlist.FindIndex(item => item.StreamUrl == newItem.StreamUrl); + await SendNextTrackMessage(newItemIndex, CancellationToken.None); + return; } @@ -625,6 +655,9 @@ namespace Emby.Dlna.PlayTo await _device.SetAvTransport(currentitem.StreamUrl, GetDlnaHeaders(currentitem), currentitem.Didl, cancellationToken).ConfigureAwait(false); + // Send a message to the DLNA device to notify what is the next track in the play list. + await SendNextTrackMessage(index, cancellationToken); + var streamInfo = currentitem.StreamInfo; if (streamInfo.StartPositionTicks > 0 && EnableClientSideSeek(streamInfo)) { @@ -738,6 +771,10 @@ namespace Emby.Dlna.PlayTo await _device.SetAvTransport(newItem.StreamUrl, GetDlnaHeaders(newItem), newItem.Didl, CancellationToken.None).ConfigureAwait(false); + // Send a message to the DLNA device to notify what is the next track in the play list. + var newItemIndex = _playlist.FindIndex(item => item.StreamUrl == newItem.StreamUrl); + await SendNextTrackMessage(newItemIndex, CancellationToken.None); + if (EnableClientSideSeek(newItem.StreamInfo)) { await SeekAfterTransportChange(newPosition, CancellationToken.None).ConfigureAwait(false); @@ -763,6 +800,10 @@ namespace Emby.Dlna.PlayTo await _device.SetAvTransport(newItem.StreamUrl, GetDlnaHeaders(newItem), newItem.Didl, CancellationToken.None).ConfigureAwait(false); + // Send a message to the DLNA device to notify what is the next track in the play list. + var newItemIndex = _playlist.FindIndex(item => item.StreamUrl == newItem.StreamUrl); + await SendNextTrackMessage(newItemIndex, CancellationToken.None); + if (EnableClientSideSeek(newItem.StreamInfo) && newPosition > 0) { await SeekAfterTransportChange(newPosition, CancellationToken.None).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index a72a87462..57e040338 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -9,6 +9,7 @@ <ProjectReference Include="..\Emby.Naming\Emby.Naming.csproj" /> <ProjectReference Include="..\Emby.Notifications\Emby.Notifications.csproj" /> <ProjectReference Include="..\Jellyfin.Api\Jellyfin.Api.csproj" /> + <ProjectReference Include="..\Jellyfin.Server.Implementations\Jellyfin.Server.Implementations.csproj" /> <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" /> <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" /> <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" /> diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs index 667e46613..8aa605a90 100644 --- a/Emby.Server.Implementations/Library/UserDataManager.cs +++ b/Emby.Server.Implementations/Library/UserDataManager.cs @@ -222,7 +222,7 @@ namespace Emby.Server.Implementations.Library var hasRuntime = runtimeTicks > 0; // If a position has been reported, and if we know the duration - if (positionTicks > 0 && hasRuntime && !(item is AudioBook)) + if (positionTicks > 0 && hasRuntime && item is not AudioBook && item is not Book) { var pctIn = decimal.Divide(positionTicks, runtimeTicks) * 100; @@ -241,7 +241,7 @@ namespace Emby.Server.Implementations.Library { // Enforce MinResumeDuration var durationSeconds = TimeSpan.FromTicks(runtimeTicks).TotalSeconds; - if (durationSeconds < _config.Configuration.MinResumeDurationSeconds && !(item is Book)) + if (durationSeconds < _config.Configuration.MinResumeDurationSeconds) { positionTicks = 0; data.Played = playedToCompletion = true; diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index f8f595faa..65964f6d9 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -117,5 +117,7 @@ "TaskRefreshChannels": "Refresh Channels", "TaskRefreshChannelsDescription": "Refreshes internet channel information.", "TaskDownloadMissingSubtitles": "Download missing subtitles", - "TaskDownloadMissingSubtitlesDescription": "Searches the internet for missing subtitles based on metadata configuration." + "TaskDownloadMissingSubtitlesDescription": "Searches the internet for missing subtitles based on metadata configuration.", + "TaskOptimizeDatabase": "Optimize database", + "TaskOptimizeDatabaseDescription": "Compacts database and truncates free space. Running this task after scanning the library or doing other changes that imply database modifications might improve performance." } diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index 16fde325f..91939843f 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -118,5 +118,7 @@ "TaskCleanActivityLog": "Limpiar registro de actividad", "Undefined": "Indefinido", "Forced": "Forzado", - "Default": "Predeterminado" + "Default": "Predeterminado", + "TaskOptimizeDatabase": "Optimizar la base de datos", + "TaskOptimizeDatabaseDescription": "Compacta y libera el espacio libre en la base de datos. Ejecutar esta tarea tras escanear la biblioteca o hacer cambios que impliquen modificaciones en la base de datos puede mejorar el rendimiento." } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs new file mode 100644 index 000000000..1ad1d0f50 --- /dev/null +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Server.Implementations; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.ScheduledTasks.Tasks +{ + /// <summary> + /// Optimizes Jellyfin's database by issuing a VACUUM command. + /// </summary> + public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask + { + private readonly ILogger<OptimizeDatabaseTask> _logger; + private readonly ILocalizationManager _localization; + private readonly JellyfinDbProvider _provider; + + /// <summary> + /// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class. + /// </summary> + public OptimizeDatabaseTask( + ILogger<OptimizeDatabaseTask> logger, + ILocalizationManager localization, + JellyfinDbProvider provider) + { + _logger = logger; + _localization = localization; + _provider = provider; + } + + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("TaskOptimizeDatabase"); + + /// <inheritdoc /> + public string Description => _localization.GetLocalizedString("TaskOptimizeDatabaseDescription"); + + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory"); + + /// <inheritdoc /> + public string Key => "OptimizeDatabaseTask"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => true; + + /// <inheritdoc /> + public bool IsLogged => true; + + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + /// <returns>IEnumerable{BaseTaskTrigger}.</returns> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return new[] + { + // Every so often + new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks } + }; + } + + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public Task Execute(CancellationToken cancellationToken, IProgress<double> progress) + { + _logger.LogInformation("Optimizing and vacuuming jellyfin.db..."); + + try + { + using var context = _provider.CreateContext(); + if (context.Database.IsSqlite()) + { + context.Database.ExecuteSqlRaw("PRAGMA optimize"); + context.Database.ExecuteSqlRaw("VACUUM"); + _logger.LogInformation("jellyfin.db optimized successfully!"); + } + else + { + _logger.LogInformation("This database doesn't support optimization"); + } + } + catch (Exception e) + { + _logger.LogError(e, "Error while optimizing jellyfin.db"); + } + + return Task.CompletedTask; + } + } +} diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index ef467da7e..62df354fd 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -1542,23 +1542,26 @@ namespace Emby.Server.Implementations.Session Limit = 1 }).Items.FirstOrDefault(); - var allExistingForDevice = _authRepo.Get( - new AuthenticationInfoQuery - { - DeviceId = deviceId - }).Items; - - foreach (var auth in allExistingForDevice) + if (!string.IsNullOrEmpty(deviceId)) { - if (existing == null || !string.Equals(auth.AccessToken, existing.AccessToken, StringComparison.Ordinal)) - { - try + var allExistingForDevice = _authRepo.Get( + new AuthenticationInfoQuery { - Logout(auth); - } - catch (Exception ex) + DeviceId = deviceId + }).Items; + + foreach (var auth in allExistingForDevice) + { + if (existing == null || !string.Equals(auth.AccessToken, existing.AccessToken, StringComparison.Ordinal)) { - _logger.LogError(ex, "Error while logging out."); + try + { + Logout(auth); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while logging out."); + } } } } diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index a837f09ca..af453d148 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -154,7 +154,7 @@ namespace Emby.Server.Implementations.TV return i.Item1 != DateTime.MinValue; } - if (alwaysEnableFirstEpisode || i.Item1 != DateTime.MinValue) + if (alwaysEnableFirstEpisode || (i.Item1 != DateTime.MinValue && i.Item1.Date >= request.NextUpDateCutoff)) { anyFound = true; return true; diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index 59400db2a..ffb726fab 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -65,6 +65,7 @@ namespace Jellyfin.Api.Controllers /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="nextUpDateCutoff">Optional. Starting date of shows to show in Next Up section.</param> /// <param name="enableTotalRecordCount">Whether to enable the total records count. Defaults to true.</param> /// <param name="disableFirstEpisode">Whether to disable sending the first episode in a series as next up.</param> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns> @@ -81,6 +82,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, + [FromQuery] DateTime? nextUpDateCutoff, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool disableFirstEpisode = false) { @@ -97,7 +99,8 @@ namespace Jellyfin.Api.Controllers StartIndex = startIndex, UserId = userId ?? Guid.Empty, EnableTotalRecordCount = enableTotalRecordCount, - DisableFirstEpisode = disableFirstEpisode + DisableFirstEpisode = disableFirstEpisode, + NextUpDateCutoff = nextUpDateCutoff ?? DateTime.MinValue }, options); diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index c10b2ddb3..3a3d7415b 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -12,10 +12,12 @@ using System.Threading.Tasks; using CommandLine; using Emby.Server.Implementations; using Emby.Server.Implementations.IO; +using Jellyfin.Server.Implementations; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Extensions; using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -220,6 +222,14 @@ namespace Jellyfin.Server } finally { + _logger.LogInformation("Running query planner optimizations in the database... This might take a while"); + // Run before disposing the application + using var context = new JellyfinDbProvider(appHost.ServiceProvider, appPaths).CreateContext(); + if (context.Database.IsSqlite()) + { + context.Database.ExecuteSqlRaw("PRAGMA optimize"); + } + appHost.Dispose(); } diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs index bc34785ee..30e2ac42f 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs @@ -274,6 +274,16 @@ namespace MediaBrowser.Controller.MediaEncoding public int? GetRequestedAudioChannels(string codec) { + if (!string.IsNullOrEmpty(codec)) + { + var value = BaseRequest.GetOption(codec, "audiochannels"); + if (!string.IsNullOrEmpty(value) + && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + { + return result; + } + } + if (BaseRequest.MaxAudioChannels.HasValue) { return BaseRequest.MaxAudioChannels; @@ -289,16 +299,6 @@ namespace MediaBrowser.Controller.MediaEncoding return BaseRequest.TranscodingMaxAudioChannels; } - if (!string.IsNullOrEmpty(codec)) - { - var value = BaseRequest.GetOption(codec, "audiochannels"); - if (!string.IsNullOrEmpty(value) - && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) - { - return result; - } - } - return null; } diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index e644c9ba7..c67f30d04 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -104,6 +104,19 @@ namespace MediaBrowser.Model.Entities return "HDR"; } + // For some Dolby Vision files, no color transfer is provided, so check the codec + + var codecTag = CodecTag; + + if (string.Equals(codecTag, "dva1", StringComparison.OrdinalIgnoreCase) + || string.Equals(codecTag, "dvav", StringComparison.OrdinalIgnoreCase) + || string.Equals(codecTag, "dvh1", StringComparison.OrdinalIgnoreCase) + || string.Equals(codecTag, "dvhe", StringComparison.OrdinalIgnoreCase) + || string.Equals(codecTag, "dav1", StringComparison.OrdinalIgnoreCase)) + { + return "HDR"; + } + return "SDR"; } } diff --git a/MediaBrowser.Model/Querying/NextUpQuery.cs b/MediaBrowser.Model/Querying/NextUpQuery.cs index 0555afc00..fa8aa829d 100644 --- a/MediaBrowser.Model/Querying/NextUpQuery.cs +++ b/MediaBrowser.Model/Querying/NextUpQuery.cs @@ -13,6 +13,7 @@ namespace MediaBrowser.Model.Querying EnableImageTypes = Array.Empty<ImageType>(); EnableTotalRecordCount = true; DisableFirstEpisode = false; + NextUpDateCutoff = DateTime.MinValue; } /// <summary> @@ -75,5 +76,10 @@ namespace MediaBrowser.Model.Querying /// Gets or sets a value indicating whether do disable sending first episode as next up. /// </summary> public bool DisableFirstEpisode { get; set; } + + /// <summary> + /// Gets or sets a value indicating the oldest date for a show to appear in Next Up. + /// </summary> + public DateTime NextUpDateCutoff { get; set; } } } |
