38
build.gradle
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
package net.schowek.nextclouddlna.controller
|
||||||
|
|
||||||
|
|
||||||
|
import net.schowek.nextclouddlna.nextcloud.content.ContentTreeProvider
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import support.IntegrationSpecification
|
||||||
|
|
||||||
|
class ContentControllerIntTest extends IntegrationSpecification {
|
||||||
|
@Autowired
|
||||||
|
ContentTreeProvider contentTreeProvider
|
||||||
|
|
||||||
|
def "should serve nextcloud files"() {
|
||||||
|
given:
|
||||||
|
def items = contentTreeProvider.tree.items
|
||||||
|
|
||||||
|
when:
|
||||||
|
Map<Integer, ResponseEntity<byte[]>> results = items.keySet().collectEntries() {
|
||||||
|
[Integer.valueOf(it), restTemplate().getForEntity(urlWithPort("/c/$it"), byte[])]
|
||||||
|
}
|
||||||
|
|
||||||
|
then:
|
||||||
|
items.values().each { item ->
|
||||||
|
assert results.containsKey(item.id)
|
||||||
|
with(results.get(item.id)) { response ->
|
||||||
|
response.statusCode == HttpStatus.OK
|
||||||
|
with(response.headers.each { it.key.toLowerCase() }) {
|
||||||
|
assert it['content-type'] == [item.format.mime]
|
||||||
|
assert it['accept-ranges'] == ["bytes"]
|
||||||
|
assert it.containsKey('contentfeatures.dlna.org')
|
||||||
|
assert it.containsKey('transfermode.dlna.org')
|
||||||
|
assert it.containsKey('realtimeinfo.dlna.org')
|
||||||
|
}
|
||||||
|
response.body.length == item.fileLength
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def "should return 404 if content does not exist"() {
|
||||||
|
when:
|
||||||
|
ResponseEntity<byte[]> response = restTemplate().getForEntity(urlWithPort("/c/blah-blah"), byte[]);
|
||||||
|
|
||||||
|
then:
|
||||||
|
response.statusCode == HttpStatus.NOT_FOUND
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,272 @@
|
||||||
|
package net.schowek.nextclouddlna.controller
|
||||||
|
|
||||||
|
|
||||||
|
import net.schowek.nextclouddlna.dlna.media.MediaServer
|
||||||
|
import org.jupnp.support.model.DIDLObject
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import support.UpnpAwareSpecification
|
||||||
|
|
||||||
|
import static org.jupnp.support.model.Protocol.HTTP_GET
|
||||||
|
import static org.jupnp.support.model.WriteStatus.NOT_WRITABLE
|
||||||
|
|
||||||
|
class UpnpControllerIntTest extends UpnpAwareSpecification {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MediaServer mediaServer
|
||||||
|
|
||||||
|
def uid
|
||||||
|
|
||||||
|
def setup() {
|
||||||
|
uid = mediaServer.serviceIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
def "should serve icon"() {
|
||||||
|
when:
|
||||||
|
def response = restTemplate().getForEntity(urlWithPort("/dev/${uid}/icon.png"), byte[]);
|
||||||
|
|
||||||
|
then:
|
||||||
|
response.statusCode == HttpStatus.OK
|
||||||
|
with(response.headers.each { it.key.toLowerCase() }) {
|
||||||
|
assert it['content-type'] == ['application/octet-stream']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def "should serve service descriptor"() {
|
||||||
|
when:
|
||||||
|
def response = restTemplate().getForEntity(urlWithPort("/dev/${uid}/desc"), String);
|
||||||
|
|
||||||
|
then:
|
||||||
|
response.statusCode == HttpStatus.OK
|
||||||
|
response.headers['content-type'] == ['text/xml']
|
||||||
|
|
||||||
|
when:
|
||||||
|
def dom = createDocument(response)
|
||||||
|
|
||||||
|
then:
|
||||||
|
nodeValue(dom, "/root/device/friendlyName") == "nextcloud-dlna-int-test"
|
||||||
|
nodeValue(dom, "/root/device/UDN") == "uuid:${uid}"
|
||||||
|
nodeValue(dom, "/root/device/presentationURL") == urlWithPort()
|
||||||
|
}
|
||||||
|
|
||||||
|
def "should serve content directory desc"() {
|
||||||
|
when:
|
||||||
|
def response = restTemplate().getForEntity(urlWithPort("/dev/${uid}/svc/upnp-org/ContentDirectory/desc"), String);
|
||||||
|
|
||||||
|
then:
|
||||||
|
response.statusCode == HttpStatus.OK
|
||||||
|
response.headers['content-type'] == ['text/xml']
|
||||||
|
|
||||||
|
when:
|
||||||
|
def dom = createDocument(response)
|
||||||
|
|
||||||
|
then:
|
||||||
|
with(node(dom, "/scpd/serviceStateTable/stateVariable[name='A_ARG_TYPE_BrowseFlag']/allowedValueList").childNodes) {
|
||||||
|
assert it.length == 2
|
||||||
|
assert it.item(0).textContent == "BrowseMetadata"
|
||||||
|
assert it.item(1).textContent == "BrowseDirectChildren"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def "should serve connectionMgr desc"() {
|
||||||
|
when:
|
||||||
|
def response = restTemplate().getForEntity(urlWithPort("/dev/${uid}/svc/upnp-org/ConnectionManager/desc"), String);
|
||||||
|
|
||||||
|
then:
|
||||||
|
response.statusCode == HttpStatus.OK
|
||||||
|
response.headers['content-type'] == ['text/xml']
|
||||||
|
|
||||||
|
when:
|
||||||
|
def dom = createDocument(response)
|
||||||
|
|
||||||
|
then:
|
||||||
|
with(nodesList(dom, "/scpd/actionList/action/name")) {
|
||||||
|
assert it.length == 3
|
||||||
|
it.item(0).textContent == "GetCurrentConnectionIDs"
|
||||||
|
it.item(1).textContent == "GetProtocolInfo"
|
||||||
|
it.item(2).textContent == "GetCurrentConnectionInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def "should handle upnp browse metadata for ROOT request"() {
|
||||||
|
given:
|
||||||
|
def nodeId = "0"
|
||||||
|
def browseFlag = "BrowseMetadata"
|
||||||
|
|
||||||
|
when:
|
||||||
|
def response = performContentDirectoryAction(nodeId, browseFlag)
|
||||||
|
|
||||||
|
then:
|
||||||
|
response.statusCode == HttpStatus.OK
|
||||||
|
response.headers['content-type'] == ['text/xml;charset=utf-8']
|
||||||
|
|
||||||
|
when:
|
||||||
|
def didl = extractDIDLFromResponse(response)
|
||||||
|
|
||||||
|
then:
|
||||||
|
didl.containers.size() == 1
|
||||||
|
with(didl.containers[0]) {
|
||||||
|
assert id == "0"
|
||||||
|
assert parentID == "-1"
|
||||||
|
assert searchable
|
||||||
|
assert restricted
|
||||||
|
assert title == "ROOT"
|
||||||
|
assert writeStatus == NOT_WRITABLE
|
||||||
|
assert clazz.value == new DIDLObject.Class("object.container").value
|
||||||
|
assert childCount == 3 // johndoe, janedoe, family folder
|
||||||
|
}
|
||||||
|
didl.items.size() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def "should handle upnp browse direct children for ROOT request"() {
|
||||||
|
given:
|
||||||
|
def nodeId = "0"
|
||||||
|
def browseFlag = "BrowseDirectChildren"
|
||||||
|
|
||||||
|
when:
|
||||||
|
def response = performContentDirectoryAction(nodeId, browseFlag)
|
||||||
|
|
||||||
|
then:
|
||||||
|
response.statusCode == HttpStatus.OK
|
||||||
|
response.headers['content-type'] == ['text/xml;charset=utf-8']
|
||||||
|
|
||||||
|
when:
|
||||||
|
def didl = extractDIDLFromResponse(response)
|
||||||
|
|
||||||
|
then:
|
||||||
|
didl.containers.size() == 3
|
||||||
|
didl.containers.each {
|
||||||
|
assert it.searchable
|
||||||
|
assert it.restricted
|
||||||
|
assert it.writeStatus == NOT_WRITABLE
|
||||||
|
assert it.clazz.value == new DIDLObject.Class("object.container").value
|
||||||
|
}
|
||||||
|
|
||||||
|
with(didl.containers[0]) {
|
||||||
|
assert id == "2"
|
||||||
|
assert parentID == "1"
|
||||||
|
assert title == "johndoe"
|
||||||
|
assert childCount == 3
|
||||||
|
}
|
||||||
|
|
||||||
|
with(didl.containers[1]) {
|
||||||
|
assert id == "387"
|
||||||
|
assert parentID == "384"
|
||||||
|
assert title == "janedoe"
|
||||||
|
assert childCount == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
with(didl.containers[2]) {
|
||||||
|
assert id == "586"
|
||||||
|
assert parentID == "584"
|
||||||
|
assert title == "family folder"
|
||||||
|
assert childCount == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
didl.items.size() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def "should handle upnp browse metadata for johndoe's directory request"() {
|
||||||
|
given:
|
||||||
|
def nodeId = "2"
|
||||||
|
def browseFlag = "BrowseMetadata"
|
||||||
|
|
||||||
|
when:
|
||||||
|
def response = performContentDirectoryAction(nodeId, browseFlag)
|
||||||
|
|
||||||
|
then:
|
||||||
|
response.statusCode == HttpStatus.OK
|
||||||
|
response.headers['content-type'] == ['text/xml;charset=utf-8']
|
||||||
|
|
||||||
|
when:
|
||||||
|
def didl = extractDIDLFromResponse(response)
|
||||||
|
|
||||||
|
then:
|
||||||
|
didl.containers.size() == 1
|
||||||
|
with(didl.containers[0]) {
|
||||||
|
assert id == "2"
|
||||||
|
assert parentID == "1"
|
||||||
|
assert searchable
|
||||||
|
assert restricted
|
||||||
|
assert title == "johndoe"
|
||||||
|
assert writeStatus == NOT_WRITABLE
|
||||||
|
assert clazz.value == new DIDLObject.Class("object.container").value
|
||||||
|
assert childCount == 3
|
||||||
|
}
|
||||||
|
didl.items.size() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def "should handle upnp browse direct children for johndoe's directory request"() {
|
||||||
|
given:
|
||||||
|
def nodeId = "2"
|
||||||
|
def browseFlag = "BrowseDirectChildren"
|
||||||
|
|
||||||
|
when:
|
||||||
|
def response = performContentDirectoryAction(nodeId, browseFlag)
|
||||||
|
|
||||||
|
then:
|
||||||
|
response.statusCode == HttpStatus.OK
|
||||||
|
response.headers['content-type'] == ['text/xml;charset=utf-8']
|
||||||
|
|
||||||
|
when:
|
||||||
|
def didl = extractDIDLFromResponse(response)
|
||||||
|
|
||||||
|
then:
|
||||||
|
didl.containers.size() == 1
|
||||||
|
with(didl.containers[0]) {
|
||||||
|
assert id == "15"
|
||||||
|
assert parentID == "2"
|
||||||
|
assert title == "photos"
|
||||||
|
assert childCount == 2
|
||||||
|
assert searchable
|
||||||
|
assert restricted
|
||||||
|
assert writeStatus == NOT_WRITABLE
|
||||||
|
assert clazz.value == new DIDLObject.Class("object.container").value
|
||||||
|
}
|
||||||
|
|
||||||
|
didl.items.size() == 2
|
||||||
|
with(didl.items[0]) {
|
||||||
|
assert it.id == "13"
|
||||||
|
assert it.parentID == "2"
|
||||||
|
assert title == "Nextcloud intro.mp4"
|
||||||
|
assert !restricted
|
||||||
|
|
||||||
|
with(resources[0]) {
|
||||||
|
assert protocolInfo.contentFormat == "video/mp4"
|
||||||
|
assert protocolInfo.protocol == HTTP_GET
|
||||||
|
assert size == 3963036
|
||||||
|
assert value == urlWithPort("/c/13")
|
||||||
|
}
|
||||||
|
|
||||||
|
with(resources[1]) { thumbnail ->
|
||||||
|
assert thumbnail.protocolInfo.contentFormat == "image/jpeg"
|
||||||
|
assert thumbnail.protocolInfo.protocol == HTTP_GET
|
||||||
|
assert thumbnail.protocolInfo.additionalInfo == "DLNA.ORG_PN=JPEG_TN"
|
||||||
|
assert thumbnail.size == 28820
|
||||||
|
assert thumbnail.value == urlWithPort("/c/273")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with(didl.items[1]) {
|
||||||
|
assert it.id == "14"
|
||||||
|
assert it.parentID == "2"
|
||||||
|
assert title == "Nextcloud.png"
|
||||||
|
assert !restricted
|
||||||
|
|
||||||
|
with(resources[0]) {
|
||||||
|
assert protocolInfo.contentFormat == "image/png"
|
||||||
|
assert protocolInfo.protocol == HTTP_GET
|
||||||
|
assert size == 50598
|
||||||
|
assert value == urlWithPort("/c/14")
|
||||||
|
}
|
||||||
|
|
||||||
|
with(resources[1]) { thumbnail ->
|
||||||
|
assert thumbnail.protocolInfo.contentFormat == "image/png"
|
||||||
|
assert thumbnail.protocolInfo.protocol == HTTP_GET
|
||||||
|
assert thumbnail.protocolInfo.additionalInfo == "DLNA.ORG_PN=PNG_TN"
|
||||||
|
assert thumbnail.size == 50545
|
||||||
|
assert thumbnail.value == urlWithPort("/c/164")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
package net.schowek.nextclouddlna.nextcloud.config
|
||||||
|
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Profile
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Profile("integration")
|
||||||
|
class NextcloudAppPathProviderInt implements NextcloudAppPathProvider {
|
||||||
|
@Override
|
||||||
|
File getNextcloudDir() {
|
||||||
|
return new File(getClass().getResource("/nextcloud/app/data").getFile())
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
package net.schowek.nextclouddlna.nextcloud.content
|
||||||
|
|
||||||
|
import net.schowek.nextclouddlna.nextcloud.db.AppConfigId
|
||||||
|
import net.schowek.nextclouddlna.nextcloud.db.AppConfigRepository
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import spock.lang.Unroll
|
||||||
|
import support.IntegrationSpecification
|
||||||
|
|
||||||
|
@Unroll
|
||||||
|
class ContentTreeProviderIntTest extends IntegrationSpecification {
|
||||||
|
@Autowired
|
||||||
|
ContentTreeProvider contentTreeProvider
|
||||||
|
@Autowired
|
||||||
|
AppConfigRepository appConfigRepository
|
||||||
|
|
||||||
|
def "should create content tree including the group folder"() {
|
||||||
|
when:
|
||||||
|
contentTreeProvider.rebuildTree(true)
|
||||||
|
|
||||||
|
then:
|
||||||
|
def root = contentTreeProvider.getNode("0")
|
||||||
|
with(root) {
|
||||||
|
nodes.size() == 3
|
||||||
|
nodes[0].name == "johndoe"
|
||||||
|
nodes[1].name == "janedoe"
|
||||||
|
nodes[2].name == "family folder"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def "should create content tree without the group folder when the option is disabled"() {
|
||||||
|
given:
|
||||||
|
appConfigRepository.deleteById(new AppConfigId("groupfolders", "enabled"))
|
||||||
|
|
||||||
|
when:
|
||||||
|
contentTreeProvider.rebuildTree(true)
|
||||||
|
|
||||||
|
then:
|
||||||
|
def root = contentTreeProvider.getNode("0")
|
||||||
|
with(root) {
|
||||||
|
nodes.size() == 2
|
||||||
|
nodes[0].name == "johndoe"
|
||||||
|
nodes[1].name == "janedoe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
package net.schowek.nextclouddlna.util
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.context.annotation.Profile
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Profile("integration")
|
||||||
|
class ServerInfoProviderInt implements ServerInfoProvider {
|
||||||
|
private final ServerPortCustomizer serverPortCustomizer
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
ServerInfoProviderInt(ServerPortCustomizer serverPortCustomizer) {
|
||||||
|
this.serverPortCustomizer = serverPortCustomizer
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
String getHost() {
|
||||||
|
return "localhost"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
int getPort() {
|
||||||
|
return serverPortCustomizer.port
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
package net.schowek.nextclouddlna.util
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.boot.web.server.ConfigurableWebServerFactory
|
||||||
|
import org.springframework.boot.web.server.WebServerFactoryCustomizer
|
||||||
|
import org.springframework.context.annotation.Profile
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Profile("integration")
|
||||||
|
public class ServerPortCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
|
||||||
|
@Value("\${random.int(9090,65535)}")
|
||||||
|
int port
|
||||||
|
|
||||||
|
int getPort() {
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
void customize(ConfigurableWebServerFactory factory) {
|
||||||
|
factory.setPort(port);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
package support
|
||||||
|
|
||||||
|
import net.schowek.nextclouddlna.NextcloudDLNAApp
|
||||||
|
import net.schowek.nextclouddlna.util.ServerInfoProvider
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootContextLoader
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.web.client.TestRestTemplate
|
||||||
|
import org.springframework.test.context.ActiveProfiles
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import spock.lang.Specification
|
||||||
|
|
||||||
|
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT
|
||||||
|
|
||||||
|
@ContextConfiguration(loader = SpringBootContextLoader, classes = NextcloudDLNAApp.class)
|
||||||
|
@SpringBootTest(webEnvironment = DEFINED_PORT)
|
||||||
|
@ActiveProfiles("integration")
|
||||||
|
class IntegrationSpecification extends Specification {
|
||||||
|
@Autowired
|
||||||
|
private TestRestTemplate restTemplate
|
||||||
|
|
||||||
|
TestRestTemplate restTemplate() {
|
||||||
|
return restTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ServerInfoProvider serverInfoProvider
|
||||||
|
|
||||||
|
protected String urlWithPort(String uri = "") {
|
||||||
|
return "http://localhost:" + serverInfoProvider.port + uri;
|
||||||
|
}
|
||||||
|
}
|
71
src/integration/groovy/support/UpnpAwareSpecification.groovy
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
package support
|
||||||
|
|
||||||
|
import org.jupnp.support.contentdirectory.DIDLParser
|
||||||
|
import org.jupnp.support.model.DIDLContent
|
||||||
|
import org.springframework.http.HttpEntity
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.w3c.dom.Document
|
||||||
|
import org.w3c.dom.Node
|
||||||
|
import org.w3c.dom.NodeList
|
||||||
|
import org.xml.sax.InputSource
|
||||||
|
import support.IntegrationSpecification
|
||||||
|
|
||||||
|
import javax.xml.parsers.DocumentBuilderFactory
|
||||||
|
import javax.xml.xpath.XPath
|
||||||
|
import javax.xml.xpath.XPathFactory
|
||||||
|
|
||||||
|
import static javax.xml.xpath.XPathConstants.NODE
|
||||||
|
import static javax.xml.xpath.XPathConstants.NODESET
|
||||||
|
|
||||||
|
class UpnpAwareSpecification extends IntegrationSpecification {
|
||||||
|
Document createDocument(ResponseEntity<String> response) {
|
||||||
|
return DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(
|
||||||
|
new InputSource(new StringReader(response.body))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String nodeValue(Document dom, String pattern) {
|
||||||
|
return node(dom, "$pattern/text()").nodeValue
|
||||||
|
}
|
||||||
|
|
||||||
|
Node node(Document dom, String pattern) {
|
||||||
|
XPath xpath = XPathFactory.newInstance().newXPath();
|
||||||
|
return (xpath.evaluate(pattern, dom, NODE) as Node)
|
||||||
|
}
|
||||||
|
|
||||||
|
NodeList nodesList(Document dom, String pattern) {
|
||||||
|
XPath xpath = XPathFactory.newInstance().newXPath();
|
||||||
|
return (xpath.evaluate(pattern, dom, NODESET) as NodeList)
|
||||||
|
}
|
||||||
|
|
||||||
|
ResponseEntity performContentDirectoryAction(String nodeId, String browseFlag) {
|
||||||
|
def requestBody = '<?xml version="1.0" encoding="utf-8"?>\n' +
|
||||||
|
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"\n' +
|
||||||
|
' s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">\n' +
|
||||||
|
' <s:Body>\n' +
|
||||||
|
' <u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">\n' +
|
||||||
|
' <ObjectID>' + nodeId + '</ObjectID>\n' +
|
||||||
|
' <BrowseFlag>' + browseFlag + '</BrowseFlag>\n' +
|
||||||
|
' <Filter>*</Filter>\n' +
|
||||||
|
' <StartingIndex>0</StartingIndex>\n' +
|
||||||
|
' <RequestedCount>200</RequestedCount>\n' +
|
||||||
|
' <SortCriteria></SortCriteria>\n' +
|
||||||
|
' </u:Browse>\n' +
|
||||||
|
' </s:Body>\n' +
|
||||||
|
'</s:Envelope>'
|
||||||
|
def headers = new HttpHeaders([
|
||||||
|
'content-type': 'text/xml; charset="utf-8"',
|
||||||
|
'soapaction' : '"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"'
|
||||||
|
]);
|
||||||
|
HttpEntity<String> request = new HttpEntity<String>(requestBody, headers);
|
||||||
|
return restTemplate().postForEntity(urlWithPort("/dev/$uid/svc/upnp-org/ContentDirectory/action"), request, String)
|
||||||
|
}
|
||||||
|
|
||||||
|
DIDLContent extractDIDLFromResponse(ResponseEntity<String> response) {
|
||||||
|
Document dom = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(
|
||||||
|
new InputSource(new StringReader(response.body))
|
||||||
|
);
|
||||||
|
return new DIDLParser().parse(nodeValue(dom, "/Envelope/Body/BrowseResponse/Result"))
|
||||||
|
}
|
||||||
|
}
|
18
src/integration/resources/application-integration.yml
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
server:
|
||||||
|
friendlyName: "nextcloud-dlna-int-test"
|
||||||
|
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:h2:mem:nextcloud-dlna-integration
|
||||||
|
username: sa
|
||||||
|
password:
|
||||||
|
driverClassName: org.h2.Driver
|
||||||
|
jpa:
|
||||||
|
database-platform: org.hibernate.dialect.H2Dialect
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: none
|
||||||
|
sql:
|
||||||
|
init:
|
||||||
|
mode: always
|
||||||
|
schema-locations: classpath:db/schema.sql
|
||||||
|
data-locations: classpath:db/data.sql
|
4
src/integration/resources/application.yml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
spring:
|
||||||
|
profiles:
|
||||||
|
active: integration
|
||||||
|
|
84
src/integration/resources/db/data.sql
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
|
||||||
|
INSERT INTO `oc_appconfig` VALUES
|
||||||
|
('groupfolders','enabled','yes'),
|
||||||
|
('groupfolders','installed_version','15.3.1'),
|
||||||
|
('groupfolders','types','filesystem,dav');
|
||||||
|
|
||||||
|
INSERT INTO `oc_filecache` VALUES
|
||||||
|
(1,1,'','d41d8cd98f00b204e9800998ecf8427e',-1,'',2,1,5341566992,1696704138,1696613362,0,0,'',23,''),
|
||||||
|
(3,2,'','d41d8cd98f00b204e9800998ecf8427e',-1,'',2,1,13286908,1696702221,1696695204,0,0,'',23,''),
|
||||||
|
(384,3,'','d41d8cd98f00b204e9800998ecf8427e',-1,'',2,1,37549364,1696702111,1695740236,0,0,'',23,''),
|
||||||
|
|
||||||
|
(2,1,'files','45b963397aa40d4a0063e0d85e4fe7a1',1,'files',2,1,5341560694,1696704138,1696704138,0,0,'',31,''),
|
||||||
|
(13,1,'files/Nextcloud intro.mp4','e4919345bcc87d4585a5525daaad99c0',2,'Nextcloud intro.mp4',9,8,3963036,1695737656,1695737656,0,0,'',27,''),
|
||||||
|
(14,1,'files/Nextcloud.png','',2,'Nextcloud.png',11,10,50598,1695737656,1695737656,0,0,'',27,''),
|
||||||
|
(15,1,'files/photos','d01bb67e7b71dd49fd06bad922f521c9',2,'photos',2,1,5656462,1695737827,1695737658,0,0,'',31,''),
|
||||||
|
(16,1,'files/photos/Birdie.jpg','cd31c7af3a0ec6e15782b5edd2774549',15,'Birdie.jpg',12,10,593508,1695737656,1695737656,0,0,'',27,''),
|
||||||
|
(17,1,'files/photos/Frog.jpg','d6219add1a9129ed0c1513af985e2081',15,'Frog.jpg',12,10,457744,1695737656,1695737656,0,0,'',27,''),
|
||||||
|
|
||||||
|
(387,3,'files','45b963397aa40d4a0063e0d85e4fe7a1',384,'files',2,1,9670097,1695740237,1695740237,0,0,'',31,''),
|
||||||
|
(396,3,'files/pictures','d01bb67e7b71dd49fd06bad922f521c9',387,'pictures',2,1,5656463,1695740165,1695740165,0,0,'',31,''),
|
||||||
|
(397,3,'files/pictures/Steps.jpg','7b2ca8d05bbad97e00cbf5833d43e912',396,'Steps.jpg',12,10,567689,1695737658,1695737658,0,0,'',27,''),
|
||||||
|
(398,3,'files/pictures/Toucan.jpg','681d1e78f46a233e12ecfa722cbc2aef',396,'Toucan.jpg',12,10,167989,1695737658,1695737658,0,0,'',27,''),
|
||||||
|
|
||||||
|
(584,2,'__groupfolders','29ff0edf73a32cb03e437d88fd049245',3,'__groupfolders',2,1,3012412,1696702221,1696702221,0,0,'',31,''),
|
||||||
|
(586,2,'__groupfolders/1','e46cc72327dfc3ccaf32f0a167e6c6d1',584,'1',2,1,3012412,1696695701,1696695701,0,0,'',31,''),
|
||||||
|
(587,2,'__groupfolders/Nextcloud community.jpg','b9b3caef83a2a1c20354b98df6bcd9d0',586,'Nextcloud community.jpg',12,10,797325,1695737657,1695737657,0,0,
|
||||||
|
'',27,''),
|
||||||
|
|
||||||
|
(4,2,'appdata_integration','bed7fa8a60170b5d88c9da5e69eaeb5a',3,'appdata_integration',2,1,10274496,1695737790,1695737790,0,0,'',31,''),
|
||||||
|
(69,2,'appdata_integration/preview','e771733d5f59ead277f502588282d693',4,'preview',2,1,5153144,1695738765,1695738765,0,0,'',31,''),
|
||||||
|
(118,2,'appdata_integration/preview/0','7a4d3ccae601e9499e1ab6c444389190',69,'c',2,1,182196,1695738765,1695738765,0,0,'',31,''),
|
||||||
|
(266,2,'appdata_integration/preview/0/0','06062a1c21b0baf285ceeb0f6cc5ccf5',118,'5',2,1,52904,1695738113,1695738113,0,0,'',31,''),
|
||||||
|
(267,2,'appdata_integration/preview/0/0/0','caef481eed68975e892a313693abbc1d',266,'1',2,1,52904,1695738113,1695738113,0,0,'',31,''),
|
||||||
|
(268,2,'appdata_integration/preview/0/0/0/0','e5373ed53baeecb521bfaf52e7732241',267,'c',2,1,52904,1695738113,1695738113,0,0,'',31,''),
|
||||||
|
(269,2,'appdata_integration/preview/0/0/0/0/0','e91eaac3bf03ce5442e638b0e9c82d01',268,'e',2,1,52904,1695738113,1695738113,0,0,'',31,''),
|
||||||
|
(270,2,'appdata_integration/preview/0/0/0/0/0/0','efb8bf57b9f71e52d73f51671eb4cc83',269,'4',2,1,52904,1695738113,1695738113,0,0,'',31,''),
|
||||||
|
(271,2,'appdata_integration/preview/0/0/0/0/0/0/0','515ef6dc4c2b8c8abeff3b29ad645aa4',270,'1',2,1,52904,1695738113,1695738113,0,0,'',31,''),
|
||||||
|
|
||||||
|
(272,2,'appdata_integration/preview/0/0/0/0/0/0/0/13','3ca72f7aea8335c7a4bd43c2e4bf89ab',271,'13',2,1,52904,1696697007,1696697007,0,0,'',31,''),
|
||||||
|
(273,2,'appdata_integration/preview/0/0/0/0/0/0/0/13/1000-563-max.jpg','de54b3c7b20873a40a485bc666faaf8c',272,'1000-563-max.jpg',12,10,28820,1695738114,1695738114,0,0,'',27,''),
|
||||||
|
(694,2,'appdata_integration/preview/0/0/0/0/0/0/0/13/455-256.jpg','08aa43b18c0bf4824edc33e8b852ab08',272,'455-256.jpg',12,10,9399,1696697007,1696697007,0,0,'',27,''),
|
||||||
|
|
||||||
|
(155,2,'appdata_integration/preview/0/0/0/0/0/0/0/14','bb5cbf8f27a56a12e83245d5532d0381',271,'14',2,1,107111,1696697008,1696697008,0,0,'',31,''),
|
||||||
|
(164,2,'appdata_integration/preview/0/0/0/0/0/0/0/14/500-500-max.png','15b68a8c62dab8e7c3773ce0d1ac4f43',155,'500-500-max.png',11,10,50545,1695737792,1695737792,0,0,'',27,''),
|
||||||
|
|
||||||
|
(803,2,'appdata_integration/preview/0/0/0/0/0/0/0/16','71e9a2780187bcba09819c38341af594',271,'16',2,1,129292,1696698525,1696698525,0,0,'',31,''),
|
||||||
|
(804,2,'appdata_integration/preview/0/0/0/0/0/0/0/16/1000-667-max.jpg','c6df2f52ea6e2bce26b0c0c9cb99b7a5',803,'1000-667-max.jpg',12,10,81421,1696698525,1696698525,0,0,'',27,''),
|
||||||
|
|
||||||
|
(811,2,'appdata_integration/preview/0/0/0/0/0/0/0/17','9fbd157aa2c41b17eb7ec32e9f93551f',271,'17',2,1,84401,1696698525,1696698525,0,0,'',31,''),
|
||||||
|
(812,2,'appdata_integration/preview/0/0/0/0/0/0/0/17/1000-667-max.jpg','351f42173986513e412dbcc450bd4b19',811,'1000-667-max.jpg',12,10,53672,1696698525,1696698525,0,0,'',27,'');
|
||||||
|
|
||||||
|
INSERT INTO `oc_group_folders` VALUES
|
||||||
|
(1,'family folder',-3,0);
|
||||||
|
|
||||||
|
INSERT INTO `oc_mimetypes` VALUES
|
||||||
|
(5,'application'),
|
||||||
|
(19,'application/gzip'),
|
||||||
|
(18,'application/javascript'),
|
||||||
|
(20,'application/json'),
|
||||||
|
(16,'application/octet-stream'),
|
||||||
|
(6,'application/pdf'),
|
||||||
|
(13,'application/vnd.oasis.opendocument.graphics'),
|
||||||
|
(15,'application/vnd.oasis.opendocument.presentation'),
|
||||||
|
(14,'application/vnd.oasis.opendocument.spreadsheet'),
|
||||||
|
(17,'application/vnd.oasis.opendocument.text'),
|
||||||
|
(7,'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
|
||||||
|
(22,'audio'),
|
||||||
|
(23,'audio/mpeg'),
|
||||||
|
(1,'httpd'),
|
||||||
|
(2,'httpd/unix-directory'),
|
||||||
|
(10,'image'),
|
||||||
|
(12,'image/jpeg'),
|
||||||
|
(11,'image/png'),
|
||||||
|
(21,'image/svg+xml'),
|
||||||
|
(3,'text'),
|
||||||
|
(4,'text/markdown'),
|
||||||
|
(8,'video'),
|
||||||
|
(9,'video/mp4');
|
||||||
|
|
||||||
|
INSERT INTO `oc_mounts` VALUES
|
||||||
|
(1,1,1,'johndoe','/johndoe/',NULL,'OC\\Files\\Mount\\LocalHomeMountProvider'),
|
||||||
|
(2,3,384,'janedoe','/janedoe/',NULL,'OC\\Files\\Mount\\LocalHomeMountProvider'),
|
||||||
|
(3,2,586,'johndoe','/johndoe/files/family folder/',NULL,'OCA\\GroupFolders\\Mount\\MountProvider'),
|
||||||
|
(4,2,586,'janedoe','/janedoe/files/family folder/',NULL,'OCA\\GroupFolders\\Mount\\MountProvider');
|
76
src/integration/resources/db/schema.sql
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
DROP TABLE IF EXISTS oc_appconfig;
|
||||||
|
CREATE TABLE `oc_appconfig` (
|
||||||
|
`appid` varchar(32) NOT NULL DEFAULT '',
|
||||||
|
`configkey` varchar(64) NOT NULL DEFAULT '',
|
||||||
|
`configvalue` longtext DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`appid`,`configkey`)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX `appconfig_config_key_index` ON oc_appconfig(`configkey`);
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS oc_filecache;
|
||||||
|
CREATE TABLE `oc_filecache` (
|
||||||
|
`fileid` NUMERIC(20) NOT NULL AUTO_INCREMENT,
|
||||||
|
`storage` NUMERIC(20) NOT NULL DEFAULT 0,
|
||||||
|
`path` varchar(4000) DEFAULT NULL,
|
||||||
|
`path_hash` varchar(32) NOT NULL DEFAULT '',
|
||||||
|
`parent` NUMERIC(20) NOT NULL DEFAULT 0,
|
||||||
|
`name` varchar(250) DEFAULT NULL,
|
||||||
|
`mimetype` NUMERIC(20) NOT NULL DEFAULT 0,
|
||||||
|
`mimepart` NUMERIC(20) NOT NULL DEFAULT 0,
|
||||||
|
`size` NUMERIC(20) NOT NULL DEFAULT 0,
|
||||||
|
`mtime` NUMERIC(20) NOT NULL DEFAULT 0,
|
||||||
|
`storage_mtime` NUMERIC(20) NOT NULL DEFAULT 0,
|
||||||
|
`encrypted` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`unencrypted_size` NUMERIC(20) NOT NULL DEFAULT 0,
|
||||||
|
`etag` varchar(40) DEFAULT NULL,
|
||||||
|
`permissions` INTEGER DEFAULT 0,
|
||||||
|
`checksum` varchar(255) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`fileid`)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX `fs_storage_path_hash` ON oc_filecache(`storage`,`path_hash`);
|
||||||
|
CREATE INDEX `fs_parent_name_hash` ON oc_filecache(`parent`,`name`);
|
||||||
|
CREATE INDEX `fs_storage_mimetype` ON oc_filecache(`storage`,`mimetype`);
|
||||||
|
CREATE INDEX `fs_storage_mimepart` ON oc_filecache(`storage`,`mimepart`);
|
||||||
|
CREATE INDEX `fs_storage_size` ON oc_filecache(`storage`,`size`,`fileid`);
|
||||||
|
CREATE INDEX `fs_id_storage_size` ON oc_filecache(`fileid`,`storage`,`size`);
|
||||||
|
CREATE INDEX `fs_parent` ON oc_filecache(`parent`);
|
||||||
|
CREATE INDEX `fs_mtime` ON oc_filecache(`mtime`);
|
||||||
|
CREATE INDEX `fs_size` ON oc_filecache(`size`);
|
||||||
|
CREATE INDEX `fs_storage_path_prefix` ON oc_filecache(`storage`,`path`);
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS oc_group_folders;
|
||||||
|
CREATE TABLE `oc_group_folders` (
|
||||||
|
`folder_id` NUMERIC(20) NOT NULL AUTO_INCREMENT,
|
||||||
|
`mount_point` varchar(4000) NOT NULL,
|
||||||
|
`quota` NUMERIC(20) NOT NULL DEFAULT -3,
|
||||||
|
`acl` INTEGER DEFAULT 0,
|
||||||
|
PRIMARY KEY (`folder_id`)
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS oc_mimetypes;
|
||||||
|
CREATE TABLE `oc_mimetypes` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`mimetype` varchar(255) NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX `mimetype_id_index` ON oc_mimetypes(`mimetype`);
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS oc_mounts;
|
||||||
|
CREATE TABLE `oc_mounts` (
|
||||||
|
`id` NUMERIC(20) NOT NULL AUTO_INCREMENT,
|
||||||
|
`storage_id` NUMERIC(20) NOT NULL,
|
||||||
|
`root_id` NUMERIC(20) NOT NULL,
|
||||||
|
`user_id` varchar(64) NOT NULL,
|
||||||
|
`mount_point` varchar(4000) NOT NULL,
|
||||||
|
`mount_id` NUMERIC(20) DEFAULT NULL,
|
||||||
|
`mount_provider_class` varchar(128) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
);
|
||||||
|
CREATE INDEX `mounts_storage_index` ON oc_mounts(`storage_id`);
|
||||||
|
CREATE INDEX `mounts_root_index` ON oc_mounts(`root_id`);
|
||||||
|
CREATE INDEX `mounts_mount_id_index` ON oc_mounts(`mount_id`);
|
||||||
|
CREATE INDEX `mounts_user_root_path_index` ON oc_mounts(`user_id`,`root_id`,`mount_point`);
|
||||||
|
CREATE INDEX `mounts_class_index` ON oc_mounts(`mount_provider_class`);
|
||||||
|
CREATE INDEX `mount_user_storage` ON oc_mounts(`storage_id`,`user_id`);
|
After Width: | Height: | Size: 779 KiB |
After Width: | Height: | Size: 28 KiB |
After Width: | Height: | Size: 9.2 KiB |
After Width: | Height: | Size: 49 KiB |
After Width: | Height: | Size: 80 KiB |
After Width: | Height: | Size: 52 KiB |
After Width: | Height: | Size: 554 KiB |
After Width: | Height: | Size: 164 KiB |
After Width: | Height: | Size: 49 KiB |
After Width: | Height: | Size: 580 KiB |
After Width: | Height: | Size: 447 KiB |
|
@ -2,7 +2,6 @@ package net.schowek.nextclouddlna
|
||||||
|
|
||||||
import jakarta.annotation.PreDestroy
|
import 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()
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,38 +0,0 @@
|
||||||
package net.schowek.nextclouddlna.dlna
|
|
||||||
|
|
||||||
import mu.KLogging
|
|
||||||
import net.schowek.nextclouddlna.dlna.media.MediaServer
|
|
||||||
import net.schowek.nextclouddlna.dlna.media.MediaServer.Companion.ICON_FILENAME
|
|
||||||
import org.jupnp.UpnpService
|
|
||||||
import org.jupnp.model.resource.IconResource
|
|
||||||
import org.jupnp.model.resource.Resource
|
|
||||||
import org.jupnp.registry.RegistryImpl
|
|
||||||
import org.jupnp.registry.RegistryMaintainer
|
|
||||||
import java.io.IOException
|
|
||||||
import java.net.URI
|
|
||||||
|
|
||||||
|
|
||||||
class RegistryImplWithOverrides(
|
|
||||||
private val upnpService: UpnpService
|
|
||||||
) : RegistryImpl(upnpService) {
|
|
||||||
private var icon: Resource<*>
|
|
||||||
|
|
||||||
init {
|
|
||||||
try {
|
|
||||||
val deviceIcon = MediaServer.createDeviceIcon()
|
|
||||||
icon = IconResource(deviceIcon.uri, deviceIcon)
|
|
||||||
} catch (e: IOException) {
|
|
||||||
throw RuntimeException(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
@Throws(IllegalArgumentException::class)
|
|
||||||
override fun getResource(pathQuery: URI): Resource<*>? {
|
|
||||||
return if ("/$ICON_FILENAME" == pathQuery.path) {
|
|
||||||
icon
|
|
||||||
} else super.getResource(pathQuery)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object : KLogging()
|
|
||||||
}
|
|
|
@ -2,13 +2,18 @@ package net.schowek.nextclouddlna.dlna
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import 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()) {
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
package net.schowek.nextclouddlna.nextcloud.config
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.context.annotation.Profile
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
interface NextcloudAppPathProvider {
|
||||||
|
val nextcloudDir: File
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Profile("!integration")
|
||||||
|
class NextcloudAppPathsProviderImpl(
|
||||||
|
@Value("\${nextcloud.filesDir}")
|
||||||
|
private val dirPath: String,
|
||||||
|
) : NextcloudAppPathProvider {
|
||||||
|
override val nextcloudDir = ensureNextcloudDir(dirPath)
|
||||||
|
|
||||||
|
private fun ensureNextcloudDir(dirPath: String): File {
|
||||||
|
if (dirPath.isEmpty()) {
|
||||||
|
throw RuntimeException("No nextcloud data directory name provided")
|
||||||
|
}
|
||||||
|
return File(dirPath).also {
|
||||||
|
if (!it.exists() || !it.isDirectory) {
|
||||||
|
throw RuntimeException("Invalid nextcloud data directory specified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,31 +1,28 @@
|
||||||
package net.schowek.nextclouddlna.nextcloud
|
package net.schowek.nextclouddlna.nextcloud.config
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct
|
|
||||||
import mu.KLogging
|
import 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 {
|
|
@ -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)
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
package net.schowek.nextclouddlna.util
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import java.time.Clock
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
class ClockConfig {
|
||||||
|
@Bean
|
||||||
|
fun clock(): Clock {
|
||||||
|
return Clock.systemDefaultZone()
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,11 +5,17 @@ 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)
|
||||||
|
|
||||||
fun contentUrl(id: Int) = "$selfUriString/c/$id"
|
fun contentUrl(id: Int) = "$selfUriString/c/$id"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,96 @@
|
||||||
|
package net.schowek.nextclouddlna.dlna
|
||||||
|
|
||||||
|
import org.jupnp.model.message.StreamResponseMessage
|
||||||
|
import org.jupnp.model.message.UpnpHeaders
|
||||||
|
import org.jupnp.model.message.UpnpRequest
|
||||||
|
import org.jupnp.model.message.UpnpResponse
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest
|
||||||
|
import spock.lang.Specification
|
||||||
|
|
||||||
|
class StreamMessageMapperTest extends Specification {
|
||||||
|
def sut = new StreamMessageMapper()
|
||||||
|
|
||||||
|
def "should map servlet request to streamRequestMessage"() {
|
||||||
|
given:
|
||||||
|
def uri = "http://foo.bar/baz"
|
||||||
|
def content = "some content"
|
||||||
|
def headers = [
|
||||||
|
"foo": "bar",
|
||||||
|
"baz": "blah"
|
||||||
|
]
|
||||||
|
|
||||||
|
def request = new MockHttpServletRequest(method, uri)
|
||||||
|
request.setContent(content.getBytes("UTF-8"))
|
||||||
|
headers.entrySet().forEach { request.addHeader(it.key, it.value) }
|
||||||
|
|
||||||
|
when:
|
||||||
|
def result = sut.map(request)
|
||||||
|
|
||||||
|
then:
|
||||||
|
result.uri == new URI(uri)
|
||||||
|
result.operation.method == expectedMethod
|
||||||
|
result.body.toString() == content
|
||||||
|
result.headers.each {
|
||||||
|
assert it.key.toLowerCase() in headers.keySet()
|
||||||
|
assert it.value == [headers[it.key.toLowerCase()]]
|
||||||
|
}
|
||||||
|
|
||||||
|
where:
|
||||||
|
method || expectedMethod
|
||||||
|
"GET" || UpnpRequest.Method.GET
|
||||||
|
"POST" || UpnpRequest.Method.POST
|
||||||
|
"M-SEARCH" || UpnpRequest.Method.MSEARCH
|
||||||
|
"NOTIFY" || UpnpRequest.Method.NOTIFY
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def "should throw exception when http method is missing or not supported"() {
|
||||||
|
given:
|
||||||
|
def request = new MockHttpServletRequest(method, "http://foo.bar/")
|
||||||
|
|
||||||
|
when:
|
||||||
|
sut.map(request)
|
||||||
|
|
||||||
|
then:
|
||||||
|
thrown RuntimeException
|
||||||
|
|
||||||
|
where:
|
||||||
|
method | _
|
||||||
|
null | _
|
||||||
|
"HEAD" | _
|
||||||
|
"foo" | _
|
||||||
|
}
|
||||||
|
|
||||||
|
def "should map streamResponseMessage to ResponseEntity"() {
|
||||||
|
given:
|
||||||
|
def content = "some content"
|
||||||
|
def headers = [
|
||||||
|
"foo": ["bar"],
|
||||||
|
"baz": ["blah"]
|
||||||
|
]
|
||||||
|
|
||||||
|
def response = new StreamResponseMessage(new UpnpResponse(responseStatus, "OK"))
|
||||||
|
response.headers = new UpnpHeaders(headers)
|
||||||
|
response.body = content
|
||||||
|
|
||||||
|
when:
|
||||||
|
def result = sut.map(response)
|
||||||
|
|
||||||
|
then:
|
||||||
|
result.statusCode == expectedHttpStatus
|
||||||
|
result.body == content
|
||||||
|
result.headers.each {
|
||||||
|
assert headers.keySet().contains(it.key.toLowerCase())
|
||||||
|
assert headers[it.key.toLowerCase()] == it.value
|
||||||
|
}
|
||||||
|
|
||||||
|
where:
|
||||||
|
responseStatus || expectedHttpStatus
|
||||||
|
200 || HttpStatus.OK
|
||||||
|
404 || HttpStatus.NOT_FOUND
|
||||||
|
500 || HttpStatus.INTERNAL_SERVER_ERROR
|
||||||
|
400 || HttpStatus.BAD_REQUEST
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
package net.schowek.nextclouddlna.nextcloud.content
|
||||||
|
|
||||||
|
import net.schowek.nextclouddlna.nextcloud.NextcloudDB
|
||||||
|
import spock.lang.Specification
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.function.Consumer
|
||||||
|
import static java.time.Instant.now
|
||||||
|
import static java.time.Instant.ofEpochSecond
|
||||||
|
import static java.time.temporal.ChronoUnit.DAYS
|
||||||
|
|
||||||
|
class ContentTreeProviderTest extends Specification {
|
||||||
|
def nextcloudDB
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
nextcloudDB = Mock(NextcloudDB)
|
||||||
|
nextcloudDB.mainNodes() >> []
|
||||||
|
nextcloudDB.groupFolders() >> []
|
||||||
|
nextcloudDB.processThumbnails(_ as Consumer<ContentItem>) >> null
|
||||||
|
}
|
||||||
|
|
||||||
|
def "should rebuild tree if it's outdated"() {
|
||||||
|
given:
|
||||||
|
def clock = Clock.fixed(now, ZoneId.systemDefault())
|
||||||
|
|
||||||
|
def sut = new ContentTreeProvider(nextcloudDB, clock)
|
||||||
|
sut.lastMTime = lastMTime.epochSecond
|
||||||
|
nextcloudDB.maxMtime() >> maxMtime.epochSecond
|
||||||
|
|
||||||
|
when:
|
||||||
|
def rebuild = sut.rebuildTree(force)
|
||||||
|
|
||||||
|
then:
|
||||||
|
rebuild == expectedResult
|
||||||
|
|
||||||
|
where:
|
||||||
|
force | now | lastMTime | maxMtime || expectedResult
|
||||||
|
true | now() | ofEpochSecond(0L) | now().minus(1, DAYS) || true
|
||||||
|
true | now() | ofEpochSecond(0L) | now().plus(1, DAYS) || true
|
||||||
|
false | now() | ofEpochSecond(0L) | now().minus(1, DAYS) || true
|
||||||
|
false | now() | ofEpochSecond(0L) | now().plus(1, DAYS) || true
|
||||||
|
false | now() | now() | now().minus(1, DAYS) || false
|
||||||
|
false | now() | now() | now().plus(1, DAYS) || true
|
||||||
|
false | now().plus(1, DAYS) | now() | now().minus(1, DAYS) || true
|
||||||
|
false | now().minus(1, DAYS) | now() | now().minus(1, DAYS) || false
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
package net.schowek.nextclouddlna.util
|
||||||
|
|
||||||
|
import spock.lang.Specification
|
||||||
|
|
||||||
|
class ExternalUrlsTest extends Specification {
|
||||||
|
def serverInfoProvider = Mock(ServerInfoProvider)
|
||||||
|
|
||||||
|
def "should generate main url for the service"() {
|
||||||
|
given:
|
||||||
|
serverInfoProvider.getPort() >> port
|
||||||
|
serverInfoProvider.getHost() >> host
|
||||||
|
def sut = new ExternalUrls(serverInfoProvider)
|
||||||
|
|
||||||
|
when:
|
||||||
|
def mainUrl = sut.selfURI
|
||||||
|
|
||||||
|
then:
|
||||||
|
mainUrl.toString() == expectedUrl
|
||||||
|
|
||||||
|
where:
|
||||||
|
host | port || expectedUrl
|
||||||
|
"foo.bar" | 9999 || "http://foo.bar:9999"
|
||||||
|
"foo.bar" | 80 || "http://foo.bar"
|
||||||
|
}
|
||||||
|
|
||||||
|
def "should generate content urls"() {
|
||||||
|
given:
|
||||||
|
serverInfoProvider.getPort() >> port
|
||||||
|
serverInfoProvider.getHost() >> host
|
||||||
|
def sut = new ExternalUrls(serverInfoProvider)
|
||||||
|
|
||||||
|
when:
|
||||||
|
def contentUrl = sut.contentUrl(contentId)
|
||||||
|
|
||||||
|
then:
|
||||||
|
contentUrl.toString() == expectedUrl
|
||||||
|
|
||||||
|
where:
|
||||||
|
host | port | contentId || expectedUrl
|
||||||
|
"foo.bar" | 9999 | 123 || "http://foo.bar:9999/c/123"
|
||||||
|
"foo.bar" | 80 | 123 || "http://foo.bar/c/123"
|
||||||
|
}
|
||||||
|
}
|