Compare commits

...

5 Commits

Author SHA1 Message Date
restitux 6ace97ff83 Add image caching logic for show posters 2022-09-13 22:57:43 -06:00
restitux 9234da67fe Remove unused Python code 2022-09-13 22:17:42 -06:00
restitux 3227730a37 Add config file for specifying backends 2022-09-13 22:16:51 -06:00
restitux afb7a456a1 Cleanup commented out lines 2022-09-13 21:36:57 -06:00
restitux a38221cca2 Add episode browser 2022-09-13 21:33:26 -06:00
12 changed files with 343 additions and 70 deletions
+123 -50
View File
@@ -1,30 +1,54 @@
from typing import Type from typing import Type
import os
import shutil
import sys import sys
from urllib import parse import urllib.parse
from collections import defaultdict
from PyQt5.QtWidgets import QApplication from PyQt5.QtWidgets import QApplication
from PyQt5.QtQml import qmlRegisterType, QQmlApplicationEngine from PyQt5.QtQml import qmlRegisterType, QQmlApplicationEngine
from PyQt5.QtQuick import QQuickImageProvider from PyQt5.QtQuick import QQuickImageProvider
# from PyQt5.QtQuick import QQuickView
from PyQt5.QtCore import QObject, pyqtProperty, pyqtSlot from PyQt5.QtCore import QObject, pyqtProperty, pyqtSlot
from PyQt5.QtGui import QImage from PyQt5.QtGui import QImage
# from PyQt5.QtCore import QObject, QUrl, pyqtProperty
from .qtmpv import MpvObject from .qtmpv import MpvObject
import requests import requests
import toml
class Show(QObject): class Episode(QObject):
def __init__(self, source, parent=None): def __init__(self, source, parent=None):
super().__init__(parent) super().__init__(parent)
self._source = source self._source = source
@pyqtProperty("QString", constant=True)
def title(self):
return self._source["Title"]
@pyqtProperty(int, constant=True)
def season(self):
return self._source["Season"]
@pyqtProperty(int, constant=True)
def episode(self):
return self._source["Episode"]
@pyqtProperty("QString", constant=True)
def filename(self):
return self._source["OriginalFilename"]
class Show(QObject):
def __init__(self, source, episodes, parent=None):
super().__init__(parent)
self._source = source
self._episodes = episodes
@pyqtProperty("QString", constant=True) @pyqtProperty("QString", constant=True)
def title(self) -> str: def title(self) -> str:
return self._source["title"] return self._source["title"]
@@ -37,10 +61,6 @@ class Show(QObject):
def description(self) -> str: def description(self) -> str:
return self._source["description"] return self._source["description"]
@pyqtProperty(int, constant=True)
def episodes(self) -> int:
return self._source["episodes"]
@pyqtProperty(int, constant=True) @pyqtProperty(int, constant=True)
def watched(self) -> int: def watched(self) -> int:
return self._source["watched"] return self._source["watched"]
@@ -49,6 +69,10 @@ class Show(QObject):
def poster(self) -> str: def poster(self) -> str:
return self._source["poster"] return self._source["poster"]
@pyqtProperty(list, constant=True)
def episodes(self) -> list[Episode]:
return self._episodes
class ProviderImageProvider(QQuickImageProvider): class ProviderImageProvider(QQuickImageProvider):
def __init__(self, icon_data): def __init__(self, icon_data):
@@ -60,26 +84,21 @@ class ProviderImageProvider(QQuickImageProvider):
print(p_str) print(p_str)
print(size) print(size)
# img = QImage.fromData(self._icon_data.encode("utf-8"))
import base64 import base64
data: bytes = base64.b64decode(self._icon_data) data: bytes = base64.b64decode(self._icon_data)
img = QImage.fromData(data) img = QImage.fromData(data)
# img = QImage.fromData(self._icon_data.encode("utf-8"))
# img = QImage(300, 300, QImage.Format_RGBA8888)
# img.fill(Qt.red)
return img, img.size() return img, img.size()
def getUrl(base: str, path: str) -> dict: def getUrl(base: str, path: str) -> dict:
url: str = parse.urljoin(base, path) url: str = urllib.parse.urljoin(base, path)
r: requests.Response = requests.get(url) r: requests.Response = requests.get(url)
return r.json() return r.json()
class Provider(QObject): class Provider(QObject):
def __init__(self, url: str, parent=None): def __init__(self, url: str, data_dir: str, parent=None):
super().__init__(parent) super().__init__(parent)
self.url: str = url self.url: str = url
@@ -89,8 +108,47 @@ class Provider(QObject):
describe["icon"] describe["icon"]
) )
# Create dictionary of show episodes
episodes: dict = getUrl(self.url, "episodes")
_episodes: defaultdict[str, list[Episode]] = defaultdict(lambda: [])
for e in episodes["episodes"]:
_episodes[e["ShowTitle"]].append(Episode(e))
shows: dict = getUrl(self.url, "shows") shows: dict = getUrl(self.url, "shows")
self._shows: dict[int, Show] = {e["id"]: Show(e) for e in shows["data"]}
# Create image_cache directory
provider_data_dir: str = os.path.join(
data_dir, "image_cache", "providers", self._name
)
os.makedirs(provider_data_dir, exist_ok=True)
# Cache show posters
for s in shows["data"]:
# Get local cache path
poster_url: str = s["poster"]
poster_path: str = urllib.parse.urlparse(poster_url).path
poster_ext: str = os.path.splitext(poster_path)[1]
download_path: str = os.path.join(
provider_data_dir, str(s["id"]) + poster_ext
)
# Download poster
try:
with open(download_path, "xb") as f:
r = requests.get(poster_url, stream=True)
if r.status_code == 200:
r.raw.decode_content = True
shutil.copyfileobj(r.raw, f)
except FileExistsError:
pass
# Overwrite poster URL with local path
s["poster"] = download_path
self._shows: dict[int, Show] = {
e["id"]: Show(e, _episodes[e["title"]]) for e in shows["data"]
}
recently_added: dict = getUrl(self.url, "recently_added") recently_added: dict = getUrl(self.url, "recently_added")
self._recently_added: list[int] = recently_added["data"] self._recently_added: list[int] = recently_added["data"]
@@ -106,7 +164,6 @@ class Provider(QObject):
def logo(self) -> str: def logo(self) -> str:
return f"image://{self._name}/logo" return f"image://{self._name}/logo"
# @pyqtProperty("QObject")
@pyqtSlot(int, result=QObject) @pyqtSlot(int, result=QObject)
def getShow(self, id) -> Show: def getShow(self, id) -> Show:
return self._shows[id] return self._shows[id]
@@ -131,8 +188,8 @@ class Provider(QObject):
class DataSource: class DataSource:
def __init__(self, providers=[]): def __init__(self, providers: list[str], data_dir: str):
self.providers: list[Provider] = [Provider(url) for url in providers] self.providers: list[Provider] = [Provider(url, data_dir) for url in providers]
def DatabaseType(data_source) -> Type: def DatabaseType(data_source) -> Type:
@@ -149,6 +206,42 @@ def DatabaseType(data_source) -> Type:
return Database return Database
def load_config() -> list[str]:
try:
config_dir: str = os.path.join(os.environ["XDG_CONFIG_HOME"], "ikinuki")
except:
config_dir: str = os.path.join(os.environ["HOME"], ".config", "ikinuki")
os.makedirs(config_dir, exist_ok=True)
config_file: str = os.path.join(config_dir, "client.toml")
try:
config: dict = toml.load(config_file)
except FileNotFoundError:
print(f'Config file not found at "{config_file}"')
print("Writing example config file. Please update and relaunch.")
default_config = """# [[backends]]
# address = "127.0.0.1"
# port = 32520"""
with open(config_file, "w") as f:
f.write(default_config)
sys.exit(-1)
return [f'http://{b["address"]}:{b["port"]}/' for b in config["backends"]]
def get_data_dir() -> str:
try:
data_dir: str = os.path.join(os.environ["XDG_DATA_HOME"], "ikinuki")
except:
data_dir: str = os.path.join(os.environ["HOME"], ".local", "share", "ikinuki")
os.makedirs(data_dir, exist_ok=True)
return data_dir
def main(): def main():
app = QApplication(sys.argv) app = QApplication(sys.argv)
@@ -157,16 +250,17 @@ def main():
locale.setlocale(locale.LC_NUMERIC, "C") locale.setlocale(locale.LC_NUMERIC, "C")
data_source = DataSource( try:
[ backends: list[str] = load_config()
"http://127.0.0.1:8080/a/", except Exception as e:
"http://127.0.0.1:8080/b/", print(f"ERROR: Could not load config file: {repr(e)}")
"http://127.0.0.1:8080/c/", sys.exit(-1)
]
) data_dir: str = get_data_dir()
data_source = DataSource(backends, data_dir)
qmlRegisterType(DatabaseType(data_source), "Ikinuki.Client", 1, 0, "Database") qmlRegisterType(DatabaseType(data_source), "Ikinuki.Client", 1, 0, "Database")
# qmlRegisterType(Provider, "Ikinuki.Client", 1, 0, "Provider")
engine = QQmlApplicationEngine() engine = QQmlApplicationEngine()
for provider in data_source.providers: for provider in data_source.providers:
@@ -182,24 +276,3 @@ def main():
win.show() win.show()
sys.exit(app.exec_()) sys.exit(app.exec_())
#
#
#
# app = QApplication([])
#
#
# window = QQmlApplicationEngine("layouts/mpv.qml")
# window.run
#
# view = QQuickView()
# url = QUrl("layouts/mpv.qml")
#
# import locale
#
# locale.setlocale(locale.LC_NUMERIC, 'C')
#
# view.setSource(url)
# view.show()
# app.exec_()
+1 -1
View File
@@ -18,7 +18,7 @@ Row {
ContentView { ContentView {
id: view id: view
viewSelected: selectedView == 1 viewSelected: selectedView == 1
currentIndex: selectedProvider + (browse ? db.Providers.length : 0) parentIndex: selectedProvider + (browse ? db.Providers.length : 0)
providers: db.Providers providers: db.Providers
} }
function mod(n, m) { function mod(n, m) {
+35 -7
View File
@@ -11,8 +11,14 @@ StackLayout {
id: tabView id: tabView
property var providers: [] property var providers: []
property bool viewSelected property bool viewSelected
state: viewSelected ? "selected" : "deselected" property int parentIndex
property int ySelect: 0 property int ySelect: 0
property bool showViewActive: false
currentIndex: showViewActive ? tabView.children.length - 3 : parentIndex
state: viewSelected ? "selected" : "deselected"
width: parent.width * viewSelected ? 0.95 : 0.8 width: parent.width * viewSelected ? 0.95 : 0.8
height: parent.height height: parent.height
@@ -28,6 +34,10 @@ StackLayout {
provider: modelData provider: modelData
} }
} }
ShowView {
id: showView
show: providers[0].getShow(providers[0].showsAlphabetic[0])
}
states: [ states: [
State { State {
name: "deselected" name: "deselected"
@@ -54,14 +64,32 @@ StackLayout {
} }
] ]
function getCurrentIndex() {
if (showViewActive) {
var x = tabView.children.length - 1;
} else {
var x = currentIndex > (providers.length - 1) ? currentIndex + 1 : currentIndex;
}
return x;
}
Keys.onPressed: (event)=> { Keys.onPressed: (event)=> {
var x = currentIndex > (providers.length - 1) ? currentIndex + 1 : currentIndex; var x = getCurrentIndex()
//tabView.children[currentIndex].Keys.pressed(event);
tabView.children[x].Keys.pressed(event); tabView.children[x].Keys.pressed(event);
if (tabView.children[x].viewExit) { if (tabView.children[x].enterShow) {
tabView.children[x].viewExit = false; showViewActive = true;
parent.browse = false; showView.show = tabView.children[x].enterShowShow;
parent.selectedView = 0; } else if (tabView.children[x].viewExit) {
if (showViewActive) {
showViewActive = false;
tabView.children[x].xIndex = 0;
tabView.children[x].viewExit = false;
tabView.children[getCurrentIndex()].enterShow = false;
} else {
tabView.children[x].viewExit = false;
parent.browse = false;
parent.selectedView = 0;
}
} }
event.accepted = true; event.accepted = true;
} }
@@ -0,0 +1,34 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import QtQuick.Controls 1.4
import QtQuick.Controls 2.15
import Ikinuki.Client 1.0
import "./ShowView"
ScrollView {
id: root
property var episodeModel
property int scrollIndex: 0
contentHeight: root.height * 0.1 * episodeModel.length
ScrollBar.vertical.position: scrollIndex / (episodeModel.length)
clip: true
Behavior on ScrollBar.vertical.position {
NumberAnimation {
duration: 200
easing.type: Easing.Linear
}
}
Column {
Repeater {
model: episodeModel
Episode {
episode: modelData
selected: index == xIndex
height: root.height * 0.1
width: root.width
}
}
}
}
@@ -11,6 +11,8 @@ Rectangle {
id: root id: root
property var provider property var provider
property bool viewExit: false property bool viewExit: false
property bool enterShow: false
property var enterShowShow
property int currentView: 0 property int currentView: 0
color: "#22282A" color: "#22282A"
Row { Row {
@@ -76,6 +78,9 @@ Rectangle {
coverGrid.scrollIndex--; coverGrid.scrollIndex--;
} }
} }
} else if (event.key == Qt.Key_Return) {
enterShowShow = root.provider.getShow(root.provider.showsAlphabetic[(coverGrid.xIndex + coverGrid.yIndex * coverGrid.numColumns)])
enterShow = true;
} }
} else if (currentView == 1) { // alphabet } else if (currentView == 1) { // alphabet
if (event.key == Qt.Key_Left) { if (event.key == Qt.Key_Left) {
@@ -11,8 +11,9 @@ Rectangle {
id: root id: root
property var provider property var provider
property int ySelect: 0 property int ySelect: 0
property int xSelect: 0
property bool viewExit: false property bool viewExit: false
property bool enterShow: false
property var enterShowShow
color: "#22282A" color: "#22282A"
Row { Row {
Item { Item {
@@ -82,6 +83,9 @@ Rectangle {
ySelect++; ySelect++;
} else if (event.key == Qt.Key_Up) { } else if (event.key == Qt.Key_Up) {
ySelect--; ySelect--;
} else if (event.key == Qt.Key_Return) {
enterShowShow = provider.getShow(elementColumn.children[ySelect].showId)
enterShow = true;
} }
event.accepted = true; event.accepted = true;
} }
@@ -0,0 +1,76 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import QtQuick.Controls 1.4
import QtQuick.Controls 2.15
import Ikinuki.Client 1.0
import "./ShowView"
Rectangle {
property var show
property bool viewExit: false
property bool enterShow: false
property int xIndex: 0
color: "#22282A"
Column {
anchors.fill: parent
anchors.leftMargin: parent.width * 0.01
anchors.rightMargin: parent.width * 0.01
anchors.topMargin: parent.height * 0.05
anchors.bottomMargin: parent.height * 0.01
Text { // header
height: parent.height * 0.1
width: parent.width
text: show.title
font.pointSize: 20
color: "#cdd7d9"
}
Row { // main view
height: parent.height * 0.8
width: parent.width
spacing: parent.width * 0.01
Item {
height: parent.height
width: height * 0.68
Image {
source: show.poster
anchors.fill: parent
mipmap: true
}
}
EpisodeView {
id: episodeView
height: parent.height
width: parent.width * 0.65
episodeModel: show.episodes
}
}
Item {
height: parent.height * 0.1
width: parent.width
}
}
Keys.onPressed: (event)=> {
if (event.key == Qt.Key_Up) {
if (xIndex > 0) {
xIndex--;
if (episodeView.scrollIndex > xIndex) {
episodeView.scrollIndex--;
}
}
} else if (event.key == Qt.Key_Down) {
if (xIndex < show.episodes.length - 1) {
if (episodeView.scrollIndex < (xIndex - 8)) {
episodeView.scrollIndex++;
}
xIndex++;
}
} else if (event.key == Qt.Key_Escape) {
viewExit = true;
}
event.accepted = true;
}
}
@@ -0,0 +1,49 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import QtQuick.Controls 1.4
import QtQuick.Controls 2.15
import Ikinuki.Client 1.0
Item {
property var episode
property bool selected
Rectangle {
anchors.fill: parent
color: "white"
visible: selected
radius: 10
}
Row {
anchors.fill: parent
Item {
height: parent.height
width: parent.width * 0.04
}
Column {
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 4
Text {
text: {
if (modelData.season == 0) {
return "S" + modelData.episode + " " + modelData.title
} else {
return String(modelData.episode).padStart(2, "0") + ". " + modelData.title
}
}
font.pointSize: 20
color: selected ? "#3c3c3c" : "#99afb4"
}
Item {
height: parent.height * 0.1
width: parent.width
}
Text {
text: " air date"
font.pointSize: 12
color: selected ? "#3c3c3c" : "#99afb4"
}
}
}
}
@@ -12,7 +12,6 @@ Item {
DropShadow { DropShadow {
anchors.fill: selector anchors.fill: selector
verticalOffset: 5 verticalOffset: 5
//horizontalOffset: 5
samples: 20 samples: 20
color: "black" color: "black"
opacity: 0.5 opacity: 0.5
@@ -24,7 +23,6 @@ Item {
color: "white" color: "white"
height: parent.height height: parent.height
width: parent.width * 0.95 width: parent.width * 0.95
//anchors.fill: parent
visible: selected && maximized visible: selected && maximized
radius: 10 radius: 10
} }
@@ -42,11 +40,6 @@ Item {
anchors.fill: parent anchors.fill: parent
id: logo id: logo
source: provider.logo source: provider.logo
//y: parent.y + (parent.height / 2) - height / 2
//anchors.top: parent.verticalCenter
// anchors.verticalCenter: text.verticalCenter
//sourceSize.height: 50
//sourceSize.width: 50
} }
ColorOverlay { ColorOverlay {
anchors.fill: logo anchors.fill: logo
@@ -66,8 +59,6 @@ Item {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 4 anchors.verticalCenterOffset: 4
visible: maximized visible: maximized
//verticalAlignment: Text.AlignVCenter
//height: parent.height
} }
} }
} }
Generated
+14 -2
View File
@@ -62,7 +62,7 @@ optional = false
python-versions = ">=3.5" python-versions = ">=3.5"
[package.extras] [package.extras]
screenshot_raw = ["pillow"] screenshot_raw = ["Pillow"]
[[package]] [[package]]
name = "requests" name = "requests"
@@ -82,6 +82,14 @@ urllib3 = ">=1.21.1,<1.27"
socks = ["PySocks (>=1.5.6,!=1.5.7)"] socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "toml"
version = "0.10.2"
description = "Python Library for Tom's Obvious, Minimal Language"
category = "main"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "1.26.12" version = "1.26.12"
@@ -98,7 +106,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "7e77956c19bb0148f37be8283857fbd29805184149d6f2c1027daab624c66b80" content-hash = "ab4b6be20253adf487e007f81b07f9f585bd4a61d4e51889f9356dfc0e03e206"
[metadata.files] [metadata.files]
certifi = [ certifi = [
@@ -152,6 +160,10 @@ requests = [
{file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"},
{file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"},
] ]
toml = [
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
]
urllib3 = [ urllib3 = [
{file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"}, {file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"},
{file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"}, {file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"},
+1
View File
@@ -10,6 +10,7 @@ python = "^3.10"
python-mpv = "^0.5.2" python-mpv = "^0.5.2"
PyQt5 = "^5.15.1" PyQt5 = "^5.15.1"
requests = "^2.28.1" requests = "^2.28.1"
toml = "^0.10.2"
[tool.poetry.scripts] [tool.poetry.scripts]