Skip to content

Commit 1897aaa

Browse files
committed
Added support for spring spel expressions in profiles.
See https://docs.spring.io/spring-framework/reference/core/expressions.html. Profiles are parsed as spel expression templates with EncoreJob as root object. Added field profileParams to EncoreJob to be used in profile templates.
2 parents 996ef2e + 80b09a5 commit 1897aaa

File tree

6 files changed

+109
-32
lines changed

6 files changed

+109
-32
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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+
)

encore-common/src/main/kotlin/se/svt/oss/encore/model/EncoreJob.kt

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ package se.svt.oss.encore.model
77
import com.fasterxml.jackson.annotation.JsonIgnore
88
import io.swagger.v3.oas.annotations.media.Schema
99
import io.swagger.v3.oas.annotations.tags.Tag
10+
import jakarta.validation.constraints.Max
11+
import jakarta.validation.constraints.Min
12+
import jakarta.validation.constraints.NotBlank
13+
import jakarta.validation.constraints.NotEmpty
14+
import jakarta.validation.constraints.Positive
1015
import org.springframework.data.annotation.Id
1116
import org.springframework.data.redis.core.RedisHash
1217
import org.springframework.data.redis.core.index.Indexed
@@ -15,11 +20,6 @@ import se.svt.oss.encore.model.input.Input
1520
import se.svt.oss.mediaanalyzer.file.MediaFile
1621
import java.time.OffsetDateTime
1722
import java.util.UUID
18-
import jakarta.validation.constraints.Max
19-
import jakarta.validation.constraints.Min
20-
import jakarta.validation.constraints.NotBlank
21-
import jakarta.validation.constraints.NotEmpty
22-
import jakarta.validation.constraints.Positive
2323

2424
@Validated
2525
@RedisHash("encore-jobs", timeToLive = (60 * 60 * 24 * 7).toLong()) // 1 week ttl
@@ -51,6 +51,12 @@ data class EncoreJob(
5151
@NotBlank
5252
val profile: String,
5353

54+
@Schema(
55+
description = "Properties for evaluation of spring spel expressions in profile",
56+
defaultValue = "{}"
57+
)
58+
val profileParams: Map<String, Any?> = emptyMap(),
59+
5460
@Schema(
5561
description = "A directory path to where the output should be written",
5662
example = "/an/output/path/dir",

encore-common/src/main/kotlin/se/svt/oss/encore/service/FfmpegExecutor.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ class FfmpegExecutor(
4242
outputFolder: String,
4343
progressChannel: SendChannel<Int>?
4444
): List<MediaFile> {
45-
val profile = profileService.getProfile(encoreJob.profile)
45+
val profile = profileService.getProfile(encoreJob)
4646
val outputs = profile.encodes.mapNotNull {
4747
it.getOutput(
4848
encoreJob,

encore-common/src/main/kotlin/se/svt/oss/encore/service/profile/ProfileService.kt

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,14 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLMapper
1111
import com.fasterxml.jackson.module.kotlin.readValue
1212
import mu.KotlinLogging
1313
import org.springframework.aot.hint.annotation.RegisterReflectionForBinding
14-
import org.springframework.beans.factory.annotation.Value
15-
import org.springframework.core.io.Resource
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
1619
import org.springframework.stereotype.Service
20+
import se.svt.oss.encore.config.ProfileProperties
21+
import se.svt.oss.encore.model.EncoreJob
1722
import se.svt.oss.encore.model.profile.AudioEncode
1823
import se.svt.oss.encore.model.profile.GenericVideoEncode
1924
import se.svt.oss.encore.model.profile.OutputProducer
@@ -38,35 +43,65 @@ import java.util.Locale
3843
ThumbnailEncode::class,
3944
ThumbnailMapEncode::class
4045
)
46+
@EnableConfigurationProperties(ProfileProperties::class)
4147
class ProfileService(
42-
@Value("\${profile.location}")
43-
private val profileLocation: Resource,
48+
private val properties: ProfileProperties,
4449
objectMapper: ObjectMapper
4550
) {
4651
private val log = KotlinLogging.logger { }
4752

53+
private val spelExpressionParser = SpelExpressionParser(
54+
SpelParserConfiguration(
55+
null,
56+
null,
57+
false,
58+
false,
59+
Int.MAX_VALUE,
60+
100_000
61+
)
62+
)
63+
64+
private val spelEvaluationContext = SimpleEvaluationContext
65+
.forReadOnlyDataBinding()
66+
.build()
67+
68+
private val spelParserContext = TemplateParserContext(
69+
properties.spelExpressionPrefix,
70+
properties.spelExpressionSuffix
71+
)
72+
4873
private val mapper =
49-
if (profileLocation.filename?.let { File(it).extension.lowercase(Locale.getDefault()) in setOf("yml", "yaml") } == true) {
74+
if (properties.location.filename?.let {
75+
File(it).extension.lowercase(Locale.getDefault()) in setOf(
76+
"yml",
77+
"yaml"
78+
)
79+
} == true
80+
) {
5081
yamlMapper()
5182
} else {
5283
objectMapper
5384
}
5485

55-
fun getProfile(name: String): Profile = try {
56-
log.debug { "Get profile $name. Reading profiles from $profileLocation" }
57-
val profiles = mapper.readValue<Map<String, String>>(profileLocation.inputStream)
86+
fun getProfile(job: EncoreJob): Profile = try {
87+
log.debug { "Get profile ${job.profile}. Reading profiles from ${properties.location}" }
88+
val profiles = mapper.readValue<Map<String, String>>(properties.location.inputStream)
5889

59-
profiles[name]
60-
?.let { readProfile(it) }
61-
?: throw RuntimeException("Could not find location for profile $name! Profiles: $profiles")
90+
profiles[job.profile]
91+
?.let { readProfile(it, job) }
92+
?: throw RuntimeException("Could not find location for profile ${job.profile}! Profiles: $profiles")
6293
} catch (e: JsonProcessingException) {
63-
throw RuntimeException("Error parsing profile $name: ${e.message}", e)
94+
throw RuntimeException("Error parsing profile ${job.profile}: ${e.message}", e)
6495
}
6596

66-
private fun readProfile(path: String): Profile {
67-
val profile = profileLocation.createRelative(path)
97+
private fun readProfile(path: String, job: EncoreJob): Profile {
98+
val profile = properties.location.createRelative(path)
6899
log.debug { "Reading $profile" }
69-
return mapper.readValue(profile.inputStream)
100+
val profileContent = profile.inputStream.bufferedReader().use { it.readText() }
101+
val resolvedProfileContent = spelExpressionParser
102+
.parseExpression(profileContent, spelParserContext)
103+
.getValue(spelEvaluationContext, job) as String
104+
return mapper.readValue(resolvedProfileContent)
70105
}
71106

72107
private fun yamlMapper() =

encore-common/src/test/kotlin/se/svt/oss/encore/service/profile/ProfileServiceTest.kt

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import com.fasterxml.jackson.databind.ObjectMapper
99
import org.junit.jupiter.api.BeforeEach
1010
import org.junit.jupiter.api.Test
1111
import org.springframework.core.io.ClassPathResource
12+
import se.svt.oss.encore.Assertions.assertThat
1213
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
1317
import java.io.IOException
1418

1519
class ProfileServiceTest {
@@ -19,50 +23,67 @@ class ProfileServiceTest {
1923

2024
@BeforeEach
2125
internal fun setUp() {
22-
profileService = ProfileService(ClassPathResource("profile/profiles.yml"), objectMapper)
26+
profileService = ProfileService(ProfileProperties(ClassPathResource("profile/profiles.yml")), objectMapper)
2327
}
2428

2529
@Test
2630
fun `successfully parses valid yaml profiles`() {
27-
listOf("archive", "program-x265", "program").forEach {
28-
profileService.getProfile(it)
31+
listOf("program-x265", "program").forEach {
32+
profileService.getProfile(jobWithProfile(it))
2933
}
3034
}
3135

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+
3251
@Test
3352
fun `invalid yaml throws exception`() {
34-
assertThatThrownBy { profileService.getProfile("test-invalid") }
53+
assertThatThrownBy { profileService.getProfile(jobWithProfile("test-invalid")) }
3554
.isInstanceOf(RuntimeException::class.java)
3655
.hasCauseInstanceOf(JsonProcessingException::class.java)
3756
.hasMessageStartingWith("Error parsing profile test-invalid: Instantiation of [simple type, class se.svt.oss.encore.model.profile.X264Encode] value failed")
3857
}
3958

4059
@Test
4160
fun `unknown profile throws error`() {
42-
assertThatThrownBy { profileService.getProfile("test-non-existing") }
61+
assertThatThrownBy { profileService.getProfile(jobWithProfile("test-non-existing")) }
4362
.isInstanceOf(RuntimeException::class.java)
4463
.hasMessageStartingWith("Could not find location for profile test-non-existing! Profiles: {")
4564
}
4665

4766
@Test
4867
fun `unreachable profile throws error`() {
49-
assertThatThrownBy { profileService.getProfile("test-invalid-location") }
68+
assertThatThrownBy { profileService.getProfile(jobWithProfile("test-invalid-location")) }
5069
.isInstanceOf(IOException::class.java)
5170
.hasMessage("class path resource [profile/test_profile_invalid_location.yml] cannot be opened because it does not exist")
5271
}
5372

5473
@Test
5574
fun `unreachable profiles throws error`() {
56-
profileService = ProfileService(ClassPathResource("nonexisting.yml"), objectMapper)
57-
assertThatThrownBy { profileService.getProfile("test-profile") }
75+
profileService = ProfileService(ProfileProperties(ClassPathResource("nonexisting.yml")), objectMapper)
76+
assertThatThrownBy { profileService.getProfile(jobWithProfile("test-profile")) }
5877
.isInstanceOf(IOException::class.java)
5978
.hasMessage("class path resource [nonexisting.yml] cannot be opened because it does not exist")
6079
}
6180

6281
@Test
6382
fun `profile value empty throw errrors`() {
64-
assertThatThrownBy { profileService.getProfile("none") }
83+
assertThatThrownBy { profileService.getProfile(jobWithProfile("none")) }
6584
.isInstanceOf(RuntimeException::class.java)
6685
.hasMessageStartingWith("Could not find location for profile none! Profiles: {")
6786
}
87+
88+
private fun jobWithProfile(profile: String) = defaultEncoreJob().copy(profile = profile)
6889
}

encore-common/src/test/resources/profile/archive.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ description: Archive format
33
encodes:
44
- type: VideoEncode
55
codec: dnxhd
6-
height: 1080
6+
height: #{profileParams['height']}
77
params:
88
b:v: 185M
99
pix_fmt: yuv422p10le
10-
suffix: _DNxHD_185x
10+
suffix: #{profileParams['suffix']}
1111
format: mxf
1212
twoPass: false
1313
audioEncode:

0 commit comments

Comments
 (0)