From 1b43855731e087f4b642a9b8e0ec298ef5fe59d0 Mon Sep 17 00:00:00 2001 From: xis Date: Sun, 8 Oct 2023 21:08:31 +0200 Subject: [PATCH] Nextcloud DLNA --- .gitignore | 3 +- build.gradle | 9 + .../schowek/nextclouddlna/NextcloudDLNA.kt | 18 ++ ...DlnaApplication.kt => NextcloudDLNAApp.kt} | 4 +- .../controller/ContentController.kt | 79 ++++++ .../nextclouddlna/dlna/DllnaService.kt | 74 +++++ .../dlna/RegistryWithOverrides.kt | 34 +++ .../dlna/media/ContentDirectoryService.kt | 115 ++++++++ .../media/ContentDirectoryServiceManager.kt | 26 ++ .../dlna/media/LocalServiceConfiguration.kt | 36 +++ .../nextclouddlna/dlna/media/MediaServer.kt | 53 ++++ .../nextclouddlna/dlna/media/NodeConverter.kt | 90 +++++++ .../dlna/transport/ApacheStreamClient.kt | 253 ++++++++++++++++++ .../ApacheStreamClientConfiguration.kt | 24 ++ .../transport/MyHttpExchangeUpnpStream.kt | 124 +++++++++ .../transport/MyStreamServerConfiguration.kt | 13 + .../dlna/transport/MyStreamServerImpl.kt | 103 +++++++ .../dlna/transport/StreamsLoggerHelper.kt | 138 ++++++++++ .../nextclouddlna/nextcloud/MediaDB.kt | 123 +++++++++ .../nextcloud/NextcloudConfigDiscovery.kt | 42 +++ .../nextcloud/content/ContentElements.kt | 43 +++ .../nextcloud/content/ContentGroup.kt | 14 + .../nextcloud/content/ContentTreeProvider.kt | 122 +++++++++ .../nextcloud/content/MediaFormat.kt | 62 +++++ .../nextcloud/db/AppConfigRepository.kt | 37 +++ .../nextcloud/db/FilecacheRepository.kt | 73 +++++ .../nextcloud/db/GroupFolderRepository.kt | 24 ++ .../nextcloud/db/MimetypeRepository.kt | 20 ++ .../nextclouddlna/util/ExternalUrls.kt | 18 ++ .../nextclouddlna/util/ServerInfoProvider.kt | 40 +++ src/main/resources/application.properties | 1 - src/main/resources/application.yml | 18 ++ src/main/resources/icon.png | Bin 0 -> 50598 bytes ...ationTests.kt => NextcloudDLNAAppTests.kt} | 2 +- 34 files changed, 1830 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/NextcloudDLNA.kt rename src/main/kotlin/net/schowek/nextclouddlna/{NextcloudDlnaApplication.kt => NextcloudDLNAApp.kt} (72%) create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/controller/ContentController.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/dlna/DllnaService.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/dlna/RegistryWithOverrides.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/dlna/media/ContentDirectoryService.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/dlna/media/ContentDirectoryServiceManager.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/dlna/media/LocalServiceConfiguration.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/dlna/media/MediaServer.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/dlna/media/NodeConverter.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/ApacheStreamClient.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/ApacheStreamClientConfiguration.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/MyHttpExchangeUpnpStream.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/MyStreamServerConfiguration.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/MyStreamServerImpl.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/StreamsLoggerHelper.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/nextcloud/MediaDB.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/nextcloud/NextcloudConfigDiscovery.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/nextcloud/content/ContentElements.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/nextcloud/content/ContentGroup.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/nextcloud/content/ContentTreeProvider.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/nextcloud/content/MediaFormat.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/nextcloud/db/AppConfigRepository.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/nextcloud/db/FilecacheRepository.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/nextcloud/db/GroupFolderRepository.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/nextcloud/db/MimetypeRepository.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/util/ExternalUrls.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/util/ServerInfoProvider.kt delete mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/icon.png rename src/test/kotlin/net/schowek/nextclouddlna/{NextcloudDlnaApplicationTests.kt => NextcloudDLNAAppTests.kt} (82%) diff --git a/.gitignore b/.gitignore index c2cf5bd..44e8645 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,12 @@ HELP.md .gradle -.idea build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ +src/main/resources/application-default.yml + ### STS ### .apt_generated .classpath diff --git a/build.gradle b/build.gradle index 4d3c818..40d0da4 100644 --- a/build.gradle +++ b/build.gradle @@ -5,6 +5,7 @@ plugins { id 'io.spring.dependency-management' version '1.1.3' id 'org.jetbrains.kotlin.jvm' version '1.8.22' id 'org.jetbrains.kotlin.plugin.spring' version '1.8.22' + id "org.jetbrains.kotlin.plugin.jpa" version '1.8.22' } group = 'net.schowek' @@ -22,6 +23,14 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'com.fasterxml.jackson.module:jackson-module-kotlin' implementation 'org.jetbrains.kotlin:kotlin-reflect' + + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.mariadb.jdbc:mariadb-java-client:3.2.0' + + implementation 'org.jupnp:org.jupnp:2.7.1' + implementation 'org.jupnp:org.jupnp.support:2.7.1' + implementation 'org.apache.httpcomponents:httpclient:4.5.14' + testImplementation 'org.springframework.boot:spring-boot-starter-test' } diff --git a/src/main/kotlin/net/schowek/nextclouddlna/NextcloudDLNA.kt b/src/main/kotlin/net/schowek/nextclouddlna/NextcloudDLNA.kt new file mode 100644 index 0000000..baa144e --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/NextcloudDLNA.kt @@ -0,0 +1,18 @@ +package net.schowek.nextclouddlna + +import jakarta.annotation.PreDestroy +import net.schowek.nextclouddlna.dlna.DlnaService +import org.jupnp.UpnpService +import org.springframework.stereotype.Component + + +@Component +class NextcloudDLNA(private val dlnaService: DlnaService) { + private val upnpService: UpnpService = dlnaService.start() + + @PreDestroy + fun destroy() { + upnpService.shutdown() + } +} + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/NextcloudDlnaApplication.kt b/src/main/kotlin/net/schowek/nextclouddlna/NextcloudDLNAApp.kt similarity index 72% rename from src/main/kotlin/net/schowek/nextclouddlna/NextcloudDlnaApplication.kt rename to src/main/kotlin/net/schowek/nextclouddlna/NextcloudDLNAApp.kt index 5123243..eaef711 100644 --- a/src/main/kotlin/net/schowek/nextclouddlna/NextcloudDlnaApplication.kt +++ b/src/main/kotlin/net/schowek/nextclouddlna/NextcloudDLNAApp.kt @@ -4,8 +4,8 @@ import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication @SpringBootApplication -class NextcloudDlnaApplication +class NextcloudDLNAApp fun main(args: Array) { - runApplication(*args) + runApplication(*args) } diff --git a/src/main/kotlin/net/schowek/nextclouddlna/controller/ContentController.kt b/src/main/kotlin/net/schowek/nextclouddlna/controller/ContentController.kt new file mode 100644 index 0000000..bea88d5 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/controller/ContentController.kt @@ -0,0 +1,79 @@ +package net.schowek.nextclouddlna.controller + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import net.schowek.nextclouddlna.nextcloud.content.ContentGroup.* +import net.schowek.nextclouddlna.nextcloud.content.ContentTreeProvider +import net.schowek.nextclouddlna.nextcloud.content.MediaFormat +import org.jupnp.support.model.Protocol +import org.jupnp.support.model.ProtocolInfo +import org.jupnp.support.model.dlna.* +import org.jupnp.support.model.dlna.DLNAAttribute.Type +import org.jupnp.support.model.dlna.DLNAAttribute.Type.* +import org.jupnp.support.model.dlna.DLNAConversionIndicator.NONE +import org.jupnp.support.model.dlna.DLNAFlags.* +import org.jupnp.support.model.dlna.DLNAOperations.* +import org.jupnp.support.model.dlna.DLNAProfiles.* +import org.slf4j.LoggerFactory +import org.springframework.core.io.FileSystemResource +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import java.util.* + + +@RestController +class ContentController( + private val contentTreeProvider: ContentTreeProvider +) { + final val logger = LoggerFactory.getLogger(ContentController::class.java) + + @RequestMapping(method = [RequestMethod.GET, RequestMethod.HEAD], value = ["/c/{id}"]) + @ResponseBody + fun getResource( + @PathVariable("id") id: String, + request: HttpServletRequest, + response: HttpServletResponse + ): ResponseEntity { + val item = contentTreeProvider.getItem(id) + if (item == null) { + logger.info("Could not find item id: {}", id) + return ResponseEntity(HttpStatus.NOT_FOUND) + } + if (!request.getHeaders("range").hasMoreElements()) { + logger.info("Serving content {} {}", request.method, id) + } + val fileSystemResource = FileSystemResource(item.path) + response.addHeader("Content-Type", item.format.mime) + response.addHeader("contentFeatures.dlna.org", makeProtocolInfo(item.format).toString()) + response.addHeader("transferMode.dlna.org", "Streaming") + response.addHeader("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*") + return ResponseEntity(fileSystemResource, HttpStatus.OK) + } + + @RequestMapping(method = [RequestMethod.GET], value = ["/rebuild"]) + @ResponseBody + fun reloadTree(): ResponseEntity<*> { + contentTreeProvider.rebuildTree() + return ResponseEntity(HttpStatus.OK) + } + + private fun makeProtocolInfo(mediaFormat: MediaFormat): DLNAProtocolInfo { + val attributes = EnumMap>( + Type::class.java + ) + if (mediaFormat.contentGroup === VIDEO) { + attributes[DLNA_ORG_PN] = DLNAProfileAttribute(AVC_MP4_LPCM) + attributes[DLNA_ORG_OP] = DLNAOperationsAttribute(RANGE) + attributes[DLNA_ORG_CI] = DLNAConversionIndicatorAttribute(NONE) + attributes[DLNA_ORG_FLAGS] = DLNAFlagsAttribute( + INTERACTIVE_TRANSFERT_MODE, + BACKGROUND_TRANSFERT_MODE, + DLNA_V15, + STREAMING_TRANSFER_MODE + ) + } + return DLNAProtocolInfo(Protocol.HTTP_GET, ProtocolInfo.WILDCARD, mediaFormat.mime, attributes) + } +} + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/DllnaService.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/DllnaService.kt new file mode 100644 index 0000000..8336b1b --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/DllnaService.kt @@ -0,0 +1,74 @@ +package net.schowek.nextclouddlna.dlna + +import net.schowek.nextclouddlna.dlna.media.MediaServer +import net.schowek.nextclouddlna.dlna.transport.ApacheStreamClient +import net.schowek.nextclouddlna.dlna.transport.ApacheStreamClientConfiguration +import net.schowek.nextclouddlna.dlna.transport.MyStreamServerConfiguration +import net.schowek.nextclouddlna.dlna.transport.MyStreamServerImpl +import net.schowek.nextclouddlna.util.ServerInfoProvider +import org.jupnp.DefaultUpnpServiceConfiguration +import org.jupnp.UpnpServiceConfiguration +import org.jupnp.UpnpServiceImpl +import org.jupnp.protocol.ProtocolFactory +import org.jupnp.registry.Registry +import org.jupnp.transport.impl.NetworkAddressFactoryImpl +import org.jupnp.transport.spi.NetworkAddressFactory +import org.jupnp.transport.spi.StreamClient +import org.jupnp.transport.spi.StreamServer +import org.springframework.stereotype.Component +import java.net.InetAddress +import java.net.NetworkInterface + + +@Component +class DlnaService( + private val serverInfoProvider: ServerInfoProvider, + private val mediaServer: MediaServer +) { + // Named this way cos NetworkAddressFactoryImpl has a bindAddresses field. + private val addressesToBind: List = listOf(serverInfoProvider.address!!) + + fun start() = MyUpnpService(MyUpnpServiceConfiguration()).also { + it.startup() + it.registry.addDevice(mediaServer.device) + } + + inner class MyUpnpService( + configuration: UpnpServiceConfiguration + ) : UpnpServiceImpl(configuration) { + override fun createRegistry(pf: ProtocolFactory): Registry { + return RegistryImplWithOverrides(this) + } + } + + private inner class MyUpnpServiceConfiguration : DefaultUpnpServiceConfiguration() { + override fun createStreamClient(): StreamClient<*> { + return ApacheStreamClient( + ApacheStreamClientConfiguration(syncProtocolExecutorService) + ) + } + + override fun createStreamServer(networkAddressFactory: NetworkAddressFactory): StreamServer<*> { + return MyStreamServerImpl( + MyStreamServerConfiguration(networkAddressFactory.streamListenPort) + ) + } + + override fun createNetworkAddressFactory( + streamListenPort: Int, + multicastResponsePort: Int + ): NetworkAddressFactory { + return MyNetworkAddressFactory(streamListenPort, multicastResponsePort) + } + } + + inner class MyNetworkAddressFactory( + streamListenPort: Int, + multicastResponsePort: Int + ) : NetworkAddressFactoryImpl(streamListenPort, multicastResponsePort) { + override fun isUsableAddress(iface: NetworkInterface, address: InetAddress): Boolean { + return addressesToBind.contains(address) + } + } +} + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/RegistryWithOverrides.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/RegistryWithOverrides.kt new file mode 100644 index 0000000..6320c7b --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/RegistryWithOverrides.kt @@ -0,0 +1,34 @@ +package net.schowek.nextclouddlna.dlna + +import net.schowek.nextclouddlna.dlna.media.MediaServer +import net.schowek.nextclouddlna.dlna.media.MediaServer.Companion.ICON_FILENAME +import org.jupnp.UpnpService +import org.jupnp.model.resource.IconResource +import org.jupnp.model.resource.Resource +import org.jupnp.registry.RegistryImpl +import java.io.IOException +import java.net.URI + + +class RegistryImplWithOverrides( + private val upnpService: UpnpService +) : RegistryImpl(upnpService) { + private var icon: Resource<*> + + init { + try { + val deviceIcon = MediaServer.createDeviceIcon() + icon = IconResource(deviceIcon.uri, deviceIcon) + } catch (e: IOException) { + throw RuntimeException(e) + } + } + + @Synchronized + @Throws(IllegalArgumentException::class) + override fun getResource(pathQuery: URI): Resource<*>? { + return if ("/$ICON_FILENAME" == pathQuery.path) { + icon + } else super.getResource(pathQuery) + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/ContentDirectoryService.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/ContentDirectoryService.kt new file mode 100644 index 0000000..4e940e4 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/ContentDirectoryService.kt @@ -0,0 +1,115 @@ +package net.schowek.nextclouddlna.dlna.media + +import net.schowek.nextclouddlna.nextcloud.content.ContentTreeProvider +import org.jupnp.support.contentdirectory.AbstractContentDirectoryService +import org.jupnp.support.contentdirectory.ContentDirectoryErrorCode +import org.jupnp.support.contentdirectory.ContentDirectoryException +import org.jupnp.support.contentdirectory.DIDLParser +import org.jupnp.support.model.BrowseFlag +import org.jupnp.support.model.BrowseFlag.* +import org.jupnp.support.model.BrowseResult +import org.jupnp.support.model.DIDLContent +import org.jupnp.support.model.SortCriterion +import org.jupnp.support.model.container.Container +import org.jupnp.support.model.item.Item +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import java.util.* +import java.util.concurrent.TimeUnit.NANOSECONDS + + +@Component +class ContentDirectoryService( + private val contentTreeProvider: ContentTreeProvider, + private val nodeConverter: NodeConverter +) : + AbstractContentDirectoryService( + mutableListOf("dc:title", "upnp:class"), // also "dc:creator", "dc:date", "res@size" + mutableListOf("dc:title") + ) { + + /** + * Root is requested with objectID="0". + */ + @Throws(ContentDirectoryException::class) + override fun browse( + objectID: String, + browseFlag: BrowseFlag, + filter: String, + firstResult: Long, + maxResults: Long, + orderby: Array + ): BrowseResult { + val startTime = System.nanoTime() + return try { + // TODO optimize: + // * checking if it's node or item before fetching them in two queries + // * not fetching children for METADATA browse flag + val node = contentTreeProvider.getNode(objectID) + if (node != null) { + if (browseFlag == METADATA) { + val didl = DIDLContent() + didl.addContainer(nodeConverter.makeContainerWithoutSubContainers(node)) + return BrowseResult(DIDLParser().generate(didl), 1, 1) + } + val containers: List = nodeConverter.makeSubContainersWithoutTheirSubContainers(node) + val items: List = nodeConverter.makeItems(node) + return toRangedResult(containers, items, firstResult, maxResults) + } + val item = contentTreeProvider.getItem(objectID) + if (item != null) { + val didl = DIDLContent() + didl.addItem(nodeConverter.makeItem(item)) + val result = DIDLParser().generate(didl) + return BrowseResult(result, 1, 1) + } + BrowseResult(DIDLParser().generate(DIDLContent()), 0, 0) + } catch (e: Exception) { + LOG.warn( + String.format( + "Failed to generate directory listing" + + " (objectID=%s, browseFlag=%s, filter=%s, firstResult=%s, maxResults=%s, orderby=%s).", + objectID, browseFlag, filter, firstResult, maxResults, Arrays.toString(orderby) + ), e + ) + throw ContentDirectoryException(ContentDirectoryErrorCode.CANNOT_PROCESS, e.toString()) + } finally { + LOG.info( + "Browse: {} ({}, {}) in {}ms.", + objectID, firstResult, maxResults, + NANOSECONDS.toMillis(System.nanoTime() - startTime) + ) + } + } + + companion object { + private val LOG = LoggerFactory.getLogger(ContentDirectoryService::class.java) + + @Throws(Exception::class) + private fun toRangedResult( + containers: List, + items: List, + firstResult: Long, + maxResultsParam: Long + ): BrowseResult { + val maxResults = if (maxResultsParam == 0L) (containers.size + items.size).toLong() else maxResultsParam + val didl = DIDLContent() + if (containers.size > firstResult) { + val from = firstResult.toInt() + val to = Math.min((firstResult + maxResults).toInt(), containers.size) + didl.containers = containers.subList(from, to) + } + if (didl.containers.size < maxResults) { + val from = Math.max(firstResult - containers.size, 0).toInt() + val to = Math.min(items.size, from + (maxResults - didl.containers.size).toInt()) + didl.items = items.subList(from, to) + } + return BrowseResult( + DIDLParser().generate(didl), + (didl.containers.size + didl.items.size).toLong(), + (containers.size + items.size).toLong() + ) + } + } +} + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/ContentDirectoryServiceManager.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/ContentDirectoryServiceManager.kt new file mode 100644 index 0000000..e0d9604 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/ContentDirectoryServiceManager.kt @@ -0,0 +1,26 @@ +package net.schowek.nextclouddlna.dlna.media + +import org.jupnp.model.DefaultServiceManager +import org.jupnp.model.meta.LocalService +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component + + +@Component +class ContentDirectoryServiceManager( + @Qualifier("contentDirectoryLocalService") + private val service: LocalService, + private val contentDirectoryService: ContentDirectoryService +) : DefaultServiceManager(service, ContentDirectoryService::class.java) { + init { + super.service.manager = this + } + + override fun createServiceInstance(): ContentDirectoryService { + return contentDirectoryService + } + + override fun lock() {} + override fun unlock() {} +} + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/LocalServiceConfiguration.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/LocalServiceConfiguration.kt new file mode 100644 index 0000000..dd6a352 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/LocalServiceConfiguration.kt @@ -0,0 +1,36 @@ +package net.schowek.nextclouddlna.dlna.media + +import org.jupnp.binding.annotations.AnnotationLocalServiceBinder +import org.jupnp.model.DefaultServiceManager +import org.jupnp.model.meta.LocalService +import org.jupnp.support.connectionmanager.ConnectionManagerService +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + + +@Configuration +class LocalServiceConfiguration { + @Bean + @Qualifier("contentDirectoryLocalService") + fun contentDirectoryLocalService(): LocalService<*> { + return AnnotationLocalServiceBinder().read(ContentDirectoryService::class.java) + } + + @Bean + @Qualifier("connectionManagerLocalService") + fun connectionManagerLocalService(): LocalService<*> { + return AnnotationLocalServiceBinder().read(ConnectionManagerService::class.java) + } + + @Bean + fun connectionServiceManager( + @Qualifier("connectionManagerLocalService") + connectionManagerService: LocalService + ): DefaultServiceManager { + return DefaultServiceManager( + connectionManagerService, ConnectionManagerService::class.java + ).also { connectionManagerService.setManager(it) } + } +} + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/MediaServer.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/MediaServer.kt new file mode 100644 index 0000000..d9884d1 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/MediaServer.kt @@ -0,0 +1,53 @@ +package net.schowek.nextclouddlna.dlna.media + +import net.schowek.nextclouddlna.util.ExternalUrls +import org.jupnp.model.meta.* +import org.jupnp.model.types.UDADeviceType +import org.jupnp.model.types.UDN.uniqueSystemIdentifier +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.io.IOException + + +@Component +class MediaServer( + @Qualifier("contentDirectoryLocalService") + private val contentDirectoryService: LocalService<*>, + @Qualifier("connectionManagerLocalService") + private val connectionManagerService: LocalService<*>, + @Value("\${server.friendlyName}") + private val friendlyName: String, + private val externalUrls: ExternalUrls +) { + val device = LocalDevice( + DeviceIdentity(uniqueSystemIdentifier("DLNAtoad-MediaServer"), 300), + UDADeviceType(DEVICE_TYPE, VERSION), + DeviceDetails(friendlyName, externalUrls.selfURI), + createDeviceIcon(), arrayOf(contentDirectoryService, connectionManagerService) + ) + + init { + LOG.info("uniqueSystemIdentifier: {} ({})", device.identity.udn, friendlyName) + } + + companion object { + private const val DEVICE_TYPE = "MediaServer" + private const val VERSION = 1 + private val LOG = LoggerFactory.getLogger(MediaServer::class.java) + const val ICON_FILENAME = "icon.png" + + @Throws(IOException::class) + fun createDeviceIcon(): Icon { + val res = MediaServer::class.java.getResourceAsStream("/$ICON_FILENAME") + ?: throw IllegalStateException("Icon not found.") + return res.use { res -> + Icon("image/png", 48, 48, 8, ICON_FILENAME, res).also { + it.validate() + } + } + } + } +} + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/NodeConverter.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/NodeConverter.kt new file mode 100644 index 0000000..2baba4b --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/NodeConverter.kt @@ -0,0 +1,90 @@ +package net.schowek.nextclouddlna.dlna.media + +import net.schowek.nextclouddlna.nextcloud.content.ContentGroup.* +import net.schowek.nextclouddlna.nextcloud.content.ContentItem +import net.schowek.nextclouddlna.nextcloud.content.ContentNode +import net.schowek.nextclouddlna.util.ExternalUrls +import org.jupnp.support.model.DIDLObject +import org.jupnp.support.model.Protocol +import org.jupnp.support.model.ProtocolInfo +import org.jupnp.support.model.Res +import org.jupnp.support.model.WriteStatus.NOT_WRITABLE +import org.jupnp.support.model.container.Container +import org.jupnp.support.model.dlna.DLNAAttribute +import org.jupnp.support.model.dlna.DLNAProfileAttribute +import org.jupnp.support.model.dlna.DLNAProfiles +import org.jupnp.support.model.dlna.DLNAProfiles.JPEG_TN +import org.jupnp.support.model.dlna.DLNAProfiles.PNG_TN +import org.jupnp.support.model.dlna.DLNAProtocolInfo +import org.jupnp.support.model.item.AudioItem +import org.jupnp.support.model.item.ImageItem +import org.jupnp.support.model.item.Item +import org.jupnp.support.model.item.VideoItem +import org.jupnp.util.MimeType +import org.springframework.stereotype.Component +import java.util.* +import java.util.Collections.unmodifiableList + + +@Component +class NodeConverter( + val externalUrls: ExternalUrls +) { + fun makeSubContainersWithoutTheirSubContainers(n: ContentNode) = + n.getNodes().map { node -> makeContainerWithoutSubContainers(node) }.toList() + + fun makeContainerWithoutSubContainers(n: ContentNode): Container { + val c = Container() + c.setClazz(DIDLObject.Class("object.container")) + c.setId("${n.id}") + c.setParentID("${n.parentId}") + c.setTitle(n.name) + c.childCount = n.getNodeAndItemCount() + c.setRestricted(true) + c.setWriteStatus(NOT_WRITABLE) + c.isSearchable = true + return c + } + + fun makeItems(n: ContentNode): List = + n.getItems().map { item -> makeItem(item) }.toList() + + + fun makeItem(c: ContentItem): Item { + val res = Res(c.format.asMimetype(), c.fileLength, externalUrls.contentUrl(c.id)) + return when (c.format.contentGroup) { + VIDEO -> VideoItem("${c.id}", "${c.parentId}", c.name, "", res) + IMAGE -> ImageItem("${c.id}", "${c.parentId}", c.name, "", res) + AUDIO -> AudioItem("${c.id}", "${c.parentId}", c.name, "", res) + else -> throw IllegalArgumentException() + }.also { + val t = c.thumb + if (t != null) { + val thumbUri: String = externalUrls.contentUrl(t.id) + it.addResource(Res(makeProtocolInfo(t.format.asMimetype()), t.fileLength, thumbUri)) + } + } + } + + companion object { + private fun makeProtocolInfo(artMimeType: MimeType): DLNAProtocolInfo { + val attributes = EnumMap>( + DLNAAttribute.Type::class.java + ) + val dlnaThumbnailProfile = findDlnaThumbnailProfile(artMimeType) + if (dlnaThumbnailProfile != null) { + attributes[DLNAAttribute.Type.DLNA_ORG_PN] = DLNAProfileAttribute(dlnaThumbnailProfile) + } + return DLNAProtocolInfo(Protocol.HTTP_GET, ProtocolInfo.WILDCARD, artMimeType.toString(), attributes) + } + + private val DLNA_THUMBNAIL_TYPES: Collection = unmodifiableList(listOf(JPEG_TN, PNG_TN)) + private val MIME_TYPE_TO_DLNA_THUMBNAIL_TYPE: Map = + DLNA_THUMBNAIL_TYPES.associateBy { it.contentFormat } + + private fun findDlnaThumbnailProfile(mimeType: MimeType): DLNAProfiles? { + return MIME_TYPE_TO_DLNA_THUMBNAIL_TYPE[mimeType.toString()] + } + } +} + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/ApacheStreamClient.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/ApacheStreamClient.kt new file mode 100644 index 0000000..6a66261 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/ApacheStreamClient.kt @@ -0,0 +1,253 @@ +package net.schowek.nextclouddlna.dlna.transport + +import org.apache.http.* +import org.apache.http.client.ResponseHandler +import org.apache.http.client.config.RequestConfig +import org.apache.http.client.methods.HttpEntityEnclosingRequestBase +import org.apache.http.client.methods.HttpGet +import org.apache.http.client.methods.HttpPost +import org.apache.http.client.methods.HttpRequestBase +import org.apache.http.config.ConnectionConfig +import org.apache.http.config.RegistryBuilder +import org.apache.http.conn.socket.ConnectionSocketFactory +import org.apache.http.conn.socket.PlainConnectionSocketFactory +import org.apache.http.entity.ByteArrayEntity +import org.apache.http.entity.StringEntity +import org.apache.http.impl.client.CloseableHttpClient +import org.apache.http.impl.client.DefaultHttpRequestRetryHandler +import org.apache.http.impl.client.HttpClients +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager +import org.apache.http.util.EntityUtils +import org.jupnp.http.Headers +import org.jupnp.model.message.* +import org.jupnp.model.message.header.UpnpHeader +import org.jupnp.transport.spi.AbstractStreamClient +import org.jupnp.transport.spi.StreamClient +import org.slf4j.LoggerFactory +import java.nio.charset.Charset +import java.nio.charset.UnsupportedCharsetException +import java.util.concurrent.Callable + + +class ApacheStreamClient( + private val configuration: ApacheStreamClientConfiguration +) : AbstractStreamClient() { + private val clientConnectionManager: PoolingHttpClientConnectionManager + private val httpClient: CloseableHttpClient + + init { + val connectionConfigBuilder = ConnectionConfig.custom().also { + it.setCharset(Charset.forName(configuration.contentCharset)) + if (configuration.socketBufferSize != -1) { + it.setBufferSize(configuration.socketBufferSize) + } + } + val requestConfigBuilder = RequestConfig.custom().also { + it.setExpectContinueEnabled(false) + // These are some safety settings, we should never run into these timeouts as we + // do our own expiration checking + it.setConnectTimeout((configuration.timeoutSeconds + 5) * 1000) + it.setSocketTimeout((configuration.timeoutSeconds + 5) * 1000) + } + + // Only register 80, not 443 and SSL + val registry = RegistryBuilder.create() + .register("http", PlainConnectionSocketFactory.getSocketFactory()) + .build() + clientConnectionManager = PoolingHttpClientConnectionManager(registry).also { + it.maxTotal = configuration.maxTotalConnections + it.defaultMaxPerRoute = configuration.maxTotalPerRoute + } + val defaultHttpRequestRetryHandler = if (configuration.requestRetryCount != -1) { + DefaultHttpRequestRetryHandler(configuration.requestRetryCount, false) + } else { + DefaultHttpRequestRetryHandler() + } + httpClient = HttpClients + .custom() + .setDefaultConnectionConfig(connectionConfigBuilder.build()) + .setConnectionManager(clientConnectionManager) + .setDefaultRequestConfig(requestConfigBuilder.build()) + .setRetryHandler(defaultHttpRequestRetryHandler) + .build() + } + + override fun getConfiguration(): ApacheStreamClientConfiguration { + return configuration + } + + override fun createRequest(requestMessage: StreamRequestMessage): HttpRequestBase { + val requestOperation = requestMessage.operation + val request: HttpRequestBase + when (requestOperation.method) { + UpnpRequest.Method.GET -> { + request = HttpGet(requestOperation.uri) + } + + UpnpRequest.Method.SUBSCRIBE -> { + request = object : HttpGet(requestOperation.uri) { + override fun getMethod(): String { + return UpnpRequest.Method.SUBSCRIBE.httpName + } + } + } + + UpnpRequest.Method.UNSUBSCRIBE -> { + request = object : HttpGet(requestOperation.uri) { + override fun getMethod(): String { + return UpnpRequest.Method.UNSUBSCRIBE.httpName + } + } + } + + UpnpRequest.Method.POST -> { + request = HttpPost(requestOperation.uri) + (request as HttpEntityEnclosingRequestBase).entity = createHttpRequestEntity(requestMessage) + } + + UpnpRequest.Method.NOTIFY -> { + request = object : HttpPost(requestOperation.uri) { + override fun getMethod(): String { + return UpnpRequest.Method.NOTIFY.httpName + } + } + (request as HttpEntityEnclosingRequestBase).entity = createHttpRequestEntity(requestMessage) + } + + else -> throw RuntimeException("Unknown HTTP method: " + requestOperation.httpMethodName) + } + + // Headers + // Add the default user agent if not already set on the message + if (!requestMessage.headers.containsKey(UpnpHeader.Type.USER_AGENT)) { + request.setHeader( + "User-Agent", getConfiguration().getUserAgentValue( + requestMessage.udaMajorVersion, + requestMessage.udaMinorVersion + ) + ) + } + if (requestMessage.operation.httpMinorVersion == 0) { + request.protocolVersion = HttpVersion.HTTP_1_0 + } else { + request.protocolVersion = HttpVersion.HTTP_1_1 + // This closes the http connection immediately after the call. + request.addHeader("Connection", "close") + } + addHeaders(request, requestMessage.headers) + return request + } + + override fun createCallable( + requestMessage: StreamRequestMessage, + request: HttpRequestBase + ): Callable { + return Callable { + LOGGER.trace("Sending HTTP request: $requestMessage") + if (LOGGER.isTraceEnabled) { + StreamsLoggerHelper.logStreamClientRequestMessage(requestMessage) + } + httpClient.execute(request, createResponseHandler(requestMessage)) + } + } + + override fun abort(request: HttpRequestBase) { + request.abort() + } + + override fun logExecutionException(t: Throwable): Boolean { + if (t is IllegalStateException) { + // TODO: Document when/why this happens and why we can ignore it, violating the + // logging rules of the StreamClient#sendRequest() method + LOGGER.trace("Illegal state: " + t.message) + return true + } else if (t is NoHttpResponseException) { + LOGGER.trace("No Http Response: " + t.message) + return true + } + return false + } + + override fun stop() { + LOGGER.trace("Shutting down HTTP client connection manager/pool") + clientConnectionManager.shutdown() + } + + private fun createHttpRequestEntity(upnpMessage: UpnpMessage<*>): HttpEntity { + return if (upnpMessage.bodyType == UpnpMessage.BodyType.BYTES) { + LOGGER.trace("Preparing HTTP request entity as byte[]") + ByteArrayEntity(upnpMessage.bodyBytes) + } else { + LOGGER.trace("Preparing HTTP request entity as string") + var charset = upnpMessage.contentTypeCharset + if (charset == null) { + charset = "UTF-8" + } + try { + StringEntity(upnpMessage.bodyString, charset) + } catch (ex: UnsupportedCharsetException) { + LOGGER.trace("HTTP request does not support charset: {}", charset) + throw RuntimeException(ex) + } + } + } + + private fun createResponseHandler(requestMessage: StreamRequestMessage?): ResponseHandler { + return ResponseHandler { httpResponse: HttpResponse -> + val statusLine = httpResponse.statusLine + LOGGER.trace("Received HTTP response: $statusLine") + + // Status + val responseOperation = UpnpResponse(statusLine.statusCode, statusLine.reasonPhrase) + + // Message + val responseMessage = StreamResponseMessage(responseOperation) + + // Headers + responseMessage.headers = UpnpHeaders(getHeaders(httpResponse)) + + // Body + val entity = httpResponse.entity + if (entity == null || entity.contentLength == 0L) { + LOGGER.trace("HTTP response message has no entity") + return@ResponseHandler responseMessage + } + val data = EntityUtils.toByteArray(entity) + if (data != null) { + if (responseMessage.isContentTypeMissingOrText) { + LOGGER.trace("HTTP response message contains text entity") + responseMessage.setBodyCharacters(data) + } else { + LOGGER.trace("HTTP response message contains binary entity") + responseMessage.setBody(UpnpMessage.BodyType.BYTES, data) + } + } else { + LOGGER.trace("HTTP response message has no entity") + } + if (LOGGER.isTraceEnabled) { + StreamsLoggerHelper.logStreamClientResponseMessage(responseMessage, requestMessage) + } + responseMessage + } + } + + companion object { + private val LOGGER = LoggerFactory.getLogger(StreamClient::class.java) + private fun addHeaders(httpMessage: HttpMessage, headers: Headers) { + for ((key, value1) in headers) { + for (value in value1) { + httpMessage.addHeader(key, value) + } + } + } + + private fun getHeaders(httpMessage: HttpMessage): Headers { + val headers = Headers() + for (header in httpMessage.allHeaders) { + headers.add(header.name, header.value) + } + return headers + } + } +} + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/ApacheStreamClientConfiguration.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/ApacheStreamClientConfiguration.kt new file mode 100644 index 0000000..d9d04a2 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/ApacheStreamClientConfiguration.kt @@ -0,0 +1,24 @@ +package net.schowek.nextclouddlna.dlna.transport + +import org.jupnp.transport.spi.AbstractStreamClientConfiguration +import java.util.concurrent.ExecutorService + + +class ApacheStreamClientConfiguration : AbstractStreamClientConfiguration { + var maxTotalConnections = 1024 + + var maxTotalPerRoute = 100 + + var contentCharset = "UTF-8" // UDA spec says it's always UTF-8 entity content + + constructor(timeoutExecutorService: ExecutorService?) : super(timeoutExecutorService) + constructor(timeoutExecutorService: ExecutorService?, timeoutSeconds: Int) : super( + timeoutExecutorService, + timeoutSeconds + ) + + val socketBufferSize: Int get() = -1 + + val requestRetryCount: Int get() = 0 +} + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/MyHttpExchangeUpnpStream.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/MyHttpExchangeUpnpStream.kt new file mode 100644 index 0000000..81d9f4e --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/MyHttpExchangeUpnpStream.kt @@ -0,0 +1,124 @@ +package net.schowek.nextclouddlna.dlna.transport + +import com.sun.net.httpserver.HttpExchange +import org.jupnp.model.message.* +import org.jupnp.protocol.ProtocolFactory +import org.jupnp.transport.spi.UpnpStream +import org.jupnp.util.io.IO +import org.slf4j.LoggerFactory +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.HttpURLConnection + + +abstract class MyHttpExchangeUpnpStream( + protocolFactory: ProtocolFactory?, + val httpExchange: HttpExchange +) : UpnpStream(protocolFactory) { + + override fun run() { + try { + val xchng = httpExchange + + // Status + val requestMessage = StreamRequestMessage( + UpnpRequest.Method.getByHttpName(xchng.requestMethod), + xchng.requestURI + ) + if (requestMessage.operation.method == UpnpRequest.Method.UNKNOWN) { + logger.warn("Method not supported by UPnP stack: {}", xchng.requestMethod) + throw RuntimeException("Method not supported: {}" + xchng.requestMethod) + } + + // Protocol + requestMessage.operation.httpMinorVersion = if (xchng.protocol.uppercase() == "HTTP/1.1") 1 else 0 + + // Connection wrapper + requestMessage.connection = createConnection() + + // Headers + requestMessage.headers = UpnpHeaders(xchng.requestHeaders) + + // Body + val bodyBytes: ByteArray + var inputStream: InputStream? = null + try { + inputStream = xchng.requestBody + bodyBytes = IO.readBytes(inputStream) + } finally { + inputStream?.close() + } + logger.info(" Reading request body bytes: " + bodyBytes.size) + if (bodyBytes.isNotEmpty() && requestMessage.isContentTypeMissingOrText) { + logger.debug("Request contains textual entity body, converting then setting string on message") + requestMessage.setBodyCharacters(bodyBytes) + } else if (bodyBytes.isNotEmpty()) { + logger.debug("Request contains binary entity body, setting bytes on message") + requestMessage.setBody(UpnpMessage.BodyType.BYTES, bodyBytes) + } else { + logger.debug("Request did not contain entity body") + } + if (bodyBytes.isNotEmpty()) { + logger.info(" Request body: " + requestMessage.body) + } + val responseMessage = process(requestMessage) + + // Return the response + if (responseMessage != null) { + // Headers + xchng.responseHeaders.putAll(responseMessage.headers) + + // Body + val responseBodyBytes = if (responseMessage.hasBody()) responseMessage.bodyBytes else null + val contentLength = responseBodyBytes?.size ?: -1 + logger.info("Sending HTTP response message: $responseMessage with content length: $contentLength") + xchng.sendResponseHeaders(responseMessage.operation.statusCode, contentLength.toLong()) + if (responseBodyBytes!!.isNotEmpty()) { + logger.debug(" Response body: " + responseMessage.body) + } + if (contentLength > 0) { + logger.debug("Response message has body, writing bytes to stream...") + var outputStream: OutputStream? = null + try { + outputStream = xchng.responseBody + IO.writeBytes(outputStream, responseBodyBytes) + outputStream.flush() + } finally { + outputStream?.close() + } + } + } else { + // If it's null, it's 404, everything else needs a proper httpResponse + logger.info("Sending HTTP response status: " + HttpURLConnection.HTTP_NOT_FOUND) + xchng.sendResponseHeaders(HttpURLConnection.HTTP_NOT_FOUND, -1) + } + responseSent(responseMessage) + } catch (t: Throwable) { + + // You definitely want to catch all Exceptions here, otherwise the server will + // simply close the socket and you get an "unexpected end of file" on the client. + // The same is true if you just rethrow an IOException - it is a mystery why it + // is declared then on the HttpHandler interface if it isn't handled in any + // way... so we always do error handling here. + + // TODO: We should only send an error if the problem was on our side + // You don't have to catch Throwable unless, like we do here in unit tests, + // you might run into Errors as well (assertions). + logger.warn("Exception occurred during UPnP stream processing:", t) + try { + httpExchange.sendResponseHeaders(HttpURLConnection.HTTP_INTERNAL_ERROR, -1) + } catch (ex: IOException) { + logger.warn("Couldn't send error response: ", ex) + } + responseException(t) + } + } + + protected abstract fun createConnection(): Connection? + + companion object { + private val logger = LoggerFactory.getLogger(MyHttpExchangeUpnpStream::class.java) + } +} + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/MyStreamServerConfiguration.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/MyStreamServerConfiguration.kt new file mode 100644 index 0000000..76ce6f4 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/MyStreamServerConfiguration.kt @@ -0,0 +1,13 @@ +package net.schowek.nextclouddlna.dlna.transport + +import org.jupnp.transport.spi.StreamServerConfiguration + +class MyStreamServerConfiguration( + private val listenPort: Int +) : StreamServerConfiguration { + var tcpConnectionBacklog = 0 + override fun getListenPort(): Int { + return listenPort + } +} + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/MyStreamServerImpl.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/MyStreamServerImpl.kt new file mode 100644 index 0000000..4e0c28f --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/MyStreamServerImpl.kt @@ -0,0 +1,103 @@ +package net.schowek.nextclouddlna.dlna.transport + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpHandler +import com.sun.net.httpserver.HttpServer +import org.jupnp.model.message.Connection +import org.jupnp.transport.Router +import org.jupnp.transport.spi.InitializationException +import org.jupnp.transport.spi.StreamServer +import org.slf4j.LoggerFactory +import java.io.IOException +import java.net.InetAddress +import java.net.InetSocketAddress + + +class MyStreamServerImpl(private val configuration: MyStreamServerConfiguration) : + StreamServer { + protected var server: HttpServer? = null + + @Synchronized + @Throws(InitializationException::class) + override fun init(bindAddress: InetAddress, router: Router) { + try { + val socketAddress = InetSocketAddress(bindAddress, configuration.getListenPort()) + server = HttpServer.create(socketAddress, configuration.tcpConnectionBacklog) + server!!.createContext("/", MyRequestHttpHandler(router)) + logger.info("* * * * * * * * * * * * * * * * * * * * * * * * * * * * * *") + logger.info("Created server (for receiving TCP streams) on: " + server!!.address) + logger.info("* * * * * * * * * * * * * * * * * * * * * * * * * * * * * *") + } catch (ex: Exception) { + throw InitializationException("Could not initialize " + javaClass.simpleName + ": " + ex.toString(), ex) + } + } + + @Synchronized + override fun getPort(): Int { + return server!!.address.port + } + + override fun getConfiguration(): MyStreamServerConfiguration { + return configuration + } + + @Synchronized + override fun run() { + logger.info("Starting StreamServer...") + // Starts a new thread but inherits the properties of the calling thread + server!!.start() + } + + @Synchronized + override fun stop() { + logger.info("Stopping StreamServer...") + if (server != null) { + server!!.stop(1) + } + } + + inner class MyRequestHttpHandler(private val router: Router) : HttpHandler { + // This is executed in the request receiving thread! + @Throws(IOException::class) + override fun handle(httpExchange: HttpExchange) { + // And we pass control to the service, which will (hopefully) start a new thread immediately so we can + // continue the receiving thread ASAP + logger.info("Received HTTP exchange: " + httpExchange.requestMethod + " " + httpExchange.requestURI + " from " + httpExchange.remoteAddress) + router.received( + object : MyHttpExchangeUpnpStream(router.protocolFactory, httpExchange) { + override fun createConnection(): Connection { + return MyHttpServerConnection(httpExchange) + } + } + ) + } + } + + /** + * Logs a warning and returns `true`, we can't access the socket using the awful JDK webserver API. + * Override this method if you know how to do it. + */ + protected fun isConnectionOpen(exchange: HttpExchange?): Boolean { + logger.warn("Can't check client connection, socket access impossible on JDK webserver!") + return true + } + + protected inner class MyHttpServerConnection(protected var exchange: HttpExchange) : Connection { + override fun isOpen(): Boolean { + return isConnectionOpen(exchange) + } + + override fun getRemoteAddress(): InetAddress? { + return if (exchange.remoteAddress != null) exchange.remoteAddress.address else null + } + + override fun getLocalAddress(): InetAddress? { + return if (exchange.localAddress != null) exchange.localAddress.address else null + } + } + + companion object { + private val logger = LoggerFactory.getLogger(MyStreamServerImpl::class.java) + } +} + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/StreamsLoggerHelper.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/StreamsLoggerHelper.kt new file mode 100644 index 0000000..fe64d30 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/StreamsLoggerHelper.kt @@ -0,0 +1,138 @@ +package net.schowek.nextclouddlna.dlna.transport + +import io.micrometer.common.util.StringUtils +import org.jupnp.model.message.StreamRequestMessage +import org.jupnp.model.message.StreamResponseMessage +import org.jupnp.model.message.UpnpMessage +import org.slf4j.LoggerFactory + + +object StreamsLoggerHelper { + private val LOGGER = LoggerFactory.getLogger(StreamsLoggerHelper::class.java) + private const val HTTPSERVER_REQUEST_BEGIN = + "================================== HTTPSERVER REQUEST BEGIN =====================================" + private const val HTTPSERVER_REQUEST_END = + "================================== HTTPSERVER REQUEST END =======================================" + private const val HTTPSERVER_RESPONSE_BEGIN = + "================================== HTTPSERVER RESPONSE BEGIN ====================================" + private const val HTTPSERVER_RESPONSE_END = + "================================== HTTPSERVER RESPONSE END ======================================" + private const val HTTPCLIENT_REQUEST_BEGIN = + "==================================== HTTPCLIENT REQUEST BEGIN ====================================" + private const val HTTPCLIENT_REQUEST_END = + "==================================== HTTPCLIENT REQUEST END ======================================" + private const val HTTPCLIENT_RESPONSE_BEGIN = + "==================================== HTTPCLIENT RESPONSE BEGIN ===================================" + private const val HTTPCLIENT_RESPONSE_END = + "==================================== HTTPCLIENT RESPONSE END =====================================" + + fun logStreamServerRequestMessage(requestMessage: StreamRequestMessage) { + val formattedRequest = getFormattedRequest(requestMessage) + val formattedHeaders = getFormattedHeaders(requestMessage) + val formattedBody = getFormattedBody(requestMessage) + LOGGER.trace( + "Received a request from {}:\n{}\n{}{}{}{}", + requestMessage.connection.remoteAddress.hostAddress, + HTTPSERVER_REQUEST_BEGIN, + formattedRequest, + formattedHeaders, + formattedBody, + HTTPSERVER_REQUEST_END + ) + } + + fun logStreamServerResponseMessage(responseMessage: StreamResponseMessage, requestMessage: StreamRequestMessage) { + val formattedResponse = getFormattedResponse(responseMessage) + val formattedHeaders = getFormattedHeaders(responseMessage) + val formattedBody = getFormattedBody(responseMessage) + LOGGER.trace( + "Send a response to {}:\n{}\n{}{}{}{}", + requestMessage.connection.remoteAddress.hostAddress, + HTTPSERVER_RESPONSE_BEGIN, + formattedResponse, + formattedHeaders, + formattedBody, + HTTPSERVER_RESPONSE_END + ) + } + + fun logStreamClientRequestMessage(requestMessage: StreamRequestMessage) { + val formattedRequest = getFormattedRequest(requestMessage) + val formattedHeaders = getFormattedHeaders(requestMessage) + val formattedBody = getFormattedBody(requestMessage) + LOGGER.trace( + "Send a request to {}:\n{}\n{}{}{}{}", + requestMessage.uri.host, + HTTPCLIENT_REQUEST_BEGIN, + formattedRequest, + formattedHeaders, + formattedBody, + HTTPCLIENT_REQUEST_END + ) + } + + fun logStreamClientResponseMessage(responseMessage: StreamResponseMessage, requestMessage: StreamRequestMessage?) { + val formattedResponse = getFormattedResponse(responseMessage) + val formattedHeaders = getFormattedHeaders(responseMessage) + val formattedBody = getFormattedBody(responseMessage) + LOGGER.trace( + "Received a response from {}:\n{}\n{}{}{}{}", + requestMessage?.uri?.host, + HTTPCLIENT_RESPONSE_BEGIN, + formattedResponse, + formattedHeaders, + formattedBody, + HTTPCLIENT_RESPONSE_END + ) + } + + private fun getFormattedRequest(requestMessage: StreamRequestMessage): String { + val request = StringBuilder() + request.append(requestMessage.operation.httpMethodName).append(" ").append(requestMessage.uri.path) + request.append(" HTTP/1.").append(requestMessage.operation.httpMinorVersion).append("\n") + return request.toString() + } + + private fun getFormattedResponse(responseMessage: StreamResponseMessage): String { + val response = StringBuilder() + response.append("HTTP/1.").append(responseMessage.operation.httpMinorVersion) + response.append(" ").append(responseMessage.operation.responseDetails).append("\n") + return response.toString() + } + + private fun getFormattedHeaders(message: UpnpMessage<*>): String { + val headers = StringBuilder() + for ((key, value1) in message.headers) { + if (StringUtils.isNotEmpty(key)) { + for (value in value1) { + headers.append(" ").append(key).append(": ").append(value).append("\n") + } + } + } + if (headers.length > 0) { + headers.insert(0, "\nHEADER:\n") + } + return headers.toString() + } + + private fun getFormattedBody(message: UpnpMessage<*>): String { + var formattedBody = "" + //message.isBodyNonEmptyString throw StringIndexOutOfBoundsException if string is empty + try { + val bodyNonEmpty = message.body != null && + (message.body is String && (message.body as String).length > 0 || message.body is ByteArray && (message.body as ByteArray).size > 0) + if (bodyNonEmpty && message.isBodyNonEmptyString) { + formattedBody = message.bodyString + } + } catch (e: Exception) { + formattedBody = "" + } + formattedBody = if (StringUtils.isNotEmpty(formattedBody)) { + "\nCONTENT:\n$formattedBody" + } else { + "" + } + return formattedBody + } +} + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/MediaDB.kt b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/MediaDB.kt new file mode 100644 index 0000000..bb13c1d --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/MediaDB.kt @@ -0,0 +1,123 @@ +package net.schowek.nextclouddlna.nextcloud + +import net.schowek.nextclouddlna.nextcloud.content.ContentItem +import net.schowek.nextclouddlna.nextcloud.content.ContentNode +import net.schowek.nextclouddlna.nextcloud.content.MediaFormat +import net.schowek.nextclouddlna.nextcloud.db.* +import net.schowek.nextclouddlna.nextcloud.db.Filecache.Companion.FOLDER_MIME_TYPE +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.dao.InvalidDataAccessResourceUsageException +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.util.* +import java.util.function.Consumer +import java.util.function.Predicate +import kotlin.collections.HashMap + + +@Component +class MediaDB( + nextcloudConfig: NextcloudConfigDiscovery, + mimetypeRepository: MimetypeRepository, + filecacheRepository: FilecacheRepository, + groupFolderRepository: GroupFolderRepository +) { + final var logger: Logger = LoggerFactory.getLogger(MediaDB::class.java) + + private val appdataDir: String + private val thumbStorageId: Int + private val contentDir: String + private val supportsGroupFolders: Boolean + private val mimetypeRepository: MimetypeRepository + private val filecacheRepository: FilecacheRepository + private val groupFolderRepository: GroupFolderRepository + private val storageUsersMap: MutableMap = HashMap() + private val mimetypes: Map + private val folderMimeType: Int + + init { + appdataDir = nextcloudConfig.appDataDir + contentDir = nextcloudConfig.nextcloudDir + supportsGroupFolders = nextcloudConfig.supportsGroupFolders + this.mimetypeRepository = mimetypeRepository + this.filecacheRepository = filecacheRepository + this.groupFolderRepository = groupFolderRepository + thumbStorageId = filecacheRepository.findFirstByPath(appdataDir).storage + logger.info("Using thumbnail storage id: {}", thumbStorageId) + mimetypes = mimetypeRepository.findAll().associate { it.id to it.mimetype } + folderMimeType = mimetypes.entries.find { it.value == FOLDER_MIME_TYPE }!!.key + } + + @Transactional(readOnly = true) + fun processThumbnails(thumbConsumer: Consumer) { + filecacheRepository.findThumbnails("$appdataDir/preview/%", thumbStorageId, folderMimeType).use { files -> + files.map { f: Filecache -> asItem(f) }.forEach(thumbConsumer) + } + } + + fun mainNodes(): List = + filecacheRepository.mainNodes().map { o -> asNode(o[0] as Filecache, o[1] as Mount) }.toList() + + fun groupFolders(): List { + when { + supportsGroupFolders -> { + try { + return groupFolderRepository.findAll().flatMap { g -> + filecacheRepository.findByPath("__groupfolders/" + g.id).map { f -> + asNode(f, g) + }.toList() + } + } catch (e: InvalidDataAccessResourceUsageException) { + logger.warn(e.message) + } + } + } + return emptyList() + } + + private fun asItem(f: Filecache): ContentItem { + val format = MediaFormat.fromMimeType(mimetypes[f.mimetype]!!) + val path: String = buildPath(f) + return ContentItem(f.id, f.parent, f.name, path, format, f.size) + } + + private fun asNode(f: Filecache): ContentNode { + return ContentNode(f.id, f.parent, f.name) + } + + private fun asNode(f: Filecache, m: Mount): ContentNode { + storageUsersMap[f.storage] = m.userId + return ContentNode(f.id, f.parent, m.userId) + } + + private fun asNode(f: Filecache, g: GroupFolder): ContentNode { + return ContentNode(f.id, f.parent, g.name) + } + + fun appendChildren(n: ContentNode) { + val children = filecacheRepository.findByParent(n.id) + + children.filter { f -> f.mimetype == folderMimeType } + .forEach { folder -> n.addNode(asNode(folder)) } + + try { + children.filter { f -> f.mimetype != folderMimeType } + .forEach { file -> n.addItem(asItem(file)) } + } catch (e: Exception) { + logger.warn(e.message) + } + } + + fun maxMtime(): Long = filecacheRepository.findFirstByOrderByStorageMtimeDesc().storageMtime + + private fun buildPath(f: Filecache): String { + return if (storageUsersMap.containsKey(f.storage)) { + val userName: String? = storageUsersMap[f.storage] + contentDir + "/" + userName + "/" + f.path + } else { + contentDir + "/" + f.path + } + } +} + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/NextcloudConfigDiscovery.kt b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/NextcloudConfigDiscovery.kt new file mode 100644 index 0000000..ac18ee5 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/NextcloudConfigDiscovery.kt @@ -0,0 +1,42 @@ +package net.schowek.nextclouddlna.nextcloud + +import net.schowek.nextclouddlna.nextcloud.db.AppConfigRepository +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.io.File +import java.util.* +import java.util.Arrays.* +import java.util.Objects.* + + +@Component +class NextcloudConfigDiscovery( + @Value("\${nextcloud.filesDir}") val nextcloudDir: String, + val appConfigRepository: AppConfigRepository +) { + final var logger = LoggerFactory.getLogger(NextcloudConfigDiscovery::class.java) + + final val appDataDir: String + final val supportsGroupFolders: Boolean + + init { + appDataDir = findAppDataDir() + supportsGroupFolders = checkGroupFoldersSupport() + logger.info("Found appdata dir: {}", appDataDir) + } + + private fun checkGroupFoldersSupport(): Boolean { + return "yes" == appConfigRepository.getValue("groupfolders", "enabled") + } + + private fun findAppDataDir(): String { + return stream(requireNonNull(File(nextcloudDir).listFiles { f -> + f.isDirectory && f.name.matches(APPDATA_NAME_PATTERN.toRegex()) + })).findFirst().orElseThrow().name + } + + companion object { + const val APPDATA_NAME_PATTERN = "appdata_\\w+" + } +} diff --git a/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/content/ContentElements.kt b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/content/ContentElements.kt new file mode 100644 index 0000000..ee8a248 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/content/ContentElements.kt @@ -0,0 +1,43 @@ +package net.schowek.nextclouddlna.nextcloud.content + +class ContentItem( + val id: Int, + val parentId: Int, + val name: String, + val path: String, + val format: MediaFormat, + val fileLength: Long +) { + var thumb: ContentItem? = null +} + +class ContentNode( + val id: Int, + val parentId: Int, + val name: String +) { + private val nodes: MutableList = ArrayList() + private val items: MutableList = ArrayList() + + fun addItem(item: ContentItem) { + items.add(item) + } + + fun addNode(node: ContentNode) { + nodes.add(node) + } + + fun getItems(): List { + return items + } + + fun getNodes(): List { + return nodes + } + + fun getNodeCount(): Int = nodes.size + + fun getItemCount(): Int = items.size + + fun getNodeAndItemCount(): Int = getNodeCount() + getItemCount() +} diff --git a/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/content/ContentGroup.kt b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/content/ContentGroup.kt new file mode 100644 index 0000000..556b7c1 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/content/ContentGroup.kt @@ -0,0 +1,14 @@ +package net.schowek.nextclouddlna.nextcloud.content + + +enum class ContentGroup(val id: String, val itemIdPrefix: String, val humanName: String) { + ROOT("0", "-", "Root"), + + // Root id of '0' is in the spec. + VIDEO("1-videos", "video-", "Videos"), + IMAGE("2-images", "image-", "Images"), + AUDIO("3-audio", "audio-", "Audio"), + SUBTITLES("4-subtitles", "subtitles-", "Subtitles"), + THUMBNAIL("5-thumbnails", "thumbnail-", "Thumbnails") +} + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/content/ContentTreeProvider.kt b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/content/ContentTreeProvider.kt new file mode 100644 index 0000000..722be51 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/content/ContentTreeProvider.kt @@ -0,0 +1,122 @@ +package net.schowek.nextclouddlna.nextcloud.content + +import jakarta.annotation.PostConstruct +import net.schowek.nextclouddlna.nextcloud.MediaDB +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.util.concurrent.atomic.AtomicInteger +import java.util.regex.Pattern + + +@Component +class ContentTreeProvider( + private val mediaDB: MediaDB +) { + final val logger = LoggerFactory.getLogger(ContentTreeProvider::class.java) + + private var tree: ContentTree = buildContentTree() + private var lastMTime = 0L + + init { + rebuildTree() + } + + @PostConstruct + @Scheduled(fixedDelay = 1000 * 60, initialDelay = 1000 * 60) + fun rebuildTree() { + val maxMtime: Long = mediaDB.maxMtime() + if (lastMTime < maxMtime) { + logger.info("ContentTree seems to be outdated - Loading...") + this.tree = buildContentTree() + lastMTime = maxMtime + } + } + + private fun buildContentTree(): ContentTree { + val tree = ContentTree() + val root = ContentNode(0, -1, "ROOT") + tree.addNode(root) + mediaDB.mainNodes().forEach { n -> + root.addNode(n) + fillNode(n, tree) + } + logger.info("Getting content from group folders...") + mediaDB.groupFolders().forEach { n -> + logger.info(" Group folder found: {}", n.name) + root.addNode(n) + fillNode(n, tree) + } + logger.info("Found {} items in {} nodes", tree.itemsCount, tree.nodesCount) + loadThumbnails(tree) + return tree + } + + private fun loadThumbnails(tree: ContentTree) { + logger.info("Loading thumbnails...") + val thumbsCount = AtomicInteger() + mediaDB.processThumbnails { thumb -> + val id = getItemIdForThumbnail(thumb) + if (id != null) { + val item = tree.getItem(id) + if (item != null && item.thumb == null) { + logger.debug("Adding thumbnail for item {}: {}", id, thumb) + item.thumb = thumb + tree.addItem(thumb) + thumbsCount.getAndIncrement() + } + } + } + logger.info("Found {} thumbnails", thumbsCount) + } + + private fun getItemIdForThumbnail(thumb: ContentItem): String? { + val pattern = Pattern.compile("^.*/preview(/[0-9a-f])+/(\\d+)/\\w+") + val matcher = pattern.matcher(thumb.path) + return if (matcher.find()) { + matcher.group(matcher.groupCount()) + } else null + } + + private fun fillNode(node: ContentNode, tree: ContentTree) { + mediaDB.appendChildren(node) + tree.addNode(node) + node.getItems().forEach { item -> + logger.debug("Adding item[{}]: " + item.path, item.id) + tree.addItem(item) + } + node.getNodes().forEach { n -> + logger.debug("Adding node: " + n.name) + fillNode(n, tree) + } + } + + fun getItem(id: String): ContentItem? = tree.getItem(id) + fun getNode(id: String): ContentNode? = tree.getNode(id) +} + + +class ContentTree { + private val nodes: MutableMap = HashMap() + private val items: MutableMap = HashMap() + + fun getNode(id: String): ContentNode? { + return nodes[id] + } + + fun getItem(id: String): ContentItem? { + return items[id] + } + + fun addItem(item: ContentItem) { + items["${item.id}"] = item + } + + fun addNode(node: ContentNode) { + nodes["${node.id}"] = node + } + + val itemsCount get() = items.size + val nodesCount get() = nodes.size +} + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/content/MediaFormat.kt b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/content/MediaFormat.kt new file mode 100644 index 0000000..06d6ea3 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/content/MediaFormat.kt @@ -0,0 +1,62 @@ +package net.schowek.nextclouddlna.nextcloud.content + +import net.schowek.nextclouddlna.nextcloud.content.ContentGroup.* +import org.jupnp.util.MimeType +import java.util.Arrays.stream + + +enum class MediaFormat( + val ext: String, + val mime: String, + val contentGroup: ContentGroup +) { + AVI("avi", "video/avi", VIDEO), + FLV("flv", "video/x-flv", VIDEO), + M4V("m4v", "video/mp4", VIDEO), + MKV("mkv", "video/x-matroska", VIDEO), + MOV("mov", "video/quicktime", VIDEO), + MP4("mp4", "video/mp4", VIDEO), + MPEG("mpeg", "video/mpeg", VIDEO), + MPG("mpg", "video/mpeg", VIDEO), + OGM("ogm", "video/ogg", VIDEO), + OGV("ogv", "video/ogg", VIDEO), + RMVB("rmvb", "application/vnd.rn-realmedia-vbr", VIDEO), + WEBM("webm", "video/webm", VIDEO), + WMV("wmv", "video/x-ms-wmv", VIDEO), + _3GP("3gp", "video/3gpp", VIDEO), + GIF("gif", "image/gif", IMAGE), + JPEG("jpeg", "image/jpeg", IMAGE), + JPG("jpg", "image/jpeg", IMAGE), + PNG("png", "image/png", IMAGE), + WEBP("webp", "image/webp", IMAGE), + AAC("aac", "audio/aac", AUDIO), + AC3("ac3", "audio/ac3", AUDIO), + FLAC("flac", "audio/flac", AUDIO), + M4A("m4a", "audio/mp4", AUDIO), + MP3("mp3", "audio/mpeg", AUDIO), + MPGA("mpga", "audio/mpeg", AUDIO), + OGA("oga", "audio/ogg", AUDIO), + OGG("ogg", "audio/ogg", AUDIO), + RA("ra", "audio/vnd.rn-realaudio", AUDIO), + WAV("wav", "audio/vnd.wave", AUDIO), + WMA("wma", "audio/x-ms-wma", AUDIO), + SRT("srt", "text/srt", SUBTITLES), + SSA("ssa", "text/x-ssa", SUBTITLES), + ASS("ass", "text/x-ass", SUBTITLES); + + fun asMimetype(): MimeType { + val slash = mime.indexOf('/') + return MimeType(mime.substring(0, slash), mime.substring(slash + 1)) + } + + companion object { + val EXT_TO_FORMAT: Map = values().associateBy { it.ext } + + fun fromMimeType(mimetype: String): MediaFormat { + return stream(values()).filter { i -> i.mime.equals(mimetype) } + .findFirst() + .orElseThrow { RuntimeException("Unknown mime type $mimetype") } + } + } +} + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/db/AppConfigRepository.kt b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/db/AppConfigRepository.kt new file mode 100644 index 0000000..5efbed9 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/db/AppConfigRepository.kt @@ -0,0 +1,37 @@ +package net.schowek.nextclouddlna.nextcloud.db + +import jakarta.persistence.* +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository + +@Repository +interface AppConfigRepository : JpaRepository { + @Query( + "SELECT a.value " + + "FROM AppConfig a " + + "WHERE id.appId=:appId " + + " AND id.configKey=:configKey" + ) + fun getValue(@Param("appId") appId: String, @Param("configKey") configKey: String): String? +} + + +@Entity +@Table(name = "oc_appconfig") +class AppConfig( + @EmbeddedId + val id: AppConfigId, + @field:Column(name = "configvalue") + private val value: String +) + +@Embeddable +class AppConfigId( + @Column(name = "appid") + val appId: String, + @Column(name = "configkey") + val configKey: String +) + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/db/FilecacheRepository.kt b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/db/FilecacheRepository.kt new file mode 100644 index 0000000..47606a1 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/db/FilecacheRepository.kt @@ -0,0 +1,73 @@ +package net.schowek.nextclouddlna.nextcloud.db + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository +import java.util.stream.Stream + + +@Repository +interface FilecacheRepository : JpaRepository { + fun findByParent(parent: Int): List + fun findByPath(path: String): List + fun findFirstByOrderByStorageMtimeDesc(): Filecache + fun findFirstByPath(path: String): Filecache + + @Query( + "SELECT f,m FROM Filecache f, Mount m " + + "WHERE m.storageId = f.storage " + + " AND path = 'files'" + ) + fun mainNodes(): List> + + @Query( + "SELECT f FROM Filecache f " + + "WHERE path LIKE :path " + + " AND storage = :storage " + + " AND mimetype <> :folderMimeType " + + "ORDER BY size DESC" + ) + fun findThumbnails( + @Param("path") path: String, + @Param("storage") storage: Int, + @Param("folderMimeType") folderMimeType: Int + ): Stream +} + + +@Entity +@Table(name = "oc_filecache") +class Filecache( + @Id + @field:Column(name = "fileid") + val id: Int, + val storage: Int, + val path: String, + val parent: Int, + val name: String, + val mimetype: Int, + val size: Long, + val mtime: Long, + val storageMtime: Long +) { + companion object { + var FOLDER_MIME_TYPE = "httpd/unix-directory" + } +} + +@Entity +@Table(name = "oc_mounts") +class Mount( + @Id + val id: Int, + val storageId: Int, + val rootId: Int, + val userId: String +) + + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/db/GroupFolderRepository.kt b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/db/GroupFolderRepository.kt new file mode 100644 index 0000000..040e0f7 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/db/GroupFolderRepository.kt @@ -0,0 +1,24 @@ +package net.schowek.nextclouddlna.nextcloud.db + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + + +@Repository +interface GroupFolderRepository : JpaRepository + +@Entity +@Table(name = "oc_group_folders") +class GroupFolder( + @Id + @Column(name = "folder_id") + val id: Int, + + @Column(name = "mount_point") + val name: String +) + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/db/MimetypeRepository.kt b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/db/MimetypeRepository.kt new file mode 100644 index 0000000..c764fa9 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/db/MimetypeRepository.kt @@ -0,0 +1,20 @@ +package net.schowek.nextclouddlna.nextcloud.db + +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + + +@Repository +interface MimetypeRepository : JpaRepository + +@Entity +@Table(name = "oc_mimetypes") +class Mimetype( + @Id + val id: Int, + val mimetype: String +) + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/util/ExternalUrls.kt b/src/main/kotlin/net/schowek/nextclouddlna/util/ExternalUrls.kt new file mode 100644 index 0000000..e56236d --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/util/ExternalUrls.kt @@ -0,0 +1,18 @@ +package net.schowek.nextclouddlna.util + +import org.springframework.stereotype.Component +import java.net.URI + + +@Component +class ExternalUrls(private val serverInfoProvider: ServerInfoProvider) { + val selfUriString: String = + "http://" + serverInfoProvider.address!!.hostAddress + ":" + serverInfoProvider.port + + val selfURI : URI get() = URI(selfUriString) + + fun contentUrl(id: Int): String { + return "$selfUriString/c/$id" + } +} + diff --git a/src/main/kotlin/net/schowek/nextclouddlna/util/ServerInfoProvider.kt b/src/main/kotlin/net/schowek/nextclouddlna/util/ServerInfoProvider.kt new file mode 100644 index 0000000..d88b6c2 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/util/ServerInfoProvider.kt @@ -0,0 +1,40 @@ +package net.schowek.nextclouddlna.util + +import jakarta.annotation.PostConstruct +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.net.* +import java.util.* + +@Component +class ServerInfoProvider( + @param:Value("\${server.port}") val port: Int, + @param:Value("\${server.interface}") private val networkInterface: String +) { + var logger = LoggerFactory.getLogger(ServerInfoProvider::class.java) + var address: InetAddress? = null + + @PostConstruct + fun init() { + address = guessInetAddress() + logger.info("Using server address: {} and port {}", address!!.hostAddress, port) + } + + private fun guessInetAddress(): InetAddress { + return try { + val en0 = NetworkInterface.getByName(networkInterface).inetAddresses + while (en0.hasMoreElements()) { + val x = en0.nextElement() + if (x is Inet4Address) { + return x + } + } + InetAddress.getLocalHost() + } catch (e: UnknownHostException) { + throw RuntimeException(e) + } catch (e: SocketException) { + throw RuntimeException(e) + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8b13789..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..9945a90 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,18 @@ +server: + port: 8080 + interface: eth0 + friendlyName: NC-DLNA! + +nextcloud: + filesDir: /path/to/your/nextcloud/dir/ending/with/data + +spring: + datasource: + url: jdbc:mariadb://localhost:3306/nextcloud + username: nextcloud + password: nextcloud + driver-class-name: org.mariadb.jdbc.Driver + jpa: + hibernate: + ddl-auto: none + diff --git a/src/main/resources/icon.png b/src/main/resources/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..fce27156ccaf6116b53a75ab42a52fa7b48a8ee7 GIT binary patch literal 50598 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 aM0DAZL|6{<4V*mT;Hau-TrN_w4EY~vUejy< literal 0 HcmV?d00001 diff --git a/src/test/kotlin/net/schowek/nextclouddlna/NextcloudDlnaApplicationTests.kt b/src/test/kotlin/net/schowek/nextclouddlna/NextcloudDLNAAppTests.kt similarity index 82% rename from src/test/kotlin/net/schowek/nextclouddlna/NextcloudDlnaApplicationTests.kt rename to src/test/kotlin/net/schowek/nextclouddlna/NextcloudDLNAAppTests.kt index 0b03bae..1889277 100644 --- a/src/test/kotlin/net/schowek/nextclouddlna/NextcloudDlnaApplicationTests.kt +++ b/src/test/kotlin/net/schowek/nextclouddlna/NextcloudDLNAAppTests.kt @@ -4,7 +4,7 @@ import org.junit.jupiter.api.Test import org.springframework.boot.test.context.SpringBootTest @SpringBootTest -class NextcloudDlnaApplicationTests { +class NextcloudDLNAAppTests { @Test fun contextLoads() {