Add new feature, set file date to episode aired date. Fix, use alternative Trakt API field for episode air time. Improve the Preview Rename tip.

Add, new setting "Set File Date to Airdate" on the Media Management tab of the Settings page to toggle this feature for new, imported and auto updating media files.

Change, home page "Series Editor" - "Rename" button to "Update Files" and add "Set File Date To Air Date" action button to this modal to add capability of updating legacy media.

Add, non UTC functions given that Windows undesirably adds time to file times set when using UTC.

Fix, the Trakt API response show.air_time_utc contains erroneous data, this is replaced with show.air_time.
This commit is contained in:
JackDandy 2014-03-08 19:01:51 +00:00
parent 9d74693bb7
commit d9eab04029
24 changed files with 316 additions and 38 deletions

View File

@ -6,6 +6,7 @@ namespace NzbDrone.Api.Config
public class MediaManagementConfigResource : RestResource
{
public Boolean AutoUnmonitorPreviouslyDownloadedEpisodes { get; set; }
public Boolean FileDateAiredDate { get; set; }
public String RecycleBin { get; set; }
public Boolean AutoDownloadPropers { get; set; }
public Boolean CreateEmptySeriesFolders { get; set; }

View File

@ -35,7 +35,7 @@ namespace NzbDrone.Api.Logs
{
Id = i + 1,
Filename = Path.GetFileName(file),
LastWriteTime = _diskProvider.GetLastFileWrite(file)
LastWriteTime = _diskProvider.GetLastFileWriteUTC(file)
});
}

View File

@ -77,6 +77,20 @@ namespace NzbDrone.Common.Disk
}
public DateTime GetLastFileWrite(string path)
{
PathEnsureFileExists(path);
return new FileInfo(path).LastWriteTime;
}
public DateTime GetLastFileWriteUTC(string path)
{
PathEnsureFileExists(path);
return new FileInfo(path).LastWriteTimeUtc;
}
private void PathEnsureFileExists(string path)
{
Ensure.That(path, () => path).IsValidPath();
@ -84,8 +98,6 @@ namespace NzbDrone.Common.Disk
{
throw new FileNotFoundException("File doesn't exist: " + path);
}
return new FileInfo(path).LastWriteTimeUtc;
}
public void EnsureFolder(string path)
@ -305,6 +317,26 @@ namespace NzbDrone.Common.Disk
Directory.SetLastWriteTimeUtc(path, dateTime);
}
public void FileSetLastWriteTime(string path, DateTime dateTime)
{
Ensure.That(path, () => path).IsValidPath();
File.SetLastWriteTime(path, dateTime);
}
public void FileSetLastAccessTime(string path, DateTime dateTime)
{
Ensure.That(path, () => path).IsValidPath();
File.SetLastAccessTimeUtc(path, dateTime);
}
public void FileSetLastAccessTimeUtc(string path, DateTime dateTime)
{
Ensure.That(path, () => path).IsValidPath();
File.SetLastAccessTimeUtc(path, dateTime);
}
public bool IsFileLocked(string file)
{
try

View File

@ -14,6 +14,7 @@ namespace NzbDrone.Common.Disk
DateTime GetLastFolderWrite(string path);
DateTime GetLastFileWrite(string path);
DateTime GetLastFileWriteUTC(string path);
void EnsureFolder(string path);
bool FolderExists(string path);
bool FileExists(string path);
@ -33,6 +34,8 @@ namespace NzbDrone.Common.Disk
void WriteAllText(string filename, string contents);
void FileSetLastWriteTimeUtc(string path, DateTime dateTime);
void FolderSetLastWriteTimeUtc(string path, DateTime dateTime);
void FileSetLastWriteTime(string path, DateTime dateTime);
void FileSetLastAccessTime(string path, DateTime dateTime);
bool IsFileLocked(string path);
string GetPathRoot(string path);
string GetParentFolder(string path);

View File

@ -29,7 +29,7 @@ namespace NzbDrone.Core.Test.MediaCoverTests
new MediaCover.MediaCover {CoverType = MediaCoverTypes.Banner}
};
Mocker.GetMock<IDiskProvider>().Setup(c => c.GetLastFileWrite(It.IsAny<string>()))
Mocker.GetMock<IDiskProvider>().Setup(c => c.GetLastFileWriteUTC(It.IsAny<string>()))
.Returns(new DateTime(1234));
Mocker.GetMock<IDiskProvider>().Setup(c => c.FileExists(It.IsAny<string>()))

View File

@ -42,7 +42,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications
private void GivenLastWriteTimeUtc(DateTime time)
{
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.GetLastFileWrite(It.IsAny<string>()))
.Setup(s => s.GetLastFileWriteUTC(It.IsAny<string>()))
.Returns(time);
}

View File

@ -21,7 +21,7 @@ namespace NzbDrone.Core.Test.ProviderTests.RecycleBinProviderTests
Mocker.GetMock<IDiskProvider>().Setup(s => s.GetLastFolderWrite(It.IsAny<String>()))
.Returns(DateTime.UtcNow.AddDays(-10));
Mocker.GetMock<IDiskProvider>().Setup(s => s.GetLastFileWrite(It.IsAny<String>()))
Mocker.GetMock<IDiskProvider>().Setup(s => s.GetLastFileWriteUTC(It.IsAny<String>()))
.Returns(DateTime.UtcNow.AddDays(-10));
}
@ -30,7 +30,7 @@ namespace NzbDrone.Core.Test.ProviderTests.RecycleBinProviderTests
Mocker.GetMock<IDiskProvider>().Setup(s => s.GetLastFolderWrite(It.IsAny<String>()))
.Returns(DateTime.UtcNow.AddDays(-3));
Mocker.GetMock<IDiskProvider>().Setup(s => s.GetLastFileWrite(It.IsAny<String>()))
Mocker.GetMock<IDiskProvider>().Setup(s => s.GetLastFileWriteUTC(It.IsAny<String>()))
.Returns(DateTime.UtcNow.AddDays(-3));
}

View File

@ -86,6 +86,12 @@ namespace NzbDrone.Core.Configuration
set { SetValue("AutoUnmonitorPreviouslyDownloadedEpisodes", value); }
}
public bool FileDateAiredDate
{
get { return GetValueBoolean("FileDateAiredDate"); }
set { SetValue("FileDateAiredDate", value); }
}
public int Retention
{
get { return GetValueInt("Retention", 0); }

View File

@ -20,6 +20,7 @@ namespace NzbDrone.Core.Configuration
//Media Management
Boolean AutoUnmonitorPreviouslyDownloadedEpisodes { get; set; }
Boolean FileDateAiredDate { get; set; }
String RecycleBin { get; set; }
Boolean AutoDownloadPropers { get; set; }
Boolean CreateEmptySeriesFolders { get; set; }

View File

@ -67,7 +67,7 @@ namespace NzbDrone.Core.MediaCover
if (_diskProvider.FileExists(filePath))
{
var lastWrite = _diskProvider.GetLastFileWrite(filePath);
var lastWrite = _diskProvider.GetLastFileWriteUTC(filePath);
mediaCover.Url += "?lastWrite=" + lastWrite.Ticks;
}
}

View File

@ -0,0 +1,18 @@
using System.Collections.Generic;
using NzbDrone.Core.Messaging.Commands;
namespace NzbDrone.Core.MediaFiles.Commands
{
public class AirDateSeriesCommand : Command
{
public List<int> SeriesIds { get; set; }
public override bool SendUpdatesToClient
{
get
{
return true;
}
}
}
}

View File

@ -22,18 +22,21 @@ namespace NzbDrone.Core.MediaFiles
public class EpisodeFileMovingService : IMoveEpisodeFiles
{
private readonly IEpisodeService _episodeService;
private readonly IUpdateEpisodeFileService _updateEpisodeFileService;
private readonly IBuildFileNames _buildFileNames;
private readonly IDiskProvider _diskProvider;
private readonly IConfigService _configService;
private readonly Logger _logger;
public EpisodeFileMovingService(IEpisodeService episodeService,
IUpdateEpisodeFileService updateEpisodeFileService,
IBuildFileNames buildFileNames,
IDiskProvider diskProvider,
IConfigService configService,
Logger logger)
{
_episodeService = episodeService;
_updateEpisodeFileService = updateEpisodeFileService;
_buildFileNames = buildFileNames;
_diskProvider = diskProvider;
_configService = configService;
@ -102,6 +105,11 @@ namespace NzbDrone.Core.MediaFiles
_diskProvider.MoveFile(episodeFile.Path, destinationFilename);
episodeFile.Path = destinationFilename;
if (_configService.FileDateAiredDate)
{
_updateEpisodeFileService.ChangeFileDateToAirdate(episodeFile, series);
}
try
{
_logger.Trace("Setting last write time on series folder: {0}", series.Path);

View File

@ -42,7 +42,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications
return false;
}
if (_diskProvider.GetLastFileWrite(localEpisode.Path) > DateTime.UtcNow.AddMinutes(-5))
if (_diskProvider.GetLastFileWriteUTC(localEpisode.Path) > DateTime.UtcNow.AddMinutes(-5))
{
_logger.Trace("{0} appears to be unpacking still", localEpisode.Path);
return false;

View File

@ -0,0 +1,15 @@
using NzbDrone.Common.Messaging;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.MediaFiles.Events
{
public class SeriesAirDatedEvent : IEvent
{
public Series Series { get; private set; }
public SeriesAirDatedEvent(Series series)
{
Series = series;
}
}
}

View File

@ -139,7 +139,7 @@ namespace NzbDrone.Core.MediaFiles
foreach (var file in _diskProvider.GetFiles(_configService.RecycleBin, SearchOption.TopDirectoryOnly))
{
if (_diskProvider.GetLastFileWrite(file).AddDays(7) > DateTime.UtcNow)
if (_diskProvider.GetLastFileWriteUTC(file).AddDays(7) > DateTime.UtcNow)
{
logger.Trace("File hasn't expired yet, skipping: {0}", file);
continue;

View File

@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Instrumentation;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Instrumentation;
using NzbDrone.Core.MediaFiles.Commands;
using NzbDrone.Core.MediaFiles.Events;
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.MediaFiles
{
public interface IUpdateEpisodeFileService
{
void ChangeFileDateToAirdate(EpisodeFile episodeFile, Series series);
}
public class UpdateEpisodeFileService : IUpdateEpisodeFileService,
IExecute<AirDateSeriesCommand>,
IHandle<SeriesScannedEvent>
{
private readonly IDiskProvider _diskProvider;
private readonly IConfigService _configService;
private readonly ISeriesService _seriesService;
private readonly IEpisodeService _episodeService;
private readonly IEventAggregator _eventAggregator;
private readonly Logger _logger;
public UpdateEpisodeFileService(IDiskProvider diskProvider,
IConfigService configService,
ISeriesService seriesService,
IEpisodeService episodeService,
IEventAggregator eventAggregator,
Logger logger)
{
_diskProvider = diskProvider;
_configService = configService;
_seriesService = seriesService;
_episodeService = episodeService;
_eventAggregator = eventAggregator;
_logger = logger;
}
public void ChangeFileDateToAirdate(EpisodeFile episodeFile, Series series)
{
var episode = new Episode();
episode.AirDate = episodeFile.Episodes.Value.First().AirDate;
episode.EpisodeFile = episodeFile;
episode.EpisodeFileId = 1;
var episodes = new List<Episode>();
episodes.Add(episode);
ChangeFileDateToAirdate(episodes, series);
}
private void ChangeFileDateToAirdate(List<Episode> episodes, Series series)
{
if (!episodes.Any())
{
_logger.ProgressDebug("{0} has no media files available to update with air dates", series.Title);
}
else
{
var done = new List<Episode>();
_logger.ProgressDebug("{0} ... checking {1} media file dates match air date", series.Title, episodes.Count);
foreach (var episode in episodes)
{
if (episode.HasFile
&& episode.EpisodeFile.IsLoaded
&& ChangeFileDate(episode.EpisodeFile.Value.Path, episode.AirDate, series.AirTime))
{
done.Add(episode);
}
}
if (done.Any())
{
_eventAggregator.PublishEvent(new SeriesAirDatedEvent(series));
_logger.ProgressDebug("{0} had {1} of {2} media file dates changed to the date and time the episode aired", series.Title, done.Count, episodes.Count);
}
else
{
_logger.ProgressDebug("{0} has all its media file dates matching the date each aired", series.Title);
}
}
}
public void Execute(AirDateSeriesCommand message)
{
var seriesToAirDate = _seriesService.GetSeries(message.SeriesIds);
foreach (var series in seriesToAirDate)
{
var episodes = _episodeService.EpisodesWithFiles(series.Id);
ChangeFileDateToAirdate(episodes, series);
}
}
public void Handle(SeriesScannedEvent message)
{
if (_configService.FileDateAiredDate)
{
var episodes = _episodeService.EpisodesWithFiles(message.Series.Id);
ChangeFileDateToAirdate(episodes, message.Series);
}
}
private bool ChangeFileDate(String filePath, String fileDate, String fileTime)
{
DateTime dateTime, oldDateTime;
bool result = false;
if (DateTime.TryParse(fileDate + ' ' + fileTime, out dateTime))
{
// avoiding false +ve checks and set date skewing by not using UTC (Windows)
oldDateTime = _diskProvider.GetLastFileWrite(filePath);
if (!DateTime.Equals(dateTime, oldDateTime))
{
try
{
_diskProvider.FileSetLastWriteTime(filePath, dateTime);
_diskProvider.FileSetLastAccessTime(filePath, dateTime);
_logger.Info("Date of file [{0}] changed from \"{1}\" to \"{2}\"", filePath, oldDateTime, dateTime);
result = true;
}
catch (Exception ex)
{
_logger.WarnException("Unable to set date of file [" + filePath + "]", ex);
}
}
}
else
{
_logger.Warn("Could not create valid date to set [{0}]", filePath);
}
return result;
}
}
}

View File

@ -78,7 +78,7 @@ namespace NzbDrone.Core.MetadataSource
series.Overview = show.overview;
series.Runtime = show.runtime;
series.Network = show.network;
series.AirTime = show.air_time_utc;
series.AirTime = show.air_time;
series.TitleSlug = show.url.ToLower().Replace("http://trakt.tv/show/", "");
series.Status = GetSeriesStatus(show.status, show.ended);
series.Ratings = GetRatings(show.ratings);

View File

@ -308,6 +308,7 @@
<Compile Include="Instrumentation\Commands\DeleteLogFilesCommand.cs" />
<Compile Include="Instrumentation\Commands\TrimLogCommand.cs" />
<Compile Include="Instrumentation\DeleteLogFilesService.cs" />
<Compile Include="MediaFiles\Commands\AirDateSeriesCommand.cs" />
<Compile Include="MediaFiles\Commands\RenameSeriesCommand.cs" />
<Compile Include="MediaFiles\Commands\RescanSeriesCommand.cs" />
<Compile Include="Lifecycle\Commands\ShutdownCommand.cs" />
@ -318,9 +319,11 @@
<Compile Include="MediaFiles\EpisodeFileMoveResult.cs" />
<Compile Include="MediaFiles\EpisodeImport\Specifications\FullSeasonSpecification.cs" />
<Compile Include="MediaFiles\Events\SeriesScannedEvent.cs" />
<Compile Include="MediaFiles\Events\SeriesAirDatedEvent.cs" />
<Compile Include="MediaFiles\MediaFileExtensions.cs" />
<Compile Include="MediaFiles\MediaInfo\VideoFileInfoReader.cs" />
<Compile Include="MediaFiles\RenameEpisodeFilePreview.cs" />
<Compile Include="MediaFiles\UpdateEpisodeFileService.cs" />
<Compile Include="Messaging\Commands\CommandExecutor.cs" />
<Compile Include="Messaging\Commands\ICommandExecutor.cs" />
<Compile Include="Messaging\Commands\IExecute.cs" />

View File

@ -10,7 +10,7 @@ define(
'AddSeries/RootFolders/RootFolderCollection',
'Shared/Toolbar/ToolbarLayout',
'AddSeries/RootFolders/RootFolderLayout',
'Series/Editor/Rename/RenameSeriesView',
'Series/Editor/UpdateFiles/UpdateFilesSeriesView',
'Config'
], function (_,
Marionette,
@ -21,26 +21,26 @@ define(
RootFolders,
ToolbarLayout,
RootFolderLayout,
RenameSeriesView,
UpdateFilesSeriesView,
Config) {
return Marionette.ItemView.extend({
template: 'Series/Editor/SeriesEditorFooterViewTemplate',
ui: {
monitored : '.x-monitored',
qualityProfile: '.x-quality-profiles',
seasonFolder : '.x-season-folder',
rootFolder : '.x-root-folder',
selectedCount : '.x-selected-count',
saveButton : '.x-save',
renameButton : '.x-rename',
container : '.series-editor-footer'
monitored : '.x-monitored',
qualityProfile : '.x-quality-profiles',
seasonFolder : '.x-season-folder',
rootFolder : '.x-root-folder',
selectedCount : '.x-selected-count',
saveButton : '.x-save',
updateFilesButton: '.x-update-files',
container : '.series-editor-footer'
},
events: {
'click .x-save' : '_updateAndSave',
'change .x-root-folder': '_rootFolderChanged',
'click .x-rename' : '_rename'
'click .x-update-files': '_updateFiles'
},
templateHelpers: function () {
@ -119,7 +119,7 @@ define(
this.ui.seasonFolder.attr('disabled', '');
this.ui.rootFolder.attr('disabled', '');
this.ui.saveButton.attr('disabled', '');
this.ui.renameButton.attr('disabled', '');
this.ui.updateFilesButton.attr('disabled', '');
}
else {
@ -128,7 +128,7 @@ define(
this.ui.seasonFolder.removeAttr('disabled', '');
this.ui.rootFolder.removeAttr('disabled', '');
this.ui.saveButton.removeAttr('disabled', '');
this.ui.renameButton.removeAttr('disabled', '');
this.ui.updateFilesButton.removeAttr('disabled', '');
}
},
@ -162,12 +162,12 @@ define(
});
},
_rename: function () {
_updateFiles: function () {
var selected = this.editorGrid.getSelectedModels();
var renameSeriesView = new RenameSeriesView({ series: selected });
this.listenToOnce(renameSeriesView, 'seriesRenamed', this._afterSave);
var updateFilesSeriesView = new UpdateFilesSeriesView({ series: selected });
this.listenToOnce(updateFilesSeriesView, 'updatingFiles', this._afterSave);
vent.trigger(vent.Commands.OpenModalCommand, renameSeriesView);
vent.trigger(vent.Commands.OpenModalCommand, updateFilesSeriesView);
}
});
});

View File

@ -45,7 +45,7 @@
<span class="pull-right">
<span class="selected-count x-selected-count">0 series selected</span>
<button class="btn btn-primary x-save">Save</button>
<button class="btn btn-danger x-rename">Rename</button>
<button class="btn btn-danger x-update-files">Update Files</button>
</span>
</div>
</div>

View File

@ -9,10 +9,11 @@ define(
], function (_, vent, Backbone, Marionette, CommandController) {
return Marionette.ItemView.extend({
template: 'Series/Editor/Rename/RenameSeriesViewTemplate',
template: 'Series/Editor/UpdateFiles/UpdateFilesSeriesViewTemplate',
events: {
'click .x-confirm-rename': '_rename'
'click .x-confirm-rename': '_rename',
'click .x-confirm-airdate': '_setFileAirDate'
},
initialize: function (options) {
@ -29,7 +30,19 @@ define(
seriesIds : seriesIds
});
this.trigger('seriesRenamed');
this.trigger('updatingFiles');
vent.trigger(vent.Commands.CloseModalCommand);
},
_setFileAirDate: function () {
var seriesIds = _.pluck(this.series, 'id');
CommandController.Execute('AirDateSeries', {
name: 'AirDateSeries',
seriesIds: seriesIds
});
this.trigger('updatingFiles');
vent.trigger(vent.Commands.CloseModalCommand);
}
});

View File

@ -1,23 +1,24 @@
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h3>Rename Selected Series</h3>
<h3>Update Files of Selected Series</h3>
</div>
<div class="modal-body rename-series-modal">
<div class="modal-body update-files-series-modal">
<div class="alert alert-info">
<button type="button" class="close" data-dismiss="alert">&times;</button>
You can use the rename function for an individual series to preview the rename
Tip: To preview a rename... select "Cancel" then any series title and use the <i data-original-title="" class="icon-nd-rename" title=""></i>
</div>
Are you sure you want to rename all files in the {{numberOfSeries}} selected series?
Are you sure you want to update all files in the {{numberOfSeries}} selected series?
{{debug}}
<ul class="selected-series">
{{#each series}}
<li>{{title}}</li>
<li>{{title}}</li>
{{/each}}
</ul>
</div>
<div class="modal-footer">
<button class="btn" data-dismiss="modal">cancel</button>
<button class="btn btn-danger x-confirm-rename">rename</button>
<button class="btn btn-danger x-confirm-airdate">set file date to air date</button>
</div>

View File

@ -314,9 +314,13 @@
.row {
margin-left: -40px;
}
.span2 {
width: 160px;
}
}
.rename-series-modal {
.update-files-series-modal {
.selected-series {
margin-top: 15px;
}

View File

@ -21,6 +21,23 @@
</div>
</div>
<div class="control-group">
<label class="control-label">Set File Date to Airdate</label>
<div class="controls">
<label class="checkbox toggle well">
<input type="checkbox" name="fileDateAiredDate" />
<p>
<span>Yes</span>
<span>No</span>
</p>
<div class="btn btn-primary slide-button" />
</label>
<span class="help-inline-checkbox">
<i class="icon-nd-form-info" title="Adjust added media file dates to the original episode aired date" />
</span>
</div>
</div>
<div class="control-group">
<label class="control-label">Download Propers</label>