Initial commit

Features so far:
- Login is working, both on back-end and GUI-wise
- Saving and reusing login tokens is working
- The home page is mostly functional
- Show details can be received and displayed in a basic manner

Following features are taken into account, but have not been fully
implemented:
- Support for multiple accounts/servers
- Securely saving login tokens
This commit is contained in:
Chris Josten 2020-09-15 16:53:13 +02:00
commit 53b3eac213
40 changed files with 2375 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
# Spec files are generated from the yaml files, so they are unneeded
rpm/*.spec
# Build folders
build/
build-*/
# IDE files
harbour-sailfin.pro.user

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "libs/qtrest"]
path = libs/qtrest
url = ../qtrest

12
harbour-sailfin.desktop Normal file
View File

@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
X-Nemo-Application-Type=silica-qt5
Icon=harbour-sailfin
Exec=harbour-sailfin
Name=harbour-sailfin
# translation example:
# your app name in German locale (de)
#
# Remember to comment out the following line, if you do not want to use
# a different app name in German locale (de).
Name[de]=harbour-sailfin

57
harbour-sailfin.pro Normal file
View File

@ -0,0 +1,57 @@
# NOTICE:
#
# Application name defined in TARGET has a corresponding QML filename.
# If name defined in TARGET is changed, the following needs to be done
# to match new name:
# - corresponding QML filename must be changed
# - desktop icon filename must be changed
# - desktop filename must be changed
# - icon definition filename in desktop file must be changed
# - translation filenames have to be changed
# The name of your application
TARGET = harbour-sailfin
CONFIG += sailfishapp c++11
SOURCES += \
src/credentialmanager.cpp \
src/harbour-sailfin.cpp \
src/jellyfinapiclient.cpp \
src/jellyfinapimodel.cpp \
src/serverdiscoverymodel.cpp
DISTFILES += \
qml/components/GlassyBackground.qml \
qml/components/LibraryItemDelegate.qml \
qml/components/MoreSection.qml \
qml/components/PlainLabel.qml \
qml/components/RemoteImage.qml \
qml/components/UserGridDelegate.qml \
qml/cover/CoverPage.qml \
qml/pages/AddServerConnectingPage.qml \
qml/pages/DetailBasePage.qml \
qml/pages/LegalPage.qml \
qml/pages/LoginDialog.qml \
qml/pages/MainPage.qml \
qml/pages/SecondPages.qml \
qml/harbour-sailfin.qml
SAILFISHAPP_ICONS = 86x86 108x108 128x128 172x172
# to disable building translations every time, comment out the
# following CONFIG line
CONFIG += sailfishapp_i18n
# German translation is enabled as an example. If you aren't
# planning to localize your app, remember to comment out the
# following TRANSLATIONS line. And also do not forget to
# modify the localized app name in the the .desktop file.
# TRANSLATIONS += \
HEADERS += \
src/credentialmanager.h \
src/jellyfinapiclient.h \
src/jellyfinapimodel.h \
src/serverdiscoverymodel.h

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

1
libs/qtrest Submodule

@ -0,0 +1 @@
Subproject commit b4994c96c4128efd26500ef8952ac11315e9f4cf

22
qml/3rdparty.xml Normal file
View File

@ -0,0 +1,22 @@
<includes>
<include>
<name>Storeman</name>
<url>https://github.com/mentaljam/harbour-storeman/tree/f64314e7f72550faf35f95f046b52cee42501cf8</url>
<type>SNIPPET</type>
<license>
<type>MIT</type>
<copyright>Copyright (c) 2017 Petr Tsymbarovich</copyright>
<text>licenses/MIT.txt</text>
</license>
</include>
<include>
<name>Hutspot</name>
<url>https://github.com/sailfish-spotify/hutspot/tree/22787baa6603b5235a3c9e6a65778e0485dfcd7b</url>
<type>SNIPPET</type>
<license>
<type>MIT</type>
<copyright>Copyright (c) 2019 sailfish-spotify contributors</copyright>
<text>licenses/MIT.txt</text>
</license>
</include>
</includes>

View File

@ -0,0 +1,43 @@
import QtQuick 2.6
import Sailfish.Silica 1.0
import QtGraphicalEffects 1.0
Rectangle {
property alias source: backgroundImage.source
property alias sourceSize: backgroundImage.sourceSize
property real dimmedOpacity: Theme.opacityFaint
readonly property alias status: backgroundImage.status
color: Theme.colorScheme == Theme.DarkOnLight ? "#fff" : "#000"
z: -1
opacity: status == Image.Ready ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 300 } }
Image {
id: backgroundImage
cache: true
smooth: false
asynchronous: true
fillMode: Image.PreserveAspectCrop
anchors.fill: parent
visible: false
}
FastBlur {
anchors.fill: backgroundImage
source: backgroundImage
opacity: dimmedOpacity
radius: 100
}
Image {
anchors.fill: parent
fillMode: Image.Tile
source: "image://theme/graphic-shader-texture"
opacity: 0.1
visible: parent.visible
}
function clear() {
//source = ""
}
}

View File

@ -0,0 +1,55 @@
import QtQuick 2.6
import Sailfish.Silica 1.0
BackgroundItem {
id: root
property alias poster: posterImage.source
property alias title: titleText.text
property bool landscape: false
width: Screen.width / 3
height: landscape ? width / 4 * 3 : width / 2 * 3
RemoteImage {
id: posterImage
anchors {
left: parent.left
top: parent.top
right: parent.right
bottom: parent.bottom
}
fillMode: Image.PreserveAspectCrop
}
Rectangle {
anchors.fill: posterImage
color: Theme.rgba(Theme.highlightBackgroundColor, Theme.highlightBackgroundOpacity)
visible: root.highlighted
}
Rectangle {
anchors {
left: parent.left
right: parent.right
bottom: parent.bottom
}
height: titleText.height * 1.5 + Theme.paddingSmall * 2
gradient: Gradient {
GradientStop { position: 0.0; color: "transparent"; }
GradientStop { position: 1.0; color: Theme.highlightDimmerColor }
}
}
Label {
id: titleText
anchors {
left: parent.left
bottom: parent.bottom
right: parent.right
leftMargin: Theme.paddingMedium
rightMargin: Theme.paddingMedium
bottomMargin: Theme.paddingSmall
}
truncationMode: TruncationMode.Fade
horizontalAlignment: Text.AlignLeft
}
}

View File

@ -0,0 +1,76 @@
/*
* File taken from Storeman. See ../3rdparty.xml for licensing information
*/
import QtQuick 2.0
import Sailfish.Silica 1.0
Item {
id: root
property alias text: label.text
property alias textAlignment: label.horizontalAlignment
property bool busy: false
property int depth: 0
readonly property color _color: enabled ? highlighted ? Theme.highlightColor : Theme.primaryColor : Theme.secondaryColor
default property alias content: container.data
implicitHeight: backgroundItem.height + container.height
width: parent.width
BackgroundItem {
id: backgroundItem
width: parent.width
height: Theme.itemSizeMedium
Rectangle {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: Theme.rgba(Theme.highlightBackgroundColor, 0.15) }
GradientStop { position: 1.0; color: "transparent" }
}
}
Label {
id: label
anchors {
left: parent.left
right: image.left
verticalCenter: parent.verticalCenter
leftMargin: Theme.horizontalPageMargin + depth * Theme.paddingLarge
rightMargin: Theme.paddingMedium
}
horizontalAlignment: Text.AlignRight
truncationMode: TruncationMode.Fade
color: _color
}
Image {
id: image
anchors {
right: parent.right
verticalCenter: parent.verticalCenter
rightMargin: Theme.horizontalPageMargin
}
visible: root.enabled && !root.busy
source: "image://theme/icon-m-right?" + _color
}
BusyIndicator {
id: busyIndicator
running: root.busy
anchors.centerIn: image
size: BusyIndicatorSize.Small
}
}
Item {
id: container
anchors {
top: backgroundItem.bottom
left: parent.left
right: parent.right
}
width: parent.width
height: children[0].height
}
}

View File

@ -0,0 +1,18 @@
import QtQuick 2.6
import Sailfish.Silica 1.0
/**
* A label with the most commonly used settings set
*/
Label {
anchors {
left: parent.left
right: parent.right
leftMargin: Theme.horizontalPageMargin
rightMargin: Theme.horizontalPageMargin
}
color: Theme.highlightColor
linkColor: Theme.primaryColor
onLinkActivated: Qt.openUrlExternally(link)
wrapMode: Text.WordWrap
}

View File

@ -0,0 +1,29 @@
import QtQuick 2.6
import Sailfish.Silica 1.0
Image {
property string fallbackImage
property bool usingFallbackImage
BusyIndicator {
anchors.centerIn: parent
running: parent.status == Image.Loading
}
Rectangle {
id: fallbackBackground
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: Theme.highlightColor; }
GradientStop { position: 1.0; color: Theme.highlightDimmerColor; }
}
visible: parent.status == Image.Error
}
Image {
id: fallbackImageItem
anchors.centerIn: parent
visible: parent.status == Image.Error
source: fallbackImage ? fallbackImage : "image://theme/icon-m-question"
}
}

View File

@ -0,0 +1,34 @@
import QtQuick 2.6
import Sailfish.Silica 1.0
GridItem {
id: root
property string image
property alias name: nameLabel.text
RemoteImage {
id: userImage
anchors.fill: parent
source: root.image ? root.image : "image://theme/icon-m-contact?" + ((root.highlighted || root.down) ? Theme.highlightColor : Theme.primaryColor)
fillMode: Image.PreserveAspectCrop
}
Rectangle {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: "transparent" }
GradientStop { position: 1.0; color: Theme.overlayBackgroundColor }
}
}
Label {
id: nameLabel
anchors {
leftMargin: Theme.horizontalPageMargin
rightMargin: Theme.horizontalPageMargin
right: parent.right
left: parent.left
bottom: parent.bottom
bottomMargin: Theme.paddingSmall
}
text: qsTr("Other account")
color: (root.highlighted || root.down) ? Theme.highlightColor : Theme.secondaryColor
}
}

22
qml/cover/CoverPage.qml Normal file
View File

@ -0,0 +1,22 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
CoverBackground {
Label {
id: label
anchors.centerIn: parent
text: qsTr("My Cover")
}
CoverActionList {
id: coverAction
CoverAction {
iconSource: "image://theme/icon-cover-next"
}
CoverAction {
iconSource: "image://theme/icon-cover-pause"
}
}
}

65
qml/harbour-sailfin.qml Normal file
View File

@ -0,0 +1,65 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
import nl.netsoj.chris.Jellyfin 1.0
import Nemo.Notifications 1.0
import "components"
import "pages"
ApplicationWindow {
id: appWindow
property bool isInSetup: false
property bool _hasInitialized: false
//property alias backdrop: backdrop
Connections {
target: ApiClient
onNetworkError: errorNotification.show("Network error: " + error)
onConnectionFailed: errorNotification.show("Connect error: " + error)
//onConnectionSuccess: errorNotification.show("Success: " + loginMessage)
}
/*GlassyBackground {
id: backdrop
anchors.fill: parent
opacity: status == Image.Ready ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 300 } }
function clear() {
source = ""
}
}*/
initialPage: Component {
MainPage {
Connections {
target: ApiClient
onSetupRequired: {
if (!isInSetup) {
isInSetup = true;
pageStack.replace(Qt.resolvedUrl("pages/AddServerPage.qml"), {"backNavigation": false});
}
}
}
onStatusChanged: {
if (status == PageStatus.Active && !_hasInitialized) {
_hasInitialized = true;
ApiClient.initialize();
}
}
}
}
cover: Qt.resolvedUrl("cover/CoverPage.qml")
allowedOrientations: Orientation.All
Notification {
id: errorNotification
previewSummary: "foo"
isTransient: true
function show(data) {
previewSummary = data;
publish();
}
}
}

17
qml/licenses/MIT.txt Normal file
View File

@ -0,0 +1,17 @@
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,40 @@
import QtQuick 2.6
import Sailfish.Silica 1.0
import nl.netsoj.chris.Jellyfin 1.0
Page {
property string serverName
property string serverAddress
property Page firstPage
allowedOrientations: Orientation.All
BusyLabel {
text: qsTr("Connecting to %1").arg(serverName)
running: true
}
onStatusChanged: {
if (status == PageStatus.Active) {
console.log("Connecting page active");
ApiClient.setupConnection();
}
}
Connections {
target: ApiClient
onConnectionSuccess: {
console.log("Login success: " + loginMessage);
pageStack.replace(Qt.resolvedUrl("LoginDialog.qml"), {"loginMessage": loginMessage, "firstPage": firstPage});
}
onConnectionFailed: function(error) {
console.log("Connection failed : " + error)
pageStack.pop();
}
onNetworkError: {
console.log("ConnectingPage: popping page!")
pageStack.pop();
}
}
}

View File

@ -0,0 +1,89 @@
import QtQuick 2.6
import Sailfish.Silica 1.0
import nl.netsoj.chris.Jellyfin 1.0
Dialog {
id: dialogRoot
allowedOrientations: Orientation.All
// Picks the address of the ComboBox if selected, otherwise the manual address entry
readonly property string address: serverSelect.currentItem._address
readonly property bool addressCorrect: serverSelect.currentIndex > 0 || manualAddress.acceptableInput
readonly property string serverName: serverSelect.currentItem._name
acceptDestination: AddServerConnectingPage {
id: connectingPage
serverName: dialogRoot.serverName
serverAddress: address
firstPage: dialogRoot
}
Column {
width: parent.width
DialogHeader {
acceptText: qsTr("Connect")
title: qsTr("Connect to Jellyfin")
}
ServerDiscoveryModel {
id: serverModel
}
ComboBox {
id: serverSelect
label: qsTr("Server")
description: qsTr("Sailfin will try to search for Jellyfin servers on your local network automatically")
menu: ContextMenu {
MenuItem {
// Special values are cool, aren't they?
readonly property string _address: manualAddress.text
readonly property string _name: manualAddress.text
text: qsTr("enter address manually")
}
Repeater {
model: serverModel
delegate: MenuItem {
readonly property string _address: address
readonly property string _name: name
text: qsTr("%1 - %2").arg(name).arg(address)
}
}
}
}
TextField {
id: manualAddress
width: parent.width
clip: true
label: qsTr("Server address")
placeholderText: qsTr("e.g. https://demo.jellyfin.org")
enabled: serverSelect.currentIndex == 0
visible: enabled
inputMethodHints: Qt.ImhUrlCharactersOnly
validator: RegExpValidator {
regExp: /^https?:\/\/[a-zA-Z0-9-._~:/?#\[\]\@\!\$\&\'\(\)\*\+\,\;\=]+$/m
}
EnterKey.enabled: addressCorrect
EnterKey.iconSource: "image://theme/icon-m-enter-accept"
EnterKey.onClicked: dialogRoot.tryConnect()
}
}
onOpened: serverModel.refresh()
canAccept: addressCorrect
function tryConnect() {
console.log("Hi there!")
ApiClient.baseUrl = address;
//ApiClient.setupConnection()
//fakeTimer.start()
}
onDone: tryConnect()
}

View File

@ -0,0 +1,138 @@
import QtQuick 2.6
import Sailfish.Silica 1.0
import nl.netsoj.chris.Jellyfin 1.0
import "../components"
Page {
id: pageRoot
property string itemId: ""
property var itemData: ({})
property bool _loading: true
readonly property bool _hasLogo: itemData.ImageTags.Logo !== undefined
readonly property string _logo: itemData.ImageTags.Logo
readonly property var _backdropImages: itemData.BackdropImageTags
readonly property var _parentBackdropImages: itemData.ParentBackdropImageTags
readonly property string parentId: itemData.ParentId
on_BackdropImagesChanged: updateBackdrop()
on_ParentBackdropImagesChanged: updateBackdrop()
function updateBackdrop() {
if (_backdropImages && _backdropImages.length > 0) {
var rand = Math.floor(Math.random() * (_backdropImages.length - 0.001))
console.log("Random: ", rand)
backdrop.source = ApiClient.baseUrl + "/Items/" + itemId + "/Images/Backdrop/" + rand + "?tag=" + _backdropImages[rand]
} else if (_parentBackdropImages && _parentBackdropImages.length > 0) {
console.log(parentId)
backdrop.source = ApiClient.baseUrl + "/Items/" + itemData.ParentBackdropItemId + "/Images/Backdrop/0?tag=" + _parentBackdropImages[0]
}
}
allowedOrientations: Orientation.All
GlassyBackground {
id: backdrop
anchors.fill: parent
}
SilicaFlickable {
anchors.fill: parent
contentHeight: content.height
Column {
id: content
width: parent.width
PageHeader {
title: itemData.Name
visible: !_hasLogo
}
Column {
width: parent.width
Item {
width: 1
height: Theme.paddingLarge
}
RemoteImage {
anchors {
horizontalCenter: parent.horizontalCenter
}
source: _hasLogo ? ApiClient.baseUrl + "/Items/" + itemId + "/Images/Logo?tag=" + _logo : undefined
}
Item {
width: 1
height: Theme.paddingLarge
}
visible: _hasLogo
}
Item {
width: 1
height: Theme.paddingLarge
}
PlainLabel {
id: overviewText
text: itemData.Overview
visible: text.length > 0
font.pixelSize: Theme.fontSizeSmall
}
Item {
visible: overviewText.visible
width: 1
height: Theme.paddingLarge
}
Row {
anchors {
//left: parent.left
right: parent.right
leftMargin: Theme.horizontalPageMargin
rightMargin: Theme.horizontalPageMargin
}
spacing: Theme.paddingMedium
IconButton {
id: favouriteButton
icon.source: "image://theme/icon-m-favorite"
}
IconButton {
id: playButton
icon.source: "image://theme/icon-l-play"
}
}
}
}
PageBusyIndicator {
running: pageRoot._loading
}
onItemIdChanged: {
itemData = {}
if (itemId.length > 0) {
pageRoot._loading = true
ApiClient.fetchItem(itemId)
}
}
onStatusChanged: {
if (status == PageStatus.Deactivating) {
backdrop.clear()
}
}
Connections {
target: ApiClient
onItemFetched: {
if (itemId === pageRoot.itemId) {
console.log(JSON.stringify(result))
pageRoot.itemData = result
pageRoot._loading = false
}
}
}
}

104
qml/pages/LegalPage.qml Normal file
View File

@ -0,0 +1,104 @@
import QtQuick 2.6
import QtQuick.XmlListModel 2.0
import Sailfish.Silica 1.0
import "../components"
Page {
allowedOrientations: Orientation.All
SilicaFlickable {
anchors.fill: parent
contentHeight: content.height
Column {
id: content
width: parent.width
XmlListModel {
id: licencesModel
source: Qt.resolvedUrl("../3rdparty.xml")
query: "/includes/include"
XmlRole { name: "name"; query: "name/string()" }
XmlRole { name: "type"; query: "type/string()" }
XmlRole { name: "url"; query: "url/string()" }
XmlRole { name: "copyright"; query: "license/copyright/string()" }
XmlRole { name: "licenseUrl"; query: "license/text/string()" }
XmlRole { name: "licenseType"; query: "license/type/string()" }
}
PageHeader {
title: qsTr("Legal")
}
PlainLabel {
text: qsTr("The Sailfin application contains some code from other projects. Without them, Sailfin would " +
"not be possible!")
}
Repeater {
model: licencesModel
Column {
width: parent.width
SectionHeader {
text: name
}
PlainLabel {
color: Theme.secondaryHighlightColor
text: {
switch(type) {
case "SNIPPET":
return qsTr("This program contains small snippets of code taken from <a href=\"%1\">%2</a>, which " +
"is licensed under the %3 license:")
.arg(model.url).arg(model.name).arg(model.licenseType);
}
}
}
Item {
width: 1
height: Theme.paddingLarge
}
SilicaFlickable {
anchors {
left: parent.left
right: parent.right
leftMargin: Theme.horizontalPageMargin
rightMargin: Theme.horizontalPageMargin
}
height: licenseLabel.contentHeight
contentWidth: licenseLabel.contentWidth
clip: true
Label {
id: licenseLabel
color: Theme.secondaryHighlightColor
font.family: "monospace"
font.pixelSize: Theme.fontSizeExtraSmall
wrapMode: Text.NoWrap
Component.onCompleted: {
var xhr = new XMLHttpRequest;
xhr.open("GET", Qt.resolvedUrl("../" + model.licenseUrl)); // set Method and File
console.log(Qt.resolvedUrl("../" + model.licenseUrl))
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE){ // if request_status == DONE
var response = model.copyright + "\n\n" + xhr.responseText;
console.log(response);
licenseLabel.text = response
}
}
xhr.send(); // begin the request
}
}
HorizontalScrollDecorator {}
}
}
}
VerticalScrollDecorator {}
}
}
}

127
qml/pages/LoginDialog.qml Normal file
View File

@ -0,0 +1,127 @@
import QtQuick 2.6
import Sailfish.Silica 1.0
import nl.netsoj.chris.Jellyfin 1.0
import "../components"
Dialog {
property string loginMessage
property Page firstPage
allowedOrientations: Orientation.All
acceptDestination: Page {
BusyLabel {
text: qsTr("Logging in as %1").arg(username.text)
running: true
}
onStatusChanged: {
if(status == PageStatus.Active) {
ApiClient.authenticate(username.text, password.text, true)
}
}
Connections {
target: ApiClient
onAuthenticatedChanged: {
if (ApiClient.authenticated) {
console.log("authenticated!")
pageStack.replaceAbove(pageStack.previousPage(firstPage), Qt.resolvedUrl("MainPage.qml"))
}
}
onAuthenticationError: {
pageStack.completeAnimation()
pageStack.pop()
}
}
}
PublicUserModel {
id: userModel
apiClient: ApiClient
Component.onCompleted: reload();
}
DialogHeader {
id: dialogHeader
anchors.left: parent.left
anchors.right: parent.right
acceptText: qsTr("Login");
}
SilicaFlickable {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: dialogHeader.bottom
anchors.bottom: parent.bottom
contentHeight: column.height
clip: true
VerticalScrollDecorator {}
Column {
id: column
width: parent.width
Flow {
width: parent.width
Repeater {
model: userModel
delegate: UserGridDelegate {
name: model.name
image: model.primaryImageTag ? "%1/Users/%2/Images/Primary?tag=%3".arg(ApiClient.baseUrl).arg(model.id).arg(model.primaryImageTag) : null
highlighted: model.name == username.text
onClicked: {
username.text = model.name
password.focus = true
}
}
}
}
SectionHeader {
text: qsTr("Credentials")
}
TextField {
id: username
width: parent.width
placeholderText: qsTr("Username")
label: qsTr("Username")
EnterKey.iconSource: "image://theme/icon-m-enter-next"
EnterKey.onClicked: password.focus = true
}
TextField {
id: password
width: parent.width
placeholderText: qsTr("Password")
label: qsTr("password")
echoMode: TextInput.Password
EnterKey.iconSource: "image://theme/icon-m-enter-accept"
EnterKey.onClicked: login()
}
SectionHeader {
text: qsTr("Login message")
}
Label {
anchors {
left: parent.left
right: parent.right
leftMargin: Theme.horizontalPageMargin
rightMargin: Theme.horizontalPageMargin
}
text: loginMessage
wrapMode: Text.WordWrap
color: Theme.highlightColor
}
}
}
canAccept: username.text.length > 0
/*onAccepted: {
pageStack.replace(Qt.resolvedUrl("MainPage.qml"))
}*/
}

125
qml/pages/MainPage.qml Normal file
View File

@ -0,0 +1,125 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
import nl.netsoj.chris.Jellyfin 1.0
import "../components"
Page {
id: page
// The effective value will be restricted by ApplicationWindow.allowedOrientations
allowedOrientations: Orientation.All
property bool _modelsLoaded: false
Connections {
target: ApiClient
onAuthenticatedChanged: {
if (authenticated && !_modelsLoaded) loadModels();
}
}
Component.onCompleted: {
if (ApiClient.authenticated && _modelsLoaded) {
loadModels();
}
}
// To enable PullDownMenu, place our content in a SilicaFlickable
SilicaFlickable {
anchors.fill: parent
// PullDownMenu and PushUpMenu must be declared in SilicaFlickable, SilicaListView or SilicaGridView
PullDownMenu {
MenuItem {
text: qsTr("About")
onClicked: pageStack.push(Qt.resolvedUrl("LegalPage.qml"))
}
MenuItem {
text: qsTr("Settings")
onClicked: pageStack.push(Qt.resolvedUrl("SecondPage.qml"))
}
}
// Tell SilicaFlickable the height of its content.
contentHeight: column.height
// Place our content in a Column. The PageHeader is always placed at the top
// of the page, followed by our content.
Column {
id: column
width: page.width
//spacing: Theme.paddingLarge
UserViewModel {
id: mediaLibraryModel2
apiClient: ApiClient
}
MoreSection {
text: "Kijken hervatten"
enabled: false
}
MoreSection {
text: "Volgende"
}
UserViewModel {
id: mediaLibraryModel
apiClient: ApiClient
}
Repeater {
model: mediaLibraryModel
MoreSection {
text: model.name
busy: userItemModel.status != ApiModel.Ready
SilicaListView {
clip: true
height: count > 0 ? Screen.width / 4 : 0
Behavior on height {
NumberAnimation { duration: 300 }
}
width: parent.width
model: userItemModel
orientation: ListView.Horizontal
delegate: LibraryItemDelegate {
property string id: model.id
title: model.name
poster: ApiClient.baseUrl + "/Items/" + model.id + "/Images/Primary?maxHeight=" + height + "&tag=" + model.imageTags["Primary"]
landscape: true
onClicked: {
pageStack.push(Qt.resolvedUrl("DetailBasePage.qml"), {"itemId": model.id})
}
}
HorizontalScrollDecorator {}
UserItemModel {
id: userItemModel
apiClient: ApiClient
parentId: model.id
limit: 12
}
Connections {
target: mediaLibraryModel
onStatusChanged: {
if (status == ApiModel.Ready) {
userItemModel.reload()
}
}
}
}
}
}
}
}
function loadModels() {
_modelsLoaded = true;
mediaLibraryModel.reload()
}
}

30
qml/pages/SecondPage.qml Normal file
View File

@ -0,0 +1,30 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
Page {
id: page
// The effective value will be restricted by ApplicationWindow.allowedOrientations
allowedOrientations: Orientation.All
SilicaListView {
id: listView
model: 20
anchors.fill: parent
header: PageHeader {
title: qsTr("Nested Page")
}
delegate: BackgroundItem {
id: delegate
Label {
x: Theme.horizontalPageMargin
text: qsTr("Item") + " " + index
anchors.verticalCenter: parent.verticalCenter
color: delegate.highlighted ? Theme.highlightColor : Theme.primaryColor
}
onClicked: console.log("Clicked " + index)
}
VerticalScrollDecorator {}
}
}

View File

@ -0,0 +1,18 @@
# Rename this file as harbour-sailfin.changes to include changelog
# entries in your RPM file.
#
# Add new changelog entries following the format below.
# Add newest entries to the top of the list.
# Separate entries from eachother with a blank line.
#
# Alternatively, if your changelog is automatically generated (e.g. with
# the git-change-log command provided with Sailfish OS SDK), create a
# harbour-sailfin.changes.run script to let mb2 run the required commands for you.
# * date Author's Name <author's email> version-release
# - Summary of changes
* Sun Apr 13 2014 Jack Tar <jack.tar@example.com> 0.0.1-1
- Scrubbed the deck
- Hoisted the sails

View File

@ -0,0 +1,25 @@
#!/bin/bash
#
# Rename this file as harbour-sailfin.changes.run to let mb2 automatically
# generate changelog from well formatted Git commit messages and tag
# annotations.
git-change-log
# Here are some basic examples how to change from the default behavior. Run
# git-change-log --help inside the Sailfish OS SDK chroot or build engine to
# learn all the options git-change-log accepts.
# Use a subset of tags
#git-change-log --tags refs/tags/my-prefix/*
# Group entries by minor revision, suppress headlines for patch-level revisions
#git-change-log --dense '/[0-9]+.[0-9+$'
# Trim very old changes
#git-change-log --since 2014-04-01
#echo '[ Some changelog entries trimmed for brevity ]'
# Use the subjects (first lines) of tag annotations when no entry would be
# included for a revision otherwise
#git-change-log --auto-add-annotations

42
rpm/harbour-sailfin.yaml Normal file
View File

@ -0,0 +1,42 @@
Name: harbour-sailfin
Summary: Sailfin
Version: 0.1
Release: 1
# The contents of the Group field should be one of the groups listed here:
# https://github.com/mer-tools/spectacle/blob/master/data/GROUPS
Group: Qt/Qt
URL: https://chris.netsoj.nl/projects/harbour-sailfin
License: LICENSE
# This must be generated before uploading a package to a remote build service.
# Usually this line does not need to be modified.
Sources:
- '%{name}-%{version}.tar.bz2'
Description: |
Play video's and music from your Jellyfin media player on your Sailfish device
Builder: qtc5
# This section specifies build dependencies that are resolved using pkgconfig.
# This is the preferred way of specifying build dependencies for your package.
PkgConfigBR:
- sailfishapp >= 1.0.2
- Qt5Core
- Qt5Qml
- Qt5Quick
# Build dependencies without a pkgconfig setup can be listed here
# PkgBR:
# - package-needed-to-build
# Runtime dependencies which are not automatically detected
Requires:
- sailfishsilica-qt5 >= 0.10.9
# All installed files
Files:
- '%{_bindir}'
- '%{_datadir}/%{name}'
- '%{_datadir}/applications/%{name}.desktop'
- '%{_datadir}/icons/hicolor/*/apps/%{name}.png'
# For more information about yaml and what's supported in Sailfish OS
# build system, please see https://wiki.merproject.org/wiki/Spectacle

58
src/credentialmanager.cpp Normal file
View File

@ -0,0 +1,58 @@
#include "credentialmanager.h"
CredentialsManager * CredentialsManager::getInstance(QObject *parent) {
return new FallbackCredentialsManager(parent);
}
////////////////////////////////////////////////////////////////////////////////////////////////////
// FallbackCredentialsManager //
////////////////////////////////////////////////////////////////////////////////////////////////////
FallbackCredentialsManager::FallbackCredentialsManager(QObject *parent)
: CredentialsManager (parent) {
m_settings.beginGroup("Credentials");
}
QString FallbackCredentialsManager::urlToGroupName(const QString &url) const {
// |'s are not allowed in URLS, but are in group names.
return QString::number(qHash(url), 16);
}
QString FallbackCredentialsManager::groupNameToUrl(const QString &group) const {
QString tmp = QString(group);
return tmp.replace('|', "/");
}
void FallbackCredentialsManager::store(const QString &server, const QString &user, const QString &token) {
m_settings.setValue(urlToGroupName(server) + "/users/" + user + "/accessToken", token);
m_settings.setValue(urlToGroupName(server) + "/address", server);
}
void FallbackCredentialsManager::get(const QString &server, const QString &user) const {
QString result = m_settings.value(urlToGroupName(server) + "/users/" + user + "/accessToken").toString();
emit CredentialsManager::tokenRetrieved(server, user, result);
}
void FallbackCredentialsManager::remove(const QString &server, const QString &user) {
m_settings.remove(urlToGroupName(server) + "/" + user);
}
void FallbackCredentialsManager::listServers() const {
QList<QString> keys = m_settings.childGroups();
qDebug() << "Servers: " << keys;
for (int i = 0; i < keys.size(); i++) {
keys[i] = m_settings.value(keys[i] + "/address").toString();
}
qDebug() << "Servers: " << keys;
emit CredentialsManager::serversListed(keys);
}
void FallbackCredentialsManager::listUsers(const QString &server) {
m_settings.beginGroup(urlToGroupName(server));
m_settings.beginGroup("users");
QStringList users = m_settings.childGroups();
qDebug() << "Users: " << users;
m_settings.endGroup();
m_settings.endGroup();
emit CredentialsManager::usersListed(users);
}

102
src/credentialmanager.h Normal file
View File

@ -0,0 +1,102 @@
#ifndef CREDENTIALS_MANAGER_H
#define CREDENTIALS_MANAGER_H
#include <QDebug>
#include <QHash>
#include <QObject>
#include <QSettings>
#include <QString>
class CredentialsManager : public QObject {
Q_OBJECT
public:
/**
* @brief Stores a token
* @param server The server to store the token for
* @param user The user to store the token for.
* @param token The token to store.
*/
virtual void store(const QString &server, const QString &user, const QString &token) {
Q_UNUSED(server)
Q_UNUSED(user)
Q_UNUSED(token)
Q_UNIMPLEMENTED();
}
/**
* @brief Retrieves a stored token. Emits tokenRetrieved when the token is retrieved.
* @param server The serverId to retrieve the token from.
* @param user The user to retrieve the token for
*/
virtual void get(const QString &server, const QString &user) const {
Q_UNUSED(server)
Q_UNUSED(user)
Q_UNIMPLEMENTED();
}
/**
* @brief removes a token
* @param server
* @param user
*/
virtual void remove(const QString &server, const QString &user) {
Q_UNUSED(server)
Q_UNUSED(user)
Q_UNIMPLEMENTED();
}
/**
* @brief Gives the list of servers that have a user stored with a token.
*/
virtual void listServers() const { Q_UNIMPLEMENTED(); }
/**
* @brief List the users with a token on a server
* @param server
*/
virtual void listUsers(const QString &server) {
Q_UNUSED(server)
Q_UNIMPLEMENTED();
}
/**
* @brief Retrieves an implementation which can store this token.
* @param The parent to set the implementations QObject parent to
* @return An implementation of this interface (may vary acrros platform).
*/
static CredentialsManager *getInstance(QObject *parent = nullptr);
/**
* @return if the implementation of this interface stores the token in a secure place.
*/
virtual bool isSecure() const { return false; }
signals:
void tokenRetrieved(const QString &server, const QString &user, const QString &token) const;
void serversListed(const QStringList &servers) const;
void usersListed(const QStringList &users) const;
protected:
explicit CredentialsManager(QObject *parent = nullptr) : QObject (parent) {}
};
/**
* @brief Implementation of CredentialsManager that stores credentials in plain-text
*/
class FallbackCredentialsManager : public CredentialsManager {
Q_OBJECT
public:
FallbackCredentialsManager(QObject *parent = nullptr);
void store(const QString &server, const QString &user, const QString &token) override;
void get(const QString &server, const QString &user) const override;
void remove(const QString &server, const QString &user) override;
void listServers() const override;
void listUsers(const QString &server) override;
bool isSecure() const override { return false; }
private:
QString urlToGroupName(const QString &url) const;
QString groupNameToUrl(const QString &group) const;
QSettings m_settings;
};
#endif

48
src/harbour-sailfin.cpp Normal file
View File

@ -0,0 +1,48 @@
#ifdef QT_QML_DEBUG
#include <QtQuick>
#endif
#include <QJSEngine>
#include <QGuiApplication>
#include <QQuickView>
#include <QQmlEngine>
#include <sailfishapp.h>
#include "jellyfinapiclient.h"
#include "jellyfinapimodel.h"
#include "serverdiscoverymodel.h"
void registerQml() {
const char* QML_NAMESPACE = "nl.netsoj.chris.Jellyfin";
qmlRegisterSingletonType<JellyfinApiClient>(QML_NAMESPACE, 1, 0, "ApiClient", [](QQmlEngine *eng, QJSEngine *js) {
Q_UNUSED(eng)
Q_UNUSED(js)
return dynamic_cast<QObject*>(new JellyfinApiClient());
});
qmlRegisterType<ServerDiscoveryModel>(QML_NAMESPACE, 1, 0, "ServerDiscoveryModel");
// API models
Jellyfin::registerModels(QML_NAMESPACE);
}
int main(int argc, char *argv[]) {
// SailfishApp::main() will display "qml/harbour-sailfin.qml", if you need more
// control over initialization, you can use:
//
// - SailfishApp::application(int, char *[]) to get the QGuiApplication *
// - SailfishApp::createView() to get a new QQuickView * instance
// - SailfishApp::pathTo(QString) to get a QUrl to a resource file
// - SailfishApp::pathToMainQml() to get a QUrl to the main QML file
//
// To display the view, call "show()" (will show fullscreen on device).
QGuiApplication *app = SailfishApp::application(argc, argv);
registerQml();
QQuickView *view = SailfishApp::createView();
view->setSource(SailfishApp::pathToMainQml());
view->show();
return app->exec();
}

214
src/jellyfinapiclient.cpp Normal file
View File

@ -0,0 +1,214 @@
#include "jellyfinapiclient.h"
#define STR2(x) #x
#define STR(x) STR2(x)
JellyfinApiClient::JellyfinApiClient(QObject *parent)
: QObject(parent) {
m_deviceName = QHostInfo::localHostName();
m_deviceId = QUuid::createUuid().toString();
m_credManager = CredentialsManager::getInstance(this);
}
////////////////////////////////////////////////////////////////////////////////////////////////////
// BASE HTTP METHODS //
////////////////////////////////////////////////////////////////////////////////////////////////////
void JellyfinApiClient::addBaseRequestHeaders(QNetworkRequest &request, const QString &path, const QUrlQuery &params) {
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 &params) {
QNetworkRequest req;
addBaseRequestHeaders(req, path, params);
return m_naManager.get(req);
}
QNetworkReply *JellyfinApiClient::post(const QString &path, const QJsonDocument &data) {
QNetworkRequest req;
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
addBaseRequestHeaders(req, path);
if (data.isEmpty())
return m_naManager.post(req, QByteArray());
else {
return m_naManager.post(req, data.toJson(QJsonDocument::Compact));
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////
// Nice to have methods //
////////////////////////////////////////////////////////////////////////////////////////////////////
void JellyfinApiClient::initialize(){
QObject *ctx1 = new QObject(this);
connect(m_credManager, &CredentialsManager::serversListed, ctx1, [this, ctx1](const QStringList &servers) {
qDebug() << "Servers listed: " << servers;
if (servers.size() == 0) {
emit this->setupRequired();
return;
}
//FIXME: support multiple servers
QString server = servers[0];
this->m_baseUrl = server;
qDebug() << "Server: " << server;
QObject *ctx2 = new QObject(this);
connect(m_credManager, &CredentialsManager::usersListed, ctx2, [this, server, ctx2](const QStringList &users) {
if (users.size() == 0) {
emit this->setupRequired();
return;
}
//FIXME: support multiple users
QString user = users[0];
qDebug() << "User: " << user;
QObject *ctx3 = new QObject(this);
connect(m_credManager, &CredentialsManager::tokenRetrieved, ctx3, [this, ctx3]
(const QString &server, const QString &user, const QString &token) {
Q_UNUSED(server)
this->m_token = token;
this->setUserId(user);
this->setAuthenticated(true);
disconnect(ctx3);
}, Qt::UniqueConnection);
m_credManager->get(server, user);
delete ctx2;
}, Qt::UniqueConnection);
m_credManager->listUsers(server);
qDebug() << "Listing users";
delete ctx1;
}, Qt::UniqueConnection);
qDebug() << "Listing servers";
m_credManager->listServers();
}
void JellyfinApiClient::setupConnection() {
// First detect redirects:
// Note that this is done without calling JellyfinApiClient::get since that automatically includes the base_url,
// which is something we want to avoid here.
QNetworkReply *rep = m_naManager.get(QNetworkRequest(m_baseUrl));
connect(rep, &QNetworkReply::finished, this, [rep, this](){
int status = statusCode(rep);
qDebug() << status;
// Check if redirect
if (status >= 300 && status < 400) {
QString location = QString::fromUtf8(rep->rawHeader("location"));
qInfo() << "Redirect from " << this->m_baseUrl << " to " << location;
QUrl base = QUrl(m_baseUrl);
QString newUrl = base.resolved(QUrl(location)).toString();
// If the url wants to redirect us to their web interface, we have to chop the last part of.
if (newUrl.endsWith("/web/index.html")) {
newUrl.chop(QString("/web/index.html").size());
this->setBaseUrl(newUrl);
getBrandingConfiguration();
} else {
this->setBaseUrl(newUrl);
setupConnection();
}
} else {
getBrandingConfiguration();
}
rep->deleteLater();
});
connect(rep, static_cast<void (QNetworkReply::*)(QNetworkReply::NetworkError)>(&QNetworkReply::error),
this, [rep, this](QNetworkReply::NetworkError error) {
qDebug() << "Error from URL: " << rep->url();
emit this->networkError(error);
rep->deleteLater();
});
}
void JellyfinApiClient::getBrandingConfiguration() {
QNetworkReply *rep = get("/Branding/Configuration");
connect(rep, &QNetworkReply::finished, this, [rep, this]() {
qDebug() << "RESPONSE: " << statusCode(rep);
switch(statusCode(rep)) {
case 200:
QJsonDocument response = QJsonDocument::fromJson(rep->readAll());
if (response.isNull() || !response.isObject()) {
emit this->connectionFailed(ApiError::JSON_ERROR);
} else {
QJsonObject obj = response.object();
if (obj.contains("LoginDisclaimer")) {
qDebug() << "Login disclaimer: " << obj["LoginDisclaimer"];
emit this->connectionSuccess(obj["LoginDisclaimer"].toString());
} else {
emit this->connectionSuccess("");
}
}
break;
}
rep->deleteLater();
});
connect(rep, static_cast<void (QNetworkReply::*)(QNetworkReply::NetworkError)>(&QNetworkReply::error),
this, [rep, this](QNetworkReply::NetworkError error) {
emit this->networkError(error);
rep->deleteLater();
});
}
void JellyfinApiClient::authenticate(QString username, QString password, bool storeCredentials) {
QJsonObject requestData;
requestData["Username"] = username;
requestData["Pw"] = password;
QNetworkReply *rep = post("/Users/Authenticatebyname", QJsonDocument(requestData));
connect(rep, &QNetworkReply::finished, this, [rep, username, storeCredentials, this]() {
int status = rep->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
qDebug() << "Got reply with status code " << status;
if (status >= 200 && status < 300) {
QJsonObject authInfo = QJsonDocument::fromJson(rep->readAll()).object();
this->m_token = authInfo["AccessToken"].toString();
this->setAuthenticated(true);
this->setUserId(authInfo["User"].toObject()["Id"].toString());
if (storeCredentials) {
m_credManager->store(this->m_baseUrl, this->m_userId, this->m_token);
}
}
rep->deleteLater();
});
connect(rep, static_cast<void (QNetworkReply::*)(QNetworkReply::NetworkError)>(&QNetworkReply::error),
this, &JellyfinApiClient::defaultNetworkErrorHandler);
}
void JellyfinApiClient::fetchItem(const QString &id) {
QNetworkReply *rep = get("/Users/" + m_userId + "/Items/" + id);
connect(rep, &QNetworkReply::finished, this, [rep, id, this]() {
int status = statusCode(rep);
if (status >= 200 && status < 300) {
QJsonObject data = QJsonDocument::fromJson(rep->readAll()).object();
emit this->itemFetched(id, data);
}
rep->deleteLater();
});
}
void JellyfinApiClient::defaultNetworkErrorHandler(QNetworkReply::NetworkError error) {
QObject *signalSender = sender();
QNetworkReply *rep = dynamic_cast<QNetworkReply *>(signalSender);
if (rep != nullptr && statusCode(rep) == 401) {
emit this->authenticationError(ApiError::INVALID_PASSWORD);
} else {
emit this->networkError(error);
}
rep->deleteLater();
}
#undef STR
#undef STR2

136
src/jellyfinapiclient.h Normal file
View File

@ -0,0 +1,136 @@
#ifndef JELLYFIN_API_CLIENT
#define JELLYFIN_API_CLIENT
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValue>
#include <QObject>
#include <QString>
#include <QtQml>
#include <QUuid>
#include <QNetworkReply>
#include <QUrlQuery>
#include "credentialmanager.h"
class JellyfinApiClient : public QObject {
Q_OBJECT
public:
explicit JellyfinApiClient(QObject *parent = nullptr);
Q_PROPERTY(QString baseUrl MEMBER m_baseUrl NOTIFY baseUrlChanged)
Q_PROPERTY(bool authenticated READ authenticated WRITE setAuthenticated NOTIFY authenticatedChanged)
Q_PROPERTY(QString userId READ userId NOTIFY userIdChanged)
/*QNetworkReply *handleRequest(QString path, QStringList sort, Pagination *pagination,
QVariantMap filters, QStringList fields, QStringList expand, QString id);*/
bool authenticated() const { return m_authenticated; }
void setBaseUrl(QString url) {
this->m_baseUrl = url;
if (this->m_baseUrl.endsWith("/")) {
this->m_baseUrl.chop(1);
}
emit this->baseUrlChanged(m_baseUrl);
}
QNetworkReply *get(const QString &path, const QUrlQuery &params = 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 &params = QUrlQuery());
/**
* @brief getBrandingConfiguration Gets the login message and custom CSS (which we ignore)
*/
void getBrandingConfiguration();
private:
CredentialsManager * m_credManager;
QString m_token;
QString m_deviceName;
QString m_deviceId;
QString m_userId = "";
void setAuthenticated(bool authenticated) {
this->m_authenticated = authenticated;
emit authenticatedChanged(authenticated);
}
void setUserId(QString userId) {
this->m_userId = userId;
emit userIdChanged(userId);
}
bool m_authenticated = false;
QString m_baseUrl;
QNetworkAccessManager m_naManager;
static inline int statusCode(QNetworkReply *rep) {
return rep->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
}
};
#endif // JELLYFIN_API_CLIENT

115
src/jellyfinapimodel.cpp Normal file
View File

@ -0,0 +1,115 @@
#include "jellyfinapimodel.h"
namespace Jellyfin {
ApiModel::ApiModel(QString path, QString subfield, QObject *parent)
: QAbstractListModel (parent),
m_path(path),
m_subfield(subfield) {
}
void ApiModel::reload() {
this->setStatus(Loading);
if (m_apiClient == nullptr) {
qWarning() << "Please set the apiClient property before (re)loading";
return;
}
if (m_path.contains(":user")) {
qDebug() << "Path contains :user, replacing with" << m_apiClient->userId();
m_path = m_path.replace(":user", m_apiClient->userId());
}
QUrlQuery query;
if (m_limit >= 0) {
query.addQueryItem("Limit", QString::number(m_limit));
}
if (!m_parentId.isEmpty()) {
query.addQueryItem("ParentId", m_parentId);
}
if (m_sortBy.empty()) {
query.addQueryItem("SortBy", enumListToString(m_sortBy));
}
QNetworkReply *rep = m_apiClient->get(m_path, query);
connect(rep, &QNetworkReply::finished, this, [this, rep]() {
QJsonDocument doc = QJsonDocument::fromJson(rep->readAll());
if (m_subfield.trimmed().isEmpty()) {
if (!doc.isArray()) {
qWarning() << "Object is not an array!";
this->setStatus(Error);
return;
}
this->m_array = doc.array();
} else {
if (!doc.isObject()) {
qWarning() << "Object is not an object!";
this->setStatus(Error);
return;
}
QJsonObject obj = doc.object();
if (!obj.contains(m_subfield)) {
qWarning() << "Object doesn't contain required subfield!";
this->setStatus(Error);
return;
}
if (!obj[m_subfield].isArray()) {
qWarning() << "Object's subfield is not an array!";
this->setStatus(Error);
return;
}
this->m_array = obj[m_subfield].toArray();
}
generateFields();
this->setStatus(Ready);
rep->deleteLater();
});
}
void ApiModel::generateFields() {
if (m_array.size() == 0) return;
this->beginResetModel();
m_roles.clear();
int i = Qt::UserRole + 1;
if (!m_array[0].isObject()) {
qWarning() << "Iterator is not an object?";
return;
}
// Walks over the keys in the first record and adds them to the rolenames.
// This assumes the back-end has the same keys for every record. I could technically
// go over all records to be really sure, but no-one got time for a O(n²) algorithm, so
// this heuristic hopefully suffices.
QJsonObject ob = m_array[0].toObject();
for (auto jt = ob.begin(); jt != ob.end(); jt++) {
QString keyName = jt.key();
keyName[0] = keyName[0].toLower();
QByteArray keyArr = keyName.toUtf8();
if (!m_roles.values().contains(keyArr)) {
m_roles.insert(i++, keyArr);
qDebug() << m_path << " adding " << keyName << " as " << ( i - 1);
}
}
this->endResetModel();
}
QVariant ApiModel::data(const QModelIndex &index, int role) const {
// Ignore roles we don't know
if (role <= Qt::UserRole || role >= Qt::UserRole + m_roles.size()) return QVariant();
// Ignore invalid indices.
if (!index.isValid()) return QVariant();
QJsonObject obj = m_array.at(index.row()).toObject();
QString key = m_roles[role];
key[0] = key[0].toUpper();
if (obj.contains(key)) {
return obj[key].toVariant();
}
return QVariant();
}
void registerModels(const char *URI) {
qmlRegisterUncreatableType<ApiModel>(URI, 1, 0, "ApiModel", "Is enum and base class");
qmlRegisterUncreatableType<SortOrder>(URI, 1, 0, "SortOrder", "Is enum");
qmlRegisterType<PublicUserModel>(URI, 1, 0, "PublicUserModel");
qmlRegisterType<UserViewModel>(URI, 1, 0, "UserViewModel");
qmlRegisterType<UserItemModel>(URI, 1, 0, "UserItemModel");
}
}

200
src/jellyfinapimodel.h Normal file
View File

@ -0,0 +1,200 @@
#ifndef JELLYFIN_API_MODEL
#define JELLYFIN_API_MODEL
#include <QAbstractListModel>
#include <QFlags>
#include <QMetaEnum>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QVariant>
#include "jellyfinapiclient.h"
namespace Jellyfin {
class SortOrder {
Q_GADGET
public:
enum SortBy {
Album,
AlbumArtist,
Artist,
Budget,
CommunityRating,
CriticRating,
DateCreated,
DatePlayed,
PlayCount,
PremiereDate,
ProductionYear,
SortName,
Random,
Revenue,
Runtime
};
Q_ENUM(SortBy)
};
/**
* @brief Abstract model for displaying a REST JSON collection. Role names will be based on the fields encountered in the
* first record.
*
* To create a new model, extend this class and create an QObject-parent constructor.
* Call the right super constructor with the right values, depending which path should be queried and
* how the result should be interpreted.
*
* Register the model in QML and create an instance. Don't forget to set the apiClient attribute or else
* the model you've created will be useless!
*
* Rolenames are based on the fields in the first object within the array of results, with the first letter
* lowercased, to accomodate for QML style guidelines. (This ain't C# here).
*
* If a call to /cats/new results in
* @code{.json}
* [
* {"Name": "meow", "Id": 432},
* {"Name": "miew", "Id": 323}
* ]
* @endcode
* The model will have roleNames for "name" and "id".
*
*/
class ApiModel : public QAbstractListModel {
Q_OBJECT
public:
enum ModelStatus {
Uninitialised,
Loading,
Ready,
Error
};
Q_ENUM(ModelStatus)
enum MediaType {
MediaUnspecified,
Series
};
Q_DECLARE_FLAGS(MediaTypes, MediaType)
Q_FLAG(MediaTypes)
/**
* @brief Creates a new basemodel
* @param path The path (relative to the baseUrl of JellyfinApiClient) to make the call to.
* @param subfield Leave empty if the root of the result is the array with results. Otherwise, set to the key name in the
* root object which contains the data.
* @param parent Parent (Standard QObject stuff)
*
* If the response looks something like this:
* @code{.json}
* [{...}, {...}, {...}]
* @endcode
* subfield should be left empty
*
* If the response looks something like this:
* @code{.json}
* {
* "offset": 0,
* "count": 20,
* "data": [{...}, {...}, {...}, ..., {...}]
* }
* @endcode
* Subfield should be set to "data" in this example.
*/
explicit ApiModel(QString path, QString subfield, QObject *parent = nullptr);
Q_PROPERTY(JellyfinApiClient *apiClient MEMBER m_apiClient)
Q_PROPERTY(ModelStatus status READ status NOTIFY statusChanged)
Q_PROPERTY(int limit MEMBER m_limit NOTIFY limitChanged)
Q_PROPERTY(QString parentId MEMBER m_parentId NOTIFY parentIdChanged)
Q_PROPERTY(QList<SortOrder::SortBy> sortBy MEMBER m_sortBy NOTIFY sortByChanged)
//Q_PROPERTY(MediaTypes includeTypes MEMBER m_includeTypes NOTIFY includeTypesChanged)
int rowCount(const QModelIndex &index) const override {
if (!index.isValid()) return m_array.size();
return 0;
}
QHash<int, QByteArray> roleNames() const override { return m_roles; }
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
ModelStatus status() const { return m_status; }
template<typename QEnum>
QString enumToString (const QEnum anEnum) { return QVariant::fromValue(anEnum).toString(); }
template<typename QEnum>
QString enumListToString (const QList<QEnum> enumList) {
QString result;
for (QEnum e : enumList) {
result += QVariant::fromValue(e).toString() + ",";
}
return result;
}
signals:
void statusChanged(ModelStatus newStatus);
void limitChanged(int newLimit);
void parentIdChanged(QString newParentId);
void sortByChanged(SortOrder::SortBy newSortOrder);
void includeTypesChanged(MediaTypes newTypes);
public slots:
/**
* @brief (Re)loads the data into this model. This might make a network request.
*/
void reload();
protected:
JellyfinApiClient *m_apiClient = nullptr;
ModelStatus m_status = Uninitialised;
QString m_path;
QString m_subfield;
QJsonArray m_array;
// Query properties
int m_limit = -1;
QString m_parentId;
QList<SortOrder::SortBy> m_sortBy = {};
MediaTypes m_includeTypes = MediaUnspecified;
QHash<int, QByteArray> m_roles;
//QHash<QByteArray, int> m_reverseRoles;
void setStatus(ModelStatus newStatus) {
this->m_status = newStatus;
emit this->statusChanged(newStatus);
}
private:
/**
* @brief Generates roleNames based on the first record in m_array.
*/
void generateFields();
QString sortByToString(SortOrder::SortBy sortBy);
QString mediaTypeToString(MediaType mediaType);
};
/**
* @brief List of the public users on the server.
*/
class PublicUserModel : public ApiModel {
public:
explicit PublicUserModel (QObject *parent = nullptr)
: ApiModel ("/users/public", "", parent) { }
};
class UserViewModel : public ApiModel {
public:
explicit UserViewModel (QObject *parent = nullptr)
: ApiModel ("/Users/:user/Views", "Items", parent) {}
};
class UserItemModel : public ApiModel {
public:
explicit UserItemModel (QObject *parent = nullptr)
: ApiModel ("/Users/:user/Items", "Items", parent) {}
};
void registerModels(const char *URI);
Q_DECLARE_OPERATORS_FOR_FLAGS(ApiModel::MediaTypes)
}
#endif //JELLYFIN_API_MODEL

View File

@ -0,0 +1,73 @@
#include "serverdiscoverymodel.h"
ServerDiscoveryModel::ServerDiscoveryModel(QObject *parent)
: QAbstractListModel (parent) {
connect(&m_socket, &QUdpSocket::readyRead, this, &ServerDiscoveryModel::on_datagramsAvailable);
m_socket.bind(BROADCAST_PORT);
}
QVariant ServerDiscoveryModel::data(const QModelIndex &index, int role) const {
if (index.row() < 0 || index.row() >= rowCount()) return QVariant();
size_t row = static_cast<size_t>(index.row());
switch(role) {
case ROLE_ADDRESS:
return m_discoveredServers[row].address;
case ROLE_ID:
return m_discoveredServers[row].id;
case ROLE_NAME:
return m_discoveredServers[row].name;
default:
return QVariant();
}
}
void ServerDiscoveryModel::refresh() {
this->beginResetModel();
this->m_discoveredServers.clear();
this->endResetModel();
m_socket.writeDatagram(MAGIC_PACKET, QHostAddress::Broadcast, BROADCAST_PORT);
}
void ServerDiscoveryModel::on_datagramsAvailable() {
int beginIndex = static_cast<int>(m_discoveredServers.size());
QByteArray datagram;
QJsonDocument jsonDocument;
QJsonParseError jsonParseError;
QHostAddress replyAddress;
std::vector<ServerDiscovery> discoveredServers;
while (m_socket.hasPendingDatagrams()) {
datagram.resize(static_cast<int>(m_socket.pendingDatagramSize()));
m_socket.readDatagram(datagram.data(), datagram.size(), &replyAddress);
jsonDocument = QJsonDocument::fromJson(datagram, &jsonParseError);
// Check if parsing failed
if (jsonDocument.isNull()) {
qDebug() << "Invalid response from " << replyAddress.toString() << ": " << jsonParseError.errorString();
continue;
}
if (jsonDocument.isObject()) {
QJsonObject rootObject = jsonDocument.object();
if (rootObject.contains("Name") && rootObject.contains("Address") && rootObject.contains("Id")) {
// We (assume) we have a correct response! Add it to the back of our temporary vector with discovered servers
discoveredServers.push_back(ServerDiscovery {
rootObject["Name"].toString(),
rootObject["Address"].toString(),
rootObject["Id"].toString()
});
} else {
qDebug() << "Invalid response from " << replyAddress.toString() << ": does not contain Name, Address, or Id field";
}
} else {
qDebug() << "Invalid response from " << replyAddress.toString() << ": root is not an object";
}
}
beginInsertRows(QModelIndex(), beginIndex, beginIndex + static_cast<int>(discoveredServers.size()) - 1);
m_discoveredServers.insert(m_discoveredServers.end(), discoveredServers.begin(), discoveredServers.end());
endInsertRows();
};

View File

@ -0,0 +1,63 @@
#ifndef SERVER_DISCOVERY_MODEL_H
#define SERVER_DISCOVERY_MODEL_H
#include <vector>
#include <QAbstractListModel>
#include <QHash>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QHostAddress>
#include <QUdpSocket>
struct ServerDiscovery {
QString name;
QString address;
QString id;
};
/**
* @brief Discovers nearby Jellyfin servers and puts them in this list.
*/
class ServerDiscoveryModel : public QAbstractListModel {
Q_OBJECT
public:
enum Roles {
ROLE_NAME = Qt::UserRole + 1,
ROLE_ADDRESS,
ROLE_ID
};
explicit ServerDiscoveryModel(QObject *parent = nullptr);
QHash<int, QByteArray> roleNames() const override {
return {
{ROLE_NAME, "name"},
{ROLE_ADDRESS, "address"},
{ROLE_ID, "id"}
};
}
int rowCount(const QModelIndex &parent = QModelIndex()) const override {
if (parent.isValid()) return 0;
return static_cast<int>(m_discoveredServers.size());
}
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
public slots:
/**
* @brief Refreshes the model and searches for new servers
*/
void refresh();
private slots:
void on_datagramsAvailable();
private:
const QByteArray MAGIC_PACKET = "who is JellyfinServer?";
const quint16 BROADCAST_PORT = 7359;
QUdpSocket m_socket;
std::vector<ServerDiscovery> m_discoveredServers;
};
#endif //SERVER_DISCOVERY_MODEL_H

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.0">
<context>
<name>CoverPage</name>
<message>
<source>My Cover</source>
<translation>Mein Cover</translation>
</message>
</context>
<context>
<name>FirstPage</name>
<message>
<source>Show Page 2</source>
<translation>Zur Seite 2</translation>
</message>
<message>
<source>UI Template</source>
<translation>UI-Vorlage</translation>
</message>
<message>
<source>Hello Sailors</source>
<translation>Hallo Matrosen</translation>
</message>
</context>
<context>
<name>SecondPage</name>
<message>
<source>Nested Page</source>
<translation>Unterseite</translation>
</message>
<message>
<source>Item</source>
<translation>Element</translation>
</message>
</context>
</TS>

View File

@ -0,0 +1,128 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.1">
<context>
<name>AddServerConnectingPage</name>
<message>
<source>Connecting to %1</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>AddServerPage</name>
<message>
<source>Connect</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Connect to Jellyfin</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Server</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Sailfin will try to search for Jellyfin servers on your local network automatically</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>enter address manually</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>%1 - %2</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Server address</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>e.g. https://demo.jellyfin.org</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>CoverPage</name>
<message>
<source>My Cover</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>LegalPage</name>
<message>
<source>Legal</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>The Sailfin application contains some code from other projects. Without them, Sailfin would not be possible!</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>This program contains small snippets of code taken from &lt;a href=&quot;%1&quot;&gt;%2&lt;/a&gt;, which is licensed under the %3 license:</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>LoginDialog</name>
<message>
<source>Logging in as %1</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Login</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Credentials</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Username</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Password</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>password</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Login message</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>MainPage</name>
<message>
<source>Settings</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>About</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SecondPage</name>
<message>
<source>Nested Page</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Item</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>UserGridDelegate</name>
<message>
<source>Other account</source>
<translation type="unfinished"></translation>
</message>
</context>
</TS>