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 IndexerFlags from 'Album/IndexerFlags';
|
||||
import Icon from 'Components/Icon';
|
||||
import MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
@@ -20,6 +21,13 @@ import styles from './TrackRow.css';
|
||||
|
||||
class TrackRow extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onMonitorTrackPress = (monitored) => {
|
||||
this.props.onMonitorTrackPress(this.props.id, monitored);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
@@ -32,6 +40,8 @@ class TrackRow extends Component {
|
||||
absoluteTrackNumber,
|
||||
title,
|
||||
duration,
|
||||
monitored,
|
||||
isSaving,
|
||||
trackFilePath,
|
||||
trackFileSize,
|
||||
customFormats,
|
||||
@@ -54,6 +64,21 @@ class TrackRow extends Component {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (name === 'monitored') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.monitored}
|
||||
>
|
||||
<MonitorToggleButton
|
||||
monitored={monitored}
|
||||
isSaving={isSaving}
|
||||
onPress={this.onMonitorTrackPress}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'medium') {
|
||||
return (
|
||||
<TableRowCell
|
||||
@@ -218,6 +243,7 @@ class TrackRow extends Component {
|
||||
|
||||
TrackRow.propTypes = {
|
||||
deleteTrackFile: PropTypes.func.isRequired,
|
||||
onMonitorTrackPress: PropTypes.func.isRequired,
|
||||
id: PropTypes.number.isRequired,
|
||||
albumId: PropTypes.number.isRequired,
|
||||
trackFileId: PropTypes.number,
|
||||
@@ -226,6 +252,7 @@ TrackRow.propTypes = {
|
||||
absoluteTrackNumber: PropTypes.number,
|
||||
title: PropTypes.string.isRequired,
|
||||
duration: PropTypes.number.isRequired,
|
||||
monitored: PropTypes.bool,
|
||||
isSaving: PropTypes.bool,
|
||||
trackFilePath: PropTypes.string,
|
||||
trackFileSize: PropTypes.number,
|
||||
@@ -238,7 +265,9 @@ TrackRow.propTypes = {
|
||||
|
||||
TrackRow.defaultProps = {
|
||||
customFormats: [],
|
||||
indexerFlags: 0
|
||||
indexerFlags: 0,
|
||||
monitored: true,
|
||||
isSaving: false
|
||||
};
|
||||
|
||||
export default TrackRow;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { toggleTrackMonitored } from 'Store/Actions/trackActions';
|
||||
import { deleteTrackFile } from 'Store/Actions/trackFileActions';
|
||||
import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector';
|
||||
import TrackRow from './TrackRow';
|
||||
@@ -7,9 +8,16 @@ import TrackRow from './TrackRow';
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state, { id }) => id,
|
||||
(state) => state.tracks.items,
|
||||
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 {
|
||||
isSaving: track ? !!track.isSaving : false,
|
||||
trackFilePath: trackFile ? trackFile.path : null,
|
||||
trackFileSize: trackFile ? trackFile.size : null,
|
||||
customFormats: trackFile ? trackFile.customFormats : [],
|
||||
@@ -21,7 +29,10 @@ function createMapStateToProps() {
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
deleteTrackFile
|
||||
deleteTrackFile,
|
||||
onMonitorTrackPress(trackId, monitored) {
|
||||
return toggleTrackMonitored({ trackId, monitored });
|
||||
}
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(TrackRow);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import { createAction } from 'redux-actions';
|
||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
||||
import Icon from 'Components/Icon';
|
||||
import { icons, sortDirections } from 'Helpers/Props';
|
||||
import { updateItem } from 'Store/Actions/baseActions';
|
||||
import { createThunk, handleThunks } from 'Store/thunks';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import createFetchHandler from './Creators/createFetchHandler';
|
||||
@@ -28,6 +30,12 @@ export const defaultState = {
|
||||
items: [],
|
||||
|
||||
columns: [
|
||||
{
|
||||
name: 'monitored',
|
||||
columnLabel: () => translate('Monitored'),
|
||||
isVisible: true,
|
||||
isModifiable: false
|
||||
},
|
||||
{
|
||||
name: '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_TABLE_OPTION = 'tracks/setTracksTableOption';
|
||||
export const CLEAR_TRACKS = 'tracks/clearTracks';
|
||||
export const TOGGLE_TRACK_MONITORED = 'tracks/toggleTrackMonitored';
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
@@ -121,13 +130,52 @@ export const fetchTracks = createThunk(FETCH_TRACKS);
|
||||
export const setTracksSort = createAction(SET_TRACKS_SORT);
|
||||
export const setTracksTableOption = createAction(SET_TRACKS_TABLE_OPTION);
|
||||
export const clearTracks = createAction(CLEAR_TRACKS);
|
||||
export const toggleTrackMonitored = createThunk(TOGGLE_TRACK_MONITORED);
|
||||
|
||||
//
|
||||
// Action Handlers
|
||||
|
||||
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,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'tracks',
|
||||
label: () => translate('Tracks'),
|
||||
isSortable: false,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'releaseDate',
|
||||
label: () => translate('ReleaseDate'),
|
||||
@@ -126,6 +132,12 @@ export const defaultState = {
|
||||
isSortable: true,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'tracks',
|
||||
label: () => translate('Tracks'),
|
||||
isSortable: false,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'releaseDate',
|
||||
label: () => translate('ReleaseDate'),
|
||||
|
||||
@@ -22,6 +22,7 @@ function CutoffUnmetRow(props) {
|
||||
title,
|
||||
lastSearchTime,
|
||||
disambiguation,
|
||||
statistics,
|
||||
isSelected,
|
||||
columns,
|
||||
onSelectedChange
|
||||
@@ -31,6 +32,11 @@ function CutoffUnmetRow(props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
trackFileCount = 0,
|
||||
trackCount = 0
|
||||
} = statistics || {};
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableSelectCell
|
||||
@@ -81,6 +87,14 @@ function CutoffUnmetRow(props) {
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'tracks') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
{trackFileCount}/{trackCount}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'releaseDate') {
|
||||
return (
|
||||
<RelativeDateCellConnector
|
||||
@@ -144,6 +158,7 @@ CutoffUnmetRow.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
lastSearchTime: PropTypes.string,
|
||||
disambiguation: PropTypes.string,
|
||||
statistics: PropTypes.object,
|
||||
isSelected: PropTypes.bool,
|
||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onSelectedChange: PropTypes.func.isRequired
|
||||
|
||||
@@ -19,6 +19,7 @@ function MissingRow(props) {
|
||||
title,
|
||||
lastSearchTime,
|
||||
disambiguation,
|
||||
statistics,
|
||||
isSelected,
|
||||
columns,
|
||||
onSelectedChange
|
||||
@@ -28,6 +29,11 @@ function MissingRow(props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
trackFileCount = 0,
|
||||
trackCount = 0
|
||||
} = statistics || {};
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<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') {
|
||||
return (
|
||||
<RelativeDateCellConnector
|
||||
@@ -125,6 +143,7 @@ MissingRow.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
lastSearchTime: PropTypes.string,
|
||||
disambiguation: PropTypes.string,
|
||||
statistics: PropTypes.object,
|
||||
isSelected: PropTypes.bool,
|
||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onSelectedChange: PropTypes.func.isRequired
|
||||
|
||||
@@ -50,5 +50,18 @@ namespace Lidarr.Api.V1.Tracks
|
||||
|
||||
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 int MediumNumber { get; set; }
|
||||
public bool HasFile { get; set; }
|
||||
public bool Monitored { get; set; }
|
||||
|
||||
public ArtistResource Artist { get; set; }
|
||||
public Ratings Ratings { get; set; }
|
||||
@@ -59,6 +60,7 @@ namespace Lidarr.Api.V1.Tracks
|
||||
Duration = model.Duration,
|
||||
MediumNumber = model.MediumNumber,
|
||||
HasFile = model.HasFile,
|
||||
Monitored = model.Monitored,
|
||||
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"",
|
||||
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"".""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)
|
||||
.Join<Track, AlbumRelease>((t, r) => t.AlbumReleaseId == r.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 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]
|
||||
public bool HasFile => TrackFileId > 0;
|
||||
|
||||
@@ -81,6 +90,7 @@ namespace NzbDrone.Core.Music
|
||||
AlbumReleaseId = other.AlbumReleaseId;
|
||||
ArtistMetadataId = other.ArtistMetadataId;
|
||||
TrackFileId = other.TrackFileId;
|
||||
Monitored = other.Monitored;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,10 @@ namespace NzbDrone.Core.Music
|
||||
#pragma warning disable CS0472
|
||||
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()
|
||||
.Join<Album, Artist>((l, r) => l.ArtistMetadataId == r.ArtistMetadataId)
|
||||
.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)
|
||||
.Where<TrackFile>(f => f.Id == null)
|
||||
.Where<AlbumRelease>(r => r.Monitored == true)
|
||||
.Where<Track>(t => t.Monitored == true)
|
||||
.Where<Album>(a => a.ReleaseDate <= currentTime)
|
||||
.GroupBy<Album>(x => x.Id)
|
||||
.GroupBy<Artist>(x => x.SortName);
|
||||
@@ -119,12 +124,16 @@ namespace NzbDrone.Core.Music
|
||||
|
||||
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()
|
||||
.Join<Album, Artist>((l, r) => l.ArtistMetadataId == r.ArtistMetadataId)
|
||||
.Join<Album, AlbumRelease>((a, r) => a.Id == r.AlbumId)
|
||||
.Join<AlbumRelease, Track>((r, t) => r.Id == t.AlbumReleaseId)
|
||||
.LeftJoin<Track, TrackFile>((t, f) => t.TrackFileId == f.Id)
|
||||
.Where<AlbumRelease>(r => r.Monitored == true)
|
||||
.Where<Track>(t => t.Monitored == true)
|
||||
.Where(BuildQualityCutoffWhereClause(qualitiesBelowCutoff))
|
||||
.GroupBy<Album>(x => x.Id)
|
||||
.GroupBy<Artist>(x => x.SortName);
|
||||
|
||||
@@ -18,6 +18,7 @@ namespace NzbDrone.Core.Music
|
||||
List<Track> TracksWithFiles(int artistId);
|
||||
List<Track> TracksWithoutFiles(int albumId);
|
||||
void SetFileId(List<Track> tracks);
|
||||
void SetMonitored(IEnumerable<int> ids, bool monitored);
|
||||
void DetachTrackFile(int trackFileId);
|
||||
}
|
||||
|
||||
@@ -107,6 +108,12 @@ namespace NzbDrone.Core.Music
|
||||
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)
|
||||
{
|
||||
var tracks = GetTracksByFileId(trackFileId);
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace NzbDrone.Core.Music
|
||||
void UpdateMany(List<Track> tracks);
|
||||
void DeleteMany(List<Track> tracks);
|
||||
void SetFileIds(List<Track> tracks);
|
||||
void SetMonitored(IEnumerable<int> ids, bool monitored);
|
||||
}
|
||||
|
||||
public class TrackService : ITrackService,
|
||||
@@ -122,6 +123,11 @@ namespace NzbDrone.Core.Music
|
||||
_trackRepository.SetFileId(tracks);
|
||||
}
|
||||
|
||||
public void SetMonitored(IEnumerable<int> ids, bool monitored)
|
||||
{
|
||||
_trackRepository.SetMonitored(ids, monitored);
|
||||
}
|
||||
|
||||
public void Handle(ReleaseDeletedEvent message)
|
||||
{
|
||||
var tracks = GetTracksByRelease(message.Release.Id);
|
||||
|
||||
Reference in New Issue
Block a user