mirror of
https://github.com/HenkKalkwater/harbour-sailfin.git
synced 2024-11-22 09:15:18 +00:00
Initial commit
Features so far: - Login is working, both on back-end and GUI-wise - Saving and reusing login tokens is working - The home page is mostly functional - Show details can be received and displayed in a basic manner Following features are taken into account, but have not been fully implemented: - Support for multiple accounts/servers - Securely saving login tokens
This commit is contained in:
commit
53b3eac213
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
# Spec files are generated from the yaml files, so they are unneeded
|
||||||
|
rpm/*.spec
|
||||||
|
|
||||||
|
# Build folders
|
||||||
|
build/
|
||||||
|
build-*/
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
harbour-sailfin.pro.user
|
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
[submodule "libs/qtrest"]
|
||||||
|
path = libs/qtrest
|
||||||
|
url = ../qtrest
|
12
harbour-sailfin.desktop
Normal file
12
harbour-sailfin.desktop
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
X-Nemo-Application-Type=silica-qt5
|
||||||
|
Icon=harbour-sailfin
|
||||||
|
Exec=harbour-sailfin
|
||||||
|
Name=harbour-sailfin
|
||||||
|
# translation example:
|
||||||
|
# your app name in German locale (de)
|
||||||
|
#
|
||||||
|
# Remember to comment out the following line, if you do not want to use
|
||||||
|
# a different app name in German locale (de).
|
||||||
|
Name[de]=harbour-sailfin
|
57
harbour-sailfin.pro
Normal file
57
harbour-sailfin.pro
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
# NOTICE:
|
||||||
|
#
|
||||||
|
# Application name defined in TARGET has a corresponding QML filename.
|
||||||
|
# If name defined in TARGET is changed, the following needs to be done
|
||||||
|
# to match new name:
|
||||||
|
# - corresponding QML filename must be changed
|
||||||
|
# - desktop icon filename must be changed
|
||||||
|
# - desktop filename must be changed
|
||||||
|
# - icon definition filename in desktop file must be changed
|
||||||
|
# - translation filenames have to be changed
|
||||||
|
|
||||||
|
# The name of your application
|
||||||
|
TARGET = harbour-sailfin
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG += sailfishapp c++11
|
||||||
|
|
||||||
|
SOURCES += \
|
||||||
|
src/credentialmanager.cpp \
|
||||||
|
src/harbour-sailfin.cpp \
|
||||||
|
src/jellyfinapiclient.cpp \
|
||||||
|
src/jellyfinapimodel.cpp \
|
||||||
|
src/serverdiscoverymodel.cpp
|
||||||
|
|
||||||
|
DISTFILES += \
|
||||||
|
qml/components/GlassyBackground.qml \
|
||||||
|
qml/components/LibraryItemDelegate.qml \
|
||||||
|
qml/components/MoreSection.qml \
|
||||||
|
qml/components/PlainLabel.qml \
|
||||||
|
qml/components/RemoteImage.qml \
|
||||||
|
qml/components/UserGridDelegate.qml \
|
||||||
|
qml/cover/CoverPage.qml \
|
||||||
|
qml/pages/AddServerConnectingPage.qml \
|
||||||
|
qml/pages/DetailBasePage.qml \
|
||||||
|
qml/pages/LegalPage.qml \
|
||||||
|
qml/pages/LoginDialog.qml \
|
||||||
|
qml/pages/MainPage.qml \
|
||||||
|
qml/pages/SecondPages.qml \
|
||||||
|
qml/harbour-sailfin.qml
|
||||||
|
|
||||||
|
SAILFISHAPP_ICONS = 86x86 108x108 128x128 172x172
|
||||||
|
|
||||||
|
# to disable building translations every time, comment out the
|
||||||
|
# following CONFIG line
|
||||||
|
CONFIG += sailfishapp_i18n
|
||||||
|
|
||||||
|
# German translation is enabled as an example. If you aren't
|
||||||
|
# planning to localize your app, remember to comment out the
|
||||||
|
# following TRANSLATIONS line. And also do not forget to
|
||||||
|
# modify the localized app name in the the .desktop file.
|
||||||
|
# TRANSLATIONS += \
|
||||||
|
|
||||||
|
HEADERS += \
|
||||||
|
src/credentialmanager.h \
|
||||||
|
src/jellyfinapiclient.h \
|
||||||
|
src/jellyfinapimodel.h \
|
||||||
|
src/serverdiscoverymodel.h
|
BIN
icons/108x108/harbour-sailfin.png
Normal file
BIN
icons/108x108/harbour-sailfin.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.3 KiB |
BIN
icons/128x128/harbour-sailfin.png
Normal file
BIN
icons/128x128/harbour-sailfin.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.8 KiB |
BIN
icons/172x172/harbour-sailfin.png
Normal file
BIN
icons/172x172/harbour-sailfin.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
BIN
icons/86x86/harbour-sailfin.png
Normal file
BIN
icons/86x86/harbour-sailfin.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.7 KiB |
1
libs/qtrest
Submodule
1
libs/qtrest
Submodule
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit b4994c96c4128efd26500ef8952ac11315e9f4cf
|
22
qml/3rdparty.xml
Normal file
22
qml/3rdparty.xml
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<includes>
|
||||||
|
<include>
|
||||||
|
<name>Storeman</name>
|
||||||
|
<url>https://github.com/mentaljam/harbour-storeman/tree/f64314e7f72550faf35f95f046b52cee42501cf8</url>
|
||||||
|
<type>SNIPPET</type>
|
||||||
|
<license>
|
||||||
|
<type>MIT</type>
|
||||||
|
<copyright>Copyright (c) 2017 Petr Tsymbarovich</copyright>
|
||||||
|
<text>licenses/MIT.txt</text>
|
||||||
|
</license>
|
||||||
|
</include>
|
||||||
|
<include>
|
||||||
|
<name>Hutspot</name>
|
||||||
|
<url>https://github.com/sailfish-spotify/hutspot/tree/22787baa6603b5235a3c9e6a65778e0485dfcd7b</url>
|
||||||
|
<type>SNIPPET</type>
|
||||||
|
<license>
|
||||||
|
<type>MIT</type>
|
||||||
|
<copyright>Copyright (c) 2019 sailfish-spotify contributors</copyright>
|
||||||
|
<text>licenses/MIT.txt</text>
|
||||||
|
</license>
|
||||||
|
</include>
|
||||||
|
</includes>
|
43
qml/components/GlassyBackground.qml
Normal file
43
qml/components/GlassyBackground.qml
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import QtQuick 2.6
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
import QtGraphicalEffects 1.0
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
property alias source: backgroundImage.source
|
||||||
|
property alias sourceSize: backgroundImage.sourceSize
|
||||||
|
property real dimmedOpacity: Theme.opacityFaint
|
||||||
|
readonly property alias status: backgroundImage.status
|
||||||
|
color: Theme.colorScheme == Theme.DarkOnLight ? "#fff" : "#000"
|
||||||
|
z: -1
|
||||||
|
opacity: status == Image.Ready ? 1.0 : 0.0
|
||||||
|
Behavior on opacity { NumberAnimation { duration: 300 } }
|
||||||
|
|
||||||
|
Image {
|
||||||
|
id: backgroundImage
|
||||||
|
cache: true
|
||||||
|
smooth: false
|
||||||
|
asynchronous: true
|
||||||
|
fillMode: Image.PreserveAspectCrop
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: false
|
||||||
|
}
|
||||||
|
|
||||||
|
FastBlur {
|
||||||
|
anchors.fill: backgroundImage
|
||||||
|
source: backgroundImage
|
||||||
|
opacity: dimmedOpacity
|
||||||
|
radius: 100
|
||||||
|
}
|
||||||
|
|
||||||
|
Image {
|
||||||
|
anchors.fill: parent
|
||||||
|
fillMode: Image.Tile
|
||||||
|
source: "image://theme/graphic-shader-texture"
|
||||||
|
opacity: 0.1
|
||||||
|
visible: parent.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
//source = ""
|
||||||
|
}
|
||||||
|
}
|
55
qml/components/LibraryItemDelegate.qml
Normal file
55
qml/components/LibraryItemDelegate.qml
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import QtQuick 2.6
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
|
||||||
|
BackgroundItem {
|
||||||
|
id: root
|
||||||
|
property alias poster: posterImage.source
|
||||||
|
property alias title: titleText.text
|
||||||
|
property bool landscape: false
|
||||||
|
width: Screen.width / 3
|
||||||
|
height: landscape ? width / 4 * 3 : width / 2 * 3
|
||||||
|
|
||||||
|
RemoteImage {
|
||||||
|
id: posterImage
|
||||||
|
anchors {
|
||||||
|
left: parent.left
|
||||||
|
top: parent.top
|
||||||
|
right: parent.right
|
||||||
|
bottom: parent.bottom
|
||||||
|
}
|
||||||
|
fillMode: Image.PreserveAspectCrop
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: posterImage
|
||||||
|
color: Theme.rgba(Theme.highlightBackgroundColor, Theme.highlightBackgroundOpacity)
|
||||||
|
visible: root.highlighted
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors {
|
||||||
|
left: parent.left
|
||||||
|
right: parent.right
|
||||||
|
bottom: parent.bottom
|
||||||
|
}
|
||||||
|
height: titleText.height * 1.5 + Theme.paddingSmall * 2
|
||||||
|
gradient: Gradient {
|
||||||
|
GradientStop { position: 0.0; color: "transparent"; }
|
||||||
|
GradientStop { position: 1.0; color: Theme.highlightDimmerColor }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: titleText
|
||||||
|
anchors {
|
||||||
|
left: parent.left
|
||||||
|
bottom: parent.bottom
|
||||||
|
right: parent.right
|
||||||
|
leftMargin: Theme.paddingMedium
|
||||||
|
rightMargin: Theme.paddingMedium
|
||||||
|
bottomMargin: Theme.paddingSmall
|
||||||
|
}
|
||||||
|
truncationMode: TruncationMode.Fade
|
||||||
|
horizontalAlignment: Text.AlignLeft
|
||||||
|
}
|
||||||
|
}
|
76
qml/components/MoreSection.qml
Normal file
76
qml/components/MoreSection.qml
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
/*
|
||||||
|
* File taken from Storeman. See ../3rdparty.xml for licensing information
|
||||||
|
*/
|
||||||
|
|
||||||
|
import QtQuick 2.0
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
property alias text: label.text
|
||||||
|
property alias textAlignment: label.horizontalAlignment
|
||||||
|
property bool busy: false
|
||||||
|
property int depth: 0
|
||||||
|
readonly property color _color: enabled ? highlighted ? Theme.highlightColor : Theme.primaryColor : Theme.secondaryColor
|
||||||
|
default property alias content: container.data
|
||||||
|
|
||||||
|
implicitHeight: backgroundItem.height + container.height
|
||||||
|
width: parent.width
|
||||||
|
|
||||||
|
BackgroundItem {
|
||||||
|
id: backgroundItem
|
||||||
|
width: parent.width
|
||||||
|
height: Theme.itemSizeMedium
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
gradient: Gradient {
|
||||||
|
GradientStop { position: 0.0; color: Theme.rgba(Theme.highlightBackgroundColor, 0.15) }
|
||||||
|
GradientStop { position: 1.0; color: "transparent" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: label
|
||||||
|
anchors {
|
||||||
|
left: parent.left
|
||||||
|
right: image.left
|
||||||
|
verticalCenter: parent.verticalCenter
|
||||||
|
leftMargin: Theme.horizontalPageMargin + depth * Theme.paddingLarge
|
||||||
|
rightMargin: Theme.paddingMedium
|
||||||
|
}
|
||||||
|
horizontalAlignment: Text.AlignRight
|
||||||
|
truncationMode: TruncationMode.Fade
|
||||||
|
color: _color
|
||||||
|
}
|
||||||
|
|
||||||
|
Image {
|
||||||
|
id: image
|
||||||
|
anchors {
|
||||||
|
right: parent.right
|
||||||
|
verticalCenter: parent.verticalCenter
|
||||||
|
rightMargin: Theme.horizontalPageMargin
|
||||||
|
}
|
||||||
|
visible: root.enabled && !root.busy
|
||||||
|
source: "image://theme/icon-m-right?" + _color
|
||||||
|
}
|
||||||
|
|
||||||
|
BusyIndicator {
|
||||||
|
id: busyIndicator
|
||||||
|
running: root.busy
|
||||||
|
anchors.centerIn: image
|
||||||
|
size: BusyIndicatorSize.Small
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: container
|
||||||
|
anchors {
|
||||||
|
top: backgroundItem.bottom
|
||||||
|
left: parent.left
|
||||||
|
right: parent.right
|
||||||
|
}
|
||||||
|
width: parent.width
|
||||||
|
height: children[0].height
|
||||||
|
}
|
||||||
|
}
|
18
qml/components/PlainLabel.qml
Normal file
18
qml/components/PlainLabel.qml
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import QtQuick 2.6
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A label with the most commonly used settings set
|
||||||
|
*/
|
||||||
|
Label {
|
||||||
|
anchors {
|
||||||
|
left: parent.left
|
||||||
|
right: parent.right
|
||||||
|
leftMargin: Theme.horizontalPageMargin
|
||||||
|
rightMargin: Theme.horizontalPageMargin
|
||||||
|
}
|
||||||
|
color: Theme.highlightColor
|
||||||
|
linkColor: Theme.primaryColor
|
||||||
|
onLinkActivated: Qt.openUrlExternally(link)
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
29
qml/components/RemoteImage.qml
Normal file
29
qml/components/RemoteImage.qml
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import QtQuick 2.6
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
|
||||||
|
Image {
|
||||||
|
property string fallbackImage
|
||||||
|
property bool usingFallbackImage
|
||||||
|
|
||||||
|
BusyIndicator {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
running: parent.status == Image.Loading
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: fallbackBackground
|
||||||
|
anchors.fill: parent
|
||||||
|
gradient: Gradient {
|
||||||
|
GradientStop { position: 0.0; color: Theme.highlightColor; }
|
||||||
|
GradientStop { position: 1.0; color: Theme.highlightDimmerColor; }
|
||||||
|
}
|
||||||
|
visible: parent.status == Image.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
Image {
|
||||||
|
id: fallbackImageItem
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: parent.status == Image.Error
|
||||||
|
source: fallbackImage ? fallbackImage : "image://theme/icon-m-question"
|
||||||
|
}
|
||||||
|
}
|
34
qml/components/UserGridDelegate.qml
Normal file
34
qml/components/UserGridDelegate.qml
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import QtQuick 2.6
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
|
||||||
|
GridItem {
|
||||||
|
id: root
|
||||||
|
property string image
|
||||||
|
property alias name: nameLabel.text
|
||||||
|
RemoteImage {
|
||||||
|
id: userImage
|
||||||
|
anchors.fill: parent
|
||||||
|
source: root.image ? root.image : "image://theme/icon-m-contact?" + ((root.highlighted || root.down) ? Theme.highlightColor : Theme.primaryColor)
|
||||||
|
fillMode: Image.PreserveAspectCrop
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
gradient: Gradient {
|
||||||
|
GradientStop { position: 0.0; color: "transparent" }
|
||||||
|
GradientStop { position: 1.0; color: Theme.overlayBackgroundColor }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
id: nameLabel
|
||||||
|
anchors {
|
||||||
|
leftMargin: Theme.horizontalPageMargin
|
||||||
|
rightMargin: Theme.horizontalPageMargin
|
||||||
|
right: parent.right
|
||||||
|
left: parent.left
|
||||||
|
bottom: parent.bottom
|
||||||
|
bottomMargin: Theme.paddingSmall
|
||||||
|
}
|
||||||
|
text: qsTr("Other account")
|
||||||
|
color: (root.highlighted || root.down) ? Theme.highlightColor : Theme.secondaryColor
|
||||||
|
}
|
||||||
|
}
|
22
qml/cover/CoverPage.qml
Normal file
22
qml/cover/CoverPage.qml
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import QtQuick 2.0
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
|
||||||
|
CoverBackground {
|
||||||
|
Label {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: qsTr("My Cover")
|
||||||
|
}
|
||||||
|
|
||||||
|
CoverActionList {
|
||||||
|
id: coverAction
|
||||||
|
|
||||||
|
CoverAction {
|
||||||
|
iconSource: "image://theme/icon-cover-next"
|
||||||
|
}
|
||||||
|
|
||||||
|
CoverAction {
|
||||||
|
iconSource: "image://theme/icon-cover-pause"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
65
qml/harbour-sailfin.qml
Normal file
65
qml/harbour-sailfin.qml
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import QtQuick 2.0
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
import nl.netsoj.chris.Jellyfin 1.0
|
||||||
|
import Nemo.Notifications 1.0
|
||||||
|
|
||||||
|
import "components"
|
||||||
|
import "pages"
|
||||||
|
|
||||||
|
ApplicationWindow {
|
||||||
|
id: appWindow
|
||||||
|
property bool isInSetup: false
|
||||||
|
property bool _hasInitialized: false
|
||||||
|
//property alias backdrop: backdrop
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: ApiClient
|
||||||
|
onNetworkError: errorNotification.show("Network error: " + error)
|
||||||
|
onConnectionFailed: errorNotification.show("Connect error: " + error)
|
||||||
|
//onConnectionSuccess: errorNotification.show("Success: " + loginMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*GlassyBackground {
|
||||||
|
id: backdrop
|
||||||
|
anchors.fill: parent
|
||||||
|
opacity: status == Image.Ready ? 1.0 : 0.0
|
||||||
|
Behavior on opacity { NumberAnimation { duration: 300 } }
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
source = ""
|
||||||
|
}
|
||||||
|
}*/
|
||||||
|
|
||||||
|
initialPage: Component {
|
||||||
|
MainPage {
|
||||||
|
Connections {
|
||||||
|
target: ApiClient
|
||||||
|
onSetupRequired: {
|
||||||
|
if (!isInSetup) {
|
||||||
|
isInSetup = true;
|
||||||
|
pageStack.replace(Qt.resolvedUrl("pages/AddServerPage.qml"), {"backNavigation": false});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onStatusChanged: {
|
||||||
|
if (status == PageStatus.Active && !_hasInitialized) {
|
||||||
|
_hasInitialized = true;
|
||||||
|
ApiClient.initialize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cover: Qt.resolvedUrl("cover/CoverPage.qml")
|
||||||
|
allowedOrientations: Orientation.All
|
||||||
|
|
||||||
|
Notification {
|
||||||
|
id: errorNotification
|
||||||
|
previewSummary: "foo"
|
||||||
|
isTransient: true
|
||||||
|
|
||||||
|
function show(data) {
|
||||||
|
previewSummary = data;
|
||||||
|
publish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
17
qml/licenses/MIT.txt
Normal file
17
qml/licenses/MIT.txt
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
40
qml/pages/AddServerConnectingPage.qml
Normal file
40
qml/pages/AddServerConnectingPage.qml
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import QtQuick 2.6
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
import nl.netsoj.chris.Jellyfin 1.0
|
||||||
|
|
||||||
|
Page {
|
||||||
|
property string serverName
|
||||||
|
property string serverAddress
|
||||||
|
property Page firstPage
|
||||||
|
|
||||||
|
allowedOrientations: Orientation.All
|
||||||
|
|
||||||
|
|
||||||
|
BusyLabel {
|
||||||
|
text: qsTr("Connecting to %1").arg(serverName)
|
||||||
|
running: true
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatusChanged: {
|
||||||
|
if (status == PageStatus.Active) {
|
||||||
|
console.log("Connecting page active");
|
||||||
|
ApiClient.setupConnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: ApiClient
|
||||||
|
onConnectionSuccess: {
|
||||||
|
console.log("Login success: " + loginMessage);
|
||||||
|
pageStack.replace(Qt.resolvedUrl("LoginDialog.qml"), {"loginMessage": loginMessage, "firstPage": firstPage});
|
||||||
|
}
|
||||||
|
onConnectionFailed: function(error) {
|
||||||
|
console.log("Connection failed : " + error)
|
||||||
|
pageStack.pop();
|
||||||
|
}
|
||||||
|
onNetworkError: {
|
||||||
|
console.log("ConnectingPage: popping page!")
|
||||||
|
pageStack.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
89
qml/pages/AddServerPage.qml
Normal file
89
qml/pages/AddServerPage.qml
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
import QtQuick 2.6
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
import nl.netsoj.chris.Jellyfin 1.0
|
||||||
|
|
||||||
|
Dialog {
|
||||||
|
id: dialogRoot
|
||||||
|
allowedOrientations: Orientation.All
|
||||||
|
// Picks the address of the ComboBox if selected, otherwise the manual address entry
|
||||||
|
readonly property string address: serverSelect.currentItem._address
|
||||||
|
readonly property bool addressCorrect: serverSelect.currentIndex > 0 || manualAddress.acceptableInput
|
||||||
|
readonly property string serverName: serverSelect.currentItem._name
|
||||||
|
|
||||||
|
|
||||||
|
acceptDestination: AddServerConnectingPage {
|
||||||
|
id: connectingPage
|
||||||
|
serverName: dialogRoot.serverName
|
||||||
|
serverAddress: address
|
||||||
|
firstPage: dialogRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
DialogHeader {
|
||||||
|
acceptText: qsTr("Connect")
|
||||||
|
title: qsTr("Connect to Jellyfin")
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerDiscoveryModel {
|
||||||
|
id: serverModel
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ComboBox {
|
||||||
|
id: serverSelect
|
||||||
|
label: qsTr("Server")
|
||||||
|
description: qsTr("Sailfin will try to search for Jellyfin servers on your local network automatically")
|
||||||
|
|
||||||
|
menu: ContextMenu {
|
||||||
|
MenuItem {
|
||||||
|
// Special values are cool, aren't they?
|
||||||
|
readonly property string _address: manualAddress.text
|
||||||
|
readonly property string _name: manualAddress.text
|
||||||
|
text: qsTr("enter address manually")
|
||||||
|
}
|
||||||
|
Repeater {
|
||||||
|
model: serverModel
|
||||||
|
delegate: MenuItem {
|
||||||
|
readonly property string _address: address
|
||||||
|
readonly property string _name: name
|
||||||
|
text: qsTr("%1 - %2").arg(name).arg(address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: manualAddress
|
||||||
|
width: parent.width
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
label: qsTr("Server address")
|
||||||
|
placeholderText: qsTr("e.g. https://demo.jellyfin.org")
|
||||||
|
|
||||||
|
enabled: serverSelect.currentIndex == 0
|
||||||
|
visible: enabled
|
||||||
|
|
||||||
|
inputMethodHints: Qt.ImhUrlCharactersOnly
|
||||||
|
validator: RegExpValidator {
|
||||||
|
regExp: /^https?:\/\/[a-zA-Z0-9-._~:/?#\[\]\@\!\$\&\'\(\)\*\+\,\;\=]+$/m
|
||||||
|
}
|
||||||
|
|
||||||
|
EnterKey.enabled: addressCorrect
|
||||||
|
EnterKey.iconSource: "image://theme/icon-m-enter-accept"
|
||||||
|
EnterKey.onClicked: dialogRoot.tryConnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpened: serverModel.refresh()
|
||||||
|
canAccept: addressCorrect
|
||||||
|
|
||||||
|
function tryConnect() {
|
||||||
|
console.log("Hi there!")
|
||||||
|
ApiClient.baseUrl = address;
|
||||||
|
//ApiClient.setupConnection()
|
||||||
|
//fakeTimer.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
onDone: tryConnect()
|
||||||
|
}
|
138
qml/pages/DetailBasePage.qml
Normal file
138
qml/pages/DetailBasePage.qml
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
import QtQuick 2.6
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
|
||||||
|
import nl.netsoj.chris.Jellyfin 1.0
|
||||||
|
|
||||||
|
import "../components"
|
||||||
|
|
||||||
|
Page {
|
||||||
|
id: pageRoot
|
||||||
|
property string itemId: ""
|
||||||
|
property var itemData: ({})
|
||||||
|
property bool _loading: true
|
||||||
|
readonly property bool _hasLogo: itemData.ImageTags.Logo !== undefined
|
||||||
|
readonly property string _logo: itemData.ImageTags.Logo
|
||||||
|
readonly property var _backdropImages: itemData.BackdropImageTags
|
||||||
|
readonly property var _parentBackdropImages: itemData.ParentBackdropImageTags
|
||||||
|
readonly property string parentId: itemData.ParentId
|
||||||
|
|
||||||
|
on_BackdropImagesChanged: updateBackdrop()
|
||||||
|
on_ParentBackdropImagesChanged: updateBackdrop()
|
||||||
|
|
||||||
|
function updateBackdrop() {
|
||||||
|
if (_backdropImages && _backdropImages.length > 0) {
|
||||||
|
var rand = Math.floor(Math.random() * (_backdropImages.length - 0.001))
|
||||||
|
console.log("Random: ", rand)
|
||||||
|
backdrop.source = ApiClient.baseUrl + "/Items/" + itemId + "/Images/Backdrop/" + rand + "?tag=" + _backdropImages[rand]
|
||||||
|
} else if (_parentBackdropImages && _parentBackdropImages.length > 0) {
|
||||||
|
console.log(parentId)
|
||||||
|
backdrop.source = ApiClient.baseUrl + "/Items/" + itemData.ParentBackdropItemId + "/Images/Backdrop/0?tag=" + _parentBackdropImages[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
allowedOrientations: Orientation.All
|
||||||
|
GlassyBackground {
|
||||||
|
id: backdrop
|
||||||
|
anchors.fill: parent
|
||||||
|
}
|
||||||
|
|
||||||
|
SilicaFlickable {
|
||||||
|
anchors.fill: parent
|
||||||
|
contentHeight: content.height
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: content
|
||||||
|
width: parent.width
|
||||||
|
|
||||||
|
PageHeader {
|
||||||
|
title: itemData.Name
|
||||||
|
visible: !_hasLogo
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
Item {
|
||||||
|
width: 1
|
||||||
|
height: Theme.paddingLarge
|
||||||
|
}
|
||||||
|
RemoteImage {
|
||||||
|
anchors {
|
||||||
|
horizontalCenter: parent.horizontalCenter
|
||||||
|
}
|
||||||
|
source: _hasLogo ? ApiClient.baseUrl + "/Items/" + itemId + "/Images/Logo?tag=" + _logo : undefined
|
||||||
|
}
|
||||||
|
Item {
|
||||||
|
width: 1
|
||||||
|
height: Theme.paddingLarge
|
||||||
|
}
|
||||||
|
visible: _hasLogo
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: 1
|
||||||
|
height: Theme.paddingLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
PlainLabel {
|
||||||
|
id: overviewText
|
||||||
|
text: itemData.Overview
|
||||||
|
visible: text.length > 0
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
visible: overviewText.visible
|
||||||
|
width: 1
|
||||||
|
height: Theme.paddingLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors {
|
||||||
|
//left: parent.left
|
||||||
|
right: parent.right
|
||||||
|
leftMargin: Theme.horizontalPageMargin
|
||||||
|
rightMargin: Theme.horizontalPageMargin
|
||||||
|
}
|
||||||
|
spacing: Theme.paddingMedium
|
||||||
|
IconButton {
|
||||||
|
id: favouriteButton
|
||||||
|
icon.source: "image://theme/icon-m-favorite"
|
||||||
|
}
|
||||||
|
IconButton {
|
||||||
|
id: playButton
|
||||||
|
icon.source: "image://theme/icon-l-play"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PageBusyIndicator {
|
||||||
|
running: pageRoot._loading
|
||||||
|
}
|
||||||
|
|
||||||
|
onItemIdChanged: {
|
||||||
|
itemData = {}
|
||||||
|
if (itemId.length > 0) {
|
||||||
|
pageRoot._loading = true
|
||||||
|
ApiClient.fetchItem(itemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatusChanged: {
|
||||||
|
if (status == PageStatus.Deactivating) {
|
||||||
|
backdrop.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: ApiClient
|
||||||
|
onItemFetched: {
|
||||||
|
if (itemId === pageRoot.itemId) {
|
||||||
|
console.log(JSON.stringify(result))
|
||||||
|
pageRoot.itemData = result
|
||||||
|
pageRoot._loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
104
qml/pages/LegalPage.qml
Normal file
104
qml/pages/LegalPage.qml
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
import QtQuick 2.6
|
||||||
|
import QtQuick.XmlListModel 2.0
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
|
||||||
|
import "../components"
|
||||||
|
|
||||||
|
Page {
|
||||||
|
allowedOrientations: Orientation.All
|
||||||
|
|
||||||
|
SilicaFlickable {
|
||||||
|
anchors.fill: parent
|
||||||
|
contentHeight: content.height
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: content
|
||||||
|
width: parent.width
|
||||||
|
|
||||||
|
XmlListModel {
|
||||||
|
id: licencesModel
|
||||||
|
source: Qt.resolvedUrl("../3rdparty.xml")
|
||||||
|
query: "/includes/include"
|
||||||
|
XmlRole { name: "name"; query: "name/string()" }
|
||||||
|
XmlRole { name: "type"; query: "type/string()" }
|
||||||
|
XmlRole { name: "url"; query: "url/string()" }
|
||||||
|
XmlRole { name: "copyright"; query: "license/copyright/string()" }
|
||||||
|
XmlRole { name: "licenseUrl"; query: "license/text/string()" }
|
||||||
|
XmlRole { name: "licenseType"; query: "license/type/string()" }
|
||||||
|
}
|
||||||
|
|
||||||
|
PageHeader {
|
||||||
|
title: qsTr("Legal")
|
||||||
|
}
|
||||||
|
|
||||||
|
PlainLabel {
|
||||||
|
text: qsTr("The Sailfin application contains some code from other projects. Without them, Sailfin would " +
|
||||||
|
"not be possible!")
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: licencesModel
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
SectionHeader {
|
||||||
|
text: name
|
||||||
|
}
|
||||||
|
|
||||||
|
PlainLabel {
|
||||||
|
color: Theme.secondaryHighlightColor
|
||||||
|
text: {
|
||||||
|
switch(type) {
|
||||||
|
case "SNIPPET":
|
||||||
|
return qsTr("This program contains small snippets of code taken from <a href=\"%1\">%2</a>, which " +
|
||||||
|
"is licensed under the %3 license:")
|
||||||
|
.arg(model.url).arg(model.name).arg(model.licenseType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: 1
|
||||||
|
height: Theme.paddingLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
SilicaFlickable {
|
||||||
|
anchors {
|
||||||
|
left: parent.left
|
||||||
|
right: parent.right
|
||||||
|
leftMargin: Theme.horizontalPageMargin
|
||||||
|
rightMargin: Theme.horizontalPageMargin
|
||||||
|
}
|
||||||
|
height: licenseLabel.contentHeight
|
||||||
|
contentWidth: licenseLabel.contentWidth
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: licenseLabel
|
||||||
|
color: Theme.secondaryHighlightColor
|
||||||
|
font.family: "monospace"
|
||||||
|
font.pixelSize: Theme.fontSizeExtraSmall
|
||||||
|
wrapMode: Text.NoWrap
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
var xhr = new XMLHttpRequest;
|
||||||
|
xhr.open("GET", Qt.resolvedUrl("../" + model.licenseUrl)); // set Method and File
|
||||||
|
console.log(Qt.resolvedUrl("../" + model.licenseUrl))
|
||||||
|
xhr.onreadystatechange = function () {
|
||||||
|
if (xhr.readyState === XMLHttpRequest.DONE){ // if request_status == DONE
|
||||||
|
var response = model.copyright + "\n\n" + xhr.responseText;
|
||||||
|
console.log(response);
|
||||||
|
licenseLabel.text = response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xhr.send(); // begin the request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalScrollDecorator {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalScrollDecorator {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
127
qml/pages/LoginDialog.qml
Normal file
127
qml/pages/LoginDialog.qml
Normal file
|
@ -0,0 +1,127 @@
|
||||||
|
import QtQuick 2.6
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
import nl.netsoj.chris.Jellyfin 1.0
|
||||||
|
|
||||||
|
import "../components"
|
||||||
|
|
||||||
|
Dialog {
|
||||||
|
property string loginMessage
|
||||||
|
property Page firstPage
|
||||||
|
|
||||||
|
allowedOrientations: Orientation.All
|
||||||
|
|
||||||
|
|
||||||
|
acceptDestination: Page {
|
||||||
|
BusyLabel {
|
||||||
|
text: qsTr("Logging in as %1").arg(username.text)
|
||||||
|
running: true
|
||||||
|
}
|
||||||
|
onStatusChanged: {
|
||||||
|
if(status == PageStatus.Active) {
|
||||||
|
ApiClient.authenticate(username.text, password.text, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: ApiClient
|
||||||
|
onAuthenticatedChanged: {
|
||||||
|
if (ApiClient.authenticated) {
|
||||||
|
console.log("authenticated!")
|
||||||
|
pageStack.replaceAbove(pageStack.previousPage(firstPage), Qt.resolvedUrl("MainPage.qml"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onAuthenticationError: {
|
||||||
|
pageStack.completeAnimation()
|
||||||
|
pageStack.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PublicUserModel {
|
||||||
|
id: userModel
|
||||||
|
apiClient: ApiClient
|
||||||
|
Component.onCompleted: reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogHeader {
|
||||||
|
id: dialogHeader
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
acceptText: qsTr("Login");
|
||||||
|
}
|
||||||
|
SilicaFlickable {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: dialogHeader.bottom
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
contentHeight: column.height
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
VerticalScrollDecorator {}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: column
|
||||||
|
width: parent.width
|
||||||
|
|
||||||
|
Flow {
|
||||||
|
width: parent.width
|
||||||
|
Repeater {
|
||||||
|
model: userModel
|
||||||
|
delegate: UserGridDelegate {
|
||||||
|
name: model.name
|
||||||
|
image: model.primaryImageTag ? "%1/Users/%2/Images/Primary?tag=%3".arg(ApiClient.baseUrl).arg(model.id).arg(model.primaryImageTag) : null
|
||||||
|
highlighted: model.name == username.text
|
||||||
|
onClicked: {
|
||||||
|
username.text = model.name
|
||||||
|
password.focus = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionHeader {
|
||||||
|
text: qsTr("Credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: username
|
||||||
|
width: parent.width
|
||||||
|
placeholderText: qsTr("Username")
|
||||||
|
label: qsTr("Username")
|
||||||
|
EnterKey.iconSource: "image://theme/icon-m-enter-next"
|
||||||
|
EnterKey.onClicked: password.focus = true
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: password
|
||||||
|
width: parent.width
|
||||||
|
|
||||||
|
placeholderText: qsTr("Password")
|
||||||
|
label: qsTr("password")
|
||||||
|
echoMode: TextInput.Password
|
||||||
|
EnterKey.iconSource: "image://theme/icon-m-enter-accept"
|
||||||
|
EnterKey.onClicked: login()
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionHeader {
|
||||||
|
text: qsTr("Login message")
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
anchors {
|
||||||
|
left: parent.left
|
||||||
|
right: parent.right
|
||||||
|
leftMargin: Theme.horizontalPageMargin
|
||||||
|
rightMargin: Theme.horizontalPageMargin
|
||||||
|
}
|
||||||
|
text: loginMessage
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
color: Theme.highlightColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canAccept: username.text.length > 0
|
||||||
|
|
||||||
|
/*onAccepted: {
|
||||||
|
pageStack.replace(Qt.resolvedUrl("MainPage.qml"))
|
||||||
|
}*/
|
||||||
|
}
|
125
qml/pages/MainPage.qml
Normal file
125
qml/pages/MainPage.qml
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
import QtQuick 2.0
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
|
||||||
|
import nl.netsoj.chris.Jellyfin 1.0
|
||||||
|
|
||||||
|
import "../components"
|
||||||
|
|
||||||
|
Page {
|
||||||
|
id: page
|
||||||
|
|
||||||
|
// The effective value will be restricted by ApplicationWindow.allowedOrientations
|
||||||
|
allowedOrientations: Orientation.All
|
||||||
|
|
||||||
|
property bool _modelsLoaded: false
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: ApiClient
|
||||||
|
onAuthenticatedChanged: {
|
||||||
|
if (authenticated && !_modelsLoaded) loadModels();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (ApiClient.authenticated && _modelsLoaded) {
|
||||||
|
loadModels();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// To enable PullDownMenu, place our content in a SilicaFlickable
|
||||||
|
SilicaFlickable {
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
// PullDownMenu and PushUpMenu must be declared in SilicaFlickable, SilicaListView or SilicaGridView
|
||||||
|
PullDownMenu {
|
||||||
|
MenuItem {
|
||||||
|
text: qsTr("About")
|
||||||
|
onClicked: pageStack.push(Qt.resolvedUrl("LegalPage.qml"))
|
||||||
|
}
|
||||||
|
MenuItem {
|
||||||
|
text: qsTr("Settings")
|
||||||
|
onClicked: pageStack.push(Qt.resolvedUrl("SecondPage.qml"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell SilicaFlickable the height of its content.
|
||||||
|
contentHeight: column.height
|
||||||
|
|
||||||
|
|
||||||
|
// Place our content in a Column. The PageHeader is always placed at the top
|
||||||
|
// of the page, followed by our content.
|
||||||
|
Column {
|
||||||
|
id: column
|
||||||
|
|
||||||
|
width: page.width
|
||||||
|
//spacing: Theme.paddingLarge
|
||||||
|
UserViewModel {
|
||||||
|
id: mediaLibraryModel2
|
||||||
|
apiClient: ApiClient
|
||||||
|
}
|
||||||
|
|
||||||
|
MoreSection {
|
||||||
|
text: "Kijken hervatten"
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
MoreSection {
|
||||||
|
text: "Volgende"
|
||||||
|
}
|
||||||
|
|
||||||
|
UserViewModel {
|
||||||
|
id: mediaLibraryModel
|
||||||
|
apiClient: ApiClient
|
||||||
|
}
|
||||||
|
Repeater {
|
||||||
|
model: mediaLibraryModel
|
||||||
|
MoreSection {
|
||||||
|
text: model.name
|
||||||
|
busy: userItemModel.status != ApiModel.Ready
|
||||||
|
|
||||||
|
SilicaListView {
|
||||||
|
clip: true
|
||||||
|
height: count > 0 ? Screen.width / 4 : 0
|
||||||
|
Behavior on height {
|
||||||
|
NumberAnimation { duration: 300 }
|
||||||
|
}
|
||||||
|
width: parent.width
|
||||||
|
model: userItemModel
|
||||||
|
orientation: ListView.Horizontal
|
||||||
|
delegate: LibraryItemDelegate {
|
||||||
|
property string id: model.id
|
||||||
|
title: model.name
|
||||||
|
poster: ApiClient.baseUrl + "/Items/" + model.id + "/Images/Primary?maxHeight=" + height + "&tag=" + model.imageTags["Primary"]
|
||||||
|
landscape: true
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
pageStack.push(Qt.resolvedUrl("DetailBasePage.qml"), {"itemId": model.id})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalScrollDecorator {}
|
||||||
|
UserItemModel {
|
||||||
|
id: userItemModel
|
||||||
|
apiClient: ApiClient
|
||||||
|
parentId: model.id
|
||||||
|
limit: 12
|
||||||
|
}
|
||||||
|
Connections {
|
||||||
|
target: mediaLibraryModel
|
||||||
|
onStatusChanged: {
|
||||||
|
if (status == ApiModel.Ready) {
|
||||||
|
userItemModel.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadModels() {
|
||||||
|
_modelsLoaded = true;
|
||||||
|
mediaLibraryModel.reload()
|
||||||
|
}
|
||||||
|
}
|
30
qml/pages/SecondPage.qml
Normal file
30
qml/pages/SecondPage.qml
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import QtQuick 2.0
|
||||||
|
import Sailfish.Silica 1.0
|
||||||
|
|
||||||
|
Page {
|
||||||
|
id: page
|
||||||
|
|
||||||
|
// The effective value will be restricted by ApplicationWindow.allowedOrientations
|
||||||
|
allowedOrientations: Orientation.All
|
||||||
|
|
||||||
|
SilicaListView {
|
||||||
|
id: listView
|
||||||
|
model: 20
|
||||||
|
anchors.fill: parent
|
||||||
|
header: PageHeader {
|
||||||
|
title: qsTr("Nested Page")
|
||||||
|
}
|
||||||
|
delegate: BackgroundItem {
|
||||||
|
id: delegate
|
||||||
|
|
||||||
|
Label {
|
||||||
|
x: Theme.horizontalPageMargin
|
||||||
|
text: qsTr("Item") + " " + index
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
color: delegate.highlighted ? Theme.highlightColor : Theme.primaryColor
|
||||||
|
}
|
||||||
|
onClicked: console.log("Clicked " + index)
|
||||||
|
}
|
||||||
|
VerticalScrollDecorator {}
|
||||||
|
}
|
||||||
|
}
|
18
rpm/harbour-sailfin.changes.in
Normal file
18
rpm/harbour-sailfin.changes.in
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# Rename this file as harbour-sailfin.changes to include changelog
|
||||||
|
# entries in your RPM file.
|
||||||
|
#
|
||||||
|
# Add new changelog entries following the format below.
|
||||||
|
# Add newest entries to the top of the list.
|
||||||
|
# Separate entries from eachother with a blank line.
|
||||||
|
#
|
||||||
|
# Alternatively, if your changelog is automatically generated (e.g. with
|
||||||
|
# the git-change-log command provided with Sailfish OS SDK), create a
|
||||||
|
# harbour-sailfin.changes.run script to let mb2 run the required commands for you.
|
||||||
|
|
||||||
|
# * date Author's Name <author's email> version-release
|
||||||
|
# - Summary of changes
|
||||||
|
|
||||||
|
* Sun Apr 13 2014 Jack Tar <jack.tar@example.com> 0.0.1-1
|
||||||
|
- Scrubbed the deck
|
||||||
|
- Hoisted the sails
|
||||||
|
|
25
rpm/harbour-sailfin.changes.run.in
Normal file
25
rpm/harbour-sailfin.changes.run.in
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Rename this file as harbour-sailfin.changes.run to let mb2 automatically
|
||||||
|
# generate changelog from well formatted Git commit messages and tag
|
||||||
|
# annotations.
|
||||||
|
|
||||||
|
git-change-log
|
||||||
|
|
||||||
|
# Here are some basic examples how to change from the default behavior. Run
|
||||||
|
# git-change-log --help inside the Sailfish OS SDK chroot or build engine to
|
||||||
|
# learn all the options git-change-log accepts.
|
||||||
|
|
||||||
|
# Use a subset of tags
|
||||||
|
#git-change-log --tags refs/tags/my-prefix/*
|
||||||
|
|
||||||
|
# Group entries by minor revision, suppress headlines for patch-level revisions
|
||||||
|
#git-change-log --dense '/[0-9]+.[0-9+$'
|
||||||
|
|
||||||
|
# Trim very old changes
|
||||||
|
#git-change-log --since 2014-04-01
|
||||||
|
#echo '[ Some changelog entries trimmed for brevity ]'
|
||||||
|
|
||||||
|
# Use the subjects (first lines) of tag annotations when no entry would be
|
||||||
|
# included for a revision otherwise
|
||||||
|
#git-change-log --auto-add-annotations
|
42
rpm/harbour-sailfin.yaml
Normal file
42
rpm/harbour-sailfin.yaml
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
Name: harbour-sailfin
|
||||||
|
Summary: Sailfin
|
||||||
|
Version: 0.1
|
||||||
|
Release: 1
|
||||||
|
# The contents of the Group field should be one of the groups listed here:
|
||||||
|
# https://github.com/mer-tools/spectacle/blob/master/data/GROUPS
|
||||||
|
Group: Qt/Qt
|
||||||
|
URL: https://chris.netsoj.nl/projects/harbour-sailfin
|
||||||
|
License: LICENSE
|
||||||
|
# This must be generated before uploading a package to a remote build service.
|
||||||
|
# Usually this line does not need to be modified.
|
||||||
|
Sources:
|
||||||
|
- '%{name}-%{version}.tar.bz2'
|
||||||
|
Description: |
|
||||||
|
Play video's and music from your Jellyfin media player on your Sailfish device
|
||||||
|
Builder: qtc5
|
||||||
|
|
||||||
|
# This section specifies build dependencies that are resolved using pkgconfig.
|
||||||
|
# This is the preferred way of specifying build dependencies for your package.
|
||||||
|
PkgConfigBR:
|
||||||
|
- sailfishapp >= 1.0.2
|
||||||
|
- Qt5Core
|
||||||
|
- Qt5Qml
|
||||||
|
- Qt5Quick
|
||||||
|
|
||||||
|
# Build dependencies without a pkgconfig setup can be listed here
|
||||||
|
# PkgBR:
|
||||||
|
# - package-needed-to-build
|
||||||
|
|
||||||
|
# Runtime dependencies which are not automatically detected
|
||||||
|
Requires:
|
||||||
|
- sailfishsilica-qt5 >= 0.10.9
|
||||||
|
|
||||||
|
# All installed files
|
||||||
|
Files:
|
||||||
|
- '%{_bindir}'
|
||||||
|
- '%{_datadir}/%{name}'
|
||||||
|
- '%{_datadir}/applications/%{name}.desktop'
|
||||||
|
- '%{_datadir}/icons/hicolor/*/apps/%{name}.png'
|
||||||
|
|
||||||
|
# For more information about yaml and what's supported in Sailfish OS
|
||||||
|
# build system, please see https://wiki.merproject.org/wiki/Spectacle
|
58
src/credentialmanager.cpp
Normal file
58
src/credentialmanager.cpp
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
#include "credentialmanager.h"
|
||||||
|
|
||||||
|
CredentialsManager * CredentialsManager::getInstance(QObject *parent) {
|
||||||
|
return new FallbackCredentialsManager(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// FallbackCredentialsManager //
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
FallbackCredentialsManager::FallbackCredentialsManager(QObject *parent)
|
||||||
|
: CredentialsManager (parent) {
|
||||||
|
m_settings.beginGroup("Credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString FallbackCredentialsManager::urlToGroupName(const QString &url) const {
|
||||||
|
// |'s are not allowed in URLS, but are in group names.
|
||||||
|
return QString::number(qHash(url), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString FallbackCredentialsManager::groupNameToUrl(const QString &group) const {
|
||||||
|
QString tmp = QString(group);
|
||||||
|
return tmp.replace('|', "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
void FallbackCredentialsManager::store(const QString &server, const QString &user, const QString &token) {
|
||||||
|
m_settings.setValue(urlToGroupName(server) + "/users/" + user + "/accessToken", token);
|
||||||
|
m_settings.setValue(urlToGroupName(server) + "/address", server);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FallbackCredentialsManager::get(const QString &server, const QString &user) const {
|
||||||
|
QString result = m_settings.value(urlToGroupName(server) + "/users/" + user + "/accessToken").toString();
|
||||||
|
emit CredentialsManager::tokenRetrieved(server, user, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FallbackCredentialsManager::remove(const QString &server, const QString &user) {
|
||||||
|
m_settings.remove(urlToGroupName(server) + "/" + user);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FallbackCredentialsManager::listServers() const {
|
||||||
|
QList<QString> keys = m_settings.childGroups();
|
||||||
|
qDebug() << "Servers: " << keys;
|
||||||
|
for (int i = 0; i < keys.size(); i++) {
|
||||||
|
keys[i] = m_settings.value(keys[i] + "/address").toString();
|
||||||
|
}
|
||||||
|
qDebug() << "Servers: " << keys;
|
||||||
|
|
||||||
|
emit CredentialsManager::serversListed(keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FallbackCredentialsManager::listUsers(const QString &server) {
|
||||||
|
m_settings.beginGroup(urlToGroupName(server));
|
||||||
|
m_settings.beginGroup("users");
|
||||||
|
QStringList users = m_settings.childGroups();
|
||||||
|
qDebug() << "Users: " << users;
|
||||||
|
m_settings.endGroup();
|
||||||
|
m_settings.endGroup();
|
||||||
|
emit CredentialsManager::usersListed(users);
|
||||||
|
}
|
102
src/credentialmanager.h
Normal file
102
src/credentialmanager.h
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
#ifndef CREDENTIALS_MANAGER_H
|
||||||
|
#define CREDENTIALS_MANAGER_H
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
class CredentialsManager : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* @brief Stores a token
|
||||||
|
* @param server The server to store the token for
|
||||||
|
* @param user The user to store the token for.
|
||||||
|
* @param token The token to store.
|
||||||
|
*/
|
||||||
|
virtual void store(const QString &server, const QString &user, const QString &token) {
|
||||||
|
Q_UNUSED(server)
|
||||||
|
Q_UNUSED(user)
|
||||||
|
Q_UNUSED(token)
|
||||||
|
Q_UNIMPLEMENTED();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @brief Retrieves a stored token. Emits tokenRetrieved when the token is retrieved.
|
||||||
|
* @param server The serverId to retrieve the token from.
|
||||||
|
* @param user The user to retrieve the token for
|
||||||
|
*/
|
||||||
|
virtual void get(const QString &server, const QString &user) const {
|
||||||
|
Q_UNUSED(server)
|
||||||
|
Q_UNUSED(user)
|
||||||
|
Q_UNIMPLEMENTED();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief removes a token
|
||||||
|
* @param server
|
||||||
|
* @param user
|
||||||
|
*/
|
||||||
|
virtual void remove(const QString &server, const QString &user) {
|
||||||
|
Q_UNUSED(server)
|
||||||
|
Q_UNUSED(user)
|
||||||
|
Q_UNIMPLEMENTED();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Gives the list of servers that have a user stored with a token.
|
||||||
|
*/
|
||||||
|
virtual void listServers() const { Q_UNIMPLEMENTED(); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief List the users with a token on a server
|
||||||
|
* @param server
|
||||||
|
*/
|
||||||
|
virtual void listUsers(const QString &server) {
|
||||||
|
Q_UNUSED(server)
|
||||||
|
Q_UNIMPLEMENTED();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Retrieves an implementation which can store this token.
|
||||||
|
* @param The parent to set the implementations QObject parent to
|
||||||
|
* @return An implementation of this interface (may vary acrros platform).
|
||||||
|
*/
|
||||||
|
static CredentialsManager *getInstance(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return if the implementation of this interface stores the token in a secure place.
|
||||||
|
*/
|
||||||
|
virtual bool isSecure() const { return false; }
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void tokenRetrieved(const QString &server, const QString &user, const QString &token) const;
|
||||||
|
void serversListed(const QStringList &servers) const;
|
||||||
|
void usersListed(const QStringList &users) const;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
explicit CredentialsManager(QObject *parent = nullptr) : QObject (parent) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Implementation of CredentialsManager that stores credentials in plain-text
|
||||||
|
*/
|
||||||
|
class FallbackCredentialsManager : public CredentialsManager {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
FallbackCredentialsManager(QObject *parent = nullptr);
|
||||||
|
void store(const QString &server, const QString &user, const QString &token) override;
|
||||||
|
void get(const QString &server, const QString &user) const override;
|
||||||
|
void remove(const QString &server, const QString &user) override;
|
||||||
|
void listServers() const override;
|
||||||
|
void listUsers(const QString &server) override;
|
||||||
|
bool isSecure() const override { return false; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString urlToGroupName(const QString &url) const;
|
||||||
|
QString groupNameToUrl(const QString &group) const;
|
||||||
|
QSettings m_settings;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
48
src/harbour-sailfin.cpp
Normal file
48
src/harbour-sailfin.cpp
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
#ifdef QT_QML_DEBUG
|
||||||
|
#include <QtQuick>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include <QJSEngine>
|
||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QQuickView>
|
||||||
|
#include <QQmlEngine>
|
||||||
|
|
||||||
|
#include <sailfishapp.h>
|
||||||
|
|
||||||
|
#include "jellyfinapiclient.h"
|
||||||
|
#include "jellyfinapimodel.h"
|
||||||
|
#include "serverdiscoverymodel.h"
|
||||||
|
|
||||||
|
|
||||||
|
void registerQml() {
|
||||||
|
const char* QML_NAMESPACE = "nl.netsoj.chris.Jellyfin";
|
||||||
|
qmlRegisterSingletonType<JellyfinApiClient>(QML_NAMESPACE, 1, 0, "ApiClient", [](QQmlEngine *eng, QJSEngine *js) {
|
||||||
|
Q_UNUSED(eng)
|
||||||
|
Q_UNUSED(js)
|
||||||
|
return dynamic_cast<QObject*>(new JellyfinApiClient());
|
||||||
|
});
|
||||||
|
qmlRegisterType<ServerDiscoveryModel>(QML_NAMESPACE, 1, 0, "ServerDiscoveryModel");
|
||||||
|
|
||||||
|
// API models
|
||||||
|
Jellyfin::registerModels(QML_NAMESPACE);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char *argv[]) {
|
||||||
|
// SailfishApp::main() will display "qml/harbour-sailfin.qml", if you need more
|
||||||
|
// control over initialization, you can use:
|
||||||
|
//
|
||||||
|
// - SailfishApp::application(int, char *[]) to get the QGuiApplication *
|
||||||
|
// - SailfishApp::createView() to get a new QQuickView * instance
|
||||||
|
// - SailfishApp::pathTo(QString) to get a QUrl to a resource file
|
||||||
|
// - SailfishApp::pathToMainQml() to get a QUrl to the main QML file
|
||||||
|
//
|
||||||
|
// To display the view, call "show()" (will show fullscreen on device).
|
||||||
|
QGuiApplication *app = SailfishApp::application(argc, argv);
|
||||||
|
registerQml();
|
||||||
|
|
||||||
|
QQuickView *view = SailfishApp::createView();
|
||||||
|
view->setSource(SailfishApp::pathToMainQml());
|
||||||
|
view->show();
|
||||||
|
|
||||||
|
return app->exec();
|
||||||
|
}
|
214
src/jellyfinapiclient.cpp
Normal file
214
src/jellyfinapiclient.cpp
Normal file
|
@ -0,0 +1,214 @@
|
||||||
|
#include "jellyfinapiclient.h"
|
||||||
|
|
||||||
|
#define STR2(x) #x
|
||||||
|
#define STR(x) STR2(x)
|
||||||
|
|
||||||
|
JellyfinApiClient::JellyfinApiClient(QObject *parent)
|
||||||
|
: QObject(parent) {
|
||||||
|
m_deviceName = QHostInfo::localHostName();
|
||||||
|
m_deviceId = QUuid::createUuid().toString();
|
||||||
|
m_credManager = CredentialsManager::getInstance(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// BASE HTTP METHODS //
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
|
||||||
|
void JellyfinApiClient::addBaseRequestHeaders(QNetworkRequest &request, const QString &path, const QUrlQuery ¶ms) {
|
||||||
|
QString authentication = "MediaBrowser ";
|
||||||
|
authentication += "Client=\"Sailfin\"";
|
||||||
|
authentication += ", Device=\"" + m_deviceName + "\"";
|
||||||
|
authentication += ", DeviceId=\"" + m_deviceId + "\"";
|
||||||
|
authentication += ", Version=\"" + QString(STR(SAILFIN_VERSION)) + "\"";
|
||||||
|
if (m_authenticated) {
|
||||||
|
authentication += ", token=\"" + m_token + "\"";
|
||||||
|
}
|
||||||
|
request.setRawHeader("X-Emby-Authorization", authentication.toUtf8());
|
||||||
|
request.setRawHeader("Accept", "application/json");
|
||||||
|
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Sailfin/%1").arg(STR(SAILFIN_VERSION)));
|
||||||
|
request.setUrl(this->m_baseUrl + path + "?" + params.toString());
|
||||||
|
qDebug() << "REQUEST TO: " << request.url();
|
||||||
|
}
|
||||||
|
|
||||||
|
QNetworkReply *JellyfinApiClient::get(const QString &path, const QUrlQuery ¶ms) {
|
||||||
|
QNetworkRequest req;
|
||||||
|
addBaseRequestHeaders(req, path, params);
|
||||||
|
return m_naManager.get(req);
|
||||||
|
}
|
||||||
|
QNetworkReply *JellyfinApiClient::post(const QString &path, const QJsonDocument &data) {
|
||||||
|
|
||||||
|
QNetworkRequest req;
|
||||||
|
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
||||||
|
addBaseRequestHeaders(req, path);
|
||||||
|
if (data.isEmpty())
|
||||||
|
return m_naManager.post(req, QByteArray());
|
||||||
|
else {
|
||||||
|
return m_naManager.post(req, data.toJson(QJsonDocument::Compact));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Nice to have methods //
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
void JellyfinApiClient::initialize(){
|
||||||
|
QObject *ctx1 = new QObject(this);
|
||||||
|
connect(m_credManager, &CredentialsManager::serversListed, ctx1, [this, ctx1](const QStringList &servers) {
|
||||||
|
qDebug() << "Servers listed: " << servers;
|
||||||
|
if (servers.size() == 0) {
|
||||||
|
emit this->setupRequired();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
//FIXME: support multiple servers
|
||||||
|
QString server = servers[0];
|
||||||
|
this->m_baseUrl = server;
|
||||||
|
qDebug() << "Server: " << server;
|
||||||
|
QObject *ctx2 = new QObject(this);
|
||||||
|
connect(m_credManager, &CredentialsManager::usersListed, ctx2, [this, server, ctx2](const QStringList &users) {
|
||||||
|
if (users.size() == 0) {
|
||||||
|
emit this->setupRequired();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
//FIXME: support multiple users
|
||||||
|
QString user = users[0];
|
||||||
|
qDebug() << "User: " << user;
|
||||||
|
|
||||||
|
QObject *ctx3 = new QObject(this);
|
||||||
|
connect(m_credManager, &CredentialsManager::tokenRetrieved, ctx3, [this, ctx3]
|
||||||
|
(const QString &server, const QString &user, const QString &token) {
|
||||||
|
Q_UNUSED(server)
|
||||||
|
this->m_token = token;
|
||||||
|
this->setUserId(user);
|
||||||
|
this->setAuthenticated(true);
|
||||||
|
disconnect(ctx3);
|
||||||
|
}, Qt::UniqueConnection);
|
||||||
|
m_credManager->get(server, user);
|
||||||
|
delete ctx2;
|
||||||
|
}, Qt::UniqueConnection);
|
||||||
|
m_credManager->listUsers(server);
|
||||||
|
qDebug() << "Listing users";
|
||||||
|
delete ctx1;
|
||||||
|
}, Qt::UniqueConnection);
|
||||||
|
qDebug() << "Listing servers";
|
||||||
|
m_credManager->listServers();
|
||||||
|
}
|
||||||
|
|
||||||
|
void JellyfinApiClient::setupConnection() {
|
||||||
|
// First detect redirects:
|
||||||
|
// Note that this is done without calling JellyfinApiClient::get since that automatically includes the base_url,
|
||||||
|
// which is something we want to avoid here.
|
||||||
|
QNetworkReply *rep = m_naManager.get(QNetworkRequest(m_baseUrl));
|
||||||
|
connect(rep, &QNetworkReply::finished, this, [rep, this](){
|
||||||
|
int status = statusCode(rep);
|
||||||
|
qDebug() << status;
|
||||||
|
|
||||||
|
// Check if redirect
|
||||||
|
if (status >= 300 && status < 400) {
|
||||||
|
QString location = QString::fromUtf8(rep->rawHeader("location"));
|
||||||
|
qInfo() << "Redirect from " << this->m_baseUrl << " to " << location;
|
||||||
|
QUrl base = QUrl(m_baseUrl);
|
||||||
|
QString newUrl = base.resolved(QUrl(location)).toString();
|
||||||
|
// If the url wants to redirect us to their web interface, we have to chop the last part of.
|
||||||
|
if (newUrl.endsWith("/web/index.html")) {
|
||||||
|
newUrl.chop(QString("/web/index.html").size());
|
||||||
|
this->setBaseUrl(newUrl);
|
||||||
|
getBrandingConfiguration();
|
||||||
|
} else {
|
||||||
|
this->setBaseUrl(newUrl);
|
||||||
|
setupConnection();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
getBrandingConfiguration();
|
||||||
|
}
|
||||||
|
rep->deleteLater();
|
||||||
|
});
|
||||||
|
connect(rep, static_cast<void (QNetworkReply::*)(QNetworkReply::NetworkError)>(&QNetworkReply::error),
|
||||||
|
this, [rep, this](QNetworkReply::NetworkError error) {
|
||||||
|
qDebug() << "Error from URL: " << rep->url();
|
||||||
|
emit this->networkError(error);
|
||||||
|
rep->deleteLater();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void JellyfinApiClient::getBrandingConfiguration() {
|
||||||
|
QNetworkReply *rep = get("/Branding/Configuration");
|
||||||
|
connect(rep, &QNetworkReply::finished, this, [rep, this]() {
|
||||||
|
qDebug() << "RESPONSE: " << statusCode(rep);
|
||||||
|
switch(statusCode(rep)) {
|
||||||
|
case 200:
|
||||||
|
QJsonDocument response = QJsonDocument::fromJson(rep->readAll());
|
||||||
|
if (response.isNull() || !response.isObject()) {
|
||||||
|
emit this->connectionFailed(ApiError::JSON_ERROR);
|
||||||
|
} else {
|
||||||
|
QJsonObject obj = response.object();
|
||||||
|
if (obj.contains("LoginDisclaimer")) {
|
||||||
|
qDebug() << "Login disclaimer: " << obj["LoginDisclaimer"];
|
||||||
|
emit this->connectionSuccess(obj["LoginDisclaimer"].toString());
|
||||||
|
} else {
|
||||||
|
emit this->connectionSuccess("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
rep->deleteLater();
|
||||||
|
});
|
||||||
|
connect(rep, static_cast<void (QNetworkReply::*)(QNetworkReply::NetworkError)>(&QNetworkReply::error),
|
||||||
|
this, [rep, this](QNetworkReply::NetworkError error) {
|
||||||
|
emit this->networkError(error);
|
||||||
|
rep->deleteLater();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void JellyfinApiClient::authenticate(QString username, QString password, bool storeCredentials) {
|
||||||
|
QJsonObject requestData;
|
||||||
|
|
||||||
|
requestData["Username"] = username;
|
||||||
|
requestData["Pw"] = password;
|
||||||
|
QNetworkReply *rep = post("/Users/Authenticatebyname", QJsonDocument(requestData));
|
||||||
|
connect(rep, &QNetworkReply::finished, this, [rep, username, storeCredentials, this]() {
|
||||||
|
int status = rep->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||||
|
qDebug() << "Got reply with status code " << status;
|
||||||
|
if (status >= 200 && status < 300) {
|
||||||
|
QJsonObject authInfo = QJsonDocument::fromJson(rep->readAll()).object();
|
||||||
|
this->m_token = authInfo["AccessToken"].toString();
|
||||||
|
this->setAuthenticated(true);
|
||||||
|
|
||||||
|
this->setUserId(authInfo["User"].toObject()["Id"].toString());
|
||||||
|
|
||||||
|
if (storeCredentials) {
|
||||||
|
m_credManager->store(this->m_baseUrl, this->m_userId, this->m_token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rep->deleteLater();
|
||||||
|
});
|
||||||
|
connect(rep, static_cast<void (QNetworkReply::*)(QNetworkReply::NetworkError)>(&QNetworkReply::error),
|
||||||
|
this, &JellyfinApiClient::defaultNetworkErrorHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
void JellyfinApiClient::fetchItem(const QString &id) {
|
||||||
|
QNetworkReply *rep = get("/Users/" + m_userId + "/Items/" + id);
|
||||||
|
connect(rep, &QNetworkReply::finished, this, [rep, id, this]() {
|
||||||
|
int status = statusCode(rep);
|
||||||
|
if (status >= 200 && status < 300) {
|
||||||
|
QJsonObject data = QJsonDocument::fromJson(rep->readAll()).object();
|
||||||
|
emit this->itemFetched(id, data);
|
||||||
|
}
|
||||||
|
rep->deleteLater();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void JellyfinApiClient::defaultNetworkErrorHandler(QNetworkReply::NetworkError error) {
|
||||||
|
QObject *signalSender = sender();
|
||||||
|
QNetworkReply *rep = dynamic_cast<QNetworkReply *>(signalSender);
|
||||||
|
if (rep != nullptr && statusCode(rep) == 401) {
|
||||||
|
emit this->authenticationError(ApiError::INVALID_PASSWORD);
|
||||||
|
} else {
|
||||||
|
emit this->networkError(error);
|
||||||
|
}
|
||||||
|
rep->deleteLater();
|
||||||
|
}
|
||||||
|
|
||||||
|
#undef STR
|
||||||
|
#undef STR2
|
136
src/jellyfinapiclient.h
Normal file
136
src/jellyfinapiclient.h
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
#ifndef JELLYFIN_API_CLIENT
|
||||||
|
#define JELLYFIN_API_CLIENT
|
||||||
|
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonParseError>
|
||||||
|
#include <QJsonValue>
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QtQml>
|
||||||
|
#include <QUuid>
|
||||||
|
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QUrlQuery>
|
||||||
|
|
||||||
|
#include "credentialmanager.h"
|
||||||
|
|
||||||
|
class JellyfinApiClient : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit JellyfinApiClient(QObject *parent = nullptr);
|
||||||
|
Q_PROPERTY(QString baseUrl MEMBER m_baseUrl NOTIFY baseUrlChanged)
|
||||||
|
Q_PROPERTY(bool authenticated READ authenticated WRITE setAuthenticated NOTIFY authenticatedChanged)
|
||||||
|
Q_PROPERTY(QString userId READ userId NOTIFY userIdChanged)
|
||||||
|
|
||||||
|
/*QNetworkReply *handleRequest(QString path, QStringList sort, Pagination *pagination,
|
||||||
|
QVariantMap filters, QStringList fields, QStringList expand, QString id);*/
|
||||||
|
|
||||||
|
bool authenticated() const { return m_authenticated; }
|
||||||
|
void setBaseUrl(QString url) {
|
||||||
|
this->m_baseUrl = url;
|
||||||
|
if (this->m_baseUrl.endsWith("/")) {
|
||||||
|
this->m_baseUrl.chop(1);
|
||||||
|
}
|
||||||
|
emit this->baseUrlChanged(m_baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
QNetworkReply *get(const QString &path, const QUrlQuery ¶ms = QUrlQuery());
|
||||||
|
QNetworkReply *post(const QString &path, const QJsonDocument &data = QJsonDocument());
|
||||||
|
void getPublicUsers();
|
||||||
|
|
||||||
|
enum ApiError {
|
||||||
|
JSON_ERROR,
|
||||||
|
UNEXPECTED_REPLY,
|
||||||
|
UNEXPECTED_STATUS,
|
||||||
|
INVALID_PASSWORD
|
||||||
|
};
|
||||||
|
|
||||||
|
QString &userId() { return m_userId; }
|
||||||
|
signals:
|
||||||
|
/*
|
||||||
|
* Emitted when the server requires authentication. Please authenticate your user via authenticate.
|
||||||
|
*/
|
||||||
|
void authenticationRequired();
|
||||||
|
|
||||||
|
void authenticationError(ApiError error);
|
||||||
|
|
||||||
|
void connectionFailed(ApiError error);
|
||||||
|
void connectionSuccess(QString loginMessage);
|
||||||
|
void networkError(QNetworkReply::NetworkError error);
|
||||||
|
|
||||||
|
void authenticatedChanged(bool authenticated);
|
||||||
|
void baseUrlChanged(const QString &baseUrl);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Set-up is required. You'll need to manually set up the baseUrl-property, call setupConnection
|
||||||
|
* afterwards and finally call authenticate.
|
||||||
|
*/
|
||||||
|
void setupRequired();
|
||||||
|
|
||||||
|
void userIdChanged(QString userId);
|
||||||
|
|
||||||
|
void itemFetched(const QString &itemId, const QJsonObject &result);
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
/**
|
||||||
|
* @brief Tries to access credentials and connect to a server. If nothing has been configured yet,
|
||||||
|
* emits setupRequired();
|
||||||
|
*/
|
||||||
|
void initialize();
|
||||||
|
/*
|
||||||
|
* Try to connect with the server. Tries to resolve redirects and retrieves information
|
||||||
|
* about the login procedure. Emits connectionSuccess on success, networkError or ConnectionFailed
|
||||||
|
* otherwise.
|
||||||
|
*/
|
||||||
|
void setupConnection();
|
||||||
|
void authenticate(QString username, QString password, bool storeCredentials = false);
|
||||||
|
void fetchItem(const QString &id);
|
||||||
|
|
||||||
|
protected slots:
|
||||||
|
void defaultNetworkErrorHandler(QNetworkReply::NetworkError error);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
/**
|
||||||
|
* @brief Adds default headers to each request, like authentication headers etc.
|
||||||
|
* @param request The request to add headers to
|
||||||
|
* @param path The path to which the request is being made
|
||||||
|
*/
|
||||||
|
void addBaseRequestHeaders(QNetworkRequest &request, const QString &path, const QUrlQuery ¶ms = QUrlQuery());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief getBrandingConfiguration Gets the login message and custom CSS (which we ignore)
|
||||||
|
*/
|
||||||
|
void getBrandingConfiguration();
|
||||||
|
|
||||||
|
|
||||||
|
private:
|
||||||
|
CredentialsManager * m_credManager;
|
||||||
|
QString m_token;
|
||||||
|
QString m_deviceName;
|
||||||
|
QString m_deviceId;
|
||||||
|
|
||||||
|
QString m_userId = "";
|
||||||
|
|
||||||
|
void setAuthenticated(bool authenticated) {
|
||||||
|
this->m_authenticated = authenticated;
|
||||||
|
emit authenticatedChanged(authenticated);
|
||||||
|
}
|
||||||
|
void setUserId(QString userId) {
|
||||||
|
this->m_userId = userId;
|
||||||
|
emit userIdChanged(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool m_authenticated = false;
|
||||||
|
QString m_baseUrl;
|
||||||
|
|
||||||
|
QNetworkAccessManager m_naManager;
|
||||||
|
static inline int statusCode(QNetworkReply *rep) {
|
||||||
|
return rep->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
#endif // JELLYFIN_API_CLIENT
|
115
src/jellyfinapimodel.cpp
Normal file
115
src/jellyfinapimodel.cpp
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
#include "jellyfinapimodel.h"
|
||||||
|
|
||||||
|
namespace Jellyfin {
|
||||||
|
ApiModel::ApiModel(QString path, QString subfield, QObject *parent)
|
||||||
|
: QAbstractListModel (parent),
|
||||||
|
m_path(path),
|
||||||
|
m_subfield(subfield) {
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiModel::reload() {
|
||||||
|
this->setStatus(Loading);
|
||||||
|
if (m_apiClient == nullptr) {
|
||||||
|
qWarning() << "Please set the apiClient property before (re)loading";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m_path.contains(":user")) {
|
||||||
|
qDebug() << "Path contains :user, replacing with" << m_apiClient->userId();
|
||||||
|
m_path = m_path.replace(":user", m_apiClient->userId());
|
||||||
|
}
|
||||||
|
QUrlQuery query;
|
||||||
|
if (m_limit >= 0) {
|
||||||
|
query.addQueryItem("Limit", QString::number(m_limit));
|
||||||
|
}
|
||||||
|
if (!m_parentId.isEmpty()) {
|
||||||
|
query.addQueryItem("ParentId", m_parentId);
|
||||||
|
}
|
||||||
|
if (m_sortBy.empty()) {
|
||||||
|
query.addQueryItem("SortBy", enumListToString(m_sortBy));
|
||||||
|
}
|
||||||
|
QNetworkReply *rep = m_apiClient->get(m_path, query);
|
||||||
|
connect(rep, &QNetworkReply::finished, this, [this, rep]() {
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(rep->readAll());
|
||||||
|
if (m_subfield.trimmed().isEmpty()) {
|
||||||
|
if (!doc.isArray()) {
|
||||||
|
qWarning() << "Object is not an array!";
|
||||||
|
this->setStatus(Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this->m_array = doc.array();
|
||||||
|
} else {
|
||||||
|
if (!doc.isObject()) {
|
||||||
|
qWarning() << "Object is not an object!";
|
||||||
|
this->setStatus(Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QJsonObject obj = doc.object();
|
||||||
|
if (!obj.contains(m_subfield)) {
|
||||||
|
qWarning() << "Object doesn't contain required subfield!";
|
||||||
|
this->setStatus(Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!obj[m_subfield].isArray()) {
|
||||||
|
qWarning() << "Object's subfield is not an array!";
|
||||||
|
this->setStatus(Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this->m_array = obj[m_subfield].toArray();
|
||||||
|
}
|
||||||
|
generateFields();
|
||||||
|
this->setStatus(Ready);
|
||||||
|
rep->deleteLater();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApiModel::generateFields() {
|
||||||
|
if (m_array.size() == 0) return;
|
||||||
|
this->beginResetModel();
|
||||||
|
m_roles.clear();
|
||||||
|
int i = Qt::UserRole + 1;
|
||||||
|
if (!m_array[0].isObject()) {
|
||||||
|
qWarning() << "Iterator is not an object?";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Walks over the keys in the first record and adds them to the rolenames.
|
||||||
|
// This assumes the back-end has the same keys for every record. I could technically
|
||||||
|
// go over all records to be really sure, but no-one got time for a O(n²) algorithm, so
|
||||||
|
// this heuristic hopefully suffices.
|
||||||
|
QJsonObject ob = m_array[0].toObject();
|
||||||
|
for (auto jt = ob.begin(); jt != ob.end(); jt++) {
|
||||||
|
QString keyName = jt.key();
|
||||||
|
keyName[0] = keyName[0].toLower();
|
||||||
|
QByteArray keyArr = keyName.toUtf8();
|
||||||
|
if (!m_roles.values().contains(keyArr)) {
|
||||||
|
m_roles.insert(i++, keyArr);
|
||||||
|
qDebug() << m_path << " adding " << keyName << " as " << ( i - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this->endResetModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant ApiModel::data(const QModelIndex &index, int role) const {
|
||||||
|
// Ignore roles we don't know
|
||||||
|
if (role <= Qt::UserRole || role >= Qt::UserRole + m_roles.size()) return QVariant();
|
||||||
|
// Ignore invalid indices.
|
||||||
|
if (!index.isValid()) return QVariant();
|
||||||
|
|
||||||
|
|
||||||
|
QJsonObject obj = m_array.at(index.row()).toObject();
|
||||||
|
|
||||||
|
QString key = m_roles[role];
|
||||||
|
key[0] = key[0].toUpper();
|
||||||
|
if (obj.contains(key)) {
|
||||||
|
return obj[key].toVariant();
|
||||||
|
}
|
||||||
|
return QVariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
void registerModels(const char *URI) {
|
||||||
|
qmlRegisterUncreatableType<ApiModel>(URI, 1, 0, "ApiModel", "Is enum and base class");
|
||||||
|
qmlRegisterUncreatableType<SortOrder>(URI, 1, 0, "SortOrder", "Is enum");
|
||||||
|
qmlRegisterType<PublicUserModel>(URI, 1, 0, "PublicUserModel");
|
||||||
|
qmlRegisterType<UserViewModel>(URI, 1, 0, "UserViewModel");
|
||||||
|
qmlRegisterType<UserItemModel>(URI, 1, 0, "UserItemModel");
|
||||||
|
}
|
||||||
|
}
|
200
src/jellyfinapimodel.h
Normal file
200
src/jellyfinapimodel.h
Normal file
|
@ -0,0 +1,200 @@
|
||||||
|
#ifndef JELLYFIN_API_MODEL
|
||||||
|
#define JELLYFIN_API_MODEL
|
||||||
|
|
||||||
|
#include <QAbstractListModel>
|
||||||
|
#include <QFlags>
|
||||||
|
#include <QMetaEnum>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QVariant>
|
||||||
|
|
||||||
|
#include "jellyfinapiclient.h"
|
||||||
|
|
||||||
|
namespace Jellyfin {
|
||||||
|
class SortOrder {
|
||||||
|
Q_GADGET
|
||||||
|
public:
|
||||||
|
enum SortBy {
|
||||||
|
Album,
|
||||||
|
AlbumArtist,
|
||||||
|
Artist,
|
||||||
|
Budget,
|
||||||
|
CommunityRating,
|
||||||
|
CriticRating,
|
||||||
|
DateCreated,
|
||||||
|
DatePlayed,
|
||||||
|
PlayCount,
|
||||||
|
PremiereDate,
|
||||||
|
ProductionYear,
|
||||||
|
SortName,
|
||||||
|
Random,
|
||||||
|
Revenue,
|
||||||
|
Runtime
|
||||||
|
};
|
||||||
|
Q_ENUM(SortBy)
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Abstract model for displaying a REST JSON collection. Role names will be based on the fields encountered in the
|
||||||
|
* first record.
|
||||||
|
*
|
||||||
|
* To create a new model, extend this class and create an QObject-parent constructor.
|
||||||
|
* Call the right super constructor with the right values, depending which path should be queried and
|
||||||
|
* how the result should be interpreted.
|
||||||
|
*
|
||||||
|
* Register the model in QML and create an instance. Don't forget to set the apiClient attribute or else
|
||||||
|
* the model you've created will be useless!
|
||||||
|
*
|
||||||
|
* Rolenames are based on the fields in the first object within the array of results, with the first letter
|
||||||
|
* lowercased, to accomodate for QML style guidelines. (This ain't C# here).
|
||||||
|
*
|
||||||
|
* If a call to /cats/new results in
|
||||||
|
* @code{.json}
|
||||||
|
* [
|
||||||
|
* {"Name": "meow", "Id": 432},
|
||||||
|
* {"Name": "miew", "Id": 323}
|
||||||
|
* ]
|
||||||
|
* @endcode
|
||||||
|
* The model will have roleNames for "name" and "id".
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class ApiModel : public QAbstractListModel {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
enum ModelStatus {
|
||||||
|
Uninitialised,
|
||||||
|
Loading,
|
||||||
|
Ready,
|
||||||
|
Error
|
||||||
|
};
|
||||||
|
Q_ENUM(ModelStatus)
|
||||||
|
|
||||||
|
enum MediaType {
|
||||||
|
MediaUnspecified,
|
||||||
|
Series
|
||||||
|
};
|
||||||
|
Q_DECLARE_FLAGS(MediaTypes, MediaType)
|
||||||
|
Q_FLAG(MediaTypes)
|
||||||
|
/**
|
||||||
|
* @brief Creates a new basemodel
|
||||||
|
* @param path The path (relative to the baseUrl of JellyfinApiClient) to make the call to.
|
||||||
|
* @param subfield Leave empty if the root of the result is the array with results. Otherwise, set to the key name in the
|
||||||
|
* root object which contains the data.
|
||||||
|
* @param parent Parent (Standard QObject stuff)
|
||||||
|
*
|
||||||
|
* If the response looks something like this:
|
||||||
|
* @code{.json}
|
||||||
|
* [{...}, {...}, {...}]
|
||||||
|
* @endcode
|
||||||
|
* subfield should be left empty
|
||||||
|
*
|
||||||
|
* If the response looks something like this:
|
||||||
|
* @code{.json}
|
||||||
|
* {
|
||||||
|
* "offset": 0,
|
||||||
|
* "count": 20,
|
||||||
|
* "data": [{...}, {...}, {...}, ..., {...}]
|
||||||
|
* }
|
||||||
|
* @endcode
|
||||||
|
* Subfield should be set to "data" in this example.
|
||||||
|
*/
|
||||||
|
explicit ApiModel(QString path, QString subfield, QObject *parent = nullptr);
|
||||||
|
Q_PROPERTY(JellyfinApiClient *apiClient MEMBER m_apiClient)
|
||||||
|
Q_PROPERTY(ModelStatus status READ status NOTIFY statusChanged)
|
||||||
|
Q_PROPERTY(int limit MEMBER m_limit NOTIFY limitChanged)
|
||||||
|
Q_PROPERTY(QString parentId MEMBER m_parentId NOTIFY parentIdChanged)
|
||||||
|
Q_PROPERTY(QList<SortOrder::SortBy> sortBy MEMBER m_sortBy NOTIFY sortByChanged)
|
||||||
|
//Q_PROPERTY(MediaTypes includeTypes MEMBER m_includeTypes NOTIFY includeTypesChanged)
|
||||||
|
|
||||||
|
int rowCount(const QModelIndex &index) const override {
|
||||||
|
if (!index.isValid()) return m_array.size();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
QHash<int, QByteArray> roleNames() const override { return m_roles; }
|
||||||
|
|
||||||
|
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||||
|
|
||||||
|
ModelStatus status() const { return m_status; }
|
||||||
|
|
||||||
|
template<typename QEnum>
|
||||||
|
QString enumToString (const QEnum anEnum) { return QVariant::fromValue(anEnum).toString(); }
|
||||||
|
|
||||||
|
template<typename QEnum>
|
||||||
|
QString enumListToString (const QList<QEnum> enumList) {
|
||||||
|
QString result;
|
||||||
|
for (QEnum e : enumList) {
|
||||||
|
result += QVariant::fromValue(e).toString() + ",";
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
signals:
|
||||||
|
void statusChanged(ModelStatus newStatus);
|
||||||
|
void limitChanged(int newLimit);
|
||||||
|
void parentIdChanged(QString newParentId);
|
||||||
|
void sortByChanged(SortOrder::SortBy newSortOrder);
|
||||||
|
void includeTypesChanged(MediaTypes newTypes);
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
/**
|
||||||
|
* @brief (Re)loads the data into this model. This might make a network request.
|
||||||
|
*/
|
||||||
|
void reload();
|
||||||
|
protected:
|
||||||
|
JellyfinApiClient *m_apiClient = nullptr;
|
||||||
|
ModelStatus m_status = Uninitialised;
|
||||||
|
|
||||||
|
QString m_path;
|
||||||
|
QString m_subfield;
|
||||||
|
QJsonArray m_array;
|
||||||
|
|
||||||
|
// Query properties
|
||||||
|
int m_limit = -1;
|
||||||
|
QString m_parentId;
|
||||||
|
QList<SortOrder::SortBy> m_sortBy = {};
|
||||||
|
MediaTypes m_includeTypes = MediaUnspecified;
|
||||||
|
|
||||||
|
QHash<int, QByteArray> m_roles;
|
||||||
|
//QHash<QByteArray, int> m_reverseRoles;
|
||||||
|
|
||||||
|
void setStatus(ModelStatus newStatus) {
|
||||||
|
this->m_status = newStatus;
|
||||||
|
emit this->statusChanged(newStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
/**
|
||||||
|
* @brief Generates roleNames based on the first record in m_array.
|
||||||
|
*/
|
||||||
|
void generateFields();
|
||||||
|
QString sortByToString(SortOrder::SortBy sortBy);
|
||||||
|
QString mediaTypeToString(MediaType mediaType);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief List of the public users on the server.
|
||||||
|
*/
|
||||||
|
class PublicUserModel : public ApiModel {
|
||||||
|
public:
|
||||||
|
explicit PublicUserModel (QObject *parent = nullptr)
|
||||||
|
: ApiModel ("/users/public", "", parent) { }
|
||||||
|
};
|
||||||
|
|
||||||
|
class UserViewModel : public ApiModel {
|
||||||
|
public:
|
||||||
|
explicit UserViewModel (QObject *parent = nullptr)
|
||||||
|
: ApiModel ("/Users/:user/Views", "Items", parent) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
class UserItemModel : public ApiModel {
|
||||||
|
public:
|
||||||
|
explicit UserItemModel (QObject *parent = nullptr)
|
||||||
|
: ApiModel ("/Users/:user/Items", "Items", parent) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
void registerModels(const char *URI);
|
||||||
|
|
||||||
|
Q_DECLARE_OPERATORS_FOR_FLAGS(ApiModel::MediaTypes)
|
||||||
|
|
||||||
|
}
|
||||||
|
#endif //JELLYFIN_API_MODEL
|
73
src/serverdiscoverymodel.cpp
Normal file
73
src/serverdiscoverymodel.cpp
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
#include "serverdiscoverymodel.h"
|
||||||
|
|
||||||
|
ServerDiscoveryModel::ServerDiscoveryModel(QObject *parent)
|
||||||
|
: QAbstractListModel (parent) {
|
||||||
|
connect(&m_socket, &QUdpSocket::readyRead, this, &ServerDiscoveryModel::on_datagramsAvailable);
|
||||||
|
m_socket.bind(BROADCAST_PORT);
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant ServerDiscoveryModel::data(const QModelIndex &index, int role) const {
|
||||||
|
if (index.row() < 0 || index.row() >= rowCount()) return QVariant();
|
||||||
|
size_t row = static_cast<size_t>(index.row());
|
||||||
|
|
||||||
|
switch(role) {
|
||||||
|
case ROLE_ADDRESS:
|
||||||
|
return m_discoveredServers[row].address;
|
||||||
|
case ROLE_ID:
|
||||||
|
return m_discoveredServers[row].id;
|
||||||
|
case ROLE_NAME:
|
||||||
|
return m_discoveredServers[row].name;
|
||||||
|
default:
|
||||||
|
return QVariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ServerDiscoveryModel::refresh() {
|
||||||
|
this->beginResetModel();
|
||||||
|
this->m_discoveredServers.clear();
|
||||||
|
this->endResetModel();
|
||||||
|
|
||||||
|
m_socket.writeDatagram(MAGIC_PACKET, QHostAddress::Broadcast, BROADCAST_PORT);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ServerDiscoveryModel::on_datagramsAvailable() {
|
||||||
|
int beginIndex = static_cast<int>(m_discoveredServers.size());
|
||||||
|
|
||||||
|
QByteArray datagram;
|
||||||
|
QJsonDocument jsonDocument;
|
||||||
|
QJsonParseError jsonParseError;
|
||||||
|
QHostAddress replyAddress;
|
||||||
|
std::vector<ServerDiscovery> discoveredServers;
|
||||||
|
|
||||||
|
while (m_socket.hasPendingDatagrams()) {
|
||||||
|
datagram.resize(static_cast<int>(m_socket.pendingDatagramSize()));
|
||||||
|
m_socket.readDatagram(datagram.data(), datagram.size(), &replyAddress);
|
||||||
|
|
||||||
|
jsonDocument = QJsonDocument::fromJson(datagram, &jsonParseError);
|
||||||
|
// Check if parsing failed
|
||||||
|
if (jsonDocument.isNull()) {
|
||||||
|
qDebug() << "Invalid response from " << replyAddress.toString() << ": " << jsonParseError.errorString();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonDocument.isObject()) {
|
||||||
|
QJsonObject rootObject = jsonDocument.object();
|
||||||
|
if (rootObject.contains("Name") && rootObject.contains("Address") && rootObject.contains("Id")) {
|
||||||
|
// We (assume) we have a correct response! Add it to the back of our temporary vector with discovered servers
|
||||||
|
discoveredServers.push_back(ServerDiscovery {
|
||||||
|
rootObject["Name"].toString(),
|
||||||
|
rootObject["Address"].toString(),
|
||||||
|
rootObject["Id"].toString()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
qDebug() << "Invalid response from " << replyAddress.toString() << ": does not contain Name, Address, or Id field";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qDebug() << "Invalid response from " << replyAddress.toString() << ": root is not an object";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beginInsertRows(QModelIndex(), beginIndex, beginIndex + static_cast<int>(discoveredServers.size()) - 1);
|
||||||
|
m_discoveredServers.insert(m_discoveredServers.end(), discoveredServers.begin(), discoveredServers.end());
|
||||||
|
endInsertRows();
|
||||||
|
};
|
63
src/serverdiscoverymodel.h
Normal file
63
src/serverdiscoverymodel.h
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
#ifndef SERVER_DISCOVERY_MODEL_H
|
||||||
|
#define SERVER_DISCOVERY_MODEL_H
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QAbstractListModel>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonParseError>
|
||||||
|
|
||||||
|
#include <QHostAddress>
|
||||||
|
#include <QUdpSocket>
|
||||||
|
|
||||||
|
struct ServerDiscovery {
|
||||||
|
QString name;
|
||||||
|
QString address;
|
||||||
|
QString id;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Discovers nearby Jellyfin servers and puts them in this list.
|
||||||
|
*/
|
||||||
|
class ServerDiscoveryModel : public QAbstractListModel {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
enum Roles {
|
||||||
|
ROLE_NAME = Qt::UserRole + 1,
|
||||||
|
ROLE_ADDRESS,
|
||||||
|
ROLE_ID
|
||||||
|
};
|
||||||
|
explicit ServerDiscoveryModel(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QHash<int, QByteArray> roleNames() const override {
|
||||||
|
return {
|
||||||
|
{ROLE_NAME, "name"},
|
||||||
|
{ROLE_ADDRESS, "address"},
|
||||||
|
{ROLE_ID, "id"}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
int rowCount(const QModelIndex &parent = QModelIndex()) const override {
|
||||||
|
if (parent.isValid()) return 0;
|
||||||
|
return static_cast<int>(m_discoveredServers.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||||
|
public slots:
|
||||||
|
/**
|
||||||
|
* @brief Refreshes the model and searches for new servers
|
||||||
|
*/
|
||||||
|
void refresh();
|
||||||
|
private slots:
|
||||||
|
void on_datagramsAvailable();
|
||||||
|
private:
|
||||||
|
const QByteArray MAGIC_PACKET = "who is JellyfinServer?";
|
||||||
|
const quint16 BROADCAST_PORT = 7359;
|
||||||
|
|
||||||
|
QUdpSocket m_socket;
|
||||||
|
std::vector<ServerDiscovery> m_discoveredServers;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif //SERVER_DISCOVERY_MODEL_H
|
37
translations/harbour-sailfin-de.ts
Normal file
37
translations/harbour-sailfin-de.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!DOCTYPE TS>
|
||||||
|
<TS version="2.0">
|
||||||
|
<context>
|
||||||
|
<name>CoverPage</name>
|
||||||
|
<message>
|
||||||
|
<source>My Cover</source>
|
||||||
|
<translation>Mein Cover</translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
|
<context>
|
||||||
|
<name>FirstPage</name>
|
||||||
|
<message>
|
||||||
|
<source>Show Page 2</source>
|
||||||
|
<translation>Zur Seite 2</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>UI Template</source>
|
||||||
|
<translation>UI-Vorlage</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Hello Sailors</source>
|
||||||
|
<translation>Hallo Matrosen</translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
|
<context>
|
||||||
|
<name>SecondPage</name>
|
||||||
|
<message>
|
||||||
|
<source>Nested Page</source>
|
||||||
|
<translation>Unterseite</translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Item</source>
|
||||||
|
<translation>Element</translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
|
</TS>
|
128
translations/harbour-sailfin.ts
Normal file
128
translations/harbour-sailfin.ts
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!DOCTYPE TS>
|
||||||
|
<TS version="2.1">
|
||||||
|
<context>
|
||||||
|
<name>AddServerConnectingPage</name>
|
||||||
|
<message>
|
||||||
|
<source>Connecting to %1</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
|
<context>
|
||||||
|
<name>AddServerPage</name>
|
||||||
|
<message>
|
||||||
|
<source>Connect</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Connect to Jellyfin</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Server</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Sailfin will try to search for Jellyfin servers on your local network automatically</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>enter address manually</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>%1 - %2</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Server address</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>e.g. https://demo.jellyfin.org</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
|
<context>
|
||||||
|
<name>CoverPage</name>
|
||||||
|
<message>
|
||||||
|
<source>My Cover</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
|
<context>
|
||||||
|
<name>LegalPage</name>
|
||||||
|
<message>
|
||||||
|
<source>Legal</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>The Sailfin application contains some code from other projects. Without them, Sailfin would not be possible!</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>This program contains small snippets of code taken from <a href="%1">%2</a>, which is licensed under the %3 license:</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
|
<context>
|
||||||
|
<name>LoginDialog</name>
|
||||||
|
<message>
|
||||||
|
<source>Logging in as %1</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Login</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Credentials</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Username</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Password</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>password</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Login message</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
|
<context>
|
||||||
|
<name>MainPage</name>
|
||||||
|
<message>
|
||||||
|
<source>Settings</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>About</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
|
<context>
|
||||||
|
<name>SecondPage</name>
|
||||||
|
<message>
|
||||||
|
<source>Nested Page</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
<message>
|
||||||
|
<source>Item</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
|
<context>
|
||||||
|
<name>UserGridDelegate</name>
|
||||||
|
<message>
|
||||||
|
<source>Other account</source>
|
||||||
|
<translation type="unfinished"></translation>
|
||||||
|
</message>
|
||||||
|
</context>
|
||||||
|
</TS>
|
Loading…
Reference in a new issue