Per-track monitoring for Lidarr — track-level Monitored flag + UI + stats fixes
Squashes 8 fork commits onto upstream/develop as a single diff for cleaner cross-fork comparison. Original history preserved on pre-squash-backup tag locally. Motivation ────────── Upstream Lidarr only supports per-album monitoring. This fork adds a per-track Monitored bit, exposed via the API, so a companion tool (musicseerr fork: shaunrd0/musicseerr) can selectively grab a single track from an album without flipping the whole album's monitor state. Changes ─────── • Track POCO + persistence src/NzbDrone.Core/Music/Model/Track.cs gains a Monitored property (default true — new tracks start monitored, matching the historical behavior where every track on a monitored album was implicitly in-scope). AlbumRepository / TrackRepository / TrackService updated to read, write, and bulk-update the field. • UI: per-track monitor toggle on album details page Adds a checkbox column in the Album Details track list so users can flip individual tracks Monitored/Unmonitored without using the API. • UI: Tracks column on Wanted/Missing and Wanted/CutoffUnmet Adds an explicit "Tracks" column to those views so the row shows monitored-tracks / total-tracks for each album, making partial-album state visible at a glance. • Stats correctness TrackCount on album-level stats is now gated by Tracks.Monitored (an album where every track is unmonitored should not be counted as having wanted/missing tracks). Wanted Tracks denominator switched from monitored-track-count to total trackCount, matching the semantics of the new column. • Build: custom Dockerfile.fork for local image Multi-stage build producing a hotio-compatible runtime image, targeting linux-musl-x64 (hotio's base is Alpine). Used by the lidarr-personal and lidarr-shared instances; lidarr (the public stock instance on gnat:8686) continues running upstream hotio. Compatibility ───────────── The schema migration is purely additive (one new BOOLEAN column with a true default), so a fork → upstream rollback works without data loss — the column simply becomes dead weight on disk.
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
#
|
||||||
|
# Multi-stage build for the local Lidarr fork (shaunrd0/Lidarr).
|
||||||
|
#
|
||||||
|
# Stage 1 (builder): .NET 8 SDK + Node 20 + yarn — runs upstream's build.sh
|
||||||
|
# targeting linux-musl-x64/net8.0 only.
|
||||||
|
# Stage 2 (runtime): hotio/lidarr:nightly — inherits hotio's s6-overlay
|
||||||
|
# PUID/PGID/UMASK entrypoint; we only swap in our binaries
|
||||||
|
# over /app/bin/. Keeps lidarr-shared's config + restic
|
||||||
|
# backup story unchanged.
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS builder
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl ca-certificates gnupg \
|
||||||
|
&& mkdir -p /etc/apt/keyrings \
|
||||||
|
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
|
||||||
|
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
|
&& npm install -g yarn \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# build.sh respects --backend / --frontend / --packages / --runtime / --framework
|
||||||
|
RUN ./build.sh --backend --frontend --packages --runtime linux-musl-x64 --framework net8.0
|
||||||
|
|
||||||
|
# Sanity check: the artifact directory must exist and contain Lidarr.dll
|
||||||
|
RUN test -f /src/_artifacts/linux-musl-x64/net8.0/Lidarr/Lidarr.dll || \
|
||||||
|
(echo "BUILD FAILED — Lidarr.dll missing from artifacts" && ls -la /src/_artifacts/ && exit 1)
|
||||||
|
|
||||||
|
|
||||||
|
FROM ghcr.io/hotio/lidarr:nightly
|
||||||
|
|
||||||
|
# Replace the hotio image's bundled binaries with our fork's build. Hotio's
|
||||||
|
# entrypoint, s6 services, PUID/PGID handling, and /config volume all stay
|
||||||
|
# the same; only the .NET app code changes.
|
||||||
|
RUN rm -rf /app/bin
|
||||||
|
|
||||||
|
COPY --from=builder --chown=root:root /src/_artifacts/linux-musl-x64/net8.0/Lidarr/ /app/bin/
|
||||||
|
|
||||||
|
# Stamp a marker file so it's obvious from inside the container which build
|
||||||
|
# is running. Useful for "is this the fork?" debugging.
|
||||||
|
RUN echo "fork: shaunrd0/Lidarr, track-monitored feature, built $(date -u +%Y-%m-%dT%H:%M:%SZ)" > /app/bin/FORK_INFO.txt
|
||||||
@@ -4,6 +4,7 @@ import AlbumFormats from 'Album/AlbumFormats';
|
|||||||
import EpisodeStatusConnector from 'Album/EpisodeStatusConnector';
|
import EpisodeStatusConnector from 'Album/EpisodeStatusConnector';
|
||||||
import IndexerFlags from 'Album/IndexerFlags';
|
import IndexerFlags from 'Album/IndexerFlags';
|
||||||
import Icon from 'Components/Icon';
|
import Icon from 'Components/Icon';
|
||||||
|
import MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||||
import TableRow from 'Components/Table/TableRow';
|
import TableRow from 'Components/Table/TableRow';
|
||||||
import Popover from 'Components/Tooltip/Popover';
|
import Popover from 'Components/Tooltip/Popover';
|
||||||
@@ -20,6 +21,13 @@ import styles from './TrackRow.css';
|
|||||||
|
|
||||||
class TrackRow extends Component {
|
class TrackRow extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onMonitorTrackPress = (monitored) => {
|
||||||
|
this.props.onMonitorTrackPress(this.props.id, monitored);
|
||||||
|
};
|
||||||
|
|
||||||
//
|
//
|
||||||
// Render
|
// Render
|
||||||
|
|
||||||
@@ -32,6 +40,8 @@ class TrackRow extends Component {
|
|||||||
absoluteTrackNumber,
|
absoluteTrackNumber,
|
||||||
title,
|
title,
|
||||||
duration,
|
duration,
|
||||||
|
monitored,
|
||||||
|
isSaving,
|
||||||
trackFilePath,
|
trackFilePath,
|
||||||
trackFileSize,
|
trackFileSize,
|
||||||
customFormats,
|
customFormats,
|
||||||
@@ -54,6 +64,21 @@ class TrackRow extends Component {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (name === 'monitored') {
|
||||||
|
return (
|
||||||
|
<TableRowCell
|
||||||
|
key={name}
|
||||||
|
className={styles.monitored}
|
||||||
|
>
|
||||||
|
<MonitorToggleButton
|
||||||
|
monitored={monitored}
|
||||||
|
isSaving={isSaving}
|
||||||
|
onPress={this.onMonitorTrackPress}
|
||||||
|
/>
|
||||||
|
</TableRowCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (name === 'medium') {
|
if (name === 'medium') {
|
||||||
return (
|
return (
|
||||||
<TableRowCell
|
<TableRowCell
|
||||||
@@ -218,6 +243,7 @@ class TrackRow extends Component {
|
|||||||
|
|
||||||
TrackRow.propTypes = {
|
TrackRow.propTypes = {
|
||||||
deleteTrackFile: PropTypes.func.isRequired,
|
deleteTrackFile: PropTypes.func.isRequired,
|
||||||
|
onMonitorTrackPress: PropTypes.func.isRequired,
|
||||||
id: PropTypes.number.isRequired,
|
id: PropTypes.number.isRequired,
|
||||||
albumId: PropTypes.number.isRequired,
|
albumId: PropTypes.number.isRequired,
|
||||||
trackFileId: PropTypes.number,
|
trackFileId: PropTypes.number,
|
||||||
@@ -226,6 +252,7 @@ TrackRow.propTypes = {
|
|||||||
absoluteTrackNumber: PropTypes.number,
|
absoluteTrackNumber: PropTypes.number,
|
||||||
title: PropTypes.string.isRequired,
|
title: PropTypes.string.isRequired,
|
||||||
duration: PropTypes.number.isRequired,
|
duration: PropTypes.number.isRequired,
|
||||||
|
monitored: PropTypes.bool,
|
||||||
isSaving: PropTypes.bool,
|
isSaving: PropTypes.bool,
|
||||||
trackFilePath: PropTypes.string,
|
trackFilePath: PropTypes.string,
|
||||||
trackFileSize: PropTypes.number,
|
trackFileSize: PropTypes.number,
|
||||||
@@ -238,7 +265,9 @@ TrackRow.propTypes = {
|
|||||||
|
|
||||||
TrackRow.defaultProps = {
|
TrackRow.defaultProps = {
|
||||||
customFormats: [],
|
customFormats: [],
|
||||||
indexerFlags: 0
|
indexerFlags: 0,
|
||||||
|
monitored: true,
|
||||||
|
isSaving: false
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TrackRow;
|
export default TrackRow;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
import { toggleTrackMonitored } from 'Store/Actions/trackActions';
|
||||||
import { deleteTrackFile } from 'Store/Actions/trackFileActions';
|
import { deleteTrackFile } from 'Store/Actions/trackFileActions';
|
||||||
import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector';
|
import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector';
|
||||||
import TrackRow from './TrackRow';
|
import TrackRow from './TrackRow';
|
||||||
@@ -7,9 +8,16 @@ import TrackRow from './TrackRow';
|
|||||||
function createMapStateToProps() {
|
function createMapStateToProps() {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
(state, { id }) => id,
|
(state, { id }) => id,
|
||||||
|
(state) => state.tracks.items,
|
||||||
createTrackFileSelector(),
|
createTrackFileSelector(),
|
||||||
(id, trackFile) => {
|
(id, trackItems, trackFile) => {
|
||||||
|
// The connector is given the track id directly; pluck the live record
|
||||||
|
// from state.tracks.items so we get isSaving (set by the toggle thunk)
|
||||||
|
// without re-fetching.
|
||||||
|
const track = trackItems.find((t) => t.id === id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
isSaving: track ? !!track.isSaving : false,
|
||||||
trackFilePath: trackFile ? trackFile.path : null,
|
trackFilePath: trackFile ? trackFile.path : null,
|
||||||
trackFileSize: trackFile ? trackFile.size : null,
|
trackFileSize: trackFile ? trackFile.size : null,
|
||||||
customFormats: trackFile ? trackFile.customFormats : [],
|
customFormats: trackFile ? trackFile.customFormats : [],
|
||||||
@@ -21,7 +29,10 @@ function createMapStateToProps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
deleteTrackFile
|
deleteTrackFile,
|
||||||
|
onMonitorTrackPress(trackId, monitored) {
|
||||||
|
return toggleTrackMonitored({ trackId, monitored });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(createMapStateToProps, mapDispatchToProps)(TrackRow);
|
export default connect(createMapStateToProps, mapDispatchToProps)(TrackRow);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createAction } from 'redux-actions';
|
import { createAction } from 'redux-actions';
|
||||||
|
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
||||||
import Icon from 'Components/Icon';
|
import Icon from 'Components/Icon';
|
||||||
import { icons, sortDirections } from 'Helpers/Props';
|
import { icons, sortDirections } from 'Helpers/Props';
|
||||||
|
import { updateItem } from 'Store/Actions/baseActions';
|
||||||
import { createThunk, handleThunks } from 'Store/thunks';
|
import { createThunk, handleThunks } from 'Store/thunks';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import createFetchHandler from './Creators/createFetchHandler';
|
import createFetchHandler from './Creators/createFetchHandler';
|
||||||
@@ -28,6 +30,12 @@ export const defaultState = {
|
|||||||
items: [],
|
items: [],
|
||||||
|
|
||||||
columns: [
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'monitored',
|
||||||
|
columnLabel: () => translate('Monitored'),
|
||||||
|
isVisible: true,
|
||||||
|
isModifiable: false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'medium',
|
name: 'medium',
|
||||||
label: () => translate('Medium'),
|
label: () => translate('Medium'),
|
||||||
@@ -113,6 +121,7 @@ export const FETCH_TRACKS = 'tracks/fetchTracks';
|
|||||||
export const SET_TRACKS_SORT = 'tracks/setTracksSort';
|
export const SET_TRACKS_SORT = 'tracks/setTracksSort';
|
||||||
export const SET_TRACKS_TABLE_OPTION = 'tracks/setTracksTableOption';
|
export const SET_TRACKS_TABLE_OPTION = 'tracks/setTracksTableOption';
|
||||||
export const CLEAR_TRACKS = 'tracks/clearTracks';
|
export const CLEAR_TRACKS = 'tracks/clearTracks';
|
||||||
|
export const TOGGLE_TRACK_MONITORED = 'tracks/toggleTrackMonitored';
|
||||||
|
|
||||||
//
|
//
|
||||||
// Action Creators
|
// Action Creators
|
||||||
@@ -121,13 +130,52 @@ export const fetchTracks = createThunk(FETCH_TRACKS);
|
|||||||
export const setTracksSort = createAction(SET_TRACKS_SORT);
|
export const setTracksSort = createAction(SET_TRACKS_SORT);
|
||||||
export const setTracksTableOption = createAction(SET_TRACKS_TABLE_OPTION);
|
export const setTracksTableOption = createAction(SET_TRACKS_TABLE_OPTION);
|
||||||
export const clearTracks = createAction(CLEAR_TRACKS);
|
export const clearTracks = createAction(CLEAR_TRACKS);
|
||||||
|
export const toggleTrackMonitored = createThunk(TOGGLE_TRACK_MONITORED);
|
||||||
|
|
||||||
//
|
//
|
||||||
// Action Handlers
|
// Action Handlers
|
||||||
|
|
||||||
export const actionHandlers = handleThunks({
|
export const actionHandlers = handleThunks({
|
||||||
[FETCH_TRACKS]: createFetchHandler(section, '/track')
|
[FETCH_TRACKS]: createFetchHandler(section, '/track'),
|
||||||
|
|
||||||
|
// Track-level monitor toggle — calls the fork-only PUT /track/monitor.
|
||||||
|
// Stock Lidarr nightly returns 405 (endpoint doesn't exist); the UI
|
||||||
|
// surfaces that via the spinner timing out / failing — caller should
|
||||||
|
// only show the column when the backend supports it.
|
||||||
|
[TOGGLE_TRACK_MONITORED]: function(getState, payload, dispatch) {
|
||||||
|
const { trackId, monitored } = payload;
|
||||||
|
|
||||||
|
dispatch(updateItem({
|
||||||
|
id: trackId,
|
||||||
|
section,
|
||||||
|
isSaving: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
const promise = createAjaxRequest({
|
||||||
|
url: '/track/monitor',
|
||||||
|
method: 'PUT',
|
||||||
|
contentType: 'application/json',
|
||||||
|
data: JSON.stringify({ trackIds: [trackId], monitored }),
|
||||||
|
dataType: 'json'
|
||||||
|
}).request;
|
||||||
|
|
||||||
|
promise.done(() => {
|
||||||
|
dispatch(updateItem({
|
||||||
|
id: trackId,
|
||||||
|
section,
|
||||||
|
isSaving: false,
|
||||||
|
monitored
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
promise.fail(() => {
|
||||||
|
dispatch(updateItem({
|
||||||
|
id: trackId,
|
||||||
|
section,
|
||||||
|
isSaving: false
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ export const defaultState = {
|
|||||||
isSortable: true,
|
isSortable: true,
|
||||||
isVisible: true
|
isVisible: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'tracks',
|
||||||
|
label: () => translate('Tracks'),
|
||||||
|
isSortable: false,
|
||||||
|
isVisible: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'releaseDate',
|
name: 'releaseDate',
|
||||||
label: () => translate('ReleaseDate'),
|
label: () => translate('ReleaseDate'),
|
||||||
@@ -126,6 +132,12 @@ export const defaultState = {
|
|||||||
isSortable: true,
|
isSortable: true,
|
||||||
isVisible: true
|
isVisible: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'tracks',
|
||||||
|
label: () => translate('Tracks'),
|
||||||
|
isSortable: false,
|
||||||
|
isVisible: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'releaseDate',
|
name: 'releaseDate',
|
||||||
label: () => translate('ReleaseDate'),
|
label: () => translate('ReleaseDate'),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function CutoffUnmetRow(props) {
|
|||||||
title,
|
title,
|
||||||
lastSearchTime,
|
lastSearchTime,
|
||||||
disambiguation,
|
disambiguation,
|
||||||
|
statistics,
|
||||||
isSelected,
|
isSelected,
|
||||||
columns,
|
columns,
|
||||||
onSelectedChange
|
onSelectedChange
|
||||||
@@ -31,6 +32,11 @@ function CutoffUnmetRow(props) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
trackFileCount = 0,
|
||||||
|
trackCount = 0
|
||||||
|
} = statistics || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableSelectCell
|
<TableSelectCell
|
||||||
@@ -81,6 +87,14 @@ function CutoffUnmetRow(props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (name === 'tracks') {
|
||||||
|
return (
|
||||||
|
<TableRowCell key={name}>
|
||||||
|
{trackFileCount}/{trackCount}
|
||||||
|
</TableRowCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (name === 'releaseDate') {
|
if (name === 'releaseDate') {
|
||||||
return (
|
return (
|
||||||
<RelativeDateCellConnector
|
<RelativeDateCellConnector
|
||||||
@@ -144,6 +158,7 @@ CutoffUnmetRow.propTypes = {
|
|||||||
title: PropTypes.string.isRequired,
|
title: PropTypes.string.isRequired,
|
||||||
lastSearchTime: PropTypes.string,
|
lastSearchTime: PropTypes.string,
|
||||||
disambiguation: PropTypes.string,
|
disambiguation: PropTypes.string,
|
||||||
|
statistics: PropTypes.object,
|
||||||
isSelected: PropTypes.bool,
|
isSelected: PropTypes.bool,
|
||||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
onSelectedChange: PropTypes.func.isRequired
|
onSelectedChange: PropTypes.func.isRequired
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ function MissingRow(props) {
|
|||||||
title,
|
title,
|
||||||
lastSearchTime,
|
lastSearchTime,
|
||||||
disambiguation,
|
disambiguation,
|
||||||
|
statistics,
|
||||||
isSelected,
|
isSelected,
|
||||||
columns,
|
columns,
|
||||||
onSelectedChange
|
onSelectedChange
|
||||||
@@ -28,6 +29,11 @@ function MissingRow(props) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
trackFileCount = 0,
|
||||||
|
trackCount = 0
|
||||||
|
} = statistics || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableSelectCell
|
<TableSelectCell
|
||||||
@@ -78,6 +84,18 @@ function MissingRow(props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (name === 'tracks') {
|
||||||
|
// trackCount = "wanted" tracks (monitored + released, or with-file).
|
||||||
|
// With the fork's Tracks.Monitored column this matches what the
|
||||||
|
// user actually expects to see downloaded for this album — not
|
||||||
|
// the full release track count (which would be totalTrackCount).
|
||||||
|
return (
|
||||||
|
<TableRowCell key={name}>
|
||||||
|
{trackFileCount}/{trackCount}
|
||||||
|
</TableRowCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (name === 'releaseDate') {
|
if (name === 'releaseDate') {
|
||||||
return (
|
return (
|
||||||
<RelativeDateCellConnector
|
<RelativeDateCellConnector
|
||||||
@@ -125,6 +143,7 @@ MissingRow.propTypes = {
|
|||||||
title: PropTypes.string.isRequired,
|
title: PropTypes.string.isRequired,
|
||||||
lastSearchTime: PropTypes.string,
|
lastSearchTime: PropTypes.string,
|
||||||
disambiguation: PropTypes.string,
|
disambiguation: PropTypes.string,
|
||||||
|
statistics: PropTypes.object,
|
||||||
isSelected: PropTypes.bool,
|
isSelected: PropTypes.bool,
|
||||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
onSelectedChange: PropTypes.func.isRequired
|
onSelectedChange: PropTypes.func.isRequired
|
||||||
|
|||||||
@@ -50,5 +50,18 @@ namespace Lidarr.Api.V1.Tracks
|
|||||||
|
|
||||||
return MapToResource(_trackService.GetTracks(trackIds), false, false);
|
return MapToResource(_trackService.GetTracks(trackIds), false, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPut("monitor")]
|
||||||
|
[Consumes("application/json")]
|
||||||
|
public IActionResult SetMonitored([FromBody] TracksMonitoredResource resource)
|
||||||
|
{
|
||||||
|
if (resource?.TrackIds == null || resource.TrackIds.Count == 0)
|
||||||
|
{
|
||||||
|
throw new BadRequestException("trackIds must contain at least one id");
|
||||||
|
}
|
||||||
|
|
||||||
|
_trackService.SetMonitored(resource.TrackIds, resource.Monitored);
|
||||||
|
return Accepted(MapToResource(_trackService.GetTracks(resource.TrackIds), false, false));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ namespace Lidarr.Api.V1.Tracks
|
|||||||
public TrackFileResource TrackFile { get; set; }
|
public TrackFileResource TrackFile { get; set; }
|
||||||
public int MediumNumber { get; set; }
|
public int MediumNumber { get; set; }
|
||||||
public bool HasFile { get; set; }
|
public bool HasFile { get; set; }
|
||||||
|
public bool Monitored { get; set; }
|
||||||
|
|
||||||
public ArtistResource Artist { get; set; }
|
public ArtistResource Artist { get; set; }
|
||||||
public Ratings Ratings { get; set; }
|
public Ratings Ratings { get; set; }
|
||||||
@@ -59,6 +60,7 @@ namespace Lidarr.Api.V1.Tracks
|
|||||||
Duration = model.Duration,
|
Duration = model.Duration,
|
||||||
MediumNumber = model.MediumNumber,
|
MediumNumber = model.MediumNumber,
|
||||||
HasFile = model.HasFile,
|
HasFile = model.HasFile,
|
||||||
|
Monitored = model.Monitored,
|
||||||
Ratings = model.Ratings,
|
Ratings = model.Ratings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Lidarr.Api.V1.Tracks
|
||||||
|
{
|
||||||
|
public class TracksMonitoredResource
|
||||||
|
{
|
||||||
|
public List<int> TrackIds { get; set; }
|
||||||
|
public bool Monitored { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,7 +75,7 @@ namespace NzbDrone.Core.ArtistStats
|
|||||||
""Albums"".""Id"" AS ""AlbumId"",
|
""Albums"".""Id"" AS ""AlbumId"",
|
||||||
COUNT(""Tracks"".""Id"") AS ""TotalTrackCount"",
|
COUNT(""Tracks"".""Id"") AS ""TotalTrackCount"",
|
||||||
SUM(CASE WHEN ""Albums"".""ReleaseDate"" <= @currentDate OR ""Tracks"".""TrackFileId"" > 0 THEN 1 ELSE 0 END) AS ""AvailableTrackCount"",
|
SUM(CASE WHEN ""Albums"".""ReleaseDate"" <= @currentDate OR ""Tracks"".""TrackFileId"" > 0 THEN 1 ELSE 0 END) AS ""AvailableTrackCount"",
|
||||||
SUM(CASE WHEN (""Albums"".""Monitored"" = {trueIndicator} AND ""Albums"".""ReleaseDate"" <= @currentDate) OR ""Tracks"".""TrackFileId"" > 0 THEN 1 ELSE 0 END) AS ""TrackCount"",
|
SUM(CASE WHEN (""Tracks"".""Monitored"" = {trueIndicator} AND ""Albums"".""Monitored"" = {trueIndicator} AND ""Albums"".""ReleaseDate"" <= @currentDate) OR ""Tracks"".""TrackFileId"" > 0 THEN 1 ELSE 0 END) AS ""TrackCount"",
|
||||||
SUM(CASE WHEN ""Tracks"".""TrackFileId"" > 0 THEN 1 ELSE 0 END) AS TrackFileCount", parameters)
|
SUM(CASE WHEN ""Tracks"".""TrackFileId"" > 0 THEN 1 ELSE 0 END) AS TrackFileCount", parameters)
|
||||||
.Join<Track, AlbumRelease>((t, r) => t.AlbumReleaseId == r.Id)
|
.Join<Track, AlbumRelease>((t, r) => t.AlbumReleaseId == r.Id)
|
||||||
.Join<AlbumRelease, Album>((r, a) => r.AlbumId == a.Id)
|
.Join<AlbumRelease, Album>((r, a) => r.AlbumId == a.Id)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using FluentMigrator;
|
||||||
|
using NzbDrone.Core.Datastore.Migration.Framework;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.Datastore.Migration
|
||||||
|
{
|
||||||
|
[Migration(200)]
|
||||||
|
public class track_monitored : NzbDroneMigrationBase
|
||||||
|
{
|
||||||
|
protected override void MainDbUpgrade()
|
||||||
|
{
|
||||||
|
Alter.Table("Tracks").AddColumn("Monitored").AsBoolean().NotNullable().WithDefaultValue(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using NLog;
|
||||||
|
using NzbDrone.Core.DecisionEngine;
|
||||||
|
using NzbDrone.Core.Download;
|
||||||
|
using NzbDrone.Core.Parser.Model;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications
|
||||||
|
{
|
||||||
|
public class TrackMonitoredSpecification : IImportDecisionEngineSpecification<LocalTrack>
|
||||||
|
{
|
||||||
|
private readonly Logger _logger;
|
||||||
|
|
||||||
|
public TrackMonitoredSpecification(Logger logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Decision IsSatisfiedBy(LocalTrack item, DownloadClientItem downloadClientItem)
|
||||||
|
{
|
||||||
|
// Manual imports (user-initiated, no download-client context) bypass the
|
||||||
|
// monitor filter so a one-off restore from disk still works.
|
||||||
|
if (downloadClientItem == null)
|
||||||
|
{
|
||||||
|
return Decision.Accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Tracks != null && item.Tracks.Any() && item.Tracks.All(t => !t.Monitored))
|
||||||
|
{
|
||||||
|
_logger.Debug("All candidate tracks for {0} are unmonitored; rejecting import", item.Path);
|
||||||
|
return Decision.Reject("Track is not monitored");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Decision.Accept();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,15 @@ namespace NzbDrone.Core.Music
|
|||||||
public int MediumNumber { get; set; }
|
public int MediumNumber { get; set; }
|
||||||
public int TrackFileId { get; set; }
|
public int TrackFileId { get; set; }
|
||||||
|
|
||||||
|
// Default to monitored=true. The schema migration sets DEFAULT 1, but
|
||||||
|
// that only applies when an INSERT omits the column. Lidarr's
|
||||||
|
// BasicRepository's auto-INSERT always sends ALL mapped columns —
|
||||||
|
// including this one. Without an explicit C# default, every newly
|
||||||
|
// inserted track would get Monitored=false, overriding the schema
|
||||||
|
// default and breaking the "new tracks are monitored by default"
|
||||||
|
// contract that backwards-compat with stock Lidarr depends on.
|
||||||
|
public bool Monitored { get; set; } = true;
|
||||||
|
|
||||||
[MemberwiseEqualityIgnore]
|
[MemberwiseEqualityIgnore]
|
||||||
public bool HasFile => TrackFileId > 0;
|
public bool HasFile => TrackFileId > 0;
|
||||||
|
|
||||||
@@ -81,6 +90,7 @@ namespace NzbDrone.Core.Music
|
|||||||
AlbumReleaseId = other.AlbumReleaseId;
|
AlbumReleaseId = other.AlbumReleaseId;
|
||||||
ArtistMetadataId = other.ArtistMetadataId;
|
ArtistMetadataId = other.ArtistMetadataId;
|
||||||
TrackFileId = other.TrackFileId;
|
TrackFileId = other.TrackFileId;
|
||||||
|
Monitored = other.Monitored;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ namespace NzbDrone.Core.Music
|
|||||||
#pragma warning disable CS0472
|
#pragma warning disable CS0472
|
||||||
private SqlBuilder AlbumsWithoutFilesBuilder(DateTime currentTime)
|
private SqlBuilder AlbumsWithoutFilesBuilder(DateTime currentTime)
|
||||||
{
|
{
|
||||||
|
// The .Where<Track>(t => t.Monitored == true) below is the load-bearing
|
||||||
|
// fork addition. Without it Lidarr would re-search albums whose only
|
||||||
|
// missing tracks are unmonitored (e.g., 1-star tracks intentionally
|
||||||
|
// deleted by external tooling) — see fork README.
|
||||||
return Builder()
|
return Builder()
|
||||||
.Join<Album, Artist>((l, r) => l.ArtistMetadataId == r.ArtistMetadataId)
|
.Join<Album, Artist>((l, r) => l.ArtistMetadataId == r.ArtistMetadataId)
|
||||||
.Join<Album, AlbumRelease>((a, r) => a.Id == r.AlbumId)
|
.Join<Album, AlbumRelease>((a, r) => a.Id == r.AlbumId)
|
||||||
@@ -101,6 +105,7 @@ namespace NzbDrone.Core.Music
|
|||||||
.LeftJoin<Track, TrackFile>((t, f) => t.TrackFileId == f.Id)
|
.LeftJoin<Track, TrackFile>((t, f) => t.TrackFileId == f.Id)
|
||||||
.Where<TrackFile>(f => f.Id == null)
|
.Where<TrackFile>(f => f.Id == null)
|
||||||
.Where<AlbumRelease>(r => r.Monitored == true)
|
.Where<AlbumRelease>(r => r.Monitored == true)
|
||||||
|
.Where<Track>(t => t.Monitored == true)
|
||||||
.Where<Album>(a => a.ReleaseDate <= currentTime)
|
.Where<Album>(a => a.ReleaseDate <= currentTime)
|
||||||
.GroupBy<Album>(x => x.Id)
|
.GroupBy<Album>(x => x.Id)
|
||||||
.GroupBy<Artist>(x => x.SortName);
|
.GroupBy<Artist>(x => x.SortName);
|
||||||
@@ -119,12 +124,16 @@ namespace NzbDrone.Core.Music
|
|||||||
|
|
||||||
private SqlBuilder AlbumsWhereCutoffUnmetBuilder(List<QualitiesBelowCutoff> qualitiesBelowCutoff)
|
private SqlBuilder AlbumsWhereCutoffUnmetBuilder(List<QualitiesBelowCutoff> qualitiesBelowCutoff)
|
||||||
{
|
{
|
||||||
|
// Parallel to AlbumsWithoutFilesBuilder — track-monitor gate for cutoff
|
||||||
|
// upgrade searches too. Otherwise upgrades would re-grab releases that
|
||||||
|
// contain unmonitored tracks.
|
||||||
return Builder()
|
return Builder()
|
||||||
.Join<Album, Artist>((l, r) => l.ArtistMetadataId == r.ArtistMetadataId)
|
.Join<Album, Artist>((l, r) => l.ArtistMetadataId == r.ArtistMetadataId)
|
||||||
.Join<Album, AlbumRelease>((a, r) => a.Id == r.AlbumId)
|
.Join<Album, AlbumRelease>((a, r) => a.Id == r.AlbumId)
|
||||||
.Join<AlbumRelease, Track>((r, t) => r.Id == t.AlbumReleaseId)
|
.Join<AlbumRelease, Track>((r, t) => r.Id == t.AlbumReleaseId)
|
||||||
.LeftJoin<Track, TrackFile>((t, f) => t.TrackFileId == f.Id)
|
.LeftJoin<Track, TrackFile>((t, f) => t.TrackFileId == f.Id)
|
||||||
.Where<AlbumRelease>(r => r.Monitored == true)
|
.Where<AlbumRelease>(r => r.Monitored == true)
|
||||||
|
.Where<Track>(t => t.Monitored == true)
|
||||||
.Where(BuildQualityCutoffWhereClause(qualitiesBelowCutoff))
|
.Where(BuildQualityCutoffWhereClause(qualitiesBelowCutoff))
|
||||||
.GroupBy<Album>(x => x.Id)
|
.GroupBy<Album>(x => x.Id)
|
||||||
.GroupBy<Artist>(x => x.SortName);
|
.GroupBy<Artist>(x => x.SortName);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ namespace NzbDrone.Core.Music
|
|||||||
List<Track> TracksWithFiles(int artistId);
|
List<Track> TracksWithFiles(int artistId);
|
||||||
List<Track> TracksWithoutFiles(int albumId);
|
List<Track> TracksWithoutFiles(int albumId);
|
||||||
void SetFileId(List<Track> tracks);
|
void SetFileId(List<Track> tracks);
|
||||||
|
void SetMonitored(IEnumerable<int> ids, bool monitored);
|
||||||
void DetachTrackFile(int trackFileId);
|
void DetachTrackFile(int trackFileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +108,12 @@ namespace NzbDrone.Core.Music
|
|||||||
SetFields(tracks, t => t.TrackFileId);
|
SetFields(tracks, t => t.TrackFileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetMonitored(IEnumerable<int> ids, bool monitored)
|
||||||
|
{
|
||||||
|
var tracks = ids.Select(id => new Track { Id = id, Monitored = monitored }).ToList();
|
||||||
|
SetFields(tracks, t => t.Monitored);
|
||||||
|
}
|
||||||
|
|
||||||
public void DetachTrackFile(int trackFileId)
|
public void DetachTrackFile(int trackFileId)
|
||||||
{
|
{
|
||||||
var tracks = GetTracksByFileId(trackFileId);
|
var tracks = GetTracksByFileId(trackFileId);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ namespace NzbDrone.Core.Music
|
|||||||
void UpdateMany(List<Track> tracks);
|
void UpdateMany(List<Track> tracks);
|
||||||
void DeleteMany(List<Track> tracks);
|
void DeleteMany(List<Track> tracks);
|
||||||
void SetFileIds(List<Track> tracks);
|
void SetFileIds(List<Track> tracks);
|
||||||
|
void SetMonitored(IEnumerable<int> ids, bool monitored);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TrackService : ITrackService,
|
public class TrackService : ITrackService,
|
||||||
@@ -122,6 +123,11 @@ namespace NzbDrone.Core.Music
|
|||||||
_trackRepository.SetFileId(tracks);
|
_trackRepository.SetFileId(tracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetMonitored(IEnumerable<int> ids, bool monitored)
|
||||||
|
{
|
||||||
|
_trackRepository.SetMonitored(ids, monitored);
|
||||||
|
}
|
||||||
|
|
||||||
public void Handle(ReleaseDeletedEvent message)
|
public void Handle(ReleaseDeletedEvent message)
|
||||||
{
|
{
|
||||||
var tracks = GetTracksByRelease(message.Release.Id);
|
var tracks = GetTracksByRelease(message.Release.Id);
|
||||||
|
|||||||
Reference in New Issue
Block a user