New: Release Groups for movie table index

* New: Release Group for movie table index

Co-authored-by: Qstick <qstick@gmail.com>

* fixup! New: Release Group for movie table index

---------

Co-authored-by: Qstick <qstick@gmail.com>
This commit is contained in:
Bogdan 2024-01-16 20:52:07 +02:00 committed by GitHub
parent 1932aec131
commit 35651ac59b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 358 additions and 26 deletions

View File

@ -74,11 +74,7 @@ CollectionMovieLabel.propTypes = {
CollectionMovieLabel.defaultProps = {
isSaving: false,
statistics: {
episodeFileCount: 0,
totalEpisodeCount: 0,
percentOfEpisodes: 0
}
statistics: {}
};
export default CollectionMovieLabel;

View File

@ -50,12 +50,16 @@ class DeleteMovieModalContent extends Component {
title,
path,
hasFile,
statistics,
deleteOptions,
sizeOnDisk,
onModalClose,
onDeleteOptionChange
} = this.props;
const {
sizeOnDisk = 0
} = statistics;
const deleteFiles = this.state.deleteFiles;
const addImportExclusion = deleteOptions.addImportExclusion;
@ -151,12 +155,16 @@ class DeleteMovieModalContent extends Component {
DeleteMovieModalContent.propTypes = {
title: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
statistics: PropTypes.object.isRequired,
hasFile: PropTypes.bool.isRequired,
sizeOnDisk: PropTypes.number.isRequired,
deleteOptions: PropTypes.object.isRequired,
onDeleteOptionChange: PropTypes.func.isRequired,
onDeletePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
DeleteMovieModalContent.defaultProps = {
statistics: {}
};
export default DeleteMovieModalContent;

View File

@ -238,7 +238,7 @@ class MovieDetails extends Component {
certification,
ratings,
path,
sizeOnDisk,
statistics,
qualityProfileId,
monitored,
studio,
@ -267,6 +267,10 @@ class MovieDetails extends Component {
movieRuntimeFormat
} = this.props;
const {
sizeOnDisk = 0
} = statistics;
const {
isOrganizeModalOpen,
isEditMovieModalOpen,
@ -734,7 +738,7 @@ MovieDetails.propTypes = {
certification: PropTypes.string,
ratings: PropTypes.object.isRequired,
path: PropTypes.string.isRequired,
sizeOnDisk: PropTypes.number.isRequired,
statistics: PropTypes.object.isRequired,
qualityProfileId: PropTypes.number.isRequired,
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
@ -773,9 +777,9 @@ MovieDetails.propTypes = {
MovieDetails.defaultProps = {
genres: [],
statistics: {},
tags: [],
isSaving: false,
sizeOnDisk: 0
isSaving: false
};
export default MovieDetails;

View File

@ -17,13 +17,13 @@ function createUnoptimizedSelector() {
createClientSideCollectionSelector('movies', 'movieIndex'),
(movies: MoviesAppState) => {
return movies.items.map((m) => {
const { monitored, status, hasFile, sizeOnDisk } = m;
const { monitored, status, hasFile, statistics } = m;
return {
monitored,
status,
hasFile,
sizeOnDisk,
statistics,
};
});
}
@ -44,16 +44,20 @@ export default function MovieIndexFooter() {
let monitored = 0;
let totalFileSize = 0;
movies.forEach((s) => {
if (s.hasFile) {
movies.forEach((m) => {
const { statistics = { sizeOnDisk: 0 } } = m;
const { sizeOnDisk = 0 } = statistics;
if (m.hasFile) {
movieFiles += 1;
}
if (s.monitored) {
if (m.monitored) {
monitored++;
}
totalFileSize += s.sizeOnDisk;
totalFileSize += sizeOnDisk;
});
return (

View File

@ -13,6 +13,7 @@ import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks';
import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar';
import MovieIndexPosterSelect from 'Movie/Index/Select/MovieIndexPosterSelect';
import { Statistics } from 'Movie/Movie';
import MoviePoster from 'Movie/MoviePoster';
import { executeCommand } from 'Store/Actions/commandActions';
import dimensions from 'Styles/Variables/dimensions';
@ -66,17 +67,19 @@ function MovieIndexOverview(props: MovieIndexOverviewProps) {
status,
path,
overview,
statistics = {} as Statistics,
images,
hasFile,
isAvailable,
tmdbId,
imdbId,
studio,
sizeOnDisk,
added,
youTubeTrailerId,
} = movie;
const { sizeOnDisk = 0 } = statistics;
const dispatch = useDispatch();
const [isEditMovieModalOpen, setIsEditMovieModalOpen] = useState(false);
const [isDeleteMovieModalOpen, setIsDeleteMovieModalOpen] = useState(false);

View File

@ -16,6 +16,7 @@ import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks';
import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar';
import MovieIndexPosterSelect from 'Movie/Index/Select/MovieIndexPosterSelect';
import { Statistics } from 'Movie/Movie';
import MoviePoster from 'Movie/MoviePoster';
import { executeCommand } from 'Store/Actions/commandActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
@ -75,12 +76,14 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
path,
movieFile,
ratings,
sizeOnDisk,
statistics = {} as Statistics,
certification,
originalTitle,
originalLanguage,
} = movie;
const { sizeOnDisk = 0 } = statistics;
const dispatch = useDispatch();
const [hasPosterError, setHasPosterError] = useState(false);
const [isEditMovieModalOpen, setIsEditMovieModalOpen] = useState(false);

View File

@ -38,6 +38,7 @@
flex: 1 0 125px;
}
.releaseGroups,
.inCinemas,
.physicalRelease,
.digitalRelease,

View File

@ -20,6 +20,7 @@ interface CssExports {
'physicalRelease': string;
'popularity': string;
'qualityProfileId': string;
'releaseGroups': string;
'rottenTomatoesRating': string;
'runtime': string;
'sizeOnDisk': string;

View File

@ -19,6 +19,7 @@ import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal';
import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks';
import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
import createMovieIndexItemSelector from 'Movie/Index/createMovieIndexItemSelector';
import { Statistics } from 'Movie/Movie';
import MoviePopularityIndex from 'Movie/MoviePopularityIndex';
import MovieTitleLink from 'Movie/MovieTitleLink';
import { executeCommand } from 'Store/Actions/commandActions';
@ -60,6 +61,7 @@ function MovieIndexRow(props: MovieIndexRowProps) {
originalLanguage,
originalTitle,
added,
statistics = {} as Statistics,
year,
inCinemas,
digitalRelease,
@ -67,7 +69,6 @@ function MovieIndexRow(props: MovieIndexRowProps) {
runtime,
minimumAvailability,
path,
sizeOnDisk,
genres = [],
ratings,
popularity,
@ -82,6 +83,8 @@ function MovieIndexRow(props: MovieIndexRowProps) {
isSaving = false,
} = movie;
const { sizeOnDisk = 0, releaseGroups = [] } = statistics;
const dispatch = useDispatch();
const [isEditMovieModalOpen, setIsEditMovieModalOpen] = useState(false);
const [isDeleteMovieModalOpen, setIsDeleteMovieModalOpen] = useState(false);
@ -380,6 +383,20 @@ function MovieIndexRow(props: MovieIndexRowProps) {
);
}
if (name === 'releaseGroups') {
const joinedReleaseGroups = releaseGroups.join(', ');
const truncatedReleaseGroups =
releaseGroups.length > 3
? `${releaseGroups.slice(0, 3).join(', ')}...`
: joinedReleaseGroups;
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<span title={joinedReleaseGroups}>{truncatedReleaseGroups}</span>
</VirtualTableRowCell>
);
}
if (name === 'tags') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>

View File

@ -31,6 +31,7 @@
flex: 1 0 125px;
}
.releaseGroups,
.inCinemas,
.physicalRelease,
.digitalRelease,

View File

@ -17,6 +17,7 @@ interface CssExports {
'physicalRelease': string;
'popularity': string;
'qualityProfileId': string;
'releaseGroups': string;
'rottenTomatoesRating': string;
'runtime': string;
'sizeOnDisk': string;

View File

@ -12,6 +12,12 @@ export interface Collection {
title: string;
}
export interface Statistics {
movieFileCount: number;
releaseGroups: string[];
sizeOnDisk: number;
}
export interface Ratings {
imdb: object;
tmdb: object;
@ -42,11 +48,11 @@ interface Movie extends ModelBase {
runtime: number;
minimumAvailability: string;
path: string;
sizeOnDisk: number;
genres: string[];
ratings: Ratings;
popularity: number;
certification: string;
statistics: Statistics;
tags: number[];
images: Image[];
movieFile: MovieFile;

View File

@ -128,6 +128,22 @@ export const filterPredicates = {
return predicate(originalLanguage ? originalLanguage.name : '', filterValue);
},
releaseGroups: function(item, filterValue, type) {
const predicate = filterTypePredicates[type];
const { statistics = {} } = item;
const { releaseGroups = [] } = statistics;
return predicate(releaseGroups, filterValue);
},
sizeOnDisk: function(item, filterValue, type) {
const predicate = filterTypePredicates[type];
const { statistics = {} } = item;
const sizeOnDisk = statistics && statistics.sizeOnDisk ? statistics.sizeOnDisk : 0;
return predicate(sizeOnDisk, filterValue);
},
inCinemas: function(item, filterValue, type) {
return dateFilterPredicate(item.inCinemas, filterValue, type);
},
@ -290,6 +306,12 @@ export const sortPredicates = {
}
return Number.MAX_VALUE;
},
sizeOnDisk: function(item) {
const { statistics = {} } = item;
return statistics.sizeOnDisk || 0;
}
};

View File

@ -206,6 +206,12 @@ export const defaultState = {
isSortable: true,
isVisible: false
},
{
name: 'releaseGroups',
label: () => translate('ReleaseGroup'),
isSortable: true,
isVisible: false
},
{
name: 'tags',
label: () => translate('Tags'),
@ -241,6 +247,17 @@ export const defaultState = {
return originalLanguage.name;
},
releaseGroups: function(item) {
const { statistics = {} } = item;
const { releaseGroups = [] } = statistics;
return releaseGroups.length ?
releaseGroups
.map((group) => group.toLowerCase())
.sort((a, b) => a.localeCompare(b)) :
undefined;
},
imdbRating: function(item) {
const { ratings = {} } = item;
@ -313,6 +330,28 @@ export const defaultState = {
return collectionList.sort(sortByName);
}
},
{
name: 'releaseGroups',
label: () => translate('ReleaseGroups'),
type: filterBuilderTypes.ARRAY,
optionsSelector: function(items) {
const groupList = items.reduce((acc, movie) => {
const { statistics = {} } = movie;
const { releaseGroups = [] } = statistics;
releaseGroups.forEach((releaseGroup) => {
acc.push({
id: releaseGroup,
name: releaseGroup
});
});
return acc;
}, []);
return groupList.sort(sortByName);
}
},
{
name: 'status',
label: () => translate('ReleaseStatus'),

View File

@ -1114,6 +1114,7 @@
"ReleaseBranchCheckOfficialBranchMessage": "Branch {0} is not a valid {appName} release branch, you will not receive updates",
"ReleaseDates": "Release Dates",
"ReleaseGroup": "Release Group",
"ReleaseGroups": "Release Groups",
"ReleaseHash": "Release Hash",
"ReleaseProfiles": "Release Profiles",
"ReleaseProfilesLoadError": "Unable to load Release Profiles",

View File

@ -0,0 +1,35 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Datastore;
namespace NzbDrone.Core.MovieStats
{
public class MovieStatistics : ResultSet
{
public int MovieId { get; set; }
public int MovieFileCount { get; set; }
public long SizeOnDisk { get; set; }
public string ReleaseGroupsString { get; set; }
public List<string> ReleaseGroups
{
get
{
var releaseGroups = new List<string>();
if (ReleaseGroupsString.IsNotNullOrWhiteSpace())
{
releaseGroups = ReleaseGroupsString
.Split('|')
.Distinct()
.Where(rg => rg.IsNotNullOrWhiteSpace())
.OrderBy(rg => rg)
.ToList();
}
return releaseGroups;
}
}
}
}

View File

@ -0,0 +1,88 @@
using System.Collections.Generic;
using System.Linq;
using Dapper;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Movies;
namespace NzbDrone.Core.MovieStats
{
public interface IMovieStatisticsRepository
{
List<MovieStatistics> MovieStatistics();
List<MovieStatistics> MovieStatistics(int movieId);
}
public class MovieStatisticsRepository : IMovieStatisticsRepository
{
private const string _selectMoviesTemplate = "SELECT /**select**/ FROM \"Movies\" /**join**/ /**innerjoin**/ /**leftjoin**/ /**where**/ /**groupby**/ /**having**/ /**orderby**/";
private const string _selectMovieFilesTemplate = "SELECT /**select**/ FROM \"MovieFiles\" /**join**/ /**innerjoin**/ /**leftjoin**/ /**where**/ /**groupby**/ /**having**/ /**orderby**/";
private readonly IMainDatabase _database;
public MovieStatisticsRepository(IMainDatabase database)
{
_database = database;
}
public List<MovieStatistics> MovieStatistics()
{
return MapResults(Query(MoviesBuilder(), _selectMoviesTemplate),
Query(MovieFilesBuilder(), _selectMovieFilesTemplate));
}
public List<MovieStatistics> MovieStatistics(int movieId)
{
return MapResults(Query(MoviesBuilder().Where<Movie>(x => x.Id == movieId), _selectMoviesTemplate),
Query(MovieFilesBuilder().Where<MovieFile>(x => x.MovieId == movieId), _selectMovieFilesTemplate));
}
private List<MovieStatistics> MapResults(List<MovieStatistics> moviesResult, List<MovieStatistics> filesResult)
{
moviesResult.ForEach(e =>
{
var file = filesResult.SingleOrDefault(f => f.MovieId == e.MovieId);
e.SizeOnDisk = file?.SizeOnDisk ?? 0;
e.ReleaseGroupsString = file?.ReleaseGroupsString;
});
return moviesResult;
}
private List<MovieStatistics> Query(SqlBuilder builder, string template)
{
var sql = builder.AddTemplate(template).LogQuery();
using var conn = _database.OpenConnection();
return conn.Query<MovieStatistics>(sql.RawSql, sql.Parameters).ToList();
}
private SqlBuilder MoviesBuilder()
{
return new SqlBuilder(_database.DatabaseType)
.Select(@"""Movies"".""Id"" AS MovieId,
SUM(CASE WHEN ""MovieFileId"" > 0 THEN 1 ELSE 0 END) AS MovieFileCount")
.GroupBy<Movie>(x => x.Id);
}
private SqlBuilder MovieFilesBuilder()
{
if (_database.DatabaseType == DatabaseType.SQLite)
{
return new SqlBuilder(_database.DatabaseType)
.Select(@"""MovieId"",
SUM(COALESCE(""Size"", 0)) AS SizeOnDisk,
GROUP_CONCAT(""ReleaseGroup"", '|') AS ReleaseGroupsString")
.GroupBy<MovieFile>(x => x.MovieId);
}
return new SqlBuilder(_database.DatabaseType)
.Select(@"""MovieId"",
SUM(COALESCE(""Size"", 0)) AS SizeOnDisk,
string_agg(""ReleaseGroup"", '|') AS ReleaseGroupsString")
.GroupBy<MovieFile>(x => x.MovieId);
}
}
}

View File

@ -0,0 +1,40 @@
using System.Collections.Generic;
using System.Linq;
namespace NzbDrone.Core.MovieStats
{
public interface IMovieStatisticsService
{
List<MovieStatistics> MovieStatistics();
MovieStatistics MovieStatistics(int movieId);
}
public class MovieStatisticsService : IMovieStatisticsService
{
private readonly IMovieStatisticsRepository _movieStatisticsRepository;
public MovieStatisticsService(IMovieStatisticsRepository movieStatisticsRepository)
{
_movieStatisticsRepository = movieStatisticsRepository;
}
public List<MovieStatistics> MovieStatistics()
{
var movieStatistics = _movieStatisticsRepository.MovieStatistics();
return movieStatistics.GroupBy(m => m.MovieId).Select(m => m.First()).ToList();
}
public MovieStatistics MovieStatistics(int movieId)
{
var stats = _movieStatisticsRepository.MovieStatistics(movieId);
if (stats == null || stats.Count == 0)
{
return new MovieStatistics();
}
return stats.First();
}
}
}

View File

@ -20,6 +20,7 @@ using NzbDrone.Core.Movies;
using NzbDrone.Core.Movies.Commands;
using NzbDrone.Core.Movies.Events;
using NzbDrone.Core.Movies.Translations;
using NzbDrone.Core.MovieStats;
using NzbDrone.Core.RootFolders;
using NzbDrone.Core.Validation;
using NzbDrone.Core.Validation.Paths;
@ -43,6 +44,7 @@ namespace Radarr.Api.V3.Movies
private readonly IMovieService _moviesService;
private readonly IMovieTranslationService _movieTranslationService;
private readonly IAddMovieService _addMovieService;
private readonly IMovieStatisticsService _movieStatisticsService;
private readonly IMapCoversToLocal _coverMapper;
private readonly IManageCommandQueue _commandQueueManager;
private readonly IRootFolderService _rootFolderService;
@ -54,6 +56,7 @@ namespace Radarr.Api.V3.Movies
IMovieService moviesService,
IMovieTranslationService movieTranslationService,
IAddMovieService addMovieService,
IMovieStatisticsService movieStatisticsService,
IMapCoversToLocal coverMapper,
IManageCommandQueue commandQueueManager,
IRootFolderService rootFolderService,
@ -74,6 +77,7 @@ namespace Radarr.Api.V3.Movies
_moviesService = moviesService;
_movieTranslationService = movieTranslationService;
_addMovieService = addMovieService;
_movieStatisticsService = movieStatisticsService;
_qualityUpgradableSpecification = qualityUpgradableSpecification;
_configService = configService;
_coverMapper = coverMapper;
@ -125,6 +129,7 @@ namespace Radarr.Api.V3.Movies
}
else
{
var movieStats = _movieStatisticsService.MovieStatistics();
var configLanguage = (Language)_configService.MovieInfoLanguage;
var availDelay = _configService.AvailabilityDelay;
@ -134,6 +139,7 @@ namespace Radarr.Api.V3.Movies
.GetAllTranslationsForLanguage(configLanguage);
var tdict = translations.ToDictionary(x => x.MovieMetadataId);
var sdict = movieStats.ToDictionary(x => x.MovieId);
if (!excludeLocalCovers)
{
@ -155,6 +161,8 @@ namespace Radarr.Api.V3.Movies
MapCoversToLocal(moviesResources, coverFileInfos);
}
LinkMovieStatistics(moviesResources, sdict);
var rootFolders = _rootFolderService.All();
moviesResources.ForEach(m => m.RootFolderPath = _rootFolderService.GetBestRootFolderPath(m.Path, rootFolders));
@ -166,6 +174,7 @@ namespace Radarr.Api.V3.Movies
protected override MovieResource GetResourceById(int id)
{
var movie = _moviesService.GetMovie(id);
return MapToResource(movie);
}
@ -183,6 +192,7 @@ namespace Radarr.Api.V3.Movies
var resource = movie.ToResource(availDelay, translation, _qualityUpgradableSpecification);
MapCoversToLocal(resource);
FetchAndLinkMovieStatistics(resource);
resource.RootFolderPath = _rootFolderService.GetBestRootFolderPath(resource.Path);
@ -278,6 +288,29 @@ namespace Radarr.Api.V3.Movies
_coverMapper.ConvertToLocalUrls(movies.Select(x => Tuple.Create(x.Id, x.Images.AsEnumerable())), coverFileInfos);
}
private void FetchAndLinkMovieStatistics(MovieResource resource)
{
LinkMovieStatistics(resource, _movieStatisticsService.MovieStatistics(resource.Id));
}
private void LinkMovieStatistics(List<MovieResource> resources, Dictionary<int, MovieStatistics> sDict)
{
foreach (var movie in resources)
{
if (sDict.TryGetValue(movie.Id, out var stats))
{
LinkMovieStatistics(movie, stats);
}
}
}
private void LinkMovieStatistics(MovieResource resource, MovieStatistics movieStatistics)
{
resource.Statistics = movieStatistics.ToResource();
resource.HasFile = movieStatistics.MovieFileCount > 0;
resource.SizeOnDisk = movieStatistics.SizeOnDisk;
}
[NonAction]
public void Handle(MovieFileImportedEvent message)
{

View File

@ -47,7 +47,6 @@ namespace Radarr.Api.V3.Movies
// public bool Downloaded { get; set; }
public string RemotePoster { get; set; }
public int Year { get; set; }
public bool HasFile { get; set; }
public string YouTubeTrailerId { get; set; }
public string Studio { get; set; }
@ -55,6 +54,9 @@ namespace Radarr.Api.V3.Movies
public string Path { get; set; }
public int QualityProfileId { get; set; }
// Compatibility
public bool HasFile { get; set; }
// Editing Only
public bool Monitored { get; set; }
public MovieStatusType MinimumAvailability { get; set; }
@ -77,6 +79,7 @@ namespace Radarr.Api.V3.Movies
public MovieFileResource MovieFile { get; set; }
public MovieCollectionResource Collection { get; set; }
public float Popularity { get; set; }
public MovieStatisticsResource Statistics { get; set; }
}
public static class MovieResourceMapper
@ -88,8 +91,6 @@ namespace Radarr.Api.V3.Movies
return null;
}
var size = model.MovieFile?.Size ?? 0;
var movieFile = model.MovieFile?.ToResource(model, upgradableSpecification, formatCalculationService);
var translatedTitle = movieTranslation?.Title ?? model.Title;
@ -108,9 +109,7 @@ namespace Radarr.Api.V3.Movies
InCinemas = model.MovieMetadata.Value.InCinemas,
PhysicalRelease = model.MovieMetadata.Value.PhysicalRelease,
DigitalRelease = model.MovieMetadata.Value.DigitalRelease,
HasFile = model.HasFile,
SizeOnDisk = size,
Status = model.MovieMetadata.Value.Status,
Overview = translatedOverview,

View File

@ -0,0 +1,30 @@
using System.Collections.Generic;
using NzbDrone.Core.MovieStats;
namespace Radarr.Api.V3.Movies
{
public class MovieStatisticsResource
{
public int MovieFileCount { get; set; }
public long SizeOnDisk { get; set; }
public List<string> ReleaseGroups { get; set; }
}
public static class MovieStatisticsResourceMapper
{
public static MovieStatisticsResource ToResource(this MovieStatistics model)
{
if (model == null)
{
return null;
}
return new MovieStatisticsResource
{
MovieFileCount = model.MovieFileCount,
SizeOnDisk = model.SizeOnDisk,
ReleaseGroups = model.ReleaseGroups
};
}
}
}