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