Compare commits

...

101 Commits

Author SHA1 Message Date
1118468427
Added playcrime channel 2024-05-08 15:39:53 +02:00
97d80f2115
Merge remote-tracking branch 'mediaminister/live' into main 2024-05-08 14:53:23 +02:00
mediaminister
0ddcdb1b0b Add live channels 2023-09-21 15:28:52 +02:00
Michaël Arnauts
e01b6b5c81 Prepare for v0.4.11 2023-07-26 15:18:52 +02:00
Michaël Arnauts
dd1f49b362
Drop testing for Python 2.7 (#127)
* Drop testing for python 2.7
2023-07-21 22:59:27 +02:00
Michaël Arnauts
bc82711886
Add support for proxies (#126)
* Add support for proxies
2023-07-21 22:54:53 +02:00
Michaël Arnauts
48c993a4e7 Fix pylint warnings/errors 2023-07-21 22:28:50 +02:00
mediaminister
c9ae78f83b
Add DRM support for all streams (#121) 2023-07-21 22:16:07 +02:00
Michaël Arnauts
dc446332ab Fix tests 2023-07-21 22:15:07 +02:00
mediaminister
7504d1a6ec
Use inputstreamhelper for unprotected MPEG-DASH (#118) 2023-07-21 22:12:09 +02:00
Michaël Arnauts
9cd8688fee Prepare for v0.4.10 2023-01-16 18:24:39 +01:00
Michaël Arnauts
be42bc6e1c Remove obsolete API_VIERVIJFZES 2023-01-16 18:23:30 +01:00
Michaël Arnauts
7f61533c4a
Update README.md 2023-01-16 18:03:30 +01:00
mediaminister
9cdebb6c7f
Update api (#114) 2023-01-16 17:59:29 +01:00
Michaël Arnauts
89f4b457df Update README.md 2023-01-04 14:22:01 +01:00
Michaël Arnauts
5ce74e4ec3 Update CI pipeline 2023-01-04 14:11:54 +01:00
Michaël Arnauts
f7f1921c2b Prepare for v0.4.9 2023-01-04 14:01:05 +01:00
mediaminister
b7a479e585
Add support for unprotected MPEG-DASH streams (#111) 2022-12-15 11:36:51 +01:00
Michaël Arnauts
98a0ffb162
Support Python 3.10 (#107)
* Support Python 3.10

* Add timeout

* Fix test
2022-10-16 15:36:00 +02:00
Michaël Arnauts
9f4bba446a
Fix clips (#108) 2022-10-16 15:32:56 +02:00
Michaël Arnauts
6d1c08e9c7 Prepare for v0.4.8 2022-07-07 16:00:54 +02:00
Michaël Arnauts
7ac3a21d1a Fix publish.py script 2022-07-07 15:52:36 +02:00
Michaël Arnauts
a26121ee37
Fix API (#105) 2022-07-07 15:51:56 +02:00
Michaël Arnauts
d5a905db5c Prepare for v0.4.7 2022-02-04 21:36:53 +01:00
Michaël Arnauts
02b2d4dbb1
Fix empty My List due to unknown items (#102)
* Ignore unavailable items on My List

* Rework My List API
2022-02-03 18:38:34 +01:00
Michaël Arnauts
21f877fb8d Update LICENSE to latest version 2022-02-02 17:54:07 +01:00
Michaël Arnauts
cfeae82636 Prepare for v0.4.6 2022-02-02 17:52:24 +01:00
Michaël Arnauts
16bb1ceab3
Modify repository so HEAD contains the Matrix version (#100) 2022-02-02 17:49:48 +01:00
Michaël Arnauts
bcb5caceeb
Fix playback of DRM protected content (#101) 2022-02-02 17:45:56 +01:00
Michaël Arnauts
4b24396aa0 Update to actions/setup-python@v2 2022-02-02 17:09:01 +01:00
michaelarnauts
d93de09d2f Prepare for v0.4.5 2021-10-21 15:25:40 +02:00
Michaël Arnauts
8b22d285e7
Remove dependency on inputstream.adaptive (#98) 2021-10-21 15:24:46 +02:00
Michaël Arnauts
13af7437af
Various fixes due to layout changes (#97)
* Various fixes due to layout changes

* Update .pylintrc
2021-10-21 15:19:10 +02:00
mediaminister
e07edfa658
Prepare for v0.4.4 (#95) 2021-09-15 16:39:40 +02:00
mediaminister
3a9848d0f0
Fix tests (#94) 2021-09-15 16:28:47 +02:00
mediaminister
9c44d323e6
Fix menu (#93) 2021-09-15 16:18:53 +02:00
michaelarnauts
f221d0040f Fix test 2021-07-12 20:15:55 +02:00
michaelarnauts
94752d2c6a Update .pylintrc 2021-07-12 20:09:54 +02:00
michaelarnauts
bfe7036999 Add issue templates 2021-07-12 19:46:38 +02:00
michaelarnauts
c5dee12e46 Prepare for v0.4.3 2021-04-24 18:30:45 +02:00
Michaël Arnauts
37121fa6dd
Fetch a week of EPG data for IPTV Manager (#89) 2021-04-24 18:27:26 +02:00
michaelarnauts
9d6eabc43e Update README.md 2021-04-24 18:24:04 +02:00
Michaël Arnauts
0edbad10df
Improve playback error handling (#87) 2021-04-23 23:27:55 +02:00
Michaël Arnauts
d5551f4e9e
Add support for Play7 (#86) 2021-04-06 18:07:15 +02:00
Michaël Arnauts
f688036559 Prepare for v0.4.2 2021-03-22 13:48:51 +01:00
Michaël Arnauts
4a47c5c933
Don't use cached episode info for playback (#82) 2021-03-22 13:40:26 +01:00
Michaël Arnauts
8f59c16eab
Improve artwork (#79)
* Update channel logo's
* Improve artwork selection
* Filter out <br /> in descriptions
2021-03-19 16:45:26 +01:00
Michaël Arnauts
d8b95cd2e9 Fix README.md 2021-02-27 10:57:27 +01:00
Michaël Arnauts
6334a215c6 Prepare for v0.4.1 2021-02-27 10:56:45 +01:00
Michaël Arnauts
2f0e236718 Update test 2021-02-25 13:00:48 +01:00
Michaël Arnauts
88e1bbc4d6
Add recommendations and categories (#76) 2021-02-17 07:42:24 +01:00
Dag Wieers
9ddc73094d
Fix incomplete descriptions (#75)
* Fix incomplete descriptions

This PR includes:
- Use full description for episodes rather than cut-off description
- Use the program description if the season description is missing

* Update kodiutils.py

Co-authored-by: Michaël Arnauts <michael.arnauts@gmail.com>
2021-02-16 16:57:52 +01:00
Michaël Arnauts
1a541552d7
Remove background metadata downloading (#74)
* Don't download metadata in the background anymore

* Remove warning logging
2021-02-15 13:42:10 +01:00
Michaël Arnauts
a80100247e
Fix error when requesting a My List that has not been created yet. (#73) 2021-02-11 18:25:51 +01:00
Michaël Arnauts
d8a9ab4e10
Create FUNDING.yml 2021-02-09 21:13:32 +01:00
Michaël Arnauts
7ad56fbca2
Cleanup CI test (#72) 2021-02-09 21:09:58 +01:00
Michaël Arnauts
1140b9b07a
Implement My List (#71)
* Fetch my list from amazon cognito sync
* Allow updating My List
2021-02-09 20:54:40 +01:00
Michaël Arnauts
497cdd6b14
Remove dependency on tox (#70) 2021-02-08 22:23:35 +01:00
Michaël Arnauts
769812f7cd Update .gitattributes 2021-02-04 12:20:49 +01:00
Michaël Arnauts
b3199dc7bd Prepare for v0.4.0 2021-02-04 12:18:47 +01:00
Michaël Arnauts
6565b4e0df Fix watched indicator 2021-02-01 13:44:39 +01:00
Dag Wieers
c41264965d
Make use of git archive (#66)
This simplifies the creation of the ZIP file as it uses .gitattributes for excluding common files.

It builds a ZIP file based on the working directory by using the current working directory as a stash.
2021-02-01 08:54:07 +01:00
Michaël Arnauts
e25ebfd8a2
Rebranding to GoPlay (#64)
* Support for rebranding to GoPlay
2021-02-01 08:53:13 +01:00
Michaël Arnauts
8a2129b894
Run CI on Windows (#62)
* Run CI on Windows

* Don't run service on windows
2020-12-17 14:16:49 +01:00
Michaël Arnauts
415a239d82
Add support for Python 3.9 (#60) 2020-11-30 10:15:52 +01:00
Michaël Arnauts
f7f90ac400 Prepare for v0.3.1 2020-11-28 17:17:51 +01:00
Michaël Arnauts
80045cdb4e
Fix authentication on some older Android devices (#58) 2020-11-28 17:16:11 +01:00
Michaël Arnauts
57b2ac36c0
Fix CI tests (#59) 2020-11-28 17:13:21 +01:00
Michaël Arnauts
c8a1908159 Fix pylint warning 2020-11-17 16:52:15 +01:00
Michaël Arnauts
3d987b969d Prepare for v0.3.0 2020-11-17 16:48:40 +01:00
Michaël Arnauts
4fd5fda393 Cleanup imports in kodiutils, and fix translatePath for Matrix. 2020-11-17 09:33:59 +01:00
Michaël Arnauts
b41bf2543f Prepare for v0.3.0 2020-11-12 14:38:10 +01:00
Michaël Arnauts
591cd91c09
Update README.md 2020-11-12 14:18:45 +01:00
mediaminister
385c0b0aec
Add inputstreamhelper (#57) 2020-11-12 08:13:13 +01:00
mediaminister
909ab94003
Fix clearing local metadata (#55) 2020-11-10 23:18:41 +01:00
Michaël Arnauts
209edde50d
Don't cast season to an int since it can be a string (#54) 2020-11-10 10:52:06 +01:00
Michaël Arnauts
f54622f930
Allow playing drm protected content (#47)
* Allow playing drm protected content
2020-11-04 12:48:33 +01:00
Michaël Arnauts
d8824d32be
Allow to install and run Kodi Logfile Uploader from the settings (#50) 2020-11-03 11:50:54 +01:00
Michaël Arnauts
84b7ade8a1
Allow to install IPTV Manager from the settings (#49) 2020-11-03 11:47:35 +01:00
Michaël Arnauts
8cec8ecfd5
Fix logging for Kodi Matrix (#48) 2020-10-26 17:25:37 +01:00
Michaël Arnauts
ed36677694
Various fixes (#46)
* Various fixes
* Order imports
* Use __name__ for _LOGGER
2020-10-26 10:25:57 +01:00
Dag Wieers
e7c06c7966
AttributeError: 'list' object has no attribute 'get' (#45) 2020-10-26 09:57:23 +01:00
Michaël Arnauts
1fdc18f73a
Update addon.xml 2020-10-10 14:14:48 +02:00
Michaël Arnauts
def9fe54b8 Fix opening items from search 2020-09-10 21:06:16 +02:00
Michaël Arnauts
dcc0a61560 Fix loading content from cache 2020-09-10 21:06:02 +02:00
Michaël Arnauts
33b53e8ab4 Fix debug logging 2020-09-10 21:05:38 +02:00
Michaël Arnauts
2cf81ea6c5 Ignore super-with-arguments pylint warning 2020-09-10 21:05:18 +02:00
mediaminister
c88e7f37c0
Show message when Kodi Player fails to get the stream (#40) 2020-08-23 17:31:10 +02:00
Michaël Arnauts
fd83e93b1d
Use sake for Kodi stubs (#36) 2020-07-09 10:16:45 +02:00
Michaël Arnauts
94067c2fa9 Release v0.2.0 2020-06-19 15:44:10 +02:00
Michaël Arnauts
85ced854a5
Add IPTV Manager to Integrations category (#35) 2020-06-19 15:28:09 +02:00
Michaël Arnauts
6ed3b1097b
Add Clean Metadata setting (#34) 2020-06-19 15:20:20 +02:00
Michaël Arnauts
3477fd4aa2
Use sock.sendall instead of sock.send (#32) 2020-06-12 17:02:19 +02:00
Michaël Arnauts
6e30322f8a
Pass genre, program title and episode number to IPTV Manager. (#31) 2020-06-09 10:51:59 +02:00
Michaël Arnauts
3902a93aa1
Add stream info for direct playing in Kodi 19 (#30) 2020-06-02 22:55:50 +02:00
Michaël Arnauts
c8781424c1
Add support for playing from the Guide with IPTV Manager (#29) 2020-05-25 20:41:38 +02:00
Michaël Arnauts
b5e36047f5
Add option for debug logging and fix CI (#27) 2020-05-11 14:12:13 +02:00
Michaël Arnauts
121ea8808f
Use inputstream.adaptive for playback (#25) 2020-04-20 16:26:16 +02:00
Michaël Arnauts
78bfae2471
Check for unused translations (#24) 2020-04-20 16:09:00 +02:00
Michaël Arnauts
b33062bd35
Add categories and clips (#23) 2020-04-20 08:59:10 +02:00
mediaminister
40af262ae6
Fix multi-line text in progress dialog (#21) 2020-04-17 10:33:20 +02:00
86 changed files with 3212 additions and 1881 deletions

14
.env.example Normal file
View File

@ -0,0 +1,14 @@
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,3 +1,4 @@
.env.example export-ignore
.github/ export-ignore .github/ export-ignore
tests/ export-ignore tests/ export-ignore
.gitattributes export-ignore .gitattributes export-ignore
@ -5,4 +6,4 @@ tests/ export-ignore
.pylintrc export-ignore .pylintrc export-ignore
Makefile export-ignore Makefile export-ignore
requirements.txt export-ignore requirements.txt export-ignore
tox.ini export-ignore scripts/ export-ignore

2
.github/FUNDING.yml vendored Normal file
View File

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

36
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,36 @@
---
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

@ -0,0 +1,20 @@
---
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,41 +1,22 @@
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:
tests: kodi-addon-checker:
name: Kodi Add-on checker name: Addon 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
with:
path: ${{ github.repository }}
- name: Set up Python 3.8
uses: actions/setup-python@v1
with:
python-version: 3.8
- name: Install dependencies
run: |
sudo apt-get install libxml2-utils xmlstarlet
python -m pip install --upgrade pip
pip install kodi-addon-checker
- name: Remove unwanted files
run: awk '/export-ignore/ { print $1 }' .gitattributes | xargs rm -rf --
working-directory: ${{ github.repository }}
- name: Rewrite addon.xml for Matrix
run: xmlstarlet ed -L -u '/addon/requires/import[@addon="xbmc.python"]/@version' -v "3.0.0" addon.xml
working-directory: ${{ github.repository }}
if: matrix.kodi-branch == 'matrix'
- name: Run kodi-addon-checker - name: Run kodi-addon-checker
run: kodi-addon-checker --branch=${{ matrix.kodi-branch }} ${{ github.repository }}/ uses: xbmc/action-kodi-addon-checker@v1.2
with:
kodi-version: matrix
addon-id: ${{ github.event.repository.name }}

View File

@ -1,58 +1,65 @@
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: Unit tests name: Add-on testing
runs-on: ubuntu-latest runs-on: ${{ matrix.os }}
env:
PYTHONIOENCODING: utf-8
PYTHONPATH: ${{ github.workspace }}/resources/lib:${{ github.workspace }}/tests
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: [2.7, 3.5, 3.6, 3.7, 3.8] os: [ ubuntu-latest, windows-latest ]
python-version: ["3.8", "3.9", "3.10"]
include:
# End-of-life Python versions are not available anymore with ubuntu-latest
- os: ubuntu-20.04
python-version: "3.5"
- os: ubuntu-20.04
python-version: "3.6"
- os: ubuntu-20.04
python-version: "3.7"
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@v3
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1 uses: actions/setup-python@v4
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install dependencies - name: Install dependencies
run: | run: pip install -r requirements.txt
sudo apt-get install gettext
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: | run: make check-pylint
make check-pylint
- name: Run tox
run: |
make check-tox
- name: Check translations - name: Check translations
run: | if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9'
make check-translations run: sudo apt-get install gettext && 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 }}
run: | PYTHONIOENCODING: utf-8
coverage run -m unittest discover KODI_HOME: ${{ github.workspace }}/tests/home
- name: Run addon KODI_INTERACTIVE: 0
run: | KODI_STUB_RPC_RESPONSES: ${{ github.workspace }}/tests/rpc
coverage run -a tests/run.py / HTTP_PROXY: ${{ secrets.HTTP_PROXY }}
- name: Run add-on service run: pytest -x -v --cov=./ --cov-report=xml tests
run: |
coverage run -a service_entry.py
- name: Upload code coverage to CodeCov - name: Upload code coverage to CodeCov
uses: codecov/codecov-action@v1 uses: codecov/codecov-action@v3
continue-on-error: true continue-on-error: true
env:
OS: ${{ matrix.os }}
PYTHON: ${{ matrix.python-version }}
with:
flags: unittests
env_vars: OS,PYTHON

34
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,34 @@
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,13 +9,14 @@
Thumbs.db Thumbs.db
*~ *~
.cache .cache
.coverage .coverage
.tox/ .tox/
tests/userdata/credentials.json
tests/userdata/temp # Testing
tests/userdata/token.json tests/home/userdata/addon_data
tests/userdata/cache .env
tests/userdata/addon_data
tests/userdata/tokens Pipfile
tests/cdm Pipfile.lock
dist/

View File

@ -1,11 +1,12 @@
[MESSAGES CONTROL] [MESSAGES CONTROL]
disable= disable=
bad-option-value, bad-option-value,
cyclic-import, # This shoud be fixed cyclic-import, # This should 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,
@ -13,3 +14,12 @@ 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,5 +1,180 @@
# 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)
@ -15,11 +190,7 @@
**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. <http://fsf.org/> Copyright (C) 2007 Free Software Foundation, Inc. <https://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 <http://www.gnu.org/licenses/>. along with this program. If not, see <https://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:
{project} Copyright (C) {year} {fullname} <program> Copyright (C) <year> <name of author>
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
<http://www.gnu.org/licenses/>. <https://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
<http://www.gnu.org/philosophy/why-not-lgpl.html>. <https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@ -1,62 +1,69 @@
export PYTHONPATH := $(CURDIR):$(CURDIR)/tests export KODI_HOME := $(CURDIR)/tests/home
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-tox check-translations check: check-pylint check-translations
check-pylint: check-pylint:
@echo ">>> Running pylint checks" @printf ">>> Running pylint checks\n"
@$(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:
@echo ">>> Running translation checks" @printf ">>> Running translation checks\n"
@$(foreach lang,$(languages), \ @$(foreach lang,$(languages), \
msgcmp resources/language/resource.language.$(lang)/strings.po resources/language/resource.language.en_gb/strings.po; \ msgcmp --use-untranslated resources/language/resource.language.$(lang)/strings.po resources/language/resource.language.en_gb/strings.po; \
) )
@scripts/check_for_unused_translations.py
check-addon: clean build check-addon: build
@echo ">>> Running addon checks" @printf ">>> Running addon checks\n"
$(eval TMPDIR := $(shell mktemp -d)) $(eval TMPDIR := $(shell mktemp -d))
@unzip ../${zip_name} -d ${TMPDIR} @unzip dist/plugin.video.viervijfzes-*+matrix.1.zip -d ${TMPDIR}
cd ${TMPDIR} && kodi-addon-checker --branch=leia cd ${TMPDIR} && kodi-addon-checker --branch=matrix
@rm -rf ${TMPDIR} @rm -rf ${TMPDIR}
codefix:
@isort -l 160 resources/
test: test-unit test: test-unit
test-unit: test-unit:
@echo ">>> Running unit tests" @printf ">>> Running unit tests\n"
@$(PYTHON) -m unittest discover -v -b -f @$(PYTHON) -m pytest tests
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/ .tox/ tests/cdm tests/userdata/temp @rm -rf .pytest_cache/ tests/cdm tests/userdata/temp
@rm -f *.log .coverage @rm -f *.log .coverage
@rm -rf dist/
build: clean build: clean
@echo ">>> Building package" @printf ">>> Building add-on\n"
@rm -f ../$(zip_name) @scripts/build.py
cd ..; zip -r $(zip_name) $(include_paths) -x $(exclude_files) @ls -lah dist/*.zip
@echo "Successfully wrote package as: ../$(zip_name)"
release: build release:
rm -rf ../repo-plugins/$(name)/* ifneq ($(release),)
unzip ../$(zip_name) -d ../repo-plugins/ docker run -it --rm --env CHANGELOG_GITHUB_TOKEN=$(GH_TOKEN) -v "$(shell pwd)":/usr/local/src/your-app githubchangeloggenerator/github-changelog-generator -u add-ons -p plugin.video.viervijfzes --no-issues --exclude-labels duplicate,question,invalid,wontfix,release,testing --future-release v$(release)
@printf "cd /addon/@version\nset $$release\nsave\nbye\n" | xmllint --shell addon.xml; \
date=$(shell date '+%Y-%m-%d'); \
printf "cd /addon/extension[@point='xbmc.addon.metadata']/news\nset v$$release ($$date)\nsave\nbye\n" | xmllint --shell addon.xml; \
# Next steps to release:
# - Modify the news-section of addons.xml
# - git add . && git commit -m "Prepare for v$(release)" && git push
# - git tag v$(release) && git push --tags
else
@printf "Usage: make release release=1.0.0\n"
endif
.PHONY: check codefix test clean build release

View File

@ -1,28 +1,24 @@
[![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) [![GitHub release](https://img.shields.io/github/v/release/add-ons/plugin.video.viervijfzes?display_name=tag)](https://github.com/add-ons/plugin.video.viervijfzes/releases)
[![Build Status](https://img.shields.io/github/workflow/status/add-ons/plugin.video.viervijfzes/CI/master)](https://github.com/add-ons/plugin.video.viervijfzes/actions?query=branch%3Amaster) [![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)
[![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)
# VIER-VIJF-ZES Kodi add-on # GoPlay Kodi add-on
*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. *plugin.video.viervijfzes* is een Kodi add-on om de video-on-demand content van [GoPlay](https://www.goplay.be/) te bekijken. Je moet hiervoor wel eerst een
account aanmaken op [goplay.be](https://www.goplay.be/).
> Note: Je moet eerst een account aanmaken op één van bovenstaande websites. Meer informatie kan je vinden op de [Wiki pagina](https://github.com/add-ons/plugin.video.viervijfzes/wiki).
## 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 VIER, VIJF en ZES * Bekijk on-demand content van Play4, Play5, Play6 en Play7
* 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
@ -37,4 +33,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 VIER, VIJF en ZES zijn eigendom van SBS België. De logo's van GoPlay, Play4, Play5, Play6 en Play7 zijn eigendom van SBS België.

View File

@ -1,8 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.viervijfzes" name="VIER-VIJF-ZES" version="0.1.0" provider-name="Michaël Arnauts"> <addon id="plugin.video.viervijfzes" name="GoPlay" version="0.4.12" provider-name="Michaël Arnauts">
<requires> <requires>
<import addon="xbmc.python" version="2.26.0"/> <import addon="xbmc.python" version="3.0.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"/>
@ -12,17 +13,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 VIER, VIJF en ZES.</summary> <summary lang="nl_NL">Bekijk programma's van Play4, Play5 en Play6.</summary>
<description lang="nl_NL">Deze add-on geeft toegang tot de programma's die aangeboden worden op de websites van VIER, VIJF en ZES.</description> <description lang="nl_NL">Deze add-on geeft toegang tot de programma's die aangeboden worden op de websites van Play4, Play5 en Play6.</description>
<disclaimer lang="nl_NL">Deze add-on wordt niet ondersteund door SBS België, en wordt aangeboden 'as is', zonder enige garantie. De logo's van VIER, VIJF en ZES 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 Play4, Play5 en Play6 zijn eigendom van SBS België.</disclaimer>
<summary lang="en_GB">Watch content from VIER, VIJF and ZES.</summary> <summary lang="en_GB">Watch content from Play4, Play5 and Play6.</summary>
<description lang="en_GB">This add-on gives access to video-on-demand content available on the websites of VIER, VIJF and ZES.</description> <description lang="en_GB">This add-on gives access to video-on-demand content available on the websites of Play4, Play5 and Play6.</description>
<disclaimer lang="en_GB">This add-on is not officially commissioned/supported by SBS Belgium and is provided 'as is' without any warranty of any kind. The VIER, VIJF and ZES 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 Play4, Play5 and Play6 logos are property of SBS Belgium.</disclaimer>
<platform>all</platform> <platform>all</platform>
<license>GPL-3.0</license> <license>GPL-3.0-only</license>
<news>v0.1.0 <news>v0.4.12 (2024-05-08)
- First release - Added live channels (by mediaminister)
</news> - Added PlayCrime Channel</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,11 +3,13 @@
from __future__ import absolute_import, division, unicode_literals from __future__ import absolute_import, division, unicode_literals
import xbmcaddon from xbmcaddon import Addon
from resources.lib import kodiutils from resources.lib import kodiutils, kodilogging
kodiutils.ADDON = xbmcaddon.Addon() # Reinitialise ADDON every invocation to fix an issue that settings are not fresh.
kodiutils.ADDON = Addon()
kodilogging.ADDON = Addon()
if __name__ == '__main__': if __name__ == '__main__':
from sys import argv from sys import argv

View File

@ -1,9 +1,12 @@
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+git://github.com/dagwieers/kodi-plugin-routing.git@setup#egg=routing git+https://github.com/tamland/kodi-plugin-routing@master#egg=routing
tox
six six
sakee
win-inet-pton; platform_system=="Windows"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -19,7 +19,15 @@ msgid "Catalogue"
msgstr "" msgstr ""
msgctxt "#30004" msgctxt "#30004"
msgid "TV Shows and Movies listed by category" msgid "Browse the catalogue"
msgstr ""
msgctxt "#30005"
msgid "Recommendations"
msgstr ""
msgctxt "#30006"
msgid "Show the recommendations"
msgstr "" msgstr ""
msgctxt "#30007" msgctxt "#30007"
@ -38,16 +46,24 @@ 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"
msgid "Browse the TV Guide"
msgstr ""
### SUBMENUS ### SUBMENUS
msgctxt "#30052"
msgid "Watch live [B]{channel}[/B]"
msgstr ""
msgctxt "#30053" msgctxt "#30053"
msgid "TV Guide for [B]{channel}[/B]" msgid "TV Guide for [B]{channel}[/B]"
msgstr "" msgstr ""
@ -64,8 +80,24 @@ 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 ""
@ -108,18 +140,10 @@ 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 ""
@ -128,22 +152,22 @@ 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"
msgid "There is no live stream available for {channel}."
msgstr ""
msgctxt "#30719"
msgid "This video cannot be played."
msgstr ""
msgctxt "#30720"
msgid "This video is not available abroad."
msgstr ""
### SETTINGS ### SETTINGS
msgctxt "#30800" msgctxt "#30800"
@ -154,11 +178,11 @@ msgctxt "#30801"
msgid "Credentials" msgid "Credentials"
msgstr "" msgstr ""
msgctxt "#30803" msgctxt "#30802"
msgid "Email address" msgid "Email address"
msgstr "" msgstr ""
msgctxt "#30805" msgctxt "#30803"
msgid "Password" msgid "Password"
msgstr "" msgstr ""
@ -166,14 +190,46 @@ msgctxt "#30820"
msgid "Interface" msgid "Interface"
msgstr "" msgstr ""
msgctxt "#30827" msgctxt "#30821"
msgid "Metadata" msgid "Show unavailable programs"
msgstr "" msgstr ""
msgctxt "#30829" msgctxt "#30840"
msgid "Periodically refresh metadata in the background" msgid "Integration"
msgstr "" msgstr ""
msgctxt "#30831" msgctxt "#30841"
msgid "Update local metadata now" msgid "IPTV Manager"
msgstr ""
msgctxt "#30842"
msgid "Install IPTV Manager add-on…"
msgstr ""
msgctxt "#30843"
msgid "Enable IPTV Manager integration"
msgstr ""
msgctxt "#30844"
msgid "IPTV Manager settings…"
msgstr ""
msgctxt "#30880"
msgid "Expert"
msgstr ""
msgctxt "#30881"
msgid "Logging"
msgstr ""
msgctxt "#30882"
msgid "Enable debug logging"
msgstr ""
msgctxt "#30883"
msgid "Install Kodi Logfile Uploader…"
msgstr ""
msgctxt "#30884"
msgid "Open Kodi Logfile Uploader…"
msgstr "" msgstr ""

View File

@ -20,8 +20,16 @@ msgid "Catalogue"
msgstr "Catalogus" msgstr "Catalogus"
msgctxt "#30004" msgctxt "#30004"
msgid "TV Shows and Movies listed by category" msgid "Browse the catalogue"
msgstr "Programma's en films per categorie" msgstr "Doorblader de catalogus"
msgctxt "#30005"
msgid "Recommendations"
msgstr "Aanbevelingen"
msgctxt "#30006"
msgid "Show the recommendations"
msgstr "Doorblader de aanbevelingen"
msgctxt "#30007" msgctxt "#30007"
msgid "Channels" msgid "Channels"
@ -39,16 +47,24 @@ 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]"
@ -65,8 +81,24 @@ 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"
@ -109,18 +141,10 @@ 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."
@ -129,22 +153,22 @@ 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"
msgid "There is no live stream available for {channel}."
msgstr "Er is geen live stream beschikbaar voor {channel}."
msgctxt "#30719"
msgid "This video cannot be played."
msgstr "Deze video kan niet afgespeeld worden."
msgctxt "#30720"
msgid "This video is not available abroad."
msgstr "Deze video is niet beschikbaar in het buitenland."
### SETTINGS ### SETTINGS
msgctxt "#30800" msgctxt "#30800"
@ -155,11 +179,11 @@ msgctxt "#30801"
msgid "Credentials" msgid "Credentials"
msgstr "Inloggegevens" msgstr "Inloggegevens"
msgctxt "#30803" msgctxt "#30802"
msgid "Email address" msgid "Email address"
msgstr "E-mailadres" msgstr "E-mailadres"
msgctxt "#30805" msgctxt "#30803"
msgid "Password" msgid "Password"
msgstr "Wachtwoord" msgstr "Wachtwoord"
@ -167,14 +191,46 @@ msgctxt "#30820"
msgid "Interface" msgid "Interface"
msgstr "Interface" msgstr "Interface"
msgctxt "#30827" msgctxt "#30821"
msgid "Metadata" msgid "Show unavailable programs"
msgstr "Metadata" msgstr "Toon onbeschikbare programma's"
msgctxt "#30829" msgctxt "#30840"
msgid "Periodically refresh metadata in the background" msgid "Integration"
msgstr "Vernieuw de lokale metdata automatisch in de achtergrond" msgstr "Integratie"
msgctxt "#30831" msgctxt "#30841"
msgid "Update local metadata now" msgid "IPTV Manager"
msgstr "De lokale metadata nu vernieuwen" msgstr "IPTV Manager"
msgctxt "#30842"
msgid "Install IPTV Manager add-on…"
msgstr "Installeer de IPTV Manager add-on…"
msgctxt "#30843"
msgid "Enable IPTV Manager integration"
msgstr "Activeer IPTV Manager integratie"
msgctxt "#30844"
msgid "IPTV Manager settings…"
msgstr "IPTV Manager instellingen…"
msgctxt "#30880"
msgid "Expert"
msgstr "Expert"
msgctxt "#30881"
msgid "Logging"
msgstr "Logboek"
msgctxt "#30882"
msgid "Enable debug logging"
msgstr "Activeer debug logging"
msgctxt "#30883"
msgid "Install Kodi Logfile Uploader…"
msgstr "Installeer Kodi Logfile Uploader…"
msgctxt "#30884"
msgid "Open Kodi Logfile Uploader…"
msgstr "Open Kodi Logfile Uploader…"

View File

@ -9,9 +9,13 @@ from routing import Plugin
from resources.lib import kodilogging from resources.lib import kodilogging
kodilogging.config() try: # Python 3
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('addon') _LOGGER = logging.getLogger(__name__)
@routing.route('/') @routing.route('/')
@ -35,18 +39,25 @@ def show_channel_menu(channel):
Channels().show_channel_menu(channel) Channels().show_channel_menu(channel)
@routing.route('/tvguide/channel/<channel>') @routing.route('/channels/<channel>/tvguide')
def show_tvguide_channel(channel): def show_channel_tvguide(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_tvguide_channel(channel) TvGuide().show_channel(channel)
@routing.route('/tvguide/channel/<channel>/<date>') @routing.route('/channels/<channel>/tvguide/<date>')
def show_tvguide_detail(channel=None, date=None): def show_channel_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_tvguide_detail(channel, date) TvGuide().show_detail(channel, date)
@routing.route('/channels/<channel>/catalog')
def show_channel_catalog(channel):
""" Show the catalog of a channel """
from resources.lib.modules.catalog import Catalog
Catalog().show_catalog_channel(channel)
@routing.route('/catalog') @routing.route('/catalog')
@ -56,25 +67,74 @@ def show_catalog():
Catalog().show_catalog() Catalog().show_catalog()
@routing.route('/catalog/by-channel/<channel>') @routing.route('/catalog/<program>')
def show_catalog_channel(channel): def show_catalog_program(program):
""" 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(channel, program) Catalog().show_program(program)
@routing.route('/catalog/program/<channel>/<program>/<season>') @routing.route('/catalog/<program>/clips')
def show_catalog_program_season(channel, program, season): def show_catalog_program_clips(program):
""" Show a program from the catalog """ """ Show the clips from a program """
from resources.lib.modules.catalog import Catalog from resources.lib.modules.catalog import Catalog
Catalog().show_program_season(channel, program, season) Catalog().show_program_clips(program)
@routing.route('/catalog/<program>/season/<season>')
def show_catalog_program_season(program, season):
""" Show a season from a program """
from resources.lib.modules.catalog import Catalog
Catalog().show_program_season(program, season)
@routing.route('/category')
def show_categories():
""" Show the catalog by category """
from resources.lib.modules.catalog import Catalog
Catalog().show_categories()
@routing.route('/category/<category>')
def show_category(category):
""" Show the catalog by category """
from resources.lib.modules.catalog import Catalog
Catalog().show_category(category)
@routing.route('/recommendations')
def show_recommendations():
""" Show my list """
from resources.lib.modules.catalog import Catalog
Catalog().show_recommendations()
@routing.route('/recommendations/<category>')
def show_recommendations_category(category):
""" Show my list """
from resources.lib.modules.catalog import Catalog
Catalog().show_recommendations_category(category)
@routing.route('/mylist')
def show_mylist():
""" Show my list """
from resources.lib.modules.catalog import Catalog
Catalog().show_mylist()
@routing.route('/mylist/add/<uuid>')
def mylist_add(uuid):
""" Add a program to My List """
from resources.lib.modules.catalog import Catalog
Catalog().mylist_add(uuid)
@routing.route('/mylist/del/<uuid>')
def mylist_del(uuid):
""" Remove a program from My List """
from resources.lib.modules.catalog import Catalog
Catalog().mylist_del(uuid)
@routing.route('/search') @routing.route('/search')
@ -85,32 +145,50 @@ def show_search(query=None):
Search().show_search(query) Search().show_search(query)
@routing.route('/play/catalog/<uuid>') @routing.route('/play/live/<channel>')
def play(uuid): def play_live(channel):
""" Play the requested item """ """ Play the requested item """
from resources.lib.modules.player import Player from resources.lib.modules.player import Player
Player().play(uuid) Player().live(channel)
@routing.route('/play/page/<channel>/<page>') @routing.route('/play/epg/<channel>/<timestamp>')
def play_from_page(channel, page): def play_epg(channel, timestamp):
""" Play the requested item """ """ Play the requested item """
try: # Python 3 from resources.lib.modules.tvguide import TvGuide
from urllib.parse import unquote TvGuide().play_epg_datetime(channel, timestamp)
except ImportError: # Python 2
from urllib import unquote
@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_from_page(channel, unquote(page)) Player().play(uuid, content_type)
@routing.route('/metadata/update') @routing.route('/play/page/<page>')
def metadata_update(): def play_from_page(page):
""" Update the metadata for the listings (called from settings) """ """ Play the requested item """
from resources.lib.modules.metadata import Metadata from resources.lib.modules.player import Player
Metadata().update() Player().play_from_page(unquote(page))
@routing.route('/iptv/channels')
def iptv_channels():
""" Generate channel data for the Kodi PVR integration """
from resources.lib.modules.iptvmanager import IPTVManager
IPTVManager(int(routing.args['port'][0])).send_channels() # pylint: disable=too-many-function-args
@routing.route('/iptv/epg')
def iptv_epg():
""" Generate EPG data for the Kodi PVR integration """
from resources.lib.modules.iptvmanager import IPTVManager
IPTVManager(int(routing.args['port'][0])).send_epg() # pylint: disable=too-many-function-args
def run(params): def run(params):
""" Run the routing plugin """ """ Run the routing plugin """
kodilogging.config()
routing.run(params) routing.run(params)

View File

@ -8,15 +8,23 @@ 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)
addon_id = xbmcaddon.Addon().getAddonInfo("id") formatter = logging.Formatter("[{}] [%(name)s] %(message)s".format(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 """
@ -24,10 +32,16 @@ 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: xbmc.LOGINFO, logging.INFO: self.info_level,
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:
@ -40,5 +54,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,35 +4,63 @@
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 = dict( SORT_METHODS = {
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'
] ]
_LOGGER = logging.getLogger('kodiutils') HTML_MAPPING = [
(re.compile(r'<(/?)i(|\s[^>]+)>', re.I), '[\\1I]'),
(re.compile(r'<(/?)b(|\s[^>]+)>', re.I), '[\\1B]'),
(re.compile(r'<em(|\s[^>]+)>', re.I), '[I]'),
(re.compile(r'</em>', re.I), '[/I]'),
(re.compile(r'<(strong|h\d)>', re.I), '[B]'),
(re.compile(r'</(strong|h\d)>', re.I), '[/B]'),
(re.compile(r'<li>', re.I), '- '),
(re.compile(r'</?(li|ul|ol)(|\s[^>]+)>', re.I), '\n'),
(re.compile(r'</?(code|div|p|pre|span)(|\s[^>]+)>', re.I), ''),
(re.compile(r'<br />', re.I), '\n'), # Remove newlines
(re.compile('(&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, context_menu=None, subtitles_path=None, def __init__(self, title, path=None, art_dict=None, info_dict=None, prop_dict=None, stream_dict=None,
is_playable=False): context_menu=None, subtitles_path=None, is_playable=False, visible=True):
""" The constructor for the TitleItem class """ The constructor for the TitleItem class
:type title: str :type title: str
:type path: str :type path: str
@ -43,6 +71,7 @@ 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
@ -53,6 +82,7 @@ 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__
@ -81,6 +111,15 @@ 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')
@ -108,6 +147,9 @@ 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')))
@ -118,7 +160,7 @@ def url_for(name, *args, **kwargs):
def show_listing(title_items, category=None, sort=None, content=None, cache=True): def show_listing(title_items, category=None, sort=None, content=None, cache=True):
""" Show a virtual directory in Kodi """ """Show a virtual directory in Kodi"""
from resources.lib.addon import routing from resources.lib.addon import routing
if content: if content:
@ -151,6 +193,9 @@ 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)
@ -188,7 +233,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, title=None, art_dict=None, info_dict=None, prop_dict=None): def play(stream, stream_type=STREAM_HLS, license_key=None, title=None, art_dict=None, info_dict=None, prop_dict=None, stream_dict=None):
"""Play the given stream""" """Play the given stream"""
from resources.lib.addon import routing from resources.lib.addon import routing
@ -199,12 +244,41 @@ def play(stream, title=None, art_dict=None, info_dict=None, prop_dict=None):
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)
def get_search_string(heading='', message=''): def get_search_string(heading='', message=''):
""" Ask the user for a search string """ """Ask the user for a search string"""
search_string = None search_string = None
keyboard = xbmc.Keyboard(message, heading) keyboard = xbmc.Keyboard(message, heading)
keyboard.doModal() keyboard.doModal()
@ -215,40 +289,39 @@ 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:
return Dialog().ok(heading=heading, line1=message) # pylint: disable=unexpected-keyword-arg,no-value-for-parameter
return Dialog().ok(heading=heading, message=message) return xbmcgui.Dialog().ok(heading=heading, line1=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:
return Dialog().yesno(heading=heading, line1=message, nolabel=nolabel, yeslabel=yeslabel, autoclose=autoclose) # pylint: disable=unexpected-keyword-arg,no-value-for-parameter
return Dialog().yesno(heading=heading, message=message, nolabel=nolabel, yeslabel=yeslabel, autoclose=autoclose) return xbmcgui.Dialog().yesno(heading=heading, line1=message, nolabel=nolabel, yeslabel=yeslabel, autoclose=autoclose)
return xbmcgui.Dialog().yesno(heading=heading, message=message, nolabel=nolabel, yeslabel=yeslabel, autoclose=autoclose)
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()
Dialog().notification(heading=heading, message=message, icon=icon, time=time) xbmcgui.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 Dialog().multiselect(heading=heading, options=options, autoclose=autoclose, preselect=preselect, useDetails=use_details) return xbmcgui.Dialog().multiselect(heading=heading, options=options, autoclose=autoclose, preselect=preselect,
useDetails=use_details)
class progress(xbmcgui.DialogProgress, object): # pylint: disable=invalid-name,useless-object-inheritance class progress(xbmcgui.DialogProgress, object): # pylint: disable=invalid-name,useless-object-inheritance
@ -264,13 +337,17 @@ 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:
return super(progress, self).create(heading, line1=message) lines = message.split('\n', 2)
line1, line2, line3 = (lines + [None] * (3 - len(lines)))
return super(progress, self).create(heading, line1=line1, line2=line2, line3=line3) # pylint: disable=unexpected-keyword-arg,no-value-for-parameter
return super(progress, self).create(heading, 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:
return super(progress, self).update(percent, line1=message) lines = message.split('\n', 2)
line1, line2, line3 = (lines + [None] * (3 - len(lines)))
return super(progress, self).update(percent, line1=line1, line2=line2, line3=line3) # pylint: disable=unexpected-keyword-arg,no-value-for-parameter
return super(progress, self).update(percent, message=message) return super(progress, self).update(percent, message=message)
@ -278,7 +355,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 Error, LC_ALL, setlocale from locale import LC_ALL, Error, 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
@ -393,13 +470,74 @@ 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=dict(setting=key)) result = jsonrpc(method='Settings.GetSettingValue', params={'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=dict(setting=key, value=value)) return jsonrpc(method='Settings.SetSettingValue', params={'setting': key, 'value': value})
def has_socks():
"""Test if socks is installed, and use a static variable to remember"""
if hasattr(has_socks, 'cached'):
return getattr(has_socks, 'cached')
try:
import socks # noqa: F401; pylint: disable=unused-variable,unused-import
except ImportError:
has_socks.cached = False
return None # Detect if this is the first run
has_socks.cached = True
return True
def get_proxies():
"""Return a usable proxies dictionary from Kodi proxy settings"""
# Use proxy settings from environment variables
env_http_proxy = os.environ.get('HTTP_PROXY')
env_https_proxy = os.environ.get('HTTPS_PROXY')
if env_http_proxy:
return {'http': env_http_proxy, 'https': env_https_proxy or env_http_proxy}
usehttpproxy = get_global_setting('network.usehttpproxy')
if usehttpproxy is not True:
return None
try:
httpproxytype = int(get_global_setting('network.httpproxytype'))
except ValueError:
httpproxytype = 0
socks_supported = has_socks()
if httpproxytype != 0 and not socks_supported:
# Only open the dialog the first time (to avoid multiple popups)
if socks_supported is None:
ok_dialog('', localize(30966)) # Requires PySocks
return None
proxy_types = ['http', 'socks4', 'socks4a', 'socks5', 'socks5h']
proxy = {
'scheme': proxy_types[httpproxytype] if 0 <= httpproxytype < 5 else 'http',
'server': get_global_setting('network.httpproxyserver'),
'port': get_global_setting('network.httpproxyport'),
'username': get_global_setting('network.httpproxyusername'),
'password': get_global_setting('network.httpproxypassword')
}
if proxy.get('username') and proxy.get('password') and proxy.get('server') and proxy.get('port'):
proxy_address = '{scheme}://{username}:{password}@{server}:{port}'.format(**proxy)
elif proxy.get('username') and proxy.get('server') and proxy.get('port'):
proxy_address = '{scheme}://{username}@{server}:{port}'.format(**proxy)
elif proxy.get('server') and proxy.get('port'):
proxy_address = '{scheme}://{server}:{port}'.format(**proxy)
elif proxy.get('server'):
proxy_address = '{scheme}://{server}'.format(**proxy)
else:
return None
return {'http': proxy_address, 'https': proxy_address}
def get_cond_visibility(condition): def get_cond_visibility(condition):
@ -425,14 +563,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 = addon_profile() + 'tokens/' get_tokens_path.cached = os.path.join(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 = addon_profile() + 'cache/' get_cache_path.cached = os.path.join(addon_profile(), 'cache')
return getattr(get_cache_path, 'cached') return getattr(get_cache_path, 'cached')
@ -491,3 +629,13 @@ 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,11 +8,10 @@ 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 ContentApi, UnavailableException, CACHE_PREVENT from resources.lib.viervijfzes.content import CACHE_PREVENT, ContentApi, UnavailableException
_LOGGER = logging.getLogger('catalog') _LOGGER = logging.getLogger(__name__)
class Catalog: class Catalog:
@ -20,21 +19,18 @@ class Catalog:
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._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(self._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 = [] items = self._api.get_programs()
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 = [self._menu.generate_titleitem(item) for item in items] listing = [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.
@ -52,53 +48,51 @@ class Catalog:
listing = [] listing = []
for item in items: for item in items:
listing.append(self._menu.generate_titleitem(item)) listing.append(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, channel, program_id): def show_program(self, 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(channel, program_id, cache=CACHE_PREVENT) # Use CACHE_PREVENT since we want fresh data program = self._api.get_program(program_id, extract_clips=True, 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: if not program.episodes and not program.clips:
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 # Go directly to the season when we have only one season and no clips
if len(program.seasons) == 1: if not program.clips and len(program.seasons) == 1:
self.show_program_season(channel, program_id, list(program.seasons.values())[0].uuid) self.show_program_season(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 kodiutils.get_global_setting('videolibrary.showallitems') is True: if program.seasons and 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', channel=channel, program=program_id, season='-1'), path=kodiutils.url_for('show_catalog_program_season', program=program_id, season='-1'),
art_dict={ art_dict={
'fanart': program.background, 'fanart': program.fanart,
'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,
} }
) )
) )
@ -108,16 +102,37 @@ 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', channel=channel, program=program_id, season=season.uuid), path=kodiutils.url_for('show_catalog_program_season', program=program_id, season=season.uuid),
art_dict={ art_dict={
'fanart': program.background, 'fanart': program.fanart,
'poster': program.poster,
'landscape': program.thumb,
}, },
info_dict={ info_dict={
'tvshowtitle': program.title, 'tvshowtitle': program.title,
'title': kodiutils.localize(30205, season=season.number), # Season {season} 'title': kodiutils.localize(30205, season=season.number) if season.number else season.title, # Season {season}
'plot': season.description, 'plot': season.description or program.description,
'set': program.title,
}
)
)
# Add Clips
if program.clips:
listing.append(
TitleItem(
title=kodiutils.localize(30059, program=program.title), # Clips for {program}
path=kodiutils.url_for('show_catalog_program_clips', program=program_id),
art_dict={
'fanart': program.fanart,
'poster': program.poster,
'landscape': program.thumb,
},
info_dict={
'tvshowtitle': program.title,
'title': kodiutils.localize(30059, program=program.title), # Clips for {program}
'plot': kodiutils.localize(30060, program=program.title), # Watch short clips of {program}
'set': program.title, 'set': program.title,
'studio': studio,
} }
) )
) )
@ -125,14 +140,13 @@ 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, channel, program_id, season_uuid): def show_program_season(self, 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(channel, program_id) program = self._api.get_program(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()
@ -145,7 +159,122 @@ 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 = [self._menu.generate_titleitem(episode) for episode in episodes] listing = [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,8 +8,10 @@ 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('channels') _LOGGER = logging.getLogger(__name__)
class Channels: class Channels:
@ -17,6 +19,8 @@ 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():
@ -33,7 +37,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_tvguide_channel', channel=channel.get('epg')) kodiutils.url_for('show_channel_tvguide', channel=channel.get('epg'))
) )
] ]
@ -50,7 +54,6 @@ 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
@ -60,43 +63,67 @@ class Channels:
kodiutils.show_listing(listing, 30007) kodiutils.show_listing(listing, 30007)
@staticmethod @staticmethod
def show_channel_menu(key): def show_channel_menu(channel):
""" Shows a TV channel """ Shows a TV channel
:type key: str :type channel: str
""" """
channel = CHANNELS[key] channel_info = CHANNELS[channel]
# 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.get('background')) fanart = '{path}/resources/logos/{logo}'.format(path=kodiutils.addon_path(), logo=channel_info.get('background'))
icon = '{path}/resources/logos/{logo}'.format(path=kodiutils.addon_path(), logo=channel_info.get('logo'))
listing = [ listing = []
listing.append(
TitleItem( TitleItem(
title=kodiutils.localize(30053, channel=channel.get('name')), # TV Guide for {channel} title=kodiutils.localize(30052, channel=channel_info.get('name')), # Watch live {channel}
path=kodiutils.url_for('show_tvguide_channel', channel=key), path=kodiutils.url_for('play_live', channel=channel_info.get('name')) + '?.pvr',
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.get('name')), # Browse the TV Guide for {channel} 'plot': kodiutils.localize(30054, channel=channel_info.get('name')), # Browse the TV Guide for {channel}
} }
), )
)
listing.append(
TitleItem( TitleItem(
title=kodiutils.localize(30055, channel=channel.get('name')), # Catalog for {channel} title=kodiutils.localize(30055, channel=channel_info.get('name')), # Catalog for {channel}
path=kodiutils.url_for('show_catalog_channel', channel=key), path=kodiutils.url_for('show_channel_catalog', channel=channel),
art_dict={ art_dict={
'icon': 'DefaultMovieTitle.png', 'icon': 'DefaultMovieTitle.png',
'fanart': fanart, 'fanart': fanart,
}, },
info_dict={ info_dict={
'plot': kodiutils.localize(30056, channel=channel.get('name')), # Browse the Catalog for {channel} 'plot': kodiutils.localize(30056, channel=channel_info.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.get('youtube', []): for youtube in channel_info.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

@ -0,0 +1,98 @@
# -*- 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,10 +3,19 @@
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 CHANNELS, STREAM_DICT from resources.lib.viervijfzes import STREAM_DICT
from resources.lib.viervijfzes.content import Program, Episode from resources.lib.viervijfzes.content import Episode, Program
try: # Python 3
from urllib.parse import quote
except ImportError: # Python 2
from urllib import quote
_LOGGER = logging.getLogger(__name__)
class Menu: class Menu:
@ -22,35 +31,68 @@ 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=dict( art_dict={
icon='DefaultMovieTitle.png', 'icon': 'DefaultMovieTitle.png',
fanart=kodiutils.get_addon_info('fanart'), 'fanart': kodiutils.get_addon_info('fanart')
), },
info_dict=dict( info_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=dict( art_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)
}
), ),
info_dict=dict( TitleItem(
plot=kodiutils.localize(30008), title=kodiutils.localize(30003), # Catalog
) path=kodiutils.url_for('show_categories'),
art_dict={
'icon': 'DefaultGenre.png',
'fanart': kodiutils.get_addon_info('fanart')
},
info_dict={
'plot': kodiutils.localize(30004)
}
),
TitleItem(
title=kodiutils.localize(30005), # Recommendations
path=kodiutils.url_for('show_recommendations'),
art_dict={
'icon': 'DefaultFavourites.png',
'fanart': kodiutils.get_addon_info('fanart')
},
info_dict={
'plot': kodiutils.localize(30006)
}
),
TitleItem(
title=kodiutils.localize(30011), # My List
path=kodiutils.url_for('show_mylist'),
art_dict={
'icon': 'DefaultPlaylist.png',
'fanart': kodiutils.get_addon_info('fanart')
},
info_dict={
'plot': kodiutils.localize(30012)
}
), ),
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=dict( art_dict={
icon='DefaultAddonsSearch.png', 'icon': 'DefaultAddonsSearch.png',
fanart=kodiutils.get_addon_info('fanart'), 'fanart': kodiutils.get_addon_info('fanart')
), },
info_dict=dict( info_dict={
plot=kodiutils.localize(30010), 'plot': kodiutils.localize(30010)
) }
) )
] ]
@ -62,14 +104,9 @@ 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 = {}
@ -78,33 +115,53 @@ 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,
}) })
if isinstance(item.episodes, list) and not item.episodes: art_dict = {
# We know that we don't have episodes 'poster': item.poster,
title = '[COLOR gray]' + item.title + '[/COLOR]' 'landscape': item.thumb,
else: 'thumb': item.thumb,
# We have episodes, or we don't know it 'fanart': item.fanart,
}
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', channel=item.channel, program=item.path), path=kodiutils.url_for('show_catalog_program', 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,
@ -113,13 +170,26 @@ 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:
# We have an UUID and can play this item directly
path = kodiutils.url_for('play_catalog', uuid=item.uuid, content_type=item.content_type)
else:
# We don't have an UUID, and first need to fetch the video information from the page
path = kodiutils.url_for('play_from_page', page=quote(item.path, safe=''))
return TitleItem(title=info_dict['title'], return TitleItem(title=info_dict['title'],
path=kodiutils.url_for('play', uuid=item.uuid), path=path,
art_dict=art_dict, art_dict=art_dict,
info_dict=info_dict, info_dict=info_dict,
stream_dict=stream_dict, stream_dict=stream_dict,

View File

@ -1,54 +0,0 @@
# -*- 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

@ -6,11 +6,13 @@ from __future__ import absolute_import, division, unicode_literals
import logging import logging
from resources.lib import kodiutils from resources.lib import kodiutils
from resources.lib.modules.menu import Menu
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.auth_awsidp import InvalidLoginException, AuthenticationException from resources.lib.viervijfzes.aws.cognito_idp import AuthenticationException, InvalidLoginException
from resources.lib.viervijfzes.content import ContentApi, UnavailableException, GeoblockedException from resources.lib.viervijfzes.content import CACHE_PREVENT, ContentApi, GeoblockedException, UnavailableException
_LOGGER = logging.getLogger('player') _LOGGER = logging.getLogger(__name__)
class Player: class Player:
@ -18,29 +20,85 @@ 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())
def play_from_page(self, channel, path): self._api = ContentApi(auth, cache_path=kodiutils.get_cache_path())
""" Play the requested item.
:type channel: string
:type path: string
"""
# Get episode information
episode = ContentApi().get_episode(channel, path)
# Play this now we have the uuid
self.play(episode.uuid)
@staticmethod
def play(item):
""" Play the requested item.
:type item: string
"""
# Workaround for Raspberry Pi 3 and older # Workaround for Raspberry Pi 3 and older
omxplayer = kodiutils.get_global_setting('videoplayer.useomxplayer')
if omxplayer is False:
kodiutils.set_global_setting('videoplayer.useomxplayer', True) 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.
:type path: string
"""
if not path:
kodiutils.ok_dialog(message=kodiutils.localize(30712)) # The video is unavailable...
return
# Get episode information
episode = self._api.get_episode(path, cache=CACHE_PREVENT)
resolved_stream = None
if episode is None:
kodiutils.ok_dialog(message=kodiutils.localize(30712))
return
if episode.stream:
# We already have a resolved stream. Nice!
# We don't need credentials for these streams.
resolved_stream = ResolvedStream(
uuid=episode.uuid,
url=episode.stream,
)
_LOGGER.debug('Already got a resolved stream: %s', resolved_stream)
if episode.uuid:
# Lookup the stream
resolved_stream = self._resolve_stream(episode.uuid, episode.content_type)
_LOGGER.debug('Resolved stream: %s', resolved_stream)
if resolved_stream:
titleitem = Menu.generate_titleitem(episode)
kodiutils.play(resolved_stream.url,
resolved_stream.stream_type,
resolved_stream.license_key,
info_dict=titleitem.info_dict,
art_dict=titleitem.art_dict,
prop_dict=titleitem.prop_dict)
def play(self, uuid, content_type):
""" Play the requested item.
:type uuid: string
:type content_type: string
"""
if not uuid:
kodiutils.ok_dialog(message=kodiutils.localize(30712)) # The video is unavailable...
return
# Lookup the stream
resolved_stream = self._resolve_stream(uuid, content_type)
kodiutils.play(resolved_stream.url, resolved_stream.stream_type, resolved_stream.license_key)
@staticmethod
def _resolve_stream(uuid, content_type):
""" Resolve the stream for the requested item
:type uuid: string
:type content_type: string
"""
try: try:
# Check if we have credentials # Check if we have credentials
if not kodiutils.get_setting('username') or not kodiutils.get_setting('password'): if not kodiutils.get_setting('username') or not kodiutils.get_setting('password'):
@ -49,28 +107,26 @@ class Player:
if confirm: if confirm:
kodiutils.open_settings() kodiutils.open_settings()
kodiutils.end_of_directory() kodiutils.end_of_directory()
return return None
# Fetch an auth token now # Fetch an auth token now
try: try:
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())
# Get stream information # Get stream information
resolved_stream = ContentApi(auth).get_stream_by_uuid(item) resolved_stream = ContentApi(auth).get_stream_by_uuid(uuid, content_type)
return resolved_stream
except (InvalidLoginException, AuthenticationException) as ex: except (InvalidLoginException, AuthenticationException) as ex:
_LOGGER.error(ex) _LOGGER.exception(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() kodiutils.end_of_directory()
return return None
except GeoblockedException: except GeoblockedException:
kodiutils.ok_dialog(heading=kodiutils.localize(30709), message=kodiutils.localize(30710)) # This video is geo-blocked... kodiutils.ok_dialog(message=kodiutils.localize(30710)) # This video is geo-blocked...
return return None
except UnavailableException: except UnavailableException:
kodiutils.ok_dialog(heading=kodiutils.localize(30711), message=kodiutils.localize(30712)) # The video is unavailable... kodiutils.ok_dialog(message=kodiutils.localize(30712)) # The video is unavailable...
return return None
# Play this item
kodiutils.play(resolved_stream)

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('search') _LOGGER = logging.getLogger(__name__)
class Search: class Search:
@ -18,7 +18,6 @@ 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
@ -40,7 +39,7 @@ class Search:
return return
# Display results # Display results
listing = [self._menu.generate_titleitem(item) for item in items] listing = [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,6 +8,7 @@ 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
@ -17,7 +18,7 @@ try: # Python 3
except ImportError: # Python 2 except ImportError: # Python 2
from urllib import quote from urllib import quote
_LOGGER = logging.getLogger('tvguide') _LOGGER = logging.getLogger(__name__)
class TvGuide: class TvGuide:
@ -35,8 +36,8 @@ class TvGuide:
dates = [] dates = []
today = datetime.today() today = datetime.today()
# The API provides 7 days in the past and 13 days in the future # The API provides 7 days in the past and 8 days in the future
for i in range(-7, 13): for i in range(-7, 8):
day = today + timedelta(days=i) day = today + timedelta(days=i)
if i == -1: if i == -1:
@ -70,7 +71,7 @@ class TvGuide:
return dates return dates
def show_tvguide_channel(self, channel): def show_channel(self, channel):
""" Shows the dates in the tv guide """ Shows the dates in the tv guide
:type channel: str :type channel: str
""" """
@ -83,7 +84,7 @@ class TvGuide:
listing.append( listing.append(
TitleItem(title=title, TitleItem(title=title,
path=kodiutils.url_for('show_tvguide_detail', channel=channel, date=day.get('key')), path=kodiutils.url_for('show_channel_tvguide_detail', channel=channel, date=day.get('key')),
art_dict={ art_dict={
'icon': 'DefaultYear.png', 'icon': 'DefaultYear.png',
'thumb': 'DefaultYear.png', 'thumb': 'DefaultYear.png',
@ -96,7 +97,7 @@ class TvGuide:
kodiutils.show_listing(listing, 30013, content='files', sort=['date']) kodiutils.show_listing(listing, 30013, content='files', sort=['date'])
def show_tvguide_detail(self, channel=None, date=None): def show_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
@ -130,7 +131,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 = None path = kodiutils.url_for('play_catalog', uuid='')
title = '[COLOR gray]' + title + '[/COLOR]' title = '[COLOR gray]' + title + '[/COLOR]'
stream_dict = STREAM_DICT.copy() stream_dict = STREAM_DICT.copy()
@ -153,8 +154,7 @@ class TvGuide:
TitleItem(title=title, TitleItem(title=title,
path=path, path=path,
art_dict={ art_dict={
'icon': program.cover, 'thumb': program.thumb,
'thumb': program.cover,
}, },
info_dict=info_dict, info_dict=info_dict,
stream_dict=stream_dict, stream_dict=stream_dict,
@ -171,9 +171,13 @@ 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(heading=kodiutils.localize(30711), message=kodiutils.localize(30713)) # The requested video was not found in the guide. kodiutils.ok_dialog(message=kodiutils.localize(30713)) # The requested video was not found in the guide.
kodiutils.end_of_directory() kodiutils.end_of_directory()
return return
kodiutils.container_update( if not broadcast.video_url:
kodiutils.url_for('play', uuid=broadcast.video_url)) kodiutils.ok_dialog(message=kodiutils.localize(30712)) # The video is unavailable and can't be played right now.
kodiutils.end_of_directory()
return
Player().play_from_page(broadcast.video_url)

View File

@ -5,16 +5,13 @@ 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 from xbmc import Monitor, Player, getInfoLabel
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
kodilogging.config() _LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger('service')
class BackgroundService(Monitor): class BackgroundService(Monitor):
@ -25,26 +22,23 @@ 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.info('Service started') _LOGGER.debug('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.info('Service stopped') _LOGGER.debug('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.info('Clearing auth tokens due to changed credentials') _LOGGER.debug('Clearing auth tokens due to changed credentials')
self._auth.clear_tokens() self._auth.clear_tokens()
# Refresh container # Refresh container
@ -62,39 +56,90 @@ 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
def update_status(_i, _total): class KodiPlayer(Player):
""" Allow to cancel the background job """ """Communication with Kodi Player"""
return self.abortRequested() or not kodiutils.get_setting_bool('metadata_update')
# Clear metadata that has expired for 30 days def __init__(self):
self._remove_expired_metadata(30 * 24 * 60 * 60) """KodiPlayer initialisation"""
Player.__init__(self)
self.listen = False
self.path = None
self.av_started = False
self.stream_path = None
# Fetch new metadata def onPlayBackStarted(self): # pylint: disable=invalid-name
success = Metadata().fetch_metadata(callback=update_status) """Called when user starts playing a file"""
self.path = getInfoLabel('Player.FilenameAndPath')
# Update metadata_last_updated if self.path.startswith('plugin://plugin.video.viervijfzes/'):
if success: self.listen = True
kodiutils.set_setting('metadata_last_updated', str(int(time()))) else:
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()
now = time() def onAVStarted(self): # pylint: disable=invalid-name
for filename in os.listdir(path): """Called when Kodi has a video or audiostream"""
fullpath = path + filename if not self.listen:
if keep_expired and os.stat(fullpath).st_mtime + keep_expired > now: return
continue _LOGGER.debug('KodiPlayer onAVStarted')
os.unlink(fullpath) self.av_started = True
def onAVChange(self): # pylint: disable=invalid-name
"""Called when Kodi has a video, audio or subtitle stream. Also happens when the stream changes."""
if not self.listen:
return
_LOGGER.debug('KodiPlayer onAVChange')
def onPlayBackSeek(self, time, seekOffset): # pylint: disable=invalid-name, redefined-outer-name
"""Called when user seeks to a time"""
if not self.listen:
return
_LOGGER.debug('KodiPlayer onPlayBackSeek time=%s offset=%s', time, seekOffset)
def onPlayBackPaused(self): # pylint: disable=invalid-name
"""Called when user pauses a playing file"""
if not self.listen:
return
_LOGGER.debug('KodiPlayer onPlayBackPaused')
def onPlayBackResumed(self): # pylint: disable=invalid-name
"""Called when user resumes a paused file or a next playlist item is started"""
if not self.listen:
return
_LOGGER.debug('KodiPlayer onPlayBackResumed')
def onPlayBackError(self): # pylint: disable=invalid-name
"""Called when playback stops due to an error"""
if not self.listen:
return
_LOGGER.debug('KodiPlayer onPlayBackError')
def onPlayBackStopped(self): # pylint: disable=invalid-name
"""Called when user stops Kodi playing a file"""
if not self.listen:
return
_LOGGER.debug('KodiPlayer onPlayBackStopped')
if not self.av_started:
# Check stream path
import requests
response = requests.get(self.stream_path, timeout=5)
if response.status_code == 403:
message_id = 30720
else:
message_id = 30719
kodiutils.ok_dialog(message=kodiutils.localize(message_id))
def onPlayBackEnded(self): # pylint: disable=invalid-name
"""Called when Kodi has ended playing a file"""
if not self.listen:
return
_LOGGER.debug('KodiPlayer onPlayBackEnded')
def run(): def run():
""" Run the BackgroundService """ """ Run the BackgroundService """
kodilogging.config()
BackgroundService().run() BackgroundService().run()

View File

@ -1,46 +1,73 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" SBS API """ """ GoPlay 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([
('vier', dict( ('Play4', {
name='VIER', 'name': 'Play4',
url='https://www.vier.be', 'url': 'live-kijken/play-4',
logo='vier.png', 'epg_id': 'vier',
background='vier-background.jpg', 'logo': 'play4.png',
studio_icon='vier', 'background': 'play4-background.png',
youtube=[ 'iptv_preset': 4,
dict( 'iptv_id': 'play4.be',
label='VIER / VIJF', 'youtube': [
logo='vier.png', {'label': 'GoPlay', 'logo': 'goplay.png', 'path': 'plugin://plugin.video.youtube/user/viertv/'},
path='plugin://plugin.video.youtube/user/viertv/', ]
), }),
], ('Play5', {
)), 'name': 'Play5',
('vijf', dict( 'url': 'live-kijken/play-5',
name='VIJF', 'epg_id': 'vijf',
url='https://www.vijf.be', 'logo': 'play5.png',
logo='vijf.png', 'background': 'play5-background.png',
background='vijf-background.jpg', 'iptv_preset': 5,
studio_icon='vijf', 'iptv_id': 'play5.be',
youtube=[ 'youtube': [
dict( {'label': 'GoPlay', 'logo': 'goplay.png', 'path': 'plugin://plugin.video.youtube/user/viertv/'},
label='VIER / VIJF', ]
logo='vijf.png', }),
path='plugin://plugin.video.youtube/user/viertv/', ('Play6', {
), 'name': 'Play6',
], 'url': 'live-kijken/play-6',
)), 'epg_id': 'zes',
('zes', dict( 'logo': 'play6.png',
name='ZES', 'background': 'play6-background.png',
url='https://www.zestv.be', 'iptv_preset': 6,
logo='zes.png', 'iptv_id': 'play6.be',
background='zes-background.jpg', 'youtube': [
studio_icon='zes', {'label': 'GoPlay', 'logo': 'goplay.png', 'path': 'plugin://plugin.video.youtube/user/viertv/'},
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 = {
@ -48,3 +75,22 @@ 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,16 +9,19 @@ import os
import time import time
from resources.lib import kodiutils from resources.lib import kodiutils
from resources.lib.viervijfzes.auth_awsidp import AwsIdp, InvalidLoginException, AuthenticationException from resources.lib.viervijfzes.aws.cognito_identity import CognitoIdentity
from resources.lib.viervijfzes.aws.cognito_idp import AuthenticationException, CognitoIdp, InvalidLoginException
from resources.lib.viervijfzes.aws.cognito_sync import CognitoSync
_LOGGER = logging.getLogger('auth-api') _LOGGER = logging.getLogger(__name__)
class AuthApi: class AuthApi:
""" VIER/VIJF/ZES Authentication API """ """ GoPlay 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'
@ -33,13 +36,13 @@ class AuthApi:
# Load tokens from cache # Load tokens from cache
try: try:
with open(self._token_path + self.TOKEN_FILE, 'r') as fdesc: with open(os.path.join(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.info('We could not use the cache since it is invalid or non-existent.') _LOGGER.warning('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 """
@ -74,30 +77,55 @@ 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.mkdir(self._token_path) os.makedirs(self._token_path)
with open(self._token_path + self.TOKEN_FILE, 'w') as fdesc: with open(os.path.join(self._token_path, self.TOKEN_FILE), 'w') as fdesc:
data = json.dumps(dict( data = json.dumps({
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(self._token_path + AuthApi.TOKEN_FILE): if os.path.exists(os.path.join(self._token_path, AuthApi.TOKEN_FILE)):
os.unlink(self._token_path + AuthApi.TOKEN_FILE) os.unlink(os.path.join(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. """
client = AwsIdp(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_CLIENT_ID) idp_client = CognitoIdp(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_CLIENT_ID)
return client.authenticate(username, password) return idp_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. """
client = AwsIdp(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_CLIENT_ID) idp_client = CognitoIdp(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_CLIENT_ID)
return client.renew_token(refresh_token) return idp_client.renew_token(refresh_token)
def get_dataset(self, dataset, key):
""" Fetch the value from the specified dataset. """
identity_client = CognitoIdentity(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_IDENTITY_POOL_ID)
id_token = self.get_token()
identity_id = identity_client.get_id(id_token)
credentials = identity_client.get_credentials_for_identity(id_token, identity_id)
sync_client = CognitoSync(AuthApi.COGNITO_IDENTITY_POOL_ID, identity_id, credentials)
data, session_token, sync_count = sync_client.list_records(dataset, key)
sync_info = {
'identity_id': identity_id,
'credentials': credentials,
'session_token': session_token,
'sync_count': sync_count,
}
return data, sync_info
@staticmethod
def put_dataset(dataset, key, value, sync_info):
""" Store the value from the specified dataset. """
sync_client = CognitoSync(AuthApi.COGNITO_IDENTITY_POOL_ID, sync_info.get('identity_id'), sync_info.get('credentials'))
sync_client.update_records(dataset, key, value, sync_info.get('session_token'), sync_info.get('sync_count'))

View File

@ -0,0 +1,69 @@
# -*- 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

@ -12,12 +12,11 @@ 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('auth-awsidp') _LOGGER = logging.getLogger(__name__)
class InvalidLoginException(Exception): class InvalidLoginException(Exception):
@ -28,11 +27,14 @@ class AuthenticationException(Exception):
""" Something went wrong while logging in """ """ Something went wrong while logging in """
class AwsIdp: class CognitoIdp:
""" AWS Identity Provider """ """ Cognito IDP """
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)
@ -75,7 +77,6 @@ class AwsIdp:
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. """
@ -292,7 +293,7 @@ class AwsIdp:
@staticmethod @staticmethod
def __hex_hash(hex_string): def __hex_hash(hex_string):
return AwsIdp.__hash_sha256(bytearray.fromhex(hex_string)) return CognitoIdp.__hash_sha256(bytearray.fromhex(hex_string))
@staticmethod @staticmethod
def __hash_sha256(buf): def __hash_sha256(buf):
@ -312,7 +313,7 @@ class AwsIdp:
# noinspection PyTypeChecker # noinspection PyTypeChecker
if not isinstance(long_int, six.string_types): if not isinstance(long_int, six.string_types):
hash_str = AwsIdp.__long_to_hex(long_int) hash_str = CognitoIdp.__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:
@ -324,7 +325,7 @@ class AwsIdp:
@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 AwsIdp.__hex_to_long(random_hex) return CognitoIdp.__hex_to_long(random_hex)
@staticmethod @staticmethod
def __get_current_timestamp(): def __get_current_timestamp():
@ -340,11 +341,7 @@ class AwsIdp:
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()
if sys.platform.startswith('win'): format_string = "{} {} {} %H:%M:%S UTC %Y".format(days[time_now.weekday()], months[time_now.month], time_now.day)
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

@ -0,0 +1,194 @@
# -*- 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,6 +3,7 @@
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
@ -10,17 +11,27 @@ 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.viervijfzes import CHANNELS from resources.lib import kodiutils
from resources.lib.kodiutils import STREAM_DASH, STREAM_HLS, html_to_kodi
from resources.lib.viervijfzes import ResolvedStream
_LOGGER = logging.getLogger('content-api') try: # Python 3
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. """
@ -37,7 +48,9 @@ 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, cover=None, background=None, seasons=None, episodes=None): def __init__(self, uuid=None, path=None, channel=None, title=None, description=None, aired=None, poster=None, thumb=None, fanart=None, seasons=None,
episodes=None,
clips=None, my_list=False):
""" """
:type uuid: str :type uuid: str
:type path: str :type path: str
@ -45,10 +58,13 @@ class Program:
:type title: str :type title: str
:type description: str :type description: str
:type aired: datetime :type aired: datetime
:type cover: str :type poster: str
:type background: str :type thumb: 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
@ -56,10 +72,13 @@ class Program:
self.title = title self.title = title
self.description = description self.description = description
self.aired = aired self.aired = aired
self.cover = cover self.poster = poster
self.background = background self.thumb = thumb
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__
@ -68,14 +87,13 @@ 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, cover=None, number=None): def __init__(self, uuid=None, path=None, channel=None, title=None, description=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
""" """
@ -84,7 +102,6 @@ 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):
@ -94,8 +111,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, cover=None, duration=None, def __init__(self, uuid=None, nodeid=None, path=None, channel=None, program_title=None, title=None, description=None, thumb=None, duration=None,
season=None, season_uuid=None, number=None, rating=None, aired=None, expiry=None): season=None, season_uuid=None, number=None, rating=None, aired=None, expiry=None, stream=None, content_type=None):
""" """
:type uuid: str :type uuid: str
:type nodeid: str :type nodeid: str
@ -104,7 +121,7 @@ class Episode:
:type program_title: str :type program_title: str
:type title: str :type title: str
:type description: str :type description: str
:type cover: str :type thumb: str
:type duration: int :type duration: int
:type season: int :type season: int
:type season_uuid: str :type season_uuid: str
@ -112,6 +129,8 @@ 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
@ -120,7 +139,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.cover = cover self.thumb = thumb
self.duration = duration self.duration = duration
self.season = season self.season = season
self.season_uuid = season_uuid self.season_uuid = season_uuid
@ -128,19 +147,38 @@ 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:
""" VIER/VIJF/ZES Content API""" """ GoPlay Content API"""
API_ENDPOINT = 'https://api.viervijfzes.be' SITE_URL = 'https://www.goplay.be'
SITE_APIS = { API_GOPLAY = 'https://api.goplay.be'
'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 """
@ -148,124 +186,482 @@ class ContentApi:
self._auth = auth self._auth = auth
self._cache_path = cache_path self._cache_path = cache_path
def get_programs(self, channel, cache=CACHE_AUTO): def get_programs(self, channel=None, 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(CHANNELS[channel]['url']) raw_html = self._get_url(self.SITE_URL + '/programmas')
# Parse programs # Parse programs
parser = HTMLParser() regex_programs = re.compile(r'data-program="(?P<json>[^"]+)"', re.DOTALL)
regex_programs = re.compile(r'<a class="program-overview__link" href="(?P<path>[^"]+)">\s+'
r'<span class="program-overview__title">\s+(?P<title>[^<]+)</span>.*?' data = [
r'</a>', re.DOTALL) json.loads(unescape(item.group('json')))
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 for %s' % channel) raise Exception('No programs found')
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', channel], cache_mode=cache, update=update, ttl=30 * 5) data = self._handle_cache(key=['programs'], cache_mode=cache, update=update, ttl=30 * 60) # 30 minutes
if not data: if not data:
return [] return []
programs = [] if channel:
for path in data: programs = [
title = data[path] self._parse_program_data(record) for record in data if record['pageInfo']['brand'] == channel
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:
# Use program with the values that we've parsed from the page programs = [
programs.append(Program(channel=channel, self._parse_program_data(record) for record in data
path=path, ]
title=title))
return programs return programs
def get_program(self, channel, path, cache=CACHE_AUTO): def get_program(self, path, extract_clips=False, 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
""" """
if channel not in CHANNELS: # We want to use the html to extract clips
raise Exception('Unknown channel %s' % channel) # This is the worst hack, since Python 2.7 doesn't support nonlocal
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(CHANNELS[channel]['url'] + '/' + path) page = self._get_url(self.SITE_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 = HTMLParser().unescape(regex_program.search(page).group(1)) json_data = 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', channel, path], cache_mode=cache, update=update) data = self._handle_cache(key=['program', path], cache_mode=cache, update=update)
if not data:
return None
program = self._parse_program_data(data)
# Also extract clips if we did a real HTTP call
if extract_clips and raw_html[0]:
clips = self._extract_videos(raw_html[0])
program.clips = clips
return program
def get_program_by_uuid(self, uuid, cache=CACHE_AUTO):
""" Get a Program object with the specified uuid.
:type uuid: str
:type cache: str
:rtype Program
"""
if not uuid:
return None
def update():
""" Fetch the program metadata """
# Fetch webpage
result = self._get_url(self.SITE_URL + '/api/program/%s' % uuid)
data = json.loads(result)
return data
# Fetch listing from cache or update if needed
data = self._handle_cache(key=['program', uuid], cache_mode=cache, update=update)
if not data:
return None
program = self._parse_program_data(data) program = self._parse_program_data(data)
return program return program
def get_episode(self, channel, path): def get_episode(self, path, cache=CACHE_AUTO):
""" 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(CHANNELS[channel]['url'] + '/' + path) page = self._get_url(self.SITE_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)
json_data = parser.unescape(regex_program.search(page).group(1)) result = regex_program.search(page)
data = json.loads(json_data)['data'] if result:
program = self._parse_program_data(data) program_json_data = unescape(result.group(1))
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)
json_data = parser.unescape(regex_episode.search(page).group(1)) result = regex_episode.search(page)
data = json.loads(json_data) if result:
episode_json_data = unescape(result.group(1))
episode_json = json.loads(episode_json_data)
# Lookup the episode in the program JSON based on the nodeId return {'program': program_json, 'episode': episode_json}
# 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['pageInfo']['nodeId']: if episode.nodeid == data['episode']['pageInfo']['nodeId']:
return episode return episode
return None return None
def get_stream_by_uuid(self, uuid): def get_stream_by_uuid(self, uuid, content_type):
""" Get the stream URL to use for this video. """ Return a ResolvedStream for this video.
:type uuid: str :type uuid: string
:rtype str :type content_type: string
:rtype: ResolvedStream
""" """
response = self._get_url(self.API_ENDPOINT + '/content/%s' % uuid, authentication=True) if content_type in ('video-long_form', 'long_form'):
mode = 'videos/long-form'
elif content_type == 'video-short_form':
mode = 'videos/short-form'
elif content_type == 'live_channel':
mode = 'liveStreams'
response = self._get_url(self.API_GOPLAY + '/web/v1/%s/%s' % (mode, uuid), authentication='Bearer %s' % self._auth.get_token())
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):
@ -273,51 +669,48 @@ 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['id'], uuid=data.get('id'),
path=data['link'].lstrip('/'), path=data.get('link').lstrip('/'),
channel=data['pageInfo']['site'], channel=data.get('pageInfo').get('brand'),
title=data['title'], title=data.get('title'),
description=data['description'], description=html_to_kodi(data.get('description')),
aired=datetime.fromtimestamp(data.get('pageInfo', {}).get('publishDate')), aired=datetime.fromtimestamp(data.get('pageInfo', {}).get('publishDate', 0.0)),
cover=data['images']['poster'], poster=data.get('images').get('poster'),
background=data['images']['hero'], thumb=data.get('images').get('teaser'),
fanart=data.get('images').get('teaser'),
) )
# Create Season info # Create Season info
program.seasons = { program.seasons = {
key: Season( key: Season(
uuid=playlist['id'], uuid=playlist.get('id'),
path=playlist['link'].lstrip('/'), path=playlist.get('link').lstrip('/'),
channel=playlist['pageInfo']['site'], channel=playlist.get('pageInfo').get('brand'),
title=playlist['title'], title=playlist.get('title'),
description=playlist['pageInfo']['description'], description=html_to_kodi(playlist.get('description')),
number=playlist['episodes'][0]['seasonNumber'], # You did not see this number=playlist.get('episodes')[0].get('seasonNumber'), # You did not see this
) )
for key, playlist in enumerate(data['playlists']) if playlist['episodes'] for key, playlist in enumerate(data.get('playlists', [])) if playlist.get('episodes')
} }
# Create Episodes info # Create Episodes info
program.episodes = [ program.episodes = [
ContentApi._parse_episode_data(episode, playlist['id']) ContentApi._parse_episode_data(episode, playlist.get('id'))
for playlist in data['playlists'] for playlist in data.get('playlists', [])
for episode in playlist['episodes'] for episode in playlist.get('episodes')
] ]
return program return program
@staticmethod @staticmethod
def _parse_episode_data(data, season_uuid): def _parse_episode_data(data, season_uuid=None):
""" 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:
@ -333,33 +726,113 @@ 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'), program_title=data.get('program', {}).get('title') if data.get('program') else data.get('title'),
title=data.get('title'), title=data.get('title'),
description=data.get('pageInfo', {}).get('description'), description=html_to_kodi(data.get('description')),
cover=data.get('image'), thumb=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(data.get('createdDate')), aired=datetime.fromtimestamp(int(data.get('createdDate'))),
expiry=datetime.fromtimestamp(data.get('unpublishDate')) if data.get('unpublishDate') else None, expiry=datetime.fromtimestamp(int(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
def _get_url(self, url, params=None, authentication=False): @staticmethod
def _parse_clip_data(data):
""" Parse the Clip JSON.
:type data: dict
:rtype Episode
"""
episode = Episode(
uuid=data.get('videoUuid'),
program_title=data.get('title'),
title=data.get('title'),
)
return episode
@staticmethod
def create_license_key(key_url, key_type='R', key_headers=None, key_value='', response_value=''):
""" Create a license key string that we need for inputstream.adaptive.
:type key_url: str
:type key_type: str
:type key_headers: dict[str, str]
:type key_value: str
:type response_value: str
:rtype str
"""
try: # Python 3
from urllib.parse import quote, urlencode
except ImportError: # Python 2
from urllib import quote, urlencode
header = ''
if key_headers:
header = urlencode(key_headers)
if key_type in ('A', 'R', 'B'):
key_value = key_type + '{SSM}'
elif key_type == 'D':
if 'D{SSM}' not in key_value:
raise ValueError('Missing D{SSM} placeholder')
key_value = quote(key_value)
return '%s|%s|%s|%s' % (key_url, header, key_value, response_value)
def _get_url(self, url, params=None, authentication=None):
""" 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': self._auth.get_token(), 'authorization': authentication,
}) }, proxies=PROXIES)
else: else:
response = self._session.get(url, params=params) response = self._session.get(url, params=params, proxies=PROXIES)
if response.status_code != 200:
_LOGGER.error(response.text)
raise Exception('Could not fetch data')
return response.text
def _post_url(self, url, params=None, data=None, authentication=None):
""" Makes a POST request for the specified URL.
:type url: str
:type authentication: str
:rtype str
"""
if authentication:
response = self._session.post(url, params=params, json=data, headers={
'authorization': authentication,
}, proxies=PROXIES)
else:
response = self._session.post(url, params=params, json=data, proxies=PROXIES)
if response.status_code not in (200, 201):
_LOGGER.error(response.text)
raise Exception('Could not fetch data')
return response.text
def _delete_url(self, url, params=None, authentication=None):
""" Makes a DELETE request for the specified URL.
:type url: str
:type authentication: str
:rtype str
"""
if authentication:
response = self._session.delete(url, params=params, headers={
'authorization': authentication,
}, proxies=PROXIES)
else:
response = self._session.delete(url, params=params, proxies=PROXIES)
if response.status_code != 200: if response.status_code != 200:
_LOGGER.error(response.text) _LOGGER.error(response.text)
@ -393,8 +866,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' filename = ('.'.join(key) + '.json').replace('/', '_')
fullpath = self._cache_path + filename fullpath = os.path.join(self._cache_path, filename)
if not os.path.exists(fullpath): if not os.path.exists(fullpath):
return None return None
@ -412,11 +885,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' filename = ('.'.join(key) + '.json').replace('/', '_')
fullpath = self._cache_path + filename fullpath = os.path.join(self._cache_path, filename)
if not os.path.exists(self._cache_path): if not os.path.exists(self._cache_path):
os.mkdir(self._cache_path) os.makedirs(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,18 +7,42 @@ import json
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
import dateutil import dateutil.parser
import dateutil.tz
import requests import requests
_LOGGER = logging.getLogger('epg-api') from resources.lib import kodiutils
_LOGGER = logging.getLogger(__name__)
GENRE_MAPPING = {
'Detective': 0x11,
'Dramaserie': 0x15,
'Fantasy': 0x13,
'Human Interest': 0x00,
'Informatief': 0x20,
'Komedie': 0x14,
'Komische serie': 0x14,
'Kookprogramma': '',
'Misdaadserie': 0x15,
'Politieserie': 0x17,
'Reality': 0x31,
'Science Fiction': 0x13,
'Show': 0x30,
'Thriller': 0x11,
'Voetbal': 0x43,
}
PROXIES = kodiutils.get_proxies()
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, won_id, won_program_id, program_description, def __init__(self, channel, program_title, episode_title, episode_title_original, number, season, genre, start,
description, duration, program_url, video_url, cover, airing): won_id, won_program_id, program_description, description, duration, program_url, video_url, thumb,
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
@ -34,22 +58,31 @@ 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.cover = cover self.thumb = thumb
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:
""" VIER/VIJF/ZES EPG API """ """ GoPlay EPG API """
EPG_ENDPOINTS = { EPG_ENDPOINTS = {
'vier': 'https://www.vier.be/api/epg/{date}', 'Play4': 'https://www.goplay.be/api/epg/vier/{date}',
'vijf': 'https://www.vijf.be/api/epg/{date}', 'Play5': 'https://www.goplay.be/api/epg/vijf/{date}',
'zes': 'https://www.zestv.be/api/epg/{date}', 'Play6': 'https://www.goplay.be/api/epg/zes/{date}',
'Play7': 'https://www.goplay.be/api/epg/zeven/{date}',
'PlayCrime': 'https://www.goplay.be/api/epg/crime/{date}',
} }
EPG_NO_BROADCAST = 'Geen uitzending'
def __init__(self): def __init__(self):
""" Initialise object """ """ Initialise object """
self._session = requests.session() self._session = requests.session()
@ -78,7 +111,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] return [self._parse_program(channel, x) for x in data if x.get('program_title') != self.EPG_NO_BROADCAST]
@staticmethod @staticmethod
def _parse_program(channel, data): def _parse_program(channel, data):
@ -90,8 +123,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() timestamp = datetime.now().replace(tzinfo=dateutil.tz.gettz('CET'))
start = datetime.fromtimestamp(data.get('timestamp')) start = datetime.fromtimestamp(data.get('timestamp')).replace(tzinfo=dateutil.tz.gettz('CET'))
if duration: if duration:
airing = bool(start <= timestamp < (start + timedelta(seconds=duration))) airing = bool(start <= timestamp < (start + timedelta(seconds=duration)))
else: else:
@ -100,10 +133,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('/')
cover = data.get('video_node', {}).get('image') thumb = data.get('video_node', {}).get('image')
else: else:
video_url = None video_url = None
cover = None thumb = None
return EpgProgram( return EpgProgram(
channel=channel, channel=channel,
@ -111,7 +144,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=int(data.get('season')) if data.get('season') else None, season=data.get('season'),
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,
@ -121,7 +154,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,
cover=cover, thumb=thumb,
airing=airing, airing=airing,
) )
@ -132,14 +165,14 @@ class EpgApi:
:rtype: EpgProgram :rtype: EpgProgram
""" """
# Parse to a real datetime # Parse to a real datetime
timestamp = dateutil.parser.parse(timestamp) timestamp = dateutil.parser.parse(timestamp).replace(tzinfo=dateutil.tz.gettz('CET'))
# 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 timestamp <= broadcast.start < (broadcast.start + timedelta(seconds=broadcast.duration)): if broadcast.start <= timestamp < (broadcast.start + timedelta(seconds=broadcast.duration)):
return broadcast return broadcast
return None return None
@ -149,7 +182,7 @@ class EpgApi:
:type url: str :type url: str
:rtype str :rtype str
""" """
response = self._session.get(url) response = self._session.get(url, proxies=PROXIES)
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 -*-
""" AUTH API """ """ Search API """
from __future__ import absolute_import, division, unicode_literals from __future__ import absolute_import, division, unicode_literals
@ -8,17 +8,21 @@ import logging
import requests import requests
from resources.lib.viervijfzes.content import Program from resources.lib import kodiutils
from resources.lib.viervijfzes.content import CACHE_ONLY, ContentApi, Program
_LOGGER = logging.getLogger('search-api') _LOGGER = logging.getLogger(__name__)
PROXIES = kodiutils.get_proxies()
class SearchApi: class SearchApi:
""" VIER/VIJF/ZES Search API """ """ GoPlay Search API """
API_ENDPOINT = 'https://api.viervijfzes.be/search' API_ENDPOINT = 'https://api.goplay.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):
@ -33,26 +37,28 @@ class SearchApi:
self.API_ENDPOINT, self.API_ENDPOINT,
json={ json={
"query": query, "query": query,
"sites": ["vier", "vijf", "zes"],
"page": 0, "page": 0,
"mode": "byDate" "mode": "programs"
} },
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(
channel=hit['_source']['site'], path=path,
path=hit['_source']['url'].strip('/'),
title=hit['_source']['title'], title=hit['_source']['title'],
description=hit['_source']['intro'], description=hit['_source']['intro'],
cover=hit['_source']['img'], poster=hit['_source']['img'],
)) ))
return results return results

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
resources/logos/goplay.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
resources/logos/play4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
resources/logos/play5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
resources/logos/play6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
resources/logos/play7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

View File

@ -1,14 +1,26 @@
<?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="30803" type="text" id="username"/> <setting label="30802" type="text" id="username"/>
<setting label="30805" type="text" id="password" option="hidden"/> <setting label="30803" type="text" id="password" option="hidden"/>
</category> </category>
<category label="30820"> <!-- Interface --> <category label="30820"> <!-- Interface -->
<setting label="30827" type="lsep"/> <!-- Metadata --> <setting label="30820" type="lsep"/> <!-- Interface -->
<setting label="30829" type="bool" id="metadata_update" default="true" subsetting="true"/> <setting label="30821" type="bool" id="interface_show_unavailable" default="true"/>
<setting label="30831" type="action" action="RunPlugin(plugin://plugin.video.viervijfzes/metadata/update)"/> </category>
<category label="30840"> <!-- Integrations -->
<setting label="30841" type="lsep"/> <!-- IPTV Manager -->
<setting label="30842" type="action" action="InstallAddon(service.iptv.manager)" option="close" visible="!System.HasAddon(service.iptv.manager)"/> <!-- Install IPTV Manager add-on -->
<setting label="30843" type="bool" id="iptv.enabled" default="true" visible="String.StartsWith(System.BuildVersion,18) + System.HasAddon(service.iptv.manager) | System.AddonIsEnabled(service.iptv.manager)" />
<setting label="30844" type="action" action="Addon.OpenSettings(service.iptv.manager)" enable="eq(-1,true)" option="close" visible="String.StartsWith(System.BuildVersion,18) + System.HasAddon(service.iptv.manager) | System.AddonIsEnabled(service.iptv.manager)" subsetting="true"/> <!-- IPTV Manager settings -->
<setting id="iptv.channels_uri" default="plugin://plugin.video.viervijfzes/iptv/channels" visible="false"/>
<setting id="iptv.epg_uri" default="plugin://plugin.video.viervijfzes/iptv/epg" visible="false"/>
</category>
<category label="30880"> <!-- Expert -->
<setting label="30881" type="lsep"/> <!-- Logging -->
<setting label="30882" type="bool" id="debug_logging" default="false"/>
<setting label="30883" type="action" action="InstallAddon(script.kodi.loguploader)" option="close" visible="!System.HasAddon(script.kodi.loguploader)"/> <!-- Install Kodi Logfile Uploader -->
<setting label="30884" type="action" action="RunAddon(script.kodi.loguploader)" visible="String.StartsWith(System.BuildVersion,18) + System.HasAddon(script.kodi.loguploader) | System.AddonIsEnabled(script.kodi.loguploader)" /> <!-- Open Kodi Logfile Uploader -->
</category> </category>
</settings> </settings>

87
scripts/build.py Executable file
View File

@ -0,0 +1,87 @@
#!/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

@ -0,0 +1,30 @@
#!/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)

225
scripts/publish.py Executable file
View File

@ -0,0 +1,225 @@
#!/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)

40
scripts/update_translations.py Executable file
View File

@ -0,0 +1,40 @@
#!/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,5 +4,30 @@
from __future__ import absolute_import, division, unicode_literals from __future__ import absolute_import, division, unicode_literals
import logging import logging
import os
import sys
logging.basicConfig() import xbmcaddon
try: # Python 3
from http.client import HTTPConnection
except ImportError: # Python 2
from httplib import HTTPConnection
logging.basicConfig(level=logging.DEBUG)
# Add logging to urllib
HTTPConnection.debuglevel = 1
# Make UTF-8 the default encoding in Python 2
if sys.version_info[0] == 2:
reload(sys) # pylint: disable=undefined-variable # noqa: F821
sys.setdefaultencoding("utf-8") # pylint: disable=no-member
# Set credentials based on environment data
# Use the .env file with Pipenv to make this work nicely during development
ADDON = xbmcaddon.Addon()
if os.environ.get('ADDON_USERNAME'):
ADDON.setSetting('username', os.environ.get('ADDON_USERNAME'))
if os.environ.get('ADDON_PASSWORD'):
ADDON.setSetting('password', os.environ.get('ADDON_PASSWORD'))

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon id="inputstream.adaptive" version="2.4.4" name="InputStream Adaptive" provider-name="peak3d">
</addon>

View File

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

View File

@ -1,12 +1,11 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com> """ Run any Kodi plugin:// URL on the commandline """
# 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
@ -15,12 +14,6 @@ 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,10 +9,11 @@ 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 from resources.lib.viervijfzes.content import ContentApi, Program, Episode, CACHE_PREVENT, Category
_LOGGER = logging.getLogger('test-api') _LOGGER = logging.getLogger(__name__)
class TestApi(unittest.TestCase): class TestApi(unittest.TestCase):
@ -22,27 +23,60 @@ 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):
for channel in ['vier', 'vijf', 'zes']: programs = self._api.get_programs()
programs = self._api.get_programs(channel) self.assertIsInstance(programs, list)
self.assertIsInstance(programs[0], Program)
def test_popular_programs(self):
for brand in [None, 'vier', 'vijf', 'zes', 'goplay']:
programs = self._api.get_popular_programs(brand)
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 channel, program in [('vier', 'auwch'), ('vijf', 'zo-man-zo-vrouw')]: for program in ['gentwest', 'zo-man-zo-vrouw']:
program = self._api.get_program(channel, program) program = self._api.get_program(program, cache=CACHE_PREVENT)
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('vier', 'auwch') program = self._api.get_program('gentwest')
self.assertIsInstance(program, Program) self.assertIsInstance(program, Program)
episode = program.episodes[0] episode = program.episodes[0]
video = self._api.get_stream_by_uuid(episode.uuid) resolved_stream = self._api.get_stream_by_uuid(episode.uuid, episode.islongform)
self.assertTrue(video) self.assertIsInstance(resolved_stream, ResolvedStream)
@unittest.skipUnless(kodiutils.get_setting('username') and kodiutils.get_setting('password'), 'Skipping since we have no credentials.')
def test_get_drm_stream(self):
resolved_stream = self._api.get_stream_by_uuid('cc77be47-0256-4254-acbf-28a03fcac423', True) # https://www.goplay.be/video/ncis-los-angeles/ncis-los-angeles-s14/ncis-los-angeles-s14-aflevering-1
self.assertIsInstance(resolved_stream, ResolvedStream)
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('test-auth') _LOGGER = logging.getLogger(__name__)
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
token = auth.get_token() id_token = auth.get_token()
self.assertTrue(token) self.assertTrue(id_token)
# Test it a second time, it should go from memory now # Test it a second time, it should go from memory now
token = auth.get_token() id_token = auth.get_token()
self.assertTrue(token) self.assertTrue(id_token)
if __name__ == '__main__': if __name__ == '__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('test-epg') _LOGGER = logging.getLogger(__name__)
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('vier', date.today().strftime('%Y-%m-%d')) programs = self._epg.get_epg('Play4', 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('vijf', date.today().strftime('%Y-%m-%d')) programs = self._epg.get_epg('Play5', 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('zes', date.today().strftime('%Y-%m-%d')) programs = self._epg.get_epg('Play6', 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,18 +41,31 @@ 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('vier', '2020-01-01') programs = self._epg.get_epg('Play4', '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('vier', date.today().strftime('%Y-%m-%d')) epg_programs = self._epg.get_epg('Play4', 'yesterday')
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.channel, epg_program.video_url) episode = api.get_episode(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()

45
tests/test_mylist.py Normal file
View File

@ -0,0 +1,45 @@
# -*- 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,11 +9,6 @@ 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
@ -34,30 +29,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='vier'), '0', '']) routing.run([routing.url_for(addon.show_channel_menu, channel='Play4'), '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_catalog_channel, channel='vier'), '0', '']) routing.run([routing.url_for(addon.show_channel_catalog, channel='Play4'), '0', ''])
def test_catalog_program_menu(self): def test_catalog_program_menu(self):
routing.run([routing.url_for(addon.show_catalog_program, channel='vier', program='de-mol'), '0', '']) routing.run([routing.url_for(addon.show_catalog_program, channel='Play4', 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='vier', program='de-mol', season=-1), '0', '']) routing.run([routing.url_for(addon.show_catalog_program_season, channel='Play4', 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_tvguide_channel, channel='vier'), '0', '']) routing.run([routing.url_for(addon.show_channel_tvguide, channel='Play4'), '0', ''])
routing.run([routing.url_for(addon.show_tvguide_detail, channel='vier', date='today'), '0', '']) routing.run([routing.url_for(addon.show_channel_tvguide_detail, channel='Play4', 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('test-search') _LOGGER = logging.getLogger(__name__)
class TestSearch(unittest.TestCase): class TestSearch(unittest.TestCase):

45
tests/test_service.py Normal file
View File

@ -0,0 +1,45 @@
# -*- 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

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

View File

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

View File

@ -1,6 +0,0 @@
{
"locale.language": "resource.language.nl_nl",
"network.bandwidth": 0,
"network.usehttpproxy": false,
"videolibrary.showallitems": true
}

View File

@ -1,247 +0,0 @@
# -*- 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

View File

@ -1,76 +0,0 @@
# -*- 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

View File

@ -1,188 +0,0 @@
# -*- 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')

View File

@ -1,327 +0,0 @@
# -*- 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

View File

@ -1,112 +0,0 @@
# -*- 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)

View File

@ -1,80 +0,0 @@
# -*- 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
View File

@ -1,27 +0,0 @@
[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