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:
2026-05-30 00:01:33 +00:00
parent 7149a912b5
commit b258e7e41b
17 changed files with 292 additions and 5 deletions
+46
View File
@@ -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
+30 -1
View File
@@ -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);
+49 -1
View File
@@ -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
View File
@@ -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);
}
}
}
@@ -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();
}
}
}
+10
View File
@@ -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);