From b165fd7c67f515e4fb61a83600788e249d12d852 Mon Sep 17 00:00:00 2001 From: xis Date: Tue, 10 Oct 2023 17:56:45 +0200 Subject: [PATCH] no jetty needed --- .../schowek/nextclouddlna/NextcloudDLNA.kt | 2 +- .../schowek/nextclouddlna/NextcloudDLNAApp.kt | 2 + .../controller/DLNAController.kt | 74 ++++++++++ .../controller/ExceptionResolver.kt | 27 ++++ .../nextclouddlna/controller/MyUpnpStream.kt | 133 ++++++++++++++++++ .../nextclouddlna/dlna/DllnaService.kt | 38 +---- .../nextclouddlna/dlna/MyProtocolFactory.kt | 17 +++ .../dlna/MyUpnpServiceConfiguration.kt | 30 ++++ .../nextclouddlna/dlna/media/MediaServer.kt | 5 +- .../transport/MyStreamServerConfiguration.kt | 1 - .../dlna/transport/MyStreamServerImpl.kt | 78 +--------- src/main/resources/application.yml | 7 + 12 files changed, 299 insertions(+), 115 deletions(-) create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/controller/DLNAController.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/controller/ExceptionResolver.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/controller/MyUpnpStream.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/dlna/MyProtocolFactory.kt create mode 100644 src/main/kotlin/net/schowek/nextclouddlna/dlna/MyUpnpServiceConfiguration.kt diff --git a/src/main/kotlin/net/schowek/nextclouddlna/NextcloudDLNA.kt b/src/main/kotlin/net/schowek/nextclouddlna/NextcloudDLNA.kt index 0ad6e19..3d49b9c 100644 --- a/src/main/kotlin/net/schowek/nextclouddlna/NextcloudDLNA.kt +++ b/src/main/kotlin/net/schowek/nextclouddlna/NextcloudDLNA.kt @@ -10,7 +10,7 @@ import org.springframework.stereotype.Component class NextcloudDLNA( private val dlnaService: DlnaService ) { - private val upnpService: UpnpService = dlnaService.start() + val upnpService: UpnpService = dlnaService.start() @PreDestroy fun destroy() { diff --git a/src/main/kotlin/net/schowek/nextclouddlna/NextcloudDLNAApp.kt b/src/main/kotlin/net/schowek/nextclouddlna/NextcloudDLNAApp.kt index eaef711..bb0f6e9 100644 --- a/src/main/kotlin/net/schowek/nextclouddlna/NextcloudDLNAApp.kt +++ b/src/main/kotlin/net/schowek/nextclouddlna/NextcloudDLNAApp.kt @@ -2,8 +2,10 @@ package net.schowek.nextclouddlna import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.web.servlet.config.annotation.EnableWebMvc @SpringBootApplication +@EnableWebMvc class NextcloudDLNAApp fun main(args: Array) { diff --git a/src/main/kotlin/net/schowek/nextclouddlna/controller/DLNAController.kt b/src/main/kotlin/net/schowek/nextclouddlna/controller/DLNAController.kt new file mode 100644 index 0000000..8c1ab7c --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/controller/DLNAController.kt @@ -0,0 +1,74 @@ +package net.schowek.nextclouddlna.controller + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import mu.KLogging +import net.schowek.nextclouddlna.NextcloudDLNA +import org.jupnp.model.message.Connection +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestMethod.GET +import org.springframework.web.bind.annotation.RequestMethod.HEAD +import org.springframework.web.bind.annotation.RequestMethod.POST +import org.springframework.web.bind.annotation.ResponseBody +import java.net.InetAddress + +@Controller +class DLNAController( + private val dlna: NextcloudDLNA +) { + @RequestMapping( + method = [GET, HEAD], value = [ + "/dev/{uid}/desc", + "/dev/{uid}/svc/upnp-org/ContentDirectory/desc", + "/dev/{uid}/svc/upnp-org/ConnectionManager/desc" + ] + ) + @ResponseBody + fun handleGet( + @PathVariable("uid") uid: String, + request: HttpServletRequest, + response: HttpServletResponse + ): ResponseEntity { + logger.info { "GET request from ${request.remoteAddr}: ${request.requestURI}" } + MyUpnpStream(dlna.upnpService.protocolFactory, request, response).run() + return ResponseEntity(HttpStatus.OK); + } + + @RequestMapping( + method = [POST], value = [ + "/dev/{uid}/svc/upnp-org/ContentDirectory/action" + ] + ) + @ResponseBody + fun handlePost( + @PathVariable("uid") uid: String, + request: HttpServletRequest, + response: HttpServletResponse + ): ResponseEntity { + logger.info { "POST request from ${request.remoteAddr}: ${request.requestURI}" } + MyUpnpStream(dlna.upnpService.protocolFactory, request, response).run() + return ResponseEntity(HttpStatus.OK); + } + + companion object : KLogging() +} + +class MyHttpServerConnection( + private val request: HttpServletRequest +) : Connection { + override fun isOpen(): Boolean { + return true + } + + override fun getRemoteAddress(): InetAddress? { + return if (request.remoteAddr != null) InetAddress.getByName(request.remoteAddr) else null + } + + override fun getLocalAddress(): InetAddress? { + return if (request.localAddr != null) InetAddress.getByName(request.localAddr) else null + } +} diff --git a/src/main/kotlin/net/schowek/nextclouddlna/controller/ExceptionResolver.kt b/src/main/kotlin/net/schowek/nextclouddlna/controller/ExceptionResolver.kt new file mode 100644 index 0000000..3fb4e88 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/controller/ExceptionResolver.kt @@ -0,0 +1,27 @@ +package net.schowek.nextclouddlna.controller + +import mu.KLogging +import org.springframework.http.HttpStatus.NOT_FOUND +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.ResponseBody +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.context.request.WebRequest +import org.springframework.web.servlet.NoHandlerFoundException + + +@ControllerAdvice +class ExceptionResolver { + @ExceptionHandler(NoHandlerFoundException::class) + @ResponseStatus(NOT_FOUND) + @ResponseBody + fun handleNoHandlerFound(e: NoHandlerFoundException, request: WebRequest): HashMap { + val response = HashMap() + response["status"] = "fail" + response["message"] = e.localizedMessage + logger.info { "404 from $request (${e.requestURL})" } + return response + } + + companion object : KLogging() +} \ No newline at end of file diff --git a/src/main/kotlin/net/schowek/nextclouddlna/controller/MyUpnpStream.kt b/src/main/kotlin/net/schowek/nextclouddlna/controller/MyUpnpStream.kt new file mode 100644 index 0000000..c114876 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/controller/MyUpnpStream.kt @@ -0,0 +1,133 @@ +package net.schowek.nextclouddlna.controller + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import mu.KLogging +import org.jupnp.model.message.StreamRequestMessage +import org.jupnp.model.message.UpnpHeaders +import org.jupnp.model.message.UpnpMessage +import org.jupnp.model.message.UpnpRequest +import org.jupnp.protocol.ProtocolFactory +import org.jupnp.transport.spi.UpnpStream +import org.jupnp.util.io.IO +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.HttpURLConnection +import java.net.URI + + +class MyUpnpStream( + protocolFactory: ProtocolFactory, + private val request: HttpServletRequest, + private val response: HttpServletResponse +) : UpnpStream(protocolFactory) { + override fun run() = try { + // Status + val requestMessage = StreamRequestMessage( + UpnpRequest.Method.getByHttpName(request.method), + URI(request.requestURI) + ) + if (requestMessage.operation.method == UpnpRequest.Method.UNKNOWN) { + logger.warn("Method not supported by UPnP stack: {}", request.method) + throw RuntimeException("Method not supported: {}" + request.method) + } + + // Protocol + requestMessage.operation.httpMinorVersion = 1 + // Connection wrapper + requestMessage.connection = MyHttpServerConnection(request) + // Headers + val headers = mutableMapOf>() + val headerNames = request.headerNames + if (headerNames != null) { + while (headerNames.hasMoreElements()) { + val headerName = headerNames.nextElement() + headers[headerName] = listOf(request.getHeader(headerName)) + } + } + requestMessage.headers = UpnpHeaders(headers) + + // Body + val bodyBytes: ByteArray + var inputStream: InputStream? = null + try { + inputStream = request.inputStream + 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.debug(" Request body: " + requestMessage.body) + } + + val responseMessage = process(requestMessage) + + // Return the response + if (responseMessage != null) { + // Headers + responseMessage.headers.forEach { response.addHeader(it.key, it.value.joinToString { v -> v }) } + + // 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") + + response.status = responseMessage.operation.statusCode + response.setContentLength(contentLength) + + // TODO return ResponseEntity +// httpExchange.sendResponseHeaders(responseMessage.operation.statusCode, contentLength.toLong()) + if (responseBodyBytes!!.isNotEmpty()) { + logger.info { " Response body: ${responseMessage.body}" } + } + if (contentLength > 0) { + logger.debug("Response message has body, writing bytes to stream...") + var outputStream: OutputStream? = null + try { + outputStream = response.outputStream + 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) + // TODO return ResponseEntity +// httpExchange.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 { + // TODO return ResponseEntity +// httpExchange.sendResponseHeaders(HttpURLConnection.HTTP_INTERNAL_ERROR, -1) + } catch (ex: IOException) { + logger.warn("Couldn't send error response: ", ex) + } + responseException(t) + } + + companion object : KLogging() +} \ No newline at end of file diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/DllnaService.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/DllnaService.kt index aeb9edc..0559ece 100644 --- a/src/main/kotlin/net/schowek/nextclouddlna/dlna/DllnaService.kt +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/DllnaService.kt @@ -10,6 +10,7 @@ import org.jupnp.DefaultUpnpServiceConfiguration import org.jupnp.UpnpServiceConfiguration import org.jupnp.UpnpServiceImpl import org.jupnp.protocol.ProtocolFactory +import org.jupnp.protocol.ProtocolFactoryImpl import org.jupnp.registry.Registry import org.jupnp.transport.impl.NetworkAddressFactoryImpl import org.jupnp.transport.spi.NetworkAddressFactory @@ -23,12 +24,10 @@ import java.net.NetworkInterface @Component class DlnaService( private val serverInfoProvider: ServerInfoProvider, + private val upnpServiceConfiguration: MyUpnpServiceConfiguration, 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 { + fun start() = MyUpnpService(upnpServiceConfiguration).also { it.startup() it.registry.addDevice(mediaServer.device) } @@ -39,35 +38,8 @@ class DlnaService( override fun createRegistry(pf: ProtocolFactory): Registry { return RegistryImplWithOverrides(this) } - } - - private inner class MyUpnpServiceConfiguration : DefaultUpnpServiceConfiguration(8081) { - 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) + override fun createProtocolFactory(): ProtocolFactory? { + return MyProtocolFactory(this) } } } diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/MyProtocolFactory.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/MyProtocolFactory.kt new file mode 100644 index 0000000..cb52ebe --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/MyProtocolFactory.kt @@ -0,0 +1,17 @@ +package net.schowek.nextclouddlna.dlna + +import mu.KLogging +import org.jupnp.UpnpService +import org.jupnp.model.meta.LocalDevice +import org.jupnp.protocol.ProtocolFactoryImpl +import org.jupnp.protocol.async.SendingNotificationAlive + +class MyProtocolFactory( + upnpService: UpnpService +) : ProtocolFactoryImpl(upnpService) { + override fun createSendingNotificationAlive(localDevice: LocalDevice): SendingNotificationAlive { + logger.info { "SENDING ALIVE $localDevice" } + return SendingNotificationAlive(upnpService, localDevice) + } + companion object : KLogging() +} \ No newline at end of file diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/MyUpnpServiceConfiguration.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/MyUpnpServiceConfiguration.kt new file mode 100644 index 0000000..facc88b --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/MyUpnpServiceConfiguration.kt @@ -0,0 +1,30 @@ +package net.schowek.nextclouddlna.dlna + +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 org.jupnp.DefaultUpnpServiceConfiguration +import org.jupnp.transport.spi.NetworkAddressFactory +import org.jupnp.transport.spi.StreamClient +import org.jupnp.transport.spi.StreamServer +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component + +@Component +class MyUpnpServiceConfiguration( + @Value("\${server.port}") + streamListenPort: Int +) : DefaultUpnpServiceConfiguration(streamListenPort) { + override fun createStreamClient(): StreamClient<*> { + return ApacheStreamClient( + ApacheStreamClientConfiguration(syncProtocolExecutorService) + ) + } + + override fun createStreamServer(networkAddressFactory: NetworkAddressFactory): StreamServer<*> { + return MyStreamServerImpl( + MyStreamServerConfiguration(networkAddressFactory.streamListenPort) + ) + } +} diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/MediaServer.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/MediaServer.kt index e34c4ee..b566feb 100644 --- a/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/MediaServer.kt +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/MediaServer.kt @@ -23,14 +23,13 @@ class MediaServer( private val externalUrls: ExternalUrls ) { val device = LocalDevice( - DeviceIdentity(uniqueSystemIdentifier("DLNAtoad-MediaServer"), 300), + DeviceIdentity(uniqueSystemIdentifier("Nextcloud-DLNA-MediaServer"), 300), UDADeviceType(DEVICE_TYPE, VERSION), DeviceDetails(friendlyName, externalUrls.selfURI), createDeviceIcon(), arrayOf(contentDirectoryService, connectionManagerService) ) - @PostConstruct - fun init() { + init { logger.info("uniqueSystemIdentifier: {} ({})", device.identity.udn, friendlyName) } diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/MyStreamServerConfiguration.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/MyStreamServerConfiguration.kt index d5125d1..31ecab2 100644 --- a/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/MyStreamServerConfiguration.kt +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/MyStreamServerConfiguration.kt @@ -5,7 +5,6 @@ import org.jupnp.transport.spi.StreamServerConfiguration class MyStreamServerConfiguration( private val listenPort: Int ) : StreamServerConfiguration { - val 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 index a8c3a79..2235b75 100644 --- a/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/MyStreamServerImpl.kt +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/MyStreamServerImpl.kt @@ -1,104 +1,28 @@ 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 mu.KLogging -import org.jupnp.model.message.Connection import org.jupnp.transport.Router -import org.jupnp.transport.spi.InitializationException import org.jupnp.transport.spi.StreamServer -import java.io.IOException import java.net.InetAddress -import java.net.InetSocketAddress class MyStreamServerImpl( private val configuration: MyStreamServerConfiguration ) : StreamServer { - private var server: HttpServer? = null - - @Synchronized - @Throws(InitializationException::class) override fun init(bindAddress: InetAddress, router: Router) { - try { - val socketAddress = InetSocketAddress(bindAddress, configuration.listenPort) - 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 + return configuration.listenPort } 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. - */ - private fun isConnectionOpen(exchange: HttpExchange?): Boolean { - logger.warn("Can't check client connection, socket access impossible on JDK webserver!") - return true - } - - private inner class MyHttpServerConnection( - private val 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: KLogging() } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 852ab82..ffd0348 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,7 @@ server: + error: + whitelabel: + enabled: false port: ${NEXTCLOUD_DLNA_SERVER_PORT:8080} interface: ${NEXTCLOUD_DLNA_INTERFACE:eth0} friendlyName: ${NEXTCLOUD_DLNA_FRIENDLY_NAME:Nextcloud-DLNA} @@ -15,3 +18,7 @@ spring: jpa: hibernate: ddl-auto: none + mvc: + throw-exception-if-no-handler-found: true + resources: + add-mappings: false