mirror of
https://github.com/Radarr/Radarr
synced 2026-01-23 16:04:18 +01:00
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:
parent
a187cee132
commit
4e98097e1a
26 changed files with 1109 additions and 4 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
22
frontend/src/App/State/DashboardAppState.ts
Normal file
22
frontend/src/App/State/DashboardAppState.ts
Normal 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;
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
72
frontend/src/Dashboard/Dashboard.css
Normal file
72
frontend/src/Dashboard/Dashboard.css
Normal 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);
|
||||
}
|
||||
17
frontend/src/Dashboard/Dashboard.css.d.ts
vendored
Normal file
17
frontend/src/Dashboard/Dashboard.css.d.ts
vendored
Normal 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;
|
||||
138
frontend/src/Dashboard/Dashboard.tsx
Normal file
138
frontend/src/Dashboard/Dashboard.tsx
Normal 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;
|
||||
40
frontend/src/Store/Actions/dashboardActions.js
Normal file
40
frontend/src/Store/Actions/dashboardActions.js
Normal 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);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
217
src/NzbDrone.Core/Analytics/DashboardService.cs
Normal file
217
src/NzbDrone.Core/Analytics/DashboardService.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/NzbDrone.Core/Analytics/DashboardStatistics.cs
Normal file
21
src/NzbDrone.Core/Analytics/DashboardStatistics.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
38
src/NzbDrone.Core/AudiobookStats/AudiobookStatistics.cs
Normal file
38
src/NzbDrone.Core/AudiobookStats/AudiobookStatistics.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
35
src/NzbDrone.Core/BookStats/BookStatistics.cs
Normal file
35
src/NzbDrone.Core/BookStats/BookStatistics.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
89
src/NzbDrone.Core/BookStats/BookStatisticsRepository.cs
Normal file
89
src/NzbDrone.Core/BookStats/BookStatisticsRepository.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/NzbDrone.Core/BookStats/BookStatisticsService.cs
Normal file
40
src/NzbDrone.Core/BookStats/BookStatisticsService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
32
src/Radarr.Api.V3/Audiobooks/AudiobookStatisticsResource.cs
Normal file
32
src/Radarr.Api.V3/Audiobooks/AudiobookStatisticsResource.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
30
src/Radarr.Api.V3/Books/BookStatisticsResource.cs
Normal file
30
src/Radarr.Api.V3/Books/BookStatisticsResource.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/Radarr.Api.V3/Dashboard/DashboardController.cs
Normal file
24
src/Radarr.Api.V3/Dashboard/DashboardController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/Radarr.Api.V3/Dashboard/DashboardResource.cs
Normal file
61
src/Radarr.Api.V3/Dashboard/DashboardResource.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue