Compare commits

..

1 Commits

Author SHA1 Message Date
Michaël Arnauts
8529c3b403 Playback from cache 2020-04-13 10:35:57 +02:00
88 changed files with 2186 additions and 3171 deletions

View File

@ -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
View File

@ -1,4 +1,3 @@
.env.example export-ignore
.github/ export-ignore .github/ export-ignore
tests/ export-ignore tests/ export-ignore
.gitattributes export-ignore .gitattributes export-ignore
@ -6,4 +5,4 @@ tests/ export-ignore
.pylintrc export-ignore .pylintrc export-ignore
Makefile export-ignore Makefile export-ignore
requirements.txt export-ignore requirements.txt export-ignore
scripts/ export-ignore tox.ini export-ignore

2
.github/FUNDING.yml vendored
View File

@ -1,2 +0,0 @@
github: michaelarnauts
custom: https://www.buymeacoffee.com/michaelarnauts

View File

@ -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.

View File

@ -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.

View File

@ -1,22 +1,41 @@
name: Kodi name: Kodi
on: on:
# Run action when pushed to master, or for commits in a pull request.
push: push:
branches: branches:
- master - master
pull_request: pull_request:
branches: branches:
- master - master
jobs: jobs:
kodi-addon-checker: tests:
name: Addon checker name: Kodi Add-on checker
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
kodi-branch: [leia, matrix]
steps: steps:
- name: Check out ${{ github.sha }} from repository ${{ github.repository }} - name: Check out ${{ github.sha }} from repository ${{ github.repository }}
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Run kodi-addon-checker
uses: xbmc/action-kodi-addon-checker@v1.2
with: with:
kodi-version: matrix path: ${{ github.repository }}
addon-id: ${{ github.event.repository.name }} - 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 }}/

View File

@ -1,65 +1,58 @@
name: CI name: CI
on: on:
# Run action when pushed to master, or for commits in a pull request.
push: push:
branches: branches:
- master - master
pull_request: pull_request:
branches: branches:
- master - master
jobs: jobs:
tests: tests:
name: Add-on testing name: Unit tests
runs-on: ${{ matrix.os }} runs-on: ubuntu-latest
env:
PYTHONIOENCODING: utf-8
PYTHONPATH: ${{ github.workspace }}/resources/lib:${{ github.workspace }}/tests
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: [ ubuntu-latest, windows-latest ] python-version: [2.7, 3.5, 3.6, 3.7, 3.8]
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"
steps: steps:
- name: Check out ${{ github.sha }} from repository ${{ github.repository }} - name: Check out ${{ github.sha }} from repository ${{ github.repository }}
uses: actions/checkout@v3 uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4 uses: actions/setup-python@v1
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install dependencies - 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 - name: Run pylint
run: make check-pylint run: |
make check-pylint
- name: Run tox
run: |
make check-tox
- name: Check translations - name: Check translations
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9' run: |
run: sudo apt-get install gettext && make check-translations make check-translations
- name: Run unit tests - name: Run unit tests
env: env:
ADDON_USERNAME: ${{ secrets.ADDON_USERNAME }} ADDON_USERNAME: ${{ secrets.ADDON_USERNAME }}
ADDON_PASSWORD: ${{ secrets.ADDON_PASSWORD }} ADDON_PASSWORD: ${{ secrets.ADDON_PASSWORD }}
PYTHONIOENCODING: utf-8 run: |
KODI_HOME: ${{ github.workspace }}/tests/home coverage run -m unittest discover
KODI_INTERACTIVE: 0 - name: Run addon
KODI_STUB_RPC_RESPONSES: ${{ github.workspace }}/tests/rpc run: |
HTTP_PROXY: ${{ secrets.HTTP_PROXY }} coverage run -a tests/run.py /
run: pytest -x -v --cov=./ --cov-report=xml tests - name: Run add-on service
run: |
coverage run -a service_entry.py
- name: Upload code coverage to CodeCov - name: Upload code coverage to CodeCov
uses: codecov/codecov-action@v3 uses: codecov/codecov-action@v1
continue-on-error: true continue-on-error: true
env:
OS: ${{ matrix.os }}
PYTHON: ${{ matrix.python-version }}
with:
flags: unittests
env_vars: OS,PYTHON

View File

@ -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
View File

@ -9,14 +9,13 @@
Thumbs.db Thumbs.db
*~ *~
.cache .cache
.coverage .coverage
.tox/ .tox/
tests/userdata/credentials.json
# Testing tests/userdata/temp
tests/home/userdata/addon_data tests/userdata/token.json
.env tests/userdata/cache
tests/userdata/addon_data
Pipfile tests/userdata/tokens
Pipfile.lock tests/cdm
dist/

View File

@ -1,12 +1,11 @@
[MESSAGES CONTROL] [MESSAGES CONTROL]
disable= disable=
bad-option-value, bad-option-value,
cyclic-import, # This should be fixed cyclic-import, # This shoud be fixed
duplicate-code, duplicate-code,
fixme, fixme,
import-outside-toplevel, import-outside-toplevel,
line-too-long, line-too-long,
no-init,
old-style-class, old-style-class,
too-few-public-methods, too-few-public-methods,
too-many-arguments, too-many-arguments,
@ -14,12 +13,3 @@ disable=
too-many-instance-attributes, too-many-instance-attributes,
too-many-locals, too-many-locals,
too-many-public-methods, 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

View File

@ -1,180 +1,5 @@
# Changelog # 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) ## [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) [Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/89f55f70b017d0add645d1e1d88f0ce8192d11c4...v0.1.0)
@ -190,7 +15,11 @@
**Merged pull requests:** **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)) - 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
View File

@ -1,7 +1,7 @@
GNU GENERAL PUBLIC LICENSE GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007 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 Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed. 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 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. 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.> {one line to give the program's name and a brief idea of what it does.}
Copyright (C) <year> <name of author> Copyright (C) {year} {name of author}
This program is free software: you can redistribute it and/or modify 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 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. GNU General Public License for more details.
You should have received a copy of the GNU General Public License 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. Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode: 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 program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details. 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, 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. 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 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 The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with 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 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 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>.

View File

@ -1,69 +1,62 @@
export KODI_HOME := $(CURDIR)/tests/home export PYTHONPATH := $(CURDIR):$(CURDIR)/tests
export KODI_INTERACTIVE := 0
PYTHON := python 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/*))) languages = $(filter-out en_gb, $(patsubst resources/language/resource.language.%, %, $(wildcard resources/language/*)))
all: check test build all: check test build
zip: build zip: build
multizip: build
check: check-pylint check-translations check: check-pylint check-tox check-translations
check-pylint: check-pylint:
@printf ">>> Running pylint checks\n" @echo ">>> Running pylint checks"
@$(PYTHON) -m pylint *.py resources/lib/ tests/ @$(PYTHON) -m pylint *.py resources/lib/ tests/
check-tox:
@echo ">>> Running tox checks"
@$(PYTHON) -m tox -q
check-translations: check-translations:
@printf ">>> Running translation checks\n" @echo ">>> Running translation checks"
@$(foreach lang,$(languages), \ @$(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 check-addon: clean build
@printf ">>> Running addon checks\n" @echo ">>> Running addon checks"
$(eval TMPDIR := $(shell mktemp -d)) $(eval TMPDIR := $(shell mktemp -d))
@unzip dist/plugin.video.viervijfzes-*+matrix.1.zip -d ${TMPDIR} @unzip ../${zip_name} -d ${TMPDIR}
cd ${TMPDIR} && kodi-addon-checker --branch=matrix cd ${TMPDIR} && kodi-addon-checker --branch=leia
@rm -rf ${TMPDIR} @rm -rf ${TMPDIR}
codefix:
@isort -l 160 resources/
test: test-unit test: test-unit
test-unit: test-unit:
@printf ">>> Running unit tests\n" @echo ">>> Running unit tests"
@$(PYTHON) -m pytest tests @$(PYTHON) -m unittest discover -v -b -f
clean: clean:
@printf ">>> Cleaning up\n"
@find . -name '*.py[cod]' -type f -delete @find . -name '*.py[cod]' -type f -delete
@find . -name '__pycache__' -type d -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 -f *.log .coverage
@rm -rf dist/
build: clean build: clean
@printf ">>> Building add-on\n" @echo ">>> Building package"
@scripts/build.py @rm -f ../$(zip_name)
@ls -lah dist/*.zip cd ..; zip -r $(zip_name) $(include_paths) -x $(exclude_files)
@echo "Successfully wrote package as: ../$(zip_name)"
release: release: build
ifneq ($(release),) rm -rf ../repo-plugins/$(name)/*
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) unzip ../$(zip_name) -d ../repo-plugins/
@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

View File

@ -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) [![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/actions/workflow/status/add-ons/plugin.video.viervijfzes/ci.yml?branch=master)](https://github.com/add-ons/plugin.video.viervijfzes/actions?query=branch%3Amaster) [![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) [![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) [![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) [![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 *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.
account aanmaken op [goplay.be](https://www.goplay.be/).
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 ## Features
De volgende features worden ondersteund: 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 * Programma's rechtstreeks afspelen vanuit de tv-gids
* Doorzoeken van alle programma's * Doorzoeken van alle programma's
* Afspelen van gerelateerde Youtube content * Afspelen van gerelateerde Youtube content
* Integratie met [IPTV Manager](https://github.com/add-ons/service.iptv.manager)
## Screenshots ## Screenshots
@ -33,4 +37,4 @@ De volgende features worden ondersteund:
## Disclaimer ## Disclaimer
Deze add-on wordt niet ondersteund door SBS Belgium, en wordt aangeboden 'as is', zonder enige garantie. 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ë.

View File

@ -1,9 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?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> <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.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.pysocks" version="1.6.8" optional="true"/>
<import addon="script.module.requests" version="2.22.0"/> <import addon="script.module.requests" version="2.22.0"/>
<import addon="script.module.routing" version="0.2.0"/> <import addon="script.module.routing" version="0.2.0"/>
@ -13,17 +12,17 @@
</extension> </extension>
<extension point="xbmc.service" library="service_entry.py"/> <extension point="xbmc.service" library="service_entry.py"/>
<extension point="xbmc.addon.metadata"> <extension point="xbmc.addon.metadata">
<summary lang="nl_NL">Bekijk programma's van Play4, Play5 en Play6.</summary> <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 Play4, Play5 en Play6.</description> <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 Play4, Play5 en Play6 zijn eigendom van SBS België.</disclaimer> <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 Play4, Play5 and Play6.</summary> <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 Play4, Play5 and Play6.</description> <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 Play4, Play5 and Play6 logos are property of SBS Belgium.</disclaimer> <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> <platform>all</platform>
<license>GPL-3.0-only</license> <license>GPL-3.0</license>
<news>v0.4.12 (2024-05-08) <news>v0.1.0
- Added live channels (by mediaminister) - First release
- Added PlayCrime Channel</news> </news>
<source>https://github.com/add-ons/plugin.video.viervijfzes</source> <source>https://github.com/add-ons/plugin.video.viervijfzes</source>
<assets> <assets>
<icon>resources/icon.png</icon> <icon>resources/icon.png</icon>

View File

@ -3,13 +3,11 @@
from __future__ import absolute_import, division, unicode_literals 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 = xbmcaddon.Addon()
kodiutils.ADDON = Addon()
kodilogging.ADDON = Addon()
if __name__ == '__main__': if __name__ == '__main__':
from sys import argv from sys import argv

View File

@ -1,12 +1,9 @@
coverage
git+git://github.com/emilsvennesson/script.module.inputstreamhelper.git@master#egg=inputstreamhelper
polib polib
pylint pylint
pytest
pytest-cov
pytest-timeout
python-dateutil python-dateutil
pysocks
requests 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 six
sakee
win-inet-pton; platform_system=="Windows"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -19,15 +19,7 @@ msgid "Catalogue"
msgstr "" msgstr ""
msgctxt "#30004" msgctxt "#30004"
msgid "Browse the catalogue" msgid "TV Shows and Movies listed by category"
msgstr ""
msgctxt "#30005"
msgid "Recommendations"
msgstr ""
msgctxt "#30006"
msgid "Show the recommendations"
msgstr "" msgstr ""
msgctxt "#30007" msgctxt "#30007"
@ -46,24 +38,16 @@ msgctxt "#30010"
msgid "Search trough the catalogue" msgid "Search trough the catalogue"
msgstr "" msgstr ""
msgctxt "#30011"
msgid "My List"
msgstr ""
msgctxt "#30012"
msgid "Browse My List"
msgstr ""
msgctxt "#30013" msgctxt "#30013"
msgid "TV guide" msgid "TV guide"
msgstr "" msgstr ""
msgctxt "#30014"
### SUBMENUS msgid "Browse the TV Guide"
msgctxt "#30052"
msgid "Watch live [B]{channel}[/B]"
msgstr "" msgstr ""
### SUBMENUS
msgctxt "#30053" msgctxt "#30053"
msgid "TV Guide for [B]{channel}[/B]" msgid "TV Guide for [B]{channel}[/B]"
msgstr "" msgstr ""
@ -80,28 +64,16 @@ msgctxt "#30056"
msgid "Browse the Catalog for [B]{channel}[/B]" msgid "Browse the Catalog for [B]{channel}[/B]"
msgstr "" msgstr ""
msgctxt "#30059"
msgid "Clips of [B]{program}[/B]"
msgstr ""
msgctxt "#30060"
msgid "Watch short clips of [B]{program}[/B]"
msgstr ""
### CONTEXT MENU ### CONTEXT MENU
msgctxt "#30100"
msgid "Add to My List"
msgstr ""
msgctxt "#30101"
msgid "Remove from My List"
msgstr ""
msgctxt "#30102" msgctxt "#30102"
msgid "Go to Program" msgid "Go to Program"
msgstr "" msgstr ""
msgctxt "#30103"
msgid "Download to cache"
msgstr ""
### CODE ### CODE
msgctxt "#30204" msgctxt "#30204"
@ -140,10 +112,18 @@ msgctxt "#30702"
msgid "An error occurred while authenticating: {error}." msgid "An error occurred while authenticating: {error}."
msgstr "" msgstr ""
msgctxt "#30709"
msgid "Geo-blocked video"
msgstr ""
msgctxt "#30710" msgctxt "#30710"
msgid "This video is geo-blocked and can't be played from your location." msgid "This video is geo-blocked and can't be played from your location."
msgstr "" msgstr ""
msgctxt "#30711"
msgid "Unavailable video"
msgstr ""
msgctxt "#30712" msgctxt "#30712"
msgid "The video is unavailable and can't be played right now." msgid "The video is unavailable and can't be played right now."
msgstr "" msgstr ""
@ -152,20 +132,56 @@ msgctxt "#30713"
msgid "The requested video was not found in the guide." msgid "The requested video was not found in the guide."
msgstr "" 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" msgctxt "#30717"
msgid "This program is not available in the catalogue." msgid "This program is not available in the catalogue."
msgstr "" msgstr ""
msgctxt "#30718" 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 "" msgstr ""
msgctxt "#30719" msgctxt "#30719"
msgid "This video cannot be played." msgid "Could not cache this episode since ffmpeg seems to be unavailable."
msgstr "" msgstr ""
msgctxt "#30720" 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 "" msgstr ""
@ -178,11 +194,11 @@ msgctxt "#30801"
msgid "Credentials" msgid "Credentials"
msgstr "" msgstr ""
msgctxt "#30802" msgctxt "#30803"
msgid "Email address" msgid "Email address"
msgstr "" msgstr ""
msgctxt "#30803" msgctxt "#30805"
msgid "Password" msgid "Password"
msgstr "" msgstr ""
@ -190,46 +206,26 @@ msgctxt "#30820"
msgid "Interface" msgid "Interface"
msgstr "" msgstr ""
msgctxt "#30821" msgctxt "#30827"
msgid "Show unavailable programs" msgid "Metadata"
msgstr ""
msgctxt "#30829"
msgid "Periodically refresh metadata in the background"
msgstr ""
msgctxt "#30831"
msgid "Update local metadata now"
msgstr "" msgstr ""
msgctxt "#30840" msgctxt "#30840"
msgid "Integration" msgid "Playback from cache"
msgstr "" msgstr ""
msgctxt "#30841" msgctxt "#30841"
msgid "IPTV Manager" msgid "Allow to download episodes to a cache"
msgstr ""
msgctxt "#30842"
msgid "Install IPTV Manager add-on…"
msgstr "" msgstr ""
msgctxt "#30843" msgctxt "#30843"
msgid "Enable IPTV Manager integration" msgid "Select the folder for the cached episodes"
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…"
msgstr "" msgstr ""

View File

@ -20,16 +20,8 @@ msgid "Catalogue"
msgstr "Catalogus" msgstr "Catalogus"
msgctxt "#30004" msgctxt "#30004"
msgid "Browse the catalogue" msgid "TV Shows and Movies listed by category"
msgstr "Doorblader de catalogus" msgstr "Programma's en films per categorie"
msgctxt "#30005"
msgid "Recommendations"
msgstr "Aanbevelingen"
msgctxt "#30006"
msgid "Show the recommendations"
msgstr "Doorblader de aanbevelingen"
msgctxt "#30007" msgctxt "#30007"
msgid "Channels" msgid "Channels"
@ -47,24 +39,16 @@ msgctxt "#30010"
msgid "Search trough the catalogue" msgid "Search trough the catalogue"
msgstr "Doorzoek de catalogus" msgstr "Doorzoek de catalogus"
msgctxt "#30011"
msgid "My List"
msgstr "Mijn lijst"
msgctxt "#30012"
msgid "Browse My List"
msgstr "Bekijk mijn lijst"
msgctxt "#30013" msgctxt "#30013"
msgid "TV guide" msgid "TV guide"
msgstr "Tv-gids" msgstr "Tv-gids"
msgctxt "#30014"
msgid "Browse the TV Guide"
msgstr "Doorblader de tv-gids"
### SUBMENUS ### SUBMENUS
msgctxt "#30052"
msgid "Watch live [B]{channel}[/B]"
msgstr "Kijk live [B]{channel}[/B]"
msgctxt "#30053" msgctxt "#30053"
msgid "TV Guide for [B]{channel}[/B]" msgid "TV Guide for [B]{channel}[/B]"
msgstr "Tv-gids voor [B]{channel}[/B]" msgstr "Tv-gids voor [B]{channel}[/B]"
@ -81,28 +65,16 @@ msgctxt "#30056"
msgid "Browse the Catalog for [B]{channel}[/B]" msgid "Browse the Catalog for [B]{channel}[/B]"
msgstr "Doorblader de catalogus voor [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 ### 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" msgctxt "#30102"
msgid "Go to Program" msgid "Go to Program"
msgstr "Ga naar programma" msgstr "Ga naar programma"
msgctxt "#30103"
msgid "Download to cache"
msgstr "Downloaden naar cache"
### CODE ### CODE
msgctxt "#30204" msgctxt "#30204"
@ -141,10 +113,18 @@ msgctxt "#30702"
msgid "An error occurred while authenticating: {error}." msgid "An error occurred while authenticating: {error}."
msgstr "Er is een fout opgetreden tijdens het aanmelden: {error}." msgstr "Er is een fout opgetreden tijdens het aanmelden: {error}."
msgctxt "#30709"
msgid "Geo-blocked video"
msgstr "Video is geografisch geblokkeerd"
msgctxt "#30710" msgctxt "#30710"
msgid "This video is geo-blocked and can't be played from your location." 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." msgstr "Deze video is geografisch geblokkeerd en kan niet worden afgespeeld vanaf je locatie."
msgctxt "#30711"
msgid "Unavailable video"
msgstr "Onbeschikbare video"
msgctxt "#30712" msgctxt "#30712"
msgid "The video is unavailable and can't be played right now." msgid "The video is unavailable and can't be played right now."
msgstr "Deze video is niet beschikbaar en kan nu niet worden afgespeeld." 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." msgid "The requested video was not found in the guide."
msgstr "De gevraagde video werd niet gevonden in de tv-gids." 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" msgctxt "#30717"
msgid "This program is not available in the catalogue." msgid "This program is not available in the catalogue."
msgstr "Dit programma is niet beschikbaar in de catalogus." msgstr "Dit programma is niet beschikbaar in de catalogus."
msgctxt "#30718" 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 "Er is geen live stream beschikbaar voor {channel}." msgstr "Kon deze aflevering niet cachen omdat de cache folder niet is ingesteld is of niet bestaat."
msgctxt "#30719" msgctxt "#30719"
msgid "This video cannot be played." msgid "Could not cache this episode since ffmpeg seems to be unavailable."
msgstr "Deze video kan niet afgespeeld worden." msgstr "Kon deze aflevering niet cachen omdat ffmpeg niet beschikbaar lijkt te zijn."
msgctxt "#30720" 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 "Deze video is niet beschikbaar in het buitenland." 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 ### SETTINGS
@ -179,11 +195,11 @@ msgctxt "#30801"
msgid "Credentials" msgid "Credentials"
msgstr "Inloggegevens" msgstr "Inloggegevens"
msgctxt "#30802" msgctxt "#30803"
msgid "Email address" msgid "Email address"
msgstr "E-mailadres" msgstr "E-mailadres"
msgctxt "#30803" msgctxt "#30805"
msgid "Password" msgid "Password"
msgstr "Wachtwoord" msgstr "Wachtwoord"
@ -191,46 +207,26 @@ msgctxt "#30820"
msgid "Interface" msgid "Interface"
msgstr "Interface" msgstr "Interface"
msgctxt "#30821" msgctxt "#30827"
msgid "Show unavailable programs" msgid "Metadata"
msgstr "Toon onbeschikbare programma's" 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" msgctxt "#30840"
msgid "Integration" msgid "Playback from cache"
msgstr "Integratie" msgstr "Afspelen vanuit de cache"
msgctxt "#30841" msgctxt "#30841"
msgid "IPTV Manager" msgid "Allow to download episodes to a cache"
msgstr "IPTV Manager" msgstr "Toestaan om afleveringen te downloaden naar de cache"
msgctxt "#30842"
msgid "Install IPTV Manager add-on…"
msgstr "Installeer de IPTV Manager add-on…"
msgctxt "#30843" msgctxt "#30843"
msgid "Enable IPTV Manager integration" msgid "Select the folder for the cached episodes"
msgstr "Activeer IPTV Manager integratie" msgstr "Selecteer de map voor de gecachte afleveringen"
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…"

View File

@ -9,13 +9,9 @@ from routing import Plugin
from resources.lib import kodilogging from resources.lib import kodilogging
try: # Python 3 kodilogging.config()
from urllib.parse import unquote
except ImportError: # Python 2
from urllib import unquote
routing = Plugin() # pylint: disable=invalid-name routing = Plugin() # pylint: disable=invalid-name
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger('addon')
@routing.route('/') @routing.route('/')
@ -39,25 +35,18 @@ def show_channel_menu(channel):
Channels().show_channel_menu(channel) Channels().show_channel_menu(channel)
@routing.route('/channels/<channel>/tvguide') @routing.route('/tvguide/channel/<channel>')
def show_channel_tvguide(channel): def show_tvguide_channel(channel):
""" Shows the dates in the tv guide """ """ Shows the dates in the tv guide """
from resources.lib.modules.tvguide import TvGuide from resources.lib.modules.tvguide import TvGuide
TvGuide().show_channel(channel) TvGuide().show_tvguide_channel(channel)
@routing.route('/channels/<channel>/tvguide/<date>') @routing.route('/tvguide/channel/<channel>/<date>')
def show_channel_tvguide_detail(channel=None, date=None): def show_tvguide_detail(channel=None, date=None):
""" Shows the programs of a specific date in the tv guide """ """ Shows the programs of a specific date in the tv guide """
from resources.lib.modules.tvguide import TvGuide from resources.lib.modules.tvguide import TvGuide
TvGuide().show_detail(channel, date) TvGuide().show_tvguide_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)
@routing.route('/catalog') @routing.route('/catalog')
@ -67,74 +56,25 @@ def show_catalog():
Catalog().show_catalog() Catalog().show_catalog()
@routing.route('/catalog/<program>') @routing.route('/catalog/by-channel/<channel>')
def show_catalog_program(program): 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 """ """ Show a program from the catalog """
from resources.lib.modules.catalog import Catalog from resources.lib.modules.catalog import Catalog
Catalog().show_program(program) Catalog().show_program(channel, program)
@routing.route('/catalog/<program>/clips') @routing.route('/catalog/program/<channel>/<program>/<season>')
def show_catalog_program_clips(program): def show_catalog_program_season(channel, program, season):
""" Show the clips from a program """ """ Show a program from the catalog """
from resources.lib.modules.catalog import Catalog from resources.lib.modules.catalog import Catalog
Catalog().show_program_clips(program) Catalog().show_program_season(channel, program, season)
@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)
@routing.route('/search') @routing.route('/search')
@ -145,50 +85,39 @@ def show_search(query=None):
Search().show_search(query) Search().show_search(query)
@routing.route('/play/live/<channel>') @routing.route('/play/catalog/<uuid>')
def play_live(channel): def play(uuid):
""" Play the requested item """ """ Play the requested item """
from resources.lib.modules.player import Player from resources.lib.modules.player import Player
Player().live(channel) Player().play(uuid)
@routing.route('/play/epg/<channel>/<timestamp>') @routing.route('/download/catalog/<uuid>')
def play_epg(channel, timestamp): def download(uuid):
""" Play the requested item """ """ Download the requested item to cache """
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 """
from resources.lib.modules.player import Player from resources.lib.modules.player import Player
Player().play(uuid, content_type) Player().download(uuid)
@routing.route('/play/page/<page>') @routing.route('/play/page/<channel>/<page>')
def play_from_page(page): def play_from_page(channel, page):
""" Play the requested item """ """ 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 from resources.lib.modules.player import Player
Player().play_from_page(unquote(page)) Player().play_from_page(channel, unquote(page))
@routing.route('/iptv/channels') @routing.route('/metadata/update')
def iptv_channels(): def metadata_update():
""" Generate channel data for the Kodi PVR integration """ """ Update the metadata for the listings (called from settings) """
from resources.lib.modules.iptvmanager import IPTVManager from resources.lib.modules.metadata import Metadata
IPTVManager(int(routing.args['port'][0])).send_channels() # pylint: disable=too-many-function-args Metadata().update()
@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
def run(params): def run(params):
""" Run the routing plugin """ """ Run the routing plugin """
kodilogging.config()
routing.run(params) routing.run(params)

View 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

View File

@ -8,23 +8,15 @@ import logging
import xbmc import xbmc
import xbmcaddon import xbmcaddon
from resources.lib import kodiutils
ADDON = xbmcaddon.Addon()
class KodiLogHandler(logging.StreamHandler): class KodiLogHandler(logging.StreamHandler):
""" A log handler for Kodi """ """ A log handler for Kodi """
def __init__(self): def __init__(self):
logging.StreamHandler.__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) 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): def emit(self, record):
""" Emit a log message """ """ Emit a log message """
@ -32,16 +24,10 @@ class KodiLogHandler(logging.StreamHandler):
logging.CRITICAL: xbmc.LOGFATAL, logging.CRITICAL: xbmc.LOGFATAL,
logging.ERROR: xbmc.LOGERROR, logging.ERROR: xbmc.LOGERROR,
logging.WARNING: xbmc.LOGWARNING, logging.WARNING: xbmc.LOGWARNING,
logging.INFO: self.info_level, logging.INFO: xbmc.LOGINFO,
logging.DEBUG: xbmc.LOGDEBUG, logging.DEBUG: xbmc.LOGDEBUG,
logging.NOTSET: xbmc.LOGNONE, 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: try:
xbmc.log(self.format(record), levels[record.levelno]) xbmc.log(self.format(record), levels[record.levelno])
except UnicodeEncodeError: except UnicodeEncodeError:
@ -54,5 +40,5 @@ class KodiLogHandler(logging.StreamHandler):
def config(): def config():
""" Setup the logger with this handler """ """ Setup the logger with this handler """
logger = logging.getLogger() logger = logging.getLogger()
logger.setLevel(logging.DEBUG) # Make sure we pass all messages, Kodi will do some filtering itself.
logger.addHandler(KodiLogHandler()) logger.addHandler(KodiLogHandler())
logger.setLevel(logging.DEBUG)

View File

@ -4,63 +4,35 @@
from __future__ import absolute_import, division, unicode_literals from __future__ import absolute_import, division, unicode_literals
import logging import logging
import os
import re
import xbmc import xbmc
import xbmcaddon import xbmcaddon
import xbmcgui import xbmcgui
import xbmcplugin 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() ADDON = xbmcaddon.Addon()
SORT_METHODS = { SORT_METHODS = dict(
'unsorted': xbmcplugin.SORT_METHOD_UNSORTED, unsorted=xbmcplugin.SORT_METHOD_UNSORTED,
'label': xbmcplugin.SORT_METHOD_LABEL_IGNORE_FOLDERS, label=xbmcplugin.SORT_METHOD_LABEL_IGNORE_FOLDERS,
'title': xbmcplugin.SORT_METHOD_TITLE, title=xbmcplugin.SORT_METHOD_TITLE,
'episode': xbmcplugin.SORT_METHOD_EPISODE, episode=xbmcplugin.SORT_METHOD_EPISODE,
'duration': xbmcplugin.SORT_METHOD_DURATION, duration=xbmcplugin.SORT_METHOD_DURATION,
'year': xbmcplugin.SORT_METHOD_VIDEO_YEAR, year=xbmcplugin.SORT_METHOD_VIDEO_YEAR,
'date': xbmcplugin.SORT_METHOD_DATE date=xbmcplugin.SORT_METHOD_DATE,
} )
DEFAULT_SORT_METHODS = [ DEFAULT_SORT_METHODS = [
'unsorted', 'title' 'unsorted', 'title'
] ]
HTML_MAPPING = [ _LOGGER = logging.getLogger('kodiutils')
(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('(&nbsp;\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__)
class TitleItem: class TitleItem:
""" This helper object holds all information to be used with Kodi xbmc's ListItem object """ """ 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, def __init__(self, title, path=None, art_dict=None, info_dict=None, prop_dict=None, stream_dict=None, context_menu=None, subtitles_path=None,
context_menu=None, subtitles_path=None, is_playable=False, visible=True): is_playable=False):
""" The constructor for the TitleItem class """ The constructor for the TitleItem class
:type title: str :type title: str
:type path: str :type path: str
@ -71,7 +43,6 @@ class TitleItem:
:type context_menu: list[tuple[str, str]] :type context_menu: list[tuple[str, str]]
:type subtitles_path: list[str] :type subtitles_path: list[str]
:type is_playable: bool :type is_playable: bool
:type visible: bool
""" """
self.title = title self.title = title
self.path = path self.path = path
@ -82,7 +53,6 @@ class TitleItem:
self.context_menu = context_menu self.context_menu = context_menu
self.subtitles_path = subtitles_path self.subtitles_path = subtitles_path
self.is_playable = is_playable self.is_playable = is_playable
self.visible = visible
def __repr__(self): def __repr__(self):
return "%r" % self.__dict__ return "%r" % self.__dict__
@ -111,15 +81,6 @@ def from_unicode(text, encoding='utf-8', errors='strict'):
return text 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(): def addon_icon():
"""Cache and return add-on icon""" """Cache and return add-on icon"""
return get_addon_info('icon') return get_addon_info('icon')
@ -147,9 +108,6 @@ def addon_path():
def addon_profile(): def addon_profile():
"""Cache and return add-on 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')))
@ -193,9 +151,6 @@ def show_listing(title_items, category=None, sort=None, content=None, cache=True
# Add the listings # Add the listings
listing = [] listing = []
for title_item in title_items: for title_item in title_items:
if not title_item.visible:
continue
# Three options: # Three options:
# - item is a virtual directory/folder (not playable, path) # - item is a virtual directory/folder (not playable, path)
# - item is a playable file (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) 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""" """Play the given stream"""
from resources.lib.addon import routing from resources.lib.addon import routing
@ -244,35 +199,6 @@ def play(stream, stream_type=STREAM_HLS, license_key=None, title=None, art_dict=
play_item.setInfo(type='video', infoLabels=info_dict) play_item.setInfo(type='video', infoLabels=info_dict)
if prop_dict: if prop_dict:
play_item.setProperties(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) xbmcplugin.setResolvedUrl(routing.handle, True, listitem=play_item)
@ -289,39 +215,40 @@ def get_search_string(heading='', message=''):
def ok_dialog(heading='', message=''): def ok_dialog(heading='', message=''):
"""Show Kodi's OK dialog""" """Show Kodi's OK dialog"""
from xbmcgui import Dialog
if not heading: if not heading:
heading = addon_name() heading = addon_name()
if kodi_version_major() < 19: if kodi_version_major() < 19:
# pylint: disable=unexpected-keyword-arg,no-value-for-parameter return Dialog().ok(heading=heading, line1=message)
return xbmcgui.Dialog().ok(heading=heading, line1=message) return Dialog().ok(heading=heading, message=message)
return xbmcgui.Dialog().ok(heading=heading, message=message)
def yesno_dialog(heading='', message='', nolabel=None, yeslabel=None, autoclose=0): def yesno_dialog(heading='', message='', nolabel=None, yeslabel=None, autoclose=0):
"""Show Kodi's Yes/No dialog""" """Show Kodi's Yes/No dialog"""
from xbmcgui import Dialog
if not heading: if not heading:
heading = addon_name() heading = addon_name()
if kodi_version_major() < 19: if kodi_version_major() < 19:
# pylint: disable=unexpected-keyword-arg,no-value-for-parameter return Dialog().yesno(heading=heading, line1=message, nolabel=nolabel, yeslabel=yeslabel, autoclose=autoclose)
return xbmcgui.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)
return xbmcgui.Dialog().yesno(heading=heading, message=message, nolabel=nolabel, yeslabel=yeslabel, autoclose=autoclose)
def notification(heading='', message='', icon='info', time=4000): def notification(heading='', message='', icon='info', time=4000):
"""Show a Kodi notification""" """Show a Kodi notification"""
from xbmcgui import Dialog
if not heading: if not heading:
heading = addon_name() heading = addon_name()
if not icon: if not icon:
icon = addon_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): def multiselect(heading='', options=None, autoclose=0, preselect=None, use_details=False):
"""Show a Kodi multi-select dialog""" """Show a Kodi multi-select dialog"""
from xbmcgui import Dialog
if not heading: if not heading:
heading = addon_name() heading = addon_name()
return xbmcgui.Dialog().multiselect(heading=heading, options=options, autoclose=autoclose, preselect=preselect, return Dialog().multiselect(heading=heading, options=options, autoclose=autoclose, preselect=preselect, useDetails=use_details)
useDetails=use_details)
class progress(xbmcgui.DialogProgress, object): # pylint: disable=invalid-name,useless-object-inheritance 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 def create(self, heading, message=''): # pylint: disable=arguments-differ
"""Create and show a progress dialog""" """Create and show a progress dialog"""
if kodi_version_major() < 19: if kodi_version_major() < 19:
lines = message.split('\n', 2) return super(progress, self).create(heading, line1=message)
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, message=message) return super(progress, self).create(heading, message=message)
def update(self, percent, message=''): # pylint: disable=arguments-differ def update(self, percent, message=''): # pylint: disable=arguments-differ
"""Update the progress dialog""" """Update the progress dialog"""
if kodi_version_major() < 19: if kodi_version_major() < 19:
lines = message.split('\n', 2) return super(progress, self).update(percent, line1=message)
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, message=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""" """Load the proper locale for date strings, only once"""
if hasattr(set_locale, 'cached'): if hasattr(set_locale, 'cached'):
return getattr(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 = get_global_setting('locale.language').split('.')[-1]
locale_lang = locale_lang[:-2] + locale_lang[-2:].upper() locale_lang = locale_lang[:-2] + locale_lang[-2:].upper()
# NOTE: setlocale() only works if the platform supports the Kodi configured locale # NOTE: setlocale() only works if the platform supports the Kodi configured locale
@ -470,74 +393,13 @@ def open_settings():
def get_global_setting(key): def get_global_setting(key):
"""Get a Kodi setting""" """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') return result.get('result', {}).get('value')
def set_global_setting(key, value): def set_global_setting(key, value):
"""Set a Kodi setting""" """Set a Kodi setting"""
return jsonrpc(method='Settings.SetSettingValue', params={'setting': key, 'value': value}) return jsonrpc(method='Settings.SetSettingValue', params=dict(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}
def get_cond_visibility(condition): def get_cond_visibility(condition):
@ -563,14 +425,14 @@ def kodi_version_major():
def get_tokens_path(): def get_tokens_path():
"""Cache and return the userdata tokens path""" """Cache and return the userdata tokens path"""
if not hasattr(get_tokens_path, 'cached'): 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') return getattr(get_tokens_path, 'cached')
def get_cache_path(): def get_cache_path():
"""Cache and return the userdata cache path""" """Cache and return the userdata cache path"""
if not hasattr(get_cache_path, 'cached'): 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') return getattr(get_cache_path, 'cached')
@ -629,13 +491,3 @@ def jsonrpc(*args, **kwargs):
if kwargs.get('jsonrpc') is None: if kwargs.get('jsonrpc') is None:
kwargs.update(jsonrpc='2.0') kwargs.update(jsonrpc='2.0')
return loads(xbmc.executeJSONRPC(dumps(kwargs))) 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)

View File

@ -8,10 +8,11 @@ import logging
from resources.lib import kodiutils from resources.lib import kodiutils
from resources.lib.kodiutils import TitleItem from resources.lib.kodiutils import TitleItem
from resources.lib.modules.menu import Menu from resources.lib.modules.menu import Menu
from resources.lib.viervijfzes import CHANNELS
from resources.lib.viervijfzes.auth import AuthApi 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: class Catalog:
@ -19,18 +20,21 @@ class Catalog:
def __init__(self): def __init__(self):
""" Initialise object """ """ Initialise object """
self._auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path()) 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()) self._api = ContentApi(auth, cache_path=kodiutils.get_cache_path())
self._menu = Menu()
def show_catalog(self): def show_catalog(self):
""" Show all the programs of all channels """ """ Show all the programs of all channels """
try: try:
items = self._api.get_programs() items = []
for channel in list(CHANNELS):
items.extend(self._api.get_programs(channel))
except Exception as ex: except Exception as ex:
kodiutils.notification(message=str(ex)) kodiutils.notification(message=str(ex))
raise raise
listing = [Menu.generate_titleitem(item) for item in items] listing = [self._menu.generate_titleitem(item) for item in items]
# Sort items by title # Sort items by title
# Used for A-Z listing or when movies and episodes are mixed. # Used for A-Z listing or when movies and episodes are mixed.
@ -48,51 +52,53 @@ class Catalog:
listing = [] listing = []
for item in items: for item in items:
listing.append(Menu.generate_titleitem(item)) listing.append(self._menu.generate_titleitem(item))
# Sort items by title # Sort items by title
# Used for A-Z listing or when movies and episodes are mixed. # Used for A-Z listing or when movies and episodes are mixed.
kodiutils.show_listing(listing, 30003, content='tvshows', sort='title') 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 """ Show a program from the catalog
:type channel: str
:type program_id: str :type program_id: str
""" """
try: 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: except UnavailableException:
kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue. kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue.
kodiutils.end_of_directory() kodiutils.end_of_directory()
return 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.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue.
kodiutils.end_of_directory() kodiutils.end_of_directory()
return return
# Go directly to the season when we have only one season and no clips # Go directly to the season when we have only one season
if not program.clips and len(program.seasons) == 1: if len(program.seasons) == 1:
self.show_program_season(program_id, list(program.seasons.values())[0].uuid) self.show_program_season(channel, program_id, list(program.seasons.values())[0].uuid)
return return
studio = CHANNELS.get(program.channel, {}).get('studio_icon')
listing = [] listing = []
# Add an '* All seasons' entry when configured in Kodi # 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( listing.append(
TitleItem( TitleItem(
title='* %s' % kodiutils.localize(30204), # * All seasons 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={ art_dict={
'fanart': program.fanart, 'fanart': program.background,
'poster': program.poster,
'landscape': program.thumb,
}, },
info_dict={ info_dict={
'tvshowtitle': program.title, 'tvshowtitle': program.title,
'title': kodiutils.localize(30204), # All seasons 'title': kodiutils.localize(30204), # All seasons
'plot': program.description, 'plot': program.description,
'set': program.title, 'set': program.title,
'studio': studio,
} }
) )
) )
@ -102,37 +108,16 @@ class Catalog:
listing.append( listing.append(
TitleItem( TitleItem(
title=season.title, # kodiutils.localize(30205, season=season.number), # Season {season} 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={ art_dict={
'fanart': program.fanart, 'fanart': program.background,
'poster': program.poster,
'landscape': program.thumb,
}, },
info_dict={ info_dict={
'tvshowtitle': program.title, 'tvshowtitle': program.title,
'title': kodiutils.localize(30205, season=season.number) if season.number else season.title, # Season {season} 'title': kodiutils.localize(30205, season=season.number), # Season {season}
'plot': season.description or program.description, 'plot': season.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}
'set': program.title, 'set': program.title,
'studio': studio,
} }
) )
) )
@ -140,13 +125,14 @@ class Catalog:
# Sort by label. Some programs return seasons unordered. # Sort by label. Some programs return seasons unordered.
kodiutils.show_listing(listing, 30003, content='tvshows') 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 """ Show the episodes of a program from the catalog
:type channel: str
:type program_id: str :type program_id: str
:type season_uuid: str :type season_uuid: str
""" """
try: try:
program = self._api.get_program(program_id) program = self._api.get_program(channel, program_id)
except UnavailableException: except UnavailableException:
kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue. kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue.
kodiutils.end_of_directory() kodiutils.end_of_directory()
@ -159,122 +145,7 @@ class Catalog:
# Show the episodes of the season that was selected # Show the episodes of the season that was selected
episodes = [e for e in program.episodes if e.season_uuid == season_uuid] 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. # Sort by episode number by default. Takes seasons into account.
kodiutils.show_listing(listing, 30003, content='episodes', sort=['episode', 'duration']) 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()

View File

@ -8,10 +8,8 @@ import logging
from resources.lib import kodiutils from resources.lib import kodiutils
from resources.lib.kodiutils import TitleItem from resources.lib.kodiutils import TitleItem
from resources.lib.viervijfzes import CHANNELS, STREAM_DICT 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: class Channels:
@ -19,8 +17,6 @@ class Channels:
def __init__(self): def __init__(self):
""" Initialise object """ """ 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 @staticmethod
def show_channels(): def show_channels():
@ -37,7 +33,7 @@ class Channels:
( (
kodiutils.localize(30053, channel=channel.get('name')), # TV Guide for {channel} kodiutils.localize(30053, channel=channel.get('name')), # TV Guide for {channel}
'Container.Update(%s)' % '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, 'plot': None,
'playcount': 0, 'playcount': 0,
'mediatype': 'video', 'mediatype': 'video',
'studio': channel.get('studio_icon'),
}, },
stream_dict=STREAM_DICT, stream_dict=STREAM_DICT,
context_menu=context_menu context_menu=context_menu
@ -63,67 +60,43 @@ class Channels:
kodiutils.show_listing(listing, 30007) kodiutils.show_listing(listing, 30007)
@staticmethod @staticmethod
def show_channel_menu(channel): def show_channel_menu(key):
""" Shows a TV channel """ 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 # 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')) fanart = '{path}/resources/logos/{logo}'.format(path=kodiutils.addon_path(), logo=channel.get('background'))
icon = '{path}/resources/logos/{logo}'.format(path=kodiutils.addon_path(), logo=channel_info.get('logo'))
listing = [] listing = [
listing.append(
TitleItem( TitleItem(
title=kodiutils.localize(30052, channel=channel_info.get('name')), # Watch live {channel} title=kodiutils.localize(30053, channel=channel.get('name')), # TV Guide for {channel}
path=kodiutils.url_for('play_live', channel=channel_info.get('name')) + '?.pvr', path=kodiutils.url_for('show_tvguide_channel', channel=key),
art_dict={
'icon': icon,
'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={ art_dict={
'icon': 'DefaultAddonTvInfo.png', 'icon': 'DefaultAddonTvInfo.png',
'fanart': fanart, 'fanart': fanart,
}, },
info_dict={ info_dict={
'plot': kodiutils.localize(30054, channel=channel_info.get('name')), # Browse the TV Guide for {channel} 'plot': kodiutils.localize(30054, channel=channel.get('name')), # Browse the TV Guide for {channel}
} }
) ),
)
listing.append(
TitleItem( TitleItem(
title=kodiutils.localize(30055, channel=channel_info.get('name')), # Catalog for {channel} title=kodiutils.localize(30055, channel=channel.get('name')), # Catalog for {channel}
path=kodiutils.url_for('show_channel_catalog', channel=channel), path=kodiutils.url_for('show_catalog_channel', channel=key),
art_dict={ art_dict={
'icon': 'DefaultMovieTitle.png', 'icon': 'DefaultMovieTitle.png',
'fanart': fanart, 'fanart': fanart,
}, },
info_dict={ 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 # Add YouTube channels
if kodiutils.get_cond_visibility('System.HasAddon(plugin.video.youtube)') != 0: 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( listing.append(
TitleItem( TitleItem(
title=kodiutils.localize(30206, label=youtube.get('label')), # Watch {label} on YouTube title=kodiutils.localize(30206, label=youtube.get('label')), # Watch {label} on YouTube

View File

@ -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}

View File

@ -3,19 +3,10 @@
from __future__ import absolute_import, division, unicode_literals from __future__ import absolute_import, division, unicode_literals
import logging
from resources.lib import kodiutils from resources.lib import kodiutils
from resources.lib.kodiutils import TitleItem from resources.lib.kodiutils import TitleItem
from resources.lib.viervijfzes import STREAM_DICT from resources.lib.viervijfzes import CHANNELS, STREAM_DICT
from resources.lib.viervijfzes.content import Episode, Program from resources.lib.viervijfzes.content import Program, Episode
try: # Python 3
from urllib.parse import quote
except ImportError: # Python 2
from urllib import quote
_LOGGER = logging.getLogger(__name__)
class Menu: class Menu:
@ -31,68 +22,35 @@ class Menu:
TitleItem( TitleItem(
title=kodiutils.localize(30001), # A-Z title=kodiutils.localize(30001), # A-Z
path=kodiutils.url_for('show_catalog'), path=kodiutils.url_for('show_catalog'),
art_dict={ art_dict=dict(
'icon': 'DefaultMovieTitle.png', icon='DefaultMovieTitle.png',
'fanart': kodiutils.get_addon_info('fanart') fanart=kodiutils.get_addon_info('fanart'),
}, ),
info_dict={ info_dict=dict(
'plot': kodiutils.localize(30002) plot=kodiutils.localize(30002),
} )
), ),
TitleItem( TitleItem(
title=kodiutils.localize(30007), # TV Channels title=kodiutils.localize(30007), # TV Channels
path=kodiutils.url_for('show_channels'), path=kodiutils.url_for('show_channels'),
art_dict={ art_dict=dict(
'icon': 'DefaultAddonPVRClient.png', icon='DefaultAddonPVRClient.png',
'fanart': kodiutils.get_addon_info('fanart') fanart=kodiutils.get_addon_info('fanart'),
},
info_dict={
'plot': kodiutils.localize(30008)
}
), ),
TitleItem( info_dict=dict(
title=kodiutils.localize(30003), # Catalog plot=kodiutils.localize(30008),
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)
}
), ),
TitleItem( TitleItem(
title=kodiutils.localize(30009), # Search title=kodiutils.localize(30009), # Search
path=kodiutils.url_for('show_search'), path=kodiutils.url_for('show_search'),
art_dict={ art_dict=dict(
'icon': 'DefaultAddonsSearch.png', icon='DefaultAddonsSearch.png',
'fanart': kodiutils.get_addon_info('fanart') fanart=kodiutils.get_addon_info('fanart'),
}, ),
info_dict={ info_dict=dict(
'plot': kodiutils.localize(30010) plot=kodiutils.localize(30010),
} )
) )
] ]
@ -104,9 +62,14 @@ class Menu:
:type item: Union[Program, Episode] :type item: Union[Program, Episode]
:rtype TitleItem :rtype TitleItem
""" """
art_dict = {
'thumb': item.cover,
'cover': item.cover,
}
info_dict = { info_dict = {
'title': item.title, 'title': item.title,
'plot': item.description, 'plot': item.description,
'studio': CHANNELS.get(item.channel, {}).get('studio_icon'),
'aired': item.aired.strftime('%Y-%m-%d') if item.aired else None, 'aired': item.aired.strftime('%Y-%m-%d') if item.aired else None,
} }
prop_dict = {} prop_dict = {}
@ -115,53 +78,33 @@ class Menu:
# Program # Program
# #
if isinstance(item, Program): if isinstance(item, Program):
art_dict.update({
'fanart': item.background,
})
info_dict.update({ info_dict.update({
'mediatype': None, 'mediatype': None,
'season': len(item.seasons) if item.seasons else None, 'season': len(item.seasons) if item.seasons else None,
}) })
art_dict = { if isinstance(item.episodes, list) and not item.episodes:
'poster': item.poster, # We know that we don't have episodes
'landscape': item.thumb, title = '[COLOR gray]' + item.title + '[/COLOR]'
'thumb': item.thumb, else:
'fanart': item.fanart, # We have episodes, or we don't know it
}
visible = True
title = item.title 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)
))
return TitleItem(title=title, return TitleItem(title=title,
path=kodiutils.url_for('show_catalog_program', program=item.path), path=kodiutils.url_for('show_catalog_program', channel=item.channel, program=item.path),
context_menu=context_menu,
art_dict=art_dict, art_dict=art_dict,
info_dict=info_dict, info_dict=info_dict)
visible=visible)
# #
# Episode # Episode
# #
if isinstance(item, Episode): if isinstance(item, Episode):
art_dict.update({
'fanart': item.cover,
})
info_dict.update({ info_dict.update({
'mediatype': 'episode', 'mediatype': 'episode',
'tvshowtitle': item.program_title, 'tvshowtitle': item.program_title,
@ -170,30 +113,27 @@ class Menu:
'episode': item.number, 'episode': item.number,
}) })
art_dict = {
'landscape': item.thumb,
'thumb': item.thumb,
'fanart': item.thumb,
}
stream_dict = STREAM_DICT.copy() stream_dict = STREAM_DICT.copy()
stream_dict.update({ stream_dict.update({
'duration': item.duration, 'duration': item.duration,
}) })
if item.uuid: if kodiutils.get_setting_bool('episode_cache_enabled'):
# We have an UUID and can play this item directly context_menu = [(
path = kodiutils.url_for('play_catalog', uuid=item.uuid, content_type=item.content_type) kodiutils.localize(30103), # Download to cache
'Container.Update(%s)' %
kodiutils.url_for('download', uuid=item.uuid)
)]
else: else:
# We don't have an UUID, and first need to fetch the video information from the page context_menu = []
path = kodiutils.url_for('play_from_page', page=quote(item.path, safe=''))
return TitleItem(title=info_dict['title'], return TitleItem(title=info_dict['title'],
path=path, path=kodiutils.url_for('play', uuid=item.uuid),
art_dict=art_dict, art_dict=art_dict,
info_dict=info_dict, info_dict=info_dict,
stream_dict=stream_dict, stream_dict=stream_dict,
prop_dict=prop_dict, prop_dict=prop_dict,
is_playable=True) is_playable=True,
context_menu=context_menu)
raise Exception('Unknown video_type') raise Exception('Unknown video_type')

View 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

View File

@ -4,15 +4,15 @@
from __future__ import absolute_import, division, unicode_literals from __future__ import absolute_import, division, unicode_literals
import logging import logging
import os
from resources.lib import kodiutils from resources.lib import kodiutils
from resources.lib.modules.menu import Menu from resources.lib.downloader import Downloader
from resources.lib.viervijfzes import CHANNELS, ResolvedStream
from resources.lib.viervijfzes.auth import AuthApi from resources.lib.viervijfzes.auth import AuthApi
from resources.lib.viervijfzes.aws.cognito_idp import AuthenticationException, InvalidLoginException from resources.lib.viervijfzes.auth_awsidp import InvalidLoginException, AuthenticationException
from resources.lib.viervijfzes.content import CACHE_PREVENT, ContentApi, GeoblockedException, UnavailableException from resources.lib.viervijfzes.content import ContentApi, UnavailableException, GeoblockedException
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger('player')
class Player: class Player:
@ -21,83 +21,94 @@ class Player:
def __init__(self): def __init__(self):
""" Initialise object """ """ Initialise object """
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_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._api = ContentApi(auth)
# Workaround for Raspberry Pi 3 and older def play_from_page(self, channel, path):
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):
""" Play the requested item. """ Play the requested item.
:type channel: string
:type path: string :type path: string
""" """
if not path:
kodiutils.ok_dialog(message=kodiutils.localize(30712)) # The video is unavailable...
return
# Get episode information # Get episode information
episode = self._api.get_episode(path, cache=CACHE_PREVENT) episode = ContentApi().get_episode(channel, path)
resolved_stream = None
if episode is None: # Play this now we have the uuid
kodiutils.ok_dialog(message=kodiutils.localize(30712)) self.play(episode.uuid)
return
if episode.stream: def play(self, uuid):
# 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):
""" Play the requested item. """ Play the requested item.
:type uuid: string :type uuid: string
:type content_type: string
""" """
if not uuid: if kodiutils.get_setting_bool('episode_cache_enabled'):
kodiutils.ok_dialog(message=kodiutils.localize(30712)) # The video is unavailable... # Check for a cached version
cached_file = self._check_cached_episode(uuid)
if cached_file:
kodiutils.play(cached_file)
return return
# Lookup the stream # Workaround for Raspberry Pi 3 and older
resolved_stream = self._resolve_stream(uuid, content_type) omxplayer = kodiutils.get_global_setting('videoplayer.useomxplayer')
kodiutils.play(resolved_stream.url, resolved_stream.stream_type, resolved_stream.license_key) if omxplayer is False:
kodiutils.set_global_setting('videoplayer.useomxplayer', True)
@staticmethod # Resolve the stream
def _resolve_stream(uuid, content_type): resolved_stream = self._fetch_stream(uuid)
""" Resolve the stream for the requested item if not resolved_stream:
kodiutils.end_of_directory()
return
# Play this item
kodiutils.play(resolved_stream)
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 uuid: string
:type content_type: string
""" """
try: try:
# Check if we have credentials # 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? message=kodiutils.localize(30701)) # To watch a video, you need to enter your credentials. Do you want to enter them now?
if confirm: if confirm:
kodiutils.open_settings() kodiutils.open_settings()
kodiutils.end_of_directory()
return None return None
# Fetch an auth token now
try: try:
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
# Get stream information # Get stream information
resolved_stream = ContentApi(auth).get_stream_by_uuid(uuid, content_type) resolved_stream = self._api.get_stream_by_uuid(uuid)
return resolved_stream
except (InvalidLoginException, AuthenticationException) as ex: except (InvalidLoginException, AuthenticationException) as ex:
_LOGGER.exception(ex) _LOGGER.error(ex)
kodiutils.ok_dialog(message=kodiutils.localize(30702, error=str(ex))) kodiutils.ok_dialog(message=kodiutils.localize(30702, error=str(ex)))
kodiutils.end_of_directory()
return None return None
except GeoblockedException: 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 return None
except UnavailableException: 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 return None

View File

@ -9,7 +9,7 @@ from resources.lib import kodiutils
from resources.lib.modules.menu import Menu from resources.lib.modules.menu import Menu
from resources.lib.viervijfzes.search import SearchApi from resources.lib.viervijfzes.search import SearchApi
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger('search')
class Search: class Search:
@ -18,6 +18,7 @@ class Search:
def __init__(self): def __init__(self):
""" Initialise object """ """ Initialise object """
self._search = SearchApi() self._search = SearchApi()
self._menu = Menu()
def show_search(self, query=None): def show_search(self, query=None):
""" Shows the search dialog """ Shows the search dialog
@ -39,7 +40,7 @@ class Search:
return return
# Display results # 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. # Sort like we get our results back.
kodiutils.show_listing(listing, 30009, content='tvshows') kodiutils.show_listing(listing, 30009, content='tvshows')

View File

@ -8,7 +8,6 @@ from datetime import datetime, timedelta
from resources.lib import kodiutils from resources.lib import kodiutils
from resources.lib.kodiutils import TitleItem from resources.lib.kodiutils import TitleItem
from resources.lib.modules.player import Player
from resources.lib.viervijfzes import STREAM_DICT from resources.lib.viervijfzes import STREAM_DICT
from resources.lib.viervijfzes.content import UnavailableException from resources.lib.viervijfzes.content import UnavailableException
from resources.lib.viervijfzes.epg import EpgApi from resources.lib.viervijfzes.epg import EpgApi
@ -18,7 +17,7 @@ try: # Python 3
except ImportError: # Python 2 except ImportError: # Python 2
from urllib import quote from urllib import quote
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger('tvguide')
class TvGuide: class TvGuide:
@ -36,8 +35,8 @@ class TvGuide:
dates = [] dates = []
today = datetime.today() today = datetime.today()
# The API provides 7 days in the past and 8 days in the future # The API provides 7 days in the past and 13 days in the future
for i in range(-7, 8): for i in range(-7, 13):
day = today + timedelta(days=i) day = today + timedelta(days=i)
if i == -1: if i == -1:
@ -71,7 +70,7 @@ class TvGuide:
return dates return dates
def show_channel(self, channel): def show_tvguide_channel(self, channel):
""" Shows the dates in the tv guide """ Shows the dates in the tv guide
:type channel: str :type channel: str
""" """
@ -84,7 +83,7 @@ class TvGuide:
listing.append( listing.append(
TitleItem(title=title, 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={ art_dict={
'icon': 'DefaultYear.png', 'icon': 'DefaultYear.png',
'thumb': 'DefaultYear.png', 'thumb': 'DefaultYear.png',
@ -97,7 +96,7 @@ class TvGuide:
kodiutils.show_listing(listing, 30013, content='files', sort=['date']) 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 """ Shows the programs of a specific date in the tv guide
:type channel: str :type channel: str
:type date: str :type date: str
@ -131,7 +130,7 @@ class TvGuide:
if program.video_url: if program.video_url:
path = kodiutils.url_for('play_from_page', channel=channel, page=quote(program.video_url, safe='')) path = kodiutils.url_for('play_from_page', channel=channel, page=quote(program.video_url, safe=''))
else: else:
path = kodiutils.url_for('play_catalog', uuid='') path = None
title = '[COLOR gray]' + title + '[/COLOR]' title = '[COLOR gray]' + title + '[/COLOR]'
stream_dict = STREAM_DICT.copy() stream_dict = STREAM_DICT.copy()
@ -154,7 +153,8 @@ class TvGuide:
TitleItem(title=title, TitleItem(title=title,
path=path, path=path,
art_dict={ art_dict={
'thumb': program.thumb, 'icon': program.cover,
'thumb': program.cover,
}, },
info_dict=info_dict, info_dict=info_dict,
stream_dict=stream_dict, stream_dict=stream_dict,
@ -171,13 +171,9 @@ class TvGuide:
""" """
broadcast = self._epg.get_broadcast(channel, timestamp) broadcast = self._epg.get_broadcast(channel, timestamp)
if not broadcast: 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() kodiutils.end_of_directory()
return return
if not broadcast.video_url: kodiutils.container_update(
kodiutils.ok_dialog(message=kodiutils.localize(30712)) # The video is unavailable and can't be played right now. kodiutils.url_for('play', uuid=broadcast.video_url))
kodiutils.end_of_directory()
return
Player().play_from_page(broadcast.video_url)

View File

@ -5,13 +5,16 @@ from __future__ import absolute_import, division, unicode_literals
import hashlib import hashlib
import logging 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 import kodilogging, kodiutils
from resources.lib.viervijfzes.auth import AuthApi from resources.lib.viervijfzes.auth import AuthApi
_LOGGER = logging.getLogger(__name__) kodilogging.config()
_LOGGER = logging.getLogger('service')
class BackgroundService(Monitor): class BackgroundService(Monitor):
@ -22,23 +25,26 @@ class BackgroundService(Monitor):
self.update_interval = 24 * 3600 # Every 24 hours self.update_interval = 24 * 3600 # Every 24 hours
self.cache_expiry = 30 * 24 * 3600 # One month self.cache_expiry = 30 * 24 * 3600 # One month
self._auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path()) self._auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
self._kodiplayer = KodiPlayer()
def run(self): def run(self):
""" Background loop for maintenance tasks """ """ Background loop for maintenance tasks """
_LOGGER.debug('Service started') _LOGGER.info('Service started')
while not self.abortRequested(): 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 # Stop when abort requested
if self.waitForAbort(10): if self.waitForAbort(10):
break break
_LOGGER.debug('Service stopped') _LOGGER.info('Service stopped')
def onSettingsChanged(self): # pylint: disable=invalid-name def onSettingsChanged(self): # pylint: disable=invalid-name
""" Callback when a setting has changed """ """ Callback when a setting has changed """
if self._has_credentials_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() self._auth.clear_tokens()
# Refresh container # Refresh container
@ -56,90 +62,39 @@ class BackgroundService(Monitor):
return True return True
return False return False
def _update_metadata(self):
""" Update the metadata for the listings """
from resources.lib.modules.metadata import Metadata
class KodiPlayer(Player): def update_status(_i, _total):
"""Communication with Kodi Player""" """ Allow to cancel the background job """
return self.abortRequested() or not kodiutils.get_setting_bool('metadata_update')
def __init__(self): # Clear metadata that has expired for 30 days
"""KodiPlayer initialisation""" self._remove_expired_metadata(30 * 24 * 60 * 60)
Player.__init__(self)
self.listen = False
self.path = None
self.av_started = False
self.stream_path = None
def onPlayBackStarted(self): # pylint: disable=invalid-name # Fetch new metadata
"""Called when user starts playing a file""" success = Metadata().fetch_metadata(callback=update_status)
self.path = getInfoLabel('Player.FilenameAndPath')
if self.path.startswith('plugin://plugin.video.viervijfzes/'): # Update metadata_last_updated
self.listen = True if success:
else: kodiutils.set_setting('metadata_last_updated', str(int(time())))
self.listen = False
@staticmethod
def _remove_expired_metadata(keep_expired=None):
""" Clear the cache """
path = kodiutils.get_cache_path()
if not os.path.exists(path):
return return
_LOGGER.debug('KodiPlayer onPlayBackStarted')
self.av_started = False
self.stream_path = self.getPlayingFile()
def onAVStarted(self): # pylint: disable=invalid-name now = time()
"""Called when Kodi has a video or audiostream""" for filename in os.listdir(path):
if not self.listen: fullpath = path + filename
return if keep_expired and os.stat(fullpath).st_mtime + keep_expired > now:
_LOGGER.debug('KodiPlayer onAVStarted') continue
self.av_started = True os.unlink(fullpath)
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')
def run(): def run():
""" Run the BackgroundService """ """ Run the BackgroundService """
kodilogging.config()
BackgroundService().run() BackgroundService().run()

View File

@ -1,73 +1,46 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" GoPlay API """ """ SBS API """
from __future__ import absolute_import, division, unicode_literals from __future__ import absolute_import, division, unicode_literals
from collections import OrderedDict from collections import OrderedDict
CHANNELS = OrderedDict([ CHANNELS = OrderedDict([
('Play4', { ('vier', dict(
'name': 'Play4', name='VIER',
'url': 'live-kijken/play-4', url='https://www.vier.be',
'epg_id': 'vier', logo='vier.png',
'logo': 'play4.png', background='vier-background.jpg',
'background': 'play4-background.png', studio_icon='vier',
'iptv_preset': 4, youtube=[
'iptv_id': 'play4.be', dict(
'youtube': [ label='VIER / VIJF',
{'label': 'GoPlay', 'logo': 'goplay.png', 'path': 'plugin://plugin.video.youtube/user/viertv/'}, logo='vier.png',
] path='plugin://plugin.video.youtube/user/viertv/',
}), ),
('Play5', { ],
'name': 'Play5', )),
'url': 'live-kijken/play-5', ('vijf', dict(
'epg_id': 'vijf', name='VIJF',
'logo': 'play5.png', url='https://www.vijf.be',
'background': 'play5-background.png', logo='vijf.png',
'iptv_preset': 5, background='vijf-background.jpg',
'iptv_id': 'play5.be', studio_icon='vijf',
'youtube': [ youtube=[
{'label': 'GoPlay', 'logo': 'goplay.png', 'path': 'plugin://plugin.video.youtube/user/viertv/'}, dict(
] label='VIER / VIJF',
}), logo='vijf.png',
('Play6', { path='plugin://plugin.video.youtube/user/viertv/',
'name': 'Play6', ),
'url': 'live-kijken/play-6', ],
'epg_id': 'zes', )),
'logo': 'play6.png', ('zes', dict(
'background': 'play6-background.png', name='ZES',
'iptv_preset': 6, url='https://www.zestv.be',
'iptv_id': 'play6.be', logo='zes.png',
'youtube': [ background='zes-background.jpg',
{'label': 'GoPlay', 'logo': 'goplay.png', 'path': 'plugin://plugin.video.youtube/user/viertv/'}, studio_icon='zes',
] youtube=[],
}), ))
('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': []
})
]) ])
STREAM_DICT = { STREAM_DICT = {
@ -75,22 +48,3 @@ STREAM_DICT = {
'height': 544, 'height': 544,
'width': 960, '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__

View File

@ -9,19 +9,16 @@ import os
import time import time
from resources.lib import kodiutils from resources.lib import kodiutils
from resources.lib.viervijfzes.aws.cognito_identity import CognitoIdentity from resources.lib.viervijfzes.auth_awsidp import AwsIdp, InvalidLoginException, AuthenticationException
from resources.lib.viervijfzes.aws.cognito_idp import AuthenticationException, CognitoIdp, InvalidLoginException
from resources.lib.viervijfzes.aws.cognito_sync import CognitoSync
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger('auth-api')
class AuthApi: class AuthApi:
""" GoPlay Authentication API """ """ VIER/VIJF/ZES Authentication API """
COGNITO_REGION = 'eu-west-1' COGNITO_REGION = 'eu-west-1'
COGNITO_POOL_ID = 'eu-west-1_dViSsKM5Y' COGNITO_POOL_ID = 'eu-west-1_dViSsKM5Y'
COGNITO_CLIENT_ID = '6s1h851s8uplco5h6mqh1jac8m' COGNITO_CLIENT_ID = '6s1h851s8uplco5h6mqh1jac8m'
COGNITO_IDENTITY_POOL_ID = 'eu-west-1:8b7eb22c-cf61-43d5-a624-04b494867234'
TOKEN_FILE = 'auth-tokens.json' TOKEN_FILE = 'auth-tokens.json'
@ -36,13 +33,13 @@ class AuthApi:
# Load tokens from cache # Load tokens from cache
try: 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()) data_json = json.loads(fdesc.read())
self._id_token = data_json.get('id_token') self._id_token = data_json.get('id_token')
self._refresh_token = data_json.get('refresh_token') self._refresh_token = data_json.get('refresh_token')
self._expiry = int(data_json.get('expiry', 0)) self._expiry = int(data_json.get('expiry', 0))
except (IOError, TypeError, ValueError): 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): def get_token(self):
""" Get a valid token """ """ Get a valid token """
@ -77,55 +74,30 @@ class AuthApi:
# Store new tokens in cache # Store new tokens in cache
if not os.path.exists(self._token_path): if not os.path.exists(self._token_path):
os.makedirs(self._token_path) os.mkdir(self._token_path)
with open(os.path.join(self._token_path, self.TOKEN_FILE), 'w') as fdesc: with open(self._token_path + self.TOKEN_FILE, 'w') as fdesc:
data = json.dumps({ data = json.dumps(dict(
'id_token': self._id_token, id_token=self._id_token,
'refresh_token': self._refresh_token, refresh_token=self._refresh_token,
'expiry': self._expiry expiry=self._expiry,
}) ))
fdesc.write(kodiutils.from_unicode(data)) fdesc.write(kodiutils.from_unicode(data))
return self._id_token return self._id_token
def clear_tokens(self): def clear_tokens(self):
""" Remove the cached tokens. """ """ Remove the cached tokens. """
if os.path.exists(os.path.join(self._token_path, AuthApi.TOKEN_FILE)): if os.path.exists(self._token_path + AuthApi.TOKEN_FILE):
os.unlink(os.path.join(self._token_path, AuthApi.TOKEN_FILE)) os.unlink(self._token_path + AuthApi.TOKEN_FILE)
@staticmethod @staticmethod
def _authenticate(username, password): def _authenticate(username, password):
""" Authenticate with Amazon Cognito and fetch a refresh token and id token. """ """ Authenticate with Amazon Cognito and fetch a refresh token and id token. """
idp_client = CognitoIdp(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_CLIENT_ID) client = AwsIdp(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_CLIENT_ID)
return idp_client.authenticate(username, password) return client.authenticate(username, password)
@staticmethod @staticmethod
def _refresh(refresh_token): def _refresh(refresh_token):
""" Use the refresh token to fetch a new id token. """ """ Use the refresh token to fetch a new id token. """
idp_client = CognitoIdp(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_CLIENT_ID) client = AwsIdp(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_CLIENT_ID)
return idp_client.renew_token(refresh_token) return 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'))

View File

@ -12,11 +12,12 @@ import hmac
import json import json
import logging import logging
import os import os
import sys
import requests import requests
import six import six
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger('auth-awsidp')
class InvalidLoginException(Exception): class InvalidLoginException(Exception):
@ -27,14 +28,11 @@ class AuthenticationException(Exception):
""" Something went wrong while logging in """ """ Something went wrong while logging in """
class CognitoIdp: class AwsIdp:
""" Cognito IDP """ """ AWS Identity Provider """
def __init__(self, pool_id, client_id): 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>). :param str pool_id: The AWS user pool to connect to (format: <region>_<poolid>).
E.g.: eu-west-1_aLkOfYN3T E.g.: eu-west-1_aLkOfYN3T
:param str client_id: The client application ID (the ID of the application connecting) :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.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.small_a_value = self.__generate_random_small_a()
self.large_a_value = self.__calculate_a() self.large_a_value = self.__calculate_a()
_LOGGER.debug("Created %s", self)
def authenticate(self, username, password): def authenticate(self, username, password):
""" Authenticate with a username and password. """ """ Authenticate with a username and password. """
@ -293,7 +292,7 @@ class CognitoIdp:
@staticmethod @staticmethod
def __hex_hash(hex_string): def __hex_hash(hex_string):
return CognitoIdp.__hash_sha256(bytearray.fromhex(hex_string)) return AwsIdp.__hash_sha256(bytearray.fromhex(hex_string))
@staticmethod @staticmethod
def __hash_sha256(buf): def __hash_sha256(buf):
@ -313,7 +312,7 @@ class CognitoIdp:
# noinspection PyTypeChecker # noinspection PyTypeChecker
if not isinstance(long_int, six.string_types): 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: else:
hash_str = long_int hash_str = long_int
if len(hash_str) % 2 == 1: if len(hash_str) % 2 == 1:
@ -325,7 +324,7 @@ class CognitoIdp:
@staticmethod @staticmethod
def __get_random(nbytes): def __get_random(nbytes):
random_hex = binascii.hexlify(os.urandom(nbytes)) random_hex = binascii.hexlify(os.urandom(nbytes))
return CognitoIdp.__hex_to_long(random_hex) return AwsIdp.__hex_to_long(random_hex)
@staticmethod @staticmethod
def __get_current_timestamp(): def __get_current_timestamp():
@ -341,7 +340,11 @@ class CognitoIdp:
days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
time_now = datetime.datetime.utcnow() 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) time_string = datetime.datetime.utcnow().strftime(format_string)
return time_string return time_string

View File

@ -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')

View File

@ -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()

View File

@ -3,7 +3,6 @@
from __future__ import absolute_import, division, unicode_literals from __future__ import absolute_import, division, unicode_literals
import hashlib
import json import json
import logging import logging
import os import os
@ -11,27 +10,17 @@ import re
import time import time
from datetime import datetime from datetime import datetime
from six.moves.html_parser import HTMLParser
import requests import requests
from resources.lib import kodiutils from resources.lib.viervijfzes import CHANNELS
from resources.lib.kodiutils import STREAM_DASH, STREAM_HLS, html_to_kodi
from resources.lib.viervijfzes import ResolvedStream
try: # Python 3 _LOGGER = logging.getLogger('content-api')
from html import unescape
except ImportError: # Python 2
from HTMLParser import HTMLParser
unescape = HTMLParser().unescape
_LOGGER = logging.getLogger(__name__)
CACHE_AUTO = 1 # Allow to use the cache, and query the API if no cache is available 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_ONLY = 2 # Only use the cache, don't use the API
CACHE_PREVENT = 3 # Don't use the cache CACHE_PREVENT = 3 # Don't use the cache
PROXIES = kodiutils.get_proxies()
class UnavailableException(Exception): class UnavailableException(Exception):
""" Is thrown when an item is unavailable. """ """ Is thrown when an item is unavailable. """
@ -48,9 +37,7 @@ class GeoblockedException(Exception):
class Program: class Program:
""" Defines a 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, def __init__(self, uuid=None, path=None, channel=None, title=None, description=None, aired=None, cover=None, background=None, seasons=None, episodes=None):
episodes=None,
clips=None, my_list=False):
""" """
:type uuid: str :type uuid: str
:type path: str :type path: str
@ -58,13 +45,10 @@ class Program:
:type title: str :type title: str
:type description: str :type description: str
:type aired: datetime :type aired: datetime
:type poster: str :type cover: str
:type thumb: str :type background: str
:type fanart: str
:type seasons: list[Season] :type seasons: list[Season]
:type episodes: list[Episode] :type episodes: list[Episode]
:type clips: list[Episode]
:type my_list: bool
""" """
self.uuid = uuid self.uuid = uuid
self.path = path self.path = path
@ -72,13 +56,10 @@ class Program:
self.title = title self.title = title
self.description = description self.description = description
self.aired = aired self.aired = aired
self.poster = poster self.cover = cover
self.thumb = thumb self.background = background
self.fanart = fanart
self.seasons = seasons self.seasons = seasons
self.episodes = episodes self.episodes = episodes
self.clips = clips
self.my_list = my_list
def __repr__(self): def __repr__(self):
return "%r" % self.__dict__ return "%r" % self.__dict__
@ -87,13 +68,14 @@ class Program:
class Season: class Season:
""" Defines a 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 uuid: str
:type path: str :type path: str
:type channel: str :type channel: str
:type title: str :type title: str
:type description: str :type description: str
:type cover: str
:type number: int :type number: int
""" """
@ -102,6 +84,7 @@ class Season:
self.channel = channel self.channel = channel
self.title = title self.title = title
self.description = description self.description = description
self.cover = cover
self.number = number self.number = number
def __repr__(self): def __repr__(self):
@ -111,8 +94,8 @@ class Season:
class Episode: class Episode:
""" Defines an 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, 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, stream=None, content_type=None): season=None, season_uuid=None, number=None, rating=None, aired=None, expiry=None):
""" """
:type uuid: str :type uuid: str
:type nodeid: str :type nodeid: str
@ -121,7 +104,7 @@ class Episode:
:type program_title: str :type program_title: str
:type title: str :type title: str
:type description: str :type description: str
:type thumb: str :type cover: str
:type duration: int :type duration: int
:type season: int :type season: int
:type season_uuid: str :type season_uuid: str
@ -129,8 +112,6 @@ class Episode:
:type rating: str :type rating: str
:type aired: datetime :type aired: datetime
:type expiry: datetime :type expiry: datetime
:type stream: string
:type content_type: string
""" """
self.uuid = uuid self.uuid = uuid
self.nodeid = nodeid self.nodeid = nodeid
@ -139,7 +120,7 @@ class Episode:
self.program_title = program_title self.program_title = program_title
self.title = title self.title = title
self.description = description self.description = description
self.thumb = thumb self.cover = cover
self.duration = duration self.duration = duration
self.season = season self.season = season
self.season_uuid = season_uuid self.season_uuid = season_uuid
@ -147,38 +128,19 @@ class Episode:
self.rating = rating self.rating = rating
self.aired = aired self.aired = aired
self.expiry = expiry 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): def __repr__(self):
return "%r" % self.__dict__ return "%r" % self.__dict__
class ContentApi: class ContentApi:
""" GoPlay Content API""" """ VIER/VIJF/ZES Content API"""
SITE_URL = 'https://www.goplay.be' API_ENDPOINT = 'https://api.viervijfzes.be'
API_GOPLAY = 'https://api.goplay.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): def __init__(self, auth=None, cache_path=None):
""" Initialise object """ """ Initialise object """
@ -186,482 +148,124 @@ class ContentApi:
self._auth = auth self._auth = auth
self._cache_path = cache_path 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. """ Get a list of all programs of the specified channel.
:type channel: str :type channel: str
:type cache: str :type cache: str
:rtype list[Program] :rtype list[Program]
""" """
if channel not in CHANNELS:
raise Exception('Unknown channel %s' % channel)
def update(): def update():
""" Fetch the program listing by scraping """ """ Fetch the program listing by scraping """
# Load webpage # Load webpage
raw_html = self._get_url(self.SITE_URL + '/programmas') raw_html = self._get_url(CHANNELS[channel]['url'])
# Parse programs # Parse programs
regex_programs = re.compile(r'data-program="(?P<json>[^"]+)"', re.DOTALL) parser = HTMLParser()
regex_programs = re.compile(r'<a class="program-overview__link" href="(?P<path>[^"]+)">\s+'
data = [ r'<span class="program-overview__title">\s+(?P<title>[^<]+)</span>.*?'
json.loads(unescape(item.group('json'))) r'</a>', re.DOTALL)
data = {
item.group('path').lstrip('/'): parser.unescape(item.group('title').strip())
for item in regex_programs.finditer(raw_html) for item in regex_programs.finditer(raw_html)
] }
if not data: if not data:
raise Exception('No programs found') raise Exception('No programs found for %s' % channel)
return data return data
# Fetch listing from cache or update if needed # 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: if not data:
return [] return []
if channel: programs = []
programs = [ for path in data:
self._parse_program_data(record) for record in data if record['pageInfo']['brand'] == channel 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: else:
programs = [ # Use program with the values that we've parsed from the page
self._parse_program_data(record) for record in data programs.append(Program(channel=channel,
] path=path,
title=title))
return programs 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. """ Get a Program object from the specified page.
:type channel: str
:type path: str :type path: str
:type extract_clips: bool
:type cache: int :type cache: int
:rtype Program :rtype Program
""" """
# We want to use the html to extract clips if channel not in CHANNELS:
# This is the worst hack, since Python 2.7 doesn't support nonlocal raise Exception('Unknown channel %s' % channel)
raw_html = [None]
def update(): def update():
""" Fetch the program metadata by scraping """ """ Fetch the program metadata by scraping """
# Fetch webpage # Fetch webpage
page = self._get_url(self.SITE_URL + '/' + path) page = self._get_url(CHANNELS[channel]['url'] + '/' + path)
# Store a copy in the parent's raw_html var.
raw_html[0] = page
# Extract JSON # Extract JSON
regex_program = re.compile(r'data-hero="([^"]+)', re.DOTALL) 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'] data = json.loads(json_data)['data']
return data return data
# Fetch listing from cache or update if needed # Fetch listing from cache or update if needed
data = self._handle_cache(key=['program', path], cache_mode=cache, update=update) data = self._handle_cache(key=['program', channel, 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
program = self._parse_program_data(data) program = self._parse_program_data(data)
return program return program
def get_episode(self, path, cache=CACHE_AUTO): def get_episode(self, channel, path):
""" Get a Episode object from the specified page. """ Get a Episode object from the specified page.
:type channel: str
:type path: str :type path: str
:type cache: str
:rtype Episode :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 # Load webpage
page = self._get_url(self.SITE_URL + '/' + path) page = self._get_url(CHANNELS[channel]['url'] + '/' + path)
program_json = None
episode_json = None
# 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 program JSON # Extract program JSON
parser = HTMLParser()
regex_program = re.compile(r'data-hero="([^"]+)', re.DOTALL) regex_program = re.compile(r'data-hero="([^"]+)', re.DOTALL)
result = regex_program.search(page) json_data = parser.unescape(regex_program.search(page).group(1))
if result: data = json.loads(json_data)['data']
program_json_data = unescape(result.group(1)) program = self._parse_program_data(data)
program_json = json.loads(program_json_data)['data']
# Extract episode JSON # Extract episode JSON
regex_episode = re.compile(r'<script type="application/json" data-drupal-selector="drupal-settings-json">(.*?)</script>', re.DOTALL) regex_episode = re.compile(r'<script type="application/json" data-drupal-selector="drupal-settings-json">(.*?)</script>', re.DOTALL)
result = regex_episode.search(page) json_data = parser.unescape(regex_episode.search(page).group(1))
if result: data = json.loads(json_data)
episode_json_data = unescape(result.group(1))
episode_json = json.loads(episode_json_data)
return {'program': program_json, 'episode': episode_json} # Lookup the episode in the program JSON based on the nodeId
# The episode we just found doesn't contain all information
# 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: for episode in program.episodes:
if episode.nodeid == data['episode']['pageInfo']['nodeId']: if episode.nodeid == data['pageInfo']['nodeId']:
return episode return episode
return None return None
def get_stream_by_uuid(self, uuid, content_type): def get_stream_by_uuid(self, uuid):
""" Return a ResolvedStream for this video. """ Get the stream URL to use for this video.
:type uuid: string :type uuid: str
:type content_type: string :rtype str
:rtype: ResolvedStream
""" """
if content_type in ('video-long_form', 'long_form'): response = self._get_url(self.API_ENDPOINT + '/content/%s' % uuid, authentication=True)
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())
data = json.loads(response) data = json.loads(response)
return data['video']['S']
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
@staticmethod @staticmethod
def _parse_program_data(data): def _parse_program_data(data):
@ -669,48 +273,51 @@ class ContentApi:
:type data: dict :type data: dict
:rtype Program :rtype Program
""" """
if data is None:
return None
# Create Program info # Create Program info
program = Program( program = Program(
uuid=data.get('id'), uuid=data['id'],
path=data.get('link').lstrip('/'), path=data['link'].lstrip('/'),
channel=data.get('pageInfo').get('brand'), channel=data['pageInfo']['site'],
title=data.get('title'), title=data['title'],
description=html_to_kodi(data.get('description')), description=data['description'],
aired=datetime.fromtimestamp(data.get('pageInfo', {}).get('publishDate', 0.0)), aired=datetime.fromtimestamp(data.get('pageInfo', {}).get('publishDate')),
poster=data.get('images').get('poster'), cover=data['images']['poster'],
thumb=data.get('images').get('teaser'), background=data['images']['hero'],
fanart=data.get('images').get('teaser'),
) )
# Create Season info # Create Season info
program.seasons = { program.seasons = {
key: Season( key: Season(
uuid=playlist.get('id'), uuid=playlist['id'],
path=playlist.get('link').lstrip('/'), path=playlist['link'].lstrip('/'),
channel=playlist.get('pageInfo').get('brand'), channel=playlist['pageInfo']['site'],
title=playlist.get('title'), title=playlist['title'],
description=html_to_kodi(playlist.get('description')), description=playlist['pageInfo']['description'],
number=playlist.get('episodes')[0].get('seasonNumber'), # You did not see this 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 # Create Episodes info
program.episodes = [ program.episodes = [
ContentApi._parse_episode_data(episode, playlist.get('id')) ContentApi._parse_episode_data(episode, playlist['id'])
for playlist in data.get('playlists', []) for playlist in data['playlists']
for episode in playlist.get('episodes') for episode in playlist['episodes']
] ]
return program return program
@staticmethod @staticmethod
def _parse_episode_data(data, season_uuid=None): def _parse_episode_data(data, season_uuid):
""" Parse the Episode JSON. """ Parse the Episode JSON.
:type data: dict :type data: dict
:type season_uuid: str :type season_uuid: str
:rtype Episode :rtype Episode
""" """
if data.get('episodeNumber'): if data.get('episodeNumber'):
episode_number = data.get('episodeNumber') episode_number = data.get('episodeNumber')
else: else:
@ -726,113 +333,33 @@ class ContentApi:
nodeid=data.get('pageInfo', {}).get('nodeId'), nodeid=data.get('pageInfo', {}).get('nodeId'),
path=data.get('link').lstrip('/'), path=data.get('link').lstrip('/'),
channel=data.get('pageInfo', {}).get('site'), 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'), title=data.get('title'),
description=html_to_kodi(data.get('description')), description=data.get('pageInfo', {}).get('description'),
thumb=data.get('image'), cover=data.get('image'),
duration=data.get('duration'), duration=data.get('duration'),
season=data.get('seasonNumber'), season=data.get('seasonNumber'),
season_uuid=season_uuid, season_uuid=season_uuid,
number=episode_number, number=episode_number,
aired=datetime.fromtimestamp(int(data.get('createdDate'))), aired=datetime.fromtimestamp(data.get('createdDate')),
expiry=datetime.fromtimestamp(int(data.get('unpublishDate'))) if data.get('unpublishDate') else None, expiry=datetime.fromtimestamp(data.get('unpublishDate')) if data.get('unpublishDate') else None,
rating=data.get('parentalRating'), rating=data.get('parentalRating')
stream=data.get('path'),
content_type=data.get('type'),
) )
return episode return episode
@staticmethod def _get_url(self, url, params=None, authentication=False):
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):
""" Makes a GET request for the specified URL. """ Makes a GET request for the specified URL.
:type url: str :type url: str
:type authentication: str
:rtype str :rtype str
""" """
if authentication: if authentication:
if not self._auth:
raise Exception('Requested to authenticate, but not auth object passed')
response = self._session.get(url, params=params, headers={ response = self._session.get(url, params=params, headers={
'authorization': authentication, 'authorization': self._auth.get_token(),
}, proxies=PROXIES) })
else: else:
response = self._session.get(url, params=params, proxies=PROXIES) response = self._session.get(url, params=params)
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)
if response.status_code != 200: if response.status_code != 200:
_LOGGER.error(response.text) _LOGGER.error(response.text)
@ -866,8 +393,8 @@ class ContentApi:
def _get_cache(self, key, allow_expired=False): def _get_cache(self, key, allow_expired=False):
""" Get an item from the cache """ """ Get an item from the cache """
filename = ('.'.join(key) + '.json').replace('/', '_') filename = '.'.join(key) + '.json'
fullpath = os.path.join(self._cache_path, filename) fullpath = self._cache_path + filename
if not os.path.exists(fullpath): if not os.path.exists(fullpath):
return None return None
@ -885,11 +412,11 @@ class ContentApi:
def _set_cache(self, key, data, ttl): def _set_cache(self, key, data, ttl):
""" Store an item in the cache """ """ Store an item in the cache """
filename = ('.'.join(key) + '.json').replace('/', '_') filename = '.'.join(key) + '.json'
fullpath = os.path.join(self._cache_path, filename) fullpath = self._cache_path + filename
if not os.path.exists(self._cache_path): if not os.path.exists(self._cache_path):
os.makedirs(self._cache_path) os.mkdir(self._cache_path)
with open(fullpath, 'w') as fdesc: with open(fullpath, 'w') as fdesc:
_LOGGER.debug('Storing to cache as %s', filename) _LOGGER.debug('Storing to cache as %s', filename)

View File

@ -7,42 +7,18 @@ import json
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
import dateutil.parser import dateutil
import dateutil.tz
import requests import requests
from resources.lib import kodiutils _LOGGER = logging.getLogger('epg-api')
_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()
class EpgProgram: class EpgProgram:
""" Defines a Program in the EPG. """ """ Defines a Program in the EPG. """
# pylint: disable=invalid-name # pylint: disable=invalid-name
def __init__(self, channel, program_title, episode_title, episode_title_original, number, season, genre, start, def __init__(self, channel, program_title, episode_title, episode_title_original, number, season, genre, start, won_id, won_program_id, program_description,
won_id, won_program_id, program_description, description, duration, program_url, video_url, thumb, description, duration, program_url, video_url, cover, airing):
airing):
self.channel = channel self.channel = channel
self.program_title = program_title self.program_title = program_title
self.episode_title = episode_title self.episode_title = episode_title
@ -58,31 +34,22 @@ class EpgProgram:
self.duration = duration self.duration = duration
self.program_url = program_url self.program_url = program_url
self.video_url = video_url self.video_url = video_url
self.thumb = thumb self.cover = cover
self.airing = airing 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): def __repr__(self):
return "%r" % self.__dict__ return "%r" % self.__dict__
class EpgApi: class EpgApi:
""" GoPlay EPG API """ """ VIER/VIJF/ZES EPG API """
EPG_ENDPOINTS = { EPG_ENDPOINTS = {
'Play4': 'https://www.goplay.be/api/epg/vier/{date}', 'vier': 'https://www.vier.be/api/epg/{date}',
'Play5': 'https://www.goplay.be/api/epg/vijf/{date}', 'vijf': 'https://www.vijf.be/api/epg/{date}',
'Play6': 'https://www.goplay.be/api/epg/zes/{date}', 'zes': 'https://www.zestv.be/api/epg/{date}',
'Play7': 'https://www.goplay.be/api/epg/zeven/{date}',
'PlayCrime': 'https://www.goplay.be/api/epg/crime/{date}',
} }
EPG_NO_BROADCAST = 'Geen uitzending'
def __init__(self): def __init__(self):
""" Initialise object """ """ Initialise object """
self._session = requests.session() self._session = requests.session()
@ -111,7 +78,7 @@ class EpgApi:
data = json.loads(response) data = json.loads(response)
# Parse the results # 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 @staticmethod
def _parse_program(channel, data): def _parse_program(channel, data):
@ -123,8 +90,8 @@ class EpgApi:
duration = int(data.get('duration')) if data.get('duration') else None duration = int(data.get('duration')) if data.get('duration') else None
# Check if this broadcast is currently airing # Check if this broadcast is currently airing
timestamp = datetime.now().replace(tzinfo=dateutil.tz.gettz('CET')) timestamp = datetime.now()
start = datetime.fromtimestamp(data.get('timestamp')).replace(tzinfo=dateutil.tz.gettz('CET')) start = datetime.fromtimestamp(data.get('timestamp'))
if duration: if duration:
airing = bool(start <= timestamp < (start + timedelta(seconds=duration))) airing = bool(start <= timestamp < (start + timedelta(seconds=duration)))
else: else:
@ -133,10 +100,10 @@ class EpgApi:
# Only allow direct playing if the linked video is the actual program # Only allow direct playing if the linked video is the actual program
if data.get('video_node', {}).get('latest_video'): if data.get('video_node', {}).get('latest_video'):
video_url = (data.get('video_node', {}).get('url') or '').lstrip('/') 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: else:
video_url = None video_url = None
thumb = None cover = None
return EpgProgram( return EpgProgram(
channel=channel, channel=channel,
@ -144,7 +111,7 @@ class EpgApi:
episode_title=data.get('episode_title'), episode_title=data.get('episode_title'),
episode_title_original=data.get('original_title'), episode_title_original=data.get('original_title'),
number=int(data.get('episode_nr')) if data.get('episode_nr') else None, 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'), genre=data.get('genre'),
start=start, start=start,
won_id=int(data.get('won_id')) if data.get('won_id') else None, won_id=int(data.get('won_id')) if data.get('won_id') else None,
@ -154,7 +121,7 @@ class EpgApi:
duration=duration, duration=duration,
program_url=(data.get('program_node', {}).get('url') or '').lstrip('/'), program_url=(data.get('program_node', {}).get('url') or '').lstrip('/'),
video_url=video_url, video_url=video_url,
thumb=thumb, cover=cover,
airing=airing, airing=airing,
) )
@ -165,14 +132,14 @@ class EpgApi:
:rtype: EpgProgram :rtype: EpgProgram
""" """
# Parse to a real datetime # 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 # Load guide info for this date
programs = self.get_epg(channel=channel, date=timestamp.strftime('%Y-%m-%d')) programs = self.get_epg(channel=channel, date=timestamp.strftime('%Y-%m-%d'))
# Find a matching broadcast # Find a matching broadcast
for broadcast in programs: 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 broadcast
return None return None
@ -182,7 +149,7 @@ class EpgApi:
:type url: str :type url: str
:rtype str :rtype str
""" """
response = self._session.get(url, proxies=PROXIES) response = self._session.get(url)
if response.status_code != 200: if response.status_code != 200:
raise Exception('Could not fetch data') raise Exception('Could not fetch data')

View File

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" Search API """ """ AUTH API """
from __future__ import absolute_import, division, unicode_literals from __future__ import absolute_import, division, unicode_literals
@ -8,21 +8,17 @@ import logging
import requests import requests
from resources.lib import kodiutils from resources.lib.viervijfzes.content import Program
from resources.lib.viervijfzes.content import CACHE_ONLY, ContentApi, Program
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger('search-api')
PROXIES = kodiutils.get_proxies()
class SearchApi: class SearchApi:
""" GoPlay Search API """ """ VIER/VIJF/ZES Search API """
API_ENDPOINT = 'https://api.goplay.be/search' API_ENDPOINT = 'https://api.viervijfzes.be/search'
def __init__(self): def __init__(self):
""" Initialise object """ """ Initialise object """
self._api = ContentApi(None, cache_path=kodiutils.get_cache_path())
self._session = requests.session() self._session = requests.session()
def search(self, query): def search(self, query):
@ -37,28 +33,26 @@ class SearchApi:
self.API_ENDPOINT, self.API_ENDPOINT,
json={ json={
"query": query, "query": query,
"sites": ["vier", "vijf", "zes"],
"page": 0, "page": 0,
"mode": "programs" "mode": "byDate"
}, }
proxies=PROXIES
) )
response.raise_for_status()
if response.status_code != 200:
raise Exception('Could not search')
data = json.loads(response.text) data = json.loads(response.text)
results = [] results = []
for hit in data['hits']['hits']: for hit in data['hits']['hits']:
if hit['_source']['bundle'] == 'program': 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( results.append(Program(
path=path, channel=hit['_source']['site'],
path=hit['_source']['url'].strip('/'),
title=hit['_source']['title'], title=hit['_source']['title'],
description=hit['_source']['intro'], description=hit['_source']['intro'],
poster=hit['_source']['img'], cover=hit['_source']['img'],
)) ))
return results return results

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
resources/logos/vier.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
resources/logos/vijf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
resources/logos/zes.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@ -1,26 +1,18 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?> <?xml version="1.0" encoding="utf-8" standalone="yes"?>
<settings> <settings>
<setting id="metadata_last_updated" visible="false"/>
<category label="30800"> <!-- Credentials --> <category label="30800"> <!-- Credentials -->
<setting label="30801" type="lsep"/> <!-- Credentials --> <setting label="30801" type="lsep"/> <!-- Credentials -->
<setting label="30802" type="text" id="username"/> <setting label="30803" type="text" id="username"/>
<setting label="30803" type="text" id="password" option="hidden"/> <setting label="30805" type="text" id="password" option="hidden"/>
</category> </category>
<category label="30820"> <!-- Interface --> <category label="30820"> <!-- Interface -->
<setting label="30820" type="lsep"/> <!-- Interface --> <setting label="30827" type="lsep"/> <!-- Metadata -->
<setting label="30821" type="bool" id="interface_show_unavailable" default="true"/> <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>
<category label="30840"> <!-- Integrations --> <category label="30840"> <!-- Playback from cache -->
<setting label="30841" type="lsep"/> <!-- IPTV Manager --> <setting label="30841" type="bool" id="episode_cache_enabled" default="true"/>
<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="folder" id="episode_cache_folder" source="local" option="writeable" enable="eq(-1,true)"/>
<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> </category>
</settings> </settings>

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -4,30 +4,5 @@
from __future__ import absolute_import, division, unicode_literals from __future__ import absolute_import, division, unicode_literals
import logging 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) 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'))

View File

@ -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>

View File

@ -1,3 +0,0 @@
<settings version="2">
<setting id="videolibrary.showallitems" default="true">true</setting>
</settings>

View File

@ -1,11 +1,12 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- 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 # pylint: disable=invalid-name
from __future__ import absolute_import, division, print_function, unicode_literals from __future__ import absolute_import, division, print_function, unicode_literals
import os import os
import sys 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) sys.path.insert(0, cwd)
from resources.lib import addon # noqa: E402 pylint: disable=wrong-import-position 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: if len(sys.argv) <= 1:
print("%s: URI argument missing\nTry '%s plugin://plugin.video.viervijfzes/' to test." % (sys.argv[0], sys.argv[0])) print("%s: URI argument missing\nTry '%s plugin://plugin.video.viervijfzes/' to test." % (sys.argv[0], sys.argv[0]))
sys.exit(1) sys.exit(1)

View File

@ -9,11 +9,10 @@ import logging
import unittest import unittest
import resources.lib.kodiutils as kodiutils import resources.lib.kodiutils as kodiutils
from resources.lib.viervijfzes import ResolvedStream
from resources.lib.viervijfzes.auth import AuthApi 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): class TestApi(unittest.TestCase):
@ -23,60 +22,27 @@ class TestApi(unittest.TestCase):
self._api = ContentApi(auth, cache_path=kodiutils.get_cache_path()) self._api = ContentApi(auth, cache_path=kodiutils.get_cache_path())
def test_programs(self): def test_programs(self):
programs = self._api.get_programs() for channel in ['vier', 'vijf', 'zes']:
self.assertIsInstance(programs, list) programs = self._api.get_programs(channel)
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)
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, list)
self.assertIsInstance(programs[0], Program) self.assertIsInstance(programs[0], Program)
def test_episodes(self): def test_episodes(self):
for program in ['gentwest', 'zo-man-zo-vrouw']: for channel, program in [('vier', 'auwch'), ('vijf', 'zo-man-zo-vrouw')]:
program = self._api.get_program(program, cache=CACHE_PREVENT) program = self._api.get_program(channel, program)
self.assertIsInstance(program, Program) self.assertIsInstance(program, Program)
self.assertIsInstance(program.seasons, dict) self.assertIsInstance(program.seasons, dict)
self.assertIsInstance(program.episodes, list) self.assertIsInstance(program.episodes, list)
self.assertIsInstance(program.episodes[0], Episode) 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.') @unittest.skipUnless(kodiutils.get_setting('username') and kodiutils.get_setting('password'), 'Skipping since we have no credentials.')
def test_get_stream(self): def test_get_stream(self):
program = self._api.get_program('gentwest') program = self._api.get_program('vier', 'auwch')
self.assertIsInstance(program, Program) self.assertIsInstance(program, Program)
episode = program.episodes[0] episode = program.episodes[0]
resolved_stream = self._api.get_stream_by_uuid(episode.uuid, episode.islongform) video = self._api.get_stream_by_uuid(episode.uuid)
self.assertIsInstance(resolved_stream, ResolvedStream) self.assertTrue(video)
@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)
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -11,7 +11,7 @@ import unittest
from resources.lib import kodiutils from resources.lib import kodiutils
from resources.lib.viervijfzes.auth import AuthApi from resources.lib.viervijfzes.auth import AuthApi
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger('test-auth')
class TestAuth(unittest.TestCase): class TestAuth(unittest.TestCase):
@ -26,12 +26,12 @@ class TestAuth(unittest.TestCase):
auth.clear_tokens() auth.clear_tokens()
# We should get a token by logging in # We should get a token by logging in
id_token = auth.get_token() token = auth.get_token()
self.assertTrue(id_token) self.assertTrue(token)
# Test it a second time, it should go from memory now # Test it a second time, it should go from memory now
id_token = auth.get_token() token = auth.get_token()
self.assertTrue(id_token) self.assertTrue(token)
if __name__ == '__main__': if __name__ == '__main__':

52
tests/test_downloader.py Normal file
View 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()

View File

@ -13,7 +13,7 @@ from resources.lib import kodiutils
from resources.lib.viervijfzes.content import ContentApi, Episode from resources.lib.viervijfzes.content import ContentApi, Episode
from resources.lib.viervijfzes.epg import EpgApi, EpgProgram from resources.lib.viervijfzes.epg import EpgApi, EpgProgram
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger('test-epg')
class TestEpg(unittest.TestCase): class TestEpg(unittest.TestCase):
@ -22,17 +22,17 @@ class TestEpg(unittest.TestCase):
self._epg = EpgApi() self._epg = EpgApi()
def test_vier_today(self): 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, list)
self.assertIsInstance(programs[0], EpgProgram) self.assertIsInstance(programs[0], EpgProgram)
def test_vijf_today(self): 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, list)
self.assertIsInstance(programs[0], EpgProgram) self.assertIsInstance(programs[0], EpgProgram)
def test_zes_today(self): 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, list)
self.assertIsInstance(programs[0], EpgProgram) self.assertIsInstance(programs[0], EpgProgram)
@ -41,31 +41,18 @@ class TestEpg(unittest.TestCase):
self._epg.get_epg('vtm', date.today().strftime('%Y-%m-%d')) self._epg.get_epg('vtm', date.today().strftime('%Y-%m-%d'))
def test_vier_out_of_range(self): 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, []) self.assertEqual(programs, [])
def test_play_video_from_epg(self): 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] epg_program = [program for program in epg_programs if program.video_url][0]
# Lookup the Episode data since we don't have an UUID # Lookup the Episode data since we don't have an UUID
api = ContentApi(cache_path=kodiutils.get_cache_path()) 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) 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__': if __name__ == '__main__':
unittest.main() unittest.main()

View File

@ -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()

View File

@ -9,6 +9,11 @@ import unittest
from resources.lib import addon 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 routing = addon.routing # pylint: disable=invalid-name
@ -29,30 +34,30 @@ class TestRouting(unittest.TestCase):
def test_channels_menu(self): def test_channels_menu(self):
routing.run([routing.url_for(addon.show_channels), '0', '']) 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): def test_catalog_menu(self):
routing.run([routing.url_for(addon.show_catalog), '0', '']) 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): 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): 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): 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): def test_search_menu(self):
routing.run([routing.url_for(addon.show_search), '0', '']) routing.run([routing.url_for(addon.show_search), '0', ''])
routing.run([routing.url_for(addon.show_search, query='de mol'), '0', '']) routing.run([routing.url_for(addon.show_search, query='de mol'), '0', ''])
def test_tvguide_menu(self): def test_tvguide_menu(self):
routing.run([routing.url_for(addon.show_channel_tvguide, channel='Play4'), '0', '']) routing.run([routing.url_for(addon.show_tvguide_channel, channel='vier'), '0', ''])
routing.run([routing.url_for(addon.show_channel_tvguide_detail, channel='Play4', date='today'), '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__': if __name__ == '__main__':

View File

@ -11,7 +11,7 @@ import unittest
from resources.lib.viervijfzes.content import Program from resources.lib.viervijfzes.content import Program
from resources.lib.viervijfzes.search import SearchApi from resources.lib.viervijfzes.search import SearchApi
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger('test-search')
class TestSearch(unittest.TestCase): class TestSearch(unittest.TestCase):

View File

@ -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()

View File

@ -0,0 +1,10 @@
{
"plugin.video.viervijfzes": {
"_comment": "do-not-add-username-and-password-here",
"metadata_update": "true"
},
"plugin.video.youtube": {
},
"service.upnext": {
}
}

View File

@ -0,0 +1,4 @@
{
"username": "username",
"password": "password"
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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