diff --git a/Makefile b/Makefile index e3e7268..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 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 @@ -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,9 +72,59 @@ $(I18Npot): $(wildcard *.cpp) $(I18Nmsgs): $(DESTDIR)$(LOCDIR)/%/LC_MESSAGES/vdr-$(PLUGIN).mo: $(PODIR)/%.mo install -D -m644 $< $@ -.PHONY: i18n +.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: + $(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 + +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 + +test-recording-move-preflight: + $(CXX) -std=c++17 -Wall -Wextra \ + recordingmutation.cpp \ + recordingmoveanalysis.cpp \ + recordingmovepreflight.cpp \ + tests/test_recording_move_preflight.cpp \ + -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 + +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 + +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) @@ -94,12 +144,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: - @-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) *~ 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 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. 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. diff --git a/recordinganalysis.cpp b/recordinganalysis.cpp new file mode 100644 index 0000000..4406091 --- /dev/null +++ b/recordinganalysis.cpp @@ -0,0 +1,360 @@ +#include "recordinganalysis.h" + +#include +#include +#include +#include +#include + +#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)) + return false; + + remote = value.substr(separator + 1); + 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 +{ + 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; +} + +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; +} + +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; +} + +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; + } + + if (!parseRemoteTimerId(timerId, result.timerId, result.remote)) + return result; + + if (result.timerId == 0) { + result.known = true; + return result; + } + + LOCK_TIMERS_READ; + + 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 (!remoteTimer) + return result; + + result.known = true; + result.active = + remoteTimer->HasFlags(tfActive) || remoteTimer->Recording(); + return result; +} + +RecordingSearchTimerLookupResult VdrRecordingSearchTimerLookup::findOrigin( + const std::string& recordingFile) const +{ + RecordingSearchTimerLookupResult result; + + if (recordingFile.empty()) + return result; + + LOCK_TIMERS_READ; + + 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; + + if (id == 0) { + result.known = true; + return result; + } + + timer = Timers->GetById(id, nullptr); + if (!timer) + 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.searchTimerRecording = present; + result.searchTimerId = present ? searchTimerId : -1; + return result; +} + +RecordingTrashAnalyzer::RecordingTrashAnalyzer( + 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 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); + analysis.revision.recordingsState = recordingFingerprint(recordingFile, recording.found); + + if (!recording.found) { + analysis.constraints.push_back(RecordingConstraint::RecordingMissing); + return analysis; + } + + analysis.recordingFile = recording.recordingFile; + analysis.revision.recordingFile = recording.recordingFile; + analysis.revision.recordingsState = recordingFingerprint(recording.recordingFile, true); + + 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.timersState = timerFingerprint( + replaying, + handlerUsage, + localTimer, + remoteTimer, + searchTimer); + + return analysis; +} diff --git a/recordinganalysis.h b/recordinganalysis.h new file mode 100644 index 0000000..9f7eb9f --- /dev/null +++ b/recordinganalysis.h @@ -0,0 +1,165 @@ +#ifndef __RECORDINGANALYSIS_H +#define __RECORDINGANALYSIS_H + +#include "recordingmutation.h" + +#include + +struct RecordingLookupResult +{ + bool found = false; + std::string recordingFile; +}; + +struct RecordingHandlerLookupResult +{ + bool known = false; + bool busy = false; +}; + +struct RecordingLocalTimerLookupResult +{ + bool known = false; + bool active = false; +}; + +struct RecordingRemoteTimerLookupResult +{ + bool known = false; + bool active = false; + int timerId = 0; + std::string remote; +}; + +struct RecordingSearchTimerLookupResult +{ + bool known = false; + bool searchTimerRecording = false; + int searchTimerId = -1; +}; + +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 IRecordingHandlerLookup +{ +public: + virtual ~IRecordingHandlerLookup() = default; + 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 IRecordingRemoteTimerLookup +{ +public: + virtual ~IRecordingRemoteTimerLookup() = default; + 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: + RecordingLookupResult find(const std::string& recordingFile) const override; +}; + +class VdrRecordingReplayLookup : public IRecordingReplayLookup +{ +public: + bool isReplaying(const std::string& recordingFile) const override; +}; + +class VdrRecordingHandlerLookup : public IRecordingHandlerLookup +{ +public: + RecordingHandlerLookupResult getUsage(const std::string& recordingFile) const override; +}; + +class VdrRecordingLocalTimerLookup : public IRecordingLocalTimerLookup +{ +public: + RecordingLocalTimerLookupResult findActive(const std::string& recordingFile) const override; +}; + +class VdrRecordingRemoteTimerLookup : public IRecordingRemoteTimerLookup +{ +public: + RecordingRemoteTimerLookupResult findActive(const std::string& recordingFile) const override; +}; + +class VdrRecordingSearchTimerLookup : public IRecordingSearchTimerLookup +{ +public: + RecordingSearchTimerLookupResult findOrigin(const std::string& recordingFile) const override; +}; + +class RecordingTrashAnalyzer +{ +public: + RecordingTrashAnalyzer( + 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; + +private: + const IRecordingLookup& recordingLookup; + const IRecordingReplayLookup& replayLookup; + const IRecordingHandlerLookup& recordingHandlerLookup; + const IRecordingLocalTimerLookup& localTimerLookup; + const IRecordingRemoteTimerLookup& remoteTimerLookup; + 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 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"; +} 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 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(); +} 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 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; +} 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"; +} 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 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"; +} 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 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; +} 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(); +} 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 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(); +} 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 diff --git a/recordingmutation.cpp b/recordingmutation.cpp new file mode 100644 index 0000000..5bc7432 --- /dev/null +++ b/recordingmutation.cpp @@ -0,0 +1,176 @@ +#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); +} + +void addCommonSafetyConstraints( + RecordingMutationPlan& plan, + const RecordingMutationAnalysis& analysis, + const RecordingMutationPolicy& policy) +{ + if (analysis.hasConstraint(RecordingConstraint::RecordingMissing)) + addBlocker(plan, RecordingConstraint::RecordingMissing); + + if (analysis.hasConstraint(RecordingConstraint::UnknownRecordingHandlerState)) + addBlocker(plan, RecordingConstraint::UnknownRecordingHandlerState); + + 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); + + 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."); +} + +} + +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); + addStep(plan, RecordingMutationStep::RefreshRecordings); + addStep(plan, RecordingMutationStep::NotifyChange); + plan.executable = true; + } + + 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) { + 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::UnknownRecordingHandlerState: return "unknown-recording-handler-state"; + 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"; +} + +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::MoveRecording: return "move-recording"; + case RecordingMutationStep::RefreshRecordings: return "refresh-recordings"; + case RecordingMutationStep::NotifyChange: return "notify-change"; + } + return "unknown"; +} diff --git a/recordingmutation.h b/recordingmutation.h new file mode 100644 index 0000000..8c0894e --- /dev/null +++ b/recordingmutation.h @@ -0,0 +1,101 @@ +#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, + UnknownRecordingHandlerState, + UnknownLocalTimerState, + UnknownRemoteTimerState, + UnknownSearchTimerState, + MoveTargetMissing, + MoveTargetInvalid, + MoveTargetSameAsSource, + MoveTargetExists +}; + +enum class RecordingMutationStep +{ + StopRecordingHandler, + StopReplay, + DeactivateLocalTimer, + DeactivateRemoteTimer, + TrashRecording, + RestoreRecording, + PurgeRecording, + MoveRecording, + RefreshRecordings, + NotifyChange +}; + +struct RecordingMutationRevision +{ + std::string recordingFile; + std::string targetFile; + long long recordingsState = 0; + long long timersState = 0; +}; + +struct RecordingMutationAnalysis +{ + RecordingMutationType type = RecordingMutationType::Trash; + std::string recordingFile; + std::string targetFile; + 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; + + RecordingMutationPlan buildMovePlan( + const RecordingMutationAnalysis& analysis, + const RecordingMutationPolicy& policy) const; +}; + +const char* RecordingConstraintName(RecordingConstraint constraint); +const char* RecordingMutationStepName(RecordingMutationStep step); + +#endif diff --git a/recordingpreflight.cpp b/recordingpreflight.cpp new file mode 100644 index 0000000..1f935c3 --- /dev/null +++ b/recordingpreflight.cpp @@ -0,0 +1,44 @@ +#include "recordingpreflight.h" + +namespace { + +void appendTrashAnalysisAndPlan( + RecordingTrashPreflightResult& 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) + : 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); + appendTrashAnalysisAndPlan(result, analysis, plan); + return result; +} diff --git a/recordingpreflight.h b/recordingpreflight.h new file mode 100644 index 0000000..6d8b8a8 --- /dev/null +++ b/recordingpreflight.h @@ -0,0 +1,66 @@ +#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; +}; + +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: + RecordingTrashPreflightService( + const RecordingTrashAnalyzer& analyzer, + const RecordingMutationPlanner& planner); + + RecordingTrashPreflightResult preview( + const std::string& recordingFile, + const RecordingMutationPolicy& policy) const; + +private: + const RecordingTrashAnalyzer& analyzer; + 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 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(); +} 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 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(); +} 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 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"; +} 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 diff --git a/recordingrenamepreflight.cpp b/recordingrenamepreflight.cpp new file mode 100644 index 0000000..465180f --- /dev/null +++ b/recordingrenamepreflight.cpp @@ -0,0 +1,72 @@ +#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)); +} + +std::string renameConstraintName(RecordingRenamePlanStatus status) +{ + return std::string("rename-") + RecordingRenamePlanStatusName(status); +} + +} + +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 constraint = renameConstraintName(renamePlan.status); + result.constraints.push_back(constraint); + result.blockers.push_back(constraint); + return result; + } + + const RecordingMutationAnalysis analysis = moveAnalyzer.analyze( + renamePlan.recordingFile, + renamePlan.targetFile); + const RecordingMutationPlan mutationPlan = mutationPlanner.buildMovePlan( + analysis, + policy); + appendMovePreflight(result, analysis, mutationPlan); + return result; +} 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 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(); +} 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 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(); +} 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 diff --git a/recordingtrash.cpp b/recordingtrash.cpp new file mode 100644 index 0000000..9b02cd1 --- /dev/null +++ b/recordingtrash.cpp @@ -0,0 +1,132 @@ +#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: + case RecordingTrashExecutorStatus::AlreadyTrashed: + 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(); +} 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 diff --git a/recordingtrashexecutor.cpp b/recordingtrashexecutor.cpp new file mode 100644 index 0000000..3a3e921 --- /dev/null +++ b/recordingtrashexecutor.cpp @@ -0,0 +1,169 @@ +#include "recordingtrashexecutor.h" + +#include "changestatetracker.h" + +#include +#include + +#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(); +} + +bool pathExists(const std::string& path) +{ + return !path.empty() && access(path.c_str(), F_OK) == 0; +} + +} + +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; + + 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) { + 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; + } + + LOCK_TIMERS_WRITE; + LOCK_RECORDINGS_WRITE; + + cRecording* recording = Recordings->GetByName(recordingFile.c_str()); + if (!recording) { + 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; + } + + 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; + } + + 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); + DeletedRecordings->Add(recording); + } + + cVideoDiskUsage::ForceCheck(); + StateChangeTracker::UpdateRecordings(); + + result.status = RecordingTrashExecutorStatus::Trashed; + result.message = "Recording moved to the VDR trash."; + return result; +} + +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: + return "blocked"; + case RecordingTrashExecutorStatus::NotFound: + return "not-found"; + case RecordingTrashExecutorStatus::Failed: + return "failed"; + } + + return "failed"; +} diff --git a/recordingtrashexecutor.h b/recordingtrashexecutor.h new file mode 100644 index 0000000..e07f62e --- /dev/null +++ b/recordingtrashexecutor.h @@ -0,0 +1,43 @@ +#ifndef __RECORDINGTRASHEXECUTOR_H +#define __RECORDINGTRASHEXECUTOR_H + +#include "recordingexecution.h" + +#include + +enum class RecordingTrashExecutorStatus +{ + Trashed, + AlreadyTrashed, + 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 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(); +} 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 diff --git a/serverthread.cpp b/serverthread.cpp index b6704b1..1d17804 100644 --- a/serverthread.cpp +++ b/serverthread.cpp @@ -40,6 +40,15 @@ void cServerThread::Action(void) ChannelsService channelsService; EventsService eventsService; RecordingsService recordingsService; + RecordingMovePreviewService recordingMovePreviewService; + RecordingRenamePreviewService recordingRenamePreviewService; + RecordingMoveValidateService recordingMoveValidateService; + RecordingRenameValidateService recordingRenameValidateService; + RecordingMoveService recordingMoveService; + RecordingRenameService recordingRenameService; + RecordingTrashPreviewService recordingTrashPreviewService; + RecordingTrashValidateService recordingTrashValidateService; + RecordingTrashService recordingTrashService; RemoteService remoteService; TimersService timersService; ChangeStateService changeStateService; @@ -59,6 +68,15 @@ 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* 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* 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); RestfulService* remote = new RestfulService("/remote", true, 1); RestfulService* timers = new RestfulService("/timers", true, 1); RestfulService* changeState = new RestfulService("/change-state", true, 1); @@ -79,6 +97,15 @@ void cServerThread::Action(void) services->appendService(recordings); services->appendService(recordingsCut); services->appendService(recordingsMarks); + services->appendService(recordingMovePreview); + services->appendService(recordingRenamePreview); + services->appendService(recordingMoveValidate); + services->appendService(recordingRenameValidate); + services->appendService(recordingMove); + services->appendService(recordingRename); + services->appendService(recordingTrashPreview); + services->appendService(recordingTrashValidate); + services->appendService(recordingTrash); services->appendService(remote); services->appendService(timers); services->appendService(changeState); @@ -92,6 +119,15 @@ 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(*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(*recordingRename->Regex()), recordingRenameService); + 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); @@ -135,7 +171,6 @@ void cServerThread::addWebappService(string name) { } i++; } - if (false == occupied) { RestfulService* service = new RestfulService(path, true, 1); services->appendService(service); diff --git a/serverthread.h b/serverthread.h index 5a0739f..f6ba2d2 100644 --- a/serverthread.h +++ b/serverthread.h @@ -16,6 +16,15 @@ #include "channels.h" #include "events.h" #include "recordings.h" +#include "recordingpreview.h" +#include "recordingmovepreview.h" +#include "recordingrenamepreview.h" +#include "recordingvalidate.h" +#include "recordingmovevalidate.h" +#include "recordingrenamevalidate.h" +#include "recordingmove.h" +#include "recordingrename.h" +#include "recordingtrash.h" #include "remote.h" #include "timers.h" #include "changestate.h" diff --git a/tests/test_recording_move_analysis.cpp b/tests/test_recording_move_analysis.cpp new file mode 100644 index 0000000..f44ea1e --- /dev/null +++ b/tests/test_recording_move_analysis.cpp @@ -0,0 +1,159 @@ +#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}; + 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; + 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; +} 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; +} 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; +} 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; +} 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; +} 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; +}