Compare commits
1 Commits
main
...
download-t
Author | SHA1 | Date | |
---|---|---|---|
|
8529c3b403 |
14
.env.example
@ -1,14 +0,0 @@
|
||||
ADDON_USERNAME=
|
||||
ADDON_PASSWORD=
|
||||
|
||||
KODI_HOME=tests/home
|
||||
KODI_INTERACTIVE=0
|
||||
KODI_STUB_VERBOSE=1
|
||||
KODI_STUB_RPC_RESPONSES=tests/rpc
|
||||
|
||||
#HTTP_PROXY=
|
||||
#HTTPS_PROXY=
|
||||
|
||||
GH_USERNAME=
|
||||
GH_TOKEN=
|
||||
EMAIL=
|
3
.gitattributes
vendored
@ -1,4 +1,3 @@
|
||||
.env.example export-ignore
|
||||
.github/ export-ignore
|
||||
tests/ export-ignore
|
||||
.gitattributes export-ignore
|
||||
@ -6,4 +5,4 @@ tests/ export-ignore
|
||||
.pylintrc export-ignore
|
||||
Makefile export-ignore
|
||||
requirements.txt export-ignore
|
||||
scripts/ export-ignore
|
||||
tox.ini export-ignore
|
||||
|
2
.github/FUNDING.yml
vendored
@ -1,2 +0,0 @@
|
||||
github: michaelarnauts
|
||||
custom: https://www.buymeacoffee.com/michaelarnauts
|
36
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -1,36 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Logs**
|
||||
Add debug logs to help troubleshoot the issue. See https://kodi.wiki/view/Log_file/Easy for more info.
|
||||
|
||||
**System**
|
||||
- Addon version:
|
||||
- Kodi version:
|
||||
- Inputstream adaptive version:
|
||||
- Operating System (Windows / Mac OS / Android / LibreElec / OSMC / ...):
|
||||
- Special Hardware (RPI / Vero4K+ / ...):
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
35
.github/workflows/addon-check.yml
vendored
@ -1,22 +1,41 @@
|
||||
name: Kodi
|
||||
|
||||
on:
|
||||
# Run action when pushed to master, or for commits in a pull request.
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
kodi-addon-checker:
|
||||
name: Addon checker
|
||||
tests:
|
||||
name: Kodi Add-on checker
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
kodi-branch: [leia, matrix]
|
||||
steps:
|
||||
- name: Check out ${{ github.sha }} from repository ${{ github.repository }}
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Run kodi-addon-checker
|
||||
uses: xbmc/action-kodi-addon-checker@v1.2
|
||||
with:
|
||||
kodi-version: matrix
|
||||
addon-id: ${{ github.event.repository.name }}
|
||||
path: ${{ github.repository }}
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.8
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get install libxml2-utils xmlstarlet
|
||||
python -m pip install --upgrade pip
|
||||
pip install kodi-addon-checker
|
||||
- name: Remove unwanted files
|
||||
run: awk '/export-ignore/ { print $1 }' .gitattributes | xargs rm -rf --
|
||||
working-directory: ${{ github.repository }}
|
||||
- name: Rewrite addon.xml for Matrix
|
||||
run: xmlstarlet ed -L -u '/addon/requires/import[@addon="xbmc.python"]/@version' -v "3.0.0" addon.xml
|
||||
working-directory: ${{ github.repository }}
|
||||
if: matrix.kodi-branch == 'matrix'
|
||||
- name: Run kodi-addon-checker
|
||||
run: kodi-addon-checker --branch=${{ matrix.kodi-branch }} ${{ github.repository }}/
|
||||
|
69
.github/workflows/ci.yml
vendored
@ -1,65 +1,58 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
# Run action when pushed to master, or for commits in a pull request.
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Add-on testing
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Unit tests
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PYTHONIOENCODING: utf-8
|
||||
PYTHONPATH: ${{ github.workspace }}/resources/lib:${{ github.workspace }}/tests
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ ubuntu-latest, windows-latest ]
|
||||
python-version: ["3.8", "3.9", "3.10"]
|
||||
include:
|
||||
# End-of-life Python versions are not available anymore with ubuntu-latest
|
||||
- os: ubuntu-20.04
|
||||
python-version: "3.5"
|
||||
- os: ubuntu-20.04
|
||||
python-version: "3.6"
|
||||
- os: ubuntu-20.04
|
||||
python-version: "3.7"
|
||||
python-version: [2.7, 3.5, 3.6, 3.7, 3.8]
|
||||
steps:
|
||||
- name: Check out ${{ github.sha }} from repository ${{ github.repository }}
|
||||
uses: actions/checkout@v3
|
||||
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
run: |
|
||||
sudo apt-get install --no-install-recommends gettext ffmpeg
|
||||
sudo pip install coverage --install-option="--install-scripts=/usr/bin"
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
- name: Run pylint
|
||||
run: make check-pylint
|
||||
|
||||
run: |
|
||||
make check-pylint
|
||||
- name: Run tox
|
||||
run: |
|
||||
make check-tox
|
||||
- name: Check translations
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9'
|
||||
run: sudo apt-get install gettext && make check-translations
|
||||
|
||||
run: |
|
||||
make check-translations
|
||||
- name: Run unit tests
|
||||
env:
|
||||
ADDON_USERNAME: ${{ secrets.ADDON_USERNAME }}
|
||||
ADDON_PASSWORD: ${{ secrets.ADDON_PASSWORD }}
|
||||
PYTHONIOENCODING: utf-8
|
||||
KODI_HOME: ${{ github.workspace }}/tests/home
|
||||
KODI_INTERACTIVE: 0
|
||||
KODI_STUB_RPC_RESPONSES: ${{ github.workspace }}/tests/rpc
|
||||
HTTP_PROXY: ${{ secrets.HTTP_PROXY }}
|
||||
run: pytest -x -v --cov=./ --cov-report=xml tests
|
||||
|
||||
run: |
|
||||
coverage run -m unittest discover
|
||||
- name: Run addon
|
||||
run: |
|
||||
coverage run -a tests/run.py /
|
||||
- name: Run add-on service
|
||||
run: |
|
||||
coverage run -a service_entry.py
|
||||
- name: Upload code coverage to CodeCov
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v1
|
||||
continue-on-error: true
|
||||
env:
|
||||
OS: ${{ matrix.os }}
|
||||
PYTHON: ${{ matrix.python-version }}
|
||||
with:
|
||||
flags: unittests
|
||||
env_vars: OS,PYTHON
|
||||
|
34
.github/workflows/release.yml
vendored
@ -1,34 +0,0 @@
|
||||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
jobs:
|
||||
build:
|
||||
name: Release plugin.video.viervijfzes
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out ${{ github.sha }} from repository ${{ github.repository }}
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Get changelog
|
||||
id: get-changelog
|
||||
run: |
|
||||
description=$(sed '1,6d;/^## /,$d' CHANGELOG.md)
|
||||
echo $description
|
||||
description="${description//'%'/'%25'}"
|
||||
description="${description//$'\n'/'%0A'}"
|
||||
description="${description//$'\r'/'%0D'}"
|
||||
echo ::set-output name=body::$description
|
||||
|
||||
- name: Generate distribution zips
|
||||
run: scripts/build.py
|
||||
|
||||
- name: Create Release on Github
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
body: ${{ steps.get-changelog.outputs.body }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: "dist/*.zip"
|
||||
token: ${{ secrets.GH_TOKEN }}
|
17
.gitignore
vendored
@ -9,14 +9,13 @@
|
||||
Thumbs.db
|
||||
*~
|
||||
.cache
|
||||
|
||||
.coverage
|
||||
.tox/
|
||||
|
||||
# Testing
|
||||
tests/home/userdata/addon_data
|
||||
.env
|
||||
|
||||
Pipfile
|
||||
Pipfile.lock
|
||||
|
||||
dist/
|
||||
tests/userdata/credentials.json
|
||||
tests/userdata/temp
|
||||
tests/userdata/token.json
|
||||
tests/userdata/cache
|
||||
tests/userdata/addon_data
|
||||
tests/userdata/tokens
|
||||
tests/cdm
|
||||
|
12
.pylintrc
@ -1,12 +1,11 @@
|
||||
[MESSAGES CONTROL]
|
||||
disable=
|
||||
bad-option-value,
|
||||
cyclic-import, # This should be fixed
|
||||
cyclic-import, # This shoud be fixed
|
||||
duplicate-code,
|
||||
fixme,
|
||||
import-outside-toplevel,
|
||||
line-too-long,
|
||||
no-init,
|
||||
old-style-class,
|
||||
too-few-public-methods,
|
||||
too-many-arguments,
|
||||
@ -14,12 +13,3 @@ disable=
|
||||
too-many-instance-attributes,
|
||||
too-many-locals,
|
||||
too-many-public-methods,
|
||||
too-many-statements,
|
||||
use-maxsplit-arg,
|
||||
consider-using-from-import,
|
||||
unspecified-encoding,
|
||||
broad-exception-raised,
|
||||
|
||||
super-with-arguments, # Python 2.7 compatibility
|
||||
raise-missing-from, # Python 2.7 compatibility
|
||||
consider-using-f-string, # Python 2.7 compatibility
|
||||
|
179
CHANGELOG.md
@ -1,180 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
[Full Changelog](https://git.jeroened.be/JeroenED/plugin.video.viervijfzes/compare/v0.4.11...v0.4.12)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Add live channels [\#129](https://github.com/add-ons/plugin.video.viervijfzes/pull/129) ([mediaminister](https://github.com/mediaminister))
|
||||
- Add PlayCrime Channel
|
||||
|
||||
|
||||
## [v0.4.11](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.11) (2023-07-26)
|
||||
|
||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.10...v0.4.11)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Add support for proxies [\#126](https://github.com/add-ons/plugin.video.viervijfzes/pull/126) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Add DRM support for all streams [\#121](https://github.com/add-ons/plugin.video.viervijfzes/pull/121) ([mediaminister](https://github.com/mediaminister))
|
||||
- Use inputstreamhelper for unprotected MPEG-DASH [\#118](https://github.com/add-ons/plugin.video.viervijfzes/pull/118) ([mediaminister](https://github.com/mediaminister))
|
||||
|
||||
## [v0.4.10](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.10) (2023-01-16)
|
||||
|
||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.9...v0.4.10)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Update api [\#114](https://github.com/add-ons/plugin.video.viervijfzes/pull/114) ([mediaminister](https://github.com/mediaminister))
|
||||
|
||||
## [v0.4.9](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.9) (2023-01-04)
|
||||
|
||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.8...v0.4.9)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Add support for unprotected MPEG-DASH streams [\#111](https://github.com/add-ons/plugin.video.viervijfzes/pull/111) ([mediaminister](https://github.com/mediaminister))
|
||||
- Fix clips [\#108](https://github.com/add-ons/plugin.video.viervijfzes/pull/108) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
|
||||
## [v0.4.8](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.8) (2022-07-07)
|
||||
|
||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.7...v0.4.8)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix API [\#105](https://github.com/add-ons/plugin.video.viervijfzes/pull/105) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
|
||||
## [v0.4.7](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.7) (2022-02-04)
|
||||
|
||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.6...v0.4.7)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix empty My List due to unknown items [\#102](https://github.com/add-ons/plugin.video.viervijfzes/pull/102) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
|
||||
## [v0.4.6](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.6) (2022-02-02)
|
||||
|
||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.5...v0.4.6)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix playback of DRM protected content [\#101](https://github.com/add-ons/plugin.video.viervijfzes/pull/101) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
|
||||
## [v0.4.5](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.5) (2021-10-21)
|
||||
|
||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.4...v0.4.5)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Remove dependency on inputstream.adaptive [\#98](https://github.com/add-ons/plugin.video.viervijfzes/pull/98) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
- Various fixes due to layout changes [\#97](https://github.com/add-ons/plugin.video.viervijfzes/pull/97) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
|
||||
## [v0.4.4](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.4) (2021-09-15)
|
||||
|
||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.3...v0.4.4)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix menu [\#93](https://github.com/add-ons/plugin.video.viervijfzes/pull/93) ([mediaminister](https://github.com/mediaminister))
|
||||
|
||||
## [v0.4.3](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.3) (2021-04-24)
|
||||
|
||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.2...v0.4.3)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Fetch a week of EPG data for IPTV Manager [\#89](https://github.com/add-ons/plugin.video.viervijfzes/pull/89) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
- Improve playback error handling [\#87](https://github.com/add-ons/plugin.video.viervijfzes/pull/87) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
- Add support for Play7 [\#86](https://github.com/add-ons/plugin.video.viervijfzes/pull/86) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
|
||||
## [v0.4.2](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.2) (2021-03-22)
|
||||
|
||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.1...v0.4.2)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Improve artwork and descriptions [\#79](https://github.com/add-ons/plugin.video.viervijfzes/pull/79) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Don't use cached episode info for playback [\#82](https://github.com/add-ons/plugin.video.viervijfzes/pull/82) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
|
||||
## [v0.4.1](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.1) (2021-02-27)
|
||||
|
||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.0...v0.4.1)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Add recommendations and categories, allow to hide unavailable programs [\#76](https://github.com/add-ons/plugin.video.viervijfzes/pull/76) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
- Fix incomplete descriptions [\#75](https://github.com/add-ons/plugin.video.viervijfzes/pull/75) ([dagwieers](https://github.com/dagwieers))
|
||||
- Remove background metadata downloading [\#74](https://github.com/add-ons/plugin.video.viervijfzes/pull/74) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
- Implement My List [\#71](https://github.com/add-ons/plugin.video.viervijfzes/pull/71) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix error when requesting a My List that has not been created yet. [\#73](https://github.com/add-ons/plugin.video.viervijfzes/pull/73) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
|
||||
## [v0.4.0](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.0) (2021-02-04)
|
||||
|
||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.3.1...v0.4.0)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Rebranding to GoPlay [\#64](https://github.com/add-ons/plugin.video.viervijfzes/pull/64) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
|
||||
## [v0.3.1](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.3.1) (2020-11-28)
|
||||
|
||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.3.0...v0.3.1)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix authentication on some older Android devices [\#58](https://github.com/add-ons/plugin.video.viervijfzes/pull/58) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
|
||||
## [v0.3.0](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.3.0) (2020-11-17)
|
||||
|
||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.2.0...v0.3.0)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Add inputstreamhelper [\#57](https://github.com/add-ons/plugin.video.viervijfzes/pull/57) ([mediaminister](https://github.com/mediaminister))
|
||||
- Allow to install and run Kodi Logfile Uploader from the settings [\#50](https://github.com/add-ons/plugin.video.viervijfzes/pull/50) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
- Allow to install IPTV Manager from the settings [\#49](https://github.com/add-ons/plugin.video.viervijfzes/pull/49) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
- Allow playing drm protected content [\#47](https://github.com/add-ons/plugin.video.viervijfzes/pull/47) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix clearing local metadata [\#55](https://github.com/add-ons/plugin.video.viervijfzes/pull/55) ([mediaminister](https://github.com/mediaminister))
|
||||
- Fix EPG due to parsing issue of the season [\#54](https://github.com/add-ons/plugin.video.viervijfzes/pull/54) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
- Fix logging for Kodi Matrix [\#48](https://github.com/add-ons/plugin.video.viervijfzes/pull/48) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
- Opening some programs without a title could throw an error [\#45](https://github.com/add-ons/plugin.video.viervijfzes/pull/45) ([dagwieers](https://github.com/dagwieers))
|
||||
- Show message when Kodi Player fails to get the stream [\#40](https://github.com/add-ons/plugin.video.viervijfzes/pull/40) ([mediaminister](https://github.com/mediaminister))
|
||||
|
||||
## [v0.2.0](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.2.0) (2020-06-19)
|
||||
|
||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.1.0...v0.2.0)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Add IPTV Manager to settings [\#35](https://github.com/add-ons/plugin.video.viervijfzes/pull/35) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
- Add "Clean Metadata" setting [\#34](https://github.com/add-ons/plugin.video.viervijfzes/pull/34) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
- Pass genre, program title and episode number to IPTV Manager [\#31](https://github.com/add-ons/plugin.video.viervijfzes/pull/31) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
- Add stream info for direct playing in Kodi 19 [\#30](https://github.com/add-ons/plugin.video.viervijfzes/pull/30) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
- Add support for playing from the Guide with IPTV Manager [\#29](https://github.com/add-ons/plugin.video.viervijfzes/pull/29) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
- Add option for debug logging and fix CI [\#27](https://github.com/add-ons/plugin.video.viervijfzes/pull/27) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
- Use inputstream.adaptive for playback [\#25](https://github.com/add-ons/plugin.video.viervijfzes/pull/25) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
- Add categories and clips [\#23](https://github.com/add-ons/plugin.video.viervijfzes/pull/23) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
- Add compatibility for Kodi 19 Python API [\#20](https://github.com/add-ons/plugin.video.viervijfzes/pull/20) ([mediaminister](https://github.com/mediaminister))
|
||||
- Py2/3 compatibility fixes [\#16](https://github.com/add-ons/plugin.video.viervijfzes/pull/16) ([mediaminister](https://github.com/mediaminister))
|
||||
- Rework cache [\#15](https://github.com/add-ons/plugin.video.viervijfzes/pull/15) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix data transfers over 1 MB to IPTV Manager [\#32](https://github.com/add-ons/plugin.video.viervijfzes/pull/32) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
- Fix multi-line text in progress dialog [\#21](https://github.com/add-ons/plugin.video.viervijfzes/pull/21) ([mediaminister](https://github.com/mediaminister))
|
||||
- Fix token encoding in auth [\#19](https://github.com/add-ons/plugin.video.viervijfzes/pull/19) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
|
||||
## [v0.1.0](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.1.0) (2020-03-27)
|
||||
|
||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/89f55f70b017d0add645d1e1d88f0ce8192d11c4...v0.1.0)
|
||||
@ -190,7 +15,11 @@
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- Improve CI tests [\#14](https://github.com/add-ons/plugin.video.viervijfzes/pull/14) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
- Small translation fixes [\#12](https://github.com/add-ons/plugin.video.viervijfzes/pull/12) ([dagwieers](https://github.com/dagwieers))
|
||||
- Various check fixes [\#11](https://github.com/add-ons/plugin.video.viervijfzes/pull/11) ([dagwieers](https://github.com/dagwieers))
|
||||
- Replace Travis with GitHub Actions [\#10](https://github.com/add-ons/plugin.video.viervijfzes/pull/10) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
- Improve code coverage [\#9](https://github.com/add-ons/plugin.video.viervijfzes/pull/9) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||
|
||||
|
||||
|
||||
|
14
LICENSE
@ -1,7 +1,7 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
@ -631,8 +631,8 @@ to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
{one line to give the program's name and a brief idea of what it does.}
|
||||
Copyright (C) {year} {name of author}
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@ -645,14 +645,14 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
{project} Copyright (C) {year} {fullname}
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
@ -664,11 +664,11 @@ might be different; for a GUI interface, you would use an "about box".
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
|
73
Makefile
@ -1,69 +1,62 @@
|
||||
export KODI_HOME := $(CURDIR)/tests/home
|
||||
export KODI_INTERACTIVE := 0
|
||||
export PYTHONPATH := $(CURDIR):$(CURDIR)/tests
|
||||
PYTHON := python
|
||||
|
||||
# Collect information to build as sensible package name
|
||||
name = $(shell xmllint --xpath 'string(/addon/@id)' addon.xml)
|
||||
version = $(shell xmllint --xpath 'string(/addon/@version)' addon.xml)
|
||||
git_branch = $(shell git rev-parse --abbrev-ref HEAD)
|
||||
git_hash = $(shell git rev-parse --short HEAD)
|
||||
zip_name = $(name)-$(version)-$(git_branch)-$(git_hash).zip
|
||||
include_files = addon_entry.py addon.xml CHANGELOG.md LICENSE README.md resources/ service_entry.py
|
||||
include_paths = $(patsubst %,$(name)/%,$(include_files))
|
||||
exclude_files = \*.new \*.orig \*.pyc \*.pyo
|
||||
|
||||
languages = $(filter-out en_gb, $(patsubst resources/language/resource.language.%, %, $(wildcard resources/language/*)))
|
||||
|
||||
all: check test build
|
||||
zip: build
|
||||
multizip: build
|
||||
|
||||
check: check-pylint check-translations
|
||||
check: check-pylint check-tox check-translations
|
||||
|
||||
check-pylint:
|
||||
@printf ">>> Running pylint checks\n"
|
||||
@echo ">>> Running pylint checks"
|
||||
@$(PYTHON) -m pylint *.py resources/lib/ tests/
|
||||
|
||||
check-tox:
|
||||
@echo ">>> Running tox checks"
|
||||
@$(PYTHON) -m tox -q
|
||||
|
||||
check-translations:
|
||||
@printf ">>> Running translation checks\n"
|
||||
@echo ">>> Running translation checks"
|
||||
@$(foreach lang,$(languages), \
|
||||
msgcmp --use-untranslated resources/language/resource.language.$(lang)/strings.po resources/language/resource.language.en_gb/strings.po; \
|
||||
msgcmp resources/language/resource.language.$(lang)/strings.po resources/language/resource.language.en_gb/strings.po; \
|
||||
)
|
||||
@scripts/check_for_unused_translations.py
|
||||
|
||||
check-addon: build
|
||||
@printf ">>> Running addon checks\n"
|
||||
check-addon: clean build
|
||||
@echo ">>> Running addon checks"
|
||||
$(eval TMPDIR := $(shell mktemp -d))
|
||||
@unzip dist/plugin.video.viervijfzes-*+matrix.1.zip -d ${TMPDIR}
|
||||
cd ${TMPDIR} && kodi-addon-checker --branch=matrix
|
||||
@unzip ../${zip_name} -d ${TMPDIR}
|
||||
cd ${TMPDIR} && kodi-addon-checker --branch=leia
|
||||
@rm -rf ${TMPDIR}
|
||||
|
||||
codefix:
|
||||
@isort -l 160 resources/
|
||||
|
||||
test: test-unit
|
||||
|
||||
test-unit:
|
||||
@printf ">>> Running unit tests\n"
|
||||
@$(PYTHON) -m pytest tests
|
||||
@echo ">>> Running unit tests"
|
||||
@$(PYTHON) -m unittest discover -v -b -f
|
||||
|
||||
clean:
|
||||
@printf ">>> Cleaning up\n"
|
||||
@find . -name '*.py[cod]' -type f -delete
|
||||
@find . -name '__pycache__' -type d -delete
|
||||
@rm -rf .pytest_cache/ tests/cdm tests/userdata/temp
|
||||
@rm -rf .pytest_cache/ .tox/ tests/cdm tests/userdata/temp
|
||||
@rm -f *.log .coverage
|
||||
@rm -rf dist/
|
||||
|
||||
build: clean
|
||||
@printf ">>> Building add-on\n"
|
||||
@scripts/build.py
|
||||
@ls -lah dist/*.zip
|
||||
@echo ">>> Building package"
|
||||
@rm -f ../$(zip_name)
|
||||
cd ..; zip -r $(zip_name) $(include_paths) -x $(exclude_files)
|
||||
@echo "Successfully wrote package as: ../$(zip_name)"
|
||||
|
||||
release:
|
||||
ifneq ($(release),)
|
||||
docker run -it --rm --env CHANGELOG_GITHUB_TOKEN=$(GH_TOKEN) -v "$(shell pwd)":/usr/local/src/your-app githubchangeloggenerator/github-changelog-generator -u add-ons -p plugin.video.viervijfzes --no-issues --exclude-labels duplicate,question,invalid,wontfix,release,testing --future-release v$(release)
|
||||
|
||||
@printf "cd /addon/@version\nset $$release\nsave\nbye\n" | xmllint --shell addon.xml; \
|
||||
date=$(shell date '+%Y-%m-%d'); \
|
||||
printf "cd /addon/extension[@point='xbmc.addon.metadata']/news\nset v$$release ($$date)\nsave\nbye\n" | xmllint --shell addon.xml; \
|
||||
|
||||
# Next steps to release:
|
||||
# - Modify the news-section of addons.xml
|
||||
# - git add . && git commit -m "Prepare for v$(release)" && git push
|
||||
# - git tag v$(release) && git push --tags
|
||||
else
|
||||
@printf "Usage: make release release=1.0.0\n"
|
||||
endif
|
||||
|
||||
.PHONY: check codefix test clean build release
|
||||
release: build
|
||||
rm -rf ../repo-plugins/$(name)/*
|
||||
unzip ../$(zip_name) -d ../repo-plugins/
|
||||
|
22
README.md
@ -1,24 +1,28 @@
|
||||
[![GitHub release](https://img.shields.io/github/v/release/add-ons/plugin.video.viervijfzes?display_name=tag)](https://github.com/add-ons/plugin.video.viervijfzes/releases)
|
||||
[![Build Status](https://img.shields.io/github/actions/workflow/status/add-ons/plugin.video.viervijfzes/ci.yml?branch=master)](https://github.com/add-ons/plugin.video.viervijfzes/actions?query=branch%3Amaster)
|
||||
[![GitHub release](https://img.shields.io/github/release/add-ons/plugin.video.viervijfzes.svg?include_prereleases)](https://github.com/add-ons/plugin.video.viervijfzes/releases)
|
||||
[![Build Status](https://img.shields.io/github/workflow/status/add-ons/plugin.video.viervijfzes/CI/master)](https://github.com/add-ons/plugin.video.viervijfzes/actions?query=branch%3Amaster)
|
||||
[![Codecov status](https://img.shields.io/codecov/c/github/add-ons/plugin.video.viervijfzes/master)](https://codecov.io/gh/add-ons/plugin.video.viervijfzes/branch/master)
|
||||
[![License: GPLv3](https://img.shields.io/badge/License-GPLv3-yellow.svg)](https://opensource.org/licenses/GPL-3.0)
|
||||
[![Contributors](https://img.shields.io/github/contributors/add-ons/plugin.video.viervijfzes.svg)](https://github.com/add-ons/plugin.video.viervijfzes/graphs/contributors)
|
||||
|
||||
# GoPlay Kodi add-on
|
||||
# VIER-VIJF-ZES Kodi add-on
|
||||
|
||||
*plugin.video.viervijfzes* is een Kodi add-on om de video-on-demand content van [GoPlay](https://www.goplay.be/) te bekijken. Je moet hiervoor wel eerst een
|
||||
account aanmaken op [goplay.be](https://www.goplay.be/).
|
||||
*plugin.video.viervijfzes* is een Kodi add-on om de video-on-demand content van [vier.be](https://www.vier.be/), [vijf.be](https://www.vijf.be/) en [zestv.be](https://www.zestv.be/) te bekijken.
|
||||
|
||||
Meer informatie kan je vinden op de [Wiki pagina](https://github.com/add-ons/plugin.video.viervijfzes/wiki).
|
||||
> Note: Je moet eerst een account aanmaken op één van bovenstaande websites.
|
||||
|
||||
## Installatie
|
||||
|
||||
Deze addon staat momenteel nog niet de repository van Kodi zelf, je moet deze voorlopig nog handmatig installeren en updaten.
|
||||
|
||||
Je kan de [laatste release](https://github.com/add-ons/plugin.video.viervijfzes/releases) downloaden, of een [development zip](https://github.com/add-ons/plugin.video.viervijfzes/archive/master.zip) van Github downloaden met de laatste wijzigingen.
|
||||
|
||||
## Features
|
||||
|
||||
De volgende features worden ondersteund:
|
||||
* Bekijk on-demand content van Play4, Play5, Play6 en Play7
|
||||
* Bekijk on-demand content van VIER, VIJF en ZES
|
||||
* Programma's rechtstreeks afspelen vanuit de tv-gids
|
||||
* Doorzoeken van alle programma's
|
||||
* Afspelen van gerelateerde Youtube content
|
||||
* Integratie met [IPTV Manager](https://github.com/add-ons/service.iptv.manager)
|
||||
|
||||
## Screenshots
|
||||
|
||||
@ -33,4 +37,4 @@ De volgende features worden ondersteund:
|
||||
## Disclaimer
|
||||
|
||||
Deze add-on wordt niet ondersteund door SBS Belgium, en wordt aangeboden 'as is', zonder enige garantie.
|
||||
De logo's van GoPlay, Play4, Play5, Play6 en Play7 zijn eigendom van SBS België.
|
||||
De logo's van VIER, VIJF en ZES zijn eigendom van SBS België.
|
||||
|
25
addon.xml
@ -1,9 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<addon id="plugin.video.viervijfzes" name="GoPlay" version="0.4.12" provider-name="Michaël Arnauts">
|
||||
<addon id="plugin.video.viervijfzes" name="VIER-VIJF-ZES" version="0.1.0" provider-name="Michaël Arnauts">
|
||||
<requires>
|
||||
<import addon="xbmc.python" version="3.0.0"/>
|
||||
<import addon="xbmc.python" version="2.26.0"/>
|
||||
<import addon="script.module.dateutil" version="2.6.0"/>
|
||||
<import addon="script.module.inputstreamhelper" version="0.5.1"/>
|
||||
<import addon="script.module.pysocks" version="1.6.8" optional="true"/>
|
||||
<import addon="script.module.requests" version="2.22.0"/>
|
||||
<import addon="script.module.routing" version="0.2.0"/>
|
||||
@ -13,17 +12,17 @@
|
||||
</extension>
|
||||
<extension point="xbmc.service" library="service_entry.py"/>
|
||||
<extension point="xbmc.addon.metadata">
|
||||
<summary lang="nl_NL">Bekijk programma's van Play4, Play5 en Play6.</summary>
|
||||
<description lang="nl_NL">Deze add-on geeft toegang tot de programma's die aangeboden worden op de websites van Play4, Play5 en Play6.</description>
|
||||
<disclaimer lang="nl_NL">Deze add-on wordt niet ondersteund door SBS België, en wordt aangeboden 'as is', zonder enige garantie. De logo's van Play4, Play5 en Play6 zijn eigendom van SBS België.</disclaimer>
|
||||
<summary lang="en_GB">Watch content from Play4, Play5 and Play6.</summary>
|
||||
<description lang="en_GB">This add-on gives access to video-on-demand content available on the websites of Play4, Play5 and Play6.</description>
|
||||
<disclaimer lang="en_GB">This add-on is not officially commissioned/supported by SBS Belgium and is provided 'as is' without any warranty of any kind. The Play4, Play5 and Play6 logos are property of SBS Belgium.</disclaimer>
|
||||
<summary lang="nl_NL">Bekijk programma's van VIER, VIJF en ZES.</summary>
|
||||
<description lang="nl_NL">Deze add-on geeft toegang tot de programma's die aangeboden worden op de websites van VIER, VIJF en ZES.</description>
|
||||
<disclaimer lang="nl_NL">Deze add-on wordt niet ondersteund door SBS België, en wordt aangeboden 'as is', zonder enige garantie. De logo's van VIER, VIJF en ZES zijn eigendom van SBS België.</disclaimer>
|
||||
<summary lang="en_GB">Watch content from VIER, VIJF and ZES.</summary>
|
||||
<description lang="en_GB">This add-on gives access to video-on-demand content available on the websites of VIER, VIJF and ZES.</description>
|
||||
<disclaimer lang="en_GB">This add-on is not officially commissioned/supported by SBS Belgium and is provided 'as is' without any warranty of any kind. The VIER, VIJF and ZES logos are property of SBS Belgium.</disclaimer>
|
||||
<platform>all</platform>
|
||||
<license>GPL-3.0-only</license>
|
||||
<news>v0.4.12 (2024-05-08)
|
||||
- Added live channels (by mediaminister)
|
||||
- Added PlayCrime Channel</news>
|
||||
<license>GPL-3.0</license>
|
||||
<news>v0.1.0
|
||||
- First release
|
||||
</news>
|
||||
<source>https://github.com/add-ons/plugin.video.viervijfzes</source>
|
||||
<assets>
|
||||
<icon>resources/icon.png</icon>
|
||||
|
@ -3,13 +3,11 @@
|
||||
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
|
||||
from xbmcaddon import Addon
|
||||
import xbmcaddon
|
||||
|
||||
from resources.lib import kodiutils, kodilogging
|
||||
from resources.lib import kodiutils
|
||||
|
||||
# Reinitialise ADDON every invocation to fix an issue that settings are not fresh.
|
||||
kodiutils.ADDON = Addon()
|
||||
kodilogging.ADDON = Addon()
|
||||
kodiutils.ADDON = xbmcaddon.Addon()
|
||||
|
||||
if __name__ == '__main__':
|
||||
from sys import argv
|
||||
|
@ -1,12 +1,9 @@
|
||||
coverage
|
||||
git+git://github.com/emilsvennesson/script.module.inputstreamhelper.git@master#egg=inputstreamhelper
|
||||
polib
|
||||
pylint
|
||||
pytest
|
||||
pytest-cov
|
||||
pytest-timeout
|
||||
python-dateutil
|
||||
pysocks
|
||||
requests
|
||||
git+https://github.com/tamland/kodi-plugin-routing@master#egg=routing
|
||||
git+git://github.com/dagwieers/kodi-plugin-routing.git@setup#egg=routing
|
||||
tox
|
||||
six
|
||||
sakee
|
||||
win-inet-pton; platform_system=="Windows"
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 23 KiB |
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 14 KiB |
@ -19,15 +19,7 @@ msgid "Catalogue"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30004"
|
||||
msgid "Browse the catalogue"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30005"
|
||||
msgid "Recommendations"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30006"
|
||||
msgid "Show the recommendations"
|
||||
msgid "TV Shows and Movies listed by category"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30007"
|
||||
@ -46,24 +38,16 @@ msgctxt "#30010"
|
||||
msgid "Search trough the catalogue"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30011"
|
||||
msgid "My List"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30012"
|
||||
msgid "Browse My List"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30013"
|
||||
msgid "TV guide"
|
||||
msgstr ""
|
||||
|
||||
|
||||
### SUBMENUS
|
||||
msgctxt "#30052"
|
||||
msgid "Watch live [B]{channel}[/B]"
|
||||
msgctxt "#30014"
|
||||
msgid "Browse the TV Guide"
|
||||
msgstr ""
|
||||
|
||||
|
||||
### SUBMENUS
|
||||
msgctxt "#30053"
|
||||
msgid "TV Guide for [B]{channel}[/B]"
|
||||
msgstr ""
|
||||
@ -80,28 +64,16 @@ msgctxt "#30056"
|
||||
msgid "Browse the Catalog for [B]{channel}[/B]"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30059"
|
||||
msgid "Clips of [B]{program}[/B]"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30060"
|
||||
msgid "Watch short clips of [B]{program}[/B]"
|
||||
msgstr ""
|
||||
|
||||
|
||||
### CONTEXT MENU
|
||||
msgctxt "#30100"
|
||||
msgid "Add to My List"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30101"
|
||||
msgid "Remove from My List"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30102"
|
||||
msgid "Go to Program"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30103"
|
||||
msgid "Download to cache"
|
||||
msgstr ""
|
||||
|
||||
|
||||
### CODE
|
||||
msgctxt "#30204"
|
||||
@ -140,10 +112,18 @@ msgctxt "#30702"
|
||||
msgid "An error occurred while authenticating: {error}."
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30709"
|
||||
msgid "Geo-blocked video"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30710"
|
||||
msgid "This video is geo-blocked and can't be played from your location."
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30711"
|
||||
msgid "Unavailable video"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30712"
|
||||
msgid "The video is unavailable and can't be played right now."
|
||||
msgstr ""
|
||||
@ -152,20 +132,56 @@ msgctxt "#30713"
|
||||
msgid "The requested video was not found in the guide."
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30714"
|
||||
msgid "Local metadata is cleared."
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30715"
|
||||
msgid "Updating metadata"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30716"
|
||||
msgid "Updating metadata ({index}/{total})..."
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30717"
|
||||
msgid "This program is not available in the catalogue."
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30718"
|
||||
msgid "There is no live stream available for {channel}."
|
||||
msgid "Could not cache this episode since the cache folder is not set or does not exist."
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30719"
|
||||
msgid "This video cannot be played."
|
||||
msgid "Could not cache this episode since ffmpeg seems to be unavailable."
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30720"
|
||||
msgid "This video is not available abroad."
|
||||
msgid "This episode is cached locally. Do you want to play from cache or stream it?"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30721"
|
||||
msgid "Stream"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30722"
|
||||
msgid "Play from cache"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30723"
|
||||
msgid "Starting download..."
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30724"
|
||||
msgid "Downloading... ({amount}%)"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30725"
|
||||
msgid "Download has finished. You can now play this episode from cache."
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30726"
|
||||
msgid "This episode is already cached. Do you want to download it again?"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@ -178,11 +194,11 @@ msgctxt "#30801"
|
||||
msgid "Credentials"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30802"
|
||||
msgctxt "#30803"
|
||||
msgid "Email address"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30803"
|
||||
msgctxt "#30805"
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
||||
@ -190,46 +206,26 @@ msgctxt "#30820"
|
||||
msgid "Interface"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30821"
|
||||
msgid "Show unavailable programs"
|
||||
msgctxt "#30827"
|
||||
msgid "Metadata"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30829"
|
||||
msgid "Periodically refresh metadata in the background"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30831"
|
||||
msgid "Update local metadata now"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30840"
|
||||
msgid "Integration"
|
||||
msgid "Playback from cache"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30841"
|
||||
msgid "IPTV Manager"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30842"
|
||||
msgid "Install IPTV Manager add-on…"
|
||||
msgid "Allow to download episodes to a cache"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30843"
|
||||
msgid "Enable IPTV Manager integration"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30844"
|
||||
msgid "IPTV Manager settings…"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30880"
|
||||
msgid "Expert"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30881"
|
||||
msgid "Logging"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30882"
|
||||
msgid "Enable debug logging"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30883"
|
||||
msgid "Install Kodi Logfile Uploader…"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30884"
|
||||
msgid "Open Kodi Logfile Uploader…"
|
||||
msgid "Select the folder for the cached episodes"
|
||||
msgstr ""
|
||||
|
@ -20,16 +20,8 @@ msgid "Catalogue"
|
||||
msgstr "Catalogus"
|
||||
|
||||
msgctxt "#30004"
|
||||
msgid "Browse the catalogue"
|
||||
msgstr "Doorblader de catalogus"
|
||||
|
||||
msgctxt "#30005"
|
||||
msgid "Recommendations"
|
||||
msgstr "Aanbevelingen"
|
||||
|
||||
msgctxt "#30006"
|
||||
msgid "Show the recommendations"
|
||||
msgstr "Doorblader de aanbevelingen"
|
||||
msgid "TV Shows and Movies listed by category"
|
||||
msgstr "Programma's en films per categorie"
|
||||
|
||||
msgctxt "#30007"
|
||||
msgid "Channels"
|
||||
@ -47,24 +39,16 @@ msgctxt "#30010"
|
||||
msgid "Search trough the catalogue"
|
||||
msgstr "Doorzoek de catalogus"
|
||||
|
||||
msgctxt "#30011"
|
||||
msgid "My List"
|
||||
msgstr "Mijn lijst"
|
||||
|
||||
msgctxt "#30012"
|
||||
msgid "Browse My List"
|
||||
msgstr "Bekijk mijn lijst"
|
||||
|
||||
msgctxt "#30013"
|
||||
msgid "TV guide"
|
||||
msgstr "Tv-gids"
|
||||
|
||||
msgctxt "#30014"
|
||||
msgid "Browse the TV Guide"
|
||||
msgstr "Doorblader de tv-gids"
|
||||
|
||||
|
||||
### SUBMENUS
|
||||
msgctxt "#30052"
|
||||
msgid "Watch live [B]{channel}[/B]"
|
||||
msgstr "Kijk live [B]{channel}[/B]"
|
||||
|
||||
msgctxt "#30053"
|
||||
msgid "TV Guide for [B]{channel}[/B]"
|
||||
msgstr "Tv-gids voor [B]{channel}[/B]"
|
||||
@ -81,28 +65,16 @@ msgctxt "#30056"
|
||||
msgid "Browse the Catalog for [B]{channel}[/B]"
|
||||
msgstr "Doorblader de catalogus voor [B]{channel}[/B]"
|
||||
|
||||
msgctxt "#30059"
|
||||
msgid "Clips of [B]{program}[/B]"
|
||||
msgstr "Clips van [B]{program}[/B]"
|
||||
|
||||
msgctxt "#30060"
|
||||
msgid "Watch short clips of [B]{program}[/B]"
|
||||
msgstr "Bekijk korte videoclips van [B]{program}[/B]"
|
||||
|
||||
|
||||
### CONTEXT MENU
|
||||
msgctxt "#30100"
|
||||
msgid "Add to My List"
|
||||
msgstr "Toevoegen aan mijn lijst"
|
||||
|
||||
msgctxt "#30101"
|
||||
msgid "Remove from My List"
|
||||
msgstr "Verwijderen uit mijn lijst"
|
||||
|
||||
msgctxt "#30102"
|
||||
msgid "Go to Program"
|
||||
msgstr "Ga naar programma"
|
||||
|
||||
msgctxt "#30103"
|
||||
msgid "Download to cache"
|
||||
msgstr "Downloaden naar cache"
|
||||
|
||||
|
||||
### CODE
|
||||
msgctxt "#30204"
|
||||
@ -141,10 +113,18 @@ msgctxt "#30702"
|
||||
msgid "An error occurred while authenticating: {error}."
|
||||
msgstr "Er is een fout opgetreden tijdens het aanmelden: {error}."
|
||||
|
||||
msgctxt "#30709"
|
||||
msgid "Geo-blocked video"
|
||||
msgstr "Video is geografisch geblokkeerd"
|
||||
|
||||
msgctxt "#30710"
|
||||
msgid "This video is geo-blocked and can't be played from your location."
|
||||
msgstr "Deze video is geografisch geblokkeerd en kan niet worden afgespeeld vanaf je locatie."
|
||||
|
||||
msgctxt "#30711"
|
||||
msgid "Unavailable video"
|
||||
msgstr "Onbeschikbare video"
|
||||
|
||||
msgctxt "#30712"
|
||||
msgid "The video is unavailable and can't be played right now."
|
||||
msgstr "Deze video is niet beschikbaar en kan nu niet worden afgespeeld."
|
||||
@ -153,21 +133,57 @@ msgctxt "#30713"
|
||||
msgid "The requested video was not found in the guide."
|
||||
msgstr "De gevraagde video werd niet gevonden in de tv-gids."
|
||||
|
||||
msgctxt "#30714"
|
||||
msgid "Local metadata is cleared."
|
||||
msgstr "De lokale metadata is verwijderd."
|
||||
|
||||
msgctxt "#30715"
|
||||
msgid "Updating metadata"
|
||||
msgstr "Vernieuwen metadata"
|
||||
|
||||
msgctxt "#30716"
|
||||
msgid "Updating metadata ({index}/{total})..."
|
||||
msgstr "Vernieuwen metadata ({index}/{total})..."
|
||||
|
||||
msgctxt "#30717"
|
||||
msgid "This program is not available in the catalogue."
|
||||
msgstr "Dit programma is niet beschikbaar in de catalogus."
|
||||
|
||||
msgctxt "#30718"
|
||||
msgid "There is no live stream available for {channel}."
|
||||
msgstr "Er is geen live stream beschikbaar voor {channel}."
|
||||
msgid "Could not cache this episode since the cache folder is not set or does not exist."
|
||||
msgstr "Kon deze aflevering niet cachen omdat de cache folder niet is ingesteld is of niet bestaat."
|
||||
|
||||
msgctxt "#30719"
|
||||
msgid "This video cannot be played."
|
||||
msgstr "Deze video kan niet afgespeeld worden."
|
||||
msgid "Could not cache this episode since ffmpeg seems to be unavailable."
|
||||
msgstr "Kon deze aflevering niet cachen omdat ffmpeg niet beschikbaar lijkt te zijn."
|
||||
|
||||
msgctxt "#30720"
|
||||
msgid "This video is not available abroad."
|
||||
msgstr "Deze video is niet beschikbaar in het buitenland."
|
||||
msgid "This episode is cached locally. Do you want to play from cache or stream it?"
|
||||
msgstr "Deze aflevering is lokaal gecached. Wil je deze afspelen vanuit de cache of streamen?"
|
||||
|
||||
msgctxt "#30721"
|
||||
msgid "Stream"
|
||||
msgstr "Stream"
|
||||
|
||||
msgctxt "#30722"
|
||||
msgid "Play from cache"
|
||||
msgstr "Afspelen vanuit de cache"
|
||||
|
||||
msgctxt "#30723"
|
||||
msgid "Starting download..."
|
||||
msgstr "Bezig met starten van de download..."
|
||||
|
||||
msgctxt "#30724"
|
||||
msgid "Downloading... ({amount}%)"
|
||||
msgstr "Bezig met downloaden... ({amount}%)"
|
||||
|
||||
msgctxt "#30725"
|
||||
msgid "Download has finished. You can now play this episode from cache."
|
||||
msgstr "De download is voltooid. Je kan deze aflevering nu afspelen vanuit de cache."
|
||||
|
||||
msgctxt "#30726"
|
||||
msgid "This episode is already cached. Do you want to download it again?"
|
||||
msgstr "Deze aflevering is al gecached. Wil je deze opnieuw downloaden?"
|
||||
|
||||
|
||||
### SETTINGS
|
||||
@ -179,11 +195,11 @@ msgctxt "#30801"
|
||||
msgid "Credentials"
|
||||
msgstr "Inloggegevens"
|
||||
|
||||
msgctxt "#30802"
|
||||
msgctxt "#30803"
|
||||
msgid "Email address"
|
||||
msgstr "E-mailadres"
|
||||
|
||||
msgctxt "#30803"
|
||||
msgctxt "#30805"
|
||||
msgid "Password"
|
||||
msgstr "Wachtwoord"
|
||||
|
||||
@ -191,46 +207,26 @@ msgctxt "#30820"
|
||||
msgid "Interface"
|
||||
msgstr "Interface"
|
||||
|
||||
msgctxt "#30821"
|
||||
msgid "Show unavailable programs"
|
||||
msgstr "Toon onbeschikbare programma's"
|
||||
msgctxt "#30827"
|
||||
msgid "Metadata"
|
||||
msgstr "Metadata"
|
||||
|
||||
msgctxt "#30829"
|
||||
msgid "Periodically refresh metadata in the background"
|
||||
msgstr "Vernieuw de lokale metdata automatisch in de achtergrond"
|
||||
|
||||
msgctxt "#30831"
|
||||
msgid "Update local metadata now"
|
||||
msgstr "De lokale metadata nu vernieuwen"
|
||||
|
||||
msgctxt "#30840"
|
||||
msgid "Integration"
|
||||
msgstr "Integratie"
|
||||
msgid "Playback from cache"
|
||||
msgstr "Afspelen vanuit de cache"
|
||||
|
||||
msgctxt "#30841"
|
||||
msgid "IPTV Manager"
|
||||
msgstr "IPTV Manager"
|
||||
|
||||
msgctxt "#30842"
|
||||
msgid "Install IPTV Manager add-on…"
|
||||
msgstr "Installeer de IPTV Manager add-on…"
|
||||
msgid "Allow to download episodes to a cache"
|
||||
msgstr "Toestaan om afleveringen te downloaden naar de cache"
|
||||
|
||||
msgctxt "#30843"
|
||||
msgid "Enable IPTV Manager integration"
|
||||
msgstr "Activeer IPTV Manager integratie"
|
||||
|
||||
msgctxt "#30844"
|
||||
msgid "IPTV Manager settings…"
|
||||
msgstr "IPTV Manager instellingen…"
|
||||
|
||||
msgctxt "#30880"
|
||||
msgid "Expert"
|
||||
msgstr "Expert"
|
||||
|
||||
msgctxt "#30881"
|
||||
msgid "Logging"
|
||||
msgstr "Logboek"
|
||||
|
||||
msgctxt "#30882"
|
||||
msgid "Enable debug logging"
|
||||
msgstr "Activeer debug logging"
|
||||
|
||||
msgctxt "#30883"
|
||||
msgid "Install Kodi Logfile Uploader…"
|
||||
msgstr "Installeer Kodi Logfile Uploader…"
|
||||
|
||||
msgctxt "#30884"
|
||||
msgid "Open Kodi Logfile Uploader…"
|
||||
msgstr "Open Kodi Logfile Uploader…"
|
||||
msgid "Select the folder for the cached episodes"
|
||||
msgstr "Selecteer de map voor de gecachte afleveringen"
|
||||
|
@ -9,13 +9,9 @@ from routing import Plugin
|
||||
|
||||
from resources.lib import kodilogging
|
||||
|
||||
try: # Python 3
|
||||
from urllib.parse import unquote
|
||||
except ImportError: # Python 2
|
||||
from urllib import unquote
|
||||
|
||||
kodilogging.config()
|
||||
routing = Plugin() # pylint: disable=invalid-name
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER = logging.getLogger('addon')
|
||||
|
||||
|
||||
@routing.route('/')
|
||||
@ -39,25 +35,18 @@ def show_channel_menu(channel):
|
||||
Channels().show_channel_menu(channel)
|
||||
|
||||
|
||||
@routing.route('/channels/<channel>/tvguide')
|
||||
def show_channel_tvguide(channel):
|
||||
@routing.route('/tvguide/channel/<channel>')
|
||||
def show_tvguide_channel(channel):
|
||||
""" Shows the dates in the tv guide """
|
||||
from resources.lib.modules.tvguide import TvGuide
|
||||
TvGuide().show_channel(channel)
|
||||
TvGuide().show_tvguide_channel(channel)
|
||||
|
||||
|
||||
@routing.route('/channels/<channel>/tvguide/<date>')
|
||||
def show_channel_tvguide_detail(channel=None, date=None):
|
||||
@routing.route('/tvguide/channel/<channel>/<date>')
|
||||
def show_tvguide_detail(channel=None, date=None):
|
||||
""" Shows the programs of a specific date in the tv guide """
|
||||
from resources.lib.modules.tvguide import TvGuide
|
||||
TvGuide().show_detail(channel, date)
|
||||
|
||||
|
||||
@routing.route('/channels/<channel>/catalog')
|
||||
def show_channel_catalog(channel):
|
||||
""" Show the catalog of a channel """
|
||||
from resources.lib.modules.catalog import Catalog
|
||||
Catalog().show_catalog_channel(channel)
|
||||
TvGuide().show_tvguide_detail(channel, date)
|
||||
|
||||
|
||||
@routing.route('/catalog')
|
||||
@ -67,74 +56,25 @@ def show_catalog():
|
||||
Catalog().show_catalog()
|
||||
|
||||
|
||||
@routing.route('/catalog/<program>')
|
||||
def show_catalog_program(program):
|
||||
@routing.route('/catalog/by-channel/<channel>')
|
||||
def show_catalog_channel(channel):
|
||||
""" Show a category in the catalog """
|
||||
from resources.lib.modules.catalog import Catalog
|
||||
Catalog().show_catalog_channel(channel)
|
||||
|
||||
|
||||
@routing.route('/catalog/program/<channel>/<program>')
|
||||
def show_catalog_program(channel, program):
|
||||
""" Show a program from the catalog """
|
||||
from resources.lib.modules.catalog import Catalog
|
||||
Catalog().show_program(program)
|
||||
Catalog().show_program(channel, program)
|
||||
|
||||
|
||||
@routing.route('/catalog/<program>/clips')
|
||||
def show_catalog_program_clips(program):
|
||||
""" Show the clips from a program """
|
||||
@routing.route('/catalog/program/<channel>/<program>/<season>')
|
||||
def show_catalog_program_season(channel, program, season):
|
||||
""" Show a program from the catalog """
|
||||
from resources.lib.modules.catalog import Catalog
|
||||
Catalog().show_program_clips(program)
|
||||
|
||||
|
||||
@routing.route('/catalog/<program>/season/<season>')
|
||||
def show_catalog_program_season(program, season):
|
||||
""" Show a season from a program """
|
||||
from resources.lib.modules.catalog import Catalog
|
||||
Catalog().show_program_season(program, season)
|
||||
|
||||
|
||||
@routing.route('/category')
|
||||
def show_categories():
|
||||
""" Show the catalog by category """
|
||||
from resources.lib.modules.catalog import Catalog
|
||||
Catalog().show_categories()
|
||||
|
||||
|
||||
@routing.route('/category/<category>')
|
||||
def show_category(category):
|
||||
""" Show the catalog by category """
|
||||
from resources.lib.modules.catalog import Catalog
|
||||
Catalog().show_category(category)
|
||||
|
||||
|
||||
@routing.route('/recommendations')
|
||||
def show_recommendations():
|
||||
""" Show my list """
|
||||
from resources.lib.modules.catalog import Catalog
|
||||
Catalog().show_recommendations()
|
||||
|
||||
|
||||
@routing.route('/recommendations/<category>')
|
||||
def show_recommendations_category(category):
|
||||
""" Show my list """
|
||||
from resources.lib.modules.catalog import Catalog
|
||||
Catalog().show_recommendations_category(category)
|
||||
|
||||
|
||||
@routing.route('/mylist')
|
||||
def show_mylist():
|
||||
""" Show my list """
|
||||
from resources.lib.modules.catalog import Catalog
|
||||
Catalog().show_mylist()
|
||||
|
||||
|
||||
@routing.route('/mylist/add/<uuid>')
|
||||
def mylist_add(uuid):
|
||||
""" Add a program to My List """
|
||||
from resources.lib.modules.catalog import Catalog
|
||||
Catalog().mylist_add(uuid)
|
||||
|
||||
|
||||
@routing.route('/mylist/del/<uuid>')
|
||||
def mylist_del(uuid):
|
||||
""" Remove a program from My List """
|
||||
from resources.lib.modules.catalog import Catalog
|
||||
Catalog().mylist_del(uuid)
|
||||
Catalog().show_program_season(channel, program, season)
|
||||
|
||||
|
||||
@routing.route('/search')
|
||||
@ -145,50 +85,39 @@ def show_search(query=None):
|
||||
Search().show_search(query)
|
||||
|
||||
|
||||
@routing.route('/play/live/<channel>')
|
||||
def play_live(channel):
|
||||
@routing.route('/play/catalog/<uuid>')
|
||||
def play(uuid):
|
||||
""" Play the requested item """
|
||||
from resources.lib.modules.player import Player
|
||||
Player().live(channel)
|
||||
Player().play(uuid)
|
||||
|
||||
|
||||
@routing.route('/play/epg/<channel>/<timestamp>')
|
||||
def play_epg(channel, timestamp):
|
||||
""" Play the requested item """
|
||||
from resources.lib.modules.tvguide import TvGuide
|
||||
TvGuide().play_epg_datetime(channel, timestamp)
|
||||
|
||||
|
||||
@routing.route('/play/catalog')
|
||||
@routing.route('/play/catalog/<uuid>/<content_type>')
|
||||
def play_catalog(uuid=None, content_type=None):
|
||||
""" Play the requested item """
|
||||
@routing.route('/download/catalog/<uuid>')
|
||||
def download(uuid):
|
||||
""" Download the requested item to cache """
|
||||
from resources.lib.modules.player import Player
|
||||
Player().play(uuid, content_type)
|
||||
Player().download(uuid)
|
||||
|
||||
|
||||
@routing.route('/play/page/<page>')
|
||||
def play_from_page(page):
|
||||
@routing.route('/play/page/<channel>/<page>')
|
||||
def play_from_page(channel, page):
|
||||
""" Play the requested item """
|
||||
try: # Python 3
|
||||
from urllib.parse import unquote
|
||||
except ImportError: # Python 2
|
||||
from urllib import unquote
|
||||
|
||||
from resources.lib.modules.player import Player
|
||||
Player().play_from_page(unquote(page))
|
||||
Player().play_from_page(channel, unquote(page))
|
||||
|
||||
|
||||
@routing.route('/iptv/channels')
|
||||
def iptv_channels():
|
||||
""" Generate channel data for the Kodi PVR integration """
|
||||
from resources.lib.modules.iptvmanager import IPTVManager
|
||||
IPTVManager(int(routing.args['port'][0])).send_channels() # pylint: disable=too-many-function-args
|
||||
|
||||
|
||||
@routing.route('/iptv/epg')
|
||||
def iptv_epg():
|
||||
""" Generate EPG data for the Kodi PVR integration """
|
||||
from resources.lib.modules.iptvmanager import IPTVManager
|
||||
IPTVManager(int(routing.args['port'][0])).send_epg() # pylint: disable=too-many-function-args
|
||||
@routing.route('/metadata/update')
|
||||
def metadata_update():
|
||||
""" Update the metadata for the listings (called from settings) """
|
||||
from resources.lib.modules.metadata import Metadata
|
||||
Metadata().update()
|
||||
|
||||
|
||||
def run(params):
|
||||
""" Run the routing plugin """
|
||||
kodilogging.config()
|
||||
routing.run(params)
|
||||
|
84
resources/lib/downloader.py
Normal file
@ -0,0 +1,84 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Episode Downloader"""
|
||||
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
_LOGGER = logging.getLogger('downloader')
|
||||
|
||||
|
||||
class Downloader:
|
||||
""" Allows to download an episode to disk for caching purposes. """
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def check():
|
||||
""" Check if we have ffmpeg installed."""
|
||||
try:
|
||||
proc = subprocess.Popen(['ffmpeg', '-version'], stderr=subprocess.PIPE)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
# Wait for the process to finish
|
||||
output = proc.stderr.readlines()
|
||||
proc.wait()
|
||||
|
||||
# Check error code
|
||||
if proc.returncode != 0:
|
||||
_LOGGER.error(output)
|
||||
return False
|
||||
|
||||
# TODO: Check version
|
||||
_LOGGER.debug('Output: %s', output)
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def download(stream, output, progress_callback=None):
|
||||
"""Download the stream to destination."""
|
||||
try:
|
||||
cmd = ['ffmpeg', '-y', '-loglevel', 'info', '-i', stream, '-codec', 'copy', output]
|
||||
# `universal_newlines` makes proc.stderr.readline() also work on \r
|
||||
proc = subprocess.Popen(cmd, stderr=subprocess.PIPE, universal_newlines=True)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
regex_total = re.compile(r"Duration: (\d{2}):(\d{2}):(\d{2})")
|
||||
regex_current = re.compile(r"time=(\d{2}):(\d{2}):(\d{2})")
|
||||
|
||||
# Keep looping over ffmpeg output
|
||||
total = None
|
||||
while True:
|
||||
line = proc.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
|
||||
_LOGGER.debug('ffmpeg output: %s', line.rstrip())
|
||||
|
||||
# Read the current status that is printed every few seconds.
|
||||
match = regex_current.search(line)
|
||||
if match and progress_callback:
|
||||
cancel = progress_callback(total, int(match.group(1)) * 3600 + int(match.group(2)) * 60 + int(match.group(3)))
|
||||
if cancel:
|
||||
proc.terminate()
|
||||
continue
|
||||
|
||||
# Read the total stream duration if we haven't found it already. It's there somewhere in the output. We'll find it.
|
||||
if not total:
|
||||
match = regex_total.search(line)
|
||||
if match:
|
||||
total = int(match.group(1)) * 3600 + int(match.group(2)) * 60 + int(match.group(3))
|
||||
|
||||
# Wait for ffmpeg to be fully finished
|
||||
proc.wait()
|
||||
|
||||
# Check error code
|
||||
if proc.returncode != 0:
|
||||
return False
|
||||
|
||||
return True
|
@ -8,23 +8,15 @@ import logging
|
||||
import xbmc
|
||||
import xbmcaddon
|
||||
|
||||
from resources.lib import kodiutils
|
||||
|
||||
ADDON = xbmcaddon.Addon()
|
||||
|
||||
|
||||
class KodiLogHandler(logging.StreamHandler):
|
||||
""" A log handler for Kodi """
|
||||
|
||||
def __init__(self):
|
||||
logging.StreamHandler.__init__(self)
|
||||
formatter = logging.Formatter("[{}] [%(name)s] %(message)s".format(ADDON.getAddonInfo("id")))
|
||||
addon_id = xbmcaddon.Addon().getAddonInfo("id")
|
||||
formatter = logging.Formatter("[{}] [%(name)s] %(message)s".format(addon_id))
|
||||
self.setFormatter(formatter)
|
||||
# xbmc.LOGNOTICE is deprecated in Kodi 19 Matrix
|
||||
if kodiutils.kodi_version_major() > 18:
|
||||
self.info_level = xbmc.LOGINFO
|
||||
else:
|
||||
self.info_level = xbmc.LOGNOTICE
|
||||
|
||||
def emit(self, record):
|
||||
""" Emit a log message """
|
||||
@ -32,16 +24,10 @@ class KodiLogHandler(logging.StreamHandler):
|
||||
logging.CRITICAL: xbmc.LOGFATAL,
|
||||
logging.ERROR: xbmc.LOGERROR,
|
||||
logging.WARNING: xbmc.LOGWARNING,
|
||||
logging.INFO: self.info_level,
|
||||
logging.INFO: xbmc.LOGINFO,
|
||||
logging.DEBUG: xbmc.LOGDEBUG,
|
||||
logging.NOTSET: xbmc.LOGNONE,
|
||||
}
|
||||
|
||||
# Map DEBUG level to info_level if debug logging setting has been activated
|
||||
# This is for troubleshooting only
|
||||
if ADDON.getSetting('debug_logging') == 'true':
|
||||
levels[logging.DEBUG] = self.info_level
|
||||
|
||||
try:
|
||||
xbmc.log(self.format(record), levels[record.levelno])
|
||||
except UnicodeEncodeError:
|
||||
@ -54,5 +40,5 @@ class KodiLogHandler(logging.StreamHandler):
|
||||
def config():
|
||||
""" Setup the logger with this handler """
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.DEBUG) # Make sure we pass all messages, Kodi will do some filtering itself.
|
||||
logger.addHandler(KodiLogHandler())
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
@ -4,63 +4,35 @@
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import xbmc
|
||||
import xbmcaddon
|
||||
import xbmcgui
|
||||
import xbmcplugin
|
||||
import xbmcvfs
|
||||
|
||||
try: # Python 3
|
||||
from html import unescape
|
||||
except ImportError: # Python 2
|
||||
from HTMLParser import HTMLParser
|
||||
|
||||
unescape = HTMLParser().unescape
|
||||
|
||||
ADDON = xbmcaddon.Addon()
|
||||
|
||||
SORT_METHODS = {
|
||||
'unsorted': xbmcplugin.SORT_METHOD_UNSORTED,
|
||||
'label': xbmcplugin.SORT_METHOD_LABEL_IGNORE_FOLDERS,
|
||||
'title': xbmcplugin.SORT_METHOD_TITLE,
|
||||
'episode': xbmcplugin.SORT_METHOD_EPISODE,
|
||||
'duration': xbmcplugin.SORT_METHOD_DURATION,
|
||||
'year': xbmcplugin.SORT_METHOD_VIDEO_YEAR,
|
||||
'date': xbmcplugin.SORT_METHOD_DATE
|
||||
}
|
||||
SORT_METHODS = dict(
|
||||
unsorted=xbmcplugin.SORT_METHOD_UNSORTED,
|
||||
label=xbmcplugin.SORT_METHOD_LABEL_IGNORE_FOLDERS,
|
||||
title=xbmcplugin.SORT_METHOD_TITLE,
|
||||
episode=xbmcplugin.SORT_METHOD_EPISODE,
|
||||
duration=xbmcplugin.SORT_METHOD_DURATION,
|
||||
year=xbmcplugin.SORT_METHOD_VIDEO_YEAR,
|
||||
date=xbmcplugin.SORT_METHOD_DATE,
|
||||
)
|
||||
DEFAULT_SORT_METHODS = [
|
||||
'unsorted', 'title'
|
||||
]
|
||||
|
||||
HTML_MAPPING = [
|
||||
(re.compile(r'<(/?)i(|\s[^>]+)>', re.I), '[\\1I]'),
|
||||
(re.compile(r'<(/?)b(|\s[^>]+)>', re.I), '[\\1B]'),
|
||||
(re.compile(r'<em(|\s[^>]+)>', re.I), '[I]'),
|
||||
(re.compile(r'</em>', re.I), '[/I]'),
|
||||
(re.compile(r'<(strong|h\d)>', re.I), '[B]'),
|
||||
(re.compile(r'</(strong|h\d)>', re.I), '[/B]'),
|
||||
(re.compile(r'<li>', re.I), '- '),
|
||||
(re.compile(r'</?(li|ul|ol)(|\s[^>]+)>', re.I), '\n'),
|
||||
(re.compile(r'</?(code|div|p|pre|span)(|\s[^>]+)>', re.I), ''),
|
||||
(re.compile(r'<br />', re.I), '\n'), # Remove newlines
|
||||
(re.compile('( \n){2,}', re.I), '\n'), # Remove repeating non-blocking spaced newlines
|
||||
(re.compile(' +', re.I), ' '), # Remove double spaces
|
||||
]
|
||||
|
||||
STREAM_HLS = 'hls'
|
||||
STREAM_DASH = 'mpd'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER = logging.getLogger('kodiutils')
|
||||
|
||||
|
||||
class TitleItem:
|
||||
""" This helper object holds all information to be used with Kodi xbmc's ListItem object """
|
||||
|
||||
def __init__(self, title, path=None, art_dict=None, info_dict=None, prop_dict=None, stream_dict=None,
|
||||
context_menu=None, subtitles_path=None, is_playable=False, visible=True):
|
||||
def __init__(self, title, path=None, art_dict=None, info_dict=None, prop_dict=None, stream_dict=None, context_menu=None, subtitles_path=None,
|
||||
is_playable=False):
|
||||
""" The constructor for the TitleItem class
|
||||
:type title: str
|
||||
:type path: str
|
||||
@ -71,7 +43,6 @@ class TitleItem:
|
||||
:type context_menu: list[tuple[str, str]]
|
||||
:type subtitles_path: list[str]
|
||||
:type is_playable: bool
|
||||
:type visible: bool
|
||||
"""
|
||||
self.title = title
|
||||
self.path = path
|
||||
@ -82,7 +53,6 @@ class TitleItem:
|
||||
self.context_menu = context_menu
|
||||
self.subtitles_path = subtitles_path
|
||||
self.is_playable = is_playable
|
||||
self.visible = visible
|
||||
|
||||
def __repr__(self):
|
||||
return "%r" % self.__dict__
|
||||
@ -111,15 +81,6 @@ def from_unicode(text, encoding='utf-8', errors='strict'):
|
||||
return text
|
||||
|
||||
|
||||
def html_to_kodi(text):
|
||||
"""Convert HTML content into Kodi formatted text"""
|
||||
if not text:
|
||||
return text
|
||||
for key, val in HTML_MAPPING:
|
||||
text = key.sub(val, text)
|
||||
return unescape(text).strip()
|
||||
|
||||
|
||||
def addon_icon():
|
||||
"""Cache and return add-on icon"""
|
||||
return get_addon_info('icon')
|
||||
@ -147,10 +108,7 @@ def addon_path():
|
||||
|
||||
def addon_profile():
|
||||
"""Cache and return add-on profile"""
|
||||
try: # Kodi 19
|
||||
return to_unicode(xbmcvfs.translatePath(ADDON.getAddonInfo('profile')))
|
||||
except AttributeError: # Kodi 18
|
||||
return to_unicode(xbmc.translatePath(ADDON.getAddonInfo('profile')))
|
||||
return to_unicode(xbmc.translatePath(ADDON.getAddonInfo('profile')))
|
||||
|
||||
|
||||
def url_for(name, *args, **kwargs):
|
||||
@ -160,7 +118,7 @@ def url_for(name, *args, **kwargs):
|
||||
|
||||
|
||||
def show_listing(title_items, category=None, sort=None, content=None, cache=True):
|
||||
"""Show a virtual directory in Kodi"""
|
||||
""" Show a virtual directory in Kodi """
|
||||
from resources.lib.addon import routing
|
||||
|
||||
if content:
|
||||
@ -193,9 +151,6 @@ def show_listing(title_items, category=None, sort=None, content=None, cache=True
|
||||
# Add the listings
|
||||
listing = []
|
||||
for title_item in title_items:
|
||||
if not title_item.visible:
|
||||
continue
|
||||
|
||||
# Three options:
|
||||
# - item is a virtual directory/folder (not playable, path)
|
||||
# - item is a playable file (playable, path)
|
||||
@ -233,7 +188,7 @@ def show_listing(title_items, category=None, sort=None, content=None, cache=True
|
||||
xbmcplugin.endOfDirectory(routing.handle, succeeded, cacheToDisc=cache)
|
||||
|
||||
|
||||
def play(stream, stream_type=STREAM_HLS, license_key=None, title=None, art_dict=None, info_dict=None, prop_dict=None, stream_dict=None):
|
||||
def play(stream, title=None, art_dict=None, info_dict=None, prop_dict=None):
|
||||
"""Play the given stream"""
|
||||
from resources.lib.addon import routing
|
||||
|
||||
@ -244,41 +199,12 @@ def play(stream, stream_type=STREAM_HLS, license_key=None, title=None, art_dict=
|
||||
play_item.setInfo(type='video', infoLabels=info_dict)
|
||||
if prop_dict:
|
||||
play_item.setProperties(prop_dict)
|
||||
if stream_dict:
|
||||
play_item.addStreamInfo('video', stream_dict)
|
||||
|
||||
# Setup Inputstream Adaptive
|
||||
if kodi_version_major() >= 19:
|
||||
play_item.setProperty('inputstream', 'inputstream.adaptive')
|
||||
else:
|
||||
play_item.setProperty('inputstreamaddon', 'inputstream.adaptive')
|
||||
|
||||
if stream_type == STREAM_HLS:
|
||||
play_item.setProperty('inputstream.adaptive.manifest_type', 'hls')
|
||||
play_item.setMimeType('application/vnd.apple.mpegurl')
|
||||
|
||||
elif stream_type == STREAM_DASH:
|
||||
play_item.setProperty('inputstream.adaptive.manifest_type', 'mpd')
|
||||
play_item.setMimeType('application/dash+xml')
|
||||
import inputstreamhelper
|
||||
if license_key is not None:
|
||||
# DRM protected MPEG-DASH
|
||||
is_helper = inputstreamhelper.Helper('mpd', drm='com.widevine.alpha')
|
||||
if is_helper.check_inputstream():
|
||||
play_item.setProperty('inputstream.adaptive.license_type', 'com.widevine.alpha')
|
||||
play_item.setProperty('inputstream.adaptive.license_key', license_key)
|
||||
else:
|
||||
# Unprotected MPEG-DASH
|
||||
is_helper = inputstreamhelper.Helper('mpd')
|
||||
is_helper.check_inputstream()
|
||||
|
||||
play_item.setContentLookup(False)
|
||||
|
||||
xbmcplugin.setResolvedUrl(routing.handle, True, listitem=play_item)
|
||||
|
||||
|
||||
def get_search_string(heading='', message=''):
|
||||
"""Ask the user for a search string"""
|
||||
""" Ask the user for a search string """
|
||||
search_string = None
|
||||
keyboard = xbmc.Keyboard(message, heading)
|
||||
keyboard.doModal()
|
||||
@ -289,39 +215,40 @@ def get_search_string(heading='', message=''):
|
||||
|
||||
def ok_dialog(heading='', message=''):
|
||||
"""Show Kodi's OK dialog"""
|
||||
from xbmcgui import Dialog
|
||||
if not heading:
|
||||
heading = addon_name()
|
||||
if kodi_version_major() < 19:
|
||||
# pylint: disable=unexpected-keyword-arg,no-value-for-parameter
|
||||
return xbmcgui.Dialog().ok(heading=heading, line1=message)
|
||||
return xbmcgui.Dialog().ok(heading=heading, message=message)
|
||||
return Dialog().ok(heading=heading, line1=message)
|
||||
return Dialog().ok(heading=heading, message=message)
|
||||
|
||||
|
||||
def yesno_dialog(heading='', message='', nolabel=None, yeslabel=None, autoclose=0):
|
||||
"""Show Kodi's Yes/No dialog"""
|
||||
from xbmcgui import Dialog
|
||||
if not heading:
|
||||
heading = addon_name()
|
||||
if kodi_version_major() < 19:
|
||||
# pylint: disable=unexpected-keyword-arg,no-value-for-parameter
|
||||
return xbmcgui.Dialog().yesno(heading=heading, line1=message, nolabel=nolabel, yeslabel=yeslabel, autoclose=autoclose)
|
||||
return xbmcgui.Dialog().yesno(heading=heading, message=message, nolabel=nolabel, yeslabel=yeslabel, autoclose=autoclose)
|
||||
return Dialog().yesno(heading=heading, line1=message, nolabel=nolabel, yeslabel=yeslabel, autoclose=autoclose)
|
||||
return Dialog().yesno(heading=heading, message=message, nolabel=nolabel, yeslabel=yeslabel, autoclose=autoclose)
|
||||
|
||||
|
||||
def notification(heading='', message='', icon='info', time=4000):
|
||||
"""Show a Kodi notification"""
|
||||
from xbmcgui import Dialog
|
||||
if not heading:
|
||||
heading = addon_name()
|
||||
if not icon:
|
||||
icon = addon_icon()
|
||||
xbmcgui.Dialog().notification(heading=heading, message=message, icon=icon, time=time)
|
||||
Dialog().notification(heading=heading, message=message, icon=icon, time=time)
|
||||
|
||||
|
||||
def multiselect(heading='', options=None, autoclose=0, preselect=None, use_details=False):
|
||||
"""Show a Kodi multi-select dialog"""
|
||||
from xbmcgui import Dialog
|
||||
if not heading:
|
||||
heading = addon_name()
|
||||
return xbmcgui.Dialog().multiselect(heading=heading, options=options, autoclose=autoclose, preselect=preselect,
|
||||
useDetails=use_details)
|
||||
return Dialog().multiselect(heading=heading, options=options, autoclose=autoclose, preselect=preselect, useDetails=use_details)
|
||||
|
||||
|
||||
class progress(xbmcgui.DialogProgress, object): # pylint: disable=invalid-name,useless-object-inheritance
|
||||
@ -337,17 +264,13 @@ class progress(xbmcgui.DialogProgress, object): # pylint: disable=invalid-name,
|
||||
def create(self, heading, message=''): # pylint: disable=arguments-differ
|
||||
"""Create and show a progress dialog"""
|
||||
if kodi_version_major() < 19:
|
||||
lines = message.split('\n', 2)
|
||||
line1, line2, line3 = (lines + [None] * (3 - len(lines)))
|
||||
return super(progress, self).create(heading, line1=line1, line2=line2, line3=line3) # pylint: disable=unexpected-keyword-arg,no-value-for-parameter
|
||||
return super(progress, self).create(heading, line1=message)
|
||||
return super(progress, self).create(heading, message=message)
|
||||
|
||||
def update(self, percent, message=''): # pylint: disable=arguments-differ
|
||||
"""Update the progress dialog"""
|
||||
if kodi_version_major() < 19:
|
||||
lines = message.split('\n', 2)
|
||||
line1, line2, line3 = (lines + [None] * (3 - len(lines)))
|
||||
return super(progress, self).update(percent, line1=line1, line2=line2, line3=line3) # pylint: disable=unexpected-keyword-arg,no-value-for-parameter
|
||||
return super(progress, self).update(percent, line1=message)
|
||||
return super(progress, self).update(percent, message=message)
|
||||
|
||||
|
||||
@ -355,7 +278,7 @@ def set_locale():
|
||||
"""Load the proper locale for date strings, only once"""
|
||||
if hasattr(set_locale, 'cached'):
|
||||
return getattr(set_locale, 'cached')
|
||||
from locale import LC_ALL, Error, setlocale
|
||||
from locale import Error, LC_ALL, setlocale
|
||||
locale_lang = get_global_setting('locale.language').split('.')[-1]
|
||||
locale_lang = locale_lang[:-2] + locale_lang[-2:].upper()
|
||||
# NOTE: setlocale() only works if the platform supports the Kodi configured locale
|
||||
@ -470,74 +393,13 @@ def open_settings():
|
||||
|
||||
def get_global_setting(key):
|
||||
"""Get a Kodi setting"""
|
||||
result = jsonrpc(method='Settings.GetSettingValue', params={'setting': key})
|
||||
result = jsonrpc(method='Settings.GetSettingValue', params=dict(setting=key))
|
||||
return result.get('result', {}).get('value')
|
||||
|
||||
|
||||
def set_global_setting(key, value):
|
||||
"""Set a Kodi setting"""
|
||||
return jsonrpc(method='Settings.SetSettingValue', params={'setting': key, 'value': value})
|
||||
|
||||
|
||||
def has_socks():
|
||||
"""Test if socks is installed, and use a static variable to remember"""
|
||||
if hasattr(has_socks, 'cached'):
|
||||
return getattr(has_socks, 'cached')
|
||||
try:
|
||||
import socks # noqa: F401; pylint: disable=unused-variable,unused-import
|
||||
except ImportError:
|
||||
has_socks.cached = False
|
||||
return None # Detect if this is the first run
|
||||
has_socks.cached = True
|
||||
return True
|
||||
|
||||
|
||||
def get_proxies():
|
||||
"""Return a usable proxies dictionary from Kodi proxy settings"""
|
||||
# Use proxy settings from environment variables
|
||||
env_http_proxy = os.environ.get('HTTP_PROXY')
|
||||
env_https_proxy = os.environ.get('HTTPS_PROXY')
|
||||
if env_http_proxy:
|
||||
return {'http': env_http_proxy, 'https': env_https_proxy or env_http_proxy}
|
||||
|
||||
usehttpproxy = get_global_setting('network.usehttpproxy')
|
||||
if usehttpproxy is not True:
|
||||
return None
|
||||
|
||||
try:
|
||||
httpproxytype = int(get_global_setting('network.httpproxytype'))
|
||||
except ValueError:
|
||||
httpproxytype = 0
|
||||
|
||||
socks_supported = has_socks()
|
||||
if httpproxytype != 0 and not socks_supported:
|
||||
# Only open the dialog the first time (to avoid multiple popups)
|
||||
if socks_supported is None:
|
||||
ok_dialog('', localize(30966)) # Requires PySocks
|
||||
return None
|
||||
|
||||
proxy_types = ['http', 'socks4', 'socks4a', 'socks5', 'socks5h']
|
||||
|
||||
proxy = {
|
||||
'scheme': proxy_types[httpproxytype] if 0 <= httpproxytype < 5 else 'http',
|
||||
'server': get_global_setting('network.httpproxyserver'),
|
||||
'port': get_global_setting('network.httpproxyport'),
|
||||
'username': get_global_setting('network.httpproxyusername'),
|
||||
'password': get_global_setting('network.httpproxypassword')
|
||||
}
|
||||
|
||||
if proxy.get('username') and proxy.get('password') and proxy.get('server') and proxy.get('port'):
|
||||
proxy_address = '{scheme}://{username}:{password}@{server}:{port}'.format(**proxy)
|
||||
elif proxy.get('username') and proxy.get('server') and proxy.get('port'):
|
||||
proxy_address = '{scheme}://{username}@{server}:{port}'.format(**proxy)
|
||||
elif proxy.get('server') and proxy.get('port'):
|
||||
proxy_address = '{scheme}://{server}:{port}'.format(**proxy)
|
||||
elif proxy.get('server'):
|
||||
proxy_address = '{scheme}://{server}'.format(**proxy)
|
||||
else:
|
||||
return None
|
||||
|
||||
return {'http': proxy_address, 'https': proxy_address}
|
||||
return jsonrpc(method='Settings.SetSettingValue', params=dict(setting=key, value=value))
|
||||
|
||||
|
||||
def get_cond_visibility(condition):
|
||||
@ -563,14 +425,14 @@ def kodi_version_major():
|
||||
def get_tokens_path():
|
||||
"""Cache and return the userdata tokens path"""
|
||||
if not hasattr(get_tokens_path, 'cached'):
|
||||
get_tokens_path.cached = os.path.join(addon_profile(), 'tokens')
|
||||
get_tokens_path.cached = addon_profile() + 'tokens/'
|
||||
return getattr(get_tokens_path, 'cached')
|
||||
|
||||
|
||||
def get_cache_path():
|
||||
"""Cache and return the userdata cache path"""
|
||||
if not hasattr(get_cache_path, 'cached'):
|
||||
get_cache_path.cached = os.path.join(addon_profile(), 'cache')
|
||||
get_cache_path.cached = addon_profile() + 'cache/'
|
||||
return getattr(get_cache_path, 'cached')
|
||||
|
||||
|
||||
@ -629,13 +491,3 @@ def jsonrpc(*args, **kwargs):
|
||||
if kwargs.get('jsonrpc') is None:
|
||||
kwargs.update(jsonrpc='2.0')
|
||||
return loads(xbmc.executeJSONRPC(dumps(kwargs)))
|
||||
|
||||
|
||||
def listdir(path):
|
||||
"""Return all files in a directory (using xbmcvfs)"""
|
||||
return xbmcvfs.listdir(path)
|
||||
|
||||
|
||||
def delete(path):
|
||||
"""Remove a file (using xbmcvfs)"""
|
||||
return xbmcvfs.delete(path)
|
||||
|
@ -8,10 +8,11 @@ import logging
|
||||
from resources.lib import kodiutils
|
||||
from resources.lib.kodiutils import TitleItem
|
||||
from resources.lib.modules.menu import Menu
|
||||
from resources.lib.viervijfzes import CHANNELS
|
||||
from resources.lib.viervijfzes.auth import AuthApi
|
||||
from resources.lib.viervijfzes.content import CACHE_PREVENT, ContentApi, UnavailableException
|
||||
from resources.lib.viervijfzes.content import ContentApi, UnavailableException, CACHE_PREVENT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER = logging.getLogger('catalog')
|
||||
|
||||
|
||||
class Catalog:
|
||||
@ -19,18 +20,21 @@ class Catalog:
|
||||
|
||||
def __init__(self):
|
||||
""" Initialise object """
|
||||
self._auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
|
||||
self._api = ContentApi(self._auth, cache_path=kodiutils.get_cache_path())
|
||||
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
|
||||
self._api = ContentApi(auth, cache_path=kodiutils.get_cache_path())
|
||||
self._menu = Menu()
|
||||
|
||||
def show_catalog(self):
|
||||
""" Show all the programs of all channels """
|
||||
try:
|
||||
items = self._api.get_programs()
|
||||
items = []
|
||||
for channel in list(CHANNELS):
|
||||
items.extend(self._api.get_programs(channel))
|
||||
except Exception as ex:
|
||||
kodiutils.notification(message=str(ex))
|
||||
raise
|
||||
|
||||
listing = [Menu.generate_titleitem(item) for item in items]
|
||||
listing = [self._menu.generate_titleitem(item) for item in items]
|
||||
|
||||
# Sort items by title
|
||||
# Used for A-Z listing or when movies and episodes are mixed.
|
||||
@ -48,51 +52,53 @@ class Catalog:
|
||||
|
||||
listing = []
|
||||
for item in items:
|
||||
listing.append(Menu.generate_titleitem(item))
|
||||
listing.append(self._menu.generate_titleitem(item))
|
||||
|
||||
# Sort items by title
|
||||
# Used for A-Z listing or when movies and episodes are mixed.
|
||||
kodiutils.show_listing(listing, 30003, content='tvshows', sort='title')
|
||||
|
||||
def show_program(self, program_id):
|
||||
def show_program(self, channel, program_id):
|
||||
""" Show a program from the catalog
|
||||
:type channel: str
|
||||
:type program_id: str
|
||||
"""
|
||||
try:
|
||||
program = self._api.get_program(program_id, extract_clips=True, cache=CACHE_PREVENT) # Use CACHE_PREVENT since we want fresh data
|
||||
program = self._api.get_program(channel, program_id, cache=CACHE_PREVENT) # Use CACHE_PREVENT since we want fresh data
|
||||
except UnavailableException:
|
||||
kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue.
|
||||
kodiutils.end_of_directory()
|
||||
return
|
||||
|
||||
if not program.episodes and not program.clips:
|
||||
if not program.episodes:
|
||||
kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue.
|
||||
kodiutils.end_of_directory()
|
||||
return
|
||||
|
||||
# Go directly to the season when we have only one season and no clips
|
||||
if not program.clips and len(program.seasons) == 1:
|
||||
self.show_program_season(program_id, list(program.seasons.values())[0].uuid)
|
||||
# Go directly to the season when we have only one season
|
||||
if len(program.seasons) == 1:
|
||||
self.show_program_season(channel, program_id, list(program.seasons.values())[0].uuid)
|
||||
return
|
||||
|
||||
studio = CHANNELS.get(program.channel, {}).get('studio_icon')
|
||||
|
||||
listing = []
|
||||
|
||||
# Add an '* All seasons' entry when configured in Kodi
|
||||
if program.seasons and kodiutils.get_global_setting('videolibrary.showallitems') is True:
|
||||
if kodiutils.get_global_setting('videolibrary.showallitems') is True:
|
||||
listing.append(
|
||||
TitleItem(
|
||||
title='* %s' % kodiutils.localize(30204), # * All seasons
|
||||
path=kodiutils.url_for('show_catalog_program_season', program=program_id, season='-1'),
|
||||
path=kodiutils.url_for('show_catalog_program_season', channel=channel, program=program_id, season='-1'),
|
||||
art_dict={
|
||||
'fanart': program.fanart,
|
||||
'poster': program.poster,
|
||||
'landscape': program.thumb,
|
||||
'fanart': program.background,
|
||||
},
|
||||
info_dict={
|
||||
'tvshowtitle': program.title,
|
||||
'title': kodiutils.localize(30204), # All seasons
|
||||
'plot': program.description,
|
||||
'set': program.title,
|
||||
'studio': studio,
|
||||
}
|
||||
)
|
||||
)
|
||||
@ -102,37 +108,16 @@ class Catalog:
|
||||
listing.append(
|
||||
TitleItem(
|
||||
title=season.title, # kodiutils.localize(30205, season=season.number), # Season {season}
|
||||
path=kodiutils.url_for('show_catalog_program_season', program=program_id, season=season.uuid),
|
||||
path=kodiutils.url_for('show_catalog_program_season', channel=channel, program=program_id, season=season.uuid),
|
||||
art_dict={
|
||||
'fanart': program.fanart,
|
||||
'poster': program.poster,
|
||||
'landscape': program.thumb,
|
||||
'fanart': program.background,
|
||||
},
|
||||
info_dict={
|
||||
'tvshowtitle': program.title,
|
||||
'title': kodiutils.localize(30205, season=season.number) if season.number else season.title, # Season {season}
|
||||
'plot': season.description or program.description,
|
||||
'set': program.title,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Add Clips
|
||||
if program.clips:
|
||||
listing.append(
|
||||
TitleItem(
|
||||
title=kodiutils.localize(30059, program=program.title), # Clips for {program}
|
||||
path=kodiutils.url_for('show_catalog_program_clips', program=program_id),
|
||||
art_dict={
|
||||
'fanart': program.fanart,
|
||||
'poster': program.poster,
|
||||
'landscape': program.thumb,
|
||||
},
|
||||
info_dict={
|
||||
'tvshowtitle': program.title,
|
||||
'title': kodiutils.localize(30059, program=program.title), # Clips for {program}
|
||||
'plot': kodiutils.localize(30060, program=program.title), # Watch short clips of {program}
|
||||
'title': kodiutils.localize(30205, season=season.number), # Season {season}
|
||||
'plot': season.description,
|
||||
'set': program.title,
|
||||
'studio': studio,
|
||||
}
|
||||
)
|
||||
)
|
||||
@ -140,13 +125,14 @@ class Catalog:
|
||||
# Sort by label. Some programs return seasons unordered.
|
||||
kodiutils.show_listing(listing, 30003, content='tvshows')
|
||||
|
||||
def show_program_season(self, program_id, season_uuid):
|
||||
def show_program_season(self, channel, program_id, season_uuid):
|
||||
""" Show the episodes of a program from the catalog
|
||||
:type channel: str
|
||||
:type program_id: str
|
||||
:type season_uuid: str
|
||||
"""
|
||||
try:
|
||||
program = self._api.get_program(program_id)
|
||||
program = self._api.get_program(channel, program_id)
|
||||
except UnavailableException:
|
||||
kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue.
|
||||
kodiutils.end_of_directory()
|
||||
@ -159,122 +145,7 @@ class Catalog:
|
||||
# Show the episodes of the season that was selected
|
||||
episodes = [e for e in program.episodes if e.season_uuid == season_uuid]
|
||||
|
||||
listing = [Menu.generate_titleitem(episode) for episode in episodes]
|
||||
listing = [self._menu.generate_titleitem(episode) for episode in episodes]
|
||||
|
||||
# Sort by episode number by default. Takes seasons into account.
|
||||
kodiutils.show_listing(listing, 30003, content='episodes', sort=['episode', 'duration'])
|
||||
|
||||
def show_program_clips(self, program_id):
|
||||
""" Show the clips of a program from the catalog
|
||||
:type program_id: str
|
||||
"""
|
||||
try:
|
||||
# We need to query the backend, since we don't cache clips.
|
||||
program = self._api.get_program(program_id, extract_clips=True, cache=CACHE_PREVENT)
|
||||
except UnavailableException:
|
||||
kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue.
|
||||
kodiutils.end_of_directory()
|
||||
return
|
||||
|
||||
listing = [Menu.generate_titleitem(episode) for episode in program.clips]
|
||||
|
||||
# Sort like we get our results back.
|
||||
kodiutils.show_listing(listing, 30003, content='episodes')
|
||||
|
||||
def show_categories(self):
|
||||
""" Shows the categories """
|
||||
categories = self._api.get_categories()
|
||||
|
||||
listing = []
|
||||
for category in categories:
|
||||
listing.append(TitleItem(title=category.title,
|
||||
path=kodiutils.url_for('show_category', category=category.uuid),
|
||||
info_dict={
|
||||
'title': category.title,
|
||||
}))
|
||||
|
||||
kodiutils.show_listing(listing, 30003, sort=['title'])
|
||||
|
||||
def show_category(self, uuid):
|
||||
""" Shows a category """
|
||||
programs = self._api.get_category_content(int(uuid))
|
||||
|
||||
listing = [
|
||||
Menu.generate_titleitem(program) for program in programs
|
||||
]
|
||||
|
||||
kodiutils.show_listing(listing, 30003, content='tvshows')
|
||||
|
||||
def show_recommendations(self):
|
||||
""" Shows the recommendations """
|
||||
# "Meest bekeken" has a specific API endpoint, the other categories are scraped from the website.
|
||||
listing = [
|
||||
TitleItem(title='Meest bekeken',
|
||||
path=kodiutils.url_for('show_recommendations_category', category='meest-bekeken'),
|
||||
info_dict={
|
||||
'title': 'Meest bekeken',
|
||||
})
|
||||
]
|
||||
|
||||
recommendations = self._api.get_recommendation_categories()
|
||||
for category in recommendations:
|
||||
listing.append(TitleItem(title=category.title,
|
||||
path=kodiutils.url_for('show_recommendations_category', category=category.uuid),
|
||||
info_dict={
|
||||
'title': category.title,
|
||||
}))
|
||||
|
||||
kodiutils.show_listing(listing, 30005, content='tvshows')
|
||||
|
||||
def show_recommendations_category(self, uuid):
|
||||
""" Shows the a category of the recommendations """
|
||||
if uuid == 'meest-bekeken':
|
||||
programs = self._api.get_popular_programs()
|
||||
episodes = []
|
||||
else:
|
||||
recommendations = self._api.get_recommendation_categories()
|
||||
category = next(category for category in recommendations if category.uuid == uuid)
|
||||
programs = category.programs
|
||||
episodes = category.episodes
|
||||
|
||||
listing = []
|
||||
for episode in episodes:
|
||||
title_item = Menu.generate_titleitem(episode)
|
||||
if episode.program_title:
|
||||
title_item.info_dict['title'] = episode.program_title + ' - ' + title_item.title
|
||||
listing.append(title_item)
|
||||
|
||||
for program in programs:
|
||||
listing.append(Menu.generate_titleitem(program))
|
||||
|
||||
kodiutils.show_listing(listing, 30005, content='tvshows')
|
||||
|
||||
def show_mylist(self):
|
||||
""" Show the programs of My List """
|
||||
mylist = self._api.get_mylist()
|
||||
|
||||
listing = [Menu.generate_titleitem(item) for item in mylist]
|
||||
|
||||
# Sort items by title
|
||||
# Used for A-Z listing or when movies and episodes are mixed.
|
||||
kodiutils.show_listing(listing, 30011, content='tvshows', sort='title')
|
||||
|
||||
def mylist_add(self, uuid):
|
||||
""" Add a program to My List """
|
||||
if not uuid:
|
||||
kodiutils.end_of_directory()
|
||||
return
|
||||
|
||||
self._api.mylist_add(uuid)
|
||||
|
||||
kodiutils.end_of_directory()
|
||||
|
||||
def mylist_del(self, uuid):
|
||||
""" Remove a program from My List """
|
||||
if not uuid:
|
||||
kodiutils.end_of_directory()
|
||||
return
|
||||
|
||||
self._api.mylist_del(uuid)
|
||||
|
||||
kodiutils.end_of_directory()
|
||||
|
@ -8,10 +8,8 @@ import logging
|
||||
from resources.lib import kodiutils
|
||||
from resources.lib.kodiutils import TitleItem
|
||||
from resources.lib.viervijfzes import CHANNELS, STREAM_DICT
|
||||
from resources.lib.viervijfzes.auth import AuthApi
|
||||
from resources.lib.viervijfzes.content import ContentApi
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER = logging.getLogger('channels')
|
||||
|
||||
|
||||
class Channels:
|
||||
@ -19,8 +17,6 @@ class Channels:
|
||||
|
||||
def __init__(self):
|
||||
""" Initialise object """
|
||||
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
|
||||
self._api = ContentApi(auth, cache_path=kodiutils.get_cache_path())
|
||||
|
||||
@staticmethod
|
||||
def show_channels():
|
||||
@ -37,7 +33,7 @@ class Channels:
|
||||
(
|
||||
kodiutils.localize(30053, channel=channel.get('name')), # TV Guide for {channel}
|
||||
'Container.Update(%s)' %
|
||||
kodiutils.url_for('show_channel_tvguide', channel=channel.get('epg'))
|
||||
kodiutils.url_for('show_tvguide_channel', channel=channel.get('epg'))
|
||||
)
|
||||
]
|
||||
|
||||
@ -54,6 +50,7 @@ class Channels:
|
||||
'plot': None,
|
||||
'playcount': 0,
|
||||
'mediatype': 'video',
|
||||
'studio': channel.get('studio_icon'),
|
||||
},
|
||||
stream_dict=STREAM_DICT,
|
||||
context_menu=context_menu
|
||||
@ -63,67 +60,43 @@ class Channels:
|
||||
kodiutils.show_listing(listing, 30007)
|
||||
|
||||
@staticmethod
|
||||
def show_channel_menu(channel):
|
||||
def show_channel_menu(key):
|
||||
""" Shows a TV channel
|
||||
:type channel: str
|
||||
:type key: str
|
||||
"""
|
||||
channel_info = CHANNELS[channel]
|
||||
channel = CHANNELS[key]
|
||||
|
||||
# Lookup the high resolution logo based on the channel name
|
||||
fanart = '{path}/resources/logos/{logo}'.format(path=kodiutils.addon_path(), logo=channel_info.get('background'))
|
||||
icon = '{path}/resources/logos/{logo}'.format(path=kodiutils.addon_path(), logo=channel_info.get('logo'))
|
||||
fanart = '{path}/resources/logos/{logo}'.format(path=kodiutils.addon_path(), logo=channel.get('background'))
|
||||
|
||||
listing = []
|
||||
|
||||
listing.append(
|
||||
listing = [
|
||||
TitleItem(
|
||||
title=kodiutils.localize(30052, channel=channel_info.get('name')), # Watch live {channel}
|
||||
path=kodiutils.url_for('play_live', channel=channel_info.get('name')) + '?.pvr',
|
||||
title=kodiutils.localize(30053, channel=channel.get('name')), # TV Guide for {channel}
|
||||
path=kodiutils.url_for('show_tvguide_channel', channel=key),
|
||||
art_dict={
|
||||
'icon': icon,
|
||||
'icon': 'DefaultAddonTvInfo.png',
|
||||
'fanart': fanart,
|
||||
},
|
||||
info_dict={
|
||||
'plot': kodiutils.localize(30052, channel=channel_info.get('name')), # Watch live {channel}
|
||||
'playcount': 0,
|
||||
'mediatype': 'video',
|
||||
},
|
||||
is_playable=True,
|
||||
)
|
||||
)
|
||||
|
||||
if channel_info.get('epg_id'):
|
||||
listing.append(
|
||||
TitleItem(
|
||||
title=kodiutils.localize(30053, channel=channel_info.get('name')), # TV Guide for {channel}
|
||||
path=kodiutils.url_for('show_channel_tvguide', channel=channel),
|
||||
art_dict={
|
||||
'icon': 'DefaultAddonTvInfo.png',
|
||||
'fanart': fanart,
|
||||
},
|
||||
info_dict={
|
||||
'plot': kodiutils.localize(30054, channel=channel_info.get('name')), # Browse the TV Guide for {channel}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
listing.append(
|
||||
'plot': kodiutils.localize(30054, channel=channel.get('name')), # Browse the TV Guide for {channel}
|
||||
}
|
||||
),
|
||||
TitleItem(
|
||||
title=kodiutils.localize(30055, channel=channel_info.get('name')), # Catalog for {channel}
|
||||
path=kodiutils.url_for('show_channel_catalog', channel=channel),
|
||||
title=kodiutils.localize(30055, channel=channel.get('name')), # Catalog for {channel}
|
||||
path=kodiutils.url_for('show_catalog_channel', channel=key),
|
||||
art_dict={
|
||||
'icon': 'DefaultMovieTitle.png',
|
||||
'fanart': fanart,
|
||||
},
|
||||
info_dict={
|
||||
'plot': kodiutils.localize(30056, channel=channel_info.get('name')), # Browse the Catalog for {channel}
|
||||
'plot': kodiutils.localize(30056, channel=channel.get('name')), # Browse the Catalog for {channel}
|
||||
}
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
# Add YouTube channels
|
||||
if kodiutils.get_cond_visibility('System.HasAddon(plugin.video.youtube)') != 0:
|
||||
for youtube in channel_info.get('youtube', []):
|
||||
for youtube in channel.get('youtube', []):
|
||||
listing.append(
|
||||
TitleItem(
|
||||
title=kodiutils.localize(30206, label=youtube.get('label')), # Watch {label} on YouTube
|
||||
|
@ -1,98 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Implementation of IPTVManager class"""
|
||||
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from resources.lib import kodiutils
|
||||
from resources.lib.viervijfzes import CHANNELS
|
||||
from resources.lib.viervijfzes.epg import EpgApi
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IPTVManager:
|
||||
"""Interface to IPTV Manager"""
|
||||
|
||||
def __init__(self, port):
|
||||
"""Initialize IPTV Manager object"""
|
||||
self.port = port
|
||||
|
||||
def via_socket(func): # pylint: disable=no-self-argument
|
||||
"""Send the output of the wrapped function to socket"""
|
||||
|
||||
def send(self):
|
||||
"""Decorator to send over a socket"""
|
||||
import json
|
||||
import socket
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.connect(('127.0.0.1', self.port))
|
||||
try:
|
||||
sock.sendall(json.dumps(func()).encode()) # pylint: disable=not-callable
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
return send
|
||||
|
||||
@via_socket
|
||||
def send_channels(): # pylint: disable=no-method-argument
|
||||
"""Return JSON-STREAMS formatted information to IPTV Manager"""
|
||||
streams = []
|
||||
for key, channel in CHANNELS.items():
|
||||
if channel.get('iptv_id'):
|
||||
streams.append({
|
||||
'id': channel.get('iptv_id'),
|
||||
'name': channel.get('name'),
|
||||
'logo': 'special://home/addons/{addon}/resources/logos/{logo}'.format(addon=kodiutils.addon_id(),
|
||||
logo=channel.get('logo')),
|
||||
'preset': channel.get('iptv_preset'),
|
||||
'stream': 'plugin://plugin.video.viervijfzes/play/live/{channel}'.format(channel=key),
|
||||
'vod': 'plugin://plugin.video.viervijfzes/play/epg/{channel}/{{date}}'.format(channel=key)
|
||||
})
|
||||
|
||||
return {'version': 1, 'streams': streams}
|
||||
|
||||
@via_socket
|
||||
def send_epg(): # pylint: disable=no-method-argument
|
||||
"""Return JSON-EPG formatted information to IPTV Manager"""
|
||||
epg_api = EpgApi()
|
||||
|
||||
try: # Python 3
|
||||
from urllib.parse import quote
|
||||
except ImportError: # Python 2
|
||||
from urllib import quote
|
||||
|
||||
today = datetime.today()
|
||||
|
||||
results = {}
|
||||
for key, channel in CHANNELS.items():
|
||||
iptv_id = channel.get('iptv_id')
|
||||
|
||||
if channel.get('iptv_id'):
|
||||
results[iptv_id] = []
|
||||
|
||||
for i in range(-3, 7):
|
||||
date = today + timedelta(days=i)
|
||||
epg = epg_api.get_epg(key, date.strftime('%Y-%m-%d'))
|
||||
|
||||
results[iptv_id].extend([
|
||||
{
|
||||
'start': program.start.isoformat(),
|
||||
'stop': (program.start + timedelta(seconds=program.duration)).isoformat(),
|
||||
'title': program.program_title,
|
||||
'subtitle': program.episode_title,
|
||||
'description': program.description,
|
||||
'episode': 'S%sE%s' % (program.season, program.number) if program.season and program.number else None,
|
||||
'genre': program.genre,
|
||||
'genre_id': program.genre_id,
|
||||
'image': program.thumb,
|
||||
'stream': kodiutils.url_for('play_from_page',
|
||||
channel=key,
|
||||
page=quote(program.video_url, safe='')) if program.video_url else None
|
||||
}
|
||||
for program in epg if program.duration
|
||||
])
|
||||
|
||||
return {'version': 1, 'epg': results}
|
@ -3,19 +3,10 @@
|
||||
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
from resources.lib import kodiutils
|
||||
from resources.lib.kodiutils import TitleItem
|
||||
from resources.lib.viervijfzes import STREAM_DICT
|
||||
from resources.lib.viervijfzes.content import Episode, Program
|
||||
|
||||
try: # Python 3
|
||||
from urllib.parse import quote
|
||||
except ImportError: # Python 2
|
||||
from urllib import quote
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from resources.lib.viervijfzes import CHANNELS, STREAM_DICT
|
||||
from resources.lib.viervijfzes.content import Program, Episode
|
||||
|
||||
|
||||
class Menu:
|
||||
@ -31,68 +22,35 @@ class Menu:
|
||||
TitleItem(
|
||||
title=kodiutils.localize(30001), # A-Z
|
||||
path=kodiutils.url_for('show_catalog'),
|
||||
art_dict={
|
||||
'icon': 'DefaultMovieTitle.png',
|
||||
'fanart': kodiutils.get_addon_info('fanart')
|
||||
},
|
||||
info_dict={
|
||||
'plot': kodiutils.localize(30002)
|
||||
}
|
||||
art_dict=dict(
|
||||
icon='DefaultMovieTitle.png',
|
||||
fanart=kodiutils.get_addon_info('fanart'),
|
||||
),
|
||||
info_dict=dict(
|
||||
plot=kodiutils.localize(30002),
|
||||
)
|
||||
),
|
||||
TitleItem(
|
||||
title=kodiutils.localize(30007), # TV Channels
|
||||
path=kodiutils.url_for('show_channels'),
|
||||
art_dict={
|
||||
'icon': 'DefaultAddonPVRClient.png',
|
||||
'fanart': kodiutils.get_addon_info('fanart')
|
||||
},
|
||||
info_dict={
|
||||
'plot': kodiutils.localize(30008)
|
||||
}
|
||||
),
|
||||
TitleItem(
|
||||
title=kodiutils.localize(30003), # Catalog
|
||||
path=kodiutils.url_for('show_categories'),
|
||||
art_dict={
|
||||
'icon': 'DefaultGenre.png',
|
||||
'fanart': kodiutils.get_addon_info('fanart')
|
||||
},
|
||||
info_dict={
|
||||
'plot': kodiutils.localize(30004)
|
||||
}
|
||||
),
|
||||
TitleItem(
|
||||
title=kodiutils.localize(30005), # Recommendations
|
||||
path=kodiutils.url_for('show_recommendations'),
|
||||
art_dict={
|
||||
'icon': 'DefaultFavourites.png',
|
||||
'fanart': kodiutils.get_addon_info('fanart')
|
||||
},
|
||||
info_dict={
|
||||
'plot': kodiutils.localize(30006)
|
||||
}
|
||||
),
|
||||
TitleItem(
|
||||
title=kodiutils.localize(30011), # My List
|
||||
path=kodiutils.url_for('show_mylist'),
|
||||
art_dict={
|
||||
'icon': 'DefaultPlaylist.png',
|
||||
'fanart': kodiutils.get_addon_info('fanart')
|
||||
},
|
||||
info_dict={
|
||||
'plot': kodiutils.localize(30012)
|
||||
}
|
||||
art_dict=dict(
|
||||
icon='DefaultAddonPVRClient.png',
|
||||
fanart=kodiutils.get_addon_info('fanart'),
|
||||
),
|
||||
info_dict=dict(
|
||||
plot=kodiutils.localize(30008),
|
||||
)
|
||||
),
|
||||
TitleItem(
|
||||
title=kodiutils.localize(30009), # Search
|
||||
path=kodiutils.url_for('show_search'),
|
||||
art_dict={
|
||||
'icon': 'DefaultAddonsSearch.png',
|
||||
'fanart': kodiutils.get_addon_info('fanart')
|
||||
},
|
||||
info_dict={
|
||||
'plot': kodiutils.localize(30010)
|
||||
}
|
||||
art_dict=dict(
|
||||
icon='DefaultAddonsSearch.png',
|
||||
fanart=kodiutils.get_addon_info('fanart'),
|
||||
),
|
||||
info_dict=dict(
|
||||
plot=kodiutils.localize(30010),
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
@ -104,9 +62,14 @@ class Menu:
|
||||
:type item: Union[Program, Episode]
|
||||
:rtype TitleItem
|
||||
"""
|
||||
art_dict = {
|
||||
'thumb': item.cover,
|
||||
'cover': item.cover,
|
||||
}
|
||||
info_dict = {
|
||||
'title': item.title,
|
||||
'plot': item.description,
|
||||
'studio': CHANNELS.get(item.channel, {}).get('studio_icon'),
|
||||
'aired': item.aired.strftime('%Y-%m-%d') if item.aired else None,
|
||||
}
|
||||
prop_dict = {}
|
||||
@ -115,53 +78,33 @@ class Menu:
|
||||
# Program
|
||||
#
|
||||
if isinstance(item, Program):
|
||||
art_dict.update({
|
||||
'fanart': item.background,
|
||||
})
|
||||
info_dict.update({
|
||||
'mediatype': None,
|
||||
'season': len(item.seasons) if item.seasons else None,
|
||||
})
|
||||
|
||||
art_dict = {
|
||||
'poster': item.poster,
|
||||
'landscape': item.thumb,
|
||||
'thumb': item.thumb,
|
||||
'fanart': item.fanart,
|
||||
}
|
||||
|
||||
visible = True
|
||||
title = item.title
|
||||
|
||||
context_menu = []
|
||||
if item.uuid:
|
||||
if item.my_list:
|
||||
context_menu.append((
|
||||
kodiutils.localize(30101), # Remove from My List
|
||||
'Container.Update(%s)' %
|
||||
kodiutils.url_for('mylist_del', uuid=item.uuid)
|
||||
))
|
||||
else:
|
||||
context_menu.append((
|
||||
kodiutils.localize(30100), # Add to My List
|
||||
'Container.Update(%s)' %
|
||||
kodiutils.url_for('mylist_add', uuid=item.uuid)
|
||||
))
|
||||
|
||||
context_menu.append((
|
||||
kodiutils.localize(30102), # Go to Program
|
||||
'Container.Update(%s)' %
|
||||
kodiutils.url_for('show_catalog_program', program=item.path)
|
||||
))
|
||||
if isinstance(item.episodes, list) and not item.episodes:
|
||||
# We know that we don't have episodes
|
||||
title = '[COLOR gray]' + item.title + '[/COLOR]'
|
||||
else:
|
||||
# We have episodes, or we don't know it
|
||||
title = item.title
|
||||
|
||||
return TitleItem(title=title,
|
||||
path=kodiutils.url_for('show_catalog_program', program=item.path),
|
||||
context_menu=context_menu,
|
||||
path=kodiutils.url_for('show_catalog_program', channel=item.channel, program=item.path),
|
||||
art_dict=art_dict,
|
||||
info_dict=info_dict,
|
||||
visible=visible)
|
||||
info_dict=info_dict)
|
||||
|
||||
#
|
||||
# Episode
|
||||
#
|
||||
if isinstance(item, Episode):
|
||||
art_dict.update({
|
||||
'fanart': item.cover,
|
||||
})
|
||||
info_dict.update({
|
||||
'mediatype': 'episode',
|
||||
'tvshowtitle': item.program_title,
|
||||
@ -170,30 +113,27 @@ class Menu:
|
||||
'episode': item.number,
|
||||
})
|
||||
|
||||
art_dict = {
|
||||
'landscape': item.thumb,
|
||||
'thumb': item.thumb,
|
||||
'fanart': item.thumb,
|
||||
}
|
||||
|
||||
stream_dict = STREAM_DICT.copy()
|
||||
stream_dict.update({
|
||||
'duration': item.duration,
|
||||
})
|
||||
|
||||
if item.uuid:
|
||||
# We have an UUID and can play this item directly
|
||||
path = kodiutils.url_for('play_catalog', uuid=item.uuid, content_type=item.content_type)
|
||||
if kodiutils.get_setting_bool('episode_cache_enabled'):
|
||||
context_menu = [(
|
||||
kodiutils.localize(30103), # Download to cache
|
||||
'Container.Update(%s)' %
|
||||
kodiutils.url_for('download', uuid=item.uuid)
|
||||
)]
|
||||
else:
|
||||
# We don't have an UUID, and first need to fetch the video information from the page
|
||||
path = kodiutils.url_for('play_from_page', page=quote(item.path, safe=''))
|
||||
context_menu = []
|
||||
|
||||
return TitleItem(title=info_dict['title'],
|
||||
path=path,
|
||||
path=kodiutils.url_for('play', uuid=item.uuid),
|
||||
art_dict=art_dict,
|
||||
info_dict=info_dict,
|
||||
stream_dict=stream_dict,
|
||||
prop_dict=prop_dict,
|
||||
is_playable=True)
|
||||
is_playable=True,
|
||||
context_menu=context_menu)
|
||||
|
||||
raise Exception('Unknown video_type')
|
||||
|
54
resources/lib/modules/metadata.py
Normal file
@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
""" Metadata module """
|
||||
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
|
||||
from resources.lib import kodiutils
|
||||
from resources.lib.viervijfzes import CHANNELS
|
||||
from resources.lib.viervijfzes.content import ContentApi, Program, CACHE_PREVENT, CACHE_AUTO
|
||||
|
||||
|
||||
class Metadata:
|
||||
""" Code responsible for the management of the local cached metadata """
|
||||
|
||||
def __init__(self):
|
||||
""" Initialise object """
|
||||
self._api = ContentApi(cache_path=kodiutils.get_cache_path())
|
||||
|
||||
def update(self):
|
||||
""" Update the metadata with a foreground progress indicator """
|
||||
# Create progress indicator
|
||||
progress = kodiutils.progress(message=kodiutils.localize(30715)) # Updating metadata
|
||||
|
||||
def update_status(i, total):
|
||||
""" Update the progress indicator """
|
||||
progress.update(int(((i + 1) / total) * 100), kodiutils.localize(30716, index=i + 1, total=total)) # Updating metadata ({index}/{total})
|
||||
return progress.iscanceled()
|
||||
|
||||
self.fetch_metadata(callback=update_status, refresh=True)
|
||||
|
||||
# Close progress indicator
|
||||
progress.close()
|
||||
|
||||
def fetch_metadata(self, callback=None, refresh=False):
|
||||
""" Fetch the metadata for all the items in the catalog
|
||||
:type callback: callable
|
||||
:type refresh: bool
|
||||
"""
|
||||
# Fetch all items from the catalog
|
||||
items = []
|
||||
for channel in list(CHANNELS):
|
||||
items.extend(self._api.get_programs(channel, CACHE_PREVENT))
|
||||
count = len(items)
|
||||
|
||||
# Loop over all of them and download the metadata
|
||||
for index, item in enumerate(items):
|
||||
if isinstance(item, Program):
|
||||
self._api.get_program(item.channel, item.path, CACHE_PREVENT if refresh else CACHE_AUTO)
|
||||
|
||||
# Run callback after every item
|
||||
if callback and callback(index, count):
|
||||
# Stop when callback returns False
|
||||
return False
|
||||
|
||||
return True
|
@ -4,15 +4,15 @@
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from resources.lib import kodiutils
|
||||
from resources.lib.modules.menu import Menu
|
||||
from resources.lib.viervijfzes import CHANNELS, ResolvedStream
|
||||
from resources.lib.downloader import Downloader
|
||||
from resources.lib.viervijfzes.auth import AuthApi
|
||||
from resources.lib.viervijfzes.aws.cognito_idp import AuthenticationException, InvalidLoginException
|
||||
from resources.lib.viervijfzes.content import CACHE_PREVENT, ContentApi, GeoblockedException, UnavailableException
|
||||
from resources.lib.viervijfzes.auth_awsidp import InvalidLoginException, AuthenticationException
|
||||
from resources.lib.viervijfzes.content import ContentApi, UnavailableException, GeoblockedException
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER = logging.getLogger('player')
|
||||
|
||||
|
||||
class Player:
|
||||
@ -21,83 +21,94 @@ class Player:
|
||||
def __init__(self):
|
||||
""" Initialise object """
|
||||
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
|
||||
self._api = ContentApi(auth, cache_path=kodiutils.get_cache_path())
|
||||
self._api = ContentApi(auth)
|
||||
|
||||
# Workaround for Raspberry Pi 3 and older
|
||||
kodiutils.set_global_setting('videoplayer.useomxplayer', True)
|
||||
|
||||
def live(self, channel):
|
||||
""" Play the live channel.
|
||||
:type channel: string
|
||||
"""
|
||||
# TODO: this doesn't work correctly, playing a live program from the PVR won't play something from the beginning
|
||||
# Lookup current program
|
||||
# broadcast = self._epg.get_broadcast(channel, datetime.datetime.now().isoformat())
|
||||
# if broadcast and broadcast.video_url:
|
||||
# self.play_from_page(broadcast.video_url)
|
||||
# return
|
||||
|
||||
channel_url = CHANNELS.get(channel, {'url': channel}).get('url')
|
||||
|
||||
self.play_from_page(channel_url)
|
||||
|
||||
def play_from_page(self, path):
|
||||
def play_from_page(self, channel, path):
|
||||
""" Play the requested item.
|
||||
:type channel: string
|
||||
:type path: string
|
||||
"""
|
||||
if not path:
|
||||
kodiutils.ok_dialog(message=kodiutils.localize(30712)) # The video is unavailable...
|
||||
return
|
||||
|
||||
# Get episode information
|
||||
episode = self._api.get_episode(path, cache=CACHE_PREVENT)
|
||||
resolved_stream = None
|
||||
episode = ContentApi().get_episode(channel, path)
|
||||
|
||||
if episode is None:
|
||||
kodiutils.ok_dialog(message=kodiutils.localize(30712))
|
||||
return
|
||||
# Play this now we have the uuid
|
||||
self.play(episode.uuid)
|
||||
|
||||
if episode.stream:
|
||||
# We already have a resolved stream. Nice!
|
||||
# We don't need credentials for these streams.
|
||||
resolved_stream = ResolvedStream(
|
||||
uuid=episode.uuid,
|
||||
url=episode.stream,
|
||||
)
|
||||
_LOGGER.debug('Already got a resolved stream: %s', resolved_stream)
|
||||
|
||||
if episode.uuid:
|
||||
# Lookup the stream
|
||||
resolved_stream = self._resolve_stream(episode.uuid, episode.content_type)
|
||||
_LOGGER.debug('Resolved stream: %s', resolved_stream)
|
||||
|
||||
if resolved_stream:
|
||||
titleitem = Menu.generate_titleitem(episode)
|
||||
kodiutils.play(resolved_stream.url,
|
||||
resolved_stream.stream_type,
|
||||
resolved_stream.license_key,
|
||||
info_dict=titleitem.info_dict,
|
||||
art_dict=titleitem.art_dict,
|
||||
prop_dict=titleitem.prop_dict)
|
||||
|
||||
def play(self, uuid, content_type):
|
||||
def play(self, uuid):
|
||||
""" Play the requested item.
|
||||
:type uuid: string
|
||||
:type content_type: string
|
||||
"""
|
||||
if not uuid:
|
||||
kodiutils.ok_dialog(message=kodiutils.localize(30712)) # The video is unavailable...
|
||||
if kodiutils.get_setting_bool('episode_cache_enabled'):
|
||||
# Check for a cached version
|
||||
cached_file = self._check_cached_episode(uuid)
|
||||
if cached_file:
|
||||
kodiutils.play(cached_file)
|
||||
return
|
||||
|
||||
# Workaround for Raspberry Pi 3 and older
|
||||
omxplayer = kodiutils.get_global_setting('videoplayer.useomxplayer')
|
||||
if omxplayer is False:
|
||||
kodiutils.set_global_setting('videoplayer.useomxplayer', True)
|
||||
|
||||
# Resolve the stream
|
||||
resolved_stream = self._fetch_stream(uuid)
|
||||
if not resolved_stream:
|
||||
kodiutils.end_of_directory()
|
||||
return
|
||||
|
||||
# Lookup the stream
|
||||
resolved_stream = self._resolve_stream(uuid, content_type)
|
||||
kodiutils.play(resolved_stream.url, resolved_stream.stream_type, resolved_stream.license_key)
|
||||
# Play this item
|
||||
kodiutils.play(resolved_stream)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_stream(uuid, content_type):
|
||||
""" Resolve the stream for the requested item
|
||||
def download(self, uuid):
|
||||
""" Download the requested item to cache.
|
||||
:type uuid: string
|
||||
"""
|
||||
# We can notify Kodi already that we won't be returning a listing.
|
||||
# This also fixes an odd Kodi bug where a starting a Progress() without closing the directory listing causes Kodi to hang.
|
||||
kodiutils.end_of_directory()
|
||||
|
||||
# Check ffmpeg
|
||||
if not Downloader.check():
|
||||
kodiutils.ok_dialog(message=kodiutils.localize(30719)) # Could not download this episode since ffmpeg seems to be unavailable.
|
||||
return
|
||||
|
||||
# Check download folder
|
||||
download_folder = kodiutils.get_setting('episode_cache_folder').rstrip('/')
|
||||
if not os.path.exists(download_folder):
|
||||
kodiutils.ok_dialog(message=kodiutils.localize(30718)) # Could not download this episode since the download folder is not set or does not exist.
|
||||
return
|
||||
|
||||
# Check if we already have downloaded this file
|
||||
download_path = '%s/%s.mp4' % (download_folder, uuid)
|
||||
if os.path.isfile(download_path):
|
||||
# You have already downloaded this episode. Do you want to download it again?
|
||||
result = kodiutils.yesno_dialog(message=kodiutils.localize(30726))
|
||||
if not result:
|
||||
return
|
||||
|
||||
# Download this item
|
||||
downloader = Downloader()
|
||||
progress = kodiutils.progress(message=kodiutils.localize(30723)) # Starting download...
|
||||
|
||||
def callback(total, current):
|
||||
""" Callback function to update the progress bar. """
|
||||
percentage = current * 100 / total
|
||||
progress.update(int(percentage), kodiutils.localize(30724, amount=round(percentage, 2))) # Downloading... ({amount}%)
|
||||
return progress.iscanceled()
|
||||
|
||||
# Resolve the stream and start the download
|
||||
resolved_stream = self._fetch_stream(uuid)
|
||||
status = downloader.download(resolved_stream, download_path, callback)
|
||||
|
||||
# Close the progress bar
|
||||
progress.close()
|
||||
|
||||
if status:
|
||||
kodiutils.ok_dialog(message=kodiutils.localize(30725)) # Download has finished. You can now play this episode from cache.
|
||||
|
||||
def _fetch_stream(self, uuid):
|
||||
""" Fetches the HLS stream of the item.
|
||||
:type uuid: string
|
||||
:type content_type: string
|
||||
"""
|
||||
try:
|
||||
# Check if we have credentials
|
||||
@ -106,27 +117,45 @@ class Player:
|
||||
message=kodiutils.localize(30701)) # To watch a video, you need to enter your credentials. Do you want to enter them now?
|
||||
if confirm:
|
||||
kodiutils.open_settings()
|
||||
kodiutils.end_of_directory()
|
||||
return None
|
||||
|
||||
# Fetch an auth token now
|
||||
try:
|
||||
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
|
||||
|
||||
# Get stream information
|
||||
resolved_stream = ContentApi(auth).get_stream_by_uuid(uuid, content_type)
|
||||
return resolved_stream
|
||||
resolved_stream = self._api.get_stream_by_uuid(uuid)
|
||||
|
||||
except (InvalidLoginException, AuthenticationException) as ex:
|
||||
_LOGGER.exception(ex)
|
||||
_LOGGER.error(ex)
|
||||
kodiutils.ok_dialog(message=kodiutils.localize(30702, error=str(ex)))
|
||||
kodiutils.end_of_directory()
|
||||
return None
|
||||
|
||||
except GeoblockedException:
|
||||
kodiutils.ok_dialog(message=kodiutils.localize(30710)) # This video is geo-blocked...
|
||||
kodiutils.ok_dialog(heading=kodiutils.localize(30709), message=kodiutils.localize(30710)) # This video is geo-blocked...
|
||||
return None
|
||||
|
||||
except UnavailableException:
|
||||
kodiutils.ok_dialog(message=kodiutils.localize(30712)) # The video is unavailable...
|
||||
kodiutils.ok_dialog(heading=kodiutils.localize(30711), message=kodiutils.localize(30712)) # The video is unavailable...
|
||||
return None
|
||||
|
||||
return resolved_stream
|
||||
|
||||
@staticmethod
|
||||
def _check_cached_episode(uuid):
|
||||
""" Check if this episode is available in the download cache.
|
||||
:type uuid: string
|
||||
"""
|
||||
download_folder = kodiutils.get_setting('episode_cache_folder').rstrip('/')
|
||||
if not download_folder or not os.path.exists(download_folder):
|
||||
return None
|
||||
|
||||
# Check if we already have downloaded this file
|
||||
download_path = '%s/%s.mp4' % (download_folder, uuid)
|
||||
if os.path.isfile(download_path):
|
||||
# You have cached this episode. Do you want to play from your cache or stream it?
|
||||
result = kodiutils.yesno_dialog(message=kodiutils.localize(30720),
|
||||
yeslabel=kodiutils.localize(30721), # Stream
|
||||
nolabel=kodiutils.localize(30722)) # Play from cache
|
||||
|
||||
if not result:
|
||||
return download_path
|
||||
|
||||
return None
|
||||
|
@ -9,7 +9,7 @@ from resources.lib import kodiutils
|
||||
from resources.lib.modules.menu import Menu
|
||||
from resources.lib.viervijfzes.search import SearchApi
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER = logging.getLogger('search')
|
||||
|
||||
|
||||
class Search:
|
||||
@ -18,6 +18,7 @@ class Search:
|
||||
def __init__(self):
|
||||
""" Initialise object """
|
||||
self._search = SearchApi()
|
||||
self._menu = Menu()
|
||||
|
||||
def show_search(self, query=None):
|
||||
""" Shows the search dialog
|
||||
@ -39,7 +40,7 @@ class Search:
|
||||
return
|
||||
|
||||
# Display results
|
||||
listing = [Menu.generate_titleitem(item) for item in items]
|
||||
listing = [self._menu.generate_titleitem(item) for item in items]
|
||||
|
||||
# Sort like we get our results back.
|
||||
kodiutils.show_listing(listing, 30009, content='tvshows')
|
||||
|
@ -8,7 +8,6 @@ from datetime import datetime, timedelta
|
||||
|
||||
from resources.lib import kodiutils
|
||||
from resources.lib.kodiutils import TitleItem
|
||||
from resources.lib.modules.player import Player
|
||||
from resources.lib.viervijfzes import STREAM_DICT
|
||||
from resources.lib.viervijfzes.content import UnavailableException
|
||||
from resources.lib.viervijfzes.epg import EpgApi
|
||||
@ -18,7 +17,7 @@ try: # Python 3
|
||||
except ImportError: # Python 2
|
||||
from urllib import quote
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER = logging.getLogger('tvguide')
|
||||
|
||||
|
||||
class TvGuide:
|
||||
@ -36,8 +35,8 @@ class TvGuide:
|
||||
dates = []
|
||||
today = datetime.today()
|
||||
|
||||
# The API provides 7 days in the past and 8 days in the future
|
||||
for i in range(-7, 8):
|
||||
# The API provides 7 days in the past and 13 days in the future
|
||||
for i in range(-7, 13):
|
||||
day = today + timedelta(days=i)
|
||||
|
||||
if i == -1:
|
||||
@ -71,7 +70,7 @@ class TvGuide:
|
||||
|
||||
return dates
|
||||
|
||||
def show_channel(self, channel):
|
||||
def show_tvguide_channel(self, channel):
|
||||
""" Shows the dates in the tv guide
|
||||
:type channel: str
|
||||
"""
|
||||
@ -84,7 +83,7 @@ class TvGuide:
|
||||
|
||||
listing.append(
|
||||
TitleItem(title=title,
|
||||
path=kodiutils.url_for('show_channel_tvguide_detail', channel=channel, date=day.get('key')),
|
||||
path=kodiutils.url_for('show_tvguide_detail', channel=channel, date=day.get('key')),
|
||||
art_dict={
|
||||
'icon': 'DefaultYear.png',
|
||||
'thumb': 'DefaultYear.png',
|
||||
@ -97,7 +96,7 @@ class TvGuide:
|
||||
|
||||
kodiutils.show_listing(listing, 30013, content='files', sort=['date'])
|
||||
|
||||
def show_detail(self, channel=None, date=None):
|
||||
def show_tvguide_detail(self, channel=None, date=None):
|
||||
""" Shows the programs of a specific date in the tv guide
|
||||
:type channel: str
|
||||
:type date: str
|
||||
@ -131,7 +130,7 @@ class TvGuide:
|
||||
if program.video_url:
|
||||
path = kodiutils.url_for('play_from_page', channel=channel, page=quote(program.video_url, safe=''))
|
||||
else:
|
||||
path = kodiutils.url_for('play_catalog', uuid='')
|
||||
path = None
|
||||
title = '[COLOR gray]' + title + '[/COLOR]'
|
||||
|
||||
stream_dict = STREAM_DICT.copy()
|
||||
@ -154,7 +153,8 @@ class TvGuide:
|
||||
TitleItem(title=title,
|
||||
path=path,
|
||||
art_dict={
|
||||
'thumb': program.thumb,
|
||||
'icon': program.cover,
|
||||
'thumb': program.cover,
|
||||
},
|
||||
info_dict=info_dict,
|
||||
stream_dict=stream_dict,
|
||||
@ -171,13 +171,9 @@ class TvGuide:
|
||||
"""
|
||||
broadcast = self._epg.get_broadcast(channel, timestamp)
|
||||
if not broadcast:
|
||||
kodiutils.ok_dialog(message=kodiutils.localize(30713)) # The requested video was not found in the guide.
|
||||
kodiutils.ok_dialog(heading=kodiutils.localize(30711), message=kodiutils.localize(30713)) # The requested video was not found in the guide.
|
||||
kodiutils.end_of_directory()
|
||||
return
|
||||
|
||||
if not broadcast.video_url:
|
||||
kodiutils.ok_dialog(message=kodiutils.localize(30712)) # The video is unavailable and can't be played right now.
|
||||
kodiutils.end_of_directory()
|
||||
return
|
||||
|
||||
Player().play_from_page(broadcast.video_url)
|
||||
kodiutils.container_update(
|
||||
kodiutils.url_for('play', uuid=broadcast.video_url))
|
||||
|
@ -5,13 +5,16 @@ from __future__ import absolute_import, division, unicode_literals
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from time import time
|
||||
|
||||
from xbmc import Monitor, Player, getInfoLabel
|
||||
from xbmc import Monitor
|
||||
|
||||
from resources.lib import kodilogging, kodiutils
|
||||
from resources.lib.viervijfzes.auth import AuthApi
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
kodilogging.config()
|
||||
_LOGGER = logging.getLogger('service')
|
||||
|
||||
|
||||
class BackgroundService(Monitor):
|
||||
@ -22,23 +25,26 @@ class BackgroundService(Monitor):
|
||||
self.update_interval = 24 * 3600 # Every 24 hours
|
||||
self.cache_expiry = 30 * 24 * 3600 # One month
|
||||
self._auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
|
||||
self._kodiplayer = KodiPlayer()
|
||||
|
||||
def run(self):
|
||||
""" Background loop for maintenance tasks """
|
||||
_LOGGER.debug('Service started')
|
||||
_LOGGER.info('Service started')
|
||||
|
||||
while not self.abortRequested():
|
||||
# Update every `update_interval` after the last update
|
||||
if kodiutils.get_setting_bool('metadata_update') and int(kodiutils.get_setting('metadata_last_updated', 0)) + self.update_interval < time():
|
||||
self._update_metadata()
|
||||
|
||||
# Stop when abort requested
|
||||
if self.waitForAbort(10):
|
||||
break
|
||||
|
||||
_LOGGER.debug('Service stopped')
|
||||
_LOGGER.info('Service stopped')
|
||||
|
||||
def onSettingsChanged(self): # pylint: disable=invalid-name
|
||||
""" Callback when a setting has changed """
|
||||
if self._has_credentials_changed():
|
||||
_LOGGER.debug('Clearing auth tokens due to changed credentials')
|
||||
_LOGGER.info('Clearing auth tokens due to changed credentials')
|
||||
self._auth.clear_tokens()
|
||||
|
||||
# Refresh container
|
||||
@ -56,90 +62,39 @@ class BackgroundService(Monitor):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _update_metadata(self):
|
||||
""" Update the metadata for the listings """
|
||||
from resources.lib.modules.metadata import Metadata
|
||||
|
||||
class KodiPlayer(Player):
|
||||
"""Communication with Kodi Player"""
|
||||
def update_status(_i, _total):
|
||||
""" Allow to cancel the background job """
|
||||
return self.abortRequested() or not kodiutils.get_setting_bool('metadata_update')
|
||||
|
||||
def __init__(self):
|
||||
"""KodiPlayer initialisation"""
|
||||
Player.__init__(self)
|
||||
self.listen = False
|
||||
self.path = None
|
||||
self.av_started = False
|
||||
self.stream_path = None
|
||||
# Clear metadata that has expired for 30 days
|
||||
self._remove_expired_metadata(30 * 24 * 60 * 60)
|
||||
|
||||
def onPlayBackStarted(self): # pylint: disable=invalid-name
|
||||
"""Called when user starts playing a file"""
|
||||
self.path = getInfoLabel('Player.FilenameAndPath')
|
||||
if self.path.startswith('plugin://plugin.video.viervijfzes/'):
|
||||
self.listen = True
|
||||
else:
|
||||
self.listen = False
|
||||
# Fetch new metadata
|
||||
success = Metadata().fetch_metadata(callback=update_status)
|
||||
|
||||
# Update metadata_last_updated
|
||||
if success:
|
||||
kodiutils.set_setting('metadata_last_updated', str(int(time())))
|
||||
|
||||
@staticmethod
|
||||
def _remove_expired_metadata(keep_expired=None):
|
||||
""" Clear the cache """
|
||||
path = kodiutils.get_cache_path()
|
||||
if not os.path.exists(path):
|
||||
return
|
||||
_LOGGER.debug('KodiPlayer onPlayBackStarted')
|
||||
self.av_started = False
|
||||
self.stream_path = self.getPlayingFile()
|
||||
|
||||
def onAVStarted(self): # pylint: disable=invalid-name
|
||||
"""Called when Kodi has a video or audiostream"""
|
||||
if not self.listen:
|
||||
return
|
||||
_LOGGER.debug('KodiPlayer onAVStarted')
|
||||
self.av_started = True
|
||||
|
||||
def onAVChange(self): # pylint: disable=invalid-name
|
||||
"""Called when Kodi has a video, audio or subtitle stream. Also happens when the stream changes."""
|
||||
if not self.listen:
|
||||
return
|
||||
_LOGGER.debug('KodiPlayer onAVChange')
|
||||
|
||||
def onPlayBackSeek(self, time, seekOffset): # pylint: disable=invalid-name, redefined-outer-name
|
||||
"""Called when user seeks to a time"""
|
||||
if not self.listen:
|
||||
return
|
||||
_LOGGER.debug('KodiPlayer onPlayBackSeek time=%s offset=%s', time, seekOffset)
|
||||
|
||||
def onPlayBackPaused(self): # pylint: disable=invalid-name
|
||||
"""Called when user pauses a playing file"""
|
||||
if not self.listen:
|
||||
return
|
||||
_LOGGER.debug('KodiPlayer onPlayBackPaused')
|
||||
|
||||
def onPlayBackResumed(self): # pylint: disable=invalid-name
|
||||
"""Called when user resumes a paused file or a next playlist item is started"""
|
||||
if not self.listen:
|
||||
return
|
||||
_LOGGER.debug('KodiPlayer onPlayBackResumed')
|
||||
|
||||
def onPlayBackError(self): # pylint: disable=invalid-name
|
||||
"""Called when playback stops due to an error"""
|
||||
if not self.listen:
|
||||
return
|
||||
_LOGGER.debug('KodiPlayer onPlayBackError')
|
||||
|
||||
def onPlayBackStopped(self): # pylint: disable=invalid-name
|
||||
"""Called when user stops Kodi playing a file"""
|
||||
if not self.listen:
|
||||
return
|
||||
_LOGGER.debug('KodiPlayer onPlayBackStopped')
|
||||
if not self.av_started:
|
||||
# Check stream path
|
||||
import requests
|
||||
response = requests.get(self.stream_path, timeout=5)
|
||||
if response.status_code == 403:
|
||||
message_id = 30720
|
||||
else:
|
||||
message_id = 30719
|
||||
kodiutils.ok_dialog(message=kodiutils.localize(message_id))
|
||||
|
||||
def onPlayBackEnded(self): # pylint: disable=invalid-name
|
||||
"""Called when Kodi has ended playing a file"""
|
||||
if not self.listen:
|
||||
return
|
||||
_LOGGER.debug('KodiPlayer onPlayBackEnded')
|
||||
now = time()
|
||||
for filename in os.listdir(path):
|
||||
fullpath = path + filename
|
||||
if keep_expired and os.stat(fullpath).st_mtime + keep_expired > now:
|
||||
continue
|
||||
os.unlink(fullpath)
|
||||
|
||||
|
||||
def run():
|
||||
""" Run the BackgroundService """
|
||||
kodilogging.config()
|
||||
BackgroundService().run()
|
||||
|
@ -1,73 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
""" GoPlay API """
|
||||
""" SBS API """
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
CHANNELS = OrderedDict([
|
||||
('Play4', {
|
||||
'name': 'Play4',
|
||||
'url': 'live-kijken/play-4',
|
||||
'epg_id': 'vier',
|
||||
'logo': 'play4.png',
|
||||
'background': 'play4-background.png',
|
||||
'iptv_preset': 4,
|
||||
'iptv_id': 'play4.be',
|
||||
'youtube': [
|
||||
{'label': 'GoPlay', 'logo': 'goplay.png', 'path': 'plugin://plugin.video.youtube/user/viertv/'},
|
||||
]
|
||||
}),
|
||||
('Play5', {
|
||||
'name': 'Play5',
|
||||
'url': 'live-kijken/play-5',
|
||||
'epg_id': 'vijf',
|
||||
'logo': 'play5.png',
|
||||
'background': 'play5-background.png',
|
||||
'iptv_preset': 5,
|
||||
'iptv_id': 'play5.be',
|
||||
'youtube': [
|
||||
{'label': 'GoPlay', 'logo': 'goplay.png', 'path': 'plugin://plugin.video.youtube/user/viertv/'},
|
||||
]
|
||||
}),
|
||||
('Play6', {
|
||||
'name': 'Play6',
|
||||
'url': 'live-kijken/play-6',
|
||||
'epg_id': 'zes',
|
||||
'logo': 'play6.png',
|
||||
'background': 'play6-background.png',
|
||||
'iptv_preset': 6,
|
||||
'iptv_id': 'play6.be',
|
||||
'youtube': [
|
||||
{'label': 'GoPlay', 'logo': 'goplay.png', 'path': 'plugin://plugin.video.youtube/user/viertv/'},
|
||||
]
|
||||
}),
|
||||
('Play7', {
|
||||
'name': 'Play7',
|
||||
'url': 'live-kijken/play-7',
|
||||
'epg_id': 'zeven',
|
||||
'logo': 'play7.png',
|
||||
'background': 'play7-background.png',
|
||||
'iptv_preset': 17,
|
||||
'iptv_id': 'play7.be',
|
||||
'youtube': []
|
||||
}),
|
||||
('PlayCrime', {
|
||||
'name': 'PlayCrime',
|
||||
'url': 'live-kijken/play-crime',
|
||||
'epg_id': 'crime',
|
||||
'logo': 'playcrime.png',
|
||||
'background': 'playcrime-background.png',
|
||||
'iptv_preset': 18,
|
||||
'iptv_id': 'playcrime.be',
|
||||
'youtube': []
|
||||
}),
|
||||
('GoPlay', {
|
||||
'name': 'Go Play',
|
||||
'url': 'https://www.goplay.be',
|
||||
'logo': 'goplay.png',
|
||||
'background': 'goplay-background.png',
|
||||
'youtube': []
|
||||
})
|
||||
('vier', dict(
|
||||
name='VIER',
|
||||
url='https://www.vier.be',
|
||||
logo='vier.png',
|
||||
background='vier-background.jpg',
|
||||
studio_icon='vier',
|
||||
youtube=[
|
||||
dict(
|
||||
label='VIER / VIJF',
|
||||
logo='vier.png',
|
||||
path='plugin://plugin.video.youtube/user/viertv/',
|
||||
),
|
||||
],
|
||||
)),
|
||||
('vijf', dict(
|
||||
name='VIJF',
|
||||
url='https://www.vijf.be',
|
||||
logo='vijf.png',
|
||||
background='vijf-background.jpg',
|
||||
studio_icon='vijf',
|
||||
youtube=[
|
||||
dict(
|
||||
label='VIER / VIJF',
|
||||
logo='vijf.png',
|
||||
path='plugin://plugin.video.youtube/user/viertv/',
|
||||
),
|
||||
],
|
||||
)),
|
||||
('zes', dict(
|
||||
name='ZES',
|
||||
url='https://www.zestv.be',
|
||||
logo='zes.png',
|
||||
background='zes-background.jpg',
|
||||
studio_icon='zes',
|
||||
youtube=[],
|
||||
))
|
||||
])
|
||||
|
||||
STREAM_DICT = {
|
||||
@ -75,22 +48,3 @@ STREAM_DICT = {
|
||||
'height': 544,
|
||||
'width': 960,
|
||||
}
|
||||
|
||||
|
||||
class ResolvedStream:
|
||||
""" Defines a stream that we can play"""
|
||||
|
||||
def __init__(self, uuid=None, url=None, stream_type=None, license_key=None):
|
||||
"""
|
||||
:type uuid: str
|
||||
:type url: str
|
||||
:type stream_type: str
|
||||
:type license_key: str
|
||||
"""
|
||||
self.uuid = uuid
|
||||
self.url = url
|
||||
self.stream_type = stream_type
|
||||
self.license_key = license_key
|
||||
|
||||
def __repr__(self):
|
||||
return "%r" % self.__dict__
|
||||
|
@ -9,19 +9,16 @@ import os
|
||||
import time
|
||||
|
||||
from resources.lib import kodiutils
|
||||
from resources.lib.viervijfzes.aws.cognito_identity import CognitoIdentity
|
||||
from resources.lib.viervijfzes.aws.cognito_idp import AuthenticationException, CognitoIdp, InvalidLoginException
|
||||
from resources.lib.viervijfzes.aws.cognito_sync import CognitoSync
|
||||
from resources.lib.viervijfzes.auth_awsidp import AwsIdp, InvalidLoginException, AuthenticationException
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER = logging.getLogger('auth-api')
|
||||
|
||||
|
||||
class AuthApi:
|
||||
""" GoPlay Authentication API """
|
||||
""" VIER/VIJF/ZES Authentication API """
|
||||
COGNITO_REGION = 'eu-west-1'
|
||||
COGNITO_POOL_ID = 'eu-west-1_dViSsKM5Y'
|
||||
COGNITO_CLIENT_ID = '6s1h851s8uplco5h6mqh1jac8m'
|
||||
COGNITO_IDENTITY_POOL_ID = 'eu-west-1:8b7eb22c-cf61-43d5-a624-04b494867234'
|
||||
|
||||
TOKEN_FILE = 'auth-tokens.json'
|
||||
|
||||
@ -36,13 +33,13 @@ class AuthApi:
|
||||
|
||||
# Load tokens from cache
|
||||
try:
|
||||
with open(os.path.join(self._token_path, self.TOKEN_FILE), 'r') as fdesc:
|
||||
with open(self._token_path + self.TOKEN_FILE, 'r') as fdesc:
|
||||
data_json = json.loads(fdesc.read())
|
||||
self._id_token = data_json.get('id_token')
|
||||
self._refresh_token = data_json.get('refresh_token')
|
||||
self._expiry = int(data_json.get('expiry', 0))
|
||||
except (IOError, TypeError, ValueError):
|
||||
_LOGGER.warning('We could not use the cache since it is invalid or non-existent.')
|
||||
_LOGGER.info('We could not use the cache since it is invalid or non-existent.')
|
||||
|
||||
def get_token(self):
|
||||
""" Get a valid token """
|
||||
@ -77,55 +74,30 @@ class AuthApi:
|
||||
|
||||
# Store new tokens in cache
|
||||
if not os.path.exists(self._token_path):
|
||||
os.makedirs(self._token_path)
|
||||
with open(os.path.join(self._token_path, self.TOKEN_FILE), 'w') as fdesc:
|
||||
data = json.dumps({
|
||||
'id_token': self._id_token,
|
||||
'refresh_token': self._refresh_token,
|
||||
'expiry': self._expiry
|
||||
})
|
||||
os.mkdir(self._token_path)
|
||||
with open(self._token_path + self.TOKEN_FILE, 'w') as fdesc:
|
||||
data = json.dumps(dict(
|
||||
id_token=self._id_token,
|
||||
refresh_token=self._refresh_token,
|
||||
expiry=self._expiry,
|
||||
))
|
||||
fdesc.write(kodiutils.from_unicode(data))
|
||||
|
||||
return self._id_token
|
||||
|
||||
def clear_tokens(self):
|
||||
""" Remove the cached tokens. """
|
||||
if os.path.exists(os.path.join(self._token_path, AuthApi.TOKEN_FILE)):
|
||||
os.unlink(os.path.join(self._token_path, AuthApi.TOKEN_FILE))
|
||||
if os.path.exists(self._token_path + AuthApi.TOKEN_FILE):
|
||||
os.unlink(self._token_path + AuthApi.TOKEN_FILE)
|
||||
|
||||
@staticmethod
|
||||
def _authenticate(username, password):
|
||||
""" Authenticate with Amazon Cognito and fetch a refresh token and id token. """
|
||||
idp_client = CognitoIdp(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_CLIENT_ID)
|
||||
return idp_client.authenticate(username, password)
|
||||
client = AwsIdp(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_CLIENT_ID)
|
||||
return client.authenticate(username, password)
|
||||
|
||||
@staticmethod
|
||||
def _refresh(refresh_token):
|
||||
""" Use the refresh token to fetch a new id token. """
|
||||
idp_client = CognitoIdp(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_CLIENT_ID)
|
||||
return idp_client.renew_token(refresh_token)
|
||||
|
||||
def get_dataset(self, dataset, key):
|
||||
""" Fetch the value from the specified dataset. """
|
||||
identity_client = CognitoIdentity(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_IDENTITY_POOL_ID)
|
||||
id_token = self.get_token()
|
||||
identity_id = identity_client.get_id(id_token)
|
||||
credentials = identity_client.get_credentials_for_identity(id_token, identity_id)
|
||||
|
||||
sync_client = CognitoSync(AuthApi.COGNITO_IDENTITY_POOL_ID, identity_id, credentials)
|
||||
data, session_token, sync_count = sync_client.list_records(dataset, key)
|
||||
|
||||
sync_info = {
|
||||
'identity_id': identity_id,
|
||||
'credentials': credentials,
|
||||
'session_token': session_token,
|
||||
'sync_count': sync_count,
|
||||
}
|
||||
|
||||
return data, sync_info
|
||||
|
||||
@staticmethod
|
||||
def put_dataset(dataset, key, value, sync_info):
|
||||
""" Store the value from the specified dataset. """
|
||||
sync_client = CognitoSync(AuthApi.COGNITO_IDENTITY_POOL_ID, sync_info.get('identity_id'), sync_info.get('credentials'))
|
||||
sync_client.update_records(dataset, key, value, sync_info.get('session_token'), sync_info.get('sync_count'))
|
||||
client = AwsIdp(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_CLIENT_ID)
|
||||
return client.renew_token(refresh_token)
|
||||
|
@ -12,11 +12,12 @@ import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import requests
|
||||
import six
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER = logging.getLogger('auth-awsidp')
|
||||
|
||||
|
||||
class InvalidLoginException(Exception):
|
||||
@ -27,14 +28,11 @@ class AuthenticationException(Exception):
|
||||
""" Something went wrong while logging in """
|
||||
|
||||
|
||||
class CognitoIdp:
|
||||
""" Cognito IDP """
|
||||
class AwsIdp:
|
||||
""" AWS Identity Provider """
|
||||
|
||||
def __init__(self, pool_id, client_id):
|
||||
"""
|
||||
|
||||
See https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/Welcome.html.
|
||||
|
||||
:param str pool_id: The AWS user pool to connect to (format: <region>_<poolid>).
|
||||
E.g.: eu-west-1_aLkOfYN3T
|
||||
:param str client_id: The client application ID (the ID of the application connecting)
|
||||
@ -77,6 +75,7 @@ class CognitoIdp:
|
||||
self.k = self.__hex_to_long(self.__hex_hash('00' + self.n_hex + '0' + self.g_hex)) # pylint: disable=invalid-name
|
||||
self.small_a_value = self.__generate_random_small_a()
|
||||
self.large_a_value = self.__calculate_a()
|
||||
_LOGGER.debug("Created %s", self)
|
||||
|
||||
def authenticate(self, username, password):
|
||||
""" Authenticate with a username and password. """
|
||||
@ -293,7 +292,7 @@ class CognitoIdp:
|
||||
|
||||
@staticmethod
|
||||
def __hex_hash(hex_string):
|
||||
return CognitoIdp.__hash_sha256(bytearray.fromhex(hex_string))
|
||||
return AwsIdp.__hash_sha256(bytearray.fromhex(hex_string))
|
||||
|
||||
@staticmethod
|
||||
def __hash_sha256(buf):
|
||||
@ -313,7 +312,7 @@ class CognitoIdp:
|
||||
|
||||
# noinspection PyTypeChecker
|
||||
if not isinstance(long_int, six.string_types):
|
||||
hash_str = CognitoIdp.__long_to_hex(long_int)
|
||||
hash_str = AwsIdp.__long_to_hex(long_int)
|
||||
else:
|
||||
hash_str = long_int
|
||||
if len(hash_str) % 2 == 1:
|
||||
@ -325,7 +324,7 @@ class CognitoIdp:
|
||||
@staticmethod
|
||||
def __get_random(nbytes):
|
||||
random_hex = binascii.hexlify(os.urandom(nbytes))
|
||||
return CognitoIdp.__hex_to_long(random_hex)
|
||||
return AwsIdp.__hex_to_long(random_hex)
|
||||
|
||||
@staticmethod
|
||||
def __get_current_timestamp():
|
||||
@ -341,7 +340,11 @@ class CognitoIdp:
|
||||
days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
|
||||
time_now = datetime.datetime.utcnow()
|
||||
format_string = "{} {} {} %H:%M:%S UTC %Y".format(days[time_now.weekday()], months[time_now.month], time_now.day)
|
||||
if sys.platform.startswith('win'):
|
||||
format_string = "{} {} %#d %H:%M:%S UTC %Y".format(days[time_now.weekday()], months[time_now.month])
|
||||
else:
|
||||
format_string = "{} {} %-d %H:%M:%S UTC %Y".format(days[time_now.weekday()], months[time_now.month])
|
||||
|
||||
time_string = datetime.datetime.utcnow().strftime(format_string)
|
||||
return time_string
|
||||
|
@ -1,69 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
""" Amazon Cognito Identity implementation without external dependencies """
|
||||
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CognitoIdentity:
|
||||
""" Cognito Identity """
|
||||
|
||||
def __init__(self, pool_id, identity_pool_id):
|
||||
"""
|
||||
|
||||
See https://docs.aws.amazon.com/cognitoidentity/latest/APIReference/Welcome.html.
|
||||
|
||||
:param str pool_id:
|
||||
:param str identity_pool_id:
|
||||
"""
|
||||
self.pool_id = pool_id
|
||||
if "_" not in self.pool_id:
|
||||
raise ValueError("Invalid pool_id format. Should be <region>_<poolid>.")
|
||||
|
||||
self.identity_pool_id = identity_pool_id
|
||||
self.region = self.pool_id.split("_")[0]
|
||||
self.url = "https://cognito-identity.%s.amazonaws.com/" % self.region
|
||||
self._session = requests.session()
|
||||
|
||||
def get_id(self, id_token):
|
||||
""" Get the Identity ID based on the id_token. """
|
||||
provider = 'cognito-idp.%s.amazonaws.com/%s' % (self.region, self.pool_id)
|
||||
data = {
|
||||
"IdentityPoolId": self.identity_pool_id,
|
||||
"Logins": {
|
||||
provider: id_token,
|
||||
}
|
||||
}
|
||||
response = self._session.post(self.url, json=data, headers={
|
||||
'x-amz-target': 'AWSCognitoIdentityService.GetId',
|
||||
'content-type': 'application/x-amz-json-1.1',
|
||||
})
|
||||
|
||||
result = json.loads(response.text)
|
||||
|
||||
return result.get('IdentityId')
|
||||
|
||||
def get_credentials_for_identity(self, id_token, identity_id):
|
||||
""" Get credentials based on the id_token and identity_id. """
|
||||
provider = 'cognito-idp.%s.amazonaws.com/%s' % (self.region, self.pool_id)
|
||||
data = {
|
||||
"IdentityId": identity_id,
|
||||
"Logins": {
|
||||
provider: id_token,
|
||||
}
|
||||
}
|
||||
|
||||
response = self._session.post(self.url, json=data, headers={
|
||||
'x-amz-target': 'AWSCognitoIdentityService.GetCredentialsForIdentity',
|
||||
'content-type': 'application/x-amz-json-1.1',
|
||||
})
|
||||
|
||||
result = json.loads(response.text)
|
||||
|
||||
return result.get('Credentials')
|
@ -1,194 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
""" Amazon Cognito Sync implementation without external dependencies """
|
||||
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
try: # Python 3
|
||||
from urllib.parse import quote, urlparse
|
||||
except ImportError: # Python 2
|
||||
from urllib import quote
|
||||
|
||||
from urlparse import urlparse
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CognitoSync:
|
||||
""" Amazon Cognito Sync """
|
||||
|
||||
def __init__(self, identity_pool_id, identity_id, credentials):
|
||||
"""
|
||||
|
||||
See https://docs.aws.amazon.com/cognitosync/latest/APIReference/Welcome.html.
|
||||
|
||||
:param str identity_pool_id:
|
||||
:param str identity_id:
|
||||
:param dict credentials:
|
||||
"""
|
||||
self.identity_pool_id = identity_pool_id
|
||||
self.identity_id = identity_id
|
||||
self.credentials = credentials
|
||||
|
||||
self.region = self.identity_pool_id.split(":")[0]
|
||||
self.url = "https://cognito-sync.%s.amazonaws.com" % self.region
|
||||
self._session = requests.session()
|
||||
|
||||
def _sign(self, request, service='cognito-sync'):
|
||||
""" Sign the request.
|
||||
|
||||
More info at https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html.
|
||||
|
||||
:param requests.PreparedRequest request: A prepared request that should be signed.
|
||||
:param str service: The service where this request is going to.
|
||||
"""
|
||||
|
||||
def sign(key, msg):
|
||||
""" Sign this message. """
|
||||
return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest()
|
||||
|
||||
def get_signature_key(key, date_stamp, region_name, service_name):
|
||||
""" Generate a signature key. """
|
||||
k_date = sign(('AWS4' + key).encode('utf-8'), date_stamp)
|
||||
k_region = sign(k_date, region_name)
|
||||
k_service = sign(k_region, service_name)
|
||||
k_signing = sign(k_service, 'aws4_request')
|
||||
return k_signing
|
||||
|
||||
# Parse the URL
|
||||
url_parsed = urlparse(request.url)
|
||||
|
||||
# Create a date for headers and the credential string
|
||||
now = datetime.datetime.utcnow()
|
||||
amzdate = now.strftime('%Y%m%dT%H%M%SZ')
|
||||
datestamp = now.strftime('%Y%m%d') # Date w/o time, used in credential scope
|
||||
|
||||
# Step 1. Create a canonical request
|
||||
canonical_uri = quote(url_parsed.path)
|
||||
canonical_querystring = url_parsed.query # TODO: sort when using multiple values
|
||||
canonical_headers = ('host:' + url_parsed.netloc + '\n' +
|
||||
'x-amz-date:' + amzdate + '\n')
|
||||
signed_headers = 'host;x-amz-date'
|
||||
|
||||
if request.body:
|
||||
payload_hash = hashlib.sha256(request.body).hexdigest()
|
||||
else:
|
||||
# SHA256 of empty string
|
||||
payload_hash = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
|
||||
|
||||
canonical_request = (request.method + '\n' +
|
||||
canonical_uri + '\n' +
|
||||
canonical_querystring + '\n' +
|
||||
canonical_headers + '\n' +
|
||||
signed_headers + '\n' +
|
||||
payload_hash)
|
||||
|
||||
# Step 2. Create a string to sign
|
||||
algorithm = 'AWS4-HMAC-SHA256'
|
||||
credential_scope = '%s/%s/%s/%s' % (datestamp, self.region, service, 'aws4_request')
|
||||
string_to_sign = (algorithm + '\n' +
|
||||
amzdate + '\n' +
|
||||
credential_scope + '\n' +
|
||||
hashlib.sha256(canonical_request.encode('utf-8')).hexdigest())
|
||||
signing_key = get_signature_key(self.credentials.get('SecretKey'), datestamp, self.region, service)
|
||||
|
||||
# Step 3. Calculate the signature
|
||||
signature = hmac.new(signing_key, string_to_sign.encode('utf-8'), hashlib.sha256).hexdigest()
|
||||
|
||||
authorization_header = '%s Credential=%s/%s, SignedHeaders=%s, Signature=%s' % (
|
||||
algorithm, self.credentials.get('AccessKeyId'), credential_scope, signed_headers, signature)
|
||||
|
||||
# Step 4. Add the signature to the request
|
||||
request.headers.update({
|
||||
'x-amz-date': amzdate,
|
||||
'Authorization': authorization_header
|
||||
})
|
||||
|
||||
def list_records(self, dataset, key):
|
||||
""" Return the values of this dataset.
|
||||
|
||||
:param str dataset: The name of the dataset to request.
|
||||
:param str key: The name of the key to request.
|
||||
:return The requested dataset
|
||||
:rtype: dict
|
||||
"""
|
||||
# Prepare the request
|
||||
request = requests.Request(
|
||||
method='GET',
|
||||
params={
|
||||
'maxResults': 1024,
|
||||
},
|
||||
url=self.url + '/identitypools/{identity_pool_id}/identities/{identity_id}/datasets/{dataset}/records'.format(
|
||||
identity_pool_id=self.identity_pool_id,
|
||||
identity_id=self.identity_id,
|
||||
dataset=dataset
|
||||
),
|
||||
headers={
|
||||
'x-amz-security-token': self.credentials.get('SessionToken'),
|
||||
}).prepare()
|
||||
|
||||
# Sign the request
|
||||
self._sign(request)
|
||||
|
||||
# Send the request
|
||||
reply = self._session.send(request)
|
||||
reply.raise_for_status()
|
||||
result = json.loads(reply.text)
|
||||
|
||||
_LOGGER.debug("Got results: %s", result.get('Records'))
|
||||
|
||||
# Return the records
|
||||
try:
|
||||
record = next(record for record in result.get('Records', []) if record.get('Key') == key)
|
||||
value = json.loads(record.get('Value'))
|
||||
sync_count = record.get('SyncCount')
|
||||
except StopIteration:
|
||||
value = None
|
||||
sync_count = 0
|
||||
|
||||
return value, result.get('SyncSessionToken'), sync_count
|
||||
|
||||
def update_records(self, dataset, key, value, session_token, sync_count):
|
||||
""" Return the values of this dataset.
|
||||
|
||||
:param str dataset: The name of the dataset to request.
|
||||
:param any value: The value.
|
||||
:param str session_token: The session token from the list_records call.
|
||||
:param int sync_count: The last SyncCount value, so we refuse race conditions.
|
||||
"""
|
||||
# Prepare the request
|
||||
request = requests.Request(
|
||||
method='POST',
|
||||
url=self.url + '/identitypools/{identity_pool_id}/identities/{identity_id}/datasets/{dataset}'.format(
|
||||
identity_pool_id=self.identity_pool_id,
|
||||
identity_id=self.identity_id,
|
||||
dataset=dataset
|
||||
),
|
||||
headers={
|
||||
'x-amz-security-token': self.credentials.get('SessionToken'),
|
||||
},
|
||||
json={
|
||||
"SyncSessionToken": session_token,
|
||||
"RecordPatches": [
|
||||
{
|
||||
"Key": key,
|
||||
"Op": "replace",
|
||||
"SyncCount": sync_count,
|
||||
"Value": json.dumps(value),
|
||||
}
|
||||
]
|
||||
}).prepare()
|
||||
|
||||
# Sign the request
|
||||
self._sign(request)
|
||||
|
||||
# Send the request
|
||||
reply = self._session.send(request)
|
||||
reply.raise_for_status()
|
@ -3,7 +3,6 @@
|
||||
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@ -11,27 +10,17 @@ import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from six.moves.html_parser import HTMLParser
|
||||
import requests
|
||||
|
||||
from resources.lib import kodiutils
|
||||
from resources.lib.kodiutils import STREAM_DASH, STREAM_HLS, html_to_kodi
|
||||
from resources.lib.viervijfzes import ResolvedStream
|
||||
from resources.lib.viervijfzes import CHANNELS
|
||||
|
||||
try: # Python 3
|
||||
from html import unescape
|
||||
except ImportError: # Python 2
|
||||
from HTMLParser import HTMLParser
|
||||
|
||||
unescape = HTMLParser().unescape
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER = logging.getLogger('content-api')
|
||||
|
||||
CACHE_AUTO = 1 # Allow to use the cache, and query the API if no cache is available
|
||||
CACHE_ONLY = 2 # Only use the cache, don't use the API
|
||||
CACHE_PREVENT = 3 # Don't use the cache
|
||||
|
||||
PROXIES = kodiutils.get_proxies()
|
||||
|
||||
|
||||
class UnavailableException(Exception):
|
||||
""" Is thrown when an item is unavailable. """
|
||||
@ -48,9 +37,7 @@ class GeoblockedException(Exception):
|
||||
class Program:
|
||||
""" Defines a Program. """
|
||||
|
||||
def __init__(self, uuid=None, path=None, channel=None, title=None, description=None, aired=None, poster=None, thumb=None, fanart=None, seasons=None,
|
||||
episodes=None,
|
||||
clips=None, my_list=False):
|
||||
def __init__(self, uuid=None, path=None, channel=None, title=None, description=None, aired=None, cover=None, background=None, seasons=None, episodes=None):
|
||||
"""
|
||||
:type uuid: str
|
||||
:type path: str
|
||||
@ -58,13 +45,10 @@ class Program:
|
||||
:type title: str
|
||||
:type description: str
|
||||
:type aired: datetime
|
||||
:type poster: str
|
||||
:type thumb: str
|
||||
:type fanart: str
|
||||
:type cover: str
|
||||
:type background: str
|
||||
:type seasons: list[Season]
|
||||
:type episodes: list[Episode]
|
||||
:type clips: list[Episode]
|
||||
:type my_list: bool
|
||||
"""
|
||||
self.uuid = uuid
|
||||
self.path = path
|
||||
@ -72,13 +56,10 @@ class Program:
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.aired = aired
|
||||
self.poster = poster
|
||||
self.thumb = thumb
|
||||
self.fanart = fanart
|
||||
self.cover = cover
|
||||
self.background = background
|
||||
self.seasons = seasons
|
||||
self.episodes = episodes
|
||||
self.clips = clips
|
||||
self.my_list = my_list
|
||||
|
||||
def __repr__(self):
|
||||
return "%r" % self.__dict__
|
||||
@ -87,13 +68,14 @@ class Program:
|
||||
class Season:
|
||||
""" Defines a Season. """
|
||||
|
||||
def __init__(self, uuid=None, path=None, channel=None, title=None, description=None, number=None):
|
||||
def __init__(self, uuid=None, path=None, channel=None, title=None, description=None, cover=None, number=None):
|
||||
"""
|
||||
:type uuid: str
|
||||
:type path: str
|
||||
:type channel: str
|
||||
:type title: str
|
||||
:type description: str
|
||||
:type cover: str
|
||||
:type number: int
|
||||
|
||||
"""
|
||||
@ -102,6 +84,7 @@ class Season:
|
||||
self.channel = channel
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.cover = cover
|
||||
self.number = number
|
||||
|
||||
def __repr__(self):
|
||||
@ -111,8 +94,8 @@ class Season:
|
||||
class Episode:
|
||||
""" Defines an Episode. """
|
||||
|
||||
def __init__(self, uuid=None, nodeid=None, path=None, channel=None, program_title=None, title=None, description=None, thumb=None, duration=None,
|
||||
season=None, season_uuid=None, number=None, rating=None, aired=None, expiry=None, stream=None, content_type=None):
|
||||
def __init__(self, uuid=None, nodeid=None, path=None, channel=None, program_title=None, title=None, description=None, cover=None, duration=None,
|
||||
season=None, season_uuid=None, number=None, rating=None, aired=None, expiry=None):
|
||||
"""
|
||||
:type uuid: str
|
||||
:type nodeid: str
|
||||
@ -121,7 +104,7 @@ class Episode:
|
||||
:type program_title: str
|
||||
:type title: str
|
||||
:type description: str
|
||||
:type thumb: str
|
||||
:type cover: str
|
||||
:type duration: int
|
||||
:type season: int
|
||||
:type season_uuid: str
|
||||
@ -129,8 +112,6 @@ class Episode:
|
||||
:type rating: str
|
||||
:type aired: datetime
|
||||
:type expiry: datetime
|
||||
:type stream: string
|
||||
:type content_type: string
|
||||
"""
|
||||
self.uuid = uuid
|
||||
self.nodeid = nodeid
|
||||
@ -139,7 +120,7 @@ class Episode:
|
||||
self.program_title = program_title
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.thumb = thumb
|
||||
self.cover = cover
|
||||
self.duration = duration
|
||||
self.season = season
|
||||
self.season_uuid = season_uuid
|
||||
@ -147,38 +128,19 @@ class Episode:
|
||||
self.rating = rating
|
||||
self.aired = aired
|
||||
self.expiry = expiry
|
||||
self.stream = stream
|
||||
self.content_type = content_type
|
||||
|
||||
def __repr__(self):
|
||||
return "%r" % self.__dict__
|
||||
|
||||
|
||||
class Category:
|
||||
""" Defines a Category. """
|
||||
|
||||
def __init__(self, uuid=None, channel=None, title=None, programs=None, episodes=None):
|
||||
"""
|
||||
:type uuid: str
|
||||
:type channel: str
|
||||
:type title: str
|
||||
:type programs: List[Program]
|
||||
:type episodes: List[Episode]
|
||||
"""
|
||||
self.uuid = uuid
|
||||
self.channel = channel
|
||||
self.title = title
|
||||
self.programs = programs
|
||||
self.episodes = episodes
|
||||
|
||||
def __repr__(self):
|
||||
return "%r" % self.__dict__
|
||||
|
||||
|
||||
class ContentApi:
|
||||
""" GoPlay Content API"""
|
||||
SITE_URL = 'https://www.goplay.be'
|
||||
API_GOPLAY = 'https://api.goplay.be'
|
||||
""" VIER/VIJF/ZES Content API"""
|
||||
API_ENDPOINT = 'https://api.viervijfzes.be'
|
||||
SITE_APIS = {
|
||||
'vier': 'https://www.vier.be/api',
|
||||
'vijf': 'https://www.vijf.be/api',
|
||||
'zes': 'https://www.zestv.be/api',
|
||||
}
|
||||
|
||||
def __init__(self, auth=None, cache_path=None):
|
||||
""" Initialise object """
|
||||
@ -186,482 +148,124 @@ class ContentApi:
|
||||
self._auth = auth
|
||||
self._cache_path = cache_path
|
||||
|
||||
def get_programs(self, channel=None, cache=CACHE_AUTO):
|
||||
def get_programs(self, channel, cache=CACHE_AUTO):
|
||||
""" Get a list of all programs of the specified channel.
|
||||
:type channel: str
|
||||
:type cache: str
|
||||
:rtype list[Program]
|
||||
"""
|
||||
if channel not in CHANNELS:
|
||||
raise Exception('Unknown channel %s' % channel)
|
||||
|
||||
def update():
|
||||
""" Fetch the program listing by scraping """
|
||||
# Load webpage
|
||||
raw_html = self._get_url(self.SITE_URL + '/programmas')
|
||||
raw_html = self._get_url(CHANNELS[channel]['url'])
|
||||
|
||||
# Parse programs
|
||||
regex_programs = re.compile(r'data-program="(?P<json>[^"]+)"', re.DOTALL)
|
||||
|
||||
data = [
|
||||
json.loads(unescape(item.group('json')))
|
||||
parser = HTMLParser()
|
||||
regex_programs = re.compile(r'<a class="program-overview__link" href="(?P<path>[^"]+)">\s+'
|
||||
r'<span class="program-overview__title">\s+(?P<title>[^<]+)</span>.*?'
|
||||
r'</a>', re.DOTALL)
|
||||
data = {
|
||||
item.group('path').lstrip('/'): parser.unescape(item.group('title').strip())
|
||||
for item in regex_programs.finditer(raw_html)
|
||||
]
|
||||
}
|
||||
|
||||
if not data:
|
||||
raise Exception('No programs found')
|
||||
raise Exception('No programs found for %s' % channel)
|
||||
|
||||
return data
|
||||
|
||||
# Fetch listing from cache or update if needed
|
||||
data = self._handle_cache(key=['programs'], cache_mode=cache, update=update, ttl=30 * 60) # 30 minutes
|
||||
data = self._handle_cache(key=['programs', channel], cache_mode=cache, update=update, ttl=30 * 5)
|
||||
if not data:
|
||||
return []
|
||||
|
||||
if channel:
|
||||
programs = [
|
||||
self._parse_program_data(record) for record in data if record['pageInfo']['brand'] == channel
|
||||
]
|
||||
else:
|
||||
programs = [
|
||||
self._parse_program_data(record) for record in data
|
||||
]
|
||||
|
||||
programs = []
|
||||
for path in data:
|
||||
title = data[path]
|
||||
program = self.get_program(channel, path, CACHE_ONLY) # Get program details, but from cache only
|
||||
if program:
|
||||
# Use program with metadata from cache
|
||||
programs.append(program)
|
||||
else:
|
||||
# Use program with the values that we've parsed from the page
|
||||
programs.append(Program(channel=channel,
|
||||
path=path,
|
||||
title=title))
|
||||
return programs
|
||||
|
||||
def get_program(self, path, extract_clips=False, cache=CACHE_AUTO):
|
||||
def get_program(self, channel, path, cache=CACHE_AUTO):
|
||||
""" Get a Program object from the specified page.
|
||||
:type channel: str
|
||||
:type path: str
|
||||
:type extract_clips: bool
|
||||
:type cache: int
|
||||
:rtype Program
|
||||
"""
|
||||
# We want to use the html to extract clips
|
||||
# This is the worst hack, since Python 2.7 doesn't support nonlocal
|
||||
raw_html = [None]
|
||||
if channel not in CHANNELS:
|
||||
raise Exception('Unknown channel %s' % channel)
|
||||
|
||||
def update():
|
||||
""" Fetch the program metadata by scraping """
|
||||
# Fetch webpage
|
||||
page = self._get_url(self.SITE_URL + '/' + path)
|
||||
|
||||
# Store a copy in the parent's raw_html var.
|
||||
raw_html[0] = page
|
||||
page = self._get_url(CHANNELS[channel]['url'] + '/' + path)
|
||||
|
||||
# Extract JSON
|
||||
regex_program = re.compile(r'data-hero="([^"]+)', re.DOTALL)
|
||||
json_data = unescape(regex_program.search(page).group(1))
|
||||
json_data = HTMLParser().unescape(regex_program.search(page).group(1))
|
||||
data = json.loads(json_data)['data']
|
||||
|
||||
return data
|
||||
|
||||
# Fetch listing from cache or update if needed
|
||||
data = self._handle_cache(key=['program', path], cache_mode=cache, update=update)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
program = self._parse_program_data(data)
|
||||
|
||||
# Also extract clips if we did a real HTTP call
|
||||
if extract_clips and raw_html[0]:
|
||||
clips = self._extract_videos(raw_html[0])
|
||||
program.clips = clips
|
||||
|
||||
return program
|
||||
|
||||
def get_program_by_uuid(self, uuid, cache=CACHE_AUTO):
|
||||
""" Get a Program object with the specified uuid.
|
||||
:type uuid: str
|
||||
:type cache: str
|
||||
:rtype Program
|
||||
"""
|
||||
if not uuid:
|
||||
return None
|
||||
|
||||
def update():
|
||||
""" Fetch the program metadata """
|
||||
# Fetch webpage
|
||||
result = self._get_url(self.SITE_URL + '/api/program/%s' % uuid)
|
||||
data = json.loads(result)
|
||||
return data
|
||||
|
||||
# Fetch listing from cache or update if needed
|
||||
data = self._handle_cache(key=['program', uuid], cache_mode=cache, update=update)
|
||||
if not data:
|
||||
return None
|
||||
data = self._handle_cache(key=['program', channel, path], cache_mode=cache, update=update)
|
||||
|
||||
program = self._parse_program_data(data)
|
||||
|
||||
return program
|
||||
|
||||
def get_episode(self, path, cache=CACHE_AUTO):
|
||||
def get_episode(self, channel, path):
|
||||
""" Get a Episode object from the specified page.
|
||||
:type channel: str
|
||||
:type path: str
|
||||
:type cache: str
|
||||
:rtype Episode
|
||||
NOTE: This function doesn't use an API.
|
||||
"""
|
||||
if channel not in CHANNELS:
|
||||
raise Exception('Unknown channel %s' % channel)
|
||||
|
||||
def update():
|
||||
""" Fetch the program metadata by scraping """
|
||||
# Load webpage
|
||||
page = self._get_url(self.SITE_URL + '/' + path)
|
||||
# Load webpage
|
||||
page = self._get_url(CHANNELS[channel]['url'] + '/' + path)
|
||||
|
||||
program_json = None
|
||||
episode_json = None
|
||||
# Extract program JSON
|
||||
parser = HTMLParser()
|
||||
regex_program = re.compile(r'data-hero="([^"]+)', re.DOTALL)
|
||||
json_data = parser.unescape(regex_program.search(page).group(1))
|
||||
data = json.loads(json_data)['data']
|
||||
program = self._parse_program_data(data)
|
||||
|
||||
# Extract video JSON by looking for a data-video tag
|
||||
# This is not present on every page
|
||||
regex_video_data = re.compile(r'data-video="([^"]+)"', re.DOTALL)
|
||||
result = regex_video_data.search(page)
|
||||
if result:
|
||||
video_id = json.loads(unescape(result.group(1)))['id']
|
||||
video_json_data = self._get_url('%s/web/v1/videos/short-form/%s' % (self.API_GOPLAY, video_id))
|
||||
video_json = json.loads(video_json_data)
|
||||
return {'video': video_json}
|
||||
# Extract episode JSON
|
||||
regex_episode = re.compile(r'<script type="application/json" data-drupal-selector="drupal-settings-json">(.*?)</script>', re.DOTALL)
|
||||
json_data = parser.unescape(regex_episode.search(page).group(1))
|
||||
data = json.loads(json_data)
|
||||
|
||||
# Extract program JSON
|
||||
regex_program = re.compile(r'data-hero="([^"]+)', re.DOTALL)
|
||||
result = regex_program.search(page)
|
||||
if result:
|
||||
program_json_data = unescape(result.group(1))
|
||||
program_json = json.loads(program_json_data)['data']
|
||||
|
||||
# Extract episode JSON
|
||||
regex_episode = re.compile(r'<script type="application/json" data-drupal-selector="drupal-settings-json">(.*?)</script>', re.DOTALL)
|
||||
result = regex_episode.search(page)
|
||||
if result:
|
||||
episode_json_data = unescape(result.group(1))
|
||||
episode_json = json.loads(episode_json_data)
|
||||
|
||||
return {'program': program_json, 'episode': episode_json}
|
||||
|
||||
# Fetch listing from cache or update if needed
|
||||
data = self._handle_cache(key=['episode', path], cache_mode=cache, update=update)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
if 'episode' in data and data['episode']['pageInfo']['type'] == 'live_channel':
|
||||
episode = Episode(
|
||||
uuid=data['episode']['pageInfo']['nodeUuid'],
|
||||
program_title=data['episode']['pageInfo']['title'],
|
||||
content_type=data['episode']['pageInfo']['type'],
|
||||
)
|
||||
return episode
|
||||
|
||||
if 'video' in data and data['video']:
|
||||
# We have found detailed episode information
|
||||
episode = self._parse_clip_data(data['video'])
|
||||
return episode
|
||||
|
||||
if 'program' in data and 'episode' in data and data['program'] and data['episode']:
|
||||
# We don't have detailed episode information
|
||||
# We need to lookup the episode in the program JSON
|
||||
program = self._parse_program_data(data['program'])
|
||||
for episode in program.episodes:
|
||||
if episode.nodeid == data['episode']['pageInfo']['nodeId']:
|
||||
return episode
|
||||
# Lookup the episode in the program JSON based on the nodeId
|
||||
# The episode we just found doesn't contain all information
|
||||
for episode in program.episodes:
|
||||
if episode.nodeid == data['pageInfo']['nodeId']:
|
||||
return episode
|
||||
|
||||
return None
|
||||
|
||||
def get_stream_by_uuid(self, uuid, content_type):
|
||||
""" Return a ResolvedStream for this video.
|
||||
:type uuid: string
|
||||
:type content_type: string
|
||||
:rtype: ResolvedStream
|
||||
def get_stream_by_uuid(self, uuid):
|
||||
""" Get the stream URL to use for this video.
|
||||
:type uuid: str
|
||||
:rtype str
|
||||
"""
|
||||
if content_type in ('video-long_form', 'long_form'):
|
||||
mode = 'videos/long-form'
|
||||
elif content_type == 'video-short_form':
|
||||
mode = 'videos/short-form'
|
||||
elif content_type == 'live_channel':
|
||||
mode = 'liveStreams'
|
||||
response = self._get_url(self.API_GOPLAY + '/web/v1/%s/%s' % (mode, uuid), authentication='Bearer %s' % self._auth.get_token())
|
||||
response = self._get_url(self.API_ENDPOINT + '/content/%s' % uuid, authentication=True)
|
||||
data = json.loads(response)
|
||||
|
||||
if not data:
|
||||
raise UnavailableException
|
||||
|
||||
# Get DRM license
|
||||
license_key = None
|
||||
if data.get('drmXml'):
|
||||
# BuyDRM format
|
||||
# See https://docs.unified-streaming.com/documentation/drm/buydrm.html#setting-up-the-client
|
||||
|
||||
# Generate license key
|
||||
license_key = self.create_license_key('https://wv-keyos.licensekeyserver.com/', key_headers={
|
||||
'customdata': data['drmXml']
|
||||
})
|
||||
|
||||
# Get manifest url
|
||||
if data.get('manifestUrls'):
|
||||
|
||||
if data.get('manifestUrls').get('dash'):
|
||||
# DASH stream
|
||||
return ResolvedStream(
|
||||
uuid=uuid,
|
||||
url=data['manifestUrls']['dash'],
|
||||
stream_type=STREAM_DASH,
|
||||
license_key=license_key,
|
||||
)
|
||||
|
||||
# HLS stream
|
||||
return ResolvedStream(
|
||||
uuid=uuid,
|
||||
url=data['manifestUrls']['hls'],
|
||||
stream_type=STREAM_HLS,
|
||||
license_key=license_key,
|
||||
)
|
||||
|
||||
# No manifest url found, get manifest from Server-Side Ad Insertion service
|
||||
if data.get('adType') == 'SSAI' and data.get('ssai'):
|
||||
url = 'https://pubads.g.doubleclick.net/ondemand/dash/content/%s/vid/%s/streams' % (
|
||||
data.get('ssai').get('contentSourceID'), data.get('ssai').get('videoID'))
|
||||
ad_data = json.loads(self._post_url(url, data=''))
|
||||
|
||||
# Server-Side Ad Insertion DASH stream
|
||||
return ResolvedStream(
|
||||
uuid=uuid,
|
||||
url=ad_data['stream_manifest'],
|
||||
stream_type=STREAM_DASH,
|
||||
license_key=license_key,
|
||||
)
|
||||
|
||||
raise UnavailableException
|
||||
|
||||
def get_program_tree(self, cache=CACHE_AUTO):
|
||||
""" Get a content tree with information about all the programs.
|
||||
:type cache: str
|
||||
:rtype dict
|
||||
"""
|
||||
|
||||
def update():
|
||||
""" Fetch the content tree """
|
||||
response = self._get_url(self.SITE_URL + '/api/content_tree')
|
||||
return json.loads(response)
|
||||
|
||||
# Fetch listing from cache or update if needed
|
||||
data = self._handle_cache(key=['content_tree'], cache_mode=cache, update=update, ttl=5 * 60) # 5 minutes
|
||||
|
||||
return data
|
||||
|
||||
def get_popular_programs(self, brand=None):
|
||||
""" Get a list of popular programs.
|
||||
:rtype list[Program]
|
||||
"""
|
||||
if brand:
|
||||
response = self._get_url(self.SITE_URL + '/api/programs/popular/%s' % brand)
|
||||
else:
|
||||
response = self._get_url(self.SITE_URL + '/api/programs/popular')
|
||||
data = json.loads(response)
|
||||
|
||||
programs = []
|
||||
for program in data:
|
||||
programs.append(self._parse_program_data(program))
|
||||
|
||||
return programs
|
||||
|
||||
def get_categories(self):
|
||||
""" Return a list of categories.
|
||||
:rtype list[Category]
|
||||
"""
|
||||
content_tree = self.get_program_tree()
|
||||
|
||||
categories = []
|
||||
for category_id, category_name in content_tree.get('categories').items():
|
||||
categories.append(Category(uuid=category_id,
|
||||
title=category_name))
|
||||
|
||||
return categories
|
||||
|
||||
def get_category_content(self, category_id):
|
||||
""" Return a category.
|
||||
:type category_id: int
|
||||
:rtype list[Program]
|
||||
"""
|
||||
content_tree = self.get_program_tree()
|
||||
|
||||
# Find out all the program_id's of the requested category
|
||||
program_ids = [key for key, value in content_tree.get('programs').items() if value.get('category') == category_id]
|
||||
|
||||
# Filter out the list of all programs to only keep the one of the requested category
|
||||
return [program for program in self.get_programs() if program.uuid in program_ids]
|
||||
|
||||
def get_recommendation_categories(self):
|
||||
""" Get a list of all categories.
|
||||
:rtype list[Category]
|
||||
"""
|
||||
# Load all programs
|
||||
all_programs = self.get_programs()
|
||||
|
||||
# Load webpage
|
||||
raw_html = self._get_url(self.SITE_URL)
|
||||
|
||||
# Categories regexes
|
||||
regex_articles = re.compile(r'<article[^>]+>([\s\S]*?)</article>', re.DOTALL)
|
||||
regex_category = re.compile(r'<h2.*?>(.*?)</h2>(?:.*?<div class=\"visually-hidden\">(.*?)</div>)?', re.DOTALL)
|
||||
|
||||
categories = []
|
||||
for result in regex_articles.finditer(raw_html):
|
||||
article_html = result.group(1)
|
||||
|
||||
match_category = regex_category.search(article_html)
|
||||
category_title = None
|
||||
if match_category:
|
||||
category_title = unescape(match_category.group(1).strip())
|
||||
if match_category.group(2):
|
||||
category_title += ' [B]%s[/B]' % unescape(match_category.group(2).strip())
|
||||
|
||||
if category_title:
|
||||
# Extract programs and lookup in all_programs so we have more metadata
|
||||
programs = []
|
||||
for program in self._extract_programs(article_html):
|
||||
try:
|
||||
rich_program = next(rich_program for rich_program in all_programs if rich_program.path == program.path)
|
||||
programs.append(rich_program)
|
||||
except StopIteration:
|
||||
programs.append(program)
|
||||
|
||||
episodes = self._extract_videos(article_html)
|
||||
|
||||
categories.append(
|
||||
Category(uuid=hashlib.md5(category_title.encode('utf-8')).hexdigest(), title=category_title, programs=programs, episodes=episodes))
|
||||
|
||||
return categories
|
||||
|
||||
def get_mylist(self):
|
||||
""" Get the content of My List
|
||||
:rtype list[Program]
|
||||
"""
|
||||
data = self._get_url(self.API_GOPLAY + '/my-list', authentication='Bearer %s' % self._auth.get_token())
|
||||
result = json.loads(data)
|
||||
|
||||
items = []
|
||||
for item in result:
|
||||
try:
|
||||
program = self.get_program_by_uuid(item.get('programId'))
|
||||
if program:
|
||||
program.my_list = True
|
||||
items.append(program)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
_LOGGER.warning(exc)
|
||||
|
||||
return items
|
||||
|
||||
def mylist_add(self, program_id):
|
||||
""" Add a program on My List """
|
||||
self._post_url(self.API_GOPLAY + '/my-list', data={'programId': program_id}, authentication='Bearer %s' % self._auth.get_token())
|
||||
|
||||
def mylist_del(self, program_id):
|
||||
""" Remove a program on My List """
|
||||
self._delete_url(self.API_GOPLAY + '/my-list-item', params={'programId': program_id}, authentication='Bearer %s' % self._auth.get_token())
|
||||
|
||||
@staticmethod
|
||||
def _extract_programs(html):
|
||||
""" Extract Programs from HTML code
|
||||
:type html: str
|
||||
:rtype list[Program]
|
||||
"""
|
||||
# Item regexes
|
||||
regex_item = re.compile(r'<a[^>]+?href=\"(?P<path>[^\"]+)\"[^>]+?>'
|
||||
r'[\s\S]*?<h3 class=\"poster-teaser__title\">(?P<title>[^<]*)</h3>[\s\S]*?poster-teaser__image\" src=\"(?P<image>[\s\S]*?)\"[\s\S]*?'
|
||||
r'</a>', re.DOTALL)
|
||||
|
||||
# Extract items
|
||||
programs = []
|
||||
for item in regex_item.finditer(html):
|
||||
path = item.group('path')
|
||||
if path.startswith('/video'):
|
||||
continue
|
||||
|
||||
# Program
|
||||
programs.append(Program(
|
||||
path=path.lstrip('/'),
|
||||
title=unescape(item.group('title')),
|
||||
poster=unescape(item.group('image')),
|
||||
))
|
||||
|
||||
return programs
|
||||
|
||||
@staticmethod
|
||||
def _extract_videos(html):
|
||||
""" Extract videos from HTML code
|
||||
:type html: str
|
||||
:rtype list[Episode]
|
||||
"""
|
||||
# Item regexes
|
||||
regex_item = re.compile(r'<a[^>]+?class=\"(?P<item_type>[^\"]+)\"[^>]+?href=\"(?P<path>[^\"]+)\"[^>]+?>[\s\S]*?</a>', re.DOTALL)
|
||||
|
||||
regex_episode_program = re.compile(r'<(?:div|h3) class=\"episode-teaser__subtitle\">([^<]*)</(?:div|h3)>')
|
||||
regex_episode_title = re.compile(r'<(?:div|h3) class=\"(?:poster|card|image|episode)-teaser__title\">(?:<span>)?([^<]*)(?:</span>)?</(?:div|h3)>')
|
||||
regex_episode_duration = re.compile(r'data-duration=\"([^\"]*)\"')
|
||||
regex_episode_video_id = re.compile(r'data-video-id=\"([^\"]*)\"')
|
||||
regex_episode_image = re.compile(r'<img class=\"episode-teaser__header\" src=\"([^<\"]*)\"')
|
||||
regex_episode_badge = re.compile(r'<div class=\"badge (?:poster|card|image|episode)-teaser__badge (?:poster|card|image|episode)-teaser__badge--default\">([^<]*)</div>')
|
||||
|
||||
# Extract items
|
||||
episodes = []
|
||||
for item in regex_item.finditer(html):
|
||||
item_html = item.group(0)
|
||||
path = item.group('path')
|
||||
item_type = item.group('item_type')
|
||||
|
||||
# Extract title
|
||||
try:
|
||||
title = unescape(regex_episode_title.search(item_html).group(1))
|
||||
except AttributeError:
|
||||
continue
|
||||
|
||||
# This is not a video
|
||||
if not path.startswith('/video'):
|
||||
continue
|
||||
|
||||
try:
|
||||
episode_program = regex_episode_program.search(item_html).group(1)
|
||||
except AttributeError:
|
||||
_LOGGER.warning('Found no episode_program for %s', title)
|
||||
episode_program = None
|
||||
|
||||
try:
|
||||
episode_duration = int(regex_episode_duration.search(item_html).group(1))
|
||||
except AttributeError:
|
||||
_LOGGER.warning('Found no episode_duration for %s', title)
|
||||
episode_duration = None
|
||||
|
||||
try:
|
||||
episode_video_id = regex_episode_video_id.search(item_html).group(1)
|
||||
except AttributeError:
|
||||
_LOGGER.warning('Found no episode_video_id for %s', title)
|
||||
episode_video_id = None
|
||||
|
||||
try:
|
||||
episode_image = unescape(regex_episode_image.search(item_html).group(1))
|
||||
except AttributeError:
|
||||
_LOGGER.warning('Found no episode_image for %s', title)
|
||||
episode_image = None
|
||||
|
||||
try:
|
||||
episode_badge = unescape(regex_episode_badge.search(item_html).group(1))
|
||||
except AttributeError:
|
||||
episode_badge = None
|
||||
|
||||
description = title
|
||||
if episode_badge:
|
||||
description += "\n\n[B]%s[/B]" % episode_badge
|
||||
|
||||
content_type = 'video-short_form' if 'card-' in item_type else 'video-long_form'
|
||||
|
||||
# Episode
|
||||
episodes.append(Episode(
|
||||
path=path.lstrip('/'),
|
||||
channel='', # TODO
|
||||
title=title,
|
||||
description=html_to_kodi(description),
|
||||
duration=episode_duration,
|
||||
uuid=episode_video_id,
|
||||
thumb=episode_image,
|
||||
program_title=episode_program,
|
||||
content_type=content_type
|
||||
))
|
||||
|
||||
return episodes
|
||||
return data['video']['S']
|
||||
|
||||
@staticmethod
|
||||
def _parse_program_data(data):
|
||||
@ -669,48 +273,51 @@ class ContentApi:
|
||||
:type data: dict
|
||||
:rtype Program
|
||||
"""
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
# Create Program info
|
||||
program = Program(
|
||||
uuid=data.get('id'),
|
||||
path=data.get('link').lstrip('/'),
|
||||
channel=data.get('pageInfo').get('brand'),
|
||||
title=data.get('title'),
|
||||
description=html_to_kodi(data.get('description')),
|
||||
aired=datetime.fromtimestamp(data.get('pageInfo', {}).get('publishDate', 0.0)),
|
||||
poster=data.get('images').get('poster'),
|
||||
thumb=data.get('images').get('teaser'),
|
||||
fanart=data.get('images').get('teaser'),
|
||||
uuid=data['id'],
|
||||
path=data['link'].lstrip('/'),
|
||||
channel=data['pageInfo']['site'],
|
||||
title=data['title'],
|
||||
description=data['description'],
|
||||
aired=datetime.fromtimestamp(data.get('pageInfo', {}).get('publishDate')),
|
||||
cover=data['images']['poster'],
|
||||
background=data['images']['hero'],
|
||||
)
|
||||
|
||||
# Create Season info
|
||||
program.seasons = {
|
||||
key: Season(
|
||||
uuid=playlist.get('id'),
|
||||
path=playlist.get('link').lstrip('/'),
|
||||
channel=playlist.get('pageInfo').get('brand'),
|
||||
title=playlist.get('title'),
|
||||
description=html_to_kodi(playlist.get('description')),
|
||||
number=playlist.get('episodes')[0].get('seasonNumber'), # You did not see this
|
||||
uuid=playlist['id'],
|
||||
path=playlist['link'].lstrip('/'),
|
||||
channel=playlist['pageInfo']['site'],
|
||||
title=playlist['title'],
|
||||
description=playlist['pageInfo']['description'],
|
||||
number=playlist['episodes'][0]['seasonNumber'], # You did not see this
|
||||
)
|
||||
for key, playlist in enumerate(data.get('playlists', [])) if playlist.get('episodes')
|
||||
for key, playlist in enumerate(data['playlists']) if playlist['episodes']
|
||||
}
|
||||
|
||||
# Create Episodes info
|
||||
program.episodes = [
|
||||
ContentApi._parse_episode_data(episode, playlist.get('id'))
|
||||
for playlist in data.get('playlists', [])
|
||||
for episode in playlist.get('episodes')
|
||||
ContentApi._parse_episode_data(episode, playlist['id'])
|
||||
for playlist in data['playlists']
|
||||
for episode in playlist['episodes']
|
||||
]
|
||||
|
||||
return program
|
||||
|
||||
@staticmethod
|
||||
def _parse_episode_data(data, season_uuid=None):
|
||||
def _parse_episode_data(data, season_uuid):
|
||||
""" Parse the Episode JSON.
|
||||
:type data: dict
|
||||
:type season_uuid: str
|
||||
:rtype Episode
|
||||
"""
|
||||
|
||||
if data.get('episodeNumber'):
|
||||
episode_number = data.get('episodeNumber')
|
||||
else:
|
||||
@ -726,113 +333,33 @@ class ContentApi:
|
||||
nodeid=data.get('pageInfo', {}).get('nodeId'),
|
||||
path=data.get('link').lstrip('/'),
|
||||
channel=data.get('pageInfo', {}).get('site'),
|
||||
program_title=data.get('program', {}).get('title') if data.get('program') else data.get('title'),
|
||||
program_title=data.get('program', {}).get('title'),
|
||||
title=data.get('title'),
|
||||
description=html_to_kodi(data.get('description')),
|
||||
thumb=data.get('image'),
|
||||
description=data.get('pageInfo', {}).get('description'),
|
||||
cover=data.get('image'),
|
||||
duration=data.get('duration'),
|
||||
season=data.get('seasonNumber'),
|
||||
season_uuid=season_uuid,
|
||||
number=episode_number,
|
||||
aired=datetime.fromtimestamp(int(data.get('createdDate'))),
|
||||
expiry=datetime.fromtimestamp(int(data.get('unpublishDate'))) if data.get('unpublishDate') else None,
|
||||
rating=data.get('parentalRating'),
|
||||
stream=data.get('path'),
|
||||
content_type=data.get('type'),
|
||||
aired=datetime.fromtimestamp(data.get('createdDate')),
|
||||
expiry=datetime.fromtimestamp(data.get('unpublishDate')) if data.get('unpublishDate') else None,
|
||||
rating=data.get('parentalRating')
|
||||
)
|
||||
return episode
|
||||
|
||||
@staticmethod
|
||||
def _parse_clip_data(data):
|
||||
""" Parse the Clip JSON.
|
||||
:type data: dict
|
||||
:rtype Episode
|
||||
"""
|
||||
episode = Episode(
|
||||
uuid=data.get('videoUuid'),
|
||||
program_title=data.get('title'),
|
||||
title=data.get('title'),
|
||||
)
|
||||
return episode
|
||||
|
||||
@staticmethod
|
||||
def create_license_key(key_url, key_type='R', key_headers=None, key_value='', response_value=''):
|
||||
""" Create a license key string that we need for inputstream.adaptive.
|
||||
:type key_url: str
|
||||
:type key_type: str
|
||||
:type key_headers: dict[str, str]
|
||||
:type key_value: str
|
||||
:type response_value: str
|
||||
:rtype str
|
||||
"""
|
||||
try: # Python 3
|
||||
from urllib.parse import quote, urlencode
|
||||
except ImportError: # Python 2
|
||||
from urllib import quote, urlencode
|
||||
|
||||
header = ''
|
||||
if key_headers:
|
||||
header = urlencode(key_headers)
|
||||
|
||||
if key_type in ('A', 'R', 'B'):
|
||||
key_value = key_type + '{SSM}'
|
||||
elif key_type == 'D':
|
||||
if 'D{SSM}' not in key_value:
|
||||
raise ValueError('Missing D{SSM} placeholder')
|
||||
key_value = quote(key_value)
|
||||
|
||||
return '%s|%s|%s|%s' % (key_url, header, key_value, response_value)
|
||||
|
||||
def _get_url(self, url, params=None, authentication=None):
|
||||
def _get_url(self, url, params=None, authentication=False):
|
||||
""" Makes a GET request for the specified URL.
|
||||
:type url: str
|
||||
:type authentication: str
|
||||
:rtype str
|
||||
"""
|
||||
if authentication:
|
||||
if not self._auth:
|
||||
raise Exception('Requested to authenticate, but not auth object passed')
|
||||
response = self._session.get(url, params=params, headers={
|
||||
'authorization': authentication,
|
||||
}, proxies=PROXIES)
|
||||
'authorization': self._auth.get_token(),
|
||||
})
|
||||
else:
|
||||
response = self._session.get(url, params=params, proxies=PROXIES)
|
||||
|
||||
if response.status_code != 200:
|
||||
_LOGGER.error(response.text)
|
||||
raise Exception('Could not fetch data')
|
||||
|
||||
return response.text
|
||||
|
||||
def _post_url(self, url, params=None, data=None, authentication=None):
|
||||
""" Makes a POST request for the specified URL.
|
||||
:type url: str
|
||||
:type authentication: str
|
||||
:rtype str
|
||||
"""
|
||||
if authentication:
|
||||
response = self._session.post(url, params=params, json=data, headers={
|
||||
'authorization': authentication,
|
||||
}, proxies=PROXIES)
|
||||
else:
|
||||
response = self._session.post(url, params=params, json=data, proxies=PROXIES)
|
||||
|
||||
if response.status_code not in (200, 201):
|
||||
_LOGGER.error(response.text)
|
||||
raise Exception('Could not fetch data')
|
||||
|
||||
return response.text
|
||||
|
||||
def _delete_url(self, url, params=None, authentication=None):
|
||||
""" Makes a DELETE request for the specified URL.
|
||||
:type url: str
|
||||
:type authentication: str
|
||||
:rtype str
|
||||
"""
|
||||
if authentication:
|
||||
response = self._session.delete(url, params=params, headers={
|
||||
'authorization': authentication,
|
||||
}, proxies=PROXIES)
|
||||
else:
|
||||
response = self._session.delete(url, params=params, proxies=PROXIES)
|
||||
response = self._session.get(url, params=params)
|
||||
|
||||
if response.status_code != 200:
|
||||
_LOGGER.error(response.text)
|
||||
@ -866,8 +393,8 @@ class ContentApi:
|
||||
|
||||
def _get_cache(self, key, allow_expired=False):
|
||||
""" Get an item from the cache """
|
||||
filename = ('.'.join(key) + '.json').replace('/', '_')
|
||||
fullpath = os.path.join(self._cache_path, filename)
|
||||
filename = '.'.join(key) + '.json'
|
||||
fullpath = self._cache_path + filename
|
||||
|
||||
if not os.path.exists(fullpath):
|
||||
return None
|
||||
@ -885,11 +412,11 @@ class ContentApi:
|
||||
|
||||
def _set_cache(self, key, data, ttl):
|
||||
""" Store an item in the cache """
|
||||
filename = ('.'.join(key) + '.json').replace('/', '_')
|
||||
fullpath = os.path.join(self._cache_path, filename)
|
||||
filename = '.'.join(key) + '.json'
|
||||
fullpath = self._cache_path + filename
|
||||
|
||||
if not os.path.exists(self._cache_path):
|
||||
os.makedirs(self._cache_path)
|
||||
os.mkdir(self._cache_path)
|
||||
|
||||
with open(fullpath, 'w') as fdesc:
|
||||
_LOGGER.debug('Storing to cache as %s', filename)
|
||||
|
@ -7,42 +7,18 @@ import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import dateutil.parser
|
||||
import dateutil.tz
|
||||
import dateutil
|
||||
import requests
|
||||
|
||||
from resources.lib import kodiutils
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
GENRE_MAPPING = {
|
||||
'Detective': 0x11,
|
||||
'Dramaserie': 0x15,
|
||||
'Fantasy': 0x13,
|
||||
'Human Interest': 0x00,
|
||||
'Informatief': 0x20,
|
||||
'Komedie': 0x14,
|
||||
'Komische serie': 0x14,
|
||||
'Kookprogramma': '',
|
||||
'Misdaadserie': 0x15,
|
||||
'Politieserie': 0x17,
|
||||
'Reality': 0x31,
|
||||
'Science Fiction': 0x13,
|
||||
'Show': 0x30,
|
||||
'Thriller': 0x11,
|
||||
'Voetbal': 0x43,
|
||||
}
|
||||
|
||||
PROXIES = kodiutils.get_proxies()
|
||||
_LOGGER = logging.getLogger('epg-api')
|
||||
|
||||
|
||||
class EpgProgram:
|
||||
""" Defines a Program in the EPG. """
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def __init__(self, channel, program_title, episode_title, episode_title_original, number, season, genre, start,
|
||||
won_id, won_program_id, program_description, description, duration, program_url, video_url, thumb,
|
||||
airing):
|
||||
def __init__(self, channel, program_title, episode_title, episode_title_original, number, season, genre, start, won_id, won_program_id, program_description,
|
||||
description, duration, program_url, video_url, cover, airing):
|
||||
self.channel = channel
|
||||
self.program_title = program_title
|
||||
self.episode_title = episode_title
|
||||
@ -58,31 +34,22 @@ class EpgProgram:
|
||||
self.duration = duration
|
||||
self.program_url = program_url
|
||||
self.video_url = video_url
|
||||
self.thumb = thumb
|
||||
self.cover = cover
|
||||
self.airing = airing
|
||||
|
||||
if GENRE_MAPPING.get(self.genre):
|
||||
self.genre_id = GENRE_MAPPING.get(self.genre)
|
||||
else:
|
||||
self.genre_id = None
|
||||
|
||||
def __repr__(self):
|
||||
return "%r" % self.__dict__
|
||||
|
||||
|
||||
class EpgApi:
|
||||
""" GoPlay EPG API """
|
||||
""" VIER/VIJF/ZES EPG API """
|
||||
|
||||
EPG_ENDPOINTS = {
|
||||
'Play4': 'https://www.goplay.be/api/epg/vier/{date}',
|
||||
'Play5': 'https://www.goplay.be/api/epg/vijf/{date}',
|
||||
'Play6': 'https://www.goplay.be/api/epg/zes/{date}',
|
||||
'Play7': 'https://www.goplay.be/api/epg/zeven/{date}',
|
||||
'PlayCrime': 'https://www.goplay.be/api/epg/crime/{date}',
|
||||
'vier': 'https://www.vier.be/api/epg/{date}',
|
||||
'vijf': 'https://www.vijf.be/api/epg/{date}',
|
||||
'zes': 'https://www.zestv.be/api/epg/{date}',
|
||||
}
|
||||
|
||||
EPG_NO_BROADCAST = 'Geen uitzending'
|
||||
|
||||
def __init__(self):
|
||||
""" Initialise object """
|
||||
self._session = requests.session()
|
||||
@ -92,7 +59,7 @@ class EpgApi:
|
||||
:type channel: str
|
||||
:type date: str
|
||||
:rtype list[EpgProgram]
|
||||
"""
|
||||
"""
|
||||
if channel not in self.EPG_ENDPOINTS:
|
||||
raise Exception('Unknown channel %s' % channel)
|
||||
|
||||
@ -111,7 +78,7 @@ class EpgApi:
|
||||
data = json.loads(response)
|
||||
|
||||
# Parse the results
|
||||
return [self._parse_program(channel, x) for x in data if x.get('program_title') != self.EPG_NO_BROADCAST]
|
||||
return [self._parse_program(channel, x) for x in data]
|
||||
|
||||
@staticmethod
|
||||
def _parse_program(channel, data):
|
||||
@ -123,8 +90,8 @@ class EpgApi:
|
||||
duration = int(data.get('duration')) if data.get('duration') else None
|
||||
|
||||
# Check if this broadcast is currently airing
|
||||
timestamp = datetime.now().replace(tzinfo=dateutil.tz.gettz('CET'))
|
||||
start = datetime.fromtimestamp(data.get('timestamp')).replace(tzinfo=dateutil.tz.gettz('CET'))
|
||||
timestamp = datetime.now()
|
||||
start = datetime.fromtimestamp(data.get('timestamp'))
|
||||
if duration:
|
||||
airing = bool(start <= timestamp < (start + timedelta(seconds=duration)))
|
||||
else:
|
||||
@ -133,10 +100,10 @@ class EpgApi:
|
||||
# Only allow direct playing if the linked video is the actual program
|
||||
if data.get('video_node', {}).get('latest_video'):
|
||||
video_url = (data.get('video_node', {}).get('url') or '').lstrip('/')
|
||||
thumb = data.get('video_node', {}).get('image')
|
||||
cover = data.get('video_node', {}).get('image')
|
||||
else:
|
||||
video_url = None
|
||||
thumb = None
|
||||
cover = None
|
||||
|
||||
return EpgProgram(
|
||||
channel=channel,
|
||||
@ -144,7 +111,7 @@ class EpgApi:
|
||||
episode_title=data.get('episode_title'),
|
||||
episode_title_original=data.get('original_title'),
|
||||
number=int(data.get('episode_nr')) if data.get('episode_nr') else None,
|
||||
season=data.get('season'),
|
||||
season=int(data.get('season')) if data.get('season') else None,
|
||||
genre=data.get('genre'),
|
||||
start=start,
|
||||
won_id=int(data.get('won_id')) if data.get('won_id') else None,
|
||||
@ -154,7 +121,7 @@ class EpgApi:
|
||||
duration=duration,
|
||||
program_url=(data.get('program_node', {}).get('url') or '').lstrip('/'),
|
||||
video_url=video_url,
|
||||
thumb=thumb,
|
||||
cover=cover,
|
||||
airing=airing,
|
||||
)
|
||||
|
||||
@ -165,14 +132,14 @@ class EpgApi:
|
||||
:rtype: EpgProgram
|
||||
"""
|
||||
# Parse to a real datetime
|
||||
timestamp = dateutil.parser.parse(timestamp).replace(tzinfo=dateutil.tz.gettz('CET'))
|
||||
timestamp = dateutil.parser.parse(timestamp)
|
||||
|
||||
# Load guide info for this date
|
||||
programs = self.get_epg(channel=channel, date=timestamp.strftime('%Y-%m-%d'))
|
||||
|
||||
# Find a matching broadcast
|
||||
for broadcast in programs:
|
||||
if broadcast.start <= timestamp < (broadcast.start + timedelta(seconds=broadcast.duration)):
|
||||
if timestamp <= broadcast.start < (broadcast.start + timedelta(seconds=broadcast.duration)):
|
||||
return broadcast
|
||||
|
||||
return None
|
||||
@ -182,7 +149,7 @@ class EpgApi:
|
||||
:type url: str
|
||||
:rtype str
|
||||
"""
|
||||
response = self._session.get(url, proxies=PROXIES)
|
||||
response = self._session.get(url)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception('Could not fetch data')
|
||||
|
@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
""" Search API """
|
||||
""" AUTH API """
|
||||
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
|
||||
@ -8,21 +8,17 @@ import logging
|
||||
|
||||
import requests
|
||||
|
||||
from resources.lib import kodiutils
|
||||
from resources.lib.viervijfzes.content import CACHE_ONLY, ContentApi, Program
|
||||
from resources.lib.viervijfzes.content import Program
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PROXIES = kodiutils.get_proxies()
|
||||
_LOGGER = logging.getLogger('search-api')
|
||||
|
||||
|
||||
class SearchApi:
|
||||
""" GoPlay Search API """
|
||||
API_ENDPOINT = 'https://api.goplay.be/search'
|
||||
""" VIER/VIJF/ZES Search API """
|
||||
API_ENDPOINT = 'https://api.viervijfzes.be/search'
|
||||
|
||||
def __init__(self):
|
||||
""" Initialise object """
|
||||
self._api = ContentApi(None, cache_path=kodiutils.get_cache_path())
|
||||
self._session = requests.session()
|
||||
|
||||
def search(self, query):
|
||||
@ -37,28 +33,26 @@ class SearchApi:
|
||||
self.API_ENDPOINT,
|
||||
json={
|
||||
"query": query,
|
||||
"sites": ["vier", "vijf", "zes"],
|
||||
"page": 0,
|
||||
"mode": "programs"
|
||||
},
|
||||
proxies=PROXIES
|
||||
"mode": "byDate"
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception('Could not search')
|
||||
|
||||
data = json.loads(response.text)
|
||||
|
||||
results = []
|
||||
for hit in data['hits']['hits']:
|
||||
if hit['_source']['bundle'] == 'program':
|
||||
path = hit['_source']['url'].split('/')[-1]
|
||||
program = self._api.get_program(path, cache=CACHE_ONLY)
|
||||
if program:
|
||||
results.append(program)
|
||||
else:
|
||||
results.append(Program(
|
||||
path=path,
|
||||
title=hit['_source']['title'],
|
||||
description=hit['_source']['intro'],
|
||||
poster=hit['_source']['img'],
|
||||
))
|
||||
results.append(Program(
|
||||
channel=hit['_source']['site'],
|
||||
path=hit['_source']['url'].strip('/'),
|
||||
title=hit['_source']['title'],
|
||||
description=hit['_source']['intro'],
|
||||
cover=hit['_source']['img'],
|
||||
))
|
||||
|
||||
return results
|
||||
|
Before Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 27 KiB |
Before Width: | Height: | Size: 41 KiB |
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 8.2 KiB |
Before Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 39 KiB |
Before Width: | Height: | Size: 9.7 KiB |
Before Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 12 KiB |
BIN
resources/logos/vier-background.jpg
Normal file
After Width: | Height: | Size: 45 KiB |
BIN
resources/logos/vier.png
Normal file
After Width: | Height: | Size: 7.7 KiB |
BIN
resources/logos/vijf-background.jpg
Normal file
After Width: | Height: | Size: 57 KiB |
BIN
resources/logos/vijf.png
Normal file
After Width: | Height: | Size: 5.2 KiB |
BIN
resources/logos/zes-background.jpg
Normal file
After Width: | Height: | Size: 59 KiB |
BIN
resources/logos/zes.png
Normal file
After Width: | Height: | Size: 9.9 KiB |
@ -1,26 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<settings>
|
||||
<setting id="metadata_last_updated" visible="false"/>
|
||||
<category label="30800"> <!-- Credentials -->
|
||||
<setting label="30801" type="lsep"/> <!-- Credentials -->
|
||||
<setting label="30802" type="text" id="username"/>
|
||||
<setting label="30803" type="text" id="password" option="hidden"/>
|
||||
<setting label="30803" type="text" id="username"/>
|
||||
<setting label="30805" type="text" id="password" option="hidden"/>
|
||||
</category>
|
||||
<category label="30820"> <!-- Interface -->
|
||||
<setting label="30820" type="lsep"/> <!-- Interface -->
|
||||
<setting label="30821" type="bool" id="interface_show_unavailable" default="true"/>
|
||||
<setting label="30827" type="lsep"/> <!-- Metadata -->
|
||||
<setting label="30829" type="bool" id="metadata_update" default="true" subsetting="true"/>
|
||||
<setting label="30831" type="action" action="RunPlugin(plugin://plugin.video.viervijfzes/metadata/update)"/>
|
||||
</category>
|
||||
<category label="30840"> <!-- Integrations -->
|
||||
<setting label="30841" type="lsep"/> <!-- IPTV Manager -->
|
||||
<setting label="30842" type="action" action="InstallAddon(service.iptv.manager)" option="close" visible="!System.HasAddon(service.iptv.manager)"/> <!-- Install IPTV Manager add-on -->
|
||||
<setting label="30843" type="bool" id="iptv.enabled" default="true" visible="String.StartsWith(System.BuildVersion,18) + System.HasAddon(service.iptv.manager) | System.AddonIsEnabled(service.iptv.manager)" />
|
||||
<setting label="30844" type="action" action="Addon.OpenSettings(service.iptv.manager)" enable="eq(-1,true)" option="close" visible="String.StartsWith(System.BuildVersion,18) + System.HasAddon(service.iptv.manager) | System.AddonIsEnabled(service.iptv.manager)" subsetting="true"/> <!-- IPTV Manager settings -->
|
||||
<setting id="iptv.channels_uri" default="plugin://plugin.video.viervijfzes/iptv/channels" visible="false"/>
|
||||
<setting id="iptv.epg_uri" default="plugin://plugin.video.viervijfzes/iptv/epg" visible="false"/>
|
||||
</category>
|
||||
<category label="30880"> <!-- Expert -->
|
||||
<setting label="30881" type="lsep"/> <!-- Logging -->
|
||||
<setting label="30882" type="bool" id="debug_logging" default="false"/>
|
||||
<setting label="30883" type="action" action="InstallAddon(script.kodi.loguploader)" option="close" visible="!System.HasAddon(script.kodi.loguploader)"/> <!-- Install Kodi Logfile Uploader -->
|
||||
<setting label="30884" type="action" action="RunAddon(script.kodi.loguploader)" visible="String.StartsWith(System.BuildVersion,18) + System.HasAddon(script.kodi.loguploader) | System.AddonIsEnabled(script.kodi.loguploader)" /> <!-- Open Kodi Logfile Uploader -->
|
||||
<category label="30840"> <!-- Playback from cache -->
|
||||
<setting label="30841" type="bool" id="episode_cache_enabled" default="true"/>
|
||||
<setting label="30843" type="folder" id="episode_cache_folder" source="local" option="writeable" enable="eq(-1,true)"/>
|
||||
</category>
|
||||
</settings>
|
||||
|
@ -1,87 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
""" Build ZIP files for all brands. """
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
DIST_DIR = 'dist'
|
||||
|
||||
|
||||
def get_files():
|
||||
""" Get a list of files that we should package. """
|
||||
# Start with all non-hidden files
|
||||
files = [f for f in os.listdir() if not f.startswith('.')]
|
||||
|
||||
# Exclude files from .gitattributes
|
||||
with open('.gitattributes', 'r') as f:
|
||||
for line in f.read().splitlines():
|
||||
filename, mode = line.split(' ')
|
||||
filename = filename.strip('/')
|
||||
if mode == 'export-ignore' and filename in files:
|
||||
files.remove(filename)
|
||||
|
||||
# Exclude files from .gitignore. I know, this won't do matching
|
||||
with open('.gitignore', 'r') as f:
|
||||
for filename in f.read().splitlines():
|
||||
filename = filename.strip('/')
|
||||
if filename in files:
|
||||
files.remove(filename)
|
||||
|
||||
return files
|
||||
|
||||
|
||||
def modify_xml(file, version, news, python=None):
|
||||
""" Modify an addon.xml. """
|
||||
with open(file, 'r+') as f:
|
||||
tree = ET.fromstring(f.read())
|
||||
|
||||
# Update values
|
||||
tree.set('version', version)
|
||||
tree.find("./extension[@point='xbmc.addon.metadata']/news").text = news
|
||||
if python:
|
||||
tree.find("./requires/import[@addon='xbmc.python']").set('version', python)
|
||||
|
||||
# Save file
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
f.write('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
|
||||
ET.tostring(tree, encoding='UTF-8').decode())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Read base addon.xml info
|
||||
with open('addon.xml', 'r') as f:
|
||||
tree = ET.fromstring(f.read())
|
||||
addon_info = {
|
||||
'id': tree.get('id'),
|
||||
'version': tree.get('version'),
|
||||
'news': tree.find("./extension[@point='xbmc.addon.metadata']/news").text
|
||||
}
|
||||
|
||||
# Make sure dist folder exists
|
||||
if not os.path.isdir(DIST_DIR):
|
||||
os.mkdir(DIST_DIR)
|
||||
|
||||
# Build addon
|
||||
brand = addon_info['id']
|
||||
dest = os.path.join(DIST_DIR, brand)
|
||||
if not os.path.isdir(dest):
|
||||
os.mkdir(dest)
|
||||
|
||||
# Copy files from add-on source
|
||||
for f in get_files():
|
||||
if os.path.isfile(f):
|
||||
shutil.copy(f, dest)
|
||||
else:
|
||||
shutil.copytree(f, os.path.join(dest, f), dirs_exist_ok=True)
|
||||
|
||||
# Update addon.xml for matrix and create zip
|
||||
modify_xml(os.path.join(dest, 'addon.xml'), addon_info['version'] + '+matrix.1', addon_info['news'])
|
||||
shutil.make_archive(os.path.join(DIST_DIR, "%s-%s+matrix.1" % (brand, addon_info['version'])), 'zip', DIST_DIR, brand)
|
||||
|
||||
# Modify addon.xml for leia and create zip
|
||||
# modify_xml(os.path.join(dest, 'addon.xml'), addon_info['version'], addon_info['news'], '2.26.0')
|
||||
# shutil.make_archive(os.path.join(DIST_DIR, "%s-%s" % (brand, addon_info['version'])), 'zip', DIST_DIR, brand)
|
@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
""" Quick and dirty way to check if all translations might be used. """
|
||||
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
# pylint: disable=invalid-name,superfluous-parens
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import polib
|
||||
|
||||
error = 0
|
||||
|
||||
# Load all python code from git
|
||||
code = subprocess.check_output(['git', 'grep', '', '--', 'resources/*.py', 'resources/settings.xml']).decode('utf-8')
|
||||
|
||||
# Load po file
|
||||
po = polib.pofile('resources/language/resource.language.en_gb/strings.po')
|
||||
for entry in po:
|
||||
# Extract msgctxt
|
||||
msgctxt = entry.msgctxt.lstrip('#')
|
||||
|
||||
if msgctxt not in code:
|
||||
print('No usage found for translation:')
|
||||
print(entry)
|
||||
error = 1
|
||||
|
||||
sys.exit(error)
|
@ -1,225 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
""" Publish a ZIP file to the Kodi repository. """
|
||||
|
||||
# Based on code from https://github.com/xbmc/kodi-addon-submitter
|
||||
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from pprint import pformat
|
||||
from tempfile import TemporaryDirectory
|
||||
from zipfile import ZipFile
|
||||
|
||||
import requests
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
GH_REPO = 'repo-plugins'
|
||||
GH_USERNAME = os.getenv('GH_USERNAME')
|
||||
GH_TOKEN = os.getenv('GH_TOKEN')
|
||||
GH_EMAIL = os.getenv('EMAIL')
|
||||
|
||||
|
||||
def get_addon_info(xml: str):
|
||||
""" Parse the passed addon.xml file and extract some information. """
|
||||
tree = ET.fromstring(xml)
|
||||
return {
|
||||
'id': tree.get('id'),
|
||||
'name': tree.get('name'),
|
||||
'version': tree.get('version'),
|
||||
'description': tree.find("./extension[@point='xbmc.addon.metadata']/description").text,
|
||||
'news': tree.find("./extension[@point='xbmc.addon.metadata']/news").text,
|
||||
'python': tree.find("./requires/import[@addon='xbmc.python']").get('version'),
|
||||
'source': tree.find("./extension[@point='xbmc.addon.metadata']/source").text,
|
||||
}
|
||||
|
||||
|
||||
def user_fork_exists(repo, gh_username, gh_token):
|
||||
""" Check if the user has a fork of the repository on Github. """
|
||||
resp = requests.get(
|
||||
'https://api.github.com/repos/{}/{}'.format(
|
||||
gh_username,
|
||||
repo
|
||||
),
|
||||
headers={'Accept': 'application/vnd.github.v3+json'},
|
||||
params={
|
||||
'type': 'all'
|
||||
},
|
||||
auth=(gh_username, gh_token)
|
||||
)
|
||||
resp_json = resp.json()
|
||||
return resp.ok and resp_json.get('fork')
|
||||
|
||||
|
||||
def create_personal_fork(repo, gh_username, gh_token):
|
||||
"""Create a personal fork for the official repo on GitHub. """
|
||||
resp = requests.post(
|
||||
'https://api.github.com/repos/xbmc/{}/forks'.format(
|
||||
repo
|
||||
),
|
||||
headers={'Accept': 'application/vnd.github.v3+json'},
|
||||
auth=(gh_username, gh_token)
|
||||
)
|
||||
if resp.ok:
|
||||
elapsed_time = 0
|
||||
while elapsed_time < 5 * 60:
|
||||
if not user_fork_exists(repo, gh_username, gh_token):
|
||||
time.sleep(20)
|
||||
elapsed_time += 20
|
||||
else:
|
||||
return
|
||||
raise Exception("Timeout waiting for fork creation exceeded")
|
||||
raise Exception('GitHub API error: {}\n{}'.format(resp.status_code, resp.text))
|
||||
|
||||
|
||||
def shell(*args):
|
||||
""" Execute a shell command. """
|
||||
subprocess.run(args, check=True)
|
||||
|
||||
|
||||
def create_addon_branch(repo, branch, source, addon_info, gh_username, gh_token, gh_email):
|
||||
""" Create and addon branch in your fork of the respective addon repo. """
|
||||
cur_dir = os.getcwd()
|
||||
os.chdir('dist')
|
||||
|
||||
local_branch_name = '{}@{}'.format(addon_info['id'], branch)
|
||||
|
||||
if os.path.isdir(repo):
|
||||
# We already have a checked out repo locally, update this with upstream code
|
||||
os.chdir(repo)
|
||||
shell('git', 'reset', '--hard') # Remove all local changes
|
||||
shell('git', 'remote', 'set-branches', '--add', 'upstream', branch) # Make sure the upstream branch exists
|
||||
shell('git', 'fetch', '-f', 'upstream', branch) # Fetch upstream
|
||||
else:
|
||||
# Clone the upstream repo
|
||||
shell('git', 'clone', '--branch', branch, '--origin', 'upstream', '--single-branch', 'https://github.com/xbmc/{}.git'.format(repo))
|
||||
os.chdir(repo)
|
||||
|
||||
# Create local branch
|
||||
shell('git', 'checkout', '-B', local_branch_name, 'upstream/{}'.format(branch))
|
||||
|
||||
# Remove current code
|
||||
if os.path.isdir(addon_info['id']):
|
||||
shutil.rmtree(addon_info['id'], ignore_errors=False)
|
||||
|
||||
# Add new code
|
||||
shutil.copytree(source, addon_info['id'])
|
||||
shell('git', 'add', '--', addon_info['id'])
|
||||
shell('git', 'status')
|
||||
|
||||
# Create a commit with the new code
|
||||
shell('git', 'config', 'user.name', gh_username)
|
||||
shell('git', 'config', 'user.email', gh_email)
|
||||
shell('git', 'commit', '-m', '[{}] {}'.format(addon_info['id'], addon_info['version']))
|
||||
|
||||
# Push branch to fork
|
||||
shell('git', 'push', '-f', 'https://{}:{}@github.com/{}/{}.git'.format(gh_username, gh_token, gh_username, repo), local_branch_name)
|
||||
|
||||
# Restore working directory
|
||||
os.chdir(cur_dir)
|
||||
|
||||
|
||||
def create_pull_request(repo, branch, addon_info, gh_username, gh_token):
|
||||
""" Create a pull request in the official repo on GitHub. """
|
||||
|
||||
local_branch_name = '{}@{}'.format(addon_info['id'], branch)
|
||||
|
||||
# Check if pull request already exists.
|
||||
resp = requests.get(
|
||||
'https://api.github.com/repos/xbmc/{}/pulls'.format(repo),
|
||||
params={
|
||||
'head': '{}:{}'.format(gh_username, local_branch_name),
|
||||
'base': branch,
|
||||
},
|
||||
headers={'Accept': 'application/vnd.github.v3+json'},
|
||||
auth=(gh_username, gh_token)
|
||||
)
|
||||
|
||||
if resp.status_code == 200 and not resp.json():
|
||||
# Create a new Pull Request
|
||||
template = """### Description
|
||||
|
||||
- **General**
|
||||
- Add-on name: {name}
|
||||
- Add-on ID: {id}
|
||||
- Version number: {version}
|
||||
- Kodi/repository version: {kodi_repo_branch}
|
||||
|
||||
- **Code location**
|
||||
- URL: {source}
|
||||
|
||||
{description}
|
||||
|
||||
### What's new
|
||||
|
||||
{news}
|
||||
|
||||
### Checklist:
|
||||
- [X] My code follows the [add-on rules](http://kodi.wiki/view/Add-on_rules) and [piracy stance](http://kodi.wiki/view/Official:Forum_rules#Piracy_Policy) of this project.
|
||||
- [X] I have read the [CONTRIBUTING](https://github.com/xbmc/repo-plugins/blob/master/CONTRIBUTING.md) document
|
||||
- [X] Each add-on submission should be a single commit with using the following style: [plugin.video.foo] v1.0.0
|
||||
"""
|
||||
pr_body = template.format(
|
||||
name=addon_info['name'],
|
||||
id=addon_info['id'],
|
||||
version=addon_info['version'],
|
||||
kodi_repo_branch=branch,
|
||||
source=addon_info['source'],
|
||||
description=addon_info['description'],
|
||||
news=addon_info['news']
|
||||
)
|
||||
resp = requests.post(
|
||||
'https://api.github.com/repos/xbmc/{}/pulls'.format(repo),
|
||||
json={
|
||||
'title': '[{}] {}'.format(local_branch_name, addon_info['version']),
|
||||
'head': '{}:{}'.format(gh_username, local_branch_name),
|
||||
'base': branch,
|
||||
'body': pr_body,
|
||||
'maintainer_can_modify': True,
|
||||
},
|
||||
headers={'Accept': 'application/vnd.github.v3+json'},
|
||||
auth=(gh_username, gh_token)
|
||||
)
|
||||
if resp.status_code != 201:
|
||||
raise Exception('GitHub API error: {}\n{}'.format(resp.status_code, pformat(resp.json())))
|
||||
|
||||
elif resp.status_code == 200 and resp.json():
|
||||
_LOGGER.info('Pull request in {} for {}:{} already exists.'.format(branch, gh_username, local_branch_name))
|
||||
|
||||
else:
|
||||
raise Exception('Unexpected GitHub error: {}\n{}'.format(resp.status_code, pformat(resp.json())))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
filenames = sys.argv[1:]
|
||||
|
||||
# Fork the repo if the user does not have a personal repo fork
|
||||
if not user_fork_exists(GH_REPO, GH_USERNAME, GH_TOKEN):
|
||||
create_personal_fork(GH_REPO, GH_USERNAME, GH_TOKEN)
|
||||
|
||||
for filename in filenames:
|
||||
with TemporaryDirectory() as extract_dir:
|
||||
with ZipFile(filename) as z:
|
||||
# Look for addon.xml in zip and load the details
|
||||
xmlfile = next(f.filename for f in z.filelist if f.filename.endswith('addon.xml'))
|
||||
addon_info = get_addon_info(z.read(xmlfile).decode('utf-8'))
|
||||
if addon_info['python'] != '3.0.0':
|
||||
branch = 'leia'
|
||||
else:
|
||||
branch = 'matrix'
|
||||
|
||||
# Extract the ZIP file to the extract_dir
|
||||
z.extractall(extract_dir)
|
||||
|
||||
# Checkout the fork locally and create a branch with our new code from the extract_dir
|
||||
create_addon_branch(GH_REPO, branch, os.path.join(extract_dir, addon_info['id']), addon_info, GH_USERNAME, GH_TOKEN, GH_EMAIL)
|
||||
|
||||
# Create pull request
|
||||
create_pull_request(GH_REPO, branch, addon_info, GH_USERNAME, GH_TOKEN)
|
@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# pylint: disable=missing-docstring,no-self-use,wrong-import-order,wrong-import-position,invalid-name
|
||||
|
||||
import sys
|
||||
from glob import glob
|
||||
|
||||
import polib
|
||||
|
||||
original_file = 'resources/language/resource.language.en_gb/strings.po'
|
||||
original = polib.pofile(original_file, wrapwidth=0)
|
||||
|
||||
for translated_file in glob('resources/language/resource.language.*/strings.po'):
|
||||
# Skip original file
|
||||
if translated_file == original_file:
|
||||
continue
|
||||
print('Updating %s...' % translated_file)
|
||||
|
||||
# Load po-files
|
||||
translated = polib.pofile(translated_file, wrapwidth=0)
|
||||
|
||||
for entry in original:
|
||||
# Find a translation
|
||||
translation = translated.find(entry.msgctxt, 'msgctxt')
|
||||
|
||||
if translation and entry.msgid == translation.msgid:
|
||||
entry.msgstr = translation.msgstr
|
||||
|
||||
original.metadata = translated.metadata
|
||||
|
||||
if sys.platform.startswith('win'):
|
||||
# On Windows save the file keeping the Linux return character
|
||||
with open(translated_file, 'wb') as _file:
|
||||
content = str(original).encode('utf-8')
|
||||
content = content.replace(b'\r\n', b'\n')
|
||||
_file.write(content)
|
||||
else:
|
||||
# Save it now over the translation
|
||||
original.save(translated_file)
|
@ -4,30 +4,5 @@
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import xbmcaddon
|
||||
|
||||
try: # Python 3
|
||||
from http.client import HTTPConnection
|
||||
except ImportError: # Python 2
|
||||
from httplib import HTTPConnection
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
# Add logging to urllib
|
||||
HTTPConnection.debuglevel = 1
|
||||
|
||||
# Make UTF-8 the default encoding in Python 2
|
||||
if sys.version_info[0] == 2:
|
||||
reload(sys) # pylint: disable=undefined-variable # noqa: F821
|
||||
sys.setdefaultencoding("utf-8") # pylint: disable=no-member
|
||||
|
||||
# Set credentials based on environment data
|
||||
# Use the .env file with Pipenv to make this work nicely during development
|
||||
ADDON = xbmcaddon.Addon()
|
||||
if os.environ.get('ADDON_USERNAME'):
|
||||
ADDON.setSetting('username', os.environ.get('ADDON_USERNAME'))
|
||||
if os.environ.get('ADDON_PASSWORD'):
|
||||
ADDON.setSetting('password', os.environ.get('ADDON_PASSWORD'))
|
||||
|
@ -1,3 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<addon id="inputstream.adaptive" version="2.4.4" name="InputStream Adaptive" provider-name="peak3d">
|
||||
</addon>
|
@ -1,3 +0,0 @@
|
||||
<settings version="2">
|
||||
<setting id="videolibrary.showallitems" default="true">true</setting>
|
||||
</settings>
|
11
tests/run.py
@ -1,11 +1,12 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
""" Run any Kodi plugin:// URL on the commandline """
|
||||
# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com>
|
||||
# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
""" Run any Kodi VTM GO plugin:// URL on the commandline """
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
@ -14,6 +15,12 @@ cwd = os.path.abspath(os.path.join(os.path.dirname(os.path.realpath(os.path.real
|
||||
sys.path.insert(0, cwd)
|
||||
from resources.lib import addon # noqa: E402 pylint: disable=wrong-import-position
|
||||
|
||||
xbmc = __import__('xbmc')
|
||||
xbmcaddon = __import__('xbmcaddon')
|
||||
xbmcgui = __import__('xbmcgui')
|
||||
xbmcplugin = __import__('xbmcplugin')
|
||||
xbmcvfs = __import__('xbmcvfs')
|
||||
|
||||
if len(sys.argv) <= 1:
|
||||
print("%s: URI argument missing\nTry '%s plugin://plugin.video.viervijfzes/' to test." % (sys.argv[0], sys.argv[0]))
|
||||
sys.exit(1)
|
||||
|
@ -9,11 +9,10 @@ import logging
|
||||
import unittest
|
||||
|
||||
import resources.lib.kodiutils as kodiutils
|
||||
from resources.lib.viervijfzes import ResolvedStream
|
||||
from resources.lib.viervijfzes.auth import AuthApi
|
||||
from resources.lib.viervijfzes.content import ContentApi, Program, Episode, CACHE_PREVENT, Category
|
||||
from resources.lib.viervijfzes.content import ContentApi, Program, Episode
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER = logging.getLogger('test-api')
|
||||
|
||||
|
||||
class TestApi(unittest.TestCase):
|
||||
@ -23,60 +22,27 @@ class TestApi(unittest.TestCase):
|
||||
self._api = ContentApi(auth, cache_path=kodiutils.get_cache_path())
|
||||
|
||||
def test_programs(self):
|
||||
programs = self._api.get_programs()
|
||||
self.assertIsInstance(programs, list)
|
||||
self.assertIsInstance(programs[0], Program)
|
||||
|
||||
def test_popular_programs(self):
|
||||
for brand in [None, 'vier', 'vijf', 'zes', 'goplay']:
|
||||
programs = self._api.get_popular_programs(brand)
|
||||
for channel in ['vier', 'vijf', 'zes']:
|
||||
programs = self._api.get_programs(channel)
|
||||
self.assertIsInstance(programs, list)
|
||||
self.assertIsInstance(programs[0], Program)
|
||||
|
||||
def test_recommendations(self):
|
||||
categories = self._api.get_recommendation_categories()
|
||||
self.assertIsInstance(categories, list)
|
||||
|
||||
def test_categories(self):
|
||||
categories = self._api.get_categories()
|
||||
self.assertIsInstance(categories, list)
|
||||
self.assertIsInstance(categories[0], Category)
|
||||
|
||||
programs = self._api.get_category_content(int(categories[0].uuid))
|
||||
self.assertIsInstance(programs, list)
|
||||
self.assertIsInstance(programs[0], Program)
|
||||
|
||||
def test_episodes(self):
|
||||
for program in ['gentwest', 'zo-man-zo-vrouw']:
|
||||
program = self._api.get_program(program, cache=CACHE_PREVENT)
|
||||
for channel, program in [('vier', 'auwch'), ('vijf', 'zo-man-zo-vrouw')]:
|
||||
program = self._api.get_program(channel, program)
|
||||
self.assertIsInstance(program, Program)
|
||||
self.assertIsInstance(program.seasons, dict)
|
||||
self.assertIsInstance(program.episodes, list)
|
||||
self.assertIsInstance(program.episodes[0], Episode)
|
||||
|
||||
def test_clips(self):
|
||||
for program in ['de-tafel-van-vier']:
|
||||
program = self._api.get_program(program, extract_clips=True, cache=CACHE_PREVENT)
|
||||
|
||||
self.assertIsInstance(program.clips, list)
|
||||
self.assertIsInstance(program.clips[0], Episode)
|
||||
|
||||
episode = self._api.get_episode(program.clips[0].path, cache=CACHE_PREVENT)
|
||||
self.assertIsInstance(episode, Episode)
|
||||
|
||||
@unittest.skipUnless(kodiutils.get_setting('username') and kodiutils.get_setting('password'), 'Skipping since we have no credentials.')
|
||||
def test_get_stream(self):
|
||||
program = self._api.get_program('gentwest')
|
||||
program = self._api.get_program('vier', 'auwch')
|
||||
self.assertIsInstance(program, Program)
|
||||
|
||||
episode = program.episodes[0]
|
||||
resolved_stream = self._api.get_stream_by_uuid(episode.uuid, episode.islongform)
|
||||
self.assertIsInstance(resolved_stream, ResolvedStream)
|
||||
|
||||
@unittest.skipUnless(kodiutils.get_setting('username') and kodiutils.get_setting('password'), 'Skipping since we have no credentials.')
|
||||
def test_get_drm_stream(self):
|
||||
resolved_stream = self._api.get_stream_by_uuid('cc77be47-0256-4254-acbf-28a03fcac423', True) # https://www.goplay.be/video/ncis-los-angeles/ncis-los-angeles-s14/ncis-los-angeles-s14-aflevering-1
|
||||
self.assertIsInstance(resolved_stream, ResolvedStream)
|
||||
video = self._api.get_stream_by_uuid(episode.uuid)
|
||||
self.assertTrue(video)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
@ -11,7 +11,7 @@ import unittest
|
||||
from resources.lib import kodiutils
|
||||
from resources.lib.viervijfzes.auth import AuthApi
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER = logging.getLogger('test-auth')
|
||||
|
||||
|
||||
class TestAuth(unittest.TestCase):
|
||||
@ -26,12 +26,12 @@ class TestAuth(unittest.TestCase):
|
||||
auth.clear_tokens()
|
||||
|
||||
# We should get a token by logging in
|
||||
id_token = auth.get_token()
|
||||
self.assertTrue(id_token)
|
||||
token = auth.get_token()
|
||||
self.assertTrue(token)
|
||||
|
||||
# Test it a second time, it should go from memory now
|
||||
id_token = auth.get_token()
|
||||
self.assertTrue(id_token)
|
||||
token = auth.get_token()
|
||||
self.assertTrue(token)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
52
tests/test_downloader.py
Normal file
@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
""" Tests for the episode downloader """
|
||||
|
||||
# pylint: disable=missing-docstring,no-self-use
|
||||
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
import logging
|
||||
import unittest
|
||||
|
||||
from resources.lib import kodiutils
|
||||
from resources.lib.downloader import Downloader
|
||||
from resources.lib.viervijfzes.auth import AuthApi
|
||||
from resources.lib.viervijfzes.content import ContentApi, Program
|
||||
|
||||
_LOGGER = logging.getLogger('test-downloader')
|
||||
|
||||
|
||||
class TestDownloader(unittest.TestCase):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(TestDownloader, self).__init__(*args, **kwargs)
|
||||
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
|
||||
self._api = ContentApi(auth, cache_path=kodiutils.get_cache_path())
|
||||
|
||||
def test_check(self):
|
||||
""" Test if ffmpeg is installed. """
|
||||
status = Downloader.check()
|
||||
self.assertTrue(status)
|
||||
|
||||
@unittest.skipUnless(kodiutils.get_setting('username') and kodiutils.get_setting('password'), 'Skipping since we have no credentials.')
|
||||
def test_download(self):
|
||||
""" Test to download a stream. """
|
||||
program = self._api.get_program('vier', 'de-mol')
|
||||
self.assertIsInstance(program, Program)
|
||||
|
||||
episode = program.episodes[0]
|
||||
stream = self._api.get_stream_by_uuid(episode.uuid)
|
||||
filename = '/tmp/download-test.mp4'
|
||||
|
||||
def progress_callback(total, seconds):
|
||||
_LOGGER.info('Downloading... Progress = %d / %d seconds', seconds, total)
|
||||
|
||||
# Terminate when we have downloaded 5 seconds, we just want to test this
|
||||
return seconds > 5
|
||||
|
||||
status = Downloader().download(stream=stream, output=filename, progress_callback=progress_callback)
|
||||
self.assertFalse(status) # status is false since we cancelled
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
@ -13,7 +13,7 @@ from resources.lib import kodiutils
|
||||
from resources.lib.viervijfzes.content import ContentApi, Episode
|
||||
from resources.lib.viervijfzes.epg import EpgApi, EpgProgram
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER = logging.getLogger('test-epg')
|
||||
|
||||
|
||||
class TestEpg(unittest.TestCase):
|
||||
@ -22,17 +22,17 @@ class TestEpg(unittest.TestCase):
|
||||
self._epg = EpgApi()
|
||||
|
||||
def test_vier_today(self):
|
||||
programs = self._epg.get_epg('Play4', date.today().strftime('%Y-%m-%d'))
|
||||
programs = self._epg.get_epg('vier', date.today().strftime('%Y-%m-%d'))
|
||||
self.assertIsInstance(programs, list)
|
||||
self.assertIsInstance(programs[0], EpgProgram)
|
||||
|
||||
def test_vijf_today(self):
|
||||
programs = self._epg.get_epg('Play5', date.today().strftime('%Y-%m-%d'))
|
||||
programs = self._epg.get_epg('vijf', date.today().strftime('%Y-%m-%d'))
|
||||
self.assertIsInstance(programs, list)
|
||||
self.assertIsInstance(programs[0], EpgProgram)
|
||||
|
||||
def test_zes_today(self):
|
||||
programs = self._epg.get_epg('Play6', date.today().strftime('%Y-%m-%d'))
|
||||
programs = self._epg.get_epg('zes', date.today().strftime('%Y-%m-%d'))
|
||||
self.assertIsInstance(programs, list)
|
||||
self.assertIsInstance(programs[0], EpgProgram)
|
||||
|
||||
@ -41,31 +41,18 @@ class TestEpg(unittest.TestCase):
|
||||
self._epg.get_epg('vtm', date.today().strftime('%Y-%m-%d'))
|
||||
|
||||
def test_vier_out_of_range(self):
|
||||
programs = self._epg.get_epg('Play4', '2020-01-01')
|
||||
programs = self._epg.get_epg('vier', '2020-01-01')
|
||||
self.assertEqual(programs, [])
|
||||
|
||||
def test_play_video_from_epg(self):
|
||||
epg_programs = self._epg.get_epg('Play4', 'yesterday')
|
||||
epg_programs = self._epg.get_epg('vier', date.today().strftime('%Y-%m-%d'))
|
||||
epg_program = [program for program in epg_programs if program.video_url][0]
|
||||
|
||||
# Lookup the Episode data since we don't have an UUID
|
||||
api = ContentApi(cache_path=kodiutils.get_cache_path())
|
||||
episode = api.get_episode(epg_program.video_url)
|
||||
episode = api.get_episode(epg_program.channel, epg_program.video_url)
|
||||
self.assertIsInstance(episode, Episode)
|
||||
|
||||
# def test_map_epg_genre(self):
|
||||
# genres = []
|
||||
# for channel in ['vier', 'vijf', 'zes']:
|
||||
# for day in ['yesterday', 'today', 'tomorrow']:
|
||||
# programs = self._epg.get_epg(channel, day)
|
||||
#
|
||||
# for program in programs:
|
||||
# if program.genre not in genres:
|
||||
# genres.append(program.genre)
|
||||
#
|
||||
# print(genres)
|
||||
#
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
@ -1,45 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
""" Tests for My List """
|
||||
|
||||
# pylint: disable=missing-docstring,no-self-use
|
||||
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
import logging
|
||||
import unittest
|
||||
|
||||
from resources.lib import kodiutils
|
||||
from resources.lib.viervijfzes.auth import AuthApi
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TestMyList(unittest.TestCase):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(TestMyList, self).__init__(*args, **kwargs)
|
||||
|
||||
@unittest.skipUnless(kodiutils.get_setting('username') and kodiutils.get_setting('password'), 'Skipping since we have no credentials.')
|
||||
def test_mylist(self):
|
||||
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
|
||||
id_token = auth.get_token()
|
||||
self.assertTrue(id_token)
|
||||
|
||||
dataset, _ = auth.get_dataset('myList', 'myList')
|
||||
self.assertTrue(dataset)
|
||||
|
||||
# Test disabled since it would cause locks due to all the CI tests changing this at the same time.
|
||||
|
||||
# # Python 2.7 doesn't support .timestamp(), and windows doesn't do '%s', so we need to calculate it ourself
|
||||
# epoch = datetime(1970, 1, 1, tzinfo=dateutil.tz.gettz('UTC'))
|
||||
# now = datetime.now(tz=dateutil.tz.gettz('UTC'))
|
||||
# timestamp = str(int((now - epoch).total_seconds())) + '000'
|
||||
# new_dataset = [
|
||||
# {'id': '06e209f9-092e-421e-9499-58c62c292b98', 'timestamp': timestamp},
|
||||
# {'id': 'da584be3-dea6-49c7-bfbd-c480d8096937', 'timestamp': timestamp}
|
||||
# ]
|
||||
#
|
||||
# auth.put_dataset('myList', 'myList', new_dataset, sync_info)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
@ -9,6 +9,11 @@ import unittest
|
||||
|
||||
from resources.lib import addon
|
||||
|
||||
xbmc = __import__('xbmc') # pylint: disable=invalid-name
|
||||
xbmcaddon = __import__('xbmcaddon') # pylint: disable=invalid-name
|
||||
xbmcgui = __import__('xbmcgui') # pylint: disable=invalid-name
|
||||
xbmcplugin = __import__('xbmcplugin') # pylint: disable=invalid-name
|
||||
xbmcvfs = __import__('xbmcvfs') # pylint: disable=invalid-name
|
||||
|
||||
routing = addon.routing # pylint: disable=invalid-name
|
||||
|
||||
@ -29,30 +34,30 @@ class TestRouting(unittest.TestCase):
|
||||
|
||||
def test_channels_menu(self):
|
||||
routing.run([routing.url_for(addon.show_channels), '0', ''])
|
||||
routing.run([routing.url_for(addon.show_channel_menu, channel='Play4'), '0', ''])
|
||||
routing.run([routing.url_for(addon.show_channel_menu, channel='vier'), '0', ''])
|
||||
|
||||
def test_catalog_menu(self):
|
||||
routing.run([routing.url_for(addon.show_catalog), '0', ''])
|
||||
|
||||
def test_recommendations_menu(self):
|
||||
routing.run([routing.url_for(addon.show_recommendations), '0', ''])
|
||||
|
||||
def test_catalog_channel_menu(self):
|
||||
routing.run([routing.url_for(addon.show_channel_catalog, channel='Play4'), '0', ''])
|
||||
routing.run([routing.url_for(addon.show_catalog_channel, channel='vier'), '0', ''])
|
||||
|
||||
def test_catalog_program_menu(self):
|
||||
routing.run([routing.url_for(addon.show_catalog_program, channel='Play4', program='de-mol'), '0', ''])
|
||||
routing.run([routing.url_for(addon.show_catalog_program, channel='vier', program='de-mol'), '0', ''])
|
||||
|
||||
def test_catalog_program_season_menu(self):
|
||||
routing.run([routing.url_for(addon.show_catalog_program_season, channel='Play4', program='de-mol', season=-1), '0', ''])
|
||||
routing.run([routing.url_for(addon.show_catalog_program_season, channel='vier', program='de-mol', season=-1), '0', ''])
|
||||
|
||||
def test_search_menu(self):
|
||||
routing.run([routing.url_for(addon.show_search), '0', ''])
|
||||
routing.run([routing.url_for(addon.show_search, query='de mol'), '0', ''])
|
||||
|
||||
def test_tvguide_menu(self):
|
||||
routing.run([routing.url_for(addon.show_channel_tvguide, channel='Play4'), '0', ''])
|
||||
routing.run([routing.url_for(addon.show_channel_tvguide_detail, channel='Play4', date='today'), '0', ''])
|
||||
routing.run([routing.url_for(addon.show_tvguide_channel, channel='vier'), '0', ''])
|
||||
routing.run([routing.url_for(addon.show_tvguide_detail, channel='vier', date='today'), '0', ''])
|
||||
|
||||
def test_metadata_update(self):
|
||||
routing.run([routing.url_for(addon.metadata_update), '0', ''])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
@ -11,7 +11,7 @@ import unittest
|
||||
from resources.lib.viervijfzes.content import Program
|
||||
from resources.lib.viervijfzes.search import SearchApi
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER = logging.getLogger('test-search')
|
||||
|
||||
|
||||
class TestSearch(unittest.TestCase):
|
||||
|
@ -1,45 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
""" Tests for background service """
|
||||
|
||||
# pylint: disable=invalid-name,missing-docstring
|
||||
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import pytest
|
||||
|
||||
from resources.lib import addon, kodiutils
|
||||
from resources.lib.service import BackgroundService
|
||||
|
||||
routing = addon.routing
|
||||
|
||||
|
||||
@unittest.skipIf(sys.platform.startswith("win"), 'Skipping on Windows.')
|
||||
@unittest.skipUnless(kodiutils.get_setting('username') and kodiutils.get_setting('password'), 'Skipping since we have no credentials.')
|
||||
class TestService(unittest.TestCase):
|
||||
""" Tests for the background service """
|
||||
|
||||
@staticmethod
|
||||
@pytest.mark.timeout(timeout=10, method='thread')
|
||||
def test_service():
|
||||
""" Run the background service for 5 seconds. It will raise an error when it doesn't stop after 10 seconds. """
|
||||
|
||||
def terminate_service(seconds=5):
|
||||
""" Sleep a bit, and send us a SIGINT signal. """
|
||||
time.sleep(seconds)
|
||||
os.kill(os.getpid(), signal.SIGINT)
|
||||
|
||||
threading.Thread(target=terminate_service).start()
|
||||
|
||||
service = BackgroundService()
|
||||
service.run()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
10
tests/userdata/addon_settings.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"plugin.video.viervijfzes": {
|
||||
"_comment": "do-not-add-username-and-password-here",
|
||||
"metadata_update": "true"
|
||||
},
|
||||
"plugin.video.youtube": {
|
||||
},
|
||||
"service.upnext": {
|
||||
}
|
||||
}
|
4
tests/userdata/credentials.json.example
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"username": "username",
|
||||
"password": "password"
|
||||
}
|
6
tests/userdata/global_settings.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"locale.language": "resource.language.nl_nl",
|
||||
"network.bandwidth": 0,
|
||||
"network.usehttpproxy": false,
|
||||
"videolibrary.showallitems": true
|
||||
}
|
247
tests/xbmc.py
Normal file
@ -0,0 +1,247 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com>
|
||||
# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
""" This file implements the Kodi xbmc module, either using stubs or alternative functionality """
|
||||
|
||||
# pylint: disable=invalid-name,no-self-use,unused-argument
|
||||
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
from xbmcextra import global_settings, import_language
|
||||
|
||||
LOGDEBUG = 0
|
||||
LOGERROR = 4
|
||||
LOGFATAL = 6
|
||||
LOGINFO = 1
|
||||
LOGNONE = 7
|
||||
LOGNOTICE = 2
|
||||
LOGSEVERE = 5
|
||||
LOGWARNING = 3
|
||||
|
||||
LOG_MAPPING = {
|
||||
LOGDEBUG: 'Debug',
|
||||
LOGERROR: 'Error',
|
||||
LOGFATAL: 'Fatal',
|
||||
LOGINFO: 'Info',
|
||||
LOGNONE: 'None',
|
||||
LOGNOTICE: 'Notice',
|
||||
LOGSEVERE: 'Severe',
|
||||
LOGWARNING: 'Warning',
|
||||
}
|
||||
|
||||
INFO_LABELS = {
|
||||
'System.BuildVersion': '18.2',
|
||||
}
|
||||
|
||||
REGIONS = {
|
||||
'datelong': '%A, %e %B %Y',
|
||||
'dateshort': '%Y-%m-%d',
|
||||
}
|
||||
|
||||
GLOBAL_SETTINGS = global_settings()
|
||||
PO = import_language(language=GLOBAL_SETTINGS.get('locale.language'))
|
||||
|
||||
|
||||
def to_unicode(text, encoding='utf-8'):
|
||||
""" Force text to unicode """
|
||||
return text.decode(encoding) if isinstance(text, bytes) else text
|
||||
|
||||
|
||||
def from_unicode(text, encoding='utf-8'):
|
||||
""" Force unicode to text """
|
||||
import sys
|
||||
if sys.version_info.major == 2 and isinstance(text, unicode): # noqa: F821; pylint: disable=undefined-variable
|
||||
return text.encode(encoding)
|
||||
return text
|
||||
|
||||
|
||||
class Keyboard:
|
||||
""" A stub implementation of the xbmc Keyboard class """
|
||||
|
||||
def __init__(self, line='', heading=''):
|
||||
""" A stub constructor for the xbmc Keyboard class """
|
||||
|
||||
def doModal(self, autoclose=0):
|
||||
""" A stub implementation for the xbmc Keyboard class doModal() method """
|
||||
|
||||
def isConfirmed(self):
|
||||
""" A stub implementation for the xbmc Keyboard class isConfirmed() method """
|
||||
return True
|
||||
|
||||
def getText(self):
|
||||
""" A stub implementation for the xbmc Keyboard class getText() method """
|
||||
return 'test'
|
||||
|
||||
|
||||
class Monitor:
|
||||
"""A stub implementation of the xbmc Monitor class"""
|
||||
|
||||
def __init__(self, line='', heading=''):
|
||||
"""A stub constructor for the xbmc Monitor class"""
|
||||
self._deadline = time.time() + 10 # 10 seconds
|
||||
|
||||
def abortRequested(self):
|
||||
"""A stub implementation for the xbmc Keyboard class abortRequested() method"""
|
||||
return time.time() > self._deadline
|
||||
|
||||
def waitForAbort(self, timeout=None):
|
||||
"""A stub implementation for the xbmc Keyboard class waitForAbort() method"""
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
class Player:
|
||||
""" A stub implementation of the xbmc Player class """
|
||||
|
||||
def __init__(self):
|
||||
self._count = 0
|
||||
|
||||
def play(self, item='', listitem=None, windowed=False, startpos=-1):
|
||||
""" A stub implementation for the xbmc Player class play() method """
|
||||
return
|
||||
|
||||
def isPlaying(self):
|
||||
""" A stub implementation for the xbmc Player class isPlaying() method """
|
||||
# Return True four times out of five
|
||||
self._count += 1
|
||||
return bool(self._count % 5 != 0)
|
||||
|
||||
def setSubtitles(self, subtitleFile):
|
||||
""" A stub implementation for the xbmc Player class setSubtitles() method """
|
||||
return
|
||||
|
||||
def showSubtitles(self, visible):
|
||||
""" A stub implementation for the xbmc Player class showSubtitles() method """
|
||||
return
|
||||
|
||||
def getTotalTime(self):
|
||||
""" A stub implementation for the xbmc Player class getTotalTime() method """
|
||||
return 0
|
||||
|
||||
def getTime(self):
|
||||
""" A stub implementation for the xbmc Player class getTime() method """
|
||||
return 0
|
||||
|
||||
def getVideoInfoTag(self):
|
||||
""" A stub implementation for the xbmc Player class getVideoInfoTag() method """
|
||||
return VideoInfoTag()
|
||||
|
||||
def getPlayingFile(self):
|
||||
""" A stub implementation for the xbmc Player class getPlayingFile() method """
|
||||
return ''
|
||||
|
||||
|
||||
class VideoInfoTag:
|
||||
""" A stub implementation of the xbmc VideoInfoTag class """
|
||||
|
||||
def __init__(self):
|
||||
""" A stub constructor for the xbmc VideoInfoTag class """
|
||||
|
||||
def getSeason(self):
|
||||
""" A stub implementation for the xbmc VideoInfoTag class getSeason() method """
|
||||
return 0
|
||||
|
||||
def getEpisode(self):
|
||||
""" A stub implementation for the xbmc VideoInfoTag class getEpisode() method """
|
||||
return 0
|
||||
|
||||
def getTVShowTitle(self):
|
||||
""" A stub implementation for the xbmc VideoInfoTag class getTVShowTitle() method """
|
||||
return ''
|
||||
|
||||
def getPlayCount(self):
|
||||
""" A stub implementation for the xbmc VideoInfoTag class getPlayCount() method """
|
||||
return 0
|
||||
|
||||
def getRating(self):
|
||||
""" A stub implementation for the xbmc VideoInfoTag class getRating() method """
|
||||
return 0
|
||||
|
||||
|
||||
def executebuiltin(string, wait=False): # pylint: disable=unused-argument
|
||||
""" A stub implementation of the xbmc executebuiltin() function """
|
||||
return
|
||||
|
||||
|
||||
def executeJSONRPC(jsonrpccommand):
|
||||
""" A reimplementation of the xbmc executeJSONRPC() function """
|
||||
command = json.loads(jsonrpccommand)
|
||||
if command.get('method') == 'Settings.GetSettingValue':
|
||||
key = command.get('params').get('setting')
|
||||
return json.dumps(dict(id=1, jsonrpc='2.0', result=dict(value=GLOBAL_SETTINGS.get(key))))
|
||||
if command.get('method') == 'Addons.GetAddonDetails':
|
||||
if command.get('params', {}).get('addonid') == 'script.module.inputstreamhelper':
|
||||
return json.dumps(dict(id=1, jsonrpc='2.0', result=dict(addon=dict(enabled='true', version='0.3.5'))))
|
||||
return json.dumps(dict(id=1, jsonrpc='2.0', result=dict(addon=dict(enabled='true', version='1.2.3'))))
|
||||
if command.get('method') == 'Textures.GetTextures':
|
||||
return json.dumps(dict(id=1, jsonrpc='2.0', result=dict(textures=[dict(cachedurl="", imagehash="", lasthashcheck="", textureid=4837, url="")])))
|
||||
if command.get('method') == 'Textures.RemoveTexture':
|
||||
return json.dumps(dict(id=1, jsonrpc='2.0', result="OK"))
|
||||
log("executeJSONRPC does not implement method '{method}'".format(**command), 'Error')
|
||||
return json.dumps(dict(error=dict(code=-1, message='Not implemented'), id=1, jsonrpc='2.0'))
|
||||
|
||||
|
||||
def getCondVisibility(string):
|
||||
""" A reimplementation of the xbmc getCondVisibility() function """
|
||||
if string == 'system.platform.android':
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def getInfoLabel(key):
|
||||
""" A reimplementation of the xbmc getInfoLabel() function """
|
||||
return INFO_LABELS.get(key)
|
||||
|
||||
|
||||
def getLocalizedString(msgctxt):
|
||||
""" A reimplementation of the xbmc getLocalizedString() function """
|
||||
for entry in PO:
|
||||
if entry.msgctxt == '#%s' % msgctxt:
|
||||
return entry.msgstr or entry.msgid
|
||||
if int(msgctxt) >= 30000:
|
||||
log('Unable to translate #{msgctxt}'.format(msgctxt=msgctxt), LOGERROR)
|
||||
return '<Untranslated>'
|
||||
|
||||
|
||||
def getRegion(key):
|
||||
""" A reimplementation of the xbmc getRegion() function """
|
||||
return REGIONS.get(key)
|
||||
|
||||
|
||||
def log(msg, level=LOGINFO):
|
||||
""" A reimplementation of the xbmc log() function """
|
||||
if level in (LOGERROR, LOGFATAL):
|
||||
print('\033[31;1m%s: \033[32;0m%s\033[0;39m' % (LOG_MAPPING.get(level), to_unicode(msg)))
|
||||
if level == LOGFATAL:
|
||||
raise Exception(msg)
|
||||
elif level in (LOGWARNING, LOGNOTICE):
|
||||
print('\033[33;1m%s: \033[32;0m%s\033[0;39m' % (LOG_MAPPING.get(level), to_unicode(msg)))
|
||||
else:
|
||||
print('\033[32;1m%s: \033[32;0m%s\033[0;39m' % (LOG_MAPPING.get(level), to_unicode(msg)))
|
||||
|
||||
|
||||
def setContent(self, content):
|
||||
""" A stub implementation of the xbmc setContent() function """
|
||||
return
|
||||
|
||||
|
||||
def sleep(seconds):
|
||||
""" A reimplementation of the xbmc sleep() function """
|
||||
time.sleep(seconds)
|
||||
|
||||
|
||||
def translatePath(path):
|
||||
""" A stub implementation of the xbmc translatePath() function """
|
||||
if path.startswith('special://home'):
|
||||
return path.replace('special://home', os.path.join(os.getcwd(), 'tests/'))
|
||||
if path.startswith('special://masterprofile'):
|
||||
return path.replace('special://masterprofile', os.path.join(os.getcwd(), 'tests/userdata/'))
|
||||
if path.startswith('special://profile'):
|
||||
return path.replace('special://profile', os.path.join(os.getcwd(), 'tests/userdata/'))
|
||||
if path.startswith('special://userdata'):
|
||||
return path.replace('special://userdata', os.path.join(os.getcwd(), 'tests/userdata/'))
|
||||
return path
|
76
tests/xbmcaddon.py
Normal file
@ -0,0 +1,76 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com>
|
||||
# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
"""This file implements the Kodi xbmcaddon module, either using stubs or alternative functionality"""
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
from xbmc import getLocalizedString
|
||||
from xbmcextra import ADDON_ID, ADDON_INFO, addon_settings
|
||||
|
||||
# Ensure the addon settings are retained (as we don't write to disk)
|
||||
ADDON_SETTINGS = addon_settings(ADDON_ID)
|
||||
|
||||
|
||||
class Addon:
|
||||
"""A reimplementation of the xbmcaddon Addon class"""
|
||||
|
||||
def __init__(self, id=ADDON_ID): # pylint: disable=redefined-builtin
|
||||
"""A stub constructor for the xbmcaddon Addon class"""
|
||||
self.id = id
|
||||
if id == ADDON_ID:
|
||||
self.settings = ADDON_SETTINGS
|
||||
else:
|
||||
self.settings = addon_settings(id)
|
||||
|
||||
def getAddonInfo(self, key):
|
||||
"""A working implementation for the xbmcaddon Addon class getAddonInfo() method"""
|
||||
stub_info = dict(id=self.id, name=self.id, version='2.3.4', type='kodi.inputstream', profile='special://userdata', path='special://userdata')
|
||||
# Add stub_info values to ADDON_INFO when missing (e.g. path and profile)
|
||||
addon_info = dict(stub_info, **ADDON_INFO)
|
||||
return addon_info.get(self.id, stub_info).get(key)
|
||||
|
||||
@staticmethod
|
||||
def getLocalizedString(msgctxt):
|
||||
"""A working implementation for the xbmcaddon Addon class getLocalizedString() method"""
|
||||
return getLocalizedString(msgctxt)
|
||||
|
||||
def getSetting(self, key):
|
||||
"""A working implementation for the xbmcaddon Addon class getSetting() method"""
|
||||
return self.settings.get(key, '')
|
||||
|
||||
def getSettingBool(self, key):
|
||||
"""A working implementation for the xbmcaddon Addon class getSettingBool() method"""
|
||||
return bool(self.settings.get(key, False))
|
||||
|
||||
def getSettingInt(self, key):
|
||||
"""A working implementation for the xbmcaddon Addon class getSettingInt() method"""
|
||||
return int(self.settings.get(key, 0))
|
||||
|
||||
def getSettingNumber(self, key):
|
||||
"""A working implementation for the xbmcaddon Addon class getSettingNumber() method"""
|
||||
return float(self.settings.get(key, 0.0))
|
||||
|
||||
@staticmethod
|
||||
def openSettings():
|
||||
"""A stub implementation for the xbmcaddon Addon class openSettings() method"""
|
||||
|
||||
def setSetting(self, key, value):
|
||||
"""A stub implementation for the xbmcaddon Addon class setSetting() method"""
|
||||
self.settings[key] = value
|
||||
# NOTE: Disable actual writing as it is no longer needed for testing
|
||||
# with open('tests/userdata/addon_settings.json', 'w') as fd:
|
||||
# json.dump(filtered_settings, fd, sort_keys=True, indent=4)
|
||||
|
||||
def setSettingBool(self, key, value):
|
||||
"""A stub implementation for the xbmcaddon Addon class setSettingBool() method"""
|
||||
self.settings[key] = value
|
||||
|
||||
def setSettingInt(self, key, value):
|
||||
"""A stub implementation for the xbmcaddon Addon class setSettingInt() method"""
|
||||
self.settings[key] = value
|
||||
|
||||
def setSettingNumber(self, key, value):
|
||||
"""A stub implementation for the xbmcaddon Addon class setSettingNumber() method"""
|
||||
self.settings[key] = value
|
188
tests/xbmcextra.py
Normal file
@ -0,0 +1,188 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com>
|
||||
# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
"""Extra functions for testing"""
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
import polib
|
||||
|
||||
|
||||
def kodi_to_ansi(string):
|
||||
"""Convert Kodi format tags to ANSI codes"""
|
||||
if string is None:
|
||||
return None
|
||||
string = string.replace('[B]', '\033[1m')
|
||||
string = string.replace('[/B]', '\033[21m')
|
||||
string = string.replace('[I]', '\033[3m')
|
||||
string = string.replace('[/I]', '\033[23m')
|
||||
string = string.replace('[COLOR gray]', '\033[30;1m')
|
||||
string = string.replace('[COLOR red]', '\033[31m')
|
||||
string = string.replace('[COLOR green]', '\033[32m')
|
||||
string = string.replace('[COLOR yellow]', '\033[33m')
|
||||
string = string.replace('[COLOR blue]', '\033[34m')
|
||||
string = string.replace('[COLOR purple]', '\033[35m')
|
||||
string = string.replace('[COLOR cyan]', '\033[36m')
|
||||
string = string.replace('[COLOR white]', '\033[37m')
|
||||
string = string.replace('[/COLOR]', '\033[39;0m')
|
||||
return string
|
||||
|
||||
|
||||
def uri_to_path(uri):
|
||||
"""Shorten a plugin URI to just the path"""
|
||||
if uri is None:
|
||||
return None
|
||||
return ' \033[33m→ \033[34m%s\033[39;0m' % uri.replace('plugin://' + ADDON_ID, '')
|
||||
|
||||
|
||||
def read_addon_xml(path):
|
||||
"""Parse the addon.xml and return an info dictionary"""
|
||||
info = dict(
|
||||
path='./', # '/storage/.kodi/addons/plugin.video.vrt.nu',
|
||||
profile='special://userdata', # 'special://profile/addon_data/plugin.video.vrt.nu/',
|
||||
type='xbmc.python.pluginsource',
|
||||
)
|
||||
|
||||
tree = ET.parse(path)
|
||||
root = tree.getroot()
|
||||
|
||||
info.update(root.attrib) # Add 'id', 'name' and 'version'
|
||||
info['author'] = info.pop('provider-name')
|
||||
|
||||
for child in root:
|
||||
if child.attrib.get('point') != 'xbmc.addon.metadata':
|
||||
continue
|
||||
for grandchild in child:
|
||||
# Handle assets differently
|
||||
if grandchild.tag == 'assets':
|
||||
for asset in grandchild:
|
||||
info[asset.tag] = asset.text
|
||||
continue
|
||||
# Not in English ? Drop it
|
||||
if grandchild.attrib.get('lang', 'en_GB') != 'en_GB':
|
||||
continue
|
||||
# Add metadata
|
||||
info[grandchild.tag] = grandchild.text
|
||||
|
||||
return {info['name']: info}
|
||||
|
||||
|
||||
def global_settings():
|
||||
"""Use the global_settings file"""
|
||||
import json
|
||||
try:
|
||||
with open('tests/userdata/global_settings.json') as f:
|
||||
settings = json.load(f)
|
||||
except OSError as e:
|
||||
print("Error: Cannot use 'tests/userdata/global_settings.json' : %s" % e)
|
||||
settings = {
|
||||
'locale.language': 'resource.language.en_gb',
|
||||
'network.bandwidth': 0,
|
||||
}
|
||||
|
||||
if 'PROXY_SERVER' in os.environ:
|
||||
settings['network.usehttpproxy'] = True
|
||||
settings['network.httpproxytype'] = 0
|
||||
print('Using proxy server from environment variable PROXY_SERVER')
|
||||
settings['network.httpproxyserver'] = os.environ.get('PROXY_SERVER')
|
||||
if 'PROXY_PORT' in os.environ:
|
||||
print('Using proxy server from environment variable PROXY_PORT')
|
||||
settings['network.httpproxyport'] = os.environ.get('PROXY_PORT')
|
||||
if 'PROXY_USERNAME' in os.environ:
|
||||
print('Using proxy server from environment variable PROXY_USERNAME')
|
||||
settings['network.httpproxyusername'] = os.environ.get('PROXY_USERNAME')
|
||||
if 'PROXY_PASSWORD' in os.environ:
|
||||
print('Using proxy server from environment variable PROXY_PASSWORD')
|
||||
settings['network.httpproxypassword'] = os.environ.get('PROXY_PASSWORD')
|
||||
return settings
|
||||
|
||||
|
||||
def addon_settings(addon_id=None):
|
||||
"""Use the addon_settings file"""
|
||||
import json
|
||||
try:
|
||||
with open('tests/userdata/addon_settings.json') as f:
|
||||
settings = json.load(f)
|
||||
except OSError as e:
|
||||
print("Error: Cannot use 'tests/userdata/addon_settings.json' : %s" % e)
|
||||
settings = {}
|
||||
|
||||
# Read credentials from environment or credentials.json
|
||||
if 'ADDON_USERNAME' in os.environ and 'ADDON_PASSWORD' in os.environ:
|
||||
# print('Using credentials from the environment variables ADDON_USERNAME and ADDON_PASSWORD')
|
||||
settings[ADDON_ID]['username'] = os.environ.get('ADDON_USERNAME')
|
||||
settings[ADDON_ID]['password'] = os.environ.get('ADDON_PASSWORD')
|
||||
elif os.path.exists('tests/userdata/credentials.json'):
|
||||
# print('Using credentials from tests/userdata/credentials.json')
|
||||
with open('tests/userdata/credentials.json') as f:
|
||||
credentials = json.load(f)
|
||||
settings[ADDON_ID].update(credentials)
|
||||
else:
|
||||
print("Error: Cannot use 'tests/userdata/credentials.json'")
|
||||
|
||||
if addon_id:
|
||||
return settings[addon_id]
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
def import_language(language):
|
||||
"""Process the language.po file"""
|
||||
try:
|
||||
podb = polib.pofile('resources/language/{language}/strings.po'.format(language=language))
|
||||
except IOError:
|
||||
podb = polib.pofile('resources/language/resource.language.en_gb/strings.po')
|
||||
|
||||
podb.extend([
|
||||
# WEEKDAY_LONG
|
||||
polib.POEntry(msgctxt='#11', msgstr='Monday'),
|
||||
polib.POEntry(msgctxt='#12', msgstr='Tuesday'),
|
||||
polib.POEntry(msgctxt='#13', msgstr='Wednesday'),
|
||||
polib.POEntry(msgctxt='#14', msgstr='Thursday'),
|
||||
polib.POEntry(msgctxt='#15', msgstr='Friday'),
|
||||
polib.POEntry(msgctxt='#16', msgstr='Saturday'),
|
||||
polib.POEntry(msgctxt='#17', msgstr='Sunday'),
|
||||
# MONTH_LONG
|
||||
polib.POEntry(msgctxt='#21', msgstr='January'),
|
||||
polib.POEntry(msgctxt='#22', msgstr='February'),
|
||||
polib.POEntry(msgctxt='#23', msgstr='March'),
|
||||
polib.POEntry(msgctxt='#24', msgstr='April'),
|
||||
polib.POEntry(msgctxt='#25', msgstr='May'),
|
||||
polib.POEntry(msgctxt='#26', msgstr='June'),
|
||||
polib.POEntry(msgctxt='#27', msgstr='July'),
|
||||
polib.POEntry(msgctxt='#28', msgstr='August'),
|
||||
polib.POEntry(msgctxt='#29', msgstr='September'),
|
||||
polib.POEntry(msgctxt='#30', msgstr='October'),
|
||||
polib.POEntry(msgctxt='#31', msgstr='November'),
|
||||
polib.POEntry(msgctxt='#32', msgstr='December'),
|
||||
# WEEKDAY_SHORT
|
||||
polib.POEntry(msgctxt='#41', msgstr='Mon'),
|
||||
polib.POEntry(msgctxt='#42', msgstr='Tue'),
|
||||
polib.POEntry(msgctxt='#43', msgstr='Wed'),
|
||||
polib.POEntry(msgctxt='#44', msgstr='Thu'),
|
||||
polib.POEntry(msgctxt='#45', msgstr='Fri'),
|
||||
polib.POEntry(msgctxt='#46', msgstr='Sat'),
|
||||
polib.POEntry(msgctxt='#47', msgstr='Sun'),
|
||||
# MONTH_LONG
|
||||
polib.POEntry(msgctxt='#51', msgstr='Jan'),
|
||||
polib.POEntry(msgctxt='#52', msgstr='Feb'),
|
||||
polib.POEntry(msgctxt='#53', msgstr='Mar'),
|
||||
polib.POEntry(msgctxt='#54', msgstr='Apr'),
|
||||
polib.POEntry(msgctxt='#55', msgstr='May'),
|
||||
polib.POEntry(msgctxt='#56', msgstr='Jun'),
|
||||
polib.POEntry(msgctxt='#57', msgstr='Jul'),
|
||||
polib.POEntry(msgctxt='#58', msgstr='Aug'),
|
||||
polib.POEntry(msgctxt='#59', msgstr='Sep'),
|
||||
polib.POEntry(msgctxt='#50', msgstr='Oct'),
|
||||
polib.POEntry(msgctxt='#51', msgstr='Nov'),
|
||||
polib.POEntry(msgctxt='#52', msgstr='Dec'),
|
||||
])
|
||||
|
||||
return podb
|
||||
|
||||
|
||||
ADDON_INFO = read_addon_xml('addon.xml')
|
||||
ADDON_ID = next(iter(list(ADDON_INFO.values()))).get('id')
|
327
tests/xbmcgui.py
Normal file
@ -0,0 +1,327 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com>
|
||||
# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
"""This file implements the Kodi xbmcgui module, either using stubs or alternative functionality"""
|
||||
|
||||
# pylint: disable=invalid-name,super-on-old-class,too-many-arguments,unused-argument,useless-super-delegation
|
||||
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
import sys
|
||||
from xbmcextra import kodi_to_ansi
|
||||
|
||||
|
||||
class Control:
|
||||
"""A reimplementation of the xbmcgui Control class"""
|
||||
|
||||
def __init__(self):
|
||||
"""A stub constructor for the xbmcgui Control class"""
|
||||
|
||||
@staticmethod
|
||||
def selectItem(index):
|
||||
"""A stub implementation for the xbmcgui Control class selectItem() method"""
|
||||
return
|
||||
|
||||
|
||||
class ControlLabel(Control):
|
||||
"""A reimplementation of the xbmcgui ControlLabel class"""
|
||||
|
||||
def __init__(self): # pylint: disable=super-init-not-called
|
||||
"""A stub constructor for the xbmcgui ControlLabel class"""
|
||||
|
||||
@staticmethod
|
||||
def getLabel():
|
||||
"""A stub implementation for the xbmcgui ControlLabel class getLabel() method"""
|
||||
return 'Label'
|
||||
|
||||
@staticmethod
|
||||
def setLabel(label='', font=None, textColor=None, disabledColor=None, shadowColor=None, focusedColor=None, label2=''):
|
||||
"""A stub implementation for the xbmcgui ControlLabel class getLabel() method"""
|
||||
|
||||
|
||||
class Dialog:
|
||||
"""A reimplementation of the xbmcgui Dialog class"""
|
||||
|
||||
def __init__(self):
|
||||
"""A stub constructor for the xbmcgui Dialog class"""
|
||||
|
||||
@staticmethod
|
||||
def notification(heading, message, icon=None, time=None, sound=None):
|
||||
"""A working implementation for the xbmcgui Dialog class notification() method"""
|
||||
heading = kodi_to_ansi(heading)
|
||||
message = kodi_to_ansi(message)
|
||||
print('\033[37;44;1mNOTIFICATION:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, message))
|
||||
|
||||
@staticmethod
|
||||
def ok(heading, message='', line1='', line2='', line3=''):
|
||||
"""A stub implementation for the xbmcgui Dialog class ok() method"""
|
||||
heading = kodi_to_ansi(heading)
|
||||
message = kodi_to_ansi(message)
|
||||
print('\033[37;44;1mOK:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, message or line1))
|
||||
|
||||
@staticmethod
|
||||
def info(listitem):
|
||||
"""A stub implementation for the xbmcgui Dialog class info() method"""
|
||||
|
||||
@staticmethod
|
||||
def select(heading, opt_list, autoclose=0, preselect=None, useDetails=False):
|
||||
"""A stub implementation for the xbmcgui Dialog class select() method"""
|
||||
if preselect is None:
|
||||
preselect = []
|
||||
heading = kodi_to_ansi(heading)
|
||||
print('\033[37;44;1mSELECT:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, ', '.join(opt_list)))
|
||||
return -1
|
||||
|
||||
@staticmethod
|
||||
def multiselect(heading, options, autoclose=0, preselect=None, useDetails=False): # pylint: disable=useless-return
|
||||
"""A stub implementation for the xbmcgui Dialog class multiselect() method"""
|
||||
if preselect is None:
|
||||
preselect = []
|
||||
heading = kodi_to_ansi(heading)
|
||||
print('\033[37;44;1mMULTISELECT:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, ', '.join(options)))
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def contextmenu(items):
|
||||
"""A stub implementation for the xbmcgui Dialog class contextmenu() method"""
|
||||
print('\033[37;44;1mCONTEXTMENU:\033[35;49;1m \033[37;1m%s\033[39;0m' % (', '.join(items)))
|
||||
return -1
|
||||
|
||||
@staticmethod
|
||||
def yesno(heading, message='', line1='', line2='', line3='', nolabel=None, yeslabel=None, autoclose=0):
|
||||
"""A stub implementation for the xbmcgui Dialog class yesno() method"""
|
||||
heading = kodi_to_ansi(heading)
|
||||
message = kodi_to_ansi(message)
|
||||
print('\033[37;44;1mYESNO:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, message or line1))
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def textviewer(heading, text=None, usemono=None):
|
||||
"""A stub implementation for the xbmcgui Dialog class textviewer() method"""
|
||||
heading = kodi_to_ansi(heading)
|
||||
text = kodi_to_ansi(text)
|
||||
print('\033[37;44;1mTEXTVIEWER:\033[35;49;1m [%s]\n\033[37;1m%s\033[39;0m' % (heading, text))
|
||||
|
||||
@staticmethod
|
||||
def browseSingle(type, heading, shares, mask=None, useThumbs=None, treatAsFolder=None, defaultt=None): # pylint: disable=redefined-builtin
|
||||
"""A stub implementation for the xbmcgui Dialog class browseSingle() method"""
|
||||
print('\033[37;44;1mBROWSESINGLE:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (type, heading))
|
||||
return 'special://masterprofile/addon_data/script.module.inputstreamhelper/'
|
||||
|
||||
|
||||
class DialogProgress:
|
||||
"""A reimplementation of the xbmcgui DialogProgress"""
|
||||
|
||||
def __init__(self):
|
||||
"""A stub constructor for the xbmcgui DialogProgress class"""
|
||||
self.percent = 0
|
||||
|
||||
def close(self):
|
||||
"""A stub implementation for the xbmcgui DialogProgress class close() method"""
|
||||
self.percent = 0
|
||||
print()
|
||||
sys.stdout.flush()
|
||||
|
||||
def create(self, heading, message='', line1='', line2='', line3=''):
|
||||
"""A stub implementation for the xbmcgui DialogProgress class create() method"""
|
||||
self.percent = 0
|
||||
heading = kodi_to_ansi(heading)
|
||||
line1 = kodi_to_ansi(line1)
|
||||
print('\033[37;44;1mPROGRESS:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, message or line1))
|
||||
sys.stdout.flush()
|
||||
|
||||
def iscanceled(self):
|
||||
"""A stub implementation for the xbmcgui DialogProgress class iscanceled() method"""
|
||||
return self.percent > 5 # Cancel at 5%
|
||||
|
||||
def update(self, percent, message='', line1='', line2='', line3=''):
|
||||
"""A stub implementation for the xbmcgui DialogProgress class update() method"""
|
||||
if (percent - 5) < self.percent:
|
||||
return
|
||||
self.percent = percent
|
||||
line1 = kodi_to_ansi(line1)
|
||||
line2 = kodi_to_ansi(line2)
|
||||
line3 = kodi_to_ansi(line3)
|
||||
if line1 or line2 or line3:
|
||||
print('\033[1G\033[37;44;1mPROGRESS:\033[35;49;1m [%d%%] \033[37;1m%s\033[39;0m' % (percent, message or line1 or line2 or line3), end='')
|
||||
else:
|
||||
print('\033[1G\033[37;44;1mPROGRESS:\033[35;49;1m [%d%%]\033[39;0m' % (percent), end='')
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
class DialogProgressBG:
|
||||
"""A reimplementation of the xbmcgui DialogProgressBG"""
|
||||
|
||||
def __init__(self):
|
||||
"""A stub constructor for the xbmcgui DialogProgressBG class"""
|
||||
self.percent = 0
|
||||
|
||||
@staticmethod
|
||||
def close():
|
||||
"""A stub implementation for the xbmcgui DialogProgressBG class close() method"""
|
||||
print()
|
||||
|
||||
@staticmethod
|
||||
def create(heading, message):
|
||||
"""A stub implementation for the xbmcgui DialogProgressBG class create() method"""
|
||||
heading = kodi_to_ansi(heading)
|
||||
message = kodi_to_ansi(message)
|
||||
print('\033[37;44;1mPROGRESS:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, message))
|
||||
|
||||
@staticmethod
|
||||
def isfinished():
|
||||
"""A stub implementation for the xbmcgui DialogProgressBG class isfinished() method"""
|
||||
|
||||
def update(self, percent, heading=None, message=None):
|
||||
"""A stub implementation for the xbmcgui DialogProgressBG class update() method"""
|
||||
if (percent - 5) < self.percent:
|
||||
return
|
||||
self.percent = percent
|
||||
message = kodi_to_ansi(message)
|
||||
if message:
|
||||
print('\033[37;44;1mPROGRESS:\033[35;49;1m [%d%%] \033[37;1m%s\033[39;0m' % (percent, message))
|
||||
else:
|
||||
print('\033[1G\033[37;44;1mPROGRESS:\033[35;49;1m [%d%%]\033[39;0m' % (percent), end='')
|
||||
|
||||
|
||||
class DialogBusy:
|
||||
"""A reimplementation of the xbmcgui DialogBusy"""
|
||||
|
||||
def __init__(self):
|
||||
"""A stub constructor for the xbmcgui DialogBusy class"""
|
||||
|
||||
@staticmethod
|
||||
def close():
|
||||
"""A stub implementation for the xbmcgui DialogBusy class close() method"""
|
||||
|
||||
@staticmethod
|
||||
def create():
|
||||
"""A stub implementation for the xbmcgui DialogBusy class create() method"""
|
||||
|
||||
|
||||
class ListItem:
|
||||
"""A reimplementation of the xbmcgui ListItem class"""
|
||||
|
||||
def __init__(self, label='', label2='', iconImage='', thumbnailImage='', path='', offscreen=False):
|
||||
"""A stub constructor for the xbmcgui ListItem class"""
|
||||
self.label = kodi_to_ansi(label)
|
||||
self.label2 = kodi_to_ansi(label2)
|
||||
self.path = path
|
||||
|
||||
@staticmethod
|
||||
def addContextMenuItems(items, replaceItems=False):
|
||||
"""A stub implementation for the xbmcgui ListItem class addContextMenuItems() method"""
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def addStreamInfo(stream_type, stream_values):
|
||||
"""A stub implementation for the xbmcgui LitItem class addStreamInfo() method"""
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def setArt(key):
|
||||
"""A stub implementation for the xbmcgui ListItem class setArt() method"""
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def setContentLookup(enable):
|
||||
"""A stub implementation for the xbmcgui ListItem class setContentLookup() method"""
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def setInfo(type, infoLabels): # pylint: disable=redefined-builtin
|
||||
"""A stub implementation for the xbmcgui ListItem class setInfo() method"""
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def setIsFolder(isFolder):
|
||||
"""A stub implementation for the xbmcgui ListItem class setIsFolder() method"""
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def setMimeType(mimetype):
|
||||
"""A stub implementation for the xbmcgui ListItem class setMimeType() method"""
|
||||
return
|
||||
|
||||
def setPath(self, path):
|
||||
"""A stub implementation for the xbmcgui ListItem class setPath() method"""
|
||||
self.path = path
|
||||
|
||||
@staticmethod
|
||||
def setProperty(key, value):
|
||||
"""A stub implementation for the xbmcgui ListItem class setProperty() method"""
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def setProperties(dictionary):
|
||||
"""A stub implementation for the xbmcgui ListItem class setProperties() method"""
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def setSubtitles(subtitleFiles):
|
||||
"""A stub implementation for the xbmcgui ListItem class setSubtitles() method"""
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def setUniqueIDs(values, defaultrating=None):
|
||||
"""A stub implementation for the xbmcgui ListItem class setUniqueIDs() method"""
|
||||
return
|
||||
|
||||
|
||||
class Window:
|
||||
"""A reimplementation of the xbmcgui Window"""
|
||||
|
||||
def __init__(self, windowId):
|
||||
"""A stub constructor for the xbmcgui Window class"""
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
"""A stub implementation for the xbmcgui Window class close() method"""
|
||||
|
||||
@staticmethod
|
||||
def getControl():
|
||||
"""A stub implementation for the xbmcgui Window class getControl() method"""
|
||||
return ControlLabel()
|
||||
|
||||
@staticmethod
|
||||
def getFocusId():
|
||||
"""A stub implementation for the xbmcgui Window class getFocusId() method"""
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def getProperty(key):
|
||||
"""A stub implementation for the xbmcgui Window class getProperty() method"""
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def setProperty(key, value):
|
||||
"""A stub implementation for the xbmcgui Window class setProperty() method"""
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def clearProperty(key):
|
||||
"""A stub implementation for the xbmcgui Window class clearProperty() method"""
|
||||
return
|
||||
|
||||
def show(self):
|
||||
"""A stub implementation for the xbmcgui Window class show() method"""
|
||||
|
||||
|
||||
class WindowXML(Window):
|
||||
"""A reimplementation of the xbmcgui WindowXML"""
|
||||
|
||||
def __init__(self):
|
||||
"""A stub constructor for the xbmcgui WindowXML class"""
|
||||
super(WindowXML, self).__init__()
|
||||
|
||||
|
||||
class WindowXMLDialog(WindowXML):
|
||||
"""A reimplementation of the xbmcgui WindowXMLDialog"""
|
||||
|
||||
def __init__(self):
|
||||
"""A stub constructor for the xbmcgui WindowXMLDialog class"""
|
||||
super(WindowXMLDialog, self).__init__()
|
||||
|
||||
|
||||
def getCurrentWindowId():
|
||||
"""A stub implementation of the xbmcgui getCurrentWindowId() method"""
|
||||
return 0
|
112
tests/xbmcplugin.py
Normal file
@ -0,0 +1,112 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com>
|
||||
# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
"""This file implements the Kodi xbmcplugin module, either using stubs or alternative functionality"""
|
||||
|
||||
# pylint: disable=invalid-name,unused-argument
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
from xbmc import LOGFATAL, LOGINFO, log
|
||||
from xbmcextra import kodi_to_ansi, uri_to_path
|
||||
|
||||
try: # Python 3
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
except ImportError: # Python 2
|
||||
from urllib2 import HTTPError, Request, urlopen
|
||||
|
||||
SORT_METHOD_NONE = 0
|
||||
SORT_METHOD_LABEL = 1
|
||||
SORT_METHOD_LABEL_IGNORE_THE = 2
|
||||
SORT_METHOD_DATE = 3
|
||||
SORT_METHOD_SIZE = 4
|
||||
SORT_METHOD_FILE = 5
|
||||
SORT_METHOD_DRIVE_TYPE = 6
|
||||
SORT_METHOD_TRACKNUM = 7
|
||||
SORT_METHOD_DURATION = 8
|
||||
SORT_METHOD_TITLE = 9
|
||||
SORT_METHOD_TITLE_IGNORE_THE = 10
|
||||
SORT_METHOD_ARTIST = 11
|
||||
SORT_METHOD_ARTIST_AND_YEAR = 12
|
||||
SORT_METHOD_ARTIST_IGNORE_THE = 13
|
||||
SORT_METHOD_ALBUM = 14
|
||||
SORT_METHOD_ALBUM_IGNORE_THE = 15
|
||||
SORT_METHOD_GENRE = 16
|
||||
SORT_METHOD_COUNTRY = 17
|
||||
SORT_METHOD_VIDEO_YEAR = 18 # This is SORT_METHOD_YEAR in Kodi
|
||||
SORT_METHOD_VIDEO_RATING = 19
|
||||
SORT_METHOD_VIDEO_USER_RATING = 20
|
||||
SORT_METHOD_DATEADDED = 21
|
||||
SORT_METHOD_PROGRAM_COUNT = 22
|
||||
SORT_METHOD_PLAYLIST_ORDER = 23
|
||||
SORT_METHOD_EPISODE = 24
|
||||
SORT_METHOD_VIDEO_TITLE = 25
|
||||
SORT_METHOD_VIDEO_SORT_TITLE = 26
|
||||
SORT_METHOD_VIDEO_SORT_TITLE_IGNORE_THE = 27
|
||||
SORT_METHOD_PRODUCTIONCODE = 28
|
||||
SORT_METHOD_SONG_RATING = 29
|
||||
SORT_METHOD_SONG_USER_RATING = 30
|
||||
SORT_METHOD_MPAA_RATING = 31
|
||||
SORT_METHOD_VIDEO_RUNTIME = 32
|
||||
SORT_METHOD_STUDIO = 33
|
||||
SORT_METHOD_STUDIO_IGNORE_THE = 34
|
||||
SORT_METHOD_FULLPATH = 35
|
||||
SORT_METHOD_LABEL_IGNORE_FOLDERS = 36
|
||||
SORT_METHOD_LASTPLAYED = 37
|
||||
SORT_METHOD_PLAYCOUNT = 38
|
||||
SORT_METHOD_LISTENERS = 39
|
||||
SORT_METHOD_UNSORTED = 40
|
||||
SORT_METHOD_CHANNEL = 41
|
||||
SORT_METHOD_CHANNEL_NUMBER = 42
|
||||
SORT_METHOD_BITRATE = 43
|
||||
SORT_METHOD_DATE_TAKEN = 44
|
||||
|
||||
|
||||
def addDirectoryItem(handle, path, listitem, isFolder=False):
|
||||
"""A reimplementation of the xbmcplugin addDirectoryItems() function"""
|
||||
label = kodi_to_ansi(listitem.label)
|
||||
path = uri_to_path(path) if path else ''
|
||||
# perma = kodi_to_ansi(listitem.label) # FIXME: Add permalink
|
||||
bullet = '»' if isFolder else '·'
|
||||
print('{bullet} {label}{path}'.format(bullet=bullet, label=label, path=path))
|
||||
return True
|
||||
|
||||
|
||||
def addDirectoryItems(handle, listing, length):
|
||||
"""A reimplementation of the xbmcplugin addDirectoryItems() function"""
|
||||
for item in listing:
|
||||
addDirectoryItem(handle, item[0], item[1], item[2])
|
||||
return True
|
||||
|
||||
|
||||
def addSortMethod(handle, sortMethod):
|
||||
"""A stub implementation of the xbmcplugin addSortMethod() function"""
|
||||
|
||||
|
||||
def endOfDirectory(handle, succeeded=True, updateListing=True, cacheToDisc=True):
|
||||
"""A stub implementation of the xbmcplugin endOfDirectory() function"""
|
||||
# print(kodi_to_ansi('[B]-=( [COLOR cyan]--------[/COLOR] )=-[/B]'))
|
||||
|
||||
|
||||
def setContent(handle, content):
|
||||
"""A stub implementation of the xbmcplugin setContent() function"""
|
||||
|
||||
|
||||
def setPluginFanart(handle, image, color1=None, color2=None, color3=None):
|
||||
"""A stub implementation of the xbmcplugin setPluginFanart() function"""
|
||||
|
||||
|
||||
def setPluginCategory(handle, category):
|
||||
"""A reimplementation of the xbmcplugin setPluginCategory() function"""
|
||||
print(kodi_to_ansi('[B]-=( [COLOR cyan]%s[/COLOR] )=-[/B]' % category))
|
||||
|
||||
|
||||
def setResolvedUrl(handle, succeeded, listitem):
|
||||
"""A stub implementation of the xbmcplugin setResolvedUrl() function"""
|
||||
request = Request(listitem.path)
|
||||
request.get_method = lambda: 'HEAD'
|
||||
try:
|
||||
response = urlopen(request)
|
||||
log('Stream playing successfully: %s' % response.code, LOGINFO)
|
||||
except HTTPError as exc:
|
||||
log('Playing stream returned: %s' % exc, LOGFATAL)
|
80
tests/xbmcvfs.py
Normal file
@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com>
|
||||
# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
"""This file implements the Kodi xbmcvfs module, either using stubs or alternative functionality"""
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
import os
|
||||
from shutil import copyfile
|
||||
|
||||
|
||||
def File(path, flags='r'):
|
||||
"""A reimplementation of the xbmcvfs File() function"""
|
||||
return open(path, flags)
|
||||
|
||||
|
||||
def Stat(path):
|
||||
"""A reimplementation of the xbmcvfs Stat() function"""
|
||||
|
||||
class stat:
|
||||
"""A reimplementation of the xbmcvfs stat class"""
|
||||
|
||||
def __init__(self, path):
|
||||
"""The constructor xbmcvfs stat class"""
|
||||
self._stat = os.stat(path)
|
||||
|
||||
def st_mtime(self):
|
||||
"""The xbmcvfs stat class st_mtime method"""
|
||||
return self._stat.st_mtime
|
||||
|
||||
return stat(path)
|
||||
|
||||
|
||||
def copy(src, dst):
|
||||
"""A reimplementation of the xbmcvfs mkdir() function"""
|
||||
return copyfile(src, dst) == dst
|
||||
|
||||
|
||||
def delete(path):
|
||||
"""A reimplementation of the xbmcvfs delete() function"""
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def exists(path):
|
||||
"""A reimplementation of the xbmcvfs exists() function"""
|
||||
return os.path.exists(path)
|
||||
|
||||
|
||||
def listdir(path):
|
||||
"""A reimplementation of the xbmcvfs listdir() function"""
|
||||
files = []
|
||||
dirs = []
|
||||
if not exists(path):
|
||||
return dirs, files
|
||||
for filename in os.listdir(path):
|
||||
fullname = os.path.join(path, filename)
|
||||
if os.path.isfile(fullname):
|
||||
files.append(filename)
|
||||
if os.path.isdir(fullname):
|
||||
dirs.append(filename)
|
||||
return dirs, files
|
||||
|
||||
|
||||
def mkdir(path):
|
||||
"""A reimplementation of the xbmcvfs mkdir() function"""
|
||||
return os.mkdir(path)
|
||||
|
||||
|
||||
def mkdirs(path):
|
||||
"""A reimplementation of the xbmcvfs mkdirs() function"""
|
||||
return os.makedirs(path)
|
||||
|
||||
|
||||
def rmdir(path):
|
||||
"""A reimplementation of the xbmcvfs rmdir() function"""
|
||||
return os.rmdir(path)
|
27
tox.ini
Normal file
@ -0,0 +1,27 @@
|
||||
[tox]
|
||||
envlist = py27,py36,py37,py38,flake8
|
||||
skipsdist = True
|
||||
skip_missing_interpreters = True
|
||||
|
||||
[testenv:flake8]
|
||||
commands =
|
||||
- {envbindir}/flake8
|
||||
deps =
|
||||
flake8
|
||||
flake8-coding
|
||||
flake8-future-import
|
||||
|
||||
[flake8]
|
||||
builtins = func
|
||||
max-line-length = 160
|
||||
ignore = FI13,FI50,FI51,FI53,FI54,W503
|
||||
require-code = True
|
||||
min-version = 2.7
|
||||
exclude = .git,.tox
|
||||
|
||||
[pytest]
|
||||
filterwarnings = default
|
||||
|
||||
[pycodestyle]
|
||||
max-line-length = 160
|
||||
|