From 58cc7145e85bbe1964f9ea0a55e5b9c48efa1f90 Mon Sep 17 00:00:00 2001 From: "Finn Hermansson (fihe02)" Date: Mon, 25 Sep 2023 12:15:52 +0200 Subject: [PATCH] Introducing Encore Instant - Chunked based encoding in Encore NEW FEATURES * Encore now supports chunked based encoding - allowing for a potential massive increase in transcoding speed (which is mostly limited by storage I/O) by encoding parts of a video in parallel across several instances / machines. * Set segmentLength (in seconds) in encoreJob to activate chunked encoding, it should be a multiple of the target GOP. Encore will then transcode segments of that length in parallel across the cluster of servers. Requires 'encore-settings.sharedWorkDir' pointing to dir accessible to all encore instances for intermediate storage of transcoded segments. * Startup time has been greatly improved by support for native compilation using graalvm (tested with graalvm community edition 17.0.8). KNOWN LIMITATIONS * Encore Instant currently relies heavily on ffmpeg's input seek. This means that input files of a format that ffmpeg can't properly input seek through (just about everything that is not I-Frame only codecs/flavours) wont work out of the box! * Encore Instant is only suitable for 1-pass transcodes, usually x264 / x265 CRF or Mezzanine files. * Encore Instant does not allow thumbnail output BREAKING CHANGES * code base has been split into three parts: - encore-web: A drop in replacement for the old encore artifact. Exposing rest endpoints. - encore-worker: Artifact suitable for running single jobs from the work queue, e.g as Kubernetes jobs triggered by KEDA. - encore-common: code shared by encore-web and encore-worker Signed-off-by: Finn Hermansson --- .github/workflows/publish.yaml | 100 ---- .github/workflows/publishjar.yml | 25 +- .idea/compiler.xml | 12 +- build.gradle.kts | 152 ------ buildSrc/build.gradle.kts | 24 + .../encore.kotlin-conventions.gradle.kts | 64 +++ ...ore.spring-boot-app-conventions.gradle.kts | 21 + checks.gradle | 12 +- encore-common/build.gradle.kts | 43 ++ .../se/svt/oss/encore/ClientConfiguration.kt | 28 ++ .../se/svt/oss/encore/EncoreRuntimeHints.kt | 30 ++ .../oss/encore/MediaAnalyzerConfiguration.kt | 0 .../se/svt/oss/encore/RedisConfiguration.kt | 33 +- .../cancellation/CancellationListener.kt | 0 .../cancellation/SegmentProgressListener.kt | 46 ++ .../svt/oss/encore/config/AudioMixPreset.kt | 3 + .../oss/encore/config/EncodingProperties.kt | 7 + .../svt/oss/encore/config/EncoreProperties.kt | 79 +++ .../se/svt/oss/encore/model/CancelEvent.kt | 0 .../se/svt/oss/encore/model/EncoreJob.kt | 21 +- .../oss/encore/model/SegmentProgressEvent.kt | 11 + .../kotlin/se/svt/oss/encore/model/Status.kt | 0 .../oss/encore/model/callback/JobProgress.kt | 0 .../se/svt/oss/encore/model/input/Input.kt | 24 +- .../oss/encore/model/mediafile/AudioLayout.kt | 0 .../oss/encore/model/mediafile/Extensions.kt | 0 .../se/svt/oss/encore/model/output/Output.kt | 0 .../oss/encore/model/profile/AudioEncode.kt | 0 .../oss/encore/model/profile/AudioEncoder.kt | 0 .../svt/oss/encore/model/profile/ChannelId.kt | 0 .../oss/encore/model/profile/ChannelLayout.kt | 0 .../model/profile/GenericVideoEncode.kt | 0 .../encore/model/profile/OutputProducer.kt | 0 .../svt/oss/encore/model/profile/Profile.kt | 0 .../encore/model/profile/SimpleAudioEncode.kt | 0 .../encore/model/profile/ThumbnailEncode.kt | 3 + .../model/profile/ThumbnailMapEncode.kt | 3 + .../oss/encore/model/profile/VideoEncode.kt | 0 .../oss/encore/model/profile/X264Encode.kt | 0 .../oss/encore/model/profile/X265Encode.kt | 0 .../oss/encore/model/profile/X26XEncode.kt | 0 .../svt/oss/encore/model/queue/QueueItem.kt | 13 +- .../svt/oss/encore/process/CommandBuilder.kt | 14 +- .../se/svt/oss/encore/process/SegmentUtil.kt | 34 ++ .../repository/ChannelLayoutConverters.kt | 0 .../encore/repository/EncoreJobRepository.kt | 5 +- .../repository/OffsetDateTimeConverters.kt | 0 .../svt/oss/encore/service/EncoreService.kt | 278 ++++++++++ .../svt/oss/encore/service/FfmpegExecutor.kt | 52 +- .../encore/service/callback/CallbackClient.kt | 19 + .../service/callback/CallbackService.kt | 3 +- .../service/localencode/LocalEncodeService.kt | 0 .../mediaanalyzer/MediaAnalyzerService.kt | 25 + .../encore/service/profile/ProfileService.kt | 33 +- .../oss/encore/service/queue/QueueService.kt | 142 ++++++ .../svt/oss/encore/service/queue/QueueUtil.kt | 0 .../kotlin/se/svt/oss/encore/EncoreClient.kt | 39 +- .../svt/oss/encore/EncoreIntegrationTest.kt | 8 +- .../oss/encore/EncoreIntegrationTestBase.kt | 23 +- .../svt/oss/encore/EncoreRuntimeHintsTest.kt | 31 ++ .../oss/encore/LocalEncodeIntegrationTest.kt | 0 .../kotlin/se/svt/oss/encore/TestConfig.kt | 22 + .../kotlin/se/svt/oss/encore/TestUtils.kt | 0 .../svt/oss/encore/model/input/InputTest.kt | 6 + .../mediafile/MediaFileExtensionsTest.kt | 0 .../encore/model/profile/AudioEncodeTest.kt | 3 +- .../model/profile/GenericVideoEncodeTest.kt | 0 .../model/profile/ThumbnailEncodeTest.kt | 0 .../model/profile/ThumbnailMapEncodeTest.kt | 0 .../encore/model/profile/VideoEncodeTest.kt | 0 .../encore/model/profile/X264EncodeTest.kt | 0 .../encore/model/profile/X265EncodeTest.kt | 0 .../oss/encore/model/queue/QueueItemTest.kt | 15 +- .../oss/encore/process/CommandBuilderTest.kt | 0 .../svt/oss/encore/process/SegmentUtilTest.kt | 82 +++ .../repository/EncoreJobRepositoryTest.kt | 16 +- .../service/callback/CallbackServiceTest.kt | 10 +- .../service/profile/ProfileServiceTest.kt | 0 .../encore/service/queue/QueueServiceTest.kt | 318 ++++++++++++ .../oss/encore/service/queue/QueueUtilTest.kt | 0 .../test/resources/application-test-local.yml | 8 +- .../src}/test/resources/application-test.yml | 6 +- .../resources/input/multiple-audio-file.json | 0 .../resources/input/multiple-video-file.json | 0 .../test/resources/input/multiple_audio.mp4 | Bin .../test/resources/input/multiple_video.mp4 | Bin .../resources/input/portrait-video-file.json | 0 .../src}/test/resources/input/test.mp4 | Bin .../src}/test/resources/input/test_stereo.mp4 | Bin .../test/resources/input/video-file-long.json | 0 .../src}/test/resources/input/video-file.json | 0 .../src}/test/resources/profile/archive.yml | 0 .../test/resources/profile/audio-streams.yml | 0 .../resources/profile/dpb_size_failed.yml | 0 .../resources/profile/multiple_inputs.yml | 0 .../src}/test/resources/profile/profiles.yml | 0 .../test/resources/profile/program-x265.yml | 0 .../src}/test/resources/profile/program.yml | 0 .../profile/test_profile_invalid.yml | 0 encore-web/Dockerfile | 12 + Dockerfile => encore-web/Dockerfile-jar | 4 +- encore-web/build.gradle.kts | 15 + .../se/svt/oss/encore/EncoreApplication.kt | 4 +- .../svt/oss/encore/EncoreWebRuntimeHints.kt | 20 + .../se/svt/oss/encore/OpenAPIConfiguration.kt | 0 .../svt/oss/encore/RepositoryConfiguration.kt | 0 .../svt/oss/encore/SchedulingConfiguration.kt | 0 .../svt/oss/encore/SecurityConfiguration.kt | 55 +- .../oss/encore/controller/EncoreController.kt | 2 +- .../oss/encore/handlers/EncoreJobHandler.kt | 0 .../se/svt/oss/encore/poll/JobPoller.kt | 61 +++ encore-web/src/main/resources/application.yml | 20 + .../src}/main/resources/asciilogo.txt | 0 .../src/main/resources/logback-json.xml | 11 + .../EncoreEndpointAccessIntegrationTest.kt | 167 ++++++ .../oss/encore/EncoreWebRuntimeHintsTest.kt | 21 + .../encore/controller/EncoreControllerTest.kt | 4 +- .../encore/handlers/EncoreJobHandlerTest.kt | 12 +- .../se/svt/oss/encore/poll/JobPollerTest.kt | 138 +++++ .../src/test/resources/application-test.yml | 47 ++ .../src/test/resources/profile/archive.yml | 15 + .../test/resources/profile/audio-streams.yml | 20 + .../resources/profile/dpb_size_failed.yml | 42 ++ .../resources/profile/multiple_inputs.yml | 73 +++ .../src/test/resources/profile/profiles.yml | 9 + .../test/resources/profile/program-x265.yml | 475 ++++++++++++++++++ .../src/test/resources/profile/program.yml | 225 +++++++++ .../profile/test_profile_invalid.yml | 58 +++ encore-worker/Dockerfile | 12 + encore-worker/build.gradle.kts | 8 + .../svt/oss/encore/EncoreWorkerApplication.kt | 51 ++ .../src/main/resources/application.yml | 13 + .../src/main/resources/asciilogo.txt | 45 ++ .../src/main/resources/logback-json.xml | 11 + .../oss/encore/EncoreWorkerApplicationTest.kt | 78 +++ .../encore_logo.png => encore_logo.png | Bin gradle/wrapper/gradle-wrapper.jar | Bin 60756 -> 63721 bytes gradle/wrapper/gradle-wrapper.properties | 4 +- gradlew | 25 +- settings.gradle | 1 - settings.gradle.kts | 4 + .../se/svt/oss/encore/FeignConfiguration.kt | 21 - .../svt/oss/encore/config/EncoreProperties.kt | 36 -- .../oss/encore/repository/URIConverters.kt | 24 - .../oss/encore/repository/UUIDConverters.kt | 24 - .../svt/oss/encore/service/EncoreService.kt | 163 ------ .../encore/service/callback/CallbackClient.kt | 17 - .../svt/oss/encore/service/poll/JobPoller.kt | 103 ---- .../oss/encore/service/queue/QueueService.kt | 76 --- src/main/resources/application-local.yml | 24 - src/main/resources/application.yml | 49 -- .../EncoreEndpointAccessIntegrationTest.kt | 120 ----- .../oss/encore/service/poll/JobPollerTest.kt | 191 ------- .../encore/service/queue/QueueServiceTest.kt | 199 -------- 154 files changed, 3263 insertions(+), 1492 deletions(-) delete mode 100644 .github/workflows/publish.yaml delete mode 100644 build.gradle.kts create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/src/main/kotlin/encore.kotlin-conventions.gradle.kts create mode 100644 buildSrc/src/main/kotlin/encore.spring-boot-app-conventions.gradle.kts create mode 100644 encore-common/build.gradle.kts create mode 100644 encore-common/src/main/kotlin/se/svt/oss/encore/ClientConfiguration.kt create mode 100644 encore-common/src/main/kotlin/se/svt/oss/encore/EncoreRuntimeHints.kt rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/MediaAnalyzerConfiguration.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/RedisConfiguration.kt (64%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/cancellation/CancellationListener.kt (100%) create mode 100644 encore-common/src/main/kotlin/se/svt/oss/encore/cancellation/SegmentProgressListener.kt rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/config/AudioMixPreset.kt (73%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/config/EncodingProperties.kt (58%) create mode 100644 encore-common/src/main/kotlin/se/svt/oss/encore/config/EncoreProperties.kt rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/CancelEvent.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/EncoreJob.kt (91%) create mode 100644 encore-common/src/main/kotlin/se/svt/oss/encore/model/SegmentProgressEvent.kt rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/Status.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/callback/JobProgress.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/input/Input.kt (92%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/mediafile/AudioLayout.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/mediafile/Extensions.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/output/Output.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/profile/AudioEncode.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/profile/AudioEncoder.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/profile/ChannelId.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/profile/ChannelLayout.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/profile/GenericVideoEncode.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/profile/OutputProducer.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/profile/Profile.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/profile/SimpleAudioEncode.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncode.kt (96%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncode.kt (96%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/profile/VideoEncode.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/profile/X264Encode.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/profile/X265Encode.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/profile/X26XEncode.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/model/queue/QueueItem.kt (65%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/process/CommandBuilder.kt (96%) create mode 100644 encore-common/src/main/kotlin/se/svt/oss/encore/process/SegmentUtil.kt rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/repository/ChannelLayoutConverters.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/repository/EncoreJobRepository.kt (89%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/repository/OffsetDateTimeConverters.kt (100%) create mode 100644 encore-common/src/main/kotlin/se/svt/oss/encore/service/EncoreService.kt rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/service/FfmpegExecutor.kt (80%) create mode 100644 encore-common/src/main/kotlin/se/svt/oss/encore/service/callback/CallbackClient.kt rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/service/callback/CallbackService.kt (94%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/service/localencode/LocalEncodeService.kt (100%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/service/mediaanalyzer/MediaAnalyzerService.kt (63%) rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/service/profile/ProfileService.kt (71%) create mode 100644 encore-common/src/main/kotlin/se/svt/oss/encore/service/queue/QueueService.kt rename {src => encore-common/src}/main/kotlin/se/svt/oss/encore/service/queue/QueueUtil.kt (100%) rename {src => encore-common/src}/test/kotlin/se/svt/oss/encore/EncoreClient.kt (51%) rename {src => encore-common/src}/test/kotlin/se/svt/oss/encore/EncoreIntegrationTest.kt (97%) rename {src => encore-common/src}/test/kotlin/se/svt/oss/encore/EncoreIntegrationTestBase.kt (91%) create mode 100644 encore-common/src/test/kotlin/se/svt/oss/encore/EncoreRuntimeHintsTest.kt rename {src => encore-common/src}/test/kotlin/se/svt/oss/encore/LocalEncodeIntegrationTest.kt (100%) create mode 100644 encore-common/src/test/kotlin/se/svt/oss/encore/TestConfig.kt rename {src => encore-common/src}/test/kotlin/se/svt/oss/encore/TestUtils.kt (100%) rename {src => encore-common/src}/test/kotlin/se/svt/oss/encore/model/input/InputTest.kt (96%) rename {src => encore-common/src}/test/kotlin/se/svt/oss/encore/model/mediafile/MediaFileExtensionsTest.kt (100%) rename {src => encore-common/src}/test/kotlin/se/svt/oss/encore/model/profile/AudioEncodeTest.kt (99%) rename {src => encore-common/src}/test/kotlin/se/svt/oss/encore/model/profile/GenericVideoEncodeTest.kt (100%) rename {src => encore-common/src}/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncodeTest.kt (100%) rename {src => encore-common/src}/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncodeTest.kt (100%) rename {src => encore-common/src}/test/kotlin/se/svt/oss/encore/model/profile/VideoEncodeTest.kt (100%) rename {src => encore-common/src}/test/kotlin/se/svt/oss/encore/model/profile/X264EncodeTest.kt (100%) rename {src => encore-common/src}/test/kotlin/se/svt/oss/encore/model/profile/X265EncodeTest.kt (100%) rename {src => encore-common/src}/test/kotlin/se/svt/oss/encore/model/queue/QueueItemTest.kt (66%) rename {src => encore-common/src}/test/kotlin/se/svt/oss/encore/process/CommandBuilderTest.kt (100%) create mode 100644 encore-common/src/test/kotlin/se/svt/oss/encore/process/SegmentUtilTest.kt rename {src => encore-common/src}/test/kotlin/se/svt/oss/encore/repository/EncoreJobRepositoryTest.kt (79%) rename {src => encore-common/src}/test/kotlin/se/svt/oss/encore/service/callback/CallbackServiceTest.kt (76%) rename {src => encore-common/src}/test/kotlin/se/svt/oss/encore/service/profile/ProfileServiceTest.kt (100%) create mode 100644 encore-common/src/test/kotlin/se/svt/oss/encore/service/queue/QueueServiceTest.kt rename {src => encore-common/src}/test/kotlin/se/svt/oss/encore/service/queue/QueueUtilTest.kt (100%) rename {src => encore-common/src}/test/resources/application-test-local.yml (83%) rename {src => encore-common/src}/test/resources/application-test.yml (91%) rename {src => encore-common/src}/test/resources/input/multiple-audio-file.json (100%) rename {src => encore-common/src}/test/resources/input/multiple-video-file.json (100%) rename {src => encore-common/src}/test/resources/input/multiple_audio.mp4 (100%) rename {src => encore-common/src}/test/resources/input/multiple_video.mp4 (100%) rename {src => encore-common/src}/test/resources/input/portrait-video-file.json (100%) rename {src => encore-common/src}/test/resources/input/test.mp4 (100%) rename {src => encore-common/src}/test/resources/input/test_stereo.mp4 (100%) rename {src => encore-common/src}/test/resources/input/video-file-long.json (100%) rename {src => encore-common/src}/test/resources/input/video-file.json (100%) rename {src => encore-common/src}/test/resources/profile/archive.yml (100%) rename {src => encore-common/src}/test/resources/profile/audio-streams.yml (100%) rename {src => encore-common/src}/test/resources/profile/dpb_size_failed.yml (100%) rename {src => encore-common/src}/test/resources/profile/multiple_inputs.yml (100%) rename {src => encore-common/src}/test/resources/profile/profiles.yml (100%) rename {src => encore-common/src}/test/resources/profile/program-x265.yml (100%) rename {src => encore-common/src}/test/resources/profile/program.yml (100%) rename {src => encore-common/src}/test/resources/profile/test_profile_invalid.yml (100%) create mode 100644 encore-web/Dockerfile rename Dockerfile => encore-web/Dockerfile-jar (67%) create mode 100644 encore-web/build.gradle.kts rename {src => encore-web/src}/main/kotlin/se/svt/oss/encore/EncoreApplication.kt (85%) create mode 100644 encore-web/src/main/kotlin/se/svt/oss/encore/EncoreWebRuntimeHints.kt rename {src => encore-web/src}/main/kotlin/se/svt/oss/encore/OpenAPIConfiguration.kt (100%) rename {src => encore-web/src}/main/kotlin/se/svt/oss/encore/RepositoryConfiguration.kt (100%) rename {src => encore-web/src}/main/kotlin/se/svt/oss/encore/SchedulingConfiguration.kt (100%) rename {src => encore-web/src}/main/kotlin/se/svt/oss/encore/SecurityConfiguration.kt (51%) rename {src => encore-web/src}/main/kotlin/se/svt/oss/encore/controller/EncoreController.kt (98%) rename {src => encore-web/src}/main/kotlin/se/svt/oss/encore/handlers/EncoreJobHandler.kt (100%) create mode 100644 encore-web/src/main/kotlin/se/svt/oss/encore/poll/JobPoller.kt create mode 100644 encore-web/src/main/resources/application.yml rename {src => encore-web/src}/main/resources/asciilogo.txt (100%) create mode 100644 encore-web/src/main/resources/logback-json.xml create mode 100644 encore-web/src/test/kotlin/se/svt/oss/encore/EncoreEndpointAccessIntegrationTest.kt create mode 100644 encore-web/src/test/kotlin/se/svt/oss/encore/EncoreWebRuntimeHintsTest.kt rename {src => encore-web/src}/test/kotlin/se/svt/oss/encore/controller/EncoreControllerTest.kt (97%) rename {src => encore-web/src}/test/kotlin/se/svt/oss/encore/handlers/EncoreJobHandlerTest.kt (82%) create mode 100644 encore-web/src/test/kotlin/se/svt/oss/encore/poll/JobPollerTest.kt create mode 100644 encore-web/src/test/resources/application-test.yml create mode 100644 encore-web/src/test/resources/profile/archive.yml create mode 100644 encore-web/src/test/resources/profile/audio-streams.yml create mode 100644 encore-web/src/test/resources/profile/dpb_size_failed.yml create mode 100644 encore-web/src/test/resources/profile/multiple_inputs.yml create mode 100644 encore-web/src/test/resources/profile/profiles.yml create mode 100644 encore-web/src/test/resources/profile/program-x265.yml create mode 100644 encore-web/src/test/resources/profile/program.yml create mode 100644 encore-web/src/test/resources/profile/test_profile_invalid.yml create mode 100644 encore-worker/Dockerfile create mode 100644 encore-worker/build.gradle.kts create mode 100644 encore-worker/src/main/kotlin/se/svt/oss/encore/EncoreWorkerApplication.kt create mode 100644 encore-worker/src/main/resources/application.yml create mode 100644 encore-worker/src/main/resources/asciilogo.txt create mode 100644 encore-worker/src/main/resources/logback-json.xml create mode 100644 encore-worker/src/test/kotlin/se/svt/oss/encore/EncoreWorkerApplicationTest.kt rename src/main/resources/encore_logo.png => encore_logo.png (100%) delete mode 100644 settings.gradle create mode 100644 settings.gradle.kts delete mode 100644 src/main/kotlin/se/svt/oss/encore/FeignConfiguration.kt delete mode 100644 src/main/kotlin/se/svt/oss/encore/config/EncoreProperties.kt delete mode 100644 src/main/kotlin/se/svt/oss/encore/repository/URIConverters.kt delete mode 100644 src/main/kotlin/se/svt/oss/encore/repository/UUIDConverters.kt delete mode 100644 src/main/kotlin/se/svt/oss/encore/service/EncoreService.kt delete mode 100644 src/main/kotlin/se/svt/oss/encore/service/callback/CallbackClient.kt delete mode 100644 src/main/kotlin/se/svt/oss/encore/service/poll/JobPoller.kt delete mode 100644 src/main/kotlin/se/svt/oss/encore/service/queue/QueueService.kt delete mode 100644 src/main/resources/application-local.yml delete mode 100644 src/main/resources/application.yml delete mode 100644 src/test/kotlin/se/svt/oss/encore/EncoreEndpointAccessIntegrationTest.kt delete mode 100644 src/test/kotlin/se/svt/oss/encore/service/poll/JobPollerTest.kt delete mode 100644 src/test/kotlin/se/svt/oss/encore/service/queue/QueueServiceTest.kt diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml deleted file mode 100644 index 33102c8a..00000000 --- a/.github/workflows/publish.yaml +++ /dev/null @@ -1,100 +0,0 @@ -name: Create and publish a Docker image - -on: - push: - branches: ['master','main'] - tags: - - 'v*' - -env: - REGISTRY: ghcr.io - IMAGE_NAME: encore-debian - DOCKER_BASE_IMAGE: ghcr.io/svt/avtools-osadl-jre-debian:latest - -jobs: - build-artifact: - runs-on: ubuntu-latest - container: - image: ghcr.io/svt/avtools-osadl-debian:latest - options: --user root - permissions: - contents: read - packages: write - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - - name: Set up JDK 11 - uses: actions/setup-java@v2 - with: - java-version: '11' - distribution: 'adopt' - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Build with Gradle - run: ./gradlew build -x test - - - name: cache build - uses: actions/cache@v2 - id: restore-build - with: - path: ./build - key: ${{ github.sha }} - - build-and-push-image: - runs-on: ubuntu-latest - needs: build-artifact - permissions: - contents: read - packages: write - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - - name: Get Cache Build - uses: actions/cache@v2 - id: restore-build - with: - path: ./build - key: ${{ github.sha }} - - run: ls ./build - - - name: 'Echo download path' - run: echo ${{steps.download.outputs.download-path}} - - - name: Log in to the Container registry - uses: docker/login-action@v1 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v3 - with: - images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} - flavor: | - latest=true - tags: | - type=raw,value={{branch}},priority=1,enable=${{ !startsWith(github.ref, 'refs/tags/v') }} - type=ref,event=tag,priority=2 - type=raw,value=${{ env.IMAGE_NAME }}-{{branch}}-{{date 'YYYYMMDD'}}-{{sha}},priority=31,enable=${{ !startsWith(github.ref, 'refs/tags/v') }} - type=raw,value=${{ env.IMAGE_NAME }}-{{tag}}-{{date 'YYYYMMDD'}}-{{sha}},priority=32, enable=${{ startsWith(github.ref, 'refs/tags/v') }} - - - name: Build and push Docker image - uses: docker/build-push-action@v2 - with: - context: . - build-args: | - DOCKER_BASE_IMAGE=${{ env.DOCKER_BASE_IMAGE }} - USR=avtools - platforms: x86_64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - diff --git a/.github/workflows/publishjar.yml b/.github/workflows/publishjar.yml index 7bd155e1..8b22d914 100644 --- a/.github/workflows/publishjar.yml +++ b/.github/workflows/publishjar.yml @@ -1,4 +1,4 @@ -name: Create and publish a Spring Boot jar/Spring Boot LaunchScript Jar +name: Create and publish Spring Boot jars and GraalVm native images on: push: @@ -13,25 +13,28 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - - name: Set up JDK 11 - uses: actions/setup-java@v2 + - name: Set up GraalVm + uses: graalvm/setup-graalvm@v1 with: - java-version: '11' - distribution: 'adopt' + java-version: '17' + distribution: 'graalvm-community' + github-token: ${{ secrets.GITHUB_TOKEN }} - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Build jars with Gradle - run: | - ./gradlew build -x test + - name: Build jars and native images with Gradle + run: gradlew build nativeCompile -x test - - name: Release Jars + - name: Release Jars and native images uses: softprops/action-gh-release@v1 if: startsWith(github.ref, 'refs/tags/') with: files: | - build/libs/encore*.jar + encore-web/build/libs/encore-web*boot.jar + encore-web/build/native/nativeCompile/encore-web + encore-worker/build/libs/encore-worker*boot.jar + encore-worker/build/native/nativeCompile/encore-worker diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 6b4e05a5..bcb93c49 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -4,14 +4,6 @@ - - - - - - + - - - + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts deleted file mode 100644 index d946db2c..00000000 --- a/build.gradle.kts +++ /dev/null @@ -1,152 +0,0 @@ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - -plugins { - idea - jacoco - id("org.springframework.boot") version "2.7.6" - id("se.ascp.gradle.gradle-versions-filter") version "0.1.16" - kotlin("jvm") version "1.7.21" - kotlin("plugin.spring") version "1.7.21" - id("com.github.fhermansson.assertj-generator") version "1.1.4" - id("org.jmailen.kotlinter") version "3.12.0" - id("io.spring.dependency-management") version "1.1.0" - id("pl.allegro.tech.build.axion-release") version "1.14.2" - - //openapi generation - id("com.github.johnrengelman.processes") version "0.5.0" - id("org.springdoc.openapi-gradle-plugin") version "1.5.0" -} - - -project.version = scmVersion.version - -apply(from = "checks.gradle") - -group = "se.svt.oss" - -assertjGenerator { - classOrPackageNames = arrayOf( - "se.svt.oss.encore.model", - "se.svt.oss.mediaanalyzer.file" - ) - entryPointPackage = "se.svt.oss.encore" -} - -kotlinter { - disabledRules = arrayOf( - "import-ordering", - "trailing-comma-on-declaration-site", - "trailing-comma-on-call-site" - ) -} - -tasks.lintKotlinTest { - source = (source - fileTree("src/test/generated-java")).asFileTree -} - -tasks.test { - useJUnitPlatform() -} - -openApi { - customBootRun { - args.set(listOf("--spring.profiles.active=local")) - } -} - -tasks.withType { - kotlinOptions.jvmTarget = "11" -} - -repositories { - mavenCentral() -} - -//don't build the extra plain jars that was auto-added in Spring Boot 2.5.0, -//https://docs.spring.io/spring-boot/docs/current/gradle-plugin/reference/htmlsingle/#packaging-executable.and-plain-archives -tasks.getByName("jar") { - enabled = false -} - -configurations { - implementation { - exclude(module = "spring-boot-starter-logging") - exclude(module = "lombok") - } -} - -dependencyManagement { - imports { - mavenBom("org.springframework.cloud:spring-cloud-dependencies:2021.0.5") - } -} - -val redissonVersion = "3.18.1" - -dependencies { - implementation("se.svt.oss:media-analyzer:2.0.1") - implementation(kotlin("reflect")) - - implementation("org.springframework.boot:spring-boot-starter-web") - implementation("org.springframework.boot:spring-boot-starter-log4j2") - implementation("org.springframework.boot:spring-boot-starter-actuator") - implementation("org.springframework.boot:spring-boot-starter-data-rest") - implementation("org.springframework.boot:spring-boot-starter-data-redis") - implementation("org.springframework.boot:spring-boot-starter-validation") - implementation("org.springframework.cloud:spring-cloud-starter-config") - implementation("org.springframework.boot:spring-boot-starter-security") - - implementation("org.redisson:redisson-spring-boot-starter:$redissonVersion") - implementation("org.redisson:redisson-spring-data-27:$redissonVersion") // match boot version - implementation("io.github.microutils:kotlin-logging:3.0.2") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.6.4") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:1.6.4") - - implementation("org.springframework.cloud:spring-cloud-starter-openfeign") - implementation("io.github.openfeign:feign-okhttp") - implementation("org.springframework.retry:spring-retry") - implementation("org.springframework.boot:spring-boot-starter-aop") - - //openapi generation - implementation("org.springdoc:springdoc-openapi-ui:1.6.12") - implementation("org.springdoc:springdoc-openapi-kotlin:1.6.12") - implementation("org.springdoc:springdoc-openapi-data-rest:1.6.12") - implementation("org.springdoc:springdoc-openapi-hateoas:1.6.12") - - testImplementation("se.svt.oss:junit5-redis-extension:3.0.0") - testImplementation("se.svt.oss:random-port-initializer:1.0.5") - testImplementation("org.awaitility:awaitility") - testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.springframework.security:spring-security-test") - testImplementation("org.assertj:assertj-core") - testImplementation("io.mockk:mockk:1.13.2") - testImplementation("com.github.tomakehurst:wiremock-jre8:2.35.0") - testImplementation("com.ninja-squad:springmockk:3.1.1") - testImplementation("org.junit.jupiter:junit-jupiter-api") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") -} - - -tasks.wrapper { - distributionType = Wrapper.DistributionType.ALL - gradleVersion = "7.5.1" -} - -val integrationTestsPreReq = setOf("mediainfo", "ffmpeg", "ffprobe").map { - - tasks.create("Verify $it is in path, required for integration tests", Exec::class.java) { - isIgnoreExitValue = true - executable = it - - if (!it.equals("mediainfo")) { - args("-hide_banner") - } - } -} - -tasks.test { - dependsOn(integrationTestsPreReq) -} - diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 00000000..1fe42474 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() + gradlePluginPortal() +} +kotlin { + jvmToolchain(17) +} +dependencies { + implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.9.10")) + implementation(platform("org.springframework.boot:spring-boot-dependencies:3.1.3")) + implementation(platform("org.springframework.cloud:spring-cloud-dependencies:2022.0.4")) + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin") + implementation("org.jetbrains.kotlin:kotlin-allopen") + implementation("org.springframework.boot:spring-boot-gradle-plugin:3.1.3") + implementation("org.jmailen.gradle:kotlinter-gradle:3.13.0") + implementation("pl.allegro.tech.build:axion-release-plugin:1.14.3") + implementation("org.graalvm.buildtools:native-gradle-plugin:0.9.25") + implementation("com.github.fhermansson:assertj-gradle-plugin:1.1.5") + implementation("se.ascp.gradle:gradle-versions-filter:0.1.16") +} diff --git a/buildSrc/src/main/kotlin/encore.kotlin-conventions.gradle.kts b/buildSrc/src/main/kotlin/encore.kotlin-conventions.gradle.kts new file mode 100644 index 00000000..1f7cb68c --- /dev/null +++ b/buildSrc/src/main/kotlin/encore.kotlin-conventions.gradle.kts @@ -0,0 +1,64 @@ +plugins { + idea + jacoco + kotlin("jvm") + kotlin("plugin.spring") + id("pl.allegro.tech.build.axion-release") + id("com.github.fhermansson.assertj-generator") + id("org.jmailen.kotlinter") + id("se.ascp.gradle.gradle-versions-filter") +} + +group = "se.svt.oss" +project.version = scmVersion.version + +tasks.withType { + useJUnitPlatform() +} +apply { from("../checks.gradle") } +repositories { + mavenCentral() +} + +kotlin { + jvmToolchain(17) +} +tasks.lintKotlinTest { + source = (source - fileTree("src/test/generated-java")).asFileTree +} +tasks.formatKotlinTest { + source = (source - fileTree("src/test/generated-java")).asFileTree +} +kotlinter { + disabledRules = arrayOf( + "import-ordering", + "trailing-comma-on-declaration-site", + "trailing-comma-on-call-site" + ) +} +assertjGenerator { + classOrPackageNames = arrayOf( + "se.svt.oss.encore.model", + "se.svt.oss.mediaanalyzer.file" + ) + entryPointPackage = "se.svt.oss.encore" + useJakartaAnnotations = true +} + +val redissonVersion = "3.23.2" + +dependencies { + implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.9.10")) + implementation(platform("org.springframework.boot:spring-boot-dependencies:3.1.3")) + implementation(platform("org.springframework.cloud:spring-cloud-dependencies:2022.0.4")) + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("io.github.microutils:kotlin-logging:3.0.5") + implementation("org.redisson:redisson-spring-boot-starter:$redissonVersion") + implementation("org.redisson:redisson-spring-data-31:$redissonVersion") // match boot version + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.assertj:assertj-core") + testImplementation("io.mockk:mockk:1.13.7") +} + + + diff --git a/buildSrc/src/main/kotlin/encore.spring-boot-app-conventions.gradle.kts b/buildSrc/src/main/kotlin/encore.spring-boot-app-conventions.gradle.kts new file mode 100644 index 00000000..530c692f --- /dev/null +++ b/buildSrc/src/main/kotlin/encore.spring-boot-app-conventions.gradle.kts @@ -0,0 +1,21 @@ +import org.springframework.boot.gradle.tasks.bundling.BootJar +plugins { + kotlin("jvm") + kotlin("plugin.spring") + id("org.springframework.boot") + id("org.graalvm.buildtools.native") +} + +tasks.named("bootJar") { + archiveClassifier.set("boot") +} + +dependencies { + implementation("org.springframework.boot:spring-boot-starter") + implementation("org.springframework.cloud:spring-cloud-starter-config") + implementation("org.springframework.boot:spring-boot-starter-logging") + implementation("net.logstash.logback:logstash-logback-encoder:7.4") +} + + + diff --git a/checks.gradle b/checks.gradle index afc1d2a3..1d1e2b12 100644 --- a/checks.gradle +++ b/checks.gradle @@ -5,12 +5,12 @@ jacocoTestCoverageVerification { includes = ['se.svt.oss.encore.*'] excludes = [ '*.invoke()', - '*.Security.getEnabled()', + '*.EncoreProperties*.get*()', '*.DefaultConstructorMarker*', - '*.EncoreApplicationKt.*', + '*ApplicationKt.main*', '*.static {...}', - '*.model.*.*.get*', - '*.service.localencode.LocalEncodeService.moveFile*' + '*.model.*.get*', + '*.service.localencode.LocalEncodeService.moveFile*', ] limit { counter = 'LINE' @@ -21,8 +21,6 @@ jacocoTestCoverageVerification { failOnViolation = true } } -jacoco { - toolVersion = '0.8.7' -} + jacocoTestCoverageVerification.dependsOn jacocoTestReport check.dependsOn jacocoTestCoverageVerification diff --git a/encore-common/build.gradle.kts b/encore-common/build.gradle.kts new file mode 100644 index 00000000..4075f0d2 --- /dev/null +++ b/encore-common/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + id("encore.kotlin-conventions") +} + +dependencies { + + api("se.svt.oss:media-analyzer:2.0.3") + implementation(kotlin("reflect")) + + compileOnly("org.springdoc:springdoc-openapi-starter-webmvc-api:2.2.0") + compileOnly("org.springframework.data:spring-data-rest-core") + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springframework.boot:spring-boot-starter-validation") + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:1.7.3") + + testImplementation(project(":encore-web")) + testImplementation("se.svt.oss:junit5-redis-extension:3.0.0") + testImplementation("org.springframework.security:spring-security-test") + testImplementation("org.awaitility:awaitility") + testImplementation("com.github.tomakehurst:wiremock-jre8-standalone:2.35.0") + testImplementation("org.springframework.boot:spring-boot-starter-webflux") + testImplementation("org.springframework.boot:spring-boot-starter-data-rest") +} + + +val integrationTestsPreReq = setOf("mediainfo", "ffmpeg", "ffprobe").map { + + tasks.create("Verify $it is in path, required for integration tests", Exec::class.java) { + isIgnoreExitValue = true + executable = it + + if (!it.equals("mediainfo")) { + args("-hide_banner") + } + } +} + +tasks.test { + dependsOn(integrationTestsPreReq) +} + diff --git a/encore-common/src/main/kotlin/se/svt/oss/encore/ClientConfiguration.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/ClientConfiguration.kt new file mode 100644 index 00000000..a99e75bd --- /dev/null +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/ClientConfiguration.kt @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpHeaders +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.support.WebClientAdapter +import org.springframework.web.service.invoker.HttpServiceProxyFactory +import se.svt.oss.encore.service.callback.CallbackClient + +@Configuration +class ClientConfiguration { + + @Bean + fun callbackClient(@Value("\${service.name:encore}") userAgent: String): CallbackClient { + val webClient = WebClient.builder() + .defaultHeader(HttpHeaders.USER_AGENT, userAgent) + .build() + return HttpServiceProxyFactory.builder(WebClientAdapter.forClient(webClient)) + .build() + .createClient(CallbackClient::class.java) + } +} diff --git a/encore-common/src/main/kotlin/se/svt/oss/encore/EncoreRuntimeHints.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/EncoreRuntimeHints.kt new file mode 100644 index 00000000..c69d664e --- /dev/null +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/EncoreRuntimeHints.kt @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore + +import org.springframework.aot.hint.MemberCategory +import org.springframework.aot.hint.RuntimeHints +import org.springframework.aot.hint.RuntimeHintsRegistrar +import se.svt.oss.encore.config.AudioMixPreset +import se.svt.oss.encore.config.EncodingProperties +import se.svt.oss.encore.config.EncoreProperties + +class EncoreRuntimeHints : RuntimeHintsRegistrar { + override fun registerHints(hints: RuntimeHints, classLoader: ClassLoader?) { + hints.reflection() + .registerType( + EncoreProperties::class.java, + MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, + ) + .registerType( + EncodingProperties::class.java, + MemberCategory.INVOKE_DECLARED_CONSTRUCTORS + ) + .registerType( + AudioMixPreset::class.java, + MemberCategory.INVOKE_DECLARED_CONSTRUCTORS + ) + } +} diff --git a/src/main/kotlin/se/svt/oss/encore/MediaAnalyzerConfiguration.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/MediaAnalyzerConfiguration.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/MediaAnalyzerConfiguration.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/MediaAnalyzerConfiguration.kt diff --git a/src/main/kotlin/se/svt/oss/encore/RedisConfiguration.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/RedisConfiguration.kt similarity index 64% rename from src/main/kotlin/se/svt/oss/encore/RedisConfiguration.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/RedisConfiguration.kt index c51453a8..816a9396 100644 --- a/src/main/kotlin/se/svt/oss/encore/RedisConfiguration.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/RedisConfiguration.kt @@ -7,21 +7,42 @@ package se.svt.oss.encore import com.fasterxml.jackson.databind.ObjectMapper import org.redisson.codec.JsonJacksonCodec import org.redisson.spring.starter.RedissonAutoConfigurationCustomizer +import org.springframework.aot.hint.annotation.RegisterReflectionForBinding import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.data.redis.core.RedisKeyValueAdapter import org.springframework.data.redis.core.convert.RedisCustomConversions import org.springframework.data.redis.repository.configuration.EnableRedisRepositories +import se.svt.oss.encore.model.CancelEvent +import se.svt.oss.encore.model.EncoreJob +import se.svt.oss.encore.model.SegmentProgressEvent +import se.svt.oss.encore.model.input.AudioInput +import se.svt.oss.encore.model.input.AudioVideoInput +import se.svt.oss.encore.model.input.VideoInput +import se.svt.oss.encore.model.queue.QueueItem import se.svt.oss.encore.repository.ByteArrayToChannelLayoutConverter import se.svt.oss.encore.repository.ByteArrayToOffsetDateTimeConverter -import se.svt.oss.encore.repository.ByteArrayToURIConverter -import se.svt.oss.encore.repository.ByteArrayToUUIDConverter import se.svt.oss.encore.repository.ChannelLayoutToByteArrayConverter import se.svt.oss.encore.repository.OffsetDateTimeToByteArrayConverter -import se.svt.oss.encore.repository.URIToByteArrayConverter -import se.svt.oss.encore.repository.UUIDToByteArrayConverter +import se.svt.oss.mediaanalyzer.file.AudioFile +import se.svt.oss.mediaanalyzer.file.ImageFile +import se.svt.oss.mediaanalyzer.file.SubtitleFile +import se.svt.oss.mediaanalyzer.file.VideoFile @Configuration +@RegisterReflectionForBinding( + EncoreJob::class, + AudioVideoInput::class, + VideoInput::class, + AudioInput::class, + ImageFile::class, + VideoFile::class, + AudioFile::class, + SubtitleFile::class, + CancelEvent::class, + SegmentProgressEvent::class, + QueueItem::class +) @EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP) class RedisConfiguration { @@ -30,10 +51,6 @@ class RedisConfiguration { listOf( OffsetDateTimeToByteArrayConverter(), ByteArrayToOffsetDateTimeConverter(), - UUIDToByteArrayConverter(), - ByteArrayToUUIDConverter(), - URIToByteArrayConverter(), - ByteArrayToURIConverter(), ChannelLayoutToByteArrayConverter(), ByteArrayToChannelLayoutConverter() ) diff --git a/src/main/kotlin/se/svt/oss/encore/cancellation/CancellationListener.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/cancellation/CancellationListener.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/cancellation/CancellationListener.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/cancellation/CancellationListener.kt diff --git a/encore-common/src/main/kotlin/se/svt/oss/encore/cancellation/SegmentProgressListener.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/cancellation/SegmentProgressListener.kt new file mode 100644 index 00000000..f3f81db5 --- /dev/null +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/cancellation/SegmentProgressListener.kt @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore.cancellation + +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.channels.trySendBlocking +import org.redisson.api.listener.MessageListener +import se.svt.oss.encore.model.SegmentProgressEvent +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean + +class SegmentProgressListener( + private val encoreJobId: UUID, + private val coroutineJob: Job, + private val totalSegments: Int, + private val progressChannel: SendChannel +) : MessageListener { + + private val completedSegments: MutableSet = ConcurrentHashMap.newKeySet() + val anyFailed = AtomicBoolean(false) + + fun completed() = anyFailed.get() || completedSegments.size == totalSegments + + fun completionCount() = completedSegments.size + + override fun onMessage(channel: CharSequence?, msg: SegmentProgressEvent?) { + if (msg?.jobId == encoreJobId) { + if (!msg.success) { + progressChannel.close() + anyFailed.set(true) + coroutineJob.cancel("Segment ${msg.segment} failed!") + } else if (completedSegments.add(msg.segment)) { + val percent = (completedSegments.size * 100.0 / totalSegments).toInt() + progressChannel.trySendBlocking(percent) + if (percent == 100) { + progressChannel.close() + } + } + } + } +} diff --git a/src/main/kotlin/se/svt/oss/encore/config/AudioMixPreset.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/config/AudioMixPreset.kt similarity index 73% rename from src/main/kotlin/se/svt/oss/encore/config/AudioMixPreset.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/config/AudioMixPreset.kt index e8b8a3cd..f8e1d063 100644 --- a/src/main/kotlin/se/svt/oss/encore/config/AudioMixPreset.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/config/AudioMixPreset.kt @@ -3,10 +3,13 @@ // SPDX-License-Identifier: EUPL-1.2 package se.svt.oss.encore.config +import org.springframework.boot.context.properties.NestedConfigurationProperty import se.svt.oss.encore.model.profile.ChannelLayout data class AudioMixPreset( val fallbackToAuto: Boolean = true, + @NestedConfigurationProperty val defaultPan: Map = emptyMap(), + @NestedConfigurationProperty val panMapping: Map> = emptyMap(), ) diff --git a/src/main/kotlin/se/svt/oss/encore/config/EncodingProperties.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/config/EncodingProperties.kt similarity index 58% rename from src/main/kotlin/se/svt/oss/encore/config/EncodingProperties.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/config/EncodingProperties.kt index bb044237..af78ad46 100644 --- a/src/main/kotlin/se/svt/oss/encore/config/EncodingProperties.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/config/EncodingProperties.kt @@ -1,9 +1,16 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + package se.svt.oss.encore.config +import org.springframework.boot.context.properties.NestedConfigurationProperty import se.svt.oss.encore.model.profile.ChannelLayout data class EncodingProperties( + @NestedConfigurationProperty val audioMixPresets: Map = mapOf("default" to AudioMixPreset()), + @NestedConfigurationProperty val defaultChannelLayouts: Map = emptyMap(), val flipWidthHeightIfPortrait: Boolean = true ) diff --git a/encore-common/src/main/kotlin/se/svt/oss/encore/config/EncoreProperties.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/config/EncoreProperties.kt new file mode 100644 index 00000000..f76b7c63 --- /dev/null +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/config/EncoreProperties.kt @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.NestedConfigurationProperty +import java.io.File +import java.time.Duration + +@ConfigurationProperties("encore-settings") +data class EncoreProperties( + /** + * transcode to local tmp dir before copying to output folder + */ + val localTemporaryEncode: Boolean = false, + /** + * number of work queues and threads + */ + val concurrency: Int = 2, + /** + * time to wait after application start before polling queue + */ + val pollInitialDelay: Duration = Duration.ofSeconds(10), + /** + * time to wait between polls + */ + val pollDelay: Duration = Duration.ofSeconds(5), + /** + * poll only the specified queue + */ + val pollQueue: Int? = null, + /** + * disable polling. could be set on encore-web if all transcoding is to be done by encore-workers + */ + val pollDisabled: Boolean = false, + /** + * should queues with higher prio be poller before the queue assigned to thread or worker + */ + val pollHigherPrio: Boolean = true, + /** + * if true, encore-worker will poll the queue until empty before shutting down, otherwise just poll once + */ + val workerDrainQueue: Boolean = false, + val redisKeyPrefix: String = "encore", + /** + * optional web security settings + */ + val security: Security = Security(), + /** + * open api contact information + */ + val openApi: OpenApi = OpenApi(), + /** + * path to directory shared by encore instances. required for encoding in segments + */ + val sharedWorkDir: File? = null, + /** + * timeout for segemnted encode before failing + */ + val segmentedEncodeTimeout: Duration = Duration.ofMinutes(120), + @NestedConfigurationProperty + val encoding: EncodingProperties = EncodingProperties() +) { + data class Security( + val enabled: Boolean = false, + val userPassword: String = "", + val adminPassword: String = "" + ) + + data class OpenApi( + val title: String = "Encore OpenAPI", + val description: String = "Endpoints for Encore", + val contactName: String = "", + val contactUrl: String = "", + val contactEmail: String = "" + ) +} diff --git a/src/main/kotlin/se/svt/oss/encore/model/CancelEvent.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/CancelEvent.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/model/CancelEvent.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/CancelEvent.kt diff --git a/src/main/kotlin/se/svt/oss/encore/model/EncoreJob.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/EncoreJob.kt similarity index 91% rename from src/main/kotlin/se/svt/oss/encore/model/EncoreJob.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/EncoreJob.kt index 37ab924d..1ec8f339 100644 --- a/src/main/kotlin/se/svt/oss/encore/model/EncoreJob.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/model/EncoreJob.kt @@ -13,14 +13,13 @@ import org.springframework.data.redis.core.index.Indexed import org.springframework.validation.annotation.Validated import se.svt.oss.encore.model.input.Input import se.svt.oss.mediaanalyzer.file.MediaFile -import java.net.URI import java.time.OffsetDateTime import java.util.UUID -import javax.validation.constraints.Max -import javax.validation.constraints.Min -import javax.validation.constraints.NotBlank -import javax.validation.constraints.NotEmpty -import javax.validation.constraints.Positive +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotEmpty +import jakarta.validation.constraints.Positive @Validated @RedisHash("encore-jobs", timeToLive = (60 * 60 * 24 * 7).toLong()) // 1 week ttl @@ -82,7 +81,7 @@ data class EncoreJob( example = "http://projectx/encorecallback", nullable = true ) - val progressCallbackUri: URI? = null, + val progressCallbackUri: String? = null, @Schema( description = "The queue priority of the EncoreJob", @@ -94,6 +93,14 @@ data class EncoreJob( @Max(100) val priority: Int = 0, + @Schema( + description = "Transcode segments of specified length in seconds in parallell. Should be a multiple of target GOP.", + example = "19.2", + nullable = true + ) + @Positive + val segmentLength: Double? = null, + @Schema( description = "The exception message, if the EncoreJob failed", example = "input/output error", diff --git a/encore-common/src/main/kotlin/se/svt/oss/encore/model/SegmentProgressEvent.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/SegmentProgressEvent.kt new file mode 100644 index 00000000..b521b8f7 --- /dev/null +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/model/SegmentProgressEvent.kt @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore.model + +import com.fasterxml.jackson.annotation.JsonTypeInfo +import java.util.UUID + +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) +data class SegmentProgressEvent(val jobId: UUID, val segment: Int, val success: Boolean) diff --git a/src/main/kotlin/se/svt/oss/encore/model/Status.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/Status.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/model/Status.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/Status.kt diff --git a/src/main/kotlin/se/svt/oss/encore/model/callback/JobProgress.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/callback/JobProgress.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/model/callback/JobProgress.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/callback/JobProgress.kt diff --git a/src/main/kotlin/se/svt/oss/encore/model/input/Input.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/input/Input.kt similarity index 92% rename from src/main/kotlin/se/svt/oss/encore/model/input/Input.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/input/Input.kt index 95bc460b..44ce36dd 100644 --- a/src/main/kotlin/se/svt/oss/encore/model/input/Input.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/model/input/Input.kt @@ -14,8 +14,8 @@ import se.svt.oss.mediaanalyzer.file.FractionString import se.svt.oss.mediaanalyzer.file.MediaContainer import se.svt.oss.mediaanalyzer.file.MediaFile import se.svt.oss.mediaanalyzer.file.VideoFile -import javax.validation.constraints.Pattern -import javax.validation.constraints.PositiveOrZero +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.PositiveOrZero const val TYPE_AUDIO_VIDEO = "AudioVideo" const val TYPE_AUDIO = "Audio" @@ -57,6 +57,10 @@ sealed interface Input { nullable = true ) val seekTo: Double? + + val copyTs: Boolean + + fun withSeekTo(seekTo: Double): Input } sealed interface AudioIn : Input { @@ -153,7 +157,8 @@ data class AudioInput( override var analyzed: MediaFile? = null, override val audioStream: Int? = null, override val channelLayout: ChannelLayout? = null, - override val seekTo: Double? = null + override val seekTo: Double? = null, + override val copyTs: Boolean = false ) : AudioIn { override val analyzedAudio: MediaContainer @JsonIgnore @@ -162,6 +167,8 @@ data class AudioInput( override val type: String get() = TYPE_AUDIO + override fun withSeekTo(seekTo: Double) = copy(seekTo = seekTo) + val duration: Double @JsonIgnore get() = analyzedAudio.duration @@ -178,7 +185,8 @@ data class VideoInput( override var analyzed: MediaFile? = null, override val videoStream: Int? = null, override val probeInterlaced: Boolean = true, - override val seekTo: Double? = null + override val seekTo: Double? = null, + override val copyTs: Boolean = false ) : VideoIn { override val analyzedVideo: VideoFile @JsonIgnore @@ -187,6 +195,8 @@ data class VideoInput( override val type: String get() = TYPE_VIDEO + override fun withSeekTo(seekTo: Double) = copy(seekTo = seekTo) + val duration: Double @JsonIgnore get() = analyzedVideo.duration @@ -207,7 +217,8 @@ data class AudioVideoInput( override val audioStream: Int? = null, override val probeInterlaced: Boolean = true, override val channelLayout: ChannelLayout? = null, - override val seekTo: Double? = null + override val seekTo: Double? = null, + override val copyTs: Boolean = false ) : VideoIn, AudioIn { override val analyzedVideo: VideoFile @JsonIgnore @@ -220,6 +231,8 @@ data class AudioVideoInput( override val type: String get() = TYPE_AUDIO_VIDEO + override fun withSeekTo(seekTo: Double) = copy(seekTo = seekTo) + val duration: Double @JsonIgnore get() = analyzedVideo.duration @@ -230,6 +243,7 @@ fun List.inputParams(readDuration: Double?): List = input.params.toParams() + (readDuration?.let { listOf("-t", "$it") } ?: emptyList()) + (input.seekTo?.let { listOf("-ss", "$it") } ?: emptyList()) + + (if (input.copyTs) listOf("-copyts") else emptyList()) + listOf("-i", input.uri) } diff --git a/src/main/kotlin/se/svt/oss/encore/model/mediafile/AudioLayout.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/mediafile/AudioLayout.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/model/mediafile/AudioLayout.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/mediafile/AudioLayout.kt diff --git a/src/main/kotlin/se/svt/oss/encore/model/mediafile/Extensions.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/mediafile/Extensions.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/model/mediafile/Extensions.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/mediafile/Extensions.kt diff --git a/src/main/kotlin/se/svt/oss/encore/model/output/Output.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/output/Output.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/model/output/Output.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/output/Output.kt diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncode.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncode.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncode.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncode.kt diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncoder.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncoder.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncoder.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncoder.kt diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/ChannelId.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ChannelId.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/model/profile/ChannelId.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ChannelId.kt diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/ChannelLayout.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ChannelLayout.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/model/profile/ChannelLayout.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ChannelLayout.kt diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/GenericVideoEncode.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/GenericVideoEncode.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/model/profile/GenericVideoEncode.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/GenericVideoEncode.kt diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/OutputProducer.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/OutputProducer.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/model/profile/OutputProducer.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/OutputProducer.kt diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/Profile.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/Profile.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/model/profile/Profile.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/Profile.kt diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/SimpleAudioEncode.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/SimpleAudioEncode.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/model/profile/SimpleAudioEncode.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/SimpleAudioEncode.kt diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncode.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncode.kt similarity index 96% rename from src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncode.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncode.kt index 011de7fa..0ef510a9 100644 --- a/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncode.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncode.kt @@ -29,6 +29,9 @@ data class ThumbnailEncode( private val log = KotlinLogging.logger { } override fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? { + if (job.segmentLength != null) { + return logOrThrow("Thumbnail is not supported in segmented encode!") + } val videoInput = job.inputs.videoInput(inputLabel) ?: return logOrThrow("Can not produce thumbnail $suffix. No video input with label $inputLabel!") val thumbnailTime = job.thumbnailTime?.let { time -> diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncode.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncode.kt similarity index 96% rename from src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncode.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncode.kt index 9f0576ff..d3cd406d 100644 --- a/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncode.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncode.kt @@ -31,6 +31,9 @@ data class ThumbnailMapEncode( private val log = KotlinLogging.logger { } override fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? { + if (job.segmentLength != null) { + return logOrThrow("Thumbnail map is not supported in segmented encode!") + } val videoInput = job.inputs.videoInput(inputLabel) val inputSeekTo = videoInput?.seekTo val videoStream = job.inputs.analyzedVideo(inputLabel)?.highestBitrateVideoStream diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/VideoEncode.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/VideoEncode.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/model/profile/VideoEncode.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/VideoEncode.kt diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/X264Encode.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/X264Encode.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/model/profile/X264Encode.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/X264Encode.kt diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/X265Encode.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/X265Encode.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/model/profile/X265Encode.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/X265Encode.kt diff --git a/src/main/kotlin/se/svt/oss/encore/model/profile/X26XEncode.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/X26XEncode.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/model/profile/X26XEncode.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/X26XEncode.kt diff --git a/src/main/kotlin/se/svt/oss/encore/model/queue/QueueItem.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/model/queue/QueueItem.kt similarity index 65% rename from src/main/kotlin/se/svt/oss/encore/model/queue/QueueItem.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/model/queue/QueueItem.kt index 25dc4ba5..966acb24 100644 --- a/src/main/kotlin/se/svt/oss/encore/model/queue/QueueItem.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/model/queue/QueueItem.kt @@ -4,7 +4,12 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo import java.time.LocalDateTime @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY) -data class QueueItem(val id: String, val priority: Int = 0, val created: LocalDateTime = LocalDateTime.now()) : +data class QueueItem( + val id: String, + val priority: Int = 0, + val created: LocalDateTime = LocalDateTime.now(), + val segment: Int? = null +) : Comparable { override fun compareTo(other: QueueItem): Int { if (this == other) { @@ -18,6 +23,10 @@ data class QueueItem(val id: String, val priority: Int = 0, val created: LocalDa if (createdCompare != 0) { return createdCompare } - return id.compareTo(other.id) + val idCompare = id.compareTo(other.id) + if (idCompare != 0) { + return idCompare + } + return (segment ?: 0).compareTo((other.segment ?: 0)) } } diff --git a/src/main/kotlin/se/svt/oss/encore/process/CommandBuilder.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/process/CommandBuilder.kt similarity index 96% rename from src/main/kotlin/se/svt/oss/encore/process/CommandBuilder.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/process/CommandBuilder.kt index e5251f11..0e2d7f27 100644 --- a/src/main/kotlin/se/svt/oss/encore/process/CommandBuilder.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/process/CommandBuilder.kt @@ -267,8 +267,18 @@ class CommandBuilder( File(outputFolder).resolve(output.output).toString() } - private fun seekParams(): List = - encoreJob.seekTo?.let { listOf("-ss", "$it") } ?: emptyList() + private fun seekParams(): List { + val copyTsAdjustment = encoreJob.inputs + .filter { it.copyTs } + .mapNotNull { it.seekTo } + .maxOrNull() + val seekTo = listOfNotNull(copyTsAdjustment, encoreJob.seekTo).sum() + return if (seekTo > 0) { + listOf("-ss", "$seekTo") + } else { + emptyList() + } + } private fun durationParams(): List = encoreJob.duration?.let { listOf("-t", "$it") } ?: emptyList() diff --git a/encore-common/src/main/kotlin/se/svt/oss/encore/process/SegmentUtil.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/process/SegmentUtil.kt new file mode 100644 index 00000000..c0c8ec05 --- /dev/null +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/process/SegmentUtil.kt @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore.process + +import se.svt.oss.encore.model.EncoreJob +import se.svt.oss.mediaanalyzer.file.MediaContainer +import kotlin.math.ceil + +fun EncoreJob.segmentLengthOrThrow() = segmentLength ?: throw RuntimeException("No segmentLength in job!") + +fun EncoreJob.numSegments(): Int { + val segLen = segmentLengthOrThrow() + val readDuration = duration + return if (readDuration != null) { + ceil(readDuration / segLen).toInt() + } else { + val segments = + inputs.map { ceil(((it.analyzed as MediaContainer).duration - (it.seekTo ?: 0.0)) / segLen).toInt() }.toSet() + if (segments.size > 1) { + throw RuntimeException("Inputs differ in length") + } + segments.first() + } +} + +fun EncoreJob.segmentDuration(segmentNumber: Int): Double = when { + duration == null -> segmentLengthOrThrow() + segmentNumber < numSegments() - 1 -> segmentLengthOrThrow() + else -> duration!! % segmentLengthOrThrow() +} + +fun EncoreJob.baseName(segmentNumber: Int) = "${baseName}_%05d".format(segmentNumber) diff --git a/src/main/kotlin/se/svt/oss/encore/repository/ChannelLayoutConverters.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/repository/ChannelLayoutConverters.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/repository/ChannelLayoutConverters.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/repository/ChannelLayoutConverters.kt diff --git a/src/main/kotlin/se/svt/oss/encore/repository/EncoreJobRepository.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/repository/EncoreJobRepository.kt similarity index 89% rename from src/main/kotlin/se/svt/oss/encore/repository/EncoreJobRepository.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/repository/EncoreJobRepository.kt index fce56fb8..2bb9a760 100644 --- a/src/main/kotlin/se/svt/oss/encore/repository/EncoreJobRepository.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/repository/EncoreJobRepository.kt @@ -6,17 +6,18 @@ package se.svt.oss.encore.repository import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag -import java.util.UUID import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable +import org.springframework.data.repository.CrudRepository import org.springframework.data.repository.PagingAndSortingRepository import org.springframework.data.rest.core.annotation.RepositoryRestResource import se.svt.oss.encore.model.EncoreJob import se.svt.oss.encore.model.Status +import java.util.UUID @RepositoryRestResource @Tag(name = "encorejob") -interface EncoreJobRepository : PagingAndSortingRepository { +interface EncoreJobRepository : PagingAndSortingRepository, CrudRepository { @Operation(summary = "Find EncoreJobs By Status", description = "Returns EncoreJobs according to the given Status") fun findByStatus(status: Status, pageable: Pageable): Page diff --git a/src/main/kotlin/se/svt/oss/encore/repository/OffsetDateTimeConverters.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/repository/OffsetDateTimeConverters.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/repository/OffsetDateTimeConverters.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/repository/OffsetDateTimeConverters.kt diff --git a/encore-common/src/main/kotlin/se/svt/oss/encore/service/EncoreService.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/service/EncoreService.kt new file mode 100644 index 00000000..7bbfe527 --- /dev/null +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/service/EncoreService.kt @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore.service + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.slf4j.MDCContext +import kotlinx.coroutines.time.withTimeout +import mu.KotlinLogging +import org.redisson.api.RTopic +import org.redisson.api.RedissonClient +import org.springframework.data.redis.core.PartialUpdate +import org.springframework.data.redis.core.RedisKeyValueTemplate +import org.springframework.stereotype.Service +import se.svt.oss.encore.cancellation.CancellationListener +import se.svt.oss.encore.cancellation.SegmentProgressListener +import se.svt.oss.encore.config.EncoreProperties +import se.svt.oss.encore.model.CancelEvent +import se.svt.oss.encore.model.EncoreJob +import se.svt.oss.encore.model.SegmentProgressEvent +import se.svt.oss.encore.model.Status +import se.svt.oss.encore.model.queue.QueueItem +import se.svt.oss.encore.process.baseName +import se.svt.oss.encore.process.numSegments +import se.svt.oss.encore.process.segmentDuration +import se.svt.oss.encore.process.segmentLengthOrThrow +import se.svt.oss.encore.repository.EncoreJobRepository +import se.svt.oss.encore.service.callback.CallbackService +import se.svt.oss.encore.service.localencode.LocalEncodeService +import se.svt.oss.encore.service.mediaanalyzer.MediaAnalyzerService +import se.svt.oss.encore.service.queue.QueueService +import se.svt.oss.mediaanalyzer.file.MediaContainer +import se.svt.oss.mediaanalyzer.file.MediaFile +import java.io.File +import java.util.Locale +import kotlin.time.TimedValue +import kotlin.time.measureTimedValue + +@Service +class EncoreService( + private val callbackService: CallbackService, + private val repository: EncoreJobRepository, + private val ffmpegExecutor: FfmpegExecutor, + private val redissonClient: RedissonClient, + private val redisKeyValueTemplate: RedisKeyValueTemplate, + private val mediaAnalyzerService: MediaAnalyzerService, + private val localEncodeService: LocalEncodeService, + private val encoreProperties: EncoreProperties, + private val queueService: QueueService, +) { + + private val log = KotlinLogging.logger {} + + private val cancelTopicName = "cancel" + + private fun sharedWorkDirOrNull(encoreJob: EncoreJob): File? = + encoreProperties.sharedWorkDir?.resolve(encoreJob.id.toString()) + + private fun sharedWorkDir(encoreJob: EncoreJob): File = + sharedWorkDirOrNull(encoreJob) + ?: throw IllegalStateException("Shared work dir has not been configured") + + fun encode(queueItem: QueueItem, job: EncoreJob) { + when { + queueItem.segment != null -> encodeSegment(job, queueItem.segment) + job.segmentLength != null -> encodeSegmented(job) + else -> encode(job) + } + } + + private fun encodeSegmented(encoreJob: EncoreJob) { + val coroutineJob = Job() + val cancelListener = CancellationListener(encoreJob.id, coroutineJob) + var progressListener: SegmentProgressListener? = null + var cancelTopic: RTopic? = null + var progressTopic: RTopic? = null + try { + initJob(encoreJob) + val numSegments = encoreJob.numSegments() + log.debug { "Encoding using $numSegments segments" } + cancelTopic = redissonClient.getTopic(cancelTopicName) + cancelTopic.addListener(CancelEvent::class.java, cancelListener) + progressTopic = redissonClient.getTopic("segment-progress") + val progressChannel = Channel() + progressListener = SegmentProgressListener(encoreJob.id, coroutineJob, numSegments, progressChannel) + progressTopic.addListener(SegmentProgressEvent::class.java, progressListener) + val timedOutput = measureTimedValue { + sharedWorkDir(encoreJob).mkdirs() + repeat(numSegments) { + queueService.enqueue( + QueueItem( + id = encoreJob.id.toString(), + priority = encoreJob.priority, + segment = it + ) + ) + } + + runBlocking(coroutineJob + MDCContext()) { + withTimeout(encoreProperties.segmentedEncodeTimeout) { + handleProgress(progressChannel, encoreJob) + while (!progressListener.completed()) { + log.info { "Awaiting completion ${progressListener.completionCount()}/$numSegments..." } + delay(1000) + } + } + } + if (progressListener.anyFailed.get()) { + throw RuntimeException("Some segments failed") + } + log.info { "All segments completed" } + val outWorkDir = sharedWorkDir(encoreJob) + val suffixes = mutableSetOf() + repeat(numSegments) { segmentNum -> + val segmentBaseName = encoreJob.baseName(segmentNum) + outWorkDir.list()?.filter { it.startsWith(segmentBaseName) } + ?.forEach { + val suffix = it.replaceFirst(segmentBaseName, "") + suffixes.add(suffix) + outWorkDir.resolve("$suffix.txt").appendText("file $it\n") + } + } + val outputFolder = File(encoreJob.outputFolder) + outputFolder.mkdirs() + val outputFiles = suffixes.map { + val targetName = encoreJob.baseName + it + log.info { "Joining segments for $targetName" } + val targetFile = outputFolder.resolve(targetName) + ffmpegExecutor.joinSegments(outWorkDir.resolve("$it.txt"), targetFile) + } + outputFiles + } + updateSuccessfulJob(encoreJob, timedOutput) + } catch (e: CancellationException) { + log.error(e) { "Job execution cancelled: ${e.message}" } + encoreJob.status = Status.CANCELLED + encoreJob.message = e.message + } catch (e: Exception) { + log.error(e) { "Job execution failed: ${e.message}" } + encoreJob.status = Status.FAILED + encoreJob.message = e.message + } finally { + repository.save(encoreJob) + sharedWorkDirOrNull(encoreJob)?.deleteRecursively() + cancelTopic?.removeListener(cancelListener) + progressListener?.let { progressTopic?.removeListener(it) } + callbackService.sendProgressCallback(encoreJob) + } + } + + private fun encodeSegment(encoreJob: EncoreJob, segmentNumber: Int) { + try { + log.info { "Start encoding ${encoreJob.baseName} segment $segmentNumber/${encoreJob.numSegments()} " } + val outputFolder = sharedWorkDir(encoreJob).absolutePath + val job = encoreJob.copy( + baseName = encoreJob.baseName(segmentNumber), + duration = encoreJob.segmentDuration(segmentNumber), + inputs = encoreJob.inputs.map { + it.withSeekTo((it.seekTo ?: 0.0) + encoreJob.segmentLengthOrThrow() * segmentNumber) + } + ) + ffmpegExecutor.run(job, outputFolder, null) + redissonClient.getTopic("segment-progress").publish(SegmentProgressEvent(encoreJob.id, segmentNumber, true)) + log.info { "Completed ${encoreJob.baseName} segment $segmentNumber/${encoreJob.numSegments()} " } + } catch (e: Exception) { + log.error(e) { "Error encoding segment $segmentNumber: ${e.message}" } + redissonClient.getTopic("segment-progress") + .publish(SegmentProgressEvent(encoreJob.id, segmentNumber, false)) + } + } + + private fun encode(encoreJob: EncoreJob) { + val coroutineJob = Job() + val cancelListener = CancellationListener(encoreJob.id, coroutineJob) + var cancelTopic: RTopic? = null + var outputFolder: String? = null + + try { + cancelTopic = redissonClient.getTopic(cancelTopicName) + cancelTopic.addListener(CancelEvent::class.java, cancelListener) + outputFolder = localEncodeService.outputFolder(encoreJob) + + val timedOutput = measureTimedValue { + initJob(encoreJob) + + val outputFiles = runBlocking(coroutineJob + MDCContext()) { + val progressChannel = Channel() + handleProgress(progressChannel, encoreJob) + ffmpegExecutor.run(encoreJob, outputFolder, progressChannel) + } + + localEncodeService.localEncodedFilesToCorrectDir(outputFolder, outputFiles, encoreJob) + } + + updateSuccessfulJob(encoreJob, timedOutput) + log.info { "Done with $encoreJob" } + } catch (e: InterruptedException) { + val message = "Job execution interrupted" + log.error(e) { message } + encoreJob.status = Status.QUEUED + encoreJob.message = message + throw e + } catch (e: CancellationException) { + log.error(e) { "Job execution cancelled: ${e.message}" } + encoreJob.status = Status.CANCELLED + encoreJob.message = e.message + } catch (e: Exception) { + log.error(e) { "Job execution failed: ${e.message}" } + encoreJob.status = Status.FAILED + encoreJob.message = e.message + } finally { + repository.save(encoreJob) + cancelTopic?.removeListener(cancelListener) + callbackService.sendProgressCallback(encoreJob) + localEncodeService.cleanup(outputFolder) + } + } + + @OptIn(FlowPreview::class) + private fun CoroutineScope.handleProgress( + progressChannel: ReceiveChannel, + encoreJob: EncoreJob + ) { + launch { + progressChannel.consumeAsFlow() + .conflate() + .distinctUntilChanged() + .sample(10_000) + .collect { + log.info { "Received progress $it" } + try { + encoreJob.progress = it + val partialUpdate = PartialUpdate(encoreJob.id, EncoreJob::class.java) + .set(encoreJob::progress.name, encoreJob.progress) + redisKeyValueTemplate.update(partialUpdate) + callbackService.sendProgressCallback(encoreJob) + } catch (e: Exception) { + log.warn(e) { "Error updating progress!" } + } + } + } + } + + private fun updateSuccessfulJob(encoreJob: EncoreJob, timedOutput: TimedValue>) { + val outputFiles = timedOutput.value + val timeInSeconds = timedOutput.duration.inWholeSeconds + val speed = outputFiles.filterIsInstance().firstOrNull()?.let { + "%.3f".format(Locale.US, it.duration / timeInSeconds).toDouble() + } ?: 0.0 + log.info { "Done encoding, time: ${timeInSeconds}s, speed: ${speed}X" } + encoreJob.output = outputFiles + encoreJob.status = Status.SUCCESSFUL + encoreJob.progress = 100 + encoreJob.speed = speed + } + + private fun initJob(encoreJob: EncoreJob) { + encoreJob.inputs.forEach { input -> + mediaAnalyzerService.analyzeInput(input) + } + log.info { "Start encoding" } + encoreJob.status = Status.IN_PROGRESS + repository.save(encoreJob) + } +} diff --git a/src/main/kotlin/se/svt/oss/encore/service/FfmpegExecutor.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/service/FfmpegExecutor.kt similarity index 80% rename from src/main/kotlin/se/svt/oss/encore/service/FfmpegExecutor.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/service/FfmpegExecutor.kt index fd988938..bc1742cb 100644 --- a/src/main/kotlin/se/svt/oss/encore/service/FfmpegExecutor.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/service/FfmpegExecutor.kt @@ -12,9 +12,8 @@ import org.springframework.stereotype.Service import se.svt.oss.encore.config.EncoreProperties import se.svt.oss.encore.model.EncoreJob import se.svt.oss.encore.model.input.maxDuration -import se.svt.oss.encore.model.output.Output -import se.svt.oss.encore.model.profile.Profile import se.svt.oss.encore.process.CommandBuilder +import se.svt.oss.encore.service.profile.ProfileService import se.svt.oss.mediaanalyzer.MediaAnalyzer import se.svt.oss.mediaanalyzer.file.MediaFile import java.io.File @@ -26,6 +25,7 @@ import kotlin.math.round @Service class FfmpegExecutor( private val mediaAnalyzer: MediaAnalyzer, + private val profileService: ProfileService, private val encoreProperties: EncoreProperties ) { @@ -39,11 +39,20 @@ class FfmpegExecutor( fun run( encoreJob: EncoreJob, - profile: Profile, - outputs: List, outputFolder: String, - progressChannel: SendChannel + progressChannel: SendChannel? ): List { + val profile = profileService.getProfile(encoreJob.profile) + val outputs = profile.encodes.mapNotNull { + it.getOutput( + encoreJob, + encoreProperties.encoding + ) + } + + check(outputs.distinctBy { it.id }.size == outputs.size) { + "Profile ${encoreJob.profile} contains duplicate suffixes: ${outputs.map { it.id }}!" + } val commands = CommandBuilder(encoreJob, profile, outputFolder, encoreProperties.encoding).buildCommands(outputs) log.info { "Start encoding ${encoreJob.baseName}..." } @@ -51,13 +60,13 @@ class FfmpegExecutor( val duration = encoreJob.duration ?: encoreJob.inputs.maxDuration() return try { File(outputFolder).mkdirs() - progressChannel.trySendBlocking(0).getOrThrow() + progressChannel?.trySendBlocking(0)?.getOrThrow() commands.forEachIndexed { index, command -> runFfmpeg(command, workDir, duration) { progress -> - progressChannel.trySendBlocking(totalProgress(progress, index, commands.size)).getOrThrow() + progressChannel?.trySendBlocking(totalProgress(progress, index, commands.size))?.getOrThrow() } } - progressChannel.close() + progressChannel?.close() outputs.flatMap { out -> out.postProcessor.process(File(outputFolder)) .map { mediaAnalyzer.analyze(it.toString()) } @@ -65,9 +74,6 @@ class FfmpegExecutor( } catch (e: CancellationException) { log.info { "Job was cancelled" } emptyList() - } catch (e: Exception) { - log.error(e) { "Failed Job" } - throw e } finally { workDir.deleteRecursively() } @@ -160,4 +166,28 @@ class FfmpegExecutor( null } } + + fun joinSegments(segmentList: File, targetFile: File): MediaFile { + val command = listOf( + "ffmpeg", + "-hide_banner", + "-loglevel", + "+level", + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + "$segmentList", + "-map", + "0", + "-ignore_unknown", + "-c", + "copy", + "$targetFile" + ) + runFfmpeg(command, segmentList.parentFile, null) {} + return mediaAnalyzer.analyze(targetFile.absolutePath) + } } diff --git a/encore-common/src/main/kotlin/se/svt/oss/encore/service/callback/CallbackClient.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/service/callback/CallbackClient.kt new file mode 100644 index 00000000..97de4102 --- /dev/null +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/service/callback/CallbackClient.kt @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore.service.callback + +import java.net.URI +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.service.annotation.HttpExchange +import org.springframework.web.service.annotation.PostExchange +import se.svt.oss.encore.model.callback.JobProgress + +@HttpExchange(contentType = MediaType.APPLICATION_JSON_VALUE) +interface CallbackClient { + + @PostExchange + fun sendProgressCallback(callbackUri: URI, @RequestBody progress: JobProgress) +} diff --git a/src/main/kotlin/se/svt/oss/encore/service/callback/CallbackService.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/service/callback/CallbackService.kt similarity index 94% rename from src/main/kotlin/se/svt/oss/encore/service/callback/CallbackService.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/service/callback/CallbackService.kt index db0f1e59..a1fab212 100644 --- a/src/main/kotlin/se/svt/oss/encore/service/callback/CallbackService.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/service/callback/CallbackService.kt @@ -8,6 +8,7 @@ import mu.KotlinLogging import org.springframework.stereotype.Service import se.svt.oss.encore.model.EncoreJob import se.svt.oss.encore.model.callback.JobProgress +import java.net.URI @Service class CallbackService(private val callbackClient: CallbackClient) { @@ -18,7 +19,7 @@ class CallbackService(private val callbackClient: CallbackClient) { encoreJob.progressCallbackUri?.let { try { callbackClient.sendProgressCallback( - it, + URI.create(it), JobProgress( encoreJob.id, encoreJob.externalId, diff --git a/src/main/kotlin/se/svt/oss/encore/service/localencode/LocalEncodeService.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/service/localencode/LocalEncodeService.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/service/localencode/LocalEncodeService.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/service/localencode/LocalEncodeService.kt diff --git a/src/main/kotlin/se/svt/oss/encore/service/mediaanalyzer/MediaAnalyzerService.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/service/mediaanalyzer/MediaAnalyzerService.kt similarity index 63% rename from src/main/kotlin/se/svt/oss/encore/service/mediaanalyzer/MediaAnalyzerService.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/service/mediaanalyzer/MediaAnalyzerService.kt index 76d8968a..40acf66c 100644 --- a/src/main/kotlin/se/svt/oss/encore/service/mediaanalyzer/MediaAnalyzerService.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/service/mediaanalyzer/MediaAnalyzerService.kt @@ -5,6 +5,7 @@ package se.svt.oss.encore.service.mediaanalyzer import mu.KotlinLogging +import org.springframework.aot.hint.annotation.RegisterReflectionForBinding import org.springframework.stereotype.Service import se.svt.oss.encore.model.input.AudioIn import se.svt.oss.encore.model.input.Input @@ -13,10 +14,34 @@ import se.svt.oss.encore.model.mediafile.selectAudioStream import se.svt.oss.encore.model.mediafile.selectVideoStream import se.svt.oss.encore.model.mediafile.trimAudio import se.svt.oss.mediaanalyzer.MediaAnalyzer +import se.svt.oss.mediaanalyzer.ffprobe.FfAudioStream +import se.svt.oss.mediaanalyzer.ffprobe.FfVideoStream +import se.svt.oss.mediaanalyzer.ffprobe.ProbeResult +import se.svt.oss.mediaanalyzer.ffprobe.UnknownStream import se.svt.oss.mediaanalyzer.file.AudioFile import se.svt.oss.mediaanalyzer.file.VideoFile +import se.svt.oss.mediaanalyzer.mediainfo.AudioTrack +import se.svt.oss.mediaanalyzer.mediainfo.GeneralTrack +import se.svt.oss.mediaanalyzer.mediainfo.ImageTrack +import se.svt.oss.mediaanalyzer.mediainfo.MediaInfo +import se.svt.oss.mediaanalyzer.mediainfo.OtherTrack +import se.svt.oss.mediaanalyzer.mediainfo.TextTrack +import se.svt.oss.mediaanalyzer.mediainfo.VideoTrack @Service +@RegisterReflectionForBinding( + MediaInfo::class, + AudioTrack::class, + GeneralTrack::class, + ImageTrack::class, + OtherTrack::class, + TextTrack::class, + VideoTrack::class, + ProbeResult::class, + FfAudioStream::class, + FfVideoStream::class, + UnknownStream::class +) class MediaAnalyzerService(private val mediaAnalyzer: MediaAnalyzer) { private val log = KotlinLogging.logger {} diff --git a/src/main/kotlin/se/svt/oss/encore/service/profile/ProfileService.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/service/profile/ProfileService.kt similarity index 71% rename from src/main/kotlin/se/svt/oss/encore/service/profile/ProfileService.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/service/profile/ProfileService.kt index 74205ccf..f2d9aa09 100644 --- a/src/main/kotlin/se/svt/oss/encore/service/profile/ProfileService.kt +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/service/profile/ProfileService.kt @@ -10,17 +10,34 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.dataformat.yaml.YAMLMapper import com.fasterxml.jackson.module.kotlin.readValue import mu.KotlinLogging +import org.springframework.aot.hint.annotation.RegisterReflectionForBinding import org.springframework.beans.factory.annotation.Value import org.springframework.core.io.Resource -import org.springframework.retry.annotation.Backoff -import org.springframework.retry.annotation.Retryable import org.springframework.stereotype.Service +import se.svt.oss.encore.model.profile.AudioEncode +import se.svt.oss.encore.model.profile.GenericVideoEncode +import se.svt.oss.encore.model.profile.OutputProducer import se.svt.oss.encore.model.profile.Profile +import se.svt.oss.encore.model.profile.SimpleAudioEncode +import se.svt.oss.encore.model.profile.ThumbnailEncode +import se.svt.oss.encore.model.profile.ThumbnailMapEncode +import se.svt.oss.encore.model.profile.X264Encode +import se.svt.oss.encore.model.profile.X265Encode import java.io.File -import java.io.IOException import java.util.Locale @Service +@RegisterReflectionForBinding( + Profile::class, + OutputProducer::class, + AudioEncode::class, + SimpleAudioEncode::class, + X264Encode::class, + X265Encode::class, + GenericVideoEncode::class, + ThumbnailEncode::class, + ThumbnailMapEncode::class +) class ProfileService( @Value("\${profile.location}") private val profileLocation: Resource, @@ -35,16 +52,6 @@ class ProfileService( objectMapper } - @Retryable( - include = [IOException::class], - maxAttempts = 3, - backoff = Backoff( - random = true, - delayExpression = "\${profiles.retry.delay:500}", - maxDelayExpression = "\${profiles.retry.max:15000}", - multiplier = 2.0 - ) - ) fun getProfile(name: String): Profile = try { log.debug { "Get profile $name. Reading profiles from $profileLocation" } val profiles = mapper.readValue>(profileLocation.inputStream) diff --git a/encore-common/src/main/kotlin/se/svt/oss/encore/service/queue/QueueService.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/service/queue/QueueService.kt new file mode 100644 index 00000000..5a16b615 --- /dev/null +++ b/encore-common/src/main/kotlin/se/svt/oss/encore/service/queue/QueueService.kt @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 +package se.svt.oss.encore.service.queue + +import jakarta.annotation.PostConstruct +import mu.KotlinLogging +import mu.withLoggingContext +import org.redisson.api.RPriorityBlockingQueue +import org.redisson.api.RedissonClient +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Component +import se.svt.oss.encore.config.EncoreProperties +import se.svt.oss.encore.model.EncoreJob +import se.svt.oss.encore.model.Status +import se.svt.oss.encore.model.queue.QueueItem +import se.svt.oss.encore.repository.EncoreJobRepository +import se.svt.oss.encore.service.queue.QueueUtil.getQueueNumberByPriority +import java.util.UUID +import java.util.concurrent.ConcurrentSkipListMap +import java.util.concurrent.TimeUnit + +@Component +class QueueService( + private val encoreProperties: EncoreProperties, + private val redisson: RedissonClient, + private val repository: EncoreJobRepository, +) { + + private val log = KotlinLogging.logger { } + private val queues = ConcurrentSkipListMap>() + + fun poll(queueNo: Int, action: (QueueItem, EncoreJob) -> Unit): Boolean { + val queueItem = if (encoreProperties.pollHigherPrio) { + pollUntil(queueNo) + } else { + getQueue(queueNo).poll() + } + if (queueItem == null) { + return false + } + log.info { "Picked up $queueItem" } + val id = UUID.fromString(queueItem.id) + val job = repository.findByIdOrNull(id) + ?: retry(id) // Sometimes there has been sync issues + ?: throw RuntimeException("Job ${queueItem.id} does not exist") + if (job.status.isCancelled) { + log.info { "Job was cancelled" } + return true + } + if (queueItem.segment != null && job.status == Status.FAILED) { + log.info { "Main job has failed" } + return true + } + withLoggingContext(job.contextMap) { + try { + action.invoke(queueItem, job) + } catch (e: InterruptedException) { + repostJob(queueItem, job) + } + } + return true + } + + private fun pollUntil(queueNo: Int): QueueItem? = + (0..queueNo) + .asSequence() + .mapNotNull { getQueue(it).poll() } + .firstOrNull() + + private fun retry(id: UUID): EncoreJob? { + Thread.sleep(5000) + log.info { "Retrying read of job from repository " } + return repository.findByIdOrNull(id) + } + + private fun repostJob(queueItem: QueueItem, job: EncoreJob) { + try { + log.info { "Adding job to queue (repost on interrupt)" } + enqueue(queueItem) + if (queueItem.segment == null) { + job.status = Status.QUEUED + repository.save(job) + } + log.info { "Added job to queue (repost on interrupt)" } + } catch (e: Exception) { + if (queueItem.segment == null) { + val message = "Failed to add interrupted job to queue" + log.error(e) { message } + job.message = message + job.status = Status.FAILED + repository.save(job) + } + } + } + + fun enqueue(job: EncoreJob) { + val queueItem = QueueItem( + id = job.id.toString(), + priority = job.priority, + created = job.createdDate.toLocalDateTime() + ) + enqueue(queueItem) + } + + fun enqueue(item: QueueItem) { + if (!queueByPrio(item.priority).offer(item, 5, TimeUnit.SECONDS)) { + throw RuntimeException("Job could not be added to queue!") + } + } + + fun getQueue(): List { + return (0 until encoreProperties.concurrency).flatMap { getQueue(it).toList() } + } + + private fun queueByPrio(priority: Int) = + getQueue(getQueueNumberByPriority(encoreProperties.concurrency, priority)) + + private fun getQueue(queueNo: Int) = queues.computeIfAbsent(queueNo) { + redisson.getPriorityBlockingQueue("${encoreProperties.redisKeyPrefix}-queue-$queueNo") + } + + @PostConstruct + internal fun handleOrphanedQueues() { + try { + val oldConcurrency = + redisson.getAtomicLong("${encoreProperties.redisKeyPrefix}-concurrency").getAndSet(encoreProperties.concurrency.toLong()).toInt() + if (oldConcurrency > encoreProperties.concurrency) { + log.info { "Moving orphaned queue items to lowest priority queue. Old concurrency: $oldConcurrency, new concurrency: ${encoreProperties.concurrency}" } + val lowestPrioQueue = getQueue(encoreProperties.concurrency - 1) + (encoreProperties.concurrency until oldConcurrency).forEach { queueNo -> + val orphanedQueue = getQueue(queueNo) + val transferred = orphanedQueue.drainTo(lowestPrioQueue) + log.info { "Moved $transferred orphaned items from queue $queueNo to lowest priority queue." } + orphanedQueue.delete() + } + } + } catch (e: Exception) { + log.error(e) { "Error checking for concurrency change: ${e.message}" } + } + } +} diff --git a/src/main/kotlin/se/svt/oss/encore/service/queue/QueueUtil.kt b/encore-common/src/main/kotlin/se/svt/oss/encore/service/queue/QueueUtil.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/service/queue/QueueUtil.kt rename to encore-common/src/main/kotlin/se/svt/oss/encore/service/queue/QueueUtil.kt diff --git a/src/test/kotlin/se/svt/oss/encore/EncoreClient.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/EncoreClient.kt similarity index 51% rename from src/test/kotlin/se/svt/oss/encore/EncoreClient.kt rename to encore-common/src/test/kotlin/se/svt/oss/encore/EncoreClient.kt index b490a780..d939c759 100644 --- a/src/test/kotlin/se/svt/oss/encore/EncoreClient.kt +++ b/encore-common/src/test/kotlin/se/svt/oss/encore/EncoreClient.kt @@ -4,51 +4,48 @@ package se.svt.oss.encore -import java.util.UUID -import org.springframework.cloud.openfeign.FeignClient import org.springframework.data.domain.Pageable import org.springframework.hateoas.PagedModel import org.springframework.http.MediaType -import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.service.annotation.GetExchange +import org.springframework.web.service.annotation.HttpExchange +import org.springframework.web.service.annotation.PostExchange import se.svt.oss.encore.model.EncoreJob import se.svt.oss.encore.model.Status import se.svt.oss.encore.model.queue.QueueItem +import java.util.UUID -@FeignClient("encore", url = "http://localhost:\${server.port}") +@HttpExchange(accept = [MediaType.APPLICATION_JSON_VALUE], contentType = MediaType.APPLICATION_JSON_VALUE) interface EncoreClient { - @GetMapping("/encoreJobs") + @GetExchange("/encoreJobs") fun jobs(): PagedModel - @GetMapping("/encoreJobs/search/findByStatus") + @GetExchange("/encoreJobs/search/findByStatus") fun findByStatus(@RequestParam("status") status: Status, pageable: Pageable): PagedModel - @PostMapping("/encoreJobs/{jobId}/cancel") + @PostExchange("/encoreJobs/{jobId}/cancel") fun cancel(@PathVariable("jobId") jobId: UUID) - @PostMapping( - "/encoreJobs", - consumes = [MediaType.APPLICATION_JSON_VALUE], - produces = [MediaType.APPLICATION_JSON_VALUE] + @PostExchange( + "/encoreJobs" ) - fun createJob(jobRequest: EncoreJob): EncoreJob + fun createJob(@RequestBody jobRequest: EncoreJob): EncoreJob - @GetMapping("/health") + @GetExchange("/health") fun health(): String - @PostMapping( - "/encoreJobs", - consumes = [MediaType.APPLICATION_JSON_VALUE], - produces = [MediaType.APPLICATION_JSON_VALUE] + @PostExchange( + "/encoreJobs" ) - fun postJson(json: String): EncoreJob + fun postJson(@RequestBody json: String): EncoreJob - @GetMapping("/encoreJobs/{jobId}") + @GetExchange("/encoreJobs/{jobId}") fun getJob(@PathVariable("jobId") jobId: UUID): EncoreJob - @GetMapping("/queue") + @GetExchange("/queue") fun queue(): List } diff --git a/src/test/kotlin/se/svt/oss/encore/EncoreIntegrationTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/EncoreIntegrationTest.kt similarity index 97% rename from src/test/kotlin/se/svt/oss/encore/EncoreIntegrationTest.kt rename to encore-common/src/test/kotlin/se/svt/oss/encore/EncoreIntegrationTest.kt index d67bf870..34ee69b2 100644 --- a/src/test/kotlin/se/svt/oss/encore/EncoreIntegrationTest.kt +++ b/encore-common/src/test/kotlin/se/svt/oss/encore/EncoreIntegrationTest.kt @@ -39,11 +39,13 @@ class EncoreIntegrationTest : EncoreIntegrationTestBase() { } @Test - fun multipleAudioStreamsOutput(@TempDir outputDir: File) { + fun multipleAudioStreamsOutputSegmentedEncode(@TempDir outputDir: File) { val baseName = "multiple_audio" val job = job(outputDir).copy( baseName = baseName, profile = "audio-streams", + segmentLength = 3.84, + priority = 100 ) val expectedOutPut = listOf(outputDir.resolve("$baseName.mp4").absolutePath) val createdJob = successfulTest(job, expectedOutPut) @@ -94,7 +96,7 @@ class EncoreIntegrationTest : EncoreIntegrationTestBase() { channelLayout = ChannelLayout.CH_LAYOUT_5POINT1, audioLabel = "alt", seekTo = 1.0 - ), + ) ) ) @@ -139,7 +141,7 @@ class EncoreIntegrationTest : EncoreIntegrationTestBase() { val standardPriorityJob = createAndAwaitJob( job = job( outputDir = outputDir1, - priority = 0, + priority = 0 ), pollInterval = Duration.ofMillis(200) ) { it.status == Status.IN_PROGRESS } diff --git a/src/test/kotlin/se/svt/oss/encore/EncoreIntegrationTestBase.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/EncoreIntegrationTestBase.kt similarity index 91% rename from src/test/kotlin/se/svt/oss/encore/EncoreIntegrationTestBase.kt rename to encore-common/src/test/kotlin/se/svt/oss/encore/EncoreIntegrationTestBase.kt index 5832209f..b7a41654 100644 --- a/src/test/kotlin/se/svt/oss/encore/EncoreIntegrationTestBase.kt +++ b/encore-common/src/test/kotlin/se/svt/oss/encore/EncoreIntegrationTestBase.kt @@ -10,6 +10,7 @@ import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.client.WireMock.anyUrl import com.github.tomakehurst.wiremock.client.WireMock.ok import com.github.tomakehurst.wiremock.client.WireMock.post +import com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig import org.awaitility.Awaitility.await import org.awaitility.Durations import org.junit.jupiter.api.AfterEach @@ -18,29 +19,27 @@ import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Value import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import import org.springframework.core.io.Resource import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler import org.springframework.test.annotation.DirtiesContext -import org.springframework.test.context.ContextConfiguration -import se.svt.oss.junit5.redis.EmbeddedRedisExtension -import se.svt.oss.randomportinitializer.RandomPortInitializer import se.svt.oss.encore.Assertions.assertThat import se.svt.oss.encore.config.EncoreProperties -import se.svt.oss.encore.model.input.AudioVideoInput import se.svt.oss.encore.model.EncoreJob import se.svt.oss.encore.model.Status import se.svt.oss.encore.model.callback.JobProgress +import se.svt.oss.encore.model.input.AudioVideoInput import se.svt.oss.encore.model.profile.ChannelLayout +import se.svt.oss.junit5.redis.EmbeddedRedisExtension import java.io.File -import java.net.URI import java.time.Duration import java.util.UUID -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ExtendWith(EmbeddedRedisExtension::class) -@ContextConfiguration(initializers = [RandomPortInitializer::class]) @DirtiesContext -class EncoreIntegrationTestBase() { +@Import(TestConfig::class) +class EncoreIntegrationTestBase { @Autowired lateinit var encoreClient: EncoreClient @@ -70,7 +69,7 @@ class EncoreIntegrationTestBase() { @BeforeEach fun setUp() { - wireMockServer = WireMockServer() + wireMockServer = WireMockServer(wireMockConfig().dynamicPort()) wireMockServer.start() wireMockServer.stubFor( post(anyUrl()) @@ -85,7 +84,7 @@ class EncoreIntegrationTestBase() { fun successfulTest( job: EncoreJob, - expectedOutputFiles: List, + expectedOutputFiles: List ): EncoreJob { val createdJob = createAndAwaitJob( job = job, @@ -147,13 +146,13 @@ class EncoreIntegrationTestBase() { baseName = file.file.nameWithoutExtension, profile = "program", outputFolder = outputDir.absolutePath, - progressCallbackUri = URI.create("http://localhost:${wireMockServer.port()}/callbacks/111"), + progressCallbackUri = "http://localhost:${wireMockServer.port()}/callbacks/111", debugOverlay = true, priority = priority, inputs = listOf( AudioVideoInput( uri = file.file.absolutePath, - channelLayout = ChannelLayout.CH_LAYOUT_5POINT1, + channelLayout = ChannelLayout.CH_LAYOUT_5POINT1 ) ), logContext = mapOf("FlowId" to UUID.randomUUID().toString()) diff --git a/encore-common/src/test/kotlin/se/svt/oss/encore/EncoreRuntimeHintsTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/EncoreRuntimeHintsTest.kt new file mode 100644 index 00000000..6d337a8b --- /dev/null +++ b/encore-common/src/test/kotlin/se/svt/oss/encore/EncoreRuntimeHintsTest.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore + +import org.junit.jupiter.api.Test +import org.springframework.aot.hint.RuntimeHints +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates +import se.svt.oss.encore.Assertions.assertThat +import se.svt.oss.encore.config.AudioMixPreset +import se.svt.oss.encore.config.EncodingProperties +import se.svt.oss.encore.config.EncoreProperties +import kotlin.reflect.jvm.javaConstructor + +class EncoreRuntimeHintsTest { + @Test + fun shouldRegisterHints() { + val hints = RuntimeHints() + EncoreRuntimeHints().registerHints(hints, javaClass.classLoader) + assertThat( + RuntimeHintsPredicates.reflection().onConstructor(EncoreProperties::class.constructors.first().javaConstructor!!) + ).accepts(hints) + assertThat( + RuntimeHintsPredicates.reflection().onConstructor(EncodingProperties::class.constructors.first().javaConstructor!!) + ).accepts(hints) + assertThat( + RuntimeHintsPredicates.reflection().onConstructor(AudioMixPreset::class.constructors.first().javaConstructor!!) + ).accepts(hints) + } +} diff --git a/src/test/kotlin/se/svt/oss/encore/LocalEncodeIntegrationTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/LocalEncodeIntegrationTest.kt similarity index 100% rename from src/test/kotlin/se/svt/oss/encore/LocalEncodeIntegrationTest.kt rename to encore-common/src/test/kotlin/se/svt/oss/encore/LocalEncodeIntegrationTest.kt diff --git a/encore-common/src/test/kotlin/se/svt/oss/encore/TestConfig.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/TestConfig.kt new file mode 100644 index 00000000..8bb610d2 --- /dev/null +++ b/encore-common/src/test/kotlin/se/svt/oss/encore/TestConfig.kt @@ -0,0 +1,22 @@ +package se.svt.oss.encore + +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Lazy +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.support.WebClientAdapter +import org.springframework.web.service.invoker.HttpServiceProxyFactory + +@TestConfiguration(proxyBeanMethods = false) +@Lazy +class TestConfig { + + @Bean + fun encoreClient(@Value("\${local.server.port}") localPort: Int): EncoreClient { + return HttpServiceProxyFactory + .builder(WebClientAdapter.forClient(WebClient.create("http://localhost:$localPort"))) + .build() + .createClient(EncoreClient::class.java) + } +} diff --git a/src/test/kotlin/se/svt/oss/encore/TestUtils.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/TestUtils.kt similarity index 100% rename from src/test/kotlin/se/svt/oss/encore/TestUtils.kt rename to encore-common/src/test/kotlin/se/svt/oss/encore/TestUtils.kt diff --git a/src/test/kotlin/se/svt/oss/encore/model/input/InputTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/model/input/InputTest.kt similarity index 96% rename from src/test/kotlin/se/svt/oss/encore/model/input/InputTest.kt rename to encore-common/src/test/kotlin/se/svt/oss/encore/model/input/InputTest.kt index 407231b2..1882390b 100644 --- a/src/test/kotlin/se/svt/oss/encore/model/input/InputTest.kt +++ b/encore-common/src/test/kotlin/se/svt/oss/encore/model/input/InputTest.kt @@ -124,4 +124,10 @@ internal class InputTest { .isInstanceOf(IllegalArgumentException::class.java) .hasMessage("Inputs contains duplicate video labels!") } + + @Test + fun testWithSeekTo() { + assertThat(inputs.map { it.withSeekTo(20.0) }) + .allSatisfy { assertThat(it).hasSeekTo(20.0) } + } } diff --git a/src/test/kotlin/se/svt/oss/encore/model/mediafile/MediaFileExtensionsTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/model/mediafile/MediaFileExtensionsTest.kt similarity index 100% rename from src/test/kotlin/se/svt/oss/encore/model/mediafile/MediaFileExtensionsTest.kt rename to encore-common/src/test/kotlin/se/svt/oss/encore/model/mediafile/MediaFileExtensionsTest.kt diff --git a/src/test/kotlin/se/svt/oss/encore/model/profile/AudioEncodeTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/AudioEncodeTest.kt similarity index 99% rename from src/test/kotlin/se/svt/oss/encore/model/profile/AudioEncodeTest.kt rename to encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/AudioEncodeTest.kt index ff538360..f534eca3 100644 --- a/src/test/kotlin/se/svt/oss/encore/model/profile/AudioEncodeTest.kt +++ b/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/AudioEncodeTest.kt @@ -188,6 +188,7 @@ class AudioEncodeTest { channels = channelCount, channelLayout = ChannelLayout.defaultChannelLayout(channelCount)?.layoutName, samplingRate = 23123, - bitrate = 213123 + bitrate = 213123, + profile = null ) } diff --git a/src/test/kotlin/se/svt/oss/encore/model/profile/GenericVideoEncodeTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/GenericVideoEncodeTest.kt similarity index 100% rename from src/test/kotlin/se/svt/oss/encore/model/profile/GenericVideoEncodeTest.kt rename to encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/GenericVideoEncodeTest.kt diff --git a/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncodeTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncodeTest.kt similarity index 100% rename from src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncodeTest.kt rename to encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncodeTest.kt diff --git a/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncodeTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncodeTest.kt similarity index 100% rename from src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncodeTest.kt rename to encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncodeTest.kt diff --git a/src/test/kotlin/se/svt/oss/encore/model/profile/VideoEncodeTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/VideoEncodeTest.kt similarity index 100% rename from src/test/kotlin/se/svt/oss/encore/model/profile/VideoEncodeTest.kt rename to encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/VideoEncodeTest.kt diff --git a/src/test/kotlin/se/svt/oss/encore/model/profile/X264EncodeTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/X264EncodeTest.kt similarity index 100% rename from src/test/kotlin/se/svt/oss/encore/model/profile/X264EncodeTest.kt rename to encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/X264EncodeTest.kt diff --git a/src/test/kotlin/se/svt/oss/encore/model/profile/X265EncodeTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/X265EncodeTest.kt similarity index 100% rename from src/test/kotlin/se/svt/oss/encore/model/profile/X265EncodeTest.kt rename to encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/X265EncodeTest.kt diff --git a/src/test/kotlin/se/svt/oss/encore/model/queue/QueueItemTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/model/queue/QueueItemTest.kt similarity index 66% rename from src/test/kotlin/se/svt/oss/encore/model/queue/QueueItemTest.kt rename to encore-common/src/test/kotlin/se/svt/oss/encore/model/queue/QueueItemTest.kt index 51f31345..f2bcdbf9 100644 --- a/src/test/kotlin/se/svt/oss/encore/model/queue/QueueItemTest.kt +++ b/encore-common/src/test/kotlin/se/svt/oss/encore/model/queue/QueueItemTest.kt @@ -9,17 +9,22 @@ internal class QueueItemTest { @Test fun testSortOrder() { val newHighPrioItem = QueueItem("new-high-prio", 100) - val oldHighPrioItem = QueueItem("old-high-prio", 100, LocalDateTime.now().minusHours(1)) - val olderHighPrioItem = QueueItem("older-high-prio", 100, LocalDateTime.now().minusHours(2)) + val now = LocalDateTime.now() + val oldHighPrioItem = QueueItem("old-high-prio", 100, now.minusHours(1)) + val olderHighPrioItem = QueueItem("older-high-prio", 100, now.minusHours(2)) + val segmentOne = QueueItem("segmented", 99, now, 1) + val segmentTwo = QueueItem("segmented", 99, now, 2) val newLowPrioItem = QueueItem("new-low-prio", 10) - val oldLowPrioItem = QueueItem("old-low-prio", 10, LocalDateTime.now().minusHours(1)) - val olderLowPrioItem = QueueItem("older-low-prio", 10, LocalDateTime.now().minusHours(2)) + val oldLowPrioItem = QueueItem("old-low-prio", 10, now.minusHours(1)) + val olderLowPrioItem = QueueItem("older-low-prio", 10, now.minusHours(2)) val expectedSorted = listOf( olderHighPrioItem, olderHighPrioItem, oldHighPrioItem, newHighPrioItem, + segmentOne, + segmentTwo, olderLowPrioItem, oldLowPrioItem, newLowPrioItem @@ -30,6 +35,8 @@ internal class QueueItemTest { newHighPrioItem, olderHighPrioItem, oldHighPrioItem, + segmentOne, + segmentTwo, olderLowPrioItem, olderHighPrioItem, newLowPrioItem, diff --git a/src/test/kotlin/se/svt/oss/encore/process/CommandBuilderTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/process/CommandBuilderTest.kt similarity index 100% rename from src/test/kotlin/se/svt/oss/encore/process/CommandBuilderTest.kt rename to encore-common/src/test/kotlin/se/svt/oss/encore/process/CommandBuilderTest.kt diff --git a/encore-common/src/test/kotlin/se/svt/oss/encore/process/SegmentUtilTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/process/SegmentUtilTest.kt new file mode 100644 index 00000000..fa1d0473 --- /dev/null +++ b/encore-common/src/test/kotlin/se/svt/oss/encore/process/SegmentUtilTest.kt @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore.process + +import org.assertj.core.data.Offset +import org.junit.jupiter.api.Test +import se.svt.oss.encore.defaultEncoreJob +import se.svt.oss.encore.Assertions.assertThat +import se.svt.oss.encore.Assertions.assertThatThrownBy +import se.svt.oss.encore.defaultVideoFile +import se.svt.oss.encore.longVideoFile +import se.svt.oss.encore.model.input.AudioVideoInput + +class SegmentUtilTest { + + private val job = defaultEncoreJob().copy( + baseName = "segment_test", + segmentLength = 19.2, + duration = null, + inputs = listOf(AudioVideoInput(uri = "test", analyzed = longVideoFile)) + ) + + @Test + fun baseName() { + assertThat(job.baseName(2)).isEqualTo("segment_test_00002") + } + + @Test + fun missingSegmentLength() { + val encoreJob = job.copy(segmentLength = null) + val message = "No segmentLength in job!" + assertThatThrownBy { + encoreJob.segmentLengthOrThrow() + }.hasMessage(message) + assertThatThrownBy { + encoreJob.numSegments() + }.hasMessage(message) + assertThatThrownBy { + encoreJob.segmentDuration(1) + }.hasMessage(message) + } + + @Test + fun hasSegmentLength() { + assertThat(job.segmentLengthOrThrow()).isEqualTo(19.2) + } + + @Test + fun numSegmentsDurationSet() { + val encoreJob = job.copy(duration = 125.0) + assertThat(encoreJob.numSegments()).isEqualTo(7) + } + + @Test + fun numSegmentsDurationNotSet() { + assertThat(job.numSegments()).isEqualTo(141) + } + + @Test + fun numSegmentsInputsDiffer() { + val encoreJob = job.copy(inputs = job.inputs + AudioVideoInput(uri = "test", analyzed = defaultVideoFile)) + assertThatThrownBy { encoreJob.numSegments() } + .hasMessage("Inputs differ in length") + } + + @Test + fun segmentDurationDurationNotSet() { + assertThat(job.segmentDuration(140)).isEqualTo(19.2) + } + + @Test + fun segmentDurationDurationSetFirst() { + assertThat(job.copy(duration = 125.0).segmentDuration(0)).isEqualTo(19.2) + } + + @Test + fun segmentDurationDurationSetLast() { + assertThat(job.copy(duration = 125.0).segmentDuration(6)).isCloseTo(9.8, Offset.offset(0.001)) + } +} diff --git a/src/test/kotlin/se/svt/oss/encore/repository/EncoreJobRepositoryTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/repository/EncoreJobRepositoryTest.kt similarity index 79% rename from src/test/kotlin/se/svt/oss/encore/repository/EncoreJobRepositoryTest.kt rename to encore-common/src/test/kotlin/se/svt/oss/encore/repository/EncoreJobRepositoryTest.kt index ad7cc02f..f7a2227f 100644 --- a/src/test/kotlin/se/svt/oss/encore/repository/EncoreJobRepositoryTest.kt +++ b/encore-common/src/test/kotlin/se/svt/oss/encore/repository/EncoreJobRepositoryTest.kt @@ -9,23 +9,17 @@ import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.data.domain.PageRequest -import org.springframework.test.annotation.DirtiesContext import org.springframework.test.context.ActiveProfiles -import org.springframework.test.context.ContextConfiguration -import se.svt.oss.junit5.redis.EmbeddedRedisExtension -import se.svt.oss.randomportinitializer.RandomPortInitializer import se.svt.oss.encore.Assertions.assertThat import se.svt.oss.encore.model.EncoreJob import se.svt.oss.encore.model.Status -import java.net.URI +import se.svt.oss.junit5.redis.EmbeddedRedisExtension import java.time.OffsetDateTime import java.util.UUID -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ExtendWith(EmbeddedRedisExtension::class) @ActiveProfiles("test") -@ContextConfiguration(initializers = [RandomPortInitializer::class]) -@DirtiesContext class EncoreJobRepositoryTest { @Autowired @@ -41,8 +35,8 @@ class EncoreJobRepositoryTest { assertThat(findByStatus.totalElements).isEqualTo(2) val callbackUrls = findByStatus.map { it.progressCallbackUri } assertThat(callbackUrls).containsExactlyInAnyOrder( - URI.create("http://transcoder2"), - URI.create("http://transcoder3") + "http://transcoder2", + "http://transcoder3" ) repository.deleteAll() } @@ -54,7 +48,7 @@ class EncoreJobRepositoryTest { profile = "animerat", outputFolder = "/shares/test", createdDate = OffsetDateTime.now(), - progressCallbackUri = URI.create(url), + progressCallbackUri = url, baseName = "test" ) encoreJob.status = status diff --git a/src/test/kotlin/se/svt/oss/encore/service/callback/CallbackServiceTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/service/callback/CallbackServiceTest.kt similarity index 76% rename from src/test/kotlin/se/svt/oss/encore/service/callback/CallbackServiceTest.kt rename to encore-common/src/test/kotlin/se/svt/oss/encore/service/callback/CallbackServiceTest.kt index c024fa61..3c058f04 100644 --- a/src/test/kotlin/se/svt/oss/encore/service/callback/CallbackServiceTest.kt +++ b/encore-common/src/test/kotlin/se/svt/oss/encore/service/callback/CallbackServiceTest.kt @@ -29,7 +29,7 @@ class CallbackServiceTest { private val encoreJob = EncoreJob( outputFolder = "/some/output", profile = "program", - progressCallbackUri = URI.create("wwww.callback.com"), + progressCallbackUri = "wwww.callback.com", progress = 50, baseName = "file" ) @@ -43,24 +43,24 @@ class CallbackServiceTest { @Test fun `successful callback`() { - every { callackClient.sendProgressCallback(encoreJob.progressCallbackUri!!, progress) } just Runs + every { callackClient.sendProgressCallback(URI.create(encoreJob.progressCallbackUri!!), progress) } just Runs callbackService.sendProgressCallback(encoreJob) - verify { callackClient.sendProgressCallback(encoreJob.progressCallbackUri!!, progress) } + verify { callackClient.sendProgressCallback(URI.create(encoreJob.progressCallbackUri!!), progress) } } @Test fun `some error upon callback`() { every { callackClient.sendProgressCallback( - encoreJob.progressCallbackUri!!, + URI.create(encoreJob.progressCallbackUri!!), progress ) } throws Exception("error") callbackService.sendProgressCallback(encoreJob) - verify { callackClient.sendProgressCallback(encoreJob.progressCallbackUri!!, progress) } + verify { callackClient.sendProgressCallback(URI.create(encoreJob.progressCallbackUri!!), progress) } } } diff --git a/src/test/kotlin/se/svt/oss/encore/service/profile/ProfileServiceTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/service/profile/ProfileServiceTest.kt similarity index 100% rename from src/test/kotlin/se/svt/oss/encore/service/profile/ProfileServiceTest.kt rename to encore-common/src/test/kotlin/se/svt/oss/encore/service/profile/ProfileServiceTest.kt diff --git a/encore-common/src/test/kotlin/se/svt/oss/encore/service/queue/QueueServiceTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/service/queue/QueueServiceTest.kt new file mode 100644 index 00000000..46ce23df --- /dev/null +++ b/encore-common/src/test/kotlin/se/svt/oss/encore/service/queue/QueueServiceTest.kt @@ -0,0 +1,318 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore.service.queue + +import io.mockk.Called +import io.mockk.Runs +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.redisson.api.RPriorityBlockingQueue +import org.redisson.api.RedissonClient +import org.springframework.data.repository.findByIdOrNull +import se.svt.oss.encore.config.EncoreProperties +import se.svt.oss.encore.defaultEncoreJob +import se.svt.oss.encore.model.EncoreJob +import se.svt.oss.encore.model.Status +import se.svt.oss.encore.model.queue.QueueItem +import se.svt.oss.encore.repository.EncoreJobRepository +import java.util.UUID +import java.util.concurrent.TimeUnit + +@ExtendWith(MockKExtension::class) +internal class QueueServiceTest { + private val highPriorityQueue = mockk>() + private val standardPriorityQueue = mockk>() + private val lowPriorityQueue = mockk>() + + val keyPrefix = "encore" + private val encoreProperties = mockk { + every { concurrency } returns 3 + every { redisKeyPrefix } returns keyPrefix + every { pollHigherPrio } returns true + } + + @MockK + private lateinit var redisson: RedissonClient + + @MockK + private lateinit var repository: EncoreJobRepository + + @InjectMockKs + private lateinit var queueService: QueueService + + private val queueItemHighPrio = QueueItem(UUID.randomUUID().toString(), 90) + private val queueItemStandardPrio = QueueItem(UUID.randomUUID().toString(), 51) + private val queueItemLowPrio = QueueItem(UUID.randomUUID().toString(), 10) + private val highPrioJob = mockk { + every { contextMap } returns emptyMap() + every { status } returns Status.QUEUED + } + private val standardPrioJob = mockk { + every { contextMap } returns emptyMap() + every { status } returns Status.QUEUED + every { status = any() } just Runs + every { message = any() } just Runs + } + private val lowPrioJob = mockk { + every { status } returns Status.QUEUED + every { contextMap } returns emptyMap() + } + + private fun mockLambda(expectedQueueItem: QueueItem, expectedEncoreJob: EncoreJob): (QueueItem, EncoreJob) -> Unit = + { item, job -> + assertThat(item).isSameAs(expectedQueueItem) + assertThat(job).isSameAs(expectedEncoreJob) + } + + private val expectHighPrio = mockLambda(queueItemHighPrio, highPrioJob) + private val expectStandardPrio = mockLambda(queueItemStandardPrio, standardPrioJob) + private val expectLowPrio = mockLambda(queueItemLowPrio, lowPrioJob) + private val expectNone: (QueueItem, EncoreJob) -> Unit = { queueItem, _ -> + throw RuntimeException("Unexpected call with $queueItem") + } + + @BeforeEach + internal fun setUp() { + every { highPriorityQueue.poll() } returns queueItemHighPrio + every { standardPriorityQueue.poll() } returns queueItemStandardPrio + every { lowPriorityQueue.poll() } returns queueItemLowPrio + every { repository.findByIdOrNull(UUID.fromString(queueItemHighPrio.id)) } returns highPrioJob + every { repository.findByIdOrNull(UUID.fromString(queueItemStandardPrio.id)) } returns standardPrioJob + every { repository.findByIdOrNull(UUID.fromString(queueItemLowPrio.id)) } returns lowPrioJob + every { redisson.getPriorityBlockingQueue("$keyPrefix-queue-0") } returns highPriorityQueue + every { redisson.getPriorityBlockingQueue("$keyPrefix-queue-1") } returns standardPriorityQueue + every { redisson.getPriorityBlockingQueue("$keyPrefix-queue-2") } returns lowPriorityQueue + } + + @Nested + inner class Init { + + @Test + fun concurrencyReduced() { + val orphanedQueue1 = mockk>() + val orphanedQueue2 = mockk>() + every { orphanedQueue1.drainTo(any()) } returns 1 + every { orphanedQueue2.drainTo(any()) } returns 1 + every { orphanedQueue1.delete() } returns true + every { orphanedQueue2.delete() } returns true + val concurrency = encoreProperties.concurrency + every { + redisson.getAtomicLong("$keyPrefix-concurrency").getAndSet(concurrency.toLong()) + } returns concurrency.toLong() + 2 + every { redisson.getPriorityBlockingQueue("$keyPrefix-queue-$concurrency") } returns orphanedQueue1 + every { redisson.getPriorityBlockingQueue("$keyPrefix-queue-${concurrency + 1}") } returns orphanedQueue2 + + queueService.handleOrphanedQueues() + + verify { orphanedQueue1.drainTo(lowPriorityQueue) } + verify { orphanedQueue2.drainTo(lowPriorityQueue) } + verify { orphanedQueue1.delete() } + verify { orphanedQueue2.delete() } + } + } + + @Nested + inner class PollHighPriorityQueue { + + @AfterEach + fun tearDown() { + verify { highPriorityQueue.poll() } + verify { standardPriorityQueue wasNot Called } + verify { lowPriorityQueue wasNot Called } + } + + @Test + fun `returns item from high priority queue if any present`() { + assertThat(queueService.poll(0, expectHighPrio)).isTrue + } + + @Test + fun `returns null if high priority queue is empty`() { + every { highPriorityQueue.poll() } returns null + assertThat(queueService.poll(0, expectNone)).isFalse + } + } + + @Nested + inner class PollHighOrStandardPriorityQueue { + + @AfterEach + fun tearDown() { + verify { highPriorityQueue.poll() } + verify { lowPriorityQueue wasNot Called } + } + + @Test + fun `returns item from high priority queue if any present`() { + assertThat(queueService.poll(1, expectHighPrio)).isTrue + verify { standardPriorityQueue wasNot Called } + } + + @Test + fun `returns items from standard priority queue if high priority queue is empty`() { + every { highPriorityQueue.poll() } returns null + assertThat(queueService.poll(1, expectStandardPrio)).isTrue + verify { standardPriorityQueue.poll() } + } + + @Test + fun `returns null if both queues empty`() { + every { highPriorityQueue.poll() } returns null + every { standardPriorityQueue.poll() } returns null + assertThat(queueService.poll(1, expectNone)).isFalse + verify { standardPriorityQueue.poll() } + } + } + + @Nested + inner class PollStandardPriorityQueue { + + @BeforeEach + fun setUp() { + every { encoreProperties.pollHigherPrio } returns false + } + + @AfterEach + fun tearDown() { + verify { standardPriorityQueue.poll() } + verify { highPriorityQueue wasNot Called } + verify { lowPriorityQueue wasNot Called } + } + + @Test + fun `returns item from queue`() { + assertThat(queueService.poll(1, expectStandardPrio)).isTrue + } + + @Test + fun `empty queue`() { + every { standardPriorityQueue.poll() } returns null + assertThat(queueService.poll(1, expectNone)).isFalse + } + + @Test + fun `job not synced yet is retried`() { + every { repository.findByIdOrNull(UUID.fromString(queueItemStandardPrio.id)) } returns null andThen standardPrioJob + assertThat(queueService.poll(1, expectStandardPrio)).isTrue + verify(exactly = 2) { repository.findByIdOrNull(UUID.fromString(queueItemStandardPrio.id)) } + } + + @Test + fun `non-existing job throws`() { + every { repository.findByIdOrNull(UUID.fromString(queueItemStandardPrio.id)) } returns null + assertThatThrownBy { queueService.poll(1, expectStandardPrio) } + .hasMessageEndingWith("does not exist") + verify(exactly = 2) { repository.findByIdOrNull(UUID.fromString(queueItemStandardPrio.id)) } + } + + @Test + fun `reenqueue on interrupt`() { + every { standardPriorityQueue.offer(any(), any(), any()) } returns true + every { repository.save(any()) } answers { firstArg() } + queueService.poll(1) { _, _ -> throw InterruptedException("shut down") } + verify { standardPriorityQueue.offer(queueItemStandardPrio, 5, TimeUnit.SECONDS) } + verify { standardPrioJob.status = Status.QUEUED } + verify { repository.save(standardPrioJob) } + } + + @Test + fun `reenqueue on interrupt fails`() { + every { standardPriorityQueue.offer(any(), any(), any()) } returns false + every { repository.save(any()) } answers { firstArg() } + queueService.poll(1) { _, _ -> throw InterruptedException("shut down") } + verify { standardPriorityQueue.offer(queueItemStandardPrio, 5, TimeUnit.SECONDS) } + verify { standardPrioJob.status = Status.FAILED } + verify { repository.save(standardPrioJob) } + } + } + + @Nested + inner class PollHighOrLowPriorityQueue { + + @AfterEach + fun tearDown() { + verify { highPriorityQueue.poll() } + } + + @Test + fun `returns item from high priority queue if any present`() { + assertThat(queueService.poll(2, expectHighPrio)).isTrue + verify { standardPriorityQueue wasNot Called } + verify { lowPriorityQueue wasNot Called } + } + + @Test + fun `returns items from low priority queue if present and high priority queue is empty`() { + every { highPriorityQueue.poll() } returns null + every { standardPriorityQueue.poll() } returns null + assertThat(queueService.poll(2, expectLowPrio)).isTrue + verify { standardPriorityQueue.poll() } + verify { lowPriorityQueue.poll() } + } + + @Test + fun `returns null if all queues are empty`() { + every { highPriorityQueue.poll() } returns null + every { standardPriorityQueue.poll() } returns null + every { lowPriorityQueue.poll() } returns null + assertThat(queueService.poll(2, expectNone)).isFalse + verify { standardPriorityQueue.poll() } + verify { lowPriorityQueue.poll() } + } + } + + @Nested + inner class Enqueue { + + @Test + fun `low priority job is enqueued on low priority queue`() { + val job = defaultEncoreJob(10) + every { lowPriorityQueue.offer(any(), any(), any()) } returns true + queueService.enqueue(job) + verify { lowPriorityQueue.offer(expectedQueueItem(job), 5, TimeUnit.SECONDS) } + verify(exactly = 0) { standardPriorityQueue.offer(any(), any(), any()) } + verify(exactly = 0) { highPriorityQueue.offer(any(), any(), any()) } + } + + @Test + fun `standard priority job is enqueued on standard priority queue`() { + val job = defaultEncoreJob(55) + every { standardPriorityQueue.offer(any(), any(), any()) } returns true + queueService.enqueue(job) + verify { standardPriorityQueue.offer(expectedQueueItem(job), 5, TimeUnit.SECONDS) } + verify(exactly = 0) { highPriorityQueue.offer(any(), any(), any()) } + verify(exactly = 0) { lowPriorityQueue.offer(any(), any(), any()) } + } + + @Test + fun `high priority job is enqueued on high priority queue`() { + val job = defaultEncoreJob(priority = 90) + every { highPriorityQueue.offer(any(), any(), any()) } returns true + queueService.enqueue(job) + verify { highPriorityQueue.offer(expectedQueueItem(job), 5, TimeUnit.SECONDS) } + verify(exactly = 0) { standardPriorityQueue.offer(any(), any(), any()) } + verify(exactly = 0) { lowPriorityQueue.offer(any(), any(), any()) } + } + + private fun expectedQueueItem(job: EncoreJob) = + QueueItem( + id = job.id.toString(), + priority = job.priority, + created = job.createdDate.toLocalDateTime() + ) + } +} diff --git a/src/test/kotlin/se/svt/oss/encore/service/queue/QueueUtilTest.kt b/encore-common/src/test/kotlin/se/svt/oss/encore/service/queue/QueueUtilTest.kt similarity index 100% rename from src/test/kotlin/se/svt/oss/encore/service/queue/QueueUtilTest.kt rename to encore-common/src/test/kotlin/se/svt/oss/encore/service/queue/QueueUtilTest.kt diff --git a/src/test/resources/application-test-local.yml b/encore-common/src/test/resources/application-test-local.yml similarity index 83% rename from src/test/resources/application-test-local.yml rename to encore-common/src/test/resources/application-test-local.yml index 55811db8..2f384c81 100644 --- a/src/test/resources/application-test-local.yml +++ b/encore-common/src/test/resources/application-test-local.yml @@ -1,6 +1,7 @@ spring: - redis: - port: ${embedded-redis.port} + data: + redis: + port: ${embedded-redis.port} main: allow-bean-definition-overriding: true @@ -8,9 +9,6 @@ logging: level: se.svt: debug -server: - port: ${random-port.server} - service: name: encore-test diff --git a/src/test/resources/application-test.yml b/encore-common/src/test/resources/application-test.yml similarity index 91% rename from src/test/resources/application-test.yml rename to encore-common/src/test/resources/application-test.yml index da270833..35106ec0 100644 --- a/src/test/resources/application-test.yml +++ b/encore-common/src/test/resources/application-test.yml @@ -1,6 +1,7 @@ spring: - redis: - port: ${embedded-redis.port} + data: + redis: + port: ${embedded-redis.port} main: allow-bean-definition-overriding: true @@ -25,6 +26,7 @@ encore-settings: local-temporary-encode: false poll-initial-delay: 1s poll-delaly: 1s + shared-work-dir: ${java.io.tmpdir}/encore-shared encoding: default-channel-layouts: 3: "3.0" diff --git a/src/test/resources/input/multiple-audio-file.json b/encore-common/src/test/resources/input/multiple-audio-file.json similarity index 100% rename from src/test/resources/input/multiple-audio-file.json rename to encore-common/src/test/resources/input/multiple-audio-file.json diff --git a/src/test/resources/input/multiple-video-file.json b/encore-common/src/test/resources/input/multiple-video-file.json similarity index 100% rename from src/test/resources/input/multiple-video-file.json rename to encore-common/src/test/resources/input/multiple-video-file.json diff --git a/src/test/resources/input/multiple_audio.mp4 b/encore-common/src/test/resources/input/multiple_audio.mp4 similarity index 100% rename from src/test/resources/input/multiple_audio.mp4 rename to encore-common/src/test/resources/input/multiple_audio.mp4 diff --git a/src/test/resources/input/multiple_video.mp4 b/encore-common/src/test/resources/input/multiple_video.mp4 similarity index 100% rename from src/test/resources/input/multiple_video.mp4 rename to encore-common/src/test/resources/input/multiple_video.mp4 diff --git a/src/test/resources/input/portrait-video-file.json b/encore-common/src/test/resources/input/portrait-video-file.json similarity index 100% rename from src/test/resources/input/portrait-video-file.json rename to encore-common/src/test/resources/input/portrait-video-file.json diff --git a/src/test/resources/input/test.mp4 b/encore-common/src/test/resources/input/test.mp4 similarity index 100% rename from src/test/resources/input/test.mp4 rename to encore-common/src/test/resources/input/test.mp4 diff --git a/src/test/resources/input/test_stereo.mp4 b/encore-common/src/test/resources/input/test_stereo.mp4 similarity index 100% rename from src/test/resources/input/test_stereo.mp4 rename to encore-common/src/test/resources/input/test_stereo.mp4 diff --git a/src/test/resources/input/video-file-long.json b/encore-common/src/test/resources/input/video-file-long.json similarity index 100% rename from src/test/resources/input/video-file-long.json rename to encore-common/src/test/resources/input/video-file-long.json diff --git a/src/test/resources/input/video-file.json b/encore-common/src/test/resources/input/video-file.json similarity index 100% rename from src/test/resources/input/video-file.json rename to encore-common/src/test/resources/input/video-file.json diff --git a/src/test/resources/profile/archive.yml b/encore-common/src/test/resources/profile/archive.yml similarity index 100% rename from src/test/resources/profile/archive.yml rename to encore-common/src/test/resources/profile/archive.yml diff --git a/src/test/resources/profile/audio-streams.yml b/encore-common/src/test/resources/profile/audio-streams.yml similarity index 100% rename from src/test/resources/profile/audio-streams.yml rename to encore-common/src/test/resources/profile/audio-streams.yml diff --git a/src/test/resources/profile/dpb_size_failed.yml b/encore-common/src/test/resources/profile/dpb_size_failed.yml similarity index 100% rename from src/test/resources/profile/dpb_size_failed.yml rename to encore-common/src/test/resources/profile/dpb_size_failed.yml diff --git a/src/test/resources/profile/multiple_inputs.yml b/encore-common/src/test/resources/profile/multiple_inputs.yml similarity index 100% rename from src/test/resources/profile/multiple_inputs.yml rename to encore-common/src/test/resources/profile/multiple_inputs.yml diff --git a/src/test/resources/profile/profiles.yml b/encore-common/src/test/resources/profile/profiles.yml similarity index 100% rename from src/test/resources/profile/profiles.yml rename to encore-common/src/test/resources/profile/profiles.yml diff --git a/src/test/resources/profile/program-x265.yml b/encore-common/src/test/resources/profile/program-x265.yml similarity index 100% rename from src/test/resources/profile/program-x265.yml rename to encore-common/src/test/resources/profile/program-x265.yml diff --git a/src/test/resources/profile/program.yml b/encore-common/src/test/resources/profile/program.yml similarity index 100% rename from src/test/resources/profile/program.yml rename to encore-common/src/test/resources/profile/program.yml diff --git a/src/test/resources/profile/test_profile_invalid.yml b/encore-common/src/test/resources/profile/test_profile_invalid.yml similarity index 100% rename from src/test/resources/profile/test_profile_invalid.yml rename to encore-common/src/test/resources/profile/test_profile_invalid.yml diff --git a/encore-web/Dockerfile b/encore-web/Dockerfile new file mode 100644 index 00000000..31c104a9 --- /dev/null +++ b/encore-web/Dockerfile @@ -0,0 +1,12 @@ +ARG DOCKER_BASE_IMAGE +FROM ${DOCKER_BASE_IMAGE} + +LABEL org.opencontainers.image.url="https://github.com/svt/encore" +LABEL org.opencontainers.image.source="https://github.com/svt/encore" + +# produced by gradle target nativeCompile +COPY build/native/nativeCompile/encore-web /app/ + +WORKDIR /app + +CMD ["/app/encore-web"] diff --git a/Dockerfile b/encore-web/Dockerfile-jar similarity index 67% rename from Dockerfile rename to encore-web/Dockerfile-jar index 7524a3cb..8dcc92b8 100644 --- a/Dockerfile +++ b/encore-web/Dockerfile-jar @@ -4,8 +4,8 @@ FROM ${DOCKER_BASE_IMAGE} LABEL org.opencontainers.image.url="https://github.com/svt/encore" LABEL org.opencontainers.image.source="https://github.com/svt/encore" -COPY build/libs/encore*.jar /app/encore.jar +COPY build/libs/encore-web-*-boot.jar /app/encore-web.jar WORKDIR /app -CMD ["java", "-jar", "encore.jar"] +CMD ["java", "-jar", "encore-web.jar"] diff --git a/encore-web/build.gradle.kts b/encore-web/build.gradle.kts new file mode 100644 index 00000000..1451249c --- /dev/null +++ b/encore-web/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("encore.kotlin-conventions") + id("encore.spring-boot-app-conventions") +} + +dependencies { + implementation(project(":encore-common")) + implementation("org.springframework.boot:spring-boot-starter-data-rest") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-actuator") + + testImplementation("com.ninja-squad:springmockk:4.0.2") + testImplementation("se.svt.oss:junit5-redis-extension:3.0.0") +} \ No newline at end of file diff --git a/src/main/kotlin/se/svt/oss/encore/EncoreApplication.kt b/encore-web/src/main/kotlin/se/svt/oss/encore/EncoreApplication.kt similarity index 85% rename from src/main/kotlin/se/svt/oss/encore/EncoreApplication.kt rename to encore-web/src/main/kotlin/se/svt/oss/encore/EncoreApplication.kt index 63b469ec..5dc5d57a 100644 --- a/src/main/kotlin/se/svt/oss/encore/EncoreApplication.kt +++ b/encore-web/src/main/kotlin/se/svt/oss/encore/EncoreApplication.kt @@ -9,14 +9,14 @@ import org.springframework.boot.actuate.autoconfigure.security.servlet.Managemen import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration import org.springframework.boot.context.properties.EnableConfigurationProperties -import org.springframework.retry.annotation.EnableRetry +import org.springframework.context.annotation.ImportRuntimeHints import se.svt.oss.encore.config.EncoreProperties -@EnableRetry @EnableConfigurationProperties(EncoreProperties::class) @SpringBootApplication( exclude = [SecurityAutoConfiguration::class, ManagementWebSecurityAutoConfiguration::class] ) +@ImportRuntimeHints(EncoreRuntimeHints::class, EncoreWebRuntimeHints::class) class EncoreApplication fun main(args: Array) { diff --git a/encore-web/src/main/kotlin/se/svt/oss/encore/EncoreWebRuntimeHints.kt b/encore-web/src/main/kotlin/se/svt/oss/encore/EncoreWebRuntimeHints.kt new file mode 100644 index 00000000..d6c82481 --- /dev/null +++ b/encore-web/src/main/kotlin/se/svt/oss/encore/EncoreWebRuntimeHints.kt @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore + +import org.springframework.aot.hint.MemberCategory +import org.springframework.aot.hint.RuntimeHints +import org.springframework.aot.hint.RuntimeHintsRegistrar +import se.svt.oss.encore.handlers.EncoreJobHandler + +class EncoreWebRuntimeHints : RuntimeHintsRegistrar { + override fun registerHints(hints: RuntimeHints, classLoader: ClassLoader?) { + hints.reflection() + .registerType( + EncoreJobHandler::class.java, + MemberCategory.INVOKE_PUBLIC_METHODS + ) + } +} diff --git a/src/main/kotlin/se/svt/oss/encore/OpenAPIConfiguration.kt b/encore-web/src/main/kotlin/se/svt/oss/encore/OpenAPIConfiguration.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/OpenAPIConfiguration.kt rename to encore-web/src/main/kotlin/se/svt/oss/encore/OpenAPIConfiguration.kt diff --git a/src/main/kotlin/se/svt/oss/encore/RepositoryConfiguration.kt b/encore-web/src/main/kotlin/se/svt/oss/encore/RepositoryConfiguration.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/RepositoryConfiguration.kt rename to encore-web/src/main/kotlin/se/svt/oss/encore/RepositoryConfiguration.kt diff --git a/src/main/kotlin/se/svt/oss/encore/SchedulingConfiguration.kt b/encore-web/src/main/kotlin/se/svt/oss/encore/SchedulingConfiguration.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/SchedulingConfiguration.kt rename to encore-web/src/main/kotlin/se/svt/oss/encore/SchedulingConfiguration.kt diff --git a/src/main/kotlin/se/svt/oss/encore/SecurityConfiguration.kt b/encore-web/src/main/kotlin/se/svt/oss/encore/SecurityConfiguration.kt similarity index 51% rename from src/main/kotlin/se/svt/oss/encore/SecurityConfiguration.kt rename to encore-web/src/main/kotlin/se/svt/oss/encore/SecurityConfiguration.kt index da0e2ec6..f79845b2 100644 --- a/src/main/kotlin/se/svt/oss/encore/SecurityConfiguration.kt +++ b/encore-web/src/main/kotlin/se/svt/oss/encore/SecurityConfiguration.kt @@ -1,56 +1,64 @@ // SPDX-FileCopyrightText: 2020 Sveriges Television AB // // SPDX-License-Identifier: EUPL-1.2 + package se.svt.oss.encore +import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointProperties +import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest +import org.springframework.boot.actuate.health.HealthEndpoint import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.http.HttpMethod -import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity -import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter -import org.springframework.security.config.web.servlet.invoke +import org.springframework.security.config.annotation.web.invoke import org.springframework.security.core.authority.SimpleGrantedAuthority -import org.springframework.security.crypto.factory.PasswordEncoderFactories +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.web.SecurityFilterChain import se.svt.oss.encore.config.EncoreProperties private const val ROLE_USER = "USER" private const val ROLE_ADMIN = "ADMIN" -private val ROLE_ANON = "ANON" +private const val ROLE_ANON = "ANON" -@ConditionalOnProperty(prefix = "encore-settings.security", name = ["enabled"]) -@EnableWebSecurity @Configuration -class SecurityConfiguration( - private val encoreProperties: EncoreProperties -) : WebSecurityConfigurerAdapter() { +@EnableWebSecurity +@ConditionalOnProperty(prefix = "encore-settings.security", name = ["enabled"]) +class SecurityConfiguration(private val encoreProperties: EncoreProperties) { - override fun configure(auth: AuthenticationManagerBuilder) { - val encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder() - auth.inMemoryAuthentication() - .withUser("user") - .password(encoder.encode(encoreProperties.security.userPassword)).roles(ROLE_USER) - .and() - .withUser("admin").password(encoder.encode(encoreProperties.security.adminPassword)).roles( - ROLE_USER, - ROLE_ADMIN - ) + @Bean + fun users(): UserDetailsService { + val user = User.builder() + .username("user") + .password(encoreProperties.security.userPassword) + .roles(ROLE_USER) + .build() + val admin = User.builder() + .username("admin") + .password(encoreProperties.security.adminPassword) + .roles(ROLE_USER, ROLE_ADMIN) + .build() + return InMemoryUserDetailsManager(user, admin) } - override fun configure(http: HttpSecurity) { + @Bean + fun filterChain(http: HttpSecurity, webEndPointProperties: WebEndpointProperties): SecurityFilterChain { http { headers { httpStrictTransportSecurity { } } authorizeRequests { - authorize(HttpMethod.GET, "/health", anonymous) + authorize(EndpointRequest.to(HealthEndpoint::class.java), permitAll) authorize(HttpMethod.GET, "/**", hasRole(ROLE_USER)) - authorize(HttpMethod.PATCH, "/**", hasRole(ROLE_ADMIN)) authorize(HttpMethod.PUT, "/**", hasRole(ROLE_ADMIN)) authorize(HttpMethod.DELETE, "/**", hasRole(ROLE_ADMIN)) authorize(HttpMethod.POST, "/**", hasRole(ROLE_ADMIN)) authorize(HttpMethod.PATCH, "/**", hasRole(ROLE_ADMIN)) authorize(HttpMethod.OPTIONS, "/**", hasRole(ROLE_ADMIN)) authorize(HttpMethod.TRACE, "/**", hasRole(ROLE_ADMIN)) + authorize(anyRequest, denyAll) } httpBasic { } csrf { disable() } @@ -58,5 +66,6 @@ class SecurityConfiguration( authorities = listOf(SimpleGrantedAuthority(ROLE_ANON)) } } + return http.build() } } diff --git a/src/main/kotlin/se/svt/oss/encore/controller/EncoreController.kt b/encore-web/src/main/kotlin/se/svt/oss/encore/controller/EncoreController.kt similarity index 98% rename from src/main/kotlin/se/svt/oss/encore/controller/EncoreController.kt rename to encore-web/src/main/kotlin/se/svt/oss/encore/controller/EncoreController.kt index 1eca689d..7abee87c 100644 --- a/src/main/kotlin/se/svt/oss/encore/controller/EncoreController.kt +++ b/encore-web/src/main/kotlin/se/svt/oss/encore/controller/EncoreController.kt @@ -26,7 +26,7 @@ import java.util.UUID class EncoreController( private val repository: EncoreJobRepository, private val redissonClient: RedissonClient, - private val queueService: QueueService, + private val queueService: QueueService ) { @Operation(summary = "Get Queues", description = "Returns a list of queues (QueueItems)", tags = ["queue"]) diff --git a/src/main/kotlin/se/svt/oss/encore/handlers/EncoreJobHandler.kt b/encore-web/src/main/kotlin/se/svt/oss/encore/handlers/EncoreJobHandler.kt similarity index 100% rename from src/main/kotlin/se/svt/oss/encore/handlers/EncoreJobHandler.kt rename to encore-web/src/main/kotlin/se/svt/oss/encore/handlers/EncoreJobHandler.kt diff --git a/encore-web/src/main/kotlin/se/svt/oss/encore/poll/JobPoller.kt b/encore-web/src/main/kotlin/se/svt/oss/encore/poll/JobPoller.kt new file mode 100644 index 00000000..da737241 --- /dev/null +++ b/encore-web/src/main/kotlin/se/svt/oss/encore/poll/JobPoller.kt @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore.poll + +import jakarta.annotation.PostConstruct +import jakarta.annotation.PreDestroy +import mu.KotlinLogging +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler +import org.springframework.stereotype.Service +import se.svt.oss.encore.config.EncoreProperties +import se.svt.oss.encore.service.EncoreService +import se.svt.oss.encore.service.queue.QueueService +import java.time.Instant +import java.util.concurrent.ScheduledFuture + +@Service +class JobPoller( + private val queueService: QueueService, + private val encoreService: EncoreService, + private val scheduler: ThreadPoolTaskScheduler, + private val encoreProperties: EncoreProperties +) { + + private val log = KotlinLogging.logger {} + private var scheduledTasks = emptyList>() + + @PostConstruct + fun init() { + if (encoreProperties.pollDisabled) { + return + } + val pollQueue = encoreProperties.pollQueue + scheduledTasks = if (pollQueue != null) { + listOf(scheduledFuture(pollQueue)) + } else { + (0 until encoreProperties.concurrency).map { queueNo -> + scheduledFuture(queueNo) + } + } + } + + private fun scheduledFuture(queueNo: Int): ScheduledFuture<*> = + scheduler.scheduleWithFixedDelay( + { + try { + queueService.poll(queueNo, encoreService::encode) + } catch (e: Throwable) { + log.error(e) { "Error polling queue $queueNo!" } + } + }, + Instant.now().plus(encoreProperties.pollInitialDelay), + encoreProperties.pollDelay + ) + + @PreDestroy + fun destroy() { + scheduledTasks.forEach { it.cancel(false) } + } +} diff --git a/encore-web/src/main/resources/application.yml b/encore-web/src/main/resources/application.yml new file mode 100644 index 00000000..48fe2a04 --- /dev/null +++ b/encore-web/src/main/resources/application.yml @@ -0,0 +1,20 @@ +spring: + application: + name: encore + banner: + location: classpath:asciilogo.txt + cloud: + config: + import-check: + enabled: false +logging: + config: classpath:logback-json.xml + +springdoc: + paths-to-exclude: /profile/encoreJobs,/profile + swagger-ui: + operations-sorter: alpha + tags-sorter: alpha + disable-swagger-default-url: true +server: + forward-headers-strategy: framework \ No newline at end of file diff --git a/src/main/resources/asciilogo.txt b/encore-web/src/main/resources/asciilogo.txt similarity index 100% rename from src/main/resources/asciilogo.txt rename to encore-web/src/main/resources/asciilogo.txt diff --git a/encore-web/src/main/resources/logback-json.xml b/encore-web/src/main/resources/logback-json.xml new file mode 100644 index 00000000..885d39f1 --- /dev/null +++ b/encore-web/src/main/resources/logback-json.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/encore-web/src/test/kotlin/se/svt/oss/encore/EncoreEndpointAccessIntegrationTest.kt b/encore-web/src/test/kotlin/se/svt/oss/encore/EncoreEndpointAccessIntegrationTest.kt new file mode 100644 index 00000000..7edc9966 --- /dev/null +++ b/encore-web/src/test/kotlin/se/svt/oss/encore/EncoreEndpointAccessIntegrationTest.kt @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore + +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.NoSuchBeanDefinitionException +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.assertj.AssertableApplicationContext +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.http.MediaType +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.reactive.server.WebTestClient +import se.svt.oss.encore.config.EncoreProperties +import se.svt.oss.encore.model.EncoreJob +import se.svt.oss.junit5.redis.EmbeddedRedisExtension + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = ["encore-settings.security.enabled=true"] +) +@ActiveProfiles("test") +@ExtendWith(EmbeddedRedisExtension::class) +class EncoreEndpointAccessIntegrationTest { + + @Autowired + lateinit var webTestClient: WebTestClient + + @Test + fun `security configuration is not loaded in context when security disabled`() { + val contextRunner = ApplicationContextRunner() + contextRunner + .withBean(EncoreProperties::class.java) + .withPropertyValues("encore-settings.security.enabled=false") + .withUserConfiguration(SecurityConfiguration::class.java) + .run { context: AssertableApplicationContext -> + assertThrows { + context.getBean(SecurityConfiguration::class.java) + } + } + } + + @Nested + inner class User { + @Test + fun `User user is allowed GET`() { + webTestClient.get() + .uri("/encoreJobs") + .headers { it.setBasicAuth("user", "upw") } + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus() + .is2xxSuccessful + } + + @Test + fun `User user is Forbidden POST`() { + webTestClient.post() + .uri("/encoreJobs") + .headers { + it.setBasicAuth("user", "upw") + it.contentType = MediaType.APPLICATION_JSON + } + .bodyValue(EncoreJob(baseName = "TEST", profile = "program", outputFolder = "/test")) + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus() + .isForbidden + } + } + + @Nested + inner class Admin { + @Test + fun `Admin user is allowed GET`() { + webTestClient.get() + .uri("/encoreJobs") + .headers { it.setBasicAuth("admin", "apw") } + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus() + .is2xxSuccessful + } + + @Test + fun `Admin user is allowed POST`() { + webTestClient.post() + .uri("/encoreJobs") + .headers { + it.setBasicAuth("admin", "apw") + it.contentType = MediaType.APPLICATION_JSON + } + .bodyValue(EncoreJob(baseName = "TEST", profile = "program", outputFolder = "/test")) + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus() + .is2xxSuccessful + } + + @Test + fun `Admin user is authorized GET health with details`() { + webTestClient.get() + .uri("/actuator/health") + .headers { + it.setBasicAuth("admin", "apw") + } + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().is2xxSuccessful + .expectBody() + .jsonPath("\$.status").isEqualTo("UP") + .jsonPath("\$.components..details").isNotEmpty + } + } + + @Nested + inner class Anonymous { + @Test + fun `Anonymous user is not authorized GET`() { + webTestClient.get() + .uri("/encoreJobs") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus() + .isUnauthorized + } + + @Test + fun `Anonymous user is authorized GET health without details`() { + webTestClient.get() + .uri("/actuator/health") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectBody() + .json("""{status: "UP", groups:["liveness","readiness"]}""", true) + } + + @Test + fun `Anonymous user is authorized GET health readiness without details`() { + webTestClient.get() + .uri("/actuator/health/readiness") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectBody() + .json("""{status: "UP"}""", true) + } + + @Test + fun `Anonymous user is not authorized POST`() { + webTestClient.post() + .uri("/encoreJobs") + .headers { + it.contentType = MediaType.APPLICATION_JSON + } + .bodyValue(EncoreJob(baseName = "TEST", profile = "program", outputFolder = "/test")) + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus() + .isUnauthorized + } + } +} diff --git a/encore-web/src/test/kotlin/se/svt/oss/encore/EncoreWebRuntimeHintsTest.kt b/encore-web/src/test/kotlin/se/svt/oss/encore/EncoreWebRuntimeHintsTest.kt new file mode 100644 index 00000000..24417f04 --- /dev/null +++ b/encore-web/src/test/kotlin/se/svt/oss/encore/EncoreWebRuntimeHintsTest.kt @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore + +import org.junit.jupiter.api.Test +import org.springframework.aot.hint.RuntimeHints +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates +import se.svt.oss.encore.Assertions.assertThat +import se.svt.oss.encore.handlers.EncoreJobHandler +import kotlin.reflect.jvm.javaMethod + +class EncoreWebRuntimeHintsTest { + @Test + fun shouldRegisterHints() { + val hints = RuntimeHints() + EncoreWebRuntimeHints().registerHints(hints, javaClass.classLoader) + assertThat(RuntimeHintsPredicates.reflection().onMethod(EncoreJobHandler::onAfterCreate.javaMethod!!)).accepts(hints) + } +} diff --git a/src/test/kotlin/se/svt/oss/encore/controller/EncoreControllerTest.kt b/encore-web/src/test/kotlin/se/svt/oss/encore/controller/EncoreControllerTest.kt similarity index 97% rename from src/test/kotlin/se/svt/oss/encore/controller/EncoreControllerTest.kt rename to encore-web/src/test/kotlin/se/svt/oss/encore/controller/EncoreControllerTest.kt index 0cf88c04..4fd1967a 100644 --- a/src/test/kotlin/se/svt/oss/encore/controller/EncoreControllerTest.kt +++ b/encore-web/src/test/kotlin/se/svt/oss/encore/controller/EncoreControllerTest.kt @@ -22,8 +22,8 @@ import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.post -import se.svt.oss.encore.defaultEncoreJob import se.svt.oss.encore.model.CancelEvent +import se.svt.oss.encore.model.EncoreJob import se.svt.oss.encore.model.Status import se.svt.oss.encore.model.queue.QueueItem import se.svt.oss.encore.repository.EncoreJobRepository @@ -73,7 +73,7 @@ class EncoreControllerTest { @Nested inner class Cancel { - private val encoreJob = defaultEncoreJob() + private val encoreJob = EncoreJob(baseName = "TEST", outputFolder = "/test", profile = "test") private fun cancelAndAssertStatus(jobId: UUID, expectedStatus: Int) { mockMvc.post("/encoreJobs/$jobId/cancel") { diff --git a/src/test/kotlin/se/svt/oss/encore/handlers/EncoreJobHandlerTest.kt b/encore-web/src/test/kotlin/se/svt/oss/encore/handlers/EncoreJobHandlerTest.kt similarity index 82% rename from src/test/kotlin/se/svt/oss/encore/handlers/EncoreJobHandlerTest.kt rename to encore-web/src/test/kotlin/se/svt/oss/encore/handlers/EncoreJobHandlerTest.kt index 53feb449..5b08299b 100644 --- a/src/test/kotlin/se/svt/oss/encore/handlers/EncoreJobHandlerTest.kt +++ b/encore-web/src/test/kotlin/se/svt/oss/encore/handlers/EncoreJobHandlerTest.kt @@ -15,12 +15,10 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import se.svt.oss.encore.Assertions.assertThat -import se.svt.oss.encore.defaultVideoFile -import se.svt.oss.encore.defaultEncoreJob +import se.svt.oss.encore.model.EncoreJob import se.svt.oss.encore.model.Status import se.svt.oss.encore.repository.EncoreJobRepository import se.svt.oss.encore.service.queue.QueueService -import se.svt.oss.mediaanalyzer.MediaAnalyzer @ExtendWith(MockKExtension::class) class EncoreJobHandlerTest { @@ -31,19 +29,13 @@ class EncoreJobHandlerTest { @MockK private lateinit var repository: EncoreJobRepository - @MockK - private lateinit var mediaAnalyzer: MediaAnalyzer - @InjectMockKs private lateinit var encoreJobHandler: EncoreJobHandler - private val job = defaultEncoreJob() - - private val videoFile = defaultVideoFile + private val job = EncoreJob(baseName = "TEST", outputFolder = "/test", profile = "test") @BeforeEach fun setUp() { - every { mediaAnalyzer.analyze(any()) } returns videoFile every { repository.save(job) } returns job } diff --git a/encore-web/src/test/kotlin/se/svt/oss/encore/poll/JobPollerTest.kt b/encore-web/src/test/kotlin/se/svt/oss/encore/poll/JobPollerTest.kt new file mode 100644 index 00000000..5d156f36 --- /dev/null +++ b/encore-web/src/test/kotlin/se/svt/oss/encore/poll/JobPollerTest.kt @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore.poll + +import io.mockk.Called +import io.mockk.Runs +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifySequence +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler +import se.svt.oss.encore.Assertions.assertThat +import se.svt.oss.encore.config.EncoreProperties +import se.svt.oss.encore.model.EncoreJob +import se.svt.oss.encore.model.queue.QueueItem +import se.svt.oss.encore.service.EncoreService +import se.svt.oss.encore.service.queue.QueueService +import java.time.Duration +import java.time.Instant +import java.util.concurrent.ScheduledFuture + +@ExtendWith(MockKExtension::class) +class JobPollerTest { + + @MockK + private lateinit var queueService: QueueService + + @MockK + private lateinit var encoreProperties: EncoreProperties + + @MockK + private lateinit var encoreService: EncoreService + + @MockK + private lateinit var scheduler: ThreadPoolTaskScheduler + + @InjectMockKs + private lateinit var jobPoller: JobPoller + + private val encoreJob = EncoreJob(baseName = "TEST", outputFolder = "/test", profile = "test") + + private val queueItem = QueueItem(encoreJob.id.toString()) + + private val capturedRunnables = mutableListOf() + private val scheduledTasks = mutableListOf>() + + @BeforeEach + fun setUp() { + every { scheduler.scheduleWithFixedDelay(capture(capturedRunnables), any(), any()) } answers { + val scheduled = mockk>() + scheduledTasks.add(scheduled) + scheduled + } + every { encoreService.encode(any(), any()) } just Runs + every { queueService.poll(any(), captureLambda()) } answers { + lambda<(QueueItem, EncoreJob) -> Unit>().captured.invoke(queueItem, encoreJob) + true + } + every { encoreProperties.concurrency } returns 3 + every { encoreProperties.pollDelay } returns Duration.ofSeconds(1) + every { encoreProperties.pollInitialDelay } returns Duration.ofSeconds(10) + every { encoreProperties.pollQueue } returns null + every { encoreProperties.pollDisabled } returns false + } + + @Test + fun doesNothingWhenPollDisabled() { + every { encoreProperties.pollDisabled } returns true + jobPoller.init() + verify { scheduler wasNot Called } + verify { encoreService wasNot Called } + assertThat(capturedRunnables).isEmpty() + } + + @Test + fun testDestroy() { + jobPoller.init() + assertThat(capturedRunnables).hasSize(3) + assertThat(scheduledTasks).hasSize(3) + scheduledTasks.forEach { + every { it.cancel(false) } returns true + } + jobPoller.destroy() + scheduledTasks.forEach { + verify { it.cancel(false) } + } + } + + @ParameterizedTest + @ValueSource(ints = [0, 1, 2]) + fun pollAll(thread: Int) { + jobPoller.init() + assertThat(capturedRunnables).hasSize(3) + capturedRunnables[thread].run() + + verifySequence { + queueService.poll(thread, any()) + encoreService.encode(queueItem, encoreJob) + } + } + + @ParameterizedTest + @ValueSource(ints = [0, 1, 2]) + fun pollSpecific(queueNo: Int) { + every { encoreProperties.pollQueue } returns queueNo + jobPoller.init() + assertThat(capturedRunnables).hasSize(1) + capturedRunnables.first().run() + verifySequence { + queueService.poll(queueNo, any()) + encoreService.encode(queueItem, encoreJob) + } + } + + @ParameterizedTest + @ValueSource(ints = [0, 1, 2]) + fun `poll causes exception`(thread: Int) { + every { queueService.poll(thread, any()) } throws Exception("error") + jobPoller.init() + + capturedRunnables[thread].run() + + verifySequence { + queueService.poll(thread, any()) + } + } +} diff --git a/encore-web/src/test/resources/application-test.yml b/encore-web/src/test/resources/application-test.yml new file mode 100644 index 00000000..5ce3bffd --- /dev/null +++ b/encore-web/src/test/resources/application-test.yml @@ -0,0 +1,47 @@ +spring: + data: + redis: + port: ${embedded-redis.port} + main: + allow-bean-definition-overriding: true +management: + endpoint: + health: + show-details: when_authorized + roles: ADMIN + probes: + enabled: true + +logging: + level: + se.svt: debug + +encore-settings: + concurrency: 3 + local-temporary-encode: false + poll-initial-delay: 1s + poll-delaly: 1s + security: + user-password: '{bcrypt}$2a$10$5UQDlMEE6PDHNtr.pY/Jh.06Dq0BTkFtQyYNQint/R0KhC4muu4PO' # 'upw' encypted with spring boot cli + admin-password: '{bcrypt}$2a$10$Yp8gtLGnpyFtlyOrL6/ajOO/hPXOCKMf4IpCW41ptMUzAUpJmmGOC' # 'apw' encypted with spring boot cli + encoding: + default-channel-layouts: + 3: "3.0" + audio-mix-presets: + default: + default-pan: + "[5.1]": c0 = c0 | c1 = c1 | c2 = c2 | c3 = c3 | c4 = c4 | c5 = c5 + stereo: c0 = c0 + 0.707*c2 + 0.707*c4 | c1 = c1 + 0.707*c2 + 0.707*c5 + pan-mapping: + "[5.1]": + stereo: c0=1.0*c0+0.707*c2+0.707*c4|c1=1.0*c1+0.707*c2+0.707*c5 + de: + fallback-to-auto: false + pan-mapping: + "[5.1]": + stereo: c0<0.25*c0+1.5*c2+0.25*c4|c1<0.25*c1+1.5*c2+0.25*c5 + "[5.1(side)]": + stereo: c0<0.25*c0+1.5*c2+0.25*c4|c1<0.25*c1+1.5*c2+0.25*c5 + +profile: + location: classpath:profile/profiles.yml diff --git a/encore-web/src/test/resources/profile/archive.yml b/encore-web/src/test/resources/profile/archive.yml new file mode 100644 index 00000000..7a3d38b5 --- /dev/null +++ b/encore-web/src/test/resources/profile/archive.yml @@ -0,0 +1,15 @@ +name: archive +description: Archive format +encodes: + - type: VideoEncode + codec: dnxhd + height: 1080 + params: + b:v: 185M + pix_fmt: yuv422p10le + suffix: _DNxHD_185x + format: mxf + twoPass: false + audioEncode: + type: SimpleAudioEncode + codec: pcm_s24le diff --git a/encore-web/src/test/resources/profile/audio-streams.yml b/encore-web/src/test/resources/profile/audio-streams.yml new file mode 100644 index 00000000..fe3aa5aa --- /dev/null +++ b/encore-web/src/test/resources/profile/audio-streams.yml @@ -0,0 +1,20 @@ +name: audio-streams +description: Video file with multiple audio streams +encodes: + - type: X264Encode + suffix: '' + twoPass: false + format: mp4 + height: 720 + params: + preset: fast + pix_fmt: yuv420p + audioEncodes: + - type: AudioEncode + codec: ac3 + bitrate: 448k + channelLayout: '5.1' + optional: true + - type: AudioEncode + bitrate: 128k + channelLayout: 'stereo' \ No newline at end of file diff --git a/encore-web/src/test/resources/profile/dpb_size_failed.yml b/encore-web/src/test/resources/profile/dpb_size_failed.yml new file mode 100644 index 00000000..cbbe8855 --- /dev/null +++ b/encore-web/src/test/resources/profile/dpb_size_failed.yml @@ -0,0 +1,42 @@ +name: dpb_size_failed +description: Test profile, should fail +scaling: fast_bilinear +encodes: + - type: X264Encode + suffix: _x264_1400 + twoPass: true + width: -2 + height: 1080 + params: + b:v: 1400k + maxrate: 2100k + bufsize: 4800k + r: 25 + fps_mode: cfr + pix_fmt: yuv420p + force_key_frames: expr:not(mod(n,96)) + level: 4.1 + profile:v: high + x264-params: + deblock: 1,1 + aq-mode: 1 + aq-strength: 0.6 + b-adapt: 2 + bframes: 8 + b-bias: 0 + b-pyramid: 2 + chroma-qp-offset: -2 + direct: auto + rc-lookahead: 70 + keyint: 192 + keyint_min: 96 + me: umh + merange: 40 + cabac: 1 + partitions: all + psy-rd: 0.4 + ref: 7 + scenecut: 40 + subme: 10 + trellis: 2 + weightp: 2 diff --git a/encore-web/src/test/resources/profile/multiple_inputs.yml b/encore-web/src/test/resources/profile/multiple_inputs.yml new file mode 100644 index 00000000..30a8d3d7 --- /dev/null +++ b/encore-web/src/test/resources/profile/multiple_inputs.yml @@ -0,0 +1,73 @@ +name: multiple-inputs +description: Test profile multiple inputs +scaling: bicubic +encodes: + - type: X264Encode + suffix: _x264_3100 + twoPass: true + params: + b:v: 3100k + maxrate: 4700k + bufsize: 6200k + r: 25 + fps_mode: cfr + pix_fmt: yuv420p + force_key_frames: expr:not(mod(n,96)) + profile:v: high + level: 4.1 + x264-params: + deblock: 0,0 + aq-mode: 1 + aq-strength: 1.0 + b-adapt: 2 + bframes: 6 + b-bias: 0 + b-pyramid: 2 + chroma-qp-offset: -2 + direct: auto + rc-lookahead: 60 + keyint: 192 + keyint_min: 96 + me: hex + merange: 16 + cabac: 1 + partitions: all + ref: 4 + scenecut: 40 + subme: 9 + trellis: 2 + weightp: 2 + audioEncode: + type: AudioEncode + bitrate: 128k + suffix: STEREO + + - type: AudioEncode + bitrate: 128k + suffix: _STEREO + + - type: AudioEncode + bitrate: 128k + suffix: _STEREO_DE + audioMixPreset: de + optional: true + + - type: AudioEncode + codec: ac3 + bitrate: 448k + suffix: _SURROUND + optional: true + channelLayout: '5.1(side)' + + - type: AudioEncode + bitrate: 128k + suffix: _STEREO_ALT + inputLabel: alt + + - type: ThumbnailMapEncode + cols: 6 + rows: 10 + + - type: ThumbnailEncode + + diff --git a/encore-web/src/test/resources/profile/profiles.yml b/encore-web/src/test/resources/profile/profiles.yml new file mode 100644 index 00000000..1e6174cf --- /dev/null +++ b/encore-web/src/test/resources/profile/profiles.yml @@ -0,0 +1,9 @@ +program: program.yml +multiple-inputs: multiple_inputs.yml +dpb_size_failed: dpb_size_failed.yml +program-x265: program-x265.yml +archive: archive.yml +audio-streams: audio-streams.yml +test-invalid: test_profile_invalid.yml +test-invalid-location: test_profile_invalid_location.yml +none: diff --git a/encore-web/src/test/resources/profile/program-x265.yml b/encore-web/src/test/resources/profile/program-x265.yml new file mode 100644 index 00000000..baf7ad6a --- /dev/null +++ b/encore-web/src/test/resources/profile/program-x265.yml @@ -0,0 +1,475 @@ +name: program-x265 +description: HEVC profile +scaling: bicubic +encodes: + - type: X265Encode + suffix: _x265_2600 + twoPass: true + height: 1080 + params: + b:v: 2600k + maxrate: 3900k + bufsize: 5200k + r: 25 + pix_fmt: yuv420p10le + profile:v: main10 + tag:v: hvc1 + force_key_frames: expr:not(mod(n,96)) + x265-params: + min-keyint: 96 + keyint: 96 + pmode: 1 + level-idc: 4.1 + ctu: 64 + min-cu-size: 8 + bframes: 6 + b-adapt: 2 + rc-lookahead: 25 + lookahead-slices: 1 + scenecut: 40 + ref: 4 + limit-refs: 1 + me: star + merange: 57 + subme: 3 + rect: 1 + amp: 0 + limit-modes: 0 + max-merge: 4 + early-skip: 0 + rskip: 1 + fast-intra: 0 + b-intra: 1 + sao: 1 + signhide: 1 + weightp: 1 + weightb: 1 + aq-mode: 2 + cutree: 1 + rd: 4 + rdoq-level: 2 + tu-intra-depth: 4 + tu-inter-depth: 4 + limit-tu: 0 + audioEncode: + type: AudioEncode + bitrate: 192k + suffix: STEREO + + - type: X265Encode + suffix: _x265_1598 + twoPass: true + height: 720 + params: + b:v: 1536847 + maxrate: 2305271 + bufsize: 3073694 + r: 25 + pix_fmt: yuv420p10le + profile:v: main10 + tag:v: hvc1 + force_key_frames: expr:not(mod(n,96)) + x265-params: + min-keyint: 96 + keyint: 96 + pmode: 1 + level-idc: 4.1 + ctu: 64 + min-cu-size: 8 + bframes: 4 + b-adapt: 2 + rc-lookahead: 20 + lookahead-slices: 1 + scenecut: 40 + ref: 4 + limit-refs: 1 + me: star + merange: 57 + subme: 3 + rect: 1 + amp: 0 + limit-modes: 0 + max-merge: 3 + early-skip: 0 + rskip: 1 + fast-intra: 0 + b-intra: 0 + sao: 1 + signhide: 1 + weightp: 1 + weightb: 0 + aq-mode: 2 + cutree: 1 + rd: 4 + rdoq-level: 2 + tu-intra-depth: 4 + tu-inter-depth: 4 + limit-tu: 0 + audioEncode: + type: AudioEncode + bitrate: 128k + suffix: STEREO + + - type: X265Encode + suffix: _x265_865 + twoPass: true + height: 540 + params: + b:v: 865857 + maxrate: 1298786 + bufsize: 1731714 + r: 25 + pix_fmt: yuv420p10le + profile:v: main10 + tag:v: hvc1 + force_key_frames: expr:not(mod(n,96)) + x265-params: + min-keyint: 96 + keyint: 96 + pmode: 1 + ctu: 64 + level-idc: 4.1 + min-cu-size: 8 + bframes: 4 + b-adapt: 2 + rc-lookahead: 20 + lookahead-slices: 1 + scenecut: 40 + ref: 4 + limit-refs: 1 + me: star + merange: 57 + subme: 3 + rect: 1 + amp: 0 + limit-modes: 0 + max-merge: 3 + early-skip: 0 + rskip: 1 + fast-intra: 0 + b-intra: 0 + sao: 1 + signhide: 1 + weightp: 1 + weightb: 0 + aq-mode: 2 + cutree: 1 + rd: 4 + rdoq-level: 2 + tu-intra-depth: 3 + tu-inter-depth: 3 + limit-tu: 0 + audioEncode: + type: AudioEncode + bitrate: 128k + suffix: STEREO + samplerate: 48000 + channels: 2 + + - type: X265Encode + suffix: _x265_474 + twoPass: true + height: 360 + params: + b:v: 695711 + maxrate: 1043567 + bufsize: 1391422 + r: 25 + pix_fmt: yuv420p + profile:v: main + tag:v: hvc1 + force_key_frames: expr:not(mod(n,96)) + x265-params: + min-keyint: 96 + keyint: 96 + level-idc: 4.1 + pmode: 1 + ctu: 64 + min-cu-size: 8 + bframes: 4 + b-adapt: 2 + rc-lookahead: 20 + lookahead-slices: 1 + scenecut: 40 + ref: 4 + limit-refs: 1 + me: star + merange: 57 + subme: 3 + rect: 1 + amp: 0 + limit-modes: 0 + max-merge: 3 + early-skip: 0 + rskip: 1 + fast-intra: 0 + b-intra: 0 + sao: 1 + signhide: 1 + weightp: 1 + weightb: 0 + aq-mode: 2 + cutree: 1 + rd: 4 + rdoq-level: 2 + tu-intra-depth: 3 + tu-inter-depth: 3 + limit-tu: 0 + audioEncode: + type: AudioEncode + bitrate: 128k + suffix: STEREO + + - type: X264Encode + suffix: _x264_243 + height: 234 + twoPass: true + params: + b:v: 324051 + maxrate: 486077 + bufsize: 648102 + r: 25 + fps_mode: cfr + pix_fmt: yuv420p + force_key_frames: expr:not(mod(n,96)) + profile:v: baseline + level: 3.1 + x264-params: + deblock: 0,0 + aq-mode: 1 + aq-strength: 1.0 + chroma-qp-offset: -2 + direct: auto + keyint: 192 + keyint_min: 96 + me: hex + merange: 16 + cabac: 0 + 8x8dct: 0 + ref: 3 + scenecut: 40 + subme: 9 + trellis: 2 + audioEncode: + type: AudioEncode + bitrate: 96k + suffix: STEREO + samplerate: 48000 + channels: 2 + + - type: X264Encode + suffix: _x264_2500 + twoPass: true + height: 1080 + params: + b:v: 3100k + maxrate: 4700k + bufsize: 6200k + r: 25 + fps_mode: cfr + pix_fmt: yuv420p + force_key_frames: expr:not(mod(n,96)) + profile:v: high + level: 4.1 + x264-params: + deblock: 0,0 + aq-mode: 1 + aq-strength: 1.0 + b-adapt: 2 + bframes: 6 + b-bias: 0 + b-pyramid: 2 + chroma-qp-offset: -2 + direct: auto + rc-lookahead: 60 + keyint: 192 + keyint_min: 96 + me: hex + merange: 16 + cabac: 1 + partitions: all + ref: 4 + scenecut: 40 + subme: 9 + trellis: 2 + weightp: 2 + audioEncode: + type: AudioEncode + bitrate: 128k + suffix: STEREO + + - type: X264Encode + suffix: _x264_1300 + height: 720 + twoPass: true + params: + b:v: 2069k + maxrate: 3104k + bufsize: 4138k + r: 25 + fps_mode: cfr + pix_fmt: yuv420p + force_key_frames: expr:not(mod(n,96)) + profile:v: main + level: 3.1 + x264-params: + deblock: 0,0 + aq-mode: 1 + aq-strength: 1.0 + b-adapt: 2 + bframes: 6 + b-bias: 0 + b-pyramid: 2 + chroma-qp-offset: -2 + direct: auto + rc-lookahead: 60 + keyint: 192 + keyint_min: 96 + me: hex + merange: 16 + cabac: 1 + partitions: all + ref: 4 + scenecut: 40 + subme: 9 + trellis: 2 + weightp: 2 + audioEncode: + type: AudioEncode + bitrate: 128k + suffix: STEREO + + - type: X264Encode + suffix: _x264_870 + twoPass: true + height: 540 + params: + b:v: 1312k + maxrate: 1968k + bufsize: 2524k + r: 25 + fps_mode: cfr + pix_fmt: yuv420p + force_key_frames: expr:not(mod(n,96)) + level: 3.1 + profile:v: main + x264-params: + deblock: 0,0 + aq-mode: 1 + aq-strength: 1.0 + b-adapt: 2 + bframes: 6 + b-bias: 0 + b-pyramid: 2 + chroma-qp-offset: -2 + direct: auto + rc-lookahead: 60 + keyint: 192 + keyint_min: 96 + me: hex + merange: 16 + cabac: 1 + partitions: all + ref: 4 + scenecut: 40 + subme: 9 + trellis: 2 + weightp: 2 + audioEncode: + type: AudioEncode + bitrate: 96k + suffix: STEREO + samplerate: 48000 + channels: 2 + + - type: X264Encode + suffix: _x264_470 + twoPass: true + height: 360 + params: + b:v: 806121 + maxrate: 1209182 + bufsize: 1612242 + r: 25 + fps_mode: cfr + pix_fmt: yuv420p + force_key_frames: expr:not(mod(n,96)) + profile:v: main + level: 3.1 + x264-params: + deblock: 0,0 + aq-mode: 1 + aq-strength: 1.0 + b-adapt: 2 + bframes: 6 + b-bias: 0 + b-pyramid: 2 + chroma-qp-offset: -2 + direct: auto + rc-lookahead: 60 + keyint: 192 + keyint_min: 96 + me: hex + merange: 16 + cabac: 1 + partitions: all + ref: 4 + scenecut: 40 + subme: 9 + trellis: 2 + weightp: 2 + audioEncode: + type: AudioEncode + bitrate: 96k + suffix: STEREO + samplerate: 48000 + channels: 2 + + - type: X264Encode + suffix: _x264_240 + twoPass: true + height: 234 + params: + b:v: 324051 + maxrate: 486077 + bufsize: 648102 + r: 25 + fps_mode: cfr + pix_fmt: yuv420p + force_key_frames: expr:not(mod(n,96)) + profile:v: baseline + level: 3.1 + x264-params: + deblock: 0,0 + aq-mode: 1 + aq-strength: 1.0 + chroma-qp-offset: -2 + direct: auto + keyint: 192 + keyint_min: 96 + me: hex + merange: 16 + cabac: 0 + 8x8dct: 0 + ref: 3 + scenecut: 40 + subme: 9 + trellis: 2 + audioEncode: + type: AudioEncode + bitrate: 96k + suffix: STEREO + + - type: AudioEncode + bitrate: 128k + suffix: _STEREO + + - type: AudioEncode + codec: ac3 + bitrate: 448k + suffix: _SURROUND + channelLayout: '5.1' + + - type: ThumbnailMapEncode + + - type: ThumbnailEncode diff --git a/encore-web/src/test/resources/profile/program.yml b/encore-web/src/test/resources/profile/program.yml new file mode 100644 index 00000000..42bbf5f9 --- /dev/null +++ b/encore-web/src/test/resources/profile/program.yml @@ -0,0 +1,225 @@ +name: program +description: Program profile +scaling: bicubic +encodes: + - type: X264Encode + suffix: _x264_3100 + twoPass: true + height: 1080 + params: + b:v: 3100k + maxrate: 4700k + bufsize: 6200k + r: 25 + fps_mode: cfr + pix_fmt: yuv420p + force_key_frames: expr:not(mod(n,96)) + profile:v: high + level: 4.1 + x264-params: + deblock: 0,0 + aq-mode: 1 + aq-strength: 1.0 + b-adapt: 2 + bframes: 6 + b-bias: 0 + b-pyramid: 2 + chroma-qp-offset: -2 + direct: auto + rc-lookahead: 60 + keyint: 192 + keyint_min: 96 + me: hex + merange: 16 + cabac: 1 + partitions: all + ref: 4 + scenecut: 40 + subme: 9 + trellis: 2 + weightp: 2 + audioEncode: + type: AudioEncode + bitrate: 128k + suffix: STEREO + + - type: X264Encode + suffix: _x264_2069 + twoPass: true + height: 720 + params: + b:v: 2069k + maxrate: 3104k + bufsize: 4138k + r: 25 + fps_mode: cfr + pix_fmt: yuv420p + force_key_frames: expr:not(mod(n,96)) + profile:v: main + level: 3.1 + x264-params: + deblock: 0,0 + aq-mode: 1 + aq-strength: 1.0 + b-adapt: 2 + bframes: 6 + b-bias: 0 + b-pyramid: 2 + chroma-qp-offset: -2 + direct: auto + rc-lookahead: 60 + keyint: 192 + keyint_min: 96 + me: hex + merange: 16 + cabac: 1 + partitions: all + ref: 4 + scenecut: 40 + subme: 9 + trellis: 2 + weightp: 2 + audioEncode: + type: AudioEncode + bitrate: 128k + suffix: STEREO + + - type: X264Encode + suffix: _x264_1312 + twoPass: true + height: 540 + params: + b:v: 1312k + maxrate: 1968k + bufsize: 2524k + r: 25 + fps_mode: cfr + pix_fmt: yuv420p + force_key_frames: expr:not(mod(n,96)) + level: 3.1 + profile:v: main + x264-params: + deblock: 0,0 + aq-mode: 1 + aq-strength: 1.0 + b-adapt: 2 + bframes: 6 + b-bias: 0 + b-pyramid: 2 + chroma-qp-offset: -2 + direct: auto + rc-lookahead: 60 + keyint: 192 + keyint_min: 96 + me: hex + merange: 16 + cabac: 1 + partitions: all + ref: 4 + scenecut: 40 + subme: 9 + trellis: 2 + weightp: 2 + audioEncode: + type: AudioEncode + bitrate: 96k + suffix: STEREO + + - type: X264Encode + suffix: _x264_806 + twoPass: true + height: 360 + params: + b:v: 806121 + maxrate: 1209182 + bufsize: 1612242 + r: 25 + fps_mode: cfr + pix_fmt: yuv420p + force_key_frames: expr:not(mod(n,96)) + profile:v: main + level: 3.1 + x264-params: + deblock: 0,0 + aq-mode: 1 + aq-strength: 1.0 + b-adapt: 2 + bframes: 6 + b-bias: 0 + b-pyramid: 2 + chroma-qp-offset: -2 + direct: auto + rc-lookahead: 60 + keyint: 192 + keyint_min: 96 + me: hex + merange: 16 + cabac: 1 + partitions: all + ref: 4 + scenecut: 40 + subme: 9 + trellis: 2 + weightp: 2 + audioEncode: + type: AudioEncode + bitrate: 96k + suffix: STEREO + + - type: X264Encode + suffix: _x264_324 + twoPass: true + height: 234 + params: + b:v: 324051 + maxrate: 486077 + bufsize: 648102 + r: 25 + fps_mode: cfr + pix_fmt: yuv420p + force_key_frames: expr:not(mod(n,96)) + profile:v: baseline + level: 3.1 + x264-params: + deblock: 0,0 + aq-mode: 1 + aq-strength: 1.0 + chroma-qp-offset: -2 + direct: auto + keyint: 192 + keyint_min: 96 + me: hex + merange: 16 + cabac: 0 + 8x8dct: 0 + ref: 3 + scenecut: 40 + subme: 9 + trellis: 2 + audioEncode: + type: AudioEncode + bitrate: 96k + suffix: STEREO + + - type: AudioEncode + bitrate: 128k + suffix: _STEREO + + - type: AudioEncode + bitrate: 128k + suffix: _STEREO_DE + audioMixPreset: de + optional: true + + - type: AudioEncode + codec: ac3 + bitrate: 448k + suffix: _SURROUND + optional: true + channelLayout: '5.1' + + - type: ThumbnailMapEncode + + - type: ThumbnailEncode + + diff --git a/encore-web/src/test/resources/profile/test_profile_invalid.yml b/encore-web/src/test/resources/profile/test_profile_invalid.yml new file mode 100644 index 00000000..1a2d7616 --- /dev/null +++ b/encore-web/src/test/resources/profile/test_profile_invalid.yml @@ -0,0 +1,58 @@ +name: animerat +description: Animated profile, optimized for animated content +scaling: lanczos +encodes: + - type: X264Encode + suffixBroken: _x264_1400 + twoPass: true + width: -2 + height: 1080 + params: + b:v: 1400k + maxrate: 2100k + bufsize: 4800k + r: 25 + fps_mode: cfr + pix_fmt: yuv420p + force_key_frames: expr:not(mod(n,96)) + level: 4.1 + profile:v: high + x264-params: + deblock: 1,1 + aq-mode: 1 + aq-strength: 0.6 + b-adapt: 2 + bframes: 8 + b-bias: 0 + b-pyramid: 2 + chroma-qp-offset: -2 + direct: auto + rc-lookahead: 70 + keyint: 192 + keyint_min: 96 + me: umh + merange: 40 + cabac: 1 + partitions: all + psy-rd: 0.4 + ref: 4 + scenecut: 40 + subme: 10 + trellis: 2 + weightp: 2 + audioEncode: + type: AudioEncode + codec: aac + channels: 2 + bitrate: 128k + suffix: STEREO + + - type: ThumbnailEncode + + - type: ThumbnailMapEncode + + - type: AudioEncode + codec: aac + channels: 2 + bitrate: 128k + suffix: _STEREO diff --git a/encore-worker/Dockerfile b/encore-worker/Dockerfile new file mode 100644 index 00000000..52698e39 --- /dev/null +++ b/encore-worker/Dockerfile @@ -0,0 +1,12 @@ +ARG DOCKER_BASE_IMAGE +FROM ${DOCKER_BASE_IMAGE} + +LABEL org.opencontainers.image.url="https://github.com/svt/encore" +LABEL org.opencontainers.image.source="https://github.com/svt/encore" + +# produced by gradle target nativeCompile +COPY build/native/nativeCompile/encore-worker /app/ + +WORKDIR /app + +CMD ["/app/encore-worker"] diff --git a/encore-worker/build.gradle.kts b/encore-worker/build.gradle.kts new file mode 100644 index 00000000..84318877 --- /dev/null +++ b/encore-worker/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id("encore.kotlin-conventions") + id("encore.spring-boot-app-conventions") +} + +dependencies { + implementation(project(":encore-common")) +} \ No newline at end of file diff --git a/encore-worker/src/main/kotlin/se/svt/oss/encore/EncoreWorkerApplication.kt b/encore-worker/src/main/kotlin/se/svt/oss/encore/EncoreWorkerApplication.kt new file mode 100644 index 00000000..1d916947 --- /dev/null +++ b/encore-worker/src/main/kotlin/se/svt/oss/encore/EncoreWorkerApplication.kt @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore + +import mu.KotlinLogging +import org.springframework.boot.CommandLineRunner +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.boot.runApplication +import org.springframework.context.ApplicationContext +import org.springframework.context.annotation.ImportRuntimeHints +import se.svt.oss.encore.config.EncoreProperties +import se.svt.oss.encore.service.EncoreService +import se.svt.oss.encore.service.queue.QueueService + +@EnableConfigurationProperties(EncoreProperties::class) +@ImportRuntimeHints(EncoreRuntimeHints::class) +@SpringBootApplication +class EncoreWorkerApplication( + private val queueService: QueueService, + private val encoreService: EncoreService, + private val applicationContext: ApplicationContext, + private val encoreProperties: EncoreProperties +) : CommandLineRunner { + private val log = KotlinLogging.logger { } + + override fun run(vararg args: String?) { + try { + poll() + } finally { + log.info { "Stopping" } + SpringApplication.exit(applicationContext) + } + } + + private fun poll() { + val queueNo = encoreProperties.pollQueue ?: 0 + log.info { "Polling queue $queueNo" } + val jobRun = queueService.poll(queueNo, encoreService::encode) + if (encoreProperties.workerDrainQueue && jobRun) { + poll() + } + } +} + +fun main(args: Array) { + runApplication(* args) +} diff --git a/encore-worker/src/main/resources/application.yml b/encore-worker/src/main/resources/application.yml new file mode 100644 index 00000000..45669c0c --- /dev/null +++ b/encore-worker/src/main/resources/application.yml @@ -0,0 +1,13 @@ +spring: + application: + name: encore + banner: + location: classpath:asciilogo.txt + main: + web-application-type: none + cloud: + config: + import-check: + enabled: false +logging: + config: classpath:logback-json.xml \ No newline at end of file diff --git a/encore-worker/src/main/resources/asciilogo.txt b/encore-worker/src/main/resources/asciilogo.txt new file mode 100644 index 00000000..c69322cb --- /dev/null +++ b/encore-worker/src/main/resources/asciilogo.txt @@ -0,0 +1,45 @@ + ``````` + ``.--:/+ossssyysssoo+/:-.``` + ``-:/syhdmmNNNMMMMMMMMMNNNNmddyso:-.` + `.:oydmNMMMMMMNNNmmmmmmmmNNNMMMMMMMNmdho/-.-+/- + `-/ydNNMMMNNmdyso//:--.....--::++oyhmmNMMMNNmmmNy+ + `-:ydNMMMNmdyo:-.``` ```.-/+yhmMMMMMMho + `.ohmMMMNmho/-`` ./hNMMMMMds + `-ohNMMMmd+:.` .:shdddmmhs` + -+dNMMNmo/.` ``...--:-. + `-ydMMMms+`` `...-:/:. + .+dNMMmd:. `-ohhdmmNhs `.-`` + `.ydMMNd+: ./dMMMMMMNd..` `.shdy+:`` .-/:- + -oNMMNd/. `/hNMMMMMMMhyso+/osNMMMNms+. `ohNdh-` + -omMMMy+` `.--` `.+smMMMMMMMMMMMNNNNMMMMMMMNh+` `sdMMNs/` + `sdMMNd:` `:ydds+:/shNNMMMMMMMMNNMMMMMMMMMMMMMho. :oNMMNs: + .:mNMNh/` `ohNMMMNmNNMMMMNmdhyo+++ooyhmNMMMMMMMs/ `-hmMMdy` + :oMMMdo. -+mNMMMMMMMMMMmdo/..``` ````.-oymNMMMMds-` `/hNMNm-. + `ohMMMo: -smNMMMMMMMMNs+. `:sdMMMMNh:-..-.` `odMMMo: + `-yNMMN:. ``/odNMMMMNho`` `.-++o++/.` .-dmMMMMmdhddh+. /yMMMy+ + `/hMMNd.` -yNMMMMh+. `-ohmNNNNNmhs:` /yNMMMMMMMMNy/ -+MMMdo` + `+dMMmy` `:hNMMMN/- +yNMMMMMMMMMNd/. `+hMMMMMMMMMhs .:NMMms- + `omMMds` `-+mMMMMN-. `-hmMMMMMMMMMMMMho -oMMMMMMMMNdy` `-NNMNy-` + .smMMds` `.++ohdMMMMNm-` `:dNMMMMMMMMMMMMds -+NMMMMmhso/: `-NNMNy-` + .smMMds` -/NNMMMMMMMMN-. `-hmMMMMMMMMMMMMho :oMMMMms-`` .-NNMNy-` + `odMMmy` .-mNMMMMMMMMM+- +yNMMMMMMMMMNd/. `+dMMMMd/` ./NMMms-` + `/dMMNd.` ``hmMMMMMMMMMd+. `-ohmNNNNNmhs:` `/yNMMMNh-` :oMMMdo. + `-yNMNN:. oymdddmNMMMMds.` `.-++o++/.` .:dNMMMMNms:. /yMMMy+ + .odMMMo: ..--..+sNMMMMNy+.` ` `:ydMMMMMMMMNmh-` .sdMMMo: + /sMMMdo. `.sdMMMMMNdo+-.```````.-:oyNNMMMMMMMMMMmy.` `/hNMNm-. + .:mNMNh/` `-ymMMMMMMNNdhyssossyhdmNMMMMMNmNNMMNm+- `-hmMMdy` + `ydMMNd-` `+hNMMMMMMMMMMMMMMMMMMMMMMMNms+/osddh/. :oNMMms: + -smMMNo: `:dNMMMMMMMNNNNMMMMMMMMMMMNy+:`` ``--.` `-ymMMNs:` + -+mmm+: `.+ydNMMMNdo++osydmMMMMMMMm/. `:yNMMmh-` + ``:::.` .-oydds:` ```/oMMMMMMMm+. ``ohNMMNs/ + .--. .:NNNmmdhy:. .:sNMMNm+-` + ` `///:-..` .-ydNMMNs+` + .:+//:::--` `./sdMMMNdo-` + /yNNNNNNdy.` `.:oyNNMMNmo:` + :sMMMMMMds-.` `.:oymNMMMNho:` + :+MMMMMMNNdys/:.``` ``.-/+yhmNMMMNdyo.` + -/mdyhdNNMMMNNmdys+//:---------::/+oyhmmNMMMNNdyo-.` + `.:-``./oydmNMMMMMMMNNmmmmmmmmmNNMMMMMMNNmdyo/.` + ``-:/syhddmNNNNMMMMMMMNNNNmdhhso+:-` + ``.--:/++oossyysso++/:-..`` + diff --git a/encore-worker/src/main/resources/logback-json.xml b/encore-worker/src/main/resources/logback-json.xml new file mode 100644 index 00000000..885d39f1 --- /dev/null +++ b/encore-worker/src/main/resources/logback-json.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/encore-worker/src/test/kotlin/se/svt/oss/encore/EncoreWorkerApplicationTest.kt b/encore-worker/src/test/kotlin/se/svt/oss/encore/EncoreWorkerApplicationTest.kt new file mode 100644 index 00000000..60c24a96 --- /dev/null +++ b/encore-worker/src/test/kotlin/se/svt/oss/encore/EncoreWorkerApplicationTest.kt @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2020 Sveriges Television AB +// +// SPDX-License-Identifier: EUPL-1.2 + +package se.svt.oss.encore + +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.boot.SpringApplication +import org.springframework.context.ApplicationContext +import se.svt.oss.encore.config.EncoreProperties +import se.svt.oss.encore.service.EncoreService +import se.svt.oss.encore.service.queue.QueueService + +@ExtendWith(MockKExtension::class) +class EncoreWorkerApplicationTest { + + @MockK + private lateinit var queueService: QueueService + + @MockK + private lateinit var encoreService: EncoreService + + @MockK + private lateinit var applicationContext: ApplicationContext + + @MockK + private lateinit var encoreProperties: EncoreProperties + + @InjectMockKs + lateinit var application: EncoreWorkerApplication + + @BeforeEach + fun setUp() { + every { queueService.poll(any(), any()) } returns true andThen false + every { encoreProperties.pollQueue } returns 1 + every { encoreProperties.workerDrainQueue } returns false + mockkStatic(SpringApplication::class) + every { SpringApplication.exit(any()) } returns 0 + } + + @AfterEach + fun tearDown() { + unmockkAll() + } + + @Test + fun pollOnce() { + application.run() + verify(exactly = 1) { queueService.poll(1, encoreService::encode) } + verify { SpringApplication.exit(applicationContext) } + } + + @Test + fun defaultsToQueue0() { + every { encoreProperties.pollQueue } returns null + application.run() + verify(exactly = 1) { queueService.poll(0, encoreService::encode) } + verify { SpringApplication.exit(applicationContext) } + } + + @Test + fun drainQueue() { + every { encoreProperties.workerDrainQueue } returns true + application.run() + verify(exactly = 2) { queueService.poll(1, encoreService::encode) } + verify { SpringApplication.exit(applicationContext) } + } +} diff --git a/src/main/resources/encore_logo.png b/encore_logo.png similarity index 100% rename from src/main/resources/encore_logo.png rename to encore_logo.png diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 249e5832f090a2944b7473328c07c9755baa3196..7f93135c49b765f8051ef9d0a6055ff8e46073d8 100644 GIT binary patch delta 44091 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 F{tsc4gtPzv delta 41205 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 jFA#1K+D2e>Q|zP /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -133,22 +131,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,6 +198,10 @@ if "$cygwin" || "$msys" ; then done fi + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + # Collect all arguments for the java command; # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # shell script including quotes and variable substitutions, so put them in diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index a8051409..00000000 --- a/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'encore' \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 00000000..a8ef87a9 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,4 @@ +rootProject.name = "encore" +include("encore-common") +include("encore-web") +include("encore-worker") \ No newline at end of file diff --git a/src/main/kotlin/se/svt/oss/encore/FeignConfiguration.kt b/src/main/kotlin/se/svt/oss/encore/FeignConfiguration.kt deleted file mode 100644 index 3fa4edf7..00000000 --- a/src/main/kotlin/se/svt/oss/encore/FeignConfiguration.kt +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-FileCopyrightText: 2020 Sveriges Television AB -// -// SPDX-License-Identifier: EUPL-1.2 - -package se.svt.oss.encore - -import feign.RequestInterceptor -import org.springframework.beans.factory.annotation.Value -import org.springframework.cloud.openfeign.EnableFeignClients -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.http.HttpHeaders - -@Configuration -@EnableFeignClients -class FeignConfiguration { - - @Bean - fun userAgentInterceptor(@Value("\${service.name:encore}") userAgent: String): RequestInterceptor = - RequestInterceptor { template -> template.header(HttpHeaders.USER_AGENT, userAgent) } -} diff --git a/src/main/kotlin/se/svt/oss/encore/config/EncoreProperties.kt b/src/main/kotlin/se/svt/oss/encore/config/EncoreProperties.kt deleted file mode 100644 index a6ebb001..00000000 --- a/src/main/kotlin/se/svt/oss/encore/config/EncoreProperties.kt +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-FileCopyrightText: 2020 Sveriges Television AB -// -// SPDX-License-Identifier: EUPL-1.2 - -package se.svt.oss.encore.config - -import org.springframework.boot.context.properties.ConfigurationProperties -import org.springframework.boot.context.properties.ConstructorBinding -import java.time.Duration - -@ConfigurationProperties("encore-settings") -@ConstructorBinding -data class EncoreProperties( - val localTemporaryEncode: Boolean = false, - val concurrency: Int = 2, - val pollInitialDelay: Duration = Duration.ofSeconds(10), - val pollDelay: Duration = Duration.ofSeconds(5), - val redisKeyPrefix: String = "encore", - val security: Security = Security(), - val openApi: OpenApi = OpenApi(), - val encoding: EncodingProperties = EncodingProperties() -) { - data class Security( - val enabled: Boolean = false, - val userPassword: String = "", - val adminPassword: String = "" - ) - - data class OpenApi( - val title: String = "Encore OpenAPI", - val description: String = "Endpoints for Encore", - val contactName: String = "", - val contactUrl: String = "", - val contactEmail: String = "" - ) -} diff --git a/src/main/kotlin/se/svt/oss/encore/repository/URIConverters.kt b/src/main/kotlin/se/svt/oss/encore/repository/URIConverters.kt deleted file mode 100644 index 42367e96..00000000 --- a/src/main/kotlin/se/svt/oss/encore/repository/URIConverters.kt +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-FileCopyrightText: 2020 Sveriges Television AB -// -// SPDX-License-Identifier: EUPL-1.2 - -package se.svt.oss.encore.repository - -import java.net.URI -import org.springframework.core.convert.converter.Converter -import org.springframework.data.convert.ReadingConverter -import org.springframework.data.convert.WritingConverter - -@WritingConverter -class URIToByteArrayConverter : Converter { - override fun convert(source: URI): ByteArray? { - return source.toString().toByteArray() - } -} - -@ReadingConverter -class ByteArrayToURIConverter : Converter { - override fun convert(source: ByteArray): URI? { - return URI.create(String(source)) - } -} diff --git a/src/main/kotlin/se/svt/oss/encore/repository/UUIDConverters.kt b/src/main/kotlin/se/svt/oss/encore/repository/UUIDConverters.kt deleted file mode 100644 index fcfeb04f..00000000 --- a/src/main/kotlin/se/svt/oss/encore/repository/UUIDConverters.kt +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-FileCopyrightText: 2020 Sveriges Television AB -// -// SPDX-License-Identifier: EUPL-1.2 - -package se.svt.oss.encore.repository - -import java.util.UUID -import org.springframework.core.convert.converter.Converter -import org.springframework.data.convert.ReadingConverter -import org.springframework.data.convert.WritingConverter - -@WritingConverter -class UUIDToByteArrayConverter : Converter { - override fun convert(source: UUID): ByteArray? { - return source.toString().toByteArray() - } -} - -@ReadingConverter -class ByteArrayToUUIDConverter : Converter { - override fun convert(source: ByteArray): UUID? { - return UUID.fromString(String(source)) - } -} diff --git a/src/main/kotlin/se/svt/oss/encore/service/EncoreService.kt b/src/main/kotlin/se/svt/oss/encore/service/EncoreService.kt deleted file mode 100644 index 050a354b..00000000 --- a/src/main/kotlin/se/svt/oss/encore/service/EncoreService.kt +++ /dev/null @@ -1,163 +0,0 @@ -// SPDX-FileCopyrightText: 2020 Sveriges Television AB -// -// SPDX-License-Identifier: EUPL-1.2 - -package se.svt.oss.encore.service - -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.conflate -import kotlinx.coroutines.flow.consumeAsFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.sample -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.slf4j.MDCContext -import mu.KotlinLogging -import org.redisson.api.RTopic -import org.redisson.api.RedissonClient -import org.springframework.data.redis.core.PartialUpdate -import org.springframework.data.redis.core.RedisKeyValueTemplate -import org.springframework.stereotype.Service -import se.svt.oss.encore.cancellation.CancellationListener -import se.svt.oss.encore.config.EncoreProperties -import se.svt.oss.encore.model.CancelEvent -import se.svt.oss.encore.model.EncoreJob -import se.svt.oss.encore.model.Status -import se.svt.oss.encore.repository.EncoreJobRepository -import se.svt.oss.encore.service.callback.CallbackService -import se.svt.oss.encore.service.localencode.LocalEncodeService -import se.svt.oss.encore.service.mediaanalyzer.MediaAnalyzerService -import se.svt.oss.encore.service.profile.ProfileService -import se.svt.oss.mediaanalyzer.file.MediaContainer -import se.svt.oss.mediaanalyzer.file.MediaFile -import java.util.Locale - -@Service -@ExperimentalCoroutinesApi -@FlowPreview -class EncoreService( - private val callbackService: CallbackService, - private val repository: EncoreJobRepository, - private val profileService: ProfileService, - private val ffmpegExecutor: FfmpegExecutor, - private val redissonClient: RedissonClient, - private val redisKeyValueTemplate: RedisKeyValueTemplate, - private val mediaAnalyzerService: MediaAnalyzerService, - private val localEncodeService: LocalEncodeService, - private val encoreProperties: EncoreProperties -) { - - private val log = KotlinLogging.logger {} - - private val cancelTopicName = "cancel" - - fun encode(encoreJob: EncoreJob) { - val coroutineJob = Job() - val cancelListener = CancellationListener(encoreJob.id, coroutineJob) - var cancelTopic: RTopic? = null - var outputFolder: String? = null - - try { - cancelTopic = redissonClient.getTopic(cancelTopicName) - cancelTopic.addListener(CancelEvent::class.java, cancelListener) - - encoreJob.inputs.forEach { input -> - mediaAnalyzerService.analyzeInput(input) - } - - log.info { "Start $encoreJob" } - encoreJob.status = Status.IN_PROGRESS - repository.save(encoreJob) - - val profile = profileService.getProfile(encoreJob.profile) - - outputFolder = localEncodeService.outputFolder(encoreJob) - - val outputs = profile.encodes.mapNotNull { - it.getOutput( - encoreJob, - encoreProperties.encoding - ) - } - - check(outputs.distinctBy { it.id }.size == outputs.size) { - "Profile ${encoreJob.profile} contains duplicate suffixes: ${outputs.map { it.id }}!" - } - - val start = System.currentTimeMillis() - - var outputFiles = runBlocking(coroutineJob + MDCContext()) { - val progressChannel = Channel() - handleProgress(progressChannel, encoreJob) - ffmpegExecutor.run(encoreJob, profile, outputs, outputFolder, progressChannel) - } - - outputFiles = localEncodeService.localEncodedFilesToCorrectDir(outputFolder, outputFiles, encoreJob) - - val time = (System.currentTimeMillis() - start) / 1000 - val speed = outputFiles.filterIsInstance().firstOrNull()?.let { - "%.3f".format(Locale.US, it.duration / time).toDouble() - } ?: 0.0 - log.info { "Done encoding, time: ${time}s, speed: ${speed}X" } - updateSuccessfulJob(encoreJob, outputFiles, speed) - log.info { "Done with $encoreJob" } - } catch (e: InterruptedException) { - val message = "Job execution interrupted" - log.error(e) { message } - encoreJob.status = Status.QUEUED - encoreJob.message = message - throw e - } catch (e: CancellationException) { - log.error(e) { "Job execution cancelled: $e.message" } - encoreJob.status = Status.CANCELLED - encoreJob.message = e.message - } catch (e: Exception) { - log.error(e) { "Job execution failed: ${e.message}" } - encoreJob.status = Status.FAILED - encoreJob.message = e.message - } finally { - repository.save(encoreJob) - cancelTopic?.removeListener(cancelListener) - callbackService.sendProgressCallback(encoreJob) - localEncodeService.cleanup(outputFolder) - } - } - - private fun CoroutineScope.handleProgress( - progressChannel: ReceiveChannel, - encoreJob: EncoreJob - ) { - launch { - progressChannel.consumeAsFlow() - .conflate() - .distinctUntilChanged() - .sample(10_000) - .collect { - log.info { "Received progress $it" } - try { - encoreJob.progress = it - val partialUpdate = PartialUpdate(encoreJob.id, EncoreJob::class.java) - .set(encoreJob::progress.name, encoreJob.progress) - redisKeyValueTemplate.update(partialUpdate) - callbackService.sendProgressCallback(encoreJob) - } catch (e: Exception) { - log.warn(e) { "Error updating progress!" } - } - } - } - } - - private fun updateSuccessfulJob(encoreJob: EncoreJob, output: List, speed: Double) { - encoreJob.output = output - encoreJob.status = Status.SUCCESSFUL - encoreJob.progress = 100 - encoreJob.speed = speed - } -} diff --git a/src/main/kotlin/se/svt/oss/encore/service/callback/CallbackClient.kt b/src/main/kotlin/se/svt/oss/encore/service/callback/CallbackClient.kt deleted file mode 100644 index 734e71b2..00000000 --- a/src/main/kotlin/se/svt/oss/encore/service/callback/CallbackClient.kt +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-FileCopyrightText: 2020 Sveriges Television AB -// -// SPDX-License-Identifier: EUPL-1.2 - -package se.svt.oss.encore.service.callback - -import java.net.URI -import org.springframework.cloud.openfeign.FeignClient -import org.springframework.web.bind.annotation.PostMapping -import se.svt.oss.encore.model.callback.JobProgress - -@FeignClient("callback") -interface CallbackClient { - - @PostMapping - fun sendProgressCallback(callbackUri: URI, progress: JobProgress) -} diff --git a/src/main/kotlin/se/svt/oss/encore/service/poll/JobPoller.kt b/src/main/kotlin/se/svt/oss/encore/service/poll/JobPoller.kt deleted file mode 100644 index b140386a..00000000 --- a/src/main/kotlin/se/svt/oss/encore/service/poll/JobPoller.kt +++ /dev/null @@ -1,103 +0,0 @@ -// SPDX-FileCopyrightText: 2020 Sveriges Television AB -// -// SPDX-License-Identifier: EUPL-1.2 - -package se.svt.oss.encore.service.poll - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import mu.KotlinLogging -import mu.withLoggingContext -import org.springframework.data.repository.findByIdOrNull -import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler -import org.springframework.stereotype.Service -import se.svt.oss.encore.config.EncoreProperties -import se.svt.oss.encore.model.EncoreJob -import se.svt.oss.encore.model.Status -import se.svt.oss.encore.model.queue.QueueItem -import se.svt.oss.encore.repository.EncoreJobRepository -import se.svt.oss.encore.service.EncoreService -import se.svt.oss.encore.service.queue.QueueService -import java.time.Instant -import java.util.UUID -import java.util.concurrent.ScheduledFuture -import javax.annotation.PostConstruct -import javax.annotation.PreDestroy - -@Service -@ExperimentalCoroutinesApi -@FlowPreview -class JobPoller( - private val repository: EncoreJobRepository, - private val queueService: QueueService, - private val encoreService: EncoreService, - private val scheduler: ThreadPoolTaskScheduler, - private val encoreProperties: EncoreProperties, -) { - - private val log = KotlinLogging.logger {} - private var scheduledTasks = emptyList>() - - @PostConstruct - fun init() { - scheduledTasks = (0 until encoreProperties.concurrency).map { queueNo -> - scheduler.scheduleWithFixedDelay( - { - try { - queueService.poll(queueNo)?.let { handleJob(it) } - } catch (e: Throwable) { - log.error(e) { "Error polling queue $queueNo!" } - } - }, - Instant.now().plus(encoreProperties.pollInitialDelay), - encoreProperties.pollDelay - ) - } - } - - @PreDestroy - fun destroy() { - scheduledTasks.forEach { it.cancel(false) } - } - - private fun handleJob(queueItem: QueueItem) { - val id = UUID.fromString(queueItem.id) - log.info { "Handling job $id" } - val job = repository.findByIdOrNull(id) - ?: retry(id) // Sometimes there has been sync issues - ?: throw RuntimeException("Job ${queueItem.id} does not exist") - - withLoggingContext(job.contextMap) { - if (job.status.isCancelled) { - log.info { "Job was cancelled" } - return - } - log.info { "Running job" } - try { - encoreService.encode(job) - } catch (e: InterruptedException) { - repostJob(job) - } - } - } - - private fun repostJob(job: EncoreJob) { - try { - log.info { "Adding job to queue (repost on interrupt)" } - queueService.enqueue(job) - log.info { "Added job to queue (repost on interrupt)" } - } catch (e: Exception) { - val message = "Failed to add interrupted job to queue" - log.error(e) { message } - job.message = message - job.status = Status.FAILED - repository.save(job) - } - } - - private fun retry(id: UUID): EncoreJob? { - Thread.sleep(5000) - log.info { "Retrying read of job from repository " } - return repository.findByIdOrNull(id) - } -} diff --git a/src/main/kotlin/se/svt/oss/encore/service/queue/QueueService.kt b/src/main/kotlin/se/svt/oss/encore/service/queue/QueueService.kt deleted file mode 100644 index c9f36f68..00000000 --- a/src/main/kotlin/se/svt/oss/encore/service/queue/QueueService.kt +++ /dev/null @@ -1,76 +0,0 @@ -// SPDX-FileCopyrightText: 2020 Sveriges Television AB -// -// SPDX-License-Identifier: EUPL-1.2 -package se.svt.oss.encore.service.queue - -import mu.KotlinLogging -import org.redisson.api.RPriorityBlockingQueue -import org.redisson.api.RedissonClient -import org.springframework.stereotype.Component -import se.svt.oss.encore.config.EncoreProperties -import se.svt.oss.encore.model.EncoreJob -import se.svt.oss.encore.model.queue.QueueItem -import se.svt.oss.encore.service.queue.QueueUtil.getQueueNumberByPriority -import java.util.concurrent.ConcurrentSkipListMap -import java.util.concurrent.TimeUnit -import javax.annotation.PostConstruct - -@Component -class QueueService( - encoreProperties: EncoreProperties, - private val redisson: RedissonClient -) { - - private val log = KotlinLogging.logger { } - private val queues = ConcurrentSkipListMap>() - private val concurrency = encoreProperties.concurrency - private val redisKeyPrefix = encoreProperties.redisKeyPrefix - - fun poll(queueNo: Int): QueueItem? = - (0..queueNo) - .asSequence() - .mapNotNull { getQueue(it).poll() } - .firstOrNull() - - fun enqueue(job: EncoreJob) { - val queueItem = QueueItem( - id = job.id.toString(), - priority = job.priority, - created = job.createdDate.toLocalDateTime() - ) - if (!queueByPrio(job.priority).offer(queueItem, 5, TimeUnit.SECONDS)) { - throw RuntimeException("Job could not be added to queue!") - } - } - - fun getQueue(): List { - return (0 until concurrency).flatMap { getQueue(it).toList() } - } - - private fun queueByPrio(priority: Int) = - getQueue(getQueueNumberByPriority(concurrency, priority)) - - private fun getQueue(queueNo: Int) = queues.computeIfAbsent(queueNo) { - redisson.getPriorityBlockingQueue("$redisKeyPrefix-queue-$queueNo") - } - - @PostConstruct - internal fun handleOrphanedQueues() { - try { - val oldConcurrency = - redisson.getAtomicLong("$redisKeyPrefix-concurrency").getAndSet(concurrency.toLong()).toInt() - if (oldConcurrency > concurrency) { - log.info { "Moving orphaned queue items to lowest priority queue. Old concurrency: $oldConcurrency, new concurrency: $concurrency" } - val lowestPrioQueue = getQueue(concurrency - 1) - (concurrency until oldConcurrency).forEach { queueNo -> - val orphanedQueue = getQueue(queueNo) - val transferred = orphanedQueue.drainTo(lowestPrioQueue) - log.info { "Moved $transferred orphaned items from queue $queueNo to lowest priority queue." } - orphanedQueue.delete() - } - } - } catch (e: Exception) { - log.error(e) { "Error checking for concurrency change: ${e.message}" } - } - } -} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml deleted file mode 100644 index 3e5933fd..00000000 --- a/src/main/resources/application-local.yml +++ /dev/null @@ -1,24 +0,0 @@ -service: - name: encore-local - -spring: - redis: - host: localhost - port: 6379 - -profile: - location: url:https://raw.githubusercontent.com/svt/encore/master/src/test/resources/profile/profiles.yml - -encore-settings: - concurrency: 3 - local-temporary-encode: false - poll-initial-delay: 1s - poll-delaly: 1s - - audio-mix-presets: - default: - pan-mapping: - 6: - 2: stereo|c0=1.0*c0+0.707*c2+0.707*c4|c1=1.0*c1+0.707*c2+0.707*c5 - de: - fallback-to-auto: true diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml deleted file mode 100644 index 1eee7289..00000000 --- a/src/main/resources/application.yml +++ /dev/null @@ -1,49 +0,0 @@ -spring: - application: - name: encore - banner: - location: classpath:asciilogo.txt - cloud: - config: - enabled: false - -health: - config: - enabled: false - -management: - endpoint: - health: - show-details: always - endpoints: - web: - base-path: / - health: - redis: - enabled: true - -service: - name: encore - -feign: - okhttp: - enabled: true - client: - config: - default: - connectTimeout: 2000 - readTimeout: 5000 - loggerLevel: basic - -encore-settings: - redis-key-prefix: ${service.name} - poll-initial-delay: 10s - poll-delaly: 5s - - -springdoc: - paths-to-exclude: /profile/encoreJobs,/profile - swagger-ui: - operations-sorter: alpha - tags-sorter: alpha - disable-swagger-default-url: true diff --git a/src/test/kotlin/se/svt/oss/encore/EncoreEndpointAccessIntegrationTest.kt b/src/test/kotlin/se/svt/oss/encore/EncoreEndpointAccessIntegrationTest.kt deleted file mode 100644 index 65ee1c17..00000000 --- a/src/test/kotlin/se/svt/oss/encore/EncoreEndpointAccessIntegrationTest.kt +++ /dev/null @@ -1,120 +0,0 @@ -// SPDX-FileCopyrightText: 2020 Sveriges Television AB -// -// SPDX-License-Identifier: EUPL-1.2 - -package se.svt.oss.encore - -import feign.FeignException -import feign.RequestInterceptor -import feign.auth.BasicAuthRequestInterceptor -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import org.springframework.beans.factory.NoSuchBeanDefinitionException -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.context.TestConfiguration -import org.springframework.boot.test.context.assertj.AssertableApplicationContext -import org.springframework.boot.test.context.runner.ApplicationContextRunner -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Import -import org.springframework.test.context.ActiveProfiles -import se.svt.oss.encore.Assertions.assertThat -import se.svt.oss.encore.config.EncoreProperties -import java.io.File - -@SpringBootTest( - webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT, - properties = ["encore-settings.security.enabled=true", "encore-settings.security.user-password=upw", "encore-settings.security.admin-password=apw"] -) -@ActiveProfiles("test") -class EncoreEndpointAccessIntegrationTest : EncoreIntegrationTestBase() { - - @Test - fun `security configuration is not loaded in context when security disabled`() { - val contextRunner = ApplicationContextRunner() - contextRunner - .withBean(EncoreProperties::class.java) - .withPropertyValues("encore-settings.security.enabled=false") - .withUserConfiguration(SecurityConfiguration::class.java) - .run { context: AssertableApplicationContext -> - assertThrows { - context.getBean(SecurityConfiguration::class.java) - } - } - } -} - -@Import(UserEndpointAccessIntegrationTest.Conf::class) -class UserEndpointAccessIntegrationTest : EncoreEndpointAccessIntegrationTest() { - - @TestConfiguration - class Conf { - @Bean - fun basicAuthRequestInterceptor(): RequestInterceptor? { - return BasicAuthRequestInterceptor("user", "upw") - } - } - - @Test - fun `User user is allowed GET`() { - encoreClient.jobs() - } - - @Test - fun `User user is Forbidden POST`() { - assertThrows { - encoreClient.createJob(job(File(""))) - } - } -} - -@Import(AdminEndpointAccessIntegrationTest.Conf::class) -class AdminEndpointAccessIntegrationTest : EncoreEndpointAccessIntegrationTest() { - - @TestConfiguration - class Conf { - @Bean - fun basicAuthRequestInterceptor(): RequestInterceptor? { - return BasicAuthRequestInterceptor("admin", "apw") - } - } - - @Test - fun `Admin user is allowed GET`() { - encoreClient.jobs() - } - - @Test - fun `Admin user is allowed POST`() { - encoreClient.createJob(job(File(""))) - } -} - -class NoUserEndpointAccessIntegrationTest : EncoreEndpointAccessIntegrationTest() { - - data class MyHealth( - val status: String, - val components: Map?, - val groups: List? - ) - - @Test - fun `Anonymous user is not authorized GET`() { - assertThrows { - encoreClient.jobs() - } - } - - @Test - fun `Anonymous user is authorized GET health without details`() { - val health = objectMapper.readValue(encoreClient.health(), MyHealth::class.java) - assertThat(health.status).isEqualTo("UP") - assertThat(health.components).isNull() - } - - @Test - fun `Anonymous user is not authorized POST`() { - assertThrows { - encoreClient.createJob(job(File(""))) - } - } -} diff --git a/src/test/kotlin/se/svt/oss/encore/service/poll/JobPollerTest.kt b/src/test/kotlin/se/svt/oss/encore/service/poll/JobPollerTest.kt deleted file mode 100644 index ae8af95f..00000000 --- a/src/test/kotlin/se/svt/oss/encore/service/poll/JobPollerTest.kt +++ /dev/null @@ -1,191 +0,0 @@ -// SPDX-FileCopyrightText: 2020 Sveriges Television AB -// -// SPDX-License-Identifier: EUPL-1.2 - -package se.svt.oss.encore.service.poll - -import io.mockk.Runs -import io.mockk.every -import io.mockk.impl.annotations.InjectMockKs -import io.mockk.impl.annotations.MockK -import io.mockk.junit5.MockKExtension -import io.mockk.just -import io.mockk.mockk -import io.mockk.verify -import io.mockk.verifySequence -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource -import org.springframework.data.repository.findByIdOrNull -import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler -import se.svt.oss.encore.Assertions.assertThat -import se.svt.oss.encore.config.EncoreProperties -import se.svt.oss.encore.defaultEncoreJob -import se.svt.oss.encore.model.Status -import se.svt.oss.encore.model.queue.QueueItem -import se.svt.oss.encore.repository.EncoreJobRepository -import se.svt.oss.encore.service.EncoreService -import se.svt.oss.encore.service.queue.QueueService -import java.time.Instant -import java.util.concurrent.ScheduledFuture - -@FlowPreview -@ExperimentalCoroutinesApi -@ExtendWith(MockKExtension::class) -class JobPollerTest { - - @MockK - private lateinit var repository: EncoreJobRepository - - @MockK - private lateinit var queueService: QueueService - - private val encoreProperties = EncoreProperties(concurrency = 3) - - @MockK - private lateinit var encoreService: EncoreService - - @MockK - private lateinit var scheduler: ThreadPoolTaskScheduler - - @InjectMockKs - private lateinit var jobPoller: JobPoller - - private val encoreJob = defaultEncoreJob() - - private val queueItem = QueueItem(encoreJob.id.toString()) - - private val capturedRunnables = mutableListOf() - private val scheduledTasks = mutableListOf>() - - @BeforeEach - fun setUp() { - every { scheduler.scheduleWithFixedDelay(capture(capturedRunnables), any(), any()) } answers { - val scheduled = mockk>() - scheduledTasks.add(scheduled) - scheduled - } - every { repository.findByIdOrNull(encoreJob.id) } returns encoreJob - every { encoreService.encode(encoreJob) } just Runs - every { queueService.poll(any()) } returns queueItem - jobPoller.init() - assertThat(capturedRunnables).hasSize(3) - } - - @Test - fun testDestroy() { - assertThat(scheduledTasks).hasSize(3) - scheduledTasks.forEach { - every { it.cancel(false) } returns true - } - jobPoller.destroy() - scheduledTasks.forEach { - verify { it.cancel(false) } - } - } - - @ParameterizedTest - @ValueSource(ints = [0, 1, 2]) - fun poll(thread: Int) { - capturedRunnables[thread].run() - - verifySequence { - queueService.poll(thread) - repository.findByIdOrNull(encoreJob.id) - encoreService.encode(encoreJob) - } - } - - @ParameterizedTest - @ValueSource(ints = [0, 1, 2]) - fun `poll causes exception`(thread: Int) { - every { queueService.poll(thread) } throws Exception("error") - - capturedRunnables[thread].run() - - verifySequence { - queueService.poll(thread) - } - } - - @ParameterizedTest - @ValueSource(ints = [0, 1, 2]) - fun repositoryReturnsNull(thread: Int) { - every { repository.findByIdOrNull(encoreJob.id) } returns null - - capturedRunnables[thread].run() - - verifySequence { - queueService.poll(thread) - repository.findByIdOrNull(encoreJob.id) - repository.findByIdOrNull(encoreJob.id) - } - } - - @ParameterizedTest - @ValueSource(ints = [0, 1, 2]) - fun repositoryRetryWorks(thread: Int) { - every { repository.findByIdOrNull(encoreJob.id) } returns null andThen encoreJob - - capturedRunnables[thread].run() - - verifySequence { - queueService.poll(thread) - repository.findByIdOrNull(encoreJob.id) - repository.findByIdOrNull(encoreJob.id) - encoreService.encode(encoreJob) - } - } - - @ParameterizedTest - @ValueSource(ints = [0, 1, 2]) - fun `cancelled job`(thread: Int) { - encoreJob.status = Status.CANCELLED - - capturedRunnables[thread].run() - assertThat(encoreJob.status).isEqualTo(Status.CANCELLED) - - verifySequence { - queueService.poll(thread) - repository.findByIdOrNull(encoreJob.id) - } - } - - @ParameterizedTest - @ValueSource(ints = [0, 1, 2]) - fun `interrupted job re-enqueues`(thread: Int) { - every { encoreService.encode(encoreJob) } throws InterruptedException() - every { queueService.enqueue(encoreJob) } just Runs - - capturedRunnables[thread].run() - assertThat(encoreJob.status).isEqualTo(Status.NEW) - - verifySequence { - queueService.poll(thread) - repository.findByIdOrNull(encoreJob.id) - queueService.enqueue(encoreJob) - } - } - - @ParameterizedTest - @ValueSource(ints = [0, 1, 2]) - fun `interrupted job re-enqueue fails`(thread: Int) { - every { encoreService.encode(encoreJob) } throws InterruptedException() - every { queueService.enqueue(encoreJob) } throws Exception("error") - every { repository.save(encoreJob) } returns encoreJob - - capturedRunnables[thread].run() - assertThat(encoreJob.status).isEqualTo(Status.FAILED) - - verifySequence { - queueService.poll(thread) - repository.findByIdOrNull(encoreJob.id) - queueService.enqueue(encoreJob) - repository.save(encoreJob) - } - } -} diff --git a/src/test/kotlin/se/svt/oss/encore/service/queue/QueueServiceTest.kt b/src/test/kotlin/se/svt/oss/encore/service/queue/QueueServiceTest.kt deleted file mode 100644 index 2c29c2b6..00000000 --- a/src/test/kotlin/se/svt/oss/encore/service/queue/QueueServiceTest.kt +++ /dev/null @@ -1,199 +0,0 @@ -// SPDX-FileCopyrightText: 2020 Sveriges Television AB -// -// SPDX-License-Identifier: EUPL-1.2 - -package se.svt.oss.encore.service.queue - -import io.mockk.every -import io.mockk.impl.annotations.InjectMockKs -import io.mockk.impl.annotations.MockK -import io.mockk.junit5.MockKExtension -import io.mockk.mockk -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith -import org.redisson.api.RPriorityBlockingQueue -import org.redisson.api.RedissonClient -import se.svt.oss.encore.config.EncoreProperties -import se.svt.oss.encore.defaultEncoreJob -import se.svt.oss.encore.model.EncoreJob -import se.svt.oss.encore.model.queue.QueueItem -import java.util.concurrent.TimeUnit - -@ExtendWith(MockKExtension::class) -internal class QueueServiceTest { - private val highPriorityQueue = mockk>() - private val standardPriorityQueue = mockk>() - private val lowPriorityQueue = mockk>() - - private val encoreProperties = EncoreProperties(concurrency = 3,) - - @MockK - private lateinit var redisson: RedissonClient - - @InjectMockKs - private lateinit var queueService: QueueService - - private val queueItemHighPrio = QueueItem("high", 90) - private val queueItemStandardPrio = QueueItem("standard", 51) - private val queueItemLowPrio = QueueItem("low", 10) - - @BeforeEach - internal fun setUp() { - every { highPriorityQueue.poll() } returns queueItemHighPrio - every { standardPriorityQueue.poll() } returns queueItemStandardPrio - every { lowPriorityQueue.poll() } returns queueItemLowPrio - every { redisson.getPriorityBlockingQueue("${encoreProperties.redisKeyPrefix}-queue-0") } returns highPriorityQueue - every { redisson.getPriorityBlockingQueue("${encoreProperties.redisKeyPrefix}-queue-1") } returns standardPriorityQueue - every { redisson.getPriorityBlockingQueue("${encoreProperties.redisKeyPrefix}-queue-2") } returns lowPriorityQueue - } - - @Nested - inner class Init { - - @Test - fun concurrencyReduced() { - val orphanedQueue1 = mockk>() - val orphanedQueue2 = mockk>() - every { orphanedQueue1.drainTo(any()) } returns 1 - every { orphanedQueue2.drainTo(any()) } returns 1 - every { orphanedQueue1.delete() } returns true - every { orphanedQueue2.delete() } returns true - val concurrency = encoreProperties.concurrency - every { redisson.getAtomicLong("${encoreProperties.redisKeyPrefix}-concurrency").getAndSet(concurrency.toLong()) } returns concurrency.toLong() + 2 - every { redisson.getPriorityBlockingQueue("${encoreProperties.redisKeyPrefix}-queue-$concurrency") } returns orphanedQueue1 - every { redisson.getPriorityBlockingQueue("${encoreProperties.redisKeyPrefix}-queue-${concurrency + 1}") } returns orphanedQueue2 - - queueService.handleOrphanedQueues() - - verify { orphanedQueue1.drainTo(lowPriorityQueue) } - verify { orphanedQueue2.drainTo(lowPriorityQueue) } - verify { orphanedQueue1.delete() } - verify { orphanedQueue2.delete() } - } - } - - @Nested - inner class PollHighPriorityQueue { - - @AfterEach - fun tearDown() { - verify { highPriorityQueue.poll() } - verify(exactly = 0) { standardPriorityQueue.poll() } - } - - @Test - fun `returns item from high priority queue if any present`() { - assertThat(queueService.poll(0)).isSameAs(queueItemHighPrio) - } - - @Test - fun `returns null if high priority queue is empty`() { - every { highPriorityQueue.poll() } returns null - assertThat(queueService.poll(0)).isNull() - } - } - - @Nested - inner class PollHighOrStandardPriorityQueue { - - @Test - fun `returns item from high priority queue if any present`() { - assertThat(queueService.poll(1)).isSameAs(queueItemHighPrio) - verify { highPriorityQueue.poll() } - verify(exactly = 0) { standardPriorityQueue.poll() } - } - - @Test - fun `returns items from standard priority queue if high priority queue is empty`() { - every { highPriorityQueue.poll() } returns null - assertThat(queueService.poll(1)).isSameAs(queueItemStandardPrio) - verify { highPriorityQueue.poll() } - verify { standardPriorityQueue.poll() } - } - - @Test - fun `returns null if both queues empty`() { - every { highPriorityQueue.poll() } returns null - every { standardPriorityQueue.poll() } returns null - assertThat(queueService.poll(1)).isNull() - verify { highPriorityQueue.poll() } - verify { standardPriorityQueue.poll() } - } - } - - @Nested - inner class PollHighOrLowPriorityQueue { - - @Test - fun `returns item from high priority queue if any present`() { - assertThat(queueService.poll(2)).isSameAs(queueItemHighPrio) - verify { highPriorityQueue.poll() } - verify(exactly = 0) { lowPriorityQueue.poll() } - } - - @Test - fun `returns items from low priority queue if present and high priority queue is empty`() { - every { highPriorityQueue.poll() } returns null - every { standardPriorityQueue.poll() } returns null - assertThat(queueService.poll(2)).isSameAs(queueItemLowPrio) - verify { highPriorityQueue.poll() } - verify { lowPriorityQueue.poll() } - } - - @Test - fun `returns null if both queues are empty`() { - every { highPriorityQueue.poll() } returns null - every { standardPriorityQueue.poll() } returns null - every { lowPriorityQueue.poll() } returns null - assertThat(queueService.poll(2)).isNull() - verify { highPriorityQueue.poll() } - verify { lowPriorityQueue.poll() } - } - } - - @Nested - inner class Enqueue { - - @Test - fun `low priority job is enqueued on low priority queue`() { - val job = defaultEncoreJob(10) - every { lowPriorityQueue.offer(any(), any(), any()) } returns true - queueService.enqueue(job) - verify { lowPriorityQueue.offer(expectedQueueItem(job), 5, TimeUnit.SECONDS) } - verify(exactly = 0) { standardPriorityQueue.offer(any(), any(), any()) } - verify(exactly = 0) { highPriorityQueue.offer(any(), any(), any()) } - } - - @Test - fun `standard priority job is enqueued on standard priority queue`() { - val job = defaultEncoreJob(55) - every { standardPriorityQueue.offer(any(), any(), any()) } returns true - queueService.enqueue(job) - verify { standardPriorityQueue.offer(expectedQueueItem(job), 5, TimeUnit.SECONDS) } - verify(exactly = 0) { highPriorityQueue.offer(any(), any(), any()) } - verify(exactly = 0) { lowPriorityQueue.offer(any(), any(), any()) } - } - - @Test - fun `high priority job is enqueued on high priority queue`() { - val job = defaultEncoreJob(priority = 90) - every { highPriorityQueue.offer(any(), any(), any()) } returns true - queueService.enqueue(job) - verify { highPriorityQueue.offer(expectedQueueItem(job), 5, TimeUnit.SECONDS) } - verify(exactly = 0) { standardPriorityQueue.offer(any(), any(), any()) } - verify(exactly = 0) { lowPriorityQueue.offer(any(), any(), any()) } - } - - private fun expectedQueueItem(job: EncoreJob) = - QueueItem( - id = job.id.toString(), - priority = job.priority, - created = job.createdDate.toLocalDateTime() - ) - } -}