diff --git a/build.gradle b/build.gradle index 19d2834..4bc09c8 100644 --- a/build.gradle +++ b/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 + diff --git a/src/integration/groovy/net/schowek/nextclouddlna/controller/ContentControllerIntTest.groovy b/src/integration/groovy/net/schowek/nextclouddlna/controller/ContentControllerIntTest.groovy new file mode 100644 index 0000000..56876ae --- /dev/null +++ b/src/integration/groovy/net/schowek/nextclouddlna/controller/ContentControllerIntTest.groovy @@ -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> 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 response = restTemplate().getForEntity(urlWithPort("/c/blah-blah"), byte[]); + + then: + response.statusCode == HttpStatus.NOT_FOUND + } +} diff --git a/src/integration/groovy/net/schowek/nextclouddlna/controller/UpnpControllerIntTest.groovy b/src/integration/groovy/net/schowek/nextclouddlna/controller/UpnpControllerIntTest.groovy new file mode 100644 index 0000000..dcbfbbf --- /dev/null +++ b/src/integration/groovy/net/schowek/nextclouddlna/controller/UpnpControllerIntTest.groovy @@ -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") + } + } + } +} diff --git a/src/integration/groovy/net/schowek/nextclouddlna/nextcloud/config/NextcloudAppPathProviderInt.groovy b/src/integration/groovy/net/schowek/nextclouddlna/nextcloud/config/NextcloudAppPathProviderInt.groovy new file mode 100644 index 0000000..2bd3b98 --- /dev/null +++ b/src/integration/groovy/net/schowek/nextclouddlna/nextcloud/config/NextcloudAppPathProviderInt.groovy @@ -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()) + } +} diff --git a/src/integration/groovy/net/schowek/nextclouddlna/nextcloud/content/ContentTreeProviderIntTest.groovy b/src/integration/groovy/net/schowek/nextclouddlna/nextcloud/content/ContentTreeProviderIntTest.groovy new file mode 100644 index 0000000..26a23d6 --- /dev/null +++ b/src/integration/groovy/net/schowek/nextclouddlna/nextcloud/content/ContentTreeProviderIntTest.groovy @@ -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" + } + } +} diff --git a/src/integration/groovy/net/schowek/nextclouddlna/util/ServerInfoProviderInt.groovy b/src/integration/groovy/net/schowek/nextclouddlna/util/ServerInfoProviderInt.groovy new file mode 100644 index 0000000..1a30d66 --- /dev/null +++ b/src/integration/groovy/net/schowek/nextclouddlna/util/ServerInfoProviderInt.groovy @@ -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 + } +} diff --git a/src/integration/groovy/net/schowek/nextclouddlna/util/ServerPortCustomizer.groovy b/src/integration/groovy/net/schowek/nextclouddlna/util/ServerPortCustomizer.groovy new file mode 100644 index 0000000..4c997ad --- /dev/null +++ b/src/integration/groovy/net/schowek/nextclouddlna/util/ServerPortCustomizer.groovy @@ -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 { + @Value("\${random.int(9090,65535)}") + int port + + int getPort() { + return port + } + + @Override + void customize(ConfigurableWebServerFactory factory) { + factory.setPort(port); + } +} \ No newline at end of file diff --git a/src/integration/groovy/support/IntegrationSpecification.groovy b/src/integration/groovy/support/IntegrationSpecification.groovy new file mode 100644 index 0000000..c248ad3 --- /dev/null +++ b/src/integration/groovy/support/IntegrationSpecification.groovy @@ -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; + } +} diff --git a/src/integration/groovy/support/UpnpAwareSpecification.groovy b/src/integration/groovy/support/UpnpAwareSpecification.groovy new file mode 100644 index 0000000..33e6cf7 --- /dev/null +++ b/src/integration/groovy/support/UpnpAwareSpecification.groovy @@ -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 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 = '\n' + + '\n' + + ' \n' + + ' \n' + + ' ' + nodeId + '\n' + + ' ' + browseFlag + '\n' + + ' *\n' + + ' 0\n' + + ' 200\n' + + ' \n' + + ' \n' + + ' \n' + + '' + def headers = new HttpHeaders([ + 'content-type': 'text/xml; charset="utf-8"', + 'soapaction' : '"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"' + ]); + HttpEntity request = new HttpEntity(requestBody, headers); + return restTemplate().postForEntity(urlWithPort("/dev/$uid/svc/upnp-org/ContentDirectory/action"), request, String) + } + + DIDLContent extractDIDLFromResponse(ResponseEntity response) { + Document dom = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse( + new InputSource(new StringReader(response.body)) + ); + return new DIDLParser().parse(nodeValue(dom, "/Envelope/Body/BrowseResponse/Result")) + } +} diff --git a/src/integration/resources/application-integration.yml b/src/integration/resources/application-integration.yml new file mode 100644 index 0000000..4c4158b --- /dev/null +++ b/src/integration/resources/application-integration.yml @@ -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 diff --git a/src/integration/resources/application.yml b/src/integration/resources/application.yml new file mode 100644 index 0000000..3338026 --- /dev/null +++ b/src/integration/resources/application.yml @@ -0,0 +1,4 @@ +spring: + profiles: + active: integration + diff --git a/src/integration/resources/db/data.sql b/src/integration/resources/db/data.sql new file mode 100644 index 0000000..269d352 --- /dev/null +++ b/src/integration/resources/db/data.sql @@ -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'); diff --git a/src/integration/resources/db/schema.sql b/src/integration/resources/db/schema.sql new file mode 100644 index 0000000..9511c56 --- /dev/null +++ b/src/integration/resources/db/schema.sql @@ -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`); diff --git a/src/integration/resources/nextcloud/app/data/__groupfolders/Nextcloud community.jpg b/src/integration/resources/nextcloud/app/data/__groupfolders/Nextcloud community.jpg new file mode 100644 index 0000000..f7f3b95 Binary files /dev/null and b/src/integration/resources/nextcloud/app/data/__groupfolders/Nextcloud community.jpg differ diff --git a/src/integration/resources/nextcloud/app/data/appdata_integration/preview/0/0/0/0/0/0/0/13/1000-563-max.jpg b/src/integration/resources/nextcloud/app/data/appdata_integration/preview/0/0/0/0/0/0/0/13/1000-563-max.jpg new file mode 100644 index 0000000..39b0267 Binary files /dev/null and b/src/integration/resources/nextcloud/app/data/appdata_integration/preview/0/0/0/0/0/0/0/13/1000-563-max.jpg differ diff --git a/src/integration/resources/nextcloud/app/data/appdata_integration/preview/0/0/0/0/0/0/0/13/455-256.jpg b/src/integration/resources/nextcloud/app/data/appdata_integration/preview/0/0/0/0/0/0/0/13/455-256.jpg new file mode 100644 index 0000000..3ef35ca Binary files /dev/null and b/src/integration/resources/nextcloud/app/data/appdata_integration/preview/0/0/0/0/0/0/0/13/455-256.jpg differ diff --git a/src/integration/resources/nextcloud/app/data/appdata_integration/preview/0/0/0/0/0/0/0/14/500-500-max.png b/src/integration/resources/nextcloud/app/data/appdata_integration/preview/0/0/0/0/0/0/0/14/500-500-max.png new file mode 100644 index 0000000..16f7e52 Binary files /dev/null and b/src/integration/resources/nextcloud/app/data/appdata_integration/preview/0/0/0/0/0/0/0/14/500-500-max.png differ diff --git a/src/integration/resources/nextcloud/app/data/appdata_integration/preview/0/0/0/0/0/0/0/16/1000-667-max.jpg b/src/integration/resources/nextcloud/app/data/appdata_integration/preview/0/0/0/0/0/0/0/16/1000-667-max.jpg new file mode 100644 index 0000000..4539ea5 Binary files /dev/null and b/src/integration/resources/nextcloud/app/data/appdata_integration/preview/0/0/0/0/0/0/0/16/1000-667-max.jpg differ diff --git a/src/integration/resources/nextcloud/app/data/appdata_integration/preview/0/0/0/0/0/0/0/17/1000-667-max.jpg b/src/integration/resources/nextcloud/app/data/appdata_integration/preview/0/0/0/0/0/0/0/17/1000-667-max.jpg new file mode 100644 index 0000000..4759855 Binary files /dev/null and b/src/integration/resources/nextcloud/app/data/appdata_integration/preview/0/0/0/0/0/0/0/17/1000-667-max.jpg differ diff --git a/src/integration/resources/nextcloud/app/data/janedoe/files/pictures/Steps.jpg b/src/integration/resources/nextcloud/app/data/janedoe/files/pictures/Steps.jpg new file mode 100644 index 0000000..aba6e50 Binary files /dev/null and b/src/integration/resources/nextcloud/app/data/janedoe/files/pictures/Steps.jpg differ diff --git a/src/integration/resources/nextcloud/app/data/janedoe/files/pictures/Toucan.jpg b/src/integration/resources/nextcloud/app/data/janedoe/files/pictures/Toucan.jpg new file mode 100644 index 0000000..11962bb Binary files /dev/null and b/src/integration/resources/nextcloud/app/data/janedoe/files/pictures/Toucan.jpg differ diff --git a/src/integration/resources/nextcloud/app/data/johndoe/files/Nextcloud intro.mp4 b/src/integration/resources/nextcloud/app/data/johndoe/files/Nextcloud intro.mp4 new file mode 100644 index 0000000..846f982 Binary files /dev/null and b/src/integration/resources/nextcloud/app/data/johndoe/files/Nextcloud intro.mp4 differ diff --git a/src/integration/resources/nextcloud/app/data/johndoe/files/Nextcloud.png b/src/integration/resources/nextcloud/app/data/johndoe/files/Nextcloud.png new file mode 100644 index 0000000..fce2715 Binary files /dev/null and b/src/integration/resources/nextcloud/app/data/johndoe/files/Nextcloud.png differ diff --git a/src/integration/resources/nextcloud/app/data/johndoe/files/photos/Birdie.jpg b/src/integration/resources/nextcloud/app/data/johndoe/files/photos/Birdie.jpg new file mode 100644 index 0000000..dd11a57 Binary files /dev/null and b/src/integration/resources/nextcloud/app/data/johndoe/files/photos/Birdie.jpg differ diff --git a/src/integration/resources/nextcloud/app/data/johndoe/files/photos/Frog.jpg b/src/integration/resources/nextcloud/app/data/johndoe/files/photos/Frog.jpg new file mode 100644 index 0000000..d309ab0 Binary files /dev/null and b/src/integration/resources/nextcloud/app/data/johndoe/files/photos/Frog.jpg differ diff --git a/src/main/kotlin/net/schowek/nextclouddlna/DllnaService.kt b/src/main/kotlin/net/schowek/nextclouddlna/DllnaService.kt index 8453f8d..9b10166 100644 --- a/src/main/kotlin/net/schowek/nextclouddlna/DllnaService.kt +++ b/src/main/kotlin/net/schowek/nextclouddlna/DllnaService.kt @@ -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 = listOf(serverInfoProvider.address!!) + private val addressesToBind: List = 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() diff --git a/src/main/kotlin/net/schowek/nextclouddlna/controller/ContentController.kt b/src/main/kotlin/net/schowek/nextclouddlna/controller/ContentController.kt index 1993e13..19a77a9 100644 --- a/src/main/kotlin/net/schowek/nextclouddlna/controller/ContentController.kt +++ b/src/main/kotlin/net/schowek/nextclouddlna/controller/ContentController.kt @@ -35,16 +35,21 @@ class ContentController( ): ResponseEntity { 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) - 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) + 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) } } diff --git a/src/main/kotlin/net/schowek/nextclouddlna/controller/UpnpController.kt b/src/main/kotlin/net/schowek/nextclouddlna/controller/UpnpController.kt index 96a2dfc..0aabcc8 100644 --- a/src/main/kotlin/net/schowek/nextclouddlna/controller/UpnpController.kt +++ b/src/main/kotlin/net/schowek/nextclouddlna/controller/UpnpController.kt @@ -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 { 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) + } } } diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/RegistryWithOverrides.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/RegistryWithOverrides.kt deleted file mode 100644 index e87463d..0000000 --- a/src/main/kotlin/net/schowek/nextclouddlna/dlna/RegistryWithOverrides.kt +++ /dev/null @@ -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() -} diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/StreamRequestMapper.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/StreamMessageMapper.kt similarity index 56% rename from src/main/kotlin/net/schowek/nextclouddlna/dlna/StreamRequestMapper.kt rename to src/main/kotlin/net/schowek/nextclouddlna/dlna/StreamMessageMapper.kt index 03437a1..3795b05 100644 --- a/src/main/kotlin/net/schowek/nextclouddlna/dlna/StreamRequestMapper.kt +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/StreamMessageMapper.kt @@ -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 { + 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>() request.headerNames?.let { while (it.hasMoreElements()) { 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 819bd94..5c0a1a6 100644 --- a/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/MediaServer.kt +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/media/MediaServer.kt @@ -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 diff --git a/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/ApacheStreamClient.kt b/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/ApacheStreamClient.kt index 0b44f92..863b7ce 100644 --- a/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/ApacheStreamClient.kt +++ b/src/main/kotlin/net/schowek/nextclouddlna/dlna/transport/ApacheStreamClient.kt @@ -115,7 +115,7 @@ class ApacheStreamClient( ): Callable { return Callable { logger.trace("Sending HTTP request: $requestMessage") - httpClient.execute(request, createResponseHandler(requestMessage)) + httpClient.execute(request, createResponseHandler()) } } @@ -141,7 +141,7 @@ class ApacheStreamClient( clientConnectionManager.shutdown() } - private fun createResponseHandler(requestMessage: StreamRequestMessage?): ResponseHandler { + private fun createResponseHandler(): ResponseHandler { return ResponseHandler { response -> val statusLine = response.statusLine logger.trace("Received HTTP response: $statusLine") diff --git a/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/NextcloudDB.kt b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/NextcloudDB.kt index b118fc8..5eae2f2 100644 --- a/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/NextcloudDB.kt +++ b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/NextcloudDB.kt @@ -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 { - val format = MediaFormat.fromMimeType(mimetypes[f.mimetype]!!) - val path: String = buildPath(f) - return ContentItem(f.id, f.parent, f.name, path, format, f.size) + 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}" } } diff --git a/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/config/NextcloudAppPathProvider.kt b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/config/NextcloudAppPathProvider.kt new file mode 100644 index 0000000..6528bbe --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/config/NextcloudAppPathProvider.kt @@ -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") + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/NextcloudConfigDiscovery.kt b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/config/NextcloudConfigDiscovery.kt similarity index 57% rename from src/main/kotlin/net/schowek/nextclouddlna/nextcloud/NextcloudConfigDiscovery.kt rename to src/main/kotlin/net/schowek/nextclouddlna/nextcloud/config/NextcloudConfigDiscovery.kt index 87d6590..fea5afb 100644 --- a/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/NextcloudConfigDiscovery.kt +++ b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/config/NextcloudConfigDiscovery.kt @@ -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 { diff --git a/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/content/ContentTreeProvider.kt b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/content/ContentTreeProvider.kt index 35110f9..88ec872 100644 --- a/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/content/ContentTreeProvider.kt +++ b/src/main/kotlin/net/schowek/nextclouddlna/nextcloud/content/ContentTreeProvider.kt @@ -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) diff --git a/src/main/kotlin/net/schowek/nextclouddlna/util/ClockConfig.kt b/src/main/kotlin/net/schowek/nextclouddlna/util/ClockConfig.kt new file mode 100644 index 0000000..fe48e46 --- /dev/null +++ b/src/main/kotlin/net/schowek/nextclouddlna/util/ClockConfig.kt @@ -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() + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/schowek/nextclouddlna/util/ExternalUrls.kt b/src/main/kotlin/net/schowek/nextclouddlna/util/ExternalUrls.kt index e70bfb0..cfbe13c 100644 --- a/src/main/kotlin/net/schowek/nextclouddlna/util/ExternalUrls.kt +++ b/src/main/kotlin/net/schowek/nextclouddlna/util/ExternalUrls.kt @@ -5,11 +5,17 @@ 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) + + val selfURI: URI get() = URI(selfUriString) fun contentUrl(id: Int) = "$selfUriString/c/$id" } diff --git a/src/main/kotlin/net/schowek/nextclouddlna/util/ServerInfoProvider.kt b/src/main/kotlin/net/schowek/nextclouddlna/util/ServerInfoProvider.kt index 4d85be8..18c155a 100644 --- a/src/main/kotlin/net/schowek/nextclouddlna/util/ServerInfoProvider.kt +++ b/src/main/kotlin/net/schowek/nextclouddlna/util/ServerInfoProvider.kt @@ -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 } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 852ab82..0c8c146 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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 + diff --git a/src/test/groovy/net/schowek/nextclouddlna/dlna/StreamMessageMapperTest.groovy b/src/test/groovy/net/schowek/nextclouddlna/dlna/StreamMessageMapperTest.groovy new file mode 100644 index 0000000..520f14e --- /dev/null +++ b/src/test/groovy/net/schowek/nextclouddlna/dlna/StreamMessageMapperTest.groovy @@ -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 + } + +} diff --git a/src/test/groovy/net/schowek/nextclouddlna/nextcloud/content/ContentTreeProviderTest.groovy b/src/test/groovy/net/schowek/nextclouddlna/nextcloud/content/ContentTreeProviderTest.groovy new file mode 100644 index 0000000..1a7822a --- /dev/null +++ b/src/test/groovy/net/schowek/nextclouddlna/nextcloud/content/ContentTreeProviderTest.groovy @@ -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) >> 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 + } +} diff --git a/src/test/groovy/net/schowek/nextclouddlna/util/ExternalUrlsTest.groovy b/src/test/groovy/net/schowek/nextclouddlna/util/ExternalUrlsTest.groovy new file mode 100644 index 0000000..e37d95e --- /dev/null +++ b/src/test/groovy/net/schowek/nextclouddlna/util/ExternalUrlsTest.groovy @@ -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" + } +}