├── .editorconfig ├── .github └── workflows │ └── publishjar.yml ├── .gitignore ├── .gitlab-ci.yml ├── .idea ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── encodings.xml └── vcs.xml ├── .reuse └── dep5 ├── CODE_OF_CONDUCT.adoc ├── CONTRIBUTING.adoc ├── LICENSE ├── LICENSES ├── Apache-2.0.txt ├── CC-BY-SA-4.0.txt ├── CC0-1.0.txt └── EUPL-1.2.txt ├── README.md ├── buildSrc ├── build.gradle.kts └── src │ └── main │ └── kotlin │ ├── encore.kotlin-conventions.gradle.kts │ └── encore.spring-boot-app-conventions.gradle.kts ├── checks.gradle ├── encore-common ├── build.gradle.kts └── src │ ├── main │ ├── kotlin │ │ └── se │ │ │ └── svt │ │ │ └── oss │ │ │ └── encore │ │ │ ├── ClientConfiguration.kt │ │ │ ├── EncoreRuntimeHints.kt │ │ │ ├── MediaAnalyzerConfiguration.kt │ │ │ ├── RedisConfiguration.kt │ │ │ ├── cancellation │ │ │ ├── CancellationListener.kt │ │ │ └── SegmentProgressListener.kt │ │ │ ├── config │ │ │ ├── AudioMixPreset.kt │ │ │ ├── EncodingProperties.kt │ │ │ ├── EncoreProperties.kt │ │ │ └── ProfileProperties.kt │ │ │ ├── model │ │ │ ├── CancelEvent.kt │ │ │ ├── EncoreJob.kt │ │ │ ├── RedisEvent.kt │ │ │ ├── SegmentProgressEvent.kt │ │ │ ├── Status.kt │ │ │ ├── callback │ │ │ │ └── JobProgress.kt │ │ │ ├── input │ │ │ │ └── Input.kt │ │ │ ├── mediafile │ │ │ │ ├── AudioLayout.kt │ │ │ │ └── Extensions.kt │ │ │ ├── output │ │ │ │ └── Output.kt │ │ │ ├── profile │ │ │ │ ├── AudioEncode.kt │ │ │ │ ├── AudioEncoder.kt │ │ │ │ ├── ChannelId.kt │ │ │ │ ├── ChannelLayout.kt │ │ │ │ ├── GenericVideoEncode.kt │ │ │ │ ├── OutputProducer.kt │ │ │ │ ├── Profile.kt │ │ │ │ ├── SimpleAudioEncode.kt │ │ │ │ ├── ThumbnailEncode.kt │ │ │ │ ├── ThumbnailMapEncode.kt │ │ │ │ ├── VideoEncode.kt │ │ │ │ ├── X264Encode.kt │ │ │ │ ├── X265Encode.kt │ │ │ │ └── X26XEncode.kt │ │ │ └── queue │ │ │ │ └── QueueItem.kt │ │ │ ├── process │ │ │ ├── CommandBuilder.kt │ │ │ ├── SegmentUtil.kt │ │ │ └── TempDir.kt │ │ │ ├── repository │ │ │ ├── ChannelLayoutConverters.kt │ │ │ ├── EncoreJobRepository.kt │ │ │ └── OffsetDateTimeConverters.kt │ │ │ └── service │ │ │ ├── ApplicationShutdownException.kt │ │ │ ├── EncoreService.kt │ │ │ ├── FfmpegExecutor.kt │ │ │ ├── ShutdownHandler.kt │ │ │ ├── callback │ │ │ ├── CallbackClient.kt │ │ │ └── CallbackService.kt │ │ │ ├── localencode │ │ │ └── LocalEncodeService.kt │ │ │ ├── mediaanalyzer │ │ │ └── MediaAnalyzerService.kt │ │ │ ├── profile │ │ │ └── ProfileService.kt │ │ │ └── queue │ │ │ ├── QueueService.kt │ │ │ └── QueueUtil.kt │ └── resources │ │ └── migrate_queue_script.lua │ ├── test │ ├── kotlin │ │ └── se │ │ │ └── svt │ │ │ └── oss │ │ │ └── encore │ │ │ ├── EncoreClient.kt │ │ │ ├── EncoreIntegrationTest.kt │ │ │ ├── EncoreIntegrationTestBase.kt │ │ │ ├── EncoreRuntimeHintsTest.kt │ │ │ ├── LocalEncodeIntegrationTest.kt │ │ │ ├── TestConfig.kt │ │ │ ├── TestUtils.kt │ │ │ ├── model │ │ │ ├── input │ │ │ │ └── InputTest.kt │ │ │ ├── mediafile │ │ │ │ └── MediaFileExtensionsTest.kt │ │ │ └── profile │ │ │ │ ├── AudioEncodeTest.kt │ │ │ │ ├── GenericVideoEncodeTest.kt │ │ │ │ ├── ThumbnailEncodeTest.kt │ │ │ │ ├── ThumbnailMapEncodeTest.kt │ │ │ │ ├── VideoEncodeTest.kt │ │ │ │ ├── X264EncodeTest.kt │ │ │ │ └── X265EncodeTest.kt │ │ │ ├── process │ │ │ ├── CommandBuilderTest.kt │ │ │ └── SegmentUtilTest.kt │ │ │ ├── repository │ │ │ └── EncoreJobRepositoryTest.kt │ │ │ └── service │ │ │ ├── callback │ │ │ └── CallbackServiceTest.kt │ │ │ ├── profile │ │ │ └── ProfileServiceTest.kt │ │ │ └── queue │ │ │ ├── QueueServiceTest.kt │ │ │ └── QueueUtilTest.kt │ └── resources │ │ ├── application-test-local.yml │ │ ├── application-test.yml │ │ ├── input │ │ ├── multiple-audio-file.json │ │ ├── multiple-video-file.json │ │ ├── multiple_audio.mp4 │ │ ├── multiple_video.mp4 │ │ ├── portrait-video-file.json │ │ ├── rotate-to-portrait-video-file.json │ │ ├── test.mp4 │ │ ├── test_stereo.mp4 │ │ ├── testyuv.yuv │ │ ├── video-file-long.json │ │ └── video-file.json │ │ └── profile │ │ ├── archive.yml │ │ ├── audio-streams.yml │ │ ├── dpb_size_failed.yml │ │ ├── multiple_inputs.yml │ │ ├── profiles.yml │ │ ├── program-x265.yml │ │ ├── program.yml │ │ └── test_profile_invalid.yml │ └── testFixtures │ └── kotlin │ └── se │ └── svt │ └── oss │ └── encore │ └── RedisExtension.kt ├── encore-web ├── Dockerfile ├── Dockerfile-jar ├── build.gradle.kts └── src │ ├── main │ ├── kotlin │ │ └── se │ │ │ └── svt │ │ │ └── oss │ │ │ └── encore │ │ │ ├── EncoreApplication.kt │ │ │ ├── EncoreWebRuntimeHints.kt │ │ │ ├── OpenAPIConfiguration.kt │ │ │ ├── RepositoryConfiguration.kt │ │ │ ├── SchedulingConfiguration.kt │ │ │ ├── SecurityConfiguration.kt │ │ │ ├── controller │ │ │ └── EncoreController.kt │ │ │ ├── handlers │ │ │ └── EncoreJobHandler.kt │ │ │ └── poll │ │ │ └── JobPoller.kt │ └── resources │ │ ├── application.yml │ │ ├── asciilogo.txt │ │ └── logback-json.xml │ └── test │ ├── kotlin │ └── se │ │ └── svt │ │ └── oss │ │ └── encore │ │ ├── EncoreEndpointAccessIntegrationTest.kt │ │ ├── EncoreWebRuntimeHintsTest.kt │ │ ├── controller │ │ └── EncoreControllerTest.kt │ │ ├── handlers │ │ └── EncoreJobHandlerTest.kt │ │ └── poll │ │ └── JobPollerTest.kt │ └── resources │ ├── application-test.yml │ └── profile │ ├── archive.yml │ ├── audio-streams.yml │ ├── dpb_size_failed.yml │ ├── multiple_inputs.yml │ ├── profiles.yml │ ├── program-x265.yml │ ├── program.yml │ └── test_profile_invalid.yml ├── encore-worker ├── Dockerfile ├── build.gradle.kts └── src │ ├── main │ ├── kotlin │ │ └── se │ │ │ └── svt │ │ │ └── oss │ │ │ └── encore │ │ │ └── EncoreWorkerApplication.kt │ └── resources │ │ ├── application.yml │ │ ├── asciilogo.txt │ │ └── logback-json.xml │ └── test │ └── kotlin │ └── se │ └── svt │ └── oss │ └── encore │ └── EncoreWorkerApplicationTest.kt ├── encore_logo.png ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew └── settings.gradle.kts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | insert_final_newline = true 5 | 6 | [{*.kt,*.kts}] 7 | ktlint_code_style = intellij_idea 8 | ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL 9 | 10 | # Disable wildcard imports entirely 11 | ij_kotlin_name_count_to_use_star_import = 2147483647 12 | ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 13 | ij_kotlin_packages_to_use_import_on_demand = unset -------------------------------------------------------------------------------- /.github/workflows/publishjar.yml: -------------------------------------------------------------------------------- 1 | name: Create and publish Spring Boot jars and GraalVm native images 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build-artifacts: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v3 17 | 18 | - name: Set up GraalVm 19 | uses: graalvm/setup-graalvm@v1 20 | with: 21 | java-version: '21' 22 | distribution: 'graalvm-community' 23 | github-token: ${{ secrets.GITHUB_TOKEN }} 24 | 25 | - name: Grant execute permission for gradlew 26 | run: chmod +x gradlew 27 | 28 | - name: Build jars and native images with Gradle 29 | run: | 30 | export LC_ALL=C.UTF-8 31 | export LANG=C.UTF-8 32 | export NATIVE_IMAGE_CONFIG_FILE="$(pwd)/native-image.properties" 33 | echo 'NativeImageArgs = -J-Xmx14G -H:+AddAllCharsets -J-Dfile.encoding=UTF-8' > $NATIVE_IMAGE_CONFIG_FILE 34 | ./gradlew build nativeCompile -x test 35 | 36 | - name: Release Jars and native images 37 | uses: softprops/action-gh-release@v1 38 | if: startsWith(github.ref, 'refs/tags/') 39 | with: 40 | files: | 41 | encore-web/build/libs/encore-web*boot.jar 42 | encore-web/build/native/nativeCompile/encore-web 43 | encore-worker/build/libs/encore-worker*boot.jar 44 | encore-worker/build/native/nativeCompile/encore-worker 45 | 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | generated-java 2 | *.iml 3 | 4 | # Gradle 4.1 + IntelliJ build directory: 5 | out/ 6 | gradlew.bat 7 | 8 | **/build 9 | 10 | *.log 11 | *.swp 12 | .gradle 13 | schema 14 | 15 | .idea/* 16 | ## Videocore shared settings: 17 | !.idea/vcs.xml 18 | !.idea/codeStyles 19 | !.idea/codeStyles/Project.xml 20 | !.idea/codeStyles/codeStyleConfig.xml 21 | !.idea/encodings.xml 22 | !.idea/sqldialects.xml 23 | !.idea/compiler.xml 24 | 25 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | include: 2 | - project: 'videocore/gitlab-templates' 3 | file: 'encore.yml' 4 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 11 | 12 | 17 | 18 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.reuse/dep5: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | 3 | Files: *.sh 4 | Copyright: 2020 Sveriges Television AB 5 | License: CC0-1.0 6 | 7 | Files: Dockerfile 8 | Copyright: 2020 Sveriges Television AB 9 | License: CC0-1.0 10 | 11 | Files: .gitignore 12 | Copyright: 2020 Sveriges Television AB 13 | License: CC0-1.0 14 | 15 | Files: *.kts 16 | Copyright: 2020 Sveriges Television AB 17 | License: CC0-1.0 18 | 19 | Files: *.xml 20 | Copyright: 2020 Sveriges Television AB 21 | License: CC0-1.0 22 | 23 | Files: *.md 24 | Copyright: 2020 Sveriges Television AB 25 | License: CC0-1.0 26 | 27 | Files: *.adoc 28 | Copyright: 2020 Sveriges Television AB 29 | License: CC0-1.0 30 | 31 | Files: *.yml 32 | Copyright: 2020 Sveriges Television AB 33 | License: CC0-1.0 34 | 35 | Files: *.json 36 | Copyright: 2020 Sveriges Television AB 37 | License: CC0-1.0 38 | 39 | Files: *.txt 40 | Copyright: 2020 Sveriges Television AB 41 | License: CC0-1.0 42 | 43 | Files: *.gradle 44 | Copyright: 2020 Sveriges Television AB 45 | License: CC0-1.0 46 | 47 | Files: *.properties 48 | Copyright: 2020 Sveriges Television AB 49 | License: CC0-1.0 50 | 51 | Files: encore_logo.png 52 | Copyright: 2021 Sveriges Television AB 53 | License: CC-BY-SA-4.0 54 | 55 | Files: *.mp4 56 | Copyright: 2021 Sveriges Television AB 57 | License: CC-BY-SA-4.0 58 | 59 | # Third-party files. 60 | 61 | Files: gradlew* 62 | Copyright: 2007-2020 The original author or authors 63 | License: Apache-2.0 64 | 65 | Files: gradle/* 66 | Copyright: 2007-2020 The original author or authors 67 | License: Apache-2.0 68 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.adoc: -------------------------------------------------------------------------------- 1 | Greek fabulist Aesop, c. 620 – 564 BCE, nailed it pretty good. 2 | 3 | 'No act of kindness, no matter how small, is ever wasted'. 4 | 5 | In other words, be nice to each other. 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.adoc: -------------------------------------------------------------------------------- 1 | = Introduction 2 | 3 | We gratefully accept contributions via 4 | https://help.github.com/articles/about-pull-requests/[pull requests]. 5 | 6 | Use the issue tracker to suggest feature requests, report bugs, and ask questions. 7 | This is also a great way to connect with the developers of the project as well 8 | as others who are interested in this solution. 9 | 10 | == Changing the code-base 11 | 12 | Generally speaking, you should fork this repository, make changes in your 13 | own fork, and then submit a pull-request. This is often called the https://gist.github.com/Chaser324/ce0505fbed06b947d962[Fork-and-Pull model] 14 | 15 | * All contributions to this project will be released under the inbound=outbound norm, that is, 16 | they are submitted under the projects main license. 17 | * By submitting a pull request or filing a bug, issue, or 18 | feature request, you agree to comply with this waiver of copyright interest. 19 | Details can be found in the link:./LICENSE[LICENSE]. 20 | * All new code should have associated unit 21 | tests that validate implemented features and the presence or lack of defects. 22 | * Additionally, the code should follow any stylistic and architectural guidelines 23 | prescribed by the project. In the absence of such guidelines, mimic the styles 24 | and patterns in the existing code-base. 25 | 26 | === Signoff and optionally Sign each Commit 27 | 28 | As part of filing a pull request you agree to the DCO. 29 | https://developercertificate.org/[Developer Certificate of Origin] 30 | 31 | A DCO is a lightweight way for a contributor to confirm that they wrote or otherwise have the right 32 | to submit code or documentation to a project. 33 | 34 | To confirm that you agree to the DCO, you need to *sign off* your commits when sending us a pull request. Technically, this is done by supplying the `-s`/`--signoff` flag to git when committing: 35 | 36 | `$ git commit -s -m "add fix for the bug"` 37 | 38 | Optionally, you can also sign the commit with `-S` which also gives your commit a nice verified button on GitHub, 39 | but, it requires that you have a GPG keypair set up. 40 | For mor information, see https://docs.github.com/en/github/authenticating-to-github/signing-commits[Sign commit on GitHub with GPG key] 41 | 42 | 43 | `$ git commit -s -S -m "add fix for the bug"` 44 | 45 | For the difference in signoff and signing, see 46 | https://medium.com/@MarkEmeis/git-commit-signoff-vs-signing-9f37ee272b14/[Git signoff vs signing] 47 | 48 | == Git History 49 | 50 | In order to maintain a high software quality standard, we strongly prefer contributions to follow these rules: 51 | 52 | * We pay more attention to the quality of commit messages. In general, we share the view on how commit messages should be written with 53 | https://github.com/git/git/blob/master/Documentation/SubmittingPatches[the Git project itself]: 54 | 55 | * https://github.com/git/git/blob/e6932248fcb41fb94a0be484050881e03c7eb298/Documentation/SubmittingPatches#L43[Make separate commits for logically separate changes.] 56 | For example, pure formatting changes that do not affect software behaviour usually do not belong in the same commit as 57 | changes to program logic. 58 | 59 | * https://github.com/git/git/blob/e6932248fcb41fb94a0be484050881e03c7eb298/Documentation/SubmittingPatches#L101[Describe your changes well.] 60 | Do not just repeat in prose what is "obvious" from the code, but provide a rationale explaining _why_ you believe 61 | your change is necessary. 62 | * https://github.com/git/git/blob/e6932248fcb41fb94a0be484050881e03c7eb298/Documentation/SubmittingPatches#L133[Describe your changes in the imperative.] 63 | Instead of writing "Fixes an issue with encoding" prefer "Fix an encoding issue". Think about it like the commit 64 | only does something _if_ it is applied. This usually results in more concise commit messages. 65 | * https://github.com/git/git/blob/e6932248fcb41fb94a0be484050881e03c7eb298/Documentation/SubmittingPatches#L95[We are picky about whitespaces.] 66 | Trailing whitespace and duplicate blank lines are simply a superfluous annoyance, and most Git tools flag them red 67 | in the diff anyway. 68 | 69 | If you have ever wondered how a "perfect" commit message is supposed to look like, just look at basically any of 70 | https://github.com/git/git/commits?author=peff[Jeff King's commits] in the Git project. 71 | 72 | Thank you for reading and happy contributing! 73 | 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SVT Encore 2 | [![License](https://img.shields.io/badge/license-EUPL-brightgreen.svg)](https://eupl.eu/) 3 | [![REUSE status](https://api.reuse.software/badge/github.com/fsfe/reuse-tool)](https://api.reuse.software/info/github.com/fsfe/reuse-tool) 4 | 5 | 6 | 7 | 8 |   9 |   10 | 11 | SVT *Encore* is a scalable video transcoding tool, built on Open Source giants like [FFmpeg](https://www.ffmpeg.org/) and [Spring Boot](https://spring.io/projects/spring-boot). 12 | 13 | 14 | *Encore* was created to scale, and abstract the transcoding _power of FFmpeg_, and to offer a simple solution for Transcoding - Transcoding-as-a-Service. 15 | 16 | *Encore* is aimed at the advanced technical user that needs a scalable video transcoding tool - for example, as a part of their VOD (Video On Demand) transcoding pipeline. 17 | 18 | ## Features 19 | 20 | - Scalable - queuing and concurrency options 21 | - Flexible profile configuration 22 | - Possibility to extend FFmpeg functionality 23 | - Tested and tried in production 24 | 25 | _Encore_ is not 26 | 27 | - A live/stream transcoder 28 | - A Video packager (see <>) 29 | - An GUI application 30 | 31 | _Built with_ 32 | 33 | * Kotlin 34 | * Gradle 35 | * Spring Boot 36 | * FFmpeg 37 | * and many other great projects 38 | 39 | ## Documentation 40 | 41 | Comprehensive documentation for _Encore_ can (and should) be read: 42 | 43 | [Online](https://svt.github.io/encore-doc/) 44 | 45 | or downloaded from the: 46 | 47 | [GitHub Repository](https://github.com/svt/encore-doc) 48 | 49 | If you have a running instance, you can also view the 50 | 51 | **OpenAPI Endpoints**: 52 | 53 | ``` 54 | http(s)://yourinstance/swagger-ui.html 55 | 56 | as json 57 | 58 | http(s)://yourinstance/v3/api-docs/ 59 | 60 | or as yaml 61 | 62 | http(s)://yourinstance/v3/api-docs.yaml 63 | ``` 64 | 65 | ### Local development 66 | 67 | Please see the [online documentation](https://svt.github.io/encore-doc/#the-user-guide) 68 | 69 | ## License 70 | 71 | Copyright 2020 Sveriges Television AB. 72 | 73 | Encore is licensed under the 74 | 75 | [EUPL-1.2-or-later](LICENSE) license 76 | 77 | ## Primary maintainer 78 | 79 | SVT Videocore Team - (videocore svt se) -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | repositories { 6 | mavenCentral() 7 | gradlePluginPortal() 8 | } 9 | kotlin { 10 | jvmToolchain(21) 11 | } 12 | 13 | dependencies { 14 | implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25") 15 | implementation("org.jetbrains.kotlin:kotlin-allopen:1.9.25") 16 | implementation("org.springframework.boot:spring-boot-gradle-plugin:3.4.3") 17 | implementation("io.spring.gradle:dependency-management-plugin:1.1.5") 18 | implementation("org.jmailen.gradle:kotlinter-gradle:4.4.1") 19 | implementation("pl.allegro.tech.build:axion-release-plugin:1.18.8") 20 | implementation("org.graalvm.buildtools:native-gradle-plugin:0.10.5") 21 | implementation("com.github.fhermansson:assertj-gradle-plugin:1.1.5") 22 | implementation("com.github.ben-manes:gradle-versions-plugin:0.51.0") 23 | } 24 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/encore.kotlin-conventions.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask 2 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 3 | 4 | plugins { 5 | idea 6 | jacoco 7 | kotlin("jvm") 8 | kotlin("plugin.spring") 9 | id("pl.allegro.tech.build.axion-release") 10 | id("com.github.fhermansson.assertj-generator") 11 | id("org.jmailen.kotlinter") 12 | id("com.github.ben-manes.versions") 13 | id("io.spring.dependency-management") 14 | } 15 | 16 | group = "se.svt.oss" 17 | project.version = scmVersion.version 18 | 19 | tasks.withType { 20 | useJUnitPlatform() 21 | } 22 | apply { from("../checks.gradle") } 23 | repositories { 24 | mavenCentral() 25 | } 26 | 27 | kotlin { 28 | jvmToolchain(21) 29 | compilerOptions { 30 | freeCompilerArgs.addAll("-Xjsr305=strict") 31 | } 32 | } 33 | 34 | fun isNonStable(version: String): Boolean { 35 | val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.uppercase().contains(it) } 36 | val regex = "^[0-9,.v-]+(-r)?$".toRegex() 37 | val isStable = stableKeyword || regex.matches(version) 38 | return isStable.not() 39 | } 40 | 41 | tasks.withType { 42 | rejectVersionIf { 43 | isNonStable(candidate.version) 44 | } 45 | } 46 | 47 | tasks.lintKotlinTest { 48 | source = (source - fileTree("src/test/generated-java")).asFileTree 49 | } 50 | tasks.formatKotlinTest { 51 | source = (source - fileTree("src/test/generated-java")).asFileTree 52 | } 53 | 54 | assertjGenerator { 55 | classOrPackageNames = arrayOf( 56 | "se.svt.oss.encore.model", 57 | "se.svt.oss.mediaanalyzer.file" 58 | ) 59 | entryPointPackage = "se.svt.oss.encore" 60 | useJakartaAnnotations = true 61 | } 62 | tasks.test { 63 | jvmArgs("-XX:+EnableDynamicAgentLoading") 64 | } 65 | dependencyManagement { 66 | imports { 67 | mavenBom("org.springframework.boot:spring-boot-dependencies:3.4.3") 68 | mavenBom("org.springframework.cloud:spring-cloud-dependencies:2024.0.0") 69 | } 70 | } 71 | dependencies { 72 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin") 73 | implementation("io.github.oshai:kotlin-logging-jvm:7.0.5") 74 | implementation("org.springframework.boot:spring-boot-starter-data-redis") 75 | testImplementation("org.springframework.boot:spring-boot-starter-test") { 76 | exclude(group = "org.mockito") 77 | } 78 | testImplementation("org.assertj:assertj-core") 79 | testImplementation("io.mockk:mockk:1.13.17") 80 | } 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/encore.spring-boot-app-conventions.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.springframework.boot.gradle.tasks.bundling.BootJar 2 | plugins { 3 | kotlin("jvm") 4 | kotlin("plugin.spring") 5 | id("org.springframework.boot") 6 | id("org.graalvm.buildtools.native") 7 | id("io.spring.dependency-management") 8 | } 9 | 10 | graalvmNative { 11 | binaries.all { 12 | buildArgs.add("--strict-image-heap") 13 | } 14 | } 15 | tasks.named("bootJar") { 16 | archiveClassifier.set("boot") 17 | } 18 | dependencyManagement { 19 | imports { 20 | mavenBom("org.springframework.boot:spring-boot-dependencies:3.4.3") 21 | mavenBom("org.springframework.cloud:spring-cloud-dependencies:2024.0.0") 22 | } 23 | } 24 | dependencies { 25 | implementation("org.springframework.boot:spring-boot-starter") 26 | implementation("org.springframework.cloud:spring-cloud-starter-config") 27 | implementation("org.springframework.boot:spring-boot-starter-logging") 28 | implementation("net.logstash.logback:logstash-logback-encoder:8.0") 29 | } 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /checks.gradle: -------------------------------------------------------------------------------- 1 | jacocoTestCoverageVerification { 2 | violationRules { 3 | rule { 4 | element = 'METHOD' 5 | includes = ['se.svt.oss.encore.*'] 6 | excludes = [ 7 | '*.invoke()', 8 | '*.EncoreProperties*.get*()', 9 | '*.DefaultConstructorMarker*', 10 | '*ApplicationKt.main*', 11 | '*.static {...}', 12 | '*.model.*.get*', 13 | '*.service.localencode.LocalEncodeService.moveFile*', 14 | '*QueueService.getQueue*', 15 | '*QueueService.migrateQueues()', 16 | '*.ShutdownHandler.*', 17 | '*FfmpegExecutor.runFfmpeg$lambda$7(java.lang.Process)', 18 | ] 19 | limit { 20 | counter = 'LINE' 21 | minimum = 0.7 22 | } 23 | } 24 | 25 | failOnViolation = true 26 | } 27 | } 28 | 29 | jacocoTestCoverageVerification.dependsOn jacocoTestReport 30 | check.dependsOn jacocoTestCoverageVerification 31 | -------------------------------------------------------------------------------- /encore-common/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("encore.kotlin-conventions") 3 | `java-test-fixtures` 4 | } 5 | 6 | dependencies { 7 | 8 | api("se.svt.oss:media-analyzer:2.0.7") 9 | implementation(kotlin("reflect")) 10 | 11 | compileOnly("org.springdoc:springdoc-openapi-starter-webmvc-api:2.6.0") 12 | compileOnly("org.springframework.data:spring-data-rest-core") 13 | compileOnly("org.springframework.boot:spring-boot-starter-validation") 14 | implementation("org.springframework.boot:spring-boot-starter-webflux") 15 | implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml") 16 | 17 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core") 18 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-slf4j") 19 | 20 | testImplementation(project(":encore-web")) 21 | testImplementation("org.springframework.security:spring-security-test") 22 | testImplementation("org.awaitility:awaitility") 23 | testImplementation("org.wiremock:wiremock-standalone:3.12.1") 24 | testImplementation("org.springframework.boot:spring-boot-starter-data-rest") 25 | testFixturesImplementation(platform("org.springframework.boot:spring-boot-dependencies:3.4.3")) 26 | testFixturesImplementation("com.redis:testcontainers-redis:2.2.4") 27 | testFixturesImplementation("io.github.microutils:kotlin-logging:3.0.5") 28 | testFixturesImplementation("org.junit.jupiter:junit-jupiter-api") 29 | testFixturesRuntimeOnly("org.junit.platform:junit-platform-launcher") 30 | } 31 | 32 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/ClientConfiguration.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore 6 | 7 | import org.springframework.beans.factory.annotation.Value 8 | import org.springframework.context.annotation.Bean 9 | import org.springframework.context.annotation.Configuration 10 | import org.springframework.http.HttpHeaders 11 | import org.springframework.web.reactive.function.client.WebClient 12 | import org.springframework.web.reactive.function.client.support.WebClientAdapter 13 | import org.springframework.web.service.invoker.HttpServiceProxyFactory 14 | import se.svt.oss.encore.service.callback.CallbackClient 15 | 16 | @Configuration(proxyBeanMethods = false) 17 | class ClientConfiguration { 18 | 19 | @Bean 20 | fun callbackClient(@Value("\${service.name:encore}") userAgent: String): CallbackClient { 21 | val webClient = WebClient.builder() 22 | .defaultHeader(HttpHeaders.USER_AGENT, userAgent) 23 | .build() 24 | return HttpServiceProxyFactory.builderFor(WebClientAdapter.create(webClient)) 25 | .build() 26 | .createClient(CallbackClient::class.java) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/EncoreRuntimeHints.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore 6 | 7 | import org.springframework.aot.hint.MemberCategory 8 | import org.springframework.aot.hint.RuntimeHints 9 | import org.springframework.aot.hint.RuntimeHintsRegistrar 10 | import se.svt.oss.encore.config.AudioMixPreset 11 | import se.svt.oss.encore.config.EncodingProperties 12 | import se.svt.oss.encore.config.EncoreProperties 13 | 14 | class EncoreRuntimeHints : RuntimeHintsRegistrar { 15 | override fun registerHints(hints: RuntimeHints, classLoader: ClassLoader?) { 16 | hints.resources().registerPattern("migrate_queue_script.lua") 17 | hints.reflection() 18 | .registerType( 19 | EncoreProperties::class.java, 20 | MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, 21 | ) 22 | .registerType( 23 | EncodingProperties::class.java, 24 | MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, 25 | ) 26 | .registerType( 27 | AudioMixPreset::class.java, 28 | MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/MediaAnalyzerConfiguration.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore 6 | 7 | import com.fasterxml.jackson.databind.ObjectMapper 8 | import org.springframework.context.annotation.Bean 9 | import org.springframework.context.annotation.Configuration 10 | import se.svt.oss.mediaanalyzer.MediaAnalyzer 11 | 12 | @Configuration(proxyBeanMethods = false) 13 | class MediaAnalyzerConfiguration { 14 | 15 | @Bean 16 | fun mediaAnalyzer(objectMapper: ObjectMapper) = MediaAnalyzer(objectMapper) 17 | } 18 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/RedisConfiguration.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore 6 | 7 | import com.fasterxml.jackson.databind.ObjectMapper 8 | import org.springframework.aot.hint.annotation.RegisterReflectionForBinding 9 | import org.springframework.context.annotation.Bean 10 | import org.springframework.context.annotation.Configuration 11 | import org.springframework.core.io.ClassPathResource 12 | import org.springframework.core.task.SimpleAsyncTaskExecutor 13 | import org.springframework.data.redis.connection.RedisConnectionFactory 14 | import org.springframework.data.redis.core.RedisKeyValueAdapter 15 | import org.springframework.data.redis.core.RedisTemplate 16 | import org.springframework.data.redis.core.convert.RedisCustomConversions 17 | import org.springframework.data.redis.core.script.RedisScript 18 | import org.springframework.data.redis.listener.PatternTopic 19 | import org.springframework.data.redis.listener.RedisMessageListenerContainer 20 | import org.springframework.data.redis.listener.adapter.MessageListenerAdapter 21 | import org.springframework.data.redis.repository.configuration.EnableRedisRepositories 22 | import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer 23 | import org.springframework.data.redis.serializer.RedisSerializer 24 | import se.svt.oss.encore.model.CancelEvent 25 | import se.svt.oss.encore.model.EncoreJob 26 | import se.svt.oss.encore.model.RedisEvent 27 | import se.svt.oss.encore.model.SegmentProgressEvent 28 | import se.svt.oss.encore.model.input.AudioInput 29 | import se.svt.oss.encore.model.input.AudioVideoInput 30 | import se.svt.oss.encore.model.input.VideoInput 31 | import se.svt.oss.encore.model.queue.QueueItem 32 | import se.svt.oss.encore.repository.ByteArrayToChannelLayoutConverter 33 | import se.svt.oss.encore.repository.ByteArrayToOffsetDateTimeConverter 34 | import se.svt.oss.encore.repository.ChannelLayoutToByteArrayConverter 35 | import se.svt.oss.encore.repository.OffsetDateTimeToByteArrayConverter 36 | import se.svt.oss.mediaanalyzer.file.AudioFile 37 | import se.svt.oss.mediaanalyzer.file.ImageFile 38 | import se.svt.oss.mediaanalyzer.file.SubtitleFile 39 | import se.svt.oss.mediaanalyzer.file.VideoFile 40 | 41 | @Configuration(proxyBeanMethods = false) 42 | @RegisterReflectionForBinding( 43 | classes = [ 44 | EncoreJob::class, 45 | AudioVideoInput::class, 46 | VideoInput::class, 47 | AudioInput::class, 48 | ImageFile::class, 49 | VideoFile::class, 50 | AudioFile::class, 51 | SubtitleFile::class, 52 | CancelEvent::class, 53 | SegmentProgressEvent::class, 54 | QueueItem::class, 55 | ], 56 | classNames = ["kotlin.collections.EmptyMap", "kotlin.collections.EmptyList", "kotlin.collections.EmptySet", "java.lang.Enum.EnumDesc"], 57 | ) 58 | @EnableRedisRepositories( 59 | enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP, 60 | keyspaceNotificationsConfigParameter = "#{\${redis.keyspace.disable-config-notifications:false} ? '' : 'Ex'}", 61 | ) 62 | class RedisConfiguration { 63 | 64 | @Bean 65 | fun redisCustomConversions() = RedisCustomConversions( 66 | listOf( 67 | OffsetDateTimeToByteArrayConverter(), 68 | ByteArrayToOffsetDateTimeConverter(), 69 | ChannelLayoutToByteArrayConverter(), 70 | ByteArrayToChannelLayoutConverter(), 71 | ), 72 | ) 73 | 74 | @Bean 75 | fun redisMessageListenerContainer(connectionFactory: RedisConnectionFactory): RedisMessageListenerContainer { 76 | val container = RedisMessageListenerContainer() 77 | container.setConnectionFactory(connectionFactory) 78 | val taskExecutor = SimpleAsyncTaskExecutor("redisMessageListenerContainer-") 79 | .apply { setVirtualThreads(true) } 80 | container.setTaskExecutor(taskExecutor) 81 | // https://github.com/spring-projects/spring-data-redis/issues/2425 82 | container.addMessageListener(MessageListenerAdapter(), PatternTopic.of("Dummy")) 83 | return container 84 | } 85 | 86 | @Bean 87 | fun redisEventTemplate( 88 | connectionFactory: RedisConnectionFactory, 89 | objectMapper: ObjectMapper, 90 | ): RedisTemplate { 91 | val template = RedisTemplate() 92 | template.connectionFactory = connectionFactory 93 | template.keySerializer = RedisSerializer.string() 94 | template.valueSerializer = Jackson2JsonRedisSerializer(objectMapper, RedisEvent::class.java) 95 | return template 96 | } 97 | 98 | @Bean 99 | fun redisQueueTemplate( 100 | connectionFactory: RedisConnectionFactory, 101 | objectMapper: ObjectMapper, 102 | ): RedisTemplate { 103 | val template = RedisTemplate() 104 | template.connectionFactory = connectionFactory 105 | template.keySerializer = RedisSerializer.string() 106 | template.valueSerializer = Jackson2JsonRedisSerializer(objectMapper, QueueItem::class.java) 107 | return template 108 | } 109 | 110 | @Bean 111 | fun redisQueueMigrationScript() = RedisScript(ClassPathResource("migrate_queue_script.lua")) 112 | } 113 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/cancellation/CancellationListener.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.cancellation 6 | 7 | import com.fasterxml.jackson.databind.ObjectMapper 8 | import com.fasterxml.jackson.module.kotlin.readValue 9 | import io.github.oshai.kotlinlogging.KotlinLogging 10 | import kotlinx.coroutines.Job 11 | import org.springframework.data.redis.connection.Message 12 | import org.springframework.data.redis.connection.MessageListener 13 | import se.svt.oss.encore.model.CancelEvent 14 | import java.util.UUID 15 | 16 | private val log = KotlinLogging.logger {} 17 | 18 | class CancellationListener( 19 | private val objectMapper: ObjectMapper, 20 | private val encoreJobId: UUID, 21 | private val coroutineJob: Job, 22 | ) : MessageListener { 23 | 24 | override fun onMessage(message: Message, pattern: ByteArray?) { 25 | val jobId = objectMapper.readValue(message.body).jobId 26 | if (jobId == encoreJobId) { 27 | log.info { "Received cancel event for job $jobId" } 28 | coroutineJob.cancel() 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/cancellation/SegmentProgressListener.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.cancellation 6 | 7 | import com.fasterxml.jackson.databind.ObjectMapper 8 | import com.fasterxml.jackson.module.kotlin.readValue 9 | import kotlinx.coroutines.Job 10 | import kotlinx.coroutines.cancel 11 | import kotlinx.coroutines.channels.SendChannel 12 | import kotlinx.coroutines.channels.trySendBlocking 13 | import org.springframework.data.redis.connection.Message 14 | import org.springframework.data.redis.connection.MessageListener 15 | import se.svt.oss.encore.model.SegmentProgressEvent 16 | import java.util.UUID 17 | import java.util.concurrent.ConcurrentHashMap 18 | import java.util.concurrent.atomic.AtomicBoolean 19 | 20 | class SegmentProgressListener( 21 | private val objectMapper: ObjectMapper, 22 | private val encoreJobId: UUID, 23 | private val coroutineJob: Job, 24 | private val totalSegments: Int, 25 | private val progressChannel: SendChannel, 26 | ) : MessageListener { 27 | 28 | private val completedSegments: MutableSet = ConcurrentHashMap.newKeySet() 29 | val anyFailed = AtomicBoolean(false) 30 | 31 | fun completed() = anyFailed.get() || completedSegments.size == totalSegments 32 | 33 | fun completionCount() = completedSegments.size 34 | 35 | override fun onMessage(message: Message, pattern: ByteArray?) { 36 | val msg = objectMapper.readValue(message.body) 37 | if (msg.jobId == encoreJobId) { 38 | if (!msg.success) { 39 | progressChannel.close() 40 | anyFailed.set(true) 41 | coroutineJob.cancel("Segment ${msg.segment} failed!") 42 | } else if (completedSegments.add(msg.segment)) { 43 | val percent = (completedSegments.size * 100.0 / totalSegments).toInt() 44 | progressChannel.trySendBlocking(percent) 45 | if (percent == 100) { 46 | progressChannel.close() 47 | } 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/config/AudioMixPreset.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | package se.svt.oss.encore.config 5 | 6 | import org.springframework.boot.context.properties.NestedConfigurationProperty 7 | import se.svt.oss.encore.model.profile.ChannelLayout 8 | 9 | data class AudioMixPreset( 10 | val fallbackToAuto: Boolean = true, 11 | @NestedConfigurationProperty 12 | val defaultPan: Map = emptyMap(), 13 | @NestedConfigurationProperty 14 | val panMapping: Map> = emptyMap(), 15 | ) 16 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/config/EncodingProperties.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.config 6 | 7 | import org.springframework.boot.context.properties.NestedConfigurationProperty 8 | import se.svt.oss.encore.model.profile.ChannelLayout 9 | 10 | data class EncodingProperties( 11 | @NestedConfigurationProperty 12 | val audioMixPresets: Map = mapOf("default" to AudioMixPreset()), 13 | @NestedConfigurationProperty 14 | val defaultChannelLayouts: Map = emptyMap(), 15 | val flipWidthHeightIfPortrait: Boolean = true, 16 | val exitOnError: Boolean = true, 17 | val globalParams: LinkedHashMap = linkedMapOf(), 18 | ) 19 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/config/EncoreProperties.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.config 6 | 7 | import org.springframework.boot.context.properties.ConfigurationProperties 8 | import org.springframework.boot.context.properties.NestedConfigurationProperty 9 | import java.io.File 10 | import java.time.Duration 11 | 12 | @ConfigurationProperties("encore-settings") 13 | data class EncoreProperties( 14 | /** 15 | * transcode to local tmp dir before copying to output folder 16 | */ 17 | val localTemporaryEncode: Boolean = false, 18 | /** 19 | * number of work queues and threads 20 | */ 21 | val concurrency: Int = 2, 22 | /** 23 | * time to wait after application start before polling queue 24 | */ 25 | val pollInitialDelay: Duration = Duration.ofSeconds(10), 26 | /** 27 | * time to wait between polls 28 | */ 29 | val pollDelay: Duration = Duration.ofSeconds(5), 30 | /** 31 | * poll only the specified queue 32 | */ 33 | val pollQueue: Int? = null, 34 | /** 35 | * disable polling. could be set on encore-web if all transcoding is to be done by encore-workers 36 | */ 37 | val pollDisabled: Boolean = false, 38 | /** 39 | * should queues with higher prio be poller before the queue assigned to thread or worker 40 | */ 41 | val pollHigherPrio: Boolean = true, 42 | /** 43 | * if true, encore-worker will poll the queue until empty before shutting down, otherwise just poll once 44 | */ 45 | val workerDrainQueue: Boolean = false, 46 | val redisKeyPrefix: String = "encore", 47 | /** 48 | * optional web security settings 49 | */ 50 | val security: Security = Security(), 51 | /** 52 | * open api contact information 53 | */ 54 | val openApi: OpenApi = OpenApi(), 55 | /** 56 | * path to directory shared by encore instances. required for encoding in segments 57 | */ 58 | val sharedWorkDir: File? = null, 59 | /** 60 | * timeout for segmented encode before failing 61 | */ 62 | val segmentedEncodeTimeout: Duration = Duration.ofMinutes(120), 63 | /*** 64 | * enable migration of queues from redis LIST to ZSET 65 | */ 66 | val queueMigrationScriptEnabled: Boolean = true, 67 | @NestedConfigurationProperty 68 | val encoding: EncodingProperties = EncodingProperties(), 69 | ) { 70 | data class Security( 71 | val enabled: Boolean = false, 72 | val userPassword: String = "", 73 | val adminPassword: String = "", 74 | ) 75 | 76 | data class OpenApi( 77 | val title: String = "Encore OpenAPI", 78 | val description: String = "Endpoints for Encore", 79 | val contactName: String = "", 80 | val contactUrl: String = "", 81 | val contactEmail: String = "", 82 | ) 83 | } 84 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/config/ProfileProperties.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.config 6 | 7 | import org.springframework.boot.context.properties.ConfigurationProperties 8 | import org.springframework.core.io.Resource 9 | 10 | @ConfigurationProperties("profile") 11 | data class ProfileProperties( 12 | val location: Resource, 13 | val spelExpressionPrefix: String = "#{", 14 | val spelExpressionSuffix: String = "}", 15 | ) 16 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/CancelEvent.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model 6 | 7 | import java.util.UUID 8 | 9 | data class CancelEvent(val jobId: UUID) : RedisEvent 10 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/RedisEvent.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model 6 | 7 | interface RedisEvent 8 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/SegmentProgressEvent.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model 6 | 7 | import java.util.UUID 8 | 9 | data class SegmentProgressEvent( 10 | val jobId: UUID, 11 | val segment: Int, 12 | val success: Boolean, 13 | ) : RedisEvent 14 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/Status.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model 6 | 7 | enum class Status(val isCompleted: Boolean) { 8 | NEW(false), 9 | QUEUED(false), 10 | IN_PROGRESS(false), 11 | SUCCESSFUL(true), 12 | FAILED(true), 13 | CANCELLED(true), 14 | ; 15 | 16 | val isCancelled: Boolean 17 | get() = this == CANCELLED 18 | } 19 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/callback/JobProgress.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.callback 6 | 7 | import se.svt.oss.encore.model.Status 8 | import java.util.UUID 9 | 10 | data class JobProgress( 11 | val jobId: UUID, 12 | val externalId: String?, 13 | val progress: Int, 14 | val status: Status, 15 | ) 16 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/mediafile/AudioLayout.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.mediafile 6 | 7 | enum class AudioLayout { 8 | NONE, 9 | INVALID, 10 | MONO_STREAMS, 11 | MULTI_TRACK, 12 | } 13 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/mediafile/Extensions.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.mediafile 6 | 7 | import io.github.oshai.kotlinlogging.KotlinLogging 8 | import se.svt.oss.encore.model.input.AudioIn 9 | import se.svt.oss.encore.model.profile.ChannelLayout 10 | import se.svt.oss.mediaanalyzer.file.AudioFile 11 | import se.svt.oss.mediaanalyzer.file.MediaContainer 12 | import se.svt.oss.mediaanalyzer.file.VideoFile 13 | 14 | private val log = KotlinLogging.logger { } 15 | 16 | fun MediaContainer.audioLayout() = when { 17 | audioStreams.isEmpty() -> AudioLayout.NONE 18 | audioStreams.size == 1 -> AudioLayout.MULTI_TRACK 19 | audioStreams.all { it.channels == 1 } -> AudioLayout.MONO_STREAMS 20 | audioStreams.first().channels > 1 -> AudioLayout.MULTI_TRACK 21 | else -> AudioLayout.INVALID 22 | } 23 | 24 | fun MediaContainer.channelCount() = if (audioLayout() == AudioLayout.MULTI_TRACK) { 25 | audioStreams.first().channels 26 | } else { 27 | audioStreams.size 28 | } 29 | 30 | fun AudioIn.channelLayout(defaultChannelLayouts: Map): ChannelLayout = when (analyzedAudio.audioLayout()) { 31 | AudioLayout.NONE, AudioLayout.INVALID -> null 32 | AudioLayout.MONO_STREAMS -> if (analyzedAudio.channelCount() == channelLayout?.channels?.size) { 33 | channelLayout 34 | } else { 35 | defaultChannelLayouts[analyzedAudio.channelCount()] 36 | ?: ChannelLayout.defaultChannelLayout(analyzedAudio.channelCount()) 37 | } 38 | 39 | AudioLayout.MULTI_TRACK -> analyzedAudio.audioStreams.first().channelLayout 40 | ?.let { ChannelLayout.getByNameOrNull(it) } 41 | ?: defaultChannelLayouts[analyzedAudio.channelCount()] 42 | ?: ChannelLayout.defaultChannelLayout(analyzedAudio.channelCount()) 43 | } ?: throw RuntimeException("Could not determine channel layout for audio input '$audioLabel'!") 44 | 45 | fun VideoFile.trimAudio(keep: Int?): VideoFile = if (keep != null && keep < audioStreams.size) { 46 | log.debug { "Using first $keep audio streams of ${audioStreams.size} of ${this.file}" } 47 | copy(audioStreams = audioStreams.take(keep)) 48 | } else { 49 | this 50 | } 51 | 52 | fun AudioFile.trimAudio(keep: Int?): AudioFile = if (keep != null && keep < audioStreams.size) { 53 | log.debug { "Using first $keep audio streams of ${audioStreams.size} of ${this.file}" } 54 | copy(audioStreams = audioStreams.take(keep)) 55 | } else { 56 | this 57 | } 58 | 59 | fun VideoFile.selectVideoStream(index: Int?): VideoFile = index?.let { 60 | copy(videoStreams = listOf(videoStreams[it])) 61 | } ?: this 62 | 63 | fun VideoFile.selectAudioStream(index: Int?): VideoFile = index?.let { 64 | copy(audioStreams = listOf(audioStreams[it])) 65 | } ?: this 66 | 67 | fun AudioFile.selectAudioStream(index: Int?): AudioFile = index?.let { 68 | copy(audioStreams = listOf(audioStreams[it])) 69 | } ?: this 70 | 71 | fun Map.toParams(): List = 72 | flatMap { entry -> 73 | listOfNotNull("-${entry.key}", entry.value?.let { "$it" }) 74 | .filterNot { it.isEmpty() } 75 | } 76 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/output/Output.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.output 6 | 7 | import java.io.File 8 | 9 | data class Output( 10 | val video: VideoStreamEncode?, 11 | val audioStreams: List = emptyList(), 12 | val output: String, 13 | val format: String = "mp4", 14 | val postProcessor: PostProcessor = PostProcessor { outputFolder -> listOf(outputFolder.resolve(output)) }, 15 | val id: String, 16 | val isImage: Boolean = false, 17 | val decodeOutputStream: String? = null, 18 | ) 19 | 20 | fun interface PostProcessor { 21 | fun process(outputFolder: File): List 22 | } 23 | 24 | interface StreamEncode { 25 | val params: List 26 | val filter: String? 27 | val twoPass: Boolean 28 | val inputLabels: List 29 | } 30 | 31 | data class VideoStreamEncode( 32 | override val params: List, 33 | val firstPassParams: List = emptyList(), 34 | override val filter: String? = null, 35 | override val twoPass: Boolean = false, 36 | override val inputLabels: List, 37 | ) : StreamEncode 38 | 39 | data class AudioStreamEncode( 40 | override val params: List, 41 | override val filter: String? = null, 42 | override val inputLabels: List, 43 | val preserveLayout: Boolean = false, 44 | ) : StreamEncode { 45 | override val twoPass: Boolean 46 | get() = false 47 | } 48 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/AudioEncoder.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.profile 6 | 7 | import io.github.oshai.kotlinlogging.KotlinLogging 8 | import se.svt.oss.encore.model.output.Output 9 | 10 | private val log = KotlinLogging.logger { } 11 | 12 | abstract class AudioEncoder : OutputProducer { 13 | 14 | abstract val optional: Boolean 15 | 16 | fun logOrThrow(message: String): Output? { 17 | if (optional) { 18 | log.info { message } 19 | return null 20 | } else { 21 | throw RuntimeException(message) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ChannelId.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.profile 6 | 7 | enum class ChannelId { 8 | FL, 9 | FR, 10 | FC, 11 | LFE, 12 | BL, 13 | BR, 14 | FLC, 15 | FRC, 16 | BC, 17 | SL, 18 | SR, 19 | TC, 20 | TFL, 21 | TFC, 22 | TFR, 23 | TBL, 24 | TBC, 25 | TBR, 26 | DL, 27 | DR, 28 | WL, 29 | WR, 30 | SDL, 31 | SDR, 32 | LFE2, 33 | TSL, 34 | TSR, 35 | BFC, 36 | BFL, 37 | BFR, 38 | } 39 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ChannelLayout.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.profile 6 | 7 | import com.fasterxml.jackson.annotation.JsonValue 8 | import se.svt.oss.encore.model.profile.ChannelId.BC 9 | import se.svt.oss.encore.model.profile.ChannelId.BFC 10 | import se.svt.oss.encore.model.profile.ChannelId.BFL 11 | import se.svt.oss.encore.model.profile.ChannelId.BFR 12 | import se.svt.oss.encore.model.profile.ChannelId.BL 13 | import se.svt.oss.encore.model.profile.ChannelId.BR 14 | import se.svt.oss.encore.model.profile.ChannelId.DL 15 | import se.svt.oss.encore.model.profile.ChannelId.DR 16 | import se.svt.oss.encore.model.profile.ChannelId.FC 17 | import se.svt.oss.encore.model.profile.ChannelId.FL 18 | import se.svt.oss.encore.model.profile.ChannelId.FLC 19 | import se.svt.oss.encore.model.profile.ChannelId.FR 20 | import se.svt.oss.encore.model.profile.ChannelId.FRC 21 | import se.svt.oss.encore.model.profile.ChannelId.LFE 22 | import se.svt.oss.encore.model.profile.ChannelId.LFE2 23 | import se.svt.oss.encore.model.profile.ChannelId.SL 24 | import se.svt.oss.encore.model.profile.ChannelId.SR 25 | import se.svt.oss.encore.model.profile.ChannelId.TBC 26 | import se.svt.oss.encore.model.profile.ChannelId.TBL 27 | import se.svt.oss.encore.model.profile.ChannelId.TBR 28 | import se.svt.oss.encore.model.profile.ChannelId.TC 29 | import se.svt.oss.encore.model.profile.ChannelId.TFC 30 | import se.svt.oss.encore.model.profile.ChannelId.TFL 31 | import se.svt.oss.encore.model.profile.ChannelId.TFR 32 | import se.svt.oss.encore.model.profile.ChannelId.TSL 33 | import se.svt.oss.encore.model.profile.ChannelId.TSR 34 | import se.svt.oss.encore.model.profile.ChannelId.WL 35 | import se.svt.oss.encore.model.profile.ChannelId.WR 36 | 37 | enum class ChannelLayout(@JsonValue val layoutName: String, val channels: List) { 38 | CH_LAYOUT_MONO("mono", listOf(FC)), 39 | CH_LAYOUT_STEREO("stereo", listOf(FL, FR)), 40 | CH_LAYOUT_2POINT1("2.1", listOf(FL, FR, LFE)), 41 | CH_LAYOUT_3POINT0("3.0", listOf(FL, FR, FC)), 42 | CH_LAYOUT_3POINT0_BACK("3.0(back)", listOf(FL, FR, BC)), 43 | CH_LAYOUT_4POINT0("4.0", listOf(FL, FR, FC, BC)), 44 | CH_LAYOUT_QUAD("quad", listOf(FL, FR, BL, BR)), 45 | CH_LAYOUT_QUAD_SIDE("quad(side)", listOf(FL, FR, SL, SR)), 46 | CH_LAYOUT_3POINT1("3.1", listOf(FL, FR, FC, LFE)), 47 | CH_LAYOUT_5POINT0("5.0", listOf(FL, FR, FC, BL, BR)), 48 | CH_LAYOUT_5POINT0_SIDE("5.0(side)", listOf(FL, FR, FC, SL, SR)), 49 | CH_LAYOUT_4POINT1("4.1", listOf(FL, FR, FC, LFE, BC)), 50 | CH_LAYOUT_5POINT1("5.1", listOf(FL, FR, FC, LFE, BL, BR)), 51 | CH_LAYOUT_5POINT1_SIDE("5.1(side)", listOf(FL, FR, FC, LFE, SL, SR)), 52 | CH_LAYOUT_6POINT0("6.0", listOf(FL, FR, FC, BC, SL, SR)), 53 | CH_LAYOUT_6POINT0_FRONT("6.0(front)", listOf(FL, FR, FLC, FRC, SL, SR)), 54 | CH_LAYOUT_3POINT1POINT2("3.1.2", listOf(FL, FR, FC, LFE, TFL, TFR)), 55 | CH_LAYOUT_HEXAGONAL("hexagonal", listOf(FL, FR, FC, BL, BR, BC)), 56 | CH_LAYOUT_6POINT1("6.1", listOf(FL, FR, FC, LFE, BC, SL, SR)), 57 | CH_LAYOUT_6POINT1_BACK("6.1(back)", listOf(FL, FR, FC, LFE, BL, BR, BC)), 58 | CH_LAYOUT_6POINT1_FRONT("6.1(front)", listOf(FL, FR, LFE, FLC, FRC, SL, SR)), 59 | CH_LAYOUT_7POINT0("7.0", listOf(FL, FR, FC, BL, BR, SL, SR)), 60 | CH_LAYOUT_7POINT0_FRONT("7.0(front)", listOf(FL, FR, FC, FLC, FRC, SL, SR)), 61 | CH_LAYOUT_7POINT1("7.1", listOf(FL, FR, FC, LFE, BL, BR, SL, SR)), 62 | CH_LAYOUT_7POINT1_WIDE("7.1(wide)", listOf(FL, FR, FC, LFE, BL, BR, FLC, FRC)), 63 | CH_LAYOUT_7POINT1_WIDE_SIDE("7.1(wide-side)", listOf(FL, FR, FC, LFE, FLC, FRC, SL, SR)), 64 | CH_LAYOUT_5POINT1POINT2("5.1.2", listOf(FL, FR, FC, LFE, BL, BR, TFL, TFR)), 65 | CH_LAYOUT_OCTAGONAL("octagonal", listOf(FL, FR, FC, BL, BR, BC, SL, SR)), 66 | CH_LAYOUT_CUBE("cube", listOf(FL, FR, BL, BR, TFL, TFR, TBL, TBR)), 67 | CH_LAYOUT_5POINT1POINT4("5.1.4", listOf(FL, FR, FC, LFE, BL, BR, TFL, TFR, TBL, TBR)), 68 | CH_LAYOUT_7POINT1POINT2("7.1.2", listOf(FL, FR, FC, LFE, BL, BR, SL, SR, TFL, TFR)), 69 | CH_LAYOUT_7POINT1POINT4("7.1.4", listOf(FL, FR, FC, LFE, BL, BR, SL, SR, TFL, TFR, TBL, TBR)), 70 | CH_LAYOUT_7POINT2POINT3("7.2.3", listOf(FL, FR, FC, LFE, BL, BR, SL, SR, TFL, TFR, TBC, LFE2)), 71 | CH_LAYOUT_9POINT1POINT4("9.1.4", listOf(FL, FR, FC, LFE, BL, BR, FLC, FRC, SL, SR, TFL, TFR, TBL, TBR)), 72 | CH_LAYOUT_HEXADECAGONAL( 73 | "hexadecagonal", 74 | listOf( 75 | FL, FR, FC, BL, BR, BC, SL, SR, TFL, TFC, TFR, TBL, TBC, TBR, WL, WR, 76 | ), 77 | ), 78 | CH_LAYOUT_DOWNMIX("downmix)", listOf(DL, DR)), 79 | CH_LAYOUT_22POINT2( 80 | "22.2", 81 | listOf( 82 | FL, 83 | FR, 84 | FC, 85 | LFE, 86 | BL, 87 | BR, 88 | FLC, 89 | FRC, 90 | BC, 91 | SL, 92 | SR, 93 | TC, 94 | TFL, 95 | TFC, 96 | TFR, 97 | TBL, 98 | TBC, 99 | TBR, 100 | LFE2, 101 | TSL, 102 | TSR, 103 | BFC, 104 | BFL, 105 | BFR, 106 | ), 107 | ), 108 | ; 109 | 110 | companion object { 111 | fun defaultChannelLayout(numChannels: Int) = entries.firstOrNull { it.channels.size == numChannels } 112 | fun getByNameOrNull(layoutName: String) = entries.firstOrNull { it.layoutName == layoutName } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/GenericVideoEncode.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.profile 6 | 7 | import se.svt.oss.encore.model.input.DEFAULT_VIDEO_LABEL 8 | 9 | data class GenericVideoEncode( 10 | override val width: Int?, 11 | override val height: Int?, 12 | override val twoPass: Boolean, 13 | override val params: LinkedHashMap, 14 | override val filters: List = emptyList(), 15 | override val audioEncode: AudioEncoder?, 16 | override val audioEncodes: List = emptyList(), 17 | override val suffix: String, 18 | override val format: String, 19 | override val codec: String, 20 | override val inputLabel: String = DEFAULT_VIDEO_LABEL, 21 | ) : VideoEncode 22 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/OutputProducer.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.profile 6 | 7 | import com.fasterxml.jackson.annotation.JsonSubTypes 8 | import com.fasterxml.jackson.annotation.JsonTypeInfo 9 | import se.svt.oss.encore.config.EncodingProperties 10 | import se.svt.oss.encore.model.EncoreJob 11 | import se.svt.oss.encore.model.output.Output 12 | 13 | @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") 14 | @JsonSubTypes( 15 | JsonSubTypes.Type(value = AudioEncode::class, name = "AudioEncode"), 16 | JsonSubTypes.Type(value = SimpleAudioEncode::class, name = "SimpleAudioEncode"), 17 | JsonSubTypes.Type(value = X264Encode::class, name = "X264Encode"), 18 | JsonSubTypes.Type(value = X265Encode::class, name = "X265Encode"), 19 | JsonSubTypes.Type(value = GenericVideoEncode::class, name = "VideoEncode"), 20 | JsonSubTypes.Type(value = ThumbnailEncode::class, name = "ThumbnailEncode"), 21 | JsonSubTypes.Type(value = ThumbnailMapEncode::class, name = "ThumbnailMapEncode"), 22 | ) 23 | interface OutputProducer { 24 | fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? 25 | } 26 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/Profile.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.profile 6 | 7 | data class Profile( 8 | val name: String, 9 | val description: String, 10 | val encodes: List, 11 | val scaling: String? = "bicubic", 12 | val deinterlaceFilter: String = "yadif", 13 | val joinSegmentParams: LinkedHashMap = linkedMapOf(), 14 | ) 15 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/SimpleAudioEncode.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.profile 6 | 7 | import se.svt.oss.encore.config.EncodingProperties 8 | import se.svt.oss.encore.model.EncoreJob 9 | import se.svt.oss.encore.model.input.DEFAULT_AUDIO_LABEL 10 | import se.svt.oss.encore.model.input.analyzedAudio 11 | import se.svt.oss.encore.model.mediafile.toParams 12 | import se.svt.oss.encore.model.output.AudioStreamEncode 13 | import se.svt.oss.encore.model.output.Output 14 | 15 | data class SimpleAudioEncode( 16 | val codec: String = "libfdk_aac", 17 | val bitrate: String? = null, 18 | val samplerate: Int? = null, 19 | val suffix: String = "_$codec", 20 | val params: LinkedHashMap = linkedMapOf(), 21 | override val optional: Boolean = false, 22 | val format: String = "mp4", 23 | val inputLabel: String = DEFAULT_AUDIO_LABEL, 24 | ) : AudioEncoder() { 25 | override fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? { 26 | val outputName = "${job.baseName}$suffix.$format" 27 | job.inputs.analyzedAudio(inputLabel) 28 | ?: return logOrThrow("Can not generate $outputName! No audio input with label '$inputLabel'.") 29 | val outParams = linkedMapOf() 30 | outParams += params 31 | outParams["c:a"] = codec 32 | samplerate?.let { outParams["ar"] = it } 33 | bitrate?.let { outParams["b:a"] = it } 34 | 35 | return Output( 36 | id = "$suffix.$format", 37 | video = null, 38 | audioStreams = listOf( 39 | AudioStreamEncode( 40 | params = outParams.toParams(), 41 | inputLabels = listOf(inputLabel), 42 | preserveLayout = true, 43 | ), 44 | ), 45 | output = outputName, 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncode.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.profile 6 | 7 | import io.github.oshai.kotlinlogging.KotlinLogging 8 | import se.svt.oss.encore.config.EncodingProperties 9 | import se.svt.oss.encore.model.EncoreJob 10 | import se.svt.oss.encore.model.input.DEFAULT_VIDEO_LABEL 11 | import se.svt.oss.encore.model.input.VideoIn 12 | import se.svt.oss.encore.model.input.videoInput 13 | import se.svt.oss.encore.model.mediafile.toParams 14 | import se.svt.oss.encore.model.output.Output 15 | import se.svt.oss.encore.model.output.VideoStreamEncode 16 | 17 | private val log = KotlinLogging.logger { } 18 | 19 | data class ThumbnailEncode( 20 | val percentages: List = listOf(25, 50, 75), 21 | val thumbnailWidth: Int = -2, 22 | val thumbnailHeight: Int = 1080, 23 | val quality: Int = 5, 24 | val suffix: String = "_thumb", 25 | val suffixZeroPad: Int = 2, 26 | val inputLabel: String = DEFAULT_VIDEO_LABEL, 27 | val optional: Boolean = false, 28 | val intervalSeconds: Double? = null, 29 | val decodeOutput: Int? = null, 30 | ) : OutputProducer { 31 | 32 | override fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? { 33 | if (job.segmentLength != null) { 34 | return logOrThrow("Thumbnail is not supported in segmented encode!") 35 | } 36 | val videoInput = job.inputs.videoInput(inputLabel) 37 | ?: return logOrThrow("Can not produce thumbnail $suffix. No video input with label $inputLabel!") 38 | val thumbnailTime = job.thumbnailTime?.let { time -> 39 | videoInput.seekTo?.let { time - it } ?: time 40 | } 41 | val select = when { 42 | thumbnailTime != null -> selectTimes(listOf(thumbnailTime)) 43 | intervalSeconds != null -> selectInterval(intervalSeconds, job.seekTo) 44 | outputDuration(videoInput, job) <= 0 -> return logOrThrow("Can not produce thumbnail $suffix. Could not detect duration.") 45 | percentages.isNotEmpty() -> selectTimes(percentagesToTimes(videoInput, job)) 46 | else -> return logOrThrow("Can not produce thumbnail $suffix. No times selected.") 47 | } 48 | 49 | val filter = "$select,scale=w=$thumbnailWidth:h=$thumbnailHeight:out_range=jpeg" 50 | val params = linkedMapOf( 51 | "fps_mode" to "vfr", 52 | "q:v" to "$quality", 53 | ) 54 | 55 | val fileRegex = Regex("${job.baseName}$suffix\\d{$suffixZeroPad}\\.jpg") 56 | 57 | return Output( 58 | id = "${suffix}0${suffixZeroPad}d.jpg", 59 | video = VideoStreamEncode( 60 | params = params.toParams(), 61 | filter = filter, 62 | inputLabels = listOf(inputLabel), 63 | ), 64 | output = "${job.baseName}$suffix%0${suffixZeroPad}d.jpg", 65 | postProcessor = { outputFolder -> 66 | outputFolder.listFiles().orEmpty().filter { it.name.matches(fileRegex) } 67 | }, 68 | isImage = true, 69 | decodeOutputStream = decodeOutput?.let { "$it:v:0" }, 70 | ) 71 | } 72 | 73 | private fun selectInterval(interval: Double, outputSeek: Double?): String { 74 | val select = if (outputSeek != null && decodeOutput == null) { 75 | "gte(t\\,$outputSeek)*(isnan(prev_selected_t)+gt(floor((t-$outputSeek)/$interval)\\,floor((prev_selected_t-$outputSeek)/$interval)))" 76 | } else { 77 | "isnan(prev_selected_t)+gt(floor(t/$interval)\\,floor(prev_selected_t/$interval))" 78 | } 79 | return "select=$select" 80 | } 81 | 82 | private fun outputDuration(videoIn: VideoIn, job: EncoreJob): Double { 83 | val videoStream = videoIn.analyzedVideo.highestBitrateVideoStream 84 | var inputDuration = videoStream.duration 85 | videoIn.seekTo?.let { inputDuration -= it } 86 | job.seekTo?.let { inputDuration -= it } 87 | return job.duration ?: inputDuration 88 | } 89 | 90 | private fun percentagesToTimes(videoIn: VideoIn, job: EncoreJob): List { 91 | val outputDuration = outputDuration(videoIn, job) 92 | return percentages 93 | .map { it * outputDuration / 100 } 94 | .map { t -> 95 | val outputSeek = job.seekTo 96 | if (outputSeek != null && decodeOutput == null) { 97 | t + outputSeek 98 | } else { 99 | t 100 | } 101 | } 102 | } 103 | 104 | private fun selectTimes(times: List) = 105 | "select=${times.joinToString("+") { "(isnan(prev_pts)+lt(prev_pts*TB\\,$it))*gte(pts*TB\\,$it)" }}" 106 | 107 | private fun logOrThrow(message: String): Output? { 108 | if (optional) { 109 | log.info { message } 110 | return null 111 | } else { 112 | throw RuntimeException(message) 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncode.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.profile 6 | 7 | import io.github.oshai.kotlinlogging.KotlinLogging 8 | import org.apache.commons.math3.fraction.Fraction 9 | import se.svt.oss.encore.config.EncodingProperties 10 | import se.svt.oss.encore.model.EncoreJob 11 | import se.svt.oss.encore.model.input.DEFAULT_VIDEO_LABEL 12 | import se.svt.oss.encore.model.input.analyzedVideo 13 | import se.svt.oss.encore.model.input.videoInput 14 | import se.svt.oss.encore.model.mediafile.toParams 15 | import se.svt.oss.encore.model.output.Output 16 | import se.svt.oss.encore.model.output.VideoStreamEncode 17 | import se.svt.oss.encore.process.createTempDir 18 | import se.svt.oss.mediaanalyzer.file.stringValue 19 | 20 | private val log = KotlinLogging.logger { } 21 | 22 | data class ThumbnailMapEncode( 23 | val tileWidth: Int = 160, 24 | val tileHeight: Int = 90, 25 | val cols: Int = 12, 26 | val rows: Int = 20, 27 | val quality: Int = 5, 28 | val optional: Boolean = true, 29 | val suffix: String = "_${cols}x${rows}_${tileWidth}x${tileHeight}_thumbnail_map", 30 | val format: String = "jpg", 31 | val inputLabel: String = DEFAULT_VIDEO_LABEL, 32 | val decodeOutput: Int? = null, 33 | ) : OutputProducer { 34 | 35 | override fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? { 36 | if (job.segmentLength != null) { 37 | return logOrThrow("Thumbnail map is not supported in segmented encode!") 38 | } 39 | val videoInput = job.inputs.videoInput(inputLabel) 40 | val inputSeekTo = videoInput?.seekTo 41 | val videoStream = job.inputs.analyzedVideo(inputLabel)?.highestBitrateVideoStream 42 | ?: return logOrThrow("No input with label $inputLabel!") 43 | 44 | var inputDuration = videoStream.duration 45 | val outputSeek = job.seekTo 46 | inputSeekTo?.let { inputDuration -= it } 47 | outputSeek?.let { inputDuration -= it } 48 | val outputDuration = job.duration ?: inputDuration 49 | 50 | if (outputDuration <= 0) { 51 | return logOrThrow("Cannot create thumbnail map $suffix! Could not detect duration.") 52 | } 53 | 54 | val interval = outputDuration / (cols * rows) 55 | val select = if (outputSeek != null && decodeOutput == null) { 56 | "gte(t\\,$outputSeek)*(isnan(prev_selected_t)+gt(floor((t-$outputSeek)/$interval)\\,floor((prev_selected_t-$outputSeek)/$interval)))" 57 | } else { 58 | "isnan(prev_selected_t)+gt(floor(t/$interval)\\,floor(prev_selected_t/$interval))" 59 | } 60 | 61 | val tempFolder = createTempDir(suffix).toFile() 62 | tempFolder.deleteOnExit() 63 | 64 | val pad = "aspect=${Fraction(tileWidth, tileHeight).stringValue()}:x=(ow-iw)/2:y=(oh-ih)/2" 65 | 66 | val scale = "-1:$tileHeight" 67 | 68 | val params = linkedMapOf( 69 | "fps_mode" to "vfr", 70 | ) 71 | return Output( 72 | id = "$suffix.$format", 73 | video = VideoStreamEncode( 74 | params = params.toParams(), 75 | filter = "select=$select,pad=$pad,scale=$scale", 76 | inputLabels = listOf(inputLabel), 77 | ), 78 | output = tempFolder.resolve("${job.baseName}$suffix%04d.png").toString(), 79 | postProcessor = { outputFolder -> 80 | try { 81 | val targetFile = outputFolder.resolve("${job.baseName}$suffix.$format") 82 | val process = ProcessBuilder( 83 | "ffmpeg", 84 | "-y", 85 | "-i", 86 | "${job.baseName}$suffix%04d.png", 87 | "-vf", 88 | "tile=${cols}x$rows", 89 | "-frames:v", 90 | "1", 91 | "-q:v", 92 | "$quality", 93 | "$targetFile", 94 | ) 95 | .directory(tempFolder) 96 | .start() 97 | val status = process.waitFor() 98 | tempFolder.deleteRecursively() 99 | if (status != 0) { 100 | throw RuntimeException("Ffmpeg returned status code $status") 101 | } 102 | listOf(targetFile) 103 | } catch (e: Exception) { 104 | logOrThrow("Error creating thumbnail map! ${e.message}") 105 | emptyList() 106 | } 107 | }, 108 | isImage = true, 109 | decodeOutputStream = decodeOutput?.let { "$it:v:0" }, 110 | ) 111 | } 112 | 113 | private fun logOrThrow(message: String): Output? { 114 | if (optional) { 115 | log.info { message } 116 | return null 117 | } else { 118 | throw RuntimeException(message) 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/VideoEncode.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.profile 6 | 7 | import org.apache.commons.math3.fraction.Fraction 8 | import se.svt.oss.encore.config.EncodingProperties 9 | import se.svt.oss.encore.model.EncoreJob 10 | import se.svt.oss.encore.model.input.VideoIn 11 | import se.svt.oss.encore.model.input.videoInput 12 | import se.svt.oss.encore.model.mediafile.toParams 13 | import se.svt.oss.encore.model.output.Output 14 | import se.svt.oss.encore.model.output.VideoStreamEncode 15 | import se.svt.oss.mediaanalyzer.file.toFractionOrNull 16 | import kotlin.math.absoluteValue 17 | 18 | interface VideoEncode : OutputProducer { 19 | val width: Int? 20 | val height: Int? 21 | val twoPass: Boolean 22 | val params: Map 23 | val filters: List? 24 | val audioEncode: AudioEncoder? 25 | val audioEncodes: List 26 | val suffix: String 27 | val format: String 28 | val codec: String 29 | val inputLabel: String 30 | 31 | override fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? { 32 | val audioEncodesToUse = audioEncodes.ifEmpty { listOfNotNull(audioEncode) } 33 | val audio = audioEncodesToUse.flatMap { it.getOutput(job, encodingProperties)?.audioStreams.orEmpty() } 34 | val videoInput = job.inputs.videoInput(inputLabel) 35 | ?: throw RuntimeException("No valid video input with label $inputLabel!") 36 | return Output( 37 | id = "$suffix.$format", 38 | video = VideoStreamEncode( 39 | params = secondPassParams().toParams(), 40 | firstPassParams = firstPassParams().toParams(), 41 | inputLabels = listOf(inputLabel), 42 | twoPass = twoPass, 43 | filter = videoFilter(job.debugOverlay, encodingProperties, videoInput), 44 | ), 45 | audioStreams = audio, 46 | output = "${job.baseName}$suffix.$format", 47 | ) 48 | } 49 | 50 | fun firstPassParams(): Map = if (!twoPass) { 51 | emptyMap() 52 | } else { 53 | params + Pair("c:v", codec) + passParams(1) 54 | } 55 | 56 | fun secondPassParams(): Map = if (!twoPass) { 57 | params + Pair("c:v", codec) 58 | } else { 59 | params + Pair("c:v", codec) + passParams(2) 60 | } 61 | 62 | fun passParams(pass: Int): Map = 63 | mapOf("pass" to pass.toString(), "passlogfile" to "log$suffix") 64 | 65 | fun videoFilter( 66 | debugOverlay: Boolean, 67 | encodingProperties: EncodingProperties, 68 | videoInput: VideoIn, 69 | ): String? { 70 | val videoFilters = mutableListOf() 71 | var scaleToWidth = width 72 | var scaleToHeight = height 73 | val videoStream = videoInput.analyzedVideo.highestBitrateVideoStream 74 | val isRotated90 = videoStream.rotation?.rem(180)?.absoluteValue == 90 75 | val outputDar = (videoInput.padTo ?: videoInput.cropTo)?.toFractionOrNull() 76 | ?: (videoStream.displayAspectRatio?.toFractionOrNull() ?: Fraction(videoStream.width, videoStream.height)) 77 | .let { if (isRotated90) it.reciprocal() else it } 78 | val outputIsPortrait = outputDar < Fraction.ONE 79 | val isScalingWithinLandscape = 80 | scaleToWidth != null && scaleToHeight != null && Fraction(scaleToWidth, scaleToHeight) > Fraction.ONE 81 | if (encodingProperties.flipWidthHeightIfPortrait && outputIsPortrait && isScalingWithinLandscape) { 82 | scaleToWidth = height 83 | scaleToHeight = width 84 | } 85 | if (scaleToWidth != null && scaleToHeight != null) { 86 | videoFilters.add("scale=$scaleToWidth:$scaleToHeight:force_original_aspect_ratio=decrease:force_divisible_by=2") 87 | videoFilters.add("setsar=1/1") 88 | } else if (scaleToWidth != null || scaleToHeight != null) { 89 | videoFilters.add("scale=${scaleToWidth ?: -2}:${scaleToHeight ?: -2}") 90 | } 91 | filters?.let { videoFilters.addAll(it) } 92 | if (debugOverlay) { 93 | videoFilters.add("drawtext=text=$suffix:fontcolor=white:fontsize=50:box=1:boxcolor=black@0.75:boxborderw=5:x=10:y=10") 94 | } 95 | return if (videoFilters.isEmpty()) null else videoFilters.joinToString(",") 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/X264Encode.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.profile 6 | 7 | import com.fasterxml.jackson.annotation.JsonProperty 8 | import se.svt.oss.encore.model.input.DEFAULT_VIDEO_LABEL 9 | 10 | data class X264Encode( 11 | override val width: Int?, 12 | override val height: Int?, 13 | override val twoPass: Boolean, 14 | @JsonProperty("params") 15 | override val ffmpegParams: LinkedHashMap = linkedMapOf(), 16 | @JsonProperty("x264-params") 17 | override val codecParams: LinkedHashMap = linkedMapOf(), 18 | override val filters: List = emptyList(), 19 | override val audioEncode: AudioEncoder? = null, 20 | override val audioEncodes: List = emptyList(), 21 | override val suffix: String, 22 | override val format: String = "mp4", 23 | override val inputLabel: String = DEFAULT_VIDEO_LABEL, 24 | ) : X26XEncode() { 25 | override val codecParamName: String 26 | get() = "x264-params" 27 | override val codec: String 28 | get() = "libx264" 29 | } 30 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/X265Encode.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.profile 6 | 7 | import com.fasterxml.jackson.annotation.JsonProperty 8 | import se.svt.oss.encore.model.input.DEFAULT_VIDEO_LABEL 9 | 10 | data class X265Encode( 11 | override val width: Int?, 12 | override val height: Int?, 13 | override val twoPass: Boolean, 14 | @JsonProperty("params") 15 | override val ffmpegParams: LinkedHashMap = linkedMapOf(), 16 | @JsonProperty("x265-params") 17 | override val codecParams: LinkedHashMap = linkedMapOf(), 18 | override val filters: List = emptyList(), 19 | override val audioEncode: AudioEncoder? = null, 20 | override val audioEncodes: List = emptyList(), 21 | override val suffix: String, 22 | override val format: String = "mp4", 23 | override val inputLabel: String = DEFAULT_VIDEO_LABEL, 24 | ) : X26XEncode() { 25 | override val codecParamName: String 26 | get() = "x265-params" 27 | override val codec: String 28 | get() = "libx265" 29 | } 30 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/profile/X26XEncode.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.profile 6 | 7 | abstract class X26XEncode : VideoEncode { 8 | 9 | abstract val ffmpegParams: LinkedHashMap 10 | 11 | abstract val codecParams: LinkedHashMap 12 | abstract val codecParamName: String 13 | 14 | override val params: Map 15 | get() = ffmpegParams + if (codecParams.isNotEmpty()) { 16 | mapOf(codecParamName to codecParams.map { "${it.key}=${it.value}" }.joinToString(":")) 17 | } else { 18 | emptyMap() 19 | } 20 | 21 | override fun passParams(pass: Int): Map { 22 | val modifiedCodecParams = (codecParams + mapOf("pass" to pass.toString(), "stats" to "log$suffix")) 23 | .map { "${it.key}=${it.value}" } 24 | .joinToString(":") 25 | return mapOf(codecParamName to modifiedCodecParams) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/model/queue/QueueItem.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.queue 6 | 7 | import com.fasterxml.jackson.annotation.JsonPropertyOrder 8 | import com.fasterxml.jackson.annotation.JsonTypeInfo 9 | import java.time.LocalDateTime 10 | 11 | @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY) 12 | @JsonPropertyOrder("created", "id", "segment") 13 | data class QueueItem( 14 | val id: String, 15 | val priority: Int = 0, 16 | val created: LocalDateTime = LocalDateTime.now(), 17 | val segment: Int? = null, 18 | ) 19 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/process/SegmentUtil.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.process 6 | 7 | import se.svt.oss.encore.model.EncoreJob 8 | import se.svt.oss.mediaanalyzer.file.MediaContainer 9 | import kotlin.math.ceil 10 | 11 | fun EncoreJob.segmentLengthOrThrow() = segmentLength ?: throw RuntimeException("No segmentLength in job!") 12 | 13 | fun EncoreJob.numSegments(): Int { 14 | val segLen = segmentLengthOrThrow() 15 | val readDuration = duration 16 | return if (readDuration != null) { 17 | ceil(readDuration / segLen).toInt() 18 | } else { 19 | val segments = 20 | inputs.map { ceil(((it.analyzed as MediaContainer).duration - (it.seekTo ?: 0.0)) / segLen).toInt() }.toSet() 21 | if (segments.size > 1) { 22 | throw RuntimeException("Inputs differ in length") 23 | } 24 | segments.first() 25 | } 26 | } 27 | 28 | fun EncoreJob.segmentDuration(segmentNumber: Int): Double = when { 29 | duration == null -> segmentLengthOrThrow() 30 | segmentNumber < numSegments() - 1 -> segmentLengthOrThrow() 31 | else -> duration!! % segmentLengthOrThrow() 32 | } 33 | 34 | fun EncoreJob.baseName(segmentNumber: Int) = "${baseName}_%05d".format(segmentNumber) 35 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/process/TempDir.kt: -------------------------------------------------------------------------------- 1 | package se.svt.oss.encore.process 2 | 3 | import java.nio.file.Path 4 | import kotlin.io.path.createTempDirectory 5 | 6 | fun createTempDir(prefix: String): Path { 7 | val tmpdir = System.getenv("ENCORE_TMPDIR") 8 | ?: System.getProperty("java.io.tmpdir") 9 | return createTempDirectory(tmpdir?.let { Path.of(it) }, prefix) 10 | } 11 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/repository/ChannelLayoutConverters.kt: -------------------------------------------------------------------------------- 1 | package se.svt.oss.encore.repository 2 | 3 | import org.springframework.boot.context.properties.ConfigurationPropertiesBinding 4 | import org.springframework.core.convert.converter.Converter 5 | import org.springframework.data.convert.ReadingConverter 6 | import org.springframework.data.convert.WritingConverter 7 | import org.springframework.stereotype.Component 8 | import se.svt.oss.encore.model.profile.ChannelLayout 9 | 10 | @Component 11 | @ConfigurationPropertiesBinding 12 | class StringToChannelLayoutConverter : Converter { 13 | override fun convert(source: String): ChannelLayout = 14 | ChannelLayout.getByNameOrNull(source) 15 | ?: throw IllegalArgumentException("$source is not a valid channel layout. Valid values: ${ChannelLayout.entries.map { it.layoutName }}") 16 | } 17 | 18 | @ReadingConverter 19 | class ByteArrayToChannelLayoutConverter : Converter { 20 | override fun convert(source: ByteArray): ChannelLayout = 21 | ChannelLayout.getByNameOrNull(String(source)) 22 | ?: throw IllegalArgumentException("${String(source)} is not a valid channel layout. Valid values: ${ChannelLayout.entries.map { it.layoutName }}") 23 | } 24 | 25 | @WritingConverter 26 | class ChannelLayoutToByteArrayConverter : Converter { 27 | override fun convert(source: ChannelLayout): ByteArray = 28 | source.layoutName.toByteArray() 29 | } 30 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/repository/EncoreJobRepository.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.repository 6 | 7 | import io.swagger.v3.oas.annotations.Operation 8 | import io.swagger.v3.oas.annotations.tags.Tag 9 | import org.springframework.data.domain.Page 10 | import org.springframework.data.domain.Pageable 11 | import org.springframework.data.repository.CrudRepository 12 | import org.springframework.data.repository.PagingAndSortingRepository 13 | import org.springframework.data.rest.core.annotation.RepositoryRestResource 14 | import se.svt.oss.encore.model.EncoreJob 15 | import se.svt.oss.encore.model.Status 16 | import java.util.UUID 17 | 18 | @RepositoryRestResource 19 | @Tag(name = "encorejob") 20 | interface EncoreJobRepository : 21 | PagingAndSortingRepository, 22 | CrudRepository { 23 | 24 | @Operation(summary = "Find EncoreJobs By Status", description = "Returns EncoreJobs according to the given Status") 25 | fun findByStatus(status: Status, pageable: Pageable): Page 26 | } 27 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/repository/OffsetDateTimeConverters.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.repository 6 | 7 | import org.springframework.core.convert.converter.Converter 8 | import org.springframework.data.convert.ReadingConverter 9 | import org.springframework.data.convert.WritingConverter 10 | import java.time.OffsetDateTime 11 | 12 | @WritingConverter 13 | class OffsetDateTimeToByteArrayConverter : Converter { 14 | override fun convert(source: OffsetDateTime): ByteArray? = source.toString().toByteArray() 15 | } 16 | 17 | @ReadingConverter 18 | class ByteArrayToOffsetDateTimeConverter : Converter { 19 | override fun convert(source: ByteArray): OffsetDateTime? = OffsetDateTime.parse(String(source)) 20 | } 21 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/service/ApplicationShutdownException.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.service 6 | 7 | class ApplicationShutdownException : RuntimeException("Application was shutdown") 8 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/service/ShutdownHandler.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.service 6 | 7 | import io.github.oshai.kotlinlogging.KotlinLogging 8 | import jakarta.annotation.PostConstruct 9 | import org.springframework.context.ApplicationListener 10 | import org.springframework.context.event.ContextClosedEvent 11 | import org.springframework.stereotype.Component 12 | import java.util.concurrent.atomic.AtomicBoolean 13 | 14 | private val log = KotlinLogging.logger {} 15 | 16 | @Component 17 | class ShutdownHandler : ApplicationListener { 18 | companion object { 19 | private val isShutDown = AtomicBoolean(false) 20 | fun isShutDown() = isShutDown.get() 21 | fun checkShutdown() { 22 | if (isShutDown()) { 23 | throw ApplicationShutdownException() 24 | } 25 | } 26 | } 27 | 28 | @PostConstruct 29 | fun addHook() { 30 | Runtime.getRuntime().addShutdownHook(Thread { isShutDown.set(true) }) 31 | } 32 | 33 | override fun onApplicationEvent(event: ContextClosedEvent) { 34 | if (isShutDown()) { 35 | log.info { "Delaying application shutdown" } 36 | Thread.sleep(6000) 37 | log.info { "Continue application shutdown" } 38 | } 39 | } 40 | 41 | override fun supportsAsyncExecution() = false 42 | } 43 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/service/callback/CallbackClient.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.service.callback 6 | 7 | import org.springframework.http.MediaType 8 | import org.springframework.web.bind.annotation.RequestBody 9 | import org.springframework.web.service.annotation.HttpExchange 10 | import org.springframework.web.service.annotation.PostExchange 11 | import se.svt.oss.encore.model.callback.JobProgress 12 | import java.net.URI 13 | 14 | @HttpExchange(contentType = MediaType.APPLICATION_JSON_VALUE) 15 | interface CallbackClient { 16 | 17 | @PostExchange 18 | fun sendProgressCallback(callbackUri: URI, @RequestBody progress: JobProgress) 19 | } 20 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/service/callback/CallbackService.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.service.callback 6 | 7 | import io.github.oshai.kotlinlogging.KotlinLogging 8 | import org.springframework.stereotype.Service 9 | import se.svt.oss.encore.model.EncoreJob 10 | import se.svt.oss.encore.model.callback.JobProgress 11 | import java.net.URI 12 | 13 | private val log = KotlinLogging.logger {} 14 | 15 | @Service 16 | class CallbackService(private val callbackClient: CallbackClient) { 17 | 18 | fun sendProgressCallback(encoreJob: EncoreJob) { 19 | encoreJob.progressCallbackUri?.let { 20 | try { 21 | callbackClient.sendProgressCallback( 22 | URI.create(it), 23 | JobProgress( 24 | encoreJob.id, 25 | encoreJob.externalId, 26 | encoreJob.progress, 27 | encoreJob.status, 28 | ), 29 | ) 30 | } catch (e: Exception) { 31 | log.debug(e) { "Sending progress callback failed" } 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/service/localencode/LocalEncodeService.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.service.localencode 6 | 7 | import io.github.oshai.kotlinlogging.KotlinLogging 8 | import org.springframework.stereotype.Service 9 | import se.svt.oss.encore.config.EncoreProperties 10 | import se.svt.oss.encore.model.EncoreJob 11 | import se.svt.oss.encore.process.createTempDir 12 | import se.svt.oss.mediaanalyzer.file.AudioFile 13 | import se.svt.oss.mediaanalyzer.file.ImageFile 14 | import se.svt.oss.mediaanalyzer.file.MediaFile 15 | import se.svt.oss.mediaanalyzer.file.VideoFile 16 | import java.io.File 17 | import java.nio.file.Files 18 | import java.nio.file.Path 19 | import java.nio.file.StandardCopyOption 20 | 21 | private val log = KotlinLogging.logger {} 22 | 23 | @Service 24 | class LocalEncodeService( 25 | private val encoreProperties: EncoreProperties, 26 | ) { 27 | 28 | fun outputFolder( 29 | encoreJob: EncoreJob, 30 | ): String = if (encoreProperties.localTemporaryEncode) { 31 | createTempDir("job_${encoreJob.id}").toString() 32 | } else { 33 | encoreJob.outputFolder 34 | } 35 | 36 | fun localEncodedFilesToCorrectDir( 37 | outputFolder: String, 38 | output: List, 39 | encoreJob: EncoreJob, 40 | ): List { 41 | if (encoreProperties.localTemporaryEncode) { 42 | val destination = File(encoreJob.outputFolder) 43 | log.debug { "Moving files to correct outputFolder ${encoreJob.outputFolder}, from local temp $outputFolder" } 44 | if (!destination.exists()) destination.mkdirs() 45 | moveTempLocalFiles(destination, outputFolder) 46 | val files = resolveMovedOutputFiles(output, encoreJob) 47 | log.debug { "Locally encoded files have been successfully moved to output folder." } 48 | return files 49 | } 50 | return output 51 | } 52 | 53 | fun cleanup(tempDirectory: String?) { 54 | if (tempDirectory != null && encoreProperties.localTemporaryEncode) { 55 | File(tempDirectory).deleteRecursively() 56 | } 57 | } 58 | 59 | private fun moveTempLocalFiles(destination: File, tempDirectory: String) { 60 | File(tempDirectory).listFiles()?.forEach { moveFile(it, destination) } 61 | } 62 | 63 | private fun moveFile(file: File, destination: File) { 64 | try { 65 | executeMoveFile(file, destination) 66 | } catch (e: Exception) { 67 | log.debug { "Error when moving file ${file.path}. Trying again. message= ${e.message}" } 68 | executeMoveFile(file, destination) 69 | } 70 | } 71 | 72 | private fun executeMoveFile(file: File, destination: File) { 73 | log.debug { "Moving file ${file.path}" } 74 | Files.move(file.toPath(), destination.resolve(file.name).toPath(), StandardCopyOption.REPLACE_EXISTING) 75 | } 76 | 77 | private fun resolveMovedOutputFiles(output: List, encoreJob: EncoreJob): List = output.map { file -> 78 | when (file) { 79 | is VideoFile -> file.copy(file = resolvePath(file, encoreJob)) 80 | is AudioFile -> file.copy(file = resolvePath(file, encoreJob)) 81 | is ImageFile -> file.copy(file = resolvePath(file, encoreJob)) 82 | else -> throw Exception("Invalid conversion") 83 | } 84 | } 85 | 86 | private fun resolvePath(file: MediaFile, encoreJob: EncoreJob): String { 87 | val path = Path.of(file.file) 88 | return path.resolveSibling("${encoreJob.outputFolder}/${path.fileName}").toString() 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/service/mediaanalyzer/MediaAnalyzerService.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.service.mediaanalyzer 6 | 7 | import io.github.oshai.kotlinlogging.KotlinLogging 8 | import org.springframework.aot.hint.annotation.RegisterReflectionForBinding 9 | import org.springframework.stereotype.Service 10 | import se.svt.oss.encore.model.input.AudioIn 11 | import se.svt.oss.encore.model.input.Input 12 | import se.svt.oss.encore.model.input.VideoIn 13 | import se.svt.oss.encore.model.mediafile.selectAudioStream 14 | import se.svt.oss.encore.model.mediafile.selectVideoStream 15 | import se.svt.oss.encore.model.mediafile.trimAudio 16 | import se.svt.oss.mediaanalyzer.MediaAnalyzer 17 | import se.svt.oss.mediaanalyzer.ffprobe.DisplayMatrix 18 | import se.svt.oss.mediaanalyzer.ffprobe.FfAudioStream 19 | import se.svt.oss.mediaanalyzer.ffprobe.FfVideoStream 20 | import se.svt.oss.mediaanalyzer.ffprobe.ProbeResult 21 | import se.svt.oss.mediaanalyzer.ffprobe.SideData 22 | import se.svt.oss.mediaanalyzer.ffprobe.UnknownSideData 23 | import se.svt.oss.mediaanalyzer.ffprobe.UnknownStream 24 | import se.svt.oss.mediaanalyzer.file.AudioFile 25 | import se.svt.oss.mediaanalyzer.file.VideoFile 26 | import se.svt.oss.mediaanalyzer.mediainfo.AudioTrack 27 | import se.svt.oss.mediaanalyzer.mediainfo.GeneralTrack 28 | import se.svt.oss.mediaanalyzer.mediainfo.ImageTrack 29 | import se.svt.oss.mediaanalyzer.mediainfo.MediaInfo 30 | import se.svt.oss.mediaanalyzer.mediainfo.OtherTrack 31 | import se.svt.oss.mediaanalyzer.mediainfo.TextTrack 32 | import se.svt.oss.mediaanalyzer.mediainfo.VideoTrack 33 | 34 | private val log = KotlinLogging.logger {} 35 | 36 | @Service 37 | @RegisterReflectionForBinding( 38 | MediaInfo::class, 39 | AudioTrack::class, 40 | GeneralTrack::class, 41 | ImageTrack::class, 42 | OtherTrack::class, 43 | TextTrack::class, 44 | VideoTrack::class, 45 | ProbeResult::class, 46 | FfAudioStream::class, 47 | FfVideoStream::class, 48 | UnknownStream::class, 49 | SideData::class, 50 | DisplayMatrix::class, 51 | UnknownSideData::class, 52 | ) 53 | class MediaAnalyzerService(private val mediaAnalyzer: MediaAnalyzer) { 54 | 55 | fun analyzeInput(input: Input) { 56 | log.debug { "Analyzing input $input" } 57 | val probeInterlaced = input is VideoIn && input.probeInterlaced 58 | val useFirstAudioStreams = (input as? AudioIn)?.channelLayout?.channels?.size 59 | 60 | input.analyzed = mediaAnalyzer.analyze( 61 | file = input.uri, 62 | probeInterlaced = probeInterlaced, 63 | ffprobeInputParams = input.params, 64 | ).let { 65 | val selectedVideoStream = (input as? VideoIn)?.videoStream 66 | val selectedAudioStream = (input as? AudioIn)?.audioStream 67 | when (it) { 68 | is VideoFile -> it.selectVideoStream(selectedVideoStream) 69 | .selectAudioStream(selectedAudioStream) 70 | .trimAudio(useFirstAudioStreams) 71 | is AudioFile -> it.selectAudioStream(selectedAudioStream) 72 | .trimAudio(useFirstAudioStreams) 73 | else -> it 74 | } 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/service/profile/ProfileService.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.service.profile 6 | 7 | import com.fasterxml.jackson.core.JsonProcessingException 8 | import com.fasterxml.jackson.databind.DeserializationFeature 9 | import com.fasterxml.jackson.databind.ObjectMapper 10 | import com.fasterxml.jackson.dataformat.yaml.YAMLMapper 11 | import com.fasterxml.jackson.module.kotlin.readValue 12 | import io.github.oshai.kotlinlogging.KotlinLogging 13 | import org.springframework.aot.hint.annotation.RegisterReflectionForBinding 14 | import org.springframework.boot.context.properties.EnableConfigurationProperties 15 | import org.springframework.expression.common.TemplateParserContext 16 | import org.springframework.expression.spel.SpelParserConfiguration 17 | import org.springframework.expression.spel.standard.SpelExpressionParser 18 | import org.springframework.expression.spel.support.SimpleEvaluationContext 19 | import org.springframework.stereotype.Service 20 | import se.svt.oss.encore.config.ProfileProperties 21 | import se.svt.oss.encore.model.EncoreJob 22 | import se.svt.oss.encore.model.profile.AudioEncode 23 | import se.svt.oss.encore.model.profile.ChannelLayout 24 | import se.svt.oss.encore.model.profile.GenericVideoEncode 25 | import se.svt.oss.encore.model.profile.OutputProducer 26 | import se.svt.oss.encore.model.profile.Profile 27 | import se.svt.oss.encore.model.profile.SimpleAudioEncode 28 | import se.svt.oss.encore.model.profile.ThumbnailEncode 29 | import se.svt.oss.encore.model.profile.ThumbnailMapEncode 30 | import se.svt.oss.encore.model.profile.X264Encode 31 | import se.svt.oss.encore.model.profile.X265Encode 32 | import java.io.File 33 | import java.util.Locale 34 | 35 | private val log = KotlinLogging.logger { } 36 | 37 | @Service 38 | @RegisterReflectionForBinding( 39 | Profile::class, 40 | OutputProducer::class, 41 | AudioEncode::class, 42 | SimpleAudioEncode::class, 43 | X264Encode::class, 44 | X265Encode::class, 45 | GenericVideoEncode::class, 46 | ThumbnailEncode::class, 47 | ThumbnailMapEncode::class, 48 | ChannelLayout::class, 49 | ) 50 | @EnableConfigurationProperties(ProfileProperties::class) 51 | class ProfileService( 52 | private val properties: ProfileProperties, 53 | private val objectMapper: ObjectMapper, 54 | ) { 55 | private val yamlMapper: YAMLMapper = 56 | YAMLMapper() 57 | .findAndRegisterModules() 58 | .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) as YAMLMapper 59 | 60 | private val spelExpressionParser = SpelExpressionParser( 61 | SpelParserConfiguration( 62 | null, 63 | null, 64 | false, 65 | false, 66 | Int.MAX_VALUE, 67 | 100_000, 68 | ), 69 | ) 70 | 71 | private val spelEvaluationContext = SimpleEvaluationContext 72 | .forReadOnlyDataBinding() 73 | .build() 74 | 75 | private val spelParserContext = TemplateParserContext( 76 | properties.spelExpressionPrefix, 77 | properties.spelExpressionSuffix, 78 | ) 79 | 80 | private fun mapper() = 81 | if (properties.location.filename?.let { 82 | File(it).extension.lowercase(Locale.getDefault()) in setOf("yml", "yaml") 83 | } == true 84 | ) { 85 | yamlMapper 86 | } else { 87 | objectMapper 88 | } 89 | 90 | fun getProfile(job: EncoreJob): Profile = try { 91 | log.debug { "Get profile ${job.profile}. Reading profiles from ${properties.location}" } 92 | val profiles = mapper().readValue>(properties.location.inputStream) 93 | 94 | profiles[job.profile] 95 | ?.let { readProfile(it, job) } 96 | ?: throw RuntimeException("Could not find location for profile ${job.profile}! Profiles: $profiles") 97 | } catch (e: JsonProcessingException) { 98 | throw RuntimeException("Error parsing profile ${job.profile}: ${e.message}", e) 99 | } 100 | 101 | private fun readProfile(path: String, job: EncoreJob): Profile { 102 | val profile = properties.location.createRelative(path) 103 | log.debug { "Reading $profile" } 104 | val profileContent = profile.inputStream.bufferedReader().use { it.readText() } 105 | val resolvedProfileContent = spelExpressionParser 106 | .parseExpression(profileContent, spelParserContext) 107 | .getValue(spelEvaluationContext, job) as String 108 | return mapper().readValue(resolvedProfileContent) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /encore-common/src/main/kotlin/se/svt/oss/encore/service/queue/QueueUtil.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | package se.svt.oss.encore.service.queue 5 | 6 | import kotlin.math.max 7 | import kotlin.math.min 8 | 9 | object QueueUtil { 10 | 11 | fun getQueueNumberByPriority(concurrency: Int, priority: Int): Int { 12 | val maxQueueNo = concurrency - 1 13 | var queueNo = maxQueueNo - (priority.toDouble() / 100 * concurrency).toInt() 14 | queueNo = min(queueNo, maxQueueNo) 15 | return max(queueNo, 0) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /encore-common/src/main/resources/migrate_queue_script.lua: -------------------------------------------------------------------------------- 1 | local key = KEYS[1] 2 | local type = redis.call("type", key)["ok"] 3 | if(type == "list") then 4 | local tmpkey = key .. "_tmp" 5 | redis.call("rename", key, tmpkey) 6 | repeat 7 | local item = redis.call("lpop", tmpkey) 8 | if (item) then 9 | local priority = cjson.decode(item).priority 10 | redis.call("zadd", key, 100 - priority, item) 11 | end 12 | until not item 13 | redis.call("del", tmpkey) 14 | return true 15 | end 16 | return false -------------------------------------------------------------------------------- /encore-common/src/test/kotlin/se/svt/oss/encore/EncoreClient.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore 6 | 7 | import org.springframework.data.domain.Pageable 8 | import org.springframework.hateoas.PagedModel 9 | import org.springframework.http.MediaType 10 | import org.springframework.web.bind.annotation.PathVariable 11 | import org.springframework.web.bind.annotation.RequestBody 12 | import org.springframework.web.bind.annotation.RequestParam 13 | import org.springframework.web.service.annotation.GetExchange 14 | import org.springframework.web.service.annotation.HttpExchange 15 | import org.springframework.web.service.annotation.PostExchange 16 | import se.svt.oss.encore.model.EncoreJob 17 | import se.svt.oss.encore.model.Status 18 | import se.svt.oss.encore.model.queue.QueueItem 19 | import java.util.UUID 20 | 21 | @HttpExchange(accept = [MediaType.APPLICATION_JSON_VALUE], contentType = MediaType.APPLICATION_JSON_VALUE) 22 | interface EncoreClient { 23 | 24 | @GetExchange("/encoreJobs") 25 | fun jobs(): PagedModel 26 | 27 | @GetExchange("/encoreJobs/search/findByStatus") 28 | fun findByStatus(@RequestParam("status") status: Status, pageable: Pageable): PagedModel 29 | 30 | @PostExchange("/encoreJobs/{jobId}/cancel") 31 | fun cancel(@PathVariable("jobId") jobId: UUID) 32 | 33 | @PostExchange( 34 | "/encoreJobs", 35 | ) 36 | fun createJob(@RequestBody jobRequest: EncoreJob): EncoreJob 37 | 38 | @GetExchange("/health") 39 | fun health(): String 40 | 41 | @PostExchange( 42 | "/encoreJobs", 43 | ) 44 | fun postJson(@RequestBody json: String): EncoreJob 45 | 46 | @GetExchange("/encoreJobs/{jobId}") 47 | fun getJob(@PathVariable("jobId") jobId: UUID): EncoreJob 48 | 49 | @GetExchange("/queue") 50 | fun queue(): List 51 | } 52 | -------------------------------------------------------------------------------- /encore-common/src/test/kotlin/se/svt/oss/encore/EncoreRuntimeHintsTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore 6 | 7 | import org.junit.jupiter.api.Test 8 | import org.springframework.aot.hint.RuntimeHints 9 | import org.springframework.aot.hint.predicate.RuntimeHintsPredicates 10 | import se.svt.oss.encore.Assertions.assertThat 11 | import se.svt.oss.encore.config.AudioMixPreset 12 | import se.svt.oss.encore.config.EncodingProperties 13 | import se.svt.oss.encore.config.EncoreProperties 14 | import kotlin.reflect.jvm.javaConstructor 15 | 16 | class EncoreRuntimeHintsTest { 17 | @Test 18 | fun shouldRegisterHints() { 19 | val hints = RuntimeHints() 20 | EncoreRuntimeHints().registerHints(hints, javaClass.classLoader) 21 | assertThat( 22 | RuntimeHintsPredicates.reflection().onConstructor(EncoreProperties::class.constructors.first().javaConstructor!!), 23 | ).accepts(hints) 24 | assertThat( 25 | RuntimeHintsPredicates.reflection().onConstructor(EncodingProperties::class.constructors.first().javaConstructor!!), 26 | ).accepts(hints) 27 | assertThat( 28 | RuntimeHintsPredicates.reflection().onConstructor(AudioMixPreset::class.constructors.first().javaConstructor!!), 29 | ).accepts(hints) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /encore-common/src/test/kotlin/se/svt/oss/encore/LocalEncodeIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore 6 | 7 | import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo 8 | import com.github.tomakehurst.wiremock.junit5.WireMockTest 9 | import org.junit.jupiter.api.Test 10 | import org.junit.jupiter.api.io.TempDir 11 | import org.springframework.test.context.ActiveProfiles 12 | import java.io.File 13 | 14 | @ActiveProfiles("test-local") 15 | @WireMockTest 16 | class LocalEncodeIntegrationTest(wireMockRuntimeInfo: WireMockRuntimeInfo) : EncoreIntegrationTestBase(wireMockRuntimeInfo) { 17 | 18 | @Test 19 | fun jobIsSuccessfulAndNoAudioPresets(@TempDir outputDir: File) { 20 | successfulTest( 21 | job(outputDir = outputDir, file = testFileSurround), 22 | defaultExpectedOutputFiles(outputDir, testFileSurround) + listOf( 23 | expectedFile( 24 | outputDir, 25 | testFileSurround, 26 | "SURROUND.mp4", 27 | ), 28 | ), 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /encore-common/src/test/kotlin/se/svt/oss/encore/TestConfig.kt: -------------------------------------------------------------------------------- 1 | package se.svt.oss.encore 2 | 3 | import org.springframework.beans.factory.annotation.Value 4 | import org.springframework.boot.test.context.TestConfiguration 5 | import org.springframework.context.annotation.Bean 6 | import org.springframework.context.annotation.Lazy 7 | import org.springframework.web.reactive.function.client.WebClient 8 | import org.springframework.web.reactive.function.client.support.WebClientAdapter 9 | import org.springframework.web.service.invoker.HttpServiceProxyFactory 10 | 11 | @TestConfiguration(proxyBeanMethods = false) 12 | @Lazy 13 | class TestConfig { 14 | 15 | @Bean 16 | fun encoreClient(@Value("\${local.server.port}") localPort: Int): EncoreClient = HttpServiceProxyFactory 17 | .builderFor(WebClientAdapter.create(WebClient.create("http://localhost:$localPort"))) 18 | .build() 19 | .createClient(EncoreClient::class.java) 20 | } 21 | -------------------------------------------------------------------------------- /encore-common/src/test/kotlin/se/svt/oss/encore/TestUtils.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore 6 | 7 | import com.fasterxml.jackson.databind.ObjectMapper 8 | import com.fasterxml.jackson.module.kotlin.readValue 9 | import org.springframework.core.io.ClassPathResource 10 | import se.svt.oss.encore.model.EncoreJob 11 | import se.svt.oss.encore.model.input.AudioVideoInput 12 | import se.svt.oss.mediaanalyzer.file.AudioFile 13 | import se.svt.oss.mediaanalyzer.file.VideoFile 14 | 15 | fun defaultEncoreJob(priority: Int = 0) = 16 | EncoreJob( 17 | profile = "animerat", 18 | outputFolder = "/output/path", 19 | priority = priority, 20 | baseName = "test", 21 | inputs = listOf( 22 | AudioVideoInput( 23 | uri = "/input/test.mp4", 24 | analyzed = defaultVideoFile, 25 | ), 26 | ), 27 | ) 28 | 29 | val defaultVideoFile by lazy { 30 | ObjectMapper().findAndRegisterModules() 31 | .readValue(ClassPathResource("/input/video-file.json").file.readText()) 32 | } 33 | 34 | val portraitVideoFile by lazy { 35 | ObjectMapper().findAndRegisterModules() 36 | .readValue(ClassPathResource("/input/portrait-video-file.json").file.readText()) 37 | } 38 | 39 | val rotateToPortraitVideoFile by lazy { 40 | ObjectMapper().findAndRegisterModules() 41 | .readValue(ClassPathResource("/input/rotate-to-portrait-video-file.json").file.readText()) 42 | } 43 | 44 | val longVideoFile by lazy { 45 | ObjectMapper().findAndRegisterModules() 46 | .readValue(ClassPathResource("/input/video-file-long.json").file.readText()) 47 | } 48 | 49 | val multipleAudioFile by lazy { 50 | ObjectMapper().findAndRegisterModules() 51 | .readValue(ClassPathResource("/input/multiple-audio-file.json").file.readText()) 52 | } 53 | 54 | val multipleVideoFile by lazy { 55 | ObjectMapper().findAndRegisterModules() 56 | .readValue(ClassPathResource("/input/multiple-video-file.json").file.readText()) 57 | } 58 | -------------------------------------------------------------------------------- /encore-common/src/test/kotlin/se/svt/oss/encore/model/input/InputTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.input 6 | 7 | import org.junit.jupiter.api.Test 8 | import se.svt.oss.encore.Assertions.assertThat 9 | import se.svt.oss.encore.Assertions.assertThatThrownBy 10 | import se.svt.oss.encore.defaultVideoFile 11 | import se.svt.oss.encore.multipleAudioFile 12 | 13 | internal class InputTest { 14 | 15 | private val extraVideoFile = defaultVideoFile.copy(file = "extra.mp4", duration = 12.0) 16 | 17 | private val inputs = listOf( 18 | VideoInput( 19 | uri = "/input1.mp4", 20 | params = linkedMapOf("a" to "b"), 21 | videoLabel = "extra", 22 | analyzed = extraVideoFile, 23 | ), 24 | AudioVideoInput( 25 | uri = "http://input2", 26 | analyzed = defaultVideoFile, 27 | ), 28 | AudioInput( 29 | uri = "/input3.mxf", 30 | params = linkedMapOf("c" to "d"), 31 | audioLabel = "other", 32 | analyzed = multipleAudioFile, 33 | ), 34 | ) 35 | 36 | @Test 37 | fun testInputParamsWithDuration() { 38 | val params = inputs.inputParams(60.5) 39 | assertThat(params) 40 | .isEqualTo( 41 | listOf( 42 | "-a", "b", "-t", "60.5", "-i", "/input1.mp4", 43 | "-t", "60.5", "-i", "http://input2", 44 | "-c", "d", "-t", "60.5", "-i", "/input3.mxf", 45 | ), 46 | ) 47 | } 48 | 49 | @Test 50 | fun testInputParamsWithOutDuration() { 51 | val params = inputs.inputParams(null) 52 | assertThat(params) 53 | .isEqualTo( 54 | listOf( 55 | "-a", "b", "-i", "/input1.mp4", 56 | "-i", "http://input2", 57 | "-c", "d", "-i", "/input3.mxf", 58 | ), 59 | ) 60 | } 61 | 62 | @Test 63 | fun testInputParamsWithoutInputSeek() { 64 | val params = listOf( 65 | VideoInput( 66 | uri = "/input1.mp4", 67 | params = linkedMapOf("a" to "b"), 68 | videoLabel = "extra", 69 | analyzed = extraVideoFile, 70 | seekTo = 47.11, 71 | ), 72 | AudioVideoInput( 73 | uri = "http://input2", 74 | analyzed = defaultVideoFile, 75 | seekTo = 47.11, 76 | ), 77 | AudioInput( 78 | uri = "/input3.mxf", 79 | params = linkedMapOf("c" to "d"), 80 | audioLabel = "other", 81 | analyzed = multipleAudioFile, 82 | seekTo = 47.11, 83 | ), 84 | ).inputParams(60.5) 85 | 86 | assertThat(params) 87 | .isEqualTo( 88 | listOf( 89 | "-a", "b", "-t", "60.5", "-ss", "47.11", "-i", "/input1.mp4", 90 | "-t", "60.5", "-ss", "47.11", "-i", "http://input2", 91 | "-c", "d", "-t", "60.5", "-ss", "47.11", "-i", "/input3.mxf", 92 | ), 93 | ) 94 | } 95 | 96 | @Test 97 | fun testMaxDuration() { 98 | val maxDuration = inputs.maxDuration() 99 | assertThat(maxDuration).isEqualTo(12.0) 100 | } 101 | 102 | @Test 103 | fun testAnalyzedAudio() { 104 | val analyzedAudio = inputs.analyzedAudio("other") 105 | assertThat(analyzedAudio).isSameAs(multipleAudioFile) 106 | } 107 | 108 | @Test 109 | fun testAnalyzedAudioDuplicates() { 110 | assertThatThrownBy { (inputs + inputs.last()).analyzedAudio("other") } 111 | .isInstanceOf(IllegalArgumentException::class.java) 112 | .hasMessage("Inputs contains duplicate audio labels!") 113 | } 114 | 115 | @Test 116 | fun testAnalyzedVideo() { 117 | val analyzedVideo = inputs.analyzedVideo(DEFAULT_VIDEO_LABEL) 118 | assertThat(analyzedVideo).isSameAs(defaultVideoFile) 119 | } 120 | 121 | @Test 122 | fun testAnalyzedVideoDuplicates() { 123 | assertThatThrownBy { (inputs + inputs.first()).analyzedVideo("extra") } 124 | .isInstanceOf(IllegalArgumentException::class.java) 125 | .hasMessage("Inputs contains duplicate video labels!") 126 | } 127 | 128 | @Test 129 | fun testWithSeekTo() { 130 | assertThat(inputs.map { it.withSeekTo(20.0) }) 131 | .allSatisfy { assertThat(it).hasSeekTo(20.0) } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/GenericVideoEncodeTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.profile 6 | 7 | class GenericVideoEncodeTest : VideoEncodeTest() { 8 | override fun createEncode( 9 | width: Int?, 10 | height: Int?, 11 | twoPass: Boolean, 12 | params: LinkedHashMap, 13 | filters: List, 14 | audioEncode: AudioEncode?, 15 | ) = GenericVideoEncode( 16 | width = width, 17 | height = height, 18 | twoPass = twoPass, 19 | params = params, 20 | filters = filters, 21 | audioEncode = audioEncode, 22 | suffix = "-generic", 23 | codec = "acodec", 24 | format = "mp4", 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncodeTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.profile 6 | 7 | import org.junit.jupiter.api.Test 8 | import se.svt.oss.encore.Assertions.assertThat 9 | import se.svt.oss.encore.Assertions.assertThatThrownBy 10 | import se.svt.oss.encore.config.EncodingProperties 11 | import se.svt.oss.encore.defaultEncoreJob 12 | import se.svt.oss.encore.model.input.DEFAULT_VIDEO_LABEL 13 | import se.svt.oss.encore.model.output.VideoStreamEncode 14 | 15 | class ThumbnailMapEncodeTest { 16 | 17 | private val encode = ThumbnailMapEncode( 18 | tileWidth = 160, 19 | tileHeight = 90, 20 | cols = 12, 21 | rows = 20, 22 | ) 23 | 24 | @Test 25 | fun `correct output`() { 26 | val output = encode.getOutput(defaultEncoreJob(), EncodingProperties()) 27 | assertThat(output) 28 | .hasNoAudioStreams() 29 | .hasId("_12x20_160x90_thumbnail_map.jpg") 30 | .hasVideo( 31 | VideoStreamEncode( 32 | params = listOf("-fps_mode", "vfr"), 33 | filter = "select=isnan(prev_selected_t)+gt(floor(t/0.041666666666666664)\\,floor(prev_selected_t/0.041666666666666664)),pad=aspect=16/9:x=(ow-iw)/2:y=(oh-ih)/2,scale=-1:90", 34 | twoPass = false, 35 | inputLabels = listOf(DEFAULT_VIDEO_LABEL), 36 | ), 37 | ) 38 | } 39 | 40 | @Test 41 | fun `correct output seekTo and duration`() { 42 | val output = ThumbnailMapEncode(cols = 6, rows = 10) 43 | .getOutput( 44 | defaultEncoreJob() 45 | .copy(seekTo = 1.0, duration = 5.0), 46 | EncodingProperties(), 47 | ) 48 | assertThat(output) 49 | .hasNoAudioStreams() 50 | .hasId("_6x10_160x90_thumbnail_map.jpg") 51 | .hasVideo( 52 | VideoStreamEncode( 53 | params = listOf("-fps_mode", "vfr"), 54 | filter = "select=gte(t\\,1.0)*(isnan(prev_selected_t)+gt(floor((t-1.0)/0.08333333333333333)\\,floor((prev_selected_t-1.0)/0.08333333333333333))),pad=aspect=16/9:x=(ow-iw)/2:y=(oh-ih)/2,scale=-1:90", 55 | twoPass = false, 56 | inputLabels = listOf(DEFAULT_VIDEO_LABEL), 57 | ), 58 | ) 59 | } 60 | 61 | @Test 62 | fun `unmapped input optional returns null`() { 63 | val output = encode.copy(inputLabel = "other", optional = true).getOutput( 64 | job = defaultEncoreJob(), 65 | encodingProperties = EncodingProperties(), 66 | ) 67 | assertThat(output).isNull() 68 | } 69 | 70 | @Test 71 | fun `unmapped input not optional throws`() { 72 | assertThatThrownBy { 73 | encode.copy(inputLabel = "other", optional = false).getOutput( 74 | job = defaultEncoreJob(), 75 | encodingProperties = EncodingProperties(), 76 | ) 77 | }.isInstanceOf(RuntimeException::class.java) 78 | .hasMessageContaining("No input with label other!") 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/X264EncodeTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.profile 6 | 7 | import se.svt.oss.encore.Assertions 8 | 9 | class X264EncodeTest : VideoEncodeTest() { 10 | override fun createEncode( 11 | width: Int?, 12 | height: Int?, 13 | twoPass: Boolean, 14 | params: LinkedHashMap, 15 | filters: List, 16 | audioEncode: AudioEncode?, 17 | ): X264Encode = X264Encode( 18 | width = width, 19 | height = height, 20 | twoPass = twoPass, 21 | ffmpegParams = params, 22 | codecParams = linkedMapOf("c" to "d"), 23 | filters = filters, 24 | audioEncode = audioEncode, 25 | suffix = "-x264", 26 | ) 27 | override fun verifyFirstPassParams(encode: VideoEncode, params: List) { 28 | if (encode.twoPass) { 29 | Assertions.assertThat(params) 30 | .containsSequence("-a", "b") 31 | .containsSequence("-c:v", encode.codec) 32 | .noneSatisfy { Assertions.assertThat(it).contains("c=d:pass=2:stats=log-x264") } 33 | } else { 34 | Assertions.assertThat(params).isEmpty() 35 | } 36 | } 37 | 38 | override fun verifySecondPassParams(encode: VideoEncode, params: List) { 39 | if (encode.twoPass) { 40 | Assertions.assertThat(params) 41 | .containsSequence("-a", "b") 42 | .containsSequence("-c:v", encode.codec) 43 | .containsSequence("-x264-params", "c=d:pass=2:stats=log-x264") 44 | } else { 45 | Assertions.assertThat(params) 46 | .containsSequence("-a", "b") 47 | .containsSequence("-c:v", encode.codec) 48 | .containsSequence("-x264-params", "c=d") 49 | .noneSatisfy { Assertions.assertThat(it).contains("pass=2:stats=log-x264") } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /encore-common/src/test/kotlin/se/svt/oss/encore/model/profile/X265EncodeTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.model.profile 6 | 7 | import se.svt.oss.encore.Assertions.assertThat 8 | 9 | class X265EncodeTest : VideoEncodeTest() { 10 | override fun createEncode( 11 | width: Int?, 12 | height: Int?, 13 | twoPass: Boolean, 14 | params: LinkedHashMap, 15 | filters: List, 16 | audioEncode: AudioEncode?, 17 | ): X265Encode = X265Encode( 18 | width = width, 19 | height = height, 20 | twoPass = twoPass, 21 | ffmpegParams = params, 22 | codecParams = linkedMapOf("c" to "d"), 23 | filters = filters, 24 | audioEncode = audioEncode, 25 | suffix = "-x265", 26 | ) 27 | 28 | override fun verifyFirstPassParams(encode: VideoEncode, params: List) { 29 | if (encode.twoPass) { 30 | assertThat(params) 31 | .containsSequence("-a", "b") 32 | .containsSequence("-c:v", encode.codec) 33 | .noneSatisfy { assertThat(it).contains("c=d:pass=2:stats=log-x265") } 34 | } else { 35 | assertThat(params).isEmpty() 36 | } 37 | } 38 | 39 | override fun verifySecondPassParams(encode: VideoEncode, params: List) { 40 | if (encode.twoPass) { 41 | assertThat(params) 42 | .containsSequence("-a", "b") 43 | .containsSequence("-c:v", encode.codec) 44 | .containsSequence("-x265-params", "c=d:pass=2:stats=log-x265") 45 | } else { 46 | assertThat(params) 47 | .containsSequence("-a", "b") 48 | .containsSequence("-c:v", encode.codec) 49 | .containsSequence("-x265-params", "c=d") 50 | .noneSatisfy { assertThat(it).contains("pass=2:stats=log-x265") } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /encore-common/src/test/kotlin/se/svt/oss/encore/process/SegmentUtilTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.process 6 | 7 | import org.assertj.core.data.Offset 8 | import org.junit.jupiter.api.Test 9 | import se.svt.oss.encore.Assertions.assertThat 10 | import se.svt.oss.encore.Assertions.assertThatThrownBy 11 | import se.svt.oss.encore.defaultEncoreJob 12 | import se.svt.oss.encore.defaultVideoFile 13 | import se.svt.oss.encore.longVideoFile 14 | import se.svt.oss.encore.model.input.AudioVideoInput 15 | 16 | class SegmentUtilTest { 17 | 18 | private val job = defaultEncoreJob().copy( 19 | baseName = "segment_test", 20 | segmentLength = 19.2, 21 | duration = null, 22 | inputs = listOf(AudioVideoInput(uri = "test", analyzed = longVideoFile)), 23 | ) 24 | 25 | @Test 26 | fun baseName() { 27 | assertThat(job.baseName(2)).isEqualTo("segment_test_00002") 28 | } 29 | 30 | @Test 31 | fun missingSegmentLength() { 32 | val encoreJob = job.copy(segmentLength = null) 33 | val message = "No segmentLength in job!" 34 | assertThatThrownBy { 35 | encoreJob.segmentLengthOrThrow() 36 | }.hasMessage(message) 37 | assertThatThrownBy { 38 | encoreJob.numSegments() 39 | }.hasMessage(message) 40 | assertThatThrownBy { 41 | encoreJob.segmentDuration(1) 42 | }.hasMessage(message) 43 | } 44 | 45 | @Test 46 | fun hasSegmentLength() { 47 | assertThat(job.segmentLengthOrThrow()).isEqualTo(19.2) 48 | } 49 | 50 | @Test 51 | fun numSegmentsDurationSet() { 52 | val encoreJob = job.copy(duration = 125.0) 53 | assertThat(encoreJob.numSegments()).isEqualTo(7) 54 | } 55 | 56 | @Test 57 | fun numSegmentsDurationNotSet() { 58 | assertThat(job.numSegments()).isEqualTo(141) 59 | } 60 | 61 | @Test 62 | fun numSegmentsInputsDiffer() { 63 | val encoreJob = job.copy(inputs = job.inputs + AudioVideoInput(uri = "test", analyzed = defaultVideoFile)) 64 | assertThatThrownBy { encoreJob.numSegments() } 65 | .hasMessage("Inputs differ in length") 66 | } 67 | 68 | @Test 69 | fun segmentDurationDurationNotSet() { 70 | assertThat(job.segmentDuration(140)).isEqualTo(19.2) 71 | } 72 | 73 | @Test 74 | fun segmentDurationDurationSetFirst() { 75 | assertThat(job.copy(duration = 125.0).segmentDuration(0)).isEqualTo(19.2) 76 | } 77 | 78 | @Test 79 | fun segmentDurationDurationSetLast() { 80 | assertThat(job.copy(duration = 125.0).segmentDuration(6)).isCloseTo(9.8, Offset.offset(0.001)) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /encore-common/src/test/kotlin/se/svt/oss/encore/repository/EncoreJobRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.repository 6 | 7 | import org.junit.jupiter.api.Test 8 | import org.junit.jupiter.api.extension.ExtendWith 9 | import org.springframework.beans.factory.annotation.Autowired 10 | import org.springframework.boot.test.context.SpringBootTest 11 | import org.springframework.data.domain.PageRequest 12 | import org.springframework.test.context.ActiveProfiles 13 | import se.svt.oss.encore.Assertions.assertThat 14 | import se.svt.oss.encore.RedisExtension 15 | import se.svt.oss.encore.model.EncoreJob 16 | import se.svt.oss.encore.model.Status 17 | import java.time.OffsetDateTime 18 | import java.util.UUID 19 | 20 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 21 | @ExtendWith(RedisExtension::class) 22 | @ActiveProfiles("test") 23 | class EncoreJobRepositoryTest { 24 | 25 | @Autowired 26 | lateinit var repository: EncoreJobRepository 27 | 28 | @Test 29 | fun filteredTest() { 30 | createAndSaveJob("http://transcoder1", Status.FAILED) 31 | createAndSaveJob("http://transcoder2", Status.QUEUED) 32 | createAndSaveJob("http://transcoder3", Status.QUEUED) 33 | 34 | val findByStatus = repository.findByStatus(Status.QUEUED, PageRequest.of(0, 10)) 35 | assertThat(findByStatus.totalElements).isEqualTo(2) 36 | val callbackUrls = findByStatus.map { it.progressCallbackUri } 37 | assertThat(callbackUrls).containsExactlyInAnyOrder( 38 | "http://transcoder2", 39 | "http://transcoder3", 40 | ) 41 | repository.deleteAll() 42 | } 43 | 44 | private fun createAndSaveJob(url: String, status: Status) { 45 | val encoreJob = EncoreJob( 46 | id = UUID.randomUUID(), 47 | externalId = "externalId", 48 | profile = "animerat", 49 | outputFolder = "/shares/test", 50 | createdDate = OffsetDateTime.now(), 51 | progressCallbackUri = url, 52 | baseName = "test", 53 | ) 54 | encoreJob.status = status 55 | encoreJob.startedDate = OffsetDateTime.now() 56 | encoreJob.completedDate = OffsetDateTime.now() 57 | repository.save(encoreJob) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /encore-common/src/test/kotlin/se/svt/oss/encore/service/callback/CallbackServiceTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.service.callback 6 | 7 | import io.mockk.Runs 8 | import io.mockk.every 9 | import io.mockk.impl.annotations.InjectMockKs 10 | import io.mockk.impl.annotations.MockK 11 | import io.mockk.junit5.MockKExtension 12 | import io.mockk.just 13 | import io.mockk.verify 14 | import org.junit.jupiter.api.Test 15 | import org.junit.jupiter.api.extension.ExtendWith 16 | import se.svt.oss.encore.model.EncoreJob 17 | import se.svt.oss.encore.model.callback.JobProgress 18 | import java.net.URI 19 | 20 | @ExtendWith(MockKExtension::class) 21 | class CallbackServiceTest { 22 | 23 | @MockK 24 | private lateinit var callackClient: CallbackClient 25 | 26 | @InjectMockKs 27 | private lateinit var callbackService: CallbackService 28 | 29 | private val encoreJob = EncoreJob( 30 | outputFolder = "/some/output", 31 | profile = "program", 32 | progressCallbackUri = "wwww.callback.com", 33 | progress = 50, 34 | baseName = "file", 35 | ) 36 | 37 | private val progress = JobProgress( 38 | encoreJob.id, 39 | encoreJob.externalId, 40 | encoreJob.progress, 41 | encoreJob.status, 42 | ) 43 | 44 | @Test 45 | fun `successful callback`() { 46 | every { callackClient.sendProgressCallback(URI.create(encoreJob.progressCallbackUri!!), progress) } just Runs 47 | 48 | callbackService.sendProgressCallback(encoreJob) 49 | 50 | verify { callackClient.sendProgressCallback(URI.create(encoreJob.progressCallbackUri!!), progress) } 51 | } 52 | 53 | @Test 54 | fun `some error upon callback`() { 55 | every { 56 | callackClient.sendProgressCallback( 57 | URI.create(encoreJob.progressCallbackUri!!), 58 | progress, 59 | ) 60 | } throws Exception("error") 61 | 62 | callbackService.sendProgressCallback(encoreJob) 63 | 64 | verify { callackClient.sendProgressCallback(URI.create(encoreJob.progressCallbackUri!!), progress) } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /encore-common/src/test/kotlin/se/svt/oss/encore/service/profile/ProfileServiceTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.service.profile 6 | 7 | import com.fasterxml.jackson.core.JsonProcessingException 8 | import com.fasterxml.jackson.databind.ObjectMapper 9 | import org.junit.jupiter.api.BeforeEach 10 | import org.junit.jupiter.api.Test 11 | import org.springframework.core.io.ClassPathResource 12 | import se.svt.oss.encore.Assertions.assertThat 13 | import se.svt.oss.encore.Assertions.assertThatThrownBy 14 | import se.svt.oss.encore.config.ProfileProperties 15 | import se.svt.oss.encore.defaultEncoreJob 16 | import se.svt.oss.encore.model.profile.GenericVideoEncode 17 | import java.io.IOException 18 | 19 | class ProfileServiceTest { 20 | 21 | private lateinit var profileService: ProfileService 22 | private val objectMapper = ObjectMapper().findAndRegisterModules() 23 | 24 | @BeforeEach 25 | internal fun setUp() { 26 | profileService = ProfileService(ProfileProperties(ClassPathResource("profile/profiles.yml")), objectMapper) 27 | } 28 | 29 | @Test 30 | fun `successfully parses valid yaml profiles`() { 31 | listOf("program-x265", "program").forEach { 32 | profileService.getProfile(jobWithProfile(it)) 33 | } 34 | } 35 | 36 | @Test 37 | fun `successully uses profile params`() { 38 | val profile = profileService.getProfile( 39 | jobWithProfile("archive").copy( 40 | profileParams = mapOf("height" to 1080, "suffix" to "test_suffix"), 41 | ), 42 | ) 43 | assertThat(profile.encodes).describedAs("encodes").hasSize(1) 44 | val outputProducer = profile.encodes.first() 45 | assertThat(outputProducer).isInstanceOf(GenericVideoEncode::class.java) 46 | assertThat(outputProducer as GenericVideoEncode) 47 | .hasHeight(1080) 48 | .hasSuffix("test_suffix") 49 | } 50 | 51 | @Test 52 | fun `invalid yaml throws exception`() { 53 | assertThatThrownBy { profileService.getProfile(jobWithProfile("test-invalid")) } 54 | .isInstanceOf(RuntimeException::class.java) 55 | .hasCauseInstanceOf(JsonProcessingException::class.java) 56 | .hasMessageStartingWith("Error parsing profile test-invalid: Instantiation of [simple type, class se.svt.oss.encore.model.profile.X264Encode] value failed") 57 | } 58 | 59 | @Test 60 | fun `unknown profile throws error`() { 61 | assertThatThrownBy { profileService.getProfile(jobWithProfile("test-non-existing")) } 62 | .isInstanceOf(RuntimeException::class.java) 63 | .hasMessageStartingWith("Could not find location for profile test-non-existing! Profiles: {") 64 | } 65 | 66 | @Test 67 | fun `unreachable profile throws error`() { 68 | assertThatThrownBy { profileService.getProfile(jobWithProfile("test-invalid-location")) } 69 | .isInstanceOf(IOException::class.java) 70 | .hasMessage("class path resource [profile/test_profile_invalid_location.yml] cannot be opened because it does not exist") 71 | } 72 | 73 | @Test 74 | fun `unreachable profiles throws error`() { 75 | profileService = ProfileService(ProfileProperties(ClassPathResource("nonexisting.yml")), objectMapper) 76 | assertThatThrownBy { profileService.getProfile(jobWithProfile("test-profile")) } 77 | .isInstanceOf(IOException::class.java) 78 | .hasMessage("class path resource [nonexisting.yml] cannot be opened because it does not exist") 79 | } 80 | 81 | @Test 82 | fun `profile value empty throw errrors`() { 83 | assertThatThrownBy { profileService.getProfile(jobWithProfile("none")) } 84 | .isInstanceOf(RuntimeException::class.java) 85 | .hasMessageStartingWith("Could not find location for profile none! Profiles: {") 86 | } 87 | 88 | private fun jobWithProfile(profile: String) = defaultEncoreJob().copy(profile = profile) 89 | } 90 | -------------------------------------------------------------------------------- /encore-common/src/test/kotlin/se/svt/oss/encore/service/queue/QueueUtilTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | package se.svt.oss.encore.service.queue 5 | 6 | import org.junit.jupiter.api.Test 7 | import se.svt.oss.encore.Assertions.assertThat 8 | import se.svt.oss.encore.service.queue.QueueUtil.getQueueNumberByPriority 9 | 10 | internal class QueueUtilTest { 11 | 12 | @Test 13 | fun getQueueNumberByPriorityConcurrency1() { 14 | (0..100).forEach { 15 | assertThat(getQueueNumberByPriority(1, it)) 16 | .`as`("queue for $it, concurrency: 1").isEqualTo(0) 17 | } 18 | } 19 | 20 | @Test 21 | fun getQueueNumberByPriorityConcurrency2() { 22 | (0..49).forEach { 23 | assertThat(getQueueNumberByPriority(2, it)) 24 | .`as`("queue for $it, concurrency: 2").isEqualTo(1) 25 | } 26 | (50..100).forEach { 27 | assertThat(getQueueNumberByPriority(2, it)) 28 | .`as`("queue for $it, concurrency: 2").isEqualTo(0) 29 | } 30 | } 31 | 32 | @Test 33 | fun getQueueNumberByPriorityConcurrency3() { 34 | (0..33).forEach { 35 | assertThat(getQueueNumberByPriority(3, it)) 36 | .`as`("queue for $it, concurrency: 3").isEqualTo(2) 37 | } 38 | (34..66).forEach { 39 | assertThat(getQueueNumberByPriority(3, it)) 40 | .`as`("queue for $it, concurrency: 3").isEqualTo(1) 41 | } 42 | (67..100).forEach { 43 | assertThat(getQueueNumberByPriority(3, it)) 44 | .`as`("queue for $it, concurrency: 3").isEqualTo(0) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /encore-common/src/test/resources/application-test-local.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | main: 3 | allow-bean-definition-overriding: true 4 | 5 | logging: 6 | level: 7 | se.svt: debug 8 | 9 | service: 10 | name: encore-test 11 | 12 | encore-settings: 13 | concurrency: 3 14 | local-temporary-encode: true 15 | poll-initial-delay: 1s 16 | poll-delay: 1s 17 | encoding: 18 | audio-mix-presets: 19 | default: 20 | fallback-to-auto: true 21 | de: 22 | fallback-to-auto: false 23 | profile: 24 | location: classpath:profile/profiles.yml 25 | -------------------------------------------------------------------------------- /encore-common/src/test/resources/application-test.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | main: 3 | allow-bean-definition-overriding: true 4 | 5 | management: 6 | endpoint: 7 | health: 8 | show-details: when_authorized 9 | roles: ADMIN 10 | 11 | logging: 12 | level: 13 | se.svt: debug 14 | 15 | encore-settings: 16 | concurrency: 3 17 | local-temporary-encode: false 18 | poll-initial-delay: 1s 19 | poll-delay: 1s 20 | shared-work-dir: ${java.io.tmpdir}/encore-shared 21 | encoding: 22 | default-channel-layouts: 23 | 3: "3.0" 24 | audio-mix-presets: 25 | default: 26 | default-pan: 27 | stereo: FL=FL+0.707107*FC+0.707107*BL+0.707107*SL|FR=FR+0.707107*FC+0.707107*BR+0.707107*SR 28 | pan-mapping: 29 | mono: 30 | stereo: FL=0.707*FC|FR=0.707*FC 31 | de: 32 | fallback-to-auto: false 33 | default-pan: 34 | stereo: FL) { 23 | SpringApplication.run(EncoreApplication::class.java, *args) 24 | } 25 | -------------------------------------------------------------------------------- /encore-web/src/main/kotlin/se/svt/oss/encore/EncoreWebRuntimeHints.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore 6 | 7 | import org.springframework.aot.hint.MemberCategory 8 | import org.springframework.aot.hint.RuntimeHints 9 | import org.springframework.aot.hint.RuntimeHintsRegistrar 10 | import se.svt.oss.encore.handlers.EncoreJobHandler 11 | 12 | class EncoreWebRuntimeHints : RuntimeHintsRegistrar { 13 | override fun registerHints(hints: RuntimeHints, classLoader: ClassLoader?) { 14 | hints.reflection() 15 | .registerType( 16 | EncoreJobHandler::class.java, 17 | MemberCategory.INVOKE_PUBLIC_METHODS, 18 | ) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /encore-web/src/main/kotlin/se/svt/oss/encore/OpenAPIConfiguration.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | package se.svt.oss.encore 5 | 6 | import io.swagger.v3.oas.models.OpenAPI 7 | import io.swagger.v3.oas.models.info.Contact 8 | import io.swagger.v3.oas.models.info.Info 9 | import io.swagger.v3.oas.models.info.License 10 | import org.springframework.context.annotation.Bean 11 | import org.springframework.context.annotation.Configuration 12 | import se.svt.oss.encore.config.EncoreProperties 13 | 14 | @Configuration(proxyBeanMethods = false) 15 | class OpenAPIConfiguration { 16 | 17 | @Bean 18 | fun customOpenAPI(encoreProperties: EncoreProperties): OpenAPI = OpenAPI().info( 19 | Info() 20 | .title(encoreProperties.openApi.title) 21 | .description(encoreProperties.openApi.description) 22 | .contact( 23 | Contact().name(encoreProperties.openApi.contactName) 24 | .url(encoreProperties.openApi.contactUrl) 25 | .email(encoreProperties.openApi.contactEmail), 26 | ).license( 27 | License().name("EUPL-1.2-or-later") 28 | .url("https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12"), 29 | ), 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /encore-web/src/main/kotlin/se/svt/oss/encore/RepositoryConfiguration.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore 6 | 7 | import org.springframework.beans.factory.annotation.Qualifier 8 | import org.springframework.context.annotation.Configuration 9 | import org.springframework.data.rest.core.config.RepositoryRestConfiguration 10 | import org.springframework.data.rest.core.event.ValidatingRepositoryEventListener 11 | import org.springframework.data.rest.webmvc.config.RepositoryRestConfigurer 12 | import org.springframework.validation.Validator 13 | import org.springframework.web.servlet.config.annotation.CorsRegistry 14 | import se.svt.oss.encore.model.EncoreJob 15 | 16 | @Configuration(proxyBeanMethods = false) 17 | class RepositoryConfiguration constructor(@Qualifier("defaultValidator") private val validator: Validator) : RepositoryRestConfigurer { 18 | override fun configureRepositoryRestConfiguration(config: RepositoryRestConfiguration, cors: CorsRegistry?) { 19 | config.exposeIdsFor(EncoreJob::class.java) 20 | } 21 | 22 | override fun configureValidatingRepositoryEventListener(validatingListener: ValidatingRepositoryEventListener?) { 23 | validatingListener?.addValidator("beforeCreate", validator) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /encore-web/src/main/kotlin/se/svt/oss/encore/SchedulingConfiguration.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore 6 | 7 | import org.springframework.context.annotation.Bean 8 | import org.springframework.context.annotation.Configuration 9 | import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler 10 | import se.svt.oss.encore.config.EncoreProperties 11 | 12 | @Configuration(proxyBeanMethods = false) 13 | class SchedulingConfiguration { 14 | 15 | @Bean 16 | fun scheduler(encoreProperties: EncoreProperties): ThreadPoolTaskScheduler { 17 | val taskScheduler = ThreadPoolTaskScheduler() 18 | taskScheduler.poolSize = encoreProperties.concurrency 19 | taskScheduler.setThreadNamePrefix("scheduling-") 20 | taskScheduler.setAwaitTerminationSeconds(6) 21 | return taskScheduler 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /encore-web/src/main/kotlin/se/svt/oss/encore/SecurityConfiguration.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore 6 | 7 | import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointProperties 8 | import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest 9 | import org.springframework.boot.actuate.health.HealthEndpoint 10 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty 11 | import org.springframework.context.annotation.Bean 12 | import org.springframework.context.annotation.Configuration 13 | import org.springframework.http.HttpMethod 14 | import org.springframework.security.config.annotation.web.builders.HttpSecurity 15 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity 16 | import org.springframework.security.config.annotation.web.invoke 17 | import org.springframework.security.core.authority.SimpleGrantedAuthority 18 | import org.springframework.security.core.userdetails.User 19 | import org.springframework.security.core.userdetails.UserDetailsService 20 | import org.springframework.security.provisioning.InMemoryUserDetailsManager 21 | import org.springframework.security.web.SecurityFilterChain 22 | import se.svt.oss.encore.config.EncoreProperties 23 | 24 | private const val ROLE_USER = "USER" 25 | private const val ROLE_ADMIN = "ADMIN" 26 | private const val ROLE_ANON = "ANON" 27 | 28 | @Configuration(proxyBeanMethods = false) 29 | @EnableWebSecurity 30 | @ConditionalOnProperty(prefix = "encore-settings.security", name = ["enabled"]) 31 | class SecurityConfiguration(private val encoreProperties: EncoreProperties) { 32 | 33 | @Bean 34 | fun users(): UserDetailsService { 35 | val user = User.builder() 36 | .username("user") 37 | .password(encoreProperties.security.userPassword) 38 | .roles(ROLE_USER) 39 | .build() 40 | val admin = User.builder() 41 | .username("admin") 42 | .password(encoreProperties.security.adminPassword) 43 | .roles(ROLE_USER, ROLE_ADMIN) 44 | .build() 45 | return InMemoryUserDetailsManager(user, admin) 46 | } 47 | 48 | @Bean 49 | fun filterChain(http: HttpSecurity, webEndPointProperties: WebEndpointProperties): SecurityFilterChain { 50 | http { 51 | headers { httpStrictTransportSecurity { } } 52 | authorizeHttpRequests { 53 | authorize(EndpointRequest.to(HealthEndpoint::class.java), permitAll) 54 | authorize(HttpMethod.GET, "/**", hasRole(ROLE_USER)) 55 | authorize(HttpMethod.PUT, "/**", hasRole(ROLE_ADMIN)) 56 | authorize(HttpMethod.DELETE, "/**", hasRole(ROLE_ADMIN)) 57 | authorize(HttpMethod.POST, "/**", hasRole(ROLE_ADMIN)) 58 | authorize(HttpMethod.PATCH, "/**", hasRole(ROLE_ADMIN)) 59 | authorize(HttpMethod.OPTIONS, "/**", hasRole(ROLE_ADMIN)) 60 | authorize(HttpMethod.TRACE, "/**", hasRole(ROLE_ADMIN)) 61 | authorize(anyRequest, denyAll) 62 | } 63 | httpBasic { } 64 | csrf { disable() } 65 | anonymous { 66 | authorities = listOf(SimpleGrantedAuthority(ROLE_ANON)) 67 | } 68 | } 69 | return http.build() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /encore-web/src/main/kotlin/se/svt/oss/encore/controller/EncoreController.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.controller 6 | 7 | import io.github.oshai.kotlinlogging.withLoggingContext 8 | import io.swagger.v3.oas.annotations.Operation 9 | import org.springframework.data.redis.core.RedisTemplate 10 | import org.springframework.data.repository.findByIdOrNull 11 | import org.springframework.http.HttpStatus 12 | import org.springframework.http.ResponseEntity 13 | import org.springframework.web.bind.annotation.CrossOrigin 14 | import org.springframework.web.bind.annotation.GetMapping 15 | import org.springframework.web.bind.annotation.PathVariable 16 | import org.springframework.web.bind.annotation.PostMapping 17 | import org.springframework.web.bind.annotation.RestController 18 | import se.svt.oss.encore.model.CancelEvent 19 | import se.svt.oss.encore.model.RedisEvent 20 | import se.svt.oss.encore.model.Status 21 | import se.svt.oss.encore.repository.EncoreJobRepository 22 | import se.svt.oss.encore.service.queue.QueueService 23 | import java.util.UUID 24 | 25 | @CrossOrigin 26 | @RestController 27 | class EncoreController( 28 | private val repository: EncoreJobRepository, 29 | private val redisTemplate: RedisTemplate, 30 | private val queueService: QueueService, 31 | ) { 32 | 33 | @Operation(summary = "Get Queues", description = "Returns a list of queues (QueueItems)", tags = ["queue"]) 34 | @GetMapping("/queue") 35 | fun getQueue() = queueService.getQueue() 36 | 37 | @Operation(summary = "Cancel an EncoreJob", description = "Cancels an EncoreJob with thw given JobId", tags = ["encorejob"]) 38 | @PostMapping("/encoreJobs/{jobId}/cancel") 39 | fun cancel(@PathVariable("jobId") jobId: UUID): ResponseEntity = 40 | try { 41 | repository.findByIdOrNull(jobId)?.let { 42 | withLoggingContext(it.contextMap) { 43 | when (it.status) { 44 | Status.NEW, Status.QUEUED -> { 45 | it.status = Status.CANCELLED 46 | repository.save(it) 47 | sendCancelEvent(jobId) 48 | ResponseEntity.ok("Ok") 49 | } 50 | Status.IN_PROGRESS -> { 51 | sendCancelEvent(jobId) 52 | ResponseEntity.ok("Ok") 53 | } 54 | else -> { 55 | ResponseEntity 56 | .status(HttpStatus.CONFLICT) 57 | .body("Cannot cancel job with status ${it.status}!") 58 | } 59 | } 60 | } 61 | } ?: ResponseEntity 62 | .status(HttpStatus.NOT_FOUND) 63 | .body("Job $jobId does not exist!") 64 | } catch (e: Exception) { 65 | ResponseEntity 66 | .status(HttpStatus.INTERNAL_SERVER_ERROR) 67 | .body(e.message) 68 | } 69 | 70 | private fun sendCancelEvent(jobId: UUID) { 71 | redisTemplate.convertAndSend("cancel", CancelEvent(jobId)) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /encore-web/src/main/kotlin/se/svt/oss/encore/handlers/EncoreJobHandler.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.handlers 6 | 7 | import io.github.oshai.kotlinlogging.KotlinLogging 8 | import io.github.oshai.kotlinlogging.withLoggingContext 9 | import org.springframework.data.rest.core.annotation.HandleAfterCreate 10 | import org.springframework.data.rest.core.annotation.RepositoryEventHandler 11 | import org.springframework.stereotype.Component 12 | import se.svt.oss.encore.model.EncoreJob 13 | import se.svt.oss.encore.model.Status 14 | import se.svt.oss.encore.repository.EncoreJobRepository 15 | import se.svt.oss.encore.service.queue.QueueService 16 | 17 | private val log = KotlinLogging.logger { } 18 | 19 | @Component 20 | @RepositoryEventHandler 21 | class EncoreJobHandler( 22 | private val queueService: QueueService, 23 | private val repository: EncoreJobRepository, 24 | ) { 25 | 26 | @HandleAfterCreate 27 | fun onAfterCreate(encoreJob: EncoreJob) { 28 | withLoggingContext(encoreJob.contextMap) { 29 | try { 30 | log.info { "Adding job to queue.. $encoreJob" } 31 | queueService.enqueue(encoreJob) 32 | log.info { "Added job to queue" } 33 | encoreJob.status = Status.QUEUED 34 | repository.save(encoreJob) 35 | } catch (e: Exception) { 36 | val message = "Failed to queue: ${e.message}" 37 | log.error(e) { message } 38 | encoreJob.status = Status.FAILED 39 | encoreJob.message = message 40 | repository.save(encoreJob) 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /encore-web/src/main/kotlin/se/svt/oss/encore/poll/JobPoller.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.poll 6 | 7 | import io.github.oshai.kotlinlogging.KotlinLogging 8 | import jakarta.annotation.PostConstruct 9 | import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler 10 | import org.springframework.stereotype.Service 11 | import se.svt.oss.encore.config.EncoreProperties 12 | import se.svt.oss.encore.service.EncoreService 13 | import se.svt.oss.encore.service.queue.QueueService 14 | import java.time.Instant 15 | import java.util.concurrent.ScheduledFuture 16 | 17 | private val log = KotlinLogging.logger {} 18 | 19 | @Service 20 | class JobPoller( 21 | private val queueService: QueueService, 22 | private val encoreService: EncoreService, 23 | private val scheduler: ThreadPoolTaskScheduler, 24 | private val encoreProperties: EncoreProperties, 25 | ) { 26 | private var scheduledTasks = emptyList>() 27 | 28 | @PostConstruct 29 | fun init() { 30 | queueService.migrateQueues() 31 | queueService.handleOrphanedQueues() 32 | if (encoreProperties.pollDisabled) { 33 | return 34 | } 35 | val pollQueue = encoreProperties.pollQueue 36 | scheduledTasks = if (pollQueue != null) { 37 | listOf(scheduledFuture(pollQueue)) 38 | } else { 39 | (0 until encoreProperties.concurrency).map { queueNo -> 40 | scheduledFuture(queueNo) 41 | } 42 | } 43 | Runtime.getRuntime().addShutdownHook( 44 | Thread { 45 | scheduledTasks.forEach { it.cancel(false) } 46 | }, 47 | ) 48 | } 49 | 50 | private fun scheduledFuture(queueNo: Int): ScheduledFuture<*> = 51 | scheduler.scheduleWithFixedDelay( 52 | { 53 | try { 54 | queueService.poll(queueNo, encoreService::encode) 55 | } catch (e: Throwable) { 56 | log.error(e) { "Error polling queue $queueNo!" } 57 | } 58 | }, 59 | Instant.now().plus(encoreProperties.pollInitialDelay), 60 | encoreProperties.pollDelay, 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /encore-web/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: encore 4 | banner: 5 | location: classpath:asciilogo.txt 6 | cloud: 7 | config: 8 | import-check: 9 | enabled: false 10 | threads: 11 | virtual: 12 | enabled: true 13 | logging: 14 | config: classpath:logback-json.xml 15 | 16 | springdoc: 17 | paths-to-exclude: /profile/encoreJobs,/profile 18 | swagger-ui: 19 | operations-sorter: alpha 20 | tags-sorter: alpha 21 | disable-swagger-default-url: true 22 | server: 23 | forward-headers-strategy: framework 24 | -------------------------------------------------------------------------------- /encore-web/src/main/resources/asciilogo.txt: -------------------------------------------------------------------------------- 1 | ``````` 2 | ``.--:/+ossssyysssoo+/:-.``` 3 | ``-:/syhdmmNNNMMMMMMMMMNNNNmddyso:-.` 4 | `.:oydmNMMMMMMNNNmmmmmmmmNNNMMMMMMMNmdho/-.-+/- 5 | `-/ydNNMMMNNmdyso//:--.....--::++oyhmmNMMMNNmmmNy+ 6 | `-:ydNMMMNmdyo:-.``` ```.-/+yhmMMMMMMho 7 | `.ohmMMMNmho/-`` ./hNMMMMMds 8 | `-ohNMMMmd+:.` .:shdddmmhs` 9 | -+dNMMNmo/.` ``...--:-. 10 | `-ydMMMms+`` `...-:/:. 11 | .+dNMMmd:. `-ohhdmmNhs `.-`` 12 | `.ydMMNd+: ./dMMMMMMNd..` `.shdy+:`` .-/:- 13 | -oNMMNd/. `/hNMMMMMMMhyso+/osNMMMNms+. `ohNdh-` 14 | -omMMMy+` `.--` `.+smMMMMMMMMMMMNNNNMMMMMMMNh+` `sdMMNs/` 15 | `sdMMNd:` `:ydds+:/shNNMMMMMMMMNNMMMMMMMMMMMMMho. :oNMMNs: 16 | .:mNMNh/` `ohNMMMNmNNMMMMNmdhyo+++ooyhmNMMMMMMMs/ `-hmMMdy` 17 | :oMMMdo. -+mNMMMMMMMMMMmdo/..``` ````.-oymNMMMMds-` `/hNMNm-. 18 | `ohMMMo: -smNMMMMMMMMNs+. `:sdMMMMNh:-..-.` `odMMMo: 19 | `-yNMMN:. ``/odNMMMMNho`` `.-++o++/.` .-dmMMMMmdhddh+. /yMMMy+ 20 | `/hMMNd.` -yNMMMMh+. `-ohmNNNNNmhs:` /yNMMMMMMMMNy/ -+MMMdo` 21 | `+dMMmy` `:hNMMMN/- +yNMMMMMMMMMNd/. `+hMMMMMMMMMhs .:NMMms- 22 | `omMMds` `-+mMMMMN-. `-hmMMMMMMMMMMMMho -oMMMMMMMMNdy` `-NNMNy-` 23 | .smMMds` `.++ohdMMMMNm-` `:dNMMMMMMMMMMMMds -+NMMMMmhso/: `-NNMNy-` 24 | .smMMds` -/NNMMMMMMMMN-. `-hmMMMMMMMMMMMMho :oMMMMms-`` .-NNMNy-` 25 | `odMMmy` .-mNMMMMMMMMM+- +yNMMMMMMMMMNd/. `+dMMMMd/` ./NMMms-` 26 | `/dMMNd.` ``hmMMMMMMMMMd+. `-ohmNNNNNmhs:` `/yNMMMNh-` :oMMMdo. 27 | `-yNMNN:. oymdddmNMMMMds.` `.-++o++/.` .:dNMMMMNms:. /yMMMy+ 28 | .odMMMo: ..--..+sNMMMMNy+.` ` `:ydMMMMMMMMNmh-` .sdMMMo: 29 | /sMMMdo. `.sdMMMMMNdo+-.```````.-:oyNNMMMMMMMMMMmy.` `/hNMNm-. 30 | .:mNMNh/` `-ymMMMMMMNNdhyssossyhdmNMMMMMNmNNMMNm+- `-hmMMdy` 31 | `ydMMNd-` `+hNMMMMMMMMMMMMMMMMMMMMMMMNms+/osddh/. :oNMMms: 32 | -smMMNo: `:dNMMMMMMMNNNNMMMMMMMMMMMNy+:`` ``--.` `-ymMMNs:` 33 | -+mmm+: `.+ydNMMMNdo++osydmMMMMMMMm/. `:yNMMmh-` 34 | ``:::.` .-oydds:` ```/oMMMMMMMm+. ``ohNMMNs/ 35 | .--. .:NNNmmdhy:. .:sNMMNm+-` 36 | ` `///:-..` .-ydNMMNs+` 37 | .:+//:::--` `./sdMMMNdo-` 38 | /yNNNNNNdy.` `.:oyNNMMNmo:` 39 | :sMMMMMMds-.` `.:oymNMMMNho:` 40 | :+MMMMMMNNdys/:.``` ``.-/+yhmNMMMNdyo.` 41 | -/mdyhdNNMMMNNmdys+//:---------::/+oyhmmNMMMNNdyo-.` 42 | `.:-``./oydmNMMMMMMMNNmmmmmmmmmNNMMMMMMNNmdyo/.` 43 | ``-:/syhddmNNNNMMMMMMMNNNNmdhhso+:-` 44 | ``.--:/++oossyysso++/:-..`` 45 | 46 | -------------------------------------------------------------------------------- /encore-web/src/main/resources/logback-json.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /encore-web/src/test/kotlin/se/svt/oss/encore/EncoreWebRuntimeHintsTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore 6 | 7 | import org.junit.jupiter.api.Test 8 | import org.springframework.aot.hint.RuntimeHints 9 | import org.springframework.aot.hint.predicate.RuntimeHintsPredicates 10 | import se.svt.oss.encore.Assertions.assertThat 11 | import se.svt.oss.encore.handlers.EncoreJobHandler 12 | import kotlin.reflect.jvm.javaMethod 13 | 14 | class EncoreWebRuntimeHintsTest { 15 | @Test 16 | fun shouldRegisterHints() { 17 | val hints = RuntimeHints() 18 | EncoreWebRuntimeHints().registerHints(hints, javaClass.classLoader) 19 | assertThat(RuntimeHintsPredicates.reflection().onMethod(EncoreJobHandler::onAfterCreate.javaMethod!!)).accepts(hints) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /encore-web/src/test/kotlin/se/svt/oss/encore/handlers/EncoreJobHandlerTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.handlers 6 | 7 | import io.mockk.Runs 8 | import io.mockk.every 9 | import io.mockk.impl.annotations.InjectMockKs 10 | import io.mockk.impl.annotations.MockK 11 | import io.mockk.junit5.MockKExtension 12 | import io.mockk.just 13 | import io.mockk.verify 14 | import org.junit.jupiter.api.BeforeEach 15 | import org.junit.jupiter.api.Test 16 | import org.junit.jupiter.api.extension.ExtendWith 17 | import se.svt.oss.encore.Assertions.assertThat 18 | import se.svt.oss.encore.model.EncoreJob 19 | import se.svt.oss.encore.model.Status 20 | import se.svt.oss.encore.repository.EncoreJobRepository 21 | import se.svt.oss.encore.service.queue.QueueService 22 | 23 | @ExtendWith(MockKExtension::class) 24 | class EncoreJobHandlerTest { 25 | 26 | @MockK 27 | private lateinit var queueService: QueueService 28 | 29 | @MockK 30 | private lateinit var repository: EncoreJobRepository 31 | 32 | @InjectMockKs 33 | private lateinit var encoreJobHandler: EncoreJobHandler 34 | 35 | private val job = EncoreJob(baseName = "TEST", outputFolder = "/test", profile = "test") 36 | 37 | @BeforeEach 38 | fun setUp() { 39 | every { repository.save(job) } returns job 40 | } 41 | 42 | @Test 43 | fun `successfully creates`() { 44 | every { queueService.enqueue(job) } just Runs 45 | 46 | encoreJobHandler.onAfterCreate(job) 47 | assertThat(job.status).isEqualTo(Status.QUEUED) 48 | 49 | verify { repository.save(job) } 50 | verify { queueService.enqueue(job) } 51 | } 52 | 53 | @Test 54 | fun `enqueue fails`() { 55 | every { queueService.enqueue(job) } throws Exception("error") 56 | 57 | encoreJobHandler.onAfterCreate(job) 58 | 59 | assertThat(job.status).isEqualTo(Status.FAILED) 60 | 61 | verify { repository.save(job) } 62 | verify { queueService.enqueue(job) } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /encore-web/src/test/kotlin/se/svt/oss/encore/poll/JobPollerTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore.poll 6 | 7 | import io.mockk.Called 8 | import io.mockk.Runs 9 | import io.mockk.every 10 | import io.mockk.impl.annotations.InjectMockKs 11 | import io.mockk.impl.annotations.MockK 12 | import io.mockk.junit5.MockKExtension 13 | import io.mockk.just 14 | import io.mockk.mockk 15 | import io.mockk.verify 16 | import io.mockk.verifySequence 17 | import org.junit.jupiter.api.BeforeEach 18 | import org.junit.jupiter.api.Test 19 | import org.junit.jupiter.api.extension.ExtendWith 20 | import org.junit.jupiter.params.ParameterizedTest 21 | import org.junit.jupiter.params.provider.ValueSource 22 | import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler 23 | import se.svt.oss.encore.Assertions.assertThat 24 | import se.svt.oss.encore.config.EncoreProperties 25 | import se.svt.oss.encore.model.EncoreJob 26 | import se.svt.oss.encore.model.queue.QueueItem 27 | import se.svt.oss.encore.service.EncoreService 28 | import se.svt.oss.encore.service.queue.QueueService 29 | import java.time.Duration 30 | import java.time.Instant 31 | import java.util.concurrent.ScheduledFuture 32 | 33 | @ExtendWith(MockKExtension::class) 34 | class JobPollerTest { 35 | 36 | @MockK 37 | private lateinit var queueService: QueueService 38 | 39 | @MockK 40 | private lateinit var encoreProperties: EncoreProperties 41 | 42 | @MockK 43 | private lateinit var encoreService: EncoreService 44 | 45 | @MockK 46 | private lateinit var scheduler: ThreadPoolTaskScheduler 47 | 48 | @InjectMockKs 49 | private lateinit var jobPoller: JobPoller 50 | 51 | private val encoreJob = EncoreJob(baseName = "TEST", outputFolder = "/test", profile = "test") 52 | 53 | private val queueItem = QueueItem(encoreJob.id.toString()) 54 | 55 | private val capturedRunnables = mutableListOf() 56 | private val scheduledTasks = mutableListOf>() 57 | 58 | @BeforeEach 59 | fun setUp() { 60 | every { scheduler.scheduleWithFixedDelay(capture(capturedRunnables), any(), any()) } answers { 61 | val scheduled = mockk>(relaxed = true) 62 | scheduledTasks.add(scheduled) 63 | scheduled 64 | } 65 | every { encoreService.encode(any(), any()) } just Runs 66 | every { queueService.poll(any(), captureLambda()) } answers { 67 | lambda<(QueueItem, EncoreJob) -> Unit>().captured.invoke(queueItem, encoreJob) 68 | true 69 | } 70 | every { queueService.handleOrphanedQueues() } just Runs 71 | every { queueService.migrateQueues() } just Runs 72 | every { encoreProperties.concurrency } returns 3 73 | every { encoreProperties.pollDelay } returns Duration.ofSeconds(1) 74 | every { encoreProperties.pollInitialDelay } returns Duration.ofSeconds(10) 75 | every { encoreProperties.pollQueue } returns null 76 | every { encoreProperties.pollDisabled } returns false 77 | } 78 | 79 | @Test 80 | fun doesNothingWhenPollDisabled() { 81 | every { encoreProperties.pollDisabled } returns true 82 | jobPoller.init() 83 | verify { scheduler wasNot Called } 84 | verify { encoreService wasNot Called } 85 | verify { queueService.handleOrphanedQueues() } 86 | assertThat(capturedRunnables).isEmpty() 87 | } 88 | 89 | @ParameterizedTest 90 | @ValueSource(ints = [0, 1, 2]) 91 | fun pollAll(thread: Int) { 92 | jobPoller.init() 93 | assertThat(capturedRunnables).hasSize(3) 94 | capturedRunnables[thread].run() 95 | 96 | verifySequence { 97 | queueService.migrateQueues() 98 | queueService.handleOrphanedQueues() 99 | queueService.poll(thread, any()) 100 | encoreService.encode(queueItem, encoreJob) 101 | } 102 | } 103 | 104 | @ParameterizedTest 105 | @ValueSource(ints = [0, 1, 2]) 106 | fun pollSpecific(queueNo: Int) { 107 | every { encoreProperties.pollQueue } returns queueNo 108 | jobPoller.init() 109 | assertThat(capturedRunnables).hasSize(1) 110 | capturedRunnables.first().run() 111 | verifySequence { 112 | queueService.migrateQueues() 113 | queueService.handleOrphanedQueues() 114 | queueService.poll(queueNo, any()) 115 | encoreService.encode(queueItem, encoreJob) 116 | } 117 | } 118 | 119 | @ParameterizedTest 120 | @ValueSource(ints = [0, 1, 2]) 121 | fun `poll causes exception`(thread: Int) { 122 | every { queueService.poll(thread, any()) } throws Exception("error") 123 | jobPoller.init() 124 | 125 | capturedRunnables[thread].run() 126 | 127 | verifySequence { 128 | queueService.migrateQueues() 129 | queueService.handleOrphanedQueues() 130 | queueService.poll(thread, any()) 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /encore-web/src/test/resources/application-test.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | main: 3 | allow-bean-definition-overriding: true 4 | management: 5 | endpoint: 6 | health: 7 | show-details: when_authorized 8 | roles: ADMIN 9 | probes: 10 | enabled: true 11 | 12 | logging: 13 | level: 14 | se.svt: debug 15 | 16 | encore-settings: 17 | concurrency: 3 18 | local-temporary-encode: false 19 | poll-initial-delay: 1s 20 | poll-delay: 1s 21 | security: 22 | user-password: '{bcrypt}$2a$10$5UQDlMEE6PDHNtr.pY/Jh.06Dq0BTkFtQyYNQint/R0KhC4muu4PO' # 'upw' encypted with spring boot cli 23 | admin-password: '{bcrypt}$2a$10$Yp8gtLGnpyFtlyOrL6/ajOO/hPXOCKMf4IpCW41ptMUzAUpJmmGOC' # 'apw' encypted with spring boot cli 24 | encoding: 25 | default-channel-layouts: 26 | 3: "3.0" 27 | audio-mix-presets: 28 | default: 29 | default-pan: 30 | stereo: FL=FL+0.707107*FC+0.707107*BL+0.707107*SL|FR=FR+0.707107*FC+0.707107*BR+0.707107*SR 31 | pan-mapping: 32 | mono: 33 | stereo: FL=0.707*FC|FR=0.707*FC 34 | de: 35 | fallback-to-auto: false 36 | default-pan: 37 | stereo: FL) { 51 | runApplication(* args) 52 | } 53 | -------------------------------------------------------------------------------- /encore-worker/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: encore 4 | banner: 5 | location: classpath:asciilogo.txt 6 | main: 7 | web-application-type: none 8 | cloud: 9 | config: 10 | import-check: 11 | enabled: false 12 | threads: 13 | virtual: 14 | enabled: true 15 | logging: 16 | config: classpath:logback-json.xml -------------------------------------------------------------------------------- /encore-worker/src/main/resources/asciilogo.txt: -------------------------------------------------------------------------------- 1 | ``````` 2 | ``.--:/+ossssyysssoo+/:-.``` 3 | ``-:/syhdmmNNNMMMMMMMMMNNNNmddyso:-.` 4 | `.:oydmNMMMMMMNNNmmmmmmmmNNNMMMMMMMNmdho/-.-+/- 5 | `-/ydNNMMMNNmdyso//:--.....--::++oyhmmNMMMNNmmmNy+ 6 | `-:ydNMMMNmdyo:-.``` ```.-/+yhmMMMMMMho 7 | `.ohmMMMNmho/-`` ./hNMMMMMds 8 | `-ohNMMMmd+:.` .:shdddmmhs` 9 | -+dNMMNmo/.` ``...--:-. 10 | `-ydMMMms+`` `...-:/:. 11 | .+dNMMmd:. `-ohhdmmNhs `.-`` 12 | `.ydMMNd+: ./dMMMMMMNd..` `.shdy+:`` .-/:- 13 | -oNMMNd/. `/hNMMMMMMMhyso+/osNMMMNms+. `ohNdh-` 14 | -omMMMy+` `.--` `.+smMMMMMMMMMMMNNNNMMMMMMMNh+` `sdMMNs/` 15 | `sdMMNd:` `:ydds+:/shNNMMMMMMMMNNMMMMMMMMMMMMMho. :oNMMNs: 16 | .:mNMNh/` `ohNMMMNmNNMMMMNmdhyo+++ooyhmNMMMMMMMs/ `-hmMMdy` 17 | :oMMMdo. -+mNMMMMMMMMMMmdo/..``` ````.-oymNMMMMds-` `/hNMNm-. 18 | `ohMMMo: -smNMMMMMMMMNs+. `:sdMMMMNh:-..-.` `odMMMo: 19 | `-yNMMN:. ``/odNMMMMNho`` `.-++o++/.` .-dmMMMMmdhddh+. /yMMMy+ 20 | `/hMMNd.` -yNMMMMh+. `-ohmNNNNNmhs:` /yNMMMMMMMMNy/ -+MMMdo` 21 | `+dMMmy` `:hNMMMN/- +yNMMMMMMMMMNd/. `+hMMMMMMMMMhs .:NMMms- 22 | `omMMds` `-+mMMMMN-. `-hmMMMMMMMMMMMMho -oMMMMMMMMNdy` `-NNMNy-` 23 | .smMMds` `.++ohdMMMMNm-` `:dNMMMMMMMMMMMMds -+NMMMMmhso/: `-NNMNy-` 24 | .smMMds` -/NNMMMMMMMMN-. `-hmMMMMMMMMMMMMho :oMMMMms-`` .-NNMNy-` 25 | `odMMmy` .-mNMMMMMMMMM+- +yNMMMMMMMMMNd/. `+dMMMMd/` ./NMMms-` 26 | `/dMMNd.` ``hmMMMMMMMMMd+. `-ohmNNNNNmhs:` `/yNMMMNh-` :oMMMdo. 27 | `-yNMNN:. oymdddmNMMMMds.` `.-++o++/.` .:dNMMMMNms:. /yMMMy+ 28 | .odMMMo: ..--..+sNMMMMNy+.` ` `:ydMMMMMMMMNmh-` .sdMMMo: 29 | /sMMMdo. `.sdMMMMMNdo+-.```````.-:oyNNMMMMMMMMMMmy.` `/hNMNm-. 30 | .:mNMNh/` `-ymMMMMMMNNdhyssossyhdmNMMMMMNmNNMMNm+- `-hmMMdy` 31 | `ydMMNd-` `+hNMMMMMMMMMMMMMMMMMMMMMMMNms+/osddh/. :oNMMms: 32 | -smMMNo: `:dNMMMMMMMNNNNMMMMMMMMMMMNy+:`` ``--.` `-ymMMNs:` 33 | -+mmm+: `.+ydNMMMNdo++osydmMMMMMMMm/. `:yNMMmh-` 34 | ``:::.` .-oydds:` ```/oMMMMMMMm+. ``ohNMMNs/ 35 | .--. .:NNNmmdhy:. .:sNMMNm+-` 36 | ` `///:-..` .-ydNMMNs+` 37 | .:+//:::--` `./sdMMMNdo-` 38 | /yNNNNNNdy.` `.:oyNNMMNmo:` 39 | :sMMMMMMds-.` `.:oymNMMMNho:` 40 | :+MMMMMMNNdys/:.``` ``.-/+yhmNMMMNdyo.` 41 | -/mdyhdNNMMMNNmdys+//:---------::/+oyhmmNMMMNNdyo-.` 42 | `.:-``./oydmNMMMMMMMNNmmmmmmmmmNNMMMMMMNNmdyo/.` 43 | ``-:/syhddmNNNNMMMMMMMNNNNmdhhso+:-` 44 | ``.--:/++oossyysso++/:-..`` 45 | 46 | -------------------------------------------------------------------------------- /encore-worker/src/main/resources/logback-json.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /encore-worker/src/test/kotlin/se/svt/oss/encore/EncoreWorkerApplicationTest.kt: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2020 Sveriges Television AB 2 | // 3 | // SPDX-License-Identifier: EUPL-1.2 4 | 5 | package se.svt.oss.encore 6 | 7 | import io.mockk.every 8 | import io.mockk.impl.annotations.InjectMockKs 9 | import io.mockk.impl.annotations.MockK 10 | import io.mockk.junit5.MockKExtension 11 | import io.mockk.mockkStatic 12 | import io.mockk.unmockkAll 13 | import io.mockk.verify 14 | import org.junit.jupiter.api.AfterEach 15 | import org.junit.jupiter.api.BeforeEach 16 | import org.junit.jupiter.api.Test 17 | import org.junit.jupiter.api.extension.ExtendWith 18 | import org.springframework.boot.SpringApplication 19 | import org.springframework.context.ApplicationContext 20 | import se.svt.oss.encore.config.EncoreProperties 21 | import se.svt.oss.encore.service.EncoreService 22 | import se.svt.oss.encore.service.queue.QueueService 23 | 24 | @ExtendWith(MockKExtension::class) 25 | class EncoreWorkerApplicationTest { 26 | 27 | @MockK 28 | private lateinit var queueService: QueueService 29 | 30 | @MockK 31 | private lateinit var encoreService: EncoreService 32 | 33 | @MockK 34 | private lateinit var applicationContext: ApplicationContext 35 | 36 | @MockK 37 | private lateinit var encoreProperties: EncoreProperties 38 | 39 | @InjectMockKs 40 | lateinit var application: EncoreWorkerApplication 41 | 42 | @BeforeEach 43 | fun setUp() { 44 | every { queueService.poll(any(), any()) } returns true andThen false 45 | every { encoreProperties.pollQueue } returns 1 46 | every { encoreProperties.workerDrainQueue } returns false 47 | mockkStatic(SpringApplication::class) 48 | every { SpringApplication.exit(any()) } returns 0 49 | } 50 | 51 | @AfterEach 52 | fun tearDown() { 53 | unmockkAll() 54 | } 55 | 56 | @Test 57 | fun pollOnce() { 58 | application.run() 59 | verify(exactly = 1) { queueService.poll(1, encoreService::encode) } 60 | verify { SpringApplication.exit(applicationContext) } 61 | } 62 | 63 | @Test 64 | fun defaultsToQueue0() { 65 | every { encoreProperties.pollQueue } returns null 66 | application.run() 67 | verify(exactly = 1) { queueService.poll(0, encoreService::encode) } 68 | verify { SpringApplication.exit(applicationContext) } 69 | } 70 | 71 | @Test 72 | fun drainQueue() { 73 | every { encoreProperties.workerDrainQueue } returns true 74 | application.run() 75 | verify(exactly = 2) { queueService.poll(1, encoreService::encode) } 76 | verify { SpringApplication.exit(applicationContext) } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /encore_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svt/encore/92d62cce8697e6cbdd99fbbb1161dcf79b763a75/encore_logo.png -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svt/encore/92d62cce8697e6cbdd99fbbb1161dcf79b763a75/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "encore" 2 | include("encore-common") 3 | include("encore-web") 4 | include("encore-worker") --------------------------------------------------------------------------------