feat: add statistics services and analytics dashboard (#133)

- Add BookStatistics and AudiobookStatistics services
- Create unified Dashboard with media type stats
- Add statistics to Book/Audiobook controllers
- Make dashboard the default landing page

Closes #6

Co-authored-by: admin <admin@ardentleatherworks.com>
This commit is contained in:
Cody Kickertz 2025-12-22 12:55:05 -06:00 committed by GitHub
parent a187cee132
commit 4e98097e1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1109 additions and 4 deletions

View file

@ -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 (
<Switch>
{/*
Movies
Dashboard
*/}
<Route exact={true} path="/" component={MovieIndex} />
<Route exact={true} path="/" component={Dashboard} />
{window.Radarr.urlBase && (
<Route
@ -69,6 +70,14 @@ function AppRoutes() {
/>
)}
<Route path="/dashboard" component={Dashboard} />
{/*
Movies
*/}
<Route exact={true} path="/movies" component={MovieIndex} />
<Route path="/add/new" component={AddNewMovieConnector} />
<Route path="/collections" component={CollectionConnector} />

View file

@ -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;

View file

@ -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<DashboardStatistics>;
export default DashboardAppState;

View file

@ -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'),

View file

@ -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);
}

View file

@ -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;

View file

@ -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 (
<div className={styles.mediaCard}>
<div className={styles.mediaCardHeader}>
<Icon name={icon} size={24} />
<h3 className={styles.mediaCardTitle}>{title}</h3>
</div>
<div className={styles.statsRow}>
<span className={styles.statsLabel}>{translate('Total')}</span>
<span className={styles.statsValue}>{stats.total}</span>
</div>
<div className={styles.statsRow}>
<span className={styles.statsLabel}>{translate('WithFiles')}</span>
<span className={styles.statsValue}>{stats.withFiles}</span>
</div>
<div className={styles.statsRow}>
<span className={styles.statsLabel}>{translate('Missing')}</span>
<span className={styles.statsValue}>{stats.missing}</span>
</div>
<div className={styles.statsRow}>
<span className={styles.statsLabel}>{translate('Monitored')}</span>
<span className={styles.statsValue}>{stats.monitored}</span>
</div>
<div className={styles.statsRow}>
<span className={styles.statsLabel}>{translate('Unmonitored')}</span>
<span className={styles.statsValue}>{stats.unmonitored}</span>
</div>
<div className={styles.statsRow}>
<span className={styles.statsLabel}>{translate('SizeOnDisk')}</span>
<span className={styles.statsValue}>
{formatBytes(stats.sizeOnDisk)}
</span>
</div>
{showDuration && stats.totalDurationMinutes > 0 && (
<div className={styles.statsRow}>
<span className={styles.statsLabel}>
{translate('TotalDuration')}
</span>
<span className={styles.statsValue}>
{Math.floor(stats.totalDurationMinutes / 60)}h{' '}
{stats.totalDurationMinutes % 60}m
</span>
</div>
)}
</div>
);
}
function Dashboard() {
const dispatch = useDispatch();
const { isFetching, item } = useSelector(createDashboardSelector());
useEffect(() => {
dispatch(fetchDashboard());
}, [dispatch]);
return (
<PageContent title={translate('Dashboard')}>
<PageContentBody>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && item ? (
<div className={styles.dashboard}>
<div className={styles.statsGrid}>
<MediaTypeCard
title={translate('Movies')}
icon={icons.MOVIE_FILE}
stats={item.movies}
/>
<MediaTypeCard
title={translate('Books')}
icon={icons.BOOK}
stats={item.books}
/>
<MediaTypeCard
title={translate('Audiobooks')}
icon={icons.AUDIOBOOK}
stats={item.audiobooks}
showDuration={true}
/>
</div>
<div className={styles.totalCard}>
<h3 className={styles.totalCardTitle}>
{translate('TotalLibrarySize')}
</h3>
<span className={styles.totalValue}>
{formatBytes(item.totalSizeOnDisk)}
</span>
</div>
</div>
) : null}
</PageContentBody>
</PageContent>
);
}
export default Dashboard;

View file

@ -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);

View file

@ -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,

View file

@ -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
};
}
}
}

View file

@ -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; }
}
}

View file

@ -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<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,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> AudiobookStatistics();
List<AudiobookStatistics> 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> AudiobookStatistics()
{
return MapResults(Query(AudiobooksBuilder(), _selectAudiobooksTemplate),
Query(AudiobookFilesBuilder(), _selectAudiobookFilesTemplate));
}
public List<AudiobookStatistics> AudiobookStatistics(int audiobookId)
{
return MapResults(Query(AudiobooksBuilder().Where<Audiobook>(x => x.Id == audiobookId), _selectAudiobooksTemplate),
Query(AudiobookFilesBuilder().Where<AudiobookFile>(x => x.AudiobookId == audiobookId), _selectAudiobookFilesTemplate));
}
private static List<AudiobookStatistics> MapResults(List<AudiobookStatistics> audiobooksResult, List<AudiobookStatistics> 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<AudiobookStatistics> Query(SqlBuilder builder, string template)
{
var sql = builder.AddTemplate(template).LogQuery();
using var conn = _database.OpenConnection();
return conn.Query<AudiobookStatistics>(sql.RawSql, sql.Parameters).ToList();
}
private SqlBuilder AudiobooksBuilder()
{
return new SqlBuilder(_database.DatabaseType)
.Select(@"""Audiobooks"".""Id"" AS AudiobookId,
COUNT(""AudiobookFiles"".""Id"") AS AudiobookFileCount")
.LeftJoin<Audiobook, AudiobookFile>((a, af) => a.Id == af.AudiobookId)
.GroupBy<Audiobook>(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<AudiobookFile>(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<AudiobookFile>(x => x.AudiobookId);
}
}
}

View file

@ -0,0 +1,40 @@
using System.Collections.Generic;
using System.Linq;
namespace NzbDrone.Core.AudiobookStats
{
public interface IAudiobookStatisticsService
{
List<AudiobookStatistics> AudiobookStatistics();
AudiobookStatistics AudiobookStatistics(int audiobookId);
}
public class AudiobookStatisticsService : IAudiobookStatisticsService
{
private readonly IAudiobookStatisticsRepository _audiobookStatisticsRepository;
public AudiobookStatisticsService(IAudiobookStatisticsRepository audiobookStatisticsRepository)
{
_audiobookStatisticsRepository = audiobookStatisticsRepository;
}
public List<AudiobookStatistics> 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();
}
}
}

View file

@ -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<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,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> BookStatistics();
List<BookStatistics> 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> BookStatistics()
{
return MapResults(Query(BooksBuilder(), _selectBooksTemplate),
Query(BookFilesBuilder(), _selectBookFilesTemplate));
}
public List<BookStatistics> BookStatistics(int bookId)
{
return MapResults(Query(BooksBuilder().Where<Book>(x => x.Id == bookId), _selectBooksTemplate),
Query(BookFilesBuilder().Where<BookFile>(x => x.BookId == bookId), _selectBookFilesTemplate));
}
private static List<BookStatistics> MapResults(List<BookStatistics> booksResult, List<BookStatistics> 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<BookStatistics> Query(SqlBuilder builder, string template)
{
var sql = builder.AddTemplate(template).LogQuery();
using var conn = _database.OpenConnection();
return conn.Query<BookStatistics>(sql.RawSql, sql.Parameters).ToList();
}
private SqlBuilder BooksBuilder()
{
return new SqlBuilder(_database.DatabaseType)
.Select(@"""Books"".""Id"" AS BookId,
COUNT(""BookFiles"".""Id"") AS BookFileCount")
.LeftJoin<Book, BookFile>((b, bf) => b.Id == bf.BookId)
.GroupBy<Book>(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<BookFile>(x => x.BookId);
}
return new SqlBuilder(_database.DatabaseType)
.Select(@"""BookId"",
SUM(COALESCE(""Size"", 0)) AS SizeOnDisk,
string_agg(""ReleaseGroup"", '|') AS ReleaseGroupsString")
.GroupBy<BookFile>(x => x.BookId);
}
}
}

View file

@ -0,0 +1,40 @@
using System.Collections.Generic;
using System.Linq;
namespace NzbDrone.Core.BookStats
{
public interface IBookStatisticsService
{
List<BookStatistics> BookStatistics();
BookStatistics BookStatistics(int bookId);
}
public class BookStatisticsService : IBookStatisticsService
{
private readonly IBookStatisticsRepository _bookStatisticsRepository;
public BookStatisticsService(IBookStatisticsRepository bookStatisticsRepository)
{
_bookStatisticsRepository = bookStatisticsRepository;
}
public List<BookStatistics> 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();
}
}
}

View file

@ -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",

View file

@ -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<AudiobookResource,
private readonly IAudiobookService _audiobookService;
private readonly IRootFolderService _rootFolderService;
private readonly IHierarchicalMonitoringService _monitoringService;
private readonly IAudiobookStatisticsService _audiobookStatisticsService;
public AudiobookController(IBroadcastSignalRMessage signalRBroadcaster,
IAudiobookService audiobookService,
IRootFolderService rootFolderService,
IHierarchicalMonitoringService monitoringService,
IAudiobookStatisticsService audiobookStatisticsService,
RootFolderValidator rootFolderValidator,
MappedNetworkDriveValidator mappedNetworkDriveValidator,
RecycleBinValidator recycleBinValidator,
@ -43,6 +47,7 @@ public AudiobookController(IBroadcastSignalRMessage signalRBroadcaster,
_audiobookService = audiobookService;
_rootFolderService = rootFolderService;
_monitoringService = monitoringService;
_audiobookStatisticsService = audiobookStatisticsService;
SharedValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop)
.IsValidPath()
@ -101,6 +106,8 @@ public List<AudiobookResource> 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<AudiobookResource> 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<AudiobookResource> resources, Dictionary<int, AudiobookStatistics> 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")]

View file

@ -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

View file

@ -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<string> 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
};
}
}
}

View file

@ -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<BookResource, Book>,
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<BookResource> 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<BookResource> 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<BookResource> resources, Dictionary<int, BookStatistics> 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")]

View file

@ -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

View file

@ -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<string> 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
};
}
}
}

View file

@ -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();
}
}
}

View file

@ -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
};
}
}
}