First implementation of completely rewriting the way Radarr handles movies. Searching for new movies is now mostly feature complete.

This commit is contained in:
Leonardo Galli 2016-12-29 14:06:51 +01:00
parent 0b278c7db8
commit 40d7590f80
53 changed files with 1845 additions and 96 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@ -19,13 +19,15 @@ gulp.task('less', function() {
paths.src.root + 'Series/series.less',
paths.src.root + 'Activity/activity.less',
paths.src.root + 'AddSeries/addSeries.less',
paths.src.root + 'AddMovies/addMovies.less',
paths.src.root + 'Calendar/calendar.less',
paths.src.root + 'Cells/cells.less',
paths.src.root + 'ManualImport/manualimport.less',
paths.src.root + 'Settings/settings.less',
paths.src.root + 'System/Logs/logs.less',
paths.src.root + 'System/Update/update.less',
paths.src.root + 'System/Info/info.less'
paths.src.root + 'System/Info/info.less',
];
return gulp.src(src)

BIN
src/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -231,8 +231,10 @@
<Compile Include="Series\SeasonResource.cs" />
<Compile Include="SeasonPass\SeasonPassModule.cs" />
<Compile Include="Series\SeriesEditorModule.cs" />
<Compile Include="Series\MovieLookupModule.cs" />
<Compile Include="Series\SeriesLookupModule.cs" />
<Compile Include="Series\SeriesModule.cs" />
<Compile Include="Series\MovieResource.cs" />
<Compile Include="Series\SeriesResource.cs" />
<Compile Include="Series\SeasonStatisticsResource.cs" />
<Compile Include="System\Backup\BackupModule.cs" />

View File

@ -0,0 +1,44 @@
using System.Collections.Generic;
using Nancy;
using NzbDrone.Api.Extensions;
using NzbDrone.Core.MediaCover;
using NzbDrone.Core.MetadataSource;
using System.Linq;
namespace NzbDrone.Api.Series
{
public class MovieLookupModule : NzbDroneRestModule<MovieResource>
{
private readonly ISearchForNewMovie _searchProxy;
public MovieLookupModule(ISearchForNewMovie searchProxy)
: base("/movies/lookup")
{
_searchProxy = searchProxy;
Get["/"] = x => Search();
}
private Response Search()
{
var imdbResults = _searchProxy.SearchForNewMovie((string)Request.Query.term);
return MapToResource(imdbResults).AsResponse();
}
private static IEnumerable<MovieResource> MapToResource(IEnumerable<Core.Tv.Movie> movies)
{
foreach (var currentSeries in movies)
{
var resource = currentSeries.ToResource();
var poster = currentSeries.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Poster);
if (poster != null)
{
resource.RemotePoster = poster.Url;
}
yield return resource;
}
}
}
}

View File

@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Api.REST;
using NzbDrone.Core.MediaCover;
using NzbDrone.Core.Tv;
namespace NzbDrone.Api.Series
{
public class MovieResource : RestResource
{
public MovieResource()
{
Monitored = true;
}
//Todo: Sorters should be done completely on the client
//Todo: Is there an easy way to keep IgnoreArticlesWhenSorting in sync between, Series, History, Missing?
//Todo: We should get the entire Profile instead of ID and Name separately
//View Only
public string Title { get; set; }
public List<AlternateTitleResource> AlternateTitles { get; set; }
public string SortTitle { get; set; }
public long? SizeOnDisk { get; set; }
public MovieStatusType Status { get; set; }
public string Overview { get; set; }
public DateTime? InCinemas { get; set; }
public List<MediaCover> Images { get; set; }
public string RemotePoster { get; set; }
public int Year { get; set; }
//View & Edit
public string Path { get; set; }
public int ProfileId { get; set; }
//Editing Only
public bool Monitored { get; set; }
public int Runtime { get; set; }
public DateTime? LastInfoSync { get; set; }
public string CleanTitle { get; set; }
public string ImdbId { get; set; }
public string TitleSlug { get; set; }
public string RootFolderPath { get; set; }
public string Certification { get; set; }
public List<string> Genres { get; set; }
public HashSet<int> Tags { get; set; }
public DateTime Added { get; set; }
public Ratings Ratings { get; set; }
//TODO: Add series statistics as a property of the series (instead of individual properties)
//Used to support legacy consumers
public int QualityProfileId
{
get
{
return ProfileId;
}
set
{
if (value > 0 && ProfileId == 0)
{
ProfileId = value;
}
}
}
}
public static class MovieResourceMapper
{
public static MovieResource ToResource(this Core.Tv.Movie model)
{
if (model == null) return null;
return new MovieResource
{
Id = model.Id,
Title = model.Title,
//AlternateTitles
SortTitle = model.SortTitle,
InCinemas = model.InCinemas,
//TotalEpisodeCount
//EpisodeCount
//EpisodeFileCount
//SizeOnDisk
Status = model.Status,
Overview = model.Overview,
//NextAiring
//PreviousAiring
Images = model.Images,
Year = model.Year,
Path = model.Path,
ProfileId = model.ProfileId,
Monitored = model.Monitored,
Runtime = model.Runtime,
LastInfoSync = model.LastInfoSync,
CleanTitle = model.CleanTitle,
ImdbId = model.ImdbId,
TitleSlug = model.TitleSlug,
RootFolderPath = model.RootFolderPath,
Certification = model.Certification,
Genres = model.Genres,
Tags = model.Tags,
Added = model.Added,
Ratings = model.Ratings
};
}
public static Core.Tv.Movie ToModel(this MovieResource resource)
{
if (resource == null) return null;
return new Core.Tv.Movie
{
Id = resource.Id,
Title = resource.Title,
//AlternateTitles
SortTitle = resource.SortTitle,
InCinemas = resource.InCinemas,
//TotalEpisodeCount
//EpisodeCount
//EpisodeFileCount
//SizeOnDisk
Overview = resource.Overview,
//NextAiring
//PreviousAiring
Images = resource.Images,
Year = resource.Year,
Path = resource.Path,
ProfileId = resource.ProfileId,
Monitored = resource.Monitored,
Runtime = resource.Runtime,
LastInfoSync = resource.LastInfoSync,
CleanTitle = resource.CleanTitle,
ImdbId = resource.ImdbId,
TitleSlug = resource.TitleSlug,
RootFolderPath = resource.RootFolderPath,
Certification = resource.Certification,
Genres = resource.Genres,
Tags = resource.Tags,
Added = resource.Added,
Ratings = resource.Ratings
};
}
public static Core.Tv.Movie ToModel(this MovieResource resource, Core.Tv.Movie movie)
{
movie.ImdbId = resource.ImdbId;
movie.Path = resource.Path;
movie.ProfileId = resource.ProfileId;
movie.Monitored = resource.Monitored;
movie.RootFolderPath = resource.RootFolderPath;
movie.Tags = resource.Tags;
return movie;
}
public static List<MovieResource> ToResource(this IEnumerable<Core.Tv.Movie> movies)
{
return movies.Select(ToResource).ToList();
}
}
}

View File

@ -0,0 +1,27 @@
using NzbDrone.Common.Exceptions;
namespace NzbDrone.Core.Exceptions
{
public class MovieNotFoundException : NzbDroneException
{
public string ImdbId { get; set; }
public MovieNotFoundException(string imdbid)
: base(string.Format("Movie with imdbid {0} was not found, it may have been removed from IMDb.", imdbid))
{
ImdbId = imdbid;
}
public MovieNotFoundException(string imdbid, string message, params object[] args)
: base(message, args)
{
ImdbId = imdbid;
}
public MovieNotFoundException(string imdbid, string message)
: base(message)
{
ImdbId = imdbid;
}
}
}

View File

@ -0,0 +1,11 @@
using System;
using System.Collections.Generic;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.MetadataSource
{
public interface IProvideMovieInfo
{
Movie GetMovieInfo(string ImdbId);
}
}

View File

@ -0,0 +1,10 @@
using System.Collections.Generic;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.MetadataSource
{
public interface ISearchForNewMovie
{
List<Movie> SearchForNewMovie(string title);
}
}

View File

@ -14,7 +14,7 @@ using Newtonsoft.Json;
namespace NzbDrone.Core.MetadataSource.SkyHook
{
public class SkyHookProxy : IProvideSeriesInfo, ISearchForNewSeries
public class SkyHookProxy : IProvideSeriesInfo, ISearchForNewSeries, IProvideMovieInfo, ISearchForNewMovie
{
private readonly IHttpClient _httpClient;
private readonly Logger _logger;
@ -38,11 +38,7 @@ namespace NzbDrone.Core.MetadataSource.SkyHook
httpRequest.AllowAutoRedirect = true;
httpRequest.SuppressHttpError = true;
string imdbId = string.Format("tt{0:D7}", tvdbSeriesId);
var imdbRequest = new HttpRequest("http://www.omdbapi.com/?i="+ imdbId + "&plot=full&r=json");
var httpResponse = _httpClient.Get(imdbRequest);
var httpResponse = _httpClient.Get<ShowResource>(httpRequest);
if (httpResponse.HasHttpError)
{
@ -56,49 +52,140 @@ namespace NzbDrone.Core.MetadataSource.SkyHook
}
}
var episodes = httpResponse.Resource.Episodes.Select(MapEpisode);
var series = MapSeries(httpResponse.Resource);
return new Tuple<Series, List<Episode>>(series, episodes.ToList());
}
public Movie GetMovieInfo(string ImdbId)
{
var imdbRequest = new HttpRequest("http://www.omdbapi.com/?i=" + ImdbId + "&plot=full&r=json");
var httpResponse = _httpClient.Get(imdbRequest);
if (httpResponse.HasHttpError)
{
if (httpResponse.StatusCode == HttpStatusCode.NotFound)
{
throw new MovieNotFoundException(ImdbId);
}
else
{
throw new HttpException(imdbRequest, httpResponse);
}
}
var response = httpResponse.Content;
dynamic json = JsonConvert.DeserializeObject(response);
var series = new Series();
var movie = new Movie();
series.Title = json.Title;
series.TitleSlug = series.Title.ToLower().Replace(" ", "-");
series.Overview = json.Plot;
series.CleanTitle = Parser.Parser.CleanSeriesTitle(series.Title);
series.TvdbId = tvdbSeriesId;
movie.Title = json.Title;
movie.TitleSlug = movie.Title.ToLower().Replace(" ", "-");
movie.Overview = json.Plot;
movie.CleanTitle = Parser.Parser.CleanSeriesTitle(movie.Title);
string airDateStr = json.Released;
DateTime airDate = DateTime.Parse(airDateStr);
series.FirstAired = airDate;
series.Year = airDate.Year;
series.ImdbId = imdbId;
series.Images = new List<MediaCover.MediaCover>();
movie.InCinemas = airDate;
movie.Year = airDate.Year;
movie.ImdbId = ImdbId;
string imdbRating = json.imdbVotes;
if (imdbRating == "N/A")
{
movie.Status = MovieStatusType.Announced;
}
else
{
movie.Status = MovieStatusType.Released;
}
string url = json.Poster;
var imdbPoster = new MediaCover.MediaCover(MediaCoverTypes.Poster, url);
series.Images.Add(imdbPoster);
movie.Images.Add(imdbPoster);
string runtime = json.Runtime;
int runtimeNum = 0;
int.TryParse(runtime.Replace("min", "").Trim(), out runtimeNum);
series.Runtime = runtimeNum;
movie.Runtime = runtimeNum;
var season = new Season();
season.SeasonNumber = 1;
season.Monitored = true;
series.Seasons.Add(season);
return movie;
}
var episode = new Episode();
public List<Movie> SearchForNewMovie(string title)
{
var lowerTitle = title.ToLower();
episode.AirDate = airDate.ToBestDateString();
episode.Title = json.Title;
episode.SeasonNumber = 1;
episode.EpisodeNumber = 1;
episode.Overview = series.Overview;
episode.AirDate = airDate.ToShortDateString();
if (lowerTitle.StartsWith("imdb:") || lowerTitle.StartsWith("imdbid:"))
{
var slug = lowerTitle.Split(':')[1].Trim();
var episodes = new List<Episode> { episode };
string imdbid = slug;
return new Tuple<Series, List<Episode>>(series, episodes.ToList());
if (slug.IsNullOrWhiteSpace() || slug.Any(char.IsWhiteSpace))
{
return new List<Movie>();
}
try
{
return new List<Movie> { GetMovieInfo(imdbid) };
}
catch (SeriesNotFoundException)
{
return new List<Movie>();
}
}
var searchTerm = lowerTitle.Replace("+", "_").Replace(" ", "_");
var firstChar = searchTerm.First();
var imdbRequest = new HttpRequest("https://v2.sg.media-imdb.com/suggests/" + firstChar + "/" + searchTerm + ".json");
var response = _httpClient.Get(imdbRequest);
var imdbCallback = "imdb$" + searchTerm + "(";
var responseCleaned = response.Content.Replace(imdbCallback, "").TrimEnd(")");
dynamic json = JsonConvert.DeserializeObject(responseCleaned);
var imdbMovies = new List<Movie>();
foreach (dynamic entry in json.d)
{
var imdbMovie = new Movie();
imdbMovie.ImdbId = entry.id;
try
{
imdbMovie.SortTitle = entry.l;
imdbMovie.Title = entry.l;
string titleSlug = entry.l;
imdbMovie.TitleSlug = titleSlug.ToLower().Replace(" ", "-");
imdbMovie.Year = entry.y;
imdbMovie.Images = new List<MediaCover.MediaCover>();
try
{
string url = entry.i[0];
var imdbPoster = new MediaCover.MediaCover(MediaCoverTypes.Poster, url);
imdbMovie.Images.Add(imdbPoster);
}
catch (Exception e)
{
_logger.Debug(entry);
continue;
}
imdbMovies.Add(imdbMovie);
}
catch
{
}
}
return imdbMovies;
}
public List<Series> SearchForNewSeries(string title)
@ -128,70 +215,14 @@ namespace NzbDrone.Core.MetadataSource.SkyHook
}
}
var httpRequest = _requestBuilder.Create()
.SetSegment("route", "search")
.AddQueryParam("term", title.ToLower().Trim())
.Build();
var searchTerm = lowerTitle.Replace("+", "_").Replace(" ", "_");
var firstChar = searchTerm.First();
var imdbRequest = new HttpRequest("https://v2.sg.media-imdb.com/suggests/"+firstChar+"/" + searchTerm + ".json");
var response = _httpClient.Get(imdbRequest);
var imdbCallback = "imdb$" + searchTerm + "(";
var responseCleaned = response.Content.Replace(imdbCallback, "").TrimEnd(")");
dynamic json = JsonConvert.DeserializeObject(responseCleaned);
var imdbMovies = new List<Series>();
foreach (dynamic entry in json.d)
{
var imdbMovie = new Series();
imdbMovie.ImdbId = entry.id;
string noTT = imdbMovie.ImdbId.Replace("tt", "");
try
{
imdbMovie.TvdbId = (int)Double.Parse(noTT);
}
catch
{
imdbMovie.TvdbId = 0;
}
try
{
imdbMovie.SortTitle = entry.l;
imdbMovie.Title = entry.l;
string titleSlug = entry.l;
imdbMovie.TitleSlug = titleSlug.ToLower().Replace(" ", "-");
imdbMovie.Year = entry.y;
imdbMovie.Images = new List<MediaCover.MediaCover>();
try
{
string url = entry.i[0];
var imdbPoster = new MediaCover.MediaCover(MediaCoverTypes.Poster, url);
imdbMovie.Images.Add(imdbPoster);
}
catch (Exception e)
{
_logger.Debug(entry);
continue;
}
imdbMovies.Add(imdbMovie);
}
catch
{
}
}
return imdbMovies;
var httpResponse = _httpClient.Get<List<ShowResource>>(httpRequest);

View File

@ -488,6 +488,7 @@
<Compile Include="Exceptions\BadRequestException.cs" />
<Compile Include="Exceptions\DownstreamException.cs" />
<Compile Include="Exceptions\NzbDroneClientException.cs" />
<Compile Include="Exceptions\MovieNotFoundExceptions.cs" />
<Compile Include="Exceptions\SeriesNotFoundException.cs" />
<Compile Include="Exceptions\ReleaseDownloadException.cs" />
<Compile Include="Exceptions\StatusCodeToExceptions.cs" />
@ -778,6 +779,8 @@
<Compile Include="Messaging\Events\IEventAggregator.cs" />
<Compile Include="Messaging\Events\IHandle.cs" />
<Compile Include="Messaging\IProcessMessage.cs" />
<Compile Include="MetadataSource\IProvideMovieInfo.cs" />
<Compile Include="MetadataSource\ISearchForNewMovie.cs" />
<Compile Include="MetadataSource\SkyHook\Resource\ActorResource.cs" />
<Compile Include="MetadataSource\SkyHook\Resource\EpisodeResource.cs" />
<Compile Include="MetadataSource\SkyHook\Resource\ImageResource.cs" />
@ -1067,6 +1070,7 @@
<Compile Include="Tv\RefreshEpisodeService.cs" />
<Compile Include="Tv\RefreshSeriesService.cs" />
<Compile Include="Tv\Season.cs" />
<Compile Include="Tv\Movie.cs" />
<Compile Include="Tv\Series.cs" />
<Compile Include="Tv\SeriesAddedHandler.cs" />
<Compile Include="Tv\SeriesScannedHandler.cs" />
@ -1075,6 +1079,7 @@
<Compile Include="Tv\SeriesService.cs">
<SubType>Code</SubType>
</Compile>
<Compile Include="Tv\MovieStatusType.cs" />
<Compile Include="Tv\SeriesStatusType.cs" />
<Compile Include="Tv\SeriesTitleNormalizer.cs" />
<Compile Include="Tv\SeriesTypes.cs" />

View File

@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using Marr.Data;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Profiles;
namespace NzbDrone.Core.Tv
{
public class Movie : ModelBase
{
public Movie()
{
Images = new List<MediaCover.MediaCover>();
Genres = new List<string>();
Actors = new List<Actor>();
Tags = new HashSet<int>();
}
public string ImdbId { get; set; }
public string Title { get; set; }
public string CleanTitle { get; set; }
public string SortTitle { get; set; }
public MovieStatusType Status { get; set; }
public string Overview { get; set; }
public bool Monitored { get; set; }
public int ProfileId { get; set; }
public DateTime? LastInfoSync { get; set; }
public int Runtime { get; set; }
public List<MediaCover.MediaCover> Images { get; set; }
public string TitleSlug { get; set; }
public string Path { get; set; }
public int Year { get; set; }
public Ratings Ratings { get; set; }
public List<string> Genres { get; set; }
public List<Actor> Actors { get; set; }
public string Certification { get; set; }
public string RootFolderPath { get; set; }
public DateTime Added { get; set; }
public DateTime? InCinemas { get; set; }
public LazyLoaded<Profile> Profile { get; set; }
public HashSet<int> Tags { get; set; }
// public AddMovieOptions AddOptions { get; set; }
public override string ToString()
{
return string.Format("[{0}][{1}]", ImdbId, Title.NullSafe());
}
}
}

View File

@ -0,0 +1,9 @@
namespace NzbDrone.Core.Tv
{
public enum MovieStatusType
{
TBA = 0, //Nothing yet announced, only rumors, but still IMDb page
Announced = 1, //AirDate is announced
Released = 2 //Has at least one PreDB release
}
}

BIN
src/UI/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,22 @@
var Backbone = require('backbone');
var MovieModel = require('../Movies/MovieModel');
var _ = require('underscore');
module.exports = Backbone.Collection.extend({
url : window.NzbDrone.ApiRoot + '/movies/lookup',
model : MovieModel,
parse : function(response) {
var self = this;
_.each(response, function(model) {
model.id = undefined;
if (self.unmappedFolderModel) {
model.path = self.unmappedFolderModel.get('folder').path;
}
});
return response;
}
});

View File

@ -0,0 +1,53 @@
var vent = require('vent');
var AppLayout = require('../AppLayout');
var Marionette = require('marionette');
var RootFolderLayout = require('./RootFolders/RootFolderLayout');
//var ExistingMoviesCollectionView = require('./Existing/AddExistingSeriesCollectionView');
var AddMoviesView = require('./AddMoviesView');
var ProfileCollection = require('../Profile/ProfileCollection');
var RootFolderCollection = require('./RootFolders/RootFolderCollection');
require('../Movies/MoviesCollection');
module.exports = Marionette.Layout.extend({
template : 'AddMovies/AddMoviesLayoutTemplate',
regions : {
workspace : '#add-movies-workspace'
},
events : {
'click .x-import' : '_importMovies',
'click .x-add-new' : '_addMovies'
},
attributes : {
id : 'add-movies-screen'
},
initialize : function() {
ProfileCollection.fetch();
RootFolderCollection.fetch().done(function() {
RootFolderCollection.synced = true;
});
},
onShow : function() {
this.workspace.show(new AddMoviesView());
},
_folderSelected : function(options) {
//vent.trigger(vent.Commands.CloseModalCommand);
//TODO: Fix this shit.
//this.workspace.show(new ExistingMoviesCollectionView({ model : options.model }));
},
_importMovies : function() {
this.rootFolderLayout = new RootFolderLayout();
this.listenTo(this.rootFolderLayout, 'folderSelected', this._folderSelected);
AppLayout.modalRegion.show(this.rootFolderLayout);
},
_addMovies : function() {
this.workspace.show(new AddMoviesView());
}
});

View File

@ -0,0 +1,16 @@
<div class="row">
<div class="col-md-12">
<div class="btn-group add-movies-btn-group btn-group-lg btn-block">
<button type="button" class="btn btn-default col-md-10 col-xs-8 add-movies-import-btn x-import">
<i class="icon-sonarr-hdd"/>
Import existing movies on disk
</button>
<button class="btn btn-default col-md-2 col-xs-4 x-add-new"><i class="icon-sonarr-active hidden-xs"></i> Add New Movie</button>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div id="add-movies-workspace"></div>
</div>
</div>

View File

@ -0,0 +1,183 @@
var _ = require('underscore');
var vent = require('vent');
var Marionette = require('marionette');
var AddMoviesCollection = require('./AddMoviesCollection');
var SearchResultCollectionView = require('./SearchResultCollectionView');
var EmptyView = require('./EmptyView');
var NotFoundView = require('./NotFoundView');
var ErrorView = require('./ErrorView');
var LoadingView = require('../Shared/LoadingView');
module.exports = Marionette.Layout.extend({
template : 'AddMovies/AddMoviesViewTemplate',
regions : {
searchResult : '#search-result'
},
ui : {
moviesSearch : '.x-movies-search',
searchBar : '.x-search-bar',
loadMore : '.x-load-more'
},
events : {
'click .x-load-more' : '_onLoadMore'
},
initialize : function(options) {
console.log(options)
this.isExisting = options.isExisting;
this.collection = new AddMoviesCollection();
if (this.isExisting) {
this.collection.unmappedFolderModel = this.model;
}
if (this.isExisting) {
this.className = 'existing-movies';
} else {
this.className = 'new-movies';
}
this.listenTo(vent, vent.Events.MoviesAdded, this._onMoviesAdded);
this.listenTo(this.collection, 'sync', this._showResults);
this.resultCollectionView = new SearchResultCollectionView({
collection : this.collection,
isExisting : this.isExisting
});
this.throttledSearch = _.debounce(this.search, 1000, { trailing : true }).bind(this);
},
onRender : function() {
var self = this;
this.$el.addClass(this.className);
this.ui.moviesSearch.keyup(function(e) {
if (_.contains([
9,
16,
17,
18,
19,
20,
33,
34,
35,
36,
37,
38,
39,
40,
91,
92,
93
], e.keyCode)) {
return;
}
self._abortExistingSearch();
self.throttledSearch({
term : self.ui.moviesSearch.val()
});
});
this._clearResults();
if (this.isExisting) {
this.ui.searchBar.hide();
}
},
onShow : function() {
this.ui.moviesSearch.focus();
},
search : function(options) {
var self = this;
this.collection.reset();
if (!options.term || options.term === this.collection.term) {
return Marionette.$.Deferred().resolve();
}
this.searchResult.show(new LoadingView());
this.collection.term = options.term;
this.currentSearchPromise = this.collection.fetch({
data : { term : options.term }
});
this.currentSearchPromise.fail(function() {
self._showError();
});
return this.currentSearchPromise;
},
_onMoviesAdded : function(options) {
if (this.isExisting && options.movies.get('path') === this.model.get('folder').path) {
this.close();
}
else if (!this.isExisting) {
this.collection.term = '';
this.collection.reset();
this._clearResults();
this.ui.moviesSearch.val('');
this.ui.moviesSearch.focus();
}
},
_onLoadMore : function() {
var showingAll = this.resultCollectionView.showMore();
this.ui.searchBar.show();
if (showingAll) {
this.ui.loadMore.hide();
}
},
_clearResults : function() {
if (!this.isExisting) {
this.searchResult.show(new EmptyView());
} else {
this.searchResult.close();
}
},
_showResults : function() {
if (!this.isClosed) {
if (this.collection.length === 0) {
this.ui.searchBar.show();
this.searchResult.show(new NotFoundView({ term : this.collection.term }));
} else {
this.searchResult.show(this.resultCollectionView);
if (!this.showingAll && this.isExisting) {
this.ui.loadMore.show();
}
}
}
},
_abortExistingSearch : function() {
if (this.currentSearchPromise && this.currentSearchPromise.readyState > 0 && this.currentSearchPromise.readyState < 4) {
console.log('aborting previous pending search request.');
this.currentSearchPromise.abort();
} else {
this._clearResults();
}
},
_showError : function() {
if (!this.isClosed) {
this.ui.searchBar.show();
this.searchResult.show(new ErrorView({ term : this.collection.term }));
this.collection.term = '';
}
}
});

View File

@ -0,0 +1,24 @@
{{#if folder.path}}
<div class="unmapped-folder-path">
<div class="col-md-12">
{{folder.path}}
</div>
</div>{{/if}}
<div class="x-search-bar">
<div class="input-group input-group-lg add-movies-search">
<span class="input-group-addon"><i class="icon-sonarr-search"/></span>
{{#if folder}}
<input type="text" class="form-control x-movies-search" value="{{folder.name}}">
{{else}}
<input type="text" class="form-control x-movies-search" placeholder="Start typing the name of the movie you want to add ...">
{{/if}}
</div>
</div>
<div class="row">
<div id="search-result" class="result-list col-md-12"/>
</div>
<div class="btn btn-block text-center new-movies-loadmore x-load-more" style="display: none;">
<i class="icon-sonarr-load-more"/>
more
</div>

View File

@ -0,0 +1,5 @@
var Marionette = require('marionette');
module.exports = Marionette.CompositeView.extend({
template : 'AddMovies/EmptyViewTemplate'
});

View File

@ -0,0 +1,3 @@
<div class="text-center hint col-md-12">
<span>You can also search by imdbid using the imdb: prefixes.</span>
</div>

View File

@ -0,0 +1,13 @@
var Marionette = require('marionette');
module.exports = Marionette.CompositeView.extend({
template : 'AddMovies/ErrorViewTemplate',
initialize : function(options) {
this.options = options;
},
templateHelpers : function() {
return this.options;
}
});

View File

@ -0,0 +1,7 @@
<div class="text-center col-md-12">
<h3>
There was an error searching for '{{term}}'.
</h3>
If the movie title contains non-alphanumeric characters try removing them, otherwise try your search again later.
</div>

View File

@ -0,0 +1,51 @@
var Marionette = require('marionette');
var AddSeriesView = require('../AddSeriesView');
var UnmappedFolderCollection = require('./UnmappedFolderCollection');
module.exports = Marionette.CompositeView.extend({
itemView : AddSeriesView,
itemViewContainer : '.x-loading-folders',
template : 'AddSeries/Existing/AddExistingSeriesCollectionViewTemplate',
ui : {
loadingFolders : '.x-loading-folders'
},
initialize : function() {
this.collection = new UnmappedFolderCollection();
this.collection.importItems(this.model);
},
showCollection : function() {
this._showAndSearch(0);
},
appendHtml : function(collectionView, itemView, index) {
collectionView.ui.loadingFolders.before(itemView.el);
},
_showAndSearch : function(index) {
var self = this;
var model = this.collection.at(index);
if (model) {
var currentIndex = index;
var folderName = model.get('folder').name;
this.addItemView(model, this.getItemView(), index);
this.children.findByModel(model).search({ term : folderName }).always(function() {
if (!self.isClosed) {
self._showAndSearch(currentIndex + 1);
}
});
}
else {
this.ui.loadingFolders.hide();
}
},
itemViewOptions : {
isExisting : true
}
});

View File

@ -0,0 +1,5 @@
<div class="x-existing-folders">
<div class="loading-folders x-loading-folders">
Loading search results from TheTVDB for your series, this may take a few minutes.
</div>
</div>

View File

@ -0,0 +1,20 @@
var Backbone = require('backbone');
var UnmappedFolderModel = require('./UnmappedFolderModel');
var _ = require('underscore');
module.exports = Backbone.Collection.extend({
model : UnmappedFolderModel,
importItems : function(rootFolderModel) {
this.reset();
var rootFolder = rootFolderModel;
_.each(rootFolderModel.get('unmappedFolders'), function(folder) {
this.push(new UnmappedFolderModel({
rootFolder : rootFolder,
folder : folder
}));
}, this);
}
});

View File

@ -0,0 +1,3 @@
var Backbone = require('backbone');
module.exports = Backbone.Model.extend({});

View File

@ -0,0 +1,18 @@
<dl class="monitor-tooltip-contents">
<dt>All</dt>
<dd>Monitor all episodes except specials</dd>
<dt>Future</dt>
<dd>Monitor episodes that have not aired yet</dd>
<dt>Missing</dt>
<dd>Monitor episodes that do not have files or have not aired yet</dd>
<dt>Existing</dt>
<dd>Monitor episodes that have files or have not aired yet</dd>
<dt>First Season</dt>
<dd>Monitor all episodes of the first season. All other seasons will be ignored</dd>
<dt>Latest Season</dt>
<dd>Monitor all episodes of the latest season and future seasons</dd>
<dt>None</dt>
<dd>No episodes will be monitored.</dd>
<!--<dt>Latest Season</dt>-->
<!--<dd>Monitor all episodes the latest season only, previous seasons will be ignored</dd>-->
</dl>

View File

@ -0,0 +1,3 @@
<select class="form-control col-md-2 x-movie-type" name="movieType">
<option value="standard">Standard</option>
</select>

View File

@ -0,0 +1,13 @@
var Marionette = require('marionette');
module.exports = Marionette.CompositeView.extend({
template : 'AddMovies/NotFoundViewTemplate',
initialize : function(options) {
this.options = options;
},
templateHelpers : function() {
return this.options;
}
});

View File

@ -0,0 +1,7 @@
<div class="text-center col-md-12">
<h3>
Sorry. We couldn't find any movies matching '{{term}}'
</h3>
<a href="https://github.com/NzbDrone/NzbDrone/wiki/FAQ#wiki-why-cant-i-add-a-new-show-to-nzbdrone-its-on-thetvdb">Why can't I find my show?</a>
</div>

View File

@ -0,0 +1,10 @@
var Backbone = require('backbone');
var RootFolderModel = require('./RootFolderModel');
require('../../Mixins/backbone.signalr.mixin');
var RootFolderCollection = Backbone.Collection.extend({
url : window.NzbDrone.ApiRoot + '/rootfolder',
model : RootFolderModel
});
module.exports = new RootFolderCollection();

View File

@ -0,0 +1,8 @@
var Marionette = require('marionette');
var RootFolderItemView = require('./RootFolderItemView');
module.exports = Marionette.CompositeView.extend({
template : 'AddSeries/RootFolders/RootFolderCollectionViewTemplate',
itemViewContainer : '.x-root-folders',
itemView : RootFolderItemView
});

View File

@ -0,0 +1,13 @@
<table class="table table-hover">
<thead>
<tr>
<th class="col-md-10 ">
Path
</th>
<th class="col-md-3">
Free Space
</th>
</tr>
</thead>
<tbody class="x-root-folders"></tbody>
</table>

View File

@ -0,0 +1,28 @@
var Marionette = require('marionette');
module.exports = Marionette.ItemView.extend({
template : 'AddSeries/RootFolders/RootFolderItemViewTemplate',
className : 'recent-folder',
tagName : 'tr',
initialize : function() {
this.listenTo(this.model, 'change', this.render);
},
events : {
'click .x-delete' : 'removeFolder',
'click .x-folder' : 'folderSelected'
},
removeFolder : function() {
var self = this;
this.model.destroy().success(function() {
self.close();
});
},
folderSelected : function() {
this.trigger('folderSelected', this.model);
}
});

View File

@ -0,0 +1,9 @@
<td class="col-md-10 x-folder folder-path">
{{path}}
</td>
<td class="col-md-3 x-folder folder-free-space">
<span>{{Bytes freeSpace}}</span>
</td>
<td class="col-md-1">
<i class="icon-sonarr-delete x-delete"></i>
</td>

View File

@ -0,0 +1,77 @@
var Marionette = require('marionette');
var RootFolderCollectionView = require('./RootFolderCollectionView');
var RootFolderCollection = require('./RootFolderCollection');
var RootFolderModel = require('./RootFolderModel');
var LoadingView = require('../../Shared/LoadingView');
var AsValidatedView = require('../../Mixins/AsValidatedView');
require('../../Mixins/FileBrowser');
var Layout = Marionette.Layout.extend({
template : 'AddSeries/RootFolders/RootFolderLayoutTemplate',
ui : {
pathInput : '.x-path'
},
regions : {
currentDirs : '#current-dirs'
},
events : {
'click .x-add' : '_addFolder',
'keydown .x-path input' : '_keydown'
},
initialize : function() {
this.collection = RootFolderCollection;
this.rootfolderListView = new RootFolderCollectionView({ collection : RootFolderCollection });
this.listenTo(this.rootfolderListView, 'itemview:folderSelected', this._onFolderSelected);
},
onShow : function() {
this.listenTo(RootFolderCollection, 'sync', this._showCurrentDirs);
this.currentDirs.show(new LoadingView());
if (RootFolderCollection.synced) {
this._showCurrentDirs();
}
this.ui.pathInput.fileBrowser();
},
_onFolderSelected : function(options) {
this.trigger('folderSelected', options);
},
_addFolder : function() {
var self = this;
var newDir = new RootFolderModel({
Path : this.ui.pathInput.val()
});
this.bindToModelValidation(newDir);
newDir.save().done(function() {
RootFolderCollection.add(newDir);
self.trigger('folderSelected', { model : newDir });
});
},
_showCurrentDirs : function() {
this.currentDirs.show(this.rootfolderListView);
},
_keydown : function(e) {
if (e.keyCode !== 13) {
return;
}
this._addFolder();
}
});
var Layout = AsValidatedView.apply(Layout);
module.exports = Layout;

View File

@ -0,0 +1,36 @@
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h3>Select Folder</h3>
</div>
<div class="modal-body root-folders-modal">
<div class="validation-errors"></div>
<div class="alert alert-info">Enter the path that contains some or all of your TV series, you will be able to choose which series you want to import<button type="button" class="close" data-dismiss="alert">×</button></div>
<div class="row">
<div class="form-group">
<div class="col-md-12">
<div class="input-group">
<span class="input-group-addon">&nbsp;<i class="icon-sonarr-folder-open"></i></span>
<input class="form-control x-path" type="text" validation-name="path" placeholder="Enter path to folder that contains your shows">
<span class="input-group-btn"><button class="btn btn-success x-add"><i class="icon-sonarr-ok"/></button></span>
</div>
</div>
</div>
</div>
<div class="row root-folders">
<div class="col-md-12">
{{#if items}}
<h4>Recent Folders</h4>
{{/if}}
<div id="current-dirs" class="root-folders-list"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn" data-dismiss="modal">Close</button>
</div>
</div>

View File

@ -0,0 +1,8 @@
var Backbone = require('backbone');
module.exports = Backbone.Model.extend({
urlRoot : window.NzbDrone.ApiRoot + '/rootfolder',
defaults : {
freeSpace : 0
}
});

View File

@ -0,0 +1,11 @@
<select class="col-md-4 form-control x-root-folder" validation-name="RootFolderPath">
{{#if this}}
{{#each this}}
<option value="{{id}}">{{path}}</option>
{{/each}}
{{else}}
<option value="">Select Path</option>
{{/if}}
<option value="addNew">Add a different path</option>
</select>

View File

@ -0,0 +1,29 @@
var Marionette = require('marionette');
var SearchResultView = require('./SearchResultView');
module.exports = Marionette.CollectionView.extend({
itemView : SearchResultView,
initialize : function(options) {
this.isExisting = options.isExisting;
this.showing = 1;
},
showAll : function() {
this.showingAll = true;
this.render();
},
showMore : function() {
this.showing += 5;
this.render();
return this.showing >= this.collection.length;
},
appendHtml : function(collectionView, itemView, index) {
if (!this.isExisting || index < this.showing || index === 0) {
collectionView.$el.append(itemView.el);
}
}
});

View File

@ -0,0 +1,272 @@
var _ = require('underscore');
var vent = require('vent');
var AppLayout = require('../AppLayout');
var Backbone = require('backbone');
var Marionette = require('marionette');
var Profiles = require('../Profile/ProfileCollection');
var RootFolders = require('./RootFolders/RootFolderCollection');
var RootFolderLayout = require('./RootFolders/RootFolderLayout');
var MoviesCollection = require('../Movies/MoviesCollection');
var Config = require('../Config');
var Messenger = require('../Shared/Messenger');
var AsValidatedView = require('../Mixins/AsValidatedView');
require('jquery.dotdotdot');
var view = Marionette.ItemView.extend({
template : 'AddMovies/SearchResultViewTemplate',
ui : {
profile : '.x-profile',
rootFolder : '.x-root-folder',
seasonFolder : '.x-season-folder',
monitor : '.x-monitor',
monitorTooltip : '.x-monitor-tooltip',
addButton : '.x-add',
addSearchButton : '.x-add-search',
overview : '.x-overview'
},
events : {
'click .x-add' : '_addWithoutSearch',
'click .x-add-search' : '_addAndSearch',
'change .x-profile' : '_profileChanged',
'change .x-root-folder' : '_rootFolderChanged',
'change .x-season-folder' : '_seasonFolderChanged',
'change .x-monitor' : '_monitorChanged'
},
initialize : function() {
if (!this.model) {
throw 'model is required';
}
this.templateHelpers = {};
this._configureTemplateHelpers();
this.listenTo(vent, Config.Events.ConfigUpdatedEvent, this._onConfigUpdated);
this.listenTo(this.model, 'change', this.render);
this.listenTo(RootFolders, 'all', this._rootFoldersUpdated);
},
onRender : function() {
var defaultProfile = Config.getValue(Config.Keys.DefaultProfileId);
var defaultRoot = Config.getValue(Config.Keys.DefaultRootFolderId);
var useSeasonFolder = Config.getValueBoolean(Config.Keys.UseSeasonFolder, true);
var defaultMonitorEpisodes = Config.getValue(Config.Keys.MonitorEpisodes, 'missing');
if (Profiles.get(defaultProfile)) {
this.ui.profile.val(defaultProfile);
}
if (RootFolders.get(defaultRoot)) {
this.ui.rootFolder.val(defaultRoot);
}
this.ui.seasonFolder.prop('checked', useSeasonFolder);
this.ui.monitor.val(defaultMonitorEpisodes);
//TODO: make this work via onRender, FM?
//works with onShow, but stops working after the first render
this.ui.overview.dotdotdot({
height : 120
});
this.templateFunction = Marionette.TemplateCache.get('AddMovies/MonitoringTooltipTemplate');
var content = this.templateFunction();
this.ui.monitorTooltip.popover({
content : content,
html : true,
trigger : 'hover',
title : 'Episode Monitoring Options',
placement : 'right',
container : this.$el
});
},
_configureTemplateHelpers : function() {
var existingMovies = MoviesCollection.where({ imdbId : this.model.get('imdbId') });
console.log(existingMovies)
if (existingMovies.length > 0) {
this.templateHelpers.existing = existingMovies[0].toJSON();
}
this.templateHelpers.profiles = Profiles.toJSON();
console.log(this.model)
console.log(this.templateHelpers.existing)
if (!this.model.get('isExisting')) {
this.templateHelpers.rootFolders = RootFolders.toJSON();
}
},
_onConfigUpdated : function(options) {
if (options.key === Config.Keys.DefaultProfileId) {
this.ui.profile.val(options.value);
}
else if (options.key === Config.Keys.DefaultRootFolderId) {
this.ui.rootFolder.val(options.value);
}
else if (options.key === Config.Keys.UseSeasonFolder) {
this.ui.seasonFolder.prop('checked', options.value);
}
else if (options.key === Config.Keys.MonitorEpisodes) {
this.ui.monitor.val(options.value);
}
},
_profileChanged : function() {
Config.setValue(Config.Keys.DefaultProfileId, this.ui.profile.val());
},
_seasonFolderChanged : function() {
Config.setValue(Config.Keys.UseSeasonFolder, this.ui.seasonFolder.prop('checked'));
},
_rootFolderChanged : function() {
var rootFolderValue = this.ui.rootFolder.val();
if (rootFolderValue === 'addNew') {
var rootFolderLayout = new RootFolderLayout();
this.listenToOnce(rootFolderLayout, 'folderSelected', this._setRootFolder);
AppLayout.modalRegion.show(rootFolderLayout);
} else {
Config.setValue(Config.Keys.DefaultRootFolderId, rootFolderValue);
}
},
_monitorChanged : function() {
Config.setValue(Config.Keys.MonitorEpisodes, this.ui.monitor.val());
},
_setRootFolder : function(options) {
vent.trigger(vent.Commands.CloseModalCommand);
this.ui.rootFolder.val(options.model.id);
this._rootFolderChanged();
},
_addWithoutSearch : function() {
this._addMovies(true);
},
_addAndSearch : function() {
this._addMovies(true);
},
_addMovies : function(searchForMissingEpisodes) {
var addButton = this.ui.addButton;
var addSearchButton = this.ui.addSearchButton;
addButton.addClass('disabled');
addSearchButton.addClass('disabled');
var profile = this.ui.profile.val();
var rootFolderPath = this.ui.rootFolder.children(':selected').text();
var options = this._getAddMoviesOptions();
options.searchForMissingEpisodes = searchForMissingEpisodes;
this.model.set({
profileId : profile,
rootFolderPath : rootFolderPath,
addOptions : options,
monitored : true
}, { silent : true });
var self = this;
var promise = this.model.save();
console.log(this.model.save);
console.log(promise);
if (searchForMissingEpisodes) {
this.ui.addSearchButton.spinForPromise(promise);
}
else {
this.ui.addButton.spinForPromise(promise);
}
promise.always(function() {
addButton.removeClass('disabled');
addSearchButton.removeClass('disabled');
});
promise.done(function() {
MoviesCollection.add(self.model);
self.close();
Messenger.show({
message : 'Added: ' + self.model.get('title'),
actions : {
goToSeries : {
label : 'Go to Movie',
action : function() {
Backbone.history.navigate('/movies/' + self.model.get('titleSlug'), { trigger : true });
}
}
},
hideAfter : 8,
hideOnNavigate : true
});
vent.trigger(vent.Events.MoviesAdded, { movie : self.model });
});
},
_rootFoldersUpdated : function() {
this._configureTemplateHelpers();
this.render();
},
_getAddMoviesOptions : function() {
var monitor = this.ui.monitor.val();
var options = {
ignoreEpisodesWithFiles : false,
ignoreEpisodesWithoutFiles : false
};
if (monitor === 'all') {
return options;
}
else if (monitor === 'future') {
options.ignoreEpisodesWithFiles = true;
options.ignoreEpisodesWithoutFiles = true;
}
else if (monitor === 'latest') {
this.model.setSeasonPass(lastSeason.seasonNumber);
}
else if (monitor === 'first') {
this.model.setSeasonPass(lastSeason.seasonNumber + 1);
this.model.setSeasonMonitored(firstSeason.seasonNumber);
}
else if (monitor === 'missing') {
options.ignoreEpisodesWithFiles = true;
}
else if (monitor === 'existing') {
options.ignoreEpisodesWithoutFiles = true;
}
else if (monitor === 'none') {
this.model.setSeasonPass(lastSeason.seasonNumber + 1);
}
return options;
}
});
AsValidatedView.apply(view);
module.exports = view;

View File

@ -0,0 +1,101 @@
<div class="search-item {{#unless isExisting}}search-item-new{{/unless}}">
<div class="row">
<div class="col-md-2">
<a href="{{imdbUrl}}" target="_blank">
{{poster}}
</a>
</div>
<div class="col-md-10">
<div class="row">
<div class="col-md-12">
<h2 class="movies-title">
{{titleWithYear}}
<span class="labels">
<span class="label label-default">{{network}}</span>
{{#unless_eq status compare="announced"}}
<span class="label label-danger">Released</span> <!-- TODO: Better handling of cases here! -->
{{/unless_eq}}
</span>
</h2>
</div>
</div>
<div class="row new-movies-overview x-overview">
<div class="col-md-12 overview-internal">
{{overview}}
</div>
</div>
<div class="row">
{{#unless existing}}
{{#unless path}}
<div class="form-group col-md-4">
<label>Path</label>
{{> RootFolderSelectionPartial rootFolders}}
</div>
{{/unless}}
<div class="form-group col-md-2">
<label>Monitor <i class="icon-sonarr-form-info monitor-tooltip x-monitor-tooltip"></i></label>
<select class="form-control col-md-2 x-monitor">
<option value="all">All</option>
<option value="missing">Missing</option>
<option value="none">None</option>
</select>
</div>
<div class="form-group col-md-2">
<label>Profile</label>
{{> ProfileSelectionPartial profiles}}
</div>
<div class="form-group col-md-2">
<label>Season Folders</label>
<div class="input-group">
<label class="checkbox toggle well">
<input type="checkbox" class="x-season-folder"/>
<p>
<span>Yes</span>
<span>No</span>
</p>
<div class="btn btn-primary slide-button"/>
</label>
</div>
</div>
{{/unless}}
{{#unless existing}}
{{#if title}}
<div class="form-group col-md-2">
<!--Uncomment if we need to add even more controls to add Movies-->
<label style="visibility: hidden">Add</label>
<div class="btn-group">
<button class="btn btn-success add x-add" title="Add">
<i class="icon-sonarr-add"></i>
</button>
<button class="btn btn-success add x-add-search" title="Add and Search for missing episodes">
<i class="icon-sonarr-search"></i>
</button>
</div>
</div>
{{else}}
<label style="visibility: hidden">Add</label>
<div class="col-md-2" title="Movies require an English title">
<button class="btn add-movies disabled">
Add
</button>
</div>
{{/if}}
{{else}}
<label style="visibility: hidden">Add</label>
<div class="col-md-2 col-md-offset-10">
<a class="btn btn-default" href="{{route}}">
Already Exists
</a>
</div>
{{/unless}}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,13 @@
<select class="form-control col-md-2 starting-season x-starting-season">
{{#each this}}
{{#if_eq seasonNumber compare="0"}}
<option value="{{seasonNumber}}">Specials</option>
{{else}}
<option value="{{seasonNumber}}">Season {{seasonNumber}}</option>
{{/if_eq}}
{{/each}}
<option value="5000000">None</option>
</select>

View File

@ -0,0 +1,177 @@
@import "../Shared/Styles/card.less";
@import "../Shared/Styles/clickable.less";
#add-movies-screen {
.existing-movies {
.card();
margin : 30px 0px;
.unmapped-folder-path {
padding: 20px;
margin-left : 0px;
font-weight : 100;
font-size : 25px;
text-align : center;
}
.new-movies-loadmore {
font-size : 30px;
font-weight : 300;
padding-top : 10px;
padding-bottom : 10px;
}
}
.new-movies {
.search-item {
.card();
margin : 40px 0px;
}
}
.add-movies-search {
margin-top : 20px;
margin-bottom : 20px;
}
.search-item {
padding-bottom : 20px;
.btn-group{
display: table;
}
.movies-title {
margin-top : 5px;
.labels {
margin-left : 10px;
.label {
font-size : 12px;
vertical-align : middle;
}
}
.year {
font-style : italic;
color : #aaaaaa;
}
}
.new-movies-overview {
overflow : hidden;
height : 103px;
.overview-internal {
overflow : hidden;
height : 80px;
}
}
.movies-poster {
min-width : 138px;
min-height : 203px;
max-width : 138px;
max-height : 203px;
margin : 10px;
}
a {
color : #343434;
}
a:hover {
text-decoration : none;
}
select {
font-size : 14px;
}
.checkbox {
margin-top : 0px;
}
.add {
i {
&:before {
color : #ffffff;
}
}
}
.monitor-tooltip {
margin-left : 5px;
}
}
.loading-folders {
margin : 30px 0px;
text-align: center;
}
.hint {
color : #999999;
font-style : italic;
}
.monitor-tooltip-contents {
padding-bottom : 0px;
dd {
padding-bottom : 8px;
}
}
}
li.add-new {
.clickable;
display: block;
padding: 3px 20px;
clear: both;
font-weight: normal;
line-height: 20px;
color: rgb(51, 51, 51);
white-space: nowrap;
}
li.add-new:hover {
text-decoration: none;
color: rgb(255, 255, 255);
background-color: rgb(0, 129, 194);
}
.root-folders-modal {
overflow : visible;
.root-folders-list {
overflow-y : auto;
max-height : 300px;
i {
.clickable();
}
}
.validation-errors {
display : none;
}
.input-group {
.form-control {
background-color : white;
}
}
.root-folders {
margin-top : 20px;
}
.recent-folder {
.clickable();
}
}

View File

@ -4,6 +4,7 @@ var Marionette = require('marionette');
var ActivityLayout = require('./Activity/ActivityLayout');
var SettingsLayout = require('./Settings/SettingsLayout');
var AddSeriesLayout = require('./AddSeries/AddSeriesLayout');
var AddMoviesLayout = require('./AddMovies/AddMoviesLayout');
var WantedLayout = require('./Wanted/WantedLayout');
var CalendarLayout = require('./Calendar/CalendarLayout');
var ReleaseLayout = require('./Release/ReleaseLayout');
@ -17,6 +18,11 @@ module.exports = NzbDroneController.extend({
this.showMainRegion(new AddSeriesLayout({ action : action }));
},
addMovies : function(action) {
this.setTitle("Add Movie");
this.showMainRegion(new AddMoviesLayout({action : action}));
},
calendar : function() {
this.setTitle('Calendar');
this.showMainRegion(new CalendarLayout());

View File

@ -28,7 +28,7 @@ Handlebars.registerHelper('imdbUrl', function() {
});
Handlebars.registerHelper('tvdbUrl', function() {
return 'http://imdb.com/title/tt' + this.tvdbId;
return 'http://imdb.com/title/tt' + this.imdbId;
});
Handlebars.registerHelper('tvRageUrl', function() {

View File

@ -0,0 +1,13 @@
var Backbone = require('backbone');
var _ = require('underscore');
module.exports = Backbone.Model.extend({
urlRoot : window.NzbDrone.ApiRoot + '/movies',
defaults : {
episodeFileCount : 0,
episodeCount : 0,
isExisting : false,
status : 0
}
});

View File

@ -0,0 +1,120 @@
var _ = require('underscore');
var Backbone = require('backbone');
var PageableCollection = require('backbone.pageable');
var MovieModel = require('./MovieModel');
var ApiData = require('../Shared/ApiData');
var AsFilteredCollection = require('../Mixins/AsFilteredCollection');
var AsSortedCollection = require('../Mixins/AsSortedCollection');
var AsPersistedStateCollection = require('../Mixins/AsPersistedStateCollection');
var moment = require('moment');
require('../Mixins/backbone.signalr.mixin');
var Collection = PageableCollection.extend({
url : window.NzbDrone.ApiRoot + '/movies',
model : MovieModel,
tableName : 'movies',
state : {
sortKey : 'sortTitle',
order : -1,
pageSize : 100000,
secondarySortKey : 'sortTitle',
secondarySortOrder : -1
},
mode : 'client',
save : function() {
var self = this;
var proxy = _.extend(new Backbone.Model(), {
id : '',
url : self.url + '/editor',
toJSON : function() {
return self.filter(function(model) {
return model.edited;
});
}
});
this.listenTo(proxy, 'sync', function(proxyModel, models) {
this.add(models, { merge : true });
this.trigger('save', this);
});
return proxy.save();
},
filterModes : {
'all' : [
null,
null
],
'continuing' : [
'status',
'continuing'
],
'ended' : [
'status',
'ended'
],
'monitored' : [
'monitored',
true
],
'missing' : [
null,
null,
function(model) { return model.get('episodeCount') !== model.get('episodeFileCount'); }
]
},
sortMappings : {
title : {
sortKey : 'sortTitle'
},
nextAiring : {
sortValue : function(model, attr, order) {
var nextAiring = model.get(attr);
if (nextAiring) {
return moment(nextAiring).unix();
}
if (order === 1) {
return 0;
}
return Number.MAX_VALUE;
}
},
percentOfEpisodes : {
sortValue : function(model, attr) {
var percentOfEpisodes = model.get(attr);
var episodeCount = model.get('episodeCount');
return percentOfEpisodes + episodeCount / 1000000;
}
},
path : {
sortValue : function(model) {
var path = model.get('path');
return path.toLowerCase();
}
}
}
});
Collection = AsFilteredCollection.call(Collection);
Collection = AsSortedCollection.call(Collection);
Collection = AsPersistedStateCollection.call(Collection);
var data = ApiData.get('series');
module.exports = new Collection(data, { full : true }).bindSignalR();

View File

@ -6,6 +6,8 @@ module.exports = Marionette.AppRouter.extend({
appRoutes : {
'addseries' : 'addSeries',
'addseries/:action(/:query)' : 'addSeries',
'addmovies' : 'addMovies',
'addmovies/:action(/:query)' : 'addMovies',
'calendar' : 'calendar',
'settings' : 'settings',
'settings/:action(/:query)' : 'settings',
@ -22,4 +24,4 @@ module.exports = Marionette.AppRouter.extend({
'serieseditor' : 'seriesEditor',
':whatever' : 'showNotFound'
}
});
});

View File

@ -7,7 +7,7 @@
</div>
<div class="row">
<div class="col-md-4 col-md-offset-4">
<a href="/addseries" class='btn btn-lg btn-block btn-success x-add-series'>
<a href="/addmovies" class='btn btn-lg btn-block btn-success x-add-series'>
<i class='icon-sonarr-add'></i>
Add Movie
</a>

View File

@ -82,7 +82,7 @@ module.exports = Marionette.Layout.extend({
{
title : 'Add Movie',
icon : 'icon-sonarr-add',
route : 'addseries'
route : 'addmovies'
},
{
title : 'Season Pass',

View File

@ -26,6 +26,7 @@
<link href="/Content/logs.css" rel='stylesheet' type='text/css'/>
<link href="/Content/settings.css" rel='stylesheet' type='text/css'/>
<link href="/Content/addSeries.css" rel='stylesheet' type='text/css'/>
<link href="/Content/addMovies.css" rel='stylesheet' type='text/css'/>
<link href="/Content/calendar.css" rel='stylesheet' type='text/css'/>
<link href="/Content/update.css" rel='stylesheet' type='text/css'/>
<link href="/Content/overrides.css" rel='stylesheet' type='text/css'/>