From ea6ea84b7b96537e89c7138e167416d089e4481e Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 22:46:20 +0200 Subject: [PATCH 001/120] Add recording mutation domain foundation --- recordingmutation.h | 88 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 recordingmutation.h diff --git a/recordingmutation.h b/recordingmutation.h new file mode 100644 index 0000000..1759717 --- /dev/null +++ b/recordingmutation.h @@ -0,0 +1,88 @@ +#ifndef __RECORDINGMUTATION_H +#define __RECORDINGMUTATION_H + +#include +#include + +enum class RecordingMutationType +{ + Trash, + Restore, + Purge, + Move, + Rename +}; + +enum class RecordingConstraint +{ + RecordingMissing, + RecordingHandlerBusy, + ReplayActive, + LocalTimerActive, + RemoteTimerActive, + SearchTimerRecording, + UnknownTimerState, + UnknownSearchTimerState +}; + +enum class RecordingMutationStep +{ + StopRecordingHandler, + StopReplay, + DeactivateLocalTimer, + DeactivateRemoteTimer, + TrashRecording, + RestoreRecording, + PurgeRecording, + RefreshRecordings, + NotifyChange +}; + +struct RecordingMutationRevision +{ + std::string recordingFile; + long long recordingsState = 0; + long long timersState = 0; +}; + +struct RecordingMutationAnalysis +{ + RecordingMutationType type = RecordingMutationType::Trash; + std::string recordingFile; + std::vector constraints; + std::vector warnings; + RecordingMutationRevision revision; + + bool hasConstraint(RecordingConstraint constraint) const; +}; + +struct RecordingMutationPolicy +{ + bool allowRecordingHandlerStop = false; + bool allowReplayStop = false; + bool allowLocalTimerStop = false; + bool allowRemoteTimerStop = false; +}; + +struct RecordingMutationPlan +{ + RecordingMutationType type = RecordingMutationType::Trash; + bool executable = false; + std::vector steps; + std::vector blockers; + std::vector warnings; + RecordingMutationRevision expectedRevision; +}; + +class RecordingMutationPlanner +{ +public: + RecordingMutationPlan buildTrashPlan( + const RecordingMutationAnalysis& analysis, + const RecordingMutationPolicy& policy) const; +}; + +const char* RecordingConstraintName(RecordingConstraint constraint); +const char* RecordingMutationStepName(RecordingMutationStep step); + +#endif From 155d489b1566b22631c5a929a1a4787c961b5862 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 22:47:11 +0200 Subject: [PATCH 002/120] Implement recording trash planner foundation --- recordingmutation.cpp | 117 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 recordingmutation.cpp diff --git a/recordingmutation.cpp b/recordingmutation.cpp new file mode 100644 index 0000000..d10aa2f --- /dev/null +++ b/recordingmutation.cpp @@ -0,0 +1,117 @@ +#include "recordingmutation.h" + +#include + +bool RecordingMutationAnalysis::hasConstraint(RecordingConstraint constraint) const +{ + return std::find(constraints.begin(), constraints.end(), constraint) != constraints.end(); +} + +namespace { + +void addBlocker(RecordingMutationPlan& plan, RecordingConstraint constraint) +{ + if (std::find(plan.blockers.begin(), plan.blockers.end(), constraint) == plan.blockers.end()) + plan.blockers.push_back(constraint); +} + +void addStep(RecordingMutationPlan& plan, RecordingMutationStep step) +{ + if (std::find(plan.steps.begin(), plan.steps.end(), step) == plan.steps.end()) + plan.steps.push_back(step); +} + +} + +RecordingMutationPlan RecordingMutationPlanner::buildTrashPlan( + const RecordingMutationAnalysis& analysis, + const RecordingMutationPolicy& policy) const +{ + RecordingMutationPlan plan; + plan.type = RecordingMutationType::Trash; + plan.warnings = analysis.warnings; + plan.expectedRevision = analysis.revision; + + if (analysis.type != RecordingMutationType::Trash) + addBlocker(plan, RecordingConstraint::RecordingMissing); + + if (analysis.hasConstraint(RecordingConstraint::RecordingMissing)) + addBlocker(plan, RecordingConstraint::RecordingMissing); + + if (analysis.hasConstraint(RecordingConstraint::UnknownTimerState)) + addBlocker(plan, RecordingConstraint::UnknownTimerState); + + if (analysis.hasConstraint(RecordingConstraint::UnknownSearchTimerState)) + addBlocker(plan, RecordingConstraint::UnknownSearchTimerState); + + if (analysis.hasConstraint(RecordingConstraint::RecordingHandlerBusy)) { + if (policy.allowRecordingHandlerStop) + addStep(plan, RecordingMutationStep::StopRecordingHandler); + else + addBlocker(plan, RecordingConstraint::RecordingHandlerBusy); + } + + if (analysis.hasConstraint(RecordingConstraint::ReplayActive)) { + if (policy.allowReplayStop) + addStep(plan, RecordingMutationStep::StopReplay); + else + addBlocker(plan, RecordingConstraint::ReplayActive); + } + + if (analysis.hasConstraint(RecordingConstraint::LocalTimerActive)) { + if (policy.allowLocalTimerStop) + addStep(plan, RecordingMutationStep::DeactivateLocalTimer); + else + addBlocker(plan, RecordingConstraint::LocalTimerActive); + } + + if (analysis.hasConstraint(RecordingConstraint::RemoteTimerActive)) { + if (policy.allowRemoteTimerStop) + addStep(plan, RecordingMutationStep::DeactivateRemoteTimer); + else + addBlocker(plan, RecordingConstraint::RemoteTimerActive); + } + + if (analysis.hasConstraint(RecordingConstraint::SearchTimerRecording)) + plan.warnings.push_back("EPGSearch may classify an interrupted recording as incomplete."); + + if (plan.blockers.empty()) { + addStep(plan, RecordingMutationStep::TrashRecording); + addStep(plan, RecordingMutationStep::RefreshRecordings); + addStep(plan, RecordingMutationStep::NotifyChange); + plan.executable = true; + } + + return plan; +} + +const char* RecordingConstraintName(RecordingConstraint constraint) +{ + switch (constraint) { + case RecordingConstraint::RecordingMissing: return "recording-missing"; + case RecordingConstraint::RecordingHandlerBusy: return "recording-handler-busy"; + case RecordingConstraint::ReplayActive: return "replay-active"; + case RecordingConstraint::LocalTimerActive: return "local-timer-active"; + case RecordingConstraint::RemoteTimerActive: return "remote-timer-active"; + case RecordingConstraint::SearchTimerRecording: return "searchtimer-recording"; + case RecordingConstraint::UnknownTimerState: return "unknown-timer-state"; + case RecordingConstraint::UnknownSearchTimerState: return "unknown-searchtimer-state"; + } + return "unknown"; +} + +const char* RecordingMutationStepName(RecordingMutationStep step) +{ + switch (step) { + case RecordingMutationStep::StopRecordingHandler: return "stop-recording-handler"; + case RecordingMutationStep::StopReplay: return "stop-replay"; + case RecordingMutationStep::DeactivateLocalTimer: return "deactivate-local-timer"; + case RecordingMutationStep::DeactivateRemoteTimer: return "deactivate-remote-timer"; + case RecordingMutationStep::TrashRecording: return "trash-recording"; + case RecordingMutationStep::RestoreRecording: return "restore-recording"; + case RecordingMutationStep::PurgeRecording: return "purge-recording"; + case RecordingMutationStep::RefreshRecordings: return "refresh-recordings"; + case RecordingMutationStep::NotifyChange: return "notify-change"; + } + return "unknown"; +} From 3de31c23eb907efb7330e9dd4cb53820afcbe4c6 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 22:48:11 +0200 Subject: [PATCH 003/120] Build recording mutation foundation --- Makefile | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/Makefile b/Makefile index e3e7268..aa2509d 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ LIBS += $(shell pkg-config --libs Magick++) CXXFLAGS += -DUSE_LIBMAGICKPLUSPLUS endif -OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o +OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o CFGS = API.html all: $(SOFILE) i18n @@ -55,8 +55,8 @@ $(DEPFILE): Makefile PODIR = po I18Npo = $(wildcard $(PODIR)/*.po) -I18Nmo = $(addsuffix .mo, $(foreach file, $(I18Npo), $(basename $(file)))) -I18Nmsgs = $(addprefix $(DESTDIR)$(LOCDIR)/, $(addsuffix /LC_MESSAGES/vdr-$(PLUGIN).mo, $(notdir $(foreach file, $(I18Npo), $(basename $(file)))))) +I18Nmo = $(addsuffix .mo, $(foreach file,$(I18Npo),$(basename $(file)))) +I18Nmsgs = $(addprefix $(DESTDIR)$(LOCDIR)/,$(addsuffix /LC_MESSAGES/vdr-$(PLUGIN).mo,$(notdir $(foreach file,$(I18Npo),$(basename $(file)))))) I18Npot = $(PODIR)/$(PLUGIN).pot %.mo: %.po @@ -87,19 +87,3 @@ install-cfg: $(CFGS) install -D $^ $(DESTDIR)$(PLGCONFDIR)/$^ install: install-lib install-i18n install-cfg - -dist: $(I18Npo) clean - @-rm -rf $(TMPDIR)/$(ARCHIVE) - @mkdir $(TMPDIR)/$(ARCHIVE) - @cp -a * $(TMPDIR)/$(ARCHIVE) - @-rm -rf $(TMPDIR)/$(ARCHIVE)/debian - @tar czf $(PACKAGE).tgz -C $(TMPDIR) $(ARCHIVE) - @-rm -rf $(TMPDIR)/$(ARCHIVE) - @echo Distribution package created as $(PACKAGE).tgz - -clean: - @-rm -f $(PODIR)/*.mo $(PODIR)/*.pot - @-rm -f $(OBJS) $(DEPFILE) *.so *.tgz core* *~ ._* - -archive: - git archive --format=tar.gz --prefix=vdr-plugin-restfulapi-${VERSION}/ --output=../vdr-plugin-restfulapi-${VERSION}.tar.gz master From a8ff0aea7f6f53ddfaaa9b7e53b9f09417ecea99 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 22:52:42 +0200 Subject: [PATCH 004/120] Fix Makefile regression from mutation build wiring --- Makefile | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index aa2509d..5eac669 100644 --- a/Makefile +++ b/Makefile @@ -55,8 +55,8 @@ $(DEPFILE): Makefile PODIR = po I18Npo = $(wildcard $(PODIR)/*.po) -I18Nmo = $(addsuffix .mo, $(foreach file,$(I18Npo),$(basename $(file)))) -I18Nmsgs = $(addprefix $(DESTDIR)$(LOCDIR)/,$(addsuffix /LC_MESSAGES/vdr-$(PLUGIN).mo,$(notdir $(foreach file,$(I18Npo),$(basename $(file)))))) +I18Nmo = $(addsuffix .mo, $(foreach file, $(I18Npo), $(basename $(file)))) +I18Nmsgs = $(addprefix $(DESTDIR)$(LOCDIR)/, $(addsuffix /LC_MESSAGES/vdr-$(PLUGIN).mo, $(notdir $(foreach file, $(I18Npo), $(basename $(file)))))) I18Npot = $(PODIR)/$(PLUGIN).pot %.mo: %.po @@ -87,3 +87,19 @@ install-cfg: $(CFGS) install -D $^ $(DESTDIR)$(PLGCONFDIR)/$^ install: install-lib install-i18n install-cfg + +dist: $(I18Npo) clean + @-rm -rf $(TMPDIR)/$(ARCHIVE) + @mkdir $(TMPDIR)/$(ARCHIVE) + @cp -a * $(TMPDIR)/$(ARCHIVE) + @-rm -rf $(TMPDIR)/$(ARCHIVE)/debian + @tar czf $(PACKAGE).tgz -C $(TMPDIR) $(ARCHIVE) + @-rm -rf $(TMPDIR)/$(ARCHIVE) + @echo Distribution package created as $(PACKAGE).tgz + +clean: + @-rm -f $(PODIR)/*.mo $(PODIR)/*.pot + @-rm -f $(OBJS) $(DEPFILE) *.so *.tgz core* *~ ._* + +archive: + git archive --format=tar.gz --prefix=vdr-plugin-restfulapi-${VERSION}/ --output=../vdr-plugin-restfulapi-${VERSION}.tar.gz master From 811a96323daa779ca8370dbf7e62714e145bd43a Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 23:05:04 +0200 Subject: [PATCH 005/120] Add recording analysis provider interfaces --- recordinganalysis.h | 54 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 recordinganalysis.h diff --git a/recordinganalysis.h b/recordinganalysis.h new file mode 100644 index 0000000..abdc56a --- /dev/null +++ b/recordinganalysis.h @@ -0,0 +1,54 @@ +#ifndef __RECORDINGANALYSIS_H +#define __RECORDINGANALYSIS_H + +#include "recordingmutation.h" + +#include + +struct RecordingLookupResult +{ + bool found = false; + std::string recordingFile; +}; + +class IRecordingLookup +{ +public: + virtual ~IRecordingLookup() = default; + virtual RecordingLookupResult find(const std::string& recordingFile) const = 0; +}; + +class IRecordingReplayLookup +{ +public: + virtual ~IRecordingReplayLookup() = default; + virtual bool isReplaying(const std::string& recordingFile) const = 0; +}; + +class VdrRecordingLookup : public IRecordingLookup +{ +public: + RecordingLookupResult find(const std::string& recordingFile) const override; +}; + +class VdrRecordingReplayLookup : public IRecordingReplayLookup +{ +public: + bool isReplaying(const std::string& recordingFile) const override; +}; + +class RecordingTrashAnalyzer +{ +public: + RecordingTrashAnalyzer( + const IRecordingLookup& recordingLookup, + const IRecordingReplayLookup& replayLookup); + + RecordingMutationAnalysis analyze(const std::string& recordingFile) const; + +private: + const IRecordingLookup& recordingLookup; + const IRecordingReplayLookup& replayLookup; +}; + +#endif From b3c87786f6115c5a587eebe89e7a6bb05ffd0a84 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 23:05:33 +0200 Subject: [PATCH 006/120] Implement recording lookup and replay analysis --- recordinganalysis.cpp | 69 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 recordinganalysis.cpp diff --git a/recordinganalysis.cpp b/recordinganalysis.cpp new file mode 100644 index 0000000..2663c74 --- /dev/null +++ b/recordinganalysis.cpp @@ -0,0 +1,69 @@ +#include "recordinganalysis.h" + +#include + +#include +#include + +RecordingLookupResult VdrRecordingLookup::find(const std::string& recordingFile) const +{ + RecordingLookupResult result; + + if (recordingFile.empty()) + return result; + + LOCK_RECORDINGS_READ; + const cRecording* recording = Recordings->GetByName(recordingFile.c_str()); + + if (!recording) + return result; + + result.found = true; + result.recordingFile = recording->FileName(); + return result; +} + +bool VdrRecordingReplayLookup::isReplaying(const std::string& recordingFile) const +{ + if (recordingFile.empty()) + return false; + + const char* nowReplaying = cReplayControl::NowReplaying(); + return nowReplaying && std::strcmp(nowReplaying, recordingFile.c_str()) == 0; +} + +RecordingTrashAnalyzer::RecordingTrashAnalyzer( + const IRecordingLookup& recordingLookup, + const IRecordingReplayLookup& replayLookup) + : recordingLookup(recordingLookup), + replayLookup(replayLookup) +{ +} + +RecordingMutationAnalysis RecordingTrashAnalyzer::analyze( + const std::string& recordingFile) const +{ + RecordingMutationAnalysis analysis; + analysis.type = RecordingMutationType::Trash; + analysis.recordingFile = recordingFile; + analysis.revision.recordingFile = recordingFile; + + const RecordingLookupResult recording = recordingLookup.find(recordingFile); + + if (!recording.found) { + analysis.constraints.push_back(RecordingConstraint::RecordingMissing); + return analysis; + } + + analysis.recordingFile = recording.recordingFile; + analysis.revision.recordingFile = recording.recordingFile; + + if (replayLookup.isReplaying(recording.recordingFile)) + analysis.constraints.push_back(RecordingConstraint::ReplayActive); + + analysis.constraints.push_back(RecordingConstraint::UnknownRecordingHandlerState); + analysis.constraints.push_back(RecordingConstraint::UnknownTimerState); + analysis.constraints.push_back(RecordingConstraint::UnknownSearchTimerState); + + return analysis; +} From ea48fb9fd6dd90f587038b786c7c7c9a6741b205 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 23:06:01 +0200 Subject: [PATCH 007/120] Model unknown recording handler analysis state --- recordingmutation.h | 1 + 1 file changed, 1 insertion(+) diff --git a/recordingmutation.h b/recordingmutation.h index 1759717..062a8e9 100644 --- a/recordingmutation.h +++ b/recordingmutation.h @@ -21,6 +21,7 @@ enum class RecordingConstraint LocalTimerActive, RemoteTimerActive, SearchTimerRecording, + UnknownRecordingHandlerState, UnknownTimerState, UnknownSearchTimerState }; From 08543c7bd04e7b4cd4ee1675bb23ea66519e5ac1 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 23:07:12 +0200 Subject: [PATCH 008/120] Block trash planning on unknown handler state --- recordingmutation.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/recordingmutation.cpp b/recordingmutation.cpp index d10aa2f..8583340 100644 --- a/recordingmutation.cpp +++ b/recordingmutation.cpp @@ -38,6 +38,9 @@ RecordingMutationPlan RecordingMutationPlanner::buildTrashPlan( if (analysis.hasConstraint(RecordingConstraint::RecordingMissing)) addBlocker(plan, RecordingConstraint::RecordingMissing); + if (analysis.hasConstraint(RecordingConstraint::UnknownRecordingHandlerState)) + addBlocker(plan, RecordingConstraint::UnknownRecordingHandlerState); + if (analysis.hasConstraint(RecordingConstraint::UnknownTimerState)) addBlocker(plan, RecordingConstraint::UnknownTimerState); @@ -94,6 +97,7 @@ const char* RecordingConstraintName(RecordingConstraint constraint) case RecordingConstraint::LocalTimerActive: return "local-timer-active"; case RecordingConstraint::RemoteTimerActive: return "remote-timer-active"; case RecordingConstraint::SearchTimerRecording: return "searchtimer-recording"; + case RecordingConstraint::UnknownRecordingHandlerState: return "unknown-recording-handler-state"; case RecordingConstraint::UnknownTimerState: return "unknown-timer-state"; case RecordingConstraint::UnknownSearchTimerState: return "unknown-searchtimer-state"; } From 163721ae150176f462e4cc2422bb94865f43f56f Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 23:08:20 +0200 Subject: [PATCH 009/120] Build recording analysis foundation --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 5eac669..58e0aa3 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ LIBS += $(shell pkg-config --libs Magick++) CXXFLAGS += -DUSE_LIBMAGICKPLUSPLUS endif -OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o +OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o CFGS = API.html all: $(SOFILE) i18n From 231f7801cc9e51cec30121525d68c0618ab5b4fc Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 23:12:49 +0200 Subject: [PATCH 010/120] Add recording handler analysis provider --- recordinganalysis.h | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/recordinganalysis.h b/recordinganalysis.h index abdc56a..4d2667f 100644 --- a/recordinganalysis.h +++ b/recordinganalysis.h @@ -11,6 +11,12 @@ struct RecordingLookupResult std::string recordingFile; }; +struct RecordingHandlerLookupResult +{ + bool known = false; + bool busy = false; +}; + class IRecordingLookup { public: @@ -25,6 +31,13 @@ class IRecordingReplayLookup virtual bool isReplaying(const std::string& recordingFile) const = 0; }; +class IRecordingHandlerLookup +{ +public: + virtual ~IRecordingHandlerLookup() = default; + virtual RecordingHandlerLookupResult getUsage(const std::string& recordingFile) const = 0; +}; + class VdrRecordingLookup : public IRecordingLookup { public: @@ -37,18 +50,26 @@ class VdrRecordingReplayLookup : public IRecordingReplayLookup bool isReplaying(const std::string& recordingFile) const override; }; +class VdrRecordingHandlerLookup : public IRecordingHandlerLookup +{ +public: + RecordingHandlerLookupResult getUsage(const std::string& recordingFile) const override; +}; + class RecordingTrashAnalyzer { public: RecordingTrashAnalyzer( const IRecordingLookup& recordingLookup, - const IRecordingReplayLookup& replayLookup); + const IRecordingReplayLookup& replayLookup, + const IRecordingHandlerLookup& recordingHandlerLookup); RecordingMutationAnalysis analyze(const std::string& recordingFile) const; private: const IRecordingLookup& recordingLookup; const IRecordingReplayLookup& replayLookup; + const IRecordingHandlerLookup& recordingHandlerLookup; }; #endif From e23297e355d4f850819f6f0433947dd97041c6d0 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 23:13:28 +0200 Subject: [PATCH 011/120] Implement recording handler usage analysis --- recordinganalysis.cpp | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/recordinganalysis.cpp b/recordinganalysis.cpp index 2663c74..1bdbb3d 100644 --- a/recordinganalysis.cpp +++ b/recordinganalysis.cpp @@ -32,11 +32,26 @@ bool VdrRecordingReplayLookup::isReplaying(const std::string& recordingFile) con return nowReplaying && std::strcmp(nowReplaying, recordingFile.c_str()) == 0; } +RecordingHandlerLookupResult VdrRecordingHandlerLookup::getUsage( + const std::string& recordingFile) const +{ + RecordingHandlerLookupResult result; + + if (recordingFile.empty()) + return result; + + result.known = true; + result.busy = RecordingsHandler.GetUsage(recordingFile.c_str()) != ruNone; + return result; +} + RecordingTrashAnalyzer::RecordingTrashAnalyzer( const IRecordingLookup& recordingLookup, - const IRecordingReplayLookup& replayLookup) + const IRecordingReplayLookup& replayLookup, + const IRecordingHandlerLookup& recordingHandlerLookup) : recordingLookup(recordingLookup), - replayLookup(replayLookup) + replayLookup(replayLookup), + recordingHandlerLookup(recordingHandlerLookup) { } @@ -61,7 +76,14 @@ RecordingMutationAnalysis RecordingTrashAnalyzer::analyze( if (replayLookup.isReplaying(recording.recordingFile)) analysis.constraints.push_back(RecordingConstraint::ReplayActive); - analysis.constraints.push_back(RecordingConstraint::UnknownRecordingHandlerState); + const RecordingHandlerLookupResult handlerUsage = + recordingHandlerLookup.getUsage(recording.recordingFile); + + if (!handlerUsage.known) + analysis.constraints.push_back(RecordingConstraint::UnknownRecordingHandlerState); + else if (handlerUsage.busy) + analysis.constraints.push_back(RecordingConstraint::RecordingHandlerBusy); + analysis.constraints.push_back(RecordingConstraint::UnknownTimerState); analysis.constraints.push_back(RecordingConstraint::UnknownSearchTimerState); From 83f1d3716abd2d382830f3fc9f621736ae6d44d7 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 23:14:41 +0200 Subject: [PATCH 012/120] Match upstream Makefile whitespace exactly --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 58e0aa3..7dbbaeb 100644 --- a/Makefile +++ b/Makefile @@ -100,6 +100,6 @@ dist: $(I18Npo) clean clean: @-rm -f $(PODIR)/*.mo $(PODIR)/*.pot @-rm -f $(OBJS) $(DEPFILE) *.so *.tgz core* *~ ._* - + archive: git archive --format=tar.gz --prefix=vdr-plugin-restfulapi-${VERSION}/ --output=../vdr-plugin-restfulapi-${VERSION}.tar.gz master From 2f3cf48b0f35072ea4bdbd92473fc8ce66ca5ca4 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 23:18:38 +0200 Subject: [PATCH 013/120] Split local and remote timer analysis constraints --- recordingmutation.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/recordingmutation.h b/recordingmutation.h index 062a8e9..1a20bae 100644 --- a/recordingmutation.h +++ b/recordingmutation.h @@ -22,7 +22,8 @@ enum class RecordingConstraint RemoteTimerActive, SearchTimerRecording, UnknownRecordingHandlerState, - UnknownTimerState, + UnknownLocalTimerState, + UnknownRemoteTimerState, UnknownSearchTimerState }; From b6abbccb770f294f498c7aee31692be02f7a0a3b Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 23:19:40 +0200 Subject: [PATCH 014/120] Block trash planning on unknown local or remote timer state --- recordingmutation.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/recordingmutation.cpp b/recordingmutation.cpp index 8583340..6e766c5 100644 --- a/recordingmutation.cpp +++ b/recordingmutation.cpp @@ -41,8 +41,11 @@ RecordingMutationPlan RecordingMutationPlanner::buildTrashPlan( if (analysis.hasConstraint(RecordingConstraint::UnknownRecordingHandlerState)) addBlocker(plan, RecordingConstraint::UnknownRecordingHandlerState); - if (analysis.hasConstraint(RecordingConstraint::UnknownTimerState)) - addBlocker(plan, RecordingConstraint::UnknownTimerState); + if (analysis.hasConstraint(RecordingConstraint::UnknownLocalTimerState)) + addBlocker(plan, RecordingConstraint::UnknownLocalTimerState); + + if (analysis.hasConstraint(RecordingConstraint::UnknownRemoteTimerState)) + addBlocker(plan, RecordingConstraint::UnknownRemoteTimerState); if (analysis.hasConstraint(RecordingConstraint::UnknownSearchTimerState)) addBlocker(plan, RecordingConstraint::UnknownSearchTimerState); @@ -98,7 +101,8 @@ const char* RecordingConstraintName(RecordingConstraint constraint) case RecordingConstraint::RemoteTimerActive: return "remote-timer-active"; case RecordingConstraint::SearchTimerRecording: return "searchtimer-recording"; case RecordingConstraint::UnknownRecordingHandlerState: return "unknown-recording-handler-state"; - case RecordingConstraint::UnknownTimerState: return "unknown-timer-state"; + case RecordingConstraint::UnknownLocalTimerState: return "unknown-local-timer-state"; + case RecordingConstraint::UnknownRemoteTimerState: return "unknown-remote-timer-state"; case RecordingConstraint::UnknownSearchTimerState: return "unknown-searchtimer-state"; } return "unknown"; From df51fe673f8c6f29d68d13afdc07d4c73019980c Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 23:20:27 +0200 Subject: [PATCH 015/120] Add local recording timer analysis provider --- recordinganalysis.h | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/recordinganalysis.h b/recordinganalysis.h index 4d2667f..c5b8a11 100644 --- a/recordinganalysis.h +++ b/recordinganalysis.h @@ -17,6 +17,12 @@ struct RecordingHandlerLookupResult bool busy = false; }; +struct RecordingLocalTimerLookupResult +{ + bool known = false; + bool active = false; +}; + class IRecordingLookup { public: @@ -38,6 +44,13 @@ class IRecordingHandlerLookup virtual RecordingHandlerLookupResult getUsage(const std::string& recordingFile) const = 0; }; +class IRecordingLocalTimerLookup +{ +public: + virtual ~IRecordingLocalTimerLookup() = default; + virtual RecordingLocalTimerLookupResult findActive(const std::string& recordingFile) const = 0; +}; + class VdrRecordingLookup : public IRecordingLookup { public: @@ -56,13 +69,20 @@ class VdrRecordingHandlerLookup : public IRecordingHandlerLookup RecordingHandlerLookupResult getUsage(const std::string& recordingFile) const override; }; +class VdrRecordingLocalTimerLookup : public IRecordingLocalTimerLookup +{ +public: + RecordingLocalTimerLookupResult findActive(const std::string& recordingFile) const override; +}; + class RecordingTrashAnalyzer { public: RecordingTrashAnalyzer( const IRecordingLookup& recordingLookup, const IRecordingReplayLookup& replayLookup, - const IRecordingHandlerLookup& recordingHandlerLookup); + const IRecordingHandlerLookup& recordingHandlerLookup, + const IRecordingLocalTimerLookup& localTimerLookup); RecordingMutationAnalysis analyze(const std::string& recordingFile) const; @@ -70,6 +90,7 @@ class RecordingTrashAnalyzer const IRecordingLookup& recordingLookup; const IRecordingReplayLookup& replayLookup; const IRecordingHandlerLookup& recordingHandlerLookup; + const IRecordingLocalTimerLookup& localTimerLookup; }; #endif From 6cabe67c3b835e30392c89fe095b3f0f225d1fa7 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 23:21:14 +0200 Subject: [PATCH 016/120] Implement local recording timer analysis --- recordinganalysis.cpp | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/recordinganalysis.cpp b/recordinganalysis.cpp index 1bdbb3d..71cbdce 100644 --- a/recordinganalysis.cpp +++ b/recordinganalysis.cpp @@ -45,13 +45,28 @@ RecordingHandlerLookupResult VdrRecordingHandlerLookup::getUsage( return result; } +RecordingLocalTimerLookupResult VdrRecordingLocalTimerLookup::findActive( + const std::string& recordingFile) const +{ + RecordingLocalTimerLookupResult result; + + if (recordingFile.empty()) + return result; + + result.known = true; + result.active = cRecordControls::GetRecordControl(recordingFile.c_str()) != nullptr; + return result; +} + RecordingTrashAnalyzer::RecordingTrashAnalyzer( const IRecordingLookup& recordingLookup, const IRecordingReplayLookup& replayLookup, - const IRecordingHandlerLookup& recordingHandlerLookup) + const IRecordingHandlerLookup& recordingHandlerLookup, + const IRecordingLocalTimerLookup& localTimerLookup) : recordingLookup(recordingLookup), replayLookup(replayLookup), - recordingHandlerLookup(recordingHandlerLookup) + recordingHandlerLookup(recordingHandlerLookup), + localTimerLookup(localTimerLookup) { } @@ -84,7 +99,15 @@ RecordingMutationAnalysis RecordingTrashAnalyzer::analyze( else if (handlerUsage.busy) analysis.constraints.push_back(RecordingConstraint::RecordingHandlerBusy); - analysis.constraints.push_back(RecordingConstraint::UnknownTimerState); + const RecordingLocalTimerLookupResult localTimer = + localTimerLookup.findActive(recording.recordingFile); + + if (!localTimer.known) + analysis.constraints.push_back(RecordingConstraint::UnknownLocalTimerState); + else if (localTimer.active) + analysis.constraints.push_back(RecordingConstraint::LocalTimerActive); + + analysis.constraints.push_back(RecordingConstraint::UnknownRemoteTimerState); analysis.constraints.push_back(RecordingConstraint::UnknownSearchTimerState); return analysis; From 303193cc2df0a77007dc55762d8c2b1900b70edf Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 23:30:37 +0200 Subject: [PATCH 017/120] Add remote recording timer analysis provider --- recordinganalysis.h | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/recordinganalysis.h b/recordinganalysis.h index c5b8a11..fca2a69 100644 --- a/recordinganalysis.h +++ b/recordinganalysis.h @@ -23,6 +23,14 @@ struct RecordingLocalTimerLookupResult bool active = false; }; +struct RecordingRemoteTimerLookupResult +{ + bool known = false; + bool active = false; + int timerId = 0; + std::string remote; +}; + class IRecordingLookup { public: @@ -51,6 +59,13 @@ class IRecordingLocalTimerLookup virtual RecordingLocalTimerLookupResult findActive(const std::string& recordingFile) const = 0; }; +class IRecordingRemoteTimerLookup +{ +public: + virtual ~IRecordingRemoteTimerLookup() = default; + virtual RecordingRemoteTimerLookupResult findActive(const std::string& recordingFile) const = 0; +}; + class VdrRecordingLookup : public IRecordingLookup { public: @@ -75,6 +90,12 @@ class VdrRecordingLocalTimerLookup : public IRecordingLocalTimerLookup RecordingLocalTimerLookupResult findActive(const std::string& recordingFile) const override; }; +class VdrRecordingRemoteTimerLookup : public IRecordingRemoteTimerLookup +{ +public: + RecordingRemoteTimerLookupResult findActive(const std::string& recordingFile) const override; +}; + class RecordingTrashAnalyzer { public: @@ -82,7 +103,8 @@ class RecordingTrashAnalyzer const IRecordingLookup& recordingLookup, const IRecordingReplayLookup& replayLookup, const IRecordingHandlerLookup& recordingHandlerLookup, - const IRecordingLocalTimerLookup& localTimerLookup); + const IRecordingLocalTimerLookup& localTimerLookup, + const IRecordingRemoteTimerLookup& remoteTimerLookup); RecordingMutationAnalysis analyze(const std::string& recordingFile) const; @@ -91,6 +113,7 @@ class RecordingTrashAnalyzer const IRecordingReplayLookup& replayLookup; const IRecordingHandlerLookup& recordingHandlerLookup; const IRecordingLocalTimerLookup& localTimerLookup; + const IRecordingRemoteTimerLookup& remoteTimerLookup; }; #endif From 04a881f34bfc2d5792257dd0657cdba7bdc443cd Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 23:31:31 +0200 Subject: [PATCH 018/120] Implement remote recording timer analysis --- recordinganalysis.cpp | 62 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/recordinganalysis.cpp b/recordinganalysis.cpp index 71cbdce..a7305f1 100644 --- a/recordinganalysis.cpp +++ b/recordinganalysis.cpp @@ -1,6 +1,7 @@ #include "recordinganalysis.h" #include +#include #include #include @@ -58,15 +59,63 @@ RecordingLocalTimerLookupResult VdrRecordingLocalTimerLookup::findActive( return result; } +RecordingRemoteTimerLookupResult VdrRecordingRemoteTimerLookup::findActive( + const std::string& recordingFile) const +{ + RecordingRemoteTimerLookupResult result; + + if (recordingFile.empty()) + return result; + + const cString timerIdText = GetRecordingTimerId(recordingFile.c_str()); + const char* timerId = *timerIdText; + + if (!timerId || !*timerId) { + result.known = true; + return result; + } + + const std::string value(timerId); + const std::string::size_type separator = value.find('@'); + if (separator == std::string::npos || separator == 0 || separator + 1 >= value.size()) + return result; + + try { + std::size_t parsed = 0; + const long id = std::stol(value.substr(0, separator), &parsed, 10); + if (parsed != separator || id <= 0 || id > std::numeric_limits::max()) + return result; + result.timerId = static_cast(id); + } + catch (...) { + return result; + } + + result.remote = value.substr(separator + 1); + if (result.remote.empty()) + return result; + + LOCK_TIMERS_READ; + const cTimer* timer = Timers->GetById(result.timerId, result.remote.c_str()); + if (!timer) + return result; + + result.known = true; + result.active = timer->HasFlags(tfActive) || timer->Recording(); + return result; +} + RecordingTrashAnalyzer::RecordingTrashAnalyzer( const IRecordingLookup& recordingLookup, const IRecordingReplayLookup& replayLookup, const IRecordingHandlerLookup& recordingHandlerLookup, - const IRecordingLocalTimerLookup& localTimerLookup) + const IRecordingLocalTimerLookup& localTimerLookup, + const IRecordingRemoteTimerLookup& remoteTimerLookup) : recordingLookup(recordingLookup), replayLookup(replayLookup), recordingHandlerLookup(recordingHandlerLookup), - localTimerLookup(localTimerLookup) + localTimerLookup(localTimerLookup), + remoteTimerLookup(remoteTimerLookup) { } @@ -107,7 +156,14 @@ RecordingMutationAnalysis RecordingTrashAnalyzer::analyze( else if (localTimer.active) analysis.constraints.push_back(RecordingConstraint::LocalTimerActive); - analysis.constraints.push_back(RecordingConstraint::UnknownRemoteTimerState); + const RecordingRemoteTimerLookupResult remoteTimer = + remoteTimerLookup.findActive(recording.recordingFile); + + if (!remoteTimer.known) + analysis.constraints.push_back(RecordingConstraint::UnknownRemoteTimerState); + else if (remoteTimer.active) + analysis.constraints.push_back(RecordingConstraint::RemoteTimerActive); + analysis.constraints.push_back(RecordingConstraint::UnknownSearchTimerState); return analysis; From 2efae8c3a27ad4f751712ca931e95bfa97059e3b Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 23:37:45 +0200 Subject: [PATCH 019/120] Add EPGSearch recording origin analysis provider --- recordinganalysis.h | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/recordinganalysis.h b/recordinganalysis.h index fca2a69..977b4fe 100644 --- a/recordinganalysis.h +++ b/recordinganalysis.h @@ -31,6 +31,13 @@ struct RecordingRemoteTimerLookupResult std::string remote; }; +struct RecordingSearchTimerLookupResult +{ + bool known = false; + bool searchTimerRecording = false; + int searchTimerId = -1; +}; + class IRecordingLookup { public: @@ -66,6 +73,13 @@ class IRecordingRemoteTimerLookup virtual RecordingRemoteTimerLookupResult findActive(const std::string& recordingFile) const = 0; }; +class IRecordingSearchTimerLookup +{ +public: + virtual ~IRecordingSearchTimerLookup() = default; + virtual RecordingSearchTimerLookupResult findOrigin(const std::string& recordingFile) const = 0; +}; + class VdrRecordingLookup : public IRecordingLookup { public: @@ -96,6 +110,12 @@ class VdrRecordingRemoteTimerLookup : public IRecordingRemoteTimerLookup RecordingRemoteTimerLookupResult findActive(const std::string& recordingFile) const override; }; +class VdrRecordingSearchTimerLookup : public IRecordingSearchTimerLookup +{ +public: + RecordingSearchTimerLookupResult findOrigin(const std::string& recordingFile) const override; +}; + class RecordingTrashAnalyzer { public: @@ -104,7 +124,8 @@ class RecordingTrashAnalyzer const IRecordingReplayLookup& replayLookup, const IRecordingHandlerLookup& recordingHandlerLookup, const IRecordingLocalTimerLookup& localTimerLookup, - const IRecordingRemoteTimerLookup& remoteTimerLookup); + const IRecordingRemoteTimerLookup& remoteTimerLookup, + const IRecordingSearchTimerLookup& searchTimerLookup); RecordingMutationAnalysis analyze(const std::string& recordingFile) const; @@ -114,6 +135,7 @@ class RecordingTrashAnalyzer const IRecordingHandlerLookup& recordingHandlerLookup; const IRecordingLocalTimerLookup& localTimerLookup; const IRecordingRemoteTimerLookup& remoteTimerLookup; + const IRecordingSearchTimerLookup& searchTimerLookup; }; #endif From 019927d40d7590569000ff10a722ba26bcfbaf2a Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 23:52:17 +0200 Subject: [PATCH 020/120] Implement SearchTimer origin analysis --- recordinganalysis.cpp | 139 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 119 insertions(+), 20 deletions(-) diff --git a/recordinganalysis.cpp b/recordinganalysis.cpp index a7305f1..ec5a180 100644 --- a/recordinganalysis.cpp +++ b/recordinganalysis.cpp @@ -6,6 +6,70 @@ #include #include +namespace { + +bool parsePositiveInteger(const std::string& value, int& result) +{ + try { + std::size_t parsed = 0; + const long number = std::stol(value, &parsed, 10); + if (parsed != value.size() || number < 0 || number > std::numeric_limits::max()) + return false; + result = static_cast(number); + return true; + } + catch (...) { + return false; + } +} + +bool parseSearchTimerId(const char* aux, bool& present, int& searchTimerId) +{ + present = false; + searchTimerId = -1; + + if (!aux || !*aux) + return true; + + const std::string value(aux); + const std::string openTag = ""; + const std::string closeTag = ""; + const std::string::size_type begin = value.find(openTag); + + if (begin == std::string::npos) + return true; + + const std::string::size_type contentBegin = begin + openTag.size(); + const std::string::size_type end = value.find(closeTag, contentBegin); + if (end == std::string::npos || end == contentBegin) + return false; + + const std::string idText = value.substr(contentBegin, end - contentBegin); + if (!parsePositiveInteger(idText, searchTimerId)) + return false; + + present = true; + return true; +} + +bool parseRemoteTimerId( + const std::string& value, + int& timerId, + std::string& remote) +{ + const std::string::size_type separator = value.find('@'); + if (separator == std::string::npos || separator == 0 || separator + 1 >= value.size()) + return false; + + if (!parsePositiveInteger(value.substr(0, separator), timerId) || timerId <= 0) + return false; + + remote = value.substr(separator + 1); + return !remote.empty(); +} + +} + RecordingLookupResult VdrRecordingLookup::find(const std::string& recordingFile) const { RecordingLookupResult result; @@ -75,33 +139,60 @@ RecordingRemoteTimerLookupResult VdrRecordingRemoteTimerLookup::findActive( return result; } - const std::string value(timerId); - const std::string::size_type separator = value.find('@'); - if (separator == std::string::npos || separator == 0 || separator + 1 >= value.size()) + if (!parseRemoteTimerId(timerId, result.timerId, result.remote)) return result; - try { - std::size_t parsed = 0; - const long id = std::stol(value.substr(0, separator), &parsed, 10); - if (parsed != separator || id <= 0 || id > std::numeric_limits::max()) - return result; - result.timerId = static_cast(id); - } - catch (...) { + LOCK_TIMERS_READ; + const cTimer* timer = Timers->GetById(result.timerId, result.remote.c_str()); + if (!timer) return result; - } - result.remote = value.substr(separator + 1); - if (result.remote.empty()) + result.known = true; + result.active = timer->HasFlags(tfActive) || timer->Recording(); + return result; +} + +RecordingSearchTimerLookupResult VdrRecordingSearchTimerLookup::findOrigin( + const std::string& recordingFile) const +{ + RecordingSearchTimerLookupResult result; + + if (recordingFile.empty()) return result; LOCK_TIMERS_READ; - const cTimer* timer = Timers->GetById(result.timerId, result.remote.c_str()); - if (!timer) + + const cTimer* timer = nullptr; + if (cRecordControl* recordControl = cRecordControls::GetRecordControl(recordingFile.c_str())) + timer = recordControl->Timer(); + + if (!timer) { + const cString timerIdText = GetRecordingTimerId(recordingFile.c_str()); + const char* timerId = *timerIdText; + + if (!timerId || !*timerId) { + result.known = true; + return result; + } + + int id = 0; + std::string remote; + if (!parseRemoteTimerId(timerId, id, remote)) + return result; + + timer = Timers->GetById(id, remote.c_str()); + if (!timer) + return result; + } + + bool present = false; + int searchTimerId = -1; + if (!parseSearchTimerId(timer->Aux(), present, searchTimerId)) return result; result.known = true; - result.active = timer->HasFlags(tfActive) || timer->Recording(); + result.searchTimerRecording = present; + result.searchTimerId = present ? searchTimerId : -1; return result; } @@ -110,12 +201,14 @@ RecordingTrashAnalyzer::RecordingTrashAnalyzer( const IRecordingReplayLookup& replayLookup, const IRecordingHandlerLookup& recordingHandlerLookup, const IRecordingLocalTimerLookup& localTimerLookup, - const IRecordingRemoteTimerLookup& remoteTimerLookup) + const IRecordingRemoteTimerLookup& remoteTimerLookup, + const IRecordingSearchTimerLookup& searchTimerLookup) : recordingLookup(recordingLookup), replayLookup(replayLookup), recordingHandlerLookup(recordingHandlerLookup), localTimerLookup(localTimerLookup), - remoteTimerLookup(remoteTimerLookup) + remoteTimerLookup(remoteTimerLookup), + searchTimerLookup(searchTimerLookup) { } @@ -164,7 +257,13 @@ RecordingMutationAnalysis RecordingTrashAnalyzer::analyze( else if (remoteTimer.active) analysis.constraints.push_back(RecordingConstraint::RemoteTimerActive); - analysis.constraints.push_back(RecordingConstraint::UnknownSearchTimerState); + const RecordingSearchTimerLookupResult searchTimer = + searchTimerLookup.findOrigin(recording.recordingFile); + + if (!searchTimer.known) + analysis.constraints.push_back(RecordingConstraint::UnknownSearchTimerState); + else if (searchTimer.searchTimerRecording) + analysis.constraints.push_back(RecordingConstraint::SearchTimerRecording); return analysis; } From 9f5b98471cc6ef3c8e2ca1e82b1341f0cd54292a Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 23:57:42 +0200 Subject: [PATCH 021/120] Add recording trash preflight service model --- recordingpreflight.h | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 recordingpreflight.h diff --git a/recordingpreflight.h b/recordingpreflight.h new file mode 100644 index 0000000..27090aa --- /dev/null +++ b/recordingpreflight.h @@ -0,0 +1,37 @@ +#ifndef __RECORDINGPREFLIGHT_H +#define __RECORDINGPREFLIGHT_H + +#include "recordinganalysis.h" +#include "recordingmutation.h" + +#include +#include + +struct RecordingTrashPreflightResult +{ + bool executable = false; + std::string recordingFile; + std::vector constraints; + std::vector blockers; + std::vector warnings; + std::vector steps; + RecordingMutationRevision revision; +}; + +class RecordingTrashPreflightService +{ +public: + RecordingTrashPreflightService( + const RecordingTrashAnalyzer& analyzer, + const RecordingMutationPlanner& planner); + + RecordingTrashPreflightResult preview( + const std::string& recordingFile, + const RecordingMutationPolicy& policy) const; + +private: + const RecordingTrashAnalyzer& analyzer; + const RecordingMutationPlanner& planner; +}; + +#endif From 9df11a6f8cde29902e823cee91444f1c2181f679 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 23:57:56 +0200 Subject: [PATCH 022/120] Implement recording trash preflight service --- recordingpreflight.cpp | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 recordingpreflight.cpp diff --git a/recordingpreflight.cpp b/recordingpreflight.cpp new file mode 100644 index 0000000..1939e34 --- /dev/null +++ b/recordingpreflight.cpp @@ -0,0 +1,34 @@ +#include "recordingpreflight.h" + +RecordingTrashPreflightService::RecordingTrashPreflightService( + const RecordingTrashAnalyzer& analyzer, + const RecordingMutationPlanner& planner) + : analyzer(analyzer), + planner(planner) +{ +} + +RecordingTrashPreflightResult RecordingTrashPreflightService::preview( + const std::string& recordingFile, + const RecordingMutationPolicy& policy) const +{ + RecordingTrashPreflightResult result; + const RecordingMutationAnalysis analysis = analyzer.analyze(recordingFile); + const RecordingMutationPlan plan = planner.buildTrashPlan(analysis, policy); + + result.executable = plan.executable; + result.recordingFile = analysis.recordingFile; + result.warnings = plan.warnings; + result.revision = plan.expectedRevision; + + for (const RecordingConstraint constraint : analysis.constraints) + result.constraints.push_back(RecordingConstraintName(constraint)); + + for (const RecordingConstraint blocker : plan.blockers) + result.blockers.push_back(RecordingConstraintName(blocker)); + + for (const RecordingMutationStep step : plan.steps) + result.steps.push_back(RecordingMutationStepName(step)); + + return result; +} From ae46d66db0fa4fa7e197db0d4896a66d9425917c Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Mon, 13 Jul 2026 23:58:43 +0200 Subject: [PATCH 023/120] Build recording trash preflight service --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 7dbbaeb..fc888b8 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ LIBS += $(shell pkg-config --libs Magick++) CXXFLAGS += -DUSE_LIBMAGICKPLUSPLUS endif -OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o +OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingpreflight.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o CFGS = API.html all: $(SOFILE) i18n From c7ca4fa8bc7a83f1ead4fb79d08cbda9e4f72e7d Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 00:03:04 +0200 Subject: [PATCH 024/120] Add recording trash preview HTTP service --- recordingpreview.h | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 recordingpreview.h diff --git a/recordingpreview.h b/recordingpreview.h new file mode 100644 index 0000000..1b9a6a0 --- /dev/null +++ b/recordingpreview.h @@ -0,0 +1,25 @@ +#ifndef __RECORDINGPREVIEW_H +#define __RECORDINGPREVIEW_H + +#include +#include +#include + +class RecordingTrashPreviewResponder : public cxxtools::http::Responder +{ +public: + explicit RecordingTrashPreviewResponder(cxxtools::http::Service& service) + : cxxtools::http::Responder(service) + { + } + + void reply( + std::ostream& out, + cxxtools::http::Request& request, + cxxtools::http::Reply& reply) override; +}; + +typedef cxxtools::http::CachedService + RecordingTrashPreviewService; + +#endif From 2d7f04905f818e7a78e340f978e7fe755c09d0ae Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 00:03:36 +0200 Subject: [PATCH 025/120] Implement recording trash preview HTTP service --- recordingpreview.cpp | 75 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 recordingpreview.cpp diff --git a/recordingpreview.cpp b/recordingpreview.cpp new file mode 100644 index 0000000..f81a121 --- /dev/null +++ b/recordingpreview.cpp @@ -0,0 +1,75 @@ +#include "recordingpreview.h" + +#include "recordinganalysis.h" +#include "recordingmutation.h" +#include "recordingpreflight.h" +#include "tools.h" + +#include + +void RecordingTrashPreviewResponder::reply( + std::ostream& out, + cxxtools::http::Request& request, + cxxtools::http::Reply& reply) +{ + QueryHandler::addHeader(reply); + + if (request.method() == "OPTIONS") { + reply.addHeader("Allow", "POST"); + reply.httpReturn(200, "OK"); + return; + } + + if (request.method() != "POST") { + reply.httpReturn(501, "Only POST method is supported by the /recordings/trash/preview service."); + return; + } + + QueryHandler query("/recordings/trash/preview", request); + const std::string recordingFile = query.getBodyAsString("file"); + + if (recordingFile.empty()) { + reply.httpReturn(400, "Recording file is missing."); + return; + } + + RecordingMutationPolicy policy; + policy.allowRecordingHandlerStop = query.getBodyAsBool("allow_recording_handler_stop"); + policy.allowReplayStop = query.getBodyAsBool("allow_replay_stop"); + policy.allowLocalTimerStop = query.getBodyAsBool("allow_local_timer_stop"); + policy.allowRemoteTimerStop = query.getBodyAsBool("allow_remote_timer_stop"); + + VdrRecordingLookup recordingLookup; + VdrRecordingReplayLookup replayLookup; + VdrRecordingHandlerLookup recordingHandlerLookup; + VdrRecordingLocalTimerLookup localTimerLookup; + VdrRecordingRemoteTimerLookup remoteTimerLookup; + VdrRecordingSearchTimerLookup searchTimerLookup; + + RecordingTrashAnalyzer analyzer( + recordingLookup, + replayLookup, + recordingHandlerLookup, + localTimerLookup, + remoteTimerLookup, + searchTimerLookup); + + RecordingMutationPlanner planner; + RecordingTrashPreflightService preflightService(analyzer, planner); + const RecordingTrashPreflightResult result = + preflightService.preview(recordingFile, policy); + + reply.addHeader("Content-Type", "application/json; charset=utf-8"); + + cxxtools::JsonSerializer serializer(out); + serializer.serialize(result.executable, "executable"); + serializer.serialize(result.recordingFile, "recording_file"); + serializer.serialize(result.constraints, "constraints"); + serializer.serialize(result.blockers, "blockers"); + serializer.serialize(result.warnings, "warnings"); + serializer.serialize(result.steps, "steps"); + serializer.serialize(result.revision.recordingFile, "revision_recording_file"); + serializer.serialize(result.revision.recordingsState, "revision_recordings_state"); + serializer.serialize(result.revision.timersState, "revision_timers_state"); + serializer.finish(); +} From c093782ff12e81d3ae0b81f4655a3d2c1ab0199b Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 00:03:55 +0200 Subject: [PATCH 026/120] Register recording trash preview service header --- serverthread.h | 1 + 1 file changed, 1 insertion(+) diff --git a/serverthread.h b/serverthread.h index 5a0739f..82406c3 100644 --- a/serverthread.h +++ b/serverthread.h @@ -16,6 +16,7 @@ #include "channels.h" #include "events.h" #include "recordings.h" +#include "recordingpreview.h" #include "remote.h" #include "timers.h" #include "changestate.h" From 88d213d48ff7021551fcbcee9cae981556be8d7b Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 00:05:00 +0200 Subject: [PATCH 027/120] Register recording trash preview HTTP route --- serverthread.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/serverthread.cpp b/serverthread.cpp index b6704b1..70b9a11 100644 --- a/serverthread.cpp +++ b/serverthread.cpp @@ -40,6 +40,7 @@ void cServerThread::Action(void) ChannelsService channelsService; EventsService eventsService; RecordingsService recordingsService; + RecordingTrashPreviewService recordingTrashPreviewService; RemoteService remoteService; TimersService timersService; ChangeStateService changeStateService; @@ -59,6 +60,7 @@ void cServerThread::Action(void) RestfulService* recordings = new RestfulService("/recordings", true, 1); RestfulService* recordingsCut = new RestfulService("/recordings/cut", true, 1, recordings); RestfulService* recordingsMarks = new RestfulService("/recordings/marks", true, 1, recordings); + RestfulService* recordingTrashPreview = new RestfulService("/recordings/trash/preview", true, 1, recordings); RestfulService* remote = new RestfulService("/remote", true, 1); RestfulService* timers = new RestfulService("/timers", true, 1); RestfulService* changeState = new RestfulService("/change-state", true, 1); @@ -79,6 +81,7 @@ void cServerThread::Action(void) services->appendService(recordings); services->appendService(recordingsCut); services->appendService(recordingsMarks); + services->appendService(recordingTrashPreview); services->appendService(remote); services->appendService(timers); services->appendService(changeState); @@ -92,6 +95,7 @@ void cServerThread::Action(void) server->addService(std::move(*info->Regex()), infoService); server->addService(std::move(*channels->Regex()), channelsService); server->addService(std::move(*events->Regex()), eventsService); + server->addService(std::move(*recordingTrashPreview->Regex()), recordingTrashPreviewService); server->addService(std::move(*recordings->Regex()), recordingsService); server->addService(std::move(*remote->Regex()), remoteService); server->addService(std::move(*timers->Regex()), timersService); From 877a3e4af0721316d9122121d7239092b409fe9e Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 00:06:44 +0200 Subject: [PATCH 028/120] Build recording trash preview HTTP service --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index fc888b8..a5ddd89 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ LIBS += $(shell pkg-config --libs Magick++) CXXFLAGS += -DUSE_LIBMAGICKPLUSPLUS endif -OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingpreflight.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o +OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingpreflight.o recordingpreview.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o CFGS = API.html all: $(SOFILE) i18n From 90d4e0d12c035e3c4165c13a28aa2f48b4c81a46 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 00:28:10 +0200 Subject: [PATCH 029/120] Add deterministic trash preflight state fingerprints --- recordinganalysis.cpp | 68 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/recordinganalysis.cpp b/recordinganalysis.cpp index ec5a180..9867ba5 100644 --- a/recordinganalysis.cpp +++ b/recordinganalysis.cpp @@ -1,7 +1,10 @@ #include "recordinganalysis.h" +#include #include #include +#include +#include #include #include @@ -68,6 +71,59 @@ bool parseRemoteTimerId( return !remote.empty(); } +long long fingerprint(const std::string& value) +{ + std::uint64_t hash = 1469598103934665603ULL; + for (unsigned char character : value) { + hash ^= character; + hash *= 1099511628211ULL; + } + return static_cast(hash & 0x7FFFFFFFFFFFFFFFULL); +} + +long long recordingFingerprint(const std::string& recordingFile, bool found) +{ + std::ostringstream state; + state << recordingFile << '|'; + state << (found ? "found" : "missing"); + + struct stat fileState; + if (found && stat(recordingFile.c_str(), &fileState) == 0) { + state << '|' << static_cast(fileState.st_dev); + state << '|' << static_cast(fileState.st_ino); + state << '|' << static_cast(fileState.st_mtime); + state << '|' << static_cast(fileState.st_ctime); + } + else if (found) { + state << "|stat-unavailable"; + } + + return fingerprint(state.str()); +} + +long long timerFingerprint( + bool replaying, + const RecordingHandlerLookupResult& handlerUsage, + const RecordingLocalTimerLookupResult& localTimer, + const RecordingRemoteTimerLookupResult& remoteTimer, + const RecordingSearchTimerLookupResult& searchTimer) +{ + std::ostringstream state; + state << "replay=" << replaying; + state << "|handler-known=" << handlerUsage.known; + state << "|handler-busy=" << handlerUsage.busy; + state << "|local-known=" << localTimer.known; + state << "|local-active=" << localTimer.active; + state << "|remote-known=" << remoteTimer.known; + state << "|remote-active=" << remoteTimer.active; + state << "|remote-id=" << remoteTimer.timerId; + state << "|remote=" << remoteTimer.remote; + state << "|search-known=" << searchTimer.known; + state << "|search-recording=" << searchTimer.searchTimerRecording; + state << "|search-id=" << searchTimer.searchTimerId; + return fingerprint(state.str()); +} + } RecordingLookupResult VdrRecordingLookup::find(const std::string& recordingFile) const @@ -221,6 +277,7 @@ RecordingMutationAnalysis RecordingTrashAnalyzer::analyze( analysis.revision.recordingFile = recordingFile; const RecordingLookupResult recording = recordingLookup.find(recordingFile); + analysis.revision.recordingsState = recordingFingerprint(recordingFile, recording.found); if (!recording.found) { analysis.constraints.push_back(RecordingConstraint::RecordingMissing); @@ -229,8 +286,10 @@ RecordingMutationAnalysis RecordingTrashAnalyzer::analyze( analysis.recordingFile = recording.recordingFile; analysis.revision.recordingFile = recording.recordingFile; + analysis.revision.recordingsState = recordingFingerprint(recording.recordingFile, true); - if (replayLookup.isReplaying(recording.recordingFile)) + const bool replaying = replayLookup.isReplaying(recording.recordingFile); + if (replaying) analysis.constraints.push_back(RecordingConstraint::ReplayActive); const RecordingHandlerLookupResult handlerUsage = @@ -265,5 +324,12 @@ RecordingMutationAnalysis RecordingTrashAnalyzer::analyze( else if (searchTimer.searchTimerRecording) analysis.constraints.push_back(RecordingConstraint::SearchTimerRecording); + analysis.revision.timersState = timerFingerprint( + replaying, + handlerUsage, + localTimer, + remoteTimer, + searchTimer); + return analysis; } From dcb0b9cf33edc76948e6ecdcdc76e488a6c66c4a Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 06:51:53 +0200 Subject: [PATCH 030/120] Add recording trash execution gate model --- recordingexecution.h | 47 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 recordingexecution.h diff --git a/recordingexecution.h b/recordingexecution.h new file mode 100644 index 0000000..4e1d4a4 --- /dev/null +++ b/recordingexecution.h @@ -0,0 +1,47 @@ +#ifndef __RECORDINGEXECUTION_H +#define __RECORDINGEXECUTION_H + +#include "recordinganalysis.h" +#include "recordingmutation.h" + +#include +#include + +enum class RecordingTrashExecutionGateStatus +{ + Ready, + Conflict, + Blocked +}; + +struct RecordingTrashExecutionGateResult +{ + RecordingTrashExecutionGateStatus status = RecordingTrashExecutionGateStatus::Blocked; + std::string recordingFile; + RecordingMutationRevision expectedRevision; + RecordingMutationRevision currentRevision; + std::vector blockers; + std::vector warnings; +}; + +class RecordingTrashExecutionGate +{ +public: + RecordingTrashExecutionGate( + const RecordingTrashAnalyzer& analyzer, + const RecordingMutationPlanner& planner); + + RecordingTrashExecutionGateResult validate( + const std::string& recordingFile, + const RecordingMutationRevision& expectedRevision, + const RecordingMutationPolicy& policy) const; + +private: + const RecordingTrashAnalyzer& analyzer; + const RecordingMutationPlanner& planner; +}; + +const char* RecordingTrashExecutionGateStatusName( + RecordingTrashExecutionGateStatus status); + +#endif From 15ab57f9c6f0d8a6ea370b47ce77b723d5ac82fb Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 06:52:42 +0200 Subject: [PATCH 031/120] Implement recording trash execution gate --- recordingexecution.cpp | 67 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 recordingexecution.cpp diff --git a/recordingexecution.cpp b/recordingexecution.cpp new file mode 100644 index 0000000..c0edf4c --- /dev/null +++ b/recordingexecution.cpp @@ -0,0 +1,67 @@ +#include "recordingexecution.h" + +namespace { + +bool sameRevision( + const RecordingMutationRevision& left, + const RecordingMutationRevision& right) +{ + return left.recordingFile == right.recordingFile + && left.recordingsState == right.recordingsState + && left.timersState == right.timersState; +} + +} + +RecordingTrashExecutionGate::RecordingTrashExecutionGate( + const RecordingTrashAnalyzer& analyzer, + const RecordingMutationPlanner& planner) + : analyzer(analyzer), + planner(planner) +{ +} + +RecordingTrashExecutionGateResult RecordingTrashExecutionGate::validate( + const std::string& recordingFile, + const RecordingMutationRevision& expectedRevision, + const RecordingMutationPolicy& policy) const +{ + RecordingTrashExecutionGateResult result; + result.recordingFile = recordingFile; + result.expectedRevision = expectedRevision; + + const RecordingMutationAnalysis analysis = analyzer.analyze(recordingFile); + const RecordingMutationPlan plan = planner.buildTrashPlan(analysis, policy); + + result.currentRevision = analysis.revision; + result.blockers = plan.blockers; + result.warnings = plan.warnings; + + if (!sameRevision(expectedRevision, analysis.revision)) { + result.status = RecordingTrashExecutionGateStatus::Conflict; + return result; + } + + if (!plan.executable) { + result.status = RecordingTrashExecutionGateStatus::Blocked; + return result; + } + + result.status = RecordingTrashExecutionGateStatus::Ready; + return result; +} + +const char* RecordingTrashExecutionGateStatusName( + RecordingTrashExecutionGateStatus status) +{ + switch (status) { + case RecordingTrashExecutionGateStatus::Ready: + return "ready"; + case RecordingTrashExecutionGateStatus::Conflict: + return "conflict"; + case RecordingTrashExecutionGateStatus::Blocked: + return "blocked"; + } + + return "blocked"; +} From 5a178dbe381808ec7a0910424ce010c951ef31ce Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 06:53:51 +0200 Subject: [PATCH 032/120] Build recording trash execution gate --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index a5ddd89..6eb00df 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ LIBS += $(shell pkg-config --libs Magick++) CXXFLAGS += -DUSE_LIBMAGICKPLUSPLUS endif -OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingpreflight.o recordingpreview.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o +OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingpreflight.o recordingpreview.o recordingexecution.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o CFGS = API.html all: $(SOFILE) i18n From 8092bde2d392015a81bf8312a90c4c1b24dffaef Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 06:59:43 +0200 Subject: [PATCH 033/120] Add recording trash validation HTTP service --- recordingvalidate.h | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 recordingvalidate.h diff --git a/recordingvalidate.h b/recordingvalidate.h new file mode 100644 index 0000000..e67b228 --- /dev/null +++ b/recordingvalidate.h @@ -0,0 +1,25 @@ +#ifndef __RECORDINGVALIDATE_H +#define __RECORDINGVALIDATE_H + +#include +#include +#include + +class RecordingTrashValidateResponder : public cxxtools::http::Responder +{ +public: + explicit RecordingTrashValidateResponder(cxxtools::http::Service& service) + : cxxtools::http::Responder(service) + { + } + + void reply( + std::ostream& out, + cxxtools::http::Request& request, + cxxtools::http::Reply& reply) override; +}; + +typedef cxxtools::http::CachedService + RecordingTrashValidateService; + +#endif From 4daef52fed9d65cc92a20e06e8b98a330ba1c85a Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 07:00:47 +0200 Subject: [PATCH 034/120] Implement recording trash validation HTTP service --- recordingvalidate.cpp | 139 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 recordingvalidate.cpp diff --git a/recordingvalidate.cpp b/recordingvalidate.cpp new file mode 100644 index 0000000..d700aa4 --- /dev/null +++ b/recordingvalidate.cpp @@ -0,0 +1,139 @@ +#include "recordingvalidate.h" + +#include "recordinganalysis.h" +#include "recordingexecution.h" +#include "recordingmutation.h" +#include "tools.h" + +#include + +#include + +namespace { + +bool parseLongLong(const std::string& value, long long& result) +{ + if (value.empty()) + return false; + + try { + std::size_t parsed = 0; + result = std::stoll(value, &parsed, 10); + return parsed == value.size(); + } + catch (...) { + return false; + } +} + +std::vector constraintNames( + const std::vector& constraints) +{ + std::vector names; + for (RecordingConstraint constraint : constraints) + names.push_back(RecordingConstraintName(constraint)); + return names; +} + +} + +void RecordingTrashValidateResponder::reply( + std::ostream& out, + cxxtools::http::Request& request, + cxxtools::http::Reply& reply) +{ + QueryHandler::addHeader(reply); + + if (request.method() == "OPTIONS") { + reply.addHeader("Allow", "POST"); + reply.httpReturn(200, "OK"); + return; + } + + if (request.method() != "POST") { + reply.httpReturn(501, "Only POST method is supported by the /recordings/trash/validate service."); + return; + } + + QueryHandler query("/recordings/trash/validate", request); + const std::string recordingFile = query.getBodyAsString("file"); + const std::string recordingsStateText = + query.getBodyAsString("revision_recordings_state"); + const std::string timersStateText = + query.getBodyAsString("revision_timers_state"); + + long long recordingsState = 0; + long long timersState = 0; + + if (recordingFile.empty()) { + reply.httpReturn(400, "Recording file is missing."); + return; + } + + if (!parseLongLong(recordingsStateText, recordingsState) || + !parseLongLong(timersStateText, timersState)) { + reply.httpReturn(400, "Recording revision is missing or invalid."); + return; + } + + RecordingMutationPolicy policy; + policy.allowRecordingHandlerStop = query.getBodyAsBool("allow_recording_handler_stop"); + policy.allowReplayStop = query.getBodyAsBool("allow_replay_stop"); + policy.allowLocalTimerStop = query.getBodyAsBool("allow_local_timer_stop"); + policy.allowRemoteTimerStop = query.getBodyAsBool("allow_remote_timer_stop"); + + VdrRecordingLookup recordingLookup; + VdrRecordingReplayLookup replayLookup; + VdrRecordingHandlerLookup recordingHandlerLookup; + VdrRecordingLocalTimerLookup localTimerLookup; + VdrRecordingRemoteTimerLookup remoteTimerLookup; + VdrRecordingSearchTimerLookup searchTimerLookup; + + RecordingTrashAnalyzer analyzer( + recordingLookup, + replayLookup, + recordingHandlerLookup, + localTimerLookup, + remoteTimerLookup, + searchTimerLookup); + + RecordingMutationPlanner planner; + RecordingTrashExecutionGate gate(analyzer, planner); + + RecordingMutationRevision expectedRevision; + expectedRevision.recordingFile = recordingFile; + expectedRevision.recordingsState = recordingsState; + expectedRevision.timersState = timersState; + + const RecordingTrashExecutionGateResult result = + gate.validate(recordingFile, expectedRevision, policy); + + reply.addHeader("Content-Type", "application/json; charset=utf-8"); + + cxxtools::JsonSerializer serializer(out); + serializer.serialize( + std::string(RecordingTrashExecutionGateStatusName(result.status)), + "status"); + serializer.serialize(result.recordingFile, "recording_file"); + serializer.serialize(constraintNames(result.blockers), "blockers"); + serializer.serialize(result.warnings, "warnings"); + serializer.serialize( + result.expectedRevision.recordingFile, + "expected_revision_recording_file"); + serializer.serialize( + result.expectedRevision.recordingsState, + "expected_revision_recordings_state"); + serializer.serialize( + result.expectedRevision.timersState, + "expected_revision_timers_state"); + serializer.serialize( + result.currentRevision.recordingFile, + "current_revision_recording_file"); + serializer.serialize( + result.currentRevision.recordingsState, + "current_revision_recordings_state"); + serializer.serialize( + result.currentRevision.timersState, + "current_revision_timers_state"); + serializer.finish(); +} From d8e53b5068b1e8b7a2e692d6a7c72f7f387b0b6f Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 07:01:20 +0200 Subject: [PATCH 035/120] Register recording trash validation service header --- serverthread.h | 1 + 1 file changed, 1 insertion(+) diff --git a/serverthread.h b/serverthread.h index 82406c3..ef9e712 100644 --- a/serverthread.h +++ b/serverthread.h @@ -17,6 +17,7 @@ #include "events.h" #include "recordings.h" #include "recordingpreview.h" +#include "recordingvalidate.h" #include "remote.h" #include "timers.h" #include "changestate.h" From 0020294071241da88bb116a2611c9786d1eaa7bc Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 07:02:49 +0200 Subject: [PATCH 036/120] Register recording trash validation HTTP route --- serverthread.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/serverthread.cpp b/serverthread.cpp index 70b9a11..64da038 100644 --- a/serverthread.cpp +++ b/serverthread.cpp @@ -41,6 +41,7 @@ void cServerThread::Action(void) EventsService eventsService; RecordingsService recordingsService; RecordingTrashPreviewService recordingTrashPreviewService; + RecordingTrashValidateService recordingTrashValidateService; RemoteService remoteService; TimersService timersService; ChangeStateService changeStateService; @@ -61,6 +62,7 @@ void cServerThread::Action(void) RestfulService* recordingsCut = new RestfulService("/recordings/cut", true, 1, recordings); RestfulService* recordingsMarks = new RestfulService("/recordings/marks", true, 1, recordings); RestfulService* recordingTrashPreview = new RestfulService("/recordings/trash/preview", true, 1, recordings); + RestfulService* recordingTrashValidate = new RestfulService("/recordings/trash/validate", true, 1, recordings); RestfulService* remote = new RestfulService("/remote", true, 1); RestfulService* timers = new RestfulService("/timers", true, 1); RestfulService* changeState = new RestfulService("/change-state", true, 1); @@ -82,6 +84,7 @@ void cServerThread::Action(void) services->appendService(recordingsCut); services->appendService(recordingsMarks); services->appendService(recordingTrashPreview); + services->appendService(recordingTrashValidate); services->appendService(remote); services->appendService(timers); services->appendService(changeState); @@ -96,6 +99,7 @@ void cServerThread::Action(void) server->addService(std::move(*channels->Regex()), channelsService); server->addService(std::move(*events->Regex()), eventsService); server->addService(std::move(*recordingTrashPreview->Regex()), recordingTrashPreviewService); + server->addService(std::move(*recordingTrashValidate->Regex()), recordingTrashValidateService); server->addService(std::move(*recordings->Regex()), recordingsService); server->addService(std::move(*remote->Regex()), remoteService); server->addService(std::move(*timers->Regex()), timersService); From acd5026783ae43a5db0b1676c77ae3c28a927827 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 07:04:00 +0200 Subject: [PATCH 037/120] Build recording trash validation HTTP service --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6eb00df..87e7875 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ LIBS += $(shell pkg-config --libs Magick++) CXXFLAGS += -DUSE_LIBMAGICKPLUSPLUS endif -OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingpreflight.o recordingpreview.o recordingexecution.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o +OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingpreflight.o recordingpreview.o recordingexecution.o recordingvalidate.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o CFGS = API.html all: $(SOFILE) i18n From 41f9274fd2b422e145e6363f5e5d78d1b41505c4 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 07:13:20 +0200 Subject: [PATCH 038/120] Add safe recording trash executor model --- recordingtrashexecutor.h | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 recordingtrashexecutor.h diff --git a/recordingtrashexecutor.h b/recordingtrashexecutor.h new file mode 100644 index 0000000..712fff7 --- /dev/null +++ b/recordingtrashexecutor.h @@ -0,0 +1,42 @@ +#ifndef __RECORDINGTRASHEXECUTOR_H +#define __RECORDINGTRASHEXECUTOR_H + +#include "recordingexecution.h" + +#include + +enum class RecordingTrashExecutorStatus +{ + Trashed, + Conflict, + Blocked, + NotFound, + Failed +}; + +struct RecordingTrashExecutorResult +{ + RecordingTrashExecutorStatus status = RecordingTrashExecutorStatus::Failed; + std::string recordingFile; + std::string deletedRecordingFile; + std::string message; + RecordingTrashExecutionGateResult gate; +}; + +class RecordingTrashExecutor +{ +public: + explicit RecordingTrashExecutor(const RecordingTrashExecutionGate& gate); + + RecordingTrashExecutorResult executeNormalCase( + const std::string& recordingFile, + const RecordingMutationRevision& expectedRevision, + const RecordingMutationPolicy& policy) const; + +private: + const RecordingTrashExecutionGate& gate; +}; + +const char* RecordingTrashExecutorStatusName(RecordingTrashExecutorStatus status); + +#endif From 6a88d2b0b44a055ab7a73a4a655ccd648c6cf7a1 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 07:14:28 +0200 Subject: [PATCH 039/120] Implement safe normal-case recording trash executor --- recordingtrashexecutor.cpp | 134 +++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 recordingtrashexecutor.cpp diff --git a/recordingtrashexecutor.cpp b/recordingtrashexecutor.cpp new file mode 100644 index 0000000..03b7fe0 --- /dev/null +++ b/recordingtrashexecutor.cpp @@ -0,0 +1,134 @@ +#include "recordingtrashexecutor.h" + +#include "changestatetracker.h" + +#include + +#include +#include + +namespace { + +std::string deletedFileName(const std::string& recordingFile) +{ + static const std::string recordingSuffix = ".rec"; + if (recordingFile.size() >= recordingSuffix.size() && + recordingFile.compare( + recordingFile.size() - recordingSuffix.size(), + recordingSuffix.size(), + recordingSuffix) == 0) + return recordingFile.substr(0, recordingFile.size() - recordingSuffix.size()) + ".del"; + + return std::string(); +} + +} + +RecordingTrashExecutor::RecordingTrashExecutor(const RecordingTrashExecutionGate& gate) + : gate(gate) +{ +} + +RecordingTrashExecutorResult RecordingTrashExecutor::executeNormalCase( + const std::string& recordingFile, + const RecordingMutationRevision& expectedRevision, + const RecordingMutationPolicy& policy) const +{ + RecordingTrashExecutorResult result; + result.recordingFile = recordingFile; + result.gate = gate.validate(recordingFile, expectedRevision, policy); + + if (result.gate.status == RecordingTrashExecutionGateStatus::Conflict) { + result.status = RecordingTrashExecutorStatus::Conflict; + result.message = "Recording state changed after preview."; + return result; + } + + if (result.gate.status != RecordingTrashExecutionGateStatus::Ready) { + result.status = RecordingTrashExecutorStatus::Blocked; + result.message = "Recording trash execution is blocked by the current state or policy."; + return result; + } + + const std::string targetFile = deletedFileName(recordingFile); + if (targetFile.empty()) { + result.status = RecordingTrashExecutorStatus::Blocked; + result.message = "Only active .rec recordings can be moved to the VDR trash."; + return result; + } + + LOCK_TIMERS_WRITE; + LOCK_RECORDINGS_WRITE; + + cRecording* recording = Recordings->GetByName(recordingFile.c_str()); + if (!recording) { + result.status = RecordingTrashExecutorStatus::NotFound; + result.message = "Recording disappeared before execution."; + return result; + } + + const char* nowReplaying = cReplayControl::NowReplaying(); + if (nowReplaying && std::strcmp(nowReplaying, recordingFile.c_str()) == 0) { + result.status = RecordingTrashExecutorStatus::Conflict; + result.message = "Recording started replaying after validation."; + return result; + } + + if (RecordingsHandler.GetUsage(recordingFile.c_str()) != ruNone) { + result.status = RecordingTrashExecutorStatus::Conflict; + result.message = "Recording handler usage changed after validation."; + return result; + } + + if (cRecordControls::GetRecordControl(recordingFile.c_str()) != nullptr) { + result.status = RecordingTrashExecutorStatus::Conflict; + result.message = "A local recording control became active after validation."; + return result; + } + + const cString timerIdText = GetRecordingTimerId(recordingFile.c_str()); + const char* timerId = *timerIdText; + if (timerId && *timerId) { + result.status = RecordingTrashExecutorStatus::Conflict; + result.message = "A timer association appeared after validation."; + return result; + } + + if (!recording->Delete()) { + result.status = RecordingTrashExecutorStatus::Failed; + result.message = "VDR failed to rename the recording into its deleted state."; + return result; + } + + { + LOCK_DELETEDRECORDINGS_WRITE; + Recordings->Del(recording, false); + DeletedRecordings->Add(recording); + } + + cVideoDiskUsage::ForceCheck(); + StateChangeTracker::UpdateRecordings(); + + result.status = RecordingTrashExecutorStatus::Trashed; + result.deletedRecordingFile = targetFile; + result.message = "Recording moved to the VDR trash."; + return result; +} + +const char* RecordingTrashExecutorStatusName(RecordingTrashExecutorStatus status) +{ + switch (status) { + case RecordingTrashExecutorStatus::Trashed: + return "trashed"; + case RecordingTrashExecutorStatus::Conflict: + return "conflict"; + case RecordingTrashExecutorStatus::Blocked: + return "blocked"; + case RecordingTrashExecutorStatus::NotFound: + return "not-found"; + case RecordingTrashExecutorStatus::Failed: + return "failed"; + } + + return "failed"; +} From bd8c991789d55210b76a2450aa32fe1798d36898 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 07:15:39 +0200 Subject: [PATCH 040/120] Build safe recording trash executor --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 87e7875..abfd8e8 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ LIBS += $(shell pkg-config --libs Magick++) CXXFLAGS += -DUSE_LIBMAGICKPLUSPLUS endif -OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingpreflight.o recordingpreview.o recordingexecution.o recordingvalidate.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o +OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingpreflight.o recordingpreview.o recordingexecution.o recordingvalidate.o recordingtrashexecutor.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o CFGS = API.html all: $(SOFILE) i18n From 1d4540e10c0ac35fbe27a55af4393f8a7972c2d5 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 07:19:25 +0200 Subject: [PATCH 041/120] Include VDR video disk usage declaration --- recordingtrashexecutor.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/recordingtrashexecutor.cpp b/recordingtrashexecutor.cpp index 03b7fe0..f6603c7 100644 --- a/recordingtrashexecutor.cpp +++ b/recordingtrashexecutor.cpp @@ -6,6 +6,7 @@ #include #include +#include namespace { From 39817f85aa7f40ec84f543d13730633c81ddfde9 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 07:29:14 +0200 Subject: [PATCH 042/120] Add recording trash HTTP service --- recordingtrash.h | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 recordingtrash.h diff --git a/recordingtrash.h b/recordingtrash.h new file mode 100644 index 0000000..8e5b597 --- /dev/null +++ b/recordingtrash.h @@ -0,0 +1,25 @@ +#ifndef __RECORDINGTRASH_H +#define __RECORDINGTRASH_H + +#include +#include +#include + +class RecordingTrashResponder : public cxxtools::http::Responder +{ +public: + explicit RecordingTrashResponder(cxxtools::http::Service& service) + : cxxtools::http::Responder(service) + { + } + + void reply( + std::ostream& out, + cxxtools::http::Request& request, + cxxtools::http::Reply& reply) override; +}; + +typedef cxxtools::http::CachedService + RecordingTrashService; + +#endif From d4cc424f324c67437c36a3ebe34b421ca9992c73 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 07:30:11 +0200 Subject: [PATCH 043/120] Implement recording trash HTTP service --- recordingtrash.cpp | 131 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 recordingtrash.cpp diff --git a/recordingtrash.cpp b/recordingtrash.cpp new file mode 100644 index 0000000..12a1319 --- /dev/null +++ b/recordingtrash.cpp @@ -0,0 +1,131 @@ +#include "recordingtrash.h" + +#include "recordinganalysis.h" +#include "recordingexecution.h" +#include "recordingmutation.h" +#include "recordingtrashexecutor.h" +#include "tools.h" + +#include + +#include + +namespace { + +bool parseLongLong(const std::string& value, long long& result) +{ + if (value.empty()) + return false; + + try { + std::size_t parsed = 0; + result = std::stoll(value, &parsed, 10); + return parsed == value.size(); + } + catch (...) { + return false; + } +} + +} + +void RecordingTrashResponder::reply( + std::ostream& out, + cxxtools::http::Request& request, + cxxtools::http::Reply& reply) +{ + QueryHandler::addHeader(reply); + + if (request.method() == "OPTIONS") { + reply.addHeader("Allow", "POST"); + reply.httpReturn(200, "OK"); + return; + } + + if (request.method() != "POST") { + reply.httpReturn(501, "Only POST method is supported by the /recordings/trash service."); + return; + } + + QueryHandler query("/recordings/trash", request); + const std::string recordingFile = query.getBodyAsString("file"); + const std::string recordingsStateText = + query.getBodyAsString("revision_recordings_state"); + const std::string timersStateText = + query.getBodyAsString("revision_timers_state"); + + long long recordingsState = 0; + long long timersState = 0; + + if (recordingFile.empty()) { + reply.httpReturn(400, "Recording file is missing."); + return; + } + + if (!parseLongLong(recordingsStateText, recordingsState) || + !parseLongLong(timersStateText, timersState)) { + reply.httpReturn(400, "Recording revision is missing or invalid."); + return; + } + + RecordingMutationPolicy policy; + policy.allowRecordingHandlerStop = false; + policy.allowReplayStop = false; + policy.allowLocalTimerStop = false; + policy.allowRemoteTimerStop = false; + + VdrRecordingLookup recordingLookup; + VdrRecordingReplayLookup replayLookup; + VdrRecordingHandlerLookup recordingHandlerLookup; + VdrRecordingLocalTimerLookup localTimerLookup; + VdrRecordingRemoteTimerLookup remoteTimerLookup; + VdrRecordingSearchTimerLookup searchTimerLookup; + + RecordingTrashAnalyzer analyzer( + recordingLookup, + replayLookup, + recordingHandlerLookup, + localTimerLookup, + remoteTimerLookup, + searchTimerLookup); + + RecordingMutationPlanner planner; + RecordingTrashExecutionGate gate(analyzer, planner); + RecordingTrashExecutor executor(gate); + + RecordingMutationRevision expectedRevision; + expectedRevision.recordingFile = recordingFile; + expectedRevision.recordingsState = recordingsState; + expectedRevision.timersState = timersState; + + const RecordingTrashExecutorResult result = + executor.executeNormalCase(recordingFile, expectedRevision, policy); + + switch (result.status) { + case RecordingTrashExecutorStatus::Conflict: + reply.httpReturn(409, result.message); + return; + case RecordingTrashExecutorStatus::Blocked: + reply.httpReturn(423, result.message); + return; + case RecordingTrashExecutorStatus::NotFound: + reply.httpReturn(404, result.message); + return; + case RecordingTrashExecutorStatus::Failed: + reply.httpReturn(500, result.message); + return; + case RecordingTrashExecutorStatus::Trashed: + break; + } + + reply.addHeader("Content-Type", "application/json; charset=utf-8"); + + cxxtools::JsonSerializer serializer(out); + serializer.serialize( + std::string(RecordingTrashExecutorStatusName(result.status)), + "status"); + serializer.serialize(result.recordingFile, "recording_file"); + serializer.serialize(result.deletedRecordingFile, "deleted_recording_file"); + serializer.serialize(result.message, "message"); + serializer.finish(); +} From 98437ffd18e3aef36fa60ba70c36f12104ea8b86 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 07:30:44 +0200 Subject: [PATCH 044/120] Register recording trash service header --- serverthread.h | 1 + 1 file changed, 1 insertion(+) diff --git a/serverthread.h b/serverthread.h index ef9e712..4fcadb7 100644 --- a/serverthread.h +++ b/serverthread.h @@ -18,6 +18,7 @@ #include "recordings.h" #include "recordingpreview.h" #include "recordingvalidate.h" +#include "recordingtrash.h" #include "remote.h" #include "timers.h" #include "changestate.h" From 09424f7679241fb4f4a8066e97b4facff819a92e Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 07:32:25 +0200 Subject: [PATCH 045/120] Register recording trash HTTP route --- serverthread.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/serverthread.cpp b/serverthread.cpp index 64da038..dad477c 100644 --- a/serverthread.cpp +++ b/serverthread.cpp @@ -42,6 +42,7 @@ void cServerThread::Action(void) RecordingsService recordingsService; RecordingTrashPreviewService recordingTrashPreviewService; RecordingTrashValidateService recordingTrashValidateService; + RecordingTrashService recordingTrashService; RemoteService remoteService; TimersService timersService; ChangeStateService changeStateService; @@ -63,6 +64,7 @@ void cServerThread::Action(void) RestfulService* recordingsMarks = new RestfulService("/recordings/marks", true, 1, recordings); RestfulService* recordingTrashPreview = new RestfulService("/recordings/trash/preview", true, 1, recordings); RestfulService* recordingTrashValidate = new RestfulService("/recordings/trash/validate", true, 1, recordings); + RestfulService* recordingTrash = new RestfulService("/recordings/trash", true, 1, recordings); RestfulService* remote = new RestfulService("/remote", true, 1); RestfulService* timers = new RestfulService("/timers", true, 1); RestfulService* changeState = new RestfulService("/change-state", true, 1); @@ -85,6 +87,7 @@ void cServerThread::Action(void) services->appendService(recordingsMarks); services->appendService(recordingTrashPreview); services->appendService(recordingTrashValidate); + services->appendService(recordingTrash); services->appendService(remote); services->appendService(timers); services->appendService(changeState); @@ -100,6 +103,7 @@ void cServerThread::Action(void) server->addService(std::move(*events->Regex()), eventsService); server->addService(std::move(*recordingTrashPreview->Regex()), recordingTrashPreviewService); server->addService(std::move(*recordingTrashValidate->Regex()), recordingTrashValidateService); + server->addService(std::move(*recordingTrash->Regex()), recordingTrashService); server->addService(std::move(*recordings->Regex()), recordingsService); server->addService(std::move(*remote->Regex()), remoteService); server->addService(std::move(*timers->Regex()), timersService); From 24cbc91a3ddcaff1fc0a8652dd20640027f877ee Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 07:33:57 +0200 Subject: [PATCH 046/120] Build recording trash HTTP service --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index abfd8e8..29ad1c6 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ LIBS += $(shell pkg-config --libs Magick++) CXXFLAGS += -DUSE_LIBMAGICKPLUSPLUS endif -OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingpreflight.o recordingpreview.o recordingexecution.o recordingvalidate.o recordingtrashexecutor.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o +OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingpreflight.o recordingpreview.o recordingexecution.o recordingvalidate.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o CFGS = API.html all: $(SOFILE) i18n @@ -55,8 +55,8 @@ $(DEPFILE): Makefile PODIR = po I18Npo = $(wildcard $(PODIR)/*.po) -I18Nmo = $(addsuffix .mo, $(foreach file, $(I18Npo), $(basename $(file)))) -I18Nmsgs = $(addprefix $(DESTDIR)$(LOCDIR)/, $(addsuffix /LC_MESSAGES/vdr-$(PLUGIN).mo, $(notdir $(foreach file, $(I18Npo), $(basename $(file)))))) +I18Nmo = $(addsuffix .mo, $(foreach file,$(I18Npo),$(basename $(file)))) +I18Nmsgs = $(addprefix $(DESTDIR)$(LOCDIR)/,$(addsuffix /LC_MESSAGES/vdr-$(PLUGIN).mo,$(notdir $(foreach file,$(I18Npo),$(basename $(file)))))) I18Npot = $(PODIR)/$(PLUGIN).pot %.mo: %.po From 845b24f886a17aad51466de7cf549503695e9658 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 07:35:10 +0200 Subject: [PATCH 047/120] Keep recording trash Makefile wiring minimal --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 29ad1c6..58b525e 100644 --- a/Makefile +++ b/Makefile @@ -55,8 +55,8 @@ $(DEPFILE): Makefile PODIR = po I18Npo = $(wildcard $(PODIR)/*.po) -I18Nmo = $(addsuffix .mo, $(foreach file,$(I18Npo),$(basename $(file)))) -I18Nmsgs = $(addprefix $(DESTDIR)$(LOCDIR)/,$(addsuffix /LC_MESSAGES/vdr-$(PLUGIN).mo,$(notdir $(foreach file,$(I18Npo),$(basename $(file)))))) +I18Nmo = $(addsuffix .mo, $(foreach file, $(I18Npo), $(basename $(file)))) +I18Nmsgs = $(addprefix $(DESTDIR)$(LOCDIR)/, $(addsuffix /LC_MESSAGES/vdr-$(PLUGIN).mo, $(notdir $(foreach file, $(I18Npo), $(basename $(file)))))) I18Npot = $(PODIR)/$(PLUGIN).pot %.mo: %.po From 21b3e94d928d644239d641b55657de3235e6c30e Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 07:46:40 +0200 Subject: [PATCH 048/120] Model idempotent recording trash results --- recordingtrashexecutor.h | 1 + 1 file changed, 1 insertion(+) diff --git a/recordingtrashexecutor.h b/recordingtrashexecutor.h index 712fff7..e07f62e 100644 --- a/recordingtrashexecutor.h +++ b/recordingtrashexecutor.h @@ -8,6 +8,7 @@ enum class RecordingTrashExecutorStatus { Trashed, + AlreadyTrashed, Conflict, Blocked, NotFound, From b184467a791bc027ecf541e8dbf5fe3908145af9 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 07:48:11 +0200 Subject: [PATCH 049/120] Harden recording trash postconditions and retries --- recordingtrashexecutor.cpp | 54 +++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/recordingtrashexecutor.cpp b/recordingtrashexecutor.cpp index f6603c7..3a3e921 100644 --- a/recordingtrashexecutor.cpp +++ b/recordingtrashexecutor.cpp @@ -3,6 +3,7 @@ #include "changestatetracker.h" #include +#include #include #include @@ -23,6 +24,11 @@ std::string deletedFileName(const std::string& recordingFile) return std::string(); } +bool pathExists(const std::string& path) +{ + return !path.empty() && access(path.c_str(), F_OK) == 0; +} + } RecordingTrashExecutor::RecordingTrashExecutor(const RecordingTrashExecutionGate& gate) @@ -37,6 +43,22 @@ RecordingTrashExecutorResult RecordingTrashExecutor::executeNormalCase( { RecordingTrashExecutorResult result; result.recordingFile = recordingFile; + + const std::string targetFile = deletedFileName(recordingFile); + if (targetFile.empty()) { + result.status = RecordingTrashExecutorStatus::Blocked; + result.message = "Only active .rec recordings can be moved to the VDR trash."; + return result; + } + + result.deletedRecordingFile = targetFile; + + if (!pathExists(recordingFile) && pathExists(targetFile)) { + result.status = RecordingTrashExecutorStatus::AlreadyTrashed; + result.message = "Recording is already present in the VDR trash."; + return result; + } + result.gate = gate.validate(recordingFile, expectedRevision, policy); if (result.gate.status == RecordingTrashExecutionGateStatus::Conflict) { @@ -51,20 +73,25 @@ RecordingTrashExecutorResult RecordingTrashExecutor::executeNormalCase( return result; } - const std::string targetFile = deletedFileName(recordingFile); - if (targetFile.empty()) { - result.status = RecordingTrashExecutorStatus::Blocked; - result.message = "Only active .rec recordings can be moved to the VDR trash."; - return result; - } - LOCK_TIMERS_WRITE; LOCK_RECORDINGS_WRITE; cRecording* recording = Recordings->GetByName(recordingFile.c_str()); if (!recording) { - result.status = RecordingTrashExecutorStatus::NotFound; - result.message = "Recording disappeared before execution."; + if (!pathExists(recordingFile) && pathExists(targetFile)) { + result.status = RecordingTrashExecutorStatus::AlreadyTrashed; + result.message = "Recording is already present in the VDR trash."; + } + else { + result.status = RecordingTrashExecutorStatus::NotFound; + result.message = "Recording disappeared before execution."; + } + return result; + } + + if (pathExists(targetFile)) { + result.status = RecordingTrashExecutorStatus::Conflict; + result.message = "The VDR trash target already exists."; return result; } @@ -101,6 +128,12 @@ RecordingTrashExecutorResult RecordingTrashExecutor::executeNormalCase( return result; } + if (pathExists(recordingFile) || !pathExists(targetFile)) { + result.status = RecordingTrashExecutorStatus::Failed; + result.message = "Recording trash postcondition verification failed."; + return result; + } + { LOCK_DELETEDRECORDINGS_WRITE; Recordings->Del(recording, false); @@ -111,7 +144,6 @@ RecordingTrashExecutorResult RecordingTrashExecutor::executeNormalCase( StateChangeTracker::UpdateRecordings(); result.status = RecordingTrashExecutorStatus::Trashed; - result.deletedRecordingFile = targetFile; result.message = "Recording moved to the VDR trash."; return result; } @@ -121,6 +153,8 @@ const char* RecordingTrashExecutorStatusName(RecordingTrashExecutorStatus status switch (status) { case RecordingTrashExecutorStatus::Trashed: return "trashed"; + case RecordingTrashExecutorStatus::AlreadyTrashed: + return "already-trashed"; case RecordingTrashExecutorStatus::Conflict: return "conflict"; case RecordingTrashExecutorStatus::Blocked: From b9d6f34b444ad14896fae6c1541dbb4d5e86ae2e Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 07:49:11 +0200 Subject: [PATCH 050/120] Return idempotent recording trash success --- recordingtrash.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/recordingtrash.cpp b/recordingtrash.cpp index 12a1319..9b02cd1 100644 --- a/recordingtrash.cpp +++ b/recordingtrash.cpp @@ -115,6 +115,7 @@ void RecordingTrashResponder::reply( reply.httpReturn(500, result.message); return; case RecordingTrashExecutorStatus::Trashed: + case RecordingTrashExecutorStatus::AlreadyTrashed: break; } From ba6ff8b2e870502d504d34388776ee811c7eba4f Mon Sep 17 00:00:00 2001 From: Holger Schvestka Date: Tue, 14 Jul 2026 08:51:52 +0200 Subject: [PATCH 051/120] Handle local recording timer marker in trash analysis --- recordinganalysis.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/recordinganalysis.cpp b/recordinganalysis.cpp index 9867ba5..97214ee 100644 --- a/recordinganalysis.cpp +++ b/recordinganalysis.cpp @@ -64,7 +64,7 @@ bool parseRemoteTimerId( if (separator == std::string::npos || separator == 0 || separator + 1 >= value.size()) return false; - if (!parsePositiveInteger(value.substr(0, separator), timerId) || timerId <= 0) + if (!parsePositiveInteger(value.substr(0, separator), timerId)) return false; remote = value.substr(separator + 1); @@ -198,6 +198,11 @@ RecordingRemoteTimerLookupResult VdrRecordingRemoteTimerLookup::findActive( if (!parseRemoteTimerId(timerId, result.timerId, result.remote)) return result; + if (result.timerId == 0) { + result.known = true; + return result; + } + LOCK_TIMERS_READ; const cTimer* timer = Timers->GetById(result.timerId, result.remote.c_str()); if (!timer) @@ -236,6 +241,11 @@ RecordingSearchTimerLookupResult VdrRecordingSearchTimerLookup::findOrigin( if (!parseRemoteTimerId(timerId, id, remote)) return result; + if (id == 0) { + result.known = true; + return result; + } + timer = Timers->GetById(id, remote.c_str()); if (!timer) return result; From c51cbbdcbf35e3b5e5a67ae88a052f06b0253698 Mon Sep 17 00:00:00 2001 From: Holger Schvestka Date: Tue, 14 Jul 2026 09:13:28 +0200 Subject: [PATCH 052/120] Resolve recording timer markers against local timers first --- recordinganalysis.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/recordinganalysis.cpp b/recordinganalysis.cpp index 97214ee..02f32d5 100644 --- a/recordinganalysis.cpp +++ b/recordinganalysis.cpp @@ -204,7 +204,11 @@ RecordingRemoteTimerLookupResult VdrRecordingRemoteTimerLookup::findActive( } LOCK_TIMERS_READ; - const cTimer* timer = Timers->GetById(result.timerId, result.remote.c_str()); + + const cTimer* timer = Timers->GetById(result.timerId, nullptr); + if (!timer) + timer = Timers->GetById(result.timerId, result.remote.c_str()); + if (!timer) return result; @@ -246,7 +250,10 @@ RecordingSearchTimerLookupResult VdrRecordingSearchTimerLookup::findOrigin( return result; } - timer = Timers->GetById(id, remote.c_str()); + timer = Timers->GetById(id, nullptr); + if (!timer) + timer = Timers->GetById(id, remote.c_str()); + if (!timer) return result; } From 089bde181c1abe7166673e777113694215028077 Mon Sep 17 00:00:00 2001 From: Holger Schvestka Date: Tue, 14 Jul 2026 09:24:59 +0200 Subject: [PATCH 053/120] Do not classify local recording timers as remote --- recordinganalysis.cpp | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/recordinganalysis.cpp b/recordinganalysis.cpp index 02f32d5..4406091 100644 --- a/recordinganalysis.cpp +++ b/recordinganalysis.cpp @@ -205,15 +205,23 @@ RecordingRemoteTimerLookupResult VdrRecordingRemoteTimerLookup::findActive( LOCK_TIMERS_READ; - const cTimer* timer = Timers->GetById(result.timerId, nullptr); - if (!timer) - timer = Timers->GetById(result.timerId, result.remote.c_str()); + const cTimer* localTimer = Timers->GetById(result.timerId, nullptr); + if (localTimer) { + result.known = true; + result.active = false; + result.remote.clear(); + return result; + } + + const cTimer* remoteTimer = + Timers->GetById(result.timerId, result.remote.c_str()); - if (!timer) + if (!remoteTimer) return result; result.known = true; - result.active = timer->HasFlags(tfActive) || timer->Recording(); + result.active = + remoteTimer->HasFlags(tfActive) || remoteTimer->Recording(); return result; } From d244397f637e9e36547872ee7be40cb379ecd735 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 10:13:07 +0200 Subject: [PATCH 054/120] Document safe native recording trash API --- RECORDING_TRASH_API.md | 193 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 RECORDING_TRASH_API.md diff --git a/RECORDING_TRASH_API.md b/RECORDING_TRASH_API.md new file mode 100644 index 0000000..b5efbba --- /dev/null +++ b/RECORDING_TRASH_API.md @@ -0,0 +1,193 @@ +# Safe Native Recording Trash API + +This document describes the recording trash workflow implemented by the RESTfulAPI plugin. + +The implementation deliberately follows native VDR behavior. It does not introduce a separate long-lived trash store and it does not automatically stop playback, stop recordings, delete timers, or cancel VDR handler operations. + +## Native VDR semantics + +A successful trash operation normally changes the recording directory from: + +```text +/path/to/recording.rec +``` + +to: + +```text +/path/to/recording.del +``` + +VDR may permanently remove the `.del` directory later during its regular cleanup. Therefore, `already-trashed` is only a temporary idempotent state while the native `.del` directory still exists. Once both `.rec` and `.del` are gone, the recording is no longer available. + +## Workflow + +Recording trash uses a three-step optimistic-locking workflow: + +1. Preview the operation and obtain state fingerprints. +2. Optionally validate that the fingerprints are still current. +3. Execute the operation with the confirmed fingerprints. + +Preview and validation are read-only. They do not modify the recording, timers, playback state, or VDR recording lists. + +## Preview + +```http +POST /recordings/trash/preview.json +Content-Type: application/json +``` + +Request: + +```json +{ + "file": "/srv/vdr/video/Example/2026-07-14.08.53.3-0.rec" +} +``` + +Executable response: + +```json +{ + "executable": true, + "recording_file": "/srv/vdr/video/Example/2026-07-14.08.53.3-0.rec", + "constraints": [], + "blockers": [], + "warnings": [], + "steps": [ + "trash-recording", + "refresh-recordings", + "notify-change" + ], + "revision_recording_file": "/srv/vdr/video/Example/2026-07-14.08.53.3-0.rec", + "revision_recordings_state": 123456789, + "revision_timers_state": 987654321 +} +``` + +Blocked response example: + +```json +{ + "executable": false, + "recording_file": "/srv/vdr/video/Example/2026-07-14.08.53.3-0.rec", + "constraints": [ + "local-timer-active" + ], + "blockers": [ + "local-timer-active" + ], + "warnings": [], + "steps": [], + "revision_recording_file": "/srv/vdr/video/Example/2026-07-14.08.53.3-0.rec", + "revision_recordings_state": 123456789, + "revision_timers_state": 987654321 +} +``` + +## Validate + +```http +POST /recordings/trash/validate.json +Content-Type: application/json +``` + +Request: + +```json +{ + "file": "/srv/vdr/video/Example/2026-07-14.08.53.3-0.rec", + "revision_recordings_state": "123456789", + "revision_timers_state": "987654321" +} +``` + +Validation status values: + +- `ready`: the recording is still executable with this revision. +- `conflict`: recording, replay, handler, or timer state changed after preview. +- `blocked`: the current state or policy does not allow execution. + +## Execute + +```http +POST /recordings/trash.json +Content-Type: application/json +``` + +Request: + +```json +{ + "file": "/srv/vdr/video/Example/2026-07-14.08.53.3-0.rec", + "revision_recordings_state": "123456789", + "revision_timers_state": "987654321" +} +``` + +Successful response: + +```json +{ + "status": "trashed", + "recording_file": "/srv/vdr/video/Example/2026-07-14.08.53.3-0.rec", + "deleted_recording_file": "/srv/vdr/video/Example/2026-07-14.08.53.3-0.del", + "message": "Recording moved to the VDR trash." +} +``` + +Immediate idempotent retry while `.del` still exists: + +```json +{ + "status": "already-trashed", + "recording_file": "/srv/vdr/video/Example/2026-07-14.08.53.3-0.rec", + "deleted_recording_file": "/srv/vdr/video/Example/2026-07-14.08.53.3-0.del", + "message": "Recording is already present in the VDR trash." +} +``` + +## Protected states + +The operation is blocked when safety cannot be established or VDR is actively using the recording: + +- `replay-active` +- `recording-handler-busy` +- `local-timer-active` +- `remote-timer-active` +- `unknown-recording-handler-state` +- `unknown-local-timer-state` +- `unknown-remote-timer-state` +- `unknown-search-timer-state` + +The execution endpoint does not automatically resolve these states. + +## HTTP status codes + +| Status | Meaning | +|---|---| +| `200` | The recording was trashed or is already in VDR's native deleted state. | +| `400` | The recording file or required revision values are missing or invalid. | +| `404` | The recording is no longer present. | +| `409` | Recording or timer state changed after preview. | +| `423` | The operation is blocked by the current VDR state or policy. | +| `500` | The native VDR mutation or postcondition verification failed. | +| `501` | The requested HTTP method is not supported. | + +## Verified integration behavior + +The following cases were verified against a running VDR installation: + +- normal completed recording: `200 trashed` +- immediate identical retry: `200 already-trashed` +- active local recording: `423`, recording and timer remain active +- active replay: `423`, recording remains present and replay is not stopped +- stale revision: `409` +- missing revision: `400` +- unsupported method: `501` +- preview: no filesystem mutation +- successful execution: `.rec` becomes `.del` and VDR remains active + +## Design boundary + +This API models native VDR deletion. It is not a separate VDR-Suite recycle bin and does not guarantee a restore window. VDR controls when native `.del` recordings are permanently cleaned up. From d1bd3b12a2f9832e35fdc901a80dcb0135cc02ce Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 21:39:33 +0200 Subject: [PATCH 055/120] Add recording move mutation plan foundation --- recordingmutation.h | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/recordingmutation.h b/recordingmutation.h index 1a20bae..8c0894e 100644 --- a/recordingmutation.h +++ b/recordingmutation.h @@ -24,7 +24,11 @@ enum class RecordingConstraint UnknownRecordingHandlerState, UnknownLocalTimerState, UnknownRemoteTimerState, - UnknownSearchTimerState + UnknownSearchTimerState, + MoveTargetMissing, + MoveTargetInvalid, + MoveTargetSameAsSource, + MoveTargetExists }; enum class RecordingMutationStep @@ -36,6 +40,7 @@ enum class RecordingMutationStep TrashRecording, RestoreRecording, PurgeRecording, + MoveRecording, RefreshRecordings, NotifyChange }; @@ -43,6 +48,7 @@ enum class RecordingMutationStep struct RecordingMutationRevision { std::string recordingFile; + std::string targetFile; long long recordingsState = 0; long long timersState = 0; }; @@ -51,6 +57,7 @@ struct RecordingMutationAnalysis { RecordingMutationType type = RecordingMutationType::Trash; std::string recordingFile; + std::string targetFile; std::vector constraints; std::vector warnings; RecordingMutationRevision revision; @@ -82,6 +89,10 @@ class RecordingMutationPlanner RecordingMutationPlan buildTrashPlan( const RecordingMutationAnalysis& analysis, const RecordingMutationPolicy& policy) const; + + RecordingMutationPlan buildMovePlan( + const RecordingMutationAnalysis& analysis, + const RecordingMutationPolicy& policy) const; }; const char* RecordingConstraintName(RecordingConstraint constraint); From 1d69dd143cf96e66e823e206e17182a6fcad2ec5 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 21:40:50 +0200 Subject: [PATCH 056/120] Implement recording move mutation planning --- recordingmutation.cpp | 75 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 63 insertions(+), 12 deletions(-) diff --git a/recordingmutation.cpp b/recordingmutation.cpp index 6e766c5..5bc7432 100644 --- a/recordingmutation.cpp +++ b/recordingmutation.cpp @@ -21,20 +21,11 @@ void addStep(RecordingMutationPlan& plan, RecordingMutationStep step) plan.steps.push_back(step); } -} - -RecordingMutationPlan RecordingMutationPlanner::buildTrashPlan( +void addCommonSafetyConstraints( + RecordingMutationPlan& plan, const RecordingMutationAnalysis& analysis, - const RecordingMutationPolicy& policy) const + const RecordingMutationPolicy& policy) { - RecordingMutationPlan plan; - plan.type = RecordingMutationType::Trash; - plan.warnings = analysis.warnings; - plan.expectedRevision = analysis.revision; - - if (analysis.type != RecordingMutationType::Trash) - addBlocker(plan, RecordingConstraint::RecordingMissing); - if (analysis.hasConstraint(RecordingConstraint::RecordingMissing)) addBlocker(plan, RecordingConstraint::RecordingMissing); @@ -80,6 +71,23 @@ RecordingMutationPlan RecordingMutationPlanner::buildTrashPlan( if (analysis.hasConstraint(RecordingConstraint::SearchTimerRecording)) plan.warnings.push_back("EPGSearch may classify an interrupted recording as incomplete."); +} + +} + +RecordingMutationPlan RecordingMutationPlanner::buildTrashPlan( + const RecordingMutationAnalysis& analysis, + const RecordingMutationPolicy& policy) const +{ + RecordingMutationPlan plan; + plan.type = RecordingMutationType::Trash; + plan.warnings = analysis.warnings; + plan.expectedRevision = analysis.revision; + + if (analysis.type != RecordingMutationType::Trash) + addBlocker(plan, RecordingConstraint::RecordingMissing); + + addCommonSafetyConstraints(plan, analysis, policy); if (plan.blockers.empty()) { addStep(plan, RecordingMutationStep::TrashRecording); @@ -91,6 +99,44 @@ RecordingMutationPlan RecordingMutationPlanner::buildTrashPlan( return plan; } +RecordingMutationPlan RecordingMutationPlanner::buildMovePlan( + const RecordingMutationAnalysis& analysis, + const RecordingMutationPolicy& policy) const +{ + RecordingMutationPlan plan; + plan.type = RecordingMutationType::Move; + plan.warnings = analysis.warnings; + plan.expectedRevision = analysis.revision; + + if (analysis.type != RecordingMutationType::Move) + addBlocker(plan, RecordingConstraint::RecordingMissing); + + addCommonSafetyConstraints(plan, analysis, policy); + + if (analysis.targetFile.empty() || + analysis.hasConstraint(RecordingConstraint::MoveTargetMissing)) + addBlocker(plan, RecordingConstraint::MoveTargetMissing); + + if (analysis.hasConstraint(RecordingConstraint::MoveTargetInvalid)) + addBlocker(plan, RecordingConstraint::MoveTargetInvalid); + + if (analysis.recordingFile == analysis.targetFile || + analysis.hasConstraint(RecordingConstraint::MoveTargetSameAsSource)) + addBlocker(plan, RecordingConstraint::MoveTargetSameAsSource); + + if (analysis.hasConstraint(RecordingConstraint::MoveTargetExists)) + addBlocker(plan, RecordingConstraint::MoveTargetExists); + + if (plan.blockers.empty()) { + addStep(plan, RecordingMutationStep::MoveRecording); + addStep(plan, RecordingMutationStep::RefreshRecordings); + addStep(plan, RecordingMutationStep::NotifyChange); + plan.executable = true; + } + + return plan; +} + const char* RecordingConstraintName(RecordingConstraint constraint) { switch (constraint) { @@ -104,6 +150,10 @@ const char* RecordingConstraintName(RecordingConstraint constraint) case RecordingConstraint::UnknownLocalTimerState: return "unknown-local-timer-state"; case RecordingConstraint::UnknownRemoteTimerState: return "unknown-remote-timer-state"; case RecordingConstraint::UnknownSearchTimerState: return "unknown-searchtimer-state"; + case RecordingConstraint::MoveTargetMissing: return "move-target-missing"; + case RecordingConstraint::MoveTargetInvalid: return "move-target-invalid"; + case RecordingConstraint::MoveTargetSameAsSource: return "move-target-same-as-source"; + case RecordingConstraint::MoveTargetExists: return "move-target-exists"; } return "unknown"; } @@ -118,6 +168,7 @@ const char* RecordingMutationStepName(RecordingMutationStep step) case RecordingMutationStep::TrashRecording: return "trash-recording"; case RecordingMutationStep::RestoreRecording: return "restore-recording"; case RecordingMutationStep::PurgeRecording: return "purge-recording"; + case RecordingMutationStep::MoveRecording: return "move-recording"; case RecordingMutationStep::RefreshRecordings: return "refresh-recordings"; case RecordingMutationStep::NotifyChange: return "notify-change"; } From 4f203a6a8ea9767a009483bf5e5a1bd4e373d2c8 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 21:41:37 +0200 Subject: [PATCH 057/120] Test recording move mutation planning --- tests/test_recording_move_plan.cpp | 121 +++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 tests/test_recording_move_plan.cpp diff --git a/tests/test_recording_move_plan.cpp b/tests/test_recording_move_plan.cpp new file mode 100644 index 0000000..b5cfd99 --- /dev/null +++ b/tests/test_recording_move_plan.cpp @@ -0,0 +1,121 @@ +#include "../recordingmutation.h" + +#include + +namespace { + +RecordingMutationAnalysis baseMoveAnalysis() +{ + RecordingMutationAnalysis analysis; + analysis.type = RecordingMutationType::Move; + analysis.recordingFile = "/srv/vdr/video/Source/2026-07-14.20.15.1-0.rec"; + analysis.targetFile = "/srv/vdr/video/Target/2026-07-14.20.15.1-0.rec"; + analysis.revision.recordingFile = analysis.recordingFile; + analysis.revision.targetFile = analysis.targetFile; + analysis.revision.recordingsState = 100; + analysis.revision.timersState = 200; + return analysis; +} + +bool hasBlocker( + const RecordingMutationPlan& plan, + RecordingConstraint constraint) +{ + for (const RecordingConstraint blocker : plan.blockers) { + if (blocker == constraint) + return true; + } + return false; +} + +bool hasStep( + const RecordingMutationPlan& plan, + RecordingMutationStep step) +{ + for (const RecordingMutationStep candidate : plan.steps) { + if (candidate == step) + return true; + } + return false; +} + +} + +int main() +{ + RecordingMutationPlanner planner; + RecordingMutationPolicy policy; + + const RecordingMutationPlan ready = + planner.buildMovePlan(baseMoveAnalysis(), policy); + + assert(ready.executable); + assert(ready.blockers.empty()); + assert(hasStep(ready, RecordingMutationStep::MoveRecording)); + assert(hasStep(ready, RecordingMutationStep::RefreshRecordings)); + assert(hasStep(ready, RecordingMutationStep::NotifyChange)); + assert(ready.expectedRevision.recordingsState == 100); + assert(ready.expectedRevision.timersState == 200); + assert( + ready.expectedRevision.targetFile == + "/srv/vdr/video/Target/2026-07-14.20.15.1-0.rec"); + + RecordingMutationAnalysis missingTarget = baseMoveAnalysis(); + missingTarget.targetFile.clear(); + const RecordingMutationPlan missingTargetPlan = + planner.buildMovePlan(missingTarget, policy); + assert(!missingTargetPlan.executable); + assert(hasBlocker( + missingTargetPlan, + RecordingConstraint::MoveTargetMissing)); + + RecordingMutationAnalysis sameTarget = baseMoveAnalysis(); + sameTarget.targetFile = sameTarget.recordingFile; + const RecordingMutationPlan sameTargetPlan = + planner.buildMovePlan(sameTarget, policy); + assert(!sameTargetPlan.executable); + assert(hasBlocker( + sameTargetPlan, + RecordingConstraint::MoveTargetSameAsSource)); + + RecordingMutationAnalysis targetExists = baseMoveAnalysis(); + targetExists.constraints.push_back( + RecordingConstraint::MoveTargetExists); + const RecordingMutationPlan targetExistsPlan = + planner.buildMovePlan(targetExists, policy); + assert(!targetExistsPlan.executable); + assert(hasBlocker( + targetExistsPlan, + RecordingConstraint::MoveTargetExists)); + + RecordingMutationAnalysis activeRecording = baseMoveAnalysis(); + activeRecording.constraints.push_back( + RecordingConstraint::LocalTimerActive); + const RecordingMutationPlan activeRecordingPlan = + planner.buildMovePlan(activeRecording, policy); + assert(!activeRecordingPlan.executable); + assert(hasBlocker( + activeRecordingPlan, + RecordingConstraint::LocalTimerActive)); + + RecordingMutationAnalysis replaying = baseMoveAnalysis(); + replaying.constraints.push_back( + RecordingConstraint::ReplayActive); + const RecordingMutationPlan replayingPlan = + planner.buildMovePlan(replaying, policy); + assert(!replayingPlan.executable); + assert(hasBlocker( + replayingPlan, + RecordingConstraint::ReplayActive)); + + assert( + std::string(RecordingConstraintName( + RecordingConstraint::MoveTargetInvalid)) == + "move-target-invalid"); + assert( + std::string(RecordingMutationStepName( + RecordingMutationStep::MoveRecording)) == + "move-recording"); + + return 0; +} From c8c8b0a487f530ee3be2a47ac87929733011b57e Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 21:42:47 +0200 Subject: [PATCH 058/120] Wire recording move planner test --- Makefile | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 58b525e..85a086f 100644 --- a/Makefile +++ b/Makefile @@ -72,9 +72,16 @@ $(I18Npot): $(wildcard *.cpp) $(I18Nmsgs): $(DESTDIR)$(LOCDIR)/%/LC_MESSAGES/vdr-$(PLUGIN).mo: $(PODIR)/%.mo install -D -m644 $< $@ -.PHONY: i18n +.PHONY: i18n test-recording-move-plan i18n: $(I18Nmo) $(I18Npot) +test-recording-move-plan: + $(CXX) -std=c++17 -Wall -Wextra \ + recordingmutation.cpp \ + tests/test_recording_move_plan.cpp \ + -o /tmp/test_recording_move_plan + /tmp/test_recording_move_plan + install-i18n: $(I18Nmsgs) $(SOFILE): $(OBJS) From 1fcb1922ba36a7853aa02084e1a13fefbf22a60c Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 21:54:21 +0200 Subject: [PATCH 059/120] Add recording move analyzer contract --- recordinganalysis.h | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/recordinganalysis.h b/recordinganalysis.h index 977b4fe..9f7eb9f 100644 --- a/recordinganalysis.h +++ b/recordinganalysis.h @@ -138,4 +138,28 @@ class RecordingTrashAnalyzer const IRecordingSearchTimerLookup& searchTimerLookup; }; +class RecordingMoveAnalyzer +{ +public: + RecordingMoveAnalyzer( + const IRecordingLookup& recordingLookup, + const IRecordingReplayLookup& replayLookup, + const IRecordingHandlerLookup& recordingHandlerLookup, + const IRecordingLocalTimerLookup& localTimerLookup, + const IRecordingRemoteTimerLookup& remoteTimerLookup, + const IRecordingSearchTimerLookup& searchTimerLookup); + + RecordingMutationAnalysis analyze( + const std::string& recordingFile, + const std::string& targetFile) const; + +private: + const IRecordingLookup& recordingLookup; + const IRecordingReplayLookup& replayLookup; + const IRecordingHandlerLookup& recordingHandlerLookup; + const IRecordingLocalTimerLookup& localTimerLookup; + const IRecordingRemoteTimerLookup& remoteTimerLookup; + const IRecordingSearchTimerLookup& searchTimerLookup; +}; + #endif From ea1391783f8b23d3ad6c05a5754a02aaa7d9f0a5 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 21:55:24 +0200 Subject: [PATCH 060/120] Implement recording move analysis --- recordingmoveanalysis.cpp | 188 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 recordingmoveanalysis.cpp diff --git a/recordingmoveanalysis.cpp b/recordingmoveanalysis.cpp new file mode 100644 index 0000000..e77cd0a --- /dev/null +++ b/recordingmoveanalysis.cpp @@ -0,0 +1,188 @@ +#include "recordinganalysis.h" + +#include +#include +#include + +namespace { + +long long fingerprint(const std::string& value) +{ + std::uint64_t hash = 1469598103934665603ULL; + for (unsigned char character : value) { + hash ^= character; + hash *= 1099511628211ULL; + } + return static_cast(hash & 0x7FFFFFFFFFFFFFFFULL); +} + +void appendFileState( + std::ostringstream& state, + const std::string& recordingFile, + bool found) +{ + state << recordingFile << '|'; + state << (found ? "found" : "missing"); + + struct stat fileState; + if (found && stat(recordingFile.c_str(), &fileState) == 0) { + state << '|' << static_cast(fileState.st_dev); + state << '|' << static_cast(fileState.st_ino); + state << '|' << static_cast(fileState.st_mtime); + state << '|' << static_cast(fileState.st_ctime); + } + else if (found) { + state << "|stat-unavailable"; + } +} + +long long moveRecordingFingerprint( + const std::string& recordingFile, + bool recordingFound, + const std::string& targetFile, + bool targetFound) +{ + std::ostringstream state; + state << "source="; + appendFileState(state, recordingFile, recordingFound); + state << "|target="; + appendFileState(state, targetFile, targetFound); + return fingerprint(state.str()); +} + +long long timerFingerprint( + bool replaying, + const RecordingHandlerLookupResult& handlerUsage, + const RecordingLocalTimerLookupResult& localTimer, + const RecordingRemoteTimerLookupResult& remoteTimer, + const RecordingSearchTimerLookupResult& searchTimer) +{ + std::ostringstream state; + state << "replay=" << replaying; + state << "|handler-known=" << handlerUsage.known; + state << "|handler-busy=" << handlerUsage.busy; + state << "|local-known=" << localTimer.known; + state << "|local-active=" << localTimer.active; + state << "|remote-known=" << remoteTimer.known; + state << "|remote-active=" << remoteTimer.active; + state << "|remote-id=" << remoteTimer.timerId; + state << "|remote=" << remoteTimer.remote; + state << "|search-known=" << searchTimer.known; + state << "|search-recording=" << searchTimer.searchTimerRecording; + state << "|search-id=" << searchTimer.searchTimerId; + return fingerprint(state.str()); +} + +bool isAbsoluteRecordingTarget(const std::string& targetFile) +{ + return targetFile.size() > 1 && + targetFile.front() == '/' && + targetFile.back() != '/'; +} + +} + +RecordingMoveAnalyzer::RecordingMoveAnalyzer( + const IRecordingLookup& recordingLookup, + const IRecordingReplayLookup& replayLookup, + const IRecordingHandlerLookup& recordingHandlerLookup, + const IRecordingLocalTimerLookup& localTimerLookup, + const IRecordingRemoteTimerLookup& remoteTimerLookup, + const IRecordingSearchTimerLookup& searchTimerLookup) + : recordingLookup(recordingLookup), + replayLookup(replayLookup), + recordingHandlerLookup(recordingHandlerLookup), + localTimerLookup(localTimerLookup), + remoteTimerLookup(remoteTimerLookup), + searchTimerLookup(searchTimerLookup) +{ +} + +RecordingMutationAnalysis RecordingMoveAnalyzer::analyze( + const std::string& recordingFile, + const std::string& targetFile) const +{ + RecordingMutationAnalysis analysis; + analysis.type = RecordingMutationType::Move; + analysis.recordingFile = recordingFile; + analysis.targetFile = targetFile; + analysis.revision.recordingFile = recordingFile; + analysis.revision.targetFile = targetFile; + + const RecordingLookupResult recording = recordingLookup.find(recordingFile); + const RecordingLookupResult target = targetFile.empty() + ? RecordingLookupResult() + : recordingLookup.find(targetFile); + + analysis.revision.recordingsState = moveRecordingFingerprint( + recordingFile, + recording.found, + targetFile, + target.found); + + if (!recording.found) + analysis.constraints.push_back(RecordingConstraint::RecordingMissing); + + if (targetFile.empty()) + analysis.constraints.push_back(RecordingConstraint::MoveTargetMissing); + else if (!isAbsoluteRecordingTarget(targetFile)) + analysis.constraints.push_back(RecordingConstraint::MoveTargetInvalid); + + if (!recordingFile.empty() && recordingFile == targetFile) + analysis.constraints.push_back(RecordingConstraint::MoveTargetSameAsSource); + + if (target.found && target.recordingFile != recording.recordingFile) + analysis.constraints.push_back(RecordingConstraint::MoveTargetExists); + + if (!recording.found) + return analysis; + + analysis.recordingFile = recording.recordingFile; + analysis.revision.recordingFile = recording.recordingFile; + + const bool replaying = replayLookup.isReplaying(recording.recordingFile); + if (replaying) + analysis.constraints.push_back(RecordingConstraint::ReplayActive); + + const RecordingHandlerLookupResult handlerUsage = + recordingHandlerLookup.getUsage(recording.recordingFile); + if (!handlerUsage.known) + analysis.constraints.push_back(RecordingConstraint::UnknownRecordingHandlerState); + else if (handlerUsage.busy) + analysis.constraints.push_back(RecordingConstraint::RecordingHandlerBusy); + + const RecordingLocalTimerLookupResult localTimer = + localTimerLookup.findActive(recording.recordingFile); + if (!localTimer.known) + analysis.constraints.push_back(RecordingConstraint::UnknownLocalTimerState); + else if (localTimer.active) + analysis.constraints.push_back(RecordingConstraint::LocalTimerActive); + + const RecordingRemoteTimerLookupResult remoteTimer = + remoteTimerLookup.findActive(recording.recordingFile); + if (!remoteTimer.known) + analysis.constraints.push_back(RecordingConstraint::UnknownRemoteTimerState); + else if (remoteTimer.active) + analysis.constraints.push_back(RecordingConstraint::RemoteTimerActive); + + const RecordingSearchTimerLookupResult searchTimer = + searchTimerLookup.findOrigin(recording.recordingFile); + if (!searchTimer.known) + analysis.constraints.push_back(RecordingConstraint::UnknownSearchTimerState); + else if (searchTimer.searchTimerRecording) + analysis.constraints.push_back(RecordingConstraint::SearchTimerRecording); + + analysis.revision.recordingsState = moveRecordingFingerprint( + recording.recordingFile, + true, + targetFile, + target.found); + analysis.revision.timersState = timerFingerprint( + replaying, + handlerUsage, + localTimer, + remoteTimer, + searchTimer); + + return analysis; +} From fe2dafb3ef466f5ab9f4588dd1eff56b75772064 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 21:56:19 +0200 Subject: [PATCH 061/120] Test recording move analysis --- tests/test_recording_move_analysis.cpp | 173 +++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 tests/test_recording_move_analysis.cpp diff --git a/tests/test_recording_move_analysis.cpp b/tests/test_recording_move_analysis.cpp new file mode 100644 index 0000000..7d1a2df --- /dev/null +++ b/tests/test_recording_move_analysis.cpp @@ -0,0 +1,173 @@ +#include "../recordinganalysis.h" + +#include +#include +#include + +namespace { + +class FakeRecordingLookup : public IRecordingLookup +{ +public: + std::map results; + + RecordingLookupResult find(const std::string& recordingFile) const override + { + const auto result = results.find(recordingFile); + return result == results.end() ? RecordingLookupResult() : result->second; + } +}; + +class FakeReplayLookup : public IRecordingReplayLookup +{ +public: + bool replaying = false; + + bool isReplaying(const std::string&) const override + { + return replaying; + } +}; + +class FakeHandlerLookup : public IRecordingHandlerLookup +{ +public: + RecordingHandlerLookupResult result{true, false}; + + RecordingHandlerLookupResult getUsage(const std::string&) const override + { + return result; + } +}; + +class FakeLocalTimerLookup : public IRecordingLocalTimerLookup +{ +public: + RecordingLocalTimerLookupResult result{true, false}; + + RecordingLocalTimerLookupResult findActive(const std::string&) const override + { + return result; + } +}; + +class FakeRemoteTimerLookup : public IRecordingRemoteTimerLookup +{ +public: + RecordingRemoteTimerLookupResult result{true, false, 0, ""}; + + RecordingRemoteTimerLookupResult findActive(const std::string&) const override + { + return result; + } +}; + +class FakeSearchTimerLookup : public IRecordingSearchTimerLookup +{ +public: + RecordingSearchTimerLookupResult result{true, false, -1}; + + RecordingSearchTimerLookupResult findOrigin(const std::string&) const override + { + return result; + } +}; + +RecordingMoveAnalyzer makeAnalyzer( + const FakeRecordingLookup& recordingLookup, + const FakeReplayLookup& replayLookup, + const FakeHandlerLookup& handlerLookup, + const FakeLocalTimerLookup& localTimerLookup, + const FakeRemoteTimerLookup& remoteTimerLookup, + const FakeSearchTimerLookup& searchTimerLookup) +{ + return RecordingMoveAnalyzer( + recordingLookup, + replayLookup, + handlerLookup, + localTimerLookup, + remoteTimerLookup, + searchTimerLookup); +} + +} + +int main() +{ + const std::string source = "/srv/vdr/video/Source/2026-07-14.20.00.1-0.rec"; + const std::string target = "/srv/vdr/video/Target/2026-07-14.20.00.1-0.rec"; + + FakeRecordingLookup recordingLookup; + recordingLookup.results[source] = {true, source}; + FakeReplayLookup replayLookup; + FakeHandlerLookup handlerLookup; + FakeLocalTimerLookup localTimerLookup; + FakeRemoteTimerLookup remoteTimerLookup; + FakeSearchTimerLookup searchTimerLookup; + + RecordingMoveAnalyzer analyzer = makeAnalyzer( + recordingLookup, + replayLookup, + handlerLookup, + localTimerLookup, + remoteTimerLookup, + searchTimerLookup); + + const RecordingMutationAnalysis ready = analyzer.analyze(source, target); + assert(ready.type == RecordingMutationType::Move); + assert(ready.recordingFile == source); + assert(ready.targetFile == target); + assert(ready.revision.recordingFile == source); + assert(ready.revision.targetFile == target); + assert(ready.revision.recordingsState != 0); + assert(ready.revision.timersState != 0); + assert(ready.constraints.empty()); + + const RecordingMutationAnalysis missingTarget = analyzer.analyze(source, ""); + assert(missingTarget.hasConstraint(RecordingConstraint::MoveTargetMissing)); + + const RecordingMutationAnalysis invalidTarget = analyzer.analyze(source, "Target/relative.rec"); + assert(invalidTarget.hasConstraint(RecordingConstraint::MoveTargetInvalid)); + + const RecordingMutationAnalysis sameTarget = analyzer.analyze(source, source); + assert(sameTarget.hasConstraint(RecordingConstraint::MoveTargetSameAsSource)); + assert(!sameTarget.hasConstraint(RecordingConstraint::MoveTargetExists)); + + recordingLookup.results[target] = {true, target}; + analyzer = makeAnalyzer( + recordingLookup, + replayLookup, + handlerLookup, + localTimerLookup, + remoteTimerLookup, + searchTimerLookup); + const RecordingMutationAnalysis collision = analyzer.analyze(source, target); + assert(collision.hasConstraint(RecordingConstraint::MoveTargetExists)); + assert(collision.revision.recordingsState != ready.revision.recordingsState); + + replayLookup.replaying = true; + handlerLookup.result.busy = true; + localTimerLookup.result.active = true; + remoteTimerLookup.result.active = true; + searchTimerLookup.result.searchTimerRecording = true; + analyzer = makeAnalyzer( + recordingLookup, + replayLookup, + handlerLookup, + localTimerLookup, + remoteTimerLookup, + searchTimerLookup); + const RecordingMutationAnalysis active = analyzer.analyze(source, "/srv/vdr/video/Other/2026.rec"); + assert(active.hasConstraint(RecordingConstraint::ReplayActive)); + assert(active.hasConstraint(RecordingConstraint::RecordingHandlerBusy)); + assert(active.hasConstraint(RecordingConstraint::LocalTimerActive)); + assert(active.hasConstraint(RecordingConstraint::RemoteTimerActive)); + assert(active.hasConstraint(RecordingConstraint::SearchTimerRecording)); + + const RecordingMutationAnalysis missingSource = analyzer.analyze( + "/srv/vdr/video/Missing/2026.rec", + target); + assert(missingSource.hasConstraint(RecordingConstraint::RecordingMissing)); + + return 0; +} From a0880a380ab1a99903b38930f8945c4efe23b5e3 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 21:57:09 +0200 Subject: [PATCH 062/120] Wire recording move analysis test --- Makefile | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 85a086f..1e7ec8b 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ LIBS += $(shell pkg-config --libs Magick++) CXXFLAGS += -DUSE_LIBMAGICKPLUSPLUS endif -OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingpreflight.o recordingpreview.o recordingexecution.o recordingvalidate.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o +OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingmoveanalysis.o recordingpreflight.o recordingpreview.o recordingexecution.o recordingvalidate.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o CFGS = API.html all: $(SOFILE) i18n @@ -55,8 +55,8 @@ $(DEPFILE): Makefile PODIR = po I18Npo = $(wildcard $(PODIR)/*.po) -I18Nmo = $(addsuffix .mo, $(foreach file, $(I18Npo), $(basename $(file)))) -I18Nmsgs = $(addprefix $(DESTDIR)$(LOCDIR)/, $(addsuffix /LC_MESSAGES/vdr-$(PLUGIN).mo, $(notdir $(foreach file, $(I18Npo), $(basename $(file)))))) +I18Nmo = $(addsuffix .mo, $(foreach file,$(I18Npo),$(basename $(file)))) +I18Nmsgs = $(addprefix $(DESTDIR)$(LOCDIR)/, $(addsuffix /LC_MESSAGES/vdr-$(PLUGIN).mo, $(notdir $(foreach file,$(I18Npo),$(basename $(file)))))) I18Npot = $(PODIR)/$(PLUGIN).pot %.mo: %.po @@ -72,7 +72,7 @@ $(I18Npot): $(wildcard *.cpp) $(I18Nmsgs): $(DESTDIR)$(LOCDIR)/%/LC_MESSAGES/vdr-$(PLUGIN).mo: $(PODIR)/%.mo install -D -m644 $< $@ -.PHONY: i18n test-recording-move-plan +.PHONY: i18n test-recording-move-plan test-recording-move-analysis i18n: $(I18Nmo) $(I18Npot) test-recording-move-plan: @@ -82,6 +82,14 @@ test-recording-move-plan: -o /tmp/test_recording_move_plan /tmp/test_recording_move_plan +test-recording-move-analysis: + $(CXX) -std=c++17 -Wall -Wextra \ + recordingmutation.cpp \ + recordingmoveanalysis.cpp \ + tests/test_recording_move_analysis.cpp \ + -o /tmp/test_recording_move_analysis + /tmp/test_recording_move_analysis + install-i18n: $(I18Nmsgs) $(SOFILE): $(OBJS) @@ -101,7 +109,6 @@ dist: $(I18Npo) clean @cp -a * $(TMPDIR)/$(ARCHIVE) @-rm -rf $(TMPDIR)/$(ARCHIVE)/debian @tar czf $(PACKAGE).tgz -C $(TMPDIR) $(ARCHIVE) - @-rm -rf $(TMPDIR)/$(ARCHIVE) @echo Distribution package created as $(PACKAGE).tgz clean: From ec8491c1f0f7b57183315d1e2dbb0364e464941c Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 21:58:12 +0200 Subject: [PATCH 063/120] Keep recording move Makefile wiring minimal --- Makefile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 1e7ec8b..6555d90 100644 --- a/Makefile +++ b/Makefile @@ -55,8 +55,8 @@ $(DEPFILE): Makefile PODIR = po I18Npo = $(wildcard $(PODIR)/*.po) -I18Nmo = $(addsuffix .mo, $(foreach file,$(I18Npo),$(basename $(file)))) -I18Nmsgs = $(addprefix $(DESTDIR)$(LOCDIR)/, $(addsuffix /LC_MESSAGES/vdr-$(PLUGIN).mo, $(notdir $(foreach file,$(I18Npo),$(basename $(file)))))) +I18Nmo = $(addsuffix .mo, $(foreach file, $(I18Npo), $(basename $(file)))) +I18Nmsgs = $(addprefix $(DESTDIR)$(LOCDIR)/, $(addsuffix /LC_MESSAGES/vdr-$(PLUGIN).mo, $(notdir $(foreach file, $(I18Npo), $(basename $(file)))))) I18Npot = $(PODIR)/$(PLUGIN).pot %.mo: %.po @@ -109,6 +109,7 @@ dist: $(I18Npo) clean @cp -a * $(TMPDIR)/$(ARCHIVE) @-rm -rf $(TMPDIR)/$(ARCHIVE)/debian @tar czf $(PACKAGE).tgz -C $(TMPDIR) $(ARCHIVE) + @-rm -rf $(TMPDIR)/$(ARCHIVE) @echo Distribution package created as $(PACKAGE).tgz clean: From 4cc2df3e277132c6aac92bcc283b0a47225c6d5b Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 22:09:25 +0200 Subject: [PATCH 064/120] Tests: keep recording move analyzer bound to mutable fakes --- tests/test_recording_move_analysis.cpp | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/tests/test_recording_move_analysis.cpp b/tests/test_recording_move_analysis.cpp index 7d1a2df..f44ea1e 100644 --- a/tests/test_recording_move_analysis.cpp +++ b/tests/test_recording_move_analysis.cpp @@ -134,13 +134,6 @@ int main() assert(!sameTarget.hasConstraint(RecordingConstraint::MoveTargetExists)); recordingLookup.results[target] = {true, target}; - analyzer = makeAnalyzer( - recordingLookup, - replayLookup, - handlerLookup, - localTimerLookup, - remoteTimerLookup, - searchTimerLookup); const RecordingMutationAnalysis collision = analyzer.analyze(source, target); assert(collision.hasConstraint(RecordingConstraint::MoveTargetExists)); assert(collision.revision.recordingsState != ready.revision.recordingsState); @@ -150,13 +143,6 @@ int main() localTimerLookup.result.active = true; remoteTimerLookup.result.active = true; searchTimerLookup.result.searchTimerRecording = true; - analyzer = makeAnalyzer( - recordingLookup, - replayLookup, - handlerLookup, - localTimerLookup, - remoteTimerLookup, - searchTimerLookup); const RecordingMutationAnalysis active = analyzer.analyze(source, "/srv/vdr/video/Other/2026.rec"); assert(active.hasConstraint(RecordingConstraint::ReplayActive)); assert(active.hasConstraint(RecordingConstraint::RecordingHandlerBusy)); From 7046bfeccfe6925f80faad10b37bade6c04e775b Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 22:11:44 +0200 Subject: [PATCH 065/120] Add recording move preflight service contract --- recordingpreflight.h | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/recordingpreflight.h b/recordingpreflight.h index 27090aa..6d8b8a8 100644 --- a/recordingpreflight.h +++ b/recordingpreflight.h @@ -18,6 +18,18 @@ struct RecordingTrashPreflightResult RecordingMutationRevision revision; }; +struct RecordingMovePreflightResult +{ + bool executable = false; + std::string recordingFile; + std::string targetFile; + std::vector constraints; + std::vector blockers; + std::vector warnings; + std::vector steps; + RecordingMutationRevision revision; +}; + class RecordingTrashPreflightService { public: @@ -34,4 +46,21 @@ class RecordingTrashPreflightService const RecordingMutationPlanner& planner; }; +class RecordingMovePreflightService +{ +public: + RecordingMovePreflightService( + const RecordingMoveAnalyzer& analyzer, + const RecordingMutationPlanner& planner); + + RecordingMovePreflightResult preview( + const std::string& recordingFile, + const std::string& targetFile, + const RecordingMutationPolicy& policy) const; + +private: + const RecordingMoveAnalyzer& analyzer; + const RecordingMutationPlanner& planner; +}; + #endif From 26d2243fda0b04285bb8fa58a51fa42171320c08 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 22:12:07 +0200 Subject: [PATCH 066/120] Implement recording move preflight service --- recordingpreflight.cpp | 58 ++++++++++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/recordingpreflight.cpp b/recordingpreflight.cpp index 1939e34..b458604 100644 --- a/recordingpreflight.cpp +++ b/recordingpreflight.cpp @@ -1,5 +1,30 @@ #include "recordingpreflight.h" +namespace { + +template +void appendAnalysisAndPlan( + Result& result, + const RecordingMutationAnalysis& analysis, + const RecordingMutationPlan& plan) +{ + result.executable = plan.executable; + result.recordingFile = analysis.recordingFile; + result.warnings = plan.warnings; + result.revision = plan.expectedRevision; + + for (const RecordingConstraint constraint : analysis.constraints) + result.constraints.push_back(RecordingConstraintName(constraint)); + + for (const RecordingConstraint blocker : plan.blockers) + result.blockers.push_back(RecordingConstraintName(blocker)); + + for (const RecordingMutationStep step : plan.steps) + result.steps.push_back(RecordingMutationStepName(step)); +} + +} + RecordingTrashPreflightService::RecordingTrashPreflightService( const RecordingTrashAnalyzer& analyzer, const RecordingMutationPlanner& planner) @@ -15,20 +40,27 @@ RecordingTrashPreflightResult RecordingTrashPreflightService::preview( RecordingTrashPreflightResult result; const RecordingMutationAnalysis analysis = analyzer.analyze(recordingFile); const RecordingMutationPlan plan = planner.buildTrashPlan(analysis, policy); + appendAnalysisAndPlan(result, analysis, plan); + return result; +} - result.executable = plan.executable; - result.recordingFile = analysis.recordingFile; - result.warnings = plan.warnings; - result.revision = plan.expectedRevision; - - for (const RecordingConstraint constraint : analysis.constraints) - result.constraints.push_back(RecordingConstraintName(constraint)); - - for (const RecordingConstraint blocker : plan.blockers) - result.blockers.push_back(RecordingConstraintName(blocker)); - - for (const RecordingMutationStep step : plan.steps) - result.steps.push_back(RecordingMutationStepName(step)); +RecordingMovePreflightService::RecordingMovePreflightService( + const RecordingMoveAnalyzer& analyzer, + const RecordingMutationPlanner& planner) + : analyzer(analyzer), + planner(planner) +{ +} +RecordingMovePreflightResult RecordingMovePreflightService::preview( + const std::string& recordingFile, + const std::string& targetFile, + const RecordingMutationPolicy& policy) const +{ + RecordingMovePreflightResult result; + const RecordingMutationAnalysis analysis = analyzer.analyze(recordingFile, targetFile); + const RecordingMutationPlan plan = planner.buildMovePlan(analysis, policy); + appendAnalysisAndPlan(result, analysis, plan); + result.targetFile = analysis.targetFile; return result; } From ce0dee59ed3b8d573501dc98f5979851c1340d86 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 22:12:46 +0200 Subject: [PATCH 067/120] Test recording move preflight service --- tests/test_recording_move_preflight.cpp | 144 ++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 tests/test_recording_move_preflight.cpp diff --git a/tests/test_recording_move_preflight.cpp b/tests/test_recording_move_preflight.cpp new file mode 100644 index 0000000..77dc763 --- /dev/null +++ b/tests/test_recording_move_preflight.cpp @@ -0,0 +1,144 @@ +#include "../recordingpreflight.h" + +#include +#include +#include +#include + +namespace { + +class FakeRecordingLookup : public IRecordingLookup +{ +public: + std::map results; + + RecordingLookupResult find(const std::string& recordingFile) const override + { + const auto result = results.find(recordingFile); + return result == results.end() ? RecordingLookupResult() : result->second; + } +}; + +class FakeReplayLookup : public IRecordingReplayLookup +{ +public: + bool replaying = false; + + bool isReplaying(const std::string&) const override + { + return replaying; + } +}; + +class FakeHandlerLookup : public IRecordingHandlerLookup +{ +public: + RecordingHandlerLookupResult result{true, false}; + + RecordingHandlerLookupResult getUsage(const std::string&) const override + { + return result; + } +}; + +class FakeLocalTimerLookup : public IRecordingLocalTimerLookup +{ +public: + RecordingLocalTimerLookupResult result{true, false}; + + RecordingLocalTimerLookupResult findActive(const std::string&) const override + { + return result; + } +}; + +class FakeRemoteTimerLookup : public IRecordingRemoteTimerLookup +{ +public: + RecordingRemoteTimerLookupResult result{true, false, 0, ""}; + + RecordingRemoteTimerLookupResult findActive(const std::string&) const override + { + return result; + } +}; + +class FakeSearchTimerLookup : public IRecordingSearchTimerLookup +{ +public: + RecordingSearchTimerLookupResult result{true, false, -1}; + + RecordingSearchTimerLookupResult findOrigin(const std::string&) const override + { + return result; + } +}; + +bool contains(const std::vector& values, const std::string& value) +{ + return std::find(values.begin(), values.end(), value) != values.end(); +} + +} + +int main() +{ + const std::string source = "/srv/vdr/video/Source/2026-07-14.20.00.1-0.rec"; + const std::string target = "/srv/vdr/video/Target/2026-07-14.20.00.1-0.rec"; + + FakeRecordingLookup recordingLookup; + recordingLookup.results[source] = {true, source}; + FakeReplayLookup replayLookup; + FakeHandlerLookup handlerLookup; + FakeLocalTimerLookup localTimerLookup; + FakeRemoteTimerLookup remoteTimerLookup; + FakeSearchTimerLookup searchTimerLookup; + + RecordingMoveAnalyzer analyzer( + recordingLookup, + replayLookup, + handlerLookup, + localTimerLookup, + remoteTimerLookup, + searchTimerLookup); + RecordingMutationPlanner planner; + RecordingMovePreflightService service(analyzer, planner); + RecordingMutationPolicy policy; + + const RecordingMovePreflightResult ready = service.preview(source, target, policy); + assert(ready.executable); + assert(ready.recordingFile == source); + assert(ready.targetFile == target); + assert(ready.constraints.empty()); + assert(ready.blockers.empty()); + assert(contains(ready.steps, "move-recording")); + assert(contains(ready.steps, "refresh-recordings")); + assert(contains(ready.steps, "notify-change")); + assert(ready.revision.recordingFile == source); + assert(ready.revision.targetFile == target); + assert(ready.revision.recordingsState != 0); + assert(ready.revision.timersState != 0); + + const RecordingMovePreflightResult missingTarget = service.preview(source, "", policy); + assert(!missingTarget.executable); + assert(contains(missingTarget.constraints, "move-target-missing")); + assert(contains(missingTarget.blockers, "move-target-missing")); + + recordingLookup.results[target] = {true, target}; + const RecordingMovePreflightResult collision = service.preview(source, target, policy); + assert(!collision.executable); + assert(contains(collision.constraints, "move-target-exists")); + assert(contains(collision.blockers, "move-target-exists")); + assert(collision.revision.recordingsState != ready.revision.recordingsState); + + replayLookup.replaying = true; + const RecordingMovePreflightResult replayBlocked = service.preview( + source, + "/srv/vdr/video/Other/2026-07-14.20.00.1-0.rec", + policy); + assert(!replayBlocked.executable); + assert(contains(replayBlocked.constraints, "replay-active")); + assert(contains(replayBlocked.blockers, "replay-active")); + + return 0; +} From 0e2afd7529e26f0c5a2f53f4d6bd2d974d604048 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 22:13:39 +0200 Subject: [PATCH 068/120] Wire recording move preflight test --- Makefile | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 6555d90..30339d5 100644 --- a/Makefile +++ b/Makefile @@ -55,8 +55,8 @@ $(DEPFILE): Makefile PODIR = po I18Npo = $(wildcard $(PODIR)/*.po) -I18Nmo = $(addsuffix .mo, $(foreach file, $(I18Npo), $(basename $(file)))) -I18Nmsgs = $(addprefix $(DESTDIR)$(LOCDIR)/, $(addsuffix /LC_MESSAGES/vdr-$(PLUGIN).mo, $(notdir $(foreach file, $(I18Npo), $(basename $(file)))))) +I18Nmo = $(addsuffix .mo, $(foreach file,$(I18Npo),$(basename $(file)))) +I18Nmsgs = $(addprefix $(DESTDIR)$(LOCDIR)/, $(addsuffix /LC_MESSAGES/vdr-$(PLUGIN).mo, $(notdir $(foreach file,$(I18Npo),$(basename $(file)))))) I18Npot = $(PODIR)/$(PLUGIN).pot %.mo: %.po @@ -72,7 +72,7 @@ $(I18Npot): $(wildcard *.cpp) $(I18Nmsgs): $(DESTDIR)$(LOCDIR)/%/LC_MESSAGES/vdr-$(PLUGIN).mo: $(PODIR)/%.mo install -D -m644 $< $@ -.PHONY: i18n test-recording-move-plan test-recording-move-analysis +.PHONY: i18n test-recording-move-plan test-recording-move-analysis test-recording-move-preflight i18n: $(I18Nmo) $(I18Npot) test-recording-move-plan: @@ -90,6 +90,15 @@ test-recording-move-analysis: -o /tmp/test_recording_move_analysis /tmp/test_recording_move_analysis +test-recording-move-preflight: + $(CXX) -std=c++17 -Wall -Wextra \ + recordingmutation.cpp \ + recordingmoveanalysis.cpp \ + recordingpreflight.cpp \ + tests/test_recording_move_preflight.cpp \ + -o /tmp/test_recording_move_preflight + /tmp/test_recording_move_preflight + install-i18n: $(I18Nmsgs) $(SOFILE): $(OBJS) @@ -109,7 +118,6 @@ dist: $(I18Npo) clean @cp -a * $(TMPDIR)/$(ARCHIVE) @-rm -rf $(TMPDIR)/$(ARCHIVE)/debian @tar czf $(PACKAGE).tgz -C $(TMPDIR) $(ARCHIVE) - @-rm -rf $(TMPDIR)/$(ARCHIVE) @echo Distribution package created as $(PACKAGE).tgz clean: From 17b5674b16b7e61b90a2f1243842402b897b227b Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 22:21:48 +0200 Subject: [PATCH 069/120] Tests: link recording analysis into move preflight test --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index 30339d5..d8bff7f 100644 --- a/Makefile +++ b/Makefile @@ -93,6 +93,7 @@ test-recording-move-analysis: test-recording-move-preflight: $(CXX) -std=c++17 -Wall -Wextra \ recordingmutation.cpp \ + recordinganalysis.cpp \ recordingmoveanalysis.cpp \ recordingpreflight.cpp \ tests/test_recording_move_preflight.cpp \ From 371b52e4eb80f61116868b5a31c47a43866b6a60 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 22:26:39 +0200 Subject: [PATCH 070/120] Split move preflight implementation from trash preflight --- recordingpreflight.cpp | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/recordingpreflight.cpp b/recordingpreflight.cpp index b458604..1f935c3 100644 --- a/recordingpreflight.cpp +++ b/recordingpreflight.cpp @@ -2,9 +2,8 @@ namespace { -template -void appendAnalysisAndPlan( - Result& result, +void appendTrashAnalysisAndPlan( + RecordingTrashPreflightResult& result, const RecordingMutationAnalysis& analysis, const RecordingMutationPlan& plan) { @@ -40,27 +39,6 @@ RecordingTrashPreflightResult RecordingTrashPreflightService::preview( RecordingTrashPreflightResult result; const RecordingMutationAnalysis analysis = analyzer.analyze(recordingFile); const RecordingMutationPlan plan = planner.buildTrashPlan(analysis, policy); - appendAnalysisAndPlan(result, analysis, plan); - return result; -} - -RecordingMovePreflightService::RecordingMovePreflightService( - const RecordingMoveAnalyzer& analyzer, - const RecordingMutationPlanner& planner) - : analyzer(analyzer), - planner(planner) -{ -} - -RecordingMovePreflightResult RecordingMovePreflightService::preview( - const std::string& recordingFile, - const std::string& targetFile, - const RecordingMutationPolicy& policy) const -{ - RecordingMovePreflightResult result; - const RecordingMutationAnalysis analysis = analyzer.analyze(recordingFile, targetFile); - const RecordingMutationPlan plan = planner.buildMovePlan(analysis, policy); - appendAnalysisAndPlan(result, analysis, plan); - result.targetFile = analysis.targetFile; + appendTrashAnalysisAndPlan(result, analysis, plan); return result; } From b9024ee464a7418cb061fad621fd2cdab796639a Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 22:27:00 +0200 Subject: [PATCH 071/120] Add isolated recording move preflight implementation --- recordingmovepreflight.cpp | 46 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 recordingmovepreflight.cpp diff --git a/recordingmovepreflight.cpp b/recordingmovepreflight.cpp new file mode 100644 index 0000000..1825903 --- /dev/null +++ b/recordingmovepreflight.cpp @@ -0,0 +1,46 @@ +#include "recordingpreflight.h" + +namespace { + +void appendMoveAnalysisAndPlan( + RecordingMovePreflightResult& result, + const RecordingMutationAnalysis& analysis, + const RecordingMutationPlan& plan) +{ + result.executable = plan.executable; + result.recordingFile = analysis.recordingFile; + result.targetFile = analysis.targetFile; + result.warnings = plan.warnings; + result.revision = plan.expectedRevision; + + for (const RecordingConstraint constraint : analysis.constraints) + result.constraints.push_back(RecordingConstraintName(constraint)); + + for (const RecordingConstraint blocker : plan.blockers) + result.blockers.push_back(RecordingConstraintName(blocker)); + + for (const RecordingMutationStep step : plan.steps) + result.steps.push_back(RecordingMutationStepName(step)); +} + +} + +RecordingMovePreflightService::RecordingMovePreflightService( + const RecordingMoveAnalyzer& analyzer, + const RecordingMutationPlanner& planner) + : analyzer(analyzer), + planner(planner) +{ +} + +RecordingMovePreflightResult RecordingMovePreflightService::preview( + const std::string& recordingFile, + const std::string& targetFile, + const RecordingMutationPolicy& policy) const +{ + RecordingMovePreflightResult result; + const RecordingMutationAnalysis analysis = analyzer.analyze(recordingFile, targetFile); + const RecordingMutationPlan plan = planner.buildMovePlan(analysis, policy); + appendMoveAnalysisAndPlan(result, analysis, plan); + return result; +} From b39f2c6045c3ae0f851642ed2f274bc02626ace7 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 22:28:13 +0200 Subject: [PATCH 072/120] Isolate recording move preflight build wiring --- Makefile | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index d8bff7f..1a941a2 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ LIBS += $(shell pkg-config --libs Magick++) CXXFLAGS += -DUSE_LIBMAGICKPLUSPLUS endif -OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingmoveanalysis.o recordingpreflight.o recordingpreview.o recordingexecution.o recordingvalidate.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o +OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingmoveanalysis.o recordingpreflight.o recordingmovepreflight.o recordingpreview.o recordingexecution.o recordingvalidate.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o CFGS = API.html all: $(SOFILE) i18n @@ -93,9 +93,8 @@ test-recording-move-analysis: test-recording-move-preflight: $(CXX) -std=c++17 -Wall -Wextra \ recordingmutation.cpp \ - recordinganalysis.cpp \ recordingmoveanalysis.cpp \ - recordingpreflight.cpp \ + recordingmovepreflight.cpp \ tests/test_recording_move_preflight.cpp \ -o /tmp/test_recording_move_preflight /tmp/test_recording_move_preflight From 99225239a5accbf46e368204cdadf3c84c110584 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 22:46:07 +0200 Subject: [PATCH 073/120] Add recording move preview HTTP contract --- recordingmovepreview.h | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 recordingmovepreview.h diff --git a/recordingmovepreview.h b/recordingmovepreview.h new file mode 100644 index 0000000..5afce6c --- /dev/null +++ b/recordingmovepreview.h @@ -0,0 +1,25 @@ +#ifndef __RECORDINGMOVEPREVIEW_H +#define __RECORDINGMOVEPREVIEW_H + +#include +#include +#include + +class RecordingMovePreviewResponder : public cxxtools::http::Responder +{ +public: + explicit RecordingMovePreviewResponder(cxxtools::http::Service& service) + : cxxtools::http::Responder(service) + { + } + + void reply( + std::ostream& out, + cxxtools::http::Request& request, + cxxtools::http::Reply& reply) override; +}; + +typedef cxxtools::http::CachedService + RecordingMovePreviewService; + +#endif From cd065080e79c0219d2f7a7a72341b0874ed24f3d Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 22:46:45 +0200 Subject: [PATCH 074/120] Implement recording move preview HTTP service --- recordingmovepreview.cpp | 83 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 recordingmovepreview.cpp diff --git a/recordingmovepreview.cpp b/recordingmovepreview.cpp new file mode 100644 index 0000000..8b084dc --- /dev/null +++ b/recordingmovepreview.cpp @@ -0,0 +1,83 @@ +#include "recordingmovepreview.h" + +#include "recordinganalysis.h" +#include "recordingmutation.h" +#include "recordingpreflight.h" +#include "tools.h" + +#include + +void RecordingMovePreviewResponder::reply( + std::ostream& out, + cxxtools::http::Request& request, + cxxtools::http::Reply& reply) +{ + QueryHandler::addHeader(reply); + + if (request.method() == "OPTIONS") { + reply.addHeader("Allow", "POST"); + reply.httpReturn(200, "OK"); + return; + } + + if (request.method() != "POST") { + reply.httpReturn(501, "Only POST method is supported by the /recordings/move/preview service."); + return; + } + + QueryHandler query("/recordings/move/preview", request); + const std::string recordingFile = query.getBodyAsString("file"); + const std::string targetFile = query.getBodyAsString("target_file"); + + if (recordingFile.empty()) { + reply.httpReturn(400, "Recording file is missing."); + return; + } + + if (targetFile.empty()) { + reply.httpReturn(400, "Recording move target file is missing."); + return; + } + + RecordingMutationPolicy policy; + policy.allowRecordingHandlerStop = query.getBodyAsBool("allow_recording_handler_stop"); + policy.allowReplayStop = query.getBodyAsBool("allow_replay_stop"); + policy.allowLocalTimerStop = query.getBodyAsBool("allow_local_timer_stop"); + policy.allowRemoteTimerStop = query.getBodyAsBool("allow_remote_timer_stop"); + + VdrRecordingLookup recordingLookup; + VdrRecordingReplayLookup replayLookup; + VdrRecordingHandlerLookup recordingHandlerLookup; + VdrRecordingLocalTimerLookup localTimerLookup; + VdrRecordingRemoteTimerLookup remoteTimerLookup; + VdrRecordingSearchTimerLookup searchTimerLookup; + + RecordingMoveAnalyzer analyzer( + recordingLookup, + replayLookup, + recordingHandlerLookup, + localTimerLookup, + remoteTimerLookup, + searchTimerLookup); + + RecordingMutationPlanner planner; + RecordingMovePreflightService preflightService(analyzer, planner); + const RecordingMovePreflightResult result = + preflightService.preview(recordingFile, targetFile, policy); + + reply.addHeader("Content-Type", "application/json; charset=utf-8"); + + cxxtools::JsonSerializer serializer(out); + serializer.serialize(result.executable, "executable"); + serializer.serialize(result.recordingFile, "recording_file"); + serializer.serialize(result.targetFile, "target_file"); + serializer.serialize(result.constraints, "constraints"); + serializer.serialize(result.blockers, "blockers"); + serializer.serialize(result.warnings, "warnings"); + serializer.serialize(result.steps, "steps"); + serializer.serialize(result.revision.recordingFile, "revision_recording_file"); + serializer.serialize(result.revision.targetFile, "revision_target_file"); + serializer.serialize(result.revision.recordingsState, "revision_recordings_state"); + serializer.serialize(result.revision.timersState, "revision_timers_state"); + serializer.finish(); +} From 59d1de59638bf40a53b94087ce6f724b343e32e7 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 22:48:14 +0200 Subject: [PATCH 075/120] Wire recording move preview service build --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 1a941a2..d4e3dbf 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ LIBS += $(shell pkg-config --libs Magick++) CXXFLAGS += -DUSE_LIBMAGICKPLUSPLUS endif -OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingmoveanalysis.o recordingpreflight.o recordingmovepreflight.o recordingpreview.o recordingexecution.o recordingvalidate.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o +OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingmoveanalysis.o recordingpreflight.o recordingmovepreflight.o recordingpreview.o recordingmovepreview.o recordingexecution.o recordingvalidate.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o CFGS = API.html all: $(SOFILE) i18n From 2f1d7013f2d0edc99d4a268b0f13728699ab94ef Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 22:48:41 +0200 Subject: [PATCH 076/120] Register recording move preview service header --- serverthread.h | 1 + 1 file changed, 1 insertion(+) diff --git a/serverthread.h b/serverthread.h index 4fcadb7..803ccba 100644 --- a/serverthread.h +++ b/serverthread.h @@ -17,6 +17,7 @@ #include "events.h" #include "recordings.h" #include "recordingpreview.h" +#include "recordingmovepreview.h" #include "recordingvalidate.h" #include "recordingtrash.h" #include "remote.h" From 44da3be6e324fbbe093104c759ea15a8f6ebb2b4 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Tue, 14 Jul 2026 22:50:10 +0200 Subject: [PATCH 077/120] Register recording move preview HTTP route --- serverthread.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/serverthread.cpp b/serverthread.cpp index dad477c..213cd70 100644 --- a/serverthread.cpp +++ b/serverthread.cpp @@ -40,6 +40,7 @@ void cServerThread::Action(void) ChannelsService channelsService; EventsService eventsService; RecordingsService recordingsService; + RecordingMovePreviewService recordingMovePreviewService; RecordingTrashPreviewService recordingTrashPreviewService; RecordingTrashValidateService recordingTrashValidateService; RecordingTrashService recordingTrashService; @@ -62,6 +63,7 @@ void cServerThread::Action(void) RestfulService* recordings = new RestfulService("/recordings", true, 1); RestfulService* recordingsCut = new RestfulService("/recordings/cut", true, 1, recordings); RestfulService* recordingsMarks = new RestfulService("/recordings/marks", true, 1, recordings); + RestfulService* recordingMovePreview = new RestfulService("/recordings/move/preview", true, 1, recordings); RestfulService* recordingTrashPreview = new RestfulService("/recordings/trash/preview", true, 1, recordings); RestfulService* recordingTrashValidate = new RestfulService("/recordings/trash/validate", true, 1, recordings); RestfulService* recordingTrash = new RestfulService("/recordings/trash", true, 1, recordings); @@ -85,6 +87,7 @@ void cServerThread::Action(void) services->appendService(recordings); services->appendService(recordingsCut); services->appendService(recordingsMarks); + services->appendService(recordingMovePreview); services->appendService(recordingTrashPreview); services->appendService(recordingTrashValidate); services->appendService(recordingTrash); @@ -101,6 +104,7 @@ void cServerThread::Action(void) server->addService(std::move(*info->Regex()), infoService); server->addService(std::move(*channels->Regex()), channelsService); server->addService(std::move(*events->Regex()), eventsService); + server->addService(std::move(*recordingMovePreview->Regex()), recordingMovePreviewService); server->addService(std::move(*recordingTrashPreview->Regex()), recordingTrashPreviewService); server->addService(std::move(*recordingTrashValidate->Regex()), recordingTrashValidateService); server->addService(std::move(*recordingTrash->Regex()), recordingTrashService); @@ -147,7 +151,6 @@ void cServerThread::addWebappService(string name) { } i++; } - if (false == occupied) { RestfulService* service = new RestfulService(path, true, 1); services->appendService(service); From 8a9d23f570dde5c0041797f2b09e640c166d1e9d Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 06:15:20 +0200 Subject: [PATCH 078/120] Add recording move execution gate contract --- recordingmoveexecution.h | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 recordingmoveexecution.h diff --git a/recordingmoveexecution.h b/recordingmoveexecution.h new file mode 100644 index 0000000..897b718 --- /dev/null +++ b/recordingmoveexecution.h @@ -0,0 +1,49 @@ +#ifndef __RECORDINGMOVEEXECUTION_H +#define __RECORDINGMOVEEXECUTION_H + +#include "recordinganalysis.h" +#include "recordingmutation.h" + +#include +#include + +enum class RecordingMoveExecutionGateStatus +{ + Ready, + Conflict, + Blocked +}; + +struct RecordingMoveExecutionGateResult +{ + RecordingMoveExecutionGateStatus status = RecordingMoveExecutionGateStatus::Blocked; + std::string recordingFile; + std::string targetFile; + RecordingMutationRevision expectedRevision; + RecordingMutationRevision currentRevision; + std::vector blockers; + std::vector warnings; +}; + +class RecordingMoveExecutionGate +{ +public: + RecordingMoveExecutionGate( + const RecordingMoveAnalyzer& analyzer, + const RecordingMutationPlanner& planner); + + RecordingMoveExecutionGateResult validate( + const std::string& recordingFile, + const std::string& targetFile, + const RecordingMutationRevision& expectedRevision, + const RecordingMutationPolicy& policy) const; + +private: + const RecordingMoveAnalyzer& analyzer; + const RecordingMutationPlanner& planner; +}; + +const char* RecordingMoveExecutionGateStatusName( + RecordingMoveExecutionGateStatus status); + +#endif From c66f0c21fe2d67059b0b0b2c40c9664bcabf68d5 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 06:15:43 +0200 Subject: [PATCH 079/120] Implement recording move execution gate --- recordingmoveexecution.cpp | 70 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 recordingmoveexecution.cpp diff --git a/recordingmoveexecution.cpp b/recordingmoveexecution.cpp new file mode 100644 index 0000000..798e7b6 --- /dev/null +++ b/recordingmoveexecution.cpp @@ -0,0 +1,70 @@ +#include "recordingmoveexecution.h" + +namespace { + +bool sameRevision( + const RecordingMutationRevision& left, + const RecordingMutationRevision& right) +{ + return left.recordingFile == right.recordingFile + && left.targetFile == right.targetFile + && left.recordingsState == right.recordingsState + && left.timersState == right.timersState; +} + +} + +RecordingMoveExecutionGate::RecordingMoveExecutionGate( + const RecordingMoveAnalyzer& analyzer, + const RecordingMutationPlanner& planner) + : analyzer(analyzer), + planner(planner) +{ +} + +RecordingMoveExecutionGateResult RecordingMoveExecutionGate::validate( + const std::string& recordingFile, + const std::string& targetFile, + const RecordingMutationRevision& expectedRevision, + const RecordingMutationPolicy& policy) const +{ + RecordingMoveExecutionGateResult result; + result.recordingFile = recordingFile; + result.targetFile = targetFile; + result.expectedRevision = expectedRevision; + + const RecordingMutationAnalysis analysis = analyzer.analyze(recordingFile, targetFile); + const RecordingMutationPlan plan = planner.buildMovePlan(analysis, policy); + + result.currentRevision = analysis.revision; + result.blockers = plan.blockers; + result.warnings = plan.warnings; + + if (!sameRevision(expectedRevision, analysis.revision)) { + result.status = RecordingMoveExecutionGateStatus::Conflict; + return result; + } + + if (!plan.executable) { + result.status = RecordingMoveExecutionGateStatus::Blocked; + return result; + } + + result.status = RecordingMoveExecutionGateStatus::Ready; + return result; +} + +const char* RecordingMoveExecutionGateStatusName( + RecordingMoveExecutionGateStatus status) +{ + switch (status) { + case RecordingMoveExecutionGateStatus::Ready: + return "ready"; + case RecordingMoveExecutionGateStatus::Conflict: + return "conflict"; + case RecordingMoveExecutionGateStatus::Blocked: + return "blocked"; + } + + return "blocked"; +} From 5750a083c935b28423bab843df6a91ba85de9117 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 06:16:05 +0200 Subject: [PATCH 080/120] Add recording move validate HTTP contract --- recordingmovevalidate.h | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 recordingmovevalidate.h diff --git a/recordingmovevalidate.h b/recordingmovevalidate.h new file mode 100644 index 0000000..22b84c3 --- /dev/null +++ b/recordingmovevalidate.h @@ -0,0 +1,25 @@ +#ifndef __RECORDINGMOVEVALIDATE_H +#define __RECORDINGMOVEVALIDATE_H + +#include +#include +#include + +class RecordingMoveValidateResponder : public cxxtools::http::Responder +{ +public: + explicit RecordingMoveValidateResponder(cxxtools::http::Service& service) + : cxxtools::http::Responder(service) + { + } + + void reply( + std::ostream& out, + cxxtools::http::Request& request, + cxxtools::http::Reply& reply) override; +}; + +typedef cxxtools::http::CachedService + RecordingMoveValidateService; + +#endif From 54fae30280863705fd0ee508d33ab85cd740945f Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 06:16:12 +0200 Subject: [PATCH 081/120] Add recording move execution gate contract --- recordingexecution.h | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/recordingexecution.h b/recordingexecution.h index 4e1d4a4..45cd00c 100644 --- a/recordingexecution.h +++ b/recordingexecution.h @@ -41,6 +41,35 @@ class RecordingTrashExecutionGate const RecordingMutationPlanner& planner; }; +struct RecordingMoveExecutionGateResult +{ + RecordingTrashExecutionGateStatus status = RecordingTrashExecutionGateStatus::Blocked; + std::string recordingFile; + std::string targetFile; + RecordingMutationRevision expectedRevision; + RecordingMutationRevision currentRevision; + std::vector blockers; + std::vector warnings; +}; + +class RecordingMoveExecutionGate +{ +public: + RecordingMoveExecutionGate( + const RecordingMoveAnalyzer& analyzer, + const RecordingMutationPlanner& planner); + + RecordingMoveExecutionGateResult validate( + const std::string& recordingFile, + const std::string& targetFile, + const RecordingMutationRevision& expectedRevision, + const RecordingMutationPolicy& policy) const; + +private: + const RecordingMoveAnalyzer& analyzer; + const RecordingMutationPlanner& planner; +}; + const char* RecordingTrashExecutionGateStatusName( RecordingTrashExecutionGateStatus status); From aca328edea78c79cc20078c4f5b5c114461a0252 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 06:17:01 +0200 Subject: [PATCH 082/120] Implement recording move validate HTTP service --- recordingmovevalidate.cpp | 153 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 recordingmovevalidate.cpp diff --git a/recordingmovevalidate.cpp b/recordingmovevalidate.cpp new file mode 100644 index 0000000..24246be --- /dev/null +++ b/recordingmovevalidate.cpp @@ -0,0 +1,153 @@ +#include "recordingmovevalidate.h" + +#include "recordinganalysis.h" +#include "recordingmoveexecution.h" +#include "recordingmutation.h" +#include "tools.h" + +#include + +#include + +namespace { + +bool parseLongLong(const std::string& value, long long& result) +{ + if (value.empty()) + return false; + + try { + std::size_t parsed = 0; + result = std::stoll(value, &parsed, 10); + return parsed == value.size(); + } + catch (...) { + return false; + } +} + +std::vector constraintNames( + const std::vector& constraints) +{ + std::vector names; + for (RecordingConstraint constraint : constraints) + names.push_back(RecordingConstraintName(constraint)); + return names; +} + +} + +void RecordingMoveValidateResponder::reply( + std::ostream& out, + cxxtools::http::Request& request, + cxxtools::http::Reply& reply) +{ + QueryHandler::addHeader(reply); + + if (request.method() == "OPTIONS") { + reply.addHeader("Allow", "POST"); + reply.httpReturn(200, "OK"); + return; + } + + if (request.method() != "POST") { + reply.httpReturn(501, "Only POST method is supported by the /recordings/move/validate service."); + return; + } + + QueryHandler query("/recordings/move/validate", request); + const std::string recordingFile = query.getBodyAsString("file"); + const std::string targetFile = query.getBodyAsString("target_file"); + const std::string recordingsStateText = + query.getBodyAsString("revision_recordings_state"); + const std::string timersStateText = + query.getBodyAsString("revision_timers_state"); + + long long recordingsState = 0; + long long timersState = 0; + + if (recordingFile.empty()) { + reply.httpReturn(400, "Recording file is missing."); + return; + } + + if (targetFile.empty()) { + reply.httpReturn(400, "Recording move target file is missing."); + return; + } + + if (!parseLongLong(recordingsStateText, recordingsState) || + !parseLongLong(timersStateText, timersState)) { + reply.httpReturn(400, "Recording move revision is missing or invalid."); + return; + } + + RecordingMutationPolicy policy; + policy.allowRecordingHandlerStop = query.getBodyAsBool("allow_recording_handler_stop"); + policy.allowReplayStop = query.getBodyAsBool("allow_replay_stop"); + policy.allowLocalTimerStop = query.getBodyAsBool("allow_local_timer_stop"); + policy.allowRemoteTimerStop = query.getBodyAsBool("allow_remote_timer_stop"); + + VdrRecordingLookup recordingLookup; + VdrRecordingReplayLookup replayLookup; + VdrRecordingHandlerLookup recordingHandlerLookup; + VdrRecordingLocalTimerLookup localTimerLookup; + VdrRecordingRemoteTimerLookup remoteTimerLookup; + VdrRecordingSearchTimerLookup searchTimerLookup; + + RecordingMoveAnalyzer analyzer( + recordingLookup, + replayLookup, + recordingHandlerLookup, + localTimerLookup, + remoteTimerLookup, + searchTimerLookup); + + RecordingMutationPlanner planner; + RecordingMoveExecutionGate gate(analyzer, planner); + + RecordingMutationRevision expectedRevision; + expectedRevision.recordingFile = recordingFile; + expectedRevision.targetFile = targetFile; + expectedRevision.recordingsState = recordingsState; + expectedRevision.timersState = timersState; + + const RecordingMoveExecutionGateResult result = + gate.validate(recordingFile, targetFile, expectedRevision, policy); + + reply.addHeader("Content-Type", "application/json; charset=utf-8"); + + cxxtools::JsonSerializer serializer(out); + serializer.serialize( + std::string(RecordingMoveExecutionGateStatusName(result.status)), + "status"); + serializer.serialize(result.recordingFile, "recording_file"); + serializer.serialize(result.targetFile, "target_file"); + serializer.serialize(constraintNames(result.blockers), "blockers"); + serializer.serialize(result.warnings, "warnings"); + serializer.serialize( + result.expectedRevision.recordingFile, + "expected_revision_recording_file"); + serializer.serialize( + result.expectedRevision.targetFile, + "expected_revision_target_file"); + serializer.serialize( + result.expectedRevision.recordingsState, + "expected_revision_recordings_state"); + serializer.serialize( + result.expectedRevision.timersState, + "expected_revision_timers_state"); + serializer.serialize( + result.currentRevision.recordingFile, + "current_revision_recording_file"); + serializer.serialize( + result.currentRevision.targetFile, + "current_revision_target_file"); + serializer.serialize( + result.currentRevision.recordingsState, + "current_revision_recordings_state"); + serializer.serialize( + result.currentRevision.timersState, + "current_revision_timers_state"); + serializer.finish(); +} From df5dedad2e3aaeccafc23514081db10f80832069 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 06:17:57 +0200 Subject: [PATCH 083/120] Revert duplicate recording move gate declaration --- recordingexecution.h | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/recordingexecution.h b/recordingexecution.h index 45cd00c..4e1d4a4 100644 --- a/recordingexecution.h +++ b/recordingexecution.h @@ -41,35 +41,6 @@ class RecordingTrashExecutionGate const RecordingMutationPlanner& planner; }; -struct RecordingMoveExecutionGateResult -{ - RecordingTrashExecutionGateStatus status = RecordingTrashExecutionGateStatus::Blocked; - std::string recordingFile; - std::string targetFile; - RecordingMutationRevision expectedRevision; - RecordingMutationRevision currentRevision; - std::vector blockers; - std::vector warnings; -}; - -class RecordingMoveExecutionGate -{ -public: - RecordingMoveExecutionGate( - const RecordingMoveAnalyzer& analyzer, - const RecordingMutationPlanner& planner); - - RecordingMoveExecutionGateResult validate( - const std::string& recordingFile, - const std::string& targetFile, - const RecordingMutationRevision& expectedRevision, - const RecordingMutationPolicy& policy) const; - -private: - const RecordingMoveAnalyzer& analyzer; - const RecordingMutationPlanner& planner; -}; - const char* RecordingTrashExecutionGateStatusName( RecordingTrashExecutionGateStatus status); From 52f84e5896a27f18d3bc6203e15bd753c626bb21 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 06:18:06 +0200 Subject: [PATCH 084/120] Test recording move execution gate --- tests/test_recording_move_execution_gate.cpp | 140 +++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 tests/test_recording_move_execution_gate.cpp diff --git a/tests/test_recording_move_execution_gate.cpp b/tests/test_recording_move_execution_gate.cpp new file mode 100644 index 0000000..8069052 --- /dev/null +++ b/tests/test_recording_move_execution_gate.cpp @@ -0,0 +1,140 @@ +#include "../recordingmoveexecution.h" + +#include +#include +#include + +namespace { + +class FakeRecordingLookup : public IRecordingLookup +{ +public: + std::map results; + + RecordingLookupResult find(const std::string& recordingFile) const override + { + const auto result = results.find(recordingFile); + return result == results.end() ? RecordingLookupResult() : result->second; + } +}; + +class FakeReplayLookup : public IRecordingReplayLookup +{ +public: + bool replaying = false; + + bool isReplaying(const std::string&) const override + { + return replaying; + } +}; + +class FakeHandlerLookup : public IRecordingHandlerLookup +{ +public: + RecordingHandlerLookupResult result{true, false}; + + RecordingHandlerLookupResult getUsage(const std::string&) const override + { + return result; + } +}; + +class FakeLocalTimerLookup : public IRecordingLocalTimerLookup +{ +public: + RecordingLocalTimerLookupResult result{true, false}; + + RecordingLocalTimerLookupResult findActive(const std::string&) const override + { + return result; + } +}; + +class FakeRemoteTimerLookup : public IRecordingRemoteTimerLookup +{ +public: + RecordingRemoteTimerLookupResult result{true, false, 0, ""}; + + RecordingRemoteTimerLookupResult findActive(const std::string&) const override + { + return result; + } +}; + +class FakeSearchTimerLookup : public IRecordingSearchTimerLookup +{ +public: + RecordingSearchTimerLookupResult result{true, false, -1}; + + RecordingSearchTimerLookupResult findOrigin(const std::string&) const override + { + return result; + } +}; + +} + +int main() +{ + const std::string source = "/srv/vdr/video/Source/2026-07-14.20.00.1-0.rec"; + const std::string target = "/srv/vdr/video/Target/2026-07-14.20.00.1-0.rec"; + + FakeRecordingLookup recordingLookup; + recordingLookup.results[source] = {true, source}; + FakeReplayLookup replayLookup; + FakeHandlerLookup handlerLookup; + FakeLocalTimerLookup localTimerLookup; + FakeRemoteTimerLookup remoteTimerLookup; + FakeSearchTimerLookup searchTimerLookup; + + RecordingMoveAnalyzer analyzer( + recordingLookup, + replayLookup, + handlerLookup, + localTimerLookup, + remoteTimerLookup, + searchTimerLookup); + RecordingMutationPlanner planner; + RecordingMoveExecutionGate gate(analyzer, planner); + RecordingMutationPolicy policy; + + const RecordingMutationAnalysis initial = analyzer.analyze(source, target); + const RecordingMoveExecutionGateResult ready = gate.validate( + source, + target, + initial.revision, + policy); + assert(ready.status == RecordingMoveExecutionGateStatus::Ready); + assert(ready.currentRevision.targetFile == target); + + RecordingMutationRevision wrongTargetRevision = initial.revision; + wrongTargetRevision.targetFile = "/srv/vdr/video/Other/2026-07-14.20.00.1-0.rec"; + const RecordingMoveExecutionGateResult wrongTarget = gate.validate( + source, + target, + wrongTargetRevision, + policy); + assert(wrongTarget.status == RecordingMoveExecutionGateStatus::Conflict); + + recordingLookup.results[target] = {true, target}; + const RecordingMoveExecutionGateResult collision = gate.validate( + source, + target, + initial.revision, + policy); + assert(collision.status == RecordingMoveExecutionGateStatus::Conflict); + + recordingLookup.results.erase(target); + replayLookup.replaying = true; + const RecordingMutationAnalysis replayAnalysis = analyzer.analyze(source, target); + const RecordingMoveExecutionGateResult blocked = gate.validate( + source, + target, + replayAnalysis.revision, + policy); + assert(blocked.status == RecordingMoveExecutionGateStatus::Blocked); + assert(!blocked.blockers.empty()); + + return 0; +} From 75b135a5681d4a24f1a73af2fbf4f6917fc43b2d Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 06:19:35 +0200 Subject: [PATCH 085/120] Wire recording move validation build and test --- Makefile | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index d4e3dbf..9a2701d 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ LIBS += $(shell pkg-config --libs Magick++) CXXFLAGS += -DUSE_LIBMAGICKPLUSPLUS endif -OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingmoveanalysis.o recordingpreflight.o recordingmovepreflight.o recordingpreview.o recordingmovepreview.o recordingexecution.o recordingvalidate.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o +OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingmoveanalysis.o recordingpreflight.o recordingmovepreflight.o recordingpreview.o recordingmovepreview.o recordingexecution.o recordingmoveexecution.o recordingvalidate.o recordingmovevalidate.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o CFGS = API.html all: $(SOFILE) i18n @@ -72,7 +72,7 @@ $(I18Npot): $(wildcard *.cpp) $(I18Nmsgs): $(DESTDIR)$(LOCDIR)/%/LC_MESSAGES/vdr-$(PLUGIN).mo: $(PODIR)/%.mo install -D -m644 $< $@ -.PHONY: i18n test-recording-move-plan test-recording-move-analysis test-recording-move-preflight +.PHONY: i18n test-recording-move-plan test-recording-move-analysis test-recording-move-preflight test-recording-move-execution-gate i18n: $(I18Nmo) $(I18Npot) test-recording-move-plan: @@ -99,6 +99,15 @@ test-recording-move-preflight: -o /tmp/test_recording_move_preflight /tmp/test_recording_move_preflight +test-recording-move-execution-gate: + $(CXX) -std=c++17 -Wall -Wextra \ + recordingmutation.cpp \ + recordingmoveanalysis.cpp \ + recordingmoveexecution.cpp \ + tests/test_recording_move_execution_gate.cpp \ + -o /tmp/test_recording_move_execution_gate + /tmp/test_recording_move_execution_gate + install-i18n: $(I18Nmsgs) $(SOFILE): $(OBJS) From 92ed636025d0515e66e42ec90e041a7d2bea4f20 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 06:20:08 +0200 Subject: [PATCH 086/120] Register recording move validate service header --- serverthread.h | 1 + 1 file changed, 1 insertion(+) diff --git a/serverthread.h b/serverthread.h index 803ccba..39427dc 100644 --- a/serverthread.h +++ b/serverthread.h @@ -19,6 +19,7 @@ #include "recordingpreview.h" #include "recordingmovepreview.h" #include "recordingvalidate.h" +#include "recordingmovevalidate.h" #include "recordingtrash.h" #include "remote.h" #include "timers.h" From e7649342bc90036c1151e0b14437c141a8116170 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 06:21:39 +0200 Subject: [PATCH 087/120] Register recording move validate HTTP route --- serverthread.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/serverthread.cpp b/serverthread.cpp index 213cd70..8a301b4 100644 --- a/serverthread.cpp +++ b/serverthread.cpp @@ -41,6 +41,7 @@ void cServerThread::Action(void) EventsService eventsService; RecordingsService recordingsService; RecordingMovePreviewService recordingMovePreviewService; + RecordingMoveValidateService recordingMoveValidateService; RecordingTrashPreviewService recordingTrashPreviewService; RecordingTrashValidateService recordingTrashValidateService; RecordingTrashService recordingTrashService; @@ -64,6 +65,7 @@ void cServerThread::Action(void) RestfulService* recordingsCut = new RestfulService("/recordings/cut", true, 1, recordings); RestfulService* recordingsMarks = new RestfulService("/recordings/marks", true, 1, recordings); RestfulService* recordingMovePreview = new RestfulService("/recordings/move/preview", true, 1, recordings); + RestfulService* recordingMoveValidate = new RestfulService("/recordings/move/validate", true, 1, recordings); RestfulService* recordingTrashPreview = new RestfulService("/recordings/trash/preview", true, 1, recordings); RestfulService* recordingTrashValidate = new RestfulService("/recordings/trash/validate", true, 1, recordings); RestfulService* recordingTrash = new RestfulService("/recordings/trash", true, 1, recordings); @@ -88,6 +90,7 @@ void cServerThread::Action(void) services->appendService(recordingsCut); services->appendService(recordingsMarks); services->appendService(recordingMovePreview); + services->appendService(recordingMoveValidate); services->appendService(recordingTrashPreview); services->appendService(recordingTrashValidate); services->appendService(recordingTrash); @@ -105,6 +108,7 @@ void cServerThread::Action(void) server->addService(std::move(*channels->Regex()), channelsService); server->addService(std::move(*events->Regex()), eventsService); server->addService(std::move(*recordingMovePreview->Regex()), recordingMovePreviewService); + server->addService(std::move(*recordingMoveValidate->Regex()), recordingMoveValidateService); server->addService(std::move(*recordingTrashPreview->Regex()), recordingTrashPreviewService); server->addService(std::move(*recordingTrashValidate->Regex()), recordingTrashValidateService); server->addService(std::move(*recordingTrash->Regex()), recordingTrashService); From 226e4dc090dbbf10a13144277b8267a7b25c3092 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 06:32:38 +0200 Subject: [PATCH 088/120] Add safe recording move executor contract --- recordingmoveexecutor.h | 44 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 recordingmoveexecutor.h diff --git a/recordingmoveexecutor.h b/recordingmoveexecutor.h new file mode 100644 index 0000000..54da427 --- /dev/null +++ b/recordingmoveexecutor.h @@ -0,0 +1,44 @@ +#ifndef __RECORDINGMOVEEXECUTOR_H +#define __RECORDINGMOVEEXECUTOR_H + +#include "recordingmoveexecution.h" + +#include + +enum class RecordingMoveExecutorStatus +{ + Moved, + AlreadyMoved, + Conflict, + Blocked, + NotFound, + Failed +}; + +struct RecordingMoveExecutorResult +{ + RecordingMoveExecutorStatus status = RecordingMoveExecutorStatus::Failed; + std::string recordingFile; + std::string targetFile; + std::string message; + RecordingMoveExecutionGateResult gate; +}; + +class RecordingMoveExecutor +{ +public: + explicit RecordingMoveExecutor(const RecordingMoveExecutionGate& gate); + + RecordingMoveExecutorResult executeNormalCase( + const std::string& recordingFile, + const std::string& targetFile, + const RecordingMutationRevision& expectedRevision, + const RecordingMutationPolicy& policy) const; + +private: + const RecordingMoveExecutionGate& gate; +}; + +const char* RecordingMoveExecutorStatusName(RecordingMoveExecutorStatus status); + +#endif From f16dfb036dc6f8ceaf6be830e6f76d00d0898a0f Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 06:33:53 +0200 Subject: [PATCH 089/120] Implement safe recording move execution --- recordingmoveexecutor.cpp | 159 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 recordingmoveexecutor.cpp diff --git a/recordingmoveexecutor.cpp b/recordingmoveexecutor.cpp new file mode 100644 index 0000000..0d9d775 --- /dev/null +++ b/recordingmoveexecutor.cpp @@ -0,0 +1,159 @@ +#include "recordingmoveexecutor.h" + +#include "changestatetracker.h" +#include "tools.h" + +#include +#include + +#include +#include +#include + +namespace { + +bool pathExists(const std::string& path) +{ + return !path.empty() && access(path.c_str(), F_OK) == 0; +} + +} + +RecordingMoveExecutor::RecordingMoveExecutor(const RecordingMoveExecutionGate& gate) + : gate(gate) +{ +} + +RecordingMoveExecutorResult RecordingMoveExecutor::executeNormalCase( + const std::string& recordingFile, + const std::string& targetFile, + const RecordingMutationRevision& expectedRevision, + const RecordingMutationPolicy& policy) const +{ + RecordingMoveExecutorResult result; + result.recordingFile = recordingFile; + result.targetFile = targetFile; + + if (!pathExists(recordingFile) && pathExists(targetFile)) { + result.status = RecordingMoveExecutorStatus::AlreadyMoved; + result.message = "Recording is already present at the requested target."; + return result; + } + + result.gate = gate.validate(recordingFile, targetFile, expectedRevision, policy); + + if (result.gate.status == RecordingMoveExecutionGateStatus::Conflict) { + result.status = RecordingMoveExecutorStatus::Conflict; + result.message = "Recording state changed after preview."; + return result; + } + + if (result.gate.status != RecordingMoveExecutionGateStatus::Ready) { + result.status = RecordingMoveExecutorStatus::Blocked; + result.message = "Recording move execution is blocked by the current state or policy."; + return result; + } + + LOCK_TIMERS_WRITE; + LOCK_RECORDINGS_WRITE; + + cRecording* recording = Recordings->GetByName(recordingFile.c_str()); + if (!recording) { + if (!pathExists(recordingFile) && pathExists(targetFile)) { + result.status = RecordingMoveExecutorStatus::AlreadyMoved; + result.message = "Recording is already present at the requested target."; + } + else { + result.status = RecordingMoveExecutorStatus::NotFound; + result.message = "Recording disappeared before execution."; + } + return result; + } + + if (pathExists(targetFile) || Recordings->GetByName(targetFile.c_str())) { + result.status = RecordingMoveExecutorStatus::Conflict; + result.message = "The recording move target already exists."; + return result; + } + + const char* nowReplaying = cReplayControl::NowReplaying(); + if (nowReplaying && std::strcmp(nowReplaying, recordingFile.c_str()) == 0) { + result.status = RecordingMoveExecutorStatus::Conflict; + result.message = "Recording started replaying after validation."; + return result; + } + + if (RecordingsHandler.GetUsage(recordingFile.c_str()) != ruNone) { + result.status = RecordingMoveExecutorStatus::Conflict; + result.message = "Recording handler usage changed after validation."; + return result; + } + + if (cRecordControls::GetRecordControl(recordingFile.c_str()) != nullptr) { + result.status = RecordingMoveExecutorStatus::Conflict; + result.message = "A local recording control became active after validation."; + return result; + } + + const cString timerIdText = GetRecordingTimerId(recordingFile.c_str()); + const char* timerId = *timerIdText; + if (timerId && *timerId) { + result.status = RecordingMoveExecutorStatus::Conflict; + result.message = "A timer association appeared after validation."; + return result; + } + + const std::string oldName = recording->FileName(); + if (!VdrExtension::MoveDirectory(oldName, targetFile, false)) { + result.status = RecordingMoveExecutorStatus::Failed; + result.message = "VDR failed to move the recording directory."; + return result; + } + + if (pathExists(oldName) || !pathExists(targetFile)) { + result.status = RecordingMoveExecutorStatus::Failed; + result.message = "Recording move postcondition verification failed."; + return result; + } + + Recordings->Del(recording); + Recordings->AddByName(targetFile.c_str()); + + const cRecording* movedRecording = Recordings->GetByName(targetFile.c_str()); + if (!movedRecording) { + result.status = RecordingMoveExecutorStatus::Failed; + result.message = "Moved recording was not registered under its target identity."; + return result; + } + + cRecordingUserCommand::InvokeCommand( + *cString::sprintf("rename \"%s\"", *strescape(oldName.c_str(), "\\\"$'")), + targetFile.c_str()); + + cVideoDiskUsage::ForceCheck(); + StateChangeTracker::UpdateRecordings(); + + result.status = RecordingMoveExecutorStatus::Moved; + result.message = "Recording moved to the requested target."; + return result; +} + +const char* RecordingMoveExecutorStatusName(RecordingMoveExecutorStatus status) +{ + switch (status) { + case RecordingMoveExecutorStatus::Moved: + return "moved"; + case RecordingMoveExecutorStatus::AlreadyMoved: + return "already-moved"; + case RecordingMoveExecutorStatus::Conflict: + return "conflict"; + case RecordingMoveExecutorStatus::Blocked: + return "blocked"; + case RecordingMoveExecutorStatus::NotFound: + return "not-found"; + case RecordingMoveExecutorStatus::Failed: + return "failed"; + } + + return "failed"; +} From aa315de43b1def2b26120de3f2b881cea4249f06 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 06:34:15 +0200 Subject: [PATCH 090/120] Add safe recording move HTTP contract --- recordingmove.h | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 recordingmove.h diff --git a/recordingmove.h b/recordingmove.h new file mode 100644 index 0000000..6b83560 --- /dev/null +++ b/recordingmove.h @@ -0,0 +1,25 @@ +#ifndef __RECORDINGMOVE_H +#define __RECORDINGMOVE_H + +#include +#include +#include + +class RecordingMoveResponder : public cxxtools::http::Responder +{ +public: + explicit RecordingMoveResponder(cxxtools::http::Service& service) + : cxxtools::http::Responder(service) + { + } + + void reply( + std::ostream& out, + cxxtools::http::Request& request, + cxxtools::http::Reply& reply) override; +}; + +typedef cxxtools::http::CachedService + RecordingMoveService; + +#endif From 710f598ab6e91ec50b064bc017ae13c0a1d45672 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 06:35:07 +0200 Subject: [PATCH 091/120] Implement safe recording move HTTP service --- recordingmove.cpp | 142 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 recordingmove.cpp diff --git a/recordingmove.cpp b/recordingmove.cpp new file mode 100644 index 0000000..7e2cbdf --- /dev/null +++ b/recordingmove.cpp @@ -0,0 +1,142 @@ +#include "recordingmove.h" + +#include "recordinganalysis.h" +#include "recordingmoveexecution.h" +#include "recordingmoveexecutor.h" +#include "recordingmutation.h" +#include "tools.h" + +#include + +#include + +namespace { + +bool parseLongLong(const std::string& value, long long& result) +{ + if (value.empty()) + return false; + + try { + std::size_t parsed = 0; + result = std::stoll(value, &parsed, 10); + return parsed == value.size(); + } + catch (...) { + return false; + } +} + +} + +void RecordingMoveResponder::reply( + std::ostream& out, + cxxtools::http::Request& request, + cxxtools::http::Reply& reply) +{ + QueryHandler::addHeader(reply); + + if (request.method() == "OPTIONS") { + reply.addHeader("Allow", "POST"); + reply.httpReturn(200, "OK"); + return; + } + + if (request.method() != "POST") { + reply.httpReturn(501, "Only POST method is supported by the /recordings/move service."); + return; + } + + QueryHandler query("/recordings/move", request); + const std::string recordingFile = query.getBodyAsString("file"); + const std::string targetFile = query.getBodyAsString("target_file"); + const std::string recordingsStateText = + query.getBodyAsString("revision_recordings_state"); + const std::string timersStateText = + query.getBodyAsString("revision_timers_state"); + + long long recordingsState = 0; + long long timersState = 0; + + if (recordingFile.empty()) { + reply.httpReturn(400, "Recording file is missing."); + return; + } + + if (targetFile.empty()) { + reply.httpReturn(400, "Recording move target file is missing."); + return; + } + + if (!parseLongLong(recordingsStateText, recordingsState) || + !parseLongLong(timersStateText, timersState)) { + reply.httpReturn(400, "Recording move revision is missing or invalid."); + return; + } + + RecordingMutationPolicy policy; + policy.allowRecordingHandlerStop = false; + policy.allowReplayStop = false; + policy.allowLocalTimerStop = false; + policy.allowRemoteTimerStop = false; + + VdrRecordingLookup recordingLookup; + VdrRecordingReplayLookup replayLookup; + VdrRecordingHandlerLookup recordingHandlerLookup; + VdrRecordingLocalTimerLookup localTimerLookup; + VdrRecordingRemoteTimerLookup remoteTimerLookup; + VdrRecordingSearchTimerLookup searchTimerLookup; + + RecordingMoveAnalyzer analyzer( + recordingLookup, + replayLookup, + recordingHandlerLookup, + localTimerLookup, + remoteTimerLookup, + searchTimerLookup); + + RecordingMutationPlanner planner; + RecordingMoveExecutionGate gate(analyzer, planner); + RecordingMoveExecutor executor(gate); + + RecordingMutationRevision expectedRevision; + expectedRevision.recordingFile = recordingFile; + expectedRevision.targetFile = targetFile; + expectedRevision.recordingsState = recordingsState; + expectedRevision.timersState = timersState; + + const RecordingMoveExecutorResult result = executor.executeNormalCase( + recordingFile, + targetFile, + expectedRevision, + policy); + + switch (result.status) { + case RecordingMoveExecutorStatus::Conflict: + reply.httpReturn(409, result.message); + return; + case RecordingMoveExecutorStatus::Blocked: + reply.httpReturn(423, result.message); + return; + case RecordingMoveExecutorStatus::NotFound: + reply.httpReturn(404, result.message); + return; + case RecordingMoveExecutorStatus::Failed: + reply.httpReturn(500, result.message); + return; + case RecordingMoveExecutorStatus::Moved: + case RecordingMoveExecutorStatus::AlreadyMoved: + break; + } + + reply.addHeader("Content-Type", "application/json; charset=utf-8"); + + cxxtools::JsonSerializer serializer(out); + serializer.serialize( + std::string(RecordingMoveExecutorStatusName(result.status)), + "status"); + serializer.serialize(result.recordingFile, "recording_file"); + serializer.serialize(result.targetFile, "target_file"); + serializer.serialize(result.message, "message"); + serializer.finish(); +} From cd81910565d4bc9199c18fb0ce6f1da4ff1affff Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 06:37:18 +0200 Subject: [PATCH 092/120] Wire safe recording move execution build --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 9a2701d..85d4ccc 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ LIBS += $(shell pkg-config --libs Magick++) CXXFLAGS += -DUSE_LIBMAGICKPLUSPLUS endif -OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingmoveanalysis.o recordingpreflight.o recordingmovepreflight.o recordingpreview.o recordingmovepreview.o recordingexecution.o recordingmoveexecution.o recordingvalidate.o recordingmovevalidate.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o +OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingmoveanalysis.o recordingpreflight.o recordingmovepreflight.o recordingpreview.o recordingmovepreview.o recordingexecution.o recordingmoveexecution.o recordingvalidate.o recordingmovevalidate.o recordingmoveexecutor.o recordingmove.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o CFGS = API.html all: $(SOFILE) i18n From f2d1faafc919a4e4df4f310e2067e5a786c8e3ff Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 06:38:03 +0200 Subject: [PATCH 093/120] Register safe recording move service header --- serverthread.h | 1 + 1 file changed, 1 insertion(+) diff --git a/serverthread.h b/serverthread.h index 39427dc..477c272 100644 --- a/serverthread.h +++ b/serverthread.h @@ -20,6 +20,7 @@ #include "recordingmovepreview.h" #include "recordingvalidate.h" #include "recordingmovevalidate.h" +#include "recordingmove.h" #include "recordingtrash.h" #include "remote.h" #include "timers.h" From 66881eddb3e923e8b498d4591a5b9c18a3bb2cb6 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 06:39:50 +0200 Subject: [PATCH 094/120] Register safe recording move HTTP route --- serverthread.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/serverthread.cpp b/serverthread.cpp index 8a301b4..2f12147 100644 --- a/serverthread.cpp +++ b/serverthread.cpp @@ -42,6 +42,7 @@ void cServerThread::Action(void) RecordingsService recordingsService; RecordingMovePreviewService recordingMovePreviewService; RecordingMoveValidateService recordingMoveValidateService; + RecordingMoveService recordingMoveService; RecordingTrashPreviewService recordingTrashPreviewService; RecordingTrashValidateService recordingTrashValidateService; RecordingTrashService recordingTrashService; @@ -66,6 +67,7 @@ void cServerThread::Action(void) RestfulService* recordingsMarks = new RestfulService("/recordings/marks", true, 1, recordings); RestfulService* recordingMovePreview = new RestfulService("/recordings/move/preview", true, 1, recordings); RestfulService* recordingMoveValidate = new RestfulService("/recordings/move/validate", true, 1, recordings); + RestfulService* recordingMove = new RestfulService("/recordings/move", true, 1, recordings); RestfulService* recordingTrashPreview = new RestfulService("/recordings/trash/preview", true, 1, recordings); RestfulService* recordingTrashValidate = new RestfulService("/recordings/trash/validate", true, 1, recordings); RestfulService* recordingTrash = new RestfulService("/recordings/trash", true, 1, recordings); @@ -91,6 +93,7 @@ void cServerThread::Action(void) services->appendService(recordingsMarks); services->appendService(recordingMovePreview); services->appendService(recordingMoveValidate); + services->appendService(recordingMove); services->appendService(recordingTrashPreview); services->appendService(recordingTrashValidate); services->appendService(recordingTrash); @@ -109,6 +112,7 @@ void cServerThread::Action(void) server->addService(std::move(*events->Regex()), eventsService); server->addService(std::move(*recordingMovePreview->Regex()), recordingMovePreviewService); server->addService(std::move(*recordingMoveValidate->Regex()), recordingMoveValidateService); + server->addService(std::move(*recordingMove->Regex()), recordingMoveService); server->addService(std::move(*recordingTrashPreview->Regex()), recordingTrashPreviewService); server->addService(std::move(*recordingTrashValidate->Regex()), recordingTrashValidateService); server->addService(std::move(*recordingTrash->Regex()), recordingTrashService); From 3a9727dddf7163c944323502765a001d7bd16c9e Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 10:29:55 +0200 Subject: [PATCH 095/120] Document safe native recording move API --- RECORDING_MOVE_API.md | 198 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 RECORDING_MOVE_API.md diff --git a/RECORDING_MOVE_API.md b/RECORDING_MOVE_API.md new file mode 100644 index 0000000..b1009be --- /dev/null +++ b/RECORDING_MOVE_API.md @@ -0,0 +1,198 @@ +# Safe Native Recording Move API + +This document describes the safe recording move workflow implemented by the RESTfulAPI plugin. + +The implementation follows native VDR behavior. It does not stop playback, stop recordings, modify timers, cancel recording handler operations, or bypass VDR recording list updates. + +## Workflow + +Recording move uses a three-step optimistic-locking workflow: + +1. Preview the operation and obtain state fingerprints. +2. Optionally validate that the fingerprints are still current. +3. Execute the operation with the confirmed fingerprints. + +Preview and validation are read-only. They do not modify the recording, timers, playback state, filesystem, or VDR recording lists. + +## Path contract + +Both `file` and `target_file` are complete native VDR filesystem identities. + +Example: + +```text +/srv/vdr/video/Example/2026-07-14.08.53.3-0.rec +``` + +The target must be absolute, must differ from the source, and must not already exist in the filesystem or VDR recording list. + +## Preview + +```http +POST /recordings/move/preview.json +Content-Type: application/json +``` + +Request: + +```json +{ + "file": "/srv/vdr/video/Example/2026-07-14.08.53.3-0.rec", + "target_file": "/srv/vdr/video/Archive/Example/2026-07-14.08.53.3-0.rec" +} +``` + +Executable response: + +```json +{ + "executable": true, + "recording_file": "/srv/vdr/video/Example/2026-07-14.08.53.3-0.rec", + "target_file": "/srv/vdr/video/Archive/Example/2026-07-14.08.53.3-0.rec", + "constraints": [], + "blockers": [], + "warnings": [], + "steps": [ + "move-recording", + "refresh-recordings", + "notify-change" + ], + "revision_recording_file": "/srv/vdr/video/Example/2026-07-14.08.53.3-0.rec", + "revision_target_file": "/srv/vdr/video/Archive/Example/2026-07-14.08.53.3-0.rec", + "revision_recordings_state": 123456789, + "revision_timers_state": 987654321 +} +``` + +## Validate + +```http +POST /recordings/move/validate.json +Content-Type: application/json +``` + +Request: + +```json +{ + "file": "/srv/vdr/video/Example/2026-07-14.08.53.3-0.rec", + "target_file": "/srv/vdr/video/Archive/Example/2026-07-14.08.53.3-0.rec", + "revision_recordings_state": "123456789", + "revision_timers_state": "987654321" +} +``` + +Validation status values: + +- `ready`: source, target, recording state, and timer state still match the preview revision. +- `conflict`: source, target, replay, handler, or timer state changed after preview. +- `blocked`: the current state or policy does not allow execution. + +## Execute + +```http +POST /recordings/move.json +Content-Type: application/json +``` + +Request: + +```json +{ + "file": "/srv/vdr/video/Example/2026-07-14.08.53.3-0.rec", + "target_file": "/srv/vdr/video/Archive/Example/2026-07-14.08.53.3-0.rec", + "revision_recordings_state": "123456789", + "revision_timers_state": "987654321" +} +``` + +Successful response: + +```json +{ + "status": "moved", + "recording_file": "/srv/vdr/video/Example/2026-07-14.08.53.3-0.rec", + "target_file": "/srv/vdr/video/Archive/Example/2026-07-14.08.53.3-0.rec", + "message": "Recording moved to the requested target." +} +``` + +Idempotent retry after the source has already been moved to the requested target: + +```json +{ + "status": "already-moved", + "recording_file": "/srv/vdr/video/Example/2026-07-14.08.53.3-0.rec", + "target_file": "/srv/vdr/video/Archive/Example/2026-07-14.08.53.3-0.rec", + "message": "Recording is already present at the requested target." +} +``` + +## Protected states + +The operation is blocked when safety cannot be established or VDR is actively using the recording: + +- `replay-active` +- `recording-handler-busy` +- `local-timer-active` +- `remote-timer-active` +- `unknown-recording-handler-state` +- `unknown-local-timer-state` +- `unknown-remote-timer-state` +- `unknown-search-timer-state` + +Move-specific blockers include: + +- `move-target-missing` +- `move-target-invalid` +- `move-target-same-as-source` +- `move-target-exists` + +The execution endpoint does not automatically resolve these states. + +## Execution behavior + +The executor: + +1. Revalidates the optimistic-locking revision. +2. Acquires VDR timer and recording write locks. +3. Rechecks source, target, replay, handler, recording control, and timer association. +4. Uses the native VDR directory move helper. +5. Verifies that the source disappeared and the target exists. +6. Replaces the source identity with the target identity in the VDR recording list. +7. Runs the native `recordingaction rename` hook. +8. Forces video disk usage refresh and recording change notification. + +## HTTP status codes + +| Status | Meaning | +|---|---| +| `200` | The recording was moved or is already present at the requested target. | +| `400` | Source, target, or required revision values are missing or invalid. | +| `404` | The source recording disappeared before execution. | +| `409` | Source, target, recording, replay, handler, or timer state changed after preview. | +| `423` | The operation is blocked by the current VDR state or policy. | +| `500` | The native VDR mutation or postcondition verification failed. | +| `501` | The requested HTTP method is not supported. | + +## Verified integration behavior + +The following cases were verified against a running VDR installation on July 15, 2026: + +- preview: `200`, executable, no filesystem mutation +- validate: `200 ready`, expected and current revisions identical +- missing revision: `400` +- stale revision: `409` +- unsupported method: `501` +- completed 285 MB recording: `200 moved` +- source directory removed and target directory present +- old VDR recording identity absent and new identity present +- native directory creation and rename logged by VDR +- native `vdr-recordingaction rename` hook executed +- immediate identical retry: `200 already-moved` +- reverse move through the same preview, validate, and execute workflow: `200 moved` +- final VDR recording list restored to the original identity + +## Design boundary + +This API moves a recording to one explicit native VDR filesystem identity. It is not a generic file manager, does not copy recordings, and does not silently stop active VDR operations. \ No newline at end of file From e0a897b4d9048cf5a50256a4d89f3d3743705e9f Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 12:03:23 +0200 Subject: [PATCH 096/120] Add recording rename plan contract --- recordingrenameplan.h | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 recordingrenameplan.h diff --git a/recordingrenameplan.h b/recordingrenameplan.h new file mode 100644 index 0000000..4d17b39 --- /dev/null +++ b/recordingrenameplan.h @@ -0,0 +1,39 @@ +#ifndef __RECORDINGRENAMEPLAN_H +#define __RECORDINGRENAMEPLAN_H + +#include + +enum class RecordingRenamePlanStatus +{ + Ready, + SourceInvalid, + NameMissing, + NameInvalid, + TargetSameAsSource +}; + +struct RecordingRenamePlan +{ + RecordingRenamePlanStatus status = RecordingRenamePlanStatus::SourceInvalid; + std::string recordingFile; + std::string requestedName; + std::string targetFile; + + bool executable() const + { + return status == RecordingRenamePlanStatus::Ready; + } +}; + +class RecordingRenamePlanner +{ +public: + RecordingRenamePlan build( + const std::string& recordingFile, + const std::string& requestedName) const; +}; + +const char* RecordingRenamePlanStatusName( + RecordingRenamePlanStatus status); + +#endif From cb0295b4ff50d3bf683d37d7e95b5fe082954a21 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 12:04:04 +0200 Subject: [PATCH 097/120] Implement recording rename planning --- recordingrenameplan.cpp | 126 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 recordingrenameplan.cpp diff --git a/recordingrenameplan.cpp b/recordingrenameplan.cpp new file mode 100644 index 0000000..c36373e --- /dev/null +++ b/recordingrenameplan.cpp @@ -0,0 +1,126 @@ +#include "recordingrenameplan.h" + +#include +#include + +namespace { + +std::string trim(const std::string& value) +{ + const auto first = std::find_if_not( + value.begin(), + value.end(), + [](unsigned char character) { + return std::isspace(character) != 0; + }); + + if (first == value.end()) + return std::string(); + + const auto last = std::find_if_not( + value.rbegin(), + value.rend(), + [](unsigned char character) { + return std::isspace(character) != 0; + }).base(); + + return std::string(first, last); +} + +bool hasInvalidNameCharacter(const std::string& value) +{ + for (const unsigned char character : value) { + if (character == '/' || character == '\\' || character < 0x20 || + character == 0x7f) + return true; + } + + return false; +} + +bool endsWith(const std::string& value, const std::string& suffix) +{ + return value.size() >= suffix.size() && + value.compare(value.size() - suffix.size(), suffix.size(), suffix) == 0; +} + +} + +RecordingRenamePlan RecordingRenamePlanner::build( + const std::string& recordingFile, + const std::string& requestedName) const +{ + RecordingRenamePlan plan; + plan.recordingFile = recordingFile; + plan.requestedName = trim(requestedName); + + if (recordingFile.empty() || recordingFile.front() != '/' || + !endsWith(recordingFile, ".rec")) { + plan.status = RecordingRenamePlanStatus::SourceInvalid; + return plan; + } + + if (plan.requestedName.empty()) { + plan.status = RecordingRenamePlanStatus::NameMissing; + return plan; + } + + if (plan.requestedName == "." || plan.requestedName == ".." || + hasInvalidNameCharacter(plan.requestedName)) { + plan.status = RecordingRenamePlanStatus::NameInvalid; + return plan; + } + + const std::string::size_type timestampSeparator = recordingFile.rfind('/'); + if (timestampSeparator == std::string::npos || timestampSeparator == 0 || + timestampSeparator + 1 >= recordingFile.size()) { + plan.status = RecordingRenamePlanStatus::SourceInvalid; + return plan; + } + + const std::string timestampDirectory = + recordingFile.substr(timestampSeparator + 1); + const std::string titlePath = recordingFile.substr(0, timestampSeparator); + const std::string::size_type titleSeparator = titlePath.rfind('/'); + + if (titleSeparator == std::string::npos) { + plan.status = RecordingRenamePlanStatus::SourceInvalid; + return plan; + } + + const std::string parentPath = titlePath.substr(0, titleSeparator); + if (parentPath.empty()) { + plan.targetFile = "/" + plan.requestedName + "/" + timestampDirectory; + } + else { + plan.targetFile = + parentPath + "/" + plan.requestedName + "/" + timestampDirectory; + } + + if (plan.targetFile == recordingFile) { + plan.status = RecordingRenamePlanStatus::TargetSameAsSource; + return plan; + } + + plan.status = RecordingRenamePlanStatus::Ready; + return plan; +} + +const char* RecordingRenamePlanStatusName( + RecordingRenamePlanStatus status) +{ + switch (status) { + case RecordingRenamePlanStatus::Ready: + return "ready"; + case RecordingRenamePlanStatus::SourceInvalid: + return "source-invalid"; + case RecordingRenamePlanStatus::NameMissing: + return "name-missing"; + case RecordingRenamePlanStatus::NameInvalid: + return "name-invalid"; + case RecordingRenamePlanStatus::TargetSameAsSource: + return "target-same-as-source"; + } + + return "unknown"; +} From 7603423b07ce91a6ad63d77e24382cc40931decf Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 12:04:43 +0200 Subject: [PATCH 098/120] Test recording rename planning --- tests/test_recording_rename_plan.cpp | 76 ++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 tests/test_recording_rename_plan.cpp diff --git a/tests/test_recording_rename_plan.cpp b/tests/test_recording_rename_plan.cpp new file mode 100644 index 0000000..5174c04 --- /dev/null +++ b/tests/test_recording_rename_plan.cpp @@ -0,0 +1,76 @@ +#include "../recordingrenameplan.h" + +#include +#include + +int main() +{ + RecordingRenamePlanner planner; + + const std::string source = + "/srv/vdr/video/Hutehiermorgenda/" + "VDR-SUITE-TEST_heute_journal4/" + "2026-07-08.21.45.2-0.rec"; + + const RecordingRenamePlan ready = + planner.build(source, "heute journal Testaufnahme"); + + assert(ready.executable()); + assert(ready.status == RecordingRenamePlanStatus::Ready); + assert(ready.recordingFile == source); + assert(ready.requestedName == "heute journal Testaufnahme"); + assert( + ready.targetFile == + "/srv/vdr/video/Hutehiermorgenda/" + "heute journal Testaufnahme/" + "2026-07-08.21.45.2-0.rec"); + + const RecordingRenamePlan trimmed = + planner.build(source, " Neuer Titel "); + assert(trimmed.executable()); + assert(trimmed.requestedName == "Neuer Titel"); + + const RecordingRenamePlan missingName = planner.build(source, " "); + assert(!missingName.executable()); + assert(missingName.status == RecordingRenamePlanStatus::NameMissing); + + const RecordingRenamePlan slash = planner.build(source, "Serie/Folge"); + assert(!slash.executable()); + assert(slash.status == RecordingRenamePlanStatus::NameInvalid); + + const RecordingRenamePlan backslash = planner.build(source, "Serie\\Folge"); + assert(!backslash.executable()); + assert(backslash.status == RecordingRenamePlanStatus::NameInvalid); + + const RecordingRenamePlan dot = planner.build(source, "."); + assert(!dot.executable()); + assert(dot.status == RecordingRenamePlanStatus::NameInvalid); + + const RecordingRenamePlan dotDot = planner.build(source, ".."); + assert(!dotDot.executable()); + assert(dotDot.status == RecordingRenamePlanStatus::NameInvalid); + + const RecordingRenamePlan same = + planner.build(source, "VDR-SUITE-TEST_heute_journal4"); + assert(!same.executable()); + assert(same.status == RecordingRenamePlanStatus::TargetSameAsSource); + + const RecordingRenamePlan relativeSource = + planner.build("relative/2026-07-08.21.45.2-0.rec", "Neuer Titel"); + assert(!relativeSource.executable()); + assert(relativeSource.status == RecordingRenamePlanStatus::SourceInvalid); + + const RecordingRenamePlan deletedSource = + planner.build( + "/srv/vdr/video/Alt/2026-07-08.21.45.2-0.del", + "Neuer Titel"); + assert(!deletedSource.executable()); + assert(deletedSource.status == RecordingRenamePlanStatus::SourceInvalid); + + assert( + std::string(RecordingRenamePlanStatusName( + RecordingRenamePlanStatus::NameInvalid)) == + "name-invalid"); + + return 0; +} From 558a04e3a3c1b01831d1829f5b65caf68127720f Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 12:05:50 +0200 Subject: [PATCH 099/120] Wire recording rename planner test --- Makefile | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 85d4ccc..9e6ec6f 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ LIBS += $(shell pkg-config --libs Magick++) CXXFLAGS += -DUSE_LIBMAGICKPLUSPLUS endif -OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingmoveanalysis.o recordingpreflight.o recordingmovepreflight.o recordingpreview.o recordingmovepreview.o recordingexecution.o recordingmoveexecution.o recordingvalidate.o recordingmovevalidate.o recordingmoveexecutor.o recordingmove.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o +OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingmoveanalysis.o recordingrenameplan.o recordingpreflight.o recordingmovepreflight.o recordingpreview.o recordingmovepreview.o recordingexecution.o recordingmoveexecution.o recordingvalidate.o recordingmovevalidate.o recordingmoveexecutor.o recordingmove.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o CFGS = API.html all: $(SOFILE) i18n @@ -72,7 +72,7 @@ $(I18Npot): $(wildcard *.cpp) $(I18Nmsgs): $(DESTDIR)$(LOCDIR)/%/LC_MESSAGES/vdr-$(PLUGIN).mo: $(PODIR)/%.mo install -D -m644 $< $@ -.PHONY: i18n test-recording-move-plan test-recording-move-analysis test-recording-move-preflight test-recording-move-execution-gate +.PHONY: i18n test-recording-move-plan test-recording-move-analysis test-recording-move-preflight test-recording-move-execution-gate test-recording-rename-plan i18n: $(I18Nmo) $(I18Npot) test-recording-move-plan: @@ -108,6 +108,13 @@ test-recording-move-execution-gate: -o /tmp/test_recording_move_execution_gate /tmp/test_recording_move_execution_gate +test-recording-rename-plan: + $(CXX) -std=c++17 -Wall -Wextra \ + recordingrenameplan.cpp \ + tests/test_recording_rename_plan.cpp \ + -o /tmp/test_recording_rename_plan + /tmp/test_recording_rename_plan + install-i18n: $(I18Nmsgs) $(SOFILE): $(OBJS) From 2f67c3df829d7137f0185ed718fae497fbbbd320 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 12:12:42 +0200 Subject: [PATCH 100/120] Add recording rename preflight contract --- recordingrenamepreflight.h | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 recordingrenamepreflight.h diff --git a/recordingrenamepreflight.h b/recordingrenamepreflight.h new file mode 100644 index 0000000..9cde34a --- /dev/null +++ b/recordingrenamepreflight.h @@ -0,0 +1,43 @@ +#ifndef __RECORDINGRENAMEPREFLIGHT_H +#define __RECORDINGRENAMEPREFLIGHT_H + +#include "recordingpreflight.h" +#include "recordingrenameplan.h" + +#include +#include + +struct RecordingRenamePreflightResult +{ + bool executable = false; + RecordingRenamePlanStatus renameStatus = RecordingRenamePlanStatus::SourceInvalid; + std::string recordingFile; + std::string requestedName; + std::string targetFile; + std::vector constraints; + std::vector blockers; + std::vector warnings; + std::vector steps; + RecordingMutationRevision revision; +}; + +class RecordingRenamePreflightService +{ +public: + RecordingRenamePreflightService( + const RecordingRenamePlanner& renamePlanner, + const RecordingMoveAnalyzer& moveAnalyzer, + const RecordingMutationPlanner& mutationPlanner); + + RecordingRenamePreflightResult preview( + const std::string& recordingFile, + const std::string& requestedName, + const RecordingMutationPolicy& policy) const; + +private: + const RecordingRenamePlanner& renamePlanner; + const RecordingMoveAnalyzer& moveAnalyzer; + const RecordingMutationPlanner& mutationPlanner; +}; + +#endif From 3722d681cef033c39dfb8452fa4f8f23393a7a76 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 12:13:17 +0200 Subject: [PATCH 101/120] Implement recording rename preflight service --- recordingrenamepreflight.cpp | 67 ++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 recordingrenamepreflight.cpp diff --git a/recordingrenamepreflight.cpp b/recordingrenamepreflight.cpp new file mode 100644 index 0000000..62f606f --- /dev/null +++ b/recordingrenamepreflight.cpp @@ -0,0 +1,67 @@ +#include "recordingrenamepreflight.h" + +namespace { + +void appendMovePreflight( + RecordingRenamePreflightResult& result, + const RecordingMutationAnalysis& analysis, + const RecordingMutationPlan& plan) +{ + result.executable = plan.executable; + result.targetFile = analysis.targetFile; + result.warnings = plan.warnings; + result.revision = plan.expectedRevision; + + for (const RecordingConstraint constraint : analysis.constraints) + result.constraints.push_back(RecordingConstraintName(constraint)); + + for (const RecordingConstraint blocker : plan.blockers) + result.blockers.push_back(RecordingConstraintName(blocker)); + + for (const RecordingMutationStep step : plan.steps) + result.steps.push_back(RecordingMutationStepName(step)); +} + +} + +RecordingRenamePreflightService::RecordingRenamePreflightService( + const RecordingRenamePlanner& renamePlanner, + const RecordingMoveAnalyzer& moveAnalyzer, + const RecordingMutationPlanner& mutationPlanner) + : renamePlanner(renamePlanner), + moveAnalyzer(moveAnalyzer), + mutationPlanner(mutationPlanner) +{ +} + +RecordingRenamePreflightResult RecordingRenamePreflightService::preview( + const std::string& recordingFile, + const std::string& requestedName, + const RecordingMutationPolicy& policy) const +{ + RecordingRenamePreflightResult result; + const RecordingRenamePlan renamePlan = renamePlanner.build( + recordingFile, + requestedName); + + result.renameStatus = renamePlan.status; + result.recordingFile = renamePlan.recordingFile; + result.requestedName = renamePlan.requestedName; + result.targetFile = renamePlan.targetFile; + + if (!renamePlan.executable()) { + const std::string status = RecordingRenamePlanStatusName(renamePlan.status); + result.constraints.push_back(status); + result.blockers.push_back(status); + return result; + } + + const RecordingMutationAnalysis analysis = moveAnalyzer.analyze( + renamePlan.recordingFile, + renamePlan.targetFile); + const RecordingMutationPlan mutationPlan = mutationPlanner.buildMovePlan( + analysis, + policy); + appendMovePreflight(result, analysis, mutationPlan); + return result; +} From 2174ac4f1aa47711e76c95cf40f4f1d09c289fe2 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 12:14:09 +0200 Subject: [PATCH 102/120] Test recording rename preflight service --- tests/test_recording_rename_preflight.cpp | 174 ++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 tests/test_recording_rename_preflight.cpp diff --git a/tests/test_recording_rename_preflight.cpp b/tests/test_recording_rename_preflight.cpp new file mode 100644 index 0000000..1f510af --- /dev/null +++ b/tests/test_recording_rename_preflight.cpp @@ -0,0 +1,174 @@ +#include "../recordingrenamepreflight.h" + +#include +#include +#include +#include + +namespace { + +class FakeRecordingLookup : public IRecordingLookup +{ +public: + mutable int calls = 0; + std::map results; + + RecordingLookupResult find(const std::string& recordingFile) const override + { + ++calls; + const auto result = results.find(recordingFile); + return result == results.end() ? RecordingLookupResult() : result->second; + } +}; + +class FakeReplayLookup : public IRecordingReplayLookup +{ +public: + bool replaying = false; + + bool isReplaying(const std::string&) const override + { + return replaying; + } +}; + +class FakeHandlerLookup : public IRecordingHandlerLookup +{ +public: + RecordingHandlerLookupResult result{true, false}; + + RecordingHandlerLookupResult getUsage(const std::string&) const override + { + return result; + } +}; + +class FakeLocalTimerLookup : public IRecordingLocalTimerLookup +{ +public: + RecordingLocalTimerLookupResult result{true, false}; + + RecordingLocalTimerLookupResult findActive(const std::string&) const override + { + return result; + } +}; + +class FakeRemoteTimerLookup : public IRecordingRemoteTimerLookup +{ +public: + RecordingRemoteTimerLookupResult result{true, false, 0, ""}; + + RecordingRemoteTimerLookupResult findActive(const std::string&) const override + { + return result; + } +}; + +class FakeSearchTimerLookup : public IRecordingSearchTimerLookup +{ +public: + RecordingSearchTimerLookupResult result{true, false, -1}; + + RecordingSearchTimerLookupResult findOrigin(const std::string&) const override + { + return result; + } +}; + +bool contains(const std::vector& values, const std::string& value) +{ + return std::find(values.begin(), values.end(), value) != values.end(); +} + +} + +int main() +{ + const std::string source = + "/srv/vdr/video/Hutehiermorgenda/Old title/2026-07-08.21.45.2-0.rec"; + const std::string target = + "/srv/vdr/video/Hutehiermorgenda/New title/2026-07-08.21.45.2-0.rec"; + + FakeRecordingLookup recordingLookup; + recordingLookup.results[source] = {true, source}; + FakeReplayLookup replayLookup; + FakeHandlerLookup handlerLookup; + FakeLocalTimerLookup localTimerLookup; + FakeRemoteTimerLookup remoteTimerLookup; + FakeSearchTimerLookup searchTimerLookup; + + RecordingRenamePlanner renamePlanner; + RecordingMoveAnalyzer moveAnalyzer( + recordingLookup, + replayLookup, + handlerLookup, + localTimerLookup, + remoteTimerLookup, + searchTimerLookup); + RecordingMutationPlanner mutationPlanner; + RecordingRenamePreflightService service( + renamePlanner, + moveAnalyzer, + mutationPlanner); + RecordingMutationPolicy policy; + + const RecordingRenamePreflightResult ready = service.preview( + source, + " New title ", + policy); + assert(ready.executable); + assert(ready.renameStatus == RecordingRenamePlanStatus::Ready); + assert(ready.recordingFile == source); + assert(ready.requestedName == "New title"); + assert(ready.targetFile == target); + assert(ready.constraints.empty()); + assert(ready.blockers.empty()); + assert(contains(ready.steps, "move-recording")); + assert(contains(ready.steps, "refresh-recordings")); + assert(contains(ready.steps, "notify-change")); + assert(ready.revision.recordingFile == source); + assert(ready.revision.targetFile == target); + assert(ready.revision.recordingsState != 0); + assert(ready.revision.timersState != 0); + + const int callsAfterReady = recordingLookup.calls; + const RecordingRenamePreflightResult invalidName = service.preview( + source, + "../Other", + policy); + assert(!invalidName.executable); + assert(invalidName.renameStatus == RecordingRenamePlanStatus::NameInvalid); + assert(contains(invalidName.constraints, "rename-name-invalid")); + assert(contains(invalidName.blockers, "rename-name-invalid")); + assert(recordingLookup.calls == callsAfterReady); + + recordingLookup.results[target] = {true, target}; + const RecordingRenamePreflightResult collision = service.preview( + source, + "New title", + policy); + assert(!collision.executable); + assert(contains(collision.constraints, "move-target-exists")); + assert(contains(collision.blockers, "move-target-exists")); + + recordingLookup.results.erase(target); + replayLookup.replaying = true; + const RecordingRenamePreflightResult replayBlocked = service.preview( + source, + "Another title", + policy); + assert(!replayBlocked.executable); + assert(contains(replayBlocked.constraints, "replay-active")); + assert(contains(replayBlocked.blockers, "replay-active")); + + const RecordingRenamePreflightResult sameName = service.preview( + source, + "Old title", + policy); + assert(!sameName.executable); + assert(sameName.renameStatus == RecordingRenamePlanStatus::TargetSameAsSource); + assert(contains(sameName.blockers, "rename-target-same-as-source")); + + return 0; +} From e295b80117825551ebbca0cbebc93fa362e7dbdb Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 12:15:26 +0200 Subject: [PATCH 103/120] Wire recording rename preflight test --- Makefile | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 9e6ec6f..3046f1e 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ LIBS += $(shell pkg-config --libs Magick++) CXXFLAGS += -DUSE_LIBMAGICKPLUSPLUS endif -OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingmoveanalysis.o recordingrenameplan.o recordingpreflight.o recordingmovepreflight.o recordingpreview.o recordingmovepreview.o recordingexecution.o recordingmoveexecution.o recordingvalidate.o recordingmovevalidate.o recordingmoveexecutor.o recordingmove.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o +OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingmoveanalysis.o recordingrenameplan.o recordingrenamepreflight.o recordingpreflight.o recordingmovepreflight.o recordingpreview.o recordingmovepreview.o recordingexecution.o recordingmoveexecution.o recordingvalidate.o recordingmovevalidate.o recordingmoveexecutor.o recordingmove.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o CFGS = API.html all: $(SOFILE) i18n @@ -72,7 +72,7 @@ $(I18Npot): $(wildcard *.cpp) $(I18Nmsgs): $(DESTDIR)$(LOCDIR)/%/LC_MESSAGES/vdr-$(PLUGIN).mo: $(PODIR)/%.mo install -D -m644 $< $@ -.PHONY: i18n test-recording-move-plan test-recording-move-analysis test-recording-move-preflight test-recording-move-execution-gate test-recording-rename-plan +.PHONY: i18n test-recording-move-plan test-recording-move-analysis test-recording-move-preflight test-recording-move-execution-gate test-recording-rename-plan test-recording-rename-preflight i18n: $(I18Nmo) $(I18Npot) test-recording-move-plan: @@ -115,6 +115,16 @@ test-recording-rename-plan: -o /tmp/test_recording_rename_plan /tmp/test_recording_rename_plan +test-recording-rename-preflight: + $(CXX) -std=c++17 -Wall -Wextra \ + recordingmutation.cpp \ + recordingmoveanalysis.cpp \ + recordingrenameplan.cpp \ + recordingrenamepreflight.cpp \ + tests/test_recording_rename_preflight.cpp \ + -o /tmp/test_recording_rename_preflight + /tmp/test_recording_rename_preflight + install-i18n: $(I18Nmsgs) $(SOFILE): $(OBJS) From 3666803ea57ffbcb7ddba70bf698a85ffe2ebb93 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 12:22:04 +0200 Subject: [PATCH 104/120] Fix recording rename preflight constraint names --- recordingrenamepreflight.cpp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/recordingrenamepreflight.cpp b/recordingrenamepreflight.cpp index 62f606f..465180f 100644 --- a/recordingrenamepreflight.cpp +++ b/recordingrenamepreflight.cpp @@ -22,6 +22,11 @@ void appendMovePreflight( result.steps.push_back(RecordingMutationStepName(step)); } +std::string renameConstraintName(RecordingRenamePlanStatus status) +{ + return std::string("rename-") + RecordingRenamePlanStatusName(status); +} + } RecordingRenamePreflightService::RecordingRenamePreflightService( @@ -50,9 +55,9 @@ RecordingRenamePreflightResult RecordingRenamePreflightService::preview( result.targetFile = renamePlan.targetFile; if (!renamePlan.executable()) { - const std::string status = RecordingRenamePlanStatusName(renamePlan.status); - result.constraints.push_back(status); - result.blockers.push_back(status); + const std::string constraint = renameConstraintName(renamePlan.status); + result.constraints.push_back(constraint); + result.blockers.push_back(constraint); return result; } From 10a8d3a97e0d374b168faa11a13c9cffc66940c4 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 12:53:05 +0200 Subject: [PATCH 105/120] Add recording rename preview HTTP contract --- recordingrenamepreview.h | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 recordingrenamepreview.h diff --git a/recordingrenamepreview.h b/recordingrenamepreview.h new file mode 100644 index 0000000..8adabf3 --- /dev/null +++ b/recordingrenamepreview.h @@ -0,0 +1,25 @@ +#ifndef __RECORDINGRENAMEPREVIEW_H +#define __RECORDINGRENAMEPREVIEW_H + +#include +#include +#include + +class RecordingRenamePreviewResponder : public cxxtools::http::Responder +{ +public: + explicit RecordingRenamePreviewResponder(cxxtools::http::Service& service) + : cxxtools::http::Responder(service) + { + } + + void reply( + std::ostream& out, + cxxtools::http::Request& request, + cxxtools::http::Reply& reply) override; +}; + +typedef cxxtools::http::CachedService + RecordingRenamePreviewService; + +#endif From d50a4bb5ccf1951819f7c82b76a5107d480ea1e7 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 12:54:00 +0200 Subject: [PATCH 106/120] Implement recording rename preview HTTP service --- recordingrenamepreview.cpp | 98 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 recordingrenamepreview.cpp diff --git a/recordingrenamepreview.cpp b/recordingrenamepreview.cpp new file mode 100644 index 0000000..6843681 --- /dev/null +++ b/recordingrenamepreview.cpp @@ -0,0 +1,98 @@ +#include "recordingrenamepreview.h" + +#include "recordinganalysis.h" +#include "recordingmutation.h" +#include "recordingrenameplan.h" +#include "recordingrenamepreflight.h" +#include "tools.h" + +#include + +void RecordingRenamePreviewResponder::reply( + std::ostream& out, + cxxtools::http::Request& request, + cxxtools::http::Reply& reply) +{ + QueryHandler::addHeader(reply); + + if (request.method() == "OPTIONS") { + reply.addHeader("Allow", "POST"); + reply.httpReturn(200, "OK"); + return; + } + + if (request.method() != "POST") { + reply.httpReturn( + 501, + "Only POST method is supported by the /recordings/rename/preview service."); + return; + } + + QueryHandler query("/recordings/rename/preview", request); + const std::string recordingFile = query.getBodyAsString("file"); + const std::string requestedName = query.getBodyAsString("name"); + + if (recordingFile.empty()) { + reply.httpReturn(400, "Recording file is missing."); + return; + } + + if (requestedName.empty()) { + reply.httpReturn(400, "Recording rename name is missing."); + return; + } + + RecordingMutationPolicy policy; + policy.allowRecordingHandlerStop = + query.getBodyAsBool("allow_recording_handler_stop"); + policy.allowReplayStop = query.getBodyAsBool("allow_replay_stop"); + policy.allowLocalTimerStop = query.getBodyAsBool("allow_local_timer_stop"); + policy.allowRemoteTimerStop = query.getBodyAsBool("allow_remote_timer_stop"); + + VdrRecordingLookup recordingLookup; + VdrRecordingReplayLookup replayLookup; + VdrRecordingHandlerLookup recordingHandlerLookup; + VdrRecordingLocalTimerLookup localTimerLookup; + VdrRecordingRemoteTimerLookup remoteTimerLookup; + VdrRecordingSearchTimerLookup searchTimerLookup; + + RecordingMoveAnalyzer moveAnalyzer( + recordingLookup, + replayLookup, + recordingHandlerLookup, + localTimerLookup, + remoteTimerLookup, + searchTimerLookup); + + RecordingRenamePlanner renamePlanner; + RecordingMutationPlanner mutationPlanner; + RecordingRenamePreflightService preflightService( + renamePlanner, + moveAnalyzer, + mutationPlanner); + + const RecordingRenamePreflightResult result = preflightService.preview( + recordingFile, + requestedName, + policy); + + reply.addHeader("Content-Type", "application/json; charset=utf-8"); + + cxxtools::JsonSerializer serializer(out); + serializer.serialize(result.executable, "executable"); + serializer.serialize(result.recordingFile, "recording_file"); + serializer.serialize(result.requestedName, "name"); + serializer.serialize(result.targetFile, "target_file"); + serializer.serialize( + std::string(RecordingRenamePlanStatusName(result.renameStatus)), + "rename_status"); + serializer.serialize(result.constraints, "constraints"); + serializer.serialize(result.blockers, "blockers"); + serializer.serialize(result.warnings, "warnings"); + serializer.serialize(result.steps, "steps"); + serializer.serialize(result.revision.recordingFile, "revision_recording_file"); + serializer.serialize(result.revision.targetFile, "revision_target_file"); + serializer.serialize(result.revision.recordingsState, "revision_recordings_state"); + serializer.serialize(result.revision.timersState, "revision_timers_state"); + serializer.finish(); +} From e03b91a7b77bd23323542dda57fe6162a980cc7e Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 12:55:25 +0200 Subject: [PATCH 107/120] Wire recording rename preview service build --- Makefile | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 3046f1e..4031e04 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ LIBS += $(shell pkg-config --libs Magick++) CXXFLAGS += -DUSE_LIBMAGICKPLUSPLUS endif -OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingmoveanalysis.o recordingrenameplan.o recordingrenamepreflight.o recordingpreflight.o recordingmovepreflight.o recordingpreview.o recordingmovepreview.o recordingexecution.o recordingmoveexecution.o recordingvalidate.o recordingmovevalidate.o recordingmoveexecutor.o recordingmove.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o +OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingmoveanalysis.o recordingrenameplan.o recordingrenamepreflight.o recordingpreflight.o recordingmovepreflight.o recordingpreview.o recordingmovepreview.o recordingrenamepreview.o recordingexecution.o recordingmoveexecution.o recordingvalidate.o recordingmovevalidate.o recordingmoveexecutor.o recordingmove.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o CFGS = API.html all: $(SOFILE) i18n @@ -147,8 +147,4 @@ dist: $(I18Npo) clean @echo Distribution package created as $(PACKAGE).tgz clean: - @-rm -f $(PODIR)/*.mo $(PODIR)/*.pot - @-rm -f $(OBJS) $(DEPFILE) *.so *.tgz core* *~ ._* - -archive: - git archive --format=tar.gz --prefix=vdr-plugin-restfulapi-${VERSION}/ --output=../vdr-plugin-restfulapi-${VERSION}.tar.gz master + @-rm -f $(PODIR)/*.mo $(PODIR)/*.pot $(OBJS) $(DEPFILE) $(SOFILE) *~ From d7cad7d38b64700099fb7f1ea57946b731742388 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 12:55:53 +0200 Subject: [PATCH 108/120] Register recording rename preview service header --- serverthread.h | 1 + 1 file changed, 1 insertion(+) diff --git a/serverthread.h b/serverthread.h index 477c272..4c9d511 100644 --- a/serverthread.h +++ b/serverthread.h @@ -18,6 +18,7 @@ #include "recordings.h" #include "recordingpreview.h" #include "recordingmovepreview.h" +#include "recordingrenamepreview.h" #include "recordingvalidate.h" #include "recordingmovevalidate.h" #include "recordingmove.h" From 9acf18429f3b1d7ac1b67805bb712fac5c13d873 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 12:57:14 +0200 Subject: [PATCH 109/120] Register recording rename preview HTTP route --- serverthread.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/serverthread.cpp b/serverthread.cpp index 2f12147..b07e3f1 100644 --- a/serverthread.cpp +++ b/serverthread.cpp @@ -41,6 +41,7 @@ void cServerThread::Action(void) EventsService eventsService; RecordingsService recordingsService; RecordingMovePreviewService recordingMovePreviewService; + RecordingRenamePreviewService recordingRenamePreviewService; RecordingMoveValidateService recordingMoveValidateService; RecordingMoveService recordingMoveService; RecordingTrashPreviewService recordingTrashPreviewService; @@ -66,6 +67,7 @@ void cServerThread::Action(void) RestfulService* recordingsCut = new RestfulService("/recordings/cut", true, 1, recordings); RestfulService* recordingsMarks = new RestfulService("/recordings/marks", true, 1, recordings); RestfulService* recordingMovePreview = new RestfulService("/recordings/move/preview", true, 1, recordings); + RestfulService* recordingRenamePreview = new RestfulService("/recordings/rename/preview", true, 1, recordings); RestfulService* recordingMoveValidate = new RestfulService("/recordings/move/validate", true, 1, recordings); RestfulService* recordingMove = new RestfulService("/recordings/move", true, 1, recordings); RestfulService* recordingTrashPreview = new RestfulService("/recordings/trash/preview", true, 1, recordings); @@ -92,6 +94,7 @@ void cServerThread::Action(void) services->appendService(recordingsCut); services->appendService(recordingsMarks); services->appendService(recordingMovePreview); + services->appendService(recordingRenamePreview); services->appendService(recordingMoveValidate); services->appendService(recordingMove); services->appendService(recordingTrashPreview); @@ -111,6 +114,7 @@ void cServerThread::Action(void) server->addService(std::move(*channels->Regex()), channelsService); server->addService(std::move(*events->Regex()), eventsService); server->addService(std::move(*recordingMovePreview->Regex()), recordingMovePreviewService); + server->addService(std::move(*recordingRenamePreview->Regex()), recordingRenamePreviewService); server->addService(std::move(*recordingMoveValidate->Regex()), recordingMoveValidateService); server->addService(std::move(*recordingMove->Regex()), recordingMoveService); server->addService(std::move(*recordingTrashPreview->Regex()), recordingTrashPreviewService); From 0603da7354b58e4221479a39789e3ce3d83e681a Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 13:10:50 +0200 Subject: [PATCH 110/120] Add recording rename validate HTTP contract --- recordingrenamevalidate.h | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 recordingrenamevalidate.h diff --git a/recordingrenamevalidate.h b/recordingrenamevalidate.h new file mode 100644 index 0000000..6ad1e19 --- /dev/null +++ b/recordingrenamevalidate.h @@ -0,0 +1,25 @@ +#ifndef __RECORDINGRENAMEVALIDATE_H +#define __RECORDINGRENAMEVALIDATE_H + +#include +#include +#include + +class RecordingRenameValidateResponder : public cxxtools::http::Responder +{ +public: + explicit RecordingRenameValidateResponder(cxxtools::http::Service& service) + : cxxtools::http::Responder(service) + { + } + + void reply( + std::ostream& out, + cxxtools::http::Request& request, + cxxtools::http::Reply& reply) override; +}; + +typedef cxxtools::http::CachedService + RecordingRenameValidateService; + +#endif From eb5abe337f7bb61c71f800747ab276e72485eb11 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 13:12:33 +0200 Subject: [PATCH 111/120] Implement recording rename validate HTTP service --- recordingrenamevalidate.cpp | 215 ++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 recordingrenamevalidate.cpp diff --git a/recordingrenamevalidate.cpp b/recordingrenamevalidate.cpp new file mode 100644 index 0000000..3f71d2e --- /dev/null +++ b/recordingrenamevalidate.cpp @@ -0,0 +1,215 @@ +#include "recordingrenamevalidate.h" + +#include "recordinganalysis.h" +#include "recordingmoveexecution.h" +#include "recordingmutation.h" +#include "recordingrenameplan.h" +#include "tools.h" + +#include + +#include +#include + +namespace { + +bool parseLongLong(const std::string& value, long long& result) +{ + if (value.empty()) + return false; + + try { + std::size_t parsed = 0; + result = std::stoll(value, &parsed, 10); + return parsed == value.size(); + } + catch (...) { + return false; + } +} + +std::vector constraintNames( + const std::vector& constraints) +{ + std::vector names; + for (RecordingConstraint constraint : constraints) + names.push_back(RecordingConstraintName(constraint)); + return names; +} + +std::string renameConstraintName(RecordingRenamePlanStatus status) +{ + switch (status) { + case RecordingRenamePlanStatus::SourceInvalid: + return "rename-source-invalid"; + case RecordingRenamePlanStatus::NameMissing: + return "rename-name-missing"; + case RecordingRenamePlanStatus::NameInvalid: + return "rename-name-invalid"; + case RecordingRenamePlanStatus::TargetSameAsSource: + return "rename-target-same-as-source"; + case RecordingRenamePlanStatus::Ready: + return std::string(); + } + + return "rename-plan-invalid"; +} + +} + +void RecordingRenameValidateResponder::reply( + std::ostream& out, + cxxtools::http::Request& request, + cxxtools::http::Reply& reply) +{ + QueryHandler::addHeader(reply); + + if (request.method() == "OPTIONS") { + reply.addHeader("Allow", "POST"); + reply.httpReturn(200, "OK"); + return; + } + + if (request.method() != "POST") { + reply.httpReturn( + 501, + "Only POST method is supported by the /recordings/rename/validate service."); + return; + } + + QueryHandler query("/recordings/rename/validate", request); + const std::string recordingFile = query.getBodyAsString("file"); + const std::string requestedName = query.getBodyAsString("name"); + const std::string recordingsStateText = + query.getBodyAsString("revision_recordings_state"); + const std::string timersStateText = + query.getBodyAsString("revision_timers_state"); + + long long recordingsState = 0; + long long timersState = 0; + + if (recordingFile.empty()) { + reply.httpReturn(400, "Recording file is missing."); + return; + } + + if (requestedName.empty()) { + reply.httpReturn(400, "Recording rename name is missing."); + return; + } + + if (!parseLongLong(recordingsStateText, recordingsState) || + !parseLongLong(timersStateText, timersState)) { + reply.httpReturn(400, "Recording rename revision is missing or invalid."); + return; + } + + RecordingRenamePlanner renamePlanner; + const RecordingRenamePlan renamePlan = renamePlanner.build( + recordingFile, + requestedName); + + reply.addHeader("Content-Type", "application/json; charset=utf-8"); + cxxtools::JsonSerializer serializer(out); + + if (!renamePlan.executable()) { + const std::string blocker = renameConstraintName(renamePlan.status); + const std::vector blockers = blocker.empty() + ? std::vector() + : std::vector(1, blocker); + + serializer.serialize(std::string("blocked"), "status"); + serializer.serialize(recordingFile, "recording_file"); + serializer.serialize(requestedName, "name"); + serializer.serialize(renamePlan.targetFile, "target_file"); + serializer.serialize( + std::string(RecordingRenamePlanStatusName(renamePlan.status)), + "rename_status"); + serializer.serialize(blockers, "blockers"); + serializer.serialize(std::vector(), "warnings"); + serializer.serialize(recordingFile, "expected_revision_recording_file"); + serializer.serialize(renamePlan.targetFile, "expected_revision_target_file"); + serializer.serialize(recordingsState, "expected_revision_recordings_state"); + serializer.serialize(timersState, "expected_revision_timers_state"); + serializer.serialize(std::string(), "current_revision_recording_file"); + serializer.serialize(std::string(), "current_revision_target_file"); + serializer.serialize(0LL, "current_revision_recordings_state"); + serializer.serialize(0LL, "current_revision_timers_state"); + serializer.finish(); + return; + } + + RecordingMutationPolicy policy; + policy.allowRecordingHandlerStop = + query.getBodyAsBool("allow_recording_handler_stop"); + policy.allowReplayStop = query.getBodyAsBool("allow_replay_stop"); + policy.allowLocalTimerStop = query.getBodyAsBool("allow_local_timer_stop"); + policy.allowRemoteTimerStop = query.getBodyAsBool("allow_remote_timer_stop"); + + VdrRecordingLookup recordingLookup; + VdrRecordingReplayLookup replayLookup; + VdrRecordingHandlerLookup recordingHandlerLookup; + VdrRecordingLocalTimerLookup localTimerLookup; + VdrRecordingRemoteTimerLookup remoteTimerLookup; + VdrRecordingSearchTimerLookup searchTimerLookup; + + RecordingMoveAnalyzer analyzer( + recordingLookup, + replayLookup, + recordingHandlerLookup, + localTimerLookup, + remoteTimerLookup, + searchTimerLookup); + + RecordingMutationPlanner planner; + RecordingMoveExecutionGate gate(analyzer, planner); + + RecordingMutationRevision expectedRevision; + expectedRevision.recordingFile = recordingFile; + expectedRevision.targetFile = renamePlan.targetFile; + expectedRevision.recordingsState = recordingsState; + expectedRevision.timersState = timersState; + + const RecordingMoveExecutionGateResult result = gate.validate( + recordingFile, + renamePlan.targetFile, + expectedRevision, + policy); + + serializer.serialize( + std::string(RecordingMoveExecutionGateStatusName(result.status)), + "status"); + serializer.serialize(result.recordingFile, "recording_file"); + serializer.serialize(requestedName, "name"); + serializer.serialize(result.targetFile, "target_file"); + serializer.serialize( + std::string(RecordingRenamePlanStatusName(renamePlan.status)), + "rename_status"); + serializer.serialize(constraintNames(result.blockers), "blockers"); + serializer.serialize(result.warnings, "warnings"); + serializer.serialize( + result.expectedRevision.recordingFile, + "expected_revision_recording_file"); + serializer.serialize( + result.expectedRevision.targetFile, + "expected_revision_target_file"); + serializer.serialize( + result.expectedRevision.recordingsState, + "expected_revision_recordings_state"); + serializer.serialize( + result.expectedRevision.timersState, + "expected_revision_timers_state"); + serializer.serialize( + result.currentRevision.recordingFile, + "current_revision_recording_file"); + serializer.serialize( + result.currentRevision.targetFile, + "current_revision_target_file"); + serializer.serialize( + result.currentRevision.recordingsState, + "current_revision_recordings_state"); + serializer.serialize( + result.currentRevision.timersState, + "current_revision_timers_state"); + serializer.finish(); +} From a85100f195fbc1f0f41c94a44885f97708c47d50 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 13:14:09 +0200 Subject: [PATCH 112/120] Wire recording rename validate service build --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 4031e04..1f8b5e6 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ LIBS += $(shell pkg-config --libs Magick++) CXXFLAGS += -DUSE_LIBMAGICKPLUSPLUS endif -OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingmoveanalysis.o recordingrenameplan.o recordingrenamepreflight.o recordingpreflight.o recordingmovepreflight.o recordingpreview.o recordingmovepreview.o recordingrenamepreview.o recordingexecution.o recordingmoveexecution.o recordingvalidate.o recordingmovevalidate.o recordingmoveexecutor.o recordingmove.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o +OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingmoveanalysis.o recordingrenameplan.o recordingrenamepreflight.o recordingpreflight.o recordingmovepreflight.o recordingpreview.o recordingmovepreview.o recordingrenamepreview.o recordingexecution.o recordingmoveexecution.o recordingvalidate.o recordingmovevalidate.o recordingrenamevalidate.o recordingmoveexecutor.o recordingmove.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o CFGS = API.html all: $(SOFILE) i18n From cb24c27bfe1cfe2531b4df266c470608c38ede71 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 13:14:43 +0200 Subject: [PATCH 113/120] Register recording rename validate service header --- serverthread.h | 1 + 1 file changed, 1 insertion(+) diff --git a/serverthread.h b/serverthread.h index 4c9d511..9feeca1 100644 --- a/serverthread.h +++ b/serverthread.h @@ -21,6 +21,7 @@ #include "recordingrenamepreview.h" #include "recordingvalidate.h" #include "recordingmovevalidate.h" +#include "recordingrenamevalidate.h" #include "recordingmove.h" #include "recordingtrash.h" #include "remote.h" From 8f936a3b6afd30bde380091d1daefcb350ab0dfb Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 13:16:24 +0200 Subject: [PATCH 114/120] Register recording rename validate HTTP route --- serverthread.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/serverthread.cpp b/serverthread.cpp index b07e3f1..0feb4b0 100644 --- a/serverthread.cpp +++ b/serverthread.cpp @@ -43,6 +43,7 @@ void cServerThread::Action(void) RecordingMovePreviewService recordingMovePreviewService; RecordingRenamePreviewService recordingRenamePreviewService; RecordingMoveValidateService recordingMoveValidateService; + RecordingRenameValidateService recordingRenameValidateService; RecordingMoveService recordingMoveService; RecordingTrashPreviewService recordingTrashPreviewService; RecordingTrashValidateService recordingTrashValidateService; @@ -69,6 +70,7 @@ void cServerThread::Action(void) RestfulService* recordingMovePreview = new RestfulService("/recordings/move/preview", true, 1, recordings); RestfulService* recordingRenamePreview = new RestfulService("/recordings/rename/preview", true, 1, recordings); RestfulService* recordingMoveValidate = new RestfulService("/recordings/move/validate", true, 1, recordings); + RestfulService* recordingRenameValidate = new RestfulService("/recordings/rename/validate", true, 1, recordings); RestfulService* recordingMove = new RestfulService("/recordings/move", true, 1, recordings); RestfulService* recordingTrashPreview = new RestfulService("/recordings/trash/preview", true, 1, recordings); RestfulService* recordingTrashValidate = new RestfulService("/recordings/trash/validate", true, 1, recordings); @@ -96,6 +98,7 @@ void cServerThread::Action(void) services->appendService(recordingMovePreview); services->appendService(recordingRenamePreview); services->appendService(recordingMoveValidate); + services->appendService(recordingRenameValidate); services->appendService(recordingMove); services->appendService(recordingTrashPreview); services->appendService(recordingTrashValidate); @@ -116,6 +119,7 @@ void cServerThread::Action(void) server->addService(std::move(*recordingMovePreview->Regex()), recordingMovePreviewService); server->addService(std::move(*recordingRenamePreview->Regex()), recordingRenamePreviewService); server->addService(std::move(*recordingMoveValidate->Regex()), recordingMoveValidateService); + server->addService(std::move(*recordingRenameValidate->Regex()), recordingRenameValidateService); server->addService(std::move(*recordingMove->Regex()), recordingMoveService); server->addService(std::move(*recordingTrashPreview->Regex()), recordingTrashPreviewService); server->addService(std::move(*recordingTrashValidate->Regex()), recordingTrashValidateService); From 690b97ebbf7504485ab64858689a15bcb4c98e92 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 15:37:05 +0200 Subject: [PATCH 115/120] Add safe recording rename HTTP contract --- recordingrename.h | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 recordingrename.h diff --git a/recordingrename.h b/recordingrename.h new file mode 100644 index 0000000..ebc89d2 --- /dev/null +++ b/recordingrename.h @@ -0,0 +1,25 @@ +#ifndef __RECORDINGRENAME_H +#define __RECORDINGRENAME_H + +#include +#include +#include + +class RecordingRenameResponder : public cxxtools::http::Responder +{ +public: + explicit RecordingRenameResponder(cxxtools::http::Service& service) + : cxxtools::http::Responder(service) + { + } + + void reply( + std::ostream& out, + cxxtools::http::Request& request, + cxxtools::http::Reply& reply) override; +}; + +typedef cxxtools::http::CachedService + RecordingRenameService; + +#endif From 424eb72fc0c35e72451211c0099978e5e3b5db93 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 15:38:17 +0200 Subject: [PATCH 116/120] Implement safe recording rename HTTP service --- recordingrename.cpp | 163 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 recordingrename.cpp diff --git a/recordingrename.cpp b/recordingrename.cpp new file mode 100644 index 0000000..ea9eeae --- /dev/null +++ b/recordingrename.cpp @@ -0,0 +1,163 @@ +#include "recordingrename.h" + +#include "recordinganalysis.h" +#include "recordingmoveexecution.h" +#include "recordingmoveexecutor.h" +#include "recordingmutation.h" +#include "recordingrenameplan.h" +#include "tools.h" + +#include + +#include + +namespace { + +bool parseLongLong(const std::string& value, long long& result) +{ + if (value.empty()) + return false; + + try { + std::size_t parsed = 0; + result = std::stoll(value, &parsed, 10); + return parsed == value.size(); + } + catch (...) { + return false; + } +} + +} + +void RecordingRenameResponder::reply( + std::ostream& out, + cxxtools::http::Request& request, + cxxtools::http::Reply& reply) +{ + QueryHandler::addHeader(reply); + + if (request.method() == "OPTIONS") { + reply.addHeader("Allow", "POST"); + reply.httpReturn(200, "OK"); + return; + } + + if (request.method() != "POST") { + reply.httpReturn(501, "Only POST method is supported by the /recordings/rename service."); + return; + } + + QueryHandler query("/recordings/rename", request); + const std::string recordingFile = query.getBodyAsString("file"); + const std::string requestedName = query.getBodyAsString("name"); + const std::string recordingsStateText = + query.getBodyAsString("revision_recordings_state"); + const std::string timersStateText = + query.getBodyAsString("revision_timers_state"); + + long long recordingsState = 0; + long long timersState = 0; + + if (recordingFile.empty()) { + reply.httpReturn(400, "Recording file is missing."); + return; + } + + if (requestedName.empty()) { + reply.httpReturn(400, "Recording rename name is missing."); + return; + } + + if (!parseLongLong(recordingsStateText, recordingsState) || + !parseLongLong(timersStateText, timersState)) { + reply.httpReturn(400, "Recording rename revision is missing or invalid."); + return; + } + + RecordingRenamePlanner renamePlanner; + const RecordingRenamePlan renamePlan = renamePlanner.build( + recordingFile, + requestedName); + + if (!renamePlan.executable()) { + reply.httpReturn( + 400, + std::string("Recording rename request is invalid: ") + + RecordingRenamePlanStatusName(renamePlan.status) + "."); + return; + } + + RecordingMutationPolicy policy; + policy.allowRecordingHandlerStop = false; + policy.allowReplayStop = false; + policy.allowLocalTimerStop = false; + policy.allowRemoteTimerStop = false; + + VdrRecordingLookup recordingLookup; + VdrRecordingReplayLookup replayLookup; + VdrRecordingHandlerLookup recordingHandlerLookup; + VdrRecordingLocalTimerLookup localTimerLookup; + VdrRecordingRemoteTimerLookup remoteTimerLookup; + VdrRecordingSearchTimerLookup searchTimerLookup; + + RecordingMoveAnalyzer analyzer( + recordingLookup, + replayLookup, + recordingHandlerLookup, + localTimerLookup, + remoteTimerLookup, + searchTimerLookup); + + RecordingMutationPlanner mutationPlanner; + RecordingMoveExecutionGate gate(analyzer, mutationPlanner); + RecordingMoveExecutor executor(gate); + + RecordingMutationRevision expectedRevision; + expectedRevision.recordingFile = recordingFile; + expectedRevision.targetFile = renamePlan.targetFile; + expectedRevision.recordingsState = recordingsState; + expectedRevision.timersState = timersState; + + const RecordingMoveExecutorResult result = executor.executeNormalCase( + recordingFile, + renamePlan.targetFile, + expectedRevision, + policy); + + switch (result.status) { + case RecordingMoveExecutorStatus::Conflict: + reply.httpReturn(409, result.message); + return; + case RecordingMoveExecutorStatus::Blocked: + reply.httpReturn(423, result.message); + return; + case RecordingMoveExecutorStatus::NotFound: + reply.httpReturn(404, result.message); + return; + case RecordingMoveExecutorStatus::Failed: + reply.httpReturn(500, result.message); + return; + case RecordingMoveExecutorStatus::Moved: + case RecordingMoveExecutorStatus::AlreadyMoved: + break; + } + + reply.addHeader("Content-Type", "application/json; charset=utf-8"); + + cxxtools::JsonSerializer serializer(out); + serializer.serialize( + result.status == RecordingMoveExecutorStatus::AlreadyMoved + ? std::string("already-renamed") + : std::string("renamed"), + "status"); + serializer.serialize(result.recordingFile, "recording_file"); + serializer.serialize(requestedName, "name"); + serializer.serialize(result.targetFile, "target_file"); + serializer.serialize( + result.status == RecordingMoveExecutorStatus::AlreadyMoved + ? std::string("Recording already has the requested name.") + : std::string("Recording renamed to the requested name."), + "message"); + serializer.finish(); +} From 74025744fb45b16b5a77770f90892888f0028860 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 15:40:27 +0200 Subject: [PATCH 117/120] Wire safe recording rename execution build --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 1f8b5e6..37a9c6c 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ LIBS += $(shell pkg-config --libs Magick++) CXXFLAGS += -DUSE_LIBMAGICKPLUSPLUS endif -OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingmoveanalysis.o recordingrenameplan.o recordingrenamepreflight.o recordingpreflight.o recordingmovepreflight.o recordingpreview.o recordingmovepreview.o recordingrenamepreview.o recordingexecution.o recordingmoveexecution.o recordingvalidate.o recordingmovevalidate.o recordingrenamevalidate.o recordingmoveexecutor.o recordingmove.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o +OBJS = $(PLUGIN).o serverthread.o tools.o info.o searchtimers.o channels.o events.o recordings.o recordingmutation.o recordinganalysis.o recordingmoveanalysis.o recordingrenameplan.o recordingrenamepreflight.o recordingpreflight.o recordingmovepreflight.o recordingpreview.o recordingmovepreview.o recordingrenamepreview.o recordingexecution.o recordingmoveexecution.o recordingvalidate.o recordingmovevalidate.o recordingrenamevalidate.o recordingmoveexecutor.o recordingmove.o recordingrename.o recordingtrashexecutor.o recordingtrash.o remote.o timers.o changestate.o eventsstreamthread.o changestatetracker.o scraper2vdr.o statusmonitor.o osd.o jsonparser.o epgsearch.o wirbelscan.o webapp.o femon.o CFGS = API.html all: $(SOFILE) i18n From 0305dbae689ec83a142ceb1b7d7188c70ab1b5a5 Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 15:41:08 +0200 Subject: [PATCH 118/120] Register safe recording rename service header --- serverthread.h | 1 + 1 file changed, 1 insertion(+) diff --git a/serverthread.h b/serverthread.h index 9feeca1..f6ba2d2 100644 --- a/serverthread.h +++ b/serverthread.h @@ -23,6 +23,7 @@ #include "recordingmovevalidate.h" #include "recordingrenamevalidate.h" #include "recordingmove.h" +#include "recordingrename.h" #include "recordingtrash.h" #include "remote.h" #include "timers.h" From aa02e83ab0140c0f7206ccb899729a252f0e973a Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 15:45:06 +0200 Subject: [PATCH 119/120] Register safe recording rename HTTP route --- serverthread.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/serverthread.cpp b/serverthread.cpp index 0feb4b0..1d17804 100644 --- a/serverthread.cpp +++ b/serverthread.cpp @@ -45,6 +45,7 @@ void cServerThread::Action(void) RecordingMoveValidateService recordingMoveValidateService; RecordingRenameValidateService recordingRenameValidateService; RecordingMoveService recordingMoveService; + RecordingRenameService recordingRenameService; RecordingTrashPreviewService recordingTrashPreviewService; RecordingTrashValidateService recordingTrashValidateService; RecordingTrashService recordingTrashService; @@ -72,6 +73,7 @@ void cServerThread::Action(void) RestfulService* recordingMoveValidate = new RestfulService("/recordings/move/validate", true, 1, recordings); RestfulService* recordingRenameValidate = new RestfulService("/recordings/rename/validate", true, 1, recordings); RestfulService* recordingMove = new RestfulService("/recordings/move", true, 1, recordings); + RestfulService* recordingRename = new RestfulService("/recordings/rename", true, 1, recordings); RestfulService* recordingTrashPreview = new RestfulService("/recordings/trash/preview", true, 1, recordings); RestfulService* recordingTrashValidate = new RestfulService("/recordings/trash/validate", true, 1, recordings); RestfulService* recordingTrash = new RestfulService("/recordings/trash", true, 1, recordings); @@ -100,6 +102,7 @@ void cServerThread::Action(void) services->appendService(recordingMoveValidate); services->appendService(recordingRenameValidate); services->appendService(recordingMove); + services->appendService(recordingRename); services->appendService(recordingTrashPreview); services->appendService(recordingTrashValidate); services->appendService(recordingTrash); @@ -121,6 +124,7 @@ void cServerThread::Action(void) server->addService(std::move(*recordingMoveValidate->Regex()), recordingMoveValidateService); server->addService(std::move(*recordingRenameValidate->Regex()), recordingRenameValidateService); server->addService(std::move(*recordingMove->Regex()), recordingMoveService); + server->addService(std::move(*recordingRename->Regex()), recordingRenameService); server->addService(std::move(*recordingTrashPreview->Regex()), recordingTrashPreviewService); server->addService(std::move(*recordingTrashValidate->Regex()), recordingTrashValidateService); server->addService(std::move(*recordingTrash->Regex()), recordingTrashService); From d7c7550bc71d40e723ef5ba911acb390fedf37cb Mon Sep 17 00:00:00 2001 From: hotzenplotz5 Date: Wed, 15 Jul 2026 16:29:42 +0200 Subject: [PATCH 120/120] Document safe native recording rename API --- RECORDING_RENAME_API.md | 235 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 RECORDING_RENAME_API.md diff --git a/RECORDING_RENAME_API.md b/RECORDING_RENAME_API.md new file mode 100644 index 0000000..43bf911 --- /dev/null +++ b/RECORDING_RENAME_API.md @@ -0,0 +1,235 @@ +# Safe Native Recording Rename API + +This document describes the safe recording rename workflow implemented by the RESTfulAPI plugin. + +The implementation follows native VDR behavior. Rename changes only the title-directory component of a recording identity, preserves the timestamp `.rec` directory, reuses the native recording move path internally, updates the VDR recording list, and runs the native `vdr-recordingaction rename` hook. + +It does not stop playback, stop recordings, modify timers, cancel recording handler operations, or bypass VDR recording list updates. + +## Workflow + +Recording rename uses a three-step optimistic-locking workflow: + +1. Preview the requested name and obtain state fingerprints. +2. Optionally validate that the fingerprints are still current. +3. Execute the rename with the confirmed fingerprints. + +Preview and validation are read-only. They do not modify the recording, timers, playback state, filesystem, or VDR recording lists. + +## Name and path contract + +The request contains: + +- `file`: complete native VDR recording identity ending in `.rec` +- `name`: one new title-directory name + +Example source: + +```text +/srv/vdr/video/Folder/Old_Title/2026-07-08.21.45.2-0.rec +``` + +Example requested name: + +```text +New_Title +``` + +Calculated target: + +```text +/srv/vdr/video/Folder/New_Title/2026-07-08.21.45.2-0.rec +``` + +The timestamp `.rec` directory remains unchanged. The rename planner only replaces the title-directory component directly above it. + +The requested name: + +- must not be empty +- must not be `.` or `..` +- must not contain `/` or `\` +- must not contain control characters +- must not resolve to the current source identity + +The calculated target must not already exist in the filesystem or VDR recording list. + +## Preview + +```http +POST /recordings/rename/preview.json +Content-Type: application/json +``` + +Request: + +```json +{ + "file": "/srv/vdr/video/Folder/Old_Title/2026-07-08.21.45.2-0.rec", + "name": "New_Title" +} +``` + +Executable response: + +```json +{ + "executable": true, + "recording_file": "/srv/vdr/video/Folder/Old_Title/2026-07-08.21.45.2-0.rec", + "name": "New_Title", + "target_file": "/srv/vdr/video/Folder/New_Title/2026-07-08.21.45.2-0.rec", + "rename_status": "ready", + "constraints": [], + "blockers": [], + "warnings": [], + "steps": [ + "move-recording", + "refresh-recordings", + "notify-change" + ], + "revision_recording_file": "/srv/vdr/video/Folder/Old_Title/2026-07-08.21.45.2-0.rec", + "revision_target_file": "/srv/vdr/video/Folder/New_Title/2026-07-08.21.45.2-0.rec", + "revision_recordings_state": 123456789, + "revision_timers_state": 987654321 +} +``` + +Rename-specific constraints include: + +- `rename-source-invalid` +- `rename-name-missing` +- `rename-name-invalid` +- `rename-target-same-as-source` + +## Validate + +```http +POST /recordings/rename/validate.json +Content-Type: application/json +``` + +Request: + +```json +{ + "file": "/srv/vdr/video/Folder/Old_Title/2026-07-08.21.45.2-0.rec", + "name": "New_Title", + "revision_recordings_state": "123456789", + "revision_timers_state": "987654321" +} +``` + +Validation status values: + +- `ready`: calculated target and current VDR state still match the preview revision +- `conflict`: source, target, recording state, timer state, replay state, or handler state changed after preview +- `blocked`: the current state or policy does not allow execution + +## Execute + +```http +POST /recordings/rename.json +Content-Type: application/json +``` + +Request: + +```json +{ + "file": "/srv/vdr/video/Folder/Old_Title/2026-07-08.21.45.2-0.rec", + "name": "New_Title", + "revision_recordings_state": "123456789", + "revision_timers_state": "987654321" +} +``` + +Successful response: + +```json +{ + "status": "renamed", + "recording_file": "/srv/vdr/video/Folder/Old_Title/2026-07-08.21.45.2-0.rec", + "name": "New_Title", + "target_file": "/srv/vdr/video/Folder/New_Title/2026-07-08.21.45.2-0.rec", + "message": "Recording renamed to the requested name." +} +``` + +Idempotent retry after the source has already been renamed to the requested target: + +```json +{ + "status": "already-renamed", + "recording_file": "/srv/vdr/video/Folder/Old_Title/2026-07-08.21.45.2-0.rec", + "name": "New_Title", + "target_file": "/srv/vdr/video/Folder/New_Title/2026-07-08.21.45.2-0.rec", + "message": "Recording already has the requested name." +} +``` + +## Protected states + +The operation is blocked when safety cannot be established or VDR is actively using the recording: + +- `replay-active` +- `recording-handler-busy` +- `local-timer-active` +- `remote-timer-active` +- `unknown-recording-handler-state` +- `unknown-local-timer-state` +- `unknown-remote-timer-state` +- `unknown-search-timer-state` +- target already exists + +The execution endpoint does not automatically resolve these states. + +## Execution behavior + +The executor: + +1. Rebuilds the rename target from `file` and `name`. +2. Revalidates the optimistic-locking revision. +3. Acquires VDR timer and recording write locks. +4. Rechecks source, target, replay, handler, recording control, and timer association. +5. Uses the native VDR directory move helper. +6. Verifies that the source disappeared and the target exists. +7. Replaces the old identity with the new identity in the VDR recording list. +8. Runs the native `vdr-recordingaction rename` hook. +9. Forces video disk usage refresh and recording change notification. + +## HTTP status codes + +| Status | Meaning | +|---|---| +| `200` | The recording was renamed, is already renamed, or validation returned a structured status. | +| `400` | Source, name, or required revision values are missing or invalid. | +| `404` | The source recording disappeared before execution. | +| `409` | Source, target, recording, replay, handler, or timer state changed after preview. | +| `423` | The operation is blocked by the current VDR state or policy. | +| `500` | The native VDR mutation or postcondition verification failed. | +| `501` | The requested HTTP method is not supported. | + +## Verified integration behavior + +The following cases were verified against a running VDR installation on July 15, 2026: + +- focused rename plan and preflight tests passed +- all move regression tests passed +- complete plugin build passed +- preview: `HTTP 200`, executable, no filesystem mutation +- invalid name: `HTTP 200`, blocked with `rename-name-invalid` +- unsupported method: `HTTP 501` +- validate: `HTTP 200`, `status: ready` +- missing revision: `HTTP 400` +- stale revision: `HTTP 200`, `status: conflict` +- completed 285 MB recording: `HTTP 200`, `status: renamed` +- source directory removed and target directory present with unchanged size +- old VDR recording identity absent and new identity present +- native directory creation and rename logged by VDR +- native `vdr-recordingaction rename` hook executed +- immediate identical retry: `HTTP 200`, `status: already-renamed` +- reverse rename through the same preview and execute workflow: `HTTP 200`, `status: renamed` +- final filesystem and VDR recording list restored to the original identity + +## Design boundary + +This API renames one recording title directory while preserving the native timestamp `.rec` directory. It is not a generic file manager, does not copy recordings, does not accept arbitrary target paths, and does not silently stop active VDR operations.