diff --git a/frontend/src/App/AppRoutes.tsx b/frontend/src/App/AppRoutes.tsx index 62b8d6ec15..923bf5e44a 100644 --- a/frontend/src/App/AppRoutes.tsx +++ b/frontend/src/App/AppRoutes.tsx @@ -17,6 +17,7 @@ import CalendarPage from 'Calendar/CalendarPage'; import CollectionConnector from 'Collection/CollectionConnector'; import NotFound from 'Components/NotFound'; import Switch from 'Components/Router/Switch'; +import Dashboard from 'Dashboard/Dashboard'; import DiscoverMovieConnector from 'DiscoverMovie/DiscoverMovieConnector'; import MovieDetailsPage from 'Movie/Details/MovieDetailsPage'; import MovieIndex from 'Movie/Index/MovieIndex'; @@ -53,10 +54,10 @@ function AppRoutes() { return ( {/* - Movies + Dashboard */} - + {window.Radarr.urlBase && ( )} + + + {/* + Movies + */} + + + diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index afc1525c41..d938ddcffe 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -9,6 +9,7 @@ import CalendarAppState from './CalendarAppState'; import CaptchaAppState from './CaptchaAppState'; import CommandAppState from './CommandAppState'; import CustomFiltersAppState from './CustomFiltersAppState'; +import DashboardAppState from './DashboardAppState'; import ExtraFilesAppState from './ExtraFilesAppState'; import HistoryAppState, { MovieHistoryAppState } from './HistoryAppState'; import InteractiveImportAppState from './InteractiveImportAppState'; @@ -97,6 +98,7 @@ interface AppState { captcha: CaptchaAppState; commands: CommandAppState; customFilters: CustomFiltersAppState; + dashboard: DashboardAppState; extraFiles: ExtraFilesAppState; history: HistoryAppState; interactiveImport: InteractiveImportAppState; diff --git a/frontend/src/App/State/DashboardAppState.ts b/frontend/src/App/State/DashboardAppState.ts new file mode 100644 index 0000000000..55faf8aebe --- /dev/null +++ b/frontend/src/App/State/DashboardAppState.ts @@ -0,0 +1,22 @@ +import { AppSectionItemState } from './AppSectionState'; + +export interface MediaTypeStatistics { + total: number; + withFiles: number; + missing: number; + monitored: number; + unmonitored: number; + sizeOnDisk: number; + totalDurationMinutes: number; +} + +export interface DashboardStatistics { + movies: MediaTypeStatistics; + books: MediaTypeStatistics; + audiobooks: MediaTypeStatistics; + totalSizeOnDisk: number; +} + +type DashboardAppState = AppSectionItemState; + +export default DashboardAppState; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.tsx b/frontend/src/Components/Page/Sidebar/PageSidebar.tsx index 4b6bb8c84c..9fe3ab545f 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebar.tsx +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.tsx @@ -44,11 +44,17 @@ interface SidebarItem { } const LINKS: SidebarItem[] = [ + { + iconName: icons.HOUSEKEEPING, + title: () => translate('Dashboard'), + to: '/', + alias: '/dashboard', + }, + { iconName: icons.MOVIE_CONTINUING, title: () => translate('Movies'), - to: '/', - alias: '/movies', + to: '/movies', children: [ { title: () => translate('AddNew'), diff --git a/frontend/src/Dashboard/Dashboard.css b/frontend/src/Dashboard/Dashboard.css new file mode 100644 index 0000000000..36efeaa7f5 --- /dev/null +++ b/frontend/src/Dashboard/Dashboard.css @@ -0,0 +1,72 @@ +.dashboard { + display: flex; + flex-direction: column; + gap: 20px; +} + +.statsGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 20px; +} + +.mediaCard { + padding: 20px; + background-color: var(--cardBackgroundColor); + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.mediaCardHeader { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid var(--borderColor); +} + +.mediaCardTitle { + font-size: 18px; + font-weight: 500; + margin: 0; +} + +.statsRow { + display: flex; + justify-content: space-between; + padding: 8px 0; +} + +.statsRow:not(:last-child) { + border-bottom: 1px solid var(--borderColor); +} + +.statsLabel { + color: var(--textMutedColor); +} + +.statsValue { + font-weight: 500; +} + +.totalCard { + padding: 20px; + background-color: var(--cardBackgroundColor); + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.totalCardTitle { + font-size: 16px; + font-weight: 500; + margin: 0 0 15px 0; + padding-bottom: 10px; + border-bottom: 1px solid var(--borderColor); +} + +.totalValue { + font-size: 24px; + font-weight: 600; + color: var(--linkColor); +} diff --git a/frontend/src/Dashboard/Dashboard.css.d.ts b/frontend/src/Dashboard/Dashboard.css.d.ts new file mode 100644 index 0000000000..052d64e10e --- /dev/null +++ b/frontend/src/Dashboard/Dashboard.css.d.ts @@ -0,0 +1,17 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + dashboard: string; + mediaCard: string; + mediaCardHeader: string; + mediaCardTitle: string; + statsGrid: string; + statsLabel: string; + statsRow: string; + statsValue: string; + totalCard: string; + totalCardTitle: string; + totalValue: string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Dashboard/Dashboard.tsx b/frontend/src/Dashboard/Dashboard.tsx new file mode 100644 index 0000000000..fba9ee5b1f --- /dev/null +++ b/frontend/src/Dashboard/Dashboard.tsx @@ -0,0 +1,138 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import { MediaTypeStatistics } from 'App/State/DashboardAppState'; +import Icon, { IconName } from 'Components/Icon'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import { icons } from 'Helpers/Props'; +import { fetchDashboard } from 'Store/Actions/dashboardActions'; +import formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; +import styles from './Dashboard.css'; + +function createDashboardSelector() { + return createSelector( + (state: AppState) => state.dashboard, + (dashboard) => { + return dashboard; + } + ); +} + +interface MediaTypeCardProps { + title: string; + icon: IconName; + stats: MediaTypeStatistics; + showDuration?: boolean; +} + +function MediaTypeCard({ + title, + icon, + stats, + showDuration = false, +}: MediaTypeCardProps) { + if (!stats) { + return null; + } + + return ( +
+
+ +

{title}

+
+
+ {translate('Total')} + {stats.total} +
+
+ {translate('WithFiles')} + {stats.withFiles} +
+
+ {translate('Missing')} + {stats.missing} +
+
+ {translate('Monitored')} + {stats.monitored} +
+
+ {translate('Unmonitored')} + {stats.unmonitored} +
+
+ {translate('SizeOnDisk')} + + {formatBytes(stats.sizeOnDisk)} + +
+ {showDuration && stats.totalDurationMinutes > 0 && ( +
+ + {translate('TotalDuration')} + + + {Math.floor(stats.totalDurationMinutes / 60)}h{' '} + {stats.totalDurationMinutes % 60}m + +
+ )} +
+ ); +} + +function Dashboard() { + const dispatch = useDispatch(); + const { isFetching, item } = useSelector(createDashboardSelector()); + + useEffect(() => { + dispatch(fetchDashboard()); + }, [dispatch]); + + return ( + + + {isFetching ? : null} + + {!isFetching && item ? ( +
+
+ + + +
+ +
+

+ {translate('TotalLibrarySize')} +

+ + {formatBytes(item.totalSizeOnDisk)} + +
+
+ ) : null} +
+
+ ); +} + +export default Dashboard; diff --git a/frontend/src/Store/Actions/dashboardActions.js b/frontend/src/Store/Actions/dashboardActions.js new file mode 100644 index 0000000000..72c40bb2d3 --- /dev/null +++ b/frontend/src/Store/Actions/dashboardActions.js @@ -0,0 +1,40 @@ +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'dashboard'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + item: {} +}; + +// +// Actions Types + +export const FETCH_DASHBOARD = 'dashboard/fetchDashboard'; + +// +// Action Creators + +export const fetchDashboard = createThunk(FETCH_DASHBOARD); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_DASHBOARD]: createFetchHandler(section, '/dashboard') +}); + +// +// Reducers + +export const reducers = createHandleActions({}, defaultState, section); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 1ad95a3021..2f81372e2d 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -7,6 +7,7 @@ import * as authors from './authorActions'; import * as blocklist from './blocklistActions'; import * as books from './bookActions'; import * as calendar from './calendarActions'; +import * as dashboard from './dashboardActions'; import * as captcha from './captchaActions'; import * as commands from './commandActions'; import * as customFilters from './customFilterActions'; @@ -47,6 +48,7 @@ export default [ books, calendar, captcha, + dashboard, commands, customFilters, discoverMovie, diff --git a/src/NzbDrone.Core/Analytics/DashboardService.cs b/src/NzbDrone.Core/Analytics/DashboardService.cs new file mode 100644 index 0000000000..e85fafb779 --- /dev/null +++ b/src/NzbDrone.Core/Analytics/DashboardService.cs @@ -0,0 +1,217 @@ +using System.Linq; +using NzbDrone.Core.Audiobooks; +using NzbDrone.Core.AudiobookStats; +using NzbDrone.Core.Books; +using NzbDrone.Core.BookStats; +using NzbDrone.Core.Movies; +using NzbDrone.Core.MovieStats; + +namespace NzbDrone.Core.Analytics +{ + public interface IDashboardService + { + DashboardStatistics GetStatistics(); + } + + public class DashboardService : IDashboardService + { + private readonly IMovieService _movieService; + private readonly IMovieStatisticsService _movieStatisticsService; + private readonly IBookService _bookService; + private readonly IBookStatisticsService _bookStatisticsService; + private readonly IAudiobookService _audiobookService; + private readonly IAudiobookStatisticsService _audiobookStatisticsService; + + public DashboardService(IMovieService movieService, + IMovieStatisticsService movieStatisticsService, + IBookService bookService, + IBookStatisticsService bookStatisticsService, + IAudiobookService audiobookService, + IAudiobookStatisticsService audiobookStatisticsService) + { + _movieService = movieService; + _movieStatisticsService = movieStatisticsService; + _bookService = bookService; + _bookStatisticsService = bookStatisticsService; + _audiobookService = audiobookService; + _audiobookStatisticsService = audiobookStatisticsService; + } + + public DashboardStatistics GetStatistics() + { + var movieStats = GetMovieStatistics(); + var bookStats = GetBookStatistics(); + var audiobookStats = GetAudiobookStatistics(); + + return new DashboardStatistics + { + Movies = movieStats, + Books = bookStats, + Audiobooks = audiobookStats, + TotalSizeOnDisk = movieStats.SizeOnDisk + bookStats.SizeOnDisk + audiobookStats.SizeOnDisk + }; + } + + private MediaTypeStatistics GetMovieStatistics() + { + var movies = _movieService.GetAllMovies(); + var stats = _movieStatisticsService.MovieStatistics(); + var statsDict = stats.ToDictionary(s => s.MovieId); + + var withFiles = 0; + var missing = 0; + var monitored = 0; + var unmonitored = 0; + long sizeOnDisk = 0; + + foreach (var movie in movies) + { + if (movie.Monitored) + { + monitored++; + } + else + { + unmonitored++; + } + + if (statsDict.TryGetValue(movie.Id, out var stat)) + { + if (stat.MovieFileCount > 0) + { + withFiles++; + } + else if (movie.Monitored) + { + missing++; + } + + sizeOnDisk += stat.SizeOnDisk; + } + else if (movie.Monitored) + { + missing++; + } + } + + return new MediaTypeStatistics + { + Total = movies.Count, + WithFiles = withFiles, + Missing = missing, + Monitored = monitored, + Unmonitored = unmonitored, + SizeOnDisk = sizeOnDisk + }; + } + + private MediaTypeStatistics GetBookStatistics() + { + var books = _bookService.GetAllBooks(); + var stats = _bookStatisticsService.BookStatistics(); + var statsDict = stats.ToDictionary(s => s.BookId); + + var withFiles = 0; + var missing = 0; + var monitored = 0; + var unmonitored = 0; + long sizeOnDisk = 0; + + foreach (var book in books) + { + if (book.Monitored) + { + monitored++; + } + else + { + unmonitored++; + } + + if (statsDict.TryGetValue(book.Id, out var stat)) + { + if (stat.BookFileCount > 0) + { + withFiles++; + } + else if (book.Monitored) + { + missing++; + } + + sizeOnDisk += stat.SizeOnDisk; + } + else if (book.Monitored) + { + missing++; + } + } + + return new MediaTypeStatistics + { + Total = books.Count, + WithFiles = withFiles, + Missing = missing, + Monitored = monitored, + Unmonitored = unmonitored, + SizeOnDisk = sizeOnDisk + }; + } + + private MediaTypeStatistics GetAudiobookStatistics() + { + var audiobooks = _audiobookService.GetAllAudiobooks(); + var stats = _audiobookStatisticsService.AudiobookStatistics(); + var statsDict = stats.ToDictionary(s => s.AudiobookId); + + var withFiles = 0; + var missing = 0; + var monitored = 0; + var unmonitored = 0; + long sizeOnDisk = 0; + var totalDurationMinutes = 0; + + foreach (var audiobook in audiobooks) + { + if (audiobook.Monitored) + { + monitored++; + } + else + { + unmonitored++; + } + + if (statsDict.TryGetValue(audiobook.Id, out var stat)) + { + if (stat.AudiobookFileCount > 0) + { + withFiles++; + } + else if (audiobook.Monitored) + { + missing++; + } + + sizeOnDisk += stat.SizeOnDisk; + totalDurationMinutes += stat.TotalDurationMinutes; + } + else if (audiobook.Monitored) + { + missing++; + } + } + + return new MediaTypeStatistics + { + Total = audiobooks.Count, + WithFiles = withFiles, + Missing = missing, + Monitored = monitored, + Unmonitored = unmonitored, + SizeOnDisk = sizeOnDisk, + TotalDurationMinutes = totalDurationMinutes + }; + } + } +} diff --git a/src/NzbDrone.Core/Analytics/DashboardStatistics.cs b/src/NzbDrone.Core/Analytics/DashboardStatistics.cs new file mode 100644 index 0000000000..c95e96d19b --- /dev/null +++ b/src/NzbDrone.Core/Analytics/DashboardStatistics.cs @@ -0,0 +1,21 @@ +namespace NzbDrone.Core.Analytics +{ + public class DashboardStatistics + { + public MediaTypeStatistics Movies { get; set; } + public MediaTypeStatistics Books { get; set; } + public MediaTypeStatistics Audiobooks { get; set; } + public long TotalSizeOnDisk { get; set; } + } + + public class MediaTypeStatistics + { + public int Total { get; set; } + public int WithFiles { get; set; } + public int Missing { get; set; } + public int Monitored { get; set; } + public int Unmonitored { get; set; } + public long SizeOnDisk { get; set; } + public int TotalDurationMinutes { get; set; } + } +} diff --git a/src/NzbDrone.Core/AudiobookStats/AudiobookStatistics.cs b/src/NzbDrone.Core/AudiobookStats/AudiobookStatistics.cs new file mode 100644 index 0000000000..ca8c5f6a20 --- /dev/null +++ b/src/NzbDrone.Core/AudiobookStats/AudiobookStatistics.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.AudiobookStats +{ + public class AudiobookStatistics : ResultSet + { + public int AudiobookId { get; set; } + public int AudiobookFileCount { get; set; } + public long SizeOnDisk { get; set; } + public long TotalDurationSeconds { get; set; } + public string ReleaseGroupsString { get; set; } + + public int TotalDurationMinutes => (int)(TotalDurationSeconds / 60); + + public List ReleaseGroups + { + get + { + var releaseGroups = new List(); + + if (ReleaseGroupsString.IsNotNullOrWhiteSpace()) + { + releaseGroups = ReleaseGroupsString + .Split('|') + .Distinct() + .Where(rg => rg.IsNotNullOrWhiteSpace()) + .OrderBy(rg => rg) + .ToList(); + } + + return releaseGroups; + } + } + } +} diff --git a/src/NzbDrone.Core/AudiobookStats/AudiobookStatisticsRepository.cs b/src/NzbDrone.Core/AudiobookStats/AudiobookStatisticsRepository.cs new file mode 100644 index 0000000000..90918b8d59 --- /dev/null +++ b/src/NzbDrone.Core/AudiobookStats/AudiobookStatisticsRepository.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using System.Linq; +using Dapper; +using NzbDrone.Core.Audiobooks; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.MediaFiles; + +namespace NzbDrone.Core.AudiobookStats +{ + public interface IAudiobookStatisticsRepository + { + List AudiobookStatistics(); + List AudiobookStatistics(int audiobookId); + } + + public class AudiobookStatisticsRepository : IAudiobookStatisticsRepository + { + private const string _selectAudiobooksTemplate = "SELECT /**select**/ FROM \"Audiobooks\" /**join**/ /**innerjoin**/ /**leftjoin**/ /**where**/ /**groupby**/ /**having**/ /**orderby**/"; + private const string _selectAudiobookFilesTemplate = "SELECT /**select**/ FROM \"AudiobookFiles\" /**join**/ /**innerjoin**/ /**leftjoin**/ /**where**/ /**groupby**/ /**having**/ /**orderby**/"; + + private readonly IMainDatabase _database; + + public AudiobookStatisticsRepository(IMainDatabase database) + { + _database = database; + } + + public List AudiobookStatistics() + { + return MapResults(Query(AudiobooksBuilder(), _selectAudiobooksTemplate), + Query(AudiobookFilesBuilder(), _selectAudiobookFilesTemplate)); + } + + public List AudiobookStatistics(int audiobookId) + { + return MapResults(Query(AudiobooksBuilder().Where(x => x.Id == audiobookId), _selectAudiobooksTemplate), + Query(AudiobookFilesBuilder().Where(x => x.AudiobookId == audiobookId), _selectAudiobookFilesTemplate)); + } + + private static List MapResults(List audiobooksResult, List filesResult) + { + audiobooksResult.ForEach(e => + { + var file = filesResult.SingleOrDefault(f => f.AudiobookId == e.AudiobookId); + + e.SizeOnDisk = file?.SizeOnDisk ?? 0; + e.TotalDurationSeconds = file?.TotalDurationSeconds ?? 0; + e.ReleaseGroupsString = file?.ReleaseGroupsString; + }); + + return audiobooksResult; + } + + private List Query(SqlBuilder builder, string template) + { + var sql = builder.AddTemplate(template).LogQuery(); + + using var conn = _database.OpenConnection(); + + return conn.Query(sql.RawSql, sql.Parameters).ToList(); + } + + private SqlBuilder AudiobooksBuilder() + { + return new SqlBuilder(_database.DatabaseType) + .Select(@"""Audiobooks"".""Id"" AS AudiobookId, + COUNT(""AudiobookFiles"".""Id"") AS AudiobookFileCount") + .LeftJoin((a, af) => a.Id == af.AudiobookId) + .GroupBy(x => x.Id); + } + + private SqlBuilder AudiobookFilesBuilder() + { + if (_database.DatabaseType == DatabaseType.SQLite) + { + return new SqlBuilder(_database.DatabaseType) + .Select(@"""AudiobookId"", + SUM(COALESCE(""Size"", 0)) AS SizeOnDisk, + SUM(COALESCE(""DurationSeconds"", 0)) AS TotalDurationSeconds, + GROUP_CONCAT(""ReleaseGroup"", '|') AS ReleaseGroupsString") + .GroupBy(x => x.AudiobookId); + } + + return new SqlBuilder(_database.DatabaseType) + .Select(@"""AudiobookId"", + SUM(COALESCE(""Size"", 0)) AS SizeOnDisk, + SUM(COALESCE(""DurationSeconds"", 0)) AS TotalDurationSeconds, + string_agg(""ReleaseGroup"", '|') AS ReleaseGroupsString") + .GroupBy(x => x.AudiobookId); + } + } +} diff --git a/src/NzbDrone.Core/AudiobookStats/AudiobookStatisticsService.cs b/src/NzbDrone.Core/AudiobookStats/AudiobookStatisticsService.cs new file mode 100644 index 0000000000..2beca27601 --- /dev/null +++ b/src/NzbDrone.Core/AudiobookStats/AudiobookStatisticsService.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.AudiobookStats +{ + public interface IAudiobookStatisticsService + { + List AudiobookStatistics(); + AudiobookStatistics AudiobookStatistics(int audiobookId); + } + + public class AudiobookStatisticsService : IAudiobookStatisticsService + { + private readonly IAudiobookStatisticsRepository _audiobookStatisticsRepository; + + public AudiobookStatisticsService(IAudiobookStatisticsRepository audiobookStatisticsRepository) + { + _audiobookStatisticsRepository = audiobookStatisticsRepository; + } + + public List AudiobookStatistics() + { + var audiobookStatistics = _audiobookStatisticsRepository.AudiobookStatistics(); + + return audiobookStatistics.GroupBy(a => a.AudiobookId).Select(a => a.First()).ToList(); + } + + public AudiobookStatistics AudiobookStatistics(int audiobookId) + { + var stats = _audiobookStatisticsRepository.AudiobookStatistics(audiobookId); + + if (stats == null || stats.Count == 0) + { + return new AudiobookStatistics(); + } + + return stats.First(); + } + } +} diff --git a/src/NzbDrone.Core/BookStats/BookStatistics.cs b/src/NzbDrone.Core/BookStats/BookStatistics.cs new file mode 100644 index 0000000000..321da17581 --- /dev/null +++ b/src/NzbDrone.Core/BookStats/BookStatistics.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.BookStats +{ + public class BookStatistics : ResultSet + { + public int BookId { get; set; } + public int BookFileCount { get; set; } + public long SizeOnDisk { get; set; } + public string ReleaseGroupsString { get; set; } + + public List ReleaseGroups + { + get + { + var releaseGroups = new List(); + + if (ReleaseGroupsString.IsNotNullOrWhiteSpace()) + { + releaseGroups = ReleaseGroupsString + .Split('|') + .Distinct() + .Where(rg => rg.IsNotNullOrWhiteSpace()) + .OrderBy(rg => rg) + .ToList(); + } + + return releaseGroups; + } + } + } +} diff --git a/src/NzbDrone.Core/BookStats/BookStatisticsRepository.cs b/src/NzbDrone.Core/BookStats/BookStatisticsRepository.cs new file mode 100644 index 0000000000..9fd7c63e5a --- /dev/null +++ b/src/NzbDrone.Core/BookStats/BookStatisticsRepository.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Linq; +using Dapper; +using NzbDrone.Core.Books; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.MediaFiles; + +namespace NzbDrone.Core.BookStats +{ + public interface IBookStatisticsRepository + { + List BookStatistics(); + List BookStatistics(int bookId); + } + + public class BookStatisticsRepository : IBookStatisticsRepository + { + private const string _selectBooksTemplate = "SELECT /**select**/ FROM \"Books\" /**join**/ /**innerjoin**/ /**leftjoin**/ /**where**/ /**groupby**/ /**having**/ /**orderby**/"; + private const string _selectBookFilesTemplate = "SELECT /**select**/ FROM \"BookFiles\" /**join**/ /**innerjoin**/ /**leftjoin**/ /**where**/ /**groupby**/ /**having**/ /**orderby**/"; + + private readonly IMainDatabase _database; + + public BookStatisticsRepository(IMainDatabase database) + { + _database = database; + } + + public List BookStatistics() + { + return MapResults(Query(BooksBuilder(), _selectBooksTemplate), + Query(BookFilesBuilder(), _selectBookFilesTemplate)); + } + + public List BookStatistics(int bookId) + { + return MapResults(Query(BooksBuilder().Where(x => x.Id == bookId), _selectBooksTemplate), + Query(BookFilesBuilder().Where(x => x.BookId == bookId), _selectBookFilesTemplate)); + } + + private static List MapResults(List booksResult, List filesResult) + { + booksResult.ForEach(e => + { + var file = filesResult.SingleOrDefault(f => f.BookId == e.BookId); + + e.SizeOnDisk = file?.SizeOnDisk ?? 0; + e.ReleaseGroupsString = file?.ReleaseGroupsString; + }); + + return booksResult; + } + + private List Query(SqlBuilder builder, string template) + { + var sql = builder.AddTemplate(template).LogQuery(); + + using var conn = _database.OpenConnection(); + + return conn.Query(sql.RawSql, sql.Parameters).ToList(); + } + + private SqlBuilder BooksBuilder() + { + return new SqlBuilder(_database.DatabaseType) + .Select(@"""Books"".""Id"" AS BookId, + COUNT(""BookFiles"".""Id"") AS BookFileCount") + .LeftJoin((b, bf) => b.Id == bf.BookId) + .GroupBy(x => x.Id); + } + + private SqlBuilder BookFilesBuilder() + { + if (_database.DatabaseType == DatabaseType.SQLite) + { + return new SqlBuilder(_database.DatabaseType) + .Select(@"""BookId"", + SUM(COALESCE(""Size"", 0)) AS SizeOnDisk, + GROUP_CONCAT(""ReleaseGroup"", '|') AS ReleaseGroupsString") + .GroupBy(x => x.BookId); + } + + return new SqlBuilder(_database.DatabaseType) + .Select(@"""BookId"", + SUM(COALESCE(""Size"", 0)) AS SizeOnDisk, + string_agg(""ReleaseGroup"", '|') AS ReleaseGroupsString") + .GroupBy(x => x.BookId); + } + } +} diff --git a/src/NzbDrone.Core/BookStats/BookStatisticsService.cs b/src/NzbDrone.Core/BookStats/BookStatisticsService.cs new file mode 100644 index 0000000000..4f910181ad --- /dev/null +++ b/src/NzbDrone.Core/BookStats/BookStatisticsService.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.BookStats +{ + public interface IBookStatisticsService + { + List BookStatistics(); + BookStatistics BookStatistics(int bookId); + } + + public class BookStatisticsService : IBookStatisticsService + { + private readonly IBookStatisticsRepository _bookStatisticsRepository; + + public BookStatisticsService(IBookStatisticsRepository bookStatisticsRepository) + { + _bookStatisticsRepository = bookStatisticsRepository; + } + + public List BookStatistics() + { + var bookStatistics = _bookStatisticsRepository.BookStatistics(); + + return bookStatistics.GroupBy(b => b.BookId).Select(b => b.First()).ToList(); + } + + public BookStatistics BookStatistics(int bookId) + { + var stats = _bookStatisticsRepository.BookStatistics(bookId); + + if (stats == null || stats.Count == 0) + { + return new BookStatistics(); + } + + return stats.First(); + } + } +} diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index af268ab0f6..f7870d5a21 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -317,6 +317,7 @@ "CutoffUnmetLoadError": "Error loading cutoff unmet items", "CutoffUnmetNoItems": "No cutoff unmet items", "Dash": "Dash", + "Dashboard": "Dashboard", "Database": "Database", "DatabaseMigration": "Database Migration", "Date": "Date", @@ -1960,7 +1961,9 @@ "TorrentDelayTime": "Torrent Delay: {torrentDelay}", "Torrents": "Torrents", "TorrentsDisabled": "Torrents Disabled", + "TotalDuration": "Total Duration", "TotalFileSize": "Total File Size", + "TotalLibrarySize": "Total Library Size", "TotalMovies": "Total Movies", "TotalRecords": "Total records: {totalRecords}", "TotalSpace": "Total Space", @@ -2069,6 +2072,7 @@ "WhitelistedSubtitleTags": "Whitelisted Subtitle Tags", "WhySearchesCouldBeFailing": "Click here to find out why searches could be failing", "Wiki": "Wiki", + "WithFiles": "With Files", "WouldYouLikeToRestoreBackup": "Would you like to restore the backup '{name}'?", "XmlRpcPath": "XML RPC Path", "Year": "Year", diff --git a/src/Radarr.Api.V3/Audiobooks/AudiobookController.cs b/src/Radarr.Api.V3/Audiobooks/AudiobookController.cs index d56d6b493b..42ff7a854e 100644 --- a/src/Radarr.Api.V3/Audiobooks/AudiobookController.cs +++ b/src/Radarr.Api.V3/Audiobooks/AudiobookController.cs @@ -1,9 +1,11 @@ using System.Collections.Generic; +using System.Linq; using FluentValidation; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Audiobooks; using NzbDrone.Core.Audiobooks.Events; +using NzbDrone.Core.AudiobookStats; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Monitoring; @@ -27,11 +29,13 @@ public class AudiobookController : RestControllerWithSignalR s.Path).Cascade(CascadeMode.Stop) .IsValidPath() @@ -101,6 +106,8 @@ public List GetAudiobooks(int? authorId = null, int? seriesId var resources = audiobooks.ToResource(); var rootFolders = _rootFolderService.All(); + var audiobookStats = _audiobookStatisticsService.AudiobookStatistics(); + var sdict = audiobookStats.ToDictionary(x => x.AudiobookId); for (var i = 0; i < resources.Count; i++) { @@ -108,6 +115,8 @@ public List GetAudiobooks(int? authorId = null, int? seriesId resources[i].EffectivelyMonitored = _monitoringService.IsEffectivelyMonitored(audiobooks[i]); } + LinkAudiobookStatistics(resources, sdict); + return resources; } @@ -127,10 +136,34 @@ private AudiobookResource MapToResource(Audiobook audiobook) var resource = audiobook.ToResource(); resource.RootFolderPath = _rootFolderService.GetBestRootFolderPath(resource.Path); resource.EffectivelyMonitored = _monitoringService.IsEffectivelyMonitored(audiobook); + FetchAndLinkAudiobookStatistics(resource); return resource; } + private void FetchAndLinkAudiobookStatistics(AudiobookResource resource) + { + LinkAudiobookStatistics(resource, _audiobookStatisticsService.AudiobookStatistics(resource.Id)); + } + + private void LinkAudiobookStatistics(List resources, Dictionary sDict) + { + foreach (var audiobook in resources) + { + if (sDict.TryGetValue(audiobook.Id, out var stats)) + { + LinkAudiobookStatistics(audiobook, stats); + } + } + } + + private static void LinkAudiobookStatistics(AudiobookResource resource, AudiobookStatistics audiobookStatistics) + { + resource.Statistics = audiobookStatistics.ToResource(); + resource.HasFile = audiobookStatistics.AudiobookFileCount > 0; + resource.SizeOnDisk = audiobookStatistics.SizeOnDisk; + } + [RestPostById] [Consumes("application/json")] [Produces("application/json")] diff --git a/src/Radarr.Api.V3/Audiobooks/AudiobookResource.cs b/src/Radarr.Api.V3/Audiobooks/AudiobookResource.cs index 8fe3c744e8..c9f30f5bd4 100644 --- a/src/Radarr.Api.V3/Audiobooks/AudiobookResource.cs +++ b/src/Radarr.Api.V3/Audiobooks/AudiobookResource.cs @@ -41,6 +41,10 @@ public AudiobookResource() public int? SeriesId { get; set; } public int? SeriesPosition { get; set; } public int? BookId { get; set; } + + public bool? HasFile { get; set; } + public long? SizeOnDisk { get; set; } + public AudiobookStatisticsResource Statistics { get; set; } } public static class AudiobookResourceMapper diff --git a/src/Radarr.Api.V3/Audiobooks/AudiobookStatisticsResource.cs b/src/Radarr.Api.V3/Audiobooks/AudiobookStatisticsResource.cs new file mode 100644 index 0000000000..60ae06d8aa --- /dev/null +++ b/src/Radarr.Api.V3/Audiobooks/AudiobookStatisticsResource.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using NzbDrone.Core.AudiobookStats; + +namespace Radarr.Api.V3.Audiobooks +{ + public class AudiobookStatisticsResource + { + public int AudiobookFileCount { get; set; } + public long SizeOnDisk { get; set; } + public int TotalDurationMinutes { get; set; } + public List ReleaseGroups { get; set; } + } + + public static class AudiobookStatisticsResourceMapper + { + public static AudiobookStatisticsResource ToResource(this AudiobookStatistics model) + { + if (model == null) + { + return null; + } + + return new AudiobookStatisticsResource + { + AudiobookFileCount = model.AudiobookFileCount, + SizeOnDisk = model.SizeOnDisk, + TotalDurationMinutes = model.TotalDurationMinutes, + ReleaseGroups = model.ReleaseGroups + }; + } + } +} diff --git a/src/Radarr.Api.V3/Books/BookController.cs b/src/Radarr.Api.V3/Books/BookController.cs index d7d239ced6..ae97c510c6 100644 --- a/src/Radarr.Api.V3/Books/BookController.cs +++ b/src/Radarr.Api.V3/Books/BookController.cs @@ -1,9 +1,11 @@ using System.Collections.Generic; +using System.Linq; using FluentValidation; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.Books; using NzbDrone.Core.Books.Events; +using NzbDrone.Core.BookStats; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Monitoring; @@ -27,11 +29,13 @@ public class BookController : RestControllerWithSignalR, private readonly IBookService _bookService; private readonly IRootFolderService _rootFolderService; private readonly IHierarchicalMonitoringService _monitoringService; + private readonly IBookStatisticsService _bookStatisticsService; public BookController(IBroadcastSignalRMessage signalRBroadcaster, IBookService bookService, IRootFolderService rootFolderService, IHierarchicalMonitoringService monitoringService, + IBookStatisticsService bookStatisticsService, RootFolderValidator rootFolderValidator, MappedNetworkDriveValidator mappedNetworkDriveValidator, RecycleBinValidator recycleBinValidator, @@ -43,6 +47,7 @@ public BookController(IBroadcastSignalRMessage signalRBroadcaster, _bookService = bookService; _rootFolderService = rootFolderService; _monitoringService = monitoringService; + _bookStatisticsService = bookStatisticsService; SharedValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop) .IsValidPath() @@ -93,6 +98,8 @@ public List GetBooks(int? authorId = null, int? seriesId = null) var resources = books.ToResource(); var rootFolders = _rootFolderService.All(); + var bookStats = _bookStatisticsService.BookStatistics(); + var sdict = bookStats.ToDictionary(x => x.BookId); for (var i = 0; i < resources.Count; i++) { @@ -100,6 +107,8 @@ public List GetBooks(int? authorId = null, int? seriesId = null) resources[i].EffectivelyMonitored = _monitoringService.IsEffectivelyMonitored(books[i]); } + LinkBookStatistics(resources, sdict); + return resources; } @@ -119,10 +128,34 @@ private BookResource MapToResource(Book book) var resource = book.ToResource(); resource.RootFolderPath = _rootFolderService.GetBestRootFolderPath(resource.Path); resource.EffectivelyMonitored = _monitoringService.IsEffectivelyMonitored(book); + FetchAndLinkBookStatistics(resource); return resource; } + private void FetchAndLinkBookStatistics(BookResource resource) + { + LinkBookStatistics(resource, _bookStatisticsService.BookStatistics(resource.Id)); + } + + private void LinkBookStatistics(List resources, Dictionary sDict) + { + foreach (var book in resources) + { + if (sDict.TryGetValue(book.Id, out var stats)) + { + LinkBookStatistics(book, stats); + } + } + } + + private static void LinkBookStatistics(BookResource resource, BookStatistics bookStatistics) + { + resource.Statistics = bookStatistics.ToResource(); + resource.HasFile = bookStatistics.BookFileCount > 0; + resource.SizeOnDisk = bookStatistics.SizeOnDisk; + } + [RestPostById] [Consumes("application/json")] [Produces("application/json")] diff --git a/src/Radarr.Api.V3/Books/BookResource.cs b/src/Radarr.Api.V3/Books/BookResource.cs index 59474c63c8..511bae16e3 100644 --- a/src/Radarr.Api.V3/Books/BookResource.cs +++ b/src/Radarr.Api.V3/Books/BookResource.cs @@ -37,6 +37,10 @@ public BookResource() public int? AuthorId { get; set; } public int? SeriesId { get; set; } public int? SeriesPosition { get; set; } + + public bool? HasFile { get; set; } + public long? SizeOnDisk { get; set; } + public BookStatisticsResource Statistics { get; set; } } public static class BookResourceMapper diff --git a/src/Radarr.Api.V3/Books/BookStatisticsResource.cs b/src/Radarr.Api.V3/Books/BookStatisticsResource.cs new file mode 100644 index 0000000000..0528c02472 --- /dev/null +++ b/src/Radarr.Api.V3/Books/BookStatisticsResource.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using NzbDrone.Core.BookStats; + +namespace Radarr.Api.V3.Books +{ + public class BookStatisticsResource + { + public int BookFileCount { get; set; } + public long SizeOnDisk { get; set; } + public List ReleaseGroups { get; set; } + } + + public static class BookStatisticsResourceMapper + { + public static BookStatisticsResource ToResource(this BookStatistics model) + { + if (model == null) + { + return null; + } + + return new BookStatisticsResource + { + BookFileCount = model.BookFileCount, + SizeOnDisk = model.SizeOnDisk, + ReleaseGroups = model.ReleaseGroups + }; + } + } +} diff --git a/src/Radarr.Api.V3/Dashboard/DashboardController.cs b/src/Radarr.Api.V3/Dashboard/DashboardController.cs new file mode 100644 index 0000000000..494a46e152 --- /dev/null +++ b/src/Radarr.Api.V3/Dashboard/DashboardController.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Analytics; +using Radarr.Http; + +namespace Radarr.Api.V3.Dashboard +{ + [V3ApiController] + public class DashboardController : Controller + { + private readonly IDashboardService _dashboardService; + + public DashboardController(IDashboardService dashboardService) + { + _dashboardService = dashboardService; + } + + [HttpGet] + [Produces("application/json")] + public DashboardResource GetDashboard() + { + return _dashboardService.GetStatistics().ToResource(); + } + } +} diff --git a/src/Radarr.Api.V3/Dashboard/DashboardResource.cs b/src/Radarr.Api.V3/Dashboard/DashboardResource.cs new file mode 100644 index 0000000000..2c3d1eb8d1 --- /dev/null +++ b/src/Radarr.Api.V3/Dashboard/DashboardResource.cs @@ -0,0 +1,61 @@ +using NzbDrone.Core.Analytics; + +namespace Radarr.Api.V3.Dashboard +{ + public class DashboardResource + { + public MediaTypeStatisticsResource Movies { get; set; } + public MediaTypeStatisticsResource Books { get; set; } + public MediaTypeStatisticsResource Audiobooks { get; set; } + public long TotalSizeOnDisk { get; set; } + } + + public class MediaTypeStatisticsResource + { + public int Total { get; set; } + public int WithFiles { get; set; } + public int Missing { get; set; } + public int Monitored { get; set; } + public int Unmonitored { get; set; } + public long SizeOnDisk { get; set; } + public int TotalDurationMinutes { get; set; } + } + + public static class DashboardResourceMapper + { + public static DashboardResource ToResource(this DashboardStatistics model) + { + if (model == null) + { + return null; + } + + return new DashboardResource + { + Movies = model.Movies.ToResource(), + Books = model.Books.ToResource(), + Audiobooks = model.Audiobooks.ToResource(), + TotalSizeOnDisk = model.TotalSizeOnDisk + }; + } + + public static MediaTypeStatisticsResource ToResource(this MediaTypeStatistics model) + { + if (model == null) + { + return null; + } + + return new MediaTypeStatisticsResource + { + Total = model.Total, + WithFiles = model.WithFiles, + Missing = model.Missing, + Monitored = model.Monitored, + Unmonitored = model.Unmonitored, + SizeOnDisk = model.SizeOnDisk, + TotalDurationMinutes = model.TotalDurationMinutes + }; + } + } +}