Merge pull request #2 from thanek/tests

Tests
This commit is contained in:
xis 2023-10-17 15:49:17 +02:00 committed by GitHub
commit b05b026a00
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1095 additions and 114 deletions

View file

@ -6,6 +6,7 @@ plugins {
id 'org.jetbrains.kotlin.jvm' version '1.8.22' id 'org.jetbrains.kotlin.jvm' version '1.8.22'
id 'org.jetbrains.kotlin.plugin.spring' 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 "org.jetbrains.kotlin.plugin.jpa" version '1.8.22'
id 'groovy'
} }
group = 'net.schowek' group = 'net.schowek'
@ -23,6 +24,11 @@ repositories {
mavenCentral() mavenCentral()
} }
configurations {
integrationTestImplementation.extendsFrom(testImplementation)
integrationTestRuntimeOnly.extendsFrom(testRuntimeOnly)
}
dependencies { dependencies {
implementation('org.springframework.boot:spring-boot-starter-web') { implementation('org.springframework.boot:spring-boot-starter-web') {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat' 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.jupnp:org.jupnp.support:2.7.1'
implementation 'org.apache.httpcomponents:httpclient:4.5.14' 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 { kotlinOptions {
freeCompilerArgs += '-Xjsr305=strict' freeCompilerArgs += '-Xjsr305=strict'
jvmTarget = '17' jvmTarget = '17'
@ -53,3 +66,24 @@ tasks.withType(KotlinCompile) {
tasks.named('test') { tasks.named('test') {
useJUnitPlatform() 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

View file

@ -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
}
}

View file

@ -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")
}
}
}
}

View file

@ -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())
}
}

View file

@ -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"
}
}
}

View file

@ -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
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View 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"))
}
}

View 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

View file

@ -0,0 +1,4 @@
spring:
profiles:
active: integration

View 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');

View 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`);

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

View file

@ -2,7 +2,6 @@ package net.schowek.nextclouddlna
import jakarta.annotation.PreDestroy import jakarta.annotation.PreDestroy
import mu.KLogging import mu.KLogging
import net.schowek.nextclouddlna.dlna.RegistryImplWithOverrides
import net.schowek.nextclouddlna.dlna.media.MediaServer import net.schowek.nextclouddlna.dlna.media.MediaServer
import net.schowek.nextclouddlna.dlna.transport.ApacheStreamClient import net.schowek.nextclouddlna.dlna.transport.ApacheStreamClient
import net.schowek.nextclouddlna.dlna.transport.ApacheStreamClientConfiguration 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.dlna.transport.MyStreamServerImpl
import net.schowek.nextclouddlna.util.ServerInfoProvider import net.schowek.nextclouddlna.util.ServerInfoProvider
import org.jupnp.DefaultUpnpServiceConfiguration import org.jupnp.DefaultUpnpServiceConfiguration
import org.jupnp.UpnpService
import org.jupnp.UpnpServiceConfiguration import org.jupnp.UpnpServiceConfiguration
import org.jupnp.UpnpServiceImpl import org.jupnp.UpnpServiceImpl
import org.jupnp.model.message.StreamRequestMessage import org.jupnp.model.message.StreamRequestMessage
import org.jupnp.model.message.StreamResponseMessage import org.jupnp.model.message.StreamResponseMessage
import org.jupnp.model.message.UpnpResponse import org.jupnp.model.message.UpnpResponse
import org.jupnp.model.meta.LocalDevice
import org.jupnp.protocol.ProtocolFactory import org.jupnp.protocol.ProtocolFactory
import org.jupnp.protocol.ProtocolFactoryImpl import org.jupnp.registry.RegistryImpl
import org.jupnp.protocol.async.SendingNotificationAlive
import org.jupnp.registry.Registry
import org.jupnp.transport.impl.NetworkAddressFactoryImpl import org.jupnp.transport.impl.NetworkAddressFactoryImpl
import org.jupnp.transport.spi.NetworkAddressFactory 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.ContextRefreshedEvent
import org.springframework.context.event.EventListener import org.springframework.context.event.EventListener
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
@ -37,7 +30,7 @@ class DlnaService(
private val mediaServer: MediaServer, private val mediaServer: MediaServer,
private val serverInfoProvider: ServerInfoProvider, private val serverInfoProvider: ServerInfoProvider,
) { ) {
private val addressesToBind: List<InetAddress> = listOf(serverInfoProvider.address!!) private val addressesToBind: List<String> = listOf(serverInfoProvider.host)
var upnpService = MyUpnpService(MyUpnpServiceConfiguration()) var upnpService = MyUpnpService(MyUpnpServiceConfiguration())
fun start() { fun start() {
@ -71,8 +64,7 @@ class DlnaService(
inner class MyUpnpService( inner class MyUpnpService(
configuration: UpnpServiceConfiguration configuration: UpnpServiceConfiguration
) : UpnpServiceImpl(configuration) { ) : UpnpServiceImpl(configuration) {
override fun createRegistry(pf: ProtocolFactory) = override fun createRegistry(pf: ProtocolFactory) = RegistryImpl(this)
RegistryImplWithOverrides(this)
} }
private inner class MyUpnpServiceConfiguration : DefaultUpnpServiceConfiguration(serverInfoProvider.port) { private inner class MyUpnpServiceConfiguration : DefaultUpnpServiceConfiguration(serverInfoProvider.port) {
@ -91,7 +83,7 @@ class DlnaService(
multicastResponsePort: Int multicastResponsePort: Int
) : NetworkAddressFactoryImpl(streamListenPort, multicastResponsePort) { ) : NetworkAddressFactoryImpl(streamListenPort, multicastResponsePort) {
override fun isUsableAddress(iface: NetworkInterface, address: InetAddress) = override fun isUsableAddress(iface: NetworkInterface, address: InetAddress) =
addressesToBind.contains(address) addressesToBind.contains(address.hostAddress)
} }
companion object : KLogging() companion object : KLogging()

View file

@ -35,16 +35,21 @@ class ContentController(
): ResponseEntity<FileSystemResource> { ): ResponseEntity<FileSystemResource> {
return contentTreeProvider.getItem(id)?.let { item -> return contentTreeProvider.getItem(id)?.let { item ->
if (!request.getHeaders("range").hasMoreElements()) { if (!request.getHeaders("range").hasMoreElements()) {
logger.info("Serving content {} {}", request.method, id) logger.info("Serving content ${request.method} $id")
} }
val fileSystemResource = FileSystemResource(item.path) 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("Content-Type", item.format.mime)
response.addHeader("contentFeatures.dlna.org", makeProtocolInfo(item.format).toString()) response.addHeader("contentFeatures.dlna.org", makeProtocolInfo(item.format).toString())
response.addHeader("transferMode.dlna.org", "Streaming") response.addHeader("transferMode.dlna.org", "Streaming")
response.addHeader("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*") response.addHeader("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*")
ResponseEntity(fileSystemResource, HttpStatus.OK) ResponseEntity(fileSystemResource, HttpStatus.OK)
}
} ?: let { } ?: let {
logger.info("Could not find item id: {}", id) logger.info("Could not find item id: $id")
ResponseEntity(HttpStatus.NOT_FOUND) ResponseEntity(HttpStatus.NOT_FOUND)
} }
} }

View file

@ -3,12 +3,10 @@ package net.schowek.nextclouddlna.controller
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import mu.KLogging import mu.KLogging
import net.schowek.nextclouddlna.DlnaService 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 net.schowek.nextclouddlna.dlna.media.MediaServer
import org.springframework.core.io.InputStreamResource import org.springframework.core.io.InputStreamResource
import org.springframework.core.io.Resource 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.MediaType.APPLICATION_OCTET_STREAM_VALUE
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
@ -20,7 +18,7 @@ import org.springframework.web.bind.annotation.RestController
@RestController @RestController
class UpnpController( class UpnpController(
private val streamRequestMapper: StreamRequestMapper, private val streamMessageMapper: StreamMessageMapper,
private val dlnaService: DlnaService private val dlnaService: DlnaService
) { ) {
@RequestMapping( @RequestMapping(
@ -50,12 +48,10 @@ class UpnpController(
request: HttpServletRequest request: HttpServletRequest
): ResponseEntity<Any> { ): ResponseEntity<Any> {
logger.info { "Upnp ${request.method} request from ${request.remoteAddr}: ${request.requestURI}" } logger.info { "Upnp ${request.method} request from ${request.remoteAddr}: ${request.requestURI}" }
return with(dlnaService.processRequest(streamRequestMapper.map(request))) { return streamMessageMapper.map(request).let { req ->
ResponseEntity( dlnaService.processRequest(req).let { res ->
body, streamMessageMapper.map(res)
HttpHeaders().also { h -> headers.entries.forEach { e -> h.add(e.key, e.value.joinToString { it }) } }, }
HttpStatusCode.valueOf(operation.statusCode)
)
} }
} }

View file

@ -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()
}

View file

@ -2,13 +2,18 @@ package net.schowek.nextclouddlna.dlna
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import mu.KLogging 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 org.springframework.stereotype.Component
import java.net.InetAddress
import java.net.URI import java.net.URI
@Component @Component
class StreamRequestMapper { class StreamMessageMapper {
fun map(request: HttpServletRequest): StreamRequestMessage { fun map(request: HttpServletRequest): StreamRequestMessage {
val requestMessage = StreamRequestMessage( val requestMessage = StreamRequestMessage(
UpnpRequest.Method.getByHttpName(request.method), UpnpRequest.Method.getByHttpName(request.method),
@ -21,11 +26,25 @@ class StreamRequestMapper {
throw RuntimeException("Method not supported: {}" + request.method) throw RuntimeException("Method not supported: {}" + request.method)
} }
requestMessage.headers = createHeaders(request) requestMessage.headers = upnpHeaders(request)
return requestMessage 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>>() val headers = mutableMapOf<String, List<String>>()
request.headerNames?.let { request.headerNames?.let {
while (it.hasMoreElements()) { while (it.hasMoreElements()) {

View file

@ -28,13 +28,15 @@ class MediaServer(
createDeviceIcon(), createDeviceIcon(),
arrayOf(contentDirectoryService, connectionManagerService) arrayOf(contentDirectoryService, connectionManagerService)
) )
val serviceIdentifier: String get() = device.identity.udn.identifierString
init { init {
logger.info("uniqueSystemIdentifier: {} ({})", device.identity.udn, friendlyName) logger.info("uniqueSystemIdentifier: {} ({})", device.identity.udn, friendlyName)
} }
companion object : KLogging() { 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 DEVICE_TYPE = "MediaServer"
private const val VERSION = 1 private const val VERSION = 1
private const val ADVERTISEMENT_AGE_IN_S = 60 private const val ADVERTISEMENT_AGE_IN_S = 60

View file

@ -115,7 +115,7 @@ class ApacheStreamClient(
): Callable<StreamResponseMessage> { ): Callable<StreamResponseMessage> {
return Callable<StreamResponseMessage> { return Callable<StreamResponseMessage> {
logger.trace("Sending HTTP request: $requestMessage") logger.trace("Sending HTTP request: $requestMessage")
httpClient.execute<StreamResponseMessage>(request, createResponseHandler(requestMessage)) httpClient.execute(request, createResponseHandler())
} }
} }
@ -141,7 +141,7 @@ class ApacheStreamClient(
clientConnectionManager.shutdown() clientConnectionManager.shutdown()
} }
private fun createResponseHandler(requestMessage: StreamRequestMessage?): ResponseHandler<StreamResponseMessage> { private fun createResponseHandler(): ResponseHandler<StreamResponseMessage> {
return ResponseHandler<StreamResponseMessage> { response -> return ResponseHandler<StreamResponseMessage> { response ->
val statusLine = response.statusLine val statusLine = response.statusLine
logger.trace("Received HTTP response: $statusLine") logger.trace("Received HTTP response: $statusLine")

View file

@ -2,6 +2,7 @@ package net.schowek.nextclouddlna.nextcloud
import jakarta.annotation.PostConstruct import jakarta.annotation.PostConstruct
import mu.KLogging import mu.KLogging
import net.schowek.nextclouddlna.nextcloud.config.NextcloudConfigDiscovery
import net.schowek.nextclouddlna.nextcloud.content.ContentItem import net.schowek.nextclouddlna.nextcloud.content.ContentItem
import net.schowek.nextclouddlna.nextcloud.content.ContentNode import net.schowek.nextclouddlna.nextcloud.content.ContentNode
import net.schowek.nextclouddlna.nextcloud.content.MediaFormat import net.schowek.nextclouddlna.nextcloud.content.MediaFormat
@ -59,9 +60,13 @@ class NextcloudDB(
} }
private fun asItem(f: Filecache): ContentItem { private fun asItem(f: Filecache): ContentItem {
try {
val format = MediaFormat.fromMimeType(mimetypes[f.mimetype]!!) val format = MediaFormat.fromMimeType(mimetypes[f.mimetype]!!)
val path: String = buildPath(f) val path: String = buildPath(f)
return ContentItem(f.id, f.parent, f.name, path, format, f.size) 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 { private fun asNode(f: Filecache): ContentNode {
@ -96,9 +101,9 @@ class NextcloudDB(
private fun buildPath(f: Filecache): String { private fun buildPath(f: Filecache): String {
return if (storageUsersMap.containsKey(f.storage)) { return if (storageUsersMap.containsKey(f.storage)) {
val userName: String? = storageUsersMap[f.storage] val userName: String? = storageUsersMap[f.storage]
"${nextcloudConfig.nextcloudDir}/$userName/${f.path}" "${nextcloudConfig.nextcloudDir.absolutePath}/$userName/${f.path}"
} else { } else {
"${nextcloudConfig.nextcloudDir}/${f.path}" "${nextcloudConfig.nextcloudDir.absolutePath}/${f.path}"
} }
} }

View file

@ -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")
}
}
}
}

View file

@ -1,31 +1,28 @@
package net.schowek.nextclouddlna.nextcloud package net.schowek.nextclouddlna.nextcloud.config
import jakarta.annotation.PostConstruct
import mu.KLogging import mu.KLogging
import net.schowek.nextclouddlna.nextcloud.db.AppConfigRepository import net.schowek.nextclouddlna.nextcloud.db.AppConfigRepository
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.io.File import java.io.File
import java.util.*
import java.util.Arrays.* import java.util.Arrays.*
import java.util.Objects.* import java.util.Objects.*
@Component @Component
class NextcloudConfigDiscovery( class NextcloudConfigDiscovery(
@Value("\${nextcloud.filesDir}") val appConfigRepository: AppConfigRepository,
val nextcloudDir: String, val nextcloudDirProvider: NextcloudAppPathProvider
val appConfigRepository: AppConfigRepository
) { ) {
final val appDataDir: String = findAppDataDir() val appDataDir: String = findAppDataDir()
final val supportsGroupFolders: Boolean = checkGroupFoldersSupport() val nextcloudDir: File get() = nextcloudDirProvider.nextcloudDir
val supportsGroupFolders: Boolean get() = checkGroupFoldersSupport()
private fun checkGroupFoldersSupport(): Boolean { fun checkGroupFoldersSupport(): Boolean {
return "yes" == appConfigRepository.getValue("groupfolders", "enabled") return "yes" == appConfigRepository.getValue("groupfolders", "enabled")
} }
private fun findAppDataDir(): String { 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()) f.isDirectory && f.name.matches(APPDATA_NAME_PATTERN.toRegex())
})).findFirst().orElseThrow().name })).findFirst().orElseThrow().name
.also { .also {

View file

@ -4,6 +4,7 @@ import mu.KLogging
import net.schowek.nextclouddlna.nextcloud.NextcloudDB import net.schowek.nextclouddlna.nextcloud.NextcloudDB
import org.springframework.scheduling.annotation.Scheduled import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.time.Clock
import java.time.Instant import java.time.Instant
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import java.util.regex.Pattern import java.util.regex.Pattern
@ -11,23 +12,30 @@ import java.util.regex.Pattern
@Component @Component
class ContentTreeProvider( class ContentTreeProvider(
private val nextcloudDB: NextcloudDB private val nextcloudDB: NextcloudDB,
private val clock: Clock
) { ) {
private var tree = buildContentTree() 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) @Scheduled(fixedDelay = REBUILD_TREE_DELAY_IN_MS, initialDelay = REBUILD_TREE_INIT_DELAY_IN_MS)
final fun rebuildTree() { final fun rebuildTree(): Boolean {
val maxMtime: Long = nextcloudDB.maxMtime() return rebuildTree(false)
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
}
} }
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 tree = ContentTree()
val root = ContentNode(0, -1, "ROOT") val root = ContentNode(0, -1, "ROOT")
tree.addNode(root) tree.addNode(root)

View file

@ -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()
}
}

View file

@ -5,9 +5,15 @@ import java.net.URI
@Component @Component
class ExternalUrls(private val serverInfoProvider: ServerInfoProvider) { class ExternalUrls(
serverInfoProvider: ServerInfoProvider
) {
val selfUriString: String = 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)

View file

@ -3,15 +3,23 @@ package net.schowek.nextclouddlna.util
import jakarta.annotation.PostConstruct import jakarta.annotation.PostConstruct
import mu.KLogging import mu.KLogging
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.net.* import java.net.*
import java.util.* import java.util.*
interface ServerInfoProvider {
val host: String
val port: Int
}
@Component @Component
class ServerInfoProvider( @Profile("!integration")
@param:Value("\${server.port}") val port: Int, class ServerInfoProviderImpl(
@param:Value("\${server.port}") override val port: Int,
@param:Value("\${server.interface}") private val networkInterface: String @param:Value("\${server.interface}") private val networkInterface: String
) { ) : ServerInfoProvider {
override val host: String get() = address!!.hostAddress
var address: InetAddress? = null var address: InetAddress? = null
@PostConstruct @PostConstruct
@ -22,9 +30,11 @@ class ServerInfoProvider(
private fun guessInetAddress(): InetAddress { private fun guessInetAddress(): InetAddress {
return try { return try {
val en0 = NetworkInterface.getByName(networkInterface).inetAddresses val iface = NetworkInterface.getByName(networkInterface)
while (en0.hasMoreElements()) { ?: throw RuntimeException("Could not find network interface $networkInterface")
val x = en0.nextElement() val addresses = iface.inetAddresses
while (addresses.hasMoreElements()) {
val x = addresses.nextElement()
if (x is Inet4Address) { if (x is Inet4Address) {
return x return x
} }

View file

@ -4,7 +4,7 @@ server:
friendlyName: ${NEXTCLOUD_DLNA_FRIENDLY_NAME:Nextcloud-DLNA} friendlyName: ${NEXTCLOUD_DLNA_FRIENDLY_NAME:Nextcloud-DLNA}
nextcloud: nextcloud:
filesDir: ${NEXTCLOUD_DATA_DIR:/path/to/your/nextcloud/dir/ending/with/data} filesDir: ${NEXTCLOUD_DATA_DIR}
spring: spring:
datasource: datasource:
@ -15,3 +15,7 @@ spring:
jpa: jpa:
hibernate: hibernate:
ddl-auto: none ddl-auto: none
sql:
init:
mode: never

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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"
}
}