commit 53b3eac213382dc5918e802a83f9d9ec154b5166 Author: Chris Josten Date: Tue Sep 15 16:53:13 2020 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a9b849 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..08a57fb --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "libs/qtrest"] + path = libs/qtrest + url = ../qtrest diff --git a/harbour-sailfin.desktop b/harbour-sailfin.desktop new file mode 100644 index 0000000..5a21920 --- /dev/null +++ b/harbour-sailfin.desktop @@ -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 diff --git a/harbour-sailfin.pro b/harbour-sailfin.pro new file mode 100644 index 0000000..60f1b93 --- /dev/null +++ b/harbour-sailfin.pro @@ -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 diff --git a/icons/108x108/harbour-sailfin.png b/icons/108x108/harbour-sailfin.png new file mode 100644 index 0000000..ab10628 Binary files /dev/null and b/icons/108x108/harbour-sailfin.png differ diff --git a/icons/128x128/harbour-sailfin.png b/icons/128x128/harbour-sailfin.png new file mode 100644 index 0000000..54375c5 Binary files /dev/null and b/icons/128x128/harbour-sailfin.png differ diff --git a/icons/172x172/harbour-sailfin.png b/icons/172x172/harbour-sailfin.png new file mode 100644 index 0000000..36eee58 Binary files /dev/null and b/icons/172x172/harbour-sailfin.png differ diff --git a/icons/86x86/harbour-sailfin.png b/icons/86x86/harbour-sailfin.png new file mode 100644 index 0000000..ad316d6 Binary files /dev/null and b/icons/86x86/harbour-sailfin.png differ diff --git a/libs/qtrest b/libs/qtrest new file mode 160000 index 0000000..b4994c9 --- /dev/null +++ b/libs/qtrest @@ -0,0 +1 @@ +Subproject commit b4994c96c4128efd26500ef8952ac11315e9f4cf diff --git a/qml/3rdparty.xml b/qml/3rdparty.xml new file mode 100644 index 0000000..5828a80 --- /dev/null +++ b/qml/3rdparty.xml @@ -0,0 +1,22 @@ + + + Storeman + https://github.com/mentaljam/harbour-storeman/tree/f64314e7f72550faf35f95f046b52cee42501cf8 + SNIPPET + + MIT + Copyright (c) 2017 Petr Tsymbarovich + licenses/MIT.txt + + + + Hutspot + https://github.com/sailfish-spotify/hutspot/tree/22787baa6603b5235a3c9e6a65778e0485dfcd7b + SNIPPET + + MIT + Copyright (c) 2019 sailfish-spotify contributors + licenses/MIT.txt + + + diff --git a/qml/components/GlassyBackground.qml b/qml/components/GlassyBackground.qml new file mode 100644 index 0000000..ae6fde4 --- /dev/null +++ b/qml/components/GlassyBackground.qml @@ -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 = "" + } +} diff --git a/qml/components/LibraryItemDelegate.qml b/qml/components/LibraryItemDelegate.qml new file mode 100644 index 0000000..a7a3e07 --- /dev/null +++ b/qml/components/LibraryItemDelegate.qml @@ -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 + } +} diff --git a/qml/components/MoreSection.qml b/qml/components/MoreSection.qml new file mode 100644 index 0000000..e757ae1 --- /dev/null +++ b/qml/components/MoreSection.qml @@ -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 + } +} diff --git a/qml/components/PlainLabel.qml b/qml/components/PlainLabel.qml new file mode 100644 index 0000000..d26cd4d --- /dev/null +++ b/qml/components/PlainLabel.qml @@ -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 +} diff --git a/qml/components/RemoteImage.qml b/qml/components/RemoteImage.qml new file mode 100644 index 0000000..f9bb0bb --- /dev/null +++ b/qml/components/RemoteImage.qml @@ -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" + } +} diff --git a/qml/components/UserGridDelegate.qml b/qml/components/UserGridDelegate.qml new file mode 100644 index 0000000..e917bde --- /dev/null +++ b/qml/components/UserGridDelegate.qml @@ -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 + } +} diff --git a/qml/cover/CoverPage.qml b/qml/cover/CoverPage.qml new file mode 100644 index 0000000..fc562d5 --- /dev/null +++ b/qml/cover/CoverPage.qml @@ -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" + } + } +} diff --git a/qml/harbour-sailfin.qml b/qml/harbour-sailfin.qml new file mode 100644 index 0000000..13288ed --- /dev/null +++ b/qml/harbour-sailfin.qml @@ -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(); + } + } +} diff --git a/qml/licenses/MIT.txt b/qml/licenses/MIT.txt new file mode 100644 index 0000000..969d061 --- /dev/null +++ b/qml/licenses/MIT.txt @@ -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. diff --git a/qml/pages/AddServerConnectingPage.qml b/qml/pages/AddServerConnectingPage.qml new file mode 100644 index 0000000..363f7f2 --- /dev/null +++ b/qml/pages/AddServerConnectingPage.qml @@ -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(); + } + } +} diff --git a/qml/pages/AddServerPage.qml b/qml/pages/AddServerPage.qml new file mode 100644 index 0000000..701ae92 --- /dev/null +++ b/qml/pages/AddServerPage.qml @@ -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() +} diff --git a/qml/pages/DetailBasePage.qml b/qml/pages/DetailBasePage.qml new file mode 100644 index 0000000..6b65048 --- /dev/null +++ b/qml/pages/DetailBasePage.qml @@ -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 + } + } + } +} diff --git a/qml/pages/LegalPage.qml b/qml/pages/LegalPage.qml new file mode 100644 index 0000000..8e63de4 --- /dev/null +++ b/qml/pages/LegalPage.qml @@ -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 %2, 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 {} + } + } +} diff --git a/qml/pages/LoginDialog.qml b/qml/pages/LoginDialog.qml new file mode 100644 index 0000000..360318c --- /dev/null +++ b/qml/pages/LoginDialog.qml @@ -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")) + }*/ +} diff --git a/qml/pages/MainPage.qml b/qml/pages/MainPage.qml new file mode 100644 index 0000000..3f4b644 --- /dev/null +++ b/qml/pages/MainPage.qml @@ -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() + } +} diff --git a/qml/pages/SecondPage.qml b/qml/pages/SecondPage.qml new file mode 100644 index 0000000..6dbadf4 --- /dev/null +++ b/qml/pages/SecondPage.qml @@ -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 {} + } +} diff --git a/rpm/harbour-sailfin.changes.in b/rpm/harbour-sailfin.changes.in new file mode 100644 index 0000000..c0d5182 --- /dev/null +++ b/rpm/harbour-sailfin.changes.in @@ -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 version-release +# - Summary of changes + +* Sun Apr 13 2014 Jack Tar 0.0.1-1 +- Scrubbed the deck +- Hoisted the sails + diff --git a/rpm/harbour-sailfin.changes.run.in b/rpm/harbour-sailfin.changes.run.in new file mode 100644 index 0000000..76c3761 --- /dev/null +++ b/rpm/harbour-sailfin.changes.run.in @@ -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 diff --git a/rpm/harbour-sailfin.yaml b/rpm/harbour-sailfin.yaml new file mode 100644 index 0000000..e03b49d --- /dev/null +++ b/rpm/harbour-sailfin.yaml @@ -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 diff --git a/src/credentialmanager.cpp b/src/credentialmanager.cpp new file mode 100644 index 0000000..e027731 --- /dev/null +++ b/src/credentialmanager.cpp @@ -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 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); +} diff --git a/src/credentialmanager.h b/src/credentialmanager.h new file mode 100644 index 0000000..b3ab328 --- /dev/null +++ b/src/credentialmanager.h @@ -0,0 +1,102 @@ +#ifndef CREDENTIALS_MANAGER_H +#define CREDENTIALS_MANAGER_H + +#include +#include +#include +#include +#include + +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 diff --git a/src/harbour-sailfin.cpp b/src/harbour-sailfin.cpp new file mode 100644 index 0000000..c9facfa --- /dev/null +++ b/src/harbour-sailfin.cpp @@ -0,0 +1,48 @@ +#ifdef QT_QML_DEBUG +#include +#endif + +#include +#include +#include +#include + +#include + +#include "jellyfinapiclient.h" +#include "jellyfinapimodel.h" +#include "serverdiscoverymodel.h" + + +void registerQml() { + const char* QML_NAMESPACE = "nl.netsoj.chris.Jellyfin"; + qmlRegisterSingletonType(QML_NAMESPACE, 1, 0, "ApiClient", [](QQmlEngine *eng, QJSEngine *js) { + Q_UNUSED(eng) + Q_UNUSED(js) + return dynamic_cast(new JellyfinApiClient()); + }); + qmlRegisterType(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(); +} diff --git a/src/jellyfinapiclient.cpp b/src/jellyfinapiclient.cpp new file mode 100644 index 0000000..e609fa3 --- /dev/null +++ b/src/jellyfinapiclient.cpp @@ -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(&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(&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(&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(signalSender); + if (rep != nullptr && statusCode(rep) == 401) { + emit this->authenticationError(ApiError::INVALID_PASSWORD); + } else { + emit this->networkError(error); + } + rep->deleteLater(); +} + +#undef STR +#undef STR2 diff --git a/src/jellyfinapiclient.h b/src/jellyfinapiclient.h new file mode 100644 index 0000000..caaa7ec --- /dev/null +++ b/src/jellyfinapiclient.h @@ -0,0 +1,136 @@ +#ifndef JELLYFIN_API_CLIENT +#define JELLYFIN_API_CLIENT + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include + +#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 diff --git a/src/jellyfinapimodel.cpp b/src/jellyfinapimodel.cpp new file mode 100644 index 0000000..31e80ec --- /dev/null +++ b/src/jellyfinapimodel.cpp @@ -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(URI, 1, 0, "ApiModel", "Is enum and base class"); + qmlRegisterUncreatableType(URI, 1, 0, "SortOrder", "Is enum"); + qmlRegisterType(URI, 1, 0, "PublicUserModel"); + qmlRegisterType(URI, 1, 0, "UserViewModel"); + qmlRegisterType(URI, 1, 0, "UserItemModel"); +} +} diff --git a/src/jellyfinapimodel.h b/src/jellyfinapimodel.h new file mode 100644 index 0000000..03b9a25 --- /dev/null +++ b/src/jellyfinapimodel.h @@ -0,0 +1,200 @@ +#ifndef JELLYFIN_API_MODEL +#define JELLYFIN_API_MODEL + +#include +#include +#include +#include +#include +#include +#include + +#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 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 roleNames() const override { return m_roles; } + + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + ModelStatus status() const { return m_status; } + + template + QString enumToString (const QEnum anEnum) { return QVariant::fromValue(anEnum).toString(); } + + template + QString enumListToString (const QList 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 m_sortBy = {}; + MediaTypes m_includeTypes = MediaUnspecified; + + QHash m_roles; + //QHash 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 diff --git a/src/serverdiscoverymodel.cpp b/src/serverdiscoverymodel.cpp new file mode 100644 index 0000000..0815ca6 --- /dev/null +++ b/src/serverdiscoverymodel.cpp @@ -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(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(m_discoveredServers.size()); + + QByteArray datagram; + QJsonDocument jsonDocument; + QJsonParseError jsonParseError; + QHostAddress replyAddress; + std::vector discoveredServers; + + while (m_socket.hasPendingDatagrams()) { + datagram.resize(static_cast(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(discoveredServers.size()) - 1); + m_discoveredServers.insert(m_discoveredServers.end(), discoveredServers.begin(), discoveredServers.end()); + endInsertRows(); +}; diff --git a/src/serverdiscoverymodel.h b/src/serverdiscoverymodel.h new file mode 100644 index 0000000..16d7aa5 --- /dev/null +++ b/src/serverdiscoverymodel.h @@ -0,0 +1,63 @@ +#ifndef SERVER_DISCOVERY_MODEL_H +#define SERVER_DISCOVERY_MODEL_H + +#include + +#include +#include +#include +#include +#include + +#include +#include + +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 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(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 m_discoveredServers; +}; + +#endif //SERVER_DISCOVERY_MODEL_H diff --git a/translations/harbour-sailfin-de.ts b/translations/harbour-sailfin-de.ts new file mode 100644 index 0000000..7dbef71 --- /dev/null +++ b/translations/harbour-sailfin-de.ts @@ -0,0 +1,37 @@ + + + + + CoverPage + + My Cover + Mein Cover + + + + FirstPage + + Show Page 2 + Zur Seite 2 + + + UI Template + UI-Vorlage + + + Hello Sailors + Hallo Matrosen + + + + SecondPage + + Nested Page + Unterseite + + + Item + Element + + + diff --git a/translations/harbour-sailfin.ts b/translations/harbour-sailfin.ts new file mode 100644 index 0000000..beedf38 --- /dev/null +++ b/translations/harbour-sailfin.ts @@ -0,0 +1,128 @@ + + + + + AddServerConnectingPage + + Connecting to %1 + + + + + AddServerPage + + Connect + + + + Connect to Jellyfin + + + + Server + + + + Sailfin will try to search for Jellyfin servers on your local network automatically + + + + enter address manually + + + + %1 - %2 + + + + Server address + + + + e.g. https://demo.jellyfin.org + + + + + CoverPage + + My Cover + + + + + LegalPage + + Legal + + + + The Sailfin application contains some code from other projects. Without them, Sailfin would not be possible! + + + + This program contains small snippets of code taken from <a href="%1">%2</a>, which is licensed under the %3 license: + + + + + LoginDialog + + Logging in as %1 + + + + Login + + + + Credentials + + + + Username + + + + Password + + + + password + + + + Login message + + + + + MainPage + + Settings + + + + About + + + + + SecondPage + + Nested Page + + + + Item + + + + + UserGridDelegate + + Other account + + + +