diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 57ec1a2..d74fbcd 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -22,12 +22,24 @@ on: branches: - 'develop' + pull_request: + paths: + - src/** + - buildSrc/** + - .github/** + - build.gradle.kts + - settings.gradle.kts + branches: + - '**' + jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 + - name: Set up JDK 23 uses: actions/setup-java@v4 with: @@ -35,6 +47,14 @@ jobs: distribution: 'temurin' cache: 'gradle' + - name: Export branch name + uses: mad9000/actions-find-and-replace-string@5 + id: branch_name + with: + source: ${{ github.ref_name }} + find: '/' + replace: '-' + - name: Log in to Docker Hub to pull images without rate limit uses: docker/login-action@v3 with: @@ -51,11 +71,13 @@ jobs: - name: Setup Gradle for a non-wrapper project uses: gradle/actions/setup-gradle@v4 with: - gradle-version: 8.10.1 + gradle-version: 8.10.2 - name: Build JVM OCI image - run: gradle jib + run: gradle jib -Djib.serialize=true # https://github.com/GoogleContainerTools/jib/issues/4301 env: + LANG: "en_US.UTF-8" + LC_ALL: "en_US.UTF-8" USERNAME: ${{ github.actor }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} @@ -64,6 +86,9 @@ jobs: - name: Build Native OCI Image run: gradle bootBuildImage --publishImage --imagePlatform linux/amd64 env: + LANG: "en_US.UTF-8" + LC_ALL: "en_US.UTF-8" + NATIVE_IMAGE_OPTIONS: "-Dsun.jnu.encoding=UTF-8 -Dfile.encoding=UTF-8" USERNAME: ${{ github.actor }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} @@ -72,6 +97,9 @@ jobs: - name: Setup QEMU for ARM64 OCI Image uses: docker/setup-qemu-action@v3 + env: + LANG: "en_US.UTF-8" + LC_ALL: "en_US.UTF-8" with: platforms: 'all' @@ -84,6 +112,8 @@ jobs: - name: Build Native ARM64 OCI Image run: gradle bootBuildImage --publishImage --imagePlatform linux/arm64 env: + LANG: "en_US.UTF-8" + LC_ALL: "en_US.UTF-8" USERNAME: ${{ github.actor }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} @@ -95,9 +125,9 @@ jobs: with: push: true tags: | - ghcr.io/schaka/janitorr:native-${{ github.ref_name }} + ghcr.io/schaka/janitorr:native-${{ steps.branch_name.outputs.value }} ${{ (startsWith(github.ref, 'refs/tags/v') && 'ghcr.io/schaka/janitorr:native-stable') || '' }} sources: | - ghcr.io/schaka/janitorr:native-amd64-${{ github.ref_name }} - ghcr.io/schaka/janitorr:native-arm64-${{ github.ref_name }} + ghcr.io/schaka/janitorr:native-amd64-${{ steps.branch_name.outputs.value }} + ghcr.io/schaka/janitorr:native-arm64-${{ steps.branch_name.outputs.value }} diff --git a/build.gradle.kts b/build.gradle.kts index 9af130e..ecf4e90 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,6 +3,7 @@ import net.nemerosa.versioning.VersioningExtension import org.gradle.plugins.ide.idea.model.IdeaModel import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_22 import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jetbrains.kotlin.resolve.compatibility import org.springframework.boot.gradle.dsl.SpringBootExtension import org.springframework.boot.gradle.tasks.aot.ProcessAot import org.springframework.boot.gradle.tasks.bundling.BootBuildImage @@ -98,7 +99,7 @@ configure { extra { val build = getBuild() val versioning: VersioningExtension = extensions.getByName("versioning") - val branch = versioning.info.branch + val branch = versioning.info.branch.replace("/", "-") val shortCommit = versioning.info.commit.take(8) project.extra["build.date-time"] = build.buildDateAndTime @@ -133,13 +134,19 @@ extra { tasks.withType { jvmArgs( arrayOf( - "-Dspring.config.additional-location=optional:file:/config/application.yaml,optional:file:/workspace/application.yaml" + "-Dspring.config.additional-location=optional:file:/config/application.yaml,optional:file:/workspace/application.yaml", + "-Dsun.jnu.encoding=UTF-8", + "-Dfile.encoding=UTF-8" ) ) } tasks.withType { - args("-Dspring.config.additional-location=optional:file:/config/application.yaml,optional:file:/workspace/application.yaml") + args( + "-Dspring.config.additional-location=optional:file:/config/application.yaml,optional:file:/workspace/application.yaml", + "-Dsun.jnu.encoding=UTF-8", + "-Dfile.encoding=UTF-8" + ) } tasks.withType { @@ -149,7 +156,7 @@ tasks.withType { docker.publishRegistry.password = System.getenv("GITHUB_TOKEN") ?: "INVALID_PASSWORD" builder = "paketobuildpacks/builder-jammy-buildpackless-tiny" - buildpacks = listOf("paketobuildpacks/java-native-image", "paketobuildpacks/health-checker") + buildpacks = listOf("paketobuildpacks/environment-variables", "paketobuildpacks/java-native-image", "paketobuildpacks/health-checker") imageName = project.extra["native.image.name"] as String version = project.extra["docker.image.version"] as String tags = project.extra["native.image.tags"] as List @@ -161,10 +168,12 @@ tasks.withType { "BPL_SPRING_AOT_ENABLED" to "true", "BP_HEALTH_CHECKER_ENABLED" to "true", "BP_JVM_CDS_ENABLED" to "true", - "BP_JVM_VERSION" to "22", - "BP_NATIVE_IMAGE_BUILD_ARGUMENTS" to """ - -march=compatibility - """.trimIndent() + "BP_JVM_VERSION" to "23", + "BPE_NATIVE_IMAGE_OPTIONS" to "-Dsun.jnu.encoding=UTF-8 -Dfile.encoding=UTF-8", + "BPE_LANG" to "en_US.UTF-8", + "BPE_LANGUAGE" to "LANGUAGE=en_US:en", + "BPE_LC_ALL" to "en_US.UTF-8", + "BP_NATIVE_IMAGE_BUILD_ARGUMENTS" to "-march=compatibility -H:+AddAllCharsets -Dsun.jnu.encoding=UTF-8 -Dfile.encoding=UTF-8" ) } @@ -179,7 +188,7 @@ jib { } } from { - image = "eclipse-temurin:22-jre-jammy" + image = "eclipse-temurin:23-jre-noble" auth { username = System.getenv("DOCKERHUB_USER") password = System.getenv("DOCKERHUB_PASSWORD") @@ -196,7 +205,12 @@ jib { } } container { - jvmFlags = listOf("-Dspring.config.additional-location=optional:file:/config/application.yaml", "-Xms256m") + jvmFlags = listOf( + "-Dspring.config.additional-location=optional:file:/config/application.yaml", + "-Dsun.jnu.encoding=UTF-8", + "-Dfile.encoding=UTF-8", + "-Xms256m", + ) mainClass = "com.github.schaka.janitorr.JanitorrApplicationKt" ports = listOf("8978") format = ImageFormat.Docker // OCI not yet supported diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0aaefbc..df97d72 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/kotlin/com/github/schaka/janitorr/JanitorrApplication.kt b/src/main/kotlin/com/github/schaka/janitorr/JanitorrApplication.kt index 2f1e091..13faf89 100644 --- a/src/main/kotlin/com/github/schaka/janitorr/JanitorrApplication.kt +++ b/src/main/kotlin/com/github/schaka/janitorr/JanitorrApplication.kt @@ -5,7 +5,9 @@ import com.github.schaka.janitorr.jellystat.JellystatClient import com.github.schaka.janitorr.mediaserver.MediaServerClient import com.github.schaka.janitorr.mediaserver.MediaServerUserClient import com.github.schaka.janitorr.mediaserver.emby.EmbyMediaServerClient +import com.github.schaka.janitorr.servarr.RestClientProperties import com.github.schaka.janitorr.servarr.ServarrService +import com.github.schaka.janitorr.servarr.bazarr.BazarrClient import com.github.schaka.janitorr.servarr.radarr.RadarrClient import com.github.schaka.janitorr.servarr.sonarr.SonarrClient import org.springframework.aot.hint.RuntimeHints @@ -37,7 +39,9 @@ class JanitorrApplication { hints.proxies().registerJdkProxy(MediaServerUserClient::class.java) hints.proxies().registerJdkProxy(RadarrClient::class.java) hints.proxies().registerJdkProxy(SonarrClient::class.java) + hints.proxies().registerJdkProxy(BazarrClient::class.java) hints.proxies().registerJdkProxy(ServarrService::class.java) + hints.proxies().registerJdkProxy(RestClientProperties::class.java) } } } diff --git a/src/main/kotlin/com/github/schaka/janitorr/cleanup/MediaCleanupSchedule.kt b/src/main/kotlin/com/github/schaka/janitorr/cleanup/MediaCleanupSchedule.kt index 31570df..a642ee3 100644 --- a/src/main/kotlin/com/github/schaka/janitorr/cleanup/MediaCleanupSchedule.kt +++ b/src/main/kotlin/com/github/schaka/janitorr/cleanup/MediaCleanupSchedule.kt @@ -9,6 +9,7 @@ import com.github.schaka.janitorr.mediaserver.library.LibraryType import com.github.schaka.janitorr.mediaserver.library.LibraryType.MOVIES import com.github.schaka.janitorr.mediaserver.library.LibraryType.TV_SHOWS import com.github.schaka.janitorr.servarr.ServarrService +import com.github.schaka.janitorr.servarr.bazarr.BazarrRestService import com.github.schaka.janitorr.servarr.radarr.Radarr import com.github.schaka.janitorr.servarr.radarr.RadarrRestService import com.github.schaka.janitorr.servarr.sonarr.Sonarr @@ -34,8 +35,9 @@ class MediaCleanupSchedule( private val log = LoggerFactory.getLogger(this::class.java.enclosingClass) } + // run every hour - @CacheEvict(cacheNames = [SonarrRestService.CACHE_NAME, RadarrRestService.CACHE_NAME]) + @CacheEvict(cacheNames = [SonarrRestService.CACHE_NAME, RadarrRestService.CACHE_NAME, BazarrRestService.CACHE_NAME_TV, BazarrRestService.CACHE_NAME_MOVIES]) @Scheduled(fixedDelay = 1000 * 60 * 60) fun runSchedule() { diff --git a/src/main/kotlin/com/github/schaka/janitorr/cleanup/TagBasedCleanupSchedule.kt b/src/main/kotlin/com/github/schaka/janitorr/cleanup/TagBasedCleanupSchedule.kt index c38018b..260cfc5 100644 --- a/src/main/kotlin/com/github/schaka/janitorr/cleanup/TagBasedCleanupSchedule.kt +++ b/src/main/kotlin/com/github/schaka/janitorr/cleanup/TagBasedCleanupSchedule.kt @@ -11,6 +11,7 @@ import com.github.schaka.janitorr.mediaserver.library.LibraryType.MOVIES import com.github.schaka.janitorr.mediaserver.library.LibraryType.TV_SHOWS import com.github.schaka.janitorr.servarr.LibraryItem import com.github.schaka.janitorr.servarr.ServarrService +import com.github.schaka.janitorr.servarr.bazarr.BazarrRestService import com.github.schaka.janitorr.servarr.radarr.Radarr import com.github.schaka.janitorr.servarr.radarr.RadarrRestService import com.github.schaka.janitorr.servarr.sonarr.Sonarr @@ -38,7 +39,7 @@ class TagBasedCleanupSchedule( } // run every hour - @CacheEvict(cacheNames = [SonarrRestService.CACHE_NAME, RadarrRestService.CACHE_NAME]) + @CacheEvict(cacheNames = [SonarrRestService.CACHE_NAME, RadarrRestService.CACHE_NAME, BazarrRestService.CACHE_NAME_TV, BazarrRestService.CACHE_NAME_MOVIES]) @Scheduled(fixedDelay = 1000 * 60 * 60) fun runSchedule() { diff --git a/src/main/kotlin/com/github/schaka/janitorr/cleanup/WeeklyEpisodeCleanupSchedule.kt b/src/main/kotlin/com/github/schaka/janitorr/cleanup/WeeklyEpisodeCleanupSchedule.kt index 04384ab..0f1e363 100644 --- a/src/main/kotlin/com/github/schaka/janitorr/cleanup/WeeklyEpisodeCleanupSchedule.kt +++ b/src/main/kotlin/com/github/schaka/janitorr/cleanup/WeeklyEpisodeCleanupSchedule.kt @@ -1,6 +1,7 @@ package com.github.schaka.janitorr.cleanup import com.github.schaka.janitorr.config.ApplicationProperties +import com.github.schaka.janitorr.servarr.bazarr.BazarrRestService import com.github.schaka.janitorr.servarr.data_structures.Tag import com.github.schaka.janitorr.servarr.history.HistoryResponse import com.github.schaka.janitorr.servarr.radarr.RadarrRestService @@ -40,7 +41,7 @@ class WeeklyEpisodeCleanupSchedule( } // run every hour - @CacheEvict(cacheNames = [SonarrRestService.CACHE_NAME, RadarrRestService.CACHE_NAME]) + @CacheEvict(cacheNames = [SonarrRestService.CACHE_NAME, RadarrRestService.CACHE_NAME, BazarrRestService.CACHE_NAME_TV, BazarrRestService.CACHE_NAME_MOVIES]) @Scheduled(fixedDelay = 1000 * 60 * 60) fun runSchedule() { diff --git a/src/main/kotlin/com/github/schaka/janitorr/config/RuntimeEnvironment.kt b/src/main/kotlin/com/github/schaka/janitorr/config/RuntimeEnvironment.kt new file mode 100644 index 0000000..08044c1 --- /dev/null +++ b/src/main/kotlin/com/github/schaka/janitorr/config/RuntimeEnvironment.kt @@ -0,0 +1,29 @@ +package com.github.schaka.janitorr.config + +import jakarta.annotation.PostConstruct +import org.slf4j.LoggerFactory +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import org.springframework.stereotype.Component +import java.nio.charset.Charset + +@Order(Ordered.HIGHEST_PRECEDENCE) +@Component +class RuntimeEnvironment { + companion object { + private val log = LoggerFactory.getLogger(this::class.java.enclosingClass) + } + + + @PostConstruct + fun init() { + log.info("Default charset {}", Charset.defaultCharset().displayName()) + log.info("sun.jnu.encoding {}", System.getProperty("sun.jnu.encoding")) + log.info("sun.stdout.encoding {}", System.getProperty("sun.stdout.encoding")) + log.info("sun.stderr.encoding {}", System.getProperty("sun.stderr.encoding")) + log.info("ENV JAVA_TOOL_OPTIONS {}", System.getenv("JAVA_TOOL_OPTIONS")) + log.info("ENV LANG {}", System.getenv("LANG")) + log.info("ENV LANGUAGE {}", System.getenv("LANGUAGE")) + log.info("ENV LC_ALL {}", System.getenv("LC_ALL")) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/schaka/janitorr/mediaserver/AbstractMediaServerService.kt b/src/main/kotlin/com/github/schaka/janitorr/mediaserver/AbstractMediaServerService.kt index bc3f2f0..1da4da7 100644 --- a/src/main/kotlin/com/github/schaka/janitorr/mediaserver/AbstractMediaServerService.kt +++ b/src/main/kotlin/com/github/schaka/janitorr/mediaserver/AbstractMediaServerService.kt @@ -4,12 +4,13 @@ import com.github.schaka.janitorr.cleanup.CleanupType import com.github.schaka.janitorr.jellystat.JellystatProperties import com.github.schaka.janitorr.mediaserver.filesystem.PathStructure import com.github.schaka.janitorr.mediaserver.library.LibraryType -import com.github.schaka.janitorr.mediaserver.library.VirtualFolderResponse import com.github.schaka.janitorr.servarr.LibraryItem import org.slf4j.LoggerFactory import org.springframework.util.FileSystemUtils import java.nio.file.Files import java.nio.file.Path +import java.nio.file.StandardCopyOption +import kotlin.io.path.Path import kotlin.io.path.exists import kotlin.io.path.listDirectoryEntries @@ -47,7 +48,7 @@ abstract class AbstractMediaServerService { } } - protected fun createSymLink(source: Path, target: Path, type: String) { + private fun createSymLink(source: Path, target: Path, type: String) { if (!Files.exists(target)) { log.debug("Creating {} link from {} to {}", type, source, target) Files.createSymbolicLink(target, source) @@ -56,13 +57,24 @@ abstract class AbstractMediaServerService { } } + private fun copyExtraFiles(files: List, target: Path) { + // TODO: files already contain the full path, consider only adding the filename to an existing source (folder) + for (filePath in files) { + val source = Path(filePath) + val targetFolder = target.parent + val targetFile = targetFolder.resolve(source.fileName) + Files.copy(source, targetFile, StandardCopyOption.REPLACE_EXISTING) + log.debug("Copying extra files from {} to {}", filePath, targetFile) + } + } + internal fun pathStructure(it: LibraryItem, leavingSoonParentPath: Path): PathStructure { - val rootPath = Path.of(it.rootFolderPath) - val itemFilePath = Path.of(it.filePath) + val rootPath = Path(it.rootFolderPath) + val itemFilePath = Path(it.filePath) val itemFolderName = itemFilePath.subtract(rootPath).firstOrNull() - val fileOrFolder = - itemFilePath.subtract(Path.of(it.parentPath)).firstOrNull() // contains filename and folder before it e.g. (Season 05) (ShowName-Episode01.mkv) or MovieName2013.mkv + // contains filename and folder before it e.g. (Season 05) (ShowName-Episode01.mkv) or MovieName2013.mkv + val fileOrFolder = itemFilePath.subtract(Path(it.parentPath)).firstOrNull() val sourceFolder = rootPath.resolve(itemFolderName) val sourceFile = sourceFolder.resolve(fileOrFolder) @@ -74,7 +86,7 @@ abstract class AbstractMediaServerService { } fun cleanupPath(leavingSoonDir: String, libraryType: LibraryType, cleanupType: CleanupType) { - val path = Path.of(leavingSoonDir, libraryType.folderName, cleanupType.folderName) + val path = Path(leavingSoonDir, libraryType.folderName, cleanupType.folderName) FileSystemUtils.deleteRecursively(path) Files.createDirectories(path) } @@ -105,6 +117,7 @@ abstract class AbstractMediaServerService { val source = sourceSeasonFolder.resolve(fileName) val target = targetSeasonFolder.resolve(fileName) createSymLink(source, target, "episode") + copyExtraFiles(it.extraFiles, target) } } else { @@ -119,12 +132,17 @@ abstract class AbstractMediaServerService { val target = structure.targetFile Files.createDirectories(structure.targetFolder) createSymLink(source, target, "movie") + copyExtraFiles(it.extraFiles, target) } else { log.info("Can't find original movie folder - no links to create {}", source) } } } catch (e: Exception) { - log.error("Couldn't find path {}", it.parentPath) + if (log.isDebugEnabled){ + log.error("Couldn't find path {} - {}", it.parentPath, it, e) + } else { + log.error("Couldn't find path {}", it.parentPath) + } } } } diff --git a/src/main/kotlin/com/github/schaka/janitorr/mediaserver/BaseMediaServerService.kt b/src/main/kotlin/com/github/schaka/janitorr/mediaserver/BaseMediaServerService.kt index 78b4a97..579659e 100644 --- a/src/main/kotlin/com/github/schaka/janitorr/mediaserver/BaseMediaServerService.kt +++ b/src/main/kotlin/com/github/schaka/janitorr/mediaserver/BaseMediaServerService.kt @@ -8,12 +8,15 @@ import com.github.schaka.janitorr.mediaserver.library.* import com.github.schaka.janitorr.mediaserver.library.LibraryType.MOVIES import com.github.schaka.janitorr.mediaserver.library.LibraryType.TV_SHOWS import com.github.schaka.janitorr.servarr.LibraryItem +import com.github.schaka.janitorr.servarr.bazarr.BazarrPayload +import com.github.schaka.janitorr.servarr.bazarr.BazarrService import org.slf4j.LoggerFactory import org.springframework.util.FileSystemUtils import java.io.IOException import java.nio.file.FileAlreadyExistsException import java.nio.file.Files import java.nio.file.Path +import kotlin.io.path.Path /** * Keep 2 layers of abstract classes. The time is 100% going to come when Emby will split off from Jellyfin or the other way around. @@ -24,6 +27,7 @@ abstract class BaseMediaServerService( val serviceName: String, val mediaServerClient: MediaServerClient, val mediaServerUserClient: MediaServerUserClient, + val bazarrService: BazarrService, val mediaServerProperties: MediaServerProperties, val applicationProperties: ApplicationProperties, val fileSystemProperties: FileSystemProperties @@ -214,8 +218,8 @@ abstract class BaseMediaServerService( val result = listLibraries() val collectionFilter = libraryType.collectionType.lowercase() // subdirectory (i.e. /leaving-soon/tv/media, /leaving-soon/movies/tag-based - val path = Path.of(fileSystemProperties.leavingSoonDir, libraryType.folderName, cleanupType.folderName) - val mediaServerPath = Path.of(fileSystemProperties.mediaServerLeavingSoonDir ?: fileSystemProperties.leavingSoonDir, libraryType.folderName, cleanupType.folderName) + val path = Path(fileSystemProperties.leavingSoonDir, libraryType.folderName, cleanupType.folderName) + val mediaServerPath = Path(fileSystemProperties.mediaServerLeavingSoonDir ?: fileSystemProperties.leavingSoonDir, libraryType.folderName, cleanupType.folderName) val pathString = mediaServerPath.toUri().path.removeSuffix("/") // Windows paths may have a trailing trash - Windows Jellyfin/Emby can't deal with that, this is a bit hacky but makes development easier val pathForMediaServer = if (windowsRegex.matches(pathString)) pathString.replaceFirst("/", "") else pathString @@ -252,10 +256,32 @@ abstract class BaseMediaServerService( cleanupPath(fileSystemProperties.leavingSoonDir, libraryType, cleanupType) } + populateExtraFiles(libraryType, items) createLinks(items, path, libraryType) createEmptyFile(path) } + private fun populateExtraFiles(type: LibraryType, items: List) { + for (item in items) { + val extraFiles = when (type) { + MOVIES -> gracefulRequest(item.id, bazarrService::getSubtitlesForMovies) + TV_SHOWS -> gracefulRequest(item.id, bazarrService::getSubtitlesForTv).filter { it.season == item.season } + }.flatMap { it.subtitles }.mapNotNull { it.path } + + item.extraFiles += extraFiles + log.trace("Adding extra files to $type for *arr id ${item.id} (season ${item.season}): $extraFiles") + } + } + + private fun gracefulRequest(id: Int, httpApiCall: (id: Int) -> List): List { + try { + return httpApiCall(id) + } catch (e: Exception) { + log.debug("Failed to request data from Bazarr", e) + } + return emptyList() + } + /** * Jellyfin/Emby require a file inside a library or they won't scan updates to media if all media was deleted. * https://github.com/jellyfin/jellyfin/issues/11913 @@ -266,7 +292,7 @@ abstract class BaseMediaServerService( try { Files.createFile(file) } catch (e: FileAlreadyExistsException) { - log.debug("File already exists: {}", file, e) + log.trace("File already exists: {}", file, e) } catch (e: IOException) { log.warn("Could not create empty file {}", fileName, e) } diff --git a/src/main/kotlin/com/github/schaka/janitorr/mediaserver/config/MediaServerConfig.kt b/src/main/kotlin/com/github/schaka/janitorr/mediaserver/config/MediaServerConfig.kt index eeb2495..fa1102f 100644 --- a/src/main/kotlin/com/github/schaka/janitorr/mediaserver/config/MediaServerConfig.kt +++ b/src/main/kotlin/com/github/schaka/janitorr/mediaserver/config/MediaServerConfig.kt @@ -17,6 +17,8 @@ import com.github.schaka.janitorr.mediaserver.jellyfin.JellyfinRestService import com.github.schaka.janitorr.mediaserver.library.* import com.github.schaka.janitorr.mediaserver.library.items.ItemPage import com.github.schaka.janitorr.mediaserver.library.items.MediaFolderItem +import com.github.schaka.janitorr.servarr.bazarr.BazarrRestService +import com.github.schaka.janitorr.servarr.bazarr.BazarrService import org.springframework.aot.hint.annotation.RegisterReflectionForBinding import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -40,8 +42,9 @@ class MediaServerConfig( fun mediaServer( jellyfinProperties: JellyfinProperties, embyProperties: EmbyProperties, + bazarrService: BazarrService, applicationProperties: ApplicationProperties, - fileSystemProperties: FileSystemProperties + fileSystemProperties: FileSystemProperties, ): AbstractMediaServerService { if (!jellyfinProperties.enabled && !embyProperties.enabled) { @@ -53,9 +56,9 @@ class MediaServerConfig( } if (embyProperties.enabled) { - return EmbyRestService(embyClient, embyUserClient, embyProperties, applicationProperties, fileSystemProperties) + return EmbyRestService(embyClient, embyUserClient, bazarrService, embyProperties, applicationProperties, fileSystemProperties) } - return JellyfinRestService(jellyfinClient, jellyfinUserClient, jellyfinProperties, applicationProperties, fileSystemProperties) + return JellyfinRestService(jellyfinClient, jellyfinUserClient, bazarrService, jellyfinProperties, applicationProperties, fileSystemProperties) } } \ No newline at end of file diff --git a/src/main/kotlin/com/github/schaka/janitorr/mediaserver/emby/EmbyRestService.kt b/src/main/kotlin/com/github/schaka/janitorr/mediaserver/emby/EmbyRestService.kt index e473184..c1c39ad 100644 --- a/src/main/kotlin/com/github/schaka/janitorr/mediaserver/emby/EmbyRestService.kt +++ b/src/main/kotlin/com/github/schaka/janitorr/mediaserver/emby/EmbyRestService.kt @@ -10,16 +10,18 @@ import com.github.schaka.janitorr.mediaserver.emby.library.LibraryOptions import com.github.schaka.janitorr.mediaserver.emby.library.PathInfo import com.github.schaka.janitorr.mediaserver.library.LibraryType import com.github.schaka.janitorr.mediaserver.library.VirtualFolderResponse +import com.github.schaka.janitorr.servarr.bazarr.BazarrService open class EmbyRestService( @Emby val embyClient: EmbyMediaServerClient, @Emby embyUserClient: MediaServerUserClient, + bazarrService: BazarrService, embyProperties: EmbyProperties, applicationProperties: ApplicationProperties, fileSystemProperties: FileSystemProperties -) : BaseMediaServerService("Emby", embyClient, embyUserClient, embyProperties, applicationProperties, fileSystemProperties) { +) : BaseMediaServerService("Emby", embyClient, embyUserClient, bazarrService, embyProperties, applicationProperties, fileSystemProperties) { override fun listLibraries(): List { return embyClient.listLibrariesPage().Items diff --git a/src/main/kotlin/com/github/schaka/janitorr/mediaserver/jellyfin/JellyfinRestService.kt b/src/main/kotlin/com/github/schaka/janitorr/mediaserver/jellyfin/JellyfinRestService.kt index 254a9cc..11f71e4 100644 --- a/src/main/kotlin/com/github/schaka/janitorr/mediaserver/jellyfin/JellyfinRestService.kt +++ b/src/main/kotlin/com/github/schaka/janitorr/mediaserver/jellyfin/JellyfinRestService.kt @@ -6,16 +6,18 @@ import com.github.schaka.janitorr.mediaserver.BaseMediaServerService import com.github.schaka.janitorr.mediaserver.MediaServerClient import com.github.schaka.janitorr.mediaserver.MediaServerUserClient import com.github.schaka.janitorr.mediaserver.library.* +import com.github.schaka.janitorr.servarr.bazarr.BazarrService open class JellyfinRestService( @Jellyfin jellyfinClient: MediaServerClient, @Jellyfin jellyfinUserClient: MediaServerUserClient, + bazarrService: BazarrService, jellyfinProperties: JellyfinProperties, applicationProperties: ApplicationProperties, fileSystemProperties: FileSystemProperties -) : BaseMediaServerService("Jellyfin", jellyfinClient, jellyfinUserClient, jellyfinProperties, applicationProperties, fileSystemProperties) { +) : BaseMediaServerService("Jellyfin", jellyfinClient, jellyfinUserClient, bazarrService, jellyfinProperties, applicationProperties, fileSystemProperties) { override fun listLibraries(): List { return mediaServerClient.listLibraries() diff --git a/src/main/kotlin/com/github/schaka/janitorr/servarr/LibraryItem.kt b/src/main/kotlin/com/github/schaka/janitorr/servarr/LibraryItem.kt index 788c30e..3ff20fd 100644 --- a/src/main/kotlin/com/github/schaka/janitorr/servarr/LibraryItem.kt +++ b/src/main/kotlin/com/github/schaka/janitorr/servarr/LibraryItem.kt @@ -24,7 +24,9 @@ data class LibraryItem( var seeding: Boolean = false, var lastSeen: LocalDateTime? = null, - val tags: List = listOf() + val tags: List = listOf(), + // extra files that may be provided and or copied over for leaving soon - like subtitles + val extraFiles: MutableList = mutableListOf(), ) { val historyAge: LocalDateTime diff --git a/src/main/kotlin/com/github/schaka/janitorr/servarr/RestClientProperties.kt b/src/main/kotlin/com/github/schaka/janitorr/servarr/RestClientProperties.kt new file mode 100644 index 0000000..a723f8b --- /dev/null +++ b/src/main/kotlin/com/github/schaka/janitorr/servarr/RestClientProperties.kt @@ -0,0 +1,7 @@ +package com.github.schaka.janitorr.servarr + +interface RestClientProperties { + val enabled: Boolean + val url: String + val apiKey: String +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/schaka/janitorr/servarr/ServarrClientConfig.kt b/src/main/kotlin/com/github/schaka/janitorr/servarr/ServarrClientConfig.kt index af1c080..d5ce28c 100644 --- a/src/main/kotlin/com/github/schaka/janitorr/servarr/ServarrClientConfig.kt +++ b/src/main/kotlin/com/github/schaka/janitorr/servarr/ServarrClientConfig.kt @@ -23,7 +23,6 @@ class ServarrClientConfig { } @Bean - @ConditionalOnProperty("clients.radarr.enabled", havingValue = "true", matchIfMissing = false) fun radarrClient(properties: RadarrProperties, mapper: ObjectMapper): RadarrClient { return Feign.builder() .decoder(JacksonDecoder(mapper)) @@ -36,7 +35,6 @@ class ServarrClientConfig { } @Bean - @ConditionalOnProperty("clients.sonarr.enabled", havingValue = "true", matchIfMissing = false) fun sonarrClient(properties: SonarrProperties, mapper: ObjectMapper): SonarrClient { return Feign.builder() .decoder(JacksonDecoder(mapper)) diff --git a/src/main/kotlin/com/github/schaka/janitorr/servarr/ServarrProperties.kt b/src/main/kotlin/com/github/schaka/janitorr/servarr/ServarrProperties.kt index 419c16e..a18870d 100644 --- a/src/main/kotlin/com/github/schaka/janitorr/servarr/ServarrProperties.kt +++ b/src/main/kotlin/com/github/schaka/janitorr/servarr/ServarrProperties.kt @@ -1,8 +1,5 @@ package com.github.schaka.janitorr.servarr -interface ServarrProperties { - val enabled: Boolean - val url: String - val apiKey: String +interface ServarrProperties : RestClientProperties { val determineAgeBy: HistorySort? } \ No newline at end of file diff --git a/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrClient.kt b/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrClient.kt new file mode 100644 index 0000000..c76e58c --- /dev/null +++ b/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrClient.kt @@ -0,0 +1,17 @@ +package com.github.schaka.janitorr.servarr.bazarr + +import feign.Param +import feign.RequestLine + +interface BazarrClient { + + @RequestLine("GET /movies?start=0&length=-1&radarrid[]={movieId}") + fun getMovieSubtitles(@Param("movieId") movieId: Int): BazarrPage + + @RequestLine("GET /episodes?seriesid[]={showId}") + fun getTvSubtitles(@Param("showId") showId: Int): BazarrPage + + @RequestLine("GET /episodes?seriesid[]={showId}&episodeid[]={episodeIds}") + fun getTvSubtitles(@Param("showId") showId: Int, @Param("episodeIds") episodeIds: List): BazarrPage + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrConfig.kt b/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrConfig.kt new file mode 100644 index 0000000..e5feeeb --- /dev/null +++ b/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrConfig.kt @@ -0,0 +1,48 @@ +package com.github.schaka.janitorr.servarr.bazarr + +import com.fasterxml.jackson.databind.ObjectMapper +import com.github.schaka.janitorr.config.ApplicationProperties +import com.github.schaka.janitorr.config.FileSystemProperties +import feign.Feign +import feign.jackson.JacksonDecoder +import feign.jackson.JacksonEncoder +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpHeaders.CONTENT_TYPE +import org.springframework.http.MediaType.APPLICATION_JSON_VALUE + +/** + * Only required for native image + */ +@Configuration(proxyBeanMethods = false) +class BazarrConfig( + val bazarrNoOpService: BazarrNoOpService +) { + + @Bean + fun bazarrService( + bazarrProperties: BazarrProperties, + bazarrClient: BazarrClient, + applicationProperties: ApplicationProperties, + fileSystemProperties: FileSystemProperties + ): BazarrService { + + if (bazarrProperties.enabled) { + return BazarrRestService(bazarrClient, applicationProperties, fileSystemProperties, bazarrProperties) + } + + return bazarrNoOpService + } + + @Bean + fun bazarrClient(properties: BazarrProperties, mapper: ObjectMapper): BazarrClient { + return Feign.builder() + .decoder(JacksonDecoder(mapper)) + .encoder(JacksonEncoder(mapper)) + .requestInterceptor { + it.header("X-API-KEY", properties.apiKey) + it.header(CONTENT_TYPE, APPLICATION_JSON_VALUE) + } + .target(BazarrClient::class.java, "${properties.url}/api") + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrNoOpService.kt b/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrNoOpService.kt new file mode 100644 index 0000000..af9ea0d --- /dev/null +++ b/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrNoOpService.kt @@ -0,0 +1,23 @@ +package com.github.schaka.janitorr.servarr.bazarr + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +@Service +class BazarrNoOpService : BazarrService { + + companion object { + private val log = LoggerFactory.getLogger(this::class.java.enclosingClass) + } + + override fun getSubtitlesForMovies(movieId: Int): List { + log.info("Bazarr is disabled, no tv episode subtitles") + return emptyList() + } + + override fun getSubtitlesForTv(showId: Int): List { + log.info("Bazarr is disabled, no movie subtitles") + return emptyList() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrPage.kt b/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrPage.kt new file mode 100644 index 0000000..11d8b7c --- /dev/null +++ b/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrPage.kt @@ -0,0 +1,5 @@ +package com.github.schaka.janitorr.servarr.bazarr + +class BazarrPage( + val data: List +) \ No newline at end of file diff --git a/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrPayload.kt b/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrPayload.kt new file mode 100644 index 0000000..4affdf5 --- /dev/null +++ b/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrPayload.kt @@ -0,0 +1,7 @@ +package com.github.schaka.janitorr.servarr.bazarr + +data class BazarrPayload ( + val episode: Int?, + val season: Int?, + val subtitles: List +) \ No newline at end of file diff --git a/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrProperties.kt b/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrProperties.kt new file mode 100644 index 0000000..1906757 --- /dev/null +++ b/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrProperties.kt @@ -0,0 +1,11 @@ +package com.github.schaka.janitorr.servarr.bazarr + +import com.github.schaka.janitorr.servarr.RestClientProperties +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "clients.bazarr") +data class BazarrProperties( + override val enabled: Boolean, + override val url: String, + override val apiKey: String, +) : RestClientProperties \ No newline at end of file diff --git a/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrRestService.kt b/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrRestService.kt new file mode 100644 index 0000000..6af2908 --- /dev/null +++ b/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrRestService.kt @@ -0,0 +1,44 @@ +package com.github.schaka.janitorr.servarr.bazarr + +import com.github.schaka.janitorr.config.ApplicationProperties +import com.github.schaka.janitorr.config.FileSystemProperties +import org.slf4j.LoggerFactory +import org.springframework.aot.hint.annotation.RegisterReflectionForBinding +import org.springframework.cache.annotation.Cacheable +import org.springframework.stereotype.Service + +@Service +@RegisterReflectionForBinding(classes = [BazarrPage::class, BazarrPayload::class, Subtitles::class]) +class BazarrRestService( + + val bazarrClient: BazarrClient, + + val applicationProperties: ApplicationProperties, + + val fileSystemProperties: FileSystemProperties, + + val bazarrProperties: BazarrProperties, + +) : BazarrService { + + companion object { + private val log = LoggerFactory.getLogger(this::class.java.enclosingClass) + const val CACHE_NAME_MOVIES = "bazarr-movie-cache" + const val CACHE_NAME_TV = "bazarr-tv-cache" + + } + + @Cacheable(CACHE_NAME_MOVIES) + override fun getSubtitlesForMovies(movieId: Int): List { + val result = bazarrClient.getMovieSubtitles(movieId) + + return result.data + } + + @Cacheable(CACHE_NAME_TV) + override fun getSubtitlesForTv(showId: Int): List { + val result = bazarrClient.getTvSubtitles(showId) + + return result.data + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrService.kt b/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrService.kt new file mode 100644 index 0000000..00f0880 --- /dev/null +++ b/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/BazarrService.kt @@ -0,0 +1,8 @@ +package com.github.schaka.janitorr.servarr.bazarr + +interface BazarrService { + + fun getSubtitlesForMovies(movieId: Int): List + + fun getSubtitlesForTv(showId: Int): List +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/Subtitles.kt b/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/Subtitles.kt new file mode 100644 index 0000000..fb090a5 --- /dev/null +++ b/src/main/kotlin/com/github/schaka/janitorr/servarr/bazarr/Subtitles.kt @@ -0,0 +1,5 @@ +package com.github.schaka.janitorr.servarr.bazarr + +data class Subtitles ( + val path: String? +) \ No newline at end of file diff --git a/src/main/kotlin/com/github/schaka/janitorr/servarr/radarr/RadarrRestService.kt b/src/main/kotlin/com/github/schaka/janitorr/servarr/radarr/RadarrRestService.kt index cb3e148..56ebf42 100644 --- a/src/main/kotlin/com/github/schaka/janitorr/servarr/radarr/RadarrRestService.kt +++ b/src/main/kotlin/com/github/schaka/janitorr/servarr/radarr/RadarrRestService.kt @@ -12,13 +12,12 @@ import com.github.schaka.janitorr.servarr.history.HistoryResponse import com.github.schaka.janitorr.servarr.quality_profile.QualityProfile import com.github.schaka.janitorr.servarr.radarr.movie.MovieFile import com.github.schaka.janitorr.servarr.radarr.movie.MoviePayload -import jakarta.annotation.PostConstruct import org.slf4j.LoggerFactory import org.springframework.aot.hint.annotation.RegisterReflectionForBinding import org.springframework.cache.annotation.Cacheable import org.springframework.stereotype.Service -import java.nio.file.Path import java.time.LocalDateTime +import kotlin.io.path.Path import kotlin.io.path.exists @Service @@ -85,7 +84,7 @@ class RadarrRestService( override fun removeEntries(items: List) { for (movie in items) { - if (fileSystemProperties.access && fileSystemProperties.validateSeeding && Path.of(movie.originalPath).exists()) { + if (fileSystemProperties.access && fileSystemProperties.validateSeeding && Path(movie.originalPath).exists()) { log.info("Can't delete movie [still seeding - file exists] ({}), id: {}, imdb: {}", movie.originalPath, movie.id, movie.imdbId) movie.seeding = true continue diff --git a/src/main/kotlin/com/github/schaka/janitorr/servarr/sonarr/SonarrRestService.kt b/src/main/kotlin/com/github/schaka/janitorr/servarr/sonarr/SonarrRestService.kt index e5cbeb1..9ecc91d 100644 --- a/src/main/kotlin/com/github/schaka/janitorr/servarr/sonarr/SonarrRestService.kt +++ b/src/main/kotlin/com/github/schaka/janitorr/servarr/sonarr/SonarrRestService.kt @@ -18,8 +18,8 @@ import org.slf4j.LoggerFactory import org.springframework.aot.hint.annotation.RegisterReflectionForBinding import org.springframework.cache.annotation.Cacheable import org.springframework.stereotype.Service -import java.nio.file.Path import java.time.LocalDateTime +import kotlin.io.path.Path import kotlin.io.path.exists @Service @@ -149,7 +149,7 @@ class SonarrRestService( !(applicationProperties.wholeShowSeedingCheck && fileSystemProperties.access && fileSystemProperties.validateSeeding && - Path.of(it.originalPath).exists()) + Path(it.originalPath).exists()) } .map { it.id } .distinct() @@ -200,7 +200,7 @@ class SonarrRestService( // we are always treating seasons as a whole, even if technically episodes could be handled individually for (item in items) { - if (fileSystemProperties.access && fileSystemProperties.validateSeeding && Path.of(item.originalPath).exists()) { + if (fileSystemProperties.access && fileSystemProperties.validateSeeding && Path(item.originalPath).exists()) { log.info("Can't delete season [still seeding - file exists] ({}), id: {}, imdb: {}", item.originalPath, item.id, item.imdbId) item.seeding = true continue diff --git a/src/main/resources/application-template.yml b/src/main/resources/application-template.yml index 0562c05..b8c23db 100644 --- a/src/main/resources/application-template.yml +++ b/src/main/resources/application-template.yml @@ -63,6 +63,10 @@ clients: api-key: "cd0912f129d348c9b69bb20d49fcbe44" only-delete-files: true # NOT RECOMMENDED - When set to true, Janitorr will only delete your media files but keep the entries in Radarr determine-age-by: most_recent # Optional property, use 'most_recent' or 'oldest' - remove this line if Janitorr should determine by upgrades enabled for your profile + bazarr: + enabled: false # Only used if you want to copy over subtitle files managed by Bazarr + url: "http://localhost:6767" + api-key: "cd0912f129d348c9b69bb20d49fcbe55" ## You can only choose one out of Jellyfin or Emby. ## User login is only needed if deletion is enabled. diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6cea1df..3ce5e7a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -29,6 +29,10 @@ clients: enabled: true url: "http://radarr:7878" api-key: "radarr-key" + bazarr: + enabled: false + url: "http://bazarr:6767" + api-key: "bazarr-key" jellyfin: enabled: true url: "http://jellyfin:8096" diff --git a/src/test/kotlin/com/github/schaka/janitorr/mediaserver/MediaRestServiceTest.kt b/src/test/kotlin/com/github/schaka/janitorr/mediaserver/MediaRestServiceTest.kt index c017961..7e8ec9f 100644 --- a/src/test/kotlin/com/github/schaka/janitorr/mediaserver/MediaRestServiceTest.kt +++ b/src/test/kotlin/com/github/schaka/janitorr/mediaserver/MediaRestServiceTest.kt @@ -5,6 +5,7 @@ import com.github.schaka.janitorr.config.FileSystemProperties import com.github.schaka.janitorr.mediaserver.jellyfin.JellyfinProperties import com.github.schaka.janitorr.mediaserver.jellyfin.JellyfinRestService import com.github.schaka.janitorr.servarr.LibraryItem +import com.github.schaka.janitorr.servarr.bazarr.BazarrService import io.mockk.impl.annotations.InjectMockKs import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.SpyK @@ -28,6 +29,9 @@ internal class MediaRestServiceTest { @MockK lateinit var mediaServerUserClient: MediaServerUserClient + @MockK + lateinit var bazarrService: BazarrService + @MockK lateinit var jellyfinProperties: JellyfinProperties