38
build.gradle
|
@ -6,6 +6,7 @@ plugins {
|
|||
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'
|
||||
id 'groovy'
|
||||
}
|
||||
|
||||
group = 'net.schowek'
|
||||
|
@ -23,6 +24,11 @@ repositories {
|
|||
mavenCentral()
|
||||
}
|
||||
|
||||
configurations {
|
||||
integrationTestImplementation.extendsFrom(testImplementation)
|
||||
integrationTestRuntimeOnly.extendsFrom(testRuntimeOnly)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation('org.springframework.boot:spring-boot-starter-web') {
|
||||
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
|
||||
|
@ -40,10 +46,17 @@ dependencies {
|
|||
implementation 'org.jupnp:org.jupnp.support:2.7.1'
|
||||
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
|
||||
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testImplementation 'org.apache.groovy:groovy:4.0.15'
|
||||
|
||||
testImplementation('org.spockframework:spock-core:2.4-M1-groovy-4.0')
|
||||
testImplementation('org.spockframework:spock-spring:2.4-M1-groovy-4.0')
|
||||
testImplementation('org.springframework.boot:spring-boot-starter-test') {
|
||||
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
|
||||
}
|
||||
testImplementation('com.h2database:h2')
|
||||
}
|
||||
|
||||
tasks.withType(KotlinCompile) {
|
||||
tasks.withType(KotlinCompile).configureEach {
|
||||
kotlinOptions {
|
||||
freeCompilerArgs += '-Xjsr305=strict'
|
||||
jvmTarget = '17'
|
||||
|
@ -53,3 +66,24 @@ tasks.withType(KotlinCompile) {
|
|||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
integrationTest {
|
||||
groovy.srcDir "$projectDir/src/integration/groovy"
|
||||
resources.srcDir "$projectDir/src/integration/resources"
|
||||
compileClasspath += main.output + test.output
|
||||
runtimeClasspath += main.output + test.output
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('integrationTest', Test) {
|
||||
useJUnitPlatform()
|
||||
description = "Run integration tests"
|
||||
group = "verification"
|
||||
testClassesDirs = sourceSets.integrationTest.output.classesDirs
|
||||
classpath = sourceSets.integrationTest.runtimeClasspath
|
||||
mustRunAfter tasks.test
|
||||
}
|
||||
|
||||
check.dependsOn integrationTest
|
||||
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
package net.schowek.nextclouddlna.controller
|
||||
|
||||
|
||||
import net.schowek.nextclouddlna.nextcloud.content.ContentTreeProvider
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import support.IntegrationSpecification
|
||||
|
||||
class ContentControllerIntTest extends IntegrationSpecification {
|
||||
@Autowired
|
||||
ContentTreeProvider contentTreeProvider
|
||||
|
||||
def "should serve nextcloud files"() {
|
||||
given:
|
||||
def items = contentTreeProvider.tree.items
|
||||
|
||||
when:
|
||||
Map<Integer, ResponseEntity<byte[]>> results = items.keySet().collectEntries() {
|
||||
[Integer.valueOf(it), restTemplate().getForEntity(urlWithPort("/c/$it"), byte[])]
|
||||
}
|
||||
|
||||
then:
|
||||
items.values().each { item ->
|
||||
assert results.containsKey(item.id)
|
||||
with(results.get(item.id)) { response ->
|
||||
response.statusCode == HttpStatus.OK
|
||||
with(response.headers.each { it.key.toLowerCase() }) {
|
||||
assert it['content-type'] == [item.format.mime]
|
||||
assert it['accept-ranges'] == ["bytes"]
|
||||
assert it.containsKey('contentfeatures.dlna.org')
|
||||
assert it.containsKey('transfermode.dlna.org')
|
||||
assert it.containsKey('realtimeinfo.dlna.org')
|
||||
}
|
||||
response.body.length == item.fileLength
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def "should return 404 if content does not exist"() {
|
||||
when:
|
||||
ResponseEntity<byte[]> response = restTemplate().getForEntity(urlWithPort("/c/blah-blah"), byte[]);
|
||||
|
||||
then:
|
||||
response.statusCode == HttpStatus.NOT_FOUND
|
||||
}
|
||||
}
|
|
@ -0,0 +1,272 @@
|
|||
package net.schowek.nextclouddlna.controller
|
||||
|
||||
|
||||
import net.schowek.nextclouddlna.dlna.media.MediaServer
|
||||
import org.jupnp.support.model.DIDLObject
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.http.HttpStatus
|
||||
import support.UpnpAwareSpecification
|
||||
|
||||
import static org.jupnp.support.model.Protocol.HTTP_GET
|
||||
import static org.jupnp.support.model.WriteStatus.NOT_WRITABLE
|
||||
|
||||
class UpnpControllerIntTest extends UpnpAwareSpecification {
|
||||
|
||||
@Autowired
|
||||
private MediaServer mediaServer
|
||||
|
||||
def uid
|
||||
|
||||
def setup() {
|
||||
uid = mediaServer.serviceIdentifier
|
||||
}
|
||||
|
||||
def "should serve icon"() {
|
||||
when:
|
||||
def response = restTemplate().getForEntity(urlWithPort("/dev/${uid}/icon.png"), byte[]);
|
||||
|
||||
then:
|
||||
response.statusCode == HttpStatus.OK
|
||||
with(response.headers.each { it.key.toLowerCase() }) {
|
||||
assert it['content-type'] == ['application/octet-stream']
|
||||
}
|
||||
}
|
||||
|
||||
def "should serve service descriptor"() {
|
||||
when:
|
||||
def response = restTemplate().getForEntity(urlWithPort("/dev/${uid}/desc"), String);
|
||||
|
||||
then:
|
||||
response.statusCode == HttpStatus.OK
|
||||
response.headers['content-type'] == ['text/xml']
|
||||
|
||||
when:
|
||||
def dom = createDocument(response)
|
||||
|
||||
then:
|
||||
nodeValue(dom, "/root/device/friendlyName") == "nextcloud-dlna-int-test"
|
||||
nodeValue(dom, "/root/device/UDN") == "uuid:${uid}"
|
||||
nodeValue(dom, "/root/device/presentationURL") == urlWithPort()
|
||||
}
|
||||
|
||||
def "should serve content directory desc"() {
|
||||
when:
|
||||
def response = restTemplate().getForEntity(urlWithPort("/dev/${uid}/svc/upnp-org/ContentDirectory/desc"), String);
|
||||
|
||||
then:
|
||||
response.statusCode == HttpStatus.OK
|
||||
response.headers['content-type'] == ['text/xml']
|
||||
|
||||
when:
|
||||
def dom = createDocument(response)
|
||||
|
||||
then:
|
||||
with(node(dom, "/scpd/serviceStateTable/stateVariable[name='A_ARG_TYPE_BrowseFlag']/allowedValueList").childNodes) {
|
||||
assert it.length == 2
|
||||
assert it.item(0).textContent == "BrowseMetadata"
|
||||
assert it.item(1).textContent == "BrowseDirectChildren"
|
||||
}
|
||||
}
|
||||
|
||||
def "should serve connectionMgr desc"() {
|
||||
when:
|
||||
def response = restTemplate().getForEntity(urlWithPort("/dev/${uid}/svc/upnp-org/ConnectionManager/desc"), String);
|
||||
|
||||
then:
|
||||
response.statusCode == HttpStatus.OK
|
||||
response.headers['content-type'] == ['text/xml']
|
||||
|
||||
when:
|
||||
def dom = createDocument(response)
|
||||
|
||||
then:
|
||||
with(nodesList(dom, "/scpd/actionList/action/name")) {
|
||||
assert it.length == 3
|
||||
it.item(0).textContent == "GetCurrentConnectionIDs"
|
||||
it.item(1).textContent == "GetProtocolInfo"
|
||||
it.item(2).textContent == "GetCurrentConnectionInfo"
|
||||
}
|
||||
}
|
||||
|
||||
def "should handle upnp browse metadata for ROOT request"() {
|
||||
given:
|
||||
def nodeId = "0"
|
||||
def browseFlag = "BrowseMetadata"
|
||||
|
||||
when:
|
||||
def response = performContentDirectoryAction(nodeId, browseFlag)
|
||||
|
||||
then:
|
||||
response.statusCode == HttpStatus.OK
|
||||
response.headers['content-type'] == ['text/xml;charset=utf-8']
|
||||
|
||||
when:
|
||||
def didl = extractDIDLFromResponse(response)
|
||||
|
||||
then:
|
||||
didl.containers.size() == 1
|
||||
with(didl.containers[0]) {
|
||||
assert id == "0"
|
||||
assert parentID == "-1"
|
||||
assert searchable
|
||||
assert restricted
|
||||
assert title == "ROOT"
|
||||
assert writeStatus == NOT_WRITABLE
|
||||
assert clazz.value == new DIDLObject.Class("object.container").value
|
||||
assert childCount == 3 // johndoe, janedoe, family folder
|
||||
}
|
||||
didl.items.size() == 0
|
||||
}
|
||||
|
||||
def "should handle upnp browse direct children for ROOT request"() {
|
||||
given:
|
||||
def nodeId = "0"
|
||||
def browseFlag = "BrowseDirectChildren"
|
||||
|
||||
when:
|
||||
def response = performContentDirectoryAction(nodeId, browseFlag)
|
||||
|
||||
then:
|
||||
response.statusCode == HttpStatus.OK
|
||||
response.headers['content-type'] == ['text/xml;charset=utf-8']
|
||||
|
||||
when:
|
||||
def didl = extractDIDLFromResponse(response)
|
||||
|
||||
then:
|
||||
didl.containers.size() == 3
|
||||
didl.containers.each {
|
||||
assert it.searchable
|
||||
assert it.restricted
|
||||
assert it.writeStatus == NOT_WRITABLE
|
||||
assert it.clazz.value == new DIDLObject.Class("object.container").value
|
||||
}
|
||||
|
||||
with(didl.containers[0]) {
|
||||
assert id == "2"
|
||||
assert parentID == "1"
|
||||
assert title == "johndoe"
|
||||
assert childCount == 3
|
||||
}
|
||||
|
||||
with(didl.containers[1]) {
|
||||
assert id == "387"
|
||||
assert parentID == "384"
|
||||
assert title == "janedoe"
|
||||
assert childCount == 1
|
||||
}
|
||||
|
||||
with(didl.containers[2]) {
|
||||
assert id == "586"
|
||||
assert parentID == "584"
|
||||
assert title == "family folder"
|
||||
assert childCount == 1
|
||||
}
|
||||
|
||||
didl.items.size() == 0
|
||||
}
|
||||
|
||||
def "should handle upnp browse metadata for johndoe's directory request"() {
|
||||
given:
|
||||
def nodeId = "2"
|
||||
def browseFlag = "BrowseMetadata"
|
||||
|
||||
when:
|
||||
def response = performContentDirectoryAction(nodeId, browseFlag)
|
||||
|
||||
then:
|
||||
response.statusCode == HttpStatus.OK
|
||||
response.headers['content-type'] == ['text/xml;charset=utf-8']
|
||||
|
||||
when:
|
||||
def didl = extractDIDLFromResponse(response)
|
||||
|
||||
then:
|
||||
didl.containers.size() == 1
|
||||
with(didl.containers[0]) {
|
||||
assert id == "2"
|
||||
assert parentID == "1"
|
||||
assert searchable
|
||||
assert restricted
|
||||
assert title == "johndoe"
|
||||
assert writeStatus == NOT_WRITABLE
|
||||
assert clazz.value == new DIDLObject.Class("object.container").value
|
||||
assert childCount == 3
|
||||
}
|
||||
didl.items.size() == 0
|
||||
}
|
||||
|
||||
def "should handle upnp browse direct children for johndoe's directory request"() {
|
||||
given:
|
||||
def nodeId = "2"
|
||||
def browseFlag = "BrowseDirectChildren"
|
||||
|
||||
when:
|
||||
def response = performContentDirectoryAction(nodeId, browseFlag)
|
||||
|
||||
then:
|
||||
response.statusCode == HttpStatus.OK
|
||||
response.headers['content-type'] == ['text/xml;charset=utf-8']
|
||||
|
||||
when:
|
||||
def didl = extractDIDLFromResponse(response)
|
||||
|
||||
then:
|
||||
didl.containers.size() == 1
|
||||
with(didl.containers[0]) {
|
||||
assert id == "15"
|
||||
assert parentID == "2"
|
||||
assert title == "photos"
|
||||
assert childCount == 2
|
||||
assert searchable
|
||||
assert restricted
|
||||
assert writeStatus == NOT_WRITABLE
|
||||
assert clazz.value == new DIDLObject.Class("object.container").value
|
||||
}
|
||||
|
||||
didl.items.size() == 2
|
||||
with(didl.items[0]) {
|
||||
assert it.id == "13"
|
||||
assert it.parentID == "2"
|
||||
assert title == "Nextcloud intro.mp4"
|
||||
assert !restricted
|
||||
|
||||
with(resources[0]) {
|
||||
assert protocolInfo.contentFormat == "video/mp4"
|
||||
assert protocolInfo.protocol == HTTP_GET
|
||||
assert size == 3963036
|
||||
assert value == urlWithPort("/c/13")
|
||||
}
|
||||
|
||||
with(resources[1]) { thumbnail ->
|
||||
assert thumbnail.protocolInfo.contentFormat == "image/jpeg"
|
||||
assert thumbnail.protocolInfo.protocol == HTTP_GET
|
||||
assert thumbnail.protocolInfo.additionalInfo == "DLNA.ORG_PN=JPEG_TN"
|
||||
assert thumbnail.size == 28820
|
||||
assert thumbnail.value == urlWithPort("/c/273")
|
||||
}
|
||||
}
|
||||
|
||||
with(didl.items[1]) {
|
||||
assert it.id == "14"
|
||||
assert it.parentID == "2"
|
||||
assert title == "Nextcloud.png"
|
||||
assert !restricted
|
||||
|
||||
with(resources[0]) {
|
||||
assert protocolInfo.contentFormat == "image/png"
|
||||
assert protocolInfo.protocol == HTTP_GET
|
||||
assert size == 50598
|
||||
assert value == urlWithPort("/c/14")
|
||||
}
|
||||
|
||||
with(resources[1]) { thumbnail ->
|
||||
assert thumbnail.protocolInfo.contentFormat == "image/png"
|
||||
assert thumbnail.protocolInfo.protocol == HTTP_GET
|
||||
assert thumbnail.protocolInfo.additionalInfo == "DLNA.ORG_PN=PNG_TN"
|
||||
assert thumbnail.size == 50545
|
||||
assert thumbnail.value == urlWithPort("/c/164")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package net.schowek.nextclouddlna.nextcloud.config
|
||||
|
||||
|
||||
import org.springframework.context.annotation.Profile
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
@Profile("integration")
|
||||
class NextcloudAppPathProviderInt implements NextcloudAppPathProvider {
|
||||
@Override
|
||||
File getNextcloudDir() {
|
||||
return new File(getClass().getResource("/nextcloud/app/data").getFile())
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package net.schowek.nextclouddlna.nextcloud.content
|
||||
|
||||
import net.schowek.nextclouddlna.nextcloud.db.AppConfigId
|
||||
import net.schowek.nextclouddlna.nextcloud.db.AppConfigRepository
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import spock.lang.Unroll
|
||||
import support.IntegrationSpecification
|
||||
|
||||
@Unroll
|
||||
class ContentTreeProviderIntTest extends IntegrationSpecification {
|
||||
@Autowired
|
||||
ContentTreeProvider contentTreeProvider
|
||||
@Autowired
|
||||
AppConfigRepository appConfigRepository
|
||||
|
||||
def "should create content tree including the group folder"() {
|
||||
when:
|
||||
contentTreeProvider.rebuildTree(true)
|
||||
|
||||
then:
|
||||
def root = contentTreeProvider.getNode("0")
|
||||
with(root) {
|
||||
nodes.size() == 3
|
||||
nodes[0].name == "johndoe"
|
||||
nodes[1].name == "janedoe"
|
||||
nodes[2].name == "family folder"
|
||||
}
|
||||
}
|
||||
|
||||
def "should create content tree without the group folder when the option is disabled"() {
|
||||
given:
|
||||
appConfigRepository.deleteById(new AppConfigId("groupfolders", "enabled"))
|
||||
|
||||
when:
|
||||
contentTreeProvider.rebuildTree(true)
|
||||
|
||||
then:
|
||||
def root = contentTreeProvider.getNode("0")
|
||||
with(root) {
|
||||
nodes.size() == 2
|
||||
nodes[0].name == "johndoe"
|
||||
nodes[1].name == "janedoe"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
package net.schowek.nextclouddlna.util
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.context.annotation.Profile
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
@Profile("integration")
|
||||
class ServerInfoProviderInt implements ServerInfoProvider {
|
||||
private final ServerPortCustomizer serverPortCustomizer
|
||||
|
||||
@Autowired
|
||||
ServerInfoProviderInt(ServerPortCustomizer serverPortCustomizer) {
|
||||
this.serverPortCustomizer = serverPortCustomizer
|
||||
}
|
||||
|
||||
@Override
|
||||
String getHost() {
|
||||
return "localhost"
|
||||
}
|
||||
|
||||
@Override
|
||||
int getPort() {
|
||||
return serverPortCustomizer.port
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
package net.schowek.nextclouddlna.util
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.boot.web.server.ConfigurableWebServerFactory
|
||||
import org.springframework.boot.web.server.WebServerFactoryCustomizer
|
||||
import org.springframework.context.annotation.Profile
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
@Profile("integration")
|
||||
public class ServerPortCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
|
||||
@Value("\${random.int(9090,65535)}")
|
||||
int port
|
||||
|
||||
int getPort() {
|
||||
return port
|
||||
}
|
||||
|
||||
@Override
|
||||
void customize(ConfigurableWebServerFactory factory) {
|
||||
factory.setPort(port);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package support
|
||||
|
||||
import net.schowek.nextclouddlna.NextcloudDLNAApp
|
||||
import net.schowek.nextclouddlna.util.ServerInfoProvider
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootContextLoader
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate
|
||||
import org.springframework.test.context.ActiveProfiles
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import spock.lang.Specification
|
||||
|
||||
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT
|
||||
|
||||
@ContextConfiguration(loader = SpringBootContextLoader, classes = NextcloudDLNAApp.class)
|
||||
@SpringBootTest(webEnvironment = DEFINED_PORT)
|
||||
@ActiveProfiles("integration")
|
||||
class IntegrationSpecification extends Specification {
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate
|
||||
|
||||
TestRestTemplate restTemplate() {
|
||||
return restTemplate
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private ServerInfoProvider serverInfoProvider
|
||||
|
||||
protected String urlWithPort(String uri = "") {
|
||||
return "http://localhost:" + serverInfoProvider.port + uri;
|
||||
}
|
||||
}
|
71
src/integration/groovy/support/UpnpAwareSpecification.groovy
Normal file
|
@ -0,0 +1,71 @@
|
|||
package support
|
||||
|
||||
import org.jupnp.support.contentdirectory.DIDLParser
|
||||
import org.jupnp.support.model.DIDLContent
|
||||
import org.springframework.http.HttpEntity
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.w3c.dom.Document
|
||||
import org.w3c.dom.Node
|
||||
import org.w3c.dom.NodeList
|
||||
import org.xml.sax.InputSource
|
||||
import support.IntegrationSpecification
|
||||
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
import javax.xml.xpath.XPath
|
||||
import javax.xml.xpath.XPathFactory
|
||||
|
||||
import static javax.xml.xpath.XPathConstants.NODE
|
||||
import static javax.xml.xpath.XPathConstants.NODESET
|
||||
|
||||
class UpnpAwareSpecification extends IntegrationSpecification {
|
||||
Document createDocument(ResponseEntity<String> response) {
|
||||
return DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(
|
||||
new InputSource(new StringReader(response.body))
|
||||
);
|
||||
}
|
||||
|
||||
String nodeValue(Document dom, String pattern) {
|
||||
return node(dom, "$pattern/text()").nodeValue
|
||||
}
|
||||
|
||||
Node node(Document dom, String pattern) {
|
||||
XPath xpath = XPathFactory.newInstance().newXPath();
|
||||
return (xpath.evaluate(pattern, dom, NODE) as Node)
|
||||
}
|
||||
|
||||
NodeList nodesList(Document dom, String pattern) {
|
||||
XPath xpath = XPathFactory.newInstance().newXPath();
|
||||
return (xpath.evaluate(pattern, dom, NODESET) as NodeList)
|
||||
}
|
||||
|
||||
ResponseEntity performContentDirectoryAction(String nodeId, String browseFlag) {
|
||||
def requestBody = '<?xml version="1.0" encoding="utf-8"?>\n' +
|
||||
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"\n' +
|
||||
' s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">\n' +
|
||||
' <s:Body>\n' +
|
||||
' <u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">\n' +
|
||||
' <ObjectID>' + nodeId + '</ObjectID>\n' +
|
||||
' <BrowseFlag>' + browseFlag + '</BrowseFlag>\n' +
|
||||
' <Filter>*</Filter>\n' +
|
||||
' <StartingIndex>0</StartingIndex>\n' +
|
||||
' <RequestedCount>200</RequestedCount>\n' +
|
||||
' <SortCriteria></SortCriteria>\n' +
|
||||
' </u:Browse>\n' +
|
||||
' </s:Body>\n' +
|
||||
'</s:Envelope>'
|
||||
def headers = new HttpHeaders([
|
||||
'content-type': 'text/xml; charset="utf-8"',
|
||||
'soapaction' : '"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"'
|
||||
]);
|
||||
HttpEntity<String> request = new HttpEntity<String>(requestBody, headers);
|
||||
return restTemplate().postForEntity(urlWithPort("/dev/$uid/svc/upnp-org/ContentDirectory/action"), request, String)
|
||||
}
|
||||
|
||||
DIDLContent extractDIDLFromResponse(ResponseEntity<String> response) {
|
||||
Document dom = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(
|
||||
new InputSource(new StringReader(response.body))
|
||||
);
|
||||
return new DIDLParser().parse(nodeValue(dom, "/Envelope/Body/BrowseResponse/Result"))
|
||||
}
|
||||
}
|
18
src/integration/resources/application-integration.yml
Normal file
|
@ -0,0 +1,18 @@
|
|||
server:
|
||||
friendlyName: "nextcloud-dlna-int-test"
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:h2:mem:nextcloud-dlna-integration
|
||||
username: sa
|
||||
password:
|
||||
driverClassName: org.h2.Driver
|
||||
jpa:
|
||||
database-platform: org.hibernate.dialect.H2Dialect
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
sql:
|
||||
init:
|
||||
mode: always
|
||||
schema-locations: classpath:db/schema.sql
|
||||
data-locations: classpath:db/data.sql
|
4
src/integration/resources/application.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
spring:
|
||||
profiles:
|
||||
active: integration
|
||||
|
84
src/integration/resources/db/data.sql
Normal file
|
@ -0,0 +1,84 @@
|
|||
|
||||
INSERT INTO `oc_appconfig` VALUES
|
||||
('groupfolders','enabled','yes'),
|
||||
('groupfolders','installed_version','15.3.1'),
|
||||
('groupfolders','types','filesystem,dav');
|
||||
|
||||
INSERT INTO `oc_filecache` VALUES
|
||||
(1,1,'','d41d8cd98f00b204e9800998ecf8427e',-1,'',2,1,5341566992,1696704138,1696613362,0,0,'',23,''),
|
||||
(3,2,'','d41d8cd98f00b204e9800998ecf8427e',-1,'',2,1,13286908,1696702221,1696695204,0,0,'',23,''),
|
||||
(384,3,'','d41d8cd98f00b204e9800998ecf8427e',-1,'',2,1,37549364,1696702111,1695740236,0,0,'',23,''),
|
||||
|
||||
(2,1,'files','45b963397aa40d4a0063e0d85e4fe7a1',1,'files',2,1,5341560694,1696704138,1696704138,0,0,'',31,''),
|
||||
(13,1,'files/Nextcloud intro.mp4','e4919345bcc87d4585a5525daaad99c0',2,'Nextcloud intro.mp4',9,8,3963036,1695737656,1695737656,0,0,'',27,''),
|
||||
(14,1,'files/Nextcloud.png','',2,'Nextcloud.png',11,10,50598,1695737656,1695737656,0,0,'',27,''),
|
||||
(15,1,'files/photos','d01bb67e7b71dd49fd06bad922f521c9',2,'photos',2,1,5656462,1695737827,1695737658,0,0,'',31,''),
|
||||
(16,1,'files/photos/Birdie.jpg','cd31c7af3a0ec6e15782b5edd2774549',15,'Birdie.jpg',12,10,593508,1695737656,1695737656,0,0,'',27,''),
|
||||
(17,1,'files/photos/Frog.jpg','d6219add1a9129ed0c1513af985e2081',15,'Frog.jpg',12,10,457744,1695737656,1695737656,0,0,'',27,''),
|
||||
|
||||
(387,3,'files','45b963397aa40d4a0063e0d85e4fe7a1',384,'files',2,1,9670097,1695740237,1695740237,0,0,'',31,''),
|
||||
(396,3,'files/pictures','d01bb67e7b71dd49fd06bad922f521c9',387,'pictures',2,1,5656463,1695740165,1695740165,0,0,'',31,''),
|
||||
(397,3,'files/pictures/Steps.jpg','7b2ca8d05bbad97e00cbf5833d43e912',396,'Steps.jpg',12,10,567689,1695737658,1695737658,0,0,'',27,''),
|
||||
(398,3,'files/pictures/Toucan.jpg','681d1e78f46a233e12ecfa722cbc2aef',396,'Toucan.jpg',12,10,167989,1695737658,1695737658,0,0,'',27,''),
|
||||
|
||||
(584,2,'__groupfolders','29ff0edf73a32cb03e437d88fd049245',3,'__groupfolders',2,1,3012412,1696702221,1696702221,0,0,'',31,''),
|
||||
(586,2,'__groupfolders/1','e46cc72327dfc3ccaf32f0a167e6c6d1',584,'1',2,1,3012412,1696695701,1696695701,0,0,'',31,''),
|
||||
(587,2,'__groupfolders/Nextcloud community.jpg','b9b3caef83a2a1c20354b98df6bcd9d0',586,'Nextcloud community.jpg',12,10,797325,1695737657,1695737657,0,0,
|
||||
'',27,''),
|
||||
|
||||
(4,2,'appdata_integration','bed7fa8a60170b5d88c9da5e69eaeb5a',3,'appdata_integration',2,1,10274496,1695737790,1695737790,0,0,'',31,''),
|
||||
(69,2,'appdata_integration/preview','e771733d5f59ead277f502588282d693',4,'preview',2,1,5153144,1695738765,1695738765,0,0,'',31,''),
|
||||
(118,2,'appdata_integration/preview/0','7a4d3ccae601e9499e1ab6c444389190',69,'c',2,1,182196,1695738765,1695738765,0,0,'',31,''),
|
||||
(266,2,'appdata_integration/preview/0/0','06062a1c21b0baf285ceeb0f6cc5ccf5',118,'5',2,1,52904,1695738113,1695738113,0,0,'',31,''),
|
||||
(267,2,'appdata_integration/preview/0/0/0','caef481eed68975e892a313693abbc1d',266,'1',2,1,52904,1695738113,1695738113,0,0,'',31,''),
|
||||
(268,2,'appdata_integration/preview/0/0/0/0','e5373ed53baeecb521bfaf52e7732241',267,'c',2,1,52904,1695738113,1695738113,0,0,'',31,''),
|
||||
(269,2,'appdata_integration/preview/0/0/0/0/0','e91eaac3bf03ce5442e638b0e9c82d01',268,'e',2,1,52904,1695738113,1695738113,0,0,'',31,''),
|
||||
(270,2,'appdata_integration/preview/0/0/0/0/0/0','efb8bf57b9f71e52d73f51671eb4cc83',269,'4',2,1,52904,1695738113,1695738113,0,0,'',31,''),
|
||||
(271,2,'appdata_integration/preview/0/0/0/0/0/0/0','515ef6dc4c2b8c8abeff3b29ad645aa4',270,'1',2,1,52904,1695738113,1695738113,0,0,'',31,''),
|
||||
|
||||
(272,2,'appdata_integration/preview/0/0/0/0/0/0/0/13','3ca72f7aea8335c7a4bd43c2e4bf89ab',271,'13',2,1,52904,1696697007,1696697007,0,0,'',31,''),
|
||||
(273,2,'appdata_integration/preview/0/0/0/0/0/0/0/13/1000-563-max.jpg','de54b3c7b20873a40a485bc666faaf8c',272,'1000-563-max.jpg',12,10,28820,1695738114,1695738114,0,0,'',27,''),
|
||||
(694,2,'appdata_integration/preview/0/0/0/0/0/0/0/13/455-256.jpg','08aa43b18c0bf4824edc33e8b852ab08',272,'455-256.jpg',12,10,9399,1696697007,1696697007,0,0,'',27,''),
|
||||
|
||||
(155,2,'appdata_integration/preview/0/0/0/0/0/0/0/14','bb5cbf8f27a56a12e83245d5532d0381',271,'14',2,1,107111,1696697008,1696697008,0,0,'',31,''),
|
||||
(164,2,'appdata_integration/preview/0/0/0/0/0/0/0/14/500-500-max.png','15b68a8c62dab8e7c3773ce0d1ac4f43',155,'500-500-max.png',11,10,50545,1695737792,1695737792,0,0,'',27,''),
|
||||
|
||||
(803,2,'appdata_integration/preview/0/0/0/0/0/0/0/16','71e9a2780187bcba09819c38341af594',271,'16',2,1,129292,1696698525,1696698525,0,0,'',31,''),
|
||||
(804,2,'appdata_integration/preview/0/0/0/0/0/0/0/16/1000-667-max.jpg','c6df2f52ea6e2bce26b0c0c9cb99b7a5',803,'1000-667-max.jpg',12,10,81421,1696698525,1696698525,0,0,'',27,''),
|
||||
|
||||
(811,2,'appdata_integration/preview/0/0/0/0/0/0/0/17','9fbd157aa2c41b17eb7ec32e9f93551f',271,'17',2,1,84401,1696698525,1696698525,0,0,'',31,''),
|
||||
(812,2,'appdata_integration/preview/0/0/0/0/0/0/0/17/1000-667-max.jpg','351f42173986513e412dbcc450bd4b19',811,'1000-667-max.jpg',12,10,53672,1696698525,1696698525,0,0,'',27,'');
|
||||
|
||||
INSERT INTO `oc_group_folders` VALUES
|
||||
(1,'family folder',-3,0);
|
||||
|
||||
INSERT INTO `oc_mimetypes` VALUES
|
||||
(5,'application'),
|
||||
(19,'application/gzip'),
|
||||
(18,'application/javascript'),
|
||||
(20,'application/json'),
|
||||
(16,'application/octet-stream'),
|
||||
(6,'application/pdf'),
|
||||
(13,'application/vnd.oasis.opendocument.graphics'),
|
||||
(15,'application/vnd.oasis.opendocument.presentation'),
|
||||
(14,'application/vnd.oasis.opendocument.spreadsheet'),
|
||||
(17,'application/vnd.oasis.opendocument.text'),
|
||||
(7,'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
|
||||
(22,'audio'),
|
||||
(23,'audio/mpeg'),
|
||||
(1,'httpd'),
|
||||
(2,'httpd/unix-directory'),
|
||||
(10,'image'),
|
||||
(12,'image/jpeg'),
|
||||
(11,'image/png'),
|
||||
(21,'image/svg+xml'),
|
||||
(3,'text'),
|
||||
(4,'text/markdown'),
|
||||
(8,'video'),
|
||||
(9,'video/mp4');
|
||||
|
||||
INSERT INTO `oc_mounts` VALUES
|
||||
(1,1,1,'johndoe','/johndoe/',NULL,'OC\\Files\\Mount\\LocalHomeMountProvider'),
|
||||
(2,3,384,'janedoe','/janedoe/',NULL,'OC\\Files\\Mount\\LocalHomeMountProvider'),
|
||||
(3,2,586,'johndoe','/johndoe/files/family folder/',NULL,'OCA\\GroupFolders\\Mount\\MountProvider'),
|
||||
(4,2,586,'janedoe','/janedoe/files/family folder/',NULL,'OCA\\GroupFolders\\Mount\\MountProvider');
|
76
src/integration/resources/db/schema.sql
Normal file
|
@ -0,0 +1,76 @@
|
|||
DROP TABLE IF EXISTS oc_appconfig;
|
||||
CREATE TABLE `oc_appconfig` (
|
||||
`appid` varchar(32) NOT NULL DEFAULT '',
|
||||
`configkey` varchar(64) NOT NULL DEFAULT '',
|
||||
`configvalue` longtext DEFAULT NULL,
|
||||
PRIMARY KEY (`appid`,`configkey`)
|
||||
);
|
||||
|
||||
CREATE INDEX `appconfig_config_key_index` ON oc_appconfig(`configkey`);
|
||||
|
||||
DROP TABLE IF EXISTS oc_filecache;
|
||||
CREATE TABLE `oc_filecache` (
|
||||
`fileid` NUMERIC(20) NOT NULL AUTO_INCREMENT,
|
||||
`storage` NUMERIC(20) NOT NULL DEFAULT 0,
|
||||
`path` varchar(4000) DEFAULT NULL,
|
||||
`path_hash` varchar(32) NOT NULL DEFAULT '',
|
||||
`parent` NUMERIC(20) NOT NULL DEFAULT 0,
|
||||
`name` varchar(250) DEFAULT NULL,
|
||||
`mimetype` NUMERIC(20) NOT NULL DEFAULT 0,
|
||||
`mimepart` NUMERIC(20) NOT NULL DEFAULT 0,
|
||||
`size` NUMERIC(20) NOT NULL DEFAULT 0,
|
||||
`mtime` NUMERIC(20) NOT NULL DEFAULT 0,
|
||||
`storage_mtime` NUMERIC(20) NOT NULL DEFAULT 0,
|
||||
`encrypted` INTEGER NOT NULL DEFAULT 0,
|
||||
`unencrypted_size` NUMERIC(20) NOT NULL DEFAULT 0,
|
||||
`etag` varchar(40) DEFAULT NULL,
|
||||
`permissions` INTEGER DEFAULT 0,
|
||||
`checksum` varchar(255) DEFAULT NULL,
|
||||
PRIMARY KEY (`fileid`)
|
||||
);
|
||||
CREATE UNIQUE INDEX `fs_storage_path_hash` ON oc_filecache(`storage`,`path_hash`);
|
||||
CREATE INDEX `fs_parent_name_hash` ON oc_filecache(`parent`,`name`);
|
||||
CREATE INDEX `fs_storage_mimetype` ON oc_filecache(`storage`,`mimetype`);
|
||||
CREATE INDEX `fs_storage_mimepart` ON oc_filecache(`storage`,`mimepart`);
|
||||
CREATE INDEX `fs_storage_size` ON oc_filecache(`storage`,`size`,`fileid`);
|
||||
CREATE INDEX `fs_id_storage_size` ON oc_filecache(`fileid`,`storage`,`size`);
|
||||
CREATE INDEX `fs_parent` ON oc_filecache(`parent`);
|
||||
CREATE INDEX `fs_mtime` ON oc_filecache(`mtime`);
|
||||
CREATE INDEX `fs_size` ON oc_filecache(`size`);
|
||||
CREATE INDEX `fs_storage_path_prefix` ON oc_filecache(`storage`,`path`);
|
||||
|
||||
DROP TABLE IF EXISTS oc_group_folders;
|
||||
CREATE TABLE `oc_group_folders` (
|
||||
`folder_id` NUMERIC(20) NOT NULL AUTO_INCREMENT,
|
||||
`mount_point` varchar(4000) NOT NULL,
|
||||
`quota` NUMERIC(20) NOT NULL DEFAULT -3,
|
||||
`acl` INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (`folder_id`)
|
||||
);
|
||||
|
||||
DROP TABLE IF EXISTS oc_mimetypes;
|
||||
CREATE TABLE `oc_mimetypes` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`mimetype` varchar(255) NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (`id`)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX `mimetype_id_index` ON oc_mimetypes(`mimetype`);
|
||||
|
||||
DROP TABLE IF EXISTS oc_mounts;
|
||||
CREATE TABLE `oc_mounts` (
|
||||
`id` NUMERIC(20) NOT NULL AUTO_INCREMENT,
|
||||
`storage_id` NUMERIC(20) NOT NULL,
|
||||
`root_id` NUMERIC(20) NOT NULL,
|
||||
`user_id` varchar(64) NOT NULL,
|
||||
`mount_point` varchar(4000) NOT NULL,
|
||||
`mount_id` NUMERIC(20) DEFAULT NULL,
|
||||
`mount_provider_class` varchar(128) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
);
|
||||
CREATE INDEX `mounts_storage_index` ON oc_mounts(`storage_id`);
|
||||
CREATE INDEX `mounts_root_index` ON oc_mounts(`root_id`);
|
||||
CREATE INDEX `mounts_mount_id_index` ON oc_mounts(`mount_id`);
|
||||
CREATE INDEX `mounts_user_root_path_index` ON oc_mounts(`user_id`,`root_id`,`mount_point`);
|
||||
CREATE INDEX `mounts_class_index` ON oc_mounts(`mount_provider_class`);
|
||||
CREATE INDEX `mount_user_storage` ON oc_mounts(`storage_id`,`user_id`);
|
After Width: | Height: | Size: 779 KiB |
After Width: | Height: | Size: 28 KiB |
After Width: | Height: | Size: 9.2 KiB |
After Width: | Height: | Size: 49 KiB |
After Width: | Height: | Size: 80 KiB |
After Width: | Height: | Size: 52 KiB |
After Width: | Height: | Size: 554 KiB |
After Width: | Height: | Size: 164 KiB |
After Width: | Height: | Size: 49 KiB |
After Width: | Height: | Size: 580 KiB |
After Width: | Height: | Size: 447 KiB |
|
@ -2,7 +2,6 @@ package net.schowek.nextclouddlna
|
|||
|
||||
import jakarta.annotation.PreDestroy
|
||||
import mu.KLogging
|
||||
import net.schowek.nextclouddlna.dlna.RegistryImplWithOverrides
|
||||
import net.schowek.nextclouddlna.dlna.media.MediaServer
|
||||
import net.schowek.nextclouddlna.dlna.transport.ApacheStreamClient
|
||||
import net.schowek.nextclouddlna.dlna.transport.ApacheStreamClientConfiguration
|
||||
|
@ -10,21 +9,15 @@ 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.UpnpService
|
||||
import org.jupnp.UpnpServiceConfiguration
|
||||
import org.jupnp.UpnpServiceImpl
|
||||
import org.jupnp.model.message.StreamRequestMessage
|
||||
import org.jupnp.model.message.StreamResponseMessage
|
||||
import org.jupnp.model.message.UpnpResponse
|
||||
import org.jupnp.model.meta.LocalDevice
|
||||
import org.jupnp.protocol.ProtocolFactory
|
||||
import org.jupnp.protocol.ProtocolFactoryImpl
|
||||
import org.jupnp.protocol.async.SendingNotificationAlive
|
||||
import org.jupnp.registry.Registry
|
||||
import org.jupnp.registry.RegistryImpl
|
||||
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.context.event.ContextRefreshedEvent
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.stereotype.Component
|
||||
|
@ -37,7 +30,7 @@ class DlnaService(
|
|||
private val mediaServer: MediaServer,
|
||||
private val serverInfoProvider: ServerInfoProvider,
|
||||
) {
|
||||
private val addressesToBind: List<InetAddress> = listOf(serverInfoProvider.address!!)
|
||||
private val addressesToBind: List<String> = listOf(serverInfoProvider.host)
|
||||
var upnpService = MyUpnpService(MyUpnpServiceConfiguration())
|
||||
|
||||
fun start() {
|
||||
|
@ -71,8 +64,7 @@ class DlnaService(
|
|||
inner class MyUpnpService(
|
||||
configuration: UpnpServiceConfiguration
|
||||
) : UpnpServiceImpl(configuration) {
|
||||
override fun createRegistry(pf: ProtocolFactory) =
|
||||
RegistryImplWithOverrides(this)
|
||||
override fun createRegistry(pf: ProtocolFactory) = RegistryImpl(this)
|
||||
}
|
||||
|
||||
private inner class MyUpnpServiceConfiguration : DefaultUpnpServiceConfiguration(serverInfoProvider.port) {
|
||||
|
@ -91,7 +83,7 @@ class DlnaService(
|
|||
multicastResponsePort: Int
|
||||
) : NetworkAddressFactoryImpl(streamListenPort, multicastResponsePort) {
|
||||
override fun isUsableAddress(iface: NetworkInterface, address: InetAddress) =
|
||||
addressesToBind.contains(address)
|
||||
addressesToBind.contains(address.hostAddress)
|
||||
}
|
||||
|
||||
companion object : KLogging()
|
||||
|
|
|
@ -35,16 +35,21 @@ class ContentController(
|
|||
): ResponseEntity<FileSystemResource> {
|
||||
return contentTreeProvider.getItem(id)?.let { item ->
|
||||
if (!request.getHeaders("range").hasMoreElements()) {
|
||||
logger.info("Serving content {} {}", request.method, id)
|
||||
logger.info("Serving content ${request.method} $id")
|
||||
}
|
||||
val fileSystemResource = FileSystemResource(item.path)
|
||||
if (!fileSystemResource.exists()) {
|
||||
logger.info("Could not find file ${fileSystemResource.path} for item id: $id")
|
||||
ResponseEntity(HttpStatus.NOT_FOUND)
|
||||
} else {
|
||||
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=*")
|
||||
ResponseEntity(fileSystemResource, HttpStatus.OK)
|
||||
}
|
||||
} ?: let {
|
||||
logger.info("Could not find item id: {}", id)
|
||||
logger.info("Could not find item id: $id")
|
||||
ResponseEntity(HttpStatus.NOT_FOUND)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,12 +3,10 @@ package net.schowek.nextclouddlna.controller
|
|||
import jakarta.servlet.http.HttpServletRequest
|
||||
import mu.KLogging
|
||||
import net.schowek.nextclouddlna.DlnaService
|
||||
import net.schowek.nextclouddlna.dlna.StreamRequestMapper
|
||||
import net.schowek.nextclouddlna.dlna.StreamMessageMapper
|
||||
import net.schowek.nextclouddlna.dlna.media.MediaServer
|
||||
import org.springframework.core.io.InputStreamResource
|
||||
import org.springframework.core.io.Resource
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpStatusCode
|
||||
import org.springframework.http.MediaType.APPLICATION_OCTET_STREAM_VALUE
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
|
@ -20,7 +18,7 @@ import org.springframework.web.bind.annotation.RestController
|
|||
|
||||
@RestController
|
||||
class UpnpController(
|
||||
private val streamRequestMapper: StreamRequestMapper,
|
||||
private val streamMessageMapper: StreamMessageMapper,
|
||||
private val dlnaService: DlnaService
|
||||
) {
|
||||
@RequestMapping(
|
||||
|
@ -50,12 +48,10 @@ class UpnpController(
|
|||
request: HttpServletRequest
|
||||
): ResponseEntity<Any> {
|
||||
logger.info { "Upnp ${request.method} request from ${request.remoteAddr}: ${request.requestURI}" }
|
||||
return with(dlnaService.processRequest(streamRequestMapper.map(request))) {
|
||||
ResponseEntity(
|
||||
body,
|
||||
HttpHeaders().also { h -> headers.entries.forEach { e -> h.add(e.key, e.value.joinToString { it }) } },
|
||||
HttpStatusCode.valueOf(operation.statusCode)
|
||||
)
|
||||
return streamMessageMapper.map(request).let { req ->
|
||||
dlnaService.processRequest(req).let { res ->
|
||||
streamMessageMapper.map(res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
package net.schowek.nextclouddlna.dlna
|
||||
|
||||
import mu.KLogging
|
||||
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 org.jupnp.registry.RegistryMaintainer
|
||||
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)
|
||||
}
|
||||
|
||||
companion object : KLogging()
|
||||
}
|
|
@ -2,13 +2,18 @@ package net.schowek.nextclouddlna.dlna
|
|||
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import mu.KLogging
|
||||
import org.jupnp.model.message.*
|
||||
import org.jupnp.model.message.StreamRequestMessage
|
||||
import org.jupnp.model.message.StreamResponseMessage
|
||||
import org.jupnp.model.message.UpnpHeaders
|
||||
import org.jupnp.model.message.UpnpRequest
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpStatusCode
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.stereotype.Component
|
||||
import java.net.InetAddress
|
||||
import java.net.URI
|
||||
|
||||
@Component
|
||||
class StreamRequestMapper {
|
||||
class StreamMessageMapper {
|
||||
fun map(request: HttpServletRequest): StreamRequestMessage {
|
||||
val requestMessage = StreamRequestMessage(
|
||||
UpnpRequest.Method.getByHttpName(request.method),
|
||||
|
@ -21,11 +26,25 @@ class StreamRequestMapper {
|
|||
throw RuntimeException("Method not supported: {}" + request.method)
|
||||
}
|
||||
|
||||
requestMessage.headers = createHeaders(request)
|
||||
requestMessage.headers = upnpHeaders(request)
|
||||
return requestMessage
|
||||
}
|
||||
|
||||
private fun createHeaders(request: HttpServletRequest): UpnpHeaders {
|
||||
fun map(response: StreamResponseMessage): ResponseEntity<Any> {
|
||||
return with(response) {
|
||||
ResponseEntity(
|
||||
body,
|
||||
HttpHeaders().also { h ->
|
||||
headers.entries.forEach { e ->
|
||||
h.add(e.key, e.value.joinToString { it })
|
||||
}
|
||||
},
|
||||
HttpStatusCode.valueOf(operation.statusCode)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun upnpHeaders(request: HttpServletRequest): UpnpHeaders {
|
||||
val headers = mutableMapOf<String, List<String>>()
|
||||
request.headerNames?.let {
|
||||
while (it.hasMoreElements()) {
|
|
@ -28,13 +28,15 @@ class MediaServer(
|
|||
createDeviceIcon(),
|
||||
arrayOf(contentDirectoryService, connectionManagerService)
|
||||
)
|
||||
val serviceIdentifier: String get() = device.identity.udn.identifierString
|
||||
|
||||
init {
|
||||
logger.info("uniqueSystemIdentifier: {} ({})", device.identity.udn, friendlyName)
|
||||
}
|
||||
|
||||
|
||||
companion object : KLogging() {
|
||||
const val ICON_FILENAME = "icon.png"
|
||||
private const val ICON_FILENAME = "icon.png"
|
||||
private const val DEVICE_TYPE = "MediaServer"
|
||||
private const val VERSION = 1
|
||||
private const val ADVERTISEMENT_AGE_IN_S = 60
|
||||
|
|
|
@ -115,7 +115,7 @@ class ApacheStreamClient(
|
|||
): Callable<StreamResponseMessage> {
|
||||
return Callable<StreamResponseMessage> {
|
||||
logger.trace("Sending HTTP request: $requestMessage")
|
||||
httpClient.execute<StreamResponseMessage>(request, createResponseHandler(requestMessage))
|
||||
httpClient.execute(request, createResponseHandler())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -141,7 +141,7 @@ class ApacheStreamClient(
|
|||
clientConnectionManager.shutdown()
|
||||
}
|
||||
|
||||
private fun createResponseHandler(requestMessage: StreamRequestMessage?): ResponseHandler<StreamResponseMessage> {
|
||||
private fun createResponseHandler(): ResponseHandler<StreamResponseMessage> {
|
||||
return ResponseHandler<StreamResponseMessage> { response ->
|
||||
val statusLine = response.statusLine
|
||||
logger.trace("Received HTTP response: $statusLine")
|
||||
|
|
|
@ -2,6 +2,7 @@ package net.schowek.nextclouddlna.nextcloud
|
|||
|
||||
import jakarta.annotation.PostConstruct
|
||||
import mu.KLogging
|
||||
import net.schowek.nextclouddlna.nextcloud.config.NextcloudConfigDiscovery
|
||||
import net.schowek.nextclouddlna.nextcloud.content.ContentItem
|
||||
import net.schowek.nextclouddlna.nextcloud.content.ContentNode
|
||||
import net.schowek.nextclouddlna.nextcloud.content.MediaFormat
|
||||
|
@ -59,9 +60,13 @@ class NextcloudDB(
|
|||
}
|
||||
|
||||
private fun asItem(f: Filecache): ContentItem {
|
||||
try {
|
||||
val format = MediaFormat.fromMimeType(mimetypes[f.mimetype]!!)
|
||||
val path: String = buildPath(f)
|
||||
return ContentItem(f.id, f.parent, f.name, path, format, f.size)
|
||||
} catch (e: Exception) {
|
||||
throw RuntimeException("Unable to create ContentItem for ${f.path}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun asNode(f: Filecache): ContentNode {
|
||||
|
@ -96,9 +101,9 @@ class NextcloudDB(
|
|||
private fun buildPath(f: Filecache): String {
|
||||
return if (storageUsersMap.containsKey(f.storage)) {
|
||||
val userName: String? = storageUsersMap[f.storage]
|
||||
"${nextcloudConfig.nextcloudDir}/$userName/${f.path}"
|
||||
"${nextcloudConfig.nextcloudDir.absolutePath}/$userName/${f.path}"
|
||||
} else {
|
||||
"${nextcloudConfig.nextcloudDir}/${f.path}"
|
||||
"${nextcloudConfig.nextcloudDir.absolutePath}/${f.path}"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
package net.schowek.nextclouddlna.nextcloud.config
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.annotation.Profile
|
||||
import org.springframework.stereotype.Component
|
||||
import java.io.File
|
||||
|
||||
interface NextcloudAppPathProvider {
|
||||
val nextcloudDir: File
|
||||
}
|
||||
|
||||
@Component
|
||||
@Profile("!integration")
|
||||
class NextcloudAppPathsProviderImpl(
|
||||
@Value("\${nextcloud.filesDir}")
|
||||
private val dirPath: String,
|
||||
) : NextcloudAppPathProvider {
|
||||
override val nextcloudDir = ensureNextcloudDir(dirPath)
|
||||
|
||||
private fun ensureNextcloudDir(dirPath: String): File {
|
||||
if (dirPath.isEmpty()) {
|
||||
throw RuntimeException("No nextcloud data directory name provided")
|
||||
}
|
||||
return File(dirPath).also {
|
||||
if (!it.exists() || !it.isDirectory) {
|
||||
throw RuntimeException("Invalid nextcloud data directory specified")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,31 +1,28 @@
|
|||
package net.schowek.nextclouddlna.nextcloud
|
||||
package net.schowek.nextclouddlna.nextcloud.config
|
||||
|
||||
import jakarta.annotation.PostConstruct
|
||||
import mu.KLogging
|
||||
import net.schowek.nextclouddlna.nextcloud.db.AppConfigRepository
|
||||
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
|
||||
val appConfigRepository: AppConfigRepository,
|
||||
val nextcloudDirProvider: NextcloudAppPathProvider
|
||||
) {
|
||||
final val appDataDir: String = findAppDataDir()
|
||||
final val supportsGroupFolders: Boolean = checkGroupFoldersSupport()
|
||||
val appDataDir: String = findAppDataDir()
|
||||
val nextcloudDir: File get() = nextcloudDirProvider.nextcloudDir
|
||||
val supportsGroupFolders: Boolean get() = checkGroupFoldersSupport()
|
||||
|
||||
private fun checkGroupFoldersSupport(): Boolean {
|
||||
fun checkGroupFoldersSupport(): Boolean {
|
||||
return "yes" == appConfigRepository.getValue("groupfolders", "enabled")
|
||||
}
|
||||
|
||||
private fun findAppDataDir(): String {
|
||||
return stream(requireNonNull(File(nextcloudDir).listFiles { f ->
|
||||
return stream(requireNonNull(nextcloudDir.listFiles { f ->
|
||||
f.isDirectory && f.name.matches(APPDATA_NAME_PATTERN.toRegex())
|
||||
})).findFirst().orElseThrow().name
|
||||
.also {
|
|
@ -4,6 +4,7 @@ import mu.KLogging
|
|||
import net.schowek.nextclouddlna.nextcloud.NextcloudDB
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Component
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.regex.Pattern
|
||||
|
@ -11,23 +12,30 @@ import java.util.regex.Pattern
|
|||
|
||||
@Component
|
||||
class ContentTreeProvider(
|
||||
private val nextcloudDB: NextcloudDB
|
||||
private val nextcloudDB: NextcloudDB,
|
||||
private val clock: Clock
|
||||
) {
|
||||
private var tree = buildContentTree()
|
||||
private var lastMTime = 0L
|
||||
var lastMTime = 0L
|
||||
|
||||
@Scheduled(fixedDelay = REBUILD_TREE_DELAY_IN_MS, initialDelay = REBUILD_TREE_INIT_DELAY_IN_MS)
|
||||
final fun rebuildTree() {
|
||||
val maxMtime: Long = nextcloudDB.maxMtime()
|
||||
val now = Instant.now().epochSecond
|
||||
if (lastMTime < maxMtime || lastMTime + MAX_REBUILD_OFFSET_IN_S > now) {
|
||||
logger.info("ContentTree seems to be outdated - Loading...")
|
||||
this.tree = buildContentTree()
|
||||
lastMTime = maxMtime
|
||||
}
|
||||
final fun rebuildTree(): Boolean {
|
||||
return rebuildTree(false)
|
||||
}
|
||||
|
||||
private fun buildContentTree(): ContentTree {
|
||||
final fun rebuildTree(force: Boolean): Boolean {
|
||||
val maxMtime: Long = nextcloudDB.maxMtime()
|
||||
val now = Instant.now(clock).epochSecond
|
||||
val rebuild = force || lastMTime < maxMtime || lastMTime + MAX_REBUILD_OFFSET_IN_S < now
|
||||
if (rebuild) {
|
||||
logger.info("ContentTree seems to be outdated - Loading...")
|
||||
this.tree = buildContentTree()
|
||||
lastMTime = Instant.now().epochSecond
|
||||
}
|
||||
return rebuild
|
||||
}
|
||||
|
||||
private final fun buildContentTree(): ContentTree {
|
||||
val tree = ContentTree()
|
||||
val root = ContentNode(0, -1, "ROOT")
|
||||
tree.addNode(root)
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
package net.schowek.nextclouddlna.util
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import java.time.Clock
|
||||
|
||||
@Configuration
|
||||
class ClockConfig {
|
||||
@Bean
|
||||
fun clock(): Clock {
|
||||
return Clock.systemDefaultZone()
|
||||
}
|
||||
}
|
|
@ -5,9 +5,15 @@ import java.net.URI
|
|||
|
||||
|
||||
@Component
|
||||
class ExternalUrls(private val serverInfoProvider: ServerInfoProvider) {
|
||||
class ExternalUrls(
|
||||
serverInfoProvider: ServerInfoProvider
|
||||
) {
|
||||
val selfUriString: String =
|
||||
"http://" + serverInfoProvider.address!!.hostAddress + ":" + serverInfoProvider.port
|
||||
when (serverInfoProvider.port) {
|
||||
80 -> "http://${serverInfoProvider.host}"
|
||||
else -> "http://${serverInfoProvider.host}:${serverInfoProvider.port}"
|
||||
}
|
||||
|
||||
|
||||
val selfURI: URI get() = URI(selfUriString)
|
||||
|
||||
|
|
|
@ -3,15 +3,23 @@ package net.schowek.nextclouddlna.util
|
|||
import jakarta.annotation.PostConstruct
|
||||
import mu.KLogging
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.annotation.Profile
|
||||
import org.springframework.stereotype.Component
|
||||
import java.net.*
|
||||
import java.util.*
|
||||
|
||||
interface ServerInfoProvider {
|
||||
val host: String
|
||||
val port: Int
|
||||
}
|
||||
|
||||
@Component
|
||||
class ServerInfoProvider(
|
||||
@param:Value("\${server.port}") val port: Int,
|
||||
@Profile("!integration")
|
||||
class ServerInfoProviderImpl(
|
||||
@param:Value("\${server.port}") override val port: Int,
|
||||
@param:Value("\${server.interface}") private val networkInterface: String
|
||||
) {
|
||||
) : ServerInfoProvider {
|
||||
override val host: String get() = address!!.hostAddress
|
||||
var address: InetAddress? = null
|
||||
|
||||
@PostConstruct
|
||||
|
@ -22,9 +30,11 @@ class ServerInfoProvider(
|
|||
|
||||
private fun guessInetAddress(): InetAddress {
|
||||
return try {
|
||||
val en0 = NetworkInterface.getByName(networkInterface).inetAddresses
|
||||
while (en0.hasMoreElements()) {
|
||||
val x = en0.nextElement()
|
||||
val iface = NetworkInterface.getByName(networkInterface)
|
||||
?: throw RuntimeException("Could not find network interface $networkInterface")
|
||||
val addresses = iface.inetAddresses
|
||||
while (addresses.hasMoreElements()) {
|
||||
val x = addresses.nextElement()
|
||||
if (x is Inet4Address) {
|
||||
return x
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ server:
|
|||
friendlyName: ${NEXTCLOUD_DLNA_FRIENDLY_NAME:Nextcloud-DLNA}
|
||||
|
||||
nextcloud:
|
||||
filesDir: ${NEXTCLOUD_DATA_DIR:/path/to/your/nextcloud/dir/ending/with/data}
|
||||
filesDir: ${NEXTCLOUD_DATA_DIR}
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
|
@ -15,3 +15,7 @@ spring:
|
|||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
sql:
|
||||
init:
|
||||
mode: never
|
||||
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
package net.schowek.nextclouddlna.dlna
|
||||
|
||||
import org.jupnp.model.message.StreamResponseMessage
|
||||
import org.jupnp.model.message.UpnpHeaders
|
||||
import org.jupnp.model.message.UpnpRequest
|
||||
import org.jupnp.model.message.UpnpResponse
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.mock.web.MockHttpServletRequest
|
||||
import spock.lang.Specification
|
||||
|
||||
class StreamMessageMapperTest extends Specification {
|
||||
def sut = new StreamMessageMapper()
|
||||
|
||||
def "should map servlet request to streamRequestMessage"() {
|
||||
given:
|
||||
def uri = "http://foo.bar/baz"
|
||||
def content = "some content"
|
||||
def headers = [
|
||||
"foo": "bar",
|
||||
"baz": "blah"
|
||||
]
|
||||
|
||||
def request = new MockHttpServletRequest(method, uri)
|
||||
request.setContent(content.getBytes("UTF-8"))
|
||||
headers.entrySet().forEach { request.addHeader(it.key, it.value) }
|
||||
|
||||
when:
|
||||
def result = sut.map(request)
|
||||
|
||||
then:
|
||||
result.uri == new URI(uri)
|
||||
result.operation.method == expectedMethod
|
||||
result.body.toString() == content
|
||||
result.headers.each {
|
||||
assert it.key.toLowerCase() in headers.keySet()
|
||||
assert it.value == [headers[it.key.toLowerCase()]]
|
||||
}
|
||||
|
||||
where:
|
||||
method || expectedMethod
|
||||
"GET" || UpnpRequest.Method.GET
|
||||
"POST" || UpnpRequest.Method.POST
|
||||
"M-SEARCH" || UpnpRequest.Method.MSEARCH
|
||||
"NOTIFY" || UpnpRequest.Method.NOTIFY
|
||||
}
|
||||
|
||||
|
||||
def "should throw exception when http method is missing or not supported"() {
|
||||
given:
|
||||
def request = new MockHttpServletRequest(method, "http://foo.bar/")
|
||||
|
||||
when:
|
||||
sut.map(request)
|
||||
|
||||
then:
|
||||
thrown RuntimeException
|
||||
|
||||
where:
|
||||
method | _
|
||||
null | _
|
||||
"HEAD" | _
|
||||
"foo" | _
|
||||
}
|
||||
|
||||
def "should map streamResponseMessage to ResponseEntity"() {
|
||||
given:
|
||||
def content = "some content"
|
||||
def headers = [
|
||||
"foo": ["bar"],
|
||||
"baz": ["blah"]
|
||||
]
|
||||
|
||||
def response = new StreamResponseMessage(new UpnpResponse(responseStatus, "OK"))
|
||||
response.headers = new UpnpHeaders(headers)
|
||||
response.body = content
|
||||
|
||||
when:
|
||||
def result = sut.map(response)
|
||||
|
||||
then:
|
||||
result.statusCode == expectedHttpStatus
|
||||
result.body == content
|
||||
result.headers.each {
|
||||
assert headers.keySet().contains(it.key.toLowerCase())
|
||||
assert headers[it.key.toLowerCase()] == it.value
|
||||
}
|
||||
|
||||
where:
|
||||
responseStatus || expectedHttpStatus
|
||||
200 || HttpStatus.OK
|
||||
404 || HttpStatus.NOT_FOUND
|
||||
500 || HttpStatus.INTERNAL_SERVER_ERROR
|
||||
400 || HttpStatus.BAD_REQUEST
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
package net.schowek.nextclouddlna.nextcloud.content
|
||||
|
||||
import net.schowek.nextclouddlna.nextcloud.NextcloudDB
|
||||
import spock.lang.Specification
|
||||
import java.time.Clock
|
||||
import java.time.ZoneId
|
||||
import java.util.function.Consumer
|
||||
import static java.time.Instant.now
|
||||
import static java.time.Instant.ofEpochSecond
|
||||
import static java.time.temporal.ChronoUnit.DAYS
|
||||
|
||||
class ContentTreeProviderTest extends Specification {
|
||||
def nextcloudDB
|
||||
|
||||
void setup() {
|
||||
nextcloudDB = Mock(NextcloudDB)
|
||||
nextcloudDB.mainNodes() >> []
|
||||
nextcloudDB.groupFolders() >> []
|
||||
nextcloudDB.processThumbnails(_ as Consumer<ContentItem>) >> null
|
||||
}
|
||||
|
||||
def "should rebuild tree if it's outdated"() {
|
||||
given:
|
||||
def clock = Clock.fixed(now, ZoneId.systemDefault())
|
||||
|
||||
def sut = new ContentTreeProvider(nextcloudDB, clock)
|
||||
sut.lastMTime = lastMTime.epochSecond
|
||||
nextcloudDB.maxMtime() >> maxMtime.epochSecond
|
||||
|
||||
when:
|
||||
def rebuild = sut.rebuildTree(force)
|
||||
|
||||
then:
|
||||
rebuild == expectedResult
|
||||
|
||||
where:
|
||||
force | now | lastMTime | maxMtime || expectedResult
|
||||
true | now() | ofEpochSecond(0L) | now().minus(1, DAYS) || true
|
||||
true | now() | ofEpochSecond(0L) | now().plus(1, DAYS) || true
|
||||
false | now() | ofEpochSecond(0L) | now().minus(1, DAYS) || true
|
||||
false | now() | ofEpochSecond(0L) | now().plus(1, DAYS) || true
|
||||
false | now() | now() | now().minus(1, DAYS) || false
|
||||
false | now() | now() | now().plus(1, DAYS) || true
|
||||
false | now().plus(1, DAYS) | now() | now().minus(1, DAYS) || true
|
||||
false | now().minus(1, DAYS) | now() | now().minus(1, DAYS) || false
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
package net.schowek.nextclouddlna.util
|
||||
|
||||
import spock.lang.Specification
|
||||
|
||||
class ExternalUrlsTest extends Specification {
|
||||
def serverInfoProvider = Mock(ServerInfoProvider)
|
||||
|
||||
def "should generate main url for the service"() {
|
||||
given:
|
||||
serverInfoProvider.getPort() >> port
|
||||
serverInfoProvider.getHost() >> host
|
||||
def sut = new ExternalUrls(serverInfoProvider)
|
||||
|
||||
when:
|
||||
def mainUrl = sut.selfURI
|
||||
|
||||
then:
|
||||
mainUrl.toString() == expectedUrl
|
||||
|
||||
where:
|
||||
host | port || expectedUrl
|
||||
"foo.bar" | 9999 || "http://foo.bar:9999"
|
||||
"foo.bar" | 80 || "http://foo.bar"
|
||||
}
|
||||
|
||||
def "should generate content urls"() {
|
||||
given:
|
||||
serverInfoProvider.getPort() >> port
|
||||
serverInfoProvider.getHost() >> host
|
||||
def sut = new ExternalUrls(serverInfoProvider)
|
||||
|
||||
when:
|
||||
def contentUrl = sut.contentUrl(contentId)
|
||||
|
||||
then:
|
||||
contentUrl.toString() == expectedUrl
|
||||
|
||||
where:
|
||||
host | port | contentId || expectedUrl
|
||||
"foo.bar" | 9999 | 123 || "http://foo.bar:9999/c/123"
|
||||
"foo.bar" | 80 | 123 || "http://foo.bar/c/123"
|
||||
}
|
||||
}
|