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 0000000..fce2715 Binary files /dev/null and b/src/main/resources/icon.png differ 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() {