no jetty needed
This commit is contained in:
parent
2b212b9607
commit
b165fd7c67
12 changed files with 299 additions and 115 deletions
|
@ -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() {
|
||||
|
|
|
@ -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<String>) {
|
||||
|
|
|
@ -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<String> {
|
||||
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<String> {
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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<String, String> {
|
||||
val response = HashMap<String, String>()
|
||||
response["status"] = "fail"
|
||||
response["message"] = e.localizedMessage
|
||||
logger.info { "404 from $request (${e.requestURL})" }
|
||||
return response
|
||||
}
|
||||
|
||||
companion object : KLogging()
|
||||
}
|
|
@ -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<String, List<String>>()
|
||||
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()
|
||||
}
|
|
@ -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<InetAddress> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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<MyStreamServerConfiguration> {
|
||||
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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue