Nextcloud DLNA
This commit is contained in:
parent
ee8af88b03
commit
1b43855731
34 changed files with 1830 additions and 5 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,11 +1,12 @@
|
||||||
HELP.md
|
HELP.md
|
||||||
.gradle
|
.gradle
|
||||||
.idea
|
|
||||||
build/
|
build/
|
||||||
!gradle/wrapper/gradle-wrapper.jar
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
!**/src/main/**/build/
|
!**/src/main/**/build/
|
||||||
!**/src/test/**/build/
|
!**/src/test/**/build/
|
||||||
|
|
||||||
|
src/main/resources/application-default.yml
|
||||||
|
|
||||||
### STS ###
|
### STS ###
|
||||||
.apt_generated
|
.apt_generated
|
||||||
.classpath
|
.classpath
|
||||||
|
|
|
@ -5,6 +5,7 @@ plugins {
|
||||||
id 'io.spring.dependency-management' version '1.1.3'
|
id 'io.spring.dependency-management' version '1.1.3'
|
||||||
id 'org.jetbrains.kotlin.jvm' version '1.8.22'
|
id 'org.jetbrains.kotlin.jvm' version '1.8.22'
|
||||||
id 'org.jetbrains.kotlin.plugin.spring' 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'
|
group = 'net.schowek'
|
||||||
|
@ -22,6 +23,14 @@ dependencies {
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin'
|
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin'
|
||||||
implementation 'org.jetbrains.kotlin:kotlin-reflect'
|
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'
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
18
src/main/kotlin/net/schowek/nextclouddlna/NextcloudDLNA.kt
Normal file
18
src/main/kotlin/net/schowek/nextclouddlna/NextcloudDLNA.kt
Normal file
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -4,8 +4,8 @@ import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||||
import org.springframework.boot.runApplication
|
import org.springframework.boot.runApplication
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
class NextcloudDlnaApplication
|
class NextcloudDLNAApp
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
runApplication<NextcloudDlnaApplication>(*args)
|
runApplication<NextcloudDLNAApp>(*args)
|
||||||
}
|
}
|
|
@ -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<FileSystemResource> {
|
||||||
|
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<Any>(HttpStatus.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeProtocolInfo(mediaFormat: MediaFormat): DLNAProtocolInfo {
|
||||||
|
val attributes = EnumMap<Type, DLNAAttribute<*>>(
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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<InetAddress> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<SortCriterion>
|
||||||
|
): 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<Container> = nodeConverter.makeSubContainersWithoutTheirSubContainers(node)
|
||||||
|
val items: List<Item> = 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<Container>,
|
||||||
|
items: List<Item>,
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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<ContentDirectoryService>,
|
||||||
|
private val contentDirectoryService: ContentDirectoryService
|
||||||
|
) : DefaultServiceManager<ContentDirectoryService>(service, ContentDirectoryService::class.java) {
|
||||||
|
init {
|
||||||
|
super.service.manager = this
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createServiceInstance(): ContentDirectoryService {
|
||||||
|
return contentDirectoryService
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun lock() {}
|
||||||
|
override fun unlock() {}
|
||||||
|
}
|
||||||
|
|
|
@ -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<ConnectionManagerService>
|
||||||
|
): DefaultServiceManager<ConnectionManagerService> {
|
||||||
|
return DefaultServiceManager(
|
||||||
|
connectionManagerService, ConnectionManagerService::class.java
|
||||||
|
).also { connectionManagerService.setManager(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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<Item> =
|
||||||
|
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, DLNAAttribute<*>>(
|
||||||
|
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<DLNAProfiles> = unmodifiableList(listOf(JPEG_TN, PNG_TN))
|
||||||
|
private val MIME_TYPE_TO_DLNA_THUMBNAIL_TYPE: Map<String, DLNAProfiles> =
|
||||||
|
DLNA_THUMBNAIL_TYPES.associateBy { it.contentFormat }
|
||||||
|
|
||||||
|
private fun findDlnaThumbnailProfile(mimeType: MimeType): DLNAProfiles? {
|
||||||
|
return MIME_TYPE_TO_DLNA_THUMBNAIL_TYPE[mimeType.toString()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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<ApacheStreamClientConfiguration?, HttpRequestBase>() {
|
||||||
|
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<ConnectionSocketFactory>()
|
||||||
|
.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<StreamResponseMessage> {
|
||||||
|
return Callable<StreamResponseMessage> {
|
||||||
|
LOGGER.trace("Sending HTTP request: $requestMessage")
|
||||||
|
if (LOGGER.isTraceEnabled) {
|
||||||
|
StreamsLoggerHelper.logStreamClientRequestMessage(requestMessage)
|
||||||
|
}
|
||||||
|
httpClient.execute<StreamResponseMessage>(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<StreamResponseMessage> {
|
||||||
|
return ResponseHandler<StreamResponseMessage> { 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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<MyStreamServerConfiguration> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
123
src/main/kotlin/net/schowek/nextclouddlna/nextcloud/MediaDB.kt
Normal file
123
src/main/kotlin/net/schowek/nextclouddlna/nextcloud/MediaDB.kt
Normal file
|
@ -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<Int, String> = HashMap()
|
||||||
|
private val mimetypes: Map<Int, String>
|
||||||
|
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<ContentItem>) {
|
||||||
|
filecacheRepository.findThumbnails("$appdataDir/preview/%", thumbStorageId, folderMimeType).use { files ->
|
||||||
|
files.map { f: Filecache -> asItem(f) }.forEach(thumbConsumer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mainNodes(): List<ContentNode> =
|
||||||
|
filecacheRepository.mainNodes().map { o -> asNode(o[0] as Filecache, o[1] as Mount) }.toList()
|
||||||
|
|
||||||
|
fun groupFolders(): List<ContentNode> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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+"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<ContentNode> = ArrayList()
|
||||||
|
private val items: MutableList<ContentItem> = ArrayList()
|
||||||
|
|
||||||
|
fun addItem(item: ContentItem) {
|
||||||
|
items.add(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addNode(node: ContentNode) {
|
||||||
|
nodes.add(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getItems(): List<ContentItem> {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getNodes(): List<ContentNode> {
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getNodeCount(): Int = nodes.size
|
||||||
|
|
||||||
|
fun getItemCount(): Int = items.size
|
||||||
|
|
||||||
|
fun getNodeAndItemCount(): Int = getNodeCount() + getItemCount()
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
|
@ -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<String, ContentNode> = HashMap()
|
||||||
|
private val items: MutableMap<String, ContentItem> = 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
|
||||||
|
}
|
||||||
|
|
|
@ -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<String, MediaFormat> = 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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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<AppConfig, AppConfigId> {
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
|
|
@ -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<Filecache, String> {
|
||||||
|
fun findByParent(parent: Int): List<Filecache>
|
||||||
|
fun findByPath(path: String): List<Filecache>
|
||||||
|
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<Array<Any>>
|
||||||
|
|
||||||
|
@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<Filecache>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
|
@ -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<GroupFolder, Int>
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "oc_group_folders")
|
||||||
|
class GroupFolder(
|
||||||
|
@Id
|
||||||
|
@Column(name = "folder_id")
|
||||||
|
val id: Int,
|
||||||
|
|
||||||
|
@Column(name = "mount_point")
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
|
@ -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<Mimetype, Int>
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "oc_mimetypes")
|
||||||
|
class Mimetype(
|
||||||
|
@Id
|
||||||
|
val id: Int,
|
||||||
|
val mimetype: String
|
||||||
|
)
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1 +0,0 @@
|
||||||
|
|
18
src/main/resources/application.yml
Normal file
18
src/main/resources/application.yml
Normal file
|
@ -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
|
||||||
|
|
BIN
src/main/resources/icon.png
Normal file
BIN
src/main/resources/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 49 KiB |
|
@ -4,7 +4,7 @@ import org.junit.jupiter.api.Test
|
||||||
import org.springframework.boot.test.context.SpringBootTest
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
class NextcloudDlnaApplicationTests {
|
class NextcloudDLNAAppTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun contextLoads() {
|
fun contextLoads() {
|
Loading…
Reference in a new issue