From b258e7e41bfb64c870783b3a4b924fb5e2e02bf6 Mon Sep 17 00:00:00 2001 From: Shaun Reed Date: Sat, 30 May 2026 00:01:33 +0000 Subject: [PATCH] =?UTF-8?q?Per-track=20monitoring=20for=20Lidarr=20?= =?UTF-8?q?=E2=80=94=20track-level=20Monitored=20flag=20+=20UI=20+=20stats?= =?UTF-8?q?=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Dockerfile.fork | 46 +++++++++++++++++ frontend/src/Album/Details/TrackRow.js | 31 +++++++++++- .../src/Album/Details/TrackRowConnector.js | 15 +++++- frontend/src/Store/Actions/trackActions.js | 50 ++++++++++++++++++- frontend/src/Store/Actions/wantedActions.js | 12 +++++ .../src/Wanted/CutoffUnmet/CutoffUnmetRow.js | 15 ++++++ frontend/src/Wanted/Missing/MissingRow.js | 19 +++++++ src/Lidarr.Api.V1/Tracks/TrackController.cs | 13 +++++ src/Lidarr.Api.V1/Tracks/TrackResource.cs | 2 + .../Tracks/TracksMonitoredResource.cs | 10 ++++ .../ArtistStats/ArtistStatisticsRepository.cs | 2 +- .../Migration/200_track_monitored.cs | 14 ++++++ .../TrackMonitoredSpecification.cs | 36 +++++++++++++ src/NzbDrone.Core/Music/Model/Track.cs | 10 ++++ .../Music/Repositories/AlbumRepository.cs | 9 ++++ .../Music/Repositories/TrackRepository.cs | 7 +++ .../Music/Services/TrackService.cs | 6 +++ 17 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 Dockerfile.fork create mode 100644 src/Lidarr.Api.V1/Tracks/TracksMonitoredResource.cs create mode 100644 src/NzbDrone.Core/Datastore/Migration/200_track_monitored.cs create mode 100644 src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/TrackMonitoredSpecification.cs diff --git a/Dockerfile.fork b/Dockerfile.fork new file mode 100644 index 000000000..44c86d27c --- /dev/null +++ b/Dockerfile.fork @@ -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 diff --git a/frontend/src/Album/Details/TrackRow.js b/frontend/src/Album/Details/TrackRow.js index 226ede5f1..31f74281c 100644 --- a/frontend/src/Album/Details/TrackRow.js +++ b/frontend/src/Album/Details/TrackRow.js @@ -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 ( + + + + ); + } + if (name === 'medium') { return ( 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); diff --git a/frontend/src/Store/Actions/trackActions.js b/frontend/src/Store/Actions/trackActions.js index a71388c88..349f9cc5c 100644 --- a/frontend/src/Store/Actions/trackActions.js +++ b/frontend/src/Store/Actions/trackActions.js @@ -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 + })); + }); + } }); // diff --git a/frontend/src/Store/Actions/wantedActions.js b/frontend/src/Store/Actions/wantedActions.js index 61d6f7752..de329ecff 100644 --- a/frontend/src/Store/Actions/wantedActions.js +++ b/frontend/src/Store/Actions/wantedActions.js @@ -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'), diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js index 452e2947a..1535934d6 100644 --- a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js @@ -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 ( + {trackFileCount}/{trackCount} + + ); + } + if (name === 'releaseDate') { return ( + {trackFileCount}/{trackCount} + + ); + } + if (name === 'releaseDate') { return ( TrackIds { get; set; } + public bool Monitored { get; set; } + } +} diff --git a/src/NzbDrone.Core/ArtistStats/ArtistStatisticsRepository.cs b/src/NzbDrone.Core/ArtistStats/ArtistStatisticsRepository.cs index d74863a4f..b83dbb7e7 100644 --- a/src/NzbDrone.Core/ArtistStats/ArtistStatisticsRepository.cs +++ b/src/NzbDrone.Core/ArtistStats/ArtistStatisticsRepository.cs @@ -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((t, r) => t.AlbumReleaseId == r.Id) .Join((r, a) => r.AlbumId == a.Id) diff --git a/src/NzbDrone.Core/Datastore/Migration/200_track_monitored.cs b/src/NzbDrone.Core/Datastore/Migration/200_track_monitored.cs new file mode 100644 index 000000000..a23026820 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/200_track_monitored.cs @@ -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); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/TrackMonitoredSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/TrackMonitoredSpecification.cs new file mode 100644 index 000000000..daf443912 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/TrackMonitoredSpecification.cs @@ -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 + { + 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(); + } + } +} diff --git a/src/NzbDrone.Core/Music/Model/Track.cs b/src/NzbDrone.Core/Music/Model/Track.cs index 9c1aed1b1..898d30dc9 100644 --- a/src/NzbDrone.Core/Music/Model/Track.cs +++ b/src/NzbDrone.Core/Music/Model/Track.cs @@ -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; } } } diff --git a/src/NzbDrone.Core/Music/Repositories/AlbumRepository.cs b/src/NzbDrone.Core/Music/Repositories/AlbumRepository.cs index 3c69cf42b..e72d5fb63 100644 --- a/src/NzbDrone.Core/Music/Repositories/AlbumRepository.cs +++ b/src/NzbDrone.Core/Music/Repositories/AlbumRepository.cs @@ -94,6 +94,10 @@ namespace NzbDrone.Core.Music #pragma warning disable CS0472 private SqlBuilder AlbumsWithoutFilesBuilder(DateTime currentTime) { + // The .Where(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((l, r) => l.ArtistMetadataId == r.ArtistMetadataId) .Join((a, r) => a.Id == r.AlbumId) @@ -101,6 +105,7 @@ namespace NzbDrone.Core.Music .LeftJoin((t, f) => t.TrackFileId == f.Id) .Where(f => f.Id == null) .Where(r => r.Monitored == true) + .Where(t => t.Monitored == true) .Where(a => a.ReleaseDate <= currentTime) .GroupBy(x => x.Id) .GroupBy(x => x.SortName); @@ -119,12 +124,16 @@ namespace NzbDrone.Core.Music private SqlBuilder AlbumsWhereCutoffUnmetBuilder(List 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((l, r) => l.ArtistMetadataId == r.ArtistMetadataId) .Join((a, r) => a.Id == r.AlbumId) .Join((r, t) => r.Id == t.AlbumReleaseId) .LeftJoin((t, f) => t.TrackFileId == f.Id) .Where(r => r.Monitored == true) + .Where(t => t.Monitored == true) .Where(BuildQualityCutoffWhereClause(qualitiesBelowCutoff)) .GroupBy(x => x.Id) .GroupBy(x => x.SortName); diff --git a/src/NzbDrone.Core/Music/Repositories/TrackRepository.cs b/src/NzbDrone.Core/Music/Repositories/TrackRepository.cs index 83e24a8bf..9856dca07 100644 --- a/src/NzbDrone.Core/Music/Repositories/TrackRepository.cs +++ b/src/NzbDrone.Core/Music/Repositories/TrackRepository.cs @@ -18,6 +18,7 @@ namespace NzbDrone.Core.Music List TracksWithFiles(int artistId); List TracksWithoutFiles(int albumId); void SetFileId(List tracks); + void SetMonitored(IEnumerable ids, bool monitored); void DetachTrackFile(int trackFileId); } @@ -107,6 +108,12 @@ namespace NzbDrone.Core.Music SetFields(tracks, t => t.TrackFileId); } + public void SetMonitored(IEnumerable 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); diff --git a/src/NzbDrone.Core/Music/Services/TrackService.cs b/src/NzbDrone.Core/Music/Services/TrackService.cs index 38ee2da24..9c50f271e 100644 --- a/src/NzbDrone.Core/Music/Services/TrackService.cs +++ b/src/NzbDrone.Core/Music/Services/TrackService.cs @@ -25,6 +25,7 @@ namespace NzbDrone.Core.Music void UpdateMany(List tracks); void DeleteMany(List tracks); void SetFileIds(List tracks); + void SetMonitored(IEnumerable ids, bool monitored); } public class TrackService : ITrackService, @@ -122,6 +123,11 @@ namespace NzbDrone.Core.Music _trackRepository.SetFileId(tracks); } + public void SetMonitored(IEnumerable ids, bool monitored) + { + _trackRepository.SetMonitored(ids, monitored); + } + public void Handle(ReleaseDeletedEvent message) { var tracks = GetTracksByRelease(message.Release.Id);