From 53b3eac213382dc5918e802a83f9d9ec154b5166 Mon Sep 17 00:00:00 2001 From: Chris Josten Date: Tue, 15 Sep 2020 16:53:13 +0200 Subject: [PATCH] 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 --- .gitignore | 9 ++ .gitmodules | 3 + harbour-sailfin.desktop | 12 ++ harbour-sailfin.pro | 57 +++++++ icons/108x108/harbour-sailfin.png | Bin 0 -> 7433 bytes icons/128x128/harbour-sailfin.png | Bin 0 -> 9004 bytes icons/172x172/harbour-sailfin.png | Bin 0 -> 12765 bytes icons/86x86/harbour-sailfin.png | Bin 0 -> 5835 bytes libs/qtrest | 1 + qml/3rdparty.xml | 22 +++ qml/components/GlassyBackground.qml | 43 +++++ qml/components/LibraryItemDelegate.qml | 55 +++++++ qml/components/MoreSection.qml | 76 +++++++++ qml/components/PlainLabel.qml | 18 +++ qml/components/RemoteImage.qml | 29 ++++ qml/components/UserGridDelegate.qml | 34 ++++ qml/cover/CoverPage.qml | 22 +++ qml/harbour-sailfin.qml | 65 ++++++++ qml/licenses/MIT.txt | 17 ++ qml/pages/AddServerConnectingPage.qml | 40 +++++ qml/pages/AddServerPage.qml | 89 ++++++++++ qml/pages/DetailBasePage.qml | 138 ++++++++++++++++ qml/pages/LegalPage.qml | 104 ++++++++++++ qml/pages/LoginDialog.qml | 127 +++++++++++++++ qml/pages/MainPage.qml | 125 +++++++++++++++ qml/pages/SecondPage.qml | 30 ++++ rpm/harbour-sailfin.changes.in | 18 +++ rpm/harbour-sailfin.changes.run.in | 25 +++ rpm/harbour-sailfin.yaml | 42 +++++ src/credentialmanager.cpp | 58 +++++++ src/credentialmanager.h | 102 ++++++++++++ src/harbour-sailfin.cpp | 48 ++++++ src/jellyfinapiclient.cpp | 214 +++++++++++++++++++++++++ src/jellyfinapiclient.h | 136 ++++++++++++++++ src/jellyfinapimodel.cpp | 115 +++++++++++++ src/jellyfinapimodel.h | 200 +++++++++++++++++++++++ src/serverdiscoverymodel.cpp | 73 +++++++++ src/serverdiscoverymodel.h | 63 ++++++++ translations/harbour-sailfin-de.ts | 37 +++++ translations/harbour-sailfin.ts | 128 +++++++++++++++ 40 files changed, 2375 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 harbour-sailfin.desktop create mode 100644 harbour-sailfin.pro create mode 100644 icons/108x108/harbour-sailfin.png create mode 100644 icons/128x128/harbour-sailfin.png create mode 100644 icons/172x172/harbour-sailfin.png create mode 100644 icons/86x86/harbour-sailfin.png create mode 160000 libs/qtrest create mode 100644 qml/3rdparty.xml create mode 100644 qml/components/GlassyBackground.qml create mode 100644 qml/components/LibraryItemDelegate.qml create mode 100644 qml/components/MoreSection.qml create mode 100644 qml/components/PlainLabel.qml create mode 100644 qml/components/RemoteImage.qml create mode 100644 qml/components/UserGridDelegate.qml create mode 100644 qml/cover/CoverPage.qml create mode 100644 qml/harbour-sailfin.qml create mode 100644 qml/licenses/MIT.txt create mode 100644 qml/pages/AddServerConnectingPage.qml create mode 100644 qml/pages/AddServerPage.qml create mode 100644 qml/pages/DetailBasePage.qml create mode 100644 qml/pages/LegalPage.qml create mode 100644 qml/pages/LoginDialog.qml create mode 100644 qml/pages/MainPage.qml create mode 100644 qml/pages/SecondPage.qml create mode 100644 rpm/harbour-sailfin.changes.in create mode 100644 rpm/harbour-sailfin.changes.run.in create mode 100644 rpm/harbour-sailfin.yaml create mode 100644 src/credentialmanager.cpp create mode 100644 src/credentialmanager.h create mode 100644 src/harbour-sailfin.cpp create mode 100644 src/jellyfinapiclient.cpp create mode 100644 src/jellyfinapiclient.h create mode 100644 src/jellyfinapimodel.cpp create mode 100644 src/jellyfinapimodel.h create mode 100644 src/serverdiscoverymodel.cpp create mode 100644 src/serverdiscoverymodel.h create mode 100644 translations/harbour-sailfin-de.ts create mode 100644 translations/harbour-sailfin.ts 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 0000000000000000000000000000000000000000..ab10628d3741e1893912e2a304931b55c8c43cf7 GIT binary patch literal 7433 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 Hu0mjfp95I$ literal 0 HcmV?d00001 diff --git a/icons/128x128/harbour-sailfin.png b/icons/128x128/harbour-sailfin.png new file mode 100644 index 0000000000000000000000000000000000000000..54375c53cbe19c8ff70ed0c2b435707a7f8a5db5 GIT binary patch literal 9004 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 A4gdfE literal 0 HcmV?d00001 diff --git a/icons/172x172/harbour-sailfin.png b/icons/172x172/harbour-sailfin.png new file mode 100644 index 0000000000000000000000000000000000000000..36eee58569097cff9011308279775e3f40a72175 GIT binary patch literal 12765 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
    3*BJ-*)G#D0C>ZP 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 literal 0 HcmV?d00001 diff --git a/icons/86x86/harbour-sailfin.png b/icons/86x86/harbour-sailfin.png new file mode 100644 index 0000000000000000000000000000000000000000..ad316d6f88bf9cbdaed51583ae466d7bf2e4337e GIT binary patch literal 5835 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 + + 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 + + + +